mathpulse-api-v3test / tests /test_jev_client.py
github-actions[bot]
deploy: backend from 0338469
d66d64c
Raw History Blame Contribute Delete
12.6 kB
from __future__ import annotations
import asyncio
import json
import logging
import threading
import time
from typing import Any, Awaitable, Callable, Mapping
import anthropic
import httpx2
import pytest
from services import jev_client
from services.ai_client import CHAT_MODEL, AIRefusalError
REQUEST = httpx2.Request("POST", "https://api.anthropic.com/v1/messages")
INTENT_CATEGORIES = (
"direct_answer_request",
"conceptual_confusion",
"calculation_error",
"off_topic_or_frustrated",
)
JudgmentCall = Callable[[], Awaitable[Any]]
FALLBACK_CASES = [
pytest.param(
lambda: jev_client.route_student_intent("I am stuck"),
{"action": "fallback_disabled", "choice": "conceptual_confusion"},
id="intent",
),
pytest.param(
lambda: jev_client.verify_lesson_factuality("x", "y"),
{"action": "fallback_disabled", "verified": True},
id="factuality",
),
pytest.param(
lambda: jev_client.score_student_mastery([], "algebra"),
{"action": "fallback_disabled", "pCorrect": 1.0, "level": 0},
id="mastery",
),
pytest.param(
lambda: jev_client.verify_solution_safety("x", "y"),
{"action": "fallback_disabled", "pLeak": 0.0, "safe": True},
id="safety",
),
]
# A reply every judgment maps to a success shape, so a fallback can only come from the failure under test.
REPLY_FOR_ANY_JUDGMENT = {
"direct_answer_request": 0.9,
"conceptual_confusion": 0.05,
"calculation_error": 0.03,
"off_topic_or_frustrated": 0.02,
"supported_probability": 0.9,
"level": 1,
"confidence": 0.9,
"leak_probability": 0.9,
}
CLAUDE_FAILURES = [
pytest.param(TimeoutError("network timeout"), id="timeout"),
pytest.param(anthropic.APITimeoutError(request=REQUEST), id="api-timeout"),
pytest.param(anthropic.APIConnectionError(request=REQUEST), id="connection"),
pytest.param(
anthropic.AuthenticationError(
"invalid x-api-key", response=httpx2.Response(401, request=REQUEST), body=None
),
id="authentication",
),
pytest.param(AIRefusalError("Claude declined the request"), id="refusal"),
pytest.param(json.JSONDecodeError("Expecting value", "", 0), id="malformed-json"),
pytest.param(RuntimeError("unexpected"), id="unexpected"),
]
class FakeClaude:
"""Stands in for services.ai_client.complete_json where jev_client looks it up."""
def __init__(self) -> None:
self.reply: Any = None
self.error: Exception | None = None
self.delay_seconds = 0.0
self.calls: list[dict[str, Any]] = []
def __call__(self, messages: list[dict[str, str]], **options: Any) -> Any:
self.calls.append({"messages": messages, "thread": threading.get_ident(), **options})
time.sleep(self.delay_seconds)
if self.error is not None:
raise self.error
return self.reply
@pytest.fixture
def claude(monkeypatch: pytest.MonkeyPatch) -> FakeClaude:
fake = FakeClaude()
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
monkeypatch.setattr(jev_client, "is_enabled", lambda: True)
monkeypatch.setattr(jev_client, "complete_json", fake)
return fake
def assert_strict_schema(schema: Mapping[str, Any]) -> None:
"""Structured outputs reject open objects and numeric range keywords."""
assert schema["type"] == "object"
assert schema["additionalProperties"] is False
assert schema["required"] == list(schema["properties"])
assert '"minimum"' not in json.dumps(schema)
assert '"maximum"' not in json.dumps(schema)
def assert_one_judgment_request(claude: FakeClaude, *, schema_keys: list[str], user_text: list[str]) -> None:
(request,) = claude.calls
system, user = request["messages"]
assert (system["role"], user["role"]) == ("system", "user")
for text in user_text:
assert text in user["content"]
assert list(request["schema"]["properties"]) == schema_keys
assert_strict_schema(request["schema"])
assert request["model"] == CHAT_MODEL
assert request["timeout"] == jev_client.CLIENT_TIMEOUT_SECONDS
assert request["thread"] != threading.get_ident()
@pytest.mark.asyncio
@pytest.mark.parametrize(("judge", "fallback"), FALLBACK_CASES)
async def test_missing_key_uses_deterministic_fallbacks(
judge: JudgmentCall, fallback: dict[str, Any], claude: FakeClaude, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
assert await judge() == fallback
assert claude.calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize(("judge", "fallback"), FALLBACK_CASES)
async def test_blank_key_is_treated_as_missing(
judge: JudgmentCall, fallback: dict[str, Any], claude: FakeClaude, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("ANTHROPIC_API_KEY", " ")
assert await judge() == fallback
assert claude.calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize(("judge", "fallback"), FALLBACK_CASES)
async def test_ai_kill_switch_uses_fallbacks(
judge: JudgmentCall, fallback: dict[str, Any], claude: FakeClaude, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(jev_client, "is_enabled", lambda: False)
assert await judge() == fallback
assert claude.calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize("error", CLAUDE_FAILURES)
@pytest.mark.parametrize(("judge", "fallback"), FALLBACK_CASES)
async def test_claude_failure_returns_fallback_and_warns(
error: Exception,
judge: JudgmentCall,
fallback: dict[str, Any],
claude: FakeClaude,
caplog: pytest.LogCaptureFixture,
) -> None:
claude.error = error
caplog.set_level(logging.WARNING)
assert await judge() == fallback
assert "Claude judgment failed" in caplog.text or "Claude judgment timed out" in caplog.text
@pytest.mark.asyncio
@pytest.mark.parametrize(("judge", "fallback"), FALLBACK_CASES)
async def test_slow_claude_call_falls_back_when_the_timeout_fires(
judge: JudgmentCall,
fallback: dict[str, Any],
claude: FakeClaude,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
monkeypatch.setattr(jev_client, "CLIENT_TIMEOUT_SECONDS", 0.02)
claude.reply = REPLY_FOR_ANY_JUDGMENT
claude.delay_seconds = 0.3
caplog.set_level(logging.WARNING)
assert await judge() == fallback
assert "Claude judgment timed out" in caplog.text
@pytest.mark.asyncio
@pytest.mark.parametrize(("judge", "fallback"), FALLBACK_CASES)
async def test_waiting_for_a_request_slot_counts_against_the_deadline(
judge: JudgmentCall,
fallback: dict[str, Any],
claude: FakeClaude,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
# The wait (0.2 s) and the call (0.2 s) each fit in the 0.3 s deadline on their own; together they do not.
monkeypatch.setattr(jev_client, "CLIENT_TIMEOUT_SECONDS", 0.3)
slots = asyncio.Semaphore(1)
monkeypatch.setattr(jev_client, "_REQUEST_LIMIT", slots)
claude.reply = REPLY_FOR_ANY_JUDGMENT
claude.delay_seconds = 0.2
caplog.set_level(logging.WARNING)
await slots.acquire()
asyncio.get_running_loop().call_later(0.2, slots.release)
assert await judge() == fallback
assert "Claude judgment timed out" in caplog.text
@pytest.mark.asyncio
async def test_saturated_request_limit_falls_back_at_the_deadline_without_calling_claude(
claude: FakeClaude, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
monkeypatch.setattr(jev_client, "CLIENT_TIMEOUT_SECONDS", 0.05)
full = asyncio.Semaphore(1)
monkeypatch.setattr(jev_client, "_REQUEST_LIMIT", full)
await full.acquire()
caplog.set_level(logging.WARNING)
# The outer bound only turns an unbounded queue wait into a test failure instead of a hang.
verdict = await asyncio.wait_for(jev_client.route_student_intent("I am stuck"), timeout=2)
assert verdict == {"action": "fallback_disabled", "choice": "conceptual_confusion"}
assert claude.calls == []
assert "Claude judgment timed out" in caplog.text
@pytest.mark.asyncio
async def test_intent_is_the_most_probable_category(claude: FakeClaude) -> None:
claude.reply = {
"direct_answer_request": 0.93,
"conceptual_confusion": 0.04,
"calculation_error": 0.02,
"off_topic_or_frustrated": 0.01,
}
verdict = await jev_client.route_student_intent("Give me the answer")
assert verdict["choice"] == "direct_answer_request"
assert verdict["confidence"] == pytest.approx(0.93)
assert verdict["probabilities"] == pytest.approx(claude.reply)
assert_one_judgment_request(claude, schema_keys=list(INTENT_CATEGORIES), user_text=["Give me the answer"])
@pytest.mark.asyncio
async def test_intent_probabilities_are_clamped_and_normalized(claude: FakeClaude) -> None:
claude.reply = {
"direct_answer_request": -0.4,
"conceptual_confusion": 0.3,
"calculation_error": 0.1,
"off_topic_or_frustrated": 0.0,
}
verdict = await jev_client.route_student_intent("What is a limit?")
assert verdict["choice"] == "conceptual_confusion"
assert verdict["confidence"] == pytest.approx(0.75)
assert verdict["probabilities"] == pytest.approx(
{
"direct_answer_request": 0.0,
"conceptual_confusion": 0.75,
"calculation_error": 0.25,
"off_topic_or_frustrated": 0.0,
}
)
assert sum(verdict["probabilities"].values()) == pytest.approx(1.0)
@pytest.mark.asyncio
async def test_intent_without_a_usable_probability_falls_back(claude: FakeClaude) -> None:
claude.reply = {category: 0.0 for category in INTENT_CATEGORIES}
assert await jev_client.route_student_intent("hello") == {
"action": "fallback_disabled",
"choice": "conceptual_confusion",
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("supported", "verified", "confidence"),
[
(0.2, False, 0.6),
(0.9, True, 0.8),
(0.5, True, 0.0),
(1.4, True, 1.0),
(-0.3, False, 1.0),
],
)
async def test_factuality_verifies_a_lesson_supported_at_even_odds_or_better(
supported: float, verified: bool, confidence: float, claude: FakeClaude
) -> None:
claude.reply = {"supported_probability": supported}
assert await jev_client.verify_lesson_factuality("reference text", "generated lesson") == {
"verified": verified,
"confidence": pytest.approx(confidence),
}
assert_one_judgment_request(
claude, schema_keys=["supported_probability"], user_text=["reference text", "generated lesson"]
)
@pytest.mark.asyncio
async def test_mastery_returns_level_and_confidence(claude: FakeClaude) -> None:
claude.reply = {"level": 2, "confidence": 0.8}
answers = [{"topic_id": "algebra", "is_correct": True}]
assert await jev_client.score_student_mastery(answers, "algebra") == {"level": 2, "pCorrect": 0.8}
assert_one_judgment_request(
claude, schema_keys=["level", "confidence"], user_text=["algebra", json.dumps(answers)]
)
assert claude.calls[0]["schema"]["properties"]["level"]["enum"] == [0, 1, 2, 3]
@pytest.mark.asyncio
async def test_mastery_confidence_is_clamped(claude: FakeClaude) -> None:
claude.reply = {"level": 3, "confidence": 1.5}
assert await jev_client.score_student_mastery([], "algebra") == {"level": 3, "pCorrect": 1.0}
@pytest.mark.asyncio
@pytest.mark.parametrize("level", [4, -1])
async def test_mastery_with_an_unknown_level_falls_back(level: int, claude: FakeClaude) -> None:
claude.reply = {"level": level, "confidence": 0.9}
assert await jev_client.score_student_mastery([], "algebra") == {
"action": "fallback_disabled",
"pCorrect": 1.0,
"level": 0,
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("leak", "p_leak", "safe"),
[
(0.9, 0.9, False),
(0.5, 0.5, False),
(0.2, 0.2, True),
(0.0, 0.0, True),
(1.2, 1.0, False),
(-0.1, 0.0, True),
],
)
async def test_solution_safety_is_safe_while_a_leak_is_less_likely_than_not(
leak: float, p_leak: float, safe: bool, claude: FakeClaude
) -> None:
claude.reply = {"leak_probability": leak}
assert await jev_client.verify_solution_safety("x = 4", "Try isolating x first") == {
"pLeak": p_leak,
"safe": safe,
}
assert_one_judgment_request(
claude, schema_keys=["leak_probability"], user_text=["x = 4", "Try isolating x first"]
)