fix(P06): defend page keys defense+grading on the REAL variant task_id (verifier P0)
The defend page fabricated taskId = `task-${competencyId}`, but variant
task_ids are `task-<seed[:16]>` (D-029) — so in the real browser flow the
defense ran against an empty trace (no digest grounding) and Grade My Work
always returned UNGRADABLE_EMPTY_TRACE. The E2E test masked this by passing
the real task_id directly.
Fix: DefenseSession resolves the learner's stored variant by competency
via GET /v1/variants?learner_id (new listVariants client), defends + grades
under the variant's real task_id, and shows an honest empty state when no
build session exists for the competency yet.
---ci---
phase: 6
milestone: v0.3
status: verify
requirements:
covered: [REQ-3-008]
partial: []
lessons:
- A green E2E test can still mask a broken UI wiring when the test hand-picks
the join key the UI is supposed to derive; verify browser flows against the
ids the pages actually construct, not the engine contract alone.
---/ci---
This commit is contained in:
@@ -38,7 +38,7 @@ export default async function DefensePage({
|
|||||||
then request your process-trace grade.
|
then request your process-trace grade.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<DefenseSession taskId={`task-${competencyId}`} />
|
<DefenseSession competencyId={competencyId} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
answerDefense,
|
answerDefense,
|
||||||
finishDefense,
|
finishDefense,
|
||||||
getDefense,
|
getDefense,
|
||||||
|
listVariants,
|
||||||
requestGrade,
|
requestGrade,
|
||||||
startDefense,
|
startDefense,
|
||||||
} from '../../lib/engine-client';
|
} from '../../lib/engine-client';
|
||||||
@@ -25,8 +26,16 @@ type MicState = 'idle' | 'recording' | 'denied' | 'unsupported';
|
|||||||
* MediaRecorder when permitted (multipart POST), browser-SR fallback per
|
* MediaRecorder when permitted (multipart POST), browser-SR fallback per
|
||||||
* the server's voice descriptor, examiner follow-ups, and the final verdict
|
* the server's voice descriptor, examiner follow-ups, and the final verdict
|
||||||
* rendered with integrity signals. No mock defense data anywhere.
|
* rendered with integrity signals. No mock defense data anywhere.
|
||||||
|
*
|
||||||
|
* Task identity: the defense + grading engines key on the learner's REAL
|
||||||
|
* variant task_id (task-<seed16>, derived server-side from the variant
|
||||||
|
* seed), NOT a fabricated per-competency id — the defense must question
|
||||||
|
* THIS build session's trace digest and the grade must read THIS build
|
||||||
|
* session's trace. `competencyId` resolves the stored variant; the engine
|
||||||
|
* client fabricates nothing.
|
||||||
*/
|
*/
|
||||||
export function DefenseSession({ taskId }: { taskId: string }) {
|
export function DefenseSession({ competencyId }: { competencyId: string }) {
|
||||||
|
const [taskId, setTaskId] = useState<string | null>(null);
|
||||||
const [defenseId, setDefenseId] = useState<string | null>(null);
|
const [defenseId, setDefenseId] = useState<string | null>(null);
|
||||||
const [turns, setTurns] = useState<DefenseTurn[]>([]);
|
const [turns, setTurns] = useState<DefenseTurn[]>([]);
|
||||||
const [answerText, setAnswerText] = useState('');
|
const [answerText, setAnswerText] = useState('');
|
||||||
@@ -42,7 +51,28 @@ export function DefenseSession({ taskId }: { taskId: string }) {
|
|||||||
setTurns(session.turns ?? []);
|
setTurns(session.turns ?? []);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Resolve the learner's stored variant for this competency — the real
|
||||||
|
// task key the defense and grading engines trace-key on. A learner who
|
||||||
|
// has not built yet has no variant: the panel says so (an honest empty
|
||||||
|
// state) instead of defending a phantom task with no trace.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const variants = await listVariants(MOCK_LEARNER_ID);
|
||||||
|
const mine = variants.find((v) => v.competency_id === competencyId);
|
||||||
|
if (!cancelled) setTaskId(mine?.task_id ?? null);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setTaskId(null);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [competencyId]);
|
||||||
|
|
||||||
const start = useCallback(async () => {
|
const start = useCallback(async () => {
|
||||||
|
if (!taskId) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -118,6 +148,7 @@ export function DefenseSession({ taskId }: { taskId: string }) {
|
|||||||
}, [defenseId, refresh]);
|
}, [defenseId, refresh]);
|
||||||
|
|
||||||
const gradeWork = useCallback(async () => {
|
const gradeWork = useCallback(async () => {
|
||||||
|
if (!taskId) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const record = await requestGrade(MOCK_LEARNER_ID, taskId);
|
const record = await requestGrade(MOCK_LEARNER_ID, taskId);
|
||||||
@@ -131,6 +162,19 @@ export function DefenseSession({ taskId }: { taskId: string }) {
|
|||||||
|
|
||||||
useEffect(() => () => recorderRef.current?.stop(), []);
|
useEffect(() => () => recorderRef.current?.stop(), []);
|
||||||
|
|
||||||
|
if (!taskId) {
|
||||||
|
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">
|
||||||
|
No build session found for this competency yet — start the task on
|
||||||
|
the build page first. The defense questions you about YOUR build
|
||||||
|
session's trace.
|
||||||
|
</p>
|
||||||
|
{error && <p className="text-xs text-amber-600 dark:text-amber-400">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!defenseId) {
|
if (!defenseId) {
|
||||||
return (
|
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">
|
<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">
|
||||||
|
|||||||
@@ -181,6 +181,18 @@ export async function generateVariant(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The learner's stored variants, chronological (D-029 — one per template). */
|
||||||
|
export async function listVariants(
|
||||||
|
learnerId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<TaskVariant[]> {
|
||||||
|
const body = await jsonFetch<{ variants: TaskVariant[] }>(
|
||||||
|
`/v1/variants?learner_id=${encodeURIComponent(learnerId)}`,
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
return body.variants ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
// -- grading (REQ-3-004) -------------------------------------------------------
|
// -- grading (REQ-3-004) -------------------------------------------------------
|
||||||
|
|
||||||
export async function requestGrade(
|
export async function requestGrade(
|
||||||
|
|||||||
Reference in New Issue
Block a user