diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 45aaf76..4cd7457 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,9 @@ jobs: python-version: "3.12" - name: Install dependencies - run: pip install -r requirements.txt + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt - name: Run tests - run: python -m pytest test_travel.py -v + run: python -m pytest -q diff --git a/app/engine/addressparse.py b/app/engine/addressparse.py new file mode 100644 index 0000000..2d2d963 --- /dev/null +++ b/app/engine/addressparse.py @@ -0,0 +1,185 @@ +"""Graceful US address parsing for free-form starting locations. + +Accepts comma-separated or run-on addresses and extracts street / city / +state / ZIP so Census geocoding can succeed without forcing users into a +single punctuation style. +""" + +from __future__ import annotations + +import re +from typing import TypedDict + +from app.engine.states import STATE_PATTERN_2LETTER, US_STATE_NAMES + +_STREET_SUFFIX = ( + r"(?:street|st|avenue|ave|boulevard|blvd|road|rd|drive|dr|lane|ln|" + r"court|ct|circle|cir|way|place|pl|terrace|ter|parkway|pkwy|" + r"highway|hwy|trail|trl|pike)\.?" +) + +_ZIP_RE = re.compile(r"\b(\d{5}(?:-\d{4})?)\b") + + +class AddressParts(TypedDict): + street: str + city: str + state: str + zip: str + formatted: str + + +def _extract_state(text: str) -> tuple[str, str]: + """Return (state_code, remainder_without_state).""" + best_len = 0 + best_code = "" + text_lower = text.lower() + for name, code in sorted(US_STATE_NAMES.items(), key=lambda x: -len(x[0])): + # Require word boundaries for short names like "dc" + pattern = r"\b" + re.escape(name) + r"\b" + if re.search(pattern, text_lower) and len(name) > best_len: + best_len = len(name) + best_code = code + + if best_code: + for full_name, code in US_STATE_NAMES.items(): + if code == best_code and len(full_name) == best_len: + remainder = re.sub( + r"\b" + re.escape(full_name) + r"\b", + "", + text, + count=1, + flags=re.IGNORECASE, + ) + return best_code, re.sub(r"\s{2,}", " ", remainder).strip(" ,") + return best_code, text + + state_match = re.search(STATE_PATTERN_2LETTER, text, flags=re.IGNORECASE) + if state_match: + code = state_match.group(1).upper() + remainder = re.sub( + r"\b" + re.escape(state_match.group(1)) + r"\b", + "", + text, + count=1, + flags=re.IGNORECASE, + ) + return code, re.sub(r"\s{2,}", " ", remainder).strip(" ,") + return "", text + + +def _split_street_city(remainder: str) -> tuple[str, str]: + """Split leftover text into (street, city) when possible.""" + remainder = remainder.strip(" ,") + if not remainder: + return "", "" + + # Street number + street name ending in a known suffix, then city + m = re.match( + rf"^(\d+\s+.{{1,80}}?\b{_STREET_SUFFIX})\s+(.+)$", + remainder, + flags=re.IGNORECASE, + ) + if m: + return m.group(1).strip(" ,"), m.group(2).strip(" ,") + + # Leading street number without recognized suffix: last token(s) = city + if re.match(r"^\d+", remainder): + tokens = remainder.split() + if len(tokens) >= 3: + # Prefer last 1 token as city; if last is short directional, take 2 + if len(tokens) >= 4 and tokens[-1].lower() in {"n", "s", "e", "w", "ne", "nw", "se", "sw"}: + return " ".join(tokens[:-2]), " ".join(tokens[-2:]) + return " ".join(tokens[:-1]), tokens[-1] + if len(tokens) == 2: + return tokens[0], tokens[1] + + # No street number — treat whole remainder as city / place name + return "", remainder + + +def parse_us_address(raw: str) -> AddressParts: + """Extract street, city, state, ZIP from free-form or structured input.""" + empty: AddressParts = { + "street": "", + "city": "", + "state": "", + "zip": "", + "formatted": (raw or "").strip(), + } + if not raw or not str(raw).strip(): + return empty + + text = re.sub(r"\s+", " ", str(raw).strip()) + text = text.replace(";", ",") + + zip_code = "" + zip_m = _ZIP_RE.search(text) + if zip_m: + zip_code = zip_m.group(1) + text = (text[: zip_m.start()] + " " + text[zip_m.end() :]).strip(" ,") + + state, remainder = _extract_state(text) + remainder = remainder.strip(" ,") + + street = "" + city = "" + + if "," in remainder: + parts = [p.strip() for p in remainder.split(",") if p.strip()] + if len(parts) >= 2: + # Heuristic: first part is street if it has a digit; else place,city + if re.search(r"\d", parts[0]): + street = parts[0] + city = parts[-1] + if len(parts) > 2 and not city: + city = parts[1] + else: + city = parts[-1] + street = ", ".join(parts[:-1]) if len(parts) > 1 else "" + if not re.search(r"\d", street): + # "Downtown, Orlando" style — keep last as city, drop street + city = parts[-1] + street = "" + elif len(parts) == 1: + street, city = _split_street_city(parts[0]) + else: + street, city = _split_street_city(remainder) + + # If state extraction ate everything and we only have zip, bail gracefully + formatted = format_address(street, city, state, zip_code) or str(raw).strip() + return { + "street": street, + "city": city, + "state": state, + "zip": zip_code, + "formatted": formatted, + } + + +def format_address(street: str, city: str, state: str, zip_code: str) -> str: + """Build a canonical ``street, city, ST ZIP`` string from parts.""" + chunks: list[str] = [] + if street: + chunks.append(street.strip()) + city_state = " ".join(x for x in [city.strip(), state.strip()] if x).strip() + if zip_code: + city_state = f"{city_state} {zip_code}".strip() if city_state else zip_code + if city_state: + chunks.append(city_state) + return ", ".join(chunks) + + +def normalize_us_address(raw: str) -> str: + """Return a comma-structured address when components can be inferred. + + Unparseable input is returned stripped unchanged (never raises). + """ + if not raw or not str(raw).strip(): + return raw + parts = parse_us_address(raw) + # Only rewrite when we confidently found state or zip — otherwise keep raw + # so hotel/landmark names are not mangled. + if parts["state"] or parts["zip"]: + return parts["formatted"] + return str(raw).strip() diff --git a/app/engine/calendar.py b/app/engine/calendar.py index 23a6800..51547f4 100644 --- a/app/engine/calendar.py +++ b/app/engine/calendar.py @@ -9,6 +9,7 @@ from __future__ import annotations import logging +import re from datetime import datetime, timedelta from typing import Any @@ -221,32 +222,26 @@ def _pick_event_date(plan: dict) -> datetime: def _parse_to_datetime(time_str: str, base_date: datetime) -> datetime | None: - """Parse a time string like '07:30 AM' or '08:00 AM +1' into a datetime.""" - import re + """Parse a free-form time string into a datetime on *base_date*.""" + from app.engine.timeparse import normalize_time, parse_time - time_str = time_str.strip() + time_str = (time_str or "").strip() if not time_str: return None - # Handle "+1" suffix for next-day events - is_next_day = "+1" in time_str - time_str = time_str.replace("+1", "").strip() - - m = re.match(r"(\d{1,2}):(\d{2})\s*(AM|PM)", time_str, re.IGNORECASE) - if not m: + # Handle "+1" / "+N" suffix for next-day events (also preserved by normalize) + is_next_day = bool(re.search(r"\+\d+", time_str)) + normalized = normalize_time(time_str) + hour, minute = parse_time(normalized, default=(-1, -1)) + if hour < 0: return None - hour = int(m.group(1)) - minute = int(m.group(2)) - meridiem = m.group(3).upper() - - if meridiem == "PM" and hour < 12: - hour += 12 - if meridiem == "AM" and hour == 12: - hour = 0 - - result = base_date.replace(hour=hour, minute=minute, second=0, microsecond=0) - if is_next_day: + result = base_date.replace(hour=hour % 24, minute=minute, second=0, microsecond=0) + # Day offsets beyond +1 from normalize_time's fmt (+N) — apply full offset + day_m = re.search(r"\+(\d+)", normalized) + if day_m: + result += timedelta(days=int(day_m.group(1))) + elif is_next_day: result += timedelta(days=1) return result diff --git a/app/engine/osrm.py b/app/engine/osrm.py index ac84117..c7bcd76 100644 --- a/app/engine/osrm.py +++ b/app/engine/osrm.py @@ -427,41 +427,31 @@ def _geocode_census(addr: str) -> tuple[float, float] | None: Handles structured US addresses (street, city, state, zip) that Nominatim often fails on. No API key, no rate limit. + Free-form input without commas (e.g. ``9801 International Dr Orlando FL + 32819``) is normalized via :func:`parse_us_address` before the Census + request. + Returns (lat, lon) or None. """ import json import urllib.request import urllib.parse - # Parse address components from the input string. - parts = [p.strip() for p in addr.split(",")] - if len(parts) < 2: - return None + from app.engine.addressparse import parse_us_address - street = parts[0] - zip_match = re.search(r'\b(\d{5}(?:-\d{4})?)\b', parts[-1]) - zip_code = zip_match.group(1) if zip_match else "" - has_street_number = bool(re.search(r'\d', parts[0])) - if not has_street_number and not zip_code: - return None - state, _remainder_state = _extract_state(parts[-1]) - if len(parts) >= 3: - city = parts[-2].strip() - elif len(parts) == 2 and state: - if re.search(r'\d', parts[0]): - remainder = re.sub(r'\b\d{5}(?:-\d{4})?\b', '', parts[1]) - remainder = re.sub(r'\b[A-Za-z]{2}\b', '', remainder) - city = remainder.strip().rstrip(",").strip() - else: - city = parts[0].strip() - street = "" - else: - city = "" + parts = parse_us_address(addr) + street = parts["street"] + city = parts["city"] + state = parts["state"] + zip_code = parts["zip"] if not city or not state: return None - has_street = bool(re.search(r'\d', street)) - if has_street and not zip_code: + # Census structured lookup works best with a street; city+state+zip is ok + # for place-level matches when street is absent. + if street and not zip_code: + # Keep prior behavior: street-level queries without ZIP are unreliable + # on Census — fall through to Nominatim. return None params = urllib.parse.urlencode({ @@ -479,7 +469,10 @@ def _geocode_census(addr: str) -> tuple[float, float] | None: data = json.loads(resp.read().decode()) if data.get("result", {}).get("addressMatches"): coords = data["result"]["addressMatches"][0]["coordinates"] - logger.info("Census geocoded: %r → (%f, %f)", addr[:60], coords["y"], coords["x"]) + logger.info( + "Census geocoded: %r → (%f, %f) [street=%r city=%r state=%r zip=%r]", + addr[:60], coords["y"], coords["x"], street, city, state, zip_code, + ) return (coords["y"], coords["x"]) return None except Exception: @@ -533,6 +526,11 @@ def _osrm_transit(origin: str, destination: str) -> dict[str, str] | None: import json import time + from app.engine.addressparse import normalize_us_address + + origin = normalize_us_address(origin) + destination = normalize_us_address(destination) + # ── Step 1a: Geocode origin to determine country context ──────────── origin_coord = _geocode_census(origin) if not origin_coord: diff --git a/app/engine/planner.py b/app/engine/planner.py index a13f5b9..5143d03 100644 --- a/app/engine/planner.py +++ b/app/engine/planner.py @@ -17,6 +17,10 @@ from dateutil.parser import ParserError from app.engine import crowd, currency, search, weather +from app.engine.addressparse import normalize_us_address +from app.engine.timeparse import fmt_time as _fmt_time +from app.engine.timeparse import normalize_time as _normalize_departure_time +from app.engine.timeparse import parse_time as _parse_time from app.llm import deepseek_client logger = logging.getLogger("plan-it.planner") @@ -64,6 +68,9 @@ def build_travel_plan( venue = search.search_venue_info(search_venue_name) # 3. Build the route legs (pass departure_time for meal-label context) + # Normalize free-form starting addresses so Census geocoding gets structure. + if starting_location: + starting_location = normalize_us_address(starting_location) route = _build_route(intent, venue, starting_location, user_input, departure_time=departure_time) @@ -1315,83 +1322,6 @@ def _check_traffic_warning(step: str) -> str | None: return None -def _parse_time(time_str: str) -> tuple[int, int]: - m = re.match(r'(\d{1,2}):(\d{2})\s*(AM|PM)', time_str, re.IGNORECASE) - if not m: - return (8, 0) - h = int(m.group(1)) - minute = int(m.group(2)) - meridiem = m.group(3).upper() - if meridiem == "PM" and h < 12: - h += 12 - if meridiem == "AM" and h == 12: - h = 0 - return (h, minute) - - -def _fmt_time(hour: int, minute: int) -> str: - """Format a 24-hour hour and minute as a 12-hour clock string. - - Handles overflow beyond 24 hours by appending a `` +N`` day-offset - suffix (e.g. ``02:48 AM +1`` for hour=26). Previously, the function - only subtracted 24 once, so hours ≥ 48 (e.g. a two-day drive with - rest stops) produced invalid times like ``14:48 PM``. - """ - day_offset = hour // 24 - hour = hour % 24 - meridiem = "AM" - if hour >= 12: - meridiem = "PM" - display_h = hour if hour <= 12 else hour - 12 - if display_h == 0: - display_h = 12 - suffix = f" +{day_offset}" if day_offset > 0 else "" - return f"{display_h:02d}:{minute:02d} {meridiem}{suffix}" - - -# --------------------------------------------------------------------------- -# Default generators -# --------------------------------------------------------------------------- - - -def _normalize_departure_time(raw: str) -> str: - """Normalize a user-entered departure time string to 'HH:MM AM/PM' format. - - Accepts '7:00 AM', '07:00', '7am', '0700', '7:00AM', etc. - Returns the normalized time string or the raw input if unparseable. - """ - stripped = raw.strip().upper() - # Try "H:MM AM/PM" or "HH:MM AM/PM" - m = re.match(r'(\d{1,2}):(\d{2})\s*(AM|PM)?', stripped) - if m: - hour = int(m.group(1)) - minute = int(m.group(2)) - meridiem = m.group(3) or ('AM' if hour < 12 else 'PM') - # Convert to 24-hour for _fmt_time: - # - 12:xx AM → hour 0 (midnight) - # - 12:xx PM → hour 12 (noon, unchanged) - # - AM otherwise → hour unchanged - # - PM otherwise → hour + 12 - if meridiem == 'AM' and hour == 12: - hour = 0 - elif meridiem == 'PM' and hour != 12: - hour += 12 - return _fmt_time(hour, minute) - # Try "7am", "7 AM", "07:00AM" etc without minutes - m = re.match(r'(\d{1,2})\s*(AM|PM)', stripped) - if m: - hour = int(m.group(1)) - meridiem = m.group(2) - return _fmt_time(hour if meridiem == 'AM' or hour == 12 else hour + 12, 0) - # Try bare hour like "7", "14" - m = re.match(r'^(\d{1,2})$', stripped) - if m: - hour = int(m.group(1)) - return _fmt_time(hour, 0) - # Return as-is; _parse_time will handle fallback - return raw - - def _default_flights(origin: str, destination: str) -> list[dict[str, str]]: return [ { diff --git a/app/engine/timeparse.py b/app/engine/timeparse.py new file mode 100644 index 0000000..b95e67b --- /dev/null +++ b/app/engine/timeparse.py @@ -0,0 +1,130 @@ +"""Shared free-form time parsing used by planner, calendar, and API schemas. + +Normalizes user-entered times to canonical ``HH:MM AM/PM`` (with optional +`` +N`` day offset from ``fmt_time`` overflow handling). +""" + +from __future__ import annotations + +import re + + +def to_24h(hour: int, meridiem: str | None) -> int | None: + """Convert hour + optional AM/PM to 24-hour clock, or None if invalid.""" + if meridiem: + if hour < 1 or hour > 12: + return None + if meridiem == "AM" and hour == 12: + return 0 + if meridiem == "PM" and hour != 12: + return hour + 12 + return hour + if hour < 0 or hour > 23: + return None + return hour + + +def fmt_time(hour: int, minute: int) -> str: + """Format a 24-hour hour and minute as a 12-hour clock string. + + Handles overflow beyond 24 hours by appending a `` +N`` day-offset + suffix (e.g. ``02:48 AM +1`` for hour=26). + """ + day_offset = hour // 24 + hour = hour % 24 + meridiem = "AM" + if hour >= 12: + meridiem = "PM" + display_h = hour if hour <= 12 else hour - 12 + if display_h == 0: + display_h = 12 + suffix = f" +{day_offset}" if day_offset > 0 else "" + return f"{display_h:02d}:{minute:02d} {meridiem}{suffix}" + + +def normalize_time(raw: str) -> str: + """Normalize a user-entered time string to ``HH:MM AM/PM`` format. + + Accepts ``7:00 AM``, ``07:00``, ``7am``, ``0700``, ``0800 AM``, + ``7:00AM``, bare hour integers, and similar free-form variants. + Returns the normalized time string or the raw input if unparseable. + """ + if raw is None: + return raw + stripped = re.sub(r"[.\u00b7]", ":", str(raw).strip().upper()) + stripped = re.sub(r"\s+", " ", stripped).strip() + # Drop day-offset suffixes for parsing; re-apply via fmt when needed. + extra_hours = 0 + day_m = re.search(r"\s*\+(\d+)\s*$", stripped) + if day_m: + extra_hours = 24 * int(day_m.group(1)) + stripped = stripped[: day_m.start()].strip() + + def _finish(hour: int, minute: int, meridiem: str | None) -> str | None: + if minute < 0 or minute > 59: + return None + hour_24 = to_24h(hour, meridiem) + if hour_24 is None: + return None + return fmt_time(hour_24 + extra_hours, minute) + + m = re.match(r"^(\d{1,2}):(\d{2})\s*(AM|PM)?$", stripped) + if m: + result = _finish(int(m.group(1)), int(m.group(2)), m.group(3)) + if result: + return result + + m = re.match(r"^(\d{3,4})\s*(AM|PM)?$", stripped) + if m: + digits = m.group(1) + meridiem = m.group(2) + if len(digits) == 3: + hour, minute = int(digits[0]), int(digits[1:]) + else: + hour, minute = int(digits[:2]), int(digits[2:]) + result = _finish(hour, minute, meridiem) + if result: + return result + + m = re.match(r"^(\d{1,2})\s*(AM|PM)$", stripped) + if m: + result = _finish(int(m.group(1)), 0, m.group(2)) + if result: + return result + + m = re.match(r"^(\d{1,2})$", stripped) + if m: + result = _finish(int(m.group(1)), 0, None) + if result: + return result + + return str(raw) + + +def parse_time(time_str: str, *, default: tuple[int, int] = (8, 0)) -> tuple[int, int]: + """Parse a free-form time to ``(hour_24, minute)``. + + Uses :func:`normalize_time` first so compact forms like ``0800 AM`` work. + Falls back to *default* when unparseable. + """ + if not time_str or not str(time_str).strip(): + return default + + normalized = normalize_time(time_str) + m = re.match(r"(\d{1,2}):(\d{2})\s*(AM|PM)", normalized, re.IGNORECASE) + if not m: + return default + + h = int(m.group(1)) + minute = int(m.group(2)) + meridiem = m.group(3).upper() + hour_24 = to_24h(h, meridiem) + if hour_24 is None: + return default + return (hour_24, minute) + + +# Back-compat aliases used throughout the planner +_fmt_time = fmt_time +_normalize_departure_time = normalize_time +_parse_time = parse_time diff --git a/app/schemas/itinerary.py b/app/schemas/itinerary.py index 72fb628..de15bdf 100644 --- a/app/schemas/itinerary.py +++ b/app/schemas/itinerary.py @@ -108,10 +108,19 @@ def walking_map_url_must_be_valid(cls, v: str | None) -> str | None: @field_validator("time") @classmethod def time_must_look_like_clock(cls, v: str) -> str: - """Lightweight check that the time string is plausible.""" - if len(v) < 5: - raise ValueError(f"time must be at least 5 characters (e.g. '07:30 AM'), got: {v}") - return v + """Normalize free-form times; reject only if unparseable.""" + import re + + from app.engine.timeparse import normalize_time + + if not v or not str(v).strip(): + raise ValueError("time must not be empty") + normalized = normalize_time(v.strip()) + if not re.match(r"^\d{1,2}:\d{2}\s*(AM|PM)", normalized, re.IGNORECASE): + raise ValueError( + f"time could not be parsed (e.g. '07:30 AM', '0800', '8am'), got: {v}" + ) + return normalized class RentalCar(BaseModel): @@ -220,5 +229,14 @@ class TravelPlan(BaseModel): @field_validator("departure_time") @classmethod def departure_time_strip(cls, v: str) -> str: - """Strip whitespace from departure_time.""" - return v.strip() if v else v + """Strip and normalize departure_time when present.""" + import re + + from app.engine.timeparse import normalize_time + + if not v: + return v + normalized = normalize_time(v.strip()) + if re.match(r"^\d{1,2}:\d{2}\s*(AM|PM)", normalized, re.IGNORECASE): + return normalized + return v.strip() diff --git a/app/schemas/requests.py b/app/schemas/requests.py index 855a386..4709309 100644 --- a/app/schemas/requests.py +++ b/app/schemas/requests.py @@ -5,6 +5,9 @@ from pydantic import BaseModel, Field, model_validator +from app.engine.addressparse import normalize_us_address +from app.engine.timeparse import normalize_time + # Patterns that indicate prompt injection or system-instruction override attempts. # These are rejected at the API boundary before any processing occurs. _PROMPT_INJECTION_PATTERNS: list[re.Pattern] = [ @@ -37,8 +40,11 @@ class TravelRequest(BaseModel): min_length=1, max_length=500, description="Where the trip starts from (e.g. home address, hotel name). " - "If omitted, the planner will flag it as unspecified rather than guessing.", - examples=["Hyatt Regency Orlando, 9801 International Dr"], + "Free-form US addresses are accepted (commas optional) and normalized when possible.", + examples=[ + "Hyatt Regency Orlando, 9801 International Dr", + "9801 International Dr Orlando FL 32819", + ], ) restaurant_preferences: Optional[str] = Field( default=None, @@ -59,29 +65,30 @@ class TravelRequest(BaseModel): default=None, min_length=1, max_length=30, - pattern=r"^(0?[1-9]|1[0-2]):[0-5]\d\s+(AM|PM)$", - description="User-defined departure time in 12-hour format (e.g. '7:00 AM', '08:00 AM', '6:30 PM'). " - "Hour must be 1-12, minute must be 00-59, and AM/PM suffix is required. " - "If omitted, the departure time will be left blank in the itinerary.", - examples=["7:00 AM", "08:00 AM", "6:30 PM"], + description="User-defined departure time. Accepts free-form values such as " + "'7:00 AM', '07:00', '7am', '0700', or '0800 AM' and normalizes to " + "'HH:MM AM/PM'. If omitted, the departure time is left blank.", + examples=["7:00 AM", "08:00 AM", "0800 AM", "6:30 PM"], ) @model_validator(mode="after") def strip_and_validate(self) -> "TravelRequest": - """Strip leading/trailing whitespace and reject obviously bogus input.""" + """Strip, normalize free-form fields, and reject injection attempts.""" self.input = self.input.strip() if self.starting_location is not None: - self.starting_location = self.starting_location.strip() + self.starting_location = normalize_us_address(self.starting_location.strip()) if self.restaurant_preferences is not None: self.restaurant_preferences = self.restaurant_preferences.strip() if self.departure_time is not None: - self.departure_time = self.departure_time.strip() + normalized = normalize_time(self.departure_time.strip()) + if not re.match(r"^\d{1,2}:\d{2}\s*(AM|PM)", normalized, re.IGNORECASE): + raise ValueError( + "departure_time could not be parsed — try formats like " + "'7:00 AM', '0700', or '8am'" + ) + self.departure_time = normalized if not self.input: raise ValueError("input must not be empty or whitespace-only") - # Reject prompt-injection / system-override attempts before any - # processing. This protects the planner from malicious input that - # tries to override system instructions. All user-supplied free-text - # fields are checked, not just ``input``. if _contains_injection(self.input): raise ValueError("input contains disallowed content") if self.starting_location and _contains_injection(self.starting_location): diff --git a/static/app.html b/static/app.html index 47d658e..b0f7d80 100644 --- a/static/app.html +++ b/static/app.html @@ -124,7 +124,7 @@