docs+tests: #873 事件契约与双 Claw Adapter 文档及哨兵测试
- 新增 tests/test_engine_event.py (31 用例: EngineEvent 线格式/to_wire/from_wire, SseAdapter 裁剪+帧格式+frames, BufferAdapter 累积/媒体/诊断/截断, Diagnosis, test_dual_claw_wiring_source_guard 校验 chat.py/im_llm.py 接线) - 新增 docs/dual-claw-event-contract.md (#873 契约文档 7 节) - 全量 pytest 64 用例全绿 (含 test_dual_claw/test_node_registry 无回归) - 仅文档/测试,非运行时代码,不 push
This commit is contained in:
1 parent
33d0f9c32e
commit
c1f8b164bb
2 files changed
+536
No files matched your search
@@ -0,0 +1,140 @@
|
||||
# 双 Claw 引擎事件契约(#873 · P0)
|
||||
|
||||
> 任务归属:加速带 #873(事件契约结构化:diagnosis + IM Bot 缓冲)
|
||||
> 关联:#874 NodeRegistry(节点身份)、#876 OTel(双 Claw 标签)、#877 落库集成
|
||||
> 真相源:`app/core/engine_event.py` + `app/core/claw_adapters.py`(容器 `qihu-aiflow`,镜像 `with-deps=df7adefc7333`)
|
||||
> 单测哨兵:`tests/test_engine_event.py`(EngineEvent/Adapter 层) + `tests/test_dual_claw.py`(R-DUAL 双端一致性)
|
||||
|
||||
---
|
||||
|
||||
## 1. Why:为什么需要收敛事件契约
|
||||
|
||||
此前引擎事件是「散落在 `chat_service` 里的裸 dict」:
|
||||
|
||||
- 构造点分散,字段全靠记忆,漏一个键前端就**静默不渲染**;
|
||||
- 没有 `trace_id` / `tenant_id` / `claw` 标签,可观测(#876)无处落脚;
|
||||
- 失败只有一句人话字符串,前端靠 `result.includes('失败')` 猜错误 —— 极脆。
|
||||
|
||||
#873 把事件收敛为「**唯一构造入口**」:所有事件必须经 `make_*` 工厂产出,再由 `to_wire()` 统一序列化。新增字段一律「**非空才输出**」,保证既有前端(`ChatPage.tsx`)零改动。
|
||||
|
||||
---
|
||||
|
||||
## 2. 单引擎原则(最高约束)
|
||||
|
||||
```
|
||||
能力只在 EngineCore(`_send_message_core`) 实现一次;
|
||||
Adapter 只做「引擎事件 → 通道」的搬运,不含任何业务判断。
|
||||
|
||||
┌──────────────┐ on_event(EngineEvent) ┌──────────────┐
|
||||
│ EngineCore │ ────────────────────────► │ ClawAdapter │
|
||||
└──────────────┘ └──────┬───────┘
|
||||
│
|
||||
┌───────────────────────────────┴────────────────────────┐
|
||||
▼ ▼
|
||||
SseAdapter (Web Claw) BufferAdapter (IM Bot Claw)
|
||||
逐帧 `data: {...}\n\n` 缓冲装配 → 整条 str | 媒体 dict
|
||||
```
|
||||
|
||||
**历史教训(#866)**:IM 侧曾出现「第二份实现」,一次全量部署就把它冲掉、能力静默回退。因此两个 Claw 的差异**必须、且只能**收敛在本文件的 `SseAdapter` / `BufferAdapter` 里。任何把引擎事件序列化逻辑写回 `chat.py` / `im_llm.py` 的提交,都会被 `test_engine_event.py` 与 `test_dual_claw.py` 的接线哨兵捕获。
|
||||
|
||||
---
|
||||
|
||||
## 3. 线格式契约(authoritative —— 前端已消费,禁止破坏)
|
||||
|
||||
| 事件 | 线格式(dict) |
|
||||
|------|----------------|
|
||||
| `delta` | `{"type":"delta","content": str}` |
|
||||
| `reasoning` | `{"type":"reasoning","content": str}` |
|
||||
| `step` | `{"type":"step","step": {...}, "index"?: int}` |
|
||||
| `done` | `{"type":"done","message_id","conversation_id","content","process_steps","used_skills","used_mcp","generating"}` |
|
||||
| `error` | `{"type":"error","message": str}` |
|
||||
|
||||
`step` 对象契约(`AiProcessBlock` 消费):
|
||||
|
||||
| kind | 结构 |
|
||||
|------|------|
|
||||
| `thinking` | `{"type":"thinking","text": str}` |
|
||||
| `skill` | `{"type":"skill","name": str,"args": dict,"result": str}` |
|
||||
| `mcp` | `{"type":"mcp","server": str,"name": str,"args": dict,"result": str}` |
|
||||
| `agent`/`kb`/`media` | 同构,按 `type` 选渲染分支 |
|
||||
|
||||
**附加字段(新增,前端可选消费,非空才输出)**:
|
||||
- `diagnosis`: `{code, severity, summary, detail, hint, retryable}` —— 结构化诊断,取代「把原因塞进 result 字符串」;
|
||||
- `phase`: `"start" | "ok" | "fail"` —— 步骤执行阶段;
|
||||
- `node`: `{"id","type","label"}` —— #874 图节点身份;
|
||||
- `seq` / `trace_id` / `tenant_id` / `claw` —— 可观测维度(#876)。
|
||||
|
||||
---
|
||||
|
||||
## 4. EngineEvent API(`engine_event.py`)
|
||||
|
||||
### 工厂(唯一构造入口)
|
||||
- `make_delta(content, *, seq=0, trace_id="", tenant_id="", claw="")` → 正文增量
|
||||
- `make_reasoning(content, **)` → 思维链增量
|
||||
- `make_step(kind, *, name="", text="", server="", args=None, result="", phase="", index=None, diagnosis=None, node=None, **)` → 过程步骤
|
||||
- `index`:同一步骤「先 `phase=start` 占位,执行完同 `index` 发 `phase=ok|fail` 覆盖」→ 前端实时进度;
|
||||
- `node`(#874):承载图节点身份,非空才输出,老前端忽略;
|
||||
- `diagnosis` 为 `error` 且 `result` 为空时,自动用 `diagnosis.to_text()` 兜底 `result`(否则前端判定「运行中」)。
|
||||
- `make_done(*, message_id, conversation_id, content="", process_steps=None, used_skills=None, used_mcp=None, generating=False, **)` → 终帧(字段集与遗留 done 帧**完全一致**)
|
||||
- `make_error(message, *, diagnosis=None, **)` → 错误终帧
|
||||
|
||||
### 序列化
|
||||
- `EngineEvent.to_wire() -> dict`:兼容铁律 —— 既有键原样输出;新增键一律「非空才输出」(不撑大帧体积、不触发未知分支)。
|
||||
- `EngineEvent.to_text() -> str`:无结构 UI(IM Bot)降级文本;返回空串表示「本事件无需展示」。
|
||||
|
||||
### 诊断
|
||||
- `Diagnosis(code, severity, summary, detail, hint, retryable)`:
|
||||
- `to_dict()` / `from_dict(d)` 双向;
|
||||
- `to_text()`:IM 纯文本降级(`ℹ️/⚠️/❌` 图标 + summary + detail + 建议)。
|
||||
|
||||
### 迁移兼容
|
||||
- `from_wire(payload)`:把遗留裸 dict 提升为 `EngineEvent`(Adapter 收到老代码 `on_event({...})` 后一律归一化)。`type` 缺失 / 非 dict → 返回 `None`(Adapter 丢弃并告警)。
|
||||
|
||||
---
|
||||
|
||||
## 5. ClawAdapter API(`claw_adapters.py`)
|
||||
|
||||
基类 `BaseClawAdapter`:
|
||||
- 实例可直接当 `on_event` 回调传给 EngineCore(`async def __call__(event)` → `emit`);
|
||||
- `emit(event)`:先 `from_wire` 归一化 → 计数 → `_handle`(异常被吞掉,Adapter 永不反噬引擎);
|
||||
- `_normalize(event)`:补全 `seq` / `trace_id` / `tenant_id` / `claw`(Adapter 自身默认值兜底)。
|
||||
|
||||
### SseAdapter(Web Claw,逐帧)
|
||||
- `claw = "web"`;构造 `SseAdapter(queue=None, *, wire_meta=False)`;
|
||||
- `_thin(ev, wire)`:**默认剥离** `seq/trace_id/tenant_id/claw`(实测 delta 单帧 46B→117B 膨胀 2.5x,2000 帧多传 ~140KB 噪音,前端不消费);**终帧保留 `trace_id`**(排障唯一抓手);
|
||||
- `frame(wire)` → `"data: " + json.dumps(wire, ensure_ascii=False) + "\n\n"`;
|
||||
- `frames()` 异步生成器:从 queue 取帧,遇 `__end__` 哨兵结束;
|
||||
- `close()`:幂等,投送哨兵帧。
|
||||
|
||||
### BufferAdapter(IM Bot Claw,缓冲装配)
|
||||
- `claw = "im"`;构造 `BufferAdapter(*, keep_reasoning=False, keep_diagnosis=True)`;
|
||||
- 累积规则:
|
||||
- `delta` 累积正文;
|
||||
- `reasoning` 默认丢弃(思维链不该刷屏微信),`keep_reasoning=True` 打开;
|
||||
- `step` 的 `error` 级 `diagnosis` 降级为文本尾注(用户至少知道哪步炸了);
|
||||
- `done.content` 优先于累积 delta(引擎已做最终裁剪/落库);
|
||||
- `assemble() -> str | dict`:
|
||||
- 媒体(以 `<img` / `<video` 开头)→ 返回结构化 `{"__media__": True, "media_type": "image"|"video", "url": "..."}`(IM 内联发图/发视频依赖,禁止退化成纯文本);
|
||||
- 空 body + error → `_render_failure()`;空 body + generating → 「内容正在生成」;否则正文 + 诊断尾注 + 生成提示;
|
||||
- 超 `_MAX_ASSEMBLED_CHARS=20000` 截断并标注「内容过长已截断」。
|
||||
|
||||
---
|
||||
|
||||
## 6. Web / IM 双 Claw 接线方式
|
||||
|
||||
| Claw | 入口 | Adapter | 线格式 |
|
||||
|------|------|---------|--------|
|
||||
| Web | `chat.py` `/messages/stream` | `SseAdapter`(委托,线格式逐字节一致) | SSE `data:` 逐帧 |
|
||||
| IM Bot | `im_llm.py`(灰阶门控) | `BufferAdapter` | 整条 `str` / `__media__` dict |
|
||||
|
||||
- `im_llm.py` 经 `BufferAdapter` 复用 `EngineCore`(`_call_engine_core`),媒体走快路径;
|
||||
- **IM_USE_ENGINE_CORE 灰阶门(默认 OFF)**:生产零改动,开启后 IM 走统一引擎,单引擎红利即时生效;
|
||||
- 两个 Adapter 必须分别被 `chat.py` / `im_llm.py` 引用(接线哨兵 `test_engine_event.py::test_dual_claw_wiring_source_guard` 锁死)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 测试与可观测
|
||||
|
||||
- `tests/test_engine_event.py`:EngineEvent 工厂 / `to_wire` 兼容 / `from_wire` / SseAdapter 裁剪与 SSE 格式 / BufferAdapter 累积与媒体 / 双 Claw 接线契约(确定性、无 DB、无网络);
|
||||
- `tests/test_dual_claw.py`(R-DUAL-*):Web/IM 能力一致、命名同源、`__media__` 保留、OTel `tenant_id`+`claw_type` 标签;
|
||||
- 指标(#876):`aiflow_span_count` / `aiflow_span_duration_ms` / `aiflow_llm_route_total`,维度含 `claw_type ∈ {web, im_wechat}`、`tenant_id`,Grafana 看板 `qihuaiflow-dual-claw-876`。
|
||||
Reference in new issue
Block a user