refactor: 规则中性化——剥离 PPT 业务,统一为订单示范域
把规则从原 ppt-gen 项目泛化为通用脚手架,覆盖 19 个文件: - ddd/architecture/design-discipline/error-codes/communication-* 等: 领域术语、错误码、消息示例、扩展点、状态流统一为「订单」示范域 - agent-dag/agent.md/agent-concurrency/agent-llm-*:DAG 节点、配置、并发、 LLM 适配从「对话→预览→生成→截图→合成」改为「intake→process→validate→finalize」 - backend-db:Session 实体/状态机/ORM/仓储/checkpoint 对齐新流水线 - 移除幻灯片专属依赖与产物:agent 去 playwright/python-pptx, Dockerfile 去 Chromium 系统库,.gitignore 去 *.pptx - 保留合法通用项:ANSI(PTY 处理)、Playwright(E2E 测试工具)、Gemini CLI(真实 provider) 验证:gemini-cli/openai-api 两变体生成退出 0,生成项目零 PPT 残留, backend/agent 骨架均可 import 运行。
This commit is contained in:
@@ -5,27 +5,26 @@ paths:
|
||||
|
||||
# Agent DAG 编排与异常体系
|
||||
|
||||
PresentationState 聚合根、DAG 节点表、runner 协调器(含 checkpoint 恢复与写入点)以及异常体系与传播规则。
|
||||
SessionState 聚合根、DAG 节点表、runner 协调器(含 checkpoint 恢复与写入点)以及异常体系与传播规则。
|
||||
|
||||
> 下文以一条「会话→LLM 处理→结果」的通用流水线作示范,仅占位;节点与状态按你的业务替换,DAG 结构、checkpoint 与异常规则不变。
|
||||
|
||||
## DAG 编排(dag/)
|
||||
|
||||
### PresentationState(domain/state.py,聚合根状态)
|
||||
### SessionState(domain/state.py,聚合根状态)
|
||||
|
||||
> 属领域层:是会话聚合根的状态载体,封装状态机不变量(如合法的 status 迁移),纯逻辑零 IO。
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class PresentationState:
|
||||
class SessionState:
|
||||
session_id: str
|
||||
work_dir: Path
|
||||
html_path: Path | None = None
|
||||
pptx_path: Path | None = None
|
||||
preview_paths: list[Path] = field(default_factory=list)
|
||||
image_source: str = ""
|
||||
image_api_key: str = ""
|
||||
input_payload: dict | None = None
|
||||
result_path: Path | None = None
|
||||
status: Literal[
|
||||
"idle", "dialog", "previewing", "generating",
|
||||
"screenshotting", "synthesizing", "done", "refining", "error"
|
||||
"idle", "intake", "processing", "validating",
|
||||
"finalizing", "done", "revising", "error"
|
||||
] = "idle"
|
||||
error_message: str | None = None
|
||||
```
|
||||
@@ -34,25 +33,24 @@ class PresentationState:
|
||||
|
||||
节点签名统一:
|
||||
```python
|
||||
async def node_xxx(state: PresentationState, send: Callable, recv_queue: asyncio.Queue) -> PresentationState
|
||||
async def node_xxx(state: SessionState, send: Callable, recv_queue: asyncio.Queue) -> SessionState
|
||||
```
|
||||
|
||||
| 节点 | 触发条件 | 行为 |
|
||||
|------|---------|------|
|
||||
| `session_start` | 收到 `start_session` | 创建 work_dir,启动 PTY,skill 检查 |
|
||||
| `phase1_dialog` | PTY 启动后 | 透传对话消息;用户 `user_input` → 写 stdin |
|
||||
| `phase2_preview` | 检测到首个预览 HTML | 拦截 3 个 HTML → 推 `style_preview`;`style_pick` → 写 stdin |
|
||||
| `phase3_generate` | 用户选风格 | 监听 PTY 直到完整 HTML;保存 `slides.html` |
|
||||
| `screenshot` | `html_ready` 后自动 | Playwright 截图,逐页推 `log` 进度 |
|
||||
| `synthesize` | `screenshot` 完成 | python-pptx 合成,推 `pptx_ready` |
|
||||
| `refine_loop` | `done` 状态下收到 `user_input` | 写 PTY stdin → 重进 `phase3_generate` |
|
||||
| `session_start` | 收到 `start_session` | 创建 work_dir,启动 LLM 会话(PTY 或 API),就绪检查 |
|
||||
| `intake` | 会话就绪后 | 收集/校验输入;用户 `user_input` → 转交 LLM |
|
||||
| `process` | 输入就绪 | 调 LLM 产出结果,流式推 `log` 进度 |
|
||||
| `validate` | `process` 完成 | 业务规则校验产出,不合规则回错误或重试 |
|
||||
| `finalize` | `validate` 通过 | 落地最终结果文件,推 `result_ready` |
|
||||
| `revise_loop` | `done` 状态下收到 `user_input` | 转交 LLM → 重进 `process` |
|
||||
|
||||
### DAG 协调器(dag/runner.py)
|
||||
|
||||
`runner.py` 有两个职责:**恢复入口**(连接建立时检查 checkpoint)和**正常流程**(串联所有节点)。
|
||||
|
||||
```python
|
||||
async def run_session(state: PresentationState, send, recv_queue: asyncio.Queue,
|
||||
async def run_session(state: SessionState, send, recv_queue: asyncio.Queue,
|
||||
checkpoint: dict | None = None):
|
||||
try:
|
||||
# ── 断点恢复入口 ──────────────────────────────────────────────
|
||||
@@ -60,40 +58,37 @@ async def run_session(state: PresentationState, send, recv_queue: asyncio.Queue,
|
||||
resume_status = checkpoint.get("status")
|
||||
|
||||
if resume_status == "done":
|
||||
# 完全恢复:直接推送已有结果,无需任何 Gemini 调用
|
||||
state.html_path = Path(checkpoint["html_path"])
|
||||
state.pptx_path = Path(checkpoint["pptx_path"])
|
||||
await send({"type": "html_ready", "session_id": state.session_id})
|
||||
await send({"type": "pptx_ready", "session_id": state.session_id})
|
||||
# 完全恢复:直接推送已有结果,无需任何 LLM 调用
|
||||
state.result_path = Path(checkpoint["result_path"])
|
||||
await send({"type": "result_ready", "session_id": state.session_id})
|
||||
await send({"type": "done"})
|
||||
# 进入 refine 循环等待用户继续
|
||||
await _refine_loop(state, send, recv_queue)
|
||||
# 进入 revise 循环等待用户继续
|
||||
await _revise_loop(state, send, recv_queue)
|
||||
return
|
||||
|
||||
if resume_status in ("screenshotting", "synthesizing"):
|
||||
# slides.html 已在磁盘,跳过 Gemini,直接从截图节点恢复
|
||||
state.html_path = Path(checkpoint["html_path"])
|
||||
state.status = "screenshotting"
|
||||
if resume_status in ("validating", "finalizing"):
|
||||
# 中间产物已在磁盘,跳过 LLM,直接从校验节点恢复
|
||||
state.result_path = Path(checkpoint["result_path"])
|
||||
state.status = "validating"
|
||||
logger.info("Resuming from checkpoint | status={}", resume_status)
|
||||
state = await screenshot(state, send) # ← checkpoint 写入点 A
|
||||
state = await synthesize(state, send) # ← checkpoint 写入点 B
|
||||
state = await validate(state, send) # ← checkpoint 写入点 A
|
||||
state = await finalize(state, send) # ← checkpoint 写入点 B
|
||||
await send({"type": "done"})
|
||||
await _refine_loop(state, send, recv_queue)
|
||||
await _revise_loop(state, send, recv_queue)
|
||||
return
|
||||
|
||||
# dialog / generating / error:无法恢复,PTY 已死
|
||||
# intake / processing / error:无法恢复,LLM 会话已死
|
||||
await send({"type": "checkpoint_lost",
|
||||
"message": "上次会话在生成过程中中断,需要重新开始"})
|
||||
"message": "上次会话在处理过程中中断,需要重新开始"})
|
||||
|
||||
# ── 正常流程 ──────────────────────────────────────────────────
|
||||
state = await session_start(state, send, recv_queue)
|
||||
state = await phase1_dialog(state, send, recv_queue)
|
||||
state = await phase2_preview(state, send, recv_queue)
|
||||
state = await phase3_generate(state, send, recv_queue)
|
||||
state = await screenshot(state, send) # ← checkpoint 写入点 A
|
||||
state = await synthesize(state, send) # ← checkpoint 写入点 B
|
||||
state = await intake(state, send, recv_queue)
|
||||
state = await process(state, send, recv_queue)
|
||||
state = await validate(state, send) # ← checkpoint 写入点 A
|
||||
state = await finalize(state, send) # ← checkpoint 写入点 B
|
||||
await send({"type": "done"})
|
||||
await _refine_loop(state, send, recv_queue)
|
||||
await _revise_loop(state, send, recv_queue)
|
||||
|
||||
except AgentException as e:
|
||||
logger.error("DAG error | code={} msg={}", e.code, e.message)
|
||||
@@ -102,27 +97,27 @@ async def run_session(state: PresentationState, send, recv_queue: asyncio.Queue,
|
||||
logger.exception("Unexpected DAG error")
|
||||
await send({"type": "error", "code": "INTERNAL_ERROR", "message": str(e)})
|
||||
finally:
|
||||
cleanup_pty(state)
|
||||
cleanup_session(state)
|
||||
|
||||
|
||||
async def _refine_loop(state, send, recv_queue):
|
||||
async def _revise_loop(state, send, recv_queue):
|
||||
while True:
|
||||
msg = await recv_queue.get()
|
||||
if msg["type"] == "user_input":
|
||||
state = await refine_loop(state, send, recv_queue, msg["text"])
|
||||
state = await screenshot(state, send) # ← checkpoint 写入点 A
|
||||
state = await synthesize(state, send) # ← checkpoint 写入点 B
|
||||
state = await revise_loop(state, send, recv_queue, msg["text"])
|
||||
state = await validate(state, send) # ← checkpoint 写入点 A
|
||||
state = await finalize(state, send) # ← checkpoint 写入点 B
|
||||
await send({"type": "done"})
|
||||
```
|
||||
|
||||
### Checkpoint 写入点
|
||||
|
||||
`screenshot` 和 `synthesize` 节点完成后,通过推送特定消息触发 Backend 写入 DB:
|
||||
`validate` 和 `finalize` 节点完成后,通过推送特定消息触发 Backend 写入 DB:
|
||||
|
||||
| 写入点 | 节点 | 推送消息(Backend 监听后写 DB) |
|
||||
|--------|------|---------------------------------|
|
||||
| A | `screenshot` 完成 | `{"type": "html_ready", "html_path": "..."}` |
|
||||
| B | `synthesize` 完成 | `{"type": "pptx_ready", "pptx_path": "..."}` |
|
||||
| A | `validate` 完成 | `{"type": "validated", "result_path": "..."}` |
|
||||
| B | `finalize` 完成 | `{"type": "result_ready", "result_path": "..."}` |
|
||||
| — | `done` | `{"type": "done"}` → Backend 将 status 更新为 `"done"` |
|
||||
|
||||
**checkpoint 写入由 Backend 负责(监听 Agent 推送的消息),Agent 只负责推送正确的消息。**
|
||||
@@ -140,29 +135,25 @@ class AgentException(Exception):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
|
||||
class GeminiStartupError(AgentException):
|
||||
class LLMStartupError(AgentException):
|
||||
def __init__(self):
|
||||
super().__init__("GEMINI_STARTUP_ERROR", "Gemini CLI 启动失败")
|
||||
super().__init__("LLM_STARTUP_ERROR", "LLM 启动/连接失败")
|
||||
|
||||
class GeminiTimeoutError(AgentException):
|
||||
class ExternalServiceTimeoutError(AgentException):
|
||||
def __init__(self, phase: str):
|
||||
super().__init__("GEMINI_TIMEOUT", f"Gemini 在 {phase} 阶段超时")
|
||||
super().__init__("EXTERNAL_SERVICE_TIMEOUT", f"外部依赖在 {phase} 阶段超时")
|
||||
|
||||
class SkillNotInstalledError(AgentException):
|
||||
def __init__(self):
|
||||
super().__init__("SKILL_NOT_INSTALLED", "skill 未安装")
|
||||
|
||||
class PlaywrightError(AgentException):
|
||||
class ExternalServiceError(AgentException):
|
||||
def __init__(self, detail: str):
|
||||
super().__init__("PLAYWRIGHT_ERROR", f"截图失败: {detail}")
|
||||
super().__init__("EXTERNAL_SERVICE_ERROR", f"外部依赖返回错误: {detail}")
|
||||
|
||||
class HtmlExtractionError(AgentException):
|
||||
class OutputValidationError(AgentException):
|
||||
def __init__(self):
|
||||
super().__init__("HTML_EXTRACTION_FAILED", "无法从 Gemini 输出中提取有效 HTML")
|
||||
super().__init__("INVALID_INPUT", "无法从输出中提取有效结果")
|
||||
```
|
||||
|
||||
### 异常传播规则
|
||||
|
||||
- DAG 节点内捕获异常 → 更新 `state.status = "error"` → 通过 `send({"type": "error", "message": ...})` 推送给前端 → 抛给 runner
|
||||
- Runner 捕获 → 记录日志 → 关闭 PTY → 清理资源
|
||||
- Runner 捕获 → 记录日志 → 关闭 LLM 会话 → 清理资源
|
||||
- **禁止在节点内静默 swallow 异常**
|
||||
|
||||
Reference in New Issue
Block a user