Akademické údaje
This commit is contained in:
parent
7d07374ce9
commit
6d0654db47
4
packages/claude/dist/packages.json
vendored
4
packages/claude/dist/packages.json
vendored
@ -3,12 +3,12 @@
|
||||
{
|
||||
"archive": "zpwiki-rag.zip",
|
||||
"name": "rag",
|
||||
"sha256": "812764028d95fc0b9f0ebd52f3aa20f38984eb1d3477da3f55a633703d423123"
|
||||
"sha256": "3eb3a59827b3eda400c7a5cd5dc3909831d5343903b159a71d843fb712970b52"
|
||||
},
|
||||
{
|
||||
"archive": "zpwiki-graphrag.zip",
|
||||
"name": "graphrag",
|
||||
"sha256": "f548b9a4ec313fd687cffb2f004435cae0f4b40a0e5f57a955fde4dff27cb5d1"
|
||||
"sha256": "99cf35d9050b5942b1d33744c49ade436e8c4b811d536257a2d0b090700dd3e0"
|
||||
}
|
||||
],
|
||||
"schema_version": 1
|
||||
|
||||
BIN
packages/claude/dist/zpwiki-graphrag.zip
vendored
BIN
packages/claude/dist/zpwiki-graphrag.zip
vendored
Binary file not shown.
BIN
packages/claude/dist/zpwiki-rag.zip
vendored
BIN
packages/claude/dist/zpwiki-rag.zip
vendored
Binary file not shown.
@ -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.
|
||||
|
||||
@ -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. '
|
||||
|
||||
@ -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.
|
||||
|
||||
166
test/test_academic.py
Normal file
166
test/test_academic.py
Normal file
@ -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
|
||||
@ -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')
|
||||
|
||||
@ -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']
|
||||
|
||||
@ -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'])
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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):
|
||||
|
||||
303
tools/knowledge/academic.py
Normal file
303
tools/knowledge/academic.py
Normal file
@ -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)
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user