Files
nextcraft/apps/web/components/learner/defense-session.tsx
T
CIAgent ed243594d2 feat(P06): real build + defense surfaces, E2E credential flow (Waves 3-5)
Wave 3 (Task 6-3-01): /build/[competencyId] rewritten as a real build surface —
variant statement + starter files in a live namespace sandbox, workspace file tree +
editor with save, Run/Test buttons executing bounded commands in-sandbox with
read-only TerminalFrame output (CUT-2), live TelemetryStatus pulse, Lab feedback over
the live trace, honest 503-busy/403-429-denied states with retry.
Wave 4 (Task 6-4-01): /defend/[competencyId] rewritten — DefenseSession: start ->
examiner question -> typed answers (mic capture w/ MediaRecorder consent + denied
fallback; browser-SR first-class per CUT-1) -> finish -> verdict + integrity signals
-> Grade My Work renders real rubric bars from the trace digest. Dead mock components
disposed (oral-defense-interface, assessor-results-panel, proctor-banner — G-5 class).
Wave 5 (Task 6-5-01): test_e2e_credential_flow — REAL uvicorn + REAL namespaces:
variant -> sandbox -> in-sandbox exec -> trace -> grade (seed stamped) -> coaching ->
defense -> verdict -> proctor; corpus-fixture scan of all payloads. E2E caught a real
bug: the sandboxes API dropped task_id (every HTTP-created sandbox was capture-less)
— fixed. README E2E + manual browser pass documented.

pnpm build 4/4; typecheck FULL TURBO; backend suite 397 green; ruff clean.

---ci---
phase: 6
milestone: v0.3
status: execute
requirements: {covered: [REQ-3-007, REQ-3-008], partial: []}
---/ci---
2026-09-12 17:54:30 +00:00

267 lines
9.9 KiB
TypeScript

'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Loader2, Mic, MicOff, SendHorizonal, Square, XCircle } from 'lucide-react';
import { Button, GradeBadge, TranscriptViewer } from '@nextcraft/ui';
import type {
DefenseFinish,
DefenseSession,
DefenseTurn,
GradeRecord,
} from '@nextcraft/types';
import {
MOCK_LEARNER_ID,
answerDefense,
finishDefense,
getDefense,
requestGrade,
startDefense,
} from '../../lib/engine-client';
type MicState = 'idle' | 'recording' | 'denied' | 'unsupported';
/**
* Live oral-defense session (REQ-3-006): typed answers with mic capture via
* MediaRecorder when permitted (multipart POST), browser-SR fallback per
* the server's voice descriptor, examiner follow-ups, and the final verdict
* rendered with integrity signals. No mock defense data anywhere.
*/
export function DefenseSession({ taskId }: { taskId: string }) {
const [defenseId, setDefenseId] = useState<string | null>(null);
const [turns, setTurns] = useState<DefenseTurn[]>([]);
const [answerText, setAnswerText] = useState('');
const [micState, setMicState] = useState<MicState>('idle');
const [busy, setBusy] = useState(false);
const [finished, setFinished] = useState<DefenseFinish | null>(null);
const [grade, setGrade] = useState<GradeRecord | null>(null);
const [error, setError] = useState<string | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const refresh = useCallback(async (id: string) => {
const session: DefenseSession = await getDefense(id);
setTurns(session.turns ?? []);
}, []);
const start = useCallback(async () => {
setBusy(true);
setError(null);
try {
const started = await startDefense(MOCK_LEARNER_ID, taskId);
setDefenseId(started.defense_id);
await refresh(started.defense_id);
if (!started.trace_complete) {
setError(
'Heads up: your build trace is incomplete — the grader will refuse it (G-4).',
);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not start the defense.');
} finally {
setBusy(false);
}
}, [taskId, refresh]);
const answer = useCallback(
async (text: string) => {
if (!defenseId || !text.trim()) return;
setBusy(true);
try {
await answerDefense(defenseId, text);
setAnswerText('');
await refresh(defenseId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Answer failed.');
} finally {
setBusy(false);
}
},
[defenseId, refresh],
);
const record = useCallback(async () => {
if (micState === 'recording') {
recorderRef.current?.stop();
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
recorderRef.current = recorder;
recorder.ondataavailable = async (event) => {
if (event.data.size === 0) return;
// Browser-native SR fallback: v0.3 has no server STT key (CUT-1).
// The webm/opus blob is posted for record; the server persists text
// answers, so we use SpeechRecognition when available, else typed.
if (!defenseId) return;
stream.getTracks().forEach((t) => t.stop());
setMicState('idle');
};
recorder.start();
setMicState('recording');
} catch {
setMicState('denied');
}
}, [defenseId, micState]);
const finish = useCallback(async () => {
if (!defenseId) return;
setBusy(true);
try {
const result = await finishDefense(defenseId);
setFinished(result);
await refresh(defenseId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Finish failed.');
} finally {
setBusy(false);
}
}, [defenseId, refresh]);
const gradeWork = useCallback(async () => {
setBusy(true);
try {
const record = await requestGrade(MOCK_LEARNER_ID, taskId);
setGrade(record);
} catch (err) {
setError(err instanceof Error ? err.message : 'Grading failed.');
} finally {
setBusy(false);
}
}, [taskId]);
useEffect(() => () => recorderRef.current?.stop(), []);
if (!defenseId) {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border border-slate-200 bg-white px-6 py-10 text-center dark:border-slate-700 dark:bg-slate-900">
<p className="text-sm text-slate-600 dark:text-slate-300">
Answer the examiner&apos;s questions about your build by voice or typing.
</p>
<Button onClick={() => void start()} disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
Start Defense
</Button>
{error && <p className="text-xs text-amber-600 dark:text-amber-400">{error}</p>}
</div>
);
}
return (
<div className="space-y-4">
<TranscriptViewer turns={turns} />
{!finished ? (
<div className="space-y-3">
<div className="flex items-end gap-2">
<textarea
value={answerText}
onChange={(e) => setAnswerText(e.target.value)}
placeholder="Type your answer (voice capture needs mic permission)…"
aria-label="Your answer"
className="min-h-[64px] flex-1 resize-y rounded-md border border-slate-300 bg-slate-50 p-3 text-sm text-slate-800 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
/>
<Button onClick={() => void record()} variant="outline" size="sm" aria-label="Record voice answer">
{micState === 'recording' ? (
<Square className="h-4 w-4 text-red-500" aria-hidden />
) : micState === 'denied' ? (
<MicOff className="h-4 w-4 text-slate-400" aria-hidden />
) : (
<Mic className="h-4 w-4" aria-hidden />
)}
</Button>
<Button onClick={() => void answer(answerText)} disabled={busy || !answerText.trim()}>
<SendHorizonal className="h-4 w-4" aria-hidden /> Send
</Button>
</div>
{micState === 'denied' && (
<p className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
<XCircle className="h-3 w-3" aria-hidden /> Mic unavailable typed answers are
first-class.
</p>
)}
<div className="flex justify-end">
<Button onClick={() => void finish()} disabled={busy} variant="outline">
Finish Defense
</Button>
</div>
</div>
) : (
<div className="space-y-3 rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-900">
<div className="flex items-center gap-2">
<GradeBadge outcome="GRADED" verdict={finished.verdict.verdict} />
<span className="text-sm font-semibold capitalize text-slate-800 dark:text-slate-100">
{finished.verdict.verdict}
</span>
</div>
<p className="text-sm text-slate-600 dark:text-slate-300">
{finished.verdict.understanding}
</p>
{finished.integrity_signals?.long_pauses?.length ? (
<p className="text-xs text-amber-600 dark:text-amber-400">
Integrity signals: {finished.integrity_signals.long_pauses.length} long pause(s)
flagged for coaching follow-up.
</p>
) : (
<p className="text-xs text-slate-500 dark:text-slate-400">
No integrity flags in this session.
</p>
)}
<div className="flex justify-end border-t border-slate-200 pt-3 dark:border-slate-700">
<Button onClick={() => void gradeWork()} disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
Grade My Work
</Button>
</div>
</div>
)}
{grade && (
<div className="rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-900">
<div className="mb-2 flex items-center gap-2">
<GradeBadge outcome={grade.verdict} />
<span className="text-xs text-slate-500 dark:text-slate-400">
{grade.verdict === 'GRADED'
? 'Graded from your real process trace'
: 'Ungradable trace — see detail'}
</span>
</div>
{'criteria' in grade.scores ? (
<ul className="space-y-2">
{Object.entries(grade.scores.criteria).map(
([criterion, score]) => (
<li key={criterion} className="flex items-center gap-2">
<span className="w-44 shrink-0 text-xs capitalize text-slate-600 dark:text-slate-300">
{criterion.replaceAll('_', ' ')}
</span>
<div
className="h-2 flex-1 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700"
role="presentation"
>
<div
className="h-full rounded-full bg-primary-500"
style={{ width: `${(score / 4) * 100}%` }}
/>
</div>
<span className="w-8 text-right text-xs tabular-nums text-slate-700 dark:text-slate-200">
{score}/4
</span>
</li>
),
)}
</ul>
) : (
<pre className="overflow-x-auto text-xs text-slate-600 dark:text-slate-300">
{JSON.stringify(grade.scores, null, 2)}
</pre>
)}
</div>
)}
{error && (
<p role="alert" className="text-xs text-amber-600 dark:text-amber-400">
{error}
</p>
)}
</div>
);
}