/fix message_id for chat/stream Endpoint

#6
by rhbt6767 - opened
API_ENDPOINTS_RESTRUCTURE.md CHANGED
@@ -14,7 +14,7 @@ lock them.
14
  unwired from `main` + Swagger (not deleted).
15
 
16
  **Open coordination questions (need a decision with Harry) — flagged inline as ⚠️:**
17
- 1. **`message_id` origin** — who mints the assistant turn id used to correlate stream ↔ observability? (Recommend: Go mints it, passes it in the chat request, Python echoes on `done`.)
18
  2. **Deterministic `/help` dispatch** — dedicated endpoint (recommended below) vs router classification.
19
  3. **Observability storage** — single JSONB row per message (recommended) vs 3 normalized tables.
20
 
@@ -31,15 +31,15 @@ the `done` event now carries the assistant `message_id` for observability correl
31
  {
32
  "user_id": "u_1a2b3c",
33
  "analysis_id": "an_42",
34
- "message_id": "msg_88f1",
35
  "message": "What were total sales by region last quarter?"
36
  }
37
  ```
38
 
39
  - `analysis_id` is the analysis-session id (replaces `room_id`). No auth header (handled by Go).
40
- - ⚠️ `message_id` (optional): the assistant turn id. **Recommended: Go mints it** alongside the
41
- `analyses_messages` row and passes it here, so the FE can call `/api/v1/observability?message_id=...`
42
- in parallel. If omitted, Python mints one and returns it on `done`.
 
43
 
44
  **Response:** `text/event-stream`. Events arrive in this order:
45
 
 
14
  unwired from `main` + Swagger (not deleted).
15
 
16
  **Open coordination questions (need a decision with Harry) — flagged inline as ⚠️:**
17
+ 1. ~~**`message_id` origin** — who mints the assistant turn id used to correlate stream ↔ observability?~~ **RESOLVED (pr/6):** **Python is the sole minter.** It is not a request field; Python always mints it (server-authoritative keeps the `/observability` correlation key out of client control, for FE-security) and returns it on the `done` event. Go/FE read it off the stream. *(The `/observability` consumer itself is future work — a later PR.)*
18
  2. **Deterministic `/help` dispatch** — dedicated endpoint (recommended below) vs router classification.
19
  3. **Observability storage** — single JSONB row per message (recommended) vs 3 normalized tables.
20
 
 
31
  {
32
  "user_id": "u_1a2b3c",
33
  "analysis_id": "an_42",
 
34
  "message": "What were total sales by region last quarter?"
35
  }
36
  ```
37
 
38
  - `analysis_id` is the analysis-session id (replaces `room_id`). No auth header (handled by Go).
39
+ - `message_id` is **not** a request field. Python always mints the assistant turn id
40
+ (server-authoritative, for FE-security) and returns it on `done`; the FE reads it there and passes
41
+ it to `/api/v1/observability?message_id=...`. Any `message_id` a caller sends is ignored. *(The
42
+ `/observability` endpoint is future work — a later PR; §7 is a forward-looking sketch.)*
43
 
44
  **Response:** `text/event-stream`. Events arrive in this order:
45
 
DEV_PLAN.md CHANGED
@@ -24,9 +24,9 @@ the endpoint contract *before* coding the tools. Status legend: ⬜ not started
24
  | **P0 — contract** | Draft + send endpoint contract to Harry (chat v2 · tools group · observability) | Rifqi + Sofhia | ✅ | `API_ENDPOINTS_RESTRUCTURE.md` sent 2026-06-30 (before-noon deadline met). Observability section flagged tentative. |
25
  | **1 — unwire** | Unwire `users`(login)/`document`/`room`/`db_client`/`data_catalog`/`analysis` from `main` + Swagger | Sofhia | ✅ | **KM-686**, commit `0b2d678`. Commented, not deleted; `chat`/`report`/`tools` kept mounted. Resolves the analysis-CRUD scope Q — whole `analysis` router unwired (Go owns it). |
26
  | **2 — v2 + regroup** | Create `src/api/v2/` and move the chat pilot there | Rifqi | ✅ | New `src/api/v2/__init__.py` + `src/api/v2/chat.py` (`POST /api/v2/chat/stream`), mounted in `main.py`. Only chat in v2; v1 `/chat/stream` kept mounted until FE moves over. Routes import-verified. |
27
- | **2 — v2 + regroup** | Chat: `room_id` → **`analysis_id`** (request field + handler + history) | Rifqi | ✅ | v2 `ChatRequest{user_id, analysis_id, message, message_id?}`; reuses warm `ChatHandler` + v1 cache/history helpers; `done` returns `{message_id}` (minted Python-side if Go omits, open-Q #1). Persistence kept transitionally → still ties to #25 (`analyses_messages`); ruff-clean. |
28
  | **2 — v2 + regroup** | Move report under tools → `/api/v1/tools/report` (+ version routes) | Rifqi | ✅ | report router re-prefixed `/api/v1` → `/api/v1/tools` (all 3 routes move together), tag → `Tools`; old `/api/v1/report` gone. Same functionality, new home. Import-verified. |
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. |
@@ -34,14 +34,16 @@ the endpoint contract *before* coding the tools. Status legend: ⬜ not started
34
  | **3 — tools + obs** | Audit `report_inputs` — covers planning + tool I/O + source? add cols / new store | Rifqi | ⬜ | **Rec:** dedicated provenance store = 1 JSONB row per message (logical 3 sections); keep Langfuse for engineering. |
35
  | **3 — tools + obs** | Build `GET /api/v1/observability` (one merged response) | Rifqi | ⬜ | Intent-based source rules (greeting/help = none; retrieve = required). Richness path-dependent (full planning only on slow path). |
36
  | **3 — tools + obs** | Keep stream **text-only**; observability is a separate parallel call | Rifqi | ⬜ | Per mentor — don't slow the stream. |
37
- | **3 — tools + obs** | Resolve `message_id` correlation (stream ↔ observability) with Harry | Rifqi ↔ Harry | | **Rec:** Go mints `message_id`, passes in chat request, Python echoes on `done`. |
38
  | **4 — biz questions** | Get Go folder; confirm `business_questions` in create-analysis (max 5); sync Python | Harry/Mentor → Rifqi | ⬜ | Go currently missing the field ("lagi difixing"). Python already models objective + business_questions. |
39
  | **deferred** | Report formats: PPT (preferred) / PDF / infographic on download | — | ⏸️ | MD is fine for the FE preview stage now. |
40
  | **deferred** | Charts (Plotly→JSON) + images tables | — | ⏸️ | Carried from §4 #26/#27. |
41
 
42
  **Next up:** Phase 2 Python work is **done** (chat→v2 `analysis_id`; `help`/`report`/`list` regrouped
43
- under `/api/v1/tools/`). Remaining: **Phase 3** the observability scratchpad + `GET /api/v1/observability`
44
- (shape already speced in the contract), then **Phase 4** (business questions, Go-blocked).
 
 
45
 
46
  ---
47
 
 
24
  | **P0 — contract** | Draft + send endpoint contract to Harry (chat v2 · tools group · observability) | Rifqi + Sofhia | ✅ | `API_ENDPOINTS_RESTRUCTURE.md` sent 2026-06-30 (before-noon deadline met). Observability section flagged tentative. |
25
  | **1 — unwire** | Unwire `users`(login)/`document`/`room`/`db_client`/`data_catalog`/`analysis` from `main` + Swagger | Sofhia | ✅ | **KM-686**, commit `0b2d678`. Commented, not deleted; `chat`/`report`/`tools` kept mounted. Resolves the analysis-CRUD scope Q — whole `analysis` router unwired (Go owns it). |
26
  | **2 — v2 + regroup** | Create `src/api/v2/` and move the chat pilot there | Rifqi | ✅ | New `src/api/v2/__init__.py` + `src/api/v2/chat.py` (`POST /api/v2/chat/stream`), mounted in `main.py`. Only chat in v2; v1 `/chat/stream` kept mounted until FE moves over. Routes import-verified. |
27
+ | **2 — v2 + regroup** | Chat: `room_id` → **`analysis_id`** (request field + handler + history) | Rifqi | ✅ | v2 `ChatRequest{user_id, analysis_id, message}`; reuses warm `ChatHandler` + v1 cache/history helpers; `done` returns `{message_id}` (always minted Python-side, server-authoritative open-Q #1 resolved in pr/6). Persistence kept transitionally → still ties to #25 (`analyses_messages`); ruff-clean. |
28
  | **2 — v2 + regroup** | Move report under tools → `/api/v1/tools/report` (+ version routes) | Rifqi | ✅ | report router re-prefixed `/api/v1` → `/api/v1/tools` (all 3 routes move together), tag → `Tools`; old `/api/v1/report` gone. Same functionality, new home. Import-verified. |
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 always minted Python-side, server-authoritative (open-Q #1 resolved, pr/6). 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. |
 
34
  | **3 — tools + obs** | Audit `report_inputs` — covers planning + tool I/O + source? add cols / new store | Rifqi | ⬜ | **Rec:** dedicated provenance store = 1 JSONB row per message (logical 3 sections); keep Langfuse for engineering. |
35
  | **3 — tools + obs** | Build `GET /api/v1/observability` (one merged response) | Rifqi | ⬜ | Intent-based source rules (greeting/help = none; retrieve = required). Richness path-dependent (full planning only on slow path). |
36
  | **3 — tools + obs** | Keep stream **text-only**; observability is a separate parallel call | Rifqi | ⬜ | Per mentor — don't slow the stream. |
37
+ | **3 — tools + obs** | Resolve `message_id` correlation (stream ↔ observability) with Harry | Rifqi ↔ Harry | | **RESOLVED (pr/6):** Python is the **sole minter** — `message_id` dropped from the `/api/v2/chat/stream` + `/api/v1/tools/help` request bodies; always minted server-side (server-authoritative, FE-security) and returned on `done`. Any caller-sent `message_id` is ignored. Contract open-Q #1 closed. |
38
  | **4 — biz questions** | Get Go folder; confirm `business_questions` in create-analysis (max 5); sync Python | Harry/Mentor → Rifqi | ⬜ | Go currently missing the field ("lagi difixing"). Python already models objective + business_questions. |
39
  | **deferred** | Report formats: PPT (preferred) / PDF / infographic on download | — | ⏸️ | MD is fine for the FE preview stage now. |
40
  | **deferred** | Charts (Plotly→JSON) + images tables | — | ⏸️ | Carried from §4 #26/#27. |
41
 
42
  **Next up:** Phase 2 Python work is **done** (chat→v2 `analysis_id`; `help`/`report`/`list` regrouped
43
+ under `/api/v1/tools/`). The `message_id` correlation contract is now settled in **pr/6** (Python sole
44
+ minter, stream-only). The full **Phase 3 observability build** scratchpad + `GET /api/v1/observability`
45
+ (shape sketched in the contract §7) — is slated for a **later PR**, then **Phase 4** (business
46
+ questions, Go-blocked).
47
 
48
  ---
49
 
main.py CHANGED
@@ -16,7 +16,8 @@ from slowapi.errors import RateLimitExceeded
16
  # from src.api.v1.db_client import router as db_client_router # unwired: Go registers DB client
17
  # from src.api.v1.data_catalog import router as data_catalog_router # unwired: Go handles the catalog
18
  # from src.api.v1.analysis import router as analysis_router # unwired: Go owns create/update analysis
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
@@ -62,7 +63,7 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
62
  # app.include_router(db_client_router) # unwired: Go registers DB client
63
  # app.include_router(data_catalog_router) # unwired: Go handles the catalog
64
  # app.include_router(analysis_router) # unwired: Go owns create/update analysis
65
- app.include_router(chat_router) # v1 chat/stream (room_id) kept until FE moves to v2
66
  app.include_router(report_router)
67
  app.include_router(tools_router)
68
  app.include_router(help_router)
 
16
  # from src.api.v1.db_client import router as db_client_router # unwired: Go registers DB client
17
  # from src.api.v1.data_catalog import router as data_catalog_router # unwired: Go handles the catalog
18
  # from src.api.v1.analysis import router as analysis_router # unwired: Go owns create/update analysis
19
+ # from src.api.v1.chat import router as chat_router # unwired: replaced by /api/v2/chat/stream
20
+ # NOTE: src.api.v1.chat module still imported by v2 chat + /tools/help — keep the file.
21
  from src.api.v1.report import router as report_router
22
  from src.api.v1.tools import router as tools_router
23
  from src.api.v1.help import router as help_router # pr/5 Phase 2: dedicated /tools/help
 
63
  # app.include_router(db_client_router) # unwired: Go registers DB client
64
  # app.include_router(data_catalog_router) # unwired: Go handles the catalog
65
  # app.include_router(analysis_router) # unwired: Go owns create/update analysis
66
+ # app.include_router(chat_router) # unwired: v2 chat replaces it (drops v1 cache ops routes)
67
  app.include_router(report_router)
68
  app.include_router(tools_router)
69
  app.include_router(help_router)
src/api/v1/help.py CHANGED
@@ -8,8 +8,9 @@ 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.
@@ -17,7 +18,6 @@ Go owns writes to `analyses_messages`. It only generates + streams.
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
@@ -39,9 +39,6 @@ router = APIRouter(prefix="/api/v1/tools", tags=["Tools"])
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")
@@ -54,7 +51,8 @@ async def help_stream(request: HelpRequest, db: AsyncSession = Depends(get_db)):
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
 
@@ -79,4 +77,4 @@ async def help_stream(request: HelpRequest, db: AsyncSession = Depends(get_db)):
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)}")
 
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` always minted Python-side,
12
+ never accepted from the caller (server-authoritative; keys the future /observability
13
+ lookup, §7).
14
 
15
  Python is generative-only (06-25 direction): this endpoint does NOT persist the turn —
16
  Go owns writes to `analyses_messages`. It only generates + streams.
 
18
 
19
  import json
20
  import uuid
 
21
 
22
  from fastapi import APIRouter, Depends, HTTPException
23
  from pydantic import BaseModel
 
39
  class HelpRequest(BaseModel):
40
  user_id: str
41
  analysis_id: str
 
 
 
42
 
43
 
44
  @router.post("/help")
 
51
  2. chunk — text fragments of the guidance
52
  3. done — `{"message_id": "..."}` for the observability lookup
53
  """
54
+ # Server-authoritative turn id — never accepted from the caller (keys /observability).
55
+ message_id = f"msg_{uuid.uuid4().hex[:12]}"
56
  try:
57
  history = await load_history(db, request.analysis_id, limit=10)
58
 
 
77
 
78
  except Exception as e:
79
  logger.error("Help failed", error=str(e))
80
+ raise HTTPException(status_code=500, detail=f"Help failed: {str(e)}") from e
src/api/v2/chat.py CHANGED
@@ -5,9 +5,9 @@
5
  - the request carries an explicit **`analysis_id`** (replacing v1's `room_id`). The
6
  two are the same session id today (`analysis_id == room_id`), so the warm,
7
  process-shared `ChatHandler` and the v1 cache/history helpers are reused unchanged.
8
- - the `done` event carries the assistant **`message_id`** (minted Python-side if Go
9
- omits it contract open-Q #1), so the FE can fetch `/api/v1/observability` for the
10
- turn in parallel with the stream.
11
 
12
  Only chat moves to v2; the tools group + observability stay on `/api/v1` (contract:
13
  API_ENDPOINTS_RESTRUCTURE.md §1).
@@ -49,18 +49,16 @@ logger = get_logger("chat_api_v2")
49
  router = APIRouter(prefix="/api/v2", tags=["Chat"])
50
 
51
 
52
- def _mint_message_id(provided: str | None) -> str:
53
- """Use Go's assistant turn id when provided; else mint one (contract open-Q #1)."""
54
- return provided or f"msg_{uuid.uuid4().hex[:12]}"
 
55
 
56
 
57
  class ChatRequest(BaseModel):
58
  user_id: str
59
  analysis_id: str
60
  message: str
61
- # ⚠️ open-Q #1: Go may mint + pass the assistant turn id; if absent we mint one and
62
- # echo it on `done` so the FE can correlate /observability with this answer.
63
- message_id: str | None = None
64
 
65
 
66
  @router.post("/chat/stream")
@@ -76,7 +74,7 @@ async def chat_stream(request: ChatRequest, db: AsyncSession = Depends(get_db)):
76
  4. done — {"message_id": "..."} for the observability lookup
77
  """
78
  analysis_id = request.analysis_id
79
- message_id = _mint_message_id(request.message_id)
80
  redis = await get_redis()
81
  cache_key = _chat_cache_key(analysis_id, request.user_id, request.message)
82
 
 
5
  - the request carries an explicit **`analysis_id`** (replacing v1's `room_id`). The
6
  two are the same session id today (`analysis_id == room_id`), so the warm,
7
  process-shared `ChatHandler` and the v1 cache/history helpers are reused unchanged.
8
+ - the `done` event carries the assistant **`message_id`**. It is always minted
9
+ Python-side and is **never accepted from the caller** (server-authoritative it keys
10
+ the future `/observability` lookup; open-Q #1 resolved). The FE reads it off `done`.
11
 
12
  Only chat moves to v2; the tools group + observability stay on `/api/v1` (contract:
13
  API_ENDPOINTS_RESTRUCTURE.md §1).
 
49
  router = APIRouter(prefix="/api/v2", tags=["Chat"])
50
 
51
 
52
+ def _mint_message_id() -> str:
53
+ """Mint the assistant turn id. Server-authoritative never accepted from the caller
54
+ (it keys the future /observability lookup). Returned on `done`; open-Q #1 resolved."""
55
+ return f"msg_{uuid.uuid4().hex[:12]}"
56
 
57
 
58
  class ChatRequest(BaseModel):
59
  user_id: str
60
  analysis_id: str
61
  message: str
 
 
 
62
 
63
 
64
  @router.post("/chat/stream")
 
74
  4. done — {"message_id": "..."} for the observability lookup
75
  """
76
  analysis_id = request.analysis_id
77
+ message_id = _mint_message_id()
78
  redis = await get_redis()
79
  cache_key = _chat_cache_key(analysis_id, request.user_id, request.message)
80