Files
2026-08-03 19:27:54 +08:00

392 lines
15 KiB
Python

"""HTTP router for MAF roundtable Conversation API at ``/api/maf-roundtable/conversations``.
Phase 1 (R1) scope:
- POST /conversations — create blank user conversation
- GET /conversations — list current user's history
- GET /conversations/{id} — public conversation DTO
- GET /conversations/{id}/messages — paginated message history
- POST /conversations/{id}/messages — atomic send + run start
Phase 2 (R2) adds:
- GET /conversations/{id}/stream — SSE real-time event stream
User isolation: owner-only reads/writes. Admin diagnostic access is read-only.
Internal fields (owner_user_id, internal_session_id, active_run_id, thread ids)
are never returned.
"""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request, status
from fastapi.responses import StreamingResponse
from app.maf_roundtable.conversation_event_hub import ConversationEventHub
from app.maf_roundtable.conversation_schemas import (
ConversationListResponse,
ConversationResponse,
CreateConversationRequest,
MessageListResponse,
SendMessageRequest,
SendMessageResponse,
)
from app.maf_roundtable.conversation_service import (
ConversationConflictError,
ConversationDisabledError,
ConversationNoSessionConfigError,
ConversationNotFoundError,
ConversationServiceError,
MafConversationService,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/maf-roundtable", tags=["maf-roundtable-conversations"])
_SSE_HEADERS = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
def _get_conversation_service(request: Request) -> MafConversationService:
svc = getattr(request.app.state, "maf_conversation_service", None)
if svc is None:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="MAF conversation service not available")
return svc
def _get_event_hub(request: Request) -> ConversationEventHub | None:
return getattr(request.app.state, "maf_conversation_event_hub", None)
def _user_ctx(request: Request) -> tuple[str, bool]:
"""Match the main MAF router's authenticated-user convention.
``AuthMiddleware`` stamps the resolved account on ``request.state.user``;
it does not create ``user_id`` / ``is_admin`` attributes. Reading those
nonexistent attributes collapses every authenticated caller into the
fallback account and breaks owner isolation.
"""
user = getattr(request.state, "user", None)
if user is None:
return "default", False
return str(user.id), getattr(user, "system_role", None) == "admin"
# ── REST endpoints (Phase 1) ──────────────────────────────────────────────
def _check_stream_ready(request: Request) -> None:
stream_ready = getattr(request.app.state, "maf_conversation_stream_ready", True)
if not stream_ready:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=getattr(request.app.state, "maf_conversation_stream_status", "stream_not_ready"),
)
@router.post("/conversations", response_model=ConversationResponse, status_code=status.HTTP_201_CREATED)
async def create_conversation(body: CreateConversationRequest, request: Request):
_check_stream_ready(request)
svc = _get_conversation_service(request)
user_id, _ = _user_ctx(request)
try:
return await svc.create_conversation(
user_id=user_id,
title=body.title.strip(),
task_context=body.task_context,
)
except ConversationDisabledError:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="MAF roundtable feature is disabled",
)
@router.get("/conversations", response_model=ConversationListResponse)
async def list_conversations(
request: Request,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
):
svc = _get_conversation_service(request)
user_id, _ = _user_ctx(request)
conversations, total = await svc.list_conversations(user_id=user_id, limit=limit, offset=offset)
return {"conversations": conversations, "total": total}
@router.get("/conversations/{conversation_id}", response_model=ConversationResponse)
async def get_conversation(conversation_id: str, request: Request):
svc = _get_conversation_service(request)
user_id, is_admin = _user_ctx(request)
try:
return await svc.get_conversation(conversation_id=conversation_id, user_id=user_id, is_admin=is_admin)
except ConversationNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
@router.get("/conversations/{conversation_id}/messages", response_model=MessageListResponse)
async def list_messages(
conversation_id: str,
request: Request,
before_seq: int | None = Query(default=None, ge=1),
limit: int = Query(default=50, ge=1, le=200),
):
svc = _get_conversation_service(request)
user_id, is_admin = _user_ctx(request)
try:
messages, last_stream_seq = await svc.list_messages(
conversation_id=conversation_id, user_id=user_id, is_admin=is_admin,
before_seq=before_seq, limit=limit,
)
return {"messages": messages, "last_stream_seq": last_stream_seq}
except ConversationNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
@router.post("/conversations/{conversation_id}/messages", response_model=SendMessageResponse, status_code=status.HTTP_201_CREATED)
async def send_message(conversation_id: str, body: SendMessageRequest, request: Request):
_check_stream_ready(request)
svc = _get_conversation_service(request)
user_id, _ = _user_ctx(request)
try:
return await svc.send_message(conversation_id=conversation_id, user_id=user_id, content=body.content)
except ConversationNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
except ConversationConflictError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A run is already active")
except ConversationNoSessionConfigError:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="No trusted session configuration")
except ConversationDisabledError:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="MAF roundtable feature is disabled")
except ConversationServiceError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message)
# ── SSE endpoint (Phase 2 / R2) ───────────────────────────────────────────
# Event types that mark the end of a run's lifecycle. When the SSE stream
# encounters any of these in order, it sends the event and then closes the
# connection so the client doesn't sit idle receiving heartbeats.
_TERMINAL_EVENT_TYPES = frozenset({
"run.completed",
"run.error",
"run.cancelled",
})
def _format_sse_event(event: dict[str, Any]) -> str:
"""Format one conversation event as an SSE frame per §4.3 contract.
``event:`` field carries the event type; ``data:`` carries the full
envelope; ``id:`` carries the event_id for Last-Event-ID reconnection.
"""
event_type = event.get("type", "message")
event_id = event.get("event_id")
payload = json.dumps(event, default=str, ensure_ascii=False)
parts = [f"event: {event_type}", f"data: {payload}"]
if event_id:
parts.append(f"id: {event_id}")
parts.append("")
parts.append("")
return "\n".join(parts)
def _format_heartbeat() -> str:
"""SSE comment frame — keeps the connection alive without producing a
client-visible event."""
return ": heartbeat\n\n"
def _is_terminal(event: dict[str, Any]) -> bool:
return event.get("type") in _TERMINAL_EVENT_TYPES
def build_conversation_event_generator(
*,
service: MafConversationService,
hub: ConversationEventHub,
request: Request,
conversation_id: str,
after_seq: int = 0,
) -> Any:
"""Extracted async generator for SSE streaming — testable without HTTP.
The ordering is:
1. **Reconcile** any missed projections for the active run (P0-1 auto).
2. **Open subscription** (register before any DB query — P0-1 fix).
3. **Replay** from DB: ``list_events(after_seq)``.
4. **Stabilise**: re-query while the hub generation advanced during replay.
5. **Snapshot** marker.
6. **Real-time**: ``wait_for_change`` → query DB → yield events.
On terminal events the stream sends the event and closes (P0-2 fix).
Heartbeat every ``hub.heartbeat_interval`` seconds.
"""
store = service._store # noqa: SLF001
async def _gen():
try:
active_run_id = await _get_active_run_id(store, conversation_id)
if active_run_id:
try:
await service.reconcile_conversation_events(run_id=active_run_id)
except Exception: # noqa: BLE001
logger.warning("SSE connect reconcile failed conv=%s", conversation_id, exc_info=True)
except Exception: # noqa: BLE001
logger.warning("SSE connect reconcile lookup failed conv=%s", conversation_id, exc_info=True)
sub = hub.open_subscription(conversation_id)
last_seq = after_seq
try:
try:
replay_events = await store.conversation_events.list_events(
conversation_id, after_seq=last_seq,
)
for evt in replay_events:
if await request.is_disconnected():
return
yield _format_sse_event(evt)
last_seq = max(last_seq, int(evt.get("seq", last_seq)))
if _is_terminal(evt):
return
except Exception:
logger.exception("SSE replay error conv=%s", conversation_id)
yield _format_sse_event({
"seq": last_seq,
"event_id": f"replay_error_{last_seq}",
"conversation_id": conversation_id,
"type": "run.error",
"occurred_at": datetime.now(UTC).isoformat(),
"data": {"error": "replay_failed"},
})
return
while sub.has_pending:
sub.mark_seen()
late_events = await store.conversation_events.list_events(
conversation_id, after_seq=last_seq,
)
if not late_events:
break
for evt in late_events:
if await request.is_disconnected():
return
yield _format_sse_event(evt)
last_seq = max(last_seq, int(evt.get("seq", last_seq)))
if _is_terminal(evt):
return
snapshot = {
"seq": last_seq,
"event_id": f"snapshot_{conversation_id}_{last_seq}",
"conversation_id": conversation_id,
"type": "conversation.snapshot",
"occurred_at": datetime.now(UTC).isoformat(),
"data": {"last_stream_seq": last_seq},
}
if not await request.is_disconnected():
yield _format_sse_event(snapshot)
heartbeat_interval = hub.heartbeat_interval
while True:
if hub.is_closed:
return
if await request.is_disconnected():
return
changed = await sub.wait_for_change(timeout=heartbeat_interval)
if not changed:
if hub.is_closed:
return
yield _format_heartbeat()
continue
new_events = await store.conversation_events.list_events(
conversation_id, after_seq=last_seq,
)
for evt in new_events:
if await request.is_disconnected():
return
yield _format_sse_event(evt)
last_seq = max(last_seq, int(evt.get("seq", last_seq)))
if _is_terminal(evt):
return
except asyncio.CancelledError:
logger.debug("SSE stream cancelled conv=%s", conversation_id)
raise
except Exception:
logger.exception("SSE stream error conv=%s", conversation_id)
return
finally:
sub.close()
return _gen()
async def _get_active_run_id(store: Any, conversation_id: str) -> str | None:
from sqlalchemy import select
from deerflow.persistence.maf_roundtable.model import MafRoundtableConversationRow
async with store.session_factory() as db:
row = (
await db.execute(
select(MafRoundtableConversationRow.active_run_id).where(
MafRoundtableConversationRow.id == conversation_id,
)
)
).first()
return row[0] if row and row[0] else None
@router.get("/conversations/{conversation_id}/stream")
async def stream_conversation(
conversation_id: str,
request: Request,
after_seq: int = Query(default=0, ge=0),
):
"""SSE stream for one conversation (§4.2)."""
svc = _get_conversation_service(request)
hub = _get_event_hub(request)
user_id, is_admin = _user_ctx(request)
if hub is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="SSE event hub not configured",
)
stream_ready = getattr(request.app.state, "maf_conversation_stream_ready", True)
if not stream_ready:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=getattr(request.app.state, "maf_conversation_stream_status", "stream_not_ready"),
)
try:
await svc.get_conversation(
conversation_id=conversation_id, user_id=user_id, is_admin=is_admin,
)
except ConversationNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
return StreamingResponse(
build_conversation_event_generator(
service=svc,
hub=hub,
request=request,
conversation_id=conversation_id,
after_seq=after_seq,
),
media_type="text/event-stream",
headers=_SSE_HEADERS,
)