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 3311b67..165b64f 100644 Binary files a/packages/claude/dist/zpwiki-graphrag.zip and b/packages/claude/dist/zpwiki-graphrag.zip differ diff --git a/packages/claude/dist/zpwiki-rag.zip b/packages/claude/dist/zpwiki-rag.zip index 1e93be1..cd84b26 100644 Binary files a/packages/claude/dist/zpwiki-rag.zip and b/packages/claude/dist/zpwiki-rag.zip differ 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: