dp-zp-agent/tools/knowledge/academic.py

555 lines
24 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.answers.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.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
# 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.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 = _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.answers.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,
)