lucashudsn commited on
Commit
fa1cfdb
·
1 Parent(s): 86d0a08

Bug fixes, daggr and gradio working

Browse files
.gitignore CHANGED
@@ -5,3 +5,6 @@ __pycache__/
5
 
6
  # Environment
7
  .env
 
 
 
 
5
 
6
  # Environment
7
  .env
8
+
9
+ # Cache
10
+ .cache/
README.md CHANGED
@@ -97,10 +97,10 @@ Gradio UI:
97
  uv run python -m wavereader.app
98
  ```
99
 
100
- Legacy dev server:
101
 
102
  ```sh
103
- uv run fastapi dev main.py
104
  ```
105
 
106
  ## Built with open everything
 
97
  uv run python -m wavereader.app
98
  ```
99
 
100
+ daggr "Morning Surf Report" canvas:
101
 
102
  ```sh
103
+ uv run python -m wavereader.daggr_pipeline
104
  ```
105
 
106
  ## Built with open everything
ROADMAP.md CHANGED
@@ -1,134 +1,108 @@
1
- # ROADMAP — GTC Golden Ticket sprint
2
 
3
- One-week sprint: **Sun Aug 30 → video published Mon Sep 7, 2026.**
4
- Contest submissions close **Sep 10** (3-day buffer). Deliverables: public HF
5
- Space, polished repo, ~2-minute video posted on LinkedIn with #NVIDIAGTC
6
- tagging Merve Noyan — doubling as an announcement of the Berlin move and
7
- openness to ML work/research in Berlin.
8
 
9
- Decisions locked: 8-day build to Mon Sep 7 · HF Inference + NVIDIA NIM behind
10
- one client · surf cams are stretch-only · demo lives on a HF Space ·
11
- FastAPI + smolagents CodeAgent + Gradio + daggr (no LangChain, no htmx).
12
 
13
  ---
14
 
15
- ## Day-by-day
16
-
17
- ### Sun Aug 30 (tonight, ~2h)
18
- - Restructure repo to the layout in `AGENTS.md`; pin Python 3.12; add deps
19
- (`gradio`, `daggr`, `openai`, `httpx`, `pydantic`, `pytest`); drop
20
- `langchain-community`.
21
- - Grab a free NVIDIA NIM API key (build.nvidia.com); verify HF token works.
22
- - 30-min spike: hit Open-Meteo Marine API for Snapper Rocks, confirm hourly
23
- wave fields look sane.
24
-
25
- ### Mon Aug 31 — Data layer
26
- - Validation pass over `data/break_details.json`: coords plausible for region,
27
- ranges sane; spot-check ~10 breaks against local knowledge.
28
- - `spots.py` (search by name/city/region/skill) + `forecasts.py`
29
- (Open-Meteo Marine + wind, hourly, 7-day, disk cache).
30
- - 📹 **Capture:** terminal printing a 7-day forecast table for 3 spots.
31
-
32
- ### Tue Sep 1 — Scoring engine
33
- - `scoring.py`: deterministic 0–10 score per hour with per-component
34
- breakdown (swell size vs ideal range, swell direction match, wind speed +
35
- offshore alignment, wave period). Unit tests.
36
- - Sanity-check rankings: Bells Beach should score on SW swell + light NW
37
- wind; Kirra on long-period E.
38
- - Outputs: "best hours today" and day-by-week profile per spot.
39
- - 📹 **Capture:** score table / heatmap clip.
40
-
41
- ### Wed Sep 2 — Agent core
42
- - `agent.py`: smolagents CodeAgent, streaming, trace capture; HF⇄NIM switch
43
- via env vars. `tools.py`: 5 tools wired (get_forecast, score_week,
44
- find_spots, get_spot_knowledge, rank_spots_this_week).
45
- - System prompt contract: numbers come from tools; the model explains.
46
- - 📹 **Capture:** the first complete agent answer, raw and candid — great b-roll.
47
-
48
- ### Thu Sep 3 — Flows + evals
49
- - End-to-end flows: near-me weekly ranking with skill filter; today-at-X time
50
- recommendation; spot Q&A using the knowledge tool.
51
- - User profile (skill level, home region) carried through conversation.
52
- - Write + run `evals/golden_questions.json`; fix prompt/tool gaps.
53
-
54
- ### Fri Sep 4 — API + UI
55
- - `api.py`: FastAPI endpoints (`/spots` `/forecast` `/score` `/ask`).
56
- - `app.py`: Gradio UI — **Australia map home page (select your break)**,
57
- chat, **interactive seaborn-styled forecast charts** whenever the agent
58
- surfaces wind/sea data, agent trace panel, HF/NIM model toggle.
59
- - 📹 **Capture:** polished Demo Q1 + OpenAPI docs screen.
60
-
61
- ### Sat Sep 5 — daggr + ship
62
- - `daggr_pipeline.py`: fixed "Morning Surf Report" DAG (geocode → find_spots
63
- → score_week → top-3 rank → InferenceNode narrates). Verify node outputs
64
- look good on the canvas.
65
- - Deploy to HF Space (secrets: `HF_TOKEN`, `NVIDIA_API_KEY`); test the public
66
- URL end-to-end.
67
- - Publish `data/break_details.json` as a HF dataset; README architecture
68
- diagram; license.
69
- - Stretch (only if everything else lands): live surf-cam snapshots → "is it
70
- clean/right now" tool for a handful of famous breaks.
71
- - 📹 **Capture:** daggr canvas walkthrough; Space opened on a phone.
72
-
73
- ### Sun Sep 6 — Rehearse + record
74
- - Run evals against the live Space; fix anything that breaks.
75
- - Full rehearsal, then record the main video (script below).
76
- - Draft the LinkedIn post.
77
-
78
- ### Mon Sep 7 — Launch
79
- - Edit and publish the video; post to LinkedIn (contest entry + Berlin
80
- announcement); submit the contest entry.
81
- - Sep 8–10: buffer for retakes / judge replies.
82
 
83
  ---
84
 
85
- ## Video script (~2 min, landscape screen captures + webcam bubble)
86
-
87
- Record each shot's milestone clip during the week as insurance; the final
88
- video is a re-recorded clean pass through the live Space.
89
-
90
- 1. **Hook (0:00–0:10).** "I moved to Berlin last week — 16,000 km from the
91
- nearest surf break. So I built an agent that finds waves back home."
92
- *(B-roll: coastline → app loading.)*
93
- 2. **Problem (0:10–0:25).** "Surf forecasting runs on local knowledge — which
94
- winds suit which break, what size it can handle." *(Show
95
- `break_details.json` snippet + raw forecast table.)*
96
- 3. **Demo Q1 (0:25–0:55).** In the app: *"Where should I surf near Byron Bay
97
- this weekend? Intermediate longboarder."* → ranked spot cards with scores
98
- and the why. *(Show the agent trace panel streaming tool calls.)*
99
- 4. **Demo Q2 (0:55–1:15).** *"What time should I surf Snapper Rocks
100
- tomorrow?"* → interactive hourly chart, best-window recommendation with
101
- wind/swell reasoning.
102
- 5. **Reasoning canvas (1:15–1:35).** daggr Morning Surf Report — click
103
- through node outputs live: rank → forecast → narrated report.
104
- 6. **Under the hood (1:35–1:50).** FastAPI OpenAPI docs flash; flip the
105
- HF ↔ NIM toggle; show the break knowledge base as a HF dataset.
106
- 7. **CTA (1:50–2:10).** "Everything runs on open models — Qwen via Hugging
107
- Face and NVIDIA NIM. Free GTC Berlin ticket, please 🤞 #NVIDIAGTC — and
108
- since I just moved to Berlin: if you're working on LLM agents or applied
109
- ML, I'd love to talk. DMs open." *(Screen: Space link + repo link.)*
110
-
111
- ## LinkedIn post skeleton
112
-
113
- 1. Hook: the move to Berlin.
114
- 2. The problem: surf forecasting's inherent knowledge.
115
- 3. Two sentences: what the app does + the two flagship questions.
116
- 4. Video + Space link.
117
- 5. Open-credentials: Qwen via HF Inference + NVIDIA NIM, open dataset on HF,
118
- daggr canvas.
119
- 6. Thanks + tags: judges, #NVIDIAGTC.
120
- 7. Berlin close: "New in Berlin, keen on ML research/engineering — say hi."
121
-
122
- *(Nice detail: Hugging Face is headquartered in Berlin — the judge will get it.)*
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
  ---
125
 
126
- ## Risks & fallbacks
127
 
128
- | Risk | Mitigation |
129
- | --- | --- |
130
- | Daggr beta misbehaves (no conditional branching; pin the version) | The agent trace panel is the reasoning demo; swap video shot 5 for a trace walkthrough. |
131
- | Open-Meteo coastal grid points sit offshore | Fetch slightly seaward coords; validate Tue against known break behavior. |
132
- | Demo-day API flakiness / rate limits | Disk-cached forecasts; daily milestone clips mean a retake never rebuilds the week. |
133
- | Tool-loop unreliability | Small tool set, strict structured outputs, `golden_questions.json` gates recording. |
134
- | Model availability on either router | Client is provider-agnostic; same model on HF and NIM; fallback model must support tool calling + `json_schema` (see `data/GENERATION.md`). |
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ROADMAP — wavereader
2
 
3
+ **Goal:** Agentic surf forecaster for Australian breaks. Demo video by **Mon Sep 7, 2026** for NVIDIA GTC Golden Ticket contest (#NVIDIAGTC).
 
 
 
 
4
 
5
+ **Stack:** Python 3.12, uv, FastAPI, smolagents CodeAgent, Gradio, daggr, Open-Meteo Marine, HF Inference + NVIDIA NIM.
 
 
6
 
7
  ---
8
 
9
+ ## ✅ Done (through Thu Sep 3)
10
+
11
+ - [x] Repo restructure per `AGENTS.md`; Python 3.12; deps added; `langchain-community` dropped
12
+ - [x] NVIDIA NIM API key + HF token verified
13
+ - [x] Open-Meteo Marine spike: Snapper Rocks hourly fields look sane
14
+ - [x] `data/break_details.json` validation pass (coords, ranges, spot-check 10)
15
+ - [x] `spots.py`: search by name/city/region/skill
16
+ - [x] `forecasts.py`: Open-Meteo Marine + wind, hourly 7-day, disk cache
17
+ - [x] `scoring.py`: deterministic 0–10 score/hour, component breakdown, unit tests
18
+ - [x] Scoring sanity checks: Bells (SW swell + NW wind), Kirra (long-period E)
19
+ - [x] `agent.py`: smolagents CodeAgent, streaming, trace capture, HF⇄NIM switch
20
+ - [x] `tools.py`: 5 tools wired (get_forecast, score_week, find_spots, get_spot_knowledge, rank_spots_this_week)
21
+ - [x] System prompt: numbers from tools, model explains
22
+ - [x] End-to-end flows: near-me weekly ranking with skill filter, best-hours-today, spot Q&A
23
+ - [ ] User profile (skill, home region) carried through conversation (skill filter works via chat; no persistent profile yet)
24
+ - [ ] `evals/golden_questions.json` written; spot-checked manually (ranking+skill, best-hours, spot Q&A) — automated runner still TODO
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  ---
27
 
28
+ ### User Data & Personalization
29
+
30
+ - [ ] **User profile model** — `User` class with:
31
+ - `skill_level`: beginner / intermediate / advanced / pro → feeds `scoring.py`
32
+ - `home_region`: state/region for default "near me"
33
+ - `preferences`:
34
+ - `wave_direction`: left / right / both
35
+ - `state`: filter by Australian state
36
+ - `location`: lat/lon + radius for "near me"
37
+ - `other_preferences`: tags (reef, beach, point, crowd_tolerance, etc.)
38
+ - [ ] **Skill level → scoring engine**
39
+ - Adjust ideal swell size per skill (beginner: 0.5–1.5m, pro: 2–4m+)
40
+ - Weight wind/period components differently per skill
41
+ - Return skill-adjusted score in `/score`
42
+ - [ ] **Preferences → pre-filter breaks** (before scoring)
43
+ - Filter by `wave_direction` match
44
+ - Filter by `state` match
45
+ - Filter by `location` radius (haversine)
46
+ - Filter by `other_preferences` tags
47
+ - Score only filtered subset
48
+ - [ ] **Custom local break (max 1/user)**
49
+ - Fields: name, lat, lon, region, optimal_swell_direction, optimal_swell_size_range, optimal_wind_direction, skill_level_suitability, notes
50
+ - Validate coords against Open-Meteo grid
51
+ - Merge into user's break list for ranking; persist in profile
52
+ - UI: Gradio form (modal/sidebar) with "Add my local break"
53
+
54
+ ### Infrastructure & Polish
55
+
56
+ - [ ] Persist user profiles (SQLite or HF dataset) — survive restarts
57
+ - [ ] Auth (email/token or HF OAuth) for multi-user Space
58
+ - [ ] Migrate `data/break_details.json` → proper DB for concurrent writes
59
+ - [ ] Add tide data source (stretch: WillyWeather integration)
60
+
61
+ ### API + UI
62
+
63
+ - [x] `api.py`: FastAPI endpoints (`/spots`, `/forecast`, `/score`, `/ask`)
64
+ - [x] `app.py`: Gradio UI — Australia map home page, chat, interactive seaborn-styled charts, agent trace panel, HF/NIM toggle
65
+ - [ ] 📹 Capture: polished Demo Q1 + OpenAPI docs screen
66
+
67
+ ### daggr + Deploy
68
+
69
+ - [x] `daggr_pipeline.py`: "Morning Surf Report" DAG (find_spots → fetch forecasts → score → rank → InferenceNode narrates); embeds in the UI via iframe
70
+ - [ ] Deploy to HF Space (secrets: `HF_TOKEN`, `NVIDIA_API_KEY`); test public URL end-to-end
71
+ - [ ] Publish `data/break_details.json` as HF dataset; README architecture diagram; license
72
+ - [ ] 📹 Capture: daggr canvas walkthrough; Space on phone
73
+ - [ ] Stretch: live surf-cam snapshots tool for famous breaks
74
+
75
+ ### Rehearse + Record
76
+
77
+ - [ ] Run evals against live Space; fix breaks
78
+ - [ ] Full rehearsal → record main video
79
+ - [ ] Draft LinkedIn post
80
+
81
+ ### Launch
82
+
83
+ - [ ] Edit + publish video; post to LinkedIn (contest + Berlin announcement); submit entry
84
+ - [ ] Sep 8–10 buffer for retakes / judge replies
85
 
86
  ---
87
 
88
+ ## Video Script (~2 min)
89
 
90
+ 1. **Hook (0:00–0:10)** — "I moved to Berlin — 16,000 km from surf. Built an agent to find waves back home."
91
+ 2. **Problem (0:10–0:25)** — Local knowledge: which winds suit which break. Show `break_details.json` + raw forecast.
92
+ 3. **Demo Q1 (0:25–0:55)** — "Where should I surf near Byron Bay this weekend? Intermediate longboarder." → ranked cards + trace panel.
93
+ 4. **Demo Q2 (0:55–1:15)** — "What time should I surf Snapper Rocks tomorrow?" → interactive hourly chart + best-window reasoning.
94
+ 5. **Reasoning canvas (1:15–1:35)** — daggr Morning Surf Report: click through node outputs live.
95
+ 6. **Under the hood (1:35–1:50)** — FastAPI OpenAPI docs; HF ↔ NIM toggle; break knowledge base as HF dataset.
96
+ 7. **CTA (1:50–2:10)** — Open models (Qwen via HF + NVIDIA NIM). Free GTC Berlin ticket 🤞 #NVIDIAGTC. New in Berlin — DMs open for ML work.
97
+
98
+ ---
99
+
100
+ ## Risks & Fallbacks
101
+
102
+ | Risk | Mitigation |
103
+ | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
104
+ | Daggr beta misbehaves (no conditional branching) | Agent trace panel = reasoning demo; swap canvas shot for trace walkthrough |
105
+ | Open-Meteo grid points sit offshore | Fetch slightly seaward coords; validate against known break behavior |
106
+ | Demo-day API flakiness / rate limits | Disk-cached forecasts; daily milestone clips = no rebuild needed for retakes |
107
+ | Tool-loop unreliability | Small tool set, strict structured outputs, `golden_questions.json` gates recording |
108
+ | Model availability on either router | Provider-agnostic client; same model on HF + NIM; fallback must support tool calling + `json_schema` |
docs/ARCHITECTURE.md ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Architecture
2
+
3
+ How wavereader turns a natural-language question into a grounded surf
4
+ recommendation. The one-sentence version: **the LLM orchestrates, code owns
5
+ the numbers.**
6
+
7
+ ```
8
+ question ──▶ SurfAgent (smolagents CodeAgent, Qwen3-Next-80B)
9
+ │ writes python code that calls tools
10
+ ▼
11
+ tools.py ──▶ spots.py (knowledge base)
12
+ │ forecasts.py (Open-Meteo + disk cache)
13
+ │ scoring.py (deterministic 0–10 score)
14
+ ▼
15
+ streaming answer + AgentTrace ──▶ Gradio UI (trace panel + auto-charts)
16
+ ```
17
+
18
+ ## Modules
19
+
20
+ | Module | Role | Key facts |
21
+ |---|---|---|
22
+ | `spots.py` | Loads + validates `data/breaks.json` / `break_details.json`; search | Records keyed by `(name, region)`; coords validated against per-state bounding boxes; 6 regions (NSW, QLD, VIC, WA, SA, TAS) |
23
+ | `forecasts.py` | Open-Meteo Marine + weather client | Hourly, 7-day horizon; merges two endpoints on `time`; disk cache with stale fallback (below) |
24
+ | `scoring.py` | Deterministic surf-quality score | Pure functions, no I/O; per-hour 0–10 score + component breakdown (below) |
25
+ | `agent.py` | smolagents CodeAgent wrapper | `SurfAgent`, provider switching, streaming, `AgentTrace`; `max_steps=10` |
26
+ | `tools.py` | Agent tools + matplotlib plot builders | 5 core tools; plain functions resolve spot by `(name, region)` then delegate |
27
+ | `api.py` | FastAPI backend | `/spots`, `/forecast`, `/score`, `/ask` — see [API.md](API.md) |
28
+ | `app.py` | Gradio UI | Map-first layout, streaming chat, trace panel, auto-charts — see [UI_GUIDE.md](UI_GUIDE.md) |
29
+ | `daggr_pipeline.py` | Fixed Morning Surf Report DAG | Visualises the deterministic pipeline only, **never** the dynamic chat loop (daggr beta has no conditional branching) |
30
+
31
+ ## The model boundary
32
+
33
+ `SYSTEM_PROMPT` in `agent.py` enforces the product's core rule: the agent
34
+ never computes a number. It writes code that calls tools, reads the results,
35
+ and explains them. Everything numeric comes from:
36
+
37
+ - **Forecasts** — Open-Meteo responses, normalised to hourly rows
38
+ (`time`, `wave_height`, `wave_period`, `wave_direction`, `wind_speed_10m`,
39
+ `wind_direction_10m`).
40
+ - **Scores** — `scoring.score_hour()`, weighted:
41
+ swell_size 0.30 · swell_direction 0.20 · wind 0.30 · period 0.20.
42
+
43
+ Component logic (all continuous curves, no cliff edges):
44
+
45
+ - **Swell size** — 10 inside the spot's ideal `size_ft_min–max` window,
46
+ Gaussian decay outside, zero above the skill profile's `max_safe_size_ft`.
47
+ - **Swell direction** — Gaussian on angular distance from the spot's ideal
48
+ direction (45° tolerance); `None` when Open-Meteo has no wave direction,
49
+ and its weight is redistributed across the remaining components (never
50
+ silently substituted with wind direction).
51
+ - **Wind** — mean of a speed score and a direction score. Speed: glassy
52
+ (≤ 5 kt) is always 10, above `min(spot max, skill max)` it is 0.
53
+ Direction: cosine falloff from perfect offshore to absolute onshore.
54
+ - **Period** — linear ramp, 0 at ≤ 4 s up to 10 at ≥ 14 s; long groundswell
55
+ is never penalised.
56
+
57
+ Skill profiles (`beginner / intermediate / advanced / expert`) cap wave size,
58
+ wind tolerance and set the comfort band. Every scored row carries its raw
59
+ inputs back (`wave_height_m`, `wind_speed_kt`, …) so the UI and trace can
60
+ show *why* a score is what it is.
61
+
62
+ ## Forecast fetching and the demo-survivability cache
63
+
64
+ - Grid points sit offshore: coords are snapped **0.13° seaward** — Tasmania
65
+ and the south coast shift south, the east coast (lon ≥ 147) shifts east,
66
+ the west coast (lon ≤ 125) shifts west.
67
+ - Responses are cached to disk at `.cache/forecasts/{lat}_{lon}_{days}.json`
68
+ as `{"fetched_at": ..., "data": ...}` envelopes, TTL **6 hours**.
69
+ - On a network failure the most recent entry — however stale — is served
70
+ instead of raising; the app only errors when there is no data at all. This
71
+ is deliberate: the demo video must survive API flakiness and rate limits.
72
+ - On UI startup a background thread warms the cache for all 100 breaks
73
+ (disable with `WAVEREADER_WARM_CACHE=0`).
74
+
75
+ ## Agent ↔ UI contract
76
+
77
+ `SurfAgent.run_stream(question)` yields:
78
+
79
+ - `("model", delta)` — LLM token chunks (the agent's code-as-reasoning)
80
+ - `("tool", tool_name)` — a tool call is happening
81
+ - `("final", answer)` — the clean final answer
82
+
83
+ Every run also fills an `AgentTrace` (`trace.to_json()`), with event types
84
+ `tool_call` / `tool_result` / `llm_chunk` / `final_answer`. The UI renders
85
+ this JSON in the trace panel and scrapes the code steps for
86
+ `get_forecast` / `score_week` / `get_spot_knowledge` /
87
+ `rank_spots_this_week(...)` invocations — whichever spots the agent touched
88
+ get interactive plotly charts rendered beneath the chat. Charts are drawn by
89
+ the UI, never by the agent, so the model's context stays small and the
90
+ graphics stay interactive.
91
+
92
+ Note for tool-call tracing: smolagents CodeAgents emit one
93
+ `python_interpreter` call per step whose "arguments" are the generated code
94
+ blob — the trace panel detects real tool usage inside the blob by regex.
95
+
96
+ ## Provider switching
97
+
98
+ Both hosts speak the OpenAI chat-completions protocol, so one client class
99
+ (smolagents `OpenAIServerModel`) covers the switch:
100
+
101
+ | Provider | Base URL | Env var |
102
+ |---|---|---|
103
+ | `hf` (default) | `https://router.huggingface.co/v1` | `HF_TOKEN` |
104
+ | `nim` | `https://integrate.api.nvidia.com/v1` | `NVIDIA_API_KEY` |
105
+
106
+ When both tokens are set, NIM wins auto-detection; the UI exposes an explicit
107
+ radio switch. `Qwen/Qwen3-Next-80B-A3B-Instruct` is the only model verified
108
+ for both OpenAI-style tool calling and strict `json_schema` on the HF router
109
+ — read `data/GENERATION.md` before swapping models.
110
+
111
+ ## Testing
112
+
113
+ `uv run pytest` (134 tests) covers scoring curves, cache behaviour/offsets,
114
+ spot validation, tool wiring, the agent (mocked model), the API, the daggr
115
+ pipeline and the UI chart builders. The agent tests mock the LLM, so the
116
+ suite runs offline; the forecast tests mock HTTP but exercise the real cache
117
+ code paths via `tmp_path`.
pyproject.toml CHANGED
@@ -11,8 +11,12 @@ dependencies = [
11
  "gradio",
12
  "httpx",
13
  "huggingface-hub>=1.29.0",
 
14
  "openai",
 
15
  "pydantic",
 
 
16
  "smolagents>=1.26.0",
17
  ]
18
 
 
11
  "gradio",
12
  "httpx",
13
  "huggingface-hub>=1.29.0",
14
+ "matplotlib>=3.11.1",
15
  "openai",
16
+ "plotly>=7.0.0",
17
  "pydantic",
18
+ "pyyaml",
19
+ "seaborn>=0.13.2",
20
  "smolagents>=1.26.0",
21
  ]
22
 
tests/test_agent.py CHANGED
@@ -3,10 +3,12 @@
3
  from __future__ import annotations
4
 
5
  import json
6
- import os
7
  from unittest.mock import MagicMock, patch
8
 
9
  import pytest
 
 
 
10
 
11
  from wavereader.agent import (
12
  AgentTrace,
@@ -118,9 +120,9 @@ class TestScoreWeekTool:
118
  def test_forward_spot_not_found(self):
119
  tool = ScoreWeekTool()
120
  with patch("wavereader.agent.score_week") as mock_score:
121
- mock_score.return_value = []
122
  result = tool.forward("Bad", "XX")
123
- assert result == []
124
 
125
 
126
  class TestFindSpotsTool:
@@ -253,93 +255,106 @@ class TestSurfAgentRun:
253
  assert agent.trace.question == "What's the surf like?"
254
  assert agent.trace.provider == PROVIDER_HF
255
 
256
- def test_run_stream_yields_chunks(self, mock_build_model):
 
 
 
 
 
257
  mock_agent = MagicMock()
258
- mock_agent.run.return_value = iter(["chunk1", "chunk2", "chunk3"])
259
 
260
  with patch("wavereader.agent.CodeAgent", return_value=mock_agent):
261
  agent = SurfAgent(provider=PROVIDER_HF)
262
  chunks = list(agent.run_stream("Test question"))
263
 
264
- assert chunks == ["chunk1", "chunk2", "chunk3"]
 
 
 
 
265
  assert agent.trace is not None
 
 
 
266
 
267
  def test_run_stream_captures_tool_calls_in_trace(self, mock_build_model):
268
- """Verify trace captures tool calls during streaming."""
269
- from smolagents.memory import ActionStep, ToolCall
270
- from smolagents.monitoring import Timing
271
-
272
- # Create a proper ActionStep-like object with tool_calls
273
- tool_call = ToolCall(name="get_forecast", arguments={"spot_name": "Snapper", "region": "QLD"}, id="call-1")
274
- memory_step_with_tool = ActionStep(
275
- step_number=1,
276
- timing=Timing(start_time=0, end_time=0),
277
- tool_calls=[tool_call],
278
- observations="forecast data",
279
- is_final_answer=False,
280
  )
281
- memory_step_without_tool = ActionStep(
282
- step_number=2,
283
- timing=Timing(start_time=0, end_time=0),
284
- tool_calls=[],
285
- observations="",
286
- is_final_answer=True,
287
  )
288
 
289
- call_count = {"count": 0}
290
- step_results = [memory_step_with_tool, memory_step_without_tool]
291
-
292
- def mock_step(memory_step, *args, **kwargs):
293
- result = step_results[call_count["count"]]
294
- call_count["count"] += 1
295
- return result
296
-
297
- # Use a real object instead of MagicMock so step can be dynamically replaced
298
- class MockAgent:
299
- def __init__(self):
300
- self.step = mock_step
301
 
302
  def run(self, question, stream=False):
303
- if stream:
304
- # Call step through self.step (which will be wrapped by traced_step)
305
- self.step(memory_step_with_tool)
306
- yield "chunk 1"
307
- self.step(memory_step_without_tool)
308
- yield "chunk 2"
309
- return "final answer"
310
-
311
- mock_agent = MockAgent()
312
 
313
- with patch("wavereader.agent.CodeAgent", return_value=mock_agent):
314
  agent = SurfAgent(provider=PROVIDER_HF)
315
- list(agent.run_stream("Test"))
316
 
 
317
  trace = agent.trace
318
  tool_call_events = [e for e in trace.events if e.type == "tool_call"]
319
  tool_result_events = [e for e in trace.events if e.type == "tool_result"]
320
 
321
  assert len(tool_call_events) == 1
322
  assert tool_call_events[0].data["name"] == "get_forecast"
323
- # observations is a string, so traced_step iterates character by character
324
- # "forecast data" = 12 chars + 1 space = 13 events
325
- assert len(tool_result_events) == 13
326
- # First and last character events
327
- assert tool_result_events[0].data["observation"] == "f"
328
- assert tool_result_events[-1].data["observation"] == "a"
329
-
330
- def test_run_stream_captures_llm_chunks(self, mock_build_model):
331
- mock_agent = MagicMock()
332
- mock_agent.step = MagicMock(return_value=MagicMock(tool_calls=[], observations=[]))
333
- mock_agent.run.return_value = iter(["hello", " world"])
 
 
 
 
 
 
 
334
 
335
- with patch("wavereader.agent.CodeAgent", return_value=mock_agent):
 
 
 
 
 
 
 
 
 
336
  agent = SurfAgent(provider=PROVIDER_HF)
337
- list(agent.run_stream("Test"))
338
 
339
- llm_events = [e for e in agent.trace.events if e.type == "llm_chunk"]
340
- assert len(llm_events) == 2
341
- assert llm_events[0].data["chunk"] == "hello"
342
- assert llm_events[1].data["chunk"] == " world"
 
343
 
344
 
345
  # ---------------------------------------------------------------------------
@@ -357,13 +372,17 @@ def test_run_agent_function(mock_build_model):
357
 
358
 
359
  def test_run_agent_stream_function(mock_build_model):
 
 
 
 
360
  mock_agent = MagicMock()
361
- mock_agent.run.return_value = iter(["stream", "ing"])
362
 
363
  with patch("wavereader.agent.CodeAgent", return_value=mock_agent):
364
  result = list(run_agent_stream("Test question", provider=PROVIDER_HF))
365
 
366
- assert result == ["stream", "ing"]
367
 
368
 
369
  # ---------------------------------------------------------------------------
 
3
  from __future__ import annotations
4
 
5
  import json
 
6
  from unittest.mock import MagicMock, patch
7
 
8
  import pytest
9
+ from smolagents.agents import ToolOutput
10
+ from smolagents.memory import FinalAnswerStep, ToolCall as SmolToolCall
11
+ from smolagents.models import ChatMessageStreamDelta
12
 
13
  from wavereader.agent import (
14
  AgentTrace,
 
120
  def test_forward_spot_not_found(self):
121
  tool = ScoreWeekTool()
122
  with patch("wavereader.agent.score_week") as mock_score:
123
+ mock_score.return_value = {"error": "Spot 'Bad' (XX) not found"}
124
  result = tool.forward("Bad", "XX")
125
+ assert "error" in result
126
 
127
 
128
  class TestFindSpotsTool:
 
255
  assert agent.trace.question == "What's the surf like?"
256
  assert agent.trace.provider == PROVIDER_HF
257
 
258
+ def test_run_stream_yields_deltas_and_final(self, mock_build_model):
259
+ events = [
260
+ ChatMessageStreamDelta(content="Hello "),
261
+ ChatMessageStreamDelta(content="world"),
262
+ FinalAnswerStep(output="Hello world"),
263
+ ]
264
  mock_agent = MagicMock()
265
+ mock_agent.run.return_value = iter(events)
266
 
267
  with patch("wavereader.agent.CodeAgent", return_value=mock_agent):
268
  agent = SurfAgent(provider=PROVIDER_HF)
269
  chunks = list(agent.run_stream("Test question"))
270
 
271
+ assert chunks == [
272
+ ("model", "Hello "),
273
+ ("model", "world"),
274
+ ("final", "Hello world"),
275
+ ]
276
  assert agent.trace is not None
277
+ final_events = [e for e in agent.trace.events if e.type == "final_answer"]
278
+ assert len(final_events) == 1
279
+ assert final_events[0].data["answer"] == "Hello world"
280
 
281
  def test_run_stream_captures_tool_calls_in_trace(self, mock_build_model):
282
+ """Trace captures tool calls/results flowing through _step_stream.
283
+
284
+ Mirrors real smolagents: _run_stream yields whatever _step_stream
285
+ yields, so tool events surface via the wrapped generator.
286
+ """
287
+ tool_call = SmolToolCall(
288
+ name="get_forecast",
289
+ arguments={"spot_name": "Snapper Rocks", "region": "QLD"},
290
+ id="call-1",
 
 
 
291
  )
292
+ tool_output = ToolOutput(
293
+ id="call-1",
294
+ output={"hourly": []},
295
+ is_final_answer=False,
296
+ observation="forecast data",
297
+ tool_call=tool_call,
298
  )
299
 
300
+ class FakeAgent:
301
+ def _step_stream(self, memory_step):
302
+ yield tool_call
303
+ yield tool_output
 
 
 
 
 
 
 
 
304
 
305
  def run(self, question, stream=False):
306
+ for output in self._step_stream(None):
307
+ yield output
308
+ yield FinalAnswerStep(output="done")
 
 
 
 
 
 
309
 
310
+ with patch("wavereader.agent.CodeAgent", return_value=FakeAgent()):
311
  agent = SurfAgent(provider=PROVIDER_HF)
312
+ results = list(agent.run_stream("Test"))
313
 
314
+ assert ("final", "done") in results
315
  trace = agent.trace
316
  tool_call_events = [e for e in trace.events if e.type == "tool_call"]
317
  tool_result_events = [e for e in trace.events if e.type == "tool_result"]
318
 
319
  assert len(tool_call_events) == 1
320
  assert tool_call_events[0].data["name"] == "get_forecast"
321
+ assert tool_call_events[0].data["arguments"] == {
322
+ "spot_name": "Snapper Rocks",
323
+ "region": "QLD",
324
+ }
325
+ assert len(tool_result_events) == 1
326
+ assert tool_result_events[0].data["observation"] == "forecast data"
327
+ assert tool_result_events[0].data["name"] == "get_forecast"
328
+
329
+ def test_run_captures_tool_calls_in_trace(self, mock_build_model):
330
+ """The non-streaming run() path also captures tool activity."""
331
+ tool_call = SmolToolCall(name="find_spots", arguments={"region": "QLD"}, id="call-2")
332
+ tool_output = ToolOutput(
333
+ id="call-2",
334
+ output=[],
335
+ is_final_answer=False,
336
+ observation="2 spots",
337
+ tool_call=tool_call,
338
+ )
339
 
340
+ class FakeAgent:
341
+ def _step_stream(self, memory_step):
342
+ yield tool_call
343
+ yield tool_output
344
+
345
+ def run(self, question, stream=False):
346
+ list(self._step_stream({"step": 1}))
347
+ return "answer"
348
+
349
+ with patch("wavereader.agent.CodeAgent", return_value=FakeAgent()):
350
  agent = SurfAgent(provider=PROVIDER_HF)
351
+ result = agent.run("Test")
352
 
353
+ assert result == "answer"
354
+ tool_call_events = [e for e in agent.trace.events if e.type == "tool_call"]
355
+ tool_result_events = [e for e in agent.trace.events if e.type == "tool_result"]
356
+ assert tool_call_events[0].data["name"] == "find_spots"
357
+ assert tool_result_events[0].data["observation"] == "2 spots"
358
 
359
 
360
  # ---------------------------------------------------------------------------
 
372
 
373
 
374
  def test_run_agent_stream_function(mock_build_model):
375
+ events = [
376
+ ChatMessageStreamDelta(content="stream"),
377
+ FinalAnswerStep(output="streaming done"),
378
+ ]
379
  mock_agent = MagicMock()
380
+ mock_agent.run.return_value = iter(events)
381
 
382
  with patch("wavereader.agent.CodeAgent", return_value=mock_agent):
383
  result = list(run_agent_stream("Test question", provider=PROVIDER_HF))
384
 
385
+ assert result == [("model", "stream"), ("final", "streaming done")]
386
 
387
 
388
  # ---------------------------------------------------------------------------
tests/test_api.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for wavereader.api — FastAPI endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import patch
6
+
7
+ import pytest
8
+ from fastapi.testclient import TestClient
9
+
10
+ from wavereader.api import app
11
+
12
+ client = TestClient(app)
13
+
14
+
15
+ def test_root():
16
+ resp = client.get("/")
17
+ assert resp.status_code == 200
18
+ data = resp.json()
19
+ assert data["name"] == "wavereader"
20
+ assert data["status"] == "ok"
21
+
22
+
23
+ def test_list_spots():
24
+ resp = client.get("/spots?limit=3")
25
+ assert resp.status_code == 200
26
+ spots = resp.json()
27
+ assert len(spots) <= 3
28
+ assert all("name" in s for s in spots)
29
+
30
+
31
+ def test_list_spots_by_region():
32
+ resp = client.get("/spots?region=QLD&limit=5")
33
+ assert resp.status_code == 200
34
+ spots = resp.json()
35
+ assert all(s["region"] == "QLD" for s in spots)
36
+
37
+
38
+ def test_get_spot():
39
+ resp = client.get("/spots/Snapper Rocks?region=QLD")
40
+ assert resp.status_code == 200
41
+ spot = resp.json()
42
+ assert spot["name"] == "Snapper Rocks"
43
+ assert spot["region"] == "QLD"
44
+
45
+
46
+ def test_get_spot_not_found():
47
+ resp = client.get("/spots/Nonexistent?region=XX")
48
+ assert resp.status_code == 404
49
+
50
+
51
+ def _fake_forecast(lat: float, lon: float, days: int = 7) -> dict:
52
+ """Offline stand-in for Open-Meteo so tests never hit the network."""
53
+ return {
54
+ "hourly": [
55
+ {
56
+ "time": f"2024-01-01T{h:02d}:00",
57
+ "wave_height": 1.5,
58
+ "wave_period": 10,
59
+ "wave_direction": 135,
60
+ "wind_speed_10m": 10,
61
+ "wind_direction_10m": 135,
62
+ }
63
+ for h in range(24)
64
+ ]
65
+ }
66
+
67
+
68
+ def test_forecast():
69
+ with patch("wavereader.forecasts.get_forecast", side_effect=_fake_forecast):
70
+ resp = client.get("/forecast?spot=Snapper Rocks&region=QLD&days=2")
71
+ assert resp.status_code == 200
72
+ data = resp.json()
73
+ assert "hourly" in data
74
+ assert len(data["hourly"]) > 0
75
+
76
+
77
+ def test_forecast_spot_not_found():
78
+ resp = client.get("/forecast?spot=Nonexistent&region=XX")
79
+ assert resp.status_code == 404
80
+
81
+
82
+ def test_score():
83
+ with patch("wavereader.forecasts.get_forecast", side_effect=_fake_forecast):
84
+ resp = client.get("/score?spot=Snapper Rocks&region=QLD&days=2")
85
+ assert resp.status_code == 200
86
+ data = resp.json()
87
+ assert isinstance(data, list)
88
+ assert len(data) > 0
89
+ assert "score" in data[0]
90
+ assert "components" in data[0]
91
+ assert "time" in data[0]
92
+
93
+
94
+ def test_score_spot_not_found():
95
+ resp = client.get("/score?spot=Nonexistent&region=XX")
96
+ assert resp.status_code == 404
97
+
98
+
99
+ def test_ask_missing_config_returns_503():
100
+ with patch("wavereader.api.SurfAgent", side_effect=ValueError("HF_TOKEN environment variable not set")):
101
+ resp = client.post("/ask", json={"question": "How's Snapper?"})
102
+ assert resp.status_code == 503
103
+ assert "HF_TOKEN" in resp.json()["detail"]
104
+
105
+
106
+ def test_ask_agent_failure_returns_502():
107
+ with patch("wavereader.api.SurfAgent", side_effect=RuntimeError("boom")):
108
+ resp = client.post("/ask", json={"question": "How's Snapper?"})
109
+ assert resp.status_code == 502
tests/test_app.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for wavereader.app — Gradio UI chart functions and helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from wavereader.app import (
8
+ create_australia_map,
9
+ create_seaborn_template,
10
+ create_wave_height_chart,
11
+ create_score_chart,
12
+ create_ranking_chart,
13
+ update_trace_panel,
14
+ build_ui,
15
+ )
16
+
17
+
18
+ def test_australia_map_creates_figure():
19
+ fig = create_australia_map()
20
+ assert fig is not None
21
+ assert len(fig.data) >= 1
22
+
23
+
24
+ def test_seaborn_template_has_keys():
25
+ t = create_seaborn_template()
26
+ assert "layout" in t
27
+ assert "font" in t["layout"]
28
+ assert "paper_bgcolor" in t["layout"]
29
+
30
+
31
+ def test_wave_height_chart_empty():
32
+ fig = create_wave_height_chart({}, "Spot")
33
+ assert fig is not None
34
+
35
+
36
+ def test_wave_height_chart_with_data():
37
+ forecast = {
38
+ "hourly": [
39
+ {
40
+ "time": f"2024-01-01T{i:02d}:00",
41
+ "wave_height": 1.5,
42
+ "wave_period": 10,
43
+ "wind_speed_10m": 15,
44
+ "wind_direction_10m": 135,
45
+ }
46
+ for i in range(24)
47
+ ]
48
+ }
49
+ fig = create_wave_height_chart(forecast, "Snapper Rocks")
50
+ assert len(fig.data) == 3 # wave height, period, wind
51
+
52
+
53
+ def test_score_chart_empty():
54
+ fig = create_score_chart([], "Spot")
55
+ assert fig is not None
56
+
57
+
58
+ def test_score_chart_with_data():
59
+ scores = [
60
+ {
61
+ "time": f"2024-01-01T{i:02d}:00",
62
+ "score": 7.5,
63
+ "components": {
64
+ "swell_size": 8.0,
65
+ "swell_direction": 7.0,
66
+ "wind": 7.5,
67
+ "period": 7.5,
68
+ },
69
+ }
70
+ for i in range(24)
71
+ ]
72
+ fig = create_score_chart(scores, "Snapper Rocks")
73
+ assert len(fig.data) == 5 # total + 4 components
74
+
75
+
76
+ def test_ranking_chart_empty():
77
+ fig = create_ranking_chart([], "NSW")
78
+ assert fig is not None
79
+
80
+
81
+ def test_ranking_chart_with_data():
82
+ ranked = [
83
+ {"name": "A", "region": "NSW", "best_score": 8.5, "best_time": "2024-01-01T12:00"},
84
+ {"name": "B", "region": "NSW", "best_score": 6.0, "best_time": "2024-01-01T08:00"},
85
+ ]
86
+ fig = create_ranking_chart(ranked, "NSW")
87
+ assert len(fig.data) >= 1
88
+
89
+
90
+ def test_update_trace_panel_empty():
91
+ result = update_trace_panel("")
92
+ assert "No trace yet" in result
93
+
94
+
95
+ def test_update_trace_panel_with_data():
96
+ trace = {
97
+ "question": "Best spot?",
98
+ "model": "Qwen",
99
+ "provider": "hf",
100
+ "events": [
101
+ {"type": "tool_call", "data": {"name": "score_week", "arguments": {"spot_name": "A"}}},
102
+ {"type": "tool_result", "data": {"observation": "[{score: 8.5}]"}},
103
+ ],
104
+ }
105
+ result = update_trace_panel(json.dumps(trace))
106
+ assert "Best spot?" in result
107
+ assert "score_week" in result
108
+
109
+
110
+ def test_update_trace_panel_invalid_json():
111
+ result = update_trace_panel("not json")
112
+ assert result == "not json"
113
+
114
+
115
+ def test_build_ui_creates_blocks():
116
+ demo = build_ui()
117
+ assert demo is not None
tests/test_daggr.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for wavereader.daggr_pipeline — Morning Surf Report DAG."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from wavereader.daggr_pipeline import (
6
+ build_pipeline,
7
+ find_spots_fn,
8
+ fetch_forecasts_fn,
9
+ score_forecasts_fn,
10
+ rank_spots_fn,
11
+ format_report_fn,
12
+ _narration_preprocess,
13
+ _narration_postprocess,
14
+ )
15
+
16
+
17
+ def test_find_spots_fn():
18
+ result = find_spots_fn("QLD", skill=None)
19
+ assert "spots" in result
20
+ assert "region" in result
21
+ assert result["region"] == "QLD"
22
+ assert len(result["spots"]) > 0
23
+
24
+
25
+ def test_find_spots_fn_all_sentinel_means_no_filter():
26
+ """The UI's "all" skill sentinel must not be passed to the substring match."""
27
+ unfiltered = find_spots_fn("QLD", skill=None)
28
+ all_sentinel = find_spots_fn("QLD", skill="all")
29
+ assert len(all_sentinel["spots"]) == len(unfiltered["spots"]) > 0
30
+ assert all_sentinel["skill"] is None
31
+
32
+
33
+ def test_find_spots_fn_with_skill():
34
+ result = find_spots_fn("QLD", skill="beginner")
35
+ assert "spots" in result
36
+ for spot in result["spots"]:
37
+ assert "beginner" in spot["additional_details"]["skill_level"].lower()
38
+
39
+
40
+ def test_pipeline_string_keys_end_to_end(tmp_path, monkeypatch):
41
+ """fetch → score → rank works with "{name}|{region}" string keys."""
42
+ from wavereader import forecasts as forecasts_mod
43
+
44
+ fake = {
45
+ "hourly": [
46
+ {
47
+ "time": "2024-01-01T06:00",
48
+ "wave_height": 1.5,
49
+ "wave_period": 12,
50
+ "wave_direction": 135,
51
+ "wind_speed_10m": 8,
52
+ "wind_direction_10m": 180,
53
+ }
54
+ ]
55
+ }
56
+ monkeypatch.setattr(forecasts_mod, "get_forecast", lambda lat, lng, days=7: fake)
57
+
58
+ spots_found = find_spots_fn("QLD")["spots"][:3]
59
+ fetched = fetch_forecasts_fn(spots_found)
60
+ assert fetched["forecasts"]
61
+ for key in fetched["forecasts"]:
62
+ assert isinstance(key, str) and "|" in key
63
+
64
+ scored = score_forecasts_fn(fetched["forecasts"], spots_found)
65
+ assert set(scored["scored"]) == set(fetched["forecasts"])
66
+
67
+ ranked = rank_spots_fn(scored["scored"], spots_found)
68
+ assert len(ranked["ranked"]) == len(spots_found)
69
+ assert ranked["ranked"][0]["best_score"] >= 0
70
+
71
+
72
+ def test_narration_preprocess_flat_prompt():
73
+ inputs = {
74
+ "ranked": [{"name": "A", "region": "QLD", "best_score": 8.0}],
75
+ "region": "QLD",
76
+ }
77
+ result = _narration_preprocess(inputs)
78
+ # daggr's text-generation path sends the first value as a plain prompt
79
+ assert list(result.keys())[0] == "prompt"
80
+ assert isinstance(result["prompt"], str)
81
+ assert "QLD" in result["prompt"]
82
+
83
+
84
+ def test_narration_postprocess_handles_shapes():
85
+ assert _narration_postprocess("plain text") == {"ai_report": "plain text"}
86
+ assert _narration_postprocess([{"generated_text": "gen"}]) == {"ai_report": "gen"}
87
+ assert _narration_postprocess(42) == {"ai_report": "42"}
88
+
89
+
90
+ def test_format_report_fn_empty():
91
+ result = format_report_fn([], "NSW")
92
+ assert "report" in result
93
+ assert "No spots" in result["report"]
94
+
95
+
96
+ def test_format_report_fn_with_data():
97
+ ranked = [
98
+ {
99
+ "name": "Test Spot",
100
+ "region": "QLD",
101
+ "best_score": 8.5,
102
+ "best_time": "2024-01-01T12:00",
103
+ "best_hour": {
104
+ "time": "2024-01-01T12:00",
105
+ "score": 8.5,
106
+ "components": {"swell_size": 9.0, "swell_direction": 8.0, "wind": 8.5, "period": 8.5},
107
+ },
108
+ }
109
+ ]
110
+ result = format_report_fn(ranked, "QLD")
111
+ assert "report" in result
112
+ assert "Test Spot" in result["report"]
113
+ assert "8.5" in result["report"]
114
+
115
+
116
+ def test_format_report_fn_missing_direction_shows_na():
117
+ ranked = [
118
+ {
119
+ "name": "Test Spot",
120
+ "region": "QLD",
121
+ "best_score": 8.5,
122
+ "best_time": "2024-01-01T12:00",
123
+ "best_hour": {
124
+ "time": "2024-01-01T12:00",
125
+ "score": 8.5,
126
+ "components": {"swell_size": 9.0, "swell_direction": None, "wind": 8.5, "period": 8.5},
127
+ },
128
+ }
129
+ ]
130
+ result = format_report_fn(ranked, "QLD")
131
+ assert "n/a" in result["report"]
132
+
133
+
134
+ def test_build_pipeline():
135
+ graph = build_pipeline()
136
+ assert graph is not None
137
+ entry = graph.get_entry_nodes()
138
+ assert len(entry) == 1
139
+ assert entry[0].name == "User Input"
140
+ outputs = graph.get_output_nodes()
141
+ assert "Narrate Report" in outputs
142
+ assert "Format Report" in outputs
143
+ order = graph.get_execution_order()
144
+ assert order[0] == "User Input"
145
+ assert order[-1] in ("Narrate Report", "Format Report")
tests/test_forecasts.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for wavereader.forecasts — cache, offsets, merge, fallbacks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from unittest.mock import patch
8
+
9
+ import httpx
10
+ import pytest
11
+
12
+ from wavereader import forecasts
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Seaward offsets
17
+ # ---------------------------------------------------------------------------
18
+
19
+ def test_offset_east_coast_moves_east():
20
+ # Snapper Rocks (QLD): seaward is east, not the old fixed south-east
21
+ rlat, rlon = forecasts._round_coords(-28.173, 153.556)
22
+ assert rlon > 153.556
23
+ assert rlat == pytest.approx(-28.173, abs=0.01)
24
+
25
+
26
+ def test_offset_west_coast_moves_west():
27
+ # Margaret River region (WA): seaward is west
28
+ rlat, rlon = forecasts._round_coords(-33.95, 115.0)
29
+ assert rlon < 115.0
30
+ assert rlat == pytest.approx(-33.95, abs=0.01)
31
+
32
+
33
+ def test_offset_south_coast_moves_south():
34
+ # Bells Beach (VIC): seaward is south
35
+ rlat, rlon = forecasts._round_coords(-38.37, 144.25)
36
+ assert rlat < -38.37
37
+ assert rlon == pytest.approx(144.25, abs=0.01)
38
+
39
+
40
+ def test_offset_tasmania_moves_south():
41
+ rlat, _ = forecasts._round_coords(-41.0, 145.0)
42
+ assert rlat < -41.0
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Cache behaviour
47
+ # ---------------------------------------------------------------------------
48
+
49
+ @pytest.fixture
50
+ def cache_env(tmp_path, monkeypatch):
51
+ """Point the cache at a temp dir and freeze time deterministically."""
52
+ monkeypatch.setattr(forecasts, "CACHE_DIR", tmp_path)
53
+ return tmp_path
54
+
55
+
56
+ def test_cache_roundtrip_and_ttl(cache_env):
57
+ now = time.time()
58
+ with patch.object(forecasts, "_fetch_open_meteo", return_value={"hourly": [{"time": "t"}]}) as fetch:
59
+ first = forecasts.get_forecast(-28.0, 153.0, days=2)
60
+ assert fetch.call_count == 1
61
+ assert first["hourly"][0]["time"] == "t"
62
+
63
+ # Envelope has a timestamp
64
+ raw = json.loads(next(cache_env.glob("*.json")).read_text())
65
+ assert raw["fetched_at"] == pytest.approx(now, abs=30)
66
+
67
+ # Fresh cache: served without a second network call
68
+ forecasts.get_forecast(-28.0, 153.0, days=2)
69
+ assert fetch.call_count == 1
70
+
71
+
72
+ def test_stale_cache_is_refetched(cache_env):
73
+ with patch.object(forecasts, "_fetch_open_meteo", side_effect=[{"hourly": [{"v": 1}]}, {"hourly": [{"v": 2}]}]):
74
+ forecasts.get_forecast(-28.0, 153.0, days=2)
75
+
76
+ # Age the cache entry beyond the TTL
77
+ p = next(cache_env.glob("*.json"))
78
+ raw = json.loads(p.read_text())
79
+ raw["fetched_at"] = time.time() - forecasts.CACHE_TTL_SECONDS - 1
80
+ p.write_text(json.dumps(raw))
81
+
82
+ with patch.object(forecasts, "_fetch_open_meteo", return_value={"hourly": [{"v": 3}]}) as fetch:
83
+ refreshed = forecasts.get_forecast(-28.0, 153.0, days=2)
84
+ assert fetch.call_count == 1
85
+ assert refreshed["hourly"][0]["v"] == 3
86
+
87
+
88
+ def test_http_failure_falls_back_to_stale_cache(cache_env):
89
+ with patch.object(forecasts, "_fetch_open_meteo", return_value={"hourly": [{"v": 1}]}):
90
+ forecasts.get_forecast(-28.0, 153.0, days=2)
91
+
92
+ # Age it, then make the network fail
93
+ p = next(cache_env.glob("*.json"))
94
+ raw = json.loads(p.read_text())
95
+ raw["fetched_at"] = 0.0
96
+ p.write_text(json.dumps(raw))
97
+
98
+ def boom(*args, **kwargs):
99
+ raise httpx.ConnectError("network down")
100
+
101
+ with patch.object(forecasts, "_fetch_open_meteo", side_effect=boom):
102
+ served = forecasts.get_forecast(-28.0, 153.0, days=2)
103
+ assert served["hourly"][0]["v"] == 1
104
+
105
+
106
+ def test_http_failure_no_cache_raises(cache_env):
107
+ def boom(*args, **kwargs):
108
+ raise httpx.ConnectError("network down")
109
+
110
+ with patch.object(forecasts, "_fetch_open_meteo", side_effect=boom):
111
+ with pytest.raises(httpx.ConnectError):
112
+ forecasts.get_forecast(-28.0, 153.0, days=2)
113
+
114
+
115
+ def test_corrupt_cache_is_ignored(cache_env):
116
+ cache_env.joinpath("-28.00_153.00_2.json").write_text("{not json")
117
+ with patch.object(forecasts, "_fetch_open_meteo", return_value={"hourly": []}) as fetch:
118
+ forecasts.get_forecast(-28.0, 153.0, days=2)
119
+ assert fetch.call_count == 1
120
+
121
+
122
+ def test_legacy_cache_format_still_loads(cache_env):
123
+ # Pre-envelope files (plain frame) are accepted and treated as stale;
124
+ # served when the network fails.
125
+ cache_env.joinpath("-28.00_153.00_2.json").write_text(json.dumps({"hourly": [{"legacy": True}]}))
126
+
127
+ def boom(*args, **kwargs):
128
+ raise httpx.ConnectError("network down")
129
+
130
+ with patch.object(forecasts, "_fetch_open_meteo", side_effect=boom):
131
+ served = forecasts.get_forecast(-28.0, 153.0, days=2)
132
+ assert served["hourly"][0]["legacy"] is True
133
+
134
+
135
+ def test_cache_key_includes_days(cache_env):
136
+ with patch.object(forecasts, "_fetch_open_meteo", return_value={"hourly": []}):
137
+ forecasts.get_forecast(-28.0, 153.0, days=2)
138
+ forecasts.get_forecast(-28.0, 153.0, days=7)
139
+ keys = {p.name for p in cache_env.glob("*.json")}
140
+ assert len(keys) == 2
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # _build_normalized
145
+ # ---------------------------------------------------------------------------
146
+
147
+ def _marine_payload(times, heights):
148
+ return {"hourly": {
149
+ "time": times,
150
+ "wave_height": heights,
151
+ "wave_period": [10.0] * len(times),
152
+ "wave_direction": [135.0] * len(times),
153
+ "wind_wave_height": [0.5] * len(times),
154
+ "swell_wave_height": [1.0] * len(times),
155
+ }}
156
+
157
+
158
+ def _weather_payload(times):
159
+ return {"hourly": {
160
+ "time": times,
161
+ "wind_speed_10m": [18.5] * len(times),
162
+ "wind_direction_10m": [180.0] * len(times),
163
+ }}
164
+
165
+
166
+ def test_build_normalized_merges_on_time():
167
+ times = ["2024-01-01T00:00", "2024-01-01T01:00"]
168
+ merged = forecasts._build_normalized(_marine_payload(times, [1.2, 1.4]), _weather_payload(times))
169
+ rows = merged["hourly"]
170
+ assert len(rows) == 2
171
+ assert rows[0]["wave_height"] == 1.2
172
+ assert rows[0]["wind_speed_10m"] == 18.5
173
+ assert rows[1]["wave_period"] == 10.0
174
+
175
+
176
+ def test_build_normalized_missing_weather_hour():
177
+ marine_times = ["2024-01-01T00:00", "2024-01-01T01:00"]
178
+ weather_times = ["2024-01-01T00:00"] # weather ends an hour early
179
+ merged = forecasts._build_normalized(
180
+ _marine_payload(marine_times, [1.2, 1.4]), _weather_payload(weather_times)
181
+ )
182
+ rows = merged["hourly"]
183
+ assert "wind_speed_10m" in rows[0]
184
+ assert "wind_speed_10m" not in rows[1]
185
+
186
+
187
+ def test_build_normalized_keeps_nulls():
188
+ times = ["2024-01-01T00:00", "2024-01-01T01:00"]
189
+ merged = forecasts._build_normalized(_marine_payload(times, [1.2, None]), _weather_payload(times))
190
+ assert merged["hourly"][1]["wave_height"] is None
tests/test_scoring.py CHANGED
@@ -83,8 +83,8 @@ def test_swell_size_outside_low():
83
  "ideal_swell": {"size_ft_min": 4, "size_ft_max": 5, "direction": "SE"},
84
  "ideal_wind": {"strength_kt_max": 10, "direction": "S"},
85
  }
86
- # just below the extended cutoff (~0.92 m)
87
- s = score_hour(0.8, 10, 5, 135, spot)["components"]["swell_size"]
88
  assert s < 10.0 and s > 0.0
89
 
90
 
@@ -93,16 +93,24 @@ def test_swell_size_outside_high():
93
  "ideal_swell": {"size_ft_min": 4, "size_ft_max": 5, "direction": "SE"},
94
  "ideal_wind": {"strength_kt_max": 10, "direction": "S"},
95
  }
96
- # just above the extended cutoff (~1.82 m)
97
- s = score_hour(1.8, 10, 5, 135, spot)["components"]["swell_size"]
98
  assert s < 10.0 and s > 0.0
99
 
100
 
101
- def test_swell_size_far_outside_zero():
 
102
  spot = {
103
  "ideal_swell": {"size_ft_min": 4, "size_ft_max": 5, "direction": "SE"},
104
  "ideal_wind": {"strength_kt_max": 10, "direction": "S"},
105
  }
 
 
 
 
 
 
 
106
  assert score_hour(3.0, 10, 5, 135, spot)["components"]["swell_size"] == 0.0
107
 
108
 
@@ -116,7 +124,8 @@ def test_swell_direction_perfect_match():
116
  "ideal_wind": {"strength_kt_max": 10, "direction": "S"},
117
  }
118
  # SE = 135°
119
- assert score_hour(1.5, 10, 5, 135, spot)["components"]["swell_direction"] == 10.0
 
120
 
121
 
122
  def test_swell_direction_opposite():
@@ -125,7 +134,8 @@ def test_swell_direction_opposite():
125
  "ideal_wind": {"strength_kt_max": 10, "direction": "S"},
126
  }
127
  # SE (135) vs NW (315) → 180° diff → 0
128
- assert score_hour(1.5, 10, 5, 315, spot)["components"]["swell_direction"] == 0.0
 
129
 
130
 
131
  # ---------------------------------------------------------------------------
@@ -175,8 +185,9 @@ def test_period_great():
175
 
176
 
177
  def test_period_too_long():
178
- # Very long periods (storm swells) still score high but not perfect
179
  assert _score_period(14.0) == 10.0
 
180
  assert _score_period(8.0) > _score_period(5.0)
181
 
182
 
@@ -193,7 +204,26 @@ def test_score_hour_shape():
193
  }
194
  assert 0 <= result["score"] <= 10
195
  for v in result["components"].values():
196
- assert 0 <= v <= 10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
 
198
 
199
  def test_score_hour_contains_raw_inputs():
@@ -276,6 +306,43 @@ def test_score_week_has_time_and_score():
276
  assert "score" in results[0]
277
 
278
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  # ---------------------------------------------------------------------------
280
  # rank_spots_this_week
281
  # ---------------------------------------------------------------------------
 
83
  "ideal_swell": {"size_ft_min": 4, "size_ft_max": 5, "direction": "SE"},
84
  "ideal_wind": {"strength_kt_max": 10, "direction": "S"},
85
  }
86
+ # inside the decay band below the ideal range (~0.91-1.22 m)
87
+ s = score_hour(1.0, 10, 5, 135, spot)["components"]["swell_size"]
88
  assert s < 10.0 and s > 0.0
89
 
90
 
 
93
  "ideal_swell": {"size_ft_min": 4, "size_ft_max": 5, "direction": "SE"},
94
  "ideal_wind": {"strength_kt_max": 10, "direction": "S"},
95
  }
96
+ # inside the decay band above the ideal range (~1.52-1.83 m)
97
+ s = score_hour(1.7, 10, 5, 135, spot)["components"]["swell_size"]
98
  assert s < 10.0 and s > 0.0
99
 
100
 
101
+ def test_swell_size_decay_is_continuous():
102
+ """Score falls smoothly through the lower edge of the ideal range."""
103
  spot = {
104
  "ideal_swell": {"size_ft_min": 4, "size_ft_max": 5, "direction": "SE"},
105
  "ideal_wind": {"strength_kt_max": 10, "direction": "S"},
106
  }
107
+ inside = score_hour(1.3, 10, 5, 135, spot)["components"]["swell_size"]
108
+ edge = score_hour(1.21, 10, 5, 135, spot)["components"]["swell_size"]
109
+ below = score_hour(1.1, 10, 5, 135, spot)["components"]["swell_size"]
110
+ assert inside == 10.0
111
+ assert edge <= 10.0
112
+ assert 0.0 < below < edge
113
+ # far outside the decay band → 0
114
  assert score_hour(3.0, 10, 5, 135, spot)["components"]["swell_size"] == 0.0
115
 
116
 
 
124
  "ideal_wind": {"strength_kt_max": 10, "direction": "S"},
125
  }
126
  # SE = 135°
127
+ result = score_hour(1.5, 10, 5, 135, spot, wave_direction_deg=135)
128
+ assert result["components"]["swell_direction"] == 10.0
129
 
130
 
131
  def test_swell_direction_opposite():
 
134
  "ideal_wind": {"strength_kt_max": 10, "direction": "S"},
135
  }
136
  # SE (135) vs NW (315) → 180° diff → 0
137
+ result = score_hour(1.5, 10, 5, 315, spot, wave_direction_deg=315)
138
+ assert result["components"]["swell_direction"] == 0.0
139
 
140
 
141
  # ---------------------------------------------------------------------------
 
185
 
186
 
187
  def test_period_too_long():
188
+ # 14 s+ is a perfect 10 (no falloff for very long periods)
189
  assert _score_period(14.0) == 10.0
190
+ assert _score_period(20.0) == 10.0
191
  assert _score_period(8.0) > _score_period(5.0)
192
 
193
 
 
204
  }
205
  assert 0 <= result["score"] <= 10
206
  for v in result["components"].values():
207
+ assert v is None or 0 <= v <= 10
208
+
209
+
210
+ def test_score_hour_missing_wave_direction():
211
+ """Unknown swell direction → component None, weight redistributed, no wind substitution."""
212
+ result = score_hour(1.5, 10, 5, 135, _SPOT, wave_direction_deg=None)
213
+ assert result["components"]["swell_direction"] is None
214
+ assert result["wave_direction_deg"] is None
215
+ # Direction was NOT silently replaced with wind direction (135 ≠ 0 → not perfect)
216
+ assert result["components"]["swell_direction"] != 10.0
217
+ assert 0 <= result["score"] <= 10
218
+ # With redistributed weights the remaining components still dominate
219
+ assert result["score"] > 0
220
+
221
+
222
+ def test_score_hour_missing_direction_differs_from_wind_substitution():
223
+ """Score without swell direction must differ from one scoring wind as swell."""
224
+ with_dir = score_hour(1.5, 10, 5, 135, _SPOT, wave_direction_deg=0)
225
+ without_dir = score_hour(1.5, 10, 5, 135, _SPOT, wave_direction_deg=None)
226
+ assert without_dir["score"] != with_dir["score"]
227
 
228
 
229
  def test_score_hour_contains_raw_inputs():
 
306
  assert "score" in results[0]
307
 
308
 
309
+ def test_score_week_skips_null_hours():
310
+ """Open-Meteo returns null marine hours beyond the horizon — skip, don't crash."""
311
+ forecast = {
312
+ "hourly": [
313
+ {
314
+ "time": "2024-01-01T00:00",
315
+ "wave_height": 1.5,
316
+ "wave_period": 10,
317
+ "wind_speed_10m": 10,
318
+ "wind_direction_10m": 135,
319
+ "wave_direction": 130,
320
+ },
321
+ {
322
+ "time": "2024-01-01T01:00",
323
+ "wave_height": None,
324
+ "wave_period": None,
325
+ "wind_speed_10m": 10,
326
+ "wind_direction_10m": 135,
327
+ "wave_direction": None,
328
+ },
329
+ ]
330
+ }
331
+ results = score_week(forecast, _SPOT)
332
+ assert len(results) == 1
333
+ assert results[0]["time"] == "2024-01-01T00:00"
334
+
335
+
336
+ def test_score_week_all_null_returns_empty():
337
+ forecast = {
338
+ "hourly": [
339
+ {"time": "2024-01-01T00:00", "wave_height": None, "wave_period": None,
340
+ "wind_speed_10m": None, "wind_direction_10m": None}
341
+ ]
342
+ }
343
+ assert score_week(forecast, _SPOT) == []
344
+
345
+
346
  # ---------------------------------------------------------------------------
347
  # rank_spots_this_week
348
  # ---------------------------------------------------------------------------
tests/test_tools.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Direct wiring tests for wavereader.tools — with forecasts mocked, spots real."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import patch
6
+
7
+ from wavereader import tools
8
+
9
+
10
+ def _fake_forecast(lat, lng, days=7):
11
+ return {
12
+ "hourly": [
13
+ {
14
+ "time": f"2024-01-01T{h:02d}:00",
15
+ "wave_height": 1.5,
16
+ "wave_period": 11,
17
+ "wave_direction": 135,
18
+ "wind_speed_10m": 9,
19
+ "wind_direction_10m": 180,
20
+ }
21
+ for h in range(6)
22
+ ]
23
+ }
24
+
25
+
26
+ def test_get_forecast_known_spot():
27
+ with patch("wavereader.forecasts.get_forecast", side_effect=_fake_forecast) as mock_fc:
28
+ result = tools.get_forecast("Snapper Rocks", "QLD")
29
+ assert "hourly" in result
30
+ mock_fc.assert_called_once()
31
+
32
+
33
+ def test_get_forecast_unknown_spot():
34
+ result = tools.get_forecast("Nowhere", "XX")
35
+ assert "error" in result
36
+
37
+
38
+ def test_score_week_known_spot():
39
+ with patch("wavereader.forecasts.get_forecast", side_effect=_fake_forecast):
40
+ result = tools.score_week("Snapper Rocks", "QLD")
41
+ assert isinstance(result, list)
42
+ assert len(result) == 6
43
+ assert "score" in result[0]
44
+
45
+
46
+ def test_score_week_unknown_spot_returns_error_dict():
47
+ """Bad spot name must be distinguishable from no scoreable hours."""
48
+ result = tools.score_week("Nowhere", "XX")
49
+ assert isinstance(result, dict)
50
+ assert "error" in result
51
+
52
+
53
+ def test_find_spots_by_query():
54
+ results = tools.find_spots(query="Snapper")
55
+ assert any(r["name"] == "Snapper Rocks" for r in results)
56
+
57
+
58
+ def test_find_spots_by_skill():
59
+ results = tools.find_spots(query="Bells", skill="advanced")
60
+ for r in results:
61
+ assert "advanced" in r["additional_details"]["skill_level"].lower()
62
+
63
+
64
+ def test_get_spot_knowledge():
65
+ result = tools.get_spot_knowledge("Bells Beach", "VIC")
66
+ assert result["name"] == "Bells Beach"
67
+ assert "ideal_swell" in result
68
+
69
+
70
+ def test_get_spot_knowledge_unknown():
71
+ assert "error" in tools.get_spot_knowledge("Nowhere", "XX")
72
+
73
+
74
+ def test_rank_spots_this_week_ranks_descending():
75
+ with patch("wavereader.forecasts.get_forecast", side_effect=_fake_forecast):
76
+ ranked = tools.rank_spots_this_week("TAS", skill=None)
77
+ assert ranked, "expected at least one TAS break"
78
+ scores = [r["best_score"] for r in ranked]
79
+ assert scores == sorted(scores, reverse=True)
80
+ for r in ranked:
81
+ assert r["region"] == "TAS"
82
+ assert r["best_time"]
uv.lock CHANGED
The diff for this file is too large to render. See raw diff
 
wavereader/agent.py CHANGED
@@ -8,23 +8,26 @@ OpenAI-style tool calling and strict ``json_schema`` on the HF router — see
8
  ``data/GENERATION.md`` before swapping models).
9
 
10
  The agent interprets and explains only; all numbers come from scoring.py /
11
- forecasts.py via the tools in tools.py. Streaming + trace capture for the UI
12
- trace panel.
 
13
  """
14
 
15
  from __future__ import annotations
16
 
 
17
  import json
18
  import os
19
- from contextlib import contextmanager
20
  from dataclasses import dataclass, field
21
- from typing import Any
22
 
23
- from smolagents import CodeAgent, InferenceClientModel, PromptTemplates, Tool, tool
24
  import yaml
25
- import importlib.resources
 
 
 
26
 
27
- from wavereader import forecasts, scoring, spots
28
  from wavereader.tools import (
29
  find_spots,
30
  get_forecast,
@@ -40,10 +43,20 @@ IMPORTANT RULES:
40
  2. You interpret and explain the data returned by tools. You do not invent wave heights, wind speeds, or surf scores.
41
  3. Tide information is qualitative only (from the knowledge base). Open-Meteo does not provide tides — never promise tide curves.
42
  4. When the user asks about a specific spot, use get_spot_knowledge first to understand its ideal conditions.
43
- 5. For forecasts and scores, use get_forecast and score_week. For rankings, use rank_spots_this_week.
44
- 6. For finding spots by region/skill, use find_spots.
45
- 7. Always cite the data source (tool name) when giving numbers.
46
- 8. Be concise and practical — surfers want actionable recommendations.
 
 
 
 
 
 
 
 
 
 
47
  """
48
 
49
  PROVIDER_HF = "hf"
@@ -57,7 +70,8 @@ NIM_BASE_URL = "https://integrate.api.nvidia.com/v1"
57
  @dataclass
58
  class TraceEvent:
59
  """A single event in the agent trace."""
60
- type: str # "tool_call", "tool_result", "llm_chunk", "error"
 
61
  timestamp: float
62
  data: dict[str, Any]
63
 
@@ -65,25 +79,29 @@ class TraceEvent:
65
  @dataclass
66
  class AgentTrace:
67
  """Captured trace of an agent run."""
 
68
  events: list[TraceEvent] = field(default_factory=list)
69
  question: str = ""
70
  model: str = ""
71
  provider: str = ""
72
 
73
  def add_event(self, event_type: str, data: dict[str, Any]) -> None:
74
- import time
75
- self.events.append(TraceEvent(type=event_type, timestamp=time.time(), data=data))
 
76
 
77
  def to_json(self) -> str:
78
- return json.dumps({
79
- "question": self.question,
80
- "model": self.model,
81
- "provider": self.provider,
82
- "events": [
83
- {"type": e.type, "timestamp": e.timestamp, "data": e.data}
84
- for e in self.events
85
- ]
86
- })
 
 
87
 
88
 
89
  class GetForecastTool(Tool):
@@ -101,7 +119,11 @@ class GetForecastTool(Tool):
101
 
102
  class ScoreWeekTool(Tool):
103
  name = "score_week"
104
- description = "Get hour-by-hour surf scores for the next 7 days at a spot."
 
 
 
 
105
  inputs = {
106
  "spot_name": {"type": "string", "description": "Name of the surf spot"},
107
  "region": {"type": "string", "description": "Australian state/region"},
@@ -114,16 +136,26 @@ class ScoreWeekTool(Tool):
114
 
115
  class FindSpotsTool(Tool):
116
  name = "find_spots"
117
- description = "Search breaks by name/region with optional skill filter."
 
 
 
118
  inputs = {
119
- "query": {"type": "string", "description": "Search query for spot name or region", "nullable": True},
 
120
  "skill": {"type": "string", "description": "Skill level filter (beginner, intermediate, advanced, expert)", "nullable": True},
121
  "limit": {"type": "integer", "description": "Maximum number of results to return", "nullable": True},
122
  }
123
  output_type = "object"
124
 
125
- def forward(self, query: str = "", skill: str | None = None, limit: int = 10) -> list[dict]:
126
- return find_spots(query=query, skill=skill, limit=limit)
 
 
 
 
 
 
127
 
128
 
129
  class GetSpotKnowledgeTool(Tool):
@@ -141,7 +173,11 @@ class GetSpotKnowledgeTool(Tool):
141
 
142
  class RankSpotsThisWeekTool(Tool):
143
  name = "rank_spots_this_week"
144
- description = "Rank spots in a region by their best score this week, with optional skill filter."
 
 
 
 
145
  inputs = {
146
  "region": {"type": "string", "description": "Australian state/region"},
147
  "skill": {"type": "string", "description": "Skill level filter", "nullable": True},
@@ -161,38 +197,44 @@ TOOLS = [
161
  ]
162
 
163
 
164
- def _build_model(provider: str, model: str | None = None) -> InferenceClientModel:
165
- """Build the InferenceClientModel for the given provider."""
 
 
 
 
166
  model_id = model or DEFAULT_MODEL
167
 
168
  if provider == PROVIDER_HF:
169
  token = os.getenv("HF_TOKEN")
170
  if not token:
171
- raise ValueError("HF_TOKEN environment variable not set for Hugging Face provider")
172
- return InferenceClientModel(
 
 
173
  model_id=model_id,
174
  api_base=HF_BASE_URL,
175
- token=token,
176
  )
177
  elif provider == PROVIDER_NIM:
178
  api_key = os.getenv("NVIDIA_API_KEY")
179
  if not api_key:
180
- raise ValueError("NVIDIA_API_KEY environment variable not set for NVIDIA NIM provider")
181
- return InferenceClientModel(
 
 
182
  model_id=model_id,
183
  api_base=NIM_BASE_URL,
184
- token=api_key,
185
  )
186
  else:
187
  raise ValueError(f"Unknown provider: {provider}. Use 'hf' or 'nim'")
188
 
189
 
190
  def _detect_provider() -> str:
191
- """Auto-detect provider from available env vars."""
192
  if os.getenv("NVIDIA_API_KEY"):
193
  return PROVIDER_NIM
194
- if os.getenv("HF_TOKEN"):
195
- return PROVIDER_HF
196
  return PROVIDER_HF
197
 
198
 
@@ -207,8 +249,12 @@ class SurfAgent:
207
  self.provider = provider or _detect_provider()
208
  self.model = model or DEFAULT_MODEL
209
  self._model = _build_model(self.provider, self.model)
210
- # Load default prompt templates from smolagents package
211
- default_templates_yaml = importlib.resources.files("smolagents.prompts").joinpath("code_agent.yaml").read_text()
 
 
 
 
212
  default_templates = yaml.safe_load(default_templates_yaml)
213
  default_templates["system_prompt"] = SYSTEM_PROMPT
214
  prompt_templates = PromptTemplates(**default_templates)
@@ -224,54 +270,88 @@ class SurfAgent:
224
  def trace(self) -> AgentTrace | None:
225
  return self._trace
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  def run(self, question: str) -> str:
228
  """Run the agent synchronously and return the final answer."""
229
- self._trace = AgentTrace(question=question, model=self.model, provider=self.provider)
230
- return self._agent.run(question)
231
-
232
- def run_stream(self, question: str):
233
- """Run the agent with streaming, yielding (chunk, trace_event) pairs."""
234
- self._trace = AgentTrace(question=question, model=self.model, provider=self.provider)
235
-
236
- # Wrap the agent's run to capture trace events
237
- from smolagents.agents import MultiStepAgent
238
- from smolagents.memory import ActionStep
239
-
240
- original_step = self._agent.step
241
-
242
- def traced_step(memory_step: ActionStep, *args, **kwargs):
243
- # Capture tool calls
244
- if memory_step.tool_calls:
245
- for tc in memory_step.tool_calls:
246
- self._trace.add_event("tool_call", {
247
- "name": tc.name,
248
- "arguments": tc.arguments,
249
- "id": tc.id,
250
- })
251
- # Capture tool results
252
- if memory_step.observations:
253
- for obs in memory_step.observations:
254
- self._trace.add_event("tool_result", {"observation": str(obs)})
255
- return original_step(memory_step, *args, **kwargs)
256
-
257
- self._agent.step = traced_step
258
-
259
  try:
260
- for chunk in self._agent.run(question, stream=True):
261
- if self._trace:
262
- self._trace.add_event("llm_chunk", {"chunk": str(chunk)})
263
- yield chunk
264
  finally:
265
- self._agent.step = original_step
266
-
267
-
268
- def run_agent(question: str, provider: str | None = None, model: str | None = None) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  """Run the surf agent and return the answer."""
270
  agent = SurfAgent(provider=provider, model=model)
271
  return agent.run(question)
272
 
273
 
274
- def run_agent_stream(question: str, provider: str | None = None, model: str | None = None):
 
 
275
  """Run the surf agent with streaming."""
276
  agent = SurfAgent(provider=provider, model=model)
277
  yield from agent.run_stream(question)
@@ -279,9 +359,17 @@ def run_agent_stream(question: str, provider: str | None = None, model: str | No
279
 
280
  if __name__ == "__main__":
281
  import sys
282
- question = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else "What time should I surf Snapper Rocks today?"
 
 
 
 
 
283
  print(f"Question: {question}\n")
284
  print("Answer:")
285
- for chunk in run_agent_stream(question):
286
- print(chunk, end="", flush=True)
287
- print()
 
 
 
 
8
  ``data/GENERATION.md`` before swapping models).
9
 
10
  The agent interprets and explains only; all numbers come from scoring.py /
11
+ forecasts.py via the tools in tools.py. Streaming + trace capture feed the UI
12
+ trace panel; the UI renders charts itself from the trace (the agent never
13
+ needs to plot).
14
  """
15
 
16
  from __future__ import annotations
17
 
18
+ import importlib.resources
19
  import json
20
  import os
21
+ import time
22
  from dataclasses import dataclass, field
23
+ from typing import Any, Generator
24
 
 
25
  import yaml
26
+ from smolagents import CodeAgent, InferenceClientModel, OpenAIServerModel, PromptTemplates, Tool
27
+ from smolagents.agents import ToolOutput
28
+ from smolagents.memory import FinalAnswerStep, ToolCall as SmolToolCall
29
+ from smolagents.models import ChatMessageStreamDelta
30
 
 
31
  from wavereader.tools import (
32
  find_spots,
33
  get_forecast,
 
43
  2. You interpret and explain the data returned by tools. You do not invent wave heights, wind speeds, or surf scores.
44
  3. Tide information is qualitative only (from the knowledge base). Open-Meteo does not provide tides — never promise tide curves.
45
  4. When the user asks about a specific spot, use get_spot_knowledge first to understand its ideal conditions.
46
+ 5. Always cite the data source (tool name) when giving numbers.
47
+ 6. Be concise and practical — surfers want actionable recommendations.
48
+ 7. The UI renders charts automatically whenever you surface forecast or score data — never describe plots or say you cannot show them.
49
+
50
+ CALL TOOLS EXACTLY LIKE THIS (keyword spellings matter):
51
+ - find_spots(query="QLD", skill="beginner") # query = spot name OR state code: NSW, QLD, VIC, WA, SA, TAS
52
+ - get_spot_knowledge(spot_name="Snapper Rocks", region="QLD")
53
+ - get_forecast(spot_name="Snapper Rocks", region="QLD")
54
+ - score_week(spot_name="Snapper Rocks", region="QLD") # ONE spot per call
55
+ - rank_spots_this_week(region="QLD", skill="beginner") # use this to COMPARE many spots in a state
56
+
57
+ Workflow: to answer "where should I surf in <state>", call rank_spots_this_week first,
58
+ then get_spot_knowledge + score_week for the top 1-2 spots. Never invent keyword
59
+ names — use exactly the ones above.
60
  """
61
 
62
  PROVIDER_HF = "hf"
 
70
  @dataclass
71
  class TraceEvent:
72
  """A single event in the agent trace."""
73
+
74
+ type: str # "tool_call", "tool_result", "llm_chunk", "final_answer", "error"
75
  timestamp: float
76
  data: dict[str, Any]
77
 
 
79
  @dataclass
80
  class AgentTrace:
81
  """Captured trace of an agent run."""
82
+
83
  events: list[TraceEvent] = field(default_factory=list)
84
  question: str = ""
85
  model: str = ""
86
  provider: str = ""
87
 
88
  def add_event(self, event_type: str, data: dict[str, Any]) -> None:
89
+ self.events.append(
90
+ TraceEvent(type=event_type, timestamp=time.time(), data=data)
91
+ )
92
 
93
  def to_json(self) -> str:
94
+ return json.dumps(
95
+ {
96
+ "question": self.question,
97
+ "model": self.model,
98
+ "provider": self.provider,
99
+ "events": [
100
+ {"type": e.type, "timestamp": e.timestamp, "data": e.data}
101
+ for e in self.events
102
+ ],
103
+ }
104
+ )
105
 
106
 
107
  class GetForecastTool(Tool):
 
119
 
120
  class ScoreWeekTool(Tool):
121
  name = "score_week"
122
+ description = (
123
+ "Get hour-by-hour surf scores for the next 7 days at ONE spot. "
124
+ "Example: score_week(spot_name=\"Snapper Rocks\", region=\"QLD\"). "
125
+ "To compare many spots, use rank_spots_this_week instead."
126
+ )
127
  inputs = {
128
  "spot_name": {"type": "string", "description": "Name of the surf spot"},
129
  "region": {"type": "string", "description": "Australian state/region"},
 
136
 
137
  class FindSpotsTool(Tool):
138
  name = "find_spots"
139
+ description = (
140
+ "Search breaks by name or state code with optional skill filter. "
141
+ "Example: find_spots(query=\"QLD\", skill=\"beginner\")"
142
+ )
143
  inputs = {
144
+ "query": {"type": "string", "description": "Spot name OR Australian state code (NSW, QLD, VIC, WA, SA, TAS)", "nullable": True},
145
+ "region": {"type": "string", "description": "Alias for query when searching a state code", "nullable": True},
146
  "skill": {"type": "string", "description": "Skill level filter (beginner, intermediate, advanced, expert)", "nullable": True},
147
  "limit": {"type": "integer", "description": "Maximum number of results to return", "nullable": True},
148
  }
149
  output_type = "object"
150
 
151
+ def forward(
152
+ self,
153
+ query: str = "",
154
+ region: str | None = None,
155
+ skill: str | None = None,
156
+ limit: int = 10,
157
+ ) -> list[dict]:
158
+ return find_spots(query=query or region or "", skill=skill, limit=limit)
159
 
160
 
161
  class GetSpotKnowledgeTool(Tool):
 
173
 
174
  class RankSpotsThisWeekTool(Tool):
175
  name = "rank_spots_this_week"
176
+ description = (
177
+ "Rank ALL spots in a state by their best score this week. "
178
+ "Example: rank_spots_this_week(region=\"QLD\", skill=\"beginner\"). "
179
+ "Use this first for 'where should I surf' questions."
180
+ )
181
  inputs = {
182
  "region": {"type": "string", "description": "Australian state/region"},
183
  "skill": {"type": "string", "description": "Skill level filter", "nullable": True},
 
197
  ]
198
 
199
 
200
+ def _build_model(provider: str, model: str | None = None) -> OpenAIServerModel:
201
+ """Build the OpenAI-compatible model client for the given provider.
202
+
203
+ Both the HF router and NVIDIA NIM speak the OpenAI chat-completions
204
+ protocol, so one client class covers the HF⇄NIM switch.
205
+ """
206
  model_id = model or DEFAULT_MODEL
207
 
208
  if provider == PROVIDER_HF:
209
  token = os.getenv("HF_TOKEN")
210
  if not token:
211
+ raise ValueError(
212
+ "HF_TOKEN environment variable not set for Hugging Face provider"
213
+ )
214
+ return OpenAIServerModel(
215
  model_id=model_id,
216
  api_base=HF_BASE_URL,
217
+ api_key=token,
218
  )
219
  elif provider == PROVIDER_NIM:
220
  api_key = os.getenv("NVIDIA_API_KEY")
221
  if not api_key:
222
+ raise ValueError(
223
+ "NVIDIA_API_KEY environment variable not set for NVIDIA NIM provider"
224
+ )
225
+ return OpenAIServerModel(
226
  model_id=model_id,
227
  api_base=NIM_BASE_URL,
228
+ api_key=api_key,
229
  )
230
  else:
231
  raise ValueError(f"Unknown provider: {provider}. Use 'hf' or 'nim'")
232
 
233
 
234
  def _detect_provider() -> str:
235
+ """Auto-detect provider from available env vars (NIM preferred)."""
236
  if os.getenv("NVIDIA_API_KEY"):
237
  return PROVIDER_NIM
 
 
238
  return PROVIDER_HF
239
 
240
 
 
249
  self.provider = provider or _detect_provider()
250
  self.model = model or DEFAULT_MODEL
251
  self._model = _build_model(self.provider, self.model)
252
+ # Load smolagents' default prompt templates, then override system prompt
253
+ default_templates_yaml = (
254
+ importlib.resources.files("smolagents.prompts")
255
+ .joinpath("code_agent.yaml")
256
+ .read_text()
257
+ )
258
  default_templates = yaml.safe_load(default_templates_yaml)
259
  default_templates["system_prompt"] = SYSTEM_PROMPT
260
  prompt_templates = PromptTemplates(**default_templates)
 
270
  def trace(self) -> AgentTrace | None:
271
  return self._trace
272
 
273
+ def _capture_item(self, item: Any) -> bool:
274
+ """Record a tool_call/tool_result from a smolagents stream item.
275
+
276
+ Returns True if the item was consumed as a tool event.
277
+ """
278
+ if self._trace is None:
279
+ return False
280
+ if isinstance(item, SmolToolCall):
281
+ self._trace.add_event(
282
+ "tool_call",
283
+ {"name": item.name, "arguments": item.arguments, "id": item.id},
284
+ )
285
+ return True
286
+ if isinstance(item, ToolOutput):
287
+ name = item.tool_call.name if item.tool_call is not None else None
288
+ self._trace.add_event(
289
+ "tool_result",
290
+ {"name": name, "observation": str(item.observation)},
291
+ )
292
+ return True
293
+ return False
294
+
295
+ def _traced_step_stream(self, original_step_stream):
296
+ """Wrap a _step_stream generator so run() also captures tool events."""
297
+
298
+ def traced(memory_step, *args, **kwargs):
299
+ for item in original_step_stream(memory_step, *args, **kwargs):
300
+ self._capture_item(item)
301
+ yield item
302
+
303
+ return traced
304
+
305
  def run(self, question: str) -> str:
306
  """Run the agent synchronously and return the final answer."""
307
+ self._trace = AgentTrace(
308
+ question=question, model=self.model, provider=self.provider
309
+ )
310
+ original_step_stream = self._agent._step_stream
311
+ self._agent._step_stream = self._traced_step_stream(original_step_stream)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  try:
313
+ return self._agent.run(question)
 
 
 
314
  finally:
315
+ self._agent._step_stream = original_step_stream
316
+
317
+ def run_stream(
318
+ self, question: str
319
+ ) -> Generator[tuple[str, Any], None, None]:
320
+ """Run the agent with streaming.
321
+
322
+ Yields ("model", delta) for LLM tokens, ("tool", tool_name) when a
323
+ tool call fires, and ("final", answer) at the end. Tool activity is
324
+ captured into the trace from the stream itself.
325
+ """
326
+ self._trace = AgentTrace(
327
+ question=question, model=self.model, provider=self.provider
328
+ )
329
+ for item in self._agent.run(question, stream=True):
330
+ if isinstance(item, ChatMessageStreamDelta):
331
+ if item.content:
332
+ self._trace.add_event("llm_chunk", {"chunk": item.content})
333
+ yield ("model", item.content)
334
+ continue
335
+ if isinstance(item, FinalAnswerStep):
336
+ self._trace.add_event("final_answer", {"answer": item.output})
337
+ yield ("final", item.output)
338
+ continue
339
+ if self._capture_item(item) and isinstance(item, SmolToolCall):
340
+ name = item.name if isinstance(item.name, str) else "code action"
341
+ yield ("tool", name)
342
+
343
+
344
+ def run_agent(
345
+ question: str, provider: str | None = None, model: str | None = None
346
+ ) -> str:
347
  """Run the surf agent and return the answer."""
348
  agent = SurfAgent(provider=provider, model=model)
349
  return agent.run(question)
350
 
351
 
352
+ def run_agent_stream(
353
+ question: str, provider: str | None = None, model: str | None = None
354
+ ):
355
  """Run the surf agent with streaming."""
356
  agent = SurfAgent(provider=provider, model=model)
357
  yield from agent.run_stream(question)
 
359
 
360
  if __name__ == "__main__":
361
  import sys
362
+
363
+ question = (
364
+ " ".join(sys.argv[1:])
365
+ if len(sys.argv) > 1
366
+ else "What time should I surf Snapper Rocks today?"
367
+ )
368
  print(f"Question: {question}\n")
369
  print("Answer:")
370
+ for kind, chunk in run_agent_stream(question):
371
+ if kind == "model":
372
+ print(chunk, end="", flush=True)
373
+ elif kind == "tool":
374
+ print(f"\n[tool: {chunk}]", flush=True)
375
+ print()
wavereader/api.py CHANGED
@@ -6,35 +6,85 @@ and any non-Gradio consumer. Endpoints: /spots, /forecast, /score, /ask.
6
 
7
  from __future__ import annotations
8
 
9
- from fastapi import FastAPI
 
 
 
 
10
 
11
  app = FastAPI(title="wavereader", version="0.1.0")
12
 
13
 
 
 
 
 
 
 
 
 
 
 
 
14
  @app.get("/")
15
  async def read_root():
16
- return {"Hello": "World"}
 
17
 
18
 
19
  @app.get("/spots")
20
- async def list_spots(query: str = "", region: str | None = None, skill: str | None = None):
21
- """Search/validate breaks via spots.py. TODO: wire up."""
22
- raise NotImplementedError
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
 
25
  @app.get("/forecast")
26
- async def forecast(spot: str, days: int = 7):
27
- """Hourly marine + wind forecast via forecasts.py. TODO: wire up."""
28
- raise NotImplementedError
 
 
 
29
 
30
 
31
  @app.get("/score")
32
- async def score(spot: str, days: int = 7):
33
- """Deterministic hourly surf scores via scoring.py. TODO: wire up."""
34
- raise NotImplementedError
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
 
37
- @app.post("/ask")
38
- async def ask(question: str):
39
- """Agent answer via agent.py tool loop. TODO: wire up."""
40
- raise NotImplementedError
 
6
 
7
  from __future__ import annotations
8
 
9
+ from fastapi import FastAPI, HTTPException
10
+ from pydantic import BaseModel
11
+
12
+ from wavereader import forecasts, scoring, spots
13
+ from wavereader.agent import run_agent, SurfAgent
14
 
15
  app = FastAPI(title="wavereader", version="0.1.0")
16
 
17
 
18
+ class AskRequest(BaseModel):
19
+ question: str
20
+ provider: str | None = None
21
+ model: str | None = None
22
+
23
+
24
+ class AskResponse(BaseModel):
25
+ answer: str
26
+ trace: str | None = None
27
+
28
+
29
  @app.get("/")
30
  async def read_root():
31
+ """Health check."""
32
+ return {"name": "wavereader", "version": "0.1.0", "status": "ok"}
33
 
34
 
35
  @app.get("/spots")
36
+ async def list_spots(query: str = "", region: str | None = None, skill: str | None = None, limit: int = 10):
37
+ """Search/validate breaks via spots.py."""
38
+ results = spots.find_spots(query=query, region=region, skill=skill, limit=limit)
39
+ return [b.model_dump() for b in results]
40
+
41
+
42
+ @app.get("/spots/{spot_name}")
43
+ async def get_spot(spot_name: str, region: str):
44
+ """Get full knowledge-base record for a spot keyed by (name, region).
45
+
46
+ ``region`` is a required query param: ``/spots/Snapper Rocks?region=QLD``.
47
+ """
48
+ spot = spots.get_spot(spot_name, region)
49
+ if spot is None:
50
+ raise HTTPException(status_code=404, detail=f"Spot '{spot_name}' ({region}) not found")
51
+ return spot.model_dump()
52
 
53
 
54
  @app.get("/forecast")
55
+ async def forecast(spot: str, region: str, days: int = 7):
56
+ """Hourly marine + wind forecast via forecasts.py."""
57
+ s = spots.get_spot(spot, region)
58
+ if s is None:
59
+ raise HTTPException(status_code=404, detail=f"Spot '{spot}' ({region}) not found")
60
+ return forecasts.get_forecast(s.coordinates.lat, s.coordinates.lng, days=days)
61
 
62
 
63
  @app.get("/score")
64
+ async def score(spot: str, region: str, days: int = 7):
65
+ """Deterministic hourly surf scores via scoring.py."""
66
+ s = spots.get_spot(spot, region)
67
+ if s is None:
68
+ raise HTTPException(status_code=404, detail=f"Spot '{spot}' ({region}) not found")
69
+ fc = forecasts.get_forecast(s.coordinates.lat, s.coordinates.lng, days=days)
70
+ return scoring.score_week(fc, s.model_dump())
71
+
72
+
73
+ @app.post("/ask", response_model=AskResponse)
74
+ async def ask(request: AskRequest):
75
+ """Agent answer via agent.py tool loop."""
76
+ try:
77
+ agent = SurfAgent(provider=request.provider, model=request.model)
78
+ answer = agent.run(request.question)
79
+ except ValueError as exc:
80
+ # Missing/unknown provider config (e.g. unset HF_TOKEN/NVIDIA_API_KEY)
81
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
82
+ except Exception as exc:
83
+ raise HTTPException(status_code=502, detail=f"Agent run failed: {exc}") from exc
84
+ trace_json = agent.trace.to_json() if agent.trace else None
85
+ return AskResponse(answer=answer, trace=trace_json)
86
 
87
 
88
+ if __name__ == "__main__":
89
+ import uvicorn
90
+ uvicorn.run(app, host="0.0.0.0", port=8000)
 
wavereader/app.py CHANGED
@@ -1,24 +1,867 @@
1
- """Gradio demo UI.
 
 
2
 
3
- Home page is a map of Australia (select your break), plus chat with the
4
- agent, interactive seaborn-styled charts (plotly/altair aesthetics) rendered
5
- whenever the agent surfaces forecast/wind data, and the agent trace panel.
6
- Calls the core modules (and FastAPI endpoints) — no custom frontend this
7
- week.
8
  """
9
 
10
  from __future__ import annotations
11
 
 
 
 
 
 
 
12
 
13
- def build_ui() -> object:
14
- """Assemble the Blocks demo (map home, chat, charts, trace panel).
15
 
16
- TODO: Australia map home page, chat pipeline against agent.run_agent,
17
- chart rendering, trace panel.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  """
19
- raise NotImplementedError
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
 
22
- if __name__ == "__main__":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  demo = build_ui()
24
- demo.launch()
 
 
 
 
 
1
+ """Gradio UI for wavereader: Australia map home, agent chat with trace panel,
2
+ interactive seaborn-styled plotly charts, weekly rankings, and the daggr
3
+ Morning Surf Report canvas.
4
 
5
+ Run with: uv run python -m wavereader.app
 
 
 
 
6
  """
7
 
8
  from __future__ import annotations
9
 
10
+ import json
11
+ import os
12
+ import re
13
+ import subprocess
14
+ import sys
15
+ import threading
16
 
17
+ import gradio as gr
18
+ import plotly.graph_objects as go
19
 
20
+ from wavereader import spots, tools
21
+ from wavereader.agent import PROVIDER_HF, PROVIDER_NIM, SurfAgent
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Styling: seaborn "muted" palette applied to plotly
25
+ # ---------------------------------------------------------------------------
26
+
27
+ MUTED = {
28
+ "blue": "#4878D0",
29
+ "orange": "#EE854A",
30
+ "green": "#6ACC64",
31
+ "red": "#D65F5F",
32
+ "purple": "#B47CC7",
33
+ "brown": "#8C613C",
34
+ "pink": "#DC7EC0",
35
+ "gray": "#797979",
36
+ "gold": "#D5BB67",
37
+ "cyan": "#82C6E2",
38
+ }
39
+
40
+ SKILL_COLORS = {
41
+ "beginner": MUTED["green"],
42
+ "intermediate": MUTED["cyan"],
43
+ "advanced": MUTED["orange"],
44
+ "expert": MUTED["red"],
45
+ }
46
+
47
+ REGIONS = ["NSW", "QLD", "VIC", "WA", "SA", "TAS"]
48
+ SKILL_FILTERS = ["all", "beginner", "intermediate", "advanced", "expert"]
49
+
50
+ _DEG_TO_COMPASS = [
51
+ "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
52
+ "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW",
53
+ ]
54
+
55
+
56
+ def _compass(deg) -> str:
57
+ if deg is None:
58
+ return "–"
59
+ try:
60
+ deg = float(deg)
61
+ except (TypeError, ValueError):
62
+ return "–"
63
+ return _DEG_TO_COMPASS[int((deg % 360) / 22.5) % 16]
64
+
65
+
66
+ def _primary_skill(skill_level: str) -> str:
67
+ """Collapse compound skill strings ('beginner to intermediate') to a bucket."""
68
+ s = (skill_level or "").lower()
69
+ if "elite" in s or "professional" in s:
70
+ return "expert"
71
+ for level in ("beginner", "intermediate", "advanced", "expert"):
72
+ if level in s:
73
+ return level
74
+ return "intermediate"
75
+
76
+
77
+ def _matches_skill(skill_level: str, wanted: str) -> bool:
78
+ if wanted in (None, "", "all"):
79
+ return True
80
+ s = (skill_level or "").lower()
81
+ if wanted == "expert":
82
+ return "expert" in s or "elite" in s or "professional" in s
83
+ return wanted in s
84
+
85
+
86
+ def create_seaborn_template() -> dict:
87
+ """Plotly layout template with seaborn aesthetics (whitegrid + muted)."""
88
+ return {
89
+ "layout": {
90
+ "font": {"family": "DejaVu Sans, Helvetica, Arial, sans-serif", "size": 13, "color": "#3b3b3b"},
91
+ "paper_bgcolor": "white",
92
+ "plot_bgcolor": "white",
93
+ "colorway": list(MUTED.values()),
94
+ "title": {"font": {"size": 17, "color": "#1f2d3d"}, "x": 0.01, "xanchor": "left"},
95
+ "legend": {"bgcolor": "rgba(0,0,0,0)", "orientation": "h", "y": 1.08},
96
+ "xaxis": {"gridcolor": "#e6e6e6", "zerolinecolor": "#e6e6e6", "linecolor": "#cccccc"},
97
+ "yaxis": {"gridcolor": "#e6e6e6", "zerolinecolor": "#e6e6e6", "linecolor": "#cccccc"},
98
+ "margin": {"l": 60, "r": 70, "t": 70, "b": 55},
99
+ }
100
+ }
101
+
102
+
103
+ def _style(fig: go.Figure) -> go.Figure:
104
+ fig.update_layout(**create_seaborn_template()["layout"])
105
+ return fig
106
+
107
+
108
+ def _empty_fig(message: str) -> go.Figure:
109
+ fig = go.Figure()
110
+ fig.add_annotation(text=message, showarrow=False, font=dict(size=15, color="#888888"))
111
+ fig.update_xaxes(visible=False)
112
+ fig.update_yaxes(visible=False)
113
+ return _style(fig)
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Charts (plotly, seaborn-styled)
118
+ # ---------------------------------------------------------------------------
119
+
120
+ def create_australia_map(skill_filter: str = "all") -> go.Figure:
121
+ """Scattergeo of all knowledge-base breaks, coloured by skill bucket."""
122
+ lats, lngs, names, texts, colors = [], [], [], [], []
123
+ for b in spots.load_breaks():
124
+ if not _matches_skill(b.additional_details.skill_level, skill_filter):
125
+ continue
126
+ swell = b.ideal_swell
127
+ wind = b.ideal_wind
128
+ lats.append(b.coordinates.lat)
129
+ lngs.append(b.coordinates.lng)
130
+ names.append(b.name)
131
+ colors.append(SKILL_COLORS.get(_primary_skill(b.additional_details.skill_level), MUTED["gray"]))
132
+ texts.append(
133
+ f"<b>{b.name}</b> ({b.region})<br>"
134
+ f"Skill: {b.additional_details.skill_level}<br>"
135
+ f"Ideal swell: {swell.size_ft_min}–{swell.size_ft_max} ft {swell.direction}<br>"
136
+ f"Ideal wind: ≤{wind.strength_kt_max} kt {wind.direction}"
137
+ )
138
+
139
+ fig = go.Figure(
140
+ go.Scattergeo(
141
+ lat=lats,
142
+ lon=lngs,
143
+ text=names,
144
+ hovertext=texts,
145
+ hoverinfo="text",
146
+ mode="markers",
147
+ marker=dict(size=9, color=colors, opacity=0.85, line=dict(width=1, color="white")),
148
+ showlegend=False,
149
+ )
150
+ )
151
+ fig.update_layout(
152
+ title=dict(text="Australian Surf Breaks", font=dict(size=17, color="#1f2d3d")),
153
+ geo=dict(
154
+ scope="world",
155
+ projection=dict(type="mercator"),
156
+ center=dict(lat=-25.5, lon=134.5),
157
+ lonaxis_range=[111, 156],
158
+ lataxis_range=[-44, -9.5],
159
+ showland=True,
160
+ landcolor="#f3efe4",
161
+ showocean=True,
162
+ oceancolor="#d7e9f5",
163
+ showcountries=False,
164
+ showcoastlines=True,
165
+ coastlinecolor="#9bb8cc",
166
+ showlakes=False,
167
+ bgcolor="#d7e9f5",
168
+ ),
169
+ height=560,
170
+ margin={"l": 10, "r": 10, "t": 50, "b": 10},
171
+ font={"family": "DejaVu Sans, Helvetica, Arial, sans-serif", "size": 13, "color": "#3b3b3b"},
172
+ )
173
+ return fig
174
+
175
+
176
+ def create_wave_height_chart(forecast: dict, spot_name: str) -> go.Figure:
177
+ """Hourly wave height (left axis) + period and wind speed (right axis)."""
178
+ hourly = (forecast or {}).get("hourly") or []
179
+ if not hourly:
180
+ return _empty_fig(f"No forecast data available for {spot_name}")
181
+
182
+ times = [h.get("time", "") for h in hourly]
183
+ wave = [h.get("wave_height") for h in hourly]
184
+ period = [h.get("wave_period") for h in hourly]
185
+ wind = [h.get("wind_speed_10m") for h in hourly]
186
+ wind_dir = [h.get("wind_direction_10m") for h in hourly]
187
+ wave_dir = [h.get("wave_direction") or h.get("swell_wave_direction") for h in hourly]
188
+
189
+ fig = go.Figure()
190
+ fig.add_trace(
191
+ go.Scatter(
192
+ x=times, y=wave, name="Wave height (m)", mode="lines",
193
+ line=dict(color=MUTED["blue"], width=3), fill="tozeroy",
194
+ fillcolor="rgba(72,120,208,0.12)",
195
+ customdata=list(zip([_compass(d) for d in wave_dir], [d for d in wave_dir])),
196
+ hovertemplate="%{x}<br>Wave height: %{y:.2f} m<br>Swell dir: %{customdata[0]} (%{customdata[1]}°)<extra></extra>",
197
+ )
198
+ )
199
+ fig.add_trace(
200
+ go.Scatter(
201
+ x=times, y=period, name="Period (s)", mode="lines",
202
+ line=dict(color=MUTED["purple"], width=2, dash="dot"),
203
+ yaxis="y2",
204
+ hovertemplate="%{x}<br>Period: %{y:.1f} s<extra></extra>",
205
+ )
206
+ )
207
+ fig.add_trace(
208
+ go.Scatter(
209
+ x=times, y=wind, name="Wind (km/h)", mode="lines",
210
+ line=dict(color=MUTED["red"], width=2),
211
+ yaxis="y2",
212
+ customdata=[_compass(d) for d in wind_dir],
213
+ hovertemplate="%{x}<br>Wind: %{y:.1f} km/h %{customdata}<extra></extra>",
214
+ )
215
+ )
216
+ fig.update_layout(
217
+ title=f"Wave & Wind Forecast — {spot_name}",
218
+ xaxis=dict(title="Time (local)"),
219
+ yaxis=dict(title="Wave height (m)", rangemode="tozero"),
220
+ yaxis2=dict(title="Period (s) / Wind (km/h)", overlaying="y", side="right", showgrid=False),
221
+ hovermode="x unified",
222
+ )
223
+ return _style(fig)
224
+
225
+
226
+ def create_score_chart(scores: list, spot_name: str) -> go.Figure:
227
+ """Hourly surf score (bold) with the four deterministic components."""
228
+ if not scores:
229
+ return _empty_fig(f"No score data available for {spot_name}")
230
+
231
+ times = [s.get("time", "") for s in scores]
232
+ total = [s.get("score") for s in scores]
233
+ comps = scores[0].get("components") or {}
234
+
235
+ fig = go.Figure()
236
+ component_colors = {
237
+ "swell_size": MUTED["blue"],
238
+ "swell_direction": MUTED["cyan"],
239
+ "wind": MUTED["red"],
240
+ "period": MUTED["purple"],
241
+ }
242
+ for comp in ("swell_size", "swell_direction", "wind", "period"):
243
+ if comp in comps:
244
+ fig.add_trace(
245
+ go.Scatter(
246
+ x=times,
247
+ y=[(s.get("components") or {}).get(comp) for s in scores],
248
+ name=comp.replace("_", " ").title(),
249
+ mode="lines",
250
+ line=dict(color=component_colors[comp], width=1.5, dash="dot"),
251
+ opacity=0.75,
252
+ hovertemplate="%{x}<br>" + comp.replace("_", " ") + ": %{y:.1f}<extra></extra>",
253
+ )
254
+ )
255
+ fig.add_trace(
256
+ go.Scatter(
257
+ x=times, y=total, name="Surf score", mode="lines",
258
+ line=dict(color="#1f2d3d", width=3.5),
259
+ hovertemplate="%{x}<br>Score: %{y:.1f}/10<extra></extra>",
260
+ )
261
+ )
262
+ fig.update_layout(
263
+ title=f"Hourly Surf Score — {spot_name}",
264
+ xaxis=dict(title="Time (local)"),
265
+ yaxis=dict(title="Score (0–10)", range=[0, 10]),
266
+ hovermode="x unified",
267
+ )
268
+ return _style(fig)
269
+
270
+
271
+ def create_ranking_chart(ranked: list, region: str) -> go.Figure:
272
+ """Horizontal bars of each spot's best score this week."""
273
+ if not ranked:
274
+ return _empty_fig(f"No ranking data for {region}")
275
+ spots_sorted = sorted(ranked, key=lambda r: r.get("best_score") or 0)
276
+ names = [f"{r.get('name', '?')} ({r.get('region', '')})" for r in spots_sorted]
277
+ values = [r.get("best_score") or 0 for r in spots_sorted]
278
+ best_times = [r.get("best_time", "–") for r in spots_sorted]
279
+
280
+ fig = go.Figure(
281
+ go.Bar(
282
+ x=values,
283
+ y=names,
284
+ orientation="h",
285
+ marker=dict(
286
+ color=values,
287
+ colorscale=[[0, MUTED["cyan"]], [0.6, MUTED["blue"]], [1, MUTED["green"]]],
288
+ line=dict(width=0),
289
+ ),
290
+ customdata=best_times,
291
+ hovertemplate="%{y}<br>Best score: %{x:.1f}/10<br>Best time: %{customdata}<extra></extra>",
292
+ )
293
+ )
294
+ fig.update_layout(
295
+ title=f"Best Scores This Week — {region}",
296
+ xaxis=dict(title="Best hourly score (0–10)", range=[0, 10]),
297
+ yaxis=dict(title=""),
298
+ height=max(360, 28 * len(names) + 120),
299
+ )
300
+ return _style(fig)
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # Trace panel
305
+ # ---------------------------------------------------------------------------
306
+
307
+ _TOOL_EMOJI = {
308
+ "get_forecast": "🌊",
309
+ "score_week": "🧮",
310
+ "find_spots": "🔎",
311
+ "get_spot_knowledge": "📚",
312
+ "rank_spots_this_week": "🏆",
313
+ }
314
+
315
+ # Tool invocations inside CodeAgent python_interpreter code blobs
316
+ _TOOL_CALL_RE = re.compile(
317
+ r"\b(get_forecast|score_week|find_spots|get_spot_knowledge|rank_spots_this_week)\(([^)]*)"
318
+ )
319
+
320
+
321
+ def _code_step_summary(args) -> tuple[str, list[tuple[str, str]]]:
322
+ """Summarise a python_interpreter code blob: (display_code, tool calls)."""
323
+ code = args if isinstance(args, str) else str(args)
324
+ calls = [
325
+ (tool, re.findall(r"[\"']([^\"']+)[\"']", argstr))
326
+ for tool, argstr in _TOOL_CALL_RE.findall(code)
327
+ ]
328
+ snippet = code.strip().replace("```", "")
329
+ if len(snippet) > 400:
330
+ snippet = snippet[:400] + " …"
331
+ return snippet, calls
332
+
333
+
334
+ def update_trace_panel(trace_json: str) -> str:
335
+ """Render an AgentTrace JSON blob as readable markdown for the UI."""
336
+ if not trace_json or not trace_json.strip():
337
+ return (
338
+ "### 🔍 Agent Trace\n\nNo trace yet — ask the agent a question and "
339
+ "watch it work: every tool call, the data it saw, and its reasoning "
340
+ "appear here."
341
+ )
342
+ try:
343
+ trace = json.loads(trace_json)
344
+ except (json.JSONDecodeError, TypeError):
345
+ return trace_json
346
+
347
+ lines = ["### 🔍 Agent Trace"]
348
+ question = trace.get("question", "")
349
+ if question:
350
+ lines.append(f"**Question:** {question}")
351
+ model = trace.get("model", "")
352
+ provider = trace.get("provider", "")
353
+ if model or provider:
354
+ lines.append(f"**Model:** `{model}` via `{provider}`")
355
+ lines.append("")
356
+
357
+ n_chunks = 0
358
+ for evt in trace.get("events", []):
359
+ etype = evt.get("type", "")
360
+ data = evt.get("data", {})
361
+ if etype == "tool_call":
362
+ name = data.get("name", "?")
363
+ args = data.get("arguments", {})
364
+ if name == "python_interpreter":
365
+ snippet, calls = _code_step_summary(args)
366
+ called = ", ".join(f"`{tool}`" for tool, _ in calls) if calls else ""
367
+ lines.append(f"- 🧠 **code step** {f'→ calls {called}' if called else ''}")
368
+ lines.append(f"\n```python\n{snippet}\n```\n")
369
+ else:
370
+ emoji = _TOOL_EMOJI.get(name, "🔧")
371
+ if isinstance(args, str):
372
+ args_display = args
373
+ else:
374
+ args_display = ", ".join(
375
+ f"{k}=`{v}`" for k, v in args.items() if v is not None
376
+ )
377
+ lines.append(f"- {emoji} **`{name}`**({args_display})")
378
+ elif etype == "tool_result":
379
+ obs = str(data.get("observation", ""))
380
+ if len(obs) > 220:
381
+ obs = obs[:220] + "…"
382
+ lines.append(f" - ↳ *{obs}*")
383
+ elif etype == "llm_chunk":
384
+ n_chunks += 1
385
+ elif etype == "final_answer":
386
+ lines.append("- ✅ **Final answer** composed")
387
+ if n_chunks:
388
+ lines.append(f"- 💬 Streamed {n_chunks} token chunks")
389
+
390
+ return "\n".join(lines)
391
+
392
+
393
+ # ---------------------------------------------------------------------------
394
+ # Data helpers for the UI
395
+ # ---------------------------------------------------------------------------
396
+
397
+ def _spot_label(b) -> str:
398
+ return f"{b.name} ({b.region})"
399
+
400
+
401
+ def _all_spot_choices() -> list[str]:
402
+ return sorted(_spot_label(b) for b in spots.load_breaks())
403
+
404
+
405
+ def _parse_spot_label(label: str):
406
+ m = re.match(r"^(.*) \(([A-Z]{2,3})\)$", label or "")
407
+ if not m:
408
+ return None
409
+ return spots.get_spot(m.group(1), m.group(2))
410
+
411
+
412
+ def _spot_card(spot) -> str:
413
+ if spot is None:
414
+ return "_Select a break to see its profile._"
415
+
416
+ def badge(text: str, color: str) -> str:
417
+ return (
418
+ f"<span class='skill-badge' style='background:{color}22; color:{color}; "
419
+ f"border:1px solid {color}66'>{text}</span>"
420
+ )
421
+
422
+ d = spot.additional_details
423
+ swell = spot.ideal_swell
424
+ wind = spot.ideal_wind
425
+ badges = " ".join(
426
+ badge(t, SKILL_COLORS.get(_primary_skill(t), MUTED["gray"]))
427
+ for t in (d.skill_level, d.break_type, d.break_direction)
428
+ )
429
+ hazards = d.hazards or "—"
430
+ return f"""
431
+ ### 🏄 {spot.name} · {spot.region}
432
+
433
+ {badges}
434
+
435
+ {spot.short_description}
436
+
437
+ | Ideal conditions | |
438
+ |---|---|
439
+ | **Swell** | {swell.size_ft_min}–{swell.size_ft_max} ft from {swell.direction} |
440
+ | **Wind** | offshore/low up to {wind.strength_kt_max} kt ({wind.direction}) |
441
+ | **Tide** | {spot.ideal_tide} *(qualitative)* |
442
+ | **Bottom** | {d.bottom_type} · {d.break_surface} |
443
+ | **Best season** | {d.best_season} |
444
+
445
+ ⚠️ **Hazards:** {hazards}
446
+
447
+ {d.other_notes or ""}
448
+ """
449
+
450
+
451
+ def _get_spot_data(spot):
452
+ """Forecast + scores for a spot, with a small in-process cache."""
453
+ if spot is None:
454
+ return None, None
455
+ cache = getattr(_get_spot_data, "_cache", None)
456
+ if cache is None:
457
+ cache = _get_spot_data._cache = {}
458
+ key = (spot.name, spot.region)
459
+ if key not in cache:
460
+ forecast = tools.get_forecast(spot.name, spot.region)
461
+ if "error" in forecast:
462
+ return None, None
463
+ scores = tools.score_week(spot.name, spot.region)
464
+ if isinstance(scores, dict) and "error" in scores:
465
+ scores = []
466
+ cache[key] = (forecast, scores)
467
+ return cache[key]
468
+
469
+
470
+ def _guess_region(name: str) -> str | None:
471
+ """Resolve a spot's region from the knowledge base by exact name."""
472
+ for b in spots.load_breaks():
473
+ if b.name.lower() == name.lower():
474
+ return b.region
475
+ return None
476
+
477
+
478
+ def _extract_trace_targets(trace_json: str) -> tuple[list[tuple[str, str | None]], list[str]]:
479
+ """What the agent looked at, from python_interpreter code blobs.
480
+
481
+ Returns (spot_pairs, rank_regions): spot (name, region-or-None) pairs for
482
+ forecast/score/knowledge calls, and regions passed to ranking calls.
483
  """
484
+ spot_pairs: list[tuple[str, str | None]] = []
485
+ rank_regions: list[str] = []
486
+ try:
487
+ trace = json.loads(trace_json)
488
+ except (json.JSONDecodeError, TypeError):
489
+ return spot_pairs, rank_regions
490
+ for evt in trace.get("events", []):
491
+ if evt.get("type") != "tool_call":
492
+ continue
493
+ args = evt.get("data", {}).get("arguments")
494
+ code = args if isinstance(args, str) else json.dumps(args or {})
495
+ for tool, vals in (
496
+ (t, re.findall(r"[\"']([^\"']+)[\"']", a))
497
+ for t, a in _TOOL_CALL_RE.findall(code)
498
+ ):
499
+ if tool in ("get_forecast", "score_week") and len(vals) >= 2:
500
+ pair = (vals[0], vals[1])
501
+ elif tool == "get_spot_knowledge" and vals:
502
+ pair = (vals[0], vals[1] if len(vals) >= 2 else None)
503
+ elif tool == "rank_spots_this_week" and vals:
504
+ if vals[0] not in rank_regions:
505
+ rank_regions.append(vals[0])
506
+ continue
507
+ else:
508
+ continue
509
+ if pair not in spot_pairs:
510
+ spot_pairs.append(pair)
511
+ return spot_pairs, rank_regions
512
 
513
 
514
+ # ---------------------------------------------------------------------------
515
+ # Agent chat
516
+ # ---------------------------------------------------------------------------
517
+
518
+ _agents: dict[str, SurfAgent] = {}
519
+
520
+
521
+ def _get_agent(provider: str) -> SurfAgent:
522
+ if provider not in _agents:
523
+ _agents[provider] = SurfAgent(provider=provider)
524
+ return _agents[provider]
525
+
526
+
527
+ def _provider_available(provider: str) -> bool:
528
+ if provider == PROVIDER_NIM:
529
+ return bool(os.getenv("NVIDIA_API_KEY"))
530
+ return bool(os.getenv("HF_TOKEN"))
531
+
532
+
533
+ def chat_submit(message: str, history: list, provider: str):
534
+ """Streaming chat handler: yields (chatbot, trace_md, score_chart, wave_chart)."""
535
+ history = list(history or [])
536
+ if not message or not message.strip():
537
+ yield history, gr.update(), gr.update(), gr.update()
538
+ return
539
+
540
+ history.append({"role": "user", "content": message})
541
+ history.append({"role": "assistant", "content": "…"})
542
+
543
+ try:
544
+ agent = _get_agent(provider)
545
+ except ValueError as exc:
546
+ history[-1]["content"] = (
547
+ f"⚠️ **Model provider not configured.**\n\n{exc}\n\n"
548
+ "Set `HF_TOKEN` or `NVIDIA_API_KEY` in your environment and restart. "
549
+ "Everything else (map, charts, rankings, scores) works without tokens."
550
+ )
551
+ yield history, gr.update(), gr.update(), gr.update()
552
+ return
553
+
554
+ thinking: list[str] = []
555
+ final_answer = ""
556
+ trace_json = ""
557
+ for kind, chunk in agent.run_stream(message):
558
+ if kind == "model":
559
+ thinking.append(chunk)
560
+ tail = "".join(thinking)[-600:]
561
+ history[-1]["content"] = f"*🧠 thinking…*\n\n```python\n{tail}\n```"
562
+ yield history, gr.update(), gr.update(), gr.update()
563
+ elif kind == "tool":
564
+ history[-1]["content"] = f"*🔧 calling `{chunk}`…*"
565
+ yield history, gr.update(), gr.update(), gr.update()
566
+ elif kind == "final":
567
+ final_answer = chunk if isinstance(chunk, str) else str(chunk)
568
+ trace_json = agent.trace.to_json() if agent.trace else ""
569
+
570
+ history[-1]["content"] = final_answer or "*(empty answer)*"
571
+ trace_md = update_trace_panel(trace_json)
572
+
573
+ # Auto-render interactive charts for what the agent looked at
574
+ score_fig: object = gr.update()
575
+ wave_fig: object = gr.update()
576
+ spot_pairs, rank_regions = _extract_trace_targets(trace_json)
577
+ if spot_pairs:
578
+ name, region = spot_pairs[0]
579
+ region = region or _guess_region(name)
580
+ spot = spots.get_spot(name, region) if region else None
581
+ if spot is not None:
582
+ forecast, scores = _get_spot_data(spot)
583
+ if scores:
584
+ score_fig = create_score_chart(scores[-48:], spot.name)
585
+ if forecast:
586
+ wave_fig = create_wave_height_chart(
587
+ {"hourly": forecast["hourly"][-48:]}, spot.name
588
+ )
589
+ elif rank_regions:
590
+ ranked = tools.rank_spots_this_week(rank_regions[0], None)
591
+ score_fig = create_ranking_chart(ranked, rank_regions[0])
592
+
593
+ yield history, trace_md, score_fig, wave_fig
594
+
595
+
596
+ # ---------------------------------------------------------------------------
597
+ # daggr Morning Surf Report + cache warming (started only by main())
598
+ # ---------------------------------------------------------------------------
599
+
600
+ def _start_daggr_server(host: str = "127.0.0.1", port: int = 7861) -> None:
601
+ """Launch the Morning Surf Report canvas as its own process.
602
+
603
+ A second Gradio instance inside this process is unreliable, so the daggr
604
+ app runs standalone; the tab's iframe just points at it.
605
+ """
606
+ try:
607
+ subprocess.Popen(
608
+ [sys.executable, "-m", "wavereader.daggr_pipeline"],
609
+ stdout=subprocess.DEVNULL,
610
+ stderr=subprocess.DEVNULL,
611
+ start_new_session=True,
612
+ )
613
+ except Exception:
614
+ pass # canvas tab falls back to a "how to start" note
615
+
616
+
617
+ def _warm_forecast_cache() -> None:
618
+ """Prefetch all break forecasts into the disk cache (demo survivability)."""
619
+ for b in spots.load_breaks():
620
+ try:
621
+ tools.get_forecast(b.name, b.region)
622
+ except Exception:
623
+ continue
624
+
625
+
626
+ def _start_background_services() -> None:
627
+ if os.getenv("WAVEREADER_EMBED_DAGGR", "1") == "1":
628
+ threading.Thread(target=_start_daggr_server, daemon=True).start()
629
+ if os.getenv("WAVEREADER_WARM_CACHE", "1") == "1":
630
+ threading.Thread(target=_warm_forecast_cache, daemon=True).start()
631
+
632
+
633
+ # ---------------------------------------------------------------------------
634
+ # UI assembly
635
+ # ---------------------------------------------------------------------------
636
+
637
+ _CUSTOM_CSS = """
638
+ .gradio-container {max-width: 1400px !important; margin: 0 auto;}
639
+ #wr-header {
640
+ background: linear-gradient(120deg, #0b3d5c 0%, #1173a6 55%, #19b3a6 100%);
641
+ border-radius: 16px; padding: 26px 30px !important; color: white;
642
+ margin-bottom: 14px;
643
+ }
644
+ #wr-header h1 {color: white; margin: 0 0 6px 0; font-size: 2em; letter-spacing: 0.5px;}
645
+ #wr-header p {color: #d7ecf7; margin: 0; font-size: 1.02em;}
646
+ .skill-badge {
647
+ display: inline-block; padding: 2px 10px; border-radius: 999px;
648
+ font-size: 0.8em; font-weight: 600; white-space: nowrap;
649
+ }
650
+ #wr-trace {
651
+ background: #f7fafc; border: 1px solid #e2e8f0; border-radius: 12px;
652
+ padding: 10px 14px; font-size: 0.92em; max-height: 640px; overflow-y: auto;
653
+ }
654
+ #wr-trace code {background: #eef2f7; padding: 1px 5px; border-radius: 5px;}
655
+ footer {display: none !important;}
656
+ """
657
+
658
+
659
+ def build_ui() -> gr.Blocks:
660
+ """Assemble the full wavereader demo UI."""
661
+ spot_choices = _all_spot_choices()
662
+ hf_ok, nim_ok = _provider_available(PROVIDER_HF), _provider_available(PROVIDER_NIM)
663
+
664
+ with gr.Blocks(
665
+ title="WaveReader — Agentic Surf Forecaster",
666
+ theme=gr.themes.Soft(
667
+ primary_hue="sky",
668
+ secondary_hue="blue",
669
+ neutral_hue="slate",
670
+ font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui"],
671
+ ),
672
+ css=_CUSTOM_CSS,
673
+ ) as demo:
674
+
675
+ # ------------------------------------------------ header
676
+ status = "🟢 agent ready" if (hf_ok or nim_ok) else "🔴 set HF_TOKEN or NVIDIA_API_KEY for the agent"
677
+ gr.HTML(
678
+ f"""<div id="wr-header">
679
+ <h1>🏄 WaveReader</h1>
680
+ <p>Agentic surf forecaster for Australian breaks — deterministic scores, visible reasoning.
681
+ &nbsp;·&nbsp; Model: <code>Qwen/Qwen3-Next-80B-A3B-Instruct</code> &nbsp;·&nbsp; {status}</p>
682
+ </div>"""
683
+ )
684
+
685
+ with gr.Tabs():
686
+
687
+ # ------------------------------------------------ tab: explore (map home)
688
+ with gr.Tab("🗺️ Explore"):
689
+ with gr.Row():
690
+ with gr.Column(scale=3):
691
+ map_plot = gr.Plot(create_australia_map(), label="Surf breaks across Australia", show_label=False)
692
+ map_skill_filter = gr.Dropdown(
693
+ choices=SKILL_FILTERS, value="all", label="Filter map by skill"
694
+ )
695
+ with gr.Column(scale=2):
696
+ gr.Markdown("### Pick your break")
697
+ spot_dropdown = gr.Dropdown(
698
+ choices=spot_choices,
699
+ value=None,
700
+ label="Search 100 breaks (name or region)",
701
+ info="Type to filter — e.g. 'Snapper' or 'QLD'",
702
+ )
703
+ spot_card_md = gr.Markdown(_spot_card(None))
704
+ spot_quick_btn = gr.Button(
705
+ "📊 Show forecast charts for this spot", variant="secondary"
706
+ )
707
+
708
+ # ------------------------------------------------ tab: agent chat
709
+ with gr.Tab("🤖 Surf Agent"):
710
+ with gr.Row():
711
+ with gr.Column(scale=5):
712
+ provider_radio = gr.Radio(
713
+ choices=[
714
+ ("Hugging Face 🤗", PROVIDER_HF),
715
+ ("NVIDIA NIM ⚡", PROVIDER_NIM),
716
+ ],
717
+ value=PROVIDER_HF if hf_ok else PROVIDER_NIM,
718
+ label="Model hosting",
719
+ )
720
+ chatbot = gr.Chatbot(
721
+ height=500,
722
+ label="WaveReader agent",
723
+ placeholder="Ask: *Where should I surf near Byron Bay this weekend as an intermediate?*",
724
+ )
725
+ with gr.Row():
726
+ chat_input = gr.Textbox(
727
+ placeholder="Ask about any break, your skill level, best hours…",
728
+ scale=8,
729
+ show_label=False,
730
+ autofocus=True,
731
+ )
732
+ chat_send = gr.Button("Send 🚀", variant="primary", scale=1)
733
+ gr.Examples(
734
+ examples=[
735
+ "Which QLD spots suit a beginner this week?",
736
+ "What time should I surf Snapper Rocks tomorrow?",
737
+ "Rank NSW breaks for an advanced surfer and explain the top pick.",
738
+ ],
739
+ inputs=[chat_input],
740
+ )
741
+ with gr.Column(scale=3):
742
+ trace_panel = gr.Markdown(update_trace_panel(""), elem_id="wr-trace")
743
+ gr.Markdown("### Charts the agent surfaced")
744
+ with gr.Row():
745
+ chat_score_chart = gr.Plot(
746
+ _empty_fig("Ask about a spot — its score chart appears here"),
747
+ label="Hourly surf score",
748
+ )
749
+ chat_wave_chart = gr.Plot(
750
+ _empty_fig("Ask about a spot — its forecast chart appears here"),
751
+ label="Wave & wind forecast",
752
+ )
753
+
754
+ # ------------------------------------------------ tab: charts
755
+ with gr.Tab("📊 Forecast Charts"):
756
+ with gr.Row():
757
+ chart_spot = gr.Dropdown(choices=spot_choices, value=None, label="Spot")
758
+ chart_load_btn = gr.Button("Load charts", variant="primary")
759
+ with gr.Row():
760
+ chart_wave_plot = gr.Plot(label="Wave height · period · wind")
761
+ with gr.Row():
762
+ chart_score_plot = gr.Plot(label="Hourly surf score + components")
763
+
764
+ # ------------------------------------------------ tab: rankings
765
+ with gr.Tab("🏆 Weekly Rankings"):
766
+ with gr.Row():
767
+ rank_region = gr.Dropdown(choices=REGIONS, value="NSW", label="Region")
768
+ rank_skill = gr.Dropdown(choices=SKILL_FILTERS, value="all", label="Skill level")
769
+ rank_btn = gr.Button("Rank this week", variant="primary")
770
+ rank_plot = gr.Plot(label="Best scores this week")
771
+ rank_table = gr.Dataframe(
772
+ headers=["Spot", "Region", "Best score", "Best time"],
773
+ label="Ranked table",
774
+ interactive=False,
775
+ )
776
+
777
+ # ------------------------------------------------ tab: morning report
778
+ with gr.Tab("📋 Morning Surf Report"):
779
+ gr.Markdown(
780
+ "The fixed **Morning Surf Report** pipeline (daggr): pick a region + "
781
+ "skill → find spots → fetch forecasts → score → rank → narrated report "
782
+ "with deterministic scores."
783
+ )
784
+ gr.HTML(
785
+ """<div style="border-radius:12px; border:1px solid #e2e8f0; overflow:hidden;">
786
+ <iframe src="http://127.0.0.1:7861" style="width:100%; height:760px; border:0;"></iframe>
787
+ </div>
788
+ <p><i>If the canvas is empty, start the pipeline app:
789
+ <code>uv run python -m wavereader.daggr_pipeline</code> (it runs alongside this UI on port 7861).</i></p>"""
790
+ )
791
+
792
+ # ------------------------------------------------ events: explore
793
+ map_skill_filter.change(
794
+ lambda skill: create_australia_map(skill),
795
+ inputs=[map_skill_filter],
796
+ outputs=[map_plot],
797
+ )
798
+
799
+ def show_spot_card(label):
800
+ return _spot_card(_parse_spot_label(label))
801
+
802
+ spot_dropdown.change(show_spot_card, inputs=[spot_dropdown], outputs=[spot_card_md])
803
+
804
+ def jump_to_charts(label):
805
+ spot = _parse_spot_label(label)
806
+ if spot is None:
807
+ return gr.update(), gr.update(), gr.update()
808
+ forecast, scores = _get_spot_data(spot)
809
+ wave = create_wave_height_chart(forecast, spot.name) if forecast else gr.update()
810
+ score = create_score_chart(scores, spot.name) if scores else gr.update()
811
+ return wave, score, gr.update(value=label)
812
+
813
+ spot_quick_btn.click(
814
+ jump_to_charts,
815
+ inputs=[spot_dropdown],
816
+ outputs=[chart_wave_plot, chart_score_plot, chart_spot],
817
+ )
818
+
819
+ # ------------------------------------------------ events: chat
820
+ chat_send.click(
821
+ chat_submit,
822
+ inputs=[chat_input, chatbot, provider_radio],
823
+ outputs=[chatbot, trace_panel, chat_score_chart, chat_wave_chart],
824
+ )
825
+ chat_input.submit(
826
+ chat_submit,
827
+ inputs=[chat_input, chatbot, provider_radio],
828
+ outputs=[chatbot, trace_panel, chat_score_chart, chat_wave_chart],
829
+ )
830
+
831
+ # ------------------------------------------------ events: charts tab
832
+ def load_charts(label):
833
+ spot = _parse_spot_label(label)
834
+ if spot is None:
835
+ return _empty_fig("Pick a spot above"), _empty_fig("Pick a spot above")
836
+ forecast, scores = _get_spot_data(spot)
837
+ wave = create_wave_height_chart(forecast, spot.name) if forecast else _empty_fig("No forecast")
838
+ score = create_score_chart(scores, spot.name) if scores else _empty_fig("No scores")
839
+ return wave, score
840
+
841
+ chart_load_btn.click(load_charts, inputs=[chart_spot], outputs=[chart_wave_plot, chart_score_plot])
842
+ chart_spot.change(load_charts, inputs=[chart_spot], outputs=[chart_wave_plot, chart_score_plot])
843
+
844
+ # ------------------------------------------------ events: rankings
845
+ def run_ranking(region, skill):
846
+ skill_arg = None if skill == "all" else skill
847
+ ranked = tools.rank_spots_this_week(region, skill_arg)
848
+ fig = create_ranking_chart(ranked, region)
849
+ table = [
850
+ [r.get("name"), r.get("region"), round(r.get("best_score") or 0, 1), r.get("best_time")]
851
+ for r in ranked
852
+ ]
853
+ return fig, table
854
+
855
+ rank_btn.click(run_ranking, inputs=[rank_region, rank_skill], outputs=[rank_plot, rank_table])
856
+
857
+ return demo
858
+
859
+
860
+ def main() -> None:
861
+ _start_background_services()
862
  demo = build_ui()
863
+ demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True)
864
+
865
+
866
+ if __name__ == "__main__":
867
+ main()
wavereader/daggr_pipeline.py CHANGED
@@ -2,15 +2,264 @@
2
 
3
  Daggr is beta and has NO conditional branching: this visualises the fixed
4
  morning pipeline only, never the dynamic chat agent loop.
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
- def build_pipeline() -> object:
11
- """Assemble the fixed morning-report DAG.
 
 
 
 
 
 
 
12
 
13
- TODO: nodes for pick-region -> fetch forecasts -> score -> summarise,
14
- rendered on the daggr canvas.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  """
16
- raise NotImplementedError
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  Daggr is beta and has NO conditional branching: this visualises the fixed
4
  morning pipeline only, never the dynamic chat agent loop.
5
+
6
+ Pipeline:
7
+ InputNode(region, skill) → FindSpotsFn → FetchForecastsFn →
8
+ ScoreForecastsFn → RankSpotsFn → { FormatReportFn (deterministic),
9
+ InferenceNode (narrates) }
10
+
11
+ Forecasts/scores flow between nodes keyed by ``"{name}|{region}"`` strings
12
+ (tuple keys don't survive a round-trip through gr.JSON).
13
  """
14
 
15
  from __future__ import annotations
16
 
17
+ import json
18
+ from typing import Any
19
+
20
+ import gradio as gr
21
+ from daggr import Graph, FnNode, InputNode, InferenceNode
22
+
23
+ from wavereader import forecasts, scoring, spots
24
+
25
+ NARRATION_PROMPT = (
26
+ "You are a surf forecaster. Given a ranked list of spots with scores, "
27
+ "write a concise, enthusiastic morning surf report. Keep it under 200 "
28
+ "words. Include specific times and scores. Be practical and actionable."
29
+ )
30
+
31
+
32
+ def _spot_key(spot: dict) -> str:
33
+ return f"{spot['name']}|{spot['region']}"
34
+
35
+
36
+ def _split_key(key: str) -> tuple[str, str]:
37
+ name, region = key.split("|", 1)
38
+ return name, region
39
+
40
+
41
+ def find_spots_fn(region: str, skill: str | None = None) -> dict[str, Any]:
42
+ """Find breaks in a region with optional skill filter.
43
+
44
+ The UI's "all" sentinel means no filter — ``spots.find_spots`` does a
45
+ substring skill match, so "all" must become None.
46
+ """
47
+ if skill in (None, "", "all"):
48
+ skill = None
49
+ breaks = spots.find_spots(region=region, skill=skill, limit=20)
50
+ return {
51
+ "spots": [b.model_dump() for b in breaks],
52
+ "region": region,
53
+ "skill": skill,
54
+ }
55
+
56
+
57
+ def _find_spots_node_fn(region: str, skill: str | None = None):
58
+ """daggr adapter: FnNode maps tuple returns to output ports in order.
59
+
60
+ ``find_spots_fn`` returns a dict for direct/test use; daggr's executor
61
+ treats a dict return as a single value for the first port only, so the
62
+ node unwraps it into ``(spots, region, skill)``.
63
+ """
64
+ result = find_spots_fn(region, skill)
65
+ return result["spots"], result["region"], result["skill"]
66
+
67
 
68
+ def fetch_forecasts_fn(spots_list: list[dict]) -> dict[str, Any]:
69
+ """Fetch forecasts for all spots."""
70
+ forecasts_map = {}
71
+ for spot in spots_list:
72
+ fc = forecasts.get_forecast(
73
+ spot["coordinates"]["lat"], spot["coordinates"]["lng"], days=7
74
+ )
75
+ forecasts_map[_spot_key(spot)] = fc
76
+ return {"forecasts": forecasts_map}
77
 
78
+
79
+ def score_forecasts_fn(forecasts_map: dict, spots_list: list[dict]) -> dict[str, Any]:
80
+ """Score forecasts for all spots."""
81
+ scored = {}
82
+ for spot in spots_list:
83
+ key = _spot_key(spot)
84
+ if key in forecasts_map:
85
+ scored[key] = scoring.score_week(forecasts_map[key], spot)
86
+ return {"scored": scored}
87
+
88
+
89
+ def rank_spots_fn(scored: dict, spots_list: list[dict]) -> dict[str, Any]:
90
+ """Rank spots by best score from the already-scored hourly rows.
91
+
92
+ Scores were computed in the previous node keyed by ``"{name}|{region}"``
93
+ strings (tuple keys don't survive a round-trip through gr.JSON), so rank
94
+ directly here rather than re-entering ``scoring.rank_spots_this_week``.
95
  """
96
+ ranked = []
97
+ for spot in spots_list:
98
+ key = _spot_key(spot)
99
+ hours = scored.get(key) or []
100
+ best_hour = max(hours, key=lambda h: h["score"]) if hours else None
101
+ ranked.append(
102
+ {
103
+ "name": spot["name"],
104
+ "region": spot["region"],
105
+ "best_score": best_hour["score"] if best_hour else 0.0,
106
+ "best_time": best_hour["time"] if best_hour else None,
107
+ "best_hour": best_hour,
108
+ }
109
+ )
110
+ ranked.sort(key=lambda x: x["best_score"], reverse=True)
111
+ return {"ranked": ranked}
112
+
113
+
114
+ def format_report_fn(ranked: list[dict], region: str) -> dict[str, str]:
115
+ """Format the deterministic morning surf report (authoritative output)."""
116
+ if not ranked:
117
+ return {"report": f"No spots found in {region}."}
118
+
119
+ lines = [f"☀️ **Morning Surf Report — {region}**", ""]
120
+ lines.append("📅 Top picks for this week:")
121
+ lines.append("")
122
+
123
+ for i, spot in enumerate(ranked[:5], 1):
124
+ best = spot["best_hour"]
125
+ time_str = best["time"].split("T")[1][:5] if best and best.get("time") else "N/A"
126
+ score = best["score"] if best else 0
127
+ comps = best["components"] if best else {}
128
+
129
+ emoji = "🟢" if score >= 7 else "🟡" if score >= 5 else "🔴"
130
+ lines.append(f"{i}. {emoji} **{spot['name']}** — Score: {score:.1f}/10 @ {time_str}")
131
+ lines.append(
132
+ f" Swell: {comps.get('swell_size') or 0:.1f} | "
133
+ f"Dir: {comps.get('swell_direction') if comps.get('swell_direction') is not None else 'n/a'} | "
134
+ f"Wind: {comps.get('wind') or 0:.1f} | Period: {comps.get('period') or 0:.1f}"
135
+ )
136
+
137
+ lines.append("")
138
+ lines.append("💡 *Scores are deterministic (0-10) based on ideal swell/wind/period for each break.*")
139
+ lines.append("*Tide is qualitative only — check local tide charts.*")
140
+
141
+ return {"report": "\n".join(lines)}
142
+
143
+
144
+ def _narration_preprocess(inputs: dict) -> dict[str, str]:
145
+ """Flatten ranked spots into a single text-generation prompt.
146
+
147
+ daggr's text-generation path calls ``client.text_generation(prompt)``
148
+ with the first input value — an OpenAI chat payload would be dumped in
149
+ as a list of dicts, so keep it a plain string.
150
+ """
151
+ prompt = (
152
+ f"{NARRATION_PROMPT}\n\n"
153
+ f"Region: {inputs['region']}\n"
154
+ f"Ranked spots: {json.dumps(inputs['ranked'][:5], default=str)}\n\n"
155
+ "Write a morning surf report."
156
+ )
157
+ return {"prompt": prompt}
158
+
159
+
160
+ def _narration_postprocess(result: Any) -> dict[str, str]:
161
+ """Extract text from a text_generation result (str or list of dicts)."""
162
+ if isinstance(result, str):
163
+ text = result
164
+ elif isinstance(result, list) and result and isinstance(result[0], dict):
165
+ text = result[0].get("generated_text", str(result))
166
+ else:
167
+ text = str(result)
168
+ return {"ai_report": text}
169
+
170
+
171
+ def build_pipeline() -> Graph:
172
+ """Assemble the fixed morning-report DAG."""
173
+ input_node = InputNode(
174
+ name="User Input",
175
+ ports={
176
+ "region": gr.Dropdown(
177
+ choices=["NSW", "QLD", "VIC", "WA", "SA", "TAS"],
178
+ label="Region",
179
+ value="NSW",
180
+ ),
181
+ "skill": gr.Dropdown(
182
+ choices=["beginner", "intermediate", "advanced", "expert", "all"],
183
+ label="Skill Level",
184
+ value="all",
185
+ ),
186
+ },
187
+ )
188
+
189
+ find_spots_node = FnNode(
190
+ _find_spots_node_fn,
191
+ name="Find Spots",
192
+ inputs={
193
+ "region": input_node.region,
194
+ "skill": input_node.skill,
195
+ },
196
+ outputs={"spots": gr.JSON(), "region": gr.Textbox(), "skill": gr.Textbox()},
197
+ )
198
+
199
+ fetch_forecasts_node = FnNode(
200
+ fetch_forecasts_fn,
201
+ name="Fetch Forecasts",
202
+ inputs={"spots_list": find_spots_node.spots},
203
+ outputs={"forecasts": gr.JSON()},
204
+ )
205
+
206
+ score_forecasts_node = FnNode(
207
+ score_forecasts_fn,
208
+ name="Score Forecasts",
209
+ inputs={
210
+ "forecasts_map": fetch_forecasts_node.forecasts,
211
+ "spots_list": find_spots_node.spots,
212
+ },
213
+ outputs={"scored": gr.JSON()},
214
+ )
215
+
216
+ rank_spots_node = FnNode(
217
+ rank_spots_fn,
218
+ name="Rank Spots",
219
+ inputs={
220
+ "scored": score_forecasts_node.scored,
221
+ "spots_list": find_spots_node.spots,
222
+ },
223
+ outputs={"ranked": gr.JSON()},
224
+ )
225
+
226
+ # Narrate with the LLM — narration only; scores stay deterministic.
227
+ narrate_node = InferenceNode(
228
+ model="Qwen/Qwen3-Next-80B-A3B-Instruct",
229
+ name="Narrate Report",
230
+ inputs={
231
+ "ranked": rank_spots_node.ranked,
232
+ "region": find_spots_node.region,
233
+ },
234
+ outputs={"ai_report": gr.Markdown()},
235
+ preprocess=_narration_preprocess,
236
+ postprocess=_narration_postprocess,
237
+ )
238
+
239
+ format_report_node = FnNode(
240
+ format_report_fn,
241
+ name="Format Report",
242
+ inputs={
243
+ "ranked": rank_spots_node.ranked,
244
+ "region": find_spots_node.region,
245
+ },
246
+ outputs={"report": gr.Markdown()},
247
+ )
248
+
249
+ graph = Graph("Morning Surf Report", nodes=[input_node])
250
+ for node in (
251
+ find_spots_node,
252
+ fetch_forecasts_node,
253
+ score_forecasts_node,
254
+ rank_spots_node,
255
+ narrate_node,
256
+ format_report_node,
257
+ ):
258
+ graph.add(node)
259
+
260
+ return graph
261
+
262
+
263
+ if __name__ == "__main__":
264
+ graph = build_pipeline()
265
+ graph.launch(host="0.0.0.0", port=7861)
wavereader/forecasts.py CHANGED
@@ -11,16 +11,21 @@ Two endpoints, merged on ``time``:
11
  knots for scoring) and ``wind_direction_10m`` (deg).
12
 
13
  Grid points snap ~10 km offshore of a spot's coords (Snapper Rocks
14
- -28.173,153.556 resolved to -28.04,153.63). Fetch slightly seaward and
15
- keep the offset consistent across all spots.
 
16
 
17
  Cache every response to disk: the demo must survive API flakiness and
18
- rate limits. Horizon: hourly, 7 days.
 
 
19
  """
20
 
21
  from __future__ import annotations
22
 
23
  import json
 
 
24
  from pathlib import Path
25
 
26
  import httpx
@@ -37,35 +42,70 @@ MARINE_HOURLY_FIELDS = (
37
  )
38
  WEATHER_HOURLY_FIELDS = ("wind_speed_10m", "wind_direction_10m")
39
 
40
- _SNAP_OFFSET = (-0.133, 0.074)
 
41
 
42
  CACHE_DIR = Path(__file__).resolve().parent.parent / ".cache" / "forecasts"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
 
45
  def _round_coords(lat: float, lon: float) -> tuple[float, float]:
46
- return round(lat + _SNAP_OFFSET[0], 2), round(lon + _SNAP_OFFSET[1], 2)
 
47
 
48
 
49
  def _cache_key(lat: float, lon: float, days: int) -> str:
50
- return f"{lat:.2f}_{lon:.2f}_{days}"
 
51
 
52
 
53
  def _cache_path(lat: float, lon: float, days: int) -> Path:
54
  return CACHE_DIR / f"{_cache_key(lat, lon, days)}.json"
55
 
56
 
57
- def _load_cache(lat: float, lon: float, days: int) -> dict | None:
 
 
 
 
 
58
  p = _cache_path(lat, lon, days)
59
- if p.exists():
60
- return json.loads(p.read_text(encoding="utf-8"))
61
- return None
 
 
 
 
 
 
62
 
63
 
64
  def _save_cache(lat: float, lon: float, days: int, data: dict) -> None:
 
65
  CACHE_DIR.mkdir(parents=True, exist_ok=True)
66
- _cache_path(lat, lon, days).write_text(
67
- json.dumps(data, ensure_ascii=False), encoding="utf-8"
68
- )
 
69
 
70
 
71
  def _build_normalized(marine: dict, weather: dict) -> dict:
@@ -98,17 +138,8 @@ def _build_normalized(marine: dict, weather: dict) -> dict:
98
  return {"hourly": frames}
99
 
100
 
101
- def get_forecast(lat: float, lon: float, days: int = 7) -> dict:
102
- """Fetch marine + weather hourly data for a coordinate, merged on time.
103
-
104
- Returns a normalised frame: ``{"hourly": [{"time": ..., "wave_height": ...,
105
- "wind_speed_10m": ..., ...}, ...]}``. Disk-cached by rounded coords + days.
106
- """
107
- rlat, rlon = _round_coords(lat, lon)
108
- cached = _load_cache(rlat, rlon, days)
109
- if cached is not None:
110
- return cached
111
-
112
  params: dict[str, str | int | float] = {
113
  "latitude": rlat,
114
  "longitude": rlon,
@@ -123,14 +154,29 @@ def get_forecast(lat: float, lon: float, days: int = 7) -> dict:
123
  resp_w.raise_for_status()
124
  weather = resp_w.json()
125
 
126
- merged = _build_normalized(marine, weather)
127
- _save_cache(rlat, rlon, days, merged)
128
- return merged
129
 
130
 
131
- if __name__ == "__main__":
132
- result = get_forecast(-28.173, 153.556, days=2)
133
- print(f"Fetched {len(result['hourly'])} hourly rows")
134
- if result["hourly"]:
135
- print("First:", result["hourly"][0])
136
- print("Last:", result["hourly"][-1])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  knots for scoring) and ``wind_direction_10m`` (deg).
12
 
13
  Grid points snap ~10 km offshore of a spot's coords (Snapper Rocks
14
+ -28.173,153.556 resolved to -28.04,153.63). Fetch slightly seaward of the
15
+ spot: east-coast breaks shift east, west-coast breaks shift west,
16
+ south-coast breaks (SA/VIC/TAS) shift south.
17
 
18
  Cache every response to disk: the demo must survive API flakiness and
19
+ rate limits. Fresh cache (within ``CACHE_TTL_SECONDS``) is served without
20
+ a network call; when the API fails, a stale cache entry is served as a
21
+ fallback rather than raising. Horizon: hourly, 7 days.
22
  """
23
 
24
  from __future__ import annotations
25
 
26
  import json
27
+ import os
28
+ import time
29
  from pathlib import Path
30
 
31
  import httpx
 
42
  )
43
  WEATHER_HOURLY_FIELDS = ("wind_speed_10m", "wind_direction_10m")
44
 
45
+ # ~0.13° is roughly 14 km — enough to move off the coast onto a marine grid point.
46
+ _SEAWARD_OFFSET_DEG = 0.13
47
 
48
  CACHE_DIR = Path(__file__).resolve().parent.parent / ".cache" / "forecasts"
49
+ CACHE_TTL_SECONDS = 6 * 3600
50
+
51
+
52
+ def _seaward_offset(lat: float, lon: float) -> tuple[float, float]:
53
+ """Offset a spot's coords toward open ocean so Open-Meteo resolves a marine grid point.
54
+
55
+ Direction is inferred from position: Tasmania and the south coast shift
56
+ south, the east coast (lon >= 147) shifts east, the west coast
57
+ (lon <= 125) shifts west.
58
+ """
59
+ if lat <= -39.5: # Tasmania: open ocean to the south
60
+ dlat, dlon = -_SEAWARD_OFFSET_DEG, 0.0
61
+ elif lon >= 147: # east coast (QLD/NSW)
62
+ dlat, dlon = 0.0, _SEAWARD_OFFSET_DEG
63
+ elif lon <= 125: # west coast (WA)
64
+ dlat, dlon = 0.0, -_SEAWARD_OFFSET_DEG
65
+ else: # south coast (SA/VIC)
66
+ dlat, dlon = -_SEAWARD_OFFSET_DEG, 0.0
67
+ return dlat, dlon
68
 
69
 
70
  def _round_coords(lat: float, lon: float) -> tuple[float, float]:
71
+ dlat, dlon = _seaward_offset(lat, lon)
72
+ return round(lat + dlat, 2), round(lon + dlon, 2)
73
 
74
 
75
  def _cache_key(lat: float, lon: float, days: int) -> str:
76
+ """Cache key from the *raw* spot coords (offset is a fetch detail only)."""
77
+ return f"{round(lat, 2):.2f}_{round(lon, 2):.2f}_{days}"
78
 
79
 
80
  def _cache_path(lat: float, lon: float, days: int) -> Path:
81
  return CACHE_DIR / f"{_cache_key(lat, lon, days)}.json"
82
 
83
 
84
+ def _load_cache(lat: float, lon: float, days: int) -> tuple[dict, float] | None:
85
+ """Return ``(data, fetched_at)`` from disk, or None if absent/corrupt.
86
+
87
+ Legacy cache files written before the envelope format are returned with
88
+ ``fetched_at = 0`` so they still work as a stale fallback.
89
+ """
90
  p = _cache_path(lat, lon, days)
91
+ if not p.exists():
92
+ return None
93
+ try:
94
+ raw = json.loads(p.read_text(encoding="utf-8"))
95
+ except (json.JSONDecodeError, OSError):
96
+ return None
97
+ if isinstance(raw, dict) and "fetched_at" in raw and "data" in raw:
98
+ return raw["data"], float(raw["fetched_at"])
99
+ return raw, 0.0
100
 
101
 
102
  def _save_cache(lat: float, lon: float, days: int, data: dict) -> None:
103
+ """Write the cache envelope atomically (crash-safe write + rename)."""
104
  CACHE_DIR.mkdir(parents=True, exist_ok=True)
105
+ envelope = {"fetched_at": time.time(), "data": data}
106
+ tmp = _cache_path(lat, lon, days).with_suffix(".tmp")
107
+ tmp.write_text(json.dumps(envelope, ensure_ascii=False), encoding="utf-8")
108
+ os.replace(tmp, _cache_path(lat, lon, days))
109
 
110
 
111
  def _build_normalized(marine: dict, weather: dict) -> dict:
 
138
  return {"hourly": frames}
139
 
140
 
141
+ def _fetch_open_meteo(rlat: float, rlon: float, days: int) -> dict:
142
+ """Call both Open-Meteo endpoints and return the merged frame."""
 
 
 
 
 
 
 
 
 
143
  params: dict[str, str | int | float] = {
144
  "latitude": rlat,
145
  "longitude": rlon,
 
154
  resp_w.raise_for_status()
155
  weather = resp_w.json()
156
 
157
+ return _build_normalized(marine, weather)
 
 
158
 
159
 
160
+ def get_forecast(lat: float, lon: float, days: int = 7) -> dict:
161
+ """Fetch marine + weather hourly data for a coordinate, merged on time.
162
+
163
+ Returns a normalised frame: ``{"hourly": [{"time": ..., "wave_height": ...,
164
+ "wind_speed_10m": ..., ...}, ...]}``. Disk-cached by rounded coords + days:
165
+ entries younger than ``CACHE_TTL_SECONDS`` are served directly; on a network
166
+ failure the most recent cache entry (however old) is served as a fallback.
167
+ Raises only when there is no usable data at all.
168
+ """
169
+ rlat, rlon = _round_coords(lat, lon)
170
+ cached = _load_cache(lat, lon, days)
171
+ if cached is not None and time.time() - cached[1] < CACHE_TTL_SECONDS:
172
+ return cached[0]
173
+
174
+ try:
175
+ merged = _fetch_open_meteo(rlat, rlon, days)
176
+ except (httpx.HTTPError, KeyError, ValueError):
177
+ if cached is not None:
178
+ return cached[0]
179
+ raise
180
+
181
+ _save_cache(lat, lon, days, merged)
182
+ return merged
wavereader/scoring.py CHANGED
@@ -1,130 +1,189 @@
1
- """Deterministic surf-quality scoring.
2
 
3
- The LLM never owns numbers: every score and ranking comes from here.
4
- Produces a 0-10 quality score per hour plus a component breakdown
5
- (swell size vs ideal, swell direction match, wind speed + offshore
6
- alignment, wave period). Tide is qualitative only — Open-Meteo has no
7
- tides, so it never feeds the numeric score.
8
  """
9
 
10
  from __future__ import annotations
11
 
12
  import math
 
13
 
14
- # ---- 16-point compass → degrees (midpoint) ----
15
 
16
  _COMPASS_DEG: dict[str, float] = {
17
- "N": 0, "NNE": 22.5, "NE": 45, "ENE": 67.5,
18
- "E": 90, "ESE": 112.5, "SE": 135, "SSE": 157.5,
19
- "S": 180, "SSW": 202.5, "SW": 225, "WSW": 247.5,
20
- "W": 270, "WNW": 292.5, "NW": 315, "NNW": 337.5,
21
  }
22
 
23
- # Weights for the four components → sum to 1.0
24
  _WEIGHTS = {
25
- "swell_size": 0.25,
26
  "swell_direction": 0.20,
27
  "wind": 0.30,
28
- "period": 0.25,
29
  }
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
- def _dir_to_deg(direction: str) -> float:
33
- """Parse a cardinal direction string to degrees.
34
 
35
- Handles single compass points ('SW'), compound ranges ('W/SW'),
36
- and comma-separated values ('SE, S, E') by averaging the endpoints.
37
- """
38
- # Normalize separators: replace commas with slashes
39
- direction = direction.replace(",", "/")
40
- parts = direction.replace(" ", "").split("/")
41
- degs = [_COMPASS_DEG[p] for p in parts]
42
- return sum(degs) / len(degs)
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
- def _angular_diff(a: float, b: float) -> float:
46
- """Shortest angular distance between two bearings in degrees."""
47
- d = abs(a - b) % 360
48
- return d if d <= 180 else 360 - d
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
- def _score_swell_size(wave_height_m: float, spot: dict) -> float:
52
- """Score 0-10 based on how close wave_height_m is to the ideal range.
53
-
54
- Ideal size is stored in feet; convert to metres for comparison.
55
- Perfect score (10) inside the range, linear decay to 0 at ±30 % outside.
56
- """
57
- ideal = spot["ideal_swell"]
58
- min_m = ideal["size_ft_min"] * 0.3048
59
- max_m = ideal["size_ft_max"] * 0.3048
60
-
61
- margin = max(max_m - min_m, 0.1)
62
- low_cutoff = min_m - margin
63
- high_cutoff = max_m + margin
64
-
65
- if low_cutoff <= wave_height_m <= high_cutoff:
66
- if min_m <= wave_height_m <= max_m:
67
- return 10.0
68
- # Partial credit inside the extended range
69
- if wave_height_m < min_m:
70
- return 10.0 - 10.0 * (min_m - wave_height_m) / margin
71
- return 10.0 - 10.0 * (wave_height_m - max_m) / margin
72
-
73
- return max(
74
- 10.0 * (1.0 - (low_cutoff - wave_height_m) / margin)
75
- if wave_height_m < low_cutoff
76
- else 10.0 * (1.0 - (wave_height_m - high_cutoff) / margin),
77
- 0.0,
78
- )
79
 
80
 
81
  def _score_swell_direction(wave_dir_deg: float, spot: dict) -> float:
82
- """Score 0-10 based on angular match between wave direction and ideal.
83
-
84
- 0° difference → 10; 90° → ~3; 180° → 0.
85
- """
86
  ideal_deg = _dir_to_deg(spot["ideal_swell"]["direction"])
87
  diff = _angular_diff(wave_dir_deg, ideal_deg)
88
- return max(0.0, 10.0 - (diff / 180.0) * 10.0)
 
 
 
 
89
 
90
 
91
- def _score_wind(wind_speed_kt: float, wind_dir_deg: float, spot: dict) -> float:
92
- """Score 0-10 combining wind-speed favour and direction (offshore) alignment.
 
 
 
 
 
93
 
94
- Offshore wind (matching the ideal direction) is best.
95
- Speed score peaks at ~50 % of strength_kt_max and falls to 0 at max.
96
- Direction score follows the same angular model as swell direction.
97
  """
98
- ideal = spot["ideal_wind"]
99
- ideal_dir_deg = _dir_to_deg(ideal["direction"])
 
100
 
101
- # Speed: 10 at ≤ half max, linear to 0 at max
102
- speed_score = max(0.0, 10.0 - 10.0 * wind_speed_kt / ideal["strength_kt_max"])
103
- speed_score = min(speed_score, 10.0)
104
 
105
- # Direction: angular difference from ideal offshore direction
106
- dir_diff = _angular_diff(wind_dir_deg, ideal_dir_deg)
107
- dir_score = max(0.0, 10.0 - (dir_diff / 180.0) * 10.0)
 
 
 
108
 
109
- return speed_score * 0.5 + dir_score * 0.5
110
 
111
 
112
  def _score_period(period_s: float) -> float:
113
- """Score 0-10 for wave period.
114
 
115
- Longer periods = better quality. 8 s → ~6, 10 s → 8, 14 s+ → 10,
116
- falling off after ~16 s.
117
  """
118
- if period_s >= 14.0:
119
- return 10.0
120
  if period_s <= 4.0:
121
  return 0.0
122
- if period_s >= 10.0:
123
- # smooth ramp 10→14
124
- return 8.0 + 2.0 * (period_s - 10.0) / 4.0
125
- # ramp 4→10
126
- return 6.0 * (period_s - 4.0) / 6.0
127
 
 
128
 
129
  def score_hour(
130
  wave_height_m: float,
@@ -132,81 +191,107 @@ def score_hour(
132
  wind_speed_kt: float,
133
  wind_direction_deg: float,
134
  spot: dict,
135
- ) -> dict:
136
- """Score one hour against a spot's ideal profile.
137
-
138
- Returns a dict with the composite 0-10 ``score``, a per-component
139
- ``components`` breakdown, and the raw inputs for the UI.
140
- """
141
- c_size = _score_swell_size(wave_height_m, spot)
142
- c_dir = _score_swell_direction(wind_direction_deg, spot)
143
- c_wind = _score_wind(wind_speed_kt, wind_direction_deg, spot)
 
 
 
 
 
144
  c_period = _score_period(wave_period_s)
145
 
146
- total = (
147
- c_size * _WEIGHTS["swell_size"]
148
- + c_dir * _WEIGHTS["swell_direction"]
149
- + c_wind * _WEIGHTS["wind"]
150
- + c_period * _WEIGHTS["period"]
 
 
 
 
 
 
 
 
 
151
  )
152
 
153
  return {
154
- "score": round(total, 2),
155
- "components": {
156
- "swell_size": round(c_size, 2),
157
- "swell_direction": round(c_dir, 2),
158
- "wind": round(c_wind, 2),
159
- "period": round(c_period, 2),
160
- },
161
  "wave_height_m": wave_height_m,
162
  "wave_period_s": wave_period_s,
163
  "wind_speed_kt": wind_speed_kt,
164
  "wind_direction_deg": wind_direction_deg,
 
165
  }
166
 
167
 
168
- def score_week(forecast: dict, spot: dict) -> list[dict]:
169
- """Score every hour of a 7-day forecast for one spot.
 
 
 
 
170
 
171
- ``forecast`` is the normalised ``{"hourly": [...]}`` frame from
172
- ``forecasts.py``. Each hour row must carry ``time``,
173
- ``wave_height``, ``wave_period``, ``wind_speed_10m`` and
174
- ``wind_direction_10m``.
175
- """
176
- rows = forecast["hourly"]
177
- results: list[dict] = []
178
  for row in rows:
 
 
 
 
 
 
 
 
179
  scored = score_hour(
180
- wave_height_m=float(row["wave_height"]),
181
- wave_period_s=float(row["wave_period"]),
182
- wind_speed_kt=float(row["wind_speed_10m"]) * 0.539957, # km/h → kt
183
- wind_direction_deg=float(row["wind_direction_10m"]),
184
  spot=spot,
 
 
185
  )
186
  scored["time"] = row["time"]
187
  results.append(scored)
 
188
  return results
189
 
190
 
191
- def rank_spots_this_week(forecasts: dict, spots_list: list[dict]) -> list[dict]:
192
- """Rank spots by their best single-hour score across the week.
 
 
 
 
 
193
 
194
- ``forecasts`` maps ``(name, region)`` → normalised forecast frame.
195
- Returns entries sorted by best score descending.
196
- """
197
- ranked: list[dict] = []
198
  for spot in spots_list:
199
  key = (spot["name"], spot["region"])
200
  if key not in forecasts:
201
  continue
202
- hours = score_week(forecasts[key], spot)
203
- best = max(hours, key=lambda h: h["score"]) if hours else None
204
- ranked.append({
205
- "name": spot["name"],
206
- "region": spot["region"],
207
- "best_score": best["score"] if best else 0.0,
208
- "best_time": best["time"] if best else None,
209
- "best_hour": best,
210
- })
 
 
 
 
 
 
211
  ranked.sort(key=lambda x: x["best_score"], reverse=True)
212
  return ranked
 
1
+ """Deterministic surf-quality scoring module.
2
 
3
+ Provides non-linear, robust surf quality evaluations calibrated by surfer skill level.
4
+ Calculates component scores (0.0 to 10.0) using continuous curves and physics-aware decay.
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
  import math
10
+ from typing import TypedDict, Any, Dict, List, Tuple, Optional
11
 
12
+ # ---- 16-Point Compass Reference ----
13
 
14
  _COMPASS_DEG: dict[str, float] = {
15
+ "N": 0.0, "NNE": 22.5, "NE": 45.0, "ENE": 67.5,
16
+ "E": 90.0, "ESE": 112.5, "SE": 135.0, "SSE": 157.5,
17
+ "S": 180.0, "SSW": 202.5, "SW": 225.0, "WSW": 247.5,
18
+ "W": 270.0, "WNW": 292.5, "NW": 315.0, "NNW": 337.5,
19
  }
20
 
 
21
  _WEIGHTS = {
22
+ "swell_size": 0.30,
23
  "swell_direction": 0.20,
24
  "wind": 0.30,
25
+ "period": 0.20,
26
  }
27
 
28
+ # Skill Profile Thresholds (values in feet, knots, seconds)
29
+ _SKILL_PROFILES: dict[str, dict[str, Any]] = {
30
+ "beginner": {
31
+ "comfort_size_min_ft": 1.0,
32
+ "comfort_size_max_ft": 3.5,
33
+ "max_safe_size_ft": 4.5,
34
+ "max_wind_kt": 12.0,
35
+ "ideal_period_max_s": 12.0,
36
+ },
37
+ "intermediate": {
38
+ "comfort_size_min_ft": 2.0,
39
+ "comfort_size_max_ft": 6.5,
40
+ "max_safe_size_ft": 8.5,
41
+ "max_wind_kt": 18.0,
42
+ "ideal_period_max_s": 16.0,
43
+ },
44
+ "advanced": {
45
+ "comfort_size_min_ft": 3.0,
46
+ "comfort_size_max_ft": 12.0,
47
+ "max_safe_size_ft": 18.0,
48
+ "max_wind_kt": 25.0,
49
+ "ideal_period_max_s": 22.0,
50
+ },
51
+ "expert": {
52
+ "comfort_size_min_ft": 4.0,
53
+ "comfort_size_max_ft": 30.0,
54
+ "max_safe_size_ft": 50.0,
55
+ "max_wind_kt": 35.0,
56
+ "ideal_period_max_s": 25.0,
57
+ },
58
+ }
59
 
60
+ SKILL_LEVELS = tuple(_SKILL_PROFILES.keys())
61
+ _DEFAULT_SKILL_LEVEL = "intermediate"
62
 
63
+ # ---- Helpers ----
 
 
 
 
 
 
 
64
 
65
+ def _dir_to_deg(direction: str) -> float:
66
+ """Parses cardinal strings, slash-separated, or 'to' directions to degrees."""
67
+ normalized = direction.replace(",", "/").replace(" to ", "/").replace(" ", "").upper()
68
+ parts = normalized.split("/")
69
+ degs = [_COMPASS_DEG[p] for p in parts if p in _COMPASS_DEG]
70
+ if not degs:
71
+ raise ValueError(f"Invalid compass direction input: {direction!r}")
72
+
73
+ # Cyclic mean for angles
74
+ sin_sum = sum(math.sin(math.radians(d)) for d in degs)
75
+ cos_sum = sum(math.cos(math.radians(d)) for d in degs)
76
+ mean_deg = math.degrees(math.atan2(sin_sum, cos_sum)) % 360
77
+ return round(mean_deg, 1)
78
 
 
 
 
 
79
 
80
+ def _angular_diff(a: float, b: float) -> float:
81
+ """Calculates shortest arc distance between two bearings in degrees."""
82
+ d = abs(a - b) % 360.0
83
+ return d if d <= 180.0 else 360.0 - d
84
+
85
+
86
+ def _gaussian_decay(value: float, target: float, sigma: float) -> float:
87
+ """Smooth Gaussian decay yielding 1.0 at target and trailing to 0.0."""
88
+ return math.exp(-((value - target) ** 2) / (2 * (sigma ** 2)))
89
+
90
+
91
+ # ---- Core Scoring Components ----
92
+
93
+ def _score_swell_size(wave_height_m: float, spot: dict, skill_level: str) -> float:
94
+ """Evaluates swell size considering spot ideals and surfer safety constraints."""
95
+ profile = _SKILL_PROFILES[skill_level]
96
+ wave_height_ft = wave_height_m * 3.28084
97
+
98
+ spot_ideal = spot["ideal_swell"]
99
+ spot_min_ft = float(spot_ideal["size_ft_min"])
100
+ spot_max_ft = float(spot_ideal["size_ft_max"])
101
+
102
+ # 1. Spot Suitability (Does the spot work?)
103
+ if spot_min_ft <= wave_height_ft <= spot_max_ft:
104
+ spot_score = 10.0
105
+ elif wave_height_ft < spot_min_ft:
106
+ sigma = max(1.0, spot_min_ft * 0.4)
107
+ spot_score = 10.0 * _gaussian_decay(wave_height_ft, spot_min_ft, sigma)
108
+ else:
109
+ sigma = max(1.5, spot_max_ft * 0.5)
110
+ spot_score = 10.0 * _gaussian_decay(wave_height_ft, spot_max_ft, sigma)
111
+
112
+ # 2. Safety & Comfort Filter
113
+ comf_min = profile["comfort_size_min_ft"]
114
+ comf_max = profile["comfort_size_max_ft"]
115
+ max_safe = profile["max_safe_size_ft"]
116
+
117
+ if wave_height_ft > max_safe:
118
+ # Zero tolerance for dangerous wave heights relative to skill
119
+ return 0.0
120
+
121
+ skill_mult = 1.0
122
+ if wave_height_ft < comf_min:
123
+ skill_mult = max(0.2, wave_height_ft / comf_min)
124
+ elif wave_height_ft > comf_max:
125
+ # Smooth penalty down to zero at max_safe_size
126
+ overage = wave_height_ft - comf_max
127
+ allowed_headroom = max_safe - comf_max
128
+ skill_mult = max(0.0, 1.0 - (overage / allowed_headroom) ** 1.5)
129
 
130
+ return round(spot_score * skill_mult, 2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
 
133
  def _score_swell_direction(wave_dir_deg: float, spot: dict) -> float:
134
+ """Calculates directional match relative to optimal spot window."""
 
 
 
135
  ideal_deg = _dir_to_deg(spot["ideal_swell"]["direction"])
136
  diff = _angular_diff(wave_dir_deg, ideal_deg)
137
+
138
+ # Half-width tolerance angle before severe drop-off (e.g., 45 degrees)
139
+ tolerance_deg = 45.0
140
+ score = 10.0 * _gaussian_decay(diff, 0.0, tolerance_deg / 1.5)
141
+ return max(0.0, min(10.0, score))
142
 
143
 
144
+ def _score_wind(
145
+ wind_speed_kt: float,
146
+ wind_dir_deg: float,
147
+ spot: dict,
148
+ skill_level: str = _DEFAULT_SKILL_LEVEL,
149
+ ) -> float:
150
+ """Scores wind as the mean of a speed score and a direction score.
151
 
152
+ Speed: glassy (<= 5 kt) is always perfect; above the tolerable limit
153
+ (the spot's ``strength_kt_max``, capped by the skill profile) it scores 0.
154
+ Direction: cosine falloff from perfect offshore to absolute onshore.
155
  """
156
+ profile_max = _SKILL_PROFILES[skill_level]["max_wind_kt"]
157
+ spot_max = float(spot["ideal_wind"]["strength_kt_max"])
158
+ limit = min(spot_max, profile_max)
159
 
160
+ offshore_dir_deg = _dir_to_deg(spot["ideal_wind"]["direction"])
161
+ diff = _angular_diff(wind_dir_deg, offshore_dir_deg)
162
+ dir_score = 10.0 * max(0.0, math.cos(math.radians(diff / 2.0)))
163
 
164
+ if wind_speed_kt <= 5.0:
165
+ speed_score = 10.0 # glassy regardless of direction
166
+ elif wind_speed_kt <= limit:
167
+ speed_score = 10.0
168
+ else:
169
+ speed_score = 0.0
170
 
171
+ return round((speed_score + dir_score) / 2.0, 2)
172
 
173
 
174
  def _score_period(period_s: float) -> float:
175
+ """Evaluates swell period quality: 0 below 4 s, 10 from 14 s up.
176
 
177
+ Long groundswell is never penalised — there is no falloff above 14 s.
 
178
  """
 
 
179
  if period_s <= 4.0:
180
  return 0.0
181
+ if period_s >= 14.0:
182
+ return 10.0
183
+ return round(10.0 * (period_s - 4.0) / 10.0, 2)
184
+
 
185
 
186
+ # ---- Main Interface Functions ----
187
 
188
  def score_hour(
189
  wave_height_m: float,
 
191
  wind_speed_kt: float,
192
  wind_direction_deg: float,
193
  spot: dict,
194
+ wave_direction_deg: Optional[float] = None,
195
+ skill_level: str = _DEFAULT_SKILL_LEVEL,
196
+ ) -> dict[str, Any]:
197
+ """Computes composite surf score (0-10) for a given hour and skill tier."""
198
+ if skill_level not in _SKILL_PROFILES:
199
+ raise ValueError(f"Invalid skill_level {skill_level!r}. Expected one of {SKILL_LEVELS}")
200
+
201
+ c_size = _score_swell_size(wave_height_m, spot, skill_level)
202
+ c_dir = (
203
+ _score_swell_direction(wave_direction_deg, spot)
204
+ if wave_direction_deg is not None
205
+ else None
206
+ )
207
+ c_wind = _score_wind(wind_speed_kt, wind_direction_deg, spot, skill_level)
208
  c_period = _score_period(wave_period_s)
209
 
210
+ components: dict[str, Optional[float]] = {
211
+ "swell_size": round(c_size, 2),
212
+ "swell_direction": round(c_dir, 2) if c_dir is not None else None,
213
+ "wind": round(c_wind, 2),
214
+ "period": round(c_period, 2),
215
+ }
216
+
217
+ # Weight redistribution when optional components are missing
218
+ active_weights = {k: v for k, v in _WEIGHTS.items() if components[k] is not None}
219
+ weight_sum = sum(active_weights.values())
220
+
221
+ composite = sum(
222
+ components[k] * (weight / weight_sum) # type: ignore
223
+ for k, weight in active_weights.items()
224
  )
225
 
226
  return {
227
+ "score": round(composite, 2),
228
+ "components": components,
229
+ "skill_level": skill_level,
 
 
 
 
230
  "wave_height_m": wave_height_m,
231
  "wave_period_s": wave_period_s,
232
  "wind_speed_kt": wind_speed_kt,
233
  "wind_direction_deg": wind_direction_deg,
234
+ "wave_direction_deg": wave_direction_deg,
235
  }
236
 
237
 
238
+ def score_week(
239
+ forecast: dict, spot: dict, skill_level: str = _DEFAULT_SKILL_LEVEL
240
+ ) -> list[dict[str, Any]]:
241
+ """Evaluates every hour in a forecast payload for a target spot."""
242
+ rows = forecast.get("hourly", [])
243
+ results = []
244
 
 
 
 
 
 
 
 
245
  for row in rows:
246
+ wave_height = row.get("wave_height")
247
+ wave_period = row.get("wave_period")
248
+ wind_speed = row.get("wind_speed_10m")
249
+ wind_dir = row.get("wind_direction_10m")
250
+
251
+ if None in (wave_height, wave_period, wind_speed, wind_dir):
252
+ continue
253
+
254
  scored = score_hour(
255
+ wave_height_m=float(wave_height),
256
+ wave_period_s=float(wave_period),
257
+ wind_speed_kt=float(wind_speed) * 0.539957, # km/h to knots conversion
258
+ wind_direction_deg=float(wind_dir),
259
  spot=spot,
260
+ wave_direction_deg=float(row["wave_direction"]) if row.get("wave_direction") is not None else None,
261
+ skill_level=skill_level,
262
  )
263
  scored["time"] = row["time"]
264
  results.append(scored)
265
+
266
  return results
267
 
268
 
269
+ def rank_spots_this_week(
270
+ forecasts: dict[tuple[str, str], dict],
271
+ spots_list: list[dict],
272
+ skill_level: str = _DEFAULT_SKILL_LEVEL,
273
+ ) -> list[dict[str, Any]]:
274
+ """Ranks provided spots by their maximum achievable hourly score."""
275
+ ranked = []
276
 
 
 
 
 
277
  for spot in spots_list:
278
  key = (spot["name"], spot["region"])
279
  if key not in forecasts:
280
  continue
281
+
282
+ hours = score_week(forecasts[key], spot, skill_level=skill_level)
283
+ best_hour = max(hours, key=lambda h: h["score"]) if hours else None
284
+
285
+ ranked.append(
286
+ {
287
+ "name": spot["name"],
288
+ "region": spot["region"],
289
+ "skill_level": skill_level,
290
+ "best_score": best_hour["score"] if best_hour else 0.0,
291
+ "best_time": best_hour["time"] if best_hour else None,
292
+ "best_hour": best_hour,
293
+ }
294
+ )
295
+
296
  ranked.sort(key=lambda x: x["best_score"], reverse=True)
297
  return ranked
wavereader/spots.py CHANGED
@@ -52,11 +52,6 @@ class IdealSwell(BaseModel):
52
  size_ft_max: float
53
  direction: str
54
 
55
- @field_validator("size_ft_min")
56
- @classmethod
57
- def size_min_lt_max(cls, v: float) -> float:
58
- return v
59
-
60
  @model_validator(mode="after")
61
  def check_swell_sizes(self) -> "IdealSwell":
62
  if self.size_ft_min >= self.size_ft_max:
@@ -124,10 +119,6 @@ class Break(BaseModel):
124
  return v
125
 
126
 
127
- def _load_raw() -> list[dict]:
128
- return json.loads(DATA_PATH.read_text(encoding="utf-8"))
129
-
130
-
131
  def load_breaks(path: Path | str | None = None) -> list[Break]:
132
  """Load and validate ``data/break_details.json``.
133
 
 
52
  size_ft_max: float
53
  direction: str
54
 
 
 
 
 
 
55
  @model_validator(mode="after")
56
  def check_swell_sizes(self) -> "IdealSwell":
57
  if self.size_ft_min >= self.size_ft_max:
 
119
  return v
120
 
121
 
 
 
 
 
122
  def load_breaks(path: Path | str | None = None) -> list[Break]:
123
  """Load and validate ``data/break_details.json``.
124
 
wavereader/tools.py CHANGED
@@ -1,47 +1,273 @@
1
- """Agent tools: the function schemas handed to the LLM.
2
 
3
- Tools are thin wrappers over spots.py / forecasts.py / scoring.py — the
4
- model chooses them, the deterministic modules compute the values.
 
5
  """
6
 
7
- from __future__ import annotations
8
-
 
 
 
 
 
 
 
9
  from wavereader import forecasts, scoring, spots
10
 
11
- TOOL_SCHEMAS = (
12
- "get_forecast",
13
- "score_week",
14
- "find_spots",
15
- "get_spot_knowledge",
16
- "rank_spots_this_week",
17
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
 
20
  def get_forecast(spot_name: str, region: str) -> dict:
21
- """Hourly marine + wind forecast for a named spot."""
22
  s = spots.get_spot(spot_name, region)
23
  if s is None:
24
  return {"error": f"Spot '{spot_name}' ({region}) not found"}
25
  return forecasts.get_forecast(s.coordinates.lat, s.coordinates.lng)
26
 
27
 
28
- def score_week(spot_name: str, region: str) -> list[dict]:
29
- """Hour-by-hour surf scores for the next 7 days at a spot."""
30
  s = spots.get_spot(spot_name, region)
31
  if s is None:
32
- return []
33
  forecast = forecasts.get_forecast(s.coordinates.lat, s.coordinates.lng)
34
  return scoring.score_week(forecast, s.model_dump())
35
 
36
 
37
  def find_spots(query: str = "", skill: str | None = None, limit: int = 10) -> list[dict]:
38
- """Search breaks by name/region with optional skill filter."""
39
  results = spots.find_spots(query=query, skill=skill, limit=limit)
40
  return [b.model_dump() for b in results]
41
 
42
 
43
  def get_spot_knowledge(spot_name: str, region: str) -> dict:
44
- """Knowledge-base profile for a spot (ideal swell/wind/tide)."""
45
  s = spots.get_spot(spot_name, region)
46
  if s is None:
47
  return {"error": f"Spot '{spot_name}' ({region}) not found"}
@@ -49,15 +275,174 @@ def get_spot_knowledge(spot_name: str, region: str) -> dict:
49
 
50
 
51
  def rank_spots_this_week(region: str, skill: str | None = None) -> list[dict]:
52
- """Rank spots in a region by their best score this week."""
53
  spots_list = spots.find_spots(region=region, skill=skill)
54
  if not spots_list:
55
  return []
56
- forecasts_map: dict[tuple[str, str], dict] = {}
57
  for b in spots_list:
58
  d = forecasts.get_forecast(b.coordinates.lat, b.coordinates.lng)
59
  forecasts_map[(b.name, b.region)] = d
60
  return scoring.rank_spots_this_week(
61
  forecasts_map,
62
  [b.model_dump() for b in spots_list],
63
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agent tools: forecast/score/knowledge lookups plus matplotlib plot builders.
2
 
3
+ Plain functions resolve a spot by ``(name, region)`` and delegate to
4
+ ``forecasts``/``scoring``/``spots``; the smolagents ``Tool`` subclasses below
5
+ expose them to the CodeAgent. The LLM never computes numbers itself.
6
  """
7
 
8
+ import base64
9
+ import io
10
+ import matplotlib
11
+ matplotlib.use("Agg") # Use non-interactive backend for server-side rendering
12
+ import matplotlib.pyplot as plt
13
+ import numpy as np
14
+ import pandas as pd
15
+ import seaborn as sns
16
+ from smolagents import Tool
17
  from wavereader import forecasts, scoring, spots
18
 
19
+ # Configure Seaborn global styling
20
+ sns.set_theme(style="whitegrid", palette="muted")
21
+
22
+
23
+ def _encode_fig_to_base64(fig) -> str:
24
+ """Helper to convert a matplotlib figure to base64 string."""
25
+ buffer = io.BytesIO()
26
+ fig.savefig(buffer, format="png", bbox_inches="tight", dpi=150)
27
+ buffer.seek(0)
28
+ encoded = base64.b64encode(buffer.getvalue()).decode("utf-8")
29
+ plt.close(fig)
30
+ return encoded
31
+
32
+
33
+ def draw_directional_arrows(ax, x_coords, y_coords, degrees, color="black"):
34
+ """Annotate plot points with direction arrows pointing towards the direction vector."""
35
+ for x, y, deg in zip(x_coords, y_coords, degrees):
36
+ if pd.isna(deg):
37
+ continue
38
+ # Convert degrees to radians (standard meteorology: 0° N, 90° E)
39
+ rad = np.radians(90 - deg)
40
+ dx = np.cos(rad) * 0.4
41
+ dy = np.sin(rad) * 0.4
42
+ ax.annotate(
43
+ "",
44
+ xy=(x + dx, y + dy),
45
+ xytext=(x - dx, y - dy),
46
+ arrowprops=dict(
47
+ arrowstyle="->",
48
+ color=color,
49
+ lw=1.5,
50
+ mutation_scale=12,
51
+ ),
52
+ )
53
+
54
+
55
+ def plot_daily_surf_overview(spot_name: str, region: str) -> dict:
56
+ s = spots.get_spot(spot_name, region)
57
+ if s is None:
58
+ return {"error": f"Spot '{spot_name}' ({region}) not found"}
59
+
60
+ forecast = forecasts.get_forecast(s.coordinates.lat, s.coordinates.lng)
61
+ scores = scoring.score_week(forecast, s.model_dump())
62
+
63
+ df = pd.DataFrame(scores)
64
+
65
+ # Dynamic date field resolution to prevent KeyError
66
+ date_col = next((col for col in ["date", "day", "datetime", "timestamp"] if col in df.columns), None)
67
+ if date_col:
68
+ df["date_label"] = pd.to_datetime(df[date_col]).dt.strftime("%a %b %d")
69
+ else:
70
+ df["date_label"] = [f"Day {i+1}" for i in range(len(df))]
71
+
72
+ fig, axes = plt.subplots(3, 1, figsize=(10, 8), sharex=True)
73
+ fig.suptitle(
74
+ f"7-Day Surf Overview: {spot_name.title()} ({region.upper()})",
75
+ fontsize=16,
76
+ fontweight="bold",
77
+ )
78
+
79
+ # 1. Overall Daily Score
80
+ sns.barplot(
81
+ data=df,
82
+ x="date_label",
83
+ y="score",
84
+ ax=axes[0],
85
+ palette="Blues_d",
86
+ hue="date_label",
87
+ legend=False,
88
+ )
89
+ axes[0].set_ylabel("Overall Score (0-10)", fontweight="bold")
90
+ axes[0].set_ylim(0, 10)
91
+ for p in axes[0].patches:
92
+ axes[0].annotate(
93
+ f"{p.get_height():.1f}",
94
+ (p.get_x() + p.get_width() / 2.0, p.get_height()),
95
+ ha="center",
96
+ va="center",
97
+ xytext=(0, 5),
98
+ textcoords="offset points",
99
+ fontweight="bold",
100
+ )
101
+
102
+ # 2. Daily Swell Size & Direction
103
+ sns.lineplot(
104
+ data=df,
105
+ x="date_label",
106
+ y="swell_height",
107
+ ax=axes[1],
108
+ marker="o",
109
+ color="teal",
110
+ linewidth=2.5,
111
+ )
112
+ if "swell_direction" in df.columns:
113
+ draw_directional_arrows(
114
+ axes[1],
115
+ range(len(df)),
116
+ df["swell_height"],
117
+ df["swell_direction"],
118
+ color="darkslategrey",
119
+ )
120
+ axes[1].set_ylabel("Swell Height (m)", fontweight="bold")
121
+
122
+ # 3. Daily Wind Speed & Direction
123
+ sns.lineplot(
124
+ data=df,
125
+ x="date_label",
126
+ y="wind_speed",
127
+ ax=axes[2],
128
+ marker="s",
129
+ color="crimson",
130
+ linewidth=2.5,
131
+ )
132
+ if "wind_direction" in df.columns:
133
+ draw_directional_arrows(
134
+ axes[2],
135
+ range(len(df)),
136
+ df["wind_speed"],
137
+ df["wind_direction"],
138
+ color="darkred",
139
+ )
140
+ axes[2].set_ylabel("Wind Speed (kts)", fontweight="bold")
141
+ axes[2].set_xlabel("Date", fontweight="bold")
142
+
143
+ plt.xticks(rotation=15)
144
+ plt.tight_layout()
145
+ return {"image_base64": _encode_fig_to_base64(fig), "format": "png"}
146
+
147
+
148
+ def plot_hourly_surf_drilldown(
149
+ spot_name: str, region: str, date_str: str | None = None
150
+ ) -> dict:
151
+ s = spots.get_spot(spot_name, region)
152
+ if s is None:
153
+ return {"error": f"Spot '{spot_name}' ({region}) not found"}
154
+
155
+ forecast = forecasts.get_forecast(s.coordinates.lat, s.coordinates.lng)
156
+
157
+ # Safely retrieve hourly data structure
158
+ hourly_raw = forecast.get("hourly") or forecast.get("forecast", [])
159
+ df = pd.DataFrame(hourly_raw)
160
+ if df.empty:
161
+ return {"error": "No hourly forecast data available"}
162
+
163
+ # Dynamic timestamp field resolution to prevent KeyError
164
+ ts_col = next((col for col in ["timestamp", "time", "date", "datetime"] if col in df.columns), None)
165
+ if ts_col is None:
166
+ return {"error": "No valid timestamp field found in hourly data"}
167
+
168
+ df["parsed_timestamp"] = pd.to_datetime(df[ts_col])
169
+
170
+ # Target specific date or default to the first available date
171
+ if date_str:
172
+ target_date = pd.to_datetime(date_str).date()
173
+ df = df[df["parsed_timestamp"].dt.date == target_date]
174
+ else:
175
+ df = df[df["parsed_timestamp"].dt.date == df["parsed_timestamp"].dt.date.iloc[0]]
176
+
177
+ if df.empty:
178
+ return {"error": f"No data found for date {date_str}"}
179
+
180
+ df["hour_str"] = df["parsed_timestamp"].dt.strftime("%H:%M")
181
+
182
+ fig, axes = plt.subplots(3, 1, figsize=(12, 9), sharex=True)
183
+ display_date = df["parsed_timestamp"].dt.strftime("%Y-%m-%d").iloc[0]
184
+ fig.suptitle(
185
+ f"Hourly Drilldown for {spot_name.title()} ({display_date})",
186
+ fontsize=16,
187
+ fontweight="bold",
188
+ )
189
+
190
+ # 1. Hourly Scores
191
+ sns.lineplot(
192
+ data=df,
193
+ x="hour_str",
194
+ y="score",
195
+ ax=axes[0],
196
+ color="purple",
197
+ marker="o",
198
+ linewidth=2,
199
+ )
200
+ axes[0].fill_between(
201
+ df["hour_str"], df["score"], color="purple", alpha=0.15
202
+ )
203
+ axes[0].set_ylabel("Score (0-10)", fontweight="bold")
204
+ axes[0].set_ylim(0, 10)
205
+
206
+ # 2. Hourly Swell Size & Direction
207
+ sns.lineplot(
208
+ data=df,
209
+ x="hour_str",
210
+ y="swell_height",
211
+ ax=axes[1],
212
+ color="dodgerblue",
213
+ marker="o",
214
+ )
215
+ if "swell_direction" in df.columns:
216
+ draw_directional_arrows(
217
+ axes[1],
218
+ range(len(df)),
219
+ df["swell_height"],
220
+ df["swell_direction"],
221
+ color="navy",
222
+ )
223
+ axes[1].set_ylabel("Swell (m)", fontweight="bold")
224
+
225
+ # 3. Hourly Wind Speed & Direction
226
+ sns.lineplot(
227
+ data=df,
228
+ x="hour_str",
229
+ y="wind_speed",
230
+ ax=axes[2],
231
+ color="orangered",
232
+ marker="o",
233
+ )
234
+ if "wind_direction" in df.columns:
235
+ draw_directional_arrows(
236
+ axes[2],
237
+ range(len(df)),
238
+ df["wind_speed"],
239
+ df["wind_direction"],
240
+ color="darkred",
241
+ )
242
+ axes[2].set_ylabel("Wind (kts)", fontweight="bold")
243
+ axes[2].set_xlabel("Time", fontweight="bold")
244
+
245
+ plt.xticks(rotation=45)
246
+ plt.tight_layout()
247
+ return {"image_base64": _encode_fig_to_base64(fig), "format": "png"}
248
 
249
 
250
  def get_forecast(spot_name: str, region: str) -> dict:
 
251
  s = spots.get_spot(spot_name, region)
252
  if s is None:
253
  return {"error": f"Spot '{spot_name}' ({region}) not found"}
254
  return forecasts.get_forecast(s.coordinates.lat, s.coordinates.lng)
255
 
256
 
257
+ def score_week(spot_name: str, region: str) -> dict | list[dict]:
 
258
  s = spots.get_spot(spot_name, region)
259
  if s is None:
260
+ return {"error": f"Spot '{spot_name}' ({region}) not found"}
261
  forecast = forecasts.get_forecast(s.coordinates.lat, s.coordinates.lng)
262
  return scoring.score_week(forecast, s.model_dump())
263
 
264
 
265
  def find_spots(query: str = "", skill: str | None = None, limit: int = 10) -> list[dict]:
 
266
  results = spots.find_spots(query=query, skill=skill, limit=limit)
267
  return [b.model_dump() for b in results]
268
 
269
 
270
  def get_spot_knowledge(spot_name: str, region: str) -> dict:
 
271
  s = spots.get_spot(spot_name, region)
272
  if s is None:
273
  return {"error": f"Spot '{spot_name}' ({region}) not found"}
 
275
 
276
 
277
  def rank_spots_this_week(region: str, skill: str | None = None) -> list[dict]:
 
278
  spots_list = spots.find_spots(region=region, skill=skill)
279
  if not spots_list:
280
  return []
281
+ forecasts_map = {}
282
  for b in spots_list:
283
  d = forecasts.get_forecast(b.coordinates.lat, b.coordinates.lng)
284
  forecasts_map[(b.name, b.region)] = d
285
  return scoring.rank_spots_this_week(
286
  forecasts_map,
287
  [b.model_dump() for b in spots_list],
288
+ )
289
+
290
+
291
+ class GetForecastTool(Tool):
292
+ name = "get_forecast"
293
+ description = "Retrieve the wave forecast for a specific surf spot."
294
+ inputs = {
295
+ "spot_name": {
296
+ "type": "string",
297
+ "description": "Name of the surf spot.",
298
+ },
299
+ "region": {
300
+ "type": "string",
301
+ "description": "Region or state code where the spot is located (e.g., 'QLD').",
302
+ },
303
+ }
304
+ output_type = "object"
305
+
306
+ def forward(self, spot_name: str, region: str) -> dict:
307
+ return get_forecast(spot_name=spot_name, region=region)
308
+
309
+
310
+ class ScoreWeekTool(Tool):
311
+ name = "score_week"
312
+ description = "Score the conditions of a surf spot for the upcoming week based on its forecast."
313
+ inputs = {
314
+ "spot_name": {
315
+ "type": "string",
316
+ "description": "Name of the surf spot.",
317
+ },
318
+ "region": {
319
+ "type": "string",
320
+ "description": "Region or state code where the spot is located.",
321
+ },
322
+ }
323
+ output_type = "object"
324
+
325
+ def forward(self, spot_name: str, region: str) -> dict | list[dict]:
326
+ return score_week(spot_name=spot_name, region=region)
327
+
328
+
329
+ class FindSpotsTool(Tool):
330
+ name = "find_spots"
331
+ description = "Search for surf spots based on query, skill level, and limit."
332
+ inputs = {
333
+ "query": {
334
+ "type": "string",
335
+ "description": "Search query or region for surf spots. Use State code (e.g., 'QLD') or specific spot names.",
336
+ "nullable": True,
337
+ },
338
+ "skill": {
339
+ "type": "string",
340
+ "description": "Skill level (e.g., beginner, intermediate, advanced).",
341
+ "nullable": True,
342
+ },
343
+ "limit": {
344
+ "type": "integer",
345
+ "description": "Maximum number of spots to return.",
346
+ "nullable": True,
347
+ },
348
+ }
349
+ output_type = "object"
350
+
351
+ def forward(
352
+ self, query: str = "", skill: str | None = None, limit: int = 10
353
+ ) -> list[dict]:
354
+ return find_spots(query=query, skill=skill, limit=limit)
355
+
356
+
357
+ class GetSpotKnowledgeTool(Tool):
358
+ name = "get_spot_knowledge"
359
+ description = "Retrieve metadata and detailed knowledge for a specific surf spot."
360
+ inputs = {
361
+ "spot_name": {
362
+ "type": "string",
363
+ "description": "Name of the surf spot.",
364
+ },
365
+ "region": {
366
+ "type": "string",
367
+ "description": "Region or state code where the spot is located.",
368
+ },
369
+ }
370
+ output_type = "object"
371
+
372
+ def forward(self, spot_name: str, region: str) -> dict:
373
+ return get_spot_knowledge(spot_name=spot_name, region=region)
374
+
375
+
376
+ class RankSpotsThisWeekTool(Tool):
377
+ name = "rank_spots_this_week"
378
+ description = "Rank spots in a region for the upcoming week based on scored surf conditions."
379
+ inputs = {
380
+ "region": {
381
+ "type": "string",
382
+ "description": "Region or state code to rank spots in (e.g., 'QLD').",
383
+ },
384
+ "skill": {
385
+ "type": "string",
386
+ "description": "Skill level filter (e.g., beginner, intermediate, advanced).",
387
+ "nullable": True,
388
+ },
389
+ }
390
+ output_type = "object"
391
+
392
+ def forward(
393
+ self, region: str, skill: str | None = None
394
+ ) -> list[dict]:
395
+ return rank_spots_this_week(region=region, skill=skill)
396
+
397
+
398
+ class PlotDailySurfOverviewTool(Tool):
399
+ name = "plot_daily_surf_overview"
400
+ description = (
401
+ "Generates a 7-day daily overview graph for a surf spot showing score, "
402
+ "swell height/direction, and wind speed/direction. Returns a base64 encoded PNG."
403
+ )
404
+ inputs = {
405
+ "spot_name": {
406
+ "type": "string",
407
+ "description": "Name of the surf spot.",
408
+ },
409
+ "region": {
410
+ "type": "string",
411
+ "description": "Region or state code (e.g., 'QLD').",
412
+ },
413
+ }
414
+ output_type = "object"
415
+
416
+ def forward(self, spot_name: str, region: str) -> dict:
417
+ return plot_daily_surf_overview(spot_name=spot_name, region=region)
418
+
419
+
420
+ class PlotHourlySurfDrilldownTool(Tool):
421
+ name = "plot_hourly_surf_drilldown"
422
+ description = (
423
+ "Generates an hourly drilldown plot for a specific day at a surf spot showing "
424
+ "hourly score, swell height/direction, and wind speed/direction. Returns a base64 encoded PNG."
425
+ )
426
+ inputs = {
427
+ "spot_name": {
428
+ "type": "string",
429
+ "description": "Name of the surf spot.",
430
+ },
431
+ "region": {
432
+ "type": "string",
433
+ "description": "Region or state code (e.g., 'QLD').",
434
+ },
435
+ "date_str": {
436
+ "type": "string",
437
+ "description": "Date to drill down into (YYYY-MM-DD). If omitted, defaults to the first available date.",
438
+ "nullable": True,
439
+ },
440
+ }
441
+ output_type = "object"
442
+
443
+ def forward(
444
+ self, spot_name: str, region: str, date_str: str | None = None
445
+ ) -> dict:
446
+ return plot_hourly_surf_drilldown(
447
+ spot_name=spot_name, region=region, date_str=date_str
448
+ )