File size: 4,174 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
"""Visual Modality Router -> decides WHAT kind of visual fits an explicit
student visualize request.

This is now only ever reached from an explicit student action (a /visualize
command or a natural-language "show me a chart of X" request in Chat) --
Wiki's old click-to-offer path was removed entirely. Because the student has
already asked for a visual, "decline" is not offered as an outcome: the
router always picks exactly one of the 5 real modalities. It does NOT verify
the source has ENOUGH data for a specific chart, nor does it pick a concrete
engine/template -- that finer extract-and-verify-and-synthesize work belongs
to each of the 5 downstream engines individually (mirroring the
D3TemplateRouter.select() -> D3DataExtractor.fill() two-stage split in
app/agents/d3/).

Decision rule: classify primarily from what the student's own wording asks
for (an explicit "chart"/"graph" -> graph, "animate"/"simulate" -> 2d_anim,
"3D model" -> 3d, "formula"/"derive" -> formula, otherwise a conceptual point
-> 2d_text), using the source material only as secondary supporting context
-- never as a reason to refuse an outcome. Downstream engines independently
decide whether to ground the result in the source, the web, or a clearly
labeled illustrative synthesis; this router's only job is picking the shape.
"""
from __future__ import annotations

from typing import List, Literal

from pydantic import BaseModel

from app.agents.cerebras_client import CerebrasClient

_MAX_CHUNK_CHARS = 3000  # mirrors BrainAgent.extract_curriculum cap


class ModalityDecision(BaseModel):
    modality: Literal["formula", "graph", "2d_text", "3d", "2d_anim"]
    reasoning: str


_SYSTEM_PROMPT = """\
You are the Visualization Router for a student research assistant. The student
has explicitly asked for a visual -- your only job is picking which of 5 shapes
best fits their request. Declining is not an option; always pick exactly one.

Classify primarily from the STUDENT'S REQUEST wording itself, using the source
material only as secondary context (it may be sparse, unrelated, or absent --
that never changes which modality is the right shape for what they asked for):

- formula: the student is asking for an equation, derivation, or formula.
- graph: the student is asking for a chart, plot, graph, or a comparison/series
  of data (e.g. "bar chart of X", "plot Y over time").
- 2d_text: the student is asking for a definitional or conceptual explanation
  best served by prose plus at most one citation, not a diagram.
- 3d: the student is asking for a spatial/structural object -- a molecule, an
  anatomical structure, a 3D geometric shape or mechanism.
- 2d_anim: the student is asking for a 2D dynamic process, motion, or
  simulation -- a mechanism, a waveform, a state transition, a physical
  process with movement.

If the request itself doesn't name a type explicitly, infer the best fit from
what's actually being asked about (a described mechanism or motion -> 2d_anim,
a described structure -> 3d, a described relationship worth charting -> graph,
otherwise -> 2d_text).

Output a single JSON object matching the schema. One sentence for reasoning.\
"""


class VisualModalityRouter:
    def __init__(self) -> None:
        self._client = CerebrasClient()

    def classify(
        self,
        selection_text: str,
        card_markdown: str,
        chunks: List[dict],
        familiarity: str,
    ) -> ModalityDecision:
        chunk_text = "\n\n".join(c["text"] for c in chunks)
        if len(chunk_text) > _MAX_CHUNK_CHARS:
            chunk_text = chunk_text[:_MAX_CHUNK_CHARS]

        messages = [
            {"role": "system", "content": _SYSTEM_PROMPT},
            {
                "role": "user",
                "content": (
                    f"STUDENT REQUEST: {selection_text}\n"
                    f"STUDENT LEVEL: {familiarity}\n\n"
                    f"SOURCE MATERIAL (may be sparse or unrelated):\n{chunk_text}\n\n"
                    f"WIKI CARD SUMMARY:\n{card_markdown[:800]}"
                ),
            },
        ]
        return self._client.structured_complete(messages, ModalityDecision, reasoning_effort="medium")