Files
nextcraft/apps/ai-service/ai_service/grading/engine.py
T
CIAgent 1a46606827 feat(P03): rubric scoring engine + calibration (Wave 2)
Task 3-2-01: prompts/grading.py (grader-v1 rubric, 4 criteria x 0-4 anchors, a-4 churn
advisory) + grading/engine.py — GradingEngine with the BINDING G-4 gate-first ordering
(INCOMPLETE_FLOODED -> gaps -> empty; LLM unreachable for gated traces; first-class
UNGRADABLE_* GradeRecords, model="none" provenance), digest-only prompts (D-028; planted
marker proven absent from all provider messages), D-020 reused via one module-direct
import of agents/structured (grep-auditable). RubricScore validated per-criterion.
Task 3-2-02: corpus/trace_fixtures.py (D-021-aligned archetype IDs) + ordering-contract
calibration test (strong>=lazy on process; strong>struggling on correctness; digest
feature separation asserted deterministically).

54 grading tests green; suite 272 green; ruff clean.

---ci---
phase: 3
milestone: v0.3
status: execute
requirements: {covered: [REQ-3-004], partial: []}
---/ci---
2026-09-12 02:17:06 +00:00

269 lines
11 KiB
Python

"""GradingEngine — rubric scoring over real process traces (REQ-3-004).
The Wave-2 composition of the grading stack:
trace completeness gate (G-4, FIRST — nothing is sent to any LLM
when the gate trips) → TraceStore.get_trace → compute_digest (D-028)
→ prompts.grading.render_trace_digest → D-020 4-layer structured
defense (agents/structured.py — REUSED, composed, never duplicated)
→ RubricScore validation → GradeStore persistence → GradeRecord.
Gate verdicts are FIRST-CLASS RESULTS, not exceptions (G-4 is binding):
UNGRADABLE_TRACE_INCOMPLETE — seq gaps in the store OR the trace is
flagged by TraceIntegrityMap (INCOMPLETE_FLOODED). The gap list /
flag reason is surfaced in `scores` for API rendering, and the
record is PERSISTED like any grade so a learner sees why no
credential can be issued for this trace — the gate outcome is
durable and auditable, not a transient error string.
UNGRADABLE_EMPTY_TRACE — no events stored for the pair.
On either verdict `scores.criteria` is empty and the LLM is never called.
DI (D-027/D-032 house pattern): the engine receives trace_store,
grade_store, integrity and provider through the constructor and knows
NOTHING of FastAPI — api/ composes it (Task 3-3-01). `model` is injected
alongside the provider so tests script the mock against the production
wiring without touching Settings.
Boundary (D-027): grading/ imports llm/ (provider protocol + Message
type), telemetry/ (store + integrity map), prompts/ (rubric text) and
ONLY agents.structured — the sanctioned shared D-020 defense. We import
the MODULE directly (`from ..agents.structured import structured_completion`)
rather than the `agents` package, mirroring how agents/base.py consumes
it (same direct-module import): that keeps the dependency surface to
exactly the two names the engine needs (structured_completion,
StructuredOutputError) and avoids executing agents/__init__ re-exports
(BaseAgent, registry, session store) that grading has no business
loading — a side-effect-hygiene choice that keeps this import line
grep-auditable as "the one agents dependency". grading/ never imports api/.
RubricScore placement (documented decision): the validated output model
lives HERE, not in prompts/. The pydantic model is the engine's return
CONTRACT (the shape GradeStore.scores must hold), while prompts/grading.py
is pure prompt text + its mirror schema HINT string — the same split as
agents/assessor.py (model + hint) but with the model owned by the engine
module that validates it. Prompt files hold text, per the prompts/ house
style; engine files hold typed contracts.
"""
import logging
from datetime import UTC, datetime
from typing import Final
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..agents.structured import StructuredOutputError, structured_completion
from ..llm.base import LLMProvider
from ..llm.types import Message
from ..prompts.grading import (
RUBRIC_CRITERIA,
RUBRIC_SCORE_SCHEMA_HINT,
SYSTEM_PROMPT,
render_trace_digest,
)
from ..telemetry.ingest import TraceIntegrityMap
from ..telemetry.store import TraceStore
from .features import compute_digest
from .store import GradeRecord, GradeStore
logger = logging.getLogger(__name__)
#: First-class gate verdicts (G-4). GradeRecord.verdict values; non-empty
#: by store contract. Rubric verdicts (mastered/developing/not_yet) ride in
#: `scores.verdict` — `record.verdict` stays the machine-readable outcome.
VERDICT_UNGRADABLE_INCOMPLETE: Final = "UNGRADABLE_TRACE_INCOMPLETE"
VERDICT_UNGRADABLE_EMPTY: Final = "UNGRADABLE_EMPTY_TRACE"
#: record.verdict for a successfully LLM-graded trace (the rubric verdict
#: travels inside scores); keeps verdict non-empty for every persisted row.
VERDICT_GRADED: Final = "GRADED"
#: Gate-detail keys surfaced in GradeRecord.scores (tests assert on these).
_INTEGRITY_FLAG_KEY: Final = "integrity_flag"
_MISSING_SEQS_KEY: Final = "missing_seqs"
class RubricScore(BaseModel):
"""Validated LLM output: per-criterion 0-4 scores + strengths + gaps + verdict.
The D-020 schema for the grader: `structured_completion` parses the
model reply into THIS shape (layer 3), retrying once with the
validation error fed back (layer 4). Exact criteria set + 0-4 ranges
are enforced here, so the scores dict persisted to GradeStore is
always rubric-shaped no matter what the model produced.
"""
model_config = ConfigDict(extra="forbid")
criteria: dict[str, int]
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"
@field_validator("criteria")
@classmethod
def _criteria_rubric_shaped(cls, value: dict[str, int]) -> dict[str, int]:
"""Exact criteria keys (no extras, no omissions) and 0-4 scores."""
expected = set(RUBRIC_CRITERIA)
got = set(value)
if got != expected:
raise ValueError(
f"criteria keys must be exactly {sorted(expected)}, got {sorted(got)}"
)
for key, score in value.items():
if not 0 <= score <= 4:
raise ValueError(f"criterion {key!r} must be within 0-4, got {score}")
return value
@field_validator("verdict")
@classmethod
def _verdict_known(cls, value: str) -> str:
allowed = {"mastered", "developing", "not_yet"}
if value not in allowed:
raise ValueError(f"verdict must be one of {sorted(allowed)}, got {value!r}")
return value
class GradingEngine:
"""Scores a (learner_id, task_id) trace into a persisted GradeRecord."""
def __init__(
self,
trace_store: TraceStore,
grade_store: GradeStore,
integrity: TraceIntegrityMap,
provider: LLMProvider,
*,
model: str = "gemma4:31b",
) -> None:
self._trace_store = trace_store
self._grade_store = grade_store
self._integrity = integrity
self._provider = provider
self._model = model
async def grade(self, learner_id: str, task_id: str) -> GradeRecord:
"""Grade one trace; persist latest-state (GradeStore upserts); return it.
Gate FIRST (G-4): the LLM is only ever reached from the fully
guarded path — no gate state can be masked by an LLM error.
"""
record = await self._grade(learner_id, task_id)
self._grade_store.save(record)
return record
# ------------------------------------------------------------------ core
async def _grade(self, learner_id: str, task_id: str) -> GradeRecord:
# -- G-4 gate FIRST: integrity flag OR seq gaps. Ordering matters:
# gaps() returns [] for an EMPTY trace, so the empty check below is
# reachable only when no rows exist at all; a gapped or flooded
# trace can never fall through to the LLM path.
if self._integrity.is_incomplete(learner_id, task_id):
reason = self._integrity.reason(learner_id, task_id) or "unknown"
gaps = self._trace_store.gaps(learner_id, task_id)
logger.info(
"grade gate (G-4): %s/%s integrity-flagged (%s) — ungradable",
learner_id,
task_id,
reason,
)
return self._ungradable(
learner_id,
task_id,
detail={_INTEGRITY_FLAG_KEY: reason, _MISSING_SEQS_KEY: gaps},
verdict=VERDICT_UNGRADABLE_INCOMPLETE,
)
gaps = self._trace_store.gaps(learner_id, task_id)
if gaps:
logger.info(
"grade gate (G-4): %s/%s seq gaps %s — ungradable",
learner_id,
task_id,
gaps,
)
return self._ungradable(
learner_id,
task_id,
detail={_INTEGRITY_FLAG_KEY: None, _MISSING_SEQS_KEY: gaps},
verdict=VERDICT_UNGRADABLE_INCOMPLETE,
)
trace = self._trace_store.get_trace(learner_id, task_id)
if not trace:
logger.info(
"grade gate: %s/%s empty trace — ungradable", learner_id, task_id
)
return self._ungradable(
learner_id,
task_id,
detail={_INTEGRITY_FLAG_KEY: None, _MISSING_SEQS_KEY: []},
verdict=VERDICT_UNGRADABLE_EMPTY,
)
# -- Guarded path: digest (D-028) → prompt → D-020 4-layer defense.
digest = compute_digest(trace)
messages = [
Message(role="system", content=SYSTEM_PROMPT),
Message(role="user", content=render_trace_digest(digest)),
]
try:
rubric = await structured_completion(
self._provider,
messages,
model=self._model,
schema=RubricScore,
schema_hint=RUBRIC_SCORE_SCHEMA_HINT,
)
except StructuredOutputError as exc:
# The trace was gradable but the model failed to produce valid
# JSON within the D-020 budget (two attempts). Raise — the API
# layer maps this to a 502 (assessor precedent). Persisting a
# fabricated or partial grade here would violate the no-silent-
# fallback rule: no credential-worthy record without a validated
# RubricScore.
raise StructuredOutputError(f"grading LLM failed for {task_id}: {exc}") from exc
logger.debug(
"graded %s/%s: %s (model=%s)",
learner_id,
task_id,
rubric.verdict,
self._model,
)
return GradeRecord(
learner_id=learner_id,
task_id=task_id,
variant_seed=None, # null until P4 (D-029)
digest=digest.model_dump(),
scores=rubric.model_dump(),
verdict=VERDICT_GRADED,
model=self._model,
created_at=datetime.now(tz=UTC),
)
# ------------------------------------------------------------ gate record
@staticmethod
def _ungradable(
learner_id: str,
task_id: str,
*,
detail: dict,
verdict: str,
) -> GradeRecord:
"""Build a gate record: no digest (nothing was graded), gate detail
surfaced in `scores` (the store allows an empty scores dict, but
G-4 requires the gap list / flag reason surfaced — the detail IS the
verdict's payload), model="none" (no LLM was involved; provenance
stays honest).
"""
return GradeRecord(
learner_id=learner_id,
task_id=task_id,
variant_seed=None,
digest={},
scores=detail,
verdict=verdict,
model="none",
created_at=datetime.now(tz=UTC),
)