Akademické údaje

This commit is contained in:
Ján Pták 2026-11-07 15:08:05 +01:00
parent 7d07374ce9
commit 6d0654db47
19 changed files with 612 additions and 22 deletions

View File

@ -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

Binary file not shown.

Binary file not shown.

View File

@ -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.

View File

@ -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. '

View File

@ -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
View 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

View File

@ -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')

View File

@ -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']

View File

@ -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'])

View File

@ -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}

View File

@ -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
View 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)

View File

@ -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:

View File

@ -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

View File

@ -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"]}
]
}

View File

@ -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()

View File

@ -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()

View File

@ -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: