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}