"""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 import re from tools.answers.context_policy import normalized from tools.knowledge.sources import advisor_name def with_profile_metadata(result,plan,db_file): """Keep indexed document authorship and literal study start separate from work events.""" import sqlite3 from tools.sync.index_state import index_snapshot,read_version from tools.rag.search_core import make_source_url if result.get('status')!='ok' or not result.get('accepted_for_delivery'):return result with index_snapshot(db_file.parent,db_file=db_file): if read_version(db_file.parent)!=result['index_version']:raise RuntimeError('Authorship snapshot changed') with sqlite3.connect(db_file.resolve().as_uri()+'?mode=ro',uri=True) as conn: rows=[] for person in plan.spec.people: if person.role!='student' or not person.source_document:continue row=conn.execute('SELECT author FROM documents WHERE path=? AND COALESCE(published,1)=1', (person.source_document,)).fetchone() if 'document_author' in plan.spec.fields and row and row[0]:rows.append({'person':person.name,'document_author':row[0], 'source_path':person.source_document,'source_url':make_source_url(person.source_document),'metadata_field':'author'}) if 'start_year' in plan.spec.fields: starts={} for chunk_id,text in conn.execute('SELECT chunk_id,text FROM chunks WHERE document_path=? AND COALESCE(published,1)=1',(person.source_document,)): for line in text.splitlines(): match=re.fullmatch(r'rok zaciatku studia ([12]\d{3})',normalized(line)) if match:starts[int(match[1])]=(chunk_id,line) if len(starts)==1: year=next(iter(starts));chunk_id,quote=starts[year] rows.append({'person':person.name,'start_year':year,'source_path':person.source_document, 'source_url':make_source_url(person.source_document),'metadata_field':'literal_start_year','chunk_id':chunk_id,'quote':quote}) if not rows:return result body,separator,sources=result['answer'].rpartition('\n\nZdroje:') if not separator:body=result['answer'] lines=[('Autor dokumentu profilu '+r['person']+': '+r['document_author']+'.') if 'document_author' in r else ('Explicitný rok začiatku štúdia — '+r['person']+': '+str(r['start_year'])+'.') for r in rows] if 'start_year' in plan.spec.fields and 'year' in plan.spec.fields: lines.append('Rok práce, dátum záznamu a dátum vykonania činnosti sú odlišné údaje.') urls=list(dict.fromkeys([*result['source_urls'],*(r['source_url'] for r in rows)])) answer=body+'\n\n'+'\n'.join(lines)+'\n\nZdroje: '+', '.join(urls) return {**result,'answer':answer,'metadata_evidence':rows, 'source_urls':urls} 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*|konzult\w*)\b", text): return "advisors" return None def completion_event(unit): """Recognize narrow, explicit outcomes, never completed subtasks or plans.""" from tools.knowledge.evidence import claim_eligible, STRUCTURAL_KINDS if unit.get("kind") in STRUCTURAL_KINDS or not claim_eligible(unit): return None 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*|uspesne obhajen[ay]|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.answers.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 # Event timing comes only from the statement, never its record/work heading. from tools.knowledge.evidence import temporal_fields timing = temporal_fields(unit["text"], unit.get("phase", "unknown")) return {"status":status,"scope":scope,"event_year":timing["event_year"], "event_date":timing["event_date"]} def answer_academic(question, index, *, engine, limit=5, units=None, mode=None, plan=None): mode = mode or academic_intent(question) from tools.knowledge.evidence import claim_eligible source_rows = units if units is not None else index["units"] rows = [r for r in source_rows if claim_eligible(r)] catalog = [ {"name": name, "kind": "Person", "source_document": path} for name, path in sorted({(r["person"], r["source_path"]) for r in source_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 = plan.resolution if plan else 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 not plan and unsupported not in {None, "work_event_date", "year_range"}: return respond( constraint_message(unsupported), status="ambiguous", reason="unsupported_constraint" ) if not plan and 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")] if plan: staff={normalized(p.name) for p in plan.spec.people if p.role in {'supervisor','consultant'}} student_names={p.name for p in plan.spec.people if p.role=='student'} students=[m for m in matches if m['name'] in student_names and 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*|konzult\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.answers.rag_evidence import requested_types types = requested_types(resolved, [m["name"] for m in students]) # Ordinary wording of the same work-type constraint must not silently # broaden "diplomovku" to every bachelor thesis and project on a profile. informal = { kind for pattern, kind in [ (r"\bdiplomovk\w*\b", "diploma_thesis"), (r"\bbakalark\w*\b", "bachelor_thesis"), ] if re.search(pattern, text) } if len(informal) > 1 or informal and types and not set(types.values()) <= informal: return respond( "Upresni, či sa pýtaš na bakalársku alebo diplomovú prácu.", status="ambiguous", reason="ambiguous_work_type", ) if informal: types = {m["name"]: next(iter(informal)) for m in students} global_types = set(types.values()) if not students: from tools.answers.rag_evidence import work_type kind = work_type(resolved) if kind: global_types = {kind} elif informal: global_types = informal # Completion-year filters apply to the EVENT, not the work heading. from tools.knowledge.query import _period try: period = ((plan.spec.period.start,plan.spec.period.end) if plan.spec.period else None) if plan else _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 and not plan 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} profile_roles=defaultdict(list) for row in rows: if row['kind'] in {'supervisor','consultant'} and not row.get('work_id'): profile_roles[row['source_path']].append(row) profile_work_ids=defaultdict(set) for row in index['units']: if row['kind']=='work_record' and row.get('work_id'):profile_work_ids[row['source_path']].add(row['work_id']) groups = defaultdict(list) for r in rows: if plan and not plan.spec.matches_work(r['person'],r.get('work_label'),r.get('work_type'),r.get('work_year')):continue 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"\bkonzult\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) meeting_selection=plan.spec.meeting if plan else None exact=bool(meeting_selection and meeting_selection.selection in {'exact','all'}) if exact: chosen=[r for r in markers if meeting_selection.matches(r)] if meeting_selection.selection=='exact' and len(chosen)>1 and len({r.get('work_id') for r in chosen})>1: return respond('Rovnakému dátumu zodpovedá viac prác. Uveď presné označenie práce.',status='ambiguous',reason='ambiguous_meeting_work') else: chosen = [r for r in markers if r.get("date") == latest] if latest else markers for marker in chosen[:limit]: heading='Dátum požadovaného stretnutia' if exact else 'Posledný záznam s úplným dátumom' displayed_date=marker.get('date') if exact else latest lines.append( f'**{marker["person"]} — {marker.get("work_label") or "profil"}**\n' f"{heading}: {displayed_date}." if displayed_date else f'**{marker["person"]}** — záznam: {marker["date_label"]}; rok nie je uvedený.' ) evidence.append(marker) if (plan and {'start_year','date'}<=set(plan.spec.fields) and not plan.spec.facets and not re.search(r'\bo com\b|\bco sa\b',text)): continue # A comparison of time roles does not ask for the meeting agenda. 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.answers.content_selection import unit_score notes = [r for r in notes if not r["text"].rstrip().endswith(":")] if plan and plan.spec.facets: from tools.answers.content_selection import facets_for notes=[r for r in notes if set(plan.spec.facets)&set(facets_for(r['text'], {'assignment':'assignment','planned':'task','result':'progress','progress':'progress'}.get(r['phase'],'other'), plan.spec.question,r['phase']))] 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 and not exact: 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"), ]: requested=set(plan.spec.fields)&{'supervisor','consultant'} if plan else set() if requested and role not in requested:continue roles = [r for r in part if advisor_name(r, role)] if not roles and len(profile_work_ids[key[0]])==1: roles=[r for r in profile_roles[key[0]] if advisor_name(r,role)] if roles:name_label='Vedúci v hlavičke profilu' if role=='supervisor' else 'Konzultant v hlavičke profilu' 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, )