lucashudsn commited on
Commit
dead2da
·
1 Parent(s): 18911ed

made some cosmetic changes, and reformatted charts, create surf report generation function and tested

Browse files
Files changed (6) hide show
  1. README.md +43 -24
  2. app/generate_surf_report.py +304 -0
  3. app/surf_forecast.py +95 -20
  4. assets/wave.svg +1 -0
  5. documents/FORECAST.md +99 -39
  6. main.py +308 -143
README.md CHANGED
@@ -1,12 +1,15 @@
1
  # wave~reader
2
 
3
- Australian surf-break encyclopaedia + deterministic surf forecast (two Gradio tabs).
4
 
5
  Tab 1 (`encyclopedia`) filters spots on a Plotly `Scattermap` and shows
6
- break details. Tab 2 (`surf forecast`) fetches Open-Meteo marine + wind
7
- data for the selected break and scores every hour 0–10. The LLM never
8
- owns numbers — generation lives in `app/`, scores/forecasts stay
9
- deterministic in `wavereader/`.
 
 
 
10
 
11
  ## Layout
12
 
@@ -16,6 +19,7 @@ app/
16
  generate_surf_break.py # single-shot structured-JSON break generator
17
  generate_base_data.py # batch/resumable enrichment runner
18
  surf_forecast.py # scored-forecast service + Plotly builders (no Gradio)
 
19
  forecasts.py # Open-Meteo marine + weather client, disk cache
20
  scoring.py # deterministic 0–10 surf-quality engine
21
  adapters.py # enriched-break -> scoring-spot bridge (flat 15kt wind)
@@ -24,11 +28,11 @@ data/
24
  australia-surf-breaks-enriched.json # output: list of schema-valid breaks
25
  surf-break-schema.json # JSON Schema every break must conform to
26
  surf-break-example-bells.json # worked example (Bells Beach) used in the prompt
27
- wavereader/
28
- agent.py / tools.py # kept, agent not wired into UI yet
29
  documents/
30
  ENCYCLOPEDIA.md # encyclopedia tab deep dive
31
- FORECAST.md # forecast tab deep dive (pipeline, scoring, cache)
32
  ```
33
 
34
  ## Setup
@@ -42,9 +46,11 @@ cp .env.example .env # then fill in HF_TOKEN
42
  ```
43
 
44
  `HF_TOKEN` (Inference Providers access,
45
- https://huggingface.co/settings/tokens) is only needed for break
46
- generation (`app/`). The forecast tab needs network access to Open-Meteo,
47
- with disk cache fallback under `.cache/` (git-ignored).
 
 
48
 
49
  ## Run the front end
50
 
@@ -59,11 +65,14 @@ Two tabs share one `selected_break` state:
59
  its details table. "Can't find your local break?" generates one live
60
  via `app/generate_surf_break.py` — session-only (`gr.State`, never
61
  written to `data/`). See `documents/ENCYCLOPEDIA.md`.
62
- - **surf forecast** — pick a break on tab 1, then press **Get forecast**
63
- on tab 2. Skill defaults to the break's own `skillLevel`
64
- (`pro-only` → `expert`); days slider 1–7. Shows best-window hero,
65
- score curve, component breakdown, waves + wind charts, daily-best and
66
- hourly tables. See `documents/FORECAST.md`.
 
 
 
67
 
68
  ## Generate break data
69
 
@@ -103,14 +112,24 @@ fences / outermost `{...}` and parses. `enrich_result()` pins canonical
103
 
104
  ## Forecast pipeline (short)
105
 
106
- `main.py fetch_forecast` → `app/surf_forecast.get_scored_week`
107
- (break → `adapters.enriched_to_scoring_spot` → `forecasts.get_forecast`
108
- → `scoring.score_week`) → Plotly figs + tables. Wind tolerance is a
109
- flat `15kt` (`adapters.DEFAULT_WIND_MAX_KT`) because the schema carries
110
- no wind-strength number. Full detail in `documents/FORECAST.md`.
 
 
 
 
 
 
 
 
 
111
 
112
  ## Kept for later
113
 
114
- `wavereader/agent.py` / `wavereader/tools.py` are the agentic
115
- forecast-explanation layer. Not wired into the UI yet — the forecast
116
- tab is deterministic only by design.
 
 
1
  # wave~reader
2
 
3
+ Australian surf-break encyclopaedia + scored surf forecast (two Gradio tabs).
4
 
5
  Tab 1 (`encyclopedia`) filters spots on a Plotly `Scattermap` and shows
6
+ break details. Tab 2 (`surf forecast`) is two-stage: Stage 1 fetches
7
+ Open-Meteo marine + wind data for the selected break and scores every
8
+ hour 0–10 (deterministic); Stage 2 optionally streams a short LLM
9
+ dot-point report that narrates those scores. The LLM never owns
10
+ numbers — generation lives in `app/generate_surf_break.py` /
11
+ `app/generate_surf_report.py`, scores/forecasts stay deterministic in
12
+ `app/scoring.py` + `app/forecasts.py`.
13
 
14
  ## Layout
15
 
 
19
  generate_surf_break.py # single-shot structured-JSON break generator
20
  generate_base_data.py # batch/resumable enrichment runner
21
  surf_forecast.py # scored-forecast service + Plotly builders (no Gradio)
22
+ generate_surf_report.py # Stage-2 streamed LLM report (narrates scores only)
23
  forecasts.py # Open-Meteo marine + weather client, disk cache
24
  scoring.py # deterministic 0–10 surf-quality engine
25
  adapters.py # enriched-break -> scoring-spot bridge (flat 15kt wind)
 
28
  australia-surf-breaks-enriched.json # output: list of schema-valid breaks
29
  surf-break-schema.json # JSON Schema every break must conform to
30
  surf-break-example-bells.json # worked example (Bells Beach) used in the prompt
31
+ wavereader/ # legacy-kept, not wired into the UI
32
+ agent.py / tools.py # agentic forecast-explanation layer (kept for later)
33
  documents/
34
  ENCYCLOPEDIA.md # encyclopedia tab deep dive
35
+ FORECAST.md # forecast tab deep dive (pipeline, scoring, cache, report)
36
  ```
37
 
38
  ## Setup
 
46
  ```
47
 
48
  `HF_TOKEN` (Inference Providers access,
49
+ https://huggingface.co/settings/tokens) is needed for break generation
50
+ (`app/generate_surf_break.py`) and the Stage-2 surf report
51
+ (`app/generate_surf_report.py`). The Stage-1 forecast charts need
52
+ network access to Open-Meteo, with disk cache fallback under `.cache/`
53
+ (git-ignored).
54
 
55
  ## Run the front end
56
 
 
65
  its details table. "Can't find your local break?" generates one live
66
  via `app/generate_surf_break.py` — session-only (`gr.State`, never
67
  written to `data/`). See `documents/ENCYCLOPEDIA.md`.
68
+ - **surf forecast** — pick a break on tab 1, then press **Get forecast
69
+ (charts)** on tab 2. Skill defaults to the break's own `skillLevel`
70
+ (`pro-only` → `expert`); window is fixed at 7 days. Shows a best-window
71
+ hero, score bars (red → green, gold ★ on the best hour), swell
72
+ height + period chart, and a wind-arrows strip (colour = direction
73
+ quality, size = strength). Then optionally press **Generate surf
74
+ report ✨** for a streamed dot-point write-up (one bullet per day +
75
+ `**Recommendation: …**`). See `documents/FORECAST.md`.
76
 
77
  ## Generate break data
78
 
 
112
 
113
  ## Forecast pipeline (short)
114
 
115
+ Stage 1 (`main.py fetch_forecast` → `app/surf_forecast.get_scored_week`):
116
+ break → `adapters.enriched_to_scoring_spot` → `forecasts.get_forecast`
117
+ → `scoring.score_week` → score / swell / wind figs + `scored_payload`
118
+ (best + daily bests + scored hours). Wind tolerance is a flat `15kt`
119
+ (`adapters.DEFAULT_WIND_MAX_KT`) because the schema carries no
120
+ wind-strength number.
121
+
122
+ Stage 2 (`main.py generate_reports` →
123
+ `app/generate_surf_report.generate_surf_report_stream`): the prompt
124
+ carries only the daily bests (≤7 lines) + best window + a one-line
125
+ break summary; the model streams plain markdown (one bullet per day +
126
+ `Recommendation` line) with reasoning disabled (`/no_think` +
127
+ `reasoning_effort: "none"`). It narrates the provided scores only.
128
+ Full detail in `documents/FORECAST.md`.
129
 
130
  ## Kept for later
131
 
132
+ `wavereader/agent.py` / `wavereader/tools.py` are the legacy agentic
133
+ forecast-explanation layer. Not wired into the UI — the forecast tab
134
+ is Stage-1 deterministic plus the Stage-2 `generate_surf_report.py`
135
+ narrator by design.
app/generate_surf_report.py ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hugging Face Inference Providers + NVIDIA Nemotron 3 -- short surf report.
3
+
4
+ Same framework as ``generate_surf_break.py`` (deliberately NOT a smolagents
5
+ CodeAgent -- single-shot call via ``InferenceClient.chat.completions.create``).
6
+
7
+ Speed-first design (TTFT is dominated by prompt prefill + output length):
8
+
9
+ - The prompt carries ONLY the daily bests (<=7 lines) + the single best
10
+ window + a one-line break summary. The old version sent every scored
11
+ hour (~168 rows for 7 days) plus the full break record -- that prefill
12
+ is what made generation feel like it never started.
13
+ - Output is plain markdown, NOT JSON: one dot-point (bullet) per day
14
+ covering the day-by-day outlook (one full sentence per day), plus a final
15
+ line naming the single best day/window to surf. Short output
16
+ (~350 tokens) with ``max_tokens=800``.
17
+ - Plain text also streams cleanly: what the user watches arrive IS the
18
+ report -- no JSON escape decoding or partial-parse step.
19
+
20
+ The LLM never owns numbers -- scores/forecasts stay deterministic in
21
+ ``app/scoring.py`` + ``app/forecasts.py``. The prompt instructs the model to
22
+ narrate only the provided numbers, never invent swell/wind/score values.
23
+
24
+ Requires ``HF_TOKEN`` env (Inference Providers access).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import re
31
+ import sys
32
+
33
+ from huggingface_hub import InferenceClient
34
+
35
+ MODEL_ID = "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-BF16"
36
+ PROVIDER = "deepinfra"
37
+
38
+ MAX_TOKENS = 800
39
+ TEMPERATURE = 0.3
40
+
41
+ # Reasoning runs in a separate channel, not in the visible text:
42
+ # - DeepInfra honors ``reasoning_effort: "none"`` (disables chain-of-thought
43
+ # entirely: faster, cheaper, direct answer). Passed via ``extra_body``.
44
+ # - Nemotron itself honors the ``/no_think`` system-prompt directive.
45
+ # Without these, the model can spend the whole token budget "thinking" and
46
+ # leave nothing for the report -- which is exactly the blank output seen
47
+ # in the UI. Marker/cleaning logic below stays as a safety net.
48
+ SYSTEM_PROMPT = "/no_think\nWrite only the final surf summary. Never reveal thinking, reasoning, or planning."
49
+ EXTRA_BODY = {"reasoning_effort": "none"}
50
+
51
+
52
+ def _day_line(d: dict) -> str:
53
+ """One daily best as a single compact line for the prompt."""
54
+ d = d or {}
55
+ # best_window() returns a raw scored row with no 'date' key -- derive
56
+ # it from the timestamp so the model never sees a "None" date (which
57
+ # visibly confused it into reasoning out loud about what "None" means).
58
+ date = d.get("date") or str(d.get("time") or "")[:10] or "?"
59
+ return (
60
+ f"{date} {d.get('time')} | score={d.get('score')}/10 | "
61
+ f"wave={d.get('wave_height_m')}m@{d.get('wave_period_s')}s | "
62
+ f"wind={d.get('wind_speed_kt')}kt@{d.get('wind_direction_deg')}deg"
63
+ )
64
+
65
+
66
+ def _break_line(break_: dict) -> str:
67
+ """One-line break context: ideals + hazards only, truncated."""
68
+ swell = (break_ or {}).get("idealSwell") or {}
69
+ wind = (break_ or {}).get("idealWind") or {}
70
+ size = swell.get("sizeRangeFt") or {}
71
+ hazards = (break_ or {}).get("hazards") or []
72
+ if isinstance(hazards, list):
73
+ hazards = ", ".join(str(h) for h in hazards[:3])
74
+ return (
75
+ f"{break_.get('name')}, {break_.get('region')}, {break_.get('state')} | "
76
+ f"ideal swell {swell.get('direction')} {size.get('min')}-{size.get('max')}ft | "
77
+ f"ideal wind {wind.get('direction')} | hazards: {hazards}"
78
+ )
79
+
80
+
81
+ def build_surf_report_prompt(
82
+ break_: dict,
83
+ skill: str,
84
+ daily: list[dict],
85
+ best: dict | None,
86
+ days: int,
87
+ ) -> str:
88
+ """Assemble the tiny report prompt: daily bests + best window only."""
89
+ daily_block = "\n".join(_day_line(d) for d in (daily or []))
90
+ best_block = _day_line(best) if best else "none"
91
+
92
+ return f"""You are a surf report writer. You narrate deterministic forecast scores -- you never invent numbers.
93
+
94
+ Rules:
95
+ 1. Wrap your ENTIRE response between these markers, on their own lines, with NOTHING outside them -- no intro, no thinking, no notes, no commentary:
96
+ @@REPORT@@
97
+ <your dot-point report, then recommendation line>
98
+ @@END@@
99
+ 2. NEVER show your thinking, reasoning, planning, or notes to yourself. Do NOT use <think> tags or any reasoning preamble -- the first thing you emit is @@REPORT@@, the last thing is @@END@@.
100
+ 3. Use ONLY the numbers/times below. Never invent wave heights, periods, wind speeds, directions, or scores.
101
+ 4. Write one dot-point (markdown bullet starting with "- ") per day below, in date order, for a {skill} surfer. Each bullet names the date with its score, wave height/period and wind, followed by one full sentence explaining the outlook in plain language (e.g. what the conditions will feel like and whether it suits a {skill} surfer). If a day scores poorly, say so plainly in a full sentence. One bullet per day, no extra bullets.
102
+ 5. After the bullets, add a blank line then exactly one line: **Recommendation: <date + time> -- <one short reason>.** naming the single best day/window to surf.
103
+
104
+ Break: {_break_line(break_ or {})}
105
+ Skill: {skill} | Window: {days} day(s)
106
+
107
+ Best window: {best_block}
108
+
109
+ Daily bests:
110
+ {daily_block}
111
+
112
+ Report:"""
113
+
114
+
115
+ def _strip_fences(text: str) -> str:
116
+ """Remove stray code fences if the model added them anyway."""
117
+ text = (text or "").strip()
118
+ fence_match = re.search(r"```(?:\w+)?\s*(.*?)\s*```", text, re.DOTALL)
119
+ if fence_match:
120
+ return fence_match.group(1).strip()
121
+ return text
122
+
123
+
124
+ _THINK_RE = re.compile(
125
+ r"<\s*(think|thinking|reason|reasoning)[^>]*>.*?(<\s*/\s*\1\s*>|$)",
126
+ re.DOTALL | re.IGNORECASE,
127
+ )
128
+ _STRAY_TAG_RE = re.compile(
129
+ r"<\s*/?\s*(think|thinking|reason|reasoning)[^>]*>", re.IGNORECASE
130
+ )
131
+
132
+
133
+ _REPORT_START = "@@REPORT@@"
134
+ _REPORT_END = "@@END@@"
135
+
136
+ # Sentences containing these phrases (case-insensitive) are model
137
+ # planning/thinking leaked as plain text (no <think> tags), e.g. "We need
138
+ # to produce dot-points...". Kept narrow on purpose: ordinary surf outlook
139
+ # words ("could be", "probably", "should be") are legit report language
140
+ # and must never be filtered.
141
+ _META_PHRASES = (
142
+ "we need to",
143
+ "i need to",
144
+ "need to produce",
145
+ "need to output",
146
+ "must end with",
147
+ "must include",
148
+ "must name",
149
+ "the best window given",
150
+ "requirement",
151
+ "need to ",
152
+ "plan:",
153
+ "thinking:",
154
+ "reasoning:",
155
+ )
156
+
157
+ _SENT_SPLIT_RE = re.compile(r"(?<=[.!?])\s+|\n+")
158
+
159
+
160
+ def _drop_thinking_sentences(text: str) -> str:
161
+ """Remove plain-text planning sentences; return "" if nothing remains.
162
+
163
+ Joins with newlines when the text is a dot-point list so bullets
164
+ survive the fallback path; otherwise joins with spaces.
165
+ """
166
+ parts = [p for p in _SENT_SPLIT_RE.split(text or "") if p.strip()]
167
+ kept = [
168
+ p for p in parts if not any(m in p.lower() for m in _META_PHRASES)
169
+ ]
170
+ if any(p.lstrip().startswith(("-", "*")) for p in kept):
171
+ return "\n".join(kept).strip()
172
+ return " ".join(kept).strip()
173
+
174
+
175
+ def _clean_output(text: str) -> str:
176
+ """Strip reasoning traces + markers + fences so only the summary remains.
177
+
178
+ Three layers, in order:
179
+
180
+ 1. ``<think>...</think>`` blocks (complete or unclosed mid-stream).
181
+ 2. ``@@REPORT@@ ... @@END@@`` markers: everything outside them is
182
+ discarded. Mid-stream (start seen, end not yet) returns whatever
183
+ follows the start marker; pre-marker thinking yields "" so the UI
184
+ shows its streaming placeholder instead of the model's planning.
185
+ 3. Fallback when the model ignores the markers: drop plain-text
186
+ planning sentences ("We need to produce...", "Probably best
187
+ is..."). Returns "" when only thinking has arrived so far.
188
+ """
189
+ text = text or ""
190
+ # Remove complete <think>...</think> blocks, and a trailing unclosed
191
+ # <think>... block (common mid-stream).
192
+ text = _THINK_RE.sub("", text)
193
+ text = _STRAY_TAG_RE.sub("", text)
194
+ if _REPORT_START in text:
195
+ after = text.split(_REPORT_START, 1)[1]
196
+ if _REPORT_END in after:
197
+ after = after.split(_REPORT_END, 1)[0]
198
+ return _strip_fences(after).strip()
199
+ cleaned = _strip_fences(text).strip()
200
+ if not cleaned:
201
+ return ""
202
+ filtered = _drop_thinking_sentences(cleaned)
203
+ return filtered
204
+
205
+
206
+ def generate_surf_report(
207
+ break_: dict,
208
+ skill: str,
209
+ daily: list[dict],
210
+ best: dict | None,
211
+ days: int,
212
+ model_id: str = MODEL_ID,
213
+ provider: str = PROVIDER,
214
+ max_tokens: int = MAX_TOKENS,
215
+ temperature: float = TEMPERATURE,
216
+ ) -> str:
217
+ """Generate the short plain-markdown report in one LLM call."""
218
+ client = InferenceClient(provider=provider, api_key=os.environ.get("HF_TOKEN"))
219
+
220
+ prompt = build_surf_report_prompt(
221
+ break_=break_, skill=skill, daily=daily, best=best, days=days
222
+ )
223
+
224
+ completion = client.chat.completions.create(
225
+ model=model_id,
226
+ messages=[
227
+ {"role": "system", "content": SYSTEM_PROMPT},
228
+ {"role": "user", "content": prompt},
229
+ ],
230
+ max_tokens=max_tokens,
231
+ temperature=temperature,
232
+ extra_body=EXTRA_BODY,
233
+ )
234
+
235
+ return _clean_output(completion.choices[0].message.content)
236
+
237
+
238
+ def generate_surf_report_stream(
239
+ break_: dict,
240
+ skill: str,
241
+ daily: list[dict],
242
+ best: dict | None,
243
+ days: int,
244
+ model_id: str = MODEL_ID,
245
+ provider: str = PROVIDER,
246
+ max_tokens: int = MAX_TOKENS,
247
+ temperature: float = TEMPERATURE,
248
+ stats: dict | None = None,
249
+ ):
250
+ """Stream the short report token-by-token.
251
+
252
+ Yields the accumulated plain-text report after every delta, so the
253
+ Gradio handler displays exactly what the user will read -- no
254
+ post-parse step needed. Reasoning is disabled at the API level
255
+ (``reasoning_effort: "none"`` + ``/no_think``), so ``delta.content``
256
+ carries the final answer; anything thinking-shaped that still slips
257
+ through is stripped live. If ``stats`` is given, it is filled with
258
+ ``reasoning_chars`` (hidden reasoning-channel text, never displayed)
259
+ and ``content_chars`` so callers can prove the visible text is the
260
+ final report, not the thinking stage.
261
+ """
262
+ client = InferenceClient(provider=provider, api_key=os.environ.get("HF_TOKEN"))
263
+
264
+ prompt = build_surf_report_prompt(
265
+ break_=break_, skill=skill, daily=daily, best=best, days=days
266
+ )
267
+
268
+ stream = client.chat.completions.create(
269
+ model=model_id,
270
+ messages=[
271
+ {"role": "system", "content": SYSTEM_PROMPT},
272
+ {"role": "user", "content": prompt},
273
+ ],
274
+ max_tokens=max_tokens,
275
+ temperature=temperature,
276
+ stream=True,
277
+ extra_body=EXTRA_BODY,
278
+ )
279
+
280
+ accumulated = ""
281
+ reasoning_chars = 0
282
+ for chunk in stream:
283
+ try:
284
+ delta = chunk.choices[0].delta
285
+ except (AttributeError, IndexError):
286
+ delta = None
287
+ content = getattr(delta, "content", None) if delta is not None else None
288
+ # Reasoning channel, when present, stays hidden -- counted only.
289
+ reasoning = getattr(delta, "reasoning_content", None) if delta is not None else None
290
+ if reasoning:
291
+ reasoning_chars += len(reasoning)
292
+ if content:
293
+ accumulated += content
294
+ yield _clean_output(accumulated)
295
+ if stats is not None:
296
+ stats["reasoning_chars"] = reasoning_chars
297
+ stats["content_chars"] = len(accumulated)
298
+ if accumulated:
299
+ yield _clean_output(accumulated)
300
+
301
+
302
+ if __name__ == "__main__":
303
+ print("Usage: import generate_surf_report() from main.py -- no standalone CLI.")
304
+ sys.exit(0)
app/surf_forecast.py CHANGED
@@ -8,6 +8,8 @@ from __future__ import annotations
8
 
9
  from typing import Any, Optional
10
 
 
 
11
  import plotly.graph_objects as go
12
 
13
  from app.adapters import break_skill, enriched_to_scoring_spot, get_coords, normalize_skill
@@ -158,6 +160,65 @@ def _score_color(score: float) -> str:
158
  _ARROWS_8 = ["↑", "↗", "→", "↘", "↓", "↙", "←", "↖"]
159
  _COMPASS_8 = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
 
162
  def _deg_to_compass(deg: float) -> str:
163
  """8-point compass label for a meteorological degree."""
@@ -275,45 +336,59 @@ def build_waves_fig(scored: list[dict]) -> go.Figure:
275
  return _strip_layout(fig, height=230, hide_x=True)
276
 
277
 
278
- def build_wind_fig(scored: list[dict]) -> go.Figure:
279
- """Wind speed line + arrow markers showing blow-to direction."""
 
 
 
 
 
 
 
 
280
  if not scored:
281
  return _empty_fig()
282
  times = [r.get("time") for r in scored]
283
  speeds = [r.get("wind_speed_kt") for r in scored]
284
  dirs = [r.get("wind_direction_deg") for r in scored]
 
 
 
285
  fig = go.Figure()
286
- fig.add_trace(
287
- go.Scatter(
288
- x=times,
289
- y=speeds,
290
- mode="lines",
291
- fill="tozeroy",
292
- fillcolor="rgba(44,160,44,0.18)",
293
- line={"color": "#2ca02c", "width": 2},
294
- hovertemplate="%{x}<br>%{y:.0f} kt %{customdata}<extra></extra>",
295
- customdata=[f"from {_deg_to_compass(d)} ({float(d):.0f}°)" for d in dirs],
296
- name="speed (kt)",
297
- )
298
- )
299
  # Subsample arrows so the strip stays readable (~1 per 6 h on a 7-day view).
300
  step = max(1, len(times) // 28)
301
  idx = list(range(0, len(times), step))
 
 
302
  fig.add_trace(
303
  go.Scatter(
304
  x=[times[i] for i in idx],
305
- y=[speeds[i] for i in idx],
306
  mode="text",
307
  text=[_deg_to_arrow_from(dirs[i]) for i in idx],
308
- textfont={"size": 17, "color": "#0b2c5c"},
 
 
 
 
309
  hovertemplate="%{x}<br>%{customdata}<extra></extra>",
310
  customdata=[
311
  f"{float(speeds[i]):.0f} kt from {_deg_to_compass(dirs[i])} "
312
  f"({float(dirs[i]):.0f}°) — arrow points where it blows to"
313
  for i in idx
314
  ],
315
- name="direction →",
 
316
  )
317
  )
318
- fig.update_layout(title="Wind (arrows point where wind blows to)", yaxis={"title": "kt"})
319
- return _strip_layout(fig, height=230, hide_x=False)
 
 
 
 
 
 
 
 
 
 
8
 
9
  from typing import Any, Optional
10
 
11
+ import math
12
+
13
  import plotly.graph_objects as go
14
 
15
  from app.adapters import break_skill, enriched_to_scoring_spot, get_coords, normalize_skill
 
160
  _ARROWS_8 = ["↑", "↗", "→", "↘", "↓", "↙", "←", "↖"]
161
  _COMPASS_8 = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
162
 
163
+ _COMPASS_DEG_16 = {
164
+ "N": 0.0, "NNE": 22.5, "NE": 45.0, "ENE": 67.5,
165
+ "E": 90.0, "ESE": 112.5, "SE": 135.0, "SSE": 157.5,
166
+ "S": 180.0, "SSW": 202.5, "SW": 225.0, "WSW": 247.5,
167
+ "W": 270.0, "WNW": 292.5, "NW": 315.0, "NNW": 337.5,
168
+ }
169
+
170
+ # Arrow colours: direction quality only (size encodes strength).
171
+ _WIND_GOOD = "#1a9850" # green — offshore / good direction
172
+ _WIND_CROSS = "#eab308" # yellow — cross-shore
173
+ _WIND_BAD = "#d73027" # red — onshore
174
+
175
+
176
+ def _parse_dir_to_deg(direction: Any) -> float | None:
177
+ """Parse 'N', 'NE/SW', 'N to SW' style bearings to degrees (cyclic mean)."""
178
+ if direction is None:
179
+ return None
180
+ if isinstance(direction, (int, float)):
181
+ try:
182
+ return float(direction) % 360.0
183
+ except (TypeError, ValueError):
184
+ return None
185
+ text = str(direction).replace(",", "/").replace(" to ", "/").replace(" ", "").upper()
186
+ parts = [p for p in text.split("/") if p in _COMPASS_DEG_16]
187
+ if not parts:
188
+ return None
189
+ sin_sum = sum(math.sin(math.radians(_COMPASS_DEG_16[p])) for p in parts)
190
+ cos_sum = sum(math.cos(math.radians(_COMPASS_DEG_16[p])) for p in parts)
191
+ return math.degrees(math.atan2(sin_sum, cos_sum)) % 360.0
192
+
193
+
194
+ def _angular_diff(a: float, b: float) -> float:
195
+ d = abs(float(a) - float(b)) % 360.0
196
+ return d if d <= 180.0 else 360.0 - d
197
+
198
+
199
+ def _wind_quality_color(wind_from_deg: float, offshore_from_deg: float | None) -> str:
200
+ """Green (offshore) ≤45°, yellow (cross) ≤135°, else red (onshore)."""
201
+ if offshore_from_deg is None:
202
+ return "#0b2c5c"
203
+ try:
204
+ diff = _angular_diff(float(wind_from_deg), float(offshore_from_deg))
205
+ except (TypeError, ValueError):
206
+ return "#0b2c5c"
207
+ if diff <= 45.0:
208
+ return _WIND_GOOD
209
+ if diff <= 135.0:
210
+ return _WIND_CROSS
211
+ return _WIND_BAD
212
+
213
+
214
+ def _wind_arrow_size(speed_kt: Any) -> float:
215
+ """Arrow size encodes strength: ~22 pt when light → ~33 pt when strong (1–1.5x)."""
216
+ try:
217
+ s = max(0.0, float(speed_kt))
218
+ except (TypeError, ValueError):
219
+ return 22.0
220
+ return 22.0 + 11.0 * min(s, 30.0) / 30.0
221
+
222
 
223
  def _deg_to_compass(deg: float) -> str:
224
  """8-point compass label for a meteorological degree."""
 
336
  return _strip_layout(fig, height=230, hide_x=True)
337
 
338
 
339
+ def build_wind_fig(scored: list[dict], spot: dict | None = None) -> go.Figure:
340
+ """Wind as colored arrows only: colour = direction quality, size = strength.
341
+
342
+ Green = offshore/good (≤45° from the spot's ideal), yellow = cross-shore,
343
+ red = onshore (>135°). Arrow size scales 22→33 pt (1–1.5x) with wind
344
+ speed in kt; heavy font weight keeps them thick. Single row, no y-axis —
345
+ strength reads from size only, exact kt on hover. Arrows point where the
346
+ wind blows TO. ``spot`` is the scoring spot (``ideal_wind.direction``);
347
+ without it all arrows fall back to dark blue.
348
+ """
349
  if not scored:
350
  return _empty_fig()
351
  times = [r.get("time") for r in scored]
352
  speeds = [r.get("wind_speed_kt") for r in scored]
353
  dirs = [r.get("wind_direction_deg") for r in scored]
354
+ offshore_deg: float | None = None
355
+ if isinstance(spot, dict):
356
+ offshore_deg = _parse_dir_to_deg((spot.get("ideal_wind") or {}).get("direction"))
357
  fig = go.Figure()
 
 
 
 
 
 
 
 
 
 
 
 
 
358
  # Subsample arrows so the strip stays readable (~1 per 6 h on a 7-day view).
359
  step = max(1, len(times) // 28)
360
  idx = list(range(0, len(times), step))
361
+ colors = [_wind_quality_color(dirs[i], offshore_deg) for i in idx]
362
+ sizes = [_wind_arrow_size(speeds[i]) for i in idx]
363
  fig.add_trace(
364
  go.Scatter(
365
  x=[times[i] for i in idx],
366
+ y=[1.0] * len(idx),
367
  mode="text",
368
  text=[_deg_to_arrow_from(dirs[i]) for i in idx],
369
+ textfont={
370
+ "size": sizes,
371
+ "color": colors,
372
+ "family": "Arial Black, Arial, sans-serif",
373
+ },
374
  hovertemplate="%{x}<br>%{customdata}<extra></extra>",
375
  customdata=[
376
  f"{float(speeds[i]):.0f} kt from {_deg_to_compass(dirs[i])} "
377
  f"({float(dirs[i]):.0f}°) — arrow points where it blows to"
378
  for i in idx
379
  ],
380
+ name="wind",
381
+ showlegend=False,
382
  )
383
  )
384
+ # No legend — the title already carries the colour meaning, and the
385
+ # overlay legend was blocking arrows.
386
+ fig.update_layout(
387
+ title="Wind (green=offshore, yellow=cross, red=onshore; size=strength)",
388
+ yaxis={"visible": False, "showticklabels": False, "range": [0.5, 1.5]},
389
+ showlegend=False,
390
+ )
391
+ fig = _strip_layout(fig, height=170, hide_x=False)
392
+ fig.update_yaxes(visible=False, showticklabels=False, showgrid=False, zeroline=False)
393
+ fig.update_layout(showlegend=False)
394
+ return fig
assets/wave.svg ADDED
documents/FORECAST.md CHANGED
@@ -1,28 +1,41 @@
1
  # Surf forecast tab
2
 
3
  The `surf forecast` tab in `main.py:build_demo()` scores the break
4
- selected on the `encyclopedia` tab. Fully deterministic: Open-Meteo
5
- numbers in, `scoring.py` numbers out. No agent/LLM in this path.
 
 
 
 
 
6
 
7
  ## Pipeline
8
 
9
  ```
10
  pick break (tab 1, break_dd -> selected_break gr.State)
11
- -> press "Get forecast" (tab 2, fetch_btn)
12
  -> fetch_forecast() [main.py]
13
- -> _load_forecast() [importlib, app/surf_forecast.py, no package import]
14
- -> get_scored_week(break, skill, days)
15
  -> adapters.enriched_to_scoring_spot(break) # schema -> scoring shape
16
  -> forecasts.get_forecast(lat, lng, days) # marine + wind, cached
17
  -> scoring.score_week(forecast, spot, skill) # 0-10 per hour
18
- -> best_window / daily_best / build_*_fig + tables
 
 
 
 
 
19
  ```
20
 
21
- `fetch_forecast` shows `gr.Progress` steps and writes human-readable
22
- status into `status_box`; any exception surfaces there with empty figs
23
- (no traceback in UI).
 
 
 
24
 
25
- ## Adapter (`wavereader/adapters.py`)
26
 
27
  `scoring.py` expects the old spot shape; the enriched schema differs,
28
  so the adapter bridges it:
@@ -30,44 +43,52 @@ so the adapter bridges it:
30
  | scoring expects | enriched has | mapping |
31
  |---|---|---|
32
  | `ideal_swell.direction: "SE"` | `idealSwell.direction: ["E","ESE","SE"]` | `/`-join list (`_dir_to_deg` parses `/` as cyclic mean); empty → `"E"` |
33
- | `ideal_swell.size_ft_min/max` | `idealSwell.sizeRangeFt.{min,max}` | float cast |
34
  | `ideal_wind.direction` | `idealWind.direction: [...]` | same `/`-join; empty → `"E"` |
35
  | `ideal_wind.strength_kt_max` | only `idealWind.type` (offshore/…) | flat `15.0` (`DEFAULT_WIND_MAX_KT`) — schema has no knots value; final limit is `min(15, skill_profile_max)` in `_score_wind` |
36
- | skill `beginner/intermediate/advanced/expert` | + `pro-only` | `pro-only` → `expert`; unknown/None → `intermediate` |
37
 
38
  Helpers: `normalize_skill()`, `break_skill()` (break's own tier),
39
- `get_coords()` (mirrors `main.py _lat/_lng`, `None` on missing).
40
 
41
- ## Forecast client (`wavereader/forecasts.py`)
42
 
43
  - Two Open-Meteo endpoints merged on `time` by `_build_normalized`:
44
  `marine-api.open-meteo.com` (hourly `wave_height`, `wave_period`,
45
  `wave_direction`, `wind_wave_height`, `swell_wave_height`) +
46
  `api.open-meteo.com` (hourly `wind_speed_10m` in km/h, converted to
47
  knots in `score_week`, + `wind_direction_10m`). Marine serves no wind.
48
- - Grid snap: coords shift ~0.13° seaward (`_seaward_offset`: TAS/south
49
- coast south, east coast east, west coast west) so lookups land on a
50
- marine grid point.
 
 
51
  - Cache: `.cache/forecasts/<lat>_<lon>_<days>.json` envelope
52
- `{fetched_at, data}`, TTL 6h (`CACHE_TTL_SECONDS`). Fresh cache served
53
- without network; on API failure the stale entry is served as fallback;
54
- raises only with no usable data at all. Horizon: hourly, 1–7 days
55
- (`days_slider`, clamped).
 
 
56
 
57
- ## Scoring (`wavereader/scoring.py`)
58
 
59
  Per hour (`score_hour`), weights `swell_size 0.30 / swell_direction
60
  0.20 / wind 0.30 / period 0.20` (redistributed when direction missing):
61
 
62
- - **size** — 10 inside spot's `[min,max]`, Gaussian falloff outside,
63
- multiplied by skill comfort penalty → 0 above `max_safe_size_ft`.
64
- - **direction** — Gaussian on angular diff to ideal (σ = 30°).
 
 
65
  - **wind** — mean of speed (10 if ≤ limit else 0; glassy ≤5kt always 10)
66
- and direction (cosine falloff from offshore). Limit =
 
67
  `min(spot 15kt, skill max)`.
68
  - **period** — 0 at ≤4s, linear to 10 at ≥14s, never penalised above.
69
 
70
- `score_week` skips hours with nulls. Skill profiles (ft / kt / s):
 
71
 
72
  | skill | comfort ft | max safe ft | max wind kt |
73
  |---|---|---|---|
@@ -78,24 +99,63 @@ Per hour (`score_hour`), weights `swell_size 0.30 / swell_direction
78
 
79
  ## UI outputs (`app/surf_forecast.py` builders)
80
 
 
 
 
 
 
 
81
  - `best_md` hero: `**score/10 @ time** — H m @ P s, wind Wkt (deg°)`.
82
- - `build_score_fig` — time → score line (y 0–10) + best-hour marker.
83
- - `build_components_fig` — 4 lines: `swell_size / swell_direction /
84
- wind / period`.
85
- - `build_waves_fig` — `wave_height_m` + `wave_period_s` (right axis).
86
- - `build_wind_fig` — `wind_speed_kt` line.
87
- - `daily_df` — best hour per date via `daily_best`.
88
- - `hourly_df` — every scored hour: time, score, 4 components, wave
89
- height/period, wind kt.
90
- - Empty `scored` → figs annotated `No forecast data`, tables empty.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
  ## Behaviour + limits
93
 
94
  - Explicit **Get forecast** button — no auto-fetch on pick (saves API
95
- calls). Skill dropdown defaults to the selected break's tier; days
96
- default 7.
 
97
  - Missing coords → `ValueError` → status error. No-break → warning
98
- state, no fetch.
99
  - Custom ⭐ break works identically (same schema + coords).
100
  - Known limits: no tide curves (Open-Meteo has none — `idealTide` is
101
  qualitative only); flat 15kt wind tolerance for all spots; swell
 
1
  # Surf forecast tab
2
 
3
  The `surf forecast` tab in `main.py:build_demo()` scores the break
4
+ selected on the `encyclopedia` tab. Two stages:
5
+
6
+ - **Stage 1 — deterministic charts** (`fetch_forecast`): Open-Meteo
7
+ numbers in, `scoring.py` numbers out. No agent/LLM in this path.
8
+ - **Stage 2 — narrated report** (`generate_reports`, opt-in): the LLM
9
+ narrates the Stage-1 scores only — it never invents swell/wind/score
10
+ values.
11
 
12
  ## Pipeline
13
 
14
  ```
15
  pick break (tab 1, break_dd -> selected_break gr.State)
16
+ -> press "Get forecast (charts)" (tab 2, fetch_btn)
17
  -> fetch_forecast() [main.py]
18
+ -> _load_forecast() [importlib, app/surf_forecast.py]
19
+ -> get_scored_week(break, skill, days=7)
20
  -> adapters.enriched_to_scoring_spot(break) # schema -> scoring shape
21
  -> forecasts.get_forecast(lat, lng, days) # marine + wind, cached
22
  -> scoring.score_week(forecast, spot, skill) # 0-10 per hour
23
+ -> best_window / daily_best / build_score_fig / build_waves_fig / build_wind_fig
24
+ -> scored_payload gr.State {break, skill, spot, scored, daily, best, days}
25
+ -> press "Generate surf report" (tab 2, report_btn) [optional]
26
+ -> generate_reports() [main.py]
27
+ -> _load_report() [importlib, app/generate_surf_report.py]
28
+ -> generate_surf_report_stream(break, skill, daily, best, days)
29
  ```
30
 
31
+ `fetch_forecast` fixes `days = 7` (no days slider in the UI;
32
+ `get_scored_week` still clamps any `days` arg to 1–7). It shows
33
+ `gr.Progress` steps and writes human-readable status into `status_box`
34
+ plus a tool-call trace into `report_telemetry_box`; any exception
35
+ surfaces there with empty figs (no traceback in UI). An empty `scored`
36
+ list disables the report path (`scored_payload = None`).
37
 
38
+ ## Adapter (`app/adapters.py`)
39
 
40
  `scoring.py` expects the old spot shape; the enriched schema differs,
41
  so the adapter bridges it:
 
43
  | scoring expects | enriched has | mapping |
44
  |---|---|---|
45
  | `ideal_swell.direction: "SE"` | `idealSwell.direction: ["E","ESE","SE"]` | `/`-join list (`_dir_to_deg` parses `/` as cyclic mean); empty → `"E"` |
46
+ | `ideal_swell.size_ft_min/max` | `idealSwell.sizeRangeFt.{min,max}` | float cast (missing → `0.0`) |
47
  | `ideal_wind.direction` | `idealWind.direction: [...]` | same `/`-join; empty → `"E"` |
48
  | `ideal_wind.strength_kt_max` | only `idealWind.type` (offshore/…) | flat `15.0` (`DEFAULT_WIND_MAX_KT`) — schema has no knots value; final limit is `min(15, skill_profile_max)` in `_score_wind` |
49
+ | skill `beginner/intermediate/advanced/expert` | + `pro-only` | `pro-only`/`pro only`/`pro` → `expert`; unknown/None → `intermediate` |
50
 
51
  Helpers: `normalize_skill()`, `break_skill()` (break's own tier),
52
+ `get_coords()` (mirrors `main.py _lat`/`_lng`, `None` on missing).
53
 
54
+ ## Forecast client (`app/forecasts.py`)
55
 
56
  - Two Open-Meteo endpoints merged on `time` by `_build_normalized`:
57
  `marine-api.open-meteo.com` (hourly `wave_height`, `wave_period`,
58
  `wave_direction`, `wind_wave_height`, `swell_wave_height`) +
59
  `api.open-meteo.com` (hourly `wind_speed_10m` in km/h, converted to
60
  knots in `score_week`, + `wind_direction_10m`). Marine serves no wind.
61
+ - Grid snap: coords shift ~0.13° seaward (`_seaward_offset`, rounded to
62
+ 2 dp by `_round_coords`) so lookups land on a marine grid point —
63
+ TAS (`lat <= -39.5`) shifts south, east coast (`lon >= 147`) shifts
64
+ east, west coast (`lon <= 125`) shifts west, otherwise (SA/VIC south
65
+ coast) shifts south.
66
  - Cache: `.cache/forecasts/<lat>_<lon>_<days>.json` envelope
67
+ `{fetched_at, data}` (key from the *raw* spot coords; the seaward
68
+ offset is a fetch detail only). TTL 6h (`CACHE_TTL_SECONDS`). Fresh
69
+ cache served without network (written atomically via tmp + rename);
70
+ on API failure the stale entry is served as fallback (legacy
71
+ pre-envelope files return with `fetched_at = 0` so they still work
72
+ as stale fallback); raises only with no usable data at all.
73
 
74
+ ## Scoring (`app/scoring.py`)
75
 
76
  Per hour (`score_hour`), weights `swell_size 0.30 / swell_direction
77
  0.20 / wind 0.30 / period 0.20` (redistributed when direction missing):
78
 
79
+ - **size** — 10 inside spot's `[min,max]`, Gaussian falloff outside
80
+ (`σ = max(1.0, min*0.4)` below, `σ = max(1.5, max*0.5)` above),
81
+ multiplied by skill comfort multiplier → 0 above `max_safe_size_ft`.
82
+ - **direction** — Gaussian on angular diff to ideal
83
+ (`σ = 45/1.5 = 30°`, so 45° off ≈ 3.2/10).
84
  - **wind** — mean of speed (10 if ≤ limit else 0; glassy ≤5kt always 10)
85
+ and direction (`10 * max(0, cos(diff/2))`, i.e. perfect offshore →
86
+ 10, 180° off (onshore) → 0). Limit =
87
  `min(spot 15kt, skill max)`.
88
  - **period** — 0 at ≤4s, linear to 10 at ≥14s, never penalised above.
89
 
90
+ `score_week` skips hours with nulls and converts `wind_speed_10m`
91
+ km/h → kt (`× 0.539957`). Skill profiles (ft / kt / s):
92
 
93
  | skill | comfort ft | max safe ft | max wind kt |
94
  |---|---|---|---|
 
99
 
100
  ## UI outputs (`app/surf_forecast.py` builders)
101
 
102
+ `get_scored_week` returns `{forecast, scored, spot, skill, lat, lng}`.
103
+ `daily_best` groups by date (first 10 chars of `time`) and returns
104
+ `{date, time, score, wave_height_m, wave_period_s, wind_speed_kt,
105
+ wind_direction_deg}` per day. `best_window` is the single
106
+ highest-scoring hour.
107
+
108
  - `best_md` hero: `**score/10 @ time** — H m @ P s, wind Wkt (deg°)`.
109
+ - `build_score_fig` — score bars coloured red → green by quality
110
+ (≥8 dark green, ≥6 light green, ≥4 yellow, ≥2 orange, else red) +
111
+ gold ★ marker on the best hour.
112
+ - `build_waves_fig` — `wave_height_m` filled area (blue, left axis) +
113
+ `wave_period_s` line (orange, right axis).
114
+ - `build_wind_fig` — arrows only, no y-axis: colour = direction
115
+ quality vs the spot's ideal offshore (green ≤45°, yellow cross
116
+ ≤135°, red onshore above that; dark blue when the spot has no
117
+ parseable direction), size = strength (22 → 33pt over 0–30kt),
118
+ subsampled to ~28 arrows. Arrows point where the wind blows TO;
119
+ exact kt + compass on hover.
120
+ - All three share `_strip_layout` styling (compact heights, unified
121
+ hover, white plot bg). `build_components_fig` is retired (returns
122
+ `No forecast data`) — use `build_score_fig`.
123
+ - Empty `scored` → figs annotated `No forecast data`, report disabled.
124
+
125
+ ## Stage-2 report (`app/generate_surf_report.py`)
126
+
127
+ Same framework as break generation (single-shot `InferenceClient`
128
+ call, `nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-BF16` via
129
+ `provider="deepinfra"`, deliberately NOT a smolagents CodeAgent).
130
+ Speed-first: the prompt carries ONLY the daily bests (≤7 lines) + the
131
+ single best window + a one-line break summary (ideals + up to 3
132
+ hazards) — never the full hourly table or full break record.
133
+
134
+ - Output is plain markdown, NOT JSON: one `- ` bullet per day in date
135
+ order (date, score, height/period, wind, short outlook phrase) plus
136
+ a final `**Recommendation: <date + time> -- <reason>.**` line.
137
+ `max_tokens=500`, `temperature=0.3`.
138
+ - Reasoning disabled at the API level (`EXTRA_BODY =
139
+ {"reasoning_effort": "none"}` + `/no_think` system prompt). Markers
140
+ `@@REPORT@@ … @@END@@` fence the answer; `_clean_output` strips
141
+ `<think>` blocks, fences, and plain-text planning sentences
142
+ ("we need to…") live during streaming.
143
+ - `generate_surf_report_stream` yields the accumulated cleaned report
144
+ per delta; `stats` reports `reasoning_chars` (hidden channel, never
145
+ displayed) vs `content_chars` so callers can prove the visible text
146
+ is the final report. `main.py generate_reports` yields an instant
147
+ "Contacting report model…" placeholder first so the button never
148
+ looks dead, then streams. LLM failure keeps the deterministic
149
+ charts — only the report box shows the error.
150
 
151
  ## Behaviour + limits
152
 
153
  - Explicit **Get forecast** button — no auto-fetch on pick (saves API
154
+ calls). Skill dropdown defaults to the selected break's tier
155
+ (`pro-only` → `expert`); window fixed at 7 days. Report is a second
156
+ explicit opt-in after inspecting the charts.
157
  - Missing coords → `ValueError` → status error. No-break → warning
158
+ state, no fetch. No scored hours → warning, report disabled.
159
  - Custom ⭐ break works identically (same schema + coords).
160
  - Known limits: no tide curves (Open-Meteo has none — `idealTide` is
161
  qualitative only); flat 15kt wind tolerance for all spots; swell
main.py CHANGED
@@ -14,6 +14,8 @@ from plotly import graph_objects as go
14
  DATA_PATH = Path(__file__).parent / "data" / "australia-surf-breaks-enriched.json"
15
  GENERATOR_PATH = Path(__file__).parent / "app" / "generate_surf_break.py"
16
  FORECAST_PATH = Path(__file__).parent / "app" / "surf_forecast.py"
 
 
17
 
18
  ALL = "All"
19
 
@@ -45,17 +47,30 @@ APP_CSS = """
45
  text-transform: lowercase;
46
  margin-bottom: 0 !important;
47
  }
48
- .hero-sub {
49
- font-size: 1.3em !important;
50
- text-transform: uppercase;
51
- letter-spacing: 3px;
52
- border-top: 2px dashed #0b2c5c;
53
- border-bottom: 2px dashed #0b2c5c;
54
- padding: 6px 0;
55
- }
56
  button, select {
57
  font-family: "Courier New", Courier, monospace !important;
58
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  .break-list { max-height: 210px; overflow-y: auto; border: 1px solid #0b2c5c; border-radius: 8px; padding: 4px; background: #eef6fd !important; }
60
  """
61
 
@@ -84,6 +99,18 @@ def _load_forecast():
84
  return module
85
 
86
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  def _empty_forecast_fig() -> go.Figure:
88
  fig = go.Figure()
89
  fig.add_annotation(text="No forecast data", showarrow=False, font={"size": 16})
@@ -547,43 +574,72 @@ def clear_custom_break(base_records: list[dict], selected: dict | None = None):
547
  def fetch_forecast(
548
  selected_break: dict | None,
549
  skill: str,
550
- days: int,
551
  progress=gr.Progress(),
552
  ):
553
- """Score a 1–7 day forecast for the shared ``selected_break`` (explicit button).
554
-
555
- Deterministic only: delegates to ``app/surf_forecast.get_scored_week``
556
- (Open-Meteo + deterministic scoring; stale-cache fallback lives inside
557
- ``get_forecast``). No agent involved.
 
 
 
 
558
  """
559
- empty_df = pd.DataFrame()
560
- if not selected_break:
 
561
  return (
 
 
 
 
 
 
 
 
 
 
 
 
562
  "⚠️ Pick a break in the encyclopedia tab first.",
563
  "_Pick a break first._",
564
  _empty_forecast_fig(),
565
  _empty_forecast_fig(),
566
  _empty_forecast_fig(),
567
- empty_df,
568
- empty_df,
569
  )
 
570
  try:
571
- try:
572
- days_int = max(1, min(7, int(days)))
573
- except (TypeError, ValueError):
574
- days_int = 7
575
- progress(0.1, desc="Loading forecast module…")
 
 
 
 
 
 
 
 
 
 
 
576
  mod = _load_forecast()
577
- progress(0.3, desc="Fetching + scoring forecast…")
578
  result = mod.get_scored_week(
579
  selected_break, skill=skill, days=days_int
580
  )
581
  scored = result.get("scored", []) or []
582
- progress(0.7, desc="Building charts…")
 
 
583
  score_fig = mod.build_score_fig(scored)
584
  waves_fig = mod.build_waves_fig(scored)
585
- wind_fig = mod.build_wind_fig(scored)
586
  best = mod.best_window(scored)
 
587
  if best:
588
  best_md = (
589
  f"**{best.get('score')}/10 @ {best.get('time')}** — "
@@ -593,147 +649,243 @@ def fetch_forecast(
593
  )
594
  else:
595
  best_md = "_No scored hours in this window._"
596
- daily = pd.DataFrame(mod.daily_best(scored))
597
- hourly_rows = []
598
- for r in scored:
599
- comps = r.get("components") or {}
600
- hourly_rows.append(
601
- {
602
- "time": r.get("time"),
603
- "score": r.get("score"),
604
- "swell_size": comps.get("swell_size"),
605
- "swell_direction": comps.get("swell_direction"),
606
- "wind": comps.get("wind"),
607
- "period": comps.get("period"),
608
- "wave_height_m": r.get("wave_height_m"),
609
- "wave_period_s": r.get("wave_period_s"),
610
- "wind_speed_kt": r.get("wind_speed_kt"),
611
- }
612
  )
613
- hourly = pd.DataFrame(
614
- hourly_rows,
615
- columns=[
616
- "time",
617
- "score",
618
- "swell_size",
619
- "swell_direction",
620
- "wind",
621
- "period",
622
- "wave_height_m",
623
- "wave_period_s",
624
- "wind_speed_kt",
625
- ],
626
  )
627
- progress(1.0, desc="Done")
628
  status = (
629
  f"✅ {selected_break.get('name', '?')} · "
630
- f"skill {result.get('skill')} · "
631
- f"{len(scored)} hour(s) scored ({days_int}d)."
632
- )
633
- return (
634
- status,
635
- best_md,
636
- score_fig,
637
- waves_fig,
638
- wind_fig,
639
- daily,
640
- hourly,
641
  )
 
 
 
 
 
 
 
 
 
 
642
  except Exception as e: # noqa: BLE001 — surface fetch/score errors in the status box
643
- return (
 
644
  f"❌ Forecast failed: {e}",
645
  "_Forecast failed._",
646
  _empty_forecast_fig(),
647
  _empty_forecast_fig(),
648
  _empty_forecast_fig(),
649
- empty_df,
650
- empty_df,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
651
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
 
653
 
654
  def build_demo() -> gr.Blocks:
655
  with gr.Blocks(title="wave~reader — Australian surf encyclopaedia") as demo:
656
  gr.Markdown("# wave~reader", elem_classes=["hero-title"])
657
- gr.Markdown("## Australian surf encyclopaedia", elem_classes=["hero-sub"])
658
- gr.Markdown("filter spots, hover a dot for its name, then pick a break for details. [lo-fi edition]")
 
 
659
  records_state = gr.State(_records(DF))
660
  custom_state = gr.State(None) # one session-only break; never persisted
661
  selected_break = gr.State(None) # shared pick for the forecast tab
662
- with gr.Tab("encyclopedia"):
663
- with gr.Row():
664
- state_dd = gr.Dropdown([ALL, *STATES], value=ALL, label="State")
665
- region_dd = gr.Dropdown([ALL, *ALL_REGIONS], value=ALL, label="Region")
666
- skill_dd = gr.Dropdown([ALL, *SKILLS], value=ALL, label="Skill level")
667
- count_md = gr.Markdown(f"**{len(DF)}** spot(s)")
668
- with gr.Row():
669
- with gr.Column(scale=3):
670
- map_plot = gr.Plot(build_map(_records(DF), default_view=True), label="Spots")
671
- cant_find_btn = gr.Button(
672
- "Can't find your local break?", variant="secondary"
673
- )
674
- with gr.Group(visible=False) as custom_box:
675
- gr.Markdown(
676
- "### Generate your local break\n"
677
- "One break per session, generated live via "
678
- "`app/generate_surf_break.py`. Session-only — "
679
- "never saved, deleted when your session ends."
680
  )
681
- with gr.Row():
682
- custom_state_dd = gr.Dropdown(
683
- STATES, value=None, label="State"
 
 
 
684
  )
685
- custom_region_dd = gr.Dropdown(
686
- ALL_REGIONS,
687
- value=None,
688
- label="Region (or type a new one)",
689
- allow_custom_value=True,
 
 
 
 
 
 
 
690
  )
691
- custom_name_txt = gr.Textbox(
692
- label="Break name", placeholder="e.g. Kilcunda"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
693
  )
694
- with gr.Row():
695
- generate_btn = gr.Button("Generate my break", variant="primary")
696
- clear_btn = gr.Button("Delete my break", variant="stop")
697
- telemetry_box = gr.Textbox(
698
- label="Generation telemetry (tool calls)",
699
- lines=8,
700
- max_lines=14,
701
- interactive=False,
702
- placeholder="Press “Generate my break” to see the tool-call trace…",
703
  )
704
- with gr.Column(scale=2):
705
- break_dd = gr.Radio(
706
- choices=[r.get("name", "?") for r in _records(DF)],
707
- value=None,
708
- label="Breaks (pick one for details)",
709
- elem_classes=["break-list"],
710
- )
711
- details_df = gr.Dataframe(
712
- headers=["Field", "Value"],
713
- row_count=(16, "fixed"),
714
- column_count=(2, "fixed"),
715
- label="Break details",
716
- wrap=True,
717
- )
718
- with gr.Tab("surf forecast"):
719
- fc_header = gr.Markdown(_NO_BREAK_HEADER)
720
- with gr.Row():
721
  fc_skill = gr.Dropdown(
722
  SKILL_ORDER, value="intermediate", label="Skill level"
723
  )
724
- days_slider = gr.Slider(1, 7, value=7, step=1, label="Days")
725
- fetch_btn = gr.Button("Get forecast", variant="primary")
726
- status_box = gr.Textbox(
727
- label="Status",
728
- interactive=False,
729
- placeholder="Pick a break, then press “Get forecast”…",
730
- )
731
- best_md = gr.Markdown("_No forecast yet._")
732
- score_plot = gr.Plot(label="Score")
733
- waves_plot = gr.Plot(label="Swell")
734
- wind_plot = gr.Plot(label="Wind")
735
- daily_df = gr.Dataframe(label="Best hour each day", wrap=True)
736
- hourly_df = gr.Dataframe(label="All scored hours", wrap=True)
 
 
 
 
 
 
737
 
738
  def _toggle_custom_box(visible: bool):
739
  return gr.update(visible=not visible), not visible
@@ -780,6 +932,11 @@ def build_demo() -> gr.Blocks:
780
  inputs=[break_dd, records_state, custom_state],
781
  outputs=[details_df, selected_break, fc_header, fc_skill],
782
  )
 
 
 
 
 
783
  generate_btn.click(
784
  generate_custom_break,
785
  inputs=[
@@ -809,22 +966,30 @@ def build_demo() -> gr.Blocks:
809
  )
810
  fetch_btn.click(
811
  fetch_forecast,
812
- inputs=[selected_break, fc_skill, days_slider],
813
  outputs=[
814
  status_box,
815
  best_md,
816
  score_plot,
817
  waves_plot,
818
  wind_plot,
819
- daily_df,
820
- hourly_df,
 
 
 
 
 
 
 
 
821
  ],
822
  )
823
  return demo
824
 
825
 
826
  def main():
827
- build_demo().launch(css=APP_CSS)
828
 
829
 
830
  if __name__ == "__main__":
 
14
  DATA_PATH = Path(__file__).parent / "data" / "australia-surf-breaks-enriched.json"
15
  GENERATOR_PATH = Path(__file__).parent / "app" / "generate_surf_break.py"
16
  FORECAST_PATH = Path(__file__).parent / "app" / "surf_forecast.py"
17
+ REPORT_PATH = Path(__file__).parent / "app" / "generate_surf_report.py"
18
+ FAVICON_PATH = Path(__file__).parent / "assets" / "wave.svg"
19
 
20
  ALL = "All"
21
 
 
47
  text-transform: lowercase;
48
  margin-bottom: 0 !important;
49
  }
 
 
 
 
 
 
 
 
50
  button, select {
51
  font-family: "Courier New", Courier, monospace !important;
52
  }
53
+ /* primary (orange) buttons -> dark blue */
54
+ .gradio-container {
55
+ --button-primary-background-fill: #0b2c5c !important;
56
+ --button-primary-background-fill-hover: #13407e !important;
57
+ --button-primary-text-color: #ffffff !important;
58
+ --button-primary-border-color: #0b2c5c !important;
59
+ }
60
+ .gradio-container button.primary,
61
+ .gradio-container .gr-button-primary,
62
+ button.primary, button.lg.primary {
63
+ background: #0b2c5c !important;
64
+ border-color: #0b2c5c !important;
65
+ color: #ffffff !important;
66
+ }
67
+ .gradio-container button.primary:hover,
68
+ .gradio-container .gr-button-primary:hover,
69
+ button.primary:hover, button.lg.primary:hover {
70
+ background: #13407e !important;
71
+ border-color: #13407e !important;
72
+ color: #ffffff !important;
73
+ }
74
  .break-list { max-height: 210px; overflow-y: auto; border: 1px solid #0b2c5c; border-radius: 8px; padding: 4px; background: #eef6fd !important; }
75
  """
76
 
 
99
  return module
100
 
101
 
102
+ def _load_report():
103
+ """Load app/generate_surf_report.py without requiring app/ to be a package."""
104
+ spec = importlib.util.spec_from_file_location(
105
+ "surf_report_generator", REPORT_PATH
106
+ )
107
+ if spec is None or spec.loader is None:
108
+ raise ImportError(f"Cannot load report generator from {REPORT_PATH}")
109
+ module = importlib.util.module_from_spec(spec)
110
+ spec.loader.exec_module(module)
111
+ return module
112
+
113
+
114
  def _empty_forecast_fig() -> go.Figure:
115
  fig = go.Figure()
116
  fig.add_annotation(text="No forecast data", showarrow=False, font={"size": 16})
 
574
  def fetch_forecast(
575
  selected_break: dict | None,
576
  skill: str,
 
577
  progress=gr.Progress(),
578
  ):
579
+ """Stage 1 — deterministic forecast only: score + build the 3 charts.
580
+
581
+ Delegates to ``app/surf_forecast.get_scored_week`` (Open-Meteo +
582
+ deterministic scoring) and builds score / swell / wind figs. No LLM
583
+ call here, so the graphs render immediately and the user can inspect
584
+ them before opting into Stage 2 (``generate_reports``). Yields
585
+ (status, best, score_fig, waves_fig, wind_fig, scored_payload,
586
+ telemetry) tuples. The LLM never owns numbers — it only narrates
587
+ these scores in Stage 2.
588
  """
589
+ logs: list[str] = []
590
+
591
+ def emit(status, best_md, score_fig, waves_fig, wind_fig, payload):
592
  return (
593
+ status,
594
+ best_md,
595
+ score_fig,
596
+ waves_fig,
597
+ wind_fig,
598
+ payload,
599
+ _telemetry(logs),
600
+ )
601
+
602
+ if not selected_break:
603
+ logs.append("⚠️ No break selected — pick one in the encyclopedia tab.")
604
+ yield emit(
605
  "⚠️ Pick a break in the encyclopedia tab first.",
606
  "_Pick a break first._",
607
  _empty_forecast_fig(),
608
  _empty_forecast_fig(),
609
  _empty_forecast_fig(),
610
+ None,
 
611
  )
612
+ return
613
  try:
614
+ days_int = 7
615
+ t0 = time.time()
616
+ logs.append(
617
+ f"🔧 [tool: get_scored_week] break='{selected_break.get('name', '?')}' "
618
+ f"skill='{skill}' days={days_int} (Open-Meteo marine+wind → scoring.score_week)."
619
+ )
620
+ progress(0.15, desc="Fetching + scoring forecast…")
621
+ yield emit(
622
+ "⏳ Fetching + scoring forecast…",
623
+ "_Scoring…_",
624
+ _empty_forecast_fig(),
625
+ _empty_forecast_fig(),
626
+ _empty_forecast_fig(),
627
+ None,
628
+ )
629
+
630
  mod = _load_forecast()
 
631
  result = mod.get_scored_week(
632
  selected_break, skill=skill, days=days_int
633
  )
634
  scored = result.get("scored", []) or []
635
+ skill_used = result.get("skill", skill)
636
+ spot = result.get("spot", {})
637
+ progress(0.6, desc="Building charts…")
638
  score_fig = mod.build_score_fig(scored)
639
  waves_fig = mod.build_waves_fig(scored)
640
+ wind_fig = mod.build_wind_fig(scored, spot)
641
  best = mod.best_window(scored)
642
+ daily = mod.daily_best(scored)
643
  if best:
644
  best_md = (
645
  f"**{best.get('score')}/10 @ {best.get('time')}** — "
 
649
  )
650
  else:
651
  best_md = "_No scored hours in this window._"
652
+ dt = time.time() - t0
653
+ logs.append(
654
+ f"📊 [tool: score_week] {len(scored)} hour(s) scored "
655
+ f"(skill={skill_used}, daily bests={len(daily)}) in {dt:.1f}s."
656
+ )
657
+ progress(1.0, desc="Charts ready")
658
+ if not scored:
659
+ logs.append("⚠️ No scored hours — charts are empty, report disabled.")
660
+ yield emit(
661
+ f"⚠️ {selected_break.get('name', '?')} · no scored hours ({days_int}d).",
662
+ best_md,
663
+ score_fig,
664
+ waves_fig,
665
+ wind_fig,
666
+ None,
 
667
  )
668
+ return
669
+
670
+ logs.append(
671
+ "✅ Charts ready — inspect them above, then press "
672
+ "“Generate surf report ✨” for the write-up (one sentence per day + recommendation)."
 
 
 
 
 
 
 
 
673
  )
 
674
  status = (
675
  f"✅ {selected_break.get('name', '?')} · "
676
+ f"skill {skill_used} · "
677
+ f"{len(scored)} hour(s) scored ({days_int}d). "
678
+ f"Charts ready — generate the report when ready."
 
 
 
 
 
 
 
 
679
  )
680
+ payload = {
681
+ "break": selected_break,
682
+ "skill": skill_used,
683
+ "spot": spot,
684
+ "scored": scored,
685
+ "daily": daily,
686
+ "best": best,
687
+ "days": days_int,
688
+ }
689
+ yield emit(status, best_md, score_fig, waves_fig, wind_fig, payload)
690
  except Exception as e: # noqa: BLE001 — surface fetch/score errors in the status box
691
+ logs.append(f"❌ Forecast failed: {e}")
692
+ yield emit(
693
  f"❌ Forecast failed: {e}",
694
  "_Forecast failed._",
695
  _empty_forecast_fig(),
696
  _empty_forecast_fig(),
697
  _empty_forecast_fig(),
698
+ None,
699
+ )
700
+
701
+
702
+ def generate_reports(
703
+ scored_payload: dict | None,
704
+ progress=gr.Progress(),
705
+ ):
706
+ """Stage 2 — short narrated report, streamed live as plain markdown.
707
+
708
+ Reads the ``scored_payload`` saved by :func:`fetch_forecast` and calls
709
+ ``app/generate_surf_report.generate_surf_report_stream`` (same
710
+ InferenceClient framework/model as break generation, ``stream=True``).
711
+ The prompt carries only the daily bests (<=7 lines) + best window, and
712
+ the model returns one dot-point per day (one full sentence of outlook
713
+ per day) plus a ``**Recommendation: ...**``
714
+ line — small prompt + short output keeps time-to-first-token low. Yields
715
+ (report_md, telemetry) tuples; the streamed text IS the report, so no
716
+ parse step. The LLM narrates the provided scores only.
717
+ """
718
+ logs: list[str] = []
719
+
720
+ def emit(report_md):
721
+ return (report_md, _telemetry(logs))
722
+
723
+ daily = (scored_payload or {}).get("daily") or []
724
+ if not scored_payload or not daily:
725
+ logs.append("⚠️ No scored forecast yet — press “Get forecast” first.")
726
+ yield emit("_No report yet — press “Get forecast” first._")
727
+ return
728
+
729
+ # Instant feedback first: this yield renders before any network wait,
730
+ # so the button never looks dead while the model spins up.
731
+ logs.append(
732
+ f"🔧 [tool: build_surf_report_prompt] {len(daily)} daily bests + "
733
+ f"best window → tiny prompt (no hourly rows)."
734
+ )
735
+ progress(0.1, desc="Contacting report model…")
736
+ yield emit("_Contacting report model… first words appear below as written._")
737
+
738
+ try:
739
+ rep = _load_report()
740
+ logs.append(
741
+ f"🧠 [tool: generate_surf_report_stream] InferenceClient "
742
+ f"provider='{rep.PROVIDER}' model='{rep.MODEL_ID}' "
743
+ f"(stream=True, temp={rep.TEMPERATURE}, max_tokens={rep.MAX_TOKENS}, "
744
+ f"reasoning disabled via extra_body={rep.EXTRA_BODY} + /no_think)…"
745
  )
746
+ progress(0.3, desc="Streaming surf report…")
747
+ yield emit("_Streaming…_")
748
+
749
+ t0 = time.time()
750
+ text = ""
751
+ chunk_count = 0
752
+ stream_stats: dict = {}
753
+ for text in rep.generate_surf_report_stream(
754
+ break_=scored_payload["break"],
755
+ skill=scored_payload["skill"],
756
+ daily=daily,
757
+ best=scored_payload.get("best"),
758
+ days=scored_payload.get("days", len(daily)),
759
+ stats=stream_stats,
760
+ ):
761
+ chunk_count += 1
762
+ yield emit(text + "\n\n⌨️ _streaming…_")
763
+ if not text.strip():
764
+ raise ValueError(
765
+ "Empty stream from report generator "
766
+ f"(reasoning_chars={stream_stats.get('reasoning_chars', '?')}, "
767
+ f"content_chars={stream_stats.get('content_chars', '?')})."
768
+ )
769
+
770
+ dt = time.time() - t0
771
+ logs.append(
772
+ f"✅ Done in {dt:.1f}s → {chunk_count} chunk(s), "
773
+ f"final report {len(text)} chars "
774
+ f"(content={stream_stats.get('content_chars', '?')} chars, "
775
+ f"hidden reasoning={stream_stats.get('reasoning_chars', '?')} chars — "
776
+ f"what you see above is the final report, not the thinking)."
777
+ )
778
+ progress(1.0, desc="Done")
779
+ yield emit(text)
780
+ except Exception as e: # noqa: BLE001 — LLM failure keeps deterministic charts
781
+ logs.append(f"❌ Report generation failed: {e}")
782
+ logs.append("💡 Check HF_TOKEN is set and the inference provider serves the model.")
783
+ logs.append("ℹ️ Charts + best window above are deterministic and still valid.")
784
+ yield emit("_Report generation failed — charts above still apply._")
785
 
786
 
787
  def build_demo() -> gr.Blocks:
788
  with gr.Blocks(title="wave~reader — Australian surf encyclopaedia") as demo:
789
  gr.Markdown("# wave~reader", elem_classes=["hero-title"])
790
+ gr.Markdown(
791
+ "a guide to australiab surf breaks — browse 238 breaks on the **encyclopedia** page, "
792
+ "then score the week ahead + get an ai write-up on the **surf forecast** page."
793
+ )
794
  records_state = gr.State(_records(DF))
795
  custom_state = gr.State(None) # one session-only break; never persisted
796
  selected_break = gr.State(None) # shared pick for the forecast tab
797
+ scored_state = gr.State(None) # stage-1 scored payload feeding stage-2 report
798
+ with gr.Tabs() as tabs:
799
+ with gr.Tab("encyclopedia", id="encyclopedia"):
800
+ gr.Markdown("### Australian surf encyclopaedia")
801
+ gr.Markdown("filter spots, hover a dot for its name, then pick a break for details.")
802
+ with gr.Row():
803
+ state_dd = gr.Dropdown([ALL, *STATES], value=ALL, label="State")
804
+ region_dd = gr.Dropdown([ALL, *ALL_REGIONS], value=ALL, label="Region")
805
+ skill_dd = gr.Dropdown([ALL, *SKILLS], value=ALL, label="Skill level")
806
+ count_md = gr.Markdown(f"**{len(DF)}** spot(s)")
807
+ with gr.Row():
808
+ with gr.Column(scale=3):
809
+ map_plot = gr.Plot(build_map(_records(DF), default_view=True), label="Spots")
810
+ cant_find_btn = gr.Button(
811
+ "Can't find your local break?", variant="secondary"
 
 
 
812
  )
813
+ with gr.Group(visible=False) as custom_box:
814
+ gr.Markdown(
815
+ "### Generate your local break\n"
816
+ "One break per session, generated live via "
817
+ "`app/generate_surf_break.py`. Session-only — "
818
+ "never saved, deleted when your session ends."
819
  )
820
+ with gr.Row():
821
+ custom_state_dd = gr.Dropdown(
822
+ STATES, value=None, label="State"
823
+ )
824
+ custom_region_dd = gr.Dropdown(
825
+ ALL_REGIONS,
826
+ value=None,
827
+ label="Region (or type a new one)",
828
+ allow_custom_value=True,
829
+ )
830
+ custom_name_txt = gr.Textbox(
831
+ label="Break name", placeholder="e.g. Kilcunda"
832
  )
833
+ with gr.Row():
834
+ generate_btn = gr.Button("Generate my break", variant="primary")
835
+ clear_btn = gr.Button("Delete my break", variant="stop")
836
+ telemetry_box = gr.Textbox(
837
+ label="Generation telemetry (tool calls)",
838
+ lines=8,
839
+ max_lines=14,
840
+ interactive=False,
841
+ placeholder="Press “Generate my break” to see the tool-call trace…",
842
+ )
843
+ with gr.Column(scale=2):
844
+ break_dd = gr.Radio(
845
+ choices=[r.get("name", "?") for r in _records(DF)],
846
+ value=None,
847
+ label="Breaks (pick one for details)",
848
+ elem_classes=["break-list"],
849
+ )
850
+ details_df = gr.Dataframe(
851
+ headers=["Field", "Value"],
852
+ row_count=(16, "fixed"),
853
+ column_count=(2, "fixed"),
854
+ label="Break details",
855
+ wrap=True,
856
  )
857
+ go_forecast_btn = gr.Button(
858
+ "Check surf forecast →", variant="primary"
 
 
 
 
 
 
 
859
  )
860
+ with gr.Tab("surf forecast", id="forecast"):
861
+ gr.Markdown("### Surf forecast")
862
+ gr.Markdown(
863
+ "pick a break in the encyclopedia, then press get forecast "
864
+ "for scored charts + an optional ai report."
865
+ )
866
+ fc_header = gr.Markdown(_NO_BREAK_HEADER)
 
 
 
 
 
 
 
 
 
 
867
  fc_skill = gr.Dropdown(
868
  SKILL_ORDER, value="intermediate", label="Skill level"
869
  )
870
+ fetch_btn = gr.Button("Get forecast (charts)", variant="primary")
871
+ status_box = gr.Textbox(
872
+ label="Status",
873
+ interactive=False,
874
+ placeholder="Pick a break, then press “Get forecast”…",
875
+ )
876
+ best_md = gr.Markdown("_No forecast yet._")
877
+ score_plot = gr.Plot(label="Score (0-10)")
878
+ waves_plot = gr.Plot(label="Swell")
879
+ wind_plot = gr.Plot(label="Wind (kt)")
880
+ report_btn = gr.Button("Generate surf report ✨", variant="secondary")
881
+ report_md = gr.Markdown("_No report yet — get the forecast, then generate the report._")
882
+ report_telemetry_box = gr.Textbox(
883
+ label="Report telemetry (tool calls)",
884
+ lines=8,
885
+ max_lines=14,
886
+ interactive=False,
887
+ placeholder="Press “Get forecast” for scoring trace, then “Generate surf report”…",
888
+ )
889
 
890
  def _toggle_custom_box(visible: bool):
891
  return gr.update(visible=not visible), not visible
 
932
  inputs=[break_dd, records_state, custom_state],
933
  outputs=[details_df, selected_break, fc_header, fc_skill],
934
  )
935
+ go_forecast_btn.click(
936
+ lambda: gr.Tabs(selected="forecast"),
937
+ inputs=None,
938
+ outputs=[tabs],
939
+ )
940
  generate_btn.click(
941
  generate_custom_break,
942
  inputs=[
 
966
  )
967
  fetch_btn.click(
968
  fetch_forecast,
969
+ inputs=[selected_break, fc_skill],
970
  outputs=[
971
  status_box,
972
  best_md,
973
  score_plot,
974
  waves_plot,
975
  wind_plot,
976
+ scored_state,
977
+ report_telemetry_box,
978
+ ],
979
+ )
980
+ report_btn.click(
981
+ generate_reports,
982
+ inputs=[scored_state],
983
+ outputs=[
984
+ report_md,
985
+ report_telemetry_box,
986
  ],
987
  )
988
  return demo
989
 
990
 
991
  def main():
992
+ build_demo().launch(css=APP_CSS, favicon_path=str(FAVICON_PATH))
993
 
994
 
995
  if __name__ == "__main__":