Skip to content

Commit 2f15572

Browse files
authored
Merge pull request #91 from zestones/22-m36-ui-tools-pour-kb-builder-onboarding
22 m36 UI tools pour kb builder onboarding
2 parents 1ea4d90 + abb3614 commit 2f15572

5 files changed

Lines changed: 574 additions & 7 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Temporary WebSocket broadcast stub — replace with ``ws_manager`` when M4.1 lands.
2+
3+
Issue #22 (M3.6) wires `ui_render` events into the KB Builder pipeline so the
4+
frontend Activity Feed and Onboarding wizard can render progress in real time.
5+
M4.1 (#23) introduces ``core.ws_manager.WSManager`` which owns the actual
6+
WebSocket fan-out; until that ships, every call site fires through this shim
7+
so the swap is one diff::
8+
9+
# before (M3.6)
10+
from agents.kb_builder._ws_stub import broadcast_stub
11+
await broadcast_stub("ui_render", {...})
12+
13+
# after (M4.1)
14+
from core.ws_manager import ws_manager
15+
await ws_manager.broadcast("ui_render", {...})
16+
17+
The payload shape this stub accepts is the contract documented in issue #23
18+
and reproduced in #22 §1 — keep them aligned so callers do not need to be
19+
edited again at swap time. The stub is intentionally awaitable so callers
20+
already use ``await`` and the eventual swap is purely textual.
21+
22+
This module emits no WebSocket frames and has no side-effects beyond a single
23+
``logging`` call. It must not import ``WebSocket`` or anything from
24+
``core.ws_manager`` to avoid a circular dependency once that module exists.
25+
"""
26+
27+
from __future__ import annotations
28+
29+
import json
30+
import logging
31+
from typing import Any
32+
33+
_log = logging.getLogger("aria.kb_builder.ws_stub")
34+
35+
36+
async def broadcast_stub(event_type: str, payload: dict[str, Any]) -> None:
37+
"""Log a structured representation of a future WebSocket broadcast.
38+
39+
The first positional arg matches ``WSManager.broadcast``'s signature
40+
(``event_type``, ``payload``) so the M4.1 swap is mechanical.
41+
42+
Args:
43+
event_type: One of the event types listed in issue #23 (here, almost
44+
always ``"ui_render"``).
45+
payload: The JSON-serialisable dict that will become the WebSocket
46+
frame's body. Logged as a single line so it can be grepped from
47+
container logs during demo prep.
48+
"""
49+
try:
50+
rendered = json.dumps(payload, default=str, separators=(",", ":"))
51+
except (TypeError, ValueError):
52+
rendered = repr(payload)
53+
_log.info("ws_stub event=%s payload=%s", event_type, rendered)

backend/agents/kb_builder/onboarding/service.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import uuid
1919
from typing import Any
2020

21+
from agents.kb_builder._ws_stub import broadcast_stub
2122
from agents.kb_builder.onboarding import session_store
2223
from agents.kb_builder.onboarding.extraction import extract_patch
2324
from agents.kb_builder.onboarding.questions import QUESTIONS
@@ -196,6 +197,35 @@ async def submit_onboarding_message(session_id: str, answer: str) -> dict[str, A
196197
)
197198
session.question_index += 1
198199

200+
# M3.6 — emit per-question progress AFTER the MCP write completes
201+
# (issue #22 acceptance #5: "All events are emitted AFTER the
202+
# corresponding MCP write completes (not before)"). Will become
203+
# ``ws_manager.broadcast`` once M4.1 (#23) lands.
204+
await broadcast_stub(
205+
"ui_render",
206+
{
207+
"agent": "kb_builder",
208+
"component": "kb_progress",
209+
"props": {
210+
"cell_id": session.cell_id,
211+
"steps": [
212+
{
213+
"label": f"Question {q['index'] + 1}/{len(QUESTIONS)}",
214+
"status": (
215+
"done"
216+
if q["index"] < session.question_index
217+
else (
218+
"in_progress" if q["index"] == session.question_index else "pending"
219+
)
220+
),
221+
}
222+
for q in QUESTIONS
223+
],
224+
},
225+
"turn_id": None, # set by orchestrator ContextVar after M4.1 (#23)
226+
},
227+
)
228+
199229
if is_final:
200230
# Re-read the row through the repository so the response shape matches
201231
# ``EquipmentKbOut`` (the router serialises it for the client).
@@ -206,6 +236,24 @@ async def submit_onboarding_message(session_id: str, answer: str) -> dict[str, A
206236
if rec is None:
207237
# Should be impossible — update_equipment_kb just returned success.
208238
raise NotFoundError(f"No equipment_kb row for cell {session.cell_id} after onboarding")
239+
# M3.6 — final card AFTER the DB re-read so the frontend's re-fetch
240+
# via ``GET /api/v1/kb/equipment/{cell_id}`` (M8.2) sees the
241+
# post-onboarding KB. Will become ``ws_manager.broadcast`` after M4.1.
242+
await broadcast_stub(
243+
"ui_render",
244+
{
245+
"agent": "kb_builder",
246+
"component": "equipment_kb_card",
247+
"props": {
248+
"cell_id": session.cell_id,
249+
"highlight_fields": [
250+
"thresholds.vibration_mm_s",
251+
"failure_patterns",
252+
],
253+
},
254+
"turn_id": None, # set by orchestrator ContextVar after M4.1 (#23)
255+
},
256+
)
209257
return {
210258
"session_id": session_id,
211259
"complete": True,

backend/modules/kb/router.py

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
start_onboarding,
1313
submit_onboarding_message,
1414
)
15+
from agents.kb_builder._ws_stub import broadcast_stub
1516
from aria_mcp.client import mcp_client
1617
from core.api_response import created, ok
1718
from core.database import get_db
@@ -51,6 +52,45 @@ def _ser_failure(r):
5152
return FailureHistoryOut.model_validate(decode_record(r, JSON_FIELDS)).model_dump(mode="json")
5253

5354

55+
# 5 phase labels — kept in sync with issue #22 §2 acceptance criterion
56+
# ("PDF upload emits exactly 5 ui_render events with component kb_progress").
57+
_UPLOAD_PHASES: tuple[str, ...] = (
58+
"Validating PDF",
59+
"Reading pages with Opus vision",
60+
"Extracting thresholds",
61+
"Validating schema",
62+
"Saving knowledge base",
63+
)
64+
65+
66+
def _phase_status(idx: int, active_idx: int) -> str:
67+
if idx < active_idx:
68+
return "done"
69+
if idx == active_idx:
70+
return "in_progress"
71+
return "pending"
72+
73+
74+
def _upload_steps(active_idx: int) -> list[dict[str, str]]:
75+
return [
76+
{"label": label, "status": _phase_status(i, active_idx)}
77+
for i, label in enumerate(_UPLOAD_PHASES)
78+
]
79+
80+
81+
async def _emit_upload_phase(cell_id: int, active_idx: int) -> None:
82+
"""Stub WS broadcast for one PDF-upload phase. See M3.6 (#22) / M4.1 (#23)."""
83+
await broadcast_stub(
84+
"ui_render",
85+
{
86+
"agent": "kb_builder",
87+
"component": "kb_progress",
88+
"props": {"cell_id": cell_id, "steps": _upload_steps(active_idx)},
89+
"turn_id": None, # set by orchestrator ContextVar after M4.1 (#23)
90+
},
91+
)
92+
93+
5494
@router.get("/equipment")
5595
async def list_kb(conn: asyncpg.Connection = Depends(get_db)):
5696
rows = await KbRepository(conn).list()
@@ -95,22 +135,24 @@ async def upload_pdf(
95135
recomputes completeness.
96136
5. Re-read and serialise via ``EquipmentKbOut``.
97137
98-
Phase log lines stand in for live progress events until M4.1 (#23) lands
99-
the websocket manager — see issue #18 §7.
138+
Phase events are emitted via ``broadcast_stub`` (M3.6 / issue #22). Each
139+
call will become ``ws_manager.broadcast("ui_render", ...)`` once M4.1
140+
(#23) lands; the payload shape is already final, only the transport is
141+
stubbed.
100142
"""
101143
if file.content_type not in ("application/pdf", "application/octet-stream"):
102144
raise HTTPException(400, "File must be a PDF")
103145

104146
lock = _upload_locks.setdefault(cell_id, asyncio.Lock())
105147
async with lock:
106-
log.info("kb_upload[cell=%d] phase=Validating PDF", cell_id)
148+
await _emit_upload_phase(cell_id, 0)
107149
pdf_bytes = await file.read()
108150
if not pdf_bytes:
109151
raise HTTPException(400, "Uploaded file is empty")
110152

111-
log.info("kb_upload[cell=%d] phase=Reading pages with Opus vision", cell_id)
153+
await _emit_upload_phase(cell_id, 1)
112154
try:
113-
log.info("kb_upload[cell=%d] phase=Extracting thresholds", cell_id)
155+
await _emit_upload_phase(cell_id, 2)
114156
kb, raw_markdown = await extract_from_pdf(pdf_bytes, cell_id)
115157
except ValueError as e:
116158
# ValidationError is a subclass of ValueError, so this single
@@ -122,11 +164,11 @@ async def upload_pdf(
122164
raise HTTPException(413, msg) from e
123165
raise HTTPException(422, f"Extraction failed after retry: {msg}") from e
124166

125-
log.info("kb_upload[cell=%d] phase=Validating schema", cell_id)
167+
await _emit_upload_phase(cell_id, 3)
126168
kb_dict = kb.model_dump(exclude={"kb_meta"})
127169
kb_dict = await bootstrap_thresholds(cell_id, kb_dict)
128170

129-
log.info("kb_upload[cell=%d] phase=Saving knowledge base", cell_id)
171+
await _emit_upload_phase(cell_id, 4)
130172
result = await mcp_client.call_tool(
131173
"update_equipment_kb",
132174
{

0 commit comments

Comments
 (0)