import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { AppShell } from "../components/shell/AppShell";
import {
Badge,
Button,
Card,
Dot,
EmptyState as UiEmptyState,
Icon,
Kicker,
surfaceClassName,
} from "../components/ui";
import {
ApiError,
personaApi,
personaReviewApi,
teacherApi,
type PersonaDraftDetail,
type PersonaDraftGenerateRequest,
type PersonaGenerationEvidence,
type PersonaReviewAction,
type PersonaReviewStatus,
type PersonaReviewSummary,
type PersonaSourceDocumentResponse,
type PersonaSummary,
type TeacherDashboardResponse,
type TeacherGrowthPoint,
} from "../lib/api";
import {
formatDateTimeKo as formatDateTime,
formatOptionalPercent,
} from "../lib/format";
import {
personaDifficultyLabel,
personaReviewStatusLabel,
personaReviewTone,
personaTheoryLabel,
} from "../lib/personaViewModel";
import {
buildPromptPreviewSections,
cloneEmptyDraft,
draftDetailToStudioPayload,
draftPayloadToStudioPayload,
editableListValue,
listValue,
nextPersonaCode,
normalizeCode,
numberValue,
objectValue,
payloadForApi,
stringValue,
validateDraft,
type Difficulty,
type PersonaStudioPayload,
type PromptPreviewSection,
type ValidationResult,
} from "./persona-studio/model";
import "./persona-studio.css";
type LoadState = "loading" | "ready" | "error";
type WorkspaceTab = "dashboard" | "catalog" | "personas";
type PersonaListTab = "all" | "review";
type PersonaDetailTab = "overview" | "learning" | "settings";
type AuthoringStep = "source" | "generate" | "edit" | "review";
type StudioTab =
| "overview"
| "clinical"
| "resistance"
| "scenario"
| "speech"
| "safety"
| "prompt";
type NumberSection = "big5" | "resistance" | "affect_baseline";
type SourceKind = PersonaDraftGenerateRequest["source_kind"];
type ObjectSection =
| "demographics"
| "presenting"
| "history"
| "speech_style"
| "ccd"
| "dsm5_dimensional"
| "triggers";
const STUDIO_TABS: { key: StudioTab; label: string }[] = [
{ key: "overview", label: "개요" },
{ key: "clinical", label: "임상" },
{ key: "resistance", label: "저항" },
{ key: "scenario", label: "회기" },
{ key: "speech", label: "말투" },
{ key: "safety", label: "안전" },
{ key: "prompt", label: "프롬프트" },
];
const AUTHORING_STEPS = [
{ key: "source", label: "자료", desc: "기록, 교재, 가이드 정리" },
{ key: "generate", label: "초안", desc: "근거를 확인하고 초안 생성" },
{ key: "edit", label: "설정", desc: "임상, 회기, 말투 조정" },
{ key: "review", label: "검토", desc: "근거, 위험, 버전 확인" },
] as const;
const WORKSPACE_TABS: { key: WorkspaceTab; label: string; desc: string }[] = [
{ key: "dashboard", label: "대시보드", desc: "운영, 학습, 검수 현황" },
{ key: "catalog", label: "카탈로그", desc: "공개 범위와 구성 정보" },
{ key: "personas", label: "페르소나", desc: "전체 목록과 검수" },
];
const AUTHORING_STEP_KEYS = AUTHORING_STEPS.map((step) => step.key);
const PERSONA_PROGRESS_STORAGE_KEY = "vignette:persona-studio:progress:v1";
const TAB_GUIDANCE: Record<
StudioTab,
{ title: string; desc: string; items: string[] }
> = {
overview: {
title: "기본 정보는 학습자가 바로 알아볼 수 있는 식별자 중심으로 작성",
desc: "코드, 난이도, 이론, 인구통계는 검색과 배정에 쓰인다. 실제 기록 기반이면 출처를 비워두지 마라.",
items: [
"표시 이름은 교수자가 구분할 수 있는 별칭으로 둔다.",
"첫 회기 입장 발화는 시뮬레이션 시작 1분의 톤을 결정한다.",
"주호소는 내담자가 직접 말할 수 있는 문장으로 쓴다.",
],
},
clinical: {
title: "임상 배경은 관찰 사실과 내부 가설을 분리해서 작성",
desc: "가족, 관계, 학교/직장, 촉발 사건은 SSOT 자료에서 확인되는 사실을 우선한다. CCD는 내담자가 직접 설명하지 않는 내부 모델이다.",
items: [
"가족/관계에는 현재 기능에 영향을 주는 반복 패턴을 적는다.",
"촉발 사건은 날짜보다 상담 장면에서 드러날 행동 변화에 초점을 둔다.",
"자동사고는 한 줄에 하나씩, 내담자 머릿속 문장처럼 쓴다.",
],
},
resistance: {
title: "수치 파라미터는 대화 난이도를 조절하는 운영값",
desc: "저항값은 임상 진단이 아니라 시뮬레이션 반응 강도다. 과도하게 극단값을 주면 교육용 피드백이 흔들린다.",
items: [
"초기 저항은 첫 3턴의 방어 강도를 의미한다.",
"침묵/회피 확률은 상담자 개입이 어긋날 때의 반응 확률이다.",
"자살사고 단계는 안전 라우팅과 검증 카드에서 별도로 확인한다.",
],
},
scenario: {
title: "회기 시나리오는 회차별 장면, 개방 조건, 금지 반응을 함께 둔다",
desc: "교수자는 '이 내담자가 언제, 왜, 어떻게 변하는지'를 보고 싶어 한다. 단순 줄거리보다 상담자 행동에 대한 반응 규칙이 중요하다.",
items: [
"1회기: 입장 태도, 숨기는 정보, 라포가 생기는 조건을 적는다.",
"2회기 이후: 이전 회기의 상담자 반응에 따라 드러나는 변화를 적는다.",
"표면화 조건에는 어떤 질문/반영이 주호소를 깊게 만드는지 쓴다.",
],
},
speech: {
title: "말투는 캐릭터 장식이 아니라 상담 기술 평가의 입력값",
desc: "말버릇, 군말, 비언어 단서는 상담자가 반영, 명료화, 공감하기 어렵거나 쉬운 지점을 만든다.",
items: [
"문장 길이는 짧음/중간/김처럼 운영 가능한 값으로 둔다.",
"군말과 말버릇은 과하지 않게 2~4개 정도가 적당하다.",
"비언어 단서는 발화 텍스트에 자연스럽게 섞일 행동만 적는다.",
],
},
safety: {
title: "역린과 금기어는 모델이 말하지 말아야 할 말이 아니라 반응 조건",
desc: "금기어 목록만 늘리면 시뮬레이션이 경직된다. 어떤 개입에서 어떤 방어, 침묵, 분노가 생기는지까지 작성해야 한다.",
items: [
"민감 영역은 내담자가 회피하거나 과민 반응하는 주제다.",
"상담자 금기는 훈련자가 하면 안 되는 개입 방식이다.",
"역린 반응은 침묵, 농담, 반문, 공격성처럼 관찰 가능한 행동으로 쓴다.",
],
},
prompt: {
title: "프롬프트 미리보기는 최종 런타임 계약 검토용",
desc: "여기서는 편집하지 않는다. 누락된 필드가 있으면 앞 탭으로 돌아가 구조화된 데이터에서 수정해라.",
items: [
"내부 설정은 내담자가 직접 설명하지 않도록 유지한다.",
"역린과 회기 규칙이 과도하게 모순되지 않는지 확인한다.",
"검수 전에는 생성 근거와 안전 항목을 함께 확인한다.",
],
},
};
function completenessLabel(result: ValidationResult): string {
if (result.errors.length === 0 && result.warnings.length === 0) return "완료";
if (result.errors.length === 0) return "검토";
return "미완";
}
function average(values: number[]): number | null {
if (values.length === 0) return null;
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function percentLabel(value: number | null | undefined): string {
return formatOptionalPercent(value, "자료 없음");
}
// 표시 이름 데이터에 가운뎃점이 2개 들어 있다("서연(가명) · 고2 · 우울/자살사고").
// 데이터는 그대로 두고 화면에서만 "서연(가명) · 고2" + "우울/자살사고" 두 줄로 나눠
// 한 줄에 가운뎃점이 1개만 남게 한다. 가운뎃점이 1개 이하면 원문을 그대로 쓴다.
function splitPersonaName(name: string): { head: string; tail: string } {
const parts = name
.split("·")
.map((part) => part.trim())
.filter(Boolean);
if (parts.length < 3) return { head: name, tail: "" };
return {
head: parts.slice(0, 2).join(" · "),
tail: parts.slice(2).join(", "),
};
}
function personaOrigin(code: string, source?: string): "system" | "custom" {
if (/^P(?:[1-9]|1[01])$/i.test(code)) return "system";
return source === "seed" ? "system" : "custom";
}
function reviewStatusDescription(status: PersonaReviewStatus): string {
if (status === "review")
return "검수자가 승인 또는 수정 요청을 결정해야 합니다.";
if (status === "draft") return "작성자가 편집 중인 비공개 초안입니다.";
if (status === "approved") return "학습자 카탈로그에 공개된 버전입니다.";
return "새 회기 배정에서 제외된 보관 버전입니다.";
}
function TextField({
label,
value,
onChange,
placeholder,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) {
return (
{label}
onChange(event.target.value)}
placeholder={placeholder}
/>
);
}
function TextAreaField({
label,
value,
onChange,
rows = 4,
placeholder,
}: {
label: string;
value: string;
onChange: (value: string) => void;
rows?: number;
placeholder?: string;
}) {
return (
{label}
);
}
function ListField({
label,
value,
onChange,
rows = 2,
placeholder,
}: {
label: string;
value: string[];
onChange: (value: string[]) => void;
rows?: number;
placeholder?: string;
}) {
const items = value.length ? value : [""];
const updateItem = (index: number, nextValue: string) => {
const next = [...items];
next[index] = nextValue;
onChange(next);
};
const removeItem = (index: number) => {
onChange(items.filter((_, itemIndex) => itemIndex !== index));
};
return (
{label}
}
onClick={() => onChange([...items, ""])}
>
항목 추가
{items.map((item, index) => (
))}
);
}
function PromptPreviewPanel({
sections,
}: {
sections: PromptPreviewSection[];
}) {
return (
{sections.map((section) => (
{section.title}
{section.desc ?
{section.desc}
: null}
{section.rows?.length ? (
{section.rows.map((row) => (
{row.label}
{row.value}
))}
) : null}
{section.items?.length ? (
{section.items.map((item, index) => (
{item}
))}
) : null}
{section.body ? (
{section.body}
) : null}
{!section.rows?.length && !section.items?.length && !section.body ? (
작성된 항목 없음
) : null}
))}
);
}
function NumberField({
label,
value,
onChange,
min = 0,
max = 1,
step = 0.05,
}: {
label: string;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
}) {
return (
{label}
onChange(Number(event.target.value))}
/>
);
}
function EmptyState({ title, desc }: { title: string; desc: string }) {
return ;
}
// 페르소나 스튜디오는 Kicker 가 21곳이라 저작 화면 한 폭에 accent dot 이 8개까지 몰렸다.
// 이 화면의 Kicker 는 전부 "섹션 제목 장식"일 뿐 현재 상태(정상/주의/라이브)를 전달하지 않으므로
// dot={false} 로 통일한다. 실제 상태 신호는 그대로 컴포넌트가 담당한다.
function GuidancePanel({ tab }: { tab: StudioTab }) {
const guidance = TAB_GUIDANCE[tab];
return (
작성 가이드
{guidance.title}
{guidance.desc}
{guidance.items.map((item) => (
{item}
))}
);
}
export default function PersonaStudio() {
const [searchParams, setSearchParams] = useSearchParams();
const requestedDraftId = searchParams.get("draft");
const requestedView = searchParams.get("view");
const workspaceTab: WorkspaceTab =
requestedView === "catalog" || requestedView === "personas"
? requestedView
: "dashboard";
const personaListTab: PersonaListTab =
searchParams.get("queue") === "review" ? "review" : "all";
const detailKey = searchParams.get("persona");
const detailTabParam = searchParams.get("detailTab");
const detailTab: PersonaDetailTab =
detailTabParam === "learning" || detailTabParam === "settings"
? detailTabParam
: "overview";
const isAuthoring =
searchParams.get("mode") === "create" ||
searchParams.get("mode") === "edit";
const requestedStep = searchParams.get("step");
const authoringStep: AuthoringStep = AUTHORING_STEP_KEYS.includes(
requestedStep as AuthoringStep,
)
? (requestedStep as AuthoringStep)
: "source";
const sourceFileInputRef = useRef(null);
const [catalog, setCatalog] = useState([]);
const [reviews, setReviews] = useState([]);
const [teacherDashboard, setTeacherDashboard] =
useState(null);
const [dashboardError, setDashboardError] = useState(null);
const [loadState, setLoadState] = useState("loading");
const [loadError, setLoadError] = useState(null);
const [draft, setDraft] = useState(() =>
cloneEmptyDraft(),
);
const [sourceText, setSourceText] = useState("");
const [sourceFilename, setSourceFilename] = useState("");
const [sourceKind, setSourceKind] = useState("client_record");
const [sourceReferences, setSourceReferences] = useState<
PersonaSourceDocumentResponse[]
>([]);
const [generationEvidence, setGenerationEvidence] = useState<
PersonaGenerationEvidence[]
>([]);
const [generationGoal, setGenerationGoal] = useState("");
const [generationSummary, setGenerationSummary] = useState("");
const [generationWarnings, setGenerationWarnings] = useState([]);
const [maskedEntities, setMaskedEntities] = useState([]);
const [editingId, setEditingId] = useState(null);
const [activeTab, setActiveTab] = useState("overview");
const [busy, setBusy] = useState<
"load" | "save" | "submit" | "decide" | "generate" | "archive" | null
>(null);
const [message, setMessage] = useState(null);
const [formError, setFormError] = useState(null);
const [autoLoadedDraftId, setAutoLoadedDraftId] = useState(
null,
);
const [personaSearch, setPersonaSearch] = useState("");
const [originFilter, setOriginFilter] = useState<"all" | "system" | "custom">(
"all",
);
const [selectedDraftDetail, setSelectedDraftDetail] =
useState(null);
const loadStudio = useCallback(async () => {
setLoadState("loading");
setLoadError(null);
setDashboardError(null);
try {
const [nextCatalog, nextReviews, nextDashboard] =
await Promise.allSettled([
personaApi.list(),
personaReviewApi.list(),
teacherApi.dashboard(),
]);
if (nextCatalog.status === "rejected") throw nextCatalog.reason;
setCatalog(nextCatalog.value);
if (nextReviews.status === "fulfilled") {
setReviews(nextReviews.value);
} else if (
nextReviews.reason instanceof ApiError &&
nextReviews.reason.status === 404
) {
setReviews([]);
} else {
throw nextReviews.reason;
}
if (nextDashboard.status === "fulfilled") {
setTeacherDashboard(nextDashboard.value);
} else {
setTeacherDashboard(null);
setDashboardError(
"학습 지표를 불러오지 못했습니다. 카탈로그와 저작 기능은 계속 사용할 수 있습니다.",
);
}
setLoadState("ready");
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
setReviews([]);
setLoadState("ready");
} else {
setLoadError(
err instanceof Error
? err.message
: "페르소나 정보를 불러오지 못했습니다.",
);
setLoadState("error");
}
}
}, []);
useEffect(() => {
void loadStudio();
}, [loadStudio]);
const suggestedCode = useMemo(
() => nextPersonaCode(catalog, reviews),
[catalog, reviews],
);
useEffect(() => {
if (draft.code || editingId || loadState !== "ready") return;
setDraft((current) => ({ ...current, code: suggestedCode }));
}, [draft.code, editingId, loadState, suggestedCode]);
const codeTaken = useMemo(() => {
const code = normalizeCode(draft.code);
if (!code || editingId) return false;
return (
catalog.some((persona) => normalizeCode(persona.code) === code) ||
reviews.some((persona) => normalizeCode(persona.code) === code)
);
}, [catalog, draft.code, editingId, reviews]);
const validation = useMemo(
() => validateDraft(draft, true, codeTaken),
[codeTaken, draft],
);
const saveValidation = useMemo(
() => validateDraft(draft, false, codeTaken),
[codeTaken, draft],
);
const promptPreviewSections = useMemo(
() => buildPromptPreviewSections(draft),
[draft],
);
const authoringStepIndex = useMemo(() => {
const hasEditedDraft = Boolean(
editingId ||
draft.display_name.trim() ||
stringValue(draft.presenting, "complaint").trim(),
);
if (hasEditedDraft && validation.errors.length === 0) return 3;
if (hasEditedDraft || sourceReferences.length > 0) return 2;
if (sourceText.trim()) return 1;
return 0;
}, [
draft.display_name,
draft.presenting,
editingId,
sourceReferences.length,
sourceText,
validation.errors.length,
]);
const setQuery = useCallback(
(updates: Record) => {
setSearchParams((current) => {
const next = new URLSearchParams(current);
for (const [key, value] of Object.entries(updates)) {
if (value === null) next.delete(key);
else next.set(key, value);
}
return next;
});
},
[setSearchParams],
);
const learningPoints = useMemo(
() =>
(teacherDashboard?.learner_growth ?? []).flatMap((learner) =>
(learner.points ?? []).map((point) => ({
...point,
learner_id: learner.learner_id,
})),
),
[teacherDashboard],
);
const usageByPersona = useMemo(() => {
const result = new Map<
string,
{
activeSessions: number;
recentSessions: number;
learners: number;
avgScore: number | null;
avgRapport: number | null;
points: Array;
}
>();
const recentSessions = teacherDashboard?.recent_sessions ?? [];
const codes = new Set([
...catalog.map((persona) => normalizeCode(persona.code)),
...reviews.map((persona) => normalizeCode(persona.code)),
...recentSessions.map((session) => normalizeCode(session.persona_code)),
...learningPoints.map((point) => normalizeCode(point.persona_code)),
]);
for (const code of codes) {
const sessions = recentSessions.filter(
(session) => normalizeCode(session.persona_code) === code,
);
const points = learningPoints.filter(
(point) => normalizeCode(point.persona_code) === code,
);
const learners = new Set([
...sessions.map((session) => session.learner_id),
...points.map((point) => point.learner_id),
]);
result.set(code, {
activeSessions: sessions.filter((session) => session.status !== "ended")
.length,
recentSessions: sessions.length,
learners: learners.size,
avgScore: average(
points.flatMap((point) => (point.score == null ? [] : [point.score])),
),
avgRapport: average(
points.flatMap((point) =>
point.rapport == null ? [] : [point.rapport],
),
),
points,
});
}
return result;
}, [catalog, learningPoints, reviews, teacherDashboard]);
const personaRows = useMemo(() => {
const published = catalog.map((persona) => ({
key: `approved:${persona.code}`,
kind: "approved" as const,
code: persona.code,
name: persona.display_name,
difficulty: persona.difficulty,
theory: persona.theory_target,
version: persona.version ?? 1,
status: "approved" as PersonaReviewStatus,
origin: personaOrigin(persona.code, persona.source),
persona,
}));
const inProgress = reviews.map((persona) => ({
key: `draft:${persona.persona_id}`,
kind: "draft" as const,
code: persona.code,
name: persona.display_name,
difficulty: persona.difficulty,
theory: persona.theory_target,
version: persona.version,
status: persona.status,
origin: personaOrigin(persona.code),
persona,
}));
const query = personaSearch.trim().toLocaleLowerCase("ko-KR");
return [...inProgress, ...published]
.filter((row) => originFilter === "all" || row.origin === originFilter)
.filter((row) => {
if (!query) return true;
return [
row.code,
row.name,
...row.theory,
personaReviewStatusLabel(row.status),
]
.join(" ")
.toLocaleLowerCase("ko-KR")
.includes(query);
})
.sort(
(left, right) =>
left.code.localeCompare(right.code, "ko-KR", { numeric: true }) ||
right.version - left.version,
);
}, [catalog, originFilter, personaSearch, reviews]);
const selectedRow = useMemo(
() => personaRows.find((row) => row.key === detailKey) ?? null,
[detailKey, personaRows],
);
const reviewQueue = useMemo(
() =>
reviews.filter(
(persona) => persona.status === "review" || persona.status === "draft",
),
[reviews],
);
const dashboardPoints = learningPoints.filter(
(point) => point.score != null || point.rapport != null,
);
const dashboardScore = average(
dashboardPoints.flatMap((point) =>
point.score == null ? [] : [point.score],
),
);
const dashboardRapport = average(
dashboardPoints.flatMap((point) =>
point.rapport == null ? [] : [point.rapport],
),
);
const activePersonaCount = Array.from(usageByPersona.values()).filter(
(usage) => usage.activeSessions > 0,
).length;
useEffect(() => {
if (!detailKey?.startsWith("draft:")) {
setSelectedDraftDetail(null);
return;
}
const personaId = detailKey.slice("draft:".length);
let cancelled = false;
void personaReviewApi
.getDraft(personaId)
.then((detail) => {
if (!cancelled) setSelectedDraftDetail(detail);
})
.catch(() => {
if (!cancelled) setSelectedDraftDetail(null);
});
return () => {
cancelled = true;
};
}, [detailKey]);
const updateRoot = useCallback(
(
key: Key,
value: PersonaStudioPayload[Key],
) => {
setDraft((current) => ({ ...current, [key]: value }));
setFormError(null);
setMessage(null);
},
[],
);
const updateObject = useCallback(
(section: ObjectSection, key: string, value: unknown) => {
setDraft((current) => ({
...current,
[section]: {
...objectValue(current[section]),
[key]: value,
},
}));
setFormError(null);
setMessage(null);
},
[],
);
const updateNumber = useCallback(
(section: NumberSection, key: string, value: number) => {
setDraft((current) => ({
...current,
[section]: {
...current[section],
[key]: Number.isFinite(value) ? value : 0,
},
}));
setFormError(null);
setMessage(null);
},
[],
);
const toggleTheory = useCallback((value: string) => {
setDraft((current) => {
const exists = current.theory_target.includes(value);
const next = exists
? current.theory_target.filter((item) => item !== value)
: [...current.theory_target, value];
return {
...current,
theory_target: next.length > 0 ? next : ["humanistic"],
};
});
}, []);
const resetDraft = useCallback(() => {
const next = cloneEmptyDraft();
next.code = suggestedCode;
setDraft(next);
setEditingId(null);
setActiveTab("overview");
setSourceText("");
setSourceFilename("");
setGenerationGoal("");
setGenerationSummary("");
setGenerationWarnings([]);
setMaskedEntities([]);
setSourceReferences([]);
setGenerationEvidence([]);
setFormError(null);
setMessage(null);
}, [suggestedCode]);
const openCreate = useCallback(() => {
resetDraft();
try {
const saved = window.localStorage.getItem(PERSONA_PROGRESS_STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved) as {
draft?: PersonaStudioPayload;
sourceText?: string;
sourceFilename?: string;
sourceKind?: SourceKind;
generationGoal?: string;
};
if (parsed.draft) setDraft(parsed.draft);
if (typeof parsed.sourceText === "string")
setSourceText(parsed.sourceText);
if (typeof parsed.sourceFilename === "string")
setSourceFilename(parsed.sourceFilename);
if (parsed.sourceKind) setSourceKind(parsed.sourceKind);
if (typeof parsed.generationGoal === "string")
setGenerationGoal(parsed.generationGoal);
setMessage("이 브라우저에 저장된 작성 중 내용을 복원했습니다.");
}
} catch {
window.localStorage.removeItem(PERSONA_PROGRESS_STORAGE_KEY);
}
setSearchParams({ view: "personas", mode: "create", step: "source" });
}, [resetDraft, setSearchParams]);
const openDraftEditor = useCallback(
(personaId: string) => {
setSearchParams({
view: "personas",
mode: "edit",
draft: personaId,
step: "edit",
});
},
[setSearchParams],
);
const loadDraft = useCallback(async (personaId: string) => {
setBusy("load");
setFormError(null);
setMessage(null);
try {
const detail = await personaReviewApi.getDraft(personaId);
setDraft(draftDetailToStudioPayload(detail));
setEditingId(detail.persona_id);
setActiveTab("overview");
setMessage(`${detail.code} v${detail.version} 초안을 불러왔습니다.`);
} catch (err) {
setFormError(
err instanceof Error ? err.message : "초안을 불러오지 못했습니다.",
);
} finally {
setBusy(null);
}
}, []);
const reviseApprovedPersona = useCallback(
async (persona: PersonaSummary) => {
if (!persona.persona_id) {
setFormError("DB에 등록된 페르소나만 수정 초안을 만들 수 있습니다.");
return;
}
setBusy("load");
setFormError(null);
setMessage(null);
try {
const detail = await personaReviewApi.reviseApproved(
persona.persona_id,
);
setDraft(draftDetailToStudioPayload(detail));
setEditingId(detail.persona_id);
setActiveTab("overview");
setMessage(
`${detail.code} v${detail.version} 수정 초안을 불러왔습니다. 승인 전까지 기존 공개본은 유지됩니다.`,
);
setSearchParams({
view: "personas",
mode: "edit",
draft: detail.persona_id,
step: "edit",
});
await loadStudio();
} catch (err) {
setFormError(
err instanceof Error
? err.message
: "공개 페르소나 수정 초안을 만들지 못했습니다.",
);
} finally {
setBusy(null);
}
},
[loadStudio, setSearchParams],
);
const archiveApprovedPersona = useCallback(
async (persona: PersonaSummary) => {
if (!persona.persona_id) {
setFormError("DB에 등록된 페르소나만 삭제할 수 있습니다.");
return;
}
const ok = window.confirm(
`${persona.code} ${persona.display_name} 공개 페르소나를 삭제할까요?\n기존 회기 기록은 유지되고, 새 회기 시작 목록에서만 사라집니다.`,
);
if (!ok) return;
setBusy("archive");
setFormError(null);
setMessage(null);
try {
const archived = await personaReviewApi.archive(persona.persona_id);
if (
editingId === persona.persona_id ||
normalizeCode(draft.code) === normalizeCode(archived.code)
) {
resetDraft();
}
setMessage(
`${archived.code} 페르소나를 보관 처리했습니다. 기존 회기 기록은 유지됩니다.`,
);
await loadStudio();
} catch (err) {
setFormError(
err instanceof Error
? err.message
: "페르소나를 삭제하지 못했습니다.",
);
} finally {
setBusy(null);
}
},
[draft.code, editingId, loadStudio, resetDraft],
);
const loadSourceFile = useCallback(
async (file: File | null) => {
if (!file) return;
const lowered = file.name.toLowerCase();
// P4: 엑셀(자유 양식)은 서버에서 변환·등록한다. 원본은 서버에 저장되지 않는다.
if (
lowered.endsWith(".xlsx") ||
lowered.endsWith(".xlsm") ||
lowered.endsWith(".xls")
) {
setBusy("generate");
setFormError(null);
try {
const registered = await personaReviewApi.uploadSource(file, {
source_kind: sourceKind,
title: file.name,
source_note: generationGoal,
});
setSourceReferences((current) => [...current, registered]);
setSourceFilename(file.name);
setMessage(
`${file.name}을(를) 표 → 텍스트로 변환해 KB 근거 문서로 등록했습니다. 원본 파일은 서버에 남지 않습니다.`,
);
} catch (err) {
setFormError(
err instanceof Error
? err.message
: "엑셀 자료를 변환해 등록하지 못했습니다.",
);
} finally {
setBusy(null);
}
return;
}
try {
const text = await file.text();
setSourceFilename(file.name);
setSourceText(text);
setFormError(null);
setMessage(
`${file.name} 첨부 내용을 불러왔습니다. 생성 시 KB 근거 문서로 먼저 등록됩니다.`,
);
} catch {
setFormError(
"첨부 파일을 텍스트로 읽지 못했습니다. txt/md/csv처럼 텍스트 추출 가능한 파일을 사용하세요.",
);
}
},
[generationGoal, sourceKind],
);
const generateFromSource = useCallback(async () => {
// 업로드로 이미 등록된 근거(엑셀 등)가 있으면 붙여넣기 없이도 생성 가능(P4).
const uploadedIds = sourceReferences.map((ref) => ref.source_id);
const hasPastedText = sourceText.trim().length >= 20;
if (!hasPastedText && uploadedIds.length === 0) {
setFormError(
"첨부(엑셀 업로드 포함) 또는 붙여넣기 자료를 20자 이상 입력해야 초안을 생성할 수 있습니다.",
);
return;
}
setBusy("generate");
setFormError(null);
setMessage(null);
setGenerationSummary("");
setGenerationWarnings([]);
setMaskedEntities([]);
setGenerationEvidence([]);
try {
const registered: PersonaSourceDocumentResponse[] = [];
if (hasPastedText) {
registered.push(
await personaReviewApi.createSource({
filename: sourceFilename || "persona-source.txt",
source_kind: sourceKind,
text: sourceText,
title: sourceFilename || "붙여넣은 페르소나 저작 자료",
source_note: generationGoal,
}),
);
}
const sourceIds = [
...uploadedIds,
...registered.map((ref) => ref.source_id),
];
const response = await personaReviewApi.generateDraft({
source_ids: sourceIds,
source_kind: sourceKind,
code_hint: normalizeCode(draft.code) || suggestedCode,
display_name_hint: draft.display_name.trim() || null,
difficulty: draft.difficulty,
theory_target: draft.theory_target,
generation_goal: generationGoal,
});
const generated = draftPayloadToStudioPayload(response.draft);
if (!generated.code || generated.code === "P") {
generated.code = suggestedCode;
}
setDraft(generated);
setActiveTab("overview");
setGenerationSummary(response.source_summary);
setGenerationWarnings(response.warnings ?? []);
setMaskedEntities([
...registered.flatMap((ref) => ref.pii_entities_masked ?? []),
...(response.pii_entities_masked ?? []),
]);
setSourceReferences(
response.source_references?.length
? response.source_references
: [...sourceReferences, ...registered],
);
setGenerationEvidence(response.evidence_chunks ?? []);
setMessage(
"첨부 자료를 RAG 근거 문서로 등록하고 초안을 생성했습니다. 각 탭에서 검수 기준에 맞게 수정하세요.",
);
} catch (err) {
setFormError(
err instanceof Error
? err.message
: "자료 기반 초안을 생성하지 못했습니다.",
);
} finally {
setBusy(null);
}
}, [
draft.code,
draft.difficulty,
draft.display_name,
draft.theory_target,
generationGoal,
sourceFilename,
sourceKind,
sourceReferences,
sourceText,
suggestedCode,
]);
useEffect(() => {
if (
!isAuthoring ||
loadState !== "ready" ||
!requestedDraftId ||
autoLoadedDraftId === requestedDraftId
)
return;
setAutoLoadedDraftId(requestedDraftId);
void loadDraft(requestedDraftId);
}, [autoLoadedDraftId, isAuthoring, loadDraft, loadState, requestedDraftId]);
useEffect(() => {
if (isAuthoring && authoringStep === "review") setActiveTab("prompt");
}, [authoringStep, isAuthoring]);
const saveDraft = useCallback(
async (submitForReview: boolean) => {
const result = submitForReview ? validation : saveValidation;
if (result.errors.length > 0) {
setFormError(result.errors[0]);
return;
}
setBusy(submitForReview ? "submit" : "save");
setFormError(null);
setMessage(null);
try {
const payload = payloadForApi(draft, submitForReview);
const saved = editingId
? await personaReviewApi.updateDraft(editingId, payload)
: await personaReviewApi.createDraft(payload);
setEditingId(saved.persona_id);
setMessage(
saved.status === "review"
? `${saved.code} v${saved.version} 검수 요청을 올렸습니다.`
: `${saved.code} v${saved.version} 초안을 저장했습니다.`,
);
if (saved.status === "review") {
window.localStorage.removeItem(PERSONA_PROGRESS_STORAGE_KEY);
}
await loadStudio();
} catch (err) {
setFormError(
err instanceof Error
? err.message
: "페르소나 초안을 저장하지 못했습니다.",
);
} finally {
setBusy(null);
}
},
[draft, editingId, loadStudio, saveValidation, validation],
);
const saveProgress = useCallback(async () => {
window.localStorage.setItem(
PERSONA_PROGRESS_STORAGE_KEY,
JSON.stringify({
draft,
sourceText,
sourceFilename,
sourceKind,
generationGoal,
}),
);
if (saveValidation.errors.length === 0) {
await saveDraft(false);
return;
}
setFormError(null);
setMessage(
"작성 중 내용을 이 브라우저에 임시 저장했습니다. 이름과 기본 정보를 채우면 서버 초안으로도 저장됩니다.",
);
}, [
draft,
generationGoal,
saveDraft,
saveValidation.errors.length,
sourceFilename,
sourceKind,
sourceText,
]);
const decideReview = useCallback(
async (personaId: string, action: PersonaReviewAction) => {
setBusy("decide");
setFormError(null);
setMessage(null);
try {
const updated = await personaReviewApi.decide(personaId, action);
setMessage(
updated.status === "approved"
? `${updated.code} v${updated.version} 승인했습니다.`
: `${updated.code} v${updated.version} 반려했습니다.`,
);
if (editingId === personaId && updated.status === "approved")
resetDraft();
await loadStudio();
} catch (err) {
setFormError(
err instanceof Error
? err.message
: "검수 결정을 저장하지 못했습니다.",
);
} finally {
setBusy(null);
}
},
[editingId, loadStudio, resetDraft],
);
const renderTab = () => {
if (activeTab === "overview") {
return (
updateRoot("code", normalizeCode(value))}
placeholder={suggestedCode}
/>
updateRoot("display_name", value)}
/>
난이도
updateRoot("difficulty", event.target.value as Difficulty)
}
>
easy
moderate
hard
합성 여부
updateRoot("is_synthetic", event.target.value === "true")
}
>
synthetic
clinical source
대상 이론
{["humanistic", "cbt", "integrative"].map((value) => (
toggleTheory(value)}
>
{value}
))}
updateObject("demographics", "age_band", value)
}
/>
updateObject("demographics", "sex", value)}
/>
updateObject("demographics", "role", value)}
/>
updateObject("demographics", "context", value)}
/>
updateObject("presenting", "complaint", value)}
/>
updateObject("presenting", "first_session_opening", value)
}
/>
updateRoot("source_provenance", value)}
/>
);
}
if (activeTab === "clinical") {
return (
{[
{
key: "family",
label: "가족",
placeholder:
"예: 부와 성취 압박이 강하고, 모와는 감정 이야기를 피함",
},
{
key: "school_or_work",
label: "학교/직장",
placeholder: "예: 조별 과제 갈등 이후 발표와 평가 상황을 회피함",
},
{
key: "relationships",
label: "관계",
placeholder:
"예: 친한 친구에게도 부담을 주기 싫어 고민을 축소해서 말함",
},
{
key: "precipitant",
label: "촉발 사건",
placeholder:
"예: 최근 실습 피드백에서 '소극적'이라는 말을 들은 뒤 불안 증가",
},
{
key: "strengths",
label: "강점",
placeholder:
"예: 기록을 꼼꼼히 남기고, 신뢰가 생기면 자기 관찰을 잘함",
},
].map(({ key, label, placeholder }) => (
updateObject("history", key, value)}
placeholder={placeholder}
/>
))}
updateObject("ccd", "core_belief", value)}
placeholder="예: 나는 충분히 잘하지 못하면 쉽게 버려질 수 있다."
/>
updateObject("ccd", "intermediate_belief", value)
}
placeholder="예: 실수하면 먼저 설명하기보다 조용히 지나가야 한다."
/>
updateObject("ccd", "automatic_thought", value)
}
placeholder="예: 또 실망시켰을 거야."
/>
updateObject("ccd", "coping_strategy", value)}
placeholder="예: 괜찮다고 말하며 주제를 바꾸고, 구체 질문에는 짧게 대답함"
/>
updateObject("ccd", "compensatory", value)}
placeholder="예: 상담자에게 인정받으려 모범 답안을 말하려 함"
/>
updateObject("dsm5_dimensional", "note", value)
}
placeholder="예: 불안은 평가 상황에서 높고, 수면 저하는 시험 기간에 악화됨"
/>
);
}
if (activeTab === "resistance") {
return (
{[
["base_resistance", "초기 저항"],
["unlock_rate", "라포 unlock"],
["decay_floor", "저항 하한"],
["silence_prob", "침묵 확률"],
["deflection_prob", "회피 확률"],
].map(([key, label]) => (
updateNumber("resistance", key, value)}
/>
))}
{[
["O", "개방성 O"],
["C", "성실성 C"],
["E", "외향성 E"],
["A", "우호성 A"],
["N", "신경성 N"],
].map(([key, label]) => (
updateNumber("big5", key, value)}
/>
))}
{[
["negative_affect", "부정 정서"],
["hopelessness", "무망감"],
["anhedonia", "무쾌감"],
["sleep", "수면"],
["anxiety", "불안"],
].map(([key, label]) => (
updateNumber("affect_baseline", key, value)}
/>
))}
updateNumber("affect_baseline", "suicide_ideation_stage", value)
}
/>
);
}
if (activeTab === "scenario") {
return (
updateObject("triggers", "session_scenarios", value)
}
rows={3}
placeholder="1회기: 상담실 입장 때 예의 바르지만 짧게 답하고, 평가 이야기는 피함"
/>
updateObject("presenting", "surface", value)}
rows={8}
placeholder="예: 상담자가 해결책을 서두르지 않고 감정과 맥락을 함께 반영할 때 주호소가 구체화됨"
/>
updateObject("triggers", "prompt_contract", value)
}
rows={8}
placeholder="예: 내담자는 CCD를 직접 설명하지 않는다. 상담자가 성급히 조언하면 방어적으로 짧게 답한다."
/>
);
}
if (activeTab === "speech") {
return (
updateObject("speech_style", "register", value)
}
/>
updateObject("speech_style", "avg_sentence_len", value)
}
/>
존댓말
updateObject(
"speech_style",
"honorific",
event.target.value === "true",
)
}
>
사용
미사용
updateObject("speech_style", "fillers", value)}
rows={1}
placeholder="예: 음..."
/>
updateObject("speech_style", "verbal_tics", value)
}
rows={1}
placeholder="예: 괜찮아요"
/>
updateObject("speech_style", "nonverbal_cues", value)
}
rows={1}
placeholder="예: 손끝을 만지작거림"
/>
);
}
if (activeTab === "safety") {
return (
updateObject("triggers", "sore_spots", value)}
rows={2}
placeholder="예: 무책임하다는 평가"
/>
updateObject("triggers", "forbidden", value)}
rows={2}
placeholder="예: 네가 예민한 거라고 단정"
/>
updateObject("triggers", "reaction", value)}
rows={7}
/>
updateObject("triggers", "taboo_terms", value)}
rows={1}
placeholder="예: 패배자"
/>
);
}
return ;
};
const selectedUsage = selectedRow
? usageByPersona.get(normalizeCode(selectedRow.code))
: null;
const selectedApproved =
selectedRow?.kind === "approved" ? selectedRow.persona : null;
const selectedReview =
selectedRow?.kind === "draft" ? selectedRow.persona : null;
const catalogSystemCount = catalog.filter(
(persona) => personaOrigin(persona.code, persona.source) === "system",
).length;
const authoringStepPosition = AUTHORING_STEP_KEYS.indexOf(authoringStep);
const previousAuthoringStep = AUTHORING_STEPS[authoringStepPosition - 1]?.key;
const nextAuthoringStep = AUTHORING_STEPS[authoringStepPosition + 1]?.key;
const dashboardView = (
{dashboardError ? (
{dashboardError}
) : null}
{[
{
label: "공개 페르소나",
value: catalog.length,
meta: "학습자 카탈로그 노출",
},
{
label: "현재 운영 중",
value: activePersonaCount,
meta: "진행 회기가 있는 페르소나",
},
{
label: "학습 인원",
value: teacherDashboard?.total_learners ?? 0,
meta: teacherDashboard?.cohort_label ?? "현재 학습 기록",
},
{
label: "진행 세션",
value: teacherDashboard?.active_sessions ?? 0,
meta: `종료 ${teacherDashboard?.ended_sessions ?? 0}회`,
},
].map((item) => (
{item.label}
{item.value}
{item.meta}
))}
학습 성과
평가와 관계 진척도
최근 학습 궤적 {dashboardPoints.length}건
상담 수행 점수
{percentLabel(dashboardScore)}
라포 진척도
{percentLabel(
dashboardRapport == null ? null : (dashboardRapport + 1) / 2,
)}
저장된 상담자 턴 평가와 라포 신호만 집계합니다. 근거가 없으면 숫자를
추정하지 않습니다.
검수 현황
결정이 필요한 버전
setSearchParams({ view: "personas", queue: "review" })
}
>
더보기
검수 대기
{reviews.filter((item) => item.status === "review").length}
승인·반려 필요
작성 중
{reviews.filter((item) => item.status === "draft").length}
비공개 초안
공개
{catalog.length}
학습자 선택 가능
페르소나별 현황
최근 학습 기록
교수자 대시보드의 실제 기록 기준
페르소나
학습자
진행
성과
진척도
{catalog.length ? (
catalog.slice(0, 8).map((persona) => {
const usage = usageByPersona.get(normalizeCode(persona.code));
const name = splitPersonaName(persona.display_name);
return (
setSearchParams({
view: "personas",
persona: `approved:${persona.code}`,
})
}
>
{persona.code}
{name.head}
{name.tail ? {name.tail} : null}
{usage?.learners ?? 0}명
{usage?.activeSessions ?? 0}회
{percentLabel(usage?.avgScore)}
{percentLabel(
usage?.avgRapport == null
? null
: (usage.avgRapport + 1) / 2,
)}
);
})
) : (
)}
);
const catalogTheoryCounts = catalog.reduce>(
(result, persona) => {
for (const theory of persona.theory_target)
result[theory] = (result[theory] ?? 0) + 1;
return result;
},
{},
);
const catalogView = (
카탈로그란
학습자가 회기를 시작할 때 선택하는 승인본 모음입니다.
검수 완료된 최신 버전만 공개하고, 초안, 검수 중, 보관 상태는
운영자에게만 보입니다.
현재 공개
{catalog.length}
시스템 {catalogSystemCount} · 추가{" "}
{catalog.length - catalogSystemCount}
노출 기준
승인된 최신 버전
초안·검수본
운영자에게만 표시
보관 처리
새 회기 배정에서 제외
기존 회기
사용한 버전과 기록 유지
장애 시 정책
대체 시드 상태를 명시
{/* eyebrow "구성"은 바로 아래 "이론 분포"와 같은 말이라 제거 */}
이론 분포
{Object.entries(catalogTheoryCounts).length ? (
Object.entries(catalogTheoryCounts).map(([theory, count]) => (
{personaTheoryLabel(theory)}
{count}
))
) : (
)}
버전과 출처
공개본 상태
setSearchParams({ view: "personas" })}
>
전체 페르소나 보기
{catalog.map((persona) => {
const name = splitPersonaName(persona.display_name);
return (
setSearchParams({
view: "personas",
persona: `approved:${persona.code}`,
})
}
>
{persona.code}
{name.head}
{name.tail ? {name.tail} : null}
{personaTheoryLabel(persona.theory_target[0] ?? "미지정")} ·{" "}
{personaDifficultyLabel(persona.difficulty)}
{personaOrigin(persona.code, persona.source) === "system"
? "시스템"
: "추가"}
v{persona.version ?? 1}
);
})}
);
const reviewQueueView = (
검수 대기
{reviews.filter((item) => item.status === "review").length}
작성 중
{reviews.filter((item) => item.status === "draft").length}
공개본
{catalog.length}
{reviewQueue.length ? (
{reviewQueue.map((persona) => {
const name = splitPersonaName(persona.display_name);
return (
{persona.code}
{personaReviewStatusLabel(persona.status)}
{name.head}
{name.tail ? {name.tail}
: null}
{reviewStatusDescription(persona.status)}
버전
v{persona.version}
요청
{formatDateTime(persona.created_at)}
출처
{persona.is_synthetic ? "작성·합성" : "근거 기반"}
setQuery({ persona: `draft:${persona.persona_id}` })
}
>
상세
openDraftEditor(persona.persona_id)}
>
수정
void decideReview(persona.persona_id, "reject")
}
disabled={busy !== null || persona.status !== "review"}
>
반려
void decideReview(persona.persona_id, "approve")
}
disabled={busy !== null || persona.status !== "review"}
>
승인
);
})}
) : (
)}
);
const personaListView = (
setQuery({ queue: "all", persona: null })}
>
전체 페르소나 {catalog.length + reviews.length}
setQuery({ queue: "review", persona: null })}
>
검수 현황 {reviewQueue.length}
{personaListTab === "review" ? (
reviewQueueView
) : (
<>
페르소나 검색
setPersonaSearch(event.target.value)}
placeholder="이름, 코드, 이론, 상태 검색"
/>
구분
setOriginFilter(event.target.value as typeof originFilter)
}
>
전체
시스템
새로 만든 페르소나
페르소나
학습 현황
구분
공개 상태
버전
작업
{personaRows.length ? (
personaRows.map((row) => {
const usage = usageByPersona.get(normalizeCode(row.code));
const name = splitPersonaName(row.name);
return (
setQuery({ persona: row.key })}
>
{row.code}
{name.head}
{name.tail ? {name.tail} : null}
{personaDifficultyLabel(row.difficulty)} ·{" "}
{row.theory.map(personaTheoryLabel).join(", ") ||
"이론 미지정"}
{usage?.learners ?? 0}명
최근 {usage?.recentSessions ?? 0}회 · 진행{" "}
{usage?.activeSessions ?? 0}
{row.origin === "system" ? "시스템" : "새로 만듦"}
{personaReviewStatusLabel(row.status)}
v{row.version}
setQuery({ persona: row.key })}
>
상세
{row.kind === "approved" ? (
<>
void reviseApprovedPersona(row.persona)
}
disabled={!row.persona.persona_id || busy !== null}
>
수정
void archiveApprovedPersona(row.persona)
}
disabled={!row.persona.persona_id || busy !== null}
>
삭제
>
) : (
openDraftEditor(row.persona.persona_id)
}
>
수정
)}
);
})
) : (
)}
>
)}
);
const detailData = selectedReview ? selectedDraftDetail : null;
const detailName = splitPersonaName(selectedRow?.name ?? "");
const personaDetailView = selectedRow ? (
}
onClick={() => setQuery({ persona: null, detailTab: null })}
>
목록으로
{selectedRow.code} · v{selectedRow.version}
{detailName.head}
{detailName.tail ? (
{detailName.tail}
) : null}
{selectedRow.kind === "approved"
? "학습자에게 공개된 현재 버전"
: reviewStatusDescription(selectedRow.status)}
{selectedApproved ? (
void reviseApprovedPersona(selectedApproved)}
disabled={!selectedApproved.persona_id || busy !== null}
>
수정
) : null}
{selectedReview ? (
openDraftEditor(selectedReview.persona_id)}
>
수정
) : null}
{(
[
{ key: "overview", label: "소개·정보" },
{ key: "learning", label: "학습 현황" },
{ key: "settings", label: "설정 내용" },
] as const
).map((tab) => (
setQuery({ detailTab: tab.key })}
>
{tab.label}
))}
{detailTab === "overview" ? (
페르소나 소개
{detailName.head}
{detailName.tail ? (
{detailName.tail}
) : null}
{selectedApproved?.presenting_summary ||
stringValue(objectValue(detailData?.presenting), "complaint") ||
"주호소 소개가 아직 작성되지 않았습니다."}
{selectedRow.theory.map((theory) => (
{personaTheoryLabel(theory)}
))}
코드
{selectedRow.code}
난이도
{personaDifficultyLabel(selectedRow.difficulty)}
구분
{selectedRow.origin === "system"
? "시스템 페르소나"
: "새로 만든 페르소나"}
공개 상태
{personaReviewStatusLabel(selectedRow.status)}
버전
v{selectedRow.version}
{detailData ? (
작성 정보
출처
{detailData.source_provenance || "미기록"}
합성 여부
{detailData.is_synthetic
? "작성·합성 초안"
: "근거 기반 초안"}
핵심 신념
{stringValue(objectValue(detailData.ccd), "core_belief") ||
"미작성"}
민감 영역
{listValue(
objectValue(detailData.triggers),
"sore_spots",
).join(", ") || "미작성"}
) : null}
) : detailTab === "learning" ? (
학습자
{selectedUsage?.learners ?? 0}
최근 학습 궤적 기준
진행 세션
{selectedUsage?.activeSessions ?? 0}
현재 진행 중
수행 점수
{percentLabel(selectedUsage?.avgScore)}
상담자 턴 평가
라포 진척
{percentLabel(
selectedUsage?.avgRapport == null
? null
: (selectedUsage.avgRapport + 1) / 2,
)}
관계 신호 환산
{selectedUsage?.points.length ? (
{selectedUsage.points
.slice(-8)
.reverse()
.map((point) => (
회기 {point.session_no} · {point.stage}
{percentLabel(point.score)}
{formatDateTime(point.ended_at ?? point.started_at)}
))}
) : (
)}
) : (
버전·공개 설정
현재 상태
{personaReviewStatusLabel(selectedRow.status)}
학습자 공개
{selectedRow.status === "approved" ? "공개" : "숨김"}
수정 방식
{selectedRow.status === "approved"
? "새 버전 초안 생성 후 재검수"
: "현재 초안에서 계속 편집"}
기존 회기
사용한 버전과 기록 유지
출처 구분
{selectedRow.origin === "system"
? "프로젝트 시스템 페르소나"
: "사용자 생성 페르소나"}
)}
) : (
);
const workspaceView = (
{/* eyebrow "페르소나"는 아래 h1(페르소나 운영/카탈로그/관리/상세)과 같은 말이라 제거 */}
{detailKey
? "페르소나 상세"
: workspaceTab === "dashboard"
? "페르소나 운영"
: workspaceTab === "catalog"
? "페르소나 카탈로그"
: "페르소나 관리"}
운영 현황부터 공개 카탈로그, 저작과 검수까지 한 흐름으로
관리합니다.
}
onClick={() => void loadStudio()}
disabled={loadState === "loading" || busy !== null}
>
{loadState === "loading" ? "확인 중" : "새로고침"}
{workspaceTab === "personas" && !detailKey ? (
}
onClick={openCreate}
>
새로운 페르소나 만들기
) : null}
{WORKSPACE_TABS.map((tab) => (
setSearchParams({ view: tab.key })}
>
{tab.label}
{tab.desc}
))}
{loadError ? (
) : null}
{formError ? (
{formError}
) : message ? (
{message}
) : null}
{detailKey
? personaDetailView
: workspaceTab === "dashboard"
? dashboardView
: workspaceTab === "catalog"
? catalogView
: personaListView}
);
const draftName = splitPersonaName(draft.display_name.trim());
const legacyAuthoring = (
{editingId ? "페르소나 수정" : "새로운 페르소나 만들기"}
{draftName.head || "새 페르소나"}
{draftName.tail ? (
{draftName.tail}
) : null}
단계별로 작성하고 언제든 이전 단계로 돌아가 수정할 수 있습니다.
}
onClick={() => void loadStudio()}
disabled={loadState === "loading" || busy !== null}
>
{loadState === "loading" ? "확인 중" : "새로고침"}
}
onClick={() => setSearchParams({ view: "personas" })}
disabled={busy !== null}
>
목록으로
{WORKSPACE_TABS.map((tab) => (
setSearchParams({ view: tab.key })}
>
{tab.label}
{tab.desc}
))}
{AUTHORING_STEPS.map((step, index) => (
setQuery({ step: step.key })}
>
{index + 1}
{step.label}
{step.desc}
))}
{loadError ? (
) : null}
{editingId ? "초안 편집" : "새 초안"}
{draftName.head || "이름 미정"}
{draftName.tail ? (
{draftName.tail}
) : null}
{completenessLabel(validation)}
{/* eyebrow "RAG 근거로 초안 만들기"는 아래 문장과 같은 말이라 제거하고,
가운뎃점 2개(첨부 파일·실제 기록·교재 가이드)는 쉼표로 풀었다. */}
첨부 파일, 실제 기록, 교재 가이드로 초안 만들기
0 ? "warn" : "neutral"}>
{sourceReferences.length > 0
? `${sourceReferences.length}개 근거`
: maskedEntities.length > 0
? `${maskedEntities.length}개 마스킹`
: "자료 대기"}
event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
void loadSourceFile(event.dataTransfer.files?.[0] ?? null);
}}
>
{
void loadSourceFile(
event.currentTarget.files?.[0] ?? null,
);
event.currentTarget.value = "";
}}
/>
SSOT 첨부
{sourceFilename || "파일을 드래그하거나 선택"}
txt, md, csv, json, html, xml에 더해 자유 양식
엑셀(xlsx)도 지원합니다. 엑셀은 서버에서 표를 텍스트로
변환해 등록하고 원본 파일은 남기지 않습니다.
}
onClick={() => sourceFileInputRef.current?.click()}
disabled={busy !== null}
>
파일 선택
자료 종류
setSourceKind(event.target.value as SourceKind)
}
>
내담자 실제 기록
교과서·가이드
혼합 메모
{generationSummary ? (
{generationSummary}
) : (
서버가 PII를 마스킹하고 KB 문서로 등록한 뒤, 검색된 근거
청크만 생성기에 보냅니다.
)}
}
onClick={() => void generateFromSource()}
disabled={busy !== null}
>
{busy === "generate" ? "생성 중" : "KB 등록 후 생성"}
{STUDIO_TABS.map((tab) => (
setActiveTab(tab.key)}
role="tab"
aria-selected={activeTab === tab.key}
>
{tab.label}
))}
{renderTab()}
{formError ? (
{formError}
) : message ? (
{message}
) : null}
}
onClick={() =>
previousAuthoringStep &&
setQuery({ step: previousAuthoringStep })
}
disabled={!previousAuthoringStep || busy !== null}
>
이전
}
onClick={() => void saveProgress()}
disabled={busy !== null}
>
{busy === "save" ? "저장 중" : "중간 저장"}
{nextAuthoringStep ? (
setQuery({ step: nextAuthoringStep })}>
다음 단계
) : (
}
onClick={() => void saveDraft(true)}
disabled={busy !== null || validation.errors.length > 0}
>
{busy === "submit" ? "요청 중" : "검수 요청"}
)}
검증
{validation.errors.length === 0 &&
validation.warnings.length === 0 ? (
필수 항목 입력 완료
) : null}
{validation.errors.map((item) => (
{item}
))}
{validation.warnings.map((item) => (
{item}
))}
생성 근거
{generationSummary ? (
{generationSummary}
) : (
자료 기반 생성 결과가 아직 없습니다.
)}
{maskedEntities.map((item) => (
{item} 마스킹됨
))}
{generationWarnings.map((item) => (
{item}
))}
{sourceReferences.map((item) => (
{item.title}
{/* 한 줄에 가운뎃점 3개가 몰려 읽기 어려워 두 줄로 나눴다(줄당 1개). */}
{item.source_id} · doc {item.doc_id ?? "-"}
{item.chunk_count} chunks ·{" "}
{item.content_hash.slice(0, 10)}
))}
{generationEvidence.slice(0, 3).map((item) => (
chunk {item.chunk_id} · {item.score.toFixed(3)}
{item.heading_path ? `${item.heading_path} · ` : ""}
{item.excerpt}
))}
검수 큐
{reviews.length > 0 ? (
{reviews.map((persona) => (
{persona.code} v{persona.version}
{personaReviewStatusLabel(persona.status)}
{formatDateTime(persona.created_at)}
void loadDraft(persona.persona_id)}
disabled={busy !== null}
>
편집
void decideReview(persona.persona_id, "reject")
}
disabled={busy !== null}
>
반려
void decideReview(persona.persona_id, "approve")
}
disabled={busy !== null}
>
승인
))}
) : (
)}
);
return isAuthoring ? legacyAuthoring : workspaceView;
}