test_assistant_service.py 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319
  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 == ["Unknown: hello there", "Unknown: 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 == 2 * total:
  260. break
  261. time.sleep(0.002)
  262. producer.join(timeout=5)
  263. assert sum(len(text.split()) for text in dispatched) == 2 * 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 == ["Unknown: hello", "Unknown: 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(
  368. (tuple(window), dict(signals) if isinstance(signals, dict) else signals)
  369. )
  370. return "EXTRACT"
  371. def parse_extraction(self, text):
  372. if self._parsed is _AUTO:
  373. return {
  374. "summary": "s",
  375. "opportunities": [],
  376. "doubts": [],
  377. "action_items": [],
  378. "risks": [],
  379. }
  380. return self._parsed
  381. class FakeLane:
  382. def __init__(self, ready=True):
  383. self._ready = ready
  384. self.submits: list[tuple] = []
  385. self.drops = 0
  386. self.predict_gate = threading.Event()
  387. def ready(self):
  388. return self._ready
  389. def state(self):
  390. return ("ok", "") if self._ready else ("loading", "")
  391. def submit(self, state, questions, lang, generation):
  392. self.submits.append((state, questions, lang, generation))
  393. return True
  394. def drop_pending(self):
  395. self.drops += 1
  396. def _ok_reply(text):
  397. return "ok"
  398. def _triage_service(triage=None, lane=None, extract_fn=None, chat_fn: Any = _AUTO):
  399. cfg = Config()
  400. cfg.assistant.turn_gap_s = 0.5
  401. clock = Clock()
  402. emitted: list[dict] = []
  403. def emit(type_, **fields):
  404. emitted.append({"type": type_, **fields})
  405. if triage is None:
  406. triage = FakeTriage()
  407. if lane is None:
  408. lane = FakeLane()
  409. if chat_fn is _AUTO:
  410. chat_fn = _ok_reply
  411. service = AssistantService(
  412. cfg,
  413. FakeSubscriber(),
  414. emit=emit,
  415. time_fn=clock,
  416. triage=triage,
  417. laya_lane=lane,
  418. triage_extract_fn=extract_fn,
  419. chat_fn=chat_fn,
  420. )
  421. return service, emitted, clock, triage, lane
  422. def _triage_events(emitted):
  423. return [e for e in emitted if e["type"] == "triage"]
  424. def _escalated_payload():
  425. return {"order": [], "items": {}, "escalated": True, "input_tokens": 3}
  426. def test_triage_status_disabled_when_no_analyzer():
  427. service, _, _ = _service()
  428. status = service.status()
  429. assert status["triage_state"] == "disabled"
  430. assert status["triage_reason"] == ""
  431. assert status["triage"] is None
  432. def test_triage_status_reflects_analyzer():
  433. service, _, _, _, _ = _triage_service(
  434. triage=FakeTriage(state_pair=("loading", "warming up"))
  435. )
  436. status = service.status()
  437. assert status["triage_state"] == "loading"
  438. assert status["triage_reason"] == "warming up"
  439. assert status["triage"] is None
  440. def test_triage_captures_transcript_lang():
  441. service, _, clock, _, _ = _triage_service()
  442. assert service._last_transcript_lang is None
  443. event = _transcript(clock, "hola")
  444. event["lang"] = "es"
  445. service.handle_event(event)
  446. assert service._last_transcript_lang == "es"
  447. service.handle_event(_transcript(clock, "hello"))
  448. assert service._last_transcript_lang is None
  449. def test_maybe_triage_passes_lang_to_lane():
  450. service, _, clock, _, lane = _triage_service()
  451. event = _transcript(clock, "hola")
  452. event["lang"] = "es"
  453. service.handle_event(event)
  454. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  455. service.tick()
  456. assert len(lane.submits) == 1
  457. _, _, lang, _ = lane.submits[0]
  458. assert lang == "es"
  459. def test_maybe_triage_runs_in_idle_mode():
  460. service, _, clock, triage, lane = _triage_service()
  461. service.handle_event(_transcript(clock, "hello"))
  462. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  463. service.tick()
  464. assert len(lane.submits) == 1
  465. state, questions, lang, gen = lane.submits[0]
  466. assert state == "- hello"
  467. assert questions is triage.questions
  468. assert lang is None
  469. assert gen == 0
  470. assert triage.build_state_calls == [("hello",)]
  471. assert service.triage_history == ["hello"]
  472. def test_maybe_triage_uses_transcript_buffer_not_chat_history():
  473. service, _, clock, triage, lane = _triage_service(chat_fn=None)
  474. service.chat_history = ["stale chat only"]
  475. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  476. service.tick()
  477. assert lane.submits == []
  478. assert triage.build_state_calls == []
  479. service.handle_event(_transcript(clock, "hello"))
  480. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  481. service.tick()
  482. assert len(lane.submits) == 1
  483. assert triage.build_state_calls == [("hello",)]
  484. def test_maybe_triage_requires_settled_turn():
  485. service, _, clock, _, lane = _triage_service()
  486. service.handle_event(_transcript(clock, "hello"))
  487. service.tick()
  488. assert lane.submits == []
  489. def test_maybe_triage_skips_unready_lane():
  490. service, _, clock, _, lane = _triage_service(lane=FakeLane(ready=False))
  491. service.handle_event(_transcript(clock, "hello"))
  492. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  493. service.tick()
  494. assert lane.submits == []
  495. def test_maybe_triage_skips_without_questions():
  496. service, _, clock, triage, lane = _triage_service(
  497. triage=FakeTriage(questions=None)
  498. )
  499. service.handle_event(_transcript(clock, "hello"))
  500. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  501. service.tick()
  502. assert lane.submits == []
  503. assert triage.build_state_calls == []
  504. def test_maybe_triage_dedupes_identical_window():
  505. service, _, clock, triage, lane = _triage_service()
  506. service.handle_event(_transcript(clock, "hello"))
  507. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  508. service.tick()
  509. assert len(lane.submits) == 1
  510. state, questions, lang, gen = lane.submits[0]
  511. assert state == "- hello"
  512. assert questions is triage.questions
  513. assert lang is None
  514. assert gen == 0
  515. service.tick()
  516. service.tick()
  517. assert len(lane.submits) == 1
  518. service.handle_event(_transcript(clock, "second"))
  519. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  520. service.tick()
  521. assert len(lane.submits) == 2
  522. assert lane.submits[1][0] == "- hello\n- second"
  523. def test_second_transcript_produces_second_submit():
  524. service, _, clock, _, lane = _triage_service()
  525. service.handle_event(_transcript(clock, "first"))
  526. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  527. service.tick()
  528. assert len(lane.submits) == 1
  529. service.handle_event(_transcript(clock, "second"))
  530. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  531. service.tick()
  532. assert len(lane.submits) == 2
  533. assert lane.submits[1][0] == "- first\n- second"
  534. def test_tick_under_50ms_with_blocked_lane():
  535. service, _, clock, _, lane = _triage_service()
  536. assert not lane.predict_gate.is_set()
  537. service.toggle_chat()
  538. service.handle_event(_transcript(clock, "hello"))
  539. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  540. start = time.perf_counter()
  541. service.tick()
  542. elapsed = time.perf_counter() - start
  543. assert len(lane.submits) == 1
  544. assert elapsed < 0.05
  545. def test_on_triage_result_emits_signals():
  546. service, emitted, clock, _, lane = _triage_service()
  547. service.toggle_chat()
  548. service.handle_event(_transcript(clock, "hello"))
  549. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  550. service.tick()
  551. _, _, _, gen = lane.submits[0]
  552. payload = {
  553. "order": ["urgency"],
  554. "items": {"urgency": {"kind": "noul"}},
  555. "escalated": False,
  556. "input_tokens": 12,
  557. }
  558. service._on_triage_result(gen, payload)
  559. staged = _triage_events(emitted)
  560. assert len(staged) == 1
  561. assert staged[0]["stage"] == "signals"
  562. assert staged[0]["triage"] == service.last_triage
  563. assert staged[0]["triage"]["escalated"] is False
  564. assert staged[0]["triage"]["input_tokens"] == 12
  565. assert service.status()["triage"] == service.last_triage
  566. def test_on_triage_result_suppresses_stale_generation():
  567. service, emitted, clock, _, lane = _triage_service()
  568. service.toggle_chat()
  569. service.handle_event(_transcript(clock, "hello"))
  570. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  571. service.tick()
  572. _, _, _, gen = lane.submits[0]
  573. service.clear()
  574. service._on_triage_result(gen, _escalated_payload())
  575. assert service.last_triage is None
  576. assert [e for e in _triage_events(emitted) if e.get("stage") == "signals"] == []
  577. service.handle_event(_transcript(clock, "hello"))
  578. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  579. service.tick()
  580. assert len(lane.submits) == 2
  581. def test_on_triage_result_accepts_non_chat_mode():
  582. service, emitted, clock, _, lane = _triage_service()
  583. service.handle_event(_transcript(clock, "hello"))
  584. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  585. service.tick()
  586. _, _, _, gen = lane.submits[0]
  587. window = service._last_triage_window
  588. service._on_triage_result(gen, _escalated_payload())
  589. assert service.last_triage is not None
  590. assert service._last_triage_window == window
  591. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
  592. def test_on_triage_result_ignores_none_payload():
  593. service, emitted, clock, _, lane = _triage_service()
  594. service.toggle_chat()
  595. service.handle_event(_transcript(clock, "hello"))
  596. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  597. service.tick()
  598. _, _, _, gen = lane.submits[0]
  599. service._on_triage_result(gen, None)
  600. assert service.last_triage is None
  601. assert _triage_events(emitted) == []
  602. def test_none_payload_enqueues_state_only_extract():
  603. service, emitted, clock, triage, lane = _triage_service(
  604. extract_fn=lambda prompt: "RAW"
  605. )
  606. service.toggle_chat()
  607. service.handle_event(_transcript(clock, "hello"))
  608. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  609. service.tick()
  610. _, _, _, gen = lane.submits[0]
  611. service._on_triage_result(gen, None)
  612. assert service.last_triage is None
  613. assert [e.get("stage") for e in _triage_events(emitted)] == []
  614. assert [j.name for j in service._inference._queue] == ["chat", "triage-extract"]
  615. job = [j for j in service._inference._queue if j.name == "triage-extract"][0]
  616. job.run()
  617. contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
  618. assert len(contents) == 1
  619. assert contents[0]["triage"] == {"content": triage.parse_extraction("RAW")}
  620. assert service.last_triage == contents[0]["triage"]
  621. assert triage.prompt_calls == [(service._last_triage_window, None)]
  622. def test_none_payload_without_extract_fn_enqueues_nothing():
  623. service, emitted, clock, _, lane = _triage_service()
  624. service.toggle_chat()
  625. service.handle_event(_transcript(clock, "hello"))
  626. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  627. service.tick()
  628. _, _, _, gen = lane.submits[0]
  629. service._on_triage_result(gen, None)
  630. assert service.last_triage is None
  631. assert _triage_events(emitted) == []
  632. assert [j.name for j in service._inference._queue] == ["chat"]
  633. def test_on_triage_result_ignores_empty_signals():
  634. service, emitted, clock, _, lane = _triage_service(
  635. triage=FakeTriage(normalized={})
  636. )
  637. service.toggle_chat()
  638. service.handle_event(_transcript(clock, "hello"))
  639. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  640. service.tick()
  641. _, _, _, gen = lane.submits[0]
  642. service._on_triage_result(gen, _escalated_payload())
  643. assert service.last_triage is None
  644. assert _triage_events(emitted) == []
  645. def test_escalation_enqueues_extract_job():
  646. service, emitted, clock, _, lane = _triage_service(
  647. triage=FakeTriage(escalate=True), extract_fn=lambda prompt: "RAW"
  648. )
  649. service.toggle_chat()
  650. service.handle_event(_transcript(clock, "we might churn"))
  651. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  652. service.tick()
  653. _, _, _, gen = lane.submits[0]
  654. service._on_triage_result(
  655. gen, {"order": [], "items": {}, "escalated": False, "input_tokens": 0}
  656. )
  657. assert service.last_triage is not None
  658. assert service.last_triage["escalated"] is True
  659. assert service._inference.depth() == 2
  660. assert [j.name for j in service._inference._queue] == ["chat", "triage-extract"]
  661. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
  662. def test_extract_runs_without_escalation():
  663. service, _, clock, _, lane = _triage_service(extract_fn=lambda prompt: "RAW")
  664. service.toggle_chat()
  665. service.handle_event(_transcript(clock, "all calm"))
  666. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  667. service.tick()
  668. _, _, _, gen = lane.submits[0]
  669. service._on_triage_result(
  670. gen, {"order": [], "items": {}, "escalated": False, "input_tokens": 0}
  671. )
  672. assert service.last_triage is not None
  673. assert service._inference.depth() == 2
  674. assert [j.name for j in service._inference._queue] == ["chat", "triage-extract"]
  675. def test_no_extract_when_disabled_in_config():
  676. service, _, clock, _, lane = _triage_service(
  677. triage=FakeTriage(escalate=True), extract_fn=lambda prompt: "RAW"
  678. )
  679. service.cfg.triage.extract_enabled = False
  680. service.toggle_chat()
  681. service.handle_event(_transcript(clock, "churn risk"))
  682. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  683. service.tick()
  684. _, _, _, gen = lane.submits[0]
  685. service._on_triage_result(gen, _escalated_payload())
  686. assert service.last_triage is not None
  687. assert service.last_triage["escalated"] is True
  688. assert [j.name for j in service._inference._queue] == ["chat"]
  689. def test_extract_refused_when_queue_full():
  690. service, emitted, clock, _, lane = _triage_service(
  691. triage=FakeTriage(escalate=True), extract_fn=lambda prompt: "RAW"
  692. )
  693. service._inference = InferenceQueue(size=2)
  694. service.toggle_chat()
  695. service.handle_event(_transcript(clock, "churn risk"))
  696. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  697. service.tick()
  698. assert service._inference.depth() == 1
  699. _, _, _, gen = lane.submits[0]
  700. service._on_triage_result(gen, _escalated_payload())
  701. assert service._inference.depth() == 1
  702. assert service._inference._queue[0].name == "chat"
  703. assert service.last_triage is not None
  704. assert service.last_triage["escalated"] is True
  705. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
  706. def test_extract_merges_content():
  707. service, emitted, _, triage, _ = _triage_service(extract_fn=lambda prompt: "RAW")
  708. service.toggle_chat()
  709. window = ("hello",)
  710. signals = _escalated_payload()
  711. service._run_triage_extract(0, window, signals)
  712. contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
  713. assert len(contents) == 1
  714. assert contents[0]["triage"] == {**signals, "content": triage.parse_extraction("RAW")}
  715. assert service.last_triage == contents[0]["triage"]
  716. assert triage.prompt_calls == [(window, signals)]
  717. def test_extract_none_parse_keeps_signals():
  718. service, emitted, _, _, _ = _triage_service(
  719. triage=FakeTriage(parsed=None), extract_fn=lambda prompt: "GARBAGE"
  720. )
  721. service.toggle_chat()
  722. signals = _escalated_payload()
  723. service.last_triage = signals
  724. service._run_triage_extract(0, ("hello",), signals)
  725. assert [e for e in _triage_events(emitted) if e.get("stage") == "content"] == []
  726. assert service.last_triage == signals
  727. assert "content" not in service.last_triage
  728. def test_extract_error_is_swallowed():
  729. def boom(prompt):
  730. raise RuntimeError("llm down")
  731. service, emitted, _, _, _ = _triage_service(extract_fn=boom)
  732. service.toggle_chat()
  733. service._run_triage_extract(0, ("hello",), _escalated_payload())
  734. assert _triage_events(emitted) == []
  735. assert service.last_triage is None
  736. def test_extract_suppressed_after_reset():
  737. service, emitted, _, _, _ = _triage_service(extract_fn=lambda prompt: "RAW")
  738. service.toggle_chat()
  739. service._reset_triage()
  740. emitted.clear()
  741. service._run_triage_extract(0, ("hello",), _escalated_payload())
  742. assert _triage_events(emitted) == []
  743. assert service.last_triage is None
  744. def test_extract_works_after_leaving_chat():
  745. seen = []
  746. service, emitted, _, _, _ = _triage_service(
  747. extract_fn=lambda prompt: seen.append(prompt) or "RAW"
  748. )
  749. service.toggle_chat()
  750. assert service.modes.exit_chat() is True
  751. service._run_triage_extract(0, ("hello",), _escalated_payload())
  752. assert seen != []
  753. assert [e.get("stage") for e in _triage_events(emitted)] == ["content"]
  754. assert service.last_triage is not None
  755. assert "content" in service.last_triage
  756. def test_clear_resets_triage():
  757. service, emitted, clock, _, lane = _triage_service(
  758. triage=FakeTriage(escalate=True), extract_fn=lambda prompt: "RAW"
  759. )
  760. service.toggle_chat()
  761. service.handle_event(_transcript(clock, "churn"))
  762. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  763. service.tick()
  764. _, _, _, gen = lane.submits[0]
  765. service._on_triage_result(gen, _escalated_payload())
  766. assert service._inference.depth() == 2
  767. assert service.clear() is True
  768. assert service._triage_generation == 1
  769. assert service.last_triage is None
  770. assert service._last_triage_window == ()
  771. assert service.triage_history == []
  772. assert lane.drops == 1
  773. assert service._inference.depth() == 0
  774. assert list(service._inference._queue) == []
  775. cleared = [e for e in _triage_events(emitted) if e.get("stage") == "cleared"]
  776. assert len(cleared) == 1
  777. assert cleared[0]["triage"] is None
  778. assert {"type": "cleared"} in emitted
  779. signals_before = len(
  780. [e for e in _triage_events(emitted) if e.get("stage") == "signals"]
  781. )
  782. service._on_triage_result(gen, _escalated_payload())
  783. assert service.last_triage is None
  784. assert (
  785. len([e for e in _triage_events(emitted) if e.get("stage") == "signals"])
  786. == signals_before
  787. )
  788. submits_before = len(lane.submits)
  789. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  790. service.tick()
  791. assert len(lane.submits) == submits_before
  792. def test_exit_chat_does_not_reset_triage():
  793. service, emitted, clock, _, lane = _triage_service(
  794. extract_fn=lambda prompt: "RAW"
  795. )
  796. service.toggle_chat()
  797. service.handle_event(_transcript(clock, "hello"))
  798. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  799. service.tick()
  800. assert len(lane.submits) == 1
  801. _, _, _, gen = lane.submits[0]
  802. service._on_triage_result(gen, _escalated_payload())
  803. assert service.last_triage is not None
  804. assert service._triage_generation == 0
  805. assert service.toggle_chat() is True
  806. assert service.modes.mode.value == "idle"
  807. assert service._triage_generation == 0
  808. assert service.last_triage is not None
  809. assert service._last_triage_window == ("hello",)
  810. assert service.triage_history == ["hello"]
  811. assert lane.drops == 0
  812. cleared = [e for e in _triage_events(emitted) if e.get("stage") == "cleared"]
  813. assert cleared == []
  814. def test_clear_without_triage_emits_no_triage_event():
  815. service, emitted, _ = _service()
  816. service.clear()
  817. assert {"type": "cleared"} in emitted
  818. assert _triage_events(emitted) == []
  819. def test_triage_end_to_end_signals_content_cleared():
  820. prompts: list[str] = []
  821. service, emitted, clock, triage, lane = _triage_service(
  822. extract_fn=lambda prompt: prompts.append(prompt) or '{"summary": "s"}'
  823. )
  824. service.toggle_chat()
  825. service.handle_event(_transcript(clock, "first thought"))
  826. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  827. service.tick()
  828. assert len(lane.submits) == 1
  829. _, _, _, gen0 = lane.submits[0]
  830. service._on_triage_result(
  831. gen0, {"order": ["urgency"], "items": {}, "escalated": False, "input_tokens": 1}
  832. )
  833. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals"]
  834. assert prompts == []
  835. assert service.last_triage is not None
  836. assert service.last_triage["escalated"] is False
  837. assert [j.name for j in service._inference._queue] == ["chat", "triage-extract"]
  838. triage._escalate = True
  839. service.handle_event(_transcript(clock, "we might churn"))
  840. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  841. service.tick()
  842. assert len(lane.submits) == 2
  843. _, _, _, gen1 = lane.submits[1]
  844. service._on_triage_result(gen1, _escalated_payload())
  845. assert [e.get("stage") for e in _triage_events(emitted)] == ["signals", "signals"]
  846. assert [j.name for j in service._inference._queue] == [
  847. "chat",
  848. "triage-extract",
  849. "chat",
  850. "triage-extract",
  851. ]
  852. for job in [j for j in service._inference._queue if j.name == "triage-extract"]:
  853. job.run()
  854. contents = [e for e in _triage_events(emitted) if e.get("stage") == "content"]
  855. assert len(contents) == 2
  856. assert len(prompts) == 2
  857. assert contents[0]["triage"]["escalated"] is False
  858. assert contents[1]["triage"]["escalated"] is True
  859. assert contents[1]["triage"]["content"] == triage.parse_extraction("x")
  860. assert service.last_triage == contents[1]["triage"]
  861. assert service.status()["triage"] == contents[1]["triage"]
  862. service.clear()
  863. cleared = [e for e in _triage_events(emitted) if e.get("stage") == "cleared"]
  864. assert len(cleared) == 1
  865. assert cleared[0]["triage"] is None
  866. assert service.last_triage is None
  867. service._on_triage_result(gen1, _escalated_payload())
  868. assert [e.get("stage") for e in _triage_events(emitted)] == [
  869. "signals",
  870. "signals",
  871. "content",
  872. "content",
  873. "cleared",
  874. ]
  875. assert service.toggle_chat() is True
  876. service.handle_event(_transcript(clock, "idle thought"))
  877. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  878. service.tick()
  879. assert len(lane.submits) == 3
  880. assert lane.submits[2][0].endswith("- idle thought")
  881. def test_tick_emits_status_on_triage_state_change():
  882. service, emitted, _, triage, _ = _triage_service(
  883. triage=FakeTriage(state_pair=("loading", "warming up"))
  884. )
  885. service.tick()
  886. statuses = [e for e in emitted if e["type"] == "status"]
  887. assert len(statuses) == 1
  888. assert statuses[0]["triage_state"] == "loading"
  889. assert statuses[0]["triage_reason"] == "warming up"
  890. assert "triage" in statuses[0]
  891. triage._state_pair = ("ok", "")
  892. service.tick()
  893. statuses = [e for e in emitted if e["type"] == "status"]
  894. assert len(statuses) == 2
  895. assert statuses[1]["triage_state"] == "ok"
  896. assert statuses[1]["triage_reason"] == ""
  897. assert "triage" in statuses[1]
  898. service.tick()
  899. assert len([e for e in emitted if e["type"] == "status"]) == 2
  900. def test_no_status_emitted_when_triage_disabled():
  901. service, emitted, _ = _service()
  902. service.tick()
  903. service.tick()
  904. assert [e for e in emitted if e["type"] == "status"] == []
  905. def test_tick_heals_triage_panel_after_lane_ready():
  906. import time as _time
  907. from pathlib import Path
  908. from stt_daemon.assistant.laya_lane import LayaLane
  909. from stt_daemon.assistant.triage import TriageAnalyzer
  910. from stt_daemon.tui.state import ViewState
  911. class _FakeRouter:
  912. def preload(self, names):
  913. return self
  914. def predict(self, state, questions, model=None, lang=None, **kwargs):
  915. if "_warmup" in questions:
  916. return {"model": model, "answers": {}, "usage": {}, "routing": {}}
  917. return {
  918. "model": model,
  919. "answers": {
  920. "frustration": {
  921. "noul": 0.1,
  922. "confidence": 0.9,
  923. "answer_confidence": 0.9,
  924. },
  925. "urgency": {
  926. "choice": "low",
  927. "confidence": 0.9,
  928. "answer_confidence": 0.9,
  929. },
  930. },
  931. "usage": {"input_tokens": 5},
  932. }
  933. def _factory(**kwargs):
  934. return _FakeRouter()
  935. cfg = Config()
  936. cfg.assistant.turn_gap_s = 0.5
  937. cfg.triage.questions_file = str(
  938. Path(__file__).resolve().parent.parent / "questions.example.json"
  939. )
  940. clock = Clock()
  941. emitted: list[dict] = []
  942. def emit(type_, **fields):
  943. emitted.append({"type": type_, **fields})
  944. analyzer = TriageAnalyzer(cfg.triage)
  945. assert analyzer.questions, "example questions must load"
  946. holder: dict = {}
  947. lane = LayaLane(
  948. cfg.triage,
  949. agent_factory=_factory,
  950. on_result=lambda gen, payload: holder["svc"]._on_triage_result(gen, payload),
  951. on_state=lambda state, reason: analyzer.refresh(state, reason),
  952. )
  953. service = AssistantService(
  954. cfg,
  955. FakeSubscriber(),
  956. emit=emit,
  957. time_fn=clock,
  958. triage=analyzer,
  959. laya_lane=lane,
  960. chat_fn=_ok_reply,
  961. )
  962. holder["svc"] = service
  963. try:
  964. lane.start()
  965. deadline = _time.monotonic() + 5.0
  966. while not lane.ready() and _time.monotonic() < deadline:
  967. _time.sleep(0.005)
  968. assert lane.ready(), "lane did not become ready"
  969. assert analyzer.state() == ("ok", "")
  970. service.tick()
  971. statuses = [e for e in emitted if e["type"] == "status"]
  972. assert statuses, "tick emitted no status after lane ready"
  973. assert statuses[-1]["triage_state"] == "ok"
  974. service.handle_event(_transcript(clock, "hello"))
  975. clock.advance(service.cfg.assistant.turn_gap_s + 0.1)
  976. service.tick()
  977. deadline = _time.monotonic() + 5.0
  978. while service.last_triage is None and _time.monotonic() < deadline:
  979. _time.sleep(0.005)
  980. assert service.last_triage is not None, "no triage signals from lane"
  981. view = ViewState()
  982. for event in emitted:
  983. view.ingest(event)
  984. assert view.triage_state == "ok"
  985. lines = view.triage_lines()
  986. assert lines
  987. assert not any("unavailable" in line for line in lines)
  988. assert not any("loading" in line for line in lines)
  989. finally:
  990. lane.stop()