전 저장소 리팩터링과 SSOT 정비

This commit is contained in:
Yun Chan 2026-07-15 21:31:30 +09:00
parent 14ecbd4e7d
commit 3dfddcac6f
173 changed files with 19679 additions and 6952 deletions

View file

@ -818,6 +818,29 @@ export interface paths {
patch?: never;
trace?: never;
};
"/personas/sources/upload": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Upload Persona Source Route
* @description /CSV source (P4).
*
* ( ).
* ·hash-only ·sanitized chunk .
*/
post: operations["upload_persona_source_route_personas_sources_upload_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/personas/{persona_id}": {
parameters: {
query?: never;
@ -1972,6 +1995,27 @@ export interface components {
*/
file: string;
};
/** Body_upload_persona_source_route_personas_sources_upload_post */
Body_upload_persona_source_route_personas_sources_upload_post: {
/**
* File
* Format: binary
*/
file: string;
/**
* Source Kind
* @default mixed_notes
* @enum {string}
*/
source_kind: "client_record" | "textbook_guide" | "mixed_notes";
/**
* Source Note
* @default
*/
source_note: string;
/** Title */
title?: string | null;
};
/** ChunkOut */
ChunkOut: {
/** Behavior Cue */
@ -2433,6 +2477,11 @@ export interface components {
persona_code: string;
/** Persona Name */
persona_name: string;
/**
* Rapport Percent
* @default 0
*/
rapport_percent: number;
/**
* Review Ready Sessions
* @default 0
@ -3467,14 +3516,22 @@ export interface components {
SessionDetailResponse: {
/** Case Id */
case_id: string;
/**
* Duration Limit Seconds
* @default 0
*/
duration_limit_seconds: number;
/** Effective Openness */
effective_openness: number;
/** Ended At */
ended_at?: string | null;
/** Goal Stages */
goal_stages?: ("라포" | "탐색" | "개입" | "정리")[];
/** Persona Code */
persona_code: string;
/** Persona Name */
persona_name: string;
progress?: components["schemas"]["SessionProgress"] | null;
/**
* Review Ready
* @default false
@ -3498,6 +3555,11 @@ export interface components {
theory_mode: string;
/** Turns */
turns?: components["schemas"]["SessionDetailTurn"][];
/**
* Warning Before End Seconds
* @default 0
*/
warning_before_end_seconds: number;
};
/** SessionDetailTurn */
SessionDetailTurn: {
@ -3575,6 +3637,34 @@ export interface components {
*/
turns_evaluated: number;
};
/**
* SessionProgress
* @description (P2). - % .
*/
SessionProgress: {
/**
* Openness Percent
* @default 0
*/
openness_percent: number;
/**
* Rapport Delta Percent
* @default 0
*/
rapport_delta_percent: number;
/**
* Rapport Percent
* @default 0
*/
rapport_percent: number;
/**
* Resistance Percent
* @default 0
*/
resistance_percent: number;
/** Stages */
stages?: components["schemas"]["SessionStageProgress"][];
};
/** SessionReviewResponse */
SessionReviewResponse: {
/** Audiourl */
@ -3656,8 +3746,36 @@ export interface components {
/** Title */
title: string;
};
/**
* SessionStageProgress
* @description (P2). .
*/
SessionStageProgress: {
/**
* Achieved
* @default false
*/
achieved: boolean;
/**
* Is Goal
* @default false
*/
is_goal: boolean;
/**
* Percent
* @default 0
*/
percent: number;
/**
* Stage
* @enum {string}
*/
stage: "라포" | "탐색" | "개입" | "정리";
};
/** SessionStartRequest */
SessionStartRequest: {
/** Goal Stages */
goal_stages?: ("라포" | "탐색" | "개입" | "정리")[];
/**
* Persona Code
* @example P1
@ -3679,8 +3797,15 @@ export interface components {
* @default false
*/
degraded: boolean;
/**
* Duration Limit Seconds
* @default 0
*/
duration_limit_seconds: number;
/** Effective Openness */
effective_openness: number;
/** Goal Stages */
goal_stages?: ("라포" | "탐색" | "개입" | "정리")[];
/** Recall Summary */
recall_summary?: string | null;
/** Session Id */
@ -3692,6 +3817,16 @@ export interface components {
* @enum {string}
*/
stage: "라포" | "탐색" | "개입" | "정리";
/**
* Started At
* @default
*/
started_at: string;
/**
* Warning Before End Seconds
* @default 0
*/
warning_before_end_seconds: number;
};
/** SessionTeacherReviewStatus */
SessionTeacherReviewStatus: {
@ -4057,8 +4192,9 @@ export interface components {
/**
* Appropriateness
* @default neutral
* @enum {string}
*/
appropriateness: string;
appropriateness: "pos" | "warn" | "neutral";
/** Appropriateness Note */
appropriateness_note?: string | null;
/** Client State Read */
@ -4117,6 +4253,7 @@ export interface components {
effective_openness: number;
/** Output Error */
output_error?: string | null;
progress?: components["schemas"]["SessionProgress"] | null;
/**
* Safety Flagged
* @default false
@ -5915,6 +6052,42 @@ export interface operations {
};
};
};
upload_persona_source_route_personas_sources_upload_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: {
"__Host-vignette_sid"?: string | null;
vignette_sid?: string | null;
};
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_upload_persona_source_route_personas_sources_upload_post"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PersonaSourceDocumentResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
archive_persona_route_personas__persona_id__delete: {
parameters: {
query?: never;

View file

@ -197,6 +197,9 @@ export type PersonaDraftGenerateResponse = ApiSchema<"PersonaDraftGenerateRespon
/** POST /sessions — sessions.py SessionStartResponse */
export type SessionStartResponse = ApiSchema<"SessionStartResponse">;
/** P2 단계 누적 게이지·상세 수치 — session_read_model.SessionProgress */
export type SessionProgress = ApiSchema<"SessionProgress">;
export type SessionStageProgress = ApiSchema<"SessionStageProgress">;
/** POST /sessions/{id}/turn — sessions.py TurnResponse */
export type TurnResponse = ApiSchema<"TurnResponse">;
@ -279,6 +282,8 @@ export interface SessionStreamDone {
crisis_resource?: CrisisResource | null;
conversation_stopped?: boolean;
output_error?: string | null;
/** P2 단계 누적 게이지·상세 수치 */
progress?: SessionProgress | null;
}
function safeParse(data: string): unknown {
@ -348,6 +353,7 @@ export async function openSessionStream(
crisis_resource: parsed.crisis_resource,
conversation_stopped: parsed.conversation_stopped,
output_error: parsed.output_error,
progress: parsed.progress ?? null,
};
handlers.onDone?.(donePayload);
return;
@ -419,6 +425,25 @@ export const personaReviewApi = {
api.post<PersonaReviewSummary>("/personas/drafts", payload),
createSource: (payload: PersonaSourceDocumentRequest) =>
api.post<PersonaSourceDocumentResponse>("/personas/sources", payload),
/** P4: 자유 양식 엑셀/CSV 업로드 → 서버 변환·등록 (원본은 서버에 저장되지 않음) */
uploadSource: async (
file: File,
options: { source_kind?: string; title?: string; source_note?: string } = {},
) => {
const form = new FormData();
form.append("file", file);
if (options.source_kind) form.append("source_kind", options.source_kind);
if (options.title) form.append("title", options.title);
if (options.source_note) form.append("source_note", options.source_note);
const res = await fetch(joinUrl("/personas/sources/upload"), {
method: "POST",
credentials: "include",
headers: { Accept: "application/json" },
body: form,
});
if (!res.ok) throw await parseError(res);
return (await res.json()) as PersonaSourceDocumentResponse;
},
generateDraft: (payload: PersonaDraftGenerateRequest) =>
api.post<PersonaDraftGenerateResponse>("/personas/drafts/generate", payload),
getDraft: (personaId: string) =>
@ -464,8 +489,12 @@ export const sessionApi = {
},
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 }),
start: (
persona_code: string,
theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic",
goal_stages: SessionStage[] = [],
) =>
api.post<SessionStartResponse>("/sessions", { persona_code, theory_mode, goal_stages }),
turn: (sessionId: string, text: string) =>
api.post<TurnResponse>(`/sessions/${encodeURIComponent(sessionId)}/turn`, { text }),
liveCoach: (sessionId: string, payload: LiveCoachRequest) =>

View file

@ -44,6 +44,35 @@ export function formatPercent(ratio: number, fractionDigits = 0): string {
return `${pct.toFixed(fractionDigits)}%`;
}
/** nullable 0~1 비율을 화면별 빈 상태 문구와 함께 표시한다. */
export function formatOptionalPercent(
ratio: number | null | undefined,
fallback = "-",
suffix = "%",
): string {
if (typeof ratio !== "number" || !Number.isFinite(ratio)) return fallback;
return `${Math.round(clamp01(ratio) * 100)}${suffix}`;
}
/** nullable 0~1 변화량을 백분율포인트로 표시한다. */
export function formatPercentagePointDelta(
delta: number | null | undefined,
fallback = "-",
): string {
if (typeof delta !== "number" || !Number.isFinite(delta)) return fallback;
const sign = delta > 0 ? "+" : "";
return `${sign}${Math.round(delta * 100)}%p`;
}
/** -1~1 상관·라포 값을 학습자용 0~100%로 변환한다. */
export function formatBipolarPercent(
value: number | null | undefined,
fallback = "-",
): string {
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
return `${Math.round(clamp01((value + 1) / 2) * 100)}%`;
}
/** 변화량 표기: +6 / -3 / 0 (부호 명시). */
export function formatDelta(delta: number): string {
if (delta > 0) return `+${delta}`;
@ -72,6 +101,33 @@ export function formatDateISO(input: string | Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** ISO/Date를 한국어 월·일·시·분으로 표시한다. */
export function formatDateTimeKo(
input: string | Date | null | undefined,
fallback = "-",
): string {
if (!input) return fallback;
const date = typeof input === "string" ? new Date(input) : input;
if (Number.isNaN(date.getTime()))
return typeof input === "string" ? input : fallback;
return date.toLocaleString("ko-KR", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/** Unix epoch 초를 한국어 월·일·시·분으로 표시한다. */
export function formatUnixSecondsKo(
seconds: number | null | undefined,
fallback = "-",
): string {
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0)
return fallback;
return formatDateTimeKo(new Date(seconds * 1000), fallback);
}
/** 0~1 클램프. */
export function clamp01(v: number): number {
if (v < 0) return 0;

View file

@ -1,5 +1,8 @@
import type { AvatarPersona } from "../components/avatar/ClientAvatar";
import type { PersonaSummary } from "./api";
import type {
AvatarAffect,
AvatarPersona,
} from "../components/avatar/ClientAvatar";
import type { PersonaReviewStatus, PersonaSummary } from "./api";
export const DIFFICULTY_LABEL: Record<string, string> = {
easy: "기초",
@ -7,6 +10,140 @@ export const DIFFICULTY_LABEL: Record<string, string> = {
hard: "고난도",
};
const THEORY_LABEL: Record<string, string> = {
humanistic: "인간중심",
cbt: "인지행동",
"cognitive-behavioral": "인지행동",
psychodynamic: "정신역동",
gestalt: "게슈탈트",
dbt: "DBT",
};
type PersonaAvatarLook = Partial<AvatarPersona> & {
expressionBias?: AvatarAffect;
};
interface PersonaAvatarAppearance {
ageBand: NonNullable<AvatarPersona["ageBand"]>;
skinTone: string;
hair: NonNullable<AvatarPersona["hair"]>;
outfitColor: string;
eyeColor: string;
realism: number;
resistance: number;
accentColor?: string;
expressionBias?: AvatarAffect;
}
const PERSONA_AVATAR_LOOKS: Record<string, PersonaAvatarLook> = {
P1: {
skinTone: "#F0DDC4",
hair: { style: "long-straight", color: "#5A4030" },
outfitColor: "#7C8A92",
eyeColor: "#7A5A3C",
accentColor: "#6B5E7D",
expressionBias: "sad",
},
P4: {
skinTone: "#E5C0A0",
hair: { style: "long-straight", color: "#35261F" },
outfitColor: "#5F6F88",
eyeColor: "#2F3438",
accentColor: "#3B6E8F",
expressionBias: "anxious",
},
P5: {
skinTone: "#D7AD8D",
hair: { style: "messy", color: "#28231F" },
outfitColor: "#6F766F",
eyeColor: "#24292A",
accentColor: "#7D8784",
expressionBias: "guarded",
},
P6: {
skinTone: "#E6BE9A",
hair: { style: "soft-wave", color: "#2D241F" },
outfitColor: "#776F85",
eyeColor: "#2E2B35",
accentColor: "#6B5E7D",
expressionBias: "conflicted",
},
P7: {
skinTone: "#D5AA88",
hair: { style: "side-part", color: "#23211E" },
outfitColor: "#596367",
eyeColor: "#25292B",
accentColor: "#6F7777",
expressionBias: "tired",
},
};
export const PENDING_PERSONA_AVATAR_APPEARANCE = {
ageBand: "adult",
skinTone: "#D7CCC3",
hair: { style: "short", color: "#7A7068" },
outfitColor: "#6F7777",
eyeColor: "#2B2B2B",
} satisfies Partial<AvatarPersona>;
export function personaAvatarLook(code: string): PersonaAvatarLook {
return PERSONA_AVATAR_LOOKS[code.trim().toUpperCase()] ?? {};
}
export function personaBaselineExpression(persona: PersonaSummary | null): AvatarAffect {
if (!persona) return "neutral";
return personaAvatarLook(persona.code).expressionBias ?? "neutral";
}
export function personaAvatarAppearance(persona: PersonaSummary): PersonaAvatarAppearance {
const look = personaAvatarLook(persona.code);
const sex = demographicText(persona.demographics.sex);
const isMale = sex === "male" || sex === "남성";
const ageBand = personaAgeBand(persona);
return {
ageBand,
skinTone: look.skinTone ?? (isMale ? "#D8B18F" : "#E3BE9B"),
hair: look.hair ?? {
style: ageBand === "teen" ? "long-straight" : isMale ? "short" : "bob",
color: isMale ? "#25221F" : "#31251E",
},
outfitColor: look.outfitColor ?? (isMale ? "#5E676A" : "#6A6874"),
eyeColor: look.eyeColor ?? "#2B2B2B",
accentColor: look.accentColor,
realism: 0.4,
resistance:
persona.difficulty === "hard"
? 0.68
: persona.difficulty === "moderate"
? 0.45
: 0.3,
expressionBias: look.expressionBias,
};
}
export function personaDifficultyLabel(value: string): string {
return DIFFICULTY_LABEL[value] ?? value;
}
export function personaTheoryLabel(value: string): string {
return THEORY_LABEL[value] ?? value;
}
export function personaReviewStatusLabel(status: PersonaReviewStatus): string {
if (status === "review") return "검수 대기";
if (status === "draft") return "수정 대기";
if (status === "approved") return "승인됨";
return "보관됨";
}
export function personaReviewTone(
status: PersonaReviewStatus,
): "accent" | "neutral" | "warn" {
if (status === "review") return "accent";
if (status === "draft") return "warn";
return "neutral";
}
export function isUsablePersona(persona: PersonaSummary): boolean {
return !persona.degraded && persona.source === "database";
}

View file

@ -0,0 +1,9 @@
/** 현재 문서를 실행 중인 Vite 진입 번들 이름을 진단 정보로 반환한다. */
export function runtimeAssetLabel(): string {
if (typeof document === "undefined") return "unknown";
const script = Array.from(document.scripts)
.map((item) => item.getAttribute("src") ?? "")
.find((src) => src.includes("/assets/index-") && src.endsWith(".js"));
if (!script) return "unknown";
return script.split("/").pop() ?? script;
}

View file

@ -1,6 +1,7 @@
export type AppTheme = "light" | "dark";
export const THEME_KEY = "vignette.theme";
const themeListeners = new Set<() => void>();
export function readInitialTheme(): AppTheme {
try {
@ -14,12 +15,26 @@ export function readInitialTheme(): AppTheme {
}
export function applyTheme(theme: AppTheme) {
const changed = document.documentElement.getAttribute("data-theme") !== theme;
document.documentElement.setAttribute("data-theme", theme);
try {
localStorage.setItem(THEME_KEY, theme);
} catch {
/* 저장 실패는 렌더링을 막지 않는다. */
}
if (changed) themeListeners.forEach((listener) => listener());
}
/** React와 비 React 진입점이 함께 쓰는 현재 적용 테마 snapshot. */
export function readAppliedTheme(): AppTheme {
const applied = document.documentElement.getAttribute("data-theme");
return applied === "light" || applied === "dark" ? applied : readInitialTheme();
}
/** 앱 전역 테마 store 구독. 테마 상태의 유일한 알림 경로다. */
export function subscribeTheme(listener: () => void) {
themeListeners.add(listener);
return () => themeListeners.delete(listener);
}
export function initTheme() {

View file

@ -0,0 +1,17 @@
import { useCallback, useSyncExternalStore } from "react";
import {
applyTheme,
readAppliedTheme,
subscribeTheme,
type AppTheme,
} from "./theme";
/**
* hook. Topbar와 Settings가 external store를 .
* Settings ([dark, setDark]) .
*/
export function useTheme(): [boolean, (next: boolean) => void] {
const theme = useSyncExternalStore(subscribeTheme, readAppliedTheme, (): AppTheme => "dark");
const setDark = useCallback((next: boolean) => applyTheme(next ? "dark" : "light"), []);
return [theme === "dark", setDark];
}