From 05f96ac9c0f3887e4a9210d56bf9444fe1708f8f Mon Sep 17 00:00:00 2001 From: jp170na Date: Tue, 3 Nov 2026 19:12:08 +0100 Subject: [PATCH] =?UTF-8?q?Oprava=20grafov=C3=BDch=20podmienok?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 + test/test_graph_query_regressions.py | 191 ++++++++++++++++++ tools/answer_evaluation/context_policy.py | 9 +- .../answer_evaluation/graph_answer_prompt.txt | 1 + tools/answer_evaluation/graph_context.py | 17 +- tools/answer_evaluation/render_answer.py | 14 +- tools/graphrag/graph_search.py | 22 +- tools/graphrag/tests/test_evaluation_wide.py | 10 +- tools/retrieval/question_constraints.py | 18 +- 9 files changed, 278 insertions(+), 14 deletions(-) create mode 100644 test/test_graph_query_regressions.py diff --git a/README.md b/README.md index 9253089..551c9b6 100644 --- a/README.md +++ b/README.md @@ -1523,6 +1523,16 @@ sa nepoužije ako dôkaz dátumu jej začatia či obhajoby. Chybové odpovede od nedostupné vyhľadávanie, nedostupný model, vyťaženie a zamietnutý neoverený text; raw výstupy odmietnutého modelu sa štandardnému klientovi neposkytujú. +Grafový filter tagu sa uplatňuje aj pri otázkach na neurčených študentov a +konkrétny typ/rok práce: všetky podmienky musí spĺňať tá istá osoba. Spojenie +„tag RAG a zároveň diplomový projekt“ nepatrí do názvu tagu. Úvodzovky umožňujú +zachovať viacslovný tag aj so spojkou „a“. Pri názve práce konkrétnej osoby sa +zachovajú aj záznamy bez názvu; odpoveď uvedie známy typ a rok a chýbajúci názov. +Spoločné tagy sa zobrazujú súhrnne, s dôkazmi pre každú požadovanú osobu. +Rozpoznané podmienky na počet vrátane slovných čísloviek („aspoň tri“) vyžadujú +spresnenie; prahové filtrovanie zatiaľ nie je implementované. Predikcia dokončenia +práce sa výslovne odlišuje od vyhľadania práce evidovanej v určitom roku. + Otázky na obsah, zadanie, ciele alebo metódy práce používajú textové výňatky z príslušnej akademickej sekcie, nie iba metadáta o názve a roku. Výňatok musí patriť požadovanej osobe, typu práce a prípadne roku. Názov, rok ani samotný diff --git a/test/test_graph_query_regressions.py b/test/test_graph_query_regressions.py new file mode 100644 index 0000000..1fe52fc --- /dev/null +++ b/test/test_graph_query_regressions.py @@ -0,0 +1,191 @@ +"""Regressions for actual Claude questions and adversarial same-person joins.""" +import copy +import json +from pathlib import Path + +import pytest + +from tools.answer_evaluation.checked_generation import generate_checked_answer +from tools.answer_evaluation.context_policy import answer_context_issues +from tools.answer_evaluation.graph_context import graph_context +from tools.answer_evaluation.render_answer import render_graph_answer +from tools.answers.answer_delivery import deliver_answer +from tools.graphrag import graph_search as gs +from tools.graphrag.tests.test_graph_search import FakeSession +from tools.retrieval.question_constraints import unsupported_constraint + + +PEOPLE=['Ján Pták','Ján Malinovský','Valerii Kutsenko','Jakub Schwarc'] +PROMPT=Path('tools/answer_evaluation/graph_answer_prompt.txt').read_text() + + +def person(index,name): + return {'kind':'Person','name':name,'element_id':f'p{index}', + 'source_document':f'pages/students/2022/p{index}/README.md'} + + +def source(index): + return {'document_path':f'pages/students/2022/p{index}/README.md', + 'source_url':f'https://example.test/p{index}'} + + +@pytest.fixture +def tagged_projects(): + catalog=[person(i,name) for i,name in enumerate(PEOPLE,1)] + catalog.append({'kind':'Keyword','name':'RAG','id':'rag','element_id':'krag'}) + projects=[{'person':name,'person_element_id':f'p{i}','year':2026, + 'work_type':'diploma_project','work_id':f'work:p{i}', + 'heading':'Diplomový projekt 2026','title':f'Téma projektu {name}',**source(i)} + for i,name in enumerate(PEOPLE,1)] + tags=[{'person':name,'person_element_id':f'p{i}','keyword':'RAG', + 'keyword_id':'rag','keyword_element_id':'krag',**source(i)} + for i,name in enumerate(PEOPLE[:3],1)] + # A fifth person has the tag, but no work in the requested year/type. + tags.append({'person':'Iná Osoba','person_element_id':'p5','keyword':'RAG', + 'keyword_id':'rag','keyword_element_id':'krag',**source(5)}) + return FakeSession({gs.CATALOG_QUERY:catalog,gs.WORK_FACTS_QUERY:projects, + gs.PERSON_KEYWORD_FACTS_QUERY:tags}) + + +@pytest.mark.parametrize('question',[ + 'Ktorí študenti majú tag RAG a zároveň diplomový projekt z roku 2026?', + 'Ktorí študenti s tagom RAG majú diplomový projekt v roku 2026?', + 'Ktorí študenti majú tag RAG a diplomový projekt z roku 2026?', + 'Ktorí študenti majú tag „RAG“ a zároveň diplomový projekt z roku 2026?', + 'Ktorí študenti s tagom RAG a zároveň diplomovým projektom z roku 2026?', + 'Ktorí študenti s tagom RAG a diplomovým projektom z roku 2026?', +]) +def test_tag_and_project_are_witnessed_by_the_same_person(tagged_projects,question): + r=gs.search_graph_with_session(tagged_projects,question,limit=50) + assert r['status']=='ok' and r['constraints']['keyword_phrase']=='rag' + assert {f['person'] for f in r['facts']}==set(PEOPLE[:3]) + work=[params for q,params in tagged_projects.calls if q==gs.WORK_FACTS_QUERY][0] + tags=[params for q,params in tagged_projects.calls if q==gs.PERSON_KEYWORD_FACTS_QUERY][0] + assert work['years']==[2026] and work['work_types']==['diploma_project'] + assert tags['keyword_ids']==['krag'] + context=graph_context(r) + assert {c['person'] for c in context['answer_candidates']}==set(PEOPLE[:3]) + assert all(c['work_type']=='diploma_project' for c in context['answer_candidates']) + + +def test_diploma_thesis_cannot_be_replaced_by_a_project(tagged_projects): + r=gs.search_graph_with_session(tagged_projects, + 'Ktorí študenti majú tag RAG a zároveň diplomovú prácu z roku 2026?',limit=50) + assert r['status']=='no_answer' and r['reason']=='no_matching_facts' + assert not r['facts'] and not r['constraints']['unresolved_keyword'] + + +def test_unknown_tag_cannot_trigger_a_global_project_listing(tagged_projects): + r=gs.search_graph_with_session(tagged_projects, + 'Ktorí študenti majú tag XNEZNAMY a zároveň diplomový projekt z roku 2026?') + assert r['status']=='no_answer' and r['reason']=='unknown_keyword' and not r['facts'] + + +@pytest.mark.parametrize('question,expected',[ + ('Ľudia s tagom strojové učenie a zároveň diplomovou prácou.','strojove ucenie'), + ('Ľudia s tagom „výskum a aplikácie“ a zároveň diplomovým projektom.','vyskum a aplikacie'), + ('Ľudia s tagom RAG z roku 2026.','rag'), +]) +def test_multiword_tag_and_condition_boundary(question,expected): + assert gs.explicit_keyword_phrase(question)==expected + + +@pytest.mark.parametrize('question,code',[ + ('Ktorí študenti majú aspoň tri práce?','quantity_condition'), + ('Ktorí študenti majú aspoň 3 práce?','quantity_condition'), + ('Kto má najmenej tri práce?','quantity_condition'), + ('Študenti s najviac dvoma projektmi?','quantity_condition'), + ('Študenti s minimálne piatimi prácami?','quantity_condition'), + ('ktorý študenti týmto tempom ukončia svoje diplomové práce v roku 2027?','completion_prediction'), + ('Ktorí študenti ukončia diplomové práce v roku 2027?','completion_prediction'), + ('Odhadni termín dokončenia diplomovej práce Jána Holpa.','completion_prediction'), + ('Kedy Ján Holp ukončil diplomovú prácu?','work_event_date'), + ('Aké metódy predikcie počasia používa diplomová práca?',None), + ('Aký je názov práce „Kto dokončí prácu v roku 2027“?',None), +]) +def test_unsupported_condition_wording(question,code): + assert unsupported_constraint(question)==code + + +@pytest.mark.parametrize('engine',['rag','graphrag']) +@pytest.mark.parametrize('question,word',[ + ('Ktorí študenti majú aspoň tri práce?','počet'), + ('Ktorí študenti týmto tempom ukončia diplomové práce v roku 2027?','predpovedať'), +]) +def test_unsupported_condition_never_calls_a_model(engine,question,word): + r=deliver_answer(question,engine=engine,complete=lambda _:pytest.fail('No model for unsupported condition')) + assert r['status']=='ambiguous' and r['accepted_for_delivery'] and word in r['answer'] + assert not r['claims'] and not r['source_urls'] + + +def selected(context): + fields=['person','record_id','title','year','work_type','work_label','keyword','category','document_author'] + rows=context.get('required_academic_records') or context['facts'] + claims=[{k:r[k] for k in fields if r.get(k) is not None} for r in rows] + return {'status':'ok','answer':'','claims':claims, + 'source_urls':[s['source_url'] for s in context['sources']]} + + +def generated(answer,context): + return generate_checked_answer(context.get('question',''),context,PROMPT, + lambda _: {'choices':[{'message':{'content':json.dumps(answer)}}]}) + + +def test_titleless_records_survive_and_are_delivered_without_invented_titles(): + catalog=[person(1,'Ján Holp')] + records=[{'person':'Ján Holp','person_element_id':'p1','work_id':f'project-{i}', + 'title':None,'heading':label,'work_type':'diploma_project','year':2020,**source(1)} + for i,label in enumerate(['Diplomový projekt 2020','Diplomový projekt 2 2020'])] + s=FakeSession({gs.CATALOG_QUERY:catalog,gs.WORK_FACTS_QUERY:records}) + raw=gs.search_graph_with_session(s,'Aký názov má diplomový projekt Jána Holpa z roku 2020?') + assert raw['status']=='ok' and len(raw['facts'])==2 + context=graph_context(raw);a=selected(context) + result=generated(a,context) + assert result['accepted_for_delivery'] + assert result['answer']['answer'].count('názov v zdroji nie je uvedený')==2 + assert len(result['answer']['claims'])==2 and not any(c.get('title') for c in result['answer']['claims']) + bad=copy.deepcopy(a);bad['claims'][0]['title']='Vymyslený názov' + assert 'unsupported_academic_binding' in answer_context_issues(bad,context,check_text=False) + bad=copy.deepcopy(a);bad['claims'].pop() + assert 'missing_academic_record_details' in answer_context_issues(bad,context,check_text=False) + + +def shared_context(three=False): + people=PEOPLE[:3] if three else PEOPLE[:2] + raw={'status':'ok','reason':None,'complete':True,'truncated':False,'aggregation':None, + 'query':'Aké spoločné kľúčové slová majú '+' a '.join(people)+'?', + 'constraints':gs.extract_query_constraints('spoločné kľúčové slová'), + 'resolved_entities':[person(i,p) for i,p in enumerate(people,1)], + 'sources':[source(i) for i in range(1,len(people)+1)],'facts':[]} + for keyword,left,right in [('NLP',1,2),('RAG',1,2)]+([('NLP',1,3)] if three else []): + raw['facts'].append({'fact_type':'shared_keyword','keyword':keyword, + 'left_person':people[left-1],'right_person':people[right-1], + 'left_document_path':source(left)['document_path'],'right_document_path':source(right)['document_path'], + 'left_source_url':source(left)['source_url'],'right_source_url':source(right)['source_url']}) + return graph_context(raw) + + +def test_shared_tags_are_summarized_once_and_require_all_peoples_evidence(): + context=shared_context();a=selected(context) + result=generated(a,context) + assert result['accepted_for_delivery'] + text=result['answer']['answer'] + assert 'Spoločné kľúčové slová: **NLP**, **RAG**.' in text + assert text.count('NLP')==1 and text.count('RAG')==1 and 'Pri osobe' not in text + bad=copy.deepcopy(a);bad['claims'].pop() + assert 'missing_shared_property_evidence' in answer_context_issues(bad,context,check_text=False) + + +def test_pairwise_shared_tag_does_not_mean_shared_by_everyone(): + context=shared_context(three=True) + assert {r['value'] for r in context['shared_properties']}=={'NLP'} + text=render_graph_answer(selected(context),context)['answer'] + assert 'Spoločné kľúčové slová: **NLP**.' in text and '**RAG**' not in text + + +def test_incomplete_shared_tags_cannot_claim_exhaustive_results(): + context=shared_context();context.update(complete=False,truncated=True,truncation_reasons=['fact_limit']) + result=generated(selected(context),context) + assert result['accepted_for_delivery'] + assert 'Doložené spoločné kľúčové slová' in result['answer']['answer'] + assert 'Výpis je neúplný.' in result['answer']['answer'] diff --git a/tools/answer_evaluation/context_policy.py b/tools/answer_evaluation/context_policy.py index c92c06c..54b98f9 100644 --- a/tools/answer_evaluation/context_policy.py +++ b/tools/answer_evaluation/context_policy.py @@ -76,6 +76,8 @@ def joined_candidates(facts: list[dict], intent: dict) -> list[dict]: continue fields = ['person', 'title', 'year'] if needs_title else ['person', 'year'] candidate={k:record[k] for k in fields if record.get(k) is not None} + if record.get('work_type'): + candidate['work_type']=record['work_type'] candidate['keyword']=tag['keyword'] candidate['evidence_documents']=sorted({p for p in [record.get('document_path'),tag.get('document_path')] if p}) candidate['source_urls']=sorted({u for u in [record.get('source_url'),tag.get('source_url')] if u}) @@ -220,11 +222,16 @@ def answer_context_issues(answer: dict, context: dict, *, check_text: bool = Tru for claim in claims): issues.append('missing_academic_record_details') break + for shared in context.get('shared_properties',[]): + for person in shared['people']: + if not any(normalized(c.get('person'))==normalized(person) and + normalized(c.get(shared['field']))==normalized(shared['value']) for c in claims): + issues.append('missing_shared_property_evidence') candidates=context.get('answer_candidates',[]) if context.get('status')=='ok' and candidates: if answer.get('status')=='no_answer': issues.append('rejected_supported_person_join') - elif not all(any(all(normalized(claim.get(k))==normalized(candidate[k]) for k in ['person','title','year','keyword'] if k in candidate) + elif not all(any(all(normalized(claim.get(k))==normalized(candidate[k]) for k in ['person','title','year','keyword','work_type'] if k in candidate) for claim in answer.get('claims',[])) for candidate in candidates): issues.append('split_or_missing_person_join') if context.get('status') in {'no_answer','ambiguous','error'} and answer.get('status')=='ok': diff --git a/tools/answer_evaluation/graph_answer_prompt.txt b/tools/answer_evaluation/graph_answer_prompt.txt index b7709e3..59b2818 100644 --- a/tools/answer_evaluation/graph_answer_prompt.txt +++ b/tools/answer_evaluation/graph_answer_prompt.txt @@ -6,6 +6,7 @@ Title je názov akademickej práce/projektu, nikdy názov profilovej stránky al Všetky podmienky jednej claim patria tej istej osobe. Title a year patria rovnakému akademickému záznamu. Category a document_author musia byť doložené tým istým document_path. Tag patrí osobe cez jej profil aj dcérske dokumenty. Pre kombináciu názvu/roku a tagu nevyžaduj rovnaký dokument. Answer_candidates sú už doložené spojenia cez tú istú osobu; zachovaj ich person, year, keyword a prípadne title spolu v jednej claim. Neodmietaj toto spojenie len pre odlišné document_path. Claims musia pokryť všetky relevantné výsledky dotazu a požadované väzby. Pri porovnaní ľudí uveď každého v samostatnej claim. Pri výbere kandidátov vynechaj nevyhovujúce osoby. Pri názve práce uveď aj rok, ak je vo faktoch známy. +Ak sú dodané shared_properties, každú spoločnú hodnotu dolož claim pre každú uvedenú osobu. Text spoločného výsledku zostaví aplikácia. Pri answer_candidates zachovaj aj work_type, ak je dodaný: diplomový projekt a diplomová práca sú odlišné typy. Required_academic_records môžu zahŕňať aj záznamy bez názvu pri otázke na názov; zachovaj ich známy typ, rok a identitu, ale názov nevymýšľaj. Academic_year_comparison opisuje spoločné roky všetkých požadovaných osôb, nie zjednotenie ich rokov. Pri dodaných required_academic_records zachovaj aj typy, názvy a identity prác z týchto rokov. Nepridávaj práce z iných rokov. Source_urls musí obsahovať konkrétne relevantné source_url zo sources pre všetky tvrdenia. Nevymýšľaj ani neupravuj URL. Ak názov/rok dokladá profil a tag dcérsky dokument, cituj oba potrebné zdroje. Nepoužívaj URL iných osôb ako náhradnú citáciu. Count môže byť číslo iba pri aggregation.complete=true a musí sa rovnať aggregation.count. Inak count=null, aj pri no_answer. Počet nie je počet zobrazených zdrojov. Presný agregovaný počet môže byť úplný aj pri orezanom výpise dôkazov. diff --git a/tools/answer_evaluation/graph_context.py b/tools/answer_evaluation/graph_context.py index 51e8ced..3b64fe9 100644 --- a/tools/answer_evaluation/graph_context.py +++ b/tools/answer_evaluation/graph_context.py @@ -10,7 +10,10 @@ def graph_context(result: dict) -> dict: """ facts = []; intent = result['constraints'] shared_year_query = intent.get('mentions_shared') and intent.get('mentions_academic_year') - work_listing = intent.get('mentions_work_listing', False) or shared_year_query + work_listing = (intent.get('mentions_work_listing', False) or shared_year_query or + (intent.get('mentions_title') and any( + row.get('fact_type') in {'person_thesis','person_work'} and not row.get('title') + for row in result['facts']))) academic_records = [] for row in result['facts']: kind = row.get('fact_type', '') @@ -33,6 +36,10 @@ def graph_context(result: dict) -> dict: if intent['mentions_start_year']: allowed.add('start_year') fact = {k: v for k, v in row.items() if k in allowed and v is not None} + if (intent.get('thesis_types') or intent.get('work_types')) and kind in {'person_thesis','person_work'}: + work_type=row.get('thesis_type') or row.get('work_type') + if work_type: + fact['work_type']=work_type if work_listing and kind in {'person_thesis', 'person_work'}: # Stable source record identity distinguishes two untitled projects # by the same person in the same year. Never use Neo4j element IDs. @@ -57,6 +64,14 @@ def graph_context(result: dict) -> dict: context['clarification'] = result.get('clarification') context['requested_people'] = list(dict.fromkeys(row['name'] for row in result.get('resolved_entities', []) if row.get('kind') == 'Person' and row.get('name'))) + shared_values={} + for fact in facts: + for field in ['keyword','category','document_author']: + if intent.get('mentions_shared') and fact.get(field) is not None: + shared_values.setdefault((field,fact[field]),set()).add(fact['person']) + people=context['requested_people'] + context['shared_properties']=[{'field':field,'value':value,'people':people} + for (field,value),witnesses in shared_values.items() if len(people)>=2 and set(people)<=witnesses] comparison = result.get('academic_year_comparison') or next((row for row in result['facts'] if row.get('fact_type') == 'academic_year_comparison'), None) if comparison is not None: diff --git a/tools/answer_evaluation/render_answer.py b/tools/answer_evaluation/render_answer.py index dfbedd6..b724015 100644 --- a/tools/answer_evaluation/render_answer.py +++ b/tools/answer_evaluation/render_answer.py @@ -10,6 +10,16 @@ def render_graph_answer(answer: dict, context: dict) -> dict: out = deepcopy(answer) lines = [] if out['status'] == 'ok': + shared_fields=set() + labels={'keyword':'kľúčové slová','category':'kategórie','document_author':'autori dokumentov'} + for field,label in labels.items(): + values=sorted({row['value'] for row in context.get('shared_properties',[]) if row['field']==field + and all(any(c.get('person')==person and c.get(field)==row['value'] for c in out['claims']) + for person in row['people'])}) + if values: + shared_fields.add(field) + prefix='Spoločné ' if context.get('complete') is True and not context.get('truncated') else 'Doložené spoločné ' + lines.append(prefix+label+': '+', '.join('**'+v+'**' for v in values)+'.') comparison = context.get('academic_year_comparison') if comparison: people = comparison.get('people', []) @@ -27,7 +37,9 @@ def render_graph_answer(answer: dict, context: dict) -> dict: if claim.get('record_id'): records.setdefault(claim['person'], []).append(claim) else: - lines.append(fact_sentences(claim, context.get('question', ''))) + shown={key:value for key,value in claim.items() if key not in shared_fields} + if any(key!='person' and value is not None for key,value in shown.items()): + lines.append(fact_sentences(shown, context.get('question', ''))) index = 0 for person, claims in records.items(): lines.extend([f'{person} má v dostupných podkladoch tieto práce a projekty:', '']) diff --git a/tools/graphrag/graph_search.py b/tools/graphrag/graph_search.py index 0bc47d3..2298c0f 100644 --- a/tools/graphrag/graph_search.py +++ b/tools/graphrag/graph_search.py @@ -725,6 +725,13 @@ def has_stem( def explicit_keyword_phrase(query: str) -> str | None: """Identify a requested tag without treating 'ZP Wiki' as the tag ZP.""" + quoted = re.search( + r'\b(?:kľúčové\s+slovo|kľúčovým\s+slovom|kľúčového\s+slova|' + r'klucove\s+slovo|klucovym\s+slovom|klucoveho\s+slova|tagom|tagu|tag)' + r'\s+[„“"«]([^„“”"»]+)[“”"»]', query, flags=re.I, + ) + if quoted: + return normalize_for_match(quoted[1]) match = re.search( r"\b(?:klucove\s+slovo|klucovym\s+slovom|klucoveho\s+slova|" r"tagom|tagu|tag)\s+(.+)", @@ -733,7 +740,9 @@ def explicit_keyword_phrase(query: str) -> str | None: if not match: return None phrase = re.split( - r"\s+(?:pri\s+osob\w*|u\s+osob\w*|a\s+(?:jej|jeho|ich|rok|pomenuj|uved\w*)|ak\s+|uved\w*\s+|" + r"\s+(?:pri\s+osob\w*|u\s+osob\w*|a\s+(?:zaroven|sucasne|diplomov\w*|bakalarsk\w*|" + r"dizertacn\w*|timov\w*|vyskumn\w*|projekt\w*|prac\w*|jej|jeho|ich|rok|pomenuj|uved\w*)|" + r"zaroven\b|sucasne\b|(?:v|z|pre)\s+rok\w*\b|ak\s+|uved\w*\s+|" r"pomenuj\s+|maju\s+|ma\s+)", match.group(1), maxsplit=1, @@ -1241,7 +1250,7 @@ def requested_name_phrases(query: str) -> list[str]: ) 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'(?:^|\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] @@ -1263,6 +1272,7 @@ def requested_name_phrases(query: str) -> list[str]: 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) )) @@ -2448,7 +2458,7 @@ def search_graph_with_session( ) keyword_specific = bool( not shared_keyword and ( - (keyword_ids and not (thesis_specific or work_specific)) + keyword_ids or (person_ids and constraints["mentions_keyword"]) ) ) @@ -2488,7 +2498,9 @@ def search_graph_with_session( if constraints["mentions_count"] and not (thesis_specific or work_specific): blocked_reasons.append("unsupported_count_domain") # A quoted unknown work title is a failed exact lookup, not a global listing. - quoted_titles = re.findall(r'[„“"«]([^„“”"»]+)[“”"»]', clean_query) + quoted_titles = [match[1] for match in re.finditer(r'[„“"«]([^„“”"»]+)[“”"»]', clean_query) + if not re.search(r'\b(?:tag|tagom|tagu|klucov\w*\s+slov\w*)$', + normalize_for_match(clean_query[:match.start()]))] if quoted_titles and (thesis_specific or work_specific) and not thesis_ids and not work_ids: blocked_reasons.append("unknown_title") constraints["unresolved_title"] = True @@ -2509,7 +2521,7 @@ def search_graph_with_session( work_rows = fetch(work_specific, WORK_FACTS_QUERY, work_ids=work_ids, years=constraints["years"], work_types=constraints["work_types"]) - if title_specific and not constraints["mentions_work_listing"]: + if title_specific and not constraints["mentions_work_listing"] and not person_ids: thesis_rows = [row for row in thesis_rows if row.get("title")] work_rows = [row for row in work_rows if row.get("title")] start_rows = fetch(constraints["mentions_start_year"], START_YEAR_FACTS_QUERY, diff --git a/tools/graphrag/tests/test_evaluation_wide.py b/tools/graphrag/tests/test_evaluation_wide.py index 3941832..1602682 100644 --- a/tools/graphrag/tests/test_evaluation_wide.py +++ b/tools/graphrag/tests/test_evaluation_wide.py @@ -80,13 +80,17 @@ def test_last_leading_title_wins_over_introductory_description(): assert extract_academics(doc)[0]['title'] == 'Generovanie jazyka s podporou vyhľadávania' -def test_title_lookup_does_not_claim_untitled_work_as_an_answer(): +def test_title_lookup_preserves_known_record_without_claiming_a_title(): fact = {'person_element_id': 'p2', 'person': 'Jakub Maruniak', 'thesis_element_id': 'th1', 'year': 2024, 'title': None, 'thesis_type': 'diploma_thesis'} session = FakeSession({gs.CATALOG_QUERY: sample_catalog(), gs.THESIS_FACTS_QUERY: [fact]}) result = gs.search_graph_with_session(session, 'Aký názov práce má osoba Jakub Maruniak v roku 2024?') - assert result['status'] == 'no_answer' - assert result['facts'] == [] + # A missing title must not erase the independently known work/year. The + # delivery regression checks the explicit "title missing" presentation. + assert result['status'] == 'ok' + assert len(result['facts']) == 1 + assert result['facts'][0]['title'] is None + assert result['facts'][0]['year'] == 2024 def test_explicit_name_precedes_a_different_topic_and_link_title_is_preserved(): diff --git a/tools/retrieval/question_constraints.py b/tools/retrieval/question_constraints.py index 03f05d8..66fe859 100644 --- a/tools/retrieval/question_constraints.py +++ b/tools/retrieval/question_constraints.py @@ -14,13 +14,23 @@ def unsupported_constraint(question: str) -> str | None: if not unicodedata.combining(c)) # Do not treat words inside quoted academic titles as query operators. text = re.sub(r'[„“"«][^„“”"»]+[“”"»]', ' ', text) + academic = re.search(r'\b(?:prac\w*|projekt\w*)\b',text) + completion = re.search(r'\b(?:ukonc\w*|dokonc\w*|obhaj\w*|odovzd\w*|stihn\w*)\b',text) + if academic and completion and (re.search(r'\b(?:tymto tempom|predpoved\w*|predik\w*|stihne|stihnu)\b',text) or + re.search(r'\b(?:ukonci|ukoncia|dokonci|dokoncia|obhaji|obhaja)\b',text) or + (re.search(r'\bodhad\w*\b',text) and + re.search(r'\b(?:ukonc\w*|dokonc\w*|obhaj\w*)\b',text))): + return 'completion_prediction' if (re.search(r'\b(?:rok\w*|datum\w*|kedy)\b',text) and - re.search(r'\b(?:obhajob\w*|obhajil\w*|dokonc\w*|ukoncil\w*|zacal\w* pracovat)\b',text) and - re.search(r'\b(?:prac\w*|projekt\w*)\b',text)): + re.search(r'\b(?:obhajob\w*|obhajil\w*|dokonc\w*|ukonc\w*|zacal\w* pracovat)\b',text) and academic): return 'work_event_date' if re.search(r'\b(?:od|do|pred|po|medzi)\s+(?:rok(?:om|mi|u)?\s+)?\d{4}\b|\b\d{4}\s*[-–]\s*\d{4}\b', text): return 'year_range' - if re.search(r'\b(?:aspon|najviac|viac ako|menej ako|minimalne|maximalne)\s+\d+', text): + number = (r'(?:\d+|jeden|jedna|jedno|jednu|jednej|jedneho|dva|dve|dvoch|dvom|dvoma|' + r'tri|troch|trom|troma|styri|styroch|styrom|pat\w*|piat\w*|sest\w*|siest\w*|' + r'sedem\w*|siedm\w*|osem\w*|osm\w*|devat\w*|deviat\w*|desat\w*|desiat\w*|' + r'jedenast\w*|dvanast\w*|trinast\w*|strnast\w*|dvadsat\w*)') + if re.search(r'\b(?:aspon|najmenej|najviac|viac ako|menej ako|minimalne|maximalne)\s+'+number+r'\b', text): return 'quantity_condition' if re.search(r'\b(?:okrem|s vynimkou)\b|\b(?:nema|nemaju)\s+(?:tag|klucov|kategori|prac|projekt)|' r'\bbez\s+(?:tagu|klucoveho slova|kategorie)\b', text): @@ -32,6 +42,8 @@ def unsupported_constraint(question: str) -> str | None: def constraint_message(code: str) -> str: return { + 'completion_prediction':'Z dostupných poznámok neviem spoľahlivo predpovedať, kto a kedy dokončí prácu. ' + 'Môžem vyhľadať práce evidované v konkrétnom roku; to nie je odhad ich dokončenia.', 'work_event_date':'Rok uvedený pri práci nepreukazuje dátum jej začatia, dokončenia alebo obhajoby. ' 'Chceš zistiť rok evidovaný pri práci?', 'year_range': 'Časovú podmienku neviem spoľahlivo vyhodnotiť ako interval. Uveď konkrétny rok práce.',