corpora / app /api /chat.py
MiKaustubh007's picture
Upload folder using huggingface_hub
524e6d5 verified
Raw
History Blame Contribute Delete
6 kB
"""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
@router.post("", response_model=ChatOut, status_code=201)
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
@router.get("", response_model=list[ChatOut])
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)
@router.get("/{chat_id}/messages", response_model=list[MessageOut])
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)
@router.post("/{chat_id}/messages")
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())