"""`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 @field_validator("analysis_id") @classmethod 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 @router.post("/help") @log_execution(logger) 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