304 lines
20 KiB
Python
304 lines
20 KiB
Python
"""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)
|