File size: 3,463 Bytes
3bacc1d
 
 
 
 
 
 
 
 
 
d8e7745
 
 
3bacc1d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49b0848
 
 
3bacc1d
 
 
 
 
 
 
 
 
f873f92
3bacc1d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d8e7745
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
"""`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 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