Spaces:
Sleeping
Sleeping
File size: 6,184 Bytes
9168b1b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 | """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}
|