음성 재생과 운영 배포 정리
This commit is contained in:
parent
8ed185ce6c
commit
ac7db95542
1020 changed files with 46863 additions and 2175 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
48
apps/web/src/lib/personaViewModel.ts
Normal file
48
apps/web/src/lib/personaViewModel.ts
Normal 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(" · ");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue