diff --git a/app/routes.py b/app/routes.py index 87222e2..6e76a82 100644 --- a/app/routes.py +++ b/app/routes.py @@ -270,6 +270,9 @@ class RagResponse(RetrievalResponse): index_version: dict[str, Any] | None = None context_budget: dict[str, Any] | None = None diagnostics: dict[str, Any] | None = None + resolved_query: str | None = None + name_resolution: dict[str, Any] | None = None + clarification: str | None = None class SearchResponse(RetrievalResponse): diff --git a/packages/claude/dist/packages.json b/packages/claude/dist/packages.json index f017a86..91d0894 100644 --- a/packages/claude/dist/packages.json +++ b/packages/claude/dist/packages.json @@ -3,12 +3,12 @@ { "archive": "zpwiki-rag.zip", "name": "rag", - "sha256": "01b9d32e246c992f529b264f5c6e9ab487e0357dfe74db3094fb77aca18b0a01" + "sha256": "b28d5c0e506c3084d8b221c9f71e6159cfe1318428aabd9f404918102303495b" }, { "archive": "zpwiki-graphrag.zip", "name": "graphrag", - "sha256": "81ce8d4ad9540671bc6c0d4f191e2874cf543d2d39f646de8cc79d722144c207" + "sha256": "02863058571a279811b86ec12c3d46bff2f09d7bfc350ba69d7058edc96aff40" } ], "schema_version": 1 diff --git a/packages/claude/dist/zpwiki-graphrag.zip b/packages/claude/dist/zpwiki-graphrag.zip index 09bf972..7accaeb 100644 Binary files a/packages/claude/dist/zpwiki-graphrag.zip and b/packages/claude/dist/zpwiki-graphrag.zip differ diff --git a/packages/claude/dist/zpwiki-rag.zip b/packages/claude/dist/zpwiki-rag.zip index 074106e..2acbea8 100644 Binary files a/packages/claude/dist/zpwiki-rag.zip and b/packages/claude/dist/zpwiki-rag.zip differ diff --git a/packages/claude/graphrag/SKILL.md b/packages/claude/graphrag/SKILL.md index c200138..a9bfa2c 100644 --- a/packages/claude/graphrag/SKILL.md +++ b/packages/claude/graphrag/SKILL.md @@ -16,6 +16,10 @@ name, add a year or drop a condition. Before calling, check names, work types, y negations and comparisons against the current message. Before presenting the result, check that its `query` matches that question. A mismatch is not a usable answer. If clarification is needed, ask specifically for the missing or ambiguous condition. +Send misspelled, unaccented or reversed names unchanged: the backend resolves them +against actual source identities. Preserve its recognized-name notice. If it offers +several people, ask the user to select a full name from those choices; never select +one yourself, invent a person, or omit an unresolved person from a comparison. For a short reply selecting the immediately preceding options, reconstruct that original full question and apply only the selection. Preserve every condition; if context is missing, ask for it. Compare the returned `query` against that resolved full question. A text question diff --git a/packages/claude/mcp_server.py b/packages/claude/mcp_server.py index ae07695..42c16a6 100644 --- a/packages/claude/mcp_server.py +++ b/packages/claude/mcp_server.py @@ -30,6 +30,9 @@ QUESTION_POLICY = ( 'If that context is missing, ask for it instead of guessing. ' 'Before calling, compare the query with the current complete question or its explicitly resolved clarification. ' 'Respect the explicitly selected engine. ' + 'Send name typos and spelling variants unchanged; the backend resolves actual catalog identities. ' + 'Keep its recognized-name notice, and ask the user to select when several people are offered. ' + 'Never guess a person or remove an unresolved person from a comparison. ' 'The returned query is audit data: compare it with the current question before presenting its answer. ' 'If they differ, do not present the mismatched answer. Ask for a concrete clarification when needed. ' ) diff --git a/packages/claude/rag/SKILL.md b/packages/claude/rag/SKILL.md index caa1bfa..925b59c 100644 --- a/packages/claude/rag/SKILL.md +++ b/packages/claude/rag/SKILL.md @@ -16,6 +16,10 @@ name, add a year or drop a condition. Before calling, check names, work types, y negations and comparisons against the current message. Before presenting the result, check that its `query` matches that question. A mismatch is not a usable answer. If clarification is needed, ask specifically for the missing or ambiguous condition. +Send misspelled, unaccented or reversed names unchanged: the backend resolves them +against actual source identities. Preserve its recognized-name notice. If it offers +several people, ask the user to select a full name from those choices; never select +one yourself, invent a person, or omit an unresolved person from a comparison. For a short reply selecting the immediately preceding options, reconstruct that original full question and apply only the selection. Preserve the person, year, work type and requested aspects. For one work, use its exact quoted heading; for "both", request both diff --git a/scripts/build_claude_packages.py b/scripts/build_claude_packages.py index 49337c1..c316426 100644 --- a/scripts/build_claude_packages.py +++ b/scripts/build_claude_packages.py @@ -11,7 +11,7 @@ import zipfile ROOT = Path(__file__).resolve().parents[1] BASE = ROOT / 'packages/claude' ENGINES = ('rag', 'graphrag') -PACKAGE_VERSION = '1.5.2' +PACKAGE_VERSION = '1.6.0' def json_bytes(value): diff --git a/test/test_person_names.py b/test/test_person_names.py new file mode 100644 index 0000000..7b0ec4a --- /dev/null +++ b/test/test_person_names.py @@ -0,0 +1,241 @@ +"""Name correction must preserve constraints and fail closed before retrieval.""" +import copy +import json +import sqlite3 +from pathlib import Path + +import pytest + +from tools.retrieval.person_names import resolve_person_names, sqlite_people +from tools.graphrag import graph_search as gs +from tools.graphrag.tests.test_graph_search import FakeSession + + +def person(name, slug): + return {'kind':'Person','name':name,'element_id':slug, + 'source_document':f'pages/students/2022/{slug}/README.md'} + + +@pytest.fixture +def people(): + return [person('Ján Holp','jan_holp'),person('Ondrej Megela','ondrej_megela'), + person('Ján Malinovský','jan_malinovsky'),person('Ján Pták','jan_ptak'), + person('Martin Šarišský','martin_sarissky'),person('Martin Jančura','martin_jancura')] + + +@pytest.mark.parametrize('mention,name',[ + ('Jan Holp','Ján Holp'),('JÁN HOLP','Ján Holp'),('Jána Holpa','Ján Holp'), + (' Jan Holp','Ján Holp'),('Holp Ján','Ján Holp'),('Holpa Jána','Ján Holp'), + ('Holp, Jan','Ján Holp'),('Ondreja Megelu','Ondrej Megela'), + ('Martina Šarišského','Martin Šarišský'),('Jána Malinovského','Ján Malinovský'), + ('Ján Holpp','Ján Holp'),('Ján Hlop','Ján Holp'),('Ondrej Megla','Ondrej Megela'), + ('Ján malinkvsý','Ján Malinovský'),('Ján Ptká','Ján Pták'), +]) +def test_variants_and_bounded_typos(people,mention,name): + q=f'Aké práce má {mention} v roku 1999?' + before=copy.deepcopy(people) + r=resolve_person_names(q,people) + assert r['status']=='ok' and [m['name'] for m in r['matches']]==[name] + assert r['resolved_query']==q[:q.index(mention.strip())]+name+q[q.index(mention.strip())+len(mention.strip()):] + assert 'v roku 1999?' in r['resolved_query'] and people==before + + +def test_multiple_people_and_all_non_name_conditions_survive(people): + q='Porovnaj Jána Holppa a Ondreja Megelu; nie diplomový projekt, iba práce z roku 2021 s tagom RAG.' + r=resolve_person_names(q,people) + assert r['status']=='ok' + assert r['resolved_query']=='Porovnaj Ján Holp a Ondrej Megela; nie diplomový projekt, iba práce z roku 2021 s tagom RAG.' + assert len(r['notices'])==1 and 'Ján Holp' in r['notices'][0] + + +def test_plural_keyword_question_does_not_hide_a_misspelled_person(people): + q='Aké spoločné kľúčové slová majú Ján Ptká a Ján Malinovský?' + r=resolve_person_names(q,people) + assert r['status']=='ok' + assert [m['name'] for m in r['matches']]==['Ján Pták','Ján Malinovský'] + assert r['resolved_query']==q.replace('Ptká','Pták') + + +@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?', + 'Porovnaj práce Jána Holpa a Xavera Neexistujúceho.', + 'Porovnaj xaver neexistujuci a jan holp.', + 'Porovnaj jan holp a xaver neexistujuci.', + 'Aké práce má Ján Hxxxx?', 'Aké práce má Xaver?', +]) +def test_unknown_name_cannot_turn_into_global_query(people,q): + r=resolve_person_names(q,people) + assert r['status']=='no_answer' and r['reason']=='unknown_person' and not r['candidates'] + session=FakeSession({gs.CATALOG_QUERY:people}) + graph=gs.search_graph_with_session(session,q) + assert graph['status']=='no_answer' and graph['facts']==[] + assert all(statement==gs.CATALOG_QUERY for statement,_ in session.calls) + + +def test_nearby_catalog_names_require_selection_and_exact_name_wins(): + catalog=[person('Martin Novák','novak'),person('Martin Novík','novik')] + r=resolve_person_names('Aké práce má Martin Novek?',catalog) + assert r['status']=='ambiguous' + assert {c['name'] for c in r['candidates']}=={'Martin Novák','Martin Novík'} + assert resolve_person_names('Aké práce má Martin Novák?',catalog)['matches'][0]['name']=='Martin Novák' + + +def test_short_first_name_is_not_automatically_assigned(people): + people = [*people, {'kind':'DocumentAuthor','name':'Ján Holp'}] + r=resolve_person_names('Aké práce má Ján?',people) + assert r['status']=='ambiguous' and len(r['candidates'])==3 + assert all(c['name'].startswith('Ján ') for c in r['candidates']) + + +def test_full_exact_name_wins_over_another_person_inflection(): + catalog=[person('Ján Novák','jan'),person('Jana Novák','jana')] + r=resolve_person_names('Aké práce má Jana Novák?',catalog) + assert r['matches'][0]['name']=='Jana Novák' + + +def test_competing_valid_inflections_require_selection(): + catalog=[person('Ján Novák','jan'),person('Jana Novák','jana')] + r=resolve_person_names('Aký je názov práce Jana Novaka?',catalog) + assert r['status']=='ambiguous' + assert {c['name'] for c in r['candidates']}=={'Ján Novák','Jana Novák'} + + +def test_rag_evidence_uses_resolved_identity_not_another_name_inflection(): + from tools.answer_evaluation.rag_evidence import rag_evidence_context + from tools.rag.rag_utils import build_context_text + catalog=[person('Ján Novák','jan'),person('Jana Novák','jana')] + q='Aký je názov diplomovej práce Jana Novák?' + sources=[{'source_id':f'S{i}','title':row['name'],'document_path':row['source_document'], + 'source_url':f'https://example.test/{i}', + 'text':'Sekcia: Diplomová práca 2024\nNázov: Výskum jazyka '+str(i)+'.'} + for i,row in enumerate(catalog,1)] + payload={'sources':sources,'context':build_context_text(sources), + 'name_resolution':resolve_person_names(q,catalog)} + c=rag_evidence_context(payload['name_resolution']['resolved_query'],payload) + assert c['requirements']['people']==['Jana Novák'] + assert {f['person'] for f in c['facts']}=={'Jana Novák'} + + +def test_canonicalized_question_keeps_natural_answer_grammar(): + from tools.answer_evaluation.presentation import fact_sentences + claim={'person':'Ján Holp','work_type':'diploma_thesis','title':'Ukážkový výskum','year':2021} + text=fact_sentences(claim,'Aký je názov diplomovej práce Ján Holp?') + assert text.startswith('Ján Holp má diplomovú prácu s názvom') and '2021' in text + assert fact_sentences(claim,'Aký je názov diplomovej práce Jána Holpa?').startswith('Diplomová práca Jána Holpa') + + +def test_two_damaged_tokens_are_never_guessed(people): + assert resolve_person_names('Aké práce má Jaann Hlop?',people)['status']=='no_answer' + + +def test_document_author_is_not_implicitly_a_student(people): + catalog=[*people,{'kind':'DocumentAuthor','name':'Daniel Hladek'}] + r=resolve_person_names('Aké práce má Daniel Hladek?',catalog) + assert r['status']=='no_answer' + author=resolve_person_names('Ktoré dokumenty napísal autor Daniel Hladekk?',catalog) + assert author['status']=='ok' and author['matches'][0]['name']=='Daniel Hladek' + + +def test_duplicate_names_can_be_selected_by_actual_profile_path(): + catalog=[person('Ján Novák','novak_a'),person('Ján Novák','novak_b')] + q='Aké práce má Ján Novák?' + assert resolve_person_names(q,catalog)['status']=='ambiguous' + selected=resolve_person_names(q+' Profil: '+catalog[1]['source_document'],catalog) + assert selected['status']=='ok' and selected['matches'][0]['source_document']==catalog[1]['source_document'] + + +def test_multiword_name_supports_surname_first(): + r=resolve_person_names('Aké práce má Chis Bogdan Paul?', [person('Bogdan Paul Chiș','bogdan')]) + assert r['status']=='ok' and r['matches'][0]['name']=='Bogdan Paul Chiș' + + +def test_literal_nonstandard_catalog_name_preserves_numeric_suffix(): + catalog=[person('Martin NaN1','nan')] + for mention in ('Martin NaN1','NaN1 Martin'): + r=resolve_person_names('Aké práce má '+mention+' v roku 1999?',catalog) + assert r['status']=='ok' and r['resolved_query']=='Aké práce má Martin NaN1 v roku 1999?' + assert resolve_person_names('Aké práce má Martin NaN2?',catalog)['status']=='no_answer' + + +@pytest.mark.parametrize('q',[ + 'Ktorí študenti majú tag RAG a diplomový projekt v roku 2026?', + 'Aké práce boli v roku 2020?', 'Vyhľadaj práce s názvom „Ján Holpp“.', + 'Ktorí študenti majú tag Ján Holpp?', + 'Ktorí študenti majú kľúčové slovo Ján Holpp a prácu v roku 2026?', +]) +def test_global_queries_and_quoted_titles_are_not_corrected_as_people(people,q): + r=resolve_person_names(q,people) + assert r['status']=='ok' and r['resolved_query']==q and not r['matches'] + + +def test_malinovsky_sarissky_never_matches_martin_jancura(people): + r=resolve_person_names('Porovnaj Jána Malinovského a Martina Šarišského.',people) + assert {m['name'] for m in r['matches']}=={'Ján Malinovský','Martin Šarišský'} + + +@pytest.fixture +def names_db(tmp_path,people): + path=tmp_path/'index.sqlite' + with sqlite3.connect(path) as conn: + conn.execute('CREATE TABLE documents(path,title,published)') + for row in people: + conn.execute('INSERT INTO documents VALUES(?,?,?)',(row['source_document'],row['name'],1)) + conn.execute('INSERT INTO documents VALUES(?,?,?)',('pages/students/2022/hidden/README.md','Xaver Skrytý',0)) + return path + + +def test_catalog_honors_publication_policy(names_db): + assert not any(p['name']=='Xaver Skrytý' for p in sqlite_people(names_db)) + assert any(p['name']=='Xaver Skrytý' for p in sqlite_people(names_db,published_only=False)) + + +@pytest.mark.parametrize('q,status',[ + ('Aké práce má Xaver Neexistujúci?','no_answer'),('Aké práce má Ján?','ambiguous')]) +def test_rag_does_not_search_or_generate_for_unresolved_subject(names_db,monkeypatch,q,status): + from tools.rag import rag_utils + from tools.answers.answer_delivery import deliver_answer + monkeypatch.setattr(rag_utils,'search_database',lambda *a,**kw:pytest.fail('Must not search globally')) + r=deliver_answer(q,engine='rag',db_file=names_db,complete=lambda _:pytest.fail('Must not call model')) + assert r['status']==status and r['accepted_for_delivery'] and r['source_urls']==[] + + +def test_graph_correction_retains_year_type_and_person_ids(people): + session=FakeSession({gs.CATALOG_QUERY:people}) + q='Aké diplomové práce má Jan Holpp v roku 1999?' + r=gs.search_graph_with_session(session,q) + assert r['query']==q and 'Ján Holp' in r['resolved_query'] + params=next(params for statement,params in session.calls if statement==gs.THESIS_FACTS_QUERY) + assert params['person_ids']==['jan_holp'] and params['years']==[1999] + assert r['constraints']['thesis_types']==['diploma_thesis'] + + +def test_both_engines_use_same_resolution_and_notice(names_db,people,monkeypatch): + from tools.answers import answer_delivery as delivery + from tools.rag import rag_utils + question='Aké práce má Ján Holpp v roku 1999?' + resolution=resolve_person_names(question,people) + payload={'status':'ok','sources':[],'context':'','name_resolution':resolution, + 'resolved_query':resolution['resolved_query'],'index_version':None} + checked={'status':'no_answer','reason':'no_matching_facts','answer':'Pre rok 1999 sa údaje nenašli.', + 'source_urls':[],'claims':[],'accepted_for_delivery':True,'complete':True, + 'truncated':False,'truncation_reasons':[]} + monkeypatch.setattr(rag_utils,'search_database',lambda *a,**k: {'results':[],'engine':'hybrid','strategies':[]}) + monkeypatch.setattr(delivery,'build_rag_context',lambda *a,**k:payload) + monkeypatch.setattr(delivery,'expand_comparison_sources',lambda db,q,p:p) + monkeypatch.setattr(delivery,'prepare_named_content',lambda db,q,p:p) + monkeypatch.setattr(delivery,'compact_rag_payload',lambda p:p) + def rag(q,p,model): + assert q==resolution['resolved_query'] + return dict(checked) + monkeypatch.setattr(delivery,'generate_verified_rag',rag) + rag_result=delivery.deliver_answer(question,engine='rag',db_file=names_db) + monkeypatch.setattr(delivery,'search_graph',lambda *a,**k:payload) + monkeypatch.setattr(delivery,'graph_context',lambda p:{'name_resolution':resolution}) + def graph(q,context,prompt,model): + assert q==resolution['resolved_query'] + return {'accepted_for_delivery':True,'answer':dict(checked),'attempts':[]} + monkeypatch.setattr(delivery,'generate_checked_answer',graph) + graph_result=delivery.deliver_answer(question,engine='graphrag',db_file=names_db) + assert rag_result['answer']==graph_result['answer'] + assert rag_result['answer'].startswith('Rozpoznané meno: **Ján Holp**') diff --git a/tools/answer_evaluation/graph_context.py b/tools/answer_evaluation/graph_context.py index ae29c78..a8a56ac 100644 --- a/tools/answer_evaluation/graph_context.py +++ b/tools/answer_evaluation/graph_context.py @@ -60,7 +60,7 @@ def graph_context(result: dict) -> dict: 'truncation_reasons':result.get('truncation_reasons',[]), 'index_version':result.get('index_version')} context['answer_candidates']=joined_candidates(facts,intent) - context['question'] = result.get('query', '') + context['question'] = result.get('resolved_query') or result.get('query', '') context['requested_years']=intent.get('years',[]) if not intent.get('mentions_start_year') else [] context['requested_work_types']=[*intent.get('thesis_types',[]),*intent.get('work_types',[])] context['clarification'] = result.get('clarification') diff --git a/tools/answer_evaluation/presentation.py b/tools/answer_evaluation/presentation.py index ed672d9..09f57e5 100644 --- a/tools/answer_evaluation/presentation.py +++ b/tools/answer_evaluation/presentation.py @@ -32,7 +32,10 @@ def owner_in_question(person: str, question: str) -> str | None: for token, match in zip(tokens, found): valid &= bool(re.fullmatch(person_token_pattern(token), normalized(match[0]))) if valid: - return question[found[0].start():found[-1].end()] + owner = question[found[0].start():found[-1].end()] + # Internal canonicalization supplies nominative catalog names. It + # is not a genitive and must use the natural person-first template. + return owner if normalized(owner) != normalized(person) else None return None diff --git a/tools/answer_evaluation/rag_evidence.py b/tools/answer_evaluation/rag_evidence.py index e8d1518..b8b2eef 100644 --- a/tools/answer_evaluation/rag_evidence.py +++ b/tools/answer_evaluation/rag_evidence.py @@ -445,6 +445,10 @@ def rag_evidence_context(question: str, payload: dict) -> dict: facts = list({tuple(sorted(row.items())): row for row in facts}.values()) requested = [person for person in people if text_contains_value(question, person, person=True)] requested=list(dict.fromkeys([*payload.get('named_subjects',[]),*requested])) + identified = list(dict.fromkeys(m['name'] for m in (payload.get('name_resolution') or {}).get('matches',[]) + if m.get('source_document'))) + if identified: + requested = identified from tools.retrieval.work_selector import parse_work_selector selector=parse_work_selector(question) expected_types = requested_types(selector.positive_text, requested) diff --git a/tools/answer_evaluation/rag_preparation.py b/tools/answer_evaluation/rag_preparation.py index 1ab5a2e..cfceacd 100644 --- a/tools/answer_evaluation/rag_preparation.py +++ b/tools/answer_evaluation/rag_preparation.py @@ -33,6 +33,11 @@ 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']] + identities = {normalized(m['name']): m['source_document'] + for m in (payload.get('name_resolution') or {}).get('matches',[]) + if m.get('source_document')} + if identities: + catalog = [r for r in catalog if identities.get(normalized(r['title'])) == r['path']] named=[r for r in catalog if text_contains_value(question,r['title'],person=True)] # Preserve question order rather than the original retrieval ranking. named.sort(key=lambda r:re.search(person_pattern(r['title']),normalized(question)).start()) diff --git a/tools/answers/answer_delivery.py b/tools/answers/answer_delivery.py index a524976..07f0b13 100644 --- a/tools/answers/answer_delivery.py +++ b/tools/answers/answer_delivery.py @@ -26,6 +26,25 @@ def retrieval_failure(engine: str) -> dict: 'complete':False,'truncated':False,'truncation_reasons':[]}) +def name_failure(retrieved: dict, engine: str) -> dict | None: + resolution = retrieved.get('name_resolution') or {} + if not resolution or resolution.get('status') == 'ok': + return None + return {'status':resolution['status'],'reason':resolution['reason'],'engine':engine, + 'accepted_for_delivery':True,'answer':resolution['message'],'claims':[],'source_urls':[], + 'name_resolution':resolution,'index_version':retrieved.get('index_version'), + 'complete':False,'truncated':False,'truncation_reasons':[]} + + +def with_name_resolution(result: dict, retrieved: dict) -> dict: + resolution = retrieved.get('name_resolution') or {} + if result.get('accepted_for_delivery') and resolution.get('notices'): + result['answer'] = '\n'.join(resolution['notices'])+'\n\n'+result['answer'] + if resolution: + result['name_resolution'] = resolution + return result + + def deliver_answer(question: str, *, engine: str = 'rag', limit: int = 5, complete=None, db_file: Path = DB_FILE, include_diagnostics: bool = False) -> dict: if not _capacity.acquire(blocking=False): @@ -48,6 +67,9 @@ def deliver_answer(question: str, *, engine: str = 'rag', limit: int = 5, retrieved = build_rag_context(db_file, question, limit=limit) if retrieved.get('status')=='error': return retrieval_failure(engine) + if failure := name_failure(retrieved, engine): + return failure + question = retrieved.get('resolved_query') or question retrieved = expand_comparison_sources(db_file, question, retrieved) retrieved = prepare_named_content(db_file, question, retrieved) retrieved = compact_rag_payload(retrieved) @@ -68,14 +90,17 @@ def deliver_answer(question: str, *, engine: str = 'rag', limit: int = 5, retrieved=search_graph(question, limit=limit) if retrieved.get('status')=='error': return retrieval_failure(engine) + if failure := name_failure(retrieved, engine): + return failure + question = retrieved.get('resolved_query') or question context = graph_context(retrieved) except Exception: return retrieval_failure(engine) if context.get('clarification'): - return {'status':'ambiguous','reason':context.get('reason'),'accepted_for_delivery':True, + return with_name_resolution({'status':'ambiguous','reason':context.get('reason'),'accepted_for_delivery':True, 'answer':context['clarification'],'claims':[],'source_urls':[], 'engine':engine, 'complete':False,'truncated':False,'truncation_reasons':[], - 'index_version':context.get('index_version')} + 'index_version':context.get('index_version')}, retrieved) prompt = (Path(__file__).resolve().parents[1] / 'answer_evaluation/graph_answer_prompt.txt').read_text() generated = generate_checked_answer(question, context, prompt, model) if generated.get('accepted_for_delivery'): @@ -91,6 +116,7 @@ def deliver_answer(question: str, *, engine: str = 'rag', limit: int = 5, raise ValueError('Unsupported answer engine') if result.get('status')=='error' or result.get('accepted_for_delivery') is not True: result=public_failure(result) + result = with_name_resolution(result, retrieved) return {**result, 'engine': engine, **({'retrieval_context': context} if include_diagnostics else {})} finally: diff --git a/tools/graphrag/graph_search.py b/tools/graphrag/graph_search.py index 61d4e86..7abcc77 100644 --- a/tools/graphrag/graph_search.py +++ b/tools/graphrag/graph_search.py @@ -9,6 +9,7 @@ from tools.retrieval.publication import DEFAULT_PUBLISHED_ONLY, PUBLICATION_POLI from .publication import public_query from tools.retrieval.retrieval_contract import result_fields, source_references, error_result from tools.retrieval.question_constraints import unsupported_constraint, constraint_message +from tools.retrieval.person_names import resolve_person_names, name_token_variants GRAPH_SCOPE = "zpwiki" @@ -1203,24 +1204,6 @@ def entity_aliases( ) -def name_token_variants(token: str) -> set[str]: - """Conservative Slovak case endings; never accept arbitrary short prefixes.""" - variants = {token} - if len(token) < 3: - return variants - if token.endswith('ova'): - variants.update(token[:-1] + suffix for suffix in ('ej', 'u', 'ou')) - elif token.endswith('y'): - variants.update(token[:-1] + suffix for suffix in ('eho', 'emu', 'ym', 'ych')) - elif token.endswith('a'): - variants.update(token[:-1] + suffix for suffix in ('y', 'e', 'u', 'ou', 'ovi', 'om')) - elif token.endswith('o'): - variants.update(token[:-1] + suffix for suffix in ('a', 'ovi', 'om')) - else: - variants.update(token + suffix for suffix in ('a', 'ovi', 'om')) - return variants - - def person_name_match(query: str, alias: str) -> tuple[float, tuple[int, int] | None]: """Match the complete name in one contiguous window, preserving token order.""" words, name = normalize_for_match(query).split(), normalize_for_match(alias).split() @@ -2352,6 +2335,16 @@ def search_graph_with_session( 'facts':[],'paths':[],'documents':[],'aggregation':None,'resolved_entities':[], 'checked_entities':[],'rejection_evidence':[],'strategies':[],'reasons':['unsupported_constraint']} catalog = run_rows(session, CATALOG_QUERY, scope=GRAPH_SCOPE, published_only=published_only) + original_query = clean_query + resolution = resolve_person_names(clean_query, catalog) + if resolution['status'] != 'ok': + return {**result_fields(status=resolution['status'],sources=[],reason=resolution['reason'],complete=False), + 'query':original_query,'constraints':constraints,'name_resolution':resolution, + 'clarification':resolution['message'] if resolution['status']=='ambiguous' else None, + 'facts':[],'paths':[],'documents':[],'aggregation':None,'resolved_entities':[], + 'checked_entities':[],'rejection_evidence':[],'strategies':[],'reasons':[resolution['reason']]} + clean_query = resolution['resolved_query'] + constraints = extract_query_constraints(clean_query) constraints['published_only'] = published_only # Explicit tag filters are resolved separately. Incidental names such as the # tag 'zp' in 'ZP Wiki' must never become additional query constraints. @@ -2360,6 +2353,11 @@ def search_graph_with_session( clean_query, catalog, max_entities=max(len(catalog), max_entities), preferred_kinds=constraints["target_kinds"], ) + identity_paths = {normalize_for_match(m['name']):m['source_document'] + for m in resolution['matches'] if m.get('source_document')} + entities = [row for row in entities if row.get('kind') != 'Person' + or normalize_for_match(row.get('name','')) not in identity_paths + or row.get('source_document') == identity_paths[normalize_for_match(row['name'])]] if phrase: keywords = explicit_keyword_entities(phrase, catalog) entities = [row for row in entities if row.get("kind") != "Keyword"] + keywords @@ -2668,7 +2666,8 @@ def search_graph_with_session( **result_fields(status=status, sources=citations, reason=reason, complete=True, truncation_reasons=truncation_reasons), "reasons": blocked_reasons, - "query": clean_query, "strategies": strategies, "constraints": constraints, + "query": original_query, "resolved_query": clean_query, "name_resolution": resolution, + "strategies": strategies, "constraints": constraints, "resolved_entities": resolved_entities, "paths": paths[:limit], "facts": visible_facts, "supporting_facts": supporting_facts, "documents": documents, "aggregation": aggregation, diff --git a/tools/rag/llm_context.py b/tools/rag/llm_context.py index 807b384..1011d38 100644 --- a/tools/rag/llm_context.py +++ b/tools/rag/llm_context.py @@ -12,7 +12,8 @@ from tools.retrieval.text_budget import split_to_fit SOURCE_FIELDS = ('source_id', 'title', 'author', 'document_path', 'source_url', 'published', 'categories', 'tags', 'section') FIELDS = ('status', 'reason', 'query', 'engine', 'strategies', 'instructions', 'answer_format', - 'complete', 'truncated', 'truncation_reasons', 'index_version', 'source_count', 'clarification', 'named_subjects', 'work_scopes') + 'complete', 'truncated', 'truncation_reasons', 'index_version', 'source_count', 'clarification', 'named_subjects', 'work_scopes', + 'resolved_query', 'name_resolution') def compact_rag_payload(data: dict, *, max_tokens: int | None = None, diff --git a/tools/rag/rag_utils.py b/tools/rag/rag_utils.py index 0431c4f..6733c8d 100644 --- a/tools/rag/rag_utils.py +++ b/tools/rag/rag_utils.py @@ -709,6 +709,17 @@ def build_rag_context( published_only: bool = DEFAULT_PUBLISHED_ONLY, max_per_document: int = 1, ) -> dict[str, Any]: + from tools.retrieval.person_names import resolve_person_names, sqlite_people + original_query = query + catalog = sqlite_people(db_path, published_only=published_only) if db_path.exists() else None + resolution = resolve_person_names(query, catalog) if catalog is not None else None + if resolution and resolution['status'] != 'ok': + return {**result_fields(status=resolution['status'], sources=[], reason=resolution['reason'], complete=False), + 'query':original_query,'engine':'hybrid','strategies':[],'source_count':0,'context':'', + 'name_resolution':resolution,'clarification':resolution['message'] if resolution['status']=='ambiguous' else None, + 'instructions':RAG_INSTRUCTIONS,'answer_format':ANSWER_FORMAT} + if resolution: + query = resolution['resolved_query'] from tools.retrieval.question_constraints import unsupported_constraint, constraint_message unsupported=unsupported_constraint(query) if unsupported: @@ -758,6 +769,11 @@ def build_rag_context( published_only=published_only, ) + identities = {m['source_document'] for m in (resolution or {}).get('matches',[]) if m.get('source_document')} + if identities: + profile_paths = {row['source_document'] for row in catalog or [] if row.get('kind')=='Person'} + results = [row for row in results if row.get('document_path') not in profile_paths + or row['document_path'] in identities] sources = [ build_source(result, index) for index, result in enumerate(results, start=1) @@ -772,7 +788,9 @@ def build_rag_context( complete=response.get('complete'), index_version=response.get('index_version'), truncation_reasons=response.get('truncation_reasons', [])), - "query": query, + "query": original_query, + "resolved_query": query, + "name_resolution": resolution, "engine": response["engine"], "strategies": response["strategies"], "source_count": len(sources), diff --git a/tools/retrieval/person_names.py b/tools/retrieval/person_names.py new file mode 100644 index 0000000..41cadb2 --- /dev/null +++ b/tools/retrieval/person_names.py @@ -0,0 +1,259 @@ +"""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 ' + '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'.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')) + + +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*)\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])} + # 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()]) + 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)) + 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): + 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