dc00a4f8f5
Wave 5-1 mypy adoption (D-058 relaxed-first): mypy 2.3.1, [tool.mypy] check_untyped_defs + no_implicit_optional + warn_unused_ignores + warn_redundant_casts + warn_unreachable, no overrides (every dep ships py.typed), files = ai_service + scripts/sandbox-agent.py + tests (not excluded). 215 errors → 0: ~140 fixed at source incl. genuine bugs found by the gate (LLMProvider protocol async-generator shape, SessionStore protocol sync/async mismatch, structured_reply genericity, get_args migrations, sandbox-agent Optional guard honesty, proctor test arg-order, dead ExecResult kwarg, 16 sqlmodel col() sites); 34 live suppressions scoped-coded + reasoned. Original 27 type:ignore dispositioned: 12 dead removed, 15 scoped (warn_unused_ignores self-eliminates the rest). Wired: scripts/typecheck.sh + package.json + turbo task + root ai:typecheck — pnpm typecheck now 5 tsc + mypy. Wave 5-2 recharts 2.15.4 → 3.10.1 (D-049/D-059): one breaking class (Tooltip formatter ValueType typing); 2 one-line inference fixes on the 2 chart files; no rollback needed (well inside the G-106 timebox). Deprecated direct dep resolved; uuid@9 gone from the tree. Build 248/248 with chart SSR markers verified in exported employer + dashboard pages. Wave 5-3 deprecated-transitive audit (G-109): 4 lockfile entries remain, all dev-only, none actionable within NFR budget — glob@7.2.3 + inflight + rimraf@3 via Storybook docgen chain (flat-cache@3); glob@10.5.0 via c8→test-exclude (D-052-binding coverage tool — swapping it is out of budget). Python: no EOL packages; zero-warning regime under filterwarnings=error is the machine-checked proof. Counts: 670 py / 44 web / 64 cli, zero warnings, G-104 allowlist still exactly 2. ---ci--- phase: 5 milestone: v0.7 status: execute ---/ci---
91 lines
2.9 KiB
Python
91 lines
2.9 KiB
Python
"""Assessment API tests — stored-grade coaching contract (REQ-3-007)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import Iterator
|
|
from datetime import UTC, datetime
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from ai_service.config import Settings
|
|
from ai_service.grading.store import GradeRecord, SQLiteGradeStore
|
|
from ai_service.llm.mock import MockProvider
|
|
from ai_service.main import create_app
|
|
from ai_service.telemetry.ingest import TraceIntegrityMap
|
|
from ai_service.telemetry.store import SQLiteTraceStore
|
|
|
|
COACHING_JSON = json.dumps(
|
|
{
|
|
"summary": "Good iterative work.",
|
|
"strengths": ["Tests after changes."],
|
|
"gaps": ["Missing edge cases."],
|
|
"next_steps": ["Add an edge-case test."],
|
|
}
|
|
)
|
|
|
|
|
|
class CoachingMock(MockProvider):
|
|
def _reply_for(self, messages, response_format):
|
|
if response_format is not None and response_format.get("type") == "json_object":
|
|
return COACHING_JSON
|
|
return super()._reply_for(messages, response_format)
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(tmp_path) -> Iterator[TestClient]:
|
|
app = create_app(Settings(provider="mock"))
|
|
app.state.provider = CoachingMock()
|
|
app.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
|
|
app.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db")
|
|
app.state.trace_integrity = TraceIntegrityMap()
|
|
with TestClient(app) as c:
|
|
yield c
|
|
|
|
|
|
def _seed_grade(client: TestClient) -> None:
|
|
|
|
# P5: TestClient.app is typed ASGIApp (Callable); at runtime it is the
|
|
# FastAPI instance — this state-injection seam is the suite's pattern.
|
|
app = client.app
|
|
store: SQLiteGradeStore = app.state.grade_store # type: ignore[attr-defined]
|
|
store.save(
|
|
GradeRecord(
|
|
learner_id="api-learner",
|
|
task_id="api-task",
|
|
variant_seed=None,
|
|
digest={"error_fix_cycles": 1},
|
|
scores={"criteria": {"process_quality": 3}, "verdict": "developing"},
|
|
verdict="GRADED",
|
|
model="gemma4:31b",
|
|
created_at=datetime.now(UTC),
|
|
)
|
|
)
|
|
|
|
|
|
def test_evaluate_renders_stored_grade_as_coaching(client) -> None:
|
|
_seed_grade(client)
|
|
resp = client.post(
|
|
"/v1/assessment/evaluate",
|
|
json={"learner_id": "api-learner", "task_id": "api-task"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["grade_verdict"] == "GRADED"
|
|
assert body["coaching"]["summary"]
|
|
|
|
|
|
def test_evaluate_without_grade_404(client) -> None:
|
|
resp = client.post(
|
|
"/v1/assessment/evaluate",
|
|
json={"learner_id": "nobody", "task_id": "nothing"},
|
|
)
|
|
assert resp.status_code == 404
|
|
assert "grade first" in resp.json()["detail"]
|
|
|
|
|
|
def test_missing_fields_422(client) -> None:
|
|
resp = client.post("/v1/assessment/evaluate", json={"learner_id": "x"})
|
|
assert resp.status_code == 422
|