2993 lines
102 KiB
TypeScript
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;
|
|
}
|