Compare commits

...

4 Commits

Author SHA1 Message Date
CIAgent 88a1dab810 docs(milestone): complete v0.2-ai-tutor-architecture
---ci---
phase: 7
milestone: v0.2
status: complete
requirements:
  covered: [REQ-2-001, REQ-2-002, REQ-2-003, REQ-2-004, REQ-2-005, REQ-2-006, REQ-2-007, REQ-2-008, REQ-2-009, REQ-2-010, REQ-2-011, REQ-2-012]
  partial: []
---/ci---

Milestone v0.2 (ai-tutor-architecture) merged to main.

Escalation record (audit remediation, durable): P1 executor
delegation failed twice (empty subagent results, zero files
created); auto-resolved at full autonomy to inline execution with
identical plan fidelity (commit 3271373, reflog-only after phase
branch squash-delete).
2026-09-11 17:34:50 +00:00
CIAgent 71728e35c8 chore(P07): audit fixes — roadmap phase 7 complete, clean remote URL
---ci---
phase: 7
milestone: v0.1
status: complete
---/ci---

Audit fixes:
- ROADMAP.md: phase 7 status corrected from in_progress to complete
- Remote origin URL: removed embedded token (security)
- Deleted stale remote milestone branch
2026-09-11 13:53:18 +00:00
CIAgent daa0463a97 chore: pre-approve esbuild and core-js-pure build scripts
---ci---
phase: 7
milestone: v0.1
status: complete
---/ci---
2026-09-11 13:07:15 +00:00
CIAgent 810485ecc8 docs(milestone): complete v0.1-nextcraft-ui-prototype
---ci---
phase: 7
milestone: v0.1
status: complete
requirements:
  covered: [REQ-001, REQ-002, REQ-003, REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010, REQ-011, REQ-012, REQ-013, REQ-014, REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-020, REQ-021, REQ-022, REQ-023, REQ-024, REQ-025, REQ-026, REQ-027, REQ-028]
  partial: []
---/ci---

Milestone v0.1 (nextcraft-ui-prototype) complete.

Summary:
- 7 phases (P0 pre-execution + P1-P6 execution + P7 final review)
- 28 requirements covered (all complete)
- 4 surfaces: Learner (7 pages), Marketplace (5), Employer Dashboard (4), Admin (4)
- 19 routes, 104 TypeScript files
- Shared component library (5 primitives + design tokens)
- Mock data: 5 competency stacks (70 competencies), 20 jobs, 15 candidates, 10 employers
- Tech: Next.js 15, Tailwind CSS v4, lucide-react, recharts, @xyflow/react, Inter font
- Storybook with 6 stories
- Dark mode, breadcrumbs, role switcher, responsive design
- Build passes, typecheck passes, Storybook build passes

Phases:
  P0 pre-execution     → v0.0.1
  P1 project-scaffolding → v0.0.2
  P2 learner-surface   → v0.0.3
  P3 marketplace-surface → v0.0.4
  P4 employer-dashboard → v0.0.5
  P5 admin-surface     → v0.0.6
  P6 polish-integration → v0.0.7
  P7 final-review-ship → v0.1.0 (milestone release)
2026-09-10 22:47:56 +00:00
186 changed files with 24891 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
# Nextcraft — ARCHITECTURE.md
## Overview
Nextcraft is a TypeScript monorepo (pnpm workspaces + turborepo) with a Next.js application hosting four surfaces (Learner, Marketplace, Employer Dashboard, Admin), a shared component library, typed mock data layer, and shared types package. As of v0.2, a Python FastAPI application (`apps/ai-service`) hosts six AI tutor agents backed by a provider-agnostic LLM layer.
**v0.2 additions:** real AI services (streaming chat), agent framework, mock engine inputs for Lab/Assessor/Proctor. **Still no database, no auth** — in-memory session store; real engines (sandbox fabric, assessment engine, identity) are v0.3+.
### Confirmed Technology Stack (v0.2)
| Technology | Version | Purpose |
|------------|---------|---------|
| Node.js | v24.15.0 | Runtime (web) |
| pnpm | 12.3.4 | Package manager + workspaces |
| turborepo | 2.3.3 | Build orchestration |
| Next.js | 15 (App Router) | Web application framework |
| React | 19+ | UI library |
| TypeScript | 5.x | Type system |
| Tailwind CSS | v4 | Utility-first CSS |
| lucide-react | latest | Icons |
| recharts | latest | Charts |
| @xyflow/react | latest | Competency graph viewer |
| Python | 3.11.2 | Runtime (ai-service) |
| FastAPI | 0.141.x | AI service framework |
| uvicorn | 0.52.x | ASGI server |
| pydantic | 2.13.x | Request/response models, structured outputs |
| pydantic-settings | 2.15.x | Settings + env-file loading (replaces python-dotenv) |
| httpx | 0.28.x | Async LLM HTTP client (ollama-cloud + local providers) |
| sse-starlette | 3.4.x | SSE framing, ping keep-alive |
| pytest | 9.x | Test runner |
| pytest-asyncio | 1.4.x | Async tests (auto mode) |
| ruff | latest | Python lint (check-only, no formatter) — `pnpm ai:lint` |
| ollama-cloud | https://ollama.com/v1 | Default LLM provider (OpenAI-compatible, Bearer auth) |
Deliberately **not** used: openai-python SDK (the `LLMProvider` protocol is the port; raw httpx keeps delta passthrough and ollama-cloud quirk tolerance), python-dotenv (pydantic-settings reads .env natively), respx (httpx MockTransport is built in).
### v0.2 Architecture Decisions (from Research)
1. **D-016 SSE envelope**`meta` event (agent/session/model, flushed before first token) → raw OpenAI-compatible chunk passthrough → optional `done``error` event before `[DONE]` on mid-stream failure. Pre-first-byte failures use proper HTTP status. Headers: `Cache-Control: no-cache`, `X-Accel-Buffering: no`.
2. **D-017 httpx direct client** — lifespan-managed `httpx.AsyncClient` (10s connect / 300s read), shared by ollama-cloud and local providers; no SDK.
3. **D-018 Agent framework**`BaseAgent` ABC (system_prompt/build_messages/stream_reply/structured_reply) + explicit registry; prompts are versioned code in `prompts/`.
4. **D-019 Session store**`SessionStore` protocol + `InMemorySessionStore` (asyncio.Lock, 20-message window, 500-cap LRU, agent-scoped sessions). DB-migration-ready.
5. **D-020 Structured outputs** — 4-layer defense: `response_format` (auto-degrade) → prompt-embedded schema → fence-strip/first-balanced-object parse → single bounded retry.
6. **D-021 Mock corpus in Python**`ai_service/corpus/` pydantic-typed, convention-aligned with TS `packages/mock-data` (shared IDs, cross-referencing headers); no codegen in v0.2.
7. **D-022 Monorepo integration** — zero-dependency shim `package.json` in apps/ai-service + `ai#*` turbo passthrough tasks (`cache:false, outputs:[]`) + root `ai:dev`/`ai:test` scripts + idempotent venv bootstrap.
8. **D-023 Testing** — pytest-asyncio auto mode; TestClient `client.stream()` for SSE; httpx MockTransport for byte-exact provider parser tests; scripted mock provider incl. failure modes. Tests never call the cloud.
---
## Components
### apps/ai-service — AI Tutor Service (v0.2 NEW)
| Component | Description | Boundaries | Depends On |
|-----------|-------------|------------|------------|
| `ai_service/main.py` | FastAPI app factory, lifespan (httpx client pool, provider factory), CORS (localhost only), /health | App entry | config, llm, agents, api |
| `ai_service/config.py` | pydantic-settings Settings (env_prefix="AI_", env_file, SecretStr key) | Configuration only | None |
| `ai_service/api/` | Endpoints: chat.py (POST /v1/chat/stream, SSE), lab.py, assessment.py, proctor.py, mentor.py; deps.py (DI) | Composes agents + sessions; never imported by llm/ or agents/ | agents, llm |
| `ai_service/llm/` | types.py (Message; ChatDelta/ChoiceDelta removed in P3 — no consumers), base.py (LLMProvider protocol), openai_compat.py (ollama-cloud + local), mock.py (deterministic), factory.py | Never imports agents/ or api/ | config |
| `ai_service/agents/` | base.py (BaseAgent ABC), registry.py, session.py (SessionStore), structured.py (JSON defense), coach/tutor/lab/assessor/proctor/mentor.py | Never imports api/ | llm, prompts, corpus |
| `ai_service/prompts/` | Per-agent system prompt constants + render_context functions (str.format_map) | Data only | None |
| `ai_service/corpus/` | Mock engine inputs: learner_context.py, telemetry.py (Lab/Proctor scenarios), artifacts.py (pre-baked artifacts, rubrics, transcripts) | Pydantic-typed; aligned with TS packages/mock-data by convention | None |
| `scripts/` | bootstrap.sh (venv + pip install idempotent), dev.sh (exports keys from .ciagent/.env.secrets → uvicorn), test.sh (pytest), lint.sh (ruff check, G-3) | Dev entry points | pyproject.toml |
| `tests/` | conftest.py (mock provider, settings override, TestClient), health, llm (MockTransport parser), agents (framework + per-agent), api (SSE stream tests) | Mock provider only — no cloud | all |
**Module boundary rules:** `llm/` never imports `agents/` or `api/`; `agents/` never imports `api/`; `api/` composes both via DI. `corpus/` is the only home of mock engine data. Prompts are code — versioned and reviewed in git.
### apps/web — Next.js Application
| Component | Description | Boundaries | Depends On |
|-----------|-------------|------------|------------|
| `app/(learner)/` | Learner surface route group: landing, catalog, competency stack, dashboard, byte viewer, sandbox mockup, assessment mockup | Learner-only routes and layouts | packages/ui, packages/mock-data, packages/types |
| `app/(marketplace)/` | Marketplace surface route group: job board, job detail, employer profile, search/filter, pricing | Marketplace-only routes and layouts | packages/ui, packages/mock-data, packages/types |
| `app/(employer)/` | Employer dashboard route group: overview, talent search, candidate profile, posting management | Employer-only routes and layouts | packages/ui, packages/mock-data, packages/types |
| `app/(admin)/` | Admin surface route group: overview, learner management, competency graph viewer, moderation | Admin-only routes and layouts | packages/ui, packages/mock-data, packages/types |
| `app/layout.tsx` | Root layout: theme provider, navigation shell, responsive container | All routes | packages/ui |
| `components/` | Surface-specific components (learner/, marketplace/, employer/, admin/) plus shared chrome (navigation-shell, header/footer, role-switcher, theme-provider, breadcrumbs, dark-mode-toggle) | App-level components | packages/ui |
| `hooks/` | use-chat-stream.ts — SSE client hook: fetch + ReadableStream, byte buffering + frame reassembly, idempotent AbortController cleanup | Client components only | ai-service SSE |
| `lib/` | sse.ts (shared SSE frame parser — CRLF normalization + `: ping` immunity, G-1), breadcrumbs.ts, format.ts | Pure utilities | None |
### packages/ui — Shared Component Library
| Component | Description | Boundaries | Depends On |
|-----------|-------------|------------|------------|
| `tokens/` | Design tokens as TS constants: colors, spacing, radii, shadows, breakpoints (mirrored as Tailwind v4 `@theme` tokens in apps/web globals.css) | Foundation layer — no dependencies | None |
| `primitives/` | Button, Input, Card, Badge, Avatar — each with a Storybook story | Atomic UI components | tokens, packages/types |
Composite/layout/theme components (navigation shell, tables, chat panels, graph viewer, theme provider) live in `apps/web/components/` as app-level components, not in packages/ui.
### packages/mock-data — Mock Data Layer
| Component | Description | Boundaries | Depends On |
|-----------|-------------|------------|------------|
| `competency-stacks.ts` | 5 competency stacks (AI Orchestration Engineer, AI Safety & Governance, Human-AI Product Designer, AI-Augmented Field Operator, Computational Sciences), each with 12-18 competencies | Typed mock data | packages/types |
| `jobs.ts` | 20+ mock AI-era job listings with skills, seniority, salary, match scores | Typed mock data | packages/types |
| `candidates.ts` | 15+ mock candidate profiles with artifacts, process traces, defense scores, microcredentials | Typed mock data | packages/types |
| `employers.ts` | 10+ mock employer profiles with logos, descriptions, open positions | Typed mock data | packages/types |
| `learner-progress.ts` | Mock learner progress data: active competencies, completion percentages, recent artifacts | Typed mock data | packages/types |
| `admin.ts` | Admin surface mock data: platform metrics, activity feed, system health, learner roster (admin view), moderation queues | Typed mock data | packages/types |
| `ai-scenarios.ts` | AI engine-input scenario IDs + display metadata for the learner agent panels; IDs string-identical to `ai_service/corpus/` (D-021) | Typed mock data | packages/types |
### packages/types — Shared Types
| Component | Description | Boundaries | Depends On |
|-----------|-------------|------------|------------|
| `domain.ts` | Competency, CompetencyStack, Microcredential, Artifact, ProcessTrace, OralDefense, AssessmentRubric | Domain types | None |
| `marketplace.ts` | Job, Employer, Candidate, JobPosting, TalentMatch, SearchFilter | Marketplace types | None |
| `user.ts` | Learner, Admin, EmployerUser, AgeGroup, Role | User types | None |
| `ui.ts` | Component props, theme config, breakpoint definitions | UI types | None |
---
## Data Flow
```
[packages/mock-data + packages/types] [ai_service/corpus]
│ (TS, web surfaces) │ (Python, agent inputs)
▼ ▼
[Next.js Route Groups] [ai-service agents]
(learner)/ (marketplace)/ coach tutor lab assessor proctor mentor
(employer)/ (admin)/ │
│ ▼
│ SSE (fetch + ReadableStream) [LLMProvider]
└────── client components ◄───────────────────┤
http://localhost:8420 ollama-cloud / local / mock
(https://ollama.com/v1)
```
- Web surfaces remain server-component-first; client components (chat, filters, graph viewer, dark mode toggle) fetch directly from ai-service over SSE (A-002: no Next.js API-route proxy in v0.2).
- The LLM provider layer is a dumb pipe — OpenAI-compatible chunks pass through byte-identical; envelope logic (meta/done/error) lives only in the API layer (D-016).
- Lab/Assessor/Proctor read mock scenarios from `ai_service/corpus/` — real engines are v0.3+.
- All automated tests use the deterministic mock provider; the cloud is for manual probes only.
---
## Build Order (v0.2)
1. **AI service scaffolding** — apps/ai-service: FastAPI app, config, provider layer (ollama-cloud/local/mock), SSE chat endpoint, pytest harness, turbo integration
2. **Agent framework** — BaseAgent, registry, session store, structured output, prompts scaffolding, learner-context corpus
3. **Coach + Tutor agents** — full implementations, chat endpoint agent routing
4. **Lab + Assessor agents** — telemetry scenarios + pre-baked artifacts corpus, /v1/lab/feedback + /v1/assessment/evaluate
5. **Proctor + Mentor agents** — proctor scenarios, /v1/proctor/signals + /v1/mentor/narrative
6. **Learner surface integration** — useChatStream hook, agent switcher, streaming/error/loading states, agent output panels across the four learner surfaces
The v0.1 build order (monorepo → types → mock data → tokens → primitives → layout → composites → surfaces → polish) is complete and preserved in git history (tags v0.0.1v0.1.0).
---
## Future Architecture (Post-v0.2, for reference)
v0.2 delivers the ai-service skeleton that later milestones fill in:
- **Mock corpus → real engines** — Lab consumes real sandbox telemetry (v0.3 sandbox fabric); Assessor grades real process traces (v0.3 assessment engine); Proctor consumes real identity/attention signals (v0.3 identity verification)
- **In-memory sessions → PostgreSQL + Drizzle ORM** — SessionStore protocol swap, no API changes
- **Mock provider → per-agent model routing** — provider factory already selects by config; per-agent `AI_<AGENT>_MODEL` overrides
- **No auth → real KYC + sessions** — A-008 dropped in v0.3 when identity verification lands
- **No search → Semantic vector search (pgvector)** — Filter UI replaced with vector similarity search
- **No payments → Payment processing** — Pricing page replaced with real subscription/payment flows
The monorepo structure (apps/web + apps/ai-service + packages/*) accommodates further apps without restructuring.
+8
View File
@@ -0,0 +1,8 @@
{
"phase": 7,
"stage": "execute",
"milestone": "v0.2",
"phase_role": "final",
"attempts": 0,
"updated_at": "2026-09-11T23:15:00Z"
}
+36
View File
@@ -0,0 +1,36 @@
# Nextcraft v0.2 — GRILL.md (Adversarial Review Verdict)
**Stage:** GRILL, Phase 0 pre-execution · **Verdict:** GO-WITH-CHANGES · **Confidence:** 0.82
## Per-Axis Findings
| Axis | Verdict | Rationale |
|------|---------|-----------|
| Feasibility | PASS | Environment claims verified (venv works, all 8 pinned packages on PyPI for py3.11, port 8420 free, secrets gitignored). Stall points pre-mitigated (idempotent bootstrap, 300s read timeout, idempotent abort; reactStrictMode confirmed in next.config). |
| Over-scoping | CONCERN (mild) | 4-layer JSON defense justified; 500-cap LRU is over-spec but cheap — keep, extend no further. Real gaps: no Python lint (fixed via G-3), cache dirs in gitignore (fixed), script path resolution (advisory c). |
| Architecture risk | CONCERN | sse-starlette `: ping` frames invisible to short TestClient streams — parser gap fixed via G-1. Agent registration had two contradictory patterns — fixed via G-4. Cloud outage blast radius contained by mock-only tests. |
| Phase sequencing | PASS | Integration-last correct: P1 freezes the SSE contract before client code exists; A-002 eliminates dev-server buffering trap. P4 heaviest but mechanical. |
| Verification honesty | CONCERN | Persona distinctness circular against self-authored mocks (disclosed; cloud probe optional). P6 must-haves manual-only. D-021 ID alignment unmechanized (advisory b). Disclosed honestly. |
| Cost/quota | PASS | Cloud burn bounded (~<100K tokens milestone-wide, manual probes only). Mock-only rule structurally enforced; mechanical guard via advisory (a). |
| Milestone honesty | PASS (conditional) | Mock-engine caveats present everywhere that matters. Release note content now bound by G-5; dead `aiTutorResponses` disposal bound by G-5. |
## Binding Decisions (applied to PLAN.md/ROADMAP.md)
- **G-1 (BINDING):** SSE frame parser in Task 6-1-01 must ignore frames with no `data:` lines (sse-starlette ping keep-alive). Added to Action + Phase 6 must-have.
- **G-2 (BINDING):** P6 end-to-end verification may run with `AI_PROVIDER=mock` fallback — real ai-service over HTTP is the requirement; provider choice is service-internal. Prevents cloud outage blocking P6.
- **G-3 (BINDING):** ruff (check-only) added: Task 1-1-04, pyproject dev extra, scripts/lint.sh, root `ai:lint`, turbo `ai#lint`, Phase 1 must-have.
- **G-4 (BINDING):** Mentor/Proctor registered centrally in `registry.py` (single registration pattern), matching P3/P4.
- **G-5 (BINDING):** v0.2.0 release note must state Lab/Assessor/Proctor run on mock engine inputs (v0.3+ for real); dead `aiTutorResponses` export disposed of in P7.
## Advisory (non-binding; applied where cheap)
- (a) conftest asserts provider is MockProvider — **applied in P1 implementation**
- (b) pytest reads packages/mock-data as text, asserts corpus IDs appear — **applied in P4 implementation**
- (c) scripts resolve repo root via script-relative dirname — **folded into Task 1-1-04/1-1-03**
- (d) `.pytest_cache/` + `.ruff_cache/` in .gitignore — **applied**
- (e) keep LRU as-is; no further session-store sophistication in v0.2
- (f) Task 6-2-02 REQ tag fixed to REQ-2-011 (routing) — **applied**
## Outcome
GO — all five binding decisions applied to PLAN.md/ROADMAP.md/.gitignore/ARCHITECTURE.md before Phase 1 execution. No axis requires escalation.
+169
View File
@@ -0,0 +1,169 @@
# Nextcraft — PERSONAS.md
## Persona Roster
### lead-developer
```yaml
active: true
phase_specific: false
reason: Coordinates task decomposition across web, AI service, and data territories; resolves conflicts between frontend, backend, and AI personas
domain: coordination
frameworks:
- next.js
- turborepo
- pnpm
- fastapi
constraints:
- pragmatic
- battle-tested defaults
- monorepo-architecture
territory:
- "**/package.json"
- "**/turbo.json"
- "**/pnpm-workspace.yaml"
- "**/tsconfig.json"
- "apps/ai-service/pyproject.toml"
```
### frontend-engineer
```yaml
active: true
phase_specific: false
reason: Phase 6 learner-surface integration — useChatStream hook, agent switcher, streaming/error/loading states, Lab/Assessor/Proctor output panels. Owns all page components, layouts, and surface-specific UI.
domain: frontend
frameworks:
- react
- next.js
- tailwindcss
- lucide-react
- recharts
- react-flow
constraints:
- component-first
- server-components-default
- minimal-client-js
- sse-client-buffering (buffer bytes, split frames on \n\n, join data: lines)
- abortcontroller-cleanup (idempotent abort in effect cleanup)
- responsive-all-breakpoints
- dark-mode-support
territory:
- "apps/web/**"
- "packages/ui/**"
- "packages/mock-data/**"
- "packages/types/**"
```
### data-engineer
```yaml
active: true
phase_specific: false
reason: Owns TS mock data layer schema and typed definitions. Does NOT own the Python corpus (that is ai-engineer territory) — the two are aligned by documented convention (D-021).
domain: data
frameworks:
- typescript
constraints:
- schema-first
- type-safe
- migration-ready
- mock-data-only
territory:
- "packages/types/**"
- "packages/mock-data/**"
```
### backend-engineer
```yaml
active: true
phase_specific: false
reason: Reactivated for v0.2 — owns apps/ai-service infrastructure: FastAPI app, settings, SSE plumbing, endpoints, monorepo/turbo integration, test harness.
domain: backend
frameworks:
- fastapi
- uvicorn
- pydantic
- httpx
- pytest
constraints:
- provider-agnostic-boundaries (llm/ imports nothing from agents/ or api/)
- streaming-first
- no-database-v0.2
- secrets-via-env-only
- mock-provider-in-tests
territory:
- "apps/ai-service/ai_service/main.py"
- "apps/ai-service/ai_service/config.py"
- "apps/ai-service/ai_service/api/**"
- "apps/ai-service/scripts/**"
- "apps/ai-service/package.json"
- "apps/ai-service/tests/api/**"
- "turbo.json"
```
### ai-engineer
```yaml
active: true
phase_specific: false
reason: Custom persona for v0.2 — owns the LLM provider layer, agent framework, prompt library, structured outputs, and mock corpora for the six tutor agents.
domain: ai
frameworks:
- pydantic
- httpx
- pytest
constraints:
- provider-agnostic-protocol
- prompts-are-code
- json-defensive-parsing
- never-call-cloud-in-tests
- delta-passthrough
territory:
- "apps/ai-service/ai_service/llm/**"
- "apps/ai-service/ai_service/agents/**"
- "apps/ai-service/ai_service/prompts/**"
- "apps/ai-service/ai_service/corpus/**"
- "apps/ai-service/tests/llm/**"
- "apps/ai-service/tests/agents/**"
```
### design-system-engineer
```yaml
active: true
phase_specific: false
reason: Owns the shared component library, design tokens, and visual consistency. Light duty in v0.2: Phase 6 may need new primitives (agent-switcher control, stream-status indicator, error toast variant).
domain: frontend
frameworks:
- tailwindcss
- storybook
- lucide-react
constraints:
- design-token-driven
- wcag-aa-contrast
- dark-mode-required
- consistent-across-surfaces
territory:
- "packages/ui/**"
```
### security-auditor
```yaml
active: false
phase_specific: false
reason: No auth in v0.2 (A-008); CORS is localhost-only; no real user data. Security review handled by verifier's STRIDE analysis layer plus a Phase 7 checklist item: secrets hygiene (key absent from code/logs/commits/errors), localhost-only CORS, no PII in prompts.
domain: security
frameworks: []
constraints: []
territory: []
```
## Phase-Specific Personas
None for v0.2. All active personas span the entire milestone. data-engineer and design-system-engineer are light-touch after Phase 1.
## Territory Conflict Resolution
| Conflict | Resolution |
|----------|------------|
| frontend-engineer vs data-engineer (packages/types, packages/mock-data) | data-engineer owns type definitions and mock data schema; frontend-engineer consumes them. If changes needed, data-engineer updates types first. |
| frontend-engineer vs design-system-engineer (packages/ui) | design-system-engineer owns design tokens and primitive components; frontend-engineer owns composite components and page-level UI. |
| ai-engineer vs data-engineer (mock data duplication) | ai-engineer owns `ai_service/corpus/` (Python); data-engineer owns `packages/mock-data` (TS). Shared entity IDs and shapes kept aligned by documented convention (D-021): cross-referencing file headers, identical `comp-*` ID strings. |
| backend-engineer vs ai-engineer (apps/ai-service) | backend-engineer owns app shell, config, API endpoints, scripts, and test harness; ai-engineer owns llm/, agents/, prompts/, corpus/. Boundary: `ai_service/api/` (backend) composes `ai_service/agents/` (AI) via DI — agents never import api/. |
| lead-developer vs any | lead-developer coordinates only, does not directly modify code files. |
+422
View File
@@ -0,0 +1,422 @@
# Nextcraft v0.2 — PLAN.md
## Overview
This plan covers execution phases 1-6 of milestone v0.2 (AI Tutor Architecture): the six AI tutor agents (Coach, Tutor, Lab, Assessor, Proctor, Mentor) as real LLM-backed services in a new `apps/ai-service` Python FastAPI application, wired into the existing v0.1 learner surface with streaming responses. Phases are strictly sequential (P1→P6); within each phase, Wave 1 tasks are parallelizable (no cross-file dependencies) and later waves depend on earlier ones.
**Environment facts (apply throughout):** Python via `python3 -m venv` (no system pip, no uv); pnpm 12.3.4 via corepack; ai-service port **8420**; default model `gemma4:31b` (config via `AI_TUTOR_MODEL`); ollama-cloud base `https://ollama.com/v1` (OpenAI-compatible, Bearer auth); API keys live only in gitignored `.ciagent/.env.secrets`, exported by `scripts/dev.sh` — never in code, commits, or logs; all automated tests use the deterministic mock provider and **never call the cloud**.
| Phase | Name | Requirements | Waves | Personas |
|-------|------|-------------|-------|----------|
| 1 | AI service scaffolding | REQ-2-001, 002, 003 | 3 | backend-engineer, ai-engineer |
| 2 | Agent framework | REQ-2-004 | 2 | ai-engineer, backend-engineer |
| 3 | Coach + Tutor agents | REQ-2-005, 006 | 3 | ai-engineer, backend-engineer |
| 4 | Lab + Assessor agents | REQ-2-007, 008 | 4 | ai-engineer, backend-engineer, data-engineer |
| 5 | Proctor + Mentor agents | REQ-2-009, 010 | 3 | ai-engineer, backend-engineer |
| 6 | Learner surface integration | REQ-2-011, 012 | 3 | frontend-engineer, design-system-engineer |
---
## Phase 1: AI Service Scaffolding
**Requirements:** REQ-2-001, REQ-2-002, REQ-2-003
**Goal:** apps/ai-service runs under uvicorn, /health responds, provider-agnostic LLM layer with ollama-cloud/local/mock providers, SSE chat streaming verified, pytest suite green with mock provider, turbo integration wired
### Wave 1: Service shell + LLM core (parallel — no shared files)
#### Task 1-1-01: FastAPI app scaffolding
- **Persona:** backend-engineer — **REQ:** REQ-2-001
- **Files:** `apps/ai-service/pyproject.toml`, `apps/ai-service/ai_service/main.py`, `apps/ai-service/ai_service/config.py`, `apps/ai-service/ai_service/__init__.py`, `apps/ai-service/tests/conftest.py`, `apps/ai-service/tests/test_health.py`, `apps/ai-service/.env.example`, `apps/ai-service/README.md`
- **Action:** pydantic-settings `Settings` (env_prefix `AI_`, env_file, `SecretStr` key, port 8420, provider select, `AI_TUTOR_MODEL` default `gemma4:31b`). FastAPI app factory in `main.py` with lifespan stub (httpx client pool comes in Wave 2), CORS localhost-only (A-008), `GET /health`. pyproject with pinned deps (fastapi, uvicorn, pydantic, pydantic-settings, httpx, sse-starlette, pytest, pytest-asyncio) and dev extra. conftest: settings override + TestClient fixture. `.env.example` documents all `AI_*` vars; README documents venv setup and dev workflow.
- **Verify:** `scripts/bootstrap.sh && scripts/test.sh` — test_health passes; `curl localhost:8420/health` returns 200
#### Task 1-1-02: LLM types, protocol, mock provider
- **Persona:** ai-engineer — **REQ:** REQ-2-002
- **Files:** `apps/ai-service/ai_service/llm/types.py`, `apps/ai-service/ai_service/llm/base.py`, `apps/ai-service/ai_service/llm/mock.py`, `apps/ai-service/ai_service/llm/__init__.py`
- **Action:** `types.py`: pydantic `Message` (role/content), `ChatDelta` (OpenAI-compatible chunk shape). `base.py`: `LLMProvider` protocol — async `stream_chat(messages, model, response_format=None) -> AsyncIterator[ChatDelta]`; the provider is a dumb pipe, no envelope logic (D-016 keeps envelope in API layer). `mock.py`: deterministic scripted provider (hash-seeded token streams, scripted failure modes: connect error, mid-stream error, malformed JSON) for tests and CI.
- **Verify:** mock provider importable and deterministic; two identical calls yield identical streams
#### Task 1-1-03: Monorepo integration (shim + turbo + scripts)
- **Persona:** backend-engineer — **REQ:** REQ-2-001
- **Files:** `apps/ai-service/package.json`, `apps/ai-service/scripts/bootstrap.sh`, `apps/ai-service/scripts/dev.sh`, `apps/ai-service/scripts/test.sh`, `turbo.json` (update), `package.json` (root, update)
- **Action:** zero-dependency shim `package.json` in apps/ai-service with `dev`/`test`/`bootstrap` script entries. Turbo passthrough tasks `ai#dev`, `ai#test`, `ai#bootstrap` (`cache: false`, `outputs: []`). Root scripts `ai:dev`, `ai:test`, `ai:bootstrap`. `bootstrap.sh`: idempotent `python3 -m venv .venv` + pip install. `dev.sh`: exports keys from `.ciagent/.env.secrets` → uvicorn on 8420. `test.sh`: pytest via venv.
- **Verify:** `corepack pnpm install && pnpm ai:bootstrap && pnpm ai:test` runs pytest through turbo; re-running bootstrap is a no-op
#### Task 1-1-04: Python lint (ruff, check-only)
- **Persona:** backend-engineer — **REQ:** REQ-2-001
- **Files:** `apps/ai-service/pyproject.toml` (update: `[tool.ruff]` config + `ruff` in dev extra), `apps/ai-service/scripts/lint.sh`, `package.json` (root, update), `turbo.json` (update)
- **Action:** Add `ruff` (check-only, no formatter) to the dev extra; `[tool.ruff]` with line-length 100, target py311. `scripts/lint.sh`: `.venv/bin/ruff check .` with repo-root path resolution via script-relative dirname (not CWD). Root script `ai:lint`, turbo passthrough `ai#lint` (`cache: false`, `outputs: []`). Run over the entire ai-service tree; fix all findings before phase ship (G-3).
- **Verify:** `pnpm ai:lint` exits 0 on the Phase 1 codebase
### Wave 2: Real providers + SSE endpoint (depends on Wave 1)
#### Task 1-2-01: OpenAI-compatible provider + factory
- **Persona:** ai-engineer — **REQ:** REQ-2-002
- **Files:** `apps/ai-service/ai_service/llm/openai_compat.py`, `apps/ai-service/ai_service/llm/factory.py`
- **Action:** `openai_compat.py`: single `OpenAICompatProvider` for ollama-cloud (`https://ollama.com/v1`, Bearer) and local endpoints (base URL from settings); raw httpx against `/v1/chat/completions` with `stream: true`, byte-identical delta passthrough, tolerant of ollama-cloud quirks. Uses the lifespan-managed `httpx.AsyncClient` (10s connect / 300s read, D-017) — no openai SDK. `factory.py`: select provider from settings (`ollama-cloud` | `local` | `mock`).
- **Verify:** provider constructs from settings for all 3 names; manual probe against ollama-cloud streams tokens (documented in README, not a test)
#### Task 1-2-02: Lifespan wiring + SSE chat endpoint
- **Persona:** backend-engineer — **REQ:** REQ-2-003
- **Files:** `apps/ai-service/ai_service/main.py` (update), `apps/ai-service/ai_service/api/deps.py`, `apps/ai-service/ai_service/api/chat.py`, `apps/ai-service/ai_service/api/__init__.py`
- **Action:** Lifespan creates the shared `httpx.AsyncClient` and provider factory; deps.py provides provider via DI. `POST /v1/chat/stream` in chat.py implements the D-016 envelope: `meta` event (agent/session/model) flushed before first token → raw OpenAI chunks passed through as `data: {json}``done` event → `error` event before `[DONE]` on mid-stream failure; pre-first-byte failures return proper HTTP status codes. Headers `Cache-Control: no-cache`, `X-Accel-Buffering: no`; sse-starlette ping keep-alive.
- **Verify:** `curl -N -X POST localhost:8420/v1/chat/stream` with mock provider shows meta event, token deltas, done, `[DONE]`
### Wave 3: Provider + endpoint test suites (depends on Wave 2)
#### Task 1-3-01: LLM provider tests (byte-exact, no cloud)
- **Persona:** ai-engineer — **REQ:** REQ-2-002
- **Files:** `apps/ai-service/tests/llm/test_openai_compat.py`, `apps/ai-service/tests/llm/test_mock.py`, `apps/ai-service/tests/llm/__init__.py`
- **Action:** httpx `MockTransport` tests parsing byte-exact fixture streams (happy path, empty delta, `[DONE]`, malformed line, mid-stream disconnect). Mock provider tests: determinism, scripted failure modes, response_format echo.
- **Verify:** `pnpm ai:test` — llm suite green; zero network calls in tests
#### Task 1-3-02: SSE stream endpoint tests
- **Persona:** backend-engineer — **REQ:** REQ-2-003
- **Files:** `apps/ai-service/tests/api/test_chat_stream.py`, `apps/ai-service/tests/api/__init__.py`
- **Action:** TestClient `client.stream()` tests: meta-first ordering, delta passthrough, done + `[DONE]` sentinel, mid-stream error event, pre-first-byte failure → HTTP status, required headers. pytest-asyncio auto mode (D-023).
- **Verify:** `pnpm ai:test` — api suite green
### Must-Haves (Phase 1)
- [ ] `scripts/bootstrap.sh` is idempotent; creates venv + installs deps without system pip
- [ ] `pnpm ai:dev` starts uvicorn; `curl localhost:8420/health` returns 200
- [ ] `pnpm ai:lint` exits 0 (ruff check over the ai-service tree) (G-3)
- [ ] `pnpm ai:test` runs the full pytest suite via turbo and passes (mock provider only — no network)
- [ ] SSE stream delivers tokens: meta event, incremental deltas, done, `[DONE]` observed via `curl -N`
- [ ] Mid-stream failure emits `error` event before `[DONE]`; pre-first-byte failure returns HTTP error status
- [ ] Provider factory resolves ollama-cloud / local / mock from settings; manual ollama-cloud probe documented in README
- [ ] `llm/` imports nothing from `agents/` or `api/` (boundary rule holds)
---
## Phase 2: Agent Framework
**Requirements:** REQ-2-004
**Goal:** Shared framework all six agents use: BaseAgent contract, session store, prompt library, registry, structured outputs — all tested against the mock provider
### Wave 1: Framework primitives (parallel — no shared files)
#### Task 2-1-01: BaseAgent ABC
- **Persona:** ai-engineer — **REQ:** REQ-2-004
- **Files:** `apps/ai-service/ai_service/agents/base.py`, `apps/ai-service/tests/agents/test_base.py`, `apps/ai-service/ai_service/agents/__init__.py`, `apps/ai-service/tests/agents/__init__.py`
- **Action:** `BaseAgent` ABC (D-018): `name`, `system_prompt`, `build_messages(history, learner_context)`, `stream_reply(...) -> AsyncIterator[ChatDelta]` (delegates to provider), `structured_reply(...)` (delegates to structured module, landed Wave 2). Subclass contract tested with a stub agent + mock provider.
- **Verify:** `pnpm ai:test` — test_base green
#### Task 2-1-02: SessionStore protocol + in-memory implementation
- **Persona:** ai-engineer — **REQ:** REQ-2-004
- **Files:** `apps/ai-service/ai_service/agents/session.py`, `apps/ai-service/tests/test_session.py`
- **Action:** `SessionStore` protocol + `InMemorySessionStore` (D-019): asyncio.Lock-guarded dict, agent-scoped session keys, 20-message rolling window, 500-cap LRU eviction. Protocol shape is DB-migration-ready (A-003).
- **Verify:** test_session covers create/append/window-trim/LRU-eviction/agent scoping
#### Task 2-1-03: Prompt library scaffolding
- **Persona:** ai-engineer — **REQ:** REQ-2-004
- **Files:** `apps/ai-service/ai_service/prompts/coach.py`, `apps/ai-service/ai_service/prompts/tutor.py`, `apps/ai-service/ai_service/prompts/lab.py`, `apps/ai-service/ai_service/prompts/assessor.py`, `apps/ai-service/ai_service/prompts/proctor.py`, `apps/ai-service/ai_service/prompts/mentor.py`, `apps/ai-service/ai_service/prompts/__init__.py`
- **Action:** Per-agent module with a versioned `SYSTEM_PROMPT` constant + `render_context(learner_context) -> dict` using `str.format_map` for learner-context injection (D-018: prompts are code, versioned in git). Initial drafts for all six; final personas land in Phases 3-5.
- **Verify:** all six prompt modules import; render_context fills placeholders without KeyError
#### Task 2-1-04: Learner context corpus
- **Persona:** ai-engineer — **REQ:** REQ-2-004
- **Files:** `apps/ai-service/ai_service/corpus/learner_context.py`, `apps/ai-service/ai_service/corpus/__init__.py`
- **Action:** Pydantic-typed learner context (active stack, competencies, progress, recent artifacts) mirroring TS `packages/mock-data` IDs per D-021 convention (cross-referencing header comment, identical `comp-*`/`stack-*` ID strings).
- **Verify:** context renders into prompt placeholders; IDs match packages/mock-data strings
### Wave 2: Composition layers (depends on Wave 1)
#### Task 2-2-01: Structured output defense
- **Persona:** ai-engineer — **REQ:** REQ-2-004
- **Files:** `apps/ai-service/ai_service/agents/structured.py`, `apps/ai-service/tests/test_structured.py`
- **Action:** 4-layer defense (D-020): (1) `response_format` request with auto-degrade on provider 400; (2) prompt-embedded JSON schema; (3) parse: strip code fences → first balanced JSON object; (4) single bounded retry with validation-error feedback. Returns pydantic-validated model or raises `StructuredOutputError`.
- **Verify:** test_structured covers fenced/unfenced/invalid JSON, retry path, degrade path — all against mock provider
#### Task 2-2-02: Agent registry
- **Persona:** ai-engineer — **REQ:** REQ-2-004
- **Files:** `apps/ai-service/ai_service/agents/registry.py`, `apps/ai-service/tests/test_registry.py`
- **Action:** Explicit registry: name → agent factory map with `register(name, factory)` / `get(name)`; raises on unknown agent. Agents are registered in their own phases (P3-P5).
- **Verify:** test_registry: register/get round-trip, unknown-agent error, duplicate registration error
#### Task 2-2-03: Session + agent DI wiring into API layer
- **Persona:** backend-engineer — **REQ:** REQ-2-004
- **Files:** `apps/ai-service/ai_service/api/deps.py` (update), `apps/ai-service/ai_service/api/chat.py` (update)
- **Action:** deps.py exposes SessionStore and provider singletons via DI. chat.py persists turn history through the session store (agent-scoped) and includes session ID in the meta event. API composes agents via DI — agents never import api/.
- **Verify:** chat request appends to and replays windowed history; `pnpm ai:test` green
### Must-Haves (Phase 2)
- [ ] BaseAgent unit tests pass (stub agent streams via mock provider)
- [ ] Session store tested: create/append, 20-message window trim, 500-cap LRU eviction, agent-scoped keys
- [ ] Structured output parsing tested against mock provider: fence-strip, first-balanced-object, invalid JSON, one bounded retry, response_format auto-degrade
- [ ] Registry tested: register/get/unknown/duplicate
- [ ] Six prompt modules render learner context without errors
- [ ] Module boundaries hold: `agents/` never imports `api/`; `llm/` never imports `agents/` or `api/`
---
## Phase 3: Coach + Tutor Agents
**Requirements:** REQ-2-005, REQ-2-006
**Goal:** Both learner-facing conversational agents fully implemented with distinct personas, registered, routed through the chat streaming endpoint
### Wave 1: Agent implementations (parallel — no shared files)
#### Task 3-1-01: Coach agent
- **Persona:** ai-engineer — **REQ:** REQ-2-005
- **Files:** `apps/ai-service/ai_service/prompts/coach.py` (finalize), `apps/ai-service/ai_service/agents/coach.py`, `apps/ai-service/tests/test_coach.py`
- **Action:** Final Coach persona: pacing guidance, motivation, retrieval practice prompts; system prompt injects learner context (active stack, progress). `CoachAgent(BaseAgent)` streams replies. Mock provider scripts a distinct coach-voice response for tests.
- **Verify:** test_coach: build_messages includes system prompt + windowed history; stream_reply yields deltas; on-persona content asserted against mock script
#### Task 3-1-02: Tutor agent
- **Persona:** ai-engineer — **REQ:** REQ-2-006
- **Files:** `apps/ai-service/ai_service/prompts/tutor.py` (finalize), `apps/ai-service/ai_service/agents/tutor.py`, `apps/ai-service/tests/test_tutor.py`
- **Action:** Final Tutor persona: concept delivery, Socratic questioning, worked examples. `TutorAgent(BaseAgent)` streams replies; mock scripts a distinct tutor-voice response.
- **Verify:** test_tutor mirrors test_coach; Coach and Tutor mock outputs are observably distinct
### Wave 2: Registration + persona verification (depends on Wave 1)
#### Task 3-2-01: Register Coach + Tutor; document cloud probe
- **Persona:** ai-engineer — **REQ:** REQ-2-005, REQ-2-006
- **Files:** `apps/ai-service/ai_service/agents/registry.py` (update), `apps/ai-service/tests/test_registry.py` (update), `apps/ai-service/README.md` (update)
- **Action:** Register both agents in the explicit registry. Extend test_registry to assert both resolve. Document the manual ollama-cloud persona probe in README (curl commands with `AI_PROVIDER=ollama-cloud`): Coach and Tutor produce distinct on-persona responses; tests remain cloud-free.
- **Verify:** `pnpm ai:test` green; manual probe against ollama-cloud shows distinct personas (documented, not automated)
### Wave 3: Chat endpoint agent routing (depends on Wave 2)
#### Task 3-3-01: Agent routing on /v1/chat/stream
- **Persona:** backend-engineer — **REQ:** REQ-2-005, REQ-2-006
- **Files:** `apps/ai-service/ai_service/api/chat.py` (update), `apps/ai-service/ai_service/api/deps.py` (update), `apps/ai-service/tests/api/test_chat_stream.py` (update)
- **Action:** Chat request gains `agent` field (validated against the registry; unknown agent → 422). Endpoint resolves the agent via DI, persists to the agent-scoped session, meta event carries the agent name. No autonomous routing in v0.2 (A-007).
- **Verify:** TestClient tests: `agent=coach` and `agent=tutor` route correctly, session scoped per agent, unknown agent rejected
### Must-Haves (Phase 3)
- [ ] Both agents produce distinct, on-persona responses (mock-asserted; manual ollama-cloud probe documented in README)
- [ ] Agent routing tested: coach/tutor resolve via registry; unknown agent returns 422
- [ ] Both agents exposed end-to-end via `POST /v1/chat/stream` with agent-scoped session history
- [ ] `pnpm ai:test` green; no cloud calls in tests
---
## Phase 4: Lab + Assessor Agents
**Requirements:** REQ-2-007, REQ-2-008
**Goal:** Lab consumes simulated sandbox telemetry and streams in-flow feedback; Assessor applies rubrics to pre-baked artifacts and returns structured scores — both over mock engine inputs
### Wave 1: Mock engine inputs (parallel — no shared files)
#### Task 4-1-01: Simulated sandbox telemetry corpus
- **Persona:** ai-engineer — **REQ:** REQ-2-007
- **Files:** `apps/ai-service/ai_service/corpus/telemetry.py`
- **Action:** Pydantic-typed Lab telemetry scenarios: scripted build-session event streams (keystrokes, commits, test runs, errors, idle gaps) keyed by scenario ID, aligned with packages/mock-data IDs (D-021).
- **Verify:** scenarios import, validate, and are addressable by ID
#### Task 4-1-02: Pre-baked artifacts + rubrics corpus
- **Persona:** ai-engineer — **REQ:** REQ-2-008
- **Files:** `apps/ai-service/ai_service/corpus/artifacts.py`
- **Action:** Pre-baked artifacts (code, design, simulation), assessment rubrics (criteria, levels, weights), and defense transcripts keyed by ID — the Assessor's mock inputs (real engines are v0.3+).
- **Verify:** rubric/artifact/transcript fixtures validate; IDs align with TS mock data
#### Task 4-1-03: TS mock-data alignment for engine inputs
- **Persona:** data-engineer — **REQ:** REQ-2-012
- **Files:** `packages/mock-data/ai-scenarios.ts` (new), `packages/mock-data/index.ts` (update)
- **Action:** Export scenario/artifact ID constants + display metadata used by the Phase 6 learner panels, mirroring `ai_service/corpus/` IDs exactly (D-021). Data-engineer owns the TS side; headers cross-reference the Python corpus.
- **Verify:** `pnpm typecheck` passes; IDs string-equal to corpus IDs
### Wave 2: Agent implementations (depends on Wave 1)
#### Task 4-2-01: Lab agent
- **Persona:** ai-engineer — **REQ:** REQ-2-007
- **Files:** `apps/ai-service/ai_service/prompts/lab.py` (finalize), `apps/ai-service/ai_service/agents/lab.py`, `apps/ai-service/tests/test_lab.py`
- **Action:** `LabAgent(BaseAgent)` consumes a telemetry scenario, builds messages summarizing the event stream, streams concrete in-flow feedback (what happened, what to adjust, next step). No session chat — scenario-driven.
- **Verify:** test_lab: given a mock scenario, feedback references scenario events (mock-scripted assertions)
#### Task 4-2-02: Assessor agent
- **Persona:** ai-engineer — **REQ:** REQ-2-008
- **Files:** `apps/ai-service/ai_service/prompts/assessor.py` (finalize), `apps/ai-service/ai_service/agents/assessor.py`, `apps/ai-service/tests/test_assessor.py`
- **Action:** `AssessorAgent(BaseAgent)` applies a rubric to an artifact + defense transcript via `structured_reply`, returning a pydantic-validated rubric score model (per-criterion scores, strengths, gaps, verdict).
- **Verify:** test_assessor: structured output validates against the rubric model; failure modes exercise the 4-layer defense
### Wave 3: Registration (depends on Wave 2)
#### Task 4-3-01: Register Lab + Assessor
- **Persona:** ai-engineer — **REQ:** REQ-2-007, REQ-2-008
- **Files:** `apps/ai-service/ai_service/agents/registry.py` (update), `apps/ai-service/tests/test_registry.py` (update)
- **Action:** Register both agents; extend registry tests.
- **Verify:** registry resolves coach/tutor/lab/assessor; `pnpm ai:test` green
### Wave 4: Endpoints (depends on Wave 3)
#### Task 4-4-01: Lab feedback endpoint
- **Persona:** backend-engineer — **REQ:** REQ-2-007
- **Files:** `apps/ai-service/ai_service/api/lab.py`, `apps/ai-service/tests/api/test_lab.py`
- **Action:** `POST /v1/lab/feedback` with scenario ID → resolves corpus scenario + Lab agent → SSE stream using the D-016 envelope (meta names agent=lab). Unknown scenario → 404.
- **Verify:** TestClient streams meta + deltas + done + `[DONE]`; unknown scenario 404
#### Task 4-4-02: Assessment evaluate endpoint
- **Persona:** backend-engineer — **REQ:** REQ-2-008
- **Files:** `apps/ai-service/ai_service/api/assessment.py`, `apps/ai-service/tests/api/test_assessment.py`
- **Action:** `POST /v1/assessment/evaluate` with artifact ID → resolves corpus artifact/rubric/transcript + Assessor agent → JSON response (validated rubric score model). Unknown artifact → 404.
- **Verify:** TestClient returns validated rubric JSON; unknown artifact 404; `pnpm ai:test` green
### Must-Haves (Phase 4)
- [ ] Lab produces scenario-relevant in-flow feedback for mock telemetry scenarios (mock provider, tested)
- [ ] Assessor returns structured rubric scores (pydantic-validated JSON) for pre-baked artifacts/transcripts
- [ ] `POST /v1/lab/feedback` streams (meta → deltas → done → `[DONE]`); `POST /v1/assessment/evaluate` returns validated JSON
- [ ] Unknown scenario/artifact IDs return 404
- [ ] Corpus IDs align with packages/mock-data (D-021); `pnpm ai:test` and `pnpm typecheck` green
---
## Phase 5: Proctor + Mentor Agents
**Requirements:** REQ-2-009, REQ-2-010
**Goal:** Proctor classifies integrity signals with coaching interventions from mock telemetry; Mentor generates long-horizon career narrative; both exposed via endpoints
### Wave 1: Proctor scenarios + Mentor agent (parallel — no shared files)
#### Task 5-1-01: Proctor telemetry scenarios
- **Persona:** ai-engineer — **REQ:** REQ-2-009
- **Files:** `apps/ai-service/ai_service/corpus/telemetry.py` (update)
- **Action:** Add proctor scenarios: tab switches, idle time, paste events, focus loss — scripted integrity-relevant event sets keyed by scenario ID.
- **Verify:** proctor scenarios validate; distinguishable from lab scenarios by type
#### Task 5-1-02: Mentor agent (+ registration)
- **Persona:** ai-engineer — **REQ:** REQ-2-010
- **Files:** `apps/ai-service/ai_service/prompts/mentor.py` (finalize), `apps/ai-service/ai_service/agents/mentor.py`, `apps/ai-service/tests/test_mentor.py`, `apps/ai-service/ai_service/agents/registry.py` (update)
- **Action:** `MentorAgent(BaseAgent)`: long-horizon career narrative — trajectory story, competency-stack progression guidance, market positioning — streaming, session-backed. Registered centrally in `registry.py` (single registration pattern, G-4).
- **Verify:** test_mentor: narrative references learner context (mock-scripted); registry resolves mentor
### Wave 2: Proctor agent + Mentor endpoint (depends on Wave 1)
#### Task 5-2-01: Proctor agent (+ registration)
- **Persona:** ai-engineer — **REQ:** REQ-2-009
- **Files:** `apps/ai-service/ai_service/prompts/proctor.py` (finalize), `apps/ai-service/ai_service/agents/proctor.py`, `apps/ai-service/tests/test_proctor.py`, `apps/ai-service/ai_service/agents/registry.py` (update)
- **Action:** `ProctorAgent(BaseAgent)`: consumes proctor scenario → `structured_reply` returns pydantic-validated signal classification (severity, signal type) + recommended coaching intervention (supportive, not punitive). Registered centrally in `registry.py` (single registration pattern, G-4).
- **Verify:** test_proctor: classified signals + interventions validate for each mock scenario; registry resolves all six agents
#### Task 5-2-02: Mentor narrative endpoint
- **Persona:** backend-engineer — **REQ:** REQ-2-010
- **Files:** `apps/ai-service/ai_service/api/mentor.py`, `apps/ai-service/tests/api/test_mentor.py`
- **Action:** `POST /v1/mentor/narrative` → Mentor agent → SSE stream with D-016 envelope, session-backed.
- **Verify:** TestClient streams meta (agent=mentor) → deltas → done → `[DONE]`
### Wave 3: Proctor endpoint (depends on Wave 2)
#### Task 5-3-01: Proctor signals endpoint
- **Persona:** backend-engineer — **REQ:** REQ-2-009
- **Files:** `apps/ai-service/ai_service/api/proctor.py`, `apps/ai-service/tests/api/test_proctor.py`
- **Action:** `POST /v1/proctor/signals` with scenario ID → resolves corpus scenario + Proctor agent → JSON response (validated signals + interventions). Unknown scenario → 404.
- **Verify:** TestClient returns classified signals JSON; `pnpm ai:test` green — full suite (all six agents registered)
### Must-Haves (Phase 5)
- [ ] Proctor produces classified signals with recommended coaching interventions for each mock scenario (structured JSON, validated)
- [ ] Mentor produces coherent long-horizon career narrative (streaming, session-backed)
- [ ] `POST /v1/proctor/signals` returns validated JSON; `POST /v1/mentor/narrative` streams
- [ ] Registry resolves all six agents; full ai-service test suite green, cloud-free
---
## Phase 6: Learner Surface Integration
**Requirements:** REQ-2-011, REQ-2-012
**Goal:** v0.1 learner surfaces wired to the real ai-service: streaming chat with agent switcher, Lab/Assessor/Proctor/Mentor outputs surfaced, error/loading states, build + typecheck green
**Note (G-2):** End-to-end verification may run with `AI_PROVIDER=mock` as a fallback — the requirement is the real ai-service over HTTP (not canned client-side responses); provider choice is service-internal. This prevents an ollama-cloud outage from blocking P6 verification. Cloud persona probes remain separate (Task 3-2-01).
### Wave 1: Client plumbing + primitives (parallel — no shared files)
#### Task 6-1-01: useChatStream hook
- **Persona:** frontend-engineer — **REQ:** REQ-2-011
- **Files:** `apps/web/hooks/use-chat-stream.ts`, `apps/web/.env.example` (update)
- **Action:** `useChatStream(agent)` hook: `fetch` POST to `${NEXT_PUBLIC_AI_SERVICE_URL}/v1/chat/stream` (default `http://localhost:8420`, A-002 — no API-route proxy); consumes `ReadableStream` with byte buffering, frame split on `\n\n`, joined `data:` lines; **ignores frames containing no `data:` lines (sse-starlette `: ping` keep-alive comment frames)** — TestClient streams are too short to surface pings, but real cloud delta gaps emit them (G-1); handles meta / delta / done / error events and `[DONE]` sentinel; idempotent `AbortController.abort()` in effect cleanup; exposes `{messages, isStreaming, error, send, retry, abort}`. `.env.example` gains `NEXT_PUBLIC_AI_SERVICE_URL`.
- **Verify:** hook unit-tested or exercised via the chat UI; unmount mid-stream aborts cleanly (no state updates after unmount)
#### Task 6-1-02: Agent switcher + streaming primitives
- **Persona:** design-system-engineer — **REQ:** REQ-2-011
- **Files:** `packages/ui/src/primitives/agent-switcher.tsx`, `packages/ui/src/primitives/stream-status.tsx`, `packages/ui/src/primitives/toast.tsx`, `packages/ui/src/primitives/index.ts` (update), `packages/ui/src/index.ts` (update)
- **Action:** Token-driven primitives: AgentSwitcher (segmented coach/tutor control with active state), StreamStatus (idle/streaming/error indicator), Toast with error variant. Dark mode + WCAG AA contrast; exported from `@nextcraft/ui`.
- **Verify:** primitives import from `@nextcraft/ui`; storybook stories render (dark + light)
### Wave 2: Chat rewrite + Byte viewer panel (depends on Wave 1)
#### Task 6-2-01: Real streaming learner chat
- **Persona:** frontend-engineer — **REQ:** REQ-2-011
- **Files:** `apps/web/components/learner/ai-tutor-chat.tsx` (rewrite), `apps/web/components/learner/agent-switcher.tsx` (composition wrapper)
- **Action:** Replace the canned `aiTutorResponses` behavior with `useChatStream`: agent switcher (Coach/Tutor per A-007), token-by-token rendering, streaming cursor + loading state, error state with retry button when ai-service is down (A-010), suggested-action chips from the meta event. Seed welcome message stays static.
- **Verify:** with ai-service running, messages stream visibly token-by-token; with ai-service stopped, error state + retry appears (no crash, no console errors)
#### Task 6-2-02: Byte viewer Tutor panel
- **Persona:** frontend-engineer — **REQ:** REQ-2-011 (agent routing: byte viewer always uses Tutor, A-007)
- **Files:** `apps/web/app/(learner)/learn/[competencyId]/page.tsx` (update), `apps/web/components/learner/byte-tutor-panel.tsx` (new)
- **Action:** Byte viewer gains a Tutor explanation panel (byte viewer always uses Tutor, A-007): "Explain this byte" streams a Socratic concept walkthrough for the current competency via useChatStream (agent fixed to tutor).
- **Verify:** on a byte page, the panel streams a Tutor explanation; error state when service down
### Wave 3: Lab / Assessment / Mentor panels (depends on Wave 1 hook; parallel — no shared files)
#### Task 6-3-01: Sandbox Lab feedback panel
- **Persona:** frontend-engineer — **REQ:** REQ-2-012
- **Files:** `apps/web/app/(learner)/build/[competencyId]/page.tsx` (update), `apps/web/components/learner/lab-feedback-panel.tsx` (new)
- **Action:** Sandbox telemetry sidebar gains a Lab feedback panel: posts the scenario ID (from `packages/mock-data/ai-scenarios`) to `/v1/lab/feedback`, streams in-flow feedback into the panel; loading + error states.
- **Verify:** sandbox page streams Lab feedback for the mock scenario; error state when service down
#### Task 6-3-02: Assessment Assessor + Proctor surfaces
- **Persona:** frontend-engineer — **REQ:** REQ-2-012
- **Files:** `apps/web/app/(learner)/defend/[competencyId]/page.tsx` (update), `apps/web/components/learner/assessor-results-panel.tsx` (new), `apps/web/components/learner/proctor-banner.tsx` (new)
- **Action:** Assessment mockup: AI reviewer panel calls `/v1/assessment/evaluate` with the artifact ID and renders the structured rubric scores (per-criterion bars, strengths, gaps, verdict); a Proctor integrity banner surfaces `/v1/proctor/signals` classifications with coaching tone. Loading skeletons + error states.
- **Verify:** defend page renders real Assessor rubric output + Proctor banner; error states when service down
#### Task 6-3-03: Dashboard Mentor panel
- **Persona:** frontend-engineer — **REQ:** REQ-2-012
- **Files:** `apps/web/app/(learner)/dashboard/page.tsx` (update), `apps/web/components/learner/mentor-panel.tsx` (new)
- **Action:** Learner dashboard gains a Mentor panel: streams career narrative from `/v1/mentor/narrative` (learner progress context), with regenerate button, loading + error states. Sits alongside the existing AI tutor chat.
- **Verify:** dashboard shows streaming Mentor narrative; error state when service down
### Must-Haves (Phase 6)
- [ ] With ai-service running: learner chat at http://localhost:3000/dashboard streams real responses token-by-token (mock provider fallback allowed per G-2 — service over HTTP is the requirement)
- [ ] Agent switcher flips Coach ↔ Tutor and the response persona changes accordingly
- [ ] Hook tolerates keep-alive comment frames (`: ping`, no data lines) during live streams (G-1)
- [ ] With ai-service stopped: all chat/panels show error states with retry — no crashes, no unhandled promise rejections, no console errors
- [ ] Byte viewer, sandbox, and assessment mockups surface Tutor/Lab/Assessor/Proctor outputs; dashboard shows Mentor narrative
- [ ] Unmounting/navigating mid-stream aborts cleanly (no post-unmount state updates)
- [ ] `pnpm build` and `pnpm typecheck` pass; `pnpm ai:test` still green
---
## Phase 7: Final Review + Ship (no planned tasks)
Orchestrated by the SHIP stage, not this plan: multi-persona code review (correctness, testing, secrets hygiene — key absent from code/logs/commits/errors, localhost-only CORS, no PII in prompts), project health audit (reconstruction test, .ciagent/ discipline, branch/commit hygiene), then merge milestone → main, tag v0.2.0, create Gitea release, mark all 12 v0.2 requirements complete.
**Release-note honesty (G-5):** the v0.2.0 release note must explicitly state that Lab/Assessor/Proctor operate on mock engine inputs (real engines are v0.3+), per D-015.
**Dead-code disposal (G-5):** review must dispose of `aiTutorResponses` (packages/mock-data/ai-tutor-responses.ts) — its only consumer is rewritten in Task 6-2-01; remove the export or mark it deprecated.
---
## User-Facing Surface
The primary user-facing surface is the **learner dashboard and learning flow** at `http://localhost:3000`, backed by the real ai-service at `http://localhost:8420`:
- `/dashboard` — AI tutor chat (Coach/Tutor switcher, streaming) + Mentor career-narrative panel
- `/learn/[competencyId]` — byte viewer with streaming Tutor explanations
- `/build/[competencyId]` — sandbox with Lab in-flow feedback panel (mock telemetry)
- `/defend/[competencyId]` — assessment with live Assessor rubric scores + Proctor integrity banner
The marketplace, employer, and admin surfaces are unchanged from v0.1.
## Happy Path
1. Learner opens `/dashboard` → chat shows welcome message; meta event confirms coach/model in the stream
2. Learner types "I'm stuck on multi-agent communication" → reply streams token-by-token with pacing guidance + a retrieval-practice prompt
3. Learner switches to **Tutor** → asks the same question → gets a Socratic concept walkthrough instead
4. Learner opens a byte tutorial → Tutor panel streams an explanation of the current competency
5. Learner opens the build sandbox → Lab panel streams feedback on the simulated telemetry scenario
6. Learner opens the defense mockup → Assessor panel shows structured rubric scores; Proctor banner shows integrity signals in coaching tone
7. Back on `/dashboard`, the Mentor panel streams a career narrative tied to the learner's progress
8. Learner kills ai-service (or it crashes) → next message shows an inline error state with **Retry**; restarting the service and retrying resumes streaming
## UX Acceptance Criteria
1. Streaming is visibly incremental — tokens appear as they arrive, not as one blob
2. Agent switcher shows the active agent (Coach/Tutor) and the response persona visibly changes
3. Loading state during connection (streaming cursor / skeleton) before first token
4. When ai-service is unreachable: inline error state + retry action on every chat/panel — no crashes, no console errors, no blank UI
5. `[DONE]` reliably ends the stream (input re-enables, no stuck "typing" state)
6. Navigating away mid-stream aborts cleanly — no leaked requests or post-unmount updates
7. All new UI uses design tokens, supports dark mode, meets WCAG AA contrast
8. Responsive at 375px, 768px, 1280px
9. No hardcoded model names or URLs in UI code — all via `NEXT_PUBLIC_AI_SERVICE_URL` and server meta events
10. `pnpm build` and `pnpm typecheck` pass with zero errors
+124
View File
@@ -0,0 +1,124 @@
# Nextcraft — PROJECT.md
## What This Is
Nextcraft is an AI-native outcome school where graduates prove what they can build — not what they can write. It credentials verifiable skill for a post-AI labor market, rejects legacy degree structures, and trains the workforce of tomorrow through hands-on competency stacks assessed entirely by AI tutors. The marketplace is the symbiotic second surface: employers meet AI-credentialed talent through an automated, algorithmically-matched job board that runs without human headcount.
**Single guiding outcome:** ≥1,000 learners placed in AI-orchestration roles via the Nextcraft marketplace within 36 months of launch, verified by CIRR-style third-party audit.
---
## Current Milestone: v0.2 — AI Tutor Architecture
**Scope:** The six AI tutor agents (Coach, Tutor, Lab, Assessor, Proctor, Mentor) as real LLM-backed services in a new `apps/ai-service` Python FastAPI application, wired into the existing v0.1 learner surface chat UI with streaming responses. Provider-agnostic LLM layer (ollama-cloud default, local endpoint + deterministic mock for tests). Lab/Assessor/Proctor operate on mock engine inputs (simulated telemetry, pre-baked artifacts) — their real engines (sandbox fabric, assessment engine, identity verification) are v0.3+.
**Status of v0.1:** Complete and shipped (v0.1.0). Founder agreement recorded (D-013).
**Tech stack:** v0.1 TS monorepo (pnpm/turborepo, Next.js) + new Python FastAPI service (`apps/ai-service`) with pydantic, SSE streaming, and an OpenAI-compatible provider client.
---
## Requirements (Validated)
The following requirements have been validated during specification and are locked for milestone v0.2 (REQ-F-001..006 activated from the deferred pool):
1. AI tutor service infrastructure — `apps/ai-service` FastAPI application, provider-agnostic LLM client, SSE streaming, session/state handling
2. Agent framework — base agent contracts, prompt management, streaming pipeline, structured outputs
3. Coach agent — pacing, motivation, retrieval practice (REQ-F-001)
4. Tutor agent — concept delivery, Socratic questioning (REQ-F-002)
5. Lab agent — in-flow feedback over simulated sandbox telemetry (REQ-F-003, mock inputs)
6. Assessor agent — rubric application to pre-baked artifacts and defenses (REQ-F-004, mock inputs)
7. Proctor agent — integrity signals from mock telemetry, coaching interventions (REQ-F-005, mock inputs)
8. Mentor agent — long-horizon career narrative (REQ-F-006)
9. Learner surface integration — streaming chat UI wired to the real service, error/loading states
## v0.1 Requirements (Complete)
All 28 v0.1 requirements (REQ-001..028) are complete and shipped as v0.1.0. See REQUIREMENTS.md traceability matrix.
## Clarified Assumptions (CLARIFY stage, full autonomy — auto-resolved)
| # | Ambiguity | Resolution | Confidence |
|---|-----------|------------|-------------|
| A-001 | Where does the ai-service live in the monorepo? | `apps/ai-service` — pnpm-workspace ignores Python; it integrates via root package.json scripts (`ai:dev`, `ai:test`), not as a pnpm package. Turbo gets passthrough tasks. | 0.95 |
| A-002 | How does the Next.js client talk to ai-service? | Direct fetch to `http://localhost:8420` (configurable via `NEXT_PUBLIC_AI_SERVICE_URL`) with SSE parsing. No Next.js API-route proxy in v0.2 — client components talk straight to the service. | 0.85 |
| A-003 | Session persistence? | In-memory dict keyed by session ID (v0.2 has no DB). Sessions lost on restart — acceptable for this milestone; store interface is DB-migration-ready. | 0.9 |
| A-004 | Port for ai-service? | 8420 (avoids common dev-port collisions with 3000/8000; documented in .env.example). | 0.8 |
| A-005 | Which ollama-cloud model? | Default `gemma4:31b` (probe-verified); configurable via `AI_TUTOR_MODEL` env. Model choice is a config, not code. | 0.85 |
| A-006 | Streaming format? | SSE with `data:` JSON lines (OpenAI-compatible delta objects), terminated by `data: [DONE]`. Matches the provider contract, so the provider layer passes deltas through unchanged. | 0.9 |
| A-007 | Agent routing in the chat UI? | Explicit agent switcher (Coach/Tutor) in the learner chat; Byte viewer always uses Tutor; sandbox uses Lab; assessment uses Assessor+Proctor; dashboard Mentor panel. No autonomous routing in v0.2. | 0.9 |
| A-008 | Auth between web and ai-service? | None in v0.2 (local dev surface). CORS limited to localhost origins. Real auth is v0.3+ with identity work. | 0.85 |
| A-009 | Python tooling? | `python3 -m venv` + pip (venv is the only available mechanism in this environment; no uv). Pydantic v2, FastAPI, uvicorn, pytest — all PyPI-reachable (verified). | 0.9 |
| A-010 | What happens when the LLM provider is unreachable? | Streaming endpoints return an error event; the UI shows error states with retry. Mock provider guarantees tests never call the cloud. | 0.9 |
## Requirements (Active — Future Milestones)
The following remain deferred beyond v0.2 and will be activated in subsequent milestones:
- Competency graph engine and adaptive pathways
- Assessment engine (process-trace grading, oral defense, per-learner variant tasks) — v0.3+
- Sandbox fabric (sandboxed IDE, design tool, simulation) — v0.3+
- Identity verification and age-gating logic (16+/18+) — the real KYC backend (v0.3+; visual flow already exists in v0.1)
- Marketplace job aggregation pipeline (3M+ jobs from 120K companies)
- AI-powered tagging, semantic vector search, company enrichment
- AI resume parsing and job matching
- SEO-optimized programmatic pages
- Payment processing and subscription management
- Human tutor marketplace (third-party course creation)
- B2B employer network functionality
- CIRR-style placement tracking and audit
## Requirements (Out of Scope — Per Vision Doctrine)
- Traditional accreditation — not sought, not pursued, not revisited
- Under-16 learners — excluded; AI school floor is 16+
- Marketplace for under-18 — excluded; marketplace is 18+ with verified identity
- Human tutors in the core school — excluded; humans exist only in the open marketplace
- Graded written exams without process trace — excluded
- Legacy job titles in curriculum — excluded
- Junk advertising — excluded
---
## Constraints
1. **MAJOR 0 until MVP** — all versions remain v0.x until the MVP is released and agreed upon by the founder
2. **No business logic in v0.1** — pure UI/UX prototype with mock data only
3. **High-fidelity interactive** — clickable navigation, realistic content, hover states, form inputs (non-functional), responsive breakpoints, loading states (mock)
4. **All data mocked** — no real API calls, no database, no authentication logic
5. **Shared design system** — all 4 surfaces use a unified component library with surface-specific theming via CSS variables
6. **TypeScript monorepo** — pnpm workspaces + turborepo for build orchestration
7. **Next.js App Router** — route groups for each surface: (learner), (marketplace), (employer), (admin)
8. **Solo founder constraint** — Phase 1 is build-only with no headcount; the prototype must be producible by a solo developer with AI assistance
9. **No traditional accreditation** — proprietary credential replaces degrees
10. **AI-first architecture** — AI tutors are the primary human-facing layer (in future milestones)
---
## Key Decisions
| ID | Decision | Rationale | Outcome |
|----|----------|-----------|---------|
| D-001 | Milestone v0.1 = UI/UX prototype only, no business logic | Founder directive: validate UX before building backend. Prototype must be agreed upon before proceeding to business logic. | Scope locked to frontend surfaces with mock data |
| D-002 | MAJOR 0 until MVP released | Founder directive: remain on v0.x until MVP is validated. Signals pre-release status. | All tags are v0.x.y until MVP agreement |
| D-003 | TypeScript monorepo (pnpm/turborepo) + Next.js | Unified codebase for all 4 surfaces. Shared component library, types, mock data. Next.js App Router for route-based surface separation. Python AI services deferred to later milestones. | Monorepo structure with apps/web + packages/* |
| D-004 | All 4 surfaces in v0.1 (Learner, Marketplace, Employer, Admin) | Founder selected all 4 surfaces for the prototype. Complete product visualization before any backend work. | 24 REQ-IDs covering all surfaces + shared infrastructure |
| D-005 | High-fidelity interactive prototype | Founder selected high-fidelity over wireframes. Realistic mock data, navigation flows, responsive layouts, component library. No backend calls. | Clickable prototype with realistic content |
| D-006 | Release forge = Gitea @ git.cloudinit.dev, owner=coreci, repo=nextcraft | Founder-provided Gitea instance for release management. Token stored in .ciagent/.env.secrets. | Ship workflow creates tags + releases on Gitea |
| D-007 | Full autonomy for CIAgent pipeline | Founder selected full autonomy. No HITL after clarify. Auto-decide above confidence 0.60. Escalation hooks: deploy, delete_data, merge_to_main. | Rapid autonomous building with kill criteria |
| D-008 | Shared component library in packages/ui/ | All surfaces share a unified design system with surface-specific theming via CSS variables. Promotes consistency and reduces duplication. | packages/ui, packages/mock-data, packages/types |
| D-009 | AI tutor UI as chat interface mockup with pre-scripted responses | The learner surface includes an AI tutor chat UI mockup. No real AI backend — pre-scripted responses simulate the Coach and Tutor agents. | Mockup only in v0.1, real agents in future milestone |
| D-010 | Age-gating represented as visual registration flow mockup | 16+/18+ age-gating shown as a UI flow with age verification step. No actual verification logic. | Visual mockup only |
| D-011 | Competency graph viewer as interactive static visualization | Admin surface includes a competency graph viewer using react-flow or similar. Mock competency nodes and edges. No real graph data. | Static graph with mock data |
| D-012 | Tech stack: TS monorepo + Python AI services (future) | v0.1 uses TS only. Python FastAPI microservices planned for AI tutor agents and assessment engine in later milestones. | v0.1: TS only. v0.2: TS + Python (apps/ai-service) |
| D-013 | v0.1 prototype founder-agreed; D-001 business-logic gate unlocked | Founder approved starting v0.2 with AI Tutor Architecture, which constitutes agreement of the v0.1 prototype per D-001. Recorded at v0.2 SPECIFY. | Business logic authorized from v0.2 onward |
| D-014 | Provider-agnostic LLM layer; ollama-cloud as initial provider | OpenAI-compatible client abstraction with pluggable providers: ollama-cloud (https://ollama.com/v1, default), local OpenAI-compatible endpoint, deterministic mock (tests/CI). Keys in gitignored .ciagent/.env.secrets, never in code or commits. | apps/ai-service llm package with 3 providers; default=ollama-cloud |
| D-015 | All six agents implemented as real LLM services; engines mocked | Coach/Tutor/Mentor fully real. Lab/Assessor/Proctor are real LLM logic over mock inputs (simulated telemetry, pre-baked artifacts) since sandbox fabric, assessment engine, and identity verification are v0.3+. Consistent with v0.1's mock-data approach. | REQ-F-001..006 complete in v0.2; real engines deferred to v0.3+ |
---
## Context
Nextcraft is being built by a solo founder using the CIAgent v0.7.0 autonomous pipeline. The vision document defines a 36-month, 3-phase roadmap to ≥1,000 placements. The first CIAgent milestone (v0.1) is intentionally scoped to UI/UX only — validating the product vision through interactive prototypes before any backend or business logic investment.
The four surfaces (Learner, Marketplace, Employer Dashboard, Admin) map directly to the four audiences in the vision: learners, employers, marketplace operators, and platform administrators. The prototype will demonstrate the complete user journey across all surfaces with realistic mock data reflecting the AI-era competency stacks, AI-orchestration job listings, and artifact+process trace+oral defense credential model.
+194
View File
@@ -0,0 +1,194 @@
# Nextcraft — REQUIREMENTS.md
## v0.2 Requirements (AI Tutor Architecture)
### AI Service Infrastructure
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-2-001 | apps/ai-service scaffolding: Python FastAPI app, pydantic settings, uvicorn, health endpoint, CORS, pytest setup, pnpm/turbo integration scripts | critical | 1 | complete |
| REQ-2-002 | Provider-agnostic LLM client: OpenAI-compatible provider interface with ollama-cloud (default), local-endpoint, and deterministic mock providers; key resolution from env files | critical | 1 | complete |
| REQ-2-003 | SSE streaming endpoint plumbing: chat completion streaming from provider through FastAPI to the Next.js client | critical | 1 | complete |
| REQ-2-004 | Agent framework: base agent contracts, session/state store, prompt management, streaming pipeline, structured output support | critical | 2 | complete |
### AI Tutor Agents
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-2-005 | Coach agent (REQ-F-001): pacing, motivation, retrieval practice — full LLM implementation | critical | 3 | complete |
| REQ-2-006 | Tutor agent (REQ-F-002): concept delivery, Socratic questioning — full LLM implementation | critical | 3 | complete |
| REQ-2-007 | Lab agent (REQ-F-003): in-flow feedback over simulated sandbox telemetry (mock inputs) | high | 4 | complete |
| REQ-2-008 | Assessor agent (REQ-F-004): rubric application to pre-baked artifacts and defense transcripts (mock inputs) | high | 4 | complete |
| REQ-2-009 | Proctor agent (REQ-F-005): integrity signals from mock telemetry, coaching interventions | high | 5 | complete |
| REQ-2-010 | Mentor agent (REQ-F-006): long-horizon career narrative | high | 5 | complete |
### Learner Surface Integration
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-2-011 | Learner chat UI wired to real service: streaming responses, agent routing, error and loading states | critical | 6 | complete |
| REQ-2-012 | Byte tutorial viewer, build sandbox, and assessment mockups surface Lab/Assessor/Proctor outputs (mock engine inputs) | high | 6 | complete |
---
## v0.1 Requirements (Complete)
### Infrastructure
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-001 | Monorepo scaffolding: pnpm workspaces, turborepo, Next.js app, TypeScript config, ESLint, Prettier | critical | 1 | complete |
| REQ-002 | Shared component library: design tokens, Button, Input, Card, Badge, Avatar, Dialog, Navigation, Table, Tabs, Progress, Tooltip, Skeleton, Toast | critical | 1 | pending |
| REQ-003 | Mock data layer: typed mock data for competency stacks, job listings, candidate profiles, employers, learner progress | critical | 1 | pending |
| REQ-004 | Routing and navigation: App Router route groups for (learner), (marketplace), (employer), (admin); shared layout components; cross-surface navigation | critical | 1 | pending |
| REQ-005 | Responsive layout system: mobile, tablet, desktop breakpoints; container components; grid system | high | 1 | pending |
### Learner Surface
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-006 | Landing page: hero, value proposition, program highlights, how-it-works (Byte→Build→Demonstrate→Defend), testimonials mockup, CTA to program catalog | critical | 2 | pending |
| REQ-007 | Program catalog: grid of competency stacks (AI Orchestration Engineer, AI Safety & Governance Lead, Human-AI Product Designer, AI-Augmented Field Operator, Computational Sciences Practitioner); stack cards with role descriptions | critical | 2 | pending |
| REQ-008 | Competency stack view: selected stack detail with 12-18 competencies listed, progress indicators, microcredential badges, mastery status | critical | 2 | pending |
| REQ-009 | Learner dashboard: active competencies, progress graph, recent artifacts, upcoming defenses, AI tutor chat mockup, milestone tracker | critical | 2 | pending |
| REQ-010 | Byte tutorial viewer: 3-7 minute micro-tutorial layout with concept panel, worked example panel, code/design/simulation viewer mockup | high | 2 | pending |
| REQ-011 | Build sandbox mockup: sandboxed IDE/design tool/simulation UI mockup with toolbar, file explorer, editor area, telemetry sidebar (process capture indicators) | high | 2 | pending |
| REQ-012 | Assessment/defense mockup: rubric display, AI reviewer panel, oral defense interface (voice/mic mockup), process trace timeline, artifact viewer | high | 2 | pending |
### Marketplace Surface
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-013 | Job board listing: searchable grid of AI-era job listings, filter sidebar (skills, seniority, location, salary), result cards with match score | critical | 3 | pending |
| REQ-014 | Job detail page: full job description, required competencies, employer info, application CTA, related jobs, AI-matched skills breakdown | critical | 3 | pending |
| REQ-015 | Employer profile: company overview, logo, description, social links, open positions, company culture mockup | high | 3 | pending |
| REQ-016 | Search/filter UI: semantic search bar, skill tags, category filters, seniority filter, remote/on-site toggle, saved searches mockup | critical | 3 | pending |
| REQ-017 | Pricing page: job posting packages (single, bundle, enterprise), talent access plans, subscription tiers, feature comparison table | high | 3 | pending |
### Employer Dashboard
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-018 | Employer dashboard overview: active postings, applicant pipeline, talent matches, analytics mockup (charts, placement stats) | critical | 4 | pending |
| REQ-019 | Talent search: searchable candidate database with AI-matched filters, candidate cards showing competency stack, microcredentials, artifact count, defense score | critical | 4 | pending |
| REQ-020 | Candidate profile view: full candidate profile with artifact gallery, process trace summary, oral defense transcripts, competency graph, microcredential verification | high | 4 | pending |
| REQ-021 | Posting management: create/edit/delete job postings, posting status tracking, applicant list per posting, interview pipeline mockup | high | 4 | pending |
### Admin Surface
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-022 | Admin overview: platform metrics (learners, employers, placements, completion rate, NPS), recent activity feed, system health mockup | critical | 5 | pending |
| REQ-023 | Learner management: searchable learner table, learner detail view, progress tracking, competency completion status, credential issuance log | high | 5 | pending |
| REQ-024 | Competency graph viewer: interactive visualization of competency stacks and their relationships, node/edge graph using react-flow, stack details on node click | high | 5 | pending |
| REQ-025 | Marketplace moderation: job posting review queue, employer verification queue, flagged content, content moderation tools mockup | high | 5 | pending |
### Polish & Integration
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-026 | Cross-surface navigation: role switcher (learner/employer/admin), breadcrumbs, consistent header/footer across all surfaces | critical | 6 | pending |
| REQ-027 | Visual consistency audit: typography scale, color palette, spacing system, dark mode toggle, accessibility baseline (WCAG AA contrast) | high | 6 | pending |
| REQ-028 | Component library finalization: Storybook setup, component documentation, prop tables, usage examples | medium | 6 | pending |
---
## v2 Requirements (Future Milestones — Deferred)
### Assessment Engine
| ID | Description | Priority | Milestone | Status |
|----|-------------|----------|-----------|--------|
| REQ-F-007 | Process-trace grading engine | high | v0.3+ | deferred |
| REQ-F-008 | Per-learner variant task generation | high | v0.3+ | deferred |
| REQ-F-009 | Oral/voice defense with AI examiner | high | v0.3+ | deferred |
| REQ-F-010 | Live in-environment build with telemetry | high | v0.3+ | deferred |
| REQ-F-021 | Sandbox fabric: sandboxed IDE, design tool, simulation | high | v0.3+ | deferred |
### Marketplace Engine
| ID | Description | Priority | Milestone | Status |
|----|-------------|----------|-----------|--------|
| REQ-F-011 | AI job aggregation pipeline (3M+ jobs from 120K companies) | high | v0.4+ | deferred |
| REQ-F-012 | AI-powered tagging (skills, categories, seniority) | high | v0.4+ | deferred |
| REQ-F-013 | Semantic vector search for role matching | high | v0.4+ | deferred |
| REQ-F-014 | AI company enrichment (logos, descriptions, social links) | medium | v0.4+ | deferred |
| REQ-F-015 | AI resume parsing (profile auto-fill) | medium | v0.4+ | deferred |
| REQ-F-016 | SEO-optimized programmatic pages | medium | v0.4+ | deferred |
### Platform Infrastructure
| ID | Description | Priority | Milestone | Status |
|----|-------------|----------|-----------|--------|
| REQ-F-017 | Identity verification and age-gating (16+/18+) — real KYC backend | high | v0.3+ | deferred |
| REQ-F-018 | Payment processing and subscription management | high | v0.3+ | deferred |
| REQ-F-019 | Human tutor marketplace (third-party courses) | medium | v0.4+ | deferred |
| REQ-F-020 | CIRR-style placement tracking and audit | medium | v0.5+ | deferred |
---
## Out of Scope (Per Vision Doctrine)
| Feature | Reason |
|---------|--------|
| Traditional accreditation | Not sought, not pursued, not revisited (Principle 4) |
| Under-16 learners | COPPA avoidance; AI school floor is 16+ |
| Marketplace for under-18 | Marketplace restricted to 18+ with verified identity |
| Human tutors in core school | Humans exist only in open marketplace as third-party sellers |
| Graded written exams without process trace | Trivially solvable by frontier AI; process-trace + oral defense more valid |
| Legacy job titles in curriculum | Programs train for AI-era roles, not obsolete ones |
| Junk advertising | Ad quality enforced algorithmically; relevant ads only |
---
## Traceability Matrix
### v0.2 (current milestone)
| Requirement | Phase | Status |
|-------------|-------|--------|
| REQ-2-001 | 1 | complete |
| REQ-2-002 | complete |
| REQ-2-003 | 1 | complete |
| REQ-2-004 | complete |
| REQ-2-005 | complete |
| REQ-2-006 | complete |
| REQ-2-007 | complete |
| REQ-2-008 | complete |
| REQ-2-009 | complete |
| REQ-2-010 | complete |
| REQ-2-011 | complete |
| REQ-2-012 | complete |
### v0.1 (complete)
| Requirement | Phase | Status |
|-------------|-------|--------|
| REQ-001 | 1 | complete |
| REQ-002 | 1 | complete |
| REQ-003 | 1 | complete |
| REQ-004 | 1 | complete |
| REQ-005 | 1 | complete |
| REQ-006 | 2 | complete |
| REQ-007 | 2 | complete |
| REQ-008 | 2 | complete |
| REQ-009 | 2 | complete |
| REQ-010 | 2 | complete |
| REQ-011 | 2 | complete |
| REQ-012 | 2 | complete |
| REQ-013 | 3 | complete |
| REQ-014 | 3 | complete |
| REQ-015 | 3 | complete |
| REQ-016 | 3 | complete |
| REQ-017 | 3 | complete |
| REQ-018 | 4 | complete |
| REQ-019 | 4 | complete |
| REQ-020 | 4 | complete |
| REQ-021 | 4 | complete |
| REQ-022 | 5 | complete |
| REQ-023 | 5 | complete |
| REQ-024 | 5 | complete |
| REQ-025 | 5 | complete |
| REQ-026 | 6 | complete |
| REQ-027 | 6 | complete |
| REQ-028 | 6 | complete |
+180
View File
@@ -0,0 +1,180 @@
# Nextcraft — ROADMAP.md
## Overview
**Milestone v0.2** — AI Tutor Architecture: The six AI tutor agents (Coach, Tutor, Lab, Assessor, Proctor, Mentor) as real LLM-backed services in a new `apps/ai-service` Python FastAPI application, wired into the existing v0.1 learner surface with streaming responses. Provider-agnostic LLM layer (ollama-cloud default). Lab/Assessor/Proctor operate on mock engine inputs — their real engines are v0.3+.
**Prior milestone:** v0.1 (nextcraft-ui-prototype) — complete, shipped as v0.1.0, founder-agreed (D-013).
**Milestone type:** Feature (new AI service + real agent capabilities)
**Tag line:** v0.1.x (patches on the v0.1 line; milestone release as v0.2.0)
**Branch:** milestone/v0.2-ai-tutor-architecture
---
## Phase List
| # | Name | Status | Depends On | Requirements | Success Criteria |
|---|------|--------|------------|--------------|------------------|
| 0 | Pre-execution | complete | — | — | Specification, clarify, research, plan complete; .ciagent/ files updated for v0.2 |
| 1 | AI service scaffolding | complete | 0 | REQ-2-001, REQ-2-002, REQ-2-003 | apps/ai-service runs (uvicorn), health endpoint responds, provider-agnostic LLM client with 3 providers (ollama-cloud/local/mock), SSE streaming verified, pytest suite passes with mock provider, turbo scripts wired |
| 2 | Agent framework | complete | 1 | REQ-2-004 | Base agent contract, session/state store, prompt templates, streaming pipeline, structured outputs; all tested |
| 3 | Coach + Tutor agents | complete | 2 | REQ-2-005, REQ-2-006 | Coach (pacing/motivation/retrieval practice) and Tutor (concept delivery/Socratic questioning) fully implemented with system prompts, tested against mock provider, wired to chat endpoint |
| 4 | Lab + Assessor agents | complete | 2 | REQ-2-007, REQ-2-008 | Lab consumes simulated sandbox telemetry (mock); Assessor applies rubrics to pre-baked artifacts/defense transcripts (mock); both tested |
| 5 | Proctor + Mentor agents | complete | 2 | REQ-2-009, REQ-2-010 | Proctor produces integrity signals + coaching interventions from mock telemetry; Mentor generates long-horizon career narrative; both tested |
| 6 | Learner surface integration | complete | 3, 4, 5 | REQ-2-011, REQ-2-012 | Learner chat streams real responses; agent routing works; byte viewer/sandbox/assessment mockups surface agent outputs; error/loading states; pnpm build + typecheck pass |
| 7 | Final review + ship | complete | 6 | — | Code review clean; audit passes; milestone tagged v0.2.0; release created on Gitea |
---
## Phase Details
### Phase 0: Pre-execution
**Goal:** Establish v0.2 specification, clarify ambiguities, research AI service architecture, create detailed plans.
**Stages:** SPECIFY → CLARIFY → RESEARCH → PLAN → GRILL → SHIP
**Deliverables:**
- Updated .ciagent/config.json, PROJECT.md, REQUIREMENTS.md, ROADMAP.md, ARCHITECTURE.md, PERSONAS.md, PLAN.md
**Success criteria:** All .ciagent/ files updated for v0.2; phase 0 shipped as v0.1.1.
---
### Phase 1: AI Service Scaffolding
**Goal:** Stand up apps/ai-service with the provider-agnostic LLM layer and SSE streaming.
**Requirements:** REQ-2-001, REQ-2-002, REQ-2-003
**Key deliverables:**
- apps/ai-service: FastAPI app, pydantic-settings, uvicorn, /health, CORS for localhost
- llm package: provider interface + ollama-cloud/local/mock providers; key resolution from .ciagent/.env.secrets via env
- SSE streaming: /v1/chat/stream endpoint streaming provider deltas
- pytest suite with mock provider; root scripts: ai:dev, ai:test; turbo integration
**Success criteria:**
- `python -m uvicorn` starts the service; /health returns 200
- Provider unit tests pass (mock); ollama-cloud integration probe works (manual)
- SSE stream delivers tokens to an HTTP client
---
### Phase 2: Agent Framework
**Goal:** Build the shared framework all six agents use.
**Requirements:** REQ-2-004
**Key deliverables:**
- BaseAgent contract: system prompt, message history, streaming completion, structured output
- Session/state store: in-memory per-learner session with message history
- Prompt management: per-agent system prompt templates with learner context injection
- Streaming pipeline: agent → provider → SSE with agent identification
- Structured outputs: JSON-schema outputs for Assessor rubric scores, Proctor signals
**Success criteria:**
- BaseAgent unit tests pass
- Session store tested (create/append/persist in-memory)
- Structured output parsing tested against mock provider
---
### Phase 3: Coach + Tutor Agents
**Goal:** Implement the two learner-facing conversational agents.
**Requirements:** REQ-2-005, REQ-2-006
**Key deliverables:**
- Coach agent: pacing guidance, motivation, retrieval practice prompts; distinct persona
- Tutor agent: concept delivery, Socratic questioning, worked examples
- Agent registry: route chat messages to the correct agent by context/selection
- Per-agent system prompts with competency-stack context injection from packages/mock-data
**Success criteria:**
- Both agents produce distinct, on-persona responses (verified against mock + ollama-cloud)
- Agent routing tested
- Both agents exposed via the chat streaming endpoint
---
### Phase 4: Lab + Assessor Agents
**Goal:** Implement the two build/assessment agents over mock engine inputs.
**Requirements:** REQ-2-007, REQ-2-008
**Key deliverables:**
- Lab agent: consumes simulated sandbox telemetry (mock event streams), produces in-flow feedback
- Assessor agent: applies rubrics to pre-baked artifacts and defense transcripts, returns structured scores + feedback
- Mock engine inputs: simulated telemetry generator, pre-baked artifact corpus in packages/mock-data
- Endpoints: /v1/lab/feedback, /v1/assessment/evaluate
**Success criteria:**
- Lab produces relevant feedback for mock telemetry scenarios
- Assessor returns structured rubric scores (JSON) for pre-baked artifacts
- Both tested against mock provider
---
### Phase 5: Proctor + Mentor Agents
**Goal:** Implement the integrity and narrative agents.
**Requirements:** REQ-2-009, REQ-2-010
**Key deliverables:**
- Proctor agent: integrity signals from mock telemetry (tab switches, idle time, paste events), coaching interventions
- Mentor agent: long-horizon career narrative, competency-stack progression guidance
- Endpoints: /v1/proctor/signals, /v1/mentor/narrative
**Success criteria:**
- Proctor produces classified signals with recommended interventions for mock scenarios
- Mentor produces coherent career-narrative responses
- Both tested against mock provider
---
### Phase 6: Learner Surface Integration
**Goal:** Wire the v0.1 learner surface to the real AI service.
**Requirements:** REQ-2-011, REQ-2-012
**Key deliverables:**
- Learner dashboard chat: real streaming via SSE, agent switcher (Coach/Tutor), error/loading states
- Byte tutorial viewer: Tutor concept explanations
- Build sandbox: Lab feedback panel fed by mock telemetry + Lab agent
- Assessment mockup: Assessor rubric output display, Proctor integrity banner
- Mentor panel on learner dashboard
**Success criteria:**
- Streaming chat works end-to-end with ai-service running
- All four learner surfaces surface agent outputs
- Graceful degradation when ai-service is down (error states, not crashes)
- `pnpm build` and `pnpm typecheck` pass
---
### Phase 7: Final Review + Ship
**Goal:** Code review, audit, milestone release.
**Key deliverables:**
- Multi-persona code review (correctness, testing, security, performance, maintainability)
- Project health audit (reconstruction test, .ciagent/ file discipline, branch hygiene, commit discipline)
- Milestone ship: merge milestone → main, tag v0.2.0, create Gitea release
**Success criteria:**
- Code review: P0 fixes applied, P1+ documented
- Audit: all checks pass, project state reconstructable from git log
- Ship: v0.2.0 tagged, milestone branch merged to main, Gitea release created — **release note explicitly states Lab/Assessor/Proctor operate on mock engine inputs (real engines v0.3+)** (G-5); dead `aiTutorResponses` export disposed of (G-5)
- All 12 v0.2 requirements marked complete
---
## v0.1 (Complete — Shipped as v0.1.0)
UI/UX Prototype: High-fidelity interactive prototype of all four Nextcraft surfaces. 7 phases (P0 + P1-P6 execution + P7 final). All 28 requirements complete. Tags v0.0.1v0.0.7, milestone release v0.1.0.
+54
View File
@@ -0,0 +1,54 @@
{
"version": "0.7.0",
"autonomy": {
"level": "full",
"decision_confidence_threshold": 0.6,
"escalation_hooks": ["deploy", "delete_data", "merge_to_main"],
"max_revision_iterations": 3,
"clarify_budget": 10
},
"release": {
"forge": "gitea",
"base_url": "https://git.cloudinit.dev",
"owner": "coreci",
"repo": "nextcraft"
},
"secrets": {
"scopes": ["gitea"],
"env_files": [".env", ".env.secrets", ".env.*"]
},
"ship": {
"per_phase": true,
"allow_skip": false,
"release_blocking": false,
"max_release_retries": 3
},
"verification": {
"bdd_default": false
},
"personas": {
"enabled": true,
"territory_enforcement": "warn"
},
"parallelization": {
"enabled": false,
"max_concurrent_agents": 1
},
"ideation": {
"max_ideas": 20,
"categories": ["security", "quality", "architecture", "coverage", "improvement", "spec", "chaos", "bdd"]
},
"security": {
"bash_allowlist": {
"blocked_env_vars": ["GITEA_TOKEN", "GITHUB_TOKEN", "GITLAB_TOKEN"]
}
},
"projects": [],
"active_project": null,
"milestone": {
"version": "v0.2",
"name": "ai-tutor-architecture",
"type": "feature",
"branch": "milestone/v0.2-ai-tutor-architecture"
}
}
+50
View File
@@ -0,0 +1,50 @@
# Environment files — never commit credentials
.env
.env.secrets
.env.*
!.env.example
# CIAgent secrets
.ciagent/.env.secrets
# Dependencies
node_modules/
__pycache__/
*.pyc
.venv/
venv/
# Build artifacts
dist/
/build/
.next/
.turbo/
*.tsbuildinfo
# Storybook
storybook-static/
# OS files
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
*.swo
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Test coverage
coverage/
.nyc_output/
# Python tooling caches
.pytest_cache/
.ruff_cache/
*.egg-info/
+3
View File
@@ -0,0 +1,3 @@
shamefully-hoist=true
strict-peer-dependencies=false
pnpm-approved-builds=esbuild,core-js-pure
+10
View File
@@ -0,0 +1,10 @@
# Nextcraft AI Service — environment template (copy values, never commit real keys)
# Real keys live in .ciagent/.env.secrets (gitignored) and are exported by scripts/dev.sh
AI_PORT=8420
AI_PROVIDER=ollama-cloud
AI_MODEL=gemma4:31b
AI_OLLAMA_CLOUD_BASE_URL=https://ollama.com/v1
AI_OLLAMA_CLOUD_API_KEY=
AI_LOCAL_BASE_URL=http://localhost:11434/v1
AI_JSON_MODE=auto
+90
View File
@@ -0,0 +1,90 @@
# Nextcraft AI Service (`apps/ai-service`)
Python FastAPI service hosting the six AI tutor agents (Coach, Tutor, Lab, Assessor, Proctor, Mentor) behind a provider-agnostic LLM layer. Port **8420**.
## Quickstart
```bash
# 1. Bootstrap (idempotent): venv + deps
bash scripts/bootstrap.sh
# 2. Run tests (mock provider only — zero network calls)
bash scripts/test.sh
# 3. Lint
bash scripts/lint.sh
# 4. Dev server (exports keys from .ciagent/.env.secrets if present)
bash scripts/dev.sh
```
Or via the monorepo root (`corepack pnpm install` first):
```bash
pnpm ai:bootstrap
pnpm ai:test
pnpm ai:lint
pnpm ai:dev
```
## Configuration
All settings use the `AI_` env prefix (pydantic-settings; see `.env.example`).
| Var | Default | Purpose |
|-----|---------|---------|
| `AI_PORT` | 8420 | Listen port |
| `AI_PROVIDER` | mock | `ollama-cloud` \| `local` \| `mock` |
| `AI_MODEL` | gemma4:31b | Model for all agents |
| `AI_OLLAMA_CLOUD_BASE_URL` | https://ollama.com/v1 | Cloud base URL |
| `AI_OLLAMA_CLOUD_API_KEY` | (empty) | Bearer key — **never commit** |
| `AI_JSON_MODE` | auto | `auto` sends response_format, degrades on 400; `off` never sends |
Tests run with `AI_PROVIDER=mock` (enforced in `tests/conftest.py` by an instance assertion) — the suite never calls the cloud.
## Endpoints
- `GET /health` — status, configured provider, model (no cloud call)
- `POST /v1/chat/stream` — SSE chat stream. Body: `{"agent": "coach"|"tutor", "session_id": "...", "messages": [{"role":"user","content":"..."}]}`. Unknown agents are rejected with 422.
SSE envelope (D-016): `meta` event first (agent/session/model), then `delta` events (incremental content), then `done`; on mid-stream failure an `error` event precedes the terminal `[DONE]` sentinel. sse-starlette emits `: ping` keep-alive comment lines on idle connections — clients must ignore frames without `data:`.
## Manual ollama-cloud persona probe (Phase 3, documented — not automated)
With the real provider, Coach and Tutor must produce distinct on-persona
responses to the same prompt:
```bash
# start with the cloud provider (keys exported from .ciagent/.env.secrets)
AI_PROVIDER=ollama-cloud .venv/bin/uvicorn ai_service.main:app --port 8420
# Coach: expect pacing + one concrete next action + a retrieval-practice question
curl -sN -X POST localhost:8420/v1/chat/stream -H 'Content-Type: application/json' \
-d '{"agent":"coach","session_id":"probe-coach","messages":[{"role":"user","content":"I am stuck on multi-agent communication patterns"}]}' \
| grep '^data:'
# Tutor: expect ONE concept + a worked example + a Socratic check question
curl -sN -X POST localhost:8420/v1/chat/stream -H 'Content-Type: application/json' \
-d '{"agent":"tutor","session_id":"probe-tutor","messages":[{"role":"user","content":"I am stuck on multi-agent communication patterns"}]}' \
| grep '^data:'
```
Verify: the two responses have visibly different voice/structure (Coach:
action + accountability; Tutor: concept + example + question). The
automated suite never calls the cloud — distinctness is enforced against
the deterministic mock (distinct system prompts → distinct hash-seeded
outputs).
## Layout
```
ai_service/
main.py app factory, lifespan (httpx pool), CORS, /health
config.py pydantic-settings
api/ endpoints (SSE envelope lives here, D-016)
llm/ provider layer — dumb pipe, no envelope logic
scripts/ bootstrap.sh dev.sh test.sh lint.sh
tests/ pytest — mock provider only
```
Boundary rules: `llm/` imports nothing from `agents/` or `api/`; `agents/` imports nothing from `api/`.
+3
View File
@@ -0,0 +1,3 @@
"""Nextcraft AI tutor service — six LLM agents behind a provider-agnostic layer."""
__version__ = "0.2.0"
@@ -0,0 +1,18 @@
"""Agent framework — BaseAgent ABC (D-018), registry, sessions, structured outputs.
Boundary rule: agents/ imports from llm/, prompts/, corpus/ — never from api/.
"""
from .base import BaseAgent
from .registry import AgentRegistry
from .session import InMemorySessionStore, SessionStore
from .structured import StructuredOutputError, extract_json_object
__all__ = [
"AgentRegistry",
"BaseAgent",
"InMemorySessionStore",
"SessionStore",
"StructuredOutputError",
"extract_json_object",
]
@@ -0,0 +1,92 @@
"""AssessorAgent — rubric application to pre-baked artifacts (REQ-2-008).
Structured-output showcase: applies the 4-layer defense (D-020) to return
a pydantic-validated rubric score. Mock engine inputs (corpus artifacts +
transcripts); real process-trace grading is v0.3+.
"""
from pydantic import BaseModel, Field
from ..corpus.artifacts import (
ArtifactSubmission,
AssessmentRubric,
DefenseTranscript,
render_rubric,
render_transcript,
)
from ..corpus.learner_context import LearnerContext, get_learner_context
from ..prompts.assessor import SYSTEM_PROMPT, render_context
from .base import BaseAgent
class CriterionScore(BaseModel):
criterion_id: str
name: str
score: int = Field(ge=0, le=100)
evidence: str
class RubricScore(BaseModel):
rubric_id: str
artifact_id: str
competency_id: str
scores: list[CriterionScore]
strengths: list[str] = Field(min_length=1, max_length=2)
gaps: list[str] = Field(min_length=1, max_length=2)
verdict: str # "mastered" | "developing" | "not_yet"
def weighted_total(self, rubric: AssessmentRubric) -> float:
by_id = {c.criterion_id: c for c in rubric.criteria}
total = 0.0
for s in self.scores:
total += s.score * by_id[s.criterion_id].weight
return total
RUBRIC_SCORE_SCHEMA_HINT = (
'{"rubric_id": "<id>", "artifact_id": "<id>", "competency_id": "<id>", '
'"scores": [{"criterion_id": "<id>", "name": "<name>", "score": <0-100>, '
'"evidence": "<one sentence>"}], "strengths": ["<one sentence>"], '
'"gaps": ["<one sentence>"], "verdict": "mastered"|"developing"|"not_yet"}'
)
class AssessorAgent(BaseAgent):
name = "assessor"
def system_prompt(self, learner_context: LearnerContext | None = None) -> str:
ctx = learner_context or get_learner_context()
return SYSTEM_PROMPT.format_map(render_context(ctx))
def build_evaluation_input(
self,
artifact: ArtifactSubmission,
rubric: AssessmentRubric,
transcript: DefenseTranscript | None,
) -> str:
parts = [
f"ARTIFACT: {artifact.name} ({artifact.artifact_id})",
f"Evidence excerpt: {artifact.evidence_excerpt}",
"",
render_rubric(rubric),
]
if transcript is not None:
parts += ["", render_transcript(transcript)]
return "\n".join(parts)
async def evaluate(
self,
artifact: ArtifactSubmission,
rubric: AssessmentRubric,
transcript: DefenseTranscript | None,
learner_context: LearnerContext | None = None,
) -> RubricScore:
evaluation_input = self.build_evaluation_input(artifact, rubric, transcript)
score: RubricScore = await self.structured_reply(
history=None,
user_input=evaluation_input,
learner_context=learner_context,
schema=RubricScore,
schema_hint=RUBRIC_SCORE_SCHEMA_HINT,
)
return score
+77
View File
@@ -0,0 +1,77 @@
"""BaseAgent ABC — the contract all six tutor agents implement (D-018).
Subclasses set `name`, override `system_prompt()`, and rarely `stream_reply()`.
The default pipeline: build_messages() → provider.stream_chat()/chat().
"""
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator
from pydantic import BaseModel
from ..config import Settings
from ..corpus.learner_context import LearnerContext
from ..llm.base import LLMProvider
from ..llm.types import Message
from .structured import structured_completion
class BaseAgent(ABC):
"""A tutor agent: system prompt + message assembly + provider delegation."""
name: str = "base"
def __init__(self, provider: LLMProvider, settings: Settings) -> None:
self.provider = provider
self.settings = settings
@abstractmethod
def system_prompt(self, learner_context: LearnerContext | None = None) -> str:
"""Return the agent's system prompt, learner-context-aware."""
def build_messages(
self,
history: list[Message] | None = None,
user_input: str = "",
learner_context: LearnerContext | None = None,
) -> list[Message]:
"""Compose the full message list: system prompt + history + user turn."""
messages: list[Message] = [
Message(role="system", content=self.system_prompt(learner_context))
]
for m in history or []:
messages.append(m)
if user_input:
messages.append(Message(role="user", content=user_input))
return messages
async def stream_reply(
self,
history: list[Message] | None = None,
user_input: str = "",
learner_context: LearnerContext | None = None,
response_format: dict | None = None,
) -> AsyncIterator[str]:
"""Stream incremental content deltas for a conversational reply."""
messages = self.build_messages(history, user_input, learner_context)
async for token in self.provider.stream_chat(
messages, model=self.settings.model, response_format=response_format
):
yield token
async def structured_reply(
self,
history: list[Message] | None = None,
user_input: str = "",
learner_context: LearnerContext | None = None,
schema: type[BaseModel] | None = None,
schema_hint: str = "",
) -> BaseModel:
"""Non-streaming completion parsed into a pydantic model (D-020 defense)."""
if schema is None:
raise ValueError("structured_reply requires a schema")
messages = self.build_messages(history, user_input, learner_context)
return await structured_completion(
self.provider, messages, model=self.settings.model,
schema=schema, schema_hint=schema_hint,
)
@@ -0,0 +1,13 @@
"""CoachAgent — pacing, motivation, retrieval practice (REQ-2-005)."""
from ..corpus.learner_context import LearnerContext, get_learner_context
from ..prompts.coach import SYSTEM_PROMPT, render_context
from .base import BaseAgent
class CoachAgent(BaseAgent):
name = "coach"
def system_prompt(self, learner_context: LearnerContext | None = None) -> str:
ctx = learner_context or get_learner_context()
return SYSTEM_PROMPT.format_map(render_context(ctx))
+37
View File
@@ -0,0 +1,37 @@
"""LabAgent — in-flow feedback over simulated sandbox telemetry (REQ-2-007).
Scenario-driven: consumes a LabTelemetryScenario from the corpus, renders
the event timeline into the conversation, streams concrete feedback.
No session chat — each request is one scenario read.
"""
from collections.abc import AsyncIterator
from ..config import Settings
from ..corpus.learner_context import LearnerContext, get_learner_context
from ..corpus.telemetry import LabTelemetryScenario, summarize_scenario
from ..llm.base import LLMProvider
from ..prompts.lab import SYSTEM_PROMPT, render_context
from .base import BaseAgent
class LabAgent(BaseAgent):
name = "lab"
def __init__(self, provider: LLMProvider, settings: Settings) -> None:
super().__init__(provider, settings)
def system_prompt(self, learner_context: LearnerContext | None = None) -> str:
ctx = learner_context or get_learner_context()
return SYSTEM_PROMPT.format_map(render_context(ctx))
async def stream_feedback(
self,
scenario: LabTelemetryScenario,
learner_context: LearnerContext | None = None,
) -> AsyncIterator[str]:
timeline = summarize_scenario(scenario)
async for token in self.stream_reply(
history=None, user_input=timeline, learner_context=learner_context
):
yield token
@@ -0,0 +1,17 @@
"""MentorAgent — long-horizon career narrative (REQ-2-010).
Streaming, session-backed conversational agent: the learner can ask
follow-up questions about their trajectory and the Mentor keeps context.
"""
from ..corpus.learner_context import LearnerContext, get_learner_context
from ..prompts.mentor import SYSTEM_PROMPT, render_context
from .base import BaseAgent
class MentorAgent(BaseAgent):
name = "mentor"
def system_prompt(self, learner_context: LearnerContext | None = None) -> str:
ctx = learner_context or get_learner_context()
return SYSTEM_PROMPT.format_map(render_context(ctx))
@@ -0,0 +1,57 @@
"""ProctorAgent — integrity signals + coaching interventions (REQ-2-009).
Consumes a ProctorScenario from the corpus, returns pydantic-validated
signal classifications via structured_reply (4-layer defense).
Mock engine inputs; real identity/attention signals are v0.3+.
"""
from pydantic import BaseModel, Field
from ..corpus.learner_context import LearnerContext, get_learner_context
from ..corpus.telemetry import ProctorScenario, summarize_proctor_scenario
from ..prompts.proctor import SYSTEM_PROMPT, render_context
from .base import BaseAgent
class IntegritySignal(BaseModel):
signal_type: str # e.g. "context_switch" | "idle_gap" | "large_paste"
severity: str # "low" | "medium" | "high"
note: str
class ProctorAssessment(BaseModel):
scenario_id: str
signals: list[IntegritySignal] = Field(min_length=0)
intervention: str # ONE supportive coaching recommendation
summary: str
PROCTOR_ASSESSMENT_SCHEMA_HINT = (
'{"scenario_id": "<id>", "signals": [{"signal_type": "<type>", '
'"severity": "low"|"medium"|"high", "note": "<one sentence>"}], '
'"intervention": "<one supportive recommendation>", '
'"summary": "<one sentence>"}'
)
class ProctorAgent(BaseAgent):
name = "proctor"
def system_prompt(self, learner_context: LearnerContext | None = None) -> str:
ctx = learner_context or get_learner_context()
return SYSTEM_PROMPT.format_map(render_context(ctx))
async def assess(
self,
scenario: ProctorScenario,
learner_context: LearnerContext | None = None,
) -> ProctorAssessment:
timeline = summarize_proctor_scenario(scenario)
assessment: ProctorAssessment = await self.structured_reply(
history=None,
user_input=timeline,
learner_context=learner_context,
schema=ProctorAssessment,
schema_hint=PROCTOR_ASSESSMENT_SCHEMA_HINT,
)
return assessment
@@ -0,0 +1,70 @@
"""Agent registry — explicit name → agent factory map (D-018, G-4).
Agents are registered centrally in their own phases (P3-P5) via
`registry.register(name, factory)`. One registration pattern, one registry.
"""
from collections.abc import Callable
from ..config import Settings
from ..llm.base import LLMProvider
from .base import BaseAgent
AgentFactory = Callable[[LLMProvider, Settings], BaseAgent]
def register_builtin_agents(registry: "AgentRegistry") -> None:
"""Central registration of all six shipped tutor agents (G-4: one pattern).
coach, tutor, lab, assessor, proctor, mentor. New agents register here
in their landing phase.
"""
from .assessor import AssessorAgent
from .coach import CoachAgent
from .lab import LabAgent
from .mentor import MentorAgent
from .proctor import ProctorAgent
from .tutor import TutorAgent
registry.register("coach", lambda provider, settings: CoachAgent(provider, settings))
registry.register("tutor", lambda provider, settings: TutorAgent(provider, settings))
registry.register("lab", lambda provider, settings: LabAgent(provider, settings))
registry.register(
"assessor", lambda provider, settings: AssessorAgent(provider, settings)
)
registry.register(
"proctor", lambda provider, settings: ProctorAgent(provider, settings)
)
registry.register(
"mentor", lambda provider, settings: MentorAgent(provider, settings)
)
class UnknownAgentError(KeyError):
"""Raised when resolving an agent name that was never registered."""
class DuplicateAgentError(ValueError):
"""Raised when registering an agent name that already exists."""
class AgentRegistry:
def __init__(self) -> None:
self._factories: dict[str, AgentFactory] = {}
def register(self, name: str, factory: AgentFactory) -> None:
if name in self._factories:
raise DuplicateAgentError(f"agent {name!r} already registered")
self._factories[name] = factory
def names(self) -> list[str]:
return sorted(self._factories)
def get(self, provider: LLMProvider, settings: Settings, name: str) -> BaseAgent:
try:
factory = self._factories[name]
except KeyError:
raise UnknownAgentError(
f"unknown agent {name!r}; registered: {self.names()}"
) from None
return factory(provider, settings)
@@ -0,0 +1,98 @@
"""SessionStore — protocol + in-memory implementation (D-019).
Protocol is DB-migration-ready (A-003): swap InMemorySessionStore for a
Redis/PG-backed implementation without touching the API layer.
Sessions are agent-scoped: switching agents starts a new session ID (avoids
persona bleed, A-007). History windowing happens here (last N messages),
controlling token growth per session.
"""
import asyncio
from collections import OrderedDict
from dataclasses import dataclass, field
from typing import Protocol
from ..llm.types import Message
DEFAULT_WINDOW = 20
DEFAULT_MAX_SESSIONS = 500
@dataclass
class AgentSession:
session_id: str
agent: str
learner_id: str = "seed-learner-1"
messages: list[Message] = field(default_factory=list)
class SessionStore(Protocol):
def get(self, session_id: str) -> AgentSession | None: ...
def create(
self, session_id: str, agent: str, learner_id: str = "seed-learner-1"
) -> AgentSession: ...
def append(self, session_id: str, message: Message) -> None: ...
def history_window(
self, session_id: str, max_messages: int = DEFAULT_WINDOW
) -> list[Message]: ...
def delete(self, session_id: str) -> None: ...
class InMemorySessionStore:
"""asyncio.Lock-guarded dict with 20-message windows and 500-cap LRU eviction."""
def __init__(
self,
window: int = DEFAULT_WINDOW,
max_sessions: int = DEFAULT_MAX_SESSIONS,
) -> None:
self._sessions: OrderedDict[str, AgentSession] = OrderedDict()
self._lock = asyncio.Lock()
self._window = window
self._max_sessions = max_sessions
async def get(self, session_id: str) -> AgentSession | None:
async with self._lock:
session = self._sessions.get(session_id)
if session is not None:
self._sessions.move_to_end(session_id) # LRU touch
return session
async def create(
self, session_id: str, agent: str, learner_id: str = "seed-learner-1"
) -> AgentSession:
async with self._lock:
session = AgentSession(session_id=session_id, agent=agent, learner_id=learner_id)
self._sessions[session_id] = session
self._evict_locked()
return session
async def append(self, session_id: str, message: Message) -> None:
async with self._lock:
session = self._sessions.get(session_id)
if session is None:
raise KeyError(f"unknown session {session_id!r}")
session.messages.append(message)
# Bound stored history too (window bounds replay, not storage):
# keep at most 2x window so retries/recent context survive.
if len(session.messages) > self._window * 2:
del session.messages[: len(session.messages) - self._window * 2]
self._sessions.move_to_end(session_id)
async def history_window(
self, session_id: str, max_messages: int = DEFAULT_WINDOW
) -> list[Message]:
async with self._lock:
session = self._sessions.get(session_id)
if session is None:
raise KeyError(f"unknown session {session_id!r}")
return list(session.messages[-max_messages:])
async def delete(self, session_id: str) -> None:
async with self._lock:
self._sessions.pop(session_id, None)
def _evict_locked(self) -> None:
while len(self._sessions) > self._max_sessions:
self._sessions.popitem(last=False) # evict least-recently-used
@@ -0,0 +1,117 @@
"""Structured output defense — 4 layers (D-020).
Layer 1: response_format={"type":"json_object"} request (auto-degrades on 400
inside the provider).
Layer 2: prompt-embedded schema hint ("Respond with ONLY valid JSON...").
Layer 3: defensive parse — strip markdown fences, extract first balanced
JSON object, pydantic model_validate.
Layer 4: single bounded retry with the validation error fed back.
"""
from typing import TypeVar
from pydantic import BaseModel, ValidationError
from ..llm.base import LLMProvider
from ..llm.types import Message
T = TypeVar("T", bound=BaseModel)
class StructuredOutputError(Exception):
"""Raised when the model output cannot be validated after one retry."""
def extract_json_object(text: str) -> str:
"""Strip fences and return the first balanced {...} block from text."""
stripped = text.strip()
if stripped.startswith("```"):
first_newline = stripped.find("\n")
if first_newline != -1:
stripped = stripped[first_newline + 1:]
if stripped.rstrip().endswith("```"):
stripped = stripped.rstrip()[:-3]
stripped = stripped.strip()
start = stripped.find("{")
if start == -1:
raise StructuredOutputError("no JSON object found in model output")
depth = 0
in_string = False
escape = False
for i, ch in enumerate(stripped[start:], start=start):
if escape:
escape = False
continue
if ch == "\\":
escape = True
continue
if ch == '"' and not escape:
in_string = not in_string
continue
if in_string:
continue
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return stripped[start:i + 1]
raise StructuredOutputError("unbalanced JSON object in model output")
def parse_structured(text: str, schema: type[T]) -> T:
"""Layer 3: fence-strip + first-balanced-object + pydantic validation."""
candidate = extract_json_object(text)
try:
return schema.model_validate_json(candidate)
except ValidationError as exc:
raise StructuredOutputError(f"schema validation failed: {exc}") from exc
def schema_instruction(schema_hint: str) -> str:
"""Layer 2: prompt-side schema text."""
return (
"Respond with ONLY a valid JSON object matching this schema — "
"no markdown fences, no prose outside the JSON. "
f"Schema: {schema_hint}"
)
async def structured_completion(
provider: LLMProvider,
messages: list[Message],
*,
model: str,
schema: type[T],
schema_hint: str,
retry_feedback: str | None = None,
) -> T:
"""Full 4-layer pipeline. One bounded retry (layer 4), then raise."""
# Build request: append schema instruction to the last user message (layer 2).
request = list(messages)
last_user = next((m for m in reversed(request) if m.role == "user"), None)
if last_user is not None:
request = [
Message(role=m.role, content=(m.content + "\n\n" + schema_instruction(schema_hint)))
if m is last_user else m
for m in request
]
response_format = {"type": "json_object"}
raw = await provider.chat(request, model=model, response_format=response_format)
try:
return parse_structured(raw, schema) # layers 1+2+3
except StructuredOutputError as exc:
# Layer 4: single bounded retry with error feedback
retry_prompt = (
f"Your previous response was invalid: {exc}. "
f"Return ONLY the corrected JSON matching: {schema_hint}"
)
request2 = list(messages)
request2.append(Message(role="user", content=retry_prompt))
raw2 = await provider.chat(request2, model=model, response_format=response_format)
try:
return parse_structured(raw2, schema)
except StructuredOutputError as exc2:
raise StructuredOutputError(
f"structured output failed after retry: {exc2}"
) from exc2
@@ -0,0 +1,13 @@
"""TutorAgent — concept delivery, Socratic questioning (REQ-2-006)."""
from ..corpus.learner_context import LearnerContext, get_learner_context
from ..prompts.tutor import SYSTEM_PROMPT, render_context
from .base import BaseAgent
class TutorAgent(BaseAgent):
name = "tutor"
def system_prompt(self, learner_context: LearnerContext | None = None) -> str:
ctx = learner_context or get_learner_context()
return SYSTEM_PROMPT.format_map(render_context(ctx))
@@ -0,0 +1,18 @@
"""API package — composes providers, sessions, and agents via DI.
Boundary rule: api/ composes agents/ and llm/; they never import api/.
"""
from .assessment import router as assessment_router
from .chat import router as chat_router
from .lab import router as lab_router
from .mentor import router as mentor_router
from .proctor import router as proctor_router
__all__ = [
"assessment_router",
"chat_router",
"lab_router",
"mentor_router",
"proctor_router",
]
@@ -0,0 +1,47 @@
"""POST /v1/assessment/evaluate — structured rubric scores (REQ-2-008).
JSON response (not SSE): a pydantic-validated RubricScore. Unknown
artifact → 404. The Assessor's structured output IS the payload.
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from ..agents.assessor import RubricScore
from ..agents.registry import AgentRegistry
from ..config import Settings
from ..corpus.artifacts import get_artifact_bundle, get_transcript_for_artifact
from ..corpus.learner_context import get_learner_context
from .deps import get_agent_registry, get_provider, get_settings
router = APIRouter(prefix="/v1")
class AssessmentRequest(BaseModel):
artifact_id: str = Field(min_length=1)
learner_id: str | None = None
@router.post("/assessment/evaluate", response_model=RubricScore)
async def assessment_evaluate(
body: AssessmentRequest,
registry: AgentRegistry = Depends(get_agent_registry),
settings: Settings = Depends(get_settings),
provider=Depends(get_provider),
) -> RubricScore:
bundle = get_artifact_bundle(body.artifact_id)
if bundle is None:
raise HTTPException(
status_code=404, detail=f"unknown artifact {body.artifact_id!r}"
)
artifact, rubric = bundle
transcript = get_transcript_for_artifact(artifact.artifact_id)
agent = registry.get(provider, settings, "assessor")
learner_context = get_learner_context(body.learner_id)
try:
return await agent.evaluate(artifact, rubric, transcript, learner_context)
except Exception as exc:
raise HTTPException(
status_code=502,
detail=f"assessment evaluation failed: {exc}",
) from exc
+128
View File
@@ -0,0 +1,128 @@
"""POST /v1/chat/stream — SSE chat with the D-016 envelope + agent routing.
Envelope: meta event first (flushed before first token), then raw content
deltas, then done; error event before [DONE] on mid-stream failure.
Pre-first-byte provider failures surface as in-band `provider_unavailable`
error events (SSE 200 headers are already committed once meta flushes).
Agent routing (A-007): the request names its agent; unknown agents are
rejected with 422. No autonomous routing in v0.2.
"""
import json
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sse_starlette.sse import EventSourceResponse
from ..agents.registry import AgentRegistry, UnknownAgentError
from ..agents.session import SessionStore
from ..config import Settings
from ..corpus.learner_context import get_learner_context
from ..llm.base import LLMProvider
from ..llm.types import Message
from .deps import get_agent_registry, get_provider, get_session_store, get_settings
router = APIRouter(prefix="/v1")
class ChatStreamRequest(BaseModel):
agent: str = Field(min_length=1)
session_id: str = Field(min_length=1)
learner_id: str | None = None
messages: list[Message] = Field(min_length=1)
@router.post("/chat/stream")
async def chat_stream(
body: ChatStreamRequest,
registry: AgentRegistry = Depends(get_agent_registry),
sessions: SessionStore = Depends(get_session_store),
settings: Settings = Depends(get_settings),
provider: LLMProvider = Depends(get_provider),
) -> EventSourceResponse:
# Route to the named agent (A-007); unknown → 422 before any streaming.
try:
agent = registry.get(provider, settings, body.agent)
except UnknownAgentError as exc:
raise HTTPException(
status_code=422, detail=str(exc)
) from None
learner_context = get_learner_context(body.learner_id)
# Agent-scoped session (A-007/G-4): persisted turn history, windowed replay.
session = await sessions.get(body.session_id)
if session is None:
session = await sessions.create(
body.session_id, agent=body.agent, learner_id=body.learner_id or "learner-001"
)
# The new user turn is the last message of the request.
user_turn = body.messages[-1]
history = await sessions.history_window(body.session_id)
# Retry dedupe (P1 from final review): a client retry resends the same
# turn after a provider failure — don't double-append it to history.
last_stored = history[-1] if history else None
is_retry = (
last_stored is not None
and last_stored.role == "user"
and last_stored.content == user_turn.content
)
if not is_retry:
await sessions.append(body.session_id, user_turn)
else:
# On retry the history replay should exclude the stored duplicate.
history = history[:-1]
async def event_stream() -> AsyncIterator[dict]:
yield {"event": "message", "data": json.dumps({
"type": "meta",
"agent": body.agent,
"session_id": body.session_id,
"model": settings.model,
})}
first_byte = True
reply_parts: list[str] = []
try:
async for token in agent.stream_reply(
history=history,
user_input=user_turn.content,
learner_context=learner_context,
):
first_byte = False
reply_parts.append(token)
yield {"event": "message", "data": json.dumps({
"type": "delta", "content": token
})}
full_reply = "".join(reply_parts)
if full_reply:
await sessions.append(
body.session_id, Message(role="assistant", content=full_reply)
)
yield {"event": "message", "data": json.dumps({
"type": "done", "finish_reason": "stop"
})}
yield {"event": "message", "data": "[DONE]"}
except Exception as exc: # CancelledError is BaseException — passes through
message = str(exc)
if first_byte:
yield {"event": "message", "data": json.dumps({
"type": "error", "code": "provider_unavailable", "message": message
})}
else:
yield {"event": "message", "data": json.dumps({
"type": "error", "code": "provider_error", "message": message
})}
# [DONE] is yielded from the except branch, NEVER from finally:
# a yield inside finally would re-raise after GeneratorExit when the
# client disconnects ("async generator ignored GeneratorExit").
yield {"event": "message", "data": "[DONE]"}
return EventSourceResponse(
event_stream(),
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
+24
View File
@@ -0,0 +1,24 @@
"""FastAPI dependencies — provider, settings, sessions, agents via app.state (DI)."""
from fastapi import Request
from ..agents.registry import AgentRegistry
from ..agents.session import SessionStore
from ..config import Settings
from ..llm.base import LLMProvider
def get_settings(request: Request) -> Settings:
return request.app.state.settings
def get_provider(request: Request) -> LLMProvider:
return request.app.state.provider
def get_session_store(request: Request) -> SessionStore:
return request.app.state.session_store
def get_agent_registry(request: Request) -> AgentRegistry:
return request.app.state.agent_registry
+72
View File
@@ -0,0 +1,72 @@
"""POST /v1/lab/feedback — SSE stream of Lab in-flow feedback (REQ-2-007).
D-016 envelope with agent=lab. Unknown scenario → 404 before streaming.
"""
import json
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sse_starlette.sse import EventSourceResponse
from ..agents.registry import AgentRegistry
from ..config import Settings
from ..corpus.learner_context import get_learner_context
from ..corpus.telemetry import get_lab_scenario
from .deps import get_agent_registry, get_provider, get_settings
router = APIRouter(prefix="/v1")
class LabFeedbackRequest(BaseModel):
scenario_id: str = Field(min_length=1)
learner_id: str | None = None
@router.post("/lab/feedback")
async def lab_feedback(
body: LabFeedbackRequest,
registry: AgentRegistry = Depends(get_agent_registry),
settings: Settings = Depends(get_settings),
provider=Depends(get_provider),
) -> EventSourceResponse:
scenario = get_lab_scenario(body.scenario_id)
if scenario is None:
raise HTTPException(
status_code=404, detail=f"unknown scenario {body.scenario_id!r}"
)
agent = registry.get(provider, settings, "lab")
learner_context = get_learner_context(body.learner_id)
async def event_stream() -> AsyncIterator[dict]:
yield {"event": "message", "data": json.dumps({
"type": "meta",
"agent": "lab",
"scenario_id": body.scenario_id,
"model": settings.model,
})}
first_byte = True
try:
async for token in agent.stream_feedback(scenario, learner_context):
first_byte = False
yield {"event": "message", "data": json.dumps({
"type": "delta", "content": token
})}
yield {"event": "message", "data": json.dumps({
"type": "done", "finish_reason": "stop"
})}
yield {"event": "message", "data": "[DONE]"}
except Exception as exc:
code = "provider_unavailable" if first_byte else "provider_error"
yield {"event": "message", "data": json.dumps({
"type": "error", "code": code, "message": str(exc)
})}
# [DONE] from except, not finally — a yield in finally would
# re-raise after GeneratorExit on client disconnect.
yield {"event": "message", "data": "[DONE]"}
return EventSourceResponse(
event_stream(),
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
+95
View File
@@ -0,0 +1,95 @@
"""POST /v1/mentor/narrative — SSE career narrative stream (REQ-2-010).
D-016 envelope with agent=mentor. Session-backed: the client supplies a
session_id; the Mentor keeps conversation context across follow-ups.
"""
import json
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sse_starlette.sse import EventSourceResponse
from ..agents.registry import AgentRegistry, UnknownAgentError
from ..agents.session import SessionStore
from ..config import Settings
from ..corpus.learner_context import get_learner_context
from ..llm.types import Message
from .deps import get_agent_registry, get_provider, get_session_store, get_settings
router = APIRouter(prefix="/v1")
class MentorNarrativeRequest(BaseModel):
session_id: str = Field(min_length=1)
prompt: str = Field(default="Narrate my trajectory.")
learner_id: str | None = None
@router.post("/mentor/narrative")
async def mentor_narrative(
body: MentorNarrativeRequest,
registry: AgentRegistry = Depends(get_agent_registry),
sessions: SessionStore = Depends(get_session_store),
settings: Settings = Depends(get_settings),
provider=Depends(get_provider),
) -> EventSourceResponse:
try:
agent = registry.get(provider, settings, "mentor")
except UnknownAgentError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from None
learner_context = get_learner_context(body.learner_id)
session = await sessions.get(body.session_id)
if session is None:
session = await sessions.create(
body.session_id, agent="mentor", learner_id=body.learner_id or "learner-001"
)
history = await sessions.history_window(body.session_id)
user_message = Message(role="user", content=body.prompt)
await sessions.append(body.session_id, user_message)
async def event_stream() -> AsyncIterator[dict]:
yield {"event": "message", "data": json.dumps({
"type": "meta",
"agent": "mentor",
"session_id": body.session_id,
"model": settings.model,
})}
first_byte = True
reply_parts: list[str] = []
try:
async for token in agent.stream_reply(
history=history,
user_input=body.prompt,
learner_context=learner_context,
):
first_byte = False
reply_parts.append(token)
yield {"event": "message", "data": json.dumps({
"type": "delta", "content": token
})}
full_reply = "".join(reply_parts)
if full_reply:
await sessions.append(
body.session_id, Message(role="assistant", content=full_reply)
)
yield {"event": "message", "data": json.dumps({
"type": "done", "finish_reason": "stop"
})}
yield {"event": "message", "data": "[DONE]"}
except Exception as exc:
code = "provider_unavailable" if first_byte else "provider_error"
yield {"event": "message", "data": json.dumps({
"type": "error", "code": code, "message": str(exc)
})}
# [DONE] from except, not finally — a yield in finally would
# re-raise after GeneratorExit on client disconnect.
yield {"event": "message", "data": "[DONE]"}
return EventSourceResponse(
event_stream(),
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
+44
View File
@@ -0,0 +1,44 @@
"""POST /v1/proctor/signals — structured integrity signals (REQ-2-009).
JSON response (not SSE): a pydantic-validated ProctorAssessment.
Unknown scenario → 404. Coaching-shaped interventions only.
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from ..agents.proctor import ProctorAssessment
from ..agents.registry import AgentRegistry
from ..config import Settings
from ..corpus.learner_context import get_learner_context
from ..corpus.telemetry import get_proctor_scenario
from .deps import get_agent_registry, get_provider, get_settings
router = APIRouter(prefix="/v1")
class ProctorRequest(BaseModel):
scenario_id: str = Field(min_length=1)
learner_id: str | None = None
@router.post("/proctor/signals", response_model=ProctorAssessment)
async def proctor_signals(
body: ProctorRequest,
registry: AgentRegistry = Depends(get_agent_registry),
settings: Settings = Depends(get_settings),
provider=Depends(get_provider),
) -> ProctorAssessment:
scenario = get_proctor_scenario(body.scenario_id)
if scenario is None:
raise HTTPException(
status_code=404, detail=f"unknown scenario {body.scenario_id!r}"
)
agent = registry.get(provider, settings, "proctor")
learner_context = get_learner_context(body.learner_id)
try:
return await agent.assess(scenario, learner_context)
except Exception as exc:
raise HTTPException(
status_code=502, detail=f"proctor assessment failed: {exc}"
) from exc
+18
View File
@@ -0,0 +1,18 @@
"""Service settings — pydantic-settings, env prefix AI_, .env support."""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="AI_", env_file=".env", extra="ignore")
port: int = 8420
provider: str = "mock"
model: str = "gemma4:31b"
ollama_cloud_base_url: str = "https://ollama.com/v1"
ollama_cloud_api_key: str = "" # SecretStr adds friction here; never logged, never echoed
local_base_url: str = "http://localhost:11434/v1"
# "auto" sends response_format and degrades on 400; "off" never sends it
json_mode: str = "auto"
@@ -0,0 +1,15 @@
"""Mock engine inputs — pydantic-typed corpus (D-021).
Convention-aligned with the TS `packages/mock-data` layer: identical ID
strings (stack-*, comp-*, learner-*, art-*, mc-*), cross-referenced by the
counterpart files. No codegen in v0.2 — alignment is by documented
convention; revisit codegen only if drift bites (v0.3).
"""
from .learner_context import LEARNER_CONTEXTS, LearnerContext, get_learner_context
__all__ = [
"LEARNER_CONTEXTS",
"LearnerContext",
"get_learner_context",
]
@@ -0,0 +1,209 @@
"""Pre-baked artifacts + rubrics + defense transcripts — Assessor mock inputs (REQ-2-008).
Counterpart: packages/mock-data/ai-scenarios.ts (artifact IDs string-identical,
D-021). Real process-trace grading is a v0.3+ engine (assessment engine);
these pre-baked submissions stand in for artifact + defense evaluation.
"""
from pydantic import BaseModel
class RubricCriterion(BaseModel):
criterion_id: str
name: str
weight: float
description: str
class AssessmentRubric(BaseModel):
rubric_id: str
competency_id: str
criteria: list[RubricCriterion]
class ArtifactSubmission(BaseModel):
artifact_id: str
name: str
artifact_type: str # "code" | "design" | "simulation"
competency_id: str
description: str
evidence_excerpt: str # what the grader sees of the artifact itself
class DefenseTranscript(BaseModel):
transcript_id: str
artifact_id: str
turns: list[dict] # {"speaker": "examiner"|"learner", "text": "..."}
_RUBRIC_ORCHESTRATION = AssessmentRubric(
rubric_id="rubric-orchestration-c002",
competency_id="stack-orchestration-c002",
criteria=[
RubricCriterion(
criterion_id="rc-architecture",
name="Agent architecture soundness",
weight=0.3,
description="State boundaries and responsibilities are clearly separated",
),
RubricCriterion(
criterion_id="rc-communication",
name="Inter-agent communication design",
weight=0.3,
description="Message contracts are explicit, typed, and failure-aware",
),
RubricCriterion(
criterion_id="rc-reliability",
name="Reliability engineering",
weight=0.25,
description="Retries, timeouts, and degradation paths handled",
),
RubricCriterion(
criterion_id="rc-process",
name="Process trace quality",
weight=0.15,
description="Telemetry shows iterative building with real checkpoints",
),
],
)
_RUBRIC_TOOL_USE = AssessmentRubric(
rubric_id="rubric-orchestration-c003",
competency_id="stack-orchestration-c003",
criteria=[
RubricCriterion(
criterion_id="rc-eval-design",
name="Evaluation design rigor",
weight=0.35,
description="Hypotheses, controls, and metrics are explicit and defensible",
),
RubricCriterion(
criterion_id="rc-eval-robustness",
name="Harness robustness",
weight=0.35,
description="Error handling, variance awareness, and reproducibility",
),
RubricCriterion(
criterion_id="rc-eval-insight",
name="Insight extraction",
weight=0.3,
description="Results are interpreted into concrete engineering decisions",
),
],
)
_ARTIFACT_RESEARCH_ASSISTANT = ArtifactSubmission(
artifact_id="art-eval-research-assistant",
name="Multi-agent research assistant (eval build)",
artifact_type="code",
competency_id="stack-orchestration-c002",
description=(
"LangGraph-based assistant planning, retrieving, drafting cited reviews."
),
evidence_excerpt=(
"planner.py defines state schema with explicit fields "
"(plan, findings, draft); tool_node.py wraps retrieval with a "
"3-retry loop and typed ToolMessage responses; tests cover "
"planner->tool->writer handoffs; README shows graph diagram"
),
)
_TRANSCRIPT_RESEARCH_ASSISTANT = DefenseTranscript(
transcript_id="defense-art-eval-research-assistant",
artifact_id="art-eval-research-assistant",
turns=[
{"speaker": "examiner",
"text": "Why did you give the planner sole write access to the plan field?"},
{"speaker": "learner",
"text": "So worker nodes can't mutate each other's inputs — "
"the state stays predictable and the graph is debuggable"},
{"speaker": "examiner",
"text": "What happens when the retrieval tool times out three times?"},
{"speaker": "learner",
"text": "The tool node degrades to a no-op ToolMessage with a "
"retry flag so the writer can fall back to existing findings"},
{"speaker": "examiner", "text": "How would you extend this to a third agent?"},
{"speaker": "learner",
"text": "Add a reviewer node with its own typed messages, same pattern"},
],
)
_ARTIFACT_RAG_DASHBOARD = ArtifactSubmission(
artifact_id="art-eval-rag-dashboard",
name="RAG retrieval quality dashboard (eval build)",
artifact_type="code",
competency_id="stack-orchestration-c003",
description="Dashboard comparing chunking strategies/rerankers across 800 queries.",
evidence_excerpt=(
"eval harness sweeps 4 chunk sizes x 3 rerankers; results table auto-generated; "
"no error handling on the query loader; tests only cover the happy path"
),
)
_TRANSCRIPT_RAG_DASHBOARD = DefenseTranscript(
transcript_id="defense-art-eval-rag-dashboard",
artifact_id="art-eval-rag-dashboard",
turns=[
{"speaker": "examiner", "text": "How did you control for query difficulty across runs?"},
{"speaker": "learner", "text": "I, um, used the same query set each time"},
{"speaker": "examiner", "text": "What happens if the query loader hits a malformed row?"},
{"speaker": "learner", "text": "I didn't handle that. It would probably crash."},
{"speaker": "examiner", "text": "What would you improve first?"},
{"speaker": "learner",
"text": "Probably add the error handling, then look at variance between runs"},
],
)
RUBRICS: dict[str, AssessmentRubric] = {
_RUBRIC_ORCHESTRATION.rubric_id: _RUBRIC_ORCHESTRATION,
_RUBRIC_TOOL_USE.rubric_id: _RUBRIC_TOOL_USE,
}
ARTIFACTS: dict[str, ArtifactSubmission] = {
a.artifact_id: a
for a in (_ARTIFACT_RESEARCH_ASSISTANT, _ARTIFACT_RAG_DASHBOARD)
}
TRANSCRIPTS: dict[str, DefenseTranscript] = {
t.transcript_id: t
for t in (_TRANSCRIPT_RESEARCH_ASSISTANT, _TRANSCRIPT_RAG_DASHBOARD)
}
def rubric_for_competency(competency_id: str) -> AssessmentRubric | None:
for rubric in RUBRICS.values():
if rubric.competency_id == competency_id:
return rubric
return None
def get_artifact_bundle(artifact_id: str) -> tuple[ArtifactSubmission, AssessmentRubric] | None:
"""Resolve (artifact, rubric) for an artifact ID; None if unknown."""
artifact = ARTIFACTS.get(artifact_id)
if artifact is None:
return None
rubric = rubric_for_competency(artifact.competency_id)
if rubric is None:
return None
return artifact, rubric
def get_transcript_for_artifact(artifact_id: str) -> DefenseTranscript | None:
for transcript in TRANSCRIPTS.values():
if transcript.artifact_id == artifact_id:
return transcript
return None
def render_rubric(rubric: AssessmentRubric) -> str:
lines = [f"Rubric: {rubric.rubric_id} (competency {rubric.competency_id})"]
for c in rubric.criteria:
lines.append(f"- {c.criterion_id} ({c.weight:.2f}): {c.name}{c.description}")
return "\n".join(lines)
def render_transcript(transcript: DefenseTranscript) -> str:
lines = [f"Defense transcript: {transcript.transcript_id}"]
for turn in transcript.turns:
lines.append(f"{turn['speaker']}: {turn['text']}")
return "\n".join(lines)
@@ -0,0 +1,108 @@
"""Learner context corpus — pydantic mirror of TS learner-progress.ts (D-021).
Counterpart: packages/mock-data/src/learner-progress.ts (or learner-progress.ts
at package root). IDs are string-identical: learner-001, stack-orchestration,
stack-safety, stack-orchestration-c00N, art-*, mc-*.
"""
from pydantic import BaseModel
class CompetencyProgress(BaseModel):
competency_id: str
title: str
status: str # "mastered" | "in_progress" | "not_started"
class StackProgress(BaseModel):
stack_id: str
title: str
percent: int
class LearnerContext(BaseModel):
learner_id: str
name: str
active_stacks: list[StackProgress]
active_competencies: list[CompetencyProgress]
microcredential_count: int
recent_artifacts: list[str] # artifact names
_STACK_ORCHESTRATION = StackProgress(
stack_id="stack-orchestration", title="AI Orchestration Engineer", percent=62
)
_STACK_SAFETY = StackProgress(
stack_id="stack-safety", title="AI Safety & Governance Lead", percent=41
)
_LEARNER_1 = LearnerContext(
learner_id="learner-001",
name="Alex Rivera",
active_stacks=[_STACK_ORCHESTRATION, _STACK_SAFETY],
active_competencies=[
CompetencyProgress(
competency_id="stack-orchestration-c001",
title="Agent architecture fundamentals",
status="mastered",
),
CompetencyProgress(
competency_id="stack-orchestration-c002",
title="Multi-agent communication patterns",
status="in_progress",
),
CompetencyProgress(
competency_id="stack-orchestration-c003",
title="Tool use and function calling",
status="in_progress",
),
CompetencyProgress(
competency_id="stack-safety-c021",
title="Red-team basics for agent systems",
status="in_progress",
),
],
microcredential_count=4,
recent_artifacts=[
"Multi-agent research assistant",
"RAG retrieval quality dashboard",
],
)
_LEARNER_2 = LearnerContext(
learner_id="learner-002",
name="Priya Chen",
active_stacks=[
StackProgress(
stack_id="stack-designer", title="Human-AI Product Designer", percent=55
),
],
active_competencies=[
CompetencyProgress(
competency_id="stack-designer-c001",
title="Prompt-to-prototype workflows",
status="mastered",
),
CompetencyProgress(
competency_id="stack-designer-c002",
title="Evaluating AI UX patterns",
status="in_progress",
),
],
microcredential_count=2,
recent_artifacts=["AI onboarding flow concept test"],
)
LEARNER_CONTEXTS: dict[str, LearnerContext] = {
_LEARNER_1.learner_id: _LEARNER_1,
_LEARNER_2.learner_id: _LEARNER_2,
}
DEFAULT_LEARNER_ID = "learner-001"
def get_learner_context(learner_id: str | None = None) -> LearnerContext:
"""Resolve a learner context by ID, falling back to the default seed."""
if learner_id is None:
return LEARNER_CONTEXTS[DEFAULT_LEARNER_ID]
return LEARNER_CONTEXTS.get(learner_id, LEARNER_CONTEXTS[DEFAULT_LEARNER_ID])
@@ -0,0 +1,178 @@
"""Simulated sandbox telemetry corpus — Lab agent mock engine inputs (REQ-2-007).
Counterpart: packages/mock-data/ai-scenarios.ts (scenario IDs string-identical,
D-021). Real sandbox telemetry is a v0.3+ engine (sandbox fabric); these
scripted event streams stand in for the build-session process trace.
"""
from pydantic import BaseModel
class TelemetryEvent(BaseModel):
timestamp: int # seconds since session start
kind: str # "keystroke_burst" | "file_save" | "run_tests" | "test_pass"
# | "test_fail" | "console_error" | "idle" | "paste" | "commit"
detail: str = ""
class LabTelemetryScenario(BaseModel):
scenario_id: str
title: str
competency_id: str
events: list[TelemetryEvent]
class ProctorEvent(BaseModel):
timestamp: int # seconds since session start
kind: str # "tab_switch" | "idle" | "paste_large" | "focus_lost" | "keystroke_burst"
detail: str = ""
class ProctorScenario(BaseModel):
scenario_id: str
title: str
competency_id: str
events: list[ProctorEvent]
_PROCTOR_SCENARIO_HEALTHY = ProctorScenario(
scenario_id="proctor-scenario-healthy",
title="Healthy defense session — focused throughout",
competency_id="stack-orchestration-c002",
events=[
ProctorEvent(timestamp=0, kind="keystroke_burst", detail="session begins"),
ProctorEvent(timestamp=310, kind="keystroke_burst", detail="long answer in progress"),
ProctorEvent(timestamp=640, kind="keystroke_burst", detail="revision pass"),
ProctorEvent(timestamp=900, kind="keystroke_burst", detail="final answer"),
],
)
_PROCTOR_SCENARIO_DISTRACTED = ProctorScenario(
scenario_id="proctor-scenario-distracted",
title="Distracted defense session — tab switches and idle gaps",
competency_id="stack-orchestration-c002",
events=[
ProctorEvent(timestamp=0, kind="keystroke_burst", detail="session begins"),
ProctorEvent(timestamp=120, kind="tab_switch", detail="to docs.nextjs.org"),
ProctorEvent(timestamp=125, kind="focus_lost", detail="window blur 40s"),
ProctorEvent(timestamp=300, kind="idle", detail="no activity for 5 minutes"),
ProctorEvent(timestamp=600, kind="tab_switch", detail="to github.com"),
ProctorEvent(timestamp=605, kind="focus_lost", detail="window blur 2m"),
ProctorEvent(timestamp=720, kind="keystroke_burst", detail="resumes typing"),
],
)
_PROCTOR_SCENARIO_FLAGGED = ProctorScenario(
scenario_id="proctor-scenario-flagged",
title="Flagged defense session — large paste during exam",
competency_id="stack-orchestration-c003",
events=[
ProctorEvent(timestamp=0, kind="keystroke_burst", detail="short intro typed"),
ProctorEvent(timestamp=85, kind="paste_large", detail="3,100 chars pasted in 2s"),
ProctorEvent(timestamp=90, kind="idle", detail="no activity for 4 minutes"),
ProctorEvent(timestamp=330, kind="paste_large", detail="2,800 chars pasted in 2s"),
],
)
PROCTOR_SCENARIOS: dict[str, ProctorScenario] = {
s.scenario_id: s
for s in (
_PROCTOR_SCENARIO_HEALTHY,
_PROCTOR_SCENARIO_DISTRACTED,
_PROCTOR_SCENARIO_FLAGGED,
)
}
def get_proctor_scenario(scenario_id: str) -> ProctorScenario | None:
return PROCTOR_SCENARIOS.get(scenario_id)
def summarize_proctor_scenario(scenario: ProctorScenario) -> str:
"""Render the proctor event timeline as compact text for prompt injection."""
lines = [f"Defense session: {scenario.title} (competency {scenario.competency_id})"]
for event in scenario.events:
lines.append(f"t+{event.timestamp}s {event.kind}: {event.detail}".rstrip(": "))
return "\n".join(lines)
_LAB_SCENARIO_STRONG = LabTelemetryScenario(
scenario_id="lab-scenario-strong",
title="Strong build session — multi-agent research assistant",
competency_id="stack-orchestration-c002",
events=[
TelemetryEvent(timestamp=0, kind="keystroke_burst", detail="planner.py"),
TelemetryEvent(timestamp=95, kind="file_save", detail="planner.py"),
TelemetryEvent(timestamp=120, kind="run_tests", detail="3 tests"),
TelemetryEvent(timestamp=126, kind="test_pass",
detail="3/3 passed"),
TelemetryEvent(timestamp=180, kind="keystroke_burst", detail="tool_node.py"),
TelemetryEvent(timestamp=260, kind="file_save", detail="tool_node.py"),
TelemetryEvent(timestamp=275, kind="run_tests", detail="4 tests"),
TelemetryEvent(timestamp=281, kind="test_pass",
detail="4/4 passed"),
TelemetryEvent(timestamp=340, kind="commit",
detail="add tool node with retries"),
],
)
_LAB_SCENARIO_STRUGGLING = LabTelemetryScenario(
scenario_id="lab-scenario-struggling",
title="Struggling build session — repeated failures, no checkpoints",
competency_id="stack-orchestration-c002",
events=[
TelemetryEvent(timestamp=0, kind="keystroke_burst", detail="main.py"),
TelemetryEvent(timestamp=210, kind="run_tests", detail="2 tests"),
TelemetryEvent(timestamp=215, kind="test_fail",
detail="ImportError: no module named 'tools'"),
TelemetryEvent(timestamp=216, kind="console_error", detail="traceback dumped"),
TelemetryEvent(timestamp=300, kind="keystroke_burst",
detail="main.py"),
TelemetryEvent(timestamp=520, kind="run_tests",
detail="2 tests"),
TelemetryEvent(timestamp=525, kind="test_fail",
detail="ImportError: no module named 'tools'"),
TelemetryEvent(timestamp=526, kind="console_error",
detail="same traceback as before"),
TelemetryEvent(timestamp=600, kind="idle",
detail="no activity for 6 minutes"),
TelemetryEvent(timestamp=960, kind="idle",
detail="no activity for 14 minutes"),
],
)
_LAB_SCENARIO_FLAGGED = LabTelemetryScenario(
scenario_id="lab-scenario-flagged",
title="Flagged build session — large paste, instant pass",
competency_id="stack-orchestration-c003",
events=[
TelemetryEvent(timestamp=0, kind="keystroke_burst", detail="eval.py"),
TelemetryEvent(timestamp=30, kind="paste",
detail="2,400 chars pasted into eval.py"),
TelemetryEvent(timestamp=45, kind="run_tests", detail="6 tests"),
TelemetryEvent(timestamp=47, kind="test_pass", detail="6/6 passed"),
TelemetryEvent(timestamp=48, kind="commit",
detail="finish eval harness"),
],
)
LAB_SCENARIOS: dict[str, LabTelemetryScenario] = {
s.scenario_id: s
for s in (_LAB_SCENARIO_STRONG, _LAB_SCENARIO_STRUGGLING, _LAB_SCENARIO_FLAGGED)
}
DEFAULT_LAB_SCENARIO_ID = "lab-scenario-strong"
def get_lab_scenario(scenario_id: str) -> LabTelemetryScenario | None:
return LAB_SCENARIOS.get(scenario_id)
def summarize_scenario(scenario: LabTelemetryScenario) -> str:
"""Render the event timeline as compact text for prompt injection."""
lines = [f"Session: {scenario.title} (competency {scenario.competency_id})"]
for event in scenario.events:
lines.append(f"t+{event.timestamp}s {event.kind}: {event.detail}".rstrip(": "))
return "\n".join(lines)
@@ -0,0 +1,15 @@
"""LLM package — provider-agnostic layer (D-017)."""
from .base import LLMProvider
from .factory import create_provider
from .mock import MockProvider
from .openai_compat import OpenAICompatProvider
from .types import Message
__all__ = [
"LLMProvider",
"Message",
"MockProvider",
"OpenAICompatProvider",
"create_provider",
]
+35
View File
@@ -0,0 +1,35 @@
"""LLMProvider protocol — the port all agents depend on (D-017).
Implementations: openai_compat.OpenAICompatProvider (ollama-cloud + local),
mock.MockProvider (deterministic, tests/CI). Providers are dumb pipes:
no envelope logic here — the API layer owns meta/done/error events (D-016).
"""
from collections.abc import AsyncIterator
from typing import Protocol
from .types import Message
class LLMProvider(Protocol):
async def stream_chat(
self,
messages: list[Message],
*,
model: str,
temperature: float = 0.7,
response_format: dict | None = None,
) -> AsyncIterator[str]:
"""Yield incremental content deltas (plain text chunks)."""
...
async def chat(
self,
messages: list[Message],
*,
model: str,
temperature: float = 0.7,
response_format: dict | None = None,
) -> str:
"""Non-streaming completion — returns the full reply text."""
...
+34
View File
@@ -0,0 +1,34 @@
"""Provider factory — selects the LLM provider from settings (D-014)."""
import httpx
from ..config import Settings
from .mock import MockProvider
from .openai_compat import OpenAICompatProvider
PROVIDER_NAMES = ("ollama-cloud", "local", "mock")
def create_provider(settings: Settings, http_client: httpx.AsyncClient):
"""Return the provider instance for settings.provider.
Raises ValueError for unknown provider names.
"""
if settings.provider == "ollama-cloud":
return OpenAICompatProvider(
http_client=http_client,
base_url=settings.ollama_cloud_base_url,
api_key=settings.ollama_cloud_api_key,
json_mode=settings.json_mode,
)
if settings.provider == "local":
return OpenAICompatProvider(
http_client=http_client,
base_url=settings.local_base_url,
json_mode=settings.json_mode,
)
if settings.provider == "mock":
return MockProvider()
raise ValueError(
f"unknown provider {settings.provider!r}; expected one of {PROVIDER_NAMES}"
)
+94
View File
@@ -0,0 +1,94 @@
"""Deterministic mock provider — tests and CI. NEVER calls the network.
Determinism: the reply text is seeded from the message content hash, so
identical inputs always produce identical outputs. Supports scripted
failure modes for error-path coverage (D-023, A-010).
"""
import hashlib
import json
from collections.abc import AsyncIterator
from .types import Message
_REPLIES = [
"Great question — let's break this down step by step and see where it leads.",
"Here is the key idea: small, verified moves compound into mastery over time.",
"Think about it this way: what would the simplest working version look like?",
"You are closer than you think. Try restating the goal in one sentence first.",
"Let me offer a different angle before we move to the next step.",
]
_JSON_REPLY = '{"summary": "mock structured reply", "confidence": 0.87}'
class MockProvider:
"""Scripted provider: deterministic streams, no network, failure injection."""
def __init__(self) -> None:
self.fail_before_first_token: bool = False
self.fail_mid_stream_at_index: int | None = None
self.abort_recorded: bool = False # set in stream finally-block (cancellation test)
def _reply_for(self, messages: list[Message], response_format: dict | None) -> str:
seed_src = "|".join(f"{m.role}:{m.content}" for m in messages)
if response_format is not None and response_format.get("type") == "json_object":
return _JSON_REPLY
digest = hashlib.sha256(seed_src.encode()).hexdigest()
base = _REPLIES[int(digest[:2], 16) % len(_REPLIES)]
# Deterministic seed tag guarantees distinct inputs → distinct replies
return f"{base} [#{digest[:8]}]"
def _tokenize(self, text: str) -> list[str]:
words = text.split(" ")
tokens: list[str] = []
for i, word in enumerate(words):
suffix = " " if i < len(words) - 1 else ""
tokens.append(word + suffix)
return tokens
async def stream_chat(
self,
messages: list[Message],
*,
model: str,
temperature: float = 0.7,
response_format: dict | None = None,
) -> AsyncIterator[str]:
if self.fail_before_first_token:
raise RuntimeError("mock provider: scripted failure before first token")
reply = self._reply_for(messages, response_format)
tokens = self._tokenize(reply)
try:
for i, token in enumerate(tokens):
if self.fail_mid_stream_at_index is not None and i == self.fail_mid_stream_at_index:
raise RuntimeError("mock provider: scripted mid-stream failure")
yield token
finally:
# Cancellation (GeneratorExit/CancelledError) lands here — tests assert this.
self.abort_recorded = True
async def chat(
self,
messages: list[Message],
*,
model: str,
temperature: float = 0.7,
response_format: dict | None = None,
) -> str:
if self.fail_before_first_token:
raise RuntimeError("mock provider: scripted failure before completion")
return self._reply_for(messages, response_format)
class ScriptedJSONProvider(MockProvider):
"""Mock variant returning a fixed JSON payload for structured tests."""
def __init__(self, payload: dict) -> None:
super().__init__()
self.payload = payload
def _reply_for(self, messages: list[Message], response_format: dict | None) -> str:
if response_format is not None and response_format.get("type") == "json_object":
return json.dumps(self.payload)
return super()._reply_for(messages, response_format)
@@ -0,0 +1,125 @@
"""OpenAI-compatible provider — one implementation serves ollama-cloud AND local
endpoints (they differ only in base_url/key). Raw httpx, no SDK (D-017).
Boundary rules:
- llm/ imports nothing from agents/ or api/
- api_key NEVER appears in exceptions, logs, or error messages
"""
import json
from collections.abc import AsyncIterator
import httpx
from .types import Message
class OpenAICompatProvider:
def __init__(
self,
http_client: httpx.AsyncClient,
base_url: str,
api_key: str = "",
json_mode: str = "auto",
) -> None:
self._client = http_client
self._base_url = base_url.rstrip("/")
self._api_key = api_key
self._json_mode = json_mode
def _headers(self) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if self._api_key:
headers["Authorization"] = f"Bearer {self._api_key}"
return headers
def _payload(
self,
messages: list[Message],
model: str,
temperature: float,
response_format: dict | None,
stream: bool,
) -> dict:
payload: dict = {
"model": model,
"messages": [{"role": m.role, "content": m.content} for m in messages],
"temperature": temperature,
}
if stream:
payload["stream"] = True
else:
payload["stream"] = False
# json_mode="auto": send response_format and degrade on 400; "off": never send
if response_format is not None and self._json_mode == "auto":
payload["response_format"] = response_format
return payload
def _sanitize(self, exc: Exception) -> RuntimeError:
text = str(exc)
if self._api_key and self._api_key in text:
text = text.replace(self._api_key, "[REDACTED]")
return RuntimeError(f"llm provider error: {text}")
async def stream_chat(
self,
messages: list[Message],
*,
model: str,
temperature: float = 0.7,
response_format: dict | None = None,
) -> AsyncIterator[str]:
payload = self._payload(messages, model, temperature, response_format, stream=True)
try:
async with self._client.stream(
"POST", f"{self._base_url}/chat/completions",
json=payload, headers=self._headers(),
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line or not line.startswith("data:"):
continue # keep-alive comments (": ping"), empty lines
data = line.removeprefix("data:").strip()
if data == "[DONE]":
return
try:
chunk = json.loads(data)
except json.JSONDecodeError:
continue # malformed line — tolerate (ollama-cloud quirks)
choices = chunk.get("choices") or []
if not choices:
continue
content = (choices[0].get("delta") or {}).get("content")
if content:
yield content
except httpx.HTTPError as exc:
raise self._sanitize(exc) from exc
async def chat(
self,
messages: list[Message],
*,
model: str,
temperature: float = 0.7,
response_format: dict | None = None,
) -> str:
payload = self._payload(messages, model, temperature, response_format, stream=False)
try:
response = await self._client.post(
f"{self._base_url}/chat/completions",
json=payload, headers=self._headers(),
)
if response.status_code == 400 and "response_format" in payload:
# json_mode auto-degrade (D-020 layer 1): retry once without it
payload.pop("response_format")
response = await self._client.post(
f"{self._base_url}/chat/completions",
json=payload, headers=self._headers(),
)
response.raise_for_status()
data = response.json()
return (data["choices"][0]["message"]["content"]) or ""
except httpx.HTTPError as exc:
raise self._sanitize(exc) from exc
except (KeyError, ValueError) as exc:
raise self._sanitize(exc) from exc
+16
View File
@@ -0,0 +1,16 @@
"""LLM layer types — messages.
Boundary rule: nothing in llm/ imports from agents/ or api/.
Providers yield plain str deltas (providers-as-pipes, D-016/D-017);
the OpenAI chunk shape lives only at the wire level inside
openai_compat.py. ChatDelta/ChoiceDelta were removed in Phase 3 after
two verification cycles confirmed no consumers (P2-a finding).
"""
from pydantic import BaseModel
class Message(BaseModel):
role: str
content: str
+65
View File
@@ -0,0 +1,65 @@
"""FastAPI app factory — lifespan, CORS, health, routers."""
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .agents.registry import AgentRegistry, register_builtin_agents
from .agents.session import InMemorySessionStore
from .api import (
assessment_router,
chat_router,
lab_router,
mentor_router,
proctor_router,
)
from .config import Settings
from .llm import create_provider
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or Settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Shared HTTP client pool (D-017): 10s connect / 300s read for cloud TTFT
timeout = httpx.Timeout(connect=10.0, read=300.0, write=30.0, pool=10.0)
app.state.http_client = httpx.AsyncClient(timeout=timeout)
app.state.settings = settings
app.state.provider = create_provider(settings, app.state.http_client)
app.state.session_store = InMemorySessionStore()
app.state.agent_registry = AgentRegistry()
register_builtin_agents(app.state.agent_registry)
yield
await app.state.http_client.aclose()
app = FastAPI(title="Nextcraft AI Service", version="0.2.0", lifespan=lifespan)
# A-008: localhost-only CORS, no credentials
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["Content-Type"],
allow_credentials=False,
)
@app.get("/health")
async def health() -> dict:
return {
"status": "ok",
"provider": settings.provider,
"model": settings.model,
}
app.include_router(chat_router)
app.include_router(lab_router)
app.include_router(assessment_router)
app.include_router(mentor_router)
app.include_router(proctor_router)
return app
app = create_app()
@@ -0,0 +1,22 @@
"""Prompt library — prompts are code: versioned in git, reviewed like code (D-018).
Each module exposes a `versioned SYSTEM_PROMPT` constant and a
`render_context(learner_context) -> dict` for str.format_map injection.
Final personas land in Phases 3-5; these are the initial drafts.
"""
from .coach import SYSTEM_PROMPT as COACH_PROMPT
from .coach import render_context as render_coach
from .mentor import SYSTEM_PROMPT as MENTOR_PROMPT
from .mentor import render_context as render_mentor
from .tutor import SYSTEM_PROMPT as TUTOR_PROMPT
from .tutor import render_context as render_tutor
__all__ = [
"COACH_PROMPT",
"MENTOR_PROMPT",
"TUTOR_PROMPT",
"render_coach",
"render_mentor",
"render_tutor",
]
@@ -0,0 +1,32 @@
"""Assessor agent prompt — rubric application to artifacts + defenses (REQ-2-008).
Final persona (Phase 4). Assessor is a rigorous, fair grader: scores each
criterion with evidence, cites what the learner did, returns ONLY valid
JSON matching the rubric schema.
Version: assessor-v2 (final for v0.2).
"""
SYSTEM_PROMPT = """You are Assessor, the grading agent of Nextcraft, an AI-native competency school.
Learner: {learner_name}.
You receive: (a) an artifact evidence excerpt, (b) its defense transcript,
and (c) the rubric for the competency. Your job:
- Score EVERY rubric criterion from 0-100, justified by evidence you can
point to in the artifact or transcript.
- Cite what the learner did ("the 3-retry loop in the tool node"), not
what they should have done — except in gaps, where the missed work goes.
- Strengths: the two strongest evidence points, each one sentence.
- Gaps: the two most important missed opportunities, each one sentence.
- Verdict: "mastered" | "developing" | "not_yet" — judged against the
rubric weights, honestly.
Rules:
- Rigorous but fair. A polished artifact with a weak defense is NOT mastery.
- Respond with ONLY a valid JSON object matching the provided schema —
no markdown fences, no prose outside the JSON."""
PROMPT_VERSION = "assessor-v2"
def render_context(learner_context) -> dict:
return {"learner_name": learner_context.name}
@@ -0,0 +1,46 @@
"""Coach agent prompt — pacing, motivation, retrieval practice (REQ-2-005).
Final persona (Phase 3). Coach is an accountability partner: warm,
action-oriented, allergic to fluff. Always ends with exactly one next action
and weaves retrieval practice into every reply.
Version: coach-v2 (final for v0.2).
"""
SYSTEM_PROMPT = """You are Coach, the pacing and motivation agent of Nextcraft,
an AI-native competency school.
Learner: {learner_name}
Active stack: {stacks}
Current focus: {progress}
Your style:
- Warm, direct, allergic to fluff. Two short paragraphs maximum.
- Pacing: name the learner's next concrete step in their current competency.
- Motivation: tie effort to their trajectory — what this unlocks, specifically.
- Retrieval practice: before introducing anything new, ask the learner to
recall or apply something they already covered (one pointed question).
Rules:
- End with exactly ONE clear next action phrased as a command ("Post your
plan for the orchestrator retry loop before starting").
- Never lecture; never list more than two options.
- If the learner is stuck or frustrated, slow down and shrink the step."""
PROMPT_VERSION = "coach-v2"
def render_context(learner_context) -> dict:
stacks = ", ".join(f"{s.title} ({s.percent}%)" for s in learner_context.active_stacks)
in_progress = [
c for c in learner_context.active_competencies if c.status == "in_progress"
]
progress = (
f"{in_progress[0].title} ({in_progress[0].competency_id})"
if in_progress
else "no competency currently in progress"
)
return {
"learner_name": learner_context.name,
"stacks": stacks or "none yet",
"progress": progress,
}
+36
View File
@@ -0,0 +1,36 @@
"""Lab agent prompt — in-flow feedback over sandbox telemetry (REQ-2-007).
Final persona (Phase 4). Lab is a pragmatic build partner: reads the
telemetry timeline, names the one most useful adjustment, gives one
concrete next step. Scenario-driven; no session chat.
Version: lab-v2 (final for v0.2).
"""
SYSTEM_PROMPT = """You are Lab, the in-flow feedback agent watching a learner
build in the Nextcraft sandbox.
Learner: {learner_name}. Active stack: {stacks}.
You receive a telemetry timeline of the learner's build session below.
Your job, in order:
1. Say what the telemetry shows — name the specific events that matter.
2. Name the single most useful adjustment (one thing, not a list).
3. Give one concrete next step phrased as a command.
Rules:
- Be specific to the events you see. If tests failed twice with the same
error, say so. If there is a long idle gap, name it.
- If the session looks healthy, say so briefly and set the next challenge.
- If something looks off (e.g., a huge paste followed by instant success),
treat it as a coaching moment, not an accusation — suggest a quick
self-check that would prove understanding.
- Three short paragraphs maximum. No headers, no bullet lists."""
PROMPT_VERSION = "lab-v2"
def render_context(learner_context) -> dict:
stacks = ", ".join(f"{s.title} ({s.percent}%)" for s in learner_context.active_stacks)
return {
"learner_name": learner_context.name,
"stacks": stacks or "none yet",
}
@@ -0,0 +1,49 @@
"""Mentor agent prompt — long-horizon career narrative (REQ-2-010).
Final persona (Phase 5). Mentor is a wise career guide: connects today's
competencies and artifacts to a long-horizon AI-era trajectory.
Version: mentor-v2 (final for v0.2).
"""
SYSTEM_PROMPT = """You are Mentor, the long-horizon career agent of Nextcraft,
an AI-native competency school.
Learner: {learner_name}
Active stacks: {stacks}
Current focus: {progress}
Microcredentials earned: {microcredentials}
Recent artifacts: {artifacts}
Your job: narrate the learner's trajectory in two to three paragraphs:
1. Where they are now — what their competency progress and artifacts say
about them as a builder (specific, evidence-based).
2. What their current stack unlocks next — name the next competency or
microcredential worth chasing and the role it points toward.
3. How they position in the AI-era labor market — which employer problems
their profile already answers.
Rules:
- Forward-looking and concrete. No fortune-telling, no flattery.
- Reference their artifacts by name at least once.
- Write like a mentor writing to one person, not a career-services brochure."""
PROMPT_VERSION = "mentor-v2"
def render_context(learner_context) -> dict:
stacks = ", ".join(f"{s.title} ({s.percent}%)" for s in learner_context.active_stacks)
in_progress = [
c for c in learner_context.active_competencies if c.status == "in_progress"
]
progress = (
f"{in_progress[0].title} ({in_progress[0].competency_id})"
if in_progress
else "no competency currently in progress"
)
return {
"learner_name": learner_context.name,
"stacks": stacks or "none yet",
"progress": progress,
"microcredentials": str(learner_context.microcredential_count),
"artifacts": ", ".join(learner_context.recent_artifacts) or "none yet",
}
@@ -0,0 +1,35 @@
"""Proctor agent prompt — integrity signals with coaching interventions (REQ-2-009).
Final persona (Phase 5). Proctor is a supportive observer, never punitive:
classifies signals, recommends ONE coaching intervention. Assume good
faith — most signals have innocent explanations.
Version: proctor-v2 (final for v0.2).
"""
SYSTEM_PROMPT = """You are Proctor, the integrity-support agent of Nextcraft,
an AI-native competency school.
Learner: {learner_name}.
You receive a telemetry timeline of defense-session events (tab switches,
idle gaps, large pastes, focus loss, keystroke bursts). Your job:
- Classify EACH notable signal: type (e.g. "context_switch", "idle_gap",
"large_paste"), severity ("low" | "medium" | "high"), and a one-sentence
note citing the event (timestamps and details).
- Recommend exactly ONE supportive coaching intervention for the session
overall — never punitive, never accusatory. Frame around helping the
learner succeed, e.g. "offer a short break", "invite them to explain
the pasted section in their own words".
Rules:
- Assume good faith. Tab switches to documentation are normal engineering.
- Idle gaps are often thinking. Only unusual patterns deserve higher severity.
- A large paste during an assessment deserves "high" severity but the
intervention stays coaching-shaped: verification, not punishment.
- Respond with ONLY a valid JSON object matching the provided schema —
no markdown fences, no prose outside the JSON."""
PROMPT_VERSION = "proctor-v2"
def render_context(learner_context) -> dict:
return {"learner_name": learner_context.name}
@@ -0,0 +1,46 @@
"""Tutor agent prompt — concept delivery, Socratic questioning (REQ-2-006).
Final persona (Phase 3). Tutor is a patient expert teacher: one concept at
a time, worked example first, Socratic check before moving on.
Version: tutor-v2 (final for v0.2).
"""
SYSTEM_PROMPT = """You are Tutor, the concept-delivery agent of Nextcraft,
an AI-native competency school.
Learner: {learner_name}
Active stack: {stacks}
Current focus: {progress}
Your style:
- Teach exactly ONE concept per reply. Never more.
- Structure: (1) name the concept in one sentence, (2) give a short worked
example (5-8 lines) the learner can trace, (3) ask ONE Socratic question
that checks whether they can apply it to a slightly different case.
Rules:
- Never dump walls of text. If the concept needs more than ~150 words, teach
only its first slice and promise the rest after the learner answers.
- If the learner's last message reveals a misconception, correct it gently
before teaching.
- If the learner answers your question, evaluate the answer explicitly
(right / partly right / not yet) before the next concept."""
PROMPT_VERSION = "tutor-v2"
def render_context(learner_context) -> dict:
stacks = ", ".join(f"{s.title} ({s.percent}%)" for s in learner_context.active_stacks)
in_progress = [
c for c in learner_context.active_competencies if c.status == "in_progress"
]
progress = (
f"{in_progress[0].title} ({in_progress[0].competency_id})"
if in_progress
else "no competency currently in progress"
)
return {
"learner_name": learner_context.name,
"stacks": stacks or "none yet",
"progress": progress,
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@nextcraft/ai-service",
"private": true,
"version": "0.2.0",
"scripts": {
"dev": "bash scripts/dev.sh",
"test": "bash scripts/test.sh",
"bootstrap": "bash scripts/bootstrap.sh",
"lint": "bash scripts/lint.sh"
}
}
+40
View File
@@ -0,0 +1,40 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "nextcraft-ai-service"
version = "0.2.0"
description = "Nextcraft AI tutor service — six LLM agents behind a provider-agnostic layer"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.141,<0.142",
"uvicorn>=0.52,<0.53",
"pydantic>=2.13,<2.14",
"pydantic-settings>=2.15,<2.16",
"httpx>=0.28,<0.29",
"sse-starlette>=3.4,<3.5",
]
[project.optional-dependencies]
dev = [
"pytest>=9.1,<10",
"pytest-asyncio>=1.4,<2",
"ruff>=0.14",
]
[tool.setuptools.packages.find]
include = ["ai_service*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B"]
# B008: Depends() in argument defaults is the idiomatic FastAPI DI pattern
ignore = ["B008"]
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Idempotent bootstrap: create venv + install deps.
# Handles Debian systems without python3-venv/ensurepip via --without-pip + get-pip.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
VENV="$APP_DIR/.venv"
mkdir -p "$HOME/.cache/ciagent"
if [ ! -x "$VENV/bin/python3" ]; then
if python3 -m venv "$VENV" 2>/dev/null; then
:
else
# No ensurepip available — create bare venv and bootstrap pip separately.
python3 -m venv --without-pip "$VENV"
fi
fi
if [ ! -x "$VENV/bin/pip" ]; then
GET_PIP="$HOME/.cache/ciagent/get-pip.py"
if [ ! -f "$GET_PIP" ]; then
curl -sSf --max-time 60 https://bootstrap.pypa.io/get-pip.py -o "$GET_PIP"
fi
"$VENV/bin/python3" "$GET_PIP" --quiet
fi
"$VENV/bin/pip" install --quiet --upgrade pip
"$VENV/bin/pip" install --quiet -e "$APP_DIR[dev]"
echo "bootstrap complete: $VENV"
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Dev server: export secrets (if present) then run uvicorn on :8420.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO_ROOT="$(cd "$APP_DIR/../.." && pwd)"
VENV="$APP_DIR/.venv"
if [ ! -x "$VENV/bin/uvicorn" ]; then
echo "venv missing — run scripts/bootstrap.sh first" >&2
exit 1
fi
SECRETS="$REPO_ROOT/.ciagent/.env.secrets"
if [ -f "$SECRETS" ]; then
while IFS='=' read -r key value; do
case "$key" in
OLLAMA_API_KEY) export AI_OLLAMA_CLOUD_API_KEY="$value" ;;
OLLAMA_BASE_URL) export AI_OLLAMA_CLOUD_BASE_URL="$value" ;;
AI_TUTOR_MODEL) export AI_MODEL="$value" ;;
esac
done < "$SECRETS"
fi
cd "$APP_DIR"
exec "$VENV/bin/uvicorn" ai_service.main:app --reload --port 8420
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Lint: ruff check over the ai-service tree.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
VENV="$APP_DIR/.venv"
if [ ! -x "$VENV/bin/ruff" ]; then
echo "venv missing — run scripts/bootstrap.sh first" >&2
exit 1
fi
cd "$APP_DIR"
exec "$VENV/bin/ruff" check .
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Test runner: pytest via venv — mock provider only, zero network calls.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
VENV="$APP_DIR/.venv"
if [ ! -x "$VENV/bin/pytest" ]; then
echo "venv missing — run scripts/bootstrap.sh first" >&2
exit 1
fi
cd "$APP_DIR"
exec "$VENV/bin/pytest" -q "$@"
View File
@@ -0,0 +1,103 @@
"""Assessor agent tests — structured rubric scores (REQ-2-008).
The Assessor is the structured-output showcase: tests use ScriptedJSONProvider
for valid payloads and exercise the 4-layer defense failure modes.
"""
import pytest
from ai_service.agents.assessor import AssessorAgent, RubricScore
from ai_service.agents.structured import StructuredOutputError
from ai_service.config import Settings
from ai_service.corpus.artifacts import (
get_artifact_bundle,
get_transcript_for_artifact,
render_rubric,
)
from ai_service.corpus.learner_context import get_learner_context
from ai_service.llm.mock import MockProvider, ScriptedJSONProvider
VALID_SCORE = {
"rubric_id": "rubric-orchestration-c002",
"artifact_id": "art-eval-research-assistant",
"competency_id": "stack-orchestration-c002",
"scores": [
{"criterion_id": "rc-architecture", "name": "Agent architecture soundness",
"score": 92, "evidence": "Explicit state schema with planner-only write access"},
{"criterion_id": "rc-communication", "name": "Inter-agent communication design",
"score": 88, "evidence": "Typed ToolMessage responses with retry flags"},
{"criterion_id": "rc-reliability", "name": "Reliability engineering",
"score": 85, "evidence": "3-retry loop with degradation path"},
{"criterion_id": "rc-process", "name": "Process trace quality",
"score": 90, "evidence": "Iterative saves with passing test checkpoints"},
],
"strengths": ["Clean state boundaries", "Failure-aware tool wrapping"],
"gaps": ["No reviewer node yet", "Graph diagram only in README"],
"verdict": "mastered",
}
def make_assessor(provider=None) -> AssessorAgent:
return AssessorAgent(provider or MockProvider(), Settings(provider="mock"))
def get_bundle(artifact_id="art-eval-research-assistant"):
bundle = get_artifact_bundle(artifact_id)
assert bundle is not None
return bundle
async def test_evaluate_returns_validated_rubric_score():
provider = ScriptedJSONProvider(VALID_SCORE)
assessor = make_assessor(provider)
artifact, rubric = get_bundle()
transcript = get_transcript_for_artifact(artifact.artifact_id)
result = await assessor.evaluate(artifact, rubric, transcript)
assert isinstance(result, RubricScore)
assert result.verdict == "mastered"
assert len(result.scores) == 4
assert result.weighted_total(rubric) == pytest.approx(
92 * 0.3 + 88 * 0.3 + 85 * 0.25 + 90 * 0.15
)
async def test_evaluate_rejects_invalid_schema_after_retry():
"""Plain MockProvider returns non-rubric JSON → 4-layer defense exhausts
its single retry and raises StructuredOutputError."""
assessor = make_assessor(MockProvider())
artifact, rubric = get_bundle()
transcript = get_transcript_for_artifact(artifact.artifact_id)
with pytest.raises(StructuredOutputError):
await assessor.evaluate(artifact, rubric, transcript)
def test_build_evaluation_input_carries_all_inputs():
assessor = make_assessor()
artifact, rubric = get_bundle()
transcript = get_transcript_for_artifact(artifact.artifact_id)
text = assessor.build_evaluation_input(artifact, rubric, transcript)
assert artifact.name in text
assert artifact.evidence_excerpt in text
assert "rc-architecture" in text # rubric rendered
assert "examiner:" in text # transcript rendered
def test_build_evaluation_input_without_transcript():
assessor = make_assessor()
artifact, rubric = get_bundle()
text = assessor.build_evaluation_input(artifact, rubric, None)
assert artifact.name in text
assert "examiner:" not in text
def test_system_prompt_names_assessor_persona():
prompt = make_assessor().system_prompt(get_learner_context())
assert "Assessor" in prompt
assert "ONLY" in prompt # JSON-only instruction
def test_rubric_render_in_prompt_is_complete():
"""The rubric passed to the model lists every criterion (fair grading)."""
artifact, rubric = get_bundle()
text = render_rubric(rubric)
assert text.count("rc-") == len(rubric.criteria)
+55
View File
@@ -0,0 +1,55 @@
"""BaseAgent contract tests — stub agent + mock provider."""
from ai_service.agents.base import BaseAgent
from ai_service.config import Settings
from ai_service.corpus.learner_context import get_learner_context
from ai_service.llm.mock import MockProvider
from ai_service.llm.types import Message
class StubAgent(BaseAgent):
name = "stub"
def system_prompt(self, learner_context=None) -> str:
return "You are Stub. Answer briefly."
def make_agent() -> StubAgent:
return StubAgent(MockProvider(), Settings(provider="mock"))
async def test_build_messages_composition():
agent = make_agent()
history = [Message(role="user", content="earlier"), Message(role="assistant", content="reply")]
messages = agent.build_messages(history, "new question")
assert messages[0].role == "system"
assert messages[0].content == "You are Stub. Answer briefly."
assert [m.content for m in messages[1:]] == ["earlier", "reply", "new question"]
async def test_stream_reply_yields_deltas():
agent = make_agent()
tokens = [t async for t in agent.stream_reply(user_input="hello")]
assert len(tokens) >= 1
assert all(isinstance(t, str) for t in tokens)
async def test_stream_reply_with_history_and_context():
agent = make_agent()
ctx = get_learner_context()
history = [Message(role="user", content="earlier"), Message(role="assistant", content="ok")]
tokens = [t async for t in agent.stream_reply(history, "next", ctx)]
assert tokens
async def test_structured_reply_requires_schema():
import pytest
agent = make_agent()
with pytest.raises(ValueError):
await agent.structured_reply(user_input="x", schema=None)
async def test_name_defaults():
assert make_agent().name == "stub"
@@ -0,0 +1,50 @@
"""Coach agent tests — persona, message assembly, streaming (REQ-2-005)."""
from ai_service.agents.coach import CoachAgent
from ai_service.config import Settings
from ai_service.corpus.learner_context import get_learner_context
from ai_service.llm.mock import MockProvider
from ai_service.llm.types import Message
def make_coach() -> CoachAgent:
return CoachAgent(MockProvider(), Settings(provider="mock"))
def test_system_prompt_includes_learner_context():
coach = make_coach()
prompt = coach.system_prompt(get_learner_context())
assert "Alex Rivera" in prompt
assert "AI Orchestration Engineer (62%)" in prompt
assert "retrieval practice" in prompt.lower()
assert "one clear next action" in prompt.lower()
def test_system_prompt_marks_current_focus():
coach = make_coach()
prompt = coach.system_prompt(get_learner_context())
assert "Multi-agent communication patterns" in prompt
def test_build_messages_system_history_user():
coach = make_coach()
history = [
Message(role="user", content="earlier"),
Message(role="assistant", content="reply"),
]
messages = coach.build_messages(history, "what next?", get_learner_context())
assert messages[0].role == "system"
assert "Coach" in messages[0].content
assert [m.content for m in messages[1:]] == ["earlier", "reply", "what next?"]
async def test_stream_reply_yields_deltas():
coach = make_coach()
ctx = get_learner_context()
tokens = [t async for t in coach.stream_reply(user_input="hello", learner_context=ctx)]
assert tokens
assert all(isinstance(t, str) for t in tokens)
def test_agent_name():
assert make_coach().name == "coach"
@@ -0,0 +1,71 @@
"""Learner context corpus tests — D-021 ID alignment + prompt rendering."""
from ai_service.corpus.learner_context import (
LEARNER_CONTEXTS,
get_learner_context,
)
from ai_service.prompts.assessor import render_context as render_assessor
from ai_service.prompts.coach import SYSTEM_PROMPT as COACH_PROMPT
from ai_service.prompts.coach import render_context as render_coach
from ai_service.prompts.lab import SYSTEM_PROMPT as LAB_PROMPT
from ai_service.prompts.lab import render_context as render_lab
from ai_service.prompts.mentor import SYSTEM_PROMPT as MENTOR_PROMPT
from ai_service.prompts.mentor import render_context as render_mentor
from ai_service.prompts.proctor import SYSTEM_PROMPT as PROCTOR_PROMPT
from ai_service.prompts.proctor import render_context as render_proctor
from ai_service.prompts.tutor import SYSTEM_PROMPT as TUTOR_PROMPT
from ai_service.prompts.tutor import render_context as render_tutor
def test_default_learner_resolves():
ctx = get_learner_context()
assert ctx.learner_id == "learner-001"
assert ctx.name == "Alex Rivera"
def test_unknown_learner_falls_back_to_default():
assert get_learner_context("nobody").learner_id == "learner-001"
def test_ids_align_with_ts_mock_data():
# D-021: identical ID strings to packages/mock-data (learner-progress.ts)
ctx = get_learner_context("learner-001")
stack_ids = {s.stack_id for s in ctx.active_stacks}
assert {"stack-orchestration", "stack-safety"} <= stack_ids
competency_ids = {c.competency_id for c in ctx.active_competencies}
assert "stack-orchestration-c001" in competency_ids
def test_all_prompt_modules_render_without_keyerror():
ctx = get_learner_context()
for render in (render_coach, render_tutor, render_mentor, render_assessor):
values = render(ctx)
assert isinstance(values, dict)
assert "learner_name" in values
assert values["learner_name"] == "Alex Rivera"
def test_prompts_format_map_with_rendered_context():
ctx = get_learner_context()
for prompt, render in (
(COACH_PROMPT, render_coach),
(TUTOR_PROMPT, render_tutor),
(MENTOR_PROMPT, render_mentor),
):
rendered = prompt.format_map(render(ctx))
assert "Alex Rivera" in rendered
assert "{" not in rendered # all placeholders filled
def test_lab_and_proctor_prompts_render():
ctx = get_learner_context()
# Each module renders through its OWN render_context (its own placeholders).
assert "Alex Rivera" in LAB_PROMPT.format_map(render_lab(ctx))
assert "Alex Rivera" in PROCTOR_PROMPT.format_map(render_proctor(ctx))
for prompt, render in ((LAB_PROMPT, render_lab), (PROCTOR_PROMPT, render_proctor)):
rendered = prompt.format_map(render(ctx))
assert "{" not in rendered # all placeholders filled
def test_two_seed_learners_exist():
assert set(LEARNER_CONTEXTS) == {"learner-001", "learner-002"}
@@ -0,0 +1,124 @@
"""Telemetry + artifacts corpus tests (REQ-2-007/008 inputs, D-021)."""
import re
from pathlib import Path
import pytest
from ai_service.corpus.artifacts import (
ARTIFACTS,
RUBRICS,
get_artifact_bundle,
get_transcript_for_artifact,
render_rubric,
render_transcript,
rubric_for_competency,
)
from ai_service.corpus.telemetry import (
LAB_SCENARIOS,
get_lab_scenario,
summarize_scenario,
)
def test_lab_scenarios_addressable_by_id():
for scenario_id in (
"lab-scenario-strong",
"lab-scenario-struggling",
"lab-scenario-flagged",
):
scenario = get_lab_scenario(scenario_id)
assert scenario is not None
assert scenario.scenario_id == scenario_id
def test_lab_scenarios_have_distinct_event_profiles():
strong = get_lab_scenario("lab-scenario-strong")
struggling = get_lab_scenario("lab-scenario-struggling")
kinds = lambda s: {e.kind for e in s.events} # noqa: E731
assert "test_pass" in kinds(strong)
assert "test_fail" in kinds(struggling)
assert "idle" in kinds(struggling)
assert "paste" in kinds(get_lab_scenario("lab-scenario-flagged"))
def test_summarize_scenario_mentions_events():
text = summarize_scenario(get_lab_scenario("lab-scenario-struggling"))
assert "test_fail" in text
assert "ImportError" in text
assert "stack-orchestration-c002" in text
def test_unknown_scenario_returns_none():
assert get_lab_scenario("lab-scenario-ghost") is None
def test_artifacts_and_rubrics_resolve():
bundle = get_artifact_bundle("art-eval-research-assistant")
assert bundle is not None
artifact, rubric = bundle
assert artifact.competency_id == "stack-orchestration-c002"
assert rubric.rubric_id == "rubric-orchestration-c002"
assert len(rubric.criteria) == 4
def test_unknown_artifact_returns_none():
assert get_artifact_bundle("art-eval-ghost") is None
def test_transcripts_pair_with_artifacts():
for artifact_id in ARTIFACTS:
transcript = get_transcript_for_artifact(artifact_id)
assert transcript is not None
assert transcript.artifact_id == artifact_id
assert len(transcript.turns) >= 4
def test_rubric_render_mentions_all_criteria():
rubric = rubric_for_competency("stack-orchestration-c002")
text = render_rubric(rubric)
for criterion in rubric.criteria:
assert criterion.criterion_id in text
def test_transcript_render_has_both_speakers():
transcript = get_transcript_for_artifact("art-eval-rag-dashboard")
text = render_transcript(transcript)
assert "examiner:" in text
assert "learner:" in text
_TS_SOURCE_CANDIDATES = [
Path(__file__).resolve().parents[4] / "packages" / "mock-data" / "ai-scenarios.ts",
Path(__file__).resolve().parents[2] / "ai-scenarios.ts",
]
def _ts_source() -> Path:
for candidate in _TS_SOURCE_CANDIDATES:
if candidate.exists():
return candidate
pytest.skip("ai-scenarios.ts not found in this checkout layout")
def test_corpus_ids_align_with_ts_mock_data():
"""D-021: Python corpus IDs string-identical to ai-scenarios.ts."""
content = _ts_source().read_text()
ts_ids = re.findall(r"id:\s*'([^']+)'", content)
ts_scenarios = ts_ids[: len(LAB_SCENARIOS)]
ts_artifacts = ts_ids[len(LAB_SCENARIOS):]
assert sorted(ts_scenarios) == sorted(LAB_SCENARIOS), (
f"scenario IDs drifted: py={sorted(LAB_SCENARIOS)} ts={sorted(ts_scenarios)}"
)
assert sorted(ts_artifacts) == sorted(ARTIFACTS), (
f"artifact IDs drifted: py={sorted(ARTIFACTS)} ts={sorted(ts_artifacts)}"
)
def test_rubric_weights_sum_to_one():
"""Every rubric's criteria weights must sum to exactly 1.0."""
for rubric in RUBRICS.values():
total = sum(c.weight for c in rubric.criteria)
assert total == pytest.approx(1.0), (
f"{rubric.rubric_id} weights sum to {total}, expected 1.0"
)
+42
View File
@@ -0,0 +1,42 @@
"""Lab agent tests — scenario-driven streaming feedback (REQ-2-007)."""
from ai_service.agents.lab import LabAgent
from ai_service.config import Settings
from ai_service.corpus.learner_context import get_learner_context
from ai_service.corpus.telemetry import get_lab_scenario, summarize_scenario
from ai_service.llm.mock import MockProvider
def make_lab() -> LabAgent:
return LabAgent(MockProvider(), Settings(provider="mock"))
def test_system_prompt_names_lab_persona():
prompt = make_lab().system_prompt(get_learner_context())
assert "Lab" in prompt
assert "Alex Rivera" in prompt
assert "telemetry" in prompt.lower()
async def test_stream_feedback_mentions_scenario_events():
"""Mock-scripted: feedback text derives from scenario timeline input —
distinct scenarios produce distinct (deterministic) replies."""
lab = make_lab()
ctx = get_learner_context()
strong = get_lab_scenario("lab-scenario-strong")
struggling = get_lab_scenario("lab-scenario-struggling")
strong_reply = "".join([t async for t in lab.stream_feedback(strong, ctx)])
struggling_reply = "".join([t async for t in lab.stream_feedback(struggling, ctx)])
assert strong_reply
assert strong_reply != struggling_reply # scenario-driven, not canned
def test_build_evaluation_messages_carry_timeline():
lab = make_lab()
scenario = get_lab_scenario("lab-scenario-flagged")
timeline = summarize_scenario(scenario)
messages = lab.build_messages(None, timeline, get_learner_context())
assert messages[0].role == "system"
assert "paste" in messages[-1].content
assert "2,400 chars" in messages[-1].content
@@ -0,0 +1,49 @@
"""Mentor agent tests — career narrative, session-backed (REQ-2-010)."""
from ai_service.agents.mentor import MentorAgent
from ai_service.config import Settings
from ai_service.corpus.learner_context import get_learner_context
from ai_service.llm.mock import MockProvider
from ai_service.llm.types import Message
def make_mentor() -> MentorAgent:
return MentorAgent(MockProvider(), Settings(provider="mock"))
def test_system_prompt_carries_full_learner_context():
prompt = make_mentor().system_prompt(get_learner_context())
assert "Alex Rivera" in prompt
assert "AI Orchestration Engineer (62%)" in prompt
assert "Multi-agent research assistant" in prompt # artifacts by name
assert "4" in prompt # microcredential count
def test_build_messages_system_history_user():
mentor = make_mentor()
history = [Message(role="user", content="what next?"),
Message(role="assistant", content="trajectory...")]
messages = mentor.build_messages(history, "tell me more", get_learner_context())
assert messages[0].role == "system"
assert "Mentor" in messages[0].content
assert [m.content for m in messages[1:]] == ["what next?", "trajectory...", "tell me more"]
async def test_stream_reply_mentions_learner_context_in_output():
"""Mock-scripted: narrative derives from context-injected messages —
different learner contexts produce distinct (deterministic) replies."""
mentor = make_mentor()
alex = get_learner_context("learner-001")
priya = get_learner_context("learner-002")
alex_reply = "".join(
[t async for t in mentor.stream_reply(user_input="narrate", learner_context=alex)]
)
priya_reply = "".join(
[t async for t in mentor.stream_reply(user_input="narrate", learner_context=priya)]
)
assert alex_reply
assert alex_reply != priya_reply
def test_agent_name():
assert make_mentor().name == "mentor"
@@ -0,0 +1,84 @@
"""Proctor agent tests — structured integrity signals (REQ-2-009)."""
import pytest
from ai_service.agents.proctor import ProctorAgent, ProctorAssessment
from ai_service.agents.structured import StructuredOutputError
from ai_service.config import Settings
from ai_service.corpus.learner_context import get_learner_context
from ai_service.corpus.telemetry import (
PROCTOR_SCENARIOS,
get_proctor_scenario,
summarize_proctor_scenario,
)
from ai_service.llm.mock import MockProvider, ScriptedJSONProvider
VALID_ASSESSMENT = {
"scenario_id": "proctor-scenario-distracted",
"signals": [
{"signal_type": "context_switch", "severity": "low",
"note": "Tab switch to docs at t+120s — normal engineering behavior"},
{"signal_type": "idle_gap", "severity": "medium",
"note": "5-minute idle at t+300s followed by more tab switches"},
],
"intervention": "Offer a short break and ask the learner to restate their answer plan",
"summary": "Distracted but explainable session; coach the focus pattern, don't flag it",
}
def make_proctor(provider=None) -> ProctorAgent:
return ProctorAgent(provider or MockProvider(), Settings(provider="mock"))
def test_proctor_scenarios_addressable_and_distinct_type():
healthy = get_proctor_scenario("proctor-scenario-healthy")
flagged = get_proctor_scenario("proctor-scenario-flagged")
assert healthy is not None and flagged is not None
kinds = lambda s: {e.kind for e in s.events} # noqa: E731
assert "tab_switch" in kinds(get_proctor_scenario("proctor-scenario-distracted"))
assert "paste_large" in kinds(flagged)
assert not kinds(healthy) & {"tab_switch", "paste_large", "focus_lost"}
def test_unknown_proctor_scenario_none():
assert get_proctor_scenario("proctor-scenario-ghost") is None
async def test_assess_returns_validated_signals():
provider = ScriptedJSONProvider(VALID_ASSESSMENT)
proctor = make_proctor(provider)
scenario = get_proctor_scenario("proctor-scenario-distracted")
result = await proctor.assess(scenario, get_learner_context())
assert isinstance(result, ProctorAssessment)
assert len(result.signals) == 2
assert result.signals[0].severity == "low"
assert "break" in result.intervention.lower()
async def test_assess_rejects_invalid_after_retry():
proctor = make_proctor(MockProvider()) # non-schema JSON
scenario = get_proctor_scenario("proctor-scenario-healthy")
with pytest.raises(StructuredOutputError):
await proctor.assess(scenario, get_learner_context())
def test_system_prompt_is_coaching_not_punitive():
prompt = make_proctor().system_prompt(get_learner_context())
assert "never punitive" in prompt.lower()
assert "good faith" in prompt.lower()
assert "ONLY" in prompt # JSON-only instruction
def test_timeline_summary_carries_events():
scenario = get_proctor_scenario("proctor-scenario-flagged")
text = summarize_proctor_scenario(scenario)
assert "paste_large" in text
assert "3,100 chars" in text
def test_all_three_proctor_scenarios_exist():
assert set(PROCTOR_SCENARIOS) == {
"proctor-scenario-healthy",
"proctor-scenario-distracted",
"proctor-scenario-flagged",
}
@@ -0,0 +1,132 @@
"""Agent registry tests — register/get round-trip, error paths (G-4), builtins."""
import pytest
from ai_service.agents.base import BaseAgent
from ai_service.agents.coach import CoachAgent
from ai_service.agents.registry import (
AgentRegistry,
DuplicateAgentError,
UnknownAgentError,
register_builtin_agents,
)
from ai_service.agents.tutor import TutorAgent
from ai_service.config import Settings
from ai_service.llm.mock import MockProvider
class DummyAgent(BaseAgent):
name = "dummy"
def system_prompt(self, learner_context=None) -> str:
return "dummy"
def make_factory():
def factory(provider, settings):
return DummyAgent(provider, settings)
return factory
def test_register_and_get():
registry = AgentRegistry()
registry.register("dummy", make_factory())
agent = registry.get(MockProvider(), Settings(provider="mock"), "dummy")
assert isinstance(agent, DummyAgent)
assert agent.name == "dummy"
def test_unknown_agent_raises():
registry = AgentRegistry()
with pytest.raises(UnknownAgentError):
registry.get(MockProvider(), Settings(provider="mock"), "ghost")
def test_duplicate_registration_raises():
registry = AgentRegistry()
registry.register("dummy", make_factory())
with pytest.raises(DuplicateAgentError):
registry.register("dummy", make_factory())
def test_names_sorted():
registry = AgentRegistry()
registry.register("zeta", make_factory())
registry.register("alpha", make_factory())
assert registry.names() == ["alpha", "zeta"]
def test_builtin_agents_register_and_resolve():
registry = AgentRegistry()
register_builtin_agents(registry)
assert set(registry.names()) >= {"coach", "tutor"}
settings = Settings(provider="mock")
coach = registry.get(MockProvider(), settings, "coach")
tutor = registry.get(MockProvider(), settings, "tutor")
assert isinstance(coach, CoachAgent)
assert isinstance(tutor, TutorAgent)
def test_lab_and_assessor_resolve_via_registry():
"""Phase 4: lab + assessor registered centrally (Task 4-3-01)."""
from ai_service.agents.assessor import AssessorAgent
from ai_service.agents.lab import LabAgent
registry = AgentRegistry()
register_builtin_agents(registry)
assert {"lab", "assessor"} <= set(registry.names())
settings = Settings(provider="mock")
lab = registry.get(MockProvider(), settings, "lab")
assessor = registry.get(MockProvider(), settings, "assessor")
assert isinstance(lab, LabAgent)
assert isinstance(assessor, AssessorAgent)
def test_proctor_and_mentor_resolve_via_registry():
"""Phase 5: proctor + mentor registered centrally (Tasks 5-1-02/5-2-01)."""
from ai_service.agents.mentor import MentorAgent
from ai_service.agents.proctor import ProctorAgent
registry = AgentRegistry()
register_builtin_agents(registry)
settings = Settings(provider="mock")
proctor = registry.get(MockProvider(), settings, "proctor")
mentor = registry.get(MockProvider(), settings, "mentor")
assert isinstance(proctor, ProctorAgent)
assert isinstance(mentor, MentorAgent)
def test_registry_resolves_all_six_agents():
"""Must-Have (Phase 5): the full roster — coach/tutor/lab/assessor/proctor/mentor."""
from ai_service.agents.assessor import AssessorAgent
from ai_service.agents.coach import CoachAgent
from ai_service.agents.lab import LabAgent
from ai_service.agents.mentor import MentorAgent
from ai_service.agents.proctor import ProctorAgent
from ai_service.agents.tutor import TutorAgent
registry = AgentRegistry()
register_builtin_agents(registry)
assert registry.names() == ["assessor", "coach", "lab", "mentor", "proctor", "tutor"]
settings = Settings(provider="mock")
expected = {
"coach": CoachAgent,
"tutor": TutorAgent,
"lab": LabAgent,
"assessor": AssessorAgent,
"proctor": ProctorAgent,
"mentor": MentorAgent,
}
for name, cls in expected.items():
agent = registry.get(MockProvider(), settings, name)
assert isinstance(agent, cls), f"{name} resolved to {type(agent).__name__}"
assert agent.name == name
def test_builtin_registration_is_idempotent_safe():
"""Duplicate registration raises — builtin bootstrap must be called once."""
registry = AgentRegistry()
register_builtin_agents(registry)
with pytest.raises(DuplicateAgentError):
register_builtin_agents(registry)
@@ -0,0 +1,86 @@
"""SessionStore tests — create/append/window/LRU/agent scoping (D-019)."""
import pytest
from ai_service.agents.session import InMemorySessionStore
from ai_service.llm.types import Message
def msg(n: int) -> Message:
return Message(role="user", content=f"m{n}")
async def test_create_and_get():
store = InMemorySessionStore()
session = await store.create("s1", agent="coach")
assert session.agent == "coach"
assert (await store.get("s1")).session_id == "s1"
assert await store.get("missing") is None
async def test_append_and_history_window():
store = InMemorySessionStore()
await store.create("s1", agent="coach")
for i in range(30):
await store.append("s1", msg(i))
window = await store.history_window("s1", max_messages=20)
assert len(window) == 20
assert window[0].content == "m10" # last 20 of m0..m29
assert window[-1].content == "m29"
async def test_default_window_uses_20():
store = InMemorySessionStore()
await store.create("s1", agent="coach")
for i in range(25):
await store.append("s1", msg(i))
window = await store.history_window("s1")
assert len(window) == 20
assert window[0].content == "m5"
async def test_lru_eviction_at_cap():
store = InMemorySessionStore(window=20, max_sessions=3)
for i in range(3):
await store.create(f"s{i}", agent="coach")
# touch s0 so s1 becomes least-recently-used
await store.get("s0")
await store.create("s3", agent="coach") # evicts s1
assert await store.get("s1") is None
assert await store.get("s0") is not None
assert await store.get("s2") is not None
assert await store.get("s3") is not None
async def test_lru_eviction_at_default_500_cap():
store = InMemorySessionStore() # defaults: window=20, max_sessions=500
for i in range(500):
await store.create(f"s{i}", agent="coach")
await store.get("s0") # touch the oldest → s1 becomes least-recently-used
await store.create("s500", agent="coach") # evicts s1
assert await store.get("s1") is None
assert await store.get("s0") is not None
assert await store.get("s499") is not None
assert await store.get("s500") is not None
async def test_append_unknown_session_raises():
store = InMemorySessionStore()
with pytest.raises(KeyError):
await store.append("nope", msg(0))
async def test_delete():
store = InMemorySessionStore()
await store.create("s1", agent="coach")
await store.delete("s1")
assert await store.get("s1") is None
async def test_sessions_are_agent_scoped():
store = InMemorySessionStore()
a = await store.create("coach-session", agent="coach")
b = await store.create("tutor-session", agent="tutor")
assert a.agent == "coach"
assert b.agent == "tutor"
assert a.session_id != b.session_id
@@ -0,0 +1,156 @@
"""Structured output defense tests — 4 layers (D-020), against mock providers."""
import pytest
from pydantic import BaseModel
from ai_service.agents.structured import (
StructuredOutputError,
extract_json_object,
parse_structured,
structured_completion,
)
from ai_service.llm.mock import MockProvider, ScriptedJSONProvider
from ai_service.llm.types import Message
class Score(BaseModel):
score: int
verdict: str
HINT = '{"score": <int 0-100>, "verdict": "<short verdict>"}'
def test_extract_json_plain():
assert extract_json_object('{"a": 1}') == '{"a": 1}'
def test_extract_json_fenced():
text = '```json\n{"a": 1}\n```'
assert extract_json_object(text) == '{"a": 1}'
def test_extract_json_with_prose_around():
text = 'Sure! Here is my answer: {"a": {"b": "x } y"}, "c": 2} hope that helps'
assert extract_json_object(text) == '{"a": {"b": "x } y"}, "c": 2}'
def test_extract_json_no_object_raises():
with pytest.raises(StructuredOutputError):
extract_json_object("no json here")
def test_extract_json_unbalanced_raises():
with pytest.raises(StructuredOutputError):
extract_json_object('{"a": 1')
def test_parse_structured_valid():
result = parse_structured('{"score": 88, "verdict": "solid"}', Score)
assert result.score == 88
def test_parse_structured_invalid_schema_raises():
with pytest.raises(StructuredOutputError):
parse_structured('{"wrong": "shape"}', Score)
async def test_structured_completion_happy_path():
provider = ScriptedJSONProvider({"score": 91, "verdict": "excellent work"})
messages = [Message(role="user", content="grade my artifact")]
result = await structured_completion(
provider, messages, model="m", schema=Score, schema_hint=HINT
)
assert result.score == 91
assert result.verdict == "excellent work"
async def test_structured_completion_retries_then_raises():
# Plain MockProvider returns non-schema JSON for json_object requests →
# both attempts fail validation → StructuredOutputError after ONE retry.
provider = MockProvider()
provider.received_calls = []
messages = [Message(role="user", content="grade me")]
with pytest.raises(StructuredOutputError):
await structured_completion(
provider, messages, model="m", schema=Score, schema_hint=HINT
)
async def test_structured_completion_retry_succeeds_after_invalid_first_response():
# Layer 4 recovery: first reply is wrong-schema fenced JSON, retry is valid.
class FlakyProvider(MockProvider):
def __init__(self) -> None:
super().__init__()
self.n = 0
self.retry_request: list[Message] = []
async def chat(self, messages, *, model, temperature=0.7, response_format=None):
self.n += 1
if self.n == 1:
return '```json\n{"summary": "wrong shape"}\n```'
self.retry_request = list(messages)
return '{"score": 75, "verdict": "recovered"}'
provider = FlakyProvider()
messages = [Message(role="user", content="grade me")]
result = await structured_completion(
provider, messages, model="m", schema=Score, schema_hint=HINT
)
assert result.score == 75
assert result.verdict == "recovered"
assert provider.n == 2
# The retry must feed the validation error back to the model.
retry_contents = " ".join(m.content for m in provider.retry_request)
assert "previous response was invalid" in retry_contents
assert HINT in retry_contents
async def test_structured_completion_is_bounded_to_one_retry():
# Permanently-invalid provider: exactly two provider calls, then raise.
class CountingProvider(MockProvider):
def __init__(self) -> None:
super().__init__()
self.calls = 0
async def chat(self, messages, *, model, temperature=0.7, response_format=None):
self.calls += 1
return await super().chat(
messages, model=model, response_format=response_format
)
provider = CountingProvider()
messages = [Message(role="user", content="grade me")]
with pytest.raises(StructuredOutputError, match="after retry"):
await structured_completion(
provider, messages, model="m", schema=Score, schema_hint=HINT
)
assert provider.calls == 2
async def test_structured_completion_sends_schema_instruction():
"""Layer 2: the schema hint must reach the provider in the request."""
provider = ScriptedJSONProvider({"score": 70, "verdict": "passing"})
captured: list[list] = []
original = provider.chat
async def recording_chat(messages, *, model, temperature=0.7, response_format=None):
captured.append(list(messages))
return await original(
messages, model=model, temperature=temperature, response_format=response_format
)
provider.chat = recording_chat
messages = [Message(role="user", content="grade")]
await structured_completion(provider, messages, model="m", schema=Score, schema_hint=HINT)
assert captured, "provider was never called"
last_user = next(m for m in reversed(captured[0]) if m.role == "user")
assert HINT in last_user.content
assert "ONLY" in last_user.content # JSON-only instruction present
# Determinism: same request yields same reply
again = await structured_completion(
provider, messages, model="m", schema=Score, schema_hint=HINT
)
assert again.score == 70
@@ -0,0 +1,76 @@
"""Tutor agent tests — persona, Socratic structure, distinctness vs Coach (REQ-2-006)."""
from ai_service.agents.coach import CoachAgent
from ai_service.agents.tutor import TutorAgent
from ai_service.config import Settings
from ai_service.corpus.learner_context import get_learner_context
from ai_service.llm.mock import MockProvider
from ai_service.llm.types import Message
def make_tutor() -> TutorAgent:
return TutorAgent(MockProvider(), Settings(provider="mock"))
def test_system_prompt_includes_learner_context():
tutor = make_tutor()
prompt = tutor.system_prompt(get_learner_context())
assert "Alex Rivera" in prompt
assert "Socratic" in prompt
assert "ONE concept" in prompt
def test_system_prompt_marks_current_focus():
tutor = make_tutor()
prompt = tutor.system_prompt(get_learner_context())
assert "Multi-agent communication patterns" in prompt
def test_build_messages_system_history_user():
tutor = make_tutor()
history = [Message(role="user", content="q1"), Message(role="assistant", content="a1")]
messages = tutor.build_messages(history, "explain again", get_learner_context())
assert messages[0].role == "system"
assert "Tutor" in messages[0].content
assert [m.content for m in messages[1:]] == ["q1", "a1", "explain again"]
async def test_stream_reply_yields_deltas():
tutor = make_tutor()
tokens = [
t
async for t in tutor.stream_reply(
user_input="hi", learner_context=get_learner_context()
)
]
assert tokens
def test_coach_and_tutor_personas_are_distinct():
"""Distinct system prompts (P3 must-have)."""
ctx = get_learner_context()
coach_prompt = CoachAgent(MockProvider(), Settings(provider="mock")).system_prompt(ctx)
tutor_prompt = TutorAgent(MockProvider(), Settings(provider="mock")).system_prompt(ctx)
assert coach_prompt != tutor_prompt
assert "retrieval practice" in coach_prompt.lower()
assert "Socratic" in tutor_prompt
async def test_coach_and_tutor_stream_outputs_are_distinct():
"""Mock outputs differ because system prompts differ (hash-seeded on content)."""
ctx = get_learner_context()
settings = Settings(provider="mock")
async def full_reply(agent):
tokens = [
t async for t in agent.stream_reply(user_input="stuck", learner_context=ctx)
]
return "".join(tokens)
coach_tokens = await full_reply(CoachAgent(MockProvider(), settings))
tutor_tokens = await full_reply(TutorAgent(MockProvider(), settings))
assert coach_tokens != tutor_tokens
def test_agent_name():
assert make_tutor().name == "tutor"
@@ -0,0 +1,79 @@
"""Assessment evaluate endpoint tests — validated JSON, 404s (REQ-2-008)."""
from ai_service.agents.assessor import RubricScore
from ai_service.llm.mock import ScriptedJSONProvider
VALID_SCORE = {
"rubric_id": "rubric-orchestration-c002",
"artifact_id": "art-eval-research-assistant",
"competency_id": "stack-orchestration-c002",
"scores": [
{"criterion_id": "rc-architecture", "name": "Agent architecture soundness",
"score": 92, "evidence": "Explicit state schema"},
{"criterion_id": "rc-communication", "name": "Inter-agent communication design",
"score": 88, "evidence": "Typed ToolMessage responses"},
{"criterion_id": "rc-reliability", "name": "Reliability engineering",
"score": 85, "evidence": "3-retry loop"},
{"criterion_id": "rc-process", "name": "Process trace quality",
"score": 90, "evidence": "Iterative checkpoints"},
],
"strengths": ["Clean state boundaries", "Failure-aware tools"],
"gaps": ["No reviewer node", "Diagram only in README"],
"verdict": "mastered",
}
def test_evaluate_returns_validated_rubric_json(client):
# Swap the app provider for a scripted-JSON provider for this test
original = client.app.state.provider
client.app.state.provider = ScriptedJSONProvider(VALID_SCORE)
try:
response = client.post(
"/v1/assessment/evaluate",
json={"artifact_id": "art-eval-research-assistant"},
)
finally:
client.app.state.provider = original
assert response.status_code == 200
data = response.json()
validated = RubricScore.model_validate(data) # response contract holds
assert validated.verdict == "mastered"
assert len(validated.scores) == 4
def test_unknown_artifact_404(client):
response = client.post("/v1/assessment/evaluate", json={"artifact_id": "ghost"})
assert response.status_code == 404
assert "ghost" in response.json()["detail"]
def test_unparseable_provider_502(client):
"""Plain MockProvider yields non-rubric JSON → structured defense exhausts
retry → endpoint translates to 502 (bad gateway to the model)."""
# default mock already returns non-rubric JSON
response = client.post(
"/v1/assessment/evaluate",
json={"artifact_id": "art-eval-research-assistant"},
)
assert response.status_code == 502
assert "failed" in response.json()["detail"].lower()
def test_missing_artifact_id_422(client):
response = client.post("/v1/assessment/evaluate", json={})
assert response.status_code == 422
def test_second_artifact_also_evaluates(client):
original = client.app.state.provider
payload = dict(VALID_SCORE, artifact_id="art-eval-rag-dashboard")
client.app.state.provider = ScriptedJSONProvider(payload)
try:
response = client.post(
"/v1/assessment/evaluate", json={"artifact_id": "art-eval-rag-dashboard"}
)
finally:
client.app.state.provider = original
assert response.status_code == 200
assert response.json()["artifact_id"] == "art-eval-rag-dashboard"
@@ -0,0 +1,241 @@
"""Chat endpoint session integration tests — history persistence + windowed replay."""
import json
def stream_events(client, payload) -> list[dict]:
with client.stream("POST", "/v1/chat/stream", json=payload) as response:
assert response.status_code == 200
events = []
for line in response.iter_lines():
if line.startswith("data:"):
d = line.removeprefix("data:").strip()
if d == "[DONE]":
events.append({"type": "[DONE]"})
else:
events.append(json.loads(d))
return events
def test_first_turn_creates_session_and_persists(client):
payload = {
"agent": "coach",
"session_id": "sess-1",
"messages": [{"role": "user", "content": "first turn"}],
}
events = stream_events(client, payload)
assert events[0]["type"] == "meta"
assert events[0]["session_id"] == "sess-1"
store = client.app.state.session_store
import asyncio
async def check():
return await store.history_window("sess-1")
contents = [m.content for m in asyncio.run(check())]
assert "first turn" in contents
assert any("Think" in c or "[" in c for c in contents) # mock reply persisted
def test_second_turn_replays_windowed_history(client):
payload1 = {
"agent": "coach",
"session_id": "sess-2",
"messages": [{"role": "user", "content": "turn one"}],
}
stream_events(client, payload1)
# Second turn: the API passes history + new message to the provider.
# With the mock provider we cannot observe provider inputs directly,
# but the session store must now hold both turns.
store = client.app.state.session_store
# The store is async; use the app's internals through a short event loop
import asyncio
result = {}
async def check():
result["window"] = await store.history_window("sess-2")
asyncio.run(check())
contents = [m.content for m in result["window"]]
assert "turn one" in contents
assert any(m.role == "assistant" for m in result["window"])
def test_second_turn_replays_history_to_provider(client):
# Observable provider input: a recording provider wrapper captures what
# the endpoint sends. Turn 2 must include turn 1's persisted messages.
from ai_service.llm.types import Message
provider = client.app.state.provider
original = provider.stream_chat
captured: list[list[Message]] = []
async def recording_stream(messages, *, model, temperature=0.7, response_format=None):
captured.append(list(messages))
async for t in original(
messages, model=model, temperature=temperature, response_format=response_format
):
yield t
provider.stream_chat = recording_stream
try:
stream_events(client, {
"agent": "coach", "session_id": "sess-replay",
"messages": [{"role": "user", "content": "turn one"}],
})
stream_events(client, {
"agent": "coach", "session_id": "sess-replay",
"messages": [{"role": "user", "content": "turn two"}],
})
finally:
provider.stream_chat = original
assert len(captured) == 2
turn1, turn2 = captured
# Agent routing (P3): provider now receives [system, ...history, user turn]
assert turn1[0].role == "system"
assert turn1[-1].content == "turn one"
assert len(turn1) == 2 # system + first user turn
turn2_contents = [m.content for m in turn2]
assert "turn one" in turn2_contents
assert "turn two" in turn2_contents
assert any(m.role == "assistant" for m in turn2) # persisted reply replayed
assert "turn two" == turn2_contents[-1] # new user turn last
assert turn2[0].role == "system" # every routed call starts with the persona
def test_history_replay_is_windowed(client):
# Windowing: only the last 20 stored messages are replayed to the provider.
from ai_service.llm.types import Message
provider = client.app.state.provider
original = provider.stream_chat
captured: list[list[Message]] = []
async def recording_stream(messages, *, model, temperature=0.7, response_format=None):
captured.append(list(messages))
async for t in original(
messages, model=model, temperature=temperature, response_format=response_format
):
yield t
provider.stream_chat = recording_stream
try:
# 15 turns → 30 persisted messages (user + assistant per turn) > 20 window.
for i in range(15):
stream_events(client, {
"agent": "coach", "session_id": "sess-window",
"messages": [{"role": "user", "content": f"turn {i}"}],
})
finally:
provider.stream_chat = original
last_input = captured[-1]
contents = [m.content for m in last_input]
# system prompt + window(20) + the new user message
assert last_input[0].role == "system"
assert len(last_input) == 1 + 20 + 1
assert "turn 0" not in contents # oldest messages trimmed out of replay
assert "turn 14" in contents
assert contents[-1] == "turn 14"
def test_replayed_system_prompt_carries_routed_persona(client):
"""History replay puts the routed agent's persona at position 0 (P3).
The system prompt on every turn — including replays — must match the
routed agent: Coach calls get the Coach persona, Tutor calls the Tutor
persona, and the two are observably distinct.
"""
from ai_service.llm.types import Message
def capture_two_turns(agent_name: str, session: str) -> list[list[Message]]:
provider = client.app.state.provider
original = provider.stream_chat
captured: list[list[Message]] = []
async def recording_stream(messages, *, model, temperature=0.7, response_format=None):
captured.append(list(messages))
async for t in original(
messages, model=model, temperature=temperature, response_format=response_format
):
yield t
provider.stream_chat = recording_stream
try:
for content in ("turn one", "turn two"):
stream_events(client, {
"agent": agent_name, "session_id": session,
"messages": [{"role": "user", "content": content}],
})
finally:
provider.stream_chat = original
return captured
coach_calls = capture_two_turns("coach", "persona-coach")
tutor_calls = capture_two_turns("tutor", "persona-tutor")
coach_replay_system = coach_calls[1][0]
tutor_replay_system = tutor_calls[1][0]
assert coach_replay_system.role == "system"
assert tutor_replay_system.role == "system"
assert "Coach" in coach_replay_system.content
assert "Tutor" in tutor_replay_system.content
assert coach_replay_system.content != tutor_replay_system.content
# persona is stable across turns within one session
assert coach_calls[0][0].content == coach_replay_system.content
def test_session_agent_scoped(client):
payload = {
"agent": "tutor",
"session_id": "sess-3",
"messages": [{"role": "user", "content": "hello"}],
}
stream_events(client, payload)
import asyncio
store = client.app.state.session_store
async def check():
return await store.get("sess-3")
session = asyncio.run(check())
assert session.agent == "tutor"
def test_client_retry_does_not_duplicate_user_turn(client):
"""P1 fix (final review): resending the same user turn after a failure
must not double-append it to session history."""
import asyncio
payload = {
"agent": "coach",
"session_id": "sess-retry",
"messages": [{"role": "user", "content": "same question"}],
}
# First attempt fails mid-stream (turn was already appended before streaming)
provider = client.app.state.provider
provider.fail_mid_stream_at_index = 0
stream_events_retry(client, payload)
provider.fail_mid_stream_at_index = None
# Client retry: identical payload
stream_events_retry(client, payload)
store = client.app.state.session_store
async def check():
return await store.history_window("sess-retry")
contents = [m.content for m in asyncio.run(check())]
user_turns = [c for c in contents if c == "same question"]
assert len(user_turns) == 1, f"expected exactly 1 stored user turn, got {len(user_turns)}"
def stream_events_retry(client, payload):
with client.stream("POST", "/v1/chat/stream", json=payload) as response:
for _ in response.iter_lines():
pass
@@ -0,0 +1,131 @@
"""SSE chat stream endpoint tests — envelope ordering, errors, headers."""
import json
def stream_lines(client, payload: dict) -> list[str]:
with client.stream("POST", "/v1/chat/stream", json=payload) as response:
assert response.status_code == 200
assert "no-cache" in response.headers.get("cache-control", "")
return [line for line in response.iter_lines() if line.strip()]
def parse_events(raw_lines: list[str]) -> list[dict]:
"""Parse SSE lines into event dicts; strips event:/data: prefixes."""
events = []
for line in raw_lines:
if line.startswith("data:"):
data = line.removeprefix("data:").strip()
if data == "[DONE]":
events.append({"type": "[DONE]"})
else:
events.append(json.loads(data))
return events
PAYLOAD = {
"agent": "tutor",
"session_id": "s1",
"messages": [{"role": "user", "content": "hello"}],
}
def test_meta_first_then_deltas_done_done_sentinel(client):
events = parse_events(stream_lines(client, PAYLOAD))
assert events[0]["type"] == "meta"
assert events[0]["agent"] == "tutor"
assert events[0]["session_id"] == "s1"
assert events[-1]["type"] == "[DONE]"
middle = events[1:-1]
deltas = [e for e in middle if e["type"] == "delta"]
dones = [e for e in middle if e["type"] == "done"]
assert len(deltas) >= 1
assert len(dones) == 1
assert dones[0]["finish_reason"] == "stop"
# delta events after meta, done before [DONE]
assert events.index(dones[0]) > events.index(deltas[0])
def test_empty_messages_rejected(client):
response = client.post(
"/v1/chat/stream",
json={"agent": "tutor", "session_id": "s", "messages": []},
)
assert response.status_code == 422
def test_mid_stream_failure_yields_error_then_done(client):
provider = client.app.state.provider
provider.fail_mid_stream_at_index = 1
events = parse_events(stream_lines(client, PAYLOAD))
provider.fail_mid_stream_at_index = None
error_events = [e for e in events if e["type"] == "error"]
assert len(error_events) == 1
assert error_events[0]["code"] == "provider_error"
assert events[-1]["type"] == "[DONE]"
# error must precede the terminal sentinel
assert events.index(error_events[0]) < events.index(events[-1])
def test_pre_first_byte_failure_yields_provider_unavailable(client):
provider = client.app.state.provider
provider.fail_before_first_token = True
events = parse_events(stream_lines(client, PAYLOAD))
provider.fail_before_first_token = False
error_events = [e for e in events if e["type"] == "error"]
assert len(error_events) == 1
assert error_events[0]["code"] == "provider_unavailable"
def test_unknown_agent_rejected_422(client):
response = client.post(
"/v1/chat/stream",
json={
"agent": "oracle",
"session_id": "s",
"messages": [{"role": "user", "content": "hi"}],
},
)
assert response.status_code == 422
assert "oracle" in response.json()["detail"]
def test_coach_routes_and_meta_names_agent(client):
payload = {
"agent": "coach",
"session_id": "route-coach",
"messages": [{"role": "user", "content": "pace me"}],
}
events = parse_events(stream_lines(client, payload))
assert events[0]["type"] == "meta"
assert events[0]["agent"] == "coach"
assert events[-1]["type"] == "[DONE]"
def test_tutor_routes_and_meta_names_agent(client):
payload = {
"agent": "tutor",
"session_id": "route-tutor",
"messages": [{"role": "user", "content": "teach me"}],
}
events = parse_events(stream_lines(client, payload))
assert events[0]["type"] == "meta"
assert events[0]["agent"] == "tutor"
assert events[-1]["type"] == "[DONE]"
def test_coach_and_tutor_streams_are_distinct(client):
"""Agent routing selects the right agent: distinct system prompts →
distinct hash-seeded mock outputs for the same user input."""
same_message = [{"role": "user", "content": "same question"}]
coach = parse_events(stream_lines(client, {
"agent": "coach", "session_id": "d1", "messages": same_message,
}))
tutor = parse_events(stream_lines(client, {
"agent": "tutor", "session_id": "d2", "messages": same_message,
}))
coach_text = "".join(e["content"] for e in coach if e["type"] == "delta")
tutor_text = "".join(e["content"] for e in tutor if e["type"] == "delta")
assert coach_text and tutor_text
assert coach_text != tutor_text
assert coach[-1]["type"] == "[DONE]"
+47
View File
@@ -0,0 +1,47 @@
"""Lab feedback endpoint tests — SSE envelope with agent=lab (REQ-2-007)."""
import json
def stream_events(client, payload) -> list[dict]:
with client.stream("POST", "/v1/lab/feedback", json=payload) as response:
assert response.status_code == 200
events = []
for line in response.iter_lines():
if line.startswith("data:"):
d = line.removeprefix("data:").strip()
if d == "[DONE]":
events.append({"type": "[DONE]"})
else:
events.append(json.loads(d))
return events
def test_lab_feedback_streams_full_envelope(client):
events = stream_events(client, {"scenario_id": "lab-scenario-strong"})
assert events[0]["type"] == "meta"
assert events[0]["agent"] == "lab"
assert events[0]["scenario_id"] == "lab-scenario-strong"
deltas = [e for e in events if e["type"] == "delta"]
assert len(deltas) >= 1
assert any(e["type"] == "done" for e in events)
assert events[-1]["type"] == "[DONE]"
def test_unknown_scenario_404(client):
response = client.post("/v1/lab/feedback", json={"scenario_id": "nope"})
assert response.status_code == 404
assert "nope" in response.json()["detail"]
def test_distinct_scenarios_distinct_replies(client):
strong = stream_events(client, {"scenario_id": "lab-scenario-strong"})
struggling = stream_events(client, {"scenario_id": "lab-scenario-struggling"})
strong_text = "".join(e["content"] for e in strong if e["type"] == "delta")
struggling_text = "".join(e["content"] for e in struggling if e["type"] == "delta")
assert strong_text != struggling_text
def test_missing_scenario_id_422(client):
response = client.post("/v1/lab/feedback", json={})
assert response.status_code == 422
+56
View File
@@ -0,0 +1,56 @@
"""Mentor narrative endpoint tests — SSE, session-backed (REQ-2-010)."""
import json
def stream_events(client, payload) -> list[dict]:
with client.stream("POST", "/v1/mentor/narrative", json=payload) as response:
assert response.status_code == 200
events = []
for line in response.iter_lines():
if line.startswith("data:"):
d = line.removeprefix("data:").strip()
if d == "[DONE]":
events.append({"type": "[DONE]"})
else:
events.append(json.loads(d))
return events
def test_narrative_streams_full_envelope(client):
events = stream_events(client, {"session_id": "mentor-1"})
assert events[0]["type"] == "meta"
assert events[0]["agent"] == "mentor"
deltas = [e for e in events if e["type"] == "delta"]
assert len(deltas) >= 1
assert any(e["type"] == "done" for e in events)
assert events[-1]["type"] == "[DONE]"
def test_narrative_is_session_backed(client):
"""Second call replays history: provider input grows; distinct mock output."""
first = stream_events(client, {"session_id": "mentor-2", "prompt": "narrate my path"})
second = stream_events(client, {"session_id": "mentor-2", "prompt": "what next?"})
first_text = "".join(e["content"] for e in first if e["type"] == "delta")
second_text = "".join(e["content"] for e in second if e["type"] == "delta")
assert first_text != second_text
def test_narrative_persists_turns(client):
import asyncio
store = client.app.state.session_store
stream_events(client, {"session_id": "mentor-3", "prompt": "hello trajectory"})
async def check():
return await store.history_window("mentor-3")
contents = [m.content for m in asyncio.run(check())]
assert "hello trajectory" in contents
assert len(contents) >= 2 # user + assistant persisted
def test_missing_session_id_422(client):
response = client.post("/v1/mentor/narrative", json={"prompt": "hi"})
assert response.status_code == 422
+49
View File
@@ -0,0 +1,49 @@
"""Proctor signals endpoint tests — validated JSON, 404s (REQ-2-009)."""
from ai_service.agents.proctor import ProctorAssessment
from ai_service.llm.mock import ScriptedJSONProvider
VALID = {
"scenario_id": "proctor-scenario-distracted",
"signals": [
{"signal_type": "context_switch", "severity": "low",
"note": "Docs tab at t+120s is normal"},
{"signal_type": "idle_gap", "severity": "medium",
"note": "5-minute idle at t+300s"},
],
"intervention": "Offer a short break, then restate the plan",
"summary": "Coaching-shaped session note",
}
def test_signals_returns_validated_json(client):
original = client.app.state.provider
client.app.state.provider = ScriptedJSONProvider(VALID)
try:
response = client.post(
"/v1/proctor/signals", json={"scenario_id": "proctor-scenario-distracted"}
)
finally:
client.app.state.provider = original
assert response.status_code == 200
validated = ProctorAssessment.model_validate(response.json())
assert validated.scenario_id == "proctor-scenario-distracted"
assert validated.intervention
def test_unknown_scenario_404(client):
response = client.post("/v1/proctor/signals", json={"scenario_id": "ghost"})
assert response.status_code == 404
def test_unparseable_provider_502(client):
response = client.post(
"/v1/proctor/signals", json={"scenario_id": "proctor-scenario-healthy"}
)
assert response.status_code == 502
assert "failed" in response.json()["detail"].lower()
def test_missing_scenario_id_422(client):
response = client.post("/v1/proctor/signals", json={})
assert response.status_code == 422
@@ -0,0 +1,93 @@
"""Client-disconnect regression tests — SSE generators must tolerate aclose().
A `yield` inside `finally` re-raises "async generator ignored GeneratorExit"
when sse-starlette closes the iterator on client disconnect (P0 finding,
final review). These tests reproduce the close path directly against each
endpoint's event_stream generator shape.
"""
import json
from ai_service.corpus.learner_context import get_learner_context
from ai_service.llm.types import Message
async def _chat_event_stream(client, session_id="close-chat", content="hi"):
"""Rebuild the chat endpoint's event_stream generator exactly as
chat.py builds it (same code shape, same session flow)."""
app = client.app
settings = app.state.settings
provider = app.state.provider
registry = app.state.agent_registry
sessions = app.state.session_store
agent = registry.get(provider, settings, "tutor")
learner_context = get_learner_context(None)
if await sessions.get(session_id) is None:
await sessions.create(session_id, agent="tutor", learner_id="learner-001")
user_turn = Message(role="user", content=content)
history = await sessions.history_window(session_id)
await sessions.append(session_id, user_turn)
async def event_stream():
yield {"event": "message", "data": json.dumps({
"type": "meta", "agent": "tutor", "session_id": session_id,
"model": settings.model,
})}
first_byte = True
reply_parts: list[str] = []
try:
async for token in agent.stream_reply(
history=history, user_input=user_turn.content,
learner_context=learner_context,
):
first_byte = False
reply_parts.append(token)
yield {"event": "message", "data": json.dumps({
"type": "delta", "content": token
})}
full_reply = "".join(reply_parts)
if full_reply:
await sessions.append(
session_id, Message(role="assistant", content=full_reply)
)
yield {"event": "message", "data": json.dumps({
"type": "done", "finish_reason": "stop"
})}
yield {"event": "message", "data": "[DONE]"}
except Exception as exc:
code = "provider_unavailable" if first_byte else "provider_error"
yield {"event": "message", "data": json.dumps({
"type": "error", "code": code, "message": str(exc)
})}
yield {"event": "message", "data": "[DONE]"}
return event_stream()
async def test_chat_stream_generator_survives_aclose(client):
"""Partial consumption then aclose() must not raise
'async generator ignored GeneratorExit' (yield-in-finally regression)."""
gen = await _chat_event_stream(client, session_id="close-chat")
meta = await gen.__anext__()
assert json.loads(meta["data"])["type"] == "meta"
delta = await gen.__anext__()
assert json.loads(delta["data"])["type"] == "delta"
# The critical assertion: closing mid-stream must be clean (no raise).
await gen.aclose()
async def test_chat_stream_survives_close_at_different_points(client):
"""Close right after meta, and right after done — all must be clean."""
gen = await _chat_event_stream(client, session_id="close-early")
await gen.__anext__() # meta only
await gen.aclose()
gen2 = await _chat_event_stream(client, session_id="close-late")
events = []
async for ev in gen2:
events.append(json.loads(ev["data"]))
if len(events) == 2:
break
await gen2.aclose()
assert events[0]["type"] == "meta"
+32
View File
@@ -0,0 +1,32 @@
"""Test suite — conftest: mock provider only, zero network (enforced)."""
import os
import pytest
from fastapi.testclient import TestClient
from ai_service.config import Settings
from ai_service.llm.mock import MockProvider
from ai_service.main import create_app
@pytest.fixture()
def settings() -> Settings:
os.environ["AI_PROVIDER"] = "mock"
return Settings(provider="mock", model="gemma4:31b", port=8421)
@pytest.fixture()
def app(settings: Settings):
return create_app(settings)
@pytest.fixture()
def client(app):
with TestClient(app) as c:
# Mechanical cloud-free guard (GRILL advisory a): the app under test
# MUST be wired to the deterministic mock provider.
assert isinstance(app.state.provider, MockProvider), (
f"tests must run against MockProvider, got {type(app.state.provider).__name__}"
)
yield c
+73
View File
@@ -0,0 +1,73 @@
"""MockProvider tests — determinism, JSON mode, failure modes."""
import pytest
from ai_service.llm.mock import MockProvider
from ai_service.llm.types import Message
MSGS = [Message(role="user", content="hello there")]
async def test_stream_is_deterministic():
p1, p2 = MockProvider(), MockProvider()
out1 = [t async for t in p1.stream_chat(MSGS, model="m")]
out2 = [t async for t in p2.stream_chat(MSGS, model="m")]
assert "".join(out1) == "".join(out2)
assert out1 == out2
async def test_stream_content_differs_for_different_input():
p = MockProvider()
a = "".join([t async for t in p.stream_chat(MSGS, model="m")])
b = "".join(
[t async for t in p.stream_chat([Message(role="user", content="other")], model="m")]
)
assert a != b
async def test_json_object_response_format():
import json
p = MockProvider()
out = "".join(
[
t
async for t in p.stream_chat(
MSGS, model="m", response_format={"type": "json_object"}
)
]
)
assert json.loads(out) == {"summary": "mock structured reply", "confidence": 0.87}
async def test_fail_before_first_token():
p = MockProvider()
p.fail_before_first_token = True
with pytest.raises(RuntimeError):
async for _ in p.stream_chat(MSGS, model="m"):
pass
async def test_fail_mid_stream():
p = MockProvider()
p.fail_mid_stream_at_index = 2
tokens = []
with pytest.raises(RuntimeError):
async for t in p.stream_chat(MSGS, model="m"):
tokens.append(t)
assert len(tokens) == 2
async def test_cancellation_records_abort():
p = MockProvider()
gen = p.stream_chat(MSGS, model="m")
await gen.__anext__()
await gen.aclose()
assert p.abort_recorded is True
async def test_chat_returns_full_reply():
p = MockProvider()
reply = await p.chat(MSGS, model="m")
streamed = "".join([t async for t in p.stream_chat(MSGS, model="m")])
assert reply == streamed
@@ -0,0 +1,177 @@
"""OpenAICompatProvider tests — byte-exact SSE parsing via httpx.MockTransport.
Covers: multi-delta happy path, keep-alive comment lines, [DONE] sentinel,
malformed line tolerance, missing optional fields, non-streaming chat(),
response_format auto-degrade on 400, api_key never leaking into exceptions.
"""
import json
import httpx
import pytest
from ai_service.llm.openai_compat import OpenAICompatProvider
from ai_service.llm.types import Message
MSGS = [Message(role="user", content="hi")]
KEY = "sk-test-abc123"
def make_client(handler) -> httpx.AsyncClient:
transport = httpx.MockTransport(handler)
return httpx.AsyncClient(transport=transport)
def sse_body(deltas: list[str], with_comments: bool = True) -> bytes:
lines = []
if with_comments:
lines.append(": ping")
for d in deltas:
lines.append("data: " + json.dumps({
"id": "chatcmpl-1", "object": "chat.completion.chunk",
"created": 1, "model": "gemma4:31b",
"choices": [{"index": 0, "delta": {"content": d}, "finish_reason": None}],
}))
if with_comments:
lines.append(": ping")
lines.append("data: [DONE]")
return ("\n".join(lines) + "\n").encode()
async def test_stream_happy_path_with_comments_and_done():
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/v1/chat/completions"
return httpx.Response(200, content=sse_body(["Hel", "lo", " world"]))
async with make_client(handler) as client:
provider = OpenAICompatProvider(client, "https://fake/v1")
tokens = [t async for t in provider.stream_chat(MSGS, model="gemma4:31b")]
assert tokens == ["Hel", "lo", " world"]
async def test_stream_tolerates_malformed_lines():
body = (
"data: not-json\n"
"data: "
+ json.dumps({
"id": "x", "object": "chat.completion.chunk", "created": 1, "model": "m",
"choices": [{"index": 0, "delta": {"content": "ok"}, "finish_reason": None}],
})
+ "\ndata: [DONE]\n"
)
def handler(request):
return httpx.Response(200, content=body.encode())
async with make_client(handler) as client:
provider = OpenAICompatProvider(client, "https://fake/v1")
tokens = [t async for t in provider.stream_chat(MSGS, model="m")]
assert tokens == ["ok"]
async def test_stream_skips_empty_content_and_empty_choices():
chunk_empty_delta = json.dumps(
{"id": "x", "model": "m",
"choices": [{"index": 0, "delta": {}, "finish_reason": None}]}
)
chunk_empty_choices = json.dumps({"id": "x", "model": "m", "choices": []})
chunk_yes = json.dumps(
{"id": "x", "model": "m",
"choices": [{"index": 0, "delta": {"content": "yes"}, "finish_reason": None}]}
)
body = (
f"data: {chunk_empty_delta}\n"
f"data: {chunk_empty_choices}\n"
f"data: {chunk_yes}\n"
"data: [DONE]\n"
)
def handler(request):
return httpx.Response(200, content=body.encode())
async with make_client(handler) as client:
provider = OpenAICompatProvider(client, "https://fake/v1")
tokens = [t async for t in provider.stream_chat(MSGS, model="m")]
assert tokens == ["yes"]
async def test_chat_non_streaming():
def handler(request):
payload = json.loads(request.content)
assert payload["stream"] is False
body = {"id": "1", "object": "chat.completion", "created": 1, "model": "m",
"choices": [{"index": 0, "message": {"role": "assistant",
"content": "full reply"},
"finish_reason": "stop"}]}
return httpx.Response(200, json=body)
async with make_client(handler) as client:
provider = OpenAICompatProvider(client, "https://fake/v1")
assert await provider.chat(MSGS, model="m") == "full reply"
async def test_response_format_auto_degrades_on_400():
calls = []
def handler(request):
payload = json.loads(request.content)
calls.append(payload)
if "response_format" in payload:
return httpx.Response(400, json={"error": "response_format unsupported"})
body = {"id": "1", "object": "chat.completion", "created": 1, "model": "m",
"choices": [{"index": 0, "message": {"role": "assistant",
"content": "json"},
"finish_reason": "stop"}]}
return httpx.Response(200, json=body)
async with make_client(handler) as client:
provider = OpenAICompatProvider(client, "https://fake/v1", json_mode="auto")
reply = await provider.chat(MSGS, model="m", response_format={"type": "json_object"})
assert reply == "json"
assert len(calls) == 2
assert "response_format" in calls[0]
assert "response_format" not in calls[1]
async def test_response_format_off_never_sends():
calls = []
def handler(request):
payload = json.loads(request.content)
calls.append(payload)
body = {"id": "1", "object": "chat.completion", "created": 1, "model": "m",
"choices": [{"index": 0, "message": {"role": "assistant",
"content": "x"},
"finish_reason": "stop"}]}
return httpx.Response(200, json=body)
async with make_client(handler) as client:
provider = OpenAICompatProvider(client, "https://fake/v1", json_mode="off")
await provider.chat(MSGS, model="m", response_format={"type": "json_object"})
assert len(calls) == 1
assert "response_format" not in calls[0]
async def test_api_key_never_in_exception():
def handler(request):
raise httpx.ConnectError("connection refused while using sk-test-abc123")
async with make_client(handler) as client:
provider = OpenAICompatProvider(client, "https://fake/v1", api_key=KEY)
with pytest.raises(RuntimeError) as exc_info:
async for _ in provider.stream_chat(MSGS, model="m"):
pass
assert "sk-test-abc123" not in str(exc_info.value)
async def test_bearer_header_sent():
seen = {}
def handler(request):
seen["auth"] = request.headers.get("Authorization")
return httpx.Response(200, content=sse_body(["x"]))
async with make_client(handler) as client:
provider = OpenAICompatProvider(client, "https://fake/v1", api_key=KEY)
_ = [t async for t in provider.stream_chat(MSGS, model="m")]
assert seen["auth"] == f"Bearer {KEY}"
+10
View File
@@ -0,0 +1,10 @@
"""Health endpoint tests."""
def test_health_returns_ok(client):
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["provider"] == "mock"
assert data["model"] == "gemma4:31b"
+2
View File
@@ -0,0 +1,2 @@
# AI service (v0.2) — learner chat/panels stream from this FastAPI service
NEXT_PUBLIC_AI_SERVICE_URL=http://localhost:8420
+28
View File
@@ -0,0 +1,28 @@
import type { StorybookConfig } from '@storybook/nextjs';
/**
* Storybook config for the Nextcraft monorepo.
*
* Stories live alongside the UI primitives in `packages/ui` (referenced
* through the `@nextcraft/ui` workspace symlink so Storybook's webpack
* resolver finds them inside the app's module graph) and next to composite
* components in `apps/web/components`. The Next.js framework preset bundles
* the essential addons (controls, docs, actions, viewport, backgrounds,
* toolbars) so we don't add `@storybook/addon-essentials` separately.
*/
const config: StorybookConfig = {
stories: [
'../node_modules/@nextcraft/ui/src/**/*.stories.@(ts|tsx|mdx)',
'../components/**/*.stories.@(ts|tsx|mdx)',
],
addons: [],
framework: {
name: '@storybook/nextjs',
options: {},
},
docs: {
autodocs: true,
},
};
export default config;
+38
View File
@@ -0,0 +1,38 @@
import type { Preview } from '@storybook/react';
import '../app/globals.css';
/**
* Storybook preview — imports the app's Tailwind stylesheet so stories
* render with the full design-token system (colors, fonts, dark mode).
*/
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
backgrounds: {
default: 'light',
values: [
{ name: 'light', value: '#ffffff' },
{ name: 'dark', value: '#020617' },
],
},
layout: 'padded',
},
// Apply the `dark` class to the story root when the dark background is
// active so class-based dark: variants resolve.
decorators: [
(Story, context) => {
const isDark = context.globals?.backgrounds?.value === '#020617';
if (typeof document !== 'undefined') {
document.documentElement.classList.toggle('dark', isDark);
}
return <Story />;
},
],
};
export default preview;
+17
View File
@@ -0,0 +1,17 @@
import { CompetencyGraph } from '../../../../components/admin/competency-graph';
export default function CompetencyGraphPage() {
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Competency Graph
</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">
Visualize stack relationships and dependencies
</p>
</header>
<CompetencyGraph />
</div>
);
}
@@ -0,0 +1,18 @@
import { LearnerTable } from '../../../../components/admin/learner-table';
import { adminLearners } from '@nextcraft/mock-data';
export default function LearnerManagementPage() {
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Learner Management
</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">
Track and manage learner progress
</p>
</header>
<LearnerTable learners={adminLearners} />
</div>
);
}
@@ -0,0 +1,17 @@
import { ModerationTabs } from '../../../../components/admin/moderation-tabs';
export default function MarketplaceModerationPage() {
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Marketplace Moderation
</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">
Review and manage marketplace content
</p>
</header>
<ModerationTabs />
</div>
);
}
+244
View File
@@ -0,0 +1,244 @@
import {
Activity,
AlertTriangle,
Briefcase,
Building2,
CheckCircle,
Clock,
Cpu,
Database,
GraduationCap,
Server,
ThumbsUp,
Users,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Card, CardBody, CardHeader } from '@nextcraft/ui';
import {
activityFeed,
platformMetrics,
serviceHealth,
systemStats,
uptimeBars,
} from '@nextcraft/mock-data';
const ICONS: Record<string, LucideIcon> = {
Users,
Building2,
Briefcase,
GraduationCap,
ThumbsUp,
Activity,
Server,
Database,
Cpu,
};
const TONE_CLASSES: Record<string, string> = {
indigo: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
emerald: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
amber: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
rose: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300',
cyan: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300',
violet: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300',
};
function statusDot(status: string): string {
if (status === 'operational') return 'bg-emerald-500';
if (status === 'degraded') return 'bg-amber-500';
return 'bg-rose-500';
}
function statusLabel(status: string): string {
if (status === 'operational') return 'Operational';
if (status === 'degraded') return 'Degraded';
return 'Down';
}
export default function AdminOverviewPage() {
return (
<div className="flex flex-col gap-8">
{/* Page header */}
<header className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Admin Dashboard
</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">
Platform overview
</p>
</header>
{/* Metric cards */}
<section
aria-label="Platform metrics"
className="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-5"
>
{platformMetrics.map((m) => {
const Icon = ICONS[m.icon] ?? Activity;
const positive = m.trendPct >= 0;
return (
<Card key={m.id}>
<CardBody className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span
className={`inline-flex h-9 w-9 items-center justify-center rounded-full ${TONE_CLASSES[m.tone]}`}
aria-hidden
>
<Icon className="h-4 w-4" />
</span>
<span
className={`text-xs font-medium ${positive ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'}`}
>
{positive ? '+' : ''}
{m.trendPct}%
</span>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
{m.label}
</p>
<p className="mt-0.5 text-2xl font-bold text-slate-900 dark:text-slate-100">
{m.value.toLocaleString()}
{m.suffix}
</p>
</div>
</CardBody>
</Card>
);
})}
</section>
{/* Activity + System health */}
<section className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Activity feed — 2/3 width */}
<div className="lg:col-span-2">
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-primary-600" aria-hidden />
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">
Activity Feed
</h2>
</div>
</CardHeader>
<CardBody className="p-0">
<ol className="flex flex-col">
{activityFeed.map((evt, i) => {
const Icon = ICONS[evt.icon] ?? Activity;
const last = i === activityFeed.length - 1;
return (
<li
key={evt.id}
className={`flex items-start gap-3 px-6 py-3 ${last ? '' : 'border-b border-slate-100 dark:border-slate-800'}`}
>
<span
className={`mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${TONE_CLASSES[evt.tone]}`}
aria-hidden
>
<Icon className="h-4 w-4" />
</span>
<div className="flex flex-1 flex-col gap-0.5">
<p className="text-sm text-slate-700 dark:text-slate-200">
{evt.message}
</p>
<span className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
<Clock className="h-3 w-3" aria-hidden />
{evt.relativeTime}
</span>
</div>
</li>
);
})}
</ol>
</CardBody>
</Card>
</div>
{/* System health — 1/3 width */}
<div>
<Card className="h-full">
<CardHeader>
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-primary-600" aria-hidden />
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">
System Health
</h2>
</div>
</CardHeader>
<CardBody className="flex flex-col gap-5">
<ul className="flex flex-col gap-2.5">
{serviceHealth.map((svc) => (
<li
key={svc.id}
className="flex items-center justify-between gap-2"
>
<span className="text-sm text-slate-700 dark:text-slate-200">
{svc.name}
</span>
<span className="flex items-center gap-2">
<span
className={`h-2.5 w-2.5 rounded-full ${statusDot(svc.status)}`}
aria-hidden
/>
<span
className={`text-xs font-medium ${svc.status === 'operational' ? 'text-emerald-600 dark:text-emerald-400' : svc.status === 'degraded' ? 'text-amber-600 dark:text-amber-400' : 'text-rose-600 dark:text-rose-400'}`}
>
{statusLabel(svc.status)}
</span>
</span>
</li>
))}
</ul>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Uptime (30d)
</h3>
<span className="text-xs font-medium text-slate-600 dark:text-slate-300">
99.96%
</span>
</div>
<div className="flex h-8 items-end gap-0.5" aria-hidden>
{uptimeBars.map((u, idx) => {
const h = Math.max(20, Math.round((u - 99.8) * 100 * 4));
const degraded = u < 99.95;
return (
<span
key={idx}
className={`flex-1 rounded-sm ${degraded ? 'bg-amber-400' : 'bg-emerald-400'}`}
style={{ height: `${Math.min(100, h)}%` }}
title={`${u.toFixed(2)}%`}
/>
);
})}
</div>
</div>
<div className="grid grid-cols-2 gap-3 border-t border-slate-200 pt-4 dark:border-slate-800">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-slate-500 dark:text-slate-400">
Error Rate
</span>
<span className="flex items-center gap-1 text-sm font-semibold text-slate-900 dark:text-slate-100">
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-hidden />
{systemStats.errorRate}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-slate-500 dark:text-slate-400">
Avg Response Time
</span>
<span className="flex items-center gap-1 text-sm font-semibold text-slate-900 dark:text-slate-100">
<CheckCircle className="h-3.5 w-3.5 text-emerald-500" aria-hidden />
{systemStats.avgResponseMs}ms
</span>
</div>
</div>
</CardBody>
</Card>
</div>
</section>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
'use client';
import type { ReactNode } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Shield } from 'lucide-react';
const LINKS = [
{ label: 'Overview', href: '/admin' },
{ label: 'Learners', href: '/admin/learners' },
{ label: 'Competency Graph', href: '/admin/graph' },
{ label: 'Moderation', href: '/admin/moderation' },
];
export default function AdminLayout({ children }: { children: ReactNode }) {
const pathname = usePathname() ?? '';
return (
<div className="mx-auto flex max-w-7xl gap-6 px-4 py-8 sm:px-6">
<aside className="hidden w-56 shrink-0 md:block">
<div className="mb-4 flex items-center gap-2 text-sm font-semibold text-slate-500 dark:text-slate-400">
<Shield className="h-4 w-4 text-rose-600" />
Admin surface
</div>
<nav className="flex flex-col gap-1">
{LINKS.map((l) => {
const isActive = pathname === l.href || pathname.startsWith(l.href + '/');
return (
<Link
key={l.href}
href={l.href}
aria-current={isActive ? 'page' : undefined}
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
isActive
? 'bg-rose-50 text-rose-700 dark:bg-rose-900/30 dark:text-rose-200'
: 'text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800'
}`}
>
{l.label}
</Link>
);
})}
</nav>
</aside>
<div className="flex-1">{children}</div>
</div>
);
}
+234
View File
@@ -0,0 +1,234 @@
import Link from 'next/link';
import {
Briefcase,
Users,
TrendingUp,
Target,
ArrowRight,
} from 'lucide-react';
import { Badge, Card, CardBody, Avatar } from '@nextcraft/ui';
import { candidates, employers } from '@nextcraft/mock-data';
import { AnalyticsCharts } from '../../../components/employer/analytics-charts';
import { matchBadgeClasses, initials } from '../../../lib/format';
/* -------------------------------------------------------------------------- */
/* Mock pipeline + metric data */
/* -------------------------------------------------------------------------- */
const METRICS = [
{ label: 'Active Postings', value: 7, trend: '+12%', icon: Briefcase, tone: 'primary' as const },
{ label: 'Total Applicants', value: 142, trend: '+24%', icon: Users, tone: 'accent' as const },
{ label: 'Talent Matches', value: 28, trend: '+8%', icon: Target, tone: 'violet' as const },
{ label: 'Placement Rate', value: '18%', trend: '+3%', icon: TrendingUp, tone: 'amber' as const },
];
const TONE_CLASSES: Record<string, string> = {
primary: 'bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300',
accent: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
violet: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300',
amber: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
};
interface PipelineStage {
name: string;
count: number;
cards: Array<{ name: string; position: string; score: number }>;
}
const PIPELINE: PipelineStage[] = [
{
name: 'Applied',
count: 8,
cards: [
{ name: 'Maya Okonkwo', position: 'AI Orchestration Engineer', score: 96 },
{ name: 'Devon Park', position: 'AI Safety Researcher', score: 91 },
{ name: 'Priya Iyer', position: 'Human-AI Designer', score: 89 },
],
},
{
name: 'Screening',
count: 5,
cards: [
{ name: 'Tomás Vega', position: 'LLM App Developer', score: 93 },
{ name: 'Sofia Marchetti', position: 'AI Governance Lead', score: 85 },
],
},
{
name: 'Interview',
count: 3,
cards: [
{ name: 'Liam Chen', position: 'Agent Reliability Eng', score: 90 },
{ name: 'Yuki Tanaka', position: 'Evaluation Engineer', score: 86 },
],
},
{
name: 'Offer',
count: 2,
cards: [{ name: 'Ingrid Solberg', position: 'AI Red Team Lead', score: 88 }],
},
{
name: 'Hired',
count: 1,
cards: [{ name: 'Hana Lindqvist', position: 'Comp Biologist', score: 88 }],
},
];
/* -------------------------------------------------------------------------- */
/* Page */
/* -------------------------------------------------------------------------- */
export default function EmployerDashboardPage() {
const employer = employers[0];
const topMatches = [...candidates].sort((a, b) => b.matchScore - a.matchScore).slice(0, 5);
return (
<div className="flex flex-col gap-8">
{/* Header */}
<header className="flex flex-col gap-2">
<Badge variant="info">Employer Dashboard</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
Employer Dashboard
</h1>
<p className="text-slate-600 dark:text-slate-400">
Welcome back, <span className="font-semibold text-slate-800 dark:text-slate-200">{employer.name}</span>.
Here is your talent pipeline at a glance.
</p>
</header>
{/* Metric cards */}
<section className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{METRICS.map((m) => {
const Icon = m.icon;
return (
<Card key={m.label}>
<CardBody className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span
className={`inline-flex h-10 w-10 items-center justify-center rounded-full ${TONE_CLASSES[m.tone]}`}
>
<Icon className="h-5 w-5" />
</span>
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
{m.trend}
</span>
</div>
<div className="flex flex-col">
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{m.value}
</span>
<span className="text-sm text-slate-500 dark:text-slate-400">{m.label}</span>
</div>
</CardBody>
</Card>
);
})}
</section>
{/* Middle row — pipeline + talent matches */}
<section className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Applicant pipeline — 2/3 width */}
<div className="lg:col-span-2">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Applicant Pipeline
</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">
{PIPELINE.reduce((s, c) => s + c.count, 0)} candidates in flight
</span>
</div>
<div className="flex gap-3 overflow-x-auto pb-2">
{PIPELINE.map((stage) => (
<div
key={stage.name}
className="flex w-56 shrink-0 flex-col gap-2 rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-900/60"
>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">
{stage.name}
</h3>
<span className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-slate-200 px-1.5 text-xs font-medium text-slate-700 dark:bg-slate-700 dark:text-slate-200">
{stage.count}
</span>
</div>
<div className="flex flex-col gap-2">
{stage.cards.map((c) => (
<div
key={c.name}
className="flex items-center gap-2 rounded-md border border-slate-200 bg-white p-2 dark:border-slate-800 dark:bg-slate-900"
>
<Avatar name={c.name} size="sm" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-xs font-medium text-slate-800 dark:text-slate-100">
{c.name}
</span>
<span className="truncate text-xs text-slate-500 dark:text-slate-400">
{c.position}
</span>
</div>
<span
className={`inline-flex h-7 w-9 shrink-0 items-center justify-center rounded-full text-xs font-bold ring-2 ${matchBadgeClasses(
c.score,
)}`}
>
{c.score}
</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
{/* Talent matches — 1/3 width */}
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Talent Matches
</h2>
<Link
href="/employer/talent"
className="inline-flex items-center gap-1 text-sm font-medium text-primary-700 hover:underline dark:text-primary-300"
>
See all <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<Card>
<CardBody className="flex flex-col gap-2 p-3">
{topMatches.map((cand) => (
<Link
key={cand.id}
href={`/employer/talent/${cand.id}`}
className="flex items-center gap-3 rounded-md p-2 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800"
>
<Avatar name={cand.name} src={cand.avatar} size="md" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium text-slate-800 dark:text-slate-100">
{cand.name}
</span>
<span className="truncate text-xs text-slate-500 dark:text-slate-400">
{cand.headline}
</span>
</div>
<span
className={`inline-flex h-8 w-12 shrink-0 items-center justify-center rounded-full text-xs font-bold ring-2 ${matchBadgeClasses(
cand.matchScore,
)}`}
>
{cand.matchScore}%
</span>
</Link>
))}
</CardBody>
</Card>
</div>
</section>
{/* Analytics charts */}
<section>
<h2 className="mb-3 text-lg font-semibold text-slate-900 dark:text-slate-100">Analytics</h2>
<AnalyticsCharts />
</section>
</div>
);
}
@@ -0,0 +1,22 @@
import { Badge } from '@nextcraft/ui';
import { jobs } from '@nextcraft/mock-data';
import { PostingManager } from '../../../../components/employer/posting-manager';
export default function PostingsPage() {
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-2">
<Badge variant="info">Posting Management</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
Posting Management
</h1>
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
Create, edit, and track your job postings. View the applicant pipeline for each posting
and manage interview stages.
</p>
</header>
<PostingManager jobs={jobs} />
</div>
);
}
@@ -0,0 +1,443 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import {
ArrowLeft,
CheckCircle,
Clock,
FileCode,
Palette,
Cpu,
Mail,
ShieldCheck,
} from 'lucide-react';
import { Avatar, Badge, Button, Card, CardBody } from '@nextcraft/ui';
import { candidates, competencyStacks } from '@nextcraft/mock-data';
import type { Artifact, ArtifactType } from '@nextcraft/types';
import { matchBadgeClasses, initials } from '../../../../../lib/format';
import { DefenseAccordion } from '../../../../../components/employer/defense-accordion';
interface PageProps {
params: Promise<{ candidateId: string }>;
}
/* -------------------------------------------------------------------------- */
/* Deterministic mock-data generators (per-candidate) */
/* -------------------------------------------------------------------------- */
function hashId(id: string): number {
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
return h;
}
function generateArtifacts(cand: { id: string; artifactCount: number; competencyStackId: string }): Artifact[] {
const namesByStack: Record<string, Array<{ name: string; type: ArtifactType; desc: string }>> = {
'stack-orchestration': [
{ name: 'Multi-agent research assistant', type: 'code', desc: 'LangGraph assistant with eval harness for cited literature reviews.' },
{ name: 'RAG retrieval quality dashboard', type: 'code', desc: 'Streamlit dashboard comparing chunking strategies across 800 eval queries.' },
{ name: 'Agent topology diagram', type: 'design', desc: 'Plan-and-execute agent with reflection and tool-retrieval sub-graphs.' },
{ name: 'Prompt regression suite', type: 'code', desc: 'Pytest suite of 320 prompt assertions with LLM-as-judge scoring.' },
{ name: 'Token-cost alerting system', type: 'code', desc: 'Real-time cost monitoring across agent calls with Slack escalation.' },
{ name: 'Function calling sandbox', type: 'simulation', desc: 'Sandboxed tool execution environment with retry + validation.' },
],
'stack-safety': [
{ name: 'Red-team probe suite', type: 'code', desc: '1,200+ adversarial probes across three model families with scoring.' },
{ name: 'Model card — internal Q&A agent', type: 'document', desc: 'Capabilities, limitations, intended use, and red-team findings.' },
{ name: 'Bias audit dashboard', type: 'code', desc: 'Disparate-impact testing across demographics with visual report.' },
{ name: 'Risk register template', type: 'document', desc: 'Risk taxonomy with severity and likelihood for 30+ deployed systems.' },
{ name: 'Jailbreak defense prototype', type: 'code', desc: 'Instruction-hierarchy enforcement with indirect injection mitigation.' },
],
'stack-designer': [
{ name: 'Transparency pattern library', type: 'design', desc: 'Confidence indicators, source attribution, and model limitation disclosure.' },
{ name: 'Agentic interaction prototype', type: 'design', desc: 'Figma prototype for delegating, interrupting, and reviewing autonomous agents.' },
{ name: 'Repair flow spec', type: 'document', desc: 'Multi-turn dialogue repair flows reducing escalations by 35%.' },
{ name: 'Persona consistency framework', type: 'design', desc: 'Character design for AI assistants with contextual tone adaptation.' },
{ name: 'Trust calibration study', type: 'document', desc: 'User mental model alignment research with over-trust mitigations.' },
],
'stack-operator': [
{ name: 'Anomaly triage dashboard', type: 'code', desc: 'ML-based anomaly score visualization cutting false positives by 40%.' },
{ name: 'Sensor calibration log', type: 'document', desc: 'Calibration schedule and drift-correction workflow for fleet sensors.' },
{ name: 'Vision inspection tuning', type: 'simulation', desc: 'Threshold tuning for vision-based quality control station.' },
{ name: 'Field data collection template', type: 'document', desc: 'Structured annotation workflow for high-quality dataset capture.' },
],
'stack-science': [
{ name: 'Active-learning DFT toolkit', type: 'code', desc: 'Open-source toolkit for active-learning loops over DFT calculations.' },
{ name: 'Phenotype prediction pipeline', type: 'code', desc: 'ML pipeline for drug discovery with reproducible Snakemake workflows.' },
{ name: 'Wind forecast downscaling', type: 'simulation', desc: 'Physics-informed model improving 72-hour wind forecasts by 18%.' },
{ name: 'Materials screening notebook', type: 'code', desc: 'Jupyter notebook for ML property prediction with active learning.' },
{ name: 'Reproducible research container', type: 'document', desc: 'Containerized workflow with Nextflow and DOI assignment.' },
],
};
const pool = namesByStack[cand.competencyStackId] ?? namesByStack['stack-orchestration'];
const offset = hashId(cand.id) % pool.length;
const count = Math.min(cand.artifactCount, pool.length);
const artifacts: Artifact[] = [];
for (let i = 0; i < count; i++) {
const item = pool[(offset + i) % pool.length];
artifacts.push({
id: `art-${cand.id}-${i}`,
name: item.name,
type: item.type,
url: `https://example.com/artifacts/${cand.id}/${i}`,
description: item.desc,
createdAt: new Date(Date.UTC(2026, 7 - i, 20 - i * 3)).toISOString(),
});
}
return artifacts;
}
function generateProcessTrace(cand: { id: string }) {
const offset = hashId(cand.id);
const baseEvents = [
{ action: 'Repository initialized', detail: 'Created project scaffold with README and license.' },
{ action: 'First commit pushed', detail: 'Initial proof-of-concept with placeholder data.' },
{ action: 'Evaluation harness added', detail: 'Wired up 50-question regression suite with LLM-as-judge.' },
{ action: 'Peer review feedback', detail: 'Two reviewers flagged edge cases in retrieval fallback path.' },
{ action: 'Iteration — fallback hardened', detail: 'Added retry + validation; eval score improved 12 points.' },
{ action: 'Final submission', detail: 'Artifact submitted for oral defense scheduling.' },
];
return baseEvents.map((e, i) => ({
timestamp: new Date(Date.UTC(2026, 6 + Math.floor(i / 2), (offset % 20) + i * 2)).toISOString(),
...e,
}));
}
function generateDefenseSessions(cand: { id: string; competencyStackId: string; microcredentials: number }) {
const stack = competencyStacks.find((s) => s.id === cand.competencyStackId);
const mastered = (stack?.competencies ?? []).filter((c) => c.status === 'mastered' || c.status === 'in_progress');
const offset = hashId(cand.id);
const count = Math.min(cand.microcredentials, mastered.length, 3);
const sessions = [];
for (let i = 0; i < count; i++) {
const comp = mastered[(offset + i) % mastered.length];
const score = 82 + ((offset + i) % 12);
sessions.push({
id: `def-${cand.id}-${i}`,
competencyName: comp.name,
score,
date: new Date(Date.UTC(2026, 5 + i, (offset % 24) + 1)).toISOString(),
transcript: [
{
question: 'Walk us through the architecture of your artifact. Why did you choose this approach?',
answer:
'I chose a plan-and-execute topology because the task required multi-step retrieval with reflection. The plan node decomposes the query, sub-agents retrieve and draft in parallel, and a reflection node scores and routes for a second pass when below threshold.',
},
{
question: 'What evaluation did you run, and what were the headline numbers?',
answer:
'I ran a 50-query regression suite scored by LLM-as-judge calibrated against a human panel (0.86 agreement). Baseline scored 71%; the reflection pass lifted it to 88% with a 14% latency cost, which stayed within budget.',
},
{
question: 'Describe a failure mode you found and how you mitigated it.',
answer:
'Retrieval fallback returned stale context on schema changes. I added a freshness check + retry with a smaller context window, which reduced stale-grounded answers from 9% to under 2%.',
},
],
});
}
return sessions;
}
function generateMicrocredentials(cand: { id: string; competencyStackId: string; microcredentials: number }) {
const stack = competencyStacks.find((s) => s.id === cand.competencyStackId);
const mastered = (stack?.competencies ?? []).filter((c) => c.status === 'mastered' || c.status === 'in_progress');
const offset = hashId(cand.id);
const count = Math.min(cand.microcredentials, mastered.length, 5);
const items = [];
for (let i = 0; i < count; i++) {
const comp = mastered[(offset + i) % mastered.length];
const score = 85 + ((offset + i) % 11);
items.push({
id: `mc-${cand.id}-${i}`,
competencyName: comp.name,
issuedAt: new Date(Date.UTC(2026, 5 + i, (offset % 24) + 1)).toISOString(),
score,
});
}
return items;
}
function generateCompetencies(cand: { id: string; competencyStackId: string }) {
const stack = competencyStacks.find((s) => s.id === cand.competencyStackId);
const comps = stack?.competencies ?? [];
const offset = hashId(cand.id);
return comps.slice(0, 8).map((c, i) => {
// Deterministically assign status biased toward mastered/in_progress for high-score candidates.
const r = (offset + i) % 10;
const status = r < 5 ? 'mastered' : r < 8 ? 'in_progress' : 'available';
return { id: c.id, name: c.name, status: status as 'mastered' | 'in_progress' | 'available' };
});
}
/* -------------------------------------------------------------------------- */
/* Icon helpers */
/* -------------------------------------------------------------------------- */
function ArtifactTypeIcon({ type }: { type: ArtifactType }) {
const cls = 'h-5 w-5';
if (type === 'code') return <FileCode className={cls} />;
if (type === 'design') return <Palette className={cls} />;
if (type === 'simulation') return <Cpu className={cls} />;
return <FileCode className={cls} />;
}
const ARTIFACT_ICON_BG: Record<ArtifactType, string> = {
code: 'bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300',
design: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300',
simulation: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
document: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
};
/* -------------------------------------------------------------------------- */
/* Page */
/* -------------------------------------------------------------------------- */
export default async function CandidateProfilePage({ params }: PageProps) {
const { candidateId } = await params;
const cand = candidates.find((c) => c.id === candidateId);
if (!cand) notFound();
const stack = competencyStacks.find((s) => s.id === cand.competencyStackId);
const artifacts = generateArtifacts(cand);
const processTrace = generateProcessTrace(cand);
const defenseSessions = generateDefenseSessions(cand);
const microcredentials = generateMicrocredentials(cand);
const competencies = generateCompetencies(cand);
const masteredCount = competencies.filter((c) => c.status === 'mastered').length;
return (
<div className="flex flex-col gap-6">
{/* Back link */}
<div>
<Link
href="/employer/talent"
className="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
>
<ArrowLeft className="h-4 w-4" />
Back to talent search
</Link>
</div>
{/* Header */}
<Card>
<CardBody className="flex flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="flex items-start gap-4">
<Avatar name={cand.name} src={cand.avatar} size="lg" />
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{cand.name}
</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">{cand.headline}</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
<Badge variant="info">{stack?.name ?? 'AI'}</Badge>
<Badge variant="success">Verified</Badge>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex flex-col items-end">
<span className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
Match Score
</span>
<span
className={`inline-flex h-12 w-14 items-center justify-center rounded-full text-base font-bold ring-2 ${matchBadgeClasses(
cand.matchScore,
)}`}
>
{cand.matchScore}%
</span>
</div>
<Button variant="primary" size="md" icon={<Mail className="h-4 w-4" />} type="button">
Contact
</Button>
</div>
</div>
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">{cand.bio}</p>
</CardBody>
</Card>
{/* Summary stats bar */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard label="Artifacts" value={cand.artifactCount} icon={<FileCode className="h-4 w-4" />} />
<StatCard label="Defense Score (avg)" value={cand.defenseScore} icon={<ShieldCheck className="h-4 w-4" />} />
<StatCard label="Microcredentials" value={cand.microcredentials} icon={<CheckCircle className="h-4 w-4" />} />
<StatCard label="Competencies Mastered" value={masteredCount} icon={<Cpu className="h-4 w-4" />} />
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Artifact gallery */}
<Card>
<CardBody className="flex flex-col gap-4">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Artifact Gallery
</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{artifacts.map((art) => (
<a
key={art.id}
href={art.url}
className="flex flex-col gap-2 rounded-lg border border-slate-200 p-3 transition-colors hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-800"
>
<div className="flex items-center gap-2">
<span
className={`inline-flex h-8 w-8 items-center justify-center rounded-full ${ARTIFACT_ICON_BG[art.type]}`}
>
<ArtifactTypeIcon type={art.type} />
</span>
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{art.name}
</span>
</div>
<p className="text-xs leading-relaxed text-slate-600 dark:text-slate-400">
{art.description}
</p>
<span className="text-xs text-slate-400">
{new Date(art.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
</a>
))}
</div>
</CardBody>
</Card>
{/* Process trace summary */}
<Card>
<CardBody className="flex flex-col gap-4">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Process Trace Summary
</h2>
<ol className="relative flex flex-col gap-4 border-l border-slate-200 pl-6 dark:border-slate-800">
{processTrace.map((step, i) => (
<li key={i} className="relative">
<span className="absolute -left-[1.6rem] flex h-5 w-5 items-center justify-center rounded-full bg-primary-100 ring-4 ring-white dark:bg-primary-900/40 dark:ring-slate-900">
<span className="h-1.5 w-1.5 rounded-full bg-primary-600" />
</span>
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
{step.action}
</span>
<span className="inline-flex items-center gap-1 text-xs text-slate-400">
<Clock className="h-3 w-3" />
{new Date(step.timestamp).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})}
</span>
</div>
<p className="text-xs text-slate-600 dark:text-slate-400">{step.detail}</p>
</div>
</li>
))}
</ol>
</CardBody>
</Card>
{/* Oral defense transcripts */}
<Card className="lg:col-span-2">
<CardBody className="flex flex-col gap-4">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Oral Defense Transcripts
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Recorded Q&amp;A from each verified oral defense session. Expand a session to read the
transcript.
</p>
<DefenseAccordion sessions={defenseSessions} />
</CardBody>
</Card>
{/* Competency mini-graph */}
<Card>
<CardBody className="flex flex-col gap-4">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Competency Progress
</h2>
<div className="flex flex-col gap-2">
{competencies.map((c) => (
<div
key={c.id}
className="flex items-center justify-between rounded-md border border-slate-200 p-2.5 dark:border-slate-800"
>
<span className="text-sm text-slate-800 dark:text-slate-200">{c.name}</span>
{c.status === 'mastered' ? (
<Badge variant="success">
<CheckCircle className="h-3 w-3" /> Mastered
</Badge>
) : c.status === 'in_progress' ? (
<Badge variant="warning">
<Clock className="h-3 w-3" /> In Progress
</Badge>
) : (
<Badge variant="default">Available</Badge>
)}
</div>
))}
</div>
</CardBody>
</Card>
{/* Microcredential verification */}
<Card>
<CardBody className="flex flex-col gap-4">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Microcredential Verification
</h2>
<div className="flex flex-col gap-2">
{microcredentials.map((mc) => (
<div
key={mc.id}
className="flex items-center justify-between rounded-md border border-slate-200 p-3 dark:border-slate-800"
>
<div className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
<div className="flex flex-col">
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
{mc.competencyName}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
Issued{' '}
{new Date(mc.issuedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200">
{mc.score}
</span>
<Badge variant="success">Verified</Badge>
</div>
</div>
))}
</div>
</CardBody>
</Card>
</div>
</div>
);
}
function StatCard({ label, value, icon }: { label: string; value: number | string; icon: React.ReactNode }) {
return (
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{icon}
</span>
<div className="flex flex-col">
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">{value}</span>
<span className="text-xs text-slate-500 dark:text-slate-400">{label}</span>
</div>
</div>
);
}
@@ -0,0 +1,22 @@
import { Badge } from '@nextcraft/ui';
import { candidates } from '@nextcraft/mock-data';
import { TalentSearch } from '../../../../components/employer/talent-search';
export default function TalentSearchPage() {
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-2">
<Badge variant="info">Talent Search</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
Talent Search
</h1>
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
AI-credentialed candidates with verified microcredentials, evidence portfolios, and oral
defense scores. Filter by competency stack, defense score, and artifact count.
</p>
</header>
<TalentSearch candidates={candidates} />
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
'use client';
import type { ReactNode } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Building2 } from 'lucide-react';
const LINKS = [
{ label: 'Overview', href: '/employer' },
{ label: 'Talent search', href: '/employer/talent' },
{ label: 'Postings', href: '/employer/postings' },
];
export default function EmployerLayout({ children }: { children: ReactNode }) {
const pathname = usePathname() ?? '';
return (
<div className="mx-auto flex max-w-7xl gap-6 px-4 py-8 sm:px-6">
<aside className="hidden w-56 shrink-0 md:block">
<div className="mb-4 flex items-center gap-2 text-sm font-semibold text-slate-500 dark:text-slate-400">
<Building2 className="h-4 w-4 text-primary-600" />
Employer surface
</div>
<nav className="flex flex-col gap-1">
{LINKS.map((l) => {
const isActive = pathname === l.href || pathname.startsWith(l.href + '/');
return (
<Link
key={l.href}
href={l.href}
aria-current={isActive ? 'page' : undefined}
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-200'
: 'text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800'
}`}
>
{l.label}
</Link>
);
})}
</nav>
</aside>
<div className="flex-1">{children}</div>
</div>
);
}
@@ -0,0 +1,300 @@
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';
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({
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);
return (
<div className="flex flex-col gap-4">
<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"
>
<ArrowLeft className="h-3.5 w-3.5" />
Back to Byte
</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>&nbsp;</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>
</div>
);
}
@@ -0,0 +1,164 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import {
ArrowLeft,
ArrowRight,
Lock,
Circle,
Loader,
CheckCircle,
Award,
Layers,
} from 'lucide-react';
import { Button, Card, CardBody, Badge } from '@nextcraft/ui';
import { competencyStacks, learnerMicrocredentials } from '@nextcraft/mock-data';
import type { Competency, CompetencyStatus } from '@nextcraft/types';
const STATUS_META: Record<
CompetencyStatus,
{ label: string; icon: typeof Lock; badgeClass: string }
> = {
locked: { label: 'Locked', icon: Lock, badgeClass: 'bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400' },
available: { label: 'Available', icon: Circle, badgeClass: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' },
in_progress: { label: 'In progress', icon: Loader, badgeClass: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' },
mastered: { label: 'Mastered', icon: CheckCircle, badgeClass: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300' },
};
const PROGRESS_BY_ID: Record<string, number> = {
'stack-orchestration-c003': 45,
'stack-orchestration-c008': 38,
'stack-safety-c019': 52,
'stack-safety-c023': 30,
'stack-designer-c034': 60,
'stack-designer-c037': 41,
'stack-operator-c048': 28,
'stack-operator-c053': 35,
'stack-science-c063': 47,
'stack-science-c066': 33,
};
function isUnlocked(c: Competency): boolean {
return c.status !== 'locked';
}
export default async function StackViewPage({
params,
}: {
params: Promise<{ stackId: string }>;
}) {
const { stackId } = await params;
const stack = competencyStacks.find((s) => s.id === stackId);
if (!stack) notFound();
const masteredCount = stack.competencies.filter((c) => c.status === 'mastered').length;
const inProgressCount = stack.competencies.filter((c) => c.status === 'in_progress').length;
const mcByComp = new Map(learnerMicrocredentials.map((m) => [m.competencyId, m]));
return (
<div className="flex flex-col gap-6">
{/* Back link */}
<Link
href="/catalog"
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" />
Back to catalog
</Link>
{/* Header */}
<header className="flex flex-col gap-3 border-l-4 border-primary-500 pl-4">
<div className="flex items-center gap-2">
<Layers className="h-5 w-5 text-primary-600" />
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
{stack.name}
</h1>
</div>
<p className="max-w-2xl text-slate-600 dark:text-slate-400">{stack.description}</p>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="info">{stack.competencies.length} competencies</Badge>
<Badge variant="success">{masteredCount} mastered</Badge>
<Badge variant="warning">{inProgressCount} in progress</Badge>
<span className="text-xs text-slate-500 dark:text-slate-400">
Target roles: {stack.targetRoles}
</span>
</div>
</header>
{/* Competency list */}
<div className="flex flex-col gap-3">
{stack.competencies.map((c) => {
const meta = STATUS_META[c.status];
const StatusIcon = meta.icon;
const mc = mcByComp.get(c.id);
const progress = PROGRESS_BY_ID[c.id];
return (
<Card key={c.id}>
<CardBody className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">
{c.name}
</h3>
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${meta.badgeClass}`}
>
<StatusIcon className="h-3 w-3" />
{meta.label}
</span>
{mc && mc.verified && (
<span className="inline-flex items-center gap-1 rounded-full bg-accent-100 px-2 py-0.5 text-xs font-medium text-accent-700 dark:bg-accent-900/40 dark:text-accent-300">
<Award className="h-3 w-3" />
Microcredential · {mc.score}
</span>
)}
</div>
<p className="text-sm text-slate-600 dark:text-slate-400">
{c.description}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
{isUnlocked(c) ? (
<Link href={`/learn/${c.id}`}>
<Button size="sm" iconRight={<ArrowRight className="h-3.5 w-3.5" />}>
Start Byte
</Button>
</Link>
) : (
<Button size="sm" variant="ghost" disabled icon={<Lock className="h-3.5 w-3.5" />}>
Locked
</Button>
)}
</div>
</div>
{/* Progress / mastery bar */}
{c.status === 'in_progress' && typeof progress === 'number' && (
<div className="flex items-center gap-2">
<div className="h-2 flex-1 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
<div
className="h-full rounded-full bg-amber-500"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-xs text-slate-500 dark:text-slate-400">
{progress}%
</span>
</div>
)}
{c.status === 'mastered' && (
<div className="flex items-center gap-2 text-xs text-emerald-700 dark:text-emerald-300">
<Award className="h-3.5 w-3.5" />
Microcredential earned
</div>
)}
</CardBody>
</Card>
);
})}
</div>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Link from 'next/link';
import { ArrowRight, Layers } from 'lucide-react';
import { Button, Card, CardBody, Badge } from '@nextcraft/ui';
import { competencyStacks } from '@nextcraft/mock-data';
export default function CatalogPage() {
return (
<div className="flex flex-col gap-6">
{/* Header */}
<header className="flex flex-col gap-2">
<Badge variant="info">Learner · Catalog</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
Program Catalog
</h1>
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
Five competency stacks covering orchestration, safety, design, field operations, and
computational sciences. Each stack is a sequence of micro-tutorials, sandbox builds,
artifacts, and oral defenses.
</p>
</header>
{/* Grid */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{competencyStacks.map((stack) => (
<Card key={stack.id} className="flex h-full flex-col">
<CardBody className="flex flex-1 flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">
<Layers className="h-5 w-5" />
</span>
<Badge variant="default">{stack.competencies.length} competencies</Badge>
</div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
{stack.name}
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
{stack.description}
</p>
<p className="text-xs text-slate-500 dark:text-slate-500">
<span className="font-medium text-slate-600 dark:text-slate-400">
Target roles:
</span>{' '}
{stack.targetRoles}
</p>
<div className="mt-auto pt-2">
<Link href={`/catalog/${stack.id}`}>
<Button variant="outline" size="sm" iconRight={<ArrowRight className="h-3.5 w-3.5" />}>
Explore Stack
</Button>
</Link>
</div>
</CardBody>
</Card>
))}
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More