feat: 운영 안정성과 세션 음성 경험 개선

This commit is contained in:
Yun Chan 2026-07-31 00:13:08 +09:00
parent facc4ad2d9
commit c788343467
95 changed files with 8431 additions and 1785 deletions

View file

@ -93,14 +93,14 @@ const STUDIO_TABS: { key: StudioTab; label: string }[] = [
];
const AUTHORING_STEPS = [
{ key: "source", label: "자료", desc: "기록·교재·가이드 정리" },
{ key: "source", label: "자료", desc: "기록, 교재, 가이드 정리" },
{ key: "generate", label: "초안", desc: "근거를 확인하고 초안 생성" },
{ key: "edit", label: "설정", desc: "임상·회기·말투 조정" },
{ key: "review", 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: "dashboard", label: "대시보드", desc: "운영, 학습, 검수 현황" },
{ key: "catalog", label: "카탈로그", desc: "공개 범위와 구성 정보" },
{ key: "personas", label: "페르소나", desc: "전체 목록과 검수" },
];
@ -123,7 +123,7 @@ const TAB_GUIDANCE: Record<
},
clinical: {
title: "임상 배경은 관찰 사실과 내부 가설을 분리해서 작성",
desc: "가족·관계·학교/직장·촉발 사건은 SSOT 자료에서 확인되는 사실을 우선한다. CCD는 내담자가 직접 설명하지 않는 내부 모델이다.",
desc: "가족, 관계, 학교/직장, 촉발 사건은 SSOT 자료에서 확인되는 사실을 우선한다. CCD는 내담자가 직접 설명하지 않는 내부 모델이다.",
items: [
"가족/관계에는 현재 기능에 영향을 주는 반복 패턴을 적는다.",
"촉발 사건은 날짜보다 상담 장면에서 드러날 행동 변화에 초점을 둔다.",
@ -150,7 +150,7 @@ const TAB_GUIDANCE: Record<
},
speech: {
title: "말투는 캐릭터 장식이 아니라 상담 기술 평가의 입력값",
desc: "말버릇, 군말, 비언어 단서는 상담자가 반영·명료화·공감하기 어렵거나 쉬운 지점을 만든다.",
desc: "말버릇, 군말, 비언어 단서는 상담자가 반영, 명료화, 공감하기 어렵거나 쉬운 지점을 만든다.",
items: [
"문장 길이는 짧음/중간/김처럼 운영 가능한 값으로 둔다.",
"군말과 말버릇은 과하지 않게 2~4개 정도가 적당하다.",
@ -159,7 +159,7 @@ const TAB_GUIDANCE: Record<
},
safety: {
title: "역린과 금기어는 모델이 말하지 말아야 할 말이 아니라 반응 조건",
desc: "금기어 목록만 늘리면 시뮬레이션이 경직된다. 어떤 개입에서 어떤 방어·침묵·분노가 생기는지까지 작성해야 한다.",
desc: "금기어 목록만 늘리면 시뮬레이션이 경직된다. 어떤 개입에서 어떤 방어, 침묵, 분노가 생기는지까지 작성해야 한다.",
items: [
"민감 영역은 내담자가 회피하거나 과민 반응하는 주제다.",
"상담자 금기는 훈련자가 하면 안 되는 개입 방식이다.",
@ -177,14 +177,12 @@ const TAB_GUIDANCE: Record<
},
};
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;
@ -194,6 +192,21 @@ 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";
@ -207,7 +220,6 @@ function reviewStatusDescription(status: PersonaReviewStatus): string {
return "새 회기 배정에서 제외된 보관 버전입니다.";
}
function TextField({
label,
value,
@ -403,12 +415,15 @@ 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> </Kicker>
<Kicker dot={false}> </Kicker>
<b>{guidance.title}</b>
<p>{guidance.desc}</p>
</div>
@ -1653,7 +1668,7 @@ export default function PersonaStudio() {
<article className="ps-performance">
<div className="ps-section-head">
<div>
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<h2> </h2>
</div>
<span> {dashboardPoints.length}</span>
@ -1694,7 +1709,7 @@ export default function PersonaStudio() {
<article className="ps-review-summary">
<div className="ps-section-head">
<div>
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<h2> </h2>
</div>
<Button
@ -1729,7 +1744,7 @@ export default function PersonaStudio() {
<section className="ps-active-table">
<div className="ps-section-head">
<div>
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<h2> </h2>
</div>
<span> </span>
@ -1749,6 +1764,7 @@ export default function PersonaStudio() {
{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"
@ -1763,7 +1779,8 @@ export default function PersonaStudio() {
>
<span>
<b>{persona.code}</b>
<small>{persona.display_name}</small>
<small>{name.head}</small>
{name.tail ? <small>{name.tail}</small> : null}
</span>
<strong>{usage?.learners ?? 0}</strong>
<strong>{usage?.activeSessions ?? 0}</strong>
@ -1801,10 +1818,10 @@ export default function PersonaStudio() {
<section className="ps-catalog-page" aria-label="페르소나 카탈로그 정보">
<article className="ps-catalog-hero">
<div>
<Kicker></Kicker>
<Kicker dot={false}></Kicker>
<h2> .</h2>
<p>
, · ·
, , ,
.
</p>
</div>
@ -1821,7 +1838,7 @@ export default function PersonaStudio() {
<section className="ps-catalog-block">
<div className="ps-section-head">
<div>
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<h2> </h2>
</div>
<Badge tone="accent"> </Badge>
@ -1852,7 +1869,7 @@ export default function PersonaStudio() {
<section className="ps-catalog-block">
<div className="ps-section-head">
<div>
<Kicker></Kicker>
{/* eyebrow "구성"은 바로 아래 "이론 분포"와 같은 말이라 제거 */}
<h2> </h2>
</div>
</div>
@ -1860,7 +1877,7 @@ export default function PersonaStudio() {
{Object.entries(catalogTheoryCounts).length ? (
Object.entries(catalogTheoryCounts).map(([theory, count]) => (
<div key={theory}>
<span>{personaTheoryLabel(theory)}</span>
<span>{personaTheoryLabel(theory)}</span>
<b>{count}</b>
<i>
<span
@ -1883,7 +1900,7 @@ export default function PersonaStudio() {
<section className="ps-catalog-block ps-catalog-block--wide">
<div className="ps-section-head">
<div>
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<h2> </h2>
</div>
<Button
@ -1895,34 +1912,38 @@ export default function PersonaStudio() {
</Button>
</div>
<div className="ps-catalog-list">
{catalog.map((persona) => (
<button
type="button"
key={persona.code}
onClick={() =>
setSearchParams({
view: "personas",
persona: `approved:${persona.code}`,
})
}
>
<span className="ps-code-box">{persona.code}</span>
<span>
<b>{persona.display_name}</b>
<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>
))}
{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>
@ -1946,69 +1967,73 @@ export default function PersonaStudio() {
</div>
{reviewQueue.length ? (
<div className="ps-review-board">
{reviewQueue.map((persona) => (
<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>{persona.display_name}</h3>
<p>{reviewStatusDescription(persona.status)}</p>
<dl>
<div>
<dt></dt>
<dd>v{persona.version}</dd>
{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>
<div>
<dt></dt>
<dd>{formatDateTime(persona.created_at)}</dd>
<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>
<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>
))}
</article>
);
})}
</div>
) : (
<EmptyState
@ -2088,6 +2113,7 @@ export default function PersonaStudio() {
{personaRows.length ? (
personaRows.map((row) => {
const usage = usageByPersona.get(normalizeCode(row.code));
const name = splitPersonaName(row.name);
return (
<div
className="ps-persona-row"
@ -2104,7 +2130,8 @@ export default function PersonaStudio() {
>
<span className="ps-code-box">{row.code}</span>
<span>
<b>{row.name}</b>
<b>{name.head}</b>
{name.tail ? <small>{name.tail}</small> : null}
<small>
{personaDifficultyLabel(row.difficulty)} ·{" "}
{row.theory.map(personaTheoryLabel).join(", ") ||
@ -2189,6 +2216,7 @@ export default function PersonaStudio() {
);
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">
@ -2201,10 +2229,13 @@ export default function PersonaStudio() {
</Button>
<div>
<Kicker>
<Kicker dot={false}>
{selectedRow.code} · v{selectedRow.version}
</Kicker>
<h2>{selectedRow.name}</h2>
<h2>{detailName.head}</h2>
{detailName.tail ? (
<p className="ps-name-tail">{detailName.tail}</p>
) : null}
<p>
{selectedRow.kind === "approved"
? "학습자에게 공개된 현재 버전"
@ -2258,8 +2289,11 @@ export default function PersonaStudio() {
{detailTab === "overview" ? (
<div className="ps-detail-grid">
<article className="ps-detail-primary">
<Kicker> </Kicker>
<h3>{selectedRow.name}</h3>
<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") ||
@ -2301,7 +2335,7 @@ export default function PersonaStudio() {
</article>
{detailData ? (
<article className="ps-detail-wide">
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<dl className="ps-settings-list">
<div>
<dt></dt>
@ -2368,7 +2402,7 @@ export default function PersonaStudio() {
<section className="ps-detail-wide">
<div className="ps-section-head">
<div>
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<h2> </h2>
</div>
</div>
@ -2400,7 +2434,7 @@ export default function PersonaStudio() {
) : (
<div className="ps-detail-grid">
<article className="ps-detail-wide">
<Kicker>· </Kicker>
<Kicker dot={false}>· </Kicker>
<dl className="ps-settings-list">
<div>
<dt> </dt>
@ -2447,7 +2481,7 @@ export default function PersonaStudio() {
<main className="ps-root">
<header className="ps-head">
<div>
<Kicker></Kicker>
{/* eyebrow "페르소나"는 아래 h1(페르소나 운영/카탈로그/관리/상세)과 같은 말이라 제거 */}
<h1>
{detailKey
? "페르소나 상세"
@ -2519,15 +2553,19 @@ export default function PersonaStudio() {
</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>
<Kicker dot={false}>
{editingId ? "페르소나 수정" : "새로운 페르소나 만들기"}
</Kicker>
<h1>{draft.display_name.trim() || "새 페르소나"}</h1>
<h1>{draftName.head || "새 페르소나"}</h1>
{draftName.tail ? (
<p className="ps-name-tail">{draftName.tail}</p>
) : null}
<p>
.
</p>
@ -2598,8 +2636,13 @@ export default function PersonaStudio() {
<section className="ps-editor">
<div className={surfaceClassName("ps-editor__top")}>
<div>
<Kicker>{editingId ? "초안 편집" : "새 초안"}</Kicker>
<h2>{draft.display_name.trim() || "이름 미정"}</h2>
<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)}
@ -2609,8 +2652,9 @@ export default function PersonaStudio() {
<Card className="ps-source-panel">
<div className="ps-source-panel__head">
<div>
<Kicker>RAG </Kicker>
<b> · · </b>
{/* eyebrow "RAG " ,
2( · · ) . */}
<b> , , </b>
</div>
<Badge tone={maskedEntities.length > 0 ? "warn" : "neutral"}>
{sourceReferences.length > 0
@ -2644,7 +2688,7 @@ export default function PersonaStudio() {
}}
/>
<div>
<Kicker>SSOT </Kicker>
<Kicker dot={false}>SSOT </Kicker>
<b>{sourceFilename || "파일을 드래그하거나 선택"}</b>
<span>
txt, md, csv, json, html, xml에
@ -2692,7 +2736,7 @@ export default function PersonaStudio() {
value={sourceText}
onChange={setSourceText}
rows={8}
placeholder="첨부 파일을 읽거나, 내담자 기록·사례 요약·교재 기준·상담 장면 메모를 붙여넣으세요."
placeholder="첨부 파일을 읽거나, 내담자 기록, 사례 요약, 교재 기준, 상담 장면 메모를 붙여넣으세요."
/>
</div>
<div className="ps-source-panel__actions">
@ -2787,7 +2831,7 @@ export default function PersonaStudio() {
<aside className="ps-inspector" aria-label="검증 상태">
<div className={surfaceClassName("ps-inspector__block")}>
<Kicker></Kicker>
<Kicker dot={false}></Kicker>
<div className="ps-check">
{validation.errors.length === 0 &&
validation.warnings.length === 0 ? (
@ -2812,7 +2856,7 @@ export default function PersonaStudio() {
</div>
<div className={surfaceClassName("ps-inspector__block")}>
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<div className="ps-check">
{generationSummary ? (
<div className="ps-check-row">
@ -2849,8 +2893,11 @@ export default function PersonaStudio() {
/>
<span>
<b>{item.title}</b>
{/* 한 줄에 가운뎃점 3개가 몰려 읽기 어려워 두 줄로 나눴다(줄당 1개). */}
<small>
{item.source_id} · doc {item.doc_id ?? "-"}
</small>
<small>
{item.source_id} · doc {item.doc_id ?? "-"} ·{" "}
{item.chunk_count} chunks ·{" "}
{item.content_hash.slice(0, 10)}
</small>
@ -2878,7 +2925,7 @@ export default function PersonaStudio() {
</div>
<div className={surfaceClassName("ps-inspector__block")}>
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
{reviews.length > 0 ? (
<div className="ps-review-cards">
{reviews.map((persona) => (