213 lines
12 KiB
Python
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))
|