Files
nextcraft/apps/ai-service/ai_service/prompts/grading.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

129 lines
6.0 KiB
Python

"""Grading rubric prompt — criteria, level anchors, digest render (REQ-3-004).
The grading prompt is deliberately learner-anonymous and trace-bare: the
model receives ONLY the fixed rubric text and the compact numeric digest
(TraceDigest JSON, D-028) — never a raw command, file path, payload
string, learner id, or task id. Everything variable the LLM sees is
deterministic counters, which both bounds the prompt-injection surface
and makes "no raw trace reaches the prompt" assert-able in tests (plant
a distinctive marker in a command payload; assert it absent from every
message the provider received).
Rubric (four criteria, each scored 0-4 — the ids are the validated
RubricScore keys enforced by grading/engine.py):
process_quality — iterative building in small, verified steps.
correctness — where the session ended (test/run outcomes).
debugging_discipline — how failures were handled.
test_usage — when and how often tests were run.
Advisory a-4 (embedded in the process_quality anchors): high edit/command
churn with NO test progress is a process-quality NEGATIVE — churn is not
work. A session with many edits/commands whose test state never moves is
thrashing, not iterating, and must score low on process quality.
House-style deviation, documented: unlike the tutor prompts, this module
has no SYSTEM_PROMPT placeholders and no render_context(learner_context)
— grading is context-free by design (learner anonymity; the digest is the
only variable input). Runtime imports are TYPE_CHECKING-only so this
module stays pure text and can never import-cycle with grading/engine.py
(engine imports this module; if this module imported grading.* at runtime
while grading/__init__ pulls engine, the package init would deadlock on a
partially-initialized module).
Version: grader-v1.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Final
if TYPE_CHECKING: # pragma: no cover - typing only; keeps this module pure text
from ..grading.features import TraceDigest
PROMPT_VERSION = "grader-v1"
#: Canonical criterion ids. The engine validates RubricScore criteria keys
#: against this tuple; the schema hint and anchors below speak the same ids.
RUBRIC_CRITERIA: Final[tuple[str, ...]] = (
"process_quality",
"correctness",
"debugging_discipline",
"test_usage",
)
#: Sentinel line the engine's user turn is rendered around. Tests (and the
#: calibration mock) split on it to locate the digest JSON in the prompt.
DIGEST_MARKER: Final = "PROCESS TRACE DIGEST (JSON):"
SYSTEM_PROMPT = """You are the Grader of Nextcraft, an AI-native competency school.
You score a learner's build session from a compact numeric digest of their
process trace. You NEVER see the raw trace — commands, file contents, and
payloads do not exist on your side; every number you need is in the digest.
Rubric — score each criterion 0-4:
process_quality — iterative building in small, verified steps.
4: tight edit→test loops throughout; small verified increments; healthy pacing.
3: steady small edits with regular runs; progress mostly verified.
2: some iteration, but large unverified leaps or long idle stretches.
1: a single bulk change (e.g. one large paste) then a single run; no iteration.
0: no meaningful work visible.
ADVISORY: high edit/command churn with NO test progress (no runs, no
movement in pass counts) is a process-quality NEGATIVE — churn is not
work. Cap such a session at 1 on this criterion no matter how many
edits or commands were counted.
correctness — where the session ended up.
4: final test status pass, with tests passing early and consistently.
3: final pass, reached through fail→fix→pass cycles that closed.
2: final pass, but preceded by a long unresolved failure streak.
1: final fail, but partial passes observed along the way.
0: final fail, or no test/run evidence at all.
debugging_discipline — how failures were handled.
4: every failure cycle closes; targeted fixes with low mean fix latency.
3: most fail→edit→re-run cycles close with a pass.
2: failures followed by edits, but cycles rarely close.
1: repeated failures with no targeted edits between runs (flailing).
0: failures with no fix attempts at all.
test_usage — when and how often tests were run.
4: tests run early (small first-pass offset) and throughout the session.
3: regular test runs interleaved with edits.
2: sparse tests; long stretches of unverified edits.
1: a single late test run only.
0: no test or run evidence.
Rules:
- Judge STRICTLY from the digest numbers; cite the fields you used.
- Strengths: the two strongest digest observations, one sentence each.
- Gaps: the two most important missed opportunities, one sentence each
(a clean session names its next-level improvement instead).
- Be rigorous but fair: a session that ends green was not necessarily
well built, and a struggling session that never passed may still show
real debugging discipline.
- Respond with ONLY a valid JSON object matching the provided schema —
no markdown fences, no prose outside the JSON."""
RUBRIC_SCORE_SCHEMA_HINT = (
'{"criteria": {"process_quality": <0-4 int>, "correctness": <0-4 int>, '
'"debugging_discipline": <0-4 int>, "test_usage": <0-4 int>}, '
'"strengths": ["<one sentence>"], "gaps": ["<one sentence>"], '
'"verdict": "mastered" | "developing" | "not_yet"}'
)
def render_trace_digest(digest: TraceDigest) -> str:
"""Render the grader's user turn: a marker line + the digest JSON — nothing else.
This is the ONLY per-session content that ever reaches the LLM (D-028):
the engine composes [system: SYSTEM_PROMPT, user: render_trace_digest(digest)]
and the D-020 defense appends its generic schema instruction to this
user turn at request time. No learner id, task id, or raw trace material
is injected — assert-able by tests.
"""
return (
"Score this build session against the rubric.\n"
f"{DIGEST_MARKER}\n{digest.model_dump_json()}"
)