| """ |
| ai_interpretation.py |
| -------------------- |
| AI-powered chart interpretation using OpenAI GPT-5.2 vision with |
| Pydantic structured output. |
| |
| Provides: |
| - Pydantic models for structured chart analysis results |
| - Vision-based chart interpretation via OpenAI's GPT-5.2 model |
| - Markdown rendering of interpretation results (framework-agnostic) |
| """ |
|
|
| from __future__ import annotations |
|
|
| import base64 |
| import json |
| import os |
| from typing import Literal |
|
|
| import openai |
| from pydantic import BaseModel, ConfigDict |
|
|
|
|
| |
| |
| |
|
|
| class TrendInfo(BaseModel): |
| """Describes the overall trend detected in the chart.""" |
|
|
| model_config = ConfigDict(extra="forbid") |
|
|
| direction: Literal["upward", "downward", "flat", "mixed"] |
| description: str |
|
|
|
|
| class SeasonalityInfo(BaseModel): |
| """Describes any seasonality detected in the chart.""" |
|
|
| model_config = ConfigDict(extra="forbid") |
|
|
| detected: bool |
| period: str | None |
| description: str |
|
|
|
|
| class StationarityInfo(BaseModel): |
| """Describes whether the series appears stationary.""" |
|
|
| model_config = ConfigDict(extra="forbid") |
|
|
| likely_stationary: bool |
| description: str |
|
|
|
|
| class AnomalyItem(BaseModel): |
| """A single anomaly or outlier observation.""" |
|
|
| model_config = ConfigDict(extra="forbid") |
|
|
| approximate_location: str |
| description: str |
| severity: Literal["low", "medium", "high"] |
|
|
|
|
| class ChartInterpretation(BaseModel): |
| """Complete structured interpretation of a time-series chart.""" |
|
|
| model_config = ConfigDict(extra="forbid") |
|
|
| chart_type_detected: str |
| trend: TrendInfo |
| seasonality: SeasonalityInfo |
| stationarity: StationarityInfo |
| anomalies: list[AnomalyItem] |
| key_observations: list[str] |
| summary: str |
| recommendations: list[str] |
|
|
|
|
| |
| |
| |
|
|
| def check_api_key_available() -> bool: |
| """Return ``True`` if the ``OPENAI_API_KEY`` environment variable is set |
| and non-empty.""" |
| key = os.environ.get("OPENAI_API_KEY", "") |
| return bool(key.strip()) |
|
|
|
|
| |
| |
| |
|
|
| _SYSTEM_PROMPT = ( |
| "You are a careful time-series analyst helping business analytics " |
| "students. Analyze the chart image and provide a structured " |
| "interpretation. Be precise about what the data shows; flag anything " |
| "noteworthy. Use plain language suitable for students." |
| ) |
|
|
|
|
| def interpret_chart( |
| png_bytes: bytes, |
| metadata: dict, |
| ) -> ChartInterpretation: |
| """Send a chart image to GPT-5.2 vision and return a structured |
| interpretation. |
| |
| Parameters |
| ---------- |
| png_bytes: |
| Raw PNG image bytes of the chart to analyse. |
| metadata: |
| Context about the chart. Expected keys: |
| |
| * ``chart_type`` -- e.g. ``"line"``, ``"bar"``, ``"decomposition"`` |
| * ``frequency_label`` -- e.g. ``"Monthly"``, ``"Daily"`` |
| * ``date_range`` -- human-readable date range string |
| * ``y_column`` -- name of the value column being plotted |
| """ |
| try: |
| client = openai.OpenAI() |
|
|
| |
| b64 = base64.b64encode(png_bytes).decode("utf-8") |
| image_data_uri = f"data:image/png;base64,{b64}" |
|
|
| chart_type = metadata.get("chart_type", "time-series") |
| metadata_str = json.dumps(metadata, default=str) |
|
|
| response = client.beta.chat.completions.parse( |
| model="gpt-5.2-2025-12-11", |
| response_format=ChartInterpretation, |
| messages=[ |
| {"role": "system", "content": _SYSTEM_PROMPT}, |
| { |
| "role": "user", |
| "content": [ |
| { |
| "type": "image_url", |
| "image_url": {"url": image_data_uri}, |
| }, |
| { |
| "type": "text", |
| "text": ( |
| f"Analyze this {chart_type} chart. " |
| f"Metadata: {metadata_str}" |
| ), |
| }, |
| ], |
| }, |
| ], |
| ) |
|
|
| |
| parsed = response.choices[0].message.parsed |
| if parsed is not None: |
| return parsed |
|
|
| |
| raw_content = response.choices[0].message.content or "" |
| data = json.loads(raw_content) |
| return ChartInterpretation(**data) |
|
|
| except Exception as exc: |
| |
| return ChartInterpretation( |
| chart_type_detected="unknown", |
| trend=TrendInfo(direction="mixed", description="Unable to determine."), |
| seasonality=SeasonalityInfo( |
| detected=False, period=None, description="Unable to determine." |
| ), |
| stationarity=StationarityInfo( |
| likely_stationary=False, description="Unable to determine." |
| ), |
| anomalies=[], |
| key_observations=["AI interpretation failed; see summary for details."], |
| summary=f"Error during AI interpretation: {exc}", |
| recommendations=["Check that your OPENAI_API_KEY is set and valid."], |
| ) |
|
|
|
|
| |
| |
| |
|
|
| _DIRECTION_EMOJI = { |
| "upward": "\u2197\ufe0f", |
| "downward": "\u2198\ufe0f", |
| "flat": "\u27a1\ufe0f", |
| "mixed": "\u2194\ufe0f", |
| } |
|
|
| _SEVERITY_COLOR = { |
| "low": "green", |
| "medium": "orange", |
| "high": "red", |
| } |
|
|
|
|
| def render_interpretation_markdown(interp: ChartInterpretation) -> str: |
| """Render a :class:`ChartInterpretation` as a Markdown string. |
| |
| Returns a formatted multi-section Markdown document suitable for |
| display in ``gr.Markdown`` or any other Markdown renderer. |
| """ |
| lines: list[str] = [] |
|
|
| lines.append("### AI Chart Interpretation") |
| lines.append(f"**Detected chart type:** {interp.chart_type_detected}") |
| lines.append("") |
|
|
| |
| lines.append("---") |
| lines.append(f"**Summary:** {interp.summary}") |
| lines.append("") |
|
|
| |
| lines.append("#### Key Observations") |
| for obs in interp.key_observations: |
| lines.append(f"- {obs}") |
| lines.append("") |
|
|
| |
| lines.append("#### Trend Analysis") |
| arrow = _DIRECTION_EMOJI.get(interp.trend.direction, "") |
| lines.append(f"**Direction:** {interp.trend.direction.capitalize()} {arrow}") |
| lines.append("") |
| lines.append(interp.trend.description) |
| lines.append("") |
|
|
| |
| lines.append("#### Seasonality") |
| status = "Detected" if interp.seasonality.detected else "Not detected" |
| lines.append(f"**Status:** {status}") |
| if interp.seasonality.period: |
| lines.append(f"**Period:** {interp.seasonality.period}") |
| lines.append("") |
| lines.append(interp.seasonality.description) |
| lines.append("") |
|
|
| |
| lines.append("#### Stationarity") |
| label = ( |
| "Likely stationary" |
| if interp.stationarity.likely_stationary |
| else "Likely non-stationary" |
| ) |
| lines.append(f"**Assessment:** {label}") |
| lines.append("") |
| lines.append(interp.stationarity.description) |
| lines.append("") |
|
|
| |
| lines.append("#### Anomalies") |
| if not interp.anomalies: |
| lines.append("No anomalies detected.") |
| else: |
| for anomaly in interp.anomalies: |
| lines.append( |
| f"- **[{anomaly.approximate_location}]** " |
| f"*{anomaly.severity.upper()}* " |
| f"-- {anomaly.description}" |
| ) |
| lines.append("") |
|
|
| |
| lines.append("#### Recommended Next Steps") |
| for i, rec in enumerate(interp.recommendations, 1): |
| lines.append(f"{i}. {rec}") |
|
|
| return "\n".join(lines) |
|
|