From df48c36422f756216cf6ffea09b393e1423330d1 Mon Sep 17 00:00:00 2001 From: baozaotumao Date: Mon, 1 Jun 2026 23:15:05 -0700 Subject: [PATCH] =?UTF-8?q?refactor:=20=E8=A7=84=E5=88=99=E4=B8=AD?= =?UTF-8?q?=E6=80=A7=E5=8C=96=E2=80=94=E2=80=94=E5=89=A5=E7=A6=BB=20PPT=20?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=EF=BC=8C=E7=BB=9F=E4=B8=80=E4=B8=BA=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E7=A4=BA=E8=8C=83=E5=9F=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把规则从原 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 运行。 --- .../.claude/rules/agent-concurrency.md.jinja | 29 +++-- template/.claude/rules/agent-dag.md | 115 ++++++++---------- .../.claude/rules/agent-extension-points.md | 18 +-- template/.claude/rules/agent-llm-api.md | 21 ++-- template/.claude/rules/agent-llm-pty.md | 27 ++-- template/.claude/rules/agent.md.jinja | 48 ++++---- template/.claude/rules/architecture.md | 28 +++-- template/.claude/rules/backend-db.md.jinja | 72 +++++------ template/.claude/rules/backend.md | 10 +- .../.claude/rules/communication-catalog.md | 19 +-- .../.claude/rules/communication-protocol.md | 8 +- template/.claude/rules/conventions.md | 2 +- template/.claude/rules/data-lifecycle.md | 12 +- template/.claude/rules/ddd.md | 23 ++-- template/.claude/rules/design-discipline.md | 20 +-- template/.claude/rules/error-codes.md | 14 ++- template/.claude/rules/frontend-runtime.md | 23 ++-- template/.claude/rules/frontend.md | 25 ++-- template/.claude/rules/observability.md | 6 +- template/.claude/rules/testing-tdd.md | 4 +- template/.gitignore.jinja | 1 - template/agent/Dockerfile.jinja | 9 -- template/agent/pyproject.toml.jinja | 8 +- 23 files changed, 257 insertions(+), 285 deletions(-) diff --git a/template/.claude/rules/agent-concurrency.md.jinja b/template/.claude/rules/agent-concurrency.md.jinja index dc61e5d..07e1a17 100644 --- a/template/.claude/rules/agent-concurrency.md.jinja +++ b/template/.claude/rules/agent-concurrency.md.jinja @@ -5,11 +5,11 @@ paths: # Agent 并发模型与资源管理 -事件驱动的异步 I/O、进程池下放 CPU 密集任务、并发上限背压、浏览器复用、生命周期取消、工作目录清理与日志规范。 +事件驱动的异步 I/O、进程池下放 CPU 密集任务、并发上限背压、昂贵外部资源复用、生命周期取消、工作目录清理与日志规范。 ## 并发模型与资源管理 -Agent 是最重的并发场景:单进程 FastAPI(单事件循环)要同时服务多个会话,每个会话持有{% if llm_provider == 'gemini-cli' %}一个 LLM CLI 子进程 + {% endif %}一个 Chromium 上下文 + 阶段性 CPU 密集合成。**核心铁律:绝不阻塞事件循环。** +Agent 是最重的并发场景:单进程 FastAPI(单事件循环)要同时服务多个会话,每个会话持有{% if llm_provider == 'gemini-cli' %}一个 LLM CLI 子进程 + {% endif %}若干外部资源 + 阶段性 CPU 密集任务。**核心铁律:绝不阻塞事件循环。** ### 工作类型与处理方式 @@ -19,7 +19,7 @@ Agent 是最重的并发场景:单进程 FastAPI(单事件循环)要同时 {% if llm_provider == 'gemini-cli' -%} | 阻塞 fd 读 | PTY master_fd 读取 | **非阻塞 fd + `loop.add_reader`**,不要 thread-per-session | {% endif -%} -| CPU 密集 | python-pptx 合成、图像处理 | `ProcessPoolExecutor` via `run_in_executor` | +| CPU 密集 | 文档/图像处理、加解密、压缩等 | `ProcessPoolExecutor` via `run_in_executor` | {% if llm_provider != 'gemini-cli' -%} | LLM 流式 | SDK `async for token in stream` | 直接 `await`,事件驱动 | {% endif %} @@ -47,19 +47,19 @@ def on_pty_readable(): ### 1. CPU 密集:进程池 {% endif %} -`synthesize` 节点(python-pptx 合成)是纯 CPU + 持 GIL,会卡住整个事件循环 1–3 秒,影响所有其他会话。必须下放到进程池: +CPU 密集节点(如文档合成、图像处理)是纯 CPU + 持 GIL,会卡住整个事件循环 1–3 秒,影响所有其他会话。必须下放到进程池: ```python process_pool = ProcessPoolExecutor(max_workers=os.cpu_count()) -await loop.run_in_executor(process_pool, build_pptx, screenshots_dir, pptx_path) +await loop.run_in_executor(process_pool, build_output, input_dir, out_path) ``` 进程池函数必须是**纯函数**(参数 + 返回值可序列化),禁止访问共享状态。 ### {% if llm_provider == 'gemini-cli' %}3{% else %}2{% endif %}. 并发上限与背压 -每个会话 = {% if llm_provider == 'gemini-cli' %}1 个 CLI 进程 + {% endif %}1 个 Chromium context,内存/CPU 开销大。用全局信号量封顶,超出排队并向前端反馈位置: +每个会话 = {% if llm_provider == 'gemini-cli' %}1 个 CLI 进程 + {% endif %}若干外部资源,内存/CPU 开销大。用全局信号量封顶,超出排队并向前端反馈位置: ```python session_semaphore = asyncio.Semaphore(settings.max_concurrent_sessions) @@ -69,21 +69,20 @@ async with session_semaphore: # 排队期间向前端推 {"type": "queued", "position": N} ``` -### {% if llm_provider == 'gemini-cli' %}4{% else %}3{% endif %}. Playwright 浏览器复用 +### {% if llm_provider == 'gemini-cli' %}4{% else %}3{% endif %}. 昂贵外部资源复用 -启动时拉起**一个** Chromium,每个会话开独立 `browser_context`(廉价),用完关 context,不要每次 `launch()`: +若某外部资源初始化昂贵(如浏览器、连接池、客户端),启动时拉起**一个**全局实例,每个会话借用廉价的子句柄,用完即还,不要每次重新 `launch()` / 建连: ```python -# lifespan 启动 -app.state.browser = await playwright.chromium.launch() +# lifespan 启动:全局单例(示范用浏览器,按需替换为连接池等) +app.state.pool = await create_expensive_resource() -# 截图节点 -context = await app.state.browser.new_context(viewport={"width": 1920, "height": 1080}) +# 会话节点:借一个廉价子句柄 +handle = await app.state.pool.acquire() try: - page = await context.new_page() ... finally: - await context.close() + await handle.close() ``` ### {% if llm_provider == 'gemini-cli' %}5{% else %}4{% endif %}. 资源生命周期与取消 @@ -100,7 +99,7 @@ finally: {% else %} await llm_session.close() # 释放 HTTP 连接 {% endif %} - await context.close() + await handle.close() # 归还借用的外部资源句柄 ``` ### 共享状态线程安全 diff --git a/template/.claude/rules/agent-dag.md b/template/.claude/rules/agent-dag.md index 7e8b085..b8fef54 100644 --- a/template/.claude/rules/agent-dag.md +++ b/template/.claude/rules/agent-dag.md @@ -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 异常** diff --git a/template/.claude/rules/agent-extension-points.md b/template/.claude/rules/agent-extension-points.md index cf495e7..3bf222b 100644 --- a/template/.claude/rules/agent-extension-points.md +++ b/template/.claude/rules/agent-extension-points.md @@ -11,33 +11,35 @@ providers / export / storage / llm 四个扩展点的统一模式:端口(Pro 四个扩展点(含 llm)都遵循同一模式:**端口(Protocol,统一声明于 `domain/ports.py`)+ 适配器多实现 + registry 工厂**。调用方只依赖端口,按配置选实现,新增能力不改调用方。 +下面端口为**示范**,按项目实际定义: + ```python # domain/ports.py —— 所有端口在此声明(六边形架构:端口属领域核心) class Exporter(Protocol): - async def export(self, screenshots: list[Path], out_path: Path) -> Path: ... + async def export(self, data: object, out_path: Path) -> Path: ... class StorageBackend(Protocol): async def save(self, key: str, data: bytes) -> None: ... async def read(self, key: str) -> bytes: ... def url_for(self, key: str) -> str: ... -class ImageProvider(Protocol): - def build_prompt_context(self) -> str: ... # 注入 gemini prompt 的图片来源说明 +class ExternalProvider(Protocol): + def build_context(self) -> str: ... # 注入 LLM prompt 的外部上下文 ``` **节点必须通过接口调用,禁止硬编码具体实现:** -- `synthesize` 节点调用 `exporter.export(...)`,不直接调 python-pptx +- `finalize` 节点调用 `exporter.export(...)`,不直接调具体导出库 - 所有文件读写经 `StorageBackend`,不直接碰 `Path.write_bytes`(本地实现内部才碰 FS) -- 加 PDF 导出 = 新增 `pdf_exporter.py` 注册到 registry,`synthesize` 一行不改 +- 加新导出格式 = 新增 `xxx_exporter.py` 注册到 registry,`finalize` 一行不改 ## registry 工厂 每个扩展点目录提供 `registry.py`,按 `Settings` 配置选择具体实现: -- `llm/`:按 `gemini_cmd` / 厂商配置选 `GeminiCliSession` 等 `LLMSession` 实现 -- `providers/`:按 `image_source` 选 `unsplash` / `pexels` / `none` 等 `ImageProvider` 实现 -- `export/`:按导出格式选 `pptx_exporter`(未来 `pdf_exporter`)等 `Exporter` 实现 +- `llm/`:按厂商配置选 `GeminiCliSession` 等 `LLMSession` 实现 +- `providers/`:按配置选具体 `ExternalProvider` 实现(含 `none` 退化态) +- `export/`:按导出格式选具体 `Exporter` 实现 - `storage/`:按存储后端选 `local_storage`(未来 `s3_storage`)等 `StorageBackend` 实现 新增能力 = 新增一个实现文件 + 在对应 `registry.py` 注册,调用方(DAG 节点)零改动。 diff --git a/template/.claude/rules/agent-llm-api.md b/template/.claude/rules/agent-llm-api.md index 0ad6bea..3a42148 100644 --- a/template/.claude/rules/agent-llm-api.md +++ b/template/.claude/rules/agent-llm-api.md @@ -75,33 +75,32 @@ Anthropic 适配器同理,将 `AsyncOpenAI` 换为 `AsyncAnthropic`,流式 A ## DAG 节点中的调用模式 ```python -async def phase3_generate(state, send, recv_queue): +async def process(state, send, recv_queue): try: - async with asyncio.timeout(settings.generate_timeout_seconds): + async with asyncio.timeout(settings.process_timeout_seconds): chunks: list[str] = [] async for token in llm_session.generate(state.messages): chunks.append(token) await send({"type": "llm_token", "text": token}) - html = extract_html("".join(chunks)) - if not html: - raise HtmlExtractionError() + result = extract_result("".join(chunks)) + if not result: + raise OutputValidationError() except TimeoutError: - raise LLMTimeoutError(phase="generate") + raise ExternalServiceTimeoutError(phase="process") ``` 不需要 ANSI 过滤——API 响应是纯文本,无终端转义码。 ## 超时保护 -每个 DAG 阶段用 `asyncio.timeout()` 包裹 LLM 调用: +每个 DAG 阶段用 `asyncio.timeout()` 包裹 LLM 调用(示范): | 阶段 | 配置项 | |------|--------| -| phase1_dialog(单轮对话) | `dialog_timeout_seconds`(默认 60s) | -| phase2_preview(3 张预览) | `preview_timeout_seconds`(默认 180s) | -| phase3_generate(完整 HTML) | `generate_timeout_seconds`(默认 300s) | +| intake(收集输入) | `intake_timeout_seconds`(默认 60s) | +| process(LLM 处理) | `process_timeout_seconds`(默认 300s) | -超时后抛 `LLMTimeoutError`(`domain/exceptions.py`),不静默挂起。 +超时后抛 `ExternalServiceTimeoutError`(`domain/exceptions.py`),不静默挂起。 ## 连接管理 diff --git a/template/.claude/rules/agent-llm-pty.md b/template/.claude/rules/agent-llm-pty.md index cd267e1..8a645dd 100644 --- a/template/.claude/rules/agent-llm-pty.md +++ b/template/.claude/rules/agent-llm-pty.md @@ -5,7 +5,7 @@ paths: # Agent LLM 驱动层与 PTY 桥接 -厂商无关的 LLM CLI 抽象、PTY 伪终端桥接机制、超时保护与 HTML 拦截检测。 +厂商无关的 LLM CLI 抽象、PTY 伪终端桥接机制、超时保护与输出拦截检测。 ## LLM CLI 驱动层(llm/) @@ -15,7 +15,7 @@ paths: |------|------| | `domain/ports.py` | `LLMSession` 协议(端口,接口定义) | | `pty_bridge.py` | 共享 PTY 机制(所有 CLI 厂商复用) | -| `gemini_cli.py` | `GeminiCliSession`:gemini 命令 + prompt 约定 + HTML 提取 | +| `gemini_cli.py` | `GeminiCliSession`:gemini 命令 + prompt 约定 + 输出提取 | **扩展新厂商**:新增 `claude_cli.py` 等实现 `LLMSession` 端口即可,PTY 机制无需重写。 @@ -74,25 +74,24 @@ os.write(master_fd, (user_text + '\n').encode('utf-8')) ### 超时保护 -每个 DAG 阶段设置独立超时(`asyncio.wait_for`): +每个 DAG 阶段设置独立超时(`asyncio.wait_for`),阶段为示范: | 阶段 | 超时 | |------|------| -| phase1_dialog(单轮回答) | 60s | -| phase2_preview(3 张预览) | 180s | -| phase3_generate(完整 HTML) | 300s | -| screenshot | 180s | +| intake(收集输入) | 60s | +| process(LLM 处理输出) | 300s | -超时后抛 `GeminiTimeoutError`。 +超时后抛 `ExternalServiceTimeoutError`。 -## HTML 拦截检测 +## 输出拦截检测 + +CLI 输出是流式文本,需按约定标记从中提取结构化结果。下面以提取代码块为例(按你的输出约定替换正则): ```python -def detect_html(text: str) -> str | None: - m = re.search(r'```html\s*([\s\S]+?)```|()', text, re.IGNORECASE) - return (m.group(1) or m.group(2)).strip() if m else None +def detect_output(text: str) -> str | None: + m = re.search(r'```result\s*([\s\S]+?)```', text, re.IGNORECASE) + return m.group(1).strip() if m else None ``` 根据 `state.status` 决定处理: -- `"previewing"` → 保存 `preview_{n}.html`,推送 `style_preview` 事件 -- `"generating"` → 保存 `slides.html`,推送 `html_ready`,触发后续节点 +- `"processing"` → 提取到结果即保存产物,推送 `result_ready`,触发后续节点 diff --git a/template/.claude/rules/agent.md.jinja b/template/.claude/rules/agent.md.jinja index dd762a7..cb20163 100644 --- a/template/.claude/rules/agent.md.jinja +++ b/template/.claude/rules/agent.md.jinja @@ -7,9 +7,11 @@ paths: Agent 服务的职责、技术栈、目录结构、配置{% if llm_provider == 'gemini-cli' %}、Skill 安装{% endif %}与测试规范。 +> 以下以一条「会话→LLM 处理→结果」的通用流水线作示范;按你的业务替换节点与扩展点,架构不变。 + ## 概览(职责) -FastAPI 服务(port {{ agent_port }})。核心职责:{% if llm_provider == 'gemini-cli' %}PTY 桥接 LLM CLI 交互式会话{% else %}通过 SDK 调用 LLM API{% endif %} + DAG 编排(对话 → 风格预览 → 生成 → 截图 → 合成)+ 事件流式推送给 Backend。 +FastAPI 服务(port {{ agent_port }})。核心职责:{% if llm_provider == 'gemini-cli' %}PTY 桥接 LLM CLI 交互式会话{% else %}通过 SDK 调用 LLM API{% endif %} + DAG 编排(intake → process → validate → finalize)+ 事件流式推送给 Backend。 ## 技术栈 @@ -28,27 +30,24 @@ FastAPI 服务(port {{ agent_port }})。核心职责:{% if llm_provider == {% else -%} | asyncio | 异步 I/O,协调 LLM 调用与 WebSocket | {% endif -%} -| playwright(async) | 截取每页幻灯片截图 | -| python-pptx | 合成 PPTX | | loguru | 结构化日志 | | pytest + pytest-asyncio | 测试 | +> 业务相关的外部能力(导出、第三方 API 等)按需经扩展点接入,对应依赖用 `uv add` 自行添加。 + ## 包管理(uv) ```bash # 添加依赖 {% if llm_provider == 'gemini-cli' -%} -uv add fastapi uvicorn playwright python-pptx loguru pydantic-settings pytest pytest-asyncio +uv add fastapi uvicorn loguru pydantic-settings pytest pytest-asyncio {% elif llm_provider == 'openai-api' -%} -uv add fastapi uvicorn openai playwright python-pptx loguru pydantic-settings pytest pytest-asyncio +uv add fastapi uvicorn openai loguru pydantic-settings pytest pytest-asyncio {% elif llm_provider == 'anthropic-api' -%} -uv add fastapi uvicorn anthropic playwright python-pptx loguru pydantic-settings pytest pytest-asyncio +uv add fastapi uvicorn anthropic loguru pydantic-settings pytest pytest-asyncio {% else -%} -uv add fastapi uvicorn playwright python-pptx loguru pydantic-settings pytest pytest-asyncio +uv add fastapi uvicorn loguru pydantic-settings pytest pytest-asyncio {% endif %} -# 安装 Playwright 浏览器(首次) -uv run playwright install chromium - # 运行服务 uv run python -m src.main @@ -93,15 +92,15 @@ agent/ │ │ └── custom_llm.py # 自定义 LLMSession 实现 {% endif -%} │ │ └── registry.py -│ ├── providers/ # 扩展点②:ImageProvider 适配器 +│ ├── providers/ # 扩展点②:外部能力 Provider 适配器(示范) +│ │ └── registry.py +│ ├── export/ # 扩展点③:Exporter 适配器(如需导出) │ │ └── registry.py -│ ├── export/ # 扩展点③:Exporter 适配器 -│ │ └── pptx_exporter.py │ ├── storage/ # 扩展点④:StorageBackend 适配器 │ │ └── local_storage.py {% if llm_provider == 'gemini-cli' -%} │ ├── skills/ -│ │ └── installer.py # CLI skill 自动安装 +│ │ └── installer.py # CLI skill 自动安装(可选) {% endif -%} │ ├── utils/ {% if llm_provider == 'gemini-cli' -%} @@ -137,7 +136,7 @@ class Settings(BaseSettings): log_level: str = "DEBUG" {% if llm_provider == 'gemini-cli' %} llm_cmd: str = "gemini" - skill_name: str = "frontend-slides" + skill_name: str = "" # 可选:要加载的 CLI skill 名 skill_repo: str = "" # 从 .env 注入 pty_read_buffer_size: int = 4096 {% elif llm_provider == 'openai-api' %} @@ -153,33 +152,32 @@ class Settings(BaseSettings): llm_model: str = "" llm_base_url: str = "" {% endif %} - generate_timeout_seconds: int = 300 - preview_timeout_seconds: int = 180 - dialog_timeout_seconds: int = 60 + process_timeout_seconds: int = 300 + intake_timeout_seconds: int = 60 max_concurrent_sessions: int = 4 process_pool_workers: int = os.cpu_count() or 4 model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") ``` {% if llm_provider == 'gemini-cli' %} -## Skill 安装(skills/installer.py) +## Skill 安装(skills/installer.py,可选) -Agent 启动时自动确保配置指定的 skill 已安装。`skill_name` 与 `skill_repo` 均来自 `Settings`,换 skill 只改 `.env`,不改代码。 +若 LLM CLI 需要预装某个 skill,Agent 启动时自动确保配置指定的 skill 已安装。`skill_name` 与 `skill_repo` 均来自 `Settings`,换 skill 只改 `.env`,不改代码。 > 注意:安装目的地路径(`~/.gemini/skills/`)是 Gemini CLI 约定;换 CLI 时 `installer.py` 需同步修改。 {% endif %} ## 测试规范 {% if llm_provider == 'gemini-cli' -%} -- PTY 测试:mock `pty.openpty`、`os.read/write`,验证 ANSI 过滤和 HTML 检测 -- 超时测试:注入假 PTY 不输出,验证 `LLMTimeoutError` 被抛出 +- PTY 测试:mock `pty.openpty`、`os.read/write`,验证 ANSI 过滤和输出检测 +- 超时测试:注入假 PTY 不输出,验证 `ExternalServiceTimeoutError` 被抛出 - skill 安装测试:mock `git clone`,验证成功/失败分支 {% else -%} - LLM session 测试:mock `LLMSession.generate` 返回 `AsyncIterator[str]`,验证流式处理 -- 超时测试:`generate` mock 为永不完成的迭代器,验证 `LLMTimeoutError` 被抛出 +- 超时测试:`generate` mock 为永不完成的迭代器,验证 `ExternalServiceTimeoutError` 被抛出 {% endif -%} - DAG 节点测试:mock `send` 回调,验证状态转换和推送消息类型 - checkpoint 恢复测试: - 注入 `checkpoint={status: "done"}` → 验证直接推送结果,不启动 LLM - - 注入 `checkpoint={status: "screenshotting"}` → 验证跳过 LLM 直接进截图节点 - - 注入 `checkpoint={status: "dialog"}` → 验证推送 `checkpoint_lost` + - 注入 `checkpoint={status: "finalizing"}` → 验证跳过 LLM 直接进 finalize 节点 + - 注入 `checkpoint={status: "intake"}` → 验证推送 `checkpoint_lost` diff --git a/template/.claude/rules/architecture.md b/template/.claude/rules/architecture.md index f248440..47c7503 100644 --- a/template/.claude/rules/architecture.md +++ b/template/.claude/rules/architecture.md @@ -2,15 +2,17 @@ > 三层架构、通信链路、限界上下文、六边形分层与依赖方向。 +> 下文以「订单」为示范业务,仅占位;新项目替换为自己的领域,分层与依赖规则不变。 + ## 三层架构 ``` frontend/ React + Vite + TypeScript + Tailwind -backend/ FastAPI(port 8000)—— WebSocket 代理 + 会话持久化 -agent/ FastAPI(port 8001)—— LLM CLI PTY 桥接 + DAG 编排 +backend/ FastAPI(port 8000)—— WebSocket 代理 + 会话/状态持久化 +agent/ FastAPI(port 8001)—— LLM 服务:CLI PTY 桥接或 API 调用 + DAG 编排 ``` -Backend 是纯代理层,不持有 AI 业务逻辑。所有交互编排在 Agent。 +Backend 是纯代理/接入层,不持有核心业务逻辑。重业务编排放在领域层(backend services 或 agent)。 ## 通信链路概览 @@ -18,31 +20,31 @@ Backend 是纯代理层,不持有 AI 业务逻辑。所有交互编排在 Agen Browser ↕ WebSocket ws://localhost:8000/ws/{session_id} Backend (port 8000) - ↕ WebSocket ws://localhost:8001/ws/{session_id} + ↕ WebSocket ws://localhost:8001/ws/{session_id} ← 仅含 agent 时 Agent (port 8001) - ↕ pty.openpty() -LLM CLI(加载所配置的幻灯片生成 skill) + ↕ pty.openpty() 或 HTTP +外部 LLM(CLI 适配器如 Gemini CLI,或 API SDK) ``` -- LLM CLI(当前适配器为 Gemini CLI)与所加载的 skill 都是**可替换的外部依赖**,不在领域内硬编码。CLI 命令、skill 来源(仓库/路径)与安装路径均经配置注入(见 `config.md`、`agent.md`),换 CLI 或换 skill 不触动领域逻辑。 +- 外部 LLM(CLI 命令或 API)与所加载的能力都是**可替换的外部依赖**,不在领域内硬编码。命令、来源与密钥均经配置注入(见 `config.md`、`agent.md`),换提供商不触动领域逻辑。 -## 限界上下文(Bounded Context) +## 限界上下文(Bounded Context,示范) | 上下文 | 载体 | 子域 | |--------|------|------| -| 生成编排 Generation | agent | 核心域(Core) | -| 会话管理 SessionMgmt | backend | 支撑域(Supporting) | -| 交互表现 Presentation | frontend | 通用域(Generic) | +| 核心业务(示范:订单处理) | backend / agent | 核心域(Core) | +| 会话/连接管理 | backend | 支撑域(Supporting) | +| 交互表现 | frontend | 通用域(Generic) | - 上下文间**仅经消息契约集成**(即 Shared Kernel,见「通信协议与交互规约」) -- LLM CLI 为外部系统,`agent/llm/` 适配器充当**防腐层(Anti-Corruption Layer)**,把终端协议翻译为领域语言,隔离外部模型对领域的侵蚀 +- 外部系统(LLM、第三方 API)由适配器充当**防腐层(Anti-Corruption Layer)**,把外部协议翻译为领域语言,隔离外部对领域的侵蚀 ## 分层(六边形,依赖恒向内) 领域层不依赖任何框架/IO: ``` -infrastructure 适配器:pty / playwright / pptx / db / ws +infrastructure 适配器:pty / 外部 API / 导出 / db / ws │ 实现端口(依赖倒置) application 用例编排:DAG runner+nodes、backend services │ 依赖 diff --git a/template/.claude/rules/backend-db.md.jinja b/template/.claude/rules/backend-db.md.jinja index 77d3a0e..e5ddfb7 100644 --- a/template/.claude/rules/backend-db.md.jinja +++ b/template/.claude/rules/backend-db.md.jinja @@ -4,7 +4,7 @@ paths: ["backend/**"] # backend-db -数据库({% if database == 'sqlite' %}SQLite{% else %}PostgreSQL{% endif %} + SQLAlchemy async)、Session 模型、Alembic 迁移、Checkpoint 断点恢复与会话保留。 +数据库({% if database == 'sqlite' %}SQLite{% else %}PostgreSQL{% endif %} + SQLAlchemy async)、Session 模型、Alembic 迁移、Checkpoint 断点恢复与会话保留。下文实体字段以「订单」作示范。 ## 数据库配置 @@ -72,6 +72,8 @@ AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) `Session` 是领域核心实体,**纯 Python,零框架/IO 依赖**。业务规则(状态迁移、恢复判断)封装在实体方法内,不外泄到 service 层。 +> 字段与状态为**示范**(订单域),按你的业务替换;状态机/恢复模式不变。 + ```python from dataclasses import dataclass from .value_objects import SessionStatus @@ -79,25 +81,20 @@ from .value_objects import SessionStatus @dataclass class Session: id: str - topic: str - language: str = "中文" - style: str | None = None - image_source: str | None = None + input_summary: str = "" # 输入摘要(示范) work_dir: str = "" status: SessionStatus = SessionStatus.IDLE - html_path: str | None = None - pptx_path: str | None = None + result_path: str | None = None error_message: str | None = None - def mark_screenshotting(self, html_path: str) -> None: - if self.status != SessionStatus.GENERATING: - raise InvalidTransitionError(self.status, SessionStatus.SCREENSHOTTING) - self.html_path = html_path - self.status = SessionStatus.SCREENSHOTTING + def mark_validating(self, result_path: str) -> None: + if self.status != SessionStatus.PROCESSING: + raise InvalidTransitionError(self.status, SessionStatus.VALIDATING) + self.result_path = result_path + self.status = SessionStatus.VALIDATING - def mark_synthesizing(self, pptx_path: str) -> None: - self.pptx_path = pptx_path - self.status = SessionStatus.SYNTHESIZING + def mark_finalizing(self) -> None: + self.status = SessionStatus.FINALIZING def mark_done(self) -> None: self.status = SessionStatus.DONE @@ -116,16 +113,16 @@ class Session: from enum import StrEnum class SessionStatus(StrEnum): - IDLE = "idle" - DIALOG = "dialog" - GENERATING = "generating" - SCREENSHOTTING = "screenshotting" - SYNTHESIZING = "synthesizing" - DONE = "done" - ERROR = "error" + IDLE = "idle" + INTAKE = "intake" + PROCESSING = "processing" + VALIDATING = "validating" + FINALIZING = "finalizing" + DONE = "done" + ERROR = "error" def can_recover(self) -> bool: - return self in (self.SCREENSHOTTING, self.SYNTHESIZING, self.DONE) + return self in (self.VALIDATING, self.FINALIZING, self.DONE) ``` ## SessionRecord ORM 映射(db/models.py) @@ -137,15 +134,11 @@ class SessionRecord(Base): __tablename__ = "sessions" id: Mapped[str] = mapped_column(String(36), primary_key=True) - topic: Mapped[str] = mapped_column(String(500)) - style: Mapped[str | None] = mapped_column(String(100)) - language: Mapped[str] = mapped_column(String(20), default="中文") - image_source: Mapped[str | None] = mapped_column(String(50)) + input_summary: Mapped[str] = mapped_column(String(500)) work_dir: Mapped[str] = mapped_column(String(500)) status: Mapped[str] = mapped_column(String(30), default="idle") error_message: Mapped[str | None] = mapped_column(Text) - html_path: Mapped[str | None] = mapped_column(String(500)) - pptx_path: Mapped[str | None] = mapped_column(String(500)) + result_path: Mapped[str | None] = mapped_column(String(500)) checkpoint_at: Mapped[datetime | None] = mapped_column(DateTime) created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), onupdate=func.now()) @@ -175,8 +168,7 @@ class SQLAlchemySessionRepository(SessionRepository): async def save(self, session: Session) -> None: record = await self._db.get(SessionRecord, session.id) record.status = session.status - record.html_path = session.html_path - record.pptx_path = session.pptx_path + record.result_path = session.result_path record.error_message = session.error_message record.checkpoint_at = func.now() await self._db.flush() @@ -184,8 +176,7 @@ class SQLAlchemySessionRepository(SessionRepository): @override async def create(self, session: Session) -> None: record = SessionRecord( - id=session.id, topic=session.topic, language=session.language, - style=session.style, image_source=session.image_source, + id=session.id, input_summary=session.input_summary, work_dir=session.work_dir, status=session.status, ) self._db.add(record) @@ -193,10 +184,9 @@ class SQLAlchemySessionRepository(SessionRepository): def _to_entity(self, r: SessionRecord) -> Session: return Session( - id=r.id, topic=r.topic, language=r.language, - style=r.style, image_source=r.image_source, work_dir=r.work_dir, + id=r.id, input_summary=r.input_summary, work_dir=r.work_dir, status=SessionStatus(r.status), - html_path=r.html_path, pptx_path=r.pptx_path, + result_path=r.result_path, error_message=r.error_message, ) ``` @@ -213,14 +203,14 @@ uv run alembic downgrade -1 ## Checkpoint / 断点恢复 -`slides.html` 成功落盘是 checkpoint 分界线: +中间结果成功落盘是 checkpoint 分界线: | 断连时状态 | 能否恢复 | 恢复行为 | |-----------|---------|---------| -| `dialog` / `generating` | ❌ | 推送 `checkpoint_lost`,提示用户重新开始 | -| `screenshotting` | ✅ | 跳过 LLM,重跑截图 | -| `synthesizing` | ✅ | 截图已在磁盘,重跑合成 | -| `done` | ✅ | 直接推送 `html_ready` + `pptx_ready` | +| `intake` / `processing` | ❌ | 推送 `checkpoint_lost`,提示用户重新开始 | +| `validating` | ✅ | 跳过 LLM,重跑校验 | +| `finalizing` | ✅ | 中间结果已在磁盘,重跑落地 | +| `done` | ✅ | 直接推送 `result_ready` | ## DB 依赖注入(core/deps.py) diff --git a/template/.claude/rules/backend.md b/template/.claude/rules/backend.md index 58084d6..dfde9d1 100644 --- a/template/.claude/rules/backend.md +++ b/template/.claude/rules/backend.md @@ -106,14 +106,14 @@ backend 是支撑域,遵循六边形架构,依赖恒向内: | 项 | 规范 | |----|------| -| 资源 | 名词复数:`/api/v1/sessions`、`/api/v1/sessions/{session_id}/pptx` | +| 资源 | 名词复数:`/api/v1/sessions`、`/api/v1/sessions/{session_id}/export` | | 路径段 | 小写 + kebab-case,不放动词(动作用 HTTP method) | | 方法 | GET 读 / POST 建 / PUT 全量改 / PATCH 局部改 / DELETE 删 | | JSON 字段 | snake_case | | 版本 | `/api/v1` 前缀 | | 状态码 | 语义化 200/201/204/4xx/5xx | -例:`GET /api/v1/sessions/{session_id}/pptx`。路径/字段命名靠 review + 可选 spectral(OpenAPI lint);HTTP 语义靠 review。 +例:`GET /api/v1/sessions/{session_id}/export`。路径/字段命名靠 review + 可选 spectral(OpenAPI lint);HTTP 语义靠 review。 ### Swagger / OpenAPI 文档 @@ -157,8 +157,8 @@ backend 是支撑域,遵循六边形架构,依赖恒向内: class Settings(BaseSettings): env: Literal["dev", "prod"] = "dev" agent_ws_url: str = "ws://localhost:8001" - work_dir: Path = Path.home() / "ppt-gen" - database_url: str = f"sqlite+aiosqlite:///{Path.home()}/ppt-gen/ppt_gen.db" + work_dir: Path = Path.home() / "app-data" + database_url: str = f"sqlite+aiosqlite:///{Path.home()}/app-data/app.db" port: int = 8000 log_level: str = "DEBUG" enable_api_docs: bool = True @@ -190,7 +190,7 @@ async def ws_proxy(ws: WebSocket, session_id: str, repo: SessionRepository = Dep ## 日志规范(core/logging.py) -loguru,两个 sink:stderr(dev 人类可读彩色)+ `~/ppt-gen/logs/backend.log`(JSON 每行一条,所有环境)。文件 sink 配 `rotation="100 MB"`、`retention="14 days"`、`compression="zip"`。级别 `DEBUG`(dev)/`INFO`(prod)。每条日志必须携带 `request_id`(loguru contextualize 注入)。禁止 `print()` 与 stdlib `logging`,统一 `from loguru import logger`。 +loguru,两个 sink:stderr(dev 人类可读彩色)+ `{WORK_DIR}/logs/backend.log`(JSON 每行一条,所有环境)。文件 sink 配 `rotation="100 MB"`、`retention="14 days"`、`compression="zip"`。级别 `DEBUG`(dev)/`INFO`(prod)。每条日志必须携带 `request_id`(loguru contextualize 注入)。禁止 `print()` 与 stdlib `logging`,统一 `from loguru import logger`。 ## 测试规范 diff --git a/template/.claude/rules/communication-catalog.md b/template/.claude/rules/communication-catalog.md index 766c845..28c047f 100644 --- a/template/.claude/rules/communication-catalog.md +++ b/template/.claude/rules/communication-catalog.md @@ -6,22 +6,23 @@ paths: ["backend/**", "agent/**", "frontend/**"] > 查阅型:各消息 type 的 payload 形状,外层由信封包裹(见 `communication-protocol.md`)。字段一律 snake_case。 +> 以下以「订单」为示范业务,仅占位;按项目实际定义自己的消息。 + 命令(Browser → Backend → Agent): ```json -{"type": "start_session", "language": "中文", "image_source": "unsplash", "image_api_key": "xxx"} -{"type": "user_input", "text": "深圳科技政策解读"} -{"type": "style_pick", "index": 2} +{"type": "start_session", "channel": "web"} +{"type": "create_order", "items": [{"sku": "A-1", "qty": 2}]} +{"type": "submit_order", "order_id": "..."} ``` 事件(Agent → Backend → Browser): ```json -{"type": "gemini_message", "text": "请问你的 PPT 主旨是什么?"} -{"type": "style_preview", "index": 1, "preview_url": "/api/v1/preview/{sid}/preview_1.html"} -{"type": "log", "message": "📸 截图第 3/10 页"} -{"type": "html_ready", "session_id": "..."} -{"type": "pptx_ready", "session_id": "..."} +{"type": "order_created", "order_id": "...", "status": "pending"} +{"type": "item_priced", "sku": "A-1", "amount": {"currency": "CNY", "value": 1990}} +{"type": "log", "message": "正在校验库存 2/3"} +{"type": "order_confirmed", "order_id": "..."} {"type": "done"} -{"type": "error", "code": "GEMINI_TIMEOUT", "message": "..."} +{"type": "error", "code": "EXTERNAL_SERVICE_TIMEOUT", "message": "..."} ``` diff --git a/template/.claude/rules/communication-protocol.md b/template/.claude/rules/communication-protocol.md index a16df61..93ba36c 100644 --- a/template/.claude/rules/communication-protocol.md +++ b/template/.claude/rules/communication-protocol.md @@ -11,8 +11,8 @@ ## 交互范式:异步事件驱动 + 命令/事件分离(CQRS 式消息) -- **命令(Command)**:客户端→服务端,表达*意图*,可被拒绝(`start_session` / `user_input` / `style_pick`) -- **事件(Event)**:服务端→客户端,陈述*已发生的事实*,单向广播(`gemini_message` / `style_preview` / `html_ready` / `done` / `error`) +- **命令(Command)**:客户端→服务端,表达*意图*,可被拒绝(示范:`start_session` / `create_order` / `submit_order`) +- **事件(Event)**:服务端→客户端,陈述*已发生的事实*,单向广播(示范:`order_created` / `item_priced` / `order_confirmed` / `done` / `error`) - 命令与事件语义不混用 ### 命令幂等(Idempotency,强制) @@ -20,7 +20,7 @@ 投递是 at-most-once,但断连/重连/前端重发会让同一命令到达多次,故**所有命令必须可安全重复执行**——重复执行的副作用等价于执行一次。这是命令的**后置条件契约**(属 DbC,见 `design-discipline.md`),非可选优化。 - **幂等键**:命令以 `correlation_id` 作幂等键去重;服务端对已处理的 `correlation_id` 直接回放上次结果,不重复触发副作用(不重复起会话 / 不重复扣资源 / 不重复落库)。 -- **天然幂等优先**:能用「设置为目标态」就不用「增量变更」。`style_pick` 是选定某态(幂等)✅;任何"追加一次""+1"式语义需显式去重。 +- **天然幂等优先**:能用「设置为目标态」就不用「增量变更」。`submit_order`(置为已提交态)是幂等 ✅;任何"追加一次""+1"式语义需显式去重。 - **REST 幂等**:`/download` `/preview` 为只读 GET,天然幂等可重试可缓存(见传输层选型)。 - **校验点**:命令处理入口先查幂等键,再执行;新增命令时在 PR 说明其幂等策略(天然幂等 / 键去重)。 @@ -31,7 +31,7 @@ ```json { "v": 1, // 协议版本 - "type": "style_preview", // 判别式 + "type": "order_created", // 判别式 "correlation_id": "uuid", // 全链路追踪 id "ts": "2026-05-31T08:00:00Z", // 发出时间(ISO-8601) "payload": { ... } // 与 type 对应的强类型体 diff --git a/template/.claude/rules/conventions.md b/template/.claude/rules/conventions.md index 8d7d1ce..4a00b9d 100644 --- a/template/.claude/rules/conventions.md +++ b/template/.claude/rules/conventions.md @@ -27,7 +27,7 @@ ## 其他约定 -- 每次生成请求使用独立 UUID 工作目录:`~/ppt-gen/{uuid}/` +- 每次会话/请求使用独立 UUID 工作目录:`{WORK_DIR}/{uuid}/`(`WORK_DIR` 经 .env 注入) ## 包管理概览 diff --git a/template/.claude/rules/data-lifecycle.md b/template/.claude/rules/data-lifecycle.md index 89477e4..58ed1bd 100644 --- a/template/.claude/rules/data-lifecycle.md +++ b/template/.claude/rules/data-lifecycle.md @@ -10,18 +10,18 @@ paths: ["backend/**", "agent/**"] | 数据 | 保留策略 | 执行方 | |------|---------|--------| -| `~/ppt-gen/{uuid}/`(HTML+截图+PPTX) | TTL 默认 24h;总磁盘超阈值(默认 5GB)按 LRU 清理 | Agent 后台定时任务(每小时扫一次) | +| `{WORK_DIR}/{uuid}/`(会话产出的中间/结果文件) | TTL 默认 24h;总磁盘超阈值(默认 5GB)按 LRU 清理 | Agent 后台定时任务(每小时扫一次) | | SQLite 会话记录 | 保留 30 天,过期软删除→定期硬删 | Backend 定时任务 | | 日志文件 | loguru `rotation="100 MB"` + `retention="14 days"` + `compression="zip"` | 各服务 logging 配置 | - 清理任务幂等、可重入;删除前校验路径在 workDir 内(防穿越) - 配置项:`SESSION_TTL_HOURS`、`MAX_DISK_GB`、`DB_RETENTION_DAYS`、`LOG_RETENTION_DAYS` -- 会话结束(done/error)后,临时中间文件(preview_*.html)可立即清理,仅保留 slides.html + result.pptx +- 会话结束(done/error)后,临时中间文件可立即清理,仅保留最终结果文件 -## 数据存储概览 +## 数据存储概览(示范) | 层 | 存储 | 内容 | |----|------|------| -| Backend | SQLite(`~/ppt-gen/ppt_gen.db`) | 会话记录(id、topic、style、status、时间戳) | -| Agent | 文件系统(`~/ppt-gen/{session_id}/`) | slides.html、preview_*.html、截图、result.pptx | -| Frontend | localStorage | 图片源配置(Unsplash/Pexels API Key) | +| Backend | SQLite(`{WORK_DIR}/app.db`) | 会话记录(id、状态、时间戳等) | +| Agent | 文件系统(`{WORK_DIR}/{session_id}/`) | 会话产出的中间文件与最终结果 | +| Frontend | localStorage | 客户端本地配置(如第三方 API Key) | diff --git a/template/.claude/rules/ddd.md b/template/.claude/rules/ddd.md index 298dc3e..29b5690 100644 --- a/template/.claude/rules/ddd.md +++ b/template/.claude/rules/ddd.md @@ -6,19 +6,20 @@ ## 统一语言(Ubiquitous Language) -代码、注释、提交、文档、对话一律使用同一套领域术语,禁止同义异名: +代码、注释、提交、文档、对话一律使用同一套领域术语,禁止同义异名。 + +**下表用「订单」作示范领域,仅为占位**——新项目应替换为自己的业务术语;结构和约束保持不变: | 术语 | 定义 | |------|------| -| 会话 Session | 一次端到端生成交互的生命周期聚合根 | -| 演示文稿 Deck | 生成的 HTML 演示文稿,领域核心产物 | -| 幻灯片 Slide | Deck 内的单页 | -| 主旨 Intent | 用户表达的演示意图 | -| 风格 Style | 视觉美学配方(Aesthetic Recipe) | -| 风格预览 StylePreview | 供选择的候选样张 | -| 精修 Refinement | 对已生成 Deck 的迭代修改 | -| 检查点 Checkpoint | 可恢复的会话状态快照 | -| 端口 Port | 领域对外部能力的抽象接口(LLM / 图片 / 导出 / 存储) | +| 订单 Order | 一次交易的生命周期聚合根 | +| 订单项 OrderItem | Order 内的单条商品明细 | +| 金额 Money | 币种 + 数值的值对象(构造即自校验) | +| 收货地址 ShippingAddress | 带校验规则的值对象 | +| 订单状态 OrderStatus | 状态机(待支付/已支付/已发货/已完成/已取消) | +| 修订 Revision | 对已存在 Order 的一次受控修改 | +| 检查点 Checkpoint | 可恢复的状态快照 | +| 端口 Port | 领域对外部能力的抽象接口(支付 / 通知 / 导出 / 存储) | 术语变更须同步更新本表、代码标识符与契约,三者恒一致。 @@ -27,7 +28,7 @@ 目标:**让后续未预见的变化只改一处**(变化局部化)。不追求初期设计周全,追求封装良好——围绕「易变的决策」隐藏信息(Parnas Information Hiding)。以下为硬约束: 1. **按意图建模,不按 CRUD 建模** - 领域对象暴露的是*领域操作*(`change_password` / `authenticate` / `refine`),不是数据表的增删改查。先问"这个对象能做什么",而非"它有哪些字段的 getter/setter"。 + 领域对象暴露的是*领域操作*(`place` / `cancel` / `add_item`),不是数据表的增删改查。先问"这个对象能做什么",而非"它有哪些字段的 getter/setter"。 2. **易变规则收拢为值对象(Value Object)** 有规则、有约束的概念不用裸标量表示。规则全封进值对象,**一处定义、全局复用**。 diff --git a/template/.claude/rules/design-discipline.md b/template/.claude/rules/design-discipline.md index 8045bc8..1c5409c 100644 --- a/template/.claude/rules/design-discipline.md +++ b/template/.claude/rules/design-discipline.md @@ -7,7 +7,7 @@ ## 六边形架构(Ports & Adapters) - **端口(Port)**:领域定义的接口(`LLMSession` / `ImageProvider` / `Exporter` / `StorageBackend`),表达"领域需要什么能力" -- **适配器(Adapter)**:端口的具体实现(当前 LLM CLI 适配器 / `pptx_exporter` …),位于最外层,可替换 +- **适配器(Adapter)**:端口的具体实现(当前 LLM CLI 适配器 / 示范导出器 …),位于最外层,可替换 - 依赖方向恒指向领域;替换外层实现不触动内层 ## SOLID(类/模块级强制) @@ -55,11 +55,13 @@ 系统预留以下接口,保证可扩展性。新增同类能力 = 新增一个实现,**不改调用方**。端口统一声明于 `agent/domain/ports.py`(六边形架构:端口属领域核心);适配器实现散于对应目录。 +下表端口为**示范**(含一个 LLM 端口 + 三个通用外部能力端口);按项目实际需要增删: + | 扩展点(端口) | 当前适配器 | 适配器位置 | 未来扩展 | |--------|---------|---------|---------| | `LLMSession` | 当前 CLI 厂商适配器 | `agent/llm/` | 其他 CLI / API SDK | -| `ImageProvider` | unsplash / pexels / none | `agent/providers/` | 自建图库 / AI 生图 | -| `Exporter` | `pptx_exporter` | `agent/export/` | pdf_exporter / 长图 | +| `PaymentGateway` | 示范支付适配器 / none | `agent/providers/` | 接入真实支付网关 | +| `Exporter` | 示范导出器(如 PDF) | `agent/export/` | 其他格式 | | `StorageBackend` | `local_storage`(本地 FS) | `agent/storage/` | S3 / OSS | 每个端口都是 `Protocol`,新适配器注册到工厂(`registry`)即生效,调用方按配置选择。具体端口签名与注册规范见 `agent.md`。 @@ -93,28 +95,28 @@ class BadSession: ## 可靠性纪律(Resilience,强制) -核心链路全是易抖动的外部依赖(LLM CLI 进程 / Chromium 截图 / 图床 HTTP),故跨边界调用必须假定**会超时、会瞬时失败、会被重复触发**。以下与「命令幂等」(见 `communication-protocol.md`)同属可靠性约束,从设计之初遵循,不允许"先实现再加兜底"。 +核心链路常含易抖动的外部依赖(LLM CLI 进程 / 第三方 API / 外部 HTTP 服务),故跨边界调用必须假定**会超时、会瞬时失败、会被重复触发**。以下与「命令幂等」(见 `communication-protocol.md`)同属可靠性约束,从设计之初遵循,不允许"先实现再加兜底"。 1. **超时无处不在(Ubiquitous Timeout)** 任何跨进程/网络/IO 的等待**必须设显式超时**,禁止裸 `await` 无界等待。 - - 外部调用统一用 `asyncio.timeout(...)` 或客户端原生 timeout 包裹;超时即抛领域错误(如 `GEMINI_TIMEOUT`),不静默挂起。 + - 外部调用统一用 `asyncio.timeout(...)` 或客户端原生 timeout 包裹;超时即抛领域错误(如 `EXTERNAL_SERVICE_TIMEOUT`),不静默挂起。 - 超时值经 `.env` 注入、不硬编码(见 `config.md`);每类外部依赖一个超时配置项。 - 反例:`await proc.read()` 无超时 → 进程卡死则会话永久僵死。 2. **重试 + 指数退避 + 抖动(Retry / Backoff + Jitter)** - 对**幂等**操作的瞬时失败(网络抖动、图床 5xx、截图偶发失败)才允许重试;非幂等操作禁止盲目重试。 + 对**幂等**操作的瞬时失败(网络抖动、第三方 5xx、外部调用偶发失败)才允许重试;非幂等操作禁止盲目重试。 - 重试须有**上限**(次数 + 总时长双封顶),退避用指数 + 随机抖动,禁固定间隔猛刷。 - 重试逻辑收拢为一处可复用策略(如 `utils/retry.py`),不在各调用点散写循环。 - 用户输入触发的命令依赖幂等键去重(见 `communication-protocol.md`),保证重试安全。 3. **降级而非整体失败(Graceful Degradation)** - 单个可选能力失败时,**降级到退化态**而非让整条生成链崩溃。 - - 经端口的退化实现承接:`ImageProvider` 失败/超额 → 降级到 `none`(无图)继续生成,而非中断 Deck。 + 单个可选能力失败时,**降级到退化态**而非让整条主链崩溃。 + - 经端口的退化实现承接:某可选外部能力失败/超额 → 降级到 `none`(跳过该能力)继续主流程,而非中断整笔业务。 - 降级须**可观测**:记日志 + 必要时以事件告知前端(区别于 `error` 致命错误)。 - 哪些能力可降级、降级到何种退化态,在端口契约里显式声明。 4. **边界校验,内部信任(Validate at Boundary)** - 不可信输入(前端命令、上传 `.md`、外部响应)在**适配器/路由边界一次性校验**(Pydantic / 类型 / 业务规则),校验失败回 `INVALID_INPUT`;越过边界进入领域后**信任已校验**,领域内不重复防御性判空。 + 不可信输入(前端命令、上传文件、外部响应)在**适配器/路由边界一次性校验**(Pydantic / 类型 / 业务规则),校验失败回 `INVALID_INPUT`;越过边界进入领域后**信任已校验**,领域内不重复防御性判空。 - 校验是边界职责,不是领域职责——领域用前置条件断言不变量,而非兜外部脏数据。 5. **资源管理强制(RAII / Context Manager)** diff --git a/template/.claude/rules/error-codes.md b/template/.claude/rules/error-codes.md index 54d8dfa..972246e 100644 --- a/template/.claude/rules/error-codes.md +++ b/template/.claude/rules/error-codes.md @@ -8,15 +8,17 @@ paths: ["backend/**", "agent/**", "frontend/**"] 错误码是契约的一部分,前端按 `code` 分支处理,不依赖 `message` 文本。**集中登记,禁止散落定义。** +下表为**示范**(订单域 + 通用基础设施码),按项目实际增删: + | code | 含义 | 来源 | |------|------|------| | `SESSION_NOT_FOUND` | 会话不存在 | Backend | | `AGENT_UNAVAILABLE` | Agent 不可用 | Backend | -| `GEMINI_STARTUP_ERROR` | LLM CLI 启动失败 | Agent | -| `GEMINI_TIMEOUT` | LLM CLI 阶段超时 | Agent | -| `SKILL_NOT_INSTALLED` | 幻灯片生成 skill 未装 | Agent | -| `HTML_EXTRACTION_FAILED` | 无法提取有效 HTML | Agent | -| `PLAYWRIGHT_ERROR` | 截图失败 | Agent | +| `ORDER_NOT_FOUND` | 订单不存在 | Backend | +| `ORDER_STATE_INVALID` | 订单状态不允许此操作 | Backend/Agent | +| `LLM_STARTUP_ERROR` | LLM 启动/连接失败 | Agent | +| `EXTERNAL_SERVICE_TIMEOUT` | 外部依赖超时 | Agent | +| `EXTERNAL_SERVICE_ERROR` | 外部依赖返回错误 | Agent | | `CHECKPOINT_LOST` | 断点不可恢复 | Agent | | `CAPACITY_EXCEEDED` | 并发超额(排队) | Agent | | `INVALID_INPUT` | 输入校验失败 | Backend/Agent | @@ -24,4 +26,4 @@ paths: ["backend/**", "agent/**", "frontend/**"] - 命名:`大类_具体`,全大写蛇形;新增错误码必须先登记到此表 - code 字面值散落在各服务的异常类(backend `src/exceptions/base.py` 的 `AppException` 子类、agent `src/domain/exceptions.py` 的 `AgentException` 子类)。**本表是单一来源**,异常类的 `code` 与本表对齐**靠 review 把关**,不做代码生成(多数 code 服务私有、跨线共享少,生成机制收益不抵复杂度)。改 code 先改本表,再改对应异常类。 -- `GEMINI_*` 等历史命名沿用现有 code 字面值(契约稳定性优先),其语义已泛化为"LLM CLI",不随当前厂商变化 +- 外部依赖类错误码(`LLM_*` / `EXTERNAL_SERVICE_*`)与具体厂商解耦,换提供商不改 code。 diff --git a/template/.claude/rules/frontend-runtime.md b/template/.claude/rules/frontend-runtime.md index be6c49f..06e920a 100644 --- a/template/.claude/rules/frontend-runtime.md +++ b/template/.claude/rules/frontend-runtime.md @@ -51,7 +51,7 @@ window.addEventListener('unhandledrejection', (event) => { | 错误类型 | 展示方式 | |---------|---------| | WebSocket 连接失败 | Toast(error)+ 重连提示 | -| Gemini 超时/失败 | 聊天气泡(系统消息)+ Toast | +| 外部服务超时/失败 | 系统消息气泡 + Toast | | 文件下载失败 | Toast(error) | | 组件渲染崩溃 | ErrorBoundary fallback UI | @@ -92,18 +92,19 @@ dispatch(msg.data) ## 页面交互状态流 +下面是**示范**状态流(订单),按你的业务替换状态与展示: + ``` -idle ──► dialog ──► previewing ──► generating ──► screenshotting ──► done - │ - refining ◄┘ +idle ──► editing ──► submitting ──► processing ──► done + │ + revising ◄┘ ``` | 状态 | 展示内容 | |------|---------| -| `idle` | 初始引导(语言/图片源选择) | -| `dialog` | 聊天气泡 + ChatInput | -| `previewing` | StylePicker(3 个 iframe 预览卡片) | -| `generating` | ProgressBar + 日志气泡 | -| `screenshotting` | ProgressBar(逐页进度) | -| `done` | PreviewFrame + DownloadBar + ChatInput(refine) | -| `error` | 错误气泡 + 重试按钮 | +| `idle` | 初始引导 | +| `editing` | 表单录入(OrderForm) | +| `submitting` | 提交中(ProgressBar) | +| `processing` | 服务端处理中(ProgressBar + 日志) | +| `done` | 结果展示 + 操作入口(可触发 revising) | +| `error` | 错误提示 + 重试按钮 | diff --git a/template/.claude/rules/frontend.md b/template/.claude/rules/frontend.md index 6f0f110..80da4b6 100644 --- a/template/.claude/rules/frontend.md +++ b/template/.claude/rules/frontend.md @@ -6,7 +6,7 @@ paths: ["frontend/**"] React + Vite + TypeScript + Tailwind 前端的技术栈、目录、各层职责边界、TS 规范与测试规范。 -聊天式交互界面,实时展示 Gemini 对话、风格预览、HTML 演示文稿和下载入口。 +> 下文以「订单」交互为示范,仅占位;组件/状态命名替换为你的业务,分层与边界规则不变。实时交互界面经 WebSocket 展示服务端推送的状态变化。 ## 技术栈 @@ -51,30 +51,27 @@ const wsUrl = import.meta.env.VITE_BACKEND_WS_URL // ws://localhost:8000 frontend/ ├── src/ │ ├── components/ # 纯 UI 组件(无业务逻辑,无直接 store 读写) -│ │ ├── ChatBubble.tsx # 单条对话气泡(Gemini / 用户) -│ │ ├── ChatInput.tsx # 输入框 + 发送按钮 -│ │ ├── StylePicker.tsx # 3 张风格预览卡片,点击选择 -│ │ ├── PreviewFrame.tsx # iframe 展示完整 HTML 演示文稿 -│ │ ├── ProgressBar.tsx # 截图/合成进度条 -│ │ ├── DownloadBar.tsx # HTML + PPTX 下载按钮 +│ │ ├── OrderForm.tsx # 录入/编辑表单(示范) +│ │ ├── OrderList.tsx # 列表展示 +│ │ ├── StatusBadge.tsx # 状态标识 +│ │ ├── ProgressBar.tsx # 处理进度条 │ │ ├── ErrorBoundary.tsx # React 错误边界(组件级异常) │ │ └── Toast.tsx # 全局 Toast 容器(sonner Toaster) │ ├── pages/ # 页面级组件(组合 components,接入 hooks) -│ │ └── GeneratorPage.tsx +│ │ └── OrderPage.tsx │ ├── hooks/ # 自定义 Hook(业务逻辑与副作用) │ │ ├── useWebSocket.ts # WebSocket 生命周期管理 -│ │ ├── usePresentation.ts # 会话状态机(dispatch + 状态衍生) -│ │ └── useImageSource.ts # 图片源配置读写(localStorage) +│ │ └── useOrder.ts # 会话状态机(dispatch + 状态衍生) │ ├── services/ # 外部通信封装(纯函数,无 React 依赖) │ │ └── wsClient.ts # WebSocket send/receive,消息序列化 │ ├── store/ # Zustand store -│ │ └── presentationStore.ts # session、messages、previewUrls、status +│ │ └── orderStore.ts # session、messages、orders、status │ ├── types/ # TypeScript 类型(只定义,不实现) -│ │ └── messages.ts # WsMessage、PreviewItem、SessionStatus 等 +│ │ └── messages.ts # WsMessage、OrderDTO、SessionStatus 等 │ ├── schemas/ # Zod schema(运行时验证,与 types 一一对应) -│ │ └── messages.ts # wsMessageSchema、stylePickSchema 等 +│ │ └── messages.ts # wsMessageSchema、createOrderSchema 等 │ ├── utils/ # 纯函数工具(无副作用) -│ │ └── ansiStrip.ts # 过滤 ANSI 转义码 +│ │ └── format.ts # 纯函数格式化(金额/日期等) │ └── main.tsx # 入口:挂载 ErrorBoundary + Toaster └── tests/ ├── components/ diff --git a/template/.claude/rules/observability.md b/template/.claude/rules/observability.md index 0209b6b..d7611d0 100644 --- a/template/.claude/rules/observability.md +++ b/template/.claude/rules/observability.md @@ -11,9 +11,9 @@ paths: ["backend/**", "agent/**", "frontend/**"] | 层 | 文件 | 格式 | |----|------|------| -| Backend | `~/ppt-gen/logs/backend.log` | JSON,每行一条,含 request_id | -| Agent | `~/ppt-gen/logs/agent.log` | JSON,每行一条,含 session_id | +| Backend | `{WORK_DIR}/logs/backend.log` | JSON,每行一条,含 request_id | +| Agent | `{WORK_DIR}/logs/agent.log` | JSON,每行一条,含 session_id | | Frontend | console(dev)/ 可接 Sentry(prod) | 结构化对象 | -- **探针**:每个服务暴露 `GET /health`(存活)与 `GET /ready`(依赖就绪,如 Agent 检查 skill 已装、Chromium 可启动) +- **探针**:每个服务暴露 `GET /health`(存活)与 `GET /ready`(依赖就绪,如 Agent 检查外部依赖可用) - 禁止 `print()` / `console.log()`,统一用各层 logger(见 `conventions.md`) diff --git a/template/.claude/rules/testing-tdd.md b/template/.claude/rules/testing-tdd.md index cf97d9f..51db55c 100644 --- a/template/.claude/rules/testing-tdd.md +++ b/template/.claude/rules/testing-tdd.md @@ -23,9 +23,9 @@ |------|------|------|------| | 单元 | 纯函数、节点、service、hook(mock 边界) | ~70% | pytest / vitest | | 集成 | 路由+DB、WS 代理转发、DAG 串联 | ~25% | httpx / Testing Library | -| E2E | 一条龙:输入→生成→下载(mock LLM CLI 输出) | ~5% | Playwright | +| E2E | 一条龙:输入→处理→结果(mock 外部依赖输出) | ~5% | Playwright | - **覆盖率门禁**:行覆盖 ≥ 80%(核心 hooks/schemas/dag 节点 100%),CI `--cov-fail-under=80` / vitest `coverage.thresholds` -- **mock 边界清晰**:只 mock 外部依赖(LLM CLI 子进程、Chromium、网络),不 mock 被测对象内部 +- **mock 边界清晰**:只 mock 外部依赖(LLM CLI 子进程、第三方 API、网络),不 mock 被测对象内部 - **测试命名**:`test_<被测>_<场景>_<期望>`;一个测试只断言一件事 - **确定性**:禁止依赖真实时间/网络/随机;用 fixture 注入 diff --git a/template/.gitignore.jinja b/template/.gitignore.jinja index a8efc48..bf597ea 100644 --- a/template/.gitignore.jinja +++ b/template/.gitignore.jinja @@ -29,6 +29,5 @@ dist/ Thumbs.db # 生成产物 -*.pptx *.db logs/ diff --git a/template/agent/Dockerfile.jinja b/template/agent/Dockerfile.jinja index 9736576..850c300 100644 --- a/template/agent/Dockerfile.jinja +++ b/template/agent/Dockerfile.jinja @@ -4,17 +4,8 @@ WORKDIR /app COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv -RUN apt-get update && apt-get install -y --no-install-recommends \ - libglib2.0-0 libnss3 libatk1.0-0 libatk-bridge2.0-0 \ - libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \ - libxfixes3 libxrandr2 libgbm1 libasound2 \ - && rm -rf /var/lib/apt/lists/* - COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --no-dev -{%- if llm_provider == 'gemini-cli' %} -RUN uv run playwright install chromium -{%- endif %} COPY src ./src diff --git a/template/agent/pyproject.toml.jinja b/template/agent/pyproject.toml.jinja index 1e58b1d..9c1c6d0 100644 --- a/template/agent/pyproject.toml.jinja +++ b/template/agent/pyproject.toml.jinja @@ -8,14 +8,12 @@ dependencies = [ "uvicorn[standard]", "loguru", "pydantic-settings", -{%- if llm_provider == 'gemini-cli' %} - "playwright", - "python-pptx", -{%- elif llm_provider == 'openai-api' %} +{%- if llm_provider == 'openai-api' %} "openai", {%- elif llm_provider == 'anthropic-api' %} "anthropic", {%- endif %} + # 业务相关外部能力(导出、第三方 SDK 等)按需自行 uv add ] [dependency-groups] @@ -50,4 +48,4 @@ root_packages = ["src"] name = "Domain zero IO" type = "forbidden" source_modules = ["src.domain"] -forbidden_modules = ["fastapi", "httpx", "subprocess", "playwright"] +forbidden_modules = ["fastapi", "httpx", "subprocess"]