Files
gogo-telefon/gogo/chat/router.py
Senad Uka fd45ff84dc 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>
2026-07-11 11:27:05 +02:00

199 lines
7.0 KiB
Python

"""Chat widget backend (§7): WebSocket endpoint + embeddable script serving.
Same agent, same tools, text instead of voice. No client accounts; sessions are
ephemeral and resumable for 24h via a localStorage token. Rate-limited per IP.
"""
from __future__ import annotations
import logging
import time
import uuid
from collections import defaultdict, deque
from datetime import UTC, datetime, timedelta
from pathlib import Path
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from fastapi.responses import Response
from sqlalchemy import select
from gogo.agent.loop import AgentConversation
from gogo.agent.prompt import compose_greeting
from gogo.db import get_sessionmaker
from gogo.models import ChatMessage, ChatSession, Tenant
log = logging.getLogger("gogo.chat")
router = APIRouter()
MAX_MESSAGE_CHARS = 500
MAX_MESSAGES_PER_SESSION = 40
MIN_SECONDS_BETWEEN_MESSAGES = 1.0
SESSIONS_PER_IP_PER_HOUR = 10
RESUME_WINDOW = timedelta(hours=24)
# ip -> deque of session-creation timestamps (in-memory; fine for MVP scale §15)
_session_creations: dict[str, deque[float]] = defaultdict(deque)
def _ip_allowed(ip: str) -> bool:
now = time.monotonic()
q = _session_creations[ip]
while q and now - q[0] > 3600:
q.popleft()
if len(q) >= SESSIONS_PER_IP_PER_HOUR:
return False
q.append(now)
return True
WIDGET_JS = (Path(__file__).parent.parent / "static" / "widget.js").read_text
@router.get("/widget.js")
async def widget_js():
return Response(WIDGET_JS(), media_type="application/javascript")
@router.websocket("/widget/ws")
async def widget_ws(ws: WebSocket):
key = ws.query_params.get("key", "")
resume = ws.query_params.get("resume", "")
ip = ws.client.host if ws.client else ""
async with get_sessionmaker()() as session:
tenant = (
await session.execute(
select(Tenant).where(Tenant.widget_public_key == key, Tenant.status == "active")
)
).scalar_one_or_none()
if tenant is None:
await ws.close(code=4404)
return
chat, history = await _resume_or_create(session, tenant, resume, ip)
if chat is None:
await ws.close(code=4429) # rate limited
return
await ws.accept()
convo = AgentConversation(
session, tenant, channel="chat", chat_session_id=chat.id
)
greeting = compose_greeting(tenant, "chat")
if history:
# rebuild the model-visible history from stored messages
for m in history:
convo.messages.append(
{"role": "user" if m.role == "user" else "assistant", "content": m.content}
)
await ws.send_json(
{
"type": "resume",
"session": chat.resume_token,
"messages": [{"role": m.role, "text": m.content} for m in history],
}
)
else:
convo.messages.append({"role": "assistant", "content": greeting})
session.add(ChatMessage(session_id=chat.id, role="assistant", content=greeting))
await session.commit()
await ws.send_json(
{"type": "greeting", "session": chat.resume_token, "text": greeting}
)
message_count = len(history)
last_message_at = 0.0
try:
while True:
data = await ws.receive_json()
if data.get("type") != "message":
continue
if data.get("website"): # honeypot field — bots fill it, humans never see it
continue
text = str(data.get("text", "")).strip()[:MAX_MESSAGE_CHARS]
if not text:
continue
now = time.monotonic()
if now - last_message_at < MIN_SECONDS_BETWEEN_MESSAGES:
await ws.send_json({"type": "error", "code": "too_fast"})
continue
last_message_at = now
message_count += 1
if message_count > MAX_MESSAGES_PER_SESSION:
await ws.send_json(
{
"type": "message",
"text": (
"Dostigli ste ograničenje poruka. Molimo pozovite salon "
"telefonom. Hvala!"
),
}
)
break
session.add(ChatMessage(session_id=chat.id, role="user", content=text))
await ws.send_json({"type": "typing"})
try:
reply = await convo.user_turn(text)
except Exception: # noqa: BLE001
log.exception("chat agent error (tenant %s)", tenant.slug)
reply = (
"Izvinite, došlo je do tehničke greške. Molimo pokušajte "
"ponovo ili pozovite salon telefonom."
)
session.add(ChatMessage(session_id=chat.id, role="assistant", content=reply))
chat.outcome = convo.outcome
await session.commit()
await ws.send_json({"type": "message", "text": reply})
except WebSocketDisconnect:
# No awaits here: the task may be cancelled during teardown and an
# in-flight DB call on a closing connection can never complete.
# Outcome is already persisted after every message.
pass
async def _resume_or_create(
session, tenant: Tenant, resume_token: str, ip: str
) -> tuple[ChatSession | None, list[ChatMessage]]:
if resume_token:
chat = (
await session.execute(
select(ChatSession).where(
ChatSession.tenant_id == tenant.id,
ChatSession.resume_token == resume_token,
)
)
).scalar_one_or_none()
if chat is not None:
started = chat.started_at
if started.tzinfo is None:
started = started.replace(tzinfo=UTC)
if datetime.now(UTC) - started < RESUME_WINDOW:
history = (
(
await session.execute(
select(ChatMessage)
.where(ChatMessage.session_id == chat.id)
.order_by(ChatMessage.at)
)
)
.scalars()
.all()
)
return chat, list(history)
if not _ip_allowed(ip):
return None, []
chat = ChatSession(
tenant_id=tenant.id,
resume_token=uuid.uuid4().hex,
client_ip=ip,
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, []