G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
This commit is contained in:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
709
apps/web/src/pages/session-review/AlliancePulseCard.tsx
Normal file
709
apps/web/src/pages/session-review/AlliancePulseCard.tsx
Normal file
|
|
@ -0,0 +1,709 @@
|
|||
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;
|
||||
ts: string;
|
||||
speaker: string;
|
||||
who: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface AlliancePulseCardProps {
|
||||
sessionId: string;
|
||||
turns: PulseTurn[];
|
||||
isSupervisorView: 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.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) => (
|
||||
<label key={turn.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(turn.id)}
|
||||
onChange={() => toggle(turn.id)}
|
||||
/>
|
||||
<span className="ap-evidence-picker__time">{turn.ts}</span>
|
||||
<span>
|
||||
<b>{turn.who}</b>
|
||||
{turn.text}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>선택할 수 있는 저장 발화가 없습니다.</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 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,
|
||||
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 (!pulse || (pulse.status !== "awaiting_agents" && pulse.status !== "processing")) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => void load(), POLL_INTERVAL_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [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 = pulse ? pulseStatusCopy(pulse) : null;
|
||||
const learnerEvidenceRequired = turns.length > 0;
|
||||
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>AI 관점을 보기 전에 먼저 스스로 판단합니다</b>
|
||||
<p>세 축을 따로 평가하세요. 제출하면 값은 잠기며 이후 관점에 맞춰 수정할 수 없습니다.</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} />
|
||||
제출 뒤에는 AI 관점이 준비될 때까지 내 판단만 보입니다.
|
||||
</span>
|
||||
<Button size="sm" disabled={!canSubmit} onClick={() => void submitSelfAssessment()}>
|
||||
{submitting ? "판단 잠그는 중" : "내 판단 잠그고 관점 비교"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} 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}>치료 동맹 펄스</Kicker>
|
||||
<h2>목표, 과업, 유대를 따로 봅니다</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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue