Spaces:
Running
Running
Commit ·
c5672b8
1
Parent(s): 69d722b
finished!!
Browse files- AGENTS.md +8 -4
- README.md +12 -21
- scripts/deploy_space.sh +30 -0
- space-config.yaml +11 -0
- tests/test_agent_viz.py +103 -17
- ui/app.py +16 -5
- ui/contracts.py +2 -2
- ui/panels/agent.py +228 -73
- ui/panels/intel.py +29 -7
- ui/panels/lens.py +3 -45
- ui/panels/story.py +52 -16
- ui/theme.py +96 -1
AGENTS.md
CHANGED
|
@@ -49,16 +49,20 @@ survives only as the fallback layer in `ui/_compat.py`.
|
|
| 49 |
## Deploying to HF Spaces
|
| 50 |
|
| 51 |
- The Space `lucashudsn/wavereader` (public) *is* this repo — remote
|
| 52 |
-
`space`. Ship with `
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
| 55 |
`HfApi().get_space_runtime("lucashudsn/wavereader")` until `RUNNING`.
|
| 56 |
-
-
|
| 57 |
`sdk_version` in lockstep with the gradio pin in `requirements.txt`
|
| 58 |
(regenerate that file only via `uv export --no-hashes
|
| 59 |
--format requirements-txt --no-dev -o requirements.txt` — other
|
| 60 |
invocations can leave stray progress lines that break pip), and
|
| 61 |
`python_version` in lockstep with `pyproject.toml`'s `requires-python`.
|
|
|
|
|
|
|
| 62 |
- `HF_TOKEN` is a Space secret (set once via *Settings → Variables and
|
| 63 |
secrets* or `HfApi().add_space_secret`); `WR_*` overrides can be added
|
| 64 |
the same way. `.cache/` is ephemeral on the Space and rebuilds lazily —
|
|
|
|
| 49 |
## Deploying to HF Spaces
|
| 50 |
|
| 51 |
- The Space `lucashudsn/wavereader` (public) *is* this repo — remote
|
| 52 |
+
`space`. Ship with `scripts/deploy_space.sh` (builds a throwaway commit
|
| 53 |
+
that prepends `space-config.yaml` to `README.md` and pushes it to
|
| 54 |
+
`space:main` — Spaces need YAML frontmatter, GitHub doesn't have to see
|
| 55 |
+
it), then `git push origin v2` to keep GitHub in sync. The Space builds
|
| 56 |
+
from the pushed commit; watch *Logs → Build* on the Space page or poll
|
| 57 |
`HfApi().get_space_runtime("lucashudsn/wavereader")` until `RUNNING`.
|
| 58 |
+
- `space-config.yaml` is the Space config. Keep
|
| 59 |
`sdk_version` in lockstep with the gradio pin in `requirements.txt`
|
| 60 |
(regenerate that file only via `uv export --no-hashes
|
| 61 |
--format requirements-txt --no-dev -o requirements.txt` — other
|
| 62 |
invocations can leave stray progress lines that break pip), and
|
| 63 |
`python_version` in lockstep with `pyproject.toml`'s `requires-python`.
|
| 64 |
+
If you change Space settings via the web UI, HF rewrites the Space's
|
| 65 |
+
`README.md` — mirror anything you care about back into the yaml.
|
| 66 |
- `HF_TOKEN` is a Space secret (set once via *Settings → Variables and
|
| 67 |
secrets* or `HfApi().add_space_secret`); `WR_*` overrides can be added
|
| 68 |
the same way. `.cache/` is ephemeral on the Space and rebuilds lazily —
|
README.md
CHANGED
|
@@ -1,17 +1,3 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: wave~reader
|
| 3 |
-
emoji: 🏄
|
| 4 |
-
colorFrom: blue
|
| 5 |
-
colorTo: indigo
|
| 6 |
-
sdk: gradio
|
| 7 |
-
sdk_version: 6.26.0
|
| 8 |
-
python_version: "3.14"
|
| 9 |
-
app_file: app.py
|
| 10 |
-
license: mit
|
| 11 |
-
pinned: false
|
| 12 |
-
short_description: Surf-forecast world model for the Australian coast
|
| 13 |
-
---
|
| 14 |
-
|
| 15 |
# wave~reader
|
| 16 |
|
| 17 |
**A surf-forecasting world model for the Australian coast: 238 breaks, 168
|
|
@@ -130,21 +116,26 @@ is session-only and never logged.
|
|
| 130 |
## Deploying to HF Spaces
|
| 131 |
|
| 132 |
The app is live at [huggingface.co/spaces/lucashudsn/wavereader](https://huggingface.co/spaces/lucashudsn/wavereader).
|
| 133 |
-
The Space
|
| 134 |
-
YAML
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
```sh
|
| 138 |
-
|
| 139 |
-
git push origin
|
| 140 |
```
|
| 141 |
|
| 142 |
The push asks for credentials: username `lucashudsn`, password = an HF
|
| 143 |
token with write access (https://huggingface.co/settings/tokens). The
|
| 144 |
-
build takes a few minutes; watch it under the Space's
|
| 145 |
or poll `HfApi().get_space_runtime("lucashudsn/wavereader")`.
|
| 146 |
|
| 147 |
-
Secrets live in the Space's
|
| 148 |
`HF_TOKEN` is already set, which is what lets the agent and narrator work
|
| 149 |
for visitors. `WR_MODEL` / `WR_PROVIDER` / `WR_BASE_URL` can be added the
|
| 150 |
same way if you ever want to reroute the LLM. Caches under `.cache/` are
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# wave~reader
|
| 2 |
|
| 3 |
**A surf-forecasting world model for the Australian coast: 238 breaks, 168
|
|
|
|
| 116 |
## Deploying to HF Spaces
|
| 117 |
|
| 118 |
The app is live at [huggingface.co/spaces/lucashudsn/wavereader](https://huggingface.co/spaces/lucashudsn/wavereader).
|
| 119 |
+
The Space _is_ this git repo — the `space` remote points at it. Spaces
|
| 120 |
+
read their config from YAML frontmatter at the top of `README.md`, but
|
| 121 |
+
GitHub renders that block as an ugly metadata table, so it lives in
|
| 122 |
+
`space-config.yaml` instead and `deploy_space.sh` prepends it in a
|
| 123 |
+
throwaway commit at push time. Keep `sdk_version` in lockstep with the
|
| 124 |
+
gradio pin in `requirements.txt`. If you change Space settings through
|
| 125 |
+
the web UI, HF commits them into the Space's `README.md` — mirror
|
| 126 |
+
anything you care about back into `space-config.yaml`.
|
| 127 |
|
| 128 |
```sh
|
| 129 |
+
scripts/deploy_space.sh # ship what's committed on v2 (pass a ref to override)
|
| 130 |
+
git push origin branch_name # keep GitHub in sync
|
| 131 |
```
|
| 132 |
|
| 133 |
The push asks for credentials: username `lucashudsn`, password = an HF
|
| 134 |
token with write access (https://huggingface.co/settings/tokens). The
|
| 135 |
+
build takes a few minutes; watch it under the Space's _Logs → Build_ tab
|
| 136 |
or poll `HfApi().get_space_runtime("lucashudsn/wavereader")`.
|
| 137 |
|
| 138 |
+
Secrets live in the Space's _Settings → Variables and secrets_ —
|
| 139 |
`HF_TOKEN` is already set, which is what lets the agent and narrator work
|
| 140 |
for visitors. `WR_MODEL` / `WR_PROVIDER` / `WR_BASE_URL` can be added the
|
| 141 |
same way if you ever want to reroute the LLM. Caches under `.cache/` are
|
scripts/deploy_space.sh
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Ship a committed ref to the HF Space without keeping the Space's YAML
|
| 3 |
+
# frontmatter in the repo copy of README.md (GitHub renders that block as
|
| 4 |
+
# an ugly metadata table). Builds a throwaway commit that prepends
|
| 5 |
+
# space-config.yaml to the README, pushes it to the `space` remote, then
|
| 6 |
+
# removes the worktree. The repo README stays clean; HF gets its config.
|
| 7 |
+
#
|
| 8 |
+
# scripts/deploy_space.sh [ref] [remote] # defaults: v2, space
|
| 9 |
+
set -euo pipefail
|
| 10 |
+
|
| 11 |
+
ref="${1:-v2}"
|
| 12 |
+
remote="${2:-space}"
|
| 13 |
+
root="$(git rev-parse --show-toplevel)"
|
| 14 |
+
config="$root/space-config.yaml"
|
| 15 |
+
tmp="$(mktemp -d)"
|
| 16 |
+
trap 'git worktree remove --force "$tmp" >/dev/null 2>&1 || rm -rf "$tmp"' EXIT
|
| 17 |
+
|
| 18 |
+
[ -f "$config" ] || { echo "missing $config" >&2; exit 1; }
|
| 19 |
+
|
| 20 |
+
git worktree add --quiet --detach "$tmp" "$ref"
|
| 21 |
+
{ printf -- '---\n'; cat "$config"; printf -- '---\n\n'; cat "$tmp/README.md"; } > "$tmp/README.md.next"
|
| 22 |
+
mv "$tmp/README.md.next" "$tmp/README.md"
|
| 23 |
+
|
| 24 |
+
git -C "$tmp" add README.md
|
| 25 |
+
git -C "$tmp" commit --quiet -m "deploy: prepend Space config to README (from $ref)"
|
| 26 |
+
# Deploys are throwaway commits, so the Space's main is rewritten every
|
| 27 |
+
# time — only the tip (the tree) matters to the build.
|
| 28 |
+
git -C "$tmp" push --force "$remote" HEAD:refs/heads/main
|
| 29 |
+
|
| 30 |
+
echo "pushed $ref (+ Space config) to $remote:main — watch the Space's Logs → Build tab"
|
space-config.yaml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
title: wave~reader
|
| 2 |
+
emoji: 🏄
|
| 3 |
+
colorFrom: blue
|
| 4 |
+
colorTo: indigo
|
| 5 |
+
sdk: gradio
|
| 6 |
+
sdk_version: 6.26.0
|
| 7 |
+
python_version: "3.14"
|
| 8 |
+
app_file: app.py
|
| 9 |
+
license: mit
|
| 10 |
+
pinned: false
|
| 11 |
+
short_description: Surf-forecast world model for the Australian coast
|
tests/test_agent_viz.py
CHANGED
|
@@ -27,29 +27,32 @@ CHATBOT = contracts.AGENT_KEYS.index("chatbot")
|
|
| 27 |
_BELLS_ARGS = {"spot_name": "Bells Beach", "region": "Victoria"}
|
| 28 |
|
| 29 |
|
| 30 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
def stream(message, **kwargs):
|
| 32 |
yield ("step", {"n": 1})
|
| 33 |
-
yield
|
| 34 |
-
yield ("tool_result", {"name": tool_name, "arguments": args,
|
| 35 |
-
"observation": obs, "summary": "ok",
|
| 36 |
-
"ms": 1.0, "preview": ""})
|
| 37 |
yield ("final", final)
|
| 38 |
-
yield ("usage", {"steps": 1, "
|
| 39 |
-
|
| 40 |
-
return stream
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def _last_emit(monkeypatch, obs, tool_name: str, args: dict,
|
| 44 |
-
final: str = "**Best: Bells Beach @ Sat am — 6/10.**"):
|
| 45 |
-
monkeypatch.setattr(C, "agent_run_stream",
|
| 46 |
-
_fake_stream(obs, tool_name, args, final))
|
| 47 |
records = C.list_breaks()
|
| 48 |
selected = C.find_break(records, "Bells Beach")
|
| 49 |
assert selected is not None, "Bells Beach must resolve from the catalogue"
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
agent_panel.fresh_store(), "standard",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
return outs, outs[-1]
|
| 54 |
|
| 55 |
|
|
@@ -160,3 +163,86 @@ def test_scored_turn_still_charts_strip_midway(monkeypatch):
|
|
| 160 |
charted = [o[strip] for o in outs if isinstance(o[strip], go.Figure)]
|
| 161 |
assert charted, "mid-turn week strip never rendered"
|
| 162 |
assert not isinstance(last[strip], go.Figure) or charted[-1] is last[strip]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
_BELLS_ARGS = {"spot_name": "Bells Beach", "region": "Victoria"}
|
| 28 |
|
| 29 |
|
| 30 |
+
def _tool_events(name: str, args: dict, obs) -> list[tuple]:
|
| 31 |
+
return [("tool_call", {"name": name, "arguments": args}),
|
| 32 |
+
("tool_result", {"name": name, "arguments": args,
|
| 33 |
+
"observation": obs, "summary": "ok",
|
| 34 |
+
"ms": 1.0, "preview": ""})]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _run(monkeypatch, events: list[tuple], final: str, query: str):
|
| 38 |
def stream(message, **kwargs):
|
| 39 |
yield ("step", {"n": 1})
|
| 40 |
+
yield from events
|
|
|
|
|
|
|
|
|
|
| 41 |
yield ("final", final)
|
| 42 |
+
yield ("usage", {"steps": 1, "profile": "standard"})
|
| 43 |
+
monkeypatch.setattr(C, "agent_run_stream", stream)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
records = C.list_breaks()
|
| 45 |
selected = C.find_break(records, "Bells Beach")
|
| 46 |
assert selected is not None, "Bells Beach must resolve from the catalogue"
|
| 47 |
+
return list(agent_panel.chat_fn(
|
| 48 |
+
query, [], "intermediate", "", selected,
|
| 49 |
+
agent_panel.fresh_store(), "standard", C.ALL, C.ALL, records))
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _last_emit(monkeypatch, obs, tool_name: str, args: dict,
|
| 53 |
+
final: str = "**Best: Bells Beach @ Sat am — 6/10.**",
|
| 54 |
+
query: str = "test question"):
|
| 55 |
+
outs = _run(monkeypatch, _tool_events(tool_name, args, obs), final, query)
|
| 56 |
return outs, outs[-1]
|
| 57 |
|
| 58 |
|
|
|
|
| 163 |
charted = [o[strip] for o in outs if isinstance(o[strip], go.Figure)]
|
| 164 |
assert charted, "mid-turn week strip never rendered"
|
| 165 |
assert not isinstance(last[strip], go.Figure) or charted[-1] is last[strip]
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _scored_obs(spot_name: str, region: str, score: float) -> dict:
|
| 169 |
+
hours = [{"time": f"2026-09-09T{h:02d}:00", "score": score - (h % 3) * 0.5,
|
| 170 |
+
"wave_height_m": 1.2, "wave_period_s": 9.0,
|
| 171 |
+
"wind_speed_kt": 7.0, "wind_direction_deg": 220,
|
| 172 |
+
"daylight": True} for h in range(6, 18)]
|
| 173 |
+
return {"spot": {"name": spot_name, "region": region},
|
| 174 |
+
"skill": "intermediate", "scored": hours,
|
| 175 |
+
"daily": [], "best": hours[0], "stub": True}
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def test_where_query_sweeps_region_when_agent_didnt_rank(monkeypatch):
|
| 179 |
+
# A recommendation question the agent answered without a rank sweep
|
| 180 |
+
# still closes on the recommended-scores leaderboard: the panel runs a
|
| 181 |
+
# deterministic engine sweep of the region named in the question
|
| 182 |
+
# (NSW -> New South Wales), never the viewed spot's unrelated week.
|
| 183 |
+
sweep = {"region": "New South Wales", "skill": "intermediate", "count": 2,
|
| 184 |
+
"spots": [
|
| 185 |
+
{"name": "Bondi Beach", "region": "Sydney",
|
| 186 |
+
"best_score": 6.5, "best_time": "2026-09-12T07:00"},
|
| 187 |
+
{"name": "Byron Bay", "region": "Northern Rivers",
|
| 188 |
+
"best_score": 5.5, "best_time": "2026-09-12T08:00"},
|
| 189 |
+
]}
|
| 190 |
+
monkeypatch.setattr(C, "api_rank_region_week",
|
| 191 |
+
lambda region, skill="intermediate", limit=10: sweep)
|
| 192 |
+
monkeypatch.setattr(C, "get_scored_week", lambda *a, **k: (_ for _ in ()).throw(
|
| 193 |
+
AssertionError("viewed-spot fallback must not run when the sweep hits")))
|
| 194 |
+
obs = _tools.get_spot_knowledge(**_BELLS_ARGS)
|
| 195 |
+
assert "error" not in obs
|
| 196 |
+
_outs, last = _last_emit(
|
| 197 |
+
monkeypatch, obs, "get_spot_knowledge", _BELLS_ARGS,
|
| 198 |
+
query="Where should I surf in NSW this weekend as a beginner?")
|
| 199 |
+
fig = last[AGENT_STRIP]
|
| 200 |
+
assert isinstance(fig, go.Figure)
|
| 201 |
+
assert any(getattr(t, "orientation", None) == "h" for t in fig.data)
|
| 202 |
+
assert "recommended breaks — New South Wales" in last[CHATBOT][-1]["content"]
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def test_seafloor_query_prefers_depth_map_over_leaderboard(monkeypatch):
|
| 206 |
+
# When the question asks about the seafloor, the depth map wins even
|
| 207 |
+
# with a rank sweep payload on the table — the chart matches the
|
| 208 |
+
# question, not the tool order.
|
| 209 |
+
rank_obs = {"region": "Victoria", "skill": "intermediate", "count": 1,
|
| 210 |
+
"spots": [{"name": "Bells Beach", "region": "Surf Coast",
|
| 211 |
+
"best_score": 7.5, "best_time": "2026-09-12T06:00"}]}
|
| 212 |
+
seafloor_obs = _tools.get_seafloor_profile(**_BELLS_ARGS)
|
| 213 |
+
assert "error" not in seafloor_obs
|
| 214 |
+
grid = {"lats": [-38.31, -38.30, -38.29], "lngs": [144.29, 144.30, 144.31],
|
| 215 |
+
"elev": [-20.0, -12.0, -6.0, -14.0, -8.0, -4.0,
|
| 216 |
+
-10.0, -5.0, -1.0],
|
| 217 |
+
"n": 3, "center": {"lat": -38.30, "lng": 144.30}}
|
| 218 |
+
monkeypatch.setattr(C, "get_seafloor",
|
| 219 |
+
lambda br, radius_km=1.2: {"grid": grid, "analysis": "",
|
| 220 |
+
"stats": {}})
|
| 221 |
+
events = _tool_events("rank_region_week", {"region": "Victoria"}, rank_obs)
|
| 222 |
+
events += _tool_events("get_seafloor_profile", _BELLS_ARGS, seafloor_obs)
|
| 223 |
+
outs = _run(monkeypatch, events,
|
| 224 |
+
"**Best: Bells Beach @ Sat am — 6/10.**",
|
| 225 |
+
"What's the seafloor like at Bells Beach?")
|
| 226 |
+
fig = outs[-1][AGENT_STRIP]
|
| 227 |
+
assert isinstance(fig, go.Figure)
|
| 228 |
+
assert any(t.type == "heatmap" for t in fig.data)
|
| 229 |
+
assert not any(getattr(t, "orientation", None) == "h" for t in fig.data)
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def test_compare_query_charts_multi_spot_scores(monkeypatch):
|
| 233 |
+
# Two score_week calls in one turn -> the turn closes on the
|
| 234 |
+
# side-by-side best-score compare, not just the last spot's strip.
|
| 235 |
+
events = []
|
| 236 |
+
for name, region, score in (("Bells Beach", "Victoria", 7.0),
|
| 237 |
+
("Snapper Rocks", "Queensland", 5.0)):
|
| 238 |
+
args = {"spot_name": name, "region": region}
|
| 239 |
+
events += _tool_events("score_week", args,
|
| 240 |
+
_scored_obs(name, region, score))
|
| 241 |
+
outs = _run(monkeypatch, events,
|
| 242 |
+
"**Best: Bells Beach @ Sun am — 7/10.**",
|
| 243 |
+
"Compare Bells Beach and Snapper Rocks this weekend.")
|
| 244 |
+
last = outs[-1]
|
| 245 |
+
fig = last[AGENT_STRIP]
|
| 246 |
+
assert isinstance(fig, go.Figure)
|
| 247 |
+
assert any(getattr(t, "orientation", None) == "h" for t in fig.data)
|
| 248 |
+
assert "best-score compare (2 spots)" in last[CHATBOT][-1]["content"]
|
ui/app.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
No tabs. One shared ``selected`` spot drives everything: picking it in
|
| 4 |
the search box (or the load default) re-tells the whole page — badges,
|
| 5 |
-
week strip, world model, climatology
|
| 6 |
the staged forecast, and the agent reads the same context. Exactly three
|
| 7 |
session states: the shared pick, the scored payload, the agent store.
|
| 8 |
Filters are one-way only.
|
|
@@ -64,8 +64,9 @@ def build() -> gr.Blocks:
|
|
| 64 |
with gr.Row(elem_classes=["hero-row"]):
|
| 65 |
with gr.Column(scale=5, min_width=220):
|
| 66 |
gr.Markdown("# wave~reader", elem_classes=["hero-title"])
|
| 67 |
-
gr.Markdown("
|
| 68 |
-
"
|
|
|
|
| 69 |
with gr.Column(scale=2, min_width=280, elem_classes=["hero-right"]):
|
| 70 |
mode_toggle = gr.Radio(
|
| 71 |
[_MODE_FORECAST, _MODE_AGENT], value=_MODE_FORECAST,
|
|
@@ -94,6 +95,11 @@ def build() -> gr.Blocks:
|
|
| 94 |
|
| 95 |
a = agent_panel.build_agent(selected, agent_store, records, vocab, visible=False)
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
def _switch_mode(mode: str):
|
| 98 |
agentic = mode == _MODE_AGENT
|
| 99 |
return gr.update(visible=not agentic), gr.update(visible=agentic)
|
|
@@ -104,7 +110,7 @@ def build() -> gr.Blocks:
|
|
| 104 |
# Internal listeners stay off the API/MCP surface — only the three
|
| 105 |
# typed ``gr.api`` tools (score_week / rank_region_week /
|
| 106 |
# explain_score) are exposed.
|
| 107 |
-
comp = {**b, **s, **i, **a,
|
| 108 |
"selected": selected, "scored": scored, "agent_store": agent_store}
|
| 109 |
select_outputs = [comp[k] for k in SELECT_KEYS]
|
| 110 |
fetch_outputs = [comp[k] for k in FETCH_KEYS]
|
|
@@ -151,7 +157,12 @@ def build() -> gr.Blocks:
|
|
| 151 |
s["fetch_forecast"], inputs=[selected, b["skill_dd"]], outputs=fetch_outputs,
|
| 152 |
show_progress="minimal", api_name=False,
|
| 153 |
)
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
show_progress="minimal", api_name=False)
|
| 156 |
# -- agent: chat streams trace cards + meter + verdict + charts --
|
| 157 |
chat_inputs = [a["msg"], a["chatbot"], b["skill_dd"], a["token_box"],
|
|
|
|
| 2 |
|
| 3 |
No tabs. One shared ``selected`` spot drives everything: picking it in
|
| 4 |
the search box (or the load default) re-tells the whole page — badges,
|
| 5 |
+
week strip, world model, climatology — then auto-fetches
|
| 6 |
the staged forecast, and the agent reads the same context. Exactly three
|
| 7 |
session states: the shared pick, the scored payload, the agent store.
|
| 8 |
Filters are one-way only.
|
|
|
|
| 64 |
with gr.Row(elem_classes=["hero-row"]):
|
| 65 |
with gr.Column(scale=5, min_width=220):
|
| 66 |
gr.Markdown("# wave~reader", elem_classes=["hero-title"])
|
| 67 |
+
gr.Markdown("smart forecasting for australia's coast — 238 breaks scored "
|
| 68 |
+
"hour by hour (0–10) from live marine data, real seafloor and "
|
| 69 |
+
"each spot's ideal conditions.", elem_classes=["hero-sub"])
|
| 70 |
with gr.Column(scale=2, min_width=280, elem_classes=["hero-right"]):
|
| 71 |
mode_toggle = gr.Radio(
|
| 72 |
[_MODE_FORECAST, _MODE_AGENT], value=_MODE_FORECAST,
|
|
|
|
| 95 |
|
| 96 |
a = agent_panel.build_agent(selected, agent_store, records, vocab, visible=False)
|
| 97 |
|
| 98 |
+
# Footer: the machinery line — feed/scorer/world-model latencies —
|
| 99 |
+
# lives below the fold, in both modes (outside the swapped columns).
|
| 100 |
+
with gr.Column(elem_classes=["page-foot"]):
|
| 101 |
+
f = story_panel.build_footer()
|
| 102 |
+
|
| 103 |
def _switch_mode(mode: str):
|
| 104 |
agentic = mode == _MODE_AGENT
|
| 105 |
return gr.update(visible=not agentic), gr.update(visible=agentic)
|
|
|
|
| 110 |
# Internal listeners stay off the API/MCP surface — only the three
|
| 111 |
# typed ``gr.api`` tools (score_week / rank_region_week /
|
| 112 |
# explain_score) are exposed.
|
| 113 |
+
comp = {**b, **s, **i, **a, **f,
|
| 114 |
"selected": selected, "scored": scored, "agent_store": agent_store}
|
| 115 |
select_outputs = [comp[k] for k in SELECT_KEYS]
|
| 116 |
fetch_outputs = [comp[k] for k in FETCH_KEYS]
|
|
|
|
| 157 |
s["fetch_forecast"], inputs=[selected, b["skill_dd"]], outputs=fetch_outputs,
|
| 158 |
show_progress="minimal", api_name=False,
|
| 159 |
)
|
| 160 |
+
# -- intel rail: pill tabs swap the three feature columns --
|
| 161 |
+
i["tab_picker"].change(i["pick"], inputs=i["tab_picker"],
|
| 162 |
+
outputs=[i["report_col"], i["world_col"], i["climate_col"]],
|
| 163 |
+
api_name=False)
|
| 164 |
+
# the report button lives in the intel rail; its handler stays story-side
|
| 165 |
+
i["narrate_btn"].click(s["narrate"], inputs=scored, outputs=i["report_md"],
|
| 166 |
show_progress="minimal", api_name=False)
|
| 167 |
# -- agent: chat streams trace cards + meter + verdict + charts --
|
| 168 |
chat_inputs = [a["msg"], a["chatbot"], b["skill_dd"], a["token_box"],
|
ui/contracts.py
CHANGED
|
@@ -11,8 +11,8 @@ from __future__ import annotations
|
|
| 11 |
|
| 12 |
# Picking a spot (search box / default on load): static story + intel.
|
| 13 |
SELECT_KEYS = (
|
| 14 |
-
"search_dd", "map_plot", "spot_header", "badges_md", "
|
| 15 |
-
"
|
| 16 |
)
|
| 17 |
|
| 18 |
# The staged forecast fetch: status, hero, strip, world model, state.
|
|
|
|
| 11 |
|
| 12 |
# Picking a spot (search box / default on load): static story + intel.
|
| 13 |
SELECT_KEYS = (
|
| 14 |
+
"search_dd", "map_plot", "spot_header", "badges_md", "description_md",
|
| 15 |
+
"rose_plot", "year_plot", "months_md", "selected",
|
| 16 |
)
|
| 17 |
|
| 18 |
# The staged forecast fetch: status, hero, strip, world model, state.
|
ui/panels/agent.py
CHANGED
|
@@ -8,11 +8,13 @@ preview. The budget meter ticks live (steps / tool calls / score_week cap /
|
|
| 8 |
≈tokens) against the slider-chosen tool budget, and the final answer's
|
| 9 |
"Best: …" contract line is lifted into a verdict banner. Any ``score_week``
|
| 10 |
payload in a tool result is charted straight into the week strip (the LLM
|
| 11 |
-
never owns numbers), and **every answered turn ends with a visualisation
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
| 16 |
|
| 17 |
User controls in the bar: a "max tool calls" slider (the per-turn step
|
| 18 |
budget; token + score_week caps scale with it) and chained State → Region
|
|
@@ -50,6 +52,37 @@ _TOOL_ICONS = {
|
|
| 50 |
_MEDALS = ["🥇", "🥈", "🥉"]
|
| 51 |
_VERDICT_RE = re.compile(r"Best:\s*(.+)", re.IGNORECASE)
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
_EMPTY_TRACE = ("<div class='trace-empty'>tool calls land here live — "
|
| 54 |
"watch the engines work…</div>")
|
| 55 |
_EMPTY_RANK_HTML = ("<div class='rank-empty'>leaderboard — ask "
|
|
@@ -226,23 +259,164 @@ def _viz_spot(args: dict, obs) -> tuple[str, str]:
|
|
| 226 |
return str(name), str(region)
|
| 227 |
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
def _turn_figure(viz: list[tuple[str, dict, object]], selected: dict | None,
|
| 230 |
-
skill: str, records: list[dict]
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
Returns (figure, markdown note) or (None, "") when nothing chartable.
|
| 240 |
"""
|
| 241 |
by_name: dict[str, tuple[dict, object]] = {}
|
| 242 |
for name, args, obs in viz:
|
| 243 |
by_name.setdefault(str(name), (args if isinstance(args, dict) else {}, obs))
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
_a, obs = by_name.get("rank_region_week", ({}, None))
|
| 247 |
rows = _coerce_rank_rows(obs)
|
| 248 |
if rows:
|
|
@@ -253,56 +427,27 @@ def _turn_figure(viz: list[tuple[str, dict, object]], selected: dict | None,
|
|
| 253 |
note = (f"region sweep{region_bit} — top pick "
|
| 254 |
f"**{top.get('name')}** ({top.get('best_score')}/10)")
|
| 255 |
return score_chart.build_rank_fig(rows, region), note
|
|
|
|
|
|
|
|
|
|
| 256 |
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
rose = (obs.get("climate") or {}).get("direction_rose_pct") or {}
|
| 261 |
-
try:
|
| 262 |
-
has_rose = any(float(v or 0) > 0 for v in rose.values())
|
| 263 |
-
except (TypeError, ValueError):
|
| 264 |
-
has_rose = False
|
| 265 |
-
if has_rose:
|
| 266 |
-
name, _region = _viz_spot(_a, obs)
|
| 267 |
-
rec = C.find_break(records, name)
|
| 268 |
-
ideal = (((rec or {}).get("idealSwell") or {}).get("direction")
|
| 269 |
-
if rec else None)
|
| 270 |
-
fig = climate_chart.build_rose_fig({"rose": rose}, name=name,
|
| 271 |
-
ideal_dirs=ideal)
|
| 272 |
-
return fig, f"swell climate — {name} (5-yr ERA5 rose)"
|
| 273 |
-
|
| 274 |
-
# 3. seafloor → depth map from the world model
|
| 275 |
-
_a, obs = by_name.get("get_seafloor_profile", ({}, None))
|
| 276 |
-
if isinstance(obs, dict):
|
| 277 |
-
name, _region = _viz_spot(_a, obs)
|
| 278 |
-
rec = C.find_break(records, name)
|
| 279 |
-
if rec:
|
| 280 |
try:
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
return
|
| 293 |
-
f"{len(obs)} spots like {ref} — similarity")
|
| 294 |
-
|
| 295 |
-
# 5. explain_score → component split
|
| 296 |
-
_a, obs = by_name.get("explain_score", ({}, None))
|
| 297 |
-
if (isinstance(obs, dict) and isinstance(obs.get("components"), dict)
|
| 298 |
-
and obs["components"]):
|
| 299 |
-
name, _region = _viz_spot(_a, obs)
|
| 300 |
-
when = str(obs.get("time") or "")
|
| 301 |
-
fig = score_chart.build_component_fig(obs["components"], spot=name,
|
| 302 |
-
when=when, score=obs.get("score"))
|
| 303 |
-
return fig, f"component split — {name} @ {when}"
|
| 304 |
|
| 305 |
-
# 6. nothing tool-chartable → the viewed spot's own scored week
|
| 306 |
sel = selected or {}
|
| 307 |
if sel.get("name"):
|
| 308 |
payload = C.get_scored_week(sel, skill=skill, days=7)
|
|
@@ -415,7 +560,9 @@ def chat_fn(message: str, history: list[dict] | None, skill: str,
|
|
| 415 |
entry_skill = skill
|
| 416 |
if isinstance(obs, dict) and obs.get("skill"):
|
| 417 |
entry_skill = obs["skill"]
|
| 418 |
-
spots.append({"label": label, "hours": hours,
|
|
|
|
|
|
|
| 419 |
# Dedupe repeated chart labels at append time ("#2", "#3", …).
|
| 420 |
counts: dict[str, int] = {}
|
| 421 |
for s in spots:
|
|
@@ -483,15 +630,21 @@ def chat_fn(message: str, history: list[dict] | None, skill: str,
|
|
| 483 |
yield emit(f"❌ Agent run failed: {e}")
|
| 484 |
return
|
| 485 |
|
| 486 |
-
# Every answered turn ends with a visualisation
|
| 487 |
-
#
|
| 488 |
-
#
|
| 489 |
-
#
|
|
|
|
| 490 |
fallback_fig = None
|
| 491 |
-
|
|
|
|
| 492 |
yield emit("📊 assembling this turn's chart…")
|
| 493 |
try:
|
| 494 |
-
fallback_fig, viz_note = _turn_figure(viz, selected, skill, records
|
|
|
|
|
|
|
|
|
|
|
|
|
| 495 |
except Exception: # noqa: BLE001 — a chart must never break the answer
|
| 496 |
fallback_fig, viz_note = None, ""
|
| 497 |
if fallback_fig is not None:
|
|
@@ -582,8 +735,9 @@ def build_agent(selected, store, records: list[dict], vocab: dict | None,
|
|
| 582 |
gr.Markdown("### 💬 ask the agent — engines compute, the llm narrates")
|
| 583 |
gr.Markdown("the agent calls ⚙ scoring, 🪨 the gebco world model, 🌡 era5 climate "
|
| 584 |
"and 📡 open-meteo directly; nemotron 3 ultra only narrates their "
|
| 585 |
-
"numbers. every answer lands with a chart —
|
| 586 |
-
"
|
|
|
|
| 587 |
ctx_md = gr.Markdown("", elem_classes=["ctx-line"])
|
| 588 |
with gr.Row():
|
| 589 |
with gr.Column(scale=1):
|
|
@@ -595,7 +749,8 @@ def build_agent(selected, store, records: list[dict], vocab: dict | None,
|
|
| 595 |
steps_slider = gr.Slider(1, 12, step=1,
|
| 596 |
value=_DEFAULT_STEPS,
|
| 597 |
label="max calls", scale=2,
|
| 598 |
-
min_width=180
|
|
|
|
| 599 |
token_box = gr.Textbox(label="HF token (optional, session-only)", type="password",
|
| 600 |
placeholder="Defaults to the Space secret — never logged or saved.")
|
| 601 |
chatbot = gr.Chatbot(label="Surf agent", height=460)
|
|
|
|
| 8 |
≈tokens) against the slider-chosen tool budget, and the final answer's
|
| 9 |
"Best: …" contract line is lifted into a verdict banner. Any ``score_week``
|
| 10 |
payload in a tool result is charted straight into the week strip (the LLM
|
| 11 |
+
never owns numbers), and **every answered turn ends with a visualisation
|
| 12 |
+
matched to the question**: recommendation questions close on the
|
| 13 |
+
recommended-scores chart — a region-sweep leaderboard, or a side-by-side
|
| 14 |
+
compare when several spots were scored this turn (a deterministic engine
|
| 15 |
+
sweep fills in when the agent never ranked); climate / seafloor / similar /
|
| 16 |
+
explain questions get their rose, depth map, similarity bars or component
|
| 17 |
+
split; anything else falls back to the viewed spot's own scored week.
|
| 18 |
|
| 19 |
User controls in the bar: a "max tool calls" slider (the per-turn step
|
| 20 |
budget; token + score_week caps scale with it) and chained State → Region
|
|
|
|
| 52 |
_MEDALS = ["🥇", "🥈", "🥉"]
|
| 53 |
_VERDICT_RE = re.compile(r"Best:\s*(.+)", re.IGNORECASE)
|
| 54 |
|
| 55 |
+
# Query intent (deterministic, on the user's text only): which end-of-turn
|
| 56 |
+
# chart matches the question. Scores charts are the house default; the
|
| 57 |
+
# specialties jump the queue only when the question asks for them.
|
| 58 |
+
_REC_RE = re.compile(
|
| 59 |
+
r"\bwhere\s+(?:should|to|can|shall|could)\b"
|
| 60 |
+
r"|\bbest\s+(?:spots?|breaks?|beaches?|places?)\b"
|
| 61 |
+
r"|\brecommend|\brank\w*|\btop\s*\d+"
|
| 62 |
+
r"|\bcompare|\bversus\b|\bvs\b"
|
| 63 |
+
r"|\bshould\s+i\s+(?:surf|go|paddle)\b", re.IGNORECASE)
|
| 64 |
+
_CLIMATE_RE = re.compile(
|
| 65 |
+
r"\bclimate\b|\bmonthly\b|\bmonths?\b|winter|summer|autumn|spring\b"
|
| 66 |
+
r"|\bswell\s+direction\b|\bwhen\s+to\s+go\b", re.IGNORECASE)
|
| 67 |
+
_SEAFLOOR_RE = re.compile(
|
| 68 |
+
r"\bseafloor\b|\bsea\s?floor\b|\bsea\s?bed\b|\bbathymetry\b|\bdepths?\b"
|
| 69 |
+
r"|\bstructure\b|\bsandbar\b|\breef\b", re.IGNORECASE)
|
| 70 |
+
_SIMILAR_RE = re.compile(
|
| 71 |
+
r"\b(?:spots?|breaks?|beaches?)\s+like\b|\bsimilar\s+to\b"
|
| 72 |
+
r"|\balternatives?\s+to\b|\bquieter\b", re.IGNORECASE)
|
| 73 |
+
_EXPLAIN_RE = re.compile(
|
| 74 |
+
r"\bwhy\b|\bexplain\w*|\bcomponent\w*|\bbreakdown\b", re.IGNORECASE)
|
| 75 |
+
_SPECIALTY_RE = (
|
| 76 |
+
("get_climate_profile", _CLIMATE_RE),
|
| 77 |
+
("get_seafloor_profile", _SEAFLOOR_RE),
|
| 78 |
+
("find_similar_spots", _SIMILAR_RE),
|
| 79 |
+
("explain_score", _EXPLAIN_RE),
|
| 80 |
+
)
|
| 81 |
+
_STATE_CODES = {
|
| 82 |
+
"NSW": "New South Wales", "QLD": "Queensland", "VIC": "Victoria",
|
| 83 |
+
"WA": "Western Australia", "SA": "South Australia", "TAS": "Tasmania",
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
_EMPTY_TRACE = ("<div class='trace-empty'>tool calls land here live — "
|
| 87 |
"watch the engines work…</div>")
|
| 88 |
_EMPTY_RANK_HTML = ("<div class='rank-empty'>leaderboard — ask "
|
|
|
|
| 259 |
return str(name), str(region)
|
| 260 |
|
| 261 |
|
| 262 |
+
def _specialty_tool(query: str) -> str | None:
|
| 263 |
+
"""The tool whose chart the question explicitly asks for, if any."""
|
| 264 |
+
for tool_name, rx in _SPECIALTY_RE:
|
| 265 |
+
if rx.search(query or ""):
|
| 266 |
+
return tool_name
|
| 267 |
+
return None
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def _rose_chart(entry: tuple[dict, object], records: list[dict]):
|
| 271 |
+
_a, obs = entry
|
| 272 |
+
if not isinstance(obs, dict):
|
| 273 |
+
return None, ""
|
| 274 |
+
rose = (obs.get("climate") or {}).get("direction_rose_pct") or {}
|
| 275 |
+
try:
|
| 276 |
+
if not any(float(v or 0) > 0 for v in rose.values()):
|
| 277 |
+
return None, ""
|
| 278 |
+
except (TypeError, ValueError):
|
| 279 |
+
return None, ""
|
| 280 |
+
name, _region = _viz_spot(_a, obs)
|
| 281 |
+
rec = C.find_break(records, name)
|
| 282 |
+
ideal = (((rec or {}).get("idealSwell") or {}).get("direction")
|
| 283 |
+
if rec else None)
|
| 284 |
+
fig = climate_chart.build_rose_fig({"rose": rose}, name=name,
|
| 285 |
+
ideal_dirs=ideal)
|
| 286 |
+
return fig, f"swell climate — {name} (5-yr ERA5 rose)"
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def _depth_chart(entry: tuple[dict, object], records: list[dict]):
|
| 290 |
+
_a, obs = entry
|
| 291 |
+
if not isinstance(obs, dict):
|
| 292 |
+
return None, ""
|
| 293 |
+
name, _region = _viz_spot(_a, obs)
|
| 294 |
+
rec = C.find_break(records, name)
|
| 295 |
+
if not rec:
|
| 296 |
+
return None, ""
|
| 297 |
+
try:
|
| 298 |
+
grid = (C.get_seafloor(rec) or {}).get("grid") or {}
|
| 299 |
+
return seafloor_chart.build_depth_fig(grid, name), \
|
| 300 |
+
f"seafloor depth map — {name}"
|
| 301 |
+
except Exception: # noqa: BLE001 — fall through to the next viz
|
| 302 |
+
return None, ""
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def _similarity_chart(entry: tuple[dict, object], records: list[dict]):
|
| 306 |
+
_a, obs = entry
|
| 307 |
+
if (isinstance(obs, list) and obs and isinstance(obs[0], dict)
|
| 308 |
+
and "similarity" in obs[0]):
|
| 309 |
+
ref = str(_a.get("spot_name") or "")
|
| 310 |
+
return (score_chart.build_similarity_fig(obs, ref),
|
| 311 |
+
f"{len(obs)} spots like {ref} — similarity")
|
| 312 |
+
return None, ""
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def _components_chart(entry: tuple[dict, object], records: list[dict]):
|
| 316 |
+
_a, obs = entry
|
| 317 |
+
if (isinstance(obs, dict) and isinstance(obs.get("components"), dict)
|
| 318 |
+
and obs["components"]):
|
| 319 |
+
name, _region = _viz_spot(_a, obs)
|
| 320 |
+
when = str(obs.get("time") or "")
|
| 321 |
+
fig = score_chart.build_component_fig(obs["components"], spot=name,
|
| 322 |
+
when=when, score=obs.get("score"))
|
| 323 |
+
return fig, f"component split — {name} @ {when}"
|
| 324 |
+
return None, ""
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def _specialty_figure(by_name: dict[str, tuple[dict, object]],
|
| 328 |
+
records: list[dict],
|
| 329 |
+
prefer: str | None = None):
|
| 330 |
+
"""First buildable question-specific chart from the tool payloads.
|
| 331 |
+
|
| 332 |
+
Scans climate rose → seafloor depth map → similarity bars → component
|
| 333 |
+
split; a question-matched tool (``prefer``) is scanned first so e.g. a
|
| 334 |
+
seafloor question wins its depth map even when a rose payload exists.
|
| 335 |
+
Returns (figure, markdown note) or (None, "").
|
| 336 |
+
"""
|
| 337 |
+
checks = (
|
| 338 |
+
("get_climate_profile", _rose_chart),
|
| 339 |
+
("get_seafloor_profile", _depth_chart),
|
| 340 |
+
("find_similar_spots", _similarity_chart),
|
| 341 |
+
("explain_score", _components_chart),
|
| 342 |
+
)
|
| 343 |
+
if prefer:
|
| 344 |
+
checks = tuple(sorted(checks, key=lambda c: c[0] != prefer))
|
| 345 |
+
for _tool_name, build in checks:
|
| 346 |
+
fig, note = build(by_name.get(_tool_name, ({}, None)), records)
|
| 347 |
+
if fig is not None:
|
| 348 |
+
return fig, note
|
| 349 |
+
return None, ""
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _scored_compare_fig(scored: list[dict]):
|
| 353 |
+
"""Side-by-side best-score bars for a multi-spot scored turn."""
|
| 354 |
+
rows = []
|
| 355 |
+
for s in scored or []:
|
| 356 |
+
best = score_chart.best_window(s.get("hours") or [])
|
| 357 |
+
if not best:
|
| 358 |
+
continue
|
| 359 |
+
rows.append({"name": str(s.get("label") or "?"),
|
| 360 |
+
"region": str(s.get("region") or ""),
|
| 361 |
+
"best_score": best.get("score"),
|
| 362 |
+
"best_time": best.get("time")})
|
| 363 |
+
if len(rows) < 2:
|
| 364 |
+
return None, ""
|
| 365 |
+
names = " vs ".join(r["name"].split(" (")[0] for r in rows[:2])
|
| 366 |
+
fig = score_chart.build_compare_fig(
|
| 367 |
+
rows, "name", "best_score", "recommended breaks — best score this week",
|
| 368 |
+
sub_keys=("region", "best_time"))
|
| 369 |
+
return fig, f"{names} — best-score compare ({len(rows)} spots)"
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def _query_region(query: str, records: list[dict]) -> str | None:
|
| 373 |
+
"""A canonical state/region named in the query (codes expanded), or None."""
|
| 374 |
+
text = f" {query or ''} ".lower()
|
| 375 |
+
vocab = sorted({str(r.get("state") or "") for r in records}
|
| 376 |
+
| {str(r.get("region") or "") for r in records},
|
| 377 |
+
key=len, reverse=True)
|
| 378 |
+
for name in vocab:
|
| 379 |
+
if name and re.search(rf"(?<![a-z]){re.escape(name.lower())}(?![a-z])",
|
| 380 |
+
text):
|
| 381 |
+
return name
|
| 382 |
+
for code, name in _STATE_CODES.items():
|
| 383 |
+
if re.search(rf"(?<![a-z]){code.lower()}(?![a-z])", text):
|
| 384 |
+
return name
|
| 385 |
+
return None
|
| 386 |
+
|
| 387 |
+
|
| 388 |
def _turn_figure(viz: list[tuple[str, dict, object]], selected: dict | None,
|
| 389 |
+
skill: str, records: list[dict], query: str = "",
|
| 390 |
+
focus: str | None = None, scored: list[dict] | None = None,
|
| 391 |
+
charted: bool = False):
|
| 392 |
+
"""Pick + build the end-of-turn chart, matched to the question.
|
| 393 |
+
|
| 394 |
+
The house chart is the recommended-surf-break-scores view: a
|
| 395 |
+
region-sweep leaderboard, or a side-by-side compare when several spots
|
| 396 |
+
were scored this turn. Question-specific charts (climate rose, seafloor
|
| 397 |
+
depth map, similarity bars, component split) jump the queue only when
|
| 398 |
+
the question asks for them. A recommendation question the agent answered
|
| 399 |
+
without any sweep still closes on one: a deterministic engine sweep of
|
| 400 |
+
the region named in the question (or the page's State/Region focus).
|
| 401 |
+
Numbers come from tool/engine output only, never from the answer text.
|
| 402 |
Returns (figure, markdown note) or (None, "") when nothing chartable.
|
| 403 |
"""
|
| 404 |
by_name: dict[str, tuple[dict, object]] = {}
|
| 405 |
for name, args, obs in viz:
|
| 406 |
by_name.setdefault(str(name), (args if isinstance(args, dict) else {}, obs))
|
| 407 |
+
q = str(query or "")
|
| 408 |
+
recommend = bool(_REC_RE.search(q))
|
| 409 |
+
prefer = _specialty_tool(q)
|
| 410 |
+
special = _specialty_figure(by_name, records, prefer)
|
| 411 |
+
|
| 412 |
+
if charted:
|
| 413 |
+
# Multi-spot scored turn: week strips already ran mid-turn — close
|
| 414 |
+
# on the side-by-side scores, never an unrelated refetch.
|
| 415 |
+
return _scored_compare_fig(scored or [])
|
| 416 |
+
if not recommend and prefer and special[0] is not None:
|
| 417 |
+
return special
|
| 418 |
+
|
| 419 |
+
# scores first: the sweep leaderboard, then this turn's scored spots
|
| 420 |
_a, obs = by_name.get("rank_region_week", ({}, None))
|
| 421 |
rows = _coerce_rank_rows(obs)
|
| 422 |
if rows:
|
|
|
|
| 427 |
note = (f"region sweep{region_bit} — top pick "
|
| 428 |
f"**{top.get('name')}** ({top.get('best_score')}/10)")
|
| 429 |
return score_chart.build_rank_fig(rows, region), note
|
| 430 |
+
compare = _scored_compare_fig(scored or [])
|
| 431 |
+
if compare[0] is not None:
|
| 432 |
+
return compare
|
| 433 |
|
| 434 |
+
if recommend:
|
| 435 |
+
region = _query_region(q, records) or (str(focus) if focus else None)
|
| 436 |
+
if region:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
try:
|
| 438 |
+
rows = _coerce_rank_rows(
|
| 439 |
+
C.api_rank_region_week(region, skill=skill))
|
| 440 |
+
except Exception: # noqa: BLE001 — chart fallbacks never raise
|
| 441 |
+
rows = None
|
| 442 |
+
if rows:
|
| 443 |
+
top = rows[0]
|
| 444 |
+
return (score_chart.build_rank_fig(rows, region),
|
| 445 |
+
f"recommended breaks — {region} — top pick "
|
| 446 |
+
f"**{top.get('name')}** ({top.get('best_score')}/10)")
|
| 447 |
+
|
| 448 |
+
if special[0] is not None:
|
| 449 |
+
return special
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
|
|
|
|
| 451 |
sel = selected or {}
|
| 452 |
if sel.get("name"):
|
| 453 |
payload = C.get_scored_week(sel, skill=skill, days=7)
|
|
|
|
| 560 |
entry_skill = skill
|
| 561 |
if isinstance(obs, dict) and obs.get("skill"):
|
| 562 |
entry_skill = obs["skill"]
|
| 563 |
+
spots.append({"label": label, "hours": hours,
|
| 564 |
+
"skill": entry_skill,
|
| 565 |
+
"region": str(region_arg or "")})
|
| 566 |
# Dedupe repeated chart labels at append time ("#2", "#3", …).
|
| 567 |
counts: dict[str, int] = {}
|
| 568 |
for s in spots:
|
|
|
|
| 630 |
yield emit(f"❌ Agent run failed: {e}")
|
| 631 |
return
|
| 632 |
|
| 633 |
+
# Every answered turn ends with a visualisation matched to the question,
|
| 634 |
+
# scores first. Multi-spot scored turns close on the side-by-side
|
| 635 |
+
# compare; otherwise the chain runs only when nothing was charted
|
| 636 |
+
# mid-turn. The status is yielded first so a slow fetch (e.g. a make-up
|
| 637 |
+
# region sweep) reads as a staged spin-up, never a hang.
|
| 638 |
fallback_fig = None
|
| 639 |
+
viz_note = ""
|
| 640 |
+
if (not turn_charted) or len(spots) > 1:
|
| 641 |
yield emit("📊 assembling this turn's chart…")
|
| 642 |
try:
|
| 643 |
+
fallback_fig, viz_note = _turn_figure(viz, selected, skill, records,
|
| 644 |
+
query=text,
|
| 645 |
+
focus=_region_hint(state, region),
|
| 646 |
+
scored=spots,
|
| 647 |
+
charted=turn_charted)
|
| 648 |
except Exception: # noqa: BLE001 — a chart must never break the answer
|
| 649 |
fallback_fig, viz_note = None, ""
|
| 650 |
if fallback_fig is not None:
|
|
|
|
| 735 |
gr.Markdown("### 💬 ask the agent — engines compute, the llm narrates")
|
| 736 |
gr.Markdown("the agent calls ⚙ scoring, 🪨 the gebco world model, 🌡 era5 climate "
|
| 737 |
"and 📡 open-meteo directly; nemotron 3 ultra only narrates their "
|
| 738 |
+
"numbers. every answer lands with a question-matched chart — "
|
| 739 |
+
"recommended-scores leaderboard, week strip, rose, depth map, "
|
| 740 |
+
"similarity bars or component split.")
|
| 741 |
ctx_md = gr.Markdown("", elem_classes=["ctx-line"])
|
| 742 |
with gr.Row():
|
| 743 |
with gr.Column(scale=1):
|
|
|
|
| 749 |
steps_slider = gr.Slider(1, 12, step=1,
|
| 750 |
value=_DEFAULT_STEPS,
|
| 751 |
label="max calls", scale=2,
|
| 752 |
+
min_width=180,
|
| 753 |
+
elem_classes=["steps-slider"])
|
| 754 |
token_box = gr.Textbox(label="HF token (optional, session-only)", type="password",
|
| 755 |
placeholder="Defaults to the Space secret — never logged or saved.")
|
| 756 |
chatbot = gr.Chatbot(label="Surf agent", height=460)
|
ui/panels/intel.py
CHANGED
|
@@ -1,19 +1,32 @@
|
|
| 1 |
-
"""Intel rail:
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
import gradio as gr
|
| 12 |
|
|
|
|
|
|
|
| 13 |
|
| 14 |
def build_intel():
|
| 15 |
"""Build the intel rail (inside an existing Column). Returns components."""
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
gr.Markdown("bathymetry around the takeoff zone (blue = deep, green = land, "
|
| 18 |
"★ = break). the 3D view carries an **animated swell layer** that "
|
| 19 |
"rides the forecast's dominant period — press ▶ swell.")
|
|
@@ -22,12 +35,21 @@ def build_intel():
|
|
| 22 |
with gr.Accordion("depth + transects", open=False):
|
| 23 |
depth_plot = gr.Plot(label="Depth map")
|
| 24 |
transect_plot = gr.Plot(label="Transects")
|
| 25 |
-
with gr.
|
| 26 |
year_plot = gr.Plot(label="When to go")
|
| 27 |
months_md = gr.Markdown("_pick a spot — its climatology lands here._")
|
| 28 |
rose_plot = gr.Plot(label="Swell climate rose")
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
return {
|
|
|
|
|
|
|
|
|
|
| 31 |
"surface_plot": surface_plot, "depth_plot": depth_plot,
|
| 32 |
"transect_plot": transect_plot, "seafloor_md": seafloor_md,
|
| 33 |
"year_plot": year_plot, "months_md": months_md, "rose_plot": rose_plot,
|
|
|
|
| 1 |
+
"""Intel rail: three click-through tabs on the right edge of the page.
|
| 2 |
|
| 3 |
+
**surf report** (the LLM-narrated write-up, generated on demand),
|
| 4 |
+
**world model** (GEBCO bathymetry) and **when to go** (ERA5 climatology)
|
| 5 |
+
hang off one pill tab row — the header mode-toggle pattern: a Radio
|
| 6 |
+
swaps three stacked columns. (Gradio's native Tabs park overflow tabs in
|
| 7 |
+
a "⋯" menu at this column width; the rail keeps its own switcher
|
| 8 |
+
instead.) Components only — their inputs arrive through the shared
|
| 9 |
+
contracts: the report button wires in ``ui/app.py``, world-model figs
|
| 10 |
+
come from the staged fetch (FETCH_KEYS), climatology from the spot pick
|
| 11 |
+
(SELECT_KEYS).
|
| 12 |
"""
|
| 13 |
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
import gradio as gr
|
| 17 |
|
| 18 |
+
_TABS = ["report", "topography", "season"]
|
| 19 |
+
|
| 20 |
|
| 21 |
def build_intel():
|
| 22 |
"""Build the intel rail (inside an existing Column). Returns components."""
|
| 23 |
+
tab_picker = gr.Radio(_TABS, value=_TABS[0], show_label=False,
|
| 24 |
+
container=False, elem_classes=["intel-tabs"])
|
| 25 |
+
with gr.Column(visible=True) as report_col:
|
| 26 |
+
narrate_btn = gr.Button("get surf report ✨", variant="secondary")
|
| 27 |
+
report_md = gr.Markdown(
|
| 28 |
+
"_no report yet — pick a spot, then get the surf report._")
|
| 29 |
+
with gr.Column(visible=False) as world_col:
|
| 30 |
gr.Markdown("bathymetry around the takeoff zone (blue = deep, green = land, "
|
| 31 |
"★ = break). the 3D view carries an **animated swell layer** that "
|
| 32 |
"rides the forecast's dominant period — press ▶ swell.")
|
|
|
|
| 35 |
with gr.Accordion("depth + transects", open=False):
|
| 36 |
depth_plot = gr.Plot(label="Depth map")
|
| 37 |
transect_plot = gr.Plot(label="Transects")
|
| 38 |
+
with gr.Column(visible=False) as climate_col:
|
| 39 |
year_plot = gr.Plot(label="When to go")
|
| 40 |
months_md = gr.Markdown("_pick a spot — its climatology lands here._")
|
| 41 |
rose_plot = gr.Plot(label="Swell climate rose")
|
| 42 |
|
| 43 |
+
def pick(tab: str):
|
| 44 |
+
"""Tab switch: show the picked feature column, hide the others."""
|
| 45 |
+
return (gr.update(visible=tab == _TABS[0]),
|
| 46 |
+
gr.update(visible=tab == _TABS[1]),
|
| 47 |
+
gr.update(visible=tab == _TABS[2]))
|
| 48 |
+
|
| 49 |
return {
|
| 50 |
+
"tab_picker": tab_picker, "pick": pick,
|
| 51 |
+
"report_col": report_col, "world_col": world_col, "climate_col": climate_col,
|
| 52 |
+
"narrate_btn": narrate_btn, "report_md": report_md,
|
| 53 |
"surface_plot": surface_plot, "depth_plot": depth_plot,
|
| 54 |
"transect_plot": transect_plot, "seafloor_md": seafloor_md,
|
| 55 |
"year_plot": year_plot, "months_md": months_md, "rose_plot": rose_plot,
|
ui/panels/lens.py
CHANGED
|
@@ -9,8 +9,6 @@ records, so no extra session state is needed here.
|
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
-
import pandas as pd
|
| 13 |
-
|
| 14 |
import gradio as gr
|
| 15 |
|
| 16 |
from ui import _compat as C
|
|
@@ -19,44 +17,6 @@ from ui.charts import map as map_chart
|
|
| 19 |
from ui.contracts import SELECT_KEYS, fill
|
| 20 |
|
| 21 |
|
| 22 |
-
def _join(value) -> str:
|
| 23 |
-
if isinstance(value, list):
|
| 24 |
-
return ", ".join(str(v) for v in value)
|
| 25 |
-
return "" if value is None else str(value)
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
def break_to_table(break_: dict) -> pd.DataFrame:
|
| 29 |
-
"""Flatten one break record into a Field/Value table."""
|
| 30 |
-
loc = (break_ or {}).get("location", {}) or {}
|
| 31 |
-
coords = loc.get("coordinates", {}) or {}
|
| 32 |
-
swell = (break_ or {}).get("idealSwell", {}) or {}
|
| 33 |
-
swell_size = swell.get("sizeRangeFt", {}) or {}
|
| 34 |
-
wind = (break_ or {}).get("idealWind", {}) or {}
|
| 35 |
-
tide = (break_ or {}).get("idealTide", {}) or {}
|
| 36 |
-
rows = [
|
| 37 |
-
("Name", break_.get("name", "")),
|
| 38 |
-
("State", break_.get("state", "")),
|
| 39 |
-
("Region", break_.get("region", "")),
|
| 40 |
-
("Description", break_.get("description", "")),
|
| 41 |
-
("Skill level", break_.get("skillLevel", "")),
|
| 42 |
-
("Break type", break_.get("breakType", "")),
|
| 43 |
-
("Peak type", break_.get("peakType", "")),
|
| 44 |
-
("Latitude", coords.get("lat", "")),
|
| 45 |
-
("Longitude", coords.get("lng", "")),
|
| 46 |
-
("Ideal swell", _join(swell.get("direction", ""))),
|
| 47 |
-
("Swell size (ft)", f"{swell_size.get('min', '?')}–{swell_size.get('max', '?')}"),
|
| 48 |
-
("Ideal wind", f"{_join(wind.get('direction', ''))} ({wind.get('type', '')})"),
|
| 49 |
-
("Ideal tide", _join(tide.get("stage", ""))),
|
| 50 |
-
("Best season", _join(break_.get("bestSeason", ""))),
|
| 51 |
-
("Hazards", _join(break_.get("hazards", ""))),
|
| 52 |
-
("Crowd", break_.get("crowdFactor", "")),
|
| 53 |
-
]
|
| 54 |
-
return pd.DataFrame(rows, columns=["Field", "Value"])
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
_EMPTY_DETAILS = pd.DataFrame(columns=["Field", "Value"])
|
| 58 |
-
|
| 59 |
-
|
| 60 |
def build_lens(records: list[dict], vocab: dict, selected):
|
| 61 |
"""Build the lens column. Returns component/handler dict for wiring."""
|
| 62 |
states = vocab["states"]
|
|
@@ -79,8 +39,6 @@ def build_lens(records: list[dict], vocab: dict, selected):
|
|
| 79 |
)
|
| 80 |
map_plot = gr.Plot(map_chart.build_map(records, default_view=True))
|
| 81 |
gr.HTML(map_chart.skill_legend_html())
|
| 82 |
-
with gr.Accordion("📖 break details", open=False):
|
| 83 |
-
details_df = gr.Dataframe(headers=["Field", "Value"], wrap=True)
|
| 84 |
|
| 85 |
def state_regions(state: str) -> dict:
|
| 86 |
regions = regions_by_state.get(state, all_regions) if state != C.ALL else all_regions
|
|
@@ -111,7 +69,7 @@ def build_lens(records: list[dict], vocab: dict, selected):
|
|
| 111 |
profile = {} # the stub rose would be fabricated data — stay honest
|
| 112 |
monthly = C.get_climate_monthly(record)
|
| 113 |
cam = C.get_surf_cam(record)
|
| 114 |
-
from ui.panels.story import format_badges
|
| 115 |
|
| 116 |
fly_fig = (map_chart.build_map(filtered or [record], selected=record)
|
| 117 |
if fly else gr.skip())
|
|
@@ -121,6 +79,7 @@ def build_lens(records: list[dict], vocab: dict, selected):
|
|
| 121 |
map_plot=fly_fig,
|
| 122 |
spot_header=header,
|
| 123 |
badges_md=format_badges(record, cam),
|
|
|
|
| 124 |
rose_plot=climate_chart.build_rose_fig(
|
| 125 |
profile, name,
|
| 126 |
ideal_dirs=(record.get("idealSwell") or {}).get("direction"),
|
|
@@ -129,7 +88,6 @@ def build_lens(records: list[dict], vocab: dict, selected):
|
|
| 129 |
monthly, name, window_label=profile.get("window_ft", ""),
|
| 130 |
),
|
| 131 |
months_md=C.format_month_hint(monthly, profile),
|
| 132 |
-
details_df=break_to_table(record),
|
| 133 |
selected=record,
|
| 134 |
)
|
| 135 |
|
|
@@ -143,7 +101,7 @@ def build_lens(records: list[dict], vocab: dict, selected):
|
|
| 143 |
|
| 144 |
return {
|
| 145 |
"search_dd": search_dd, "state_dd": state_dd, "region_dd": region_dd,
|
| 146 |
-
"skill_dd": skill_dd, "map_plot": map_plot,
|
| 147 |
"state_regions": state_regions, "filter_changed": filter_changed,
|
| 148 |
"pick_by_name": pick_by_name, "select_spot": select_spot,
|
| 149 |
}
|
|
|
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
|
|
|
|
|
|
| 12 |
import gradio as gr
|
| 13 |
|
| 14 |
from ui import _compat as C
|
|
|
|
| 17 |
from ui.contracts import SELECT_KEYS, fill
|
| 18 |
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
def build_lens(records: list[dict], vocab: dict, selected):
|
| 21 |
"""Build the lens column. Returns component/handler dict for wiring."""
|
| 22 |
states = vocab["states"]
|
|
|
|
| 39 |
)
|
| 40 |
map_plot = gr.Plot(map_chart.build_map(records, default_view=True))
|
| 41 |
gr.HTML(map_chart.skill_legend_html())
|
|
|
|
|
|
|
| 42 |
|
| 43 |
def state_regions(state: str) -> dict:
|
| 44 |
regions = regions_by_state.get(state, all_regions) if state != C.ALL else all_regions
|
|
|
|
| 69 |
profile = {} # the stub rose would be fabricated data — stay honest
|
| 70 |
monthly = C.get_climate_monthly(record)
|
| 71 |
cam = C.get_surf_cam(record)
|
| 72 |
+
from ui.panels.story import format_badges, format_description
|
| 73 |
|
| 74 |
fly_fig = (map_chart.build_map(filtered or [record], selected=record)
|
| 75 |
if fly else gr.skip())
|
|
|
|
| 79 |
map_plot=fly_fig,
|
| 80 |
spot_header=header,
|
| 81 |
badges_md=format_badges(record, cam),
|
| 82 |
+
description_md=format_description(record),
|
| 83 |
rose_plot=climate_chart.build_rose_fig(
|
| 84 |
profile, name,
|
| 85 |
ideal_dirs=(record.get("idealSwell") or {}).get("direction"),
|
|
|
|
| 88 |
monthly, name, window_label=profile.get("window_ft", ""),
|
| 89 |
),
|
| 90 |
months_md=C.format_month_hint(monthly, profile),
|
|
|
|
| 91 |
selected=record,
|
| 92 |
)
|
| 93 |
|
|
|
|
| 101 |
|
| 102 |
return {
|
| 103 |
"search_dd": search_dd, "state_dd": state_dd, "region_dd": region_dd,
|
| 104 |
+
"skill_dd": skill_dd, "map_plot": map_plot,
|
| 105 |
"state_regions": state_regions, "filter_changed": filter_changed,
|
| 106 |
"pick_by_name": pick_by_name, "select_spot": select_spot,
|
| 107 |
}
|
ui/panels/story.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
-
"""Story panel: the selected spot's week — chips, strip
|
| 2 |
|
| 3 |
``fetch_forecast`` stays a *staged generator* so the spin-up reads as
|
| 4 |
engines working, not a spinner: stage 1 lands the Open-Meteo fetch +
|
| 5 |
week strip immediately (~ms warm), while the GEBCO world model resolves
|
| 6 |
in a parallel thread and streams in as stage 2. Stage timings (feed,
|
| 7 |
-
scorer, world model) land in the status line — the LLM
|
| 8 |
-
numbers.
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
|
@@ -34,7 +34,7 @@ def _join(value) -> str:
|
|
| 34 |
|
| 35 |
|
| 36 |
def format_badges(record: dict | None, cam: dict | None = None) -> str:
|
| 37 |
-
"""Spot identity as lo-fi chips:
|
| 38 |
b = record or {}
|
| 39 |
if not b:
|
| 40 |
return ""
|
|
@@ -49,10 +49,22 @@ def format_badges(record: dict | None, cam: dict | None = None) -> str:
|
|
| 49 |
tide = _join((b.get("idealTide") or {}).get("stage"))
|
| 50 |
if tide:
|
| 51 |
parts.append(f'<span class="badge-chip">🌊 tide: {esc(tide)}</span>')
|
| 52 |
-
swell =
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
if b.get("crowdFactor"):
|
| 57 |
parts.append(f'<span class="badge-chip">👥 {esc(str(b["crowdFactor"]))}</span>')
|
| 58 |
if b.get("bestSeason"):
|
|
@@ -66,6 +78,14 @@ def format_badges(record: dict | None, cam: dict | None = None) -> str:
|
|
| 66 |
return '<div class="badge-row">' + " ".join(parts) + "</div>"
|
| 67 |
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
def _empty_seafloor():
|
| 70 |
blank = seafloor_chart._empty_fig()
|
| 71 |
return blank, blank, blank
|
|
@@ -198,12 +218,18 @@ def narrate(payload: dict | None):
|
|
| 198 |
|
| 199 |
|
| 200 |
def build_story(selected, scored):
|
| 201 |
-
"""Build the story column. Returns component dict for wiring.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
with gr.Column(scale=4):
|
| 203 |
spot_header = gr.Markdown("### pick a spot — search above or filter the map")
|
|
|
|
| 204 |
badges_md = gr.Markdown()
|
| 205 |
-
status_box = gr.Textbox(label="engines", interactive=False,
|
| 206 |
-
placeholder="the spin-up line — feed, scorer, world model — lands here…")
|
| 207 |
hero_md = gr.Markdown("_the week summary lands here._")
|
| 208 |
with gr.Tabs():
|
| 209 |
with gr.Tab("score"):
|
|
@@ -213,13 +239,23 @@ def build_story(selected, scored):
|
|
| 213 |
with gr.Tab("wind"):
|
| 214 |
wind_plot = gr.Plot(label="wind — speed + direction")
|
| 215 |
gr.Markdown(f"<div class='strip-caption'>{strip_chart.CAPTION}</div>")
|
| 216 |
-
narrate_btn = gr.Button("get surf report ✨", variant="secondary")
|
| 217 |
-
report_md = gr.Markdown("_no report yet — pick a spot, then get the surf report._")
|
| 218 |
|
| 219 |
return {
|
| 220 |
-
"spot_header": spot_header, "badges_md": badges_md,
|
| 221 |
-
"
|
|
|
|
| 222 |
"wind_plot": wind_plot,
|
| 223 |
-
"narrate_btn": narrate_btn, "report_md": report_md,
|
| 224 |
"fetch_forecast": fetch_forecast, "narrate": narrate,
|
| 225 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Story panel: the selected spot's week — chips, strip (report is intel-side).
|
| 2 |
|
| 3 |
``fetch_forecast`` stays a *staged generator* so the spin-up reads as
|
| 4 |
engines working, not a spinner: stage 1 lands the Open-Meteo fetch +
|
| 5 |
week strip immediately (~ms warm), while the GEBCO world model resolves
|
| 6 |
in a parallel thread and streams in as stage 2. Stage timings (feed,
|
| 7 |
+
scorer, world model) land in the page footer's status line — the LLM
|
| 8 |
+
never owns the numbers.
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
|
|
|
| 34 |
|
| 35 |
|
| 36 |
def format_badges(record: dict | None, cam: dict | None = None) -> str:
|
| 37 |
+
"""Spot identity as lo-fi chips: every break detail but the description."""
|
| 38 |
b = record or {}
|
| 39 |
if not b:
|
| 40 |
return ""
|
|
|
|
| 49 |
tide = _join((b.get("idealTide") or {}).get("stage"))
|
| 50 |
if tide:
|
| 51 |
parts.append(f'<span class="badge-chip">🌊 tide: {esc(tide)}</span>')
|
| 52 |
+
swell = b.get("idealSwell") or {}
|
| 53 |
+
swell_dir = _join(swell.get("direction"))
|
| 54 |
+
if swell_dir:
|
| 55 |
+
parts.append(f'<span class="badge-chip">🧭 swell dir: {esc(swell_dir)}</span>')
|
| 56 |
+
size = swell.get("sizeRangeFt") or {}
|
| 57 |
+
if size.get("min") is not None:
|
| 58 |
+
parts.append(f'<span class="badge-chip">📏 likes {size.get("min", "?")}–'
|
| 59 |
+
f'{size.get("max", "?")} ft</span>')
|
| 60 |
+
wind = b.get("idealWind") or {}
|
| 61 |
+
wind_bits = [_ for _ in (_join(wind.get("direction")), str(wind.get("type") or "")) if _]
|
| 62 |
+
if wind_bits:
|
| 63 |
+
parts.append(f'<span class="badge-chip">🌬 wind: {esc(", ".join(wind_bits))}</span>')
|
| 64 |
+
coords = (b.get("location") or {}).get("coordinates") or {}
|
| 65 |
+
if coords.get("lat") is not None and coords.get("lng") is not None:
|
| 66 |
+
parts.append(f'<span class="badge-chip">📍 {coords["lat"]:.3f}, '
|
| 67 |
+
f'{coords["lng"]:.3f}</span>')
|
| 68 |
if b.get("crowdFactor"):
|
| 69 |
parts.append(f'<span class="badge-chip">👥 {esc(str(b["crowdFactor"]))}</span>')
|
| 70 |
if b.get("bestSeason"):
|
|
|
|
| 78 |
return '<div class="badge-row">' + " ".join(parts) + "</div>"
|
| 79 |
|
| 80 |
|
| 81 |
+
def format_description(record: dict | None) -> str:
|
| 82 |
+
"""The break's own words, as a quiet paragraph under the badges."""
|
| 83 |
+
desc = html.escape((record or {}).get("description") or "").strip()
|
| 84 |
+
if not desc:
|
| 85 |
+
return "<div class='spot-desc spot-desc-empty'>no description on file for this spot</div>"
|
| 86 |
+
return f"<div class='spot-desc'>{desc}</div>"
|
| 87 |
+
|
| 88 |
+
|
| 89 |
def _empty_seafloor():
|
| 90 |
blank = seafloor_chart._empty_fig()
|
| 91 |
return blank, blank, blank
|
|
|
|
| 218 |
|
| 219 |
|
| 220 |
def build_story(selected, scored):
|
| 221 |
+
"""Build the story column. Returns component dict for wiring.
|
| 222 |
+
|
| 223 |
+
The story column leads with the break itself: name, description,
|
| 224 |
+
then the detail chips and the daily ratings. The surf-report
|
| 225 |
+
feature lives in the intel
|
| 226 |
+
tabs (``ui/panels/intel.py``); the ``narrate`` handler stays here —
|
| 227 |
+
the wiring points the intel tab's button at it.
|
| 228 |
+
"""
|
| 229 |
with gr.Column(scale=4):
|
| 230 |
spot_header = gr.Markdown("### pick a spot — search above or filter the map")
|
| 231 |
+
description_md = gr.Markdown()
|
| 232 |
badges_md = gr.Markdown()
|
|
|
|
|
|
|
| 233 |
hero_md = gr.Markdown("_the week summary lands here._")
|
| 234 |
with gr.Tabs():
|
| 235 |
with gr.Tab("score"):
|
|
|
|
| 239 |
with gr.Tab("wind"):
|
| 240 |
wind_plot = gr.Plot(label="wind — speed + direction")
|
| 241 |
gr.Markdown(f"<div class='strip-caption'>{strip_chart.CAPTION}</div>")
|
|
|
|
|
|
|
| 242 |
|
| 243 |
return {
|
| 244 |
+
"spot_header": spot_header, "badges_md": badges_md,
|
| 245 |
+
"description_md": description_md, "hero_md": hero_md,
|
| 246 |
+
"score_plot": score_plot, "swell_plot": swell_plot,
|
| 247 |
"wind_plot": wind_plot,
|
|
|
|
| 248 |
"fetch_forecast": fetch_forecast, "narrate": narrate,
|
| 249 |
}
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def build_footer():
|
| 253 |
+
"""The page footer: the engines/latency line, out of the story's way.
|
| 254 |
+
|
| 255 |
+
Same component + contract key as before (``status_box`` in
|
| 256 |
+
FETCH_KEYS) — only its position on the page moved.
|
| 257 |
+
"""
|
| 258 |
+
status_box = gr.Textbox(
|
| 259 |
+
label="engines", interactive=False, elem_classes=["foot-status"],
|
| 260 |
+
placeholder="idle — pick a spot and the spin-up line lands here…")
|
| 261 |
+
return {"status_box": status_box}
|
ui/theme.py
CHANGED
|
@@ -111,6 +111,7 @@ button.primary:hover, button.lg.primary:hover {
|
|
| 111 |
--checkbox-label-border-color-selected: #0b2c5c !important;
|
| 112 |
--checkbox-label-text-color: #0b2c5c !important;
|
| 113 |
--checkbox-label-text-color-selected: #ffffff !important;
|
|
|
|
| 114 |
}
|
| 115 |
.gradio-container .gr-box, .gradio-container .gr-panel,
|
| 116 |
.gradio-container .block, .gradio-container .form,
|
|
@@ -213,6 +214,60 @@ ul.options .item {
|
|
| 213 |
.badge-link { background: #d6e9f8 !important; text-decoration: none; font-weight: 600; }
|
| 214 |
.badge-link:hover { background: #0b2c5c !important; color: #ffffff !important; }
|
| 215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
/* week hero: day chips with score-coloured left borders */
|
| 217 |
.day-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0; }
|
| 218 |
.day-chip {
|
|
@@ -249,6 +304,43 @@ ul.options .item {
|
|
| 249 |
.agent-controls .wrap { flex-wrap: nowrap !important; }
|
| 250 |
.ctx-line { font-size: 0.78em; opacity: 0.8; margin: 0 2px 4px !important; }
|
| 251 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
/* agent trace: live tool cards with ms timing bars + payload previews */
|
| 253 |
.trace { display: flex; flex-direction: column; gap: 6px; }
|
| 254 |
.trace-ask { font-size: 0.78em; opacity: 0.7; margin-bottom: 2px; }
|
|
@@ -307,7 +399,10 @@ ul.options .item {
|
|
| 307 |
.mode-toggle .wrap { display: inline-flex !important; gap: 0 !important; }
|
| 308 |
.mode-toggle input[type="radio"],
|
| 309 |
.mode-toggle input[type="radio"] + * .circle,
|
| 310 |
-
.mode-toggle span.circle
|
|
|
|
|
|
|
|
|
|
| 311 |
.mode-toggle label {
|
| 312 |
border-radius: 999px !important; padding: 4px 14px !important;
|
| 313 |
font-size: 0.78em; font-weight: 600; color: #0b2c5c; cursor: pointer;
|
|
|
|
| 111 |
--checkbox-label-border-color-selected: #0b2c5c !important;
|
| 112 |
--checkbox-label-text-color: #0b2c5c !important;
|
| 113 |
--checkbox-label-text-color-selected: #ffffff !important;
|
| 114 |
+
--slider-color: #0b2c5c !important;
|
| 115 |
}
|
| 116 |
.gradio-container .gr-box, .gradio-container .gr-panel,
|
| 117 |
.gradio-container .block, .gradio-container .form,
|
|
|
|
| 214 |
.badge-link { background: #d6e9f8 !important; text-decoration: none; font-weight: 600; }
|
| 215 |
.badge-link:hover { background: #0b2c5c !important; color: #ffffff !important; }
|
| 216 |
|
| 217 |
+
/* intel rail: tab row styled after the story's score/swell/wind strip —
|
| 218 |
+
bare text tabs (no block chrome), one line, orange underline marks the
|
| 219 |
+
selected one (colors measured off the live strip: #f97316 selected) */
|
| 220 |
+
.gradio-container .intel-tabs,
|
| 221 |
+
.gradio-container form:has(.intel-tabs),
|
| 222 |
+
.gradio-container .block:has(> .intel-tabs) {
|
| 223 |
+
border: none !important;
|
| 224 |
+
background: transparent !important;
|
| 225 |
+
box-shadow: none !important;
|
| 226 |
+
padding: 0 !important;
|
| 227 |
+
}
|
| 228 |
+
.intel-tabs { margin: 0 0 4px; border-bottom: 1px solid #c9dff2; }
|
| 229 |
+
.intel-tabs .wrap { display: inline-flex !important; flex-wrap: nowrap !important; gap: 0 !important; }
|
| 230 |
+
.gradio-container .intel-tabs label {
|
| 231 |
+
border: none !important;
|
| 232 |
+
border-bottom: 2px solid transparent !important;
|
| 233 |
+
border-radius: 0 !important;
|
| 234 |
+
background: transparent !important;
|
| 235 |
+
box-shadow: none !important;
|
| 236 |
+
color: #0b2c5c !important;
|
| 237 |
+
font-size: 14px !important;
|
| 238 |
+
font-weight: 400 !important;
|
| 239 |
+
padding: 4px 16px 6px !important;
|
| 240 |
+
margin: 0 0 -1px !important;
|
| 241 |
+
white-space: nowrap !important;
|
| 242 |
+
}
|
| 243 |
+
.gradio-container .intel-tabs label.selected,
|
| 244 |
+
.gradio-container .intel-tabs label:has(input[type="radio"]:checked) {
|
| 245 |
+
background: transparent !important;
|
| 246 |
+
color: #f97316 !important;
|
| 247 |
+
border-bottom: 2px solid #f97316 !important;
|
| 248 |
+
}
|
| 249 |
+
.gradio-container .intel-tabs label.selected span,
|
| 250 |
+
.gradio-container .intel-tabs label:has(input[type="radio"]:checked) span {
|
| 251 |
+
color: #f97316 !important;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
/* spot description: the break's own words, right under the spot name */
|
| 255 |
+
.spot-desc { font-size: 0.85em; line-height: 1.55; margin: 2px 0 4px !important; }
|
| 256 |
+
.spot-desc-empty { opacity: 0.55; font-style: italic; }
|
| 257 |
+
|
| 258 |
+
/* page footer: the machinery line (engines + latencies) below the fold */
|
| 259 |
+
.page-foot { border-top: 2px solid #c9dff2; margin-top: 10px; }
|
| 260 |
+
.gradio-container .page-foot .form,
|
| 261 |
+
.gradio-container .page-foot .block {
|
| 262 |
+
border: none !important; background: transparent !important;
|
| 263 |
+
padding: 0 !important; box-shadow: none !important;
|
| 264 |
+
}
|
| 265 |
+
.gradio-container .page-foot label span { font-size: 0.72em; opacity: 0.7; }
|
| 266 |
+
.gradio-container .page-foot textarea {
|
| 267 |
+
background: transparent !important; border: none !important;
|
| 268 |
+
font-size: 0.8em !important; padding: 2px 2px !important; resize: none !important;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
/* week hero: day chips with score-coloured left borders */
|
| 272 |
.day-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0; }
|
| 273 |
.day-chip {
|
|
|
|
| 304 |
.agent-controls .wrap { flex-wrap: nowrap !important; }
|
| 305 |
.ctx-line { font-size: 0.78em; opacity: 0.8; margin: 0 2px 4px !important; }
|
| 306 |
|
| 307 |
+
/* max-calls slider: ink-blue instrument, no number box — the thumb is the
|
| 308 |
+
value; min/max labels bracket the track */
|
| 309 |
+
.steps-slider .tab-like-container { display: none !important; }
|
| 310 |
+
.steps-slider .slider_input_container { align-items: center !important; margin-top: 4px !important; }
|
| 311 |
+
.steps-slider .min_value, .steps-slider .max_value {
|
| 312 |
+
font-size: 12px !important; font-weight: 600 !important;
|
| 313 |
+
color: #4a6fa5 !important; opacity: 1 !important;
|
| 314 |
+
}
|
| 315 |
+
.steps-slider input[type="range"] { height: 20px !important; }
|
| 316 |
+
.steps-slider input[type="range"]::-webkit-slider-runnable-track {
|
| 317 |
+
height: 8px !important; border-radius: 999px !important;
|
| 318 |
+
background: linear-gradient(to right, #0b2c5c var(--range_progress), #c9dff2 var(--range_progress)) !important;
|
| 319 |
+
}
|
| 320 |
+
.steps-slider input[type="range"]::-webkit-slider-thumb {
|
| 321 |
+
appearance: none !important;
|
| 322 |
+
height: 18px !important; width: 18px !important; margin-top: -5px !important;
|
| 323 |
+
background: #ffffff !important;
|
| 324 |
+
border: 2.5px solid #0b2c5c !important;
|
| 325 |
+
border-radius: 50% !important;
|
| 326 |
+
box-shadow: 0 1px 4px rgba(11, 44, 92, 0.3) !important;
|
| 327 |
+
transition: transform 0.12s ease !important;
|
| 328 |
+
}
|
| 329 |
+
.steps-slider input[type="range"]:hover::-webkit-slider-thumb,
|
| 330 |
+
.steps-slider input[type="range"]:active::-webkit-slider-thumb { transform: scale(1.15); }
|
| 331 |
+
.steps-slider input[type="range"]::-moz-range-track {
|
| 332 |
+
height: 8px !important; border-radius: 999px !important; background: #c9dff2 !important;
|
| 333 |
+
}
|
| 334 |
+
.steps-slider input[type="range"]::-moz-range-progress {
|
| 335 |
+
height: 8px !important; border-radius: 999px !important; background: #0b2c5c !important;
|
| 336 |
+
}
|
| 337 |
+
.steps-slider input[type="range"]::-moz-range-thumb {
|
| 338 |
+
height: 14px !important; width: 14px !important;
|
| 339 |
+
background: #ffffff !important; border: 2.5px solid #0b2c5c !important;
|
| 340 |
+
border-radius: 50% !important;
|
| 341 |
+
box-shadow: 0 1px 4px rgba(11, 44, 92, 0.3) !important;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
/* agent trace: live tool cards with ms timing bars + payload previews */
|
| 345 |
.trace { display: flex; flex-direction: column; gap: 6px; }
|
| 346 |
.trace-ask { font-size: 0.78em; opacity: 0.7; margin-bottom: 2px; }
|
|
|
|
| 399 |
.mode-toggle .wrap { display: inline-flex !important; gap: 0 !important; }
|
| 400 |
.mode-toggle input[type="radio"],
|
| 401 |
.mode-toggle input[type="radio"] + * .circle,
|
| 402 |
+
.mode-toggle span.circle,
|
| 403 |
+
.intel-tabs input[type="radio"],
|
| 404 |
+
.intel-tabs input[type="radio"] + * .circle,
|
| 405 |
+
.intel-tabs span.circle { display: none !important; }
|
| 406 |
.mode-toggle label {
|
| 407 |
border-radius: 999px !important; padding: 4px 14px !important;
|
| 408 |
font-size: 0.78em; font-weight: 600; color: #0b2c5c; cursor: pointer;
|