"""`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 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 @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 /observability). message_id = f"msg_{uuid.uuid4().hex[:12]}" 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, ): 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