Stage 4: honest health, explicit error policy, event-bus integrity
Engine mode is now explicit: USE_MOCK_SIP=true is the only way to get the mock engine; an unconfigured trunk fails startup with guidance instead of silently degrading. Root-caused why the engine always ran mock: nested pydantic-settings never read .env (no env_file on the sub-settings classes) — all 8 now declare it. /health stops lying: reports engine mode (sippy|mock), a live DB SELECT 1, trunk registration state with reason, and TTS/STT availability from their last real request; "healthy" now requires ready + db + sippy + registered trunk. Error policy: leaf services (tts/transcription/llm_client) raise and track availability; call-loop callers catch, publish EventType.ERROR naming the failed service, and apply an explicit fallback. Persistence writes get one bounded 3x exponential retry, then an ERROR log — no more silent data loss. Event bus: a full subscriber queue drops its oldest event (counted) instead of silently evicting the subscription; subscribe(replay_last=N) delivers the advertised history replay, used by /ws/events (25). Receptionist correctness: a matched TAKE_MESSAGE rule beats the LLM; voicemail polls for early hangup and stops/transcribes/hangs up in finally; RecordingSession finally keeps its leg_ids so taps detach. Dead code removed: models/contact.py + Contact table, dtmf_buffer, transcribe_stream stub, SMS stub in notification.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -203,6 +203,19 @@ class ReceptionistService:
|
||||
# State machine steps
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
async def _service_error(self, call_id: str, service: str, error: Exception) -> None:
|
||||
"""Surface a failed dependency as a typed event, not silence."""
|
||||
logger.error(f"⚠️ {service} failed for {call_id}: {error}")
|
||||
try:
|
||||
await self.gateway.event_bus.publish(GatewayEvent(
|
||||
type=EventType.ERROR,
|
||||
call_id=call_id,
|
||||
data={"service": service, "error": str(error)},
|
||||
message=f"⚠️ {service} failed: {error}",
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _greet(self, call: ActiveCall, sip_leg_id: str) -> None:
|
||||
await self.gateway.event_bus.publish(GatewayEvent(
|
||||
type=EventType.RECEPTIONIST_GREETING,
|
||||
@@ -250,7 +263,11 @@ class ReceptionistService:
|
||||
if not audio or self.transcription is None:
|
||||
return ""
|
||||
|
||||
return await self.transcription.transcribe(bytes(audio))
|
||||
try:
|
||||
return await self.transcription.transcribe(bytes(audio))
|
||||
except Exception as e:
|
||||
await self._service_error(call.id, "transcription", e)
|
||||
return ""
|
||||
|
||||
async def _classify(
|
||||
self,
|
||||
@@ -291,7 +308,7 @@ class ReceptionistService:
|
||||
system=self.settings.llm_persona,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Receptionist LLM classify failed: {e}")
|
||||
await self._service_error(call.id, "llm", e)
|
||||
return {
|
||||
"intent": transcript,
|
||||
"urgency": "normal",
|
||||
@@ -304,10 +321,14 @@ class ReceptionistService:
|
||||
routing_decision: Optional[RoutingDecision],
|
||||
classification: dict,
|
||||
) -> RoutingAction:
|
||||
"""Rules win on conflict; otherwise use the LLM's recommendation."""
|
||||
if routing_decision and routing_decision.action.type not in (
|
||||
RoutingActionType.TAKE_MESSAGE,
|
||||
):
|
||||
"""Rules win on conflict; otherwise use the LLM's recommendation.
|
||||
|
||||
A decision counts as a rule only when one actually matched
|
||||
(matched_rule_id set) — the no-rule default is take_message and
|
||||
must stay overridable by the LLM. A matched TAKE_MESSAGE rule
|
||||
wins like any other rule.
|
||||
"""
|
||||
if routing_decision and routing_decision.matched_rule_id:
|
||||
return routing_decision.action
|
||||
|
||||
recommended = (classification.get("recommended_action") or "ring").lower()
|
||||
@@ -347,36 +368,42 @@ class ReceptionistService:
|
||||
call.id, media_pipeline=media, leg_ids=[sip_leg_id]
|
||||
)
|
||||
try:
|
||||
await asyncio.sleep(self.settings.message_max_seconds)
|
||||
# Record up to the cap, but stop early once the caller hangs
|
||||
# up (leg termination ends the call via the leg-state wiring).
|
||||
deadline = _time.monotonic() + self.settings.message_max_seconds
|
||||
while _time.monotonic() < deadline:
|
||||
await asyncio.sleep(1.0)
|
||||
if self.gateway.call_manager.get_call(call.id) is None:
|
||||
break
|
||||
finally:
|
||||
session = await recording_svc.stop_recording(
|
||||
call.id, media_pipeline=media
|
||||
)
|
||||
|
||||
message_text = ""
|
||||
rec_path = session.filepath_mixed if session else None
|
||||
if rec_path and Path(rec_path).exists() and self.transcription is not None:
|
||||
try:
|
||||
audio_bytes = Path(rec_path).read_bytes()
|
||||
message_text = await self.transcription.transcribe(audio_bytes)
|
||||
except Exception as e:
|
||||
logger.warning(f"Receptionist transcribe failed: {e}")
|
||||
message_text = ""
|
||||
rec_path = session.filepath_mixed if session else None
|
||||
if rec_path and Path(rec_path).exists() and self.transcription is not None:
|
||||
try:
|
||||
audio_bytes = Path(rec_path).read_bytes()
|
||||
message_text = await self.transcription.transcribe(audio_bytes)
|
||||
except Exception as e:
|
||||
await self._service_error(call.id, "transcription", e)
|
||||
|
||||
if message_text:
|
||||
call.transcript_chunks.append(f"caller_message: {message_text}")
|
||||
if message_text:
|
||||
call.transcript_chunks.append(f"caller_message: {message_text}")
|
||||
|
||||
await self.gateway.event_bus.publish(GatewayEvent(
|
||||
type=EventType.RECEPTIONIST_MESSAGE_SAVED,
|
||||
call_id=call.id,
|
||||
data={
|
||||
"path": rec_path,
|
||||
"transcript": message_text,
|
||||
"caller": call.remote_number,
|
||||
},
|
||||
message=f"📥 Message saved from {call.remote_number}",
|
||||
))
|
||||
await self.gateway.event_bus.publish(GatewayEvent(
|
||||
type=EventType.RECEPTIONIST_MESSAGE_SAVED,
|
||||
call_id=call.id,
|
||||
data={
|
||||
"path": rec_path,
|
||||
"transcript": message_text,
|
||||
"caller": call.remote_number,
|
||||
},
|
||||
message=f"📥 Message saved from {call.remote_number}",
|
||||
))
|
||||
|
||||
await self._hangup(call, sip_leg_id)
|
||||
await self._hangup(call, sip_leg_id)
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -394,7 +421,11 @@ class ReceptionistService:
|
||||
fd, tmp_path = tempfile.mkstemp(suffix=".wav", prefix=f"recept_{call.id}_")
|
||||
os.close(fd)
|
||||
try:
|
||||
ok = await tts.synthesize_to_file(text, tmp_path)
|
||||
try:
|
||||
ok = await tts.synthesize_to_file(text, tmp_path)
|
||||
except Exception as e:
|
||||
await self._service_error(call.id, "tts", e)
|
||||
return
|
||||
if not ok:
|
||||
return
|
||||
await media.play_wav(sip_leg_id, tmp_path)
|
||||
|
||||
Reference in New Issue
Block a user