File size: 37,818 Bytes
a1f52bf
 
 
 
 
 
 
91f98a7
a1f52bf
 
c5672b8
 
 
 
 
 
 
a1f52bf
91f98a7
 
 
 
 
 
9fae83d
 
 
 
 
 
 
a1f52bf
9fae83d
a1f52bf
9fae83d
 
 
 
a1f52bf
 
9fae83d
a1f52bf
 
 
 
 
 
 
 
 
 
 
 
 
c5672b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1f52bf
 
 
 
 
 
 
9fae83d
 
 
 
 
 
 
a1f52bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9fae83d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1f52bf
 
 
 
 
 
 
 
c5672b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1f52bf
c5672b8
 
 
 
 
 
 
 
 
 
 
 
 
a1f52bf
 
 
 
 
c5672b8
 
 
 
 
 
 
 
 
 
 
 
 
a1f52bf
 
 
 
 
 
 
 
 
 
c5672b8
 
 
a1f52bf
c5672b8
 
 
a1f52bf
c5672b8
 
 
 
 
 
 
 
 
 
 
 
a1f52bf
 
 
 
 
 
 
 
 
 
 
9fae83d
 
 
 
91f98a7
 
a1f52bf
9fae83d
 
a1f52bf
9fae83d
a1f52bf
9fae83d
91f98a7
 
 
 
 
a1f52bf
 
9fae83d
 
a1f52bf
 
 
 
9fae83d
a1f52bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9fae83d
 
 
 
 
 
8c80df3
9fae83d
 
91f98a7
 
 
9fae83d
 
a1f52bf
 
 
 
9fae83d
a1f52bf
 
 
 
 
 
 
9fae83d
 
 
a1f52bf
 
 
 
 
 
 
9fae83d
 
 
a1f52bf
 
 
 
 
 
 
9fae83d
 
 
 
 
a1f52bf
9fae83d
 
 
a1f52bf
 
9fae83d
 
 
c5672b8
 
 
a1f52bf
 
9fae83d
a1f52bf
 
 
 
 
 
 
9fae83d
a1f52bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9fae83d
 
a1f52bf
9fae83d
 
a1f52bf
 
 
 
9fae83d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c5672b8
 
 
 
 
a1f52bf
c5672b8
 
a1f52bf
 
c5672b8
 
 
 
 
a1f52bf
 
 
 
 
9fae83d
a1f52bf
 
 
 
 
 
 
 
 
 
 
 
 
9fae83d
 
 
a1f52bf
9fae83d
 
 
a1f52bf
9fae83d
a1f52bf
 
9fae83d
a1f52bf
 
 
91f98a7
 
 
 
 
 
 
 
 
 
 
 
 
a1f52bf
91f98a7
a1f52bf
 
91f98a7
 
a1f52bf
8c80df3
91f98a7
 
9fae83d
 
 
a1f52bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91f98a7
 
a1f52bf
 
 
 
 
c5672b8
 
 
a1f52bf
9fae83d
a1f52bf
 
91f98a7
 
 
 
 
 
 
c5672b8
 
a1f52bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91f98a7
a1f52bf
91f98a7
 
 
 
 
 
9fae83d
 
 
 
 
 
 
 
a1f52bf
 
 
 
 
 
91f98a7
 
a1f52bf
91f98a7
9fae83d
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
"""Agent panel: live tool-trace feed, budget meter, verdict banner, charts.

Consumes typed v2 agent events (``token | step | tool_call | tool_result |
final | usage``) from :func:`ui._compat.agent_run_stream`. Every tool call
renders as a trace card — pending while running, then done with a wall-time
ms bar scaled to the slowest call this turn and an expandable payload
preview. The budget meter ticks live (steps / tool calls / score_week cap /
≈tokens) against the slider-chosen tool budget, and the final answer's
"Best: …" contract line is lifted into a verdict banner. Any ``score_week``
payload in a tool result is charted straight into the week strip (the LLM
never owns numbers), and **every answered turn ends with a visualisation
matched to the question**: recommendation questions close on the
recommended-scores chart — a region-sweep leaderboard, or a side-by-side
compare when several spots were scored this turn (a deterministic engine
sweep fills in when the agent never ranked); climate / seafloor / similar /
explain questions get their rose, depth map, similarity bars or component
split; anything else falls back to the viewed spot's own scored week.

User controls in the bar: a "max tool calls" slider (the per-turn step
budget; token + score_week caps scale with it) and chained State → Region
dropdowns — page-one style, the State pick rewrites the Region choices —
that anchor "where should I surf" sweeps. Both are plain component inputs —
session state stays at exactly three ``gr.State`` objects. Skill comes
from the lens panel's Skill filter — one skill, whole page.

The BYO HF token box is session-only: the value is passed straight to the
agent factory per turn, never stored, never logged.
"""

from __future__ import annotations

import html
import json
import re

import gradio as gr

from ui import _compat as C
from ui.charts import climate as climate_chart
from ui.charts import seafloor as seafloor_chart
from ui.charts import score as score_chart
from ui.charts import strip as strip_chart
from ui.contracts import AGENT_KEYS, fill

_TOOL_ICONS = {
    "score_week": "⚙", "rank_region_week": "🏆", "explain_score": "🔍",
    "find_best_windows": "🕐", "find_spots": "🔎", "find_similar_spots": "🧭",
    "get_spot_knowledge": "📖", "get_climate_profile": "🌡",
    "get_seafloor_profile": "🪨", "get_session_brief": "🤿",
    "list_regions": "🗺",
}
_MEDALS = ["🥇", "🥈", "🥉"]
_VERDICT_RE = re.compile(r"Best:\s*(.+)", re.IGNORECASE)

# Query intent (deterministic, on the user's text only): which end-of-turn
# chart matches the question. Scores charts are the house default; the
# specialties jump the queue only when the question asks for them.
_REC_RE = re.compile(
    r"\bwhere\s+(?:should|to|can|shall|could)\b"
    r"|\bbest\s+(?:spots?|breaks?|beaches?|places?)\b"
    r"|\brecommend|\brank\w*|\btop\s*\d+"
    r"|\bcompare|\bversus\b|\bvs\b"
    r"|\bshould\s+i\s+(?:surf|go|paddle)\b", re.IGNORECASE)
_CLIMATE_RE = re.compile(
    r"\bclimate\b|\bmonthly\b|\bmonths?\b|winter|summer|autumn|spring\b"
    r"|\bswell\s+direction\b|\bwhen\s+to\s+go\b", re.IGNORECASE)
_SEAFLOOR_RE = re.compile(
    r"\bseafloor\b|\bsea\s?floor\b|\bsea\s?bed\b|\bbathymetry\b|\bdepths?\b"
    r"|\bstructure\b|\bsandbar\b|\breef\b", re.IGNORECASE)
_SIMILAR_RE = re.compile(
    r"\b(?:spots?|breaks?|beaches?)\s+like\b|\bsimilar\s+to\b"
    r"|\balternatives?\s+to\b|\bquieter\b", re.IGNORECASE)
_EXPLAIN_RE = re.compile(
    r"\bwhy\b|\bexplain\w*|\bcomponent\w*|\bbreakdown\b", re.IGNORECASE)
_SPECIALTY_RE = (
    ("get_climate_profile", _CLIMATE_RE),
    ("get_seafloor_profile", _SEAFLOOR_RE),
    ("find_similar_spots", _SIMILAR_RE),
    ("explain_score", _EXPLAIN_RE),
)
_STATE_CODES = {
    "NSW": "New South Wales", "QLD": "Queensland", "VIC": "Victoria",
    "WA": "Western Australia", "SA": "South Australia", "TAS": "Tasmania",
}

_EMPTY_TRACE = ("<div class='trace-empty'>tool calls land here live — "
                "watch the engines work…</div>")
_EMPTY_RANK_HTML = ("<div class='rank-empty'>leaderboard — ask "
                    "“where should I surf in …” to rank a region.</div>")


def _short_args(args: dict, limit: int = 120) -> 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 _pretty_time(value) -> str:
    text = str(value or "")
    return text.replace("T", " ")[:16]


def _trace_html(cards: list[dict], ask: str = "") -> str:
    """Render the live tool trace: cards + timing bars + payload previews."""
    bits = []
    if ask:
        bits.append(f"<div class='trace-ask'>ask: {html.escape(ask[:140])}</div>")
    if not cards:
        bits.append(_EMPTY_TRACE)
        return "".join(bits) if ask else _EMPTY_TRACE
    times = [c.get("ms") for c in cards
             if c.get("state") == "done" and isinstance(c.get("ms"), (int, float))]
    slowest = max(times) if times else 0.0
    parts = list(bits)
    parts.append("<div class='trace'>")
    for c in cards:
        icon = _TOOL_ICONS.get(str(c.get("name")), "🔧")
        done = c.get("state") == "done"
        state_cls = "done" if done else "pending"
        ms = c.get("ms")
        ms_bit = ("<span class='trace-ms'>…running</span>" if not done
                  else f"<span class='trace-ms'>⚡ {float(ms):.0f} ms</span>"
                  if isinstance(ms, (int, float))
                  else "<span class='trace-ms'>done</span>")
        bar = ""
        if done and isinstance(ms, (int, float)) and slowest > 0:
            pct = max(6, int(round(100.0 * float(ms) / slowest)))
            bar = f"<div class='trace-bar'><i style='width:{pct}%'></i></div>"
        summary = (f"<div class='trace-summary'>{html.escape(str(c.get('summary') or ''))}</div>"
                   if done and c.get("summary") else "")
        details = ""
        if done and c.get("preview"):
            details = ("<details><summary>payload</summary>"
                       f"<pre>{html.escape(str(c['preview']))}</pre></details>")
        parts.append(
            f"<div class='trace-card {state_cls}'>"
            "<div class='trace-head'>"
            f"<span class='trace-icon'>{icon}</span>"
            f"<code>{html.escape(str(c.get('name', '?')))}"
            f"({html.escape(str(c.get('args', '')))})</code>"
            f"{ms_bit}</div>{bar}{summary}{details}</div>"
        )
    parts.append("</div>")
    return "".join(parts)


def _verdict_html(text: str) -> str:
    """Lift the answer's contract verdict line ("Best: …") into a banner."""
    for line in (text or "").splitlines():
        match = _VERDICT_RE.search(line)
        if match:
            clean = re.sub(r"\*", "", match.group(1)).strip()
            if clean:
                return (f"<div class='verdict-banner'>🏆 <b>Best:</b> "
                        f"{html.escape(clean)}</div>")
    return ""


def _format_rank_cards(rank: list[dict] | None) -> str:
    if not rank:
        return _EMPTY_RANK_HTML
    parts = ["<div class='rank-list'>"]
    for i, r in enumerate(rank):
        medal = _MEDALS[i] if i < len(_MEDALS) else str(i + 1)
        score = r.get("best_score", "")
        score_bit = f"{score}/10" if score not in ("", None) else "—"
        parts.append(
            "<div class='rank-card'>"
            f"<span class='rank-num'>{medal}</span>"
            "<span class='rank-name'>"
            f"<b>{html.escape(str(r.get('name', '?')))}</b> "
            f"<i>{html.escape(str(r.get('region', '') or ''))}</i></span>"
            f"<span class='rank-score'>{html.escape(score_bit)}</span>"
            f"<span class='rank-time'>{html.escape(_pretty_time(r.get('best_time')))}</span>"
            "</div>"
        )
    parts.append("</div>")
    return "".join(parts)


def _format_meter(usage: dict | None, source: str,
                  caps: dict | None = None) -> str:
    """Final meter after a turn: exact tokens (when known) + steps + caps."""
    if not usage:
        return "_Token meter — per-turn usage lands here._"
    model = str(usage.get("model") or "").split("/")[-1]
    profile = usage.get("profile")
    budget = usage.get("budget") or caps or {}
    tail = (f" · {profile} profile" if profile else "") \
        + (f" · cap {budget.get('max_steps', '?')} steps" if budget else "") \
        + (f" · {model}" if model else "") + f" (via {source})"
    if usage.get("prompt_tokens") is not None or usage.get("completion_tokens") is not None:
        p = usage.get("prompt_tokens", "?")
        c = usage.get("completion_tokens", "?")
        try:
            total = int(p) + int(c)
        except (TypeError, ValueError):
            total = "?"
        return f"🪙 **prompt {p} · completion {c} · total {total} tokens**{tail}"
    steps = usage.get("steps", "?")
    calls = usage.get("tool_calls") or {}
    n_calls = sum(calls.values()) if isinstance(calls, dict) else "?"
    return f"⚙️ **{steps} steps · {n_calls} tool call(s)**{tail}"


def _live_meter(live: dict, caps: dict, source: str) -> str:
    """Mid-turn meter: running counts against the depth profile's caps."""
    bits = [
        f"⚙️ steps {live.get('steps', 0)}/{caps.get('max_steps', '?')}",
        f"🔧 {live.get('calls', 0)} call(s)",
        f"score_week {live.get('score_week', 0)}/{caps.get('score_week_calls', '?')}",
        f"≈{live.get('chars', 0) // 4} tokens",
    ]
    return " · ".join(bits) + f" — _via {source}_"


def fresh_store() -> dict:
    return {"spots": [], "rank": None, "usage": None}


def _build_strip(hours: list[dict], label: str, records: list[dict]):
    """Week strip + best-hour line for an agent-scored spot."""
    spot_name = label.split(" (")[0]
    match = C.find_break(records, spot_name)
    fig = strip_chart.build_week_strip(hours, _scoring_spot(match), height=520)
    return fig, score_chart.format_best(score_chart.best_window(hours), label)


def _scoring_spot(break_: dict | None) -> dict | None:
    """Minimal scoring spot for wind directional colouring."""
    if not break_:
        return None
    wind = (break_ or {}).get("idealWind", {}) or {}
    direction = wind.get("direction", "N")
    if isinstance(direction, list):
        direction = "/".join(str(d) for d in direction if d)
    return {"ideal_wind": {"direction": str(direction or "N")}}


def _coerce_rank_rows(payload) -> list[dict] | None:
    if isinstance(payload, dict) and isinstance(payload.get("rank"), list):
        payload = payload["rank"]
    if isinstance(payload, dict) and isinstance(payload.get("spots"), list):
        payload = payload["spots"]
    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 _viz_spot(args: dict, obs) -> tuple[str, str]:
    """(name, region) for a tool observation, falling back to call args."""
    info = (obs.get("spot") or {}) if isinstance(obs, dict) else {}
    name = args.get("spot_name") or info.get("name") or ""
    region = args.get("region") or info.get("region") or ""
    return str(name), str(region)


def _specialty_tool(query: str) -> str | None:
    """The tool whose chart the question explicitly asks for, if any."""
    for tool_name, rx in _SPECIALTY_RE:
        if rx.search(query or ""):
            return tool_name
    return None


def _rose_chart(entry: tuple[dict, object], records: list[dict]):
    _a, obs = entry
    if not isinstance(obs, dict):
        return None, ""
    rose = (obs.get("climate") or {}).get("direction_rose_pct") or {}
    try:
        if not any(float(v or 0) > 0 for v in rose.values()):
            return None, ""
    except (TypeError, ValueError):
        return None, ""
    name, _region = _viz_spot(_a, obs)
    rec = C.find_break(records, name)
    ideal = (((rec or {}).get("idealSwell") or {}).get("direction")
             if rec else None)
    fig = climate_chart.build_rose_fig({"rose": rose}, name=name,
                                       ideal_dirs=ideal)
    return fig, f"swell climate — {name} (5-yr ERA5 rose)"


def _depth_chart(entry: tuple[dict, object], records: list[dict]):
    _a, obs = entry
    if not isinstance(obs, dict):
        return None, ""
    name, _region = _viz_spot(_a, obs)
    rec = C.find_break(records, name)
    if not rec:
        return None, ""
    try:
        grid = (C.get_seafloor(rec) or {}).get("grid") or {}
        return seafloor_chart.build_depth_fig(grid, name), \
            f"seafloor depth map — {name}"
    except Exception:  # noqa: BLE001 — fall through to the next viz
        return None, ""


def _similarity_chart(entry: tuple[dict, object], records: list[dict]):
    _a, obs = entry
    if (isinstance(obs, list) and obs and isinstance(obs[0], dict)
            and "similarity" in obs[0]):
        ref = str(_a.get("spot_name") or "")
        return (score_chart.build_similarity_fig(obs, ref),
                f"{len(obs)} spots like {ref} — similarity")
    return None, ""


def _components_chart(entry: tuple[dict, object], records: list[dict]):
    _a, obs = entry
    if (isinstance(obs, dict) and isinstance(obs.get("components"), dict)
            and obs["components"]):
        name, _region = _viz_spot(_a, obs)
        when = str(obs.get("time") or "")
        fig = score_chart.build_component_fig(obs["components"], spot=name,
                                              when=when, score=obs.get("score"))
        return fig, f"component split — {name} @ {when}"
    return None, ""


def _specialty_figure(by_name: dict[str, tuple[dict, object]],
                      records: list[dict],
                      prefer: str | None = None):
    """First buildable question-specific chart from the tool payloads.

    Scans climate rose → seafloor depth map → similarity bars → component
    split; a question-matched tool (``prefer``) is scanned first so e.g. a
    seafloor question wins its depth map even when a rose payload exists.
    Returns (figure, markdown note) or (None, "").
    """
    checks = (
        ("get_climate_profile", _rose_chart),
        ("get_seafloor_profile", _depth_chart),
        ("find_similar_spots", _similarity_chart),
        ("explain_score", _components_chart),
    )
    if prefer:
        checks = tuple(sorted(checks, key=lambda c: c[0] != prefer))
    for _tool_name, build in checks:
        fig, note = build(by_name.get(_tool_name, ({}, None)), records)
        if fig is not None:
            return fig, note
    return None, ""


def _scored_compare_fig(scored: list[dict]):
    """Side-by-side best-score bars for a multi-spot scored turn."""
    rows = []
    for s in scored or []:
        best = score_chart.best_window(s.get("hours") or [])
        if not best:
            continue
        rows.append({"name": str(s.get("label") or "?"),
                     "region": str(s.get("region") or ""),
                     "best_score": best.get("score"),
                     "best_time": best.get("time")})
    if len(rows) < 2:
        return None, ""
    names = " vs ".join(r["name"].split(" (")[0] for r in rows[:2])
    fig = score_chart.build_compare_fig(
        rows, "name", "best_score", "recommended breaks — best score this week",
        sub_keys=("region", "best_time"))
    return fig, f"{names} — best-score compare ({len(rows)} spots)"


def _query_region(query: str, records: list[dict]) -> str | None:
    """A canonical state/region named in the query (codes expanded), or None."""
    text = f" {query or ''} ".lower()
    vocab = sorted({str(r.get("state") or "") for r in records}
                   | {str(r.get("region") or "") for r in records},
                   key=len, reverse=True)
    for name in vocab:
        if name and re.search(rf"(?<![a-z]){re.escape(name.lower())}(?![a-z])",
                              text):
            return name
    for code, name in _STATE_CODES.items():
        if re.search(rf"(?<![a-z]){code.lower()}(?![a-z])", text):
            return name
    return None


def _turn_figure(viz: list[tuple[str, dict, object]], selected: dict | None,
                 skill: str, records: list[dict], query: str = "",
                 focus: str | None = None, scored: list[dict] | None = None,
                 charted: bool = False):
    """Pick + build the end-of-turn chart, matched to the question.

    The house chart is the recommended-surf-break-scores view: a
    region-sweep leaderboard, or a side-by-side compare when several spots
    were scored this turn. Question-specific charts (climate rose, seafloor
    depth map, similarity bars, component split) jump the queue only when
    the question asks for them. A recommendation question the agent answered
    without any sweep still closes on one: a deterministic engine sweep of
    the region named in the question (or the page's State/Region focus).
    Numbers come from tool/engine output only, never from the answer text.
    Returns (figure, markdown note) or (None, "") when nothing chartable.
    """
    by_name: dict[str, tuple[dict, object]] = {}
    for name, args, obs in viz:
        by_name.setdefault(str(name), (args if isinstance(args, dict) else {}, obs))
    q = str(query or "")
    recommend = bool(_REC_RE.search(q))
    prefer = _specialty_tool(q)
    special = _specialty_figure(by_name, records, prefer)

    if charted:
        # Multi-spot scored turn: week strips already ran mid-turn — close
        # on the side-by-side scores, never an unrelated refetch.
        return _scored_compare_fig(scored or [])
    if not recommend and prefer and special[0] is not None:
        return special

    # scores first: the sweep leaderboard, then this turn's scored spots
    _a, obs = by_name.get("rank_region_week", ({}, None))
    rows = _coerce_rank_rows(obs)
    if rows:
        region = str((obs or {}).get("region") if isinstance(obs, dict) else "") \
            or str(_a.get("region") or "")
        top = rows[0]
        region_bit = f" — {region}" if region else ""
        note = (f"region sweep{region_bit} — top pick "
                f"**{top.get('name')}** ({top.get('best_score')}/10)")
        return score_chart.build_rank_fig(rows, region), note
    compare = _scored_compare_fig(scored or [])
    if compare[0] is not None:
        return compare

    if recommend:
        region = _query_region(q, records) or (str(focus) if focus else None)
        if region:
            try:
                rows = _coerce_rank_rows(
                    C.api_rank_region_week(region, skill=skill))
            except Exception:  # noqa: BLE001 — chart fallbacks never raise
                rows = None
            if rows:
                top = rows[0]
                return (score_chart.build_rank_fig(rows, region),
                        f"recommended breaks — {region} — top pick "
                        f"**{top.get('name')}** ({top.get('best_score')}/10)")

    if special[0] is not None:
        return special

    sel = selected or {}
    if sel.get("name"):
        payload = C.get_scored_week(sel, skill=skill, days=7)
        hours = payload.get("scored") or []
        if hours:
            label = (f"{sel.get('name')} ({sel.get('region')})"
                     if sel.get("region") else str(sel.get("name")))
            return _build_strip(hours, label, records)

    return None, ""


def chat_fn(message: str, history: list[dict] | None, skill: str,
            hf_token: str, selected: dict | None, store: dict | None,
            steps: int | None, state: str | None, region: str | None,
            records: list[dict]):
    """Stream an agent turn: trace cards + meter + verdict + charts."""
    history = list(history or [])
    store = dict(store or fresh_store())
    cards: list[dict] = []
    usage = None
    verdict = ""
    token_source = "session token" if (hf_token or "").strip() else "space secret / env"
    try:
        steps_n = max(1, int(steps)) if steps is not None else _DEFAULT_STEPS
    except (TypeError, ValueError):
        steps_n = _DEFAULT_STEPS
    caps = C.agent_budget_for_steps(steps_n)
    live = {"steps": 0, "calls": 0, "score_week": 0, "chars": 0}
    meter = _live_meter(live, caps, token_source)
    spots = list(store.get("spots") or [])
    rank = store.get("rank")
    # Chat decorations injected by tool results (chart/seafloor/brief lines).
    # The final event replaces the streamed answer, so they are kept apart
    # and re-appended after it — otherwise they'd be wiped.
    decorations: list[str] = []
    text = (message or "").strip()
    # Tool observations kept for the end-of-turn visualisation (every
    # answered turn ends with a chart), plus whether this turn already
    # charted scored hours.
    viz: list[tuple[str, dict, object]] = []
    turn_charted = False

    def emit(status, strip_fig=None, spot_update=None):
        return fill(AGENT_KEYS, chatbot=history, msg="",
                    trace_html=_trace_html(cards, ask=text), token_md=meter,
                    agent_status=status, verdict_html=verdict,
                    agent_strip=strip_fig if strip_fig is not None else gr.skip(),
                    spot_dd=spot_update if spot_update is not None else gr.skip(),
                    rank_html=_format_rank_cards(rank),
                    agent_store=dict(store, spots=spots, rank=rank, usage=usage))

    if not text:
        yield emit("⚠️ Type a question first.")
        return
    history = [*history, {"role": "user", "content": text}, {"role": "assistant", "content": ""}]
    yield emit("⏳ Agent thinking…")

    skill = C.scoring_skill(skill)
    try:
        stream = C.agent_run_stream(text, skill=skill, hf_token=hf_token or "",
                                    selected_break=selected,
                                    region_hint=_region_hint(state, region),
                                    max_steps=steps_n)
        for kind, payload in stream:
            if kind == "token":
                chunk = str(payload)
                history[-1]["content"] += chunk
                live["chars"] += len(chunk)
                meter = _live_meter(live, caps, token_source)
                yield emit("💬 Streaming answer…")
            elif kind == "step":
                try:
                    live["steps"] = max(live["steps"], int(payload.get("n", 0)))
                except (TypeError, ValueError):
                    pass
                meter = _live_meter(live, caps, token_source)
                yield emit(f"⚙️ Step {live['steps']}/{caps.get('max_steps', '?')} — thinking…")
            elif kind == "tool_call":
                d = payload if isinstance(payload, dict) else {"name": str(payload)}
                name = str(d.get("name", "?"))
                cards.append({"name": name, "args": _short_args(d.get("arguments", {})),
                              "state": "pending", "ms": None, "summary": "", "preview": ""})
                live["calls"] += 1
                if name == "score_week":
                    live["score_week"] += 1
                meter = _live_meter(live, caps, token_source)
                yield emit(f"🔧 {name} — ⚙ engines working…")
            elif kind == "tool_result":
                d = payload if isinstance(payload, dict) else {}
                name = str(d.get("name", "?"))
                for c in reversed(cards):
                    if c["state"] == "pending":
                        c["state"] = "done"
                        c["ms"] = d.get("ms")
                        c["summary"] = d.get("summary") or "done"
                        c["preview"] = (d.get("preview") or "")[:4000]
                        break
                obs = d.get("observation")
                hours = score_chart.coerce_scored_hours(obs)
                if hours:
                    args = d.get("arguments") or {}
                    spot_name = args.get("spot_name")
                    region_arg = args.get("region")
                    if isinstance(obs, dict):
                        info = obs.get("spot") or {}
                        spot_name = spot_name or info.get("name")
                        region_arg = region_arg or info.get("region")
                    label = f"{spot_name} ({region_arg})" if spot_name and region_arg else (spot_name or f"spot {len(spots) + 1}")
                    entry_skill = skill
                    if isinstance(obs, dict) and obs.get("skill"):
                        entry_skill = obs["skill"]
                    spots.append({"label": label, "hours": hours,
                                  "skill": entry_skill,
                                  "region": str(region_arg or "")})
                    # Dedupe repeated chart labels at append time ("#2", "#3", …).
                    counts: dict[str, int] = {}
                    for s in spots:
                        counts[s["label"]] = counts.get(s["label"], 0) + 1
                        if counts[s["label"]] > 1:
                            s["label"] = f"{label} #{counts[s['label']]}"
                    fig, best_md = _build_strip(hours, spots[-1]["label"], records)
                    charted = f"📊 _Charted: {spots[-1]['label']} — {best_md}_"
                    decorations.append(charted)
                    history[-1]["content"] += f"\n\n{charted}"
                    labels = [s["label"] for s in spots]
                    turn_charted = True
                    yield emit(f"📊 Charted '{spots[-1]['label']}' — agent still thinking…",
                               strip_fig=fig,
                               spot_update=gr.update(choices=labels, value=spots[-1]["label"],
                                                     visible=len(labels) > 1))
                    continue
                if isinstance(obs, (dict, list)) and not (
                        isinstance(obs, dict) and "error" in obs):
                    viz.append((name, d.get("arguments") or {}, obs))
                rows = _coerce_rank_rows(obs)
                if rows:
                    rank = rows
                    yield emit(f"🏆 Ranked {len(rows)} spot(s) — agent still thinking…")
                    continue
                if name == "get_seafloor_profile" and isinstance(obs, dict) and "error" not in obs:
                    shelf = obs.get("shelf_class") or "shape read"
                    channel = (" — deeper gutter(s) nearby" if obs.get("channel_hint")
                               else "")
                    note = f"🪨 _Seafloor: {shelf}{channel}._"
                    decorations.append(note)
                    history[-1]["content"] += f"\n\n{note}"
                elif name == "get_session_brief" and isinstance(obs, dict) and "error" not in obs:
                    wetsuit = obs.get("wetsuit") or "check the water temp"
                    sst = obs.get("sst_c")
                    sun = f" · sun {obs.get('sunrise', '')}–{obs.get('sunset', '')}"
                    note = (f"🤿 _{wetsuit}"
                            + (f" (water {sst} °C)" if sst is not None else "")
                            + sun + "_")
                    decorations.append(note)
                    history[-1]["content"] += f"\n\n{note}"
                yield emit(f"📦 {name} done — agent still thinking…")
            elif kind == "usage":
                usage = payload if isinstance(payload, dict) else None
                meter = _format_meter(usage, token_source, caps=caps)
                yield emit("🪙 Usage recorded — agent still thinking…")
            elif kind == "final":
                final_text = str(payload) if payload else history[-1]["content"]
                tail = ("\n\n" + "\n\n".join(decorations)) if decorations else ""
                history[-1]["content"] = final_text + tail
                verdict = _verdict_html(final_text)
                yield emit("✅ Answer complete.")
    except ValueError as e:
        if "HF_TOKEN" in str(e):
            history[-1]["content"] = ("_No Hugging Face token found._ Paste one in the "
                                      "**HF token** box (session-only, never logged) or set "
                                      "`HF_TOKEN`, then ask again.")
            yield emit("⚠️ No HF token — paste one above or set HF_TOKEN.")
            return
        history[-1]["content"] += f"\n\n_❌ Agent run failed: {e}_"
        yield emit(f"❌ Agent run failed: {e}")
        return
    except Exception as e:
        history[-1]["content"] += f"\n\n_❌ Agent run failed: {e}_"
        yield emit(f"❌ Agent run failed: {e}")
        return

    # Every answered turn ends with a visualisation matched to the question,
    # scores first. Multi-spot scored turns close on the side-by-side
    # compare; otherwise the chain runs only when nothing was charted
    # mid-turn. The status is yielded first so a slow fetch (e.g. a make-up
    # region sweep) reads as a staged spin-up, never a hang.
    fallback_fig = None
    viz_note = ""
    if (not turn_charted) or len(spots) > 1:
        yield emit("📊 assembling this turn's chart…")
        try:
            fallback_fig, viz_note = _turn_figure(viz, selected, skill, records,
                                                  query=text,
                                                  focus=_region_hint(state, region),
                                                  scored=spots,
                                                  charted=turn_charted)
        except Exception:  # noqa: BLE001 — a chart must never break the answer
            fallback_fig, viz_note = None, ""
        if fallback_fig is not None:
            history[-1]["content"] += f"\n\n📊 _Charted: {viz_note}_"

    labels = [s["label"] for s in spots]
    status = ("✅ Answer complete — chart updated." if fallback_fig is not None
              else "✅ Answer complete." if (spots or rank)
              else "💬 Nothing chartable this turn — pick a spot and ask again.")
    out = dict(chatbot=history, msg="",
               trace_html=_trace_html(cards, ask=text), token_md=meter,
               agent_status=status, verdict_html=verdict,
               spot_dd=gr.update(choices=labels, value=(labels[0] if labels else None),
                                 visible=len(labels) > 1),
               rank_html=_format_rank_cards(rank),
               agent_store=dict(store, spots=spots, rank=rank, usage=usage))
    if fallback_fig is not None:
        out["agent_strip"] = fallback_fig
    yield fill(AGENT_KEYS, **out)


def show_spot(label: str | None, store: dict | None, records: list[dict]):
    """Rebuild the week strip for the spot picked in the dropdown."""
    spots = (store or {}).get("spots") or []
    entry = next((s for s in spots if s.get("label") == label), None)
    if entry is None:
        return gr.skip()
    try:
        fig, _ = _build_strip(entry["hours"], entry["label"], records)
        return fig
    except Exception:
        return gr.skip()


_DEFAULT_STEPS = 6


def _region_hint(state: str | None, region: str | None) -> str | None:
    """Agent focus from the chained filters: region > state > auto (None)."""
    for value in (region, state):
        if value and value != C.ALL:
            return str(value)
    return None


def ctx_line(skill: str, selected: dict | None, steps: int | None,
             state: str | None, region: str | None) -> str:
    """One live line showing what the agent inherits from the page."""
    caps = C.agent_budget_for_steps(int(steps) if steps else _DEFAULT_STEPS)
    name = (selected or {}).get("name") or "no spot picked"
    reg = (selected or {}).get("region") or ""
    hint = _region_hint(state, region)
    focus = hint or "auto (map pick)"
    view = f"{name}" + (f" — {reg}" if reg else "")
    return (f"scoring for **{C.scoring_skill(skill)}** · viewing **{view}** · "
            f"focus **{focus}** · budget **{caps['max_steps']} steps / "
            f"{caps['score_week_calls']} score_week / {caps['max_tokens']} tokens**")


def clear_chat():
    """Reset the agent panel (session token box is a component, untouched)."""
    return fill(AGENT_KEYS, chatbot=[], msg="",
                trace_html=_trace_html([]), token_md="_Token meter — per-turn usage lands here._",
                agent_status="Ask anything — every answer lands with a chart…",
                verdict_html="",
                agent_strip=None,
                spot_dd=gr.update(choices=[], value=None, visible=False),
                rank_html=_EMPTY_RANK_HTML, agent_store=fresh_store())


CHIP_PROMPTS = [
    "Where should I surf in NSW this weekend as a beginner?",
    "When are the best morning windows at Bells Beach this week?",
    "Compare Bells Beach and Snapper Rocks this weekend.",
    "What's the seafloor like at Bells Beach — and what wetsuit do I need?",
    "What breaks are like Snapper Rocks but quieter?",
]


def build_agent(selected, store, records: list[dict], vocab: dict | None,
                visible: bool = False) -> dict:
    """Build the agent bar (full width, hidden until agentic mode slides on)."""
    states = list((vocab or {}).get("states") or [])
    regions_by_state = dict((vocab or {}).get("regions_by_state") or {})
    all_regions = list((vocab or {}).get("all_regions") or [])

    with gr.Column(elem_classes=["agent-col"], visible=visible) as agent_col:
        gr.Markdown("### 💬 ask the agent — engines compute, the llm narrates")
        gr.Markdown("the agent calls ⚙ scoring, 🪨 the gebco world model, 🌡 era5 climate "
                    "and 📡 open-meteo directly; nemotron 3 ultra only narrates their "
                    "numbers. every answer lands with a question-matched chart — "
                    "recommended-scores leaderboard, week strip, rose, depth map, "
                    "similarity bars or component split.")
        ctx_md = gr.Markdown("", elem_classes=["ctx-line"])
        with gr.Row():
            with gr.Column(scale=1):
                with gr.Row(elem_classes=["agent-controls"]):
                    state_dd = gr.Dropdown([C.ALL, *states], value=C.ALL,
                                           label="State", min_width=120)
                    region_dd = gr.Dropdown([C.ALL, *all_regions], value=C.ALL,
                                            label="Region", min_width=120)
                    steps_slider = gr.Slider(1, 12, step=1,
                                             value=_DEFAULT_STEPS,
                                             label="max calls", scale=2,
                                             min_width=180,
                                             elem_classes=["steps-slider"])
                token_box = gr.Textbox(label="HF token (optional, session-only)", type="password",
                                       placeholder="Defaults to the Space secret — never logged or saved.")
                chatbot = gr.Chatbot(label="Surf agent", height=460)
                with gr.Row(elem_classes=["chat-input-row"]):
                    msg = gr.Textbox(show_label=False,
                                     placeholder="e.g. When should I surf Bells Beach this week? (Enter to send)",
                                     container=False, scale=8)
                    send_btn = gr.Button("Send", variant="primary", scale=1)
                    clear_btn = gr.Button("Clear", variant="stop", scale=1)
                trace_html = gr.HTML(_trace_html([]))
            with gr.Column(scale=1):
                verdict_html = gr.HTML("", elem_classes=["verdict-slot"])
                chips = [gr.Button(p, variant="secondary", elem_classes=["chip-btn"])
                         for p in CHIP_PROMPTS]
                token_md = gr.Markdown("_Token meter — per-turn usage lands here._")
                status_box = gr.Textbox(label="Status", interactive=False,
                                        placeholder="Ask anything — every answer lands with a chart…")
                spot_dd = gr.Dropdown(choices=[], value=None, visible=False, label="Chart spot (multiple scored)")
                agent_strip = gr.Plot(label="week strip — agent-scored spot")
                rank_html = gr.HTML(_EMPTY_RANK_HTML)

    def _chat(message, history, skill, hf_token, sel, st, steps, state, region):
        yield from chat_fn(message, history, skill, hf_token, sel, st,
                           steps, state, region, records)

    def agent_state_regions(state: str):
        """Page-one pattern: the State pick rewrites the Region choices."""
        regions = regions_by_state.get(state, all_regions) if state != C.ALL else all_regions
        return gr.update(choices=[C.ALL, *regions], value=C.ALL)

    def _show(label, st):
        return show_spot(label, st, records)

    def _chip(text):
        return lambda: text

    return {
        "agent_col": agent_col,
        "token_box": token_box, "chatbot": chatbot, "msg": msg,
        "send_btn": send_btn, "clear_btn": clear_btn, "trace_html": trace_html,
        "verdict_html": verdict_html, "rank_html": rank_html,
        "token_md": token_md, "agent_status": status_box, "spot_dd": spot_dd,
        "agent_strip": agent_strip, "chips": tuple(chips),
        "steps_slider": steps_slider, "state_dd": state_dd,
        "region_dd": region_dd, "ctx_md": ctx_md,
        "chat": _chat, "show_spot": _show, "clear": clear_chat, "chip_text": _chip,
        "ctx": ctx_line, "state_regions": agent_state_regions,
    }