| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922 |
- """Triage analyzer: pure, import-light classification helpers.
- Stdlib only. No ML imports here (not even lazily): the dedicated lane module
- owns the model import, background thread, and inference. This module only
- validates the questions file, condenses transcript windows, normalizes raw
- answers into display items, decides escalation, and builds/parses/finalizes
- the plain-text brief. The only I/O is the one-time questions file read in the
- constructor.
- Public API (consumed by the assistant service layer):
- TRIAGE_REASONS = ("ok", "no_questions", "loading", "laya_missing",
- "disabled", "error")
- def load_questions(path: str) -> dict | None
- class TriageAnalyzer:
- def __init__(self, cfg) -> None
- @property
- def questions(self) -> dict | None
- def build_state(self, window) -> str
- def normalize(self, payload) -> dict
- def should_escalate(self, signals) -> bool
- def build_brief_prompt(self, deltas, signals) -> str
- def build_ledger_prompt(self, narrative, ledger, deltas, signals) -> str
- def parse_brief(self, text) -> str | None
- def parse_ledger_reply(self, text, truncated=False) -> tuple[str, list[str]] | None
- def parse_facts(self, block) -> list[str]
- def should_update_brief(self, signals) -> bool
- def finalize_brief(self, text, finish_reason) -> str | None
- def state(self) -> tuple[str, str]
- def refresh(self, state: str, reason: str = "") -> None
- class FactLedger:
- def __init__(self, max_facts=200, fact_max_chars=240) -> None
- def render(self) -> str
- def merge(self, new_facts) -> int
- def facts(self) -> list[str]
- def clear(self) -> None
- @classmethod
- def from_brief(cls, brief, ...) -> "FactLedger"
- ``cfg`` is duck-typed (attribute access only, never imported): it provides
- ``questions_file``, ``state_char_budget`` (default 3000),
- ``confidence_field`` (default ``"answer_confidence"``),
- ``escalate_threshold`` (default 0.6), ``choice_min_confidence``
- (default 0.85), ``brief_max_chars`` (default 4000) and
- ``brief_gate_threshold`` (default 0.5).
- """
- import json
- import logging
- import re
- import unicodedata
- logger = logging.getLogger(__name__)
- TRIAGE_REASONS = ("ok", "no_questions", "loading", "laya_missing", "disabled", "error")
- _VALID_TYPES = ("choice", "score", "noul")
- def _invalid(path, qid, why):
- logger.warning("ignoring questions file %r: question %r: %s", path, qid, why)
- return None
- def load_questions(path: str) -> dict | None:
- """Load and validate a bare Laya questions dict; None (never raise) on failure."""
- try:
- with open(path, encoding="utf-8") as handle:
- data = json.load(handle)
- except (OSError, ValueError, UnicodeError) as exc:
- logger.warning("ignoring questions file %r: cannot read/parse (%s)", path, exc)
- return None
- if not isinstance(data, dict) or not data:
- logger.warning("ignoring questions file %r: top level must be a non-empty object", path)
- return None
- for qid, qdef in data.items():
- if not isinstance(qdef, dict):
- return _invalid(path, qid, "definition must be an object")
- qtype = qdef.get("type")
- if qtype not in _VALID_TYPES:
- return _invalid(
- path, qid, "unknown type %r; use one of %s" % (qtype, sorted(_VALID_TYPES))
- )
- if "instructions" not in qdef:
- return _invalid(path, qid, "no 'instructions'; add the text the model should answer")
- crit = qdef.get("criteria")
- if qtype == "choice":
- if not isinstance(crit, (dict, list)):
- return _invalid(path, qid, "a choice question takes 'criteria' as a dict or list")
- if not crit:
- return _invalid(path, qid, "a choice question needs at least one criterion")
- elif qtype == "score":
- if not isinstance(crit, list):
- return _invalid(path, qid, "a score question takes 'criteria' as a list")
- if not crit:
- return _invalid(path, qid, "a score question needs at least one level")
- if None in crit:
- return _invalid(path, qid, "score criteria must not contain null")
- elif crit is not None:
- if not isinstance(crit, dict):
- return _invalid(path, qid, "a noul question takes 'criteria' as a dict or omits it")
- keys = {str(key).lower() for key in crit}
- if not keys <= {"true", "false"}:
- return _invalid(
- path, qid, "a noul question takes 'criteria' keyed only 'true'/'false'"
- )
- return data
- def _number(value):
- if isinstance(value, bool):
- return None
- try:
- result = float(value)
- except (TypeError, ValueError):
- return None
- return result
- class TriageAnalyzer:
- """Pure analyzer over a duck-typed triage config (see module docstring)."""
- def __init__(self, cfg) -> None:
- self._cfg = cfg
- path = getattr(cfg, "questions_file", "questions.json")
- try:
- questions = load_questions(path)
- except Exception as exc: # load_questions never raises; belt and braces
- logger.warning("ignoring questions file %r: unexpected error (%s)", path, exc)
- questions = None
- self._questions = questions
- if questions is None:
- self._state = "no_questions"
- self._reason = "no questions loaded from %s" % (path,)
- else:
- self._state = "loading"
- self._reason = ""
- @property
- def questions(self) -> dict | None:
- return self._questions
- def _budget(self) -> int:
- try:
- return int(getattr(self._cfg, "state_char_budget", 3000))
- except (TypeError, ValueError):
- return 3000
- def _confidence_field(self) -> str:
- return getattr(self._cfg, "confidence_field", "answer_confidence")
- def _escalate_threshold(self) -> float:
- try:
- return float(getattr(self._cfg, "escalate_threshold", 0.6))
- except (TypeError, ValueError):
- return 0.6
- def _choice_min_confidence(self) -> float:
- try:
- return float(getattr(self._cfg, "choice_min_confidence", 0.85))
- except (TypeError, ValueError):
- return 0.85
- def build_state(self, window) -> str:
- """Condense oldest->newest utterances newest-first, restore order, "- " lines."""
- utterances = [str(item) for item in (window or [])]
- if not utterances:
- return ""
- budget = self._budget()
- rendered = ["- " + text for text in utterances]
- kept = []
- total = 0
- for line in reversed(rendered):
- extra = len(line) + (1 if kept else 0)
- if kept and total + extra > budget:
- break
- kept.append(line)
- total += extra
- kept.reverse()
- return "\n".join(kept)
- def _normalize_item(self, qid, qdef, answer):
- if not isinstance(answer, dict):
- return None
- qtype = qdef.get("type")
- field = self._confidence_field()
- if qtype == "choice":
- label = answer.get("choice")
- if not isinstance(label, str):
- return None
- crit = qdef.get("criteria")
- if isinstance(crit, dict):
- options = list(crit.keys())
- elif isinstance(crit, list):
- options = list(crit)
- else:
- return None
- if options and label not in options:
- return None
- confidence = _number(answer.get("confidence"))
- answer_confidence = _number(answer.get("answer_confidence"))
- if confidence is None or answer_confidence is None:
- return None
- gate = confidence if field == "confidence" else answer_confidence
- return {
- "kind": "choice",
- "choice": label,
- "confidence": confidence,
- "answer_confidence": answer_confidence,
- "gate": gate,
- "flagged": gate < self._choice_min_confidence(),
- }
- if qtype == "score":
- expected = _number(answer.get("score"))
- crit = qdef.get("criteria")
- if expected is None or not isinstance(crit, list) or not crit:
- return None
- level = max(0, min(len(crit) - 1, round(expected)))
- label = None
- legend = answer.get("legend")
- if isinstance(legend, dict) and str(level) in legend:
- label = legend[str(level)]
- else:
- label = crit[level]
- confidence = _number(answer.get("confidence"))
- answer_confidence = _number(answer.get("answer_confidence"))
- if confidence is None or answer_confidence is None:
- return None
- gate = confidence if field == "confidence" else answer_confidence
- return {
- "kind": "score",
- "expected": expected,
- "level": level,
- "label": str(label),
- "confidence": confidence,
- "answer_confidence": answer_confidence,
- "gate": gate,
- "flagged": expected >= (len(crit) - 1) * 0.5,
- }
- if qtype == "noul":
- prob = _number(answer.get("noul"))
- if prob is None:
- return None
- confidence = _number(answer.get("confidence"))
- answer_confidence = _number(answer.get("answer_confidence"))
- if confidence is None or answer_confidence is None:
- return None
- gate = confidence if field == "confidence" else answer_confidence
- return {
- "kind": "noul",
- "p": prob,
- "confidence": confidence,
- "answer_confidence": answer_confidence,
- "gate": gate,
- "flagged": prob >= self._escalate_threshold(),
- }
- return None
- def normalize(self, payload) -> dict:
- """Normalize a full laya result or a bare answers dict into signals."""
- result = {"order": [], "items": {}, "escalated": False, "input_tokens": 0}
- if not isinstance(payload, dict):
- return result
- raw_answers = payload.get("answers")
- if isinstance(raw_answers, dict):
- answers = raw_answers
- usage = payload.get("usage")
- if isinstance(usage, dict):
- tokens = _number(usage.get("input_tokens", 0))
- result["input_tokens"] = int(tokens) if tokens is not None else 0
- else:
- answers = payload
- questions = self._questions or {}
- for qid in questions:
- if qid not in answers:
- continue
- try:
- item = self._normalize_item(qid, questions[qid], answers[qid])
- except Exception:
- continue
- if item is None:
- continue
- result["order"].append(qid)
- result["items"][qid] = item
- result["escalated"] = self.should_escalate(result)
- return result
- def _score_levels(self, qid):
- questions = self._questions or {}
- qdef = questions.get(qid)
- if not isinstance(qdef, dict):
- return None
- crit = qdef.get("criteria")
- if not isinstance(crit, list) or not crit:
- return None
- return len(crit)
- def should_escalate(self, signals) -> bool:
- """True iff any arm fires: noul p, low choice gate, or high score expected."""
- items = signals.get("items", {}) if isinstance(signals, dict) else {}
- if not isinstance(items, dict):
- return False
- for qid, item in items.items():
- if not isinstance(item, dict):
- continue
- kind = item.get("kind")
- if kind == "noul":
- prob = _number(item.get("p"))
- if prob is not None and prob >= self._escalate_threshold():
- return True
- elif kind == "choice":
- gate = _number(item.get("gate"))
- if gate is not None and gate < self._choice_min_confidence():
- return True
- elif kind == "score":
- expected = _number(item.get("expected"))
- if expected is None:
- continue
- levels = self._score_levels(qid)
- if levels is None:
- if item.get("flagged") is True:
- return True
- elif expected >= (levels - 1) * 0.5:
- return True
- return False
- @staticmethod
- def _clip(text, limit=160) -> str:
- """Collapse whitespace and truncate to ~limit chars with an ellipsis."""
- try:
- collapsed = " ".join(str(text).split())
- except Exception:
- return ""
- if len(collapsed) <= limit:
- return collapsed
- return collapsed[: max(0, limit - 1)].rstrip() + "…"
- def _signal_words(self, qid, item, qdef=None, escalated=False) -> str:
- try:
- kind = item.get("kind") if isinstance(item, dict) else None
- except Exception:
- return "%s: flagged" % (qid,)
- try:
- questions = self._questions or {}
- if qdef is None and isinstance(questions, dict):
- candidate = questions.get(qid)
- if isinstance(candidate, dict):
- qdef = candidate
- instructions = ""
- if isinstance(qdef, dict):
- raw = qdef.get("instructions")
- if isinstance(raw, str) and raw.strip():
- instructions = self._clip(raw.strip())
- suffix = " [escalated]" if (escalated and isinstance(item, dict)
- and item.get("flagged") is True) else ""
- if kind == "choice":
- choice = item.get("choice")
- gate = _number(item.get("gate"))
- flagged = item.get("flagged") is True
- conf = "%.2f" % gate if gate is not None else "?"
- head = "%s (choice): selected %s (conf %s%s)" % (
- qid, choice, conf, ", flagged as uncertain" if flagged else "")
- label_def = ""
- try:
- crit = qdef.get("criteria") if isinstance(qdef, dict) else None
- if isinstance(crit, dict) and isinstance(choice, str):
- definition = crit.get(choice)
- if isinstance(definition, str) and definition.strip():
- label_def = self._clip(definition.strip())
- except Exception:
- label_def = ""
- tail = " — %s" % instructions if instructions else ""
- if label_def:
- tail += " Options: %s" % label_def if not tail else " Options: %s" % label_def
- if not instructions:
- tail = " Options: %s" % label_def
- return head + tail + suffix
- if kind == "score":
- expected = _number(item.get("expected"))
- level = item.get("level")
- label = item.get("label")
- flagged = item.get("flagged") is True
- exp = "%.2f" % expected if expected is not None else "?"
- head = "%s (score): expected %s, level %s (%s)%s" % (
- qid, exp, level, label, ", flagged" if flagged else "")
- if instructions:
- head += " — %s" % instructions
- return head + suffix
- if kind == "noul":
- prob = _number(item.get("p"))
- gate = _number(item.get("gate"))
- flagged = item.get("flagged") is True
- prob_s = "%.2f" % prob if prob is not None else "?"
- gate_s = ", gate=%.2f" % gate if gate is not None else ""
- head = "%s (p=%s%s%s)" % (
- qid, prob_s, ", flagged" if flagged else "", gate_s)
- if instructions:
- head += ": %s" % instructions
- return head + suffix
- return "%s: flagged" % (qid,)
- except Exception:
- try:
- return "%s: flagged" % (qid,)
- except Exception:
- return "signal: flagged"
- def _brief_max_chars(self) -> int:
- try:
- value = int(getattr(self._cfg, "brief_max_chars", 4000))
- except (TypeError, ValueError):
- return 4000
- return value if value > 0 else 4000
- def _brief_gate_threshold(self) -> float:
- try:
- return float(getattr(self._cfg, "brief_gate_threshold", 0.5))
- except (TypeError, ValueError):
- return 0.5
- def _signal_lines(self, signals) -> list[str]:
- """Render each ordered signal as a "- " words line; never raises.
- Filters low-value noise: noul items with p < 0.30 are omitted,
- except the ``substantive_update`` gate arm which is always kept for
- threshold context. Choice/score items are always kept (flagged
- marker explains uncertainty). Original order is preserved.
- """
- lines = []
- try:
- items = signals.get("items", {}) if isinstance(signals, dict) else {}
- order = signals.get("order", []) if isinstance(signals, dict) else []
- escalated = signals.get("escalated") is True if isinstance(signals, dict) else False
- if not isinstance(items, dict):
- return []
- if not isinstance(order, list):
- return []
- questions = self._questions if isinstance(self._questions, dict) else {}
- for qid in order:
- try:
- item = items.get(qid)
- if not isinstance(item, dict):
- continue
- if item.get("kind") == "noul" and qid != "substantive_update":
- prob = _number(item.get("p"))
- if prob is not None and prob < 0.30:
- continue
- qdef = questions.get(qid) if isinstance(questions, dict) else None
- lines.append(
- "- " + self._signal_words(qid, item, qdef=qdef,
- escalated=escalated))
- except Exception:
- continue
- except Exception:
- return []
- return lines
- def build_brief_prompt(self, deltas, signals) -> str:
- """Render new speaker-labeled utterances + focus words; no JSON keys."""
- try:
- body = "\n".join("- " + delta for delta in (deltas or []))
- except Exception:
- body = ""
- parts = ["New utterances:\n" + body]
- focus = self._signal_lines(signals)
- if focus:
- parts.append("Focus:\n" + "\n".join(focus))
- parts.append("Return only the updated brief as plain text.")
- return "\n\n".join(parts)
- def build_ledger_prompt(self, narrative, ledger, deltas, signals) -> str:
- """Ledger-aware single-turn prompt: volatile narrative + code-owned ledger.
- The model is shown the current narrative (the sections it owns) and the
- current ledger (facts it must NOT repeat) and asked for the two halves
- back in fixed machine-parseable sections. ``NEW FACTS:`` is placed first
- so a length cutoff loses narrative (regenerated every pass) rather than a
- durable fact.
- """
- try:
- body = "\n".join("- " + delta for delta in (deltas or []))
- except Exception:
- body = ""
- ledger_text = "\n".join("- " + fact for fact in (ledger or [])) or "(none yet)"
- parts = [
- "CURRENT LEDGER (facts already recorded - never repeat these):\n" + ledger_text,
- "CURRENT BRIEF:\n" + (narrative or "(none yet)"),
- "NEW UTTERANCES:\n" + body,
- ]
- focus = self._signal_lines(signals)
- if focus:
- parts.append("Focus:\n" + "\n".join(focus))
- parts.append(
- "Reply with exactly these two sections and nothing else:\n"
- "NEW FACTS:\n"
- "- only genuinely new durable facts, one per line; write 'none' if there are none\n"
- "BRIEF:\n"
- "Summary:\n...\nOpportunities:\n...\nBlockers/Risks:\n...\n"
- "Decisions:\n...\nOpen Questions:\n..."
- )
- return "\n\n".join(parts)
- def parse_ledger_reply(self, text, truncated: bool = False) -> tuple[str, list[str]] | None:
- """Split a ledger reply into ``(narrative, new_facts)``.
- Both halves are optional and fail open: a missing ``NEW FACTS:`` block
- yields no facts, and a missing ``BRIEF:`` block yields an empty
- narrative (the caller then keeps the prior narrative). Any ``Prior
- Details:`` the model volunteers is dropped from the narrative so the
- code-rendered ledger is never duplicated. When ``truncated`` the reply hit
- the token cap, so the final fact line may be cut mid-sentence and is
- discarded. Returns None only when the reply carries no usable text at all.
- """
- cleaned = self.parse_brief(text)
- if cleaned is None:
- return None
- facts_at = cleaned.upper().find("NEW FACTS:")
- brief_at = cleaned.upper().find("BRIEF:")
- if facts_at == -1 and brief_at == -1:
- return None
- if facts_at != -1 and (brief_at == -1 or facts_at < brief_at):
- facts_end = brief_at if brief_at != -1 else len(cleaned)
- facts_block = cleaned[facts_at + len("NEW FACTS:"): facts_end]
- narrative_block = cleaned[brief_at + len("BRIEF:"):] if brief_at != -1 else ""
- else:
- facts_block = ""
- narrative_block = cleaned[brief_at + len("BRIEF:"):]
- facts = self.parse_facts(facts_block)
- if truncated and facts:
- facts = facts[:-1]
- narrative = self._strip_prior_details(narrative_block).strip()
- return narrative, facts
- def parse_facts(self, block) -> list[str]:
- """Extract durable facts from a ``NEW FACTS:`` block; never treats prose as a fact."""
- if not isinstance(block, str):
- return []
- facts = []
- for line in block.splitlines():
- stripped = line.strip()
- if not stripped or stripped.startswith("#"):
- continue
- marker = next((m for m in ("- ", "* ", "• ") if stripped.startswith(m)), None)
- if marker is not None:
- candidate = stripped[len(marker):]
- elif stripped[:2].rstrip(".").isdigit() or stripped[:3].rstrip(".").isdigit():
- candidate = stripped.split(".", 1)[1] if "." in stripped else stripped
- else:
- continue
- candidate = candidate.strip().strip("`").strip()
- if not candidate or candidate.lower() in ("none", "-"):
- continue
- if ":" in candidate and candidate.lower().startswith("new facts"):
- continue
- facts.append(candidate)
- return facts
- @staticmethod
- def _strip_prior_details(text: str) -> str:
- """Drop a model-supplied ``Prior Details:`` section from the narrative."""
- if not isinstance(text, str):
- return ""
- heads = (
- "Summary:", "Opportunities:", "Blockers/Risks:", "Responsibilities:",
- "Decisions:", "Action Items:", "Deadlines:", "Open Questions:",
- )
- at = text.find("Prior Details:")
- if at == -1:
- return text
- rest = text[at + len("Prior Details:"):]
- cuts = [rest.find(h) for h in heads if rest.find(h) != -1]
- end = min(cuts) if cuts else len(rest)
- return (text[:at] + rest[end:]).strip()
- @staticmethod
- def _normalize_bullet(text: str) -> str:
- """Collapse a bullet to a comparison key; empty when it carries no words."""
- lowered = " ".join(str(text).strip().lower().split())
- for prefix in ("- **new:**", "- new:", "**new:**", "new:"):
- if lowered.startswith(prefix):
- lowered = lowered[len(prefix):].strip()
- break
- return lowered.strip("-* ").strip()
- def extract_bullets(self, brief) -> list[str]:
- """Return the normalized bullet keys of every '- ' line in ``brief``."""
- if not isinstance(brief, str):
- return []
- keys = []
- for line in brief.splitlines():
- stripped = line.strip()
- if not stripped.startswith("- "):
- continue
- key = self._normalize_bullet(stripped)
- if key:
- keys.append(key)
- return keys
- def restore_missing_bullets(self, new_brief, prior_brief) -> str:
- """Re-append any prior bullet that the model dropped from ``new_brief``.
- Deterministic retention guard kept as a belt-and-braces layer beneath the
- code-owned ledger: a previously-recorded bullet must never vanish because
- the model re-emitted the document without it. Bullets the new brief merely
- reworded still match by normalized key, so only a genuinely absent bullet
- is restored. Restoration appends under the brief's final ``Prior
- Details:`` section, or at the end when absent.
- """
- if not isinstance(new_brief, str) or not new_brief.strip():
- return new_brief
- if not isinstance(prior_brief, str) or not prior_brief.strip():
- return new_brief
- present = set(self.extract_bullets(new_brief))
- missing = []
- for line in prior_brief.splitlines():
- stripped = line.strip()
- if not stripped.startswith("- "):
- continue
- key = self._normalize_bullet(stripped)
- if not key or key in present:
- continue
- present.add(key)
- missing.append(stripped[2:].strip())
- if not missing:
- return new_brief
- restored = "\n".join("- " + bullet for bullet in missing)
- marker = "Prior Details:"
- if marker in new_brief:
- head, _, tail = new_brief.rpartition(marker)
- return f"{head}{marker}{tail.rstrip()}\n{restored}"
- return f"{new_brief.rstrip()}\n\nPrior Details:\n{restored}"
- @staticmethod
- def _first_arg_start(text: str) -> int:
- name_at = text.find("updated_brief")
- if name_at != -1:
- cursor = name_at + len("updated_brief")
- while cursor < len(text) and text[cursor] == " ":
- cursor += 1
- if cursor < len(text) and text[cursor] == "=":
- return cursor + 1
- call_at = text.find("update_brief(")
- if call_at == -1:
- call_at = text.find("update_brief (")
- return call_at + len("update_brief(") if call_at != -1 else 0
- @staticmethod
- def _first_quote(text: str, start: int) -> tuple[int, str]:
- opening = -1
- quote = "'"
- for candidate in ("'", '"'):
- index = text.find(candidate, start)
- if index != -1 and (opening == -1 or index < opening):
- opening, quote = index, candidate
- return opening, quote
- @staticmethod
- def _arg_boundary_quote(text: str, opening: int, quote: str) -> int:
- index = opening + 1
- while True:
- candidate = text.find(quote, index)
- if candidate == -1:
- return -1
- after = candidate + 1
- while after < len(text) and text[after] == " ":
- after += 1
- if after >= len(text) or text[after] in ",)]<":
- return candidate
- index = candidate + 1
- def _unwrap_tool_call(self, text: str) -> str | None:
- """Unwrap a stray tool-call envelope into its first plain argument value.
- Only fires when an explicit wrapper marker is present so clean markdown
- containing ``[``, ``(``, or ``'`` is passed through untouched. The first
- argument is taken up to its closing quote boundary, so a multi-argument
- call never swallows the separators; an envelope that cannot be reduced to
- a clean payload is rejected (None) rather than stored verbatim.
- """
- markers = ("<|tool_call_start|>", "<|tool_call_end|>", "update_brief(", "update_brief (")
- if not any(marker in text for marker in markers):
- return text
- if "<|tool_call_start|>" in text:
- text = text.split("<|tool_call_start|>", 1)[1]
- if "<|tool_call_end|>" in text:
- text = text.split("<|tool_call_end|>", 1)[0]
- text = text.strip()
- if "update_brief(" not in text and "update_brief (" not in text:
- if any(token in text for token in ("<|", "previous_brief=", "mode=")):
- return None
- return text
- opening, quote = self._first_quote(text, self._first_arg_start(text))
- if opening == -1:
- return None
- closing = self._arg_boundary_quote(text, opening, quote)
- if closing == -1:
- return None
- value = text[opening + 1 : closing].strip()
- if any(token in value for token in ("<|", "previous_brief=", "mode=")):
- return None
- return value
- def parse_brief(self, text) -> str | None:
- """Strip a code fence and any stray tool-call envelope; None when empty."""
- if not isinstance(text, str):
- return None
- cleaned = text.strip()
- if not cleaned:
- return None
- if cleaned.startswith("```"):
- _, _, rest = cleaned.partition("\n")
- cleaned = rest if rest else ""
- end = cleaned.rfind("```")
- if end != -1:
- cleaned = cleaned[:end]
- cleaned = cleaned.strip()
- if not cleaned:
- return None
- cleaned = self._unwrap_tool_call(cleaned)
- if not cleaned:
- return None
- # Some models emit escaped newlines (literal backslash-n) instead of
- # real line breaks; unescape only when no real newline is present so
- # genuine backslashes in clean markdown are never mangled.
- if "\n" not in cleaned and ("\\n" in cleaned or "\\t" in cleaned):
- cleaned = (
- cleaned.replace("\\r\\n", "\n")
- .replace("\\n", "\n")
- .replace("\\t", "\t")
- .strip()
- )
- if not cleaned:
- return None
- limit = self._brief_max_chars()
- if len(cleaned) > limit:
- logger.warning("truncating brief from %d to %d chars", len(cleaned), limit)
- cleaned = cleaned[:limit]
- return cleaned
- def should_update_brief(self, signals) -> bool:
- """Fail-open gate on the substantive_update noul arm."""
- if not isinstance(signals, dict):
- return True
- items = signals.get("items", {})
- if not isinstance(items, dict):
- return True
- item = items.get("substantive_update")
- if not isinstance(item, dict):
- return True
- prob = _number(item.get("p"))
- if prob is None:
- return True
- return prob >= self._brief_gate_threshold()
- def finalize_brief(self, text, finish_reason) -> str | None:
- """Accept any parseable brief; a length cutoff truncates, never rejects."""
- if finish_reason == "length":
- logger.warning("brief reply hit the length limit; keeping the truncated text")
- return self.parse_brief(text)
- def state(self) -> tuple[str, str]:
- """Return the cached (state, reason) pair; performs no I/O."""
- return (self._state, self._reason)
- def refresh(self, state: str, reason: str = "") -> None:
- """Explicit transition; keeps no_questions; ignores unknown states."""
- if self._questions is None:
- return
- if state not in TRIAGE_REASONS:
- logger.warning("ignoring unknown triage state %r", state)
- return
- self._state = state
- self._reason = reason if isinstance(reason, str) else str(reason)
- def _accents_folded(text: str) -> str:
- """Casefold, strip combining accents, and collapse to plain words."""
- decomposed = unicodedata.normalize("NFKD", str(text))
- without_marks = "".join(ch for ch in decomposed if not unicodedata.combining(ch))
- return " ".join(without_marks.casefold().split())
- def _dedupe_key(text: str) -> str:
- """Accent- and punctuation-insensitive key for near-verbatim comparisons."""
- return " ".join(t for t in re.split(r"[^0-9a-z$%]+", _accents_folded(text)) if t)
- def _number_tokens(text: str) -> frozenset:
- """Digit-bearing tokens (currency/percent stripped) for strict dedupe checks."""
- tokens = set()
- for raw in _accents_folded(text).split():
- digits = "".join(ch for ch in raw if ch.isdigit())
- if digits:
- tokens.add(digits)
- return frozenset(tokens)
- def _entity_tokens(text: str) -> frozenset:
- """Capitalized (proper-noun) tokens, accent-folded, for strict dedupe checks."""
- tokens = set()
- for raw in str(text).split():
- stripped = raw.strip(".,;:!?()[]\"'")
- if stripped[:1].isupper() and stripped[1:].islower():
- tokens.add(_accents_folded(stripped))
- return frozenset(tokens)
- class FactLedger:
- """Code-owned, append-only ledger of durable facts.
- The model proposes new facts per pass; this class owns dedupe, the size cap,
- eviction, and rendering, so a recorded fact can only disappear via a logged,
- deterministic eviction (never because the model dropped it). Pure and
- stdlib-only, matching the analyzer.
- Dedupe biasses toward *keeping* a fact: an exact normalized match is dropped,
- and a fuzzy near-match is dropped only when its number and entity tokens are
- identical. A redundant bullet is cosmetic; a false merge would silently lose a
- fact and break the retention guarantee.
- """
- def __init__(self, max_facts: int = 200, fact_max_chars: int = 240) -> None:
- self.max_facts = max(1, int(max_facts))
- self.fact_max_chars = max(1, int(fact_max_chars))
- self._facts: list[str] = []
- def __len__(self) -> int:
- return len(self._facts)
- def facts(self) -> list[str]:
- """Return a copy of the recorded facts, in insertion order."""
- return list(self._facts)
- def clear(self) -> None:
- """Drop every recorded fact."""
- self._facts = []
- def render(self) -> str:
- """Render the ledger as a ``Prior Details:`` section ("" when empty)."""
- if not self._facts:
- return ""
- return "Prior Details:\n" + "\n".join("- " + fact for fact in self._facts)
- def merge(self, new_facts) -> int:
- """Append genuinely-new facts, dedupe, then evict oldest over the cap.
- Returns the number of facts added. Eviction is oldest-first and logged;
- a fact added in this call is never evicted by this call.
- """
- added = 0
- for raw in new_facts or []:
- fact = self._clean(raw)
- if fact is None:
- continue
- if self._is_duplicate(fact):
- continue
- self._facts.append(fact)
- added += 1
- if added:
- self._evict()
- return added
- def _clean(self, raw):
- if not isinstance(raw, str):
- return None
- fact = " ".join(raw.strip().split())
- if not fact:
- return None
- if len(fact) > self.fact_max_chars:
- fact = fact[: self.fact_max_chars].rstrip()
- return fact or None
- @staticmethod
- def _tokens(text: str) -> list[str]:
- normalized = _accents_folded(text)
- return [t for t in re.split(r"[^0-9a-z$%]+", normalized) if t]
- def _is_duplicate(self, fact: str) -> bool:
- key = _dedupe_key(fact)
- cand_tokens = self._tokens(fact)
- cand_numbers = _number_tokens(fact)
- cand_entities = _entity_tokens(fact)
- for existing in self._facts:
- if _dedupe_key(existing) == key:
- return True
- if cand_numbers != _number_tokens(existing):
- continue
- if cand_entities != _entity_tokens(existing):
- continue
- existing_tokens = self._tokens(existing)
- if not cand_tokens or not existing_tokens:
- continue
- overlap = len(set(cand_tokens) & set(existing_tokens)) / len(set(cand_tokens))
- if overlap >= 0.85:
- return True
- return False
- def _evict(self) -> None:
- overflow = len(self._facts) - self.max_facts
- if overflow <= 0:
- return
- evicted = self._facts[:overflow]
- self._facts = self._facts[overflow:]
- logger.info(
- "fact ledger at cap %d; evicted %d oldest fact(s): %s",
- self.max_facts,
- len(evicted),
- "; ".join(evicted),
- )
- @classmethod
- def from_brief(cls, brief, max_facts: int = 200, fact_max_chars: int = 240):
- """Rebuild a ledger by harvesting ``- `` bullets from an existing brief."""
- ledger = cls(max_facts=max_facts, fact_max_chars=fact_max_chars)
- if isinstance(brief, str):
- for line in brief.splitlines():
- stripped = line.strip()
- if stripped.startswith("- "):
- ledger.merge([stripped[2:]])
- return ledger
|