# `live/` — the V3 live broadcast service

Where the production V3 connection plugs into the **`core/`** engine + renderer. The demo (`../demo/`)
exercises the same `core/` through file-backed implementations, so production is a **swap of seams, not
a rewrite** (see [`../../../docs/V3-broadcast.md` §6](../../../docs/V3-broadcast.md)). The live
**data path is implemented and tested end-to-end**; what remains is the V2 input adapter and the
OBS→RTMP output.

| File | §6 seam | Status | Sub-phase |
|---|---|---|---|
| `streaming_score.py` | #2 incremental scoring | ✅ **done** — re-runs the unchanged `enrich` on the growing prefix (no forked logic); standings live, commentary on a finalisation lag; `capture_ai` surfaces beats for the editor | B-A |
| `live_feed_server.py` | #3 engine → renderer | ✅ **done** — stdlib threading **SSE** server, serves static page + `/events`, late-joiner backlog | B-B |
| `web/live_feed.js` | #3 engine → renderer (client) | ✅ **done** — `LiveFeed` subclasses `FileFeed`, applies init/delta/result, re-derives via `_ingest` | B-B |
| `ai_live_editor.py` | live commentary/camera AI | ✅ **done** — bounded per-tick edit under a latency budget → deterministic fallback (Haiku); reuses the offline `ai_director`/`ai_schema` | B-C |
| `serve_live.py` | demo driver | ✅ **done** — replays the recorded race through the above (`--ai` to enable the editor) | — |
| `tts/synth.py` | #5 neural TTS — **three roles** (Lead, Tactical, Rules; legacy m/f compatible) | ✅ implemented (Google Cloud TTS) | B-D |
| `live_adapter.py` | #1 data → engine | ✅ **done** — GPS→frame transform + de-duped polling, DI-friendly callables. V2 HTTP is one wiring of those callables; **`v3_adapter.py`** is the V3 wiring (production). | B-A |
| `v3_adapter.py` | #1 data → engine (V3) | ✅ **done & tested** — wires the adapter's callables to V3 reads (`reads.boat_position_at` + `broadcast_feed.build_feed`); **no V2 dependency** in the live read path. End-to-end with StreamingScorer in `v3/ingest/tests/test_v3_live_adapter.py`. | B-A |
| `director_obs.py` | #4 presentation → output | 🟡 **to-spec** — `OBSController` over obs-websocket v5 (set browser-source URL, start/stop stream + record); needs a live OBS to verify | B-C |

Legend: ✅ done & tested · 🟡 implemented to-spec, needs the external system (V2 / OBS) to verify · ⛔ stub.

## The AI director (status)

The director (subject + timing → camera + commentary) is **rule-based and engine-authoritative**
(`v3/scoring/salience.py` + `director.py` + the commentary engine). On top sits a **bounded AI layer**
— `ai_live_editor.py` (live, Haiku) and the offline `v3/scoring/ai_director.py` (Opus) — which per beat
only chooses **air-or-drop** (pacing) and **which engine-proposed camera shot** to frame. It never writes
text, names boats, or invents camera moves; with no `ANTHROPIC_API_KEY` it's a deterministic no-op.
✅ done & tested. To make the director *more* AI-driven (AI-chosen subject/timing, wider camera
authority, AI-authored colour) see the roadmap in [`../../../docs/V3-broadcast.md` § 0](../../../docs/V3-broadcast.md).

Every bounded-LLM call (offline `ai_director`/`ai_author`, live `ai_live_editor`) goes through one
`v3/scoring/commentary_llm.py`: a process-wide client, the forced-single-tool request, the cache-shaped
system (invariant prefix cached, per-call directive after the breakpoint), and the live latency bound.

## Agent / MCP control layer

Beside the data path sits a small **agent-control layer** so an LLM can *run* the show (not just render
it) — read-only by default, actuating only when a box opts in. It is the mechanism behind
[`../../../docs/plans/trwc-obs-production-workflow.md` §9](../../../docs/plans/trwc-obs-production-workflow.md);
the human operator owns the switch box, so nothing here can cut a scene or touch the stream unless
`BROADCAST_ALLOW_CONTROL` is set on a VR-owned / unmanned box.

| File | What it is |
|---|---|
| `obs_mcp_server.py` | OBS control as MCP tools (scenes, RTMP + recording, OBS upload health) — read tools always, write tools gated |
| `youtube_mcp_server.py` | YouTube side as MCP tools (viewers, ingest health, title/privacy, go-live) — same read/write split |
| `broadcast_mcp_server.py` | Both on one FastMCP + the fused `broadcast_health` glance; each module still runs standalone |
| `broadcast_control.py` | The single WRITE gate `control_enabled()` (the `BROADCAST_ALLOW_CONTROL` check) — shared so servers + agent can't drift |
| `director_agent.py` | The **programmatic** co-pilot: a bounded Tool Runner over the same OBS/YouTube tool functions, in-process beside OBS. `assess()` = one health-read+report pass; `monitor()` loops. Read-only default (Haiku); control mode Opus |

The MCP servers make the tools callable from a human-driven agent (Claude Code); `director_agent.py` runs
them from Claude programmatically. Both share the same tool functions (one source of truth) and the one
`broadcast_control` gate. Tests: `core/tests/test_broadcast_control.py`, `core/tests/test_director_agent.py`.

## How it fits together
```
V3 store ───v3_adapter (or V2 live_state ──live_adapter)───▶ {meta,course,teams,frames}   ✅ V3 wired
                                   │
                          streaming_score (wraps core enrich)      ✅ B-A
                                   │ {standings, events, aiBeats} deltas
                          ai_live_editor (bounded LLM, per tick)   ✅ edits colour beats before they air
                                   │
                          live_feed_server (SSE)                   ✅ B-B
                                   │  BroadcastFeed contract
   core/web (?feed=live / config.feed='live' → web/live_feed.js LiveFeed) ──▶ renderer (unchanged)  ✅
                                   │
                          OBS (browser source) ──▶ RTMP YouTube    (🟡 director_obs.py drives OBS; needs a live OBS)
```

## Try it
```
.venv-demo/Scripts/python live/serve_live.py            # replay the demo race live over SSE
.venv-demo/Scripts/python live/serve_live.py --ai       # + the live bounded-LLM editor (Haiku)
# then open the printed URL:  http://localhost:8765/core/web/index.html?feed=live
```

## The broadcast presents; it never decides

Three things follow, and they are load-bearing:

- **Optional.** A race must run start to finish with no broadcast running at all. Nothing here writes
  to the race plane — not a gun, not a stop, not a result. Whether a race has finished is decided
  **server-side from the finish line** (`v3/ingest/finish_detector.py`); this producer finds out by
  reading `GET /v3/manage/instances` like any other client.
- **Several at once, explicitly scoped.** Each producer is launched with the race it owns
  (`--instance`) or the course it follows (`--follow --course`). Nothing negotiates; two producers
  wanting different races get different arguments. A second **language** is not a second producer —
  one feed carries every language and the renderer picks with `?lang=`.
- **Source-agnostic.** Live, replay and simulation are the same job. The source appears only in the
  *description* of what is on screen; no behaviour branches on it. What legitimately differs is
  whether the data window is **unbounded** (keep polling) or **bounded** (a fixed span, replayed) —
  that is a data-arrival property, not a statement about where the boats are.

## One producer, every deployment
The producer must not care where the backend is, where it itself runs, or whether the races are live,
replayed or simulated. Three switches cover it, and they compose:

| | flag | what it does |
|---|---|---|
| **which race** | `--instance <id>` | pins ONE race (the classic mode; a relaunch per heat) |
| | `--follow` | follows the operator's races (scope with `--owner`; `--course` is an optional FILTER, not a requirement — a race carries its own course): stays with the race on air until it has a **winner** (plus the hold on the finish) or the operator closes it, then moves to the next — joined in progress, elapsed track back-filled. One process for a whole regatta day. The renderer **swaps the race in place**; it never reloads. |
| **where the data is** | `$VR_DATABASE_URL` | store-backed (co-located producer: no HTTP hop) |
| | `--read-base <url>` | positions over the read API |
| | `--follow --read-base` *(no DSN)* | **entirely over REST** (`rest_source.py`) — races, roster, course, wind and the join back-fill too. No database credentials, so the same command runs against a local backend or gcloud, from the render PC or from the cloud. |

```
# whole day, no database, local backend or gcloud — only the URL changes
python live/serve_v3_live.py --live --follow --course <courseId> --read-base http://127.0.0.1:8080
python live/serve_v3_live.py --live --follow --course <courseId> --read-base https://apps.viewregatta.com
```
Live, replay and simulation are the *same* client contract (the simulator is a client of these very
endpoints), so the producer cannot tell them apart — which is the point.

The hand-over policy itself is `race_queue.py` (`OnAirQueue` + `StartedRaces`), shared verbatim with
the sim's operator-driven bridge so the simulator exercises exactly what production runs.

**Between races it shows the venue, not a blank.** A producer pointed at a race day has nothing on
air for its first minutes and again after every finish. Publishing nothing then is exactly what an
empty OBS browser source looks like, so `broadcast_following` puts up a **waiting presentation**: the
course, the fleet of the next allocated-but-unstarted race, and `meta.idle` — which is what switches
the renderer to its area-panning look-down camera instead of a race pose. The boats are published
**team-less**, because naming teams would assert a line-up for a race that has not begun, and no
scorer runs, because there is no race to score.

Two failure modes it exists to prevent, both seen live: a blank page while the operator gets ready,
and a presentation that *lies* — a placeholder gun making the scene claim a race is eleven minutes in,
with standings and commentary, while the fleet is still sitting on the line. It answers with nothing
only while nobody is allocated yet, and retries each poll rather than publishing an empty sea.

This was for a long time supplied **only by the simulator**, which meant the picture was right in
rehearsal and blank in production — the worst way round for something whose whole job is to be seen.

## Reusing the core
Each module imports the shared engine from `../../scoring` (a `sys.path` insert at the top). The
renderer is reused as-is: select the live source via `core/web/config.js` `feed:'live'` **or** the
`?feed=live` URL override; everything else in `core/web/` is identical to the demo.

## Acceptance
- **B-A** golden-file parity ✅ — `core/tests/test_streaming_parity.py` (marked `slow`): streaming the
  recorded `real.json` tick-for-tick accumulates to the batch `enrich` events + standings + result.
- **B-B** transport ✅ — `core/tests/test_live_feed_transport.py`: SSE init/delta/result + late-joiner
  backlog; the renderer runs on `LiveFeed` with no render-logic changes.
- **B-C** live AI editor ✅ — `core/tests/test_live_ai_editor.py`: no-key identity, slow-model fallback
  within budget, in-budget re-frame/drop, `capture_ai` beat parity. OBS→RTMP first private YouTube test
  still pending (needs an OBS instance).
- **B-D/B-E** neural TTS + boat video + real wind; dress rehearsal on recorded/synthetic concurrency.
