텍스트 응답 음성 재생 연결

This commit is contained in:
Yun Chan 2026-07-13 16:09:34 +09:00
parent 64e06a1185
commit d80e33da5e
9 changed files with 524 additions and 16 deletions

View file

@ -18,14 +18,15 @@ import hashlib
import time import time
from typing import Optional from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException, status
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel, Field
from starlette.websockets import WebSocketState from starlette.websockets import WebSocketState
from .. import session_persistence, turn_runtime from .. import session_persistence, turn_runtime
from ..auth_sessions import get_session, user_has_consent, user_onboarding_complete from ..auth_sessions import get_session, user_has_consent, user_onboarding_complete
from ..config import settings from ..config import settings
from ..deps import Principal, Role from ..deps import CurrentPrincipal, Principal, Role
from ..engine_client import EngineError, engine_client from ..engine_client import EngineError, engine_client
from ..persona_repository import ( from ..persona_repository import (
PersonaVoiceMap, PersonaVoiceMap,
@ -41,6 +42,13 @@ from ..store import InProcSession, TurnRecord, store
router = APIRouter(prefix="/voice", tags=["voice"]) 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. # WebSocket close codes.
WS_CLOSE_DEGRADED = 1011 WS_CLOSE_DEGRADED = 1011
WS_CLOSE_BAD_REQUEST = 1008 WS_CLOSE_BAD_REQUEST = 1008
@ -123,6 +131,89 @@ async def voice_health() -> JSONResponse:
return JSONResponse(body, status_code=200 if available else 503) 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") @router.websocket("/ws")
async def voice_ws(websocket: WebSocket) -> None: async def voice_ws(websocket: WebSocket) -> None:
"""Run one authenticated learner voice cascade.""" """Run one authenticated learner voice cascade."""
@ -596,6 +687,19 @@ async def _load_voice_session(
return sess, None 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: async def _principal_from_websocket(websocket: WebSocket) -> Principal | None:
"""Restore the same server-side browser session used by REST routes.""" """Restore the same server-side browser session used by REST routes."""
raw_cookie = websocket.cookies.get(settings.cookie_name) raw_cookie = websocket.cookies.get(settings.cookie_name)

View file

@ -12,7 +12,8 @@ from fastapi import HTTPException
from .deps import Principal, Role from .deps import Principal, Role
from .persona_repository import PersonaVoiceMap from .persona_repository import PersonaVoiceMap
from .routes import voice as voice_routes 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" 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: async def test_audio_start_binary_chunks_audio_end_ping_close_contract(self) -> None:
websocket = FakeWebSocket( websocket = FakeWebSocket(
[ [

View file

@ -139,6 +139,25 @@ async function startFakeEngine(): Promise<TestServer> {
); );
return; 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.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not found" })); 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 ({ test("drives one voice turn through the Session mic UI with synthetic browser audio @single-run", async ({
page, page,
}, testInfo) => { }, testInfo) => {
test.setTimeout(90_000); test.setTimeout(150_000);
const diagnostics: string[] = []; const diagnostics: string[] = [];
page.on("pageerror", (error) => diagnostics.push(`pageerror: ${error.message}`)); page.on("pageerror", (error) => diagnostics.push(`pageerror: ${error.message}`));
@ -848,7 +867,10 @@ test.describe("voice cascade success path", () => {
}); });
page.on("response", (response) => { page.on("response", (response) => {
const url = response.url(); 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}`); diagnostics.push(`response: ${response.status()} ${url}`);
} }
}); });
@ -964,6 +986,39 @@ test.describe("voice cascade success path", () => {
].join("\n\n"), ].join("\n\n"),
).toBeVisible({ timeout: 20_000 }); ).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"); const mic = page.locator(".sx-mic");
await expect(mic).toBeEnabled(); await expect(mic).toBeEnabled();
await mic.click(); await mic.click();

View file

@ -407,6 +407,26 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/eval/health": {
parameters: { parameters: {
query?: never; query?: never;
@ -1341,6 +1361,30 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
@ -1951,6 +1995,71 @@ export interface components {
/** Source Id */ /** Source Id */
source_id?: string | null; 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 * ClientStateRead
* @description '읽기' ( ). * @description '읽기' ( ).
@ -4368,6 +4477,16 @@ export interface components {
/** Voice Id */ /** Voice Id */
voice_id: string; 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; responses: never;
parameters: 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: { eval_health_eval_health_get: {
parameters: { parameters: {
query?: never; 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"];
};
};
};
};
} }

View file

@ -447,6 +447,21 @@ export const sessionApi = {
return { available: false, reason: "voice health check failed" }; return { available: false, reason: "voice health check failed" };
} }
}, },
speakClientTurn: async (sessionId: string, turnSeq: number): Promise<Blob> => {
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) => get: (sessionId: string) =>
api.get<SessionDetailResponse>(`/sessions/${encodeURIComponent(sessionId)}`), api.get<SessionDetailResponse>(`/sessions/${encodeURIComponent(sessionId)}`),
start: (persona_code: string, theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic") => start: (persona_code: string, theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic") =>

View file

@ -886,6 +886,8 @@ export default function Session() {
const audioContextRef = useRef<AudioContext | null>(null); const audioContextRef = useRef<AudioContext | null>(null);
const ttsChunksRef = useRef<BlobPart[]>([]); const ttsChunksRef = useRef<BlobPart[]>([]);
const ttsPlaybackCleanupRef = useRef<(() => void) | null>(null); const ttsPlaybackCleanupRef = useRef<(() => void) | null>(null);
const ttsPlaybackRequestRef = useRef(0);
const playTtsAudioRef = useRef<(() => Promise<void>) | null>(null);
const pendingVoiceLearnerIdRef = useRef<number | null>(null); const pendingVoiceLearnerIdRef = useRef<number | null>(null);
const pendingVoiceLearnerTextRef = useRef<string>(""); const pendingVoiceLearnerTextRef = useRef<string>("");
const coachEvidenceCloseRef = useRef<HTMLButtonElement>(null); const coachEvidenceCloseRef = useRef<HTMLButtonElement>(null);
@ -930,6 +932,7 @@ export default function Session() {
}, [ensureVoiceAudioContext]); }, [ensureVoiceAudioContext]);
const stopTtsPlayback = useCallback(() => { const stopTtsPlayback = useCallback(() => {
ttsPlaybackRequestRef.current += 1;
const cleanup = ttsPlaybackCleanupRef.current; const cleanup = ttsPlaybackCleanupRef.current;
ttsPlaybackCleanupRef.current = null; ttsPlaybackCleanupRef.current = null;
if (cleanup) cleanup(); if (cleanup) cleanup();
@ -1405,6 +1408,30 @@ export default function Session() {
} }
}, [acceptConsent, consentChecked, pushSignal]); }, [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( const appendServerClientReply = useCallback(
(replyText: string | null, at = elapsed, options?: { suppressEmptyWarning?: boolean }) => { (replyText: string | null, at = elapsed, options?: { suppressEmptyWarning?: boolean }) => {
setClientReplyPending(false); setClientReplyPending(false);
@ -1450,6 +1477,8 @@ export default function Session() {
setClientReplyPending(true); setClientReplyPending(true);
setAvatarState("thinking"); setAvatarState("thinking");
setTurnError(null); setTurnError(null);
stopTtsPlayback();
void primeVoicePlayback();
let clientId: number | null = null; let clientId: number | null = null;
let clientReply = ""; let clientReply = "";
@ -1539,6 +1568,9 @@ export default function Session() {
} }
setAvatarState("listening"); setAvatarState("listening");
if (!conversationStopped && !qualityRetryable) { if (!conversationStopped && !qualityRetryable) {
if (clientReply && typeof done.turn_seq === "number") {
void speakTextClientTurn(liveSessionId, done.turn_seq);
}
void requestLiveCoach({ void requestLiveCoach({
learnerText: text, learnerText: text,
clientReply, clientReply,
@ -1580,7 +1612,10 @@ export default function Session() {
liveSessionId, liveSessionId,
elapsed, elapsed,
pushSignal, pushSignal,
primeVoicePlayback,
requestLiveCoach, requestLiveCoach,
speakTextClientTurn,
stopTtsPlayback,
]); ]);
const onComposeKey = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => { const onComposeKey = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
@ -1752,6 +1787,15 @@ export default function Session() {
} }
}, [clientName, closeVoiceSocket, ensureVoiceAudioContext, stopTtsPlayback]); }, [clientName, closeVoiceSocket, ensureVoiceAudioContext, stopTtsPlayback]);
useEffect(() => {
playTtsAudioRef.current = playTtsAudio;
return () => {
if (playTtsAudioRef.current === playTtsAudio) {
playTtsAudioRef.current = null;
}
};
}, [playTtsAudio]);
const startVoiceCapture = useCallback(async () => { const startVoiceCapture = useCallback(async () => {
if (!liveSessionId || paused || sending || sessionEnded) return; if (!liveSessionId || paused || sending || sessionEnded) return;
if (!navigator.mediaDevices?.getUserMedia) { if (!navigator.mediaDevices?.getUserMedia) {

View file

@ -562,6 +562,7 @@
<p class="dg-note">125차 적용(2026-07-01): 긴 회기 평가가 기존 짧은 리뷰 polling window를 넘기면 사용자는 <code>평가 대기</code>에 고착된 것처럼 볼 수 있었다. <code>SessionReview</code>의 ready polling window를 약 90초로 늘리고, 10번째 review 응답에서야 <code>평가 완료</code>가 되는 route-fixture 회귀를 추가했다. 검증: <code>npm run typecheck</code>, <code>PLAYWRIGHT_PORT=5253 npx playwright test e2e/session-review.spec.ts --project=chromium-desktop --workers=1 --grep "long-running session evaluation"</code> 1 passed, 현재 Playwright 수집 기준 <code>188 tests in 19 files</code>. 로컬 작업트리 기준이며 배포 전이다. 서브에이전트가 찾은 추가 후보(teacher dashboard stale-missing 불일치, 중복 <code>POST /end</code> 재평가 덮어쓰기, RAG/source-pack skip)는 이번 라운드에서는 후속으로 남겼다.</p> <p class="dg-note">125차 적용(2026-07-01): 긴 회기 평가가 기존 짧은 리뷰 polling window를 넘기면 사용자는 <code>평가 대기</code>에 고착된 것처럼 볼 수 있었다. <code>SessionReview</code>의 ready polling window를 약 90초로 늘리고, 10번째 review 응답에서야 <code>평가 완료</code>가 되는 route-fixture 회귀를 추가했다. 검증: <code>npm run typecheck</code>, <code>PLAYWRIGHT_PORT=5253 npx playwright test e2e/session-review.spec.ts --project=chromium-desktop --workers=1 --grep "long-running session evaluation"</code> 1 passed, 현재 Playwright 수집 기준 <code>188 tests in 19 files</code>. 로컬 작업트리 기준이며 배포 전이다. 서브에이전트가 찾은 추가 후보(teacher dashboard stale-missing 불일치, 중복 <code>POST /end</code> 재평가 덮어쓰기, RAG/source-pack skip)는 이번 라운드에서는 후속으로 남겼다.</p>
<p class="dg-note">126차 적용(2026-07-02): 125차 후속으로 남긴 AI 평가 silent error 후보를 닫았다. 교수자 대시보드도 상세 리뷰와 같은 missing session evaluation 규칙을 써서 timeout+grace 이후 evaluation row가 없으면 <code>평가 실패</code>와 재시도 필요 사유를 표시한다. 이미 종료된 세션에 <code>POST /sessions/{id}/end</code>가 다시 들어와도 background session evaluation을 재예약하지 않아 기존 ready row가 새 실패로 덮이지 않는다. AI 평가가 ready가 아니면 교수자 리뷰를 <code>검토 완료</code>로 닫아 pending queue에서 숨길 수 없고, UI의 <code>검토 완료</code> 버튼도 비활성화된다. <code>kb-source-packs.spec.ts</code>는 source-scoped evaluator retrieval 503을 더 이상 skip하지 않는다. 검증: <code>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</code> 8 passed, <code>npm run typecheck</code>, <code>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"</code> 4 passed, <code>PLAYWRIGHT_PORT=5255 npx playwright test e2e/kb-source-packs.spec.ts --project=chromium-single-run --workers=1</code> 1 passed, 현재 Playwright 수집 기준 <code>188 tests in 19 files</code>. 로컬 작업트리 기준이며 배포 전이다.</p> <p class="dg-note">126차 적용(2026-07-02): 125차 후속으로 남긴 AI 평가 silent error 후보를 닫았다. 교수자 대시보드도 상세 리뷰와 같은 missing session evaluation 규칙을 써서 timeout+grace 이후 evaluation row가 없으면 <code>평가 실패</code>와 재시도 필요 사유를 표시한다. 이미 종료된 세션에 <code>POST /sessions/{id}/end</code>가 다시 들어와도 background session evaluation을 재예약하지 않아 기존 ready row가 새 실패로 덮이지 않는다. AI 평가가 ready가 아니면 교수자 리뷰를 <code>검토 완료</code>로 닫아 pending queue에서 숨길 수 없고, UI의 <code>검토 완료</code> 버튼도 비활성화된다. <code>kb-source-packs.spec.ts</code>는 source-scoped evaluator retrieval 503을 더 이상 skip하지 않는다. 검증: <code>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</code> 8 passed, <code>npm run typecheck</code>, <code>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"</code> 4 passed, <code>PLAYWRIGHT_PORT=5255 npx playwright test e2e/kb-source-packs.spec.ts --project=chromium-single-run --workers=1</code> 1 passed, 현재 Playwright 수집 기준 <code>188 tests in 19 files</code>. 로컬 작업트리 기준이며 배포 전이다.</p>
<p class="dg-note">127차 적용(2026-07-02): 마무리 전 AI 튜터/음성 위기 UI의 silent error 두 개만 좁게 닫았다. 세션 UI의 로컬 코칭 quota가 0으로 stale해도 즉시 차단하지 않고 서버 <code>GET /live-coach</code> quota를 재조회한 뒤 실제 잔여 기회가 있으면 <code>POST /live-coach</code>를 계속 진행한다. quota exhaustion이나 재조회 실패는 이전 코칭 카드 아래에 묻히지 않도록 <code>coachError</code>를 우선 표시한다. 음성 <code>reply.conversation_stopped</code>가 빈 텍스트로 끝나는 위기 경로는 TTS 종료 이벤트를 기다리지 않고 socket을 닫아 109 안전 게이트와 입력 disabled 상태를 유지한다. 검증: <code>npm run typecheck</code>, <code>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"</code> 7 passed, 현재 Playwright 수집 기준 <code>194 tests in 19 files</code>. 로컬 작업트리 기준이며 배포 전이다. 서브에이전트가 지적한 live coach LLM audit 실패 은폐는 다음 라운드 후보로 남긴다.</p> <p class="dg-note">127차 적용(2026-07-02): 마무리 전 AI 튜터/음성 위기 UI의 silent error 두 개만 좁게 닫았다. 세션 UI의 로컬 코칭 quota가 0으로 stale해도 즉시 차단하지 않고 서버 <code>GET /live-coach</code> quota를 재조회한 뒤 실제 잔여 기회가 있으면 <code>POST /live-coach</code>를 계속 진행한다. quota exhaustion이나 재조회 실패는 이전 코칭 카드 아래에 묻히지 않도록 <code>coachError</code>를 우선 표시한다. 음성 <code>reply.conversation_stopped</code>가 빈 텍스트로 끝나는 위기 경로는 TTS 종료 이벤트를 기다리지 않고 socket을 닫아 109 안전 게이트와 입력 disabled 상태를 유지한다. 검증: <code>npm run typecheck</code>, <code>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"</code> 7 passed, 현재 Playwright 수집 기준 <code>194 tests in 19 files</code>. 로컬 작업트리 기준이며 배포 전이다. 서브에이전트가 지적한 live coach LLM audit 실패 은폐는 다음 라운드 후보로 남긴다.</p>
<p class="dg-note">128차 적용(2026-07-13): 텍스트 입력으로 생성된 AI 내담자 응답이 저장·표시만 되고 OpenAI TTS를 호출하지 않던 공백을 닫았다. 인증된 <code>POST /voice/speech</code>는 소유 회기의 저장된 client-visible 응답만 합성하고, Session은 텍스트 발화 전 사용자 제스처에서 Web Audio를 준비한 뒤 MP3를 재생한다. 새 발화는 이전 합성·재생을 취소한다. 검증: backend voice 32 passed, voice/session focused 91 passed, <code>npm run typecheck</code>, <code>npm run check:api-types</code>, <code>npm run build</code>, <code>PLAYWRIGHT_PORT=5271 npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run --workers=1</code> 2 passed, 운영 OpenAI TTS 직접 smoke는 98,133-byte MP3(11.68초)를 반환했다. 배포 증거는 아래 production row에 동기화한다.</p>
<p class="dg-note">20차 적용(2026-06-28): 신규 Google/SAML 사용자는 <code>account_status=pending</code>으로 시작하고 승인 전에는 <code>/pending</code> 안내 화면만 본다. <code>yunchan@twentyoz.kr</code>는 슈퍼 관리자 allowlist로 admin+approved를 받으며, <code>/admin/users</code>는 가입 승인 탭에서 pending 계정을 승인 또는 보류 처리한다.</p> <p class="dg-note">20차 적용(2026-06-28): 신규 Google/SAML 사용자는 <code>account_status=pending</code>으로 시작하고 승인 전에는 <code>/pending</code> 안내 화면만 본다. <code>yunchan@twentyoz.kr</code>는 슈퍼 관리자 allowlist로 admin+approved를 받으며, <code>/admin/users</code>는 가입 승인 탭에서 pending 계정을 승인 또는 보류 처리한다.</p>
<p class="dg-note">21차 적용(2026-06-28): 관리자 페이지 진입권을 기본 역할과 분리해 <code>app_user.admin_access</code>로 저장한다. <code>AUTH_SUPER_ADMIN_EMAILS</code> 기본값은 <code>yunchan@twentyoz.kr</code>, <code>hoonjungkoo@hs.ac.kr</code>이며, 슈퍼 관리자는 학습자·교수자·관리자 공간 전환과 관리자 권한 부여/회수를 할 수 있다. 학생·교수 계정도 <code>admin_access=true</code>면 우측 상단 관리자 진입이 노출된다. 구성 슈퍼 관리자의 권한 회수와 계정 비활성화는 차단한다.</p> <p class="dg-note">21차 적용(2026-06-28): 관리자 페이지 진입권을 기본 역할과 분리해 <code>app_user.admin_access</code>로 저장한다. <code>AUTH_SUPER_ADMIN_EMAILS</code> 기본값은 <code>yunchan@twentyoz.kr</code>, <code>hoonjungkoo@hs.ac.kr</code>이며, 슈퍼 관리자는 학습자·교수자·관리자 공간 전환과 관리자 권한 부여/회수를 할 수 있다. 학생·교수 계정도 <code>admin_access=true</code>면 우측 상단 관리자 진입이 노출된다. 구성 슈퍼 관리자의 권한 회수와 계정 비활성화는 차단한다.</p>
<p class="dg-note">25차 적용(2026-06-28): <code>/admin/users</code>에서 허용 도메인 밖 이메일도 정확한 계정 단위로 강제 등록할 수 있다. Google/SAML/dev-login은 미리 등록된 이메일만 도메인 게이트 예외로 통과시키고, provider 로그인 시 기존 관리 row의 역할·코호트·승인 상태를 이어받는다. 미등록 외부 도메인 로그인은 계속 차단한다.</p> <p class="dg-note">25차 적용(2026-06-28): <code>/admin/users</code>에서 허용 도메인 밖 이메일도 정확한 계정 단위로 강제 등록할 수 있다. Google/SAML/dev-login은 미리 등록된 이메일만 도메인 게이트 예외로 통과시키고, provider 로그인 시 기존 관리 row의 역할·코호트·승인 상태를 이어받는다. 미등록 외부 도메인 로그인은 계속 차단한다.</p>
@ -977,7 +978,7 @@
<tr><td>H4 consent gate</td><td><code>py -3.11 -X utf8 -m pytest -p no:cacheprovider app/test_voice_ws.py -q</code> / <code>PLAYWRIGHT_PORT=5231 npx playwright test e2e/voice.spec.ts --project=chromium-single-run --workers=1 --grep "consent withdrawal"</code></td><td>16 backend passed + browser WS 1 passed; voice binding now checks onboarding/consent before both existing <code>session_id</code> and dev <code>persona_code</code> paths. 동의 철회 뒤 기존 voice session id 재접속도 <code>consent_required</code>로 닫히며, 같은 suite가 voice turn 저장 실패 구조화 error도 검증한다.</td></tr> <tr><td>H4 consent gate</td><td><code>py -3.11 -X utf8 -m pytest -p no:cacheprovider app/test_voice_ws.py -q</code> / <code>PLAYWRIGHT_PORT=5231 npx playwright test e2e/voice.spec.ts --project=chromium-single-run --workers=1 --grep "consent withdrawal"</code></td><td>16 backend passed + browser WS 1 passed; voice binding now checks onboarding/consent before both existing <code>session_id</code> and dev <code>persona_code</code> paths. 동의 철회 뒤 기존 voice session id 재접속도 <code>consent_required</code>로 닫히며, 같은 suite가 voice turn 저장 실패 구조화 error도 검증한다.</td></tr>
<tr><td>H1 pre/post evidence API + KPI export</td><td><code>app/test_user_support_tickets.py</code>, <code>app/test_runtime_policy.py</code>, <code>app/test_admin_ops.py</code>, <code>app/test_phase3_kpi_export.py</code>, <code>app/test_phase3_artifact_checker.py</code>, <code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>e2e/session-review.spec.ts</code>, <code>e2e/session-persistence.spec.ts</code></td><td>Current H1 broad 46 passed; KPI contract focused 10 passed. <code>app.learner_prepost_measure</code> stores learner-scoped aggregate pre/post scores for self-efficacy, skill proficiency, and training satisfaction. <code>scripts/export-phase3-kpi.py</code> emits pseudonymous <code>prepost_measures.csv</code> and KPI report scaffold with paired normalized deltas. <code>phase3_kpi_contract.py</code> owns metric names, required keys, and <code>computed_prepost</code>/<code>design_pending</code> status values used by exporter/checker/tests. Generated API contract exposes <code>UserPrepostMeasure*</code>. 학습자 <code>SessionReview</code>는 1~5 aggregate score 조회/저장 카드를 표시하고, 기존 저장 점수를 비우는 입력은 invalid로 표면화해 <code>저장된 값 기준</code>으로 오인하지 않게 한다. 최신 route-fixture pre/post focused E2E 1 passed, DB-backed pre/post 저장·재조회 E2E까지 포함한 session-persistence 7 passed + visual gate 2 passed.</td></tr> <tr><td>H1 pre/post evidence API + KPI export</td><td><code>app/test_user_support_tickets.py</code>, <code>app/test_runtime_policy.py</code>, <code>app/test_admin_ops.py</code>, <code>app/test_phase3_kpi_export.py</code>, <code>app/test_phase3_artifact_checker.py</code>, <code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>e2e/session-review.spec.ts</code>, <code>e2e/session-persistence.spec.ts</code></td><td>Current H1 broad 46 passed; KPI contract focused 10 passed. <code>app.learner_prepost_measure</code> stores learner-scoped aggregate pre/post scores for self-efficacy, skill proficiency, and training satisfaction. <code>scripts/export-phase3-kpi.py</code> emits pseudonymous <code>prepost_measures.csv</code> and KPI report scaffold with paired normalized deltas. <code>phase3_kpi_contract.py</code> owns metric names, required keys, and <code>computed_prepost</code>/<code>design_pending</code> status values used by exporter/checker/tests. Generated API contract exposes <code>UserPrepostMeasure*</code>. 학습자 <code>SessionReview</code>는 1~5 aggregate score 조회/저장 카드를 표시하고, 기존 저장 점수를 비우는 입력은 invalid로 표면화해 <code>저장된 값 기준</code>으로 오인하지 않게 한다. 최신 route-fixture pre/post focused E2E 1 passed, DB-backed pre/post 저장·재조회 E2E까지 포함한 session-persistence 7 passed + visual gate 2 passed.</td></tr>
<tr><td>Persona review workflow</td><td><code>app.test_persona_review</code></td><td>31 tests OK; approved-only catalog, teacher/admin queue, draft authoring, approved persona revision, archive audit, learner 403</td></tr> <tr><td>Persona review workflow</td><td><code>app.test_persona_review</code></td><td>31 tests OK; approved-only catalog, teacher/admin queue, draft authoring, approved persona revision, archive audit, learner 403</td></tr>
<tr><td>Voice WS contract</td><td><code>app.test_voice_ws</code></td><td>16 tests OK; auth guard, onboarding/consent guard for existing session binding, <code>audio_start</code> PCM metadata, binary chunks, <code>audio_end</code>, <code>text_turn</code>, <code>stt_result</code> ready/pending, ping, max audio cap, TTS chunk envelope, and structured <code>turn_persistence_unavailable</code> error payload</td></tr> <tr><td>Voice WS/REST contract</td><td><code>app.test_voice_ws</code></td><td>18 tests OK; auth guard, onboarding/consent guard, <code>audio_start</code> PCM metadata, binary chunks, <code>audio_end</code>, <code>text_turn</code>, <code>stt_result</code> ready/pending, ping, max audio cap, TTS chunk envelope, structured <code>turn_persistence_unavailable</code> error payload, and owned persisted client-visible turn only <code>POST /voice/speech</code> synthesis</td></tr>
<tr><td>Voice preset/EOT</td><td><code>app.test_voice_service</code></td><td>14 tests OK; TTS payload, preset fallback, DB voice-map resolver, unsupported provider fallback, P1 sample TTS, EOT readiness</td></tr> <tr><td>Voice preset/EOT</td><td><code>app.test_voice_service</code></td><td>14 tests OK; TTS payload, preset fallback, DB voice-map resolver, unsupported provider fallback, P1 sample TTS, EOT readiness</td></tr>
<tr><td>Voice metadata</td><td><code>python -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</code> + <code>PLAYWRIGHT_PORT=5197 npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run --workers=1</code> + <code>PLAYWRIGHT_PORT=5205 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1</code></td><td>77 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 메타를 <code>audio_end</code>에 전송, provider_events JSONB 보존·sanitizing·taxonomy·client turn 미혼입 검증, DB-backed review <code>nonverbal</code>은 timing/audio와 제한된 paralinguistic/prosody/audio_quality 칩을 중복 침묵 없이 노출</td></tr> <tr><td>Voice metadata</td><td><code>python -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</code> + <code>PLAYWRIGHT_PORT=5197 npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run --workers=1</code> + <code>PLAYWRIGHT_PORT=5205 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1</code></td><td>77 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 메타를 <code>audio_end</code>에 전송, provider_events JSONB 보존·sanitizing·taxonomy·client turn 미혼입 검증, DB-backed review <code>nonverbal</code>은 timing/audio와 제한된 paralinguistic/prosody/audio_quality 칩을 중복 침묵 없이 노출</td></tr>
<tr><td>Session end/evaluation recovery + AI tutor E2E</td><td><code>PLAYWRIGHT_PORT=5205 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1</code> / <code>PLAYWRIGHT_PORT=5238 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "finishes session end evaluation"</code> / <code>PLAYWRIGHT_PORT=5237 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "explicit teacher session reevaluation"</code> / <code>PLAYWRIGHT_PORT=5245 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "manual AI evaluation retry"</code> / <code>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"</code> / <code>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" -q</code></td><td>DB-backed session-persistence 7 passed가 실제 DB/API/엔진으로 세션 생성→턴→종료→background deep 평가 durable row→교수자 리뷰 <code>평가 완료</code>까지 검증한다. 추가 자동 평가 E2E 1 passed는 같은 persisted row가 <code>/teacher/dashboard</code><code>evaluation_status=ready</code>, <code>review_ready=true</code>, <code>supervisor_state=평가 완료</code>로 반영되는지 확인한다. 명시 재평가 1 passed는 교수자 <code>POST /eval/sessions/{id}/reevaluate</code>가 durable <code>app.session_evaluation</code> row를 저장하고 <code>/eval/.../evaluation</code><code>/review</code><code>평가 완료</code>로 반영되는지 확인한다. 수동 UI 재시도 E2E 1 passed는 실제 실패 row를 만든 뒤 교수자 리뷰의 <code>AI 평가 재시도</code> 버튼 클릭이 real <code>POST /eval/sessions/{id}/reevaluate</code> → durable ready row → review/dashboard ready 상태로 이어지는지 확인한다. 최신 backend/UI focused 8 passed + 4 passed는 오래된 missing evaluation row가 교수자 대시보드에서도 <code>평가 실패</code>로 보이고, 이미 종료된 session <code>/end</code> 재호출이 평가를 재예약하지 않으며, 평가 ready 전에는 <code>검토 완료</code>로 큐에서 숨길 수 없음을 검증한다. 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 수준의 재시도 소유권이다.</td></tr> <tr><td>Session end/evaluation recovery + AI tutor E2E</td><td><code>PLAYWRIGHT_PORT=5205 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1</code> / <code>PLAYWRIGHT_PORT=5238 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "finishes session end evaluation"</code> / <code>PLAYWRIGHT_PORT=5237 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "explicit teacher session reevaluation"</code> / <code>PLAYWRIGHT_PORT=5245 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "manual AI evaluation retry"</code> / <code>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"</code> / <code>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" -q</code></td><td>DB-backed session-persistence 7 passed가 실제 DB/API/엔진으로 세션 생성→턴→종료→background deep 평가 durable row→교수자 리뷰 <code>평가 완료</code>까지 검증한다. 추가 자동 평가 E2E 1 passed는 같은 persisted row가 <code>/teacher/dashboard</code><code>evaluation_status=ready</code>, <code>review_ready=true</code>, <code>supervisor_state=평가 완료</code>로 반영되는지 확인한다. 명시 재평가 1 passed는 교수자 <code>POST /eval/sessions/{id}/reevaluate</code>가 durable <code>app.session_evaluation</code> row를 저장하고 <code>/eval/.../evaluation</code><code>/review</code><code>평가 완료</code>로 반영되는지 확인한다. 수동 UI 재시도 E2E 1 passed는 실제 실패 row를 만든 뒤 교수자 리뷰의 <code>AI 평가 재시도</code> 버튼 클릭이 real <code>POST /eval/sessions/{id}/reevaluate</code> → durable ready row → review/dashboard ready 상태로 이어지는지 확인한다. 최신 backend/UI focused 8 passed + 4 passed는 오래된 missing evaluation row가 교수자 대시보드에서도 <code>평가 실패</code>로 보이고, 이미 종료된 session <code>/end</code> 재호출이 평가를 재예약하지 않으며, 평가 ready 전에는 <code>검토 완료</code>로 큐에서 숨길 수 없음을 검증한다. 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 수준의 재시도 소유권이다.</td></tr>
@ -985,7 +986,7 @@
<tr><td>C3 theory-mode reevaluation</td><td><code>C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_eval_routes.py -q</code> / <code>PLAYWRIGHT_PORT=5229 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "selected CBT theory mode"</code> / <code>app/test_eval_routes.py app/test_notifications.py app/test_session_turn_persistence.py</code></td><td>eval route 5 passed, focused backend 39 passed, DB-backed browser E2E 1 passed. 실제 Session UI에서 선택한 <code>CBT</code><code>POST /sessions</code> payload와 DB-backed <code>GET /sessions/{id}</code> 상세의 <code>theory_mode=cbt</code>로 보존된다. 수동 session 재평가는 persona 기본 <code>theory_target</code>보다 학습자가 선택한 session <code>theory_mode</code>를 우선하고, turn 재평가도 <code>TurnContext.theory_mode</code>에 같은 값을 전달한다.</td></tr> <tr><td>C3 theory-mode reevaluation</td><td><code>C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_eval_routes.py -q</code> / <code>PLAYWRIGHT_PORT=5229 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "selected CBT theory mode"</code> / <code>app/test_eval_routes.py app/test_notifications.py app/test_session_turn_persistence.py</code></td><td>eval route 5 passed, focused backend 39 passed, DB-backed browser E2E 1 passed. 실제 Session UI에서 선택한 <code>CBT</code><code>POST /sessions</code> payload와 DB-backed <code>GET /sessions/{id}</code> 상세의 <code>theory_mode=cbt</code>로 보존된다. 수동 session 재평가는 persona 기본 <code>theory_target</code>보다 학습자가 선택한 session <code>theory_mode</code>를 우선하고, turn 재평가도 <code>TurnContext.theory_mode</code>에 같은 값을 전달한다.</td></tr>
<tr><td>Crisis safety gate UI</td><td><code>PLAYWRIGHT_PORT=5193 npx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run --workers=1</code> / <code>PLAYWRIGHT_PORT=5259 npx playwright test e2e/session-mvp.spec.ts --project=chromium-desktop --workers=1 --grep "voice conversation stop"</code></td><td>mock SSE <code>safety</code> + <code>done.conversation_stopped</code>와 mock voice <code>reply.conversation_stopped</code>에서 안전 자원 109와 입력 disabled가 유지되고, 빈 client reply를 <code>내담자 응답 없음</code>으로 덮지 않으며 live-coach를 호출하지 않는지 검증한다. 음성 위기 경로는 TTS 종료 이벤트 없이도 socket close를 확인한다.</td></tr> <tr><td>Crisis safety gate UI</td><td><code>PLAYWRIGHT_PORT=5193 npx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run --workers=1</code> / <code>PLAYWRIGHT_PORT=5259 npx playwright test e2e/session-mvp.spec.ts --project=chromium-desktop --workers=1 --grep "voice conversation stop"</code></td><td>mock SSE <code>safety</code> + <code>done.conversation_stopped</code>와 mock voice <code>reply.conversation_stopped</code>에서 안전 자원 109와 입력 disabled가 유지되고, 빈 client reply를 <code>내담자 응답 없음</code>으로 덮지 않으며 live-coach를 호출하지 않는지 검증한다. 음성 위기 경로는 TTS 종료 이벤트 없이도 socket close를 확인한다.</td></tr>
<tr><td>Persona/session review UI E2E</td><td><code>PLAYWRIGHT_PORT=5215 npx playwright test e2e/teacher.spec.ts --project=chromium-single-run --workers=1</code></td><td>9 passed mixed suite; real server-row rendering과 route-fixture UI 흐름을 함께 포함한다. 교수자가 pending persona를 승인하고, pending/recent review detail로 진입하며, 별도 student analysis 메뉴·학습자 검색·행 펼침·full learner timeline drilldown을 검증한다.</td></tr> <tr><td>Persona/session review UI E2E</td><td><code>PLAYWRIGHT_PORT=5215 npx playwright test e2e/teacher.spec.ts --project=chromium-single-run --workers=1</code></td><td>9 passed mixed suite; real server-row rendering과 route-fixture UI 흐름을 함께 포함한다. 교수자가 pending persona를 승인하고, pending/recent review detail로 진입하며, 별도 student analysis 메뉴·학습자 검색·행 펼침·full learner timeline drilldown을 검증한다.</td></tr>
<tr><td>Voice UI synthetic E2E</td><td><code>npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run</code> / <code>PLAYWRIGHT_PORT=5248 npx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run --workers=1 --grep "pending voice transcript"</code></td><td>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 <code>저장 실패</code> turn instead of a silent successful transcript</td></tr> <tr><td>Voice UI synthetic E2E</td><td><code>npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run</code> / <code>PLAYWRIGHT_PORT=5248 npx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run --workers=1 --grep "pending voice transcript"</code></td><td>voice-success 2 passed + pending transcript failure UI 1 passed; direct WS cascade, Session 텍스트 턴의 persisted reply <code>/voice/speech</code>→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 <code>저장 실패</code> turn instead of a silent successful transcript</td></tr>
<tr><td>Voice s2s decision memo</td><td><code>docs/decisions/voice-s2s-poc.md</code></td><td>criteria recorded; keep/drop decision remains owner DECIDE</td></tr> <tr><td>Voice s2s decision memo</td><td><code>docs/decisions/voice-s2s-poc.md</code></td><td>criteria recorded; keep/drop decision remains owner DECIDE</td></tr>
<tr><td>Hanshin data/SSO gate</td><td><code>docs/ops/hanshin-data-governance-gate.md</code></td><td>artifact created; written external evidence still required</td></tr> <tr><td>Hanshin data/SSO gate</td><td><code>docs/ops/hanshin-data-governance-gate.md</code></td><td>artifact created; written external evidence still required</td></tr>
<tr><td>Hanshin feedback 1차 improvement pack</td><td><code>docs/ops/hanshin-feedback-improvement-plan-2026-07-03.md</code> / <code>docs/redteam/hanshin-feedback-plan-redteam-2026-07-03.md</code> / <code>docs/redteam/hanshin-feedback-plan-counter-redteam-2026-07-03.md</code> / backend+web implementation</td><td>1차 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.</td></tr> <tr><td>Hanshin feedback 1차 improvement pack</td><td><code>docs/ops/hanshin-feedback-improvement-plan-2026-07-03.md</code> / <code>docs/redteam/hanshin-feedback-plan-redteam-2026-07-03.md</code> / <code>docs/redteam/hanshin-feedback-plan-counter-redteam-2026-07-03.md</code> / backend+web implementation</td><td>1차 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.</td></tr>
@ -1017,7 +1018,7 @@
<tr><td>Learner/readiness E2E</td><td><code>learner.spec.ts + readiness.spec.ts desktop/mobile</code></td><td>14 passed</td></tr> <tr><td>Learner/readiness E2E</td><td><code>learner.spec.ts + readiness.spec.ts desktop/mobile</code></td><td>14 passed</td></tr>
<tr><td>Learner screenshots</td><td><code>learn-empty-desktop.png / learn-empty-mobile-compact.png</code></td><td>document overflow 0, empty history visible</td></tr> <tr><td>Learner screenshots</td><td><code>learn-empty-desktop.png / learn-empty-mobile-compact.png</code></td><td>document overflow 0, empty history visible</td></tr>
<tr><td>Session layout/turn</td><td><code>session-layout.spec.ts desktop/mobile</code></td><td>8 passed</td></tr> <tr><td>Session layout/turn</td><td><code>session-layout.spec.ts desktop/mobile</code></td><td>8 passed</td></tr>
<tr><td>Session voice guard/view-model</td><td><code>npm run typecheck</code> / <code>npm run build</code> / <code>npx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run --workers=1</code> / <code>npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run --workers=1</code> / <code>npx playwright test e2e/session-layout.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1</code></td><td>typecheck/build passed; P1 text MVP 1 passed, voice cascade + Session mic UI 2 passed, session-layout desktop/mobile 8 passed. <code>isVoiceStatusBusy()</code> and <code>sessionVoiceStatusView()</code> 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.</td></tr> <tr><td>Session voice guard/view-model</td><td><code>npm run typecheck</code> / <code>npm run build</code> / <code>npx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run --workers=1</code> / <code>npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run --workers=1</code> / <code>npx playwright test e2e/session-layout.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1</code></td><td>typecheck/build passed; P1 text MVP 1 passed, voice cascade + Session text/mic UI 2 passed, session-layout desktop/mobile 8 passed. <code>isVoiceStatusBusy()</code> and <code>sessionVoiceStatusView()</code> 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.</td></tr>
<tr><td>Session end + dark theme UX</td><td><code>npm run typecheck</code>, <code>npm run build</code>, <code>layout-visual-gate</code>, <code>session-layout</code>, <code>avatar-expression</code>, <code>settings</code>, <code>session-review</code></td><td>typecheck/build passed; Playwright 묶음 36 passed. P1 active session은 <code>rasterArtSet="seoyeon-live2d-psd-v2"</code>를 사용한다. sad 표정은 PSD 기반 눈물/우는 입/울상 눈썹 파츠를 렌더한다. Settings now treats API <code>system</code> preference as current initial theme instead of forcing light.</td></tr> <tr><td>Session end + dark theme UX</td><td><code>npm run typecheck</code>, <code>npm run build</code>, <code>layout-visual-gate</code>, <code>session-layout</code>, <code>avatar-expression</code>, <code>settings</code>, <code>session-review</code></td><td>typecheck/build passed; Playwright 묶음 36 passed. P1 active session은 <code>rasterArtSet="seoyeon-live2d-psd-v2"</code>를 사용한다. sad 표정은 PSD 기반 눈물/우는 입/울상 눈썹 파츠를 렌더한다. Settings now treats API <code>system</code> preference as current initial theme instead of forcing light.</td></tr>
<tr><td>Voice/session focused</td><td><code>voice + voice-success + session-layout</code></td><td>15 passed</td></tr> <tr><td>Voice/session focused</td><td><code>voice + voice-success + session-layout</code></td><td>15 passed</td></tr>
<tr><td>Layout redesign handoff</td><td><code>docs/archive/ops/layout-redesign-handoff-2026-06-26.md</code></td><td>subagent scopes, files, verification, remaining visual review recorded</td></tr> <tr><td>Layout redesign handoff</td><td><code>docs/archive/ops/layout-redesign-handoff-2026-06-26.md</code></td><td>subagent scopes, files, verification, remaining visual review recorded</td></tr>

View file

@ -298,9 +298,12 @@ OpenAI STT/TTS 어댑터(순수 변환 + voice preset 매핑). 상담 로직은
OpenAI가 아닌 provider row는 현재 live OpenAI TTS로 보내지 않고 기존 fallback을 사용한다. OpenAI가 아닌 provider row는 현재 live OpenAI TTS로 보내지 않고 기존 fallback을 사용한다.
dev 런타임 스키마 보강은 기존 DB의 `app.persona_voice_map` 누락도 복구해 seed materializer와 dev 런타임 스키마 보강은 기존 DB의 `app.persona_voice_map` 누락도 복구해 seed materializer와
`/voice/ws` 바인딩이 같은 테이블을 사용하게 한다. `/voice/ws` 바인딩이 같은 테이블을 사용하게 한다.
프론트는 마이크 클릭 시 `AudioContext`를 먼저 resume해 재생 권한을 확보하고, TTS Blob을 Web Audio 프론트는 마이크 클릭과 텍스트 발화 전송 시 `AudioContext`를 먼저 resume해 재생 권한을 확보하고,
buffer source로 재생하면서 `AnalyserNode`로 립싱크 RMS를 산출한다. 디코딩 실패 시 `<audio>` 재생으로 TTS Blob을 Web Audio buffer source로 재생하면서 `AnalyserNode`로 립싱크 RMS를 산출한다. 디코딩 실패 시
fallback한다. `<audio>` 재생으로 fallback한다.
- 텍스트 턴의 AI 내담자 응답은 인증된 `POST /voice/speech``session_id`/`turn_seq`로 소유 회기를 다시
로드하고, 이미 저장된 client-visible 내담자 응답만 MP3로 합성한다. 브라우저가 임의 문장을 보내는 유료
TTS 프록시가 아니며, 마이크 WebSocket과 같은 voice map/OpenAI TTS 어댑터를 공유한다.
- 키 없으면 명확히 degraded(`is_available()=False`, `VoiceUnavailable`). 라우트가 503/WS close로 변환. - 키 없으면 명확히 degraded(`is_available()=False`, `VoiceUnavailable`). 라우트가 503/WS close로 변환.
`/voice/ws` WebSocket 캐스케이드(`routes/voice.py`): `/voice/ws` WebSocket 캐스케이드(`routes/voice.py`):
@ -318,7 +321,8 @@ server: ready → state(listening) → state(thinking) → transcript → reply
내부 taxonomy `event_type`/`category`를 붙이고, raw transcript/text payload는 보존하지 않는다. 내부 taxonomy `event_type`/`category`를 붙이고, raw transcript/text payload는 보존하지 않는다.
인증된 회기 리뷰 API는 정규화 taxonomy 중 일부를 `nonverbal` 칩으로만 파생 노출한다. 인증된 회기 리뷰 API는 정규화 taxonomy 중 일부를 `nonverbal` 칩으로만 파생 노출한다.
provider/source/raw type/text/transcript는 응답하지 않고, 공개 공유 카드는 축어록과 provider raw를 싣지 않는다. provider/source/raw type/text/transcript는 응답하지 않고, 공개 공유 카드는 축어록과 provider raw를 싣지 않는다.
- `text_turn` 경로는 접근성/결정론 테스트용 텍스트 전용 경로. - 텍스트 입력 경로도 턴 저장 완료 뒤 `/voice/speech`를 호출해 AI 내담자 음성을 재생한다. 새 텍스트 발화를
보내면 이전 합성/재생 요청을 무효화해 겹쳐 재생하지 않는다.
### 2.9 세션 라우트 — `app/routes/sessions.py` (실제 데이터 흐름) ### 2.9 세션 라우트 — `app/routes/sessions.py` (실제 데이터 흐름)

View file

@ -264,9 +264,11 @@ VITE_API_BASE=http://127.0.0.1:8000 npm run e2e # 프록시 대신 API
- **병렬 시나리오**: 150 tests (desktop 75 + mobile 75) - **병렬 시나리오**: 150 tests (desktop 75 + mobile 75)
- **`@single-run` 직렬 시나리오**: 44 tests (DB 영속화·세션 MVP·음성 성공경로·회기말 평가 저장·교수자 명시 재평가 저장·교수자 턴 재평가 저장·교수자 UI 평가 재시도·교수자 사용자별 분석·source pack sync·이론모드 저장·동의 철회 후 voice 차단·브라우저 stream PII 마스킹·음성 transcript 저장 실패 UI 표면화 등) - **`@single-run` 직렬 시나리오**: 44 tests (DB 영속화·세션 MVP·음성 성공경로·회기말 평가 저장·교수자 명시 재평가 저장·교수자 턴 재평가 저장·교수자 UI 평가 재시도·교수자 사용자별 분석·source pack sync·이론모드 저장·동의 철회 후 voice 차단·브라우저 stream PII 마스킹·음성 transcript 저장 실패 UI 표면화 등)
- `e2e/voice-success.spec.ts`는 직접 `/voice/ws` 캐스케이드와 Session 마이크 UI를 함께 검증하며, - `e2e/voice-success.spec.ts`는 직접 `/voice/ws` 캐스케이드, Session 텍스트 턴의
브라우저 `<audio>.play()`가 차단된 조건에서도 Web Audio buffer source 재생이 시작되는지와 `POST /voice/speech`, Session 마이크 UI를 함께 검증한다. 브라우저 `<audio>.play()`가 차단된 조건에서도
Session 마이크 UI가 `audio_end`에 browser voice activity/silence 메타를 싣는지 확인한다. Web Audio buffer source 재생이 시작되는지와 Session 마이크 UI가 `audio_end`에 browser voice
activity/silence 메타를 싣는지 확인한다.
- 2026-07-13 focused 검증: `PLAYWRIGHT_PORT=5271 npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run --workers=1` **2 passed**. 텍스트 턴 저장→소유 회기/턴 기반 `/voice/speech`→OpenAI speech 요청→Web Audio buffer 재생 시작을 확인하고, 별도 새 회기에서 마이크 PCM/STT→AI reply→TTS chunk/`tts_end` 경로가 유지되는지 검증한다. 백엔드 voice focused는 **32 passed**이며, 운영 키 직접 smoke는 `gpt-4o-mini-tts`가 98,133-byte MP3(11.68초)를 반환했다.
- 2026-07-01 focused 검증: `PLAYWRIGHT_PORT=5205 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1` **7 passed**. 실제 브라우저 `openSessionStream()``/sessions/{id}/stream` → DB-backed `/review` 축어록 저장 경로, AI 튜터 코칭 이력 저장/재로딩, WebSocket `stt_result` 음성 비언어 메타데이터, Session 마이크 UI가 생성한 voice activity/silence 메타, Phase 3 pre/post 점수의 DB-backed 저장/재조회, 그리고 세션 종료 background deep 평가가 durable DB row로 저장되어 교수자 리뷰가 `평가 완료`로 전환되는지 검증한다. AI 튜터 코칭 이력 검증은 `POST /live-coach` 응답과 DB-backed history payload의 `status=ready`, `latency_ms>0`도 확인해 규칙 기반 `degraded` fallback 200 응답이 정상 AI 코칭으로 통과하지 못하게 한다. - 2026-07-01 focused 검증: `PLAYWRIGHT_PORT=5205 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1` **7 passed**. 실제 브라우저 `openSessionStream()``/sessions/{id}/stream` → DB-backed `/review` 축어록 저장 경로, AI 튜터 코칭 이력 저장/재로딩, WebSocket `stt_result` 음성 비언어 메타데이터, Session 마이크 UI가 생성한 voice activity/silence 메타, Phase 3 pre/post 점수의 DB-backed 저장/재조회, 그리고 세션 종료 background deep 평가가 durable DB row로 저장되어 교수자 리뷰가 `평가 완료`로 전환되는지 검증한다. AI 튜터 코칭 이력 검증은 `POST /live-coach` 응답과 DB-backed history payload의 `status=ready`, `latency_ms>0`도 확인해 규칙 기반 `degraded` fallback 200 응답이 정상 AI 코칭으로 통과하지 못하게 한다.
- 2026-07-01 추가 DB-backed 검증: `PLAYWRIGHT_PORT=5238 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "finishes session end evaluation"` **1 passed**. 같은 자동 종료 평가 row가 `/teacher/dashboard``recent_sessions`에서도 `evaluation_status=ready`, `review_ready=true`, `supervisor_state=평가 완료`로 반영되는지 실제 DB/API/엔진으로 검증한다. - 2026-07-01 추가 DB-backed 검증: `PLAYWRIGHT_PORT=5238 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "finishes session end evaluation"` **1 passed**. 같은 자동 종료 평가 row가 `/teacher/dashboard``recent_sessions`에서도 `evaluation_status=ready`, `review_ready=true`, `supervisor_state=평가 완료`로 반영되는지 실제 DB/API/엔진으로 검증한다.
- 2026-07-01 추가 DB-backed 검증: `PLAYWRIGHT_PORT=5237 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "explicit teacher session reevaluation"` **1 passed**. 실제 DB/API/엔진에서 학습자 세션과 턴을 만든 뒤 교수자 `POST /eval/sessions/{id}/reevaluate`가 durable `app.session_evaluation` row를 저장하고, `/eval/.../evaluation``/review``평가 완료`로 반영되는지 검증한다. - 2026-07-01 추가 DB-backed 검증: `PLAYWRIGHT_PORT=5237 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1 --grep "explicit teacher session reevaluation"` **1 passed**. 실제 DB/API/엔진에서 학습자 세션과 턴을 만든 뒤 교수자 `POST /eval/sessions/{id}/reevaluate`가 durable `app.session_evaluation` row를 저장하고, `/eval/.../evaluation``/review``평가 완료`로 반영되는지 검증한다.