| """`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 |
|
|
| |
| |
| |
| 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 |
| """ |
| |
| 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": |
| |
| yield {"event": "done", "data": json.dumps({"message_id": message_id})} |
| elif event["event"] == "error": |
| yield event |
| return |
| else: |
| |
| 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 |
|
|