Rifqi Hafizuddin
[NOTICKET] fix(db): reconcile analyses schema + migrate chat to analyses_messages
283eb0e | """Chat endpoint with streaming support.""" | |
| import uuid | |
| import json | |
| from typing import List, Dict, Any, Optional | |
| from fastapi import APIRouter, Depends, HTTPException | |
| from langchain_core.messages import HumanMessage, AIMessage | |
| from pydantic import BaseModel | |
| from sqlalchemy import select | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from sse_starlette.sse import EventSourceResponse | |
| from src.agents.chat_handler import ChatHandler | |
| from src.config.settings import settings | |
| from src.db.postgres.connection import get_db | |
| from src.db.postgres.models import AnalysesMessageRow | |
| from src.db.redis.connection import get_redis | |
| from src.middlewares.logging import get_logger, log_execution | |
| logger = get_logger("chat_api") | |
| router = APIRouter(prefix="/api/v1", tags=["Chat"]) | |
| # One shared ChatHandler for the process. It holds no per-request state (user_id | |
| # is passed into handle()), and lazily builds + caches the Orchestrator/Chatbot | |
| # chains — so reusing it keeps the Azure OpenAI clients (and their httpx/TLS pools) | |
| # warm across requests instead of re-handshaking on the first call of every request. | |
| # enable_slow_path is env-gated (ENABLE_SLOW_PATH): when on, structured intents route | |
| # Orchestrator -> Planner -> TaskRunner -> Assembler so the team can test e2e here. | |
| _chat_handler = ChatHandler( | |
| enable_tracing=True, | |
| enable_slow_path=settings.enable_slow_path, | |
| enable_gate=settings.enable_gate, | |
| ) | |
| _GREETINGS = frozenset(["hi", "hello", "hey", "halo", "hai", "hei"]) | |
| _GOODBYES = frozenset(["bye", "goodbye", "thanks", "thank you", "terima kasih", "sampai jumpa"]) | |
| def _fast_intent(message: str) -> Optional[str]: | |
| """Return a direct response for obvious greetings/farewells, else None.""" | |
| lower = message.lower().strip().rstrip("!.,?") | |
| if lower in _GREETINGS: | |
| return "Hello! How can I assist you today?" | |
| if lower in _GOODBYES: | |
| return "Goodbye! Have a great day!" | |
| return None | |
| class ChatRequest(BaseModel): | |
| user_id: str | |
| room_id: str | |
| message: str | |
| async def get_cached_response(redis, cache_key: str) -> Optional[dict]: | |
| cached = await redis.get(cache_key) | |
| if cached: | |
| data = json.loads(cached) | |
| if isinstance(data, dict) and "response" in data: | |
| return data | |
| # legacy: plain string cached before this change | |
| return {"response": data, "sources": []} | |
| return None | |
| # 1h TTL per the 2026-06-11 checkpoint decision (Redis = retrieval/query caching | |
| # only, short-lived). Was 24h, which served stale answers after re-ingestion. | |
| _CHAT_CACHE_TTL_SECONDS = 3600 | |
| # Only stateless replies are safe to cache. The cache key is (room, user, message) | |
| # with no analysis-state/data version, so caching a state- or data-dependent answer | |
| # (help / problem_statement / check / structured_flow / unstructured_flow) would | |
| # replay a stale answer after the state or data changes — and, since the read check | |
| # runs before the gate, could even bypass the gate when the same message repeats. | |
| # So we cache ONLY the `chat` intent. Caching analysis answers needs proper | |
| # invalidation on data/state change — deferred. The write is gated by the intent the | |
| # handler already emits; the read stays as-is (safe because only `chat` is ever | |
| # stored). | |
| _CACHEABLE_INTENTS = frozenset({"chat"}) | |
| def _chat_cache_key(room_id: str, user_id: str, message: str) -> str: | |
| # user_id is part of the key so one user's cached answer can never be | |
| # replayed to another (R5); room_id stays first so the room-wide clear | |
| # endpoint can keep matching on a `chat:{room_id}:*` prefix. | |
| # LIMITATION (T-G): the key omits conversation history, so a repeated message | |
| # replays its cached answer even if the conversation has since moved on. Only | |
| # the stateless `chat` intent is cached, so the blast radius is small — but a | |
| # history-aware key (hash of last-N turns) would close it. Flagged to Harry. | |
| return f"{settings.redis_prefix}chat:{room_id}:{user_id}:{message}" | |
| async def cache_response(redis, cache_key: str, response: str, sources: list): | |
| await redis.setex( | |
| cache_key, | |
| _CHAT_CACHE_TTL_SECONDS, | |
| json.dumps({"response": response, "sources": sources}), | |
| ) | |
| async def load_history(db: AsyncSession, analysis_id: str, limit: int = 10) -> list: | |
| """Load recent conversation messages for an analysis as LangChain messages (oldest-first). | |
| Reads the dedorch `analyses_messages` table (`role ∈ user|ai`), which replaced the | |
| deprecated `rooms`/`chat_messages`. | |
| """ | |
| result = await db.execute( | |
| select(AnalysesMessageRow) | |
| .where(AnalysesMessageRow.analysis_id == analysis_id) | |
| .order_by(AnalysesMessageRow.created_at.asc()) | |
| .limit(limit) | |
| ) | |
| rows = result.scalars().all() | |
| return [ | |
| HumanMessage(content=row.content) if row.role == "user" else AIMessage(content=row.content) | |
| for row in rows | |
| ] | |
| async def save_messages( | |
| db: AsyncSession, | |
| analysis_id: str, | |
| user_id: str, | |
| user_content: str, | |
| assistant_content: str, | |
| ): | |
| """Persist the user turn + AI answer to dedorch `analyses_messages` (`role` user|ai). | |
| Python writes this Go-owned table as a consumer (it does not create it). RAG source | |
| citations are streamed to the client but not persisted — the old `message_sources` | |
| table is deprecated along with `chat_messages`. | |
| """ | |
| db.add(AnalysesMessageRow( | |
| id=str(uuid.uuid4()), analysis_id=analysis_id, user_id=user_id, | |
| role="user", content=user_content, | |
| )) | |
| db.add(AnalysesMessageRow( | |
| id=str(uuid.uuid4()), analysis_id=analysis_id, user_id=user_id, | |
| role="ai", content=assistant_content, | |
| )) | |
| await db.commit() | |
| async def clear_chat_cache(room_id: str, user_id: str, message: str): | |
| """Delete the Redis cache entry for a specific room + user + message pair.""" | |
| redis = await get_redis() | |
| cache_key = _chat_cache_key(room_id, user_id, message) | |
| deleted = await redis.delete(cache_key) | |
| return {"deleted": deleted > 0, "cache_key": cache_key} | |
| async def clear_room_cache(room_id: str): | |
| """Delete all Redis cache entries for a room.""" | |
| redis = await get_redis() | |
| pattern = f"{settings.redis_prefix}chat:{room_id}:*" | |
| keys = await redis.keys(pattern) | |
| if keys: | |
| await redis.delete(*keys) | |
| return {"deleted_count": len(keys), "room_id": room_id} | |
| async def clear_retrieval_cache(user_id: str): | |
| """Delete all cached retrieval results for a user. Call this after uploading/processing new documents.""" | |
| from src.retrieval.router import retrieval_router | |
| deleted = await retrieval_router.invalidate_cache(user_id) | |
| return {"deleted_count": deleted, "user_id": user_id} | |
| async def chat_stream(request: ChatRequest, db: AsyncSession = Depends(get_db)): | |
| """Chat endpoint with streaming response. | |
| SSE event sequence: | |
| 1. sources — JSON array of source refs from ChatHandler (table for | |
| structured; deduped document_id/page_label for unstructured) | |
| 2. chunk — text fragments of the answer | |
| 3. done — signals end of stream | |
| """ | |
| redis = await get_redis() | |
| cache_key = _chat_cache_key(request.room_id, request.user_id, request.message) | |
| # Redis cache hit | |
| cached = await get_cached_response(redis, cache_key) | |
| logger.info("cache check", cache_key=cache_key, cache_hit=cached is not None) | |
| if cached: | |
| logger.info("Returning cached response") | |
| cached_text = cached["response"] | |
| cached_sources = cached["sources"] | |
| await save_messages(db, request.room_id, request.user_id, request.message, cached_text) | |
| async def stream_cached(): | |
| yield {"event": "sources", "data": json.dumps(cached_sources)} | |
| for i in range(0, len(cached_text), 50): | |
| yield {"event": "chunk", "data": cached_text[i:i + 50]} | |
| yield {"event": "done", "data": ""} | |
| return EventSourceResponse(stream_cached()) | |
| try: | |
| # Fast intent: greetings/farewells bypass LLM entirely | |
| direct = _fast_intent(request.message) | |
| if direct: | |
| await cache_response(redis, cache_key, direct, sources=[]) | |
| await save_messages(db, request.room_id, request.user_id, request.message, direct) | |
| async def stream_direct(): | |
| yield {"event": "sources", "data": json.dumps([])} | |
| yield {"event": "chunk", "data": direct} | |
| yield {"event": "done", "data": ""} | |
| return EventSourceResponse(stream_direct()) | |
| history = await load_history(db, request.room_id, limit=10) | |
| handler = _chat_handler | |
| async def stream_response(): | |
| logger.info("stream_response started", room_id=request.room_id, user_id=request.user_id) | |
| full_response = "" | |
| sources: List[Dict[str, Any]] = [] | |
| effective_intent: Optional[str] = None | |
| async for event in handler.handle( | |
| request.message, request.user_id, history, analysis_id=request.room_id | |
| ): | |
| if event["event"] == "intent": | |
| # consumed internally (not forwarded); gates caching below. | |
| try: | |
| effective_intent = json.loads(event["data"]).get("intent") | |
| except (TypeError, ValueError, AttributeError): | |
| effective_intent = None | |
| elif event["event"] == "sources": | |
| try: | |
| sources = json.loads(event["data"]) or [] | |
| except (TypeError, ValueError): | |
| sources = [] | |
| yield event | |
| elif event["event"] == "chunk": | |
| full_response += event["data"] | |
| yield event | |
| elif event["event"] == "done": | |
| # Only cache stateless `chat` replies — caching a state/data- | |
| # dependent answer would replay it stale (see _CACHEABLE_INTENTS). | |
| if effective_intent in _CACHEABLE_INTENTS: | |
| await cache_response(redis, cache_key, full_response, sources=sources) | |
| logger.info("saving messages", sources_count=len(sources), sources=sources) | |
| try: | |
| await save_messages(db, request.room_id, request.user_id, request.message, full_response) | |
| except Exception as e: | |
| logger.error("save_messages failed", room_id=request.room_id, error=str(e)) | |
| yield event | |
| elif event["event"] == "status": | |
| # slow-path progress ("Planning…", "Running N steps…"): forward | |
| # so the client shows activity and the SSE connection stays alive. | |
| yield event | |
| elif event["event"] == "error": | |
| yield event | |
| return | |
| return EventSourceResponse(stream_response()) | |
| except Exception as e: | |
| logger.error("Chat failed", error=str(e)) | |
| raise HTTPException(status_code=500, detail=f"Chat failed: {str(e)}") | |