ed243594d2
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---
267 lines
9.9 KiB
TypeScript
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'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>
|
|
);
|
|
} |