Rifqi Hafizuddin
[NOTICKET] fix: review cheap batch β F-19 SSE order, F-22/F-24 analysis_id, F-26, F-16, F-4
cbc8d6a | """`help` skill endpoint β dedicated, deterministic dispatch (pr/5 Phase 2). | |
| `POST /api/v1/tools/help` streams state-aware next-step guidance over SSE. Unlike v1 | |
| β where `/help` was reachable only by letting the intent router classify a chat | |
| message β this endpoint dispatches Help directly: the slash command IS the intent, so | |
| there is no router round-trip and no misclassification risk (contract open-Q #2, | |
| resolved in favour of a dedicated endpoint). | |
| Contract: `API_ENDPOINTS_RESTRUCTURE.md` Β§3. The SSE shape mirrors `/chat/stream`, but | |
| help never references documents, so `sources` is always `[]` and there are no `status` | |
| pings. The `done` event carries the assistant `message_id` β always minted Python-side, | |
| never accepted from the caller (server-authoritative; keys the future /observability | |
| lookup, Β§7). | |
| Python is generative-only (06-25 direction): this endpoint does NOT persist the turn β | |
| Go owns writes to `analyses_messages`. It only generates + streams. | |
| """ | |
| import json | |
| import uuid | |
| from fastapi import APIRouter, Depends, HTTPException | |
| from pydantic import BaseModel, field_validator | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from sse_starlette.sse import EventSourceResponse | |
| # Reuse the warm, process-shared ChatHandler (keeps HelpAgent + Azure clients warm) | |
| # and the same history loader the chat endpoint uses. `load_history` reads by | |
| # `analysis_id` (== room_id today); it moves to `analyses_messages` with DEV_PLAN #25. | |
| from src.api.v1.chat import _chat_handler, load_history | |
| from src.db.postgres.connection import get_db | |
| from src.middlewares.logging import get_logger, log_execution | |
| logger = get_logger("help_api") | |
| router = APIRouter(prefix="/api/v1/tools", tags=["Tools"]) | |
| class HelpRequest(BaseModel): | |
| user_id: str | |
| analysis_id: str | |
| def _analysis_id_is_uuid(cls, v: str) -> str: | |
| """Same boundary rule as `POST /api/v2/chat/stream`. (F-22) | |
| Kept identical on purpose: a non-UUID id can never match a row in `analyses` | |
| (`id uuid NOT NULL`), so the state read silently returns nothing and Help | |
| answers from an empty state instead of saying the id was wrong. Two live | |
| endpoints taking the same field should not disagree on what is valid. | |
| """ | |
| try: | |
| uuid.UUID(v) | |
| except (ValueError, AttributeError, TypeError): | |
| raise ValueError( | |
| "analysis_id must be a UUID (the id of an existing analysis)" | |
| ) from None | |
| return v | |
| async def help_stream(request: HelpRequest, db: AsyncSession = Depends(get_db)): | |
| """Stream state-aware next-step guidance (deterministic `/help` dispatch). | |
| SSE event sequence: | |
| 1. sources β always `[]` (help never references documents) | |
| 2. chunk β text fragments of the guidance | |
| 3. done β `{"message_id": "..."}` for the observability lookup | |
| """ | |
| # Server-authoritative turn id β never accepted from the caller (keys traceability). | |
| # Canonical UUID string, matching Go's `analyses_messages.id` shape (mirrors v2 chat). | |
| message_id = str(uuid.uuid4()) | |
| try: | |
| history = await load_history(db, request.analysis_id, limit=10) | |
| async def stream_response(): | |
| async for event in _chat_handler.stream_help( | |
| request.user_id, | |
| request.analysis_id, | |
| history=history, | |
| message=None, | |
| message_id=message_id, | |
| ): | |
| if event["event"] == "done": | |
| # Stamp the turn id so the FE can fetch /observability for it. | |
| yield {"event": "done", "data": json.dumps({"message_id": message_id})} | |
| elif event["event"] == "error": | |
| yield event | |
| return | |
| else: | |
| # `sources` ([]) and `chunk` pass through unchanged. | |
| yield event | |
| return EventSourceResponse(stream_response()) | |
| except Exception as e: | |
| logger.error("Help failed", error=str(e)) | |
| raise HTTPException(status_code=500, detail=f"Help failed: {str(e)}") from e | |