"""wave~reader v2 — ONE page: map lens → spot story → intel rail + agent.
No tabs. One shared ``selected`` spot drives everything: picking it in
the search box (or the load default) re-tells the whole page — badges,
week strip, world model, climatology — then auto-fetches
the staged forecast, and the agent reads the same context. Exactly three
session states: the shared pick, the scored payload, the agent store.
Filters are one-way only.
All component-output ordering comes from :mod:`ui.contracts` — handlers
yield via ``fill(KEYS, …)`` and the wiring assembles outputs the same
way, so panels and wiring can't drift apart. On load, a background
thread warms caches for featured breaks (the default spot fetches live).
"""
from __future__ import annotations
import threading
import gradio as gr
from ui import _compat as C
from ui.contracts import AGENT_KEYS, FETCH_KEYS, SELECT_KEYS
from ui.panels import agent as agent_panel
from ui.panels import intel as intel_panel
from ui.panels import lens as lens_panel
from ui.panels import story as story_panel
_DEFAULT_SPOT = "Bells Beach"
_FEATURED = ["Snapper Rocks", "Bondi Beach", "Shipstern Bluff", "Kirra"]
_MODE_FORECAST = "🌊 forecast"
_MODE_AGENT = "🤖 agentic mode"
def _warm_caches(records: list[dict]) -> None:
"""Pre-fetch forecast + GEBCO grids for featured breaks (daemon thread)."""
def work():
for name in _FEATURED:
b = C.find_break(records, name)
if b is None:
continue
try:
C.get_scored_week(b, skill="intermediate", days=7)
except Exception: # noqa: BLE001 — warming must never break the UI
pass
try:
C.get_seafloor(b)
except Exception: # noqa: BLE001
pass
threading.Thread(target=work, daemon=True).start()
def build() -> gr.Blocks:
"""Assemble the demo (no launch — the Space entrypoint launches)."""
records = C.list_breaks()
vocab = C.index_breaks(records)
with gr.Blocks(title="wave~reader — one page to your next surf") as demo:
# Hero band: title + mode toggle on one line, engine chips (with
# the MCP note folded in) as a single compact strip underneath.
with gr.Column(elem_classes=["hero-band"]):
with gr.Row(elem_classes=["hero-row"]):
with gr.Column(scale=5, min_width=220):
gr.Markdown("# wave~reader", elem_classes=["hero-title"])
gr.Markdown("smart forecasting for australia's coast — 238 breaks scored "
"hour by hour (0–10) from live marine data, real seafloor data and "
"each spot's ideal conditions.", elem_classes=["hero-sub"])
with gr.Column(scale=2, min_width=280, elem_classes=["hero-right"]):
mode_toggle = gr.Radio(
[_MODE_FORECAST, _MODE_AGENT], value=_MODE_FORECAST,
show_label=False, elem_classes=["mode-toggle"],
container=False)
gr.Markdown(
C.engine_strip(
"mcp ready — /gradio_api/mcp/ · "
"score_week / rank_region_week / "
"explain_score"),
elem_classes=["engine-md"])
selected = gr.State(None)
scored = gr.State(None)
agent_store = gr.State(agent_panel.fresh_store())
# Two full views share the page: the forecast dashboard and the
# agent interface. The header toggle swaps them wholesale; both
# stay wired so switching is instant (no refetch).
with gr.Column(visible=True, elem_classes=["main-col"]) as main_col:
with gr.Row():
b = lens_panel.build_lens(records, vocab, selected)
s = story_panel.build_story(selected, scored)
with gr.Column(scale=3):
i = intel_panel.build_intel()
a = agent_panel.build_agent(selected, agent_store, records, vocab, visible=False)
# Footer: the machinery line — feed/scorer/world-model latencies —
# lives below the fold, in both modes (outside the swapped columns).
with gr.Column(elem_classes=["page-foot"]):
f = story_panel.build_footer()
def _switch_mode(mode: str):
agentic = mode == _MODE_AGENT
return gr.update(visible=not agentic), gr.update(visible=agentic)
mode_toggle.change(_switch_mode, inputs=mode_toggle,
outputs=[main_col, a["agent_col"]], api_name=False)
# Internal listeners stay off the API/MCP surface — only the three
# typed ``gr.api`` tools (score_week / rank_region_week /
# explain_score) are exposed.
comp = {**b, **s, **i, **a, **f,
"selected": selected, "scored": scored, "agent_store": agent_store}
select_outputs = [comp[k] for k in SELECT_KEYS]
fetch_outputs = [comp[k] for k in FETCH_KEYS]
agent_outputs = [comp[k] for k in AGENT_KEYS]
def _on_load():
"""First paint already tells a story: warm caches + default spot."""
_warm_caches(records)
record = C.find_break(records, _DEFAULT_SPOT)
return b["select_spot"](record, None, fly=False)
demo.load(_on_load, inputs=None, outputs=select_outputs, api_name=False).then(
s["fetch_forecast"], inputs=[selected, b["skill_dd"]], outputs=fetch_outputs,
show_progress="minimal", api_name=False,
)
# -- lens: one-way filters reframe the map + search choices --
b["state_dd"].change(
b["state_regions"], inputs=b["state_dd"], outputs=b["region_dd"],
api_name=False,
).then(
b["filter_changed"], inputs=[b["state_dd"], b["region_dd"], b["skill_dd"]],
outputs=[b["map_plot"], b["search_dd"]], api_name=False,
)
b["region_dd"].change(
b["filter_changed"], inputs=[b["state_dd"], b["region_dd"], b["skill_dd"]],
outputs=[b["map_plot"], b["search_dd"]], api_name=False,
)
# the Skill filter doubles as the scoring tier: refilter the map,
# then rescore the picked spot's week at that level
b["skill_dd"].change(
b["filter_changed"], inputs=[b["state_dd"], b["region_dd"], b["skill_dd"]],
outputs=[b["map_plot"], b["search_dd"]], api_name=False,
).then(
s["fetch_forecast"], inputs=[selected, b["skill_dd"]], outputs=fetch_outputs,
show_progress="minimal", api_name=False,
)
# -- picking a spot re-tells the page, then auto-fetches its week --
b["search_dd"].change(
b["pick_by_name"],
inputs=[b["search_dd"], b["state_dd"], b["region_dd"], b["skill_dd"]],
outputs=select_outputs, api_name=False,
).then(
s["fetch_forecast"], inputs=[selected, b["skill_dd"]], outputs=fetch_outputs,
show_progress="minimal", api_name=False,
)
# -- intel rail: pill tabs swap the three feature columns --
i["tab_picker"].change(i["pick"], inputs=i["tab_picker"],
outputs=[i["report_col"], i["world_col"], i["climate_col"]],
api_name=False)
# the report button lives in the intel rail; its handler stays story-side
i["narrate_btn"].click(s["narrate"], inputs=scored, outputs=i["report_md"],
show_progress="minimal", api_name=False)
# -- agent: chat streams trace cards + meter + verdict + charts --
chat_inputs = [a["msg"], a["chatbot"], b["skill_dd"], a["token_box"],
selected, agent_store, a["steps_slider"], a["state_dd"],
a["region_dd"]]
a["send_btn"].click(
a["chat"], inputs=chat_inputs, outputs=agent_outputs,
show_progress="minimal", api_name=False,
)
a["msg"].submit(
a["chat"], inputs=chat_inputs, outputs=agent_outputs,
show_progress="minimal", api_name=False,
)
a["spot_dd"].change(
a["show_spot"], inputs=[a["spot_dd"], agent_store],
outputs=[a["agent_strip"]], api_name=False,
)
a["clear_btn"].click(a["clear"], inputs=None, outputs=agent_outputs,
api_name=False)
# Chips fill AND send in one click (set msg, then run the chat chain).
for chip, prompt in zip(a["chips"], agent_panel.CHIP_PROMPTS):
chip.click(a["chip_text"](prompt), inputs=None, outputs=[a["msg"]],
api_name=False).then(
a["chat"], inputs=chat_inputs, outputs=agent_outputs,
show_progress="minimal", api_name=False,
)
# -- agent filters: page-one pattern, State rewrites Region choices --
a["state_dd"].change(
a["state_regions"], inputs=a["state_dd"], outputs=a["region_dd"],
api_name=False,
)
# -- agent context line: what the bar inherits from the page --
ctx_outputs = [a["ctx_md"]]
ctx_inputs = [b["skill_dd"], selected, a["steps_slider"],
a["state_dd"], a["region_dd"]]
for trigger in (selected, b["skill_dd"], a["steps_slider"],
a["state_dd"], a["region_dd"]):
trigger.change(
a["ctx"], inputs=ctx_inputs,
outputs=ctx_outputs, api_name=False,
)
return demo