triage.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922
  1. """Triage analyzer: pure, import-light classification helpers.
  2. Stdlib only. No ML imports here (not even lazily): the dedicated lane module
  3. owns the model import, background thread, and inference. This module only
  4. validates the questions file, condenses transcript windows, normalizes raw
  5. answers into display items, decides escalation, and builds/parses/finalizes
  6. the plain-text brief. The only I/O is the one-time questions file read in the
  7. constructor.
  8. Public API (consumed by the assistant service layer):
  9. TRIAGE_REASONS = ("ok", "no_questions", "loading", "laya_missing",
  10. "disabled", "error")
  11. def load_questions(path: str) -> dict | None
  12. class TriageAnalyzer:
  13. def __init__(self, cfg) -> None
  14. @property
  15. def questions(self) -> dict | None
  16. def build_state(self, window) -> str
  17. def normalize(self, payload) -> dict
  18. def should_escalate(self, signals) -> bool
  19. def build_brief_prompt(self, deltas, signals) -> str
  20. def build_ledger_prompt(self, narrative, ledger, deltas, signals) -> str
  21. def parse_brief(self, text) -> str | None
  22. def parse_ledger_reply(self, text, truncated=False) -> tuple[str, list[str]] | None
  23. def parse_facts(self, block) -> list[str]
  24. def should_update_brief(self, signals) -> bool
  25. def finalize_brief(self, text, finish_reason) -> str | None
  26. def state(self) -> tuple[str, str]
  27. def refresh(self, state: str, reason: str = "") -> None
  28. class FactLedger:
  29. def __init__(self, max_facts=200, fact_max_chars=240) -> None
  30. def render(self) -> str
  31. def merge(self, new_facts) -> int
  32. def facts(self) -> list[str]
  33. def clear(self) -> None
  34. @classmethod
  35. def from_brief(cls, brief, ...) -> "FactLedger"
  36. ``cfg`` is duck-typed (attribute access only, never imported): it provides
  37. ``questions_file``, ``state_char_budget`` (default 3000),
  38. ``confidence_field`` (default ``"answer_confidence"``),
  39. ``escalate_threshold`` (default 0.6), ``choice_min_confidence``
  40. (default 0.85), ``brief_max_chars`` (default 4000) and
  41. ``brief_gate_threshold`` (default 0.5).
  42. """
  43. import json
  44. import logging
  45. import re
  46. import unicodedata
  47. logger = logging.getLogger(__name__)
  48. TRIAGE_REASONS = ("ok", "no_questions", "loading", "laya_missing", "disabled", "error")
  49. _VALID_TYPES = ("choice", "score", "noul")
  50. def _invalid(path, qid, why):
  51. logger.warning("ignoring questions file %r: question %r: %s", path, qid, why)
  52. return None
  53. def load_questions(path: str) -> dict | None:
  54. """Load and validate a bare Laya questions dict; None (never raise) on failure."""
  55. try:
  56. with open(path, encoding="utf-8") as handle:
  57. data = json.load(handle)
  58. except (OSError, ValueError, UnicodeError) as exc:
  59. logger.warning("ignoring questions file %r: cannot read/parse (%s)", path, exc)
  60. return None
  61. if not isinstance(data, dict) or not data:
  62. logger.warning("ignoring questions file %r: top level must be a non-empty object", path)
  63. return None
  64. for qid, qdef in data.items():
  65. if not isinstance(qdef, dict):
  66. return _invalid(path, qid, "definition must be an object")
  67. qtype = qdef.get("type")
  68. if qtype not in _VALID_TYPES:
  69. return _invalid(
  70. path, qid, "unknown type %r; use one of %s" % (qtype, sorted(_VALID_TYPES))
  71. )
  72. if "instructions" not in qdef:
  73. return _invalid(path, qid, "no 'instructions'; add the text the model should answer")
  74. crit = qdef.get("criteria")
  75. if qtype == "choice":
  76. if not isinstance(crit, (dict, list)):
  77. return _invalid(path, qid, "a choice question takes 'criteria' as a dict or list")
  78. if not crit:
  79. return _invalid(path, qid, "a choice question needs at least one criterion")
  80. elif qtype == "score":
  81. if not isinstance(crit, list):
  82. return _invalid(path, qid, "a score question takes 'criteria' as a list")
  83. if not crit:
  84. return _invalid(path, qid, "a score question needs at least one level")
  85. if None in crit:
  86. return _invalid(path, qid, "score criteria must not contain null")
  87. elif crit is not None:
  88. if not isinstance(crit, dict):
  89. return _invalid(path, qid, "a noul question takes 'criteria' as a dict or omits it")
  90. keys = {str(key).lower() for key in crit}
  91. if not keys <= {"true", "false"}:
  92. return _invalid(
  93. path, qid, "a noul question takes 'criteria' keyed only 'true'/'false'"
  94. )
  95. return data
  96. def _number(value):
  97. if isinstance(value, bool):
  98. return None
  99. try:
  100. result = float(value)
  101. except (TypeError, ValueError):
  102. return None
  103. return result
  104. class TriageAnalyzer:
  105. """Pure analyzer over a duck-typed triage config (see module docstring)."""
  106. def __init__(self, cfg) -> None:
  107. self._cfg = cfg
  108. path = getattr(cfg, "questions_file", "questions.json")
  109. try:
  110. questions = load_questions(path)
  111. except Exception as exc: # load_questions never raises; belt and braces
  112. logger.warning("ignoring questions file %r: unexpected error (%s)", path, exc)
  113. questions = None
  114. self._questions = questions
  115. if questions is None:
  116. self._state = "no_questions"
  117. self._reason = "no questions loaded from %s" % (path,)
  118. else:
  119. self._state = "loading"
  120. self._reason = ""
  121. @property
  122. def questions(self) -> dict | None:
  123. return self._questions
  124. def _budget(self) -> int:
  125. try:
  126. return int(getattr(self._cfg, "state_char_budget", 3000))
  127. except (TypeError, ValueError):
  128. return 3000
  129. def _confidence_field(self) -> str:
  130. return getattr(self._cfg, "confidence_field", "answer_confidence")
  131. def _escalate_threshold(self) -> float:
  132. try:
  133. return float(getattr(self._cfg, "escalate_threshold", 0.6))
  134. except (TypeError, ValueError):
  135. return 0.6
  136. def _choice_min_confidence(self) -> float:
  137. try:
  138. return float(getattr(self._cfg, "choice_min_confidence", 0.85))
  139. except (TypeError, ValueError):
  140. return 0.85
  141. def build_state(self, window) -> str:
  142. """Condense oldest->newest utterances newest-first, restore order, "- " lines."""
  143. utterances = [str(item) for item in (window or [])]
  144. if not utterances:
  145. return ""
  146. budget = self._budget()
  147. rendered = ["- " + text for text in utterances]
  148. kept = []
  149. total = 0
  150. for line in reversed(rendered):
  151. extra = len(line) + (1 if kept else 0)
  152. if kept and total + extra > budget:
  153. break
  154. kept.append(line)
  155. total += extra
  156. kept.reverse()
  157. return "\n".join(kept)
  158. def _normalize_item(self, qid, qdef, answer):
  159. if not isinstance(answer, dict):
  160. return None
  161. qtype = qdef.get("type")
  162. field = self._confidence_field()
  163. if qtype == "choice":
  164. label = answer.get("choice")
  165. if not isinstance(label, str):
  166. return None
  167. crit = qdef.get("criteria")
  168. if isinstance(crit, dict):
  169. options = list(crit.keys())
  170. elif isinstance(crit, list):
  171. options = list(crit)
  172. else:
  173. return None
  174. if options and label not in options:
  175. return None
  176. confidence = _number(answer.get("confidence"))
  177. answer_confidence = _number(answer.get("answer_confidence"))
  178. if confidence is None or answer_confidence is None:
  179. return None
  180. gate = confidence if field == "confidence" else answer_confidence
  181. return {
  182. "kind": "choice",
  183. "choice": label,
  184. "confidence": confidence,
  185. "answer_confidence": answer_confidence,
  186. "gate": gate,
  187. "flagged": gate < self._choice_min_confidence(),
  188. }
  189. if qtype == "score":
  190. expected = _number(answer.get("score"))
  191. crit = qdef.get("criteria")
  192. if expected is None or not isinstance(crit, list) or not crit:
  193. return None
  194. level = max(0, min(len(crit) - 1, round(expected)))
  195. label = None
  196. legend = answer.get("legend")
  197. if isinstance(legend, dict) and str(level) in legend:
  198. label = legend[str(level)]
  199. else:
  200. label = crit[level]
  201. confidence = _number(answer.get("confidence"))
  202. answer_confidence = _number(answer.get("answer_confidence"))
  203. if confidence is None or answer_confidence is None:
  204. return None
  205. gate = confidence if field == "confidence" else answer_confidence
  206. return {
  207. "kind": "score",
  208. "expected": expected,
  209. "level": level,
  210. "label": str(label),
  211. "confidence": confidence,
  212. "answer_confidence": answer_confidence,
  213. "gate": gate,
  214. "flagged": expected >= (len(crit) - 1) * 0.5,
  215. }
  216. if qtype == "noul":
  217. prob = _number(answer.get("noul"))
  218. if prob is None:
  219. return None
  220. confidence = _number(answer.get("confidence"))
  221. answer_confidence = _number(answer.get("answer_confidence"))
  222. if confidence is None or answer_confidence is None:
  223. return None
  224. gate = confidence if field == "confidence" else answer_confidence
  225. return {
  226. "kind": "noul",
  227. "p": prob,
  228. "confidence": confidence,
  229. "answer_confidence": answer_confidence,
  230. "gate": gate,
  231. "flagged": prob >= self._escalate_threshold(),
  232. }
  233. return None
  234. def normalize(self, payload) -> dict:
  235. """Normalize a full laya result or a bare answers dict into signals."""
  236. result = {"order": [], "items": {}, "escalated": False, "input_tokens": 0}
  237. if not isinstance(payload, dict):
  238. return result
  239. raw_answers = payload.get("answers")
  240. if isinstance(raw_answers, dict):
  241. answers = raw_answers
  242. usage = payload.get("usage")
  243. if isinstance(usage, dict):
  244. tokens = _number(usage.get("input_tokens", 0))
  245. result["input_tokens"] = int(tokens) if tokens is not None else 0
  246. else:
  247. answers = payload
  248. questions = self._questions or {}
  249. for qid in questions:
  250. if qid not in answers:
  251. continue
  252. try:
  253. item = self._normalize_item(qid, questions[qid], answers[qid])
  254. except Exception:
  255. continue
  256. if item is None:
  257. continue
  258. result["order"].append(qid)
  259. result["items"][qid] = item
  260. result["escalated"] = self.should_escalate(result)
  261. return result
  262. def _score_levels(self, qid):
  263. questions = self._questions or {}
  264. qdef = questions.get(qid)
  265. if not isinstance(qdef, dict):
  266. return None
  267. crit = qdef.get("criteria")
  268. if not isinstance(crit, list) or not crit:
  269. return None
  270. return len(crit)
  271. def should_escalate(self, signals) -> bool:
  272. """True iff any arm fires: noul p, low choice gate, or high score expected."""
  273. items = signals.get("items", {}) if isinstance(signals, dict) else {}
  274. if not isinstance(items, dict):
  275. return False
  276. for qid, item in items.items():
  277. if not isinstance(item, dict):
  278. continue
  279. kind = item.get("kind")
  280. if kind == "noul":
  281. prob = _number(item.get("p"))
  282. if prob is not None and prob >= self._escalate_threshold():
  283. return True
  284. elif kind == "choice":
  285. gate = _number(item.get("gate"))
  286. if gate is not None and gate < self._choice_min_confidence():
  287. return True
  288. elif kind == "score":
  289. expected = _number(item.get("expected"))
  290. if expected is None:
  291. continue
  292. levels = self._score_levels(qid)
  293. if levels is None:
  294. if item.get("flagged") is True:
  295. return True
  296. elif expected >= (levels - 1) * 0.5:
  297. return True
  298. return False
  299. @staticmethod
  300. def _clip(text, limit=160) -> str:
  301. """Collapse whitespace and truncate to ~limit chars with an ellipsis."""
  302. try:
  303. collapsed = " ".join(str(text).split())
  304. except Exception:
  305. return ""
  306. if len(collapsed) <= limit:
  307. return collapsed
  308. return collapsed[: max(0, limit - 1)].rstrip() + "…"
  309. def _signal_words(self, qid, item, qdef=None, escalated=False) -> str:
  310. try:
  311. kind = item.get("kind") if isinstance(item, dict) else None
  312. except Exception:
  313. return "%s: flagged" % (qid,)
  314. try:
  315. questions = self._questions or {}
  316. if qdef is None and isinstance(questions, dict):
  317. candidate = questions.get(qid)
  318. if isinstance(candidate, dict):
  319. qdef = candidate
  320. instructions = ""
  321. if isinstance(qdef, dict):
  322. raw = qdef.get("instructions")
  323. if isinstance(raw, str) and raw.strip():
  324. instructions = self._clip(raw.strip())
  325. suffix = " [escalated]" if (escalated and isinstance(item, dict)
  326. and item.get("flagged") is True) else ""
  327. if kind == "choice":
  328. choice = item.get("choice")
  329. gate = _number(item.get("gate"))
  330. flagged = item.get("flagged") is True
  331. conf = "%.2f" % gate if gate is not None else "?"
  332. head = "%s (choice): selected %s (conf %s%s)" % (
  333. qid, choice, conf, ", flagged as uncertain" if flagged else "")
  334. label_def = ""
  335. try:
  336. crit = qdef.get("criteria") if isinstance(qdef, dict) else None
  337. if isinstance(crit, dict) and isinstance(choice, str):
  338. definition = crit.get(choice)
  339. if isinstance(definition, str) and definition.strip():
  340. label_def = self._clip(definition.strip())
  341. except Exception:
  342. label_def = ""
  343. tail = " — %s" % instructions if instructions else ""
  344. if label_def:
  345. tail += " Options: %s" % label_def if not tail else " Options: %s" % label_def
  346. if not instructions:
  347. tail = " Options: %s" % label_def
  348. return head + tail + suffix
  349. if kind == "score":
  350. expected = _number(item.get("expected"))
  351. level = item.get("level")
  352. label = item.get("label")
  353. flagged = item.get("flagged") is True
  354. exp = "%.2f" % expected if expected is not None else "?"
  355. head = "%s (score): expected %s, level %s (%s)%s" % (
  356. qid, exp, level, label, ", flagged" if flagged else "")
  357. if instructions:
  358. head += " — %s" % instructions
  359. return head + suffix
  360. if kind == "noul":
  361. prob = _number(item.get("p"))
  362. gate = _number(item.get("gate"))
  363. flagged = item.get("flagged") is True
  364. prob_s = "%.2f" % prob if prob is not None else "?"
  365. gate_s = ", gate=%.2f" % gate if gate is not None else ""
  366. head = "%s (p=%s%s%s)" % (
  367. qid, prob_s, ", flagged" if flagged else "", gate_s)
  368. if instructions:
  369. head += ": %s" % instructions
  370. return head + suffix
  371. return "%s: flagged" % (qid,)
  372. except Exception:
  373. try:
  374. return "%s: flagged" % (qid,)
  375. except Exception:
  376. return "signal: flagged"
  377. def _brief_max_chars(self) -> int:
  378. try:
  379. value = int(getattr(self._cfg, "brief_max_chars", 4000))
  380. except (TypeError, ValueError):
  381. return 4000
  382. return value if value > 0 else 4000
  383. def _brief_gate_threshold(self) -> float:
  384. try:
  385. return float(getattr(self._cfg, "brief_gate_threshold", 0.5))
  386. except (TypeError, ValueError):
  387. return 0.5
  388. def _signal_lines(self, signals) -> list[str]:
  389. """Render each ordered signal as a "- " words line; never raises.
  390. Filters low-value noise: noul items with p < 0.30 are omitted,
  391. except the ``substantive_update`` gate arm which is always kept for
  392. threshold context. Choice/score items are always kept (flagged
  393. marker explains uncertainty). Original order is preserved.
  394. """
  395. lines = []
  396. try:
  397. items = signals.get("items", {}) if isinstance(signals, dict) else {}
  398. order = signals.get("order", []) if isinstance(signals, dict) else []
  399. escalated = signals.get("escalated") is True if isinstance(signals, dict) else False
  400. if not isinstance(items, dict):
  401. return []
  402. if not isinstance(order, list):
  403. return []
  404. questions = self._questions if isinstance(self._questions, dict) else {}
  405. for qid in order:
  406. try:
  407. item = items.get(qid)
  408. if not isinstance(item, dict):
  409. continue
  410. if item.get("kind") == "noul" and qid != "substantive_update":
  411. prob = _number(item.get("p"))
  412. if prob is not None and prob < 0.30:
  413. continue
  414. qdef = questions.get(qid) if isinstance(questions, dict) else None
  415. lines.append(
  416. "- " + self._signal_words(qid, item, qdef=qdef,
  417. escalated=escalated))
  418. except Exception:
  419. continue
  420. except Exception:
  421. return []
  422. return lines
  423. def build_brief_prompt(self, deltas, signals) -> str:
  424. """Render new speaker-labeled utterances + focus words; no JSON keys."""
  425. try:
  426. body = "\n".join("- " + delta for delta in (deltas or []))
  427. except Exception:
  428. body = ""
  429. parts = ["New utterances:\n" + body]
  430. focus = self._signal_lines(signals)
  431. if focus:
  432. parts.append("Focus:\n" + "\n".join(focus))
  433. parts.append("Return only the updated brief as plain text.")
  434. return "\n\n".join(parts)
  435. def build_ledger_prompt(self, narrative, ledger, deltas, signals) -> str:
  436. """Ledger-aware single-turn prompt: volatile narrative + code-owned ledger.
  437. The model is shown the current narrative (the sections it owns) and the
  438. current ledger (facts it must NOT repeat) and asked for the two halves
  439. back in fixed machine-parseable sections. ``NEW FACTS:`` is placed first
  440. so a length cutoff loses narrative (regenerated every pass) rather than a
  441. durable fact.
  442. """
  443. try:
  444. body = "\n".join("- " + delta for delta in (deltas or []))
  445. except Exception:
  446. body = ""
  447. ledger_text = "\n".join("- " + fact for fact in (ledger or [])) or "(none yet)"
  448. parts = [
  449. "CURRENT LEDGER (facts already recorded - never repeat these):\n" + ledger_text,
  450. "CURRENT BRIEF:\n" + (narrative or "(none yet)"),
  451. "NEW UTTERANCES:\n" + body,
  452. ]
  453. focus = self._signal_lines(signals)
  454. if focus:
  455. parts.append("Focus:\n" + "\n".join(focus))
  456. parts.append(
  457. "Reply with exactly these two sections and nothing else:\n"
  458. "NEW FACTS:\n"
  459. "- only genuinely new durable facts, one per line; write 'none' if there are none\n"
  460. "BRIEF:\n"
  461. "Summary:\n...\nOpportunities:\n...\nBlockers/Risks:\n...\n"
  462. "Decisions:\n...\nOpen Questions:\n..."
  463. )
  464. return "\n\n".join(parts)
  465. def parse_ledger_reply(self, text, truncated: bool = False) -> tuple[str, list[str]] | None:
  466. """Split a ledger reply into ``(narrative, new_facts)``.
  467. Both halves are optional and fail open: a missing ``NEW FACTS:`` block
  468. yields no facts, and a missing ``BRIEF:`` block yields an empty
  469. narrative (the caller then keeps the prior narrative). Any ``Prior
  470. Details:`` the model volunteers is dropped from the narrative so the
  471. code-rendered ledger is never duplicated. When ``truncated`` the reply hit
  472. the token cap, so the final fact line may be cut mid-sentence and is
  473. discarded. Returns None only when the reply carries no usable text at all.
  474. """
  475. cleaned = self.parse_brief(text)
  476. if cleaned is None:
  477. return None
  478. facts_at = cleaned.upper().find("NEW FACTS:")
  479. brief_at = cleaned.upper().find("BRIEF:")
  480. if facts_at == -1 and brief_at == -1:
  481. return None
  482. if facts_at != -1 and (brief_at == -1 or facts_at < brief_at):
  483. facts_end = brief_at if brief_at != -1 else len(cleaned)
  484. facts_block = cleaned[facts_at + len("NEW FACTS:"): facts_end]
  485. narrative_block = cleaned[brief_at + len("BRIEF:"):] if brief_at != -1 else ""
  486. else:
  487. facts_block = ""
  488. narrative_block = cleaned[brief_at + len("BRIEF:"):]
  489. facts = self.parse_facts(facts_block)
  490. if truncated and facts:
  491. facts = facts[:-1]
  492. narrative = self._strip_prior_details(narrative_block).strip()
  493. return narrative, facts
  494. def parse_facts(self, block) -> list[str]:
  495. """Extract durable facts from a ``NEW FACTS:`` block; never treats prose as a fact."""
  496. if not isinstance(block, str):
  497. return []
  498. facts = []
  499. for line in block.splitlines():
  500. stripped = line.strip()
  501. if not stripped or stripped.startswith("#"):
  502. continue
  503. marker = next((m for m in ("- ", "* ", "• ") if stripped.startswith(m)), None)
  504. if marker is not None:
  505. candidate = stripped[len(marker):]
  506. elif stripped[:2].rstrip(".").isdigit() or stripped[:3].rstrip(".").isdigit():
  507. candidate = stripped.split(".", 1)[1] if "." in stripped else stripped
  508. else:
  509. continue
  510. candidate = candidate.strip().strip("`").strip()
  511. if not candidate or candidate.lower() in ("none", "-"):
  512. continue
  513. if ":" in candidate and candidate.lower().startswith("new facts"):
  514. continue
  515. facts.append(candidate)
  516. return facts
  517. @staticmethod
  518. def _strip_prior_details(text: str) -> str:
  519. """Drop a model-supplied ``Prior Details:`` section from the narrative."""
  520. if not isinstance(text, str):
  521. return ""
  522. heads = (
  523. "Summary:", "Opportunities:", "Blockers/Risks:", "Responsibilities:",
  524. "Decisions:", "Action Items:", "Deadlines:", "Open Questions:",
  525. )
  526. at = text.find("Prior Details:")
  527. if at == -1:
  528. return text
  529. rest = text[at + len("Prior Details:"):]
  530. cuts = [rest.find(h) for h in heads if rest.find(h) != -1]
  531. end = min(cuts) if cuts else len(rest)
  532. return (text[:at] + rest[end:]).strip()
  533. @staticmethod
  534. def _normalize_bullet(text: str) -> str:
  535. """Collapse a bullet to a comparison key; empty when it carries no words."""
  536. lowered = " ".join(str(text).strip().lower().split())
  537. for prefix in ("- **new:**", "- new:", "**new:**", "new:"):
  538. if lowered.startswith(prefix):
  539. lowered = lowered[len(prefix):].strip()
  540. break
  541. return lowered.strip("-* ").strip()
  542. def extract_bullets(self, brief) -> list[str]:
  543. """Return the normalized bullet keys of every '- ' line in ``brief``."""
  544. if not isinstance(brief, str):
  545. return []
  546. keys = []
  547. for line in brief.splitlines():
  548. stripped = line.strip()
  549. if not stripped.startswith("- "):
  550. continue
  551. key = self._normalize_bullet(stripped)
  552. if key:
  553. keys.append(key)
  554. return keys
  555. def restore_missing_bullets(self, new_brief, prior_brief) -> str:
  556. """Re-append any prior bullet that the model dropped from ``new_brief``.
  557. Deterministic retention guard kept as a belt-and-braces layer beneath the
  558. code-owned ledger: a previously-recorded bullet must never vanish because
  559. the model re-emitted the document without it. Bullets the new brief merely
  560. reworded still match by normalized key, so only a genuinely absent bullet
  561. is restored. Restoration appends under the brief's final ``Prior
  562. Details:`` section, or at the end when absent.
  563. """
  564. if not isinstance(new_brief, str) or not new_brief.strip():
  565. return new_brief
  566. if not isinstance(prior_brief, str) or not prior_brief.strip():
  567. return new_brief
  568. present = set(self.extract_bullets(new_brief))
  569. missing = []
  570. for line in prior_brief.splitlines():
  571. stripped = line.strip()
  572. if not stripped.startswith("- "):
  573. continue
  574. key = self._normalize_bullet(stripped)
  575. if not key or key in present:
  576. continue
  577. present.add(key)
  578. missing.append(stripped[2:].strip())
  579. if not missing:
  580. return new_brief
  581. restored = "\n".join("- " + bullet for bullet in missing)
  582. marker = "Prior Details:"
  583. if marker in new_brief:
  584. head, _, tail = new_brief.rpartition(marker)
  585. return f"{head}{marker}{tail.rstrip()}\n{restored}"
  586. return f"{new_brief.rstrip()}\n\nPrior Details:\n{restored}"
  587. @staticmethod
  588. def _first_arg_start(text: str) -> int:
  589. name_at = text.find("updated_brief")
  590. if name_at != -1:
  591. cursor = name_at + len("updated_brief")
  592. while cursor < len(text) and text[cursor] == " ":
  593. cursor += 1
  594. if cursor < len(text) and text[cursor] == "=":
  595. return cursor + 1
  596. call_at = text.find("update_brief(")
  597. if call_at == -1:
  598. call_at = text.find("update_brief (")
  599. return call_at + len("update_brief(") if call_at != -1 else 0
  600. @staticmethod
  601. def _first_quote(text: str, start: int) -> tuple[int, str]:
  602. opening = -1
  603. quote = "'"
  604. for candidate in ("'", '"'):
  605. index = text.find(candidate, start)
  606. if index != -1 and (opening == -1 or index < opening):
  607. opening, quote = index, candidate
  608. return opening, quote
  609. @staticmethod
  610. def _arg_boundary_quote(text: str, opening: int, quote: str) -> int:
  611. index = opening + 1
  612. while True:
  613. candidate = text.find(quote, index)
  614. if candidate == -1:
  615. return -1
  616. after = candidate + 1
  617. while after < len(text) and text[after] == " ":
  618. after += 1
  619. if after >= len(text) or text[after] in ",)]<":
  620. return candidate
  621. index = candidate + 1
  622. def _unwrap_tool_call(self, text: str) -> str | None:
  623. """Unwrap a stray tool-call envelope into its first plain argument value.
  624. Only fires when an explicit wrapper marker is present so clean markdown
  625. containing ``[``, ``(``, or ``'`` is passed through untouched. The first
  626. argument is taken up to its closing quote boundary, so a multi-argument
  627. call never swallows the separators; an envelope that cannot be reduced to
  628. a clean payload is rejected (None) rather than stored verbatim.
  629. """
  630. markers = ("<|tool_call_start|>", "<|tool_call_end|>", "update_brief(", "update_brief (")
  631. if not any(marker in text for marker in markers):
  632. return text
  633. if "<|tool_call_start|>" in text:
  634. text = text.split("<|tool_call_start|>", 1)[1]
  635. if "<|tool_call_end|>" in text:
  636. text = text.split("<|tool_call_end|>", 1)[0]
  637. text = text.strip()
  638. if "update_brief(" not in text and "update_brief (" not in text:
  639. if any(token in text for token in ("<|", "previous_brief=", "mode=")):
  640. return None
  641. return text
  642. opening, quote = self._first_quote(text, self._first_arg_start(text))
  643. if opening == -1:
  644. return None
  645. closing = self._arg_boundary_quote(text, opening, quote)
  646. if closing == -1:
  647. return None
  648. value = text[opening + 1 : closing].strip()
  649. if any(token in value for token in ("<|", "previous_brief=", "mode=")):
  650. return None
  651. return value
  652. def parse_brief(self, text) -> str | None:
  653. """Strip a code fence and any stray tool-call envelope; None when empty."""
  654. if not isinstance(text, str):
  655. return None
  656. cleaned = text.strip()
  657. if not cleaned:
  658. return None
  659. if cleaned.startswith("```"):
  660. _, _, rest = cleaned.partition("\n")
  661. cleaned = rest if rest else ""
  662. end = cleaned.rfind("```")
  663. if end != -1:
  664. cleaned = cleaned[:end]
  665. cleaned = cleaned.strip()
  666. if not cleaned:
  667. return None
  668. cleaned = self._unwrap_tool_call(cleaned)
  669. if not cleaned:
  670. return None
  671. # Some models emit escaped newlines (literal backslash-n) instead of
  672. # real line breaks; unescape only when no real newline is present so
  673. # genuine backslashes in clean markdown are never mangled.
  674. if "\n" not in cleaned and ("\\n" in cleaned or "\\t" in cleaned):
  675. cleaned = (
  676. cleaned.replace("\\r\\n", "\n")
  677. .replace("\\n", "\n")
  678. .replace("\\t", "\t")
  679. .strip()
  680. )
  681. if not cleaned:
  682. return None
  683. limit = self._brief_max_chars()
  684. if len(cleaned) > limit:
  685. logger.warning("truncating brief from %d to %d chars", len(cleaned), limit)
  686. cleaned = cleaned[:limit]
  687. return cleaned
  688. def should_update_brief(self, signals) -> bool:
  689. """Fail-open gate on the substantive_update noul arm."""
  690. if not isinstance(signals, dict):
  691. return True
  692. items = signals.get("items", {})
  693. if not isinstance(items, dict):
  694. return True
  695. item = items.get("substantive_update")
  696. if not isinstance(item, dict):
  697. return True
  698. prob = _number(item.get("p"))
  699. if prob is None:
  700. return True
  701. return prob >= self._brief_gate_threshold()
  702. def finalize_brief(self, text, finish_reason) -> str | None:
  703. """Accept any parseable brief; a length cutoff truncates, never rejects."""
  704. if finish_reason == "length":
  705. logger.warning("brief reply hit the length limit; keeping the truncated text")
  706. return self.parse_brief(text)
  707. def state(self) -> tuple[str, str]:
  708. """Return the cached (state, reason) pair; performs no I/O."""
  709. return (self._state, self._reason)
  710. def refresh(self, state: str, reason: str = "") -> None:
  711. """Explicit transition; keeps no_questions; ignores unknown states."""
  712. if self._questions is None:
  713. return
  714. if state not in TRIAGE_REASONS:
  715. logger.warning("ignoring unknown triage state %r", state)
  716. return
  717. self._state = state
  718. self._reason = reason if isinstance(reason, str) else str(reason)
  719. def _accents_folded(text: str) -> str:
  720. """Casefold, strip combining accents, and collapse to plain words."""
  721. decomposed = unicodedata.normalize("NFKD", str(text))
  722. without_marks = "".join(ch for ch in decomposed if not unicodedata.combining(ch))
  723. return " ".join(without_marks.casefold().split())
  724. def _dedupe_key(text: str) -> str:
  725. """Accent- and punctuation-insensitive key for near-verbatim comparisons."""
  726. return " ".join(t for t in re.split(r"[^0-9a-z$%]+", _accents_folded(text)) if t)
  727. def _number_tokens(text: str) -> frozenset:
  728. """Digit-bearing tokens (currency/percent stripped) for strict dedupe checks."""
  729. tokens = set()
  730. for raw in _accents_folded(text).split():
  731. digits = "".join(ch for ch in raw if ch.isdigit())
  732. if digits:
  733. tokens.add(digits)
  734. return frozenset(tokens)
  735. def _entity_tokens(text: str) -> frozenset:
  736. """Capitalized (proper-noun) tokens, accent-folded, for strict dedupe checks."""
  737. tokens = set()
  738. for raw in str(text).split():
  739. stripped = raw.strip(".,;:!?()[]\"'")
  740. if stripped[:1].isupper() and stripped[1:].islower():
  741. tokens.add(_accents_folded(stripped))
  742. return frozenset(tokens)
  743. class FactLedger:
  744. """Code-owned, append-only ledger of durable facts.
  745. The model proposes new facts per pass; this class owns dedupe, the size cap,
  746. eviction, and rendering, so a recorded fact can only disappear via a logged,
  747. deterministic eviction (never because the model dropped it). Pure and
  748. stdlib-only, matching the analyzer.
  749. Dedupe biasses toward *keeping* a fact: an exact normalized match is dropped,
  750. and a fuzzy near-match is dropped only when its number and entity tokens are
  751. identical. A redundant bullet is cosmetic; a false merge would silently lose a
  752. fact and break the retention guarantee.
  753. """
  754. def __init__(self, max_facts: int = 200, fact_max_chars: int = 240) -> None:
  755. self.max_facts = max(1, int(max_facts))
  756. self.fact_max_chars = max(1, int(fact_max_chars))
  757. self._facts: list[str] = []
  758. def __len__(self) -> int:
  759. return len(self._facts)
  760. def facts(self) -> list[str]:
  761. """Return a copy of the recorded facts, in insertion order."""
  762. return list(self._facts)
  763. def clear(self) -> None:
  764. """Drop every recorded fact."""
  765. self._facts = []
  766. def render(self) -> str:
  767. """Render the ledger as a ``Prior Details:`` section ("" when empty)."""
  768. if not self._facts:
  769. return ""
  770. return "Prior Details:\n" + "\n".join("- " + fact for fact in self._facts)
  771. def merge(self, new_facts) -> int:
  772. """Append genuinely-new facts, dedupe, then evict oldest over the cap.
  773. Returns the number of facts added. Eviction is oldest-first and logged;
  774. a fact added in this call is never evicted by this call.
  775. """
  776. added = 0
  777. for raw in new_facts or []:
  778. fact = self._clean(raw)
  779. if fact is None:
  780. continue
  781. if self._is_duplicate(fact):
  782. continue
  783. self._facts.append(fact)
  784. added += 1
  785. if added:
  786. self._evict()
  787. return added
  788. def _clean(self, raw):
  789. if not isinstance(raw, str):
  790. return None
  791. fact = " ".join(raw.strip().split())
  792. if not fact:
  793. return None
  794. if len(fact) > self.fact_max_chars:
  795. fact = fact[: self.fact_max_chars].rstrip()
  796. return fact or None
  797. @staticmethod
  798. def _tokens(text: str) -> list[str]:
  799. normalized = _accents_folded(text)
  800. return [t for t in re.split(r"[^0-9a-z$%]+", normalized) if t]
  801. def _is_duplicate(self, fact: str) -> bool:
  802. key = _dedupe_key(fact)
  803. cand_tokens = self._tokens(fact)
  804. cand_numbers = _number_tokens(fact)
  805. cand_entities = _entity_tokens(fact)
  806. for existing in self._facts:
  807. if _dedupe_key(existing) == key:
  808. return True
  809. if cand_numbers != _number_tokens(existing):
  810. continue
  811. if cand_entities != _entity_tokens(existing):
  812. continue
  813. existing_tokens = self._tokens(existing)
  814. if not cand_tokens or not existing_tokens:
  815. continue
  816. overlap = len(set(cand_tokens) & set(existing_tokens)) / len(set(cand_tokens))
  817. if overlap >= 0.85:
  818. return True
  819. return False
  820. def _evict(self) -> None:
  821. overflow = len(self._facts) - self.max_facts
  822. if overflow <= 0:
  823. return
  824. evicted = self._facts[:overflow]
  825. self._facts = self._facts[overflow:]
  826. logger.info(
  827. "fact ledger at cap %d; evicted %d oldest fact(s): %s",
  828. self.max_facts,
  829. len(evicted),
  830. "; ".join(evicted),
  831. )
  832. @classmethod
  833. def from_brief(cls, brief, max_facts: int = 200, fact_max_chars: int = 240):
  834. """Rebuild a ledger by harvesting ``- `` bullets from an existing brief."""
  835. ledger = cls(max_facts=max_facts, fact_max_chars=fact_max_chars)
  836. if isinstance(brief, str):
  837. for line in brief.splitlines():
  838. stripped = line.strip()
  839. if stripped.startswith("- "):
  840. ledger.merge([stripped[2:]])
  841. return ledger