Files
nextcraft/apps/ai-service/ai_service/main.py
T
CIAgent 6ab0ae2c0a fix(P04): ship variant anchors to the grader prompt (MH#4 second clause — verifier P1)
GradingEngine takes an optional VariantStore (constructor DI); when the graded
task_id joins to a stored variant: the template's difficulty anchors render into
the grader user turn ("Expected effort envelope" — same bar for every variant of
the template, a-5) and the variant seed is stamped on the GradeRecord (D-029).
Lifespan reordered: VariantStore builds before the engine and is passed in.
Anchors context carries only template id + anchor numbers — D-028 learner-anonymity
preserved (leak tests keep holding). Plain engine (no store) stays variant-blind;
non-variant tasks grade without the envelope.

3 new tests: variant task -> anchors + seed present in prompt/record;
non-variant task -> no envelope; plain engine -> variant_seed None.
Suite 327 green; ruff clean.

---ci---
phase: 4
milestone: v0.3
status: verify
requirements: {covered: [REQ-3-005], partial: []}
---/ci---
2026-09-12 03:51:25 +00:00

180 lines
7.6 KiB
Python

"""FastAPI app factory — lifespan, CORS, health, routers."""
import asyncio
import contextlib
import logging
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,
sandboxes_router,
telemetry_router,
variants_router,
)
from .config import Settings
from .grading.engine import GradingEngine
from .grading.store import SQLiteGradeStore
from .llm import create_provider
from .sandbox import SandboxManager, UnshareBackend
from .telemetry.ingest import TraceIntegrityMap
from .telemetry.store import SQLiteTraceStore
from .variants.generator import VariantGenerator
from .variants.store import SQLiteVariantStore
logger = logging.getLogger(__name__)
#: Interval between wall-clock/G-2 reaper passes (the manager owns the pass;
#: the lifespan owns the loop). 60s against a 900s default timeout → ≤6.7% lag.
REAPER_INTERVAL_S = 60.0
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)
# v0.3 sandbox fabric (REQ-3-001): singleton manager, DI'd via
# app.state. Tests may pre-set app.state.sandbox_manager (dependency
# override by state injection) to swap the backend; the lifespan then
# adopts it instead of constructing the real UnshareBackend one.
manager = getattr(app.state, "sandbox_manager", None)
if manager is None:
manager = SandboxManager(backend=UnshareBackend(), settings=settings)
app.state.sandbox_manager = manager
await manager.start() # a-1: reap on-disk orphans from a previous process
# Telemetry persistence (REQ-3-003, D-027): TraceStore wired through
# app.state. Tests may pre-set app.state.trace_store (state-injection
# override, same pattern as sandbox_manager) — the lifespan adopts it.
trace_store = getattr(app.state, "trace_store", None)
if trace_store is None:
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
trace_store = SQLiteTraceStore(db_path=settings.db_path)
app.state.trace_store = trace_store
# Trace-integrity flags (G-3 INCOMPLETE_FLOODED): process-local map is
# intentional (D-019 registry precedent); the lifespan owns it so the
# grader and the ingest endpoint share one instance.
if getattr(app.state, "trace_integrity", None) is None:
app.state.trace_integrity = TraceIntegrityMap()
# Variant generation (REQ-3-005): VariantStore from the same
# SQLite file as traces/grades (D-027), one VariantGenerator singleton
# wired through app.state — the generator receives store + provider
# via constructor DI and knows nothing of FastAPI (api/ composes it,
# same pattern as GradingEngine). Tests may pre-set
# app.state.variant_store / app.state.variant_generator (the same
# state-injection override); the lifespan adopts a pre-set store but
# NEVER rebuilds a pre-set generator (its provider binding is part
# of the test fixture).
# ORDER NOTE: built BEFORE the grading engine — the engine takes the
# variant store (Phase 4 MH#4: variant anchors ship to the grader
# prompt; variant_seed stamped on graded records).
variant_store = getattr(app.state, "variant_store", None)
if variant_store is None:
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
variant_store = SQLiteVariantStore(db_path=settings.db_path)
app.state.variant_store = variant_store
if getattr(app.state, "variant_generator", None) is None:
app.state.variant_generator = VariantGenerator(
variant_store,
app.state.provider,
model=settings.model,
)
# Grading persistence + engine (REQ-3-004): GradeStore from the same
# SQLite file as traces (D-027), one GradingEngine singleton wired
# through app.state — the engine receives its stores via constructor
# DI and knows nothing of FastAPI (api/ owns composition). Tests may
# pre-set app.state.grade_store / app.state.grading_engine (the same
# state-injection override as sandbox_manager/trace_store) to swap
# either; the lifespan adopts a pre-set store but NEVER rebuilds a
# pre-set engine (its provider binding is part of the test fixture).
grade_store = getattr(app.state, "grade_store", None)
if grade_store is None:
grade_store = SQLiteGradeStore(db_path=settings.db_path)
app.state.grade_store = grade_store
if getattr(app.state, "grading_engine", None) is None:
app.state.grading_engine = GradingEngine(
trace_store,
grade_store,
app.state.trace_integrity,
app.state.provider,
model=settings.model,
variant_store=variant_store, # MH#4: anchors + seed (D-029)
)
async def _reaper_loop() -> None:
# Wall-clock timeout + G-2 workdir-size sweep, one pass per tick.
while True:
await asyncio.sleep(REAPER_INTERVAL_S)
try:
await manager.reap_expired()
except Exception:
logger.exception("sandbox reaper pass failed; retrying next tick")
reaper = asyncio.create_task(_reaper_loop())
try:
yield
finally:
reaper.cancel()
with contextlib.suppress(asyncio.CancelledError):
await reaper
# No orphans outlive the process (a-1, shutdown half): destroy
# everything live; workdirs stay on disk for snapshot restore.
await manager.destroy_all()
trace_store.close()
grade_store.close()
variant_store.close()
await app.state.http_client.aclose()
app = FastAPI(title="Nextcraft AI Service", version="0.3.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", "DELETE", "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)
app.include_router(sandboxes_router)
app.include_router(telemetry_router)
app.include_router(variants_router)
return app
app = create_app()