음성 재생과 운영 배포 정리

This commit is contained in:
Yun Chan 2026-06-28 12:18:20 +09:00
parent 8ed185ce6c
commit ac7db95542
1020 changed files with 46863 additions and 2175 deletions

File diff suppressed because it is too large Load diff

View file

@ -173,23 +173,39 @@ export type PersonaSummary = ApiSchema<"PersonaSummary">;
export type PersonaReviewStatus = "draft" | "review" | "approved" | "archived";
export type PersonaReviewAction = ApiSchema<"PersonaReviewDecisionRequest">["action"];
export type PersonaReviewSummary = ApiSchema<"PersonaReviewSummary">;
export type PersonaRevisionRequest = ApiSchema<"PersonaRevisionRequest">;
export type PersonaDraftPayload = ApiSchema<"PersonaDraftPayload">;
export type PersonaDraftDetail = ApiSchema<"PersonaDraftDetail">;
export type PersonaSourceDocumentRequest = ApiSchema<"PersonaSourceDocumentRequest">;
export type PersonaSourceDocumentResponse = ApiSchema<"PersonaSourceDocumentResponse">;
export type PersonaGenerationEvidence = ApiSchema<"PersonaGenerationEvidence">;
export type PersonaDraftGenerateRequest = ApiSchema<"PersonaDraftGenerateRequest">;
export type PersonaDraftGenerateResponse = ApiSchema<"PersonaDraftGenerateResponse">;
/** POST /sessions — sessions.py SessionStartResponse */
export type SessionStartResponse = ApiSchema<"SessionStartResponse">;
/** POST /sessions/{id}/turn — sessions.py TurnResponse */
export type TurnResponse = ApiSchema<"TurnResponse">;
export type LiveCoachRequest = ApiSchema<"LiveCoachRequest">;
export type LiveCoachEvent = ApiSchema<"LiveCoachEvent">;
export type LiveCoachHistoryResponse = ApiSchema<"LiveCoachHistoryResponse">;
export type LiveCoachSuggestion = ApiSchema<"LiveCoachSuggestion">;
export type LiveCoachSource = ApiSchema<"LiveCoachSource">;
/** POST /sessions/{id}/end — sessions.py SessionEndResponse */
export type SessionEndResponse = ApiSchema<"SessionEndResponse">;
export type CrisisResource = ApiSchema<"CrisisResourceResponse">;
export type LearnerSessionSummary = ApiSchema<"LearnerSessionSummary">;
export type SessionArchiveResponse = ApiSchema<"SessionArchiveResponse">;
export type LearnerSessionsResponse = ApiSchema<"LearnerSessionsResponse">;
export type LearnerDashboardResponse = ApiSchema<"LearnerDashboardResponse">;
export type LearnerDashboardPersonaProgress = ApiSchema<"LearnerDashboardPersonaProgress">;
export type LearnerDashboardAchievement = ApiSchema<"LearnerDashboardAchievement">;
export type LearnerDashboardFeedbackItem = ApiSchema<"LearnerDashboardFeedbackItem">;
export type SessionDetailTurn = ApiSchema<"SessionDetailTurn">;
@ -213,6 +229,8 @@ export type ReviewWorksheetSection = ApiSchema<"ReviewWorksheetSection-Output">;
export type ReviewCaseWorksheet = ApiSchema<"ReviewCaseWorksheet">;
export type ReviewCaseWorksheetSaveRequest = ApiSchema<"ReviewCaseWorksheetSaveRequest">;
export type SessionReviewResponse = ApiSchema<"SessionReviewResponse">;
export type SessionShareResponse = ApiSchema<"SessionShareResponse">;
export type SessionShareDeleteResponse = ApiSchema<"SessionShareDeleteResponse">;
/* =====================================================================
SSE POST /sessions/{id}/stream
@ -381,14 +399,23 @@ export const personaReviewApi = {
}),
createDraft: (payload: PersonaDraftPayload) =>
api.post<PersonaReviewSummary>("/personas/drafts", payload),
createSource: (payload: PersonaSourceDocumentRequest) =>
api.post<PersonaSourceDocumentResponse>("/personas/sources", payload),
generateDraft: (payload: PersonaDraftGenerateRequest) =>
api.post<PersonaDraftGenerateResponse>("/personas/drafts/generate", payload),
getDraft: (personaId: string) =>
api.get<PersonaDraftDetail>(`/personas/drafts/${encodeURIComponent(personaId)}`),
updateDraft: (personaId: string, payload: PersonaDraftPayload) =>
api.put<PersonaReviewSummary>(`/personas/drafts/${encodeURIComponent(personaId)}`, payload),
reviseApproved: (personaId: string, payload: PersonaRevisionRequest = { submit_for_review: false }) =>
api.post<PersonaDraftDetail>(`/personas/${encodeURIComponent(personaId)}/revisions`, payload),
archive: (personaId: string) =>
api.del<PersonaReviewSummary>(`/personas/${encodeURIComponent(personaId)}`),
};
export const sessionApi = {
list: () => api.get<LearnerSessionsResponse>("/sessions"),
dashboard: () => api.get<LearnerDashboardResponse>("/sessions/dashboard"),
/**
* (STT/TTS provider ). degraded 503
* available ( ).
@ -408,8 +435,19 @@ export const sessionApi = {
api.post<SessionStartResponse>("/sessions", { persona_code, theory_mode }),
turn: (sessionId: string, text: string) =>
api.post<TurnResponse>(`/sessions/${encodeURIComponent(sessionId)}/turn`, { text }),
liveCoach: (sessionId: string, payload: LiveCoachRequest) =>
api.post<LiveCoachSuggestion>(
`/sessions/${encodeURIComponent(sessionId)}/live-coach`,
payload,
),
liveCoachHistory: (sessionId: string) =>
api.get<LiveCoachHistoryResponse>(`/sessions/${encodeURIComponent(sessionId)}/live-coach`),
end: (sessionId: string) =>
api.post<SessionEndResponse>(`/sessions/${encodeURIComponent(sessionId)}/end`),
archive: (sessionId: string) =>
api.post<SessionArchiveResponse>(`/sessions/${encodeURIComponent(sessionId)}/archive`, {}),
restore: (sessionId: string) =>
api.post<SessionArchiveResponse>(`/sessions/${encodeURIComponent(sessionId)}/restore`, {}),
review: (sessionId: string) =>
api.get<SessionReviewResponse>(`/sessions/${encodeURIComponent(sessionId)}/review`),
saveWorksheet: (sessionId: string, payload: ReviewCaseWorksheetSaveRequest) =>
@ -417,6 +455,10 @@ export const sessionApi = {
`/sessions/${encodeURIComponent(sessionId)}/review/worksheet`,
payload,
),
createShare: (sessionId: string) =>
api.post<SessionShareResponse>(`/sessions/${encodeURIComponent(sessionId)}/share`, {}),
revokeShare: (sessionId: string) =>
api.del<SessionShareDeleteResponse>(`/sessions/${encodeURIComponent(sessionId)}/share`),
stream: openSessionStream,
};
@ -426,10 +468,44 @@ export type AdminHealthResponse = ApiSchema<"AdminHealthResponse">;
export type AdminUsageBreakdown = ApiSchema<"AdminUsageBreakdown">;
export type AdminUsageBudget = ApiSchema<"AdminUsageBudget">;
export type AdminUsageResponse = ApiSchema<"AdminUsageResponse">;
export type AdminHealthEvent = ApiSchema<"AdminHealthEvent">;
export type AdminUptimeServiceSummary = ApiSchema<"AdminUptimeServiceSummary">;
export type AdminUptimeResponse = ApiSchema<"AdminUptimeResponse">;
export type AdminSupportTicket = ApiSchema<"AdminSupportTicketResponse">;
export type AdminTicketsResponse = ApiSchema<"AdminTicketsResponse">;
export type AdminTicketPatchRequest = ApiSchema<"AdminTicketPatch">;
export type AdminTicketFilters = {
status?: AdminSupportTicket["status"] | "";
category?: AdminSupportTicket["category"] | "";
priority?: AdminSupportTicket["priority"] | "";
assignedGroup?: string;
sourcePath?: string;
staleOnly?: boolean;
search?: string;
windowDays?: number;
};
export const adminApi = {
health: () => api.get<AdminHealthResponse>("/admin/health"),
usage: (windowDays = 7) => api.get<AdminUsageResponse>(`/admin/usage?window_days=${windowDays}`),
uptime: (windowHours = 24) =>
api.get<AdminUptimeResponse>(`/admin/uptime?window_hours=${windowHours}`),
tickets: (filters: AdminTicketFilters = {}) => {
const params = new URLSearchParams({ window_days: String(filters.windowDays ?? 30) });
if (filters.status) params.set("status", filters.status);
if (filters.category) params.set("category", filters.category);
if (filters.priority) params.set("priority", filters.priority);
if (filters.assignedGroup?.trim()) params.set("assigned_group", filters.assignedGroup.trim());
if (filters.sourcePath?.trim()) params.set("source_path", filters.sourcePath.trim());
if (filters.staleOnly) params.set("stale_only", "true");
if (filters.search?.trim()) params.set("search", filters.search.trim());
return api.get<AdminTicketsResponse>(`/admin/tickets?${params.toString()}`);
},
updateTicket: (ticketId: string, body: AdminTicketPatchRequest) =>
apiFetch<AdminSupportTicket>(`/admin/tickets/${encodeURIComponent(ticketId)}`, {
method: "PATCH",
body,
}),
};
export type AdminManagedUser = ApiSchema<"AdminUserResponse">;
@ -462,24 +538,52 @@ export type TeacherGrowthPoint = ApiSchema<"TeacherGrowthPoint">;
export type TeacherLearnerGrowth = ApiSchema<"TeacherLearnerGrowth">;
export type TeacherDashboardResponse = ApiSchema<"TeacherDashboardResponse">;
export type TeacherSessionReviewStatusRequest = ApiSchema<"TeacherSessionReviewStatusRequest">;
export type TeacherSessionReviewStatusResponse = ApiSchema<"TeacherSessionReviewStatusResponse">;
export const teacherApi = {
dashboard: () => api.get<TeacherDashboardResponse>("/teacher/dashboard"),
updateSessionReviewStatus: (
sessionId: string,
body: TeacherSessionReviewStatusRequest,
) =>
apiFetch<TeacherSessionReviewStatusResponse>(
`/teacher/sessions/${encodeURIComponent(sessionId)}/review-status`,
{ method: "PUT", body },
),
};
export type UserProfileResponse = ApiSchema<"UserProfileResponse">;
export type LegalDocumentsResponse = ApiSchema<"LegalDocumentsResponse">;
export type NotificationPreferences = ApiSchema<"NotificationPreferences">;
export type UserPreferencesResponse = ApiSchema<"UserPreferencesResponse">;
export type UserPreferencesPatchRequest = ApiSchema<"UserPreferencesPatch">;
export type UserProfilePatchRequest = ApiSchema<"UserProfilePatch">;
export type OnboardingRequest = ApiSchema<"OnboardingRequest">;
export type AvatarUploadResponse = ApiSchema<"AvatarUploadResponse">;
export type VoicePresetResponse = ApiSchema<"VoicePresetResponse">;
export type RoleString = "learner" | "teacher" | "admin" | string;
export const userApi = {
legalDocs: () => api.get<LegalDocumentsResponse>("/users/legal-docs"),
me: () => api.get<UserProfileResponse>("/users/me"),
updateMe: (body: UserProfilePatchRequest) =>
apiFetch<UserProfileResponse>("/users/me", { method: "PATCH", body }),
uploadAvatar: async (file: File) => {
const form = new FormData();
form.append("file", file);
const res = await fetch(joinUrl("/users/me/avatar"), {
method: "POST",
credentials: "include",
headers: { Accept: "application/json" },
body: form,
});
if (!res.ok) throw await parseError(res);
return (await res.json()) as AvatarUploadResponse;
},
completeOnboarding: (body: OnboardingRequest) =>
apiFetch<UserProfileResponse>("/users/me/onboarding", { method: "POST", body }),
preferences: () => api.get<UserPreferencesResponse>("/users/me/preferences"),
updatePreferences: (body: UserPreferencesPatchRequest) =>
apiFetch<UserPreferencesResponse>("/users/me/preferences", { method: "PATCH", body }),

View file

@ -7,9 +7,10 @@ import {
useState,
type ReactNode,
} from "react";
import { api, authApi, type MeResponse } from "./api";
import { api, apiUrl, authApi, type MeResponse } from "./api";
export type Role = "learner" | "teacher" | "admin";
export type AccountStatus = "pending" | "approved" | "suspended";
export type DesignRole = "learner" | "instructor" | "admin";
export interface AuthUser {
@ -17,8 +18,16 @@ export interface AuthUser {
email: string;
name: string;
role: Role;
adminAccess: boolean;
superAdmin: boolean;
accountStatus: AccountStatus;
approvalRequired: boolean;
cohortIds: string[];
consentAt: number | null;
onboardingCompletedAt: number | null;
nickname: string;
selfIntroduction: string;
avatarUrl: string;
}
export interface AuthContextValue {
@ -27,6 +36,7 @@ export interface AuthContextValue {
loading: boolean;
login: (role: Role, opts?: { email?: string; displayName?: string }) => Promise<AuthUser>;
logout: () => Promise<void>;
refresh: () => Promise<AuthUser | null>;
acceptConsent: () => Promise<void>;
withdrawConsent: () => Promise<void>;
}
@ -57,6 +67,12 @@ export function roleHomePath(role: Role): string {
}
}
export function canAccessRole(user: AuthUser, role: Role): boolean {
if (user.role === role) return true;
if (user.superAdmin) return true;
return role === "admin" && user.adminAccess;
}
const DEV_EMAIL_BY_ROLE: Record<Role, string> = {
learner: "learner@hs.ac.kr",
teacher: "teacher@hs.ac.kr",
@ -72,13 +88,24 @@ const DEV_NAME_BY_ROLE: Record<Role, string> = {
const AuthContext = createContext<AuthContextValue | null>(null);
function userFromMe(me: MeResponse): AuthUser {
const onboarding = me as MeResponse & { onboarding_completed_at?: number | null };
const nickname = (me.nickname ?? "").trim();
const avatarUrl = (me.avatar_url ?? "").trim();
return {
userId: me.user_id,
email: me.email,
name: me.display_name || me.email || me.user_id,
name: nickname || me.display_name || me.email || me.user_id,
role: (me.role as Role) ?? "learner",
adminAccess: Boolean(me.admin_access),
superAdmin: Boolean(me.super_admin),
accountStatus: (me.account_status as AccountStatus | undefined) ?? "approved",
approvalRequired: me.approval_required ?? false,
cohortIds: me.cohort_ids ?? [],
consentAt: me.consent_at ?? null,
onboardingCompletedAt: onboarding.onboarding_completed_at ?? null,
nickname,
selfIntroduction: (me.self_introduction ?? "").trim(),
avatarUrl: avatarUrl ? apiUrl(avatarUrl) : "",
};
}
@ -120,6 +147,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return next;
}, []);
const refresh = useCallback<AuthContextValue["refresh"]>(async () => {
try {
const me = await api.get<MeResponse>("/auth/me");
const next = userFromMe(me);
setUser(next);
return next;
} catch {
setUser(null);
return null;
}
}, []);
const logout = useCallback<AuthContextValue["logout"]>(async () => {
try {
await api.post("/auth/logout");
@ -141,8 +180,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);
const value = useMemo<AuthContextValue>(
() => ({ user, role: user?.role ?? null, loading, login, logout, acceptConsent, withdrawConsent }),
[user, loading, login, logout, acceptConsent, withdrawConsent],
() => ({
user,
role: user?.role ?? null,
loading,
login,
logout,
refresh,
acceptConsent,
withdrawConsent,
}),
[user, loading, login, logout, refresh, acceptConsent, withdrawConsent],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View file

@ -0,0 +1,48 @@
import type { AvatarPersona } from "../components/avatar/ClientAvatar";
import type { PersonaSummary } from "./api";
export const DIFFICULTY_LABEL: Record<string, string> = {
easy: "기초",
moderate: "중간",
hard: "고난도",
};
export function isUsablePersona(persona: PersonaSummary): boolean {
return !persona.degraded && persona.source === "database";
}
export function unavailablePersonaMessage(persona: PersonaSummary): string {
if (persona.degraded) {
return "카탈로그 원본을 확인하지 못해 현재 연습에 사용할 수 없습니다.";
}
return "데이터베이스에서 확인된 내담자가 아니어서 현재 연습에 사용할 수 없습니다.";
}
export function demographicText(value: unknown): string | null {
if (typeof value === "string") return value.trim() || null;
if (typeof value === "number") return String(value);
return null;
}
export function shortPersonaName(displayName: string): string {
return displayName.split("·")[0]?.replace(/\(.*?\)/g, "").trim() || displayName.trim();
}
export function personaAgeBand(summary: PersonaSummary): AvatarPersona["ageBand"] {
const ageBand = demographicText(summary.demographics.age_band) ?? "";
const grade = demographicText(summary.demographics.grade) ?? "";
if (ageBand.includes("16") || ageBand.includes("18") || grade.includes("고")) return "teen";
if (ageBand.includes("25") || ageBand.includes("30")) return "youngAdult";
if (ageBand.includes("60") || ageBand.includes("70")) return "senior";
return "adult";
}
export function personaMeta(summary: PersonaSummary): string {
const fields = [
demographicText(summary.demographics.age_band),
demographicText(summary.demographics.grade),
demographicText(summary.demographics.job),
demographicText(summary.demographics.status),
].filter(Boolean);
return [...fields, "교육용 가상 내담자"].join(" · ");
}