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']
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):
for row in shared_session.responses[gs.WORK_FACTS_QUERY]:
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')
@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',[
'Aké práce má Xaver Neexistujúci?', 'Aké práce má 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 jan holp a xaver neexistujuci.',
'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):
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):
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'
@pytest.mark.parametrize('question',[
'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):
p=prepare(corpus,question)
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):
db,raw=corpus
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
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']]
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']
for m in (payload.get('name_resolution') or {}).get('matches',[])
for m in resolution.get('matches',[])
if m.get('source_document')}
if identities:
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())
if len(named)>4:
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:
return payload
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
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]:
normalized = normalize_for_match(query.replace(',', ' separator '))
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
)] + requested_authors
requested_names = requested_name_phrases(clean_query)
unknown_names = []
for requested in requested_names:
if phrase and normalize_for_match(requested) in normalize_for_match(phrase):
continue
personal = [row for row in catalog if row.get("kind") in {"Person", "DocumentAuthor"}]
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
# The shared resolver has already rejected unknown/ambiguous subjects.
# Re-parsing prose here with a second role regex used to invent additional
# names from presentation instructions, despite successful resolution.
constraints["requested_person_names"] = list(dict.fromkeys(
normalize_for_match(match['name']) for match in resolution['matches']))
constraints["unresolved_person_names"] = []
constraints["unresolved_document_author"] = bool(author_mentions and not requested_authors)
aliases_to_ids: dict[tuple[str, str], set[str]] = {}
for row in entities:
@ -2487,8 +2429,6 @@ def search_graph_with_session(
blocked_reasons.append('missing_documented_start_year')
if constraints["unresolved_keyword"]:
blocked_reasons.append("unknown_keyword")
if unknown_names:
blocked_reasons.append("unknown_person")
if constraints["unresolved_document_author"]:
blocked_reasons.append("unknown_document_author")
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():
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():

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}
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
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))
# "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))
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
end = tokens[i+1].end() if pair else token.end()
if covered(token.start(), end):