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:
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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, []
|
||||
|
||||
@@ -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
|
||||
|
||||
10
gogo/jobs.py
10
gogo/jobs.py
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user