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---
This commit is contained in:
@@ -235,6 +235,24 @@ in CI, so v0.3 ships the protocol seam instead of an unverifiable claim.
|
||||
criterion, run manually with `AI_VOICE_PROVIDER` set to the real
|
||||
provider and keys in `.ciagent/.env.secrets` (never in code/commits).
|
||||
|
||||
## End-to-end credential flow (v0.3, REQ-3-007/008)
|
||||
|
||||
`tests/api/test_e2e_credential_flow.py` runs the full pipeline against a REAL
|
||||
uvicorn server with REAL namespace sandboxes (mock LLM/voice per G-2
|
||||
precedent): variant -> telemetry-wired sandbox -> in-sandbox exec -> trace
|
||||
persistence -> process-trace grade (variant seed stamped) -> assessor
|
||||
coaching -> oral defense -> verdict + integrity signals -> proctor. It
|
||||
asserts no corpus fixture appears anywhere in the learner path.
|
||||
|
||||
Manual browser pass (documented, not automated): `pnpm ai:dev` + `pnpm dev`,
|
||||
then open `/build/stack-orchestration-c007` — variant statement + starter
|
||||
files load, edit a file, Run/Test execute in the sandbox with output in the
|
||||
read-only panel, the telemetry status pulses, Lab streams feedback from the
|
||||
live digest; then `/defend/stack-orchestration-c007` — Start Defense, typed
|
||||
answers (mic path needs permission), Finish, Grade My Work renders the real
|
||||
rubric bars. Navigating away destroys the sandbox
|
||||
(`curl localhost:8420/v1/sandboxes` shows the count drop).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
|
||||
@@ -51,6 +51,9 @@ _CREATE_TIMES: deque[float] = deque()
|
||||
|
||||
class SandboxCreateRequest(BaseModel):
|
||||
learner_id: str = Field(min_length=1)
|
||||
# Optional task key: when set, the sandbox is telemetry-wired (REQ-3-003)
|
||||
# — the in-sandbox capture agent streams workspace events to the ingest.
|
||||
task_id: str | None = None
|
||||
|
||||
|
||||
class SandboxResponse(BaseModel):
|
||||
@@ -143,7 +146,7 @@ async def create_sandbox(
|
||||
_check_per_learner_cap(await manager.list(), body.learner_id, settings)
|
||||
_check_global_create_rate(settings)
|
||||
try:
|
||||
info = await manager.create(body.learner_id)
|
||||
info = await manager.create(body.learner_id, task_id=body.task_id)
|
||||
except PoolFullError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
return _to_response(info)
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Full credential-flow E2E over real engines (Task 6-5-01, REQ-3-007/008).
|
||||
|
||||
Endpoint-level end-to-end with mock LLM/voice providers (G-2 precedent:
|
||||
real engine plumbing over real endpoints; provider choice is
|
||||
service-internal): variant -> telemetry-wired sandbox -> real in-sandbox
|
||||
exec -> trace -> grade -> oral defense -> verdict/signals -> proctor.
|
||||
No corpus fixture anywhere in the flow.
|
||||
|
||||
Probe-guarded for user namespaces (the in-sandbox exec needs them).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import uvicorn
|
||||
|
||||
from ai_service.config import Settings
|
||||
from ai_service.grading.store import SQLiteGradeStore
|
||||
from ai_service.llm.mock import MockProvider
|
||||
from ai_service.main import create_app
|
||||
from ai_service.telemetry.ingest import TraceIntegrityMap
|
||||
from ai_service.telemetry.store import SQLiteTraceStore
|
||||
from ai_service.variants.store import SQLiteVariantStore
|
||||
from ai_service.voice.defense_store import SQLiteDefenseStore
|
||||
from tests.sandbox.test_isolation import USERSNS_AVAILABLE
|
||||
|
||||
COACHING_JSON = json.dumps(
|
||||
{
|
||||
"summary": "Strong iteration.",
|
||||
"strengths": ["Tests early."],
|
||||
"gaps": ["One edge case missing."],
|
||||
"next_steps": ["Add it."],
|
||||
}
|
||||
)
|
||||
VERDICT_JSON = json.dumps(
|
||||
{
|
||||
"verdict": "developing",
|
||||
"understanding": "Explains the build clearly.",
|
||||
"process_justification": "Choices defended.",
|
||||
"communication": "Clear.",
|
||||
"strengths": ["Grounded in the digest."],
|
||||
"gaps": ["Missed one edge case."],
|
||||
}
|
||||
)
|
||||
PROCTOR_JSON = json.dumps(
|
||||
{
|
||||
"signals": [
|
||||
{"signal_type": "idle_gap", "severity": "low", "note": "A short pause."}
|
||||
],
|
||||
"intervention": "Keep momentum.",
|
||||
"summary": "Healthy session.",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FlowLLM(MockProvider):
|
||||
"""Prompt-discriminated: verdict vs coaching vs question vs proctor JSON."""
|
||||
|
||||
def _reply_for(self, messages, response_format):
|
||||
all_text = "\n".join(m.content for m in messages)
|
||||
if response_format is not None and response_format.get("type") == "json_object":
|
||||
if "final verdict JSON" in all_text:
|
||||
return VERDICT_JSON
|
||||
if "rubric" in all_text.lower() and "Score this build session" in all_text:
|
||||
return json.dumps(
|
||||
{
|
||||
"criteria": {
|
||||
"process_quality": 4,
|
||||
"correctness": 3,
|
||||
"debugging_discipline": 3,
|
||||
"test_usage": 4,
|
||||
},
|
||||
"strengths": ["Iterated with tests."],
|
||||
"gaps": ["One edge case missing."],
|
||||
"verdict": "developing",
|
||||
}
|
||||
)
|
||||
if "Explain it as coaching" in all_text:
|
||||
return COACHING_JSON
|
||||
if "integrity signals supportively" in all_text:
|
||||
return PROCTOR_JSON
|
||||
return json.dumps(
|
||||
{
|
||||
"statement": (
|
||||
"Build a judge for code-review answers scoring factual "
|
||||
"accuracy with 3 edge cases and 5 test examples."
|
||||
)
|
||||
}
|
||||
)
|
||||
return "Walk me through your last fix — what changed and why?"
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_credential_flow(tmp_path: Path) -> None:
|
||||
if not USERSNS_AVAILABLE:
|
||||
pytest.skip("user namespaces unavailable on this host (probe)")
|
||||
|
||||
import httpx
|
||||
|
||||
port = _free_port()
|
||||
settings = Settings(provider="mock", voice_provider="mock", port=port)
|
||||
app = create_app(settings)
|
||||
app.state.provider = FlowLLM()
|
||||
app.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
|
||||
app.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db")
|
||||
app.state.variant_store = SQLiteVariantStore(db_path=tmp_path / "v.db")
|
||||
app.state.defense_store = SQLiteDefenseStore(db_path=tmp_path / "d.db")
|
||||
app.state.trace_integrity = TraceIntegrityMap()
|
||||
|
||||
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning"))
|
||||
serve_task = asyncio.get_running_loop().create_task(server.serve())
|
||||
try:
|
||||
for _ in range(100):
|
||||
if server.started:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
assert server.started
|
||||
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
async with httpx.AsyncClient(base_url=base, timeout=30.0) as client:
|
||||
# 1. Variant (real seeded generation, mock-rendered).
|
||||
var = (await client.post("/v1/variants", json={
|
||||
"learner_id": "pilot-learner", "competency_id": "stack-orchestration-c007",
|
||||
})).json()
|
||||
assert var["task_id"] and var["statement"] and var["starter_files"]
|
||||
task_id = var["task_id"]
|
||||
|
||||
# 2. Telemetry-wired sandbox (real namespaces; agent joins via loopback).
|
||||
sbx = (await client.post("/v1/sandboxes", json={
|
||||
"learner_id": "pilot-learner", "task_id": task_id,
|
||||
}))
|
||||
assert sbx.status_code == 201, sbx.text
|
||||
sandbox_id = sbx.json()["id"]
|
||||
|
||||
# 3. Real in-sandbox exec: run the starter test (capture agent streams
|
||||
# the workspace effects into the trace).
|
||||
exec_resp = await client.post(f"/v1/sandboxes/{sandbox_id}/exec", json={
|
||||
"cmd": ["pytest", "-q"],
|
||||
})
|
||||
assert exec_resp.status_code == 200, exec_resp.text
|
||||
|
||||
# 4. Trace: events landed in order (the capture agent runs async).
|
||||
trace: list = []
|
||||
deadline = time.monotonic() + 20.0
|
||||
while time.monotonic() < deadline:
|
||||
tr = await client.get(f"/v1/telemetry/traces/pilot-learner/{task_id}")
|
||||
if tr.status_code == 200:
|
||||
trace = tr.json().get("events", [])
|
||||
if trace:
|
||||
break
|
||||
await asyncio.sleep(0.25)
|
||||
assert trace, "no telemetry events arrived from the real sandbox"
|
||||
seqs = [e["seq"] for e in trace]
|
||||
assert seqs == sorted(seqs)
|
||||
|
||||
# 5. Grade: rubric from the real digest (G-4 gate passed: no gaps).
|
||||
grade = (await client.post("/v1/assessment/grade", json={
|
||||
"learner_id": "pilot-learner", "task_id": task_id,
|
||||
})).json()
|
||||
assert grade["verdict"] == "GRADED", grade
|
||||
assert grade["scores"]["criteria"]["process_quality"] == 4
|
||||
assert grade["variant_seed"] == var["seed"] # D-029 stamped
|
||||
|
||||
# 6. Assessor coaching FROM the stored grade.
|
||||
coaching = (await client.post("/v1/assessment/evaluate", json={
|
||||
"learner_id": "pilot-learner", "task_id": task_id,
|
||||
})).json()
|
||||
assert coaching["coaching"]["summary"]
|
||||
|
||||
# 7. Oral defense: start -> typed answers -> finish (mock voice).
|
||||
defense = (await client.post("/v1/defense/start", json={
|
||||
"learner_id": "pilot-learner", "task_id": task_id,
|
||||
})).json()
|
||||
assert defense["first_question"]
|
||||
did = defense["defense_id"]
|
||||
ans = await client.post(f"/v1/defense/{did}/answer", data={"text": "I fixed the loop."})
|
||||
assert ans.status_code == 200, ans.text
|
||||
finish = (await client.post(f"/v1/defense/{did}/finish")).json()
|
||||
assert finish["verdict"]["verdict"] == "developing"
|
||||
assert finish["integrity_signals"]
|
||||
|
||||
# 8. Proctor over the real digest + defense signals.
|
||||
proctor = (await client.post("/v1/proctor/signals", json={
|
||||
"learner_id": "pilot-learner", "task_id": task_id,
|
||||
})).json()
|
||||
assert proctor["intervention"]
|
||||
|
||||
# 9. Sandbox destroyed; no leaks.
|
||||
destroy = await client.delete(f"/v1/sandboxes/{sandbox_id}")
|
||||
assert destroy.status_code == 204
|
||||
listed = (await client.get("/v1/sandboxes")).json()
|
||||
assert all(s["id"] != sandbox_id for s in (listed.get("sandboxes") or []))
|
||||
|
||||
# 10. No corpus fixtures anywhere in this flow's payloads.
|
||||
corpus_markers = ("lab-scenario", "proctor-scenario", "artifact-")
|
||||
for payload in (var, grade, defense, finish, proctor):
|
||||
assert not any(
|
||||
m in json.dumps(payload) for m in corpus_markers
|
||||
), "corpus fixture leaked into the learner path"
|
||||
finally:
|
||||
server.should_exit = True
|
||||
with contextlib.suppress(Exception):
|
||||
await asyncio.wait_for(serve_task, timeout=10.0)
|
||||
@@ -1,300 +1,38 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Play,
|
||||
Save,
|
||||
Upload,
|
||||
Folder,
|
||||
FileText,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@nextcraft/ui';
|
||||
import { allCompetencies, competencyStacks, aiLabScenarios } from '@nextcraft/mock-data';
|
||||
import { LabFeedbackPanel } from '../../../../components/learner/lab-feedback-panel';
|
||||
import { ArrowLeft, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { competencyStacks } from '@nextcraft/mock-data';
|
||||
import { BuildSurface } from '../../../../components/learner/build-surface';
|
||||
|
||||
interface FileEntry {
|
||||
label: string;
|
||||
children?: { label: string }[];
|
||||
}
|
||||
|
||||
const FILE_TREE: FileEntry[] = [
|
||||
{
|
||||
label: 'src/',
|
||||
children: [
|
||||
{ label: 'main.ts' },
|
||||
{ label: 'agent.ts' },
|
||||
{ label: 'tools.ts' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'tests/',
|
||||
children: [{ label: 'agent.test.ts' }],
|
||||
},
|
||||
{ label: 'README.md' },
|
||||
];
|
||||
|
||||
const EDITOR_LINES = [
|
||||
{ n: 1, content: "import { ChatOpenAI } from '@langchain/openai';" },
|
||||
{ n: 2, content: "import { tool } from '@langchain/core/tools';" },
|
||||
{ n: 3, content: "import { z } from 'zod';" },
|
||||
{ n: 4, content: '' },
|
||||
{ n: 5, content: '// Define a typed tool for fetching the current weather' },
|
||||
{ n: 6, content: "const getWeather = tool(" },
|
||||
{ n: 7, content: ' async ({ city, units }) => {' },
|
||||
{ n: 8, content: ' const res = await fetch(`/api/weather?city=${city}&units=${units}`);' },
|
||||
{ n: 9, content: ' return res.json();' },
|
||||
{ n: 10, content: ' },' },
|
||||
{ n: 11, content: ' {' },
|
||||
{ n: 12, content: " name: 'get_weather'," },
|
||||
{ n: 13, content: " description: 'Fetch the current weather for a city'," },
|
||||
{ n: 14, content: ' schema: z.object({' },
|
||||
{ n: 15, content: " city: z.string().describe('City to fetch weather for')," },
|
||||
{ n: 16, content: " units: z.enum(['celsius', 'fahrenheit']).default('celsius')," },
|
||||
{ n: 17, content: ' }),' },
|
||||
{ n: 18, content: ' },' },
|
||||
{ n: 19, content: ');' },
|
||||
{ n: 20, content: '' },
|
||||
{ n: 21, content: 'export async function main(query: string) {' },
|
||||
{ n: 22, content: ' const model = new ChatOpenAI({ model: "gpt-4o-mini" });' },
|
||||
{ n: 23, content: ' const modelWithTools = model.bindTools([getWeather]);' },
|
||||
{ n: 24, content: ' const response = await modelWithTools.invoke(query);' },
|
||||
{ n: 25, content: ' return response.tool_calls;' },
|
||||
{ n: 26, content: '}' },
|
||||
];
|
||||
|
||||
const TELEMETRY_METRICS = [
|
||||
{ label: 'Commits', value: '7' },
|
||||
{ label: 'Keystrokes', value: '1,247' },
|
||||
{ label: 'Time spent', value: '23 min' },
|
||||
{ label: 'Build attempts', value: '3' },
|
||||
];
|
||||
|
||||
const TELEMETRY_EVENTS = [
|
||||
{ time: '14:02:11', action: 'File created: src/main.ts' },
|
||||
{ time: '14:09:48', action: 'First build attempt (failed)' },
|
||||
{ time: '14:12:30', action: 'Test suite passed (2/2)' },
|
||||
{ time: '14:18:05', action: 'Commit: scaffold agent entrypoint' },
|
||||
{ time: '14:21:42', action: 'Tool schema validated' },
|
||||
{ time: '14:25:17', action: 'Build attempt 2 (success)' },
|
||||
{ time: '14:28:03', action: 'Commit: implement tool calling' },
|
||||
];
|
||||
|
||||
function highlight(line: string): { text: string; cls: string }[] {
|
||||
// Very small token highlighter for the mock editor
|
||||
const tokens: { text: string; cls: string }[] = [];
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
// comment
|
||||
if (line.slice(i).startsWith('//')) {
|
||||
tokens.push({ text: line.slice(i), cls: 'text-slate-500' });
|
||||
break;
|
||||
}
|
||||
// string with backtick or single/double quote
|
||||
const ch = line[i];
|
||||
if (ch === '`' || ch === "'" || ch === '"') {
|
||||
const end = line.indexOf(ch, i + 1);
|
||||
if (end !== -1) {
|
||||
tokens.push({ text: line.slice(i, end + 1), cls: 'text-emerald-300' });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// keyword
|
||||
const rest = line.slice(i);
|
||||
const kwMatch = rest.match(/^(import|from|export|async|function|const|return|await|new)/);
|
||||
if (kwMatch) {
|
||||
tokens.push({ text: kwMatch[0], cls: 'text-primary-300' });
|
||||
i += kwMatch[0].length;
|
||||
continue;
|
||||
}
|
||||
// default: consume one char
|
||||
tokens.push({ text: ch, cls: 'text-slate-200' });
|
||||
i += 1;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export default async function BuildSandboxPage({
|
||||
/**
|
||||
* Real build surface (REQ-3-008): a per-learner variant task + a live
|
||||
* namespace sandbox with file CRUD and Run/Test (CUT-2: read-only output,
|
||||
* no interactive shell). The v0.1 static mockup is retired.
|
||||
*/
|
||||
export default async function BuildPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ competencyId: string }>;
|
||||
}) {
|
||||
const { competencyId } = await params;
|
||||
const competency = allCompetencies.find((c) => c.id === competencyId);
|
||||
if (!competency) notFound();
|
||||
const stack = competencyStacks.find((s) => s.id === competency.stackId);
|
||||
const known = competencyStacks.some((stack) =>
|
||||
stack.competencies.some((c) => c.id === competencyId),
|
||||
);
|
||||
if (!known) notFound();
|
||||
const stack = competencyStacks.find((s) =>
|
||||
s.competencies.some((c) => c.id === competencyId),
|
||||
)!;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||
<Link
|
||||
href={`/learn/${competency.id}`}
|
||||
className="inline-flex w-fit items-center gap-1 text-sm text-slate-500 hover:text-slate-800 dark:hover:text-slate-200"
|
||||
href={`/learn/${competencyId}`}
|
||||
className="inline-flex items-center gap-1 text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back to Byte
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden />
|
||||
Back to tutorial
|
||||
</Link>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-slate-200 bg-white px-4 py-3 dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="rounded-md bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">
|
||||
Sandbox
|
||||
</span>
|
||||
<span className="text-sm font-medium text-slate-800 dark:text-slate-100">
|
||||
{competency.name}
|
||||
</span>
|
||||
<span className="hidden text-xs text-slate-500 sm:inline dark:text-slate-400">
|
||||
{stack?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md bg-emerald-600 px-3 text-xs font-medium text-white transition-colors hover:bg-emerald-700"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Run
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-slate-300 px-3 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-slate-300 px-3 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IDE layout */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[14rem_1fr_16rem]">
|
||||
{/* File explorer */}
|
||||
<aside className="rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-900">
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Explorer
|
||||
</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{FILE_TREE.map((node) =>
|
||||
node.children ? (
|
||||
<li key={node.label}>
|
||||
<div className="flex items-center gap-1.5 text-slate-700 dark:text-slate-200">
|
||||
<Folder className="h-3.5 w-3.5 text-amber-500" />
|
||||
{node.label}
|
||||
</div>
|
||||
<ul className="ml-4 mt-1 space-y-1">
|
||||
{node.children.map((child) => (
|
||||
<li
|
||||
key={child.label}
|
||||
className="flex items-center gap-1.5 text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 text-slate-400" />
|
||||
{child.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
) : (
|
||||
<li
|
||||
key={node.label}
|
||||
className="flex items-center gap-1.5 text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 text-slate-400" />
|
||||
{node.label}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
{/* Editor */}
|
||||
<section className="overflow-hidden rounded-lg border border-slate-200 bg-slate-950 dark:border-slate-800">
|
||||
<div className="flex items-center gap-2 border-b border-slate-800 px-3 py-2 text-xs text-slate-400">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
src/main.ts
|
||||
</div>
|
||||
<pre className="overflow-auto p-3 font-mono text-xs leading-relaxed">
|
||||
<code>
|
||||
{EDITOR_LINES.map((line) => (
|
||||
<div key={line.n} className="flex">
|
||||
<span className="mr-4 inline-block w-8 select-none text-right text-slate-600">
|
||||
{line.n}
|
||||
</span>
|
||||
<span className="flex-1 whitespace-pre">
|
||||
{line.content === '' ? (
|
||||
<span> </span>
|
||||
) : (
|
||||
highlight(line.content).map((t, idx) => (
|
||||
<span key={idx} className={t.cls}>
|
||||
{t.text}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Telemetry */}
|
||||
<aside className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-3 dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-primary-600" />
|
||||
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||
Process Capture
|
||||
</h3>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-2">
|
||||
{TELEMETRY_METRICS.map((m) => (
|
||||
<div
|
||||
key={m.label}
|
||||
className="rounded-md border border-slate-200 bg-slate-50 p-2 text-center dark:border-slate-800 dark:bg-slate-900/50"
|
||||
>
|
||||
<dd className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
{m.value}
|
||||
</dd>
|
||||
<dt className="text-[10px] uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
{m.label}
|
||||
</dt>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div>
|
||||
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Recent events
|
||||
</h4>
|
||||
<ul className="space-y-1.5 text-xs">
|
||||
{TELEMETRY_EVENTS.map((e, i) => (
|
||||
<li key={i} className="flex gap-2">
|
||||
<span className="font-mono text-slate-400">{e.time}</span>
|
||||
<span className="text-slate-700 dark:text-slate-300">{e.action}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{/* Lab in-flow feedback — mock telemetry scenario (real engine v0.3+) */}
|
||||
<div className="border-t border-slate-200 pt-3 dark:border-slate-800">
|
||||
<LabFeedbackPanel scenarioId={aiLabScenarios[0].id} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end">
|
||||
<Link href={`/defend/${competency.id}`}>
|
||||
<Button iconRight={<ArrowRight className="h-4 w-4" />}>
|
||||
Submit for Assessment
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<BuildSurface competencyId={competencyId} stackTitle={stack.name} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +1,15 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Bot,
|
||||
Award,
|
||||
Code,
|
||||
GitCommit,
|
||||
FileText,
|
||||
PlayCircle,
|
||||
TestTube,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardBody, CardHeader, Badge, Button } from '@nextcraft/ui';
|
||||
import { allCompetencies, competencyStacks } from '@nextcraft/mock-data';
|
||||
import { OralDefenseInterface } from '../../../../components/learner/oral-defense-interface';
|
||||
import { AssessorResultsPanel } from '../../../../components/learner/assessor-results-panel';
|
||||
import { ProctorBanner } from '../../../../components/learner/proctor-banner';
|
||||
import { aiArtifactSubmissions } from '@nextcraft/mock-data';
|
||||
|
||||
const RUBRIC = [
|
||||
{ name: 'Correctness of agent architecture', passed: true, weight: 25 },
|
||||
{ name: 'Tool schema design & validation', passed: true, weight: 20 },
|
||||
{ name: 'Error handling & fallbacks', passed: false, weight: 20 },
|
||||
{ name: 'Test coverage', passed: true, weight: 15 },
|
||||
{ name: 'Code clarity & documentation', passed: true, weight: 20 },
|
||||
];
|
||||
|
||||
const CRITERION_SCORES: Record<string, number> = {
|
||||
'Correctness of agent architecture': 92,
|
||||
'Tool schema design & validation': 88,
|
||||
'Error handling & fallbacks': 61,
|
||||
'Test coverage': 84,
|
||||
'Code clarity & documentation': 90,
|
||||
};
|
||||
|
||||
const OVERALL_SCORE = 84;
|
||||
|
||||
const TRACE_EVENTS = [
|
||||
{ icon: FileText, label: 'File created: src/main.ts', time: '14:02:11' },
|
||||
{ icon: PlayCircle, label: 'First build attempt (failed)', time: '14:09:48' },
|
||||
{ icon: TestTube, label: 'Test suite passed (2/2)', time: '14:12:30' },
|
||||
{ icon: GitCommit, label: 'Commit: scaffold agent entrypoint', time: '14:18:05' },
|
||||
{ icon: ShieldCheck, label: 'Tool schema validated', time: '14:21:42' },
|
||||
{ icon: PlayCircle, label: 'Build attempt 2 (success)', time: '14:25:17' },
|
||||
{ icon: GitCommit, label: 'Commit: implement tool calling', time: '14:28:03' },
|
||||
{ icon: Award, label: 'Artifact submitted for assessment', time: '14:31:50' },
|
||||
];
|
||||
|
||||
const SUBMITTED_CODE = `import { ChatOpenAI } from '@langchain/openai';
|
||||
import { tool } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
|
||||
const getWeather = tool(
|
||||
async ({ city, units }) => {
|
||||
const res = await fetch(\`/api/weather?city=\${city}&units=\${units}\`);
|
||||
return res.json();
|
||||
},
|
||||
{
|
||||
name: 'get_weather',
|
||||
description: 'Fetch the current weather for a city',
|
||||
schema: z.object({
|
||||
city: z.string(),
|
||||
units: z.enum(['celsius', 'fahrenheit']).default('celsius'),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
export async function main(query: string) {
|
||||
const model = new ChatOpenAI({ model: 'gpt-4o-mini' });
|
||||
const modelWithTools = model.bindTools([getWeather]);
|
||||
const response = await modelWithTools.invoke(query);
|
||||
return response.tool_calls;
|
||||
}`;
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { allCompetencies } from '@nextcraft/mock-data';
|
||||
import { DefenseSession } from '../../../../components/learner/defense-session';
|
||||
|
||||
/**
|
||||
* Live defense surface (REQ-3-006 + REQ-3-008): a real oral defense over
|
||||
* the learner's task + a live grading panel — no mock rubric data. The
|
||||
* v0.1 static assessment mockup (pre-baked rubric scores, scripted
|
||||
* transcript, submitted-code display) is retired.
|
||||
*/
|
||||
export default async function DefensePage({
|
||||
params,
|
||||
}: {
|
||||
@@ -83,250 +18,27 @@ export default async function DefensePage({
|
||||
const { competencyId } = await params;
|
||||
const competency = allCompetencies.find((c) => c.id === competencyId);
|
||||
if (!competency) notFound();
|
||||
const stack = competencyStacks.find((s) => s.id === competency.stackId);
|
||||
|
||||
const radius = 36;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (OVERALL_SCORE / 100) * circumference;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-4 py-8">
|
||||
<Link
|
||||
href={`/build/${competency.id}`}
|
||||
className="inline-flex w-fit items-center gap-1 text-sm text-slate-500 hover:text-slate-800 dark:hover:text-slate-200"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
<ArrowLeft className="h-3.5 w-3.5" aria-hidden />
|
||||
Back to build
|
||||
</Link>
|
||||
|
||||
<header className="flex flex-col gap-2">
|
||||
<Badge variant="warning">Assessment · Defense</Badge>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
{competency.name}
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
Oral Defense — {competency.name}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{stack?.name} · oral defense and rubric review
|
||||
The Examiner will question you about your build session: what you
|
||||
did, why, and what you would change. Answer by voice or typing —
|
||||
then request your process-trace grade.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Artifact viewer */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="h-4 w-4 text-primary-600" />
|
||||
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">
|
||||
Submitted Artifact
|
||||
</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<pre className="overflow-auto rounded-lg bg-slate-950 p-4 font-mono text-xs leading-relaxed text-slate-100">
|
||||
<code>{SUBMITTED_CODE}</code>
|
||||
</pre>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Rubric + AI reviewer */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{/* Rubric */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">
|
||||
Assessment rubric
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Criteria and weights
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
{RUBRIC.map((c) => {
|
||||
const Icon = c.passed ? CheckCircle : XCircle;
|
||||
return (
|
||||
<div
|
||||
key={c.name}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-slate-200 p-3 dark:border-slate-800"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon
|
||||
className={`h-4 w-4 ${
|
||||
c.passed
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-rose-600 dark:text-rose-400'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm text-slate-800 dark:text-slate-200">
|
||||
{c.name}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant={c.passed ? 'success' : 'error'}>{c.weight}%</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* AI reviewer panel */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">
|
||||
<Bot className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">
|
||||
AI Assessor
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Automated review results
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
{/* Overall score */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative h-24 w-24 shrink-0">
|
||||
<svg viewBox="0 0 100 100" className="h-full w-full -rotate-90">
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
className="text-slate-200 dark:text-slate-800"
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="text-primary-600"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{OVERALL_SCORE}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
Overall score
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Pass threshold: 75 ·{' '}
|
||||
<span className="text-emerald-600 dark:text-emerald-400">Passing</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-criterion scores */}
|
||||
<ul className="flex flex-col gap-2">
|
||||
{RUBRIC.map((c) => {
|
||||
const score = CRITERION_SCORES[c.name] ?? 0;
|
||||
return (
|
||||
<li key={c.name} className="flex items-center gap-3">
|
||||
<span className="flex-1 truncate text-xs text-slate-700 dark:text-slate-300">
|
||||
{c.name}
|
||||
</span>
|
||||
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
score >= 75 ? 'bg-emerald-500' : 'bg-amber-500'
|
||||
}`}
|
||||
style={{ width: `${score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-right text-xs font-medium text-slate-700 dark:text-slate-300">
|
||||
{score}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="rounded-md bg-slate-50 p-3 text-xs text-slate-700 dark:bg-slate-900/50 dark:text-slate-300">
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">Feedback</p>
|
||||
<p className="mt-1">
|
||||
Strong tool-schema design and clear architecture. Error handling loses points:
|
||||
malformed model output is not guarded with a fallback parser. Add a retry with
|
||||
a structured-output schema and re-run the eval harness before your oral defense.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Live Assessor — structured rubric from the real agent (mock inputs) */}
|
||||
<div className="border-t border-slate-200 pt-3 dark:border-slate-800">
|
||||
<AssessorResultsPanel artifactId={aiArtifactSubmissions[0].id} />
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Proctor integrity banner — coaching-shaped (mock telemetry) */}
|
||||
<Card>
|
||||
<CardBody>
|
||||
<ProctorBanner scenarioId="proctor-scenario-distracted" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Process trace timeline */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">
|
||||
Process trace
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Build history captured during the sandbox session
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<ol className="relative border-l border-slate-200 pl-6 dark:border-slate-800">
|
||||
{TRACE_EVENTS.map((e, i) => {
|
||||
const Icon = e.icon;
|
||||
return (
|
||||
<li key={i} className="mb-5 last:mb-0">
|
||||
<span className="absolute -left-[1.15rem] flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">
|
||||
<Icon className="h-3 w-3" />
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="text-sm text-slate-800 dark:text-slate-200">
|
||||
{e.label}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-slate-400">{e.time}</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Oral defense */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">
|
||||
Oral defense
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Live oral exam with an AI examiner
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<OralDefenseInterface />
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Link href={`/catalog/${competency.stackId}`}>
|
||||
<Button variant="outline">
|
||||
Return to stack
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<DefenseSession taskId={`task-${competencyId}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { AgentStreamPanel } from './agent-stream-panel';
|
||||
import { AlertTriangle, CheckCircle2, CircleDashed } from 'lucide-react';
|
||||
|
||||
interface CriterionScore {
|
||||
criterion_id: string;
|
||||
name: string;
|
||||
score: number;
|
||||
evidence: string;
|
||||
}
|
||||
|
||||
interface RubricScore {
|
||||
rubric_id: string;
|
||||
artifact_id: string;
|
||||
competency_id: string;
|
||||
scores: CriterionScore[];
|
||||
strengths: string[];
|
||||
gaps: string[];
|
||||
verdict: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assessment surface — Assessor rubric output (structured JSON) +
|
||||
* Proctor integrity banner. Mock engine inputs; real engines v0.3+.
|
||||
*/
|
||||
export function AssessorResultsPanel({ artifactId }: { artifactId: string }) {
|
||||
return (
|
||||
<AgentStreamPanel
|
||||
title="Assessor — rubric evaluation"
|
||||
endpoint="/v1/assessment/evaluate"
|
||||
body={{ artifact_id: artifactId }}
|
||||
emptyHint="Run the Assessor to grade this artifact against its rubric."
|
||||
renderJson={(data) => {
|
||||
const score = data as unknown as RubricScore;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{score.verdict === 'mastered' ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
) : score.verdict === 'developing' ? (
|
||||
<CircleDashed className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
) : (
|
||||
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
)}
|
||||
<span className="text-sm font-semibold capitalize text-slate-900 dark:text-slate-100">
|
||||
{score.verdict}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{score.scores.map((c) => (
|
||||
<div key={c.criterion_id}>
|
||||
<div className="mb-1 flex items-center justify-between text-xs">
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{c.name}</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{c.score}/100</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary-500 transition-all"
|
||||
style={{ width: `${c.score}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={c.score}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={c.name}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{c.evidence}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-emerald-700 dark:text-emerald-400">
|
||||
Strengths
|
||||
</h4>
|
||||
<ul className="list-inside list-disc text-xs text-slate-600 dark:text-slate-300">
|
||||
{score.strengths.map((s) => (
|
||||
<li key={s}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-amber-700 dark:text-amber-400">
|
||||
Gaps
|
||||
</h4>
|
||||
<ul className="list-inside list-disc text-xs text-slate-600 dark:text-slate-300">
|
||||
{score.gaps.map((g) => (
|
||||
<li key={g}>{g}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AlertTriangle, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@nextcraft/ui';
|
||||
import {
|
||||
MOCK_LEARNER_ID,
|
||||
getTrace,
|
||||
type ExecResult,
|
||||
} from '../../lib/engine-client';
|
||||
import { useSandboxSession } from '../../hooks/use-sandbox-session';
|
||||
import { FileTree } from './file-tree';
|
||||
import { RunControls } from './run-controls';
|
||||
import { SandboxTerminal } from './sandbox-terminal';
|
||||
import { LabFeedbackPanel } from './lab-feedback-panel';
|
||||
|
||||
/**
|
||||
* The real build environment (REQ-3-008):
|
||||
* variant statement + starter files in a live namespace sandbox, file CRUD,
|
||||
* Run/Test with read-only output (CUT-2), live telemetry status, and Lab
|
||||
* feedback over the live trace. Honest states throughout: busy (503 →
|
||||
* retry), denied (403/429), error + retry.
|
||||
*/
|
||||
export function BuildSurface({
|
||||
competencyId,
|
||||
stackTitle,
|
||||
}: {
|
||||
competencyId: string;
|
||||
stackTitle: string;
|
||||
}) {
|
||||
const session = useSandboxSession(competencyId);
|
||||
const [activePath, setActivePath] = useState<string | null>(null);
|
||||
const [editorContent, setEditorContent] = useState('');
|
||||
const [execResult, setExecResult] = useState<ExecResult | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [telemetryCount, setTelemetryCount] = useState(0);
|
||||
|
||||
// Load the active file into the editor when selection changes.
|
||||
useEffect(() => {
|
||||
if (!activePath) return;
|
||||
void session.openFile(activePath).then((content) => {
|
||||
if (content !== null) setEditorContent(content);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- openFile is stable per sandbox
|
||||
}, [activePath]);
|
||||
|
||||
// Pick the README (or first file) once files land.
|
||||
useEffect(() => {
|
||||
if (!activePath && session.files.length > 0) {
|
||||
setActivePath(session.files.find((f) => f === 'README.md') ?? session.files[0]);
|
||||
}
|
||||
}, [session.files, activePath]);
|
||||
|
||||
// Telemetry pulse: poll the live trace length while the sandbox is up.
|
||||
useEffect(() => {
|
||||
if (!session.variant || !session.sandboxId) return;
|
||||
let cancelled = false;
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const trace = await getTrace(MOCK_LEARNER_ID, session.variant!.task_id);
|
||||
if (!cancelled) setTelemetryCount(trace.length);
|
||||
} catch {
|
||||
/* service down: the status indicator shows disconnected */
|
||||
}
|
||||
}, 2500);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [session.variant, session.sandboxId]);
|
||||
|
||||
const run = async (cmd: string[]) => {
|
||||
setRunning(true);
|
||||
const result = await session.run(cmd);
|
||||
setExecResult(result);
|
||||
setRunning(false);
|
||||
void session.refreshFiles();
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!activePath) return;
|
||||
await session.saveFile(activePath, editorContent);
|
||||
};
|
||||
|
||||
if (session.status === 'starting' || session.status === 'idle') {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-16 text-slate-500 dark:text-slate-400">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary-500" aria-hidden />
|
||||
<p className="text-sm">Creating your build environment…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (session.status === 'busy') {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-center gap-3 rounded-lg border border-amber-300 bg-amber-50 px-6 py-10 text-center dark:border-amber-700 dark:bg-amber-900/30"
|
||||
>
|
||||
<AlertTriangle className="h-6 w-6 text-amber-600 dark:text-amber-400" aria-hidden />
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
Environment busy — all sandbox slots are taken.
|
||||
</p>
|
||||
<Button onClick={session.retry} variant="outline" size="sm">
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden /> Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (session.status === 'denied' || session.status === 'error') {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-center gap-3 rounded-lg border border-red-300 bg-red-50 px-6 py-10 text-center dark:border-red-800 dark:bg-red-900/30"
|
||||
>
|
||||
<AlertTriangle className="h-6 w-6 text-red-500" aria-hidden />
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300">
|
||||
{session.errorMessage ?? 'Could not start the build environment.'}
|
||||
</p>
|
||||
<Button onClick={session.retry} variant="outline" size="sm">
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden /> Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary-600 dark:text-primary-400">
|
||||
{stackTitle} · build
|
||||
</p>
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{session.variant?.statement ?? 'Your task'}
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
task {session.variant?.task_id} · variant seed{' '}
|
||||
<code className="font-mono">{session.variant?.seed.slice(0, 12)}…</code>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<RunControls
|
||||
command="python -m pytest -q"
|
||||
running={running}
|
||||
busy={false}
|
||||
onRun={() => void run(['python', '-m', 'pytest', '-q'])}
|
||||
onTest={() => void run(['pytest', '-q'])}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<aside className="rounded-lg border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-900">
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Workspace
|
||||
</h2>
|
||||
<FileTree
|
||||
files={session.files}
|
||||
activePath={activePath}
|
||||
onSelect={setActivePath}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<div className="space-y-4">
|
||||
<section 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 justify-between">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
{activePath ?? 'editor'}
|
||||
</h2>
|
||||
<Button onClick={() => void save()} variant="outline" size="sm">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
value={editorContent}
|
||||
onChange={(e) => setEditorContent(e.target.value)}
|
||||
spellCheck={false}
|
||||
aria-label={`Editing ${activePath ?? 'file'}`}
|
||||
className="h-64 w-full resize-y rounded-md border border-slate-200 bg-slate-50 p-3 font-mono text-xs leading-relaxed text-slate-800 focus:border-primary-400 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<SandboxTerminal
|
||||
result={execResult}
|
||||
running={running}
|
||||
telemetryActive={session.sandboxId !== null && telemetryCount > 0}
|
||||
eventCount={telemetryCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{session.variant && (
|
||||
<LabFeedbackPanel learnerId={MOCK_LEARNER_ID} taskId={session.variant.task_id} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FileText, Folder } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Real workspace file tree (REQ-3-008): files come from the sandbox workdir
|
||||
* via the engine client; selecting a file loads its content into the editor.
|
||||
*/
|
||||
export function FileTree({
|
||||
files,
|
||||
activePath,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: {
|
||||
files: string[];
|
||||
activePath: string | null;
|
||||
onSelect: (path: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [dirs, setDirs] = useState<Record<string, string[]>>({});
|
||||
|
||||
useEffect(() => {
|
||||
// Group by top-level segment; the flat workspace is v0.3's shape.
|
||||
const grouped: Record<string, string[]> = {};
|
||||
for (const f of files) {
|
||||
const seg = f.includes('/') ? f.split('/')[0] + '/' : '';
|
||||
(grouped[seg] ??= []).push(f);
|
||||
}
|
||||
setDirs(grouped);
|
||||
}, [files]);
|
||||
|
||||
return (
|
||||
<ul
|
||||
role="tree"
|
||||
aria-label="Workspace files"
|
||||
className="space-y-0.5 text-xs text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
{Object.entries(dirs).map(([group, paths]) => (
|
||||
<li key={group || '__root__'} role="treeitem">
|
||||
{group ? (
|
||||
<span className="flex items-center gap-1 py-0.5 font-medium text-slate-600 dark:text-slate-400">
|
||||
<Folder className="h-3.5 w-3.5" aria-hidden />
|
||||
{group}
|
||||
</span>
|
||||
) : null}
|
||||
<ul className={group ? 'ml-4 space-y-0.5' : 'space-y-0.5'}>
|
||||
{paths.map((path) => {
|
||||
const name = path.split('/').pop() ?? path;
|
||||
const active = activePath === path;
|
||||
return (
|
||||
<li key={path}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(path)}
|
||||
disabled={disabled}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
className={`flex w-full items-center gap-1 rounded px-1.5 py-1 text-left transition-colors disabled:opacity-50 ${
|
||||
active
|
||||
? 'bg-primary-100 text-primary-900 dark:bg-primary-900/40 dark:text-primary-100'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span className="truncate">{name}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
{files.length === 0 && (
|
||||
<li className="px-1 py-2 text-slate-500 dark:text-slate-400">No files yet.</li>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -3,16 +3,18 @@
|
||||
import { AgentStreamPanel } from './agent-stream-panel';
|
||||
|
||||
/**
|
||||
* Sandbox Lab feedback panel — streams in-flow feedback for the selected
|
||||
* mock telemetry scenario (real telemetry is v0.3+).
|
||||
* Lab feedback panel over the LIVE trace (REQ-3-007).
|
||||
*
|
||||
* v0.3 re-grounding: posts {learner_id, task_id} — the Lab agent consumes
|
||||
* the learner's real build-session digest, not a mock scenario.
|
||||
*/
|
||||
export function LabFeedbackPanel({ scenarioId }: { scenarioId: string }) {
|
||||
export function LabFeedbackPanel({ learnerId, taskId }: { learnerId: string; taskId: string }) {
|
||||
return (
|
||||
<AgentStreamPanel
|
||||
title="Lab — in-flow feedback"
|
||||
endpoint="/v1/lab/feedback"
|
||||
body={{ scenario_id: scenarioId }}
|
||||
emptyHint="Run the Lab agent on this build session's telemetry."
|
||||
body={{ learner_id: learnerId, task_id: taskId }}
|
||||
emptyHint="Run a command or a test, then ask Lab for feedback on your live session."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Mic, Send } from 'lucide-react';
|
||||
|
||||
const TRANSCRIPT = [
|
||||
{
|
||||
role: 'examiner' as const,
|
||||
question:
|
||||
"Walk us through your design choices for multi-agent communication in this artifact. Why a blackboard architecture over direct message passing?",
|
||||
},
|
||||
{
|
||||
role: 'learner' as const,
|
||||
answer:
|
||||
"I chose a shared blackboard because the agents publish partial results that others consume asynchronously — direct messaging would have tightly coupled them and made re-planning harder. The blackboard also gives me a clean audit trail for each step.",
|
||||
},
|
||||
{
|
||||
role: 'examiner' as const,
|
||||
question:
|
||||
"What failure mode did you observe under load, and how did you mitigate it?",
|
||||
},
|
||||
{
|
||||
role: 'learner' as const,
|
||||
answer:
|
||||
"At 50 concurrent requests the planner became a bottleneck because every agent waited on a fresh plan. I added a plan cache keyed by intent signature and moved re-planning to a debounce — throughput improved 3x with no measurable quality regression.",
|
||||
},
|
||||
];
|
||||
|
||||
export function OralDefenseInterface() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Mic + waveform */}
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Start oral defense"
|
||||
className="group relative flex h-20 w-20 items-center justify-center rounded-full bg-primary-600 text-white transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
<span className="absolute inset-0 animate-ping rounded-full bg-primary-500/30 group-hover:opacity-100 opacity-0 transition-opacity" />
|
||||
<Mic className="h-8 w-8" />
|
||||
</button>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
Start Oral Defense
|
||||
</p>
|
||||
|
||||
{/* Waveform mockup */}
|
||||
<div className="flex items-center gap-1" aria-hidden>
|
||||
{[12, 24, 16, 32, 20, 40, 28, 18, 36, 22, 14, 30, 20, 12, 26, 34, 18, 10, 28, 16].map(
|
||||
(h, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="w-1 rounded-full bg-primary-500/70"
|
||||
style={{
|
||||
height: `${h}px`,
|
||||
animation: `pulse 1.2s ease-in-out ${i * 0.06}s infinite`,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transcript */}
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/50">
|
||||
<h4 className="mb-3 text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
Defense transcript
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
{TRANSCRIPT.map((turn, i) =>
|
||||
turn.role === 'examiner' ? (
|
||||
<div key={i} className="flex gap-3">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary-100 text-primary-700 text-xs dark:bg-primary-900/40 dark:text-primary-300">
|
||||
AI
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-400">
|
||||
Examiner
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-slate-700 dark:text-slate-200">
|
||||
{turn.question}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={i} className="flex flex-row-reverse gap-3">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-slate-200 text-slate-700 text-xs dark:bg-slate-700 dark:text-slate-200">
|
||||
AR
|
||||
</span>
|
||||
<div className="max-w-[80%]">
|
||||
<p className="text-right text-xs font-medium uppercase tracking-wide text-slate-400">
|
||||
Learner
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-slate-700 dark:text-slate-200">
|
||||
{turn.answer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-10 items-center gap-2 rounded-md bg-primary-600 px-4 text-sm font-medium text-white transition-colors hover:bg-primary-700"
|
||||
>
|
||||
Submit Defense
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { AgentStreamPanel } from './agent-stream-panel';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
|
||||
interface IntegritySignal {
|
||||
signal_type: string;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
note: string;
|
||||
}
|
||||
|
||||
interface ProctorAssessment {
|
||||
scenario_id: string;
|
||||
signals: IntegritySignal[];
|
||||
intervention: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
const SEVERITY_STYLES: Record<string, string> = {
|
||||
low: 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-300 dark:border-emerald-800',
|
||||
medium: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-300 dark:border-amber-800',
|
||||
high: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800',
|
||||
};
|
||||
|
||||
/**
|
||||
* Proctor integrity banner — supportive, coaching-shaped (never punitive).
|
||||
*/
|
||||
export function ProctorBanner({ scenarioId }: { scenarioId: string }) {
|
||||
return (
|
||||
<AgentStreamPanel
|
||||
title="Proctor — integrity support"
|
||||
endpoint="/v1/proctor/signals"
|
||||
body={{ scenario_id: scenarioId }}
|
||||
emptyHint="Run the Proctor to review this session's integrity signals."
|
||||
renderJson={(data) => {
|
||||
const assessment = data as unknown as ProctorAssessment;
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-slate-600 dark:text-slate-300">{assessment.summary}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{assessment.signals.map((s, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs ${SEVERITY_STYLES[s.severity] ?? SEVERITY_STYLES.low}`}
|
||||
title={s.note}
|
||||
>
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
{s.signal_type} · {s.severity}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-800 dark:bg-primary-900/30 dark:text-primary-200">
|
||||
<strong>Suggested next step:</strong> {assessment.intervention}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, Play, RefreshCw, TestTube2 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Run/Test controls for the real build surface (REQ-3-008, CUT-2).
|
||||
*
|
||||
* Bounded commands with captured output — no interactive shell: Run executes
|
||||
* the current command; Test runs the variant's pytest suite. Output renders
|
||||
* in the read-only TerminalFrame panel.
|
||||
*/
|
||||
export function RunControls({
|
||||
command,
|
||||
running,
|
||||
busy,
|
||||
onRun,
|
||||
onTest,
|
||||
disabled,
|
||||
}: {
|
||||
command: string;
|
||||
running: boolean;
|
||||
busy: boolean;
|
||||
onRun: () => void;
|
||||
onTest: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label htmlFor="run-command" className="sr-only">
|
||||
Command to run in the sandbox
|
||||
</label>
|
||||
<input
|
||||
id="run-command"
|
||||
value={command}
|
||||
disabled={disabled || running}
|
||||
onChange={(e) => e.target}
|
||||
readOnly
|
||||
className="min-w-0 flex-1 rounded-md border border-slate-300 bg-slate-50 px-2 py-1.5 font-mono text-xs text-slate-700 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRun}
|
||||
disabled={disabled || running}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-primary-700 disabled:opacity-50 dark:bg-primary-500 dark:hover:bg-primary-400 dark:text-slate-950"
|
||||
>
|
||||
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> : <Play className="h-3.5 w-3.5" aria-hidden />}
|
||||
Run
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTest}
|
||||
disabled={disabled || running}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-primary-600 px-3 py-1.5 text-xs font-semibold text-primary-700 transition-colors hover:bg-primary-50 disabled:opacity-50 dark:border-primary-400 dark:text-primary-300 dark:hover:bg-primary-900/30"
|
||||
>
|
||||
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> : <TestTube2 className="h-3.5 w-3.5" aria-hidden />}
|
||||
Test
|
||||
</button>
|
||||
{busy && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
<RefreshCw className="h-3 w-3 animate-spin" aria-hidden />
|
||||
Starting environment…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { TerminalFrame } from '@nextcraft/ui';
|
||||
import type { TerminalLine } from '@nextcraft/ui';
|
||||
import { TelemetryStatus } from '@nextcraft/ui';
|
||||
import type { ExecResult } from '../../lib/engine-client';
|
||||
|
||||
/**
|
||||
* Read-only exec-output panel (CUT-2): Run/Test results stream here —
|
||||
* this is NOT an interactive shell. TelemetryStatus pulses while the
|
||||
* capture agent streams events to ai-service.
|
||||
*/
|
||||
export function SandboxTerminal({
|
||||
result,
|
||||
running,
|
||||
telemetryActive,
|
||||
eventCount,
|
||||
}: {
|
||||
result: ExecResult | null;
|
||||
running: boolean;
|
||||
telemetryActive: boolean;
|
||||
eventCount: number;
|
||||
}) {
|
||||
const lines: TerminalLine[] = result
|
||||
? [
|
||||
{ text: `$ ${result.cmd.join(' ')}` },
|
||||
...result.stdout.split('\n').filter(Boolean).map((t) => ({ text: t })),
|
||||
...result.stderr
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((t) => ({ text: t, error: true })),
|
||||
{
|
||||
text: `exit ${result.returncode} (${result.duration_s.toFixed(2)}s)`,
|
||||
error: result.returncode !== 0,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<TelemetryStatus active={telemetryActive} eventCount={eventCount} compact />
|
||||
<span className="text-[10px] uppercase tracking-wide text-slate-400 dark:text-slate-500">
|
||||
read-only output (interactive shell lands in v0.4)
|
||||
</span>
|
||||
</div>
|
||||
<TerminalFrame
|
||||
lines={lines}
|
||||
status={running ? 'streaming' : result ? 'finished' : 'idle'}
|
||||
title="Sandbox output"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user