File size: 6,653 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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
from __future__ import annotations
import re
from decimal import Decimal, InvalidOperation
from typing import Any, Iterator, List, Optional
from pydantic import BaseModel
from app.agents.cerebras_client import CerebrasClient
from app.agents.d3.registry import D3Template
from app.agents.senses_agent import VISION_MODEL_ID


class D3SourceValidationError(ValueError):
    pass


class D3TransformNarrative(BaseModel):
    answer_markdown: str


def _numeric_leaves(value: Any) -> Iterator[Decimal]:
    if isinstance(value, BaseModel):
        for field_value in value.__dict__.values():
            yield from _numeric_leaves(field_value)
    elif isinstance(value, dict):
        for field_value in value.values():
            yield from _numeric_leaves(field_value)
    elif isinstance(value, (list, tuple)):
        for field_value in value:
            yield from _numeric_leaves(field_value)
    elif isinstance(value, (int, float)) and not isinstance(value, bool):
        yield Decimal(str(value)).normalize()
    elif isinstance(value, str) and re.fullmatch(r"\s*[-+]?\d[\d,]*(?:\.\d+)?%?\s*", value):
        cleaned = value.strip().replace(",", "").rstrip("%")
        try:
            yield Decimal(cleaned).normalize()
        except InvalidOperation:
            return


def _source_numbers(text: str) -> set[Decimal]:
    found: set[Decimal] = set()
    for token in re.findall(r"[-+]?\d[\d,]*(?:\.\d+)?", text):
        try:
            found.add(Decimal(token.replace(",", "")).normalize())
        except InvalidOperation:
            continue
    return found


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

    def fill(
        self,
        template: D3Template,
        concept: str,
        chunks: List[dict],
        familiarity: str,
        *,
        strict_source: bool = False,
        image_base64: str = "",
        selected_metric: str = "",
    ) -> BaseModel:
        chunk_text = "\n\n".join(f"[{c.get('source','?')}]: {c['text']}" for c in chunks)[:3000]
        if strict_source:
            extraction_rule = (
                "Transcribe only labels and values that are visibly present in the SELECTED SOURCE. "
                "Never invent, estimate, interpolate, calculate an unstated aggregate, or substitute example data. "
                "Keep model/method names exactly associated with their source row. If a metric is specified, use "
                "only that column. If the schema cannot be filled faithfully, fail instead of fabricating output."
            )
        else:
            extraction_rule = (
                "Prefer real numbers, labels, and relationships found verbatim in the SOURCE MATERIAL. If the source "
                "does not contain the data this chart needs, invent a plausible representative illustrative example."
            )
        messages = [
            {"role": "system", "content": (
                f"You provide data for a specific trusted visualization. {extraction_rule}\n\n"
                f"CHART: {template.title}{template.data_requirements}\n"
                f"SELECTED METRIC: {selected_metric or 'not explicitly named'}\n\nSELECTED SOURCE:\n{chunk_text}"
            )},
            {"role": "user", "content": (
                ([{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}},
                  {"type": "text", "text": f"REQUEST: {concept}\nLEVEL: {familiarity}\nFill the schema from this selected image and any selected text."}]
                 if image_base64 else f"REQUEST: {concept}\nLEVEL: {familiarity}\nFill the schema from the selected text.")
            )},
        ]
        # Never declines -- the system prompt above asks the model to synthesize a
        # plausible illustrative example when the source lacks real data, rather
        # than leave the schema empty. D3Engine reports whether the result actually
        # came from the source via a separate grounding heuristic (see d3/engine.py).
        data = self._client.structured_complete(
            messages,
            template.schema,
            model=VISION_MODEL_ID if image_base64 else None,
            reasoning_effort="low" if strict_source else "medium",
        )
        if strict_source and not image_base64:
            allowed_numbers = _source_numbers(chunk_text)
            missing = sorted({value for value in _numeric_leaves(data) if value not in allowed_numbers})
            if missing:
                raise D3SourceValidationError(
                    "Extracted visualization contains numeric values absent from the selected source: "
                    + ", ".join(str(value) for value in missing[:6])
                )
        return data

    def explain(
        self,
        *,
        concept: str,
        template: D3Template,
        data: BaseModel,
        chunks: List[dict],
        image_base64: str = "",
    ) -> str:
        """Answer the analytical part of a transformation request from the same source."""
        chunk_text = "\n\n".join(str(c.get("text", "")) for c in chunks)[:5000]
        system = (
            "Give a concise research-assistant answer accompanying a source-selected visualization. "
            "Directly answer the user's analytical question in at most four short sentences. Use only "
            "facts and numeric comparisons visible in the selected source or the extracted chart data. "
            "For improvement questions, identify the compared baseline and where values increased; do "
            "not call a larger value an improvement when the metric direction is unknown. Do not discuss "
            "the routing process, template selection, or extraction. If the source cannot support part of "
            "the requested interpretation, state that limitation briefly."
        )
        text = (
            f"REQUEST:\n{concept}\n\nVISUAL FORM: {template.title}\n\n"
            f"EXTRACTED DATA:\n{data.model_dump_json()}\n\nSELECTED TEXT:\n{chunk_text}"
        )
        content: Any = text
        if image_base64:
            content = [
                {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}},
                {"type": "text", "text": text},
            ]
        result = self._client.structured_complete(
            [{"role": "system", "content": system}, {"role": "user", "content": content}],
            D3TransformNarrative,
            model=VISION_MODEL_ID if image_base64 else None,
            reasoning_effort="low",
        )
        return result.answer_markdown.strip()