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:
baozaotumao
2026-06-01 23:15:05 -07:00
parent bb6503c3f8
commit df48c36422
23 changed files with 257 additions and 285 deletions
@@ -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() # 归还借用的外部资源句柄
```
### 共享状态线程安全
+53 -62
View File
@@ -5,27 +5,26 @@ paths:
# Agent DAG 编排与异常体系
PresentationState 聚合根、DAG 节点表、runner 协调器(含 checkpoint 恢复与写入点)以及异常体系与传播规则。
SessionState 聚合根、DAG 节点表、runner 协调器(含 checkpoint 恢复与写入点)以及异常体系与传播规则。
> 下文以一条「会话→LLM 处理→结果」的通用流水线作示范,仅占位;节点与状态按你的业务替换,DAG 结构、checkpoint 与异常规则不变。
## DAG 编排(dag/
### PresentationStatedomain/state.py,聚合根状态)
### SessionStatedomain/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,启动 PTYskill 检查 |
| `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 异常**
@@ -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 节点)零改动。
+10 -11
View File
@@ -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_preview3 张预览 | `preview_timeout_seconds`(默认 180s |
| phase3_generate(完整 HTML | `generate_timeout_seconds`(默认 300s |
| intake(收集输入) | `intake_timeout_seconds`(默认 60s |
| processLLM 处理 | `process_timeout_seconds`(默认 300s |
超时后抛 `LLMTimeoutError``domain/exceptions.py`),不静默挂起。
超时后抛 `ExternalServiceTimeoutError``domain/exceptions.py`),不静默挂起。
## 连接管理
+13 -14
View File
@@ -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_preview3 张预览 | 180s |
| phase3_generate(完整 HTML | 300s |
| screenshot | 180s |
| intake(收集输入 | 60s |
| processLLM 处理输出 | 300s |
超时后抛 `GeminiTimeoutError`
超时后抛 `ExternalServiceTimeoutError`
## HTML 拦截检测
## 输出拦截检测
CLI 输出是流式文本,需按约定标记从中提取结构化结果。下面以提取代码块为例(按你的输出约定替换正则):
```python
def detect_html(text: str) -> str | None:
m = re.search(r'```html\s*([\s\S]+?)```|(<html[\s\S]+?</html>)', 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`,触发后续节点
+23 -25
View File
@@ -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 -%}
| playwrightasync | 截取每页幻灯片截图 |
| 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 需要预装某个 skillAgent 启动时自动确保配置指定的 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`
+15 -13
View File
@@ -2,15 +2,17 @@
> 三层架构、通信链路、限界上下文、六边形分层与依赖方向。
> 下文以「订单」为示范业务,仅占位;新项目替换为自己的领域,分层与依赖规则不变。
## 三层架构
```
frontend/ React + Vite + TypeScript + Tailwind
backend/ FastAPIport 8000)—— WebSocket 代理 + 会话持久化
agent/ FastAPIport 8001)—— LLM CLI PTY 桥接 + DAG 编排
backend/ FastAPIport 8000)—— WebSocket 代理 + 会话/状态持久化
agent/ FastAPIport 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
外部 LLMCLI 适配器如 Gemini CLI,或 API SDK
```
- LLM CLI(当前适配器为 Gemini CLI)与所加载的 skill 都是**可替换的外部依赖**,不在领域内硬编码。CLI 命令、skill 来源(仓库/路径)与安装路径均经配置注入(见 `config.md``agent.md`),换 CLI 或换 skill 不触动领域逻辑。
- 外部 LLMCLI 命令或 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
│ 依赖
+31 -41
View File
@@ -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
+5 -5
View File
@@ -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 + 可选 spectralOpenAPI lint);HTTP 语义靠 review。
例:`GET /api/v1/sessions/{session_id}/export`。路径/字段命名靠 review + 可选 spectralOpenAPI 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,两个 sinkstderrdev 人类可读彩色)+ `~/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,两个 sinkstderrdev 人类可读彩色)+ `{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`
## 测试规范
@@ -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": "..."}
```
@@ -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 对应的强类型体
+1 -1
View File
@@ -27,7 +27,7 @@
## 其他约定
- 每次生成请求使用独立 UUID 工作目录:`~/ppt-gen/{uuid}/`
- 每次会话/请求使用独立 UUID 工作目录:`{WORK_DIR}/{uuid}/``WORK_DIR` 经 .env 注入)
## 包管理概览
+6 -6
View File
@@ -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 |
+12 -11
View File
@@ -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**
有规则、有约束的概念不用裸标量表示。规则全封进值对象,**一处定义、全局复用**。
+11 -9
View File
@@ -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**
+8 -6
View File
@@ -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。
+12 -11
View File
@@ -51,7 +51,7 @@ window.addEventListener('unhandledrejection', (event) => {
| 错误类型 | 展示方式 |
|---------|---------|
| WebSocket 连接失败 | Toasterror+ 重连提示 |
| Gemini 超时/失败 | 聊天气泡(系统消息+ Toast |
| 外部服务超时/失败 | 系统消息气泡 + Toast |
| 文件下载失败 | Toasterror |
| 组件渲染崩溃 | 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` | StylePicker3 个 iframe 预览卡片 |
| `generating` | ProgressBar + 日志气泡 |
| `screenshotting` | ProgressBar(逐页进度 |
| `done` | PreviewFrame + DownloadBar + ChatInputrefine |
| `error` | 错误气泡 + 重试按钮 |
| `idle` | 初始引导 |
| `editing` | 表单录入(OrderForm |
| `submitting` | 提交中(ProgressBar |
| `processing` | 服务端处理中(ProgressBar + 日志 |
| `done` | 结果展示 + 操作入口(可触发 revising |
| `error` | 错误提示 + 重试按钮 |
+11 -14
View File
@@ -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/
+3 -3
View File
@@ -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 | consoledev/ 可接 Sentryprod | 结构化对象 |
- **探针**:每个服务暴露 `GET /health`(存活)与 `GET /ready`(依赖就绪,如 Agent 检查 skill 已装、Chromium 可启动
- **探针**:每个服务暴露 `GET /health`(存活)与 `GET /ready`(依赖就绪,如 Agent 检查外部依赖可用
- 禁止 `print()` / `console.log()`,统一用各层 logger(见 `conventions.md`
+2 -2
View File
@@ -23,9 +23,9 @@
|------|------|------|------|
| 单元 | 纯函数、节点、service、hookmock 边界) | ~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 注入