Spaces:
Sleeping
Sleeping
| """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} | |