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>
This commit is contained in:
16
bot.py
16
bot.py
@@ -103,7 +103,11 @@ WHISPER_COMPUTE = os.environ.get("WHISPER_COMPUTE", "float16")
|
|||||||
WHISPER_HOTWORDS = os.environ.get(
|
WHISPER_HOTWORDS = os.environ.get(
|
||||||
"WHISPER_HOTWORDS",
|
"WHISPER_HOTWORDS",
|
||||||
"Advanced Vision Care, eye exam, annual exam, appointment, optometry, contact lens, "
|
"Advanced Vision Care, eye exam, annual exam, appointment, optometry, contact lens, "
|
||||||
"Hialeah, Kendall, Tamarac, Pembroke Pines, Lauderdale Lakes, Miami Gardens, Boca Raton",
|
"Hialeah, Kendall, Tamarac, Pembroke Pines, Lauderdale Lakes, Miami Gardens, Boca Raton, "
|
||||||
|
# Common caller-area cities (not offices) — the 2026-07-05 call heard "Weston" as
|
||||||
|
# "Western Florida" repeatedly, which broke the nearest-office flow.
|
||||||
|
"Weston, Davie, Miramar, Plantation, Sunrise, Hollywood, Coral Springs, Doral, "
|
||||||
|
"Miami Lakes, Aventura, Coral Gables, Homestead, Cutler Bay",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Twilio sends 8 kHz mu-law on the wire, but faster-whisper assumes any numpy array is
|
# Twilio sends 8 kHz mu-law on the wire, but faster-whisper assumes any numpy array is
|
||||||
@@ -245,7 +249,15 @@ SYSTEM_PROMPT = (
|
|||||||
"that matches one of our offices (for example they say 'Kendall' and we have a Kendall "
|
"that matches one of our offices (for example they say 'Kendall' and we have a Kendall "
|
||||||
"office), simply confirm THAT office and move on — do NOT offer, compare, or mention any "
|
"office), simply confirm THAT office and move on — do NOT offer, compare, or mention any "
|
||||||
"other office or city, and do NOT ask them to choose between offices. Only if their area "
|
"other office or city, and do NOT ask them to choose between offices. Only if their area "
|
||||||
"matches no office should you name the single nearest one. List offices only if asked.\n"
|
"matches no office should you name the single nearest one. List offices only if asked. "
|
||||||
|
"The offices in PRACTICE FACTS are our ONLY offices — never agree we have an office "
|
||||||
|
"somewhere else, even if the caller insists you mentioned one.\n"
|
||||||
|
"- DISTANCES / CLOSEST OFFICE: when a GEOGRAPHY section appears below, answer distance and "
|
||||||
|
"closest-office questions from it word for word — it already lists approximate driving "
|
||||||
|
"miles from the caller's area to every office, closest first. If there is no GEOGRAPHY "
|
||||||
|
"section yet, ask what city or area they're in. NEVER estimate, compute, or guess a "
|
||||||
|
"distance or drive time yourself, and never claim an office is 'close to' or 'near' a "
|
||||||
|
"place unless the GEOGRAPHY section says so.\n"
|
||||||
"- INSURANCE — log only, never promise, never guess: ask open-endedly what insurance they "
|
"- INSURANCE — log only, never promise, never guess: ask open-endedly what insurance they "
|
||||||
"have (for example, 'What insurance do you have?'). Do NOT read out or suggest plan names "
|
"have (for example, 'What insurance do you have?'). Do NOT read out or suggest plan names "
|
||||||
"from the list — let the caller tell you. Capture ONLY what the caller actually says; NEVER "
|
"from the list — let the caller tell you. Capture ONLY what the caller actually says; NEVER "
|
||||||
|
|||||||
31
callstate.py
31
callstate.py
@@ -28,6 +28,8 @@ from loguru import logger
|
|||||||
from pipecat.frames.frames import BotStoppedSpeakingFrame, Frame, LLMContextFrame
|
from pipecat.frames.frames import BotStoppedSpeakingFrame, Frame, LLMContextFrame
|
||||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||||
|
|
||||||
|
import practice
|
||||||
|
|
||||||
# Short, in-call variant of the post-call extractor (extract.py): only what's needed to
|
# Short, in-call variant of the post-call extractor (extract.py): only what's needed to
|
||||||
# build the checklist, temperature 0, capped output. Runs on the local Ollama model.
|
# build the checklist, temperature 0, capped output. Runs on the local Ollama model.
|
||||||
_STATE_INSTRUCTIONS = (
|
_STATE_INSTRUCTIONS = (
|
||||||
@@ -54,6 +56,9 @@ _STATE_INSTRUCTIONS = (
|
|||||||
'"billing question"). Only \'an appointment\' with no visit reason is NOT a reason — '
|
'"billing question"). Only \'an appointment\' with no visit reason is NOT a reason — '
|
||||||
"use null then.\n"
|
"use null then.\n"
|
||||||
' "location": string or null — the office/city the caller wants\n'
|
' "location": string or null — the office/city the caller wants\n'
|
||||||
|
' "caller_area": string or null — the city/area where the CALLER themselves is located '
|
||||||
|
"or wants an office near (\"I'm in Weston\", \"what's closest to Davie\") — NOT an office "
|
||||||
|
"they merely asked about. Corrections win here too.\n"
|
||||||
' "patient_name": string or null — ONLY a name the caller explicitly gave as their own '
|
' "patient_name": string or null — ONLY a name the caller explicitly gave as their own '
|
||||||
"(\"my name is…\", \"this is…\", or answering a name question); NEVER a name guessed "
|
"(\"my name is…\", \"this is…\", or answering a name question); NEVER a name guessed "
|
||||||
"from other words, a garbled transcription, or a name only the assistant used\n"
|
"from other words, a garbled transcription, or a name only the assistant used\n"
|
||||||
@@ -252,12 +257,34 @@ class CallStateGroomer(FrameProcessor):
|
|||||||
)
|
)
|
||||||
self._task.add_done_callback(self._extract_done)
|
self._task.add_done_callback(self._extract_done)
|
||||||
|
|
||||||
|
def _geo_block(self, messages) -> str:
|
||||||
|
"""Precomputed office-distance table for the caller's area (see
|
||||||
|
practice.geography_block). Anchor = the LLM-extracted caller_area when we have
|
||||||
|
one (it understands context and corrections); before that (extraction lags one
|
||||||
|
turn) a synchronous gazetteer scan of the user's own words — first place that
|
||||||
|
isn't one of our office cities, since office names in caller speech are usually
|
||||||
|
about our offices, not where they live."""
|
||||||
|
state = self._state or {}
|
||||||
|
for key in ("caller_area", "location"):
|
||||||
|
v = state.get(key)
|
||||||
|
if isinstance(v, str) and v.strip():
|
||||||
|
anchor = practice.match_place(v)
|
||||||
|
if anchor:
|
||||||
|
return practice.geography_block(anchor)
|
||||||
|
hits = []
|
||||||
|
for m in messages:
|
||||||
|
if m.get("role") == "user" and isinstance(m.get("content"), str):
|
||||||
|
hits += practice.places_in_text(m["content"])
|
||||||
|
non_office = [h for h in hits if h not in practice.OFFICE_PLACE_KEYS]
|
||||||
|
anchor = (non_office or hits or [None])[0]
|
||||||
|
return practice.geography_block(anchor) if anchor else ""
|
||||||
|
|
||||||
def _groom_context(self):
|
def _groom_context(self):
|
||||||
messages = merge_consecutive_user_messages(list(self._context.messages))
|
messages = merge_consecutive_user_messages(list(self._context.messages))
|
||||||
block = build_state_block(self._state)
|
blocks = [b for b in (build_state_block(self._state), self._geo_block(messages)) if b]
|
||||||
for i, m in enumerate(messages):
|
for i, m in enumerate(messages):
|
||||||
if m.get("role") == "system":
|
if m.get("role") == "system":
|
||||||
content = self._base_system + ("\n\n" + block if block else "")
|
content = "\n\n".join([self._base_system, *blocks])
|
||||||
if m.get("content") != content:
|
if m.get("content") != content:
|
||||||
messages[i] = {**m, "content": content}
|
messages[i] = {**m, "content": content}
|
||||||
break
|
break
|
||||||
|
|||||||
207
practice.py
207
practice.py
@@ -16,18 +16,20 @@ from loguru import logger
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Real facts from advancedvisioncareflorida.com
|
# 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 = [
|
LOCATIONS = [
|
||||||
# Broward County
|
# Broward County
|
||||||
{"city": "Hollywood / Fort Lauderdale", "address": "2873 Stirling Rd, Fort Lauderdale, FL 33312", "phone": "(954) 983-4969"},
|
{"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"},
|
{"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"},
|
{"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"},
|
{"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
|
# Miami-Dade County
|
||||||
{"city": "Hialeah", "address": "1770 W 32nd Pl, Hialeah, FL 33012", "phone": "(305) 885-4477"},
|
{"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"},
|
{"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"},
|
{"city": "Miami Gardens", "address": "4771 NW 183rd St, Miami Gardens, FL 33055", "phone": "(305) 390-2467", "latlon": (25.942, -80.272)},
|
||||||
# Palm Beach County
|
# Palm Beach County
|
||||||
{"city": "Boca Raton", "address": "21673 State Road 7, Boca Raton, FL 33428", "phone": "(561) 470-2310"},
|
{"city": "Boca Raton", "address": "21673 State Road 7, Boca Raton, FL 33428", "phone": "(561) 470-2310", "latlon": (26.358, -80.202)},
|
||||||
]
|
]
|
||||||
|
|
||||||
PRACTICE_FACTS = {
|
PRACTICE_FACTS = {
|
||||||
@@ -94,6 +96,195 @@ def _find_location(name: str):
|
|||||||
return None
|
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) ──
|
# ─── Tools (used when ENABLE_TOOLS=true and the model supports tool-calling) ──
|
||||||
|
|
||||||
def persist_appointment(record: dict) -> str:
|
def persist_appointment(record: dict) -> str:
|
||||||
|
|||||||
Reference in New Issue
Block a user