Spaces:
Running
Running
Commit ·
e40f80b
1
Parent(s): 5550159
restructured and implemented agent scaffold and tools
Browse files- .zcode/plans/plan-sess_e88d3e27-48e8-42f3-bcb1-112079be5cdf.md +0 -23
- AGENTS.md +5 -4
- README.md +2 -2
- ROADMAP.md +2 -2
- pyproject.toml +1 -0
- tests/test_agent.py +379 -0
- tests/test_scoring.py +319 -0
- tests/test_spots.py +185 -0
- uv.lock +165 -0
- wavereader/agent.py +272 -9
- wavereader/forecasts.py +109 -19
- wavereader/scoring.py +187 -9
- wavereader/spots.py +160 -30
- wavereader/tools.py +37 -14
.zcode/plans/plan-sess_e88d3e27-48e8-42f3-bcb1-112079be5cdf.md
DELETED
|
@@ -1,23 +0,0 @@
|
|
| 1 |
-
# Write project documentation files for WaveReader
|
| 2 |
-
|
| 3 |
-
Create three markdown files at the repo root capturing everything agreed in this chat, as the project's source of truth for the week:
|
| 4 |
-
|
| 5 |
-
## 1. `README.md` — public-facing
|
| 6 |
-
- Project pitch: agentic Australian surf forecaster; the "inherent knowledge" problem; the two flagship questions.
|
| 7 |
-
- Features: agent answers ("Where should I surf near Byron Bay this weekend?", "What time at Snapper tomorrow?"), **interactive forecast graphs** shown whenever the agent surfaces wind/sea data (seaborn-styled), **Australia break-selector map on the home page**, agent trace panel, daggr "Morning Surf Report" canvas, HF ↔ NVIDIA NIM model toggle, 101-break open knowledge base.
|
| 8 |
-
- Architecture diagram + stack: FastAPI, hand-rolled tool loop (no LangChain), Gradio, daggr, Qwen3-Next-80B, Open-Meteo Marine API; note on interactivity for seaborn-style charts.
|
| 9 |
-
- Quickstart (uv, env vars HF_TOKEN / NVIDIA_API_KEY), HF Space link (placeholder), license note.
|
| 10 |
-
|
| 11 |
-
## 2. `AGENTS.md` — for AI coding agents working in this repo
|
| 12 |
-
- Project context + contest deadline context (video Mon Sep 7).
|
| 13 |
-
- Stack rules: pin Python 3.12; deps to add/remove; no LangChain; provider-agnostic OpenAI-compatible client; deterministic scoring engine owns the numbers, LLM only interprets.
|
| 14 |
-
- Repo layout, data file schemas (breaks.json, break_details.json), conventions, evals/golden_questions.json purpose.
|
| 15 |
-
|
| 16 |
-
## 3. `ROADMAP.md` — the working plan
|
| 17 |
-
- Day-by-day tasks Sun Aug 30 → Mon Sep 7 (as agreed, updated to include the map home page + interactive graphs on Fri's UI day).
|
| 18 |
-
- Daggr strategy + beta caveat (no conditional loops; fixed Morning Surf Report DAG).
|
| 19 |
-
- Shot-by-shot video script (~2 min) with the Berlin move/open-to-work CTA.
|
| 20 |
-
- LinkedIn post copy skeleton (contest entry + Berlin announcement).
|
| 21 |
-
- Risks & fallbacks section.
|
| 22 |
-
|
| 23 |
-
No code changes — documentation only.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
AGENTS.md
CHANGED
|
@@ -25,9 +25,10 @@ The product promises (see README.md):
|
|
| 25 |
`>=3.14` — downgrade it; 3.14 has unnecessary lib-compatibility risk for a
|
| 26 |
one-week build.
|
| 27 |
- **Package manager:** uv (`uv add ...`, `uv sync`).
|
| 28 |
-
- **
|
| 29 |
-
|
| 30 |
-
touched. `ddgs` stays (used by
|
|
|
|
| 31 |
- **Provider-agnostic LLM client:** one OpenAI-compatible client class; the
|
| 32 |
base URL + key come from env vars (`HF_TOKEN`, `NVIDIA_API_KEY`). Default
|
| 33 |
model: `Qwen/Qwen3-Next-80B-A3B-Instruct` — chosen because it is the only
|
|
@@ -51,7 +52,7 @@ wavereader/
|
|
| 51 |
spots.py # load + validate + search breaks (name/city/region/skill)
|
| 52 |
forecasts.py # Open-Meteo Marine + weather client; hourly, 7-day; disk cache
|
| 53 |
scoring.py # deterministic 0–10 surf-quality score/hour + component breakdown
|
| 54 |
-
agent.py #
|
| 55 |
tools.py # get_forecast, score_week, find_spots, get_spot_knowledge,
|
| 56 |
# rank_spots_this_week
|
| 57 |
api.py # FastAPI endpoints
|
|
|
|
| 25 |
`>=3.14` — downgrade it; 3.14 has unnecessary lib-compatibility risk for a
|
| 26 |
one-week build.
|
| 27 |
- **Package manager:** uv (`uv add ...`, `uv sync`).
|
| 28 |
+
- **Agent framework:** smolagents (CodeAgent). The tool loop uses smolagents'
|
| 29 |
+
CodeAgent with InferenceClientModel for Hugging Face / NVIDIA NIM. Remove
|
| 30 |
+
`langchain-community` from deps when touched. `ddgs` stays (used by
|
| 31 |
+
`data/generate_data.py`).
|
| 32 |
- **Provider-agnostic LLM client:** one OpenAI-compatible client class; the
|
| 33 |
base URL + key come from env vars (`HF_TOKEN`, `NVIDIA_API_KEY`). Default
|
| 34 |
model: `Qwen/Qwen3-Next-80B-A3B-Instruct` — chosen because it is the only
|
|
|
|
| 52 |
spots.py # load + validate + search breaks (name/city/region/skill)
|
| 53 |
forecasts.py # Open-Meteo Marine + weather client; hourly, 7-day; disk cache
|
| 54 |
scoring.py # deterministic 0–10 surf-quality score/hour + component breakdown
|
| 55 |
+
agent.py # smolagents CodeAgent; streaming; trace capture; HF⇄NIM switch
|
| 56 |
tools.py # get_forecast, score_week, find_spots, get_spot_knowledge,
|
| 57 |
# rank_spots_this_week
|
| 58 |
api.py # FastAPI endpoints
|
README.md
CHANGED
|
@@ -15,7 +15,7 @@ The agent calls real forecast tools, scores every spot with a deterministic surf
|
|
| 15 |
|
| 16 |
| | |
|
| 17 |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| 18 |
-
| 🤖 **Agentic answers** |
|
| 19 |
| 📊 **Interactive forecast graphs** | Whenever the agent surfaces wind, swell or wave data, it renders as interactive charts (seaborn-styled): hourly wave height, period, wind speed and direction. |
|
| 20 |
| 🗺️ **Australia break map** | The home page is a map of Australia: pick your break directly, or search by city/suburb and let the agent rank nearby spots for your skill level. |
|
| 21 |
| 🧠 **Break knowledge base** | 101 breaks with coordinates, ideal swell/wind/tide, break type, skill level and hazards — generated with an open model, web-grounded, published as an open dataset. |
|
|
@@ -33,7 +33,7 @@ The agent calls real forecast tools, scores every spot with a deterministic surf
|
|
| 33 |
┌──────────────────────────────▼───────────── FastAPI ────────────────┐
|
| 34 |
│ /spots /forecast /score /ask │
|
| 35 |
├───────────────────────────────────────────────────────────────────────┤
|
| 36 |
-
│ agent.py —
|
| 37 |
│ ↳ tools: get_forecast · score_week · find_spots(near/skill) │
|
| 38 |
│ get_spot_knowledge · rank_spots_this_week │
|
| 39 |
├───────────────────────────────────────────────────────────────────────┤
|
|
|
|
| 15 |
|
| 16 |
| | |
|
| 17 |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| 18 |
+
| 🤖 **Agentic answers** | smolagents CodeAgent over Qwen3-Next-80B — the LLM interprets, deterministic tools own the data. |
|
| 19 |
| 📊 **Interactive forecast graphs** | Whenever the agent surfaces wind, swell or wave data, it renders as interactive charts (seaborn-styled): hourly wave height, period, wind speed and direction. |
|
| 20 |
| 🗺️ **Australia break map** | The home page is a map of Australia: pick your break directly, or search by city/suburb and let the agent rank nearby spots for your skill level. |
|
| 21 |
| 🧠 **Break knowledge base** | 101 breaks with coordinates, ideal swell/wind/tide, break type, skill level and hazards — generated with an open model, web-grounded, published as an open dataset. |
|
|
|
|
| 33 |
┌──────────────────────────────▼───────────── FastAPI ────────────────┐
|
| 34 |
│ /spots /forecast /score /ask │
|
| 35 |
├───────────────────────────────────────────────────────────────────────┤
|
| 36 |
+
│ agent.py — smolagents CodeAgent (streaming + trace) │
|
| 37 |
│ ↳ tools: get_forecast · score_week · find_spots(near/skill) │
|
| 38 |
│ get_spot_knowledge · rank_spots_this_week │
|
| 39 |
├───────────────────────────────────────────────────────────────────────┤
|
ROADMAP.md
CHANGED
|
@@ -8,7 +8,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 +
|
| 12 |
|
| 13 |
---
|
| 14 |
|
|
@@ -39,7 +39,7 @@ FastAPI + hand-rolled agent loop + Gradio + daggr (no LangChain, no htmx).
|
|
| 39 |
- 📹 **Capture:** score table / heatmap clip.
|
| 40 |
|
| 41 |
### Wed Sep 2 — Agent core
|
| 42 |
-
- `agent.py`:
|
| 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.
|
|
|
|
| 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 |
|
|
|
|
| 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.
|
pyproject.toml
CHANGED
|
@@ -13,6 +13,7 @@ dependencies = [
|
|
| 13 |
"huggingface-hub>=1.29.0",
|
| 14 |
"openai",
|
| 15 |
"pydantic",
|
|
|
|
| 16 |
]
|
| 17 |
|
| 18 |
[dependency-groups]
|
|
|
|
| 13 |
"huggingface-hub>=1.29.0",
|
| 14 |
"openai",
|
| 15 |
"pydantic",
|
| 16 |
+
"smolagents>=1.26.0",
|
| 17 |
]
|
| 18 |
|
| 19 |
[dependency-groups]
|
tests/test_agent.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for wavereader.agent — smolagents surf forecasting agent."""
|
| 2 |
+
|
| 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,
|
| 13 |
+
FindSpotsTool,
|
| 14 |
+
GetForecastTool,
|
| 15 |
+
GetSpotKnowledgeTool,
|
| 16 |
+
RankSpotsThisWeekTool,
|
| 17 |
+
ScoreWeekTool,
|
| 18 |
+
SurfAgent,
|
| 19 |
+
TraceEvent,
|
| 20 |
+
PROVIDER_HF,
|
| 21 |
+
PROVIDER_NIM,
|
| 22 |
+
DEFAULT_MODEL,
|
| 23 |
+
run_agent,
|
| 24 |
+
run_agent_stream,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
# Fixtures
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
|
| 32 |
+
@pytest.fixture(autouse=True)
|
| 33 |
+
def clear_env(monkeypatch):
|
| 34 |
+
"""Clear provider env vars for each test."""
|
| 35 |
+
monkeypatch.delenv("HF_TOKEN", raising=False)
|
| 36 |
+
monkeypatch.delenv("NVIDIA_API_KEY", raising=False)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@pytest.fixture
|
| 40 |
+
def mock_model():
|
| 41 |
+
"""Mock InferenceClientModel to avoid real API calls."""
|
| 42 |
+
with patch("wavereader.agent.InferenceClientModel") as mock:
|
| 43 |
+
# Configure the mock to return a MagicMock when instantiated
|
| 44 |
+
mock_instance = MagicMock()
|
| 45 |
+
mock.return_value = mock_instance
|
| 46 |
+
yield mock
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@pytest.fixture
|
| 50 |
+
def mock_build_model():
|
| 51 |
+
"""Mock _build_model to return a mock model instance."""
|
| 52 |
+
with patch("wavereader.agent._build_model") as mock:
|
| 53 |
+
mock_instance = MagicMock()
|
| 54 |
+
mock.return_value = mock_instance
|
| 55 |
+
yield mock
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
# TraceEvent / AgentTrace
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
def test_trace_event_creation():
|
| 63 |
+
evt = TraceEvent(type="tool_call", timestamp=123.0, data={"name": "test"})
|
| 64 |
+
assert evt.type == "tool_call"
|
| 65 |
+
assert evt.timestamp == 123.0
|
| 66 |
+
assert evt.data == {"name": "test"}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_agent_trace_add_event():
|
| 70 |
+
trace = AgentTrace()
|
| 71 |
+
trace.add_event("tool_call", {"name": "get_forecast", "args": {}})
|
| 72 |
+
assert len(trace.events) == 1
|
| 73 |
+
assert trace.events[0].type == "tool_call"
|
| 74 |
+
assert trace.events[0].data["name"] == "get_forecast"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_agent_trace_to_json():
|
| 78 |
+
trace = AgentTrace(question="test?", model="Qwen", provider="hf")
|
| 79 |
+
trace.add_event("llm_chunk", {"chunk": "hello"})
|
| 80 |
+
json_str = trace.to_json()
|
| 81 |
+
data = json.loads(json_str)
|
| 82 |
+
assert data["question"] == "test?"
|
| 83 |
+
assert data["model"] == "Qwen"
|
| 84 |
+
assert data["provider"] == "hf"
|
| 85 |
+
assert data["events"][0]["type"] == "llm_chunk"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ---------------------------------------------------------------------------
|
| 89 |
+
# Tools (unit test tool forward methods)
|
| 90 |
+
# ---------------------------------------------------------------------------
|
| 91 |
+
|
| 92 |
+
class TestGetForecastTool:
|
| 93 |
+
def test_forward_success(self):
|
| 94 |
+
tool = GetForecastTool()
|
| 95 |
+
with patch("wavereader.agent.get_forecast") as mock_get:
|
| 96 |
+
mock_get.return_value = {"hourly": [{"time": "2024-01-01T00:00", "wave_height": 1.5}]}
|
| 97 |
+
result = tool.forward("Snapper Rocks", "QLD")
|
| 98 |
+
assert result["hourly"][0]["wave_height"] == 1.5
|
| 99 |
+
mock_get.assert_called_once_with("Snapper Rocks", "QLD")
|
| 100 |
+
|
| 101 |
+
def test_forward_spot_not_found(self):
|
| 102 |
+
tool = GetForecastTool()
|
| 103 |
+
with patch("wavereader.agent.get_forecast") as mock_get:
|
| 104 |
+
mock_get.return_value = {"error": "Spot 'Bad' (XX) not found"}
|
| 105 |
+
result = tool.forward("Bad", "XX")
|
| 106 |
+
assert "error" in result
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class TestScoreWeekTool:
|
| 110 |
+
def test_forward_success(self):
|
| 111 |
+
tool = ScoreWeekTool()
|
| 112 |
+
with patch("wavereader.agent.score_week") as mock_score:
|
| 113 |
+
mock_score.return_value = [{"time": "2024-01-01T00:00", "score": 8.5}]
|
| 114 |
+
result = tool.forward("Snapper Rocks", "QLD")
|
| 115 |
+
assert result[0]["score"] == 8.5
|
| 116 |
+
mock_score.assert_called_once_with("Snapper Rocks", "QLD")
|
| 117 |
+
|
| 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:
|
| 127 |
+
def test_forward_with_query(self):
|
| 128 |
+
tool = FindSpotsTool()
|
| 129 |
+
with patch("wavereader.agent.find_spots") as mock_find:
|
| 130 |
+
mock_find.return_value = [{"name": "Snapper Rocks", "region": "QLD"}]
|
| 131 |
+
result = tool.forward(query="Snapper")
|
| 132 |
+
assert len(result) == 1
|
| 133 |
+
assert result[0]["name"] == "Snapper Rocks"
|
| 134 |
+
mock_find.assert_called_once_with(query="Snapper", skill=None, limit=10)
|
| 135 |
+
|
| 136 |
+
def test_forward_with_skill_and_limit(self):
|
| 137 |
+
tool = FindSpotsTool()
|
| 138 |
+
with patch("wavereader.agent.find_spots") as mock_find:
|
| 139 |
+
mock_find.return_value = []
|
| 140 |
+
tool.forward(query="", skill="beginner", limit=5)
|
| 141 |
+
mock_find.assert_called_once_with(query="", skill="beginner", limit=5)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
class TestGetSpotKnowledgeTool:
|
| 145 |
+
def test_forward_success(self):
|
| 146 |
+
tool = GetSpotKnowledgeTool()
|
| 147 |
+
with patch("wavereader.agent.get_spot_knowledge") as mock_get:
|
| 148 |
+
mock_get.return_value = {"name": "Bells Beach", "region": "VIC", "ideal_swell": {}}
|
| 149 |
+
result = tool.forward("Bells Beach", "VIC")
|
| 150 |
+
assert result["name"] == "Bells Beach"
|
| 151 |
+
mock_get.assert_called_once_with("Bells Beach", "VIC")
|
| 152 |
+
|
| 153 |
+
def test_forward_spot_not_found(self):
|
| 154 |
+
tool = GetSpotKnowledgeTool()
|
| 155 |
+
with patch("wavereader.agent.get_spot_knowledge") as mock_get:
|
| 156 |
+
mock_get.return_value = {"error": "Spot 'Bad' (XX) not found"}
|
| 157 |
+
result = tool.forward("Bad", "XX")
|
| 158 |
+
assert "error" in result
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
class TestRankSpotsThisWeekTool:
|
| 162 |
+
def test_forward_success(self):
|
| 163 |
+
tool = RankSpotsThisWeekTool()
|
| 164 |
+
with patch("wavereader.agent.rank_spots_this_week") as mock_rank:
|
| 165 |
+
mock_rank.return_value = [
|
| 166 |
+
{"name": "A", "region": "QLD", "best_score": 9.0},
|
| 167 |
+
{"name": "B", "region": "QLD", "best_score": 7.0},
|
| 168 |
+
]
|
| 169 |
+
result = tool.forward("QLD")
|
| 170 |
+
assert len(result) == 2
|
| 171 |
+
assert result[0]["best_score"] == 9.0
|
| 172 |
+
mock_rank.assert_called_once_with("QLD", None)
|
| 173 |
+
|
| 174 |
+
def test_forward_with_skill(self):
|
| 175 |
+
tool = RankSpotsThisWeekTool()
|
| 176 |
+
with patch("wavereader.agent.rank_spots_this_week") as mock_rank:
|
| 177 |
+
mock_rank.return_value = []
|
| 178 |
+
tool.forward("NSW", "advanced")
|
| 179 |
+
mock_rank.assert_called_once_with("NSW", "advanced")
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# ---------------------------------------------------------------------------
|
| 183 |
+
# SurfAgent initialization
|
| 184 |
+
# ---------------------------------------------------------------------------
|
| 185 |
+
|
| 186 |
+
class TestSurfAgentInit:
|
| 187 |
+
def test_init_with_explicit_provider_hf(self, mock_build_model):
|
| 188 |
+
agent = SurfAgent(provider=PROVIDER_HF)
|
| 189 |
+
assert agent.provider == PROVIDER_HF
|
| 190 |
+
mock_build_model.assert_called_once_with(PROVIDER_HF, DEFAULT_MODEL)
|
| 191 |
+
|
| 192 |
+
def test_init_with_explicit_provider_nim(self, mock_build_model):
|
| 193 |
+
agent = SurfAgent(provider=PROVIDER_NIM)
|
| 194 |
+
assert agent.provider == PROVIDER_NIM
|
| 195 |
+
mock_build_model.assert_called_once_with(PROVIDER_NIM, DEFAULT_MODEL)
|
| 196 |
+
|
| 197 |
+
def test_init_auto_detect_hf(self, mock_build_model, monkeypatch):
|
| 198 |
+
monkeypatch.setenv("HF_TOKEN", "test-token")
|
| 199 |
+
agent = SurfAgent(provider=None)
|
| 200 |
+
assert agent.provider == PROVIDER_HF
|
| 201 |
+
mock_build_model.assert_called_once_with(PROVIDER_HF, DEFAULT_MODEL)
|
| 202 |
+
|
| 203 |
+
def test_init_auto_detect_nim(self, mock_build_model, monkeypatch):
|
| 204 |
+
monkeypatch.setenv("NVIDIA_API_KEY", "test-key")
|
| 205 |
+
agent = SurfAgent(provider=None)
|
| 206 |
+
assert agent.provider == PROVIDER_NIM
|
| 207 |
+
mock_build_model.assert_called_once_with(PROVIDER_NIM, DEFAULT_MODEL)
|
| 208 |
+
|
| 209 |
+
def test_init_prefers_nim_over_hf(self, mock_build_model, monkeypatch):
|
| 210 |
+
monkeypatch.setenv("HF_TOKEN", "test-token")
|
| 211 |
+
monkeypatch.setenv("NVIDIA_API_KEY", "test-key")
|
| 212 |
+
agent = SurfAgent(provider=None)
|
| 213 |
+
assert agent.provider == PROVIDER_NIM
|
| 214 |
+
mock_build_model.assert_called_once_with(PROVIDER_NIM, DEFAULT_MODEL)
|
| 215 |
+
|
| 216 |
+
def test_init_unknown_provider_raises(self):
|
| 217 |
+
with pytest.raises(ValueError, match="Unknown provider"):
|
| 218 |
+
SurfAgent(provider="unknown")
|
| 219 |
+
|
| 220 |
+
def test_init_no_api_key_raises_hf(self, monkeypatch):
|
| 221 |
+
monkeypatch.delenv("HF_TOKEN", raising=False)
|
| 222 |
+
monkeypatch.delenv("NVIDIA_API_KEY", raising=False)
|
| 223 |
+
with pytest.raises(ValueError, match="HF_TOKEN"):
|
| 224 |
+
SurfAgent(provider=PROVIDER_HF)
|
| 225 |
+
|
| 226 |
+
def test_init_no_api_key_raises_nim(self, monkeypatch):
|
| 227 |
+
monkeypatch.delenv("HF_TOKEN", raising=False)
|
| 228 |
+
monkeypatch.delenv("NVIDIA_API_KEY", raising=False)
|
| 229 |
+
with pytest.raises(ValueError, match="NVIDIA_API_KEY"):
|
| 230 |
+
SurfAgent(provider=PROVIDER_NIM)
|
| 231 |
+
|
| 232 |
+
def test_init_custom_model(self, mock_build_model):
|
| 233 |
+
agent = SurfAgent(provider=PROVIDER_HF, model="custom/model")
|
| 234 |
+
assert agent.model == "custom/model"
|
| 235 |
+
mock_build_model.assert_called_once_with(PROVIDER_HF, "custom/model")
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
# ---------------------------------------------------------------------------
|
| 239 |
+
# SurfAgent run / trace capture
|
| 240 |
+
# ---------------------------------------------------------------------------
|
| 241 |
+
|
| 242 |
+
class TestSurfAgentRun:
|
| 243 |
+
def test_run_creates_trace(self, mock_build_model):
|
| 244 |
+
mock_agent = MagicMock()
|
| 245 |
+
mock_agent.run.return_value = "Test answer"
|
| 246 |
+
|
| 247 |
+
with patch("wavereader.agent.CodeAgent", return_value=mock_agent):
|
| 248 |
+
agent = SurfAgent(provider=PROVIDER_HF)
|
| 249 |
+
result = agent.run("What's the surf like?")
|
| 250 |
+
|
| 251 |
+
assert result == "Test answer"
|
| 252 |
+
assert agent.trace is not None
|
| 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 |
+
# ---------------------------------------------------------------------------
|
| 346 |
+
# Module-level convenience functions
|
| 347 |
+
# ---------------------------------------------------------------------------
|
| 348 |
+
|
| 349 |
+
def test_run_agent_function(mock_build_model):
|
| 350 |
+
mock_agent = MagicMock()
|
| 351 |
+
mock_agent.run.return_value = "Direct answer"
|
| 352 |
+
|
| 353 |
+
with patch("wavereader.agent.CodeAgent", return_value=mock_agent):
|
| 354 |
+
result = run_agent("Test question", provider=PROVIDER_HF)
|
| 355 |
+
|
| 356 |
+
assert result == "Direct answer"
|
| 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 |
+
# ---------------------------------------------------------------------------
|
| 370 |
+
# Provider constants
|
| 371 |
+
# ---------------------------------------------------------------------------
|
| 372 |
+
|
| 373 |
+
def test_provider_constants():
|
| 374 |
+
assert PROVIDER_HF == "hf"
|
| 375 |
+
assert PROVIDER_NIM == "nim"
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
if __name__ == "__main__":
|
| 379 |
+
pytest.main([__file__, "-v"])
|
tests/test_scoring.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for wavereader.scoring — deterministic surf-quality scoring."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from wavereader.scoring import (
|
| 8 |
+
_angular_diff,
|
| 9 |
+
_dir_to_deg,
|
| 10 |
+
_score_period,
|
| 11 |
+
_score_swell_direction,
|
| 12 |
+
_score_swell_size,
|
| 13 |
+
_score_wind,
|
| 14 |
+
rank_spots_this_week,
|
| 15 |
+
score_hour,
|
| 16 |
+
score_week,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
# Helpers
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
|
| 24 |
+
_SPOT = {
|
| 25 |
+
"name": "Test Spot",
|
| 26 |
+
"region": "QLD",
|
| 27 |
+
"ideal_swell": {"size_ft_min": 3, "size_ft_max": 8, "direction": "SE"},
|
| 28 |
+
"ideal_wind": {"strength_kt_max": 10, "direction": "S"},
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# Direction parsing
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
|
| 36 |
+
def test_dir_to_deg_simple():
|
| 37 |
+
assert _dir_to_deg("N") == 0
|
| 38 |
+
assert _dir_to_deg("E") == 90
|
| 39 |
+
assert _dir_to_deg("S") == 180
|
| 40 |
+
assert _dir_to_deg("W") == 270
|
| 41 |
+
assert _dir_to_deg("SW") == 225
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_dir_to_deg_compound():
|
| 45 |
+
# W/SW = midpoint of 270 and 225 = 247.5
|
| 46 |
+
assert _dir_to_deg("W/SW") == 247.5
|
| 47 |
+
# E/NE = midpoint of 90 and 45 = 67.5
|
| 48 |
+
assert _dir_to_deg("E/NE") == 67.5
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
# Angular diff
|
| 53 |
+
# ---------------------------------------------------------------------------
|
| 54 |
+
|
| 55 |
+
def test_angular_diff_zero():
|
| 56 |
+
assert _angular_diff(0, 0) == 0
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_angular_diff_symmetric():
|
| 60 |
+
assert _angular_diff(45, 315) == 90
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_angular_diff_wrap():
|
| 64 |
+
assert _angular_diff(350, 10) == 20
|
| 65 |
+
assert _angular_diff(10, 350) == 20
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
# Swell size scoring
|
| 70 |
+
# ---------------------------------------------------------------------------
|
| 71 |
+
|
| 72 |
+
def test_swell_size_in_ideal():
|
| 73 |
+
# 4 ft ideal → ~1.22 m; 5 ft → ~1.52 m
|
| 74 |
+
spot = {
|
| 75 |
+
"ideal_swell": {"size_ft_min": 4, "size_ft_max": 5, "direction": "SE"},
|
| 76 |
+
"ideal_wind": {"strength_kt_max": 10, "direction": "S"},
|
| 77 |
+
}
|
| 78 |
+
assert score_hour(1.4, 10, 5, 135, spot)["components"]["swell_size"] == 10.0
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_swell_size_outside_low():
|
| 82 |
+
spot = {
|
| 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 |
+
|
| 91 |
+
def test_swell_size_outside_high():
|
| 92 |
+
spot = {
|
| 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 |
+
|
| 109 |
+
# ---------------------------------------------------------------------------
|
| 110 |
+
# Swell direction scoring
|
| 111 |
+
# ---------------------------------------------------------------------------
|
| 112 |
+
|
| 113 |
+
def test_swell_direction_perfect_match():
|
| 114 |
+
spot = {
|
| 115 |
+
"ideal_swell": {"size_ft_min": 3, "size_ft_max": 8, "direction": "SE"},
|
| 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():
|
| 123 |
+
spot = {
|
| 124 |
+
"ideal_swell": {"size_ft_min": 3, "size_ft_max": 8, "direction": "SE"},
|
| 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 |
+
# ---------------------------------------------------------------------------
|
| 132 |
+
# Wind scoring
|
| 133 |
+
# ---------------------------------------------------------------------------
|
| 134 |
+
|
| 135 |
+
def test_wind_perfect_offshore():
|
| 136 |
+
spot = {
|
| 137 |
+
"ideal_wind": {"strength_kt_max": 10, "direction": "N"},
|
| 138 |
+
"ideal_swell": {"size_ft_min": 3, "size_ft_max": 8, "direction": "SE"},
|
| 139 |
+
}
|
| 140 |
+
# calm wind from N
|
| 141 |
+
s = score_hour(1.5, 10, 0, 0, spot)["components"]["wind"]
|
| 142 |
+
assert s == 10.0
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_wind_opposite_direction():
|
| 146 |
+
spot = {
|
| 147 |
+
"ideal_wind": {"strength_kt_max": 10, "direction": "N"},
|
| 148 |
+
"ideal_swell": {"size_ft_min": 3, "size_ft_max": 8, "direction": "SE"},
|
| 149 |
+
}
|
| 150 |
+
# strong wind from S (180° away from N)
|
| 151 |
+
s = score_hour(1.5, 10, 20, 180, spot)["components"]["wind"]
|
| 152 |
+
assert s == 0.0
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def test_wind_speed_capped():
|
| 156 |
+
spot = {
|
| 157 |
+
"ideal_wind": {"strength_kt_max": 10, "direction": "N"},
|
| 158 |
+
"ideal_swell": {"size_ft_min": 3, "size_ft_max": 8, "direction": "SE"},
|
| 159 |
+
}
|
| 160 |
+
# wind above max speed, perfect direction → speed=0, dir=10 → weighted 5.0
|
| 161 |
+
s = score_hour(1.5, 10, 12, 0, spot)["components"]["wind"]
|
| 162 |
+
assert s == 5.0
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# ---------------------------------------------------------------------------
|
| 166 |
+
# Period scoring
|
| 167 |
+
# ---------------------------------------------------------------------------
|
| 168 |
+
|
| 169 |
+
def test_period_short():
|
| 170 |
+
assert _score_period(4.0) == 0.0
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_period_great():
|
| 174 |
+
assert _score_period(14.0) == 10.0
|
| 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 |
+
|
| 183 |
+
# ---------------------------------------------------------------------------
|
| 184 |
+
# score_hour – composite & breakdown
|
| 185 |
+
# ---------------------------------------------------------------------------
|
| 186 |
+
|
| 187 |
+
def test_score_hour_shape():
|
| 188 |
+
result = score_hour(1.5, 10, 5, 135, _SPOT)
|
| 189 |
+
assert "score" in result
|
| 190 |
+
assert "components" in result
|
| 191 |
+
assert set(result["components"].keys()) == {
|
| 192 |
+
"swell_size", "swell_direction", "wind", "period"
|
| 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():
|
| 200 |
+
result = score_hour(1.5, 10, 5, 135, _SPOT)
|
| 201 |
+
assert result["wave_height_m"] == 1.5
|
| 202 |
+
assert result["wave_period_s"] == 10
|
| 203 |
+
assert result["wind_speed_kt"] == 5
|
| 204 |
+
assert result["wind_direction_deg"] == 135
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# ---------------------------------------------------------------------------
|
| 208 |
+
# Bells Beach sanity check (roadmap)
|
| 209 |
+
# ---------------------------------------------------------------------------
|
| 210 |
+
|
| 211 |
+
def test_bells_beach_swell_nw_wind_high_score():
|
| 212 |
+
"""Bells Beach should score well on SW swell + light NW wind."""
|
| 213 |
+
bells = {
|
| 214 |
+
"name": "Bells Beach",
|
| 215 |
+
"region": "VIC",
|
| 216 |
+
"ideal_swell": {"size_ft_min": 4, "size_ft_max": 12, "direction": "SW"},
|
| 217 |
+
"ideal_wind": {"strength_kt_max": 15, "direction": "NW"},
|
| 218 |
+
}
|
| 219 |
+
# SW swell = 225°, light NW wind = 315°, low speed
|
| 220 |
+
result = score_hour(2.0, 12, 5, 315, bells)
|
| 221 |
+
assert result["score"] > 6.0
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# ---------------------------------------------------------------------------
|
| 225 |
+
# Kirra sanity check (roadmap): long-period E
|
| 226 |
+
# ---------------------------------------------------------------------------
|
| 227 |
+
|
| 228 |
+
def test_kirra_long_period():
|
| 229 |
+
"""Kirra scores well on long-period swells."""
|
| 230 |
+
kirra = {
|
| 231 |
+
"name": "Kirra",
|
| 232 |
+
"region": "QLD",
|
| 233 |
+
"ideal_swell": {"size_ft_min": 3, "size_ft_max": 8, "direction": "SE"},
|
| 234 |
+
"ideal_wind": {"strength_kt_max": 10, "direction": "S"},
|
| 235 |
+
}
|
| 236 |
+
long_result = score_hour(1.5, 14, 5, 135, kirra)
|
| 237 |
+
short_result = score_hour(1.5, 6, 5, 135, kirra)
|
| 238 |
+
assert long_result["components"]["period"] > short_result["components"]["period"]
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
# ---------------------------------------------------------------------------
|
| 242 |
+
# score_week
|
| 243 |
+
# ---------------------------------------------------------------------------
|
| 244 |
+
|
| 245 |
+
def test_score_week_count():
|
| 246 |
+
forecast = {
|
| 247 |
+
"hourly": [
|
| 248 |
+
{
|
| 249 |
+
"time": "2024-01-01T00:00",
|
| 250 |
+
"wave_height": 1.5,
|
| 251 |
+
"wave_period": 10,
|
| 252 |
+
"wind_speed_10m": 10,
|
| 253 |
+
"wind_direction_10m": 135,
|
| 254 |
+
}
|
| 255 |
+
for _ in range(24)
|
| 256 |
+
]
|
| 257 |
+
}
|
| 258 |
+
results = score_week(forecast, _SPOT)
|
| 259 |
+
assert len(results) == 24
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def test_score_week_has_time_and_score():
|
| 263 |
+
forecast = {
|
| 264 |
+
"hourly": [
|
| 265 |
+
{
|
| 266 |
+
"time": "2024-01-01T00:00",
|
| 267 |
+
"wave_height": 1.5,
|
| 268 |
+
"wave_period": 10,
|
| 269 |
+
"wind_speed_10m": 10,
|
| 270 |
+
"wind_direction_10m": 135,
|
| 271 |
+
}
|
| 272 |
+
]
|
| 273 |
+
}
|
| 274 |
+
results = score_week(forecast, _SPOT)
|
| 275 |
+
assert results[0]["time"] == "2024-01-01T00:00"
|
| 276 |
+
assert "score" in results[0]
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
# ---------------------------------------------------------------------------
|
| 280 |
+
# rank_spots_this_week
|
| 281 |
+
# ---------------------------------------------------------------------------
|
| 282 |
+
|
| 283 |
+
def test_rank_spots_descending():
|
| 284 |
+
spot_a = {
|
| 285 |
+
"name": "A",
|
| 286 |
+
"region": "QLD",
|
| 287 |
+
"ideal_swell": {"size_ft_min": 3, "size_ft_max": 8, "direction": "SE"},
|
| 288 |
+
"ideal_wind": {"strength_kt_max": 10, "direction": "S"},
|
| 289 |
+
}
|
| 290 |
+
spot_b = {
|
| 291 |
+
"name": "B",
|
| 292 |
+
"region": "NSW",
|
| 293 |
+
"ideal_swell": {"size_ft_min": 2, "size_ft_max": 4, "direction": "SE"},
|
| 294 |
+
"ideal_wind": {"strength_kt_max": 10, "direction": "S"},
|
| 295 |
+
}
|
| 296 |
+
base_forecast = {
|
| 297 |
+
"hourly": [
|
| 298 |
+
{
|
| 299 |
+
"time": "2024-01-01T00:00",
|
| 300 |
+
"wave_height": 1.5,
|
| 301 |
+
"wave_period": 10,
|
| 302 |
+
"wind_speed_10m": 10,
|
| 303 |
+
"wind_direction_10m": 135,
|
| 304 |
+
}
|
| 305 |
+
]
|
| 306 |
+
}
|
| 307 |
+
forecasts = {
|
| 308 |
+
("A", "QLD"): base_forecast,
|
| 309 |
+
("B", "NSW"): base_forecast,
|
| 310 |
+
}
|
| 311 |
+
# A gets a higher score because its ideal range includes 1.5m better
|
| 312 |
+
ranked = rank_spots_this_week(forecasts, [spot_a, spot_b])
|
| 313 |
+
assert len(ranked) == 2
|
| 314 |
+
assert ranked[0]["best_score"] >= ranked[1]["best_score"]
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def test_rank_spots_empty():
|
| 318 |
+
ranked = rank_spots_this_week({}, [])
|
| 319 |
+
assert ranked == []
|
tests/test_spots.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for wavereader.spots — data layer."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from wavereader.spots import (
|
| 8 |
+
AdditionalDetails,
|
| 9 |
+
Break,
|
| 10 |
+
Coordinates,
|
| 11 |
+
IdealSwell,
|
| 12 |
+
IdealWind,
|
| 13 |
+
find_spots,
|
| 14 |
+
get_spot,
|
| 15 |
+
load_breaks,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
# load_breaks
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
|
| 23 |
+
def test_load_breaks_count():
|
| 24 |
+
breaks = load_breaks()
|
| 25 |
+
assert len(breaks) == 100
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_load_breaks_all_have_fields():
|
| 29 |
+
breaks = load_breaks()
|
| 30 |
+
for b in breaks:
|
| 31 |
+
assert b.name
|
| 32 |
+
assert b.region
|
| 33 |
+
assert b.coordinates.lat is not None
|
| 34 |
+
assert b.coordinates.lng is not None
|
| 35 |
+
assert b.ideal_swell.size_ft_min < b.ideal_swell.size_ft_max
|
| 36 |
+
assert b.ideal_wind.strength_kt_max > 0
|
| 37 |
+
assert b.additional_details.skill_level
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
# find_spots
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
|
| 44 |
+
def test_find_spots_by_name():
|
| 45 |
+
results = find_spots(query="Snapper")
|
| 46 |
+
assert all("Snapper" in r.name for r in results)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_find_spots_by_region():
|
| 50 |
+
results = find_spots(region="QLD")
|
| 51 |
+
assert all(r.region == "QLD" for r in results)
|
| 52 |
+
assert len(results) > 0
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_find_spots_by_skill():
|
| 56 |
+
results = find_spots(region="QLD", skill="beginner")
|
| 57 |
+
assert all("beginner" in r.additional_details.skill_level.lower() for r in results)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_find_spots_limits():
|
| 61 |
+
results = find_spots(query="", region="NSW", limit=3)
|
| 62 |
+
assert len(results) <= 3
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_find_spots_empty_query_returns_all():
|
| 66 |
+
results = find_spots(query="", limit=100)
|
| 67 |
+
assert len(results) == 100
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_find_spots_no_match():
|
| 71 |
+
results = find_spots(query="NonexistentBreakXyz")
|
| 72 |
+
assert results == []
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ---------------------------------------------------------------------------
|
| 76 |
+
# get_spot
|
| 77 |
+
# ---------------------------------------------------------------------------
|
| 78 |
+
|
| 79 |
+
def test_get_spot_existing():
|
| 80 |
+
spot = get_spot("Snapper Rocks", "QLD")
|
| 81 |
+
assert spot is not None
|
| 82 |
+
assert spot.name == "Snapper Rocks"
|
| 83 |
+
assert spot.region == "QLD"
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_get_spot_missing():
|
| 87 |
+
spot = get_spot("Nonexistent", "XX")
|
| 88 |
+
assert spot is None
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# ---------------------------------------------------------------------------
|
| 92 |
+
# Pydantic validation
|
| 93 |
+
# ---------------------------------------------------------------------------
|
| 94 |
+
|
| 95 |
+
def test_break_round_trip():
|
| 96 |
+
b = load_breaks()[0]
|
| 97 |
+
data = b.model_dump()
|
| 98 |
+
restored = Break.model_validate(data)
|
| 99 |
+
assert restored.name == b.name
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_break_invalid_region():
|
| 103 |
+
with pytest.raises(Exception):
|
| 104 |
+
Break.model_validate({
|
| 105 |
+
"name": "Bad",
|
| 106 |
+
"region": "XX",
|
| 107 |
+
"short_description": "x",
|
| 108 |
+
"coordinates": {"lat": -33.0, "lng": 151.0},
|
| 109 |
+
"ideal_swell": {"size_ft_min": 2.0, "size_ft_max": 5.0, "direction": "SE"},
|
| 110 |
+
"ideal_wind": {"strength_kt_max": 15.0, "direction": "SW"},
|
| 111 |
+
"ideal_tide": "mid",
|
| 112 |
+
"additional_details": {
|
| 113 |
+
"break_type": "beach",
|
| 114 |
+
"break_direction": "left",
|
| 115 |
+
"break_surface": "sand",
|
| 116 |
+
"bottom_type": "sand",
|
| 117 |
+
"skill_level": "beginner",
|
| 118 |
+
"best_season": "summer",
|
| 119 |
+
"hazards": "rips",
|
| 120 |
+
"other_notes": "x",
|
| 121 |
+
},
|
| 122 |
+
})
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_break_invalid_coords():
|
| 126 |
+
with pytest.raises(Exception):
|
| 127 |
+
Break.model_validate({
|
| 128 |
+
"name": "Bad",
|
| 129 |
+
"region": "NSW",
|
| 130 |
+
"short_description": "x",
|
| 131 |
+
"coordinates": {"lat": 0.0, "lng": 0.0},
|
| 132 |
+
"ideal_swell": {"size_ft_min": 2.0, "size_ft_max": 5.0, "direction": "SE"},
|
| 133 |
+
"ideal_wind": {"strength_kt_max": 15.0, "direction": "SW"},
|
| 134 |
+
"ideal_tide": "mid",
|
| 135 |
+
"additional_details": {
|
| 136 |
+
"break_type": "beach",
|
| 137 |
+
"break_direction": "left",
|
| 138 |
+
"break_surface": "sand",
|
| 139 |
+
"bottom_type": "sand",
|
| 140 |
+
"skill_level": "beginner",
|
| 141 |
+
"best_season": "summer",
|
| 142 |
+
"hazards": "rips",
|
| 143 |
+
"other_notes": "x",
|
| 144 |
+
},
|
| 145 |
+
})
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_break_swell_min_lt_max():
|
| 149 |
+
with pytest.raises(Exception):
|
| 150 |
+
Break.model_validate({
|
| 151 |
+
"name": "Bad",
|
| 152 |
+
"region": "NSW",
|
| 153 |
+
"short_description": "x",
|
| 154 |
+
"coordinates": {"lat": -33.0, "lng": 151.0},
|
| 155 |
+
"ideal_swell": {"size_ft_min": 5.0, "size_ft_max": 3.0, "direction": "SE"},
|
| 156 |
+
"ideal_wind": {"strength_kt_max": 15.0, "direction": "SW"},
|
| 157 |
+
"ideal_tide": "mid",
|
| 158 |
+
"additional_details": {
|
| 159 |
+
"break_type": "beach",
|
| 160 |
+
"break_direction": "left",
|
| 161 |
+
"break_surface": "sand",
|
| 162 |
+
"bottom_type": "sand",
|
| 163 |
+
"skill_level": "beginner",
|
| 164 |
+
"best_season": "summer",
|
| 165 |
+
"hazards": "rips",
|
| 166 |
+
"other_notes": "x",
|
| 167 |
+
},
|
| 168 |
+
})
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ---------------------------------------------------------------------------
|
| 172 |
+
# Region bounding-box validation
|
| 173 |
+
# ---------------------------------------------------------------------------
|
| 174 |
+
|
| 175 |
+
def test_bells_beach_vic():
|
| 176 |
+
"""Bells Beach is the VIC validation target from the roadmap."""
|
| 177 |
+
b = get_spot("Bells Beach", "VIC")
|
| 178 |
+
assert b is not None
|
| 179 |
+
assert -39.0 <= b.coordinates.lat <= -36.0
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def test_snapper_rocks_qld():
|
| 183 |
+
b = get_spot("Snapper Rocks", "QLD")
|
| 184 |
+
assert b is not None
|
| 185 |
+
assert -29.0 <= b.coordinates.lat <= -10.0
|
uv.lock
CHANGED
|
@@ -156,6 +156,137 @@ wheels = [
|
|
| 156 |
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
| 157 |
]
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
[[package]]
|
| 160 |
name = "click"
|
| 161 |
version = "8.5.0"
|
|
@@ -1515,6 +1646,21 @@ wheels = [
|
|
| 1515 |
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
| 1516 |
]
|
| 1517 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1518 |
[[package]]
|
| 1519 |
name = "rich"
|
| 1520 |
version = "15.0.0"
|
|
@@ -1698,6 +1844,23 @@ wheels = [
|
|
| 1698 |
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
| 1699 |
]
|
| 1700 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1701 |
[[package]]
|
| 1702 |
name = "sniffio"
|
| 1703 |
version = "1.3.1"
|
|
@@ -1958,6 +2121,7 @@ dependencies = [
|
|
| 1958 |
{ name = "huggingface-hub" },
|
| 1959 |
{ name = "openai" },
|
| 1960 |
{ name = "pydantic" },
|
|
|
|
| 1961 |
]
|
| 1962 |
|
| 1963 |
[package.dev-dependencies]
|
|
@@ -1975,6 +2139,7 @@ requires-dist = [
|
|
| 1975 |
{ name = "huggingface-hub", specifier = ">=1.29.0" },
|
| 1976 |
{ name = "openai" },
|
| 1977 |
{ name = "pydantic" },
|
|
|
|
| 1978 |
]
|
| 1979 |
|
| 1980 |
[package.metadata.requires-dev]
|
|
|
|
| 156 |
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
| 157 |
]
|
| 158 |
|
| 159 |
+
[[package]]
|
| 160 |
+
name = "charset-normalizer"
|
| 161 |
+
version = "3.5.1"
|
| 162 |
+
source = { registry = "https://pypi.org/simple" }
|
| 163 |
+
sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
|
| 164 |
+
wheels = [
|
| 165 |
+
{ url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" },
|
| 166 |
+
{ url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" },
|
| 167 |
+
{ url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" },
|
| 168 |
+
{ url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" },
|
| 169 |
+
{ url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" },
|
| 170 |
+
{ url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" },
|
| 171 |
+
{ url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" },
|
| 172 |
+
{ url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" },
|
| 173 |
+
{ url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" },
|
| 174 |
+
{ url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" },
|
| 175 |
+
{ url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" },
|
| 176 |
+
{ url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" },
|
| 177 |
+
{ url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" },
|
| 178 |
+
{ url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" },
|
| 179 |
+
{ url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" },
|
| 180 |
+
{ url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" },
|
| 181 |
+
{ url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" },
|
| 182 |
+
{ url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" },
|
| 183 |
+
{ url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" },
|
| 184 |
+
{ url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" },
|
| 185 |
+
{ url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" },
|
| 186 |
+
{ url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" },
|
| 187 |
+
{ url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" },
|
| 188 |
+
{ url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" },
|
| 189 |
+
{ url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" },
|
| 190 |
+
{ url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" },
|
| 191 |
+
{ url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" },
|
| 192 |
+
{ url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" },
|
| 193 |
+
{ url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" },
|
| 194 |
+
{ url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" },
|
| 195 |
+
{ url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" },
|
| 196 |
+
{ url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" },
|
| 197 |
+
{ url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" },
|
| 198 |
+
{ url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" },
|
| 199 |
+
{ url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" },
|
| 200 |
+
{ url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" },
|
| 201 |
+
{ url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" },
|
| 202 |
+
{ url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" },
|
| 203 |
+
{ url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" },
|
| 204 |
+
{ url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" },
|
| 205 |
+
{ url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" },
|
| 206 |
+
{ url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" },
|
| 207 |
+
{ url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" },
|
| 208 |
+
{ url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" },
|
| 209 |
+
{ url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" },
|
| 210 |
+
{ url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" },
|
| 211 |
+
{ url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" },
|
| 212 |
+
{ url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" },
|
| 213 |
+
{ url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" },
|
| 214 |
+
{ url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" },
|
| 215 |
+
{ url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" },
|
| 216 |
+
{ url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" },
|
| 217 |
+
{ url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" },
|
| 218 |
+
{ url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" },
|
| 219 |
+
{ url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" },
|
| 220 |
+
{ url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" },
|
| 221 |
+
{ url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" },
|
| 222 |
+
{ url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" },
|
| 223 |
+
{ url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" },
|
| 224 |
+
{ url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" },
|
| 225 |
+
{ url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" },
|
| 226 |
+
{ url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" },
|
| 227 |
+
{ url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" },
|
| 228 |
+
{ url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" },
|
| 229 |
+
{ url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" },
|
| 230 |
+
{ url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" },
|
| 231 |
+
{ url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" },
|
| 232 |
+
{ url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" },
|
| 233 |
+
{ url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" },
|
| 234 |
+
{ url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" },
|
| 235 |
+
{ url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" },
|
| 236 |
+
{ url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" },
|
| 237 |
+
{ url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" },
|
| 238 |
+
{ url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" },
|
| 239 |
+
{ url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" },
|
| 240 |
+
{ url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" },
|
| 241 |
+
{ url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" },
|
| 242 |
+
{ url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" },
|
| 243 |
+
{ url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" },
|
| 244 |
+
{ url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" },
|
| 245 |
+
{ url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" },
|
| 246 |
+
{ url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" },
|
| 247 |
+
{ url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" },
|
| 248 |
+
{ url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" },
|
| 249 |
+
{ url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" },
|
| 250 |
+
{ url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" },
|
| 251 |
+
{ url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" },
|
| 252 |
+
{ url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" },
|
| 253 |
+
{ url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" },
|
| 254 |
+
{ url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" },
|
| 255 |
+
{ url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" },
|
| 256 |
+
{ url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" },
|
| 257 |
+
{ url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" },
|
| 258 |
+
{ url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" },
|
| 259 |
+
{ url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" },
|
| 260 |
+
{ url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" },
|
| 261 |
+
{ url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" },
|
| 262 |
+
{ url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" },
|
| 263 |
+
{ url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" },
|
| 264 |
+
{ url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" },
|
| 265 |
+
{ url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" },
|
| 266 |
+
{ url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" },
|
| 267 |
+
{ url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" },
|
| 268 |
+
{ url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" },
|
| 269 |
+
{ url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" },
|
| 270 |
+
{ url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" },
|
| 271 |
+
{ url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
|
| 272 |
+
{ url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
|
| 273 |
+
{ url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
|
| 274 |
+
{ url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
|
| 275 |
+
{ url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
|
| 276 |
+
{ url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
|
| 277 |
+
{ url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
|
| 278 |
+
{ url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
|
| 279 |
+
{ url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
|
| 280 |
+
{ url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
|
| 281 |
+
{ url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
|
| 282 |
+
{ url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
|
| 283 |
+
{ url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
|
| 284 |
+
{ url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
|
| 285 |
+
{ url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
|
| 286 |
+
{ url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
|
| 287 |
+
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
|
| 288 |
+
]
|
| 289 |
+
|
| 290 |
[[package]]
|
| 291 |
name = "click"
|
| 292 |
version = "8.5.0"
|
|
|
|
| 1646 |
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
| 1647 |
]
|
| 1648 |
|
| 1649 |
+
[[package]]
|
| 1650 |
+
name = "requests"
|
| 1651 |
+
version = "2.34.2"
|
| 1652 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1653 |
+
dependencies = [
|
| 1654 |
+
{ name = "certifi" },
|
| 1655 |
+
{ name = "charset-normalizer" },
|
| 1656 |
+
{ name = "idna" },
|
| 1657 |
+
{ name = "urllib3" },
|
| 1658 |
+
]
|
| 1659 |
+
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
| 1660 |
+
wheels = [
|
| 1661 |
+
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
| 1662 |
+
]
|
| 1663 |
+
|
| 1664 |
[[package]]
|
| 1665 |
name = "rich"
|
| 1666 |
version = "15.0.0"
|
|
|
|
| 1844 |
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
| 1845 |
]
|
| 1846 |
|
| 1847 |
+
[[package]]
|
| 1848 |
+
name = "smolagents"
|
| 1849 |
+
version = "1.26.0"
|
| 1850 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1851 |
+
dependencies = [
|
| 1852 |
+
{ name = "huggingface-hub" },
|
| 1853 |
+
{ name = "jinja2" },
|
| 1854 |
+
{ name = "pillow" },
|
| 1855 |
+
{ name = "python-dotenv" },
|
| 1856 |
+
{ name = "requests" },
|
| 1857 |
+
{ name = "rich" },
|
| 1858 |
+
]
|
| 1859 |
+
sdist = { url = "https://files.pythonhosted.org/packages/bd/85/3ca67bb57743434ac321821ea54cbdbf0d3dcc8d55f37199709e83141340/smolagents-1.26.0.tar.gz", hash = "sha256:4ec92313265f9cfbcabfc88e192b4bc4505f8475dc5f33dc872062fc567037bd", size = 239034, upload-time = "2026-05-29T05:08:44.732Z" }
|
| 1860 |
+
wheels = [
|
| 1861 |
+
{ url = "https://files.pythonhosted.org/packages/89/8c/72bd5edc13288e3f27d4d9c1ef65adc0a68c950ee1fc6d3be270e1110f6c/smolagents-1.26.0-py3-none-any.whl", hash = "sha256:70e1cfb1576f782da93190ee31d9bb2659e5ca4bd84fda0c412e1f20498f28b6", size = 161466, upload-time = "2026-05-29T05:08:43.28Z" },
|
| 1862 |
+
]
|
| 1863 |
+
|
| 1864 |
[[package]]
|
| 1865 |
name = "sniffio"
|
| 1866 |
version = "1.3.1"
|
|
|
|
| 2121 |
{ name = "huggingface-hub" },
|
| 2122 |
{ name = "openai" },
|
| 2123 |
{ name = "pydantic" },
|
| 2124 |
+
{ name = "smolagents" },
|
| 2125 |
]
|
| 2126 |
|
| 2127 |
[package.dev-dependencies]
|
|
|
|
| 2139 |
{ name = "huggingface-hub", specifier = ">=1.29.0" },
|
| 2140 |
{ name = "openai" },
|
| 2141 |
{ name = "pydantic" },
|
| 2142 |
+
{ name = "smolagents", specifier = ">=1.26.0" },
|
| 2143 |
]
|
| 2144 |
|
| 2145 |
[package.metadata.requires-dev]
|
wavereader/agent.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
Provider-agnostic:
|
| 4 |
-
|
| 5 |
``NVIDIA_API_KEY`` -> https://integrate.api.nvidia.com/v1). Default model:
|
| 6 |
``Qwen/Qwen3-Next-80B-A3B-Instruct`` (only tested model supporting both
|
| 7 |
OpenAI-style tool calling and strict ``json_schema`` on the HF router — see
|
|
@@ -14,11 +14,274 @@ trace panel.
|
|
| 14 |
|
| 15 |
from __future__ import annotations
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
|
|
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""smolagents CodeAgent for surf forecasting.
|
| 2 |
|
| 3 |
+
Provider-agnostic: uses InferenceClientModel with base URL + key from env
|
| 4 |
+
(``HF_TOKEN`` -> https://router.huggingface.co/v1,
|
| 5 |
``NVIDIA_API_KEY`` -> https://integrate.api.nvidia.com/v1). Default model:
|
| 6 |
``Qwen/Qwen3-Next-80B-A3B-Instruct`` (only tested model supporting both
|
| 7 |
OpenAI-style tool calling and strict ``json_schema`` on the HF router — see
|
|
|
|
| 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,
|
| 31 |
+
get_spot_knowledge,
|
| 32 |
+
rank_spots_this_week,
|
| 33 |
+
score_week,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
SYSTEM_PROMPT = """You are a surf forecasting assistant for Australian breaks.
|
| 37 |
+
|
| 38 |
+
IMPORTANT RULES:
|
| 39 |
+
1. You NEVER compute scores, forecasts, or rankings yourself. All numbers MUST come from the provided tools.
|
| 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"
|
| 50 |
+
PROVIDER_NIM = "nim"
|
| 51 |
+
|
| 52 |
+
DEFAULT_MODEL = "Qwen/Qwen3-Next-80B-A3B-Instruct"
|
| 53 |
+
HF_BASE_URL = "https://router.huggingface.co/v1"
|
| 54 |
+
NIM_BASE_URL = "https://integrate.api.nvidia.com/v1"
|
| 55 |
+
|
| 56 |
+
|
| 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 |
+
|
| 64 |
+
|
| 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):
|
| 90 |
+
name = "get_forecast"
|
| 91 |
+
description = "Get hourly marine + wind forecast for a named spot."
|
| 92 |
+
inputs = {
|
| 93 |
+
"spot_name": {"type": "string", "description": "Name of the surf spot (e.g., 'Snapper Rocks')"},
|
| 94 |
+
"region": {"type": "string", "description": "Australian state/region (NSW, QLD, VIC, WA, SA, TAS)"},
|
| 95 |
+
}
|
| 96 |
+
output_type = "object"
|
| 97 |
+
|
| 98 |
+
def forward(self, spot_name: str, region: str) -> dict:
|
| 99 |
+
return get_forecast(spot_name, region)
|
| 100 |
+
|
| 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"},
|
| 108 |
+
}
|
| 109 |
+
output_type = "object"
|
| 110 |
+
|
| 111 |
+
def forward(self, spot_name: str, region: str) -> list[dict]:
|
| 112 |
+
return score_week(spot_name, region)
|
| 113 |
+
|
| 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):
|
| 130 |
+
name = "get_spot_knowledge"
|
| 131 |
+
description = "Get knowledge-base profile for a spot (ideal swell/wind/tide, skill level, hazards)."
|
| 132 |
+
inputs = {
|
| 133 |
+
"spot_name": {"type": "string", "description": "Name of the surf spot"},
|
| 134 |
+
"region": {"type": "string", "description": "Australian state/region"},
|
| 135 |
+
}
|
| 136 |
+
output_type = "object"
|
| 137 |
+
|
| 138 |
+
def forward(self, spot_name: str, region: str) -> dict:
|
| 139 |
+
return get_spot_knowledge(spot_name, region)
|
| 140 |
+
|
| 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},
|
| 148 |
+
}
|
| 149 |
+
output_type = "object"
|
| 150 |
+
|
| 151 |
+
def forward(self, region: str, skill: str | None = None) -> list[dict]:
|
| 152 |
+
return rank_spots_this_week(region, skill)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
TOOLS = [
|
| 156 |
+
GetForecastTool(),
|
| 157 |
+
ScoreWeekTool(),
|
| 158 |
+
FindSpotsTool(),
|
| 159 |
+
GetSpotKnowledgeTool(),
|
| 160 |
+
RankSpotsThisWeekTool(),
|
| 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 |
+
|
| 199 |
+
class SurfAgent:
|
| 200 |
+
"""Surf forecasting agent with streaming and trace capture."""
|
| 201 |
+
|
| 202 |
+
def __init__(
|
| 203 |
+
self,
|
| 204 |
+
provider: str | None = None,
|
| 205 |
+
model: str | None = None,
|
| 206 |
+
):
|
| 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)
|
| 215 |
+
self._agent = CodeAgent(
|
| 216 |
+
tools=TOOLS,
|
| 217 |
+
model=self._model,
|
| 218 |
+
prompt_templates=prompt_templates,
|
| 219 |
+
max_steps=10,
|
| 220 |
+
)
|
| 221 |
+
self._trace: AgentTrace | None = None
|
| 222 |
+
|
| 223 |
+
@property
|
| 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)
|
| 278 |
+
|
| 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()
|
wavereader/forecasts.py
CHANGED
|
@@ -2,28 +2,32 @@
|
|
| 2 |
|
| 3 |
Two endpoints, merged on ``time``:
|
| 4 |
|
| 5 |
-
* ``marine-api.open-meteo.com`` — hourly ``wave_height`` (m),
|
| 6 |
-
(s), ``wave_direction`` (deg), ``
|
| 7 |
-
``
|
| 8 |
-
entries, no nulls, ~1.3 m @ ~6 s from ~SE. The marine endpoint
|
| 9 |
-
serve wind fields.
|
| 10 |
-
* ``api.open-meteo.com`` — hourly ``wind_speed_10m`` (km/h; convert to
|
| 11 |
-
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 |
-
the offset consistent across all spots.
|
| 16 |
|
| 17 |
-
Cache every response to disk: the demo must survive API flakiness and
|
| 18 |
-
limits. Horizon: hourly, 7 days.
|
| 19 |
"""
|
| 20 |
|
| 21 |
from __future__ import annotations
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
MARINE_URL = "https://marine-api.open-meteo.com/v1/marine"
|
| 24 |
WEATHER_URL = "https://api.open-meteo.com/v1/forecast"
|
| 25 |
|
| 26 |
-
# Marine hourly fields verified against the live API 2026-08-30.
|
| 27 |
MARINE_HOURLY_FIELDS = (
|
| 28 |
"wave_height",
|
| 29 |
"wave_period",
|
|
@@ -31,16 +35,102 @@ MARINE_HOURLY_FIELDS = (
|
|
| 31 |
"wind_wave_height",
|
| 32 |
"swell_wave_height",
|
| 33 |
)
|
| 34 |
-
WEATHER_HOURLY_FIELDS = (
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
def get_forecast(lat: float, lon: float, days: int = 7) -> dict:
|
| 41 |
"""Fetch marine + weather hourly data for a coordinate, merged on time.
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
"""
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
Two endpoints, merged on ``time``:
|
| 4 |
|
| 5 |
+
* ``marine-api.open-meteo.com`` — hourly ``wave_height`` (m),
|
| 6 |
+
``wave_period`` (s), ``wave_direction`` (deg), ``wind_wave_height`` (m),
|
| 7 |
+
``swell_wave_height`` (m). Verified 2026-08-30 at Snapper Rocks: 48
|
| 8 |
+
hourly entries, no nulls, ~1.3 m @ ~6 s from ~SE. The marine endpoint
|
| 9 |
+
does NOT serve wind fields.
|
| 10 |
+
* ``api.open-meteo.com`` — hourly ``wind_speed_10m`` (km/h; convert to
|
| 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
|
| 27 |
+
|
| 28 |
MARINE_URL = "https://marine-api.open-meteo.com/v1/marine"
|
| 29 |
WEATHER_URL = "https://api.open-meteo.com/v1/forecast"
|
| 30 |
|
|
|
|
| 31 |
MARINE_HOURLY_FIELDS = (
|
| 32 |
"wave_height",
|
| 33 |
"wave_period",
|
|
|
|
| 35 |
"wind_wave_height",
|
| 36 |
"swell_wave_height",
|
| 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:
|
| 72 |
+
"""Merge marine + weather hourly on ``time`` into a clean frame."""
|
| 73 |
+
m = marine["hourly"]
|
| 74 |
+
w = weather["hourly"]
|
| 75 |
+
marine_times = m["time"]
|
| 76 |
+
|
| 77 |
+
weather_index: dict[str, dict[str, float]] = {}
|
| 78 |
+
for i, t in enumerate(w["time"]):
|
| 79 |
+
entry: dict[str, float] = {"time": t}
|
| 80 |
+
for field in WEATHER_HOURLY_FIELDS:
|
| 81 |
+
if field in w and i < len(w[field]):
|
| 82 |
+
entry[field] = w[field][i]
|
| 83 |
+
weather_index[t] = entry
|
| 84 |
+
|
| 85 |
+
frames: list[dict] = []
|
| 86 |
+
for i, t in enumerate(marine_times):
|
| 87 |
+
row: dict[str, float | str] = {"time": t}
|
| 88 |
+
for field in MARINE_HOURLY_FIELDS:
|
| 89 |
+
if field in m and i < len(m[field]):
|
| 90 |
+
row[field] = m[field][i]
|
| 91 |
+
wr = weather_index.get(t)
|
| 92 |
+
if wr:
|
| 93 |
+
for field in WEATHER_HOURLY_FIELDS:
|
| 94 |
+
if field in wr:
|
| 95 |
+
row[field] = wr[field]
|
| 96 |
+
frames.append(row)
|
| 97 |
+
|
| 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,
|
| 115 |
+
"forecast_days": days,
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
|
| 119 |
+
resp_m = client.get(MARINE_URL, params={**params, "hourly": ",".join(MARINE_HOURLY_FIELDS)})
|
| 120 |
+
resp_m.raise_for_status()
|
| 121 |
+
marine = resp_m.json()
|
| 122 |
+
resp_w = client.get(WEATHER_URL, params={**params, "hourly": ",".join(WEATHER_HOURLY_FIELDS)})
|
| 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])
|
wavereader/scoring.py
CHANGED
|
@@ -2,13 +2,129 @@
|
|
| 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,
|
| 6 |
-
qualitative only — Open-Meteo has no
|
| 7 |
-
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
def score_hour(
|
| 14 |
wave_height_m: float,
|
|
@@ -19,16 +135,78 @@ def score_hour(
|
|
| 19 |
) -> dict:
|
| 20 |
"""Score one hour against a spot's ideal profile.
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
breakdown dict for the UI.
|
| 25 |
"""
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
def score_week(forecast: dict, spot: dict) -> list[dict]:
|
| 30 |
"""Score every hour of a 7-day forecast for one spot.
|
| 31 |
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
"""
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 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
|
wavereader/spots.py
CHANGED
|
@@ -3,31 +3,148 @@
|
|
| 3 |
Source of truth is ``data/break_details.json`` (records keyed by
|
| 4 |
``(name, region)``; identity fields anchored to ``data/breaks.json``).
|
| 5 |
|
| 6 |
-
Validation pass lives here rather than hand-edits: coords plausible for
|
| 7 |
-
named region, ranges sane, spot-check ~10 records against local
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
name: str
|
| 21 |
region: str
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
"""Load and validate ``data/break_details.json``.
|
| 26 |
|
| 27 |
-
|
| 28 |
-
sane ranges), raise on failure.
|
| 29 |
"""
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
def find_spots(
|
|
@@ -35,19 +152,32 @@ def find_spots(
|
|
| 35 |
region: str | None = None,
|
| 36 |
skill: str | None = None,
|
| 37 |
limit: int = 10,
|
| 38 |
-
) -> list[
|
| 39 |
-
"""Search breaks by name/
|
| 40 |
-
|
| 41 |
-
TODO: case-insensitive substring match over name/region; skill filter
|
| 42 |
-
from knowledge-base suitability. Used by the find_spots tool and
|
| 43 |
-
/spots endpoint.
|
| 44 |
-
"""
|
| 45 |
-
raise NotImplementedError
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def get_spot(spot: Spot) -> dict:
|
| 49 |
-
"""Return the full knowledge-base record for one break.
|
| 50 |
|
| 51 |
-
|
| 52 |
"""
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
Source of truth is ``data/break_details.json`` (records keyed by
|
| 4 |
``(name, region)``; identity fields anchored to ``data/breaks.json``).
|
| 5 |
|
| 6 |
+
Validation pass lives here rather than hand-edits: coords plausible for
|
| 7 |
+
the named region, ranges sane, spot-check ~10 records against local
|
| 8 |
+
knowledge.
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
+
import json
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
from pydantic import BaseModel, field_validator, model_validator
|
| 17 |
+
|
| 18 |
+
DATA_PATH = Path(__file__).resolve().parent.parent / "data" / "break_details.json"
|
| 19 |
+
VALID_REGIONS = {"NSW", "QLD", "VIC", "WA", "SA", "TAS"}
|
| 20 |
+
VALID_SKILL_LEVELS = {
|
| 21 |
+
"beginner",
|
| 22 |
+
"beginner to intermediate",
|
| 23 |
+
"beginner to advanced",
|
| 24 |
+
"intermediate",
|
| 25 |
+
"intermediate to advanced",
|
| 26 |
+
"intermediate to expert",
|
| 27 |
+
"advanced",
|
| 28 |
+
"advanced to expert",
|
| 29 |
+
"expert",
|
| 30 |
+
"expert only",
|
| 31 |
+
"elite/professional only",
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
# Approximate bounding boxes per Australian state/territory (lat_min, lng_min, lat_max, lng_max).
|
| 35 |
+
_REGION_BOUNDS: dict[str, tuple[float, float, float, float]] = {
|
| 36 |
+
"NSW": (-37.5, 147.5, -26.0, 154.5),
|
| 37 |
+
"QLD": (-29.0, 138.0, -10.0, 154.5),
|
| 38 |
+
"VIC": (-39.5, 140.5, -36.0, 150.5),
|
| 39 |
+
"WA": (-36.0, 113.0, -15.0, 130.0),
|
| 40 |
+
"SA": (-38.5, 135.5, -34.0, 142.5),
|
| 41 |
+
"TAS": (-44.0, 144.0, -39.0, 149.5),
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class Coordinates(BaseModel):
|
| 46 |
+
lat: float
|
| 47 |
+
lng: float
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class IdealSwell(BaseModel):
|
| 51 |
+
size_ft_min: float
|
| 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:
|
| 63 |
+
raise ValueError("size_ft_min must be strictly less than size_ft_max")
|
| 64 |
+
return self
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class IdealWind(BaseModel):
|
| 68 |
+
strength_kt_max: float
|
| 69 |
+
direction: str
|
| 70 |
+
|
| 71 |
+
@field_validator("strength_kt_max")
|
| 72 |
+
@classmethod
|
| 73 |
+
def positive(cls, v: float) -> float:
|
| 74 |
+
if v <= 0:
|
| 75 |
+
raise ValueError("strength_kt_max must be positive")
|
| 76 |
+
return v
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class AdditionalDetails(BaseModel):
|
| 80 |
+
break_type: str
|
| 81 |
+
break_direction: str
|
| 82 |
+
break_surface: str
|
| 83 |
+
bottom_type: str
|
| 84 |
+
skill_level: str
|
| 85 |
+
best_season: str
|
| 86 |
+
hazards: str
|
| 87 |
+
other_notes: str
|
| 88 |
+
|
| 89 |
+
@field_validator("skill_level")
|
| 90 |
+
@classmethod
|
| 91 |
+
def valid_skill(cls, v: str) -> str:
|
| 92 |
+
if v not in VALID_SKILL_LEVELS:
|
| 93 |
+
raise ValueError(f"skill_level '{v}' not in known levels")
|
| 94 |
+
return v
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class Break(BaseModel):
|
| 98 |
name: str
|
| 99 |
region: str
|
| 100 |
+
short_description: str
|
| 101 |
+
coordinates: Coordinates
|
| 102 |
+
ideal_swell: IdealSwell
|
| 103 |
+
ideal_wind: IdealWind
|
| 104 |
+
ideal_tide: str
|
| 105 |
+
additional_details: AdditionalDetails
|
| 106 |
+
|
| 107 |
+
@field_validator("region")
|
| 108 |
+
@classmethod
|
| 109 |
+
def valid_region(cls, v: str) -> str:
|
| 110 |
+
if v not in VALID_REGIONS:
|
| 111 |
+
raise ValueError(f"region '{v}' not in {VALID_REGIONS}")
|
| 112 |
+
return v
|
| 113 |
+
|
| 114 |
+
@field_validator("coordinates")
|
| 115 |
+
@classmethod
|
| 116 |
+
def coords_in_bounds(cls, v: Coordinates, info: dict) -> Coordinates:
|
| 117 |
+
region = info.data.get("region")
|
| 118 |
+
if region and region in _REGION_BOUNDS:
|
| 119 |
+
lo_lat, lo_lng, hi_lat, hi_lng = _REGION_BOUNDS[region]
|
| 120 |
+
if not (lo_lat <= v.lat <= hi_lat and lo_lng <= v.lng <= hi_lng):
|
| 121 |
+
raise ValueError(
|
| 122 |
+
f"coords ({v.lat}, {v.lng}) outside plausible range for {region}"
|
| 123 |
+
)
|
| 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 |
|
| 134 |
+
Raises on validation failure so broken data is caught early.
|
|
|
|
| 135 |
"""
|
| 136 |
+
p = Path(path) if path else DATA_PATH
|
| 137 |
+
raw = json.loads(p.read_text(encoding="utf-8"))
|
| 138 |
+
breaks: list[Break] = []
|
| 139 |
+
for i, record in enumerate(raw):
|
| 140 |
+
try:
|
| 141 |
+
breaks.append(Break.model_validate(record))
|
| 142 |
+
except Exception as exc:
|
| 143 |
+
raise ValueError(
|
| 144 |
+
f"Invalid record at index {i}: {record.get('name', '?')} "
|
| 145 |
+
f"({record.get('region', '?')})"
|
| 146 |
+
) from exc
|
| 147 |
+
return breaks
|
| 148 |
|
| 149 |
|
| 150 |
def find_spots(
|
|
|
|
| 152 |
region: str | None = None,
|
| 153 |
skill: str | None = None,
|
| 154 |
limit: int = 10,
|
| 155 |
+
) -> list[Break]:
|
| 156 |
+
"""Search breaks by name/region and optional skill filter.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
+
Case-insensitive substring match over name and region.
|
| 159 |
"""
|
| 160 |
+
all_breaks = load_breaks()
|
| 161 |
+
q = query.lower().strip()
|
| 162 |
+
results: list[Break] = []
|
| 163 |
+
for b in all_breaks:
|
| 164 |
+
if q and q not in b.name.lower() and q not in b.region.lower():
|
| 165 |
+
continue
|
| 166 |
+
if region and b.region != region:
|
| 167 |
+
continue
|
| 168 |
+
if skill:
|
| 169 |
+
skill_lower = skill.lower()
|
| 170 |
+
if skill_lower not in b.additional_details.skill_level.lower():
|
| 171 |
+
continue
|
| 172 |
+
results.append(b)
|
| 173 |
+
if len(results) >= limit:
|
| 174 |
+
break
|
| 175 |
+
return results
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def get_spot(name: str, region: str) -> Break | None:
|
| 179 |
+
"""Return the full knowledge-base record for one break, keyed by (name, region)."""
|
| 180 |
+
for b in load_breaks():
|
| 181 |
+
if b.name == name and b.region == region:
|
| 182 |
+
return b
|
| 183 |
+
return None
|
wavereader/tools.py
CHANGED
|
@@ -6,6 +6,8 @@ model chooses them, the deterministic modules compute the values.
|
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
|
|
|
|
|
| 9 |
TOOL_SCHEMAS = (
|
| 10 |
"get_forecast",
|
| 11 |
"score_week",
|
|
@@ -15,26 +17,47 @@ TOOL_SCHEMAS = (
|
|
| 15 |
)
|
| 16 |
|
| 17 |
|
| 18 |
-
def get_forecast(
|
| 19 |
-
"""Hourly marine + wind forecast for a named spot.
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
-
def score_week(
|
| 24 |
-
"""Hour-by-hour surf scores for the next 7 days at a spot.
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
-
def find_spots(query: str, skill: str | None = None) -> list[dict]:
|
| 29 |
-
"""Search breaks by name/region with optional skill filter.
|
| 30 |
-
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
-
def get_spot_knowledge(
|
| 34 |
-
"""Knowledge-base profile for a spot (ideal swell/wind/tide).
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
def rank_spots_this_week(region: str, skill: str | None = None) -> list[dict]:
|
| 39 |
-
"""Rank spots in a region by their best score this week.
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
+
from wavereader import forecasts, scoring, spots
|
| 10 |
+
|
| 11 |
TOOL_SCHEMAS = (
|
| 12 |
"get_forecast",
|
| 13 |
"score_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"}
|
| 48 |
+
return s.model_dump()
|
| 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 |
+
)
|