Initial import: zcbot personal task agent
DESIGN.md / PROGRESS.md 落地 Phase 1-3: - core/: LiteLLM 封装 + ReAct loop + 会话持久化 + Anthropic skill registry - tools/: read/write/edit/glob/grep/shell/run_python/load_skill (Hybrid 范式) - skills/coding | proposal: WHY+WHAT 风格 SKILL.md - skills/ppt: 完整渐进披露 (SKILL + 4 references + 3 scripts) · 借鉴 hugohe3/ppt-master 的两阶段 + spec lock 思路 · MSO_SHAPE 图标体系 + 安全区 + 越界检测 · 默认商务红主题 (#C00000 / #E15554 / #FFC107) - config/models/: DeepSeek V4 flash/pro 档案 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
3a66849953
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# Virtualenv
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# 用户运行产物 / 临时文件
|
||||||
|
workspace/
|
||||||
|
slides/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Claude Code 本地状态
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# IDE / OS
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# 可执行产物 (PPT skill 测试输出)
|
||||||
|
*.tmp.pptx
|
||||||
|
output.pptx
|
||||||
|
untitled*.pptx
|
||||||
|
|
||||||
|
# 用户本地工具脚本 / 规划文件 (不入库)
|
||||||
|
规划.docx
|
||||||
|
cl.ps1
|
||||||
|
col.bat
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
# 实施进度
|
||||||
|
|
||||||
|
> 配合 `DESIGN.md` 阅读。本文件记录已完成的事、关键决策、与原设计的偏差。
|
||||||
|
|
||||||
|
最后更新: 2026-05-06 (PPT skill 完善:references + scripts;v2 加图标系统 + 安全区 + 越界检测 + 默认红色主题)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 总体状态
|
||||||
|
|
||||||
|
| Phase | 标题 | 状态 | 备注 |
|
||||||
|
|------|-----|-----|------|
|
||||||
|
| 1 | 最小可用骨架 | ✅ 完成 | 全部验收点过 |
|
||||||
|
| 2 | Skill 系统 + 三个 skill | ✅ 完成 | Anthropic 格式;coding/ppt/proposal |
|
||||||
|
| 3 | Hybrid 范式 (run_python) | ✅ 完成 | subprocess + 敏感 env 过滤 |
|
||||||
|
| 4 | 演化性能力 | 🟡 部分 | Model Profile 已就位;capability probing 未做;版本化 prompts 未做 |
|
||||||
|
| 5 | Eval Suite | ❌ 未开始 | |
|
||||||
|
| 6 | 长任务工程化 | 🟡 部分 | session 中断恢复已完成;context 压缩、双层记忆未做 |
|
||||||
|
| 7 | 打磨 | ❌ 未开始 | Docker 沙盒 / 更多 skill / Web UI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已完成清单
|
||||||
|
|
||||||
|
### 1. 项目骨架
|
||||||
|
- 目录: `core/ tools/ skills/ prompts/ config/ workspace/`
|
||||||
|
- 入口: `cli.py` (REPL) + `main.py` (装配)
|
||||||
|
- 依赖: `requirements.txt` (litellm / pyyaml / click / rich / python-pptx / python-docx / matplotlib)
|
||||||
|
- 本地虚拟环境: `.venv/`(Python 3.10.9)
|
||||||
|
|
||||||
|
### 2. 模型层
|
||||||
|
- `core/capabilities.py`: `ModelCapabilities` 数据类,从 `config/models/<family>.yaml` 加载
|
||||||
|
- `core/llm.py`: LiteLLM 封装,自动按 capabilities 启用 parallel_tools / reasoning_effort / prompt_caching / thinking_mode;指数退避重试
|
||||||
|
- `config/models/deepseek_v4.yaml`: flash 和 pro 两档
|
||||||
|
- 缺 `DEEPSEEK_API_KEY` 时报清晰错误,不崩
|
||||||
|
|
||||||
|
### 3. 会话与持久化
|
||||||
|
- `core/session.py`: 内存消息列表 + 元数据 + 落盘 JSON,文件格式
|
||||||
|
```json
|
||||||
|
{"meta": {"id","created_at","cwd","model","model_profile"}, "messages": [...]}
|
||||||
|
```
|
||||||
|
老格式(纯 list)向后兼容
|
||||||
|
- 每次 `cli.py chat` 启动一个新 session,文件名 `workspace/sessions/<YYYYMMDD_HHMMSS>.json`
|
||||||
|
- 支持: `--resume last` / `--resume <id>`;resume 时若当前 cwd 与记录不同会警告
|
||||||
|
- REPL 命令: `/exit /reset /new /id`
|
||||||
|
- `cli.py sessions` 列表显示 id / msgs / cwd / 第一条用户消息预览
|
||||||
|
|
||||||
|
### 4. ReAct 主循环
|
||||||
|
- `core/loop.py`: LLM ↔ tool 循环,无 tool_calls 即返回
|
||||||
|
- LLM 调用包了 `console.status("thinking...", spinner="dots")` 转圈点
|
||||||
|
- 工具结果对模型截断到 16K 字符,对用户预览 400 字符
|
||||||
|
- 所有日志走 `rich.Console`,彩色
|
||||||
|
|
||||||
|
### 5. 通用工具
|
||||||
|
- `tools/base.py`: `Tool` 基类 + `_resolve` 路径解析
|
||||||
|
- `tools/fs.py`:
|
||||||
|
- `read` —— 带行号,支持 offset/limit
|
||||||
|
- `write` —— 自动建父目录,覆写
|
||||||
|
- `edit` —— old_str **唯一匹配**约束(CoreCoder 风格)
|
||||||
|
- `glob` —— `**/*.py` 等模式
|
||||||
|
- `grep` —— Python 正则,自动跳过 `.git node_modules __pycache__ .venv venv dist build`
|
||||||
|
- `tools/shell.py`: subprocess 执行,黑名单拦 `rm -rf /` 等;默认 60s 超时
|
||||||
|
- `tools/run_python.py`: subprocess 跑临时 .py 文件,过滤 `*API_KEY *TOKEN *SECRET *PASSWORD *PRIVATE_KEY` 环境变量
|
||||||
|
|
||||||
|
### 6. Skill 系统(Anthropic 渐进披露标准)
|
||||||
|
- `core/skills.py`: `SkillRegistry` 扫描 `skills/<name>/`,只读 SKILL.md frontmatter 做 discovery
|
||||||
|
- `tools/skill_tool.py`: `load_skill(name)` 工具返回完整 SKILL.md 给模型
|
||||||
|
- 三个 skill,均按 WHY+WHAT 风格写,不写 Step 1/2/3:
|
||||||
|
- `skills/coding/SKILL.md`
|
||||||
|
- `skills/ppt/` —— 完整渐进披露结构(借鉴 hugohe3/ppt-master 的两阶段 + spec lock 思路):
|
||||||
|
- `SKILL.md`(两阶段工作流 + 八条对齐 + 默认红色主题 + 反模式)
|
||||||
|
- `references/design_principles.md`(字号/配色/留白/图表 + §4.1 **字数预算表**)
|
||||||
|
- `references/canvas_presets.md`(16:9 / 4:3 / 9:16 等画布表)
|
||||||
|
- `references/layouts.md`(9 种轻量版式 + **safe area 起手** + assert_inside / TEXT_TO_FIT_SHAPE 兜底)
|
||||||
|
- `references/icons.md`(MSO_SHAPE 图标目录 + unicode 字形表 + 5 个标准图标 helper)
|
||||||
|
- `scripts/quality_check.py`(页数/标题/bullet/字号/配色 + **shape 越界 + 文本溢出估算**)
|
||||||
|
- `scripts/source_to_md.py`(PDF/DOCX/PPTX/URL → Markdown,策略阶段输入)
|
||||||
|
- `scripts/render_icon.py`(unicode 字形 → 透明 PNG,MSO_SHAPE 兜底)
|
||||||
|
- **默认配色**:商务红 PRIMARY `#C00000` / SECONDARY `#E15554` / ACCENT `#FFC107`
|
||||||
|
- `skills/proposal/SKILL.md`(含工作目录约定 + 字数表 + python-docx 合并模板)
|
||||||
|
|
||||||
|
### 7. System Prompt
|
||||||
|
- `prompts/system/general_v1.md`(无版本化软链接,直接引用 v1)
|
||||||
|
- 启动时拼接顺序: 通用指引 → discovery 块(skill 列表) → 当前工作目录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键决策与偏差
|
||||||
|
|
||||||
|
| 项 | 决策 | 与设计差异 |
|
||||||
|
|---|------|-----------|
|
||||||
|
| 工具基目录 | 用户当前 cwd,不是 workspace/ | 设计未明说;选 cwd 是因为 agent 该操作用户的项目 |
|
||||||
|
| Workspace 用途 | 只存 sessions/(暂时) | 设计含 `tasks/ memory/ logs/`,后续 Phase 6 再加 |
|
||||||
|
| Session 粒度 | 一个文件一个 session,无 task 概念 | 设计有 task_id / state.json,Phase 6 再加 |
|
||||||
|
| 版本化 prompt | 直接 general_v1.md,无 active.md 软链接 | Windows 软链接麻烦;后续要切版本时再做 |
|
||||||
|
| run_python 沙盒 | subprocess + env 过滤 | 设计阶段 1 就是这套,未升级 Docker |
|
||||||
|
| 工具数 | 8 个 (read/write/edit/glob/grep/shell/run_python/load_skill) | 设计上限 ≤10 同时可见,目前刚好 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收过的测试
|
||||||
|
|
||||||
|
- 全项目 `ast.parse` 语法 OK
|
||||||
|
- yaml 配置可解析
|
||||||
|
- 所有 import 链路在 venv 中跑通
|
||||||
|
- `cli.py --help` / `cli.py chat --help` / `cli.py sessions --help` 正常
|
||||||
|
- `SkillRegistry` 识别出 3 个 skill,discovery 块拼装正确
|
||||||
|
- 缺 `DEEPSEEK_API_KEY` 时报清晰错误
|
||||||
|
- 实测 DeepSeek API 接通(`deepseek-v4-flash` 模型 ID 被认),仅因账户余额不足而返回 InsufficientBalance —— **接入路径已通**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已知遗留 / 下一步候选
|
||||||
|
|
||||||
|
按性价比排序:
|
||||||
|
|
||||||
|
1. **Phase 4 capability probing**(~半天)—— 启动时跑 needle-in-haystack / 并行 tool 探测,把 yaml 声称的能力对账
|
||||||
|
2. **Phase 5 Eval Suite**(~2 天)—— 模型升级决策的依据。每类任务 3-5 个 case,客观 + LLM judge 双评分
|
||||||
|
3. **Phase 6 task 概念 + state.json**(~1 天)—— 让 session 升级为任务,workspace 加 `tasks/<task_id>/`
|
||||||
|
4. **Phase 6 context 三层压缩**(~1 天)—— 兜底用,V4 长上下文一般用不到
|
||||||
|
5. **Phase 6 双层记忆**(~半天)—— `workspace/memory/core.md` 注 prompt + `extended/` 按需读
|
||||||
|
6. **Phase 7 Docker 沙盒**(~1 天)—— 替换 subprocess,run_python 安全升级
|
||||||
|
7. **Phase 7 更多 skill / 模型档案**(持续)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件清单(代码量)
|
||||||
|
|
||||||
|
```
|
||||||
|
core/capabilities.py 71 行
|
||||||
|
core/llm.py 89 行
|
||||||
|
core/loop.py 99 行
|
||||||
|
core/session.py 77 行
|
||||||
|
core/skills.py 81 行
|
||||||
|
tools/base.py 34 行
|
||||||
|
tools/fs.py 182 行
|
||||||
|
tools/shell.py 63 行
|
||||||
|
tools/run_python.py 84 行
|
||||||
|
tools/skill_tool.py 45 行
|
||||||
|
main.py 120 行
|
||||||
|
cli.py 138 行
|
||||||
|
─────────────────────────────────
|
||||||
|
合计 Python 1083 行
|
||||||
|
|
||||||
|
prompts/system/general_v1.md
|
||||||
|
skills/coding/SKILL.md
|
||||||
|
skills/ppt/SKILL.md
|
||||||
|
skills/proposal/SKILL.md
|
||||||
|
config/agent.yaml
|
||||||
|
config/models/deepseek_v4.yaml
|
||||||
|
requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
设计预估 Phase 1-3 大约 800-1000 行,实际 1083 行,略多但仍在可读范围。
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
"""CLI 入口: 简单 REPL。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python cli.py chat # 新建一个 session
|
||||||
|
python cli.py chat --resume last # 恢复最近一个
|
||||||
|
python cli.py chat --resume 20260506_141523
|
||||||
|
python cli.py chat --model deepseek_v4.pro
|
||||||
|
python cli.py sessions # 列出历史 session
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.prompt import Prompt
|
||||||
|
|
||||||
|
from main import build_agent, load_config, resolve_workspace, sessions_dir
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
def cli() -> None:
|
||||||
|
"""zcbot - 个人任务 agent"""
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option("--model", default=None, help="模型档案,如 deepseek_v4.flash 或 deepseek_v4.pro")
|
||||||
|
@click.option("--workspace", default=None, help="工作目录(存 sessions/)")
|
||||||
|
@click.option("--resume", default=None, help="恢复某个 session: 'last' 或 session_id")
|
||||||
|
def chat(model: str, workspace: str, resume: str) -> None:
|
||||||
|
"""启动交互式 REPL。每次启动默认开新 session,用 --resume 接老的。"""
|
||||||
|
console = Console()
|
||||||
|
try:
|
||||||
|
agent, session, sid = build_agent(
|
||||||
|
model_name=model,
|
||||||
|
workspace=workspace,
|
||||||
|
console=console,
|
||||||
|
session_id=resume,
|
||||||
|
resume=bool(resume),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]启动失败:[/red] {type(e).__name__}: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if resume:
|
||||||
|
console.print(
|
||||||
|
f"[green]恢复 session[/green] [bold]{sid}[/bold] ({len(session.messages)} 条消息) "
|
||||||
|
f"model: [bold]{agent.caps.model_id}[/bold]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
console.print(
|
||||||
|
f"[green]新 session[/green] [bold]{sid}[/bold] "
|
||||||
|
f"model: [bold]{agent.caps.model_id}[/bold]"
|
||||||
|
)
|
||||||
|
console.print("[dim]/exit 退出 /reset 清空当前对话 /new 开一个新 session /id 显示 session id[/dim]\n")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
user_input = Prompt.ask("[bold blue]you[/bold blue]")
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
console.print("\n[dim]bye[/dim]")
|
||||||
|
break
|
||||||
|
|
||||||
|
cmd = user_input.strip()
|
||||||
|
if cmd in ("/exit", "/quit"):
|
||||||
|
break
|
||||||
|
if cmd == "/reset":
|
||||||
|
session.reset(keep_system=True)
|
||||||
|
console.print("[dim]当前 session 已重置(保留 system)[/dim]")
|
||||||
|
continue
|
||||||
|
if cmd == "/new":
|
||||||
|
try:
|
||||||
|
agent, session, sid = build_agent(
|
||||||
|
model_name=model, workspace=workspace, console=console
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]新建失败:[/red] {type(e).__name__}: {e}")
|
||||||
|
continue
|
||||||
|
console.print(f"[green]新 session[/green] [bold]{sid}[/bold]")
|
||||||
|
continue
|
||||||
|
if cmd == "/id":
|
||||||
|
cwd_disp = session.meta.get("cwd", "?")
|
||||||
|
model_disp = session.meta.get("model", agent.caps.model_id)
|
||||||
|
console.print(f"[dim]session: {sid} model: {model_disp} cwd: {cwd_disp}[/dim]")
|
||||||
|
continue
|
||||||
|
if not cmd:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
agent.run(user_input)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
console.print("\n[yellow]已中断本轮。下一条输入会继续这个 session。[/yellow]")
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]运行错误:[/red] {type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option("--workspace", default=None, help="工作目录")
|
||||||
|
@click.option("--limit", default=20, help="显示最近 N 个")
|
||||||
|
def sessions(workspace: str, limit: int) -> None:
|
||||||
|
"""列出已有 session。"""
|
||||||
|
cfg = load_config()
|
||||||
|
ws = resolve_workspace(workspace, cfg)
|
||||||
|
sdir = sessions_dir(ws)
|
||||||
|
|
||||||
|
items = sorted(sdir.glob("*.json"), reverse=True)[:limit]
|
||||||
|
if not items:
|
||||||
|
click.echo(f"(no sessions in {sdir})")
|
||||||
|
return
|
||||||
|
|
||||||
|
click.echo(f"{'session id':<18} {'msgs':>4} {'cwd':<32} preview")
|
||||||
|
click.echo("-" * 100)
|
||||||
|
for p in items:
|
||||||
|
try:
|
||||||
|
data = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(data, list):
|
||||||
|
messages, meta = data, {}
|
||||||
|
else:
|
||||||
|
messages = data.get("messages", []) or []
|
||||||
|
meta = data.get("meta", {}) or {}
|
||||||
|
n = len(messages)
|
||||||
|
preview = ""
|
||||||
|
for m in messages:
|
||||||
|
if isinstance(m, dict) and m.get("role") == "user":
|
||||||
|
preview = (m.get("content") or "")[:50].replace("\n", " ")
|
||||||
|
break
|
||||||
|
cwd = meta.get("cwd") or "?"
|
||||||
|
if len(cwd) > 32:
|
||||||
|
cwd = "..." + cwd[-29:]
|
||||||
|
except Exception as e:
|
||||||
|
n, preview, cwd = -1, f"[parse error: {e}]", "?"
|
||||||
|
click.echo(f"{p.stem:<18} {n:>4} {cwd:<32} {preview}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli()
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
# 默认模型档案: <family>.<variant>,对应 config/models/<family>.yaml
|
||||||
|
default_model: deepseek_v4.flash
|
||||||
|
|
||||||
|
models_dir: config/models
|
||||||
|
skills_dir: skills
|
||||||
|
workspace_dir: workspace
|
||||||
|
system_prompt: prompts/system/general_v1.md
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
# DeepSeek V4 模型档案
|
||||||
|
# 如你的账号还没有 V4 访问权限,可把 model_id 改成 deepseek/deepseek-chat
|
||||||
|
family: deepseek_v4
|
||||||
|
|
||||||
|
variants:
|
||||||
|
flash:
|
||||||
|
model_id: deepseek/deepseek-v4-flash
|
||||||
|
api_base: https://api.deepseek.com/v1
|
||||||
|
api_key_env: DEEPSEEK_API_KEY
|
||||||
|
max_context: 1048576
|
||||||
|
reliable_context: 262144
|
||||||
|
max_output: 8192
|
||||||
|
parallel_tools: false
|
||||||
|
tool_calling_quality: good
|
||||||
|
thinking_mode: false
|
||||||
|
reasoning_effort_levels: []
|
||||||
|
default_reasoning_effort: ""
|
||||||
|
code_quality: good
|
||||||
|
enable_run_python: true
|
||||||
|
max_iterations: 50
|
||||||
|
optimal_temperature: 0.3
|
||||||
|
prompt_caching: false
|
||||||
|
extended_thinking: false
|
||||||
|
|
||||||
|
pro:
|
||||||
|
model_id: deepseek/deepseek-v4-pro
|
||||||
|
api_base: https://api.deepseek.com/v1
|
||||||
|
api_key_env: DEEPSEEK_API_KEY
|
||||||
|
max_context: 1048576
|
||||||
|
reliable_context: 524288
|
||||||
|
max_output: 8192
|
||||||
|
parallel_tools: true
|
||||||
|
tool_calling_quality: excellent
|
||||||
|
thinking_mode: true
|
||||||
|
reasoning_effort_levels: [low, medium, high, max]
|
||||||
|
default_reasoning_effort: medium
|
||||||
|
code_quality: excellent
|
||||||
|
enable_run_python: true
|
||||||
|
max_iterations: 100
|
||||||
|
optimal_temperature: 0.2
|
||||||
|
prompt_caching: false
|
||||||
|
extended_thinking: false
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""模型能力档案: 不同模型的参数差异都收敛到 yaml,加新模型不用改代码。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field, fields
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelCapabilities:
|
||||||
|
model_id: str = ""
|
||||||
|
family: str = ""
|
||||||
|
variant: str = ""
|
||||||
|
|
||||||
|
# 上下文
|
||||||
|
max_context: int = 128_000
|
||||||
|
reliable_context: int = 64_000
|
||||||
|
max_output: int = 4096
|
||||||
|
|
||||||
|
# Tool calling
|
||||||
|
parallel_tools: bool = False
|
||||||
|
tool_calling_quality: str = "good"
|
||||||
|
|
||||||
|
# 思考模式
|
||||||
|
thinking_mode: bool = False
|
||||||
|
reasoning_effort_levels: List[str] = field(default_factory=list)
|
||||||
|
default_reasoning_effort: str = ""
|
||||||
|
|
||||||
|
# 代码 / 沙盒
|
||||||
|
code_quality: str = "good"
|
||||||
|
enable_run_python: bool = False
|
||||||
|
|
||||||
|
# 工程参数
|
||||||
|
max_iterations: int = 50
|
||||||
|
optimal_temperature: float = 0.3
|
||||||
|
|
||||||
|
# provider 特性
|
||||||
|
prompt_caching: bool = False
|
||||||
|
extended_thinking: bool = False
|
||||||
|
|
||||||
|
# API 接入
|
||||||
|
api_base: str = ""
|
||||||
|
api_key_env: str = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, name: str, models_dir: Path) -> "ModelCapabilities":
|
||||||
|
"""name: '<family>.<variant>',如 'deepseek_v4.flash'。"""
|
||||||
|
if "." in name:
|
||||||
|
family, variant = name.split(".", 1)
|
||||||
|
else:
|
||||||
|
family, variant = name, "default"
|
||||||
|
|
||||||
|
path = Path(models_dir) / f"{family}.yaml"
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(f"模型档案不存在: {path}")
|
||||||
|
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
|
variants = data.get("variants", {})
|
||||||
|
if variant not in variants:
|
||||||
|
raise ValueError(
|
||||||
|
f"档案 {path} 没有 variant={variant};可选: {list(variants)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
var = variants[variant]
|
||||||
|
valid_keys = {f.name for f in fields(cls)}
|
||||||
|
kwargs = {k: v for k, v in var.items() if k in valid_keys}
|
||||||
|
kwargs["family"] = data.get("family", family)
|
||||||
|
kwargs["variant"] = variant
|
||||||
|
return cls(**kwargs)
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""LiteLLM 封装: capabilities 决定调用参数,自动重试。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
|
import litellm
|
||||||
|
from litellm.exceptions import (
|
||||||
|
APIConnectionError,
|
||||||
|
APIError,
|
||||||
|
RateLimitError,
|
||||||
|
ServiceUnavailableError,
|
||||||
|
Timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .capabilities import ModelCapabilities
|
||||||
|
|
||||||
|
|
||||||
|
class TokenCounter:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.prompt_tokens = 0
|
||||||
|
self.completion_tokens = 0
|
||||||
|
|
||||||
|
def add(self, usage: Any) -> None:
|
||||||
|
if not usage:
|
||||||
|
return
|
||||||
|
if hasattr(usage, "model_dump"):
|
||||||
|
usage = usage.model_dump()
|
||||||
|
elif hasattr(usage, "dict"):
|
||||||
|
usage = usage.dict()
|
||||||
|
if isinstance(usage, dict):
|
||||||
|
self.prompt_tokens += int(usage.get("prompt_tokens") or 0)
|
||||||
|
self.completion_tokens += int(usage.get("completion_tokens") or 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> int:
|
||||||
|
return self.prompt_tokens + self.completion_tokens
|
||||||
|
|
||||||
|
|
||||||
|
class LLM:
|
||||||
|
def __init__(self, capabilities: ModelCapabilities) -> None:
|
||||||
|
self.caps = capabilities
|
||||||
|
env_name = capabilities.api_key_env or "DEEPSEEK_API_KEY"
|
||||||
|
self.api_key = os.environ.get(env_name)
|
||||||
|
self.api_base = capabilities.api_base or None
|
||||||
|
self.token_counter = TokenCounter()
|
||||||
|
if not self.api_key:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"环境变量 {env_name} 未设置,无法调用 {capabilities.model_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def chat(
|
||||||
|
self,
|
||||||
|
messages: List[dict],
|
||||||
|
tools: Optional[list] = None,
|
||||||
|
parallel_tool_calls: Optional[bool] = None,
|
||||||
|
reasoning_effort: Optional[str] = None,
|
||||||
|
max_retries: int = 3,
|
||||||
|
) -> Any:
|
||||||
|
kwargs: dict = {
|
||||||
|
"model": self.caps.model_id,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": self.caps.optimal_temperature,
|
||||||
|
"api_key": self.api_key,
|
||||||
|
}
|
||||||
|
if self.api_base:
|
||||||
|
kwargs["api_base"] = self.api_base
|
||||||
|
if tools:
|
||||||
|
kwargs["tools"] = tools
|
||||||
|
if self.caps.parallel_tools and parallel_tool_calls is not False:
|
||||||
|
kwargs["parallel_tool_calls"] = True
|
||||||
|
if self.caps.thinking_mode and reasoning_effort:
|
||||||
|
kwargs["reasoning_effort"] = reasoning_effort
|
||||||
|
if self.caps.prompt_caching:
|
||||||
|
kwargs["extra_headers"] = {"anthropic-beta": "prompt-caching-2024-07-31"}
|
||||||
|
|
||||||
|
last_err: Optional[Exception] = None
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
response = litellm.completion(**kwargs)
|
||||||
|
self.token_counter.add(getattr(response, "usage", None))
|
||||||
|
return response
|
||||||
|
except (RateLimitError, APIConnectionError, ServiceUnavailableError, Timeout, APIError) as e:
|
||||||
|
last_err = e
|
||||||
|
if attempt == max_retries - 1:
|
||||||
|
break
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
raise last_err # type: ignore[misc]
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
"""主 agent loop: ReAct 风格,LLM ↔ Tool 反复直到无 tool_call。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from .capabilities import ModelCapabilities
|
||||||
|
from .llm import LLM
|
||||||
|
from .session import Session
|
||||||
|
|
||||||
|
|
||||||
|
class AgentLoop:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
llm: LLM,
|
||||||
|
tools: Dict[str, Any],
|
||||||
|
session: Session,
|
||||||
|
capabilities: ModelCapabilities,
|
||||||
|
console: Optional[Console] = None,
|
||||||
|
max_iterations: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
|
self.llm = llm
|
||||||
|
self.tools = tools
|
||||||
|
self.session = session
|
||||||
|
self.caps = capabilities
|
||||||
|
self.max_iterations = max_iterations or capabilities.max_iterations
|
||||||
|
self.console = console or Console()
|
||||||
|
|
||||||
|
def run(self, user_message: str) -> str:
|
||||||
|
self.session.append({"role": "user", "content": user_message})
|
||||||
|
|
||||||
|
for _ in range(self.max_iterations):
|
||||||
|
with self.console.status("[dim]thinking...[/dim]", spinner="dots"):
|
||||||
|
response = self.llm.chat(
|
||||||
|
messages=self.session.messages,
|
||||||
|
tools=[t.schema for t in self.tools.values()],
|
||||||
|
reasoning_effort=self.caps.default_reasoning_effort or None,
|
||||||
|
)
|
||||||
|
msg = response.choices[0].message
|
||||||
|
self.session.append(msg)
|
||||||
|
|
||||||
|
tool_calls = getattr(msg, "tool_calls", None) or []
|
||||||
|
content = getattr(msg, "content", None)
|
||||||
|
if content:
|
||||||
|
self.console.print(f"[cyan]assistant>[/cyan] {content}")
|
||||||
|
|
||||||
|
if not tool_calls:
|
||||||
|
return content or ""
|
||||||
|
|
||||||
|
for tc in tool_calls:
|
||||||
|
result = self._execute_tool_call(tc)
|
||||||
|
self.session.append(
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tc.id,
|
||||||
|
"content": result,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return "[reached max iterations]"
|
||||||
|
|
||||||
|
def _execute_tool_call(self, tc: Any) -> str:
|
||||||
|
name = tc.function.name
|
||||||
|
raw_args = tc.function.arguments or "{}"
|
||||||
|
try:
|
||||||
|
args = json.loads(raw_args)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
return f"[Error] invalid JSON arguments for {name}: {e}"
|
||||||
|
|
||||||
|
preview = json.dumps(args, ensure_ascii=False)
|
||||||
|
if len(preview) > 200:
|
||||||
|
preview = preview[:200] + "..."
|
||||||
|
self.console.print(f"[yellow]tool>[/yellow] {name}({preview})")
|
||||||
|
|
||||||
|
tool = self.tools.get(name)
|
||||||
|
if tool is None:
|
||||||
|
return f"[Error] unknown tool: {name}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = tool.execute(**args)
|
||||||
|
except TypeError as e:
|
||||||
|
return f"[Error] bad arguments to {name}: {e}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"[Error executing {name}] {type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
if not isinstance(result, str):
|
||||||
|
result = str(result)
|
||||||
|
|
||||||
|
# 控制返回给模型的 tool 结果体量,避免炸 context
|
||||||
|
MAX_LEN = 16_000
|
||||||
|
if len(result) > MAX_LEN:
|
||||||
|
result = result[:MAX_LEN] + f"\n[... truncated, {len(result) - MAX_LEN} chars ...]"
|
||||||
|
|
||||||
|
# 给用户预览(截短)
|
||||||
|
preview = result if len(result) < 400 else result[:400] + "..."
|
||||||
|
self.console.print(f"[dim]{preview}[/dim]")
|
||||||
|
return result
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
"""会话: 内存中的消息列表 + meta(cwd / model / created_at) + 落盘 json。
|
||||||
|
|
||||||
|
文件格式:
|
||||||
|
{
|
||||||
|
"meta": {"id": "...", "created_at": "...", "cwd": "...", "model": "..."},
|
||||||
|
"messages": [...]
|
||||||
|
}
|
||||||
|
|
||||||
|
兼容老格式: 如果文件根是 list,就当 messages 处理,meta 为空。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _to_dict(msg: Any) -> Any:
|
||||||
|
if isinstance(msg, dict):
|
||||||
|
return msg
|
||||||
|
if hasattr(msg, "model_dump"):
|
||||||
|
return msg.model_dump(exclude_none=True)
|
||||||
|
if hasattr(msg, "dict"):
|
||||||
|
return msg.dict(exclude_none=True)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
system_prompt: str = "",
|
||||||
|
path: Optional[Path] = None,
|
||||||
|
meta: Optional[dict] = None,
|
||||||
|
) -> None:
|
||||||
|
self.messages: List[dict] = []
|
||||||
|
self.path = path
|
||||||
|
self.meta: Dict[str, Any] = dict(meta or {})
|
||||||
|
if system_prompt:
|
||||||
|
self.messages.append({"role": "system", "content": system_prompt})
|
||||||
|
|
||||||
|
def append(self, msg: Any) -> None:
|
||||||
|
self.messages.append(_to_dict(msg))
|
||||||
|
if self.path is not None:
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def reset(self, keep_system: bool = True) -> None:
|
||||||
|
if keep_system and self.messages and self.messages[0].get("role") == "system":
|
||||||
|
self.messages = [self.messages[0]]
|
||||||
|
else:
|
||||||
|
self.messages = []
|
||||||
|
if self.path is not None:
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
if self.path is None:
|
||||||
|
return
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = {"meta": self.meta, "messages": self.messages}
|
||||||
|
self.path.write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: Path) -> "Session":
|
||||||
|
s = cls(path=path)
|
||||||
|
if not path.exists():
|
||||||
|
return s
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(data, list):
|
||||||
|
# 老格式: 纯消息列表
|
||||||
|
s.messages = data
|
||||||
|
s.meta = {}
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
s.messages = data.get("messages", []) or []
|
||||||
|
s.meta = data.get("meta", {}) or {}
|
||||||
|
return s
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
"""Skill 注册表 (Anthropic 标准格式)。
|
||||||
|
|
||||||
|
每个 skill 是 skills/<name>/ 目录,内含 SKILL.md(带 frontmatter)+ 可选的
|
||||||
|
references/、scripts/、assets/。启动时只读 frontmatter 做 discovery,完整 SKILL.md
|
||||||
|
和 references 由 agent 按需加载(渐进披露)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional, Tuple
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
_FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?", re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_frontmatter(text: str) -> Tuple[dict, str]:
|
||||||
|
"""解析 markdown 顶部的 YAML frontmatter。返回 (meta, body)。"""
|
||||||
|
m = _FRONTMATTER_RE.match(text)
|
||||||
|
if not m:
|
||||||
|
return {}, text
|
||||||
|
meta = yaml.safe_load(m.group(1)) or {}
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
meta = {}
|
||||||
|
return meta, text[m.end():]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Skill:
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
skill_dir: Path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def skill_md(self) -> Path:
|
||||||
|
return self.skill_dir / "SKILL.md"
|
||||||
|
|
||||||
|
def full_content(self) -> str:
|
||||||
|
return self.skill_md.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dir(cls, skill_dir: Path) -> Optional["Skill"]:
|
||||||
|
md = skill_dir / "SKILL.md"
|
||||||
|
if not md.exists():
|
||||||
|
return None
|
||||||
|
meta, _ = parse_frontmatter(md.read_text(encoding="utf-8"))
|
||||||
|
name = meta.get("name") or skill_dir.name
|
||||||
|
desc = meta.get("description") or ""
|
||||||
|
if not desc:
|
||||||
|
return None # description 是 discovery 的关键,缺了不收
|
||||||
|
return cls(name=name, description=desc, skill_dir=skill_dir)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillRegistry:
|
||||||
|
def __init__(self, skills_dir: Path) -> None:
|
||||||
|
self.skills_dir = Path(skills_dir)
|
||||||
|
self.skills: Dict[str, Skill] = {}
|
||||||
|
self._scan()
|
||||||
|
|
||||||
|
def _scan(self) -> None:
|
||||||
|
if not self.skills_dir.exists():
|
||||||
|
return
|
||||||
|
for child in sorted(self.skills_dir.iterdir()):
|
||||||
|
if not child.is_dir():
|
||||||
|
continue
|
||||||
|
skill = Skill.from_dir(child)
|
||||||
|
if skill is not None:
|
||||||
|
self.skills[skill.name] = skill
|
||||||
|
|
||||||
|
def discovery_block(self) -> str:
|
||||||
|
"""启动时注入 system prompt 的 skill 列表(name + description)。"""
|
||||||
|
if not self.skills:
|
||||||
|
return ""
|
||||||
|
lines = [f"- **{s.name}**: {s.description}" for s in self.skills.values()]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def get(self, name: str) -> Optional[Skill]:
|
||||||
|
return self.skills.get(name)
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
"""装配入口: 读 config → 加载 capabilities/skills → 构造 LLM/tools/session/loop。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from core.capabilities import ModelCapabilities
|
||||||
|
from core.llm import LLM
|
||||||
|
from core.loop import AgentLoop
|
||||||
|
from core.session import Session
|
||||||
|
from core.skills import SkillRegistry
|
||||||
|
from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool
|
||||||
|
from tools.run_python import RunPythonTool
|
||||||
|
from tools.shell import ShellTool
|
||||||
|
from tools.skill_tool import LoadSkillTool
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> dict:
|
||||||
|
return yaml.safe_load((ROOT / "config" / "agent.yaml").read_text(encoding="utf-8")) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_workspace(workspace: Optional[str], cfg: Optional[dict] = None) -> Path:
|
||||||
|
cfg = cfg or load_config()
|
||||||
|
p = Path(workspace) if workspace else ROOT / cfg.get("workspace_dir", "workspace")
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def sessions_dir(workspace_dir: Path) -> Path:
|
||||||
|
d = workspace_dir / "sessions"
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_session_path(workspace_dir: Path, session_id: Optional[str], resume: bool) -> Tuple[Path, str]:
|
||||||
|
"""返回 (path, session_id)。resume=True 时找现有文件,否则新建一个时间戳 id。"""
|
||||||
|
sdir = sessions_dir(workspace_dir)
|
||||||
|
if resume:
|
||||||
|
if session_id in (None, "", "last"):
|
||||||
|
existing = sorted(sdir.glob("*.json"))
|
||||||
|
if not existing:
|
||||||
|
raise FileNotFoundError(f"{sdir} 下没有任何 session 可恢复")
|
||||||
|
path = existing[-1]
|
||||||
|
return path, path.stem
|
||||||
|
path = sdir / f"{session_id}.json"
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(f"session 不存在: {path}")
|
||||||
|
return path, session_id # type: ignore[return-value]
|
||||||
|
sid = session_id or datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
return sdir / f"{sid}.json", sid
|
||||||
|
|
||||||
|
|
||||||
|
def build_agent(
|
||||||
|
model_name: Optional[str] = None,
|
||||||
|
workspace: Optional[str] = None,
|
||||||
|
console: Optional[Console] = None,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
resume: bool = False,
|
||||||
|
) -> Tuple[AgentLoop, Session, str]:
|
||||||
|
cfg = load_config()
|
||||||
|
model = model_name or cfg["default_model"]
|
||||||
|
|
||||||
|
caps = ModelCapabilities.load(model, ROOT / cfg["models_dir"])
|
||||||
|
llm = LLM(caps)
|
||||||
|
|
||||||
|
workspace_dir = resolve_workspace(workspace, cfg)
|
||||||
|
session_path, sid = resolve_session_path(workspace_dir, session_id, resume)
|
||||||
|
|
||||||
|
# 工具基目录: 用户当前 cwd —— agent 操作的是用户项目,不是 zcbot 仓库本身
|
||||||
|
tool_base = Path.cwd()
|
||||||
|
|
||||||
|
skills = SkillRegistry(ROOT / cfg.get("skills_dir", "skills"))
|
||||||
|
|
||||||
|
if resume:
|
||||||
|
# 恢复: 直接加载老 session,不再注入新的 system prompt
|
||||||
|
session = Session.load(session_path)
|
||||||
|
saved_cwd = session.meta.get("cwd")
|
||||||
|
if saved_cwd and console is not None and saved_cwd != str(tool_base):
|
||||||
|
console.print(
|
||||||
|
f"[yellow]提示:[/yellow] 当前 cwd 与 session 记录不同 —— "
|
||||||
|
f"工具基于 current cwd,不会自动切回。\n"
|
||||||
|
f" session cwd: [dim]{saved_cwd}[/dim]\n"
|
||||||
|
f" current cwd: [dim]{tool_base}[/dim]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
system_prompt = (ROOT / cfg["system_prompt"]).read_text(encoding="utf-8")
|
||||||
|
if skills.skills:
|
||||||
|
system_prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}"
|
||||||
|
system_prompt += f"\n\n## 当前工作目录\n{tool_base}"
|
||||||
|
meta = {
|
||||||
|
"id": sid,
|
||||||
|
"created_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"cwd": str(tool_base),
|
||||||
|
"model": caps.model_id,
|
||||||
|
"model_profile": model,
|
||||||
|
}
|
||||||
|
session = Session(system_prompt=system_prompt, path=session_path, meta=meta)
|
||||||
|
session.save() # 立刻落盘,占住文件名
|
||||||
|
|
||||||
|
tools = {}
|
||||||
|
for cls in (ReadTool, WriteTool, EditTool, GlobTool, GrepTool, ShellTool):
|
||||||
|
t = cls(base_dir=tool_base)
|
||||||
|
tools[t.name] = t
|
||||||
|
|
||||||
|
if skills.skills:
|
||||||
|
ls = LoadSkillTool(registry=skills, base_dir=tool_base)
|
||||||
|
tools[ls.name] = ls
|
||||||
|
|
||||||
|
if caps.enable_run_python:
|
||||||
|
rp = RunPythonTool(base_dir=tool_base)
|
||||||
|
tools[rp.name] = rp
|
||||||
|
|
||||||
|
agent = AgentLoop(llm, tools, session, caps, console=console)
|
||||||
|
return agent, session, sid
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
你是一个本地任务 agent,帮用户完成软件工程、文档撰写、内容生成类任务。
|
||||||
|
|
||||||
|
## 通用工具
|
||||||
|
- `read` / `write` / `edit` —— 文件操作
|
||||||
|
- `glob` / `grep` —— 文件搜索
|
||||||
|
- `shell` —— 执行命令(默认 60s 超时)
|
||||||
|
- `run_python` —— 在子进程里跑 Python (数据处理、生成 .pptx/.docx、画图等)
|
||||||
|
- `load_skill` —— 加载某个 skill 的完整指引
|
||||||
|
|
||||||
|
## Skill 机制
|
||||||
|
你启动时只看到下方 skill 的"名字 + 描述"。当用户的任务匹配某个 skill 的领域,
|
||||||
|
**先 `load_skill(name)`** 拿到完整指引(工作流、模板、原则),再开始干活。
|
||||||
|
|
||||||
|
不要凭印象推测一个 skill 怎么用 —— 永远 load 一下。skill 数有限,加载成本很低。
|
||||||
|
|
||||||
|
## 工作原则
|
||||||
|
- 动手前先看: 用 read/grep/glob 摸清现状,再 edit
|
||||||
|
- 改动最小化: edit 工具的 old_str 必须唯一匹配,不够唯一就多带上下文
|
||||||
|
- 有测试就跑测试验证;没有就用 run_python 写一段最小复现验证
|
||||||
|
- 输出简洁: 不复述 diff,只说做了什么、下一步要不要继续
|
||||||
|
- 工具结果带 `[Error ...]` 时,先想清楚原因再重试,不要盲目重复同一调用
|
||||||
|
- 不臆造 API、文献、数据 —— 不知道就 read 源码 / 让用户提供 / 明说不知道
|
||||||
|
|
||||||
|
## 路径
|
||||||
|
默认工作目录在系统消息末尾,所有相对路径基于该目录。
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
litellm>=1.50.0
|
||||||
|
pyyaml>=6.0
|
||||||
|
click>=8.1.0
|
||||||
|
rich>=13.7.0
|
||||||
|
|
||||||
|
# 文档生成 (run_python 在 ppt / proposal skill 里会用到)
|
||||||
|
python-pptx>=0.6.21
|
||||||
|
python-docx>=1.1.0
|
||||||
|
matplotlib>=3.8.0
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
name: coding
|
||||||
|
description: 修改、调试、实现代码相关任务。当用户要求修 bug、写函数、重构、读懂代码库、跑测试时使用。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Coding
|
||||||
|
|
||||||
|
## 资源
|
||||||
|
- 通用工具就够: read / grep / glob / edit / write / shell / run_python
|
||||||
|
- 没有专属 scripts 或 templates,因为代码任务的多样性来自代码本身
|
||||||
|
|
||||||
|
## 原则
|
||||||
|
- **先看后改**: 用 grep/glob 定位,read 读出修改点的上下文,再 edit。盲改会出错。
|
||||||
|
- **改动最小**: 只动必要行,不顺手重构、不改无关空白
|
||||||
|
- **edit 唯一匹配**: old_str 必须在文件里出现且仅出现一次,不够唯一就多带上下文
|
||||||
|
- **验证优先**: 项目有测试就 `shell pytest` / `npm test`;没有就写最小复现脚本验证
|
||||||
|
- **不臆造 API**: 看到没用过的库,先 read 它的源码或文档,不要凭直觉拼方法名
|
||||||
|
|
||||||
|
## 输出
|
||||||
|
- 改完后一两句话说清: 改了什么、为什么、怎么验证
|
||||||
|
- 不复述 diff —— 用户会自己看
|
||||||
|
|
||||||
|
## 反模式
|
||||||
|
- 一次性 write 整个大文件 (改 3 行就别 write 200 行)
|
||||||
|
- 没读过文件直接 edit (大概率 old_str 匹配不上)
|
||||||
|
- 跑测试失败就立刻改测试 (先看是测试错还是代码错)
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
---
|
||||||
|
name: ppt
|
||||||
|
description: 生成 PowerPoint 演示文稿 (.pptx)。当用户要求做汇报 PPT、把材料/会议纪要/方案转为幻灯片、生成演示稿时使用。
|
||||||
|
---
|
||||||
|
|
||||||
|
# PPT
|
||||||
|
|
||||||
|
把材料变成可演示的 .pptx。**先定调,再出稿,再验收** —— 不要一口气把整份 deck 丢出去。
|
||||||
|
|
||||||
|
## 资源
|
||||||
|
- `references/design_principles.md` —— 字号/颜色/层级/留白/字数预算等硬规则,出稿前先翻一遍
|
||||||
|
- `references/canvas_presets.md` —— 16:9 / 4:3 / 9:16 / A4 等画布尺寸表
|
||||||
|
- `references/layouts.md` —— 9 种常用版式的 python-pptx 起手代码 + safe area 辅助 (封面/目录/分章/要点/双栏/图表/图片/金句/结尾)
|
||||||
|
- `references/icons.md` —— MSO_SHAPE 图标目录 + unicode 字形表 (替代大色块的轻量装饰)
|
||||||
|
- `scripts/source_to_md.py` —— 可执行,把 PDF/DOCX/PPTX/URL 转成干净 Markdown 再做素材
|
||||||
|
- `scripts/render_icon.py` —— 可执行,unicode 字形 → 透明 PNG (MSO_SHAPE 覆盖不到时兜底)
|
||||||
|
- `scripts/quality_check.py` —— 可执行,产物 .pptx 出来后跑一遍验收 (含越界 / 文本溢出检测)
|
||||||
|
|
||||||
|
## 默认主题
|
||||||
|
**商务红** —— PRIMARY `#C00000` / SECONDARY `#E15554` / ACCENT `#FFC107`。除非 spec_lock 指定其它配色,layouts.md 起手代码就用这套。其它备选见 `design_principles.md` §2。
|
||||||
|
|
||||||
|
## 两阶段工作流
|
||||||
|
|
||||||
|
### 阶段一: 策略 (Strategist)
|
||||||
|
产物:`spec_lock.md` —— 整个 deck 的"宪法",执行阶段每生成一页前都要重读。
|
||||||
|
|
||||||
|
**八条对齐**(不全部确认完,不开工):
|
||||||
|
1. **画布**: 16:9 / 4:3 / 9:16 (默认 16:9,见 canvas_presets.md)
|
||||||
|
2. **页数**: 默认 5-8 页;长报告再加,但每超 1 页就要问一次"这页非加不可吗"
|
||||||
|
3. **受众**: 领导汇报 / 同行评审 / 大众科普 / 客户 pitch —— 决定信息密度和措辞
|
||||||
|
4. **风格**: 商务正式 / 学术严谨 / 现代简约 / 极简留白 (默认现代简约)
|
||||||
|
5. **配色**: 主色 + 辅色 + 强调色,三色封顶。给具体 hex,不要"蓝色系"这种话
|
||||||
|
6. **字体**: 中文标题/正文,英文标题/正文。Win 默认 微软雅黑 + Arial
|
||||||
|
7. **图标/插图**: 是否要、风格 (线性/扁平/拟物)、来源 (用户提供 / 不用)
|
||||||
|
8. **图表**: 数据 ≥ 3 个点的页面默认配图;明确哪几页要图
|
||||||
|
|
||||||
|
写入 `spec_lock.md` 后给用户看一眼再继续。**spec_lock 写定后不要再改**,有冲突回头跟用户重新对齐。
|
||||||
|
|
||||||
|
### 阶段二: 执行 (Executor)
|
||||||
|
**逐页生成**,不是一次性 dump 全 deck。每页前先读一次 `spec_lock.md`,然后:
|
||||||
|
|
||||||
|
1. 写一个 `run_python` block,用 python-pptx 添加这一页 (载入已有 .pptx,append slide,save)
|
||||||
|
2. 跑完报这一页的:版式、标题、要点条数、是否含图
|
||||||
|
3. 用户确认 / 微调后再下一页
|
||||||
|
|
||||||
|
**为什么逐页?** 一次性出全 deck 很容易越到后面越糊。逐页能让用户在第 2 页就发现风格不对,而不是看完 8 页才推翻重来。
|
||||||
|
|
||||||
|
**例外**: 用户明确说 "你别问,直接全做了" —— 那就一次跑完,但跑完后必须用 `quality_check.py` 验收。
|
||||||
|
|
||||||
|
### 阶段三: 验收
|
||||||
|
- `python scripts/quality_check.py <output.pptx>` —— 检页数/标题/bullet 条数/文件大小
|
||||||
|
- 不通过的项,回头 edit 对应页
|
||||||
|
|
||||||
|
## 设计原则 (硬规则)
|
||||||
|
- **每页一个核心信息**: 一页讲一件事,塞两件就拆页
|
||||||
|
- **bullet ≤ 5 条**: 超过就拆页或改成图表/双栏
|
||||||
|
- **正文不写完整段落**: 列要点;长句留给演讲者口述
|
||||||
|
- **数据 ≥ 3 个点应有图表**: 用 matplotlib 生成 .png 嵌入
|
||||||
|
- **中文标题 ≤ 30 字** / **英文标题 ≤ 12 词**
|
||||||
|
- **配色三色封顶**: 主色 + 辅色 + 强调色,其他都用灰阶
|
||||||
|
- **少用大色块,多用细线 + 图标 + 留白**: 满铺色块只在封面/分章/结尾克制使用
|
||||||
|
- **图标走 MSO_SHAPE**: 原生形状可编辑、可缩放;复杂图标走 `render_icon.py`
|
||||||
|
- **Shape 不能越界**: `layouts.md` 的起手代码用 `assert_inside` 在生成时即报错;最终必跑 `quality_check.py`
|
||||||
|
- **字数按预算来**: 写 bullet 前查 `design_principles.md §4.1` 的字数预算表,溢出靠拆条不靠收缩字号
|
||||||
|
- 详细规则见 `references/design_principles.md`
|
||||||
|
|
||||||
|
## 工作目录约定
|
||||||
|
```
|
||||||
|
<task_dir>/
|
||||||
|
├── source.md # 阶段一: source_to_md.py 转出的素材
|
||||||
|
├── spec_lock.md # 阶段一: 八条对齐落定
|
||||||
|
├── slides/
|
||||||
|
│ └── chart_p3.png # 各页用到的图片素材
|
||||||
|
└── <topic>.pptx # 最终产物 (文件名按主题命名,不要 untitled.pptx)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 反模式
|
||||||
|
- 用户没给材料就开始硬编内容
|
||||||
|
- 八条没对齐就跑 python-pptx
|
||||||
|
- 一个 `run_python` 出整 deck (中途改方向就要全推翻)
|
||||||
|
- 跑完不做 `quality_check.py` 就交付
|
||||||
|
- 起名 `output.pptx` / `untitled.pptx` —— 务必按主题给文件名
|
||||||
|
- 文字塞满整张幻灯片 —— 留白本身是设计
|
||||||
|
|
||||||
|
## 输出
|
||||||
|
完成后告诉用户:文件路径、页数、用到的版式列表、是否有未满足的 spec 项。问一句要不要再改。
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
# 画布尺寸预设
|
||||||
|
|
||||||
|
> 阶段一选画布时查这张表。**画布定了之后所有版式按这个尺寸算坐标**,不要中途改。
|
||||||
|
|
||||||
|
## 标准尺寸表
|
||||||
|
|
||||||
|
| 用途 | 比例 | 宽×高 (英寸) | python-pptx | 说明 |
|
||||||
|
|-----|------|------------|------------|------|
|
||||||
|
| **现代商务汇报** | 16:9 | 13.33 × 7.5 | `Inches(13.33), Inches(7.5)` | **默认选这个** |
|
||||||
|
| 老投影仪 | 4:3 | 10 × 7.5 | `Inches(10), Inches(7.5)` | 老会议室、教学场景 |
|
||||||
|
| 竖屏手机 / 朋友圈 | 9:16 | 7.5 × 13.33 | `Inches(7.5), Inches(13.33)` | 移动端阅读、视频号封面 |
|
||||||
|
| 小红书 | 3:4 | 7.5 × 10 | `Inches(7.5), Inches(10)` | 单图阅读 |
|
||||||
|
| 微信公众号长图 | 1:n | 7.5 × 7.5 起 | `Inches(7.5), Inches(7.5)` | 单页或拼接 |
|
||||||
|
| 海报 (A4 横) | √2:1 | 11.69 × 8.27 | `Inches(11.69), Inches(8.27)` | 打印 |
|
||||||
|
| 海报 (A4 竖) | 1:√2 | 8.27 × 11.69 | `Inches(8.27), Inches(11.69)` | 打印 |
|
||||||
|
| 大屏宣讲 | 16:9 高 dpi | 同 16:9 | 同上 | 字号上调 4-6pt |
|
||||||
|
|
||||||
|
## 选画布的几条经验
|
||||||
|
|
||||||
|
- 不知道选哪个 —— **16:9**,99% 场合通吃
|
||||||
|
- 用户在投影仪墙上看 —— 16:9
|
||||||
|
- 用户在电脑屏幕上看 —— 16:9 或 4:3
|
||||||
|
- 用户在手机上看 —— 9:16
|
||||||
|
- 用户要打印散发 —— A4 横或 A4 竖
|
||||||
|
- 用户说"做个图发朋友圈" —— 3:4 或 1:1,不是 PPT 范畴但 python-pptx 也能干
|
||||||
|
|
||||||
|
## python-pptx 画布初始化
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pptx import Presentation
|
||||||
|
from pptx.util import Inches, Pt
|
||||||
|
|
||||||
|
prs = Presentation()
|
||||||
|
# 16:9 默认
|
||||||
|
prs.slide_width = Inches(13.33)
|
||||||
|
prs.slide_height = Inches(7.5)
|
||||||
|
|
||||||
|
# 4:3 改这两行
|
||||||
|
# prs.slide_width = Inches(10)
|
||||||
|
# prs.slide_height = Inches(7.5)
|
||||||
|
|
||||||
|
# 9:16 改这两行
|
||||||
|
# prs.slide_width = Inches(7.5)
|
||||||
|
# prs.slide_height = Inches(13.33)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安全边距 (各画布通用)
|
||||||
|
|
||||||
|
- 左右边距: **画布宽 × 0.05** (16:9 即 0.67 寸)
|
||||||
|
- 上下边距: **画布高 × 0.07** (16:9 即 0.5 寸)
|
||||||
|
- 内容区域: 画布尺寸减去四周边距,所有元素都摆在这个矩形内
|
||||||
|
|
||||||
|
## 字号随画布缩放
|
||||||
|
|
||||||
|
如果画布超过 16:9 默认尺寸 (比如做 4K 大屏),**所有字号 × (实际宽 / 13.33)**。模型自己换算,不要硬抄默认表。
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
# PPT 设计硬规则
|
||||||
|
|
||||||
|
> 出稿前过一遍。**这些不是建议,是工程约束** —— 模型生成 PPT 最常见的失败模式都是违反这些规则。
|
||||||
|
|
||||||
|
## 1. 字号 (16:9 标准)
|
||||||
|
|
||||||
|
| 元素 | 字号 (Pt) | 备注 |
|
||||||
|
|-----|----------|------|
|
||||||
|
| 主标题 (封面) | 44-54 | 单行不换行 |
|
||||||
|
| 标题 (内页) | 28-36 | 中文常用 32 |
|
||||||
|
| 副标题 / 章节小标题 | 20-24 | |
|
||||||
|
| 正文 / bullet | 18-22 | 低于 18 投影看不清 |
|
||||||
|
| 注释 / 数据来源 | 12-14 | 灰色,弱化 |
|
||||||
|
| 页脚页码 | 10-12 | 弱化处理 |
|
||||||
|
|
||||||
|
**底线**: 投影到 100 寸大屏,后排看得清最小字号是 18pt。**绝不能小于 14pt**,除非是数据来源等弱化信息。
|
||||||
|
|
||||||
|
## 2. 配色
|
||||||
|
|
||||||
|
### 三色制
|
||||||
|
- **主色 (Primary)** —— 标题、强调、关键数据。占视觉权重 60%
|
||||||
|
- **辅色 (Secondary)** —— 副标题、次要图形元素。占 30%
|
||||||
|
- **强调色 (Accent)** —— 关键数据点、CTA、警告。占 10%,不要泛滥
|
||||||
|
- 其他全部用灰阶 (#1F1F1F / #555 / #888 / #CCC / #F5F5F5)
|
||||||
|
|
||||||
|
### 推荐配色对照 (红色主题为默认)
|
||||||
|
| 风格 | 主色 | 辅色 | 强调色 | 备注 |
|
||||||
|
|-----|------|------|-------|------|
|
||||||
|
| **商务红** ⭐ 默认 | #C00000 | #E15554 | #FFC107 | 党政/年终/路演通用 |
|
||||||
|
| 中国红 | #8B0000 | #B22222 | #FFD700 | 民族/国货/红色文化主题 |
|
||||||
|
| 现代红 | #B91C1C | #DC2626 | #F59E0B | 新消费/科技产品发布 |
|
||||||
|
| 暖朱红 | #C73E1D | #E76F51 | #F4A261 | 学术汇报/行业会议 |
|
||||||
|
| 商务蓝 | #1F4E79 | #2E75B6 | #FFC000 | 金融/保险/政企 |
|
||||||
|
| 学术灰 | #2F2F2F | #595959 | #C00000 | 严肃论文/答辩 |
|
||||||
|
| 现代简约 | #2D3748 | #4A5568 | #38B2AC | 互联网/SaaS |
|
||||||
|
| 科技深色 | #0A192F | #112240 | #64FFDA | 黑客松/技术大会 |
|
||||||
|
|
||||||
|
### 禁忌
|
||||||
|
- 红配绿、紫配黄等高对比互补色不要直接用
|
||||||
|
- 渐变只用在 accent 上,正文/标题不要渐变
|
||||||
|
- 一份 deck 主色不要换。封面是 A 色、内页变 B 色 —— 这是大忌
|
||||||
|
|
||||||
|
## 3. 留白
|
||||||
|
|
||||||
|
- 标题与上边距 ≥ 0.4 英寸
|
||||||
|
- bullet 之间行距 1.3-1.5 倍
|
||||||
|
- 一页内容占满 70% 即可,**不要塞到边缘**
|
||||||
|
- 边距统一 (左右 0.7 寸,上下 0.5 寸常用值)
|
||||||
|
|
||||||
|
## 4. 信息密度
|
||||||
|
|
||||||
|
| 页类型 | 字数上限 | 图表 |
|
||||||
|
|-------|---------|-----|
|
||||||
|
| 封面 | 30 字 | 可选装饰图 |
|
||||||
|
| 目录 | 每条 ≤ 15 字 | 不要图 |
|
||||||
|
| 分章页 | ≤ 20 字 | 大号数字 + 章节名 |
|
||||||
|
| 要点页 | bullet ≤ 5 条,每条 ≤ 25 字 | 可选小图标 |
|
||||||
|
| 数据页 | 标题 + 一句结论 | **必须有图表** |
|
||||||
|
| 图片页 | ≤ 15 字标题 + 1-2 行说明 | 主体是图 |
|
||||||
|
|
||||||
|
## 4.1 字数预算 (避免溢出)
|
||||||
|
|
||||||
|
> 这是**布局超界的根因表**。bullet 写超了会顶到下一页元素;标题写超了会换行顶下来。开写前查这张表,而不是写完看 quality_check 报错。
|
||||||
|
|
||||||
|
公式: `每行字数 ≈ 框宽(in) × 72 / 字号(pt)`
|
||||||
|
|
||||||
|
| 字号 | 框宽 11.93 in (整宽) | 框宽 5.5 in (双栏单边) | 框宽 4.6 in (图片页文字区) |
|
||||||
|
|-----|--------------------|----------------------|--------------------------|
|
||||||
|
| 44 pt (主标题) | ≤ 19 字 | — | — |
|
||||||
|
| 36 pt (大标题) | ≤ 23 字 | — | — |
|
||||||
|
| 32 pt (内页标题) | ≤ 26 字 | — | — |
|
||||||
|
| 22 pt (要点) | ≤ 39 字 | ≤ 18 字 | ≤ 15 字 |
|
||||||
|
| 18 pt (正文) | ≤ 47 字 | ≤ 22 字 | ≤ 18 字 |
|
||||||
|
| 14 pt (注释) | ≤ 61 字 | ≤ 28 字 | ≤ 23 字 |
|
||||||
|
|
||||||
|
**英文字符按中文 0.5 个换算** (即英文每行约 2× 中文字数)。
|
||||||
|
|
||||||
|
### 行高估算
|
||||||
|
|
||||||
|
每行高度 ≈ `字号 × 1.4 / 72` (英寸)
|
||||||
|
|
||||||
|
| 字号 | 单行高 | 1 行框高 | 2 行框高 | 3 行框高 |
|
||||||
|
|-----|-------|---------|---------|---------|
|
||||||
|
| 32 pt | 0.62 in | 0.7 in | 1.3 in | 1.9 in |
|
||||||
|
| 22 pt | 0.43 in | 0.5 in | 0.9 in | 1.3 in |
|
||||||
|
| 18 pt | 0.35 in | 0.4 in | 0.8 in | 1.1 in |
|
||||||
|
| 14 pt | 0.27 in | 0.3 in | 0.6 in | 0.9 in |
|
||||||
|
|
||||||
|
**用法**: bullet 字数预计超表上限就拆条,不要试图靠 `auto_size` 收缩字号兜底 —— 会出现一页里字号大小不一,反而难看。
|
||||||
|
|
||||||
|
## 5. 文字层级
|
||||||
|
|
||||||
|
- 一页最多 3 级层级 (标题 / 正文 / 子项)
|
||||||
|
- 子项缩进 0.3-0.5 英寸
|
||||||
|
- 子项字号比父级小 2-4pt
|
||||||
|
- 不要四级以上嵌套
|
||||||
|
|
||||||
|
## 6. 图片规则
|
||||||
|
|
||||||
|
- **分辨率**: 投影建议 150 dpi 以上,印刷 300 dpi
|
||||||
|
- **占位**: 图片占满指定区域,不要拉伸变形 —— 用 `width=` 或 `height=` 单一参数让 python-pptx 等比缩放
|
||||||
|
- **背景**: 透明 PNG 优先;白底 JPG 在深色页上要做底色匹配
|
||||||
|
- **数量**: 一页最多 2 张图,3 张以上是网格图,按九宫格摆
|
||||||
|
|
||||||
|
## 7. 图表规则 (matplotlib)
|
||||||
|
|
||||||
|
- 颜色用 spec_lock 里定的主/辅/强调三色,**不要用 matplotlib 默认色板**
|
||||||
|
- 字号: 标题 16,坐标轴 12,刻度 10
|
||||||
|
- 去掉上方和右方边框 (`ax.spines['top'/'right'].set_visible(False)`)
|
||||||
|
- 数据标签直接标在柱子/点上,优先于看坐标
|
||||||
|
- 中文字体: `plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei']`
|
||||||
|
- 负号: `plt.rcParams['axes.unicode_minus'] = False`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 示例:符合规则的柱状图 (默认红色主题)
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei']
|
||||||
|
plt.rcParams['axes.unicode_minus'] = False
|
||||||
|
fig, ax = plt.subplots(figsize=(10, 5), dpi=150)
|
||||||
|
bars = ax.bar(["Q1","Q2","Q3","Q4"], [12,18,25,31],
|
||||||
|
color=["#C00000","#C00000","#C00000","#FFC107"]) # 末尾突出
|
||||||
|
for bar, v in zip(bars, [12,18,25,31]):
|
||||||
|
ax.text(bar.get_x()+bar.get_width()/2, v+0.5, str(v),
|
||||||
|
ha='center', fontsize=11)
|
||||||
|
ax.set_title("季度营收 (亿元)", fontsize=16)
|
||||||
|
ax.spines['top'].set_visible(False)
|
||||||
|
ax.spines['right'].set_visible(False)
|
||||||
|
fig.savefig("chart.png", bbox_inches="tight", dpi=150)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 一致性 (跨页)
|
||||||
|
|
||||||
|
- 标题位置不要跳来跳去 —— 所有内页标题都在同一像素位置
|
||||||
|
- 页脚 (页码 / logo / 标题) 在所有内页位置一致
|
||||||
|
- 字体在同 deck 内不要换 —— 中文一种字体,英文一种,够了
|
||||||
|
- 配色不变,字号梯度不变
|
||||||
|
|
||||||
|
## 9. 反模式速查
|
||||||
|
|
||||||
|
| 症状 | 原因 | 修法 |
|
||||||
|
|-----|------|-----|
|
||||||
|
| 一页字密密麻麻 | 没拆页 | 拆 2-3 页或转图表 |
|
||||||
|
| 投影看不清 | 字号 < 18 | 加大字号或拆页 |
|
||||||
|
| 颜色花 | 用了超过 5 种色 | 退回三色制 |
|
||||||
|
| bullet 是完整段落 | 把演讲稿当 bullet 写 | 提炼关键词,完整句留给口述 |
|
||||||
|
| 图表默认配色 | 没改 matplotlib 色板 | 用 spec_lock 主色 |
|
||||||
|
| 图标/图片随意找的 | 没统一风格 | 同一来源 / 同一风格 |
|
||||||
|
| 标题在每页位置都不一样 | 没用统一版式 | 见 layouts.md,固定模板 |
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
# 图标系统
|
||||||
|
|
||||||
|
> **首选 `MSO_SHAPE.*` —— PowerPoint 原生形状,矢量、可编辑、配色随主题。** 复杂图标(齿轮、放大镜、文件夹等无对应 MSO_SHAPE)再走 `render_icon.py` 用 unicode 字形栅格化为 PNG。
|
||||||
|
|
||||||
|
## A. MSO_SHAPE 图标目录
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pptx.enum.shapes import MSO_SHAPE
|
||||||
|
```
|
||||||
|
|
||||||
|
### 标记类 (放在 bullet 前 / 标题旁)
|
||||||
|
|
||||||
|
| 用途 | MSO_SHAPE | 说明 |
|
||||||
|
|-----|-----------|------|
|
||||||
|
| 圆点 bullet | `OVAL` | 0.18×0.18 in,实心填充 |
|
||||||
|
| 方点 bullet | `RECTANGLE` | 0.16×0.16 in,实心 |
|
||||||
|
| 钻石点 | `DIAMOND` | 0.2×0.2 in |
|
||||||
|
| 对号 ✓ | `CHEVRON` 旋转 / 或用字形 | MSO 没有专门"check"形;用字形更清晰 |
|
||||||
|
| 加号 + | `MATH_PLUS` | 强调"新增"语境 |
|
||||||
|
| 星 ★ | `STAR_5_POINT` | 重点项;不要每页都用 |
|
||||||
|
| 心 | `HEART` | 用户向 / 软话题 |
|
||||||
|
|
||||||
|
### 箭头类 (流程 / 趋势)
|
||||||
|
|
||||||
|
| 用途 | MSO_SHAPE | 说明 |
|
||||||
|
|-----|-----------|------|
|
||||||
|
| 右箭头 → | `RIGHT_ARROW` | 流程下一步 |
|
||||||
|
| 上箭头 ↑ | `UP_ARROW` | 增长 |
|
||||||
|
| 下箭头 ↓ | `DOWN_ARROW` | 下降 |
|
||||||
|
| 双向箭头 ↔ | `LEFT_RIGHT_ARROW` | 对比 / 关联 |
|
||||||
|
| 折线右箭头 | `BENT_ARROW` / `CURVED_RIGHT_ARROW` | 转折 |
|
||||||
|
| 五边形流程 | `PENTAGON` | 流程节点(横排) |
|
||||||
|
| V 形 | `CHEVRON` | 流程节点(空间紧) |
|
||||||
|
|
||||||
|
### 几何/装饰
|
||||||
|
|
||||||
|
| 用途 | MSO_SHAPE | 说明 |
|
||||||
|
|-----|-----------|------|
|
||||||
|
| 圆形头像底 | `OVAL` | 头像/数字徽章 |
|
||||||
|
| 圆角矩形 | `ROUNDED_RECTANGLE` | 标签 / 按钮态 |
|
||||||
|
| 标注气泡 | `ROUNDED_RECTANGULAR_CALLOUT` | 引述 |
|
||||||
|
| 雷电 | `LIGHTNING_BOLT` | 突破 / 创新 |
|
||||||
|
| 太阳 | `SUN` | 机会 / 启示 |
|
||||||
|
| 月亮 | `MOON` | 夜晚 / 安静主题 |
|
||||||
|
| 云 | `CLOUD` | SaaS / 网络主题 |
|
||||||
|
| 禁止 | `NO_SYMBOL` | 反模式 / 禁止 |
|
||||||
|
| 笑脸 | `SMILEY_FACE` | 用户满意 |
|
||||||
|
|
||||||
|
### 引用/装饰
|
||||||
|
|
||||||
|
| 用途 | MSO_SHAPE | 说明 |
|
||||||
|
|-----|-----------|------|
|
||||||
|
| 大引号 | 字形 `"` 或 `LEFT_BRACE` | 金句页常用 |
|
||||||
|
| 横线分隔 | `RECTANGLE` 高 0.04 in | 标题下装饰线 |
|
||||||
|
| 竖线分隔 | `RECTANGLE` 宽 0.04 in | 双栏中线 |
|
||||||
|
| 三点 ⋯ | `OVAL` × 3 | 加载 / 进行中 |
|
||||||
|
|
||||||
|
## B. 标准用法
|
||||||
|
|
||||||
|
### B1. 圆点 bullet
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pptx.enum.shapes import MSO_SHAPE
|
||||||
|
from pptx.util import Inches
|
||||||
|
|
||||||
|
def add_dot(slide, x, y, size=0.18, color=ACCENT):
|
||||||
|
dot = slide.shapes.add_shape(MSO_SHAPE.OVAL,
|
||||||
|
Inches(x), Inches(y),
|
||||||
|
Inches(size), Inches(size))
|
||||||
|
dot.fill.solid(); dot.fill.fore_color.rgb = color
|
||||||
|
dot.line.fill.background()
|
||||||
|
return dot
|
||||||
|
```
|
||||||
|
|
||||||
|
### B2. 编号徽章 (圆 + 数字)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def add_badge(slide, x, y, num, diameter=0.7,
|
||||||
|
fill=PRIMARY, fg=RGBColor(255,255,255)):
|
||||||
|
circle = slide.shapes.add_shape(MSO_SHAPE.OVAL,
|
||||||
|
Inches(x), Inches(y),
|
||||||
|
Inches(diameter), Inches(diameter))
|
||||||
|
circle.fill.solid(); circle.fill.fore_color.rgb = fill
|
||||||
|
circle.line.fill.background()
|
||||||
|
tf = circle.text_frame
|
||||||
|
tf.text = str(num)
|
||||||
|
p = tf.paragraphs[0]
|
||||||
|
p.alignment = PP_ALIGN.CENTER
|
||||||
|
r = p.runs[0]
|
||||||
|
r.font.bold = True
|
||||||
|
r.font.size = Pt(20)
|
||||||
|
r.font.color.rgb = fg
|
||||||
|
r.font.name = "Arial"
|
||||||
|
return circle
|
||||||
|
```
|
||||||
|
|
||||||
|
### B3. 流程节点 (五边形)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def add_pentagon(slide, x, y, w, h, text, fill=PRIMARY):
|
||||||
|
shp = slide.shapes.add_shape(MSO_SHAPE.PENTAGON,
|
||||||
|
Inches(x), Inches(y),
|
||||||
|
Inches(w), Inches(h))
|
||||||
|
shp.fill.solid(); shp.fill.fore_color.rgb = fill
|
||||||
|
shp.line.fill.background()
|
||||||
|
tf = shp.text_frame
|
||||||
|
tf.text = text
|
||||||
|
p = tf.paragraphs[0]; p.alignment = PP_ALIGN.CENTER
|
||||||
|
r = p.runs[0]; r.font.size = Pt(14); r.font.bold = True
|
||||||
|
r.font.color.rgb = RGBColor(255,255,255); r.font.name = "微软雅黑"
|
||||||
|
return shp
|
||||||
|
|
||||||
|
# 用法:水平排五个节点
|
||||||
|
for i, label in enumerate(["调研","设计","开发","测试","上线"]):
|
||||||
|
add_pentagon(slide, 0.7 + i*2.4, 3.5, 2.2, 0.8, label)
|
||||||
|
```
|
||||||
|
|
||||||
|
### B4. 强调箭头 (右箭头)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def add_arrow_right(slide, x, y, w, h, fill=ACCENT):
|
||||||
|
a = slide.shapes.add_shape(MSO_SHAPE.RIGHT_ARROW,
|
||||||
|
Inches(x), Inches(y),
|
||||||
|
Inches(w), Inches(h))
|
||||||
|
a.fill.solid(); a.fill.fore_color.rgb = fill
|
||||||
|
a.line.fill.background()
|
||||||
|
return a
|
||||||
|
```
|
||||||
|
|
||||||
|
### B5. 标题装饰线
|
||||||
|
|
||||||
|
```python
|
||||||
|
def add_accent_line(slide, x, y, length=1.0, thickness=0.05, color=ACCENT):
|
||||||
|
"""标题下面那条 1 寸长的强调横线 (替代大色块的轻量做法)"""
|
||||||
|
bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE,
|
||||||
|
Inches(x), Inches(y),
|
||||||
|
Inches(length), Inches(thickness))
|
||||||
|
bar.fill.solid(); bar.fill.fore_color.rgb = color
|
||||||
|
bar.line.fill.background()
|
||||||
|
return bar
|
||||||
|
```
|
||||||
|
|
||||||
|
## C. Unicode 字形 (MSO_SHAPE 没有的图形)
|
||||||
|
|
||||||
|
某些图标 MSO_SHAPE 没有对应,用 unicode 字形渲染成 PNG 嵌入。Win/Mac 默认字体覆盖良好。
|
||||||
|
|
||||||
|
### 推荐字形 (避开 emoji,用单色符号)
|
||||||
|
|
||||||
|
```
|
||||||
|
✓ ✔ ✗ ✘ 对号 / 错号
|
||||||
|
✦ ✧ ✪ ★ 星
|
||||||
|
→ ← ↑ ↓ ↔ 箭头
|
||||||
|
⬛ ⬜ ◆ ◇ 方块菱形
|
||||||
|
● ○ ◉ ◎ 圆
|
||||||
|
※ ◇ ⬢ ⬡ 装饰
|
||||||
|
☰ ☱ ☲ ☳ 汉字六十四卦类(简洁)
|
||||||
|
∴ ∵ ⇒ ⇔ 数学
|
||||||
|
№ ¶ § † 文档符号
|
||||||
|
↗ ↘ ↙ ↖ 斜箭头
|
||||||
|
⌘ ⌥ ⌃ ⏎ 键盘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 用 render_icon.py 生成
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生成对号 PNG (强调色,96px)
|
||||||
|
python scripts/render_icon.py "✓" --color "#38B2AC" --size 96 -o slides/check.png
|
||||||
|
|
||||||
|
# 然后嵌入幻灯片
|
||||||
|
slide.shapes.add_picture("slides/check.png", Inches(1), Inches(2),
|
||||||
|
width=Inches(0.5))
|
||||||
|
```
|
||||||
|
|
||||||
|
## D. 用图标的几条原则
|
||||||
|
|
||||||
|
1. **同一 deck 风格统一** —— 全用 MSO_SHAPE 或全用字形 PNG,不要混
|
||||||
|
2. **颜色限定** —— 只用 PRIMARY / SECONDARY / ACCENT / GREY,不要每个图标独立配色
|
||||||
|
3. **大小克制** —— bullet 前的 dot 0.15-0.2 in;独立装饰图标 0.5-1.5 in;不要超过 2 in
|
||||||
|
4. **间距统一** —— 图标右侧到文字的间距固定,通常 0.2-0.3 in
|
||||||
|
5. **不替换文字** —— 图标是辅助,不是表意主体;一个 ★ 不能代替"重点"两字
|
||||||
|
6. **避免 emoji** —— emoji 在不同系统渲染差异大,且自带颜色与你的配色冲突
|
||||||
|
|
||||||
|
## E. 不要做什么
|
||||||
|
|
||||||
|
- ❌ 在每页都堆图标
|
||||||
|
- ❌ 用网上随便下载的彩色图标 (主题不统一)
|
||||||
|
- ❌ 用 emoji (🚀💡⚡) 当严肃汇报的图标
|
||||||
|
- ❌ 图标尺寸大于标题字号高度的 2 倍
|
||||||
|
- ❌ 用 STAR / HEART 装饰严肃议题 (融资额、合规)
|
||||||
|
|
@ -0,0 +1,366 @@
|
||||||
|
# 9 种常用版式 (16:9, 13.33×7.5 in)
|
||||||
|
|
||||||
|
> **2.0 版本要点**:大幅减少满铺色块,引入 MSO_SHAPE 图标点缀,所有元素经 safe_area 校验不会越出画布。
|
||||||
|
|
||||||
|
复制 → 改文案 → 跑。配色用 `spec_lock.md` 里的实际 hex 替换占位。
|
||||||
|
|
||||||
|
## 通用起手 + 安全辅助
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pptx import Presentation
|
||||||
|
from pptx.util import Inches, Pt, Emu
|
||||||
|
from pptx.dml.color import RGBColor
|
||||||
|
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR, MSO_AUTO_SIZE
|
||||||
|
from pptx.enum.shapes import MSO_SHAPE
|
||||||
|
|
||||||
|
# ---- 配色 (默认红色主题; spec_lock 里有覆盖以 spec_lock 为准) ----
|
||||||
|
PRIMARY = RGBColor(0xC0, 0x00, 0x00) # 深红 - 标题/强调/关键数据
|
||||||
|
SECONDARY = RGBColor(0xE1, 0x55, 0x54) # 砖红 - 次要图形
|
||||||
|
ACCENT = RGBColor(0xFF, 0xC1, 0x07) # 金黄 - 关键数据点/CTA
|
||||||
|
INK = RGBColor(0x1F, 0x1F, 0x1F)
|
||||||
|
GREY = RGBColor(0x59, 0x59, 0x59)
|
||||||
|
GREY_LIGHT = RGBColor(0x88, 0x88, 0x88)
|
||||||
|
BG = RGBColor(0xFA, 0xFA, 0xFA) # 背景近白
|
||||||
|
WHITE = RGBColor(255, 255, 255)
|
||||||
|
|
||||||
|
CN_FONT = "微软雅黑"
|
||||||
|
EN_FONT = "Arial"
|
||||||
|
|
||||||
|
# ---- 画布与安全区 ----
|
||||||
|
prs = Presentation()
|
||||||
|
prs.slide_width = Inches(13.33)
|
||||||
|
prs.slide_height = Inches(7.5)
|
||||||
|
SLIDE_W = 13.33
|
||||||
|
SLIDE_H = 7.5
|
||||||
|
MARGIN_X = 0.7 # 左右
|
||||||
|
MARGIN_Y = 0.5 # 上下
|
||||||
|
SAFE_LEFT = MARGIN_X
|
||||||
|
SAFE_TOP = MARGIN_Y
|
||||||
|
SAFE_RIGHT = SLIDE_W - MARGIN_X
|
||||||
|
SAFE_BOTTOM = SLIDE_H - MARGIN_Y
|
||||||
|
SAFE_W = SAFE_RIGHT - SAFE_LEFT # 11.93
|
||||||
|
SAFE_H = SAFE_BOTTOM - SAFE_TOP # 6.5
|
||||||
|
BLANK = prs.slide_layouts[6]
|
||||||
|
|
||||||
|
def assert_inside(left, top, width, height, name=""):
|
||||||
|
"""放置前调一次。越界直接报错而不是悄悄超出。"""
|
||||||
|
if left < 0 or top < 0:
|
||||||
|
raise ValueError(f"[{name}] 左/上为负: ({left}, {top})")
|
||||||
|
if left + width > SLIDE_W + 1e-3:
|
||||||
|
raise ValueError(f"[{name}] 右越界: {left}+{width} > {SLIDE_W}")
|
||||||
|
if top + height > SLIDE_H + 1e-3:
|
||||||
|
raise ValueError(f"[{name}] 下越界: {top}+{height} > {SLIDE_H}")
|
||||||
|
|
||||||
|
# ---- 文本辅助 (默认 word_wrap, shrink-to-fit 兜底) ----
|
||||||
|
def set_text(tf, text, size, bold=False, color=INK, align=PP_ALIGN.LEFT,
|
||||||
|
font=CN_FONT):
|
||||||
|
tf.text = text
|
||||||
|
p = tf.paragraphs[0]; p.alignment = align
|
||||||
|
r = p.runs[0]
|
||||||
|
r.font.name = font; r.font.size = Pt(size); r.font.bold = bold
|
||||||
|
r.font.color.rgb = color
|
||||||
|
|
||||||
|
def add_textbox(slide, left, top, width, height, text, size,
|
||||||
|
bold=False, color=INK, align=PP_ALIGN.LEFT,
|
||||||
|
anchor=MSO_ANCHOR.TOP, font=CN_FONT, shrink=True,
|
||||||
|
name="textbox"):
|
||||||
|
assert_inside(left, top, width, height, name)
|
||||||
|
tb = slide.shapes.add_textbox(Inches(left), Inches(top),
|
||||||
|
Inches(width), Inches(height))
|
||||||
|
tf = tb.text_frame
|
||||||
|
tf.vertical_anchor = anchor
|
||||||
|
tf.word_wrap = True
|
||||||
|
if shrink:
|
||||||
|
# 文字超出框高时自动收缩字号 (兜底,不替代字数预算)
|
||||||
|
tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
|
||||||
|
set_text(tf, text, size, bold, color, align, font)
|
||||||
|
return tb
|
||||||
|
|
||||||
|
# ---- 形状辅助 (无边线实心填充) ----
|
||||||
|
def add_rect(slide, left, top, width, height, fill, name="rect"):
|
||||||
|
assert_inside(left, top, width, height, name)
|
||||||
|
s = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(left), Inches(top),
|
||||||
|
Inches(width), Inches(height))
|
||||||
|
s.fill.solid(); s.fill.fore_color.rgb = fill
|
||||||
|
s.line.fill.background()
|
||||||
|
return s
|
||||||
|
|
||||||
|
def add_shape(slide, kind, left, top, width, height, fill, name="shape"):
|
||||||
|
assert_inside(left, top, width, height, name)
|
||||||
|
s = slide.shapes.add_shape(kind, Inches(left), Inches(top),
|
||||||
|
Inches(width), Inches(height))
|
||||||
|
s.fill.solid(); s.fill.fore_color.rgb = fill
|
||||||
|
s.line.fill.background()
|
||||||
|
return s
|
||||||
|
|
||||||
|
def add_dot(slide, x, y, size=0.18, color=ACCENT):
|
||||||
|
return add_shape(slide, MSO_SHAPE.OVAL, x, y, size, size, color, "dot")
|
||||||
|
|
||||||
|
def add_accent_line(slide, x, y, length=1.0, thickness=0.05, color=ACCENT):
|
||||||
|
"""标题下面那条强调线,替代大色块"""
|
||||||
|
return add_rect(slide, x, y, length, thickness, color, "accent_line")
|
||||||
|
|
||||||
|
def add_badge(slide, x, y, num, diameter=0.7, fill=PRIMARY, fg=WHITE):
|
||||||
|
"""编号徽章 (圆 + 数字)"""
|
||||||
|
c = add_shape(slide, MSO_SHAPE.OVAL, x, y, diameter, diameter, fill, "badge")
|
||||||
|
tf = c.text_frame; tf.text = str(num)
|
||||||
|
p = tf.paragraphs[0]; p.alignment = PP_ALIGN.CENTER
|
||||||
|
r = p.runs[0]
|
||||||
|
r.font.bold = True; r.font.size = Pt(int(diameter * 28))
|
||||||
|
r.font.color.rgb = fg; r.font.name = EN_FONT
|
||||||
|
return c
|
||||||
|
|
||||||
|
# ---- 标题套件 (内页通用) ----
|
||||||
|
def page_title(slide, text, page_num=None, total=None, footer="项目汇报"):
|
||||||
|
add_textbox(slide, SAFE_LEFT, SAFE_TOP, SAFE_W, 0.7, text,
|
||||||
|
32, bold=True, color=PRIMARY, name="title")
|
||||||
|
add_accent_line(slide, SAFE_LEFT, SAFE_TOP + 0.85,
|
||||||
|
length=0.8, color=ACCENT)
|
||||||
|
if page_num is not None and total is not None:
|
||||||
|
add_textbox(slide, SAFE_LEFT, 7.0, 6, 0.4, footer,
|
||||||
|
11, color=GREY_LIGHT, shrink=False, name="footer")
|
||||||
|
add_textbox(slide, 12.0, 7.0, 1.2, 0.4, f"{page_num} / {total}",
|
||||||
|
11, color=GREY_LIGHT, align=PP_ALIGN.RIGHT,
|
||||||
|
shrink=False, name="page_num")
|
||||||
|
```
|
||||||
|
|
||||||
|
> **要点**:
|
||||||
|
> - `assert_inside` 阻止任何越界。元素超出画布会立刻报 `ValueError`,而不是悄悄裁剪
|
||||||
|
> - `add_textbox` 默认 `word_wrap=True` + `auto_size=TEXT_TO_SHAPE_AND_FIT_TEXT` —— 文字溢出自动缩字号
|
||||||
|
> - `page_title` 用细线代替大块色填,所有内页统一调用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## L1 · 封面 (Cover) —— 极简,无大色块
|
||||||
|
|
||||||
|
```python
|
||||||
|
slide = prs.slides.add_slide(BLANK)
|
||||||
|
|
||||||
|
# 左上角小色块 + 标题左侧细色条
|
||||||
|
add_rect(slide, 0.7, 0.7, 0.6, 0.06, PRIMARY) # 顶部短线
|
||||||
|
add_rect(slide, 0.7, 1.05, 0.06, 1.5, ACCENT) # 左侧竖线 (装饰)
|
||||||
|
|
||||||
|
# 主标题
|
||||||
|
add_textbox(slide, 0.7, 2.6, 11.9, 1.4, "项目名称 / 演示主题",
|
||||||
|
44, bold=True, color=INK, name="cover_title")
|
||||||
|
# 副标题 (灰色,弱化)
|
||||||
|
add_textbox(slide, 0.7, 4.1, 11.9, 0.6, "一句话副标题或定位",
|
||||||
|
22, color=GREY, name="cover_sub")
|
||||||
|
# 汇报人 / 日期
|
||||||
|
add_textbox(slide, 0.7, 6.4, 11.9, 0.4,
|
||||||
|
"汇报人 · 部门 · 2026-05-06", 14, color=GREY_LIGHT,
|
||||||
|
name="cover_meta")
|
||||||
|
# 右下角小图标点缀 (五角星,可选)
|
||||||
|
add_shape(slide, MSO_SHAPE.STAR_5_POINT, 12.2, 6.3, 0.5, 0.5, ACCENT,
|
||||||
|
"deco_star")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## L2 · 目录 (Agenda) —— 编号徽章 + 文字
|
||||||
|
|
||||||
|
```python
|
||||||
|
slide = prs.slides.add_slide(BLANK)
|
||||||
|
page_title(slide, "目录")
|
||||||
|
|
||||||
|
items = ["背景与现状", "核心问题", "解决方案", "实施计划", "预期成果"]
|
||||||
|
for i, item in enumerate(items):
|
||||||
|
y = 1.9 + i * 0.95
|
||||||
|
add_badge(slide, SAFE_LEFT, y, i + 1, diameter=0.65)
|
||||||
|
add_textbox(slide, SAFE_LEFT + 1.0, y, SAFE_W - 1.0, 0.65,
|
||||||
|
item, 22, color=INK, anchor=MSO_ANCHOR.MIDDLE,
|
||||||
|
name=f"agenda_{i}")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## L3 · 章节分隔 (Section Divider) —— 浅色背景 + 大字编号
|
||||||
|
|
||||||
|
```python
|
||||||
|
slide = prs.slides.add_slide(BLANK)
|
||||||
|
# 整页极浅灰 (替代深色满铺)
|
||||||
|
add_rect(slide, 0, 0, SLIDE_W, SLIDE_H, BG)
|
||||||
|
# 左侧装饰竖条
|
||||||
|
add_rect(slide, 0.7, 2.5, 0.08, 2.5, ACCENT)
|
||||||
|
# 大编号 (主色,描边视觉感)
|
||||||
|
add_textbox(slide, 1.1, 2.0, 4, 2.5, "01", 160, bold=True,
|
||||||
|
color=PRIMARY, font=EN_FONT, name="sec_num")
|
||||||
|
# 章节名
|
||||||
|
add_textbox(slide, 5.5, 2.8, 7, 1.0, "背景与现状",
|
||||||
|
44, bold=True, color=INK, anchor=MSO_ANCHOR.MIDDLE,
|
||||||
|
name="sec_title")
|
||||||
|
# 引言
|
||||||
|
add_textbox(slide, 5.5, 4.0, 7, 0.6,
|
||||||
|
"本章讨论行业现状与机会窗口", 18, color=GREY,
|
||||||
|
name="sec_lead")
|
||||||
|
# 装饰小图标
|
||||||
|
add_shape(slide, MSO_SHAPE.RIGHT_ARROW, 5.5, 5.0, 0.6, 0.3, ACCENT,
|
||||||
|
"sec_arrow")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## L4 · 要点 (Bullets) —— 圆点 + 文字,无大块色
|
||||||
|
|
||||||
|
```python
|
||||||
|
slide = prs.slides.add_slide(BLANK)
|
||||||
|
page_title(slide, "核心结论")
|
||||||
|
|
||||||
|
bullets = [
|
||||||
|
"结论一:用一句话讲清楚",
|
||||||
|
"结论二:具体数据支撑,如增长 27%",
|
||||||
|
"结论三:对未来的判断,简洁有力",
|
||||||
|
"结论四:可选第四条,不要超过 5 条",
|
||||||
|
]
|
||||||
|
for i, b in enumerate(bullets):
|
||||||
|
y = 2.0 + i * 0.95
|
||||||
|
add_dot(slide, SAFE_LEFT + 0.05, y + 0.22, size=0.18, color=ACCENT)
|
||||||
|
add_textbox(slide, SAFE_LEFT + 0.45, y, SAFE_W - 0.45, 0.6,
|
||||||
|
b, 22, color=INK, anchor=MSO_ANCHOR.MIDDLE,
|
||||||
|
name=f"bullet_{i}")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## L5 · 双栏对比 (Two-Column) —— 中线分隔,小色块标签
|
||||||
|
|
||||||
|
```python
|
||||||
|
slide = prs.slides.add_slide(BLANK)
|
||||||
|
page_title(slide, "现状 vs 改进后")
|
||||||
|
|
||||||
|
mid_x = SLIDE_W / 2
|
||||||
|
|
||||||
|
# 中间细分隔线 (替代两块大矩形)
|
||||||
|
add_rect(slide, mid_x - 0.02, 2.0, 0.04, 4.8, RGBColor(0xDD, 0xDD, 0xDD),
|
||||||
|
"divider")
|
||||||
|
|
||||||
|
# 左栏小标签 (色块只占小区域)
|
||||||
|
add_rect(slide, SAFE_LEFT, 2.0, 0.8, 0.35, GREY, "left_tag")
|
||||||
|
add_textbox(slide, SAFE_LEFT, 2.0, 0.8, 0.35, "现状", 14, bold=True,
|
||||||
|
color=WHITE, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE,
|
||||||
|
shrink=False, name="left_label")
|
||||||
|
left_pts = ["问题 A: 描述", "问题 B: 描述", "问题 C: 描述"]
|
||||||
|
for i, p in enumerate(left_pts):
|
||||||
|
add_dot(slide, SAFE_LEFT + 0.05, 2.7 + i * 0.7 + 0.18, color=GREY)
|
||||||
|
add_textbox(slide, SAFE_LEFT + 0.45, 2.7 + i * 0.7,
|
||||||
|
mid_x - SAFE_LEFT - 0.7, 0.55, p, 18, color=INK,
|
||||||
|
anchor=MSO_ANCHOR.MIDDLE, name=f"l_pt_{i}")
|
||||||
|
|
||||||
|
# 右栏小标签
|
||||||
|
add_rect(slide, mid_x + 0.3, 2.0, 0.8, 0.35, PRIMARY, "right_tag")
|
||||||
|
add_textbox(slide, mid_x + 0.3, 2.0, 0.8, 0.35, "改进后", 14, bold=True,
|
||||||
|
color=WHITE, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE,
|
||||||
|
shrink=False, name="right_label")
|
||||||
|
right_pts = ["改善 A: 描述", "改善 B: 描述", "改善 C: 描述"]
|
||||||
|
for i, p in enumerate(right_pts):
|
||||||
|
add_dot(slide, mid_x + 0.35, 2.7 + i * 0.7 + 0.18, color=ACCENT)
|
||||||
|
add_textbox(slide, mid_x + 0.75, 2.7 + i * 0.7,
|
||||||
|
SAFE_RIGHT - mid_x - 0.75, 0.55, p, 18, color=INK,
|
||||||
|
anchor=MSO_ANCHOR.MIDDLE, name=f"r_pt_{i}")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## L6 · 图表为主 (Chart-focus) —— 标题 + 一句结论 + 大图
|
||||||
|
|
||||||
|
```python
|
||||||
|
# chart.png 已用 matplotlib 生成 (见 design_principles.md §7)
|
||||||
|
slide = prs.slides.add_slide(BLANK)
|
||||||
|
page_title(slide, "季度营收持续增长")
|
||||||
|
# 一句话结论
|
||||||
|
add_textbox(slide, SAFE_LEFT, SAFE_TOP + 1.1, SAFE_W, 0.5,
|
||||||
|
"Q4 同比增长 158%,创历史新高", 18, color=GREY,
|
||||||
|
name="lead")
|
||||||
|
# 图表 (居中,占 9 寸宽,高度自适应)
|
||||||
|
slide.shapes.add_picture("chart.png", Inches(2.2), Inches(2.4),
|
||||||
|
width=Inches(8.9))
|
||||||
|
# 数据来源 (右下角弱化)
|
||||||
|
add_textbox(slide, SAFE_LEFT, 6.95, SAFE_W, 0.4,
|
||||||
|
"数据来源: 公司年报 2025", 11, color=GREY_LIGHT,
|
||||||
|
align=PP_ALIGN.RIGHT, shrink=False, name="source")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## L7 · 图片为主 (Image-focus) —— 文字在图旁,不压图
|
||||||
|
|
||||||
|
> 之前用满铺图 + 半透明遮罩,效果不稳定。改成"图占 60% + 文字独立区"。
|
||||||
|
|
||||||
|
```python
|
||||||
|
slide = prs.slides.add_slide(BLANK)
|
||||||
|
# 左侧图占 60% 宽
|
||||||
|
slide.shapes.add_picture("hero.jpg", Inches(0), Inches(0),
|
||||||
|
width=Inches(8), height=Inches(7.5))
|
||||||
|
# 右侧浅灰背景区放文字
|
||||||
|
add_rect(slide, 8, 0, 5.33, 7.5, BG, "text_panel")
|
||||||
|
add_rect(slide, 8.4, 1.0, 0.06, 0.8, ACCENT, "deco_bar") # 装饰短线
|
||||||
|
add_textbox(slide, 8.4, 2.0, 4.6, 1.6, "走进未来", 36,
|
||||||
|
bold=True, color=INK, name="img_title")
|
||||||
|
add_textbox(slide, 8.4, 3.8, 4.6, 1.5,
|
||||||
|
"用一两句话点出主旨,不要把演讲稿搬上来。",
|
||||||
|
18, color=GREY, name="img_caption")
|
||||||
|
# 图标:右下角的箭头,引导视线
|
||||||
|
add_shape(slide, MSO_SHAPE.RIGHT_ARROW, 8.4, 6.5, 0.7, 0.35, ACCENT,
|
||||||
|
"img_cta")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## L8 · 金句 / 大字 (Quote) —— 留白主导,装饰极简
|
||||||
|
|
||||||
|
```python
|
||||||
|
slide = prs.slides.add_slide(BLANK)
|
||||||
|
# 左上大引号 (用 STAR 不合适;用字形)
|
||||||
|
add_textbox(slide, 0.8, 0.6, 1.5, 1.5, '"', 200, bold=True,
|
||||||
|
color=ACCENT, font=EN_FONT, shrink=False, name="quote_mark")
|
||||||
|
# 金句 (深色,留白多)
|
||||||
|
add_textbox(slide, 1.5, 2.7, 10.5, 2.0,
|
||||||
|
"把复杂留给我们,把简单留给用户。", 36, bold=True,
|
||||||
|
color=INK, anchor=MSO_ANCHOR.MIDDLE, name="quote_text")
|
||||||
|
# 装饰短线
|
||||||
|
add_accent_line(slide, 1.5, 5.0, length=0.5, color=ACCENT)
|
||||||
|
# 出处
|
||||||
|
add_textbox(slide, 1.5, 5.2, 10.5, 0.5, "—— 公司价值观 2025",
|
||||||
|
16, color=GREY, name="quote_attr")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## L9 · 结尾 / Q&A —— 浅色 + 大字,不再满铺深色
|
||||||
|
|
||||||
|
```python
|
||||||
|
slide = prs.slides.add_slide(BLANK)
|
||||||
|
# 顶部 + 底部装饰短线 (代替整页色块)
|
||||||
|
add_rect(slide, SAFE_LEFT, 0.6, 0.8, 0.06, ACCENT, "top_line")
|
||||||
|
add_rect(slide, SAFE_RIGHT - 0.8, 6.85, 0.8, 0.06, ACCENT, "bottom_line")
|
||||||
|
|
||||||
|
add_textbox(slide, 0, 2.5, SLIDE_W, 1.6, "Thank You", 80, bold=True,
|
||||||
|
color=PRIMARY, align=PP_ALIGN.CENTER, font=EN_FONT,
|
||||||
|
name="thanks")
|
||||||
|
add_textbox(slide, 0, 4.3, SLIDE_W, 0.6, "欢迎提问与讨论",
|
||||||
|
22, color=ACCENT, align=PP_ALIGN.CENTER, name="qa")
|
||||||
|
add_textbox(slide, 0, 6.2, SLIDE_W, 0.5,
|
||||||
|
"联系方式 / 邮箱 / 公众号", 14, color=GREY_LIGHT,
|
||||||
|
align=PP_ALIGN.CENTER, name="contact")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 选版式速查
|
||||||
|
|
||||||
|
```
|
||||||
|
有数据 ≥ 3 点 → L6 (Chart-focus)
|
||||||
|
对比类 (前/后, A/B) → L5 (Two-Column)
|
||||||
|
要点 ≤ 5 条 → L4 (Bullets)
|
||||||
|
转场 / 换章 → L3 (Section Divider)
|
||||||
|
首页 → L1 (Cover)
|
||||||
|
末页 → L9 (Q&A)
|
||||||
|
有大图 / 视觉优先 → L7 (Image-focus)
|
||||||
|
观点强调 / 名言 → L8 (Quote)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三个常犯的越界场景
|
||||||
|
|
||||||
|
1. **bullet 字数超额** —— 22pt 在 11.5 寸宽下每行约 50 个中文字。超过 1 行就溢出 0.7 in 高的框。**用 `assert_inside` + `auto_size=TEXT_TO_SHAPE_AND_FIT_TEXT` 兜底**;但根本解法是**字数压缩**(见 design_principles.md §字数预算)
|
||||||
|
2. **标题占两行** —— 标题在 0.7 in 高的框里,32pt 单行高约 0.45 in,**两行就溢出**。中文标题 ≤ 30 字
|
||||||
|
3. **图片不等比拉伸** —— `add_picture(width=, height=)` 同时给会变形;**只给 width 或 height 一项**
|
||||||
|
|
@ -0,0 +1,258 @@
|
||||||
|
"""quality_check.py: 验收 .pptx,产出问题清单。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python quality_check.py <output.pptx> [--spec spec_lock.md]
|
||||||
|
|
||||||
|
检查项:
|
||||||
|
- 文件存在且 > 10KB
|
||||||
|
- 总页数与 spec 一致 (如提供 spec_lock.md)
|
||||||
|
- 每页有标题
|
||||||
|
- 每页 bullet ≤ 5 条
|
||||||
|
- 文字字号 ≥ 14pt (除页脚)
|
||||||
|
- 颜色集合 ≤ 5 种 (粗略统计)
|
||||||
|
- 没有 untitled / output / placeholder 等占位文件名
|
||||||
|
- **形状不越出画布边界** (left+width / top+height 超界即报)
|
||||||
|
- **textbox 文本估算行数 > 框高度** —— 推断溢出
|
||||||
|
|
||||||
|
退出码:
|
||||||
|
0 = 全通过
|
||||||
|
1 = 有 warning
|
||||||
|
2 = 致命问题 (文件缺失等)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pptx import Presentation
|
||||||
|
from pptx.util import Pt
|
||||||
|
except ImportError:
|
||||||
|
print("[fatal] pip install python-pptx", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- spec 解析 (松散 markdown 解析,够用就行) ----
|
||||||
|
|
||||||
|
def parse_spec(spec_path: Path) -> dict:
|
||||||
|
if not spec_path or not spec_path.exists():
|
||||||
|
return {}
|
||||||
|
text = spec_path.read_text(encoding="utf-8")
|
||||||
|
spec: dict = {}
|
||||||
|
|
||||||
|
m = re.search(r"页数[:\s]*(\d+)", text)
|
||||||
|
if m:
|
||||||
|
spec["page_count"] = int(m.group(1))
|
||||||
|
|
||||||
|
m = re.search(r"画布[:\s]*(16:9|4:3|9:16|1:1|3:4)", text)
|
||||||
|
if m:
|
||||||
|
spec["canvas"] = m.group(1)
|
||||||
|
|
||||||
|
hexes = re.findall(r"#([0-9A-Fa-f]{6})", text)
|
||||||
|
if hexes:
|
||||||
|
spec["colors"] = [h.upper() for h in hexes[:5]]
|
||||||
|
|
||||||
|
return spec
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 检查 ----
|
||||||
|
|
||||||
|
def check_pptx(path: Path, spec: dict) -> tuple[list, list]:
|
||||||
|
"""returns (errors, warnings)"""
|
||||||
|
errors, warnings = [], []
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
errors.append(f"文件不存在: {path}")
|
||||||
|
return errors, warnings
|
||||||
|
|
||||||
|
size_kb = path.stat().st_size / 1024
|
||||||
|
if size_kb < 10:
|
||||||
|
errors.append(f"文件太小 ({size_kb:.1f}KB),python-pptx 可能没写完")
|
||||||
|
|
||||||
|
name = path.stem.lower()
|
||||||
|
if name in ("untitled", "output", "presentation", "untitled1", "new", "test"):
|
||||||
|
warnings.append(
|
||||||
|
f"文件名 '{path.name}' 太通用,建议按主题命名"
|
||||||
|
)
|
||||||
|
|
||||||
|
prs = Presentation(path)
|
||||||
|
n_slides = len(prs.slides)
|
||||||
|
slide_w_in = prs.slide_width / 914400 # EMU → inch
|
||||||
|
slide_h_in = prs.slide_height / 914400
|
||||||
|
print(
|
||||||
|
f"[info] 文件: {path.name} 大小: {size_kb:.1f}KB "
|
||||||
|
f"页数: {n_slides} 画布: {slide_w_in:.2f}×{slide_h_in:.2f} in"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = spec.get("page_count")
|
||||||
|
if expected and n_slides != expected:
|
||||||
|
warnings.append(f"页数 {n_slides} 与 spec 期望 {expected} 不符")
|
||||||
|
|
||||||
|
spec_colors = set(spec.get("colors", []))
|
||||||
|
seen_colors: set[str] = set()
|
||||||
|
|
||||||
|
for idx, slide in enumerate(prs.slides, 1):
|
||||||
|
title_text = None
|
||||||
|
bullet_count = 0
|
||||||
|
small_font_count = 0
|
||||||
|
|
||||||
|
for s_i, shape in enumerate(slide.shapes):
|
||||||
|
# ---- 形状越界检查 (任何 shape) ----
|
||||||
|
try:
|
||||||
|
left_in = shape.left / 914400 if shape.left is not None else 0
|
||||||
|
top_in = shape.top / 914400 if shape.top is not None else 0
|
||||||
|
w_in = shape.width / 914400 if shape.width is not None else 0
|
||||||
|
h_in = shape.height / 914400 if shape.height is not None else 0
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
left_in = top_in = w_in = h_in = 0
|
||||||
|
|
||||||
|
tol = 0.02 # 0.02 in 容忍 (约 0.5mm)
|
||||||
|
shape_label = (
|
||||||
|
shape.name if hasattr(shape, "name") and shape.name
|
||||||
|
else f"shape#{s_i}"
|
||||||
|
)
|
||||||
|
if left_in < -tol or top_in < -tol:
|
||||||
|
warnings.append(
|
||||||
|
f"第 {idx} 页 {shape_label} 起点为负: "
|
||||||
|
f"({left_in:.2f}, {top_in:.2f})"
|
||||||
|
)
|
||||||
|
if left_in + w_in > slide_w_in + tol:
|
||||||
|
overflow = left_in + w_in - slide_w_in
|
||||||
|
warnings.append(
|
||||||
|
f"第 {idx} 页 {shape_label} 右越界 {overflow:.2f}in "
|
||||||
|
f"(画布 {slide_w_in:.2f},shape 右 {left_in + w_in:.2f})"
|
||||||
|
)
|
||||||
|
if top_in + h_in > slide_h_in + tol:
|
||||||
|
overflow = top_in + h_in - slide_h_in
|
||||||
|
warnings.append(
|
||||||
|
f"第 {idx} 页 {shape_label} 下越界 {overflow:.2f}in "
|
||||||
|
f"(画布 {slide_h_in:.2f},shape 底 {top_in + h_in:.2f})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not shape.has_text_frame:
|
||||||
|
continue
|
||||||
|
tf = shape.text_frame
|
||||||
|
text = (tf.text or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if title_text is None and len(text) <= 40 and "\n" not in text:
|
||||||
|
title_text = text
|
||||||
|
|
||||||
|
# ---- 文本溢出估算 ----
|
||||||
|
# 估算:中文字号 N pt 在框宽 W in 下,每行约 W*72/N 个中文字
|
||||||
|
# 非空段落数 + 长段落折行数 ≈ 实际行数
|
||||||
|
# 行数 × (size_pt * 1.4 / 72) > 框高 → 溢出
|
||||||
|
try:
|
||||||
|
first_size_pt = None
|
||||||
|
for para in tf.paragraphs:
|
||||||
|
for run in para.runs:
|
||||||
|
if run.font.size:
|
||||||
|
first_size_pt = run.font.size.pt
|
||||||
|
break
|
||||||
|
if first_size_pt:
|
||||||
|
break
|
||||||
|
if first_size_pt and w_in > 0.5 and h_in > 0.2:
|
||||||
|
chars_per_line = max(1, int(w_in * 72 / first_size_pt))
|
||||||
|
est_lines = 0
|
||||||
|
for para in tf.paragraphs:
|
||||||
|
ptxt = (para.text or "").strip()
|
||||||
|
if not ptxt:
|
||||||
|
continue
|
||||||
|
est_lines += max(
|
||||||
|
1,
|
||||||
|
(len(ptxt) + chars_per_line - 1) // chars_per_line
|
||||||
|
)
|
||||||
|
line_height_in = first_size_pt * 1.4 / 72
|
||||||
|
needed_h = est_lines * line_height_in
|
||||||
|
if needed_h > h_in + 0.1:
|
||||||
|
warnings.append(
|
||||||
|
f"第 {idx} 页 {shape_label} 文本可能溢出 "
|
||||||
|
f"(估 {est_lines} 行,需 {needed_h:.2f}in,"
|
||||||
|
f"框高 {h_in:.2f}in): {text[:25]}..."
|
||||||
|
)
|
||||||
|
except (AttributeError, TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
for para in tf.paragraphs:
|
||||||
|
ptxt = (para.text or "").strip()
|
||||||
|
if not ptxt:
|
||||||
|
continue
|
||||||
|
if len(ptxt) > 1 and ptxt != title_text:
|
||||||
|
bullet_count += 1
|
||||||
|
for run in para.runs:
|
||||||
|
if run.font.size:
|
||||||
|
if run.font.size < Pt(14):
|
||||||
|
small_font_count += 1
|
||||||
|
if run.font.color and run.font.color.type:
|
||||||
|
try:
|
||||||
|
rgb = run.font.color.rgb
|
||||||
|
if rgb is not None:
|
||||||
|
seen_colors.add(str(rgb))
|
||||||
|
except (AttributeError, KeyError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if title_text is None:
|
||||||
|
warnings.append(f"第 {idx} 页缺标题")
|
||||||
|
elif len(title_text) > 30:
|
||||||
|
warnings.append(
|
||||||
|
f"第 {idx} 页标题过长 ({len(title_text)} 字): {title_text[:20]}..."
|
||||||
|
)
|
||||||
|
|
||||||
|
if bullet_count > 5:
|
||||||
|
warnings.append(
|
||||||
|
f"第 {idx} 页 bullet {bullet_count} 条 (上限 5),建议拆页或转图表"
|
||||||
|
)
|
||||||
|
|
||||||
|
if small_font_count > 0:
|
||||||
|
warnings.append(
|
||||||
|
f"第 {idx} 页有 {small_font_count} 处字号 < 14pt,投影看不清"
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(seen_colors) > 6:
|
||||||
|
warnings.append(
|
||||||
|
f"颜色 {len(seen_colors)} 种 (含不同灰阶),理想 ≤ 5;考虑收敛到三色制"
|
||||||
|
)
|
||||||
|
|
||||||
|
if spec_colors and seen_colors:
|
||||||
|
unmatched = seen_colors - spec_colors
|
||||||
|
if len(unmatched) > 3:
|
||||||
|
warnings.append(
|
||||||
|
f"出现 {len(unmatched)} 个 spec_lock 之外的颜色,可能用了 matplotlib 默认色板"
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors, warnings
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("pptx", type=Path)
|
||||||
|
ap.add_argument("--spec", type=Path, default=None,
|
||||||
|
help="spec_lock.md 路径")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
spec = parse_spec(args.spec) if args.spec else {}
|
||||||
|
if spec:
|
||||||
|
print(f"[info] spec 已加载: {spec}")
|
||||||
|
|
||||||
|
errors, warnings = check_pptx(args.pptx, spec)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("\n[errors]")
|
||||||
|
for e in errors:
|
||||||
|
print(f" ✗ {e}")
|
||||||
|
if warnings:
|
||||||
|
print("\n[warnings]")
|
||||||
|
for w in warnings:
|
||||||
|
print(f" ! {w}")
|
||||||
|
|
||||||
|
if not errors and not warnings:
|
||||||
|
print("\n[ok] 全部通过")
|
||||||
|
sys.exit(0)
|
||||||
|
sys.exit(2 if errors else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
"""render_icon.py: unicode 字形 → 透明背景 PNG。
|
||||||
|
|
||||||
|
MSO_SHAPE 覆盖不到的图标 (齿轮、放大镜、文件夹等),用字形渲染兜底。
|
||||||
|
首选 MSO_SHAPE,见 references/icons.md。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python render_icon.py "✓" --color "#38B2AC" --size 96 -o check.png
|
||||||
|
python render_icon.py "★" --color "#FFC000" --size 128 -o star.png
|
||||||
|
python render_icon.py "→" --color "#1F4E79" --size 64 -o arrow.png
|
||||||
|
|
||||||
|
退出码:
|
||||||
|
0 = 成功
|
||||||
|
1 = Pillow 缺失
|
||||||
|
2 = 字体找不到
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def find_font(preferred: list[str]) -> str | None:
|
||||||
|
"""按顺序找系统字体。返回字体路径或 None。"""
|
||||||
|
candidates = []
|
||||||
|
# Windows
|
||||||
|
candidates += [
|
||||||
|
rf"C:\Windows\Fonts\{name}" for name in [
|
||||||
|
"seguisym.ttf", # Segoe UI Symbol
|
||||||
|
"seguiemj.ttf", # Segoe UI Emoji (彩色,慎用)
|
||||||
|
"msyh.ttc", "msyh.ttf", # 微软雅黑
|
||||||
|
"simsun.ttc", # 宋体
|
||||||
|
"arial.ttf",
|
||||||
|
]
|
||||||
|
]
|
||||||
|
# macOS
|
||||||
|
candidates += [
|
||||||
|
"/System/Library/Fonts/Apple Symbols.ttf",
|
||||||
|
"/System/Library/Fonts/PingFang.ttc",
|
||||||
|
"/Library/Fonts/Arial Unicode.ttf",
|
||||||
|
]
|
||||||
|
# Linux
|
||||||
|
candidates += [
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
]
|
||||||
|
|
||||||
|
if preferred:
|
||||||
|
candidates = preferred + candidates
|
||||||
|
|
||||||
|
for c in candidates:
|
||||||
|
if Path(c).exists():
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def hex_to_rgba(hex_str: str) -> tuple[int, int, int, int]:
|
||||||
|
h = hex_str.lstrip("#")
|
||||||
|
if len(h) == 6:
|
||||||
|
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), 255
|
||||||
|
if len(h) == 8:
|
||||||
|
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), int(h[6:8], 16)
|
||||||
|
raise ValueError(f"bad hex color: {hex_str}")
|
||||||
|
|
||||||
|
|
||||||
|
def render(glyph: str, color: str, size_px: int, output: Path,
|
||||||
|
font_path: str | None, padding: int) -> None:
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
except ImportError:
|
||||||
|
print("[fatal] pip install Pillow", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
font_path = font_path or find_font([])
|
||||||
|
if not font_path:
|
||||||
|
print("[fatal] no symbol font found; pass --font /path/to/font.ttf",
|
||||||
|
file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
rgba = hex_to_rgba(color)
|
||||||
|
|
||||||
|
# 字体载入,用 size_px 的 0.85 做实际字号让字形不顶格
|
||||||
|
font_size = int(size_px * 0.85)
|
||||||
|
font = ImageFont.truetype(font_path, font_size)
|
||||||
|
|
||||||
|
# 测量字形真实包围盒
|
||||||
|
tmp = Image.new("RGBA", (size_px * 2, size_px * 2), (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(tmp)
|
||||||
|
bbox = draw.textbbox((0, 0), glyph, font=font)
|
||||||
|
tw = bbox[2] - bbox[0]
|
||||||
|
th = bbox[3] - bbox[1]
|
||||||
|
|
||||||
|
# 输出画布:正方形,边长 = size_px,加 padding
|
||||||
|
canvas_size = size_px + 2 * padding
|
||||||
|
img = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# 居中绘制 (考虑 bbox 偏移)
|
||||||
|
x = (canvas_size - tw) // 2 - bbox[0]
|
||||||
|
y = (canvas_size - th) // 2 - bbox[1]
|
||||||
|
draw.text((x, y), glyph, font=font, fill=rgba)
|
||||||
|
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
img.save(output, "PNG")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("glyph", help="unicode 字符,如 ✓ ★ →")
|
||||||
|
ap.add_argument("--color", default="#1F4E79", help="hex,默认 #1F4E79")
|
||||||
|
ap.add_argument("--size", type=int, default=96,
|
||||||
|
help="像素边长 (字形主体),默认 96")
|
||||||
|
ap.add_argument("--padding", type=int, default=8,
|
||||||
|
help="周围透明边距像素,默认 8")
|
||||||
|
ap.add_argument("--font", default=None,
|
||||||
|
help="自定义字体路径 (.ttf/.ttc/.otf)")
|
||||||
|
ap.add_argument("-o", "--output", type=Path, required=True,
|
||||||
|
help="输出 PNG 路径")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
render(args.glyph, args.color, args.size, args.output,
|
||||||
|
args.font, args.padding)
|
||||||
|
size_kb = args.output.stat().st_size / 1024
|
||||||
|
print(f"[ok] {args.output} ({size_kb:.1f} KB)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
"""source_to_md.py: 把素材转成干净 Markdown,作为后续策略阶段的输入。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python source_to_md.py <input> # 自动按扩展名识别
|
||||||
|
python source_to_md.py <url> # http/https 走 web 抓
|
||||||
|
python source_to_md.py file.pdf -o source.md
|
||||||
|
|
||||||
|
支持:
|
||||||
|
.pdf → pypdf 提取文本
|
||||||
|
.docx → python-docx 段落
|
||||||
|
.pptx → python-pptx 提取每页文字
|
||||||
|
.txt/.md → 直读
|
||||||
|
URL → requests + 简易 HTML 剥离
|
||||||
|
|
||||||
|
设计原则:模型在策略阶段只看 Markdown,不读二进制 / 不爬复杂排版。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def from_pdf(path: Path) -> str:
|
||||||
|
try:
|
||||||
|
from pypdf import PdfReader
|
||||||
|
except ImportError:
|
||||||
|
return "[error] pip install pypdf"
|
||||||
|
reader = PdfReader(str(path))
|
||||||
|
parts = [f"# {path.stem}\n"]
|
||||||
|
for i, page in enumerate(reader.pages, 1):
|
||||||
|
text = (page.extract_text() or "").strip()
|
||||||
|
if text:
|
||||||
|
parts.append(f"\n## Page {i}\n\n{text}\n")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def from_docx(path: Path) -> str:
|
||||||
|
try:
|
||||||
|
from docx import Document
|
||||||
|
except ImportError:
|
||||||
|
return "[error] pip install python-docx"
|
||||||
|
doc = Document(str(path))
|
||||||
|
parts = [f"# {path.stem}\n"]
|
||||||
|
for para in doc.paragraphs:
|
||||||
|
text = para.text.strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
style = (para.style.name or "").lower() if para.style else ""
|
||||||
|
if "heading 1" in style:
|
||||||
|
parts.append(f"\n## {text}\n")
|
||||||
|
elif "heading 2" in style:
|
||||||
|
parts.append(f"\n### {text}\n")
|
||||||
|
elif "heading 3" in style:
|
||||||
|
parts.append(f"\n#### {text}\n")
|
||||||
|
else:
|
||||||
|
parts.append(f"\n{text}\n")
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def from_pptx(path: Path) -> str:
|
||||||
|
try:
|
||||||
|
from pptx import Presentation
|
||||||
|
except ImportError:
|
||||||
|
return "[error] pip install python-pptx"
|
||||||
|
prs = Presentation(str(path))
|
||||||
|
parts = [f"# {path.stem}\n"]
|
||||||
|
for i, slide in enumerate(prs.slides, 1):
|
||||||
|
parts.append(f"\n## Slide {i}\n")
|
||||||
|
for shape in slide.shapes:
|
||||||
|
if shape.has_text_frame:
|
||||||
|
txt = shape.text_frame.text.strip()
|
||||||
|
if txt:
|
||||||
|
parts.append(f"\n{txt}\n")
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def from_text(path: Path) -> str:
|
||||||
|
return path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
_TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
_WS_RE = re.compile(r"\n{3,}")
|
||||||
|
|
||||||
|
|
||||||
|
def from_url(url: str) -> str:
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
return "[error] pip install requests"
|
||||||
|
r = requests.get(url, timeout=30, headers={
|
||||||
|
"User-Agent": "Mozilla/5.0 (compatible; ppt-source-to-md/1.0)"
|
||||||
|
})
|
||||||
|
r.raise_for_status()
|
||||||
|
html = r.text
|
||||||
|
|
||||||
|
# 极简剥离:script/style 删,标签去除
|
||||||
|
html = re.sub(r"<script[\s\S]*?</script>", "", html, flags=re.I)
|
||||||
|
html = re.sub(r"<style[\s\S]*?</style>", "", html, flags=re.I)
|
||||||
|
|
||||||
|
title_m = re.search(r"<title[^>]*>([^<]+)</title>", html, re.I)
|
||||||
|
title = title_m.group(1).strip() if title_m else url
|
||||||
|
|
||||||
|
# 块级标签转换行
|
||||||
|
html = re.sub(r"</?(p|div|br|li|h[1-6]|tr)[^>]*>", "\n", html, flags=re.I)
|
||||||
|
text = _TAG_RE.sub("", html)
|
||||||
|
text = re.sub(r" ", " ", text)
|
||||||
|
text = re.sub(r"&", "&", text)
|
||||||
|
text = re.sub(r"<", "<", text)
|
||||||
|
text = re.sub(r">", ">", text)
|
||||||
|
text = re.sub(r""", '"', text)
|
||||||
|
text = "\n".join(line.strip() for line in text.splitlines())
|
||||||
|
text = _WS_RE.sub("\n\n", text).strip()
|
||||||
|
|
||||||
|
return f"# {title}\n\nSource: {url}\n\n{text}\n"
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch(src: str) -> str:
|
||||||
|
parsed = urlparse(src)
|
||||||
|
if parsed.scheme in ("http", "https"):
|
||||||
|
return from_url(src)
|
||||||
|
|
||||||
|
path = Path(src)
|
||||||
|
if not path.exists():
|
||||||
|
return f"[error] not found: {src}"
|
||||||
|
|
||||||
|
ext = path.suffix.lower()
|
||||||
|
if ext == ".pdf":
|
||||||
|
return from_pdf(path)
|
||||||
|
if ext == ".docx":
|
||||||
|
return from_docx(path)
|
||||||
|
if ext == ".pptx":
|
||||||
|
return from_pptx(path)
|
||||||
|
if ext in (".txt", ".md"):
|
||||||
|
return from_text(path)
|
||||||
|
return f"[error] unsupported extension: {ext}"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("src", help="文件路径或 http(s) URL")
|
||||||
|
ap.add_argument("-o", "--output", type=Path, default=None,
|
||||||
|
help="写到文件;默认打印到 stdout")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
md = dispatch(args.src)
|
||||||
|
if args.output:
|
||||||
|
args.output.write_text(md, encoding="utf-8")
|
||||||
|
print(f"[ok] {args.output} ({len(md)} chars)")
|
||||||
|
else:
|
||||||
|
sys.stdout.write(md)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
---
|
||||||
|
name: proposal
|
||||||
|
description: 撰写科研申报书/课题立项书 (国自然、省基金、横向项目、校级课题)。当用户要写课题申请、立项依据、研究计划、技术路线、本子时使用。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 科研申报书
|
||||||
|
|
||||||
|
## 工作流
|
||||||
|
1. **对齐课题信息**: 不要急着写正文。先与用户确认并落到 `project.md`:
|
||||||
|
- 研究方向 / 拟解决的关键科学问题
|
||||||
|
- 创新点 (3 条以内,要"小而尖")
|
||||||
|
- 技术路线骨架
|
||||||
|
- 应用场景与受众
|
||||||
|
2. **分章节起草**: 每章一个 .md 文件,不要一次性出全文 —— 单章写完先给用户看,定调后再下一章
|
||||||
|
3. **合并定稿**: 用 `run_python` + python-docx,把各章节 md 套模板渲染成 .docx
|
||||||
|
|
||||||
|
## 工作目录约定
|
||||||
|
```
|
||||||
|
proposal/
|
||||||
|
├── project.md # 课题信息卡片
|
||||||
|
├── sections/
|
||||||
|
│ ├── 01_background.md # 立项依据
|
||||||
|
│ ├── 02_objectives.md # 研究内容与目标
|
||||||
|
│ ├── 03_method.md # 拟采取的研究方案
|
||||||
|
│ ├── 04_innovation.md # 特色与创新
|
||||||
|
│ ├── 05_basis.md # 研究基础
|
||||||
|
│ └── 06_cv.md # 申请人简介
|
||||||
|
└── proposal.docx # 最终输出
|
||||||
|
```
|
||||||
|
|
||||||
|
## 字数(国自然青年示例,其他基金按实际套)
|
||||||
|
| 章节 | 推荐字数 |
|
||||||
|
|-----|---------|
|
||||||
|
| 立项依据 | 5000-8000 |
|
||||||
|
| 研究内容与目标 | 2000-3000 |
|
||||||
|
| 研究方案 | 3000-5000 |
|
||||||
|
| 特色与创新 | 800-1500 |
|
||||||
|
| 研究基础 | 1500-2500 |
|
||||||
|
| 申请人简介 | 1000-2000 |
|
||||||
|
|
||||||
|
超出/不足都不专业,严格控字数。
|
||||||
|
|
||||||
|
## 硬规则
|
||||||
|
- **文献必须真实**: 不可编造作者、年份、DOI、期刊。需要引用先告诉用户来源,让用户提供文献清单
|
||||||
|
- **GB/T 7714 顺序编码制**: 引文 [1][2][3]...,文末参考文献顺序对应
|
||||||
|
- **不堆形容词**: "首次提出""填补空白""国际领先" 一律不用,除非用户明确要这种话术
|
||||||
|
- **逻辑先行**: 立项依据按 "现状 → 问题 → 本课题切入点" 三段式;研究方案按 "目标 → 任务分解 → 技术路线 → 可行性"
|
||||||
|
|
||||||
|
## 合并 docx 模板
|
||||||
|
```python
|
||||||
|
from docx import Document
|
||||||
|
from docx.shared import Pt
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
doc = Document()
|
||||||
|
style = doc.styles['Normal']
|
||||||
|
style.font.name = '宋体'
|
||||||
|
style.font.size = Pt(12)
|
||||||
|
|
||||||
|
sections = sorted(Path("proposal/sections").glob("*.md"))
|
||||||
|
for sec in sections:
|
||||||
|
text = sec.read_text(encoding="utf-8")
|
||||||
|
for para in text.split("\n\n"):
|
||||||
|
para = para.strip()
|
||||||
|
if not para:
|
||||||
|
continue
|
||||||
|
if para.startswith("# "):
|
||||||
|
doc.add_heading(para[2:], level=1)
|
||||||
|
elif para.startswith("## "):
|
||||||
|
doc.add_heading(para[3:], level=2)
|
||||||
|
else:
|
||||||
|
doc.add_paragraph(para)
|
||||||
|
doc.save("proposal/proposal.docx")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 反模式
|
||||||
|
- 用户没给课题就开始硬编内容
|
||||||
|
- 一次性出全文 (用户没法迭代)
|
||||||
|
- 引文里写 "[Smith et al., 2023]" 但其实根本没这篇文献
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""Tool 基类: 子类只需声明 name/description/parameters 和 execute。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Tool(ABC):
|
||||||
|
name: str = ""
|
||||||
|
description: str = ""
|
||||||
|
parameters: dict = {}
|
||||||
|
|
||||||
|
def __init__(self, base_dir: Optional[Path] = None) -> None:
|
||||||
|
self.base_dir: Path = Path(base_dir) if base_dir else Path.cwd()
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def execute(self, **kwargs) -> str:
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def schema(self) -> dict:
|
||||||
|
return {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"parameters": self.parameters,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _resolve(self, path: str) -> Path:
|
||||||
|
p = Path(path)
|
||||||
|
return p if p.is_absolute() else (self.base_dir / p)
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
"""文件系统工具: read / write / edit / glob / grep。
|
||||||
|
|
||||||
|
edit 工具采用 CoreCoder 的"唯一匹配"约束: old_str 必须在文件中出现且仅出现一次,
|
||||||
|
否则报错——这是防止 LLM 改错地方的业界最佳实践。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .base import Tool
|
||||||
|
|
||||||
|
|
||||||
|
class ReadTool(Tool):
|
||||||
|
name = "read"
|
||||||
|
description = (
|
||||||
|
"Read a text file. Returns content with 1-indexed line numbers. "
|
||||||
|
"Use offset/limit for large files."
|
||||||
|
)
|
||||||
|
parameters = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string", "description": "Absolute or relative file path"},
|
||||||
|
"offset": {"type": "integer", "description": "Start line (1-indexed)", "default": 1},
|
||||||
|
"limit": {"type": "integer", "description": "Max lines", "default": 2000},
|
||||||
|
},
|
||||||
|
"required": ["path"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self, path: str, offset: int = 1, limit: int = 2000) -> str:
|
||||||
|
p = self._resolve(path)
|
||||||
|
if not p.exists():
|
||||||
|
return f"[Error] file not found: {p}"
|
||||||
|
if not p.is_file():
|
||||||
|
return f"[Error] not a file: {p}"
|
||||||
|
try:
|
||||||
|
text = p.read_text(encoding="utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return f"[Error] not a UTF-8 text file: {p}"
|
||||||
|
|
||||||
|
lines = text.split("\n")
|
||||||
|
start = max(1, offset)
|
||||||
|
end = min(len(lines), start + limit - 1)
|
||||||
|
out = [f"{i+1:6d}\t{lines[i]}" for i in range(start - 1, end)]
|
||||||
|
header = f"[{p}] lines {start}-{end} of {len(lines)}\n"
|
||||||
|
return header + "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
class WriteTool(Tool):
|
||||||
|
name = "write"
|
||||||
|
description = (
|
||||||
|
"Write content to a file (creates parent dirs, overwrites if exists). "
|
||||||
|
"Prefer 'edit' for modifying existing files."
|
||||||
|
)
|
||||||
|
parameters = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string"},
|
||||||
|
"content": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["path", "content"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self, path: str, content: str) -> str:
|
||||||
|
p = self._resolve(path)
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
p.write_text(content, encoding="utf-8")
|
||||||
|
return f"[wrote {len(content)} chars to {p}]"
|
||||||
|
|
||||||
|
|
||||||
|
class EditTool(Tool):
|
||||||
|
name = "edit"
|
||||||
|
description = (
|
||||||
|
"Replace a unique string in a file. old_str MUST occur exactly once in the file, "
|
||||||
|
"otherwise the call fails. Include enough surrounding context to make it unique."
|
||||||
|
)
|
||||||
|
parameters = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string"},
|
||||||
|
"old_str": {"type": "string", "description": "Exact substring to replace, must be unique"},
|
||||||
|
"new_str": {"type": "string", "description": "Replacement string"},
|
||||||
|
},
|
||||||
|
"required": ["path", "old_str", "new_str"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self, path: str, old_str: str, new_str: str) -> str:
|
||||||
|
p = self._resolve(path)
|
||||||
|
if not p.exists():
|
||||||
|
return f"[Error] file not found: {p}"
|
||||||
|
content = p.read_text(encoding="utf-8")
|
||||||
|
count = content.count(old_str)
|
||||||
|
if count == 0:
|
||||||
|
return f"[Error] old_str not found in {p}"
|
||||||
|
if count > 1:
|
||||||
|
return f"[Error] old_str appears {count} times in {p}, must be unique — add more context"
|
||||||
|
p.write_text(content.replace(old_str, new_str), encoding="utf-8")
|
||||||
|
return f"[edited {p}: 1 replacement]"
|
||||||
|
|
||||||
|
|
||||||
|
class GlobTool(Tool):
|
||||||
|
name = "glob"
|
||||||
|
description = "Find files by glob pattern (e.g. '**/*.py', 'src/*.md'). Returns up to 200 paths."
|
||||||
|
parameters = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {"type": "string"},
|
||||||
|
"path": {"type": "string", "description": "Base directory (default: cwd)", "default": "."},
|
||||||
|
},
|
||||||
|
"required": ["pattern"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self, pattern: str, path: str = ".") -> str:
|
||||||
|
base = self._resolve(path)
|
||||||
|
if not base.exists():
|
||||||
|
return f"[Error] base path not found: {base}"
|
||||||
|
# 把 '**/' 前缀的递归交给 rglob,其他用 glob
|
||||||
|
if "**" in pattern:
|
||||||
|
matches = sorted(str(p) for p in base.glob(pattern))
|
||||||
|
else:
|
||||||
|
matches = sorted(str(p) for p in base.glob(pattern))
|
||||||
|
if not matches:
|
||||||
|
return f"[no matches for '{pattern}' under {base}]"
|
||||||
|
return "\n".join(matches[:200])
|
||||||
|
|
||||||
|
|
||||||
|
class GrepTool(Tool):
|
||||||
|
name = "grep"
|
||||||
|
description = "Search a regex in files. Returns up to 200 'path:line:content' lines."
|
||||||
|
parameters = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {"type": "string", "description": "Python regex"},
|
||||||
|
"path": {"type": "string", "default": "."},
|
||||||
|
"glob": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "File glob filter, e.g. '*.py' or '**/*.md'",
|
||||||
|
"default": "",
|
||||||
|
},
|
||||||
|
"ignore_case": {"type": "boolean", "default": False},
|
||||||
|
},
|
||||||
|
"required": ["pattern"],
|
||||||
|
}
|
||||||
|
|
||||||
|
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build"}
|
||||||
|
|
||||||
|
def execute(self, pattern: str, path: str = ".", glob: str = "", ignore_case: bool = False) -> str:
|
||||||
|
base = self._resolve(path)
|
||||||
|
if not base.exists():
|
||||||
|
return f"[Error] base path not found: {base}"
|
||||||
|
flags = re.IGNORECASE if ignore_case else 0
|
||||||
|
try:
|
||||||
|
regex = re.compile(pattern, flags)
|
||||||
|
except re.error as e:
|
||||||
|
return f"[Error] invalid regex: {e}"
|
||||||
|
|
||||||
|
if glob:
|
||||||
|
files = list(base.glob(glob)) if "**" in glob else list(base.rglob(glob))
|
||||||
|
else:
|
||||||
|
files = list(base.rglob("*"))
|
||||||
|
|
||||||
|
matches: list[str] = []
|
||||||
|
for f in files:
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
if any(part in self.SKIP_DIRS for part in f.parts):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
text = f.read_text(encoding="utf-8")
|
||||||
|
except (UnicodeDecodeError, OSError):
|
||||||
|
continue
|
||||||
|
for i, line in enumerate(text.split("\n"), 1):
|
||||||
|
if regex.search(line):
|
||||||
|
matches.append(f"{f}:{i}:{line}")
|
||||||
|
if len(matches) >= 200:
|
||||||
|
break
|
||||||
|
if len(matches) >= 200:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
return f"[no matches for /{pattern}/ in {base}]"
|
||||||
|
return "\n".join(matches)
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
"""run_python: 在 subprocess 里执行 Python 代码 (Hybrid 范式的关键)。
|
||||||
|
|
||||||
|
JSON tool call 处理离散操作 (read/edit/shell),run_python 处理连续逻辑
|
||||||
|
(数据计算、批处理、生成 .pptx/.docx)。让模型自己挑工具。
|
||||||
|
|
||||||
|
阶段 1 (本地): subprocess + 工作目录限制 + 敏感环境变量过滤
|
||||||
|
阶段 2 (后期): Docker / E2B 替换执行后端
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .base import Tool
|
||||||
|
|
||||||
|
_SENSITIVE_PATTERNS = ("API_KEY", "TOKEN", "SECRET", "PASSWORD", "PRIVATE_KEY")
|
||||||
|
|
||||||
|
|
||||||
|
class RunPythonTool(Tool):
|
||||||
|
name = "run_python"
|
||||||
|
description = (
|
||||||
|
"Execute Python code in a subprocess. Returns stdout/stderr/exit_code.\n"
|
||||||
|
"Use for: data analysis, batch file ops, document generation (.pptx/.docx), "
|
||||||
|
"matplotlib charts, or any task where Python is more natural than chaining tools.\n"
|
||||||
|
"Working directory is the agent's base dir. Files you create persist there.\n"
|
||||||
|
"Available libs (install with shell pip if missing): "
|
||||||
|
"pandas, numpy, matplotlib, python-pptx, python-docx, requests, pypdf."
|
||||||
|
)
|
||||||
|
parameters = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Python source. Anything written to stdout is returned.",
|
||||||
|
},
|
||||||
|
"timeout": {"type": "integer", "default": 120, "description": "Seconds before kill"},
|
||||||
|
},
|
||||||
|
"required": ["code"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self, code: str, timeout: int = 120) -> str:
|
||||||
|
# 写到临时文件,避免 -c 转义问题
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
suffix=".py", mode="w", delete=False, encoding="utf-8"
|
||||||
|
) as f:
|
||||||
|
f.write(code)
|
||||||
|
script_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
env = os.environ.copy()
|
||||||
|
for k in list(env):
|
||||||
|
u = k.upper()
|
||||||
|
if any(p in u for p in _SENSITIVE_PATTERNS):
|
||||||
|
del env[k]
|
||||||
|
env["PYTHONIOENCODING"] = "utf-8"
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, script_path],
|
||||||
|
cwd=str(self.base_dir),
|
||||||
|
capture_output=True,
|
||||||
|
timeout=timeout,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return f"[Error] python script timed out after {timeout}s"
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
Path(script_path).unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if result.stdout:
|
||||||
|
parts.append(f"[stdout]\n{result.stdout.rstrip()}")
|
||||||
|
if result.stderr:
|
||||||
|
parts.append(f"[stderr]\n{result.stderr.rstrip()}")
|
||||||
|
parts.append(f"[exit {result.returncode}]")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""Shell 执行: subprocess 跑命令,有黑名单拦明显危险操作。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from .base import Tool
|
||||||
|
|
||||||
|
|
||||||
|
class ShellTool(Tool):
|
||||||
|
name = "shell"
|
||||||
|
description = (
|
||||||
|
"Execute a shell command and return stdout/stderr/exit_code. "
|
||||||
|
"Default 60s timeout. Working directory is the agent's base dir."
|
||||||
|
)
|
||||||
|
parameters = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {"type": "string"},
|
||||||
|
"timeout": {"type": "integer", "default": 60, "description": "Seconds before kill"},
|
||||||
|
},
|
||||||
|
"required": ["command"],
|
||||||
|
}
|
||||||
|
|
||||||
|
BLOCKED_PATTERNS = (
|
||||||
|
"rm -rf /",
|
||||||
|
"rm -rf ~",
|
||||||
|
"rm -rf $HOME",
|
||||||
|
":(){ :|:& };:",
|
||||||
|
"mkfs",
|
||||||
|
"dd if=/dev/zero",
|
||||||
|
"> /dev/sda",
|
||||||
|
"format c:",
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute(self, command: str, timeout: int = 60) -> str:
|
||||||
|
normalized = command.lower()
|
||||||
|
for pat in self.BLOCKED_PATTERNS:
|
||||||
|
if pat in normalized:
|
||||||
|
return f"[Error] blocked dangerous command pattern: {pat!r}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
shell=True,
|
||||||
|
cwd=str(self.base_dir),
|
||||||
|
capture_output=True,
|
||||||
|
timeout=timeout,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return f"[Error] command timed out after {timeout}s"
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
return f"[Error] {e}"
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if result.stdout:
|
||||||
|
parts.append(f"[stdout]\n{result.stdout.rstrip()}")
|
||||||
|
if result.stderr:
|
||||||
|
parts.append(f"[stderr]\n{result.stderr.rstrip()}")
|
||||||
|
parts.append(f"[exit {result.returncode}]")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
"""load_skill 工具: agent 主动加载某个 skill 的完整 SKILL.md。
|
||||||
|
|
||||||
|
这是渐进披露的中间层 (Activation): 启动时只看到 name+description,
|
||||||
|
认为自己要做某类任务时,调 load_skill 拿到完整指引。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from core.skills import SkillRegistry
|
||||||
|
|
||||||
|
from .base import Tool
|
||||||
|
|
||||||
|
|
||||||
|
class LoadSkillTool(Tool):
|
||||||
|
name = "load_skill"
|
||||||
|
description = (
|
||||||
|
"Load full instructions for a skill. Call this when the current task matches "
|
||||||
|
"a skill's domain (e.g. writing PPT, coding, research proposal). "
|
||||||
|
"Returns the SKILL.md body, which may reference further files in skills/<name>/references."
|
||||||
|
)
|
||||||
|
parameters = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Skill name as listed in the system prompt's discovery block",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, registry: SkillRegistry, base_dir: Optional[Path] = None) -> None:
|
||||||
|
super().__init__(base_dir)
|
||||||
|
self.registry = registry
|
||||||
|
|
||||||
|
def execute(self, name: str) -> str:
|
||||||
|
skill = self.registry.get(name)
|
||||||
|
if skill is None:
|
||||||
|
available = ", ".join(self.registry.skills.keys()) or "(none)"
|
||||||
|
return f"[Error] skill '{name}' not found. Available: {available}"
|
||||||
|
body = skill.full_content()
|
||||||
|
header = f"[skill={skill.name}, dir={skill.skill_dir}]\n"
|
||||||
|
return header + body
|
||||||
Loading…
Reference in New Issue