Spaces:
Running
Running
Download ui/_compat.py from lucashudsn/wavereader: direct link, hf CLI and curl.
- Browser
- Download file 36.3 kB
-
https://huggingface.co/spaces/lucashudsn/wavereader/resolve/main/ui/_compat.py
- Command line
-
hf download hf://spaces/lucashudsn/wavereader/ui/_compat.py
-
curl -L -o _compat.py https://huggingface.co/spaces/lucashudsn/wavereader/resolve/main/ui/_compat.py
36.3 kB
| """Backend access layer: real ``wavereader.*`` modules win, fallbacks below. | |
| Resolution order per capability (first success wins): | |
| 1. ``wavereader.*`` — the real v2 core (Worker A: breaks/openmeteo/scoring/ | |
| seafloor; Worker B: climate). Present on this branch: use it. | |
| 2. ``app.*`` — v1 modules still in the tree (same payload shapes, live data). | |
| 3. ``ui._stubs`` — deterministic fakes (offline-safe, used by smoke tests). | |
| Worker C (``wavereader.tools`` / ``agent`` / ``narrate``) is absent on this | |
| branch right now, so the tool functions below are implemented directly on | |
| the Worker A/B core with the exact signatures the plan agreed | |
| (``score_week`` / ``rank_region_week`` / ``explain_score``); when Worker C | |
| lands, only this module changes — panels, charts and root ``app.py`` already | |
| speak typed tool payloads. | |
| Standing rule: never read ``.env`` or log secrets here. The BYO HF token | |
| is passed straight through to narrate/agent factories and never stored. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from collections.abc import Iterator | |
| from typing import Any | |
| from ui import _stubs as _stub | |
| ALL = "All" | |
| _BREAKS_CACHE: list[dict] | None = None | |
| def _core(): | |
| """Worker A/B core modules, or None when unavailable (Worker branches).""" | |
| try: | |
| from wavereader import breaks as _b | |
| from wavereader import climate as _c | |
| from wavereader import openmeteo as _o | |
| from wavereader import scoring as _s | |
| from wavereader import seafloor as _sf | |
| return _b, _o, _s, _c, _sf | |
| except ImportError: | |
| return None | |
| # ---------------------------------------------------------------- breaks --- | |
| def list_breaks() -> list[dict]: | |
| """Full break catalogue as validated record dicts (real core first). | |
| Memoized: jsonschema-validating 238 records costs ~800 ms, which made | |
| every agent tool call (``api_score_week`` et al.) pay it again. The | |
| catalogue is static per process, so cache once and reuse. Callers treat | |
| records as read-only. | |
| """ | |
| global _BREAKS_CACHE | |
| if _BREAKS_CACHE is not None: | |
| return _BREAKS_CACHE | |
| core = _core() | |
| if core is not None: | |
| try: | |
| _BREAKS_CACHE = core[0].load_breaks() | |
| return _BREAKS_CACHE | |
| except Exception: | |
| pass | |
| try: # v1 catalogue (238 enriched breaks) | |
| from legacy.app.breaks_data import _records as _rec # type: ignore | |
| from legacy.app.breaks_data import DF as _DF # type: ignore | |
| return list(_rec(_DF)) | |
| except ImportError: | |
| pass | |
| return [dict(b) for b in _stub.STUB_BREAKS] | |
| def index_breaks(records: list[dict]) -> dict: | |
| """Derived filter vocab: states, regions-by-state, all regions, skills.""" | |
| states = sorted({str(r.get("state", "?")) for r in records}) | |
| regions_by_state: dict[str, list[str]] = {} | |
| for s in states: | |
| regions_by_state[s] = sorted({str(r.get("region", "?")) for r in records if str(r.get("state")) == s}) | |
| all_regions = sorted({str(r.get("region", "?")) for r in records}) | |
| order = _stub.SKILL_ORDER | |
| have = {str(r.get("skillLevel", "")).lower() for r in records} | |
| skills = [s for s in order if s in have] or sorted(have) | |
| return {"states": states, "regions_by_state": regions_by_state, | |
| "all_regions": all_regions, "skills": skills} | |
| def filter_records(records: list[dict], state: str | None = None, | |
| region: str | None = None, skill: str | None = None) -> list[dict]: | |
| """One-way filter (\"All\"/None = no filter, case-insensitive).""" | |
| core = _core() | |
| if core is not None: | |
| try: | |
| return core[0].filter_breaks(records, state=state, region=region, skill=skill) | |
| except Exception: | |
| pass | |
| out = records | |
| if state and state != ALL: | |
| out = [r for r in out if str(r.get("state", "")).lower() == state.lower()] | |
| if region and region != ALL: | |
| out = [r for r in out if str(r.get("region", "")).lower() == region.lower()] | |
| if skill and skill != ALL: | |
| out = [r for r in out if str(r.get("skillLevel", "")).lower() == skill.lower()] | |
| return out | |
| DEFAULT_SKILL = "intermediate" | |
| def scoring_skill(skill: str | None) -> str: | |
| """Map a lens Skill-filter value to a concrete scoring tier. | |
| The filter's ``All`` (and blank) mean "no catalogue filter", but the | |
| scoring engine needs a real tier — fall back to ``intermediate``. | |
| """ | |
| s = (skill or "").strip().lower() | |
| return DEFAULT_SKILL if not s or s == ALL.lower() else s | |
| def find_break(records: list[dict], name: str | None) -> dict | None: | |
| """Find a record by name (fuzzy via the real core, exact fallback).""" | |
| if not name: | |
| return None | |
| core = _core() | |
| if core is not None: | |
| try: | |
| hit = core[0].resolve_break(name, breaks=records) | |
| if hit is not None: | |
| return hit | |
| except Exception: | |
| pass | |
| want = str(name).strip().lower() | |
| for r in records or []: | |
| if str(r.get("name", "")).strip().lower() == want: | |
| return r | |
| return None | |
| # --------------------------------------------------------------- scoring --- | |
| def _score_with_core(break_: dict, skill: str, days: int) -> tuple[list[dict], dict, dict]: | |
| """Fetch + score via Worker A core. Raises on any failure (caller falls back). | |
| Returns ``(scored, spot, forecast)`` — the raw forecast frame carries | |
| the daily sunrise/sunset times and the hourly sea-surface temperature | |
| that the normalized payload surfaces (sun markers + wetsuit chip). | |
| """ | |
| core = _core() | |
| if core is None: | |
| raise ImportError("wavereader core unavailable") | |
| _b, _o, _s, _c, _sf = core | |
| lat, lng = _b.get_coords(break_) | |
| if lat is None or lng is None: | |
| raise ValueError(f"Missing coordinates for break {break_.get('name', '?')!r}") | |
| spot = _s.enriched_to_scoring_spot(break_) | |
| forecast = _o.get_forecast(lat, lng, days) | |
| scored = _s.score_week(forecast, spot, skill) | |
| return scored, spot, forecast | |
| _WETSUIT_BANDS = ( | |
| (24.0, "boardshorts"), | |
| (21.0, "springsuit"), | |
| (18.0, "3/2 wetsuit"), | |
| (14.0, "4/3 wetsuit"), | |
| (10.0, "5/4 wetsuit + boots"), | |
| (0.0, "5/4 + hood & boots"), | |
| ) | |
| def _wetsuit_hint(sst_c: float | None) -> str | None: | |
| """Deterministic wetsuit advice from sea-surface temperature.""" | |
| if sst_c is None: | |
| return None | |
| for floor, gear in _WETSUIT_BANDS: | |
| if sst_c >= floor: | |
| return f"{sst_c:.0f} °C water → {gear}" | |
| return None | |
| def _normalize_payload(break_: dict, skill: str, scored: list[dict], | |
| spot: dict, forecast: dict | None = None) -> dict: | |
| """One payload shape for every backend: scored/daily/daily_best/best. | |
| When the raw forecast frame is available (v2 core path) the payload | |
| also carries the daily ``sun`` frame (sunrise/sunset marker lists) | |
| and the mean sea-surface temperature with its wetsuit hint. | |
| """ | |
| from ui.charts import score as _sc | |
| best = _sc.best_window(scored) # daylight-only: night never wins the pick | |
| sun: dict = {} | |
| sst_c = None | |
| if isinstance(forecast, dict): | |
| daily = forecast.get("daily") or {} | |
| sun = {k: daily[k] for k in ("sunrise", "sunset") if daily.get(k)} | |
| temps = [r.get("sea_surface_temperature") for r in forecast.get("hourly") or [] | |
| if isinstance(r, dict) and r.get("sea_surface_temperature") is not None] | |
| if temps: | |
| sst_c = round(sum(float(t) for t in temps) / len(temps), 1) | |
| return { | |
| "break": break_, | |
| "skill": skill, | |
| "spot": spot, | |
| "scored": scored, | |
| "daily": _sc.daily_summary(scored), | |
| "daily_best": _sc.daily_best(scored), | |
| "best": best, | |
| "sun": sun, | |
| "sst_c": sst_c, | |
| "wetsuit_hint": _wetsuit_hint(sst_c), | |
| } | |
| def get_scored_week(break_: dict | None, skill: str = "intermediate", | |
| days: int = 7) -> dict: | |
| """Full scored payload for one break (core → v1 → stub; never hits LLM).""" | |
| if not break_: | |
| raise ValueError("No break selected") | |
| skill = (skill or "intermediate").strip().lower() | |
| days = max(1, min(7, int(days or 7))) | |
| try: | |
| scored, spot, forecast = _score_with_core(break_, skill, days) | |
| return _normalize_payload(break_, skill, scored, spot, forecast) | |
| except (ImportError, ValueError): | |
| raise | |
| except Exception: | |
| pass | |
| try: # v1 deterministic path (network: Open-Meteo marine + wind) | |
| from legacy.app import surf_forecast as _mod # type: ignore | |
| result = _mod.get_scored_week(break_, skill=skill, days=days) | |
| scored = result.get("scored", []) or [] | |
| payload = _normalize_payload(break_, result.get("skill", skill), scored, | |
| result.get("spot", {})) | |
| payload["sun"] = result.get("sun") or {} | |
| payload["sst_c"] = result.get("sst_c") | |
| payload["wetsuit_hint"] = result.get("wetsuit_hint") | |
| return payload | |
| except ImportError: | |
| pass | |
| except (ValueError, TypeError): | |
| pass | |
| hours = _stub.stub_scored_hours(break_, skill=skill, days=days) | |
| return _normalize_payload( | |
| break_, skill, hours, | |
| {"name": break_.get("name"), "region": break_.get("region")}) | |
| def engine_strip(note: str = "") -> str: | |
| """The 'engines online' strip as lo-fi HTML chips: world model / feeds / LLM / MCP. | |
| Names every engine the app runs so the demo makes the machinery | |
| evident: GEBCO world model, Open-Meteo feeds, ERA5 climate, the | |
| deterministic scorer, the Nemotron LLM via Inference Providers, MCP. | |
| ``note`` renders as a faint trailing line (e.g. the MCP endpoints). | |
| """ | |
| model = provider = "Nemotron 3 Ultra 550B · deepinfra" | |
| try: | |
| from wavereader import llm as _l | |
| model = str(_l.get_model_id()).split("/")[-1] | |
| provider = str(_l.get_provider()) | |
| except ImportError: | |
| pass | |
| engines = [ | |
| ("🌍", "world model", "GEBCO 2020 bathymetry"), | |
| ("📡", "swell feed", "Open-Meteo marine + wind"), | |
| ("🌡", "climate", "ERA5 5-yr"), | |
| ("⚙", "scoring", "deterministic v2"), | |
| ("🧠", "llm", f"{model} · {provider}"), | |
| ("🔌", "mcp", "on"), | |
| ] | |
| chips = "".join( | |
| f'<span class="engine-chip">{icon} <b>{name}</b> {detail}</span>' | |
| for icon, name, detail in engines | |
| ) | |
| note_html = f'<span class="engine-note">{note}</span>' if note else "" | |
| return f'<div class="engine-strip">{chips}{note_html}</div>' | |
| def api_score_week(spot_name: str, region: str = "", | |
| skill: str = "intermediate") -> dict: | |
| """Typed ``score_week`` tool (single source for agent + MCP + UI fallback).""" | |
| try: # Worker C tools module, when it lands back on the branch | |
| from wavereader import tools as _t # type: ignore | |
| out = _t.score_week(spot_name=spot_name, region=region or None, skill=skill) # type: ignore[attr-defined] | |
| if isinstance(out, dict): | |
| return dict(out) | |
| except ImportError: | |
| pass | |
| records = list_breaks() | |
| core = _core() | |
| break_ = None | |
| if core is not None: | |
| try: | |
| break_ = core[0].resolve_break(spot_name, region or None, records) | |
| except Exception: | |
| break_ = None | |
| if break_ is None: | |
| break_ = find_break(records, spot_name) | |
| if break_ is None: | |
| return {"error": f"unknown spot {spot_name!r}"} | |
| try: | |
| payload = get_scored_week(break_, skill=skill, days=7) | |
| except (ValueError, RuntimeError) as e: | |
| return {"error": str(e)} | |
| return {"spot": payload["spot"], "skill": payload["skill"], | |
| "scored": payload["scored"], "daily": payload["daily"], | |
| "daily_best": payload["daily_best"], "best": payload["best"]} | |
| def api_rank_region_week(region: str, skill: str = "intermediate", | |
| limit: int = 10) -> dict: | |
| """Typed ``rank_region_week`` tool: rank a region's breaks by best score.""" | |
| try: # Worker C tools module, when it lands back on the branch | |
| from wavereader import tools as _t # type: ignore | |
| out = _t.rank_region_week(region=region, skill=skill) # type: ignore[attr-defined] | |
| if isinstance(out, dict): | |
| out = dict(out) | |
| out.setdefault("rank", out.get("spots")) | |
| return out | |
| except ImportError: | |
| pass | |
| skill = (skill or "intermediate").strip().lower() | |
| records = list_breaks() | |
| want = (region or "").strip().lower() | |
| cands = [r for r in records | |
| if str(r.get("state", "")).lower() == want or str(r.get("region", "")).lower() == want] | |
| if not cands: | |
| return {"error": f"unknown region {region!r}"} | |
| core = _core() | |
| rows: list[dict] = [] | |
| if core is not None: | |
| _b, _o, _s, _c, _sf = core | |
| try: | |
| lim = max(1, min(12, int(limit))) | |
| except (TypeError, ValueError): | |
| lim = 10 | |
| spots, forecasts = [], {} | |
| for b in cands[:lim]: | |
| try: | |
| spot = _s.enriched_to_scoring_spot(b) | |
| lat, lng = _b.get_coords(b) | |
| if lat is None or lng is None: | |
| continue | |
| forecasts[(spot["name"], spot["region"])] = _o.get_forecast(lat, lng, 2) | |
| spots.append(spot) | |
| except Exception: | |
| continue | |
| if spots: | |
| try: | |
| rows = _s.rank_spots(forecasts, spots, skill) | |
| except Exception: | |
| rows = [] | |
| if not rows: # offline fallback: stub-rank the candidates deterministically | |
| for b in cands[:10]: | |
| hours = _stub.stub_scored_hours(b, skill=skill, days=2) | |
| best = max(hours, key=lambda r: r.get("score", 0)) if hours else {} | |
| rows.append({"name": b.get("name"), "region": b.get("region"), | |
| "best_score": best.get("score"), "best_time": best.get("time")}) | |
| rows.sort(key=lambda r: r.get("best_score") or 0, reverse=True) | |
| return {"region": region, "skill": skill, "rank": rows} | |
| def api_explain_score(spot_name: str, skill: str = "intermediate", | |
| time: str = "") -> dict: | |
| """Typed ``explain_score`` tool: score + component split for one hour.""" | |
| try: # Worker C tools module, when it lands back on the branch | |
| from wavereader import tools as _t # type: ignore | |
| out = _t.explain_score(spot_name=spot_name, skill=skill, time=time or None) # type: ignore[attr-defined] | |
| if isinstance(out, dict): | |
| return dict(out) | |
| except ImportError: | |
| pass | |
| payload = api_score_week(spot_name, skill=skill) | |
| if "error" in payload: | |
| return payload | |
| hours = payload.get("scored") or [] | |
| row = next((h for h in hours if time and time in str(h.get("time", ""))), None) | |
| row = row or (max(hours, key=lambda r: float(r.get("score") or 0)) if hours else None) | |
| if row is None: | |
| return {"error": f"no scored hours for {spot_name!r}"} | |
| return {"spot": payload["spot"], "skill": payload["skill"], "time": row.get("time"), | |
| "score": row.get("score"), "components": row.get("components"), | |
| "wave_height_m": row.get("wave_height_m"), "wave_period_s": row.get("wave_period_s"), | |
| "wind_speed_kt": row.get("wind_speed_kt"), | |
| "wind_direction_deg": row.get("wind_direction_deg")} | |
| # ------------------------------------------------------- climate + audit --- | |
| _MONTH_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", | |
| "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] | |
| def get_climate_profile(break_: dict | None) -> dict: | |
| """5-year swell climate profile, pre-digested for display. | |
| ``{rose, best_months, medians, window_ft, dominant, top_share, | |
| ideal_dirs, findings, period}``. ``window_ft``/``dominant``/``top_share`` | |
| state the headline facts in plain words (ideal size window; which way | |
| the swell comes from and how much of the record it explains); | |
| ``findings`` is the stated-vs-observed audit so the UI can cite | |
| agreement or mismatch. Real Worker B profile when the break carries | |
| (or can resolve) a climate profile, else the deterministic stub | |
| profile — same core shape, so the rose chart never breaks. | |
| """ | |
| if not break_: | |
| return {} | |
| core = _core() | |
| if core is not None: | |
| try: | |
| _b, _o, _s, _c, _sf = core | |
| prof = _c.break_climate(break_) | |
| rose = dict(prof.get("direction_rose_pct") or {}) | |
| monthly = prof.get("monthly") or {} | |
| ranked = sorted(monthly.items(), | |
| key=lambda kv: float((kv[1] or {}).get("days_in_ideal_window") or 0), | |
| reverse=True) | |
| best_months = [_MONTH_ABBR[int(m) - 1] for m, _ in ranked[:3] if str(m).isdigit()] | |
| heights = [float(v.get("median_swell_height_m")) for _, v in ranked | |
| if isinstance(v, dict) and v.get("median_swell_height_m") is not None] | |
| periods = [float(v.get("median_swell_period_s")) for _, v in ranked | |
| if isinstance(v, dict) and v.get("median_swell_period_s") is not None] | |
| heights.sort() | |
| periods.sort() | |
| if rose and any(v > 0 for v in rose.values()): | |
| try: | |
| findings = _c.audit_break(break_, prof) | |
| except Exception: | |
| findings = [] | |
| swell = break_.get("idealSwell") if isinstance(break_.get("idealSwell"), dict) else {} | |
| ideal_dirs = [str(d).strip().upper() for d in (swell.get("direction") or []) | |
| if isinstance(d, str) and d.strip()] | |
| return {"rose": rose, "best_months": best_months, | |
| "median_height_m": round(heights[len(heights) // 2], 2) if heights else None, | |
| "median_period_s": round(periods[len(periods) // 2], 1) if periods else None, | |
| "window_ft": _c.ideal_window_label(prof), | |
| "dominant": [[d, p] for d, p in _c.dominant_directions(prof, 2)], | |
| "top_share": _c.rose_top_share(prof, 2), | |
| "ideal_dirs": ideal_dirs, | |
| "findings": findings, | |
| "period": prof.get("period") or {}, | |
| "source": "era5-5yr"} | |
| except Exception: | |
| pass | |
| prof = _stub.climate_profile(break_) | |
| prof["source"] = "stub" | |
| return prof | |
| def get_climate_monthly(break_: dict | None) -> list[dict] | None: | |
| """Monthly climatology for the "when to go" view (real ERA5 only). | |
| Returns ``[{month, height_m, period_s, days, n_days, pct}]`` (12 | |
| entries) from the break's profile. ``pct`` is the interpretable unit — | |
| the share of that month's days whose swell fell in the ideal window — | |
| with ``n_days`` as its denominator for hover citations. None when the | |
| break has no climate profile yet — the chart shows a graceful placeholder | |
| rather than stub data. | |
| """ | |
| if not break_: | |
| return None | |
| core = _core() | |
| if core is None: | |
| return None | |
| try: | |
| from wavereader import climate as _c | |
| prof = _c.break_climate(break_) | |
| monthly = prof.get("monthly") or {} | |
| out = [] | |
| for m in range(1, 13): | |
| entry = monthly.get(str(m), {}) or {} | |
| days = entry.get("days_in_ideal_window") or 0 | |
| n = entry.get("n_days") or 0 | |
| out.append({ | |
| "month": m, | |
| "height_m": entry.get("median_swell_height_m"), | |
| "period_s": entry.get("median_swell_period_s"), | |
| "days": days, | |
| "n_days": n, | |
| "pct": round(100.0 * days / n, 1) if n else None, | |
| }) | |
| if not any(o["days"] or o["height_m"] for o in out): | |
| return None | |
| return out | |
| except Exception: | |
| return None | |
| def format_month_hint(monthly: list[dict] | None, profile: dict | None) -> str: | |
| """"When to plan your trip" hint + stated-vs-observed audit, in words. | |
| Line 1 answers the trip question (which months, how often the swell | |
| sits in the ideal window, median size/period). Line 2 reports the | |
| audit: whether the observed ERA5 swell agrees with the dataset's | |
| stated ideal direction / best season. Deterministic — numbers come | |
| straight from the profile. | |
| """ | |
| if not monthly: | |
| return "_No climate profile for this spot yet._" | |
| profile = profile or {} | |
| ranked = sorted(monthly, key=lambda m: -(m.get("pct") if m.get("pct") is not None | |
| else (m.get("days") or 0))) | |
| best = ranked[:3] | |
| abbr = _MONTH_ABBR | |
| pair = sorted(best[:2], key=lambda m: m["month"]) # a range reads chronologically | |
| months = "–".join(abbr[m["month"] - 1] for m in pair) \ | |
| if len(best) > 1 else abbr[best[0]["month"] - 1] | |
| bits = [f"**plan for {months}**"] | |
| pcts = [m["pct"] for m in best[:2] if m.get("pct") is not None] | |
| window = profile.get("window_ft") | |
| if pcts: | |
| span = f"{min(pcts):.0f}–{max(pcts):.0f}%" if len(pcts) > 1 else f"{pcts[0]:.0f}%" | |
| bits.append(span + (f" of days in the {window} window" if window | |
| else " of days in the ideal window")) | |
| med = profile.get("median_height_m") | |
| per = profile.get("median_period_s") | |
| if med is not None: | |
| bits.append(f"median swell {med} m" + (f" @ {per}s" if per is not None else "")) | |
| period = profile.get("period") or {} | |
| era = f"ERA5 {str(period.get('start', ''))[:4]}–{str(period.get('end', ''))[:4]}" \ | |
| if period.get("start") and period.get("end") else "ERA5 5-yr" | |
| hint = " · ".join(bits) + f" ({era})" | |
| # stated (dataset) vs observed (ERA5): cite the audit, never a vibe. | |
| findings = profile.get("findings") or [] | |
| lines = [hint] | |
| for f in findings: | |
| field, severity = f.get("field"), f.get("severity") | |
| if field == "idealSwell.direction" and severity == "high": | |
| dom = profile.get("dominant") or [] | |
| dom_txt = dom[0][0] if dom else "?" | |
| share = profile.get("top_share") | |
| stated = profile.get("ideal_dirs") or [] | |
| lines.append( | |
| f"⚠ stated ideal {', '.join(stated) or '—'} but observed swell runs " | |
| f"{dom_txt} ({share:.0f}% of days from the top-2 directions)" | |
| if share is not None else | |
| f"⚠ stated ideal {', '.join(stated) or '—'} but observed swell runs {dom_txt}") | |
| elif field == "bestSeason" and severity == "medium": | |
| peak = f.get("climate_value") or [] | |
| names = ", ".join(_MONTH_ABBR[m - 1] for m in peak | |
| if isinstance(m, int) and 1 <= m <= 12) | |
| if names: | |
| lines.append(f"⚠ stated best season {', '.join(f.get('dataset_value') or [])}" | |
| f" — good days peak in {names}") | |
| if not findings and profile.get("source") == "era5-5yr" and profile.get("dominant"): | |
| stated = profile.get("ideal_dirs") or [] | |
| if stated: | |
| dom_label, dom_pct = profile["dominant"][0] | |
| lines.append(f"✓ observed dominant swell {dom_label} ({dom_pct:.0f}% of days)" | |
| f" matches the stated ideal ({', '.join(stated)})") | |
| return "\n\n".join(lines) | |
| _SURF_CAMS: dict | None = None | |
| def get_surf_cam(break_: dict | None) -> dict | None: | |
| """Curated surf-cam page link for a break, or None (data/surf-cams.json).""" | |
| global _SURF_CAMS | |
| if not break_: | |
| return None | |
| if _SURF_CAMS is None: | |
| import json | |
| from pathlib import Path | |
| try: | |
| path = Path(__file__).resolve().parent.parent / "data" / "surf-cams.json" | |
| _SURF_CAMS = json.loads(path.read_text(encoding="utf-8")) | |
| except (OSError, ValueError): | |
| _SURF_CAMS = {} | |
| key = f"{break_.get('name', '')} | {break_.get('state', '')} | {break_.get('region', '')}" | |
| cam = _SURF_CAMS.get(key) | |
| return dict(cam) if isinstance(cam, dict) else None | |
| # --------------------------------------------------------------- seafloor --- | |
| def get_seafloor(break_: dict | None, radius_km: float = 1.2) -> dict: | |
| """Bathymetry grid + deterministic markdown + stats for one break.""" | |
| if not break_: | |
| raise ValueError("No break selected") | |
| coords = ((break_ or {}).get("location") or {}).get("coordinates") or {} | |
| try: | |
| lat, lng = float(coords["lat"]), float(coords["lng"]) | |
| except (KeyError, TypeError, ValueError): | |
| raise ValueError(f"Missing coordinates for break {break_.get('name', '?')!r}") | |
| core = _core() | |
| if core is not None: | |
| try: | |
| return dict(core[4].get_seafloor(lat, lng, radius_km=radius_km)) | |
| except Exception: | |
| pass | |
| try: # v1 seafloor (network: OpenTopoData) | |
| from legacy.app import seafloor as _s2 # type: ignore | |
| return dict(_s2.get_seafloor(lat, lng, radius_km=radius_km)) | |
| except ImportError: | |
| pass | |
| grid = _stub.seafloor_grid(break_, radius_km=radius_km) | |
| return {"grid": grid, "analysis": "_Stub seafloor — real GEBCO grid lands with Worker A._", | |
| "stats": {"dataset": "stub-gebco"}} | |
| # ---------------------------------------------------------------- narrate --- | |
| def _deterministic_report(payload: dict) -> str: | |
| """Numbers-in, prose-out fallback: daily lines + recommendation, no LLM.""" | |
| break_ = payload.get("break") or {} | |
| name = break_.get("name", "?") | |
| skill = payload.get("skill", "intermediate") | |
| daily = payload.get("daily_best") or payload.get("daily") or [] | |
| best = payload.get("best") or {} | |
| lines = [f"### {name} — {skill} outlook (deterministic draft)"] | |
| for d in daily: | |
| lines.append( | |
| f"- **{d.get('date', str(d.get('time', ''))[:10])}**: {d.get('score')}/10 @ {d.get('time')} — " | |
| f"{d.get('wave_height_m')}m @ {d.get('wave_period_s')}s, " | |
| f"wind {d.get('wind_speed_kt')}kt." | |
| ) | |
| if best: | |
| lines.append(f"\n**Recommendation:** {best.get('score')}/10 @ {best.get('time')} " | |
| f"looks like the pick of the week.") | |
| wetsuit = payload.get("wetsuit_hint") | |
| if wetsuit: | |
| lines.append(f"🤿 {wetsuit}.") | |
| return "\n".join(lines) | |
| def narrate_stream(payload: dict | None, hf_token: str = "") -> Iterator[str]: | |
| """Stream a surf-report markdown for a scored payload (LLM → fallback). | |
| Tries Worker C ``wavereader/narrate.py``, then the v1 report streamer | |
| (token arg first, ``HF_TOKEN`` env as fallback), then the deterministic | |
| template so the button always produces markdown offline. | |
| """ | |
| if not payload or not (payload.get("daily_best") or payload.get("daily")): | |
| yield "_No scored forecast yet — pick a break first._" | |
| return | |
| daily = payload.get("daily_best") or [] | |
| try: # Worker C narrator (Nemotron, numbers-in prose-out) | |
| from wavereader import narrate as _n # type: ignore | |
| for chunk in _n.generate_report_stream( # type: ignore[attr-defined] | |
| payload.get("break") or {}, payload.get("skill", "intermediate"), | |
| daily, payload.get("best"), payload.get("days", len(daily)), | |
| hf_token=(hf_token or "").strip() or None): | |
| yield chunk | |
| return | |
| except ImportError: | |
| pass | |
| except Exception: | |
| pass | |
| try: # v1 report streamer (InferenceClient, token from env) | |
| from legacy.app import generate_surf_report as _rep # type: ignore | |
| for chunk in _rep.generate_surf_report_stream( | |
| break_=payload.get("break") or {}, skill=payload.get("skill", "intermediate"), | |
| daily=daily, best=payload.get("best"), | |
| days=payload.get("days", len(daily))): | |
| yield chunk | |
| return | |
| except Exception: | |
| pass | |
| text = _deterministic_report(payload) # offline fallback, chunked like a stream | |
| words = text.split(" ") | |
| for i in range(1, len(words) + 1, 24): | |
| yield " ".join(words[:i]) | |
| # ------------------------------------------------------------------- agent --- | |
| # Mirrored offline default; the real values come from wavereader.agent.BUDGETS. | |
| _FALLBACK_PROFILES: dict[str, dict[str, int]] = { | |
| "quick": {"max_steps": 4, "max_tokens": 500, "score_week_calls": 1}, | |
| "standard": {"max_steps": 6, "max_tokens": 700, "score_week_calls": 2}, | |
| "deep": {"max_steps": 10, "max_tokens": 1100, "score_week_calls": 3}, | |
| } | |
| def agent_profiles() -> dict[str, dict[str, int]]: | |
| """Budget profiles (quick/standard/deep) for the agent depth control.""" | |
| try: | |
| from wavereader.agent import BUDGETS as _b # type: ignore | |
| return {k: dict(v) for k, v in _b.items()} | |
| except ImportError: | |
| return {k: dict(v) for k, v in _FALLBACK_PROFILES.items()} | |
| def agent_budget_for_steps(steps: int) -> dict[str, int]: | |
| """Full budget derived from a step count (the UI slider path).""" | |
| try: | |
| from wavereader.agent import budget_for_steps as _bfs # type: ignore | |
| return _bfs(steps) | |
| except ImportError: | |
| s = max(1, int(steps)) | |
| return {"max_steps": s, "max_tokens": 100 + 100 * s, | |
| "score_week_calls": max(1, round(s / 3))} | |
| def _stub_agent_stream(message: str, skill: str, | |
| selected_break: dict | None, | |
| caps: dict | None = None) -> Iterator[tuple[str, Any]]: | |
| """Offline agent: one stub score_week call → charts + meter + answer.""" | |
| name = (selected_break or {}).get("name") or "Bells Beach" | |
| region = (selected_break or {}).get("region") or "" | |
| args = {"spot_name": name, "region": region, "skill": skill} | |
| yield ("step", {"n": 1}) | |
| yield ("tool_call", {"name": "score_week", "arguments": args}) | |
| payload = api_score_week(name, region, skill) | |
| hours = payload.get("scored") or [] | |
| yield ("tool_result", {"name": "score_week", "arguments": args, | |
| "observation": payload, | |
| "summary": f"{len(hours)} scored hour(s) for {name}", | |
| "preview": json.dumps(payload, default=str)[:2000]}) | |
| yield ("step", {"n": 2}) | |
| yield ("usage", {"prompt_tokens": 1180, "completion_tokens": 210, | |
| "steps": 2, "tool_calls": {"score_week": 1}, | |
| "profile": "standard", | |
| "budget": caps or agent_profiles()["standard"]}) | |
| best = payload.get("best") or {} | |
| yield ("final", f"**Best: {name} @ {best.get('time')} — {best.get('score')}/10.**\n\n" | |
| f"{name} looks surfable — {best.get('wave_height_m')}m @ " | |
| f"{best.get('wave_period_s')}s. _Stub answer — the real " | |
| f"Nemotron agent lands with Worker C._") | |
| def _real_agent_stream(question: str, hf_token: str, | |
| profile: str = "standard", | |
| max_steps: int | None = None) -> Iterator[tuple[str, Any]]: | |
| """Adapt Worker C dict events to the panel's (kind, payload) tuples.""" | |
| from wavereader import agent as _a # type: ignore | |
| for event in _a.run_stream(question, hf_token=(hf_token or "").strip() or None, # type: ignore[attr-defined] | |
| max_steps=max_steps, profile=profile): | |
| kind = event.get("kind") | |
| if kind == "token": | |
| yield ("token", event.get("text", "")) | |
| elif kind == "step": | |
| yield ("step", {"n": event.get("n")}) | |
| elif kind == "tool_call": | |
| yield ("tool_call", {"name": event.get("name", "?"), | |
| "arguments": event.get("arguments", {})}) | |
| elif kind == "tool_result": | |
| yield ("tool_result", {"name": event.get("name", "?"), "arguments": {}, | |
| "observation": event.get("output"), | |
| "summary": event.get("summary", ""), | |
| "ms": event.get("ms"), | |
| "preview": event.get("preview")}) | |
| elif kind == "final": | |
| yield ("final", event.get("text", "")) | |
| elif kind == "usage": | |
| yield ("usage", {"steps": event.get("steps"), "tool_calls": event.get("tool_calls"), | |
| "model": event.get("model"), "provider": event.get("provider"), | |
| "max_tokens": event.get("max_tokens"), | |
| "profile": event.get("profile"), | |
| "budget": event.get("budget")}) | |
| def _v1_agent_stream(message: str, skill: str, hf_token: str, | |
| selected_break: dict | None) -> Iterator[tuple[str, Any]]: | |
| """Adapt the v1 CodeAgent stream (app/agent.py) to typed v2 events.""" | |
| from legacy.app import agent as _am # type: ignore | |
| agent = _am.SurfAgent(hf_token=(hf_token or "").strip() or None) | |
| for kind, data in agent.run_stream(message): | |
| if kind == "model": | |
| yield ("token", data) | |
| elif kind == "tool_start": | |
| d = data if isinstance(data, dict) else {"name": str(data)} | |
| yield ("tool_call", {"name": d.get("name", "?"), | |
| "arguments": d.get("arguments", {})}) | |
| elif kind == "tool_end": | |
| d = data if isinstance(data, dict) else {"name": "?", "summary": str(data)} | |
| yield ("tool_result", {"name": d.get("name", "?"), | |
| "arguments": {}, | |
| "observation": d.get("observation"), | |
| "summary": d.get("summary", "")}) | |
| elif kind == "final": | |
| yield ("final", data) | |
| # ("tool", "code") kinds are display-only in v1 — skip here. | |
| def agent_run_stream(message: str, skill: str = "intermediate", | |
| hf_token: str = "", selected_break: dict | None = None, | |
| profile: str = "standard", region_hint: str | None = None, | |
| max_steps: int | None = None, | |
| ) -> Iterator[tuple[str, Any]]: | |
| """Yield typed agent events: token | step | tool_call | tool_result | final | usage. | |
| Worker C ``wavereader/agent.py`` first (native tool calling), then the | |
| v1 CodeAgent adapter, then the offline stub. ``hf_token`` is session-only | |
| and is never logged or persisted. ``max_steps`` caps the turn's tool | |
| calls (the UI slider); ``profile`` is the fallback budget. ``region_hint`` | |
| anchors "where should I surf" sweeps when the user picks an explicit | |
| region focus. | |
| """ | |
| skill = (skill or "intermediate").strip().lower() | |
| sel = selected_break or {} | |
| context = ", ".join(p for p in (sel.get("name"), sel.get("region"), sel.get("state")) if p) | |
| question = message | |
| extras = [f"surfer skill level = {skill}"] | |
| if context: | |
| extras.append(f"user is viewing {context}") | |
| if region_hint and region_hint != "auto": | |
| extras.append(f"preferred region focus = {region_hint}") | |
| if max_steps is not None: | |
| budget = agent_budget_for_steps(max_steps) | |
| else: | |
| budget = agent_profiles().get(profile) or agent_profiles()["standard"] | |
| extras.append( | |
| f"tool budget = at most {budget['max_steps']} steps, " | |
| f"{budget['score_week_calls']} score_week calls" | |
| ) | |
| question = f"[{'; '.join(extras)}]\n{message}" | |
| try: # Worker C: ToolCallingAgent with real ToolCall/ToolOutput events | |
| for event in _real_agent_stream(question, hf_token, profile=profile, | |
| max_steps=max_steps): | |
| yield event | |
| return | |
| except ImportError: | |
| pass | |
| except Exception as e: | |
| yield ("final", f"_Agent run failed: {e}_") | |
| return | |
| try: | |
| for event in _v1_agent_stream(question, skill, hf_token, selected_break): | |
| yield event | |
| return | |
| except Exception: | |
| pass | |
| for event in _stub_agent_stream(message, skill, selected_break, caps=budget): | |
| yield event | |