Spaces:
Sleeping
Sleeping
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)})
|