feat(dashboard): default STT=medium, apply feedback+loading, log categories+filters

- Default STT model changed small -> medium (WhisperSTT default + service env;
  medium pre-cached). UI labels/hints updated.
- Model 적용 buttons: if the picked model == current, flash "변경사항 없음" for 3s
  then show the current model again; if it actually changes, STT shows a live
  loading panel that polls /api/models until the worker is ready and logs
  completion to the event log (set_stt_model/set_llm_model now return `changed`).
- Event/error log gains categories (READY, CONNECT, MODEL, TTS, VOICE, FILTER,
  SETTING, TURN, BRAIN, VISION, PIPELINE, …): Monitor.log takes an optional `cat`,
  call sites tagged, and each line shows a coloured category chip. A bot
  (re)connection now logs a CONNECT event.
- Log search extended: filter by 레벨(type), 종류(category, auto-populated), and
  시간(time range) in addition to free text.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-08-26 23:51:58 +09:00
parent d164630bb8
commit 1ac45214ce
5 changed files with 136 additions and 44 deletions

View File

@@ -82,11 +82,11 @@ def _run_stt_test(host: str, port: int) -> None:
monitor.set_components({"source": "none", "vision": "none", "stt": "whisper",
"brain": "none", "tts": "none"})
monitor.set_status(running=True, listening=False)
monitor.log("info", "STT 인식 테스트 서버 시작 — GPU 워밍업 중…")
monitor.log("info", "STT 인식 테스트 서버 시작 — GPU 워밍업 중…", cat="READY")
print("\n STT 워밍업 중… (모델 로드 + CUDA 예열)")
dash.warm() # load + warm the GPU worker so the first recognition is instant
dev = getattr(stt, "resolved_device", None) or "?"
monitor.log("info", f"STT 준비 완료 (device={dev}). 녹음/파일 업로드로 인식하세요.")
monitor.log("info", f"STT 준비 완료 (device={dev}). 녹음/파일 업로드로 인식하세요.", cat="READY")
shown = host if host not in ("0.0.0.0", "") else _lan_ip()
print(f"\n 음성 인식 테스트 사이트: http://{shown}:{port} (STT device: {dev})")
@@ -134,12 +134,12 @@ def _run_voice_server(host: str, port: int) -> None:
monitor.set_components({"source": "none", "vision": "none", "stt": "whisper",
"brain": brain_name, "tts": "melo"})
monitor.set_status(running=True, listening=False)
monitor.log("info", "디스코드 음성 서버 시작 — STT+TTS GPU 워밍업 중…")
monitor.log("info", "디스코드 음성 서버 시작 — STT+TTS GPU 워밍업 중…", cat="READY")
print("\n STT+TTS 워밍업 중… (모델 로드 + CUDA 예열)")
dash.warm()
sdev = getattr(stt, "resolved_device", None) or "?"
monitor.set_status(listening=True)
monitor.log("info", f"음성 서버 준비 완료 (STT device={sdev}). 디스코드 봇 연결 대기.")
monitor.log("info", f"음성 서버 준비 완료 (STT device={sdev}). 디스코드 봇 연결 대기.", cat="READY")
shown = host if host not in ("0.0.0.0", "") else _lan_ip()
print(f"\n 음성 서버 준비 완료 (STT device: {sdev}, 두뇌: {brain_name})")

View File

@@ -18,7 +18,7 @@ stays usable directly.
Env:
WSAI_WHISPER_PYTHON interpreter with faster-whisper installed
(default: /home/claude/jarvis-stt/whisper312/bin/python)
WSAI_WHISPER_MODEL model size/name (default: small)
WSAI_WHISPER_MODEL model size/name (default: medium)
WSAI_WHISPER_DEVICE cpu | cuda | auto (default auto: GPU if present,
else CPU; the worker falls back to CPU if CUDA fails)
WSAI_WHISPER_LANGUAGE forced language, e.g. ko (default ko; "" = autodetect)
@@ -67,7 +67,7 @@ class WhisperSTT:
audio_source: AsyncIterator[str] | None = None,
) -> None:
self.python = python or os.environ.get("WSAI_WHISPER_PYTHON", _DEFAULT_PYTHON)
self.model = model or os.environ.get("WSAI_WHISPER_MODEL", "small")
self.model = model or os.environ.get("WSAI_WHISPER_MODEL", "medium")
self.device = device or os.environ.get("WSAI_WHISPER_DEVICE", "auto")
# "" means autodetect; a real code like "ko" forces the language.
env_lang = os.environ.get("WSAI_WHISPER_LANGUAGE", "ko")

View File

@@ -232,7 +232,7 @@ def _make_handler(dash: "Dashboard"):
return
prompt_store.set_persona(prompt)
monitor.log("info", "봇 프롬프트가 수정되었습니다" if prompt.strip()
else "봇 프롬프트가 기본값으로 초기화되었습니다")
else "봇 프롬프트가 기본값으로 초기화되었습니다", cat="PROMPT")
body = json.dumps({
"ok": True,
"prompt": prompt_store.get_persona(self._default_persona()),
@@ -253,7 +253,11 @@ def _make_handler(dash: "Dashboard"):
except (ValueError, AttributeError):
self._send_json({"ok": False, "error": "invalid JSON"}, 400)
return
was_connected = dash.bot.state().get("connected")
dash.bot.report(data)
if not was_connected: # transition disconnected -> connected
who = data.get("botTag") or data.get("username") or data.get("identity") or ""
monitor.log("info", f"디스코드 봇 연결됨{(' · ' + who) if who else ''}", cat="CONNECT")
# Hand the bot any queued commands + the current listen filters +
# behaviour settings in the same round trip so it does not have to
# poll extra endpoints.
@@ -273,10 +277,10 @@ def _make_handler(dash: "Dashboard"):
channel_id = (data.get("channelId") or "").strip()
if channel_id and guild_id:
cid = dash.bot.enqueue({"type": "join", "guildId": guild_id, "channelId": channel_id})
monitor.log("info", f"음성채널 참여 요청 (guild={guild_id} channel={channel_id})")
monitor.log("info", f"음성채널 참여 요청 (guild={guild_id} channel={channel_id})", cat="VOICE")
else:
cid = dash.bot.enqueue({"type": "leave"})
monitor.log("info", "음성채널 나가기 요청")
monitor.log("info", "음성채널 나가기 요청", cat="VOICE")
self._send_json({"ok": True, "commandId": cid})
def _handle_bot_lists(self) -> None:
@@ -291,7 +295,7 @@ def _make_handler(dash: "Dashboard"):
self._send_json({"ok": False, "error": str(exc)}, 400)
return
saved = dash.bot.set_lists(guild_id, data.get("lists") or {})
monitor.log("info", f"청취 화이트/블랙리스트 업데이트 (guild={guild_id})")
monitor.log("info", f"청취 화이트/블랙리스트 업데이트 (guild={guild_id})", cat="FILTER")
self._send_json({"ok": True, "guildId": guild_id, "lists": saved})
def _handle_model_switch(self, which: str) -> None:
@@ -323,7 +327,7 @@ def _make_handler(dash: "Dashboard"):
self._send_json({"ok": False, "error": "invalid JSON"}, 400)
return
settings = dash.bot.set_settings(data)
monitor.log("info", "봇 설정 변경: " + json.dumps(settings, ensure_ascii=False))
monitor.log("info", "봇 설정 변경: " + json.dumps(settings, ensure_ascii=False), cat="SETTING")
self._send_json({"ok": True, "settings": settings})
def _handle_tts_settings_get(self) -> None:
@@ -349,7 +353,8 @@ def _make_handler(dash: "Dashboard"):
tgt = data.get("emotion") or "base"
monitor.log("info", f"봇 목소리 파라미터 적용 ({tgt}): base="
+ json.dumps(res["base"], ensure_ascii=False)
+ " overrides=" + json.dumps(res["overrides"], ensure_ascii=False))
+ " overrides=" + json.dumps(res["overrides"], ensure_ascii=False),
cat="TTS")
self._send_json(res)
def _handle_tts_preview(self) -> None:
@@ -609,20 +614,22 @@ class Dashboard:
raise ValueError("model required")
if model not in self.STT_OPTIONS:
raise ValueError(f"unknown STT model: {model}")
if model != self.stt.model:
changed = model != self.stt.model
if changed:
self.stt.model = model
self._submit(self.stt.aclose()) # drop old worker (fast)
# Reload+warm in the background; don't block the HTTP response.
asyncio.run_coroutine_threadsafe(self._warm_stt_bg(), self._loop)
return self.models_settings()["stt"]
self.monitor.log("info", f"STT 모델 전환 시작: {model} (로딩 중…)", cat="MODEL")
return {**self.models_settings()["stt"], "changed": changed}
async def _warm_stt_bg(self) -> None:
try:
await self.stt.warmup()
self.monitor.log("info", f"STT 모델 로드 완료: {self.stt.model} "
f"(device={getattr(self.stt, 'resolved_device', '?')})")
f"(device={getattr(self.stt, 'resolved_device', '?')})", cat="READY")
except Exception as exc: # noqa: BLE001
self.monitor.log("error", f"STT 모델 로드 실패({self.stt.model}): {exc}")
self.monitor.log("error", f"STT 모델 로드 실패({self.stt.model}): {exc}", cat="MODEL")
def set_llm_model(self, model: str) -> dict:
"""Switch the Claude model live — applied on the next reply (no reload)."""
@@ -631,9 +638,11 @@ class Dashboard:
model = str(model).strip()
if model not in self.LLM_OPTIONS:
raise ValueError(f"unknown LLM model: {model}")
changed = model != self.brain.model
self.brain.model = model
self.monitor.log("info", f"LLM 모델 변경: {model} (다음 답변부터 적용)")
return self.models_settings()["llm"]
if changed:
self.monitor.log("info", f"LLM 모델 변경: {model} (다음 답변부터 적용)", cat="MODEL")
return {**self.models_settings()["llm"], "changed": changed}
def voice_turn(self, audio_bytes: bytes, speaker: str = "",
guild: str = "", channel: str = "") -> dict:
@@ -718,7 +727,7 @@ class Dashboard:
self.monitor.add_claude_usage(u.get("input", 0), u.get("output", 0))
except Exception as exc: # noqa: BLE001
log.exception("brain failed")
self.monitor.log("error", f"두뇌 응답 실패: {exc}")
self.monitor.log("error", f"두뇌 응답 실패: {exc}", cat="BRAIN")
blob = f"{getattr(exc, 'status_code', '')} {exc}".lower()
if "529" in blob or "overload" in blob:
# Transient server overload survived the SDK retries.
@@ -858,6 +867,12 @@ PAGE = r"""<!DOCTYPE html>
.cfgrow{display:flex;gap:9px;align-items:center;font-size:13px;color:var(--fg);cursor:pointer;padding:4px 0}
.cfgrow input[type=checkbox]{width:16px;height:16px;accent-color:#3aa0ff;cursor:pointer}
.cfghint{color:var(--muted);font-weight:400;font-size:12px}
.mload{display:flex;align-items:center;gap:9px;margin-top:10px;padding:8px 12px;
background:#0f2333;border:1px solid #234a63;border-radius:10px;font-size:13px;color:var(--fg)}
.mload .bar{flex:1;height:6px;border-radius:4px;background:#12304a;overflow:hidden;position:relative}
.spin{width:15px;height:15px;border:2px solid #2a4a63;border-top-color:#3aa0ff;border-radius:50%;
display:inline-block;animation:spin 0.8s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.sttrow{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.btn{background:#173042;border:1px solid #234a63;color:var(--fg);border-radius:10px;padding:8px 14px;font-size:13px;cursor:pointer}
.btn:hover{background:#1d3d54}
@@ -962,6 +977,19 @@ PAGE = r"""<!DOCTYPE html>
.logline.info .lv{color:var(--accent)}
.logline.error .lv{color:var(--err)}
.logline.warn .lv{color:var(--warn)}
.lc-tag{flex:0 0 auto;min-width:62px;text-align:center;font-size:10px;font-weight:700;
letter-spacing:.3px;padding:1px 7px;border-radius:9px;background:#1b2b3a;color:#8fb3d6;
border:1px solid #24405a}
.lc-READY{background:#12331f;color:#7fe0a0;border-color:#1f5a34}
.lc-CONNECT{background:#12283f;color:#7fb8ff;border-color:#22507f}
.lc-MODEL{background:#2a2340;color:#c3a6ff;border-color:#463a7a}
.lc-TTS{background:#0f2f33;color:#6fe0d8;border-color:#1f5a5a}
.lc-VOICE{background:#33280f;color:#e0c06f;border-color:#5a481f}
.lc-FILTER{background:#301a2a;color:#e79fd0;border-color:#5a2545}
.lc-SETTING{background:#22303a;color:#9fc7e0;border-color:#2f5568}
.lc-TURN,.lc-ERROR{background:#3a1520;color:#ffb3bb;border-color:#7a2531}
.lc-BRAIN,.lc-VISION{background:#2a2340;color:#c3a6ff;border-color:#463a7a}
.lc-WARN{background:#332a10;color:#ffd98a;border-color:#5a4a1f}
.logline .lm{flex:1;color:var(--fg);white-space:pre-wrap;word-break:break-word}
.logline.error .lm{color:#ffb3bb}
.logline .lacts{opacity:0;display:flex;gap:4px}
@@ -1052,7 +1080,10 @@ PAGE = r"""<!DOCTYPE html>
<button id="mLLMapply" class="btn">적용</button>
<span id="mLLMstat" class="sttstat"></span>
</div>
<p class="cfghint" style="margin:8px 0 0">STT는 전환 시 모델을 다시 로드합니다(처음 medium/large-v3는 다운로드로 수 분 걸릴 수 있어요). LLM은 다음 답변부터 즉시 적용됩니다. 서비스 재시작 시 기본값(small · Haiku)으로 돌아갑니다.</p>
<div id="mLoad" class="mload" style="display:none">
<span class="spin"></span><span id="mLoadText">로딩 중…</span>
</div>
<p class="cfghint" style="margin:8px 0 0">STT는 전환 시 모델을 다시 로드합니다(처음 medium/large-v3는 다운로드로 수 분 걸릴 수 있어요). LLM은 다음 답변부터 즉시 적용됩니다. 서비스 재시작 시 기본값(medium · Haiku)으로 돌아갑니다.</p>
</div>
</section>
<div class="demobar" id="demobar" style="display:none"></div>
@@ -1081,11 +1112,19 @@ PAGE = r"""<!DOCTYPE html>
<div class="logbar">
<button id="logToggle" class="logtoggle">▾ 이벤트 / 오류 로그</button>
<input id="logSearch" class="logsearch" placeholder="로그 검색 (텍스트)">
<select id="logLevel" class="logsel">
<option value="">전체</option>
<option value="error">오류만</option>
<option value="warn">경고만</option>
<option value="info">정보만</option>
<select id="logLevel" class="logsel" title="레벨(타입)">
<option value="">레벨 전체</option>
<option value="error">오류(ERROR)</option>
<option value="warn">경고(WARN)</option>
<option value="info">정보(INFO)</option>
</select>
<select id="logCat" class="logsel" title="이벤트 종류"><option value="">종류 전체</option></select>
<select id="logTime" class="logsel" title="시간">
<option value="0">전체 시간</option>
<option value="5">최근 5분</option>
<option value="30">최근 30분</option>
<option value="60">최근 1시간</option>
<option value="180">최근 3시간</option>
</select>
<span id="logCount" class="logcount"></span>
<button id="logClear" class="btn logbtn">로그 삭제</button>
@@ -1243,20 +1282,34 @@ function applyTurnFilter(){
// --- Bottom terminal log panel: store all events, render filtered ---------- #
let logEvents = []; // {id, level, message, wall}
function logCat(e){ return (e.cat || (e.level||'info').toUpperCase()); }
function logMatches(e){
const lv = $('logLevel').value;
if(lv && e.level!==lv) return false;
const c = $('logCat').value;
if(c && logCat(e)!==c) return false;
const mins = +$('logTime').value;
if(mins && (Date.now()/1000 - (e.wall||0)) > mins*60) return false;
const q = $('logSearch').value.trim().toLowerCase();
if(q && !((e.message||'').toLowerCase().includes(q) || fmtTime(e.wall).includes(q))) return false;
if(q && !((e.message||'').toLowerCase().includes(q) || logCat(e).toLowerCase().includes(q) || fmtTime(e.wall).includes(q))) return false;
return true;
}
// Keep the 종류(카테고리) dropdown in sync with whatever categories appear.
function refreshCatOptions(){
const sel=$('logCat'); const cur=sel.value;
const cats=[...new Set(logEvents.map(logCat))].sort();
sel.innerHTML='<option value="">종류 전체</option>'
+ cats.map(c=>'<option value="'+c+'">'+esc(c)+'</option>').join('');
sel.value = cats.includes(cur) ? cur : '';
}
function renderLogs(){
const body = $('logbody');
refreshCatOptions();
const shown = logEvents.filter(logMatches);
body.innerHTML = shown.map(e =>
'<div class="logline '+(e.level||'info')+'" data-id="'+e.id+'">'
+ '<span class="lt">'+fmtTime(e.wall)+'</span>'
+ '<span class="lv">'+esc(e.level||'info')+'</span>'
+ '<span class="lc-tag lc-'+esc(logCat(e))+'">'+esc(logCat(e))+'</span>'
+ '<span class="lm">'+esc(e.message)+'</span>'
+ '<span class="lacts"><button class="lact" data-act="edit" title="수정">✎</button>'
+ '<button class="lact" data-act="del" title="삭제">✕</button></span>'
@@ -1329,8 +1382,8 @@ function wireCollapse(toggleId, bodyId, caretId){
wireCollapse('modelToggle','modelBody','modelCaret');
initModels();
})();
const STT_LABEL = {tiny:'tiny (가장 빠름)', base:'base', small:'small (기본)',
medium:'medium (정확도↑)', 'large-v3':'large-v3 (최고 정확도)'};
const STT_LABEL = {tiny:'tiny (가장 빠름)', base:'base', small:'small (빠름)',
medium:'medium (기본·정확)', 'large-v3':'large-v3 (최고 정확도)'};
const LLM_LABEL = {'claude-haiku-4-5':'Haiku 4.5 (가장 빠름)',
'claude-sonnet-4-5':'Sonnet 4.5 (고품질·조금 느림)'};
function fillSel(sel, options, current, labels){
@@ -1349,22 +1402,54 @@ async function initModels(){
$('mLLMstat').textContent='현재: '+(LLM_LABEL[j.llm.current]||j.llm.current||'?'); }
else { $('mLLM').disabled=$('mLLMapply').disabled=true; $('mLLMstat').textContent='LLM 비활성(echo 모드)'; }
$('mSTTapply').onclick = async ()=>{
const m=$('mSTT').value; $('mSTTstat').textContent='전환 중… (첫 다운로드면 수 분 걸릴 수 있어요)';
const m=$('mSTT').value;
try{ const r=await fetch('/api/models/stt',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({model:m})}); const jj=await r.json();
if(jj.ok){ toast('STT 모델 전환: '+m); $('mSTTstat').textContent='전환됨: '+m+' · 로딩은 백그라운드로 진행됩니다'; }
else { $('mSTTstat').textContent='실패: '+(jj.error||''); }
if(!jj.ok){ $('mSTTstat').textContent='실패: '+(jj.error||''); return; }
if(!jj.stt.changed){ flash3($('mSTTstat'), '변경사항 없음', '현재: '+(jj.stt.current||m)+(jj.stt.device?(' · '+jj.stt.device):'')); return; }
pollSTTLoad(m); // 실제 변경 → 아래 로딩창 + 완료 시 이벤트 로그
}catch(e){ $('mSTTstat').textContent='오류: '+e; }
};
$('mLLMapply').onclick = async ()=>{
const m=$('mLLM').value;
try{ const r=await fetch('/api/models/llm',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({model:m})}); const jj=await r.json();
if(jj.ok){ toast('LLM 모델 변경: '+(LLM_LABEL[m]||m)); $('mLLMstat').textContent='현재: '+(LLM_LABEL[m]||m)+' · 다음 답변부터'; }
else { $('mLLMstat').textContent='실패: '+(jj.error||''); }
if(!jj.ok){ $('mLLMstat').textContent='실패: '+(jj.error||''); return; }
if(!jj.llm.changed){ flash3($('mLLMstat'), '변경사항 없음', '현재: '+(LLM_LABEL[jj.llm.current]||jj.llm.current)); return; }
toast('LLM 모델 변경: '+(LLM_LABEL[m]||m));
$('mLLMstat').textContent='현재: '+(LLM_LABEL[m]||m)+' · 다음 답변부터';
}catch(e){ $('mLLMstat').textContent='오류: '+e; }
};
}
// 3초 동안 임시 메시지를 보여준 뒤 원래(현재 모델) 텍스트로 복귀.
function flash3(el, temp, revert){
el.textContent = temp;
clearTimeout(el._t);
el._t = setTimeout(()=>{ el.textContent = revert; }, 3000);
}
// STT 실제 전환: 아래 로딩창을 띄우고 /api/models를 폴링해 ready가 될 때까지 표시.
let sttPollTimer=null;
async function pollSTTLoad(target){
const load=$('mLoad'), txt=$('mLoadText');
load.style.display='flex';
$('mSTTstat').textContent='전환 중…';
const t0=Date.now();
if(sttPollTimer) clearInterval(sttPollTimer);
const tick=async ()=>{
const sec=Math.round((Date.now()-t0)/1000);
txt.textContent = 'STT '+target+' 로딩 중… (경과 '+sec+'초, 처음이면 다운로드로 수 분 걸릴 수 있어요)';
try{
const j=await (await fetch('/api/models')).json();
if(j&&j.ok&&j.stt.current===target&&j.stt.ready){
clearInterval(sttPollTimer); sttPollTimer=null;
load.style.display='none';
$('mSTTstat').textContent='현재: '+target+(j.stt.device?(' · '+j.stt.device):'')+' · 로드 완료';
toast('STT 모델 로드 완료: '+target);
}
}catch(e){}
};
tick(); sttPollTimer=setInterval(tick, 1500);
}
// --- 봇 목소리(TTS) 조절: 감정별 슬라이더 → 미리듣기 → 봇 적용 -------------- #
let ttsInited = false;
@@ -1538,6 +1623,8 @@ window.addEventListener('resize', syncDockPad);
})();
$('logSearch').oninput = renderLogs;
$('logLevel').onchange = renderLogs;
$('logCat').onchange = renderLogs;
$('logTime').onchange = renderLogs;
$('logClear').onclick = async ()=>{
if(!confirm('로그를 모두 삭제할까요?')) return;
try{ await fetch('/api/logs/clear',{method:'POST'}); toast('로그를 삭제했습니다'); }

View File

@@ -129,7 +129,7 @@ class Turn:
# Guarded so the repeated _touch()/finish() calls can't double-count.
if self.status == "error" and not self._error_logged:
self._error_logged = True
self._monitor.log("error", f"대화 #{self.id} 실패: {self.error}")
self._monitor.log("error", f"대화 #{self.id} 실패: {self.error}", cat="TURN")
self._touch()
# -- internal --------------------------------------------------------- #
@@ -209,12 +209,17 @@ class Monitor:
self._status["claude_output_tokens"] += int(output_tokens or 0)
self._broadcast({"type": "status", "status": self.status_snapshot()})
def log(self, level: str, message: str) -> None:
"""A free-form lifecycle/error line (startup, disconnect, crash…)."""
def log(self, level: str, message: str, cat: str | None = None) -> None:
"""A free-form lifecycle/error line (startup, disconnect, crash…).
``cat`` is a short category tag (READY, CONNECT, MODEL, TTS, VOICE,
FILTER, SETTING, TURN, BRAIN, PIPELINE, …) shown as a chip and filterable
on the dashboard. Defaults to the upper-cased level when omitted."""
with self._lock:
self._event_id += 1
evt = {"type": "log", "id": self._event_id, "level": level,
"message": message, "wall": _now_wall()}
"cat": (cat or level.upper()), "message": message,
"wall": _now_wall()}
self._events.append(evt)
if level == "error":
self._status["errors_total"] += 1

View File

@@ -65,7 +65,7 @@ class Pipeline:
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}")
self.monitor.log("error", f"화면 이해 실패: {exc}", cat="VISION")
continue
await self.context.update(obs)
log.debug("screen: %s", obs.text[:120])
@@ -122,7 +122,7 @@ class Pipeline:
return
if self.monitor is not None:
self.monitor.set_status(listening=True)
self.monitor.log("info", "음성 수신 시작 — 발화 대기 중")
self.monitor.log("info", "음성 수신 시작 — 발화 대기 중", cat="PIPELINE")
try:
async for utt in self.stt.utterances():
await self._handle(utt)
@@ -157,7 +157,7 @@ class Pipeline:
except Exception as exc: # a warm failure must not abort startup
log.warning("prewarm %s failed: %s", name, exc)
if self.monitor is not None:
self.monitor.log("error", f"{name} 예열 실패: {exc}")
self.monitor.log("error", f"{name} 예열 실패: {exc}", cat="READY")
async def run(self) -> None:
# A TaskGroup (not bare gather) so that if ONE loop raises, the others
@@ -167,7 +167,7 @@ class Pipeline:
# still-live loop (close-during-use).
if self.monitor is not None:
self.monitor.set_status(running=True)
self.monitor.log("info", "파이프라인 시작")
self.monitor.log("info", "파이프라인 시작", cat="PIPELINE")
await self._prewarm()
try:
async with asyncio.TaskGroup() as tg:
@@ -177,12 +177,12 @@ class Pipeline:
except* Exception as eg:
if self.monitor is not None:
for exc in eg.exceptions:
self.monitor.log("error", f"루프 예외: {type(exc).__name__}: {exc}")
self.monitor.log("error", f"루프 예외: {type(exc).__name__}: {exc}", cat="PIPELINE")
raise
finally:
if self.monitor is not None:
self.monitor.set_status(running=False, listening=False)
self.monitor.log("info", "파이프라인 종료")
self.monitor.log("info", "파이프라인 종료", cat="PIPELINE")
await self.aclose()
async def aclose(self) -> None: