dp-zp-agent/tools/answers/content_selection.py
2026-11-15 16:27:08 +01:00

566 lines
22 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"
# Named tools in a record: acronyms (RAG, MCP), CamelCase (OpenWebUI, GraphRAG), names with digits
# (Neo4J, Fast120, me5mini), domains, and common lower-case tool names.
TOOL_NAMES = set("""python pytorch tensorflow keras docker kubernetes unsloth llamafactory qlora lora optuna whisper
bert slovakbert mistral llama qwen olmo gemma huggingface transformers spacy stanza prodigy rasa langchain llamaindex
elasticsearch opensearch neo4j cypher postgres postgresql sqlite mysql mongodb redis fastapi flask django react vue
angular nodejs javascript typescript java kotlin matlab opencv yolo onnx ollama openwebui telegram discord grafana
prometheus jupyter colab git gitea github gitlab slurm faiss qdrant chroma milvus pandas numpy scikit sklearn skills
skill streamlit gradio langgraph cogdb""".split())
TOOL_PATTERN = re.compile(r"(?<![\w.])(?:[A-Z][A-Za-z]*[A-Z][A-Za-z]*|[A-Za-z]+\d[\w.]*|\d+[A-Za-z][\w]*|[a-z0-9-]+\.[a-z]{2,4})(?![\w])")
SEARCH_METHODS = r"\b(?:vektorov\w* vyhladav\w*|textov\w* vyhladav\w*|vyhladavani\w*|dotrenov\w*|fine.?tun\w*|embedding\w*|grafov\w* databaz\w*|znalostn\w* graf\w*)\b"
def named_tools(text: str) -> list[str]:
"""Tool and technology names the record mentions (not dates, not plain words)."""
found = [m for m in TOOL_PATTERN.findall(text or "") if not re.fullmatch(r"\d+[.:]\d*", m) and len(m) >= 2]
found += [w for w in re.findall(r"[A-Za-z][A-Za-z0-9]+", text or "") if w.lower() in TOOL_NAMES]
return list(dict.fromkeys(found))
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")
tools = named_tools(text)
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*|frontend\w*|backend\w*|vyskus\w*|sumarizac\w*)\b",
n,
) or tools or re.search(SEARCH_METHODS, n):
actual_use = actual_methods_requested(question)
# A status/result record naming a tool ("Sú Skills aj MCP server", "začíname pracovať na
# GraphRAG a Neo4J") states real use unless it is phrased as a plan or as not done.
stated_in_status = (role in {"progress", "result"} and bool(tools or re.search(SEARCH_METHODS, n))
and not re.search(PLANNED, n))
actual_statement = not re.search(NOT_COMPLETED, n) and (
re.search(COMPLETED, n) or re.search(r'\bvyskus(?:any|ana|ane|al|ala|ali)\b',n)
or re.search(r"\b(?:pouziva\w*|pouzil\w*|vyuziva\w*|vyuzil\w*|vieme indexov\w*)\b", n)
or (not re.search(PLANNED,n) and re.search(r'\b(?:frontend|backend)\s+\w+',n))
or stated_in_status
)
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