{"components":{"schemas":{"Error":{"properties":{"error":{"type":"string"}},"type":"object"},"GenerateResult":{"properties":{"items":{"description":"Non-empty on success. Text / data-file sources return 1\u20132 items\n(depending on `include_alt` and whether an alternative was\nproposed); the image source always returns a single item.\n","items":{"properties":{"component":{"description":"Chart component used (`bar`, `line`, `pie`, \u2026).","type":"string"},"id":{"description":"`v3-<uuid7-hex>`.","type":"string"},"role":{"description":"Which branch of the router produced this chart.","enum":["primary","alternative"],"type":"string"},"subtitle":{"description":"Short takeaway (\u226410 words) off the IR headline; handled like `title`.","type":"string"},"title":{"description":"Chart title lifted off the normalized IR. NOT baked into the\nSVG \u2014 the consumer draws it as a separate text layer. Empty\nwhen the model produced no title.\n","type":"string"},"url":{"description":"Public S3 URL of the generated SVG.","format":"uri","type":"string"}},"type":"object"},"type":"array"},"warnings":{"description":"Present when the request carried ignored fields (e.g. `slide_data`\non generate). Each entry is a human-readable hint for the caller.\n","items":{"type":"string"},"type":"array"}},"type":"object"},"HealthResponse":{"properties":{"version":{"description":"Service release from VERSION.md.","type":"string"}},"required":["version"],"type":"object"},"RejectError":{"properties":{"error":{"type":"string"},"reason":{"type":"string"}},"type":"object"},"SlideData":{"description":"Slide narration context for `POST /api/v1/chart/animate`. When it\ncarries per-word `word_boundaries`, the engine re-times the chart's\nbaked animation to the narration. Accepted on generate routes for\nforward compatibility but ignored there \u2014 the response includes a\n`warnings` entry pointing callers at `/animate`.\n","properties":{"duration_ms":{"description":"Final slide audio duration in ms.","type":"integer"},"language":{"description":"Narration language.","type":"string"},"text":{"description":"Narration with SSML stripped (the same string sent to TTS).","type":"string"},"word_boundaries":{"description":"Word-level speech timing from the slide audio start.","items":{"properties":{"offset":{"description":"Word start time in ms from slide audio start.","type":"integer"},"text":{"description":"The spoken word.","type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"StyleObject":{"description":"Editor-supplied visual overrides, applied deterministically AFTER the\nLLM produces the IR. All fields are optional and invalid values are\nignored (the engine/LLM defaults stand). `theme` and `font` are\naccepted for forward-compatibility but not yet consumed by the engine.\n","properties":{"accent_color":{"description":"Primary accent `#rrggbb` hex (a comma-separated list is tolerated).","type":"string"},"chart_background":{"description":"Keyword (`light`/`dark`/`transparent`) or `#rrggbb` hex for the chart pane.","type":"string"},"font":{"description":"Reserved \u2014 accepted but not yet applied by the engine.","type":"string"},"text_color":{"description":"Chrome text `#rrggbb` hex (axis / legend / title).","type":"string"},"theme":{"description":"Reserved \u2014 accepted but not yet applied by the engine.","type":"string"}},"type":"object"}},"securitySchemes":{"elaiToken":{"description":"Shared secret matching `AI_CHARTS_API_TOKEN` on the service. Unset disables the gate.","in":"header","name":"X-Elai-Token","type":"apiKey"}}},"info":{"description":"LLM-backed SVG chart generation (chart_engine pipeline). Each generate\ncall also writes a row into `ai_charts.chat_entries` so\n`GET /api/v1/history` can replay the editor's chat after a reload.\n\nThe `/api/v1/chart/*` and `/history` routes are gated by `X-Elai-Token`.\nThe components proxy (`/components/<key>`) is intentionally open so\n`<img src>` tags work without header control.\n","title":"AI Charts","version":"1.1.0"},"openapi":"3.0.3","paths":{"/api/v1/chart/animate":{"post":{"description":"Runs the narration-alignment stage over an already-baked chart so\nits reveal / highlights / out land on the spoken words \u2014 without\nre-running the producer. The `chart_id` in the body names the immutable\nsource; on success a fresh `component_id` + `url` are minted with the\nre-timed schedule and the source CR and S3 object are left untouched\n(render pinning). Alignment is best-effort: when the narration cannot\nre-time the chart (no usable `word_boundaries`, no CSS animation, the\ndetection pass finds nothing) the call returns `422` and nothing is\nwritten. The source must have been generated with a baked animated\nartifact (a `charts` document exists) \u2014 legacy / unknown ids return\n`404`.\n","requestBody":{"content":{"application/json":{"schema":{"properties":{"chart_id":{"description":"The chart's `component_id` (must match a stored CR).","type":"string"},"slide_data":{"$ref":"#/components/schemas/SlideData"}},"required":["chart_id","slide_data"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"applied":{"description":"Always `true` on a 200 (the schedule was re-timed).","type":"boolean"},"component_id":{"description":"The **new** chart minted for the re-timed artifact (a\nfresh uuid7 hex). Differs from the request's `chart_id`.\n","type":"string"},"source_chart_id":{"description":"The source chart this alignment forked from (equals the\nrequest body's `chart_id`).\n","type":"string"},"title":{"description":"Chart title lifted off CR semantics, if any.","nullable":true,"type":"string"},"url":{"description":"Public URL of the new baked SVG (a new immutable key; the\nsource object's URL is unchanged).\n","format":"uri","type":"string"}},"type":"object"}}},"description":"Alignment applied; a new forked chart was written (source untouched)."},"400":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"Missing `chart_id`, or `slide_data` has no `word_boundaries`."},"401":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"Missing or invalid `X-Elai-Token` header."},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"No editable `charts` record for `chart_id`."},"422":{"content":{"application/json":{"schema":{"properties":{"applied":{"type":"boolean"},"error":{"type":"string"},"reason":{"type":"string"}},"type":"object"}}},"description":"Alignment declined to re-time the chart for this narration (e.g. the\nchart baked no CSS animation, or no narration states were detected).\nBody carries a human-readable `reason` and `applied: false`.\n"},"503":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"MongoDB unreachable \u2014 the chart store could not be read/written."}},"security":[{"elaiToken":[]}],"summary":"Re-time a stored chart to narration as a copy-on-write fork.","tags":["charts"]}},"/api/v1/chart/generate/from-file":{"post":{"description":"`multipart/form-data` with a `file` part (CSV / TSV / Excel `.xlsx` /\nJSON / NDJSON) and a JSON `data` part carrying the common generation\nfields. The file is normalised to CSV text before the engine sees it;\nan unparseable file is a `422` rejection.\n","requestBody":{"content":{"multipart/form-data":{"schema":{"properties":{"data":{"description":"JSON-encoded object with `video_id`, `user_id`, and the\noptional `prompt` (free-text guidance), `include_alt`,\n`seed`, and `style` (see `StyleObject`).\n","type":"string"},"file":{"description":"The data file (CSV / TSV / Excel / JSON / NDJSON).","format":"binary","type":"string"}},"required":["file","data"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerateResult"}}},"description":"JSON envelope carrying 1\u20132 chart items."},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Missing `file`, malformed `data`, or missing `video_id` / `user_id`."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Missing or invalid `X-Elai-Token` header."},"413":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Uploaded file exceeds `AI_CHARTS_MAX_FILE_BYTES`."},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RejectError"}}},"description":"File could not be parsed into a table, or rejected as not chartable."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Pipeline failure \u2014 no SVG produced, or the engine crashed."},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"MongoDB unreachable \u2014 we never silently drop a row the editor expects."}},"security":[{"elaiToken":[]}],"summary":"Generate SVG chart(s) from an uploaded data file","tags":["generate"]}},"/api/v1/chart/generate/from-image":{"post":{"description":"`multipart/form-data` with a `file` part (SVG export or raster\nscreenshot / photo of a chart) and a JSON `data` part carrying the\ncommon generation fields. The image contract reproduces a SINGLE\nsource chart, so `include_alt` has no effect and the response always\ncarries exactly one item.\n","requestBody":{"content":{"multipart/form-data":{"schema":{"properties":{"data":{"description":"JSON-encoded object with `video_id`, `user_id`, and the\noptional `prompt` (free-text guidance), `seed`, and `style`\n(see `StyleObject`).\n","type":"string"},"file":{"description":"The chart image (SVG, PNG, JPEG, \u2026).","format":"binary","type":"string"}},"required":["file","data"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerateResult"}}},"description":"JSON envelope carrying a single chart item."},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Missing `file`, malformed `data`, or missing `video_id` / `user_id`."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Missing or invalid `X-Elai-Token` header."},"413":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Uploaded image exceeds `AI_CHARTS_MAX_IMAGE_BYTES`."},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RejectError"}}},"description":"Image rejected as not a chart, or could not be interpreted."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Pipeline failure \u2014 no SVG produced, or the engine crashed."},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"MongoDB unreachable \u2014 we never silently drop a row the editor expects."}},"security":[{"elaiToken":[]}],"summary":"Generate an SVG chart from an uploaded chart image","tags":["generate"]}},"/api/v1/chart/generate/from-text":{"post":{"description":"Runs the chart_engine pipeline (LLM \u2192 IR \u2192 Vega SVG) from a free-text\nprompt and/or slide body, returning the public S3 URL(s) of the\ngenerated chart(s). At least one of `prompt` / `text` must be\nnon-empty; both are concatenated for the engine. Charts are baked with\ntheir default animation; for narration-aligned timing, call\n`POST /api/v1/chart/animate` after generation. POST-only and called\nserver-to-server (the `X-Elai-Token` is never shipped to the browser).\n","requestBody":{"content":{"application/json":{"schema":{"properties":{"include_alt":{"default":true,"description":"When `true` (default) the pipeline produces BOTH the primary\nand the alternative component (up to 2 items). When `false`\nonly the primary ships. Truthy `1|true|yes|on`; falsy\n`0|false|no|off`.\n","type":"boolean"},"prompt":{"description":"Natural-language instruction for the chart. Optional if `text` is set.","type":"string"},"seed":{"description":"Opaque per-request token echoed into the chat_entries row.","type":"string"},"slide_data":{"allOf":[{"$ref":"#/components/schemas/SlideData"}],"description":"Accepted for forward compatibility but **ignored** at\ngenerate; the response includes a `warnings` entry. Use\n`POST /api/v1/chart/animate` for narration-aligned timing.\n"},"style":{"$ref":"#/components/schemas/StyleObject"},"text":{"description":"Slide body / source text to chart. Optional if `prompt` is set.","type":"string"},"user_id":{"description":"See `video_id`.","type":"string"},"video_id":{"description":"Scopes the S3 key:\n`projects/{video_id}/{user_id}/components/{uuid7}.svg`.\n","type":"string"}},"required":["video_id","user_id"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerateResult"}}},"description":"JSON envelope carrying 1\u20132 chart items."},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Missing `video_id` / `user_id`, or neither `prompt` nor `text`."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Missing or invalid `X-Elai-Token` header."},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RejectError"}}},"description":"Upstream classifier rejected the request as not a chart query."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Pipeline failure \u2014 no SVG produced, or the engine crashed."},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"MongoDB unreachable \u2014 we never silently drop a row the editor expects."}},"security":[{"elaiToken":[]}],"summary":"Generate SVG chart(s) from free text","tags":["generate"]}},"/api/v1/chart/sync":{"post":{"description":"Applies a sparse, whitelisted Change Request (CCR) to the source\nchart's stored composed representation (CR) and writes the patched\nbaked SVG as a **new** chart (copy-on-write). The `chart_id` in the body\nnames the immutable source; on success a fresh `component_id` + `url`\nare minted and the source CR and S3 object are left untouched. This lets\nthe editor keep editing while a long-running render stays pinned to the\nid it was dispatched with. The patch rewrites CSS custom properties +\nthe embedded `chart-meta` (including the new id) \u2014 no re-render for v1\nsurfaces. v1 edits cover explicit `palette` / `background` /\n`text_color` and animation feel levers only. The source must have been\ngenerated with a baked animated artifact (a `charts` document exists)\n\u2014 legacy / unknown ids return `404`. The source is identified by\n`chart_id` in the request body only.\n","requestBody":{"content":{"application/json":{"schema":{"properties":{"changes":{"description":"Sparse, layer-grouped patch. Only whitelisted groups\nappear; missing groups mean unchanged. Forbidden surfaces\n(`data`, `semantics`, `settings`, `component`, raw\n`meta` / `svg`) are rejected at the parse boundary.\n","properties":{"animation":{"description":"AR patch (in-place). Keys: `speed`, `target_total_ms`,\n`stagger_ms`, `step_ms`, `hold_ms`, `pulse_dur_ms`,\n`start_state`, `reveal_axis`, `schedule` (with\n`schedule.track` replacing the track array wholesale).\nNot editable: `preset`.\n","type":"object"},"style":{"description":"SR patch (in-place). Keys: `palette` (non-empty list,\nsame length as artifact colour tokens), `background`,\n`text_color`. Not editable: `accent_color`, `theme`,\n`font` \u2014 send an explicit `palette` instead.\n","type":"object"}},"type":"object"},"chart_id":{"description":"The chart's `component_id` (must match a stored CR).","type":"string"},"client_context":{"description":"Optional UI telemetry (never affects the patch).","properties":{"interaction_id":{"type":"string"},"reason":{"type":"string"},"source":{"type":"string"}},"type":"object"},"schema_version":{"description":"CCR contract version (v1 = `\"1\"`).","type":"string"}},"required":["schema_version","chart_id","changes"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"component_id":{"description":"The **new** chart minted for this edit (a fresh uuid7\nhex). Differs from the request's `chart_id`.\n","type":"string"},"rendered":{"description":"Always `false` for v1 edits (geometry unchanged).\n","type":"boolean"},"source_chart_id":{"description":"The source chart this edit forked from (equals the\nrequest body's `chart_id`).\n","type":"string"},"title":{"description":"Chart title lifted off CR semantics, if any.","nullable":true,"type":"string"},"url":{"description":"Public URL of the new baked SVG (a new immutable key; the\nsource object's URL is unchanged).\n","format":"uri","type":"string"}},"type":"object"}}},"description":"CCR applied; a new forked chart was written (source untouched)."},"400":{"content":{"application/json":{"schema":{"properties":{"code":{"type":"string"},"error":{"type":"string"},"field":{"type":"string"}},"type":"object"}}},"description":"Malformed or rejected CCR. Body carries a machine-readable `code`\n(e.g. `ccr_forbidden_surface`,\n`ccr_empty_changes`, `ccr_invalid_field`, `ccr_unknown_target_ref`,\n`ccr_null_not_allowed`, `ccr_structural_change`) and an optional\n`field` path.\n"},"401":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"Missing or invalid `X-Elai-Token` header."},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"No editable `charts` record for `chart_id`."},"503":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"MongoDB unreachable \u2014 the chart store could not be read/written."}},"security":[{"elaiToken":[]}],"summary":"Sync a stored chart (apply a CCR) as a copy-on-write fork.","tags":["charts"]}},"/api/v1/components/{key}":{"get":{"description":"Read-through Redis \u2192 S3. Not gated by `X-Elai-Token` so `<img src>`\ntags work; the uuid7 key shape is the guard.\n","parameters":[{"description":"Full S3 key, e.g. `projects/<v>/<u>/components/<uuid7>.svg`.","in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"image/svg+xml":{"schema":{"type":"string"}}},"description":"SVG bytes. `X-Cache` header is `HIT` or `MISS`."},"400":{"description":"Key shape doesn't match the upload pattern."},"404":{"description":"No object at the given key."},"500":{"description":"S3 fetch failed for non-404 reasons."}},"summary":"Stream a stored SVG from the artifact bucket.","tags":["components"]}},"/api/v1/debug/chart/{chart_id}":{"get":{"description":"Read-only inspection endpoint for an admin debug UI. Returns the\ncomposed representation (`cr`) plus lineage metadata for the chart.\nNot part of the editing contract; caller-level authorization (admin\naccounts only) is enforced by the backend proxy, this service only\nrequires `X-Elai-Token`.\n","parameters":[{"description":"Chart id (= component_id) whose CR to return.","in":"path","name":"chart_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"chart_id":{"type":"string"},"cr":{"description":"Composed representation (DR / CS / SR / AR).","type":"object"},"created_at":{"format":"date-time","nullable":true,"type":"string"},"parent_chart_id":{"nullable":true,"type":"string"},"s3_key":{"type":"string"}},"type":"object"}}},"description":"The chart's CR (state-of-record) plus lineage metadata."},"401":{"description":"Missing or bad `X-Elai-Token`."},"404":{"description":"No charts document for the id."},"503":{"description":"Charts persistence (MongoDB) unavailable."}},"security":[{"elaiToken":[]}],"summary":"Admin/debug \u2014 return the stored CR for a chart id.","tags":["debug"]}},"/api/v1/health":{"get":{"description":"Returns 200 when the service is up and MongoDB accepts a ping (same\n`AI_CHARTS_MONGO_URI` as history/charts). Not gated by `X-Elai-Token`.\n","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}},"description":"Service healthy and database reachable."},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Persistence not configured or MongoDB unreachable."}},"summary":"Liveness/readiness probe","tags":["system"]}},"/api/v1/history":{"get":{"description":"Newest-first by uuid7 `_id`. Pass `before=<id>` to walk to the\nnext page. Each entry mirrors the document shape persisted by\nthe `/api/v1/chart/generate/*` routes; fields you don't recognise may\nbe ignored (new fields ship without a major-version bump).\n","parameters":[{"in":"query","name":"video_id","required":true,"schema":{"type":"string"}},{"in":"query","name":"user_id","required":true,"schema":{"type":"string"}},{"description":"Clamped server-side to [1, 200].","in":"query","name":"limit","required":false,"schema":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},{"description":"uuid7 cursor \u2014 return only entries with `id < before`.","in":"query","name":"before","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"items":{"properties":{"created_at":{"description":"ISO 8601 UTC, e.g. `2026-05-19T12:34:56Z`.","format":"date-time","type":"string"},"engine":{"properties":{"animation":{"type":"boolean"},"cost_usd":{"type":"number"},"input_tokens":{"type":"integer"},"latency_ms":{"type":"number"},"max_tokens":{"type":"integer"},"model":{"type":"string"},"output_tokens":{"type":"integer"},"temperature":{"type":"number"},"version":{"example":"v3","type":"string"}},"type":"object"},"error":{"description":"Only present when `status=\"error\"`.","type":"string"},"id":{"description":"uuid7, lexicographically sortable.","type":"string"},"include_alt":{"type":"boolean"},"items":{"description":"Only when `status=\"ok\"`. Generate-endpoint items plus `component_id` / `s3_key` for replay.","items":{"type":"object"},"type":"array"},"prompt":{"type":"string"},"rejection_reason":{"description":"Only present when `status=\"rejected\"`.","type":"string"},"seed":{"type":"string"},"service_version":{"type":"string"},"status":{"enum":["ok","rejected","error"],"type":"string"},"user_id":{"type":"string"},"video_id":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"}}},"description":"JSON envelope carrying 0 to `limit` chat history entries,\nnewest first.\n"},"400":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"Missing `video_id` / `user_id` or non-integer `limit`."},"401":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"Missing or invalid `X-Elai-Token` header."},"503":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"MongoDB unreachable. Endpoint fails loud (not empty) so the editor can show a \"history unavailable\" toast."}},"security":[{"elaiToken":[]}],"summary":"List chat history entries for a (video_id, user_id) pair.","tags":["chat-history"]}}}}
