From 660f4d9ef98158eff540f321f6b851591b9fd87c Mon Sep 17 00:00:00 2001 From: jp170na Date: Tue, 10 Nov 2026 01:53:33 +0100 Subject: [PATCH] Preserve verified conversation references across RAG and GraphRAG --- app/routes.py | 2 + .../conversation_context/verification.json | 1129 +++++++++++++++++ packages/claude/client.py | 23 +- packages/claude/dist/packages.json | 4 +- packages/claude/dist/zpwiki-graphrag.zip | Bin 8892 -> 9624 bytes packages/claude/dist/zpwiki-rag.zip | Bin 9125 -> 9706 bytes packages/claude/graphrag/SKILL.md | 19 +- packages/claude/mcp_server.py | 25 +- packages/claude/rag/SKILL.md | 22 +- test/test_conversation_context.py | 245 ++++ test/test_question_delivery.py | 8 + tools/answers/answer_delivery.py | 55 +- tools/answers/answer_errors.py | 4 + tools/answers/conversation.py | 263 ++++ tools/answers/question_intent.py | 11 + tools/answers/question_plan.py | 45 +- tools/claude/build_claude_packages.py | 2 +- tools/knowledge/academic.py | 22 +- tools/knowledge/local_graph.py | 4 +- tools/knowledge/query.py | 7 +- tools/knowledge/text_retrieval.py | 2 +- tools/retrieval/person_names.py | 4 +- 22 files changed, 1836 insertions(+), 60 deletions(-) create mode 100644 experiments/conversation_context/verification.json create mode 100644 test/test_conversation_context.py create mode 100644 tools/answers/conversation.py diff --git a/app/routes.py b/app/routes.py index 78d4e63..ab23916 100644 --- a/app/routes.py +++ b/app/routes.py @@ -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.") diff --git a/experiments/conversation_context/verification.json b/experiments/conversation_context/verification.json new file mode 100644 index 0000000..c02505a --- /dev/null +++ b/experiments/conversation_context/verification.json @@ -0,0 +1,1129 @@ +{ + "checked_at_utc": "2026-11-10T00:52:20.163245+00:00", + "state": "BLOCK_1_IMPLEMENTED_AND_DEPLOYED_AWAITING_CLAUDE_UI_AND_JOINT_CLOSURE", + "scope": "Question understanding and explicit conversational references. Not the later prose-quality/proposal-generation block or a new accuracy TEST.", + "base_commit": "787cb6dfa38bd2ef410eb6925f46ae1bf3b39c31", + "design": { + "plan": "Shared typed person/role/work/year/period/meeting selector plus requested answer mode.", + "context": "Explicit optional context_token passed by the client. HMAC-SHA256 with domain-separated existing SEARCH_API_KEY, 24-hour expiry, source-index version and source-ID checks. No shared last-user/last-person state or chat database.", + "audit": "Original query is echoed unchanged; resolved_query records only backend interpretation.", + "retrieval": "Exact meeting conditions constrain RAG evidence selection and parametrized Neo4j traversal before final scope checks.", + "followups": [ + "v danom stretnutí", + "vtedy", + "pri tej druhej práci", + "daj obe", + "new aspect", + "new meeting year", + "supervisor role with a new work year" + ], + "fail_closed": [ + "missing context", + "several possible meetings/works", + "different person", + "unknown new person", + "invalid signature", + "expiry", + "different source version", + "scope mismatch" + ], + "answer_modes": [ + "fact", + "description", + "comparison", + "similarity", + "proposal" + ], + "proposal": "Recognized explicitly and returned as proposal_not_available, rather than accepting unrelated factual excerpts as a proposal. Generation belongs to block 2." + }, + "verification": { + "full_regression": { + "command": ".venv/bin/python -m pytest -q test tools -o faulthandler_timeout=45", + "passed": 1582, + "skipped": 90, + "note": "After the full run, one additional displayed-work-option restriction was covered by the final focused suite and public MCP checks." + }, + "final_focused": { + "command": ".venv/bin/python -m pytest -q test/test_conversation_context.py", + "passed": 35 + }, + "known_live_regressions": { + "passed": 28, + "total": 28, + "cases": [ + { + "id": "latest", + "engine": "rag", + "query": "Kedy mal Jakub Schwarc posledné stretnutie a o čom bolo?", + "passed": true, + "status": "ok", + "reason": null, + "answer": "**Jakub Schwarc — Diplomová práca 2027**\nPosledný záznam s úplným dátumom: 2026-11-05.\n\n- Priebežný stav: napísané skripty na spúštanie a prípravu dát a na vyhodnotenie.\n\n- Priebežný stav: Máme prístup na HPC\n\n- Priebežný stav: hľadanie optimálneho LR pomocou optuna na hruska.kemt.fei.tuke.sk - je to dosť pomalé\n\n- Priebežný stav: skúšanie SK bench. Niektoré repozitáre nf HF hub zatiaľ nefungovali.\n\n- Plán: Spustite skript na vyhodnotnotenie na HPC. Pozrite si https://github.com/slovak-nlp/sk-bench/blob/main/container/slurm/README.md\n\n- Plán: Spustite experiment na dotrénovanie na HPC\n\n- Plán: sk bench vyskúšať ešte raz.\n\n- Plán: Nakopírujte si dáta na HPC. Použite projektový adresár /projects/cisloprojektu . Môžete použit scp. Potrebujete tunel.\n\nProfil 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.\n\nZdroje: https://zp.kemt.fei.tuke.sk/students/2022/jakub_schwarc", + "resolved_query": null, + "has_context": true, + "evidence_scope": [ + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomový projekt 2026", + "record_date": null, + "meeting_id": "bbdd7a83d033172418b263d1afc79b80" + } + ] + }, + { + "id": "meeting_followup", + "engine": "rag", + "query": "Na čom pracoval Jakub Schwarc v danom stretnutí?", + "passed": true, + "status": "ok", + "reason": null, + "answer": "**Jakub Schwarc — Diplomová práca 2027**\nDátum požadovaného stretnutia: 2026-11-05.\n\n- Priebežný stav: napísané skripty na spúštanie a prípravu dát a na vyhodnotenie.\n\n- Priebežný stav: Máme prístup na HPC\n\n- Priebežný stav: hľadanie optimálneho LR pomocou optuna na hruska.kemt.fei.tuke.sk - je to dosť pomalé\n\n- Priebežný stav: skúšanie SK bench. Niektoré repozitáre nf HF hub zatiaľ nefungovali.\n\n- Plán: Spustite skript na vyhodnotnotenie na HPC. Pozrite si https://github.com/slovak-nlp/sk-bench/blob/main/container/slurm/README.md\n\n- Plán: Spustite experiment na dotrénovanie na HPC\n\n- Plán: sk bench vyskúšať ešte raz.\n\n- Plán: Nakopírujte si dáta na HPC. Použite projektový adresár /projects/cisloprojektu . Môžete použit scp. Potrebujete tunel.\n\nZdroje: https://zp.kemt.fei.tuke.sk/students/2022/jakub_schwarc", + "resolved_query": "Na čom pracoval Jakub Schwarc na stretnutí 5.11.2026? Pri práci \"Diplomová práca 2027\".", + "has_context": true, + "evidence_scope": [ + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + } + ] + }, + { + "id": "phrase_typo", + "engine": "rag", + "query": "ma com v danom stretnuti pracoval jakub schwarc", + "passed": true, + "status": "ok", + "reason": null, + "answer": "**Jakub Schwarc — Diplomová práca 2027**\nDátum požadovaného stretnutia: 2026-11-05.\n\n- Priebežný stav: napísané skripty na spúštanie a prípravu dát a na vyhodnotenie.\n\n- Priebežný stav: Máme prístup na HPC\n\n- Priebežný stav: hľadanie optimálneho LR pomocou optuna na hruska.kemt.fei.tuke.sk - je to dosť pomalé\n\n- Priebežný stav: skúšanie SK bench. Niektoré repozitáre nf HF hub zatiaľ nefungovali.\n\n- Plán: Spustite skript na vyhodnotnotenie na HPC. Pozrite si https://github.com/slovak-nlp/sk-bench/blob/main/container/slurm/README.md\n\n- Plán: Spustite experiment na dotrénovanie na HPC\n\n- Plán: sk bench vyskúšať ešte raz.\n\n- Plán: Nakopírujte si dáta na HPC. Použite projektový adresár /projects/cisloprojektu . Môžete použit scp. Potrebujete tunel.\n\nZdroje: https://zp.kemt.fei.tuke.sk/students/2022/jakub_schwarc", + "resolved_query": "na čom na stretnutí 5.11.2026 pracoval jakub schwarc Pri práci \"Diplomová práca 2027\".", + "has_context": true, + "evidence_scope": [ + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + } + ] + }, + { + "id": "relative_time", + "engine": "rag", + "query": "Aké úlohy mal vtedy?", + "passed": true, + "status": "ok", + "reason": null, + "answer": "**Jakub Schwarc — Diplomová práca 2027**\nDátum požadovaného stretnutia: 2026-11-05.\n\n- Plán: Spustite skript na vyhodnotnotenie na HPC. Pozrite si https://github.com/slovak-nlp/sk-bench/blob/main/container/slurm/README.md\n\n- Plán: Spustite experiment na dotrénovanie na HPC\n\n- Plán: sk bench vyskúšať ešte raz.\n\n- Plán: Nakopírujte si dáta na HPC. Použite projektový adresár /projects/cisloprojektu . Môžete použit scp. Potrebujete tunel.\n\nZdroje: https://zp.kemt.fei.tuke.sk/students/2022/jakub_schwarc", + "resolved_query": "Aké úlohy mal na stretnutí 5.11.2026? Osoba: Jakub Schwarc. Na stretnutí 5.11.2026. Pri práci \"Diplomová práca 2027\".", + "has_context": true, + "evidence_scope": [ + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + } + ] + }, + { + "id": "old_explicit_date", + "engine": "rag", + "query": "Na čom pracoval Jakub Schwarc na stretnutí 1. 10. 2026?", + "passed": true, + "status": "ok", + "reason": null, + "answer": "**Jakub Schwarc — Diplomová práca 2027**\nDátum požadovaného stretnutia: 2026-10-01.\n\n- Zaznamenaný výsledok: vytvorený repozitár s konfiguráciami\n\n- Priebežný stav: prípravených 10 experimentov s eval a loss priebehom.\n\n- Zaznamenaný výsledok: porovnanie AYA a Alpaca dotrénovania. Alpaca vyzerá lepšie.\n\n- Priebežný stav: vyskúšaná AYA collection dataset - zbehlo 86 percent datasetu. Kolísala evaluácia.\n\n- Plán: Pripravte skript, ktorý zozbiera a pripraví trénovacie dáta, ktoré mame k dispozícii. Dáta rozdeľte na 3 časti. train, test, eval. Test a eval by mali byť tak, nastavené, aby vyhodnotenie na nich netrovalo príáliš dlho. Stačí iba jedna mnoižina ak sú dáta podobné.\n\n- Plán: pripravte skript, ktorý vyhodnotí výsledný model. Na vyhodnotenie použite \"eval\" časť dát.\n\n- Plán: použite vyhodnotenie SK-bench.\n\n- Plán: Použite iný multilinguálny model na dotrénovanie - napr. OLMO.\n\nZobrazený je obmedzený výber. Zúž otázku na študenta, konkrétnu prácu alebo obdobie, prípadne zvýš limit.\n\nZdroje: https://zp.kemt.fei.tuke.sk/students/2022/jakub_schwarc", + "resolved_query": "Na čom pracoval Jakub Schwarc na stretnutí 1.10.2026?", + "has_context": true, + "evidence_scope": [ + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-10-01", + "meeting_id": "528179c268355b28fb9590a21f71850a" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-10-01", + "meeting_id": "528179c268355b28fb9590a21f71850a" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-10-01", + "meeting_id": "528179c268355b28fb9590a21f71850a" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-10-01", + "meeting_id": "528179c268355b28fb9590a21f71850a" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-10-01", + "meeting_id": "528179c268355b28fb9590a21f71850a" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-10-01", + "meeting_id": "528179c268355b28fb9590a21f71850a" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-10-01", + "meeting_id": "528179c268355b28fb9590a21f71850a" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-10-01", + "meeting_id": "528179c268355b28fb9590a21f71850a" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-10-01", + "meeting_id": "528179c268355b28fb9590a21f71850a" + } + ] + }, + { + "id": "missing_context", + "engine": "rag", + "query": "Na čom pracoval Jakub Schwarc v danom stretnutí?", + "passed": true, + "status": "ambiguous", + "reason": "missing_meeting_context", + "answer": "Ktoré stretnutie máš na mysli? Uveď študenta a dátum alebo presné označenie stretnutia.", + "resolved_query": null, + "has_context": true, + "evidence_scope": [] + }, + { + "id": "wrong_context_person", + "engine": "rag", + "query": "Na čom vtedy pracoval Ján Holp?", + "passed": true, + "status": "ambiguous", + "reason": "context_subject_mismatch", + "answer": "Odkaz patrí k inej osobe. Uveď pre aktuálneho študenta konkrétnu prácu alebo dátum stretnutia.", + "resolved_query": null, + "has_context": true, + "evidence_scope": [] + }, + { + "id": "unknown_new_person", + "engine": "rag", + "query": "Aké práce má xaver neexistujuci?", + "passed": true, + "status": "no_answer", + "reason": "unknown_person", + "answer": "Meno „xaver neexistujuci“ sa nepodarilo spoľahlivo priradiť k osobe v dostupných podkladoch. Uveď presné celé meno.", + "resolved_query": null, + "has_context": false, + "evidence_scope": [] + }, + { + "id": "forged_context", + "engine": "rag", + "query": "Na čom vtedy pracoval?", + "passed": true, + "status": "error", + "reason": "invalid_context", + "answer": "Kontext predchádzajúcej otázky sa nepodarilo overiť. Zopakuj úplnú otázku s osobou, prácou a prípadným dátumom.", + "resolved_query": null, + "has_context": false, + "evidence_scope": [] + }, + { + "id": "proposal_route", + "engine": "rag", + "query": "Čo by sa dalo spojiť z práce Jána Ptáka a Jakuba Schwarca?", + "passed": true, + "status": "error", + "reason": "proposal_not_available", + "answer": "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.", + "resolved_query": null, + "has_context": false, + "evidence_scope": [] + }, + { + "id": "ambiguous_work", + "engine": "rag", + "query": "Aké metódy používal Ján Holp pri diplomovom projekte v roku 2020?", + "passed": true, + "status": "ambiguous", + "reason": "ambiguous_work", + "answer": "Ktorú prácu osoby Ján Holp máš na mysli? V indexe sú tieto možnosti: Diplomový projekt 2020; Diplomový projekt 2 2020. Uveď presné označenie projektu.", + "resolved_query": null, + "has_context": true, + "evidence_scope": [] + }, + { + "id": "both_works", + "engine": "rag", + "query": "Daj obe.", + "passed": true, + "status": "ok", + "reason": null, + "answer": "**Ján Holp — Diplomový projekt 2 2020**\n\nZaznamenané výsledky (dátum záznamu: 2020-11-13):\n- Vyriešil technické problémy s Cassandra a umožnil indexovanie z Cassandra do Elasticsearch. [1]\n\n**Ján Holp — Diplomový projekt 2020**\n\nZaznamenané výsledky (dátum záznamu: 2020-06-23):\n- Zabezpečil funkčnosť databázy Cassandra. [1]\n- Zabezpečil funkčnosť indexovania zo súboru. [1]\n\nZdroje: [1](https://zp.kemt.fei.tuke.sk/students/2016/jan_holp)\n\n[1]: https://zp.kemt.fei.tuke.sk/students/2016/jan_holp", + "resolved_query": "Aké metódy používal Ján Holp pri diplomovom projekte v roku 2020? Pýtam sa na obe práce: \"Diplomový projekt 2020\" aj \"Diplomový projekt 2 2020\".", + "has_context": true, + "evidence_scope": [ + { + "person": "Ján Holp", + "work": "Diplomový projekt 2 2020", + "record_date": "2020-11-13", + "meeting_id": "756526bbb8f178e89e178eb8414d71a2" + }, + { + "person": "Ján Holp", + "work": "Diplomový projekt 2020", + "record_date": "2020-06-23", + "meeting_id": "bdd04ff26753878f8926fd23a48bebe8" + }, + { + "person": "Ján Holp", + "work": "Diplomový projekt 2020", + "record_date": "2020-06-23", + "meeting_id": "bdd04ff26753878f8926fd23a48bebe8" + } + ] + }, + { + "id": "second_work", + "engine": "rag", + "query": "Pri tej druhej práci.", + "passed": true, + "status": "ok", + "reason": null, + "answer": "**Ján Holp — Diplomový projekt 2 2020**\n\nZaznamenané výsledky (dátum záznamu: 2020-11-13): Boli vyriešené technické problémy s Cassandra a umožnené indexovanie z Cassandra do Elasticsearch. [1]\n\nZdroje: [1](https://zp.kemt.fei.tuke.sk/students/2016/jan_holp)\n\n[1]: https://zp.kemt.fei.tuke.sk/students/2016/jan_holp", + "resolved_query": "Aké metódy používal Ján Holp pri diplomovom projekte v roku 2020? Pýtam sa na prácu: \"Diplomový projekt 2 2020\".", + "has_context": true, + "evidence_scope": [ + { + "person": "Ján Holp", + "work": "Diplomový projekt 2 2020", + "record_date": "2020-11-13", + "meeting_id": "756526bbb8f178e89e178eb8414d71a2" + } + ] + }, + { + "id": "fresh_holp_title", + "engine": "rag", + "query": "Aký je názov diplomovej práce Jána Holpa a z ktorého roku je?", + "passed": true, + "status": "ok", + "reason": null, + "answer": "Ján Holp má diplomovú prácu s názvom **Systém získavania informácií v slovenskom jazyku** (rok práce: **2021**).\n\nZdroj: https://zp.kemt.fei.tuke.sk/students/2016/jan_holp", + "resolved_query": null, + "has_context": true, + "evidence_scope": [] + }, + { + "id": "latest", + "engine": "graphrag", + "query": "Kedy mal Jakub Schwarc posledné stretnutie a o čom bolo?", + "passed": true, + "status": "ok", + "reason": null, + "answer": "**Jakub Schwarc — Diplomová práca 2027**\nPosledný záznam s úplným dátumom: 2026-11-05.\n\n- Priebežný stav: napísané skripty na spúštanie a prípravu dát a na vyhodnotenie.\n\n- Priebežný stav: Máme prístup na HPC\n\n- Priebežný stav: hľadanie optimálneho LR pomocou optuna na hruska.kemt.fei.tuke.sk - je to dosť pomalé\n\n- Priebežný stav: skúšanie SK bench. Niektoré repozitáre nf HF hub zatiaľ nefungovali.\n\n- Plán: Spustite skript na vyhodnotnotenie na HPC. Pozrite si https://github.com/slovak-nlp/sk-bench/blob/main/container/slurm/README.md\n\n- Plán: Spustite experiment na dotrénovanie na HPC\n\n- Plán: sk bench vyskúšať ešte raz.\n\n- Plán: Nakopírujte si dáta na HPC. Použite projektový adresár /projects/cisloprojektu . Môžete použit scp. Potrebujete tunel.\n\nProfil 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.\n\nZdroje: https://zp.kemt.fei.tuke.sk/students/2022/jakub_schwarc", + "resolved_query": null, + "has_context": true, + "evidence_scope": [ + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomový projekt 2026", + "record_date": null, + "meeting_id": "bbdd7a83d033172418b263d1afc79b80" + } + ] + }, + { + "id": "meeting_followup", + "engine": "graphrag", + "query": "Na čom pracoval Jakub Schwarc v danom stretnutí?", + "passed": true, + "status": "ok", + "reason": null, + "answer": "**Jakub Schwarc — Diplomová práca 2027**\nDátum požadovaného stretnutia: 2026-11-05.\n\n- Priebežný stav: napísané skripty na spúštanie a prípravu dát a na vyhodnotenie.\n\n- Priebežný stav: Máme prístup na HPC\n\n- Priebežný stav: hľadanie optimálneho LR pomocou optuna na hruska.kemt.fei.tuke.sk - je to dosť pomalé\n\n- Priebežný stav: skúšanie SK bench. Niektoré repozitáre nf HF hub zatiaľ nefungovali.\n\n- Plán: Spustite skript na vyhodnotnotenie na HPC. Pozrite si https://github.com/slovak-nlp/sk-bench/blob/main/container/slurm/README.md\n\n- Plán: Spustite experiment na dotrénovanie na HPC\n\n- Plán: sk bench vyskúšať ešte raz.\n\n- Plán: Nakopírujte si dáta na HPC. Použite projektový adresár /projects/cisloprojektu . Môžete použit scp. Potrebujete tunel.\n\nZdroje: https://zp.kemt.fei.tuke.sk/students/2022/jakub_schwarc", + "resolved_query": "Na čom pracoval Jakub Schwarc na stretnutí 5.11.2026? Pri práci \"Diplomová práca 2027\".", + "has_context": true, + "evidence_scope": [ + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + } + ] + }, + { + "id": "phrase_typo", + "engine": "graphrag", + "query": "ma com v danom stretnuti pracoval jakub schwarc", + "passed": true, + "status": "ok", + "reason": null, + "answer": "**Jakub Schwarc — Diplomová práca 2027**\nDátum požadovaného stretnutia: 2026-11-05.\n\n- Priebežný stav: napísané skripty na spúštanie a prípravu dát a na vyhodnotenie.\n\n- Priebežný stav: Máme prístup na HPC\n\n- Priebežný stav: hľadanie optimálneho LR pomocou optuna na hruska.kemt.fei.tuke.sk - je to dosť pomalé\n\n- Priebežný stav: skúšanie SK bench. Niektoré repozitáre nf HF hub zatiaľ nefungovali.\n\n- Plán: Spustite skript na vyhodnotnotenie na HPC. Pozrite si https://github.com/slovak-nlp/sk-bench/blob/main/container/slurm/README.md\n\n- Plán: Spustite experiment na dotrénovanie na HPC\n\n- Plán: sk bench vyskúšať ešte raz.\n\n- Plán: Nakopírujte si dáta na HPC. Použite projektový adresár /projects/cisloprojektu . Môžete použit scp. Potrebujete tunel.\n\nZdroje: https://zp.kemt.fei.tuke.sk/students/2022/jakub_schwarc", + "resolved_query": "na čom na stretnutí 5.11.2026 pracoval jakub schwarc Pri práci \"Diplomová práca 2027\".", + "has_context": true, + "evidence_scope": [ + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + } + ] + }, + { + "id": "relative_time", + "engine": "graphrag", + "query": "Aké úlohy mal vtedy?", + "passed": true, + "status": "ok", + "reason": null, + "answer": "**Jakub Schwarc — Diplomová práca 2027**\nDátum požadovaného stretnutia: 2026-11-05.\n\n- Plán: Spustite skript na vyhodnotnotenie na HPC. Pozrite si https://github.com/slovak-nlp/sk-bench/blob/main/container/slurm/README.md\n\n- Plán: Spustite experiment na dotrénovanie na HPC\n\n- Plán: sk bench vyskúšať ešte raz.\n\n- Plán: Nakopírujte si dáta na HPC. Použite projektový adresár /projects/cisloprojektu . Môžete použit scp. Potrebujete tunel.\n\nZdroje: https://zp.kemt.fei.tuke.sk/students/2022/jakub_schwarc", + "resolved_query": "Aké úlohy mal na stretnutí 5.11.2026? Osoba: Jakub Schwarc. Na stretnutí 5.11.2026. Pri práci \"Diplomová práca 2027\".", + "has_context": true, + "evidence_scope": [ + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-11-05", + "meeting_id": "d7385f9350e8a9ecd2b125e333c68e82" + } + ] + }, + { + "id": "old_explicit_date", + "engine": "graphrag", + "query": "Na čom pracoval Jakub Schwarc na stretnutí 1. 10. 2026?", + "passed": true, + "status": "ok", + "reason": null, + "answer": "**Jakub Schwarc — Diplomová práca 2027**\nDátum požadovaného stretnutia: 2026-10-01.\n\n- Zaznamenaný výsledok: vytvorený repozitár s konfiguráciami\n\n- Priebežný stav: prípravených 10 experimentov s eval a loss priebehom.\n\n- Zaznamenaný výsledok: porovnanie AYA a Alpaca dotrénovania. Alpaca vyzerá lepšie.\n\n- Priebežný stav: vyskúšaná AYA collection dataset - zbehlo 86 percent datasetu. Kolísala evaluácia.\n\n- Plán: Pripravte skript, ktorý zozbiera a pripraví trénovacie dáta, ktoré mame k dispozícii. Dáta rozdeľte na 3 časti. train, test, eval. Test a eval by mali byť tak, nastavené, aby vyhodnotenie na nich netrovalo príáliš dlho. Stačí iba jedna mnoižina ak sú dáta podobné.\n\n- Plán: pripravte skript, ktorý vyhodnotí výsledný model. Na vyhodnotenie použite \"eval\" časť dát.\n\n- Plán: použite vyhodnotenie SK-bench.\n\n- Plán: Použite iný multilinguálny model na dotrénovanie - napr. OLMO.\n\nZobrazený je obmedzený výber. Zúž otázku na študenta, konkrétnu prácu alebo obdobie, prípadne zvýš limit.\n\nZdroje: https://zp.kemt.fei.tuke.sk/students/2022/jakub_schwarc", + "resolved_query": "Na čom pracoval Jakub Schwarc na stretnutí 1.10.2026?", + "has_context": true, + "evidence_scope": [ + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-10-01", + "meeting_id": "528179c268355b28fb9590a21f71850a" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-10-01", + "meeting_id": "528179c268355b28fb9590a21f71850a" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-10-01", + "meeting_id": "528179c268355b28fb9590a21f71850a" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-10-01", + "meeting_id": "528179c268355b28fb9590a21f71850a" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-10-01", + "meeting_id": "528179c268355b28fb9590a21f71850a" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-10-01", + "meeting_id": "528179c268355b28fb9590a21f71850a" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-10-01", + "meeting_id": "528179c268355b28fb9590a21f71850a" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-10-01", + "meeting_id": "528179c268355b28fb9590a21f71850a" + }, + { + "person": "Jakub Schwarc", + "work": "Diplomová práca 2027", + "record_date": "2026-10-01", + "meeting_id": "528179c268355b28fb9590a21f71850a" + } + ] + }, + { + "id": "missing_context", + "engine": "graphrag", + "query": "Na čom pracoval Jakub Schwarc v danom stretnutí?", + "passed": true, + "status": "ambiguous", + "reason": "missing_meeting_context", + "answer": "Ktoré stretnutie máš na mysli? Uveď študenta a dátum alebo presné označenie stretnutia.", + "resolved_query": null, + "has_context": true, + "evidence_scope": [] + }, + { + "id": "wrong_context_person", + "engine": "graphrag", + "query": "Na čom vtedy pracoval Ján Holp?", + "passed": true, + "status": "ambiguous", + "reason": "context_subject_mismatch", + "answer": "Odkaz patrí k inej osobe. Uveď pre aktuálneho študenta konkrétnu prácu alebo dátum stretnutia.", + "resolved_query": null, + "has_context": true, + "evidence_scope": [] + }, + { + "id": "unknown_new_person", + "engine": "graphrag", + "query": "Aké práce má xaver neexistujuci?", + "passed": true, + "status": "no_answer", + "reason": "unknown_person", + "answer": "Meno „xaver neexistujuci“ sa nepodarilo spoľahlivo priradiť k osobe v dostupných podkladoch. Uveď presné celé meno.", + "resolved_query": null, + "has_context": false, + "evidence_scope": [] + }, + { + "id": "forged_context", + "engine": "graphrag", + "query": "Na čom vtedy pracoval?", + "passed": true, + "status": "error", + "reason": "invalid_context", + "answer": "Kontext predchádzajúcej otázky sa nepodarilo overiť. Zopakuj úplnú otázku s osobou, prácou a prípadným dátumom.", + "resolved_query": null, + "has_context": false, + "evidence_scope": [] + }, + { + "id": "proposal_route", + "engine": "graphrag", + "query": "Čo by sa dalo spojiť z práce Jána Ptáka a Jakuba Schwarca?", + "passed": true, + "status": "error", + "reason": "proposal_not_available", + "answer": "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.", + "resolved_query": null, + "has_context": false, + "evidence_scope": [] + }, + { + "id": "ambiguous_work", + "engine": "graphrag", + "query": "Aké metódy používal Ján Holp pri diplomovom projekte v roku 2020?", + "passed": true, + "status": "ambiguous", + "reason": "ambiguous_work", + "answer": "Ktorú prácu osoby Ján Holp máš na mysli? V indexe sú tieto možnosti: Diplomový projekt 2 2020; Diplomový projekt 2020. Uveď presné označenie projektu.", + "resolved_query": null, + "has_context": true, + "evidence_scope": [] + }, + { + "id": "both_works", + "engine": "graphrag", + "query": "Daj obe.", + "passed": true, + "status": "ok", + "reason": null, + "answer": "**Ján Holp — Diplomový projekt 2 2020**\n\nZaznamenané výsledky (dátum záznamu: 2020-11-13):\n- Pri práci na projekte sa vyriešili technické problémy s Cassandra a umožnilo sa indexovanie z Cassandra do ES. [1]\n\n**Ján Holp — Diplomový projekt 2020**\n\nZaznamenané výsledky (dátum záznamu: 2020-06-23):\n- V rámci projektu bola zavedená a funkčná databáza Cassandra. [1]\n- Bolo implementované a funkčné indexovanie zo súboru. [1]\n\nZdroje: [1](https://zp.kemt.fei.tuke.sk/students/2016/jan_holp)\n\n[1]: https://zp.kemt.fei.tuke.sk/students/2016/jan_holp", + "resolved_query": "Aké metódy používal Ján Holp pri diplomovom projekte v roku 2020? Pýtam sa na obe práce: \"Diplomový projekt 2 2020\" aj \"Diplomový projekt 2020\".", + "has_context": true, + "evidence_scope": [ + { + "person": "Ján Holp", + "work": "Diplomový projekt 2 2020", + "record_date": "2020-11-13", + "meeting_id": "756526bbb8f178e89e178eb8414d71a2" + }, + { + "person": "Ján Holp", + "work": "Diplomový projekt 2020", + "record_date": "2020-06-23", + "meeting_id": "bdd04ff26753878f8926fd23a48bebe8" + }, + { + "person": "Ján Holp", + "work": "Diplomový projekt 2020", + "record_date": "2020-06-23", + "meeting_id": "bdd04ff26753878f8926fd23a48bebe8" + } + ] + }, + { + "id": "second_work", + "engine": "graphrag", + "query": "Pri tej druhej práci.", + "passed": true, + "status": "ok", + "reason": null, + "answer": "**Ján Holp — Diplomový projekt 2020**\n\nZaznamenané výsledky (dátum záznamu: 2020-06-23): Pri diplomovom projekte bola implementovaná databáza Cassandra. [1] Bolo realizované indexovanie zo súboru. [1]\n\nZdroje: [1](https://zp.kemt.fei.tuke.sk/students/2016/jan_holp)\n\n[1]: https://zp.kemt.fei.tuke.sk/students/2016/jan_holp", + "resolved_query": "Aké metódy používal Ján Holp pri diplomovom projekte v roku 2020? Pýtam sa na prácu: \"Diplomový projekt 2020\".", + "has_context": true, + "evidence_scope": [ + { + "person": "Ján Holp", + "work": "Diplomový projekt 2020", + "record_date": "2020-06-23", + "meeting_id": "bdd04ff26753878f8926fd23a48bebe8" + }, + { + "person": "Ján Holp", + "work": "Diplomový projekt 2020", + "record_date": "2020-06-23", + "meeting_id": "bdd04ff26753878f8926fd23a48bebe8" + } + ] + }, + { + "id": "fresh_holp_title", + "engine": "graphrag", + "query": "Aký je názov diplomovej práce Jána Holpa a z ktorého roku je?", + "passed": true, + "status": "ok", + "reason": null, + "answer": "Ján Holp má diplomovú prácu s názvom **Systém získavania informácií v slovenskom jazyku** (rok práce: **2021**).\n\nZdroj: https://zp.kemt.fei.tuke.sk/students/2016/jan_holp", + "resolved_query": null, + "has_context": true, + "evidence_scope": [] + } + ], + "note": "Expected ambiguity/error behavior is tested as such; passing an unavailable proposal-mode case does not claim proposal generation is implemented. No independent accuracy estimate." + }, + "public_mcp": { + "public_url": "https://pressed-extra-graduation-readings.trycloudflare.com/mcp", + "tools_schema_has_context": true, + "results": [ + { + "engine": "rag", + "case": "latest", + "passed": true + }, + { + "engine": "rag", + "case": "relative_meeting_tasks", + "passed": true, + "answer": "**Jakub Schwarc — Diplomová práca 2027**\nDátum požadovaného stretnutia: 2026-11-05.\n\n- Plán: Spustite skript na vyhodnotnotenie na HPC. Pozrite si https://github.com/slovak-nlp/sk-bench/blob/main/container/slurm/README.md\n\n- Plán: Spustite experiment na dotrénovanie na HPC\n\n- Plán: sk bench vyskúšať ešte raz.\n\n- Plán: Nakopírujte si dáta na HPC. Použite projektový adresár /projects/cisloprojektu . Môžete použit scp. Potrebujete tunel.\n\nZdroje: https://zp.kemt.fei.tuke.sk/students/2022/jakub_schwarc" + }, + { + "engine": "rag", + "case": "missing_context", + "passed": true + }, + { + "engine": "graphrag", + "case": "latest", + "passed": true + }, + { + "engine": "graphrag", + "case": "relative_meeting_tasks", + "passed": true, + "answer": "**Jakub Schwarc — Diplomová práca 2027**\nDátum požadovaného stretnutia: 2026-11-05.\n\n- Plán: Spustite skript na vyhodnotnotenie na HPC. Pozrite si https://github.com/slovak-nlp/sk-bench/blob/main/container/slurm/README.md\n\n- Plán: Spustite experiment na dotrénovanie na HPC\n\n- Plán: sk bench vyskúšať ešte raz.\n\n- Plán: Nakopírujte si dáta na HPC. Použite projektový adresár /projects/cisloprojektu . Môžete použit scp. Potrebujete tunel.\n\nZdroje: https://zp.kemt.fei.tuke.sk/students/2022/jakub_schwarc" + }, + { + "engine": "graphrag", + "case": "missing_context", + "passed": true + } + ] + }, + "oauth": { + "passed": true, + "checks": [ + "discovery", + "S256 PKCE", + "DCR", + "owner login and consent", + "code replay rejection", + "MCP tool discovery", + "refresh rotation", + "test grant revocation" + ], + "test_grants_revoked": true + }, + "skill_packages": { + "version": "1.9.0", + "reproducible_archives_match_current_source": true, + "manifest": { + "packages": [ + { + "archive": "zpwiki-rag.zip", + "name": "rag", + "sha256": "f55d72cdf1bb751807221ade986ac582098ee41c3e49a2ac3b291742935f13f2" + }, + { + "archive": "zpwiki-graphrag.zip", + "name": "graphrag", + "sha256": "78ec35715f31a2be7ca5c155506a499415fa49e644ed6309ea680bb46ab943f8" + } + ], + "schema_version": 1 + } + }, + "runtime": { + "api_files_match_workspace": true, + "mcp_files_match_workspace": true, + "sqlite_integrity": "ok", + "foreign_key_errors": 0, + "metadata_versions_equal": true, + "content_checkpoint_and_graph_match": true, + "index_version": { + "version": "815b67d4b9c54cbcb5d6bd87251fd2ef", + "git_revision": "c3ec9f611e645a2fe14fe66d518eb0924bf9bb40", + "created_at_utc": "2026-11-09T20:49:43.773353+00:00", + "source_sha256": "201798bf276106986e7ef01b4d6357960cb495d09c9855b7df922c1df4fb35a7", + "documents_sha256": "8b7eea54f6233cdc9d16610296d32b9336ab0cbaa4b881872a088c05464960be", + "chunks_sha256": "8357d1e0641206990d26827b94f37916661595261f368e30f828bccd03171c95", + "embedding_model": "intfloat/multilingual-e5-small", + "publication_policy": "explicit-false-hidden-v1", + "embedding_fingerprint": "d066fb9a266a1a0c77c65da75dcf538cfab5c87d5c605ff26b1fd2da8a12aa92" + }, + "content_version": "9440b8501af042b871e40de3fdce8899", + "capabilities_ready": { + "text_search": true, + "metadata_graph": true, + "knowledge": true, + "generation": false + } + }, + "wiki": { + "revision": "c3ec9f611e645a2fe14fe66d518eb0924bf9bb40", + "local_worktree_clean": true, + "wiki_writes_performed": false + } + }, + "remaining_limits": [ + "User must update both Claude Skills and refresh the connector tool schema before testing the browser UI. Actual browser behavior is not verified by protocol tests alone.", + "The host must pass its own latest answer token explicitly. The server has no independent access to the entire human/Claude conversation; tokens are not a substitute for host-side question fidelity.", + "Natural meeting prose and actual recommendation generation belong to the next block. This change does not claim those are complete.", + "Unknown/ambiguous references need a concrete clarification; no unrestricted search is substituted.", + "Quick Tunnel still requires the running laptop/Docker and may get a new address after renewal.", + "No new independent accuracy score or 94% claim is made. Existing consumed TEST artifacts are unchanged." + ], + "references": [ + "https://modelcontextprotocol.io/specification/2025-06-18/server/tools", + "https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents" + ], + "runtime_sha256": { + "tools/answers/answer_coverage.py": "746ab3b89ba65f263b63037be55ee02ee25aaaf685dae6e95f7bd6a5536fc6fa", + "tools/answers/rag_metadata.py": "a16486932e66aed571fd25cb59c5656892613488ea90afe80c41b0c1d15afcf1", + "tools/answers/checked_generation.py": "e36def33fb83e5c973fa632685b4782fa411a7eeb7151ef8c14007f58fa1f375", + "tools/answers/answer_delivery.py": "9dc14e563d441ac813bf76d92be87635fa7dfbc30bcfa832c218410636270cf3", + "tools/answers/presentation.py": "954c65288ddc3c8f873fec3ef99176adb70785806043ac1814c7a01a267b4aad", + "tools/answers/content_selection.py": "8e09f4423f2fd3b253f5779f62849b11b9570a112a472ea07392468901e3d80e", + "tools/answers/synthesis.py": "d669136a99d71b9046119debc077f2ab51336ab8ffe1bcd5e5853cfa8ba8e748", + "tools/answers/graph_context.py": "5b14150b7f6903cd92c004d56943d949167be8859acf4f97aed14a713e59faff", + "tools/answers/model_client.py": "f595eb1b594364faff21ce2a6842c8c7e40a5ca4e6a2c3085cdc4a4bae28ae04", + "tools/answers/answer_errors.py": "3bc883d4e58d1f74ea2527f93867fe04231a09d24964dfec4436c97a1eaeddc4", + "tools/answers/context_policy.py": "526f5de0b9112e23abd76e403799f156d0150c83f03577251af23d1685991327", + "tools/answers/verified_rag.py": "b2e9777b269f2b19c16ad9e1aa86644f09a9ad96489358a1294e51d21025611e", + "tools/answers/answer_format.py": "533cfe3a30c835d22441d877df63fe08e366d01ed6d15474fe6fcff26bde259b", + "tools/answers/evidence_selection.py": "1007a85b8838e1eb3c34ee610a4f81dfff57410a90213e02b8ccde90a0fedbd2", + "tools/answers/__init__.py": "8b8ab7f996121e5e5b1b594cf08f4c3b4f16f1b434b74276c803a3a83681d2cb", + "tools/answers/conversation.py": "24e77e208d164de06e7294ba9a0618a8b546861893e1bd2cc9f42f0ee079c2b3", + "tools/answers/rag_preparation.py": "98468b111c9f8f6d423f3c6a07300c3ad7a7e9fef5bae1d1385b91a04771e069", + "tools/answers/rag_evidence.py": "351b99eb5757991696dbe10dcb794cf02aea7c2232c2b7eabab4e3bf1a7ad687", + "tools/answers/reference_links.py": "eb59206bcb3b79912daafcd591e7adb504ec592e61ea32e4e133cb6a65fe0b57", + "tools/answers/render_answer.py": "953cae2cdbdbf05a1b0c1f2746c4dc5f0d85c9bcfd02c105df38a3ff1d2451af", + "tools/answers/question_plan.py": "e30f4f9f67723bb17a627469d0800621600cef05810747bded973157351517ed", + "tools/answers/question_intent.py": "a044a75ab04c57c11763b78775cc63673debed8f543523ee279e89db1f204a5b", + "tools/knowledge/concepts.py": "de0d005ab298c3d86020b0cb09fc695852303d8a5f411d0a3828b58131713b8d", + "tools/knowledge/sources.py": "709c2201288a920e99578fe4e58e191a742deb5f15170104820680d156280c69", + "tools/knowledge/publication.py": "a5a29af22fd73a57f53da47c687200f0ad392d48ce94a23250f00c7a53731148", + "tools/knowledge/academic.py": "6d39cdd747b5ece8803605f0713a9afeeee1813d3e49bb9ff2ac7e0ae4c3b73d", + "tools/knowledge/__init__.py": "905a19d7cf5d95379800b2ad917ad759fa376f23d2f8a244527b9dc187555019", + "tools/knowledge/evidence.py": "b0eb7d3e0f6a97083829a9940b3a64dade96d4f98435eb5ebfb43adaf10fac18", + "tools/knowledge/graph_answers.py": "fd599b7ff65485ae11e77022cc007b99e5bd3b533383cc64dc5b7aa3dabf81fa", + "tools/knowledge/store.py": "fa22cd357df8c3cde4cd1b6a4f0a9b78d12721f3970ec896584205f8a5d5d7c1", + "tools/knowledge/extraction.py": "c1819afc5072a5999c985b4547809db997ecf3e1112b031534ae34ed295bd106", + "tools/knowledge/local_graph.py": "58af4bd5b888756728739e26d8bc9f4cba1aaf75853c17e11453d4f38fa29f1e", + "tools/knowledge/graph_projection.py": "dac3058008fb43c833d239b6a7ced27ef5419004cc768b246741eb2e1a69b12a", + "tools/knowledge/text_retrieval.py": "946911f6b1aac8ccf1861c33b75b574df1deca810787b3bd0b2c708a9dd20f71", + "tools/knowledge/query.py": "ea372294da8e8f928328b467cfb7a652ee9b693bff42f346f6f1a4267de10b73", + "tools/knowledge/build.py": "4666b1cd2d42533b7289f5e2ecaac11c5cc16e09f3ea4b9a2714d17cb8c2f9f6", + "tools/knowledge/planned_answers.py": "e5820cb6c8d6e15315ae94e263e2886b0309b6c7eee8e09338750adbede2aa8c", + "tools/graphrag/graph_search.py": "eff65f570d3a52a9cb4132db8f3c4b59dc08840001ae1bfbab6c7ab5eda09bb0", + "tools/retrieval/person_names.py": "c4f492e75acfc205ef6172bba7ea69d6424a60f4554cd79529edaab4b28238d7", + "tools/rag/llm_context.py": "8f195ff16639de4379c41108f7a3a456d90d985307ebde3691c1406b3f76dfa5", + "app/routes.py": "55b2178834dc0e98cf832c4c87ef07304a99b0ebffc9d4b41093fbfaaed10235", + "app/main.py": "38ae3f77e8a0ddec414459da1ec17d5247d2f9457bc7ceb2af17d442658a1d28" + }, + "mcp_sha256": { + "client.py": "b1ce6d41714396afb57e87149c20415451ff16d97fe0d5d5afbe1332374ae516", + "mcp_server.py": "c5f37ea54687af8950ac73df29b9d047939af04528bc3572b8b47bb04d2d5513", + "oauth_provider.py": "85e00facfe9c772b174b57f125986a329e3b1bc99f72f4a7f553b763fbea166b" + } +} diff --git a/packages/claude/client.py b/packages/claude/client.py index 2f6362e..3f353e0 100644 --- a/packages/claude/client.py +++ b/packages/claude/client.py @@ -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) diff --git a/packages/claude/dist/packages.json b/packages/claude/dist/packages.json index 6b33982..25e5a7f 100644 --- a/packages/claude/dist/packages.json +++ b/packages/claude/dist/packages.json @@ -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 diff --git a/packages/claude/dist/zpwiki-graphrag.zip b/packages/claude/dist/zpwiki-graphrag.zip index ea83b5bda446ba7863a942de82b91867cb13e8ea..5cfb30943701fee4aa45e21aeb280e4aabafa5b2 100644 GIT binary patch delta 9192 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 delta 8456 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

9+r*Q=BF9(kl%qLYjf+1V%VcngrW_6z%ue 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 diff --git a/packages/claude/dist/zpwiki-rag.zip b/packages/claude/dist/zpwiki-rag.zip index 2f66ed639db49abfa947b89375c2bfcc0747d555..d4de847ee6948bbe047edc42e697e6f7bc5eb172 100644 GIT binary patch delta 9234 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 delta 8666 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 diff --git a/packages/claude/graphrag/SKILL.md b/packages/claude/graphrag/SKILL.md index fc7fb8c..b1c068e 100644 --- a/packages/claude/graphrag/SKILL.md +++ b/packages/claude/graphrag/SKILL.md @@ -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. diff --git a/packages/claude/mcp_server.py b/packages/claude/mcp_server.py index b65d917..f9594bd 100644 --- a/packages/claude/mcp_server.py +++ b/packages/claude/mcp_server.py @@ -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 {})) ) ) diff --git a/packages/claude/rag/SKILL.md b/packages/claude/rag/SKILL.md index 83b38b6..65a572c 100644 --- a/packages/claude/rag/SKILL.md +++ b/packages/claude/rag/SKILL.md @@ -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. diff --git a/test/test_conversation_context.py b/test/test_conversation_context.py new file mode 100644 index 0000000..35ce3e2 --- /dev/null +++ b/test/test_conversation_context.py @@ -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' diff --git a/test/test_question_delivery.py b/test/test_question_delivery.py index d6632e0..1800a98 100644 --- a/test/test_question_delivery.py +++ b/test/test_question_delivery.py @@ -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: diff --git a/tools/answers/answer_delivery.py b/tools/answers/answer_delivery.py index 3bf7025..f0fab7b 100644 --- a/tools/answers/answer_delivery.py +++ b/tools/answers/answer_delivery.py @@ -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( diff --git a/tools/answers/answer_errors.py b/tools/answers/answer_errors.py index 482ef7a..b8ef4e9 100644 --- a/tools/answers/answer_errors.py +++ b/tools/answers/answer_errors.py @@ -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; " diff --git a/tools/answers/conversation.py b/tools/answers/conversation.py new file mode 100644 index 0000000..bf8a229 --- /dev/null +++ b/tools/answers/conversation.py @@ -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']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 diff --git a/tools/answers/question_intent.py b/tools/answers/question_intent.py index a87c615..6637fd3 100644 --- a/tools/answers/question_intent.py +++ b/tools/answers/question_intent.py @@ -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'(? 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))?[.!?]?", diff --git a/tools/answers/question_plan.py b/tools/answers/question_plan.py index 50461ec..ab10111 100644 --- a/tools/answers/question_plan.py +++ b/tools/answers/question_plan.py @@ -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ť.', diff --git a/tools/claude/build_claude_packages.py b/tools/claude/build_claude_packages.py index 319debc..0e73fb2 100644 --- a/tools/claude/build_claude_packages.py +++ b/tools/claude/build_claude_packages.py @@ -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): diff --git a/tools/knowledge/academic.py b/tools/knowledge/academic.py index 2d8c24c..bfcb8d8 100644 --- a/tools/knowledge/academic.py +++ b/tools/knowledge/academic.py @@ -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." diff --git a/tools/knowledge/local_graph.py b/tools/knowledge/local_graph.py index a9c2a24..08d9a36 100644 --- a/tools/knowledge/local_graph.py +++ b/tools/knowledge/local_graph.py @@ -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, diff --git a/tools/knowledge/query.py b/tools/knowledge/query.py index d21bf1f..0ef091e 100644 --- a/tools/knowledge/query.py +++ b/tools/knowledge/query.py @@ -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 diff --git a/tools/knowledge/text_retrieval.py b/tools/knowledge/text_retrieval.py index bfcdc23..5da14e6 100644 --- a/tools/knowledge/text_retrieval.py +++ b/tools/knowledge/text_retrieval.py @@ -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 diff --git a/tools/retrieval/person_names.py b/tools/retrieval/person_names.py index 6f30b87..39f8034 100644 --- a/tools/retrieval/person_names.py +++ b/tools/retrieval/person_names.py @@ -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 = [