텍스트 응답 음성 재생 연결
This commit is contained in:
parent
64e06a1185
commit
d80e33da5e
9 changed files with 524 additions and 16 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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") =>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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` (실제 데이터 흐름)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`가 `평가 완료`로 반영되는지 검증한다.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue