From 6d0654db472e365b506b6fab8411c237e70617a8 Mon Sep 17 00:00:00 2001 From: jp170na Date: Sat, 7 Nov 2026 15:08:05 +0100 Subject: [PATCH] =?UTF-8?q?Akademick=C3=A9=20=C3=BAdaje?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/claude/dist/packages.json | 4 +- packages/claude/dist/zpwiki-graphrag.zip | Bin 8278 -> 8502 bytes packages/claude/dist/zpwiki-rag.zip | Bin 8449 -> 8744 bytes packages/claude/graphrag/SKILL.md | 6 + packages/claude/mcp_server.py | 3 + packages/claude/rag/SKILL.md | 9 + test/test_academic.py | 166 +++++++++++++ test/test_claude_packages.py | 16 ++ test/test_knowledge_live.py | 2 + test/test_source_audit.py | 4 +- tools/answers/answer_delivery.py | 4 +- tools/claude/build_claude_packages.py | 2 +- tools/knowledge/academic.py | 303 +++++++++++++++++++++++ tools/knowledge/build.py | 14 +- tools/knowledge/query.py | 8 +- tools/knowledge/questions.json | 10 +- tools/knowledge/sources.py | 38 ++- tools/knowledge/store.py | 39 ++- tools/retrieval/person_names.py | 6 +- 19 files changed, 612 insertions(+), 22 deletions(-) create mode 100644 test/test_academic.py create mode 100644 tools/knowledge/academic.py diff --git a/packages/claude/dist/packages.json b/packages/claude/dist/packages.json index 398fc8b..73249ad 100644 --- a/packages/claude/dist/packages.json +++ b/packages/claude/dist/packages.json @@ -3,12 +3,12 @@ { "archive": "zpwiki-rag.zip", "name": "rag", - "sha256": "812764028d95fc0b9f0ebd52f3aa20f38984eb1d3477da3f55a633703d423123" + "sha256": "3eb3a59827b3eda400c7a5cd5dc3909831d5343903b159a71d843fb712970b52" }, { "archive": "zpwiki-graphrag.zip", "name": "graphrag", - "sha256": "f548b9a4ec313fd687cffb2f004435cae0f4b40a0e5f57a955fde4dff27cb5d1" + "sha256": "99cf35d9050b5942b1d33744c49ade436e8c4b811d536257a2d0b090700dd3e0" } ], "schema_version": 1 diff --git a/packages/claude/dist/zpwiki-graphrag.zip b/packages/claude/dist/zpwiki-graphrag.zip index 3311b67c2766e25fab7fa428f39c7754122ea2e7..165b64fcec37920f8494bbe4a545e7a1e371cc54 100644 GIT binary patch delta 3363 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 H00000vboy7 delta 3288 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 W7n9H-Dgw10ll&nj2JIgJ0001ePB~Tp diff --git a/packages/claude/graphrag/SKILL.md b/packages/claude/graphrag/SKILL.md index fedae02..64f2756 100644 --- a/packages/claude/graphrag/SKILL.md +++ b/packages/claude/graphrag/SKILL.md @@ -25,6 +25,12 @@ full question and apply only the selection. Preserve every condition; if context ask for it. Compare the returned `query` against that resolved full question. 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 +supported by the shared backend. Keep combined supervisor, work and completion-year filters in one +question: they must refer to the same work. A consultant is not a supervisor. A work year, submitted +text or completed task does not prove successful defense, completed studies or current enrollment. +Preserve unknown outcomes, the distinction between work and studies, and warnings about partially +dated meetings. Do not infer missing years or today's status from an old source record. 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. diff --git a/packages/claude/mcp_server.py b/packages/claude/mcp_server.py index 3f96c9f..05ae32f 100644 --- a/packages/claude/mcp_server.py +++ b/packages/claude/mcp_server.py @@ -44,6 +44,9 @@ ANSWER_PRESENTATION_POLICY = ( 'Do not add factual claims, guesses or commentary from earlier chat answers. ' 'document_author means author of the source document; it does not establish thesis authorship, ' 'supervision or a maintainer role. Do not guess those roles. ' + 'Explicit supervisors and consultants are separate roles. A work year or submitted text does not prove ' + 'successful defense or completed studies. Preserve unknown outcomes and partial-date caveats. ' + 'The latest dated meeting is the latest source record, not necessarily a current enrollment status. ' 'The answer already includes the specific missing-information and completeness notices checked by the backend. ' 'Do not append a paragraph about backend validation, partial coverage, candidate/result limits or retrieval diagnostics. ' 'Do not infer that results are exhaustive from the absence of technical metadata. ' diff --git a/packages/claude/rag/SKILL.md b/packages/claude/rag/SKILL.md index cccef23..1d05426 100644 --- a/packages/claude/rag/SKILL.md +++ b/packages/claude/rag/SKILL.md @@ -56,6 +56,15 @@ keep all conditions belonging to each question together. Do not combine different `index_version` values as if they represented one snapshot. Presentation boundaries: + +- The shared backend supports explicitly documented supervisors, consultants, latest meeting records + and completion outcomes. Send the whole question, including a supervisor and completion-year condition, + in one call. A consultant is not a supervisor; document authorship proves neither role. +- A work year is not its completion year. Submitted work, a completed task or an old meeting does not + prove successful defense, completed studies or current enrollment. Preserve the backend's distinctions + between work and studies, explicit outcomes and unknown outcomes. Never fill in a missing date year. +- For latest-meeting answers preserve the warning when undated or partially dated records cannot be + ordered reliably. A dated record describes the source snapshot, not necessarily today's situation. - `answer_rejected` means the answer failed validation, not that the question is ambiguous or evidence is absent. Preserve the supplied error explanation. Do not recommend changing a name/type/year unless the backend actually asks for that specific clarification. diff --git a/test/test_academic.py b/test/test_academic.py new file mode 100644 index 0000000..c8aa22d --- /dev/null +++ b/test/test_academic.py @@ -0,0 +1,166 @@ +"""Academic joins: roles, record dates and outcomes must belong to one work.""" +import pytest + +from tools.knowledge.sources import profile_units,advisor_name +from tools.knowledge.academic import answer_academic,completion_event,academic_intent +from tools.knowledge.query import answer_from_index,intent + + +def index(*profiles): + rows=[] + for number,(name,text) in enumerate(profiles): + rows.extend(profile_units({'title':name,'path':f'pages/students/2022/student_{number}/README.md', + 'content':text},include_records=True)) + return {'version':'fixture','index_version':{'version':'fixture'},'academic_schema':1,'units':rows} + + +@pytest.fixture(params=['rag','graphrag']) +def engine(request):return request.param + + +def test_supervisor_and_consultant_are_distinct_and_missing_role_is_not_invented(engine): + data=index(('Alena Nová','# Bakalárska práca 2026\nkonzultant Matúš Čavojský\n' + 'Predbežný názov:\nOrchestrácia modelov pomocou MCP\n')) + r=answer_from_index('Kto je vedúci a konzultant bakalárskej práce Aleny Novej?',data,engine=engine) + assert r['status']=='ok' + assert 'Konzultant: Matúš Čavojský' in r['answer'] + assert 'Vedúci práce: v dostupných podkladoch nie je uvedený' in r['answer'] + assert 'Orchestrácia modelov' in r['answer'] and r['source_urls'] + + +def test_profile_level_supervisor_is_not_assigned_to_every_work(engine): + data=index(('Alena Nová','Vedúci: Daniel Hládek\n# Diplomová práca 2024\nNázov: Overené grafy\n')) + r=answer_academic('Kto je vedúci diplomovej práce Aleny Novej z roku 2024?',data,engine=engine) + assert 'Vedúci práce: Daniel' not in r['answer'] + assert 'nie je uvedený' in r['answer'] + + +def test_latest_meeting_is_selected_by_date_even_when_profile_order_is_reversed(engine): + data=index(('Alena Nová','# Diplomová práca 2026\nStretnutie 5.5.2026\nStav:\n' + '- Implementovaný systém RAG.\nÚlohy:\n- Vyhodnoťte model.\n' + 'Stretnutie 1.2.2026\nStav:\n- Vytvorený prvý návrh systému.\n')) + r=answer_academic('Kedy mala Alena Nová posledné stretnutie a o čom bolo?',data,engine=engine) + assert r['status']=='ok' and '2026-05-05' in r['answer'] + assert 'Implementovaný' in r['answer'] and 'Plán:' in r['answer'] + assert 'prvý návrh' not in r['answer'] + assert all(e.get('date')=='2026-05-05' for e in r['evidence']) + + +def test_empty_latest_meeting_does_not_borrow_older_meeting_contents(engine): + data=index(('Alena Nová','# Diplomová práca 2026\nStretnutie 1.2.2026\n' + 'Stav:\n- Implementovaný systém RAG.\nStretnutie 5.5.2026\n')) + r=answer_academic('Kedy mala Alena Nová posledné stretnutie?',data,engine=engine) + assert '2026-05-05' in r['answer'] and 'Obsah tohto stretnutia nie je' in r['answer'] + assert 'Implementovaný' not in r['answer'] + + +def test_partial_date_does_not_inherit_year_or_establish_absolute_latest_meeting(engine): + data=index(('Alena Nová','# Diplomová práca 2027\nStretnutie 5.5.2026\nStav:\n' + '- Implementovaný systém RAG.\nStretnutie 7.11.\nStav:\n- Urobená nová verzia systému.\n')) + r=answer_academic('Kedy mala Alena Nová posledné stretnutie?',data,engine=engine) + assert '2026-05-05' in r['answer'] and 'či sú novšie' in r['answer'] + assert '2027-11-07' not in r['answer'] and '2026-11-07' not in r['answer'] + + +def test_premature_work_note_does_not_prove_failed_defense_or_ended_studies(engine): + data=index(('Samuel Vasiľ','# Bakalárska práca 2026\nUkončil predčasne\n')) + r=answer_academic('Ukončil Samuel Vasiľ štúdium úspešne alebo predčasne?',data,engine=engine) + assert 'Ukončil predčasne' in r['answer'] + assert r['reason']=='completion_not_documented' + assert 'rozsahom' in r['answer'] and 'Nemožno z toho usúdiť, že stále prebieha' in r['answer'] + + +@pytest.mark.parametrize('sentence',[ + 'Plán ukončiť štúdium v roku 2024.', + 'Mal by úspešne ukončiť diplomovú prácu v roku 2024.', + 'Dokončil text práce v roku 2024.', + 'Dokončil prácu na experimente v roku 2024.', + 'Implementovaný systém RAG.', + 'Nie je potvrdené, že úspešne obhájil diplomovú prácu.', + 'Kolega úspešne obhájil diplomovú prácu v roku 2024.', + 'Úspešne obhájil diplomovú prácu, ale neukončil štúdium.', +]) +def test_subtasks_plans_negations_and_other_people_do_not_prove_graduation(sentence): + row={'text':sentence,'phase':'progress','work_type':'diplomova','work_year':2024} + assert completion_event(row) is None + + +def test_work_heading_year_is_not_a_completion_year(engine): + data=index(('Alena Nová','# Diplomová práca 2024\nVedúci: Daniel Hládek\n' + 'Stretnutie 1.6.2024\nStav:\n- Úspešne obhájila diplomovú prácu.\n')) + r=answer_academic('Ktorí študenti v roku 2024 úspešne ukončili práce pod vedením Daniela Hladeka?',data,engine=engine) + assert r['status']=='no_answer' and r['reason']=='academic_conditions_not_documented' + + +def test_completion_supervisor_and_year_join_to_same_person_and_work(engine): + data=index( + ('Alena Nová','# Diplomová práca 2024\nVedúci: Daniel Hládek\nNázov: Presné grafy\n' + 'Stav:\n- Úspešne obhájila diplomovú prácu v roku 2024.\n'), + ('Boris Starý','# Diplomová práca 2024\nVedúci: Ján Staš\nStav:\n' + '- Úspešne obhájil diplomovú prácu v roku 2024.\n' + '# Bakalárska práca 2022\nVedúci: Daniel Hládek\n'), + ('Cyril Malý','# Diplomová práca 2024\nVedúci: Daniel Hládek\n' + 'Stav:\n- Úspešne obhájil diplomovú prácu v roku 2025.\n')) + r=answer_academic('Ktorí študenti v roku 2024 úspešne ukončili práce pod vedením Daniela Hladeka?',data,engine=engine) + assert r['status']=='ok' and 'Alena Nová' in r['answer'] and 'Presné grafy' in r['answer'] + assert 'Boris Starý' not in r['answer'] and 'Cyril Malý' not in r['answer'] + assert {e['person'] for e in r['evidence']}=={'Alena Nová'} + assert {e['work_label'] for e in r['evidence']}=={'Diplomová práca 2024'} + + +def test_submission_is_not_summarized_as_success(engine): + data=index(('Alena Nová','# Diplomová práca 2024\nVedúci: Daniel Hládek\n' + 'Stav:\n- Odovzdala diplomovú prácu v roku 2024.\n')) + r=answer_academic('Ktorí študenti v roku 2024 úspešne ukončili práce pod vedením Daniela Hladeka?',data,engine=engine) + assert r['status']=='no_answer' + + +def test_actual_conflicting_outcomes_are_reported_instead_of_cherry_picking_success(engine): + data=index(('Alena Nová','# Diplomová práca 2024\nVedúci: Daniel Hládek\nStav:\n' + '- Úspešne obhájila diplomovú prácu v roku 2024.\n' + '- Neobhájila diplomovú prácu v roku 2024.\n')) + r=answer_academic('Ktorí študenti v roku 2024 úspešne ukončili práce pod vedením Daniela Hladeka?',data,engine=engine) + assert r['status']=='no_answer' and r['reason']=='conflicting_completion_evidence' + assert 'rozporné záznamy' in r['answer'] + + +def test_exact_completion_date_needs_an_explicit_event_date_not_only_event_year(engine): + data=index(('Alena Nová','# Diplomová práca 2024\nStav:\n' + '- Úspešne obhájila diplomovú prácu v roku 2024.\n')) + r=answer_academic('Obhájila Alena Nová diplomovú prácu 5.6.2024?',data,engine=engine) + assert r['status']=='no_answer' + data=index(('Alena Nová','# Diplomová práca 2024\nStav:\n' + '- Úspešne obhájila diplomovú prácu dňa 5.6.2024.\n')) + r=answer_academic('Obhájila Alena Nová diplomovú prácu 5.6.2024?',data,engine=engine) + assert r['status']=='ok' and '5.6.2024' in r['answer'] + + +def test_limit_is_reported_without_claiming_an_exhaustive_list(engine): + data=index(*[(name,'# Diplomová práca 2024\nVedúci: Daniel Hládek\n' + 'Stav:\n- Úspešne obhájil diplomovú prácu v roku 2024.\n') + for name in ['Alena Nová','Boris Starý']]) + r=answer_academic('Ktorí študenti v roku 2024 úspešne ukončili práce pod vedením Daniela Hladeka?',data,engine=engine,limit=1) + assert r['truncated'] and 'obmedzený výber' in r['answer'] + assert len({e['person'] for e in r['evidence']})==1 + + +def test_old_checkpoint_does_not_silently_answer_new_intents(engine): + data=index(('Alena Nová','# Diplomová práca 2024\nVedúci: Daniel Hládek\n')) + data.pop('academic_schema') + r=answer_academic('Kto je vedúci práce Aleny Novej?',data,engine=engine) + assert r['status']=='error' and r['reason']=='content_index_unavailable' + + +@pytest.mark.parametrize('question,mode',[ + ('Kto je vedúci práce Jána Holpa?','advisors'), + ('Kto vedie diplomovú prácu Jána Holpa?','advisors'), + ('Kedy mal Ján Holp naposledy stretnutie?','meeting'), + ('Ukončil Samuel Vasiľ štúdium úspešne?','completion'), +]) +def test_academic_intents_use_the_shared_backend(question,mode): + assert intent(question)==academic_intent(question)==mode + + +def test_completed_subtasks_are_not_routed_to_degree_completion(): + assert academic_intent('Aké experimenty dokončil Ján Holp?') is None + assert academic_intent('Ktoré úlohy dokončil Ján Holp?') is None diff --git a/test/test_claude_packages.py b/test/test_claude_packages.py index 14a881a..620b810 100644 --- a/test/test_claude_packages.py +++ b/test/test_claude_packages.py @@ -106,6 +106,22 @@ def test_answer_is_exact_checked_text_without_diagnostics(backend, engine): assert authorization == 'Bearer ' + KEY +@pytest.mark.parametrize('engine', ['rag','graphrag']) +def test_academic_answer_survives_real_api_and_mcp_client_contract(backend,engine): + from tools.knowledge.sources import profile_units + from tools.knowledge.academic import answer_academic + question='Kto je vedúci diplomovej práce Aleny Novej z roku 2024?' + units=profile_units({'title':'Alena Nová','path':'pages/students/2022/alena/README.md', + 'content':'# Diplomová práca 2024\nVedúci: Daniel Hládek\n'},include_records=True) + index={'units':units,'version':'fixture','index_version':{'version':'fixture'},'academic_schema':1} + answer=answer_academic(question,index,engine=engine) + backend['checked'].clear();backend['checked'].update(answer) + actual=client.query_backend(question,engine) + assert actual['accepted_for_delivery'] and actual['answer']==answer['answer'] + assert actual['source_urls']==answer['source_urls'] + assert actual['answer_coverage']['status']=='supported' + + @pytest.mark.parametrize('engine', ['rag', 'graphrag']) def test_retrieval_explicitly_requires_final_verification(backend, engine): result = client.query_backend('Práca Evy Novej?', engine, mode='retrieve') diff --git a/test/test_knowledge_live.py b/test/test_knowledge_live.py index 5fb75fc..229ea6b 100644 --- a/test/test_knowledge_live.py +++ b/test/test_knowledge_live.py @@ -30,6 +30,8 @@ def test_real_profile_answer_preserves_evidence(case,engine,monkeypatch): result=deliver_answer(case['query'],engine=engine,limit=10) assert result['accepted_for_delivery'] and result['status']!='error',result.get('reason') if 'expected_status' in case:assert result['status']==case['expected_status'] + for text in case.get('answer_contains',[]):assert text in result['answer'] + for text in case.get('answer_excludes',[]):assert text not in result['answer'] evidence=result.get('evidence',[]) for row in evidence: source=ROOT.parent/'zpwiki'/row['source_path'] diff --git a/test/test_source_audit.py b/test/test_source_audit.py index f39865c..506c697 100644 --- a/test/test_source_audit.py +++ b/test/test_source_audit.py @@ -163,11 +163,11 @@ def test_new_lifecycle_note_does_not_reextract_unchanged_later_profiles(tmp_path durable_json(data/'index_version.json',version(1)) build(wiki,data,complete,{'model':'fixture'},graph=False,vectors=False) old=json.loads((data/'knowledge/index.json').read_text()) - old_ids={u['person']:u['id'] for u in old['units']} + old_ids={(u['person'],u['text']):u['id'] for u in old['units']} paths[0].write_text(paths[0].read_text()+'\nUkončil predčasne\n') durable_json(data/'index_version.json',version(2)) report=build(wiki,data,complete,{'model':'fixture'},graph=False,vectors=False) new=json.loads((data/'knowledge/index.json').read_text()) assert report['llm_batches']==0 - assert all(u['id']==old_ids[u['person']] for u in new['units'] if u['kind']!='lifecycle') + assert all(u['id']==old_ids[(u['person'],u['text'])] for u in new['units'] if u['kind']!='lifecycle') assert any(u['kind']=='lifecycle' for u in new['units']) diff --git a/tools/answers/answer_delivery.py b/tools/answers/answer_delivery.py index e52ad9e..1524c8d 100644 --- a/tools/answers/answer_delivery.py +++ b/tools/answers/answer_delivery.py @@ -81,7 +81,9 @@ def _deliver_answer(question: str, *, engine: str = 'rag', limit: int = 5, 'claims':[],'source_urls':[],'engine':engine,'index_version':None, 'complete':False,'truncated':False,'truncation_reasons':[]} unsupported=unsupported_constraint(question) - if unsupported and unsupported!='year_range': + from tools.knowledge.academic import academic_intent + if unsupported and unsupported!='year_range' and not ( + unsupported=='work_event_date' and academic_intent(question)=='completion'): return {'status':'ambiguous','reason':'unsupported_constraint','accepted_for_delivery':True, 'answer':constraint_message(unsupported),'claims':[],'source_urls':[], 'index_version':None,'complete':False,'truncated':False,'truncation_reasons':[],'engine':engine} diff --git a/tools/claude/build_claude_packages.py b/tools/claude/build_claude_packages.py index 3b49761..fd675f4 100644 --- a/tools/claude/build_claude_packages.py +++ b/tools/claude/build_claude_packages.py @@ -11,7 +11,7 @@ import zipfile ROOT = Path(__file__).resolve().parents[2] BASE = ROOT / 'packages/claude' ENGINES = ('rag', 'graphrag') -PACKAGE_VERSION = '1.7.1' +PACKAGE_VERSION = '1.8.0' def json_bytes(value): diff --git a/tools/knowledge/academic.py b/tools/knowledge/academic.py new file mode 100644 index 0000000..a2e5217 --- /dev/null +++ b/tools/knowledge/academic.py @@ -0,0 +1,303 @@ +"""Source-bound academic roles, meeting records and explicit completion events. + +Neither authorship nor an academic heading proves supervision or graduation. +This path needs no generative model: every delivered statement cites literal +evidence from the same profile and work. Unknown is never treated as ongoing. +""" +from __future__ import annotations + +from collections import defaultdict +from datetime import date +import re + +from tools.answer_evaluation.context_policy import normalized +from tools.knowledge.sources import advisor_name +from tools.retrieval.person_names import resolve_person_names +from tools.retrieval.work_selector import parse_work_selector + + +def academic_intent(question): + text = normalized(question) + completion = re.search(r'\b(?:ukonc\w*|skoncil\w*|dokonc\w*|obhaj\w*|neobhaj\w*|absolvov\w*|' + r'prerusil\w*|studuje|studium|stale prebieha)\b', text) + subtask = re.search(r'\b(?:uloh\w*|experiment\w*|implement\w*|trenovan\w*)\b',text) + academic_object = re.search(r'\b(?:obhaj\w*|studium|studia|studuje|ukoncenie prace)\b',text) + if completion and (not subtask or academic_object): + return 'completion' + if re.search(r'\b(?:stretnut\w*|konzultaci\w*)\b', text) and re.search( + r'\b(?:naposledy|posledn\w*|najnov\w*|kedy|o com)\b', text): + return 'meeting' + if re.search(r'\bkto\b.*\b(?:vedie|viedol|viedla)\b',text): + return 'advisors' + if re.search(r'\b(?:veduc\w*|skolitel\w*|konzultant\w*)\b', text): + return 'advisors' + return None + + +def completion_event(unit): + """Recognize narrow, explicit outcomes, never completed subtasks or plans.""" + text = normalized(unit['text']) + if unit.get('phase') in {'assignment','planned'} or unit.get('completion_basis') == 'checked_task': + return None + if re.search(r'\b(?:planuj\w*|plan |ciel\w*|mal by|ma byt|bude|budu|treba|chce|chcel|' + r'nepotvrd\w*|nie je potvrden\w*|nie je dolozen\w*|zatial neukoncil\w*)\b',text): + return None + # Avoid interpreting a nested quote about somebody else as this student's outcome. + if re.search(r'\b(?:iny student|inych studentov|kolega|kolegy\w*)\b',text): + return None + study = bool(re.search(r'\b(?:studium|studia|studuje|absolvov\w*)\b',text)) + work = bool(re.search(r'\b(?:prac\w*|projekt\w*|obhaj\w*|neobhaj\w*)\b',text)) + if study and work: + return None # Two different event scopes in one sentence need separate evidence. + status = None + if re.search(r'\b(?:ukoncil\w*|skoncil\w*) predcasne\b|\bprerusil\w* studium\b',text): + status = 'interrupted' if 'prerusil' in text else 'premature' + elif re.search(r'\bneobhajil\w*\b|\bneuspesne (?:ukoncil\w*|obhajil\w*)\b',text): + status = 'unsuccessful' + elif re.search(r'\b(?:uspesne (?:ukoncil\w*|obhajil\w*|absolvoval\w*)|obhajil\w*|absolvoval\w*)\b',text): + if not re.search(r'\b(?:neobhajil\w*|nie|neuspesne)\b',text): + status = 'successful' + elif re.search(r'\b(?:dokonc(?:il|ila|ene|ena)\w*|ukoncil\w*|odovzdal\w*)\b',text) and (study or work): + status = 'submitted' if 'odovzdal' in text else 'completed_unspecified' + elif study and re.search(r'\b(?:stale studuje|studium pokracuje|studium prebieha)\b',text): + status = 'ongoing' + elif work and re.search(r'\b(?:praca|projekt) (?:stale )?(?:prebieha|pokracuje)\b',text): + status = 'ongoing' + if not status or not (study or work or status in {'premature','interrupted'}): + return None + # A model, database, experiment or text being finished isn't a defended thesis. + if re.search(r'\b(?:text prace|pisanie prace|minimov\w*|pracu na|ukoncil\w* trenovan\w*|dokonc\w* implement\w*)\b',text): + return None + scope = 'studies' if study else 'work' if work else 'unspecified' + if scope == 'work': + from tools.answer_evaluation.rag_evidence import academic_scope + mentioned=academic_scope(unit['text']) + if mentioned and unit.get('work_type') and mentioned[0]!=unit['work_type']: + return None + # A dated note is NOT automatically the date the event happened. + years = set(re.findall(r'\b(?:v roku|roku|dna|v)\s+([12]\d{3})\b',text)) + years.update(re.findall(r'\b\d{1,2}\.\s*\d{1,2}\.\s*([12]\d{3})\b',unit['text'])) + event_year = int(next(iter(years))) if len(years)==1 else None + event_date=None + dates=re.findall(r'\b(\d{1,2})\.\s*(\d{1,2})\.\s*([12]\d{3})\b',unit['text']) + if len(dates)==1: + try:event_date=date(int(dates[0][2]),int(dates[0][1]),int(dates[0][0])).isoformat() + except ValueError:return None + return {'status':status,'scope':scope,'event_year':event_year,'event_date':event_date} + + +def answer_academic(question, index, *, engine, limit=5, units=None, mode=None): + mode = mode or academic_intent(question) + rows = units if units is not None else index['units'] + catalog = [{'name':name,'kind':'Person','source_document':path} + for name,path in sorted({(r['person'],r['source_path']) for r in rows})] + role_names = {normalized(name):name for r in rows for role in ('supervisor','consultant') + if (name:=advisor_name(r,role))} + known = {normalized(p['name']) for p in catalog} + catalog += [{'name':name,'kind':'Person'} for key,name in role_names.items() if key not in known] + resolution = resolve_person_names(question,catalog) + base = {'engine':engine,'index_version':index['index_version'],'knowledge_version':index['version'], + 'accepted_for_delivery':True,'claims':[],'complete':False,'truncated':False, + 'truncation_reasons':[],'name_resolution':resolution} + + def respond(text, evidence=(), *, status='ok', reason=None, truncated=False): + unique = list({r['id']:r for r in evidence}.values()) + urls = list(dict.fromkeys(r['source_url'] for r in unique)) + text = '\n\n'.join([*resolution.get('notices',[]),text]) + if urls:text += '\n\nZdroje: '+', '.join(urls) + return {**base,'status':status,'reason':reason,'answer':text,'source_urls':urls, + 'truncated':truncated,'truncation_reasons':['academic_result_limit'] if truncated else [], + 'evidence':unique,'answer_coverage':{ + 'scope':'list' if mode=='advisors' else 'overview', + 'status':'not_found' if status=='no_answer' else 'unavailable' if status=='error' else + 'partial' if reason or truncated else 'supported', + 'warnings':[],'limitations':[reason] if reason else []}} + + if resolution['status']!='ok': + return respond(resolution['message'],status=resolution['status'],reason=resolution['reason']) + if index.get('academic_schema')!=1: + return respond('Index rolí, stretnutí a ukončení ešte nie je pripravený pre túto verziu dát. ' + 'Je potrebná obnova obsahového indexu.',status='error',reason='content_index_unavailable') + resolved=resolution.get('resolved_query',question) + text=normalized(resolved) + selection=parse_work_selector(resolved) + from tools.retrieval.question_constraints import unsupported_constraint,constraint_message + unsupported=unsupported_constraint(resolved) + if unsupported not in {None,'work_event_date','year_range'}: + return respond(constraint_message(unsupported),status='ambiguous',reason='unsupported_constraint') + if re.search(r'\b(?:tag\w*|klucov\w* slov\w*|autor dokumentu|semest\w*)\b',text): + return respond('Túto kombinovanú podmienku neviem spoľahlivo overiť. Uveď osobu, konkrétnu prácu ' + 'a požadovanú rolu, stretnutie alebo ukončenie.',status='ambiguous',reason='unsupported_academic_join') + matches=resolution.get('matches',[]) + staff={normalized(m['name']) for m in matches if normalized(m['name']) in role_names} + students=[m for m in matches if m.get('source_document')] + # A known mentor can also have a student profile: only treat it as mentor + # when the question explicitly filters by leadership/consultation. + role_filter=bool(staff and re.search(r'\b(?:pod vedenim|vedie|viedol|veden\w*|veduc\w*|skolitel\w*|konzultant\w*)\b',text)) + if role_filter:students=[m for m in students if normalized(m['name']) not in staff] + global_question=bool(re.search(r'\b(?:kto|ktori|ktore)\b',text) and mode=='completion') + if not students and not role_filter and not global_question: + return respond('Uveď celé meno študenta alebo meno vedúceho, podľa ktorého mám práce vybrať.', + status='ambiguous',reason='missing_subject') + if len(staff)>1 and role_filter: + return respond('Uveď jedného vedúceho alebo konzultanta pre tento výber prác.', + status='ambiguous',reason='ambiguous_advisor_filter') + from tools.answer_evaluation.rag_evidence import requested_types + types=requested_types(resolved,[m['name'] for m in students]) + global_types=set(types.values()) + if not students: + from tools.answer_evaluation.rag_evidence import work_type + kind=work_type(resolved) + if kind:global_types={kind} + # Completion-year filters apply to the EVENT, not the work heading. + from tools.knowledge.query import _period + try:period=_period(resolved) + except ValueError: + return respond('Uveď platný rok alebo interval dátumov.',status='ambiguous',reason='invalid_period') + work_year_match=re.search(r'\b(?:diplomov\w*|bakalarsk\w*|timov\w*)\s+(?:prac\w*|projekt\w*)' + r'(?:\s+\d{1,2})?\s+(?:z\s+roku\s+)?([12]\d{3})\b',text) + work_year=int(work_year_match[1]) if work_year_match else None + if mode=='completion' and period and work_year: + return respond('Máš na mysli rok evidovaný pri práci, alebo rok jej skutočného ukončenia? ' + 'Uveď výslovne požadovaný dátum ukončenia; tieto roky nemusia byť rovnaké.', + status='ambiguous',reason='completion_year_scope') + selected_paths={m['source_document'] for m in students} + groups=defaultdict(list) + for r in rows: + if selected_paths and r['source_path'] not in selected_paths:continue + if selection.specific and not selection.matches(r.get('work_label') or ''):continue + kind=types.get(r['person']) + if kind and r.get('work_type')!=kind:continue + if not students and global_types and r.get('work_type') not in global_types:continue + if mode=='advisors' and period and not (r.get('work_year') and + int(period[0][:4])<=r['work_year']<=int(period[1][:4])):continue + if mode=='meeting' and work_year and r.get('work_year')!=work_year:continue + groups[(r['source_path'],r.get('work_label'))].append(r) + requested_role='consultant' if re.search(r'\bkonzultant\w*\b',text) and not re.search(r'\bveduc\w*|\bveden\w*',text) else 'supervisor' + if role_filter: + groups={key:part for key,part in groups.items() if key[1] and any( + normalized(advisor_name(r,requested_role) or '') in staff for r in part)} + evidence=[];lines=[];omitted=False + status_labels={'successful':'úspešné ukončenie / obhajoba','unsuccessful':'neúspešné ukončenie / obhajoba', + 'premature':'predčasné ukončenie','interrupted':'prerušenie štúdia', + 'completed_unspecified':'ukončenie bez doloženého výsledku obhajoby', + 'submitted':'odovzdanie; úspešná obhajoba nepotvrdená','ongoing':'výslovne zaznamenané pokračovanie'} + study_question=bool(re.search(r'\bstudium\b|\bstudia\b|\bstuduje\b',text)) + wants_success=bool(re.search(r'\buspesn\w*\b',text)) + wants_failure=bool(re.search(r'\bneuspesn\w*\b|\bneobhaj\w*\b',text)) + if wants_success and wants_failure:wants_success=wants_failure=False + outcomes=defaultdict(list) + conflicts=set() + if mode=='completion': + for key,part in groups.items(): + all_events=[(r,event) for r in part if (event:=completion_event(r))] + # Unresolved contradictory outcomes of the same work must not + # produce an unqualified success list. A later dated re-defense + # can be queried explicitly by date instead. + statuses={event['status'] for r,event in all_events if event['scope']=='work'} + if {'successful','unsuccessful'}<=statuses and any(not event['event_date'] for r,event in all_events): + conflicts.add(key) + continue + for r,event in all_events: + if study_question and event['scope']!='studies':continue + if not study_question and key[1] and event['scope']=='studies':continue + if role_filter and event['scope']!='work':continue + if wants_success and event['status']!='successful':continue + if wants_failure and event['status']!='unsuccessful':continue + if period: + if event['event_date']: + if not period[0]<=event['event_date']<=period[1]:continue + elif not (period[0].endswith('-01-01') and period[1].endswith('-12-31') and + event['event_year'] and int(period[0][:4])<=event['event_year']<=int(period[1][:4])):continue + outcomes[key].append((r,event)) + # Unscoped introductory metadata must not become a fictional extra work. + keys=sorted((key for key,part in groups.items() if key[1] or any( + completion_event(r) or advisor_name(r,'supervisor') or advisor_name(r,'consultant') or r['kind']=='meeting' + for r in part)),key=lambda k:(groups[k][0]['person'],k[0],k[1] or '')) + if mode=='completion' and (role_filter or not students):keys=[k for k in keys if outcomes[k] or k in conflicts] + if mode=='meeting': + by_person=defaultdict(list) + for key in keys:by_person[key[0]].extend(groups[key]) + for path,part in by_person.items(): + markers=[r for r in part if r['kind']=='meeting' and (not period or work_year or + r.get('date') and period[0]<=r['date']<=period[1])] + dated=[r for r in markers if r.get('date')] + if not markers: + lines.append(f'**{part[0]["person"]}**: v tomto výbere nie je doložený záznam stretnutia.');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 + for marker in chosen[:limit]: + lines.append(f'**{marker["person"]} — {marker.get("work_label") or "profil"}**\n' + f'Posledný záznam s úplným dátumom: {latest}.' if latest else + f'**{marker["person"]}** — záznam: {marker["date_label"]}; rok nie je uvedený.') + evidence.append(marker) + notes=[r for r in part if r.get('meeting_id')==marker['meeting_id'] and + r['kind'] not in {'meeting','work_record','reference','supervisor','consultant'}] + from tools.answer_evaluation.content_selection import unit_score + notes=[r for r in notes if not r['text'].rstrip().endswith(':')] + notes.sort(key=lambda r:(-unit_score(r['text'],'progress' if r['phase'] in + {'result','progress'} else 'task'),r['phase']!='result',r['line'])) + actual=[r for r in notes if r['phase'] in {'result','progress'}] + planned=[r for r in notes if r['phase']=='planned'] + selected=actual[:4]+planned[:4] + selected.extend(r for r in notes if r not in selected) + notes=selected + for note in notes[:8]: + phase={'result':'Zaznamenaný výsledok','progress':'Priebežný stav','planned':'Plán','assignment':'Zadanie'}.get(note['phase'],'Záznam') + quote=re.sub(r'^\s*(?:[-*+]|\d+[.)])\s+','',note['text']) + lines.append(f'- {phase}: {quote}');evidence.append(note) + if not notes:lines.append('Obsah tohto stretnutia nie je v profile uvedený.') + if len(notes)>8:omitted=True + if len(chosen)>limit:omitted=True + partial=[r for r in part if r['kind']=='meeting' and not r.get('date')] + if partial: + 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.') + evidence.extend(partial) + else: + for key in keys[:limit]: + part=groups[key];person=part[0]['person'];label=key[1] or 'profil (bez priradenia ku konkrétnej práci)' + lines.append(f'**{person} — {label}**') + header=next((r for r in part if r['kind']=='work_record'),None) + if header:evidence.append(header) + title=next((r for r in part if r['kind']=='topic' and not r['text'].rstrip().endswith(':')),None) + if title:lines.append('Názov / téma zo zdroja: '+title['text']);evidence.append(title) + if mode=='advisors' or role_filter: + for role,name_label in [('supervisor','Vedúci práce'),('consultant','Konzultant')]: + roles=[r for r in part if advisor_name(r,role)] + names=list(dict.fromkeys(advisor_name(r,role) for r in roles)) + lines.append(name_label+': '+(', '.join(names) if names else 'v dostupných podkladoch nie je uvedený')+'.') + evidence.extend(roles) + if mode=='advisors': + lines.append('Autor dokumentu nie je automaticky vedúci ani konzultant práce.') + if mode=='completion': + if key in conflicts: + lines.append('Zdroj obsahuje rozporné záznamy o výsledku tejto práce; bez doloženého poradia ' + 'obhajob nemožno potvrdiť úspešné ukončenie.') + evidence.extend(r for r in part if completion_event(r)) + continue + found=outcomes[key] + for r,event in found: + scope={'studies':'štúdium','work':'práca','unspecified':'rozsah ukončenia nie je výslovne uvedený'}[event['scope']] + lines.append(f'- {status_labels[event["status"]]} ({scope}): „{r["text"]}“') + lines.append('Rok udalosti: '+str(event['event_year'])+'.' if event['event_year'] else + 'Dátum ukončenia nie je doložený; rok práce ho nenahrádza.') + evidence.append(r) + if not found: + lines.append('Úspešné ani neúspešné ukončenie tejto práce' + (' či celého štúdia' if study_question else '')+ + ' nie je v dostupných podkladoch doložené. Nemožno z toho usúdiť, že stále prebieha.') + notes=[r for r in part if completion_event(r)] + for note in notes: + lines.append('Doplňujúci záznam s odlišným alebo neurčeným rozsahom: „'+note['text']+'“.');evidence.append(note) + omitted=len(keys)>limit + if not lines: + return respond('V dostupných profiloch nie je doložená práca, ktorá by súčasne spĺňala všetky podmienky ' + 'otázky. Vedúci, konkrétna práca, výsledok ukončenia a rok udalosti musia byť doložené ' + 'pri tom istom zázname. Rok práce, autor dokumentu ani odovzdanie textu nepotvrdzujú ' + 'úspešnú obhajobu. To neznamená, že takí študenti neexistujú.', + status='no_answer',reason='academic_conditions_not_documented') + if omitted:lines.append('Zobrazený je obmedzený výber. Zúž otázku na študenta, konkrétnu prácu alebo obdobie, prípadne zvýš limit.') + unknown=mode=='completion' and not any(outcomes.values()) + return respond('\n\n'.join(lines),evidence,status='no_answer' if unknown else 'ok', + reason='conflicting_completion_evidence' if conflicts else 'completion_not_documented' if unknown else None, + truncated=omitted) diff --git a/tools/knowledge/build.py b/tools/knowledge/build.py index 29c8cfe..55bd961 100644 --- a/tools/knowledge/build.py +++ b/tools/knowledge/build.py @@ -54,7 +54,7 @@ def _build(wiki_root, data_dir, complete, model_identity, *, graph, vectors, wor documents = [d for d in documents if d.get('published') is not False] if not documents: raise RuntimeError('No published student profiles to extract.') - units = [unit for d in documents for unit in profile_units(d)] + units = [unit for d in documents for unit in profile_units(d, include_records=True)] # Keep whole statements, including trailing negations/qualifications. # Only the model's annotation view may be shortened; evidence never is. previous=data_dir/'knowledge/index.json' @@ -66,7 +66,8 @@ def _build(wiki_root, data_dir, complete, model_identity, *, graph, vectors, wor # model cache just because its position in the corpus has shifted. bounded = [{**unit,'id':prior_ids.get(source_identity(unit),identifier(unit['id'],i,unit['text']))} for i,unit in enumerate(units)] - annotatable=[u for u in bounded if u['kind'] not in {'reference','lifecycle'}] + local_kinds={'reference','lifecycle','work_record','meeting','consultant'} + annotatable=[u for u in bounded if u['kind'] not in local_kinds] cache = data_dir / 'knowledge/cache' cache.mkdir(parents=True, exist_ok=True) # Reuse annotations independently of dates/line offsets. These are parsed @@ -129,10 +130,15 @@ def _build(wiki_root, data_dir, complete, model_identity, *, graph, vectors, wor by_id.update({u['id']:u for u in rows}) calls+=int(not cached);hits+=int(cached) extracted=[by_id[u['id']] for u in extracted] - extracted.extend({**u,'concepts':[],'relations':[]} for u in bounded if u['kind'] in {'reference','lifecycle'}) + extracted.extend({**u,'concepts':[],'relations':[]} for u in bounded if u['kind'] in local_kinds) order={u['id']:i for i,u in enumerate(bounded)} extracted.sort(key=lambda u:order[u['id']]) - index = {'schema': 1, 'index_version': version, 'model': model_identity, + from tools.knowledge.academic import completion_event + for unit in extracted: + if event := completion_event(unit): + unit.update(completion_status=event['status'],completion_scope=event['scope'], + completion_event_year=event['event_year'],completion_event_date=event['event_date']) + index = {'schema': 1, 'academic_schema': 1, 'index_version': version, 'model': model_identity, 'source_scope': 'published_student_profiles', 'profiles': len(documents), 'units': extracted, 'graph_ready': graph} if vectors: diff --git a/tools/knowledge/query.py b/tools/knowledge/query.py index 7107562..2b1cdbe 100644 --- a/tools/knowledge/query.py +++ b/tools/knowledge/query.py @@ -41,6 +41,9 @@ def model_json(response): def intent(question: str) -> str | None: + from tools.knowledge.academic import academic_intent + if mode := academic_intent(question): + return mode text = normalized(question) if re.search(r'\b(?:podobn\w*|pribuzn\w*)\b', text) and re.search(r'\b(?:kto|student\w*|osob\w*|prac\w*)\b', text): return 'similar' @@ -152,6 +155,9 @@ def answer_from_index(question: str, index: dict, *, engine: str, limit: int = 5 units: list[dict] | None = None, relevance=None, complete=None, mode=None) -> dict: mode = mode or intent(question) assert mode + if mode in {'advisors','meeting','completion'}: + from tools.knowledge.academic import answer_academic + return answer_academic(question,index,engine=engine,limit=limit,units=units,mode=mode) rows = units if units is not None else index['units'] identities=sorted({(u['person'],u['source_path']) for u in rows}) catalog = [{'name': name, 'kind': 'Person','source_document':path} for name,path in identities] @@ -248,7 +254,7 @@ def answer_from_index(question: str, index: dict, *, engine: str, limit: int = 5 return row.get('work_year') in range(int(period[0][:4]), int(period[1][:4])+1) return True - filtered = [r for r in rows if scoped(r) and r['kind'] not in {'reference','lifecycle'} + filtered = [r for r in rows if scoped(r) and r['kind'] not in {'reference','lifecycle','meeting','work_record','consultant'} and not reference_only(r['text'])] candidate_limited=False semantic_similarity=False diff --git a/tools/knowledge/questions.json b/tools/knowledge/questions.json index c704e5b..c851e7f 100644 --- a/tools/knowledge/questions.json +++ b/tools/knowledge/questions.json @@ -18,6 +18,14 @@ {"id":"content-15","query":"Ako pokročil Ján?","expected_status":"ambiguous"}, {"id":"content-16","query":"Na čom pracoval Ján Holpp?","requires_recognized_name_notice":true}, {"id":"content-17","query":"Aký je názov diplomovej práce Jána Holpa a z ktorého roku je?","regression":"Systém získavania informácií v slovenskom jazyku; 2021"}, - {"id":"content-18","query":"Aké spoločné kľúčové slová majú Ján Pták a Ján Malinovský?","regression":"NLP; RAG"} + {"id":"content-18","query":"Aké spoločné kľúčové slová majú Ján Pták a Ján Malinovský?","regression":"NLP; RAG"}, + {"id":"academic-01","query":"Kto je vedúcim diplomovej práce Jána Holpa z roku 2021?","expected_person":"Ján Holp","answer_contains":["Vedúci práce: Daniel Hládek","Systém získavania informácií"]}, + {"id":"academic-02","query":"Kto je vedúci a konzultant bakalárskej práce Samuela Vasiľa?","expected_person":"Samuel Vasiľ","answer_contains":["Konzultant: Matúš Čavojský","Vedúci práce: v dostupných podkladoch nie je uvedený"]}, + {"id":"academic-03","query":"Kedy mal Ján Malinovský naposledy stretnutie a o čom bolo?","expected_person":"Ján Malinovský","answer_contains":["2026-05-14","RAGAS","Plán:"]}, + {"id":"academic-04","query":"Ukončil Samuel Vasiľ štúdium úspešne alebo predčasne?","expected_status":"no_answer","answer_contains":["Ukončil predčasne","odlišným alebo neurčeným rozsahom"]}, + {"id":"academic-05","query":"Ktorí študenti v roku 2024 úspešne ukončili svoje práce pod vedením Daniela Hladeka?","expected_status":"no_answer","answer_contains":["súčasne spĺňala všetky podmienky","úspešnú obhajobu"]}, + {"id":"academic-06","query":"Kto je vedúci diplomovej práce Dávida Omastu z roku 2023?","expected_person":"Dávid Omasta","answer_contains":["Vedúci práce: Ján Staš"],"answer_excludes":["Vedúci práce: Daniel Hládek"]}, + {"id":"academic-07","query":"Kto je vedúci diplomovej práce Dávida Omastu z roku 2024?","expected_person":"Dávid Omasta","answer_contains":["Vedúci práce: Daniel Hládek"],"answer_excludes":["Vedúci práce: Ján Staš"]}, + {"id":"academic-08","query":"Ukončil Ondrej Megela diplomovú prácu úspešne alebo neúspešne?","expected_status":"no_answer","answer_contains":["nie je v dostupných podkladoch doložené","Nemožno z toho usúdiť, že stále prebieha"]} ] } diff --git a/tools/knowledge/sources.py b/tools/knowledge/sources.py index bdd972e..398556c 100644 --- a/tools/knowledge/sources.py +++ b/tools/knowledge/sources.py @@ -25,10 +25,16 @@ def identifier(*values) -> str: def supervisor_name(unit: dict) -> str | None: - if unit['kind']!='supervisor': + return advisor_name(unit, 'supervisor') + + +def advisor_name(unit: dict, role: str) -> str | None: + if unit['kind'] != role: return None plain=re.sub(r'[*_]','',unit['text']).strip().lstrip('-+ ') - match=re.match(r'^(?:meno ved[uú]ceho(?: pr[aá]ce)?|ved[uú]c[iaí](?: pr[aá]ce)?|[šs]kolite[lľ])\s*[:\-]?\s*(.+)$',plain,re.I) + label = (r'(?:meno ved[uú]ceho(?: pr[aá]ce)?|ved[uú]c[iaí](?: pr[aá]ce)?|[šs]kolite[lľ](?:ka)?)' + if role == 'supervisor' else r'(?:meno konzultanta|konzultant(?:ka)?(?: pr[aá]ce)?)') + match=re.match(r'^'+label+r'\s*[:\-]?\s*(.+)$',plain,re.I) if not match:return None value=match[1] value=re.sub(r'\[([^]]+)\]\([^)]*\)',r'\1',value) @@ -65,7 +71,7 @@ def meeting_date(line: str) -> dict | None: 'date_label': match[0], 'date_precision': 'day' if match[3] else 'day_month'} -def profile_units(document: dict) -> list[dict]: +def profile_units(document: dict, *, include_records: bool = False) -> list[dict]: """Keep literal source units, line numbers, work identity and explicit dates.""" person, path = document['title'].strip(), document['path'] scope = None @@ -76,6 +82,14 @@ def profile_units(document: dict) -> list[dict]: units, buffer = [], [] start = 0 fence = None + meeting_id = None + + def record(text, number, kind): + return {'id':identifier(path,work_label,number,kind,text),'person':person, + 'source_path':path,'source_url':make_source_url(path), + 'line':number+document.get('body_line_offset',0),'work_label':work_label, + 'work_type':scope[0] if scope else None,'work_year':scope[1] if scope else None, + 'text':text,'kind':kind,'phase':'unknown',**timestamp,'meeting_id':meeting_id} def flush(): nonlocal role @@ -85,7 +99,8 @@ def profile_units(document: dict) -> list[dict]: buffer.clear() plain = re.sub(r'^\s*(?:[-*+]|\d+[.)])\s+', '', text) lifecycle=bool(LIFECYCLE.search(normalized(text))) - if (len(normalized(text).split()) < 3 and not lifecycle) or re.fullmatch(r'https?://\S+', plain): + words=normalized(text).split() + if not words or (len(words) < 3 and not lifecycle and not include_records) or re.fullmatch(r'https?://\S+', plain): if role=='topic':role='other' return current_role = effective_role(text, role) @@ -115,16 +130,19 @@ def profile_units(document: dict) -> list[dict]: if lifecycle:kind,phase='lifecycle','unknown' if re.search(r'\b(?:meno veduceho|veduci(?: prace)?|veduca(?: prace)?|skolitel)\b', normalized(text)): kind, phase = 'supervisor', 'unknown' + if re.match(r'^(?:meno konzultanta|konzultant(?:ka)?\b)', normalized(plain)): + kind, phase = 'consultant', 'unknown' units.append({'id': identifier(path, work_label, start, text), 'person': person, 'source_path': path, 'source_url': make_source_url(path), 'line': start+document.get('body_line_offset',0), 'work_label': work_label, 'work_type': scope[0] if scope else None, 'work_year': scope[1] if scope else None, 'text': text, - 'kind': kind, 'phase': phase, 'completion_basis':'checked_task' if checked else 'source_statement', **timestamp}) + 'kind': kind, 'phase': phase, 'completion_basis':'checked_task' if checked else 'source_statement', + **({'meeting_id':meeting_id} if include_records else {}), **timestamp}) if role=='topic': role='other' role_labels = {'assignment': {'zadanie', 'navrh na zadanie', 'ciel', 'ciele', 'ciele na semester'}, - 'topic': {'tema','zameranie','nazov','nazov prace'}, + 'topic': {'tema','zameranie','nazov','nazov prace','predbezny nazov','navrh na nazov'}, 'progress': {'stav', 'priebezny stav', 'hotove', 'urobene', 'vysledky','vystupy'}, 'task': {'ulohy', 'zasobnik uloh', 'treba dokoncit', 'plan'}} for number, line in enumerate(document['content'].splitlines(), 1): @@ -142,10 +160,14 @@ def profile_units(document: dict) -> list[dict]: work_level=heading_level role, source_phase = 'other', 'unknown' timestamp = {'date': None, 'date_label': None, 'date_precision': 'unknown'} + meeting_id = None + if include_records:units.append(record(line.strip(),number,'work_record')) continue if (new_date := meeting_date(line)) is not None: flush() timestamp = new_date + meeting_id = identifier(path,work_label,number,line.strip()) + if include_records:units.append(record(line.strip(),number,'meeting')) role, source_phase = 'other', 'unknown' continue label = normalized(line.strip().strip('*_# ').rstrip(':')) @@ -166,10 +188,12 @@ def profile_units(document: dict) -> list[dict]: scope,work_label,work_level=None,None,None role,source_phase='other','unknown' timestamp={'date':None,'date_label':None,'date_precision':'unknown'} + meeting_id=None inline_label=normalized(line.split(':',1)[0]) if ':' in line else '' if (LIFECYCLE.search(normalized(line)) or inline_label in { 'nazov','nazov prace','predbezny nazov','tema','veduci','veduci prace','meno veduceho', - 'meno veduceho prace','skolitel','repozitar'}): + 'meno veduceho prace','skolitel','konzultant','konzultant prace','meno konzultanta','repozitar'} + or re.match(r'^\s*(?:konzultant(?:ka)?|vedúci|veduci)\s+',line,re.I)): flush() if not line.strip() or re.match(r'^\s*(?:[-*+]\s+|\d+[.)]\s+)', line): flush() diff --git a/tools/knowledge/store.py b/tools/knowledge/store.py index 44c97b7..7aee728 100644 --- a/tools/knowledge/store.py +++ b/tools/knowledge/store.py @@ -20,7 +20,8 @@ def write_graph(index: dict) -> None: with create_neo4j_driver(settings) as driver, driver.session(database=settings.database) as session: driver.verify_connectivity() for name,label,field in [('evidence','KnowledgeEvidence','id'),('concept','KnowledgeConcept','id'), - ('person','KnowledgePerson','source_path')]: + ('person','KnowledgePerson','source_path'), + ('work','KnowledgeWork','id'),('advisor','KnowledgeAdvisor','id')]: session.run(f'CREATE CONSTRAINT zp_content_{name} IF NOT EXISTS ' f'FOR (n:{label}) REQUIRE (n.graph_scope,n.{field}) IS UNIQUE').consume() session.run('CREATE CONSTRAINT zp_content_state IF NOT EXISTS ' @@ -52,6 +53,42 @@ def write_graph(index: dict) -> None: CREATE (r:KnowledgeRelation {graph_scope:$scope,predicate:row.predicate,origin:'llm'}) CREATE (r)-[:SUBJECT]->(a), (r)-[:OBJECT]->(b), (r)-[:EVIDENCED_BY]->(e)''', rows=relations,scope=SCOPE).consume() + from tools.knowledge.sources import advisor_name + # Roles are explicit, source-backed edges attached to an exact work. + # Profile-level roles remain unassigned; they are never spread over + # every work on that profile. + tx.run('MATCH (n:KnowledgeWork {graph_scope:$scope}) DETACH DELETE n',scope=SCOPE).consume() + tx.run('MATCH (n:KnowledgeAdvisor {graph_scope:$scope}) DETACH DELETE n',scope=SCOPE).consume() + works=[{'id':identifier(u['source_path'],u['work_label']), + **{k:u.get(k) for k in ('source_path','work_label','work_type','work_year')},'evidence':u['id']} + for u in index['units'] if u['kind']=='work_record'] + tx.run('''UNWIND $rows AS row + MATCH (p:KnowledgePerson {source_path:row.source_path,graph_scope:$scope}), + (e:KnowledgeEvidence {id:row.evidence,graph_scope:$scope}) + MERGE (w:KnowledgeWork {id:row.id,graph_scope:$scope}) + SET w.label=row.work_label,w.type=row.work_type,w.year=row.work_year + MERGE (p)-[:HAS_WORK]->(w) MERGE (w)-[:EVIDENCED_BY]->(e)''',rows=works,scope=SCOPE).consume() + for role,edge in [('supervisor','SUPERVISED_BY'),('consultant','CONSULTED_BY')]: + advisors=[{'work':identifier(u['source_path'],u['work_label']), + 'id':identifier(normalized(name)),'name':name,'evidence':u['id']} + for u in index['units'] if u.get('work_label') and (name:=advisor_name(u,role))] + tx.run(f'''UNWIND $rows AS row + MATCH (w:KnowledgeWork {{id:row.work,graph_scope:$scope}}), + (e:KnowledgeEvidence {{id:row.evidence,graph_scope:$scope}}) + MERGE (a:KnowledgeAdvisor {{id:row.id,graph_scope:$scope}}) SET a.name=row.name + MERGE (w)-[:{edge}]->(a) MERGE (e)-[:DOCUMENTS_ROLE]->(a)''',rows=advisors,scope=SCOPE).consume() + tx.run('''MATCH (e:KnowledgeEvidence {graph_scope:$scope}) + WHERE e.meeting_id IS NOT NULL AND e.kind <> 'meeting' + MATCH (m:KnowledgeEvidence {graph_scope:$scope,kind:'meeting',meeting_id:e.meeting_id}) + MERGE (e)-[:RECORDED_AT]->(m)''',scope=SCOPE).consume() + tx.run('''MATCH (p:KnowledgePerson {graph_scope:$scope})-[:HAS_WORK]->(w), + (p)-[:HAS_EVIDENCE]->(e) + WHERE e.work_label=w.label AND e.kind='meeting' + MERGE (w)-[:HAS_MEETING]->(e)''',scope=SCOPE).consume() + tx.run('''MATCH (p:KnowledgePerson {graph_scope:$scope})-[:HAS_WORK]->(w), + (p)-[:HAS_EVIDENCE]->(e) + WHERE e.work_label=w.label AND e.completion_status IS NOT NULL + MERGE (w)-[:HAS_STATUS_RECORD]->(e)''',scope=SCOPE).consume() tx.run('MERGE (s:KnowledgeState {scope:$scope}) SET s.version=$version', scope=SCOPE, version=index['version']).consume() tx.commit() diff --git a/tools/retrieval/person_names.py b/tools/retrieval/person_names.py index 2706abf..6d3349e 100644 --- a/tools/retrieval/person_names.py +++ b/tools/retrieval/person_names.py @@ -85,7 +85,7 @@ _STOP = set('a aj ale alebo ak ako ake aky aku ktore ktori ktora ktory kto kolko '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'.split()) + 'aktualne teraz vedie viedol pokrocil pokrocili kedy prislusne o com pod'.split()) _STOP = {normalize(word) for word in _STOP} @@ -95,7 +95,9 @@ def _generic(word: str) -> bool: 'zameran', 'ciel', 'vysled', 'priebeh', 'pouz', 'experiment', 'problem', 'metod', 'riesen', 'system', 'vyhladav', 'inform', 'studij', 'databaz', 'zdroj', 'prehlad', 'presn', 'postup', - 'hodnot', 'technol', 'zadani', 'uloh', 'spaja', 'evidov', 'veden')) + 'hodnot', 'technol', 'zadani', 'uloh', 'spaja', 'evidov', 'veden', + 'veduc', 'konzultant', 'skolitel', 'ukonc', 'skonc', 'uspes', + 'neuspes', 'obhaj', 'studium', 'studia', 'stretnut', 'posledn')) def _identity(row: dict) -> tuple: