sofhiaazzhr Claude Opus 4.8 commited on
Commit
a881d38
·
1 Parent(s): ce7be17

[KM-687] Add Dedicated /tools/help Endpoint + Regroup Tools List

Browse files

pr/5 Phase 2 (Sofhia's part of the skills regroup under /tools):

- New POST /api/v1/tools/help (src/api/v1/help.py): dedicated, deterministic
help dispatch — the slash command IS the intent, so no router round-trip and
no misclassification (contract open-Q #2, dedicated endpoint). SSE shape
mirrors chat: sources ([]) -> chunk -> done {message_id}. Generative-only:
does NOT persist the turn (Go owns analyses_messages). message_id is minted
Python-side when Go omits it (open-Q #1), echoed on done.
- ChatHandler.stream_help(): additive method reusing the warm HelpAgent +
state store + is_report_ready. The router help path (handle() intent=="help")
is left intact — BOTH paths stay live by design.
- Rename GET /api/v1/tools -> GET /api/v1/tools/list (tools.py).
- Mount help_router in main.py; mark the two rows done in DEV_PLAN §0.

Verified: app imports clean, /api/v1/tools/help + /api/v1/tools/list mounted,
old /api/v1/tools gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

DEV_PLAN.md CHANGED
@@ -26,8 +26,8 @@ the endpoint contract *before* coding the tools. Status legend: ⬜ not started
26
  | **2 — v2 + regroup** | Create `src/api/v2/` and move the chat pilot there | Rifqi/Sofhia | ⬜ | Only chat moves to v2; mirror v1 structure. |
27
  | **2 — v2 + regroup** | Chat: `room_id` → **`analysis_id`** (request field + handler + history) | Rifqi | ⬜ | `ChatRequest` [chat.py:51](src/api/v1/chat.py:51); `done` event returns `message_id`. Ties to #25 (`analyses_messages`). |
28
  | **2 — v2 + regroup** | Move report under tools → `/api/v1/tools/report` (+ version routes) | Rifqi | ⬜ | Today standalone `/api/v1/report` (still mounted). Same functionality, new path. |
29
- | **2 — v2 + regroup** | Move help under tools → `POST /api/v1/tools/help` (dedicated endpoint) | Sofhia | | Today help is a chat **intent** only. Dedicated endpoint = deterministic dispatch (open Q for Harry). |
30
- | **2 — v2 + regroup** | Tools list → `/api/v1/tools/list` | Sofhia | | Today `GET /api/v1/tools` ([tools.py:133](src/api/v1/tools.py:133)). |
31
  | **2 — v2 + regroup** | FE: slash menu = `/help` only; report = right-side button | Mentor (FE) | ⬜ | Coordination note, not Python work. |
32
  | **3 — tools + obs** | Finish `help` so it actually **calls** (not just lists) + test | Sofhia | ⬜ | Mentor: help currently only lists tools. Core #2 after chat. |
33
  | **3 — tools + obs** | Observability **scratchpad** (decorator) accumulating in the chat agent | Rifqi + Sofhia | ⬜ | Capture planning / tool I/O / sources during the run; flush one record on `done`. |
 
26
  | **2 — v2 + regroup** | Create `src/api/v2/` and move the chat pilot there | Rifqi/Sofhia | ⬜ | Only chat moves to v2; mirror v1 structure. |
27
  | **2 — v2 + regroup** | Chat: `room_id` → **`analysis_id`** (request field + handler + history) | Rifqi | ⬜ | `ChatRequest` [chat.py:51](src/api/v1/chat.py:51); `done` event returns `message_id`. Ties to #25 (`analyses_messages`). |
28
  | **2 — v2 + regroup** | Move report under tools → `/api/v1/tools/report` (+ version routes) | Rifqi | ⬜ | Today standalone `/api/v1/report` (still mounted). Same functionality, new path. |
29
+ | **2 — v2 + regroup** | Move help under tools → `POST /api/v1/tools/help` (dedicated endpoint) | Sofhia | | New `src/api/v1/help.py` (SSE: `sources:[]`→`chunk`→`done{message_id}`) + additive `ChatHandler.stream_help()` (reuses HelpAgent+state+readiness, no router). Generative-only (no persist). **Router `help` intent KEPT** — both paths live by design. message_id minted Python-side if Go omits (open-Q #1). Import-verified. |
30
+ | **2 — v2 + regroup** | Tools list → `/api/v1/tools/list` | Sofhia | | Renamed route `GET /api/v1/tools` → `GET /api/v1/tools/list` ([tools.py:133](src/api/v1/tools.py:133)). |
31
  | **2 — v2 + regroup** | FE: slash menu = `/help` only; report = right-side button | Mentor (FE) | ⬜ | Coordination note, not Python work. |
32
  | **3 — tools + obs** | Finish `help` so it actually **calls** (not just lists) + test | Sofhia | ⬜ | Mentor: help currently only lists tools. Core #2 after chat. |
33
  | **3 — tools + obs** | Observability **scratchpad** (decorator) accumulating in the chat agent | Rifqi + Sofhia | ⬜ | Capture planning / tool I/O / sources during the run; flush one record on `done`. |
main.py CHANGED
@@ -19,6 +19,7 @@ from slowapi.errors import RateLimitExceeded
19
  from src.api.v1.chat import router as chat_router
20
  from src.api.v1.report import router as report_router
21
  from src.api.v1.tools import router as tools_router
 
22
  from src.db.postgres.init_db import init_db
23
  import os
24
  import uvicorn
@@ -63,6 +64,7 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
63
  app.include_router(chat_router)
64
  app.include_router(report_router)
65
  app.include_router(tools_router)
 
66
 
67
 
68
  @app.get("/")
 
19
  from src.api.v1.chat import router as chat_router
20
  from src.api.v1.report import router as report_router
21
  from src.api.v1.tools import router as tools_router
22
+ from src.api.v1.help import router as help_router # pr/5 Phase 2: dedicated /tools/help
23
  from src.db.postgres.init_db import init_db
24
  import os
25
  import uvicorn
 
64
  app.include_router(chat_router)
65
  app.include_router(report_router)
66
  app.include_router(tools_router)
67
+ app.include_router(help_router)
68
 
69
 
70
  @app.get("/")
src/agents/chat_handler.py CHANGED
@@ -227,6 +227,55 @@ class ChatHandler:
227
  # Public entry
228
  # ------------------------------------------------------------------
229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  async def handle(
231
  self,
232
  message: str,
 
227
  # Public entry
228
  # ------------------------------------------------------------------
229
 
230
+ async def stream_help(
231
+ self,
232
+ user_id: str,
233
+ analysis_id: str | None,
234
+ history: list[BaseMessage] | None = None,
235
+ message: str | None = None,
236
+ ) -> AsyncIterator[dict[str, Any]]:
237
+ """Deterministic `help` dispatch for the dedicated `/api/v1/tools/help` endpoint.
238
+
239
+ Bypasses the intent router — the slash command IS the intent, so there is no
240
+ classify round-trip and no misclassification risk. Streams the same guidance as
241
+ the `help` branch of `handle()`, reusing the warm HelpAgent + state store.
242
+
243
+ Emits SSE-style events: `sources` (always `[]` — help never references
244
+ documents), `chunk`*, then `done` (data left empty; the endpoint stamps the
245
+ `message_id`). On failure, yields a terminal `error` event.
246
+ """
247
+ # Load (or lazily create) the analysis state; fail closed to a not-validated
248
+ # stub so help degrades gracefully on a missing row / read error / legacy id.
249
+ state: AnalysisState | None = None
250
+ if analysis_id:
251
+ try:
252
+ state = await self._get_state_store().ensure(analysis_id, user_id)
253
+ except Exception as e: # noqa: BLE001 — never block help on a state read
254
+ logger.warning("help state ensure failed", analysis_id=analysis_id, error=str(e))
255
+ if state is None:
256
+ state = await self._load_analysis_state(analysis_id)
257
+
258
+ # report_ready (seam #5): deterministic, never-throws (fails closed to
259
+ # not-ready) — the HelpAgent guard only offers generate_report when ready.
260
+ from .report.readiness import is_report_ready
261
+
262
+ report_ready = await is_report_ready(analysis_id, state)
263
+
264
+ yield {"event": "sources", "data": json.dumps([])}
265
+ try:
266
+ async for token in self._get_help_agent().astream(
267
+ state,
268
+ history=history,
269
+ message=message,
270
+ report_ready=report_ready,
271
+ ):
272
+ yield {"event": "chunk", "data": token}
273
+ except Exception as e: # noqa: BLE001
274
+ logger.error("help streaming failed", user_id=user_id, error=str(e))
275
+ yield {"event": "error", "data": f"Help generation failed: {e}"}
276
+ return
277
+ yield {"event": "done", "data": ""}
278
+
279
  async def handle(
280
  self,
281
  message: str,
src/api/v1/help.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """`help` skill endpoint — dedicated, deterministic dispatch (pr/5 Phase 2).
2
+
3
+ `POST /api/v1/tools/help` streams state-aware next-step guidance over SSE. Unlike v1
4
+ — where `/help` was reachable only by letting the intent router classify a chat
5
+ message — this endpoint dispatches Help directly: the slash command IS the intent, so
6
+ there is no router round-trip and no misclassification risk (contract open-Q #2,
7
+ resolved in favour of a dedicated endpoint).
8
+
9
+ Contract: `API_ENDPOINTS_RESTRUCTURE.md` §3. The SSE shape mirrors `/chat/stream`, but
10
+ help never references documents, so `sources` is always `[]` and there are no `status`
11
+ pings. The `done` event carries the assistant `message_id` for observability
12
+ correlation (§7).
13
+
14
+ Python is generative-only (06-25 direction): this endpoint does NOT persist the turn —
15
+ Go owns writes to `analyses_messages`. It only generates + streams.
16
+ """
17
+
18
+ import json
19
+ import uuid
20
+ from typing import Optional
21
+
22
+ from fastapi import APIRouter, Depends, HTTPException
23
+ from pydantic import BaseModel
24
+ from sqlalchemy.ext.asyncio import AsyncSession
25
+ from sse_starlette.sse import EventSourceResponse
26
+
27
+ # Reuse the warm, process-shared ChatHandler (keeps HelpAgent + Azure clients warm)
28
+ # and the same history loader the chat endpoint uses. `load_history` reads by
29
+ # `analysis_id` (== room_id today); it moves to `analyses_messages` with DEV_PLAN #25.
30
+ from src.api.v1.chat import _chat_handler, load_history
31
+ from src.db.postgres.connection import get_db
32
+ from src.middlewares.logging import get_logger, log_execution
33
+
34
+ logger = get_logger("help_api")
35
+
36
+ router = APIRouter(prefix="/api/v1/tools", tags=["Tools"])
37
+
38
+
39
+ class HelpRequest(BaseModel):
40
+ user_id: str
41
+ analysis_id: str
42
+ # ⚠️ open-Q #1: Go may mint the assistant turn id and pass it; if absent, Python
43
+ # mints one and returns it on `done` so the FE can call /observability in parallel.
44
+ message_id: Optional[str] = None
45
+
46
+
47
+ @router.post("/help")
48
+ @log_execution(logger)
49
+ async def help_stream(request: HelpRequest, db: AsyncSession = Depends(get_db)):
50
+ """Stream state-aware next-step guidance (deterministic `/help` dispatch).
51
+
52
+ SSE event sequence:
53
+ 1. sources — always `[]` (help never references documents)
54
+ 2. chunk — text fragments of the guidance
55
+ 3. done — `{"message_id": "..."}` for the observability lookup
56
+ """
57
+ message_id = request.message_id or f"msg_{uuid.uuid4().hex[:12]}"
58
+ try:
59
+ history = await load_history(db, request.analysis_id, limit=10)
60
+
61
+ async def stream_response():
62
+ async for event in _chat_handler.stream_help(
63
+ request.user_id,
64
+ request.analysis_id,
65
+ history=history,
66
+ message=None,
67
+ ):
68
+ if event["event"] == "done":
69
+ # Stamp the turn id so the FE can fetch /observability for it.
70
+ yield {"event": "done", "data": json.dumps({"message_id": message_id})}
71
+ elif event["event"] == "error":
72
+ yield event
73
+ return
74
+ else:
75
+ # `sources` ([]) and `chunk` pass through unchanged.
76
+ yield event
77
+
78
+ return EventSourceResponse(stream_response())
79
+
80
+ except Exception as e:
81
+ logger.error("Help failed", error=str(e))
82
+ raise HTTPException(status_code=500, detail=f"Help failed: {str(e)}")
src/api/v1/tools.py CHANGED
@@ -130,11 +130,14 @@ _COMMAND_CATALOG: list[CommandResponse] = [
130
  ]
131
 
132
 
133
- @router.get("/tools", response_model=ListToolsResponse)
134
  @log_execution(logger)
135
  async def list_tools() -> ListToolsResponse:
136
  """List the user-invocable slash-command catalog (skills + tools).
137
 
138
  Static per deployment — safe for the Golang backend to cache.
 
 
 
139
  """
140
  return ListToolsResponse(count=len(_COMMAND_CATALOG), tools=_COMMAND_CATALOG)
 
130
  ]
131
 
132
 
133
+ @router.get("/tools/list", response_model=ListToolsResponse)
134
  @log_execution(logger)
135
  async def list_tools() -> ListToolsResponse:
136
  """List the user-invocable slash-command catalog (skills + tools).
137
 
138
  Static per deployment — safe for the Golang backend to cache.
139
+
140
+ pr/5 Phase 2: moved from `GET /api/v1/tools` to `GET /api/v1/tools/list` so the
141
+ skills group is `/tools/list` · `/tools/help` · `/tools/report`.
142
  """
143
  return ListToolsResponse(count=len(_COMMAND_CATALOG), tools=_COMMAND_CATALOG)