Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- README.md +59 -7
- app.py +16 -0
- matchday/__init__.py +28 -0
- matchday/agent.py +142 -0
- matchday/agent_loop.py +1076 -0
- matchday/api_registry.py +772 -0
- matchday/apis/__init__.py +26 -0
- matchday/apis/flights.py +153 -0
- matchday/apis/hotels.py +159 -0
- matchday/apis/pois.py +161 -0
- matchday/apis/weather.py +182 -0
- matchday/apis/web.py +89 -0
- matchday/app.py +245 -0
- matchday/config.py +40 -0
- matchday/errors.py +733 -0
- matchday/file-listing-claude-code.txt +0 -0
- matchday/file-listing-deepagents.txt +713 -0
- matchday/file-listing-hermes-agent.txt +0 -0
- matchday/fixtures.py +115 -0
- matchday/get_started.py +9 -0
- matchday/intent.py +193 -0
- matchday/modal_spike.py +290 -0
- matchday/models.py +491 -0
- matchday/prompts.py +95 -0
- matchday/publish_agent_trace.py +75 -0
- matchday/record_trace.py +130 -0
- matchday/render.py +196 -0
- matchday/scoring.py +608 -0
- matchday/traces/session_001.jsonl +4 -0
- matchday/trip_tool.py +563 -0
- requirements.txt +8 -0
README.md
CHANGED
|
@@ -1,13 +1,65 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
-
pinned:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("<", "<").replace(">", ">").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
|