ishaq101's picture
/fix planner count and report (#19)
f282b15
Raw
History Blame
4.23 kB
"""`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