전 저장소 리팩터링과 SSOT 정비

This commit is contained in:
Yun Chan 2026-07-15 21:31:30 +09:00
parent 14ecbd4e7d
commit 3dfddcac6f
173 changed files with 19679 additions and 6952 deletions

View file

@ -0,0 +1,552 @@
import type {
PersonaDraftDetail,
PersonaDraftPayload,
PersonaReviewSummary,
PersonaSummary,
} from "../../lib/api";
import { formatOptionalPercent } from "../../lib/format";
export type JsonRecord = Record<string, unknown>;
export type Difficulty = "easy" | "moderate" | "hard";
export interface PersonaStudioPayload {
code: string;
display_name: string;
difficulty: Difficulty;
theory_target: string[];
demographics: JsonRecord;
presenting: JsonRecord;
history: JsonRecord;
big5: Record<string, number>;
resistance: Record<string, number>;
speech_style: JsonRecord;
affect_baseline: Record<string, number>;
ccd: JsonRecord;
dsm5_dimensional: JsonRecord;
triggers: JsonRecord;
source_provenance: string;
is_synthetic: boolean;
submit_for_review: boolean;
}
export interface ValidationResult {
errors: string[];
warnings: string[];
}
export interface PromptPreviewRow {
label: string;
value: string;
}
export interface PromptPreviewSection {
title: string;
desc?: string;
rows?: PromptPreviewRow[];
items?: string[];
body?: string;
}
const EMPTY_STUDIO_DRAFT: PersonaStudioPayload = {
code: "",
display_name: "",
difficulty: "moderate",
theory_target: ["humanistic"],
demographics: {
age_band: "",
sex: "",
role: "",
context: "",
},
presenting: {
complaint: "",
surface: "",
first_session_opening: "",
},
history: {
family: "",
school_or_work: "",
relationships: "",
precipitant: "",
strengths: "",
},
big5: {
O: 0.5,
C: 0.5,
E: 0.5,
A: 0.5,
N: 0.5,
},
resistance: {
base_resistance: 0.5,
unlock_rate: 0.1,
decay_floor: 0.05,
silence_prob: 0.15,
deflection_prob: 0.25,
},
speech_style: {
register: "polite",
avg_sentence_len: "medium",
fillers: [],
honorific: true,
verbal_tics: [],
nonverbal_cues: [],
},
affect_baseline: {
negative_affect: 0.45,
hopelessness: 0.2,
anhedonia: 0.2,
sleep: 0.2,
anxiety: 0.35,
suicide_ideation_stage: 1,
},
ccd: {
core_belief: "",
intermediate_belief: "",
automatic_thought: [],
coping_strategy: "",
compensatory: "",
},
dsm5_dimensional: {
anxiety: 0.35,
depression: 0.2,
somatic: 0.1,
note: "",
},
triggers: {
sore_spots: [],
forbidden: [],
reaction: "",
taboo_terms: [],
session_scenarios: [],
prompt_contract: "",
},
source_provenance: "clinical draft",
is_synthetic: true,
submit_for_review: false,
};
export function cloneEmptyDraft(): PersonaStudioPayload {
return JSON.parse(JSON.stringify(EMPTY_STUDIO_DRAFT)) as PersonaStudioPayload;
}
function isRecord(value: unknown): value is JsonRecord {
return !!value && typeof value === "object" && !Array.isArray(value);
}
export function objectValue(value: unknown): JsonRecord {
return isRecord(value) ? value : {};
}
export function stringValue(section: JsonRecord, key: string): string {
const value = section[key];
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean")
return String(value);
return "";
}
export function listValue(section: JsonRecord, key: string): string[] {
const value = section[key];
if (Array.isArray(value)) {
return value.map((item) => String(item)).filter((item) => item.trim());
}
if (typeof value === "string" && value.trim()) return [value.trim()];
return [];
}
export function editableListValue(section: JsonRecord, key: string): string[] {
const value = section[key];
if (Array.isArray(value)) return value.map((item) => String(item));
if (typeof value === "string" && value.trim()) return [value.trim()];
return [];
}
export function numberValue(
section: Record<string, number>,
key: string,
fallback: number,
): number {
const value = section[key];
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
export function normalizeCode(raw: string): string {
return raw.trim().toUpperCase();
}
export function nextPersonaCode(
catalog: PersonaSummary[],
reviews: PersonaReviewSummary[],
): string {
const used = new Set<string>();
for (const persona of catalog) used.add(normalizeCode(persona.code));
for (const persona of reviews) used.add(normalizeCode(persona.code));
let next = 1;
while (used.has(`P${next}`)) next += 1;
return `P${next}`;
}
export function draftDetailToStudioPayload(
detail: PersonaDraftDetail,
): PersonaStudioPayload {
const extended = detail as PersonaDraftDetail & { triggers?: JsonRecord };
return {
code: detail.code,
display_name: detail.display_name,
difficulty:
detail.difficulty === "easy" || detail.difficulty === "hard"
? detail.difficulty
: "moderate",
theory_target: detail.theory_target,
demographics: objectValue(detail.demographics),
presenting: objectValue(detail.presenting),
history: objectValue(detail.history),
big5: { ...detail.big5 },
resistance: { ...detail.resistance },
speech_style: objectValue(detail.speech_style),
affect_baseline: { ...detail.affect_baseline },
ccd: objectValue(detail.ccd),
dsm5_dimensional: objectValue(detail.dsm5_dimensional),
triggers: objectValue(extended.triggers),
source_provenance: detail.source_provenance,
is_synthetic: detail.is_synthetic,
submit_for_review: detail.status === "review",
};
}
export function draftPayloadToStudioPayload(
payload: PersonaDraftPayload,
): PersonaStudioPayload {
const extended = payload as PersonaDraftPayload & { triggers?: JsonRecord };
return {
code: payload.code,
display_name: payload.display_name,
difficulty: payload.difficulty,
theory_target: payload.theory_target ?? ["humanistic"],
demographics: objectValue(payload.demographics),
presenting: objectValue(payload.presenting),
history: objectValue(payload.history),
big5: { O: 0.5, C: 0.5, E: 0.5, A: 0.5, N: 0.5, ...(payload.big5 ?? {}) },
resistance: {
base_resistance: 0.5,
unlock_rate: 0.1,
decay_floor: 0.05,
silence_prob: 0.15,
deflection_prob: 0.25,
...(payload.resistance ?? {}),
},
speech_style: objectValue(payload.speech_style),
affect_baseline: {
negative_affect: 0.45,
hopelessness: 0.2,
anhedonia: 0.2,
sleep: 0.2,
anxiety: 0.35,
suicide_ideation_stage: 1,
...(payload.affect_baseline ?? {}),
},
ccd: objectValue(payload.ccd),
dsm5_dimensional: objectValue(payload.dsm5_dimensional),
triggers: objectValue(extended.triggers),
source_provenance: payload.source_provenance,
is_synthetic: payload.is_synthetic,
submit_for_review: false,
};
}
export function payloadForApi(
draft: PersonaStudioPayload,
submitForReview: boolean,
): PersonaDraftPayload & { triggers: JsonRecord } {
const ccd = {
...draft.ccd,
automatic_thought: listValue(draft.ccd, "automatic_thought"),
};
const speechStyle = {
...draft.speech_style,
fillers: listValue(draft.speech_style, "fillers"),
verbal_tics: listValue(draft.speech_style, "verbal_tics"),
nonverbal_cues: listValue(draft.speech_style, "nonverbal_cues"),
};
const triggers = {
...draft.triggers,
sore_spots: listValue(draft.triggers, "sore_spots"),
forbidden: listValue(draft.triggers, "forbidden"),
taboo_terms: listValue(draft.triggers, "taboo_terms"),
session_scenarios: listValue(draft.triggers, "session_scenarios"),
};
return {
code: normalizeCode(draft.code),
display_name: draft.display_name.trim(),
difficulty: draft.difficulty,
theory_target: draft.theory_target,
demographics: draft.demographics,
presenting: draft.presenting,
history: draft.history,
big5: draft.big5,
resistance: draft.resistance,
speech_style: speechStyle,
affect_baseline: draft.affect_baseline,
ccd,
dsm5_dimensional: draft.dsm5_dimensional,
triggers,
source_provenance: draft.source_provenance.trim(),
is_synthetic: draft.is_synthetic,
submit_for_review: submitForReview,
};
}
function previewText(value: string): string {
return value.trim() || "-";
}
function previewList(section: JsonRecord, key: string): string[] {
return listValue(section, key);
}
function previewListText(section: JsonRecord, key: string): string {
const items = previewList(section, key);
return items.length ? items.join("\n") : "-";
}
function scaleLabel(value: number): string {
return formatOptionalPercent(value);
}
export function buildPromptPreviewSections(
draft: PersonaStudioPayload,
): PromptPreviewSection[] {
const demographics = draft.demographics;
const presenting = draft.presenting;
const history = draft.history;
const speech = draft.speech_style;
const ccd = draft.ccd;
const dsm5 = draft.dsm5_dimensional;
const triggers = draft.triggers;
return [
{
title: `L1 페르소나 카드: ${previewText(draft.display_name)}`,
desc: "학습자에게 노출될 식별자와 첫 장면을 검토한다.",
rows: [
{ label: "코드", value: previewText(draft.code) },
{ label: "난이도", value: draft.difficulty },
{
label: "이론",
value: draft.theory_target.length
? draft.theory_target.join(", ")
: "-",
},
{ label: "출처", value: previewText(draft.source_provenance) },
{
label: "합성 여부",
value: draft.is_synthetic ? "합성/작성 초안" : "근거 기반 초안",
},
],
},
{
title: "인적 범주와 표층 호소",
rows: [
{
label: "연령대",
value: previewText(stringValue(demographics, "age_band")),
},
{
label: "성별/역할",
value:
[
stringValue(demographics, "sex"),
stringValue(demographics, "role"),
]
.filter(Boolean)
.join(" / ") || "-",
},
{
label: "상황",
value: previewText(stringValue(demographics, "context")),
},
{
label: "주호소",
value: previewText(stringValue(presenting, "complaint")),
},
{
label: "표면화 방식",
value: previewText(stringValue(presenting, "surface")),
},
{
label: "첫 회기 입장 발화",
value: previewText(stringValue(presenting, "first_session_opening")),
},
],
},
{
title: "임상 배경과 내부 사례개념화",
desc: "내담자가 직접 설명하지 않는 내부 모델이다.",
rows: [
{ label: "가족", value: previewText(stringValue(history, "family")) },
{
label: "학교/직장",
value: previewText(stringValue(history, "school_or_work")),
},
{
label: "관계",
value: previewText(stringValue(history, "relationships")),
},
{
label: "촉발 사건",
value: previewText(stringValue(history, "precipitant")),
},
{
label: "강점",
value: previewText(stringValue(history, "strengths")),
},
{
label: "핵심 신념",
value: previewText(stringValue(ccd, "core_belief")),
},
{
label: "중간 신념",
value: previewText(stringValue(ccd, "intermediate_belief")),
},
{ label: "자동사고", value: previewListText(ccd, "automatic_thought") },
{
label: "대처 전략",
value: previewText(stringValue(ccd, "coping_strategy")),
},
{
label: "보상 행동",
value: previewText(stringValue(ccd, "compensatory")),
},
],
},
{
title: "말투와 비언어 단서",
rows: [
{
label: "말투 register",
value: previewText(stringValue(speech, "register")),
},
{
label: "문장 길이",
value: previewText(stringValue(speech, "avg_sentence_len")),
},
{
label: "존대",
value:
stringValue(speech, "honorific") === "false"
? "사용 안 함"
: "사용",
},
{ label: "군말", value: previewListText(speech, "fillers") },
{ label: "말버릇", value: previewListText(speech, "verbal_tics") },
{
label: "비언어 단서",
value: previewListText(speech, "nonverbal_cues"),
},
],
},
{
title: "내부 수치 파라미터",
desc: "진단이 아니라 시뮬레이션 반응 강도와 평가 입력값이다.",
rows: [
{
label: "Big5 O/C/E/A/N",
value: ["O", "C", "E", "A", "N"]
.map(
(key) => `${key} ${scaleLabel(numberValue(draft.big5, key, 0))}`,
)
.join(" · "),
},
{
label: "저항",
value: `기본 ${scaleLabel(numberValue(draft.resistance, "base_resistance", 0))}, 해제 ${scaleLabel(numberValue(draft.resistance, "unlock_rate", 0))}, 바닥 ${scaleLabel(numberValue(draft.resistance, "decay_floor", 0))}`,
},
{
label: "침묵/회피",
value: `침묵 ${scaleLabel(numberValue(draft.resistance, "silence_prob", 0))}, 회피 ${scaleLabel(numberValue(draft.resistance, "deflection_prob", 0))}`,
},
{
label: "정서 기저선",
value: `부정정서 ${scaleLabel(numberValue(draft.affect_baseline, "negative_affect", 0))}, 불안 ${scaleLabel(numberValue(draft.affect_baseline, "anxiety", 0))}, 무망감 ${scaleLabel(numberValue(draft.affect_baseline, "hopelessness", 0))}`,
},
{
label: "자살사고 단계",
value: String(
numberValue(draft.affect_baseline, "suicide_ideation_stage", 0),
),
},
{
label: "DSM-5 차원",
value: `불안 ${scaleLabel(numberValue(dsm5 as Record<string, number>, "anxiety", 0))}, 우울 ${scaleLabel(numberValue(dsm5 as Record<string, number>, "depression", 0))}, 신체 ${scaleLabel(numberValue(dsm5 as Record<string, number>, "somatic", 0))}`,
},
{ label: "DSM 메모", value: previewText(stringValue(dsm5, "note")) },
],
},
{
title: "역린과 안전 반응",
desc: "금지어가 아니라 상담자 개입에 대한 반응 조건으로 검토한다.",
rows: [
{ label: "민감 영역", value: previewListText(triggers, "sore_spots") },
{ label: "상담자 금기", value: previewListText(triggers, "forbidden") },
{ label: "금기어", value: previewListText(triggers, "taboo_terms") },
{
label: "반응 양상",
value: previewText(stringValue(triggers, "reaction")),
},
],
},
{
title: "회기 시나리오",
items: previewList(triggers, "session_scenarios"),
},
{
title: "추가 프롬프트 계약",
body: previewText(stringValue(triggers, "prompt_contract")),
},
];
}
export function validateDraft(
draft: PersonaStudioPayload,
submitForReview: boolean,
codeTaken: boolean,
): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
const code = normalizeCode(draft.code);
if (!/^P\d+$/.test(code)) errors.push("코드는 P숫자 형식이어야 합니다.");
if (codeTaken)
errors.push(`${code} 코드는 이미 공개 또는 검수 큐에서 사용 중입니다.`);
if (!draft.display_name.trim()) errors.push("표시 이름이 비어 있습니다.");
if (!draft.source_provenance.trim())
warnings.push("출처/작성 근거가 비어 있습니다.");
if (!stringValue(draft.presenting, "complaint").trim()) {
if (submitForReview) errors.push("주호소가 비어 있습니다.");
else warnings.push("주호소가 비어 있습니다.");
}
if (!stringValue(draft.history, "precipitant").trim())
warnings.push("촉발 사건이 비어 있습니다.");
if (!stringValue(draft.ccd, "core_belief").trim()) {
if (submitForReview) errors.push("CCD 핵심신념이 비어 있습니다.");
else warnings.push("CCD 핵심신념이 비어 있습니다.");
}
if (listValue(draft.ccd, "automatic_thought").length === 0)
warnings.push("자동사고가 비어 있습니다.");
if (listValue(draft.triggers, "sore_spots").length === 0) {
if (submitForReview) errors.push("역린 민감 영역이 비어 있습니다.");
else warnings.push("역린 민감 영역이 비어 있습니다.");
}
if (listValue(draft.triggers, "forbidden").length === 0)
warnings.push("상담자 금기 반응이 비어 있습니다.");
if (!stringValue(draft.triggers, "reaction").trim())
warnings.push("역린 반응 양상이 비어 있습니다.");
if (listValue(draft.triggers, "session_scenarios").length === 0) {
warnings.push("회기별 시나리오가 비어 있습니다.");
}
return { errors, warnings };
}