Rifqi Hafizuddin
[KM-688] feat(api): v2 chat pilot + regroup report under /tools; adopt lead API contracts
ad2ce61 | """Chat endpoint — v2 pilot (pr/5 Phase 2). | |
| `POST /api/v2/chat/stream` is the v2 of the only FE→Python call. It is identical to | |
| `POST /api/v1/chat/stream` except: | |
| - the request carries an explicit **`analysis_id`** (replacing v1's `room_id`). The | |
| two are the same session id today (`analysis_id == room_id`), so the warm, | |
| process-shared `ChatHandler` and the v1 cache/history helpers are reused unchanged. | |
| - the `done` event carries the assistant **`message_id`** (minted Python-side if Go | |
| omits it — contract open-Q #1), so the FE can fetch `/api/v1/observability` for the | |
| turn in parallel with the stream. | |
| Only chat moves to v2; the tools group + observability stay on `/api/v1` (contract: | |
| API_ENDPOINTS_RESTRUCTURE.md §1). | |
| ⚠️ Persistence (transitional). This mirrors v1: it still load/saves turn history via the | |
| analysis-keyed message tables so multi-turn context works in the playground. Moving the | |
| read/write to Go-owned `analyses_messages` (and making Python read-only) is DEV_PLAN #25. | |
| Note Sofhia's `/tools/help` is already generative-only — align chat with that under #25. | |
| """ | |
| import json | |
| import uuid | |
| from typing import Any | |
| from fastapi import APIRouter, Depends, HTTPException | |
| from pydantic import BaseModel | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from sse_starlette.sse import EventSourceResponse | |
| # Reuse the v1 chat machinery verbatim (warm ChatHandler + cache/history helpers) so | |
| # v2 stays a thin field-rename over the same logic. Importing the module-private helpers | |
| # is the established pattern here (handlers/help.py imports `_chat_handler` the same way). | |
| from src.api.v1.chat import ( | |
| _CACHEABLE_INTENTS, | |
| _chat_cache_key, | |
| _chat_handler, | |
| _fast_intent, | |
| cache_response, | |
| get_cached_response, | |
| load_history, | |
| save_messages, | |
| ) | |
| from src.db.postgres.connection import get_db | |
| from src.db.redis.connection import get_redis | |
| from src.middlewares.logging import get_logger, log_execution | |
| logger = get_logger("chat_api_v2") | |
| router = APIRouter(prefix="/api/v2", tags=["Chat"]) | |
| def _mint_message_id(provided: str | None) -> str: | |
| """Use Go's assistant turn id when provided; else mint one (contract open-Q #1).""" | |
| return provided or f"msg_{uuid.uuid4().hex[:12]}" | |
| class ChatRequest(BaseModel): | |
| user_id: str | |
| analysis_id: str | |
| message: str | |
| # ⚠️ open-Q #1: Go may mint + pass the assistant turn id; if absent we mint one and | |
| # echo it on `done` so the FE can correlate /observability with this answer. | |
| message_id: str | None = None | |
| async def chat_stream(request: ChatRequest, db: AsyncSession = Depends(get_db)): | |
| """Chat endpoint with streaming response (v2 — keyed on `analysis_id`). | |
| SSE event sequence: | |
| 1. sources — JSON array of source refs (table for structured; deduped | |
| document_id/page_label for unstructured; [] for chat/help/error) | |
| 2. status — slow-path progress pings (optional) | |
| 3. chunk — text fragments of the answer | |
| 4. done — {"message_id": "..."} for the observability lookup | |
| """ | |
| analysis_id = request.analysis_id | |
| message_id = _mint_message_id(request.message_id) | |
| redis = await get_redis() | |
| cache_key = _chat_cache_key(analysis_id, request.user_id, request.message) | |
| # v2 `done` always carries the turn id (v1 sent an empty `done`). | |
| done_event = {"event": "done", "data": json.dumps({"message_id": message_id})} | |
| # Redis cache hit (stateless `chat` intent only). | |
| 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, analysis_id, request.message, cached_text, sources=cached_sources) | |
| 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 done_event | |
| return EventSourceResponse(stream_cached()) | |
| try: | |
| # Fast intent: greetings/farewells bypass the LLM entirely. | |
| direct = _fast_intent(request.message) | |
| if direct: | |
| await cache_response(redis, cache_key, direct, sources=[]) | |
| await save_messages(db, analysis_id, request.message, direct, sources=[]) | |
| async def stream_direct(): | |
| yield {"event": "sources", "data": json.dumps([])} | |
| yield {"event": "chunk", "data": direct} | |
| yield done_event | |
| return EventSourceResponse(stream_direct()) | |
| history = await load_history(db, analysis_id, limit=10) | |
| handler = _chat_handler | |
| async def stream_response(): | |
| logger.info("stream_response started", analysis_id=analysis_id, user_id=request.user_id) | |
| full_response = "" | |
| sources: list[dict[str, Any]] = [] | |
| effective_intent: str | None = None | |
| async for event in handler.handle( | |
| request.message, request.user_id, history, analysis_id=analysis_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 (see _CACHEABLE_INTENTS). | |
| if effective_intent in _CACHEABLE_INTENTS: | |
| await cache_response(redis, cache_key, full_response, sources=sources) | |
| try: | |
| await save_messages( | |
| db, analysis_id, request.message, full_response, sources=sources | |
| ) | |
| except Exception as e: | |
| logger.error("save_messages failed", analysis_id=analysis_id, error=str(e)) | |
| yield done_event | |
| elif event["event"] == "status": | |
| # slow-path progress: forward so the client shows activity. | |
| 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)}") from e | |