"""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*|frontend\w*|backend\w*|vyskus\w*|sumarizac\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'\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)) ) 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