Stabilize runtime auth and E2E coverage
This commit is contained in:
parent
6a3e3b541c
commit
188e899394
133 changed files with 55987 additions and 6775 deletions
|
|
@ -3,12 +3,22 @@
|
|||
백엔드 계약: apps/api/app/routes (auth.py, sessions.py).
|
||||
- 모든 요청은 credentials:"include" (BFF __Host-vignette_sid HttpOnly 쿠키).
|
||||
- 에러는 ApiError 로 표준화.
|
||||
- SSE: GET /sessions/{id}/stream 의 token/done/ping/error 이벤트를 콜백으로 전달.
|
||||
- SSE: POST /sessions/{id}/stream 의 token/done/ping/error 이벤트를 콜백으로 전달.
|
||||
===================================================================== */
|
||||
|
||||
// Vite 환경변수. 기본 "/api" (vite proxy 또는 nginx 가 백엔드로 라우팅).
|
||||
const API_BASE: string =
|
||||
(import.meta.env.VITE_API_BASE as string | undefined) ?? "/api";
|
||||
function defaultApiBase(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
const host = window.location.hostname;
|
||||
if (host === "vignette.chanpaca.net" || host.endsWith(".pages.dev")) {
|
||||
return "https://api-vignette.chanpaca.net";
|
||||
}
|
||||
}
|
||||
return "/api";
|
||||
}
|
||||
|
||||
const configuredApiBase = (import.meta.env.VITE_API_BASE as string | undefined)?.trim();
|
||||
const API_BASE: string = configuredApiBase || defaultApiBase();
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
|
@ -43,6 +53,16 @@ function joinUrl(path: string): string {
|
|||
return `${base}${p}`;
|
||||
}
|
||||
|
||||
export function apiUrl(path: string): string {
|
||||
return joinUrl(path);
|
||||
}
|
||||
|
||||
export function apiWsUrl(path: string): string {
|
||||
const url = new URL(joinUrl(path), window.location.origin);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function parseError(res: Response): Promise<ApiError> {
|
||||
let detail = res.statusText || "request failed";
|
||||
let body: unknown = undefined;
|
||||
|
|
@ -126,19 +146,47 @@ export const api = {
|
|||
/** GET /auth/me — auth.py MeResponse */
|
||||
export interface MeResponse {
|
||||
user_id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
role: string; // "learner" | "teacher" | "admin"
|
||||
cohort_ids: string[];
|
||||
}
|
||||
|
||||
export interface AuthConfigResponse {
|
||||
google_oauth_configured: boolean;
|
||||
allowed_email_domains: string[];
|
||||
redirect_uri: string;
|
||||
dev_login_enabled: boolean;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
config: () => api.get<AuthConfigResponse>("/auth/config"),
|
||||
};
|
||||
|
||||
export type SessionStage = "라포" | "탐색" | "개입" | "정리";
|
||||
|
||||
/** GET /personas — personas.py PersonaSummary */
|
||||
export interface PersonaSummary {
|
||||
code: string;
|
||||
display_name: string;
|
||||
difficulty: "easy" | "moderate" | "hard" | string;
|
||||
theory_target: string[];
|
||||
demographics: Record<string, unknown>;
|
||||
presenting_summary: string;
|
||||
voice_preset: string | null;
|
||||
source: string;
|
||||
degraded: boolean;
|
||||
}
|
||||
|
||||
/** POST /sessions — sessions.py SessionStartResponse */
|
||||
export interface SessionStartResponse {
|
||||
session_id: string;
|
||||
case_id: string;
|
||||
session_no: number;
|
||||
stage: SessionStage;
|
||||
effective_openness: number;
|
||||
recall_summary: string | null;
|
||||
degraded: boolean;
|
||||
}
|
||||
|
||||
/** POST /sessions/{id}/turn — sessions.py TurnResponse */
|
||||
|
|
@ -157,17 +205,144 @@ export interface SessionEndResponse {
|
|||
digest_pending: boolean;
|
||||
}
|
||||
|
||||
export interface LearnerSessionSummary {
|
||||
session_id: string;
|
||||
persona_code: string;
|
||||
persona_name: string;
|
||||
session_no: number;
|
||||
status: "active" | "ended";
|
||||
stage: string;
|
||||
turn_count: number;
|
||||
learner_turn_count: number;
|
||||
client_turn_count: number;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
review_ready: boolean;
|
||||
}
|
||||
|
||||
export interface LearnerSessionsResponse {
|
||||
source: string;
|
||||
sessions: LearnerSessionSummary[];
|
||||
}
|
||||
|
||||
export interface SessionDetailTurn {
|
||||
turn_seq: number;
|
||||
speaker: "learner" | "client";
|
||||
stage: string;
|
||||
text: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SessionDetailResponse {
|
||||
session_id: string;
|
||||
case_id: string;
|
||||
persona_code: string;
|
||||
persona_name: string;
|
||||
theory_mode: string;
|
||||
status: "active" | "ended";
|
||||
stage: SessionStage;
|
||||
effective_openness: number;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
turns: SessionDetailTurn[];
|
||||
review_ready: boolean;
|
||||
}
|
||||
|
||||
export interface ReviewClient {
|
||||
name: string;
|
||||
initial: string;
|
||||
persona: string;
|
||||
}
|
||||
|
||||
export interface ReviewTechnique {
|
||||
kind: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ReviewNote {
|
||||
author: "ai" | "instructor" | string;
|
||||
tone: "good" | "watch";
|
||||
title: string;
|
||||
body: string;
|
||||
quote?: string | null;
|
||||
}
|
||||
|
||||
export interface ReviewTurn {
|
||||
id: string;
|
||||
ts: string;
|
||||
speaker: "learner" | "client";
|
||||
who: string;
|
||||
text: string;
|
||||
techniques: ReviewTechnique[];
|
||||
note?: ReviewNote | null;
|
||||
}
|
||||
|
||||
export interface ReviewPhaseSegment {
|
||||
key: string;
|
||||
label: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface ReviewValencePoint {
|
||||
t: number;
|
||||
v: number;
|
||||
}
|
||||
|
||||
export interface ReviewRubricRow {
|
||||
name: string;
|
||||
cluster: string;
|
||||
ratio: number;
|
||||
quality: "good" | "watch";
|
||||
freq: string;
|
||||
}
|
||||
|
||||
export interface ReviewPoint {
|
||||
title: string;
|
||||
body: string;
|
||||
jumpTo?: string | null;
|
||||
}
|
||||
|
||||
export interface SessionReviewResponse {
|
||||
session_id: string;
|
||||
client: ReviewClient;
|
||||
date: string;
|
||||
durationLabel: string;
|
||||
durationSeconds: number;
|
||||
reachedPhase: string;
|
||||
sessionSignal: string;
|
||||
supervisorState: string;
|
||||
supervisorName: string;
|
||||
summary: string;
|
||||
phases: ReviewPhaseSegment[];
|
||||
phaseAxis: string[];
|
||||
valenceAxis: string[];
|
||||
clientValence: ReviewValencePoint[];
|
||||
counselorBaseline: ReviewValencePoint[];
|
||||
turns: ReviewTurn[];
|
||||
rubric: ReviewRubricRow[];
|
||||
goodMoments: ReviewPoint[];
|
||||
growthPoints: ReviewPoint[];
|
||||
nextLine?: string | null;
|
||||
clientFeedback?: string | null;
|
||||
audioUrl?: string | null;
|
||||
pdfExportUrl?: string | null;
|
||||
degraded: boolean;
|
||||
reviewReady: boolean;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
SSE 헬퍼 — GET /sessions/{id}/stream
|
||||
SSE 헬퍼 — POST /sessions/{id}/stream
|
||||
백엔드 이벤트(sse_starlette): "token" | "done" | "ping" | "safety" | "error"
|
||||
EventSource 는 쿠키를 same-origin 으로 자동 전송하므로 BFF 인증과 호환.
|
||||
턴 본문이 필요하므로 EventSource 가 아니라 fetch stream 으로 처리한다.
|
||||
===================================================================== */
|
||||
|
||||
export interface SessionStreamHandlers {
|
||||
/** 서버가 요청을 수락했고 learner turn 이 저장 가능한 지점 */
|
||||
onOpen?: () => void;
|
||||
/** 내담자 AI 토큰 1조각 */
|
||||
onToken?: (chunk: string) => void;
|
||||
/** 스트림 정상 종료 */
|
||||
onDone?: (data: { session_id: string }) => void;
|
||||
onDone?: (data: SessionStreamDone) => void;
|
||||
/** 안전(위기) 신호 */
|
||||
onSafety?: (data: unknown) => void;
|
||||
/** 에러 이벤트(백엔드 EngineError) 또는 연결 오류 */
|
||||
|
|
@ -176,9 +351,12 @@ export interface SessionStreamHandlers {
|
|||
onPing?: () => void;
|
||||
}
|
||||
|
||||
export interface SessionStreamHandle {
|
||||
/** 스트림 종료(EventSource close) */
|
||||
close: () => void;
|
||||
export interface SessionStreamDone {
|
||||
session_id: string;
|
||||
stage?: SessionStage;
|
||||
effective_openness?: number;
|
||||
turn_seq?: number;
|
||||
safety_flagged?: boolean;
|
||||
}
|
||||
|
||||
function safeParse(data: string): unknown {
|
||||
|
|
@ -190,61 +368,285 @@ function safeParse(data: string): unknown {
|
|||
}
|
||||
|
||||
/**
|
||||
* 내담자 AI 응답 SSE 스트림을 연다.
|
||||
* @returns close() 가능한 핸들. 컴포넌트 언마운트 시 반드시 close 호출.
|
||||
* 내담자 AI 응답 SSE 스트림을 실행한다.
|
||||
* 백엔드는 learner turn 을 저장한 뒤 token/done 이벤트를 흘린다.
|
||||
*/
|
||||
export function openSessionStream(
|
||||
export async function openSessionStream(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
handlers: SessionStreamHandlers,
|
||||
): SessionStreamHandle {
|
||||
const url = joinUrl(`/sessions/${encodeURIComponent(sessionId)}/stream`);
|
||||
// withCredentials: same-origin 쿠키 전송 (BFF). cross-origin SSE 는 CORS 필요.
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
): Promise<SessionStreamDone> {
|
||||
const res = await fetch(joinUrl(`/sessions/${encodeURIComponent(sessionId)}/stream`), {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
|
||||
// addEventListener 의 커스텀 이벤트 리스너 시그니처는 Event 를 받으므로
|
||||
// MessageEvent 로 안전하게 좁힌다(.data 접근).
|
||||
const dataOf = (ev: Event): string | undefined =>
|
||||
(ev as MessageEvent).data as string | undefined;
|
||||
if (!res.ok) {
|
||||
throw await parseError(res);
|
||||
}
|
||||
if (!res.body) {
|
||||
throw new ApiError(res.status, "스트림 응답 본문이 없습니다.");
|
||||
}
|
||||
|
||||
es.addEventListener("token", (ev: Event) => {
|
||||
const data = dataOf(ev);
|
||||
if (data != null) handlers.onToken?.(data);
|
||||
});
|
||||
es.addEventListener("done", (ev: Event) => {
|
||||
const parsed = safeParse(dataOf(ev) ?? "{}") as { session_id?: string };
|
||||
handlers.onDone?.({ session_id: parsed.session_id ?? sessionId });
|
||||
es.close();
|
||||
});
|
||||
es.addEventListener("safety", (ev: Event) => {
|
||||
handlers.onSafety?.(safeParse(dataOf(ev) ?? "null"));
|
||||
});
|
||||
es.addEventListener("ping", () => {
|
||||
handlers.onPing?.();
|
||||
});
|
||||
es.addEventListener("error", (ev: Event) => {
|
||||
// sse_starlette 의 명시적 error 이벤트는 data 를 가짐.
|
||||
// 브라우저 연결 오류 이벤트는 data 가 없음 → 일반 연결 오류로 처리.
|
||||
const data = dataOf(ev);
|
||||
if (data) {
|
||||
const parsed = safeParse(data) as { detail?: string };
|
||||
handlers.onError?.({ detail: parsed.detail ?? "stream error" });
|
||||
} else if (es.readyState === EventSource.CLOSED) {
|
||||
handlers.onError?.({ detail: "스트림 연결이 종료되었습니다." });
|
||||
handlers.onOpen?.();
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let eventName = "message";
|
||||
let dataLines: string[] = [];
|
||||
let donePayload: SessionStreamDone | null = null;
|
||||
let streamError: ApiError | null = null;
|
||||
|
||||
const dispatch = () => {
|
||||
if (!eventName && dataLines.length === 0) return;
|
||||
const data = dataLines.join("\n");
|
||||
const event = eventName || "message";
|
||||
eventName = "message";
|
||||
dataLines = [];
|
||||
|
||||
if (event === "token") {
|
||||
handlers.onToken?.(data);
|
||||
return;
|
||||
}
|
||||
if (event === "done") {
|
||||
const parsed = safeParse(data || "{}") as Partial<SessionStreamDone>;
|
||||
donePayload = {
|
||||
session_id: parsed.session_id ?? sessionId,
|
||||
stage: parsed.stage,
|
||||
effective_openness: parsed.effective_openness,
|
||||
turn_seq: parsed.turn_seq,
|
||||
safety_flagged: parsed.safety_flagged,
|
||||
};
|
||||
handlers.onDone?.(donePayload);
|
||||
return;
|
||||
}
|
||||
if (event === "safety") {
|
||||
handlers.onSafety?.(safeParse(data || "null"));
|
||||
return;
|
||||
}
|
||||
if (event === "ping") {
|
||||
handlers.onPing?.();
|
||||
return;
|
||||
}
|
||||
if (event === "error") {
|
||||
const parsed = safeParse(data || "{}") as { detail?: string };
|
||||
const detail = parsed.detail ?? "stream error";
|
||||
handlers.onError?.({ detail });
|
||||
streamError = new ApiError(503, detail, parsed);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
close: () => es.close(),
|
||||
};
|
||||
|
||||
const processLine = (line: string) => {
|
||||
if (line === "") {
|
||||
dispatch();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith(":")) return;
|
||||
const idx = line.indexOf(":");
|
||||
const field = idx === -1 ? line : line.slice(0, idx);
|
||||
const rawValue = idx === -1 ? "" : line.slice(idx + 1);
|
||||
const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue;
|
||||
if (field === "event") eventName = value;
|
||||
else if (field === "data") dataLines.push(value);
|
||||
};
|
||||
|
||||
const processBuffer = (final = false) => {
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
buffer = final ? "" : (lines.pop() ?? "");
|
||||
for (const line of lines) processLine(line.endsWith("\r") ? line.slice(0, -1) : line);
|
||||
if (final && buffer) processLine(buffer);
|
||||
if (final && dataLines.length > 0) dispatch();
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
processBuffer();
|
||||
if (streamError) break;
|
||||
}
|
||||
buffer += decoder.decode();
|
||||
processBuffer(true);
|
||||
|
||||
if (streamError) throw streamError;
|
||||
return donePayload ?? { session_id: sessionId };
|
||||
}
|
||||
|
||||
/* === 세션 API 헬퍼 (Features 단계 Session 페이지가 사용) === */
|
||||
export const personaApi = {
|
||||
list: () => api.get<PersonaSummary[]>("/personas"),
|
||||
};
|
||||
|
||||
export const sessionApi = {
|
||||
list: () => api.get<LearnerSessionsResponse>("/sessions"),
|
||||
get: (sessionId: string) =>
|
||||
api.get<SessionDetailResponse>(`/sessions/${encodeURIComponent(sessionId)}`),
|
||||
start: (persona_code: string, theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic") =>
|
||||
api.post<SessionStartResponse>("/sessions", { persona_code, theory_mode }),
|
||||
turn: (sessionId: string, text: string) =>
|
||||
api.post<TurnResponse>(`/sessions/${encodeURIComponent(sessionId)}/turn`, { text }),
|
||||
end: (sessionId: string) =>
|
||||
api.post<SessionEndResponse>(`/sessions/${encodeURIComponent(sessionId)}/end`),
|
||||
review: (sessionId: string) =>
|
||||
api.get<SessionReviewResponse>(`/sessions/${encodeURIComponent(sessionId)}/review`),
|
||||
stream: openSessionStream,
|
||||
};
|
||||
|
||||
export type AdminHealthStatus = "ok" | "degraded" | "down";
|
||||
|
||||
export interface AdminServiceHealth {
|
||||
key: string;
|
||||
name: string;
|
||||
status: AdminHealthStatus;
|
||||
detail: string;
|
||||
metric: string;
|
||||
load: number;
|
||||
}
|
||||
|
||||
export interface AdminHealthResponse {
|
||||
status: AdminHealthStatus;
|
||||
environment: string;
|
||||
engine_mode: string;
|
||||
services: AdminServiceHealth[];
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
health: () => api.get<AdminHealthResponse>("/admin/health"),
|
||||
};
|
||||
|
||||
export interface AdminManagedUser {
|
||||
user_id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
role: "learner" | "teacher" | "admin";
|
||||
cohort_ids: string[];
|
||||
affiliation: string;
|
||||
active_sessions: number;
|
||||
created_at: number;
|
||||
last_seen_at: number;
|
||||
source: "database" | "server_session_registry";
|
||||
}
|
||||
|
||||
export interface AdminUsersResponse {
|
||||
source: "database" | "server_session_registry";
|
||||
durable: boolean;
|
||||
users: AdminManagedUser[];
|
||||
}
|
||||
|
||||
export type AdminUserCreateRequest = Pick<
|
||||
AdminManagedUser,
|
||||
"email" | "display_name" | "role" | "affiliation" | "cohort_ids"
|
||||
>;
|
||||
|
||||
export const adminUsersApi = {
|
||||
list: () => api.get<AdminUsersResponse>("/admin/users"),
|
||||
create: (body: AdminUserCreateRequest) =>
|
||||
apiFetch<AdminManagedUser>("/admin/users", { method: "POST", body }),
|
||||
update: (
|
||||
userId: string,
|
||||
body: Partial<Pick<AdminManagedUser, "display_name" | "role" | "affiliation" | "cohort_ids">>,
|
||||
) => apiFetch<AdminManagedUser>(`/admin/users/${encodeURIComponent(userId)}`, {
|
||||
method: "PATCH",
|
||||
body,
|
||||
}),
|
||||
deactivate: (userId: string) =>
|
||||
apiFetch<{ ok: boolean; user_id: string }>(`/admin/users/${encodeURIComponent(userId)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
};
|
||||
|
||||
export interface TeacherSessionSummary {
|
||||
session_id: string;
|
||||
learner_id: string;
|
||||
learner_label: string;
|
||||
persona_code: string;
|
||||
persona_name: string;
|
||||
session_no: number;
|
||||
status: "active" | "ended" | string;
|
||||
stage: string;
|
||||
turn_count: number;
|
||||
learner_turn_count: number;
|
||||
client_turn_count: number;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
}
|
||||
|
||||
export interface TeacherDashboardResponse {
|
||||
source: string;
|
||||
cohort_label: string;
|
||||
total_learners: number;
|
||||
active_sessions: number;
|
||||
ended_sessions: number;
|
||||
pending_reviews: TeacherSessionSummary[];
|
||||
recent_sessions: TeacherSessionSummary[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const teacherApi = {
|
||||
dashboard: () => api.get<TeacherDashboardResponse>("/teacher/dashboard"),
|
||||
};
|
||||
|
||||
export interface UserProfileResponse {
|
||||
user_id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
role: RoleString;
|
||||
cohort_ids: string[];
|
||||
affiliation: string;
|
||||
}
|
||||
|
||||
export interface NotificationPreferences {
|
||||
session_done: boolean;
|
||||
safety_signal: boolean;
|
||||
learner_progress: boolean;
|
||||
product_news: boolean;
|
||||
}
|
||||
|
||||
export interface UserPreferencesResponse {
|
||||
theme: "system" | "light" | "dark" | string;
|
||||
voice_preset_id: string;
|
||||
voice_rate: number;
|
||||
notifications: NotificationPreferences;
|
||||
}
|
||||
|
||||
export interface VoicePresetResponse {
|
||||
id: string;
|
||||
voice_id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
persona_hint: string;
|
||||
}
|
||||
|
||||
export type RoleString = "learner" | "teacher" | "admin" | string;
|
||||
|
||||
export const userApi = {
|
||||
me: () => api.get<UserProfileResponse>("/users/me"),
|
||||
updateMe: (body: { display_name?: string; affiliation?: string }) =>
|
||||
apiFetch<UserProfileResponse>("/users/me", { method: "PATCH", body }),
|
||||
preferences: () => api.get<UserPreferencesResponse>("/users/me/preferences"),
|
||||
updatePreferences: (body: Partial<UserPreferencesResponse>) =>
|
||||
apiFetch<UserPreferencesResponse>("/users/me/preferences", { method: "PATCH", body }),
|
||||
voicePresets: () => api.get<VoicePresetResponse[]>("/users/me/voice-presets"),
|
||||
};
|
||||
|
||||
export interface AdminEngineConfigResponse {
|
||||
engine_mode: string;
|
||||
engine_url: string;
|
||||
model: string;
|
||||
updated_by: string | null;
|
||||
updated_at: number | null;
|
||||
durable: boolean;
|
||||
source: "database" | "runtime_cache" | "runtime_default" | string;
|
||||
}
|
||||
|
||||
export const adminEngineApi = {
|
||||
get: () => api.get<AdminEngineConfigResponse>("/admin/engine-config"),
|
||||
update: (body: Partial<Pick<AdminEngineConfigResponse, "engine_mode" | "engine_url" | "model">>) =>
|
||||
apiFetch<AdminEngineConfigResponse>("/admin/engine-config", { method: "PATCH", body }),
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue