CKSINGH commited on
Commit
1d9df4d
·
1 Parent(s): 3450950

Fix destination extraction and remove BKK default

Browse files
Files changed (1) hide show
  1. app/service.py +85 -172
app/service.py CHANGED
@@ -1,13 +1,11 @@
1
  from __future__ import annotations
2
 
3
- import json
4
  import logging
5
  import os
6
  import random
7
  import re
8
  import time
9
  import uuid
10
- from pathlib import Path
11
  from typing import Any, Dict, List, Literal, Optional, Tuple
12
 
13
  from pydantic import BaseModel, Field
@@ -15,17 +13,10 @@ from pydantic import BaseModel, Field
15
  LOGGER = logging.getLogger("flight-agent-api")
16
  logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper())
17
 
18
- DEFAULT_BUCKET_PATH = "/mnt/app-storage"
19
- DEFAULT_LOCAL_FALLBACK = "/tmp/flight-agent-data"
20
-
21
-
22
- # ============================================================
23
- # Models
24
- # ============================================================
25
 
26
  class TripRequest(BaseModel):
27
  origin: Optional[str] = None
28
- destination: str = "BKK"
29
  depart_date: Optional[str] = None
30
  return_date: Optional[str] = None
31
  adults: int = 1
@@ -90,91 +81,6 @@ class AgentCoreState(BaseModel):
90
  self.audit.append({"ts": time.time(), "kind": kind, "payload": payload})
91
 
92
 
93
- # ============================================================
94
- # Storage
95
- # ============================================================
96
-
97
-
98
- def _sanitize_thread_id(thread_id: str) -> str:
99
- value = re.sub(r"[^A-Za-z0-9_.-]", "_", thread_id or "anonymous")
100
- return value[:120] or "anonymous"
101
-
102
-
103
- class StorageManager:
104
- def __init__(self) -> None:
105
- requested = os.getenv("APP_STORAGE_PATH", DEFAULT_BUCKET_PATH).strip() or DEFAULT_BUCKET_PATH
106
- fallback = os.getenv("LOCAL_STORAGE_FALLBACK", DEFAULT_LOCAL_FALLBACK).strip() or DEFAULT_LOCAL_FALLBACK
107
- path, mode = self._resolve_storage_path(requested, fallback)
108
- self.base_path = path
109
- self.mode = mode
110
- self.sessions_dir = self.base_path / "sessions"
111
- self.logs_dir = self.base_path / "logs"
112
- self.cache_dir = self.base_path / "cache"
113
- for folder in (self.sessions_dir, self.logs_dir, self.cache_dir):
114
- folder.mkdir(parents=True, exist_ok=True)
115
- LOGGER.info("Storage initialized at %s (mode=%s)", self.base_path, self.mode)
116
-
117
- def _resolve_storage_path(self, requested: str, fallback: str) -> Tuple[Path, str]:
118
- requested_path = Path(requested)
119
- try:
120
- requested_path.mkdir(parents=True, exist_ok=True)
121
- probe = requested_path / ".write_test"
122
- probe.write_text("ok", encoding="utf-8")
123
- probe.unlink(missing_ok=True)
124
- if requested_path == Path(DEFAULT_BUCKET_PATH):
125
- return requested_path, "bucket"
126
- return requested_path, "custom"
127
- except Exception as exc: # pragma: no cover - defensive
128
- LOGGER.warning("Bucket/custom storage path unavailable (%s). Falling back to %s", exc, fallback)
129
- fallback_path = Path(fallback)
130
- fallback_path.mkdir(parents=True, exist_ok=True)
131
- return fallback_path, "local-fallback"
132
-
133
- def get_info(self) -> Dict[str, Any]:
134
- return {
135
- "base_path": str(self.base_path),
136
- "mode": self.mode,
137
- "sessions_dir": str(self.sessions_dir),
138
- "logs_dir": str(self.logs_dir),
139
- "cache_dir": str(self.cache_dir),
140
- }
141
-
142
- def _session_file(self, thread_id: str) -> Path:
143
- return self.sessions_dir / f"{_sanitize_thread_id(thread_id)}.json"
144
-
145
- def load_session(self, thread_id: str) -> Optional[AgentCoreState]:
146
- path = self._session_file(thread_id)
147
- if not path.exists():
148
- return None
149
- try:
150
- data = json.loads(path.read_text(encoding="utf-8"))
151
- return AgentCoreState.model_validate(data)
152
- except Exception as exc: # pragma: no cover - defensive
153
- LOGGER.warning("Failed to load session %s: %s", thread_id, exc)
154
- return None
155
-
156
- def save_session(self, thread_id: str, state: AgentCoreState) -> None:
157
- path = self._session_file(thread_id)
158
- path.write_text(json.dumps(state.model_dump(), ensure_ascii=False, indent=2), encoding="utf-8")
159
-
160
- def delete_session(self, thread_id: str) -> None:
161
- self._session_file(thread_id).unlink(missing_ok=True)
162
-
163
- def append_event(self, thread_id: str, kind: str, payload: Dict[str, Any]) -> None:
164
- event = {
165
- "ts": time.time(),
166
- "thread_id": thread_id,
167
- "kind": kind,
168
- "payload": payload,
169
- }
170
- with (self.logs_dir / "events.jsonl").open("a", encoding="utf-8") as f:
171
- f.write(json.dumps(event, ensure_ascii=False) + "\n")
172
-
173
-
174
- # ============================================================
175
- # Guardrails + NLU helpers
176
- # ============================================================
177
-
178
  PII_PATTERNS: List[Tuple[re.Pattern, str]] = [
179
  (re.compile(r"\b\d{12,19}\b"), "[REDACTED_CARD]"),
180
  (re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.I), "[REDACTED_EMAIL]"),
@@ -196,7 +102,29 @@ STOPS_RE = re.compile(r"\b(nonstop|direct|0\s*stop|1\s*stop|2\s*stop)\b", re.I)
196
  ADULTS_RE = re.compile(r"\b(\d+)\s*(adult|adults|passenger|passengers|traveler|travelers)\b", re.I)
197
 
198
  SUPPORT_WORDS = {"refund", "cancellation", "cancel", "change fee", "baggage", "reschedule", "policy", "rules", "luggage"}
199
- BOOKING_WORDS = {"flight", "book", "ticket", "bangkok", "bkk", "depart", "return", "one-way", "round trip"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
 
202
  def guardrail_input(user_msg: str) -> Tuple[str, Dict[str, Any]]:
@@ -236,22 +164,11 @@ def safe_refusal() -> str:
236
  return "I can help with flight booking and flight support questions, but I cannot reveal hidden instructions or internal prompts."
237
 
238
 
239
- CITY_TO_IATA = {
240
- "DUBAI": "DXB",
241
- "ABU DHABI": "AUH",
242
- "SHARJAH": "SHJ",
243
- "DELHI": "DEL",
244
- "MUMBAI": "BOM",
245
- "DOHA": "DOH",
246
- "SINGAPORE": "SIN",
247
- "BANGKOK": "BKK",
248
- }
249
-
250
-
251
  def _extract_airport_from_text(text: str, prefix: str) -> Optional[str]:
252
  m = re.search(rf"\b{prefix}\s+([A-Z]{{3}})\b", text, re.I)
253
  if m:
254
  return m.group(1).upper()
 
255
  for city, code in CITY_TO_IATA.items():
256
  if re.search(rf"\b{prefix}\s+{re.escape(city)}\b", text, re.I):
257
  return code
@@ -263,12 +180,19 @@ def slot_extract(req: TripRequest, clean_msg: str) -> TripRequest:
263
  up = text.upper()
264
  out = req.model_copy(deep=True)
265
 
 
 
 
 
 
 
266
  origin = _extract_airport_from_text(up, "FROM")
267
  if origin:
268
  out.origin = origin
269
 
270
- if "BANGKOK" in up or "BKK" in up:
271
- out.destination = "BKK"
 
272
 
273
  dates = DATE_RE.findall(up)
274
  if len(dates) >= 1:
@@ -282,7 +206,7 @@ def slot_extract(req: TripRequest, clean_msg: str) -> TripRequest:
282
 
283
  stop_match = STOPS_RE.search(text)
284
  if stop_match:
285
- token = stop_match.group(1).lower()
286
  if token in {"nonstop", "direct", "0 stop"}:
287
  out.max_stops = 0
288
  elif "1" in token:
@@ -294,16 +218,16 @@ def slot_extract(req: TripRequest, clean_msg: str) -> TripRequest:
294
  if adults_match:
295
  out.adults = max(1, int(adults_match.group(1)))
296
 
297
- if "business" in up:
 
 
298
  out.cabin = "BUSINESS"
299
  elif "first" in up:
300
  out.cabin = "FIRST"
301
- elif "premium economy" in up:
302
- out.cabin = "PREMIUM_ECONOMY"
303
  elif "economy" in up:
304
  out.cabin = "ECONOMY"
305
 
306
- if "checked" in up or "check-in" in up:
307
  out.baggage = "CHECKED"
308
  elif "no baggage" in up:
309
  out.baggage = "NONE"
@@ -322,7 +246,13 @@ def heuristic_intent(clean_msg: str) -> Tuple[str, str, float]:
322
  if any(k in m for k in SUPPORT_WORDS):
323
  return "TRAVEL_SUPPORT", "POLICY_EXPLAIN", 0.88
324
 
325
- if any(k in m for k in BOOKING_WORDS) or DATE_RE.search(m) or _extract_airport_from_text(m.upper(), "FROM"):
 
 
 
 
 
 
326
  return "TRAVEL_BOOKING", "FLIGHT_SEARCH", 0.84
327
 
328
  if any(k in m for k in ["hi", "hello", "hey"]):
@@ -335,23 +265,24 @@ def missing_slots(req: TripRequest) -> List[str]:
335
  miss: List[str] = []
336
  if not req.origin:
337
  miss.append("origin (for example DXB)")
 
 
338
  if not req.depart_date:
339
  miss.append("depart date in YYYY-MM-DD")
340
  return miss
341
 
342
 
343
- # ============================================================
344
- # Mock tools
345
- # ============================================================
346
-
347
  CARRIERS = ["EK", "QR", "EY", "TG", "SQ", "AI"]
 
348
 
349
 
350
  def make_mock_flight_options(req: TripRequest, k: int = 10) -> List[FlightOption]:
351
- if not req.origin or not req.depart_date:
352
  return []
353
 
354
- rng = random.Random(f"{req.origin}-{req.depart_date}-{req.max_price_usd}-{req.max_stops}-{req.cabin}")
 
 
355
  options: List[FlightOption] = []
356
 
357
  for _ in range(k):
@@ -367,7 +298,7 @@ def make_mock_flight_options(req: TripRequest, k: int = 10) -> List[FlightOption
367
  legs.append(
368
  FlightLeg(
369
  from_airport=req.origin,
370
- to_airport="BKK",
371
  depart_time=f"{req.depart_date}T09:{rng.randint(10, 59):02d}",
372
  arrive_time=f"{req.depart_date}T{rng.randint(14, 20):02d}:{rng.randint(10, 59):02d}",
373
  carrier=carrier,
@@ -375,7 +306,7 @@ def make_mock_flight_options(req: TripRequest, k: int = 10) -> List[FlightOption
375
  )
376
  )
377
  else:
378
- hub = rng.choice(["DOH", "SIN", "KUL", "BOM"])
379
  legs.append(
380
  FlightLeg(
381
  from_airport=req.origin,
@@ -389,7 +320,7 @@ def make_mock_flight_options(req: TripRequest, k: int = 10) -> List[FlightOption
389
  legs.append(
390
  FlightLeg(
391
  from_airport=hub,
392
- to_airport="BKK",
393
  depart_time=f"{req.depart_date}T{rng.randint(12, 16):02d}:{rng.randint(10, 59):02d}",
394
  arrive_time=f"{req.depart_date}T{rng.randint(16, 23):02d}:{rng.randint(10, 59):02d}",
395
  carrier=carrier,
@@ -452,14 +383,19 @@ def send_email(email: str, subject: str, body: str) -> Dict[str, Any]:
452
  }
453
 
454
 
455
- # ============================================================
456
- # Ranking + formatting
457
- # ============================================================
458
-
459
  POLICY_DOCS = [
460
- {"source_id": "policy_refund_1", "text": "Refund eligibility depends on fare brand. FLEX fares generally have lower change and cancellation penalties than LITE fares."},
461
- {"source_id": "policy_baggage_1", "text": "Checked baggage inclusion depends on fare brand and route. Cabin baggage is usually included unless the fare is very restrictive."},
462
- {"source_id": "policy_change_1", "text": "Changes may be permitted before departure subject to fare rules, price difference, and applicable service fees."},
 
 
 
 
 
 
 
 
 
463
  ]
464
 
465
 
@@ -506,37 +442,17 @@ def present_topk(state: AgentCoreState, options: List[FlightOption], k: int = 3)
506
  return "\n".join(lines)
507
 
508
 
509
- # ============================================================
510
- # Agent service
511
- # ============================================================
512
-
513
  class FlightBookingAgentService:
514
  def __init__(self) -> None:
515
  self.sessions: Dict[str, AgentCoreState] = {}
516
- self.storage = StorageManager()
517
-
518
- def get_storage_info(self) -> Dict[str, Any]:
519
- return self.storage.get_info()
520
 
521
  def _get_state(self, thread_id: str) -> AgentCoreState:
522
- if thread_id in self.sessions:
523
- return self.sessions[thread_id]
524
- loaded = self.storage.load_session(thread_id)
525
- if loaded is not None:
526
- self.sessions[thread_id] = loaded
527
- return loaded
528
- self.sessions[thread_id] = AgentCoreState()
529
  return self.sessions[thread_id]
530
 
531
- def _persist(self, thread_id: str, state: AgentCoreState, event_kind: Optional[str] = None) -> None:
532
- self.storage.save_session(thread_id, state)
533
- if event_kind:
534
- self.storage.append_event(thread_id, event_kind, {"audit_count": len(state.audit)})
535
-
536
  def reset_state(self, thread_id: str) -> None:
537
  self.sessions.pop(thread_id, None)
538
- self.storage.delete_session(thread_id)
539
- self.storage.append_event(thread_id, "session.reset", {})
540
 
541
  def get_state(self, thread_id: str) -> Dict[str, Any]:
542
  return self._get_state(thread_id).model_dump()
@@ -554,7 +470,7 @@ class FlightBookingAgentService:
554
  if meta.get("injection_suspected"):
555
  response = safe_refusal()
556
  core.log("refuse", {"reason": "prompt_injection"})
557
- return self._response(thread_id, response, core, "chat.refusal")
558
 
559
  parent, child, conf = heuristic_intent(clean_msg)
560
  if parent == "TRAVEL_BOOKING":
@@ -565,44 +481,44 @@ class FlightBookingAgentService:
565
 
566
  if core.booking_hold and core.booking_hold.status == "HELD" and core.user_contact.get("email"):
567
  response = self._confirm_booking(core)
568
- return self._response(thread_id, response, core, "chat.auto_confirm")
569
 
570
  if parent == "TRAVEL_SUPPORT":
571
  response = self._support(clean_msg, core)
572
- return self._response(thread_id, response, core, "chat.support")
573
 
574
  if parent == "TRAVEL_BOOKING" and child == "FLIGHT_BOOK_HOLD":
575
  response = self._create_hold(clean_msg, core)
576
- return self._response(thread_id, response, core, "chat.hold")
577
 
578
  if parent == "TRAVEL_BOOKING":
579
  response = self._search_flights(core)
580
- return self._response(thread_id, response, core, "chat.search")
581
 
582
  if parent == "SMALLTALK":
583
- response = "I can help book flights to Bangkok. Try: From DXB on 2026-07-10 under 650"
584
- return self._response(thread_id, response, core, "chat.smalltalk")
585
 
586
  response = "I can only help with flight booking or flight support in this demo."
587
- return self._response(thread_id, response, core, "chat.out_of_scope")
588
 
589
  def search(self, thread_id: str, req: TripRequest) -> Dict[str, Any]:
590
  core = self._get_state(thread_id)
591
  core.request = req
592
  response = self._search_flights(core)
593
- return self._response(thread_id, response, core, "api.search")
594
 
595
  def hold(self, thread_id: str, choice_index: int) -> Dict[str, Any]:
596
  core = self._get_state(thread_id)
597
  clean_msg = f"book {choice_index}"
598
  response = self._create_hold(clean_msg, core)
599
- return self._response(thread_id, response, core, "api.hold")
600
 
601
  def confirm(self, thread_id: str, email: str) -> Dict[str, Any]:
602
  core = self._get_state(thread_id)
603
  core.user_contact["email"] = email
604
  response = self._confirm_booking(core)
605
- return self._response(thread_id, response, core, "api.confirm")
606
 
607
  def _support(self, clean_msg: str, core: AgentCoreState) -> str:
608
  docs = retrieve_policy_notes(clean_msg, topk=2)
@@ -615,8 +531,8 @@ class FlightBookingAgentService:
615
  miss = missing_slots(core.request)
616
  if miss:
617
  msg = (
618
- f"I can help book your flight to Bangkok (BKK). Please provide: {', '.join(miss)}.\n"
619
- f"Example: From DXB on 2026-07-10 under 650"
620
  )
621
  core.log("search.ask_clarify", {"missing": miss})
622
  return msg
@@ -697,12 +613,9 @@ class FlightBookingAgentService:
697
  core.log("tool.send_email", email_res)
698
  return f"Booking confirmed for {email}. Confirmation reference: {core.booking_hold.hold_id}."
699
 
700
- def _response(self, thread_id: str, response: str, core: AgentCoreState, event_kind: str) -> Dict[str, Any]:
701
- payload = {
702
  "thread_id": thread_id,
703
  "response": response,
704
  "state": core.model_dump(),
705
- "storage": self.get_storage_info(),
706
  }
707
- self._persist(thread_id, core, event_kind)
708
- return payload
 
1
  from __future__ import annotations
2
 
 
3
  import logging
4
  import os
5
  import random
6
  import re
7
  import time
8
  import uuid
 
9
  from typing import Any, Dict, List, Literal, Optional, Tuple
10
 
11
  from pydantic import BaseModel, Field
 
13
  LOGGER = logging.getLogger("flight-agent-api")
14
  logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper())
15
 
 
 
 
 
 
 
 
16
 
17
  class TripRequest(BaseModel):
18
  origin: Optional[str] = None
19
+ destination: Optional[str] = None
20
  depart_date: Optional[str] = None
21
  return_date: Optional[str] = None
22
  adults: int = 1
 
81
  self.audit.append({"ts": time.time(), "kind": kind, "payload": payload})
82
 
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  PII_PATTERNS: List[Tuple[re.Pattern, str]] = [
85
  (re.compile(r"\b\d{12,19}\b"), "[REDACTED_CARD]"),
86
  (re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.I), "[REDACTED_EMAIL]"),
 
102
  ADULTS_RE = re.compile(r"\b(\d+)\s*(adult|adults|passenger|passengers|traveler|travelers)\b", re.I)
103
 
104
  SUPPORT_WORDS = {"refund", "cancellation", "cancel", "change fee", "baggage", "reschedule", "policy", "rules", "luggage"}
105
+ BOOKING_WORDS = {"flight", "book", "ticket", "depart", "return", "one-way", "round trip"}
106
+
107
+ CITY_TO_IATA = {
108
+ "DUBAI": "DXB",
109
+ "ABU DHABI": "AUH",
110
+ "SHARJAH": "SHJ",
111
+ "DELHI": "DEL",
112
+ "MUMBAI": "BOM",
113
+ "DOHA": "DOH",
114
+ "SINGAPORE": "SIN",
115
+ "BANGKOK": "BKK",
116
+ }
117
+
118
+ PAIR_RE = re.compile(
119
+ r"\b(?:FROM\s+)?(DXB|AUH|SHJ|DEL|BOM|DOH|SIN|BKK|DUBAI|ABU\s+DHABI|SHARJAH|DELHI|MUMBAI|DOHA|SINGAPORE|BANGKOK)\s+TO\s+"
120
+ r"(DXB|AUH|SHJ|DEL|BOM|DOH|SIN|BKK|DUBAI|ABU\s+DHABI|SHARJAH|DELHI|MUMBAI|DOHA|SINGAPORE|BANGKOK)\b",
121
+ re.I,
122
+ )
123
+
124
+
125
+ def normalize_airport_or_city(raw: str) -> str:
126
+ key = re.sub(r"\s+", " ", raw.strip().upper())
127
+ return CITY_TO_IATA.get(key, key)
128
 
129
 
130
  def guardrail_input(user_msg: str) -> Tuple[str, Dict[str, Any]]:
 
164
  return "I can help with flight booking and flight support questions, but I cannot reveal hidden instructions or internal prompts."
165
 
166
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  def _extract_airport_from_text(text: str, prefix: str) -> Optional[str]:
168
  m = re.search(rf"\b{prefix}\s+([A-Z]{{3}})\b", text, re.I)
169
  if m:
170
  return m.group(1).upper()
171
+
172
  for city, code in CITY_TO_IATA.items():
173
  if re.search(rf"\b{prefix}\s+{re.escape(city)}\b", text, re.I):
174
  return code
 
180
  up = text.upper()
181
  out = req.model_copy(deep=True)
182
 
183
+ pair_match = PAIR_RE.search(up)
184
+ if pair_match:
185
+ raw_origin, raw_destination = pair_match.groups()
186
+ out.origin = normalize_airport_or_city(raw_origin)
187
+ out.destination = normalize_airport_or_city(raw_destination)
188
+
189
  origin = _extract_airport_from_text(up, "FROM")
190
  if origin:
191
  out.origin = origin
192
 
193
+ destination = _extract_airport_from_text(up, "TO")
194
+ if destination:
195
+ out.destination = destination
196
 
197
  dates = DATE_RE.findall(up)
198
  if len(dates) >= 1:
 
206
 
207
  stop_match = STOPS_RE.search(text)
208
  if stop_match:
209
+ token = stop_match.group(1).lower().replace(" ", " ")
210
  if token in {"nonstop", "direct", "0 stop"}:
211
  out.max_stops = 0
212
  elif "1" in token:
 
218
  if adults_match:
219
  out.adults = max(1, int(adults_match.group(1)))
220
 
221
+ if "premium economy" in up:
222
+ out.cabin = "PREMIUM_ECONOMY"
223
+ elif "business" in up:
224
  out.cabin = "BUSINESS"
225
  elif "first" in up:
226
  out.cabin = "FIRST"
 
 
227
  elif "economy" in up:
228
  out.cabin = "ECONOMY"
229
 
230
+ if "checked" in up or "CHECK-IN" in up:
231
  out.baggage = "CHECKED"
232
  elif "no baggage" in up:
233
  out.baggage = "NONE"
 
246
  if any(k in m for k in SUPPORT_WORDS):
247
  return "TRAVEL_SUPPORT", "POLICY_EXPLAIN", 0.88
248
 
249
+ if (
250
+ any(k in m for k in BOOKING_WORDS)
251
+ or DATE_RE.search(m)
252
+ or _extract_airport_from_text(m.upper(), "FROM")
253
+ or _extract_airport_from_text(m.upper(), "TO")
254
+ or PAIR_RE.search(m.upper())
255
+ ):
256
  return "TRAVEL_BOOKING", "FLIGHT_SEARCH", 0.84
257
 
258
  if any(k in m for k in ["hi", "hello", "hey"]):
 
265
  miss: List[str] = []
266
  if not req.origin:
267
  miss.append("origin (for example DXB)")
268
+ if not req.destination:
269
+ miss.append("destination (for example BKK or Bangkok)")
270
  if not req.depart_date:
271
  miss.append("depart date in YYYY-MM-DD")
272
  return miss
273
 
274
 
 
 
 
 
275
  CARRIERS = ["EK", "QR", "EY", "TG", "SQ", "AI"]
276
+ HUBS = ["DOH", "SIN", "KUL", "BOM"]
277
 
278
 
279
  def make_mock_flight_options(req: TripRequest, k: int = 10) -> List[FlightOption]:
280
+ if not req.origin or not req.destination or not req.depart_date:
281
  return []
282
 
283
+ rng = random.Random(
284
+ f"{req.origin}-{req.destination}-{req.depart_date}-{req.max_price_usd}-{req.max_stops}-{req.cabin}"
285
+ )
286
  options: List[FlightOption] = []
287
 
288
  for _ in range(k):
 
298
  legs.append(
299
  FlightLeg(
300
  from_airport=req.origin,
301
+ to_airport=req.destination,
302
  depart_time=f"{req.depart_date}T09:{rng.randint(10, 59):02d}",
303
  arrive_time=f"{req.depart_date}T{rng.randint(14, 20):02d}:{rng.randint(10, 59):02d}",
304
  carrier=carrier,
 
306
  )
307
  )
308
  else:
309
+ hub = rng.choice([h for h in HUBS if h not in {req.origin, req.destination}] or HUBS)
310
  legs.append(
311
  FlightLeg(
312
  from_airport=req.origin,
 
320
  legs.append(
321
  FlightLeg(
322
  from_airport=hub,
323
+ to_airport=req.destination,
324
  depart_time=f"{req.depart_date}T{rng.randint(12, 16):02d}:{rng.randint(10, 59):02d}",
325
  arrive_time=f"{req.depart_date}T{rng.randint(16, 23):02d}:{rng.randint(10, 59):02d}",
326
  carrier=carrier,
 
383
  }
384
 
385
 
 
 
 
 
386
  POLICY_DOCS = [
387
+ {
388
+ "source_id": "policy_refund_1",
389
+ "text": "Refund eligibility depends on fare brand. FLEX fares generally have lower change and cancellation penalties than LITE fares.",
390
+ },
391
+ {
392
+ "source_id": "policy_baggage_1",
393
+ "text": "Checked baggage inclusion depends on fare brand and route. Cabin baggage is usually included unless the fare is very restrictive.",
394
+ },
395
+ {
396
+ "source_id": "policy_change_1",
397
+ "text": "Changes may be permitted before departure subject to fare rules, price difference, and applicable service fees.",
398
+ },
399
  ]
400
 
401
 
 
442
  return "\n".join(lines)
443
 
444
 
 
 
 
 
445
  class FlightBookingAgentService:
446
  def __init__(self) -> None:
447
  self.sessions: Dict[str, AgentCoreState] = {}
 
 
 
 
448
 
449
  def _get_state(self, thread_id: str) -> AgentCoreState:
450
+ if thread_id not in self.sessions:
451
+ self.sessions[thread_id] = AgentCoreState()
 
 
 
 
 
452
  return self.sessions[thread_id]
453
 
 
 
 
 
 
454
  def reset_state(self, thread_id: str) -> None:
455
  self.sessions.pop(thread_id, None)
 
 
456
 
457
  def get_state(self, thread_id: str) -> Dict[str, Any]:
458
  return self._get_state(thread_id).model_dump()
 
470
  if meta.get("injection_suspected"):
471
  response = safe_refusal()
472
  core.log("refuse", {"reason": "prompt_injection"})
473
+ return self._response(thread_id, response, core)
474
 
475
  parent, child, conf = heuristic_intent(clean_msg)
476
  if parent == "TRAVEL_BOOKING":
 
481
 
482
  if core.booking_hold and core.booking_hold.status == "HELD" and core.user_contact.get("email"):
483
  response = self._confirm_booking(core)
484
+ return self._response(thread_id, response, core)
485
 
486
  if parent == "TRAVEL_SUPPORT":
487
  response = self._support(clean_msg, core)
488
+ return self._response(thread_id, response, core)
489
 
490
  if parent == "TRAVEL_BOOKING" and child == "FLIGHT_BOOK_HOLD":
491
  response = self._create_hold(clean_msg, core)
492
+ return self._response(thread_id, response, core)
493
 
494
  if parent == "TRAVEL_BOOKING":
495
  response = self._search_flights(core)
496
+ return self._response(thread_id, response, core)
497
 
498
  if parent == "SMALLTALK":
499
+ response = "I can help book flights. Try: From DXB to BKK on 2026-07-10 under 650"
500
+ return self._response(thread_id, response, core)
501
 
502
  response = "I can only help with flight booking or flight support in this demo."
503
+ return self._response(thread_id, response, core)
504
 
505
  def search(self, thread_id: str, req: TripRequest) -> Dict[str, Any]:
506
  core = self._get_state(thread_id)
507
  core.request = req
508
  response = self._search_flights(core)
509
+ return self._response(thread_id, response, core)
510
 
511
  def hold(self, thread_id: str, choice_index: int) -> Dict[str, Any]:
512
  core = self._get_state(thread_id)
513
  clean_msg = f"book {choice_index}"
514
  response = self._create_hold(clean_msg, core)
515
+ return self._response(thread_id, response, core)
516
 
517
  def confirm(self, thread_id: str, email: str) -> Dict[str, Any]:
518
  core = self._get_state(thread_id)
519
  core.user_contact["email"] = email
520
  response = self._confirm_booking(core)
521
+ return self._response(thread_id, response, core)
522
 
523
  def _support(self, clean_msg: str, core: AgentCoreState) -> str:
524
  docs = retrieve_policy_notes(clean_msg, topk=2)
 
531
  miss = missing_slots(core.request)
532
  if miss:
533
  msg = (
534
+ f"I can help book your flight. Please provide: {', '.join(miss)}.\n"
535
+ f"Example: From DXB to BKK on 2026-07-10 under 650"
536
  )
537
  core.log("search.ask_clarify", {"missing": miss})
538
  return msg
 
613
  core.log("tool.send_email", email_res)
614
  return f"Booking confirmed for {email}. Confirmation reference: {core.booking_hold.hold_id}."
615
 
616
+ def _response(self, thread_id: str, response: str, core: AgentCoreState) -> Dict[str, Any]:
617
+ return {
618
  "thread_id": thread_id,
619
  "response": response,
620
  "state": core.model_dump(),
 
621
  }