From af61a8a27396c2ca2b30c499544a5df0f923ddcd Mon Sep 17 00:00:00 2001 From: jp170na Date: Thu, 5 Nov 2026 01:48:10 +0100 Subject: [PATCH] =?UTF-8?q?Oprava=20rozpozn=C3=A1vania=20mien?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/test_comparison_answers.py | 18 +++++ test/test_person_names.py | 26 +++++++ test/test_quality_block.py | 19 ++++- tools/answer_evaluation/rag_preparation.py | 24 +++---- tools/graphrag/graph_search.py | 72 ++----------------- .../tests/test_graph_search_entity_v2.py | 5 +- tools/retrieval/person_names.py | 24 ++++++- 7 files changed, 101 insertions(+), 87 deletions(-) diff --git a/test/test_comparison_answers.py b/test/test_comparison_answers.py index 08e6450..18669d3 100644 --- a/test/test_comparison_answers.py +++ b/test/test_comparison_answers.py @@ -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 diff --git a/test/test_person_names.py b/test/test_person_names.py index 7b0ec4a..d6e6c5b 100644 --- a/test/test_person_names.py +++ b/test/test_person_names.py @@ -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) diff --git a/test/test_quality_block.py b/test/test_quality_block.py index d24b9f9..dbf97b8 100644 --- a/test/test_quality_block.py +++ b/test/test_quality_block.py @@ -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: diff --git a/tools/answer_evaluation/rag_preparation.py b/tools/answer_evaluation/rag_preparation.py index cfceacd..6067956 100644 --- a/tools/answer_evaluation/rag_preparation.py +++ b/tools/answer_evaluation/rag_preparation.py @@ -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]) diff --git a/tools/graphrag/graph_search.py b/tools/graphrag/graph_search.py index 7abcc77..6c6d081 100644 --- a/tools/graphrag/graph_search.py +++ b/tools/graphrag/graph_search.py @@ -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: diff --git a/tools/graphrag/tests/test_graph_search_entity_v2.py b/tools/graphrag/tests/test_graph_search_entity_v2.py index a70a64c..26d66df 100644 --- a/tools/graphrag/tests/test_graph_search_entity_v2.py +++ b/tools/graphrag/tests/test_graph_search_entity_v2.py @@ -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(): diff --git a/tools/retrieval/person_names.py b/tools/retrieval/person_names.py index 41cadb2..6c8c47a 100644 --- a/tools/retrieval/person_names.py +++ b/tools/retrieval/person_names.py @@ -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):