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).
This commit is contained in:
CIAgent
2026-09-11 17:34:50 +00:00
parent 71728e35c8
commit 88a1dab810
104 changed files with 6093 additions and 739 deletions
+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
@@ -11,7 +11,8 @@ import {
Activity,
} from 'lucide-react';
import { Button } from '@nextcraft/ui';
import { allCompetencies, competencyStacks } from '@nextcraft/mock-data';
import { allCompetencies, competencyStacks, aiLabScenarios } from '@nextcraft/mock-data';
import { LabFeedbackPanel } from '../../../../components/learner/lab-feedback-panel';
interface FileEntry {
label: string;
@@ -279,6 +280,10 @@ export default async function BuildSandboxPage({
))}
</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>
+17 -1
View File
@@ -19,6 +19,7 @@ import {
learnerMicrocredentials,
} from '@nextcraft/mock-data';
import { AiTutorChat } from '../../../components/learner/ai-tutor-chat';
import { MentorPanel } from '../../../components/learner/mentor-panel';
import { ProgressGraph } from '../../../components/learner/progress-graph';
const ACTIVE_COMPETENCY_IDS = [
@@ -265,7 +266,7 @@ export default function DashboardPage() {
AI Tutor
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Coach and Socratic tutor · mock responses
Coach and Socratic tutor · live streaming
</p>
</div>
</div>
@@ -274,6 +275,21 @@ export default function DashboardPage() {
<AiTutorChat />
</CardBody>
</Card>
{/* Mentor — long-horizon career narrative */}
<Card>
<CardHeader>
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">
Mentor
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Long-horizon career trajectory · live streaming
</p>
</CardHeader>
<CardBody>
<MentorPanel />
</CardBody>
</Card>
</div>
);
}
@@ -16,6 +16,9 @@ import {
import { Card, CardBody, CardHeader, Badge, Button } from '@nextcraft/ui';
import { allCompetencies, competencyStacks } from '@nextcraft/mock-data';
import { OralDefenseInterface } from '../../../../components/learner/oral-defense-interface';
import { AssessorResultsPanel } from '../../../../components/learner/assessor-results-panel';
import { ProctorBanner } from '../../../../components/learner/proctor-banner';
import { aiArtifactSubmissions } from '@nextcraft/mock-data';
const RUBRIC = [
{ name: 'Correctness of agent architecture', passed: true, weight: 25 },
@@ -254,10 +257,22 @@ export default async function DefensePage({
a structured-output schema and re-run the eval harness before your oral defense.
</p>
</div>
{/* Live Assessor — structured rubric from the real agent (mock inputs) */}
<div className="border-t border-slate-200 pt-3 dark:border-slate-800">
<AssessorResultsPanel artifactId={aiArtifactSubmissions[0].id} />
</div>
</CardBody>
</Card>
</div>
{/* Proctor integrity banner — coaching-shaped (mock telemetry) */}
<Card>
<CardBody>
<ProctorBanner scenarioId="proctor-scenario-distracted" />
</CardBody>
</Card>
{/* Process trace timeline */}
<Card>
<CardHeader>
@@ -4,6 +4,7 @@ import { ArrowLeft, ArrowRight, Clock } from 'lucide-react';
import { Button, Card, CardBody, Badge } from '@nextcraft/ui';
import { allCompetencies, competencyStacks } from '@nextcraft/mock-data';
import { WorkedExampleTabs } from '../../../../components/learner/worked-example-tabs';
import { ByteTutorPanel } from '../../../../components/learner/byte-tutor-panel';
export default async function ByteTutorialPage({
params,
@@ -39,6 +40,12 @@ export default async function ByteTutorialPage({
</header>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Tutor explanation panel — agent fixed to tutor (A-007) */}
<Card>
<CardBody>
<ByteTutorPanel competencyId={competency.id} competencyName={competency.name} />
</CardBody>
</Card>
{/* Concept panel */}
<Card className="flex flex-col">
<CardBody className="flex flex-col gap-4">
@@ -0,0 +1,191 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { parseSseEvents } from '../../lib/sse';
import { Bot, RefreshCw, AlertTriangle, Loader2 } from 'lucide-react';
const AI_SERVICE_URL =
process.env.NEXT_PUBLIC_AI_SERVICE_URL ?? 'http://localhost:8420';
interface StreamPanelProps {
title: string;
endpoint: string; // e.g. "/v1/lab/feedback"
body: Record<string, unknown>;
autoLoad?: boolean;
emptyHint?: string;
/** Renders structured JSON results (assessor/proctor) as custom UI */
renderJson?: (data: Record<string, unknown>) => React.ReactNode;
}
interface SseResult {
text: string;
error: string | null;
streaming: boolean;
}
/**
* Generic SSE-consuming panel for the non-chat agent endpoints
* (lab feedback, mentor narrative, assessor/proctor JSON).
* Streams text panels; renders structured JSON via renderJson when set.
*/
export function AgentStreamPanel({
title,
endpoint,
body,
autoLoad = false,
emptyHint,
renderJson,
}: StreamPanelProps) {
const [result, setResult] = useState<SseResult>({ text: '', error: null, streaming: false });
const [json, setJson] = useState<Record<string, unknown> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const startedRef = useRef(false);
const load = useCallback(async () => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setResult({ text: '', error: null, streaming: true });
setJson(null);
try {
const response = await fetch(`${AI_SERVICE_URL}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
});
const contentType = response.headers.get('content-type') ?? '';
if (!response.ok) {
throw new Error(`AI service error (${response.status})`);
}
if (contentType.includes('application/json')) {
const data = (await response.json()) as Record<string, unknown>;
setJson(data);
setResult({ text: '', error: null, streaming: false });
return;
}
if (!response.body) {
throw new Error('AI service returned an empty stream');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let text = '';
let done = false;
while (!done) {
const { value, done: readerDone } = await reader.read();
if (readerDone) break;
buffer += decoder.decode(value, { stream: true });
const { events, rest } = parseSseEvents(buffer);
buffer = rest;
for (const raw of events) {
if (raw === '[DONE]') {
done = true;
break;
}
try {
const event = JSON.parse(raw);
if (event.type === 'delta') {
text += event.content as string;
setResult({ text, error: null, streaming: true });
} else if (event.type === 'error') {
setResult({ text, error: event.message as string, streaming: false });
done = true;
break;
}
} catch {
// ignore non-JSON frames
}
}
}
setResult((prev) => ({ ...prev, streaming: false }));
} catch (err) {
const aborted = err instanceof DOMException && err.name === 'AbortError';
if (!aborted) {
setResult({
text: '',
error: err instanceof Error ? err.message : 'connection failed',
streaming: false,
});
} else {
setResult((prev) => ({ ...prev, streaming: false }));
}
} finally {
abortRef.current = null;
}
}, [endpoint, body]);
useEffect(() => {
return () => {
abortRef.current?.abort();
abortRef.current = null;
};
}, []);
useEffect(() => {
if (autoLoad && !startedRef.current) {
startedRef.current = true;
void load();
}
}, [autoLoad, load]);
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h3 className="inline-flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-slate-100">
<Bot className="h-4 w-4 text-primary-600 dark:text-primary-400" />
{title}
</h3>
<button
onClick={() => void load()}
disabled={result.streaming}
className="inline-flex items-center gap-1 rounded-md border border-slate-300 px-2 py-1 text-xs text-slate-600 transition-colors hover:border-primary-400 hover:text-primary-700 disabled:opacity-50 dark:border-slate-600 dark:text-slate-300 dark:hover:border-primary-400 dark:hover:text-primary-300"
>
{result.streaming ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
{result.streaming ? 'Streaming…' : json || result.text ? 'Regenerate' : 'Generate'}
</button>
</div>
{result.error && (
<div
role="alert"
className="flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div>
<p className="font-medium">AI service unavailable</p>
<p className="opacity-80">{result.error}</p>
<button onClick={() => void load()} className="mt-1 font-semibold underline">
Retry
</button>
</div>
</div>
)}
{!result.error && !json && !result.text && !result.streaming && (
<p className="text-xs text-slate-500 dark:text-slate-400">
{emptyHint ?? 'Generate to see the agent in action.'}
</p>
)}
{json && renderJson ? (
renderJson(json)
) : result.text ? (
<div className="whitespace-pre-wrap rounded-md bg-slate-100 px-3 py-2 text-sm leading-relaxed text-slate-800 dark:bg-slate-800 dark:text-slate-100">
{result.text}
{result.streaming && (
<span className="ml-0.5 inline-block h-4 w-1.5 animate-pulse rounded-sm bg-primary-500 align-middle" />
)}
</div>
) : null}
</div>
);
}
+112 -84
View File
@@ -1,130 +1,110 @@
'use client';
import { useState, useRef, useEffect, type FormEvent } from 'react';
import { Bot, Send, User } from 'lucide-react';
import { aiTutorResponses, type TutorResponse } from '@nextcraft/mock-data';
import { Bot, Send, AlertTriangle, RotateCcw } from 'lucide-react';
import { primaryLearner } from '@nextcraft/mock-data';
import { Avatar } from '@nextcraft/ui';
import { useChatStream, type AgentName, type StreamMessage } from '../../hooks/use-chat-stream';
interface ChatMessage {
id: string;
role: 'learner' | 'tutor';
content: string;
suggestedActions?: string[];
}
const SEED_MESSAGES: ChatMessage[] = [
{
id: 'seed-1',
role: 'tutor',
content:
"Welcome back, Alex. You're 62% through the AI Orchestration stack. What would you like to work on today?",
suggestedActions: ['Review my pacing', 'Start Multi-Agent Communication', 'Prep for my defense'],
},
const AGENTS: { id: AgentName; label: string; blurb: string }[] = [
{ id: 'coach', label: 'Coach', blurb: 'Pacing, motivation, retrieval practice' },
{ id: 'tutor', label: 'Tutor', blurb: 'Concepts, worked examples, Socratic checks' },
];
const SEED_MESSAGE: StreamMessage = {
id: 'seed-1',
role: 'assistant',
content:
"Welcome back, Alex. You're 62% through the AI Orchestration stack. What would you like to work on today?",
suggestedActions: ['Review my pacing', 'Start Multi-Agent Communication', 'Prep for my defense'],
};
export function AiTutorChat() {
const [messages, setMessages] = useState<ChatMessage[]>(SEED_MESSAGES);
const [agent, setAgent] = useState<AgentName>('coach');
const [input, setInput] = useState('');
const [isTyping, setIsTyping] = useState(false);
const { messages, isStreaming, error, send, retry, abort } = useChatStream(agent);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, isTyping]);
}, [messages, isStreaming, error]);
function send(e: FormEvent) {
function handleSubmit(e: FormEvent) {
e.preventDefault();
const text = input.trim();
if (!text || isTyping) return;
const learnerMsg: ChatMessage = { id: `me-${Date.now()}`, role: 'learner', content: text };
setMessages((prev) => [...prev, learnerMsg]);
if (!text || isStreaming) return;
setInput('');
setIsTyping(true);
void send(text);
}
window.setTimeout(() => {
const pick: TutorResponse =
aiTutorResponses[Math.floor(Math.random() * aiTutorResponses.length)];
const tutorMsg: ChatMessage = {
id: `tutor-${Date.now()}`,
role: 'tutor',
content: pick.message,
suggestedActions: pick.suggestedActions,
};
setMessages((prev) => [...prev, tutorMsg]);
setIsTyping(false);
}, 1000);
function switchAgent(next: AgentName) {
if (isStreaming) abort();
setAgent(next);
}
return (
<div className="flex h-[28rem] flex-col">
{/* Agent switcher (A-007: explicit routing, no autonomy) */}
<div className="mb-3 flex items-center gap-2" role="tablist" aria-label="Choose tutor agent">
{AGENTS.map((a) => (
<button
key={a.id}
role="tab"
aria-selected={agent === a.id}
onClick={() => switchAgent(a.id)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
agent === a.id
? 'bg-primary-600 text-white'
: 'border border-slate-300 text-slate-600 hover:border-primary-400 hover:text-primary-700 dark:border-slate-600 dark:text-slate-300 dark:hover:text-primary-300'
}`}
>
{a.label}
</button>
))}
</div>
{/* Message list */}
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto pr-2">
<ChatMessage message={SEED_MESSAGE} />
{messages.map((m) => (
<div
key={m.id}
className={`flex gap-3 ${m.role === 'learner' ? 'flex-row-reverse' : 'flex-row'}`}
>
{m.role === 'tutor' ? (
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">
<Bot className="h-4 w-4" />
</span>
) : (
<Avatar name={primaryLearner.name} src={primaryLearner.avatar} size="sm" />
)}
<div
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
m.role === 'tutor'
? 'bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-100'
: 'bg-primary-600 text-white'
}`}
>
<p className="leading-relaxed">{m.content}</p>
{m.suggestedActions && m.suggestedActions.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{m.suggestedActions.map((action) => (
<button
key={action}
onClick={() => setInput(action)}
className="rounded-full border border-slate-300 bg-white px-2 py-0.5 text-xs text-slate-600 transition-colors hover:border-primary-400 hover:text-primary-700 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-primary-400 dark:hover:text-primary-300"
>
{action}
</button>
))}
</div>
)}
</div>
</div>
<ChatMessage key={m.id} message={m} onAction={(a) => setInput(a)} />
))}
{/* Typing indicator */}
{isTyping && (
<div className="flex flex-row gap-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">
<Bot className="h-4 w-4" />
{/* Error state with retry (A-010) */}
{error && (
<div
role="alert"
className="flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
<AlertTriangle className="h-4 w-4 shrink-0" />
<span className="flex-1">
The tutor service is unreachable. Your message can be retried.
</span>
<div className="flex items-center gap-1 rounded-lg bg-slate-100 px-3 py-3 dark:bg-slate-800">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" />
</div>
<button
onClick={retry}
className="inline-flex items-center gap-1 font-semibold underline"
>
<RotateCcw className="h-3 w-3" /> Retry
</button>
</div>
)}
</div>
{/* Input */}
<form onSubmit={send} className="mt-3 flex items-center gap-2 border-t border-slate-200 pt-3 dark:border-slate-800">
<form onSubmit={handleSubmit} className="mt-3 flex items-center gap-2 border-t border-slate-200 pt-3 dark:border-slate-800">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask your AI tutor anything…"
placeholder={`Ask your ${agent} anything…`}
aria-label="Message"
className="h-10 flex-1 rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-900 placeholder:text-slate-400 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
<button
type="submit"
disabled={!input.trim() || isTyping}
disabled={!input.trim() || isStreaming}
className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-primary-600 text-white transition-colors hover:bg-primary-700 disabled:opacity-50"
aria-label="Send message"
>
@@ -133,4 +113,52 @@ export function AiTutorChat() {
</form>
</div>
);
}
function ChatMessage({
message,
onAction,
}: {
message: StreamMessage;
onAction?: (text: string) => void;
}) {
const isAssistant = message.role === 'assistant';
return (
<div className={`flex gap-3 ${isAssistant ? 'flex-row' : 'flex-row-reverse'}`}>
{isAssistant ? (
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">
<Bot className="h-4 w-4" />
</span>
) : (
<Avatar name={primaryLearner.name} src={primaryLearner.avatar} size="sm" />
)}
<div
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
isAssistant
? 'bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-100'
: 'bg-primary-600 text-white'
}`}
>
<p className="leading-relaxed whitespace-pre-wrap">
{message.content}
{message.streaming && (
<span className="ml-0.5 inline-block h-4 w-1.5 animate-pulse rounded-sm bg-primary-500 align-middle" />
)}
</p>
{message.suggestedActions && message.suggestedActions.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{message.suggestedActions.map((action) => (
<button
key={action}
onClick={() => onAction?.(action)}
className="rounded-full border border-slate-300 bg-white px-2 py-0.5 text-xs text-slate-600 transition-colors hover:border-primary-400 hover:text-primary-700 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-primary-400 dark:hover:text-primary-300"
>
{action}
</button>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,101 @@
'use client';
import { AgentStreamPanel } from './agent-stream-panel';
import { AlertTriangle, CheckCircle2, CircleDashed } from 'lucide-react';
interface CriterionScore {
criterion_id: string;
name: string;
score: number;
evidence: string;
}
interface RubricScore {
rubric_id: string;
artifact_id: string;
competency_id: string;
scores: CriterionScore[];
strengths: string[];
gaps: string[];
verdict: string;
}
/**
* Assessment surface — Assessor rubric output (structured JSON) +
* Proctor integrity banner. Mock engine inputs; real engines v0.3+.
*/
export function AssessorResultsPanel({ artifactId }: { artifactId: string }) {
return (
<AgentStreamPanel
title="Assessor — rubric evaluation"
endpoint="/v1/assessment/evaluate"
body={{ artifact_id: artifactId }}
emptyHint="Run the Assessor to grade this artifact against its rubric."
renderJson={(data) => {
const score = data as unknown as RubricScore;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
{score.verdict === 'mastered' ? (
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
) : score.verdict === 'developing' ? (
<CircleDashed className="h-5 w-5 text-amber-600 dark:text-amber-400" />
) : (
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
)}
<span className="text-sm font-semibold capitalize text-slate-900 dark:text-slate-100">
{score.verdict}
</span>
</div>
<div className="flex flex-col gap-3">
{score.scores.map((c) => (
<div key={c.criterion_id}>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="font-medium text-slate-700 dark:text-slate-300">{c.name}</span>
<span className="text-slate-500 dark:text-slate-400">{c.score}/100</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
<div
className="h-full rounded-full bg-primary-500 transition-all"
style={{ width: `${c.score}%` }}
role="progressbar"
aria-valuenow={c.score}
aria-valuemin={0}
aria-valuemax={100}
aria-label={c.name}
/>
</div>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{c.evidence}</p>
</div>
))}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-emerald-700 dark:text-emerald-400">
Strengths
</h4>
<ul className="list-inside list-disc text-xs text-slate-600 dark:text-slate-300">
{score.strengths.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
</div>
<div>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-amber-700 dark:text-amber-400">
Gaps
</h4>
<ul className="list-inside list-disc text-xs text-slate-600 dark:text-slate-300">
{score.gaps.map((g) => (
<li key={g}>{g}</li>
))}
</ul>
</div>
</div>
</div>
);
}}
/>
);
}
@@ -0,0 +1,33 @@
'use client';
import { AgentStreamPanel } from './agent-stream-panel';
/**
* Byte viewer Tutor panel — "Explain this byte" streams a Socratic concept
* walkthrough for the current competency (agent fixed to tutor, A-007).
*/
export function ByteTutorPanel({
competencyId,
competencyName,
}: {
competencyId: string;
competencyName: string;
}) {
return (
<AgentStreamPanel
title="Tutor — explain this byte"
endpoint="/v1/chat/stream"
body={{
agent: 'tutor',
session_id: `byte-${competencyId}`,
messages: [
{
role: 'user',
content: `Explain the byte "${competencyName}" — one concept, a worked example, then a question to check my understanding.`,
},
],
}}
emptyHint="Ask the Tutor to walk you through this byte concept step by step."
/>
);
}
@@ -0,0 +1,18 @@
'use client';
import { AgentStreamPanel } from './agent-stream-panel';
/**
* Sandbox Lab feedback panel — streams in-flow feedback for the selected
* mock telemetry scenario (real telemetry is v0.3+).
*/
export function LabFeedbackPanel({ scenarioId }: { scenarioId: string }) {
return (
<AgentStreamPanel
title="Lab — in-flow feedback"
endpoint="/v1/lab/feedback"
body={{ scenario_id: scenarioId }}
emptyHint="Run the Lab agent on this build session's telemetry."
/>
);
}
@@ -0,0 +1,18 @@
'use client';
import { AgentStreamPanel } from './agent-stream-panel';
/**
* Dashboard Mentor panel — streams a long-horizon career narrative
* tied to the learner's progress. Session-backed follow-ups in v0.3+ UI.
*/
export function MentorPanel() {
return (
<AgentStreamPanel
title="Mentor — your trajectory"
endpoint="/v1/mentor/narrative"
body={{ session_id: 'dashboard-mentor', prompt: 'Narrate my trajectory.' }}
emptyHint="Ask the Mentor where your competency progress is taking you."
/>
);
}
@@ -0,0 +1,60 @@
'use client';
import { AgentStreamPanel } from './agent-stream-panel';
import { ShieldCheck } from 'lucide-react';
interface IntegritySignal {
signal_type: string;
severity: 'low' | 'medium' | 'high';
note: string;
}
interface ProctorAssessment {
scenario_id: string;
signals: IntegritySignal[];
intervention: string;
summary: string;
}
const SEVERITY_STYLES: Record<string, string> = {
low: 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-300 dark:border-emerald-800',
medium: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-300 dark:border-amber-800',
high: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800',
};
/**
* Proctor integrity banner — supportive, coaching-shaped (never punitive).
*/
export function ProctorBanner({ scenarioId }: { scenarioId: string }) {
return (
<AgentStreamPanel
title="Proctor — integrity support"
endpoint="/v1/proctor/signals"
body={{ scenario_id: scenarioId }}
emptyHint="Run the Proctor to review this session's integrity signals."
renderJson={(data) => {
const assessment = data as unknown as ProctorAssessment;
return (
<div className="flex flex-col gap-3">
<p className="text-xs text-slate-600 dark:text-slate-300">{assessment.summary}</p>
<div className="flex flex-wrap gap-1.5">
{assessment.signals.map((s, i) => (
<span
key={i}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs ${SEVERITY_STYLES[s.severity] ?? SEVERITY_STYLES.low}`}
title={s.note}
>
<ShieldCheck className="h-3 w-3" />
{s.signal_type} · {s.severity}
</span>
))}
</div>
<p className="rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-800 dark:bg-primary-900/30 dark:text-primary-200">
<strong>Suggested next step:</strong> {assessment.intervention}
</p>
</div>
);
}}
/>
);
}
+218
View File
@@ -0,0 +1,218 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { parseSseEvents } from '../lib/sse';
const AI_SERVICE_URL =
process.env.NEXT_PUBLIC_AI_SERVICE_URL ?? 'http://localhost:8420';
export type AgentName = 'coach' | 'tutor' | 'lab' | 'assessor' | 'proctor' | 'mentor';
export interface StreamMessage {
id: string;
role: 'user' | 'assistant';
content: string;
agent?: AgentName;
streaming?: boolean;
suggestedActions?: string[];
}
interface StreamState {
messages: StreamMessage[];
isStreaming: boolean;
error: string | null;
model: string | null;
}
interface ChatStreamEvent {
type: 'meta' | 'delta' | 'done' | 'error';
[key: string]: unknown;
}
function decodeEvent(raw: string): ChatStreamEvent | '[DONE]' | null {
if (raw === '[DONE]') return '[DONE]';
try {
const parsed = JSON.parse(raw);
if (typeof parsed?.type === 'string') return parsed as ChatStreamEvent;
// OpenAI-shaped chunks (id/choices) are not used by our envelope;
// ignore anything without a type.
return null;
} catch {
return null;
}
}
export function useChatStream(agent: AgentName) {
const [state, setState] = useState<StreamState>({
messages: [],
isStreaming: false,
error: null,
model: null,
});
const abortRef = useRef<AbortController | null>(null);
// Agent-scoped sessions (A-007/D-019): switching agents starts a NEW
// session per agent — no persona bleed across switcher flips.
const sessionsRef = useRef<Partial<Record<AgentName, string>>>({});
if (!sessionsRef.current[agent]) {
const uuid =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: String(Date.now());
sessionsRef.current[agent] = `${agent}-${uuid}`;
}
// Idempotent abort + cleanup on unmount or agent switch (Strict Mode safe)
const abort = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
}, []);
useEffect(() => {
return () => {
abortRef.current?.abort();
abortRef.current = null;
};
}, []);
const send = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed || abortRef.current) return;
const userMessage: StreamMessage = {
id: `user-${Date.now()}`,
role: 'user',
content: trimmed,
};
const assistantId = `assistant-${Date.now()}`;
setState((prev) => ({
...prev,
messages: [...prev.messages, userMessage],
isStreaming: true,
error: null,
}));
const controller = new AbortController();
abortRef.current = controller;
try {
const response = await fetch(`${AI_SERVICE_URL}/v1/chat/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agent,
session_id: sessionsRef.current[agent],
messages: [{ role: 'user', content: trimmed }],
}),
signal: controller.signal,
});
if (!response.ok || !response.body) {
throw new Error(`AI service unavailable (${response.status})`);
}
setState((prev) => ({
...prev,
messages: [
...prev.messages,
{ id: assistantId, role: 'assistant', content: '', agent, streaming: true },
],
}));
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let done = false;
while (!done) {
const { value, done: readerDone } = await reader.read();
if (readerDone) break;
buffer += decoder.decode(value, { stream: true });
const { events, rest } = parseSseEvents(buffer);
buffer = rest;
for (const raw of events) {
const event = decodeEvent(raw);
if (event === null) continue;
if (event === '[DONE]') {
done = true;
setState((prev) => ({
...prev,
isStreaming: false,
messages: prev.messages.map((m) =>
m.id === assistantId ? { ...m, streaming: false } : m,
),
}));
break;
}
if (event.type === 'meta') {
setState((prev) => ({ ...prev, model: (event.model as string) ?? null }));
} else if (event.type === 'delta') {
const content = event.content as string;
setState((prev) => ({
...prev,
messages: prev.messages.map((m) =>
m.id === assistantId ? { ...m, content: m.content + content } : m,
),
}));
} else if (event.type === 'error') {
setState((prev) => ({
...prev,
error: (event.message as string) ?? 'stream error',
}));
}
}
}
setState((prev) => ({
...prev,
isStreaming: false,
messages: prev.messages.map((m) =>
m.id === assistantId ? { ...m, streaming: false } : m,
),
}));
} catch (err) {
const aborted = err instanceof DOMException && err.name === 'AbortError';
if (!aborted) {
setState((prev) => ({
...prev,
isStreaming: false,
error: err instanceof Error ? err.message : 'connection failed',
messages: prev.messages.map((m) =>
m.id === assistantId ? { ...m, streaming: false } : m,
),
}));
} else {
setState((prev) => ({
...prev,
isStreaming: false,
messages: prev.messages.map((m) =>
m.id === assistantId ? { ...m, streaming: false } : m,
),
}));
}
} finally {
abortRef.current = null;
}
},
[agent],
);
const retry = useCallback(() => {
setState((prev) => ({ ...prev, error: null }));
const lastUser = [...state.messages].reverse().find((m) => m.role === 'user');
if (lastUser) void send(lastUser.content);
}, [send, state.messages]);
return {
messages: state.messages,
isStreaming: state.isStreaming,
error: state.error,
model: state.model,
send,
retry,
abort,
};
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Shared SSE parsing for the AI service streams.
*
* Normalizes CRLF (sse-starlette's wire terminator is \r\n) to LF, then
* splits frames on blank lines. Multiple `data:` lines within one frame
* are joined with \n per the SSE spec. Frames with no data lines
* (keep-alive `: ping` comments) are ignored (G-1).
*/
export function parseSseEvents(buffer: string): { events: string[]; rest: string } {
const normalized = buffer.replace(/\r\n/g, '\n');
const events: string[] = [];
const separatorIndex = normalized.lastIndexOf('\n\n');
if (separatorIndex === -1) return { events, rest: normalized };
const complete = normalized.slice(0, separatorIndex);
const rest = normalized.slice(separatorIndex + 2);
for (const frame of complete.split('\n\n')) {
const dataLines = frame
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart());
if (dataLines.length === 0) continue; // ping/comment frame — ignore (G-1)
events.push(dataLines.join('\n'));
}
return { events, rest };
}