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:
@@ -15,6 +15,7 @@ from .interfaces import (
|
||||
Utterance,
|
||||
VisionBackend,
|
||||
)
|
||||
from .monitor import Monitor
|
||||
from .state import SharedScreenContext
|
||||
|
||||
log = logging.getLogger("wsai.pipeline")
|
||||
@@ -41,6 +42,7 @@ class Pipeline:
|
||||
tts: TextToSpeech | None = None,
|
||||
text_channel: TextChannel | None = None,
|
||||
history_turns: int = 12,
|
||||
monitor: Monitor | None = None,
|
||||
) -> None:
|
||||
self.source = source
|
||||
self.vision = vision
|
||||
@@ -48,6 +50,7 @@ class Pipeline:
|
||||
self.stt = stt
|
||||
self.tts = tts
|
||||
self.text_channel = text_channel
|
||||
self.monitor = monitor
|
||||
self.context = SharedScreenContext()
|
||||
self._history: list[tuple[str, str]] = []
|
||||
self._history_turns = history_turns
|
||||
@@ -59,18 +62,43 @@ class Pipeline:
|
||||
async for frame in self.source.frames():
|
||||
try:
|
||||
obs = await self.vision.describe(frame)
|
||||
except Exception: # a single bad frame must not kill the loop
|
||||
except Exception as exc: # a single bad frame must not kill the loop
|
||||
log.exception("vision.describe failed")
|
||||
if self.monitor is not None:
|
||||
self.monitor.log("error", f"화면 이해 실패: {exc}")
|
||||
continue
|
||||
await self.context.update(obs)
|
||||
log.debug("screen: %s", obs.text[:120])
|
||||
|
||||
# -- conversation ------------------------------------------------------ #
|
||||
async def _handle(self, utt: Utterance) -> None:
|
||||
screen = await self.context.latest()
|
||||
reply = await self.brain.respond(utt.text, screen, self._history)
|
||||
self._remember(utt.text, reply.text)
|
||||
await self._emit(reply)
|
||||
if self.monitor is None:
|
||||
screen = await self.context.latest()
|
||||
reply = await self.brain.respond(utt.text, screen, self._history)
|
||||
self._remember(utt.text, reply.text)
|
||||
await self._emit(reply)
|
||||
return
|
||||
|
||||
# Same work, but each stage is timed and streamed to the dashboard so a
|
||||
# viewer can see what was heard, what the brain answered, how long each
|
||||
# step took, and whether anything errored.
|
||||
turn = self.monitor.turn(source=utt.source)
|
||||
turn.heard(utt.text)
|
||||
try:
|
||||
async with turn.step("화면 맥락"):
|
||||
screen = await self.context.latest()
|
||||
async with turn.step("두뇌(생각)"):
|
||||
reply = await self.brain.respond(utt.text, screen, self._history)
|
||||
turn.replied(reply.text)
|
||||
self._remember(utt.text, reply.text)
|
||||
async with turn.step("응답(TTS/전송)"):
|
||||
await self._emit(reply)
|
||||
except Exception as exc:
|
||||
turn.finish(error=f"{type(exc).__name__}: {exc}")
|
||||
self.monitor.log("error", f"대화 #{turn.id} 실패: {exc}")
|
||||
raise
|
||||
else:
|
||||
turn.finish()
|
||||
|
||||
def _remember(self, user: str, ai: str) -> None:
|
||||
self._history.append((user, ai))
|
||||
@@ -91,8 +119,15 @@ class Pipeline:
|
||||
async def _listen_voice(self) -> None:
|
||||
if self.stt is None:
|
||||
return
|
||||
async for utt in self.stt.utterances():
|
||||
await self._handle(utt)
|
||||
if self.monitor is not None:
|
||||
self.monitor.set_status(listening=True)
|
||||
self.monitor.log("info", "음성 수신 시작 — 발화 대기 중")
|
||||
try:
|
||||
async for utt in self.stt.utterances():
|
||||
await self._handle(utt)
|
||||
finally:
|
||||
if self.monitor is not None:
|
||||
self.monitor.set_status(listening=False)
|
||||
|
||||
async def _listen_text(self) -> None:
|
||||
if self.text_channel is None:
|
||||
@@ -107,12 +142,23 @@ class Pipeline:
|
||||
# failing loop propagated while the siblings kept running detached, and
|
||||
# aclose() in the finally then closed a source/stt out from under a
|
||||
# still-live loop (close-during-use).
|
||||
if self.monitor is not None:
|
||||
self.monitor.set_status(running=True)
|
||||
self.monitor.log("info", "파이프라인 시작")
|
||||
try:
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(self._perceive())
|
||||
tg.create_task(self._listen_voice())
|
||||
tg.create_task(self._listen_text())
|
||||
except* Exception as eg:
|
||||
if self.monitor is not None:
|
||||
for exc in eg.exceptions:
|
||||
self.monitor.log("error", f"루프 예외: {type(exc).__name__}: {exc}")
|
||||
raise
|
||||
finally:
|
||||
if self.monitor is not None:
|
||||
self.monitor.set_status(running=False, listening=False)
|
||||
self.monitor.log("info", "파이프라인 종료")
|
||||
await self.aclose()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user