Spaces:
Sleeping
Sleeping
| """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" | |