Preserve verified conversation references across RAG and GraphRAG

This commit is contained in:
Ján Pták 2026-11-10 01:53:33 +01:00
parent 787cb6dfa3
commit 660f4d9ef9
22 changed files with 1836 additions and 60 deletions

View File

@ -162,6 +162,7 @@ class AnswerRequest(StrictRequestModel):
engine: Literal["rag", "graphrag"] = "rag"
limit: int = Field(default=5, ge=1, le=20)
include_diagnostics: bool = False
context_token: str | None = Field(default=None,min_length=1,max_length=16000)
@router.post("/answer", dependencies=[Depends(require_search_api_key)], include_in_schema=False)
@ -172,6 +173,7 @@ def verified_answer(request: AnswerRequest) -> dict:
engine=request.engine,
limit=request.limit,
include_diagnostics=request.include_diagnostics,
**({'context_token':request.context_token} if request.context_token else {}),
)
except Exception:
logger.exception("Príprava overenej odpovede zlyhala.")

File diff suppressed because it is too large Load Diff

View File

@ -93,6 +93,10 @@ class Config:
def error_result(reason):
messages = {
'invalid_context':'Kontext predchádzajúcej otázky sa nepodarilo overiť. Zopakuj úplnú otázku s osobou, prácou a prípadným dátumom.',
'stale_context':'Dáta alebo kontext konverzácie sa zmenili. Zopakuj úplnú otázku; starý výber stretnutia nepoužijem.',
'expired_context':'Kontext predchádzajúcej otázky už vypršal. Zopakuj úplnú otázku.',
'proposal_not_available':'Otázka žiada návrh možného prepojenia, nie iba existujúce fakty. Návrhový režim ešte nie je dostupný; výpis podkladov preto nevydávam za hotový návrh.',
"reference_unavailable": "Zdrojový repozitár sa nepodarilo verejne overiť. Odpoveď založenú iba na tomto odkaze neuvádzam.",
"unauthorized": "Prístup nebol autorizovaný. Skontroluj prihlásenie konektora a jeho konfiguráciu.",
"forbidden": "Pre tento nástroj nemáš potrebné oprávnenie.",
@ -188,7 +192,7 @@ def validate_metadata(value):
raise ClientError("invalid_backend_response")
def query_backend(query: str, engine: str, *, mode="answer", limit=5, config=None):
def query_backend(query: str, engine: str, *, mode="answer", limit=5, config=None, context_token=None):
"""Return only approved answer fields, or explicitly marked retrieval context."""
try:
if (
@ -208,8 +212,11 @@ def query_backend(query: str, engine: str, *, mode="answer", limit=5, config=Non
if not 1 <= len(query) <= 500:
raise ClientError("invalid_query")
config = config or Config.load()
if context_token is not None and (mode!='answer' or not isinstance(context_token,str) or not 1<=len(context_token)<=16000):
raise ClientError('invalid_query')
endpoint = "/answer" if mode == "answer" else "/" + engine
body = {"query": query.strip(), "limit": limit}
if context_token is not None:body['context_token']=context_token
if mode == "answer" or engine == "rag":
body["include_diagnostics"] = False
if mode == "answer":
@ -223,6 +230,7 @@ def query_backend(query: str, engine: str, *, mode="answer", limit=5, config=Non
# Raw model output, diagnostics and rejected text must not escape.
reason = value.get("reason")
safe_reasons = {
'invalid_context','stale_context','expired_context','proposal_not_available',
"model_unavailable",
"answer_capacity_exceeded",
"answer_rejected",
@ -277,6 +285,14 @@ def query_backend(query: str, engine: str, *, mode="answer", limit=5, config=Non
raise ClientError("invalid_backend_response")
if "knowledge_version" in value and not isinstance(value["knowledge_version"], str):
raise ClientError("invalid_backend_response")
if 'conversation_context' in value:
context=value['conversation_context']
if (not isinstance(context,dict) or not isinstance(context.get('token'),str)
or not 1<=len(context['token'])<=16000
or any(not isinstance(context.get(k),list) for k in ['work_options','meeting_options'])):
raise ClientError('invalid_backend_response')
if 'resolved_query' in value and not isinstance(value['resolved_query'],str):
raise ClientError('invalid_backend_response')
fields |= {
"query",
"answer",
@ -287,6 +303,7 @@ def query_backend(query: str, engine: str, *, mode="answer", limit=5, config=Non
"answer_coverage",
}
fields.add("knowledge_version")
fields |= {'conversation_context','resolved_query'}
else:
if not isinstance(value.get("sources"), list):
raise ClientError("invalid_backend_response")
@ -359,6 +376,7 @@ def answer_for_presentation(result: dict) -> dict:
Never change an incomplete result into a claim of completeness.
"""
fields = {
'conversation_context','resolved_query',
"status",
"reason",
"query",
@ -381,11 +399,12 @@ def main(engine=None):
parser.add_argument("--mode", choices=["answer", "retrieve"], default="answer")
parser.add_argument("--limit", type=int, default=5)
parser.add_argument("--health", action="store_true")
parser.add_argument('--context-token',help='Opaque conversation token from this chat\'s previous tool result')
args = parser.parse_args()
result = (
backend_health()
if args.health
else query_backend(args.query, engine or args.engine, mode=args.mode, limit=args.limit)
else query_backend(args.query, engine or args.engine, mode=args.mode, limit=args.limit, context_token=args.context_token)
)
if not args.health and args.mode == "answer":
result = answer_for_presentation(result)

View File

@ -3,12 +3,12 @@
{
"archive": "zpwiki-rag.zip",
"name": "rag",
"sha256": "c7a207660004c1e4e7ae7d507006df231c666d13c53067bd5896a022ef44b64d"
"sha256": "f55d72cdf1bb751807221ade986ac582098ee41c3e49a2ac3b291742935f13f2"
},
{
"archive": "zpwiki-graphrag.zip",
"name": "graphrag",
"sha256": "1f340a0298625b7dd5f427e8501dde8ec781cc8d72e7a4f50b9ad4b21de6b1fa"
"sha256": "78ec35715f31a2be7ca5c155506a499415fa49e644ed6309ea680bb46ab943f8"
}
],
"schema_version": 1

Binary file not shown.

Binary file not shown.

View File

@ -20,9 +20,17 @@ Send misspelled, unaccented or reversed names unchanged: the backend resolves th
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.
For a follow-up such as "v danom stretnutí", "vtedy", "pri tej druhej práci" or "daj obe",
pass the user's current question unchanged in `query` and copy `conversation_context.token`
from this chat's immediately preceding tool result into optional `context_token`.
The backend resolves and validates the source identities, exact work and meeting against the
same index version. Do not decode, invent or modify the token, use another chat's token,
or replace it with retrieved wiki text. Never send a token for an unrelated standalone question.
If no token is available, ask for a complete question with the person, work and meeting date;
do not guess the missing date or choose one of several previous works/meetings yourself.
For standalone questions omit `context_token`. Compare returned `query` to the exact current
user question; `resolved_query` records only the backend-validated interpretation.
Never present the token or conversation_context as part of the answer.
The content graph also supports progress, dated outcomes and similar-topic questions when its
extraction is ready. Preserve any backend guidance instead of inventing graph evidence.
Explicit source-backed supervisors, consultants, meeting records and completion outcomes are also
@ -34,10 +42,6 @@ dated meetings. Do not infer missing years or today's status from an old source
Do not turn a task into a result, a work year into a meeting date, or shared topics into collaboration.
For multiple explicit commands in one message, handle each question separately in order;
keep all conditions belonging to each question together.
For a follow-up omitting a person or supervisor, reconstruct the full question only when
that subject is unambiguous in the immediately preceding exchange. Preserve the new year
and every remaining condition. Otherwise ask for the name. Document authorship does not
establish supervision.
1. With a ZP Wiki MCP connector, load its current tool definition before calling it.
When tools are deferred, use the host's tool search (for example `tool_search`) to find
@ -50,6 +54,7 @@ establish supervision.
run `python scripts/query.py "the complete question"` relative to this directory.
Keep every condition in the same query (maximum 500 characters): names, work type,
year, title, keyword, category and document author have different meanings.
For script follow-ups, add `--context-token` with the unchanged token from the previous result.
3. Default mode calls `/answer`. If `accepted_for_delivery=true`, present `answer`
unchanged, including citations and completeness notices. Add no new factual claims.
Claude must follow this presentation rule; the skill itself cannot enforce the UI output.

View File

@ -24,18 +24,15 @@ QUESTION_POLICY = (
"Use the exact loaded tool identifier and schema. For zpwiki_rag and zpwiki_graphrag, "
"the question belongs in the query argument, never an argument named question. "
"A tool-not-loaded or invalid-argument error is a host invocation error, not a DNS failure. "
"For a follow-up omitting a person or supervisor, inherit the subject only from an unambiguous "
"immediately preceding exchange and reconstruct one full question with the new year and all constraints. "
"Otherwise ask for the name. Never infer supervision from document authorship. "
"For a complete question, copy the latest user question verbatim after removing only its leading "
"/rag or /graphrag command. Keep names, work types, years, negations, comparisons and every condition. "
"Never reuse a previous question or an example, paraphrase it or split its conditions across searches. "
"If the user supplies multiple explicit commands, handle each exact question separately in order. "
"For a short reply selecting options from the immediately preceding clarification, reconstruct the original "
"full question and apply only that selection. Preserve its person, year, work type and requested aspects. "
"Use exact quoted work headings for a selected work; if the user chooses both, ask about both works in one full query. "
"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. "
"For any follow-up, keep the current query verbatim and pass conversation_context.token from this chat's "
"immediately preceding tool response as context_token. Never decode, edit, fabricate or reuse another chat's token. "
"The backend validates and resolves the person, work and exact meeting; resolved_query is its audit field. "
"Without that token, ask for the full person/work/date rather than guessing a referent. "
"For standalone questions omit context_token. Never display conversation_context in the answer. "
"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. "
@ -79,26 +76,30 @@ def tool_result(result):
)
async def zpwiki_rag(query: str, limit: int = 5) -> CallToolResult:
async def zpwiki_rag(query: str, limit: int = 5, context_token: str | None = None) -> CallToolResult:
"""Verified ZP Wiki text answer. Keep the full question, names, work type and year.
Pass the full question in query, not question. Optional limit is an integer.
For a follow-up copy conversation_context.token from the last result into context_token.
Present the accepted answer with its citations; add no guesses or history commentary.
"""
return tool_result(
answer_for_presentation(await asyncio.to_thread(query_backend, query, "rag", limit=limit))
answer_for_presentation(await asyncio.to_thread(query_backend, query, "rag", limit=limit,
**({'context_token':context_token} if context_token is not None else {})))
)
async def zpwiki_graphrag(query: str, limit: int = 5) -> CallToolResult:
async def zpwiki_graphrag(query: str, limit: int = 5, context_token: str | None = None) -> CallToolResult:
"""Verified graph answer: relationships, related topics, progress, dated outcomes, years and counts in ZP Wiki.
Pass the full question in query, not question. Optional limit is an integer.
For a follow-up copy conversation_context.token from the last result into context_token.
Present the accepted answer with its citations; no_answer means no supporting evidence.
"""
return tool_result(
answer_for_presentation(
await asyncio.to_thread(query_backend, query, "graphrag", limit=limit)
await asyncio.to_thread(query_backend, query, "graphrag", limit=limit,
**({'context_token':context_token} if context_token is not None else {}))
)
)

View File

@ -24,16 +24,17 @@ Send misspelled, unaccented or reversed names unchanged: the backend resolves th
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
works in one query and keep their evidence separate. Do not search the words "both" alone.
If the preceding question/options are unavailable, ask for them rather than guessing.
Compare the returned `query` against this resolved full question in that case.
For a follow-up that omits the person or supervisor (for example "but which ones in 2026?"),
carry over that subject only when it is unambiguous in the immediately preceding exchange.
Send one complete question preserving the new year and all remaining conditions. If the subject
is unclear, ask for the name; never send a subjectless search or infer supervision from authorship.
For a follow-up such as "v danom stretnutí", "vtedy", "pri tej druhej práci" or "daj obe",
pass the user's current question unchanged in `query` and copy `conversation_context.token`
from this chat's immediately preceding tool result into optional `context_token`.
The backend resolves and validates the source identities, exact work and meeting against the
same index version. Do not decode, invent or modify the token, use another chat's token,
or replace it with retrieved wiki text. Never send a token for an unrelated standalone question.
If no token is available, ask for a complete question with the person, work and meeting date;
do not guess the missing date or choose one of several previous works/meetings yourself.
For standalone questions omit `context_token`. Compare returned `query` to the exact current
user question; `resolved_query` records only the backend-validated interpretation.
Never present the token or conversation_context as part of the answer.
For multiple explicit commands in one message, handle each question separately in order;
keep all conditions belonging to each question together.
@ -48,6 +49,7 @@ keep all conditions belonging to each question together.
`python scripts/query.py "the complete question"` relative to this skill's directory.
The question limit is 500 characters. Preserve names, work type, year and all constraints.
Do not split a combined factual condition across independent searches.
For script follow-ups, add `--context-token` with the unchanged token from the previous result.
3. Default mode requests `/answer`. When `accepted_for_delivery=true`, present its `answer`
unchanged as the verified answer. Do not add factual claims or replace its citations.
This is a presentation instruction, not a guarantee enforced by the Claude interface.

View File

@ -0,0 +1,245 @@
"""Multi-turn reference resolution with real source IDs, no paid model calls."""
import copy
import json
import time
import pytest
from tools.answers.conversation import issue_context,resolve_question,seal,unseal,ContextError
from tools.answers.question_plan import build_question_plan,QuestionPlan,MeetingRequest,validate_model_plan,PlanRejected
from tools.knowledge.sources import profile_units
from tools.knowledge.academic import answer_academic
CATALOG=[{'name':'Jakub Schwarc','kind':'Person','source_document':'pages/students/2022/jakub_schwarc/README.md'},
{'name':'Ján Holp','kind':'Person','source_document':'pages/students/2016/jan_holp/README.md'}]
VERSION={'version':'v1'}
@pytest.fixture
def data(monkeypatch):
monkeypatch.setenv('SEARCH_API_KEY','context-fixture-only-key')
a=profile_units({'title':'Jakub Schwarc','path':CATALOG[0]['source_document'],'content':
'# Diplomová práca 2027\nStretnutie 5.11.2026\nStav:\n- Napísané skripty na vyhodnotenie.\n'
'Úlohy:\n- Spustite experiment na HPC.\nStretnutie 1.10.2026\nStav:\n- Porovnané modely.\n'
'# Diplomový projekt 2026\nStretnutie 10.6.2026\nStav:\n- Vyskúšaný starší model.\n'
'Stretnutie 27.2.\nStav:\n- Iná poznámka bez roku.'},include_records=True)
b=profile_units({'title':'Ján Holp','path':CATALOG[1]['source_document'],'content':
'# Diplomový projekt 2 2020\nStav:\n- Funguje indexovanie Cassandra.\n'
'# Diplomový projekt 2020\nStav:\n- Funguje databáza Cassandra.'},include_records=True)
return {'version':'content-v1','index_version':VERSION,'academic_schema':1,'units':a+b}
def latest(data):
q='Kedy mal Jakub Schwarc posledné stretnutie a o čom bolo?'
p=build_question_plan(q,CATALOG,index_version=VERSION)
result=answer_academic(q,data,engine='rag',mode='meeting',plan=p)
context=issue_context(q,result,CATALOG,data,VERSION)
assert len(context['meeting_options'])==1
return context
@pytest.mark.parametrize('q',[
'Na čom pracoval Jakub Schwarc v danom stretnutí?',
'ma com v danom stretnuti pracoval jakub schwarc',
'Na čom vtedy pracoval?',
])
@pytest.mark.parametrize('engine',['rag','graphrag'])
def test_followup_keeps_exact_meeting_work_person_and_record_year(data,q,engine):
resolved=resolve_question(q,token=latest(data)['token'],catalog=CATALOG,index=data,version=VERSION)
assert not resolved.clarification
plan=build_question_plan(resolved.query,CATALOG,meeting=resolved.meeting,index_version=VERSION)
assert plan.spec.intent=='meeting' and plan.spec.period.start=='2026-11-05'
assert not any(2026 in w.years for w in plan.spec.works)
rows=[r for r in data['units'] if plan.spec.matches_evidence(r)]
reply=answer_academic(resolved.query,data,engine=engine,mode='meeting',plan=plan,units=rows)
assert reply['status']=='ok' and 'Napísané skripty' in reply['answer']
assert 'Porovnané modely' not in reply['answer'] and 'starší model' not in reply['answer']
assert '2026-10-01' not in reply['answer'] and 'Diplomový projekt 2026' not in reply['answer']
assert 'Dátum požadovaného stretnutia: 2026-11-05' in reply['answer']
@pytest.mark.parametrize('q',['Na čom vtedy pracoval?','Na čom pracoval Jakub Schwarc v danom stretnutí?','Daj obe.','Pri tej druhej práci.'])
def test_reference_without_context_asks_instead_of_global_search(data,q):
r=resolve_question(q,catalog=CATALOG,index=data,version=VERSION)
assert r.clarification and r.reason in {'missing_meeting_context','missing_followup_context'}
def test_another_person_cannot_borrow_a_meeting(data):
r=resolve_question('Na čom vtedy pracoval Ján Holp?',token=latest(data)['token'],catalog=CATALOG,index=data,version=VERSION)
assert r.reason=='context_subject_mismatch'
def test_fresh_standalone_question_does_not_inherit_old_subject_or_meeting(data):
q='Aký je názov diplomovej práce Jána Holpa a z ktorého roku je?'
r=resolve_question(q,token=latest(data)['token'],catalog=CATALOG,index=data,version=VERSION)
assert r.query==q and r.meeting is None
def test_unknown_new_person_does_not_get_replaced_by_previous_person(data):
q='Aké práce má xaver neexistujuci?'
r=resolve_question(q,token=latest(data)['token'],catalog=CATALOG,index=data,version=VERSION)
assert r.query==q and 'Schwarc' not in r.query
@pytest.mark.parametrize('date',['1.10.2026','1. 10. 2026','2026-10-01'])
def test_date_spelling_variants_preserve_exact_same_record_scope(data,date):
p=build_question_plan('Na čom pracoval Jakub Schwarc na stretnutí '+date+'?',CATALOG)
assert p.spec.meeting.selection=='exact' and p.spec.period.start=='2026-10-01'
def test_implicit_new_aspect_still_belongs_to_previous_meeting(data):
r=resolve_question('A aké úlohy dostal?',token=latest(data)['token'],catalog=CATALOG,index=data,version=VERSION)
p=build_question_plan(r.query,CATALOG,meeting=r.meeting)
rows=[r for r in data['units'] if p.spec.matches_evidence(r)]
reply=answer_academic(r.query,data,engine='rag',mode='meeting',plan=p,units=rows)
assert 'Spustite experiment na HPC' in reply['answer'] and 'Napísané skripty' not in reply['answer']
def test_vtedy_after_a_predicate_is_not_an_unknown_person(data):
r=resolve_question('Aké úlohy mal vtedy?',token=latest(data)['token'],catalog=CATALOG,index=data,version=VERSION)
assert not r.clarification and r.meeting.ids
p=build_question_plan(r.query,CATALOG,meeting=r.meeting)
assert not p.clarification and p.spec.facets==['plans']
def test_year_followup_changes_record_period_without_reusing_old_meeting_id(data):
r=resolve_question('A v roku 2025?',token=latest(data)['token'],catalog=CATALOG,index=data,version=VERSION)
p=build_question_plan(r.query,CATALOG)
assert not p.clarification and p.spec.intent=='meeting' and r.meeting is None
assert p.spec.meeting.selection=='latest' and p.spec.period.field=='record_date'
assert p.spec.period.start=='2025-01-01' and p.spec.period.end=='2025-12-31'
def test_staff_role_is_preserved_in_a_year_followup(data):
catalog=CATALOG+[{'kind':'DocumentAuthor','name':'Daniel Hladek'}]
q='Ktoré práce vedie Daniel Hladek?'
result={'status':'ok','accepted_for_delivery':True,'index_version':VERSION,'answer':'Výber vedených prác.'}
context=issue_context(q,result,catalog,data,VERSION)
r=resolve_question('A ktoré v roku 2026?',token=context['token'],catalog=catalog,index=data,version=VERSION)
p=build_question_plan(r.query,catalog)
assert p.spec.people[0].role=='supervisor' and p.spec.people[0].name=='Daniel Hladek'
assert p.spec.works[0].years==[2026]
def test_final_scope_check_rejects_true_facts_from_wrong_person_or_meeting(data):
from tools.answers.conversation import answer_matches_scope
old=next(r for r in data['units'] if r.get('record_date')=='2026-10-01' and r['kind']!='meeting')
wrong={'status':'ok','accepted_for_delivery':True,'evidence':[old],'claims':[]}
q='Na čom pracoval Jakub Schwarc na stretnutí 5.11.2026?'
assert not answer_matches_scope(q,wrong,CATALOG)
q='Aký je názov diplomovej práce Jána Holpa?'
assert not answer_matches_scope(q,wrong,CATALOG)
@pytest.mark.parametrize('change,reason',[
('signature','invalid_context'),('version','stale_context'),('expiry','expired_context'),('source','invalid_context')])
def test_invalid_expired_stale_and_missing_source_references_fail_closed(data,change,reason):
token=latest(data)['token'];version=VERSION
if change=='signature':token=token[:-3]+'xxx'
if change=='version':version={'version':'new-version'}
if change=='expiry':
payload=unseal(token,VERSION);payload['expires']=int(time.time())-1;token=seal(payload)
if change=='source':data['units']=[u for u in data['units'] if u['kind']!='meeting']
with pytest.raises(ContextError,match=reason):
resolve_question('Na čom vtedy pracoval?',token=token,catalog=CATALOG,index=data,version=version)
def test_multiple_previous_meetings_require_a_specific_selection(data):
context=latest(data);payload=unseal(context['token'],VERSION)
marker=next(r for r in data['units'] if r['kind']=='meeting' and r['date']=='2026-10-01')
payload['meetings'].append({'id':marker['id'],'person':marker['person'],'work_id':marker['work_id'],
'label':marker['work_label'],'date':marker['date'],'date_label':marker['date_label']})
r=resolve_question('Na čom vtedy pracoval?',token=seal(payload),catalog=CATALOG,index=data,version=VERSION)
assert r.reason=='ambiguous_meeting_reference' and '2026-11-05' in r.clarification and '2026-10-01' in r.clarification
def test_both_cannot_select_an_undisplayed_old_work_from_a_meeting_answer(data):
context=latest(data)
assert len(context['work_options'])==1
r=resolve_question('Daj obe.',token=context['token'],catalog=CATALOG,index=data,version=VERSION)
assert r.reason=='ambiguous_work_reference'
def work_context(data):
q='Aké metódy používal Ján Holp pri diplomovom projekte v roku 2020?'
result={'status':'ambiguous','reason':'ambiguous_work','accepted_for_delivery':True,'index_version':VERSION,
'answer':'Ktorú prácu? Diplomový projekt 2 2020; Diplomový projekt 2020.'}
return issue_context(q,result,CATALOG,data,VERSION)
def test_both_reconstructs_one_complete_question_with_original_aspects(data):
r=resolve_question('Daj obe.',token=work_context(data)['token'],catalog=CATALOG,index=data,version=VERSION)
p=build_question_plan(r.query,CATALOG)
assert not p.clarification and p.spec.facets==['methods']
assert p.spec.scope=='selected_works' and len(p.spec.works[0].labels)==2 and p.spec.works[0].years==[2020]
def test_second_work_selection_keeps_original_request_but_only_selected_work(data):
r=resolve_question('Pri tej druhej práci.',token=work_context(data)['token'],catalog=CATALOG,index=data,version=VERSION)
p=build_question_plan(r.query,CATALOG)
assert not r.clarification and p.spec.facets==['methods']
assert p.spec.matches_work('Ján Holp','Diplomový projekt 2020','diploma_project',2020)
assert not p.spec.matches_work('Ján Holp','Diplomový projekt 2 2020','diploma_project',2020)
def test_second_selection_can_follow_a_successful_both_answer(data):
from tools.knowledge.planned_answers import answer_facets
both=resolve_question('Daj obe.',token=work_context(data)['token'],catalog=CATALOG,index=data,version=VERSION)
plan=build_question_plan(both.query,CATALOG)
result=answer_facets(plan,data,data['units'],engine='rag',limit=5)
assert result['status']=='ok'
context=issue_context(both.query,result,CATALOG,data,VERSION)
second=resolve_question('Pri tej druhej práci.',token=context['token'],catalog=CATALOG,index=data,version=VERSION)
plan=build_question_plan(second.query,CATALOG)
result=answer_facets(plan,data,data['units'],engine='rag',limit=5)
assert result['status']=='ok' and len({r['work_id'] for r in result['evidence']})==1
assert all(r['work_label']==context['work_options'][1]['label'] for r in result['evidence'])
def test_new_aspect_inherits_person_work_type_and_year(data):
r=resolve_question('A aké výsledky?',token=work_context(data)['token'],catalog=CATALOG,index=data,version=VERSION)
p=build_question_plan(r.query,CATALOG)
assert p.spec.facets==['results'] and p.spec.people[0].name=='Ján Holp'
assert p.spec.works[0].work_type=='diploma_project' and p.spec.works[0].years==[2020]
def test_exact_date_is_not_silently_replaced_by_latest(data):
q='Na čom pracoval Jakub Schwarc na stretnutí 1.10.2026?'
p=build_question_plan(q,CATALOG)
assert p.spec.intent=='meeting' and p.spec.meeting.selection=='exact'
assert p.spec.period.start==p.spec.period.end=='2026-10-01'
rows=[r for r in data['units'] if r.get('record_date')=='2026-10-01']
reply=answer_academic(q,data,engine='rag',mode='meeting',plan=p,units=rows)
assert 'Porovnané modely' in reply['answer'] and 'Napísané skripty' not in reply['answer']
def test_model_cannot_drop_meeting_or_turn_proposal_into_metadata(data):
p=build_question_plan('Na čom pracoval Jakub Schwarc na stretnutí 1.10.2026?',CATALOG).spec
with pytest.raises(PlanRejected,match='meeting'):
validate_model_plan(p.model_copy(update={'meeting':None}),p)
p=build_question_plan('Čo by sa dalo spojiť z práce Jána Holpa a Jakuba Schwarca?',CATALOG).spec
assert p.intent=='proposal' and p.answer_mode=='proposal'
with pytest.raises(PlanRejected,match='answer mode'):
validate_model_plan(p.model_copy(update={'answer_mode':'fact'}),p)
def test_same_meaning_typo_and_paraphrase_use_same_route(data):
from tools.answers.conversation import normalize_question
for q in ['na com pracoval Jakub Schwarc','ma com pracoval Jakub Schwarc']:
p=build_question_plan(normalize_question(q),CATALOG)
assert p.spec.intent=='progress' and p.spec.answer_mode=='description'
def test_client_transports_context_without_rewriting_query(monkeypatch):
from packages.claude import client
captured={}
def request(config,endpoint,body):
captured.update(body)
return {'status':'ambiguous','reason':'test','query':body['query'],'engine':'rag',
'accepted_for_delivery':True,'answer':'Vyber stretnutie.','claims':[],'source_urls':[],
'complete':False,'truncated':False,'truncation_reasons':[],'index_version':VERSION,
'resolved_query':'Overená úplná otázka.','conversation_context':{'token':'opaque','work_options':[],'meeting_options':[]}}
monkeypatch.setattr(client,'request_json',request)
result=client.query_backend('Daj obe.','rag',config=client.Config('http://localhost:8000','test'),context_token='opaque')
assert captured['query']=='Daj obe.' and captured['context_token']=='opaque'
assert client.answer_for_presentation(result)['conversation_context']['token']=='opaque'

View File

@ -17,6 +17,14 @@ QUESTIONS = [
]
@pytest.mark.parametrize('engine',['rag','graphrag'])
def test_context_token_crosses_real_http_without_model_rewriting(backend,engine):
result=client.query_backend('Daj obe.',engine,context_token='opaque-context')
assert result['accepted_for_delivery'] and result['query']=='Daj obe.'
body=backend['requests'][-1][1]
assert body['query']=='Daj obe.' and body['context_token']=='opaque-context'
@pytest.mark.parametrize("engine", ["rag", "graphrag"])
def test_latest_question_and_all_conditions_cross_http_unchanged(backend, engine):
for question in QUESTIONS:

View File

@ -71,18 +71,47 @@ def deliver_answer(
complete=None,
db_file: Path = DB_FILE,
include_diagnostics: bool = False,
context_token: str | None = None,
) -> dict:
from tools.answers.reference_links import checked_repository_links
from tools.answers.conversation import resolve_question, issue_context, answer_matches_scope, ContextError
from tools.answers.question_plan import planning_catalog
from tools.knowledge.store import load_index
from tools.sync.index_state import read_version
result = _deliver_answer(
question,
engine=engine,
limit=limit,
complete=complete,
db_file=db_file,
include_diagnostics=include_diagnostics,
)
return checked_repository_links(result)
catalog=[];index=None;version=None
try:
with index_snapshot(db_file.parent,db_file=db_file):
version=read_version(db_file.parent)
catalog=planning_catalog(db_file)
index=load_index(db_file.parent,version)
except (OSError,ValueError,RuntimeError):
if context_token:
return public_failure({'reason':'stale_context','engine':engine})
try:
resolved=resolve_question(question,token=context_token,catalog=catalog,index=index,version=version)
except ContextError as error:
return public_failure({'reason':str(error),'engine':engine})
if resolved.clarification:
result=clarification_result(resolved.clarification,resolved.reason,engine=engine,index_version=version)
else:
result = _deliver_answer(
resolved.query,
engine=engine,
limit=limit,
complete=complete,
db_file=db_file,
include_diagnostics=include_diagnostics,
meeting=resolved.meeting,
expected_version=version if context_token else None,
)
if catalog and not answer_matches_scope(resolved.query,result,catalog,resolved.meeting):
result=public_failure({'reason':'answer_rejected','engine':engine})
result=checked_repository_links(result)
if resolved.query!=question.strip():result['resolved_query']=resolved.query
conversation=issue_context(resolved.query,result,catalog,index,version)
if conversation:result['conversation_context']=conversation
return result
def _deliver_answer(
@ -93,6 +122,8 @@ def _deliver_answer(
complete=None,
db_file: Path = DB_FILE,
include_diagnostics: bool = False,
meeting=None,
expected_version=None,
) -> dict:
if not _capacity.acquire(blocking=False):
return public_failure({"reason": "answer_capacity_exceeded", "engine": engine})
@ -103,12 +134,14 @@ def _deliver_answer(
from tools.knowledge.query import deliver_content, ContentModelUnavailable, ContentAnswerRejected
try:
with index_snapshot(db_file.parent, db_file=db_file) as planned_version:
if expected_version is not None and expected_version!=planned_version:
return public_failure({'reason':'stale_context','engine':engine})
catalog = planning_catalog(db_file)
plan_model = complete or (lambda messages: complete_with_configured_model(
messages, output_schema=QuestionPlan.model_json_schema()))
verify_model = complete or (lambda messages: complete_with_configured_model(
messages, output_schema=PlanMeaning.model_json_schema()))
plan = build_question_plan(question, catalog, complete=plan_model,verify_complete=verify_model,index_version=planned_version)
plan = build_question_plan(question, catalog, complete=plan_model,verify_complete=verify_model,index_version=planned_version,meeting=meeting)
except PlanModelUnavailable:
return public_failure({'reason':'model_unavailable','engine':engine})
except PlanRejected:
@ -121,6 +154,8 @@ def _deliver_answer(
value['name_resolution']=plan.resolution
if include_diagnostics:value['question_plan']=plan.model_dump()
return value
if plan.spec.intent=='proposal':
return public_failure({'reason':'proposal_not_available','engine':engine})
question = plan.spec.question
try:
content_result = deliver_content(

View File

@ -1,6 +1,10 @@
"""Public failure wording; transport/model exception text is never a UI message."""
MESSAGES = {
'invalid_context':'Kontext predchádzajúcej otázky sa nepodarilo overiť. Zopakuj úplnú otázku s osobou, prácou a prípadným dátumom.',
'stale_context':'Dáta alebo kontext konverzácie sa zmenili. Zopakuj úplnú otázku; starý výber stretnutia nepoužijem.',
'expired_context':'Kontext predchádzajúcej otázky už vypršal. Zopakuj úplnú otázku.',
'proposal_not_available':'Otázka žiada návrh možného prepojenia, nie iba existujúce fakty. Návrhový režim ešte nie je dostupný; výpis podkladov preto nevydávam za hotový návrh.',
"reference_unavailable": "Zdrojový repozitár sa nepodarilo verejne overiť. Odpoveď založenú iba na tomto odkaze neuvádzam.",
"content_index_unavailable": "Obsahové vyhľadávanie momentálne nie je pripravené alebo sa jeho výstup "
"nepodarilo overiť. Treba overiť extrakciu pre aktuálnu verziu dát; "

View File

@ -0,0 +1,263 @@
"""Stateless, signed conversation references; source text is never chat memory.
The caller explicitly returns a token from its own previous tool response. No
global last-person/last-meeting state is kept, and nothing is written to wiki.
"""
from __future__ import annotations
import base64
from dataclasses import dataclass
import hashlib
import hmac
import json
import os
import re
import time
from tools.answers.context_policy import normalized
from tools.answers.question_intent import normalize_question
from tools.answers.question_plan import MeetingRequest, direct_spec
from tools.retrieval.person_names import resolve_person_names
MAX_TOKEN = 16000
LIFETIME = 24 * 60 * 60
MEETING_REF = re.compile(r'\b(?:vtedy|(?:v|na|o|z|pri) (?:danom|tomto|tom|tomto istom) stretnuti)\b')
WORK_REF = re.compile(r'\b(?:(?:tej|tu|t[uú]) (?:druhej|druhu|prvej|prvu)|(?:pri|o) druhej) (?:praci|pracu|projekte)\b')
BOTH = re.compile(r'^(?:(?:daj|uved|ukaz|vypis|porovnaj|chcem|prosim)\s+)?(?:obe|oba|obidv\w*)(?:\s+(?:prace|projekty))?[.!?]?$')
class ContextError(ValueError):
pass
@dataclass
class ResolvedQuestion:
query: str
meeting: MeetingRequest | None = None
clarification: str | None = None
reason: str | None = None
def _key():
# Domain-separated from API authentication; no additional .env secret.
key = os.getenv('SEARCH_API_KEY', '')
return hashlib.sha256(('zp-conversation-v1\0'+key).encode()).digest() if key else None
def _encode(value):
return base64.urlsafe_b64encode(value).decode().rstrip('=')
def _decode(value):
return base64.b64decode(value+'='*(-len(value)%4),altchars=b'-_',validate=True)
def seal(payload):
key = _key()
if key is None:
return None # Local library tests need no private API configuration.
body = _encode(json.dumps(payload,ensure_ascii=False,separators=(',',':')).encode())
token = body+'.'+_encode(hmac.digest(key,body.encode(),'sha256'))
return token if len(token)<=MAX_TOKEN else None
def unseal(token, version):
try:
if not isinstance(token,str) or not 1<=len(token)<=MAX_TOKEN or _key() is None:
raise ValueError()
body,signature=token.split('.')
if not hmac.compare_digest(_decode(signature),hmac.digest(_key(),body.encode(),'sha256')):
raise ValueError()
value=json.loads(_decode(body))
if value.get('schema')!=1 or not isinstance(value.get('question'),str):raise ValueError()
if value['expires']<time.time():raise ContextError('expired_context')
if value['version']!=(version or {}).get('version'):raise ContextError('stale_context')
return value
except ContextError:
raise
except (ValueError,KeyError,TypeError,UnicodeError):
raise ContextError('invalid_context') from None
def _options(index, spec):
people={p.name for p in spec.people if p.role=='student'}
rows=[r for r in (index or {}).get('units',[]) if r['person'] in people and
spec.matches_work(r['person'],r.get('work_label'),r.get('work_type'),r.get('work_year'))]
works=[];seen=set()
for r in rows:
if r.get('work_id') and r['work_id'] not in seen:
seen.add(r['work_id'])
works.append({'person':r['person'],'id':r['work_id'],'label':r['work_label']})
return works, [r for r in rows if r['kind']=='meeting']
def issue_context(question, result, catalog, index, version):
if result.get('accepted_for_delivery') is not True or result.get('status') not in {'ok','ambiguous'}:
return None
if result.get('index_version')!=version or index is None:return None
resolution=resolve_person_names(question,catalog)
if resolution['status']!='ok':return None
spec=direct_spec(question,resolution)
works,markers=_options(index,spec)
if not spec.people:return None
# Options must follow the displayed order, particularly numbered choices.
answer=normalized(result.get('answer',''))
if result.get('status')=='ok':
shown=[w for w in works if normalized(w['label']) in answer]
if shown:works=shown
works.sort(key=lambda w:answer.find(normalized(w['label'])) if normalized(w['label']) in answer else len(answer))
evidence=result.get('evidence',[])
focused={r.get('meeting_id') or (r.get('id') if r.get('kind')=='meeting' else None) for r in evidence}
meetings=[{'id':r['id'],'person':r['person'],'work_id':r.get('work_id'),
'label':r.get('work_label'),'date':r.get('date'),'date_label':r.get('date_label')}
for r in markers if (r.get('meeting_id') or r['id']) in focused]
if spec.meeting and spec.meeting.selection=='latest' and any(m['date'] for m in meetings):
latest=max(m['date'] for m in meetings if m['date'])
meetings=[m for m in meetings if m['date']==latest]
# Include exact source-backed options in a clarification, never every
# meeting as if it had already been selected by a successful answer.
if not meetings and result.get('reason')=='missing_meeting_context':
meetings=[{'id':r['id'],'person':r['person'],'work_id':r.get('work_id'),
'label':r.get('work_label'),'date':r.get('date'),'date_label':r.get('date_label')}
for r in markers[:12]]
if len(works)>20 or len(meetings)>12:return None
payload={'schema':1,'version':version['version'],'expires':int(time.time())+LIFETIME,
'question':question,'people':[p.model_dump() for p in spec.people],
'works':works,'meetings':meetings}
token=seal(payload)
return {'token':token,'work_options':works,'meeting_options':meetings} if token else None
def resolve_question(question, *, token=None, catalog, index, version):
query=normalize_question(question)
n=normalized(query)
context=unseal(token,version) if token else None
year_followup=re.fullmatch(r'(?:(?:a|ale)(?: co)?\s+)?v roku ([12]\d{3})[.?]?',n)
if context and year_followup:
old=direct_spec(context['question'],resolve_person_names(context['question'],catalog))
if old.intent=='meeting' and old.meeting and old.meeting.selection=='latest':
query=re.sub(r'\bv roku [12]\d{3}\b','',context['question'],flags=re.I).rstrip(' .?')+' v roku '+year_followup[1]+'?'
n=normalized(query)
meeting_ref=bool(MEETING_REF.search(n))
work_ref=WORK_REF.search(n)
both=bool(BOTH.fullmatch(n))
explicit_date=bool(re.search(r'\b\d{1,2}\.\s*\d{1,2}\.\s*[12]\d{3}\b',query))
# An explicit date is stronger than a redundant word such as "vtedy".
needs_context=meeting_ref and not explicit_date or bool(work_ref) or both
short_followup=bool(context and (explicit_date or re.match(r'^(?:a |ale |čo |co |aké |ake |kedy |čo sa |co sa )',n)))
if needs_context and context is None:
return ResolvedQuestion(query,clarification=(
'Ktoré stretnutie máš na mysli? Uveď študenta a dátum alebo presné označenie stretnutia.' if meeting_ref
else 'Ktoré práce máš na mysli? Uveď osobu a presné označenia prác.'),
reason='missing_meeting_context' if meeting_ref else 'missing_followup_context')
if not context or not (needs_context or short_followup):return ResolvedQuestion(query)
# Revalidate references against the captured published generation, not just
# a plausible name, an unsigned caller dictionary or a model's assertions.
by_id={r['id']:r for r in (index or {}).get('units',[])}
for marker in context['meetings']:
row=by_id.get(marker['id'])
if not row or row['kind']!='meeting' or row['person']!=marker['person'] or row.get('work_id')!=marker['work_id']:
raise ContextError('invalid_context')
for work in context['works']:
if not any(r.get('work_id')==work['id'] and r['person']==work['person'] and r.get('work_label')==work['label'] for r in by_id.values()):
raise ContextError('invalid_context')
actual=resolve_person_names(query,catalog)
if actual['status']!='ok':return ResolvedQuestion(query)
named=[m['name'] for m in actual.get('matches',[])]
previous={p['name'] for p in context['people']}
if needs_context and named and not set(named)<=previous:
return ResolvedQuestion(query,clarification='Odkaz patrí k inej osobe. Uveď pre aktuálneho študenta konkrétnu prácu alebo dátum stretnutia.',reason='context_subject_mismatch')
if both or work_ref:
works=context['works']
if both and len(works)!=2 or work_ref and len(works)<2:
return ResolvedQuestion(query,clarification='Predchádzajúce možnosti neurčujú jednoznačný výber. Uveď presné názvy požadovaných prác.',reason='ambiguous_work_reference')
chosen=works if both else [works[0 if 'prv' in work_ref[0] else 1]]
if len({w['person'] for w in chosen})!=1:
return ResolvedQuestion(query,clarification='Uveď osobu a presné označenie každej vyberanej práce.',reason='ambiguous_work_reference')
selection_only=bool(work_ref and WORK_REF.sub('',n).strip(' .?!') in {'','pri','o','daj','ukaz'})
if both or selection_only:
from tools.retrieval.work_selector import QUOTED,label_key
old_labels={label_key(w['label']) for w in works}
query=QUOTED.sub(lambda m:'' if label_key(m[1]) in old_labels else m[0],context['question'])
else:
query=re.sub(r'\b(?:pri\s+)?(?:tej\s+)?(?:druhej|prvej)\s+(?:práci|praci|projekte)\b',
'pri vybranej práci',query,flags=re.I)
query+=' Pýtam sa na '+('obe práce: ' if both else 'prácu: ')+ ' aj '.join('"'+w['label']+'"' for w in chosen)+'.'
if not named and not (both or selection_only):query+=' Osoba: '+chosen[0]['person']+'.'
elif not named and len(previous)==1:
if re.fullmatch(r'\d{1,2}\.\s*\d{1,2}\.\s*[12]\d{3}[.?]?',query):
query='Na čom pracoval na stretnutí '+query.rstrip('?')+'?'
person=context['people'][0]
prefix={'supervisor':'Pod vedením ','consultant':'Konzultant ',
'document_author':'Autor dokumentu '}.get(person['role'],'Osoba: ')
query+=' '+prefix+person['name']+'.'
elif not named and previous:
return ResolvedQuestion(query,clarification='Na ktorú z predchádzajúcich osôb otázka nadväzuje? Uveď celé meno.',reason='ambiguous_person_reference')
meeting=None
from tools.retrieval.question_constraints import requested_years
inherited_meeting=bool(not named and not explicit_date and not requested_years(question) and not work_ref and not both and short_followup
and context['meetings'] and direct_spec(context['question'],resolve_person_names(context['question'],catalog)).intent=='meeting')
if (meeting_ref or inherited_meeting) and not explicit_date:
markers=context['meetings']
if len(markers)!=1:
options='; '.join(m['person']+' — '+str(m['label'])+' — '+str(m['date'] or m['date_label']) for m in markers)
return ResolvedQuestion(query,clarification='Ktoré stretnutie máš na mysli? '+(options+'. ' if options else '')+'Uveď konkrétny dátum alebo označenie.',reason='ambiguous_meeting_reference')
marker=markers[0]
meeting=MeetingRequest(selection='exact',ids=[by_id[marker['id']].get('meeting_id') or marker['id']])
label=marker['date'] or marker['date_label']
if marker['date']:
y,m,d=marker['date'].split('-');label=f'{int(d)}.{int(m)}.{y}'
# Match on original Unicode spelling without replacing unrelated words.
query=re.sub(r'\bvtedy\b|\b(?:v|na|o|z|pri)\s+(?:danom|tomto|tom|tomto istom)\s+stretnut[ií]\b',
'na stretnutí '+label,query,flags=re.I)
if inherited_meeting:query+=' Na stretnutí '+label+'.'
query+=' Pri práci "'+marker['label']+'".' if marker['label'] else ''
if not both and meeting is None and len(previous)==1 and not named:
query=_inherit_constraints(query,context,catalog)
if len(query)>500:
return ResolvedQuestion(query,clarification='Nadväzujúca otázka je príliš dlhá. Uveď stručne osobu, prácu, dátum a požadovanú informáciu.',reason='resolved_query_too_long')
return ResolvedQuestion(query,meeting=meeting)
def _inherit_constraints(query, context, catalog):
"""Carry previous explicit constraints, allowing explicit new replacements."""
from tools.answers.rag_evidence import WORK_LABELS
old=direct_spec(context['question'],resolve_person_names(context['question'],catalog))
new=direct_spec(query,resolve_person_names(query,catalog))
if len(old.people)!=1:return query
prior=old.works[0] if old.works else None
current=new.works[0] if new.works else None
if prior and current:
if not (current.work_type or current.labels or current.numbers):
if prior.labels:query+=' Pri práci '+ ' alebo '.join('"'+v+'"' for v in prior.labels)+'.'
elif prior.work_type:query+=' Typ práce: '+WORK_LABELS[prior.work_type]+'.'
if prior.years and not new.years and new.period is None:
query+=' Rok práce: '+', '.join(str(y) for y in prior.years)+'.'
if prior.excluded_labels and not current.excluded_labels:
query+=' Nie '+ ' ani '.join('"'+v+'"' for v in prior.excluded_labels)+'.'
for condition in old.conditions:
if condition.kind in {'tag','category','document_author','exclusion','current_status'} and not any(c.kind==condition.kind for c in new.conditions):
query+=' '+condition.quote+'.'
if old.period and not new.period:
def label(value):
y,m,d=value.split('-');return f'{int(d)}.{int(m)}.{y}'
query+=' Obdobie od '+label(old.period.start)+' do '+label(old.period.end)+'.'
return query
def answer_matches_scope(question, result, catalog, meeting=None):
"""Necessary final identity/meeting check, independent of prose quality."""
if result.get('status')!='ok' or not result.get('accepted_for_delivery'):return True
resolution=resolve_person_names(question,catalog)
if resolution['status']!='ok':return False
spec=direct_spec(question,resolution,meeting=meeting)
names={p.name for p in spec.people if p.role=='student'}
rows=result.get('evidence',[])
if names and spec.intent not in {'similar','discovery'}:
if any(r.get('person') and r['person'] not in names for r in [*rows,*result.get('claims',[])]):return False
if spec.meeting:
if not rows:return False
if any(not spec.matches_evidence(r) for r in rows):return False
if spec.period and spec.period.field=='record_date' and any(
not r.get('record_date') or not spec.period.start<=r['record_date']<=spec.period.end for r in rows):return False
return True

View File

@ -23,6 +23,15 @@ FACET_LABELS = {
}
def normalize_question(question: str) -> str:
"""Only a narrow phrase typo; identities, dates and conditions stay literal."""
text=re.sub(r'^ma\s+(?:čom|com)\b','na čom',question.strip(),flags=re.I)
text=re.sub(r'(?<!\d)(\d{1,2})\.\s*(\d{1,2})\.\s*([12]\d{3})(?!\d)',
lambda m:f'{m[1]}.{m[2]}.{m[3]}',text)
return re.sub(r'((?:stretnut[iíe]|dňa|dna)\s+)([12]\d{3})-(\d{2})-(\d{2})\b',
lambda m:f'{m[1]}{int(m[4])}.{int(m[3])}.{m[2]}',text,flags=re.I)
def requested_facets(question: str) -> list[str]:
text = normalized(re.sub(r'[„“"«][^„“”"»]+[“”"»]', " ", question))
text = re.sub(r'\bnezamienaj\s+(?:ich|to|ciele|zadanie|plany|ulohy)\s+za\s+(?:hotove\s+)?vysled\w*|\bbez domyslania vysled\w*',' ',text)
@ -65,6 +74,8 @@ def actual_methods_requested(question: str) -> bool:
def unresolved_followup(question: str) -> bool:
return bool(
re.fullmatch(r'(?:(?:a|ale)(?: co)?\s+)?v roku [12]\d{3}[.?]?',normalized(question)) or
re.search(r'\b(?:vtedy|(?:v|na|o|z|pri) (?:danom|tomto|tom|tomto istom) stretnuti|(?:tej|pri) druhej praci)\b',normalized(question)) or
re.fullmatch(
r"(?:(?:daj|uved|ukaz|vypis|porovnaj|chcem|prosim)\s+)?"
r"(?:obe|oba|obidv\w*|vsetky|prvy|druhy)(?:\s+(?:prace|projekty))?[.!?]?",

View File

@ -21,7 +21,7 @@ from tools.retrieval.question_constraints import unsupported_constraint, request
from tools.retrieval.work_selector import parse_work_selector, WorkSelector
WorkType = Literal['bachelor_thesis','diploma_thesis','doctoral_thesis','bachelor_project','diploma_project','team_project','research_project','scientific_project']
Intent = Literal['metadata','facets','progress','timeline','period','discovery','similar','advisors','meeting','completion','supervisor','unsupported']
Intent = Literal['metadata','facets','progress','timeline','period','discovery','similar','advisors','meeting','completion','supervisor','proposal','unsupported']
Facet = Literal['assignment','plans','problems','methods','experiments','progress','results']
class Strict(BaseModel):
@ -63,6 +63,13 @@ class Condition(Strict):
kind: Literal['tag','category','document_author','quantity','exclusion','current_status','year_verification','other']
quote: str
class MeetingRequest(Strict):
selection: Literal['latest','exact','all']
ids: list[str] = Field(default_factory=list, max_length=12)
def matches(self, row):
return not self.ids or (row.get('meeting_id') or (row.get('id') if row.get('kind')=='meeting' else None)) in self.ids
class QuestionPlan(Strict):
question: str
intent: Intent = 'metadata'
@ -70,6 +77,8 @@ class QuestionPlan(Strict):
works: list[WorkRequest] = Field(default_factory=list)
years: list[int] = Field(default_factory=list)
period: Period | None = None
meeting: MeetingRequest | None = None
answer_mode: Literal['fact','description','comparison','similarity','proposal'] = 'fact'
conditions: list[Condition] = Field(default_factory=list)
fields: list[Literal['title','year','start_year','work_type','keyword','category','document_author','supervisor','consultant','status','date']] = Field(default_factory=list)
facets: list[Facet] = Field(default_factory=list)
@ -83,6 +92,10 @@ class QuestionPlan(Strict):
return False
return not filters or any(w.matches(label,kind,year) for w in filters)
def matches_evidence(self, row):
return (self.matches_work(row['person'],row.get('work_label'),row.get('work_type'),row.get('work_year'))
and (self.meeting is None or self.meeting.matches(row)))
class PlannedQuestion(Strict):
"""Server-only bindings; not part of the model's writable output schema."""
spec: QuestionPlan
@ -118,17 +131,25 @@ def planning_catalog(db_file):
return catalog
def direct_spec(question, resolution):
def direct_spec(question, resolution, *, meeting=None):
from tools.knowledge.query import intent, invalid_question, _period
from tools.knowledge.academic import academic_intent
q=resolution.get('resolved_query') or question
n=normalized(q)
selector=parse_work_selector(q)
mode=intent(q)
meeting_words=bool(re.search(r'\b(?:stretnut\w*|konzultaci\w*)\b',n))
if meeting_words and (meeting or re.search(r'\d{1,2}\.\s*\d{1,2}\.\s*[12]\d{3}',q)):
mode='meeting'
meeting=meeting or MeetingRequest(selection='exact')
elif mode=='meeting' and re.search(r'\b(?:posledn\w*|naposledy|najnov\w*)\b',n):
meeting=meeting or MeetingRequest(selection='latest')
elif meeting_words and re.search(r'\b(?:ake|ktore|vsetky) stretnut\w*\b',n):
mode='meeting';meeting=MeetingRequest(selection='all')
facets=requested_facets(q)
global_discovery=bool(re.search(r'\b(?:kto|ktori studenti|ktory student|u koho)\b',n)
and re.search(r'\b(?:venov\w*|zaober\w*|pracov\w*|vyskus\w*|vytvor\w*|pouz\w*|ries\w*|implement\w*)\b',n))
if mode not in {'similar','supervisor','advisors','completion','meeting','period'} and global_discovery:
if mode not in {'similar','supervisor','advisors','completion','meeting','period','proposal'} and global_discovery:
mode='discovery'
# Facet questions without an academic lifecycle intent share one execution
# path, including GraphRAG's content evidence. Explicit metadata stays metadata.
@ -179,6 +200,7 @@ def direct_spec(question, resolution):
else 'count' if re.search(r'\b(?:kolko|pocet)\b',n) else 'brief' if re.search(r'\bstrucn\w*|\bjednoducho\b',n)
else 'list' if scope=='across_works' or selector.multiple else 'overview')
unsupported=[]
if meeting_words and meeting is None:unsupported.append('missing_meeting_context')
if mode in {'progress','timeline','similar'} and not people:unsupported.append('missing_subject')
if mode and mode!='metadata' and presentation=='count':unsupported.append('content_count_not_established')
if invalid_question(q):unsupported.append('unintelligible_question')
@ -187,14 +209,14 @@ def direct_spec(question, resolution):
if len(full_dates)<2 and re.search(r'\b(?:kvartal\w*|minuly rok|minulom roku|v marci|v januari|v februari|v aprili|v maji|v juni|v juli|v auguste|v septembri|v oktobri|v novembri|v decembri)\b',n):unsupported.append('period_needs_dates')
exclusion_text=re.sub(r'\bbez\s+(?:domyslania|dohadov|spekulacii|nepodlozenych tvrdeni)(?:\s+vysledkov)?\b',' ',n)
if re.search(r'\bbez\b',exclusion_text) and not (selector.unnumbered or selector.excluded_numbers or selector.excluded_labels):unsupported.append('exclusion')
if unresolved_followup(q):unsupported.append('missing_followup_context')
if unresolved_followup(q) and meeting is None:unsupported.append('missing_followup_context')
if re.search(r'\bsemest\w*\b',n) and len(re.findall(r'\d{1,2}\.\d{1,2}\.[12]\d{3}',q))<2:unsupported.append('enrollment_not_established' if re.search(r'\b(?:aktual\w*|teraz|v akom|v ktorom)\b',n) else 'period_needs_dates')
code=unsupported_constraint(q)
if code and not (code=='work_event_date' and academic_intent(q)=='completion'):
if code!='year_range':unsupported.append(code)
period=None
has_dates=bool(re.search(r'\d{1,2}\.\d{1,2}\.[12]\d{3}',q))
if has_dates or code=='year_range' or mode in {'period','timeline','completion'} or mode in {'progress','facets'} and years and not TYPE_PATTERN.search(types_text):
if has_dates or code=='year_range' or mode in {'period','timeline','completion'} or mode in {'progress','facets','meeting'} and years and not TYPE_PATTERN.search(types_text):
try:
bounds=_period(q)
if bounds:
@ -234,7 +256,10 @@ def direct_spec(question, resolution):
fields=[field for field,pattern in [('title',r'\bnaz(?:ov|v)\w*'),('year',r'\b(?:ktoreho roku|ktorych rokoch|rok\w*)'),('work_type',r'\btyp\w*'),('keyword',r'\b(?:tag\w*|klucov\w*)'),('category',r'\bkategor\w*'),('document_author',r'\bautor\w* dokument\w*'),('supervisor',r'\b(?:vedie|veduc\w*|veden\w*)'),('consultant',r'\bkonzult\w*'),('status',r'\b(?:ukoncil\w*|obhaj\w*|skoncil\w*)'),('date',r'\b(?:kedy|datum\w*)')] if re.search(pattern,n)]
if study_start:
fields.append('start_year')
answer_mode=('proposal' if mode=='proposal' else 'comparison' if presentation in {'comparison','table'}
else 'similarity' if mode=='similar' else 'description' if mode and mode!='metadata' else 'fact')
return QuestionPlan(fields=fields,question=q,intent=mode or 'metadata',people=people,works=works,years=years,period=period,
meeting=meeting,answer_mode=answer_mode,
conditions=conditions,facets=facets,scope=scope,presentation=presentation,unsupported=list(dict.fromkeys(unsupported)))
@ -247,6 +272,9 @@ def _model_json(response):
def validate_model_plan(candidate, baseline, *, allow_work_binding=False):
"""Schema validity does not authorize a changed identity, filter or time role."""
if candidate.question!=baseline.question:raise PlanRejected('Question rewrite')
if candidate.meeting!=baseline.meeting:raise PlanRejected('Changed meeting selection')
if candidate.answer_mode!=baseline.answer_mode and baseline.answer_mode in {'proposal','comparison','similarity'}:
raise PlanRejected('Changed requested answer mode')
if candidate.people!=baseline.people:raise PlanRejected('Changed identities or roles')
if candidate.years!=baseline.years or candidate.period!=baseline.period:
raise PlanRejected('Changed temporal filters')
@ -280,14 +308,17 @@ def validate_model_plan(candidate, baseline, *, allow_work_binding=False):
return candidate
def build_question_plan(question, catalog, *, complete=None, verify_complete=None, index_version=None):
def build_question_plan(question, catalog, *, complete=None, verify_complete=None, index_version=None, meeting=None):
from tools.answers.question_intent import normalize_question
question=normalize_question(question)
resolution=resolve_person_names(question,catalog or []) if catalog is not None else {
'status':'ok','matches':[],'resolved_query':question,'notices':[]}
spec=direct_spec(question,resolution)
spec=direct_spec(question,resolution,meeting=meeting)
bound=PlannedQuestion(spec=spec,resolution=resolution,index_version=index_version)
if spec.unsupported:
code=spec.unsupported[0]
messages={'missing_subject':'Uveď celé meno študenta, ktorého prácu mám vyhľadať.',
'missing_meeting_context':'Ktoré stretnutie máš na mysli? Uveď konkrétny dátum alebo požiadaj výslovne o posledné stretnutie.',
'content_count_not_established':'Obsahové vyhľadávanie poskytuje doložené prípady, nie overený úplný počet. Požiadaj o príklady alebo počet podľa evidovaných metadát.',
'unintelligible_question':'Otázke nerozumiem. Uveď študenta, tému alebo výsledok.',
'missing_followup_context':'Uveď osobu a presné označenia prác alebo úplnú otázku. Samotné „obe“ bez predchádzajúcich možností neviem spoľahlivo vyhľadať.',

View File

@ -12,7 +12,7 @@ import zipfile
ROOT = Path(__file__).resolve().parents[2]
BASE = ROOT / "packages/claude"
ENGINES = ("rag", "graphrag")
PACKAGE_VERSION = "1.8.3"
PACKAGE_VERSION = "1.9.0"
def json_bytes(value):

View File

@ -449,12 +449,21 @@ def answer_academic(question, index, *, engine, limit=5, units=None, mode=None,
)
continue
latest = max((r["date"] for r in dated), default=None)
chosen = [r for r in markers if r.get("date") == latest] if latest else markers
meeting_selection=plan.spec.meeting if plan else None
exact=bool(meeting_selection and meeting_selection.selection in {'exact','all'})
if exact:
chosen=[r for r in markers if meeting_selection.matches(r)]
if meeting_selection.selection=='exact' and len(chosen)>1 and len({r.get('work_id') for r in chosen})>1:
return respond('Rovnakému dátumu zodpovedá viac prác. Uveď presné označenie práce.',status='ambiguous',reason='ambiguous_meeting_work')
else:
chosen = [r for r in markers if r.get("date") == latest] if latest else markers
for marker in chosen[:limit]:
heading='Dátum požadovaného stretnutia' if exact else 'Posledný záznam s úplným dátumom'
displayed_date=marker.get('date') if exact else latest
lines.append(
f'**{marker["person"]} — {marker.get("work_label") or "profil"}**\n'
f"Posledný záznam s úplným dátumom: {latest}."
if latest
f"{heading}: {displayed_date}."
if displayed_date
else f'**{marker["person"]}** — záznam: {marker["date_label"]}; rok nie je uvedený.'
)
evidence.append(marker)
@ -471,6 +480,11 @@ def answer_academic(question, index, *, engine, limit=5, units=None, mode=None,
from tools.answers.content_selection import unit_score
notes = [r for r in notes if not r["text"].rstrip().endswith(":")]
if plan and plan.spec.facets:
from tools.answers.content_selection import facets_for
notes=[r for r in notes if set(plan.spec.facets)&set(facets_for(r['text'],
{'assignment':'assignment','planned':'task','result':'progress','progress':'progress'}.get(r['phase'],'other'),
plan.spec.question,r['phase']))]
notes.sort(
key=lambda r: (
-unit_score(
@ -503,7 +517,7 @@ def answer_academic(question, index, *, engine, limit=5, units=None, mode=None,
if len(chosen) > limit:
omitted = True
partial = [r for r in part if r["kind"] == "meeting" and not r.get("date")]
if partial:
if partial and not exact:
lines.append(
"Profil obsahuje aj záznamy bez úplného dátumu; nemožno spoľahlivo určiť, "
"či sú novšie. Rok nedopĺňam z roku práce."

View File

@ -24,6 +24,7 @@ WITH p,w,e WHERE (p.source_path IN $profiles OR
EXISTS { MATCH (w)-[:SUPERVISED_BY]->(v:KnowledgeAdvisor {graph_scope:$scope}) WHERE v.id IN $supervisors } OR
EXISTS { MATCH (w)-[:CONSULTED_BY]->(v:KnowledgeAdvisor {graph_scope:$scope}) WHERE v.id IN $consultants })
AND ((w.id IN $works AND e.work_id=w.id) OR ($profile_records AND e.work_id IS NULL))
AND (size($meeting_ids)=0 OR e.id IN $meeting_ids OR e.meeting_id IN $meeting_ids)
AND ($date_field IS NULL OR ($start <= e[$date_field] AND e[$date_field] <= $end))
OPTIONAL MATCH (w)-[:HAS_ACTIVITY]->(a:KnowledgeActivity {graph_scope:$scope})-[:SUPPORTED_BY]->(e)
OPTIONAL MATCH (e)-[:ABOUT]->(c:KnowledgeConcept {graph_scope:$scope})
@ -123,7 +124,7 @@ def seed_ids(question, index, spec):
terms = _terms(question); families = concept_families(question)
candidates = [u for u in index['units'] if u.get('work_id') and claim_eligible(u)
and u.get('annotation_status')=='knowledge' and not administrative_only(u['text'])
and spec.matches_work(u['person'],u.get('work_label'),u.get('work_type'),u.get('work_year'))
and spec.matches_evidence(u)
and activity_supported(question,u)]
if spec.period and spec.period.field!='work_year':
candidates=[u for u in candidates if u.get(spec.period.field) and
@ -172,6 +173,7 @@ def retrieve_local_graph(question, index, bound, *, limit=5):
ids=ids[:SEED_LIMIT]
period=spec.period if spec.period and spec.period.field!='work_year' else None
params={'scope':store.SCOPE,'profiles':profiles,'works':works,'ids':ids,
'meeting_ids':spec.meeting.ids if spec.meeting else [],
'supervisors':[store.identifier(normalized(p.name)) for p in spec.people if p.role=='supervisor'],
'consultants':[store.identifier(normalized(p.name)) for p in spec.people if p.role=='consultant'],
'date_field':period.field if period else None,'start':period.start if period else None,'end':period.end if period else None,

View File

@ -48,6 +48,9 @@ def model_json(response):
def intent(question: str) -> str | None:
from tools.knowledge.academic import academic_intent
text = normalized(question)
if re.search(r'^(?:navrhni|navrhnite)\b|\b(?:co|ako) by sa (?:dalo|dali|mohlo|mohli) (?:spojit|prepojit|vyuzit)\b|\bmoznosti spoluprace\b',text):
return 'proposal'
if mode := academic_intent(question):
return mode
text = normalized(question)
@ -59,7 +62,7 @@ def intent(question: str) -> str | None:
r"\b(?:ktore|ake|kto)\b", text
):
return "supervisor"
if re.search(r"\b(?:pokroc\w*|pokrok\w*|posun\w*|napred\w*)\b|\bna com\b", text):
if re.search(r"\b(?:pokroc\w*|pokrok\w*|posun\w*|napred\w*)\b|\bna com\b|\bcomu sa venoval\w*\b|\bnad cim pracoval\w*\b|\bco (?:urobil|spravil|riesil)\w*\b", text):
return "progress"
if re.search(r"\bkedy\b", text) and re.search(
r"\b(?:vysled\w*|dosiah\w*|urobil\w*|implement\w*)\b", text
@ -1048,6 +1051,8 @@ def deliver_content(question: str, *, engine: str, limit: int, db_file, complete
if engine=='rag' and plan and mode not in {'advisors','completion','supervisor'}:
from tools.knowledge.text_retrieval import retrieve_units
units,retrieved_relevance,retrieval_trace=retrieve_units(question,index,plan.spec,limit=limit)
if plan and plan.spec.meeting:
units=[row for row in units if plan.spec.matches_evidence(row)]
def traced(result):
if not include_diagnostics:
return result

View File

@ -29,7 +29,7 @@ def retrieve_units(question, index, spec, *, limit=5):
people={p.name for p in spec.people if p.role=='student'}
similar=spec.intent=='similar'
def in_scope(row):
if not spec.matches_work(row['person'],row.get('work_label'),row.get('work_type'),row.get('work_year')):return False
if not spec.matches_evidence(row):return False
if spec.period and spec.period.field!='work_year':
value=row.get(spec.period.field)
if not value or not spec.period.start<=value<=spec.period.end:return False

View File

@ -99,7 +99,7 @@ _STOP = set(
"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 "
"aktualne teraz vedie viedol pokrocil pokrocili kedy prislusne o com pod".split()
"aktualne teraz vtedy predtym potom vedie viedol pokrocil pokrocili kedy prislusne o com pod".split()
)
_STOP = {normalize(word) for word in _STOP}
@ -189,7 +189,7 @@ def resolve_person_names(query: str, catalog: list[dict]) -> dict:
}
author_lookup = bool(
re.search(
r"\b(?:autor\w*|dokument\w*|vedie|viedol|veduc\w*|skolitel\w*|konzult\w*)\b", normalize(query)
r"\b(?:autor\w*|dokument\w*|vedie|viedol|veduc\w*|veden\w*|skolitel\w*|konzult\w*)\b", normalize(query)
)
)
people = [