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, )