commit 3a668499533de16c51660f3bf76c710e1fadca5f Author: caoqianming Date: Wed May 6 11:02:59 2026 +0800 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37cb962 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..20e90f5 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,1140 @@ +# 个人任务 Agent 设计方案 v2 + +> 一个面向"写汇报 PPT、写科研申报书、写代码"三类任务的轻量级 agent 框架。 +> 完全自实现,综合 nanobot / CoreCoder / better-claw / smolagents 的优点,模型自由,**为模型持续升级做了演化性设计**。 + +> **v2 更新要点(2026-05)**: +> 1. 模型策略基于 DeepSeek V4 实际能力(2026-04-24 发布)重新设计 +> 2. Skill 系统对齐 Anthropic 开放标准(2025-12 发布,行业已成共识) +> 3. 工具范式改为 Hybrid:JSON tool call + run_python 混合 +> 4. 新增第 6 章「演化性设计」—— 让 agent 跟着模型升级而升级 +> 5. 新增 Eval Suite 框架,作为模型升级的决策依据 + +--- + +## 1. 设计目标与边界 + +### 1.1 要做什么 + +构建一个本地运行的任务型 agent,能稳定完成三类工作: + +| 任务模式 | 输入示例 | 输出 | 主要能力 | +|---------|---------|------|---------| +| **PPT 模式** | "把这份会议纪要做成 5 页汇报 PPT" | `.pptx` 文件 | 大纲提炼、版式设计、图表生成 | +| **科研申报模式** | "写一份国自然青年基金的立项依据" | `.docx` 文件(分章节) | 长文写作、文献检索、格式套模板 | +| **编码模式** | "修这个文件的 bug" / "实现这个函数" | 修改后的代码 | 文件编辑、shell 执行、迭代验证 | + +### 1.2 明确不做什么 + +- ❌ 子 agent / subagent +- ❌ IM 渠道(Telegram / WeChat / Discord 等) +- ❌ 多用户系统 +- ❌ Web UI(初期 CLI 即可) +- ❌ 自定义 RAG / 向量检索 +- ❌ Anthropic 锁定(必须模型自由) + +### 1.3 关键约束 + +- **模型自由**:支持 DeepSeek V4 / Kimi / Qwen / GPT / Claude 等任意 OpenAI-compatible API +- **代码可控**:总代码量 1100-1500 行,自己能完全读懂 +- **任务持久化**:任意时刻关机,下次能恢复 +- **长任务稳定**:单任务可跑数小时不崩 +- **演化性**:模型升级时 agent 能力随之升级,不需要大改架构 + +--- + +## 2. 各家方案借鉴清单 + +| 借鉴自 | 借鉴的设计 | 为什么抄 | +|--------|-----------|---------| +| **CoreCoder** | 主 agent loop 简洁实现 | ~150 行写完核心,可读性极高 | +| **CoreCoder** | Edit 工具的"唯一匹配"约束 | 防止 LLM 改错地方,业界最佳实践 | +| **CoreCoder** | 三层 context 压缩(简化版,V4 时代不太用) | 兜底方案 | +| **Anthropic Agent Skills** | **SKILL.md + 渐进披露 标准** | 行业标准,2025-12 开放,跨平台兼容 | +| **nanobot** | Workspace + 任务隔离的目录结构 | 多任务并行不互相污染 | +| **nanobot** | 双层记忆(core + extended) | core 注入 prompt,extended 按需读 | +| **better-claw** | Mid-query 轮转 + carryover | 长任务工程兜底 | +| **better-claw** | 任务状态持久化(state.json) | 支持中断恢复 | +| **smolagents** | LiteLLM 做模型层 | 一行切换 25+ provider | +| **smolagents** | `@tool` 装饰器风格 | 工具定义最简洁 | +| **smolagents** | **CodeAgent 思路(部分采用)** | 通过 `run_python` 工具实现 hybrid 范式 | +| **CodeAct 论文** | 代码作为 action 的范式 | 在数据/计算/批处理任务上比 JSON 强 20% | + +--- + +## 3. 整体架构 + +### 3.1 分层结构 + +``` +┌──────────────────────────────────────────────┐ +│ 入口层 │ +│ - CLI(初期):interactive REPL │ +│ - 后期可加:Streamlit / Gradio Web UI │ +└──────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────┐ +│ 任务路由层 │ +│ - /ppt → PPT 模式 + ppt skill │ +│ - /doc → 科研写作模式 + proposal skill │ +│ - /code → 编码模式 + coding skill │ +│ - 默认 → 通用模式 │ +└──────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────┐ +│ Agent 核心 │ +│ - Loop:ReAct 循环(LLM ↔ Tool) │ +│ - Capability Manager:模型能力探测与适配 │ +│ - Context Manager:三层压缩(必要时) │ +│ - Session:对话历史持久化 │ +│ - Memory:双层记忆系统 │ +└──────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────┐ +│ 工具层(Hybrid 范式) │ +│ 通用工具(JSON tool call): │ +│ - read / write / edit / glob / grep / shell │ +│ - web_search / web_fetch │ +│ - load_skill │ +│ - run_python(沙盒执行,Hybrid 关键) │ +│ │ +│ Skill 提供的工具(标准格式): │ +│ - skills//scripts/*.py │ +└──────────────────────────────────────────────┘ + ↓ +┌──────────────────────────────────────────────┐ +│ LLM 层(LiteLLM + Model Profile) │ +│ - 默认:DeepSeek V4-Flash │ +│ - 升级:DeepSeek V4-Pro / Claude Opus 4.7 │ +│ - Profile 化配置,新模型 5 分钟接入 │ +└──────────────────────────────────────────────┘ +``` + +### 3.2 目录结构 + +``` +your_agent/ +├── core/ +│ ├── __init__.py +│ ├── loop.py # 主 agent loop ~150 行 +│ ├── llm.py # LiteLLM 封装 ~120 行 +│ ├── capabilities.py # 模型能力探测与适配 ~100 行 ⭐新增 +│ ├── context.py # 三层 context 压缩 ~150 行 +│ ├── session.py # 会话持久化 ~100 行 +│ └── memory.py # 双层记忆 ~80 行 +├── tools/ +│ ├── __init__.py +│ ├── base.py # Tool 基类 ~50 行 +│ ├── fs.py # read/write/edit/glob/grep ~250 行 +│ ├── shell.py # bash 执行 + 安全检查 ~80 行 +│ ├── web.py # web_search + fetch ~80 行 +│ ├── run_python.py # 沙盒 Python 执行器 ~100 行 ⭐新增 +│ └── skill_tool.py # load_skill 工具 ~60 行 +├── skills/ # ⭐ 标准 Anthropic Agent Skills 格式 +│ ├── ppt/ +│ │ ├── SKILL.md # 主指引(短) +│ │ ├── references/ # 详细资料(按需读) +│ │ ├── scripts/ # 可执行脚本 +│ │ └── assets/ # 模板等资源 +│ ├── proposal/ +│ │ ├── SKILL.md +│ │ ├── references/ +│ │ ├── scripts/ +│ │ └── assets/ +│ └── coding/ +│ ├── SKILL.md +│ └── references/ +├── prompts/ # ⭐ 版本化系统提示词 +│ ├── system/ +│ │ ├── general_v1.md +│ │ └── general_active.md → general_v1.md +│ └── modes/ +│ ├── ppt.md +│ ├── proposal.md +│ └── coding.md +├── config/ +│ ├── agent.yaml # 主配置 +│ └── models/ # ⭐ 模型档案库 +│ ├── _template.yaml +│ ├── deepseek_v4.yaml +│ ├── claude_4_7.yaml +│ └── gpt_5.yaml +├── evals/ # ⭐ 评估任务集 +│ ├── coding/ +│ ├── ppt/ +│ ├── proposal/ +│ └── runner.py # eval 执行器 +├── workspace/ # 用户数据(gitignore) +│ ├── tasks/ +│ ├── memory/ +│ │ ├── core.md +│ │ └── extended/ +│ └── logs/ +├── cli.py # CLI 入口 ~150 行 +├── main.py # 装配 + 启动 ~50 行 +├── requirements.txt +└── README.md +``` + +**总代码量预估:1300-1600 行 Python**(比 v1 多 ~200 行,因为加了 capabilities 和 run_python) + +--- + +## 4. 核心组件设计 + +### 4.1 Agent Loop(`core/loop.py`) + +```python +class AgentLoop: + def __init__(self, llm, tools, capabilities, context_manager, session, + max_iterations=None): + self.llm = llm + self.tools = tools + self.caps = capabilities # ⭐ 模型能力档案 + self.ctx = context_manager + self.session = session + # 迭代次数从 capabilities 读取,不同模型不同 + self.max_iterations = max_iterations or self.caps.max_iterations + + def run(self, user_message: str) -> str: + self.session.append({"role": "user", "content": user_message}) + + for iteration in range(self.max_iterations): + messages = self.ctx.check_and_compress(self.session.messages) + + response = self.llm.chat( + messages=messages, + tools=[t.schema for t in self.tools.values()], + # ⭐ 高级特性按 capabilities 启用 + parallel_tool_calls=self.caps.parallel_tools, + reasoning_effort=self.caps.default_reasoning_effort + ) + msg = response.choices[0].message + self.session.append(msg) + + if not msg.tool_calls: + return msg.content + + # ⭐ 并行 vs 串行执行根据能力决定 + if self.caps.parallel_tools and len(msg.tool_calls) > 1: + results = self._execute_tools_parallel(msg.tool_calls) + else: + results = self._execute_tools_serial(msg.tool_calls) + + for tool_call, result in results: + self.session.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": result + }) + + return "[已达到最大迭代次数]" +``` + +### 4.2 Model Profile + Capabilities(`core/capabilities.py`)⭐ 新增 + +**核心思想**:每个模型有自己的能力档案,agent 行为根据档案动态调整。新模型出来,加一个 yaml 即可。 + +```python +@dataclass +class ModelCapabilities: + """模型能力档案""" + model_id: str + + # 基础能力 + max_context: int # 最大上下文(tokens) + reliable_context: int # 实测可靠上下文 + max_output: int # 最大输出 + + # Tool calling + parallel_tools: bool # 是否支持并行 tool call + tool_calling_quality: str # "excellent" / "good" / "fair" + + # 思考模式 + thinking_mode: bool # 是否支持思考模式 + reasoning_effort_levels: list # ["low","medium","high","max"] + default_reasoning_effort: str + + # 推理与代码 + code_quality: str # CodeAct 范式适配度 + enable_run_python: bool # 是否启用 run_python 工具 + + # 工程参数 + max_iterations: int # 最大迭代次数 + optimal_temperature: float + + # 特殊功能 + prompt_caching: bool # Anthropic 特有 + extended_thinking: bool # Claude 4.x 特有 + + @classmethod + def from_yaml(cls, path: Path) -> "ModelCapabilities": + """从 config/models/*.yaml 加载""" + ... + + @classmethod + def detect(cls, model_id: str) -> "ModelCapabilities": + """根据 model_id 自动找到对应档案 + deepseek-v4-flash → config/models/deepseek_v4.yaml (variant=flash) + claude-opus-4-7 → config/models/claude_4_7.yaml + """ + ... +``` + +**模型档案示例** (`config/models/deepseek_v4.yaml`): + +```yaml +family: deepseek_v4 +variants: + flash: + model_id: deepseek/deepseek-v4-flash + max_context: 1048576 + reliable_context: 262144 + max_output: 384000 + parallel_tools: true + tool_calling_quality: good + thinking_mode: true + reasoning_effort_levels: [non_thinking, thinking] + default_reasoning_effort: non_thinking + 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 + max_context: 1048576 + reliable_context: 524288 + max_output: 384000 + 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 +``` + +**Claude 4.7 档案** (`config/models/claude_4_7.yaml`): + +```yaml +family: claude_4_7 +variants: + opus: + model_id: anthropic/claude-opus-4-7 + max_context: 200000 + reliable_context: 200000 + max_output: 8192 + parallel_tools: true + tool_calling_quality: excellent + thinking_mode: true + reasoning_effort_levels: [low, medium, high] + default_reasoning_effort: medium + code_quality: excellent + enable_run_python: true + max_iterations: 100 + optimal_temperature: 0.2 + prompt_caching: true # Claude 特有 + extended_thinking: true +``` + +**新模型怎么加(以未来 V5 为例)**: + +```bash +# 1. 复制模板 +cp config/models/_template.yaml config/models/deepseek_v5.yaml + +# 2. 填能力(从模型发布博客 + 跑一次 capability probe) +vim config/models/deepseek_v5.yaml + +# 3. 跑 eval suite 验证 +python evals/runner.py --model deepseek-v5-pro + +# 4. 切换默认模型 +vim config/agent.yaml # default_model: deepseek-v5-pro +``` + +整个流程不需要改任何 agent 核心代码。 + +### 4.3 LLM 封装(`core/llm.py`) + +```python +class LLM: + def __init__(self, capabilities: ModelCapabilities, api_key: str, base_url: str = None): + self.caps = capabilities + self.api_key = api_key + self.base_url = base_url + self.token_counter = TokenCounter() + + def chat(self, messages, tools=None, parallel_tool_calls=None, + reasoning_effort=None, max_retries=3): + # 用 capabilities 自动填充默认值 + kwargs = { + "model": self.caps.model_id, + "messages": messages, + "tools": tools, + "temperature": self.caps.optimal_temperature, + "api_key": self.api_key, + "base_url": self.base_url, + } + + # 按能力启用 + 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"} + + for attempt in range(max_retries): + try: + response = litellm.completion(**kwargs) + self.token_counter.add(response.usage, self.caps.model_id) + return response + except (RateLimitError, APIConnectionError): + if attempt == max_retries - 1: + raise + time.sleep(2 ** attempt) +``` + +### 4.4 工具系统(Hybrid 范式) + +#### 4.4.1 通用工具(JSON tool call) + +文件操作工具的核心仍然是 **Edit 工具的"唯一匹配"约束**(借鉴 CoreCoder): + +```python +class EditTool(Tool): + name = "edit" + description = "Replace a unique string in a file with another string." + + def execute(self, path: str, old_str: str, new_str: str) -> str: + content = Path(path).read_text() + count = content.count(old_str) + if count == 0: + return f"[Error] old_str not found in {path}" + if count > 1: + return f"[Error] old_str appears {count} times, must be unique" + Path(path).write_text(content.replace(old_str, new_str)) + return self._make_diff(content, ...) +``` + +#### 4.4.2 `run_python` 工具 ⭐ 新增 + +**Hybrid 范式的关键**:agent 主要用 JSON tool call,但需要时可以写代码作为 action: + +```python +class RunPythonTool(Tool): + name = "run_python" + description = """Execute Python code in a sandboxed environment. + + Use for: + - Data analysis, statistics, calculations + - Batch file operations (process many files) + - Document generation (PPT, Word, charts) + - Tasks where code is more natural than tool composition + + Available libraries: pandas, numpy, matplotlib, python-pptx, python-docx, + arxiv, requests, pypdf, pdfplumber. + + Working directory is the current task's tasks//. + Files created here are automatically available to the user. + """ + + def execute(self, code: str, timeout: int = 60) -> str: + # 阶段 1(本地用):subprocess + venv + 工作目录限制 + # 阶段 2(更安全):Docker container + # 阶段 3(公开服务):E2B / Modal + + with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f: + f.write(code) + script_path = f.name + + try: + result = subprocess.run( + [sys.executable, script_path], + cwd=self.task_dir, # 限制工作目录 + capture_output=True, + timeout=timeout, + env=self._safe_env() # 过滤敏感环境变量 + ) + return self._format_result(result) + finally: + os.unlink(script_path) +``` + +**为什么这是关键设计**: +- **JSON tool call 处理离散操作**(读文件、跑命令、查文献) +- **Code execution 处理连续逻辑**(算数据、批处理、生成文档) +- 模型自己决定什么时候用什么,不是你硬编码 + +#### 4.4.3 工具粒度原则 ⭐ 新增 + +工具切分按"原子操作"原则,不做高级封装: + +```python +# ❌ 反模式:工具做太多,模型用不灵活 +class GenerateProposalTool: + def execute(self, topic): + # 内部硬编码 8 章节流程 + +# ✅ 正模式:原子操作,组合策略给模型 +class WriteSectionTool: # 写一节 +class CompileDocxTool: # 合并成 docx +class SearchPapersTool: # 查文献 +class FormatBibtexTool: # BibTeX 格式化 +``` + +**理由**:模型变强后会有更好的组合策略。封装太死接收不到模型升级的红利。 + +### 4.5 Skill 系统(对齐 Anthropic 开放标准)⭐ 重大调整 + +#### 4.5.1 标准目录结构 + +每个 skill 是一个目录,包含: + +``` +skills/proposal/ +├── SKILL.md # 主指引(短,~3000 tokens 内) +├── references/ # 详细资料(按需加载) +│ ├── nsfc_format.md +│ ├── citation_style.md +│ └── section_examples.md +├── scripts/ # 可执行脚本(可作为工具) +│ ├── search_papers.py +│ ├── format_bibtex.py +│ └── compile_docx.py +└── assets/ # 模板、字体等 + └── templates/ + ├── nsfc_youth.docx + ├── nsfc_general.docx + └── nsfc_key.docx +``` + +#### 4.5.2 SKILL.md 标准格式 + +```markdown +--- +name: proposal +description: 撰写科研申报书(国自然/省基金/横向项目)。当用户需要写课题申请、立项依据、项目书时使用。 +--- + +# 科研申报书 + +## 资源 +- `references/nsfc_format.md`:国自然格式细节 +- `references/citation_style.md`:引文规范 +- `references/section_examples.md`:各章节范例 +- `scripts/search_papers.py`:可执行,文献检索 +- `scripts/compile_docx.py`:可执行,合并章节为 docx +- `assets/templates/`:不同基金类型的 docx 模板 + +## 原则 +- 文献必须真实(用 search_papers,绝不编造) +- 分章节写,不一次性生成全文 +- 先与用户对齐课题信息卡片 + +## 工作目录 +所有产出在 `tasks//`: +- `project.md` - 课题信息卡片 +- `sections/.md` - 各章节草稿 +- `proposal.docx` - 最终输出 + +## 字数参考 +立项依据 5000-8000 字,研究内容 3000-5000 字。 +具体格式参见 references/nsfc_format.md。 +``` + +注意:**不再写"Step 1/2/3"流程**,只写资源、原则、目标。让模型自己规划。 + +#### 4.5.3 Progressive Disclosure(渐进披露)的三层加载 + +按 Anthropic 标准: + +| 层 | 时机 | 内容 | Token 占用 | +|---|------|------|----------| +| **Discovery** | Agent 启动 | 仅 `name + description`,所有 skill 都加载 | 几百 tokens | +| **Activation** | 任务匹配某个 skill | 完整 SKILL.md 主体 | 1000-5000 tokens | +| **Execution** | SKILL.md 引用某个 reference 时 | 单个 reference 文件 | 视情况 | + +具体实现: + +```python +# 启动时:Discovery +def build_initial_system_prompt(skills) -> str: + skill_descriptions = [] + for name, skill in skills.items(): + meta = parse_frontmatter(skill["SKILL.md"]) + skill_descriptions.append(f"- {name}: {meta['description']}") + + return f""" +You are a task agent. Available skills: +{chr(10).join(skill_descriptions)} + +Use `load_skill(name)` to load full instructions when relevant. +""" + +# Agent 调用 load_skill 后:Activation +class LoadSkillTool(Tool): + def execute(self, name: str) -> str: + return (skills_dir / name / "SKILL.md").read_text() + +# Agent 在 SKILL.md 里看到 references/xxx.md,主动调 read_file:Execution +# 这一层不需要专门工具,就是普通 read 工具 +``` + +#### 4.5.4 Skill 设计原则(基于 Anthropic 官方 + 行业经验) + +1. **Description 是关键** —— 决定模型能否触发,要明确具体 +2. **SKILL.md 主体不超过 5000 tokens / 500 行** —— 超过就拆 references/ +3. **写 WHY+WHAT,不写 HOW** —— 描述目标和资源,不写步骤 +4. **代码即工具又是文档** —— scripts/ 里的脚本可以执行,也可以读到 context 当文档 +5. **保持 Skill 数 ≤ 20**,工具数 ≤ 10 同时可见(超过后准确率下降) + +### 4.6 Context 管理(简化版) + +V4 时代 long context 性能好了很多,**大部分任务不再需要复杂压缩**。但保留三层兜底: + +```python +class ContextManager: + def __init__(self, capabilities, llm): + # ⭐ 阈值从 capabilities 读取,不同模型不同 + self.max_tokens = capabilities.reliable_context + self.soft = self.max_tokens * 0.6 # V4 长 context 强,提高阈值 + self.force = self.max_tokens * 0.85 + self.collapse = self.max_tokens * 0.95 + self.llm = llm + + def check_and_compress(self, messages): + tokens = count_tokens(messages) + if tokens < self.soft: + return messages + if tokens < self.force: + return self._snip_old_tool_results(messages) + if tokens < self.collapse: + return self._microcompact(messages) + return self._collapse(messages, llm=self.llm) +``` + +**实测预期**:DeepSeek V4-Pro 在 256K 内基本不触发任何压缩,写一份完整申报书(7-8 万 token)用 V4-Flash 也只触发到 soft 层。 + +### 4.7 Session 与 Memory + +(沿用 v1 设计,无重大变化) + +--- + +## 5. 模型路由策略(基于 V4 实际能力)⭐ 重大调整 + +### 5.1 默认配置:V4-Flash 当主力 + +```yaml +# config/agent.yaml +default_model: deepseek_v4.flash + +# 模式覆盖 +by_mode: + general: deepseek_v4.flash + + coding: deepseek_v4.flash # SWE-Bench 80.6,Flash 已够用 + coding_hard: deepseek_v4.pro # 复杂 bug、架构设计 + + ppt: deepseek_v4.flash # PPT 生成不需要顶级模型 + + proposal_draft: deepseek_v4.flash + proposal_final: + profile: deepseek_v4.pro + reasoning_effort: max # 终稿用最强模式 + +# 工具用模型(便宜) +utility: + summarize: deepseek_v4.flash + title: deepseek_v4.flash + +# 紧急升级路径(V4 不行时手动切) +fallback: + - claude_4_7.opus # 国基终稿如果质量不够,临时切 Claude +``` + +### 5.2 成本预估 + +| 任务 | V4-Flash | V4-Pro-Max | Claude Opus 4.7 | +|-----|---------|-----------|------------------| +| 修一个 bug(~10 轮) | $0.01 | $0.05 | $0.30 | +| 5 页汇报 PPT | $0.05 | $0.20 | $1.50 | +| 一份完整申报书(2-3 小时) | $0.30 | $1.50 | $10-15 | + +**结论**:99% 任务 V4-Flash 已够用,关键终稿可升级 Pro,Claude 仅作 fallback。 + +--- + +## 6. 演化性设计 ⭐ 新增章节 + +> **核心问题**:模型每 3-6 个月迭代一次,agent 怎么不被甩在后面? + +### 6.1 设计哲学 + +**Less Scaffolding, More Trust**(少脚手架,多信任) + +老 agent 框架(LangChain 早期、AutoGPT)失败的核心原因:**给 LLM 太多脚手架,模型升级后这些脚手架成了枷锁**。 + +参考反例: +- 强制 ReAct 三段式输出 —— GPT-4 出来后这种格式反而降智 +- PydanticOutputParser 死磕格式 —— Structured Output 内置后成了多此一举 +- Prompt 里详细教"应该怎么思考" —— 强模型不需要被教 + +**正确做法**:把 LLM 当一个**会持续变强的同事**对待,告诉它目标,不告诉它步骤。 + +### 6.2 七条具体原则 + +#### 原则 1:Prompt 用 WHY+WHAT,不用 HOW + +``` +❌ HOW 型: +"修 bug 时: +1. 先用 read 工具读文件 +2. 再用 grep 找相关位置 +3. 然后用 edit 工具替换 +4. 最后跑测试..." + +✅ WHY+WHAT 型: +"目标:修复用户报告的 bug,做最小可逆修改。 +工具:read, edit, grep, run, ... +原则:验证后再改、最小变更、有测试就跑。" +``` + +#### 原则 2:Skill 用渐进披露,不写完整流程 + +直接对齐 Anthropic 开放标准。Discovery 层只放 description,模型理解能力越强触发越准 —— 你不用回头给老 description 加 trigger 词。 + +#### 原则 3:工具按原子操作切分,不做高级封装 + +详见 4.4.3。粒度太粗,模型升级后没有施展空间。 + +#### 原则 4:Model Profile 化,不硬编码 + +详见 4.2。所有模型相关参数都在 yaml 里,新模型 5 分钟接入。 + +#### 原则 5:Capability Probing(启动时探测) + +```python +def probe_capabilities(llm) -> dict: + """启动时跑几个小测试,验证 yaml 里声称的能力""" + results = {} + + # 测试 1:并行 tool call 是否真的工作 + response = llm.chat([...], tools=[...test_tools]) + results["parallel_tools_actual"] = len(response.tool_calls) > 1 + + # 测试 2:thinking mode 输出是否符合预期 + response = llm.chat([...], reasoning_effort="medium") + results["thinking_works"] = hasattr(response.choices[0].message, "reasoning_content") + + # 测试 3:long context recall(简化版 needle in haystack) + needle = f"The secret code is {random_code()}." + haystack = make_long_context(needle, target_tokens=100_000) + response = llm.chat([{"role": "user", "content": haystack + "\nWhat is the secret code?"}]) + results["long_context_100k"] = random_code() in response.choices[0].message.content + + return results +``` + +发现实际能力跟 yaml 不符 → 警告并自动调整。 + +#### 原则 6:版本化 Prompt,支持 A/B 切换 + +``` +prompts/system/ +├── coding_v1.md # 老模型用的详细版 +├── coding_v2.md # 新模型用的精简版 +└── coding_active.md → coding_v2.md +``` + +模型升级时: +1. 写一个新版本 prompt(更精简、更信任模型) +2. 在 eval suite 上对比 v1 vs v2 +3. 数据说话,切换 active 软链接 + +#### 原则 7:Eval Suite —— 模型升级的决策基础 + +**最关键的一条**。没有 eval,你升级模型只能"凭感觉"。 + +详见下一节。 + +### 6.3 Eval Suite 框架 + +#### 6.3.1 目录结构 + +``` +evals/ +├── coding/ +│ ├── fix_import_bug/ +│ │ ├── input/ # 输入文件 +│ │ │ └── main.py +│ │ ├── prompt.txt # 给 agent 的指令 +│ │ ├── expected/ # 期望输出 +│ │ │ └── main.py +│ │ └── rubric.yaml # 评分标准 +│ ├── implement_function/ +│ └── refactor/ +├── ppt/ +│ ├── meeting_to_slides/ +│ │ ├── input/ +│ │ │ └── notes.md +│ │ ├── prompt.txt +│ │ └── rubric.yaml # 主观评分(LLM-as-judge) +│ └── ... +├── proposal/ +│ ├── write_intro_section/ +│ ├── search_and_cite/ +│ └── ... +└── runner.py # 执行器 +``` + +#### 6.3.2 Rubric 示例 + +**客观评分**(coding 任务): +```yaml +# evals/coding/fix_import_bug/rubric.yaml +type: deterministic +checks: + - type: file_diff + path: main.py + expected_path: expected/main.py + - type: run_command + command: python -c "import main" + expect_exit_code: 0 + - type: run_tests + command: pytest tests/ +``` + +**主观评分**(ppt/proposal 任务): +```yaml +# evals/ppt/meeting_to_slides/rubric.yaml +type: llm_judge +judge_model: claude-opus-4-7 # 用强模型当裁判 +criteria: + - "幻灯片数是否符合要求(5 页)" + - "每页 bullet 是否 ≤ 5 条" + - "信息密度是否合理" + - "是否有图表(如果数据 ≥ 3 个点)" +score_threshold: 7 # 满分 10 +``` + +#### 6.3.3 Runner + +```python +# evals/runner.py +def run_eval_suite(model_id: str, suite: str = "all"): + results = [] + for case_dir in find_cases(suite): + # 起一个干净的 agent 实例 + agent = build_agent(model=model_id, workspace=tmp_workspace()) + + # 跑测试 + prompt = (case_dir / "prompt.txt").read_text() + result = agent.run(prompt) + + # 评分 + rubric = load_rubric(case_dir / "rubric.yaml") + score = grade(rubric, agent.workspace, result) + + results.append({ + "case": case_dir.name, + "score": score, + "tokens": agent.token_counter.total, + "cost": agent.token_counter.cost_usd, + "duration": agent.duration_seconds + }) + + return summarize(results) + +if __name__ == "__main__": + # 模型升级时,跑这个 + print(run_eval_suite("deepseek-v4-flash")) + print(run_eval_suite("deepseek-v4-pro")) + # 对比看哪个性价比最高 +``` + +#### 6.3.4 Eval 的真实用途 + +每次模型升级,你能用数据回答这些问题: + +> Q1:V5-Flash 出来了,值得升级吗? +> A:跑 eval suite,对比 V4-Flash vs V5-Flash 的 score 和 cost。 + +> Q2:Claude Opus 5.0 出来了,要不要换主力? +> A:跑 eval。如果 score 提升 < 10% 但 cost 涨 10x,继续用 DeepSeek。 + +> Q3:某个 prompt 改了之后,效果是好是坏? +> A:跑 eval。 + +**没有 eval suite,你的"升级"全靠想象。** + +### 6.4 实操:模型升级 checklist + +未来 V5、Opus 5、GPT-6 出来时,按这个流程: + +```markdown +## 模型升级 Checklist + +- [ ] 1. 写新模型档案 yaml (5 分钟,从 _template 起) +- [ ] 2. 跑 capability probe 验证 yaml(10 分钟) +- [ ] 3. 跑完整 eval suite 测试新模型(30 分钟,看任务量) +- [ ] 4. 对比 score / cost / latency,判断是否升级 +- [ ] 5. 如果升级: + - [ ] 在 config 里调整 default_model + - [ ] 检查现有 prompt 是否可以精简(强模型不需要那么多脚手架) + - [ ] 跑 eval 回归一遍 +- [ ] 6. 部分模式按需升级(比如只把 proposal_final 升级到新 Pro) +``` + +整个流程**不需要改 agent 核心代码**。 + +--- + +## 7. 关键工程细节 + +### 7.1 任务状态(`tasks//state.json`) + +```json +{ + "task_id": "proposal_20260102_1430", + "mode": "proposal", + "description": "国自然青年基金 - LLM agent 在医疗问诊", + "status": "active", + "model_used": "deepseek-v4-pro", + "reasoning_effort": "max", + "created_at": 1735800000, + "tokens_used": {"prompt": 145000, "completion": 38000}, + "cost_usd": 0.42 +} +``` + +### 7.2 中断恢复 / 成本控制 / 安全约束 + +(沿用 v1 设计,无重大变化) + +--- + +## 8. 实施路线图 + +### Phase 1:最小可用骨架(2 天) + +- [x] `core/llm.py` + Model Profile 雏形 +- [x] `core/loop.py` - 主循环 +- [x] `core/session.py` +- [x] `tools/base.py` + `tools/fs.py` + `tools/shell.py` +- [x] `cli.py` - 基础 REPL +- [x] `config/agent.yaml` + `config/models/deepseek_v4.yaml` + +**验收**:`python cli.py chat` 能让 V4-Flash 修一个简单 Python bug。 + +### Phase 2:Skill 系统(标准格式)+ 三个 skill(2 天) + +- [x] `tools/skill_tool.py`(LoadSkill) +- [x] 三个 skill 目录,对齐 Anthropic 格式 +- [x] 任务模式路由 + +**验收**:三种模式都能进入,渐进披露正常工作。 + +### Phase 3:Hybrid 范式(1-2 天) + +- [x] `tools/run_python.py` - subprocess 沙盒版 +- [x] PPT/Word 通过 run_python 生成(不再做高级 API 封装) +- [x] PDF / 文献检索脚本到 skills/proposal/scripts/ + +**验收**:能产出 .pptx 和 .docx,文献检索真实。 + +### Phase 4:演化性能力(1-2 天)⭐ 新增 + +- [x] `core/capabilities.py` - Model Profile 加载 +- [x] Capability Probing 启动检测 +- [x] 版本化 prompts/ 目录结构 +- [x] 配置热重载 + +**验收**:能切换 V4-Flash 和 V4-Pro 不用改代码,只改 config。 + +### Phase 5:Eval Suite(2 天)⭐ 新增 + +- [x] `evals/runner.py` +- [x] 每种任务 3-5 个测试 case +- [x] LLM-as-judge 评分 +- [x] 报告输出(score / cost / latency) + +**验收**:`python evals/runner.py --model deepseek-v4-flash` 能跑完所有任务并出报告。 + +### Phase 6:长任务工程化(2-3 天) + +- [x] `core/context.py` - 三层压缩(兜底用) +- [x] `core/memory.py` - 双层记忆 +- [x] 任务恢复机制 + +**验收**:写完整一份申报书不崩,中断后能恢复。 + +### Phase 7:打磨(持续) + +- 双层记忆系统完善 +- 更多 skill +- Web UI(可选) +- Docker 沙盒(替代 subprocess) +- 更多模型档案(Claude / GPT / Kimi 等) + +--- + +## 9. 技术栈清单 + +``` +# requirements.txt(核心) + +# LLM +litellm>=1.50.0 +tiktoken>=0.7.0 + +# 文档(给 run_python 用) +python-pptx>=0.6.21 +python-docx>=1.1.0 +pypdf>=3.17.0 +pdfplumber>=0.10.0 +matplotlib>=3.8.0 +pandas>=2.0.0 + +# 文献 +arxiv>=2.1.0 +requests>=2.31.0 + +# CLI / 配置 +click>=8.1.0 +rich>=13.7.0 +pydantic>=2.5.0 +pyyaml>=6.0 +python-frontmatter>=1.0.0 + +# Eval +deepdiff>=6.0 # 文件 diff 比对 + +# 开发 +pytest>=7.4.0 +ruff>=0.1.0 +``` + +--- + +## 10. 风险与权衡 + +### 10.1 已知风险 + +| 风险 | 缓解 | +|-----|------| +| run_python 沙盒安全(subprocess 不够强) | 限制工作目录 + 环境变量过滤;后期升级 Docker | +| V4 在某些复杂任务上仍不如 Claude | Eval suite 帮判断;fallback 机制 | +| Skill description 不够好 → 触发不准 | 用 V4-Pro 优化 description;eval 测触发率 | +| Long context 退化 | Capability probe 探测 reliable_context;不要依赖宣称值 | +| Prompt 改了一次就不敢动 | 版本化 + eval 让改动有数据支撑 | + +### 10.2 取舍说明 + +**为什么改用 Anthropic Skill 标准而不是自创**: +- 行业标准已成,跨平台兼容 +- 直接拿到 Anthropic skills repo 的现成资源 +- 未来想换底层 SDK 不用改 skill + +**为什么用 Hybrid 范式而不是纯 CodeAgent**: +- DeepSeek V4 在 JSON tool calling 已足够稳定 +- 沙盒成本更低(只在需要时执行代码) +- 可以兼容 thinking 模式(纯 CodeAgent 跟 thinking 不太合) + +**为什么花精力做 Eval Suite**: +- 没有 eval,模型升级决策只能凭感觉 +- 一次性投入,长期复用 +- 跑 eval 的成本(~$10)远低于因为没 eval 选错模型的成本 + +**为什么不做 subagent**: +- 用户明确不需要 +- 加了之后状态管理复杂度爆炸 +- 单 agent + skill 已能覆盖 95% 场景 + +--- + +## 11. 与 v1 方案的差异 + +| 维度 | v1 | v2 | +|-----|----|----| +| 默认模型 | deepseek-chat (V3.2) | deepseek-v4-flash | +| Context 阈值 | 65K, 0.5/0.7/0.85 | 256K, 0.6/0.85/0.95 | +| 工具范式 | 纯 JSON tool call | **Hybrid:JSON + run_python** | +| Skill 格式 | 自创 | **Anthropic 开放标准** | +| Skill 描述风格 | "Step 1/2/3" 流程 | **WHY+WHAT 风格** | +| 模型配置 | 散在 config 里 | **Model Profile 化** | +| 升级机制 | 无 | **Capability Probing + Eval Suite** | +| Prompt 管理 | 散在代码里 | **版本化 + active 软链接** | +| 工具粒度 | 部分高级封装(如 make_pptx) | **原子化(用 run_python 调 python-pptx)** | +| 代码量 | 1100-1300 行 | 1300-1600 行 | + +--- + +## 12. 下一步 + +确认方案后,从 Phase 1 开始落地: + +1. 起项目骨架 +2. 写 `core/llm.py` + `core/capabilities.py` +3. 写 `config/models/deepseek_v4.yaml` +4. 跑通最小 REPL +5. 用 V4-Flash 跑一个简单任务 + +Phase 1 预计 2 天完成,跑通后能立即用。 + +--- + +## 附录 A:Anthropic Skill 标准参考 + +官方资源: +- [Agent Skills 文档](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview) +- [Anthropic skills 仓库](https://github.com/anthropics/skills)(开源,可直接抄) +- 现成可用的 skill:pdf-processing、xlsx、docx、pptx、claude-api 等 + +行业落地: +- Claude Code: `~/.claude/skills/` +- OpenAI Codex CLI: `.agents/skills/` +- Google Gemini CLI: `.gemini/skills/` +- GitHub Copilot: 同日跟进 +- **格式完全统一**,只是路径不同 + +## 附录 B:DeepSeek V4 关键事实(2026-04-24) + +模型: +- V4-Pro:1.6T 总 / 49B 激活,1M context +- V4-Flash:284B 总 / 13B 激活,1M context +- 三种推理模式:non-thinking / thinking / thinking-max + +Agent 能力(V4-Pro-Max): +- SWE-Bench Verified: 80.6%(对标 Claude Opus 4.6 的 80.8%) +- Terminal-Bench 2.0: 67.9%(超过 Claude 4.6 的 64.3%) +- MCPAtlas: 73.6%(对标 Claude 4.6 的 73.8%) + +价格: +- 输入约 $0.145 / M tokens(Claude Opus 的 1/7) +- 输出约 $1.74 / M tokens(Claude Opus 的 1/6) + +迁移: +- `deepseek-chat` / `deepseek-reasoner` 在 2026-07-24 后下线 +- 必须迁到 `deepseek-v4-flash` / `deepseek-v4-pro` + +## 附录 C:演化性设计的灵感来源 + +- **Anthropic "Equipping agents for the real world"** —— Skill 渐进披露的设计哲学 +- **CodeAct 论文(Wang et al. 2024)** —— Code as action 范式 +- **LangChain 早期教训** —— 过度脚手架的反例 +- **Karpathy 的 nanoGPT 哲学** —— "可读性优先于功能完备" + +--- + +*Last updated: 2026-05-02* +*v2 changes: DeepSeek V4 / Anthropic Skill 标准 / Hybrid 范式 / 演化性设计* diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..454de94 --- /dev/null +++ b/PROGRESS.md @@ -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/.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/.json` +- 支持: `--resume last` / `--resume `;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//`,只读 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//` +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 行,略多但仍在可读范围。 diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..de5b994 --- /dev/null +++ b/cli.py @@ -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() diff --git a/config/agent.yaml b/config/agent.yaml new file mode 100644 index 0000000..b5b7f8d --- /dev/null +++ b/config/agent.yaml @@ -0,0 +1,7 @@ +# 默认模型档案: .,对应 config/models/.yaml +default_model: deepseek_v4.flash + +models_dir: config/models +skills_dir: skills +workspace_dir: workspace +system_prompt: prompts/system/general_v1.md diff --git a/config/models/deepseek_v4.yaml b/config/models/deepseek_v4.yaml new file mode 100644 index 0000000..b95d587 --- /dev/null +++ b/config/models/deepseek_v4.yaml @@ -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 diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/capabilities.py b/core/capabilities.py new file mode 100644 index 0000000..a3e7312 --- /dev/null +++ b/core/capabilities.py @@ -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: '.',如 '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) diff --git a/core/llm.py b/core/llm.py new file mode 100644 index 0000000..726ab38 --- /dev/null +++ b/core/llm.py @@ -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] diff --git a/core/loop.py b/core/loop.py new file mode 100644 index 0000000..95db636 --- /dev/null +++ b/core/loop.py @@ -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 diff --git a/core/session.py b/core/session.py new file mode 100644 index 0000000..47a6a4a --- /dev/null +++ b/core/session.py @@ -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 diff --git a/core/skills.py b/core/skills.py new file mode 100644 index 0000000..a0fcec7 --- /dev/null +++ b/core/skills.py @@ -0,0 +1,81 @@ +"""Skill 注册表 (Anthropic 标准格式)。 + +每个 skill 是 skills// 目录,内含 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) diff --git a/main.py b/main.py new file mode 100644 index 0000000..fc892f8 --- /dev/null +++ b/main.py @@ -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 diff --git a/prompts/system/general_v1.md b/prompts/system/general_v1.md new file mode 100644 index 0000000..5913351 --- /dev/null +++ b/prompts/system/general_v1.md @@ -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 源码 / 让用户提供 / 明说不知道 + +## 路径 +默认工作目录在系统消息末尾,所有相对路径基于该目录。 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..78e9f5d --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/skills/coding/SKILL.md b/skills/coding/SKILL.md new file mode 100644 index 0000000..9f1ca94 --- /dev/null +++ b/skills/coding/SKILL.md @@ -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 匹配不上) +- 跑测试失败就立刻改测试 (先看是测试错还是代码错) diff --git a/skills/ppt/SKILL.md b/skills/ppt/SKILL.md new file mode 100644 index 0000000..20d6cc6 --- /dev/null +++ b/skills/ppt/SKILL.md @@ -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 ` —— 检页数/标题/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` + +## 工作目录约定 +``` +/ +├── source.md # 阶段一: source_to_md.py 转出的素材 +├── spec_lock.md # 阶段一: 八条对齐落定 +├── slides/ +│ └── chart_p3.png # 各页用到的图片素材 +└── .pptx # 最终产物 (文件名按主题命名,不要 untitled.pptx) +``` + +## 反模式 +- 用户没给材料就开始硬编内容 +- 八条没对齐就跑 python-pptx +- 一个 `run_python` 出整 deck (中途改方向就要全推翻) +- 跑完不做 `quality_check.py` 就交付 +- 起名 `output.pptx` / `untitled.pptx` —— 务必按主题给文件名 +- 文字塞满整张幻灯片 —— 留白本身是设计 + +## 输出 +完成后告诉用户:文件路径、页数、用到的版式列表、是否有未满足的 spec 项。问一句要不要再改。 diff --git a/skills/ppt/references/canvas_presets.md b/skills/ppt/references/canvas_presets.md new file mode 100644 index 0000000..5952197 --- /dev/null +++ b/skills/ppt/references/canvas_presets.md @@ -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)**。模型自己换算,不要硬抄默认表。 diff --git a/skills/ppt/references/design_principles.md b/skills/ppt/references/design_principles.md new file mode 100644 index 0000000..e6623d7 --- /dev/null +++ b/skills/ppt/references/design_principles.md @@ -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,固定模板 | diff --git a/skills/ppt/references/icons.md b/skills/ppt/references/icons.md new file mode 100644 index 0000000..5f5da1e --- /dev/null +++ b/skills/ppt/references/icons.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 装饰严肃议题 (融资额、合规) diff --git a/skills/ppt/references/layouts.md b/skills/ppt/references/layouts.md new file mode 100644 index 0000000..8d5e474 --- /dev/null +++ b/skills/ppt/references/layouts.md @@ -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 一项** diff --git a/skills/ppt/scripts/quality_check.py b/skills/ppt/scripts/quality_check.py new file mode 100644 index 0000000..f83efdb --- /dev/null +++ b/skills/ppt/scripts/quality_check.py @@ -0,0 +1,258 @@ +"""quality_check.py: 验收 .pptx,产出问题清单。 + +用法: + python quality_check.py [--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() diff --git a/skills/ppt/scripts/render_icon.py b/skills/ppt/scripts/render_icon.py new file mode 100644 index 0000000..af0d080 --- /dev/null +++ b/skills/ppt/scripts/render_icon.py @@ -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() diff --git a/skills/ppt/scripts/source_to_md.py b/skills/ppt/scripts/source_to_md.py new file mode 100644 index 0000000..ffd1285 --- /dev/null +++ b/skills/ppt/scripts/source_to_md.py @@ -0,0 +1,157 @@ +"""source_to_md.py: 把素材转成干净 Markdown,作为后续策略阶段的输入。 + +用法: + python source_to_md.py # 自动按扩展名识别 + python source_to_md.py # 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"", "", html, flags=re.I) + html = re.sub(r"", "", html, flags=re.I) + + title_m = re.search(r"]*>([^<]+)", html, re.I) + title = title_m.group(1).strip() if title_m else url + + # 块级标签转换行 + html = re.sub(r"]*>", "\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() diff --git a/skills/proposal/SKILL.md b/skills/proposal/SKILL.md new file mode 100644 index 0000000..e4b2e1f --- /dev/null +++ b/skills/proposal/SKILL.md @@ -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]" 但其实根本没这篇文献 diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/base.py b/tools/base.py new file mode 100644 index 0000000..09c2558 --- /dev/null +++ b/tools/base.py @@ -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) diff --git a/tools/fs.py b/tools/fs.py new file mode 100644 index 0000000..565de5b --- /dev/null +++ b/tools/fs.py @@ -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) diff --git a/tools/run_python.py b/tools/run_python.py new file mode 100644 index 0000000..2ee47cc --- /dev/null +++ b/tools/run_python.py @@ -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) diff --git a/tools/shell.py b/tools/shell.py new file mode 100644 index 0000000..8ca0510 --- /dev/null +++ b/tools/shell.py @@ -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) diff --git a/tools/skill_tool.py b/tools/skill_tool.py new file mode 100644 index 0000000..0c6a67e --- /dev/null +++ b/tools/skill_tool.py @@ -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//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