study-buddy / app /agents /d3 /extractor.py
GitHub Actions
deploy d092bea3608b7a29952f16357fda39b7a29e399b
2e818da
Raw
History Blame Contribute Delete
6.65 kB
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()