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