File size: 2,609 Bytes
5a60e93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Charts endpoint — user-facing chart retrieval for one turn (S2 visualization,
SPINE_V2_PLAN §4.4/§4.5).

`GET /api/v1/charts?message_id=` returns every chart a `render_chart` tool call
produced during one assistant turn, as `dataeyond.chart.v1` envelopes
(SPINE_V2_PLAN §4.2). Rows are written by the chat pipeline right before the `done`
SSE event; the FE fires this GET on `done` with the `message_id` from that event.
Lookup is by `message_id` alone (Python-minted UUID4 — lead decision 2026-07-13).

Every response is HTTP 200 with an explicit `status` marker (lead ask 2026-07-13):
  - `success`   — ≥1 chart; render them.
  - `empty`     — the turn completed but produced no charts (the common case).
  - `not_found` — no completed turn is known for this message_id (mistyped/stale
                  id, or an error turn — those never write a row). Distinguished
                  from `empty` via the turn's traceability row.

No auth — Go fronts Python.
"""

from typing import Literal

from fastapi import APIRouter, Query
from pydantic import BaseModel

from src.charts import ChartRecord, PostgresChartStore
from src.middlewares.logging import get_logger, log_execution

logger = get_logger("charts_api")

router = APIRouter(prefix="/api/v1", tags=["Charts"])

# Warm, process-shared store (mirrors the chat endpoints' module-level instances).
_store = PostgresChartStore()


class ChartsResponse(BaseModel):
    status: Literal["success", "empty", "not_found"]
    message: str
    count: int
    charts: list[ChartRecord]


@router.get("/charts", response_model=ChartsResponse)
@log_execution(logger)
async def get_charts(
    message_id: str = Query(
        ..., description="Assistant turn id, taken from the `done` SSE event"
    ),
) -> ChartsResponse:
    """Fetch every chart for one turn. Always 200 — the `status` field carries the
    outcome, so the FE can call this unconditionally on every `done`."""
    charts = await _store.list_for_message(message_id)
    if charts:
        return ChartsResponse(
            status="success",
            message=f"{len(charts)} chart(s) for this message.",
            count=len(charts),
            charts=charts,
        )
    if await _store.turn_exists(message_id):
        return ChartsResponse(
            status="empty",
            message="This message completed without producing charts.",
            count=0,
            charts=[],
        )
    return ChartsResponse(
        status="not_found",
        message="No completed turn is known for this message_id.",
        count=0,
        charts=[],
    )