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

213 lines
12 KiB
Python

"""Conservative assertion provenance; profile ownership is not event authorship."""
from __future__ import annotations
from datetime import date
import re
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from tools.answers.context_policy import normalized
from tools.retrieval.person_names import name_token_variants, normalize
EXTRACTION_SCHEMA = 2
EXTRACTION_POLICY = "scoped-assertions-v2"
STRUCTURAL_KINDS = {"reference", "work_record", "meeting", "supervisor", "consultant"}
ACTION = re.compile(
r"\b(?:neobhajil\w*|obhajil\w*|obhajena|obhajeny|ukoncil\w*|skoncil\w*|absolvoval\w*|"
r"odovzdal\w*|prerusil\w*|implementoval\w*|vytvoril\w*|vyhodnotil\w*|"
r"pouzil\w*|pouziva(?:m|s|me|te|ju)?|natrenoval\w*|dosiahol\w*|ziskal\w*|pracoval\w*)\b"
)
NAME = re.compile(r"\b[A-ZÁÄČĎÉÍĹĽŇÓÔŔŠŤÚÝŽ][a-záäčďéíĺľňóôŕšťúýž]+(?:[- ][A-ZÁÄČĎÉÍĹĽŇÓÔŔŠŤÚÝŽ][a-záäčďéíĺľňóôŕšťúýž]+){0,3}\b")
GENERIC = {"praca", "projekt", "student", "studentka", "uspesne", "neuspesne", "podla", "stav", "on", "ona", "system", "agent", "model", "aplikacia", "kto", "kedy", "toto", "asi", "ak", "tabulka", "chunk", "chatbot", "zaujala", "zaujalo"}
class EvidenceAssertion(BaseModel):
"""One literal source unit; unknown actor/work/time stays explicitly unknown."""
model_config = ConfigDict(extra="allow", strict=True)
person: str = Field(min_length=1)
actor: str | None
actor_status: Literal["explicit_owner","profile_context","explicit_other","uncertain","not_applicable"]
work_id: str | None
work_label: str | None
work_type: str | None
work_year: int | None
text: str = Field(min_length=1)
phase: Literal["assignment","planned","progress","result","administrative","unknown"]
section_scope: Literal["profile","work","meeting_note","followup_task","backlog","future_period","example","references"]
source_path: str = Field(min_length=1)
source_url: str = Field(min_length=1)
line: int = Field(ge=1)
end_line: int = Field(ge=1)
record_date: str | None
event_date: str | None
event_year: int | None
@model_validator(mode="after")
def validate_scope(self):
if self.end_line != self.line + self.text.count("\n"):
raise ValueError("Source line range does not match the literal quote")
if self.actor_status in {"explicit_owner","profile_context"} and self.actor != self.person:
raise ValueError("Profile actor binding does not match its person")
if bool(self.work_label) != bool(self.work_id):
raise ValueError("Work identity must be explicit or explicitly missing")
if self.phase in {"assignment","planned"} and (self.event_date or self.event_year):
raise ValueError("A planned time is not an executed event time")
if self.event_year and str(self.event_year) not in self.text:
raise ValueError("An event year must occur in the source assertion")
if self.section_scope in {"backlog","future_period"} and (self.record_date or self.model_extra.get("meeting_id")):
raise ValueError("A general backlog cannot inherit a meeting date")
return self
def same_person(mention: str, owner: str) -> bool:
actual, expected = normalize(mention).split(), normalize(owner).split()
return len(actual) == len(expected) and all(a in name_token_variants(e) for a, e in zip(actual, expected))
def actor_binding(unit: dict) -> dict:
owner = unit.get("person")
if unit.get("kind") in STRUCTURAL_KINDS:
return {"actor": None, "actor_status": "not_applicable", "actor_quote": None}
raw = re.sub(r"^\s*(?:[-*+]\s*(?:\[[xX ]\])?|\d+[.)])\s*", "", unit["text"])
text = normalized(raw)
# Third-person and reported/quoted statements need an explicit actor.
if re.search(r"\b(?:kolega|kolegyna|iny student|inych studentov|oni|ony)\b|"
r"\b(?:jeho|jej) (?:diplomov\w*|bakalarsk\w*|praca|pracu|obhajoba|studium)\b", text):
return {"actor": None, "actor_status": "uncertain", "actor_quote": None}
candidates = []
name_mentions = []
# Names before a finite action verb, or an explicit passive agent, are
# considered separately from names mentioned as tools/authors of material.
for match in NAME.finditer(raw):
phrase = match[0]
words = phrase.split()
while words and (normalize(words[0]) in GENERIC or ACTION.fullmatch(normalize(words[0]))
or re.fullmatch(r"(?:implementovan|vytvoren|vyhodnoten|natrenovan|ziskan)\w*",normalize(words[0]))):
words.pop(0)
phrase = " ".join(words)
if not phrase:
continue
name_mentions.append(phrase)
raw_tail = raw[match.end():]
# Do not borrow the verb from the next sentence ("Python. Vyhodnotil BERT").
tail = normalized(re.split(r"[.!?;]",raw_tail,maxsplit=1)[0])
head = normalized(re.split(r"[.!?;]",raw[:match.start()])[-1])
near_action = re.match(r"^(?:(?:uspesne|neuspesne|uz|samostatne|spolocne|v roku \d{4})\s+){0,3}", tail)
remainder = tail[near_action.end():] if near_action else tail
if re.match(r"^(?:ma|mal|mala)\b", remainder):
# A single capitalized subject may be another person. Without
# evidence of identity it is uncertain, never assigned to the owner.
if owner and same_person(phrase, owner):
candidates.append(phrase)
else:
return {"actor":None,"actor_status":"uncertain","actor_quote":phrase}
if (ACTION.match(remainder)
or re.search(r"\b(?:neobhajil\w*|obhajil\w*|obhajena|obhajeny|ukoncil\w*|absolvoval\w*)\s*$", head)
or re.search(r"\b(?:obhajena|obhajeny|vytvoreny|vykonany)\b.*\b(?:studentom|studentkou|autorom)\s*$", head)):
candidates.append(phrase)
if candidates:
if owner and all(same_person(name, owner) for name in candidates):
return {"actor": owner, "actor_status": "explicit_owner", "actor_quote": candidates[0]}
return {"actor": candidates[0] if len(set(candidates)) == 1 else None,
"actor_status": "explicit_other" if len(set(candidates)) == 1 else "uncertain",
"actor_quote": candidates[0] if len(set(candidates)) == 1 else None}
# A possessive name or a passive third-person agent cannot be replaced by
# the profile owner when this narrow parser cannot resolve it.
if re.search(r"\b\w+(?:ova|ov|ova) (?:obhajoba|praca)\b", text) and not text.startswith(("diplomova", "bakalarska")):
return {"actor": None, "actor_status": "uncertain", "actor_quote": None}
if ACTION.search(text) and any(len(mention.split()) >= 2 and not (owner and same_person(mention,owner)) for mention in name_mentions):
return {"actor":None,"actor_status":"uncertain","actor_quote":None}
if not owner:
return {"actor": None, "actor_status": "uncertain", "actor_quote": None}
return {"actor": owner, "actor_status": "profile_context", "actor_quote": None}
def temporal_fields(text: str, phase: str) -> dict:
"""Dates inside a statement are not inherited from its record/work heading."""
n = normalized(text)
planned = phase in {"assignment", "planned"}
observed = bool(ACTION.search(n) or re.search(
r"\b(?:implementovan\w*|vytvoren\w*|vyhodnoten\w*|nameran\w*|ziskan\w*|"
r"dokonceny|dokoncena|otestovan\w*|urobene)\b",n))
def linked_time(start, *, year=False):
prefix = normalized(text[max(0,start-90):start])
# Publication/edition/work years and deadlines are different time roles.
if re.search(r"\b(?:publikovan\w*|vydan\w*|clanok|clanku|verzia|verzie)\b[^.]{0,65}$",prefix):
return False
if re.search(r"\b(?:z|zo|od)\s*$",prefix):
return False
if planned:
return year or bool(re.search(r"\b(?:do|dna|na|najneskor|termin|deadline|on)\s*$",prefix))
return observed and not re.search(r"\b(?:do|najneskor|pred|po|before|after|by|ku dnu|k datumu|as of)\s*$",prefix)
dates = []
for m in re.finditer(r"\b(\d{1,2})\.\s*(\d{1,2})\.\s*([12]\d{3})\b", text):
try:
if linked_time(m.start()):
dates.append((date(int(m[3]), int(m[2]), int(m[1])).isoformat(), m[0]))
except ValueError:
pass
for m in re.finditer(r"\b([12]\d{3})-(\d{2})-(\d{2})\b", text):
try:
if linked_time(m.start()):
dates.append((date(int(m[1]), int(m[2]), int(m[3])).isoformat(), m[0]))
except ValueError:
pass
years = {int(m[1]) for m in re.finditer(r"\b(?:v roku|v|on)\s+([12]\d{3})\b", text, re.I)
if linked_time(m.start(),year=True)}
years.update(int(d[0][:4]) for d in dates)
exact = dates[0][0] if len(set(d[0] for d in dates)) == 1 else None
year = next(iter(years)) if len(years) == 1 else None
partial = re.search(r"\b(\d{1,2}\.\s*\d{1,2}\.)(?!\s*[12]\d{3})", text)
precision = "day" if exact else "year" if year else "day_month" if partial else "unknown"
label = dates[0][1] if exact else str(year) if year else partial[1] if partial else None
return {"event_date": exact if not planned else None, "event_year": year if not planned else None,
"event_date_quote": label if not planned else None,
"event_date_precision": precision if not planned else "unknown",
"target_date": exact if planned else None, "target_year": year if planned else None,
"target_date_quote":label if planned else None,
"target_date_precision":precision if planned else "unknown"}
def claim_eligible(unit: dict) -> bool:
if unit.get("section_scope") in {"example","references"}:
return False
if unit.get("kind") in STRUCTURAL_KINDS:
return True
binding = actor_binding(unit)
return (binding["actor_status"] in {"explicit_owner", "profile_context"}
and unit.get("annotation_status") not in {"uncertain", "irrelevant"})
RELATION_CUES = {
"uses": r"(?:pouziva(?:ju)?|vyuziva(?:ju)?|pouzil[ai]?|vyuzil[ai]?|uses?|used|utilizes?)",
"evaluates": r"(?:vyhodnocuje|vyhodnotil[ai]?|testuje|otestoval[ai]?|evaluates?|evaluated|tests?)",
"produces": r"(?:generuje|vytvara|vytvoril[ai]?|produces?|generates?|outputs?)",
"about": r"(?:sa venuje|sa tyka|je zameran[yae] na|is about|focuses on)",
}
def relation_has_local_support(unit: dict, relation: dict) -> bool:
"""Necessary local support before independent semantic verification.
Only narrow, directed clauses are activated. Wider/paraphrased relationships
remain unverified; literal endpoint co-occurrence never suffices.
"""
quote = relation["quote"]
if not quote or quote not in unit["text"] or not claim_eligible(unit):
return False
n = normalized(quote)
if re.search(r"\b(?:nie|not|never|without|bez|nebol\w*|nepouziv\w*|nevytvar\w*|nevyhodnoc\w*)\b", normalized(unit["text"])):
return False
if re.search(r"\bsa (?:pouziva|vyuziva|vytvara|vyhodnocuje)\b|\bis (?:used|produced|evaluated)\b", n):
return False
subject, obj = normalized(relation["subject"]), normalized(relation["object"])
left, right = n.find(subject), n.find(obj)
if left < 0 or right <= left + len(subject):
return False
# Evidence must be a single clause, never two unrelated sentences.
if re.search(r"[.!?;\n]", quote):
if re.search(r"[.!?;\n]", quote.rstrip('.!? ')):
return False
between = n[left + len(subject):right]
return bool(re.search(r"\b" + RELATION_CUES[relation["predicate"]] + r"\b", between))