customercore / src /api /routers /stream.py
Saibalaji Namburi
feat(k8s): deploy api to kind multinode cluster and verify with adversarial red-teaming simulator
5553f71
Raw
History Blame Contribute Delete
5.57 kB
"""
CustomerCore API — Server-Sent Events (SSE) Stream Router (Phase 13)
Wires the Supabase persistent repository layer into the streaming endpoints.
Replaces the in-memory triage_store with TicketRepository.
"""
from __future__ import annotations
import asyncio
import json
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from src.api.auth import AuthenticatedTenant, verify_token
from src.api.models import TriageStatus
from src.db.repository import TicketRepository
router = APIRouter(prefix="/api/v1/triage", tags=["Streaming"])
# How often to check for status updates (seconds)
_POLL_INTERVAL = 0.5
# Maximum wait time before giving up and closing the stream
_MAX_WAIT_SECONDS = 120
@router.get(
"/{ticket_id}/stream",
summary="Real-time SSE stream for triage progress",
description=(
"Subscribe to Server-Sent Events for a ticket's triage progress. "
"The stream emits status events as each agent completes. "
"Closes automatically when triage reaches complete/hitl/failed status."
),
response_class=StreamingResponse,
)
async def stream_triage(
ticket_id: str,
caller: AuthenticatedTenant = Depends(verify_token),
) -> StreamingResponse:
"""
Stream real-time triage progress via Server-Sent Events from Supabase.
"""
repo = TicketRepository(caller.tenant_id)
record = await repo.get(ticket_id)
if not record:
raise HTTPException(status_code=404, detail=f"Ticket {ticket_id} not found.")
async def event_generator():
"""
Async generator that polls the database and yields SSE events.
"""
elapsed = 0.0
last_status = None
# Send initial connection confirmation event
yield _sse_event({
"type": "connected",
"ticket_id": ticket_id,
"timestamp": datetime.utcnow().isoformat(),
})
while elapsed < _MAX_WAIT_SECONDS:
await asyncio.sleep(_POLL_INTERVAL)
elapsed += _POLL_INTERVAL
rec = await repo.get(ticket_id)
current_status = TriageStatus(rec["status"]) if rec else None
# Only emit an event if status changed (avoids flooding the client)
if current_status != last_status:
last_status = current_status
if current_status == TriageStatus.PROCESSING:
yield _sse_event({
"type": "status",
"status": "processing",
"message": "AI agents are analyzing your ticket...",
"elapsed_ms": int(elapsed * 1000),
})
elif current_status == TriageStatus.HITL:
yield _sse_event({
"type": "hitl",
"status": "hitl",
"message": "Ticket flagged for human review.",
"hitl_reason": rec.get("hitl_reason") if rec else None,
"elapsed_ms": int(elapsed * 1000),
})
yield _sse_event({"type": "done"})
return
elif current_status == TriageStatus.COMPLETE:
from src.api.routers.triage import row_to_response
res_obj = row_to_response(rec) if rec else None
yield _sse_event({
"type": "complete",
"status": "complete",
"category": res_obj.category if res_obj else None,
"priority": res_obj.priority.value if res_obj and res_obj.priority else None,
"confidence": res_obj.confidence if res_obj else 0.85,
"churn_risk": res_obj.churn_risk if res_obj else "low",
"hitl_required": res_obj.hitl_required if res_obj else False,
"processing_ms": res_obj.processing_ms if res_obj else None,
"elapsed_ms": int(elapsed * 1000),
})
yield _sse_event({"type": "done"})
return
elif current_status == TriageStatus.FAILED:
yield _sse_event({
"type": "error",
"status": "failed",
"error": rec.get("error_message", "Unknown error") if rec else "Unknown error",
"elapsed_ms": int(elapsed * 1000),
})
yield _sse_event({"type": "done"})
return
# Send a keep-alive comment every 10 seconds (prevents proxy timeouts)
if elapsed % 10 < _POLL_INTERVAL:
yield ": keep-alive\n\n"
# Timeout — stream ran too long
yield _sse_event({
"type": "timeout",
"message": f"Stream timed out after {_MAX_WAIT_SECONDS}s. "
f"Poll GET /api/v1/triage/{ticket_id} for result.",
})
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # Disable nginx buffering (critical for SSE)
"Connection": "keep-alive",
},
)
def _sse_event(data: dict) -> str:
"""Format a dictionary as a W3C-compliant SSE event string."""
return f"data: {json.dumps(data)}\n\n"