test_triage.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716
  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 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_load_missing_file_returns_none_with_warning(caplog):
  44. with caplog.at_level(logging.WARNING, logger="stt_daemon.assistant.triage"):
  45. assert load_questions("/nonexistent/questions.json") is None
  46. assert caplog.records, "expected a warning for the missing file"
  47. def test_load_bad_json_returns_none_with_warning(tmp_path, caplog):
  48. path = tmp_path / "questions.json"
  49. path.write_text("{not valid json", encoding="utf-8")
  50. with caplog.at_level(logging.WARNING, logger="stt_daemon.assistant.triage"):
  51. assert load_questions(str(path)) is None
  52. assert caplog.records
  53. @pytest.mark.parametrize(
  54. "bad_entry",
  55. [
  56. {"type": "mystery", "instructions": "do something"}, # unknown type
  57. {"type": "score", "instructions": "rate it", "criteria": {"0": "low"}}, # score dict
  58. { # noul criteria with a bad key
  59. "type": "noul",
  60. "instructions": "holds?",
  61. "criteria": {"true": "yes", "maybe": "hmm"},
  62. },
  63. "just a string", # non-object entry
  64. {"type": "choice", "instructions": "pick one", "criteria": []}, # empty choice
  65. {"type": "choice", "instructions": "pick one", "criteria": {}}, # empty choice dict
  66. {"type": "choice", "criteria": ["a", "b"]}, # missing instructions
  67. {"type": "score", "instructions": "rate it", "criteria": ["a", None]}, # None level
  68. {"type": "noul", "instructions": "holds?", "criteria": ["true"]}, # noul list
  69. ],
  70. )
  71. def test_load_malformed_returns_none_with_warning(tmp_path, caplog, bad_entry):
  72. path = _write_questions(
  73. tmp_path, {"good": {"type": "noul", "instructions": "ok?"}, "bad": bad_entry}
  74. )
  75. with caplog.at_level(logging.WARNING, logger="stt_daemon.assistant.triage"):
  76. assert load_questions(str(path)) is None
  77. assert caplog.records
  78. # --- todo 9: normalize ------------------------------------------------------
  79. def _score_questions():
  80. return {
  81. "severity": {
  82. "type": "score",
  83. "instructions": "How severe is the issue?",
  84. "criteria": ["none", "low", "high", "critical"],
  85. },
  86. "category": {
  87. "type": "choice",
  88. "instructions": "What is this about?",
  89. "criteria": {"support": "help needed", "sales": "buying"},
  90. },
  91. "churn_risk": {"type": "noul", "instructions": "User may leave."},
  92. }
  93. def test_normalize_choice_exact(tmp_path):
  94. analyzer = _analyzer(tmp_path, _score_questions())
  95. payload = {
  96. "model": "multilingual",
  97. "answers": {
  98. "category": {
  99. "type": "choice",
  100. "choice": "support",
  101. "confidence": 0.7,
  102. "answer_confidence": 0.4,
  103. }
  104. },
  105. "usage": {"input_tokens": 123, "output_tokens": 0},
  106. }
  107. result = analyzer.normalize(payload)
  108. assert result["order"] == ["category"]
  109. assert result["items"]["category"] == {
  110. "kind": "choice",
  111. "choice": "support",
  112. "confidence": 0.7,
  113. "answer_confidence": 0.4,
  114. "gate": 0.4,
  115. "flagged": True,
  116. }
  117. assert result["input_tokens"] == 123
  118. assert result["escalated"] is True
  119. def test_normalize_score_with_legend(tmp_path):
  120. analyzer = _analyzer(tmp_path, _score_questions())
  121. payload = {
  122. "answers": {
  123. "severity": {
  124. "type": "score",
  125. "score": 2.6,
  126. "legend": {"0": "none", "1": "low", "2": "high", "3": "critical"},
  127. "confidence": 0.5,
  128. "answer_confidence": 0.6,
  129. }
  130. },
  131. "usage": {"input_tokens": 7, "output_tokens": 0},
  132. }
  133. item = analyzer.normalize(payload)["items"]["severity"]
  134. assert item["kind"] == "score"
  135. assert item["expected"] == 2.6
  136. assert item["level"] == 3
  137. assert item["label"] == "critical"
  138. assert item["confidence"] == 0.5
  139. assert item["answer_confidence"] == 0.6
  140. assert item["gate"] == 0.6
  141. assert item["flagged"] is True
  142. def test_normalize_score_legend_fallback(tmp_path):
  143. analyzer = _analyzer(tmp_path, _score_questions())
  144. payload = {
  145. "answers": {
  146. "severity": {"type": "score", "score": 1.2, "confidence": 0.9, "answer_confidence": 0.9}
  147. }
  148. }
  149. item = analyzer.normalize(payload)["items"]["severity"]
  150. assert item["level"] == 1
  151. assert item["label"] == "low" # falls back to criteria[level]
  152. assert item["flagged"] is False # 1.2 < (4 - 1) * 0.5
  153. def test_normalize_noul_and_bare_payload(tmp_path):
  154. analyzer = _analyzer(tmp_path, _score_questions())
  155. bare = {
  156. "churn_risk": {"type": "noul", "noul": 0.9, "confidence": 0.9, "answer_confidence": 0.85}
  157. }
  158. result = analyzer.normalize(bare)
  159. assert result["order"] == ["churn_risk"]
  160. assert result["items"]["churn_risk"] == {
  161. "kind": "noul",
  162. "p": 0.9,
  163. "confidence": 0.9,
  164. "answer_confidence": 0.85,
  165. "gate": 0.85,
  166. "flagged": True,
  167. }
  168. assert result["input_tokens"] == 0
  169. def test_normalize_skips_unknown_qid_and_malformed(tmp_path):
  170. analyzer = _analyzer(tmp_path, _score_questions())
  171. payload = {
  172. "answers": {
  173. "ghost": {"type": "noul", "noul": 0.99, "confidence": 0.9, "answer_confidence": 0.9},
  174. "category": {"type": "choice", "choice": 42}, # malformed: no confidences
  175. "churn_risk": "nonsense", # malformed: not a dict
  176. },
  177. "usage": {"input_tokens": 3, "output_tokens": 0},
  178. }
  179. result = analyzer.normalize(payload)
  180. assert result["order"] == []
  181. assert result["items"] == {}
  182. assert result["escalated"] is False
  183. def test_normalize_ignores_side_channel_fields(tmp_path):
  184. analyzer = _analyzer(tmp_path, _score_questions())
  185. payload = {
  186. "answers": {
  187. "category": {
  188. "type": "choice",
  189. "choice": "sales",
  190. "confidence": 0.95,
  191. "answer_confidence": 0.9,
  192. "action": {"act" + "_probability": 0.99},
  193. }
  194. },
  195. "usage": {"input_tokens": 1, "output_tokens": 0},
  196. }
  197. result = analyzer.normalize(payload)
  198. assert result["items"]["category"]["flagged"] is False
  199. assert "act" + "_probability" not in json.dumps(result)
  200. source = Path(triage.__file__).read_text(encoding="utf-8")
  201. assert "act" + "_probability" not in source
  202. def test_normalize_gating_field_selectable(tmp_path):
  203. analyzer = _analyzer(tmp_path, _score_questions(), confidence_field="confidence")
  204. payload = {
  205. "answers": {
  206. "category": {
  207. "type": "choice",
  208. "choice": "sales",
  209. "confidence": 0.5,
  210. "answer_confidence": 0.99,
  211. }
  212. }
  213. }
  214. item = analyzer.normalize(payload)["items"]["category"]
  215. assert item["gate"] == 0.5 # gates on confidence, keeps both
  216. assert item["answer_confidence"] == 0.99
  217. assert item["flagged"] is True
  218. # --- todo 9: condensation ---------------------------------------------------
  219. def test_condense_keeps_newest_chronological_no_numerals(tmp_path):
  220. analyzer = _analyzer(tmp_path, _score_questions(), state_char_budget=40)
  221. window = ["alpha first", "beta second", "gamma third newest here"]
  222. state = analyzer.build_state(window)
  223. assert "gamma third newest here" in state
  224. assert "alpha first" not in state # oldest dropped under the budget
  225. lines = state.splitlines()
  226. assert all(line.startswith("- ") for line in lines)
  227. assert not any(ch.isdigit() for ch in state) # no numerals injected
  228. # chronological restore: kept lines stay in original order
  229. kept = [u for u in window if u in state]
  230. assert [line[2:] for line in lines] == kept
  231. def test_condense_empty_window(tmp_path):
  232. analyzer = _analyzer(tmp_path, _score_questions())
  233. assert analyzer.build_state([]) == ""
  234. # --- todo 10: escalation + extraction ---------------------------------------
  235. def _signals(choice_gate=0.9, noul_p=0.1, score_expected=0.2):
  236. return {
  237. "order": ["category", "churn_risk", "severity"],
  238. "items": {
  239. "category": {
  240. "kind": "choice",
  241. "choice": "sales",
  242. "confidence": choice_gate,
  243. "answer_confidence": choice_gate,
  244. "gate": choice_gate,
  245. "flagged": choice_gate < 0.85,
  246. },
  247. "churn_risk": {
  248. "kind": "noul",
  249. "p": noul_p,
  250. "confidence": noul_p,
  251. "answer_confidence": noul_p,
  252. "gate": noul_p,
  253. "flagged": noul_p >= 0.6,
  254. },
  255. "severity": {
  256. "kind": "score",
  257. "expected": score_expected,
  258. "level": 0,
  259. "label": "none",
  260. "confidence": 0.9,
  261. "answer_confidence": 0.9,
  262. "gate": 0.9,
  263. "flagged": score_expected >= 1.5,
  264. },
  265. },
  266. "escalated": False,
  267. "input_tokens": 0,
  268. }
  269. def test_escalation_arms_fire_independently(tmp_path):
  270. analyzer = _analyzer(tmp_path, _score_questions())
  271. assert analyzer.should_escalate(_signals(choice_gate=0.5)) is True # choice arm
  272. assert analyzer.should_escalate(_signals(noul_p=0.8)) is True # noul arm
  273. assert analyzer.should_escalate(_signals(score_expected=2.0)) is True # score arm
  274. assert analyzer.should_escalate(_signals()) is False
  275. def test_normalize_escalated_matches_should_escalate(tmp_path):
  276. analyzer = _analyzer(tmp_path, _score_questions())
  277. payload = {
  278. "answers": {
  279. "category": {
  280. "type": "choice",
  281. "choice": "sales",
  282. "confidence": 0.9,
  283. "answer_confidence": 0.9,
  284. },
  285. "churn_risk": {
  286. "type": "noul",
  287. "noul": 0.1,
  288. "confidence": 0.9,
  289. "answer_confidence": 0.9,
  290. },
  291. "severity": {
  292. "type": "score",
  293. "score": 0.1,
  294. "confidence": 0.9,
  295. "answer_confidence": 0.9,
  296. },
  297. }
  298. }
  299. result = analyzer.normalize(payload)
  300. assert result["escalated"] == analyzer.should_escalate(result) is False
  301. def test_build_extraction_prompt_words_not_dump(tmp_path):
  302. analyzer = _analyzer(tmp_path, _score_questions())
  303. prompt = analyzer.build_extraction_prompt(["please cancel my account"], _signals(noul_p=0.8))
  304. assert "please cancel my account" in prompt
  305. assert "churn_risk" in prompt # flagged signal named in words
  306. assert "category" in prompt # unflagged signals included too
  307. assert "severity" in prompt
  308. assert "Signals:" in prompt
  309. assert "Flagged signals:" not in prompt
  310. assert "{'kind'" not in prompt and '{"kind"' not in prompt # no raw dict dump
  311. for key in ("summary", "opportunities", "doubts", "action_items", "risks"):
  312. assert key in prompt
  313. def test_build_extraction_prompt_state_only_when_no_signals(tmp_path):
  314. analyzer = _analyzer(tmp_path, _score_questions())
  315. for signals in (None, {}, {"order": [], "items": {}}):
  316. prompt = analyzer.build_extraction_prompt(["hello there"], signals)
  317. assert "hello there" in prompt
  318. assert "Signals:" not in prompt
  319. assert "Flagged signals:" not in prompt
  320. assert "summary" in prompt # instruction keys still present
  321. assert "opportunities" in prompt
  322. prompt = analyzer.build_extraction_prompt(["hello there"], None)
  323. assert "Signals:" not in prompt
  324. assert "\nnone" not in prompt
  325. def _extraction_doc(**overrides):
  326. doc = {
  327. "summary": "user wants to cancel",
  328. "opportunities": ["winback offer"],
  329. "doubts": ["reason unclear"],
  330. "action_items": [{"what": "call back", "why": "save account"}],
  331. "risks": ["churn"],
  332. }
  333. doc.update(overrides)
  334. return json.dumps(doc)
  335. def test_parse_extraction_valid_fenced(tmp_path):
  336. analyzer = _analyzer(tmp_path, _score_questions())
  337. text = "```json\n" + _extraction_doc() + "\n```"
  338. parsed = analyzer.parse_extraction(text)
  339. assert parsed is not None
  340. assert parsed["summary"] == "user wants to cancel"
  341. assert parsed["action_items"] == [{"what": "call back", "why": "save account"}]
  342. def test_parse_extraction_invalid_returns_none(tmp_path):
  343. analyzer = _analyzer(tmp_path, _score_questions())
  344. assert analyzer.parse_extraction("not json at all {{{") is None
  345. assert analyzer.parse_extraction(json.dumps({"summary": "only"})) is None # bad shape
  346. assert analyzer.parse_extraction(None) is None
  347. assert analyzer.parse_extraction("") is None
  348. assert analyzer.parse_extraction("x" * 30000) is None # oversized
  349. def test_parse_extraction_caps_lists_and_strings(tmp_path):
  350. analyzer = _analyzer(tmp_path, _score_questions())
  351. parsed = analyzer.parse_extraction(
  352. _extraction_doc(
  353. opportunities=["o%d" % i for i in range(9)],
  354. doubts=["d%d" % i for i in range(7)],
  355. risks=["r%d" % i for i in range(6)],
  356. summary="s" * 2000,
  357. )
  358. )
  359. assert parsed is not None
  360. assert len(parsed["opportunities"]) == 5
  361. assert len(parsed["doubts"]) == 5
  362. assert len(parsed["risks"]) == 5
  363. assert len(parsed["summary"]) <= 500
  364. # --- todo 11: cached state() ------------------------------------------------
  365. def test_state_initial_loading_with_good_questions(tmp_path):
  366. analyzer = _analyzer(tmp_path, _score_questions())
  367. assert analyzer.state() == ("loading", "")
  368. def test_state_initial_no_questions_with_bad_path(tmp_path):
  369. missing = str(tmp_path / "missing.json")
  370. analyzer = TriageAnalyzer(_cfg(missing))
  371. state, reason = analyzer.state()
  372. assert state == "no_questions"
  373. assert missing in reason
  374. def test_state_vocabulary_has_no_extra_member():
  375. assert set(triage.TRIAGE_REASONS) == {
  376. "ok",
  377. "no_questions",
  378. "loading",
  379. "laya_missing",
  380. "disabled",
  381. "error",
  382. }
  383. assert "service" + "_down" not in triage.TRIAGE_REASONS
  384. def test_refresh_transitions(tmp_path):
  385. analyzer = _analyzer(tmp_path, _score_questions())
  386. analyzer.refresh("ok")
  387. assert analyzer.state() == ("ok", "")
  388. analyzer.refresh("laya_missing", "run: uv sync --extra triage")
  389. assert analyzer.state() == ("laya_missing", "run: uv sync --extra triage")
  390. analyzer.refresh("error", "boom")
  391. assert analyzer.state() == ("error", "boom")
  392. def test_refresh_unknown_state_ignored(tmp_path, caplog):
  393. analyzer = _analyzer(tmp_path, _score_questions())
  394. analyzer.refresh("ok")
  395. with caplog.at_level(logging.WARNING, logger="stt_daemon.assistant.triage"):
  396. analyzer.refresh("bogus_state")
  397. assert analyzer.state() == ("ok", "")
  398. assert caplog.records
  399. def test_no_questions_state_cannot_be_overridden(tmp_path):
  400. analyzer = TriageAnalyzer(_cfg(str(tmp_path / "missing.json")))
  401. assert analyzer.state()[0] == "no_questions"
  402. analyzer.refresh("ok")
  403. analyzer.refresh("error", "boom")
  404. assert analyzer.state()[0] == "no_questions"
  405. def test_state_does_not_touch_lane(tmp_path):
  406. analyzer = _analyzer(tmp_path, _score_questions())
  407. class StrictLane:
  408. def __init__(self):
  409. self.calls = []
  410. def __getattr__(self, name):
  411. def record(*args, **kwargs):
  412. self.calls.append(name)
  413. return record
  414. lane = StrictLane()
  415. for _ in range(3):
  416. analyzer.state()
  417. analyzer.refresh("ok")
  418. analyzer.state()
  419. assert lane.calls == []
  420. # --- F3: end-to-end triage flow on fakes ------------------------------------
  421. EXTRACT_JSON = (
  422. '{"summary":"s","opportunities":[],"doubts":[],"action_items":[],"risks":[]}'
  423. )
  424. class _Clock:
  425. """Injected clock: no sleeps, fully deterministic."""
  426. def __init__(self, start=1000.0):
  427. self.t = float(start)
  428. def __call__(self):
  429. return self.t
  430. def advance(self, dt):
  431. self.t += float(dt)
  432. class _FakeSubscriber:
  433. def events(self):
  434. return iter(())
  435. def close(self):
  436. pass
  437. class _FakeLane:
  438. """Duck-typed LayaLane: canned-ready, records submits, counts drops."""
  439. def __init__(self):
  440. self.submits: list[tuple] = []
  441. self.drops = 0
  442. def ready(self) -> bool:
  443. return True
  444. def state(self):
  445. return ("ok", "")
  446. def submit(self, state, questions, lang, generation):
  447. self.submits.append((state, questions, lang, generation))
  448. return True
  449. def drop_pending(self):
  450. self.drops += 1
  451. return 1
  452. def _answers(noul_p=0.1):
  453. """A full laya-shaped answers payload for the example questions."""
  454. answers = {
  455. "category": {
  456. "type": "choice",
  457. "choice": "sales",
  458. "confidence": 0.9,
  459. "answer_confidence": 0.9,
  460. },
  461. "urgency": {
  462. "type": "choice",
  463. "choice": "low",
  464. "confidence": 0.9,
  465. "answer_confidence": 0.9,
  466. },
  467. }
  468. for qid in (
  469. "frustration",
  470. "churn_risk",
  471. "needs_action",
  472. "needs_clarification",
  473. "contains_idea",
  474. "business_opportunity",
  475. "positive_sentiment",
  476. ):
  477. answers[qid] = {
  478. "type": "noul",
  479. "noul": noul_p,
  480. "confidence": 0.9,
  481. "answer_confidence": 0.9,
  482. }
  483. return {"model": "multilingual", "answers": answers, "usage": {"input_tokens": 42}}
  484. def test_end_to_end_triage_flow():
  485. """signals -> content -> cleared across service, analyzer, lane, and ViewState.
  486. The lane result is wired by calling `service._on_triage_result(gen, payload)`
  487. directly (the runner does exactly this in production); the extraction job is
  488. drained with `job.run()` so no worker thread is started.
  489. """
  490. cfg = Config()
  491. cfg.assistant.turn_gap_s = 0.5
  492. cfg.triage.enabled = True
  493. cfg.triage.questions_file = EXAMPLE_PATH
  494. clock = _Clock(1000.0)
  495. lane = _FakeLane()
  496. emitted: list[dict] = []
  497. def emit(type_, **fields):
  498. emitted.append({"type": type_, **fields})
  499. analyzer = TriageAnalyzer(cfg.triage)
  500. service = AssistantService(
  501. cfg,
  502. _FakeSubscriber(),
  503. emit=emit,
  504. time_fn=clock,
  505. chat_fn=lambda text: "reply",
  506. triage=analyzer,
  507. laya_lane=lane,
  508. triage_extract_fn=lambda prompt: EXTRACT_JSON,
  509. )
  510. def triage_events():
  511. return [e for e in emitted if e["type"] == "triage"]
  512. def extract_jobs():
  513. return [j for j in service._inference._queue if j.name == "triage-extract"]
  514. # 1. settled turn -> exactly one lane submit, lang + generation recorded
  515. assert service.toggle_chat() is True
  516. service.handle_event(
  517. {"type": events.TRANSCRIPT, "text": "hola, necesito ayuda", "lang": "es"}
  518. )
  519. clock.advance(1.0)
  520. service.tick()
  521. assert len(lane.submits) == 1
  522. state_text, questions, lang, gen1 = lane.submits[0]
  523. assert "hola, necesito ayuda" in state_text
  524. assert questions is analyzer.questions
  525. assert lang == "es"
  526. assert gen1 == 0
  527. # 2. non-escalating result -> signals plus one state+signals extraction job
  528. service._on_triage_result(gen1, _answers(noul_p=0.1))
  529. assert [e["stage"] for e in triage_events()] == ["signals"]
  530. assert triage_events()[0]["triage"]["escalated"] is False
  531. assert service.last_triage is not None
  532. assert service.last_triage["escalated"] is False
  533. assert len(extract_jobs()) == 1
  534. # 3. escalating turn -> second submit, then a second extract job
  535. service.handle_event(
  536. {"type": events.TRANSCRIPT, "text": "quiero cancelar mi suscripcion", "lang": "es"}
  537. )
  538. clock.advance(1.0)
  539. service.tick()
  540. assert len(lane.submits) == 2
  541. gen2 = lane.submits[1][3]
  542. service._on_triage_result(gen2, _answers(noul_p=0.9))
  543. assert [e["stage"] for e in triage_events()] == ["signals", "signals"]
  544. assert triage_events()[1]["triage"]["escalated"] is True
  545. jobs = extract_jobs()
  546. assert len(jobs) == 2
  547. # 4. run the extraction jobs directly (deterministic, no worker thread)
  548. for job in jobs:
  549. job.run()
  550. content_events = [e for e in triage_events() if e["stage"] == "content"]
  551. assert len(content_events) == 2
  552. merged = content_events[1]["triage"]
  553. assert merged["content"]["summary"] == "s"
  554. assert merged["escalated"] is True # signals preserved under the content
  555. assert merged["order"] == triage_events()[1]["triage"]["order"]
  556. # 5. drive the emitted stream through a fresh ViewState
  557. view = ViewState()
  558. for event in emitted:
  559. view.ingest(event)
  560. analyzer.refresh("ok") # the lane callback does this in production
  561. view.ingest({"type": "status", **service.status()})
  562. assert view.triage is not None
  563. assert view.triage["content"]["summary"] == "s"
  564. lines = view.triage_lines()
  565. assert lines
  566. assert any("summary" in line and "s" in line for line in lines)
  567. # 6. clear -> cleared event empties the panel and bumps the epoch
  568. baseline_epoch = view.triage_epoch
  569. already = len(emitted)
  570. service.clear()
  571. for event in emitted[already:]:
  572. view.ingest(event)
  573. assert view.triage is None
  574. assert view.triage_epoch > baseline_epoch
  575. # a stale result after clear must not re-populate the panel
  576. service._on_triage_result(gen2, _answers(noul_p=0.9))
  577. assert [e["stage"] for e in triage_events()] == [
  578. "signals",
  579. "signals",
  580. "content",
  581. "content",
  582. "cleared",
  583. ]
  584. assert view.triage is None
  585. assert lane.drops == 1
  586. # 7. disabled control: no triage/lane -> status disabled, clear emits nothing
  587. cfg2 = Config()
  588. clock2 = _Clock(2000.0)
  589. emitted2: list[dict] = []
  590. def emit2(type_, **fields):
  591. emitted2.append({"type": type_, **fields})
  592. plain = AssistantService(cfg2, _FakeSubscriber(), emit=emit2, time_fn=clock2)
  593. assert plain.status()["triage_state"] == "disabled"
  594. assert plain.clear() is True
  595. assert [e for e in emitted2 if e["type"] == "triage"] == []