- 유저 등록/관리 search now has 4 types: 유저(non-bot members), 봇(bot members), 통화방(current voice-channel members, bot flag enriched), 역할(roles — expandable to the members under each role; register the role OR a member under it). - Persist settings to a JSON state store (WSAI_STATE_FILE, default ~/.config/wsai/state.json) so they survive a service/container restart: per-guild listen lists + bargeIn (BotControl), TTS base/overrides and the chosen STT/LLM models (Dashboard). Applied on voice-server startup before warm. - TTS 감정 선택에 최상단 "전체변경 (모든 감정)" 추가: 적용하면 base를 그 값으로 세팅하고 모든 per-emotion override를 지워 전 감정이 한 번에 그 값으로 말합니다. - Move the component status bar (눈/시각/귀/LLM/입) above the bot bar. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
120 lines
4.0 KiB
Python
120 lines
4.0 KiB
Python
"""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 == ["화면 맥락", "LLM(생각)", "응답(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"] == "LLM(생각)")
|
|
assert brain_step["ok"] is False
|
|
assert "boom" in brain_step["error"]
|
|
assert snap["status"]["errors_total"] >= 1
|
|
|
|
|
|
def test_error_turn_increments_errors_total_once():
|
|
"""The dashboard voice turn (dashboard.voice_turn) records a turn and calls
|
|
turn.finish(error=...) directly — it never goes through the pipeline's
|
|
log("error") path. That failure must still land in errors_total, and exactly
|
|
once no matter how many times the turn is re-published."""
|
|
mon = Monitor()
|
|
turn = mon.turn(source="discord")
|
|
turn.heard("`코드` 얘기") # touches/publishes again
|
|
turn.replied("답변") # and again
|
|
assert mon.status_snapshot()["errors_total"] == 0
|
|
|
|
turn.finish(error="melo synth failed: KeyError '`'")
|
|
assert mon.status_snapshot()["errors_total"] == 1
|
|
|
|
# A stray re-finish/re-publish must not double count.
|
|
turn.finish(error="melo synth failed: KeyError '`'")
|
|
turn._touch()
|
|
assert mon.status_snapshot()["errors_total"] == 1
|
|
|
|
# The failure is also visible as an error-level event for the live feed.
|
|
events = mon.snapshot()["events"]
|
|
assert any(e["type"] == "log" and e["level"] == "error" for e in events)
|
|
|
|
|
|
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
|