4acffac71e
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---
188 lines
7.1 KiB
Python
188 lines
7.1 KiB
Python
"""/v1/variants — seeded per-learner task variant endpoints (REQ-3-005, D-029).
|
|
|
|
Three faces over the variant engine (the generator + store stay FastAPI-free;
|
|
this module owns all HTTP wiring — the D-027/D-032 house pattern):
|
|
|
|
POST /v1/variants {learner_id, template_id | competency_id}
|
|
→ 200 the learner's variant — GENERATED on the first request,
|
|
CACHED (store read, zero LLM calls) on every repeat: D-029
|
|
reproducibility means one (learner_id, template_id) is ONE
|
|
variant forever, so a regenerate is always a 200 of the SAME
|
|
variant, never a second render.
|
|
→ 404 unknown template_id, or competency_id with no bound template.
|
|
→ 422 neither template_id nor competency_id given.
|
|
GET /v1/variants/{task_id}
|
|
→ 200 the stored variant owning the task key (the grading and
|
|
telemetry join path); 404 when no variant was ever generated
|
|
for the task.
|
|
GET /v1/variants?learner_id=...
|
|
→ 200 the learner's variants, chronological; [] when none.
|
|
|
|
Template resolution: an explicit `template_id` wins; without it the FIRST
|
|
template bound to `competency_id` is used (`template_for_competency`,
|
|
D-021 corpus alignment). The response carries `competency_id` resolved
|
|
from the template library at read time — an enrichment, not a persisted
|
|
column (the seed re-derives the whole variant, D-029) — so the learner
|
|
surface can bind a variant to its competency without a library round-trip.
|
|
|
|
Distinctness (REQ-3-005): different learners on the same template draw
|
|
different seeded params and receive distinct statements and task_ids;
|
|
tests/api/test_variants.py asserts this end-to-end through the API.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from typing import Self
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel, Field, model_validator
|
|
|
|
from ..variants.generator import VariantGenerator
|
|
from ..variants.store import VariantRecord, VariantStore
|
|
from ..variants.templates import TaskTemplate, get_template, template_for_competency
|
|
from .deps import get_variant_generator, get_variant_store
|
|
|
|
router = APIRouter(prefix="/v1/variants", tags=["variants"])
|
|
|
|
|
|
# -- contracts ------------------------------------------------------------------
|
|
|
|
|
|
class VariantGenerateRequest(BaseModel):
|
|
"""One variant identity: an explicit `template_id`, or the first
|
|
template bound to a `competency_id` (D-021). `template_id` wins when
|
|
both are given (explicit identity beats derived); at least one is
|
|
required — 422 otherwise.
|
|
"""
|
|
|
|
learner_id: str = Field(min_length=1)
|
|
template_id: str | None = None
|
|
competency_id: str | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def _require_template_or_competency(self) -> Self:
|
|
if self.template_id is None and self.competency_id is None:
|
|
raise ValueError("template_id or competency_id is required")
|
|
return self
|
|
|
|
|
|
class VariantResponse(BaseModel):
|
|
"""VariantRecord over HTTP, plus the `competency_id` enrichment.
|
|
|
|
Every field except `competency_id` mirrors `VariantRecord` exactly
|
|
(snake_case; `created_at` is an ISO 8601 UTC datetime) — the wire shape
|
|
typed as `TaskVariant` in packages/types/variants.ts.
|
|
"""
|
|
|
|
learner_id: str
|
|
task_id: str
|
|
template_id: str
|
|
competency_id: str
|
|
seed: str
|
|
params: dict[str, str | int]
|
|
statement: str
|
|
starter_files: dict[str, str]
|
|
created_at: datetime
|
|
|
|
|
|
class VariantListResponse(BaseModel):
|
|
variants: list[VariantResponse]
|
|
|
|
|
|
# -- resolution + rendering -----------------------------------------------------
|
|
|
|
|
|
def _resolve_template(body: VariantGenerateRequest) -> TaskTemplate:
|
|
"""Template for the request: the explicit id, else the first template
|
|
bound to the competency; 404 when neither resolves."""
|
|
if body.template_id is not None:
|
|
template = get_template(body.template_id)
|
|
if template is None:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"no task template with id {body.template_id!r}",
|
|
)
|
|
return template
|
|
# The request validator guarantees the disjunction, so reaching here
|
|
# means a competency_id was given (never None).
|
|
assert body.competency_id is not None
|
|
templates = template_for_competency(body.competency_id)
|
|
if not templates:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"no task template for competency {body.competency_id!r}",
|
|
)
|
|
return templates[0]
|
|
|
|
|
|
def _competency_for(template_id: str) -> str:
|
|
"""competency_id enrichment for stored records (read paths)."""
|
|
template = get_template(template_id)
|
|
if template is None:
|
|
# Integrity guard: a stored variant referencing a template that is
|
|
# no longer in the library cannot be enriched; fail loudly rather
|
|
# than fabricate a competency binding.
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"stored variant references unknown template {template_id!r}",
|
|
)
|
|
return template.competency_id
|
|
|
|
|
|
def _to_response(record: VariantRecord, competency_id: str) -> VariantResponse:
|
|
return VariantResponse(
|
|
learner_id=record.learner_id,
|
|
task_id=record.task_id,
|
|
template_id=record.template_id,
|
|
competency_id=competency_id,
|
|
seed=record.seed,
|
|
params=dict(record.params),
|
|
statement=record.statement,
|
|
starter_files=dict(record.starter_files),
|
|
created_at=record.created_at,
|
|
)
|
|
|
|
|
|
# -- endpoints ------------------------------------------------------------------
|
|
|
|
|
|
@router.post("", response_model=VariantResponse)
|
|
async def generate_variant(
|
|
body: VariantGenerateRequest,
|
|
generator: VariantGenerator = Depends(get_variant_generator),
|
|
) -> VariantResponse:
|
|
"""The learner's variant for the resolved template — generated on the
|
|
first request, cached (no LLM call) on every repeat: D-029 makes a
|
|
regenerate a 200 of the SAME stored variant.
|
|
"""
|
|
template = _resolve_template(body)
|
|
record = await generator.generate(body.learner_id, template.id)
|
|
return _to_response(record, competency_id=template.competency_id)
|
|
|
|
|
|
@router.get("", response_model=VariantListResponse)
|
|
async def list_variants(
|
|
learner_id: str,
|
|
store: VariantStore = Depends(get_variant_store),
|
|
) -> VariantListResponse:
|
|
"""All stored variants for the learner, chronological; [] when none."""
|
|
variants = [
|
|
_to_response(record, competency_id=_competency_for(record.template_id))
|
|
for record in store.list_for_learner(learner_id)
|
|
]
|
|
return VariantListResponse(variants=variants)
|
|
|
|
|
|
@router.get("/{task_id}", response_model=VariantResponse)
|
|
async def get_variant(
|
|
task_id: str,
|
|
store: VariantStore = Depends(get_variant_store),
|
|
) -> VariantResponse:
|
|
"""The stored variant owning the task key — the grading and telemetry
|
|
join path; 404 when no variant was ever generated for the task."""
|
|
record = store.get_by_task(task_id)
|
|
if record is None:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"no stored variant for task {task_id!r}"
|
|
)
|
|
return _to_response(record, competency_id=_competency_for(record.template_id))
|