Spaces:
Sleeping
Sleeping
File size: 5,723 Bytes
2e818da | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | """Tests for VisualModalityRouter -> verifies routing rules and prompt grounding.
The router is only ever reached from an explicit student visualize request (the
old Wiki click-to-offer path was removed), so "decline" is not a valid outcome:
the router always returns one of the 5 real modalities.
"""
from unittest.mock import MagicMock
from app.agents.modality_router import VisualModalityRouter
def _make_router_with_response(modality: str) -> tuple:
"""Return (router, captured_messages, captured_kwargs) where the client returns a canned decision."""
router = VisualModalityRouter.__new__(VisualModalityRouter)
mock_client = MagicMock()
router._client = mock_client
from app.agents.modality_router import ModalityDecision
decision = ModalityDecision(modality=modality, reasoning="test reasoning")
captured = {}
def fake_structured_complete(messages, model_cls, **kwargs):
captured["messages"] = messages
captured["kwargs"] = kwargs
return decision
mock_client.structured_complete.side_effect = fake_structured_complete
return router, captured
CHUNKS_WITH_DATA = [
{"source": "notes.pdf", "text": "Revenue in 2020: $120M, 2021: $145M, 2022: $178M"},
{"source": "notes.pdf", "text": "Growth rate was 20.8% year over year."},
]
CHUNKS_WITHOUT_DATA = [
{"source": "notes.pdf", "text": "Entropy is a measure of disorder in a thermodynamic system."},
{"source": "notes.pdf", "text": "The second law states entropy increases in isolated systems."},
]
def test_classify_returns_modality_decision():
router, _ = _make_router_with_response("graph")
result = router.classify("revenue trends", "## Revenue\n...", CHUNKS_WITH_DATA, "high_school")
assert result.modality == "graph"
def test_classify_uses_reasoning_effort():
"""This engine writes actual chart-shape decisions -- reasoning should be enabled."""
router, captured = _make_router_with_response("graph")
router.classify("revenue trends", "## Revenue\n...", CHUNKS_WITH_DATA, "high_school")
assert captured["kwargs"].get("reasoning_effort") == "medium"
def test_prompt_contains_chunk_text():
"""The router must embed source material in the prompt so decisions are grounded."""
router, captured = _make_router_with_response("graph")
router.classify("revenue", "card text", CHUNKS_WITH_DATA, "graduate")
user_message = captured["messages"][1]["content"]
assert "Revenue in 2020" in user_message, "chunk text must appear in the user message"
assert "notes.pdf" not in user_message # chunk source key is not embedded, just the text
def test_prompt_contains_selection_and_level():
router, captured = _make_router_with_response("2d_text")
router.classify("entropy", "card", CHUNKS_WITHOUT_DATA, "eli5")
user_message = captured["messages"][1]["content"]
assert "entropy" in user_message
assert "eli5" in user_message
def test_prompt_caps_chunk_text():
"""Chunks larger than 3000 chars must be truncated."""
big_chunk = [{"source": "big.pdf", "text": "x" * 5000}]
router, captured = _make_router_with_response("2d_text")
router.classify("concept", "card", big_chunk, "high_school")
user_message = captured["messages"][1]["content"]
# The chunk text is capped at 3000 chars inside classify()
assert len(user_message) < 5000 + 500 # well under 5000 raw chunk length
def test_2d_anim_for_dynamic_concept():
router, _ = _make_router_with_response("2d_anim")
result = router.classify("pendulum motion", "card", CHUNKS_WITHOUT_DATA, "high_school")
assert result.modality == "2d_anim"
def test_graph_for_chart_request_even_with_unrelated_chunks():
"""Regression test: an explicit 'bar chart of X' request must route to graph based on
the student's own wording, not get remapped to some fixed default because the
retrieved chunks are unrelated to X. (Confirmed real bug: a hardcoded decline->2d_anim
remap previously sent every request, regardless of type, to 2d_anim.)"""
router, _ = _make_router_with_response("graph")
result = router.classify(
"bar chart between population of india in 1990 and in 2010",
"card",
CHUNKS_WITHOUT_DATA, # deliberately unrelated to the request
"high_school",
)
assert result.modality == "graph"
def test_only_five_modalities_are_valid_literal_values():
"""`decline` must not be constructible -- this router never offers it as an outcome."""
from app.agents.modality_router import ModalityDecision
for modality in ["formula", "graph", "2d_text", "3d", "2d_anim"]:
decision = ModalityDecision(modality=modality, reasoning="r")
assert decision.modality == modality
import pytest
from pydantic import ValidationError
with pytest.raises(ValidationError):
ModalityDecision(modality="decline", reasoning="r")
def test_decline_reason_field_removed():
"""decline_reason was part of the old decline outcome; it must not linger as a field."""
from app.agents.modality_router import ModalityDecision
assert "decline_reason" not in ModalityDecision.model_fields
def test_prompt_never_offers_decline():
"""The system prompt must explicitly rule out declining -- the student already asked
for a visual, so the router always picks one of the 5 real shapes."""
from app.agents.modality_router import _SYSTEM_PROMPT
prompt_lower = _SYSTEM_PROMPT.lower()
assert "not an option" in prompt_lower or "always pick exactly one" in prompt_lower
for modality in ["formula", "graph", "2d_text", "3d", "2d_anim"]:
assert modality in prompt_lower, f"modality {modality!r} missing from system prompt"
|