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 ( ); } function TextAreaField({ label, value, onChange, rows = 4, placeholder, }: { label: string; value: string; onChange: (value: string) => void; rows?: number; placeholder?: string; }) { return (