skill(proposal): mermaid 管线 + render_docx 图片插入 + 图题自动编号

新增 render_diagrams.py 把 ```mermaid``` 块预渲染到 figures/fig_<sha1>.png
(优先本地 mmdc, 回退 mermaid.ink 公网 API, 都失败留 WARN 不阻塞);
render_docx.py 加 ![](path) 识别 + mermaid 缓存查找, 缺缓存自动 ASCII fallback,
图题"图 N <caption>"全局自增, 替换原模板里的 [图 2-2 ...] 裸占位写法。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-18 21:37:16 +08:00
parent 9aa2efc335
commit d6fc004367
6 changed files with 648 additions and 13 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。
最后更新:2026-05-18(system prompt skill 机制改"可选辅助",通用任务不必硬套 skill)
最后更新:2026-05-18(proposal skill 加 mermaid 管线:`render_diagrams.py` 预渲染 + `render_docx.py` 图片插入 + 图题自动编号;`key_rd.md` 占位 `[图 N-N ...]` 换成真 mermaid 例子)
---
@ -21,6 +21,7 @@
## 已完成关键能力
- **05-18 / proposal skill 流程图/结构图管线**:用户反馈"申报 skill 关于流程图、结构图等的生成有些问题,包括渲染到 docx 里"。诊断结果:① `render_docx.py` 整个脚本没有 `add_picture` / 没引 `Inches`,所谓"画流程图"只能走 `add_code_block` 的 ASCII 字符画(`新宋体` + Consolas + box drawing),Word 里 CJK 与 `─ │ ┌ ┐` 不真等宽,中文标签一长就错位,评审看到字符画扣印象分;② 模板里写满 `[图 2-2 关键技术关系架构]` 裸占位,但 SKILL.md 零提及 mermaid / graphviz / matplotlib,模型只能瞎编 ASCII;③ 评审红线"图编号连续无遗漏"(`references/review_redlines.md:96`)无机制保证。**方案**:Mermaid 管线 + matplotlib 兜底 + 图编号自增。**新增 `scripts/render_diagrams.py`**(143 行):扫 sections/*.md 的 ```mermaid``` 块 → 算 sha1 前 10 位作稳定 id → 落到 `<task_dir>/figures/fig_<hash>.png`;两阶 backend:① 本地 `mmdc`(npm i -g @mermaid-js/mermaid-cli;最高质量、离线)② `mermaid.ink` 公网 API(`https://mermaid.ink/img/<url-safe-b64>`,urlsafe_b64encode rstrip '=';不装东西、要联网);两个都失败留 WARN 退出 0(不阻塞流水线);`%% caption: <图题>` 行注释抽题文,mermaid 本身当注释跳过、render_docx 当题用;不改动 .md 文件(源是真相);幂等(png 存在跳过)。**改 `render_docx.py`**(+~70 行):① 加 `![caption](path)` 单行识别 → `add_picture(width=Cm(15))` 居中 + 五号宋体居中图题段落"图 N <caption>",N 通过 `ctx` 字典(`{sections_dir, figures_dir, fig_no}`)在 `render_md_block` 调用链里递增,relative 路径以 .md 所在目录为锚;图片源缺失 → 留 `[图片缺失: <src>]` 占位段防 silent miss、文档不崩;② 围栏 lang == "mermaid" 特判:算同源 sha1 查 `<sections_dir>/../figures/fig_<hash>.png`,命中走插图 + 题(同样自增编号、复用 `extract_mermaid_caption`),未命中**继续走原 `add_code_block` ASCII fallback 路径**(mmdc 没装也能交差,只是不漂亮);③ A4 减页边距得正文宽 16cm,图宽 cap `Cm(15)` 留 1cm 安全垫;④ `add_picture` 失败 try/except 不让整 doc 崩,改占位文字。**改 SKILL.md**:`资源` 段加 `render_diagrams.py` 行;阶段三命令链插入 `render_diagrams.py` 前置(可选,无 mermaid 块直接跳过);新增"插图"段(类型选择表 / mermaid `%% caption` 约定 + 完整 flowchart 例子 / matplotlib `figsize=(10,4)` `dpi=150` 中文字体 SimHei 配色规范 / 不要手写"图 2-2"章节-序号);反模式加 3 条(ASCII 字符画当真图 / 手写图编号 / 裸 `[图 N-N ...]` 占位)。**改 `templates/key_rd.md`**:① §04_content (一) 主要研究内容里 `[图 2-2 关键技术关系架构]` 占位换成完整 ```mermaid flowchart LR``` 块(关键问题 Q1/Q2 → 技术 T1/T2/T3 → 平台,带 `%% caption:`);② §04 (三) 技术路线加"项目总体技术路线" mermaid `flowchart TB` 例子(需求→设计→突破→集成→示范 5 阶段 + 双向反馈虚线);③ §09_schedule 甘特图改"两种画法 A. mermaid `gantt` B. matplotlib `barh`"并给完整 mermaid gantt 示例。**没动**:`major_project.md` / `nsfc_joint_fund.md` 只是"配图"提示,不是裸占位,通过 SKILL.md 横向覆盖;`scripts/word_count.py` / `quality_check.py`(图不计字数,质量检查暂不涉及图占位)。**Smoke 4 case 全绿**(`scripts/_smoke_proposal_diagrams.py`,留作回归):① cached mermaid + direct image + ASCII fallback 混排(`figures: 2` 报告对、`inline_shapes == 2`、缓存命中走"图 1/图 2"、缺缓存 mermaid 走 ASCII 源保留 + 不申请图号"图 3");② 无插图回归(`figures: 0` + table 完好);③ `render_diagrams.py` API 调用(`find_mermaid_blocks` 抽 2 块 / `extract_caption` 命中/未命中 / 预填 cache png 全走 `cache` backend 不走网络);④ 图片源缺失走占位文字,后续段落不丢。**Windows GBK 子进程坑**:smoke 跑 subprocess 拿不到 UTF-8 stdout(`UnicodeDecodeError 0xd6`),给子进程 env 加 `PYTHONIOENCODING=utf-8` 修;同 memory 里 emoji 编码教训同源。**文档**:**只动 PROGRESS**(skill 内部能力增强 ≠ 架构变化,不动 DESIGN;skill CLI 不是 zcbot 对外行为,不动 RUN —— 按 CLAUDE.md 三文档边界)。**净增量**:~213 行代码新增,5 行文档示例改写,sections/*.md 不动(源永远是 mermaid 真相)。**留给真用户的体验**:模型不需要再瞎编 ASCII,直接写 mermaid 块就行;mmdc/网络都没的极端环境下 docx 仍能产(ASCII 退化,文字不丢);图编号永远连续不重不漏(自动),手工占位的旧坑彻底关上。
- **05-18 / `POST /v1/files/rename` + 顶层目录 delete 加 task 引用闸**:用户反复抠"文件夹改名 / 删除时怎么不破 DB 一致性"。架构最终落点:**`/v1/files/*` 是唯一的目录树 mutation 命名空间,DB-FS 一致性作为服务端不变量内化**(放弃曾经的"files API 永不进 DB"惯例 —— 那是当初没考虑顶层目录时形成的偶然,把它升格成铁律反而导出双命名空间代价);`GET /v1/folders` 保留,但定位为"项目聚合视图"(只读,带 n_tasks/last_used,新建任务 datalist 用),不做 mutation。**判定**:`target.parent.resolve() == root.resolve() and target.is_dir()` ⇒ 顶层目录(就是 task 的 working_dir)。**新 `POST /v1/files/rename`**:校验 `validate_task_name(new_name)` / target 存在 / 不能等于 user_root / sibling 不能已存在;**顶层目录**走 DB-aware 分支:`session_scope()` 事务内 `SELECT task_id, run_status WHERE working_dir=old_db FOR UPDATE` 锁所有关联 task,任一 `run_status in ('running','cancelling')` → 409;`check_no_subtask(new_db, exclude_task_ids=tids)` 防改名后与其它 task 形成嵌套(exclude 平移过去的自己);`UPDATE tasks SET working_dir=new_db` → `os.rename(old_fs, new_fs)` —— FS 失败 raise → session_scope 回滚 DB UPDATE。**非顶层**(子目录 / 文件)纯 FS rename,不动 DB。**事务顺序考量**:DB UPDATE 在 FS rename 之前(都在事务未提交期间),FS 失败可回滚 UPDATE;唯一不一致窗口是"FS 改完 + commit 失败"(PG 单事务 commit 极少失败,接受)。**`POST /v1/files/delete` 收紧**:同样的顶层目录判定,若顶层目录有任意 task 引用 → 409 "请先 DELETE 关联 task 再删目录",避免悬空指针。**`check_no_subtask` 扩 `exclude_task_ids` 参数**:`core/storage/utils.py` 加可选 Iterable[UUID],循环里跳过这些 task_id;rename 场景刚需(否则被改名 task 与自己未来的 new_db 误判为嵌套);其它 caller 默认 None 行为不变。**dev SPA 同步**(`web/static/dev.html`):file row 加 `改名` 按钮,prompt 拿新名 → POST `/v1/files/rename`;rename 后:① 当前 `state.filesPath` 若在被改名子树内做前缀替换继续停留(`rel === filesPath` 或 `filesPath.startsWith(rel + "/")` → 替换前缀为 res.new);② `loadFolderSuggestions()` 刷 datalist;③ `res.tasks_updated > 0``loadTaskList()` + `selectTask(state.taskId)`(task 卡片 / chat 头里展示的 working_dir 末段也跟着变)。delete confirm 文案补一句"顶层目录且仍被 task 引用需先删 task";删除完成也 `loadFolderSuggestions()`。**Smoke 5 case 全绿**(in-process TestClient + PG):① 子目录 rename 纯 FS / tasks_updated=0;② 顶层目录 rename 同步 UPDATE / tasks_updated=N / FS 改完 + DB working_dir 跟着变;③ 顶层目录 rename 时有 running task → 409;④ 删顶层有 task 引用 → 409;⑤ rename 目标已存在 → 409。**Smoke 文件**(`scripts/smoke_files_rename.py`)跑完未删(留作回归用)。**没动**:`GET /v1/folders` 接口、`DELETE /v1/tasks/{id}` 行为(仍删 DB 行不动 FS,与新 delete 配对刚好覆盖"销毁项目"全链路);`/v1/files/{list,upload,download}` 路由签名;skill / chat / cancel 等其它路由。**架构反思**:此前一版我先提的双命名空间 `/v1/folders/rename` vs `/v1/files/rename`,内部 if path is top-level 切分支被自己视为"代码异味" —— 实际是反了,这种分支**从数据状态派生**(path 恰好是 working_dir),不是从客户端意图派生,放服务端是更安全的位置(client 没法绕过去导致悬空引用);双命名空间反而把同一个分支搬到 client 去做,失去强制力且端点表面翻倍。这条工程教训记 §7.9。
- **05-18 / system prompt skill 机制改"可选辅助"**:接 `GET /v1/skills` + 下拉选择落地后,task 创建时 skill 字段允许留空成为常态。原 `prompts/system/general_v1.md` 第 14 行 `"永远 load 一下。skill 数有限,加载成本很低"` 在新形态下变得过激 —— 简单问答 / 通用编码 / 文件操作不该被强行匹配到 coding 等 skill。改为"Skill 是**可选辅助**"+ 明确列出"简单问答、读代码、改 bug、文件操作这类通用任务,直接用通用工具就够,不必为每个任务硬套 skill"。一旦决定要用仍要求 load 完整指引(原则不变)。**未动**:skill discovery block 内容(name + description 注入仍按 registry 顺序)、`load_skill` 工具协议、SKILL.md 内容。**tradeoff**:边缘场景(用户提"整理大纲"可能落 proposal 也可能不用)agent 现在会偏向不 load,可能漏掉好的模板;但比原来"什么都套 coding"的噪音更可接受。
- **05-18 / `GET /v1/skills` + dev SPA skill 字段改下拉**:原 `nt-skill` 是自由输入(用户得记住 `coding / ppt / proposal` 拼写),用户反馈"加 skill 接口给前端选"。后端 `web/app.py` lifespan 启动时 `SkillRegistry(ROOT / cfg["skills_dir"])` 扫一次挂到 `app.state.skill_registry`(文件系统静态,运行中不变);新增 `GET /v1/skills``require_user` JWT 鉴权,返 `{skills:[{name,description}]}` 按 name 升序(registry 已 sorted)。dev SPA(`web/static/dev.html`):`<input id=nt-skill>` 换 `<select>`,首项固定 `(默认 · 不限定)` 空值;`hd-new` 打开 modal 时 `loadSkillOptions()``loadFolderSuggestions()` 并发(`Promise.all`),首次拉到的列表缓存到 `state.skills`,失败时静默退化为只剩"默认"项不阻塞。option 文案 `name — description`,`title` 也带 description 鼠标悬停看长文。Smoke:`TestClient` 起 app → `/v1/auth/login` 拿 token → `/v1/skills` 返 3 项(coding/ppt/proposal)+ 描述;无 token 401。**未动**:`_build_system_prompt` 注入的 skill discovery block(name + description)和这里渲染的下拉项是同源 registry,改一处不影响另一处;`POST /v1/tasks` body 不校验 `skill ∈ registry`(留空 / 任意串都允许,与 schema 一致 — 真要拦在 UI 层早就拦了)。

View File

@ -0,0 +1,244 @@
"""Smoke: render_docx.py 图片 + mermaid 缓存路径。
构造一个临时 sections/, figures/ 结构, render_docx, 验证:
- mermaid hash figures/ 有对应 png 走插图路径
- mermaid hash figures/ png ASCII fallback (不崩, 文本保留)
- ![](path) 直接图片 走插图路径
- 图编号自增
- inline_shapes = 命中插图的次数
"""
from __future__ import annotations
import hashlib
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
RENDER_DOCX = ROOT / "skills" / "proposal" / "scripts" / "render_docx.py"
PYTHON = ROOT / ".venv" / "Scripts" / "python.exe"
if not PYTHON.exists():
PYTHON = Path(sys.executable) # CI / unix fallback
def _run_render(sections: Path, out: Path) -> subprocess.CompletedProcess:
"""跑 render_docx.py, 子进程强制 utf-8 输出 (Windows GBK stdout 兜底)。"""
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
return subprocess.run(
[str(PYTHON), str(RENDER_DOCX), str(sections), "--fund-type", "key_rd", "-o", str(out)],
capture_output=True, text=True, encoding="utf-8", env=env,
)
def mermaid_hash(source: str) -> str:
return hashlib.sha1(source.strip().encode("utf-8")).hexdigest()[:10]
def make_tiny_png(out: Path) -> None:
"""用 matplotlib 生成一张 1-bar 的真 png(确保 python-docx 能 add_picture)。"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(4, 2), dpi=100)
ax.bar(["A", "B", "C"], [1, 3, 2], color="#c00000")
ax.set_title("smoke")
fig.savefig(str(out), bbox_inches="tight")
plt.close(fig)
def case(name: str):
def deco(fn):
def wrapped(*a, **kw):
print(f"[case] {name} ...", end=" ")
try:
fn(*a, **kw)
print("OK")
except Exception as e:
print(f"FAIL: {e}")
raise
return wrapped
return deco
@case("happy: cached mermaid + direct image + ASCII fallback")
def smoke_happy(tmp: Path) -> None:
sections = tmp / "sections"
figures = tmp / "figures"
sections.mkdir(parents=True)
figures.mkdir(parents=True)
# mermaid block #1 — 命中缓存
m1 = (
"%% caption: 关键问题与技术映射\n"
"flowchart LR\n"
" A --> B\n"
)
h1 = mermaid_hash(m1)
png1 = figures / f"fig_{h1}.png"
make_tiny_png(png1)
# mermaid block #2 — 缺缓存, 走 ASCII fallback
m2 = (
"%% caption: 缺缓存的图\n"
"flowchart TB\n"
" X --> Y\n"
)
# direct image — 自己造 png
direct_png = figures / "direct.png"
make_tiny_png(direct_png)
# 写 .md
(sections / "01_test.md").write_text(
f"""# 测试章节
这是一段散文**加粗** *斜体* 应当正确解析
```mermaid
{m1.rstrip()}
```
正文继续下面是一张缺缓存的 mermaid:
```mermaid
{m2.rstrip()}
```
再看一张直接引用的图:
![已有 PNG: 一柱形示例](../figures/direct.png)
末尾段
""",
encoding="utf-8",
)
out = tmp / "test.docx"
proc = _run_render(sections, out)
assert proc.returncode == 0, f"render_docx exited {proc.returncode}\nSTDERR: {proc.stderr}\nSTDOUT: {proc.stdout}"
assert out.is_file() and out.stat().st_size > 1000, f"output docx not produced: {out}"
# 报告里应明确 figures: 2 (mermaid#1 + direct)
assert "figures: 2" in proc.stdout, f"expected 'figures: 2' in stdout, got:\n{proc.stdout}"
# 打开 docx 验内容
from docx import Document
doc = Document(str(out))
# 真插图数(inline_shapes 计 add_picture)= 2
assert len(doc.inline_shapes) == 2, f"expected 2 inline shapes, got {len(doc.inline_shapes)}"
all_text = "\n".join(p.text for p in doc.paragraphs)
# 命中缓存的 mermaid 走图 + 题
assert "图 1" in all_text and "关键问题与技术映射" in all_text, "missing fig 1 caption"
# direct 图也有题
assert "图 2" in all_text and "已有 PNG" in all_text, "missing fig 2 caption"
# 缺缓存的 mermaid 走 ASCII fallback,源码保留
assert "flowchart TB" in all_text and "X --> Y" in all_text, "ASCII fallback didn't preserve mermaid source"
# 缺缓存的不应该有 "图 3"(没插入图就不计数)
assert "图 3" not in all_text, "ghost figure number for missed cache"
@case("happy: no diagrams at all (regression: existing flows unchanged)")
def smoke_no_diagrams(tmp: Path) -> None:
sections = tmp / "sections"
sections.mkdir(parents=True)
(sections / "01.md").write_text(
"# 标题\n\n散文段落。**加粗**。\n\n| 列 1 | 列 2 |\n|---|---|\n| a | b |\n",
encoding="utf-8",
)
out = tmp / "test.docx"
proc = _run_render(sections, out)
assert proc.returncode == 0, f"render_docx exited {proc.returncode}\nSTDERR: {proc.stderr}"
assert "figures: 0" in proc.stdout, f"expected 'figures: 0' in stdout, got:\n{proc.stdout}"
from docx import Document
doc = Document(str(out))
assert len(doc.inline_shapes) == 0
assert len(doc.tables) == 1 # markdown table
@case("render_diagrams: scans + hashes mermaid blocks, cache hit short-circuit")
def smoke_render_diagrams(tmp: Path) -> None:
"""不依赖 mmdc / mermaid.ink:预先放 cache png, 期望 render_diagrams 全部 'cache' 命中。"""
sys.path.insert(0, str(ROOT / "skills" / "proposal" / "scripts"))
try:
import render_diagrams as rd
finally:
sys.path.pop(0)
sections = tmp / "sections"
figures = tmp / "figures"
sections.mkdir(parents=True)
figures.mkdir(parents=True)
m1 = "%% caption: 图甲\nflowchart LR\n A --> B\n"
m2 = "flowchart TB\n X --> Y\n Y --> Z\n"
(sections / "a.md").write_text(
f"# A\n\n```mermaid\n{m1.rstrip()}\n```\n\n散文。\n\n```mermaid\n{m2.rstrip()}\n```\n",
encoding="utf-8",
)
(sections / "b.md").write_text("# B\n\n仅文本,无 mermaid。\n", encoding="utf-8")
# 预填两个 png 让 render_one 走 cache 分支(避开网络)
for src in (m1, m2):
h = rd.mermaid_hash(src)
(figures / f"fig_{h}.png").write_bytes(b"\x89PNG\r\n\x1a\nfake")
# API 调用(不走 subprocess, 避免 stdout 编码再次干扰)
rc = rd.render_sections(sections)
assert rc == 0, f"render_sections rc={rc}"
# caption 抽取
assert rd.extract_caption(m1) == "图甲"
assert rd.extract_caption(m2) is None
# find_mermaid_blocks 行为
text = (sections / "a.md").read_text(encoding="utf-8")
blocks = rd.find_mermaid_blocks(text)
assert len(blocks) == 2, f"expected 2 blocks, got {len(blocks)}"
@case("missing image src → 占位文字, 不崩")
def smoke_missing_image(tmp: Path) -> None:
sections = tmp / "sections"
sections.mkdir(parents=True)
(sections / "01.md").write_text(
"# 测试\n\n![不存在](figures/ghost.png)\n\n后面一段。\n",
encoding="utf-8",
)
out = tmp / "test.docx"
proc = _run_render(sections, out)
assert proc.returncode == 0, f"render_docx exited {proc.returncode}\nSTDERR: {proc.stderr}"
from docx import Document
doc = Document(str(out))
assert len(doc.inline_shapes) == 0
all_text = "\n".join(p.text for p in doc.paragraphs)
assert "图片缺失" in all_text, "missing-image placeholder not rendered"
assert "后面一段" in all_text, "後续段落丢了"
def main() -> None:
if not RENDER_DOCX.exists():
print(f"[ERR] render_docx.py not found: {RENDER_DOCX}", file=sys.stderr)
sys.exit(2)
with tempfile.TemporaryDirectory(prefix="zcbot_smoke_") as td:
base = Path(td)
smoke_happy(base / "happy")
smoke_no_diagrams(base / "nodia")
smoke_render_diagrams(base / "diag")
smoke_missing_image(base / "ghost")
print()
print("[OK] all smoke cases passed")
if __name__ == "__main__":
main()

View File

@ -17,7 +17,8 @@ description: 撰写中国科研项目申报书 / 课题任务书 (国家重点
- `<skill_dir>/references/budget_rules.md` —— 间接费用台阶 + B1-B4 表
- `<skill_dir>/templates/spec_lock.md` —— 阶段一八条对齐的固定字段模板 (复制到 `<task_dir>/spec_lock.md`)
- `<skill_dir>/templates/{key_rd,major_project,nsfc_joint_fund}.md` —— **有完整章节模板**的 3 类基金;其它 4 类 (`nsfc_general` / `nsfc_youth` / `provincial` / `enterprise`) 复用 `nsfc_joint_fund``key_rd` 骨架,差异看 `fund_types.md` § 4-6
- `<skill_dir>/scripts/render_docx.py` —— md→docx,自动加目录、解析 `**bold**`/`*italic*`/`` `code` ``、列表分行
- `<skill_dir>/scripts/render_docx.py` —— md→docx,自动加目录、解析 `**bold**`/`*italic*`/`` `code` ``、列表分行、`![](path)` 居中插图 + 图题自动编号、识别 mermaid 块查 `figures/` 缓存
- `<skill_dir>/scripts/render_diagrams.py` —— sections/*.md 里的 ```mermaid``` 块预渲染成 `<task_dir>/figures/fig_<hash>.png`(优先用本地 `mmdc`,回退 `mermaid.ink` 公网 API,失败留警告)
- `<skill_dir>/scripts/word_count.py` —— 章节字数 vs 预算
- `<skill_dir>/scripts/quality_check.py` —— 结构完整性 / 假大空话术 / 占位符未替换 / 指南覆盖度 (--spec 选项)
@ -73,11 +74,14 @@ markitdown https://example.com/x -o <task_dir>/source/policy.md
```bash
python <skill_dir>/scripts/word_count.py <task_dir>/sections/ --fund-type key_rd
python <skill_dir>/scripts/quality_check.py <task_dir>/sections/ --fund-type key_rd --spec <task_dir>/spec_lock.md
python <skill_dir>/scripts/render_diagrams.py <task_dir>/sections/ # 预渲染 mermaid → figures/*.png (有插图的 task 才跑)
python <skill_dir>/scripts/render_docx.py <task_dir>/sections/ --fund-type key_rd -o <task_dir>/<topic>.docx
```
`--spec` 让质量检查交叉对照 spec_lock,提示哪些指南考核指标在 sections 里没出现。不通过的项,回头 edit 对应章节。
`render_diagrams.py` 是**可选**前置 —— 章节里没 mermaid 块就跳过。即使跑了但 mmdc 没装 + 联不上 mermaid.ink,`render_docx.py` 会把 mermaid 源文本以 ASCII 等宽兜底显示(评审能看,但不漂亮)。**只要 mermaid 块就能直接交给 `render_docx.py`,figures/ 没有就走 ASCII fallback,不会崩。**
## 工作目录
`<task_dir>` = system prompt 给的**绝对路径**(`…/workspace/tasks/<task_id>/`)。**所有产物都写到 task_dir 下**,不要写到 cwd / `skills/` / repo 根。
@ -102,6 +106,56 @@ python <skill_dir>/scripts/render_docx.py <task_dir>/sections/ --fund-type key
边界 (最容易混):
- 立项依据回答 **WHY** (背景+痛点) | 研究目标回答 **WHAT 终态** (量化指标) | 研究内容回答 **WHAT 任务** (技术清单) | 研究方法回答 **HOW 原理** (算法/模型) | 技术路线回答 **HOW 流程** (输入→处理→输出)
## 插图 (流程图 / 结构图 / 技术路线图 / 甘特图)
**禁止把 ASCII 字符画 (`┌─┐│└─┘ →`) 当真图交差** —— Word 里 box-drawing 与 CJK 不真等宽,标签一长就错位,评审看到直接扣印象分。
### 类型选择
| 图类型 | 用法 | 工具 |
|---|---|---|
| 流程图 / 结构图 / 关系图 / 技术路线图 | flowchart / graph | **mermaid** (写在 md 里) |
| 时序图 (调用关系) | sequenceDiagram | **mermaid** |
| 甘特图 (进度安排) | gantt | **mermaid** 或 matplotlib `barh` (matplotlib 更适合打印) |
| 数据图表 (柱/折/饼) | 数据可视化 | **matplotlib**(配 spec_lock 主色,生成 png 后 ![]() 引用) |
| 已有图片 (截图 / 设计稿 / 厂商架构图) | 直接 ![]() | 用户提供 png/jpg |
### mermaid 块约定
直接写进章节 `.md` 里。**第一行加 `%% caption: <图题>` 注释**(题号 render_docx.py 自动加),mermaid 当注释跳过,render_docx 当题文用:
````markdown
```mermaid
%% caption: 关键技术与研究内容映射
flowchart LR
Q1[关键问题 1<br/>多源异构数据融合] --> T1[技术 1<br/>跨模态对齐]
Q1 --> T2[技术 2<br/>实体消歧]
Q2[关键问题 2<br/>实时响应] --> T3[技术 3<br/>增量索引]
T1 & T2 & T3 --> P[支撑平台]
```
````
`render_diagrams.py` 一遍 → `figures/fig_<hash>.png` 落盘 → 再跑 `render_docx.py` 会把这个块替换成"图 N 关键技术与研究内容映射"居中插图。
### matplotlib 直出 png
甘特图 / 数据图用 `run_python` 跑 matplotlib,落到 `<task_dir>/figures/<name>.png`,章节里直接:
```markdown
![甘特图: 项目 4 阶段进度安排](figures/gantt.png)
```
写 matplotlib 代码时:
- 中文字体 `plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei']`,负号 `axes.unicode_minus=False`
- `figsize=(10, 4)` 起步,过宽超出页边距(`render_docx` 上限 15cm,超了自动等比缩)
- `dpi=150`(印刷质量;< 100 会糊)
- 颜色用 spec_lock 里定的主/辅色,不要 matplotlib 默认色板
- `bbox_inches='tight'` 收紧白边
### 图编号
不要手写"图 2-2"这种章节-序号,render_docx 渲染时**全局自增**(图 1 / 图 2 / ...),手写编号会和自动编号撞。要 chapter-section 编号等真有需要再扩。
## 硬规则速查 (违反即扣分)
- **字体**: 标题黑体四号; 正文中文宋体小四 / 英文 Times New Roman; 行距 1.5 倍 —— `render_docx.py` 已强制
@ -124,6 +178,9 @@ python <skill_dir>/scripts/render_docx.py <task_dir>/sections/ --fund-type key
- 引文写"[Smith et al., 2023]" 但其实没这篇文献
- 不跑 `quality_check.py` 就交付
- 文件名 `output.docx` / `申报书.docx` —— 按主题命名
- **用 ASCII 字符画 `┌─┐│└─┘ →` 当流程图/结构图** —— 中文 Word 里必错位,看插图段
- **手写图编号 (图 2-2 / 图 3-1)** —— render_docx 自动全局编号,手写会撞
- **`[图 2-2 关键技术架构]` 这种裸占位** —— 占位等于没图,要么写 mermaid 块要么 ![]() 引 png
## 输出

View File

@ -0,0 +1,193 @@
"""预处理 sections/*.md 里的 mermaid 块 → 缓存为 figures/fig_<hash>.png。
不改动 sections/*.md(mermaid 源是真相,留着方便迭代),只往 <sections_dir>/../figures/
下落 PNG 缓存, mermaid 源的 sha1 前缀为文件名render_docx.py 在遇到 ```mermaid
时按相同 hash figures/,有就插图 + 图题,没有就 ASCII 兜底
渲染后端选择 (按优先级):
1. 本地 mmdc (mermaid-cli) 最高质量, Node.js + npm i -g @mermaid-js/mermaid-cli
2. mermaid.ink 公网 API 不装东西,要联网
两种都没,留警告退出 0(让流水线继续),render_docx.py ASCII fallback
题注约定:mermaid 块第一行可写
%% caption: 关键技术关系架构
caption 不写也能渲染,题号自动在 render_docx.py 里递增
用法:
python render_diagrams.py <task_dir>/sections/
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
_FENCE_OPEN_RE = re.compile(r"^\s*```\s*mermaid\s*$")
_FENCE_CLOSE_RE = re.compile(r"^\s*```\s*$")
_CAPTION_RE = re.compile(r"^\s*%%\s*caption\s*:\s*(.+?)\s*$", re.IGNORECASE)
MERMAID_INK_URL = "https://mermaid.ink/img/{payload}?type=png&bgColor=FFFFFF"
def mermaid_hash(source: str) -> str:
"""对 mermaid 源算 sha1, 取前 10 位作为文件名稳定 id。"""
return hashlib.sha1(source.strip().encode("utf-8")).hexdigest()[:10]
def extract_caption(source: str) -> str | None:
for ln in source.splitlines():
m = _CAPTION_RE.match(ln)
if m:
return m.group(1).strip()
return None
def find_mermaid_blocks(md_text: str) -> list[str]:
"""返回 .md 里所有 mermaid 块的源码(不含 ``` fence)。"""
blocks: list[str] = []
lines = md_text.splitlines()
i = 0
n = len(lines)
while i < n:
if _FENCE_OPEN_RE.match(lines[i]):
buf: list[str] = []
i += 1
while i < n and not _FENCE_CLOSE_RE.match(lines[i]):
buf.append(lines[i])
i += 1
blocks.append("\n".join(buf))
i += 1
else:
i += 1
return blocks
def render_via_mmdc(source: str, out_png: Path) -> bool:
"""有 mmdc 就用 mmdc, 输出 png 到 out_png。成功 True, 失败 False。"""
mmdc = shutil.which("mmdc")
if not mmdc:
return False
with tempfile.NamedTemporaryFile("w", suffix=".mmd", delete=False, encoding="utf-8") as tf:
tf.write(source)
tmp_path = Path(tf.name)
try:
proc = subprocess.run(
[mmdc, "-i", str(tmp_path), "-o", str(out_png), "-b", "white", "--quiet"],
capture_output=True,
text=True,
timeout=60,
)
if proc.returncode != 0:
print(f" [mmdc] returncode={proc.returncode}: {proc.stderr.strip()[:200]}", file=sys.stderr)
return False
return out_png.exists()
except (subprocess.TimeoutExpired, OSError) as e:
print(f" [mmdc] error: {e}", file=sys.stderr)
return False
finally:
try:
tmp_path.unlink()
except OSError:
pass
def render_via_mermaid_ink(source: str, out_png: Path) -> bool:
"""通过 mermaid.ink 公网 API 渲染。要联网。成功 True, 失败 False。"""
payload = base64.urlsafe_b64encode(source.strip().encode("utf-8")).decode("ascii").rstrip("=")
url = MERMAID_INK_URL.format(payload=payload)
try:
req = urllib.request.Request(url, headers={"User-Agent": "zcbot-proposal/1.0"})
with urllib.request.urlopen(req, timeout=30) as resp:
if resp.status != 200:
print(f" [mermaid.ink] HTTP {resp.status}", file=sys.stderr)
return False
data = resp.read()
if not data or len(data) < 100:
print(f" [mermaid.ink] payload too small ({len(data)} bytes)", file=sys.stderr)
return False
out_png.write_bytes(data)
return True
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError) as e:
print(f" [mermaid.ink] error: {e}", file=sys.stderr)
return False
def render_one(source: str, out_png: Path) -> str:
"""渲染一块 mermaid → png。返回使用的后端名 / "skip" / "fail""""
if out_png.exists():
return "cache"
if render_via_mmdc(source, out_png):
return "mmdc"
if render_via_mermaid_ink(source, out_png):
return "mermaid.ink"
return "fail"
def render_sections(sections_dir: Path) -> int:
if not sections_dir.is_dir():
print(f"[ERR] sections dir not found: {sections_dir}", file=sys.stderr)
return 2
figures_dir = sections_dir.parent / "figures"
figures_dir.mkdir(parents=True, exist_ok=True)
md_files = sorted(sections_dir.glob("*.md"))
if not md_files:
print(f"[ERR] no .md found in {sections_dir}", file=sys.stderr)
return 2
total = 0
by_backend: dict[str, int] = {}
fail_blocks: list[tuple[Path, str]] = []
for md in md_files:
text = md.read_text(encoding="utf-8")
blocks = find_mermaid_blocks(text)
if not blocks:
continue
for src in blocks:
total += 1
h = mermaid_hash(src)
png = figures_dir / f"fig_{h}.png"
backend = render_one(src, png)
by_backend[backend] = by_backend.get(backend, 0) + 1
cap = extract_caption(src) or "(no caption)"
mark = {"cache": "·", "mmdc": "+", "mermaid.ink": "+", "fail": "x"}[backend]
print(f" {mark} [{backend:11s}] {md.name} :: {h} :: {cap}")
if backend == "fail":
fail_blocks.append((md, cap))
print()
print(f"[OK] processed {total} mermaid block(s) -> {figures_dir}")
for b, c in sorted(by_backend.items()):
print(f" {b}: {c}")
if fail_blocks:
print()
print(f"[WARN] {len(fail_blocks)} block(s) failed to render. render_docx.py 会走 ASCII fallback.")
print(f" 要画真图: 装 mmdc (npm i -g @mermaid-js/mermaid-cli) 或保证联网走 mermaid.ink。")
for md, cap in fail_blocks:
print(f" - {md.name} :: {cap}")
return 0
def main() -> None:
ap = argparse.ArgumentParser(description="预处理 sections/*.md 的 mermaid 块 → figures/*.png")
ap.add_argument("sections_dir", type=Path, help="sections/*.md 目录")
args = ap.parse_args()
rc = render_sections(args.sections_dir)
sys.exit(rc)
if __name__ == "__main__":
main()

View File

@ -10,12 +10,17 @@
- 内联 markdown 解析: **加粗** / *斜体* / `等宽`
- 列表/引用文献项 ([N], 1., (1), , -, *) 各自独立成段
- markdown 表格自动识别, 包含分隔行 |---|---|
- 图片 ![caption](path.png) 居中插入 + 图题自动编号 ( 1 / 2 / ...)
- ```mermaid``` : sha1 <sections_dir>/../figures/fig_<hash>.png,
命中走图 + 图题; 未命中走 ASCII fallback (等宽字体保留 box drawing)
PNG render_diagrams.py 预生成,本脚本只做查表 + 插入
用法:
python render_docx.py <sections_dir> --fund-type key_rd -o <out.docx>
"""
from __future__ import annotations
import argparse
import hashlib
import re
import sys
from pathlib import Path
@ -327,9 +332,70 @@ def render_table(doc: Document, table_lines: list[str]) -> None:
run.bold = True
# ───────────────────────── 图片 + 图题 ─────────────────────────
# ![caption](path) 或 ![](path)
_IMAGE_LINE_RE = re.compile(r"^\s*!\[(?P<cap>[^\]]*)\]\((?P<src>[^)\s]+)\)\s*$")
# mermaid 块里第一行 %% caption: 关键技术关系架构
_MERMAID_CAPTION_RE = re.compile(r"^\s*%%\s*caption\s*:\s*(.+?)\s*$", re.IGNORECASE)
# 申报书正文最大图宽 (A4 - 左 3 - 右 2 = 16 cm,留 1 cm 边距更稳)
_MAX_IMG_WIDTH = Cm(15)
def mermaid_hash(source: str) -> str:
"""与 render_diagrams.py 同算法: sha1 前 10 位。"""
return hashlib.sha1(source.strip().encode("utf-8")).hexdigest()[:10]
def extract_mermaid_caption(source: str) -> str | None:
for ln in source.splitlines():
m = _MERMAID_CAPTION_RE.match(ln)
if m:
return m.group(1).strip()
return None
def _resolve_image_path(src: str, base_dir: Path) -> Path | None:
"""图片相对路径以 base_dir (单个 .md 所在目录) 为锚。"""
p = Path(src)
if not p.is_absolute():
p = (base_dir / p).resolve()
return p if p.is_file() else None
def add_image(doc: Document, png_path: Path, caption: str | None, ctx: dict) -> None:
"""居中插入图片 + 图题 (五号宋体居中, 编号自增)。"""
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.first_line_indent = None
p.paragraph_format.space_before = Pt(6)
p.paragraph_format.space_after = Pt(3)
run = p.add_run()
try:
run.add_picture(str(png_path), width=_MAX_IMG_WIDTH)
except Exception as e:
# 图片坏了不让整个 doc 崩, 退化成一条占位文字
run.add_text(f"[图片插入失败: {png_path.name}: {e}]")
return
ctx["fig_no"] = ctx.get("fig_no", 0) + 1
cap_p = doc.add_paragraph()
cap_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
cap_p.paragraph_format.first_line_indent = None
cap_p.paragraph_format.space_before = Pt(0)
cap_p.paragraph_format.space_after = Pt(6)
cap_text = f"{ctx['fig_no']} {caption}" if caption else f"{ctx['fig_no']}"
cap_run = cap_p.add_run(cap_text)
cap_run.font.size = Pt(10.5) # 五号
cap_run.bold = True
_set_run_fonts(cap_run, cn_font="宋体", en_font="Times New Roman")
# ───────────────────────── 主渲染 ─────────────────────────
def render_md_block(doc: Document, md_text: str) -> None:
def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
lines = md_text.splitlines()
i = 0
n = len(lines)
@ -346,7 +412,21 @@ def render_md_block(doc: Document, md_text: str) -> None:
i += 1
continue
# fenced 代码块 / ASCII 流程图 (```...``` 或 ~~~...~~~)
# 图片 ![caption](path.png) — 单独成行
m_img = _IMAGE_LINE_RE.match(line)
if m_img:
src = m_img.group("src")
cap = m_img.group("cap").strip() or None
png = _resolve_image_path(src, ctx["sections_dir"])
if png is not None:
add_image(doc, png, cap, ctx)
else:
# 找不到图片源: 留占位段防止 silently miss
add_body_paragraph(doc, f"[图片缺失: {src}]", indent=False)
i += 1
continue
# fenced 代码块 / ASCII 流程图 / mermaid (```...``` 或 ~~~...~~~)
m_fence = _FENCE_RE.match(line)
if m_fence:
fence = m_fence.group(1)
@ -361,6 +441,15 @@ def render_md_block(doc: Document, md_text: str) -> None:
break
code.append(lines[i]) # 不 rstrip, 保留原始空格
i += 1
# mermaid 块: 查 figures/fig_<hash>.png 缓存, 命中走图 + 题
if lang.lower() == "mermaid":
source = "\n".join(code)
png = ctx["figures_dir"] / f"fig_{mermaid_hash(source)}.png"
if png.is_file():
add_image(doc, png, extract_mermaid_caption(source), ctx)
continue
# else fall through to ASCII fallback (保留 mermaid 源文本)
add_code_block(doc, code, lang)
continue
@ -418,11 +507,18 @@ def render_sections(sections_dir: Path, out: Path, fund_type: str) -> None:
print(f"[ERR] no .md found in {sections_dir}", file=sys.stderr)
sys.exit(2)
figures_dir = sections_dir.parent / "figures"
ctx: dict = {
"sections_dir": sections_dir,
"figures_dir": figures_dir,
"fig_no": 0,
}
doc = init_doc()
add_toc(doc)
for f in md_files:
text = f.read_text(encoding="utf-8")
render_md_block(doc, text)
render_md_block(doc, text, ctx)
doc.add_page_break()
out.parent.mkdir(parents=True, exist_ok=True)
@ -432,7 +528,7 @@ def render_sections(sections_dir: Path, out: Path, fund_type: str) -> None:
chars = sum(len(p.text) for p in doc.paragraphs)
tbls = len(doc.tables)
print(f"[OK] rendered {len(md_files)} sections -> {out}")
print(f" paragraphs: {paras} | tables: {tbls} | total chars: {chars}")
print(f" paragraphs: {paras} | tables: {tbls} | figures: {ctx['fig_no']} | total chars: {chars}")
print(f" fund_type: {fund_type}")
print(f" font: 中文宋体小四 / 英文 Times New Roman 小四 / 行距 1.5 / 首行缩进 2 字符")
print(f" 提示: 在 Word 中打开后按 F9 (或右键目录 -> 更新域) 生成实际目录。")

View File

@ -79,9 +79,19 @@
针对 <场景 2 痛点>,亟需解决问题二 "<问题 2>";
项目研究内容
本项目围绕 N 个关键问题, 开展 M 项关键技术研究, 对应关系如图 2-2
[图 2-2 关键技术关系架构]
本项目围绕 N 个关键问题, 开展 M 项关键技术研究, 对应关系如图。
```
```mermaid
%% caption: 关键问题与关键技术对应关系
flowchart LR
Q1[关键问题 1<br/>多源异构数据融合] --> T1[技术 1<br/>跨模态对齐]
Q1 --> T2[技术 2<br/>实体消歧]
Q2[关键问题 2<br/>实时响应] --> T3[技术 3<br/>增量索引]
T1 & T2 & T3 --> P[支撑平台]
```
```
技术 1: <技术名>
针对 <具体痛点>, 提出 <方法>, 突破 <子技术 a/b/c>, 支持 <最终能达到>
@ -90,11 +100,22 @@
### (二) 项目拟采取的研究方法 (限 2000 字)
总体方法 + 各课题研究方法 (按"需求分析 → 体系设计 → 技术突破 → 系统研发 → 应用示范"5 阶段)。配 4-6 张图。
总体方法 + 各课题研究方法 (按"需求分析 → 体系设计 → 技术突破 → 系统研发 → 应用示范"5 阶段)。配 4-6 张图(mermaid `flowchart``graph`,见 SKILL.md 插图段)
### (三) 项目的技术路线 (限 2000 字)
总体路线图 + 各课题技术路线图。
总体路线图 + 各课题技术路线图。例:
```mermaid
%% caption: 项目总体技术路线
flowchart TB
A[需求分析] --> B[体系设计]
B --> C[关键技术突破]
C --> D[系统研发与集成]
D --> E[应用示范]
C -. 反馈 .-> B
E -. 反馈 .-> C
```
---
@ -141,6 +162,29 @@
按周期分 4 阶段 + 配甘特图 + 里程碑节点 (每 6-12 月一个: 时间 + 事件 + 关键指标 + 考核方式 + 交付物)。
甘特图两种画法,任选其一:
**A. mermaid gantt** (简单,自动跟图编号):
```mermaid
%% caption: 项目进度甘特图
gantt
title 项目 4 阶段进度安排
dateFormat YYYY-MM
section 第一阶段 需求与体系
需求调研与场景拆解 :a1, 2026-01, 4M
技术体系设计 :a2, after a1, 3M
section 第二阶段 关键技术
技术 1 突破 :b1, after a2, 6M
技术 2-3 并行 :b2, after a2, 8M
section 第三阶段 系统集成
平台研发 :c1, after b1, 6M
section 第四阶段 应用示范
示范点部署与评测 :d1, after c1, 6M
```
**B. matplotlib `barh`** (打印质量更佳,但代码长;甘特图本身简单时优先用 A):
`run_python`,把 png 落到 `<task_dir>/figures/gantt.png`,然后章节里 `![甘特图: 项目 4 阶段进度安排](figures/gantt.png)`。代码骨架见 SKILL.md "插图" 段。
---
## 10_organization.md — 第五部分 组织实施、保障措施及风险分析