mzidan000 commited on
Commit
9f26f45
·
verified ·
1 Parent(s): 5fcae20

Upload folder using huggingface_hub

Browse files
app.py CHANGED
@@ -30,7 +30,7 @@ from fastapi.responses import HTMLResponse # noqa: E402
30
  from gradio import Server # noqa: E402
31
 
32
  from matchday.agent import MatchDayAgent # noqa: E402
33
- from matchday.agent_loop import BuildTripPackagesArgs # noqa: E402
34
  from matchday.intent import parse_intent # noqa: E402
35
  from matchday.models import TripRequest # noqa: E402
36
  from matchday.prompts import EXPLANATION_HINT # noqa: E402
@@ -97,18 +97,6 @@ async def _pulse(coro, holder, message, interval: int = 9):
97
  yield _ev(type="commentary", text=f"{message} ({int(time.monotonic() - start)}s)")
98
 
99
 
100
- def _args_to_trip(a: BuildTripPackagesArgs) -> TripRequest:
101
- return TripRequest(
102
- origin_airport=a.origin_airport,
103
- match_name=a.match_name or "the match",
104
- match_date=date.fromisoformat(a.match_date),
105
- check_in=date.fromisoformat(a.check_in),
106
- check_out=date.fromisoformat(a.check_out),
107
- travelers=a.travelers,
108
- budget_tier=a.budget_tier,
109
- )
110
-
111
-
112
  async def _agent_explain(agent, user_text: str, trip: TripRequest, result) -> str:
113
  """Round 2 — Nemotron compares the packages. Best-effort ('' on failure)."""
114
  args_json = json.dumps(trip.model_dump(mode="json"))
@@ -157,70 +145,108 @@ async def plan_trip(user_text: str) -> str:
157
  except Exception as exc:
158
  logger.warning("agent init failed (%s); deterministic path.", exc)
159
 
160
- # Round 1 Nemotron decides which tool to call.
161
- r1: dict = {"tool_calls": []}
162
- if agent is not None:
163
- yield _ev(type="commentary", text="🤖 Nemotron is choosing your best options…")
164
- h1 = {}
165
- try:
166
- async for beat in _pulse(
167
- agent.run([{"role": "user", "content": user_text}]),
168
- h1,
169
- "🤖 Nemotron is choosing your best options…",
170
- ):
171
- yield beat
172
- r1 = h1["result"]
173
- except Exception as exc:
174
- logger.warning("agent round 1 failed (%s).", exc)
175
- r1 = {"tool_calls": []}
176
-
177
- # Resolve a validated trip: Nemotron's args first, then deterministic parse.
178
- tool_calls = r1.get("tool_calls") or []
179
  trip: TripRequest | None = None
180
- if tool_calls and tool_calls[0].get("name") == "build_trip_packages":
181
- try:
182
- trip = _args_to_trip(
183
- BuildTripPackagesArgs.model_validate(tool_calls[0].get("arguments", {}))
184
- )
185
- except Exception:
186
- trip = None
187
- if trip is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  parsed = parse_intent(user_text)
189
- if parsed.trip_request is not None:
190
- trip = parsed.trip_request
191
-
192
- if trip is None:
193
- clarify_q = ""
194
- if tool_calls and tool_calls[0].get("name") == "clarify":
195
- clarify_q = tool_calls[0].get("arguments", {}).get("question", "")
196
- if not clarify_q:
197
- clarify_q = parse_intent(user_text).question
198
- yield _ev(
199
- type="clarify",
200
- text=clarify_q
201
- or "Tell me where you're flying from and which match you want to see.",
202
- )
203
- return
 
 
 
 
 
 
 
 
 
 
 
204
 
205
- yield _ev(type="greenlight", text=trip.summary())
206
- yield _ev(
207
- type="commentary",
208
- text="✈️ Scanning airlines · 🏨 Finding hotels near BC Place · 🌤️ Checking the match-day forecast…",
209
- )
210
- hb = {}
211
- try:
212
- async for beat in _pulse(
213
- build_trip_packages(trip),
214
- hb,
215
- "✈️ Scanning airlines · 🏨 hotels near BC Place · 🌤️ weather",
216
- ):
217
- yield beat
218
- result = hb["result"]
219
- except Exception as exc:
220
- yield _ev(type="error", text=f"⚠️ {exc}")
221
  return
222
 
223
- yield _ev(type="commentary", text="🗺️ Scoring 3 packages Nemotron is writing your comparison…")
 
 
 
224
  explanation = ""
225
  if agent is not None:
226
  he = {}
@@ -228,7 +254,7 @@ async def plan_trip(user_text: str) -> str:
228
  async for beat in _pulse(
229
  _agent_explain(agent, user_text, trip, result),
230
  he,
231
- "🗺️ Scoring 3 packages — Nemotron is writing your comparison…",
232
  ):
233
  yield beat
234
  explanation = he["result"]
 
30
  from gradio import Server # noqa: E402
31
 
32
  from matchday.agent import MatchDayAgent # noqa: E402
33
+ from matchday.agent_loop import run_agent_loop # noqa: E402
34
  from matchday.intent import parse_intent # noqa: E402
35
  from matchday.models import TripRequest # noqa: E402
36
  from matchday.prompts import EXPLANATION_HINT # noqa: E402
 
97
  yield _ev(type="commentary", text=f"{message} ({int(time.monotonic() - start)}s)")
98
 
99
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  async def _agent_explain(agent, user_text: str, trip: TripRequest, result) -> str:
101
  """Round 2 — Nemotron compares the packages. Best-effort ('' on failure)."""
102
  args_json = json.dumps(trip.model_dump(mode="json"))
 
145
  except Exception as exc:
146
  logger.warning("agent init failed (%s); deterministic path.", exc)
147
 
148
+ # ── Smart path: the bounded agent loop (K1). Nemotron UNDERSTANDS the
149
+ # request, may GROUND itself with web_search (I6), may CLARIFY to capture
150
+ # intent (P7), and calls build_trip_packages when ready. The loop validates
151
+ # args, dedups, and self-corrects one malformed call (A4). No more bypass.
152
+ messages: list[dict] = [{"role": "user", "content": user_text}]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  trip: TripRequest | None = None
154
+ result = None # TripPackageResult produced inside the loop's build call
155
+ agent_text = "" # a clarify question or direct answer from the Brain
156
+
157
+ if agent is not None:
158
+ yield _ev(type="commentary", text="🧠 Nemotron is understanding your request…")
159
+ for attempt in range(3): # cap grounding rounds (web_search → build)
160
+ h = {}
161
+ try:
162
+ async for beat in _pulse(
163
+ run_agent_loop(agent, messages),
164
+ h,
165
+ "🧠 Nemotron is understanding your request & choosing tools",
166
+ ):
167
+ yield beat
168
+ res = h.get("result")
169
+ except Exception as exc:
170
+ logger.warning("agent loop attempt %d failed (%s).", attempt, exc)
171
+ res = None
172
+
173
+ if res is None:
174
+ break
175
+
176
+ if res.type == "tool_called" and res.tool == "build_trip_packages":
177
+ result = res.result.get("full_result")
178
+ trip = res.result.get("trip")
179
+ break
180
+
181
+ if res.type == "tool_called" and res.tool == "web_search":
182
+ # Brain grounded itself — thread the result back so it can build.
183
+ tcid = f"call_ws_{attempt}"
184
+ messages.append({
185
+ "role": "assistant", "content": "",
186
+ "tool_calls": [{
187
+ "id": tcid, "type": "function",
188
+ "function": {
189
+ "name": "web_search",
190
+ "arguments": json.dumps(res.result.get("query") or {}),
191
+ },
192
+ }],
193
+ })
194
+ messages.append({
195
+ "role": "tool", "tool_call_id": tcid, "name": "web_search",
196
+ "content": json.dumps(res.result, ensure_ascii=False)[:1200],
197
+ })
198
+ yield _ev(
199
+ type="commentary",
200
+ text="🔎 Grounded with a web search — now building your packages…",
201
+ )
202
+ continue
203
+
204
+ if res.type == "final_answer":
205
+ agent_text = res.text or ""
206
+ break
207
+ # fallback_to_deterministic → drop through to the deterministic path
208
+ break
209
+
210
+ # ── Deterministic fallback (K3): parse intent + build directly. Used when
211
+ # the agent is unavailable, hedged to a non-build answer, or the loop failed.
212
+ if result is None and not agent_text:
213
  parsed = parse_intent(user_text)
214
+ trip = parsed.trip_request
215
+ if trip is not None:
216
+ yield _ev(type="greenlight", text=trip.summary())
217
+ yield _ev(
218
+ type="commentary",
219
+ text="✈️ Scanning airlines · 🏨 Finding hotels near BC Place · 🌤️ Checking the match-day forecast…",
220
+ )
221
+ hb = {}
222
+ try:
223
+ async for beat in _pulse(
224
+ build_trip_packages(trip),
225
+ hb,
226
+ "✈️ Scanning airlines · 🏨 hotels near BC Place · 🌤️ weather",
227
+ ):
228
+ yield beat
229
+ result = hb["result"]
230
+ except Exception as exc:
231
+ yield _ev(type="error", text=f"⚠️ {exc}")
232
+ return
233
+ else:
234
+ yield _ev(
235
+ type="clarify",
236
+ text=parsed.question
237
+ or "Tell me where you're flying from and which match you want to see.",
238
+ )
239
+ return
240
 
241
+ # Clarify / direct answer from the Brain (no packages to show).
242
+ if result is None:
243
+ yield _ev(type="clarify", text=agent_text)
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  return
245
 
246
+ # ── Render. greenlight confirms the captured intent just before the packages.
247
+ if trip is not None:
248
+ yield _ev(type="greenlight", text=trip.summary())
249
+ yield _ev(type="commentary", text="🗺️ Nemotron is comparing your 3 packages…")
250
  explanation = ""
251
  if agent is not None:
252
  he = {}
 
254
  async for beat in _pulse(
255
  _agent_explain(agent, user_text, trip, result),
256
  he,
257
+ "🗺️ Nemotron is comparing your 3 packages…",
258
  ):
259
  yield beat
260
  explanation = he["result"]
matchday/agent.py CHANGED
@@ -22,6 +22,7 @@ from typing import Any
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
@@ -116,8 +117,15 @@ class MatchDayAgent:
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
 
 
22
  import modal
23
 
24
  from matchday.agent_loop import BuildTripPackagesArgs, ClarifyArgs, WebSearchArgs
25
+ from matchday.errors import classify_error
26
  from matchday.prompts import build_system_prompt
27
 
28
  # The Modal app name (must match `modal.App("matchday-spike")` in modal_spike.py
 
117
  enable_thinking=self._thinking,
118
  )
119
  except Exception as exc:
120
+ # Classify (N29) instead of swallowing to an opaque empty result.
121
+ # Re-raise so the AgentLoop can degrade gracefully (fallback to the
122
+ # deterministic parser); the classified reason is logged here.
123
+ classified = classify_error(exc)
124
+ logger.error(
125
+ "Nemotron generate failed: %s (reason=%s, retryable=%s, degrade=%s)",
126
+ exc, classified.reason.value, classified.retryable, classified.should_degrade,
127
+ )
128
+ raise
129
 
130
  return {"tool_calls": _parse_tool_calls(msg), "text": msg.get("content") or ""}
131
 
matchday/agent_loop.py CHANGED
@@ -196,9 +196,12 @@ async def _build_trip_packages(
196
  ) -> dict[str, Any]:
197
  """Execute the build_trip_packages tool.
198
 
199
- Dispatches to all enabled API normalizers via the registry, then runs
200
- the deterministic scoring engine to produce the top 3 ScoredPackage
201
- objects.
 
 
 
202
 
203
  Parameters
204
  ----------
@@ -208,124 +211,67 @@ async def _build_trip_packages(
208
  Returns
209
  -------
210
  dict[str, Any]
211
- A compact result dict with packages, or an error key.
212
  """
213
- from matchday.scoring import score_options
214
- from matchday.models import Flight, Hotel, Weather
215
-
216
- # Determine which normalizers are available
217
- normalizer_names = registry.get_enabled_normalizers(category="full_trip")
218
- if not normalizer_names:
219
- # Fall back to individual categories
220
- flight_names = registry.get_enabled_normalizers(category="flights")
221
- hotel_names = registry.get_enabled_normalizers(category="hotels")
222
- weather_names = registry.get_enabled_normalizers(category="weather")
223
- amenity_names = registry.get_enabled_normalizers(category="amenities")
224
- else:
225
- flight_names = [n for n in normalizer_names if n in registry.get_enabled_normalizers(category="flights")]
226
- hotel_names = [n for n in normalizer_names if n in registry.get_enabled_normalizers(category="hotels")]
227
- weather_names = [n for n in normalizer_names if n in registry.get_enabled_normalizers(category="weather")]
228
- amenity_names = [n for n in normalizer_names if n in registry.get_enabled_normalizers(category="amenities")]
229
-
230
- # Build dispatch params
231
  from datetime import date
232
 
233
- match_date = date.fromisoformat(validated_args["match_date"])
234
- check_in = date.fromisoformat(validated_args["check_in"])
235
- check_out = date.fromisoformat(validated_args["check_out"])
236
 
237
- params: dict[str, dict[str, Any]] = {}
238
- for fn in flight_names:
239
- params[fn] = {
240
- "origin": validated_args["origin_airport"],
241
- "destination": "YVR",
242
- "departure_date": validated_args["match_date"],
243
- }
244
- for hn in hotel_names:
245
- params[hn] = {
246
- "check_in_date": validated_args["check_in"],
247
- "check_out_date": validated_args["check_out"],
248
- }
249
- for wn in weather_names:
250
- params[wn] = {
251
- "latitude": 49.2827,
252
- "longitude": -123.1207,
253
- "start_date": validated_args["check_in"],
254
- "end_date": validated_args["check_out"],
255
- }
256
- for an in amenity_names:
257
- params[an] = {
258
- "latitude": 49.2827,
259
- "longitude": -123.1207,
260
- "radius_km": 1.0,
261
- }
262
-
263
- # Dispatch all enabled normalizers concurrently
264
- tasks: dict[str, asyncio.Task[Any]] = {}
265
- for normalizer_name in flight_names + hotel_names + weather_names + amenity_names:
266
- p = params.get(normalizer_name, {})
267
- tasks[normalizer_name] = asyncio.create_task(
268
- registry.dispatch(normalizer_name, p)
269
  )
 
 
 
 
 
 
 
 
 
 
270
 
271
- results: dict[str, Any] = {}
272
- for normalizer_name, task in tasks.items():
273
- try:
274
- api_result = await asyncio.wait_for(task, timeout=PER_TOOL_TIMEOUT_SECONDS)
275
- results[normalizer_name] = api_result
276
- except asyncio.TimeoutError:
277
- logger.warning("Normalizer %s timed out after %ss", normalizer_name, PER_TOOL_TIMEOUT_SECONDS)
278
- results[normalizer_name] = None
279
- except Exception as exc:
280
- logger.warning("Normalizer %s failed: %s", normalizer_name, exc)
281
- results[normalizer_name] = None
282
-
283
- # Collect flights, hotels, weather from API results
284
- flights: list[Flight] = []
285
- hotels: list[Hotel] = []
286
- weather_list: list[Weather] = []
287
-
288
- for fn in flight_names:
289
- r = results.get(fn)
290
- if r is not None and r.success and isinstance(r.data, list):
291
- flights.extend(r.data)
292
-
293
- for hn in hotel_names:
294
- r = results.get(hn)
295
- if r is not None and r.success and isinstance(r.data, list):
296
- hotels.extend(r.data)
297
-
298
- for wn in weather_names:
299
- r = results.get(wn)
300
- if r is not None and r.success:
301
- data = r.data
302
- if isinstance(data, list):
303
- weather_list.extend(data)
304
-
305
- # Run deterministic scoring
306
- packages = score_options(
307
- flights=flights,
308
- hotels=hotels,
309
- weather=weather_list,
310
- match_date=match_date,
311
- budget_tier=validated_args.get("budget_tier", "mid_range"),
312
- )
313
 
314
- if not packages:
315
  return {
316
  "tool": "build_trip_packages",
317
  "success": False,
318
  "reason": "No valid trip packages could be formed with the available data.",
319
  "packages": [],
 
 
320
  }
321
 
322
- # Serialise packages compactly
323
- compact_packages = [_compact_package(pkg) for pkg in packages]
324
-
325
  return {
326
  "tool": "build_trip_packages",
327
  "success": True,
328
  "packages": compact_packages,
 
 
329
  }
330
 
331
 
 
196
  ) -> dict[str, Any]:
197
  """Execute the build_trip_packages tool.
198
 
199
+ Routes through the canonical ``trip_tool.build_trip_packages`` (U1 parallel
200
+ gather + K4 scoring) -- no dispatch duplication -- and returns BOTH the
201
+ compact packages (for the Brain's context) AND the full ``TripPackageResult``
202
+ (stashed as ``full_result``) plus the resolved ``TripRequest`` (as ``trip``)
203
+ so the caller (app.py) can render the cards / map / timeline without
204
+ re-running the searches.
205
 
206
  Parameters
207
  ----------
 
211
  Returns
212
  -------
213
  dict[str, Any]
214
+ ``{tool, success, packages(compact), full_result, trip}`` or an error.
215
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  from datetime import date
217
 
218
+ from matchday.models import TripRequest
219
+ from matchday.trip_tool import build_trip_packages
 
220
 
221
+ # Resolve a validated TripRequest from the tool arguments.
222
+ try:
223
+ args = BuildTripPackagesArgs.model_validate(validated_args)
224
+ trip = TripRequest(
225
+ origin_airport=args.origin_airport,
226
+ match_name=args.match_name or "the match",
227
+ match_date=date.fromisoformat(args.match_date),
228
+ check_in=date.fromisoformat(args.check_in),
229
+ check_out=date.fromisoformat(args.check_out),
230
+ travelers=args.travelers,
231
+ budget_tier=args.budget_tier,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  )
233
+ except Exception as exc: # invalid args -> graceful error back to the Brain
234
+ logger.warning("build_trip_packages arg resolution failed: %s", exc)
235
+ return {
236
+ "tool": "build_trip_packages",
237
+ "success": False,
238
+ "reason": f"Invalid trip arguments: {exc}",
239
+ "packages": [],
240
+ "full_result": None,
241
+ "trip": None,
242
+ }
243
 
244
+ # Single canonical dispatch + scoring path.
245
+ try:
246
+ full = await build_trip_packages(trip)
247
+ except Exception as exc:
248
+ logger.warning("build_trip_packages failed: %s", exc)
249
+ return {
250
+ "tool": "build_trip_packages",
251
+ "success": False,
252
+ "reason": str(exc),
253
+ "packages": [],
254
+ "full_result": None,
255
+ "trip": trip,
256
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
 
258
+ if not full.packages:
259
  return {
260
  "tool": "build_trip_packages",
261
  "success": False,
262
  "reason": "No valid trip packages could be formed with the available data.",
263
  "packages": [],
264
+ "full_result": full,
265
+ "trip": trip,
266
  }
267
 
268
+ compact_packages = [_compact_package(pkg) for pkg in full.packages]
 
 
269
  return {
270
  "tool": "build_trip_packages",
271
  "success": True,
272
  "packages": compact_packages,
273
+ "full_result": full,
274
+ "trip": trip,
275
  }
276
 
277
 
matchday/prompts.py CHANGED
@@ -53,14 +53,21 @@ World Cup context:
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
 
53
 
54
  _TOOL_GUIDANCE = """\
55
  Tool-use rules:
56
+ - UNDERSTAND FIRST, THEN ACT. Read what the traveler actually wants before
57
+ calling a tool. Capture their intent: the origin city/airport, the match,
58
+ the dates, the budget, the vibe.
59
+ - If the request is clear (you can infer an origin, a match, and at least one
60
+ date), call `build_trip_packages` right away it runs all searches in
61
+ parallel and returns 3 scored packages.
62
+ - You MAY use `web_search` (restricted to fifa.com, vancouverfwc26.ca,
63
+ bcplace.com, translink.ca) to GROUND your understanding — e.g. confirm a
64
+ match exists on the date, or find a kickoff time / venue rule the other
65
+ tools don't provide. Do NOT use it for prices, flights, hotels, or weather;
66
+ those come from `build_trip_packages`.
67
+ - If the origin OR the date is genuinely missing or ambiguous, call `clarify`
68
+ with ONE specific, friendly question (offer concrete options, e.g. "Flying
69
+ from Montreal (YUL) or Toronto (YYZ)?"). Do not over-clarify a clear request.
70
+ - Do not call a tool you already called with identical arguments this turn.
71
  - When you have the packages, write a concise comparison: name each package's
72
  strength (Cheapest / Safest Arrival / Closest to Stadium), cite the real
73
  prices from the tool, note any 'example' (non-live) data, and end with a
matchday/scoring.py CHANGED
@@ -31,6 +31,7 @@ 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
 
@@ -247,6 +248,7 @@ def score_options(
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
 
@@ -303,6 +305,8 @@ def score_options(
303
 
304
  # -- Edge case: no flights ----------------------------------------------
305
  if not flights:
 
 
306
  return []
307
 
308
  # -- Filter late arrivals -----------------------------------------------
@@ -311,6 +315,8 @@ def score_options(
311
 
312
  # -- Edge case: all flights are late ------------------------------------
313
  if not on_time_flights:
 
 
314
  return []
315
 
316
  # -- Build all valid (flight, hotel) combinations -----------------------
@@ -331,6 +337,8 @@ def score_options(
331
 
332
  # -- Edge case: all prices are None -------------------------------------
333
  if not scored:
 
 
334
  return []
335
 
336
  # -- Normalise each dimension -------------------------------------------
 
31
  from typing import Literal
32
  from zoneinfo import ZoneInfo
33
 
34
+ from matchday.errors import FailoverReason
35
  from matchday.models import Flight, Hotel, ScoredPackage, Weather
36
 
37
 
 
248
  weather: list[Weather],
249
  match_date: date,
250
  budget_tier: str = "mid_range",
251
+ reason_out: list | None = None,
252
  ) -> list[ScoredPackage]:
253
  """Score and rank travel packages from candidate flights and hotels.
254
 
 
305
 
306
  # -- Edge case: no flights ----------------------------------------------
307
  if not flights:
308
+ if reason_out is not None:
309
+ reason_out.append(FailoverReason.SCORING_EMPTY)
310
  return []
311
 
312
  # -- Filter late arrivals -----------------------------------------------
 
315
 
316
  # -- Edge case: all flights are late ------------------------------------
317
  if not on_time_flights:
318
+ if reason_out is not None:
319
+ reason_out.append(FailoverReason.SCORING_ALL_LATE)
320
  return []
321
 
322
  # -- Build all valid (flight, hotel) combinations -----------------------
 
337
 
338
  # -- Edge case: all prices are None -------------------------------------
339
  if not scored:
340
+ if reason_out is not None:
341
+ reason_out.append(FailoverReason.SCORING_MISSING_PRICES)
342
  return []
343
 
344
  # -- Normalise each dimension -------------------------------------------
matchday/trip_tool.py CHANGED
@@ -386,13 +386,20 @@ async def build_trip_packages(trip_request: TripRequest) -> TripPackageResult:
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
 
386
  if weather_data is None:
387
  weather_data = []
388
 
389
+ score_reason: list = []
390
  scored_packages = scoring.score_options(
391
  flights=flights_data,
392
  hotels=hotels_data,
393
  weather=weather_data,
394
  match_date=trip_request.match_date,
395
  budget_tier=trip_request.budget_tier,
396
+ reason_out=score_reason,
397
  )
398
+ if not scored_packages and score_reason:
399
+ # Surface the SCORING_* taxonomy reason (N15) instead of an opaque
400
+ # 'failed' status, so the user learns WHY no packages could be formed
401
+ # (e.g. all flights land after kickoff vs. flights API was down).
402
+ degradation_notices.append(user_message_for(score_reason[0]))
403
 
404
  # ------------------------------------------------------------------
405
  # 5. Attach amenities to scored packages