vignette/apps/web/src/pages/session-review/AlliancePulseCard.tsx

766 lines
24 KiB
TypeScript

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<AllianceDimension, number | null>;
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 (
<fieldset className="ap-axis" disabled={disabled}>
<legend>
<span>{copy.label}</span>
<small>{copy.prompt}</small>
</legend>
<div className="ap-scale" role="radiogroup" aria-label={`${copy.label} 평가`}>
{SCALE.map((option) => (
<label
key={option.value}
className={`ap-scale__option ${value === option.value ? "is-selected" : ""}`}
>
<input
type="radio"
name={`${prefix}-${dimension}`}
value={option.value}
aria-label={`${option.number} ${option.label}`}
checked={value === option.value}
onChange={() => onChange(option.value)}
/>
<span aria-hidden="true">{option.number}</span>
<small>{option.label}</small>
</label>
))}
</div>
</fieldset>
);
}
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 (
<details className="ap-evidence-picker">
<summary>
{summaryLabel}
<span>{selected.length} </span>
</summary>
{eligible.length > 0 ? (
<div className="ap-evidence-picker__list">
{eligible.map(({ turn, persistedId }) => (
<label key={turn.id}>
<input
type="checkbox"
checked={selected.includes(persistedId)}
onChange={() => toggle(persistedId)}
/>
<span className="ap-evidence-picker__time">{turn.ts}</span>
<span>
<b>{turn.who}</b>
{turn.text}
</span>
</label>
))}
</div>
) : (
<p> ID가 .</p>
)}
</details>
);
}
function MeasurementEvidence({
measurement,
onJumpToTurn,
}: {
measurement: AllianceMeasurement;
onJumpToTurn: (turnId: string) => void;
}) {
const evidence = measurement.evidence ?? [];
if (!measurement.rationale && evidence.length === 0) return null;
return (
<details className="ap-measurement-evidence">
<summary> {evidence.length}</summary>
{measurement.rationale ? <p>{measurement.rationale}</p> : null}
{evidence.length > 0 ? (
<div className="ap-measurement-evidence__turns">
{evidence.map((item) => (
<button
type="button"
key={`${measurement.measurement_id}-${item.turn_id}-${item.seq}`}
onClick={() => onJumpToTurn(item.turn_id)}
>
<span>{item.seq} </span>
{item.text}
</button>
))}
</div>
) : null}
</details>
);
}
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 (
<div
className={`ap-perspective-cell ${failed || value == null ? "is-unavailable" : ""}`}
>
<span className="ap-perspective-cell__label">
<b>{PERSPECTIVE_COPY[perspective].label}</b>
<small>{PERSPECTIVE_COPY[perspective].provenance}</small>
</span>
<strong>{scoreText(value)}</strong>
<span>{scoreDescriptor(value)}</span>
{measurement ? (
<MeasurementEvidence measurement={measurement} onJumpToTurn={onJumpToTurn} />
) : (
<small> </small>
)}
</div>
);
}
function LockedWaitingState({ pulse }: { pulse: AlliancePulse }) {
const terminalWithoutReveal =
pulse.status === "error" ||
pulse.status === "degraded" ||
Boolean(pulse.error_code);
return (
<div className="ap-waiting" role="status" aria-live="polite">
<div className="ap-waiting__lock">
<Icon name="shield" size={18} strokeWidth={1.8} />
</div>
<div>
<b>
{terminalWithoutReveal
? "내 판단은 보존됐습니다"
: "내 판단이 잠겼습니다"}
</b>
<p>
{terminalWithoutReveal
? "공개 가능한 AI 근거를 확정하지 못했습니다. 준비되지 않은 점수는 추정해 표시하지 않습니다."
: "AI 관점은 아직 화면에 표시하지 않습니다. 두 관점이 준비되면 한 번에 공개합니다."}
</p>
</div>
{terminalWithoutReveal ? null : (
<div className="ap-waiting__skeleton" aria-hidden="true">
<span />
<span />
<span />
</div>
)}
<dl className="ap-locked-scores">
{DIMENSIONS.map((dimension) => (
<div key={dimension}>
<dt>{DIMENSION_COPY[dimension].short}</dt>
<dd>{scoreText(pulse.self_scores?.[dimension] ?? null)}</dd>
</div>
))}
</dl>
</div>
);
}
function SelfOnlyState({ pulse }: { pulse: AlliancePulse }) {
return (
<div className="ap-waiting ap-self-only" aria-label="저장된 치료 동맹 자기평가">
<div className="ap-waiting__copy">
<b> </b>
<p> . AI· .</p>
</div>
<dl className="ap-locked-scores">
{DIMENSIONS.map((dimension) => (
<div key={dimension}>
<dt>{DIMENSION_COPY[dimension].short}</dt>
<dd>{scoreText(pulse.self_scores?.[dimension] ?? null)}</dd>
</div>
))}
</dl>
</div>
);
}
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 (
<div className="ap-comparison" aria-label="치료 동맹 관점 비교">
<div
className={`ap-comparison__head ${supervisorExists ? "has-supervisor" : ""}`}
aria-hidden="true"
>
<span> </span>
{perspectives.map((perspective) => (
<span key={perspective}>
<b>{PERSPECTIVE_COPY[perspective].label}</b>
<small>{PERSPECTIVE_COPY[perspective].provenance}</small>
</span>
))}
</div>
{DIMENSIONS.map((dimension) => (
<section
className={`ap-comparison__row ${supervisorExists ? "has-supervisor" : ""}`}
key={dimension}
aria-labelledby={`ap-dimension-${dimension}`}
>
<div className="ap-comparison__axis">
<b id={`ap-dimension-${dimension}`}>{DIMENSION_COPY[dimension].label}</b>
<span>{DIMENSION_COPY[dimension].prompt}</span>
</div>
{perspectives.map((perspective) => (
<PerspectiveCell
key={perspective}
pulse={pulse}
dimension={dimension}
perspective={perspective}
onJumpToTurn={onJumpToTurn}
/>
))}
</section>
))}
<p className="ap-comparison__note">
. .
</p>
</div>
);
}
function SupervisorRatingForm({
sessionId,
pulse,
turns,
onRecorded,
}: {
sessionId: string;
pulse: AlliancePulse;
turns: PulseTurn[];
onRecorded: () => Promise<void>;
}) {
const [draft, setDraft] = useState<ScoreDraft>(EMPTY_DRAFT);
const [evidenceTurnIds, setEvidenceTurnIds] = useState<string[]>([]);
const [note, setNote] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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 (
<details className="ap-supervisor" open={!hasExisting}>
<summary>{hasExisting ? "교수자 판정 추가" : "교수자 판정 기록"}</summary>
<div className="ap-supervisor__body">
<p>
. .
</p>
{DIMENSIONS.map((dimension) => (
<AxisAssessmentControl
key={dimension}
dimension={dimension}
value={draft[dimension]}
prefix="supervisor-alliance"
disabled={saving}
onChange={(value) =>
setDraft((current) => ({ ...current, [dimension]: value }))
}
/>
))}
<EvidencePicker
turns={turns}
selected={evidenceTurnIds}
onChange={setEvidenceTurnIds}
scope="supervisor"
/>
<label className="ap-note-field">
<span> </span>
<textarea
rows={3}
value={note}
maxLength={2000}
placeholder="관점 차이를 해석하고 다음 지도에서 확인할 점을 남깁니다."
onChange={(event) => setNote(event.target.value)}
/>
</label>
{error ? <p className="ap-inline-error" role="alert">{error}</p> : null}
{recorded ? (
<p className="ap-inline-success" role="status"> .</p>
) : null}
<Button
size="sm"
disabled={
saving ||
!isCompleteDraft(draft) ||
evidenceTurnIds.length === 0 ||
!note.trim()
}
onClick={() => void submit()}
>
{saving ? "판정 저장 중" : "근거와 함께 판정 추가"}
</Button>
</div>
</details>
);
}
export function AlliancePulseCard({
sessionId,
turns,
isSupervisorView,
feedbackEnabled,
onJumpToTurn,
}: AlliancePulseCardProps) {
const [loadState, setLoadState] = useState<LoadState>("loading");
const [pulse, setPulse] = useState<AlliancePulse | null>(null);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState<ScoreDraft>(EMPTY_DRAFT);
const [evidenceTurnIds, setEvidenceTurnIds] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const load = useCallback(
async (signal?: AbortSignal) => {
try {
const response = await alliancePulseApi.list(sessionId, signal);
setPulse(latestPostPulse(response.items ?? []));
setLoadState("ready");
setError(null);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
setLoadState("error");
setError(
err instanceof Error ? err.message : "치료 동맹 펄스를 불러오지 못했습니다.",
);
}
},
[sessionId],
);
useEffect(() => {
const controller = new AbortController();
void load(controller.signal);
return () => controller.abort();
}, [load]);
useEffect(() => {
if (
!feedbackEnabled ||
!pulse ||
(pulse.status !== "awaiting_agents" && pulse.status !== "processing")
) {
return;
}
const timer = window.setTimeout(() => void load(), POLL_INTERVAL_MS);
return () => window.clearTimeout(timer);
}, [feedbackEnabled, load, pulse]);
const submitSelfAssessment = async () => {
if (!isCompleteDraft(draft) || submitting) return;
setSubmitting(true);
setError(null);
try {
const scores: AllianceScores = draft;
const created = await alliancePulseApi.create(sessionId, {
checkpoint: "post",
scores,
evidence_turn_ids: evidenceTurnIds,
});
setPulse({
pulse_id: created.pulse_id,
checkpoint: "post",
status: created.status,
learner_locked_at: new Date().toISOString(),
revealed_at: null,
error_code: null,
self_scores: scores,
measurements: [],
});
setLoadState("ready");
await load();
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
await load();
setError("이미 잠긴 자기평가가 있어 저장된 기록을 불러왔습니다.");
} else {
setError(
err instanceof Error ? err.message : "자기평가를 잠그지 못했습니다.",
);
}
} finally {
setSubmitting(false);
}
};
const revealReady = Boolean(
pulse?.learner_locked_at && pulse?.revealed_at,
);
const status = feedbackEnabled && pulse ? pulseStatusCopy(pulse) : null;
const learnerEvidenceRequired = turns.some((turn) => Boolean(turn.turn_id?.trim()));
const canSubmit =
isCompleteDraft(draft) &&
(!learnerEvidenceRequired || evidenceTurnIds.length > 0) &&
!submitting;
let content: ReactNode;
if (loadState === "loading") {
content = (
<div className="ap-loading" role="status" aria-label="치료 동맹 펄스 불러오는 중">
<span />
<span />
<span />
</div>
);
} else if (loadState === "error" && !pulse) {
content = (
<div className="ap-load-error" role="alert">
<p>{error ?? "치료 동맹 펄스를 불러오지 못했습니다."}</p>
<Button variant="secondary" size="sm" onClick={() => void load()}>
</Button>
</div>
);
} else if (!pulse && isSupervisorView) {
content = (
<div className="ap-empty">
<b> </b>
<p> AI .</p>
</div>
);
} else if (!pulse) {
content = (
<div className="ap-self-form">
<div className="ap-self-form__intro">
<b>
{feedbackEnabled
? "AI 관점을 보기 전에 먼저 스스로 판단합니다"
: "목표·과업·유대를 내가 직접 기록합니다"}
</b>
<p>
{feedbackEnabled
? "세 축을 따로 평가하세요. 제출하면 값은 잠기며 이후 관점에 맞춰 수정할 수 없습니다."
: "세 축을 따로 평가해 저장합니다. AI·교수자 파생 비교는 표시하지 않습니다."}
</p>
</div>
{DIMENSIONS.map((dimension) => (
<AxisAssessmentControl
key={dimension}
dimension={dimension}
value={draft[dimension]}
prefix="learner-alliance"
disabled={submitting}
onChange={(value) =>
setDraft((current) => ({ ...current, [dimension]: value }))
}
/>
))}
<EvidencePicker
turns={turns}
selected={evidenceTurnIds}
onChange={setEvidenceTurnIds}
scope="learner"
/>
{error ? <p className="ap-inline-error" role="alert">{error}</p> : null}
<div className="ap-self-form__commit">
<span>
<Icon name="shield" size={15} strokeWidth={1.8} />
{feedbackEnabled
? "제출 뒤에는 AI 관점이 준비될 때까지 내 판단만 보입니다."
: "제출 뒤에도 내가 직접 잠근 기록만 표시합니다."}
</span>
<Button size="sm" disabled={!canSubmit} onClick={() => void submitSelfAssessment()}>
{submitting
? "판단 잠그는 중"
: feedbackEnabled
? "내 판단 잠그고 관점 비교"
: "내 판단 기록"}
</Button>
</div>
</div>
);
} else if (!feedbackEnabled) {
content = <SelfOnlyState pulse={pulse} />;
} else if (!revealReady) {
content = <LockedWaitingState pulse={pulse} />;
} else {
content = (
<>
<ComparisonView pulse={pulse} onJumpToTurn={onJumpToTurn} />
{isSupervisorView ? (
<SupervisorRatingForm
sessionId={sessionId}
pulse={pulse}
turns={turns}
onRecorded={() => load()}
/>
) : null}
</>
);
}
return (
<Card className="sr-card ap-card" aria-busy={loadState === "loading"}>
<div className="ap-card__head">
<div>
<Kicker dot={false}>
{feedbackEnabled ? "치료 동맹 펄스" : "치료 동맹 자기평가"}
</Kicker>
<h2>
{feedbackEnabled
? "목표, 과업, 유대를 따로 봅니다"
: "목표, 과업, 유대를 직접 기록합니다"}
</h2>
</div>
{status ? <Badge tone={status.tone}>{status.label}</Badge> : null}
</div>
{status ? <p className="ap-card__status-copy">{status.body}</p> : null}
{content}
</Card>
);
}