Files
nextcraft/apps/ai-service/ai_service/api/lab.py
T
CIAgent dc00a4f8f5 chore(P05): type & dependency hardening — mypy + recharts 3 (REQ-7-005)
Wave 5-1 mypy adoption (D-058 relaxed-first): mypy 2.3.1, [tool.mypy]
check_untyped_defs + no_implicit_optional + warn_unused_ignores +
warn_redundant_casts + warn_unreachable, no overrides (every dep ships
py.typed), files = ai_service + scripts/sandbox-agent.py + tests (not
excluded). 215 errors → 0: ~140 fixed at source incl. genuine bugs found
by the gate (LLMProvider protocol async-generator shape, SessionStore
protocol sync/async mismatch, structured_reply genericity, get_args
migrations, sandbox-agent Optional guard honesty, proctor test arg-order,
dead ExecResult kwarg, 16 sqlmodel col() sites); 34 live suppressions
scoped-coded + reasoned. Original 27 type:ignore dispositioned: 12 dead
removed, 15 scoped (warn_unused_ignores self-eliminates the rest).
Wired: scripts/typecheck.sh + package.json + turbo task + root
ai:typecheck — pnpm typecheck now 5 tsc + mypy.

Wave 5-2 recharts 2.15.4 → 3.10.1 (D-049/D-059): one breaking class
(Tooltip formatter ValueType typing); 2 one-line inference fixes on the
2 chart files; no rollback needed (well inside the G-106 timebox).
Deprecated direct dep resolved; uuid@9 gone from the tree. Build 248/248
with chart SSR markers verified in exported employer + dashboard pages.

Wave 5-3 deprecated-transitive audit (G-109): 4 lockfile entries remain,
all dev-only, none actionable within NFR budget — glob@7.2.3 + inflight +
rimraf@3 via Storybook docgen chain (flat-cache@3); glob@10.5.0 via
c8→test-exclude (D-052-binding coverage tool — swapping it is out of
budget). Python: no EOL packages; zero-warning regime under
filterwarnings=error is the machine-checked proof.

Counts: 670 py / 44 web / 64 cli, zero warnings, G-104 allowlist still
exactly 2.

---ci---
phase: 5
milestone: v0.7
status: execute
---/ci---
2026-09-27 10:08:07 +00:00

88 lines
3.1 KiB
Python

"""POST /v1/lab/feedback — SSE stream of Lab in-flow feedback (REQ-3-007).
v0.3 re-grounding: LIVE trace digest. Request carries {learner_id, task_id};
the digest is computed from the learner's real TraceStore events (D-028)
and handed to the Lab agent. No corpus scenarios. Empty/unknown trace is NOT
an error — Lab gets a "no telemetry yet" timeline and coaches the baseline.
D-016 envelope with agent=lab.
"""
import json
from collections.abc import AsyncIterator
from typing import cast
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sse_starlette.sse import EventSourceResponse
from ..agents.lab import LabAgent
from ..agents.registry import AgentRegistry
from ..config import Settings
from ..corpus.learner_context import get_learner_context
from ..grading.features import compute_digest
from .deps import (
get_agent_registry,
get_provider,
get_settings,
get_trace_store,
)
router = APIRouter(prefix="/v1")
class LabFeedbackRequest(BaseModel):
learner_id: str = Field(min_length=1)
task_id: str = Field(min_length=1)
@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),
trace_store=Depends(get_trace_store),
) -> EventSourceResponse:
# P5: registry.get returns BaseAgent; "lab" is registered as LabAgent
# (register_builtin_agents) — the cast states that mapping.
agent = cast(LabAgent, registry.get(provider, settings, "lab"))
learner_context = get_learner_context(body.learner_id)
trace = (
trace_store.get_trace(body.learner_id, body.task_id)
if body.task_id in trace_store.list_tasks(body.learner_id)
else []
)
digest = compute_digest(trace) if trace else None
async def event_stream() -> AsyncIterator[dict]:
yield {"event": "message", "data": json.dumps({
"type": "meta",
"agent": "lab",
"task_id": body.task_id,
"model": settings.model,
})}
first_byte = True
try:
async for token in agent.stream_feedback(digest, 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"},
)