전 저장소 리팩터링과 SSOT 정비
This commit is contained in:
parent
14ecbd4e7d
commit
3dfddcac6f
173 changed files with 19679 additions and 6952 deletions
552
apps/web/src/pages/persona-studio/model.ts
Normal file
552
apps/web/src/pages/persona-studio/model.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue