Hotel_App / modules /llm_parser.py
MiriamCamachodQ's picture
Upload 19 files
9168b1b verified
Raw
History Blame Contribute Delete
6.18 kB
"""Heuristic parser for free-form hotel search requests."""
from __future__ import annotations
import re
from copy import deepcopy
from datetime import date
from typing import Any
from .prompts import AMBIGUOUS_DATE_PHRASES, AMENITY_SYNONYMS, EXTRACTION_SCHEMA, REQUIRED_HINT_WORDS
ISO_DATE_RE = re.compile(r"\b\d{4}-\d{2}-\d{2}\b")
def _to_iso_if_valid(value: str) -> str | None:
# Validate and normalize ISO date strings to YYYY-MM-DD.
try:
parsed = date.fromisoformat(value)
return parsed.isoformat()
except ValueError:
return None
def _extract_location(text: str) -> str | None:
# Capture phrase after "in" and stop near known separators.
location_match = re.search(
r"\bin\s+([a-zA-Z][a-zA-Z\s,\-]+?)(?=\s+(?:from|check[- ]?in|on|under|below|budget|with|for|adults|children|party|$))",
text,
flags=re.IGNORECASE,
)
if location_match:
return location_match.group(1).strip(" ,.")
return None
def _extract_budget(text: str) -> float | None:
# Step 1: Prefer explicit budget cue words (under, below, max, budget).
budget_match = re.search(r"(?:under|below|max|budget)\s*\$?\s*(\d+(?:\.\d+)?)", text, flags=re.IGNORECASE)
if budget_match:
return float(budget_match.group(1))
# Step 2: Fallback to the first plain dollar amount.
dollar_match = re.search(r"\$\s*(\d+(?:\.\d+)?)", text)
if dollar_match:
return float(dollar_match.group(1))
return None
def _extract_party_fields(text: str) -> dict[str, int | None]:
# Parse optional occupancy details to enrich search context.
adults = None
children = None
party_size = None
adults_match = re.search(r"(\d+)\s+adults?", text, flags=re.IGNORECASE)
children_match = re.search(r"(\d+)\s+children?", text, flags=re.IGNORECASE)
party_match = re.search(r"(?:party size|for)\s*(\d+)", text, flags=re.IGNORECASE)
if adults_match:
adults = int(adults_match.group(1))
if children_match:
children = int(children_match.group(1))
if party_match:
party_size = int(party_match.group(1))
elif adults is not None or children is not None:
party_size = (adults or 0) + (children or 0)
return {"party_size": party_size, "adults": adults, "children": children}
def _normalize_amenity(token: str) -> str | None:
# Canonicalize amenity wording to stabilize matching/scoring.
clean = token.strip().lower()
if not clean:
return None
return AMENITY_SYNONYMS.get(clean, clean)
def _extract_amenities(text: str) -> tuple[list[str], list[str]]:
# Step 1: Initialize amenity buckets.
lowered = text.lower()
required: set[str] = set()
preferred: set[str] = set()
# Step 2: Choose required-mode if strong requirement hints are present.
required_mode = any(hint in lowered for hint in REQUIRED_HINT_WORDS)
for alias, canonical in AMENITY_SYNONYMS.items():
if alias in lowered:
if required_mode:
required.add(canonical)
else:
preferred.add(canonical)
# If user explicitly uses "preferred"/"nice to have", push matching amenities there.
# Step 3: If "preferred" language appears, force matched amenities into preferred bucket.
if "preferred" in lowered or "nice to have" in lowered:
for alias, canonical in AMENITY_SYNONYMS.items():
if alias in lowered:
preferred.add(canonical)
required.discard(canonical)
return sorted(required), sorted(preferred)
def parse_user_request(user_request: str) -> dict[str, Any]:
# Step 1: Start from schema defaults and prepare validation containers.
payload: dict[str, Any] = deepcopy(EXTRACTION_SCHEMA)
clarifications: list[str] = []
errors: list[str] = []
# Step 2: Short-circuit on empty input with a user-friendly clarification.
text = (user_request or "").strip()
if not text:
clarifications.append("Please describe what hotel stay you want, including city and exact dates in YYYY-MM-DD.")
return {"payload": payload, "clarifications": clarifications, "errors": errors}
# Step 3: Flag relative/ambiguous date phrases for clarification.
lowered = text.lower()
if any(phrase in lowered for phrase in AMBIGUOUS_DATE_PHRASES):
clarifications.append("Please provide exact check-in and check-out dates in YYYY-MM-DD (relative dates like 'next weekend' are ambiguous).")
# Step 4: Extract scalar and list fields from free-form text.
payload["location"] = _extract_location(text)
payload["budget_max_usd"] = _extract_budget(text)
payload.update(_extract_party_fields(text))
required, preferred = _extract_amenities(text)
payload["required_amenities"] = required
payload["preferred_amenities"] = preferred
# Step 5: Parse explicit ISO dates if present.
found_dates = ISO_DATE_RE.findall(text)
if len(found_dates) >= 2:
check_in_iso = _to_iso_if_valid(found_dates[0])
check_out_iso = _to_iso_if_valid(found_dates[1])
payload["check_in_date"] = check_in_iso
payload["check_out_date"] = check_out_iso
if check_in_iso is None or check_out_iso is None:
clarifications.append("I found date-like text, but it is not valid ISO format. Please use YYYY-MM-DD.")
else:
clarifications.append("Please provide check-in and check-out dates in YYYY-MM-DD.")
# Step 6: Require destination location.
if not payload["location"]:
clarifications.append("Please provide the destination location (city or area).")
# Step 7: Validate date order only when both dates exist.
check_in = payload.get("check_in_date")
check_out = payload.get("check_out_date")
if check_in and check_out and check_out <= check_in:
errors.append("Invalid date range: check_out_date must be later than check_in_date.")
return {"payload": payload, "clarifications": clarifications, "errors": errors}