feat: live status dashboard for the voice loop

Add a stdlib-only observability site so you can open a browser and watch,
step by step: whether it is listening, what it heard, what the brain thought
and answered, how long each stage took, and whether anything errored.

- wsai/monitor.py: thread-safe telemetry hub (per-turn timed steps, status
  header, error log) with a pub/sub for live push.
- wsai/dashboard.py: stdlib http.server serving a self-contained page plus an
  SSE (/events) live stream; /api/state snapshot fallback.
- Pipeline emits step-by-step turn telemetry (화면 맥락 → 두뇌 → 응답) and
  listening/running status; optional monitor, so existing paths are untouched.
- `python -m wsai --dashboard` starts the site (0.0.0.0:8787, WSAI_DASHBOARD_PORT)
  and loops the mock voice demo so there is always live activity to watch.
- Tests cover turn recording, per-step timing, error marking, and live push.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-08-16 20:24:13 +09:00
parent 3d76cd6c52
commit 6b0755e1ff
7 changed files with 780 additions and 21 deletions

95
tests/test_monitor.py Normal file
View File

@@ -0,0 +1,95 @@
"""The monitor must record step-by-step turns (heard / thought / answered,
per-step timing, ok vs error) and stream them to subscribers — that data is
exactly what the status dashboard renders."""
import asyncio
import json
from wsai.backends.mock import MockBrain, MockSTT, MockTTS
from wsai.monitor import Monitor
from wsai.pipeline import Pipeline
def test_monitor_records_turn_with_timed_steps():
async def go():
mon = Monitor()
pipe = Pipeline(
brain=MockBrain(),
stt=MockSTT(script=["안녕"], interval=0.01),
tts=MockTTS(),
monitor=mon,
)
await asyncio.wait_for(pipe.run(), timeout=5)
return mon
mon = asyncio.run(go())
snap = mon.snapshot()
assert snap["status"]["turns_total"] == 1
assert snap["status"]["running"] is False # cleaned up after run
assert len(snap["turns"]) == 1
turn = snap["turns"][0]
assert turn["heard"] == "안녕" # what it heard
assert turn["reply"] # what it answered
assert turn["status"] == "ok" # it worked
assert turn["total_ms"] >= 0
# step-by-step: every stage is named and timed
names = [s["name"] for s in turn["steps"]]
assert names == ["화면 맥락", "두뇌(생각)", "응답(TTS/전송)"]
assert all(s["ok"] is True for s in turn["steps"])
assert all(s["ms"] >= 0 for s in turn["steps"])
def test_monitor_marks_errors():
class BoomBrain(MockBrain):
async def respond(self, user_text, screen, history):
raise RuntimeError("boom")
async def go():
mon = Monitor()
pipe = Pipeline(
brain=BoomBrain(),
stt=MockSTT(script=["안녕"], interval=0.01),
tts=MockTTS(),
monitor=mon,
)
try:
await asyncio.wait_for(pipe.run(), timeout=5)
except BaseException:
pass # TaskGroup re-raises; we only care about recorded telemetry
return mon
mon = asyncio.run(go())
snap = mon.snapshot()
turn = snap["turns"][0]
assert turn["status"] == "error"
brain_step = next(s for s in turn["steps"] if s["name"] == "두뇌(생각)")
assert brain_step["ok"] is False
assert "boom" in brain_step["error"]
assert snap["status"]["errors_total"] >= 1
def test_subscriber_receives_live_turn_events():
async def go():
mon = Monitor()
q = mon.subscribe()
pipe = Pipeline(
brain=MockBrain(),
stt=MockSTT(script=["안녕"], interval=0.01),
tts=MockTTS(),
monitor=mon,
)
await asyncio.wait_for(pipe.run(), timeout=5)
return q
q = asyncio.run(go())
events = []
while not q.empty():
events.append(json.loads(q.get_nowait()))
types = {e["type"] for e in events}
assert "turn" in types # live turn updates were pushed
assert "status" in types # listening/running status changes were pushed