"""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))