test_triage.py 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296
  1. """Tests for the triage analyzer (todos 3, 9, 10, 11). Pure, hardware-free."""
  2. import json
  3. import logging
  4. from pathlib import Path
  5. from types import SimpleNamespace
  6. import pytest
  7. from stt_daemon import events
  8. from stt_daemon.assistant import triage
  9. from stt_daemon.assistant.service import AssistantService
  10. from stt_daemon.assistant.triage import FactLedger, TriageAnalyzer, load_questions
  11. from stt_daemon.config import Config
  12. from stt_daemon.tui.state import ViewState
  13. EXAMPLE_PATH = str(Path(__file__).resolve().parent.parent / "questions.example.json")
  14. def _cfg(path=EXAMPLE_PATH, **overrides):
  15. base = {
  16. "questions_file": path,
  17. "state_char_budget": 1200,
  18. "confidence_field": "answer_confidence",
  19. "escalate_threshold": 0.6,
  20. "choice_min_confidence": 0.85,
  21. }
  22. base.update(overrides)
  23. return SimpleNamespace(**base)
  24. def _write_questions(tmp_path, questions):
  25. path = tmp_path / "questions.json"
  26. path.write_text(json.dumps(questions), encoding="utf-8")
  27. return str(path)
  28. def _analyzer(tmp_path, questions=None, **overrides):
  29. if questions is None:
  30. return TriageAnalyzer(_cfg(**overrides))
  31. return TriageAnalyzer(_cfg(_write_questions(tmp_path, questions), **overrides))
  32. # --- todo 3: load_questions -------------------------------------------------
  33. def test_load_example_order_preserved():
  34. raw = json.loads(Path(EXAMPLE_PATH).read_text(encoding="utf-8"))
  35. questions = load_questions(EXAMPLE_PATH)
  36. assert questions is not None
  37. assert questions
  38. assert list(questions.keys()) == list(raw.keys())
  39. for definition in questions.values():
  40. assert definition["type"] in {"choice", "score", "noul"}
  41. assert isinstance(definition["instructions"], str)
  42. assert definition["instructions"].strip()
  43. def test_example_questions_match_design():
  44. questions = load_questions(EXAMPLE_PATH)
  45. assert questions is not None
  46. assert list(questions.keys()) == [
  47. "substantive_update",
  48. "opportunity",
  49. "blocker",
  50. "decision_status",
  51. "action_item",
  52. "responsibility_clarity",
  53. "deadline",
  54. "open_question",
  55. ]
  56. for qid, definition in questions.items():
  57. assert isinstance(definition["instructions"], str)
  58. assert definition["instructions"].strip()
  59. if definition["type"] == "choice":
  60. criteria = definition["criteria"]
  61. options = list(criteria.keys()) if isinstance(criteria, dict) else list(criteria)
  62. assert "other" in options
  63. assert len(options) <= 7
  64. if definition["type"] == "noul":
  65. assert isinstance(definition.get("instructions"), str)
  66. assert definition["instructions"].strip()
  67. def test_load_missing_file_returns_none_with_warning(caplog):
  68. with caplog.at_level(logging.WARNING, logger="stt_daemon.assistant.triage"):
  69. assert load_questions("/nonexistent/questions.json") is None
  70. assert caplog.records, "expected a warning for the missing file"
  71. def test_load_bad_json_returns_none_with_warning(tmp_path, caplog):
  72. path = tmp_path / "questions.json"
  73. path.write_text("{not valid json", encoding="utf-8")
  74. with caplog.at_level(logging.WARNING, logger="stt_daemon.assistant.triage"):
  75. assert load_questions(str(path)) is None
  76. assert caplog.records
  77. @pytest.mark.parametrize(
  78. "bad_entry",
  79. [
  80. {"type": "mystery", "instructions": "do something"}, # unknown type
  81. {"type": "score", "instructions": "rate it", "criteria": {"0": "low"}}, # score dict
  82. { # noul criteria with a bad key
  83. "type": "noul",
  84. "instructions": "holds?",
  85. "criteria": {"true": "yes", "maybe": "hmm"},
  86. },
  87. "just a string", # non-object entry
  88. {"type": "choice", "instructions": "pick one", "criteria": []}, # empty choice
  89. {"type": "choice", "instructions": "pick one", "criteria": {}}, # empty choice dict
  90. {"type": "choice", "criteria": ["a", "b"]}, # missing instructions
  91. {"type": "score", "instructions": "rate it", "criteria": ["a", None]}, # None level
  92. {"type": "noul", "instructions": "holds?", "criteria": ["true"]}, # noul list
  93. ],
  94. )
  95. def test_load_malformed_returns_none_with_warning(tmp_path, caplog, bad_entry):
  96. path = _write_questions(
  97. tmp_path, {"good": {"type": "noul", "instructions": "ok?"}, "bad": bad_entry}
  98. )
  99. with caplog.at_level(logging.WARNING, logger="stt_daemon.assistant.triage"):
  100. assert load_questions(str(path)) is None
  101. assert caplog.records
  102. # --- todo 9: normalize ------------------------------------------------------
  103. def _score_questions():
  104. return {
  105. "severity": {
  106. "type": "score",
  107. "instructions": "How severe is the issue?",
  108. "criteria": ["none", "low", "high", "critical"],
  109. },
  110. "category": {
  111. "type": "choice",
  112. "instructions": "What is this about?",
  113. "criteria": {"support": "help needed", "sales": "buying"},
  114. },
  115. "churn_risk": {"type": "noul", "instructions": "User may leave."},
  116. }
  117. def test_normalize_choice_exact(tmp_path):
  118. analyzer = _analyzer(tmp_path, _score_questions())
  119. payload = {
  120. "model": "multilingual",
  121. "answers": {
  122. "category": {
  123. "type": "choice",
  124. "choice": "support",
  125. "confidence": 0.7,
  126. "answer_confidence": 0.4,
  127. }
  128. },
  129. "usage": {"input_tokens": 123, "output_tokens": 0},
  130. }
  131. result = analyzer.normalize(payload)
  132. assert result["order"] == ["category"]
  133. assert result["items"]["category"] == {
  134. "kind": "choice",
  135. "choice": "support",
  136. "confidence": 0.7,
  137. "answer_confidence": 0.4,
  138. "gate": 0.4,
  139. "flagged": True,
  140. }
  141. assert result["input_tokens"] == 123
  142. assert result["escalated"] is True
  143. def test_normalize_score_with_legend(tmp_path):
  144. analyzer = _analyzer(tmp_path, _score_questions())
  145. payload = {
  146. "answers": {
  147. "severity": {
  148. "type": "score",
  149. "score": 2.6,
  150. "legend": {"0": "none", "1": "low", "2": "high", "3": "critical"},
  151. "confidence": 0.5,
  152. "answer_confidence": 0.6,
  153. }
  154. },
  155. "usage": {"input_tokens": 7, "output_tokens": 0},
  156. }
  157. item = analyzer.normalize(payload)["items"]["severity"]
  158. assert item["kind"] == "score"
  159. assert item["expected"] == 2.6
  160. assert item["level"] == 3
  161. assert item["label"] == "critical"
  162. assert item["confidence"] == 0.5
  163. assert item["answer_confidence"] == 0.6
  164. assert item["gate"] == 0.6
  165. assert item["flagged"] is True
  166. def test_normalize_score_legend_fallback(tmp_path):
  167. analyzer = _analyzer(tmp_path, _score_questions())
  168. payload = {
  169. "answers": {
  170. "severity": {"type": "score", "score": 1.2, "confidence": 0.9, "answer_confidence": 0.9}
  171. }
  172. }
  173. item = analyzer.normalize(payload)["items"]["severity"]
  174. assert item["level"] == 1
  175. assert item["label"] == "low" # falls back to criteria[level]
  176. assert item["flagged"] is False # 1.2 < (4 - 1) * 0.5
  177. def test_normalize_noul_and_bare_payload(tmp_path):
  178. analyzer = _analyzer(tmp_path, _score_questions())
  179. bare = {
  180. "churn_risk": {"type": "noul", "noul": 0.9, "confidence": 0.9, "answer_confidence": 0.85}
  181. }
  182. result = analyzer.normalize(bare)
  183. assert result["order"] == ["churn_risk"]
  184. assert result["items"]["churn_risk"] == {
  185. "kind": "noul",
  186. "p": 0.9,
  187. "confidence": 0.9,
  188. "answer_confidence": 0.85,
  189. "gate": 0.85,
  190. "flagged": True,
  191. }
  192. assert result["input_tokens"] == 0
  193. def test_normalize_skips_unknown_qid_and_malformed(tmp_path):
  194. analyzer = _analyzer(tmp_path, _score_questions())
  195. payload = {
  196. "answers": {
  197. "ghost": {"type": "noul", "noul": 0.99, "confidence": 0.9, "answer_confidence": 0.9},
  198. "category": {"type": "choice", "choice": 42}, # malformed: no confidences
  199. "churn_risk": "nonsense", # malformed: not a dict
  200. },
  201. "usage": {"input_tokens": 3, "output_tokens": 0},
  202. }
  203. result = analyzer.normalize(payload)
  204. assert result["order"] == []
  205. assert result["items"] == {}
  206. assert result["escalated"] is False
  207. def test_normalize_ignores_side_channel_fields(tmp_path):
  208. analyzer = _analyzer(tmp_path, _score_questions())
  209. payload = {
  210. "answers": {
  211. "category": {
  212. "type": "choice",
  213. "choice": "sales",
  214. "confidence": 0.95,
  215. "answer_confidence": 0.9,
  216. "action": {"act" + "_probability": 0.99},
  217. }
  218. },
  219. "usage": {"input_tokens": 1, "output_tokens": 0},
  220. }
  221. result = analyzer.normalize(payload)
  222. assert result["items"]["category"]["flagged"] is False
  223. assert "act" + "_probability" not in json.dumps(result)
  224. source = Path(triage.__file__).read_text(encoding="utf-8")
  225. assert "act" + "_probability" not in source
  226. def test_normalize_gating_field_selectable(tmp_path):
  227. analyzer = _analyzer(tmp_path, _score_questions(), confidence_field="confidence")
  228. payload = {
  229. "answers": {
  230. "category": {
  231. "type": "choice",
  232. "choice": "sales",
  233. "confidence": 0.5,
  234. "answer_confidence": 0.99,
  235. }
  236. }
  237. }
  238. item = analyzer.normalize(payload)["items"]["category"]
  239. assert item["gate"] == 0.5 # gates on confidence, keeps both
  240. assert item["answer_confidence"] == 0.99
  241. assert item["flagged"] is True
  242. # --- todo 9: condensation ---------------------------------------------------
  243. def test_condense_keeps_newest_chronological_no_numerals(tmp_path):
  244. analyzer = _analyzer(tmp_path, _score_questions(), state_char_budget=40)
  245. window = ["alpha first", "beta second", "gamma third newest here"]
  246. state = analyzer.build_state(window)
  247. assert "gamma third newest here" in state
  248. assert "alpha first" not in state # oldest dropped under the budget
  249. lines = state.splitlines()
  250. assert all(line.startswith("- ") for line in lines)
  251. assert not any(ch.isdigit() for ch in state) # no numerals injected
  252. # chronological restore: kept lines stay in original order
  253. kept = [u for u in window if u in state]
  254. assert [line[2:] for line in lines] == kept
  255. def test_condense_empty_window(tmp_path):
  256. analyzer = _analyzer(tmp_path, _score_questions())
  257. assert analyzer.build_state([]) == ""
  258. # --- todo 10: escalation + extraction ---------------------------------------
  259. def _signals(choice_gate=0.9, noul_p=0.1, score_expected=0.2):
  260. return {
  261. "order": ["category", "churn_risk", "severity"],
  262. "items": {
  263. "category": {
  264. "kind": "choice",
  265. "choice": "sales",
  266. "confidence": choice_gate,
  267. "answer_confidence": choice_gate,
  268. "gate": choice_gate,
  269. "flagged": choice_gate < 0.85,
  270. },
  271. "churn_risk": {
  272. "kind": "noul",
  273. "p": noul_p,
  274. "confidence": noul_p,
  275. "answer_confidence": noul_p,
  276. "gate": noul_p,
  277. "flagged": noul_p >= 0.6,
  278. },
  279. "severity": {
  280. "kind": "score",
  281. "expected": score_expected,
  282. "level": 0,
  283. "label": "none",
  284. "confidence": 0.9,
  285. "answer_confidence": 0.9,
  286. "gate": 0.9,
  287. "flagged": score_expected >= 1.5,
  288. },
  289. },
  290. "escalated": False,
  291. "input_tokens": 0,
  292. }
  293. def test_escalation_arms_fire_independently(tmp_path):
  294. analyzer = _analyzer(tmp_path, _score_questions())
  295. assert analyzer.should_escalate(_signals(choice_gate=0.5)) is True # choice arm
  296. assert analyzer.should_escalate(_signals(noul_p=0.8)) is True # noul arm
  297. assert analyzer.should_escalate(_signals(score_expected=2.0)) is True # score arm
  298. assert analyzer.should_escalate(_signals()) is False
  299. def test_normalize_escalated_matches_should_escalate(tmp_path):
  300. analyzer = _analyzer(tmp_path, _score_questions())
  301. payload = {
  302. "answers": {
  303. "category": {
  304. "type": "choice",
  305. "choice": "sales",
  306. "confidence": 0.9,
  307. "answer_confidence": 0.9,
  308. },
  309. "churn_risk": {
  310. "type": "noul",
  311. "noul": 0.1,
  312. "confidence": 0.9,
  313. "answer_confidence": 0.9,
  314. },
  315. "severity": {
  316. "type": "score",
  317. "score": 0.1,
  318. "confidence": 0.9,
  319. "answer_confidence": 0.9,
  320. },
  321. }
  322. }
  323. result = analyzer.normalize(payload)
  324. assert result["escalated"] == analyzer.should_escalate(result) is False
  325. # --- todo 5: brief prompt / parse / gate / finalize --------------------------
  326. def test_build_brief_prompt_order_and_focus(tmp_path):
  327. analyzer = _analyzer(tmp_path, _score_questions())
  328. prompt = analyzer.build_brief_prompt(
  329. ["Alice: let's ship Friday", "Bob: agreed"], _signals(noul_p=0.8)
  330. )
  331. assert prompt.startswith("New utterances:\n- Alice: let's ship Friday\n- Bob: agreed")
  332. assert "Focus:" in prompt
  333. assert "churn_risk" in prompt # signal named in words
  334. assert prompt.index("New utterances:") < prompt.index("Focus:")
  335. assert prompt.index("Focus:") < prompt.index("Return only the updated brief")
  336. # no JSON extraction keys or raw dict shapes leak into the brief prompt
  337. for key in ("summary", "opportunities", "action_items", "risks", "doubts"):
  338. assert key not in prompt
  339. assert "{" not in prompt and "}" not in prompt
  340. def test_build_brief_prompt_no_focus_without_signals(tmp_path):
  341. analyzer = _analyzer(tmp_path, _score_questions())
  342. for signals in (None, {}, {"order": [], "items": {}}):
  343. prompt = analyzer.build_brief_prompt(["solo utterance"], signals)
  344. assert "solo utterance" in prompt
  345. assert "Focus:" not in prompt
  346. assert prompt.endswith("Return only the updated brief as plain text.")
  347. def test_build_ledger_prompt_inlines_ledger_narrative_and_deltas(tmp_path):
  348. analyzer = _analyzer(tmp_path, _score_questions())
  349. prompt = analyzer.build_ledger_prompt(
  350. "Summary: shipped\n\nOpportunities: expand",
  351. ["fact one", "fact two"],
  352. ["Alice: let's ship Friday"],
  353. _signals(noul_p=0.8),
  354. )
  355. assert "- fact one" in prompt and "- fact two" in prompt
  356. assert "CURRENT LEDGER" in prompt
  357. assert "Summary: shipped" in prompt
  358. assert "NEW UTTERANCES:\n- Alice: let's ship Friday" in prompt
  359. assert prompt.index("CURRENT LEDGER") < prompt.index("CURRENT BRIEF:")
  360. assert prompt.index("CURRENT BRIEF:") < prompt.index("NEW UTTERANCES:")
  361. assert prompt.index("NEW UTTERANCES:") < prompt.index("Focus:")
  362. assert "churn_risk" in prompt
  363. assert "NEW FACTS:" in prompt and "BRIEF:" in prompt
  364. def test_build_ledger_prompt_empty_ledger_and_narrative(tmp_path):
  365. analyzer = _analyzer(tmp_path, _score_questions())
  366. prompt = analyzer.build_ledger_prompt("", [], ["solo"], None)
  367. assert "CURRENT LEDGER" in prompt and "(none yet)" in prompt
  368. assert "CURRENT BRIEF:\n(none yet)" in prompt
  369. assert "NEW UTTERANCES:\n- solo" in prompt
  370. assert "Focus:" not in prompt
  371. def test_extract_bullets_strips_new_marker_and_normalizes(tmp_path):
  372. analyzer = _analyzer(tmp_path, _score_questions())
  373. brief = (
  374. "Summary: things\n"
  375. "- top level bullet\n"
  376. " - indented child\n"
  377. "- **New:** tagged fact\n"
  378. "not a bullet\n"
  379. "\n"
  380. )
  381. assert analyzer.extract_bullets(brief) == [
  382. "top level bullet",
  383. "indented child",
  384. "tagged fact",
  385. ]
  386. def test_restore_missing_bullets_reappends_dropped_fact(tmp_path):
  387. analyzer = _analyzer(tmp_path, _score_questions())
  388. prior = (
  389. "Summary: old\n\nPrior Details:\n"
  390. "- The budget was $305 million.\n"
  391. "- Sense could find prey.\n"
  392. )
  393. new = "Summary: newer\n\nPrior Details:\n- Sense could find prey.\n"
  394. restored = analyzer.restore_missing_bullets(new, prior)
  395. assert "- Sense could find prey." in restored
  396. assert "- The budget was $305 million." in restored
  397. assert restored.count("- Sense could find prey.") == 1
  398. def test_restore_missing_bullets_matches_reworded_bullet(tmp_path):
  399. analyzer = _analyzer(tmp_path, _score_questions())
  400. prior = "Prior Details:\n- **New:** Sense could find prey.\n"
  401. new = "Prior Details:\n- Sense could find prey.\n"
  402. restored = analyzer.restore_missing_bullets(new, prior)
  403. assert restored == new
  404. def test_restore_missing_bullets_restores_reworded_as_new_fact(tmp_path):
  405. analyzer = _analyzer(tmp_path, _score_questions())
  406. prior = "Prior Details:\n- The budget was $305 million.\n"
  407. new = "Prior Details:\n- Budget: $305 million (nearly Spider-Man 2).\n"
  408. restored = analyzer.restore_missing_bullets(new, prior)
  409. assert restored.count("$305 million") == 2
  410. assert restored.rstrip().endswith("- The budget was $305 million.")
  411. def test_restore_missing_bullets_noop_without_prior_or_new(tmp_path):
  412. analyzer = _analyzer(tmp_path, _score_questions())
  413. new = "Summary: only\nPrior Details:\n- keep me\n"
  414. assert analyzer.restore_missing_bullets(new, "") == new
  415. assert analyzer.restore_missing_bullets(new, None) == new
  416. assert analyzer.restore_missing_bullets("", new) == ""
  417. assert analyzer.restore_missing_bullets(None, new) is None
  418. def test_restore_missing_bullets_appends_section_when_absent(tmp_path):
  419. analyzer = _analyzer(tmp_path, _score_questions())
  420. prior = "- orphan fact\n"
  421. new = "Summary: no prior details header\n"
  422. restored = analyzer.restore_missing_bullets(new, prior)
  423. assert "- orphan fact" in restored
  424. assert "Prior Details:" in restored
  425. def test_build_ledger_prompt_contains_resolution_rule(tmp_path):
  426. analyzer = _analyzer(tmp_path, _score_questions())
  427. prompt = analyzer.build_ledger_prompt("Summary: x", [], ["hi"], None)
  428. assert "Never silently delete Action Items, Open Questions" in prompt
  429. assert "Done: <original>" in prompt
  430. assert "NEW FACTS bullet" in prompt
  431. def test_restore_missing_bullets_reappends_dropped_action_item(tmp_path):
  432. analyzer = _analyzer(tmp_path, _score_questions())
  433. prior = (
  434. "Summary: s\n\nAction Items:\n- Call Maria about pricing\n\n"
  435. "Open Questions:\n- Who owns the launch checklist?\n"
  436. )
  437. new = "Summary: s\n\nDecisions:\n- nothing yet\n"
  438. restored = analyzer.restore_missing_bullets(new, prior)
  439. assert "- Call Maria about pricing" in restored
  440. assert "- Who owns the launch checklist?" in restored
  441. assert "Prior Details:" in restored
  442. def test_parse_brief_strips_fences_and_rejects_empty(tmp_path):
  443. analyzer = _analyzer(tmp_path, _score_questions())
  444. assert analyzer.parse_brief("```\nhello brief\n```") == "hello brief"
  445. assert analyzer.parse_brief("```markdown\nhello brief\n```") == "hello brief"
  446. assert analyzer.parse_brief("plain brief") == "plain brief"
  447. assert analyzer.parse_brief(" padded ") == "padded"
  448. assert analyzer.parse_brief("") is None
  449. assert analyzer.parse_brief(" ") is None
  450. assert analyzer.parse_brief(None) is None
  451. assert analyzer.parse_brief(42) is None
  452. assert analyzer.parse_brief("```") is None
  453. def test_parse_brief_unescapes_literal_backslash_n(tmp_path):
  454. analyzer = _analyzer(tmp_path, _score_questions())
  455. assert analyzer.parse_brief("## Title\\n\\nBody line") == "## Title\n\nBody line"
  456. assert analyzer.parse_brief("a\\tb") == "a\tb"
  457. assert analyzer.parse_brief("line1\nline2\\nline3") == "line1\nline2\\nline3"
  458. def test_parse_brief_truncates_not_rejects(tmp_path):
  459. analyzer = _analyzer(tmp_path, _score_questions())
  460. parsed = analyzer.parse_brief("x" * 5000)
  461. assert parsed is not None
  462. assert parsed == "x" * 4000
  463. assert len(parsed) == 4000
  464. wrapped = "[update_brief(updated_brief='" + "x" * 5000 + "')]"
  465. assert analyzer.parse_brief(wrapped) == "x" * 4000
  466. def test_parse_brief_honors_configured_cap(tmp_path):
  467. analyzer = _analyzer(tmp_path, _score_questions(), brief_max_chars=10)
  468. assert analyzer.parse_brief("x" * 50) == "x" * 10
  469. def test_parse_brief_unwraps_single_quoted_tool_call_with_apostrophes(tmp_path):
  470. analyzer = _analyzer(tmp_path, _score_questions())
  471. observed = "<|tool_call_start|>[update_brief(updated_brief='the company's game failed')]"
  472. assert analyzer.parse_brief(observed) == "the company's game failed"
  473. def test_parse_brief_unwraps_double_quoted_tool_call(tmp_path):
  474. analyzer = _analyzer(tmp_path, _score_questions())
  475. wrapped = '[update_brief(updated_brief="the company\'s plan")]'
  476. assert analyzer.parse_brief(wrapped) == "the company's plan"
  477. def test_parse_brief_unwraps_trailing_end_marker(tmp_path):
  478. analyzer = _analyzer(tmp_path, _score_questions())
  479. wrapped = "[update_brief(updated_brief='ship friday')]<|tool_call_end|>"
  480. assert analyzer.parse_brief(wrapped) == "ship friday"
  481. def test_parse_brief_unwraps_fenced_and_wrapped(tmp_path):
  482. analyzer = _analyzer(tmp_path, _score_questions())
  483. wrapped = "```\n<|tool_call_start|>[update_brief(updated_brief='fenced payload')]\n```"
  484. assert analyzer.parse_brief(wrapped) == "fenced payload"
  485. def test_parse_brief_does_not_mangle_clean_markdown(tmp_path):
  486. analyzer = _analyzer(tmp_path, _score_questions())
  487. clean = "Here's the [brief]: it's the company's plan - don't mangle [it]."
  488. assert analyzer.parse_brief(clean) == clean
  489. prose = "We should update_brief the notes, here's [why]."
  490. assert analyzer.parse_brief(prose) == prose
  491. def test_parse_brief_empty_tool_call_payload_is_none(tmp_path):
  492. analyzer = _analyzer(tmp_path, _score_questions())
  493. assert analyzer.parse_brief("[update_brief(updated_brief='')]") is None
  494. def test_parse_brief_unwraps_multi_arg_tool_call(tmp_path):
  495. analyzer = _analyzer(tmp_path, _score_questions())
  496. wrapped = (
  497. "[update_brief(updated_brief='## Opportunities\n- ship Friday', "
  498. "previous_brief='## old\n- y', mode='read')]"
  499. )
  500. assert analyzer.parse_brief(wrapped) == "## Opportunities\n- ship Friday"
  501. def test_parse_brief_unwraps_multi_arg_with_markers(tmp_path):
  502. analyzer = _analyzer(tmp_path, _score_questions())
  503. wrapped = (
  504. "<|tool_call_start|>[update_brief(updated_brief='## Opportunities\n- ship Friday', "
  505. "previous_brief='## old\n- y', mode='read')]<|tool_call_end|>"
  506. )
  507. assert analyzer.parse_brief(wrapped) == "## Opportunities\n- ship Friday"
  508. def test_parse_brief_unwraps_multi_arg_apostrophes_preserved(tmp_path):
  509. analyzer = _analyzer(tmp_path, _score_questions())
  510. wrapped = (
  511. "[update_brief(updated_brief='the company's game failed', "
  512. "previous_brief='## old', mode='read')]"
  513. )
  514. assert analyzer.parse_brief(wrapped) == "the company's game failed"
  515. def test_parse_brief_unwraps_positional_tool_call(tmp_path):
  516. analyzer = _analyzer(tmp_path, _score_questions())
  517. assert analyzer.parse_brief("[update_brief('payload here', mode='read')]") == "payload here"
  518. def test_parse_brief_rejects_polluted_tool_call(tmp_path):
  519. analyzer = _analyzer(tmp_path, _score_questions())
  520. polluted = "[update_brief(updated_brief='oops, previous_brief=', mode='read')]"
  521. assert analyzer.parse_brief(polluted) is None
  522. no_arg = "<|tool_call_start|>[update_brief(previous_brief=old, mode=read)]"
  523. assert analyzer.parse_brief(no_arg) is None
  524. def test_parse_brief_clean_mode_text_not_mangled(tmp_path):
  525. analyzer = _analyzer(tmp_path, _score_questions())
  526. clean = "Set mode=read, then read [the brief] - it's fine (really)."
  527. assert analyzer.parse_brief(clean) == clean
  528. def test_parse_brief_rejects_marker_with_artifacts_but_no_call(tmp_path):
  529. analyzer = _analyzer(tmp_path, _score_questions())
  530. assert analyzer.parse_brief("<|tool_call_start|>previous_brief='x', mode='read'") is None
  531. assert analyzer.parse_brief("<|tool_call_start|>mode='read'") is None
  532. def _brief_signals(p=None):
  533. item = {"kind": "noul"}
  534. if p is not None:
  535. item["p"] = p
  536. return {"order": ["substantive_update"], "items": {"substantive_update": item}}
  537. def test_should_update_brief_gate(tmp_path):
  538. analyzer = _analyzer(tmp_path, _score_questions())
  539. assert analyzer.should_update_brief(_brief_signals(0.9)) is True
  540. assert analyzer.should_update_brief(_brief_signals(0.5)) is True # boundary
  541. assert analyzer.should_update_brief(_brief_signals(0.2)) is False
  542. # absent update / no numeric p / not a dict -> fail open
  543. assert analyzer.should_update_brief({"order": [], "items": {}}) is True
  544. assert analyzer.should_update_brief(_brief_signals(None)) is True
  545. assert analyzer.should_update_brief(None) is True
  546. assert analyzer.should_update_brief({"items": {"substantive_update": "nope"}}) is True
  547. def test_should_update_brief_threshold_configured(tmp_path):
  548. analyzer = _analyzer(tmp_path, _score_questions(), brief_gate_threshold=0.8)
  549. assert analyzer.should_update_brief(_brief_signals(0.6)) is False
  550. assert analyzer.should_update_brief(_brief_signals(0.8)) is True
  551. def test_finalize_brief_length_truncates_not_rejects(tmp_path, caplog):
  552. analyzer = _analyzer(tmp_path, _score_questions())
  553. with caplog.at_level(logging.WARNING, logger="stt_daemon.assistant.triage"):
  554. result = analyzer.finalize_brief("y" * 5000, "length")
  555. assert result is not None
  556. assert result == "y" * 4000
  557. assert caplog.records # warned about the length cutoff but still accepted
  558. def test_finalize_brief_stop_and_empty(tmp_path):
  559. analyzer = _analyzer(tmp_path, _score_questions())
  560. assert analyzer.finalize_brief("a brief", "stop") == "a brief"
  561. assert analyzer.finalize_brief("", "stop") is None
  562. assert analyzer.finalize_brief("```\nfenced\n```", "stop") == "fenced"
  563. assert analyzer.finalize_brief(None, "length") is None
  564. # --- todo 11: cached state() ------------------------------------------------
  565. def test_state_initial_loading_with_good_questions(tmp_path):
  566. analyzer = _analyzer(tmp_path, _score_questions())
  567. assert analyzer.state() == ("loading", "")
  568. def test_state_initial_no_questions_with_bad_path(tmp_path):
  569. missing = str(tmp_path / "missing.json")
  570. analyzer = TriageAnalyzer(_cfg(missing))
  571. state, reason = analyzer.state()
  572. assert state == "no_questions"
  573. assert missing in reason
  574. def test_state_vocabulary_has_no_extra_member():
  575. assert set(triage.TRIAGE_REASONS) == {
  576. "ok",
  577. "no_questions",
  578. "loading",
  579. "laya_missing",
  580. "disabled",
  581. "error",
  582. }
  583. assert "service" + "_down" not in triage.TRIAGE_REASONS
  584. def test_refresh_transitions(tmp_path):
  585. analyzer = _analyzer(tmp_path, _score_questions())
  586. analyzer.refresh("ok")
  587. assert analyzer.state() == ("ok", "")
  588. analyzer.refresh("laya_missing", "run: uv sync --extra triage")
  589. assert analyzer.state() == ("laya_missing", "run: uv sync --extra triage")
  590. analyzer.refresh("error", "boom")
  591. assert analyzer.state() == ("error", "boom")
  592. def test_refresh_unknown_state_ignored(tmp_path, caplog):
  593. analyzer = _analyzer(tmp_path, _score_questions())
  594. analyzer.refresh("ok")
  595. with caplog.at_level(logging.WARNING, logger="stt_daemon.assistant.triage"):
  596. analyzer.refresh("bogus_state")
  597. assert analyzer.state() == ("ok", "")
  598. assert caplog.records
  599. def test_no_questions_state_cannot_be_overridden(tmp_path):
  600. analyzer = TriageAnalyzer(_cfg(str(tmp_path / "missing.json")))
  601. assert analyzer.state()[0] == "no_questions"
  602. analyzer.refresh("ok")
  603. analyzer.refresh("error", "boom")
  604. assert analyzer.state()[0] == "no_questions"
  605. def test_state_does_not_touch_lane(tmp_path):
  606. analyzer = _analyzer(tmp_path, _score_questions())
  607. class StrictLane:
  608. def __init__(self):
  609. self.calls = []
  610. def __getattr__(self, name):
  611. def record(*args, **kwargs):
  612. self.calls.append(name)
  613. return record
  614. lane = StrictLane()
  615. for _ in range(3):
  616. analyzer.state()
  617. analyzer.refresh("ok")
  618. analyzer.state()
  619. assert lane.calls == []
  620. # --- F3: end-to-end triage flow on fakes ------------------------------------
  621. EXTRACT_JSON = (
  622. '{"summary":"s","opportunities":[],"doubts":[],"action_items":[],"risks":[]}'
  623. )
  624. class _Clock:
  625. """Injected clock: no sleeps, fully deterministic."""
  626. def __init__(self, start=1000.0):
  627. self.t = float(start)
  628. def __call__(self):
  629. return self.t
  630. def advance(self, dt):
  631. self.t += float(dt)
  632. class _FakeSubscriber:
  633. def events(self):
  634. return iter(())
  635. def close(self):
  636. pass
  637. class _FakeLane:
  638. """Duck-typed LayaLane: canned-ready, records submits, counts drops."""
  639. def __init__(self):
  640. self.submits: list[tuple] = []
  641. self.drops = 0
  642. def ready(self) -> bool:
  643. return True
  644. def state(self):
  645. return ("ok", "")
  646. def submit(self, state, questions, lang, generation):
  647. self.submits.append((state, questions, lang, generation))
  648. return True
  649. def drop_pending(self):
  650. self.drops += 1
  651. return 1
  652. def _answers(noul_p=0.1):
  653. """A full laya-shaped answers payload for the example questions.
  654. `noul_p` drives every noul arm, including the `substantive_update` gate:
  655. below `brief_gate_threshold` (0.5) the turn is gated OUT (no brief job),
  656. at or above it the turn gates IN.
  657. """
  658. answers = {
  659. "decision_status": {
  660. "type": "choice",
  661. "choice": "made",
  662. "confidence": 0.9,
  663. "answer_confidence": 0.9,
  664. },
  665. "responsibility_clarity": {
  666. "type": "choice",
  667. "choice": "explicit",
  668. "confidence": 0.9,
  669. "answer_confidence": 0.9,
  670. },
  671. }
  672. for qid in (
  673. "substantive_update",
  674. "opportunity",
  675. "blocker",
  676. "action_item",
  677. "deadline",
  678. "open_question",
  679. ):
  680. answers[qid] = {
  681. "type": "noul",
  682. "noul": noul_p,
  683. "confidence": 0.9,
  684. "answer_confidence": 0.9,
  685. }
  686. return {"model": "multilingual", "answers": answers, "usage": {"input_tokens": 42}}
  687. def test_end_to_end_triage_flow():
  688. """signals -> content -> cleared across service, analyzer, lane, and ViewState.
  689. The lane result is wired by calling `service._on_triage_result(gen, payload)`
  690. directly (the runner does exactly this in production); the brief job is
  691. drained with `job.run()` so no worker thread is started. The
  692. single-outstanding guard plus the shared `_pending_brief` collapse settled
  693. turns into ONE job consuming every buffered delta, and the
  694. `substantive_update` gate can reduce that to ZERO jobs.
  695. """
  696. cfg = Config()
  697. cfg.assistant.turn_gap_s = 0.5
  698. cfg.triage.enabled = True
  699. cfg.triage.questions_file = EXAMPLE_PATH
  700. clock = _Clock(1000.0)
  701. lane = _FakeLane()
  702. emitted: list[dict] = []
  703. prompts: list[str] = []
  704. def emit(type_, **fields):
  705. emitted.append({"type": type_, **fields})
  706. analyzer = TriageAnalyzer(cfg.triage)
  707. def brief_fn(prompt, on_io=None, finalize=None):
  708. prompts.append(prompt)
  709. return "NEW FACTS:\nnone\n\nBRIEF:\nSummary: brief\n"
  710. service = AssistantService(
  711. cfg,
  712. _FakeSubscriber(),
  713. emit=emit,
  714. time_fn=clock,
  715. chat_fn=lambda text: "reply",
  716. triage=analyzer,
  717. laya_lane=lane,
  718. brief_fn=brief_fn,
  719. )
  720. def triage_events():
  721. return [e for e in emitted if e["type"] == "triage"]
  722. def brief_jobs():
  723. return [j for j in service._inference._queue if j.name == "brief-update"]
  724. # 1. settled turn -> exactly one lane submit, lang + generation recorded
  725. assert service.toggle_chat() is True
  726. service.handle_event(
  727. {"type": events.TRANSCRIPT, "text": "hola, necesito ayuda", "lang": "es"}
  728. )
  729. clock.advance(1.0)
  730. service.tick()
  731. assert len(lane.submits) == 1
  732. state_text, questions, lang, gen1 = lane.submits[0]
  733. assert "hola, necesito ayuda" in state_text
  734. assert questions is analyzer.questions
  735. assert lang == "es"
  736. assert gen1 == 0
  737. # 2. low-p turn -> signals only: the gate (p=0.1 < 0.5) dispatches ZERO jobs
  738. service._on_triage_result(gen1, _answers(noul_p=0.1))
  739. assert [e["stage"] for e in triage_events()] == ["signals"]
  740. assert triage_events()[0]["triage"]["escalated"] is False
  741. assert service.last_triage is not None
  742. assert service.last_triage["escalated"] is False
  743. assert brief_jobs() == []
  744. assert prompts == []
  745. # 3. high-p turn -> second submit, then ONE brief job (the only one outstanding)
  746. service.handle_event(
  747. {"type": events.TRANSCRIPT, "text": "quiero cancelar mi suscripcion", "lang": "es"}
  748. )
  749. clock.advance(1.0)
  750. service.tick()
  751. assert len(lane.submits) == 2
  752. gen2 = lane.submits[1][3]
  753. service._on_triage_result(gen2, _answers(noul_p=0.9))
  754. assert [e["stage"] for e in triage_events()] == ["signals", "signals"]
  755. assert triage_events()[1]["triage"]["escalated"] is True
  756. jobs = brief_jobs()
  757. assert len(jobs) == 1
  758. # 4. run the brief job directly (deterministic, no worker thread): it consumes
  759. # both buffered deltas and emits ONE content with the brief merged over signals
  760. jobs[0].run()
  761. assert len(prompts) == 1
  762. assert "hola, necesito ayuda" in prompts[0]
  763. assert "quiero cancelar mi suscripcion" in prompts[0]
  764. content_events = [e for e in triage_events() if e["stage"] == "content"]
  765. assert len(content_events) == 1
  766. merged = content_events[0]["triage"]
  767. assert merged["brief"] == "Summary: brief"
  768. assert merged["escalated"] is True # signals preserved under the brief
  769. assert merged["order"] == triage_events()[1]["triage"]["order"]
  770. # 5. drive the emitted stream through a fresh ViewState
  771. view = ViewState()
  772. for event in emitted:
  773. view.ingest(event)
  774. analyzer.refresh("ok") # the lane callback does this in production
  775. view.ingest({"type": "status", **service.status()})
  776. assert view.triage is not None
  777. assert view.triage["brief"] == "Summary: brief"
  778. lines = view.triage_lines()
  779. assert lines
  780. # 6. clear -> cleared event empties the panel and bumps the epoch
  781. baseline_epoch = view.triage_epoch
  782. already = len(emitted)
  783. service.clear()
  784. for event in emitted[already:]:
  785. view.ingest(event)
  786. assert view.triage is None
  787. assert view.triage_epoch > baseline_epoch
  788. # a stale result after clear must not re-populate the panel
  789. service._on_triage_result(gen2, _answers(noul_p=0.9))
  790. assert [e["stage"] for e in triage_events()] == [
  791. "signals",
  792. "signals",
  793. "content",
  794. "cleared",
  795. ]
  796. assert view.triage is None
  797. assert lane.drops == 1
  798. # 7. disabled control: no triage/lane -> status disabled, clear emits nothing
  799. cfg2 = Config()
  800. clock2 = _Clock(2000.0)
  801. emitted2: list[dict] = []
  802. def emit2(type_, **fields):
  803. emitted2.append({"type": type_, **fields})
  804. plain = AssistantService(cfg2, _FakeSubscriber(), emit=emit2, time_fn=clock2)
  805. assert plain.status()["triage_state"] == "disabled"
  806. assert plain.clear() is True
  807. assert [e for e in emitted2 if e["type"] == "triage"] == []
  808. def test_ledger_merge_dedupes_exact_and_punctuation_variants():
  809. ledger = FactLedger()
  810. assert ledger.merge(["The budget was $305 million.", "Sense could find prey."]) == 2
  811. assert ledger.merge(["sense could find prey", "The budget was $305 million"]) == 0
  812. assert ledger.facts() == [
  813. "The budget was $305 million.",
  814. "Sense could find prey.",
  815. ]
  816. def test_ledger_merge_keeps_near_match_with_different_number():
  817. ledger = FactLedger()
  818. ledger.merge(["Sold 1.9 million units."])
  819. assert ledger.merge(["Sold 10 million units."]) == 1
  820. assert len(ledger) == 2
  821. def test_ledger_merge_keeps_near_match_with_different_entity():
  822. ledger = FactLedger()
  823. ledger.merge(["Marcus Smith directed the game."])
  824. assert ledger.merge(["Mike Daly directed the game."]) == 1
  825. assert len(ledger) == 2
  826. def test_ledger_merge_ignores_empty_and_non_string():
  827. ledger = FactLedger()
  828. assert ledger.merge(["", " ", None, 123]) == 0
  829. assert ledger.render() == ""
  830. def test_ledger_render_has_prior_details_section():
  831. ledger = FactLedger()
  832. ledger.merge(["fact one", "fact two"])
  833. assert ledger.render() == "Prior Details:\n- fact one\n- fact two"
  834. def test_ledger_evicts_oldest_over_cap_and_never_new():
  835. ledger = FactLedger(max_facts=2)
  836. ledger.merge(["alpha", "beta"])
  837. ledger.merge(["gamma"])
  838. assert ledger.facts() == ["beta", "gamma"]
  839. def test_ledger_truncates_overlong_fact():
  840. ledger = FactLedger(fact_max_chars=10)
  841. ledger.merge(["x" * 50])
  842. assert ledger.facts() == ["x" * 10]
  843. def test_ledger_clear_empties():
  844. ledger = FactLedger()
  845. ledger.merge(["fact"])
  846. ledger.clear()
  847. assert ledger.facts() == []
  848. assert ledger.render() == ""
  849. def test_ledger_from_brief_harvests_bullets():
  850. ledger = FactLedger.from_brief("Summary: s\n\nPrior Details:\n- one\n- two\n")
  851. assert ledger.facts() == ["one", "two"]
  852. def test_parse_ledger_reply_splits_narrative_and_facts(tmp_path):
  853. analyzer = _analyzer(tmp_path, _score_questions())
  854. narrative, facts = analyzer.parse_ledger_reply(
  855. "NEW FACTS:\n- budget is $305m\n- sense finds prey\n\nBRIEF:\nSummary: ok"
  856. )
  857. assert facts == ["budget is $305m", "sense finds prey"]
  858. assert narrative == "Summary: ok"
  859. def test_parse_ledger_reply_drops_trailing_fact_when_truncated(tmp_path):
  860. analyzer = _analyzer(tmp_path, _score_questions())
  861. _, facts = analyzer.parse_ledger_reply(
  862. "NEW FACTS:\n- complete fact\n- half a fac", truncated=True
  863. )
  864. assert facts == ["complete fact"]
  865. def test_parse_ledger_reply_missing_facts_block(tmp_path):
  866. analyzer = _analyzer(tmp_path, _score_questions())
  867. narrative, facts = analyzer.parse_ledger_reply("BRIEF:\nSummary: only narrative")
  868. assert facts == []
  869. assert narrative == "Summary: only narrative"
  870. def test_parse_ledger_reply_strips_model_prior_details(tmp_path):
  871. analyzer = _analyzer(tmp_path, _score_questions())
  872. narrative, _ = analyzer.parse_ledger_reply(
  873. "NEW FACTS:\nnone\n\nBRIEF:\nSummary: s\n\nPrior Details:\n- model echo\n"
  874. )
  875. assert "model echo" not in narrative
  876. assert "Summary: s" in narrative
  877. def test_parse_ledger_reply_none_on_unusable(tmp_path):
  878. analyzer = _analyzer(tmp_path, _score_questions())
  879. assert analyzer.parse_ledger_reply("") is None
  880. assert analyzer.parse_ledger_reply("just some prose with no sections") is None
  881. def test_parse_facts_ignores_prose_and_handles_markers(tmp_path):
  882. analyzer = _analyzer(tmp_path, _score_questions())
  883. block = "- bullet fact\n* star fact\n• dot fact\n1. numbered fact\nplain prose\nnone"
  884. assert analyzer.parse_facts(block) == [
  885. "bullet fact",
  886. "star fact",
  887. "dot fact",
  888. "numbered fact",
  889. ]
  890. def _rich_questions():
  891. return {
  892. "substantive_update": {
  893. "type": "noul",
  894. "instructions": "This recent conversation segment adds something a running brief "
  895. "should record.",
  896. },
  897. "blocker": {
  898. "type": "noul",
  899. "instructions": "An obstacle, dependency, objection, or risk that could block "
  900. "or delay the work was raised.",
  901. },
  902. "churn_risk": {"type": "noul", "instructions": "User may leave."},
  903. "category": {
  904. "type": "choice",
  905. "instructions": "What is this about?",
  906. "criteria": {"support": "help needed", "sales": "buying"},
  907. },
  908. "severity": {
  909. "type": "score",
  910. "instructions": "How severe is the issue?",
  911. "criteria": ["none", "low", "high", "critical"],
  912. },
  913. }
  914. def _rich_signals(**overrides):
  915. items = {
  916. "blocker": {
  917. "kind": "noul",
  918. "p": 0.91,
  919. "confidence": 0.9,
  920. "answer_confidence": 0.9,
  921. "gate": 0.9,
  922. "flagged": True,
  923. },
  924. "churn_risk": {
  925. "kind": "noul",
  926. "p": 0.10,
  927. "confidence": 0.9,
  928. "answer_confidence": 0.9,
  929. "gate": 0.9,
  930. "flagged": False,
  931. },
  932. "substantive_update": {
  933. "kind": "noul",
  934. "p": 0.10,
  935. "confidence": 0.9,
  936. "answer_confidence": 0.9,
  937. "gate": 0.9,
  938. "flagged": False,
  939. },
  940. "category": {
  941. "kind": "choice",
  942. "choice": "support",
  943. "confidence": 0.4,
  944. "answer_confidence": 0.4,
  945. "gate": 0.4,
  946. "flagged": True,
  947. },
  948. "severity": {
  949. "kind": "score",
  950. "expected": 2.6,
  951. "level": 3,
  952. "label": "critical",
  953. "confidence": 0.9,
  954. "answer_confidence": 0.9,
  955. "gate": 0.9,
  956. "flagged": True,
  957. },
  958. }
  959. items.update(overrides.get("items", {}))
  960. order = overrides.get(
  961. "order", ["blocker", "churn_risk", "substantive_update", "category", "severity"])
  962. return {"order": order, "items": items, "escalated": overrides.get("escalated", False),
  963. "input_tokens": 0}
  964. def test_signal_lines_filters_low_noul_but_keeps_gate_qid(tmp_path):
  965. analyzer = _analyzer(tmp_path, _rich_questions())
  966. lines = analyzer._signal_lines(_rich_signals())
  967. text = "\n".join(lines)
  968. assert not any("churn_risk" in line for line in lines) # p=0.10 filtered
  969. assert any("substantive_update" in line for line in lines) # gate qid kept
  970. assert any("blocker" in line for line in lines) # high-signal kept
  971. assert "p=0.91" in text and "flagged" in text
  972. assert "could block or delay" in text # instructions carried
  973. assert all(line.startswith("- ") for line in lines)
  974. # original order preserved among kept lines
  975. kept = [line for line in lines]
  976. assert [i for i, line in enumerate(kept) if "blocker" in line][0] < [
  977. i for i, line in enumerate(kept) if "category" in line][0]
  978. def test_signal_lines_choice_carries_instructions_and_flag(tmp_path):
  979. analyzer = _analyzer(tmp_path, _rich_questions())
  980. lines = analyzer._signal_lines(_rich_signals(order=["category"], escalated=False))
  981. assert len(lines) == 1
  982. assert "category (choice): selected support (conf 0.40, flagged as uncertain)" in lines[0]
  983. assert "What is this about?" in lines[0]
  984. assert "help needed" in lines[0] # criteria[selected] definition
  985. def test_signal_lines_escalated_marker_only_on_flagged(tmp_path):
  986. analyzer = _analyzer(tmp_path, _rich_questions())
  987. lines = analyzer._signal_lines(_rich_signals(
  988. order=["blocker", "substantive_update"], escalated=True))
  989. by_qid = {line.split(" ")[1]: line for line in lines}
  990. assert by_qid["blocker"].endswith(" [escalated]")
  991. assert not by_qid["substantive_update"].endswith(" [escalated]")
  992. plain = analyzer._signal_lines(_rich_signals(
  993. order=["blocker", "substantive_update"], escalated=False))
  994. assert not any("[escalated]" in line for line in plain)
  995. def test_signal_lines_score_format_and_instruction_cap(tmp_path):
  996. questions = _rich_questions()
  997. questions["severity"]["instructions"] = "x " * 200 # force long instructions
  998. analyzer = _analyzer(tmp_path, questions)
  999. lines = analyzer._signal_lines(_rich_signals(order=["severity"]))
  1000. assert len(lines) == 1
  1001. assert "severity (score): expected 2.60, level 3 (critical), flagged" in lines[0]
  1002. assert len(lines[0]) <= len("- severity (score): expected 2.60, level 3 "
  1003. "(critical), flagged — ") + 160 + len(" [escalated]")
  1004. assert lines[0].rstrip().endswith("…")
  1005. def test_signal_lines_never_raises_on_malformed(tmp_path):
  1006. analyzer = _analyzer(tmp_path, _rich_questions())
  1007. assert analyzer._signal_lines(None) == [] # type: ignore[arg-type]
  1008. assert analyzer._signal_lines({}) == [] # type: ignore[dict-item]
  1009. assert analyzer._signal_lines({"order": "nope", "items": "nope"}) == [] # type: ignore[dict-item]
  1010. assert analyzer._signal_lines({"order": ["ghost"], "items": {}}) == []
  1011. def test_signal_lines_opportunity_kept_below_threshold(tmp_path):
  1012. questions = _rich_questions()
  1013. questions["opportunity"] = {"type": "noul", "instructions": "A new lead."}
  1014. analyzer = _analyzer(tmp_path, questions)
  1015. items = _rich_signals()["items"]
  1016. items["opportunity"] = {
  1017. "kind": "noul",
  1018. "p": 0.10,
  1019. "confidence": 0.9,
  1020. "answer_confidence": 0.9,
  1021. "gate": 0.9,
  1022. "flagged": False,
  1023. }
  1024. signals = {"order": ["blocker", "opportunity", "churn_risk"],
  1025. "items": items, "escalated": False}
  1026. lines = analyzer._signal_lines(signals)
  1027. assert any("opportunity" in line for line in lines)
  1028. assert not any("churn_risk" in line for line in lines)
  1029. def test_signal_lines_opportunity_ordered_first(tmp_path):
  1030. questions = _rich_questions()
  1031. questions["opportunity"] = {"type": "noul", "instructions": "A new lead."}
  1032. analyzer = _analyzer(tmp_path, questions)
  1033. items = _rich_signals()["items"]
  1034. items["opportunity"] = {
  1035. "kind": "noul",
  1036. "p": 0.80,
  1037. "confidence": 0.9,
  1038. "answer_confidence": 0.9,
  1039. "gate": 0.9,
  1040. "flagged": False,
  1041. }
  1042. signals = {"order": ["blocker", "severity", "opportunity"],
  1043. "items": items, "escalated": False}
  1044. lines = analyzer._signal_lines(signals)
  1045. assert lines[0].startswith("- opportunity")
  1046. # relative order of the rest preserved
  1047. rest_idx = [i for i, line in enumerate(lines) if "blocker" in line][0]
  1048. sev_idx = [i for i, line in enumerate(lines) if "severity" in line][0]
  1049. assert rest_idx < sev_idx
  1050. def test_build_ledger_prompt_contains_opportunity_lead_rule(tmp_path):
  1051. analyzer = _analyzer(tmp_path, _rich_questions())
  1052. prompt = analyzer.build_ledger_prompt("Summary: x", [], ["hi"], None)
  1053. assert "currently-open opportunities first" in prompt
  1054. assert "Opportunities section the most complete" in prompt