Research.io / api /chat.py
Prince-2025's picture
Initial Upload for Deployment.
ad94b49 verified
Raw History Blame Contribute Delete
4.29 kB
"""
Chat router — streams LLM responses as Server-Sent Events (SSE).
Client uses fetch() + ReadableStream, not EventSource (since we POST).
SSE event shapes:
data: {"type": "start"}
data: {"type": "token", "content": "..."}
data: {"type": "done", "image": null, "chunks": [...]}
data: {"type": "error", "message": "..."}
"""
from collections.abc import AsyncGenerator
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from core.database import get_pool
from core.sse import SSE_HEADERS, sse_event
from models.conversation import get_conversation, insert_conversation, update_conversation_papers
from models.paper import get_paper
from schemas.chat import ChatReq
from services.llm import get_chat_llm, get_context, build_chat_messages, optimize_query
from services.memory import get_conversation_history, save_messages
router = APIRouter(prefix="/api/chat", tags=["chat"])
async def _chat_generator(session_id: str, query: str, paper_id: str | None = None) -> AsyncGenerator[str, None]:
pool = get_pool()
async with pool.acquire() as conn:
conversation = await get_conversation(conn, session_id)
if not conversation:
if not paper_id:
yield sse_event({"type": "error", "message": "No paper loaded for this session."})
return
await insert_conversation(conn, mode='chat', paper_a_id=paper_id, cid=session_id)
conversation = await get_conversation(conn, session_id)
else:
# If the user switched papers in the same session, update the conversation
if paper_id and conversation["paper_a_id"] != paper_id:
await update_conversation_papers(conn, session_id, paper_a_id=paper_id)
conversation["paper_a_id"] = paper_id
# Clear old chat history for the new paper
await conn.execute("DELETE FROM messages WHERE conversation_id = $1", session_id)
if not conversation or not conversation["paper_a_id"]:
yield sse_event({"type": "error", "message": "No paper loaded for this session."})
return
paper = await get_paper(conn, conversation["paper_a_id"])
if not paper:
yield sse_event({"type": "error", "message": "Paper record not found."})
return
history = await get_conversation_history(conn, session_id)
# Step 1: Optimize the user query for retrieval
optimized = await optimize_query(query)
retrieval_query = optimized["query"]
print(f"Query optimizer: action={optimized['action']} | original='{query}' | rewritten='{retrieval_query}'")
yield sse_event({"type": "optimized_query", "action": optimized["action"], "original": query, "query": retrieval_query})
# Step 2: Retrieve context using the optimized query
context, chunks = await get_context(retrieval_query, paper["id"])
# Step 3: Build messages with the original system prompt
messages = build_chat_messages(query, context, history)
yield sse_event({"type": "start"})
llm = get_chat_llm()
full_response = ""
async for chunk in llm.astream(messages):
token = chunk.content or ""
if token:
full_response += token
yield sse_event({"type": "token", "content": token})
# Serialize chunks for the frontend (text + page + scores)
chunks_payload = [
{
"text": c["text"],
"page": c["page"],
"score": round(c["score"], 4),
"embed_score": round(c.get("embed_score", c["score"]), 4),
"rerank_score": round(c.get("rerank_score", c["score"]), 4),
}
for c in chunks
]
yield sse_event({"type": "done", "image": None, "chunks": chunks_payload})
# Persist messages
async with pool.acquire() as conn:
await save_messages(conn, session_id, query, full_response, chunks=chunks_payload)
@router.post("/stream")
async def chat_stream(req: ChatReq):
if not req.content.strip():
raise HTTPException(400, "Empty message.")
return StreamingResponse(
_chat_generator(req.session_id, req.content.strip(), req.paper_id),
media_type="text/event-stream",
headers=SSE_HEADERS,
)