test_assistant_speaker.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. """Tests for Phase E: assistant speaker cache, display resolution, naming, and ctl."""
  2. import itertools
  3. import json
  4. import socket
  5. from pathlib import Path
  6. from stt_daemon import events
  7. from stt_daemon.assistant.control import _Client, ControlServer
  8. from stt_daemon.assistant.service import AssistantService
  9. from stt_daemon.config import Config
  10. from stt_daemon.ctl import COMMAND_MAP, build_message
  11. _sequence = itertools.count(1000)
  12. class Clock:
  13. def __init__(self, start=1000.0):
  14. self.t = float(start)
  15. def __call__(self):
  16. return self.t
  17. class FakeSubscriber:
  18. def events(self):
  19. return iter(())
  20. def close(self):
  21. pass
  22. class FakeAudit:
  23. def __init__(self):
  24. self.records: list[dict] = []
  25. def record(self, **fields):
  26. self.records.append(fields)
  27. def _service(tmp_path, audit=None):
  28. cfg = Config()
  29. cfg.speaker.store_dir = str(tmp_path / "speakers")
  30. clock = Clock()
  31. relayed: list[dict] = []
  32. service = AssistantService(
  33. cfg,
  34. FakeSubscriber(),
  35. time_fn=clock,
  36. audit=audit,
  37. relay=relayed.append,
  38. )
  39. return service, relayed, clock
  40. def _speakers_event(clock, speakers):
  41. return {
  42. "v": 1,
  43. "type": events.SPEAKERS,
  44. "seq": next(_sequence),
  45. "ts": clock(),
  46. "speakers": speakers,
  47. }
  48. def _transcript(clock, text, **fields):
  49. event = {
  50. "v": 1,
  51. "type": events.TRANSCRIPT,
  52. "seq": next(_sequence),
  53. "ts": clock(),
  54. "text": text,
  55. }
  56. event.update(fields)
  57. return event
  58. _ROSTER = [
  59. {
  60. "id": "spk_0002",
  61. "name": "",
  62. "status": "provisional",
  63. "cumulative_s": 3.0,
  64. "sample_path": "speakers/samples/spk_0002.wav",
  65. "created_at": "2026-09-28T10:00:00",
  66. },
  67. {
  68. "id": "spk_0001",
  69. "name": "Efren",
  70. "status": "confirmed",
  71. "cumulative_s": 20.0,
  72. "sample_path": "",
  73. "created_at": "2026-09-28T09:00:00",
  74. },
  75. ]
  76. def _cached(service, clock):
  77. service.handle_event(_speakers_event(clock, _ROSTER))
  78. def test_speakers_event_caches_roster_sorted_by_id(tmp_path):
  79. service, _, clock = _service(tmp_path)
  80. assert service.speaker_list() == []
  81. _cached(service, clock)
  82. roster = service.speaker_list()
  83. assert [entry["id"] for entry in roster] == ["spk_0001", "spk_0002"]
  84. assert roster[0]["name"] == "Efren"
  85. def test_speakers_event_is_relayed_to_viewers(tmp_path):
  86. service, relayed, clock = _service(tmp_path)
  87. event = _speakers_event(clock, _ROSTER)
  88. service.handle_event(event)
  89. assert len(relayed) == 1
  90. assert relayed[0]["type"] == events.SPEAKERS
  91. def test_transcript_matched_named_relays_name(tmp_path):
  92. service, relayed, clock = _service(tmp_path)
  93. event = _transcript(
  94. clock,
  95. "hello",
  96. seg_id=1,
  97. speaker_id="spk_0001",
  98. speaker_status="matched",
  99. speaker="Efren",
  100. speaker_score=0.8,
  101. )
  102. service.handle_event(event)
  103. assert relayed[0]["speaker"] == "Efren"
  104. assert relayed[0]["speaker_id"] == "spk_0001"
  105. assert relayed[0]["speaker_status"] == "matched"
  106. assert relayed[0]["speaker_score"] == 0.8
  107. assert event["speaker"] == "Efren"
  108. def test_transcript_unmatched_cases_relay_unknown(tmp_path):
  109. cases = [
  110. {"speaker_id": "spk_0002", "speaker_status": "matched", "speaker": "spk_0002"},
  111. {"speaker_id": "spk_0002", "speaker_status": "uncertain", "speaker": "spk_0002?"},
  112. {"speaker_id": None, "speaker_status": "unknown", "speaker": "?"},
  113. {"speaker_id": None, "speaker_status": "none", "speaker": "?"},
  114. ]
  115. for fields in cases:
  116. service, relayed, clock = _service(tmp_path)
  117. event = _transcript(clock, "hello", seg_id=1, speaker_score=None, **fields)
  118. service.handle_event(event)
  119. assert relayed[0]["speaker"] == "Unknown"
  120. assert relayed[0]["speaker_id"] == fields["speaker_id"]
  121. assert relayed[0]["speaker_status"] == fields["speaker_status"]
  122. def test_speaker_name_known_id_writes_inbox_request(tmp_path):
  123. audit = FakeAudit()
  124. service, _, clock = _service(tmp_path, audit=audit)
  125. _cached(service, clock)
  126. assert service.speaker_name("spk_0002", "Ana") is True
  127. inbox = Path(service.cfg.speaker.store_dir) / "inbox"
  128. files = list(inbox.glob("*.json"))
  129. assert len(files) == 1
  130. assert json.loads(files[0].read_text(encoding="utf-8")) == {
  131. "speaker_id": "spk_0002",
  132. "name": "Ana",
  133. }
  134. assert audit.records == [
  135. {"mode": "speaker", "action": "name", "speaker_id": "spk_0002", "name": "Ana"}
  136. ]
  137. def test_speaker_name_unknown_id_writes_nothing(tmp_path):
  138. audit = FakeAudit()
  139. service, _, clock = _service(tmp_path, audit=audit)
  140. _cached(service, clock)
  141. assert service.speaker_name("spk_9999", "Nobody") is False
  142. inbox = Path(service.cfg.speaker.store_dir) / "inbox"
  143. assert not inbox.exists() or list(inbox.glob("*.json")) == []
  144. assert audit.records == []
  145. def test_speaker_name_inbox_is_private(tmp_path):
  146. service, _, clock = _service(tmp_path)
  147. _cached(service, clock)
  148. assert service.speaker_name("spk_0002", "Ana") is True
  149. inbox = Path(service.cfg.speaker.store_dir) / "inbox"
  150. assert (inbox.stat().st_mode & 0o077) == 0
  151. def test_chat_context_uses_display_labels(tmp_path):
  152. service, _, clock = _service(tmp_path)
  153. service.toggle_chat()
  154. service.handle_event(
  155. _transcript(
  156. clock, "hi", speaker_id="spk_0001", speaker_status="matched", speaker="Efren"
  157. )
  158. )
  159. service.handle_event(
  160. _transcript(clock, "yo", speaker_id=None, speaker_status="none", speaker="?")
  161. )
  162. assert service.chat_history == ["Efren: hi", "Unknown: yo"]
  163. assert service._pending_chat == ["Efren: hi", "Unknown: yo"]
  164. assert service.triage_history == ["hi", "yo"]
  165. def _dispatch(service, message):
  166. cfg = Config()
  167. server = ControlServer(cfg, service)
  168. left, right = socket.socketpair()
  169. try:
  170. client = _Client(left, 256)
  171. server._dispatch(client, message)
  172. return json.loads(bytes(client.queue.popleft()).decode("utf-8"))
  173. finally:
  174. left.close()
  175. right.close()
  176. def test_control_speaker_list_reply_shape(tmp_path):
  177. service, _, clock = _service(tmp_path)
  178. _cached(service, clock)
  179. reply = _dispatch(service, {"type": "speaker_list"})
  180. assert reply["type"] == "speaker_list"
  181. assert [entry["id"] for entry in reply["speakers"]] == ["spk_0001", "spk_0002"]
  182. def test_control_speaker_name_ack(tmp_path):
  183. service, _, clock = _service(tmp_path)
  184. _cached(service, clock)
  185. reply = _dispatch(
  186. service, {"type": "speaker_name", "speaker_id": "spk_0001", "name": "Efren"}
  187. )
  188. assert reply["type"] == "ack"
  189. assert reply["command"] == "speaker_name"
  190. def test_control_speaker_name_unknown_id(tmp_path):
  191. service, _, clock = _service(tmp_path)
  192. _cached(service, clock)
  193. reply = _dispatch(
  194. service, {"type": "speaker_name", "speaker_id": "spk_9999", "name": "Nobody"}
  195. )
  196. assert reply["type"] == "error"
  197. assert reply["code"] == "unknown_speaker"
  198. def test_control_speaker_name_empty_name(tmp_path):
  199. service, _, clock = _service(tmp_path)
  200. _cached(service, clock)
  201. reply = _dispatch(
  202. service, {"type": "speaker_name", "speaker_id": "spk_0001", "name": ""}
  203. )
  204. assert reply["type"] == "error"
  205. assert reply["code"] == "bad_message"
  206. def test_ctl_command_map_and_build_message():
  207. assert COMMAND_MAP["speaker-list"] == "speaker_list"
  208. assert COMMAND_MAP["speaker-name"] == "speaker_name"
  209. assert build_message("speaker-list") == {"type": "speaker_list"}
  210. assert build_message("speaker-name", rest=["spk_0001", "Efren"]) == {
  211. "type": "speaker_name",
  212. "speaker_id": "spk_0001",
  213. "name": "Efren",
  214. }