Files
avc-phone-ai/practice.py
tocmo0nlord 1aff7ea2fc Add deterministic geography: precomputed nearest-office distances injected per turn
The 2026-07-05 Weston call (CA3e88ce) showed the model guessing geography: it
recommended Boca Raton for a Weston caller (real answer: Pembroke Pines, ~9 mi),
invented distances ('Weston to Hialeah is twenty miles'), and confirmed a
nonexistent Miami Lakes office when the caller insisted.

- practice.py: office lat/lon + ~70-place South Florida gazetteer + haversine;
  geography_block() renders a full distance table (closest first) from the
  caller's area to every office. 'Westin' aliased to Weston (STT spelling).
- callstate.py: extractor gains caller_area; CallStateGroomer appends the
  GEOGRAPHY block to the system message — zero-lag via a synchronous gazetteer
  scan of user turns until the extractor catches up.
- bot.py: DISTANCES prompt rule (answer only from GEOGRAPHY, never estimate),
  only-these-8-offices guard, caller-area cities added to Whisper hotwords
  ('Weston' was heard as 'Western Florida').

Verified: staged replay of the failed exchanges on activeblue-avc:latest —
correct office, correct distances, Miami Lakes denied (5/5 + 3/3 runs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:15:21 +00:00

354 lines
15 KiB
Python

"""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 <city>" 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)