File size: 4,234 Bytes
3bacc1d
 
 
 
 
 
 
 
 
 
d8e7745
 
 
3bacc1d
 
 
 
 
 
 
 
 
cbc8d6a
3bacc1d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cbc8d6a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
"""`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