Spaces:
Runtime error
Runtime error
| """Chat CRUD + SSE streaming through the LangGraph agent. | |
| SSE events: stage {name} · token {content} · retry · citations {items} · done {message_id} | |
| """ | |
| import json | |
| import anyio | |
| from fastapi import APIRouter, Depends, HTTPException | |
| from pydantic import BaseModel | |
| from sqlalchemy import select | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from sse_starlette.sse import EventSourceResponse | |
| from app.agent.graph import get_graph | |
| from app.api.collections import get_owned_collection | |
| from app.core.security import Principal, get_principal | |
| from app.db.models import Chat, Message | |
| from app.db.session import get_db, get_sessionmaker | |
| from app.observability.tracing import langchain_callbacks | |
| router = APIRouter(prefix="/chats", tags=["chat"]) | |
| NODE_STAGES = {"planner", "retrieve", "rerank", "answer", "verify"} | |
| class ChatCreate(BaseModel): | |
| collection_id: str | |
| title: str = "New chat" | |
| class ChatOut(BaseModel): | |
| id: str | |
| collection_id: str | |
| title: str | |
| model_config = {"from_attributes": True} | |
| class MessageOut(BaseModel): | |
| id: str | |
| role: str | |
| content: str | |
| citations: list | |
| model_config = {"from_attributes": True} | |
| class MessageIn(BaseModel): | |
| content: str | |
| async def _get_owned_chat(chat_id: str, db: AsyncSession, principal: Principal) -> Chat: | |
| chat = await db.get(Chat, chat_id) | |
| if chat is None or chat.tenant_id != principal.tenant_id: | |
| raise HTTPException(status_code=404, detail="Chat not found") | |
| return chat | |
| async def create_chat( | |
| body: ChatCreate, | |
| db: AsyncSession = Depends(get_db), | |
| principal: Principal = Depends(get_principal), | |
| ): | |
| await get_owned_collection(body.collection_id, db, principal) | |
| chat = Chat( | |
| tenant_id=principal.tenant_id, | |
| user_id=principal.user_id, | |
| collection_id=body.collection_id, | |
| title=body.title, | |
| ) | |
| db.add(chat) | |
| await db.commit() | |
| await db.refresh(chat) | |
| return chat | |
| async def list_chats( | |
| db: AsyncSession = Depends(get_db), | |
| principal: Principal = Depends(get_principal), | |
| ): | |
| rows = await db.scalars( | |
| select(Chat).where(Chat.tenant_id == principal.tenant_id).order_by(Chat.created_at.desc()) | |
| ) | |
| return list(rows) | |
| async def list_messages( | |
| chat_id: str, | |
| db: AsyncSession = Depends(get_db), | |
| principal: Principal = Depends(get_principal), | |
| ): | |
| chat = await _get_owned_chat(chat_id, db, principal) | |
| rows = await db.scalars( | |
| select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at) | |
| ) | |
| return list(rows) | |
| async def send_message( | |
| chat_id: str, | |
| body: MessageIn, | |
| db: AsyncSession = Depends(get_db), | |
| principal: Principal = Depends(get_principal), | |
| ): | |
| chat = await _get_owned_chat(chat_id, db, principal) | |
| history_rows = await db.scalars( | |
| select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at) | |
| ) | |
| history = [{"role": m.role, "content": m.content} for m in history_rows] | |
| db.add(Message(chat_id=chat.id, role="user", content=body.content)) | |
| await db.commit() | |
| state = { | |
| "question": body.content, | |
| "tenant_id": principal.tenant_id, | |
| "collection_id": chat.collection_id, | |
| "history": history, | |
| "retries": 0, | |
| } | |
| async def stream(): | |
| final: dict = {} | |
| tokens: list[str] = [] | |
| citations: list = [] | |
| answer_runs = 0 | |
| saved_id: str | None = None | |
| async def _save(content: str) -> str | None: | |
| nonlocal saved_id | |
| if saved_id or not content: | |
| return saved_id | |
| async with get_sessionmaker()() as s: | |
| msg = Message( | |
| chat_id=chat.id, role="assistant", content=content, citations=citations | |
| ) | |
| s.add(msg) | |
| await s.commit() | |
| await s.refresh(msg) | |
| saved_id = msg.id | |
| return saved_id | |
| try: | |
| config = {"callbacks": langchain_callbacks()} | |
| async for ev in get_graph().astream_events(state, version="v2", config=config): | |
| kind = ev["event"] | |
| node = ev.get("metadata", {}).get("langgraph_node", "") | |
| if kind == "on_chain_start" and ev.get("name") in NODE_STAGES: | |
| if ev["name"] == "answer": | |
| answer_runs += 1 | |
| if answer_runs > 1: | |
| tokens.clear() | |
| yield {"event": "retry", "data": "{}"} | |
| yield {"event": "stage", "data": json.dumps({"name": ev["name"]})} | |
| elif kind == "on_chat_model_stream" and node == "answer": | |
| content = ev["data"]["chunk"].content | |
| if content: | |
| tokens.append(content) | |
| yield {"event": "token", "data": json.dumps({"content": content})} | |
| elif kind == "on_chain_end" and ev.get("name") == "rerank": | |
| citations = (ev["data"]["output"] or {}).get("citations", []) | |
| elif kind == "on_chain_end" and ev.get("name") == "LangGraph": | |
| final = ev["data"]["output"] or {} | |
| citations = final.get("citations", citations) | |
| msg_id = await _save(final.get("answer") or "".join(tokens)) | |
| yield {"event": "citations", "data": json.dumps({"items": citations})} | |
| yield {"event": "done", "data": json.dumps({"message_id": msg_id})} | |
| finally: | |
| # client disconnected mid-stream — persist what was generated | |
| if saved_id is None and tokens: | |
| with anyio.CancelScope(shield=True): | |
| await _save("".join(tokens)) | |
| return EventSourceResponse(stream()) | |