diff --git "a/main.py" "b/main.py" --- "a/main.py" +++ "b/main.py" @@ -1,2893 +1,61 @@ -"""Gradio map front end for Australian surf breaks.""" +"""Gradio map front end for Australian surf breaks (thin entrypoint).""" from __future__ import annotations -import ast -import importlib.util -import json -import re -import sys -import time -from pathlib import Path - -import gradio as gr -import pandas as pd -from plotly import graph_objects as go - -DATA_PATH = Path(__file__).parent / "data" / "australia-surf-breaks-enriched.json" -GENERATOR_PATH = Path(__file__).parent / "app" / "generate_surf_break.py" -FORECAST_PATH = Path(__file__).parent / "app" / "surf_forecast.py" -REPORT_PATH = Path(__file__).parent / "app" / "generate_surf_report.py" -AGENT_PATH = Path(__file__).parent / "app" / "agent.py" -FAVICON_PATH = Path(__file__).parent / "assets" / "wave.svg" - -ALL = "All" - -SKILL_ORDER = ["beginner", "intermediate", "advanced", "expert", "pro-only"] -SKILL_COLORS = { - "beginner": "#2ca02c", - "intermediate": "#1f77b4", - "advanced": "#ff7f0e", - "expert": "#d62728", - "pro-only": "#9467bd", -} -CUSTOM_MARKER_COLOR = "#FFD700" # gold highlight for the session-only break -APP_CSS = """ -/* wave~reader lo-fi theme: light blue bg (#eef6fd everywhere), dark blue font */ -.gradio-container, .gradio-container-4x, main, body { - background: #eef6fd !important; - color: #0b2c5c !important; - font-family: "Courier New", Courier, monospace !important; -} -.gradio-container h1, .gradio-container h2, .gradio-container h3, -.gradio-container p, .gradio-container span, .gradio-container label, -.gradio-container .markdown, .prose { - color: #0b2c5c !important; - font-family: "Courier New", Courier, monospace !important; -} -.hero-title { - font-size: 2.2em !important; - letter-spacing: 1px; - text-transform: lowercase; - margin-bottom: 0 !important; -} -button, select { - font-family: "Courier New", Courier, monospace !important; -} -/* primary (orange) buttons -> dark blue */ -.gradio-container { - --button-primary-background-fill: #0b2c5c !important; - --button-primary-background-fill-hover: #13407e !important; - --button-primary-text-color: #ffffff !important; - --button-primary-border-color: #0b2c5c !important; -} -.gradio-container button.primary, -.gradio-container .gr-button-primary, -button.primary, button.lg.primary { - background: #0b2c5c !important; - border-color: #0b2c5c !important; - color: #ffffff !important; -} -.gradio-container button.primary:hover, -.gradio-container .gr-button-primary:hover, -button.primary:hover, button.lg.primary:hover { - background: #13407e !important; - border-color: #13407e !important; - color: #ffffff !important; -} -.break-list { max-height: 210px; overflow-y: auto; border: 1px solid #0b2c5c; border-radius: 8px; padding: 4px; background: #eef6fd !important; } -/* selected break dot -> dark blue (radio circle fill + border) */ -.break-list input[type="radio"]:checked, -.break-list input[type="checkbox"]:checked { - background-color: #0b2c5c !important; - border-color: #0b2c5c !important; - accent-color: #0b2c5c !important; -} -.break-list label.selected { - background: #d6e9f8 !important; - color: #0b2c5c !important; -} -/* blocks match the page bg (#eef6fd) — padding + dark borders define them */ -.gradio-container { - --background-fill-primary: #eef6fd !important; - --background-fill-secondary: #eef6fd !important; - --block-background-fill: #eef6fd !important; - --block-border-color: #0b2c5c !important; - --block-label-background-fill: #eef6fd !important; - --block-label-text-color: #0b2c5c !important; - --input-background-fill: #ffffff !important; - --input-border-color: #0b2c5c !important; - --input-placeholder-color: #4a6fa5 !important; - --table-background-fill: #eef6fd !important; - --table-border-color: #0b2c5c !important; - --table-odd-background-fill: #eef6fd !important; - --table-even-background-fill: #eef6fd !important; - --chatbot-code-background-fill: #eef6fd !important; - --button-secondary-background-fill: #eef6fd !important; - --button-secondary-background-fill-hover: #d6e9f8 !important; - --button-secondary-text-color: #0b2c5c !important; - --button-secondary-border-color: #0b2c5c !important; - --button-cancel-background-fill: #eef6fd !important; - --button-cancel-text-color: #0b2c5c !important; - --button-cancel-border-color: #0b2c5c !important; - --block-border-width: 2px !important; - --block-shadow: none !important; - --block-title-text-size: 15px !important; - --checkbox-background-color-selected: #0b2c5c !important; - --checkbox-border-color-selected: #0b2c5c !important; - --checkbox-border-color-focus: #0b2c5c !important; - --checkbox-border-color-hover: #0b2c5c !important; - --checkbox-label-background-fill-selected: #d6e9f8 !important; - --checkbox-label-text-color-selected: #0b2c5c !important; -} -.gradio-container .gr-box, .gradio-container .gr-panel, -.gradio-container .block, .gradio-container .form, -.gradio-container .gr-group, .gradio-container .gr-block, -.gradio-container .tabitem, .gradio-container .tabs { - background: #eef6fd !important; - border-color: #0b2c5c !important; - border-width: 2px !important; - border-radius: 12px !important; - padding: 11px !important; - gap: 8px !important; -} -.gradio-container input, .gradio-container textarea, -.gradio-container select, .gradio-container .gr-textbox, -.gradio-container .gr-dropdown, .gradio-container .gr-radio, -.gradio-container .gr-checkbox, .gradio-container .gr-dataframe { - background: #ffffff !important; - border-color: #0b2c5c !important; - color: #0b2c5c !important; -} -.gradio-container table, .gradio-container thead, -.gradio-container tbody, .gradio-container th, -.gradio-container td, .gradio-container .table-wrap { - background: #eef6fd !important; - color: #0b2c5c !important; -} -.gradio-container tbody tr:nth-child(even) { - background: #eef6fd !important; -} -.gradio-container .message, .gradio-container .bubble, -.gradio-container .message-wrap { - background: #ffffff !important; - border-color: #0b2c5c !important; - color: #0b2c5c !important; -} -/* browse tabs stand out: big lowercase pill tabs, same courier + dark blue */ -.gradio-container .tab-nav { - gap: 14px !important; - padding: 6px 2px 12px 2px !important; - border-bottom: 2px solid #0b2c5c !important; - background: transparent !important; -} -.gradio-container .tab-nav button { - background: #eef6fd !important; - color: #0b2c5c !important; - border: 2px solid #0b2c5c !important; - border-radius: 999px !important; - font-size: 1.15em !important; - font-weight: bold !important; - text-transform: lowercase !important; - letter-spacing: 1px !important; - padding: 10px 28px !important; -} -.gradio-container .tab-nav button:hover { - background: #d6e9f8 !important; -} -.gradio-container .tab-nav button.selected { - background: #0b2c5c !important; - color: #ffffff !important; - border-color: #0b2c5c !important; -} -/* agentic-mode sliding toggle: style the checkbox as an on/off switch */ -.mode-switch input[type="checkbox"] { - appearance: none !important; - -webkit-appearance: none !important; - width: 52px !important; - height: 28px !important; - border-radius: 999px !important; - background: #8fb8dd !important; - border: 2px solid #0b2c5c !important; - position: relative !important; - cursor: pointer !important; - outline: none !important; - flex-shrink: 0 !important; -} -.mode-switch input[type="checkbox"]::after { - content: "" !important; - position: absolute !important; - top: 1px !important; - left: 2px !important; - width: 20px !important; - height: 20px !important; - border-radius: 50% !important; - background: #ffffff !important; - border: 2px solid #0b2c5c !important; - transition: left 0.15s ease-in-out !important; -} -.mode-switch input[type="checkbox"]:checked { - background: #0b2c5c !important; -} -.mode-switch input[type="checkbox"]:checked::after { - left: 24px !important; -} -/* header row: title left, compact toggle pinned top-right */ -.header-row { align-items: flex-start !important; } -/* messenger-style chat: input row pinned under the chat log */ -.chat-input-row { align-items: flex-end !important; } -.mode-switch-wrap { max-width: 170px !important; margin-left: auto !important; flex-grow: 0 !important; } -.mode-switch { max-width: 160px !important; margin-left: auto !important; } -.mode-switch-wrap label { font-size: 0.85em !important; } -""" - - -def _load_generator(): - """Load app/generate_surf_break.py without requiring app/ to be a package.""" - spec = importlib.util.spec_from_file_location( - "surf_break_generator", GENERATOR_PATH - ) - if spec is None or spec.loader is None: - raise ImportError(f"Cannot load generator from {GENERATOR_PATH}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _load_forecast(): - """Load app/surf_forecast.py without requiring app/ to be a package.""" - spec = importlib.util.spec_from_file_location( - "surf_forecast_mod", FORECAST_PATH - ) - if spec is None or spec.loader is None: - raise ImportError(f"Cannot load forecast module from {FORECAST_PATH}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _load_report(): - """Load app/generate_surf_report.py without requiring app/ to be a package.""" - spec = importlib.util.spec_from_file_location( - "surf_report_generator", REPORT_PATH - ) - if spec is None or spec.loader is None: - raise ImportError(f"Cannot load report generator from {REPORT_PATH}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _load_agent(): - """Load app/agent.py without requiring app/ to be a package.""" - spec = importlib.util.spec_from_file_location("surf_agent_mod", AGENT_PATH) - if spec is None or spec.loader is None: - raise ImportError(f"Cannot load surf agent from {AGENT_PATH}") - module = importlib.util.module_from_spec(spec) - # Register before exec: app/agent.py defines @dataclass types at import - # time, and dataclasses looks up sys.modules[cls.__module__] while the - # class body executes. Without this, exec raises - # "AttributeError: 'NoneType' object has no attribute '__dict__'". - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -def _empty_forecast_fig() -> go.Figure: - fig = go.Figure() - fig.add_annotation(text="No forecast data", showarrow=False, font={"size": 16}) - return fig - - -_NO_BREAK_HEADER = "### No break selected — pick one in the break book tab." - -_NO_REPORT_MD = "_No report yet — pick a break to auto-load the forecast, then generate the report._" - -BROWSE_INTRO = ( - "a guide to australian surf breaks — browse 238 breaks on the **break book** page, " - "then score the week ahead + get an ai write-up on the **swell check** page, " - "or switch to **agentic mode** above to chat with the surf agent." -) -AGENT_INTRO = ( - "agentic mode — chat with the surf agent below. " - "charts + leaderboard stay hidden until the agent actually scores a spot." -) - -AGENT_MODE_LABEL = "agentic mode" - - -def on_mode_change(agentic: bool): - """Toggle between browse UI and the agent-only interface (slide switch).""" - is_agent = bool(agentic) - return ( - gr.update(visible=not is_agent), - gr.update(visible=is_agent), - AGENT_INTRO if is_agent else BROWSE_INTRO, - ) - - -def go_to_forecast(): - """Leave agentic mode and jump the browse tabs to the forecast page.""" - return ( - gr.Tabs(selected="forecast"), - gr.update(value=False), - gr.update(visible=True), - gr.update(visible=False), - BROWSE_INTRO, - ) - - -def load_breaks(path: Path = DATA_PATH) -> pd.DataFrame: - data = json.loads(path.read_text()) - if isinstance(data, dict): - # Legacy format: {"name | state | region": {...}} mapping. - df = pd.read_json(path, orient="index") - else: - df = pd.DataFrame(data) - if "error" in df.columns: - df = df[df["error"].isna()].drop(columns=["error"]) - return df.reset_index(drop=True) - - -def filter_breaks( - df: pd.DataFrame, - state: str | None = None, - region: str | None = None, - skill: str | None = None, -) -> pd.DataFrame: - """Filter breaks by state / region / skill level ("All"/None = no filter).""" - out = df - if state and state != ALL: - out = out[out["state"].str.lower() == state.lower()] - if region and region != ALL: - out = out[out["region"].str.lower() == region.lower()] - if skill and skill != ALL: - out = out[out["skillLevel"].str.lower() == skill.lower()] - return out.reset_index(drop=True) - - -def _lat(break_: dict) -> float | None: - try: - return float(break_["location"]["coordinates"]["lat"]) - except (KeyError, TypeError, ValueError): - return None - - -def _lng(break_: dict) -> float | None: - try: - return float(break_["location"]["coordinates"]["lng"]) - except (KeyError, TypeError, ValueError): - return None - - -def _zoom_for_span(span: float) -> float: - """Pick a map zoom level that fits a lat/lng degree span.""" - if span > 25: - return 3.5 - if span > 12: - return 4.2 - if span > 6: - return 5.0 - if span > 3: - return 6.0 - if span > 1.5: - return 7.0 - if span > 0.7: - return 8.0 - if span > 0.3: - return 9.0 - return 10.0 - - -AUSTRALIA_CENTER = {"lat": -25.5, "lon": 134.0} -AUSTRALIA_ZOOM = 3.5 - - -def build_map(records: list[dict], default_view: bool = False) -> go.Figure: - """Scattermap of breaks; point order matches ``records`` order. - - Recenters (and zooms) to fit the given records, so filtering - reframes the map on the matching spots — unless ``default_view`` - is set, which frames the whole of Australia. - """ - lats = [_lat(r) for r in records] - lngs = [_lng(r) for r in records] - names = [r.get("name", "?") for r in records] - skills = [str(r.get("skillLevel", "?")) for r in records] - regions = [r.get("region", "?") for r in records] - - valid = [(la, lo) for la, lo in zip(lats, lngs) if la is not None and lo is not None] - if valid and not default_view: - center_lat = sum(la for la, _ in valid) / len(valid) - center_lng = sum(lo for _, lo in valid) / len(valid) - span = max( - max(la for la, _ in valid) - min(la for la, _ in valid), - max(lo for _, lo in valid) - min(lo for _, lo in valid), - ) - zoom = _zoom_for_span(span) - else: - center_lat, center_lng, zoom = ( - AUSTRALIA_CENTER["lat"], - AUSTRALIA_CENTER["lon"], - AUSTRALIA_ZOOM, - ) - - fig = go.Figure() - if records: - # One trace per skill level so the map gets a skill-level legend - # on top (a single trace with per-point colours shows no legend). - by_skill: dict[str, list[int]] = {} - for i, s in enumerate(skills): - by_skill.setdefault(s.lower(), []).append(i) - ordered = [s for s in SKILL_ORDER if s in by_skill] - ordered += [s for s in by_skill if s not in SKILL_ORDER] - for skill_key in ordered: - idx = by_skill[skill_key] - # Preserve the canonical label casing from the first record. - label = skills[idx[0]] - fig.add_trace( - go.Scattermap( - lat=[lats[i] for i in idx], - lon=[lngs[i] for i in idx], - mode="markers", - marker={ - "size": 10, - "color": SKILL_COLORS.get(skill_key, "#1f77b4"), - }, - text=[names[i] for i in idx], - customdata=[(names[i], regions[i], skills[i]) for i in idx], - hovertemplate=( - "%{customdata[0]}
" - "%{customdata[1]} · %{customdata[2]}" - "" - ), - name=label, - ) - ) - fig.update_layout( - map_style="open-street-map", - hovermode="closest", - height=550, - margin={"l": 0, "r": 0, "t": 30, "b": 0}, - map=dict(center={"lat": center_lat, "lon": center_lng}, zoom=zoom), - showlegend=True, - legend={"orientation": "h", "y": 1.02, "x": 0}, - ) - if not records: - fig.add_annotation( - text="No spots match these filters", - showarrow=False, - font={"size": 16}, - ) - return fig - - -def build_map_with_custom( - base_records: list[dict], custom: dict | None, default_view: bool = False -) -> go.Figure: - """Base map plus a gold highlight marker for the session-only custom break.""" - fig = build_map(base_records, default_view=default_view) - if custom: - la, lo = _lat(custom), _lng(custom) - if la is not None and lo is not None: - name = custom.get("name", "?") - fig.add_trace( - go.Scattermap( - lat=[la], - lon=[lo], - mode="markers", - marker={ - "size": 20, - "color": CUSTOM_MARKER_COLOR, - "symbol": "star", - }, - text=[f"{name} (your break)"], - hovertemplate=f"{name}
your generated break", - name="Your break", - ) - ) - return fig - - -def _join(value) -> str: - if isinstance(value, list): - return ", ".join(str(v) for v in value) - return "" if value is None else str(value) - - -def break_to_table(break_: dict) -> pd.DataFrame: - """Flatten one break record into a Field/Value table.""" - loc = break_.get("location", {}) or {} - coords = loc.get("coordinates", {}) or {} - swell = break_.get("idealSwell", {}) or {} - swell_size = swell.get("sizeRangeFt", {}) or {} - wind = break_.get("idealWind", {}) or {} - tide = break_.get("idealTide", {}) or {} - rows = [ - ("Name", break_.get("name", "")), - ("State", break_.get("state", "")), - ("Region", break_.get("region", "")), - ("Description", break_.get("description", "")), - ("Skill level", break_.get("skillLevel", "")), - ("Break type", break_.get("breakType", "")), - ("Peak type", break_.get("peakType", "")), - ("Latitude", coords.get("lat", "")), - ("Longitude", coords.get("lng", "")), - ("Ideal swell", _join(swell.get("direction", ""))), - ("Swell size (ft)", f"{swell_size.get('min', '?')}–{swell_size.get('max', '?')}"), - ("Ideal wind", f"{_join(wind.get('direction', ''))} ({wind.get('type', '')})"), - ("Ideal tide", _join(tide.get("stage", ""))), - ("Best season", _join(break_.get("bestSeason", ""))), - ("Hazards", _join(break_.get("hazards", ""))), - ("Crowd", break_.get("crowdFactor", "")), - ] - return pd.DataFrame(rows, columns=["Field", "Value"]) - - -DF = load_breaks() -STATES = sorted(DF["state"].dropna().unique().tolist()) -REGIONS_BY_STATE = { - state: sorted(DF[DF["state"] == state]["region"].dropna().unique().tolist()) - for state in STATES -} -ALL_REGIONS = sorted(DF["region"].dropna().unique().tolist()) -SKILLS = [s for s in SKILL_ORDER if s in set(DF["skillLevel"].str.lower())] or sorted( - DF["skillLevel"].dropna().unique().tolist() +from app.break_details import break_to_table +from app.breaks_data import ( + ALL_REGIONS, + DF, + REGIONS_BY_STATE, + SKILLS, + STATES, + filter_breaks, + load_breaks, ) +from app.browse_sync import on_break_pick, update_map +from app.config import ALL +from app.custom_break import clear_custom_break, generate_custom_break +from app.forecast_handlers import fetch_forecast, generate_reports +from app.maps import build_map, build_map_with_custom +from app.seafloor_handlers import fetch_seafloor, generate_seafloor_explanation +from app.theme import APP_CSS +from app.ui import build_demo + +__all__ = [ + "ALL", + "ALL_REGIONS", + "DF", + "REGIONS_BY_STATE", + "SKILLS", + "STATES", + "APP_CSS", + "break_to_table", + "build_demo", + "build_map", + "build_map_with_custom", + "clear_custom_break", + "fetch_forecast", + "fetch_seafloor", + "generate_seafloor_explanation", + "filter_breaks", + "generate_custom_break", + "generate_reports", + "load_breaks", + "on_break_pick", + "update_map", +] +def main(): + import argparse -def _records(df: pd.DataFrame) -> list[dict]: - return df.to_dict(orient="records") - - -def _effective_names(base_records: list[dict], custom: dict | None) -> list[str]: - names = [r.get("name", "?") for r in base_records] - if custom and custom.get("name"): - names = [*names, f"{custom['name']} ⭐ (your break)"] - return names - - -def _break_list_label(names: list[str]) -> str: - """Break-checklist label with the live spot count folded in.""" - return f"Breaks (pick one for details) — {len(names)} spot(s)" - - -def _resolve_pick(break_label: str, base_records: list[dict], custom: dict | None) -> dict | None: - """Map a Break-list label back to its record (custom break has a ⭐ suffix).""" - if custom and break_label == f"{custom.get('name', '')} ⭐ (your break)": - return custom - for r in base_records: - if r.get("name") == break_label: - return r - return None - - -def update_map(state: str, region: str, skill: str, custom: dict | None): - """Re-filter, rebuild the map (with custom highlight), refresh break choices. - - The spot count lives in the break-checklist label (no separate count - display) and both tab pickers (break book + swell check) get the same - choices so users can iterate forecasts without switching tabs. - """ - df = filter_breaks(DF, state, region, skill) - records = _records(df) - unfiltered = all(v in (None, ALL) for v in (state, region, skill)) - fig = build_map_with_custom(records, custom, default_view=unfiltered) - names = _effective_names(records, custom) - label = _break_list_label(names) - picker_update = gr.update(choices=names, value=None, label=label) - return ( - fig, - picker_update, - picker_update, - records, - pd.DataFrame(columns=["Field", "Value"]), - ) - - -def update_region_choices(state: str): - regions = REGIONS_BY_STATE.get(state, ALL_REGIONS) if state != ALL else ALL_REGIONS - return gr.update(choices=[ALL, *regions], value=ALL) - - -def update_custom_regions(state: str): - regions = REGIONS_BY_STATE.get(state, ALL_REGIONS) - return gr.update(choices=[*regions], value=None) - - -def sync_custom_from_filters(state: str, region: str): - """Push the main map State/Region filters into the generation form. - - A specific filter value overwrites the custom form so the wave-data - generation function (``generate_surf_break``) runs in the same filtered - context as the map; ``ALL`` leaves that field untouched (``gr.skip``). - """ - if state and state != ALL: - regions = REGIONS_BY_STATE.get(state, ALL_REGIONS) - state_update = gr.update(value=state) - else: - regions = ALL_REGIONS - state_update = gr.skip() - if region and region != ALL: - choices = [*regions] if region in regions else [*regions, region] - region_update = gr.update(choices=choices, value=region) - else: - region_update = gr.update(choices=[*regions]) - return state_update, region_update - - -def on_break_pick(break_label: str, records: list[dict], custom: dict | None): - """Update details + shared forecast state when either break list changes. - - Also refreshes the forecast-tab header and both skill defaults (the - forecast dropdown and the agent-tab skill), and mirrors the pick into - the other tab's checklist so both pages stay consistent. Works for - base records and the custom ⭐ break via ``_resolve_pick``. - """ - empty_details = pd.DataFrame(columns=["Field", "Value"]) - if not break_label: - return empty_details, None, _NO_BREAK_HEADER, gr.skip(), gr.skip(), gr.skip(), gr.skip() - record = _resolve_pick(break_label, records or [], custom) - if record is None: - return empty_details, None, _NO_BREAK_HEADER, gr.skip(), gr.skip(), gr.skip(), gr.skip() - raw_skill = str(record.get("skillLevel") or "intermediate").strip().lower() - skill_value = raw_skill if raw_skill in SKILL_ORDER else "intermediate" - # The agent skill dropdown has no "pro-only" tier — map it to expert. - agent_skill_value = "expert" if skill_value == "pro-only" else skill_value - header = ( - f"### {record.get('name', '?')} — " - f"{record.get('region', '?')}, {record.get('state', '?')}" - ) - pick_update = gr.update(value=break_label) - return ( - break_to_table(record), - record, - header, - gr.update(value=skill_value), - gr.update(value=agent_skill_value), - pick_update, - pick_update, - ) - - -def _telemetry(lines: list[str]) -> str: - return "\n".join(lines) - - -def generate_custom_break( - break_name: str, - custom_state: str, - custom_region: str, - base_records: list[dict], - existing_custom: dict | None, - filter_state: str = ALL, - filter_region: str = ALL, - progress=gr.Progress(), -): - """Generate one session-only break via app/generate_surf_break.py. - - The main map State/Region filters flow through here: an empty custom - field falls back to the active map filter so the wave-data generation - function (``generate_surf_break``) always runs in the filtered context. - An explicitly typed custom value still takes precedence. - Yields progressive (log, details, map, dropdown, state) tuples so the - telemetry panel streams the tool-call process live. Only one custom - break exists per session — a new generation replaces the old one, and - it is never written to disk (gr.State dies with the session). - """ - logs: list[str] = [] - empty_details = pd.DataFrame(columns=["Field", "Value"]) - - def emit(fig=None, details=None, choices=None): - return ( - _telemetry(logs), - details if details is not None else empty_details, - fig if fig is not None else gr.skip(), - choices if choices is not None else gr.skip(), - choices if choices is not None else gr.skip(), - gr.skip(), - ) - - break_name = (break_name or "").strip() - raw_state = (custom_state or "").strip() - raw_region = (custom_region or "").strip() - # Fall back to the main map filters when the custom field is empty. - state = raw_state - if not state and filter_state and filter_state != ALL: - state = filter_state - logs.append(f"↪ Using map filter state='{state}'.") - region = raw_region - if not region and filter_region and filter_region != ALL: - region = filter_region - logs.append(f"↪ Using map filter region='{region}'.") - if not break_name or not state or not region: - logs.append("⚠️ Enter a break name, state, and region first.") - yield emit() - return - - note = ( - "ℹ️ One custom break per session — generating again replaces it. " - "Session-only: never saved to data/, deleted when the session ends." - ) - if existing_custom: - logs.append(f"♻️ Replacing previous session break '{existing_custom.get('name', '?')}'.") - logs.append(note) - t0 = time.time() - logs.append( - f"🔧 [tool: build_surf_break_prompt] name='{break_name}' " - f"state='{state}' region='{region}' + schema/example from data/." - ) - progress(0.15, desc="Building prompt…") - yield emit() - - try: - gen = _load_generator() - logs.append( - f"🧠 [tool: generate_surf_break] InferenceClient " - f"provider='{gen.PROVIDER}' model='{gen.MODEL_ID}' " - f"(single-shot structured JSON, temp=0.4, max_tokens=2048)…" - ) - progress(0.4, desc="Calling surf-break generator…") - yield emit() - - generated = gen.generate_surf_break( - break_name=break_name, state=state, region=region - ) - logs.append( - "📦 [tool: extract_json] Stripped fences / outermost {…}, parsed JSON " - f"keys={sorted(generated.keys())}." - ) - progress(0.75, desc="Validating result…") - yield emit() - - custom = { - "id": f"session-only | {break_name} | {state} | {region}", - "state": state, - "region": region, - **generated, - } - custom["state"] = state - custom["region"] = region - loc = dict(custom.get("location") or {}) - loc["state"] = state - loc["region"] = region - custom["location"] = loc - - lat, lng = _lat(custom), _lng(custom) - dt = time.time() - t0 - logs.append( - f"✅ Done in {dt:.1f}s → '{custom.get('name', break_name)}' " - f"({custom.get('skillLevel', '?')}, {custom.get('breakType', '?')}) " - f"@ lat={lat}, lng={lng}. Added to map ⭐ (session-only)." - ) - progress(1.0, desc="Done") - fig = build_map_with_custom(base_records or [], custom) - details = break_to_table(custom) - names = _effective_names(base_records or [], custom) - picker_update = gr.update( - choices=names, - value=f"{custom.get('name', '')} ⭐ (your break)", - label=_break_list_label(names), - ) - yield ( - _telemetry(logs), - details, - fig, - picker_update, - picker_update, - custom, - ) - except Exception as e: # noqa: BLE001 — surface generator errors in the telemetry panel - logs.append(f"❌ Generation failed: {e}") - logs.append("💡 Check HF_TOKEN is set and the inference provider serves the model.") - yield emit() - - -def clear_custom_break(base_records: list[dict], selected: dict | None = None): - """Delete the session-only break and rebuild the base map. - - If the deleted custom break was the forecast selection, the shared - ``selected_break`` state (and forecast header) is cleared too; - otherwise both are left untouched. - """ - fig = build_map(base_records or [], default_view=False) - names = [r.get("name", "?") for r in (base_records or [])] - was_selected_custom = bool( - selected and str(selected.get("id", "")).startswith("session-only") - ) - picker_update = gr.update(choices=names, value=None, label=_break_list_label(names)) - return ( - None, - "🗑️ Session break deleted.", - pd.DataFrame(columns=["Field", "Value"]), - fig, - picker_update, - picker_update, - None if was_selected_custom else gr.skip(), - _NO_BREAK_HEADER if was_selected_custom else gr.skip(), - gr.skip(), - ) - - -def fetch_forecast( - selected_break: dict | None, - skill: str, - progress=gr.Progress(), -): - """Stage 1 — deterministic forecast only: score + build the 3 charts. - - Delegates to ``app/surf_forecast.get_scored_week`` (Open-Meteo + - deterministic scoring) and builds score / swell / wind figs. No LLM - call here, so the graphs render immediately and the user can inspect - them before opting into Stage 2 (``generate_reports``). Runs - automatically on break pick / skill change (no button). Yields - (status, score_fig, waves_fig, wind_fig, scored_payload, - telemetry, report_md) tuples — report_md is always reset so a stale - report never lingers after a new fetch. The LLM never owns numbers — - it only narrates these scores in Stage 2. - """ - logs: list[str] = [] - - def emit(status, score_fig, waves_fig, wind_fig, payload): - return ( - status, - score_fig, - waves_fig, - wind_fig, - payload, - _telemetry(logs), - _NO_REPORT_MD, - ) - - if not selected_break: - logs.append("⚠️ No break selected — pick one in the break list above.") - yield emit( - "⚠️ Pick a break in the list above first.", - _empty_forecast_fig(), - _empty_forecast_fig(), - _empty_forecast_fig(), - None, - ) - return - try: - days_int = 7 - t0 = time.time() - logs.append( - f"🔧 [tool: get_scored_week] break='{selected_break.get('name', '?')}' " - f"skill='{skill}' days={days_int} (Open-Meteo marine+wind → scoring.score_week)." - ) - progress(0.15, desc="Fetching + scoring forecast…") - yield emit( - "⏳ Fetching + scoring forecast…", - _empty_forecast_fig(), - _empty_forecast_fig(), - _empty_forecast_fig(), - None, - ) - - mod = _load_forecast() - result = mod.get_scored_week( - selected_break, skill=skill, days=days_int - ) - scored = result.get("scored", []) or [] - skill_used = result.get("skill", skill) - spot = result.get("spot", {}) - progress(0.6, desc="Building charts…") - score_fig = mod.build_score_fig(scored) - waves_fig = mod.build_waves_fig(scored) - wind_fig = mod.build_wind_fig(scored, spot) - try: - sun = result.get("sun") or {} - score_fig = mod.add_sun_markers(score_fig, sun) - waves_fig = mod.add_sun_markers(waves_fig, sun) - except AttributeError: - sun = {} - pass - best = mod.best_window(scored) - daily = mod.daily_best(scored) - dt = time.time() - t0 - logs.append( - f"📊 [tool: score_week] {len(scored)} hour(s) scored " - f"(skill={skill_used}, daily bests={len(daily)}) in {dt:.1f}s." - ) - progress(1.0, desc="Charts ready") - if not scored: - logs.append("⚠️ No scored hours — charts are empty, report disabled.") - yield emit( - f"⚠️ {selected_break.get('name', '?')} · no scored hours ({days_int}d).", - score_fig, - waves_fig, - wind_fig, - None, - ) - return - - logs.append( - "✅ Charts ready — inspect them above, then press " - "“Generate surf report ✨” for the write-up (one sentence per day + recommendation)." - ) - status = ( - f"✅ {selected_break.get('name', '?')} · " - f"skill {skill_used} · " - f"{len(scored)} hour(s) scored ({days_int}d). " - f"Charts ready — generate the report when ready." - ) - wetsuit = result.get("wetsuit_hint") - if wetsuit: - status += f" 🤿 {wetsuit}." - payload = { - "break": selected_break, - "skill": skill_used, - "spot": spot, - "scored": scored, - "daily": daily, - "best": best, - "days": days_int, - "sun": result.get("sun") or {}, - "sst_c": result.get("sst_c"), - "wetsuit_hint": result.get("wetsuit_hint"), - } - yield emit(status, score_fig, waves_fig, wind_fig, payload) - except Exception as e: # noqa: BLE001 — surface fetch/score errors in the status box - logs.append(f"❌ Forecast failed: {e}") - yield emit( - f"❌ Forecast failed: {e}", - _empty_forecast_fig(), - _empty_forecast_fig(), - _empty_forecast_fig(), - None, - ) - - -def generate_reports( - scored_payload: dict | None, - progress=gr.Progress(), -): - """Stage 2 — short narrated report, streamed live as plain markdown. - - Reads the ``scored_payload`` saved by :func:`fetch_forecast` and calls - ``app/generate_surf_report.generate_surf_report_stream`` (same - InferenceClient framework/model as break generation, ``stream=True``). - The prompt carries only the daily bests (<=7 lines) + best window, and - the model returns one dot-point per day (one full sentence of outlook - per day) plus a ``**Recommendation: ...**`` - line — small prompt + short output keeps time-to-first-token low. Yields - (report_md, telemetry) tuples; the streamed text IS the report, so no - parse step. The LLM narrates the provided scores only. - """ - logs: list[str] = [] - - def emit(report_md): - return (report_md, _telemetry(logs)) - - daily = (scored_payload or {}).get("daily") or [] - if not scored_payload or not daily: - logs.append("⚠️ No scored forecast yet — pick a break first.") - yield emit("_No report yet — pick a break to auto-load the forecast first._") - return - - # Instant feedback first: this yield renders before any network wait, - # so the button never looks dead while the model spins up. - logs.append( - f"🔧 [tool: build_surf_report_prompt] {len(daily)} daily bests + " - f"best window → tiny prompt (no hourly rows)." - ) - progress(0.1, desc="Contacting report model…") - yield emit("_Contacting report model… watch the telemetry box below for progress._") - - try: - rep = _load_report() - logs.append( - f"🧠 [tool: generate_surf_report_stream] InferenceClient " - f"provider='{rep.PROVIDER}' model='{rep.MODEL_ID}' " - f"(stream=True, temp={rep.TEMPERATURE}, max_tokens={rep.MAX_TOKENS}, " - f"reasoning disabled via extra_body={rep.EXTRA_BODY} + /no_think)…" - ) - progress(0.3, desc="Writing surf report…") - - t0 = time.time() - text = "" - shown = "" - chunk_count = 0 - first_content_at: float | None = None - last_heartbeat = t0 - stream_stats: dict = {} - for text in rep.generate_surf_report_stream( - break_=scored_payload["break"], - skill=scored_payload["skill"], - daily=daily, - best=scored_payload.get("best"), - days=scored_payload.get("days", len(daily)), - stats=stream_stats, - ): - chunk_count += 1 - # Only real report words go in the report box — the cleaner's - # pre-marker output is "" (thinking stripped), so re-yielding - # every chunk just flickered an empty box with a fake bar. - if text.strip() and text != shown: - shown = text - if first_content_at is None: - first_content_at = time.time() - logs.append( - f"📝 First report words in {first_content_at - t0:.1f}s " - "— writing below." - ) - yield emit(shown) - else: - # While waiting for real words, heartbeat the telemetry - # box so the UI visibly works (chunk count + elapsed). - now = time.time() - if now - last_heartbeat >= 5.0: - last_heartbeat = now - logs.append( - f"⏳ Receiving… {chunk_count} chunk(s), " - f"{now - t0:.0f}s elapsed." - ) - yield emit( - shown - or "_Writing report… progress is in the telemetry box below._" - ) - if not shown.strip(): - if chunk_count: - logs.append( - f"⏳ Stream ended with {chunk_count} chunk(s) but no " - "report words survived cleaning." - ) - raise ValueError( - "Empty stream from report generator " - f"(reasoning_chars={stream_stats.get('reasoning_chars', '?')}, " - f"content_chars={stream_stats.get('content_chars', '?')})." - ) - text = shown - - dt = time.time() - t0 - logs.append( - f"✅ Done in {dt:.1f}s → {chunk_count} chunk(s), " - f"final report {len(text)} chars " - f"(content={stream_stats.get('content_chars', '?')} chars, " - f"hidden reasoning={stream_stats.get('reasoning_chars', '?')} chars — " - f"what you see above is the final report, not the thinking)." - ) - progress(1.0, desc="Done") - yield emit(text) - except Exception as e: # noqa: BLE001 — LLM failure keeps deterministic charts - logs.append(f"❌ Report generation failed: {e}") - logs.append("💡 Check HF_TOKEN is set and the inference provider serves the model.") - logs.append("ℹ️ Charts above are deterministic and still valid.") - yield emit("_Report generation failed — charts above still apply._") - - -def _coerce_scored_hours(payload) -> list[dict] | None: - """Coerce a score_week tool_result payload to a list of scored hour dicts. - - Accepts a list of dicts directly, a JSON string, or a Python-repr - string (``str(list)`` as captured by the wavereader trace), plus a - ``{"scored": [...]}`` wrapper (as an object or a JSON string of one — - trace observations are strings). Also accepts the - ``{"windows": [...]}`` slim shape from ``find_best_windows``. - Returns None when not scorable. - """ - if isinstance(payload, dict) and isinstance(payload.get("scored"), list): - payload = payload["scored"] - if isinstance(payload, dict) and isinstance(payload.get("windows"), list): - payload = payload["windows"] - if isinstance(payload, str): - text = payload.strip() - if not text: - return None - try: - payload = json.loads(text) - except (json.JSONDecodeError, ValueError): - try: - payload = ast.literal_eval(text) - except (ValueError, SyntaxError): - return None - if isinstance(payload, dict) and isinstance(payload.get("scored"), list): - payload = payload["scored"] - if not isinstance(payload, list) or not payload: - return None - if not all(isinstance(r, dict) and "score" in r and "time" in r for r in payload): - return None - return payload - - -def _agent_scored_hours(trace) -> list[dict] | None: - """Return the LAST score_week tool_result payload from an agent trace. - - Charts are built from this deterministic payload — the LLM never owns - numbers. Returns None when the run surfaced no scorable hours. - """ - events = list(getattr(trace, "events", None) or []) - for ev in reversed(events): - if getattr(ev, "type", None) != "tool_result": - continue - data = getattr(ev, "data", None) or {} - if data.get("name") != "score_week": - continue - payload = data.get("observation") - if payload is None: - payload = data.get("payload", data.get("result")) - hours = _coerce_scored_hours(payload) - if hours: - return hours - return None - - -def _agent_score_spot_ref(trace) -> tuple[str | None, str | None]: - """Return (spot_name, region) from the last score_week tool_call, if any.""" - events = list(getattr(trace, "events", None) or []) - for ev in reversed(events): - if getattr(ev, "type", None) != "tool_call": - continue - data = getattr(ev, "data", None) or {} - if data.get("name") != "score_week": - continue - args = data.get("arguments", {}) - if isinstance(args, str): - try: - args = json.loads(args) - except (json.JSONDecodeError, ValueError): - args = {} - if isinstance(args, dict): - return args.get("spot_name"), args.get("region") - return None, None - return None, None - - -def _coerce_rank_rows(payload) -> list[dict] | None: - """Coerce a rank_spots_this_week tool_result payload to a row list. - - Accepts a list of dicts directly, a JSON string, or a Python-repr - string (``str(list)`` as captured by the wavereader trace), plus a - ``{"rank": [...]}`` wrapper as an object or JSON string. Returns - None when not a rank payload. - """ - if isinstance(payload, dict) and isinstance(payload.get("rank"), list): - payload = payload["rank"] - if isinstance(payload, str): - text = payload.strip() - if not text: - return None - try: - payload = json.loads(text) - except (json.JSONDecodeError, ValueError): - try: - payload = ast.literal_eval(text) - except (ValueError, SyntaxError): - return None - if isinstance(payload, dict) and isinstance(payload.get("rank"), list): - payload = payload["rank"] - if not isinstance(payload, list) or not payload: - return None - if not all( - isinstance(r, dict) and "name" in r and "best_score" in r for r in payload - ): - return None - return payload - - -def _parse_tool_args(data: dict) -> dict: - """Return the arguments dict from a tool_call event payload.""" - args = data.get("arguments", data.get("args", {})) - if isinstance(args, str): - try: - parsed = json.loads(args) - return parsed if isinstance(parsed, dict) else {} - except (json.JSONDecodeError, ValueError): - return {} - return args if isinstance(args, dict) else {} - - -def _result_payload(data: dict): - """Return the observation payload from a tool_result event payload.""" - payload = data.get("observation") - if payload is None: - payload = data.get("payload", data.get("result")) - return payload - - -def _agent_chart_data(trace) -> dict: - """Collect ALL score_week + get_forecast payloads + rank payload. - - Returns ``{"spots": [...], "forecasts": [...], "rank": [...] | None}`` - where each spot is ``{"label": str, "spot_name": str | None, - "region": str | None, "hours": [...]}`` and each forecast entry is - ``{"label": str, "spot_name": ..., "region": ..., "hours": [...]}`` - with swell/wind-chartable (unscored) hours. Handles both the new - ``{"scored": [...]}`` dict shape and the legacy list shape via - :func:`_coerce_scored_hours`. Score calls are paired with their - tool_call args in FIFO order so each chart is labelled with the spot - the agent actually scored; forecast calls pair the same way via - :func:`_coerce_forecast_hours`. - """ - spots: list[dict] = [] - forecasts: list[dict] = [] - rank: list[dict] | None = None - events = list(getattr(trace, "events", None) or []) - pending: list[tuple[str | None, str | None, str | None]] = [] - pending_fc: list[tuple[str | None, str | None]] = [] - for ev in events: - ev_type = getattr(ev, "type", None) - data = getattr(ev, "data", None) or {} - if ev_type in ("tool_call", "tool_start", "tool"): - name = data.get("name") - if name in ("score_week", "find_best_windows"): - args = _parse_tool_args(data) - pending.append( - (args.get("spot_name"), args.get("region"), args.get("skill")) - ) - elif name == "get_forecast": - args = _parse_tool_args(data) - pending_fc.append((args.get("spot_name"), args.get("region"))) - elif ev_type in ("tool_result", "tool_end"): - name = data.get("name") - if name in ("score_week", "find_best_windows"): - raw = _result_payload(data) - hours = _coerce_scored_hours(raw) - if not hours: - continue - if pending: - spot_name, region, skill_arg = pending.pop(0) - else: - spot_name, region, skill_arg = None, None, None - skill_val = skill_arg - if isinstance(raw, dict): - spot_info = raw.get("spot") or {} - spot_name = spot_name or spot_info.get("name") - region = region or spot_info.get("region") or spot_info.get( - "state" - ) - skill_val = raw.get("skill") or skill_val - if spot_name: - label = ( - f"{spot_name} ({region})" - if region - else str(spot_name) - ) - else: - label = f"spot {len(spots) + 1}" - spots.append( - { - "label": label, - "spot_name": spot_name, - "region": region, - "skill": skill_val, - "hours": hours, - } - ) - elif name == "get_forecast": - raw = _result_payload(data) - if isinstance(raw, dict) and "error" in raw: - if pending_fc: - pending_fc.pop(0) - continue - hours = _coerce_forecast_hours(raw) - if not hours: - continue - if pending_fc: - spot_name, region = pending_fc.pop(0) - else: - spot_name, region = None, None - if spot_name: - label = ( - f"{spot_name} ({region})" - if region - else str(spot_name) - ) - else: - label = f"forecast {len(forecasts) + 1}" - forecasts.append( - { - "label": label, - "spot_name": spot_name, - "region": region, - "hours": hours, - } - ) - elif name == "rank_spots_this_week": - rows = _coerce_rank_rows(_result_payload(data)) - if rows: - rank = rows - # Deduplicate labels so the dropdown stays unambiguous. - seen: dict[str, int] = {} - for spot in spots: - label = spot["label"] - seen[label] = seen.get(label, 0) + 1 - if seen[label] > 1: - spot["label"] = f"{label} #{seen[label]}" - for entry in forecasts: - label = entry["label"] - seen[label] = seen.get(label, 0) + 1 - if seen[label] > 1: - entry["label"] = f"{label} #{seen[label]}" - return {"spots": spots, "forecasts": forecasts, "rank": rank} - - -def _format_rank_table(rank: list[dict] | None) -> pd.DataFrame: - """Format a rank_spots_this_week payload as a leaderboard dataframe.""" - if not rank: - return pd.DataFrame(columns=["Rank", "Spot", "Region", "Best score", "Best time"]) - rows = [ - ( - i + 1, - r.get("name", "?"), - r.get("region", "?"), - r.get("best_score", ""), - r.get("best_time", ""), - ) - for i, r in enumerate(rank) - ] - return pd.DataFrame( - rows, columns=["Rank", "Spot", "Region", "Best score", "Best time"] - ) - - -def _short_args(args: dict, limit: int = 160) -> str: - try: - text = json.dumps(args, ensure_ascii=False) - except (TypeError, ValueError): - text = str(args) - return text if len(text) <= limit else text[: limit - 1] + "…" - - -def _summarize_result(name: str | None, payload) -> str: - """One-line summary of a tool_result observation for telemetry.""" - if name == "score_week": - hours = _coerce_scored_hours(payload) - if hours: - if isinstance(payload, dict): - spot = payload.get("spot") or {} - label = spot.get("name") - skill = payload.get("skill") - if label: - return ( - f"{len(hours)} scored hour(s) for {label}" - + (f" ({skill})" if skill else "") - ) - return f"{len(hours)} scored hour(s)" - if name == "rank_spots_this_week": - rows = _coerce_rank_rows(payload) - if rows: - top = rows[0] - return ( - f"{len(rows)} spot(s) ranked " - f"(top: {top.get('name', '?')} {top.get('best_score', '?')}/10)" - ) - if name == "get_forecast": - hours = _coerce_forecast_hours(payload) - if hours: - return f"{len(hours)} forecast hour(s) (swell + wind)" - if name == "find_best_windows": - wins = None - if isinstance(payload, dict): - wins = payload.get("windows") - if isinstance(wins, list) and wins: - top = wins[0] - return ( - f"{len(wins)} window(s) " - f"(top: {top.get('time', '?')} {top.get('score', '?')}/10)" - ) - return "filtered windows" - if name == "explain_score_breakdown": - if isinstance(payload, dict) and "components" in payload: - return ( - f"breakdown {payload.get('time', '?')} " - f"{payload.get('score', '?')}/10 {payload.get('components')}" - )[:140] - if name == "get_spot_sun_sst": - if isinstance(payload, dict): - bits = [] - if payload.get("sea_surface_temp_c") is not None: - bits.append(f"SST {payload.get('sea_surface_temp_c')}C") - if payload.get("wetsuit_hint"): - bits.append(str(payload.get("wetsuit_hint"))) - return "; ".join(bits)[:140] or "sun + SST" - if name == "find_similar_spots": - if isinstance(payload, list) and payload: - return ( - f"{len(payload)} similar spot(s) " - f"(top: {payload[0].get('name', '?')})" - ) - if name == "list_states_regions": - if isinstance(payload, dict) and "states" in payload: - return f"{len(payload['states'])} states vocab" - if isinstance(payload, str): - text = payload.strip() - if len(text) > 140: - return f"{len(text)} chars: {text[:139]}…" - return f"{len(text)} chars" if len(text) > 60 else text or "(empty)" - if isinstance(payload, list): - return f"{len(payload)} row(s)" - if isinstance(payload, dict): - if "error" in payload: - return f"error: {payload.get('error')}" - return f"keys={sorted(payload.keys())}" - return str(payload)[:140] if payload is not None else "(empty)" - - -def _build_agent_telemetry(trace, tool_count: int = 0) -> list[str]: - """Rich telemetry lines from trace events: args, duration, summaries. - - Handles ``tool_call``/``tool_start`` (+ legacy ``tool``) starts and - ``tool_result``/``tool_end`` ends, pairing them by event id when - present and FIFO by tool name otherwise. - """ - lines: list[str] = [] - events = list(getattr(trace, "events", None) or []) - pending_by_id: dict[str, tuple[str, dict, float]] = {} - pending_fifo: list[tuple[str, dict, float]] = [] - for ev in events: - ev_type = getattr(ev, "type", None) - data = getattr(ev, "data", None) or {} - ts = float(getattr(ev, "timestamp", 0) or 0) - if ev_type in ("tool_call", "tool_start", "tool"): - name = str(data.get("name", "?")) - args = _parse_tool_args(data) - tid = data.get("id") - if tid is not None: - pending_by_id[str(tid)] = (name, args, ts) - else: - pending_fifo.append((name, args, ts)) - lines.append(f"🔧 [tool: {name}] args={_short_args(args)}") - elif ev_type in ("tool_result", "tool_end"): - name = str(data.get("name", "?")) - payload = _result_payload(data) - summary = _summarize_result(name, payload) - tid = data.get("id") - t0: float | None = None - if tid is not None and str(tid) in pending_by_id: - _, _, t0 = pending_by_id.pop(str(tid)) - elif pending_fifo: - queued_name, _, queued_t0 = pending_fifo[0] - if queued_name == name or queued_name == "?": - _, _, t0 = pending_fifo.pop(0) - if t0 and ts and ts >= t0: - lines.append(f"📦 [{name}] done in {ts - t0:.1f}s → {summary}") - else: - lines.append(f"📦 [{name}] → {summary}") - elif ev_type == "error": - lines.append(f"❌ {data.get('error', data)}") - if tool_count and not lines: - lines.append(f"ℹ️ {tool_count} tool call(s) ran (no trace details).") - return lines - - -def _match_break_by_name(name: str | None) -> dict | None: - """Find an enriched break record by name (case-insensitive).""" - if not name: - return None - want = str(name).strip().lower() - for record in _records(DF): - if str(record.get("name", "")).strip().lower() == want: - return record - return None - - -def _agent_chart_spot(selected_break: dict | None, trace, forecast_mod): - """Resolve the scoring spot for the agent-tab wind chart. - - Prefers the break the agent actually scored (matched from the - score_week tool_call back to the enriched list), falling back to the - encyclopedia ``selected_break``. Returns None when neither resolves — - the wind fig then renders without directional colouring. - """ - to_spot = forecast_mod.enriched_to_scoring_spot - spot_name, _region = _agent_score_spot_ref(trace) - match = _match_break_by_name(spot_name) - if match is not None: - return to_spot(match) - if selected_break: - return to_spot(selected_break) - return None - - -def _format_agent_best( - best: dict | None, label: str | None = None, skill: str | None = None -) -> str: - """Format a best_window() hour as markdown (same shape as forecast tab). - - ``label``/``skill`` suffix keeps agent-tab headers identical to the - forecast tab's ``best_md`` context (spot + skill) whenever known. - """ - if not best: - return "_No scored hours in this window._" - base = ( - f"**{best.get('score')}/10 @ {best.get('time')}** — " - f"{best.get('wave_height_m')}m @ {best.get('wave_period_s')}s, " - f"wind {best.get('wind_speed_kt')}kt " - f"({best.get('wind_direction_deg')}°)" - ) - suffix_bits = [b for b in (label, skill) if b] - if suffix_bits: - base += f" · _{' · '.join(suffix_bits)}_" - return base - - -_CODE_CALL_RE = re.compile(r"([A-Za-z_]\w*)\s*\(([^()]*)\)") -_NON_TOOL_CALLS = frozenset({"print", "final_answer", "len", "str", "float", "int", "round"}) - - -def _parse_code_calls(code: str) -> list[str]: - """Parse executed agent code into short chat-friendly tool lines. - - Strips assignments/``print(...)`` wrappers, drops non-tool calls, - and truncates long arg lists. E.g. - ``win2 = find_best_windows(spot_name="X", ...)`` → - ``🔧 find_best_windows(spot_name="X", …)``. - """ - lines: list[str] = [] - for func, args in _CODE_CALL_RE.findall(code or ""): - if func in _NON_TOOL_CALLS: - continue - args = " ".join(args.split()) - if len(args) > 140: - args = args[:139] + "…" - lines.append(f"🔧 `{func}({args})`") - return lines - - -def _human_tool_status(name: str | None, args: dict) -> str: - """Human-readable activity line for a tool_start event. - - Turns raw calls into e.g. ``Scoring Bells Beach for intermediate…`` - so the status box reads as progress while the tool loads. - """ - args = args or {} - spot = args.get("spot_name") or args.get("spot") or args.get("query") - region = args.get("region") - skill = args.get("skill") - if name == "score_week": - what = f"Scoring {spot or 'spot'}" + (f" ({region})" if region else "") - if skill: - what += f" for {skill}" - return f"{what}…" - if name == "get_forecast": - what = f"Fetching forecast for {spot or 'spot'}" - if region: - what += f" ({region})" - return f"{what}…" - if name == "rank_spots_this_week": - return f"Ranking spots in {region or 'region'}" + ( - f" for {skill}…" if skill else "…" - ) - if name == "get_spot_knowledge": - return f"Looking up {spot or 'spot'}" + ( - f" ({region})…" if region else "…" - ) - if name == "find_spots": - q = args.get("query") or region or "" - return f"Searching breaks for '{q or '…'}'" + ( - f" ({skill})…" if skill else "…" - ) - if name == "find_best_windows": - spot = args.get("spot_name") or args.get("spot") - part = args.get("daypart") or "all" - extra = " (weekend)" if args.get("weekend_only") else "" - return f"Finding best {part} windows for {spot or 'spot'}{extra}…" - if name == "explain_score_breakdown": - spot = args.get("spot_name") or args.get("spot") - return f"Explaining score for {spot or 'spot'}…" - if name == "get_spot_sun_sst": - spot = args.get("spot_name") or args.get("spot") - return f"Checking sun + water temp for {spot or 'spot'}…" - if name == "find_similar_spots": - spot = args.get("spot_name") or args.get("spot") - return f"Finding breaks like {spot or 'spot'}…" - if name == "list_states_regions": - return "Listing states + regions…" - return f"Running {name or 'tool'}…" - - -def _live_spot_label(spot_name: str | None, region: str | None, idx: int) -> str: - """Label a progressively-charted spot (mirrors _agent_chart_data).""" - if spot_name: - return f"{spot_name} ({region})" if region else str(spot_name) - return f"spot {idx}" - - -def _dedupe_spot_labels(spots: list[dict]) -> None: - """Append ``#N`` suffixes so live dropdown labels stay unambiguous.""" - seen: dict[str, int] = {} - for spot in spots: - label = spot.get("label", "?") - seen[label] = seen.get(label, 0) + 1 - if seen[label] > 1: - spot["label"] = f"{label} #{seen[label]}" - - -def _resolve_spot_for_entry( - entry: dict | None, selected_break: dict | None, forecast_mod -): - """Resolve the scoring spot for wind directional colouring. - - Prefers the break the agent actually scored (matched by name back to - the enriched list), falling back to the encyclopedia ``selected_break``. - Single place both the final and progressive chart paths use. - """ - to_spot = forecast_mod.enriched_to_scoring_spot - match = _match_break_by_name((entry or {}).get("spot_name")) - if match is not None: - return to_spot(match) - if selected_break: - return to_spot(selected_break) - return None - - -def _build_chart_trio( - hours: list[dict], spot, label: str | None = None, skill: str | None = None -): - """Build score/swell/wind figs + best markdown with one shared style. - - Same builders, same heights/titles/empty states in the forecast tab, - the agent-tab final render, progressive updates, and the spot dropdown. - """ - mod = _load_forecast() - score_fig = mod.build_score_fig(hours) - waves_fig = mod.build_waves_fig(hours) - wind_fig = mod.build_wind_fig(hours, spot) - best_md = _format_agent_best(mod.best_window(hours), label, skill) - return score_fig, waves_fig, wind_fig, best_md - - -def _coerce_forecast_hours(payload) -> list[dict] | None: - """Coerce a get_forecast frame to chartable swell/wind hour dicts. - - Accepts the raw ``{"hourly": [...]}`` frame (or a JSON/Python-repr - string of one). Each row is mapped to the scored-hour key shape the - swell/wind builders read (``wave_height_m``, ``wave_period_s``, - ``wind_speed_kt``, ``wind_direction_deg``, ``time``) so an unscored - forecast still outputs its swell + wind graphs. Returns None when - no chartable rows exist. - """ - if isinstance(payload, dict) and isinstance(payload.get("hourly"), list): - rows = payload["hourly"] - elif isinstance(payload, str): - text = payload.strip() - if not text: - return None - try: - parsed = json.loads(text) - except (json.JSONDecodeError, ValueError): - try: - parsed = ast.literal_eval(text) - except (ValueError, SyntaxError): - return None - if isinstance(parsed, dict) and isinstance(parsed.get("hourly"), list): - rows = parsed["hourly"] - elif isinstance(parsed, list): - rows = parsed - else: - return None - elif isinstance(payload, list): - rows = payload - elif isinstance(payload, dict): - return None - else: - return None - if not isinstance(rows, list) or not rows: - return None - hours: list[dict] = [] - for r in rows: - if not isinstance(r, dict) or "time" not in r: - continue - try: - wave_h = r.get("wave_height", r.get("wave_height_m")) - period = r.get("wave_period", r.get("wave_period_s")) - wind_spd = r.get("wind_speed_10m") - if wind_spd is None: - wind_spd = r.get("wind_speed_kt") - wind_kt = float(wind_spd) if wind_spd is not None else None - else: - wind_kt = float(wind_spd) * 0.539957 # km/h -> kt - wind_dir = r.get("wind_direction_10m", r.get("wind_direction_deg")) - if wave_h is None or period is None or wind_kt is None or wind_dir is None: - continue - hours.append( - { - "time": r.get("time"), - "wave_height_m": float(wave_h), - "wave_period_s": float(period), - "wave_direction_deg": ( - float(r.get("wave_direction")) - if r.get("wave_direction") is not None - else None - ), - "wind_speed_kt": round(wind_kt, 1), - "wind_direction_deg": float(wind_dir), - } - ) - except (TypeError, ValueError): - continue - return hours or None - - -def _format_forecast_best(hours: list[dict], label: str | None = None) -> str: - """One-line swell/wind summary for an unscored forecast (no scores).""" - if not hours: - return "_No forecast hours in this window._" - try: - peak = max(hours, key=lambda r: float(r.get("wave_height_m") or 0)) - calm = min(hours, key=lambda r: float(r.get("wind_speed_kt") or 0)) - base = ( - f"**Peak {peak.get('wave_height_m')}m @ {peak.get('wave_period_s')}s " - f"({peak.get('time')})** — " - f"lightest wind {calm.get('wind_speed_kt')}kt @ {calm.get('time')}" - ) - except (TypeError, ValueError, KeyError): - base = f"**{len(hours)} forecast hour(s)**" - peak = None - if label: - base += f" · _{label}_ (unscored forecast — no score chart)" - return base - - -def _build_forecast_duo(hours: list[dict], spot, label: str | None = None): - """Build swell + wind figs + summary for a raw get_forecast frame. - - No score fig exists without scoring — callers must pass - ``gr.skip()`` for the score output so the current score chart is kept. - """ - mod = _load_forecast() - waves_fig = mod.build_waves_fig(hours) - wind_fig = mod.build_wind_fig(hours, spot) - best_md = _format_forecast_best(hours, label) - return waves_fig, wind_fig, best_md - - -def _format_pref_chip( - pref_state: str | None, - pref_region: str | None, - skill: str | None, - stance: str | None, - selected_break: dict | None, -) -> str: - """Render the surf-agent context chip (active prefs + encyclopedia pick).""" - state = pref_state if pref_state and pref_state != ALL else "All" - region = pref_region if pref_region and pref_region != ALL else "All" - skill_txt = (skill or "intermediate").strip().lower() - stance_txt = (stance or "no preference").strip().lower() - if selected_break and selected_break.get("name"): - ctx = ", ".join( - p - for p in ( - selected_break.get("name"), - selected_break.get("region"), - selected_break.get("state"), - ) - if p - ) - viewing = ctx or selected_break.get("name") - else: - viewing = "none" - return ( - f"_Prefs: state={state} · region={region} · skill={skill_txt} · " - f"stance={stance_txt} · viewing={viewing}_" - ) - - -HISTORY_PREPEND_TURNS = 6 - - -def on_agent_spot_change(spot_label: str | None, charts_state: dict | None, - selected_break: dict | None): - """Rebuild the agent-tab charts for the spot picked in the dropdown.""" - spots = (charts_state or {}).get("spots") or [] - entry = next((s for s in spots if s.get("label") == spot_label), None) - if entry is None: - return gr.skip(), gr.skip(), gr.skip(), gr.skip() - try: - mod = _load_forecast() - scored = entry["hours"] - spot = _resolve_spot_for_entry(entry, selected_break, mod) - label = entry.get("label") - skill = entry.get("skill") - score_fig, waves_fig, wind_fig, best_md = _build_chart_trio( - scored, spot, label, skill - ) - return score_fig, waves_fig, wind_fig, best_md - except Exception: # noqa: BLE001 — keep the current charts on failure - return gr.skip(), gr.skip(), gr.skip(), gr.skip() - - -def chat_fn( - message: str, - history: list[dict] | None, - skill: str, - hf_token: str, - selected_break: dict | None, - pref_state: str | None = None, - pref_region: str | None = None, - stance: str | None = None, - pref_skill: str | None = None, - agent_session=None, - agent_token_used: str | None = None, - agent_conv: list[dict] | None = None, - agent_charts: dict | None = None, - daypart: str | None = None, - progress=gr.Progress(), -): - """Chat with a session-persistent SurfAgent, then chart every payload. - - The ``agent_session`` gr.State holds one SurfAgent per user session so - follow-ups reuse the smolagents conversation memory instead of - starting fresh. A changed ``hf_token`` (or a missing session) rebuilds - the agent; on a rebuild the last ``HISTORY_PREPEND_TURNS`` turns from - ``agent_conv`` are prepended to the question explicitly. Streams - ``run_stream`` events, then renders ALL score_week payloads in the - trace (spot dropdown when >1), swell + wind charts for get_forecast - payloads, plus the rank leaderboard when a - rank_spots_this_week payload exists. The textbox is cleared via the - second output (``""``). - Yields (chatbot, msg_box, telemetry, status, score_fig, waves_fig, - wind_fig, best_md, spot_dd, rank_df, charts, session, token_used, - conv, charts_visible, rank_visible) tuples. - """ - history = list(history or []) - agent_conv = list(agent_conv or []) - logs: list[str] = [] - message = (message or "").strip() - - def emit(status, score_fig=gr.skip(), waves_fig=gr.skip(), wind_fig=gr.skip(), - best_md=gr.skip(), spot_dd=gr.skip(), rank_df=gr.skip(), - charts=gr.skip(), session=gr.skip(), token_used=gr.skip(), - conv=gr.skip(), charts_visible=gr.skip(), rank_visible=gr.skip()): - return ( - history, - "", - _telemetry(logs), - status, - score_fig, - waves_fig, - wind_fig, - best_md, - spot_dd, - rank_df, - charts, - session, - token_used, - conv, - charts_visible, - rank_visible, - ) - - if not message: - logs.append("⚠️ Type a question first.") - yield emit("⚠️ Type a question first.") - return - - skill = (pref_skill or skill or "intermediate").strip().lower() - hint_bits = [f"surfer skill level = {skill}"] - # Encyclopedia pick wins over pref state/region; explicit pref wins over ALL. - sel_state = (selected_break or {}).get("state") if selected_break else None - sel_region = (selected_break or {}).get("region") if selected_break else None - eff_state = sel_state or ( - pref_state if pref_state and pref_state != ALL else None - ) - eff_region = sel_region or ( - pref_region if pref_region and pref_region != ALL else None - ) - if eff_state: - hint_bits.append(f"state={eff_state}") - if eff_region: - hint_bits.append(f"region={eff_region}") - if selected_break: - context = ", ".join( - p for p in ( - selected_break.get("name"), - selected_break.get("region"), - selected_break.get("state"), - ) if p - ) - if context: - hint_bits.append(f"User is viewing {context}.") - stance_norm = (stance or "no preference").strip().lower() - if stance_norm and stance_norm != "no preference": - hint_bits.append( - f"stance={stance_norm} (goofy prefers lefts, natural prefers rights, " - "a-frame neutral; affects break advice, not scores)" - ) - daypart_norm = (daypart or "all day").strip().lower() - if daypart_norm == "mornings": - hint_bits.append( - "prefer morning windows: use find_best_windows(daypart=\"morning\") " - "for each candidate spot" - ) - elif daypart_norm == "weekend": - hint_bits.append( - "weekend only: use find_best_windows(weekend_only=True) " - "for each candidate spot" - ) - hint = "\n[" + "; ".join(hint_bits) + "]" - token_key = (hf_token or "").strip() - fresh = agent_session is None or (agent_token_used or "") != token_key - if fresh and agent_conv: - convo_lines = [] - for turn in agent_conv[-HISTORY_PREPEND_TURNS:]: - q = str(turn.get("q", "")).strip() - a = str(turn.get("a", "")).strip() - if q: - convo_lines.append(f"User: {q}") - if a: - convo_lines.append(f"Assistant: {a[:800]}") - convo_block = "\n".join(convo_lines) - question = f"Conversation so far:\n{convo_block}\n\nFollow-up: {message}{hint}" - else: - question = message + hint - - history = [*history, - {"role": "user", "content": message}, - {"role": "assistant", "content": ""}] - if fresh: - logs.append(f"🧠 [agent] Starting new SurfAgent session (skill={skill})…") - else: - logs.append(f"🧠 [agent] Reusing session SurfAgent (skill={skill})…") - progress(0.1, desc="Contacting surf agent…") - yield emit("⏳ Agent thinking…") - - try: - agent_mod = _load_agent() - except Exception as e: # noqa: BLE001 — sibling module not ready yet - logs.append(f"❌ Surf agent module not available: {e}") - history[-1]["content"] = ( - "_The surf agent isn't available yet (app/agent.py failed to load)._" - ) - yield emit("❌ Surf agent module not available.") - return - - if fresh: - try: - token = token_key or None # None → HF_TOKEN env is used - agent_session = agent_mod.SurfAgent(hf_token=token) - agent_token_used = token_key - except ValueError as e: - if "HF_TOKEN" in str(e): - logs.append("⚠️ No HF token — set HF_TOKEN env or paste a token above.") - history[-1]["content"] = ( - "_No Hugging Face token found._ Paste one in the **HF token** box " - "(per-session override, never logged) or set `HF_TOKEN` in your " - "environment, then ask again. Get a token at " - "https://huggingface.co/settings/tokens." - ) - yield emit("⚠️ No HF token — paste one above or set HF_TOKEN.") - return - raise - except Exception as e: # noqa: BLE001 — friendly chat message, not a crash - logs.append(f"❌ Could not start surf agent: {e}") - history[-1]["content"] = f"_Could not start the surf agent: {e}_" - yield emit(f"❌ Could not start surf agent: {e}") - return - agent = agent_session - - tool_count = 0 - live_spots: list[dict] = [] - live_rank: list[dict] | None = None - live_forecasts: list[dict] = [] - pending_score_args: list[dict] = [] - pending_forecast_args: list[dict] = [] - try: - progress(0.3, desc="Streaming agent answer…") - for kind, payload in agent.run_stream(question): - if kind == "model": - history[-1]["content"] += payload - suffix = f" ({tool_count} tool(s) so far)" if tool_count else "" - if live_spots: - suffix += f" · 📊 {live_spots[-1]['label']}" - yield emit(f"💬 Streaming answer…{suffix}") - elif kind == "tool": - # Backward-compat alias fired just before tool_start. - tool_count += 1 - name = str(payload) - logs.append(f"🔧 [tool: {name}]") - yield emit(f"🔧 Tool call {tool_count}: {name}…") - elif kind == "tool_start": - data = payload if isinstance(payload, dict) else {} - name = str(data.get("name", "?")) - args = data.get("arguments", {}) - if not isinstance(args, dict): - args = _parse_tool_args({"arguments": args}) - # The ("tool", name) compat event already counted this call. - if logs and logs[-1] == f"🔧 [tool: {name}]": - pass - else: - tool_count += 1 - logs.append(f"🔧 [tool: {name}]") - logs.append(f"🔧 [tool: {name}] args={_short_args(args)}") - if name in ("score_week", "find_best_windows"): - pending_score_args.append(dict(args)) - elif name == "get_forecast": - pending_forecast_args.append(dict(args)) - human = _human_tool_status(name, args) - progress(0.3, desc=human) - yield emit(f"🔧 {human} (tool {tool_count})") - elif kind == "tool_end": - data = payload if isinstance(payload, dict) else {} - name = str(data.get("name", "?")) - ms = data.get("ms") - summary = data.get("summary", "") - if ms is not None: - logs.append(f"📦 [{name}] done in {ms / 1000:.1f}s → {summary}") - else: - logs.append(f"📦 [{name}] → {summary}") - # Prefer the full observation carried on the event itself - # (CodeAgent inner-tool path); fall back to peeking the trace - # for legacy ToolCallingAgent-style events. - obs = data.get("observation") - if obs is None: - try: - events = list(getattr(getattr(agent, "trace", None), "events", None) or []) - if events: - obs = (getattr(events[-1], "data", None) or {}).get("observation") - except Exception: # noqa: BLE001 — progressive is best-effort - obs = None - if name in ("score_week", "find_best_windows"): - try: - hours = _coerce_scored_hours(obs) - except Exception: # noqa: BLE001 — keep streaming on bad payload - hours = None - if hours: - call_args = ( - pending_score_args.pop(0) - if pending_score_args - else {} - ) - spot_name = call_args.get("spot_name") - region = call_args.get("region") - skill_used = call_args.get("skill") or skill - if isinstance(obs, dict): - spot_info = obs.get("spot") or {} - spot_name = spot_name or spot_info.get("name") - region = region or spot_info.get("region") or spot_info.get( - "state" - ) - skill_used = obs.get("skill") or skill_used - label = _live_spot_label( - spot_name, region, len(live_spots) + 1 - ) - entry = { - "label": label, - "spot_name": spot_name, - "region": region, - "skill": skill_used, - "hours": hours, - } - live_spots.append(entry) - _dedupe_spot_labels(live_spots) - try: - mod = _load_forecast() - spot = _resolve_spot_for_entry( - entry, selected_break, mod - ) - s_fig, w_fig, wi_fig, best = _build_chart_trio( - hours, spot, entry["label"], skill_used - ) - logs.append( - f"📊 [tool: score_week trace] {len(hours)} hour(s) " - f"charted for '{entry['label']}' (deterministic)." - ) - labels = [s["label"] for s in live_spots] - spot_update = gr.update( - choices=labels, - value=entry["label"], - visible=len(labels) > 1, - ) - live_charts = { - "spots": live_spots, - "forecasts": live_forecasts, - "rank": live_rank, - } - yield emit( - f"📊 Charted '{entry['label']}' ({len(hours)} hours) — " - "agent still thinking…", - s_fig, - w_fig, - wi_fig, - best, - spot_update, - gr.skip(), - live_charts, - gr.skip(), - gr.skip(), - gr.skip(), - gr.update(visible=True), - gr.skip(), - ) - except Exception: # noqa: BLE001 — charts fail, answer stands - yield emit(f"📦 {name} done — agent still thinking…") - else: - yield emit(f"📦 {name} done — agent still thinking…") - elif name == "rank_spots_this_week": - try: - rows = _coerce_rank_rows(obs) - except Exception: # noqa: BLE001 — keep streaming on bad payload - rows = None - if rows: - live_rank = rows - rank_df = _format_rank_table(rows) - logs.append( - f"🏆 [tool: rank_spots_this_week trace] {len(rows)} spot(s) " - f"ranked (top: {rows[0].get('name', '?')} " - f"{rows[0].get('best_score', '?')}/10)." - ) - yield emit( - f"🏆 Ranked {len(rows)} spots — top: " - f"{rows[0].get('name', '?')} — agent still thinking…", - gr.skip(), - gr.skip(), - gr.skip(), - gr.skip(), - gr.skip(), - rank_df, - {"spots": live_spots, "forecasts": live_forecasts, "rank": live_rank}, - gr.skip(), - gr.skip(), - gr.skip(), - gr.skip(), - gr.update(visible=True), - ) - else: - yield emit(f"📦 {name} done — agent still thinking…") - elif name == "get_forecast": - try: - hours = _coerce_forecast_hours(obs) - except Exception: # noqa: BLE001 — keep streaming on bad payload - hours = None - if hours: - call_args = ( - pending_forecast_args.pop(0) - if pending_forecast_args - else {} - ) - spot_name = call_args.get("spot_name") - region = call_args.get("region") - label = _live_spot_label( - spot_name, region, len(live_forecasts) + 1 - ) - entry = { - "label": label, - "spot_name": spot_name, - "region": region, - "hours": hours, - } - live_forecasts.append(entry) - _dedupe_spot_labels(live_forecasts) - try: - mod = _load_forecast() - spot = _resolve_spot_for_entry( - entry, selected_break, mod - ) - w_fig, wi_fig, best = _build_forecast_duo( - hours, spot, entry["label"] - ) - logs.append( - f"📊 [tool: get_forecast trace] {len(hours)} hour(s) " - f"charted for '{entry['label']}' (swell + wind, unscored)." - ) - live_charts = { - "spots": live_spots, - "forecasts": live_forecasts, - "rank": live_rank, - } - yield emit( - f"📊 Charted forecast '{entry['label']}' " - f"({len(hours)} hours) — agent still thinking…", - gr.skip(), - w_fig, - wi_fig, - best, - gr.skip(), - gr.skip(), - live_charts, - gr.skip(), - gr.skip(), - gr.skip(), - gr.update(visible=True), - gr.skip(), - ) - except Exception: # noqa: BLE001 — charts fail, answer stands - yield emit(f"📦 {name} done — agent still thinking…") - else: - yield emit(f"📦 {name} done — agent still thinking…") - else: - yield emit(f"📦 {name} done — agent still thinking…") - elif kind == "code": - # Executed code block, parsed into chat-friendly tool - # lines. Display-only — the ("final", ...) event replaces - # the bubble with the clean answer, so never overwrite - # history here and never log "Answer complete" yet. - call_lines = _parse_code_calls(payload if isinstance(payload, str) else "") - if call_lines: - logs.append(f"🧾 executed: {' · '.join(c.strip('`') for c in call_lines)}") - yield emit(f"🧾 Ran {len(call_lines)} call(s) — reading results…") - else: - yield emit("🧾 Code ran — reading results…", ) - elif kind == "final": - # Clean final answer replaces the streamed model/code tokens. - clean = payload if isinstance(payload, str) else str(payload or "") - history[-1]["content"] = clean or history[-1]["content"] - logs.append(f"✅ Answer complete ({tool_count} tool call(s)).") - yield emit("✅ Answer complete — building charts…") - except ValueError as e: - if "HF_TOKEN" in str(e): - logs.append("⚠️ No HF token — set HF_TOKEN env or paste a token above.") - history[-1]["content"] = ( - "_No Hugging Face token found._ Paste one in the **HF token** box " - "or set `HF_TOKEN`, then ask again." - ) - yield emit("⚠️ No HF token — paste one above or set HF_TOKEN.") - return - logs.append(f"❌ Agent run failed: {e}") - history[-1]["content"] += f"\n\n_❌ Agent run failed: {e}_" - yield emit(f"❌ Agent run failed: {e}") - return - except Exception as e: # noqa: BLE001 — keep the partial answer visible - logs.append(f"❌ Agent run failed: {e}") - history[-1]["content"] += f"\n\n_❌ Agent run failed: {e}_" - yield emit(f"❌ Agent run failed: {e}") - return - - try: - trace = getattr(agent, "trace", None) - for line in _build_agent_telemetry(trace, tool_count): - if line not in logs: - logs.append(line) - except Exception: # noqa: BLE001 — telemetry must not crash the chat - pass - - final_answer = history[-1]["content"] if history else "" - agent_conv = [*agent_conv, {"q": message, "a": final_answer}] - - try: - chart_data = _agent_chart_data(getattr(agent, "trace", None)) - except Exception: # noqa: BLE001 — trace parsing must not crash the chat - chart_data = {"spots": [], "forecasts": [], "rank": None} - spots = chart_data.get("spots") or [] - forecasts = chart_data.get("forecasts") or [] - rank = chart_data.get("rank") - rank_df = _format_rank_table(rank) - if rank: - logs.append( - f"🏆 [tool: rank_spots_this_week trace] {len(rank)} spot(s) ranked " - f"(top: {rank[0].get('name', '?')} {rank[0].get('best_score', '?')}/10)." - ) - if not spots and not forecasts: - logs.append("ℹ️ No score_week/get_forecast payload in this answer — charts need a specific break.") - yield emit( - "💬 No scored forecast in this answer — ask for a specific break to see charts.", - gr.skip(), - gr.skip(), - gr.skip(), - gr.skip(), - gr.update(choices=[], value=None, visible=False), - rank_df, - chart_data, - agent_session, - agent_token_used, - agent_conv, - gr.update(visible=False), - gr.update(visible=bool(rank)), - ) - return - - if not spots and forecasts: - try: - mod = _load_forecast() - latest = forecasts[-1] - spot = _resolve_spot_for_entry(latest, selected_break, mod) - waves_fig, wind_fig, best_md = _build_forecast_duo( - latest["hours"], spot, latest.get("label") - ) - logs.append( - f"📊 [tool: get_forecast trace] {len(latest['hours'])} hour(s) charted " - f"for '{latest.get('label')}' (swell + wind, unscored)." - ) - status = ( - f"✅ Forecast charts ready — {len(latest['hours'])} hour(s) " - f"for '{latest.get('label')}' from the agent trace." - ) - history[-1]["content"] += f"\n\n📊 _Forecast charted below: {latest.get('label')} — {best_md}_" - agent_conv = [*agent_conv[:-1], {"q": message, "a": history[-1]["content"]}] - yield emit( - status, - gr.skip(), - waves_fig, - wind_fig, - best_md, - gr.skip(), - rank_df, - chart_data, - agent_session, - agent_token_used, - agent_conv, - gr.update(visible=True), - gr.update(visible=bool(rank)), - ) - except Exception as e: # noqa: BLE001 — charts fail, answer still stands - logs.append(f"⚠️ Could not build forecast charts from trace: {e}") - yield emit( - "💬 Answer above stands — charts unavailable for this run.", - gr.skip(), - gr.skip(), - gr.skip(), - gr.skip(), - gr.skip(), - rank_df, - chart_data, - agent_session, - agent_token_used, - agent_conv, - gr.update(visible=False), - gr.update(visible=bool(rank)), - ) - return - - try: - mod = _load_forecast() - first = spots[0] - # Carry skill through when the trace payload lacks it. - if not first.get("skill"): - first["skill"] = skill - spot = _resolve_spot_for_entry(first, selected_break, mod) - score_fig, waves_fig, wind_fig, best_md = _build_chart_trio( - first["hours"], spot, first["label"], first.get("skill") - ) - logs.append( - f"📊 [tool: score_week trace] {len(first['hours'])} hour(s) charted " - f"for '{first['label']}' (deterministic — not LLM output)." - ) - if len(spots) > 1: - logs.append( - f"📊 {len(spots)} scored spot(s) in this answer — " - "pick one in the chart-spot dropdown." - ) - labels = [s["label"] for s in spots] - spot_update = gr.update(choices=labels, value=labels[0], visible=len(labels) > 1) - status = ( - f"✅ Charts ready — {len(first['hours'])} scored hour(s) " - f"for '{first['label']}' from the agent trace." - ) - if len(spots) > 1: - status += f" ({len(spots)} spots scored — switch via dropdown.)" - history[-1]["content"] += f"\n\n📊 _Scored forecast charted below: {first['label']} — {best_md}_" - agent_conv = [*agent_conv[:-1], {"q": message, "a": history[-1]["content"]}] - yield emit( - status, - score_fig, - waves_fig, - wind_fig, - best_md, - spot_update, - rank_df, - chart_data, - agent_session, - agent_token_used, - agent_conv, - gr.update(visible=True), - gr.update(visible=bool(rank)), - ) - except Exception as e: # noqa: BLE001 — charts fail, answer still stands - logs.append(f"⚠️ Could not build charts from trace: {e}") - yield emit( - "💬 Answer above stands — charts unavailable for this run.", - gr.skip(), - gr.skip(), - gr.skip(), - gr.skip(), - gr.skip(), - rank_df, - chart_data, - agent_session, - agent_token_used, - agent_conv, - gr.update(visible=False), - gr.update(visible=bool(rank)), - ) - - -def clear_agent_chat(): - """Reset the agent tab: session agent, history, charts, leaderboard.""" - return ( - [], - "", - "", - "", - gr.skip(), - gr.skip(), - gr.skip(), - "_No agent answer yet._", - gr.update(choices=[], value=None, visible=False), - pd.DataFrame(columns=["Rank", "Spot", "Region", "Best score", "Best time"]), - None, - None, - "", - [], - gr.update(visible=False), - gr.update(visible=False), + parser = argparse.ArgumentParser(description="wave~reader — break book + swell check") + parser.add_argument( + "--share", + action="store_true", + help="launch a public gradio.live link (great for sharing demos)", ) - - -def on_rank_select(evt: gr.SelectData, rank_df: pd.DataFrame): - """Click a leaderboard row → prefill the agent box with a drill-down. - - The rank table only carries best score/time (no hourly rows), so the - click composes a follow-up question instead of charting directly — - the agent's score_week call then produces the graphs. - """ - try: - row = rank_df.iloc[evt.index[0]] - spot = str(row.get("Spot", "") or "").strip() - region = str(row.get("Region", "") or "").strip() - if not spot: - return gr.skip() - where = f" in {region}" if region and region != "?" else "" - return gr.update(value=f"When should I surf {spot}{where} this week?") - except (AttributeError, IndexError, KeyError, TypeError): - return gr.skip() - - -def build_demo() -> gr.Blocks: - with gr.Blocks(title="wave~reader — break book + swell check") as demo: - with gr.Row(elem_classes=["header-row"]): - with gr.Column(scale=9): - gr.Markdown("# wave~reader", elem_classes=["hero-title"]) - intro_md = gr.Markdown(BROWSE_INTRO) - with gr.Column(scale=1, min_width=150, elem_classes=["mode-switch-wrap"]): - mode_toggle = gr.Checkbox( - value=False, - label=AGENT_MODE_LABEL, - elem_classes=["mode-switch"], - container=True, - ) - records_state = gr.State(_records(DF)) - custom_state = gr.State(None) # one session-only break; never persisted - selected_break = gr.State(None) # shared pick for the forecast tab - scored_state = gr.State(None) # stage-1 scored payload feeding stage-2 report - agent_session = gr.State(None) # session-persistent SurfAgent instance - agent_token_used = gr.State("") # hf_token key the session agent was built with - agent_conv = gr.State([]) # explicit conversation history for fresh agents - agent_charts = gr.State(None) # all score_week payloads + rank payload - with gr.Group(visible=True) as browse_group: - with gr.Tabs() as tabs: - with gr.Tab("break book", id="encyclopedia"): - gr.Markdown("### break book") - gr.Markdown("filter spots, hover a dot for its name, then pick a break for details.") - with gr.Row(): - state_dd = gr.Dropdown([ALL, *STATES], value=ALL, label="State") - region_dd = gr.Dropdown([ALL, *ALL_REGIONS], value=ALL, label="Region") - skill_dd = gr.Dropdown([ALL, *SKILLS], value=ALL, label="Skill level") - with gr.Row(): - with gr.Column(scale=3): - map_plot = gr.Plot(build_map(_records(DF), default_view=True), label="Spots") - cant_find_btn = gr.Button( - "Can't find your local break?", variant="primary" - ) - with gr.Group(visible=False) as custom_box: - gr.Markdown( - "### Generate your local break\n" - "One break per session, generated live via " - "`app/generate_surf_break.py`. Session-only — " - "never saved, deleted when your session ends." - ) - with gr.Row(): - custom_state_dd = gr.Dropdown( - STATES, value=None, label="State" - ) - custom_region_dd = gr.Dropdown( - ALL_REGIONS, - value=None, - label="Region (or type a new one)", - allow_custom_value=True, - ) - custom_name_txt = gr.Textbox( - label="Break name", placeholder="e.g. Kilcunda" - ) - with gr.Row(): - generate_btn = gr.Button("Generate my break", variant="primary") - clear_btn = gr.Button("Delete my break", variant="stop") - telemetry_box = gr.Textbox( - label="Generation telemetry (tool calls)", - lines=8, - max_lines=14, - interactive=False, - placeholder="Press “Generate my break” to see the tool-call trace…", - ) - with gr.Column(scale=2): - break_dd = gr.Radio( - choices=[r.get("name", "?") for r in _records(DF)], - value=None, - label=f"Breaks (pick one for details) — {len(DF)} spot(s)", - elem_classes=["break-list"], - ) - details_df = gr.Dataframe( - headers=["Field", "Value"], - row_count=(16, "fixed"), - column_count=(2, "fixed"), - label="Break details", - wrap=True, - ) - go_forecast_btn = gr.Button( - "Check swell →", variant="primary" - ) - with gr.Tab("swell check", id="forecast"): - gr.Markdown("### swell check") - gr.Markdown( - "pick a break below — charts load automatically " - "+ an optional ai report." - ) - fc_break_dd = gr.Radio( - choices=[r.get("name", "?") for r in _records(DF)], - value=None, - label=f"Breaks (pick one for details) — {len(DF)} spot(s)", - elem_classes=["break-list"], - ) - fc_header = gr.Markdown(_NO_BREAK_HEADER) - fc_skill = gr.Dropdown( - SKILL_ORDER, value="intermediate", label="Skill level" - ) - status_box = gr.Textbox( - label="Status", - interactive=False, - placeholder="Pick a break — forecast loads automatically…", - ) - with gr.Tabs(): - with gr.Tab("Score"): - score_plot = gr.Plot(label="Score (0-10)") - with gr.Tab("Swell"): - waves_plot = gr.Plot(label="Swell") - with gr.Tab("Wind"): - wind_plot = gr.Plot(label="Wind (kt)") - report_btn = gr.Button("Generate surf report ✨", variant="secondary") - report_md = gr.Markdown(_NO_REPORT_MD) - report_telemetry_box = gr.Textbox( - label="Report telemetry (tool calls)", - lines=8, - max_lines=14, - interactive=False, - placeholder="Pick a break for auto scoring trace, then “Generate surf report”…", - ) - with gr.Group(visible=False) as agent_group: - gr.Markdown("### Agentic mode") - gr.Markdown( - "chat with the smolagents surf agent (`app/agent.py`) — it calls " - "forecast/scoring tools and explains in the chat. " - "charts + leaderboard appear below only when the agent " - "actually scores a spot. pick a break in browse mode for " - "context, or just name one in your question." - ) - with gr.Row(): - pref_state = gr.Dropdown( - [ALL, *STATES], value=ALL, label="State preference" - ) - pref_region = gr.Dropdown( - [ALL, *ALL_REGIONS], value=ALL, label="Region preference" - ) - agent_skill = gr.Dropdown( - SKILL_ORDER, - value="intermediate", - label="Skill level", - ) - stance_dd = gr.Dropdown( - ["natural", "goofy", "no preference"], - value="no preference", - label="Stance", - ) - daypart_dd = gr.Dropdown( - ["all day", "mornings", "weekend"], - value="all day", - label="When", - ) - pref_chip = gr.Markdown( - "_Prefs: state=All · region=All · skill=intermediate · " - "stance=no preference · viewing=none_" - ) - with gr.Row(): - agent_token = gr.Textbox( - label="HF token (optional)", - type="password", - placeholder="Defaults to HF_TOKEN env — per-session override, never logged.", - ) - with gr.Row(): - chip1 = gr.Button("Best in NSW this weekend (beginner)", variant="secondary") - chip2 = gr.Button("Bells Beach mornings?", variant="secondary") - chip3 = gr.Button("Quieter like Snapper?", variant="secondary") - chip4 = gr.Button("Wetsuit + sunrise at Noosa?", variant="secondary") - with gr.Group(elem_classes=["chat-panel"]): - agent_chat = gr.Chatbot(label="Surf agent", height=420) - with gr.Row(elem_classes=["chat-input-row"]): - agent_msg = gr.Textbox( - show_label=False, - value="What does the surf look like this morning?", - placeholder="e.g. When should I surf Bells Beach this week? (Enter to send)", - container=False, - scale=8, - ) - agent_send = gr.Button("Send", variant="primary", scale=1) - agent_clear = gr.Button("Clear", variant="stop", scale=1) - agent_telemetry_box = gr.Textbox( - label="Agent activity (live tool calls)", - lines=8, - max_lines=14, - interactive=False, - placeholder="Send a question — tools start/finish here live, e.g. scoring Bells Beach…", - ) - agent_status_box = gr.Textbox( - label="Status", - interactive=False, - placeholder="Ask about a break — charts appear as each spot is scored…", - ) - with gr.Group(visible=False) as agent_charts_group: - agent_best_md = gr.Markdown("_No agent answer yet._") - agent_spot_dd = gr.Dropdown( - choices=[], - value=None, - visible=False, - label="Chart spot (multiple scored)", - ) - with gr.Tabs(): - with gr.Tab("Score"): - agent_score_plot = gr.Plot(label="Score (0-10)") - with gr.Tab("Swell"): - agent_waves_plot = gr.Plot(label="Swell") - with gr.Tab("Wind"): - agent_wind_plot = gr.Plot(label="Wind (kt)") - with gr.Group(visible=False) as agent_rank_group: - agent_rank_df = gr.Dataframe( - headers=["Rank", "Spot", "Region", "Best score", "Best time"], - label="Leaderboard (rank_spots_this_week — click a row to ask about it)", - wrap=True, - ) - - def _toggle_custom_box(visible: bool): - return gr.update(visible=not visible), not visible - - box_visible = gr.State(False) - cant_find_btn.click( - _toggle_custom_box, - inputs=box_visible, - outputs=[custom_box, box_visible], - ) - custom_state_dd.change( - update_custom_regions, - inputs=custom_state_dd, - outputs=custom_region_dd, - ) - - state_dd.change( - update_region_choices, inputs=state_dd, outputs=region_dd - ).then( - update_map, - inputs=[state_dd, region_dd, skill_dd, custom_state], - outputs=[map_plot, break_dd, fc_break_dd, records_state, details_df], - ).then( - sync_custom_from_filters, - inputs=[state_dd, region_dd], - outputs=[custom_state_dd, custom_region_dd], - ) - region_dd.change( - update_map, - inputs=[state_dd, region_dd, skill_dd, custom_state], - outputs=[map_plot, break_dd, fc_break_dd, records_state, details_df], - ).then( - sync_custom_from_filters, - inputs=[state_dd, region_dd], - outputs=[custom_state_dd, custom_region_dd], - ) - skill_dd.change( - update_map, - inputs=[state_dd, region_dd, skill_dd, custom_state], - outputs=[map_plot, break_dd, fc_break_dd, records_state, details_df], - ) - break_dd.change( - on_break_pick, - inputs=[break_dd, records_state, custom_state], - outputs=[details_df, selected_break, fc_header, fc_skill, agent_skill, break_dd, fc_break_dd], - ).then( - fetch_forecast, - inputs=[selected_break, fc_skill], - outputs=[ - status_box, - score_plot, - waves_plot, - wind_plot, - scored_state, - report_telemetry_box, - report_md, - ], - ) - fc_break_dd.change( - on_break_pick, - inputs=[fc_break_dd, records_state, custom_state], - outputs=[details_df, selected_break, fc_header, fc_skill, agent_skill, break_dd, fc_break_dd], - ).then( - fetch_forecast, - inputs=[selected_break, fc_skill], - outputs=[ - status_box, - score_plot, - waves_plot, - wind_plot, - scored_state, - report_telemetry_box, - report_md, - ], - ) - fc_skill.change( - fetch_forecast, - inputs=[selected_break, fc_skill], - outputs=[ - status_box, - score_plot, - waves_plot, - wind_plot, - scored_state, - report_telemetry_box, - report_md, - ], - ) - mode_toggle.change( - on_mode_change, - inputs=mode_toggle, - outputs=[browse_group, agent_group, intro_md], - ) - go_forecast_btn.click( - go_to_forecast, - inputs=None, - outputs=[tabs, mode_toggle, browse_group, agent_group, intro_md], - ) - generate_btn.click( - generate_custom_break, - inputs=[ - custom_name_txt, - custom_state_dd, - custom_region_dd, - records_state, - custom_state, - state_dd, - region_dd, - ], - outputs=[telemetry_box, details_df, map_plot, break_dd, fc_break_dd, custom_state], - ) - clear_btn.click( - clear_custom_break, - inputs=[records_state, selected_break], - outputs=[ - custom_state, - telemetry_box, - details_df, - map_plot, - break_dd, - fc_break_dd, - selected_break, - fc_header, - fc_skill, - ], - ) - report_btn.click( - generate_reports, - inputs=[scored_state], - outputs=[ - report_md, - report_telemetry_box, - ], - ) - pref_state.change( - update_region_choices, inputs=pref_state, outputs=pref_region - ).then( - _format_pref_chip, - inputs=[pref_state, pref_region, agent_skill, stance_dd, selected_break], - outputs=pref_chip, - ) - for _comp in (pref_region, agent_skill, stance_dd, daypart_dd): - _comp.change( - _format_pref_chip, - inputs=[pref_state, pref_region, agent_skill, stance_dd, selected_break], - outputs=pref_chip, - ) - agent_send.click( - chat_fn, - inputs=[agent_msg, agent_chat, agent_skill, agent_token, selected_break, pref_state, pref_region, stance_dd, agent_skill, agent_session, agent_token_used, agent_conv, agent_charts, daypart_dd], - outputs=[ - agent_chat, - agent_msg, - agent_telemetry_box, - agent_status_box, - agent_score_plot, - agent_waves_plot, - agent_wind_plot, - agent_best_md, - agent_spot_dd, - agent_rank_df, - agent_charts, - agent_session, - agent_token_used, - agent_conv, - agent_charts_group, - agent_rank_group, - ], - ) - agent_msg.submit( - chat_fn, - inputs=[agent_msg, agent_chat, agent_skill, agent_token, selected_break, pref_state, pref_region, stance_dd, agent_skill, agent_session, agent_token_used, agent_conv, agent_charts, daypart_dd], - outputs=[ - agent_chat, - agent_msg, - agent_telemetry_box, - agent_status_box, - agent_score_plot, - agent_waves_plot, - agent_wind_plot, - agent_best_md, - agent_spot_dd, - agent_rank_df, - agent_charts, - agent_session, - agent_token_used, - agent_conv, - agent_charts_group, - agent_rank_group, - ], - ) - agent_spot_dd.change( - on_agent_spot_change, - inputs=[agent_spot_dd, agent_charts, selected_break], - outputs=[ - agent_score_plot, - agent_waves_plot, - agent_wind_plot, - agent_best_md, - ], - ) - agent_clear.click( - clear_agent_chat, - inputs=None, - outputs=[ - agent_chat, - agent_msg, - agent_telemetry_box, - agent_status_box, - agent_score_plot, - agent_waves_plot, - agent_wind_plot, - agent_best_md, - agent_spot_dd, - agent_rank_df, - agent_charts, - agent_session, - agent_token_used, - agent_conv, - agent_charts_group, - agent_rank_group, - ], - ) - chip1.click( - lambda: gr.update(value="Where should I surf in New South Wales this weekend as a beginner?"), - inputs=None, - outputs=agent_msg, - ) - chip2.click( - lambda: gr.update(value="When are the best morning windows at Bells Beach this week?"), - inputs=None, - outputs=agent_msg, - ) - chip3.click( - lambda: gr.update(value="What breaks are like Snapper Rocks but quieter?"), - inputs=None, - outputs=agent_msg, - ) - chip4.click( - lambda: gr.update(value="What is the water temp + sunrise/sunset for Noosa Heads this week, and what wetsuit?"), - inputs=None, - outputs=agent_msg, - ) - agent_rank_df.select( - on_rank_select, - inputs=agent_rank_df, - outputs=agent_msg, - ) - return demo - - -def main(): - build_demo().launch(css=APP_CSS, favicon_path=str(FAVICON_PATH)) + args = parser.parse_args() + build_demo().launch(css=APP_CSS, share=args.share) if __name__ == "__main__":