Spaces:
Running
Running
| from __future__ import annotations | |
| import logging | |
| import time | |
| from typing import Any, Iterator, List | |
| from pydantic import BaseModel | |
| from app.schemas.graph import HTML5VisualPayload | |
| from app.agents.d3 import templates as _templates # noqa: F401 - populate trusted registry | |
| from app.agents.d3.registry import TEMPLATES | |
| from app.agents.d3.router import D3TemplateRouter | |
| from app.agents.d3.extractor import D3DataExtractor | |
| logger = logging.getLogger(__name__) | |
| class D3MetricClarificationError(ValueError): | |
| def __init__(self, metrics: list[str]) -> None: | |
| self.metrics = metrics[:5] | |
| super().__init__("The selected source contains several equally plausible metrics.") | |
| def _iter_string_leaves(obj: Any) -> Iterator[str]: | |
| if isinstance(obj, BaseModel): | |
| for v in obj.__dict__.values(): | |
| yield from _iter_string_leaves(v) | |
| elif isinstance(obj, (list, tuple)): | |
| for v in obj: | |
| yield from _iter_string_leaves(v) | |
| elif isinstance(obj, str): | |
| yield obj | |
| def _looks_grounded(data: BaseModel, chunk_text: str) -> bool: | |
| """D3 chart data has no anchor concept (unlike formula/text_ref/shell, which | |
| self-check a verbatim source substring) -- this is the closest honest signal: | |
| true only if at least one distinctive label the model produced actually | |
| appears verbatim in the source chunks. | |
| Two deliberate filters against false positives (a fabricated chart getting | |
| mislabeled as source-grounded by coincidence): | |
| - Short strings (< 6 chars) are excluded -- too likely to appear by chance. | |
| - Purely numeric strings (years, counts: "2010", "1990") are excluded even if | |
| long enough -- these are exactly the kind of value a synthesized chart is | |
| most likely to share with unrelated real content by pure coincidence (a | |
| fabricated "2010" data point "matching" an unrelated year mentioned | |
| elsewhere in the source proves nothing about whether the chart itself is | |
| grounded). | |
| """ | |
| if not chunk_text: | |
| return False | |
| for value in _iter_string_leaves(data): | |
| candidate = value.strip() | |
| if len(candidate) < 6: | |
| continue | |
| if candidate.replace(".", "", 1).replace("-", "", 1).isdigit(): | |
| continue | |
| if candidate in chunk_text: | |
| return True | |
| return False | |
| class D3Engine: | |
| def __init__(self, router=None, extractor=None) -> None: | |
| self._router = router or D3TemplateRouter() | |
| self._extractor = extractor or D3DataExtractor() | |
| def generate(self, concept: str, chunks: List[dict], familiarity: str) -> HTML5VisualPayload: | |
| sel = self._router.select(concept, chunks, familiarity) | |
| template = TEMPLATES[sel.template_id] | |
| data = self._extractor.fill(template, concept, chunks, familiarity) | |
| html = template.render(data) | |
| chunk_text = "\n\n".join(c.get("text", "") for c in chunks) | |
| source = "paper" if _looks_grounded(data, chunk_text) else "model_knowledge" | |
| return HTML5VisualPayload( | |
| html_code=html, animation_type="graph", explanation=f"{template.title}: {concept}.", source=source | |
| ) | |
| def transform_selected( | |
| self, | |
| concept: str, | |
| chunks: List[dict], | |
| familiarity: str, | |
| image_base64: str = "", | |
| ) -> tuple[HTML5VisualPayload, str]: | |
| started = time.perf_counter() | |
| selection = self._router.select_transform(concept, chunks, familiarity) | |
| if selection.requires_metric_clarification and len(selection.metric_candidates) >= 2: | |
| raise D3MetricClarificationError(selection.metric_candidates) | |
| routed_ms = (time.perf_counter() - started) * 1000 | |
| template = TEMPLATES[selection.template_id] | |
| data = self._extractor.fill( | |
| template, | |
| concept, | |
| chunks, | |
| familiarity, | |
| strict_source=True, | |
| image_base64=image_base64, | |
| selected_metric=selection.selected_metric, | |
| ) | |
| extracted_ms = (time.perf_counter() - started) * 1000 - routed_ms | |
| narrative_started = time.perf_counter() | |
| try: | |
| answer_markdown = self._extractor.explain( | |
| concept=concept, | |
| template=template, | |
| data=data, | |
| chunks=chunks, | |
| image_base64=image_base64, | |
| ) | |
| except Exception: | |
| logger.exception("D3 selected transform narrative failed for template=%s", selection.template_id) | |
| answer_markdown = ( | |
| f"The selected source is shown as a **{template.title.lower()}**" | |
| + (f" using **{selection.selected_metric}**." if selection.selected_metric else ".") | |
| ) | |
| narrative_ms = (time.perf_counter() - narrative_started) * 1000 | |
| logger.info( | |
| "D3 selected transform template=%s metric=%r source=%s route_ms=%.1f extract_ms=%.1f narrative_ms=%.1f total_ms=%.1f", | |
| selection.template_id, | |
| selection.selected_metric, | |
| "image" if image_base64 else "text", | |
| routed_ms, | |
| extracted_ms, | |
| narrative_ms, | |
| (time.perf_counter() - started) * 1000, | |
| ) | |
| metric_suffix = f" using {selection.selected_metric}" if selection.selected_metric else "" | |
| visual = HTML5VisualPayload( | |
| html_code=template.render(data), | |
| animation_type="graph", | |
| explanation=f"{template.title}{metric_suffix}, transcribed from the selected source.", | |
| source="paper", | |
| trusted_template=True, | |
| template_id=selection.template_id, | |
| ) | |
| return visual, answer_markdown | |