[KM-687] Add Dedicated /tools/help Endpoint + Regroup Tools List
Browse filespr/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 +2 -2
- main.py +2 -0
- src/agents/chat_handler.py +49 -0
- src/api/v1/help.py +82 -0
- src/api/v1/tools.py +4 -1
|
@@ -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 |
|
| 30 |
-
| **2 — v2 + regroup** | Tools list → `/api/v1/tools/list` | Sofhia |
|
| 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`. |
|
|
@@ -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("/")
|
|
@@ -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,
|
|
@@ -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)}")
|
|
@@ -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)
|