import { useCallback, useEffect, useMemo, useState, type ReactNode, } from "react"; import { Badge, Button, Card, Icon, Kicker } from "../../components/ui"; import { ApiError } from "../../lib/api"; import { alliancePulseApi, type AllianceDimension, type AllianceMeasurement, type AlliancePerspective, type AlliancePulse, type AllianceScores, } from "./alliancePulseApi"; import "./alliance-pulse.css"; interface PulseTurn { id: string; turn_id?: string | null; ts: string; speaker: string; who: string; text: string; } interface AlliancePulseCardProps { sessionId: string; turns: PulseTurn[]; isSupervisorView: boolean; feedbackEnabled: boolean; onJumpToTurn: (turnId: string) => void; } type ScoreDraft = Record; type LoadState = "loading" | "ready" | "error"; const DIMENSIONS: AllianceDimension[] = ["goal", "task", "bond"]; const DIMENSION_COPY: Record< AllianceDimension, { label: string; short: string; prompt: string } > = { goal: { label: "목표 합의", short: "목표", prompt: "이번 회기에서 무엇을 다루려는지 서로 이해했나요?", }, task: { label: "과업 합의", short: "과업", prompt: "목표를 위해 사용한 질문과 활동이 도움이 됐나요?", }, bond: { label: "정서적 유대", short: "유대", prompt: "내담자가 존중받고 안전하다고 느꼈을까요?", }, }; const CORE_PERSPECTIVES: AlliancePerspective[] = [ "learner_self_report", "client_agent_report", "independent_observer", ]; const PERSPECTIVE_COPY: Record< AlliancePerspective, { label: string; provenance: string } > = { learner_self_report: { label: "내 판단", provenance: "잠긴 자기평가" }, client_agent_report: { label: "내담자 관점", provenance: "AI 역할 추론" }, independent_observer: { label: "관찰자 관점", provenance: "AI 축어록 추론" }, supervisor_human: { label: "교수자 판정", provenance: "교수자 근거 판정" }, }; const SCALE = [ { value: 0, number: "1", label: "전혀 아니다" }, { value: 0.25, number: "2", label: "조금 아니다" }, { value: 0.5, number: "3", label: "보통이다" }, { value: 0.75, number: "4", label: "대체로 그렇다" }, { value: 1, number: "5", label: "매우 그렇다" }, ] as const; const EMPTY_DRAFT: ScoreDraft = { goal: null, task: null, bond: null }; const POLL_INTERVAL_MS = 2_000; function scoreText(value: number | null): string { if (value == null || !Number.isFinite(value)) return "근거 없음"; return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}점`; } function scoreDescriptor(value: number | null): string { if (value == null || !Number.isFinite(value)) return "판단 불가"; if (value >= 0.8) return "강하게 형성됨"; if (value >= 0.6) return "대체로 형성됨"; if (value >= 0.4) return "혼재함"; if (value >= 0.2) return "약한 편"; return "거의 형성되지 않음"; } function pulseStatusCopy(pulse: AlliancePulse): { label: string; tone: "neutral" | "accent" | "warn" | "crit" | "info"; body: string; } { if (pulse.status === "awaiting_agents" || pulse.status === "processing") { return { label: "관점 분석 중", tone: "info", body: "자기평가는 잠겼습니다. 내담자와 관찰자 관점이 모두 준비된 뒤 함께 공개합니다.", }; } if (pulse.status === "degraded") { return { label: "일부 근거 부족", tone: "warn", body: "확인 가능한 관점만 표시합니다. 생성되지 않은 점수를 임의로 채우지 않습니다.", }; } if (pulse.status === "error" || pulse.error_code) { return { label: "분석 보류", tone: "crit", body: "자기평가는 보존됐지만 AI 관점은 준비되지 않았습니다. 점수를 추정해 표시하지 않습니다.", }; } return { label: "관점 비교 준비됨", tone: "accent", body: "세 축을 평균내지 않고 관점별 차이와 발화 근거를 확인합니다.", }; } function isCompleteDraft(draft: ScoreDraft): draft is AllianceScores { return DIMENSIONS.every((dimension) => draft[dimension] != null); } function latestPostPulse(items: AlliancePulse[]): AlliancePulse | null { const postItems = items.filter((item) => item.checkpoint === "post"); return postItems.length > 0 ? postItems[postItems.length - 1] : null; } function selectedMeasurement( pulse: AlliancePulse, dimension: AllianceDimension, perspective: AlliancePerspective, ): AllianceMeasurement | null { const candidates = pulse.measurements.filter( (item) => item.dimension === dimension && item.perspective === perspective, ); return candidates.length > 0 ? candidates[candidates.length - 1] : null; } function AxisAssessmentControl({ dimension, value, onChange, disabled = false, prefix, }: { dimension: AllianceDimension; value: number | null; onChange: (value: number) => void; disabled?: boolean; prefix: string; }) { const copy = DIMENSION_COPY[dimension]; return (
{copy.label} {copy.prompt}
{SCALE.map((option) => ( ))}
); } function EvidencePicker({ turns, selected, onChange, scope, }: { turns: PulseTurn[]; selected: string[]; onChange: (turnIds: string[]) => void; scope: "learner" | "supervisor"; }) { const eligible = useMemo( () => turns .flatMap((turn) => { const persistedId = turn.turn_id?.trim(); return persistedId ? [{ turn, persistedId }] : []; }) .slice(-12), [turns], ); const summaryLabel = scope === "learner" ? "내 판단의 근거 장면 선택" : "교수자 판정 근거 장면 선택"; const toggle = (turnId: string) => { onChange( selected.includes(turnId) ? selected.filter((item) => item !== turnId) : [...selected, turnId], ); }; return (
{summaryLabel} {selected.length}개 선택 {eligible.length > 0 ? (
{eligible.map(({ turn, persistedId }) => ( ))}
) : (

영속 ID가 확인된 저장 발화가 없습니다.

)}
); } function MeasurementEvidence({ measurement, onJumpToTurn, }: { measurement: AllianceMeasurement; onJumpToTurn: (turnId: string) => void; }) { const evidence = measurement.evidence ?? []; if (!measurement.rationale && evidence.length === 0) return null; return (
근거 {evidence.length}개 {measurement.rationale ?

{measurement.rationale}

: null} {evidence.length > 0 ? (
{evidence.map((item) => ( ))}
) : null}
); } function PerspectiveCell({ pulse, dimension, perspective, onJumpToTurn, }: { pulse: AlliancePulse; dimension: AllianceDimension; perspective: AlliancePerspective; onJumpToTurn: (turnId: string) => void; }) { const measurement = perspective === "learner_self_report" ? null : selectedMeasurement(pulse, dimension, perspective); const value = perspective === "learner_self_report" ? (pulse.self_scores?.[dimension] ?? null) : (measurement?.value ?? null); const failed = measurement != null && measurement.status !== "ready"; return (
{PERSPECTIVE_COPY[perspective].label} {PERSPECTIVE_COPY[perspective].provenance} {scoreText(value)} {scoreDescriptor(value)} {measurement ? ( ) : ( 먼저 기록한 자기평가 )}
); } function LockedWaitingState({ pulse }: { pulse: AlliancePulse }) { const terminalWithoutReveal = pulse.status === "error" || pulse.status === "degraded" || Boolean(pulse.error_code); return (
{terminalWithoutReveal ? "내 판단은 보존됐습니다" : "내 판단이 잠겼습니다"}

{terminalWithoutReveal ? "공개 가능한 AI 근거를 확정하지 못했습니다. 준비되지 않은 점수는 추정해 표시하지 않습니다." : "AI 관점은 아직 화면에 표시하지 않습니다. 두 관점이 준비되면 한 번에 공개합니다."}

{terminalWithoutReveal ? null : ( )}
{DIMENSIONS.map((dimension) => (
{DIMENSION_COPY[dimension].short}
{scoreText(pulse.self_scores?.[dimension] ?? null)}
))}
); } function SelfOnlyState({ pulse }: { pulse: AlliancePulse }) { return (
저장한 내 치료 동맹 자기평가

내가 직접 잠근 세 축만 보존합니다. AI·교수자 파생 비교는 표시하지 않습니다.

{DIMENSIONS.map((dimension) => (
{DIMENSION_COPY[dimension].short}
{scoreText(pulse.self_scores?.[dimension] ?? null)}
))}
); } function ComparisonView({ pulse, onJumpToTurn, }: { pulse: AlliancePulse; onJumpToTurn: (turnId: string) => void; }) { const supervisorExists = pulse.measurements.some( (measurement) => measurement.perspective === "supervisor_human", ); const perspectives = supervisorExists ? [...CORE_PERSPECTIVES, "supervisor_human" as const] : CORE_PERSPECTIVES; return (
{DIMENSIONS.map((dimension) => (
{DIMENSION_COPY[dimension].label} {DIMENSION_COPY[dimension].prompt}
{perspectives.map((perspective) => ( ))}
))}

총점과 평균은 만들지 않습니다. 축별 차이는 다음 회기에서 확인할 학습 질문으로 사용합니다.

); } function SupervisorRatingForm({ sessionId, pulse, turns, onRecorded, }: { sessionId: string; pulse: AlliancePulse; turns: PulseTurn[]; onRecorded: () => Promise; }) { const [draft, setDraft] = useState(EMPTY_DRAFT); const [evidenceTurnIds, setEvidenceTurnIds] = useState([]); const [note, setNote] = useState(""); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [recorded, setRecorded] = useState(false); const hasExisting = pulse.measurements.some( (measurement) => measurement.perspective === "supervisor_human", ); const submit = async () => { if ( !isCompleteDraft(draft) || evidenceTurnIds.length === 0 || !note.trim() ) return; setSaving(true); setError(null); try { await alliancePulseApi.addSupervisorRating(sessionId, pulse.pulse_id, { scores: draft, evidence_turn_ids: evidenceTurnIds, note: note.trim(), }); setRecorded(true); setDraft(EMPTY_DRAFT); setEvidenceTurnIds([]); setNote(""); await onRecorded(); } catch (err) { setError( err instanceof Error ? err.message : "교수자 동맹 판정을 저장하지 못했습니다.", ); } finally { setSaving(false); } }; return (
{hasExisting ? "교수자 판정 추가" : "교수자 판정 기록"}

세 축을 각각 판정하고 근거 발화를 연결합니다. 새 판정은 기존 기록을 덮어쓰지 않습니다.

{DIMENSIONS.map((dimension) => ( setDraft((current) => ({ ...current, [dimension]: value })) } /> ))}