M6 + M0: hardening, deploy docs, PoC scripts

- Pipeline-down fallback: dialplan plays gogo-fallback after a failed
  AudioSocket (§15) + script to synthesize the Bosnian prompt via TTS
- Voice heartbeat file → /health/voice endpoint + live status in admin panel
- Retention job also purges stale call registrations; chat sessions metered;
  100%-usage super-admin alert email (GOGO_ADMIN_ALERT_EMAIL)
- .env.example, docs/DEPLOY.md (compose stack, GPU node, backups pg_dump,
  update procedure, security notes)
- §16 Definition of Done as an automated test: softphone-style call → fake
  partner availability → request pushed → webhook confirm → console SMS
- M0 PoC scripts: STT accuracy harness (CER/WER + spoken-digit check; dry-run
  verified with espeak-ng samples + faster-whisper small on CPU — phone number
  extracted exactly), TTS bake-off (Azure vs ElevenLabs, latency+cost), and
  end-to-end latency smoke test speaking real AudioSocket to the live pipeline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 11:27:05 +02:00
parent de5a2abdeb
commit fd45ff84dc
16 changed files with 733 additions and 5 deletions

View File

@@ -772,8 +772,20 @@ async def health_page(
{"at": row.at, "kind": f"SMS/{row.kind}", "to": row.to_msisdn, "error": row.error}
)
# voice pipeline heartbeat (written by the M4 pipeline service)
voice_status, voice_badge = "nije pokrenut (M4)", "pending"
# voice pipeline heartbeat (written by the voice service, M6)
import os
import time as time_mod
from gogo.voice.server import heartbeat_path
try:
age = time_mod.time() - os.path.getmtime(heartbeat_path())
if age <= 60:
voice_status, voice_badge = f"radi (heartbeat prije {int(age)}s)", "confirmed"
else:
voice_status, voice_badge = f"NE RADI (heartbeat star {int(age)}s)", "rejected"
except OSError:
voice_status, voice_badge = "nije pokrenut", "pending"
return templates.TemplateResponse(
request,

View File

@@ -21,3 +21,21 @@ async def health():
async def health_db(session: AsyncSession = Depends(get_session)):
await session.execute(text("SELECT 1"))
return {"status": "ok"}
@router.get("/health/voice")
async def health_voice():
"""Voice pipeline liveness via its heartbeat file (shared volume)."""
import os
import time
from gogo.voice.server import heartbeat_path
path = heartbeat_path()
try:
age = time.time() - os.path.getmtime(path)
except OSError:
return {"status": "down", "detail": "no heartbeat file"}
if age > 60:
return {"status": "down", "detail": f"heartbeat {int(age)}s old"}
return {"status": "ok", "heartbeat_age_s": int(age)}

View File

@@ -191,5 +191,8 @@ async def _resume_or_create(
outcome="abandoned", # upgraded as the conversation progresses
)
session.add(chat)
from gogo.metering import record_chat_session
await record_chat_session(session, tenant.id)
await session.commit()
return chat, []

View File

@@ -22,6 +22,7 @@ class Settings(BaseSettings):
smtp_password: str = ""
smtp_starttls: bool = True
email_from: str = "Gogo Telefon <noreply@gogotelefon.ba>"
admin_alert_email: str = "" # super-admin alerts (100% usage etc.), empty = off
# SMS. provider=console logs instead of sending (GSM gateway impl in M5).
sms_provider: str = "console" # console | gsm_gateway

View File

@@ -97,6 +97,16 @@ async def run_retention_job() -> None:
log.exception("failed to delete recording %s", path)
continue
call.recording_path = None
# drop stale dialplan handoff rows (they are only needed for minutes)
from sqlalchemy import delete
from gogo.models import CallRegistration
reg_cutoff = datetime.now(UTC) - timedelta(days=7)
await session.execute(
delete(CallRegistration).where(CallRegistration.created_at < reg_cutoff)
)
await session.commit()
if rows:
log.info("retention: cleared %d recordings", len(rows))

View File

@@ -61,6 +61,7 @@ async def record_agent_call(
tenant.slug, used // 60, tenant.included_minutes,
)
await _send_warning(session, tenant, used // 60, 100)
await _alert_admin(tenant, used // 60)
elif used >= included_s * 0.8 and not counter.warned_80:
counter.warned_80 = True
await _send_warning(session, tenant, used // 60, 80)
@@ -75,6 +76,28 @@ async def _send_warning(session: AsyncSession, tenant: Tenant, used_minutes: int
log.exception("usage warning email failed (tenant %s)", tenant.slug)
async def _alert_admin(tenant: Tenant, used_minutes: int) -> None:
"""100% soft-limit hit → alert the super-admin for an upsell conversation (§12)."""
from gogo.config import get_settings
from gogo.email import EmailMessage, send_email
to = get_settings().admin_alert_email
if not to:
return
try:
await send_email(
EmailMessage(
[to],
f"[Gogo] {tenant.name} prešao 100% minuta ({used_minutes} min)",
f"Salon {tenant.name} ({tenant.slug}) je prešao uključene minute "
f"({used_minutes}/{tenant.included_minutes}). Asistent i dalje odgovara "
"(hard cutoff je isključen) — vrijeme za razgovor o većem paketu.",
)
)
except Exception: # noqa: BLE001
log.exception("admin alert email failed")
async def record_sms(session: AsyncSession, tenant_id: uuid.UUID) -> None:
counter = await get_counter(session, tenant_id)
counter.sms_sent += 1

View File

@@ -156,6 +156,9 @@ exten => _[+0-9].,1,NoOp(Gogo inbound ${{EXTEN}} for {slug})
same => n,Dial(${{TARGETS}},${{RINGTIME}})
same => n,GotoIf($["${{DIALSTATUS}}"="ANSWER"]?answered)
same => n(agent),AudioSocket(${{GOGO_UUID}},${{GOGO_VOICE}})
; if the voice pipeline is down AudioSocket fails instantly → fallback (§15);
; after a normal agent call the caller is gone and Playback is a no-op
same => n,Playback(gogo-fallback)
same => n,Hangup()
same => n(answered),Set(RES=${{CURL(${{GOGO_BACKEND}}/internal/calls/${{GOGO_UUID}}/answered?token=${{GOGO_TOKEN}}&duration=${{CDR(billsec)}})}})
same => n,Hangup()

View File

@@ -94,14 +94,38 @@ async def handle_connection(reader: asyncio.StreamReader, writer: asyncio.Stream
await session.run()
async def _heartbeat() -> None:
"""Touch a heartbeat file every 15 s; /health/voice + admin panel read its age (M6)."""
import os
path = heartbeat_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
while True:
try:
with open(path, "w") as f:
f.write(str(int(asyncio.get_running_loop().time())))
except OSError:
log.exception("cannot write heartbeat %s", path)
await asyncio.sleep(15)
def heartbeat_path() -> str:
import os
return os.path.join(get_settings().recordings_dir, ".voice-heartbeat")
async def serve() -> None:
s = get_settings()
server = await asyncio.start_server(handle_connection, s.voice_host, s.voice_port)
addrs = ", ".join(str(sock.getsockname()) for sock in server.sockets)
log.info("AudioSocket server listening on %s", addrs)
# health heartbeat file for the admin panel / monitoring (M6)
async with server:
await server.serve_forever()
heartbeat = asyncio.create_task(_heartbeat())
try:
async with server:
await server.serve_forever()
finally:
heartbeat.cancel()
def main() -> None: