File size: 8,749 Bytes
d23bf95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Pure-function rendering helpers for the kiosk chat + map UI.

Lives separately from app.py so capture/replay tooling can import these
without triggering the (heavy) model load at app.py module level.
"""

from __future__ import annotations

import html
import json
import re


KNOWN_TOOLS = {
    "route_planner", "fare_calculator", "station_info", "line_info",
    "disruption_feed", "knowledge_base", "submit_assistant_state",
}


# --- narrative cleanup -----------------------------------------------------

_THINK_RE = re.compile(r"<think>.*?</think>\s*", re.DOTALL)
_TOOL_CALL_RE = re.compile(r"<tool_call>.*?</tool_call>\s*", re.DOTALL)


def strip_think_block(text: str) -> str:
    """Remove <think>...</think> content (and stray close-tags emitted by the
    Qwen3.5 chat template) before showing the raw model output."""
    text = _THINK_RE.sub("", text)
    return text.replace("</think>", "").strip()


def strip_tool_calls(text: str) -> str:
    """Remove every <tool_call>...</tool_call> block so what remains is just
    the model's narrative for chat display."""
    return _TOOL_CALL_RE.sub("", text)


def to_narrative(text: str) -> str:
    """Narrative text shown in the chat bubble β€” neither think tags nor
    tool-call XML belong there."""
    return strip_think_block(strip_tool_calls(text))


# --- tool card -------------------------------------------------------------

def tool_status(name: str, result) -> tuple[str, str]:
    """(status_label, glyph) for the UI metadata block.
    - hallucinated β†’ βœ— (tool name unknown)
    - registered + error β†’ ⚠ (validation/domain error)
    - else β†’ βœ“"""
    if name not in KNOWN_TOOLS:
        return "hallucinated", "βœ—"
    if isinstance(result, dict) and result.get("error"):
        return "rejected", "⚠"
    return "done", "βœ“"


def summarise_result(name: str, result) -> str:
    if not isinstance(result, dict):
        return ""
    if result.get("error"):
        err = result["error"]
        if isinstance(err, dict) and isinstance(err.get("detail"), list):
            parts = []
            for d in err["detail"][:4]:
                loc = ".".join(str(x) for x in (d.get("loc") or [])[1:])
                parts.append(f"{loc}: {d.get('msg', 'invalid')}")
            return "; ".join(parts)
        return str(err)[:240]
    if name == "route_planner" and isinstance(result.get("stops"), list):
        t = result.get("transfers", 0)
        return f"{len(result['stops'])} stops Β· {t} transfer{'s' if t != 1 else ''} Β· {round(result.get('estimated_minutes', 0))} min"
    if name == "fare_calculator":
        return f"total {result.get('currency', '')} {result.get('total', '?')}"
    if name == "submit_assistant_state":
        return "accepted" if result.get("accepted") else "rejected"
    if name == "disruption_feed" and isinstance(result.get("disruptions"), list):
        return f"{len(result['disruptions'])} active"
    if name == "station_info" and isinstance(result.get("stations"), list):
        return f"{len(result['stations'])} station(s)"
    return ""


def summarise_args(name: str, args: dict, bundle: dict) -> str:
    """One-line preview of tool args (station IDs resolved to names where
    possible) β€” paired with `summarise_result` to drive the tool card.
    Field names mirror the Pydantic schemas in harness/mock_server.py."""
    sb = bundle["station_by_id"]
    if name == "route_planner":
        f = (sb.get(args.get("origin")) or {}).get("name") or args.get("origin") or "?"
        t = (sb.get(args.get("destination")) or {}).get("name") or args.get("destination") or "?"
        s = f"{f} β†’ {t}"
        extras = []
        rs = args.get("station_restrictions") or []
        sc = args.get("segment_closures") or []
        lc = args.get("line_closures") or []
        if rs:
            extras.append(f"{len(rs)} restriction{'s' if len(rs) != 1 else ''}")
        if sc:
            extras.append(f"{len(sc)} closure{'s' if len(sc) != 1 else ''}")
        if lc:
            extras.append(f"{len(lc)} line closure{'s' if len(lc) != 1 else ''}")
        if extras:
            s += " Β· " + ", ".join(extras)
        return s
    if name == "fare_calculator":
        passengers = args.get("passengers") or {}
        if isinstance(passengers, dict):
            parts = [f"{v}Γ— {k}" for k, v in passengers.items() if isinstance(v, int) and v > 0]
            base = ", ".join(parts) if parts else "β€”"
        else:
            base = "β€”"
        rid = args.get("route_id")
        return f"{base} Β· {rid}" if rid else base
    if name == "station_info":
        ids = args.get("station_ids")
        if isinstance(ids, list):
            if len(ids) == 1:
                return (sb.get(ids[0]) or {}).get("name") or ids[0]
            return f"{len(ids)} stations"
        sid = args.get("station_id")
        if sid:
            return (sb.get(sid) or {}).get("name") or sid
        return "β€”"
    if name == "line_info":
        lines = args.get("lines")
        if isinstance(lines, list) and lines:
            return ", ".join(str(l) for l in lines[:4]) + ("…" if len(lines) > 4 else "")
        return str(args.get("line") or "β€”")
    if name == "disruption_feed":
        line = args.get("line")
        station = args.get("station")
        sev = args.get("severity_filter")
        parts = []
        if line:
            parts.append(f"line {line}")
        if station:
            parts.append(f"@{(sb.get(station) or {}).get('name') or station}")
        if sev and sev != "all":
            parts.append(sev)
        return " Β· ".join(parts) if parts else "all disruptions"
    if name == "knowledge_base":
        if args.get("policy_id"):
            return f"policy: {args['policy_id']}"
        q = (args.get("query") or "").strip()
        return (q[:80] + "…") if len(q) > 80 else (q or "β€”")
    if name == "submit_assistant_state":
        out = args.get("outcome") or "β€”"
        ka = args.get("kiosk_action") or {}
        action = ka.get("action") if isinstance(ka, dict) else ka
        return f"{out} / {action or 'β€”'}"
    return "β€”"


def format_tool_card(name: str, args: dict, result, elapsed_ms: float,
                     status: str, glyph: str, bundle: dict) -> str:
    """Compact HTML card for one tool round-trip. Top: glyph + name + time.
    Body: in/out one-liners. Bottom: <details> hides the raw JSON for power
    users. Renders inside a chatbot bot bubble (chatbot has sanitize_html
    disabled so the <div>/<details> survive)."""
    args_preview = summarise_args(name, args, bundle)
    result_preview = summarise_result(name, result) or "β€”"

    args_json = json.dumps(args, indent=2, ensure_ascii=False)
    result_json = json.dumps(result, indent=2, default=str, ensure_ascii=False)
    if len(result_json) > 1200:
        result_json = result_json[:1197] + "…"

    e = html.escape
    return (
        f'<div class="metro-tool-card metro-tool-{e(status)}">'
        f'<div class="metro-tool-head">'
        f'<span class="metro-tool-glyph">{e(glyph)}</span>'
        f'<span class="metro-tool-name">{e(name)}</span>'
        f'<span class="metro-tool-time">{elapsed_ms:.0f}ms</span>'
        f'</div>'
        f'<div class="metro-tool-row">'
        f'<span class="metro-tool-key">in</span>'
        f'<span class="metro-tool-val">{e(args_preview)}</span>'
        f'</div>'
        f'<div class="metro-tool-row">'
        f'<span class="metro-tool-key">out</span>'
        f'<span class="metro-tool-val">{e(result_preview)}</span>'
        f'</div>'
        f'<details class="metro-tool-raw">'
        f'<summary>raw</summary>'
        f'<div class="metro-tool-raw-label">args</div>'
        f'<pre>{e(args_json)}</pre>'
        f'<div class="metro-tool-raw-label">result</div>'
        f'<pre>{e(result_json)}</pre>'
        f'</details>'
        f'</div>'
    )


# --- route geometry --------------------------------------------------------

def route_with_coords(stops, station_by_id: dict) -> list:
    """Annotate route_planner stops with lat/lon + carry through line and
    transfer flags so the JS can draw per-line segments and mark transfer
    stations (matching the /simulator dashboard's drawRoute behavior)."""
    out = []
    for s in stops or []:
        if not isinstance(s, dict):
            continue
        sid = s.get("station_id")
        geo = station_by_id.get(sid, {}) if sid else {}
        out.append({
            "station_id": sid,
            "name": s.get("station_name") or geo.get("name"),
            "lat": geo.get("lat"),
            "lon": geo.get("lon"),
            "line": s.get("line"),
            "is_transfer": bool(s.get("is_transfer")),
        })
    return out