feat(P04): variants API + TS types (Wave 3)

Task 4-3-01: POST /v1/variants (template_id or competency_id resolution; cache-first
D-029; 404 unknown template/competency; 422 neither), GET /v1/variants/{task_id},
GET /v1/variants?learner_id=. SQLiteVariantStore + VariantGenerator wired into lifespan.
Task 4-3-02: packages/types/variants.ts (TaskVariant, VariantParams) + grading.ts
(RubricScore + GradeRecord, criteria-typed) — field-for-field Python parity,
scores typed as the documented rubric-or-gate union.

11 endpoint tests green (distinct learners -> distinct statements at API level; cache
hit -> zero LLM calls); suite 324 green; typecheck 7/7; ruff clean.

---ci---
phase: 4
milestone: v0.3
status: execute
requirements: {covered: [REQ-3-005], partial: []}
---/ci---
This commit is contained in:
CIAgent
2026-09-12 03:21:11 +00:00
parent 82b9de382a
commit 4acffac71e
8 changed files with 639 additions and 1 deletions
+26
View File
@@ -19,6 +19,7 @@ from .api import (
proctor_router,
sandboxes_router,
telemetry_router,
variants_router,
)
from .config import Settings
from .grading.engine import GradingEngine
@@ -27,6 +28,8 @@ 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__)
@@ -94,6 +97,27 @@ def create_app(settings: Settings | None = None) -> FastAPI:
model=settings.model,
)
# Variant generation (REQ-3-005, Wave 3): 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).
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,
)
async def _reaper_loop() -> None:
# Wall-clock timeout + G-2 workdir-size sweep, one pass per tick.
while True:
@@ -115,6 +139,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
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)
@@ -143,6 +168,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.include_router(proctor_router)
app.include_router(sandboxes_router)
app.include_router(telemetry_router)
app.include_router(variants_router)
return app