dp-zp-agent/tools/answers/content_selection.py

540 lines
20 KiB
Python

"""Role-aware, bounded excerpt selection and presentation from actual evidence."""
from __future__ import annotations
import re
from tools.answers.context_policy import normalized
from tools.answers.question_intent import (
FACET_LABELS,
planned_experiments,
requested_facets,
actual_methods_requested,
)
COMPLETED = (
r"\b(?:(?:implementovan|natrenovan|vytvoren|extrahovan|vykonan|vyhodnoten|nameran|"
r"vyriesen|odstranen|otestovan|porovnan|dosiahnut|dokoncen)(?:y|a|e|u|ou|ych|ym|ymi|om)|"
r"urobene vyhodnotenie|implementoval\w*|natrenoval\w*|vyhodnotil\w*|funguje|funguju)\b"
)
PLANNED = r"\b(?:bude|budu|planuj\w*|planovan\w*|cielom|mal by|ma sa|ma (?:implementovat|vypracovat|vyhodnotit|vytvorit|navrhnut|natrenovat|otestovat)|treba|pripravte|navrhnite|otestujte|vyhodnotte)\b"
ADMIN = r"\b(?:konzultac\w*|struktura prace|pisomn\w*|pisat|odovzda\w*\s+(?:text\w*|pisomn\w*|prac\w*)|wip|termin\w*)\b"
NOT_COMPLETED = r"\b(?:nie je|nie su|nebol\w*|este nie|zatial nie|nebude|nebudu|nefung\w*|neimplementov\w*|nevytvoren\w*|nevyhodnoten\w*)\b"
def has_qualification(text: str) -> bool:
return bool(
re.search(
r"\b(?:iba|len|avsak|ale|zatial|podmien\w*|obmedzen\w*|nebol\w*|nie|nepotvrd\w*)\b",
normalized(text),
)
)
def administrative_only(text: str) -> bool:
n = normalized(text)
return bool(re.search(ADMIN, n) and not re.search(COMPLETED, n))
def reference_only(text: str) -> bool:
"""A link alone establishes a reference, never an implemented result."""
value = re.sub(r"^\s*(?:[-*+]|\d+[.)])\s+", "", text).strip()
if re.fullmatch(r"\[[^]]+\]\([^\s]+\)", value):
return True
from tools.answers.context_policy import text_urls
links = text_urls(value)
if not links:
return False
for url in links:
value = value.replace(url, "")
return bool(
re.fullmatch(
r"(?:repozitar(?: s \w+(?: \w+)?)?|zdrojove kody|vystup je tu|vystupy su tu|odkaz|git)?",
normalized(value),
)
)
def split_units(text: str) -> list[str]:
from tools.answers.rag_evidence import descriptive_text
parts = re.split(r"\n\s*\n|(?=^[ \t]*(?:\d+[.)]|[-+*])\s+)", text, flags=re.M)
units = []
for part in parts:
part = part.strip()
if not descriptive_text(part):
continue
# A source paragraph is kept literal, including its negations and qualifications.
for unit in (
[part]
if len(part) <= 900 or has_qualification(part)
else re.split(r"(?<=[.!?])\s+(?=[A-ZÁČĎÉÍĽŇÓŠŤÚÝŽ])", part)
):
if descriptive_text(unit):
units.append(unit.strip())
return units
def effective_role(text: str, role: str) -> str:
n = normalized(re.sub(r"^\s*(?:\d+[.)]|[-+*])\s+", "", text))
if role in {"other", "progress"}:
from tools.answers.presentation import GOAL_VERBS
first = n.split()[0] if n else ""
if (
first in {normalized(word) for word in [*GOAL_VERBS, *GOAL_VERBS.values()]}
or n.startswith("cielom je")
or re.search(PLANNED, n)
):
return "task"
if role == "other" and re.search(COMPLETED, n) and not re.search(PLANNED, n):
return "progress"
return role
def evidence_phase(text: str, role: str, source_phase: str = "unknown") -> str:
if reference_only(text):
return "unknown"
if role == "assignment":
return "assignment"
if role == "task":
return "planned"
if re.search(PLANNED, normalized(text)):
return "planned"
if administrative_only(text):
return "administrative"
if role == "progress":
n = normalized(text)
negative_finding = re.search(r"\b(?:nebol uspesny|nebola uspesna|neboli uspesne|neprekonal\w*|nezlepsil\w*)\b",n)
if negative_finding and re.search(r"\b(?:test\w*|meran\w*|experiment\w*|vyhodnot\w*)\b",n):
return "result"
if re.search(NOT_COMPLETED, n):
return "progress"
if source_phase == "result" or (
not re.search(NOT_COMPLETED, normalized(text))
and re.search(COMPLETED, normalized(text))
):
return "result"
return "progress"
return "unknown"
def facets_for(text: str, role: str, question: str, phase: str | None = None) -> list[str]:
if reference_only(text):
return []
n = normalized(text)
facets = []
if role == "assignment":
facets.append("assignment")
if role == "task":
facets.append("plans")
if role == "progress" and not re.search(PLANNED, n) and not administrative_only(text):
facets.append("progress")
if (phase or evidence_phase(text, role)) == "result":
facets.append("results")
if re.search(
r"\b(?:problem\w*|tazkost\w*|tazke|prekazk\w*|chyb\w*|zlyha\w*|nepodar\w*|nevyries\w*|nefung\w*|nepasuj\w*|nesulad\w*|nekompatibil\w*)\b", n
) and role not in {"assignment", "task"}:
facets.append("problems")
if re.search(
r"\b(?:metod\w*|technolog\w*|nastroj\w*|pouzi\w*|vyuziv\w*|pomocou|implementovan\w*|natrenovan\w* model\w*|indexov\w*|databaz\w*|webov\w* rozhrani\w*)\b",
n,
):
actual_use = actual_methods_requested(question)
actual_statement = not re.search(NOT_COMPLETED, n) and (
re.search(COMPLETED, n)
or re.search(r"\b(?:pouziva\w*|pouzil\w*|vyuziva\w*|vyuzil\w*|vieme indexov\w*)\b", n)
)
if not actual_use or (role not in {"assignment", "task"} and actual_statement):
facets.append("methods")
if re.search(
r"\b(?:experiment\w*|meran\w*|test\w*|otest\w*|vyhodnot\w*|benchmark\w*|f1|presnost\w*)\b",
n,
):
wants_done = re.search(
r"\b(?:vykonal\w*|vykonan\w*|otestoval\w*|uskutocnil\w*|uskutocnen\w*|realizovan\w*|vyhodnotil\w*)\b",
normalized(question),
)
if wants_done and role in {"assignment", "task"}:
pass
elif (
not planned_experiments(question)
or role in {"assignment", "task"}
or re.search(PLANNED, n)
):
facets.append("experiments")
return facets
def unit_score(text: str, role: str, facet: str = "") -> int:
n = normalized(text)
score = 25 if role == "progress" else 10
if re.search(COMPLETED, n):
score += 45
if re.search(
r"\b(?:model|system|algoritm\w*|vyhodnot\w*|experiment\w*|presnost\w*|pagerank|bert)\b", n
):
score += 15
if administrative_only(text):
score -= 70
if facet == "problems" and re.search(r"\b(?:problem\w*|chyb\w*|zlyha\w*)\b", n):
score += 35
if role in {"assignment", "task"} and facet == "results":
score -= 100
return score
def prioritize_frames(frames: list[str], question: str) -> list[str]:
"""Order whole source windows before budgeting; do not rewrite their text."""
from tools.answers.rag_evidence import content_sections, TITLE_LABELS
rankings = []
for facet in requested_facets(question):
scores = []
for index, frame in enumerate(frames):
relevant = []
for section in content_sections(frame):
for text in split_units(section["text"]):
role = effective_role(text, section["kind"])
phase = evidence_phase(text, role, section.get("phase", "unknown"))
if len(text) <= 2500 and facet in facets_for(text, role, question, phase):
relevant.append(unit_score(text, role, facet))
if relevant:
scores.append((index, max(relevant)))
rankings.append([i for i, _ in sorted(scores, key=lambda item: -item[1])])
order = []
if re.search(r"\bnaz(?:ov|v)\w*\b", normalized(question)) and frames:
order = [
i
for i, frame in enumerate(frames)
if any(
":" in line and normalized(line.split(":", 1)[0]) in TITLE_LABELS
for line in frame.splitlines()
)
] or [0]
seen = set(order)
for pos in range(max(map(len, rankings), default=0)):
for ranking in rankings:
if pos < len(ranking) and ranking[pos] not in seen:
seen.add(ranking[pos])
order.append(ranking[pos])
return [frames[i] for i in [*order, *(i for i in range(len(frames)) if i not in seen)]]
def evidence_units(context: dict, quotes: list[dict] | None = None) -> list[dict]:
from tools.answers.rag_evidence import content_sections, quote_sections, quote_matches_passage
rows = []
for passage in context.get("content_passages", []):
pieces = (
content_sections(passage["text"])
if quotes is None
else [
piece
for quote in quotes
if quote_matches_passage(quote, passage)
for piece in quote_sections(quote, {"content_passages": [passage]})
]
)
for piece in pieces:
for unit in split_units(piece["text"]):
role = effective_role(unit, piece["kind"])
phase = evidence_phase(unit, role, piece.get("phase", "unknown"))
rows.append(
{
k: passage.get(k)
for k in [
"person",
"work_type",
"year",
"work_label",
"source_id",
"passage_id",
]
}
| {
"text": unit,
"kind": role,
"phase": phase,
"facets": facets_for(unit, role, context.get("question", ""), phase),
}
)
# Overlapping chunks/quotes can repeat the same fact with different roles.
# An explicit role wins over an unlabelled duplicate, within the same work.
unique = {}
for row in rows:
clean = re.sub(r"^\s*(?:\d+[.)]|[-+*])\s+", "", row["text"])
key = tuple(row.get(k) for k in ["person", "work_type", "year"]) + (
normalized(row.get("work_label")),
row["phase"],
" ".join(clean.split()),
)
old = unique.get(key)
if old is None or (old["kind"] == "other" and row["kind"] != "other"):
unique[key] = row
units = list(unique.values())
# Prefer a whole longer excerpt over its overlapping prefix, within one
# work and phase. Never merge people, years or plans with recorded results.
def plain(row):
return " ".join(re.sub(r"^\s*(?:\d+[.)]|[-+*])\s+", "", row["text"]).split())
return [
row
for row in units
if not any(
all(row.get(k) == other.get(k) for k in ["person", "work_type", "year", "phase"])
and normalized(row.get("work_label")) == normalized(other.get("work_label"))
and len(plain(row)) < len(plain(other))
and " " + plain(row) + " " in " " + plain(other) + " "
for other in units
)
]
def scope_groups(context: dict) -> list[tuple]:
req = context.get("requirements", {})
groups = []
for person in req.get("people", []):
labels = list(
dict.fromkeys(
s.get("work_label")
for s in context.get("work_scopes", [])
if s.get("person") == person
)
)
(
groups.extend((person, label) for label in labels)
if req.get("separate_works") and labels
else groups.append((person, None))
)
return groups
def matches_scope(row: dict, person: str, label: str | None) -> bool:
return row.get("person") == person and (
label is None or normalized(row.get("work_label")) == normalized(label)
)
def scope_context(context: dict, person: str, label: str | None) -> dict:
return {
**context,
"content_passages": [
p for p in context.get("content_passages", []) if matches_scope(p, person, label)
],
}
def candidates(context: dict) -> list[dict]:
from tools.answers.evidence_selection import identify
facets = context["requirements"].get("facets", [])
selected = []
seen = set()
units = evidence_units(context)
for person, label in scope_groups(context):
for facet in facets:
relevant = [
u for u in units if matches_scope(u, person, label) and facet in u["facets"]
]
if (
facet == "progress"
and "results" in facets
and any(u["phase"] == "progress" for u in relevant)
):
relevant = [u for u in relevant if u["phase"] == "progress"]
if facet != "assignment":
relevant.sort(key=lambda u: -unit_score(u["text"], u["kind"], facet))
accepted = 0
for unit in relevant:
text = unit["text"]
if len(text) > 2500:
context["complete"] = False
context["truncated"] = True
context["truncation_reasons"] = list(
dict.fromkeys(
[*(context.get("truncation_reasons") or []), "evidence_unit_limit"]
)
)
continue
key = (unit.get("passage_id"), unit["source_id"], text, unit["phase"])
if key not in seen:
seen.add(key)
selected.append(
identify(
{**unit, "text": text},
required=unit["kind"] == "assignment" and "assignment" in facets,
)
)
accepted += 1
if accepted == 2 and facet != "assignment":
break
return selected
def missing_facets(answer: dict, context: dict) -> list[tuple[str, str]]:
return list(
dict.fromkeys(
(person, facet) for person, _label, facet in missing_work_facets(answer, context)
)
)
def missing_work_facets(answer: dict, context: dict) -> list[tuple]:
found = evidence_units(context, answer.get("evidence_quotes", []))
return [
(person, label, facet)
for person, label in scope_groups(context)
for facet in context["requirements"].get("facets", [])
if not any(matches_scope(row, person, label) and facet in row["facets"] for row in found)
]
def fit_candidates(sent: dict, context: dict, limit: int, encoding: str) -> None:
"""Budget the actual candidate JSON, sharing space across named people."""
import json
from tools.rag.build_chunks import TokenCounter
counter = TokenCounter(encoding)
if counter.encoding is None:
raise RuntimeError("Missing context tokenizer")
original = list(context.get("content_candidates", []))
groups = [
[row for row in original if matches_scope(row, person, label)]
for person, label in scope_groups(context)
]
ordered = [
group[i]
for i in range(max(map(len, groups), default=0))
for group in groups
if i < len(group)
]
selected = []
for row in ordered:
if (
counter.count(
json.dumps({**sent, "content_candidates": [*selected, row]}, ensure_ascii=False)
)
<= limit
):
selected.append(row)
if len(selected) != len(original):
context["complete"] = False
context["truncated"] = True
context["truncation_reasons"] = list(
dict.fromkeys([*(context.get("truncation_reasons") or []), "answer_candidate_budget"])
)
sent.update({key: context[key] for key in ["complete", "truncated", "truncation_reasons"]})
context["content_candidates"] = selected
sent["content_candidates"] = selected
# Account for the added limitation metadata as well.
while selected and counter.count(json.dumps(sent, ensure_ascii=False)) > limit:
selected.pop()
if counter.count(json.dumps(sent, ensure_ascii=False)) > limit:
raise ValueError("Mandatory answer metadata exceeds context budget")
def facet_lines(answer: dict, context: dict) -> list[str]:
from tools.answers.presentation import (
goal_text,
evidence_subject,
missing_aspects,
comparison_table,
)
req = context["requirements"]
units = evidence_units(context, answer.get("evidence_quotes", []))
facets = req.get("facets", [])
groups = []
labels = {f: FACET_LABELS[f] for f in facets}
if "experiments" in facets and planned_experiments(context.get("question", "")):
labels["experiments"] = "Plánované experimenty a vyhodnotenie"
for person, work_label in scope_groups(context):
group = {
"subject": (
evidence_subject(person, scope_context(context, person, work_label))
if work_label is None
else person + " — " + " ".join(work_label.split())
),
"items": {},
"missing": [],
}
seen = set()
for facet in facets:
rows = [
u for u in units if matches_scope(u, person, work_label) and facet in u["facets"]
]
if (
facet == "progress"
and "results" in facets
and any(u["phase"] == "progress" for u in rows)
):
rows = [u for u in rows if u["phase"] == "progress"]
if facet != "assignment":
rows.sort(key=lambda u: -unit_score(u["text"], u["kind"], facet))
if not rows:
group["missing"].append(facet)
rendered = []
for row in rows:
key = (
" ".join(re.sub(r"^\s*(?:\d+[.)]|[-+*])\s+", "", row["text"]).split()),
row["phase"],
)
if key in seen:
continue
text = row["text"]
if row["kind"] in {"assignment", "task"}:
text, _ = goal_text(text)
text = re.sub(r"^\s*(?:\d+[.)]|[-+*])\s+", "", text)
phase = (
" (plán)"
if row["kind"] in {"assignment", "task"}
and facet not in {"assignment", "plans"}
else ""
)
state = (
"Zaznamenaný výsledok: "
if row["phase"] == "result" and "results" in facets and facet != "results"
else ""
)
rendered.append(state + text + phase)
seen.add(key)
if len(rendered) == 2 and facet != "assignment":
break
group["items"][facet] = rendered
groups.append(group)
if len(groups) > 1 and re.search(r"\bporovnaj\w*\b", normalized(context.get("question", ""))):
rows = []
for facet in facets:
if not any(g["items"][facet] for g in groups):
if all(facet not in g["missing"] for g in groups):
continue
values = [
"\n".join(g["items"][facet])
or ("V podkladoch chýbajú údaje." if facet in g["missing"] else "Doložené vyššie.")
for g in groups
]
rows.append((labels[facet], values))
if any(any(g["items"][f] for f in facets) for g in groups):
table = comparison_table(["Oblasť", *(g["subject"] for g in groups)], rows)
if table is not None:
return table
lines = []
for group in groups:
title = group["subject"]
if len(facets) == 1 and group["items"][facets[0]]:
title += " — " + labels[facets[0]].lower()
lines.extend(["**" + title + "**", ""])
for facet in facets:
items = group["items"][facet]
if not items:
continue
if len(facets) > 1:
lines.append("**" + labels[facet] + ":**")
lines.extend("- " + text for text in items)
lines.append("")
if group["missing"]:
lines.extend([missing_aspects(group["missing"], context.get("question", "")), ""])
return lines