Rozpoznávanie mien
This commit is contained in:
parent
49c8159e2a
commit
4eaf74c529
@ -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):
|
||||
|
||||
4
packages/claude/dist/packages.json
vendored
4
packages/claude/dist/packages.json
vendored
@ -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
|
||||
|
||||
BIN
packages/claude/dist/zpwiki-graphrag.zip
vendored
BIN
packages/claude/dist/zpwiki-graphrag.zip
vendored
Binary file not shown.
BIN
packages/claude/dist/zpwiki-rag.zip
vendored
BIN
packages/claude/dist/zpwiki-rag.zip
vendored
Binary file not shown.
@ -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
|
||||
|
||||
@ -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. '
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
241
test/test_person_names.py
Normal file
241
test/test_person_names.py
Normal file
@ -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**')
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
259
tools/retrieval/person_names.py
Normal file
259
tools/retrieval/person_names.py
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user