Preserve verified conversation references across RAG and GraphRAG
This commit is contained in:
parent
787cb6dfa3
commit
660f4d9ef9
@ -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.")
|
||||
|
||||
1129
experiments/conversation_context/verification.json
Normal file
1129
experiments/conversation_context/verification.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
|
||||
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": "c7a207660004c1e4e7ae7d507006df231c666d13c53067bd5896a022ef44b64d"
|
||||
"sha256": "f55d72cdf1bb751807221ade986ac582098ee41c3e49a2ac3b291742935f13f2"
|
||||
},
|
||||
{
|
||||
"archive": "zpwiki-graphrag.zip",
|
||||
"name": "graphrag",
|
||||
"sha256": "1f340a0298625b7dd5f427e8501dde8ec781cc8d72e7a4f50b9ad4b21de6b1fa"
|
||||
"sha256": "78ec35715f31a2be7ca5c155506a499415fa49e644ed6309ea680bb46ab943f8"
|
||||
}
|
||||
],
|
||||
"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.
@ -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.
|
||||
|
||||
@ -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 {}))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
245
test/test_conversation_context.py
Normal file
245
test/test_conversation_context.py
Normal 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'
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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; "
|
||||
|
||||
263
tools/answers/conversation.py
Normal file
263
tools/answers/conversation.py
Normal 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
|
||||
@ -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))?[.!?]?",
|
||||
|
||||
@ -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ť.',
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = [
|
||||
|
||||
Loading…
Reference in New Issue
Block a user