"""Advanced Vision Care practice facts + the phone agent's tools. Facts sourced from advancedvisioncareflorida.com (8 locations across Broward, Miami-Dade, Palm Beach). NOTE: the website does NOT publish office hours, so we do NOT assert hours — the agent must offer to have staff confirm them instead of inventing them. Fill HOURS in if/when you have them. """ import json import os import re from datetime import datetime, timezone from loguru import logger # ───────────────────────────────────────────────────────────────────────────── # Real facts from advancedvisioncareflorida.com # ───────────────────────────────────────────────────────────────────────────── # "latlon" is block-level approximate (good enough for nearest-office ranking and # "about X miles" answers; never used for navigation). LOCATIONS = [ # Broward County {"city": "Hollywood / Fort Lauderdale", "address": "2873 Stirling Rd, Fort Lauderdale, FL 33312", "phone": "(954) 983-4969", "latlon": (26.047, -80.183)}, {"city": "Tamarac", "address": "5865 N University Dr, Tamarac, FL 33321", "phone": "(954) 720-2720", "latlon": (26.198, -80.253)}, {"city": "Pembroke Pines", "address": "246 S Flamingo Rd, Pembroke Pines, FL 33027", "phone": "(954) 443-1230", "latlon": (26.005, -80.312)}, {"city": "Lauderdale Lakes", "address": "3682 W Oakland Park Blvd, Lauderdale Lakes, FL 33311", "phone": "(954) 730-8087", "latlon": (26.166, -80.198)}, # Miami-Dade County {"city": "Hialeah", "address": "1770 W 32nd Pl, Hialeah, FL 33012", "phone": "(305) 885-4477", "latlon": (25.876, -80.300)}, {"city": "Kendall", "address": "11605 N Kendall Dr, Miami, FL 33176", "phone": "(305) 982-8927", "latlon": (25.686, -80.381)}, {"city": "Miami Gardens", "address": "4771 NW 183rd St, Miami Gardens, FL 33055", "phone": "(305) 390-2467", "latlon": (25.942, -80.272)}, # Palm Beach County {"city": "Boca Raton", "address": "21673 State Road 7, Boca Raton, FL 33428", "phone": "(561) 470-2310", "latlon": (26.358, -80.202)}, ] PRACTICE_FACTS = { "name": "Advanced Vision Care", "locations": LOCATIONS, "insurance": [ "CarePlus", "Doctors Health", "Florida Blue Medicare", "Optum", "Spectera", "Sunshine Health", "VSP", "WellCare", ], "services": ( "routine and medical eye exams, contact lens exams, pediatric eye exams, " "and LASIK consultations" ), # Website does not publish hours — leave None so the agent won't invent them. "hours": None, } REQUESTS_LOG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "appointment_requests.jsonl") # Expand street abbreviations so the TTS speaks "North Kendall Drive", not "N … D-R". _ABBREV = { "NW": "Northwest", "NE": "Northeast", "SW": "Southwest", "SE": "Southeast", "N": "North", "S": "South", "E": "East", "W": "West", "Dr": "Drive", "Rd": "Road", "Blvd": "Boulevard", "St": "Street", "Ave": "Avenue", "Pl": "Place", "Ln": "Lane", "Ct": "Court", "Hwy": "Highway", "FL": "Florida", } def _spoken_address(addr: str) -> str: """Expand directional + street-type abbreviations for natural speech.""" return re.sub( r"\b(" + "|".join(re.escape(k) for k in _ABBREV) + r")\b", lambda m: _ABBREV[m.group(1)], addr, ) def practice_summary() -> str: """Compact facts block for the system prompt.""" f = PRACTICE_FACTS loc_lines = "\n".join(f" - {l['city']}: {_spoken_address(l['address'])} — {l['phone']}" for l in f["locations"]) hours = f["hours"] or ( "NOT published — do not state specific hours; offer to have the office confirm." ) return ( f"Practice name: {f['name']}\n" f"Locations ({len(f['locations'])} offices across South Florida):\n{loc_lines}\n" f"Insurance accepted (these EXACT plans only): {', '.join(f['insurance'])}.\n" f"Services: {f['services']}\n" f"Hours: {hours}\n" ) def _find_location(name: str): """Loose match a caller's city/location text to a known office.""" if not name: return None n = name.lower() for l in LOCATIONS: if n in l["city"].lower() or l["city"].lower() in n: return l return None # ───────────────────────────────────────────────────────────────────────────── # Geography — deterministic nearest-office answers (no LLM, no external API). # # Added after the 2026-07-05 Weston call: the model guessed which office was close # ("Boca Raton is relatively close", "Weston to Hialeah is twenty miles" — invented) # and hallucinated a Miami Lakes office. Instead we precompute distances from the # caller's stated area to every office and inject them via CallStateGroomer, so the # model only ever repeats numbers it was handed. # # Coordinates are approximate residential centers — fine for ranking offices and # "about X miles", which is all a phone answer needs. # ───────────────────────────────────────────────────────────────────────────── GAZETTEER = { # Broward "weston": (26.080, -80.390), "davie": (26.076, -80.277), "southwest ranches": (26.059, -80.337), "cooper city": (26.057, -80.272), "plantation": (26.127, -80.250), "sunrise": (26.157, -80.300), "lauderhill": (26.164, -80.208), "tamarac": (26.203, -80.250), "north lauderdale": (26.217, -80.226), "margate": (26.245, -80.206), "coconut creek": (26.284, -80.184), "coral springs": (26.271, -80.271), "parkland": (26.310, -80.237), "pompano beach": (26.238, -80.125), "deerfield beach": (26.318, -80.100), "lighthouse point": (26.276, -80.087), "oakland park": (26.172, -80.132), "wilton manors": (26.160, -80.139), "fort lauderdale": (26.122, -80.137), "lauderdale lakes": (26.166, -80.208), "hollywood": (26.011, -80.150), "dania beach": (26.052, -80.144), "hallandale beach": (25.981, -80.148), "pembroke pines": (26.008, -80.297), "pembroke park": (25.988, -80.178), "west park": (25.984, -80.199), "miramar": (25.977, -80.303), # Miami-Dade "miami": (25.775, -80.194), "miami beach": (25.790, -80.130), "brickell": (25.758, -80.193), "little havana": (25.773, -80.215), "north miami": (25.890, -80.187), "north miami beach": (25.933, -80.163), "aventura": (25.956, -80.139), "sunny isles beach": (25.943, -80.122), "miami gardens": (25.942, -80.246), "opa-locka": (25.902, -80.250), "opa locka": (25.902, -80.250), "miami lakes": (25.909, -80.309), "hialeah": (25.858, -80.278), "hialeah gardens": (25.865, -80.324), "medley": (25.838, -80.332), "doral": (25.819, -80.355), "sweetwater": (25.763, -80.373), "fontainebleau": (25.772, -80.346), "westchester": (25.745, -80.327), "west miami": (25.757, -80.296), "coral gables": (25.721, -80.268), "south miami": (25.707, -80.293), "pinecrest": (25.667, -80.308), "kendall": (25.679, -80.355), "west kendall": (25.706, -80.439), "kendale lakes": (25.708, -80.407), "the hammocks": (25.670, -80.445), "tamiami": (25.758, -80.402), "country walk": (25.633, -80.432), "richmond west": (25.611, -80.430), "palmetto bay": (25.622, -80.320), "cutler bay": (25.575, -80.336), "homestead": (25.469, -80.478), "florida city": (25.448, -80.479), "key biscayne": (25.691, -80.163), # Palm Beach (southern) "boca raton": (26.359, -80.130), "west boca": (26.351, -80.210), "delray beach": (26.461, -80.073), "boynton beach": (26.525, -80.087), "lake worth": (26.617, -80.072), "wellington": (26.659, -80.241), "west palm beach": (26.715, -80.054), } _PLACE_ALIASES = { "westin": "weston", # frequent STT spelling of Weston (hotel-chain bias) "ft lauderdale": "fort lauderdale", "ft. lauderdale": "fort lauderdale", "kendall west": "west kendall", "hallandale": "hallandale beach", "sunny isles": "sunny isles beach", "the pines": "pembroke pines", } # Gazetteer keys that ARE one of our office cities (used to prefer the caller's own # area over office names they merely asked about). OFFICE_PLACE_KEYS = frozenset({ "hollywood", "fort lauderdale", "tamarac", "pembroke pines", "lauderdale lakes", "hialeah", "kendall", "miami gardens", "boca raton", }) # Which office a gazetteer key belongs to, for the "right in " special case. _OFFICE_FOR_KEY = { "hollywood": "Hollywood / Fort Lauderdale", "fort lauderdale": "Hollywood / Fort Lauderdale", "tamarac": "Tamarac", "pembroke pines": "Pembroke Pines", "lauderdale lakes": "Lauderdale Lakes", "hialeah": "Hialeah", "kendall": "Kendall", "miami gardens": "Miami Gardens", "boca raton": "Boca Raton", } # Longest names first so "north miami beach" wins over "north miami" over "miami". _PLACE_RE = re.compile( r"\b(" + "|".join( re.escape(k) for k in sorted(list(GAZETTEER) + list(_PLACE_ALIASES), key=len, reverse=True) ) + r")\b" ) # Straight-line → driving miles fudge factor for the South Florida grid. _ROAD_FACTOR = 1.3 def _haversine_miles(a, b) -> float: import math lat1, lon1, lat2, lon2 = map(math.radians, (*a, *b)) h = (math.sin((lat2 - lat1) / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin((lon2 - lon1) / 2) ** 2) return 3958.8 * 2 * math.asin(math.sqrt(h)) def _road_miles(a, b) -> int: return max(1, round(_haversine_miles(a, b) * _ROAD_FACTOR)) def match_place(text: str): """Canonical gazetteer key for a free-text place mention, or None.""" if not text: return None m = _PLACE_RE.search(text.lower()) if not m: return None k = m.group(1) return _PLACE_ALIASES.get(k, k) def places_in_text(text: str): """All gazetteer places mentioned in the text, canonical, in order of appearance.""" if not text: return [] return [_PLACE_ALIASES.get(k, k) for k in _PLACE_RE.findall(text.lower())] def geography_block(place_key: str) -> str: """Precomputed distance table from the caller's area to every office, closest first — injected into the system prompt so distance / nearest-office answers are read, never guessed. Returns "" for an unknown place.""" coords = GAZETTEER.get(place_key) if not coords: return "" pretty = place_key.title() ranked = sorted( ((l["city"], _road_miles(coords, l["latlon"])) for l in LOCATIONS), key=lambda x: x[1], ) table = "; ".join( f"{city} about {mi} miles" + (" (CLOSEST)" if i == 0 else "") for i, (city, mi) in enumerate(ranked) ) home = _OFFICE_FOR_KEY.get(place_key) if home: situated = f"We have an office right in {pretty} (the {home} office)." else: situated = (f"We do NOT have an office in {pretty}; the closest is " f"{ranked[0][0]}, about {ranked[0][1]} miles away.") return ( f"GEOGRAPHY (precomputed, approximate driving distances from {pretty} — the ONLY " f"source you may use for any distance or closest-office answer; NEVER estimate a " f"distance yourself and NEVER confirm an office at any place not in PRACTICE FACTS):\n" f"- {situated}\n" f"- From {pretty}: {table}." ) # ─── Tools (used when ENABLE_TOOLS=true and the model supports tool-calling) ── def persist_appointment(record: dict) -> str: """Write an appointment request to Odoo (a crm.lead) if configured, else to the JSONL fallback so a request is never lost. Returns where it landed. Used by both the post-call extraction and the (optional) in-call tool.""" record.setdefault("ts", datetime.now(timezone.utc).isoformat()) kind = record.get("kind", "appointment") if os.environ.get("ODOO_USER") and os.environ.get("ODOO_API_KEY"): try: from odoo_client import create_appointment_request if kind == "callback": reason = record.get("reason") or "callback request" else: reason = f"[{record.get('location') or 'location TBD'}] {record.get('reason') or ''}".strip() model, rec_id = create_appointment_request( patient_name=record.get("patient_name"), callback_number=record.get("callback_number"), reason=reason, preferred_time=record.get("preferred_time"), insurance=record.get("insurance"), call_sid=record.get("call_sid"), kind=kind, ) logger.info(f"{kind.capitalize()} -> Odoo {model} id={rec_id}: {record.get('patient_name')}") return f"odoo:{model}:{rec_id}" except Exception as e: logger.warning(f"Odoo write failed ({e!r}); falling back to local log") record["odoo_error"] = repr(e) with open(REQUESTS_LOG, "a") as fh: fh.write(json.dumps(record) + "\n") logger.info(f"{kind.capitalize()} -> JSONL: {record.get('patient_name')}") return "jsonl" async def record_appointment_request(params): """In-call tool path (only used when ENABLE_TOOLS=true). Wraps persist_appointment.""" args = params.arguments or {} persist_appointment({ "call_sid": getattr(params, "call_sid", None), "patient_name": args.get("patient_name"), "callback_number": args.get("callback_number"), "location": args.get("location"), "reason": args.get("reason"), "preferred_time": args.get("preferred_time"), "source": "in_call_tool", }) await params.result_callback( {"status": "captured", "message": "Got it — our staff will call you back to confirm the time."} ) async def get_practice_info(params): """Return practice facts (optionally narrowed to one location) for accurate answers.""" args = params.arguments or {} loc = _find_location(args.get("location", "")) result = { "name": PRACTICE_FACTS["name"], "insurance": PRACTICE_FACTS["insurance"], "services": PRACTICE_FACTS["services"], "hours": "not published — offer to have the office confirm", } result["location"] = loc if loc else PRACTICE_FACTS["locations"] await params.result_callback(result)