diff --git a/apps/api/app/routes/voice.py b/apps/api/app/routes/voice.py index a6f3e7f..b8b02ff 100644 --- a/apps/api/app/routes/voice.py +++ b/apps/api/app/routes/voice.py @@ -18,14 +18,15 @@ import hashlib import time from typing import Optional -from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException -from fastapi.responses import JSONResponse +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException, status +from fastapi.responses import JSONResponse, Response +from pydantic import BaseModel, Field from starlette.websockets import WebSocketState from .. import session_persistence, turn_runtime from ..auth_sessions import get_session, user_has_consent, user_onboarding_complete from ..config import settings -from ..deps import Principal, Role +from ..deps import CurrentPrincipal, Principal, Role from ..engine_client import EngineError, engine_client from ..persona_repository import ( PersonaVoiceMap, @@ -41,6 +42,13 @@ from ..store import InProcSession, TurnRecord, store router = APIRouter(prefix="/voice", tags=["voice"]) + +class VoiceSpeechRequest(BaseModel): + """Request OpenAI TTS for an already-persisted client reply.""" + + session_id: str = Field(min_length=1, max_length=80) + turn_seq: int = Field(ge=1) + # WebSocket close codes. WS_CLOSE_DEGRADED = 1011 WS_CLOSE_BAD_REQUEST = 1008 @@ -123,6 +131,89 @@ async def voice_health() -> JSONResponse: return JSONResponse(body, status_code=200 if available else 503) +@router.post("/speech") +async def voice_speech(body: VoiceSpeechRequest, principal: CurrentPrincipal) -> Response: + """Synthesize the persisted client reply for a completed text turn. + + The browser sends only session/turn identifiers. The server reloads the + owned session and speaks the stored client-visible reply, so this endpoint + cannot be used as an arbitrary paid text-to-speech proxy. + """ + learner = principal + if learner.role != Role.LEARNER and learner.can_access_role(Role.LEARNER): + learner = learner.with_role(Role.LEARNER) + if learner.role != Role.LEARNER: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="only learners can use voice", + ) + + access_error = await _practice_access_error(learner) + if access_error is not None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=access_error) + if not voice_service.is_available(): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="OPENAI_API_KEY is not configured", + ) + + sess, err = await turn_runtime.load_owned_session( + body.session_id, + learner, + allow_ended=False, + ) + if sess is None: + status_code = { + turn_runtime.SessionAccessError.FORBIDDEN: status.HTTP_403_FORBIDDEN, + turn_runtime.SessionAccessError.ENDED: status.HTTP_409_CONFLICT, + }.get(err, status.HTTP_404_NOT_FOUND) + raise HTTPException(status_code=status_code, detail=f"voice session {err or 'not_found'}") + + text = _client_turn_text_for_speech(sess, body.turn_seq) + if text is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="client reply not found for turn", + ) + + voice_preset = await _resolve_session_voice( + session_id=body.session_id, + persona_code=sess.persona.code, + explicit_preset=None, + ) + try: + chunks = [ + chunk.audio + async for chunk in voice_service.synthesize_stream(text, voice_preset) + ] + except VoiceUnavailable as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"TTS failed: {exc}", + ) from exc + + audio = b"".join(chunks) + if not audio: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="client reply has no speakable text", + ) + return Response( + content=audio, + media_type="audio/mpeg", + headers={ + "Cache-Control": "no-store", + "X-Vignette-TTS-Model": voice_svc.TTS_MODEL, + "X-Vignette-TTS-Provider": voice_service.tts_provider(), + }, + ) + + @router.websocket("/ws") async def voice_ws(websocket: WebSocket) -> None: """Run one authenticated learner voice cascade.""" @@ -596,6 +687,19 @@ async def _load_voice_session( return sess, None +def _client_turn_text_for_speech(sess: InProcSession, turn_seq: int) -> str | None: + """Return the persisted client-visible reply for one completed turn.""" + for turn in reversed(sess.turns): + if ( + turn.turn_seq == turn_seq + and turn.speaker == "client" + and turn.is_visible_to("client") + ): + text = (turn.text_masked or turn.text).strip() + return text or None + return None + + async def _principal_from_websocket(websocket: WebSocket) -> Principal | None: """Restore the same server-side browser session used by REST routes.""" raw_cookie = websocket.cookies.get(settings.cookie_name) diff --git a/apps/api/app/test_voice_ws.py b/apps/api/app/test_voice_ws.py index 03deb2f..de7477e 100644 --- a/apps/api/app/test_voice_ws.py +++ b/apps/api/app/test_voice_ws.py @@ -12,7 +12,8 @@ from fastapi import HTTPException from .deps import Principal, Role from .persona_repository import PersonaVoiceMap from .routes import voice as voice_routes -from .services.voice import VoicePreset +from .services.voice import TTSChunk, VoicePreset +from .store import TurnRecord SESSION_ID = "voice-ws-contract-session" @@ -130,6 +131,98 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase): ], ) + def test_text_tts_uses_only_the_persisted_client_visible_reply(self) -> None: + session = SimpleNamespace( + turns=[ + TurnRecord( + turn_seq=2, + speaker="counselor", + stage="초기", + text="raw learner text", + text_masked="masked learner text", + ), + TurnRecord( + turn_seq=2, + speaker="client", + stage="초기", + text="raw client reply", + text_masked="마스킹된 내담자 응답", + ), + TurnRecord( + turn_seq=3, + speaker="client", + stage="초기", + text="hidden evaluator reply", + text_masked="hidden evaluator reply", + visible_to=("evaluator",), + ), + ] + ) + + self.assertEqual( + voice_routes._client_turn_text_for_speech(session, 2), + "마스킹된 내담자 응답", + ) + self.assertIsNone(voice_routes._client_turn_text_for_speech(session, 3)) + self.assertIsNone(voice_routes._client_turn_text_for_speech(session, 99)) + + async def test_text_turn_speech_returns_openai_audio_for_owned_persisted_turn(self) -> None: + session = SimpleNamespace( + persona=SimpleNamespace(code="P1"), + turns=[ + TurnRecord( + turn_seq=4, + speaker="client", + stage="초기", + text="내담자 응답", + text_masked="내담자 응답", + ) + ], + ) + synthesized: list[tuple[str, VoicePreset]] = [] + + async def synthesize(text: str, voice: VoicePreset): + synthesized.append((text, voice)) + yield TTSChunk(audio=b"mp3-a") + yield TTSChunk(audio=b"mp3-b") + + with patch.object( + voice_routes, + "_practice_access_error", + AsyncMock(return_value=None), + ), patch.object( + voice_routes.turn_runtime, + "load_owned_session", + AsyncMock(return_value=(session, None)), + ), patch.object( + voice_routes, + "_resolve_session_voice", + AsyncMock(return_value=VOICE_PRESET), + ), patch.object( + voice_routes.voice_service, + "is_available", + return_value=True, + ), patch.object( + voice_routes.voice_service, + "tts_provider", + return_value="openai", + ), patch.object( + voice_routes.voice_service, + "synthesize_stream", + new=synthesize, + ): + response = await voice_routes.voice_speech( + voice_routes.VoiceSpeechRequest(session_id=SESSION_ID, turn_seq=4), + _principal(), + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.media_type, "audio/mpeg") + self.assertEqual(response.body, b"mp3-amp3-b") + self.assertEqual(response.headers["cache-control"], "no-store") + self.assertEqual(response.headers["x-vignette-tts-provider"], "openai") + self.assertEqual(synthesized, [("내담자 응답", VOICE_PRESET)]) + async def test_audio_start_binary_chunks_audio_end_ping_close_contract(self) -> None: websocket = FakeWebSocket( [ diff --git a/apps/web/e2e/voice-success.spec.ts b/apps/web/e2e/voice-success.spec.ts index d749878..424e3ee 100644 --- a/apps/web/e2e/voice-success.spec.ts +++ b/apps/web/e2e/voice-success.spec.ts @@ -139,6 +139,25 @@ async function startFakeEngine(): Promise { ); return; } + if (req.method === "POST" && req.url === "/v1/stream") { + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + }); + res.write(`event: token\ndata: ${JSON.stringify({ text: "괜찮아요. " })}\n\n`); + res.write(`event: token\ndata: ${JSON.stringify({ text: "천천히 말해볼게요." })}\n\n`); + res.end( + `event: done\ndata: ${JSON.stringify({ + provider: "e2e", + model: "fake-client", + tokens_in: 1, + tokens_out: 1, + cost_usd: 0, + turns: 1, + })}\n\n`, + ); + return; + } res.writeHead(404, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "not found" })); }); @@ -836,7 +855,7 @@ test.describe("voice cascade success path", () => { test("drives one voice turn through the Session mic UI with synthetic browser audio @single-run", async ({ page, }, testInfo) => { - test.setTimeout(90_000); + test.setTimeout(150_000); const diagnostics: string[] = []; page.on("pageerror", (error) => diagnostics.push(`pageerror: ${error.message}`)); @@ -848,7 +867,10 @@ test.describe("voice cascade success path", () => { }); page.on("response", (response) => { const url = response.url(); - if (response.status() >= 400 && (url.includes("/sessions") || url.includes("/voice/ws"))) { + if ( + response.status() >= 400 && + (url.includes("/sessions") || url.includes("/voice/ws") || url.includes("/voice/speech")) + ) { diagnostics.push(`response: ${response.status()} ${url}`); } }); @@ -964,6 +986,39 @@ test.describe("voice cascade success path", () => { ].join("\n\n"), ).toBeVisible({ timeout: 20_000 }); + const textInput = page.getByLabel("학습자 발화 입력"); + const sendButton = page.getByRole("button", { name: "보내기", exact: true }); + await textInput.fill("텍스트로 말해도 내담자 음성을 들려주세요."); + await sendButton.click(); + await expect + .poll( + () => openai.requests().filter((request) => request === "POST /v1/audio/speech").length, + { timeout: 30_000 }, + ) + .toBeGreaterThan(0); + await expect + .poll(async () => (await readVoiceUiProbe(page)).audioBufferStarts, { timeout: 30_000 }) + .toBeGreaterThan(0); + await expect(page.locator(".sx-utt").filter({ hasText: "괜찮아요. 천천히 말해볼게요." })).toBeVisible(); + + const freshVoiceSession = await page.evaluate(async ({ apiBase, personaCode }) => { + const response = await fetch(`${apiBase}/sessions`, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ persona_code: personaCode, theory_mode: "humanistic" }), + }); + return { + ok: response.ok, + status: response.status, + body: await response.json() as { session_id?: string }, + }; + }, { apiBase: api.baseURL, personaCode: SEEDED_VOICE_PERSONA_CODE }); + expect(freshVoiceSession, api.logs()).toMatchObject({ ok: true }); + expect(typeof freshVoiceSession.body.session_id).toBe("string"); + await page.goto(`${web.baseURL}/learn/session/${freshVoiceSession.body.session_id}`); + await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 20_000 }); + const mic = page.locator(".sx-mic"); await expect(mic).toBeEnabled(); await mic.click(); diff --git a/apps/web/src/lib/api.gen.ts b/apps/web/src/lib/api.gen.ts index 0c6d3cf..d4c4ec7 100644 --- a/apps/web/src/lib/api.gen.ts +++ b/apps/web/src/lib/api.gen.ts @@ -407,6 +407,26 @@ export interface paths { patch?: never; trace?: never; }; + "/client-diagnostics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Record Client Diagnostic + * @description Log a minimal browser-side boot/render failure report. + */ + post: operations["record_client_diagnostic_client_diagnostics_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/eval/health": { parameters: { query?: never; @@ -1341,6 +1361,30 @@ export interface paths { patch?: never; trace?: never; }; + "/voice/speech": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Voice Speech + * @description Synthesize the persisted client reply for a completed text turn. + * + * The browser sends only session/turn identifiers. The server reloads the + * owned session and speaks the stored client-visible reply, so this endpoint + * cannot be used as an arbitrary paid text-to-speech proxy. + */ + post: operations["voice_speech_voice_speech_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1951,6 +1995,71 @@ export interface components { /** Source Id */ source_id?: string | null; }; + /** ClientDiagnosticError */ + ClientDiagnosticError: { + /** At */ + at?: string | null; + /** + * Detail + * @default + */ + detail: string; + /** + * Kind + * @default + */ + kind: string; + }; + /** ClientDiagnosticRequest */ + ClientDiagnosticRequest: { + /** + * Asset + * @default + */ + asset: string; + /** Body Text Length */ + body_text_length?: number | null; + /** + * Document Ready State + * @default + */ + document_ready_state: string; + /** Elapsed Ms */ + elapsed_ms?: number | null; + /** Errors */ + errors?: components["schemas"]["ClientDiagnosticError"][]; + /** + * Href + * @default + */ + href: string; + /** + * Path + * @default + */ + path: string; + /** + * Reason + * @default unknown + */ + reason: string; + /** Root Child Count */ + root_child_count?: number | null; + /** Root Text Length */ + root_text_length?: number | null; + /** + * User Agent + * @default + */ + user_agent: string; + /** + * Viewport + * @default + */ + viewport: string; + /** Visible Nodes */ + visible_nodes?: number | null; + }; /** * ClientStateRead * @description 내담자 상태 '읽기' — 학습자 발화 직후 내담자 응답에서 관측된 상태(읽기 채점 근거). @@ -4368,6 +4477,16 @@ export interface components { /** Voice Id */ voice_id: string; }; + /** + * VoiceSpeechRequest + * @description Request OpenAI TTS for an already-persisted client reply. + */ + VoiceSpeechRequest: { + /** Session Id */ + session_id: string; + /** Turn Seq */ + turn_seq: number; + }; }; responses: never; parameters: never; @@ -5140,6 +5259,41 @@ export interface operations { }; }; }; + record_client_diagnostic_client_diagnostics_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ClientDiagnosticRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: boolean; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; eval_health_eval_health_get: { parameters: { query?: never; @@ -6953,4 +7107,40 @@ export interface operations { }; }; }; + voice_speech_voice_speech_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["VoiceSpeechRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 172dd9b..234fcf6 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -447,6 +447,21 @@ export const sessionApi = { return { available: false, reason: "voice health check failed" }; } }, + speakClientTurn: async (sessionId: string, turnSeq: number): Promise => { + const r = await fetch(apiUrl("/voice/speech"), { + method: "POST", + credentials: "include", + headers: { + Accept: "audio/mpeg", + "Content-Type": "application/json", + }, + body: JSON.stringify({ session_id: sessionId, turn_seq: turnSeq }), + }); + if (!r.ok) throw await parseError(r); + const audio = await r.blob(); + if (!audio.size) throw new ApiError(502, "TTS 응답 오디오가 비어 있습니다."); + return audio; + }, get: (sessionId: string) => api.get(`/sessions/${encodeURIComponent(sessionId)}`), start: (persona_code: string, theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic") => diff --git a/apps/web/src/pages/Session.tsx b/apps/web/src/pages/Session.tsx index c72c815..7a02b37 100644 --- a/apps/web/src/pages/Session.tsx +++ b/apps/web/src/pages/Session.tsx @@ -886,6 +886,8 @@ export default function Session() { const audioContextRef = useRef(null); const ttsChunksRef = useRef([]); const ttsPlaybackCleanupRef = useRef<(() => void) | null>(null); + const ttsPlaybackRequestRef = useRef(0); + const playTtsAudioRef = useRef<(() => Promise) | null>(null); const pendingVoiceLearnerIdRef = useRef(null); const pendingVoiceLearnerTextRef = useRef(""); const coachEvidenceCloseRef = useRef(null); @@ -930,6 +932,7 @@ export default function Session() { }, [ensureVoiceAudioContext]); const stopTtsPlayback = useCallback(() => { + ttsPlaybackRequestRef.current += 1; const cleanup = ttsPlaybackCleanupRef.current; ttsPlaybackCleanupRef.current = null; if (cleanup) cleanup(); @@ -1405,6 +1408,30 @@ export default function Session() { } }, [acceptConsent, consentChecked, pushSignal]); + const speakTextClientTurn = useCallback( + async (sessionId: string, turnSeq: number) => { + const requestId = ttsPlaybackRequestRef.current + 1; + ttsPlaybackRequestRef.current = requestId; + setVoiceStatus("speaking"); + setVoiceDetail("OpenAI 음성을 생성하고 있습니다."); + try { + const audio = await sessionApi.speakClientTurn(sessionId, turnSeq); + if (ttsPlaybackRequestRef.current !== requestId) return; + ttsChunksRef.current = [audio]; + const play = playTtsAudioRef.current; + if (!play) throw new Error("voice playback is not ready"); + await play(); + } catch { + if (ttsPlaybackRequestRef.current !== requestId) return; + setAvatarState("listening"); + setVoiceStatus("degraded"); + setVoiceDetail("OpenAI 음성을 재생하지 못했습니다. 자막 응답은 화면에 남겼습니다."); + pushSignal("warn", "AI 음성 재생 실패"); + } + }, + [pushSignal], + ); + const appendServerClientReply = useCallback( (replyText: string | null, at = elapsed, options?: { suppressEmptyWarning?: boolean }) => { setClientReplyPending(false); @@ -1450,6 +1477,8 @@ export default function Session() { setClientReplyPending(true); setAvatarState("thinking"); setTurnError(null); + stopTtsPlayback(); + void primeVoicePlayback(); let clientId: number | null = null; let clientReply = ""; @@ -1539,6 +1568,9 @@ export default function Session() { } setAvatarState("listening"); if (!conversationStopped && !qualityRetryable) { + if (clientReply && typeof done.turn_seq === "number") { + void speakTextClientTurn(liveSessionId, done.turn_seq); + } void requestLiveCoach({ learnerText: text, clientReply, @@ -1580,7 +1612,10 @@ export default function Session() { liveSessionId, elapsed, pushSignal, + primeVoicePlayback, requestLiveCoach, + speakTextClientTurn, + stopTtsPlayback, ]); const onComposeKey = (e: ReactKeyboardEvent) => { @@ -1752,6 +1787,15 @@ export default function Session() { } }, [clientName, closeVoiceSocket, ensureVoiceAudioContext, stopTtsPlayback]); + useEffect(() => { + playTtsAudioRef.current = playTtsAudio; + return () => { + if (playTtsAudioRef.current === playTtsAudio) { + playTtsAudioRef.current = null; + } + }; + }, [playTtsAudio]); + const startVoiceCapture = useCallback(async () => { if (!liveSessionId || paused || sending || sessionEnded) return; if (!navigator.mediaDevices?.getUserMedia) { diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index 2964544..c9483a5 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -562,6 +562,7 @@

125차 적용(2026-07-01): 긴 회기 평가가 기존 짧은 리뷰 polling window를 넘기면 사용자는 평가 대기에 고착된 것처럼 볼 수 있었다. SessionReview의 ready polling window를 약 90초로 늘리고, 10번째 review 응답에서야 평가 완료가 되는 route-fixture 회귀를 추가했다. 검증: npm run typecheck, PLAYWRIGHT_PORT=5253 npx playwright test e2e/session-review.spec.ts --project=chromium-desktop --workers=1 --grep "long-running session evaluation" 1 passed, 현재 Playwright 수집 기준 188 tests in 19 files. 로컬 작업트리 기준이며 배포 전이다. 서브에이전트가 찾은 추가 후보(teacher dashboard stale-missing 불일치, 중복 POST /end 재평가 덮어쓰기, RAG/source-pack skip)는 이번 라운드에서는 후속으로 남겼다.

126차 적용(2026-07-02): 125차 후속으로 남긴 AI 평가 silent error 후보를 닫았다. 교수자 대시보드도 상세 리뷰와 같은 missing session evaluation 규칙을 써서 timeout+grace 이후 evaluation row가 없으면 평가 실패와 재시도 필요 사유를 표시한다. 이미 종료된 세션에 POST /sessions/{id}/end가 다시 들어와도 background session evaluation을 재예약하지 않아 기존 ready row가 새 실패로 덮이지 않는다. AI 평가가 ready가 아니면 교수자 리뷰를 검토 완료로 닫아 pending queue에서 숨길 수 없고, UI의 검토 완료 버튼도 비활성화된다. kb-source-packs.spec.ts는 source-scoped evaluator retrieval 503을 더 이상 skip하지 않는다. 검증: C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider apps/api/app/test_teacher_dashboard.py apps/api/app/test_session_turn_persistence.py -k "teacher_cannot_close_review_before_ai_session_evaluation_ready or teacher_can_mark_session_review_closed_with_note or end_session_does_not_reschedule_evaluation_for_already_ended_session or session_evaluation or stale_missing" -q 8 passed, npm run typecheck, PLAYWRIGHT_PORT=5254 npx playwright test e2e/session-review.spec.ts --project=chromium-desktop --workers=1 --grep "manual AI retry|long-running session evaluation|retry a failed AI session evaluation|retry fails" 4 passed, PLAYWRIGHT_PORT=5255 npx playwright test e2e/kb-source-packs.spec.ts --project=chromium-single-run --workers=1 1 passed, 현재 Playwright 수집 기준 188 tests in 19 files. 로컬 작업트리 기준이며 배포 전이다.

127차 적용(2026-07-02): 마무리 전 AI 튜터/음성 위기 UI의 silent error 두 개만 좁게 닫았다. 세션 UI의 로컬 코칭 quota가 0으로 stale해도 즉시 차단하지 않고 서버 GET /live-coach quota를 재조회한 뒤 실제 잔여 기회가 있으면 POST /live-coach를 계속 진행한다. quota exhaustion이나 재조회 실패는 이전 코칭 카드 아래에 묻히지 않도록 coachError를 우선 표시한다. 음성 reply.conversation_stopped가 빈 텍스트로 끝나는 위기 경로는 TTS 종료 이벤트를 기다리지 않고 socket을 닫아 109 안전 게이트와 입력 disabled 상태를 유지한다. 검증: npm run typecheck, PLAYWRIGHT_PORT=5259 npx playwright test e2e/session-mvp.spec.ts --project=chromium-desktop --workers=1 --grep "AI tutor|stale empty AI tutor quota|quota exhaustion|degraded AI tutor|voice conversation stop" 7 passed, 현재 Playwright 수집 기준 194 tests in 19 files. 로컬 작업트리 기준이며 배포 전이다. 서브에이전트가 지적한 live coach LLM audit 실패 은폐는 다음 라운드 후보로 남긴다.

+

128차 적용(2026-07-13): 텍스트 입력으로 생성된 AI 내담자 응답이 저장·표시만 되고 OpenAI TTS를 호출하지 않던 공백을 닫았다. 인증된 POST /voice/speech는 소유 회기의 저장된 client-visible 응답만 합성하고, Session은 텍스트 발화 전 사용자 제스처에서 Web Audio를 준비한 뒤 MP3를 재생한다. 새 발화는 이전 합성·재생을 취소한다. 검증: backend voice 32 passed, voice/session focused 91 passed, npm run typecheck, npm run check:api-types, npm run build, PLAYWRIGHT_PORT=5271 npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run --workers=1 2 passed, 운영 OpenAI TTS 직접 smoke는 98,133-byte MP3(11.68초)를 반환했다. 배포 증거는 아래 production row에 동기화한다.

20차 적용(2026-06-28): 신규 Google/SAML 사용자는 account_status=pending으로 시작하고 승인 전에는 /pending 안내 화면만 본다. yunchan@twentyoz.kr는 슈퍼 관리자 allowlist로 admin+approved를 받으며, /admin/users는 가입 승인 탭에서 pending 계정을 승인 또는 보류 처리한다.

21차 적용(2026-06-28): 관리자 페이지 진입권을 기본 역할과 분리해 app_user.admin_access로 저장한다. AUTH_SUPER_ADMIN_EMAILS 기본값은 yunchan@twentyoz.kr, hoonjungkoo@hs.ac.kr이며, 슈퍼 관리자는 학습자·교수자·관리자 공간 전환과 관리자 권한 부여/회수를 할 수 있다. 학생·교수 계정도 admin_access=true면 우측 상단 관리자 진입이 노출된다. 구성 슈퍼 관리자의 권한 회수와 계정 비활성화는 차단한다.

25차 적용(2026-06-28): /admin/users에서 허용 도메인 밖 이메일도 정확한 계정 단위로 강제 등록할 수 있다. Google/SAML/dev-login은 미리 등록된 이메일만 도메인 게이트 예외로 통과시키고, provider 로그인 시 기존 관리 row의 역할·코호트·승인 상태를 이어받는다. 미등록 외부 도메인 로그인은 계속 차단한다.

@@ -977,7 +978,7 @@ H4 consent gatepy -3.11 -X utf8 -m pytest -p no:cacheprovider app/test_voice_ws.py -q / PLAYWRIGHT_PORT=5231 npx playwright test e2e/voice.spec.ts --project=chromium-single-run --workers=1 --grep "consent withdrawal"16 backend passed + browser WS 1 passed; voice binding now checks onboarding/consent before both existing session_id and dev persona_code paths. 동의 철회 뒤 기존 voice session id 재접속도 consent_required로 닫히며, 같은 suite가 voice turn 저장 실패 구조화 error도 검증한다. H1 pre/post evidence API + KPI exportapp/test_user_support_tickets.py, app/test_runtime_policy.py, app/test_admin_ops.py, app/test_phase3_kpi_export.py, app/test_phase3_artifact_checker.py, npm run check:api-types, npm run typecheck, e2e/session-review.spec.ts, e2e/session-persistence.spec.tsCurrent H1 broad 46 passed; KPI contract focused 10 passed. app.learner_prepost_measure stores learner-scoped aggregate pre/post scores for self-efficacy, skill proficiency, and training satisfaction. scripts/export-phase3-kpi.py emits pseudonymous prepost_measures.csv and KPI report scaffold with paired normalized deltas. phase3_kpi_contract.py owns metric names, required keys, and computed_prepost/design_pending status values used by exporter/checker/tests. Generated API contract exposes UserPrepostMeasure*. 학습자 SessionReview는 1~5 aggregate score 조회/저장 카드를 표시하고, 기존 저장 점수를 비우는 입력은 invalid로 표면화해 저장된 값 기준으로 오인하지 않게 한다. 최신 route-fixture pre/post focused E2E 1 passed, DB-backed pre/post 저장·재조회 E2E까지 포함한 session-persistence 7 passed + visual gate 2 passed. Persona review workflowapp.test_persona_review31 tests OK; approved-only catalog, teacher/admin queue, draft authoring, approved persona revision, archive audit, learner 403 - Voice WS contractapp.test_voice_ws16 tests OK; auth guard, onboarding/consent guard for existing session binding, audio_start PCM metadata, binary chunks, audio_end, text_turn, stt_result ready/pending, ping, max audio cap, TTS chunk envelope, and structured turn_persistence_unavailable error payload + Voice WS/REST contractapp.test_voice_ws18 tests OK; auth guard, onboarding/consent guard, audio_start PCM metadata, binary chunks, audio_end, text_turn, stt_result ready/pending, ping, max audio cap, TTS chunk envelope, structured turn_persistence_unavailable error payload, and owned persisted client-visible turn only POST /voice/speech synthesis Voice preset/EOTapp.test_voice_service14 tests OK; TTS payload, preset fallback, DB voice-map resolver, unsupported provider fallback, P1 sample TTS, EOT readiness Voice metadatapython -X utf8 -m pytest -p no:cacheprovider app/test_runtime_policy.py app/test_session_turn_persistence.py app/test_voice_ws.py app/test_voice_service.py -q + PLAYWRIGHT_PORT=5197 npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run --workers=1 + PLAYWRIGHT_PORT=5205 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=177 backend passed + voice-success 2 passed + latest session-persistence 7 passed; audio_ref/silence_ms/speech_rate/barge_in/provider_events persisted or readiness-checked on learner voice turn, Session 마이크 UI는 browser voice activity/trailing silence 메타를 audio_end에 전송, provider_events JSONB 보존·sanitizing·taxonomy·client turn 미혼입 검증, DB-backed review nonverbal은 timing/audio와 제한된 paralinguistic/prosody/audio_quality 칩을 중복 침묵 없이 노출 Session end/evaluation recovery + AI tutor E2EPLAYWRIGHT_PORT=5205 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 / PLAYWRIGHT_PORT=5238 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "finishes session end evaluation" / PLAYWRIGHT_PORT=5237 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "explicit teacher session reevaluation" / PLAYWRIGHT_PORT=5245 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "manual AI evaluation retry" / PLAYWRIGHT_PORT=5254 npx playwright test e2e/session-review.spec.ts --project=chromium-desktop --workers=1 --grep "manual AI retry|long-running session evaluation|retry a failed AI session evaluation|retry fails" / py -3.11 -X utf8 -m pytest -p no:cacheprovider app/test_notifications.py app/test_session_turn_persistence.py app/test_evaluation_persistence.py -k "session_evaluation or missing_session_evaluation or scheduled_session_evaluation" -qDB-backed session-persistence 7 passed가 실제 DB/API/엔진으로 세션 생성→턴→종료→background deep 평가 durable row→교수자 리뷰 평가 완료까지 검증한다. 추가 자동 평가 E2E 1 passed는 같은 persisted row가 /teacher/dashboardevaluation_status=ready, review_ready=true, supervisor_state=평가 완료로 반영되는지 확인한다. 명시 재평가 1 passed는 교수자 POST /eval/sessions/{id}/reevaluate가 durable app.session_evaluation row를 저장하고 /eval/.../evaluation/review평가 완료로 반영되는지 확인한다. 수동 UI 재시도 E2E 1 passed는 실제 실패 row를 만든 뒤 교수자 리뷰의 AI 평가 재시도 버튼 클릭이 real POST /eval/sessions/{id}/reevaluate → durable ready row → review/dashboard ready 상태로 이어지는지 확인한다. 최신 backend/UI focused 8 passed + 4 passed는 오래된 missing evaluation row가 교수자 대시보드에서도 평가 실패로 보이고, 이미 종료된 session /end 재호출이 평가를 재예약하지 않으며, 평가 ready 전에는 검토 완료로 큐에서 숨길 수 없음을 검증한다. Backend recovery focused 16 passed는 종료됐지만 evaluation row가 없는 오래된 DB 세션을 startup recovery가 evaluator AI context로 찾아 기존 session-end 평가를 재예약하고, 같은 session_id 중복 background 평가를 in-flight set으로 막는지 검증한다. 남은 후속은 다중 프로세스 durable claim/job table 수준의 재시도 소유권이다. @@ -985,7 +986,7 @@ C3 theory-mode reevaluationC:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_eval_routes.py -q / PLAYWRIGHT_PORT=5229 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "selected CBT theory mode" / app/test_eval_routes.py app/test_notifications.py app/test_session_turn_persistence.pyeval route 5 passed, focused backend 39 passed, DB-backed browser E2E 1 passed. 실제 Session UI에서 선택한 CBTPOST /sessions payload와 DB-backed GET /sessions/{id} 상세의 theory_mode=cbt로 보존된다. 수동 session 재평가는 persona 기본 theory_target보다 학습자가 선택한 session theory_mode를 우선하고, turn 재평가도 TurnContext.theory_mode에 같은 값을 전달한다. Crisis safety gate UIPLAYWRIGHT_PORT=5193 npx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run --workers=1 / PLAYWRIGHT_PORT=5259 npx playwright test e2e/session-mvp.spec.ts --project=chromium-desktop --workers=1 --grep "voice conversation stop"mock SSE safety + done.conversation_stopped와 mock voice reply.conversation_stopped에서 안전 자원 109와 입력 disabled가 유지되고, 빈 client reply를 내담자 응답 없음으로 덮지 않으며 live-coach를 호출하지 않는지 검증한다. 음성 위기 경로는 TTS 종료 이벤트 없이도 socket close를 확인한다. Persona/session review UI E2EPLAYWRIGHT_PORT=5215 npx playwright test e2e/teacher.spec.ts --project=chromium-single-run --workers=19 passed mixed suite; real server-row rendering과 route-fixture UI 흐름을 함께 포함한다. 교수자가 pending persona를 승인하고, pending/recent review detail로 진입하며, 별도 student analysis 메뉴·학습자 검색·행 펼침·full learner timeline drilldown을 검증한다. - Voice UI synthetic E2Enpx playwright test e2e/voice-success.spec.ts --project=chromium-single-run / PLAYWRIGHT_PORT=5248 npx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run --workers=1 --grep "pending voice transcript"voice-success 2 passed + pending transcript failure UI 1 passed; direct WS cascade, Session mic button path with synthetic browser audio/Web Audio playback, and final transcript followed by persistence failure becoming a visible 저장 실패 turn instead of a silent successful transcript + Voice UI synthetic E2Enpx playwright test e2e/voice-success.spec.ts --project=chromium-single-run / PLAYWRIGHT_PORT=5248 npx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run --workers=1 --grep "pending voice transcript"voice-success 2 passed + pending transcript failure UI 1 passed; direct WS cascade, Session 텍스트 턴의 persisted reply /voice/speech→OpenAI speech→Web Audio 재생, Session mic button path with synthetic browser audio/Web Audio playback, and final transcript followed by persistence failure becoming a visible 저장 실패 turn instead of a silent successful transcript Voice s2s decision memodocs/decisions/voice-s2s-poc.mdcriteria recorded; keep/drop decision remains owner DECIDE Hanshin data/SSO gatedocs/ops/hanshin-data-governance-gate.mdartifact created; written external evidence still required Hanshin feedback 1차 improvement packdocs/ops/hanshin-feedback-improvement-plan-2026-07-03.md / docs/redteam/hanshin-feedback-plan-redteam-2026-07-03.md / docs/redteam/hanshin-feedback-plan-counter-redteam-2026-07-03.md / backend+web implementation1차 implemented: P1 role mapping + client-only history injection, P2 quality retry/no-save gate + SSE full-response buffer, P3 review display humanization/raw error mapper/fast-deep labels/client feedback label, P4 supervisor scope panel. Verification: backend focused 84 passed, web typecheck and API type check passed, session-review focused 8 passed, layout visual gate 12 passed, session-layout 8 passed. Remaining follow-ups are stored fallback metadata, lower-latency partial stream policy, fast/deep reconciliation, resident namespace/prompt caching, and clinical rubric/CBT wording gates. @@ -1017,7 +1018,7 @@ Learner/readiness E2Elearner.spec.ts + readiness.spec.ts desktop/mobile14 passed Learner screenshotslearn-empty-desktop.png / learn-empty-mobile-compact.pngdocument overflow 0, empty history visible Session layout/turnsession-layout.spec.ts desktop/mobile8 passed - Session voice guard/view-modelnpm run typecheck / npm run build / npx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run --workers=1 / npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run --workers=1 / npx playwright test e2e/session-layout.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1typecheck/build passed; P1 text MVP 1 passed, voice cascade + Session mic UI 2 passed, session-layout desktop/mobile 8 passed. isVoiceStatusBusy() and sessionVoiceStatusView() own mic busy/disabled, aria label, transcript/response/status labels, and text-turn blocking without changing WebSocket/TTS lifecycle, DOM/CSS, API payload, or the recording click-to-send path. + Session voice guard/view-modelnpm run typecheck / npm run build / npx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run --workers=1 / npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run --workers=1 / npx playwright test e2e/session-layout.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1typecheck/build passed; P1 text MVP 1 passed, voice cascade + Session text/mic UI 2 passed, session-layout desktop/mobile 8 passed. isVoiceStatusBusy() and sessionVoiceStatusView() own mic busy/disabled, aria label, transcript/response/status labels, and text-turn blocking; persisted text-turn replies now share the OpenAI TTS Web Audio lifecycle without changing the recording click-to-send path. Session end + dark theme UXnpm run typecheck, npm run build, layout-visual-gate, session-layout, avatar-expression, settings, session-reviewtypecheck/build passed; Playwright 묶음 36 passed. P1 active session은 rasterArtSet="seoyeon-live2d-psd-v2"를 사용한다. sad 표정은 PSD 기반 눈물/우는 입/울상 눈썹 파츠를 렌더한다. Settings now treats API system preference as current initial theme instead of forcing light. Voice/session focusedvoice + voice-success + session-layout15 passed Layout redesign handoffdocs/archive/ops/layout-redesign-handoff-2026-06-26.mdsubagent scopes, files, verification, remaining visual review recorded diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md index b42a19b..bb16d09 100644 --- a/docs/guides/architecture.md +++ b/docs/guides/architecture.md @@ -298,9 +298,12 @@ OpenAI STT/TTS 어댑터(순수 변환 + voice preset 매핑). 상담 로직은 OpenAI가 아닌 provider row는 현재 live OpenAI TTS로 보내지 않고 기존 fallback을 사용한다. dev 런타임 스키마 보강은 기존 DB의 `app.persona_voice_map` 누락도 복구해 seed materializer와 `/voice/ws` 바인딩이 같은 테이블을 사용하게 한다. - 프론트는 마이크 클릭 시 `AudioContext`를 먼저 resume해 재생 권한을 확보하고, TTS Blob을 Web Audio - buffer source로 재생하면서 `AnalyserNode`로 립싱크 RMS를 산출한다. 디코딩 실패 시 `