Oprava rozpoznávania mien

This commit is contained in:
Ján Pták 2026-11-05 01:48:10 +01:00
parent 4eaf74c529
commit af61a8a273
7 changed files with 101 additions and 87 deletions

View File

@ -227,6 +227,24 @@ def test_shared_year_has_only_intersection_and_bound_work_details(shared_session
assert '2019' not in rendered['answer'] and 'Ukážkový názov' in rendered['answer'] assert '2019' not in rendered['answer'] and 'Ukážkový názov' in rendered['answer']
def test_shared_year_with_per_person_presentation_instructions(shared_session):
question=('V ktorých rokoch majú Ján Holp a Ondrej Megela obaja evidovanú prácu alebo projekt? '
'Najprv uveď spoločné roky, potom pri každej osobe príslušné práce s typom a názvom, '
'ak je uvedený. Pridaj zdroje.')
result=gs.search_graph_with_session(shared_session,question)
assert result['status']=='ok'
assert result['academic_year_comparison']['shared_years']==[2020]
context=graph_context(result)
records=context['required_academic_records']
assert len(records)==3 and {r['person'] for r in records}=={'Ján Holp','Ondrej Megela'}
answer={'status':'ok','claims':records,
'source_urls':[s['source_url'] for s in context['sources']]}
rendered=render_graph_answer(answer,context)
assert answer_context_issues(rendered,context)==[]
assert 'Spoločný evidovaný rok je **2020**.' in rendered['answer']
assert '2019' not in rendered['answer']
def test_no_common_year_is_not_a_union(shared_session): def test_no_common_year_is_not_a_union(shared_session):
for row in shared_session.responses[gs.WORK_FACTS_QUERY]: for row in shared_session.responses[gs.WORK_FACTS_QUERY]:
if row['person_element_id']=='p1': row['year']=2021 if row['person_element_id']=='p1': row['year']=2021

View File

@ -56,6 +56,30 @@ def test_plural_keyword_question_does_not_hide_a_misspelled_person(people):
assert r['resolved_query']==q.replace('Ptká','Pták') assert r['resolved_query']==q.replace('Ptká','Pták')
@pytest.mark.parametrize('instruction', [
'potom pri každej osobe príslušné práce s typom a názvom, ak je uvedený',
'potom pri každej osobe relevantné práce',
'potom pri každom študentovi jednotlivé práce',
'potom pre obe osoby príslušné práce',
'potom pri vybraných osobách stručné zhrnutie',
])
def test_presentation_instructions_are_not_additional_people(people, instruction):
q=('V ktorých rokoch majú Ján Holp a Ondrej Megela obaja evidovanú prácu alebo projekt? '
'Najprv uveď spoločné roky, '+instruction+'. Pridaj zdroje.')
r=resolve_person_names(q,people)
assert r['status']=='ok'
assert [m['name'] for m in r['matches']]==['Ján Holp','Ondrej Megela']
assert r['resolved_query']==q and r['notices']==[]
@pytest.mark.parametrize('mention',['Xaver Neexistujúci','xaver neexistujuci'])
def test_generic_person_reference_does_not_hide_another_unknown_subject(people, mention):
q=f'Porovnaj Ján Holp a {mention}. Pri každej osobe uveď príslušné práce.'
r=resolve_person_names(q,people)
assert r['status']=='no_answer' and r['reason']=='unknown_person'
assert r['mention']==mention
@pytest.mark.parametrize('q',[ @pytest.mark.parametrize('q',[
'Aké práce má Xaver Neexistujúci?', 'Aké práce má xaver neexistujuci?', 'Aké práce má Xaver Neexistujúci?', 'Aké práce má xaver neexistujuci?',
'Aký je názov práce xaver neexistujuci?', 'Aký je názov práce xaver neexistujuci?',
@ -63,6 +87,8 @@ def test_plural_keyword_question_does_not_hide_a_misspelled_person(people):
'Porovnaj xaver neexistujuci a jan holp.', 'Porovnaj xaver neexistujuci a jan holp.',
'Porovnaj jan holp a xaver neexistujuci.', 'Porovnaj jan holp a xaver neexistujuci.',
'Aké práce má Ján Hxxxx?', 'Aké práce má Xaver?', 'Aké práce má Ján Hxxxx?', 'Aké práce má Xaver?',
'Z kandidátov Ján Holp, neznama osoba, Ondrej Megela vyber osoby s prácou v roku 2024.',
'Z kandidátov Ján Holp a neznama osoba vyber osoby s prácou v roku 2024.',
]) ])
def test_unknown_name_cannot_turn_into_global_query(people,q): def test_unknown_name_cannot_turn_into_global_query(people,q):
r=resolve_person_names(q,people) r=resolve_person_names(q,people)

View File

@ -142,19 +142,34 @@ def test_candidates_are_actual_bound_literal_evidence(corpus):
def test_unknown_second_subject_is_not_silently_ignored(corpus): def test_unknown_second_subject_is_not_silently_ignored(corpus):
p=prepare(corpus,'Aké problémy riešili študenti Eva Nová a Boris Noyy?') p=prepare(corpus,'Aké problémy riešili študenti Eva Nová a Boris Nxxxxx?')
assert p['status']=='ambiguous' and p['reason']=='unresolved_subject' assert p['status']=='ambiguous' and p['reason']=='unresolved_subject'
@pytest.mark.parametrize('question',[ @pytest.mark.parametrize('question',[
'Aké problémy riešili Eva Nová a Xaver Neexistujúci?', 'Aké problémy riešili Eva Nová a Xaver Neexistujúci?',
'Aké problémy riešil Boris Noyy?', 'Aké problémy riešil Boris Nxxxxx?',
]) ])
def test_unresolved_subject_cannot_be_replaced_by_available_profiles(corpus,question): def test_unresolved_subject_cannot_be_replaced_by_available_profiles(corpus,question):
p=prepare(corpus,question) p=prepare(corpus,question)
assert p['status']=='ambiguous' and p['reason']=='unresolved_subject' assert p['status']=='ambiguous' and p['reason']=='unresolved_subject'
def test_content_preparation_uses_shared_typo_resolution_and_keeps_notice(corpus):
p=prepare(corpus,'Aké problémy riešili študenti Eva Nová a Boris Noyy?')
assert p['status']=='ok' and p['named_subjects']==['Eva Nová','Boris Nový']
assert p['resolved_query']=='Aké problémy riešili študenti Eva Nová a Boris Nový?'
assert any('Boris Nový' in n for n in p['name_resolution']['notices'])
def test_content_preparation_does_not_treat_per_person_instructions_as_names(corpus):
q=('Porovnaj výsledky Evy Novej a Borisa Nového. '
'Pri každej osobe príslušné výsledky uveď samostatne. Pridaj zdroje.')
p=prepare(corpus,q)
assert p['status']=='ok' and p['named_subjects']==['Eva Nová','Boris Nový']
assert p['name_resolution']['status']=='ok'
def test_partial_answer_names_the_missing_aspect(corpus): def test_partial_answer_names_the_missing_aspect(corpus):
db,raw=corpus db,raw=corpus
with sqlite3.connect(db) as conn: with sqlite3.connect(db) as conn:

View File

@ -33,8 +33,15 @@ def prepare_named_content(db_file, question: str, payload: dict) -> dict:
return payload return payload
catalog=[dict(r) for r in conn.execute('SELECT path,title,author,published FROM documents WHERE COALESCE(published,1)=1') catalog=[dict(r) for r in conn.execute('SELECT path,title,author,published FROM documents WHERE COALESCE(published,1)=1')
if PROFILE.fullmatch(r['path']) and r['title']] if PROFILE.fullmatch(r['path']) and r['title']]
from tools.retrieval.person_names import resolve_person_names
resolution=payload.get('name_resolution') or resolve_person_names(question,[
{'kind':'Person','name':r['title'],'source_document':r['path']} for r in catalog])
if resolution['status'] != 'ok':
return clarify(payload,'unresolved_subject',resolution['message'])
question=resolution['resolved_query']
payload={**payload,'name_resolution':resolution,'resolved_query':question}
identities = {normalized(m['name']): m['source_document'] identities = {normalized(m['name']): m['source_document']
for m in (payload.get('name_resolution') or {}).get('matches',[]) for m in resolution.get('matches',[])
if m.get('source_document')} if m.get('source_document')}
if identities: if identities:
catalog = [r for r in catalog if identities.get(normalized(r['title'])) == r['path']] catalog = [r for r in catalog if identities.get(normalized(r['title'])) == r['path']]
@ -43,21 +50,6 @@ def prepare_named_content(db_file, question: str, payload: dict) -> dict:
named.sort(key=lambda r:re.search(person_pattern(r['title']),normalized(question)).start()) named.sort(key=lambda r:re.search(person_pattern(r['title']),normalized(question)).start())
if len(named)>4: if len(named)>4:
return clarify(payload,'too_many_subjects','Pre podrobné obsahové porovnanie vyber najviac štyri osoby naraz.') return clarify(payload,'too_many_subjects','Pre podrobné obsahové porovnanie vyber najviac štyri osoby naraz.')
from tools.graphrag.graph_search import requested_name_phrases
first_names={normalized(r['title']).split()[0] for r in catalog}
capitalized={normalized(match[0]) for match in re.finditer(
r'\b[A-ZÁÄČĎÉÍĹĽŇÓÔŔŠŤÚÝŽ][^\W\d_]+(?:\s+[A-ZÁÄČĎÉÍĹĽŇÓÔŔŠŤÚÝŽ][^\W\d_]+){1,3}\b',question)}
unknown=[]
for phrase in requested_name_phrases(question):
words=phrase.split()
named_companion=(named and phrase in capitalized and
re.search(r'\ba\s+'+re.escape(phrase)+r'\b',normalized(question)))
if (2<=len(words)<=4 and (words[0] in first_names or named_companion) and
not any(text_contains_value(phrase,r['title'],person=True) for r in catalog)):
unknown.append(phrase)
if unknown:
return clarify(payload,'unresolved_subject','Meno „'+unknown[0]+'“ sa nepodarilo spoľahlivo priradiť. '
'Uveď jeho presné celé meno; ostatné osoby nebudem používať ako náhradu.')
if not named: if not named:
return payload return payload
types=requested_types(selector.positive_text,[r['title'] for r in named]) types=requested_types(selector.positive_text,[r['title'] for r in named])

View File

@ -1219,46 +1219,6 @@ def person_name_match(query: str, alias: str) -> tuple[float, tuple[int, int] |
return best return best
def requested_name_phrases(query: str) -> list[str]:
"""Recognise explicitly requested names, including unknown lower-case names.
This guard is separate from catalog resolution: an unresolved subject must
not turn the empty person filter into an unrestricted global listing.
"""
normalized = normalize_for_match(query.replace(',', ' separator '))
candidates = []
role = re.search(
r'\b(?:osob(?:a|y|u|e|ach|ami|am|ou)?|student(?:a|ovi|om|i|ov|ka|ky|ke|ku)?|kandidat(?:ov|i|a))\s+(.+)',
normalized,
)
if role:
tail = re.split(
r'(?:^|\s+)(?:s(?:o)?\s+(?:nazv\w*|nazov\w*|rok\w*)|splna\w*|ma|maju|mal|mali|je|su|'
r'ktory\w*|ktora\w*|pri\s+ktorych|v\s+roku|uved\w*|prepojen\w*|spojen\w*|'
r'vyber|najdi|zisti|cez|pomenuj|uved\w*|vypis|ak)\b', role.group(1), maxsplit=1,
)[0]
candidates.extend(re.split(r'\s+(?:a|separator)\s+', tail))
verb = re.search(r'\b(?:ma|mal|robil|riesil|venuje|venoval)\s+(.+)', normalized)
if verb:
candidates.append(re.split(r'\s+(?:v|s|so|a|ak|ktory\w*)\b', verb.group(1), maxsplit=1)[0])
# Capitalised full names also cover genitive phrases such as 'práce X Y'.
upper = 'A-ZÁÄČĎÉÍĹĽŇÓÔŔŠŤÚÝŽ'
for match in re.finditer(rf'\b([{upper}][^\W\d_]*(?:\s+[{upper}][^\W\d_]*){{1,3}})\b', query):
words = match.group(1).split()
if all(not word.isupper() for word in words):
candidates.append(normalize_for_match(match.group(1)))
generic = {'ktore', 'ktori', 'ktora', 'ktory', 'vsetci', 'vsetky', 'vsetkych', 'maju', 'ma',
'praca', 'prace', 'pracu', 'projekt', 'projekty', 'dokument', 'dokumenty', 'rok', 'roku',
'klucove', 'klucovy', 'klucovym', 'tag', 'tagy', 'nazov', 'nazvy', 'temu',
'kategoriu', 'kategorie', 'uvedenu', 'uvedeny', 'priradeny', 'priradene',
'je', 'su', 'sa', 's', 'pri', 'pre', 'z', 'na', 'v', 'ake', 'aky', 'kto', 'kolko'}
return list(dict.fromkeys(
candidate.strip() for candidate in candidates
if 1 <= len(candidate.split()) <= 5 and candidate.split()[0] not in generic
and not re.match(r'^(?:diplomov|bakalarsk|dizertacn|timov|vyskumn|zaverecn)\w*\s+(?:prac\w*|projekt\w*)\b',candidate)
))
def named_author_phrases(query: str) -> list[str]: def named_author_phrases(query: str) -> list[str]:
normalized = normalize_for_match(query.replace(',', ' separator ')) normalized = normalize_for_match(query.replace(',', ' separator '))
match = re.search(r'\bautor(?:a|om)?(?:\s+dokument(?:u|ov))?\s+(.+)', normalized) match = re.search(r'\bautor(?:a|om)?(?:\s+dokument(?:u|ov))?\s+(.+)', normalized)
@ -2385,30 +2345,12 @@ def search_graph_with_session(
row.get("kind") == "Person" and row.get("matched_alias") in author_names row.get("kind") == "Person" and row.get("matched_alias") in author_names
)] + requested_authors )] + requested_authors
requested_names = requested_name_phrases(clean_query) # The shared resolver has already rejected unknown/ambiguous subjects.
unknown_names = [] # Re-parsing prose here with a second role regex used to invent additional
for requested in requested_names: # names from presentation instructions, despite successful resolution.
if phrase and normalize_for_match(requested) in normalize_for_match(phrase): constraints["requested_person_names"] = list(dict.fromkeys(
continue normalize_for_match(match['name']) for match in resolution['matches']))
personal = [row for row in catalog if row.get("kind") in {"Person", "DocumentAuthor"}] constraints["unresolved_person_names"] = []
known_person = any(person_name_match(requested, row.get("name", ""))[0] > 0 for row in personal)
known_title = any(
normalize_for_match(requested) in alias
for row in catalog if row.get("kind") not in {"Person", "DocumentAuthor"}
for alias in entity_aliases(row)
)
if len(requested.split()) == 1:
# A verb can precede the first inflected word of a fully resolved
# thesis title. That is a title anchor, not an unknown person.
known_title = known_title or any(
tokens_match(normalize_for_match(requested), word)
for row in entities if row.get('kind') in {'Thesis', 'Work'}
for alias in entity_aliases(row) for word in alias.split()
)
if not known_person and not known_title:
unknown_names.append(requested)
constraints["requested_person_names"] = requested_names
constraints["unresolved_person_names"] = unknown_names
constraints["unresolved_document_author"] = bool(author_mentions and not requested_authors) constraints["unresolved_document_author"] = bool(author_mentions and not requested_authors)
aliases_to_ids: dict[tuple[str, str], set[str]] = {} aliases_to_ids: dict[tuple[str, str], set[str]] = {}
for row in entities: for row in entities:
@ -2487,8 +2429,6 @@ def search_graph_with_session(
blocked_reasons.append('missing_documented_start_year') blocked_reasons.append('missing_documented_start_year')
if constraints["unresolved_keyword"]: if constraints["unresolved_keyword"]:
blocked_reasons.append("unknown_keyword") blocked_reasons.append("unknown_keyword")
if unknown_names:
blocked_reasons.append("unknown_person")
if constraints["unresolved_document_author"]: if constraints["unresolved_document_author"]:
blocked_reasons.append("unknown_document_author") blocked_reasons.append("unknown_document_author")
if ambiguous_names: if ambiguous_names:

View File

@ -149,7 +149,10 @@ def test_single_token_unknown_subject_never_becomes_global_lookup(query):
def test_trailing_instruction_is_not_an_unknown_candidate_name(): def test_trailing_instruction_is_not_an_unknown_candidate_name():
query = 'Over prepojenie osôb Alena Nová a Boris Malý cez kľúčové slovo RAG a pomenuj spoločné kľúčové slovo.' query = 'Over prepojenie osôb Alena Nová a Boris Malý cez kľúčové slovo RAG a pomenuj spoločné kľúčové slovo.'
assert gs.requested_name_phrases(query) == ['alena nova', 'boris maly'] result = gs.search_graph_with_session(FakeSession({gs.CATALOG_QUERY: catalog()}), query)
assert result['constraints']['requested_person_names'] == ['alena nova', 'boris maly']
assert result['constraints']['unresolved_person_names'] == []
assert result['name_resolution']['status'] == 'ok'
def test_document_subject_is_not_misread_as_the_named_document_author(): def test_document_subject_is_not_misread_as_the_named_document_author():

View File

@ -181,20 +181,40 @@ def resolve_person_names(query: str, catalog: list[dict]) -> dict:
title_starts = {variant for row in catalog if row.get('kind') not in {'Person','DocumentAuthor',None} 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 key in ('name','title','heading') if row.get(key) and normalize(row[key])
for variant in name_token_variants(normalize(row[key]).split()[0])} 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 # Explicit subjects: full capitalized names; known given names followed by
# an unknown surname; names after a role, possessive work noun or predicate. # an unknown surname; names after a role, possessive work noun or predicate.
for i, token in enumerate(tokens): for i, token in enumerate(tokens):
if (not any(c.isalpha() for c in words[i]) or _generic(words[i]) 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())): or inside_quote(token.start(), token.end()) or covered(token.start(), token.end())):
continue continue
prefix = normalize(query[:token.start()]) # 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)) 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()]) 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])) 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() 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()) 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)) comparison_subject = bool(selected and pair and re.search(r'\b(?:a|porovnaj)\s*$', prefix))
if not (role or (pair and words[i] in first_names) or upper_pair or comparison_subject): # 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 continue
end = tokens[i+1].end() if pair else token.end() end = tokens[i+1].end() if pair else token.end()
if covered(token.start(), end): if covered(token.start(), end):