vignette/apps/web/src/pages/PersonaStudio.tsx

2993 lines
102 KiB
TypeScript

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 className="ps-field">
<span>{label}</span>
<input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
/>
</label>
);
}
function TextAreaField({
label,
value,
onChange,
rows = 4,
placeholder,
}: {
label: string;
value: string;
onChange: (value: string) => void;
rows?: number;
placeholder?: string;
}) {
return (
<label className="ps-field ps-field--wide">
<span>{label}</span>
<textarea
value={value}
onChange={(event) => onChange(event.target.value)}
rows={rows}
placeholder={placeholder}
/>
</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 (
<div className="ps-list-field ps-field--wide">
<div className="ps-list-field__head">
<span>{label}</span>
<Button
variant="ghost"
size="sm"
leading={<Icon name="plus" size={15} />}
onClick={() => onChange([...items, ""])}
>
</Button>
</div>
<div className="ps-list-field__rows">
{items.map((item, index) => (
<div className="ps-list-item" key={`${label}-${index}`}>
<textarea
aria-label={`${label} ${index + 1}`}
value={item}
onChange={(event) => updateItem(index, event.target.value)}
rows={rows}
placeholder={index === 0 ? placeholder : undefined}
/>
<button
type="button"
className="ps-list-item__remove"
onClick={() => removeItem(index)}
aria-label={`${label} ${index + 1} 삭제`}
title="삭제"
>
<Icon name="x" size={16} />
</button>
</div>
))}
</div>
</div>
);
}
function PromptPreviewPanel({
sections,
}: {
sections: PromptPreviewSection[];
}) {
return (
<div className="ps-prompt" aria-label="프롬프트 미리보기">
{sections.map((section) => (
<section
className={surfaceClassName("ps-prompt-section", {
variant: "inset",
flat: true,
})}
key={section.title}
>
<div className="ps-prompt-section__head">
<h3>{section.title}</h3>
{section.desc ? <p>{section.desc}</p> : null}
</div>
{section.rows?.length ? (
<dl className="ps-prompt-rows">
{section.rows.map((row) => (
<div
className="ps-prompt-row"
key={`${section.title}-${row.label}`}
>
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
))}
</dl>
) : null}
{section.items?.length ? (
<ul className="ps-prompt-list">
{section.items.map((item, index) => (
<li key={`${section.title}-${index}`}>{item}</li>
))}
</ul>
) : null}
{section.body ? (
<p className="ps-prompt-body">{section.body}</p>
) : null}
{!section.rows?.length && !section.items?.length && !section.body ? (
<p className="ps-prompt-empty"> </p>
) : null}
</section>
))}
</div>
);
}
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 className="ps-numfield">
<span>{label}</span>
<input
type="number"
min={min}
max={max}
step={step}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
/>
</label>
);
}
function EmptyState({ title, desc }: { title: string; desc: string }) {
return <UiEmptyState className="ps-empty" title={title} description={desc} />;
}
// 페르소나 스튜디오는 Kicker 가 21곳이라 저작 화면 한 폭에 accent dot 이 8개까지 몰렸다.
// 이 화면의 Kicker 는 전부 "섹션 제목 장식"일 뿐 현재 상태(정상/주의/라이브)를 전달하지 않으므로
// dot={false} 로 통일한다. 실제 상태 신호는 그대로 <Dot tone="..."> 컴포넌트가 담당한다.
function GuidancePanel({ tab }: { tab: StudioTab }) {
const guidance = TAB_GUIDANCE[tab];
return (
<div className="ps-guidance">
<div>
<Kicker dot={false}> </Kicker>
<b>{guidance.title}</b>
<p>{guidance.desc}</p>
</div>
<ul>
{guidance.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
}
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<HTMLInputElement | null>(null);
const [catalog, setCatalog] = useState<PersonaSummary[]>([]);
const [reviews, setReviews] = useState<PersonaReviewSummary[]>([]);
const [teacherDashboard, setTeacherDashboard] =
useState<TeacherDashboardResponse | null>(null);
const [dashboardError, setDashboardError] = useState<string | null>(null);
const [loadState, setLoadState] = useState<LoadState>("loading");
const [loadError, setLoadError] = useState<string | null>(null);
const [draft, setDraft] = useState<PersonaStudioPayload>(() =>
cloneEmptyDraft(),
);
const [sourceText, setSourceText] = useState("");
const [sourceFilename, setSourceFilename] = useState("");
const [sourceKind, setSourceKind] = useState<SourceKind>("client_record");
const [sourceReferences, setSourceReferences] = useState<
PersonaSourceDocumentResponse[]
>([]);
const [generationEvidence, setGenerationEvidence] = useState<
PersonaGenerationEvidence[]
>([]);
const [generationGoal, setGenerationGoal] = useState("");
const [generationSummary, setGenerationSummary] = useState("");
const [generationWarnings, setGenerationWarnings] = useState<string[]>([]);
const [maskedEntities, setMaskedEntities] = useState<string[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<StudioTab>("overview");
const [busy, setBusy] = useState<
"load" | "save" | "submit" | "decide" | "generate" | "archive" | null
>(null);
const [message, setMessage] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [autoLoadedDraftId, setAutoLoadedDraftId] = useState<string | null>(
null,
);
const [personaSearch, setPersonaSearch] = useState("");
const [originFilter, setOriginFilter] = useState<"all" | "system" | "custom">(
"all",
);
const [selectedDraftDetail, setSelectedDraftDetail] =
useState<PersonaDraftDetail | null>(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<string, string | null>) => {
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<TeacherGrowthPoint & { learner_id: string }>;
}
>();
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 extends keyof PersonaStudioPayload>(
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 (
<div className="ps-form-grid">
<TextField
label="코드"
value={draft.code}
onChange={(value) => updateRoot("code", normalizeCode(value))}
placeholder={suggestedCode}
/>
<TextField
label="표시 이름"
value={draft.display_name}
onChange={(value) => updateRoot("display_name", value)}
/>
<label className="ps-field">
<span></span>
<select
value={draft.difficulty}
onChange={(event) =>
updateRoot("difficulty", event.target.value as Difficulty)
}
>
<option value="easy">easy</option>
<option value="moderate">moderate</option>
<option value="hard">hard</option>
</select>
</label>
<label className="ps-field">
<span> </span>
<select
value={draft.is_synthetic ? "true" : "false"}
onChange={(event) =>
updateRoot("is_synthetic", event.target.value === "true")
}
>
<option value="true">synthetic</option>
<option value="false">clinical source</option>
</select>
</label>
<div className="ps-field ps-field--wide">
<span> </span>
<div className="ps-segments">
{["humanistic", "cbt", "integrative"].map((value) => (
<button
type="button"
key={value}
className={
draft.theory_target.includes(value) ? "is-active" : ""
}
onClick={() => toggleTheory(value)}
>
{value}
</button>
))}
</div>
</div>
<TextField
label="연령대"
value={stringValue(draft.demographics, "age_band")}
onChange={(value) =>
updateObject("demographics", "age_band", value)
}
/>
<TextField
label="성별/젠더"
value={stringValue(draft.demographics, "sex")}
onChange={(value) => updateObject("demographics", "sex", value)}
/>
<TextField
label="역할/학년"
value={stringValue(draft.demographics, "role")}
onChange={(value) => updateObject("demographics", "role", value)}
/>
<TextField
label="맥락"
value={stringValue(draft.demographics, "context")}
onChange={(value) => updateObject("demographics", "context", value)}
/>
<TextAreaField
label="주호소"
value={stringValue(draft.presenting, "complaint")}
onChange={(value) => updateObject("presenting", "complaint", value)}
/>
<TextAreaField
label="첫 회기 입장 발화"
value={stringValue(draft.presenting, "first_session_opening")}
onChange={(value) =>
updateObject("presenting", "first_session_opening", value)
}
/>
<TextField
label="출처"
value={draft.source_provenance}
onChange={(value) => updateRoot("source_provenance", value)}
/>
</div>
);
}
if (activeTab === "clinical") {
return (
<div className="ps-form-grid">
{[
{
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 }) => (
<TextAreaField
key={key}
label={label}
value={stringValue(draft.history, key)}
onChange={(value) => updateObject("history", key, value)}
placeholder={placeholder}
/>
))}
<TextAreaField
label="핵심신념"
value={stringValue(draft.ccd, "core_belief")}
onChange={(value) => updateObject("ccd", "core_belief", value)}
placeholder="예: 나는 충분히 잘하지 못하면 쉽게 버려질 수 있다."
/>
<TextAreaField
label="중간신념"
value={stringValue(draft.ccd, "intermediate_belief")}
onChange={(value) =>
updateObject("ccd", "intermediate_belief", value)
}
placeholder="예: 실수하면 먼저 설명하기보다 조용히 지나가야 한다."
/>
<ListField
label="자동사고"
value={editableListValue(draft.ccd, "automatic_thought")}
onChange={(value) =>
updateObject("ccd", "automatic_thought", value)
}
placeholder="예: 또 실망시켰을 거야."
/>
<TextAreaField
label="대처전략"
value={stringValue(draft.ccd, "coping_strategy")}
onChange={(value) => updateObject("ccd", "coping_strategy", value)}
placeholder="예: 괜찮다고 말하며 주제를 바꾸고, 구체 질문에는 짧게 대답함"
/>
<TextAreaField
label="보상전략"
value={stringValue(draft.ccd, "compensatory")}
onChange={(value) => updateObject("ccd", "compensatory", value)}
placeholder="예: 상담자에게 인정받으려 모범 답안을 말하려 함"
/>
<TextAreaField
label="증상 차원 메모"
value={stringValue(draft.dsm5_dimensional, "note")}
onChange={(value) =>
updateObject("dsm5_dimensional", "note", value)
}
placeholder="예: 불안은 평가 상황에서 높고, 수면 저하는 시험 기간에 악화됨"
/>
</div>
);
}
if (activeTab === "resistance") {
return (
<div className="ps-number-grid">
{[
["base_resistance", "초기 저항"],
["unlock_rate", "라포 unlock"],
["decay_floor", "저항 하한"],
["silence_prob", "침묵 확률"],
["deflection_prob", "회피 확률"],
].map(([key, label]) => (
<NumberField
key={key}
label={label}
value={numberValue(draft.resistance, key, 0)}
onChange={(value) => updateNumber("resistance", key, value)}
/>
))}
{[
["O", "개방성 O"],
["C", "성실성 C"],
["E", "외향성 E"],
["A", "우호성 A"],
["N", "신경성 N"],
].map(([key, label]) => (
<NumberField
key={key}
label={label}
value={numberValue(draft.big5, key, 0.5)}
onChange={(value) => updateNumber("big5", key, value)}
/>
))}
{[
["negative_affect", "부정 정서"],
["hopelessness", "무망감"],
["anhedonia", "무쾌감"],
["sleep", "수면"],
["anxiety", "불안"],
].map(([key, label]) => (
<NumberField
key={key}
label={label}
value={numberValue(draft.affect_baseline, key, 0)}
onChange={(value) => updateNumber("affect_baseline", key, value)}
/>
))}
<NumberField
label="자살사고 단계"
value={numberValue(
draft.affect_baseline,
"suicide_ideation_stage",
1,
)}
min={1}
max={5}
step={1}
onChange={(value) =>
updateNumber("affect_baseline", "suicide_ideation_stage", value)
}
/>
</div>
);
}
if (activeTab === "scenario") {
return (
<div className="ps-form-grid">
<ListField
label="회기별 시나리오"
value={editableListValue(draft.triggers, "session_scenarios")}
onChange={(value) =>
updateObject("triggers", "session_scenarios", value)
}
rows={3}
placeholder="1회기: 상담실 입장 때 예의 바르지만 짧게 답하고, 평가 이야기는 피함"
/>
<TextAreaField
label="표면화 조건"
value={stringValue(draft.presenting, "surface")}
onChange={(value) => updateObject("presenting", "surface", value)}
rows={8}
placeholder="예: 상담자가 해결책을 서두르지 않고 감정과 맥락을 함께 반영할 때 주호소가 구체화됨"
/>
<TextAreaField
label="추가 프롬프트 계약"
value={stringValue(draft.triggers, "prompt_contract")}
onChange={(value) =>
updateObject("triggers", "prompt_contract", value)
}
rows={8}
placeholder="예: 내담자는 CCD를 직접 설명하지 않는다. 상담자가 성급히 조언하면 방어적으로 짧게 답한다."
/>
</div>
);
}
if (activeTab === "speech") {
return (
<div className="ps-form-grid">
<TextField
label="어조"
value={stringValue(draft.speech_style, "register")}
onChange={(value) =>
updateObject("speech_style", "register", value)
}
/>
<TextField
label="문장 길이"
value={stringValue(draft.speech_style, "avg_sentence_len")}
onChange={(value) =>
updateObject("speech_style", "avg_sentence_len", value)
}
/>
<label className="ps-field">
<span></span>
<select
value={String(Boolean(draft.speech_style.honorific))}
onChange={(event) =>
updateObject(
"speech_style",
"honorific",
event.target.value === "true",
)
}
>
<option value="true"></option>
<option value="false"></option>
</select>
</label>
<ListField
label="군말"
value={editableListValue(draft.speech_style, "fillers")}
onChange={(value) => updateObject("speech_style", "fillers", value)}
rows={1}
placeholder="예: 음..."
/>
<ListField
label="말버릇"
value={editableListValue(draft.speech_style, "verbal_tics")}
onChange={(value) =>
updateObject("speech_style", "verbal_tics", value)
}
rows={1}
placeholder="예: 괜찮아요"
/>
<ListField
label="비언어 단서"
value={editableListValue(draft.speech_style, "nonverbal_cues")}
onChange={(value) =>
updateObject("speech_style", "nonverbal_cues", value)
}
rows={1}
placeholder="예: 손끝을 만지작거림"
/>
</div>
);
}
if (activeTab === "safety") {
return (
<div className="ps-form-grid">
<ListField
label="역린 민감 영역"
value={editableListValue(draft.triggers, "sore_spots")}
onChange={(value) => updateObject("triggers", "sore_spots", value)}
rows={2}
placeholder="예: 무책임하다는 평가"
/>
<ListField
label="상담자 금기"
value={editableListValue(draft.triggers, "forbidden")}
onChange={(value) => updateObject("triggers", "forbidden", value)}
rows={2}
placeholder="예: 네가 예민한 거라고 단정"
/>
<TextAreaField
label="역린 반응"
value={stringValue(draft.triggers, "reaction")}
onChange={(value) => updateObject("triggers", "reaction", value)}
rows={7}
/>
<ListField
label="내담자 금기어"
value={editableListValue(draft.triggers, "taboo_terms")}
onChange={(value) => updateObject("triggers", "taboo_terms", value)}
rows={1}
placeholder="예: 패배자"
/>
</div>
);
}
return <PromptPreviewPanel sections={promptPreviewSections} />;
};
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 = (
<section className="ps-overview" aria-label="페르소나 운영 대시보드">
{dashboardError ? (
<div className="ps-inline-note is-warn" role="status">
{dashboardError}
</div>
) : null}
<div className="ps-kpi-grid">
{[
{
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) => (
<article className="ps-kpi" key={item.label}>
<span>{item.label}</span>
<b>{item.value}</b>
<small>{item.meta}</small>
</article>
))}
</div>
<div className="ps-dashboard-grid">
<article className="ps-performance">
<div className="ps-section-head">
<div>
<Kicker dot={false}> </Kicker>
<h2> </h2>
</div>
<span> {dashboardPoints.length}</span>
</div>
<div className="ps-performance__metrics">
<div>
<span> </span>
<b>{percentLabel(dashboardScore)}</b>
<i>
<span
style={{
width: `${Math.max(0, (dashboardScore ?? 0) * 100)}%`,
}}
/>
</i>
</div>
<div>
<span> </span>
<b>
{percentLabel(
dashboardRapport == null ? null : (dashboardRapport + 1) / 2,
)}
</b>
<i>
<span
style={{
width: `${Math.max(0, ((dashboardRapport ?? -1) + 1) * 50)}%`,
}}
/>
</i>
</div>
</div>
<p>
.
.
</p>
</article>
<article className="ps-review-summary">
<div className="ps-section-head">
<div>
<Kicker dot={false}> </Kicker>
<h2> </h2>
</div>
<Button
size="sm"
variant="secondary"
onClick={() =>
setSearchParams({ view: "personas", queue: "review" })
}
>
</Button>
</div>
<div className="ps-review-summary__grid">
<div>
<span> </span>
<b>{reviews.filter((item) => item.status === "review").length}</b>
<small>· </small>
</div>
<div>
<span> </span>
<b>{reviews.filter((item) => item.status === "draft").length}</b>
<small> </small>
</div>
<div>
<span></span>
<b>{catalog.length}</b>
<small> </small>
</div>
</div>
</article>
</div>
<section className="ps-active-table">
<div className="ps-section-head">
<div>
<Kicker dot={false}> </Kicker>
<h2> </h2>
</div>
<span> </span>
</div>
<div
className="ps-usage-table"
role="table"
aria-label="페르소나별 학습 현황"
>
<div className="ps-usage-table__head" role="row">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{catalog.length ? (
catalog.slice(0, 8).map((persona) => {
const usage = usageByPersona.get(normalizeCode(persona.code));
const name = splitPersonaName(persona.display_name);
return (
<button
type="button"
className="ps-usage-row"
key={persona.code}
onClick={() =>
setSearchParams({
view: "personas",
persona: `approved:${persona.code}`,
})
}
>
<span>
<b>{persona.code}</b>
<small>{name.head}</small>
{name.tail ? <small>{name.tail}</small> : null}
</span>
<strong>{usage?.learners ?? 0}</strong>
<strong>{usage?.activeSessions ?? 0}</strong>
<strong>{percentLabel(usage?.avgScore)}</strong>
<strong>
{percentLabel(
usage?.avgRapport == null
? null
: (usage.avgRapport + 1) / 2,
)}
</strong>
</button>
);
})
) : (
<EmptyState
title="공개 페르소나 없음"
desc="승인된 페르소나가 생기면 운영 현황을 표시합니다."
/>
)}
</div>
</section>
</section>
);
const catalogTheoryCounts = catalog.reduce<Record<string, number>>(
(result, persona) => {
for (const theory of persona.theory_target)
result[theory] = (result[theory] ?? 0) + 1;
return result;
},
{},
);
const catalogView = (
<section className="ps-catalog-page" aria-label="페르소나 카탈로그 정보">
<article className="ps-catalog-hero">
<div>
<Kicker dot={false}></Kicker>
<h2> .</h2>
<p>
, , ,
.
</p>
</div>
<div className="ps-catalog-hero__count">
<span> </span>
<b>{catalog.length}</b>
<small>
{catalogSystemCount} · {" "}
{catalog.length - catalogSystemCount}
</small>
</div>
</article>
<div className="ps-catalog-grid">
<section className="ps-catalog-block">
<div className="ps-section-head">
<div>
<Kicker dot={false}> </Kicker>
<h2> </h2>
</div>
<Badge tone="accent"> </Badge>
</div>
<dl className="ps-settings-list">
<div>
<dt> </dt>
<dd> </dd>
</div>
<div>
<dt>·</dt>
<dd> </dd>
</div>
<div>
<dt> </dt>
<dd> </dd>
</div>
<div>
<dt> </dt>
<dd> </dd>
</div>
<div>
<dt> </dt>
<dd> </dd>
</div>
</dl>
</section>
<section className="ps-catalog-block">
<div className="ps-section-head">
<div>
{/* eyebrow "구성"은 바로 아래 "이론 분포"와 같은 말이라 제거 */}
<h2> </h2>
</div>
</div>
<div className="ps-distribution">
{Object.entries(catalogTheoryCounts).length ? (
Object.entries(catalogTheoryCounts).map(([theory, count]) => (
<div key={theory}>
<span>{personaTheoryLabel(theory)}</span>
<b>{count}</b>
<i>
<span
style={{
width: `${(count / Math.max(1, catalog.length)) * 100}%`,
}}
/>
</i>
</div>
))
) : (
<EmptyState
title="분포 자료 없음"
desc="공개 페르소나가 생기면 이론 구성을 표시합니다."
/>
)}
</div>
</section>
</div>
<section className="ps-catalog-block ps-catalog-block--wide">
<div className="ps-section-head">
<div>
<Kicker dot={false}> </Kicker>
<h2> </h2>
</div>
<Button
size="sm"
variant="secondary"
onClick={() => setSearchParams({ view: "personas" })}
>
</Button>
</div>
<div className="ps-catalog-list">
{catalog.map((persona) => {
const name = splitPersonaName(persona.display_name);
return (
<button
type="button"
key={persona.code}
onClick={() =>
setSearchParams({
view: "personas",
persona: `approved:${persona.code}`,
})
}
>
<span className="ps-code-box">{persona.code}</span>
<span>
<b>{name.head}</b>
{name.tail ? <small>{name.tail}</small> : null}
<small>
{personaTheoryLabel(persona.theory_target[0] ?? "미지정")} ·{" "}
{personaDifficultyLabel(persona.difficulty)}
</small>
</span>
<span>
{personaOrigin(persona.code, persona.source) === "system"
? "시스템"
: "추가"}
</span>
<strong>v{persona.version ?? 1}</strong>
<Icon name="chevron-right" size={16} />
</button>
);
})}
</div>
</section>
</section>
);
const reviewQueueView = (
<section className="ps-review-page" aria-label="페르소나 검수 현황">
<div className="ps-review-page__summary">
<div>
<span> </span>
<b>{reviews.filter((item) => item.status === "review").length}</b>
</div>
<div>
<span> </span>
<b>{reviews.filter((item) => item.status === "draft").length}</b>
</div>
<div>
<span></span>
<b>{catalog.length}</b>
</div>
</div>
{reviewQueue.length ? (
<div className="ps-review-board">
{reviewQueue.map((persona) => {
const name = splitPersonaName(persona.display_name);
return (
<article key={persona.persona_id} className="ps-review-card">
<div className="ps-review-card__top">
<span className="ps-code-box">{persona.code}</span>
<Badge tone={personaReviewTone(persona.status)}>
{personaReviewStatusLabel(persona.status)}
</Badge>
</div>
<h3>{name.head}</h3>
{name.tail ? <p className="ps-name-tail">{name.tail}</p> : null}
<p>{reviewStatusDescription(persona.status)}</p>
<dl>
<div>
<dt></dt>
<dd>v{persona.version}</dd>
</div>
<div>
<dt></dt>
<dd>{formatDateTime(persona.created_at)}</dd>
</div>
<div>
<dt></dt>
<dd>{persona.is_synthetic ? "작성·합성" : "근거 기반"}</dd>
</div>
</dl>
<div className="ps-review-card__actions">
<Button
size="sm"
variant="secondary"
onClick={() =>
setQuery({ persona: `draft:${persona.persona_id}` })
}
>
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => openDraftEditor(persona.persona_id)}
>
</Button>
<Button
size="sm"
variant="secondary"
onClick={() =>
void decideReview(persona.persona_id, "reject")
}
disabled={busy !== null || persona.status !== "review"}
>
</Button>
<Button
size="sm"
onClick={() =>
void decideReview(persona.persona_id, "approve")
}
disabled={busy !== null || persona.status !== "review"}
>
</Button>
</div>
</article>
);
})}
</div>
) : (
<EmptyState
title="검수 대기 없음"
desc="새 검수 요청이나 작성 중 초안이 생기면 여기에 표시됩니다."
/>
)}
</section>
);
const personaListView = (
<section className="ps-personas-page" aria-label="전체 페르소나">
<div
className="ps-subtabs"
role="tablist"
aria-label="페르소나 목록 영역"
>
<button
type="button"
role="tab"
aria-selected={personaListTab === "all"}
className={personaListTab === "all" ? "is-active" : ""}
onClick={() => setQuery({ queue: "all", persona: null })}
>
<b>{catalog.length + reviews.length}</b>
</button>
<button
type="button"
role="tab"
aria-selected={personaListTab === "review"}
className={personaListTab === "review" ? "is-active" : ""}
onClick={() => setQuery({ queue: "review", persona: null })}
>
<b>{reviewQueue.length}</b>
</button>
</div>
{personaListTab === "review" ? (
reviewQueueView
) : (
<>
<div className="ps-table-tools">
<label>
<span> </span>
<input
value={personaSearch}
onChange={(event) => setPersonaSearch(event.target.value)}
placeholder="이름, 코드, 이론, 상태 검색"
/>
</label>
<label>
<span></span>
<select
value={originFilter}
onChange={(event) =>
setOriginFilter(event.target.value as typeof originFilter)
}
>
<option value="all"></option>
<option value="system"></option>
<option value="custom"> </option>
</select>
</label>
</div>
<div
className="ps-persona-table"
role="table"
aria-label="지원 페르소나 목록"
>
<div className="ps-persona-table__head" role="row">
<span></span>
<span> </span>
<span></span>
<span> </span>
<span></span>
<span></span>
</div>
{personaRows.length ? (
personaRows.map((row) => {
const usage = usageByPersona.get(normalizeCode(row.code));
const name = splitPersonaName(row.name);
return (
<div
className="ps-persona-row"
role="row"
key={row.key}
data-approved-persona-row={
row.kind === "approved" ? "true" : undefined
}
>
<button
type="button"
className="ps-persona-row__identity"
onClick={() => setQuery({ persona: row.key })}
>
<span className="ps-code-box">{row.code}</span>
<span>
<b>{name.head}</b>
{name.tail ? <small>{name.tail}</small> : null}
<small>
{personaDifficultyLabel(row.difficulty)} ·{" "}
{row.theory.map(personaTheoryLabel).join(", ") ||
"이론 미지정"}
</small>
</span>
</button>
<span className="ps-persona-row__usage">
<b>{usage?.learners ?? 0}</b>
<small>
{usage?.recentSessions ?? 0} · {" "}
{usage?.activeSessions ?? 0}
</small>
</span>
<span>
<Badge tone="neutral">
{row.origin === "system" ? "시스템" : "새로 만듦"}
</Badge>
</span>
<span>
<Badge tone={personaReviewTone(row.status)}>
{personaReviewStatusLabel(row.status)}
</Badge>
</span>
<span className="ps-version">v{row.version}</span>
<span className="ps-persona-row__actions">
<Button
size="sm"
variant="secondary"
onClick={() => setQuery({ persona: row.key })}
>
</Button>
{row.kind === "approved" ? (
<>
<Button
size="sm"
variant="secondary"
onClick={() =>
void reviseApprovedPersona(row.persona)
}
disabled={!row.persona.persona_id || busy !== null}
>
</Button>
<Button
size="sm"
variant="danger"
onClick={() =>
void archiveApprovedPersona(row.persona)
}
disabled={!row.persona.persona_id || busy !== null}
>
</Button>
</>
) : (
<Button
size="sm"
variant="secondary"
onClick={() =>
openDraftEditor(row.persona.persona_id)
}
>
</Button>
)}
</span>
</div>
);
})
) : (
<EmptyState
title="조건에 맞는 페르소나 없음"
desc="검색어나 구분 필터를 바꿔 보세요."
/>
)}
</div>
</>
)}
</section>
);
const detailData = selectedReview ? selectedDraftDetail : null;
const detailName = splitPersonaName(selectedRow?.name ?? "");
const personaDetailView = selectedRow ? (
<section className="ps-detail-page" aria-label={`${selectedRow.name} 상세`}>
<div className="ps-drill-head">
<Button
variant="secondary"
size="sm"
leading={<Icon name="chevron-left" size={15} />}
onClick={() => setQuery({ persona: null, detailTab: null })}
>
</Button>
<div>
<Kicker dot={false}>
{selectedRow.code} · v{selectedRow.version}
</Kicker>
<h2>{detailName.head}</h2>
{detailName.tail ? (
<p className="ps-name-tail">{detailName.tail}</p>
) : null}
<p>
{selectedRow.kind === "approved"
? "학습자에게 공개된 현재 버전"
: reviewStatusDescription(selectedRow.status)}
</p>
</div>
<div className="ps-drill-head__actions">
{selectedApproved ? (
<Button
variant="secondary"
onClick={() => void reviseApprovedPersona(selectedApproved)}
disabled={!selectedApproved.persona_id || busy !== null}
>
</Button>
) : null}
{selectedReview ? (
<Button
variant="secondary"
onClick={() => openDraftEditor(selectedReview.persona_id)}
>
</Button>
) : null}
</div>
</div>
<div
className="ps-subtabs"
role="tablist"
aria-label="페르소나 상세 영역"
>
{(
[
{ key: "overview", label: "소개·정보" },
{ key: "learning", label: "학습 현황" },
{ key: "settings", label: "설정 내용" },
] as const
).map((tab) => (
<button
type="button"
role="tab"
aria-selected={detailTab === tab.key}
className={detailTab === tab.key ? "is-active" : ""}
key={tab.key}
onClick={() => setQuery({ detailTab: tab.key })}
>
{tab.label}
</button>
))}
</div>
{detailTab === "overview" ? (
<div className="ps-detail-grid">
<article className="ps-detail-primary">
<Kicker dot={false}> </Kicker>
<h3>{detailName.head}</h3>
{detailName.tail ? (
<p className="ps-name-tail">{detailName.tail}</p>
) : null}
<p>
{selectedApproved?.presenting_summary ||
stringValue(objectValue(detailData?.presenting), "complaint") ||
"주호소 소개가 아직 작성되지 않았습니다."}
</p>
<div className="ps-chip-row">
{selectedRow.theory.map((theory) => (
<span key={theory}>{personaTheoryLabel(theory)}</span>
))}
</div>
</article>
<article className="ps-detail-facts">
<dl>
<div>
<dt></dt>
<dd>{selectedRow.code}</dd>
</div>
<div>
<dt></dt>
<dd>{personaDifficultyLabel(selectedRow.difficulty)}</dd>
</div>
<div>
<dt></dt>
<dd>
{selectedRow.origin === "system"
? "시스템 페르소나"
: "새로 만든 페르소나"}
</dd>
</div>
<div>
<dt> </dt>
<dd>{personaReviewStatusLabel(selectedRow.status)}</dd>
</div>
<div>
<dt></dt>
<dd>v{selectedRow.version}</dd>
</div>
</dl>
</article>
{detailData ? (
<article className="ps-detail-wide">
<Kicker dot={false}> </Kicker>
<dl className="ps-settings-list">
<div>
<dt></dt>
<dd>{detailData.source_provenance || "미기록"}</dd>
</div>
<div>
<dt> </dt>
<dd>
{detailData.is_synthetic
? "작성·합성 초안"
: "근거 기반 초안"}
</dd>
</div>
<div>
<dt> </dt>
<dd>
{stringValue(objectValue(detailData.ccd), "core_belief") ||
"미작성"}
</dd>
</div>
<div>
<dt> </dt>
<dd>
{listValue(
objectValue(detailData.triggers),
"sore_spots",
).join(", ") || "미작성"}
</dd>
</div>
</dl>
</article>
) : null}
</div>
) : detailTab === "learning" ? (
<div className="ps-learning-detail">
<div className="ps-kpi-grid ps-kpi-grid--detail">
<article className="ps-kpi">
<span></span>
<b>{selectedUsage?.learners ?? 0}</b>
<small> </small>
</article>
<article className="ps-kpi">
<span> </span>
<b>{selectedUsage?.activeSessions ?? 0}</b>
<small> </small>
</article>
<article className="ps-kpi">
<span> </span>
<b>{percentLabel(selectedUsage?.avgScore)}</b>
<small> </small>
</article>
<article className="ps-kpi">
<span> </span>
<b>
{percentLabel(
selectedUsage?.avgRapport == null
? null
: (selectedUsage.avgRapport + 1) / 2,
)}
</b>
<small> </small>
</article>
</div>
<section className="ps-detail-wide">
<div className="ps-section-head">
<div>
<Kicker dot={false}> </Kicker>
<h2> </h2>
</div>
</div>
{selectedUsage?.points.length ? (
<div className="ps-point-list">
{selectedUsage.points
.slice(-8)
.reverse()
.map((point) => (
<div key={`${point.learner_id}:${point.session_id}`}>
<span>
{point.session_no} · {point.stage}
</span>
<b>{percentLabel(point.score)}</b>
<small>
{formatDateTime(point.ended_at ?? point.started_at)}
</small>
</div>
))}
</div>
) : (
<EmptyState
title="학습 기록 없음"
desc="이 페르소나로 평가된 회기가 생기면 궤적을 표시합니다."
/>
)}
</section>
</div>
) : (
<div className="ps-detail-grid">
<article className="ps-detail-wide">
<Kicker dot={false}>· </Kicker>
<dl className="ps-settings-list">
<div>
<dt> </dt>
<dd>{personaReviewStatusLabel(selectedRow.status)}</dd>
</div>
<div>
<dt> </dt>
<dd>{selectedRow.status === "approved" ? "공개" : "숨김"}</dd>
</div>
<div>
<dt> </dt>
<dd>
{selectedRow.status === "approved"
? "새 버전 초안 생성 후 재검수"
: "현재 초안에서 계속 편집"}
</dd>
</div>
<div>
<dt> </dt>
<dd> </dd>
</div>
<div>
<dt> </dt>
<dd>
{selectedRow.origin === "system"
? "프로젝트 시스템 페르소나"
: "사용자 생성 페르소나"}
</dd>
</div>
</dl>
</article>
</div>
)}
</section>
) : (
<EmptyState
title="페르소나를 찾지 못했습니다"
desc="목록으로 돌아가 다시 선택하세요."
/>
);
const workspaceView = (
<AppShell contextLabel="페르소나" navRole="teacher" wide>
<main className="ps-root">
<header className="ps-head">
<div>
{/* eyebrow "페르소나"는 아래 h1(페르소나 운영/카탈로그/관리/상세)과 같은 말이라 제거 */}
<h1>
{detailKey
? "페르소나 상세"
: workspaceTab === "dashboard"
? "페르소나 운영"
: workspaceTab === "catalog"
? "페르소나 카탈로그"
: "페르소나 관리"}
</h1>
<p>
,
.
</p>
</div>
<div className="ps-head__actions">
<Button
variant="secondary"
leading={<Icon name="review" size={16} />}
onClick={() => void loadStudio()}
disabled={loadState === "loading" || busy !== null}
>
{loadState === "loading" ? "확인 중" : "새로고침"}
</Button>
{workspaceTab === "personas" && !detailKey ? (
<Button
leading={<Icon name="plus" size={16} />}
onClick={openCreate}
>
</Button>
) : null}
</div>
</header>
<nav className="ps-workspace-tabs" aria-label="페르소나 관리 영역">
{WORKSPACE_TABS.map((tab) => (
<button
type="button"
key={tab.key}
className={workspaceTab === tab.key ? "is-active" : ""}
aria-current={workspaceTab === tab.key ? "page" : undefined}
onClick={() => setSearchParams({ view: tab.key })}
>
<span>{tab.label}</span>
<small>{tab.desc}</small>
</button>
))}
</nav>
{loadError ? (
<section className="ps-error" role="alert">
<Icon name="alert" size={18} />
{loadError}
</section>
) : null}
{formError ? (
<p className="ps-status is-error" role="alert">
{formError}
</p>
) : message ? (
<p className="ps-status">{message}</p>
) : null}
{detailKey
? personaDetailView
: workspaceTab === "dashboard"
? dashboardView
: workspaceTab === "catalog"
? catalogView
: personaListView}
</main>
</AppShell>
);
const draftName = splitPersonaName(draft.display_name.trim());
const legacyAuthoring = (
<AppShell contextLabel="페르소나" navRole="teacher" wide>
<main className="ps-root">
<header className="ps-head">
<div>
<Kicker dot={false}>
{editingId ? "페르소나 수정" : "새로운 페르소나 만들기"}
</Kicker>
<h1>{draftName.head || "새 페르소나"}</h1>
{draftName.tail ? (
<p className="ps-name-tail">{draftName.tail}</p>
) : null}
<p>
.
</p>
</div>
<div className="ps-head__actions">
<Button
variant="secondary"
leading={<Icon name="review" size={16} />}
onClick={() => void loadStudio()}
disabled={loadState === "loading" || busy !== null}
>
{loadState === "loading" ? "확인 중" : "새로고침"}
</Button>
<Button
variant="secondary"
leading={<Icon name="chevron-left" size={16} />}
onClick={() => setSearchParams({ view: "personas" })}
disabled={busy !== null}
>
</Button>
</div>
</header>
<nav className="ps-workspace-tabs" aria-label="페르소나 관리 영역">
{WORKSPACE_TABS.map((tab) => (
<button
type="button"
key={tab.key}
className={tab.key === "personas" ? "is-active" : ""}
onClick={() => setSearchParams({ view: tab.key })}
>
<span>{tab.label}</span>
<small>{tab.desc}</small>
</button>
))}
</nav>
<nav className="ps-stepper" aria-label="페르소나 작성 단계">
{AUTHORING_STEPS.map((step, index) => (
<button
type="button"
key={step.key}
className={`${authoringStep === step.key ? "is-current" : ""} ${index < authoringStepPosition || index < authoringStepIndex ? "is-done" : ""}`}
aria-current={authoringStep === step.key ? "step" : undefined}
onClick={() => setQuery({ step: step.key })}
>
<span>{index + 1}</span>
<span>
<b>{step.label}</b>
<small>{step.desc}</small>
</span>
</button>
))}
</nav>
{loadError ? (
<section className="ps-error" role="alert">
<Icon name="alert" size={18} />
{loadError}
</section>
) : null}
<section
className="ps-layout ps-authoring-layout"
data-authoring-step={authoringStep}
>
<section className="ps-editor">
<div className={surfaceClassName("ps-editor__top")}>
<div>
<Kicker dot={false}>
{editingId ? "초안 편집" : "새 초안"}
</Kicker>
<h2>{draftName.head || "이름 미정"}</h2>
{draftName.tail ? (
<p className="ps-name-tail">{draftName.tail}</p>
) : null}
</div>
<Badge tone={validation.errors.length === 0 ? "accent" : "warn"}>
{completenessLabel(validation)}
</Badge>
</div>
<Card className="ps-source-panel">
<div className="ps-source-panel__head">
<div>
{/* eyebrow "RAG 근거로 초안 만들기"는 아래 문장과 같은 말이라 제거하고,
가운뎃점 2개(첨부 파일·실제 기록·교재 가이드)는 쉼표로 풀었다. */}
<b> , , </b>
</div>
<Badge tone={maskedEntities.length > 0 ? "warn" : "neutral"}>
{sourceReferences.length > 0
? `${sourceReferences.length}개 근거`
: maskedEntities.length > 0
? `${maskedEntities.length}개 마스킹`
: "자료 대기"}
</Badge>
</div>
<div className="ps-source-grid">
<div
className={surfaceClassName("ps-upload-card ps-field--wide", {
variant: "inset",
})}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
void loadSourceFile(event.dataTransfer.files?.[0] ?? null);
}}
>
<input
ref={sourceFileInputRef}
className="ps-file-input"
type="file"
accept=".txt,.md,.csv,.json,.html,.xml,.xlsx,.xlsm"
onChange={(event) => {
void loadSourceFile(
event.currentTarget.files?.[0] ?? null,
);
event.currentTarget.value = "";
}}
/>
<div>
<Kicker dot={false}>SSOT </Kicker>
<b>{sourceFilename || "파일을 드래그하거나 선택"}</b>
<span>
txt, md, csv, json, html, xml에
(xlsx) .
.
</span>
</div>
<Button
variant="secondary"
size="sm"
leading={<Icon name="review" size={14} />}
onClick={() => sourceFileInputRef.current?.click()}
disabled={busy !== null}
>
</Button>
</div>
<label className="ps-field">
<span> </span>
<select
value={sourceKind}
onChange={(event) =>
setSourceKind(event.target.value as SourceKind)
}
>
<option value="client_record"> </option>
<option value="textbook_guide">·</option>
<option value="mixed_notes"> </option>
</select>
</label>
<TextField
label="파일/자료명"
value={sourceFilename}
onChange={setSourceFilename}
placeholder="예: 상담기록-1회기.txt"
/>
<TextField
label="생성 목표"
value={generationGoal}
onChange={setGenerationGoal}
placeholder="예: 인간중심 초심자 훈련용"
/>
<TextAreaField
label="자료 본문"
value={sourceText}
onChange={setSourceText}
rows={8}
placeholder="첨부 파일을 읽거나, 내담자 기록, 사례 요약, 교재 기준, 상담 장면 메모를 붙여넣으세요."
/>
</div>
<div className="ps-source-panel__actions">
{generationSummary ? (
<span>{generationSummary}</span>
) : (
<span>
PII를 KB ,
.
</span>
)}
<Button
leading={<Icon name="review" size={15} />}
onClick={() => void generateFromSource()}
disabled={busy !== null}
>
{busy === "generate" ? "생성 중" : "KB 등록 후 생성"}
</Button>
</div>
</Card>
<div
className={surfaceClassName("ps-tabs", {
variant: "inset",
flat: true,
})}
role="tablist"
aria-label="저작 섹션"
>
{STUDIO_TABS.map((tab) => (
<button
type="button"
key={tab.key}
className={activeTab === tab.key ? "is-active" : ""}
onClick={() => setActiveTab(tab.key)}
role="tab"
aria-selected={activeTab === tab.key}
>
{tab.label}
</button>
))}
</div>
<Card className="ps-edit-panel">
<GuidancePanel tab={activeTab} />
{renderTab()}
</Card>
{formError ? (
<p className="ps-status is-error" role="alert">
{formError}
</p>
) : message ? (
<p className="ps-status">{message}</p>
) : null}
<div className="ps-actions">
<Button
variant="secondary"
leading={<Icon name="chevron-left" size={15} />}
onClick={() =>
previousAuthoringStep &&
setQuery({ step: previousAuthoringStep })
}
disabled={!previousAuthoringStep || busy !== null}
>
</Button>
<Button
variant="secondary"
leading={<Icon name="check" size={15} />}
onClick={() => void saveProgress()}
disabled={busy !== null}
>
{busy === "save" ? "저장 중" : "중간 저장"}
</Button>
{nextAuthoringStep ? (
<Button onClick={() => setQuery({ step: nextAuthoringStep })}>
</Button>
) : (
<Button
leading={<Icon name="review" size={15} />}
onClick={() => void saveDraft(true)}
disabled={busy !== null || validation.errors.length > 0}
>
{busy === "submit" ? "요청 중" : "검수 요청"}
</Button>
)}
</div>
</section>
<aside className="ps-inspector" aria-label="검증 상태">
<div className={surfaceClassName("ps-inspector__block")}>
<Kicker dot={false}></Kicker>
<div className="ps-check">
{validation.errors.length === 0 &&
validation.warnings.length === 0 ? (
<div className="ps-check-row">
<Dot tone="pos" />
<span> </span>
</div>
) : null}
{validation.errors.map((item) => (
<div className="ps-check-row" key={item}>
<Dot tone="crit" />
<span>{item}</span>
</div>
))}
{validation.warnings.map((item) => (
<div className="ps-check-row" key={item}>
<Dot tone="warn" />
<span>{item}</span>
</div>
))}
</div>
</div>
<div className={surfaceClassName("ps-inspector__block")}>
<Kicker dot={false}> </Kicker>
<div className="ps-check">
{generationSummary ? (
<div className="ps-check-row">
<Dot tone="info" />
<span>{generationSummary}</span>
</div>
) : (
<div className="ps-check-row">
<Dot tone="muted" />
<span> .</span>
</div>
)}
{maskedEntities.map((item) => (
<div className="ps-check-row" key={`masked:${item}`}>
<Dot tone="warn" />
<span>{item} </span>
</div>
))}
{generationWarnings.map((item) => (
<div className="ps-check-row" key={`generation:${item}`}>
<Dot tone="warn" />
<span>{item}</span>
</div>
))}
{sourceReferences.map((item) => (
<div
className="ps-check-row ps-check-row--stack"
key={`source:${item.source_id}`}
>
<Dot
tone={
item.external_llm_ok && !item.degraded ? "info" : "warn"
}
/>
<span>
<b>{item.title}</b>
{/* 한 줄에 가운뎃점 3개가 몰려 읽기 어려워 두 줄로 나눴다(줄당 1개). */}
<small>
{item.source_id} · doc {item.doc_id ?? "-"}
</small>
<small>
{item.chunk_count} chunks ·{" "}
{item.content_hash.slice(0, 10)}
</small>
</span>
</div>
))}
{generationEvidence.slice(0, 3).map((item) => (
<div
className={surfaceClassName("ps-evidence", {
variant: "inset",
flat: true,
})}
key={`evidence:${item.source_id}:${item.chunk_id}`}
>
<b>
chunk {item.chunk_id} · {item.score.toFixed(3)}
</b>
<span>
{item.heading_path ? `${item.heading_path} · ` : ""}
{item.excerpt}
</span>
</div>
))}
</div>
</div>
<div className={surfaceClassName("ps-inspector__block")}>
<Kicker dot={false}> </Kicker>
{reviews.length > 0 ? (
<div className="ps-review-cards">
{reviews.map((persona) => (
<article
className={surfaceClassName("ps-review", {
variant: "inset",
})}
key={`${persona.persona_id}:decision`}
>
<div>
<b>
{persona.code} v{persona.version}
</b>
<Badge tone={personaReviewTone(persona.status)}>
{personaReviewStatusLabel(persona.status)}
</Badge>
</div>
<span>{formatDateTime(persona.created_at)}</span>
<div className="ps-review__actions">
<Button
size="sm"
variant="secondary"
onClick={() => void loadDraft(persona.persona_id)}
disabled={busy !== null}
>
</Button>
<Button
size="sm"
variant="secondary"
onClick={() =>
void decideReview(persona.persona_id, "reject")
}
disabled={busy !== null}
>
</Button>
<Button
size="sm"
onClick={() =>
void decideReview(persona.persona_id, "approve")
}
disabled={busy !== null}
>
</Button>
</div>
</article>
))}
</div>
) : (
<EmptyState
title="결정 대기 없음"
desc="검수 요청 후 승인·반려가 가능합니다."
/>
)}
</div>
</aside>
</section>
</main>
</AppShell>
);
return isAuthoring ? legacyAuthoring : workspaceView;
}