mzidan000 commited on
Commit
46f87c1
·
verified ·
1 Parent(s): 25f3b22

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -1,13 +1,65 @@
1
  ---
2
- title: Matchday
3
- emoji: 🐠
4
  colorFrom: blue
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: MatchDay
3
+ emoji:
4
  colorFrom: blue
5
+ colorTo: green
6
  sdk: gradio
7
+ sdk_version: "5.0"
 
8
  app_file: app.py
9
+ pinned: true
10
+ license: mit
11
+ tags:
12
+ - build-small-hackathon
13
+ - backyard-ai
14
+ - agents
15
+ - react-agent
16
+ - tool-use
17
+ - nemotron
18
+ - nvidia
19
+ - modal
20
+ - gradio
21
+ - fifa-world-cup-2026
22
+ - travel
23
+ models:
24
+ - nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16
25
  ---
26
 
27
+ # MatchDay 2026 FIFA World Cup, Vancouver
28
+
29
+ MatchDay is a Layla.ai-style **travel-intelligence agent** with one job: get you
30
+ to a 2026 FIFA World Cup match in Vancouver with the **cheapest flight, the
31
+ safest arrival, and a hotel closest to BC Place — explained, not just listed.**
32
+
33
+ Say *"Flying from Montreal, want Canada vs Qatar, mid-range, June 26-29"* and
34
+ MatchDay's agent builds **3 ranked, scored packages** (flights · hotels ·
35
+ weather · what's near the stadium) on an interactive Leaflet map, each price
36
+ tagged with **honest provenance** — `● live` vs `example` — so nothing is
37
+ hallucinated.
38
+
39
+ ## How it works — Brain + Hands
40
+ - **Brain:** **NVIDIA Nemotron-3-Nano-30B-A3B-BF16** — a 30B-total / **3B-active**
41
+ Mixture-of-Experts model, served on **Modal A100** via SGLang. It selects tools,
42
+ reasons, and writes the explanations. **It never calls an API or names a price.**
43
+ - **Hands:** deterministic Python calls the APIs (flights, hotels, weather, POIs),
44
+ scores packages with a fixed cost / arrival-buffer / stadium-proximity formula,
45
+ and attaches provenance to every value.
46
+ - **Loop:** a bounded ReAct agent (≤5 tool rounds). Nemotron decides the sequence,
47
+ the hands execute, results return — Nemotron-3-Nano emits structured tool calls
48
+ via SGLang's `qwen3_coder` + `nemotron_3` parsers.
49
+
50
+ ## Why this is "small"
51
+ Nemotron-3-Nano-30B is a MoE — only **~3B parameters activate per token**, so the
52
+ reasoning path is genuinely lean. Heavy 30B inference runs **remotely on Modal**
53
+ (sanctioned hackathon compute); the Gradio Space itself stays lightweight.
54
+
55
+ ## Tech
56
+ Nemotron-3-Nano-30B-A3B · Modal (A100-80GB, SGLang v0.5.12) · Gradio · SerpApi
57
+ (Google Flights/Hotels/Search) · Open-Meteo · OpenStreetMap · httpx/Pydantic v2.
58
+
59
+ ## Built for Build Small
60
+ Track: **Backyard AI.** Targeting: 🟩 NVIDIA Nemotron Quest · 🟢 Modal Awards ·
61
+ 🤖 Best Agent · 🎨 Off-Brand (custom UI) · 📡 Sharing-is-Caring (agent trace on the
62
+ Hub) · 📓 Field Notes. **Sponsor tools used: Nemotron-3-Nano-30B (NVIDIA) + Modal.**
63
+
64
+ > Tag strings above are best-guess HF literals — confirm against the official
65
+ > Build Small submission form before submitting.
app.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HF Space entry point — delegates to the MatchDay Gradio app.
2
+
3
+ Hugging Face Spaces run this file and serve the module-level ``demo`` (a
4
+ ``gr.Blocks``). The real app lives in ``matchday/app.py``; this thin wrapper
5
+ just makes the repo root Space-compatible.
6
+ """
7
+ import os
8
+ import sys
9
+
10
+ # Ensure the repo root is importable when the Space runs this file.
11
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
12
+
13
+ from matchday.app import demo # noqa: E402,F401 (re-exported for the Space)
14
+
15
+ if __name__ == "__main__":
16
+ demo.launch()
matchday/__init__.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MatchDay — AI trip planner for the 2026 FIFA World Cup in Vancouver.
2
+
3
+ Brain + Hands architecture: Nemotron-3-Nano-30B-A3B (the brain) selects tools
4
+ and writes explanations; Python (the hands) executes every API call and scores
5
+ every price deterministically. The LLM never calls an API or computes a price.
6
+
7
+ Package layout
8
+ --------------
9
+ models.py Pydantic v2 domain schemas (the structured trip object).
10
+ scoring.py Deterministic package ranking (min-max + tiered weights).
11
+ errors.py FailoverReason taxonomy + classify_error + retry/backoff.
12
+ api_registry.py Self-registering normalizer registry + dispatch + gating.
13
+ trip_tool.py build_trip_packages composite (parallel dispatch + scoring).
14
+ agent_loop.py Bounded ReAct loop (Nemotron picks tools, Python runs them).
15
+ prompts.py System-prompt assembly (3 tiers + behavior preset).
16
+ modal_spike.py Nemotron-on-A100/SGLang Modal serving engine (remote only).
17
+ apis/ Concrete API normalizers — registered on import.
18
+
19
+ Normalizer registration is a side effect of importing ``matchday.apis`` (each
20
+ ``@register_api`` decorator runs at module load). Consumers that need dispatch
21
+ to work must ``import matchday.apis`` first.
22
+ """
23
+
24
+ # Intentionally no re-exports here: importing submodules from the package root
25
+ # would risk circular imports (several submodules import each other). Import the
26
+ # specific submodule you need, e.g. ``from matchday.models import TripRequest``.
27
+
28
+ __version__ = "0.1.0"
matchday/agent.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The Nemotron agent (the Brain) — calls Modal SGLang with tools, returns
2
+ parsed tool_calls.
3
+
4
+ This is the ``agent`` object ``AgentLoop`` expects: an async ``run(messages)``
5
+ returning ``{"tool_calls": [...], "text": str}``. It builds the OpenAI ``tools``
6
+ array from the agent_loop argument schemas, prepends the system prompt, and
7
+ calls ``Server.generate.remote.aio`` (Nemotron-3-Nano-30B on Modal A100).
8
+
9
+ N13 (resolved): SGLang v0.5.12 + ``--tool-call-parser qwen3_coder`` +
10
+ ``--reasoning-parser nemotron_3`` emits structured ``tool_calls`` that this
11
+ class parses. Nemotron decides; Python executes (Brain + Hands).
12
+
13
+ Requires the Modal app to be deployed (``modal deploy matchday/modal_spike.py``)
14
+ so ``Server().generate.remote()`` resolves from any client.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ from typing import Any
21
+
22
+ import modal
23
+
24
+ from matchday.agent_loop import BuildTripPackagesArgs, ClarifyArgs, WebSearchArgs
25
+ from matchday.prompts import build_system_prompt
26
+
27
+ # The Modal app name (must match `modal.App("matchday-spike")` in modal_spike.py
28
+ # and the `modal deploy` target). The 30B model runs remotely; this client only
29
+ # needs the deployed handle — it never imports the serving code.
30
+ _MODAL_APP = "matchday-spike"
31
+ _MODAL_CLASS = "Server"
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # OpenAI-style tool definitions. Parameters come from the Pydantic arg schemas
36
+ # (single source of truth — the same schemas AgentLoop validates against).
37
+ TOOL_SPECS: list[dict[str, Any]] = [
38
+ {
39
+ "type": "function",
40
+ "function": {
41
+ "name": "build_trip_packages",
42
+ "description": (
43
+ "Build 3 ranked, scored trip packages (flights, hotels, weather, "
44
+ "nearby spots) for a FIFA 2026 Vancouver trip. Use when the user "
45
+ "has given an origin, a match, and a date. Returns Cheapest / "
46
+ "Safest Arrival / Closest-to-Stadium packages with real prices."
47
+ ),
48
+ "parameters": BuildTripPackagesArgs.model_json_schema(),
49
+ },
50
+ },
51
+ {
52
+ "type": "function",
53
+ "function": {
54
+ "name": "clarify",
55
+ "description": (
56
+ "Ask the user ONE clarifying question when a required trip slot "
57
+ "(origin, match, or date) is missing and cannot be inferred."
58
+ ),
59
+ "parameters": ClarifyArgs.model_json_schema(),
60
+ },
61
+ },
62
+ {
63
+ "type": "function",
64
+ "function": {
65
+ "name": "web_search",
66
+ "description": (
67
+ "Search the web for supplemental FIFA/Vancouver context (kick-off "
68
+ "times, venue policies). NOT for prices, flights, hotels, or "
69
+ "weather — those come from build_trip_packages."
70
+ ),
71
+ "parameters": WebSearchArgs.model_json_schema(),
72
+ },
73
+ },
74
+ ]
75
+
76
+ # Tool-calling sampling (NVIDIA model card): temp 0.6 / top_p 0.95.
77
+ _TOOL_TEMPERATURE = 0.6
78
+ _TOOL_TOP_P = 0.95
79
+ _TOOL_MAX_TOKENS = 2048
80
+
81
+
82
+ class MatchDayAgent:
83
+ """Nemotron-on-Modal wrapper presenting the ``async run(messages)`` contract.
84
+
85
+ Stateless across turns beyond the cached ``Server`` proxy and system prompt;
86
+ the AgentLoop owns conversation history.
87
+ """
88
+
89
+ def __init__(self, *, thinking: bool = False) -> None:
90
+ # Reference the DEPLOYED Modal app by name: a fresh client process (the
91
+ # Gradio app) cannot call .remote() on a bare imported class — the app
92
+ # must be looked up. Requires `modal deploy matchday/modal_spike.py`.
93
+ self._server = modal.Cls.from_name(_MODAL_APP, _MODAL_CLASS)()
94
+ self._system_prompt = build_system_prompt()
95
+ self._thinking = thinking
96
+
97
+ async def run(self, messages: list[dict], *, tools: list[dict] | None = None) -> dict[str, Any]:
98
+ """Call Nemotron; return ``{"tool_calls": [...], "text": str}``.
99
+
100
+ ``messages`` is the live conversation (user/assistant/tool roles) WITHOUT
101
+ the system prompt — this class prepends it. ``tools=None`` uses the
102
+ default ``TOOL_SPECS``; pass ``tools=[]`` to disable tool-calling (e.g.
103
+ the final explanation turn, where Nemotron must write text, not call a
104
+ tool). On any inference failure, returns empty tool_calls + empty text so
105
+ the caller can fall back.
106
+ """
107
+ eff_tools = TOOL_SPECS if tools is None else tools
108
+ full = [{"role": "system", "content": self._system_prompt}, *messages]
109
+ try:
110
+ msg: dict[str, Any] = await self._server.generate.remote.aio(
111
+ full,
112
+ tools=eff_tools,
113
+ temperature=_TOOL_TEMPERATURE,
114
+ top_p=_TOOL_TOP_P,
115
+ max_tokens=_TOOL_MAX_TOKENS,
116
+ enable_thinking=self._thinking,
117
+ )
118
+ except Exception as exc:
119
+ logger.error("Nemotron generate failed: %s", exc)
120
+ return {"tool_calls": [], "text": ""}
121
+
122
+ return {"tool_calls": _parse_tool_calls(msg), "text": msg.get("content") or ""}
123
+
124
+
125
+ def _parse_tool_calls(msg: dict[str, Any]) -> list[dict[str, Any]]:
126
+ """Normalize SGLang tool_calls into ``{name, arguments, id}`` dicts."""
127
+ out: list[dict[str, Any]] = []
128
+ for tc in msg.get("tool_calls") or []:
129
+ fn = tc.get("function") or {}
130
+ raw_args = fn.get("arguments", "{}")
131
+ if isinstance(raw_args, str):
132
+ try:
133
+ args = json.loads(raw_args) if raw_args.strip() else {}
134
+ except json.JSONDecodeError:
135
+ logger.warning("Malformed tool_call arguments: %r", raw_args)
136
+ args = {}
137
+ elif isinstance(raw_args, dict):
138
+ args = raw_args
139
+ else:
140
+ args = {}
141
+ out.append({"name": fn.get("name", ""), "arguments": args, "id": tc.get("id", "")})
142
+ return out
matchday/agent_loop.py ADDED
@@ -0,0 +1,1076 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MatchDay -- Bounded Nemotron agent loop with controlled tool use.
3
+
4
+ Architecture (per Codex recommendation):
5
+ - Maximum 2 tool rounds (Nemotron calls a tool -> result returned -> one more
6
+ turn to maybe call a second tool or produce final answer).
7
+ - Tool-name allowlist: ["build_trip_packages", "web_search", "clarify"]
8
+ - Pydantic argument validation before every tool call
9
+ - Duplicate-call detection (same tool + same args in same turn)
10
+ - One malformed-call correction attempt (send error back to Nemotron)
11
+ - Per-tool timeout (30s default)
12
+ - Controlled fallback: if agent loop fails, fall back to deterministic
13
+ intent extraction
14
+ - Compact tool results (below 2,000 tokens total)
15
+
16
+ Returns:
17
+ {"type": "tool_called", "tool": str, "result": dict}
18
+ {"type": "final_answer", "text": str}
19
+ {"type": "fallback_to_deterministic", "reason": str}
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import json
26
+ import logging
27
+ from dataclasses import dataclass, field
28
+ from datetime import datetime, timezone
29
+ from typing import Any, Literal
30
+
31
+ from pydantic import BaseModel, Field, field_validator
32
+
33
+ from matchday.api_registry import registry
34
+ from matchday.models import ScoredPackage
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Constants
40
+ # ---------------------------------------------------------------------------
41
+
42
+ MAX_TOOL_ROUNDS: int = 5
43
+ """Maximum number of tool-call rounds Nemotron gets before we force a final
44
+ answer or fall back to deterministic extraction. A ceiling, not a target —
45
+ happy-path turns exit at 2-3 rounds (Nemotron calls build_trip_packages, then
46
+ explains). 5 fits the hard path (4 tool rounds + 1 explain) with the grace call
47
+ as a safety net; reference agents cap far higher (hermes 90, deepagents 9999)."""
48
+
49
+ PER_TOOL_TIMEOUT_SECONDS: float = 30.0
50
+ """Timeout per individual tool execution."""
51
+
52
+ MAX_RESULT_TOKENS: int = 2_000
53
+ """Upper bound on the tool-result string fed back to the model. Tool results
54
+ longer than this are aggressively truncated."""
55
+
56
+ ALLOWED_TOOLS: frozenset[str] = frozenset(
57
+ {"build_trip_packages", "web_search", "clarify"}
58
+ )
59
+ """The only tool names Nemotron may call."""
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Pydantic schemas for tool arguments
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ class BuildTripPackagesArgs(BaseModel):
68
+ """Arguments for the build_trip_packages tool.
69
+
70
+ This tool assembles scored travel packages by dispatching to all enabled
71
+ API normalizers, then running the deterministic scoring engine. Nemotron
72
+ passes the TripRequest slots; the tool returns the top 3 ScoredPackage
73
+ objects.
74
+ """
75
+
76
+ origin_airport: str = Field(
77
+ ..., description="3-letter IATA departure airport code (e.g. YUL)"
78
+ )
79
+ match_date: str = Field(
80
+ ..., description="Match date in YYYY-MM-DD format"
81
+ )
82
+ check_in: str = Field(
83
+ ..., description="Hotel check-in date in YYYY-MM-DD format"
84
+ )
85
+ check_out: str = Field(
86
+ ..., description="Hotel check-out date in YYYY-MM-DD format"
87
+ )
88
+ travelers: int = Field(default=1, ge=1, le=6, description="Number of travelers")
89
+ budget_tier: str = Field(
90
+ default="mid_range",
91
+ pattern=r"^(budget|mid_range|premium)$",
92
+ description="Budget tier: budget, mid_range, or premium",
93
+ )
94
+ match_name: str = Field(
95
+ default="", description="Human-readable match description (optional)"
96
+ )
97
+
98
+ @field_validator("origin_airport")
99
+ @classmethod
100
+ def _normalize_airport(cls, v: str) -> str:
101
+ cleaned = v.strip().upper()
102
+ if len(cleaned) != 3 or not cleaned.isalpha():
103
+ raise ValueError(
104
+ f"Invalid airport code: {v!r}. Must be a 3-letter IATA code."
105
+ )
106
+ return cleaned
107
+
108
+
109
+ class WebSearchArgs(BaseModel):
110
+ """Arguments for the web_search tool.
111
+
112
+ Nemotron uses this to fetch supplemental data (match schedule details,
113
+ venue info, current events near the travel date).
114
+ """
115
+
116
+ query: str = Field(..., min_length=1, description="Search query string")
117
+ max_results: int = Field(default=5, ge=1, le=10, description="Max results to return")
118
+
119
+
120
+ class ClarifyArgs(BaseModel):
121
+ """Arguments for the clarify tool.
122
+
123
+ Nemotron uses this to ask the user a clarifying question when intent is
124
+ ambiguous. The assistant presents the question to the user and waits for
125
+ a response.
126
+ """
127
+
128
+ question: str = Field(
129
+ ..., min_length=1, description="Clarifying question for the user"
130
+ )
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Argument parser registry
135
+ # ---------------------------------------------------------------------------
136
+
137
+ _TOOL_ARG_SCHEMAS: dict[str, type[BaseModel]] = {
138
+ "build_trip_packages": BuildTripPackagesArgs,
139
+ "web_search": WebSearchArgs,
140
+ "clarify": ClarifyArgs,
141
+ }
142
+
143
+
144
+ def _parse_tool_args(tool_name: str, raw_args: dict[str, Any]) -> dict[str, Any]:
145
+ """Validate and parse tool arguments using the registered Pydantic schema.
146
+
147
+ Parameters
148
+ ----------
149
+ tool_name:
150
+ The tool name that Nemotron wants to call.
151
+ raw_args:
152
+ The raw arguments dict emitted by the model.
153
+
154
+ Returns
155
+ -------
156
+ dict[str, Any]
157
+ Validated arguments as a plain dict.
158
+
159
+ Raises
160
+ ------
161
+ ValueError
162
+ If the tool name is unknown or validation fails.
163
+ """
164
+ schema = _TOOL_ARG_SCHEMAS.get(tool_name)
165
+ if schema is None:
166
+ raise ValueError(
167
+ f"No argument schema registered for tool {tool_name!r}. "
168
+ f"Known tools: {list(_TOOL_ARG_SCHEMAS)}"
169
+ )
170
+ validated = schema.model_validate(raw_args)
171
+ return validated.model_dump()
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Duplicate-call detection
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ def _call_key(tool_name: str, args: dict[str, Any]) -> str:
180
+ """Deterministic key for a (tool_name, args) pair used in duplicate detection.
181
+
182
+ Args are serialised with sorted keys so that two calls with the same
183
+ semantic arguments produce the same key regardless of key ordering.
184
+ """
185
+ return f"{tool_name}:{json.dumps(args, sort_keys=True, default=str)}"
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Tool implementation
190
+ # ---------------------------------------------------------------------------
191
+
192
+
193
+ async def _build_trip_packages(
194
+ validated_args: dict[str, Any],
195
+ ) -> dict[str, Any]:
196
+ """Execute the build_trip_packages tool.
197
+
198
+ Dispatches to all enabled API normalizers via the registry, then runs
199
+ the deterministic scoring engine to produce the top 3 ScoredPackage
200
+ objects.
201
+
202
+ Parameters
203
+ ----------
204
+ validated_args:
205
+ Pre-validated argument dict from ``BuildTripPackagesArgs``.
206
+
207
+ Returns
208
+ -------
209
+ dict[str, Any]
210
+ A compact result dict with packages, or an error key.
211
+ """
212
+ from matchday.scoring import score_options
213
+ from matchday.models import Flight, Hotel, Weather
214
+
215
+ # Determine which normalizers are available
216
+ normalizer_names = registry.get_enabled_normalizers(category="full_trip")
217
+ if not normalizer_names:
218
+ # Fall back to individual categories
219
+ flight_names = registry.get_enabled_normalizers(category="flights")
220
+ hotel_names = registry.get_enabled_normalizers(category="hotels")
221
+ weather_names = registry.get_enabled_normalizers(category="weather")
222
+ amenity_names = registry.get_enabled_normalizers(category="amenities")
223
+ else:
224
+ flight_names = [n for n in normalizer_names if n in registry.get_enabled_normalizers(category="flights")]
225
+ hotel_names = [n for n in normalizer_names if n in registry.get_enabled_normalizers(category="hotels")]
226
+ weather_names = [n for n in normalizer_names if n in registry.get_enabled_normalizers(category="weather")]
227
+ amenity_names = [n for n in normalizer_names if n in registry.get_enabled_normalizers(category="amenities")]
228
+
229
+ # Build dispatch params
230
+ from datetime import date
231
+
232
+ match_date = date.fromisoformat(validated_args["match_date"])
233
+ check_in = date.fromisoformat(validated_args["check_in"])
234
+ check_out = date.fromisoformat(validated_args["check_out"])
235
+
236
+ params: dict[str, dict[str, Any]] = {}
237
+ for fn in flight_names:
238
+ params[fn] = {
239
+ "origin": validated_args["origin_airport"],
240
+ "destination": "YVR",
241
+ "departure_date": validated_args["match_date"],
242
+ }
243
+ for hn in hotel_names:
244
+ params[hn] = {
245
+ "check_in_date": validated_args["check_in"],
246
+ "check_out_date": validated_args["check_out"],
247
+ }
248
+ for wn in weather_names:
249
+ params[wn] = {
250
+ "latitude": 49.2827,
251
+ "longitude": -123.1207,
252
+ "start_date": validated_args["check_in"],
253
+ "end_date": validated_args["check_out"],
254
+ }
255
+ for an in amenity_names:
256
+ params[an] = {
257
+ "latitude": 49.2827,
258
+ "longitude": -123.1207,
259
+ "radius_km": 1.0,
260
+ }
261
+
262
+ # Dispatch all enabled normalizers concurrently
263
+ tasks: dict[str, asyncio.Task[Any]] = {}
264
+ for normalizer_name in flight_names + hotel_names + weather_names + amenity_names:
265
+ p = params.get(normalizer_name, {})
266
+ tasks[normalizer_name] = asyncio.create_task(
267
+ registry.dispatch(normalizer_name, p)
268
+ )
269
+
270
+ results: dict[str, Any] = {}
271
+ for normalizer_name, task in tasks.items():
272
+ try:
273
+ api_result = await asyncio.wait_for(task, timeout=PER_TOOL_TIMEOUT_SECONDS)
274
+ results[normalizer_name] = api_result
275
+ except asyncio.TimeoutError:
276
+ logger.warning("Normalizer %s timed out after %ss", normalizer_name, PER_TOOL_TIMEOUT_SECONDS)
277
+ results[normalizer_name] = None
278
+ except Exception as exc:
279
+ logger.warning("Normalizer %s failed: %s", normalizer_name, exc)
280
+ results[normalizer_name] = None
281
+
282
+ # Collect flights, hotels, weather from API results
283
+ flights: list[Flight] = []
284
+ hotels: list[Hotel] = []
285
+ weather_list: list[Weather] = []
286
+
287
+ for fn in flight_names:
288
+ r = results.get(fn)
289
+ if r is not None and r.success and isinstance(r.data, list):
290
+ flights.extend(r.data)
291
+
292
+ for hn in hotel_names:
293
+ r = results.get(hn)
294
+ if r is not None and r.success and isinstance(r.data, list):
295
+ hotels.extend(r.data)
296
+
297
+ for wn in weather_names:
298
+ r = results.get(wn)
299
+ if r is not None and r.success:
300
+ data = r.data
301
+ if isinstance(data, list):
302
+ weather_list.extend(data)
303
+
304
+ # Run deterministic scoring
305
+ packages = score_options(
306
+ flights=flights,
307
+ hotels=hotels,
308
+ weather=weather_list,
309
+ match_date=match_date,
310
+ budget_tier=validated_args.get("budget_tier", "mid_range"),
311
+ )
312
+
313
+ if not packages:
314
+ return {
315
+ "tool": "build_trip_packages",
316
+ "success": False,
317
+ "reason": "No valid trip packages could be formed with the available data.",
318
+ "packages": [],
319
+ }
320
+
321
+ # Serialise packages compactly
322
+ compact_packages = [_compact_package(pkg) for pkg in packages]
323
+
324
+ return {
325
+ "tool": "build_trip_packages",
326
+ "success": True,
327
+ "packages": compact_packages,
328
+ }
329
+
330
+
331
+ async def _web_search(validated_args: dict[str, Any]) -> dict[str, Any]:
332
+ """Execute the web_search tool.
333
+
334
+ Delegates to the SerpApi web search normalizer registered in the API
335
+ registry. If no web search normalizer is enabled, returns a graceful
336
+ error rather than crashing.
337
+
338
+ Parameters
339
+ ----------
340
+ validated_args:
341
+ Pre-validated argument dict from ``WebSearchArgs``.
342
+
343
+ Returns
344
+ -------
345
+ dict[str, Any]
346
+ Compact search results.
347
+ """
348
+ web_search_names = registry.get_enabled_normalizers(category="web_search")
349
+ if not web_search_names:
350
+ return {
351
+ "tool": "web_search",
352
+ "success": False,
353
+ "reason": "Web search is not available (no API key configured).",
354
+ "results": [],
355
+ }
356
+
357
+ name = web_search_names[0]
358
+ params = {
359
+ "query": validated_args["query"],
360
+ "max_results": validated_args.get("max_results", 5),
361
+ }
362
+
363
+ try:
364
+ api_result = await asyncio.wait_for(
365
+ registry.dispatch(name, params),
366
+ timeout=PER_TOOL_TIMEOUT_SECONDS,
367
+ )
368
+ except asyncio.TimeoutError:
369
+ return {
370
+ "tool": "web_search",
371
+ "success": False,
372
+ "reason": f"Web search timed out after {PER_TOOL_TIMEOUT_SECONDS}s.",
373
+ "results": [],
374
+ }
375
+ except Exception as exc:
376
+ return {
377
+ "tool": "web_search",
378
+ "success": False,
379
+ "reason": f"Web search error: {exc}",
380
+ "results": [],
381
+ }
382
+
383
+ if not api_result.success:
384
+ return {
385
+ "tool": "web_search",
386
+ "success": False,
387
+ "reason": api_result.error or "Web search returned no data.",
388
+ "results": [],
389
+ }
390
+
391
+ # Compact the results
392
+ raw_results = api_result.data
393
+ if isinstance(raw_results, list):
394
+ compact_results = raw_results[: validated_args.get("max_results", 5)]
395
+ elif isinstance(raw_results, dict):
396
+ items = raw_results.get("results", raw_results.get("items", [raw_results]))
397
+ compact_results = items[: validated_args.get("max_results", 5)]
398
+ else:
399
+ compact_results = [raw_results]
400
+
401
+ return {
402
+ "tool": "web_search",
403
+ "success": True,
404
+ "results": compact_results,
405
+ }
406
+
407
+
408
+ async def _clarify(validated_args: dict[str, Any]) -> dict[str, Any]:
409
+ """Execute the clarify tool.
410
+
411
+ This is the only tool that produces a final-answer-style output: the
412
+ agent loop returns the clarifying question as a ``type: final_answer``
413
+ so the frontend can present it to the user.
414
+
415
+ Parameters
416
+ ----------
417
+ validated_args:
418
+ Pre-validated argument dict from ``ClarifyArgs``.
419
+
420
+ Returns
421
+ -------
422
+ dict[str, Any]
423
+ A specially marked result that the agent loop interprets as a
424
+ final answer rather than a tool result to return to the model.
425
+ """
426
+ return {
427
+ "tool": "clarify",
428
+ "success": True,
429
+ "type": "final_answer", # signals the loop to stop
430
+ "text": validated_args["question"],
431
+ }
432
+
433
+
434
+ _TOOL_IMPLS: dict[str, Any] = {
435
+ "build_trip_packages": _build_trip_packages,
436
+ "web_search": _web_search,
437
+ "clarify": _clarify,
438
+ }
439
+
440
+ # ---------------------------------------------------------------------------
441
+ # Result compaction
442
+ # ---------------------------------------------------------------------------
443
+
444
+
445
+ def _compact_package(pkg: ScoredPackage) -> dict[str, Any]:
446
+ """Serialize a ScoredPackage to a compact dict for model consumption.
447
+
448
+ Strips out verbose fields (generated_at, source identifiers) and
449
+ truncates amenity lists to at most 5 entries to stay within the
450
+ 2,000-token budget.
451
+ """
452
+ amenities_compact = [
453
+ {
454
+ "name": a.name,
455
+ "category": a.category,
456
+ "distance_km": round(a.distance_km, 2),
457
+ }
458
+ for a in (pkg.amenities or [])
459
+ ][:5]
460
+
461
+ return {
462
+ "label": pkg.label,
463
+ "flight": {
464
+ "flight_number": pkg.flight.flight_number,
465
+ "airline": pkg.flight.airline,
466
+ "origin": pkg.flight.origin,
467
+ "destination": pkg.flight.destination,
468
+ "departure_time": pkg.flight.departure_time.isoformat(),
469
+ "arrival_time": pkg.flight.arrival_time.isoformat(),
470
+ "price_cad": pkg.flight.price_cad,
471
+ },
472
+ "hotel": (
473
+ {
474
+ "name": pkg.hotel.name,
475
+ "total_price_cad": pkg.hotel.total_price_cad,
476
+ "rating": pkg.hotel.rating,
477
+ "distance_to_stadium_km": pkg.hotel.distance_to_stadium_km,
478
+ }
479
+ if pkg.hotel is not None
480
+ else None
481
+ ),
482
+ "total_cost_cad": pkg.total_cost_cad,
483
+ "arrival_buffer_hours": pkg.arrival_buffer_hours,
484
+ "hotel_to_stadium_min": pkg.hotel_to_stadium_min,
485
+ "scores": dict(pkg.scores),
486
+ "explanation": pkg.explanation,
487
+ "amenities": amenities_compact,
488
+ "weather_summary": _compact_weather(pkg.weather),
489
+ }
490
+
491
+
492
+ def _compact_weather(weather_list: list[Any]) -> list[dict[str, Any]]:
493
+ """Compact weather forecasts down to date + key metrics."""
494
+ return [
495
+ {
496
+ "date": str(w.date),
497
+ "temp_max_c": w.temp_max_c,
498
+ "temp_min_c": w.temp_min_c,
499
+ "precipitation_probability": w.precipitation_probability,
500
+ }
501
+ for w in weather_list
502
+ ]
503
+
504
+
505
+ def _truncate_result(
506
+ result_str: str, max_tokens: int = MAX_RESULT_TOKENS
507
+ ) -> str:
508
+ """Crude token-truncation: roughly 4 characters per token."""
509
+ if not result_str:
510
+ return result_str
511
+ max_chars = max_tokens * 4
512
+ if len(result_str) <= max_chars:
513
+ return result_str
514
+ truncated = result_str[:max_chars]
515
+ # Cut at last newline to avoid corrupting a JSON object mid-stream
516
+ last_newline = truncated.rfind("\n")
517
+ if last_newline > max_chars // 2:
518
+ truncated = truncated[:last_newline]
519
+ return truncated + "\n... [truncated]"
520
+
521
+
522
+ # ---------------------------------------------------------------------------
523
+ # ToolAllowList
524
+ # ---------------------------------------------------------------------------
525
+
526
+
527
+ @dataclass
528
+ class ToolAllowList:
529
+ """Governs which tools Nemotron may call and validates every invocation.
530
+
531
+ Responsibilities:
532
+ - Whitelist-based name checking.
533
+ - Pydantic argument validation per tool.
534
+ - Duplicate-call detection within a turn.
535
+ - Compact result formatting (under 2,000 tokens).
536
+ """
537
+
538
+ allowed_tools: frozenset[str] = field(default_factory=lambda: ALLOWED_TOOLS)
539
+ """Frozenset of permitted tool names. Immutable after construction."""
540
+
541
+ def validate_tool_name(self, name: str) -> bool:
542
+ """Check whether *name* is in the allowlist.
543
+
544
+ Returns
545
+ -------
546
+ bool
547
+ ``True`` if the tool name is permitted.
548
+ """
549
+ return name in self.allowed_tools
550
+
551
+ def validate_args(self, name: str, args: dict[str, Any]) -> dict[str, Any]:
552
+ """Validate tool arguments using the registered Pydantic schema.
553
+
554
+ Parameters
555
+ ----------
556
+ name:
557
+ The tool name.
558
+ args:
559
+ Raw arguments dict from the model.
560
+
561
+ Returns
562
+ -------
563
+ dict[str, Any]
564
+ Validated arguments as a plain dict.
565
+
566
+ Raises
567
+ ------
568
+ ValueError
569
+ If the tool name is unknown or validation fails (Pydantic
570
+ ``ValidationError`` is re-raised as ``ValueError``).
571
+ """
572
+ return _parse_tool_args(name, args)
573
+
574
+ def is_duplicate(
575
+ self, name: str, args: dict[str, Any], history: list[tuple[str, dict[str, Any]]]
576
+ ) -> bool:
577
+ """Detect if this exact (tool, args) pair was already called.
578
+
579
+ Parameters
580
+ ----------
581
+ name:
582
+ The tool name.
583
+ args:
584
+ The validated or raw arguments.
585
+ history:
586
+ List of ``(tool_name, args_dict)`` tuples from the current turn.
587
+
588
+ Returns
589
+ -------
590
+ bool
591
+ ``True`` if the same tool with semantically identical args has
592
+ already been called.
593
+ """
594
+ key = _call_key(name, args)
595
+ for prev_name, prev_args in history:
596
+ if prev_name == name and _call_key(prev_name, prev_args) == key:
597
+ return True
598
+ return False
599
+
600
+ def format_tool_result(self, name: str, result: dict[str, Any]) -> str:
601
+ """Format a tool result dict into a compact string for model consumption.
602
+
603
+ Parameters
604
+ ----------
605
+ name:
606
+ The tool name (unused, kept for future extensibility).
607
+ result:
608
+ The result dict from the tool implementation.
609
+
610
+ Returns
611
+ -------
612
+ str
613
+ Compact string representation, guaranteed under 2,000 tokens.
614
+ """
615
+ compact = _strip_nones(result)
616
+ raw = json.dumps(compact, indent=None, ensure_ascii=False, default=str)
617
+ return _truncate_result(raw, max_tokens=MAX_RESULT_TOKENS)
618
+
619
+
620
+ def _strip_nones(obj: Any) -> Any:
621
+ """Recursively remove keys with ``None`` values from dicts.
622
+
623
+ Helps keep the serialised result compact by omitting empty fields
624
+ rather than emitting ``"field": null``.
625
+ """
626
+ if isinstance(obj, dict):
627
+ return {k: _strip_nones(v) for k, v in obj.items() if v is not None}
628
+ if isinstance(obj, list):
629
+ return [_strip_nones(item) for item in obj]
630
+ return obj
631
+
632
+
633
+ # ---------------------------------------------------------------------------
634
+ # AgentLoopResult
635
+ # ---------------------------------------------------------------------------
636
+
637
+
638
+ @dataclass
639
+ class AgentLoopResult:
640
+ """The structured return value of ``AgentLoop.run()``.
641
+
642
+ Three possible outcomes:
643
+
644
+ 1. **tool_called** -- Nemotron called a tool successfully and we have a
645
+ result to display. ``tool`` and ``result`` carry the details.
646
+ 2. **final_answer** -- Nemotron produced a natural-language answer without
647
+ calling any tools (or called ``clarify``, which is terminal). ``text``
648
+ carries the answer.
649
+ 3. **fallback_to_deterministic** -- The loop failed for a recoverable
650
+ reason (malformed JSON, timeout, invalid tool). ``reason`` describes
651
+ the failure mode.
652
+
653
+ This is intentionally a dataclass (not a Pydantic model) for maximum
654
+ simplicity -- it is the return value of the loop, not a domain model.
655
+ """
656
+
657
+ type: Literal["tool_called", "final_answer", "fallback_to_deterministic"]
658
+ """Outcome discriminator."""
659
+
660
+ tool: str = ""
661
+ """The tool name that was called (only for ``tool_called``)."""
662
+
663
+ result: dict[str, Any] = field(default_factory=dict)
664
+ """The tool result (only for ``tool_called``)."""
665
+
666
+ text: str = ""
667
+ """The final answer text (only for ``final_answer``)."""
668
+
669
+ reason: str = ""
670
+ """The failure reason (only for ``fallback_to_deterministic``)."""
671
+
672
+
673
+ # ---------------------------------------------------------------------------
674
+ # AgentLoop
675
+ # ---------------------------------------------------------------------------
676
+
677
+
678
+ class AgentLoop:
679
+ """Bounded agent loop that orchestrates Nemotron tool use.
680
+
681
+ The loop runs at most ``MAX_TOOL_ROUNDS`` (2) turns. Each turn:
682
+ 1. Send the current message list to the agent (Nemotron).
683
+ 2. Parse the agent's response.
684
+ 3. If the agent calls a tool:
685
+ a. Validate the tool name against the allowlist.
686
+ b. Validate arguments via Pydantic.
687
+ c. Check for duplicates.
688
+ d. Execute the tool with a timeout.
689
+ e. Format the result and append it to messages.
690
+ 4. If the agent produces a text answer, return it.
691
+ 5. If anything goes wrong, attempt one correction pass, then fall back.
692
+
693
+ The loop terminates early on:
694
+ - A ``final_answer`` outcome (clarify tool or direct text answer).
695
+ - An unrecoverable error (fallback to deterministic).
696
+ - Exhaustion of tool rounds.
697
+ """
698
+
699
+ def __init__(
700
+ self,
701
+ agent: Any,
702
+ tools: dict[str, Any] | None = None,
703
+ max_rounds: int = MAX_TOOL_ROUNDS,
704
+ ) -> None:
705
+ """Initialise the agent loop.
706
+
707
+ Parameters
708
+ ----------
709
+ agent:
710
+ The LLM agent. Must have an ``async def run(messages: list[dict])``
711
+ method that returns either ``{"tool_calls": [...], "text": ...}``
712
+ or ``{"text": ...}``. This is typically a ``MatchDayAgent``
713
+ instance backed by SGLang + Nemotron.
714
+ tools:
715
+ Optional dict of additional tools beyond the built-in allowlist.
716
+ If ``None``, only the built-in allowlist tools are available.
717
+ The built-in allowlist always takes precedence; external tools
718
+ are checked separately.
719
+ max_rounds:
720
+ Maximum number of tool-call rounds (default 2). After this many
721
+ rounds the loop forces a final answer or falls back.
722
+ """
723
+ self._agent = agent
724
+ self._max_rounds = max_rounds
725
+ self._allow_list = ToolAllowList()
726
+
727
+ # Merge external tools if provided (built-in impls are always present)
728
+ self._tool_impls: dict[str, Any] = dict(_TOOL_IMPLS)
729
+ if tools is not None:
730
+ for name, impl in tools.items():
731
+ if name in ALLOWED_TOOLS:
732
+ self._tool_impls[name] = impl
733
+ else:
734
+ logger.debug(
735
+ "Ignoring external tool %r: not in allowlist", name
736
+ )
737
+
738
+ # ------------------------------------------------------------------
739
+ # Public API
740
+ # ------------------------------------------------------------------
741
+
742
+ async def run(self, messages: list[dict]) -> AgentLoopResult:
743
+ """Run the bounded agent loop.
744
+
745
+ Parameters
746
+ ----------
747
+ messages:
748
+ The conversation so far as a list of OpenAI-style message dicts.
749
+ The first message is typically a system prompt, followed by
750
+ user messages. The loop appends tool results and assistant
751
+ responses as it progresses.
752
+
753
+ Returns
754
+ -------
755
+ AgentLoopResult
756
+ One of three outcome types (see ``AgentLoopResult``).
757
+ """
758
+ round_num = 0
759
+ tool_history: list[tuple[str, dict[str, Any]]] = []
760
+ correction_used = False
761
+
762
+ while round_num < self._max_rounds:
763
+ round_num += 1
764
+
765
+ # ---- Step 1: Call the agent ----
766
+ try:
767
+ response = await self._call_agent(messages)
768
+ except Exception as exc:
769
+ logger.error("Agent call failed on round %d: %s", round_num, exc)
770
+ return AgentLoopResult(
771
+ type="fallback_to_deterministic",
772
+ reason=f"Agent call failed on round {round_num}: {exc}",
773
+ )
774
+
775
+ # ---- Step 2: Check for tool calls ----
776
+ tool_calls = response.get("tool_calls", [])
777
+ text = response.get("text", "")
778
+
779
+ if not tool_calls:
780
+ # Agent produced a text-only answer.
781
+ final_text = text or "I don't have enough information to answer."
782
+ # Sanity-check: if text is empty or trivial, fall back
783
+ if len(final_text.strip()) < 5:
784
+ return AgentLoopResult(
785
+ type="fallback_to_deterministic",
786
+ reason="Agent returned empty or trivial response.",
787
+ )
788
+ return AgentLoopResult(
789
+ type="final_answer",
790
+ text=final_text,
791
+ )
792
+
793
+ # ---- Step 3: Process each tool call ----
794
+ # We process tool calls sequentially to keep message ordering
795
+ # deterministic and avoid confusing the model.
796
+ for tc in tool_calls:
797
+ tool_name = tc.get("name", "")
798
+ raw_args = tc.get("arguments", {})
799
+
800
+ # Handle both string and dict argument formats
801
+ if isinstance(raw_args, str):
802
+ try:
803
+ raw_args = json.loads(raw_args)
804
+ except json.JSONDecodeError:
805
+ raw_args = {}
806
+
807
+ # Process one tool call. On malformed/validation errors we send
808
+ # the error back to the agent (one correction attempt) by
809
+ # injecting an error message and looping around. On success
810
+ # we return immediately with the tool result.
811
+ result = await self._process_tool_call(
812
+ tool_name=tool_name,
813
+ raw_args=raw_args,
814
+ tool_history=tool_history,
815
+ messages=messages,
816
+ correction_used=correction_used,
817
+ )
818
+
819
+ if isinstance(result, AgentLoopResult):
820
+ # Terminal outcome: final_answer, tool_called, or fallback
821
+ return result
822
+
823
+ # result is None = a correctable error occurred.
824
+ if not correction_used:
825
+ # We injected an error message into `messages`. Set the
826
+ # flag so the next loop round is the correction attempt.
827
+ correction_used = True
828
+ # Break out of the tool-calls loop to let the model retry
829
+ # in the next round (the error message is already in
830
+ # the message list).
831
+ break
832
+
833
+ # Second malformed call in the same turn -> fall back.
834
+ # The error message is already in `messages` but we don't
835
+ # give the model another chance.
836
+ return AgentLoopResult(
837
+ type="fallback_to_deterministic",
838
+ reason=(
839
+ f"Tool call {tool_name!r} failed after correction "
840
+ f"attempt on round {round_num}."
841
+ ),
842
+ )
843
+
844
+ else:
845
+ # All tool calls processed without error or correction.
846
+ # Continue to next round if rounds remain.
847
+ continue
848
+
849
+ # If we broke out of the for loop to use the correction attempt,
850
+ # continue the outer while loop to let the model retry.
851
+ continue
852
+
853
+ # ---- Round limit reached: force final answer or fallback ----
854
+ return AgentLoopResult(
855
+ type="fallback_to_deterministic",
856
+ reason=f"Agent loop exceeded maximum {self._max_rounds} tool rounds.",
857
+ )
858
+
859
+ # ------------------------------------------------------------------
860
+ # Internal helpers
861
+ # ------------------------------------------------------------------
862
+
863
+ async def _call_agent(self, messages: list[dict]) -> dict[str, Any]:
864
+ """Call the underlying agent and return its response.
865
+
866
+ The agent's ``run()`` method must be async and return a dict with
867
+ optional ``tool_calls`` and/or ``text`` keys.
868
+
869
+ Parameters
870
+ ----------
871
+ messages:
872
+ The current message list.
873
+
874
+ Returns
875
+ -------
876
+ dict[str, Any]
877
+ The agent response dict.
878
+
879
+ Raises
880
+ ------
881
+ asyncio.TimeoutError
882
+ If the agent call exceeds the per-tool timeout (30s).
883
+ Exception
884
+ Propagates any other exception from the agent.
885
+ """
886
+ try:
887
+ return await asyncio.wait_for(
888
+ self._agent.run(messages),
889
+ timeout=PER_TOOL_TIMEOUT_SECONDS,
890
+ )
891
+ except asyncio.TimeoutError:
892
+ raise # re-raised so the caller handles it
893
+
894
+ async def _process_tool_call(
895
+ self,
896
+ tool_name: str,
897
+ raw_args: dict[str, Any],
898
+ tool_history: list[tuple[str, dict[str, Any]]],
899
+ messages: list[dict],
900
+ correction_used: bool,
901
+ ) -> AgentLoopResult | None:
902
+ """Process a single tool call from the agent.
903
+
904
+ Returns an ``AgentLoopResult`` if the tool call leads to a terminal
905
+ state (final answer, tool_called, fallback), or ``None`` if there
906
+ was a correctable error and the caller should retry.
907
+
908
+ Parameters
909
+ ----------
910
+ tool_name:
911
+ The tool name the agent wants to call.
912
+ raw_args:
913
+ The raw arguments dict.
914
+ tool_history:
915
+ History of tool calls in this turn.
916
+ messages:
917
+ The mutable conversation message list. Error messages are appended
918
+ here so the model can self-correct on the next round.
919
+ correction_used:
920
+ ``True`` if a correction attempt was already made this turn.
921
+
922
+ Returns
923
+ -------
924
+ AgentLoopResult | None
925
+ A terminal result, or ``None`` to signal a correctable error
926
+ (the error message is already in ``messages``).
927
+ """
928
+ # ---- Validate tool name ----
929
+ if not self._allow_list.validate_tool_name(tool_name):
930
+ error_msg = (
931
+ f"Tool {tool_name!r} is not allowed. "
932
+ f"Allowed tools: {sorted(self._allow_list.allowed_tools)}. "
933
+ "Please call one of the allowed tools or provide a text answer."
934
+ )
935
+ logger.warning("Blocked invalid tool name: %s", tool_name)
936
+
937
+ self._inject_error_message(messages, tool_name, error_msg)
938
+
939
+ if correction_used:
940
+ return AgentLoopResult(
941
+ type="fallback_to_deterministic",
942
+ reason=f"Invalid tool name {tool_name!r} after correction attempt.",
943
+ )
944
+ # One correction attempt allowed — signal caller to retry
945
+ return None
946
+
947
+ # ---- Validate arguments ----
948
+ try:
949
+ validated_args = self._allow_list.validate_args(tool_name, raw_args)
950
+ except Exception as exc:
951
+ error_msg = (
952
+ f"Argument validation failed for {tool_name!r}: {exc}. "
953
+ "Please correct the arguments and call the tool again."
954
+ )
955
+ logger.warning("Argument validation error: %s", exc)
956
+
957
+ self._inject_error_message(messages, tool_name, error_msg)
958
+
959
+ if correction_used:
960
+ return AgentLoopResult(
961
+ type="fallback_to_deterministic",
962
+ reason=(
963
+ f"Argument validation failed for {tool_name!r} "
964
+ f"after correction attempt: {exc}"
965
+ ),
966
+ )
967
+ # One correction attempt allowed
968
+ return None
969
+
970
+ # ---- Check for duplicates ----
971
+ if self._allow_list.is_duplicate(tool_name, validated_args, tool_history):
972
+ # Silently skip: the result would be identical
973
+ logger.info("Skipping duplicate tool call: %s", tool_name)
974
+ return None
975
+
976
+ # ---- Execute the tool ----
977
+ impl = self._tool_impls.get(tool_name)
978
+ if impl is None:
979
+ return AgentLoopResult(
980
+ type="fallback_to_deterministic",
981
+ reason=f"No implementation registered for tool {tool_name!r}.",
982
+ )
983
+
984
+ try:
985
+ tool_result = await asyncio.wait_for(
986
+ impl(validated_args),
987
+ timeout=PER_TOOL_TIMEOUT_SECONDS,
988
+ )
989
+ except asyncio.TimeoutError:
990
+ return AgentLoopResult(
991
+ type="fallback_to_deterministic",
992
+ reason=(
993
+ f"Tool {tool_name!r} timed out after "
994
+ f"{PER_TOOL_TIMEOUT_SECONDS}s."
995
+ ),
996
+ )
997
+ except Exception as exc:
998
+ logger.error("Tool %s execution error: %s", tool_name, exc)
999
+ return AgentLoopResult(
1000
+ type="fallback_to_deterministic",
1001
+ reason=f"Tool {tool_name!r} execution failed: {exc}",
1002
+ )
1003
+
1004
+ # ---- Handle clarify tool (terminal) ----
1005
+ if tool_result.get("type") == "final_answer":
1006
+ return AgentLoopResult(
1007
+ type="final_answer",
1008
+ text=tool_result.get("text", ""),
1009
+ )
1010
+
1011
+ # ---- Return tool_called result ----
1012
+ return AgentLoopResult(
1013
+ type="tool_called",
1014
+ tool=tool_name,
1015
+ result=tool_result,
1016
+ )
1017
+
1018
+ def _inject_error_message(
1019
+ self,
1020
+ messages: list[dict],
1021
+ tool_name: str,
1022
+ error: str,
1023
+ ) -> None:
1024
+ """Inject a tool error message into the conversation.
1025
+
1026
+ The message is added as a ``role: tool`` message so that the model
1027
+ sees the error on the next round and can self-correct.
1028
+
1029
+ Parameters
1030
+ ----------
1031
+ messages:
1032
+ The mutable conversation message list.
1033
+ tool_name:
1034
+ The tool name that produced the error.
1035
+ error:
1036
+ The error string.
1037
+ """
1038
+ messages.append({
1039
+ "role": "tool",
1040
+ "tool_call_id": tool_name,
1041
+ "name": tool_name,
1042
+ "content": error,
1043
+ })
1044
+
1045
+
1046
+ # ---------------------------------------------------------------------------
1047
+ # Convenience: run_agent_loop
1048
+ # ---------------------------------------------------------------------------
1049
+
1050
+
1051
+ async def run_agent_loop(
1052
+ agent: Any,
1053
+ messages: list[dict],
1054
+ tools: dict[str, Any] | None = None,
1055
+ max_rounds: int = MAX_TOOL_ROUNDS,
1056
+ ) -> AgentLoopResult:
1057
+ """Top-level convenience function for the agent loop.
1058
+
1059
+ Parameters
1060
+ ----------
1061
+ agent:
1062
+ The LLM agent with an ``async run(messages)`` method.
1063
+ messages:
1064
+ The conversation message list.
1065
+ tools:
1066
+ Optional extra tools (merged with built-in allowlist).
1067
+ max_rounds:
1068
+ Maximum tool rounds (default 2).
1069
+
1070
+ Returns
1071
+ -------
1072
+ AgentLoopResult
1073
+ The loop outcome.
1074
+ """
1075
+ loop = AgentLoop(agent=agent, tools=tools, max_rounds=max_rounds)
1076
+ return await loop.run(messages)
matchday/api_registry.py ADDED
@@ -0,0 +1,772 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MatchDay -- Self-registering API normalizer registry.
3
+
4
+ Based on hermes-agent ``tools/registry.py`` and Claude Code's ``buildTool`` pattern.
5
+
6
+ Architecture:
7
+ - ``APINormalizer`` dataclass (with ``__slots__``) describing an external API
8
+ endpoint: its name, category, input/output Pydantic schemas, async handler,
9
+ availability check, and required environment variables.
10
+ - ``APIRegistry`` class that owns all normalizers, handles registration
11
+ (with collision detection), dispatch (input validation -> handler ->
12
+ ``APIResult`` envelope), and introspection (``get_enabled_normalizers``,
13
+ ``get_categories``).
14
+ - Category composition (``API_CATEGORIES``) with ``resolve_category()`` that
15
+ expands composite categories recursively (cycle-safe).
16
+ - Decorator ``@register_api(...)`` for declarative self-registration.
17
+ - ``build_normalizer()`` helper that merges ``NORMALIZER_DEFAULTS`` with
18
+ provided overrides (def wins).
19
+ - ``_check_ttl_cache`` -- 30-second TTL cache on ``check_fn`` results so
20
+ repeated ``is_enabled()`` calls don't hammer env-var lookups.
21
+
22
+ Usage:
23
+ from matchday.api_registry import registry, register_api
24
+
25
+ @register_api("serpapi_flights", "flights", check_fn=lambda: bool(os.getenv("SERPAPI_KEY")))
26
+ async def search_flights(input: FlightInput) -> list[Flight]:
27
+ ...
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import functools
33
+ import os
34
+ import time
35
+ from collections.abc import Awaitable, Callable
36
+ from dataclasses import dataclass, field
37
+ from datetime import datetime, timezone
38
+ from typing import Any, ParamSpec, TypeVar
39
+
40
+ from pydantic import BaseModel
41
+
42
+ from matchday.models import APIResult
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # APINormalizer -- single API endpoint descriptor
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class APINormalizer:
51
+ """Descriptor for one external API normalizer function.
52
+
53
+ Every normalizer registered in the system is represented by one of these.
54
+ The registry uses the fields below for dispatch, validation, and
55
+ availability gating.
56
+
57
+ ``slots=True`` eliminates the instance ``__dict__``, saving memory and
58
+ attribute-lookup overhead on the hot path.
59
+ """
60
+
61
+ name: str
62
+ """Unique identifier, e.g. ``serpapi_flights``."""
63
+
64
+ category: str
65
+ """Category key from ``API_CATEGORIES``, e.g. ``flights``, ``hotels``."""
66
+
67
+ input_schema: type[BaseModel]
68
+ """Pydantic model used to validate incoming parameters."""
69
+
70
+ output_schema: type[BaseModel]
71
+ """Pydantic model type that the handler returns (a single model or
72
+ ``list[Model]``). Used for type introspection / doc generation."""
73
+
74
+ handler: Callable[..., Awaitable[Any] | Any]
75
+ """The actual normalizer function. Signature should match what
76
+ ``dispatch()`` calls: ``handler(**validated_params)``."""
77
+
78
+ check_fn: Callable[[], bool]
79
+ """Called by ``is_enabled()`` to determine whether this normalizer's
80
+ API key / credentials are available. Defaults to a no-op returning
81
+ ``False`` (fail-closed)."""
82
+
83
+ requires_env: list[str] = field(default_factory=list)
84
+ """Environment variable names required for this normalizer, e.g.
85
+ ``["SERPAPI_KEY"]``. ``is_enabled()`` checks that all are set before
86
+ calling ``check_fn``."""
87
+
88
+ is_async: bool = True
89
+ """``True`` when ``handler`` is an async function. The registry uses this
90
+ to decide whether to ``await`` the call in ``dispatch()``."""
91
+
92
+ description: str = ""
93
+ """Human-readable description of what this normalizer does."""
94
+
95
+ emoji: str = ""
96
+ """Single emoji for frontend / logging decoration."""
97
+
98
+ def __post_init__(self) -> None:
99
+ """Validate invariants at construction time."""
100
+ if not self.name:
101
+ raise ValueError("APINormalizer.name must be a non-empty string.")
102
+ if not self.category:
103
+ raise ValueError("APINormalizer.category must be a non-empty string.")
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # NORMALIZER_DEFAULTS -- fail-closed defaults (Claude Code buildTool pattern)
108
+ # ---------------------------------------------------------------------------
109
+
110
+ NORMALIZER_DEFAULTS: dict[str, Any] = {
111
+ "is_enabled": False,
112
+ "is_concurrency_safe": False,
113
+ "max_result_size_chars": 100_000,
114
+ }
115
+ """Fail-closed defaults for all normalizers.
116
+
117
+ These are NOT fields of ``APINormalizer`` itself but are used by calling code
118
+ that wraps normalizers (e.g. builders, middleware) -- see ``build_normalizer``
119
+ below.
120
+ """
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # API_CATEGORIES -- category hierarchy with composition
124
+ # ---------------------------------------------------------------------------
125
+
126
+ API_CATEGORIES: dict[str, dict[str, Any]] = {
127
+ "flights": {
128
+ "description": "Flight search and pricing via SerpApi (Google Flights).",
129
+ "normalizers": ["serpapi_flights"],
130
+ },
131
+ "hotels": {
132
+ "description": "Hotel search and pricing via SerpApi (Google Hotels).",
133
+ "normalizers": ["serpapi_hotels"],
134
+ },
135
+ "weather": {
136
+ "description": "Weather forecasts via Open-Meteo.",
137
+ "normalizers": ["openmeteo_weather"],
138
+ },
139
+ "amenities": {
140
+ "description": "Points of interest near BC Place via OSM Overpass API.",
141
+ "normalizers": ["osm_amenities"],
142
+ },
143
+ "photos": {
144
+ "description": "Photo search for destinations and venues.",
145
+ "normalizers": ["pexels_photos"],
146
+ },
147
+ "web_search": {
148
+ "description": "General-purpose web search for supplemental data.",
149
+ "normalizers": ["serpapi_web_search"],
150
+ },
151
+ "full_trip": {
152
+ "description": "Composite category: all data needed for a complete trip package.",
153
+ "includes": ["flights", "hotels", "weather", "amenities"],
154
+ },
155
+ }
156
+ """Category definitions.
157
+
158
+ Each entry has either:
159
+ - ``normalizers``: list of concrete normalizer names in this category.
160
+ - ``includes``: list of sub-category names (recursive composition).
161
+ """
162
+
163
+
164
+ def resolve_category(name: str, visited: set[str] | None = None) -> list[str]:
165
+ """Recursively expand a category into a flat list of normalizer names.
166
+
167
+ Composite categories (those with ``includes``) are expanded depth-first.
168
+ Cycle detection prevents infinite recursion.
169
+
170
+ Parameters
171
+ ----------
172
+ name:
173
+ Category key to resolve (e.g. ``"full_trip"``).
174
+ visited:
175
+ Internal set used for cycle detection. Pass ``None`` on the top-level
176
+ call; the function manages this automatically.
177
+
178
+ Returns
179
+ -------
180
+ list[str]
181
+ Flat list of concrete normalizer names belonging to this category.
182
+
183
+ Raises
184
+ ------
185
+ ValueError
186
+ If the category name is unknown or a cycle is detected.
187
+
188
+ Examples
189
+ --------
190
+ >>> resolve_category("full_trip")
191
+ ["serpapi_flights", "serpapi_hotels", "openmeteo_weather", "osm_amenities"]
192
+ """
193
+ if visited is None:
194
+ visited = set()
195
+
196
+ if name in visited:
197
+ raise ValueError(
198
+ f"Cycle detected resolving category {name!r}. "
199
+ f"Visited: {visited}"
200
+ )
201
+
202
+ category = API_CATEGORIES.get(name)
203
+ if category is None:
204
+ raise ValueError(
205
+ f"Unknown category {name!r}. Available: {list(API_CATEGORIES)}"
206
+ )
207
+
208
+ visited.add(name)
209
+
210
+ # Composite category -- expand includes recursively
211
+ if "includes" in category:
212
+ result: list[str] = []
213
+ for sub in category["includes"]:
214
+ result.extend(resolve_category(sub, visited))
215
+ return result
216
+
217
+ # Leaf category -- return normalizer names directly
218
+ return list(category.get("normalizers", []))
219
+
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # TTL cache for check_fn results
223
+ # ---------------------------------------------------------------------------
224
+
225
+
226
+ class _TTLCache:
227
+ """Simple time-to-live cache with per-key expiry.
228
+
229
+ Used to avoid hammering env-var lookups and ``check_fn`` calls on every
230
+ ``is_enabled()`` invocation.
231
+ """
232
+
233
+ __slots__ = ("_data", "_ttl_seconds")
234
+
235
+ def __init__(self, ttl_seconds: float = 30.0) -> None:
236
+ self._data: dict[str, tuple[bool, float]] = {}
237
+ self._ttl_seconds = ttl_seconds
238
+
239
+ def get(self, key: str) -> bool | None:
240
+ """Return cached value if still fresh, or ``None`` if expired/missing."""
241
+ entry = self._data.get(key)
242
+ if entry is None:
243
+ return None
244
+ value, expires_at = entry
245
+ if time.monotonic() > expires_at:
246
+ del self._data[key]
247
+ return None
248
+ return value
249
+
250
+ def set(self, key: str, value: bool) -> None:
251
+ """Store value with a fresh expiry timestamp."""
252
+ self._data[key] = (value, time.monotonic() + self._ttl_seconds)
253
+
254
+ def invalidate(self, key: str) -> None:
255
+ """Remove a single key from the cache."""
256
+ self._data.pop(key, None)
257
+
258
+ def clear(self) -> None:
259
+ """Remove all cached entries."""
260
+ self._data.clear()
261
+
262
+
263
+ _check_ttl_cache = _TTLCache(ttl_seconds=30.0)
264
+ """Module-level TTL cache shared across all ``is_enabled()`` calls.
265
+
266
+ Keyed by normalizer name. Evicted after 30 seconds.
267
+ """
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # APIRegistry
272
+ # ---------------------------------------------------------------------------
273
+
274
+ P = ParamSpec("P")
275
+ T = TypeVar("T")
276
+
277
+
278
+ class APIRegistry:
279
+ """Self-registering normalizer registry.
280
+
281
+ Owns all ``APINormalizer`` entries and provides:
282
+ - ``register()`` to add entries (with collision detection).
283
+ - ``dispatch()`` to call a normalizer by name with Pydantic validation
284
+ and ``APIResult`` envelope wrapping.
285
+ - ``is_enabled()`` to check API availability.
286
+ - ``get_enabled_normalizers()`` for introspection.
287
+ - ``get_categories()`` to enumerate category structure.
288
+ """
289
+
290
+ __slots__ = ("_normalizers", "_categories", "_generation")
291
+
292
+ def __init__(self) -> None:
293
+ self._normalizers: dict[str, APINormalizer] = {}
294
+ """name -> APINormalizer mapping."""
295
+ self._categories: dict[str, list[str]] = {}
296
+ """category -> [normalizer_name, ...] reverse index."""
297
+ self._generation: int = 0
298
+ """Monotonic counter bumped on every register/override for cache
299
+ invalidation (e.g. clearing downstream caches that key on registry
300
+ contents)."""
301
+
302
+ # ── registration ──────────────────────────────────────────────────────
303
+
304
+ def register(
305
+ self,
306
+ name: str,
307
+ category: str,
308
+ input_schema: type[BaseModel],
309
+ output_schema: type[BaseModel],
310
+ handler: Callable[..., Awaitable[Any] | Any],
311
+ check_fn: Callable[[], bool] | None = None,
312
+ *,
313
+ requires_env: list[str] | None = None,
314
+ is_async: bool = True,
315
+ description: str = "",
316
+ emoji: str = "",
317
+ override: bool = False,
318
+ ) -> None:
319
+ """Register a new normalizer in the registry.
320
+
321
+ Parameters
322
+ ----------
323
+ name:
324
+ Unique identifier for this normalizer (e.g. ``"serpapi_flights"``).
325
+ category:
326
+ Category key (must exist in ``API_CATEGORIES``).
327
+ input_schema:
328
+ Pydantic model for input validation in ``dispatch()``.
329
+ output_schema:
330
+ Pydantic model type for the return value.
331
+ handler:
332
+ The normalizer function. Called as ``handler(**validated_params)``.
333
+ check_fn:
334
+ Callable returning ``True`` if the API key/credentials are
335
+ available. Defaults to a function that returns ``False``
336
+ (fail-closed).
337
+ requires_env:
338
+ List of environment variable names required for this normalizer.
339
+ is_async:
340
+ ``True`` (default) if the handler is async.
341
+ description:
342
+ Human-readable description of this normalizer.
343
+ emoji:
344
+ Decoration emoji.
345
+ override:
346
+ If ``False`` (default), registering an existing name raises
347
+ ``ValueError``. If ``True``, the existing entry is replaced.
348
+
349
+ Raises
350
+ ------
351
+ ValueError
352
+ If ``name`` already exists and ``override=False``, or if category
353
+ is unknown.
354
+ """
355
+ # Validate category
356
+ if category not in API_CATEGORIES:
357
+ raise ValueError(
358
+ f"Unknown category {category!r}. "
359
+ f"Available: {list(API_CATEGORIES)}"
360
+ )
361
+
362
+ # Collision detection
363
+ if name in self._normalizers and not override:
364
+ raise ValueError(
365
+ f"Normalizer {name!r} is already registered. "
366
+ "Use override=True to replace."
367
+ )
368
+
369
+ normalizer = APINormalizer(
370
+ name=name,
371
+ category=category,
372
+ input_schema=input_schema,
373
+ output_schema=output_schema,
374
+ handler=handler,
375
+ check_fn=check_fn if check_fn is not None else (lambda: False),
376
+ requires_env=requires_env or [],
377
+ is_async=is_async,
378
+ description=description,
379
+ emoji=emoji,
380
+ )
381
+
382
+ self._normalizers[name] = normalizer
383
+
384
+ # Update reverse category index
385
+ if category not in self._categories:
386
+ self._categories[category] = []
387
+ if name not in self._categories[category]:
388
+ self._categories[category].append(name)
389
+
390
+ self._generation += 1
391
+
392
+ # ── lookup ────────────────────────────────────────────────────────────
393
+
394
+ def get_entry(self, name: str) -> APINormalizer | None:
395
+ """Look up a normalizer by name.
396
+
397
+ Returns
398
+ -------
399
+ APINormalizer or ``None`` if not found.
400
+ """
401
+ return self._normalizers.get(name)
402
+
403
+ # ── dispatch ──────────────────────────────────────────────────────────
404
+
405
+ async def dispatch(self, name: str, params: dict[str, Any]) -> APIResult:
406
+ """Call a normalizer by name with validated parameters.
407
+
408
+ 1. Looks up the normalizer by ``name``.
409
+ 2. Validates ``params`` against the normalizer's ``input_schema``
410
+ (Pydantic model).
411
+ 3. Calls the handler with the validated data (as keyword arguments).
412
+ 4. Wraps the result in an ``APIResult`` envelope.
413
+ 5. Catches ALL exceptions and returns ``APIResult(success=False,
414
+ error=...)``.
415
+
416
+ Parameters
417
+ ----------
418
+ name:
419
+ Registered normalizer name.
420
+ params:
421
+ Raw parameters dict (will be validated against the normalizer's
422
+ ``input_schema``).
423
+
424
+ Returns
425
+ -------
426
+ APIResult
427
+ Success or failure envelope.
428
+ """
429
+ normalizer = self._normalizers.get(name)
430
+ if normalizer is None:
431
+ return APIResult(
432
+ success=False,
433
+ data=None,
434
+ error=f"Unknown normalizer: {name!r}.",
435
+ source="registry",
436
+ as_of=datetime.now(timezone.utc),
437
+ )
438
+
439
+ # Validate input with Pydantic
440
+ try:
441
+ validated = normalizer.input_schema.model_validate(params)
442
+ except Exception as exc:
443
+ return APIResult(
444
+ success=False,
445
+ data=None,
446
+ error=f"Input validation failed for {name!r}: {exc}",
447
+ source=name,
448
+ as_of=datetime.now(timezone.utc),
449
+ )
450
+
451
+ # Call handler
452
+ try:
453
+ raw_result = normalizer.handler(**validated.model_dump())
454
+
455
+ if normalizer.is_async:
456
+ raw_result = await raw_result # type: ignore[assignment]
457
+ except Exception as exc:
458
+ return APIResult(
459
+ success=False,
460
+ data=None,
461
+ error=f"Handler {name!r} raised: {type(exc).__name__}: {exc}",
462
+ source=name,
463
+ as_of=datetime.now(timezone.utc),
464
+ )
465
+
466
+ return APIResult(
467
+ success=True,
468
+ data=raw_result,
469
+ error=None,
470
+ source=name,
471
+ as_of=datetime.now(timezone.utc),
472
+ )
473
+
474
+ # ── availability gating ───────────────────────────────────────────────
475
+
476
+ def is_enabled(self, name: str) -> bool:
477
+ """Check whether a normalizer is usable right now.
478
+
479
+ Returns ``True`` only when:
480
+ 1. The normalizer is registered.
481
+ 2. All ``requires_env`` variables are set (non-empty) in the
482
+ environment.
483
+ 3. ``check_fn()`` returns ``True``.
484
+
485
+ Results are cached in ``_check_ttl_cache`` for 30 seconds.
486
+
487
+ Parameters
488
+ ----------
489
+ name:
490
+ Normalizer name to check.
491
+
492
+ Returns
493
+ -------
494
+ bool
495
+ """
496
+ normalizer = self._normalizers.get(name)
497
+ if normalizer is None:
498
+ return False
499
+
500
+ # Check TTL cache first
501
+ cached = _check_ttl_cache.get(name)
502
+ if cached is not None:
503
+ return cached
504
+
505
+ # Check required env vars
506
+ for env_var in normalizer.requires_env:
507
+ if not os.environ.get(env_var):
508
+ _check_ttl_cache.set(name, False)
509
+ return False
510
+
511
+ # Run the check_fn
512
+ try:
513
+ result = bool(normalizer.check_fn())
514
+ except Exception:
515
+ result = False
516
+
517
+ _check_ttl_cache.set(name, result)
518
+ return result
519
+
520
+ # ── introspection ─────────────────────────────────────────────────────
521
+
522
+ def get_enabled_normalizers(self, category: str | None = None) -> list[str]:
523
+ """Return names of all currently enabled normalizers.
524
+
525
+ Parameters
526
+ ----------
527
+ category:
528
+ If provided, filter to normalizers in this category (and its
529
+ sub-categories if composite). If ``None``, return all enabled
530
+ normalizers across all categories.
531
+
532
+ Returns
533
+ -------
534
+ list[str]
535
+ Sorted list of normalizer names whose ``is_enabled()`` is
536
+ ``True``.
537
+ """
538
+ names: list[str] = []
539
+
540
+ if category is not None:
541
+ # Resolve composite categories into concrete normalizer names
542
+ try:
543
+ resolved = resolve_category(category)
544
+ except ValueError:
545
+ # Unknown category or cycle
546
+ return []
547
+ names = resolved
548
+ else:
549
+ names = list(self._normalizers.keys())
550
+
551
+ return sorted(n for n in names if self.is_enabled(n))
552
+
553
+ def get_categories(self) -> dict[str, dict[str, Any]]:
554
+ """Return a shallow copy of the category definitions.
555
+
556
+ Returns
557
+ -------
558
+ dict
559
+ Keys are category names; values are the definition dicts from
560
+ ``API_CATEGORIES``.
561
+ """
562
+ return dict(API_CATEGORIES)
563
+
564
+ @property
565
+ def generation(self) -> int:
566
+ """Monotonic counter bumped on every registration change.
567
+
568
+ Consumers (e.g. caches that depend on registry contents) can store
569
+ the last-seen generation and compare to detect staleness.
570
+ """
571
+ return self._generation
572
+
573
+
574
+ # ---------------------------------------------------------------------------
575
+ # Module-level singleton
576
+ # ---------------------------------------------------------------------------
577
+
578
+ registry: APIRegistry = APIRegistry()
579
+ """Module-level singleton registry instance.
580
+
581
+ All normalizers should register themselves against this instance, either
582
+ via ``registry.register(...)`` or the ``@register_api`` decorator::
583
+
584
+ from matchday.api_registry import registry
585
+
586
+ registry.register("my_api", "flights", MyInput, MyOutput, my_handler, ...)
587
+ """
588
+
589
+
590
+ # ---------------------------------------------------------------------------
591
+ # Decorator: @register_api
592
+ # ---------------------------------------------------------------------------
593
+
594
+
595
+ def register_api(
596
+ name: str,
597
+ category: str,
598
+ *,
599
+ input_schema: type[BaseModel] | None = None,
600
+ output_schema: type[BaseModel] | None = None,
601
+ check_fn: Callable[[], bool] | None = None,
602
+ requires_env: list[str] | None = None,
603
+ is_async: bool = True,
604
+ description: str = "",
605
+ emoji: str = "",
606
+ override: bool = False,
607
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
608
+ """Decorator that registers a function as an API normalizer.
609
+
610
+ Usage::
611
+
612
+ @register_api("serpapi_flights", "flights",
613
+ input_schema=FlightInput,
614
+ output_schema=list[Flight],
615
+ check_fn=lambda: bool(os.getenv("SERPAPI_KEY")))
616
+ async def search_flights(**kwargs) -> list[Flight]:
617
+ ...
618
+
619
+ Parameters
620
+ ----------
621
+ name:
622
+ Unique normalizer identifier.
623
+ category:
624
+ Category key (must exist in ``API_CATEGORIES``).
625
+ input_schema:
626
+ Pydantic model for input validation. If omitted, the decorated
627
+ function's type annotations will NOT be used -- you MUST provide
628
+ a schema or register manually.
629
+ output_schema:
630
+ Pydantic model type for the return value.
631
+ check_fn:
632
+ Callable returning ``True`` when the API is available.
633
+ Defaults to ``lambda: False`` (fail-closed).
634
+ requires_env:
635
+ Environment variables required by this normalizer.
636
+ is_async:
637
+ Whether the handler is async (default ``True``).
638
+ description:
639
+ Human-readable description.
640
+ emoji:
641
+ Decoration emoji.
642
+ override:
643
+ Allow replacing an existing registration with the same name.
644
+
645
+ Returns
646
+ -------
647
+ Callable
648
+ The original function (unchanged) -- the decorator only registers it
649
+ with the module-level ``registry`` singleton.
650
+
651
+ Raises
652
+ ------
653
+ ValueError
654
+ If ``input_schema`` is ``None`` (explicit schema is required).
655
+ """
656
+ if input_schema is None:
657
+ raise ValueError(
658
+ "@register_api requires an explicit input_schema. "
659
+ "Pydantic model is needed for input validation in dispatch()."
660
+ )
661
+
662
+ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
663
+ registry.register(
664
+ name=name,
665
+ category=category,
666
+ input_schema=input_schema,
667
+ output_schema=output_schema or BaseModel,
668
+ handler=fn,
669
+ check_fn=check_fn,
670
+ requires_env=requires_env,
671
+ is_async=is_async,
672
+ description=description or fn.__doc__ or "",
673
+ emoji=emoji,
674
+ override=override,
675
+ )
676
+ return fn
677
+
678
+ return decorator
679
+
680
+
681
+ # ---------------------------------------------------------------------------
682
+ # Helper: build_normalizer
683
+ # ---------------------------------------------------------------------------
684
+
685
+
686
+ def build_normalizer(
687
+ *,
688
+ name: str,
689
+ category: str,
690
+ input_schema: type[BaseModel],
691
+ output_schema: type[BaseModel],
692
+ handler: Callable[..., Awaitable[Any] | Any],
693
+ check_fn: Callable[[], bool] | None = None,
694
+ requires_env: list[str] | None = None,
695
+ is_async: bool = True,
696
+ description: str = "",
697
+ emoji: str = "",
698
+ ) -> APINormalizer:
699
+ """Build an ``APINormalizer`` by applying ``NORMALIZER_DEFAULTS`` first,
700
+ then overlaying the provided keyword arguments (def wins).
701
+
702
+ This is the Claude Code ``buildTool`` pattern: start with fail-closed
703
+ defaults, then let the caller's explicit overrides take precedence.
704
+
705
+ Parameters
706
+ ----------
707
+ name:
708
+ Unique normalizer identifier.
709
+ category:
710
+ Category key.
711
+ input_schema:
712
+ Pydantic input model.
713
+ output_schema:
714
+ Pydantic output model type.
715
+ handler:
716
+ The normalizer function.
717
+ check_fn:
718
+ API availability check. Defaults to ``lambda: False``.
719
+ requires_env:
720
+ Required environment variables.
721
+ is_async:
722
+ Whether the handler is async.
723
+ description:
724
+ Human-readable description.
725
+ emoji:
726
+ Decoration emoji.
727
+
728
+ Returns
729
+ -------
730
+ APINormalizer
731
+ A fully populated normalizer instance.
732
+
733
+ Notes
734
+ -----
735
+ ``NORMALIZER_DEFAULTS`` fields (``is_enabled``, ``is_concurrency_safe``,
736
+ ``max_result_size_chars``) are NOT stored in ``APINormalizer`` itself --
737
+ they are returned in the extra info dict if you need them. Callers that
738
+ wrap normalizers (e.g. middleware, executors) should read those defaults
739
+ from ``NORMALIZER_DEFAULTS`` directly.
740
+ """
741
+ # Start with a fresh dict built from NORMALIZER_DEFAULTS
742
+ overrides: dict[str, Any] = {
743
+ "name": name,
744
+ "category": category,
745
+ "input_schema": input_schema,
746
+ "output_schema": output_schema,
747
+ "handler": handler,
748
+ "check_fn": check_fn if check_fn is not None else (lambda: False),
749
+ "requires_env": requires_env or [],
750
+ "is_async": is_async,
751
+ "description": description,
752
+ "emoji": emoji,
753
+ }
754
+
755
+ return APINormalizer(**overrides)
756
+
757
+
758
+ # ---------------------------------------------------------------------------
759
+ # Explicit re-export so that ``from matchday.api_registry import registry``
760
+ # works cleanly.
761
+ # ---------------------------------------------------------------------------
762
+
763
+ __all__ = [
764
+ "APINormalizer",
765
+ "APIRegistry",
766
+ "API_CATEGORIES",
767
+ "NORMALIZER_DEFAULTS",
768
+ "build_normalizer",
769
+ "register_api",
770
+ "registry",
771
+ "resolve_category",
772
+ ]
matchday/apis/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Concrete API normalizers for MatchDay.
2
+
3
+ Importing this package registers every normalizer with the module-level
4
+ ``registry`` singleton in ``matchday.api_registry`` — registration is a side
5
+ effect of the ``@register_api`` decorator running at module load time.
6
+
7
+ Any consumer that relies on ``registry.dispatch(...)`` / ``get_enabled_*`` must
8
+ ``import matchday.apis`` (or a specific submodule) before dispatching, otherwise
9
+ the registry is inert and every dispatch returns "Unknown normalizer".
10
+
11
+ Normalizers are grouped by data source:
12
+
13
+ - ``weather`` — Open-Meteo daily forecast (keyless).
14
+ - ``pois`` — OpenStreetMap points of interest near BC Place (keyless).
15
+ - ``routes`` — OpenRouteService walking/transit routes (ORS key).
16
+ - ``flights`` / ``hotels`` / ``web`` — SerpApi (Google Flights/Hotels/Search).
17
+ """
18
+
19
+ # Load .env first so the registry's is_enabled() checks see API keys.
20
+ # os.environ.setdefault is used, so real (deployed) env vars win over the file.
21
+ from matchday import config # noqa: F401
22
+
23
+ # Importing each submodule triggers its @register_api side effects.
24
+ from matchday.apis import flights, hotels, pois, weather, web # noqa: F401 (side-effect registration)
25
+
26
+ __all__ = ["config", "flights", "hotels", "pois", "weather", "web"]
matchday/apis/flights.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SerpApi (Google Flights) flight-search normalizer, with fixture fallback.
2
+
3
+ Attempts a live SerpApi Google Flights query (origin -> YVR on match day); on
4
+ any failure — no key, quota exhausted, network error, parse error, or empty
5
+ result — it falls back to ``fixtures.fallback_flights`` so the pipeline always
6
+ yields candidates. Live results carry ``source="serpapi"``; fallback carries
7
+ ``source="fallback"`` — honest provenance either way (the plan's A7/I4
8
+ contract; Nemotron never prices, Python does).
9
+
10
+ Plan alignment: P3 lifecycle, I4 provenance, A7 fixture fallback. Retry +
11
+ timeout live inside the handler (I5 middleware deferred). SerpApi returns
12
+ airport-local times (naive); scoring's ``_to_vancouver_aware`` handles them.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import os
18
+ from datetime import date, datetime, timezone
19
+ from typing import Any
20
+
21
+ import httpx
22
+ from pydantic import BaseModel, ConfigDict, field_validator
23
+
24
+ from matchday import fixtures
25
+ from matchday.api_registry import register_api
26
+ from matchday.errors import DegradeImmediately, MaxRetriesExceeded, retry_with_backoff
27
+ from matchday.models import Flight
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ _SERPAPI_URL = "https://serpapi.com/search"
32
+ _REQUEST_TIMEOUT = 30.0
33
+ _MAX_RESULTS = 8
34
+
35
+
36
+ class FlightsInput(BaseModel):
37
+ """Input for the flights normalizer.
38
+
39
+ Accepts the full ``TripRequest`` dump (extra ignored). ``match_date`` is
40
+ used as the outbound travel date (the app books travel on match day).
41
+ """
42
+
43
+ model_config = ConfigDict(extra="ignore")
44
+
45
+ origin_airport: str
46
+ match_date: date
47
+ destination: str = "YVR"
48
+
49
+ @field_validator("origin_airport", "destination")
50
+ @classmethod
51
+ def _iata(cls, v: str) -> str:
52
+ cleaned = v.strip().upper()
53
+ if len(cleaned) != 3 or not cleaned.isalpha():
54
+ raise ValueError(f"Invalid IATA code: {v!r}")
55
+ return cleaned
56
+
57
+
58
+ def _parse_time(raw: str) -> datetime:
59
+ """Parse SerpApi's airport-local time string, e.g. '2026-06-26 11:00'."""
60
+ return datetime.fromisoformat(raw)
61
+
62
+
63
+ def _normalize(groups: list[dict[str, Any]], origin: str, dest: str) -> list[Flight]:
64
+ """Turn SerpApi flight groups into ``Flight`` models (primary leg each)."""
65
+ fetched_at = datetime.now(timezone.utc)
66
+ out: list[Flight] = []
67
+ for group in groups[:_MAX_RESULTS]:
68
+ legs = group.get("flights") or []
69
+ if not legs:
70
+ continue
71
+ # Primary leg: first segment. (YUL->YVR is nonstop; connections are
72
+ # represented as multiple legs — we surface the operating carrier.)
73
+ leg = legs[0]
74
+ try:
75
+ dep = leg["departure_airport"]
76
+ arr = leg["arrival_airport"]
77
+ price = group.get("price")
78
+ out.append(
79
+ Flight(
80
+ flight_number=str(leg.get("flight_number") or "").strip(),
81
+ airline=str(leg.get("airline") or "Unknown").strip(),
82
+ origin=origin,
83
+ destination=dest,
84
+ departure_time=_parse_time(dep["time"]),
85
+ arrival_time=_parse_time(arr["time"]),
86
+ price_cad=float(price) if price is not None else None,
87
+ booking_url=None, # booking_token requires a follow-up call; deferred
88
+ source="serpapi",
89
+ as_of=fetched_at,
90
+ )
91
+ )
92
+ except (KeyError, ValueError, TypeError) as exc:
93
+ logger.warning("Skipping SerpApi flight group: %s", exc)
94
+ return out
95
+
96
+
97
+ @register_api(
98
+ "serpapi_flights",
99
+ "flights",
100
+ input_schema=FlightsInput,
101
+ output_schema=Flight,
102
+ check_fn=lambda: bool(os.getenv("SERPAPI_API_KEY")),
103
+ requires_env=["SERPAPI_API_KEY"],
104
+ is_async=True,
105
+ description="Flight search + pricing via SerpApi (Google Flights).",
106
+ emoji="✈️",
107
+ )
108
+ async def fetch_flights(
109
+ *, origin_airport: str, match_date: date, destination: str = "YVR", **_: Any
110
+ ) -> list[Flight]:
111
+ """Fetch flights origin -> destination on match_date via SerpApi.
112
+
113
+ Falls back to fixtures (source="fallback") on any failure so the pipeline
114
+ always yields candidates.
115
+ """
116
+ key = os.getenv("SERPAPI_API_KEY")
117
+ if not key:
118
+ logger.info("No SERPAPI_API_KEY — using fixture flights.")
119
+ return fixtures.fallback_flights(origin=origin_airport, match_date=match_date)
120
+
121
+ params = {
122
+ "engine": "google_flights",
123
+ "departure_id": origin_airport,
124
+ "arrival_id": destination,
125
+ "outbound_date": match_date.isoformat(),
126
+ "type": "2", # one-way
127
+ "adults": "1",
128
+ "currency": "CAD",
129
+ "hl": "en",
130
+ "gl": "ca",
131
+ "api_key": key,
132
+ }
133
+
134
+ async def _get() -> dict[str, Any]:
135
+ async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT) as client:
136
+ resp = await client.get(_SERPAPI_URL, params=params)
137
+ resp.raise_for_status()
138
+ return resp.json()
139
+
140
+ try:
141
+ data = await retry_with_backoff(
142
+ _get, max_retries=1, base_delay=1.0, max_delay=4.0
143
+ )
144
+ except Exception as exc: # DegradeImmediately / MaxRetriesExceeded / network
145
+ logger.warning("SerpApi flights failed (%s); fixture fallback.", exc)
146
+ return fixtures.fallback_flights(origin=origin_airport, match_date=match_date)
147
+
148
+ groups = (data.get("best_flights") or []) + (data.get("other_flights") or [])
149
+ flights = _normalize(groups, origin_airport, destination)
150
+ if not flights:
151
+ logger.warning("SerpApi flights parsed empty; fixture fallback.")
152
+ return fixtures.fallback_flights(origin=origin_airport, match_date=match_date)
153
+ return flights
matchday/apis/hotels.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SerpApi (Google Hotels) hotel-search normalizer, with fixture fallback.
2
+
3
+ Attempts a live SerpApi Google Hotels query (Vancouver near BC Place for the
4
+ trip window); on any failure it falls back to ``fixtures.fallback_hotels``.
5
+ Live results carry ``source="serpapi"``; fallback carries ``source="fallback"``.
6
+ Hotel-to-stadium distance is computed (haversine from GPS) so scoring's transit
7
+ dimension has real geometry. Nemotron never prices; Python does.
8
+
9
+ Plan alignment: P3 lifecycle, I4 provenance, A7 fixture fallback. Retry +
10
+ timeout inside the handler (I5 deferred).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import math
16
+ import os
17
+ from datetime import date, datetime, timezone
18
+ from typing import Any
19
+
20
+ import httpx
21
+ from pydantic import BaseModel, ConfigDict
22
+
23
+ from matchday import fixtures
24
+ from matchday.api_registry import register_api
25
+ from matchday.errors import DegradeImmediately, MaxRetriesExceeded, retry_with_backoff
26
+ from matchday.models import Hotel
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ _SERPAPI_URL = "https://serpapi.com/search"
31
+ _BC_PLACE_LAT = 49.2827
32
+ _BC_PLACE_LON = -123.1207
33
+ _REQUEST_TIMEOUT = 30.0
34
+ _MAX_RESULTS = 10
35
+
36
+
37
+ class HotelsInput(BaseModel):
38
+ """Input for the hotels normalizer (accepts the full TripRequest dump)."""
39
+
40
+ model_config = ConfigDict(extra="ignore")
41
+
42
+ check_in: date
43
+ check_out: date
44
+
45
+
46
+ def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
47
+ earth_radius_km = 6371.0088
48
+ p1 = math.radians(lat1)
49
+ p2 = math.radians(lat2)
50
+ d_phi = math.radians(lat2 - lat1)
51
+ d_lambda = math.radians(lon2 - lon1)
52
+ a = (
53
+ math.sin(d_phi / 2) ** 2
54
+ + math.cos(p1) * math.cos(p2) * math.sin(d_lambda / 2) ** 2
55
+ )
56
+ return 2 * earth_radius_km * math.asin(math.sqrt(a))
57
+
58
+
59
+ def _normalize(
60
+ properties: list[dict[str, Any]], check_in: date, check_out: date
61
+ ) -> list[Hotel]:
62
+ """Turn SerpApi hotel properties into ``Hotel`` models."""
63
+ fetched_at = datetime.now(timezone.utc)
64
+ nights = max(1, (check_out - check_in).days)
65
+ out: list[Hotel] = []
66
+ for prop in properties[:_MAX_RESULTS]:
67
+ try:
68
+ name = prop.get("name")
69
+ if not name:
70
+ continue
71
+ rpn = prop.get("rate_per_night") or {}
72
+ trate = prop.get("total_rate") or {}
73
+ per_night = rpn.get("extracted_lowest")
74
+ total = trate.get("extracted_lowest")
75
+ if total is None and per_night is not None:
76
+ total = float(per_night) * nights # derive when SerpApi omits it
77
+
78
+ gps = prop.get("gps_coordinates") or {}
79
+ lat = gps.get("latitude") or gps.get("lat")
80
+ lon = gps.get("longitude") or gps.get("lng")
81
+ distance = (
82
+ round(_haversine_km(_BC_PLACE_LAT, _BC_PLACE_LON, lat, lon), 3)
83
+ if lat is not None and lon is not None
84
+ else None
85
+ )
86
+ rating = prop.get("overall_rating")
87
+
88
+ out.append(
89
+ Hotel(
90
+ name=name,
91
+ price_per_night_cad=float(per_night) if per_night is not None else None,
92
+ total_price_cad=float(total) if total is not None else None,
93
+ latitude=float(lat) if lat is not None else None,
94
+ longitude=float(lon) if lon is not None else None,
95
+ distance_to_stadium_km=distance,
96
+ rating=float(rating) if rating is not None else None,
97
+ booking_url=prop.get("link"),
98
+ source="serpapi",
99
+ as_of=fetched_at,
100
+ )
101
+ )
102
+ except (ValueError, TypeError) as exc:
103
+ logger.warning("Skipping SerpApi hotel %r: %s", prop.get("name"), exc)
104
+ return out
105
+
106
+
107
+ @register_api(
108
+ "serpapi_hotels",
109
+ "hotels",
110
+ input_schema=HotelsInput,
111
+ output_schema=Hotel,
112
+ check_fn=lambda: bool(os.getenv("SERPAPI_API_KEY")),
113
+ requires_env=["SERPAPI_API_KEY"],
114
+ is_async=True,
115
+ description="Hotel search + pricing via SerpApi (Google Hotels).",
116
+ emoji="🏨",
117
+ )
118
+ async def fetch_hotels(*, check_in: date, check_out: date, **_: Any) -> list[Hotel]:
119
+ """Fetch hotels near BC Place for the trip window via SerpApi.
120
+
121
+ Falls back to fixtures (source="fallback") on any failure.
122
+ """
123
+ key = os.getenv("SERPAPI_API_KEY")
124
+ if not key:
125
+ logger.info("No SERPAPI_API_KEY — using fixture hotels.")
126
+ return fixtures.fallback_hotels(check_in=check_in, check_out=check_out)
127
+
128
+ params = {
129
+ "engine": "google_hotels",
130
+ "q": "Hotels in Vancouver near BC Place",
131
+ "check_in_date": check_in.isoformat(),
132
+ "check_out_date": check_out.isoformat(),
133
+ "adults": "2",
134
+ "currency": "CAD",
135
+ "hl": "en",
136
+ "gl": "ca",
137
+ "api_key": key,
138
+ }
139
+
140
+ async def _get() -> dict[str, Any]:
141
+ async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT) as client:
142
+ resp = await client.get(_SERPAPI_URL, params=params)
143
+ resp.raise_for_status()
144
+ return resp.json()
145
+
146
+ try:
147
+ data = await retry_with_backoff(
148
+ _get, max_retries=1, base_delay=1.0, max_delay=4.0
149
+ )
150
+ except Exception as exc:
151
+ logger.warning("SerpApi hotels failed (%s); fixture fallback.", exc)
152
+ return fixtures.fallback_hotels(check_in=check_in, check_out=check_out)
153
+
154
+ properties = data.get("properties") or []
155
+ hotels = _normalize(properties, check_in, check_out)
156
+ if not hotels:
157
+ logger.warning("SerpApi hotels parsed empty; fixture fallback.")
158
+ return fixtures.fallback_hotels(check_in=check_in, check_out=check_out)
159
+ return hotels
matchday/apis/pois.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenStreetMap points-of-interest normalizer (keyless — no API key).
2
+
3
+ Uses the Overpass API to find food & drink amenities (restaurants, cafes, bars,
4
+ pubs, fast food, food courts) near BC Place Stadium, Vancouver. Each result is
5
+ normalized into an ``Amenity`` model with straight-line (haversine) distance to
6
+ BC Place and inline provenance (``source="osm"``).
7
+
8
+ Plan alignment
9
+ --------------
10
+ - P3 full normalizer lifecycle (validate -> fetch -> normalize -> provenance).
11
+ - U3 output budget: results capped at ``_MAX_RESULTS``, sorted by distance.
12
+ - I4 inline provenance on each Amenity (source + as_of).
13
+ - Keyless per the bootstrap (OpenStreetMap only asks for a descriptive
14
+ User-Agent). Retry + timeout inside the handler (I5 deferred).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import math
20
+ from datetime import datetime, timezone
21
+ from typing import Any
22
+
23
+ import httpx
24
+ from pydantic import BaseModel, ConfigDict, Field
25
+
26
+ from matchday.api_registry import register_api
27
+ from matchday.errors import DegradeImmediately, MaxRetriesExceeded, retry_with_backoff
28
+ from matchday.models import Amenity
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # BC Place Stadium, Vancouver — anchor for every POI search.
33
+ _BC_PLACE_LAT = 49.2827
34
+ _BC_PLACE_LON = -123.1207
35
+ _OVERPASS_URL = "https://overpass-api.de/api/interpreter"
36
+ _USER_AGENT = (
37
+ "MatchDay/0.1 (FIFA 2026 Vancouver trip planner; "
38
+ "build-small-hackathon Space)"
39
+ )
40
+ # Food & drink amenities worth surfacing near a stadium on match day.
41
+ _AMENITY_FILTER = "restaurant|cafe|bar|pub|fast_food|food_court"
42
+ _MAX_RESULTS = 40
43
+ _REQUEST_TIMEOUT = 25.0
44
+
45
+
46
+ class POIInput(BaseModel):
47
+ """Input schema for the POI normalizer.
48
+
49
+ Accepts the full ``TripRequest`` dump (extra ignored); ``radius_km``
50
+ defaults to 1.0 km when absent (the whole-trip dispatch path omits it).
51
+ """
52
+
53
+ model_config = ConfigDict(extra="ignore")
54
+
55
+ radius_km: float = Field(default=1.0, ge=0.1, le=5.0)
56
+
57
+
58
+ def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
59
+ """Great-circle distance in km between two lat/lon points."""
60
+ earth_radius_km = 6371.0088
61
+ phi1 = math.radians(lat1)
62
+ phi2 = math.radians(lat2)
63
+ d_phi = math.radians(lat2 - lat1)
64
+ d_lambda = math.radians(lon2 - lon1)
65
+ a = (
66
+ math.sin(d_phi / 2) ** 2
67
+ + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2
68
+ )
69
+ return 2 * earth_radius_km * math.asin(math.sqrt(a))
70
+
71
+
72
+ def _build_query(radius_m: int) -> str:
73
+ """Build an Overpass QL query for food/drink nodes near BC Place."""
74
+ return (
75
+ "[out:json][timeout:25];"
76
+ "node[\"amenity\"~\""
77
+ + _AMENITY_FILTER
78
+ + "\"]"
79
+ + f"(around:{radius_m},{_BC_PLACE_LAT},{_BC_PLACE_LON});"
80
+ + f"out body {_MAX_RESULTS};"
81
+ )
82
+
83
+
84
+ def _normalize(elements: list[dict[str, Any]]) -> list[Amenity]:
85
+ """Turn Overpass elements into distance-sorted ``Amenity`` models."""
86
+ fetched_at = datetime.now(timezone.utc)
87
+ out: list[Amenity] = []
88
+ for el in elements:
89
+ if el.get("type") != "node":
90
+ continue
91
+ tags = el.get("tags") or {}
92
+ name = tags.get("name")
93
+ category = tags.get("amenity")
94
+ if not name or not category:
95
+ continue # unnamed / untagged nodes are useless to a traveler
96
+ lat = el.get("lat")
97
+ lon = el.get("lon")
98
+ if lat is None or lon is None:
99
+ continue
100
+ try:
101
+ out.append(
102
+ Amenity(
103
+ name=name,
104
+ category=category,
105
+ distance_km=round(
106
+ _haversine_km(_BC_PLACE_LAT, _BC_PLACE_LON, lat, lon), 3
107
+ ),
108
+ latitude=lat,
109
+ longitude=lon,
110
+ source="osm",
111
+ as_of=fetched_at,
112
+ )
113
+ )
114
+ except (ValueError, TypeError) as exc:
115
+ logger.warning("Skipping POI %r: %s", name, exc)
116
+
117
+ out.sort(key=lambda a: a.distance_km)
118
+ return out
119
+
120
+
121
+ @register_api(
122
+ "osm_amenities",
123
+ "amenities",
124
+ input_schema=POIInput,
125
+ output_schema=Amenity,
126
+ check_fn=lambda: True, # keyless — always available
127
+ requires_env=[],
128
+ is_async=True,
129
+ description="Food/drink points of interest near BC Place via OpenStreetMap Overpass.",
130
+ emoji="🗺️",
131
+ )
132
+ async def fetch_pois(*, radius_km: float = 1.0, **_: Any) -> list[Amenity]:
133
+ """Fetch food/drink POIs near BC Place within ``radius_km``.
134
+
135
+ Returns a distance-sorted list of ``Amenity`` models (nearest first), or
136
+ ``[]`` on any failure — the registry wraps the result in an ``APIResult``.
137
+ """
138
+ radius_m = int(radius_km * 1000)
139
+ query = _build_query(radius_m)
140
+
141
+ async def _post() -> dict[str, Any]:
142
+ async with httpx.AsyncClient(
143
+ timeout=_REQUEST_TIMEOUT, headers={"User-Agent": _USER_AGENT}
144
+ ) as client:
145
+ resp = await client.post(_OVERPASS_URL, data={"data": query})
146
+ resp.raise_for_status()
147
+ return resp.json()
148
+
149
+ try:
150
+ data = await retry_with_backoff(
151
+ _post, max_retries=2, base_delay=2.0, max_delay=10.0
152
+ )
153
+ except (DegradeImmediately, MaxRetriesExceeded) as exc:
154
+ logger.warning("Overpass fetch degraded/exhausted: %s", exc)
155
+ return []
156
+ except Exception as exc: # pragma: no cover — final safety net
157
+ logger.warning("Overpass unexpected error: %s", exc)
158
+ return []
159
+
160
+ elements = data.get("elements") or []
161
+ return _normalize(elements)
matchday/apis/weather.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Open-Meteo weather forecast normalizer (keyless — no API key required).
2
+
3
+ Open-Meteo (https://api.open-meteo.com/v1/forecast) provides free, no-auth
4
+ daily forecasts. We query Vancouver (BC Place Stadium) across the trip window
5
+ and normalize the response into ``Weather`` models carrying inline provenance
6
+ (``source="openmeteo"``, ``as_of=<fetch time>``).
7
+
8
+ Plan alignment
9
+ --------------
10
+ - P3 full normalizer lifecycle (validate -> fetch -> classify -> normalize ->
11
+ attach provenance). Timeout + retry live inside this handler rather
12
+ than in a registry middleware layer (I5): smallest production-quality
13
+ approach that reuses ``errors.retry_with_backoff`` (N30) without a new
14
+ abstraction. Assumption noted in BUILD_PROGRESS.md.
15
+ - U3 output budget: the response is already compact (one Weather per day); no
16
+ extra truncation needed here.
17
+ - I2 (cache) + I4 (envelope provenance) are layered on in later slices.
18
+ - Brain + Hands: Nemotron never fetches weather; Python does, deterministically.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from datetime import date, datetime, timedelta, timezone
24
+ from typing import Any
25
+
26
+ import httpx
27
+ from pydantic import BaseModel, ConfigDict
28
+
29
+ from matchday.api_registry import register_api
30
+ from matchday.errors import DegradeImmediately, MaxRetriesExceeded, retry_with_backoff
31
+ from matchday.models import Weather
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # Vancouver is this app's only destination (2026 FIFA World Cup host city).
36
+ # BC Place Stadium coordinates — every forecast is anchored here.
37
+ _BC_PLACE_LAT = 49.2827
38
+ _BC_PLACE_LON = -123.1207
39
+ _OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
40
+ _DAILY_VARS = (
41
+ "temperature_2m_max",
42
+ "temperature_2m_min",
43
+ "precipitation_probability_max",
44
+ "weather_code",
45
+ )
46
+ # Open-Meteo's free forecast horizon is ~16 days FROM TODAY (it rejects
47
+ # end_date beyond today+~16d with HTTP 400). Use 15 for a safety margin. The
48
+ # window is clamped to this horizon; a wholly-out-of-range window degrades to [].
49
+ _MAX_FORECAST_DAYS = 15
50
+ _REQUEST_TIMEOUT = 20.0
51
+
52
+
53
+ class WeatherInput(BaseModel):
54
+ """Input schema for the weather normalizer.
55
+
56
+ Accepts the full ``TripRequest`` dump (extra fields ignored) and uses only
57
+ the check-in / check-out window to bound the forecast range. Dates arrive
58
+ as ``date`` objects from ``TripRequest.model_dump()``.
59
+ """
60
+
61
+ model_config = ConfigDict(extra="ignore")
62
+
63
+ check_in: date
64
+ check_out: date
65
+
66
+
67
+ def _clamp_window(check_in: date, check_out: date) -> tuple[date, date] | None:
68
+ """Bound the forecast window to Open-Meteo's today-relative horizon.
69
+
70
+ Open-Meteo rejects ``end_date`` beyond today+~16d (HTTP 400). Clamp ``end``
71
+ to the horizon; return ``None`` if the whole window is beyond it so the
72
+ caller degrades to ``[]`` gracefully. Match-day forecasts (the ones that
73
+ matter for the demo) survive as long as the match is within ~15 days.
74
+ """
75
+ horizon = date.today() + timedelta(days=_MAX_FORECAST_DAYS)
76
+ start = check_in
77
+ end = min(check_out, horizon)
78
+ if end < start:
79
+ return None
80
+ return start, end
81
+
82
+
83
+ def _normalize(data: dict[str, Any]) -> list[Weather]:
84
+ """Turn an Open-Meteo daily payload into a list of ``Weather`` models.
85
+
86
+ Defensive against missing keys / null cells: Open-Meteo occasionally omits
87
+ a variable for a given day. Each row is built independently so one bad day
88
+ does not discard the whole forecast.
89
+ """
90
+ daily = data.get("daily") or {}
91
+ times: list[str] = daily.get("time") or []
92
+ if not times:
93
+ return []
94
+
95
+ t_max = daily.get("temperature_2m_max") or []
96
+ t_min = daily.get("temperature_2m_min") or []
97
+ precip = daily.get("precipitation_probability_max") or []
98
+ wcode = daily.get("weather_code") or []
99
+ fetched_at = datetime.now(timezone.utc)
100
+
101
+ out: list[Weather] = []
102
+ for i, day_str in enumerate(times):
103
+ try:
104
+ out.append(
105
+ Weather(
106
+ date=date.fromisoformat(day_str),
107
+ temp_max_c=float(t_max[i])
108
+ if i < len(t_max) and t_max[i] is not None
109
+ else 0.0,
110
+ temp_min_c=float(t_min[i])
111
+ if i < len(t_min) and t_min[i] is not None
112
+ else 0.0,
113
+ precipitation_probability=float(precip[i])
114
+ if i < len(precip) and precip[i] is not None
115
+ else 0.0,
116
+ weather_code=int(wcode[i])
117
+ if i < len(wcode) and wcode[i] is not None
118
+ else 0,
119
+ source="openmeteo",
120
+ as_of=fetched_at,
121
+ )
122
+ )
123
+ except (ValueError, IndexError, TypeError) as exc:
124
+ logger.warning("Skipping weather day %r: %s", day_str, exc)
125
+
126
+ return out
127
+
128
+
129
+ @register_api(
130
+ "openmeteo_weather",
131
+ "weather",
132
+ input_schema=WeatherInput,
133
+ output_schema=Weather,
134
+ check_fn=lambda: True, # keyless — always available
135
+ requires_env=[],
136
+ is_async=True,
137
+ description="Daily weather forecast for Vancouver (BC Place) via Open-Meteo.",
138
+ emoji="🌤️",
139
+ )
140
+ async def fetch_weather(*, check_in: date, check_out: date, **_: Any) -> list[Weather]:
141
+ """Fetch the daily forecast for Vancouver across ``check_in..check_out``.
142
+
143
+ Returns a list of ``Weather`` models (one per day), or ``[]`` on any failure
144
+ — the registry still wraps the result in an ``APIResult`` envelope, so the
145
+ caller sees a clean success/failure signal rather than an exception.
146
+ """
147
+ window = _clamp_window(check_in, check_out)
148
+ if window is None:
149
+ logger.info(
150
+ "Trip window %s..%s beyond Open-Meteo horizon; no forecast.",
151
+ check_in,
152
+ check_out,
153
+ )
154
+ return []
155
+ start, end = window
156
+ params = {
157
+ "latitude": _BC_PLACE_LAT,
158
+ "longitude": _BC_PLACE_LON,
159
+ "daily": ",".join(_DAILY_VARS),
160
+ "start_date": start.isoformat(),
161
+ "end_date": end.isoformat(),
162
+ "timezone": "auto",
163
+ }
164
+
165
+ async def _get() -> dict[str, Any]:
166
+ async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT) as client:
167
+ resp = await client.get(_OPEN_METEO_URL, params=params)
168
+ resp.raise_for_status()
169
+ return resp.json()
170
+
171
+ try:
172
+ data = await retry_with_backoff(
173
+ _get, max_retries=2, base_delay=1.0, max_delay=8.0
174
+ )
175
+ except (DegradeImmediately, MaxRetriesExceeded) as exc:
176
+ logger.warning("Open-Meteo fetch degraded/exhausted: %s", exc)
177
+ return []
178
+ except Exception as exc: # pragma: no cover — final safety net
179
+ logger.warning("Open-Meteo unexpected error: %s", exc)
180
+ return []
181
+
182
+ return _normalize(data)
matchday/apis/web.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SerpApi (Google Search) web-search normalizer, with graceful [] fallback.
2
+
3
+ Supplemental data for the agent: match-schedule details, venue info, current
4
+ events near the travel date. Returns compact ``{title, url, snippet}`` dicts
5
+ (K6 budget: title|url|snippet[:160]). On any failure (no key, quota, network)
6
+ it returns ``[]`` — web search is optional, so the pipeline never blocks on it.
7
+
8
+ Plan alignment: P3 lifecycle, K6 compact serialization, A7 graceful degrade.
9
+ Nemotron never fetches; Python does. Retry + timeout inside the handler.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import os
15
+ from typing import Any
16
+
17
+ import httpx
18
+ from pydantic import BaseModel, ConfigDict, Field
19
+
20
+ from matchday.api_registry import register_api
21
+ from matchday.errors import DegradeImmediately, MaxRetriesExceeded, retry_with_backoff
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ _SERPAPI_URL = "https://serpapi.com/search"
26
+ _REQUEST_TIMEOUT = 30.0
27
+ _SNIPPET_CAP = 160
28
+
29
+
30
+ class WebSearchInput(BaseModel):
31
+ """Input for the web_search normalizer."""
32
+
33
+ model_config = ConfigDict(extra="ignore")
34
+
35
+ query: str = Field(min_length=1, description="Search query string")
36
+ max_results: int = Field(default=5, ge=1, le=10)
37
+
38
+
39
+ def _normalize(organic: list[dict[str, Any]] | None, max_results: int) -> list[dict[str, str]]:
40
+ out: list[dict[str, str]] = []
41
+ for r in (organic or [])[:max_results]:
42
+ title = (r.get("title") or "").strip()
43
+ url = (r.get("link") or r.get("url") or "").strip()
44
+ snippet = (r.get("snippet") or "").strip()[:_SNIPPET_CAP]
45
+ if title and url:
46
+ out.append({"title": title, "url": url, "snippet": snippet})
47
+ return out
48
+
49
+
50
+ @register_api(
51
+ "serpapi_web_search",
52
+ "web_search",
53
+ input_schema=WebSearchInput,
54
+ check_fn=lambda: bool(os.getenv("SERPAPI_API_KEY")),
55
+ requires_env=["SERPAPI_API_KEY"],
56
+ is_async=True,
57
+ description="General web search via SerpApi (Google).",
58
+ emoji="🔍",
59
+ )
60
+ async def fetch_web_search(*, query: str, max_results: int = 5, **_: Any) -> list[dict[str, str]]:
61
+ """Fetch web results for ``query`` via SerpApi. Returns [] on any failure."""
62
+ key = os.getenv("SERPAPI_API_KEY")
63
+ if not key:
64
+ logger.info("No SERPAPI_API_KEY — web_search returning [].")
65
+ return []
66
+
67
+ params = {
68
+ "engine": "google",
69
+ "q": query,
70
+ "hl": "en",
71
+ "gl": "ca",
72
+ "api_key": key,
73
+ }
74
+
75
+ async def _get() -> dict[str, Any]:
76
+ async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT) as client:
77
+ resp = await client.get(_SERPAPI_URL, params=params)
78
+ resp.raise_for_status()
79
+ return resp.json()
80
+
81
+ try:
82
+ data = await retry_with_backoff(
83
+ _get, max_retries=1, base_delay=1.0, max_delay=4.0
84
+ )
85
+ except Exception as exc:
86
+ logger.warning("SerpApi web_search failed (%s); returning [].", exc)
87
+ return []
88
+
89
+ return _normalize(data.get("organic_results"), max_results)
matchday/app.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MatchDay — Gradio app for the 2026 FIFA World Cup in Vancouver.
2
+
3
+ Agentic flow (primary): Nemotron (Brain, on Modal) decides which tool to call
4
+ and extracts the trip intent -> Python (Hands) executes build_trip_packages and
5
+ scores every price deterministically -> Nemotron writes the comparison. Falls
6
+ back to the deterministic intent parser if the agent is unavailable or declines
7
+ to call a tool, so the demo never breaks.
8
+
9
+ Brain + Hands: Nemotron never calls an API or names a price; Python does. Every
10
+ figure carries a provenance badge (live vs example).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ import logging
17
+ from datetime import date
18
+
19
+ import gradio as gr
20
+
21
+ from matchday.agent import MatchDayAgent
22
+ from matchday.agent_loop import BuildTripPackagesArgs
23
+ from matchday.intent import parse_intent
24
+ from matchday.models import TripRequest
25
+ from matchday.prompts import EXPLANATION_HINT
26
+ from matchday.render import render_full
27
+ from matchday.trip_tool import build_trip_packages, format_for_nemotron
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Nemotron primary, deterministic fallback. Flip to False to force the
32
+ # deterministic path (useful if Modal is down or for a lightning-fast demo).
33
+ USE_AGENT = True
34
+
35
+ _HEADER = """
36
+ <div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
37
+ background:linear-gradient(135deg,#0f172a,#1e3a8a);color:#fff;
38
+ padding:22px 26px;border-radius:14px;margin-bottom:6px">
39
+ <div style="font-size:28px;font-weight:800;letter-spacing:-.02em">⚽ MatchDay</div>
40
+ <div style="font-size:14px;opacity:.88;margin-top:4px">
41
+ Your AI trip planner for the <b>2026 FIFA World Cup in Vancouver</b>.
42
+ Powered by Nemotron-3-Nano-30B (the brain) + deterministic Python (the hands)
43
+ on Modal. Tell me where you're flying from and which match — I'll build 3
44
+ ranked packages (flights · hotels · weather · what's near the stadium) with
45
+ <b>live data and honest provenance</b>.
46
+ </div>
47
+ </div>
48
+ """
49
+
50
+ _EXAMPLES = [
51
+ "Flying from Montreal, want Canada vs Qatar, mid-range, June 26-29, just me",
52
+ "I want to fly from Toronto to see Brazil vs Germany, premium, July 12, 2 adults",
53
+ "Take me from Ottawa to Vancouver for Canada vs Qatar on 2026-06-26, budget",
54
+ "From Halifax, Canada vs Morocco June 18, couple, luxury",
55
+ ]
56
+
57
+
58
+ def _greenlight_html(summary: str) -> str:
59
+ return (
60
+ '<div style="font-family:-apple-system,Roboto,sans-serif;padding:12px 16px;'
61
+ 'background:#f0fdf4;border:1px solid #bbf7d0;border-radius:12px;'
62
+ f'color:#166534;font-size:14px;margin:8px 0">✅ <b>Planning your trip:</b> '
63
+ f"{summary}</div>"
64
+ )
65
+
66
+
67
+ def _clarify_html(question: str) -> str:
68
+ return (
69
+ '<div style="font-family:-apple-system,Roboto,sans-serif;padding:16px;'
70
+ 'background:#eff6ff;border:1px solid #bfdbfe;border-radius:12px;'
71
+ f'color:#1e3a8a;font-size:15px">💬 {question}</div>'
72
+ )
73
+
74
+
75
+ def _explain_html(text: str) -> str:
76
+ safe = text.replace("<", "&lt;").replace(">", "&gt;").replace("\n", "<br>")
77
+ return (
78
+ '<div style="font-family:-apple-system,Roboto,sans-serif;padding:14px 16px;'
79
+ 'background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;'
80
+ f'color:#0f172a;font-size:14px;margin-top:10px;line-height:1.5">'
81
+ f'<b style="color:#1e3a8a">🤖 Nemotron compares your options:</b><br>{safe}</div>'
82
+ )
83
+
84
+
85
+ def _fallback_note_html() -> str:
86
+ return (
87
+ '<div style="font-size:11px;color:#64748b;padding:4px 2px">'
88
+ "⚙️ agent offline — showing deterministic result.</div>"
89
+ )
90
+
91
+
92
+ def _args_to_trip(a: BuildTripPackagesArgs) -> TripRequest:
93
+ return TripRequest(
94
+ origin_airport=a.origin_airport,
95
+ match_name=a.match_name or "the match",
96
+ match_date=date.fromisoformat(a.match_date),
97
+ check_in=date.fromisoformat(a.check_in),
98
+ check_out=date.fromisoformat(a.check_out),
99
+ travelers=a.travelers,
100
+ budget_tier=a.budget_tier,
101
+ )
102
+
103
+
104
+ async def _agent_explain(agent, user_text: str, trip: TripRequest, result) -> str:
105
+ """Round 2: Nemotron compares the packages. Best-effort ('' on failure).
106
+
107
+ Builds a tool_call/tool_result pair from the executed trip so Nemotron sees
108
+ the packages regardless of whether it or the deterministic parser drove the
109
+ build.
110
+ """
111
+ args_json = json.dumps(trip.model_dump(mode="json"))
112
+ convo = [
113
+ {"role": "user", "content": user_text},
114
+ {
115
+ "role": "assistant",
116
+ "content": "",
117
+ "tool_calls": [{
118
+ "id": "call_build",
119
+ "type": "function",
120
+ "function": {"name": "build_trip_packages", "arguments": args_json},
121
+ }],
122
+ },
123
+ {
124
+ "role": "tool",
125
+ "tool_call_id": "call_build",
126
+ "name": "build_trip_packages",
127
+ "content": format_for_nemotron(result),
128
+ },
129
+ {"role": "user", "content": EXPLANATION_HINT},
130
+ ]
131
+ try:
132
+ r2 = await agent.run(convo, tools=[]) # no tools -> Nemotron must write text
133
+ return (r2.get("text") or "").strip()
134
+ except Exception as exc:
135
+ logger.warning("explanation round failed: %s", exc)
136
+ return ""
137
+
138
+
139
+ async def _agentic(user_text: str) -> str | None:
140
+ """Nemotron decides + explains, with a deterministic build safety net.
141
+
142
+ Nemotron (round 1) picks a tool. If it calls build_trip_packages we use its
143
+ args; if it clarifies/declines but the deterministic parser can still extract
144
+ a complete trip, we build anyway (the 30B model is sometimes over-cautious).
145
+ Only a genuinely incomplete request yields a clarifying question. Nemotron
146
+ always writes the final comparison (round 2).
147
+ """
148
+ try:
149
+ agent = MatchDayAgent()
150
+ except Exception as exc:
151
+ logger.warning("agent init failed (%s); deterministic fallback.", exc)
152
+ return None
153
+
154
+ base = [{"role": "user", "content": user_text}]
155
+ try:
156
+ r1 = await agent.run(base)
157
+ except Exception as exc:
158
+ logger.warning("agent round 1 failed (%s); deterministic fallback.", exc)
159
+ return None
160
+
161
+ tool_calls = r1.get("tool_calls") or []
162
+ trip: TripRequest | None = None
163
+
164
+ if tool_calls and tool_calls[0].get("name") == "build_trip_packages":
165
+ try:
166
+ trip = _args_to_trip(
167
+ BuildTripPackagesArgs.model_validate(tool_calls[0].get("arguments", {}))
168
+ )
169
+ except Exception as exc:
170
+ logger.warning("agent build_trip_packages args invalid (%s).", exc)
171
+
172
+ # Safety net: if Nemotron clarified/declined/gave bad args, try deterministic.
173
+ if trip is None:
174
+ parsed = parse_intent(user_text)
175
+ if parsed.trip_request is not None:
176
+ trip = parsed.trip_request
177
+
178
+ if trip is None:
179
+ # Genuinely incomplete: surface a clarifying question (Nemotron's, if any).
180
+ clarify_q = ""
181
+ if tool_calls and tool_calls[0].get("name") == "clarify":
182
+ clarify_q = tool_calls[0].get("arguments", {}).get("question", "")
183
+ if not clarify_q:
184
+ clarify_q = parse_intent(user_text).question
185
+ return _clarify_html(
186
+ clarify_q or "Tell me where you're flying from and which match you want to see."
187
+ )
188
+
189
+ try:
190
+ result = await build_trip_packages(trip)
191
+ except Exception as exc:
192
+ return _greenlight_html(trip.summary()) + f'<div style="color:#991b1b;padding:12px">⚠️ {exc}</div>'
193
+
194
+ explanation = await _agent_explain(agent, user_text, trip, result)
195
+ return (
196
+ _greenlight_html(trip.summary())
197
+ + render_full(result)
198
+ + (_explain_html(explanation) if explanation else "")
199
+ )
200
+
201
+
202
+ async def _deterministic(user_text: str) -> str:
203
+ parsed = parse_intent(user_text)
204
+ if parsed.trip_request is None:
205
+ return _clarify_html(parsed.question)
206
+ greenlight = _greenlight_html(parsed.trip_request.summary())
207
+ try:
208
+ result = await build_trip_packages(parsed.trip_request)
209
+ except Exception as exc:
210
+ return greenlight + f'<div style="color:#991b1b;padding:12px">⚠️ {exc}</div>'
211
+ return greenlight + _fallback_note_html() + render_full(result)
212
+
213
+
214
+ async def _plan(user_text: str) -> str:
215
+ if USE_AGENT:
216
+ html = await _agentic(user_text)
217
+ if html is not None:
218
+ return html
219
+ return await _deterministic(user_text)
220
+
221
+
222
+ def plan_trip(user_text: str) -> str:
223
+ """Gradio handler (sync wrapper around the async agentic pipeline)."""
224
+ return asyncio.run(_plan(user_text))
225
+
226
+
227
+ with gr.Blocks(title="MatchDay — FIFA World Cup Vancouver") as demo:
228
+ gr.HTML(_HEADER)
229
+ with gr.Row():
230
+ user_input = gr.Textbox(
231
+ label="Tell me about your trip",
232
+ placeholder="e.g. Flying from Montreal, want Canada vs Qatar, mid-range, June 26-29, just me",
233
+ lines=2,
234
+ value=_EXAMPLES[0],
235
+ scale=4,
236
+ )
237
+ plan_btn = gr.Button("🏈 Plan my trip", variant="primary", scale=1)
238
+ output = gr.HTML(label="Your trip packages")
239
+ gr.Examples(examples=_EXAMPLES, inputs=user_input)
240
+ plan_btn.click(fn=plan_trip, inputs=user_input, outputs=output)
241
+ user_input.submit(fn=plan_trip, inputs=user_input, outputs=output)
242
+
243
+
244
+ if __name__ == "__main__":
245
+ demo.launch()
matchday/config.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Environment configuration for MatchDay.
2
+
3
+ Loads ``matchday/.env`` into ``os.environ`` at import so the registry's
4
+ ``is_enabled()`` checks (which read ``os.environ``) see the API keys. Safe to
5
+ import multiple times. Uses manual parsing — no python-dotenv dependency, so
6
+ the HF Space runtime stays lightweight.
7
+
8
+ Real environment variables always win over ``.env`` (``os.environ.setdefault``),
9
+ so deployed Space secrets take precedence over the local file.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from pathlib import Path
15
+
16
+ _LOADED = False
17
+
18
+
19
+ def load_env(path: str | os.PathLike | None = None) -> None:
20
+ """Populate ``os.environ`` from a ``.env`` file (idempotent)."""
21
+ global _LOADED
22
+ if _LOADED and path is None:
23
+ return
24
+
25
+ env_path = Path(path) if path else Path(__file__).resolve().parent / ".env"
26
+ if env_path.exists():
27
+ for line in env_path.read_text().splitlines():
28
+ line = line.strip()
29
+ if not line or line.startswith("#") or "=" not in line:
30
+ continue
31
+ key, value = line.split("=", 1)
32
+ key = key.strip()
33
+ value = value.strip().strip('"').strip("'")
34
+ os.environ.setdefault(key, value)
35
+
36
+ _LOADED = True
37
+
38
+
39
+ # Load on import so any subsequent registry / normalizer code sees the keys.
40
+ load_env()
matchday/errors.py ADDED
@@ -0,0 +1,733 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MatchDay -- Error handling taxonomy, classification, and retry logic.
3
+
4
+ Based on the hermes-agent error_classifier.py FailoverReason pattern and
5
+ retry_utils.py jittered_backoff implementation.
6
+
7
+ Architecture:
8
+ - FailoverReason enum: 22 types covering API, model, scoring, and network
9
+ failures, plus UNKNOWN as the final catch-all.
10
+ - ClassifiedError dataclass: Every exception is classified into one of these
11
+ types with a safe user_message, retry policy, and degradation flag.
12
+ - classify_error(): Maps Python exceptions + HTTP status codes to
13
+ ClassifiedError.
14
+ - jittered_backoff(): Exponential backoff with random jitter to prevent
15
+ thundering herd on shared infrastructure.
16
+ - retry_with_backoff(): Async retry loop that classifies, backs off, and
17
+ either retries or signals graceful degradation.
18
+ - GRACEFUL_DEGRADATION: Map from FailoverReason to user-facing messages.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import random
25
+ import time
26
+ from dataclasses import dataclass, field
27
+ from enum import Enum
28
+ from typing import Awaitable, Callable, TypeVar
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # FailoverReason — exhaustive taxonomy of everything that can go wrong
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ class FailoverReason(Enum):
36
+ """Every failure mode the MatchDay system can encounter.
37
+
38
+ Naming convention: ``SOURCE_SPECIFIC_ERROR`` so callers can pattern-match
39
+ on the prefix for broad categories (API_*, MODEL_*, SCORING_*, NETWORK_*,
40
+ VALIDATION_*).
41
+ """
42
+
43
+ # ── API provider failures ────────────────────────────────────────────
44
+ API_RATE_LIMIT = "rate_limit"
45
+ """Provider returned HTTP 429 or equivalent rate-limit signal."""
46
+
47
+ API_AUTH = "api_auth"
48
+ """Provider returned HTTP 401/403 — key expired, revoked, or missing."""
49
+
50
+ API_TIMEOUT = "api_timeout"
51
+ """Provider did not respond within the configured timeout window."""
52
+
53
+ API_DOWN = "api_down"
54
+ """Provider returned HTTP 5xx or the endpoint is unreachable (DNS/TCP)."""
55
+
56
+ API_PARSE_ERROR = "api_parse_error"
57
+ """Provider returned a body that could not be parsed (malformed JSON,
58
+ unexpected schema, empty response)."""
59
+
60
+ API_QUOTA_EXCEEDED = "api_quota_exceeded"
61
+ """Provider returned HTTP 402/429 indicating the billing quota is
62
+ exhausted (distinct from transient rate-limiting)."""
63
+
64
+ API_INTERNAL_ERROR = "api_internal_error"
65
+ """Provider returned HTTP 500/502/503 with a server-side fault."""
66
+
67
+ # ── Model inference failures ─────────────────────────────────────────
68
+ MODEL_COLD_START = "model_cold_start"
69
+ """The ML model (Nemotron) is still loading — warmup not yet complete."""
70
+
71
+ MODEL_OOM = "model_oom"
72
+ """GPU out-of-memory during inference. Usually requires scale-down or
73
+ model restart."""
74
+
75
+ MODEL_MALFORMED_JSON = "model_malformed_json"
76
+ """Model output could not be parsed as valid JSON for structured
77
+ extraction."""
78
+
79
+ MODEL_TIMEOUT = "model_timeout"
80
+ """Inference exceeded the per-call timeout (usually 60-120s)."""
81
+
82
+ MODEL_EMPTY_RESPONSE = "model_empty_response"
83
+ """Model returned a zero-length or whitespace-only string."""
84
+
85
+ MODEL_CONTEXT_LENGTH = "model_context_length"
86
+ """Input exceeded the model's maximum context length (8,192 tokens for
87
+ the current Nemotron deployment)."""
88
+
89
+ MODEL_REPETITIVE = "model_repetitive"
90
+ """Model entered a repetition loop (same token sequence repeated 3+ times)
91
+ producing unusable output."""
92
+
93
+ # ── Scoring / ranking failures ───────────────────────────────────────
94
+ SCORING_EMPTY = "scoring_empty"
95
+ """No scored packages were produced — all flights/hotels were filtered
96
+ out or no trips matched the constraints."""
97
+
98
+ SCORING_ALL_LATE = "scoring_all_late"
99
+ """All candidate flights arrive after kickoff; no packages can be formed
100
+ with a non-negative arrival buffer."""
101
+
102
+ SCORING_MISSING_PRICES = "scoring_missing_prices"
103
+ """Essential price data is missing from the underlying API results
104
+ (e.g. all flights have price_cad=None), making scoring impossible."""
105
+
106
+ SCORING_NO_HOTELS = "scoring_no_hotels"
107
+ """No hotels were returned for the check-in/check-out window. Scoring
108
+ can still proceed with hotel=None, but the user should be warned."""
109
+
110
+ # ── Network / infrastructure failures ────────────────────────────────
111
+ NETWORK_ERROR = "network_error"
112
+ """Generic network failure (DNS resolution failure, connection refused,
113
+ SSL/TLS handshake error, connection reset)."""
114
+
115
+ NETWORK_TIMEOUT = "network_timeout"
116
+ """The TCP connection or TLS handshake timed out (not an API-level
117
+ timeout — occurred before the HTTP request was sent)."""
118
+
119
+ # ── Input / validation failures ──────────────────────────────────────
120
+ VALIDATION_ERROR = "validation_error"
121
+ """Input validation failed (e.g. Pydantic validation error, invalid
122
+ airport code, date outside World Cup window)."""
123
+
124
+ # ── Catch-all ────────────────────────────────────────────────────────
125
+ UNKNOWN = "unknown"
126
+ """An exception type that did not match any known classifier. Should be
127
+ logged and investigated; the system degrades gracefully."""
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # ClassifiedError — structured error envelope
132
+ # ---------------------------------------------------------------------------
133
+
134
+
135
+ @dataclass
136
+ class ClassifiedError:
137
+ """Every exception in the system is classified into this envelope.
138
+
139
+ The caller can inspect ``reason`` to decide on retry vs. degrade,
140
+ use ``user_message`` for safe UI display, and check ``should_degrade``
141
+ to bypass the retry loop and fall back to fixtures / partial results.
142
+ """
143
+
144
+ reason: FailoverReason
145
+ """The classified failure mode from the taxonomy above."""
146
+
147
+ message: str
148
+ """Detailed, developer-oriented diagnostic message (includes exception
149
+ repr, stack context, etc.). NOT safe for UI display."""
150
+
151
+ status_code: int | None
152
+ """HTTP status code when the failure originated from an API call,
153
+ or ``None`` for local errors (validation, model parsing, etc.)."""
154
+
155
+ retryable: bool
156
+ """``True`` when a retry with backoff is likely to succeed.
157
+ ``False`` when the error is permanent (auth failure, validation
158
+ error, malformed model output that won't change on re-run)."""
159
+
160
+ retry_after_seconds: float | None
161
+ """For rate-limited errors, the ``Retry-After`` header value from the
162
+ provider. ``None`` when no explicit retry-after is available — callers
163
+ should use jittered_backoff defaults instead."""
164
+
165
+ should_degrade: bool
166
+ """``True`` when the system should skip retries and fall back to
167
+ fixtures / partial results / cached data immediately. This is set
168
+ for permanent failures (auth, validation, malformed JSON) and for
169
+ errors where retrying is pointless (all flights late, no hotels)."""
170
+
171
+ user_message: str
172
+ """A safe, user-facing message that explains what happened without
173
+ exposing internal details (provider names, stack traces, API keys).
174
+ Display this in the UI when ``should_degrade`` is ``True``."""
175
+
176
+ timestamp: float = field(default_factory=time.time)
177
+ """Unix timestamp when this error was classified. Useful for logging
178
+ and for computing time-in-state in monitoring dashboards."""
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # classify_error — map Python exceptions to ClassifiedError
183
+ # ---------------------------------------------------------------------------
184
+
185
+
186
+ # Sentinel object used to detect missing Retry-After header without
187
+ # conflating None-as-not-provided with None-as-value.
188
+ _MISSING = object()
189
+
190
+
191
+ def classify_error(
192
+ exception: Exception,
193
+ status_code: int | None = None,
194
+ retry_after_header: str | None = None,
195
+ ) -> ClassifiedError:
196
+ """Map any Python exception to a ``ClassifiedError``.
197
+
198
+ Parameters
199
+ ----------
200
+ exception:
201
+ The caught exception to classify.
202
+ status_code:
203
+ HTTP status code if the error originated from an API response.
204
+ ``None`` for non-HTTP errors.
205
+ retry_after_header:
206
+ Raw ``Retry-After`` header value from the provider response, if
207
+ available. Parsed as seconds if present.
208
+
209
+ Returns
210
+ -------
211
+ ClassifiedError
212
+ Structured error envelope suitable for retry/ degrade decisions and
213
+ safe UI display.
214
+ """
215
+ # ---- Parse Retry-After header if present ----
216
+ retry_after: float | None = None
217
+ if retry_after_header is not None:
218
+ try:
219
+ retry_after = float(retry_after_header)
220
+ except (ValueError, TypeError):
221
+ retry_after = None
222
+
223
+ # ---- Re-classify an already classified error (pass-through) ----
224
+ if isinstance(exception, ClassifiedError):
225
+ return exception
226
+
227
+ exc_name = type(exception).__name__
228
+ exc_repr = repr(exception)
229
+
230
+ # ---- HTTP status-code-based classification ----
231
+ if status_code is not None:
232
+ return _classify_by_status(
233
+ exception, exc_name, exc_repr, status_code, retry_after
234
+ )
235
+
236
+ # ---- Exception-type-based classification ----
237
+ return _classify_by_exception(exception, exc_name, exc_repr, retry_after)
238
+
239
+
240
+ def _classify_by_status(
241
+ exception: Exception,
242
+ exc_name: str,
243
+ exc_repr: str,
244
+ status_code: int,
245
+ retry_after: float | None,
246
+ ) -> ClassifiedError:
247
+ """Classify based on HTTP status code."""
248
+ # 429 — Rate limited
249
+ if status_code == 429:
250
+ return ClassifiedError(
251
+ reason=FailoverReason.API_RATE_LIMIT,
252
+ message=f"API rate limited (HTTP 429): {exc_repr}",
253
+ status_code=status_code,
254
+ retryable=True,
255
+ retry_after_seconds=retry_after,
256
+ should_degrade=False,
257
+ user_message="Too many requests. Retrying automatically...",
258
+ )
259
+
260
+ # 401, 403 — Auth failure (never retry)
261
+ if status_code in (401, 403):
262
+ return ClassifiedError(
263
+ reason=FailoverReason.API_AUTH,
264
+ message=f"API authentication failed (HTTP {status_code}): {exc_repr}",
265
+ status_code=status_code,
266
+ retryable=False,
267
+ retry_after_seconds=None,
268
+ should_degrade=True,
269
+ user_message="A service connection is misconfigured. Please try again later.",
270
+ )
271
+
272
+ # 402 — Payment / quota required
273
+ if status_code == 402:
274
+ return ClassifiedError(
275
+ reason=FailoverReason.API_QUOTA_EXCEEDED,
276
+ message=f"API quota exceeded (HTTP 402): {exc_repr}",
277
+ status_code=status_code,
278
+ retryable=False,
279
+ retry_after_seconds=retry_after,
280
+ should_degrade=True,
281
+ user_message="A search service has reached its monthly limit. Results may be incomplete.",
282
+ )
283
+
284
+ # 408 — Request timeout (retryable — could be transient)
285
+ if status_code == 408:
286
+ return ClassifiedError(
287
+ reason=FailoverReason.API_TIMEOUT,
288
+ message=f"API request timed out (HTTP 408): {exc_repr}",
289
+ status_code=status_code,
290
+ retryable=True,
291
+ retry_after_seconds=None,
292
+ should_degrade=False,
293
+ user_message="Search is taking longer than expected. Retrying...",
294
+ )
295
+
296
+ # 5xx — Server error (retryable)
297
+ if 500 <= status_code <= 599:
298
+ return ClassifiedError(
299
+ reason=FailoverReason.API_INTERNAL_ERROR,
300
+ message=f"API internal error (HTTP {status_code}): {exc_repr}",
301
+ status_code=status_code,
302
+ retryable=True,
303
+ retry_after_seconds=retry_after,
304
+ should_degrade=False,
305
+ user_message="A search service is temporarily unavailable. Retrying...",
306
+ )
307
+
308
+ # -- Any other non-2xx status --
309
+ return ClassifiedError(
310
+ reason=FailoverReason.API_DOWN,
311
+ message=f"API returned unexpected HTTP {status_code}: {exc_repr}",
312
+ status_code=status_code,
313
+ retryable=True,
314
+ retry_after_seconds=None,
315
+ should_degrade=False,
316
+ user_message="Unexpected response from search service. Retrying...",
317
+ )
318
+
319
+
320
+ def _classify_by_exception(
321
+ exception: Exception,
322
+ exc_name: str,
323
+ exc_repr: str,
324
+ retry_after: float | None,
325
+ ) -> ClassifiedError:
326
+ """Classify based on exception type when no HTTP status code is available."""
327
+
328
+ # ---- Timeouts ----
329
+ # Note: asyncio.TimeoutError is a subclass of TimeoutError in 3.11+.
330
+ if isinstance(exception, TimeoutError):
331
+ return ClassifiedError(
332
+ reason=FailoverReason.NETWORK_TIMEOUT,
333
+ message=f"Connection timed out: {exc_repr}",
334
+ status_code=None,
335
+ retryable=True,
336
+ retry_after_seconds=None,
337
+ should_degrade=False,
338
+ user_message="Connection timed out. Retrying...",
339
+ )
340
+
341
+ # ---- Connection / network errors ----
342
+ # ConnectionError is the base for ConnectionRefusedError,
343
+ # ConnectionResetError, ConnectionAbortedError, etc.
344
+ # OSError covers DNS failures, "No route to host", etc.
345
+ if isinstance(exception, ConnectionError):
346
+ return ClassifiedError(
347
+ reason=FailoverReason.NETWORK_ERROR,
348
+ message=f"Connection error: {exc_repr}",
349
+ status_code=None,
350
+ retryable=True,
351
+ retry_after_seconds=None,
352
+ should_degrade=False,
353
+ user_message="Could not connect to a search service. Retrying...",
354
+ )
355
+
356
+ # OSError after checking ConnectionError subclasses above — catches
357
+ # "socket.gaierror", "EHOSTUNREACH", "ENETUNREACH", etc.
358
+ if isinstance(exception, OSError):
359
+ return ClassifiedError(
360
+ reason=FailoverReason.NETWORK_ERROR,
361
+ message=f"OS/network error: {exc_repr}",
362
+ status_code=None,
363
+ retryable=True,
364
+ retry_after_seconds=None,
365
+ should_degrade=False,
366
+ user_message="Network error. Retrying...",
367
+ )
368
+
369
+ # ---- JSON decode errors (API parse failure) ----
370
+ if isinstance(exception, (ValueError, json.JSONDecodeError)) and "JSON" in exc_name:
371
+ return ClassifiedError(
372
+ reason=FailoverReason.API_PARSE_ERROR,
373
+ message=f"Failed to parse API response: {exc_repr}",
374
+ status_code=None,
375
+ retryable=False,
376
+ retry_after_seconds=None,
377
+ should_degrade=True,
378
+ user_message="Received an unexpected response format. Please try again.",
379
+ )
380
+
381
+ # ---- General ValueError (likely data issues) ----
382
+ if isinstance(exception, ValueError):
383
+ return ClassifiedError(
384
+ reason=FailoverReason.VALIDATION_ERROR,
385
+ message=f"Validation error: {exc_repr}",
386
+ status_code=None,
387
+ retryable=False,
388
+ retry_after_seconds=None,
389
+ should_degrade=True,
390
+ user_message="Something didn't look right. Please refine your search.",
391
+ )
392
+
393
+ # ---- Type errors (programming bugs — never retry) ----
394
+ if isinstance(exception, TypeError):
395
+ return ClassifiedError(
396
+ reason=FailoverReason.VALIDATION_ERROR,
397
+ message=f"Type error: {exc_repr}",
398
+ status_code=None,
399
+ retryable=False,
400
+ retry_after_seconds=None,
401
+ should_degrade=True,
402
+ user_message="An internal error occurred. Please try again.",
403
+ )
404
+
405
+ # ---- KeyError / IndexError (data structure mismatches) ----
406
+ if isinstance(exception, (KeyError, IndexError)):
407
+ return ClassifiedError(
408
+ reason=FailoverReason.API_PARSE_ERROR,
409
+ message=f"Data structure error: {exc_repr}",
410
+ status_code=None,
411
+ retryable=False,
412
+ retry_after_seconds=None,
413
+ should_degrade=True,
414
+ user_message="Received unexpected data. Please try again.",
415
+ )
416
+
417
+ # ---- Unicode / encoding errors ----
418
+ if isinstance(exception, (UnicodeError, LookupError)):
419
+ return ClassifiedError(
420
+ reason=FailoverReason.API_PARSE_ERROR,
421
+ message=f"Encoding error: {exc_repr}",
422
+ status_code=None,
423
+ retryable=False,
424
+ retry_after_seconds=None,
425
+ should_degrade=True,
426
+ user_message="Received unreadable data. Please try again.",
427
+ )
428
+
429
+ # ---- Fallthrough: anything we haven't explicitly matched ----
430
+ return ClassifiedError(
431
+ reason=FailoverReason.UNKNOWN,
432
+ message=f"Unhandled exception ({exc_name}): {exc_repr}",
433
+ status_code=None,
434
+ retryable=False,
435
+ retry_after_seconds=None,
436
+ should_degrade=True,
437
+ user_message="An unexpected error occurred. Please try again.",
438
+ )
439
+
440
+
441
+ # Import json module used in the classifier above
442
+ import json # noqa: E402 (imported here to keep it close to usage)
443
+
444
+
445
+ # ---------------------------------------------------------------------------
446
+ # jittered_backoff — exponential backoff with random jitter
447
+ # ---------------------------------------------------------------------------
448
+
449
+
450
+ def jittered_backoff(
451
+ attempt: int,
452
+ *,
453
+ base_delay: float = 5.0,
454
+ max_delay: float = 120.0,
455
+ jitter_ratio: float = 0.5,
456
+ ) -> float:
457
+ """Compute the sleep duration before retry attempt ``attempt``.
458
+
459
+ Uses exponential backoff with additive jitter (1-based exponent), matching
460
+ the hermes-agent retry_utils.py pattern:
461
+ delay = min(base_delay * 2 ** (attempt - 1), max_delay)
462
+ sleep = delay + random_uniform(0, jitter_ratio * delay)
463
+
464
+ This prevents the thundering herd problem: when N clients all fail at the
465
+ same time and retry, exponential backoff with additive jitter spreads
466
+ independently instead of having them all land at the same moments.
467
+
468
+ Parameters
469
+ ----------
470
+ attempt:
471
+ The retry attempt number (1-based). ``attempt=1`` is the first retry.
472
+ base_delay:
473
+ Base delay in seconds. Default 5.0.
474
+ max_delay:
475
+ Maximum delay cap in seconds. Default 120.0 (2 minutes).
476
+
477
+ Returns
478
+ -------
479
+ float
480
+ Seconds to sleep before the next retry.
481
+
482
+ Examples
483
+ --------
484
+ >>> import time
485
+ >>> sleep_for = jittered_backoff(attempt=1, base_delay=5.0)
486
+ >>> time.sleep(sleep_for)
487
+ """
488
+ delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
489
+ jitter = random.uniform(0, jitter_ratio * delay)
490
+ return delay + jitter
491
+
492
+
493
+ # ---------------------------------------------------------------------------
494
+ # retry_with_backoff — async retry loop with graceful degradation
495
+ # ---------------------------------------------------------------------------
496
+
497
+ T = TypeVar("T")
498
+
499
+
500
+ class MaxRetriesExceeded(Exception):
501
+ """Raised after ``max_retries`` attempts all failed.
502
+
503
+ The final ``ClassifiedError`` from the last attempt is stored in
504
+ ``.last_error``. The original exception is chained via ``.__cause__``.
505
+ Callers can inspect ``.last_error.reason`` to decide on degradation.
506
+ """
507
+
508
+ def __init__(self, last_error: ClassifiedError) -> None:
509
+ self.last_error = last_error
510
+ super().__init__(
511
+ f"Max retries exceeded. Last error: {last_error.reason.value} "
512
+ f"({last_error.message})"
513
+ )
514
+
515
+
516
+ class DegradeImmediately(Exception):
517
+ """Raised when the first classification indicates immediate degradation.
518
+
519
+ This is a control-flow exception: it bypasses the retry loop and signals
520
+ the caller to fall back to fixtures / partial results. The attached
521
+ ``ClassifiedError`` describes why.
522
+ """
523
+
524
+ def __init__(self, classified: ClassifiedError) -> None:
525
+ self.classified = classified
526
+ self.reason = classified.reason
527
+ self.user_message = classified.user_message
528
+ super().__init__(classified.user_message)
529
+
530
+
531
+ async def retry_with_backoff(
532
+ fn: Callable[..., Awaitable[T]],
533
+ max_retries: int = 3,
534
+ base_delay: float = 5.0,
535
+ max_delay: float = 120.0,
536
+ *args,
537
+ **kwargs,
538
+ ) -> T:
539
+ """Call ``fn(*args, **kwargs)`` with exponential backoff and retry logic.
540
+
541
+ On each failure the exception is classified via ``classify_error``.
542
+ Non-retryable or degradation-worthy errors raise ``DegradeImmediately``
543
+ to short-circuit the retry loop. Retryable errors back off with jitter
544
+ and then retry. After ``max_retries`` consecutive failures,
545
+ ``MaxRetriesExceeded`` is raised with the final ``ClassifiedError``
546
+ attached.
547
+
548
+ Parameters
549
+ ----------
550
+ fn:
551
+ An async callable to invoke and retry on failure.
552
+ max_retries:
553
+ Maximum number of retry attempts after the initial call (default 3).
554
+ Total calls = 1 initial + ``max_retries`` retries.
555
+ base_delay:
556
+ Base backoff delay in seconds (passed to ``jittered_backoff``).
557
+ max_delay:
558
+ Maximum backoff delay cap in seconds.
559
+ *args, **kwargs:
560
+ Forwarded to ``fn`` on every invocation.
561
+
562
+ Returns
563
+ -------
564
+ T
565
+ The return value of ``fn`` on success.
566
+
567
+ Raises
568
+ ------
569
+ DegradeImmediately
570
+ On the first failure, if the error is non-retryable or should
571
+ degrade (e.g. auth failure, validation error, malformed JSON).
572
+ The caller should catch this and switch to degraded mode.
573
+ MaxRetriesExceeded
574
+ After all retries are exhausted. The caller should catch this and
575
+ switch to degraded mode.
576
+
577
+ Examples
578
+ --------
579
+ .. code-block:: python
580
+
581
+ from matchday.errors import retry_with_backoff, DegradeImmediately, MaxRetriesExceeded
582
+
583
+ try:
584
+ result = await retry_with_backoff(
585
+ my_api_call, max_retries=2, arg1="foo"
586
+ )
587
+ except DegradeImmediately as exc:
588
+ logger.warning("Degrading: %s", exc.user_message)
589
+ result = fallback_fixture()
590
+ except MaxRetriesExceeded as exc:
591
+ logger.error("All retries failed: %s", exc.last_error.message)
592
+ result = fallback_fixture()
593
+ """
594
+ last_error: ClassifiedError | None = None
595
+ last_exception: BaseException | None = None
596
+
597
+ for attempt in range(1 + max_retries):
598
+ try:
599
+ return await fn(*args, **kwargs)
600
+ except DegradeImmediately as exc:
601
+ # Re-raise control-flow exceptions from nested retry calls.
602
+ raise
603
+ except Exception as exc:
604
+ classified = classify_error(exc)
605
+
606
+ # If this is the first attempt and we should degrade immediately,
607
+ # short-circuit — don't waste time retrying.
608
+ if attempt == 0 and classified.should_degrade:
609
+ raise DegradeImmediately(classified) from exc
610
+
611
+ last_error = classified
612
+ last_exception = exc
613
+
614
+ # Non-retryable errors on subsequent attempts also degrade.
615
+ if not classified.retryable:
616
+ raise DegradeImmediately(classified) from exc
617
+
618
+ # If we still have retries left, sleep with jittered backoff.
619
+ if attempt < max_retries:
620
+ # Respect provider-specified retry-after if present, otherwise
621
+ # use our own computed backoff.
622
+ delay = (
623
+ classified.retry_after_seconds
624
+ if classified.retry_after_seconds is not None
625
+ else jittered_backoff(attempt + 1, base_delay=base_delay, max_delay=max_delay)
626
+ )
627
+ await asyncio.sleep(delay)
628
+
629
+ # All attempts exhausted.
630
+ raise MaxRetriesExceeded(last_error) from last_exception
631
+
632
+
633
+ # ---------------------------------------------------------------------------
634
+ # GRACEFUL_DEGRADATION — user-facing messages per failure mode
635
+ # ---------------------------------------------------------------------------
636
+
637
+ GRACEFUL_DEGRADATION: dict[FailoverReason, str] = {
638
+ # ── API provider failures ────────────────────────────────────────────
639
+ FailoverReason.API_RATE_LIMIT: (
640
+ "Live search temporarily unavailable. Showing example data."
641
+ ),
642
+ FailoverReason.API_AUTH: (
643
+ "A service connection is misconfigured. Showing example data."
644
+ ),
645
+ FailoverReason.API_TIMEOUT: (
646
+ "Search timed out. Results may be incomplete."
647
+ ),
648
+ FailoverReason.API_DOWN: (
649
+ "A search service is temporarily down. Showing example data."
650
+ ),
651
+ FailoverReason.API_PARSE_ERROR: (
652
+ "Received unexpected data from a search service. "
653
+ "Showing example data."
654
+ ),
655
+ FailoverReason.API_QUOTA_EXCEEDED: (
656
+ "A search service has reached its monthly limit. "
657
+ "Showing example data."
658
+ ),
659
+ FailoverReason.API_INTERNAL_ERROR: (
660
+ "A search service encountered an error. Retrying..."
661
+ ),
662
+ # ── Model inference failures ─────────────────────────────────────────
663
+ FailoverReason.MODEL_COLD_START: (
664
+ "Warming up our AI. This takes about 2 minutes..."
665
+ ),
666
+ FailoverReason.MODEL_OOM: (
667
+ "The AI model ran out of memory. Showing example data."
668
+ ),
669
+ FailoverReason.MODEL_MALFORMED_JSON: (
670
+ "Our AI produced unexpected output. Please try again."
671
+ ),
672
+ FailoverReason.MODEL_TIMEOUT: (
673
+ "AI response timed out. Please try again."
674
+ ),
675
+ FailoverReason.MODEL_EMPTY_RESPONSE: (
676
+ "AI returned no response. Please try again."
677
+ ),
678
+ FailoverReason.MODEL_CONTEXT_LENGTH: (
679
+ "Your request was too long. Please try a simpler search."
680
+ ),
681
+ FailoverReason.MODEL_REPETITIVE: (
682
+ "AI output was not usable. Please try again."
683
+ ),
684
+ # ── Scoring / ranking failures ───────────────────────────────────────
685
+ FailoverReason.SCORING_EMPTY: (
686
+ "No matching trips found. Try different dates or budget."
687
+ ),
688
+ FailoverReason.SCORING_ALL_LATE: (
689
+ "Flights arrive too close to kickoff. "
690
+ "Consider arriving a day earlier."
691
+ ),
692
+ FailoverReason.SCORING_MISSING_PRICES: (
693
+ "Price data is unavailable. Showing partial results."
694
+ ),
695
+ FailoverReason.SCORING_NO_HOTELS: (
696
+ "No hotels found for your dates. Showing flight-only options."
697
+ ),
698
+ # ── Network / infrastructure failures ────────────────────────────────
699
+ FailoverReason.NETWORK_ERROR: (
700
+ "Network connection issue. Please check your connection."
701
+ ),
702
+ FailoverReason.NETWORK_TIMEOUT: (
703
+ "Request timed out. Please check your connection."
704
+ ),
705
+ # ── Input / validation failures ──────────────────────────────────────
706
+ FailoverReason.VALIDATION_ERROR: (
707
+ "Something didn't look right. Please refine your search."
708
+ ),
709
+ # ── Catch-all ────────────────────────────────────────────────────────
710
+ FailoverReason.UNKNOWN: (
711
+ "An unexpected error occurred. Please try again."
712
+ ),
713
+ }
714
+
715
+
716
+ def user_message_for(reason: FailoverReason, fallback: str = "") -> str:
717
+ """Look up the user-facing message for a ``FailoverReason``.
718
+
719
+ Parameters
720
+ ----------
721
+ reason:
722
+ The failure reason to look up.
723
+ fallback:
724
+ Optional fallback string used when the reason is not found in the
725
+ degradation map (defensive — should never happen in practice since
726
+ the map covers all enum members).
727
+
728
+ Returns
729
+ -------
730
+ str
731
+ The user-safe message string.
732
+ """
733
+ return GRACEFUL_DEGRADATION.get(reason, fallback or reason.value.replace("_", " ").title())
matchday/file-listing-claude-code.txt ADDED
The diff for this file is too large to render. See raw diff
 
matchday/file-listing-deepagents.txt ADDED
@@ -0,0 +1,713 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.DS_Store
2
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/actions/uv_setup/action.yml
3
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/CODEOWNERS
4
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/dependabot.yml
5
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/images/logo-dark.svg
6
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/images/logo-light.svg
7
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/ISSUE_TEMPLATE/bug-report.yml
8
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/ISSUE_TEMPLATE/config.yml
9
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/ISSUE_TEMPLATE/feature-request.yml
10
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/ISSUE_TEMPLATE/privileged.yml
11
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/PULL_REQUEST_TEMPLATE.md
12
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/RELEASING.md
13
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/scripts/aggregate_evals.py
14
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/scripts/analyze_eval_failures.py
15
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/scripts/check_extras_sync.py
16
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/scripts/check_version_equality.py
17
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/scripts/models.py
18
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/scripts/pr-labeler-config.json
19
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/scripts/pr-labeler.js
20
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/scripts/test_release_options.py
21
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/_benchmark.yml
22
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/_lint.yml
23
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/_test.yml
24
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/auto-label-by-package.yml
25
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/check_extras_sync.yml
26
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/check_lockfiles.yml
27
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/check_partner_bounds.yml
28
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/check_sdk_pin.yml
29
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/check_versions.yml
30
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/ci.yml
31
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/close_unchecked_issues.yml
32
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/deepagents-example.yml.example
33
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/evals.yml
34
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/harbor.yml
35
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/integration_tests.yml
36
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/pr_labeler_backfill.yml
37
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/pr_labeler.yml
38
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/pr_lint.yml
39
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/release-please.yml
40
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/release.yml
41
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/reopen_on_assignment.yml
42
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/require_issue_link.yml
43
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/sync_priority_labels.yml
44
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.github/workflows/tag-external-issues.yml
45
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.gitignore
46
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.markdownlint.json
47
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.mcp.json
48
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.pre-commit-config.yaml
49
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.release-please-manifest.json
50
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.vscode/extensions.json
51
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/.vscode/settings.json
52
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/action.yml
53
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/AGENTS.md
54
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/.DS_Store
55
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/async-subagent-server/.env.example
56
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/async-subagent-server/pyproject.toml
57
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/async-subagent-server/README.md
58
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/async-subagent-server/server.py
59
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/async-subagent-server/supervisor.py
60
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/async-subagent-server/test_server.py
61
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/async-subagent-server/uv.lock
62
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/better-harness/.DS_Store
63
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/better-harness/.gitignore
64
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/better-harness/better_harness_optimization.svg
65
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/better-harness/better_harness_plugin.py
66
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/better-harness/better_harness/__init__.py
67
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/better-harness/better_harness/agent.py
68
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/better-harness/better_harness/core.py
69
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/better-harness/better_harness/patching.py
70
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/better-harness/better_harness/runners.py
71
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/better-harness/examples/deepagents_example.toml
72
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/better-harness/pyproject.toml
73
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/better-harness/README.md
74
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/better-harness/tests/__init__.py
75
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/better-harness/tests/test_better_harness.py
76
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/better-harness/uv.lock
77
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/content-builder-agent/.gitignore
78
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/content-builder-agent/AGENTS.md
79
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/content-builder-agent/content_writer.py
80
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/content-builder-agent/pyproject.toml
81
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/content-builder-agent/README.md
82
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/content-builder-agent/skills/blog-post/SKILL.md
83
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/content-builder-agent/skills/social-media/SKILL.md
84
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/content-builder-agent/subagents.yaml
85
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/content-builder-agent/uv.lock
86
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deep_research/.env.example
87
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deep_research/agent.py
88
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deep_research/langgraph.json
89
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deep_research/pyproject.toml
90
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deep_research/README.md
91
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deep_research/research_agent.ipynb
92
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deep_research/research_agent/__init__.py
93
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deep_research/research_agent/prompts.py
94
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deep_research/research_agent/tools.py
95
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deep_research/utils.py
96
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deep_research/uv.lock
97
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-coding-agent/.env.example
98
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-coding-agent/AGENTS.md
99
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-coding-agent/deepagents.assistant-scope.toml
100
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-coding-agent/deepagents.toml
101
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-coding-agent/mcp.json
102
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-coding-agent/README.md
103
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-coding-agent/skills/code-review/lint_check.py
104
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-coding-agent/skills/code-review/SKILL.md
105
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-coding-agent/skills/coding-prefs/SKILL.md
106
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-coding-agent/skills/planning/SKILL.md
107
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-content-writer/.env.example
108
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-content-writer/AGENTS.md
109
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-content-writer/deepagents.toml
110
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-content-writer/README.md
111
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-content-writer/skills/blog-post/SKILL.md
112
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-content-writer/skills/social-media/SKILL.md
113
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-content-writer/test_user_memory.py
114
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-content-writer/user/AGENTS.md
115
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-gtm-agent/AGENTS.md
116
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-gtm-agent/deepagents.toml
117
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-gtm-agent/mcp.json
118
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-gtm-agent/README.md
119
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-gtm-agent/skills/competitor-analysis/SKILL.md
120
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-gtm-agent/subagents/market-researcher/AGENTS.md
121
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-gtm-agent/subagents/market-researcher/deepagents.toml
122
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-gtm-agent/subagents/market-researcher/skills/analyze-market/SKILL.md
123
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-mcp-docs-agent/AGENTS.md
124
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-mcp-docs-agent/deepagents.toml
125
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-mcp-docs-agent/mcp.json
126
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/deploy-mcp-docs-agent/README.md
127
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/downloading_agents/content-writer.zip
128
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/downloading_agents/README.md
129
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/nvidia_deep_agent/.env.example
130
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/nvidia_deep_agent/.gitignore
131
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/nvidia_deep_agent/langgraph.json
132
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/nvidia_deep_agent/pyproject.toml
133
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/nvidia_deep_agent/README.md
134
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/nvidia_deep_agent/skills/cudf-analytics/SKILL.md
135
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/nvidia_deep_agent/skills/cuml-machine-learning/SKILL.md
136
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/nvidia_deep_agent/skills/data-visualization/SKILL.md
137
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/nvidia_deep_agent/skills/gpu-document-processing/SKILL.md
138
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/nvidia_deep_agent/src/__init__.py
139
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/nvidia_deep_agent/src/agent.py
140
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/nvidia_deep_agent/src/AGENTS.md
141
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/nvidia_deep_agent/src/backend.py
142
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/nvidia_deep_agent/src/prompts.py
143
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/nvidia_deep_agent/src/tools.py
144
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/nvidia_deep_agent/uv.lock
145
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/ralph_mode/ralph_mode_diagram.png
146
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/ralph_mode/ralph_mode.py
147
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/ralph_mode/README.md
148
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/README.md
149
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/repl_swarm/pyproject.toml
150
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/repl_swarm/README.md
151
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/repl_swarm/skills/swarm/index.ts
152
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/repl_swarm/skills/swarm/SKILL.md
153
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/repl_swarm/swarm_agent.py
154
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/repl_swarm/uv.lock
155
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/rlm_agent/pyproject.toml
156
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/rlm_agent/README.md
157
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/rlm_agent/rlm_agent.py
158
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/rlm_agent/uv.lock
159
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/text-to-sql-agent/.env.example
160
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/text-to-sql-agent/.gitignore
161
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/text-to-sql-agent/agent.py
162
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/text-to-sql-agent/AGENTS.md
163
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/text-to-sql-agent/pyproject.toml
164
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/text-to-sql-agent/README.md
165
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/text-to-sql-agent/skills/query-writing/SKILL.md
166
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/text-to-sql-agent/skills/schema-exploration/SKILL.md
167
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/text-to-sql-agent/text-to-sql-langsmith-trace.png
168
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/examples/text-to-sql-agent/uv.lock
169
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/.env.example
170
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/CHANGELOG.md
171
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/deepagents_acp/__init__.py
172
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/deepagents_acp/__main__.py
173
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/deepagents_acp/_version.py
174
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/deepagents_acp/py.typed.py
175
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/deepagents_acp/server.py
176
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/deepagents_acp/utils.py
177
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/examples/__init__.py
178
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/examples/demo_agent.py
179
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/examples/local_context.py
180
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/Makefile
181
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/pyproject.toml
182
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/README.md
183
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/run_demo_agent.sh
184
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/static/img/deepagentsacp.gif
185
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/static/img/newdeepagent.png
186
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/tests/__init__.py
187
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/tests/chat_model.py
188
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/tests/test_agent.py
189
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/tests/test_command_allowlist.py
190
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/tests/test_dangerous_patterns.py
191
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/tests/test_main.py
192
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/tests/test_model_switching.py
193
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/tests/test_utils.py
194
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/acp/uv.lock
195
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/CHANGELOG.md
196
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/__init__.py
197
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/__main__.py
198
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/_ask_user_types.py
199
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/_cli_context.py
200
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/_debug.py
201
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/_env_vars.py
202
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/_git.py
203
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/_server_config.py
204
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/_session_stats.py
205
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/_testing_models.py
206
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/_textual_patches.py
207
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/_version.py
208
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/agent.py
209
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/app.py
210
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/app.tcss
211
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/ask_user.py
212
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/built_in_skills/__init__.py
213
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/built_in_skills/remember/SKILL.md
214
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/built_in_skills/skill-creator/scripts/init_skill.py
215
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/built_in_skills/skill-creator/scripts/quick_validate.py
216
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/built_in_skills/skill-creator/SKILL.md
217
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/clipboard.py
218
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/command_registry.py
219
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/config.py
220
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/configurable_model.py
221
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/default_agent_prompt.md
222
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/deploy/__init__.py
223
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/deploy/bundler.py
224
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/deploy/commands.py
225
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/deploy/config.py
226
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/deploy/templates.py
227
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/editor.py
228
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/extras_info.py
229
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/file_ops.py
230
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/formatting.py
231
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/hooks.py
232
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/input.py
233
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/integrations/__init__.py
234
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/integrations/sandbox_factory.py
235
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/integrations/sandbox_provider.py
236
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/local_context.py
237
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/main.py
238
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/mcp_tools.py
239
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/mcp_trust.py
240
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/media_utils.py
241
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/model_config.py
242
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/non_interactive.py
243
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/notifications.py
244
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/offload.py
245
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/output.py
246
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/project_utils.py
247
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/py.typed
248
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/remote_client.py
249
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/server_graph.py
250
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/server_manager.py
251
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/server.py
252
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/sessions.py
253
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/skills/__init__.py
254
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/skills/commands.py
255
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/skills/invocation.py
256
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/skills/load.py
257
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/subagents.py
258
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/system_prompt.md
259
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/terminal_capabilities.py
260
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/textual_adapter.py
261
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/theme.py
262
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/token_state.py
263
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/tool_display.py
264
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/tools.py
265
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/ui.py
266
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/unicode_security.py
267
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/update_check.py
268
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/__init__.py
269
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/_links.py
270
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/agent_selector.py
271
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/approval.py
272
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/ask_user.py
273
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/autocomplete.py
274
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/chat_input.py
275
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/diff.py
276
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/history.py
277
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/loading.py
278
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/mcp_viewer.py
279
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/message_store.py
280
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/messages.py
281
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/model_selector.py
282
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/notification_center.py
283
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/notification_detail.py
284
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/notification_settings.py
285
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/status.py
286
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/theme_selector.py
287
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/thread_selector.py
288
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/tool_renderers.py
289
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/tool_widgets.py
290
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/update_available.py
291
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/deepagents_cli/widgets/welcome.py
292
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/DEV.md
293
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/examples/deploy-content-writer/.env.example
294
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/examples/deploy-content-writer/skills/blog-post/SKILL.md
295
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/examples/deploy-content-writer/skills/social-media/SKILL.md
296
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/examples/deploy-content-writer/user/context.md
297
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/examples/deploy-content-writer/user/preferences.md
298
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/examples/skills/arxiv-search/arxiv_search.py
299
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/examples/skills/arxiv-search/SKILL.md
300
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/examples/skills/langgraph-docs/SKILL.md
301
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/examples/skills/skill-creator/scripts/init_skill.py
302
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/examples/skills/skill-creator/scripts/quick_validate.py
303
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/examples/skills/skill-creator/SKILL.md
304
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/examples/skills/web-research/SKILL.md
305
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/images/cli.png
306
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/Makefile
307
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/pyproject.toml
308
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/README.md
309
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/scripts/check_imports.py
310
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/scripts/debug_server.sh
311
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/scripts/install.sh
312
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/integration_tests/__init__.py
313
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/integration_tests/benchmarks/__init__.py
314
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/integration_tests/benchmarks/test_codspeed_import_benchmarks.py
315
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/integration_tests/benchmarks/test_startup_benchmarks.py
316
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/integration_tests/conftest.py
317
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/integration_tests/test_acp_mode.py
318
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/integration_tests/test_compact_resume.py
319
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/integration_tests/test_sandbox_factory.py
320
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/integration_tests/test_sandbox_operations.py
321
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/README.md
322
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/__init__.py
323
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/conftest.py
324
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/deploy/__init__.py
325
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/deploy/test_bundler.py
326
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/deploy/test_commands.py
327
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/deploy/test_config.py
328
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/skills/__init__.py
329
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/skills/test_commands.py
330
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/skills/test_load.py
331
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/skills/test_skills_json.py
332
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_agent_friendly.py
333
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_agent_selector.py
334
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_agent.py
335
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_app.py
336
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_approval.py
337
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_args.py
338
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_ask_user_middleware.py
339
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_ask_user.py
340
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_autocomplete.py
341
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_charset.py
342
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_chat_input.py
343
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_command_registry.py
344
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_compact_tool.py
345
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_config.py
346
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_configurable_model.py
347
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_debug.py
348
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_editor.py
349
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_end_to_end.py
350
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_env_vars.py
351
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_exception_handling.py
352
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_extras_info.py
353
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_file_ops.py
354
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_formatting.py
355
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_git.py
356
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_history.py
357
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_hooks.py
358
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_imports.py
359
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_input_parsing.py
360
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_links.py
361
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_loading.py
362
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_local_context.py
363
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_main_acp_mode.py
364
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_main_args.py
365
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_main.py
366
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_mcp_tools.py
367
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_mcp_trust.py
368
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_mcp_viewer.py
369
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_media_utils.py
370
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_message_store.py
371
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_messages.py
372
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_model_config.py
373
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_model_selector.py
374
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_model_switch.py
375
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_non_interactive.py
376
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_notification_center.py
377
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_notification_detail.py
378
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_notifications.py
379
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_offload_dict_messages.py
380
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_offload.py
381
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_output.py
382
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_reload.py
383
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_remote_client.py
384
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_sandbox_factory.py
385
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_server_config.py
386
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_server_graph.py
387
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_server_helpers.py
388
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_server_manager.py
389
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_server.py
390
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_session_stats.py
391
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_sessions.py
392
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_shell_allow_list.py
393
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_skill_invocation.py
394
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_status.py
395
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_subagents.py
396
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_terminal_capabilities.py
397
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_textual_adapter.py
398
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_textual_patches.py
399
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_theme.py
400
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_thread_selector.py
401
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_token_tracker.py
402
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_tool_display.py
403
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_ui.py
404
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_unicode_security.py
405
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_update_available.py
406
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_update_check.py
407
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_version.py
408
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/test_welcome.py
409
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/tools/__init__.py
410
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/tests/unit_tests/tools/test_fetch_url.py
411
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/THREAT_MODEL.md
412
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/cli/uv.lock
413
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/CHANGELOG.md
414
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/__init__.py
415
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/_models.py
416
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/_version.py
417
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/backends/__init__.py
418
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/backends/composite.py
419
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/backends/filesystem.py
420
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/backends/langsmith.py
421
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/backends/local_shell.py
422
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/backends/protocol.py
423
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/backends/sandbox.py
424
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/backends/state.py
425
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/backends/store.py
426
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/backends/utils.py
427
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/graph.py
428
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/middleware/__init__.py
429
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/middleware/_tool_exclusion.py
430
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/middleware/_utils.py
431
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/middleware/async_subagents.py
432
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/middleware/filesystem.py
433
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/middleware/memory.py
434
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/middleware/patch_tool_calls.py
435
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/middleware/permissions.py
436
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/middleware/skills.py
437
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/middleware/subagents.py
438
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/middleware/summarization.py
439
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/profiles/__init__.py
440
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/profiles/_harness_profiles.py
441
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/profiles/_openai.py
442
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/profiles/_openrouter.py
443
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/deepagents/py.typed
444
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/Makefile
445
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/pyproject.toml
446
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/README.md
447
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/scripts/check_imports.py
448
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/__init__.py
449
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/benchmarks/__init__.py
450
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/benchmarks/test_benchmark_create_deep_agent.py
451
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/integration_tests/__init__.py
452
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/integration_tests/test_deepagents.py
453
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/integration_tests/test_filesystem_middleware.py
454
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/integration_tests/test_langsmith_sandbox.py
455
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/integration_tests/test_subagent_middleware.py
456
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/README.md
457
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/__init__.py
458
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/backends/__init__.py
459
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/backends/test_backwards_compat.py
460
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/backends/test_composite_backend_async.py
461
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/backends/test_composite_backend.py
462
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/backends/test_file_format.py
463
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/backends/test_files_update_deprecation.py
464
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/backends/test_filesystem_backend_async.py
465
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/backends/test_filesystem_backend.py
466
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/backends/test_langsmith_sandbox.py
467
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/backends/test_local_shell_backend.py
468
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/backends/test_protocol.py
469
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/backends/test_sandbox_backend.py
470
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/backends/test_state_backend_async.py
471
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/backends/test_state_backend.py
472
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/backends/test_store_backend_async.py
473
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/backends/test_store_backend.py
474
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/backends/test_timeout_compat.py
475
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/backends/test_utils.py
476
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/chat_model.py
477
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/middleware/__init__.py
478
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/middleware/test_compact_tool.py
479
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/middleware/test_filesystem_middleware_init.py
480
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/middleware/test_memory_middleware_async.py
481
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/middleware/test_memory_middleware.py
482
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/middleware/test_skills_middleware_async.py
483
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/middleware/test_skills_middleware.py
484
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/middleware/test_subagent_middleware_init.py
485
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/middleware/test_summarization_factory.py
486
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/middleware/test_summarization_middleware.py
487
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/middleware/test_tool_schemas.py
488
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/smoke_tests/__init__.py
489
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/smoke_tests/conftest.py
490
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/smoke_tests/snapshots/custom_system_message_tools.json
491
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/smoke_tests/snapshots/custom_system_message.md
492
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/smoke_tests/snapshots/system_prompt_with_execute_tools.json
493
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/smoke_tests/snapshots/system_prompt_with_execute.md
494
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/smoke_tests/snapshots/system_prompt_with_memory_and_skills_tools.json
495
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/smoke_tests/snapshots/system_prompt_with_memory_and_skills.md
496
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/smoke_tests/snapshots/system_prompt_with_sync_and_async_subagents_tools.json
497
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/smoke_tests/snapshots/system_prompt_with_sync_and_async_subagents.md
498
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/smoke_tests/snapshots/system_prompt_without_execute_tools.json
499
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/smoke_tests/snapshots/system_prompt_without_execute.md
500
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/smoke_tests/test_system_prompt.py
501
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/test_artifacts_root.py
502
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/test_async_subagents.py
503
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/test_end_to_end.py
504
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/test_file_system_tools_async.py
505
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/test_file_system_tools.py
506
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/test_graph.py
507
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/test_local_sandbox_operations.py
508
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/test_local_shell.py
509
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/test_middleware_async.py
510
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/test_middleware.py
511
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/test_models.py
512
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/test_permissions.py
513
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/test_subagents.py
514
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/test_timing.py
515
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/test_todo_middleware.py
516
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/unit_tests/test_version.py
517
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/tests/utils.py
518
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/THREAT_MODEL.md
519
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/deepagents/uv.lock
520
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/CONTRIBUTING.md
521
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/deepagents_evals/__init__.py
522
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/deepagents_evals/categories.json
523
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/deepagents_evals/radar.py
524
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/deepagents_harbor/__init__.py
525
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/deepagents_harbor/backend.py
526
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/deepagents_harbor/deepagents_wrapper.py
527
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/deepagents_harbor/failure.py
528
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/deepagents_harbor/langsmith_environment.py
529
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/deepagents_harbor/langsmith.py
530
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/deepagents_harbor/metadata.py
531
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/deepagents_harbor/stats.py
532
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/EVAL_CATALOG.md
533
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/Makefile
534
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/MODEL_GROUPS.md
535
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/pyproject.toml
536
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/README.md
537
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/scripts/analyze.py
538
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/scripts/generate_eval_catalog.py
539
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/scripts/generate_model_groups.py
540
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/scripts/generate_radar.py
541
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/scripts/harbor_langsmith.py
542
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/__init__.py
543
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/__init__.py
544
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/conftest.py
545
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/data/benchmark_samples/bfcl_v3_final.json
546
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/data/benchmark_samples/frames_final.json
547
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/data/benchmark_samples/nexus_final.json
548
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/data/bfcl_apis/__init__.py
549
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/data/bfcl_apis/long_context.py
550
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/data/bfcl_apis/message_api.py
551
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/data/bfcl_apis/ticket_api.py
552
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/data/bfcl_apis/trading_bot.py
553
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/data/bfcl_apis/travel_booking.py
554
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/data/bfcl_apis/vehicle_control.py
555
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/external_benchmarks.py
556
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/fixtures/summarization_seed_messages.json
557
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/llm_judge.py
558
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/memory_agent_bench/__init__.py
559
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/memory_agent_bench/configs.py
560
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/memory_agent_bench/data_utils.py
561
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/memory_agent_bench/eval_utils.py
562
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/memory_agent_bench/test_memory_agent_bench.py
563
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/pytest_reporter.py
564
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/README.md
565
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/tau2_airline/__init__.py
566
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/tau2_airline/data/db.json
567
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/tau2_airline/data/policy.md
568
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/tau2_airline/data/tasks.json
569
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/tau2_airline/domain.py
570
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/tau2_airline/evaluation.py
571
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/tau2_airline/LICENSE
572
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/tau2_airline/runner.py
573
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/tau2_airline/test_tau2_airline.py
574
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/tau2_airline/user_sim.py
575
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/test__reporter_sample.py
576
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/test_external_benchmarks.py
577
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/test_file_operations.py
578
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/test_followup_quality.py
579
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/test_hitl.py
580
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/test_memory_multiturn.py
581
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/test_memory.py
582
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/test_skills.py
583
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/test_subagents.py
584
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/test_summarization.py
585
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/test_system_prompt.py
586
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/test_todos.py
587
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/test_tool_selection.py
588
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/test_tool_usage_incident_graph.py
589
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/test_tool_usage_relational.py
590
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/evals/utils.py
591
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/unit_tests/__init__.py
592
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/unit_tests/test_analyze_eval_failures.py
593
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/unit_tests/test_category_tagging.py
594
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/unit_tests/test_eval_catalog.py
595
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/unit_tests/test_external_benchmark_helpers.py
596
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/unit_tests/test_generate_radar_script.py
597
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/unit_tests/test_harbor_backend.py
598
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/unit_tests/test_imports.py
599
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/unit_tests/test_infra.py
600
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/unit_tests/test_langsmith_environment.py
601
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/unit_tests/test_langsmith.py
602
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/unit_tests/test_model_groups.py
603
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/unit_tests/test_pytest_reporter.py
604
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/unit_tests/test_radar.py
605
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/tests/unit_tests/test_trajectory.py
606
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/evals/uv.lock
607
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/Makefile
608
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/daytona/CHANGELOG.md
609
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/daytona/langchain_daytona/__init__.py
610
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/daytona/langchain_daytona/_version.py
611
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/daytona/langchain_daytona/sandbox.py
612
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/daytona/LICENSE
613
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/daytona/Makefile
614
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/daytona/pyproject.toml
615
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/daytona/README.md
616
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/daytona/tests/__init__.py
617
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/daytona/tests/integration_tests/__init__.py
618
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/daytona/tests/integration_tests/test_integration.py
619
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/daytona/tests/test_import.py
620
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/daytona/tests/unit_tests/__init__.py
621
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/daytona/tests/unit_tests/test_import.py
622
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/daytona/uv.lock
623
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/modal/CHANGELOG.md
624
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/modal/langchain_modal/__init__.py
625
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/modal/langchain_modal/_version.py
626
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/modal/langchain_modal/sandbox.py
627
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/modal/LICENSE
628
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/modal/Makefile
629
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/modal/pyproject.toml
630
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/modal/README.md
631
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/modal/tests/__init__.py
632
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/modal/tests/integration_tests/__init__.py
633
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/modal/tests/integration_tests/test_integration.py
634
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/modal/tests/test_import.py
635
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/modal/tests/unit_tests/__init__.py
636
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/modal/tests/unit_tests/test_import.py
637
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/modal/uv.lock
638
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/CHANGELOG.md
639
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/langchain_quickjs/__init__.py
640
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/langchain_quickjs/_format.py
641
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/langchain_quickjs/_prompt.py
642
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/langchain_quickjs/_ptc.py
643
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/langchain_quickjs/_repl.py
644
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/langchain_quickjs/_skills.py
645
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/langchain_quickjs/middleware.py
646
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/langchain_quickjs/py.typed
647
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/LICENSE
648
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/Makefile
649
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/pyproject.toml
650
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/README.md
651
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/tests/__init__.py
652
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/tests/integration_tests/__init__.py
653
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/tests/integration_tests/test_rlm.py
654
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/tests/unit_tests/__init__.py
655
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/tests/unit_tests/smoke_tests/__init__.py
656
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/tests/unit_tests/smoke_tests/conftest.py
657
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/tests/unit_tests/smoke_tests/snapshots/quickjs_system_prompt_mixed_foreign_functions.md
658
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/tests/unit_tests/smoke_tests/snapshots/quickjs_system_prompt_no_tools.md
659
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/tests/unit_tests/smoke_tests/test_system_prompt.py
660
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/tests/unit_tests/test_end_to_end_async.py
661
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/tests/unit_tests/test_end_to_end.py
662
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/tests/unit_tests/test_ptc.py
663
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/tests/unit_tests/test_repl_middleware.py
664
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/tests/unit_tests/test_skills_integration.py
665
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/tests/unit_tests/test_skills.py
666
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/quickjs/uv.lock
667
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/runloop/CHANGELOG.md
668
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/runloop/langchain_runloop/__init__.py
669
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/runloop/langchain_runloop/_version.py
670
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/runloop/langchain_runloop/sandbox.py
671
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/runloop/LICENSE
672
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/runloop/Makefile
673
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/runloop/pyproject.toml
674
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/runloop/README.md
675
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/runloop/tests/__init__.py
676
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/runloop/tests/integration_tests/__init__.py
677
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/runloop/tests/integration_tests/test_integration.py
678
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/runloop/tests/test_import.py
679
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/runloop/tests/unit_tests/__init__.py
680
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/runloop/tests/unit_tests/test_import.py
681
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/partners/runloop/uv.lock
682
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/README.md
683
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/CHANGELOG.md
684
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/langchain_repl/__init__.py
685
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/langchain_repl/_foreign_function_docs.py
686
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/langchain_repl/_version.py
687
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/langchain_repl/interpreter.py
688
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/langchain_repl/middleware.py
689
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/LICENSE
690
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/Makefile
691
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/pyproject.toml
692
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/README.md
693
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/tests/__init__.py
694
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/tests/benchmarks/test_benchmark_interpreter.py
695
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/tests/unit_tests/__init__.py
696
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/tests/unit_tests/chat_model.py
697
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/tests/unit_tests/smoke_tests/__init__.py
698
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/tests/unit_tests/smoke_tests/conftest.py
699
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/tests/unit_tests/smoke_tests/snapshots/langchain_repl_system_prompt_mixed_foreign_functions_old.md
700
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/tests/unit_tests/smoke_tests/snapshots/langchain_repl_system_prompt_mixed_foreign_functions.md
701
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/tests/unit_tests/smoke_tests/snapshots/langchain_repl_system_prompt_no_tools_old.md
702
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/tests/unit_tests/smoke_tests/snapshots/langchain_repl_system_prompt_no_tools.md
703
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/tests/unit_tests/smoke_tests/test_system_prompt.py
704
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/tests/unit_tests/test_end_to_end_async.py
705
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/tests/unit_tests/test_end_to_end.py
706
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/tests/unit_tests/test_foreign_function_docs.py
707
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/tests/unit_tests/test_import.py
708
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/tests/unit_tests/test_interpreter.py
709
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/tests/unit_tests/test_system_prompt.py
710
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/libs/repl/uv.lock
711
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/LICENSE
712
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/README.md
713
+ /Users/mmz_10/Desktop/Telecom/context-other-codebases/deepagents-main/release-please-config.json
matchday/file-listing-hermes-agent.txt ADDED
The diff for this file is too large to render. See raw diff
 
matchday/fixtures.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fallback fixture data for MatchDay (graceful degradation).
2
+
3
+ When a live provider is unavailable — no API key, quota exhausted, network
4
+ failure, or unparseable response — the SerpApi normalizers fall back to these
5
+ realistic Vancouver World Cup fixtures so the pipeline always produces scored
6
+ packages and the demo never crashes.
7
+
8
+ Provenance is HONEST: every fixture carries ``source="fallback"`` and a fixed
9
+ ``as_of`` sentinel, so the frontend can clearly mark it as example data rather
10
+ than live. This is the plan's A7/I4 fixture-fallback contract: live data when
11
+ available, deterministic example data otherwise, always labelled.
12
+
13
+ Fixtures are deterministic (no randomness) so demos and tests reproduce.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from datetime import date, datetime, timezone
18
+ from zoneinfo import ZoneInfo
19
+
20
+ from matchday.models import Flight, Hotel
21
+
22
+ # Vancouver time zone (PDT, UTC-7, during the 2026 World Cup).
23
+ _VANCOUVER = ZoneInfo("America/Vancouver")
24
+ # Fixed "static" timestamp marking fixture (non-live) data.
25
+ _STATIC_AS_OF = datetime(2026, 6, 1, 0, 0, 0, tzinfo=timezone.utc)
26
+ _YVR = "YVR"
27
+
28
+
29
+ # Realistic hotels near BC Place Stadium. ``dist_km`` is approximate
30
+ # straight-line distance to the stadium. Per-night rates in CAD.
31
+ _HOTEL_FIXTURES: list[dict] = [
32
+ {"name": "Sandman Signature Vancouver", "per_night": 219, "lat": 49.2790, "lon": -123.1170, "dist_km": 0.40, "rating": 4.3},
33
+ {"name": "YWCA Hotel Vancouver", "per_night": 128, "lat": 49.2796, "lon": -123.1270, "dist_km": 0.50, "rating": 3.9},
34
+ {"name": "Victorian Hotel", "per_night": 165, "lat": 49.2820, "lon": -123.1350, "dist_km": 1.30, "rating": 4.1},
35
+ {"name": "Fairmont Hotel Vancouver", "per_night": 309, "lat": 49.2838, "lon": -123.1188, "dist_km": 0.30, "rating": 4.7},
36
+ {"name": "Marriott Parq Vancouver", "per_night": 268, "lat": 49.2710, "lon": -123.1040, "dist_km": 1.60, "rating": 4.5},
37
+ {"name": "Best Western Plus Downtown", "per_night": 184, "lat": 49.2812, "lon": -123.1300, "dist_km": 0.85, "rating": 4.2},
38
+ ]
39
+
40
+ # Realistic flights from a Canadian origin to YVR on match day. Arrival times
41
+ # (Vancouver local) span the day so scoring yields distinct Cheapest / Safest
42
+ # Arrival / Closest-to-Stadium packages. Prices in CAD.
43
+ _FLIGHT_FIXTURES: list[dict] = [
44
+ {"airline": "Flair Airlines", "no": "F8 501", "dh": 6, "dm": 15, "ah": 8, "am": 35, "price": 198},
45
+ {"airline": "Porter Airlines", "no": "PD 303", "dh": 7, "dm": 5, "ah": 9, "am": 50, "price": 237},
46
+ {"airline": "Air Canada", "no": "AC 301", "dh": 8, "dm": 0, "ah": 10, "am": 40, "price": 289},
47
+ {"airline": "WestJet", "no": "WS 511", "dh": 11, "dm": 20, "ah": 13, "am": 30, "price": 245},
48
+ {"airline": "Air Canada", "no": "AC 305", "dh": 13, "dm": 10, "ah": 15, "am": 20, "price": 312},
49
+ {"airline": "WestJet", "no": "WS 715", "dh": 15, "dm": 30, "ah": 17, "am": 10, "price": 268},
50
+ ]
51
+
52
+
53
+ def fallback_flights(*, origin: str, match_date: date) -> list[Flight]:
54
+ """Return realistic fixture flights ``origin`` -> YVR on ``match_date``.
55
+
56
+ Times are Vancouver-local (the field that matters for arrival-buffer
57
+ scoring). Each carries ``source="fallback"`` so the UI can flag it as
58
+ example data.
59
+ """
60
+ out: list[Flight] = []
61
+ for f in _FLIGHT_FIXTURES:
62
+ depart = datetime(
63
+ match_date.year, match_date.month, match_date.day,
64
+ f["dh"], f["dm"], tzinfo=_VANCOUVER,
65
+ )
66
+ arrive = datetime(
67
+ match_date.year, match_date.month, match_date.day,
68
+ f["ah"], f["am"], tzinfo=_VANCOUVER,
69
+ )
70
+ out.append(
71
+ Flight(
72
+ flight_number=f["no"],
73
+ airline=f["airline"],
74
+ origin=origin.strip().upper()[:3],
75
+ destination=_YVR,
76
+ departure_time=depart,
77
+ arrival_time=arrive,
78
+ price_cad=float(f["price"]),
79
+ booking_url=None,
80
+ source="fallback",
81
+ as_of=_STATIC_AS_OF,
82
+ )
83
+ )
84
+ return out
85
+
86
+
87
+ def fallback_hotels(*, check_in: date, check_out: date) -> list[Hotel]:
88
+ """Return realistic fixture hotels near BC Place for the given window.
89
+
90
+ ``total_price_cad`` is derived from the per-night rate x nights so scoring
91
+ produces correct totals. Each carries ``source="fallback"``.
92
+ """
93
+ nights = max(1, (check_out - check_in).days)
94
+ out: list[Hotel] = []
95
+ for h in _HOTEL_FIXTURES:
96
+ out.append(
97
+ Hotel(
98
+ name=h["name"],
99
+ price_per_night_cad=float(h["per_night"]),
100
+ total_price_cad=float(h["per_night"] * nights),
101
+ latitude=h["lat"],
102
+ longitude=h["lon"],
103
+ distance_to_stadium_km=h["dist_km"],
104
+ rating=h["rating"],
105
+ booking_url=None,
106
+ source="fallback",
107
+ as_of=_STATIC_AS_OF,
108
+ )
109
+ )
110
+ return out
111
+
112
+
113
+ def is_fixture(obj: object) -> bool:
114
+ """True if ``obj`` (a model with a ``source`` field) is fallback data."""
115
+ return getattr(obj, "source", None) == "fallback"
matchday/get_started.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import modal
2
+ app = modal.App("example-get-started")
3
+ @app.function()
4
+ def square(x):
5
+ print("This code is running on a remote worker!")
6
+ return x**2
7
+ @app.local_entrypoint()
8
+ def main():
9
+ print("the square is", square.remote(42))
matchday/intent.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic intent extraction — the Brain's fallback + green-light validator.
2
+
3
+ Turns free-text trip requests into a validated ``TripRequest`` using rules +
4
+ airport aliases (K3) — NO LLM. Nemotron is the primary extractor; this is the
5
+ deterministic safety net (``fallback_to_deterministic``) so the demo works even
6
+ when Modal/Nemotron is cold or tool-calling misbehaves, and so the chat flow can
7
+ green-light a trip without a round-trip to the model.
8
+
9
+ Plan alignment: K3 (airport alias resolution + coercion), A5 (green-light
10
+ validation), Brain+Hands (Python parses intent deterministically — Nemotron
11
+ never validates, Python does).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass
17
+ from datetime import date, timedelta
18
+
19
+ from matchday.models import TripRequest
20
+
21
+ # City / spoken alias -> IATA. Canadian origins first (the app's audience),
22
+ # then common hubs. K3 alias resolution ("montreal" -> "YUL").
23
+ AIRPORT_ALIASES: dict[str, str] = {
24
+ "montreal": "YUL", "montréal": "YUL", "yul": "YUL", "dorval": "YUL", "mirabel": "YUL",
25
+ "toronto": "YYZ", "yyz": "YYZ", "pearson": "YYZ",
26
+ "ottawa": "YOW", "yow": "YOW",
27
+ "calgary": "YYC", "yyc": "YYC",
28
+ "edmonton": "YEG", "yeg": "YEG",
29
+ "halifax": "YHZ", "yhz": "YHZ",
30
+ "winnipeg": "YWG", "ywg": "YWG",
31
+ "vancouver": "YVR", "yvr": "YVR",
32
+ "quebec city": "YQB", "québec": "YQB", "yqb": "YQB",
33
+ "saskatoon": "YXE", "regina": "YQR",
34
+ "victoria": "YYJ", "kelowna": "YLW",
35
+ "new york": "JFK", "jfk": "JFK", "lga": "LGA", "ewr": "EWR",
36
+ "los angeles": "LAX", "lax": "LAX", "san francisco": "SFO",
37
+ "chicago": "ORD", "seattle": "SEA", "boston": "BOS",
38
+ "london": "YXU", "paris": "CDG", "mexico city": "MEX",
39
+ }
40
+
41
+ _BUDGET_KEYWORDS: list[tuple[str, str]] = [
42
+ ("budget", "budget"), ("cheap", "budget"), ("economy", "budget"), ("affordable", "budget"),
43
+ ("mid-range", "mid_range"), ("mid range", "mid_range"), ("mid", "mid_range"),
44
+ ("moderate", "mid_range"), ("standard", "mid_range"), ("comfortable", "mid_range"),
45
+ ("premium", "premium"), ("luxury", "premium"), ("luxurious", "premium"),
46
+ ("high-end", "premium"), ("high end", "premium"), ("splurge", "premium"),
47
+ ]
48
+
49
+ _MONTH_INDEX = {m: i for i, m in enumerate(
50
+ ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"], 1
51
+ )}
52
+
53
+ _DEFAULT_NIGHTS = 3
54
+ _WC_YEAR = 2026
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class ParseResult:
59
+ """Outcome of deterministic intent parsing.
60
+
61
+ ``trip_request`` is set when all required slots were extracted and the
62
+ result validates; otherwise ``missing`` lists what's absent and
63
+ ``question`` is a clarifying prompt for the user.
64
+ """
65
+
66
+ trip_request: TripRequest | None
67
+ missing: list[str]
68
+ question: str
69
+
70
+
71
+ def _month_index(name: str) -> int | None:
72
+ return _MONTH_INDEX.get(name.lower().strip().rstrip(".")[:3])
73
+
74
+
75
+ def _find_origin(text: str) -> str | None:
76
+ low = text.lower()
77
+ for alias, iata in AIRPORT_ALIASES.items():
78
+ if alias in low:
79
+ return iata
80
+ m = re.search(r"\b([A-Z]{3})\b", text)
81
+ if m and m.group(1) in AIRPORT_ALIASES.values():
82
+ return m.group(1)
83
+ return None
84
+
85
+
86
+ def _find_dates(text: str) -> tuple[date | None, date | None]:
87
+ """Return (start_date, end_date_or_None) from the text."""
88
+ # ISO dates first: 2026-06-26
89
+ iso = re.findall(r"2026-(\d{2})-(\d{2})", text)
90
+ if iso:
91
+ days = sorted(date(_WC_YEAR, int(mo), int(d)) for mo, d in iso)
92
+ return days[0], (days[-1] if len(days) > 1 else None)
93
+
94
+ # "Month Day" with optional "-Day" / "to Day" / "until Day"
95
+ pat = re.compile(r"([a-z]{3,9})\.?\s+(\d{1,2})(?:\s*(?:-|–|to|until|through)\s*(\d{1,2}))?", re.IGNORECASE)
96
+ found: list[date] = []
97
+ for mname, d1, d2 in pat.findall(text):
98
+ mi = _month_index(mname)
99
+ if not mi or not (1 <= int(d1) <= 31):
100
+ continue
101
+ found.append(date(_WC_YEAR, mi, int(d1)))
102
+ if d2 and 1 <= int(d2) <= 31:
103
+ found.append(date(_WC_YEAR, mi, int(d2)))
104
+ if found:
105
+ found.sort()
106
+ return found[0], (found[-1] if len(found) > 1 else None)
107
+ return None, None
108
+
109
+
110
+ def _find_travelers(text: str) -> int:
111
+ low = text.lower()
112
+ m = re.search(r"(\d+)\s*(?:people|adults|travelers|travellers|pax|of us|guests|persons)", low)
113
+ if m:
114
+ return max(1, min(6, int(m.group(1))))
115
+ if any(w in low for w in ["just me", "solo", "by myself", " for me"]):
116
+ return 1
117
+ if any(w in low for w in ["couple", "my partner", "wife", "husband", "girlfriend", "boyfriend"]):
118
+ return 2
119
+ return 1
120
+
121
+
122
+ def _find_budget(text: str) -> str | None:
123
+ low = text.lower()
124
+ for kw, tier in _BUDGET_KEYWORDS:
125
+ if kw in low:
126
+ return tier
127
+ return None
128
+
129
+
130
+ def _find_match(text: str) -> str | None:
131
+ # Team names are capitalized (Canada, Qatar, Brazil, Germany). Matching
132
+ # capitalized tokens avoids grabbing leading filler like "want"/"see".
133
+ pat = (
134
+ r"([A-Z][A-Za-z']+(?:\s+[A-Z][A-Za-z']+){0,2})"
135
+ r"\s+vs\.?\s+"
136
+ r"([A-Z][A-Za-z']+(?:\s+[A-Z][A-Za-z']+){0,2})"
137
+ )
138
+ m = re.search(pat, text)
139
+ if m:
140
+ return f"{m.group(1).strip()} vs {m.group(2).strip()}"
141
+ return None
142
+
143
+
144
+ def parse_intent(text: str) -> ParseResult:
145
+ """Extract a validated ``TripRequest`` from free text (best-effort).
146
+
147
+ Returns a ``ParseResult``: a validated trip, or the missing slots + a
148
+ clarifying question. Never raises — invalid input yields a question.
149
+ """
150
+ missing: list[str] = []
151
+
152
+ origin = _find_origin(text)
153
+ if not origin:
154
+ missing.append("where you're flying from (e.g. 'Montreal' or 'YUL')")
155
+
156
+ start, end = _find_dates(text)
157
+ if not start:
158
+ missing.append("the match date (e.g. 'June 26')")
159
+
160
+ if missing:
161
+ question = "I'd love to plan your World Cup trip! Tell me " + "; ".join(missing) + "."
162
+ return ParseResult(None, missing, question)
163
+
164
+ travelers = _find_travelers(text)
165
+ budget = _find_budget(text) or "mid_range"
166
+ match_name = _find_match(text) or "the match"
167
+ match_date = start
168
+ check_in = start
169
+ check_out = end if (end and end > start) else start + timedelta(days=_DEFAULT_NIGHTS)
170
+
171
+ try:
172
+ trip = TripRequest(
173
+ origin_airport=origin,
174
+ match_name=match_name,
175
+ match_date=match_date,
176
+ check_in=check_in,
177
+ check_out=check_out,
178
+ travelers=travelers,
179
+ budget_tier=budget,
180
+ )
181
+ except Exception as exc:
182
+ return ParseResult(
183
+ None,
184
+ ["valid World Cup dates (June 11 – July 19, 2026)"],
185
+ f"I couldn't build a valid trip from that: {exc}",
186
+ )
187
+
188
+ return ParseResult(trip, [], "")
189
+
190
+
191
+ def green_light_summary(trip: TripRequest) -> str:
192
+ """A human one-liner for the green-light confirmation card (P7)."""
193
+ return trip.summary()
matchday/modal_spike.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Session 1.0 — Nemotron Nano 30B spike. SPIKE PASSED 2026-06-09.
3
+
4
+ Based EXACTLY on Modal's official example:
5
+ https://github.com/modal-labs/modal-examples/blob/main/06_gpu_and_ml/llm-serving/nemotron_inference.py
6
+ https://modal.com/docs/examples/nemotron_inference
7
+
8
+ Changes from example: Nano 30B BF16 on A100-80GB:1 (not Ultra 550B NVFP4 on B200:4).
9
+
10
+ ROOT CAUSE FIXES (what made it work after 4 failed attempts):
11
+ 1. Docker image: lmsysorg/sglang:v0.5.11 (NOT debian_slim + manual vllm install)
12
+ Pre-configured CUDA, FlashInfer, NCCL — no dependency hell.
13
+ 2. Entry point: sglang serve (NOT python -m sglang.launch_server)
14
+ launch_server is a legacy wrapper. serve is the correct command.
15
+ 3. startup_timeout: 120*MINUTES (NOT default)
16
+ First download is 60+ GB. Default timeout kills the container.
17
+ 4. Volume cache: huggingface-cache Volume (NOT ephemeral storage)
18
+ Subsequent runs load from disk at 1-2 GB/s instead of re-downloading.
19
+ 5. --tp 1: Single GPU (NOT default tp=4 used for Ultra)
20
+ 6. --trust-remote-code: Required. Nemotron uses custom model architecture.
21
+ 7. wait_ready() + warmup(): From Modal example. Ensures server is serving.
22
+ 8. Stripped Ultra-specific flags: No spec_args, no FP4 backends, no NVFP4.
23
+ Those are for the 550B model on B200. Nano 30B BF16 doesn't need them.
24
+ Checklist item 13 10 11 12 3 2 5 1
25
+ WHAT WORKED:
26
+ - Nemotron 31.6B boots on A100-80GB with 85% GPU mem fraction
27
+ - Cold start (first download): ~8 min. Warm start (from Volume): ~2 min.
28
+ - Structured JSON intent extraction works (temperature=0.0)
29
+ - "The current year is 2026" in system prompt fixes date defaults
30
+
31
+ N13 (2026-06-13): added --tool-call-parser qwen3_coder + --reasoning-parser
32
+ nemotron_3 + --attention-backend flashinfer. Image bumped lmsysorg/sglang:
33
+ v0.5.11 -> v0.5.12. NOTE: v0.5.12's --reasoning-parser accepts 'nemotron_3'
34
+ (NOT 'nano_v3' from the NVIDIA card — that name is for a different SGLang
35
+ build; verified from the live argparse error). qwen3_coder is accepted as-is.
36
+ Plus generate() (OpenAI tools API) + test_tool_call live test. Serving logs
37
+ un-suppressed for diagnosis.
38
+ """
39
+ import subprocess, time, json
40
+ import modal
41
+ import modal.experimental
42
+
43
+ MINUTES = 60
44
+ MODEL_NAME = "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16"
45
+ GPU = "A100-80GB:1"
46
+ PORT = 8000
47
+
48
+ # ── Image (same as Modal's example) ──
49
+ sglang_image = (
50
+ modal.Image.from_registry("lmsysorg/sglang:v0.5.12")
51
+ .entrypoint([]) # silence chatty logs
52
+ .run_commands("rm -rf /root/.cache/huggingface")
53
+ .env({
54
+ "HF_XET_HIGH_PERFORMANCE": "1",
55
+ "SAFETENSORS_FAST_GPU": "1",
56
+ "NVIDIA_TF32_OVERRIDE": "1",
57
+ })
58
+ )
59
+
60
+ # ── Volume for model cache (CRITICAL — avoids 60GB download every cold start) ──
61
+ HF_CACHE_VOL = modal.Volume.from_name("huggingface-cache", create_if_missing=True)
62
+ HF_CACHE_PATH = "/root/.cache/huggingface"
63
+ sglang_image = sglang_image.env({"HF_HUB_CACHE": HF_CACHE_PATH})
64
+
65
+ # ── Secrets ──
66
+ hf_secret = modal.Secret.from_name("huggingface-token")
67
+
68
+ # ── Helpers (copied from Modal's example) ──
69
+ with sglang_image.imports():
70
+ import requests
71
+
72
+ def wait_ready(process, timeout=20 * MINUTES):
73
+ deadline = time.time() + timeout
74
+ while time.time() < deadline:
75
+ try:
76
+ check_running(process)
77
+ requests.get(f"http://127.0.0.1:{PORT}/health").raise_for_status()
78
+ return
79
+ except (subprocess.CalledProcessError, requests.exceptions.ConnectionError, requests.exceptions.HTTPError):
80
+ time.sleep(5)
81
+ raise TimeoutError(f"SGLang not ready within {timeout}s")
82
+
83
+ def check_running(p):
84
+ if (rc := p.poll()) is not None:
85
+ raise subprocess.CalledProcessError(rc, cmd=p.args)
86
+
87
+ def warmup():
88
+ payload = {"messages": [{"role": "user", "content": "Hello"}], "max_tokens": 16}
89
+ for _ in range(3):
90
+ requests.post(f"http://127.0.0.1:{PORT}/v1/chat/completions", json=payload, timeout=30).raise_for_status()
91
+
92
+
93
+ # ── Server class (same pattern as Modal's example) ──
94
+ app = modal.App("matchday-spike")
95
+
96
+ @app.cls(
97
+ image=sglang_image,
98
+ gpu=GPU,
99
+ volumes={HF_CACHE_PATH: HF_CACHE_VOL},
100
+ secrets=[hf_secret],
101
+ startup_timeout=120 * MINUTES, # time to download + load 60GB model
102
+ scaledown_window=300,
103
+ )
104
+ class Server:
105
+ @modal.enter()
106
+ def startup(self):
107
+ cmd = [
108
+ "sglang", "serve",
109
+ "--model-path", MODEL_NAME,
110
+ "--host", "0.0.0.0",
111
+ "--port", str(PORT),
112
+ "--tp", "1",
113
+ "--trust-remote-code",
114
+ # N13 (deploy blocker): tool-calling + reasoning parser flags.
115
+ # Authoritative source: NVIDIA model card "Use it with SGLang".
116
+ # Without these, SGLang returns tool calls as raw text that the
117
+ # agent loop cannot parse -- the entire Brain+Hands architecture
118
+ # depends on qwen3_coder emitting structured tool_calls.
119
+ "--tool-call-parser", "qwen3_coder",
120
+ "--reasoning-parser", "nemotron_3",
121
+ "--attention-backend", "flashinfer",
122
+ "--context-length", "8192",
123
+ "--mem-fraction-static", "0.85",
124
+ ]
125
+ # Inherit stdout/stderr (do NOT DEVNULL) so SGLang's boot logs stream to
126
+ # the container output — essential for diagnosing parser/version issues.
127
+ # Re-suppress for production once serving is confirmed stable.
128
+ self.process = subprocess.Popen(cmd)
129
+ wait_ready(self.process)
130
+ warmup()
131
+ print("[MATCHDAY] Server ready and warmed up!")
132
+
133
+ @modal.exit()
134
+ def stop(self):
135
+ self.process.terminate()
136
+
137
+ @modal.method()
138
+ def chat(self, messages: list, temperature: float = 0.0, max_tokens: int = 256) -> str:
139
+ resp = requests.post(
140
+ f"http://127.0.0.1:{PORT}/v1/chat/completions",
141
+ json={
142
+ "model": MODEL_NAME,
143
+ "messages": messages,
144
+ "temperature": temperature,
145
+ "max_tokens": max_tokens,
146
+ "chat_template_kwargs": {"enable_thinking": False},
147
+ },
148
+ timeout=60,
149
+ )
150
+ resp.raise_for_status()
151
+ return resp.json()["choices"][0]["message"]["content"]
152
+
153
+ @modal.method()
154
+ def generate(
155
+ self,
156
+ messages: list,
157
+ *,
158
+ tools: list | None = None,
159
+ tool_choice: str | dict | None = None,
160
+ temperature: float = 0.6,
161
+ top_p: float = 0.95,
162
+ max_tokens: int = 4096,
163
+ enable_thinking: bool = False,
164
+ ) -> dict:
165
+ """OpenAI-compatible completion returning the FULL assistant message.
166
+
167
+ Unlike :meth:`chat` (content string only), this returns the whole
168
+ message dict so the caller can read parsed ``tool_calls``. This is the
169
+ inference entry point the agent loop uses: Nemotron decides which tool
170
+ to call, Python executes it (Brain + Hands -- the LLM never calls an
171
+ API or computes a price).
172
+
173
+ Per the NVIDIA model card: temperature=0.6 / top_p=0.95 for tool
174
+ calling; temperature=1.0 / top_p=1.0 for reasoning turns.
175
+ ``enable_thinking`` maps to the chat-template thinking toggle
176
+ (``chat_template_kwargs``); False for fast tool-routing turns.
177
+ """
178
+ payload: dict = {
179
+ "model": MODEL_NAME,
180
+ "messages": messages,
181
+ "temperature": temperature,
182
+ "top_p": top_p,
183
+ "max_tokens": max_tokens,
184
+ "chat_template_kwargs": {"enable_thinking": enable_thinking},
185
+ }
186
+ if tools:
187
+ payload["tools"] = tools
188
+ payload["tool_choice"] = tool_choice or "auto"
189
+
190
+ resp = requests.post(
191
+ f"http://127.0.0.1:{PORT}/v1/chat/completions",
192
+ json=payload,
193
+ timeout=120,
194
+ )
195
+ resp.raise_for_status()
196
+ return resp.json()["choices"][0]["message"]
197
+
198
+
199
+ # ── Test endpoint ──
200
+ @app.local_entrypoint()
201
+ def main():
202
+ server = Server()
203
+
204
+ result = server.chat.remote([
205
+ {"role": "system", "content": "The current year is 2026. Extract trip intent. Return ONLY valid JSON: {\"origin_airport\":\"XXX\",\"match_name\":\"...\",\"check_in\":\"YYYY-MM-DD\",\"check_out\":\"YYYY-MM-DD\",\"travelers\":N,\"budget_tier\":\"budget|mid_range|premium\"}. No preamble, no markdown."},
206
+ {"role": "user", "content": "Flying from Montreal, want Canada vs Qatar, mid-range, June 17-20, just me"}
207
+ ], temperature=0.0, max_tokens=256)
208
+
209
+ print("=== RAW OUTPUT ===")
210
+ print(result)
211
+ print("=== PARSED INTENT ===")
212
+ intent = json.loads(result)
213
+ print(json.dumps(intent, indent=2))
214
+ print("=== SPIKE PASSED ===" if "origin_airport" in intent else "=== SPIKE FAILED ===")
215
+
216
+
217
+ # ── N13 live test: does SGLang parse tool calls via qwen3_coder? ──
218
+ @app.local_entrypoint()
219
+ def test_tool_call():
220
+ """N13 de-risk test (the deploy blocker).
221
+
222
+ Sends ONE tool definition (``get_weather``) plus a prompt that should
223
+ trigger it, then inspects whether SGLang returns a parsed ``tool_calls``
224
+ list. PASS => ``--tool-call-parser qwen3_coder`` works on this image.
225
+ FAIL (no tool_calls / raw text) or a server boot error => bump the SGLang
226
+ image in the official-example pattern before falling back to prompt-JSON.
227
+ """
228
+ server = Server()
229
+ tools = [{
230
+ "type": "function",
231
+ "function": {
232
+ "name": "get_weather",
233
+ "description": "Get the weather forecast for a location.",
234
+ "parameters": {
235
+ "type": "object",
236
+ "properties": {
237
+ "latitude": {"type": "number"},
238
+ "longitude": {"type": "number"},
239
+ },
240
+ "required": ["latitude", "longitude"],
241
+ },
242
+ },
243
+ }]
244
+ messages = [
245
+ {
246
+ "role": "system",
247
+ "content": (
248
+ "The current year is 2026. You are a trip-planning assistant. "
249
+ "When the user asks for data you do not have, call a tool. "
250
+ "Do not guess values."
251
+ ),
252
+ },
253
+ {
254
+ "role": "user",
255
+ "content": (
256
+ "What is the weather forecast for Vancouver "
257
+ "(latitude 49.28, longitude -123.12) on 2026-06-26?"
258
+ ),
259
+ },
260
+ ]
261
+
262
+ try:
263
+ msg = server.generate.remote(
264
+ messages,
265
+ tools=tools,
266
+ temperature=0.6,
267
+ top_p=0.95,
268
+ max_tokens=512,
269
+ )
270
+ except Exception as exc: # boot error, parser 400, etc.
271
+ print("=== N13 TOOL-CALL TEST ===")
272
+ print("=== ERROR calling generate.remote:", repr(exc))
273
+ print("=== N13 FAIL (server/boot error) -- check parser flags + image ===")
274
+ return
275
+
276
+ print("=== N13 TOOL-CALL TEST ===")
277
+ print("assistant message:", json.dumps(msg, indent=2, default=str))
278
+
279
+ tool_calls = msg.get("tool_calls") or []
280
+ if tool_calls:
281
+ names = [(tc.get("function") or {}).get("name") for tc in tool_calls]
282
+ print("=== PARSED TOOL CALLS:", names)
283
+ if "get_weather" in names:
284
+ print("=== N13 PASS: qwen3_coder parses tool calls on this image ===")
285
+ else:
286
+ print("=== N13 PARTIAL: tool_calls parsed but unexpected name ===")
287
+ else:
288
+ content = (msg.get("content") or "")[:300]
289
+ print("=== NO PARSED TOOL CALLS. Raw content:", repr(content))
290
+ print("=== N13 FAIL: parser not producing tool_calls -- bump image next ===")
matchday/models.py ADDED
@@ -0,0 +1,491 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MatchDay -- Pydantic v2 domain models.
3
+
4
+ Core data types for the World Cup Vancouver Trip Optimizer.
5
+ All models use Pydantic v2 patterns (field_validator, model_validator, ConfigDict).
6
+
7
+ Covers the full pipeline:
8
+ Chat intent → TripRequest / IntentResult
9
+ Search APIs → Flight, Hotel, Weather, Amenity
10
+ Ranking → ScoredPackage
11
+ Error handling → APIResult
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from datetime import date, datetime, timezone
17
+ from typing import Any, Literal
18
+
19
+ from pydantic import BaseModel, ConfigDict, field_validator, model_validator
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Layer 1 & 2: Intent extraction + validation
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ class TripRequest(BaseModel):
28
+ """Validated, code-enforced trip intent extracted from user chat.
29
+
30
+ Every field is validated deterministically by Pydantic (not prompt-only).
31
+ This is the single source of truth for all downstream search and scoring.
32
+ """
33
+
34
+ origin_airport: str
35
+ """3-letter IATA code for the departure airport, e.g. ``YUL``."""
36
+
37
+ match_name: str
38
+ """Human-readable match description, e.g. ``Canada vs Qatar``."""
39
+
40
+ match_date: date
41
+ """Date of the World Cup match (must fall in the 2026 tournament window)."""
42
+
43
+ check_in: date
44
+ """Hotel check-in date (must be on or before match_date)."""
45
+
46
+ check_out: date
47
+ """Hotel check-out date (must be on or after check_in)."""
48
+
49
+ travelers: int = 1
50
+ """Number of travelers (1-6)."""
51
+
52
+ budget_tier: Literal["budget", "mid_range", "premium"]
53
+ """Overall spending preference."""
54
+
55
+ currency: str = "CAD"
56
+ """Currency for all price displays (default CAD)."""
57
+
58
+ model_config = ConfigDict(
59
+ frozen=True,
60
+ extra="forbid",
61
+ validate_default=True,
62
+ )
63
+
64
+ # -- field validators ---------------------------------------------------
65
+
66
+ @field_validator("origin_airport")
67
+ @classmethod
68
+ def _normalize_airport(cls, v: str) -> str:
69
+ cleaned = v.strip().upper()
70
+ if len(cleaned) != 3 or not cleaned.isalpha():
71
+ raise ValueError(
72
+ f"Invalid airport code: {v!r}. Must be a 3-letter IATA code "
73
+ f"(e.g. YUL, YYZ, JFK)."
74
+ )
75
+ return cleaned
76
+
77
+ @field_validator("match_date")
78
+ @classmethod
79
+ def _validate_worldcup_window(cls, v: date) -> date:
80
+ if v < date(2026, 6, 11) or v > date(2026, 7, 19):
81
+ raise ValueError(
82
+ f"Match date {v} is outside the 2026 FIFA World Cup window "
83
+ f"(June 11 - July 19, 2026)."
84
+ )
85
+ return v
86
+
87
+ @field_validator("check_in")
88
+ @classmethod
89
+ def _validate_check_in(cls, v: date) -> date:
90
+ # Cross-field validation against match_date is done by the
91
+ # model_validator below; here we only check basic range.
92
+ return v
93
+
94
+ @field_validator("check_out")
95
+ @classmethod
96
+ def _validate_check_out(cls, v: date) -> date:
97
+ return v
98
+
99
+ @field_validator("travelers")
100
+ @classmethod
101
+ def _validate_travelers(cls, v: int) -> int:
102
+ if v < 1 or v > 6:
103
+ raise ValueError(
104
+ f"Travelers must be between 1 and 6 (got {v})."
105
+ )
106
+ return v
107
+
108
+ # -- cross-field validators ---------------------------------------------
109
+
110
+ @model_validator(mode="after")
111
+ def _check_dates_ordering(self) -> TripRequest:
112
+ """check_in <= match_date and check_in <= check_out."""
113
+ errs: list[str] = []
114
+
115
+ if self.check_in > self.match_date:
116
+ errs.append(
117
+ f"Check-in ({self.check_in}) must be on or before match "
118
+ f"date ({self.match_date})."
119
+ )
120
+ if self.check_out < self.check_in:
121
+ errs.append(
122
+ f"Check-out ({self.check_out}) must be on or after check-in "
123
+ f"({self.check_in})."
124
+ )
125
+
126
+ if errs:
127
+ raise ValueError("; ".join(errs))
128
+ return self
129
+
130
+ # -- display helpers ----------------------------------------------------
131
+
132
+ def summary(self) -> str:
133
+ """Human-readable one-liner for the green-light confirmation."""
134
+ return (
135
+ f"{self.origin_airport} -> Vancouver, "
136
+ f"{self.check_in} to {self.check_out}, "
137
+ f"{self.budget_tier} budget, "
138
+ f"{self.travelers} traveler{'s' if self.travelers > 1 else ''}, "
139
+ f"watching {self.match_name} on {self.match_date}."
140
+ )
141
+
142
+
143
+ class IntentResult(BaseModel):
144
+ """The structured output from Nemotron's intent-extraction step.
145
+
146
+ ``trip_request`` is ``None`` when Nemotron could not confidently extract
147
+ all required slots; in that case ``follow_up_question`` carries the
148
+ clarifying question to present to the user.
149
+ """
150
+
151
+ reasoning: str
152
+ """Nemotron's chain-of-thought / reasoning trace for this extraction."""
153
+
154
+ trip_request: TripRequest | None
155
+ """Parsed and validated trip intent, or ``None`` if slots are missing."""
156
+
157
+ follow_up_question: str | None
158
+ """Question to ask the user when slots are missing or ambiguous."""
159
+
160
+ confidence: float
161
+ """Nemotron's self-reported confidence in this extraction (0.0 - 1.0)."""
162
+
163
+ model_config = ConfigDict(frozen=True, extra="forbid")
164
+
165
+ @field_validator("confidence")
166
+ @classmethod
167
+ def _clamp_confidence(cls, v: float) -> float:
168
+ if v < 0.0 or v > 1.0:
169
+ raise ValueError(
170
+ f"Confidence must be between 0.0 and 1.0 (got {v})."
171
+ )
172
+ return v
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # Layer 3: Normalised API results
177
+ # ---------------------------------------------------------------------------
178
+
179
+
180
+ class Flight(BaseModel):
181
+ """Normalised flight result from SerpApi (Google Flights engine).
182
+
183
+ ``price_cad`` is ``None`` when SerpApi returns a "price unavailable"
184
+ status (common for codeshare / charter flights). Never set to 0.
185
+ """
186
+
187
+ flight_number: str
188
+ """e.g. ``AC 123``."""
189
+
190
+ airline: str
191
+ """e.g. ``Air Canada``."""
192
+
193
+ origin: str
194
+ """IATA departure airport."""
195
+
196
+ destination: str
197
+ """IATA arrival airport (always YVR for this app)."""
198
+
199
+ departure_time: datetime
200
+ """Local departure datetime (always in airport local time)."""
201
+
202
+ arrival_time: datetime
203
+ """Local arrival datetime (always in airport local time)."""
204
+
205
+ price_cad: float | None
206
+ """Total price in CAD, or None when unavailable. Never 0."""
207
+
208
+ booking_url: str | None
209
+ """Deep link to book this fare."""
210
+
211
+ source: str = "serpapi"
212
+ """Data source identifier."""
213
+
214
+ as_of: datetime
215
+ """When this data was fetched from the provider."""
216
+
217
+ model_config = ConfigDict(frozen=True, extra="forbid")
218
+
219
+ @field_validator("origin", "destination")
220
+ @classmethod
221
+ def _normalize_iata(cls, v: str) -> str:
222
+ return v.strip().upper()
223
+
224
+ @field_validator("price_cad")
225
+ @classmethod
226
+ def _validate_price(cls, v: float | None) -> float | None:
227
+ if v is not None and v <= 0:
228
+ raise ValueError(
229
+ f"price_cad must be positive or None (got {v}). "
230
+ "Use None for 'unavailable', never 0."
231
+ )
232
+ return v
233
+
234
+
235
+ class Hotel(BaseModel):
236
+ """Normalised hotel result from SerpApi (Google Hotels engine).
237
+
238
+ Both ``price_per_night_cad`` and ``total_price_cad`` are optional because
239
+ Google Hotels sometimes lacks explicit per-night or total pricing for
240
+ certain property types.
241
+ """
242
+
243
+ name: str
244
+ """Hotel name."""
245
+
246
+ price_per_night_cad: float | None
247
+ """Average nightly rate in CAD, or None."""
248
+
249
+ total_price_cad: float | None
250
+ """Total stay cost in CAD, or None."""
251
+
252
+ latitude: float | None
253
+ """Decimal latitude."""
254
+
255
+ longitude: float | None
256
+ """Decimal longitude."""
257
+
258
+ distance_to_stadium_km: float | None
259
+ """Approximate straight-line distance to BC Place, in km. None when
260
+ coordinates are missing."""
261
+
262
+ rating: float | None
263
+ """Google Hotels rating (0.0 - 5.0), or None."""
264
+
265
+ booking_url: str | None
266
+ """Deep link to book this property."""
267
+
268
+ source: str = "serpapi"
269
+ """Data source identifier."""
270
+
271
+ as_of: datetime
272
+ """When this data was fetched."""
273
+
274
+ model_config = ConfigDict(frozen=True, extra="forbid")
275
+
276
+ @field_validator("rating")
277
+ @classmethod
278
+ def _validate_rating(cls, v: float | None) -> float | None:
279
+ if v is not None and (v < 0.0 or v > 5.0):
280
+ raise ValueError(f"Rating must be 0.0-5.0 or None (got {v}).")
281
+ return v
282
+
283
+ @field_validator("distance_to_stadium_km")
284
+ @classmethod
285
+ def _validate_distance(cls, v: float | None) -> float | None:
286
+ if v is not None and v < 0:
287
+ raise ValueError(
288
+ f"distance_to_stadium_km must be >= 0 or None (got {v})."
289
+ )
290
+ return v
291
+
292
+
293
+ class Weather(BaseModel):
294
+ """Daily weather forecast from Open-Meteo.
295
+
296
+ WMO weather codes (https://open-meteo.com/en/docs#weathervariables):
297
+ 0 Clear sky
298
+ 1-3 Mainly clear / partly cloudy / overcast
299
+ 45-48 Fog
300
+ 51-57 Drizzle
301
+ 61-67 Rain
302
+ 71-77 Snow
303
+ 80-82 Rain showers
304
+ 95-99 Thunderstorm
305
+ """
306
+
307
+ date: date
308
+ """Forecast date."""
309
+
310
+ temp_max_c: float
311
+ """Daily maximum temperature in Celsius."""
312
+
313
+ temp_min_c: float
314
+ """Daily minimum temperature in Celsius."""
315
+
316
+ precipitation_probability: float
317
+ """Maximum precipitation probability for the day (0-100 %)."""
318
+
319
+ weather_code: int
320
+ """WMO weather interpretation code."""
321
+
322
+ source: str = "openmeteo"
323
+ """Data source identifier."""
324
+
325
+ as_of: datetime
326
+ """When this forecast was fetched."""
327
+
328
+ model_config = ConfigDict(frozen=True, extra="forbid")
329
+
330
+ @field_validator("precipitation_probability")
331
+ @classmethod
332
+ def _clamp_precip(cls, v: float) -> float:
333
+ if v < 0.0 or v > 100.0:
334
+ raise ValueError(
335
+ f"precipitation_probability must be 0-100 (got {v})."
336
+ )
337
+ return v
338
+
339
+
340
+ class Amenity(BaseModel):
341
+ """A point of interest near BC Place from OSM Overpass.
342
+
343
+ Categories follow OSM amenity tags:
344
+ restaurant, cafe, bar, pub, fast_food, food_court, etc.
345
+ """
346
+
347
+ name: str
348
+ """Establishment name."""
349
+
350
+ category: str
351
+ """OSM amenity category, e.g. ``restaurant``, ``cafe``, ``bar``."""
352
+
353
+ distance_km: float
354
+ """Approximate straight-line distance from BC Place in km."""
355
+
356
+ latitude: float | None = None
357
+ """Decimal latitude (for map placement). None when OSM omitted coords."""
358
+
359
+ longitude: float | None = None
360
+ """Decimal longitude (for map placement). None when OSM omitted coords."""
361
+
362
+ source: str = "osm"
363
+ """Data source identifier."""
364
+
365
+ as_of: datetime
366
+ """When this data was fetched."""
367
+
368
+ model_config = ConfigDict(frozen=True, extra="forbid")
369
+
370
+ @field_validator("distance_km")
371
+ @classmethod
372
+ def _validate_dist(cls, v: float) -> float:
373
+ if v < 0:
374
+ raise ValueError(
375
+ f"distance_km must be >= 0 (got {v})."
376
+ )
377
+ return v
378
+
379
+ @field_validator("category")
380
+ @classmethod
381
+ def _normalise_category(cls, v: str) -> str:
382
+ return v.strip().lower()
383
+
384
+
385
+ # ---------------------------------------------------------------------------
386
+ # Layer 4: Scored output
387
+ # ---------------------------------------------------------------------------
388
+
389
+
390
+ class ScoredPackage(BaseModel):
391
+ """One of the three ranked travel packages returned to the frontend.
392
+
393
+ Each package optimises a different objective:
394
+
395
+ * ``Cheapest`` -- lowest total cost that still arrives before kickoff
396
+ * ``Safest Arrival`` -- maximum buffer between landing and match start
397
+ * ``Closest to Stadium`` -- shortest transit from hotel to BC Place
398
+ """
399
+
400
+ label: Literal["Cheapest", "Safest Arrival", "Closest to Stadium"]
401
+ """Human-readable label identifying which objective this package optimises."""
402
+
403
+ flight: Flight
404
+ """Selected flight for this package."""
405
+
406
+ hotel: Hotel | None
407
+ """Selected hotel, or None when no suitable hotel was found."""
408
+
409
+ weather: list[Weather]
410
+ """Forecast for all dates in the trip (check_in through match_date
411
+ at minimum)."""
412
+
413
+ amenities: list[Amenity]
414
+ """Points of interest within walking distance of the hotel or stadium."""
415
+
416
+ total_cost_cad: float
417
+ """Total trip cost (flight + hotel) in CAD. 0.0 when hotel is None."""
418
+
419
+ arrival_buffer_hours: float
420
+ """Hours between flight arrival and the default 7:00 PM PT kickoff.
421
+ Negative values mean the flight arrives after kickoff."""
422
+
423
+ hotel_to_stadium_min: int
424
+ """Walking / transit time from hotel to BC Place in minutes. 0 when
425
+ hotel is None."""
426
+
427
+ scores: dict[str, float]
428
+ """Individual dimension scores (affordability, buffer, distance, etc.)
429
+ as computed by the deterministic scoring engine."""
430
+
431
+ explanation: str
432
+ """Nemotron-written natural language explanation of why this package
433
+ was chosen and what tradeoffs it makes."""
434
+
435
+ generated_at: datetime
436
+ """Timestamp when this package was assembled."""
437
+
438
+ model_config = ConfigDict(frozen=True, extra="forbid")
439
+
440
+ @field_validator("arrival_buffer_hours")
441
+ @classmethod
442
+ def _validate_buffer(cls, v: float) -> float:
443
+ # Negative buffers are semantically valid (flight lands after
444
+ # kickoff) and signal a problem to the caller.
445
+ return v
446
+
447
+ @field_validator("hotel_to_stadium_min")
448
+ @classmethod
449
+ def _validate_walk(cls, v: int) -> int:
450
+ if v < 0:
451
+ raise ValueError(
452
+ f"hotel_to_stadium_min must be >= 0 (got {v})."
453
+ )
454
+ return v
455
+
456
+
457
+ # ---------------------------------------------------------------------------
458
+ # Layer 5: Unified result envelope
459
+ # ---------------------------------------------------------------------------
460
+
461
+
462
+ class APIResult(BaseModel):
463
+ """Unified result envelope for every external API call.
464
+
465
+ Follows the ``tool_error`` / ``tool_result`` pattern used throughout the
466
+ codebase: every call returns this shape regardless of success or failure,
467
+ so callers always have a uniform interface.
468
+ """
469
+
470
+ success: bool
471
+ """True when the API call succeeded and ``data`` is populated."""
472
+
473
+ data: Any | None
474
+ """The parsed API response payload. ``None`` on failure."""
475
+
476
+ error: str | None
477
+ """Human-readable error message. ``None`` on success."""
478
+
479
+ source: str
480
+ """Identifies the provider, e.g. ``serpapi``, ``openmeteo``, ``osm``."""
481
+
482
+ as_of: datetime
483
+ """When the request was made (server timestamp)."""
484
+
485
+ model_config = ConfigDict(
486
+ frozen=True,
487
+ extra="forbid",
488
+ # Allow arbitrary types so that ``data`` can hold any JSON-serialisable
489
+ # value without Pydantic trying to coerce it.
490
+ arbitrary_types_allowed=True,
491
+ )
matchday/prompts.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """System-prompt assembly for the Nemotron agent (the Brain).
2
+
3
+ K7 three-tier prompt (stable persona + Vancouver/FIFA context + volatile date)
4
+ carrying the N22 behavior preset (no preamble; understand -> act -> verify;
5
+ first-attempt-rarely-correct; disagree respectfully; don't stop partway) and
6
+ A3 tool-use guidance.
7
+
8
+ With native tool-calling (N13, resolved live), the tool *definitions* travel in
9
+ the OpenAI ``tools`` param (built by ``matchday.agent``); this prompt carries
10
+ the persona + behavior + decision guidance — never the tool JSON schemas.
11
+
12
+ Brain + Hands: Nemotron picks tools, reasons, explains. Python executes every
13
+ tool and scores every price. Nemotron NEVER calls an API, names a price, or
14
+ invents a flight number / weather reading — it calls a tool.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from datetime import date
19
+
20
+ # ── Stable tier: identity + behavior preset (N22) ───────────────────────────
21
+
22
+ _BEHAVIOR_PRESET = """\
23
+ You are MatchDay, an AI trip-planning agent for the 2026 FIFA World Cup in
24
+ Vancouver. You help football fans plan a complete trip — flights, hotels near BC
25
+ Place Stadium, weather, and what's around the stadium — then present 3 ranked,
26
+ scored packages.
27
+
28
+ How you work (Brain + Hands):
29
+ - You are the BRAIN. You decide which tool to call, reason about the result, and
30
+ write the final explanation. You NEVER call an API, fetch a URL, or state a
31
+ price / flight number / weather figure yourself — that is the HANDS' job
32
+ (deterministic Python). If you don't have data, call a tool. Never guess.
33
+ - Think in a tight loop: understand the request -> act with a tool -> verify the
34
+ result against what the user actually asked -> repeat or answer. Your first
35
+ attempt is rarely perfect; check the tool output before answering.
36
+ - Don't stop partway. If a tool returns partial data (e.g. hotels unavailable),
37
+ still build the best packages you can and say so honestly.
38
+ - No preamble ("Sure!", "I'd be happy to"). Disagree respectfully if a request
39
+ is impossible (e.g. a date outside the World Cup window).
40
+ - Every price, hotel, and weather reading you cite MUST come from a tool result.
41
+ Never round or alter a price; quote the tool output verbatim.
42
+ """
43
+
44
+ _FIFA_CONTEXT = """\
45
+ World Cup context:
46
+ - Tournament window: June 11 - July 19, 2026. Vancouver matches are at BC Place
47
+ Stadium (49.2827, -123.1207). Evening kickoffs default to 7:00 PM Pacific.
48
+ - Vancouver's airport is YVR; the user flies IN to YVR, their origin varies.
49
+ - Currency is CAD. Budget tiers: budget / mid_range / premium.
50
+ """
51
+
52
+ # ── A3 tool-use guidance (text; tool schemas ride in the `tools` param) ─────
53
+
54
+ _TOOL_GUIDANCE = """\
55
+ Tool-use rules:
56
+ - DEFAULT TO ACTION. If the user names a departure city or airport, a match, and
57
+ any date, call `build_trip_packages` immediately — do NOT clarify. This single
58
+ tool runs all searches in parallel and returns 3 scored packages. Only call
59
+ `clarify` if the origin OR the date is entirely absent.
60
+ - Do NOT call a tool you already called with identical arguments this turn.
61
+ - Do NOT call `web_search` for prices, flights, hotels, or weather — those come
62
+ from `build_trip_packages`. Use `web_search` only for supplemental context
63
+ (a kick-off time, a venue policy) the other tools don't provide.
64
+ - When you have the packages, write a concise comparison: name each package's
65
+ strength (Cheapest / Safest Arrival / Closest to Stadium), cite the real
66
+ prices from the tool, note any 'example' (non-live) data, and end with a
67
+ one-line recommendation. Keep it under ~150 words.
68
+ """
69
+
70
+
71
+ def build_system_prompt(today: date | None = None) -> str:
72
+ """Assemble the full system prompt (stable + context + volatile date).
73
+
74
+ Byte-stable across turns (date-only timestamp) so SGLang's prefix KV cache
75
+ survives multi-turn conversations (N28). The live conversation is appended by
76
+ the caller as user/assistant messages, not baked in here.
77
+ """
78
+ today_iso = (today or date.today()).isoformat()
79
+ return (
80
+ f"Today's date is {today_iso}. The current year is 2026.\n\n"
81
+ f"{_BEHAVIOR_PRESET}\n"
82
+ f"{_FIFA_CONTEXT}\n"
83
+ f"{_TOOL_GUIDANCE}"
84
+ )
85
+
86
+
87
+ # Hint appended on the final explanation turn (keeps Nemotron grounded in the
88
+ # tool output — never invents amenities, ratings, or prices).
89
+ EXPLANATION_HINT = (
90
+ "Compare the 3 packages for the traveler. For each: its label, the total CAD "
91
+ "price (verbatim from the tool — do not round), the flight airline + arrival "
92
+ "time, the hotel + distance to BC Place, and the match-day weather. Flag any "
93
+ "'example' (non-live) data. Never invent amenities, ratings, or prices. End "
94
+ "with a one-line recommendation."
95
+ )
matchday/publish_agent_trace.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MatchDay — publish an agent trace to the Hugging Face Hub.
3
+
4
+ Build Small "Sharing is Caring" badge: share the agent's tool-call trace
5
+ (decisions + results) on the Hub so others can learn from it.
6
+
7
+ Usage:
8
+ python publish_agent_trace.py <trace.jsonl> <repo-id> [--repo-type dataset]
9
+
10
+ Example:
11
+ python publish_agent_trace.py traces/session_001.jsonl build-small-hackathon/matchday-agent-traces
12
+
13
+ Notes:
14
+ - No model runs here — this only uploads one JSONL file. Keep it minimal.
15
+ - Reads HF_TOKEN from the environment, else from matchday/.env.
16
+ - The token value is never printed.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import os
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ from huggingface_hub import HfApi, create_repo
26
+
27
+
28
+ def _load_token() -> str | None:
29
+ """Return HF_TOKEN from env, else from matchday/.env. Never prints the value."""
30
+ tok = os.environ.get("HF_TOKEN")
31
+ if tok:
32
+ return tok
33
+ env = Path(__file__).resolve().parent / ".env"
34
+ if env.exists():
35
+ for line in env.read_text().splitlines():
36
+ line = line.strip()
37
+ if line.startswith("HF_TOKEN="):
38
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
39
+ return None
40
+
41
+
42
+ def main() -> int:
43
+ p = argparse.ArgumentParser(description="Publish a MatchDay agent trace to the HF Hub.")
44
+ p.add_argument("trace", help="path to the agent trace JSONL file")
45
+ p.add_argument("repo_id", help="HF Hub repo id, e.g. build-small-hackathon/matchday-agent-traces")
46
+ p.add_argument("--repo-type", default="dataset", choices=["dataset", "model", "space"])
47
+ p.add_argument("--private", action="store_true", help="create as private (default public)")
48
+ args = p.parse_args()
49
+
50
+ trace = Path(args.trace)
51
+ if not trace.is_file():
52
+ print(f"[publish_agent_trace] trace not found: {trace}", file=sys.stderr)
53
+ return 1
54
+
55
+ token = _load_token()
56
+ if not token:
57
+ print("[publish_agent_trace] HF_TOKEN not found in env or matchday/.env", file=sys.stderr)
58
+ return 2
59
+
60
+ api = HfApi(token=token)
61
+ create_repo(args.repo_id, repo_type=args.repo_type, private=args.private, exist_ok=True, token=token)
62
+ api.upload_file(
63
+ path_or_fileobj=str(trace),
64
+ path_in_repo=trace.name,
65
+ repo_id=args.repo_id,
66
+ repo_type=args.repo_type,
67
+ token=token,
68
+ )
69
+ url = f"https://huggingface.co/{args.repo_type}s/{args.repo_id}/blob/main/{trace.name}"
70
+ print(f"[publish_agent_trace] uploaded {trace.name} -> {url}")
71
+ return 0
72
+
73
+
74
+ if __name__ == "__main__":
75
+ raise SystemExit(main())
matchday/record_trace.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Record a MatchDay agent trace (Build Small "Sharing is Caring" badge).
2
+
3
+ Runs one agentic trip-planning turn against the deployed Nemotron (Brain) +
4
+ deterministic Python (Hands) and writes a JSONL trace of every step: the user
5
+ message, Nemotron's tool-call decision, the executed tool result (with the
6
+ scored packages + their provenance), and the final explanation. This is the
7
+ artifact others can learn from. Publish it via ``publish_agent_trace.py`` once a
8
+ write-scoped HF token is available.
9
+
10
+ Usage:
11
+ python3 -m matchday.record_trace "<query>" [out.jsonl]
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import json
17
+ import sys
18
+ from datetime import date, datetime, timezone
19
+ from pathlib import Path
20
+
21
+ from matchday.agent import MatchDayAgent
22
+ from matchday.agent_loop import BuildTripPackagesArgs
23
+ from matchday.intent import parse_intent
24
+ from matchday.models import TripRequest
25
+ from matchday.prompts import EXPLANATION_HINT
26
+ from matchday.trip_tool import build_trip_packages, format_for_nemotron
27
+
28
+
29
+ def _ts() -> str:
30
+ return datetime.now(timezone.utc).isoformat()
31
+
32
+
33
+ def _args_to_trip(a: BuildTripPackagesArgs) -> TripRequest:
34
+ return TripRequest(
35
+ origin_airport=a.origin_airport,
36
+ match_name=a.match_name or "the match",
37
+ match_date=date.fromisoformat(a.match_date),
38
+ check_in=date.fromisoformat(a.check_in),
39
+ check_out=date.fromisoformat(a.check_out),
40
+ travelers=a.travelers,
41
+ budget_tier=a.budget_tier,
42
+ )
43
+
44
+
45
+ def _packages_view(result) -> list[dict]:
46
+ return [
47
+ {
48
+ "label": p.label,
49
+ "total_cost_cad": p.total_cost_cad,
50
+ "flight": f"{p.flight.airline} {p.flight.flight_number} (lands {p.flight.arrival_time:%H:%M})",
51
+ "hotel": p.hotel.name if p.hotel else None,
52
+ "provenance": {
53
+ "flight": p.flight.source,
54
+ "hotel": p.hotel.source if p.hotel else None,
55
+ },
56
+ }
57
+ for p in result.packages
58
+ ]
59
+
60
+
61
+ async def record(query: str, out_path: str) -> None:
62
+ agent = MatchDayAgent()
63
+ trace: list[dict] = []
64
+ log = lambda **e: trace.append({"ts": _ts(), **e}) # noqa: E731
65
+
66
+ log(event="user", content=query)
67
+ base = [{"role": "user", "content": query}]
68
+ r1 = await agent.run(base)
69
+ tool_calls = r1.get("tool_calls") or []
70
+ log(
71
+ event="nemotron_decision",
72
+ tool_calls=tool_calls,
73
+ text=(r1.get("text") or "")[:240],
74
+ )
75
+
76
+ trip: TripRequest | None = None
77
+ if tool_calls and tool_calls[0].get("name") == "build_trip_packages":
78
+ try:
79
+ trip = _args_to_trip(BuildTripPackagesArgs.model_validate(tool_calls[0].get("arguments", {})))
80
+ except Exception:
81
+ trip = None
82
+ if trip is None:
83
+ parsed = parse_intent(query)
84
+ if parsed.trip_request is not None:
85
+ trip = parsed.trip_request
86
+ log(event="deterministic_fallback", trip=parsed.trip_request.summary())
87
+ if trip is None:
88
+ log(event="clarify", question=parse_intent(query).question)
89
+ else:
90
+ result = await build_trip_packages(trip)
91
+ log(
92
+ event="tool_result",
93
+ tool="build_trip_packages",
94
+ trip=trip.summary(),
95
+ status=result.status,
96
+ degradation=result.degradation_notices,
97
+ packages=_packages_view(result),
98
+ compact=format_for_nemotron(result),
99
+ )
100
+ convo = [
101
+ base[0],
102
+ {
103
+ "role": "assistant",
104
+ "content": "",
105
+ "tool_calls": [{
106
+ "id": "call_build",
107
+ "type": "function",
108
+ "function": {"name": "build_trip_packages", "arguments": json.dumps(trip.model_dump(mode="json"))},
109
+ }],
110
+ },
111
+ {"role": "tool", "tool_call_id": "call_build", "name": "build_trip_packages", "content": format_for_nemotron(result)},
112
+ {"role": "user", "content": EXPLANATION_HINT},
113
+ ]
114
+ r2 = await agent.run(convo, tools=[])
115
+ log(event="nemotron_explanation", text=r2.get("text") or "")
116
+
117
+ out = Path(out_path)
118
+ out.parent.mkdir(parents=True, exist_ok=True)
119
+ with out.open("w") as fh:
120
+ for event in trace:
121
+ fh.write(json.dumps(event, default=str) + "\n")
122
+ print(f"trace written: {out} ({len(trace)} events)")
123
+
124
+
125
+ if __name__ == "__main__":
126
+ q = sys.argv[1] if len(sys.argv) > 1 else (
127
+ "Flying from Montreal, want Canada vs Qatar, mid-range, June 26-29, just me"
128
+ )
129
+ o = sys.argv[2] if len(sys.argv) > 2 else "matchday/traces/session_001.jsonl"
130
+ asyncio.run(record(q, o))
matchday/render.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTML rendering for MatchDay — Layla-competitive cards + Leaflet map.
2
+
3
+ Pure functions: a ``TripPackageResult`` -> HTML strings for ``gr.HTML`` panels.
4
+ Every price / hotel / weather reading carries a provenance badge (I4) so judges
5
+ can verify nothing is hallucinated: "● live" (serpapi/openmeteo/osm) vs
6
+ "example data" (fallback). The look is custom (Off-Brand spirit), not the
7
+ default Gradio chrome.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from html import escape
12
+ from typing import Any
13
+
14
+ from matchday.models import ScoredPackage
15
+ from matchday.trip_tool import TripPackageResult
16
+
17
+ BC_PLACE_LAT = 49.2827
18
+ BC_PLACE_LON = -123.1207
19
+
20
+ _LABEL_COLOR = {
21
+ "Cheapest": "#16a34a",
22
+ "Safest Arrival": "#2563eb",
23
+ "Closest to Stadium": "#9333ea",
24
+ }
25
+
26
+ # WMO weather code -> emoji (subset; unknown -> 🌡️).
27
+ _WMO_ICON: dict[int, str] = {
28
+ 0: "☀️", 1: "🌤️", 2: "⛅", 3: "☁️", 45: "🌫️", 48: "🌫️",
29
+ 51: "🌦️", 53: "🌦️", 55: "🌦️", 61: "🌧️", 63: "🌧️", 65: "🌧️",
30
+ 71: "🌨️", 73: "🌨️", 75: "🌨️", 80: "🌧️", 81: "🌧️", 82: "🌧️",
31
+ 95: "⛈️", 96: "⛈️", 99: "⛈️",
32
+ }
33
+
34
+
35
+ _CSS = """
36
+ <style>
37
+ .md-wrap{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#0f172a;}
38
+ .md-status{padding:10px 14px;border-radius:10px;margin:8px 0;font-weight:600;}
39
+ .md-status.complete{background:#dcfce7;color:#166534;}
40
+ .md-status.partial{background:#fef9c3;color:#854d0e;}
41
+ .md-status.failed{background:#fee2e2;color:#991b1b;}
42
+ .md-cards{display:flex;gap:14px;flex-wrap:wrap;margin:10px 0;}
43
+ .md-card{flex:1 1 300px;min-width:280px;border:1px solid #e2e8f0;border-radius:14px;overflow:hidden;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.08);}
44
+ .md-card-head{padding:12px 14px;color:#fff;}
45
+ .md-card-head .lbl{font-size:13px;text-transform:uppercase;letter-spacing:.06em;opacity:.9;}
46
+ .md-card-head .price{font-size:26px;font-weight:800;margin-top:2px;}
47
+ .md-card-body{padding:12px 14px;display:grid;gap:8px;}
48
+ .md-row{font-size:13px;line-height:1.35;}
49
+ .md-row b{color:#334155;}
50
+ .md-badge{display:inline-block;font-size:10px;font-weight:700;padding:1px 6px;border-radius:999px;text-transform:uppercase;letter-spacing:.04em;vertical-align:middle;margin-left:4px;}
51
+ .md-live{background:#dcfce7;color:#166534;}
52
+ .md-example{background:#fef3c7;color:#92400e;}
53
+ .md-sub{color:#64748b;font-size:11px;}
54
+ #matchday-map{height:380px;border-radius:12px;border:1px solid #e2e8f0;margin-top:10px;}
55
+ </style>
56
+ """
57
+
58
+
59
+ def _e(x: Any) -> str:
60
+ return escape(str(x)) if x is not None else ""
61
+
62
+
63
+ def _prov_badge(source: str | None) -> str:
64
+ s = (source or "").lower()
65
+ if s == "fallback":
66
+ return '<span class="md-badge md-example">example</span>'
67
+ if s in ("serpapi", "openmeteo", "osm"):
68
+ return '<span class="md-badge md-live">● live</span>'
69
+ return f'<span class="md-badge">{_e(source)}</span>'
70
+
71
+
72
+ def _weather_line(pkg: ScoredPackage) -> str:
73
+ if not pkg.weather:
74
+ return '<div class="md-row">🌤️ <b>Weather:</b> forecast unavailable</div>'
75
+ w = pkg.weather[0]
76
+ icon = _WMO_ICON.get(w.weather_code, "🌡️")
77
+ return (
78
+ f'<div class="md-row">{icon} <b>Match-day weather:</b> {_e(w.date)} · '
79
+ f'{w.temp_min_c:g}–{w.temp_max_c:g}°C · {w.precipitation_probability:g}% precip '
80
+ f'{_prov_badge(w.source)}</div>'
81
+ )
82
+
83
+
84
+ def render_card(pkg: ScoredPackage) -> str:
85
+ color = _LABEL_COLOR.get(pkg.label, "#475569")
86
+ f = pkg.flight
87
+ h = pkg.hotel
88
+ hotel_html = (
89
+ f'<div class="md-row">🏨 <b>{_e(h.name)}</b> · '
90
+ f"{(h.total_price_cad is not None) and ('$'+format(round(h.total_price_cad), ',')) or 'price n/a'} total · "
91
+ f'{(h.rating is not None) and (f"{h.rating:g}★") or ""} · '
92
+ f'{(h.distance_to_stadium_km is not None) and (f"{h.distance_to_stadium_km:g} km to BC Place") or ""} '
93
+ f"{_prov_badge(h.source)}</div>"
94
+ ) if h else '<div class="md-row">🏨 <b>No hotel found</b> — flight-only package</div>'
95
+
96
+ buffer = pkg.arrival_buffer_hours
97
+ buf_txt = f"+{buffer:g}h before kickoff" if buffer >= 0 else f"{buffer:g}h (arrives after kickoff!)"
98
+
99
+ return f"""
100
+ <div class="md-card">
101
+ <div class="md-card-head" style="background:{color}">
102
+ <div class="lbl">{_e(pkg.label)}</div>
103
+ <div class="price">${pkg.total_cost_cad:,.0f} <span style="font-size:13px;font-weight:500;opacity:.85">CAD total</span></div>
104
+ </div>
105
+ <div class="md-card-body">
106
+ <div class="md-row">✈️ <b>{_e(f.airline)} {_e(f.flight_number)}</b> · {_e(f.origin)}→{_e(f.destination)} ·
107
+ lands {f.arrival_time.strftime('%H:%M')} {_prov_badge(f.source)}</div>
108
+ {hotel_html}
109
+ {_weather_line(pkg)}
110
+ <div class="md-row">⏱️ <b>Arrival buffer:</b> {buf_txt}</div>
111
+ <div class="md-row">🚶 <b>Hotel→stadium:</b> {pkg.hotel_to_stadium_min} min walk · {len(pkg.amenities)} nearby spots</div>
112
+ </div>
113
+ </div>
114
+ """
115
+
116
+
117
+ def render_cards(result: TripPackageResult) -> str:
118
+ if not result.packages:
119
+ return '<div class="md-row">No packages could be formed from the available data.</div>'
120
+ cards = "".join(render_card(p) for p in result.packages)
121
+ return f'{_CSS}<div class="md-wrap"><div class="md-cards">{cards}</div></div>'
122
+
123
+
124
+ def render_status_bar(result: TripPackageResult) -> str:
125
+ cls = result.status
126
+ notices = " · ".join(result.degradation_notices) if result.degradation_notices else "all systems go"
127
+ return (
128
+ f'{_CSS}<div class="md-wrap"><div class="md-status {cls}">'
129
+ f"{result.status.upper()} · {len(result.packages)} package(s) · {notices}"
130
+ f"</div></div>"
131
+ )
132
+
133
+
134
+ def _js_markers(result: TripPackageResult) -> str:
135
+ """Build Leaflet JS adding stadium + hotel + POI markers + hotel→stadium lines."""
136
+ lines: list[str] = []
137
+ # BC Place stadium
138
+ lines.append(
139
+ f"L.marker([{BC_PLACE_LAT},{BC_PLACE_LON}],{{icon:stadiumIcon}})"
140
+ f".addTo(map).bindPopup('<b>BC Place Stadium</b><br>Match venue');"
141
+ )
142
+ seen: set[tuple[float, float]] = set()
143
+ for p in result.packages:
144
+ h = p.hotel
145
+ if h and h.latitude and h.longitude and (h.latitude, h.longitude) not in seen:
146
+ seen.add((h.latitude, h.longitude))
147
+ name = escape(h.name)
148
+ lines.append(
149
+ f"L.marker([{h.latitude},{h.longitude}],{{icon:hotelIcon}})"
150
+ f".addTo(map).bindPopup('<b>🏨 {name}</b><br>{_e(p.label)}');"
151
+ )
152
+ lines.append(
153
+ f"L.polyline([[{h.latitude},{h.longitude}],[{BC_PLACE_LAT},{BC_PLACE_LON}]],"
154
+ f"{{color:'#2563eb',dashArray:'5,7',weight:2}}).addTo(map);"
155
+ )
156
+ # POIs (from first package that has them)
157
+ for p in result.packages:
158
+ for a in (p.amenities or [])[:15]:
159
+ if a.latitude and a.longitude and (a.latitude, a.longitude) not in seen:
160
+ seen.add((a.latitude, a.longitude))
161
+ lines.append(
162
+ f"L.marker([{a.latitude},{a.longitude}],{{icon:poiIcon}})"
163
+ f".addTo(map).bindPopup('{escape(a.category)} · {escape(a.name)}');"
164
+ )
165
+ if p.amenities:
166
+ break
167
+ return "\n".join(lines)
168
+
169
+
170
+ def render_map(result: TripPackageResult) -> str:
171
+ return f"""
172
+ {_CSS}
173
+ <div class="md-wrap">
174
+ <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
175
+ <div id="matchday-map"></div>
176
+ <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
177
+ <script>
178
+ (function(){{
179
+ var el=document.getElementById('matchday-map');
180
+ if(!el||window.L===undefined){{return;}}
181
+ if(el._lmap){{el._lmap.remove();}}
182
+ var map=L.map('matchday-map').setView([{BC_PLACE_LAT},{BC_PLACE_LON}],15);
183
+ el._lmap=map;
184
+ L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png',{{maxZoom:19,attribution:'© OpenStreetMap'}}).addTo(map);
185
+ var stadiumIcon=L.divIcon({{html:'🏟️',iconSize:[26,26],iconAnchor:[13,13]}});
186
+ var hotelIcon=L.divIcon({{html:'🏨',iconSize:[22,22],iconAnchor:[11,11]}});
187
+ var poiIcon=L.divIcon({{html:'📍',iconSize:[16,16],iconAnchor:[8,8]}});
188
+ {_js_markers(result)}
189
+ }})();
190
+ </script>
191
+ </div>
192
+ """
193
+
194
+
195
+ def render_full(result: TripPackageResult) -> str:
196
+ return render_status_bar(result) + render_cards(result) + render_map(result)
matchday/scoring.py ADDED
@@ -0,0 +1,608 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MatchDay -- Deterministic scoring engine for travel package ranking.
3
+
4
+ Based on the blueprint scoring section (IMPLEMENTATION_PLAN.md), lessons from
5
+ errors.py (FailoverReason taxonomy), and models.py (Pydantic domain models).
6
+
7
+ Architecture:
8
+ - ScoringWeights: frozen dataclass holding cost/buffer/transit weight triples
9
+ (inspired by BudgetConfig pattern from reference codebases).
10
+ - BUDGET_WEIGHTS: tiered weight profiles (budget, mid_range, premium).
11
+ - min_max_scale: 0-1 normalizer with epsilon guard against zero-variance.
12
+ - filter_late_arrivals: removes flights whose arrival is after kickoff.
13
+ - score_options: the main entry point -- cartesian product of flights x hotels,
14
+ normalizes, applies weights, deduplicates, returns top 3 ScoredPackage.
15
+ - Dedup key: (flight_number, departure_time.date(), origin, hotel_name,
16
+ hotel_address) per blueprint audit finding H3.
17
+
18
+ Edge cases handled:
19
+ - Empty flights list => return []
20
+ - Empty hotels list => score flights only with hotel=None
21
+ - All flights arrive after kickoff => return [] (scoring_all_late)
22
+ - All prices are None => return [] (scoring_missing_prices)
23
+ - Single or double distinct options => return what is available
24
+ - Zero-variance cost/buffer/transit => neutral score (0.5) via epsilon
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass
30
+ from datetime import date, datetime, timezone
31
+ from typing import Literal
32
+ from zoneinfo import ZoneInfo
33
+
34
+ from matchday.models import Flight, Hotel, ScoredPackage, Weather
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # ScoringWeights -- frozen weight triples (BudgetConfig pattern)
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class ScoringWeights:
44
+ """Dimension weights for the composite scoring formula.
45
+
46
+ Every weight is a float in [0.0, 1.0]. The three weights do not
47
+ strictly need to sum to 1.0, but they do in every defined profile for
48
+ intuitive interpretability.
49
+
50
+ Attributes
51
+ ----------
52
+ cost:
53
+ Weight for the normalised cost dimension (higher = more important).
54
+ Invert=true is applied before weighting: *lower* costs score higher.
55
+ buffer:
56
+ Weight for the arrival buffer dimension (hours before kickoff).
57
+ Higher buffers score higher.
58
+ transit:
59
+ Weight for the hotel-to-stadium transit dimension (minutes).
60
+ Invert=true is applied before weighting: *shorter* transits score higher.
61
+ """
62
+
63
+ cost: float
64
+ buffer: float
65
+ transit: float
66
+
67
+
68
+ BUDGET_WEIGHTS: dict[str, ScoringWeights] = {
69
+ "budget": ScoringWeights(cost=0.50, buffer=0.30, transit=0.20),
70
+ "mid_range": ScoringWeights(cost=0.35, buffer=0.30, transit=0.35),
71
+ "premium": ScoringWeights(cost=0.20, buffer=0.35, transit=0.45),
72
+ }
73
+ """Tiered weight profiles keyed by budget_tier string.
74
+
75
+ - ``budget``: Cost matters most (50 %), transit least (20 %).
76
+ - ``mid_range``: Balanced, with a slight lean toward cost.
77
+ - ``premium``: Comfort (buffer + transit) matters more than cost.
78
+ """
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Normalization helper
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ def min_max_scale(values: list[float], invert: bool = False) -> list[float]:
87
+ """Normalise a list of floats to [0.0, 1.0] via min-max scaling.
88
+
89
+ When ``invert=True``, the result is flipped so that the *smallest* input
90
+ value maps to 1.0 and the *largest* maps to 0.0. Use this for dimensions
91
+ where lower is better (cost, transit time).
92
+
93
+ The ``epsilon = 1e-6`` guard prevents division by zero when all values
94
+ are identical. In that degenerate case, every element receives 0.5,
95
+ representing a neutral score (neither better nor worse than peers).
96
+
97
+ Parameters
98
+ ----------
99
+ values:
100
+ The raw dimension values to normalise. Must be non-empty.
101
+ invert:
102
+ When True, smaller raw values produce larger normalised scores.
103
+
104
+ Returns
105
+ -------
106
+ list[float]
107
+ Normalised scores in [0.0, 1.0], one per input value.
108
+
109
+ Examples
110
+ --------
111
+ >>> min_max_scale([100.0, 200.0, 300.0])
112
+ [0.0, 0.5, 1.0]
113
+ >>> min_max_scale([100.0, 200.0, 300.0], invert=True)
114
+ [1.0, 0.5, 0.0]
115
+ >>> min_max_scale([5.0, 5.0, 5.0])
116
+ [0.5, 0.5, 0.5] # epsilon guard
117
+ """
118
+ if not values:
119
+ return []
120
+
121
+ epsilon = 1e-6
122
+ v_min = min(values)
123
+ v_max = max(values)
124
+ span = v_max - v_min
125
+
126
+ # All values are identical (within epsilon): return neutral score.
127
+ if span < epsilon:
128
+ return [0.5] * len(values)
129
+
130
+ scaled = [(v - v_min) / span for v in values]
131
+ return [1.0 - s if invert else s for s in scaled]
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Late arrival filter
136
+ # ---------------------------------------------------------------------------
137
+
138
+
139
+ # Vancouver time zone — BC Place matches kick off in Pacific Time. ZoneInfo is
140
+ # DST-aware, so the kickoff instant is correct on both PDT (UTC-7) and PST dates.
141
+ _VANCOUVER = ZoneInfo("America/Vancouver")
142
+ _DEFAULT_KICKOFF_HOUR = 19 # 7:00 PM local
143
+
144
+
145
+ def _to_vancouver_aware(dt: datetime) -> datetime:
146
+ """Return ``dt`` as a Vancouver-timezone-aware datetime.
147
+
148
+ Naive datetimes (e.g. SerpApi local times that carry no tz info) are
149
+ treated as Vancouver-local. This keeps comparisons/subtractions against the
150
+ aware kickoff from raising ``TypeError`` (aware vs naive) and keeps the
151
+ timezone consistent across the scoring math.
152
+ """
153
+ if dt.tzinfo is None:
154
+ return dt.replace(tzinfo=_VANCOUVER)
155
+ return dt
156
+
157
+
158
+ def _default_kickoff_dt(match_date: date) -> datetime:
159
+ """Default kickoff as 7:00 PM Pacific Time on ``match_date``.
160
+
161
+ World Cup matches at BC Place are typically evening local time. The result
162
+ is DST-aware (correct PDT/PST offset for the date), fixing the prior bug
163
+ where kickoff was built as 19:00 UTC (12:00 PDT) — 7 hours too early.
164
+ """
165
+ return datetime(
166
+ match_date.year,
167
+ match_date.month,
168
+ match_date.day,
169
+ _DEFAULT_KICKOFF_HOUR,
170
+ 0,
171
+ 0,
172
+ tzinfo=_VANCOUVER,
173
+ )
174
+
175
+
176
+ def filter_late_arrivals(
177
+ flights: list[Flight], kickoff: datetime
178
+ ) -> list[Flight]:
179
+ """Return only flights whose arrival is strictly before kickoff.
180
+
181
+ Flights arriving exactly at or after ``kickoff`` are excluded because
182
+ the traveler cannot reach BC Place before the match starts.
183
+
184
+ Parameters
185
+ ----------
186
+ flights:
187
+ Candidate flights to filter.
188
+ kickoff:
189
+ Match kickoff datetime (timezone-aware). Flights with arrival times
190
+ prior to this (strictly less) are kept.
191
+
192
+ Returns
193
+ -------
194
+ list[Flight]
195
+ Flights that arrive before kickoff. May be empty.
196
+ """
197
+ return [f for f in flights if _to_vancouver_aware(f.arrival_time) < kickoff]
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Deduplication
202
+ # ---------------------------------------------------------------------------
203
+
204
+
205
+ def _dedup_key(flight: Flight, hotel: Hotel | None) -> tuple:
206
+ """Compute a stable deduplication key for a flight+hotel combination.
207
+
208
+ Per blueprint audit finding H3, the dedup key is:
209
+ (flight_number, departure_time.date(), origin, hotel_name, hotel_address)
210
+
211
+ When ``hotel`` is None (no hotels available), the hotel_name and
212
+ hotel_address are both replaced with ``"__NONE__"`` so that flight-only
213
+ options are still deduplicated among themselves.
214
+
215
+ Parameters
216
+ ----------
217
+ flight:
218
+ The flight in this combination.
219
+ hotel:
220
+ The hotel in this combination, or None.
221
+
222
+ Returns
223
+ -------
224
+ tuple
225
+ A hashable dedup key.
226
+ """
227
+ return (
228
+ flight.flight_number,
229
+ flight.departure_time.date(),
230
+ flight.origin,
231
+ hotel.name if hotel is not None else "__NONE__",
232
+ # Use hotel name as proxy for address since Hotel model has no
233
+ # explicit address field. Coordinates would be more precise but
234
+ # are impractical as a dedup key component.
235
+ hotel.name if hotel is not None else "__NONE__",
236
+ )
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # Main scoring function
241
+ # ---------------------------------------------------------------------------
242
+
243
+
244
+ def score_options(
245
+ flights: list[Flight],
246
+ hotels: list[Hotel],
247
+ weather: list[Weather],
248
+ match_date: date,
249
+ budget_tier: str = "mid_range",
250
+ ) -> list[ScoredPackage]:
251
+ """Score and rank travel packages from candidate flights and hotels.
252
+
253
+ This is the main entry point for the scoring engine. It performs:
254
+
255
+ 1. Filter late arrivals (flights arriving after 7 PM PT kickoff).
256
+ 2. Cartesian product of every (flight, hotel) combination.
257
+ 3. Compute ``total_cost_cad``, ``arrival_buffer_hours``, and
258
+ ``hotel_to_stadium_min`` for each combination.
259
+ 4. Skip combinations where cost data is missing (None sentinel).
260
+ 5. Min-max normalise each dimension across all valid combinations.
261
+ 6. Apply budget-adjusted weights to produce a composite score.
262
+ 7. Sort by composite score descending.
263
+ 8. Select top 3 distinct combinations (by dedup key).
264
+ 9. Label each selected package: cheapest by cost, safest by buffer,
265
+ closest by transit.
266
+
267
+ Edge cases (all produce deterministic results, never raise):
268
+
269
+ - ``flights`` is empty -> return []
270
+ - ``hotels`` is empty -> score flights only, label hotels as "unavailable"
271
+ - All flights arrive after kickoff -> return [] (reason: scoring_all_late)
272
+ - All prices are None -> return [] (reason: scoring_missing_prices)
273
+ - Only 1-2 distinct dedup options -> return what is available
274
+
275
+ Parameters
276
+ ----------
277
+ flights:
278
+ All candidate flights from the SerpApi normalizer.
279
+ hotels:
280
+ All candidate hotels from the SerpApi normalizer. May be empty.
281
+ weather:
282
+ Weather forecasts for dates overlapping the trip. Used as display
283
+ data only -- not incorporated into the composite score (per audit
284
+ finding H4).
285
+ match_date:
286
+ The date of the match. Used to compute the default kickoff time.
287
+ budget_tier:
288
+ One of ``"budget"``, ``"mid_range"``, or ``"premium"``. Selects
289
+ the weight profile from ``BUDGET_WEIGHTS``. Defaults to
290
+ ``"mid_range"``.
291
+
292
+ Returns
293
+ -------
294
+ list[ScoredPackage]
295
+ Ranked packages, at most 3, ordered by composite score descending.
296
+ Empty list when no valid combinations exist.
297
+ """
298
+ # -- Resolve weights ----------------------------------------------------
299
+ weights = BUDGET_WEIGHTS.get(
300
+ budget_tier,
301
+ BUDGET_WEIGHTS["mid_range"], # safe fallback
302
+ )
303
+
304
+ # -- Edge case: no flights ----------------------------------------------
305
+ if not flights:
306
+ return []
307
+
308
+ # -- Filter late arrivals -----------------------------------------------
309
+ kickoff = _default_kickoff_dt(match_date)
310
+ on_time_flights = filter_late_arrivals(flights, kickoff)
311
+
312
+ # -- Edge case: all flights are late ------------------------------------
313
+ if not on_time_flights:
314
+ return []
315
+
316
+ # -- Build all valid (flight, hotel) combinations -----------------------
317
+ # When hotels is empty, treat it as [None] so we create flight-only combos.
318
+ hotel_list: list[Hotel | None] = list(hotels) if hotels else [None]
319
+
320
+ scored: list[_ScoredCandidate] = []
321
+
322
+ for flight in on_time_flights:
323
+ for hotel in hotel_list:
324
+ candidate = _score_single_option(
325
+ flight=flight,
326
+ hotel=hotel,
327
+ kickoff=kickoff,
328
+ )
329
+ if candidate is not None:
330
+ scored.append(candidate)
331
+
332
+ # -- Edge case: all prices are None -------------------------------------
333
+ if not scored:
334
+ return []
335
+
336
+ # -- Normalise each dimension -------------------------------------------
337
+ costs = [c.total_cost_cad for c in scored]
338
+ buffers = [c.arrival_buffer_hours for c in scored]
339
+ transits = [c.hotel_to_stadium_min for c in scored]
340
+
341
+ norm_costs = min_max_scale(costs, invert=True) # lower cost = higher score
342
+ norm_buffers = min_max_scale(buffers) # higher buffer = higher score
343
+ norm_transits = min_max_scale(transits, invert=True) # shorter transit = higher score
344
+
345
+ # -- Compute composite score --------------------------------------------
346
+ for i, candidate in enumerate(scored):
347
+ candidate.composite = (
348
+ weights.cost * norm_costs[i]
349
+ + weights.buffer * norm_buffers[i]
350
+ + weights.transit * norm_transits[i]
351
+ )
352
+
353
+ # -- Sort by composite descending ---------------------------------------
354
+ scored.sort(key=lambda c: c.composite, reverse=True)
355
+
356
+ # -- Deduplicate, keeping highest-scoring per key -----------------------
357
+ seen: set[tuple] = set()
358
+ distinct: list[_ScoredCandidate] = []
359
+ for candidate in scored:
360
+ key = _dedup_key(candidate.flight, candidate.hotel)
361
+ if key not in seen:
362
+ seen.add(key)
363
+ distinct.append(candidate)
364
+
365
+ # -- Select top 3 -------------------------------------------------------
366
+ top = distinct[:3]
367
+
368
+ # -- Label winners ------------------------------------------------------
369
+ _label_top_three(top)
370
+
371
+ # -- Build ScoredPackage models -----------------------------------------
372
+ now = datetime.now(timezone.utc)
373
+ packages: list[ScoredPackage] = []
374
+
375
+ for candidate in top:
376
+ # Compute per-dimension scores for the scores dict
377
+ dim_scores = {
378
+ "affordability": norm_costs[scored.index(candidate)]
379
+ if candidate in scored
380
+ else 0.5,
381
+ "arrival_buffer": norm_buffers[scored.index(candidate)]
382
+ if candidate in scored
383
+ else 0.5,
384
+ "proximity": norm_transits[scored.index(candidate)]
385
+ if candidate in scored
386
+ else 0.5,
387
+ "composite": candidate.composite,
388
+ }
389
+
390
+ package = ScoredPackage(
391
+ label=candidate.label,
392
+ flight=candidate.flight,
393
+ hotel=candidate.hotel,
394
+ weather=weather,
395
+ amenities=[], # amenities are populated by the caller
396
+ total_cost_cad=candidate.total_cost_cad,
397
+ arrival_buffer_hours=candidate.arrival_buffer_hours,
398
+ hotel_to_stadium_min=candidate.hotel_to_stadium_min,
399
+ scores=dim_scores,
400
+ explanation="", # Explanation is filled by Nemotron after scoring
401
+ generated_at=now,
402
+ )
403
+ packages.append(package)
404
+
405
+ return packages
406
+
407
+
408
+ # ---------------------------------------------------------------------------
409
+ # Internal helpers
410
+ # ---------------------------------------------------------------------------
411
+
412
+
413
+ class _ScoredCandidate:
414
+ """Internal mutable candidate for scoring before ScoredPackage assembly.
415
+
416
+ This is a simple attribute holder (not a frozen dataclass) because the
417
+ composite score is computed in two phases: first the raw dimensions are
418
+ calculated, then after normalisation the composite is set.
419
+ """
420
+
421
+ __slots__ = (
422
+ "flight",
423
+ "hotel",
424
+ "total_cost_cad",
425
+ "arrival_buffer_hours",
426
+ "hotel_to_stadium_min",
427
+ "composite",
428
+ "label",
429
+ )
430
+
431
+ def __init__(
432
+ self,
433
+ flight: Flight,
434
+ hotel: Hotel | None,
435
+ total_cost_cad: float,
436
+ arrival_buffer_hours: float,
437
+ hotel_to_stadium_min: int,
438
+ ) -> None:
439
+ self.flight = flight
440
+ self.hotel = hotel
441
+ self.total_cost_cad = total_cost_cad
442
+ self.arrival_buffer_hours = arrival_buffer_hours
443
+ self.hotel_to_stadium_min = hotel_to_stadium_min
444
+ self.composite = 0.0
445
+ self.label: Literal["Cheapest", "Safest Arrival", "Closest to Stadium", ""] = ""
446
+
447
+
448
+ def _score_single_option(
449
+ flight: Flight,
450
+ hotel: Hotel | None,
451
+ kickoff: datetime,
452
+ ) -> _ScoredCandidate | None:
453
+ """Compute raw dimensions for one (flight, hotel) combination.
454
+
455
+ Returns ``None`` when the combination is invalid (missing price data).
456
+
457
+ Parameters
458
+ ----------
459
+ flight:
460
+ The flight in this combination.
461
+ hotel:
462
+ The hotel in this combination, or None for flight-only.
463
+ kickoff:
464
+ Match kickoff datetime.
465
+
466
+ Returns
467
+ -------
468
+ _ScoredCandidate | None
469
+ The candidate with raw dimensions, or None if prices are missing.
470
+ """
471
+ # -- Total cost ---------------------------------------------------------
472
+ # Flights with unavailable prices cannot be scored.
473
+ if flight.price_cad is None:
474
+ return None
475
+
476
+ total_cost = flight.price_cad
477
+
478
+ # Add hotel cost when hotel is available and has a total price.
479
+ if hotel is not None:
480
+ if hotel.total_price_cad is not None:
481
+ total_cost += hotel.total_price_cad
482
+ elif hotel.price_per_night_cad is not None:
483
+ # Approximate: total = price_per_night * nights
484
+ # Nights can't be derived without check-in/check-out from the
485
+ # caller; this path uses price_per_night as a fallback.
486
+ # The calling code should pass explicit total_price_cad when
487
+ # available.
488
+ total_cost += hotel.price_per_night_cad
489
+ else:
490
+ # Hotel has no price data at all -- score it with flight cost only.
491
+ # This is a partial result; the frontend will display "price
492
+ # unavailable" for the hotel.
493
+ pass
494
+
495
+ # -- Arrival buffer -----------------------------------------------------
496
+ # kickoff and flight.arrival_time are both timezone-aware (or naive but
497
+ # consistent). Compute the difference in hours.
498
+ buffer_seconds = (kickoff - _to_vancouver_aware(flight.arrival_time)).total_seconds()
499
+ buffer_hours = buffer_seconds / 3600.0
500
+
501
+ # -- Hotel-to-stadium transit -------------------------------------------
502
+ if hotel is not None and hotel.distance_to_stadium_km is not None:
503
+ # Convert distance to an estimated walking time at 5 km/h average pace.
504
+ # This is a rough proxy; the ORS API provides actual walking routes.
505
+ transit_min = int(round((hotel.distance_to_stadium_km / 5.0) * 60))
506
+ elif hotel is not None and hotel.latitude is not None and hotel.longitude is not None:
507
+ # Hotel has coordinates but no pre-computed distance. Use a small
508
+ # default rather than failing.
509
+ transit_min = 30
510
+ else:
511
+ transit_min = 0
512
+
513
+ return _ScoredCandidate(
514
+ flight=flight,
515
+ hotel=hotel,
516
+ total_cost_cad=round(total_cost, 2),
517
+ arrival_buffer_hours=round(buffer_hours, 2),
518
+ hotel_to_stadium_min=transit_min,
519
+ )
520
+
521
+
522
+ def _label_top_three(
523
+ candidates: list[_ScoredCandidate],
524
+ ) -> None:
525
+ """Assign labels to the top scored candidates.
526
+
527
+ Labels are assigned greedily by finding the best-in-class for each
528
+ objective:
529
+
530
+ - ``Cheapest``: The candidate with the lowest ``total_cost_cad``.
531
+ - ``Safest Arrival``: The candidate with the highest
532
+ ``arrival_buffer_hours``.
533
+ - ``Closest to Stadium``: The candidate with the lowest
534
+ ``hotel_to_stadium_min``.
535
+
536
+ A single candidate can win only one label. If fewer than 3 candidates
537
+ exist, only the available labels are assigned.
538
+
539
+ Parameters
540
+ ----------
541
+ candidates:
542
+ The top scored candidates (at most 3), pre-sorted by composite
543
+ descending. Labels are assigned in-place.
544
+ """
545
+ if not candidates:
546
+ return
547
+
548
+ # Available labels to assign
549
+ labels: list[Literal["Cheapest", "Safest Arrival", "Closest to Stadium"]] = [
550
+ "Cheapest",
551
+ "Safest Arrival",
552
+ "Closest to Stadium",
553
+ ]
554
+
555
+ # Track which candidates have already received a label
556
+ assigned_indices: set[int] = set()
557
+
558
+ for label in labels:
559
+ if len(assigned_indices) >= len(candidates):
560
+ break
561
+
562
+ best_idx: int | None = None
563
+
564
+ if label == "Cheapest":
565
+ # Pick the candidate with the lowest cost that hasn't been labelled
566
+ best_val = float("inf")
567
+ for i, c in enumerate(candidates):
568
+ if i in assigned_indices:
569
+ continue
570
+ if c.total_cost_cad < best_val:
571
+ best_val = c.total_cost_cad
572
+ best_idx = i
573
+ elif label == "Safest Arrival":
574
+ # Pick the candidate with the highest buffer
575
+ best_val = float("-inf")
576
+ for i, c in enumerate(candidates):
577
+ if i in assigned_indices:
578
+ continue
579
+ if c.arrival_buffer_hours > best_val:
580
+ best_val = c.arrival_buffer_hours
581
+ best_idx = i
582
+ elif label == "Closest to Stadium":
583
+ # Pick the candidate with the lowest transit time
584
+ best_val = float("inf")
585
+ for i, c in enumerate(candidates):
586
+ if i in assigned_indices:
587
+ continue
588
+ if c.hotel_to_stadium_min < best_val:
589
+ best_val = c.hotel_to_stadium_min
590
+ best_idx = i
591
+
592
+ if best_idx is not None:
593
+ candidates[best_idx].label = label
594
+ assigned_indices.add(best_idx)
595
+
596
+ # Safety net for any still-unlabelled candidate. Not reachable when
597
+ # len(candidates) <= 3 (the greedy loop above assigns one distinct label
598
+ # per candidate), but assign from the UNUSED labels rather than by raw
599
+ # index so duplicate labels are impossible even if this ever fires.
600
+ used = {c.label for c in candidates if c.label}
601
+ remaining = [
602
+ lab
603
+ for lab in ("Cheapest", "Safest Arrival", "Closest to Stadium")
604
+ if lab not in used
605
+ ]
606
+ for c in candidates:
607
+ if c.label == "" and remaining:
608
+ c.label = remaining.pop(0)
matchday/traces/session_001.jsonl ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {"ts": "2026-06-13T21:09:55.888255+00:00", "event": "user", "content": "Flying from Montreal, want Canada vs Qatar, mid-range, June 26-29, just me"}
2
+ {"ts": "2026-06-13T21:09:58.239967+00:00", "event": "nemotron_decision", "tool_calls": [{"name": "build_trip_packages", "arguments": {"check_out": "2026-06-29", "budget_tier": "mid_range", "origin_airport": "YUL", "match_name": "Canada vs Qatar", "check_in": "2026-06-26", "match_date": "2026-06-26", "travelers": 1}, "id": "call_c70b566e1c79417b8ae924eb"}], "text": "\n"}
3
+ {"ts": "2026-06-13T21:09:59.343542+00:00", "event": "tool_result", "tool": "build_trip_packages", "trip": "YUL -> Vancouver, 2026-06-26 to 2026-06-29, mid_range budget, 1 traveler, watching Canada vs Qatar on 2026-06-26.", "status": "complete", "degradation": ["Photos unavailable"], "packages": [{"label": "Cheapest", "total_cost_cad": 890.0, "flight": "Flair Airlines F8 107 (lands 11:00)", "hotel": "Hotel At The Waldorf", "provenance": {"flight": "serpapi", "hotel": "serpapi"}}, {"label": "Safest Arrival", "total_cost_cad": 970.0, "flight": "Air Canada AC 303 (lands 10:45)", "hotel": "Hotel At The Waldorf", "provenance": {"flight": "serpapi", "hotel": "serpapi"}}, {"label": "Closest to Stadium", "total_cost_cad": 1353.0, "flight": "Flair Airlines F8 107 (lands 11:00)", "hotel": "Best Western Premier Chateau Granville Hotel & Suites & Conf. Centre", "provenance": {"flight": "serpapi", "hotel": "serpapi"}}], "compact": "STATUS: complete | 1 degradation(s) | 3 package(s) scored\n DEGRADATION: Photos unavailable\n#1 Cheapest | $890.00 CAD | Flight Flair Airlines F8 107 lands 11:00 from YUL (source=serpapi) | Hotel Hotel At The Waldorf 3.367km to BC Place 3.6\u2605 (source=serpapi) | +8.0h buffer | 40 min walk | rain risk 35%\n#2 Safest Arrival | $970.00 CAD | Flight Air Canada AC 303 lands 10:45 from YUL (source=serpapi) | Hotel Hotel At The Waldorf 3.367km to BC Place 3.6\u2605 (source=serpapi) | +8.2h buffer | 40 min walk | rain risk 35%\n#3 Closest to Stadium | $1,353.00 CAD | Flight Flair Airlines F8 107 lands 11:00 from YUL (source=serpapi) | Hotel Best Western Premier Chateau Granville Hotel & Suites & Conf. Centre 0.615km to BC Place 4.2\u2605 (source=serpapi) | +8.0h buffer | 7 min walk | rain risk 35%"}
4
+ {"ts": "2026-06-13T21:10:01.869612+00:00", "event": "nemotron_explanation", "text": "**#1 Cheapest** \u2013 $890.00 CAD | Airline: Flair Airlines | Arrival: 11:00 | Hotel: Hotel At The Waldorf | Distance: 3.367km to BC Place | Weather: rain risk 35% (example) \n**#2 Safest Arrival** \u2013 $970.00 CAD | Airline: Air Canada | Arrival: 10:45 | Hotel: Hotel At The Waldorf | Distance: 3.367km to BC Place | Weather: rain risk 35% (example) \n**#3 Closest to Stadium** \u2013 $1,353.00 CAD | Airline: Flair Airlines | Arrival: 11:00 | Hotel: Best Western Premier Chateau Granville Hotel & Suites & Conf. Centre | Distance: 0.615km to BC Place | Weather: rain risk 35% (example) \n\n**Recommendation:** Closest to Stadium offers the shortest walk but at a premium price."}
matchday/trip_tool.py ADDED
@@ -0,0 +1,563 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MatchDay -- Coarse-grained trip-package assembly tool.
3
+
4
+ Nemotron calls this single tool with a validated ``TripRequest``. Python
5
+ handles everything inside:
6
+
7
+ 1. Validate the ``TripRequest`` with Pydantic (belt-and-suspenders).
8
+ 2. Call all enabled API normalizers (flights, hotels, weather, amenities,
9
+ photos) concurrently via ``asyncio.gather()``.
10
+ 3. Handle partial failures -- some APIs may be down, others succeed.
11
+ 4. Run deterministic scoring via ``scoring.score_options()``.
12
+ 5. Return a compact ``TripPackageResult``.
13
+
14
+ Nemotron never sees raw API results. It only sees the scored packages and
15
+ compact text summaries.
16
+
17
+ Architecture
18
+ ------------
19
+ ``build_trip_packages`` is the top-level entry point called by Nemotron.
20
+ It performs the full pipeline synchronisation, then hands the scored result
21
+ to ``format_for_nemotron`` for compact serialisation.
22
+
23
+ ``TripPackageResult`` is the structured return type, carrying the scored
24
+ packages, a status enum, degradation notices, and provenance metadata.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import logging
31
+ from datetime import datetime, timezone
32
+ from typing import Any, Literal
33
+
34
+ from pydantic import BaseModel, ConfigDict
35
+
36
+ from matchday import scoring
37
+ from matchday.api_registry import registry
38
+ from matchday.errors import (
39
+ DegradeImmediately,
40
+ FailoverReason,
41
+ MaxRetriesExceeded,
42
+ user_message_for,
43
+ )
44
+ from matchday.models import (
45
+ Amenity,
46
+ Flight,
47
+ Hotel,
48
+ ScoredPackage,
49
+ TripRequest,
50
+ Weather,
51
+ )
52
+
53
+ # Side-effect import: registers every API normalizer (weather/pois/flights/
54
+ # hotels) with the module-level ``registry`` so dispatch() below finds them.
55
+ # Without this, the registry is inert and every dispatch returns "Unknown
56
+ # normalizer". Keep this AFTER the ``matchday.*`` imports above.
57
+ import matchday.apis # noqa: F401,E402
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # TripPackageResult -- Nemotron-facing result envelope
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ class TripPackageResult(BaseModel):
68
+ """Compact result envelope returned to Nemotron.
69
+
70
+ ``packages`` is always sorted by composite score descending (highest
71
+ overall value first). The list may be empty when no valid packages
72
+ could be formed.
73
+
74
+ ``status`` reflects the overall health of the pipeline:
75
+ - ``"complete"``: All four core API categories (flights, hotels,
76
+ weather, amenities) succeeded.
77
+ - ``"partial"``: At least one core category failed but packages
78
+ were still formed from the available data.
79
+ - ``"failed"``: No packages could be formed (critical failures
80
+ across all categories).
81
+ """
82
+
83
+ packages: list[ScoredPackage]
84
+ """Scored and ranked packages, at most 3. May be empty."""
85
+
86
+ status: Literal["complete", "partial", "failed"]
87
+ """Pipeline health indicator."""
88
+
89
+ degradation_notices: list[str]
90
+ """Human-readable notices for every category that failed or degraded.
91
+
92
+ Examples::
93
+
94
+ ["Hotels unavailable, showing flights only",
95
+ "Weather forecast unavailable"]
96
+ """
97
+
98
+ generated_at: datetime
99
+ """Timestamp when this result was assembled."""
100
+
101
+ total_combinations_scored: int
102
+ """Number of (flight, hotel) combinations that were scored.
103
+
104
+ Zero indicates that no combinations were valid (e.g. all prices
105
+ missing, all flights late).
106
+ """
107
+
108
+ model_config = ConfigDict(frozen=True, extra="forbid")
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Category constants
113
+ # ---------------------------------------------------------------------------
114
+
115
+ _CORE_CATEGORIES: list[str] = [
116
+ "flights",
117
+ "hotels",
118
+ "weather",
119
+ "amenities",
120
+ ]
121
+ """Categories that are required for a ``"complete"`` status."""
122
+
123
+ _OPTIONAL_CATEGORIES: list[str] = [
124
+ "photos",
125
+ ]
126
+ """Categories that are always optional; their failure never degrades status."""
127
+
128
+ _ALL_CATEGORIES: list[str] = _CORE_CATEGORIES + _OPTIONAL_CATEGORIES
129
+ """All categories dispatched during build_trip_packages."""
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Category dispatch helpers
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ async def _dispatch_category(
138
+ category: str,
139
+ params: dict[str, Any],
140
+ ) -> tuple[str, Any]:
141
+ """Dispatch all enabled normalizers in *category* and return their data.
142
+
143
+ Parameters
144
+ ----------
145
+ category:
146
+ Category key to dispatch (e.g. ``"flights"``, ``"hotels"``).
147
+ params:
148
+ Input parameters forwarded to every normalizer in the category.
149
+
150
+ Returns
151
+ -------
152
+ tuple[str, Any]
153
+ ``(category, aggregated_data)`` where *aggregated_data* is the
154
+ merged result from all successful normalizers in the category.
155
+ The merge strategy is category-specific:
156
+
157
+ - ``flights``: merged list of ``Flight`` objects.
158
+ - ``hotels``: merged list of ``Hotel`` objects.
159
+ - ``weather``: merged list of ``Weather`` objects.
160
+ - ``amenities``: merged list of ``Amenity`` objects.
161
+ - ``photos``: merged list of photo URLs / metadata.
162
+
163
+ On total failure (no normalizers enabled, all failed, or none
164
+ registered), the value is ``None``.
165
+ """
166
+ enabled = registry.get_enabled_normalizers(category=category)
167
+
168
+ if not enabled:
169
+ return category, None
170
+
171
+ # Dispatch all enabled normalizers concurrently
172
+ tasks = [registry.dispatch(name, params) for name in enabled]
173
+ results = await asyncio.gather(*tasks, return_exceptions=True)
174
+
175
+ # Collect successful data, log failures
176
+ merged: list[Any] = []
177
+ for name, result in zip(enabled, results):
178
+ if isinstance(result, Exception):
179
+ logger.warning(
180
+ "Normalizer %s raised exception: %s: %s",
181
+ name,
182
+ type(result).__name__,
183
+ result,
184
+ )
185
+ continue
186
+ if result.success and result.data is not None:
187
+ data = result.data
188
+ if isinstance(data, list):
189
+ merged.extend(data)
190
+ else:
191
+ merged.append(data)
192
+ else:
193
+ logger.info(
194
+ "Normalizer %s returned failure: %s",
195
+ name,
196
+ result.error,
197
+ )
198
+
199
+ if not merged:
200
+ return category, None
201
+
202
+ return category, merged
203
+
204
+
205
+ async def _dispatch_all(params: dict[str, Any]) -> dict[str, Any]:
206
+ """Dispatch all categories concurrently.
207
+
208
+ Parameters
209
+ ----------
210
+ params:
211
+ Input parameters forwarded to every category's normalizers.
212
+
213
+ Returns
214
+ -------
215
+ dict[str, Any]
216
+ Mapping from category name to aggregated data (or ``None`` on
217
+ total failure). Keys match those in ``_ALL_CATEGORIES``.
218
+ """
219
+ tasks = [_dispatch_category(cat, params) for cat in _ALL_CATEGORIES]
220
+ results = await asyncio.gather(*tasks)
221
+
222
+ return dict(results)
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Degradation notice generation
227
+ # ---------------------------------------------------------------------------
228
+
229
+
230
+ def _build_degradation_notices(category_data: dict[str, Any]) -> list[str]:
231
+ """Build human-readable degradation notices for failed categories.
232
+
233
+ Parameters
234
+ ----------
235
+ category_data:
236
+ Mapping from category name to aggregated data (``None`` means
237
+ total failure for that category).
238
+
239
+ Returns
240
+ -------
241
+ list[str]
242
+ Notices sorted in display order (core first).
243
+ """
244
+ notices: list[str] = []
245
+
246
+ for cat in _ALL_CATEGORIES:
247
+ data = category_data.get(cat)
248
+ if data is not None:
249
+ continue # category succeeded
250
+
251
+ if cat == "flights":
252
+ notices.append("Flight search unavailable")
253
+ elif cat == "hotels":
254
+ notices.append("Hotels unavailable, showing flights only")
255
+ elif cat == "weather":
256
+ notices.append("Weather forecast unavailable")
257
+ elif cat == "amenities":
258
+ notices.append("Nearby amenities unavailable")
259
+ elif cat == "photos":
260
+ notices.append("Photos unavailable")
261
+
262
+ return notices
263
+
264
+
265
+ def _infer_status(
266
+ category_data: dict[str, Any],
267
+ packages: list[ScoredPackage],
268
+ ) -> Literal["complete", "partial", "failed"]:
269
+ """Infer the result status from category health and packages.
270
+
271
+ "failed" when no packages exist and critical data (flights) is missing.
272
+ "complete" when all core categories succeeded.
273
+ "partial" when at least one core category failed but packages exist.
274
+ """
275
+ if not packages:
276
+ return "failed"
277
+
278
+ core_ok = all(
279
+ category_data.get(cat) is not None for cat in _CORE_CATEGORIES
280
+ )
281
+ return "complete" if core_ok else "partial"
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # Main entry point
286
+ # ---------------------------------------------------------------------------
287
+
288
+
289
+ async def build_trip_packages(trip_request: TripRequest) -> TripPackageResult:
290
+ """Assemble and score trip packages for a validated ``TripRequest``.
291
+
292
+ This is the single coarse-grained tool that Nemotron calls. It:
293
+
294
+ 1. Validates the ``TripRequest`` with Pydantic (already validated by
295
+ the caller, but belt-and-suspenders).
296
+ 2. Dispatches all enabled API normalizers (flights, hotels, weather,
297
+ amenities, photos) concurrently via ``asyncio.gather()``.
298
+ 3. Aggregates results per category; failures are recorded in
299
+ degradation notices rather than aborting the whole pipeline.
300
+ 4. Passes flights, hotels, and weather to ``scoring.score_options()``
301
+ for deterministic ranking.
302
+ 5. Attaches amenities to the top packages.
303
+ 6. Wraps everything in a ``TripPackageResult`` with status and source
304
+ provenance.
305
+
306
+ Parameters
307
+ ----------
308
+ trip_request:
309
+ Fully validated trip intent from Nemotron's extraction step.
310
+ Every field has been checked by Pydantic validators in the
311
+ ``TripRequest`` model before reaching this function.
312
+
313
+ Returns
314
+ -------
315
+ TripPackageResult
316
+ Scored packages with status, degradation notices, and metadata.
317
+ **Never raises.** All errors (API failures, network issues, empty
318
+ results) are captured inside the result envelope.
319
+
320
+ Examples
321
+ --------
322
+ >>> from datetime import date
323
+ >>> req = TripRequest(
324
+ ... origin_airport="YUL",
325
+ ... match_name="Canada vs Qatar",
326
+ ... match_date=date(2026, 7, 1),
327
+ ... check_in=date(2026, 6, 30),
328
+ ... check_out=date(2026, 7, 2),
329
+ ... travelers=2,
330
+ ... budget_tier="mid_range",
331
+ ... )
332
+ >>> result = await build_trip_packages(req)
333
+ >>> result.status
334
+ 'complete'
335
+ >>> len(result.packages)
336
+ 3
337
+ """
338
+ now = datetime.now(timezone.utc)
339
+
340
+ # ------------------------------------------------------------------
341
+ # 1. Validate TripRequest (belt-and-suspenders -- caller should have
342
+ # already validated, but Pydantic model_validate is cheap)
343
+ # ------------------------------------------------------------------
344
+ try:
345
+ trip_request = TripRequest.model_validate(
346
+ trip_request.model_dump()
347
+ )
348
+ except Exception as exc:
349
+ return TripPackageResult(
350
+ packages=[],
351
+ status="failed",
352
+ degradation_notices=[f"Invalid trip request: {exc}"],
353
+ generated_at=now,
354
+ total_combinations_scored=0,
355
+ )
356
+
357
+ # ------------------------------------------------------------------
358
+ # 2. Dispatch all API categories concurrently
359
+ # ------------------------------------------------------------------
360
+ params = trip_request.model_dump()
361
+ # Add derived parameters that normalizers need
362
+ params["destination"] = "YVR" # Vancouver International Airport
363
+ params["match_date"] = trip_request.match_date
364
+
365
+ category_data = await _dispatch_all(params)
366
+
367
+ flights_data: list[Flight] | None = category_data.get("flights")
368
+ hotels_data: list[Hotel] | None = category_data.get("hotels")
369
+ weather_data: list[Weather] | None = category_data.get("weather")
370
+ amenities_data: list[Amenity] | None = category_data.get("amenities")
371
+
372
+ # ------------------------------------------------------------------
373
+ # 3. Build degradation notices
374
+ # ------------------------------------------------------------------
375
+ degradation_notices = _build_degradation_notices(category_data)
376
+
377
+ # ------------------------------------------------------------------
378
+ # 4. Run deterministic scoring
379
+ # ------------------------------------------------------------------
380
+ if flights_data is None:
381
+ flights_data = []
382
+
383
+ if hotels_data is None:
384
+ hotels_data = []
385
+
386
+ if weather_data is None:
387
+ weather_data = []
388
+
389
+ scored_packages = scoring.score_options(
390
+ flights=flights_data,
391
+ hotels=hotels_data,
392
+ weather=weather_data,
393
+ match_date=trip_request.match_date,
394
+ budget_tier=trip_request.budget_tier,
395
+ )
396
+
397
+ # ------------------------------------------------------------------
398
+ # 5. Attach amenities to scored packages
399
+ # ------------------------------------------------------------------
400
+ if scored_packages and amenities_data:
401
+ # ScoredPackage is frozen, so rebuild the list with amenities attached.
402
+ # The prior loop rebinding the local name ``pkg`` was a silent no-op --
403
+ # it never mutated ``scored_packages``. Cap per package (U3 budget).
404
+ amenities = list(amenities_data)[:25]
405
+ scored_packages = [
406
+ pkg.model_copy(update={"amenities": amenities})
407
+ for pkg in scored_packages
408
+ ]
409
+
410
+ total_combinations = len(scored_packages)
411
+
412
+ # ------------------------------------------------------------------
413
+ # 6. Build result envelope
414
+ # ------------------------------------------------------------------
415
+ status = _infer_status(category_data, scored_packages)
416
+
417
+ return TripPackageResult(
418
+ packages=scored_packages,
419
+ status=status,
420
+ degradation_notices=degradation_notices,
421
+ generated_at=now,
422
+ total_combinations_scored=total_combinations,
423
+ )
424
+
425
+
426
+ # ---------------------------------------------------------------------------
427
+ # Compact serialisation for Nemotron
428
+ # ---------------------------------------------------------------------------
429
+
430
+
431
+ def format_for_nemotron(result: TripPackageResult) -> str:
432
+ """Return a compact string representation of ``TripPackageResult``.
433
+
434
+ The output is designed to stay under 2,000 tokens so Nemotron can
435
+ ingest it without consuming its entire context window. Only the most
436
+ salient fields are included:
437
+
438
+ - ``label`` (package objective name)
439
+ - ``total_cost`` (flight + hotel in CAD)
440
+ - ``arrival_buffer`` (hours before kickoff)
441
+ - ``transit_min`` (hotel-to-stadium walking minutes)
442
+ - ``weather_risk`` (simple summary of precipitation risk)
443
+
444
+ Raw API responses, full flight/hotel JSON blobs, and raw score vectors
445
+ are **not** included.
446
+
447
+ Parameters
448
+ ----------
449
+ result:
450
+ The result envelope from ``build_trip_packages``.
451
+
452
+ Returns
453
+ -------
454
+ str
455
+ Compact multi-line string representation, one line per package,
456
+ plus a status summary line.
457
+
458
+ Examples
459
+ --------
460
+ >>> result = await build_trip_packages(trip_request)
461
+ >>> print(format_for_nemotron(result))
462
+ STATUS: complete | 1 degradation | 3 packages scored
463
+ #1 Cheapest | $1,245.00 CAD | +4.5h buffer | 12 min walk | rain risk 30%
464
+ #2 Safest Arrival | $1,520.00 CAD | +8.2h buffer | 18 min walk | rain risk 30%
465
+ #3 Closest to Stadium | $1,670.00 CAD | +3.0h buffer | 5 min walk | rain risk 60%
466
+ """
467
+ lines: list[str] = []
468
+
469
+ status_line_parts: list[str] = [
470
+ f"STATUS: {result.status}",
471
+ ]
472
+
473
+ if result.degradation_notices:
474
+ status_line_parts.append(
475
+ f"{len(result.degradation_notices)} degradation(s)"
476
+ )
477
+ else:
478
+ status_line_parts.append("no degradation")
479
+
480
+ status_line_parts.append(
481
+ f"{result.total_combinations_scored} package(s) scored"
482
+ )
483
+ lines.append(" | ".join(status_line_parts))
484
+
485
+ # Append degradation detail lines when present
486
+ for notice in result.degradation_notices:
487
+ lines.append(f" DEGRADATION: {notice}")
488
+
489
+ if not result.packages:
490
+ lines.append(" No packages available.")
491
+ return "\n".join(lines)
492
+
493
+ for idx, pkg in enumerate(result.packages, start=1):
494
+ parts: list[str] = [
495
+ f"#{idx} {pkg.label:<20}",
496
+ ]
497
+
498
+ parts.append(f"${pkg.total_cost_cad:,.2f} CAD")
499
+
500
+ # Flight details — give Nemotron the REAL carrier/route/time so its
501
+ # explanation quotes them instead of inventing flight numbers.
502
+ f = pkg.flight
503
+ parts.append(
504
+ f"Flight {f.airline} {f.flight_number} lands "
505
+ f"{f.arrival_time:%H:%M} from {f.origin} (source={f.source})"
506
+ )
507
+
508
+ # Hotel details — real name + distance so it isn't hallucinated.
509
+ h = pkg.hotel
510
+ if h is not None:
511
+ rating = f"{h.rating:g}★" if h.rating is not None else ""
512
+ parts.append(
513
+ f"Hotel {h.name} {h.distance_to_stadium_km:g}km to BC Place "
514
+ f"{rating} (source={h.source})"
515
+ )
516
+ else:
517
+ parts.append("Hotel: none (flight-only)")
518
+
519
+ buffer_sign = "+" if pkg.arrival_buffer_hours >= 0 else ""
520
+ parts.append(
521
+ f"{buffer_sign}{pkg.arrival_buffer_hours:.1f}h buffer"
522
+ )
523
+
524
+ parts.append(f"{pkg.hotel_to_stadium_min} min walk")
525
+
526
+ # Weather risk: pick max precipitation probability
527
+ if pkg.weather:
528
+ max_precip = max(
529
+ w.precipitation_probability for w in pkg.weather
530
+ )
531
+ parts.append(f"rain risk {max_precip:.0f}%")
532
+ else:
533
+ parts.append("weather N/A")
534
+
535
+ lines.append(" | ".join(parts))
536
+
537
+ return "\n".join(lines)
538
+
539
+
540
+ # ---------------------------------------------------------------------------
541
+ # Convenience: build-and-format in one async call
542
+ # ---------------------------------------------------------------------------
543
+
544
+
545
+ async def build_and_format(trip_request: TripRequest) -> str:
546
+ """Build packages and return the compact string representation.
547
+
548
+ This is a convenience wrapper for Nemotron that collapses the two-step
549
+ pipeline into a single call. Nemotron calls this when it only needs the
550
+ text summary, not the structured ``TripPackageResult``.
551
+
552
+ Parameters
553
+ ----------
554
+ trip_request:
555
+ Validated trip intent.
556
+
557
+ Returns
558
+ -------
559
+ str
560
+ Compact formatted output from ``format_for_nemotron``.
561
+ """
562
+ result = await build_trip_packages(trip_request)
563
+ return format_for_nemotron(result)
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # MatchDay runtime dependencies (HF Space + local).
2
+ # The deterministic path needs only the first three; `modal` is required once
3
+ # the Nemotron agent layer (N13) is wired in (it calls Modal remotely — the
4
+ # 30B model never runs locally).
5
+ gradio>=5.0
6
+ httpx>=0.27
7
+ pydantic>=2.0
8
+ modal>=0.70