test_assistant_service.py 39 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274
  1. """Tests for the assistant service (Phase 2) using synthetic event streams."""
  2. import itertools
  3. import threading
  4. import time
  5. from typing import Any
  6. from stt_daemon import events
  7. from stt_daemon.assistant.inference import InferenceQueue
  8. from stt_daemon.assistant.service import EXECUTE_BUSY, AssistantService
  9. from stt_daemon.config import Config
  10. _sequence = itertools.count(1)
  11. class Clock:
  12. def __init__(self, start=1000.0):
  13. self.t = float(start)
  14. def __call__(self):
  15. return self.t
  16. def advance(self, dt):
  17. self.t += float(dt)
  18. class FakeSubscriber:
  19. def events(self):
  20. return iter(())
  21. def close(self):
  22. pass
  23. def _service():
  24. cfg = Config()
  25. clock = Clock()
  26. emitted: list[dict] = []
  27. def emit(type_, **fields):
  28. emitted.append({"type": type_, **fields})
  29. service = AssistantService(cfg, FakeSubscriber(), emit=emit, time_fn=clock)
  30. return service, emitted, clock
  31. def _ev(type_, clock, **fields):
  32. event = {"v": 1, "type": type_, "seq": next(_sequence), "ts": clock()}
  33. event.update(fields)
  34. return event
  35. def _transcript(clock, text, ts=None):
  36. return {
  37. "v": 1,
  38. "type": events.TRANSCRIPT,
  39. "seq": next(_sequence),
  40. "ts": clock() if ts is None else ts,
  41. "text": text,
  42. }
  43. def test_ready_requires_matching_segment_done():
  44. service, _, clock = _service()
  45. service.handle_event(_ev(events.SPEECH_START, clock))
  46. assert service.ready() is False
  47. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  48. assert service.ready() is False # awaiting segment 1
  49. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  50. assert service.ready() is True
  51. def test_stale_segment_done_does_not_satisfy_later_turn():
  52. service, _, clock = _service()
  53. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  54. service.handle_event(_ev(events.SPEECH_START, clock))
  55. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=2))
  56. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  57. assert service.ready() is False # still awaiting segment 2
  58. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=2, emitted=True))
  59. assert service.ready() is True
  60. def test_gated_segment_done_clears_readiness():
  61. service, _, clock = _service()
  62. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=3))
  63. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=3, emitted=False))
  64. assert service.ready() is True
  65. def test_speech_stop_without_seg_id_clears_readiness():
  66. """A discarded turn closes with speech_stop but no seg_id; readiness must recover."""
  67. service, _, clock = _service()
  68. service.handle_event(_ev(events.SPEECH_START, clock))
  69. assert service.ready() is False
  70. service.handle_event(_ev(events.SPEECH_STOP, clock)) # discarded: no seg_id
  71. assert service.speech_open is False
  72. assert service.awaited_seg_id is None
  73. assert service.ready() is True
  74. def test_status_resets_stale_speech_state_on_reconnect():
  75. service, _, clock = _service()
  76. service.handle_event(_ev(events.SPEECH_START, clock))
  77. assert service.speech_open is True
  78. service.handle_event(_ev(events.STATUS, clock, capture="x", segments_done=0))
  79. assert service.speech_open is False
  80. assert service.awaited_seg_id is None
  81. assert service.ready() is True
  82. def test_command_buffer_uses_arm_timestamp():
  83. service, _, clock = _service()
  84. clock.advance(10)
  85. assert service.arm() is True
  86. arm_ts = service.arm_ts
  87. assert arm_ts is not None
  88. # Replayed transcript from before arming is ignored.
  89. service.handle_event(_transcript(clock, "old", ts=arm_ts - 5))
  90. assert service.command_text == ""
  91. service.handle_event(_transcript(clock, "what time is it"))
  92. assert service.command_text == "what time is it"
  93. def test_mutual_exclusion_between_command_and_chat():
  94. service, _, _ = _service()
  95. assert service.toggle_chat() is True
  96. assert service.arm() is False # cannot arm in chat
  97. assert service.toggle_chat() is True # back to idle
  98. assert service.arm() is True
  99. assert service.toggle_chat() is False # cannot chat while armed
  100. def test_arm_timeout_disarms_and_emits_error():
  101. service, emitted, clock = _service()
  102. service.arm()
  103. clock.advance(service.cfg.assistant.command_arm_timeout_s + 0.1)
  104. service.tick()
  105. assert service.modes.mode.value == "idle"
  106. assert service.command_text == ""
  107. assert any(e["type"] == "error" and e["code"] == "arm_timeout" for e in emitted)
  108. def test_long_speech_does_not_disarm():
  109. service, emitted, clock = _service()
  110. service.arm()
  111. service.handle_event(_ev(events.SPEECH_START, clock))
  112. clock.advance(service.cfg.assistant.command_arm_timeout_s + 30.0)
  113. service.tick()
  114. assert service.modes.mode.value == "command_armed"
  115. assert not any(e["type"] == "error" for e in emitted)
  116. def test_captured_command_survives_arm_timeout():
  117. service, emitted, clock = _service()
  118. service.arm()
  119. service.handle_event(_transcript(clock, "what time is it"))
  120. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  121. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  122. clock.advance(service.cfg.assistant.command_arm_timeout_s + 30.0)
  123. service.tick()
  124. assert service.modes.mode.value == "command_armed"
  125. assert service.command_text == "what time is it"
  126. assert not any(e["type"] == "error" for e in emitted)
  127. def test_stt_stall_cancels_armed_command():
  128. service, emitted, clock = _service()
  129. service.handle_event(_ev(events.HEARTBEAT, clock, worker_stall_s=0.0))
  130. service.arm()
  131. clock.advance(service.cfg.assistant.stall_timeout_ms / 1000.0 + 0.5)
  132. service.tick()
  133. assert service.modes.mode.value == "idle"
  134. assert any(e["type"] == "error" and e["code"] == "stt_stall" for e in emitted)
  135. def test_worker_stall_marks_stt_degraded():
  136. service, _, clock = _service()
  137. long_stall = service.cfg.assistant.worker_stall_ms / 1000.0 + 1.0
  138. service.handle_event(_ev(events.HEARTBEAT, clock, worker_stall_s=long_stall))
  139. assert service.stt_ok() is False
  140. assert service.ready() is False
  141. def test_chat_idle_timeout_exits_chat():
  142. service, _, clock = _service()
  143. assert service.toggle_chat() is True
  144. clock.advance(service.cfg.assistant.chat_idle_timeout_s + 0.1)
  145. service.tick()
  146. assert service.modes.mode.value == "idle"
  147. def test_chat_exit_phrase_exits_chat():
  148. service, _, clock = _service()
  149. assert service.toggle_chat() is True
  150. service.handle_event(_transcript(clock, "That's all."))
  151. assert service.modes.mode.value == "idle"
  152. assert service.chat_history == []
  153. def test_viewer_detached_exits_chat_and_forgets_history():
  154. service, _, clock = _service()
  155. assert service.toggle_chat() is True
  156. service.handle_event(_transcript(clock, "hello"))
  157. assert service.viewer_detached() is True
  158. assert service.modes.mode.value == "idle"
  159. assert service.chat_history == []
  160. def test_viewer_detached_is_noop_outside_chat():
  161. service, _, _ = _service()
  162. assert service.viewer_detached() is False
  163. assert service.modes.mode.value == "idle"
  164. def test_exit_chat_invokes_chat_reset():
  165. cfg = Config()
  166. resets: list[int] = []
  167. service = AssistantService(
  168. cfg, FakeSubscriber(), time_fn=Clock(), chat_reset=lambda: resets.append(1)
  169. )
  170. service.toggle_chat()
  171. service.viewer_detached()
  172. assert resets == [1]
  173. def test_chat_accumulates_turns():
  174. service, _, clock = _service()
  175. service.toggle_chat()
  176. for text in ("hello there", "how are you"):
  177. service.handle_event(_transcript(clock, text))
  178. assert service.chat_history == ["hello there", "how are you"]
  179. def test_execute_requires_readiness():
  180. service, _, clock = _service()
  181. service.arm()
  182. service.handle_event(_transcript(clock, "do it"))
  183. service.handle_event(_ev(events.SPEECH_START, clock))
  184. assert service.execute() is None # speech still open
  185. assert service.modes.mode.value == "command_armed"
  186. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  187. assert service.execute() is None # awaiting segment 1
  188. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  189. assert service.execute() == "do it"
  190. assert service.modes.mode.value == "executing"
  191. assert service.finish() is True
  192. assert service.modes.mode.value == "idle"
  193. def test_execute_reports_busy_when_queue_rejects():
  194. service, emitted, clock = _service()
  195. # A zero-capacity lane rejects the priority command job on submit.
  196. service._inference = InferenceQueue(size=0)
  197. service.executor = object()
  198. service.arm()
  199. service.handle_event(_transcript(clock, "do it"))
  200. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  201. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  202. assert service.execute() is EXECUTE_BUSY
  203. assert service.modes.mode.value == "idle"
  204. assert not any(e.get("code") == "busy" for e in emitted)
  205. def test_turn_ended_requires_quiet_gap():
  206. service, _, clock = _service()
  207. service.handle_event(_ev(events.SPEECH_STOP, clock, seg_id=1))
  208. service.handle_event(_ev(events.SEGMENT_DONE, clock, seg_id=1, emitted=True))
  209. assert service.ready() is True
  210. assert service.turn_ended() is False
  211. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  212. assert service.turn_ended() is True
  213. def test_emits_mode_and_command_text():
  214. service, emitted, clock = _service()
  215. service.arm()
  216. assert {"type": "mode", "mode": "command_armed"} in emitted
  217. service.handle_event(_transcript(clock, "hi"))
  218. assert any(e["type"] == "command_text" and e["text"] == "hi" for e in emitted)
  219. def test_cancel_clears_command_buffer():
  220. service, _, clock = _service()
  221. service.arm()
  222. service.handle_event(_transcript(clock, "hi"))
  223. assert service.cancel() is True
  224. assert service.modes.mode.value == "idle"
  225. assert service.command_text == ""
  226. assert service.arm_ts is None
  227. def test_pending_chat_is_not_lost_across_threads():
  228. """Transcripts appended on the reader thread while the ticker dispatches
  229. must all reach the model: the join-and-clear in _dispatch_chat is atomic."""
  230. cfg = Config()
  231. cfg.assistant.turn_gap_s = 0.01
  232. dispatched: list[str] = []
  233. def emit(type_, **fields):
  234. if type_ == "chat_message" and fields.get("role") == "user":
  235. dispatched.append(fields["text"])
  236. service = AssistantService(cfg, FakeSubscriber(), emit=emit, chat_fn=lambda text: "ok")
  237. service.toggle_chat()
  238. total = 200
  239. producer = threading.Thread(
  240. target=lambda: [
  241. service.handle_event(
  242. {
  243. "v": 1,
  244. "type": events.TRANSCRIPT,
  245. "seq": i,
  246. "ts": time.time(),
  247. "text": f"w{i}",
  248. }
  249. )
  250. for i in range(total)
  251. ],
  252. daemon=True,
  253. )
  254. producer.start()
  255. deadline = time.time() + 10.0
  256. while time.time() < deadline:
  257. service.tick()
  258. words = sum(len(text.split()) for text in dispatched)
  259. if not producer.is_alive() and words == total:
  260. break
  261. time.sleep(0.002)
  262. producer.join(timeout=5)
  263. assert sum(len(text.split()) for text in dispatched) == total
  264. def test_chat_notify_emits_compaction_note():
  265. service, emitted, _ = _service()
  266. service.chat_notify("compacted", turns=4)
  267. assert {
  268. "type": "chat_message",
  269. "role": "note",
  270. "text": "Compacted 4 earlier turns into a summary.",
  271. } in emitted
  272. def test_chat_notify_emits_truncated_error():
  273. service, emitted, _ = _service()
  274. service.chat_notify("truncated", message="cut off")
  275. assert {"type": "error", "code": "truncated", "message": "cut off"} in emitted
  276. def test_clear_empties_chat_history():
  277. service, emitted, clock = _service()
  278. service.toggle_chat()
  279. for text in ("hello", "how are you"):
  280. service.handle_event(_transcript(clock, text))
  281. assert service.chat_history == ["hello", "how are you"]
  282. assert len(service._pending_chat) == 2
  283. assert service.clear() is True
  284. assert service.chat_history == []
  285. assert service._pending_chat == []
  286. assert {"type": "cleared"} in emitted
  287. def test_clear_invokes_chat_reset():
  288. cfg = Config()
  289. resets: list[int] = []
  290. service = AssistantService(
  291. cfg, FakeSubscriber(), time_fn=Clock(), chat_reset=lambda: resets.append(1)
  292. )
  293. service.toggle_chat()
  294. service.clear()
  295. assert resets == [1]
  296. def test_clear_does_not_change_mode():
  297. service, _, clock = _service()
  298. service.toggle_chat()
  299. assert service.modes.mode.value == "chat"
  300. service.clear()
  301. assert service.modes.mode.value == "chat"
  302. def test_clear_always_returns_true():
  303. service, _, _ = _service()
  304. assert service.clear() is True
  305. service.toggle_chat()
  306. service.handle_event(_ev(events.TRANSCRIPT, Clock(), text="test"))
  307. assert service.clear() is True
  308. def test_heartbeat_emits_inference_depth_and_in_flight():
  309. """Calling tick() emits heartbeat with inference_depth (int) and inference_in_flight (bool)."""
  310. service, emitted, clock = _service()
  311. service.tick()
  312. heartbeats = [e for e in emitted if e["type"] == "heartbeat"]
  313. assert len(heartbeats) == 1
  314. heartbeat = heartbeats[0]
  315. assert "inference_depth" in heartbeat
  316. assert isinstance(heartbeat["inference_depth"], int)
  317. assert "inference_in_flight" in heartbeat
  318. assert isinstance(heartbeat["inference_in_flight"], bool)
  319. def test_status_contains_inference_depth_and_in_flight():
  320. """status() snapshot contains inference_depth (int) and inference_in_flight (bool)."""
  321. service, _, _ = _service()
  322. status = service.status()
  323. assert "inference_depth" in status
  324. assert isinstance(status["inference_depth"], int)
  325. assert "inference_in_flight" in status
  326. assert isinstance(status["inference_in_flight"], bool)
  327. _AUTO = object()
  328. class FakeTriage:
  329. def __init__(
  330. self,
  331. questions=_AUTO,
  332. state_pair=("ok", ""),
  333. escalate=False,
  334. parsed=_AUTO,
  335. normalized=_AUTO,
  336. ):
  337. if questions is _AUTO:
  338. questions = {"urgency": {"type": "noul", "instructions": "how urgent"}}
  339. self._questions = questions
  340. self._state_pair = state_pair
  341. self._escalate = escalate
  342. self._parsed = parsed
  343. self._normalized = normalized
  344. self.build_state_calls: list[tuple] = []
  345. self.normalize_calls: list = []
  346. self.prompt_calls: list[tuple] = []
  347. @property
  348. def questions(self):
  349. return self._questions
  350. def state(self):
  351. return self._state_pair
  352. def build_state(self, window):
  353. self.build_state_calls.append(tuple(window))
  354. return "\n".join("- " + str(item) for item in (window or []))
  355. def normalize(self, payload):
  356. self.normalize_calls.append(payload)
  357. if self._normalized is not _AUTO:
  358. return self._normalized
  359. if not isinstance(payload, dict):
  360. return {}
  361. signals = {"order": [], "items": {}, "escalated": False, "input_tokens": 0}
  362. signals.update(payload)
  363. if self._escalate:
  364. signals["escalated"] = True
  365. return signals
  366. def build_extraction_prompt(self, window, signals):
  367. self.prompt_calls.append((tuple(window), dict(signals)))
  368. return "EXTRACT"
  369. def parse_extraction(self, text):
  370. if self._parsed is _AUTO:
  371. return {
  372. "summary": "s",
  373. "opportunities": [],
  374. "doubts": [],
  375. "action_items": [],
  376. "risks": [],
  377. }
  378. return self._parsed
  379. class FakeLane:
  380. def __init__(self, ready=True):
  381. self._ready = ready
  382. self.submits: list[tuple] = []
  383. self.drops = 0
  384. self.predict_gate = threading.Event()
  385. def ready(self):
  386. return self._ready
  387. def state(self):
  388. return ("ok", "") if self._ready else ("loading", "")
  389. def submit(self, state, questions, lang, generation):
  390. self.submits.append((state, questions, lang, generation))
  391. return True
  392. def drop_pending(self):
  393. self.drops += 1
  394. def _ok_reply(text):
  395. return "ok"
  396. def _triage_service(triage=None, lane=None, extract_fn=None, chat_fn: Any = _AUTO):
  397. cfg = Config()
  398. cfg.assistant.turn_gap_s = 0.5
  399. clock = Clock()
  400. emitted: list[dict] = []
  401. def emit(type_, **fields):
  402. emitted.append({"type": type_, **fields})
  403. if triage is None:
  404. triage = FakeTriage()
  405. if lane is None:
  406. lane = FakeLane()
  407. if chat_fn is _AUTO:
  408. chat_fn = _ok_reply
  409. service = AssistantService(
  410. cfg,
  411. FakeSubscriber(),
  412. emit=emit,
  413. time_fn=clock,
  414. triage=triage,
  415. laya_lane=lane,
  416. triage_extract_fn=extract_fn,
  417. chat_fn=chat_fn,
  418. )
  419. return service, emitted, clock, triage, lane
  420. def _triage_events(emitted):
  421. return [e for e in emitted if e["type"] == "triage"]
  422. def _escalated_payload():
  423. return {"order": [], "items": {}, "escalated": True, "input_tokens": 3}
  424. def test_triage_status_disabled_when_no_analyzer():
  425. service, _, _ = _service()
  426. status = service.status()
  427. assert status["triage_state"] == "disabled"
  428. assert status["triage_reason"] == ""
  429. assert status["triage"] is None
  430. def test_triage_status_reflects_analyzer():
  431. service, _, _, _, _ = _triage_service(
  432. triage=FakeTriage(state_pair=("loading", "warming up"))
  433. )
  434. status = service.status()
  435. assert status["triage_state"] == "loading"
  436. assert status["triage_reason"] == "warming up"
  437. assert status["triage"] is None
  438. def test_triage_captures_transcript_lang():
  439. service, _, clock, _, _ = _triage_service()
  440. assert service._last_transcript_lang is None
  441. event = _transcript(clock, "hola")
  442. event["lang"] = "es"
  443. service.handle_event(event)
  444. assert service._last_transcript_lang == "es"
  445. service.handle_event(_transcript(clock, "hello"))
  446. assert service._last_transcript_lang is None
  447. def test_maybe_triage_passes_lang_to_lane():
  448. service, _, clock, _, lane = _triage_service()
  449. event = _transcript(clock, "hola")
  450. event["lang"] = "es"
  451. service.handle_event(event)
  452. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  453. service.tick()
  454. assert len(lane.submits) == 1
  455. _, _, lang, _ = lane.submits[0]
  456. assert lang == "es"
  457. def test_maybe_triage_runs_in_idle_mode():
  458. service, _, clock, triage, lane = _triage_service()
  459. service.handle_event(_transcript(clock, "hello"))
  460. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  461. service.tick()
  462. assert len(lane.submits) == 1
  463. state, questions, lang, gen = lane.submits[0]
  464. assert state == "- hello"
  465. assert questions is triage.questions
  466. assert lang is None
  467. assert gen == 0
  468. assert triage.build_state_calls == [("hello",)]
  469. assert service.triage_history == ["hello"]
  470. def test_maybe_triage_uses_transcript_buffer_not_chat_history():
  471. service, _, clock, triage, lane = _triage_service(chat_fn=None)
  472. service.chat_history = ["stale chat only"]
  473. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  474. service.tick()
  475. assert lane.submits == []
  476. assert triage.build_state_calls == []
  477. service.handle_event(_transcript(clock, "hello"))
  478. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  479. service.tick()
  480. assert len(lane.submits) == 1
  481. assert triage.build_state_calls == [("hello",)]
  482. def test_maybe_triage_requires_settled_turn():
  483. service, _, clock, _, lane = _triage_service()
  484. service.handle_event(_transcript(clock, "hello"))
  485. service.tick()
  486. assert lane.submits == []
  487. def test_maybe_triage_skips_unready_lane():
  488. service, _, clock, _, lane = _triage_service(lane=FakeLane(ready=False))
  489. service.handle_event(_transcript(clock, "hello"))
  490. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  491. service.tick()
  492. assert lane.submits == []
  493. def test_maybe_triage_skips_without_questions():
  494. service, _, clock, triage, lane = _triage_service(
  495. triage=FakeTriage(questions=None)
  496. )
  497. service.handle_event(_transcript(clock, "hello"))
  498. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  499. service.tick()
  500. assert lane.submits == []
  501. assert triage.build_state_calls == []
  502. def test_maybe_triage_dedupes_identical_window():
  503. service, _, clock, triage, lane = _triage_service()
  504. service.handle_event(_transcript(clock, "hello"))
  505. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  506. service.tick()
  507. assert len(lane.submits) == 1
  508. state, questions, lang, gen = lane.submits[0]
  509. assert state == "- hello"
  510. assert questions is triage.questions
  511. assert lang is None
  512. assert gen == 0
  513. service.tick()
  514. service.tick()
  515. assert len(lane.submits) == 1
  516. service.handle_event(_transcript(clock, "second"))
  517. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  518. service.tick()
  519. assert len(lane.submits) == 2
  520. assert lane.submits[1][0] == "- hello\n- second"
  521. def test_second_transcript_produces_second_submit():
  522. service, _, clock, _, lane = _triage_service()
  523. service.handle_event(_transcript(clock, "first"))
  524. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  525. service.tick()
  526. assert len(lane.submits) == 1
  527. service.handle_event(_transcript(clock, "second"))
  528. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  529. service.tick()
  530. assert len(lane.submits) == 2
  531. assert lane.submits[1][0] == "- first\n- second"
  532. def test_tick_under_50ms_with_blocked_lane():
  533. service, _, clock, _, lane = _triage_service()
  534. assert not lane.predict_gate.is_set()
  535. service.toggle_chat()
  536. service.handle_event(_transcript(clock, "hello"))
  537. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  538. start = time.perf_counter()
  539. service.tick()
  540. elapsed = time.perf_counter() - start
  541. assert len(lane.submits) == 1
  542. assert elapsed < 0.05
  543. def test_on_triage_result_emits_signals():
  544. service, emitted, clock, _, lane = _triage_service()
  545. service.toggle_chat()
  546. service.handle_event(_transcript(clock, "hello"))
  547. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  548. service.tick()
  549. _, _, _, gen = lane.submits[0]
  550. payload = {
  551. "order": ["urgency"],
  552. "items": {"urgency": {"kind": "noul"}},
  553. "escalated": False,
  554. "input_tokens": 12,
  555. }
  556. service._on_triage_result(gen, payload)
  557. staged = _triage_events(emitted)
  558. assert len(staged) == 1
  559. assert staged[0]["stage"] == "signals"
  560. assert staged[0]["triage"] == service.last_triage
  561. assert staged[0]["triage"]["escalated"] is False
  562. assert staged[0]["triage"]["input_tokens"] == 12
  563. assert service.status()["triage"] == service.last_triage
  564. def test_on_triage_result_suppresses_stale_generation():
  565. service, emitted, clock, _, lane = _triage_service()
  566. service.toggle_chat()
  567. service.handle_event(_transcript(clock, "hello"))
  568. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  569. service.tick()
  570. _, _, _, gen = lane.submits[0]
  571. service.clear()
  572. service._on_triage_result(gen, _escalated_payload())
  573. assert service.last_triage is None
  574. assert [e for e in _triage_events(emitted) if e.get("stage") == "signals"] == []
  575. service.handle_event(_transcript(clock, "hello"))
  576. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  577. service.tick()
  578. assert len(lane.submits) == 2
  579. def test_on_triage_result_accepts_non_chat_mode():
  580. service, emitted, clock, _, lane = _triage_service()
  581. service.handle_event(_transcript(clock, "hello"))
  582. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  583. service.tick()
  584. _, _, _, gen = lane.submits[0]
  585. window = service._last_triage_window
  586. service._on_triage_result(gen, _escalated_payload())
  587. assert service.last_triage is not None
  588. assert service._last_triage_window == window
  589. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
  590. def test_on_triage_result_ignores_none_payload():
  591. service, emitted, clock, _, lane = _triage_service()
  592. service.toggle_chat()
  593. service.handle_event(_transcript(clock, "hello"))
  594. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  595. service.tick()
  596. _, _, _, gen = lane.submits[0]
  597. service._on_triage_result(gen, None)
  598. assert service.last_triage is None
  599. assert _triage_events(emitted) == []
  600. def test_on_triage_result_ignores_empty_signals():
  601. service, emitted, clock, _, lane = _triage_service(
  602. triage=FakeTriage(normalized={})
  603. )
  604. service.toggle_chat()
  605. service.handle_event(_transcript(clock, "hello"))
  606. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  607. service.tick()
  608. _, _, _, gen = lane.submits[0]
  609. service._on_triage_result(gen, _escalated_payload())
  610. assert service.last_triage is None
  611. assert _triage_events(emitted) == []
  612. def test_escalation_enqueues_extract_job():
  613. service, emitted, clock, _, lane = _triage_service(
  614. triage=FakeTriage(escalate=True), extract_fn=lambda prompt: "RAW"
  615. )
  616. service.toggle_chat()
  617. service.handle_event(_transcript(clock, "we might churn"))
  618. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  619. service.tick()
  620. _, _, _, gen = lane.submits[0]
  621. service._on_triage_result(
  622. gen, {"order": [], "items": {}, "escalated": False, "input_tokens": 0}
  623. )
  624. assert service.last_triage is not None
  625. assert service.last_triage["escalated"] is True
  626. assert service._inference.depth() == 2
  627. assert [j.name for j in service._inference._queue] == ["chat", "triage-extract"]
  628. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
  629. def test_no_extract_without_escalation():
  630. service, _, clock, _, lane = _triage_service(extract_fn=lambda prompt: "RAW")
  631. service.toggle_chat()
  632. service.handle_event(_transcript(clock, "all calm"))
  633. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  634. service.tick()
  635. _, _, _, gen = lane.submits[0]
  636. service._on_triage_result(
  637. gen, {"order": [], "items": {}, "escalated": False, "input_tokens": 0}
  638. )
  639. assert service.last_triage is not None
  640. assert service._inference.depth() == 1
  641. assert [j.name for j in service._inference._queue] == ["chat"]
  642. def test_no_extract_when_disabled_in_config():
  643. service, _, clock, _, lane = _triage_service(
  644. triage=FakeTriage(escalate=True), extract_fn=lambda prompt: "RAW"
  645. )
  646. service.cfg.triage.extract_enabled = False
  647. service.toggle_chat()
  648. service.handle_event(_transcript(clock, "churn risk"))
  649. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  650. service.tick()
  651. _, _, _, gen = lane.submits[0]
  652. service._on_triage_result(gen, _escalated_payload())
  653. assert service.last_triage is not None
  654. assert service.last_triage["escalated"] is True
  655. assert [j.name for j in service._inference._queue] == ["chat"]
  656. def test_extract_refused_when_queue_full():
  657. service, emitted, clock, _, lane = _triage_service(
  658. triage=FakeTriage(escalate=True), extract_fn=lambda prompt: "RAW"
  659. )
  660. service._inference = InferenceQueue(size=2)
  661. service.toggle_chat()
  662. service.handle_event(_transcript(clock, "churn risk"))
  663. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  664. service.tick()
  665. assert service._inference.depth() == 1
  666. _, _, _, gen = lane.submits[0]
  667. service._on_triage_result(gen, _escalated_payload())
  668. assert service._inference.depth() == 1
  669. assert service._inference._queue[0].name == "chat"
  670. assert service.last_triage is not None
  671. assert service.last_triage["escalated"] is True
  672. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
  673. def test_extract_merges_content():
  674. service, emitted, _, triage, _ = _triage_service(extract_fn=lambda prompt: "RAW")
  675. service.toggle_chat()
  676. window = ("hello",)
  677. signals = _escalated_payload()
  678. service._run_triage_extract(0, window, signals)
  679. contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
  680. assert len(contents) == 1
  681. assert contents[0]["triage"] == {**signals, "content": triage.parse_extraction("RAW")}
  682. assert service.last_triage == contents[0]["triage"]
  683. assert triage.prompt_calls == [(window, signals)]
  684. def test_extract_none_parse_keeps_signals():
  685. service, emitted, _, _, _ = _triage_service(
  686. triage=FakeTriage(parsed=None), extract_fn=lambda prompt: "GARBAGE"
  687. )
  688. service.toggle_chat()
  689. signals = _escalated_payload()
  690. service.last_triage = signals
  691. service._run_triage_extract(0, ("hello",), signals)
  692. assert [e for e in _triage_events(emitted) if e.get("stage") == "content"] == []
  693. assert service.last_triage == signals
  694. assert "content" not in service.last_triage
  695. def test_extract_error_is_swallowed():
  696. def boom(prompt):
  697. raise RuntimeError("llm down")
  698. service, emitted, _, _, _ = _triage_service(extract_fn=boom)
  699. service.toggle_chat()
  700. service._run_triage_extract(0, ("hello",), _escalated_payload())
  701. assert _triage_events(emitted) == []
  702. assert service.last_triage is None
  703. def test_extract_suppressed_after_reset():
  704. service, emitted, _, _, _ = _triage_service(extract_fn=lambda prompt: "RAW")
  705. service.toggle_chat()
  706. service._reset_triage()
  707. emitted.clear()
  708. service._run_triage_extract(0, ("hello",), _escalated_payload())
  709. assert _triage_events(emitted) == []
  710. assert service.last_triage is None
  711. def test_extract_works_after_leaving_chat():
  712. seen = []
  713. service, emitted, _, _, _ = _triage_service(
  714. extract_fn=lambda prompt: seen.append(prompt) or "RAW"
  715. )
  716. service.toggle_chat()
  717. assert service.modes.exit_chat() is True
  718. service._run_triage_extract(0, ("hello",), _escalated_payload())
  719. assert seen != []
  720. assert [e.get("stage") for e in _triage_events(emitted)] == ["content"]
  721. assert service.last_triage is not None
  722. assert "content" in service.last_triage
  723. def test_clear_resets_triage():
  724. service, emitted, clock, _, lane = _triage_service(
  725. triage=FakeTriage(escalate=True), extract_fn=lambda prompt: "RAW"
  726. )
  727. service.toggle_chat()
  728. service.handle_event(_transcript(clock, "churn"))
  729. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  730. service.tick()
  731. _, _, _, gen = lane.submits[0]
  732. service._on_triage_result(gen, _escalated_payload())
  733. assert service._inference.depth() == 2
  734. assert service.clear() is True
  735. assert service._triage_generation == 1
  736. assert service.last_triage is None
  737. assert service._last_triage_window == ()
  738. assert service.triage_history == []
  739. assert lane.drops == 1
  740. assert service._inference.depth() == 0
  741. assert list(service._inference._queue) == []
  742. cleared = [e for e in _triage_events(emitted) if e.get("stage") == "cleared"]
  743. assert len(cleared) == 1
  744. assert cleared[0]["triage"] is None
  745. assert {"type": "cleared"} in emitted
  746. signals_before = len(
  747. [e for e in _triage_events(emitted) if e.get("stage") == "signals"]
  748. )
  749. service._on_triage_result(gen, _escalated_payload())
  750. assert service.last_triage is None
  751. assert (
  752. len([e for e in _triage_events(emitted) if e.get("stage") == "signals"])
  753. == signals_before
  754. )
  755. submits_before = len(lane.submits)
  756. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  757. service.tick()
  758. assert len(lane.submits) == submits_before
  759. def test_exit_chat_does_not_reset_triage():
  760. service, emitted, clock, _, lane = _triage_service(
  761. extract_fn=lambda prompt: "RAW"
  762. )
  763. service.toggle_chat()
  764. service.handle_event(_transcript(clock, "hello"))
  765. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  766. service.tick()
  767. assert len(lane.submits) == 1
  768. _, _, _, gen = lane.submits[0]
  769. service._on_triage_result(gen, _escalated_payload())
  770. assert service.last_triage is not None
  771. assert service._triage_generation == 0
  772. assert service.toggle_chat() is True
  773. assert service.modes.mode.value == "idle"
  774. assert service._triage_generation == 0
  775. assert service.last_triage is not None
  776. assert service._last_triage_window == ("hello",)
  777. assert service.triage_history == ["hello"]
  778. assert lane.drops == 0
  779. cleared = [e for e in _triage_events(emitted) if e.get("stage") == "cleared"]
  780. assert cleared == []
  781. def test_clear_without_triage_emits_no_triage_event():
  782. service, emitted, _ = _service()
  783. service.clear()
  784. assert {"type": "cleared"} in emitted
  785. assert _triage_events(emitted) == []
  786. def test_triage_end_to_end_signals_content_cleared():
  787. prompts: list[str] = []
  788. service, emitted, clock, triage, lane = _triage_service(
  789. extract_fn=lambda prompt: prompts.append(prompt) or '{"summary": "s"}'
  790. )
  791. service.toggle_chat()
  792. service.handle_event(_transcript(clock, "first thought"))
  793. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  794. service.tick()
  795. assert len(lane.submits) == 1
  796. _, _, _, gen0 = lane.submits[0]
  797. service._on_triage_result(
  798. gen0, {"order": ["urgency"], "items": {}, "escalated": False, "input_tokens": 1}
  799. )
  800. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
  801. assert prompts == []
  802. assert service.last_triage is not None
  803. assert service.last_triage["escalated"] is False
  804. assert [j.name for j in service._inference._queue] == ["chat"]
  805. triage._escalate = True
  806. service.handle_event(_transcript(clock, "we might churn"))
  807. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  808. service.tick()
  809. assert len(lane.submits) == 2
  810. _, _, _, gen1 = lane.submits[1]
  811. service._on_triage_result(gen1, _escalated_payload())
  812. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals", "signals"]
  813. assert [j.name for j in service._inference._queue] == [
  814. "chat",
  815. "chat",
  816. "triage-extract",
  817. ]
  818. job = [j for j in service._inference._queue if j.name == "triage-extract"][0]
  819. job.run()
  820. contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
  821. assert len(contents) == 1
  822. assert len(prompts) == 1
  823. assert contents[0]["triage"]["escalated"] is True
  824. assert contents[0]["triage"]["content"] == triage.parse_extraction("x")
  825. assert service.last_triage == contents[0]["triage"]
  826. assert service.status()["triage"] == contents[0]["triage"]
  827. service.clear()
  828. cleared = [e for e in _triage_events(emitted) if e.get("stage") == "cleared"]
  829. assert len(cleared) == 1
  830. assert cleared[0]["triage"] is None
  831. assert service.last_triage is None
  832. service._on_triage_result(gen1, _escalated_payload())
  833. assert [e.get("stage") for e in _triage_events(emitted)] == [
  834. "signals",
  835. "signals",
  836. "content",
  837. "cleared",
  838. ]
  839. assert service.toggle_chat() is True
  840. service.handle_event(_transcript(clock, "idle thought"))
  841. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  842. service.tick()
  843. assert len(lane.submits) == 3
  844. assert lane.submits[2][0].endswith("- idle thought")
  845. def test_tick_emits_status_on_triage_state_change():
  846. service, emitted, _, triage, _ = _triage_service(
  847. triage=FakeTriage(state_pair=("loading", "warming up"))
  848. )
  849. service.tick()
  850. statuses = [e for e in emitted if e["type"] == "status"]
  851. assert len(statuses) == 1
  852. assert statuses[0]["triage_state"] == "loading"
  853. assert statuses[0]["triage_reason"] == "warming up"
  854. assert "triage" in statuses[0]
  855. triage._state_pair = ("ok", "")
  856. service.tick()
  857. statuses = [e for e in emitted if e["type"] == "status"]
  858. assert len(statuses) == 2
  859. assert statuses[1]["triage_state"] == "ok"
  860. assert statuses[1]["triage_reason"] == ""
  861. assert "triage" in statuses[1]
  862. service.tick()
  863. assert len([e for e in emitted if e["type"] == "status"]) == 2
  864. def test_no_status_emitted_when_triage_disabled():
  865. service, emitted, _ = _service()
  866. service.tick()
  867. service.tick()
  868. assert [e for e in emitted if e["type"] == "status"] == []
  869. def test_tick_heals_triage_panel_after_lane_ready():
  870. import time as _time
  871. from pathlib import Path
  872. from stt_daemon.assistant.laya_lane import LayaLane
  873. from stt_daemon.assistant.triage import TriageAnalyzer
  874. from stt_daemon.tui.state import ViewState
  875. class _FakeRouter:
  876. def preload(self, names):
  877. return self
  878. def predict(self, state, questions, model=None, lang=None, **kwargs):
  879. if "_warmup" in questions:
  880. return {"model": model, "answers": {}, "usage": {}, "routing": {}}
  881. return {
  882. "model": model,
  883. "answers": {
  884. "frustration": {
  885. "noul": 0.1,
  886. "confidence": 0.9,
  887. "answer_confidence": 0.9,
  888. },
  889. "urgency": {
  890. "choice": "low",
  891. "confidence": 0.9,
  892. "answer_confidence": 0.9,
  893. },
  894. },
  895. "usage": {"input_tokens": 5},
  896. }
  897. def _factory(**kwargs):
  898. return _FakeRouter()
  899. cfg = Config()
  900. cfg.assistant.turn_gap_s = 0.5
  901. cfg.triage.questions_file = str(
  902. Path(__file__).resolve().parent.parent / "questions.example.json"
  903. )
  904. clock = Clock()
  905. emitted: list[dict] = []
  906. def emit(type_, **fields):
  907. emitted.append({"type": type_, **fields})
  908. analyzer = TriageAnalyzer(cfg.triage)
  909. assert analyzer.questions, "example questions must load"
  910. holder: dict = {}
  911. lane = LayaLane(
  912. cfg.triage,
  913. agent_factory=_factory,
  914. on_result=lambda gen, payload: holder["svc"]._on_triage_result(gen, payload),
  915. on_state=lambda state, reason: analyzer.refresh(state, reason),
  916. )
  917. service = AssistantService(
  918. cfg,
  919. FakeSubscriber(),
  920. emit=emit,
  921. time_fn=clock,
  922. triage=analyzer,
  923. laya_lane=lane,
  924. chat_fn=_ok_reply,
  925. )
  926. holder["svc"] = service
  927. try:
  928. lane.start()
  929. deadline = _time.monotonic() + 5.0
  930. while not lane.ready() and _time.monotonic() < deadline:
  931. _time.sleep(0.005)
  932. assert lane.ready(), "lane did not become ready"
  933. assert analyzer.state() == ("ok", "")
  934. service.tick()
  935. statuses = [e for e in emitted if e["type"] == "status"]
  936. assert statuses, "tick emitted no status after lane ready"
  937. assert statuses[-1]["triage_state"] == "ok"
  938. service.handle_event(_transcript(clock, "hello"))
  939. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  940. service.tick()
  941. deadline = _time.monotonic() + 5.0
  942. while service.last_triage is None and _time.monotonic() < deadline:
  943. _time.sleep(0.005)
  944. assert service.last_triage is not None, "no triage signals from lane"
  945. view = ViewState()
  946. for event in emitted:
  947. view.ingest(event)
  948. assert view.triage_state == "ok"
  949. lines = view.triage_lines()
  950. assert lines
  951. assert not any("unavailable" in line for line in lines)
  952. assert not any("loading" in line for line in lines)
  953. finally:
  954. lane.stop()