File size: 3,036 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
from __future__ import annotations

from typing import Any, Optional

from app.agents.d3.engine import D3Engine
from app.agents.formula_engine import FormulaEngine
from app.agents.modality_router import ModalityDecision, VisualModalityRouter
from app.agents.shell_visual_engine import ShellVisualEngine
from app.agents.text_ref_engine import TextRefEngine
from app.agents.tutor_agent import TutorAgent
from app.schemas.graph import HTML5VisualPayload


class VisualizationService:
    """Thin dispatch boundary in front of the 5 visual-generation engines.

    Does no chunk/RAG work itself -- it only (1) delegates modality classification
    to `VisualModalityRouter`, and (2) routes an already-decided modality to whichever
    of the 5 engines owns it: formula/graph/2d_text/3d/2d_anim. It never declines on
    its own, even with zero/weak chunks -- each engine independently handles that by
    synthesizing an illustrative result and reporting `HTML5VisualPayload.source`
    accordingly, since the student has already explicitly asked for a visual.
    """

    def __init__(
        self,
        router: Optional[VisualModalityRouter] = None,
        formula: Optional[FormulaEngine] = None,
        text_ref: Optional[TextRefEngine] = None,
        d3: Optional[D3Engine] = None,
        shell: Optional[ShellVisualEngine] = None,
    ) -> None:
        self.router = router or VisualModalityRouter()
        self.formula = formula or FormulaEngine()
        self.text_ref = text_ref or TextRefEngine()
        self.d3 = d3 or D3Engine()
        self.shell = shell or ShellVisualEngine()

    def classify(
        self,
        selection_text: str,
        card_markdown: str,
        chunks: list[dict[str, Any]],
        familiarity: str,
    ) -> ModalityDecision:
        return self.router.classify(selection_text, card_markdown, chunks, familiarity)

    def generate(
        self,
        concept: str,
        modality: str,
        familiarity: str,
        chunks: list[dict[str, Any]] | None = None,
        web_results: list[dict[str, Any]] | None = None,
    ) -> HTML5VisualPayload:
        chunks = chunks or []

        if modality == "formula":
            return self.formula.generate(concept, chunks, familiarity)
        if modality == "2d_text":
            return self.text_ref.generate(concept, chunks, familiarity, web_results)
        if modality == "graph":
            return self.d3.generate(concept, chunks, familiarity)
        if modality in ("3d", "2d_anim"):
            return self.shell.generate(concept, modality, chunks, familiarity)

        # Defensive fallback for a bad/unrecognized modality string -- the router
        # itself never produces one of these, since `ModalityDecision.modality`
        # is a closed Literal of the 5 real values.
        reason = "This one is better discussed in chat."
        return HTML5VisualPayload(
            html_code=TutorAgent._decline_html(concept, reason),
            animation_type="2d_text",
            explanation=reason,
        )