"""Conservative, catalog-bound person resolution shared by both retrieval engines. Only contiguous full names may be corrected automatically. Fuzzy matches must have an exact/inflected anchor, at most one misspelled token and a unique best catalog identity. An unresolved explicit subject never means an empty filter. """ from __future__ import annotations from collections import defaultdict import re import sqlite3 import unicodedata from contextlib import closing def normalize(value: str) -> str: text = unicodedata.normalize('NFKD', value).casefold() return ' '.join(re.findall(r'[^\W_]+', ''.join(c for c in text if not unicodedata.combining(c)))) def name_token_variants(token: str) -> set[str]: variants = {token} if len(token) < 3: return variants irregular = {'peter': ('petra', 'petrovi', 'petrom'), 'pavol': ('pavla', 'pavlovi', 'pavlom'), 'frantisek': ('frantiska', 'frantiskovi', 'frantiskom')} variants.update(irregular.get(token, ())) if token.endswith('ova'): variants.update(token[:-1] + s for s in ('ej', 'u', 'ou')) elif token.endswith('y'): variants.update(token[:-1] + s for s in ('eho', 'emu', 'ym', 'ych')) elif token.endswith('a'): variants.update(token[:-1] + s for s in ('y', 'e', 'u', 'ou', 'ovi', 'om')) elif token.endswith('o'): variants.update(token[:-1] + s for s in ('a', 'ovi', 'om')) else: variants.update(token + s for s in ('a', 'ovi', 'om')) return variants def _distance(a: str, b: str) -> int: """Optimal string alignment distance, including adjacent transpositions.""" rows = [list(range(len(b) + 1))] for i, left in enumerate(a, 1): row = [i] for j, right in enumerate(b, 1): value = min(row[-1] + 1, rows[-1][j] + 1, rows[-1][j-1] + (left != right)) if i > 1 and j > 1 and left == b[j-2] and a[i-2] == right: value = min(value, rows[-2][j-2] + 1) row.append(value) rows.append(row) return rows[-1][-1] def _score(words: list[str], canonical: list[str]) -> int | None: if len(words) != len(canonical): return None scores = [] for actual, expected in zip(words, canonical): if actual == expected: scores.append(0) elif actual in name_token_variants(expected): scores.append(1) else: if not actual.isalpha() or not expected.isalpha() or min(len(actual), len(expected)) < 4: return None cap = 2 if min(len(actual), len(expected)) >= 8 else 1 forms = name_token_variants(expected) distance = min((_distance(actual, form) for form in forms if abs(len(actual)-len(form)) <= cap), default=cap+1) if distance > cap: return None scores.append(10 + distance) # Never assemble a person from two approximate tokens. if sum(s >= 10 for s in scores) > 1 or all(s >= 10 for s in scores): return None return sum(scores) _STOP = set('a aj ale alebo ak ako ake aky aku ktore ktori ktora ktory kto kolko ci co obaja obidvaja ' 'ma maju mal mali je su bol bola bolo boli budu bude sa s so pri pre z zo na v od do roku rokoch ' 'praca prace pracu projekt projekty dokument dokumenty nazov nazvy nazv ' 'tag tagy klucove slova slovo temu temy kategoriu kategorie ' 'uved uvedte vypis vyber najdi zisti porovnaj popis opis čomu komu vymenuj pomenuj ' 'vsetci vsetky vsetkych plan stav vysledky problemy metody experimenty ' 'osoba osoby osobu osobe osobach student studenti studentov autora autor ' 'aktualne teraz vedie viedol pokrocil pokrocili kedy prislusne o com pod'.split()) _STOP = {normalize(word) for word in _STOP} def _generic(word: str) -> bool: return word in _STOP or word.startswith(('diplomov', 'bakalarsk', 'dizertacn', 'timov', 'zaverecn', 'dokument', 'osob', 'student', 'kandidat', 'kategori', 'zameran', 'ciel', 'vysled', 'priebeh', 'pouz', 'experiment', 'problem', 'metod', 'riesen', 'system', 'vyhladav', 'inform', 'studij', 'databaz', 'zdroj', 'prehlad', 'presn', 'postup', 'hodnot', 'technol', 'zadani', 'uloh', 'spaja', 'evidov', 'veden', 'veduc', 'konzultant', 'skolitel', 'ukonc', 'skonc', 'uspes', 'neuspes', 'obhaj', 'studium', 'studia', 'stretnut', 'posledn')) def _identity(row: dict) -> tuple: return (normalize(row['name']), row.get('kind', 'Person'), row.get('source_document') or row.get('element_id') or row.get('id') or row['name']) def resolve_person_names(query: str, catalog: list[dict]) -> dict: """Return an internal canonical query, public matches and fail-closed choices. query itself stays unchanged for HTTP/MCP echo binding. Replacements affect name character spans only: work types, years, tags and negations survive. """ people = list({ _identity(row): row for row in catalog if row.get('name') and len(normalize(row['name']).split()) >= 2 and row.get('kind', 'Person') in {'Person', 'DocumentAuthor'}}.values()) profile_names = {normalize(row['name']) for row in people if row.get('kind','Person')=='Person'} author_lookup = bool(re.search(r'\b(?:autor\w*|dokument\w*|vedie|viedol|veduc\w*|skolitel\w*)\b', normalize(query))) people = [row for row in people if row.get('kind')!='DocumentAuthor' or author_lookup and normalize(row['name']) not in profile_names] tokens = list(re.finditer(r'[^\W_]+', query, re.UNICODE)) words = [normalize(token[0]) for token in tokens] quoted = [(m.start(), m.end()) for m in re.finditer(r'["„“«][^"„“”»]+["“”»]', query)] opaque = [(m.start(),m.end()) for m in re.finditer(r'(?:https?://|(?:pages/)?(?:students|interns)/)[^\s]+', query)] # A tag/title can itself look like a person's name. Those literals are # constraints, not an invitation to correct a student identity. literal = [(m.start(1),m.end(1)) for m in re.finditer( r'\b(?:tag(?:om|u)?|kľúčov\w*\s+slov\w*|klucov\w*\s+slov\w*|názvom|nazvom)\s+' r'([^,;?!\n]+?)(?=\s+(?:a|pri|v\s+roku|z\s+roku|ktor\w*)\b|[,;?!\n]|$)',query,re.I) if normalize(m[1]).split()[0] not in {'ma','maju','mal','mali','je','su','bol','boli','uved','vypis'}] def inside_quote(start, end): return any(left <= start and end <= right for left, right in [*quoted,*opaque,*literal]) spans = defaultdict(list) for row in people: canonical = normalize(row['name']).split() orders = [canonical] if len(canonical) >= 2: orders.append([canonical[-1], *canonical[:-1]]) for i in range(len(tokens)-len(canonical)+1): start, end = tokens[i].start(), tokens[i+len(canonical)-1].end() if inside_quote(start, end): continue gaps = [query[tokens[j].end():tokens[j+1].start()] for j in range(i, i+len(canonical)-1)] if any(not re.fullmatch(r'[\s,]+', gap) for gap in gaps): continue scores = [(score, order != canonical) for order in orders if (score := _score(words[i:i+len(canonical)], order)) is not None] if scores: score, reversed_order = min(scores) spans[(start, end)].append((score, row, reversed_order)) selected = [] for (start, end), options in sorted(spans.items(), key=lambda item: (min(v[0] for v in item[1]), -(item[0][1]-item[0][0]), item[0][0])): if any(start < right and left < end for left, right, _ in selected): continue best = min(value[0] for value in options) # A complete literal name wins. Otherwise every bounded plausible # identity is a choice: the number of inflected tokens is not evidence # that one real person is more likely than another. choices = [v for v in options if v[0] == 0] if best == 0 else options explicit_profiles = [v for v in choices if v[1].get('source_document') and v[1]['source_document'] in query] if len(explicit_profiles) == 1: choices = explicit_profiles # A document author and student may share a name without being two # student identities. Keep roles separate in graph constraints later. by_name = defaultdict(list) for value in choices: by_name[normalize(value[1]['name'])].append(value) duplicate = any(len({_identity(v[1]) for v in group if v[1].get('kind','Person')=='Person'}) > 1 for group in by_name.values()) if len(by_name) > 1 or duplicate: return _blocked(query, 'ambiguous', 'ambiguous_person', query[start:end], [v[1] for v in choices]) choices.sort(key=lambda v:v[1].get('kind','Person') != 'Person') selected.append((start, end, choices[0])) def covered(start, end): return any(left <= start and end <= right for left, right, _ in selected) first_names = {variant for row in people for variant in name_token_variants(normalize(row['name']).split()[0])} title_starts = {variant for row in catalog if row.get('kind') not in {'Person','DocumentAuthor',None} for key in ('name','title','heading') if row.get(key) and normalize(row[key]) for variant in name_token_variants(normalize(row[key]).split()[0])} first_subject = min((left for left, _, _ in selected), default=len(query)) list_prefix = normalize(re.split(r'[.!?;\n]', query[:first_subject])[-1]) explicit_list = bool(re.search( r'\b(?:kandidat(?:ov|i)|osob(?:y|ach)?|student(?:i|ov))\s*$', list_prefix)) # Explicit subjects: full capitalized names; known given names followed by # an unknown surname; names after a role, possessive work noun or predicate. for i, token in enumerate(tokens): if (not any(c.isalpha() for c in words[i]) or _generic(words[i]) or inside_quote(token.start(), token.end()) or covered(token.start(), token.end())): continue # A predicate in an earlier sentence cannot introduce this token: # "uveď práce. Pridaj zdroje" must not create a person named Pridaj. prefix = normalize(re.split(r'[.!?;\n]', query[:token.start()])[-1]) role = bool(re.search(r'\b(?:osob\w*|student\w*|kandidat\w*|autor(?:a|om)?|ma|mal|robil|riesil|venuje|prace|pracu|projektu)\s*$', prefix)) # "Pri každej osobe príslušné práce" refers back to the subjects; # it does not introduce a person named "príslušné". Do not use this # quantified role as evidence of a new name. Independent full-name # and known-given-name checks below still detect additional subjects. reference = bool(re.search( r'\b(?:kazd\w*|jednotliv\w*|vsetk\w*|uveden\w*|vybran\w*|dan\w*|' r'obe|oboch|obom|oboma|obidv\w*|tieto|tychto|tymto)\s+' r'(?:osob\w*|student\w*|kandidat\w*|autor\w*)\s*$', prefix)) role = role and not reference following = i+1 < len(tokens) and re.fullmatch(r'\s+', query[token.end():tokens[i+1].start()]) pair = bool(following and not _generic(words[i+1])) upper_pair = bool(pair and token[0][0].isupper() and tokens[i+1][0][0].isupper() and not token[0].isupper() and not tokens[i+1][0].isupper()) comparison_subject = bool(selected and pair and re.search(r'\b(?:a|porovnaj)\s*$', prefix)) # Lower-case unknown entries in an explicit candidate list must still # fail closed, even if they follow a comma or a known person. list_subject = explicit_list and any( right <= token.start() and re.fullmatch(r'\s*(?:,|a)\s*', query[right:token.start()]) for _, right, _ in selected) if not (role or (pair and words[i] in first_names) or upper_pair or comparison_subject or list_subject): continue end = tokens[i+1].end() if pair else token.end() if covered(token.start(), end): continue mention = query[token.start():end] if not pair and words[i] in title_starts: continue if not pair: # Partial names are never fuzzy-expanded. Offer actual catalog choices. options = [row for row in people if any(words[i] in name_token_variants(part) for part in normalize(row['name']).split())] if options: return _blocked(query, 'ambiguous', 'incomplete_person_name', mention, options) return _blocked(query, 'no_answer', 'unknown_person', mention, []) resolved = query matches = [] for start, end, (score, row, reverse) in sorted(selected, reverse=True): resolved = resolved[:start] + row['name'] + resolved[end:] matches.append({'input': query[start:end], 'name': row['name'], 'method': 'typo' if score >= 10 else 'reordered' if reverse else 'variant', 'source_document': row.get('source_document')}) matches.reverse() notices = [f'Rozpoznané meno: **{match["name"]}** (v otázke „{match["input"]}“).' for match in matches if match['method'] in {'typo', 'reordered'}] return {'status': 'ok', 'reason': None, 'resolved_query': resolved, 'matches': matches, 'notices': notices} def _blocked(query, status, reason, mention, rows): candidates = list({(row['name'],row.get('source_document')): {'name':row['name'],'source_document':row.get('source_document')} for row in rows}.values()) if status == 'ambiguous': choices = '; '.join(row['name'] + (f' [{row["source_document"]}]' if row.get('source_document') else '') for row in candidates) message = f'Meno „{mention}“ nie je jednoznačné. Ktorú osobu máš na mysli? Možnosti: {choices}. Uveď celé meno' message += ' a pri rovnakých menách aj cestu profilu.' if len({r['name'] for r in candidates}) < len(candidates) else '.' else: message = f'Meno „{mention}“ sa nepodarilo spoľahlivo priradiť k osobe v dostupných podkladoch. Uveď presné celé meno.' return {'status':status,'reason':reason,'resolved_query':query,'matches':[], 'notices':[], 'mention':mention,'candidates':candidates,'message':message} def sqlite_people(db_file, *, published_only=True) -> list[dict] | None: """Read profile identities, never infer people from retrieved prose.""" with closing(sqlite3.connect(db_file.resolve().as_uri()+'?mode=ro', uri=True)) as conn: conn.row_factory = sqlite3.Row columns = {row[1] for row in conn.execute('PRAGMA table_info(documents)')} if not {'path','title'} <= columns: # Legacy isolated chunk fixtures have no identity catalog at all. # A real documents table with zero profiles remains an empty catalog. return None where = ' WHERE COALESCE(published,1)=1' if published_only and 'published' in columns else '' fields = 'path,title,author' if 'author' in columns else 'path,title' result = [] for row in conn.execute('SELECT '+fields+' FROM documents'+where): profile = re.fullmatch(r'(?:pages/)?(?:students/\d{4}/[^/]+|interns/[^/]+)/README\.md', row['path']) if row['title']: result.append({'kind':'Person' if profile else 'Document','name':row['title'], 'source_document':row['path']}) if 'author' in columns and row['author']: result.append({'kind':'DocumentAuthor','name':str(row['author'])}) return result