File size: 6,707 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
from __future__ import annotations
import logging
import re
from typing import List, Optional
from pydantic import BaseModel, Field
from app.agents.cerebras_client import CerebrasClient
from app.agents.d3.registry import catalog_for_prompt

logger = logging.getLogger(__name__)


class D3Selection(BaseModel):
    template_id: str = Field(description="Chosen template id from the catalog -- always pick one.")
    reasoning: str = Field("", description="One sentence on why this template fits the request.")
    selected_metric: str = Field("", description="The source metric to visualize, when the request or table identifies one.")
    explicit_family: bool = Field(False, description="True when the user explicitly named the requested visual family.")
    requires_metric_clarification: bool = Field(False, description="True only when multiple source metrics are equally plausible and none was requested.")
    metric_candidates: list[str] = Field(default_factory=list, description="Two to five exact source column labels when clarification is required.")


class D3TemplateSelectionError(ValueError):
    pass


_EXPLICIT_TEMPLATE_TERMS: tuple[tuple[tuple[str, ...], tuple[str, ...]], ...] = (
    (("donut", "doughnut"), ("donut_chart",)),
    (("pie chart", "pie graph", " pie "), ("pie_chart",)),
    (("grouped bar", "clustered bar"), ("grouped_bar",)),
    (("stacked bar",), ("stacked_bar",)),
    (("bar chart", "bar graph"), ("bar_chart", "grouped_bar", "stacked_bar")),
    (("multi-line", "multiple line", "multi line"), ("multi_line",)),
    (("line chart", "line graph"), ("line", "multi_line")),
    (("scatter",), ("scatter",)),
    (("histogram",), ("histogram",)),
)


def _explicit_allowed_templates(concept: str) -> tuple[str, ...]:
    padded = f" {concept.lower()} "
    for terms, template_ids in _EXPLICIT_TEMPLATE_TERMS:
        if any(term in padded for term in terms):
            return template_ids
    # A bare mention such as "simplify this table" describes the selected
    # input, not the desired output. Lock only imperative/output phrasing.
    if re.search(
        r"\b(?:as|into)\s+(?:a\s+)?(?:compact\s+)?table\b|"
        r"\b(?:create|make|render|show)\s+(?:me\s+)?(?:a\s+)?(?:compact\s+)?table\b",
        concept.lower(),
    ):
        return ("data_table",)
    return ()


class D3TemplateRouter:
    def __init__(self, client: Optional[CerebrasClient] = None) -> None:
        self._client = client or CerebrasClient()

    def select(self, concept: str, chunks: List[dict], familiarity: str) -> D3Selection:
        chunk_text = "\n\n".join(f"[{c.get('source','?')}]: {c['text']}" for c in chunks)[:3000]
        messages = [
            {"role": "system", "content": (
                "You choose the single best D3 chart template for a student's chart request. The "
                "student has already asked for a chart -- always pick exactly one template id from "
                "the CATALOG, never decline. Prefer what the student's own wording asks for (e.g. "
                "'bar chart' -> a bar template, 'over time' -> a line template, a relationship/network "
                "-> a network template). Use the source material as supplementary context for the data "
                "itself, but it is never a reason to refuse a pick -- if the source doesn't contain the "
                "data this chart needs, that is handled by a later synthesis step, not by you.\n\n"
                f"CATALOG:\n{catalog_for_prompt()}\n\nSOURCE MATERIAL:\n{chunk_text}"
            )},
            {"role": "user", "content": f"Concept: '{concept}' (level: {familiarity}). Choose the best-fitting template."},
        ]
        sel = self._client.structured_complete(messages, D3Selection, reasoning_effort="medium")
        from app.agents.d3.registry import TEMPLATES
        if sel.template_id not in TEMPLATES:
            # Root logger defaults to WARNING (no logging.basicConfig() anywhere in the
            # app) -- logger.info() here would be silently swallowed, so this must be a
            # warning to actually surface a fallback that otherwise looks identical to a
            # deliberate bar_chart pick.
            logger.warning(
                "D3TemplateRouter: model chose unknown template_id=%r for concept=%r -- falling back to bar_chart",
                sel.template_id, concept,
            )
            sel = D3Selection(
                template_id="bar_chart",
                reasoning=f"Fallback: model chose unknown template id {sel.template_id!r}.",
            )
        return sel

    def select_transform(self, concept: str, chunks: List[dict], familiarity: str) -> D3Selection:
        """Agent-select a trusted template, then enforce explicit user chart commands."""
        chunk_text = "\n\n".join(f"[{c.get('source','selection')}]: {c.get('text','')}" for c in chunks)[:5000]
        allowed = _explicit_allowed_templates(concept)
        messages = [
            {"role": "system", "content": (
                "You route a selected paper table, plot, or image into one trusted visualization template. "
                "Choose exactly one template_id from the live CATALOG. The user's explicit visual family is "
                "a command: pie must remain pie, donut must remain donut, table must remain table, and so on. "
                "Choose a source metric explicitly requested by the user. If none is named, prefer a clearly "
                "labeled Avg/Average/overall column that applies to the requested rows. If there is no clear "
                "aggregate and several columns are equally plausible, set requires_metric_clarification=true and "
                "list their exact labels in metric_candidates. Do not choose values or "
                "invent data; a separate extractor handles transcription.\n\n"
                f"CATALOG:\n{catalog_for_prompt()}\n\nSELECTED CONTEXT:\n{chunk_text}"
            )},
            {"role": "user", "content": f"REQUEST: {concept}\nLEVEL: {familiarity}\nChoose the template and metric."},
        ]
        selection = self._client.structured_complete(messages, D3Selection, reasoning_effort="low")
        from app.agents.d3.registry import TEMPLATES
        if selection.template_id not in TEMPLATES:
            raise D3TemplateSelectionError(f"The visualization agent selected unknown template {selection.template_id!r}.")
        if allowed and selection.template_id not in allowed:
            raise D3TemplateSelectionError(
                f"The visualization agent changed the explicitly requested chart family to {selection.template_id!r}."
            )
        return selection.model_copy(update={"explicit_family": bool(allowed)})