skill(proposal): mermaid 管线 + render_docx 图片插入 + 图题自动编号
新增 render_diagrams.py 把 ```mermaid``` 块预渲染到 figures/fig_<sha1>.png (优先本地 mmdc, 回退 mermaid.ink 公网 API, 都失败留 WARN 不阻塞); render_docx.py 加  识别 + mermaid 缓存查找, 缺缓存自动 ASCII fallback, 图题"图 N <caption>"全局自增, 替换原模板里的 [图 2-2 ...] 裸占位写法。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9aa2efc335
commit
d6fc004367
|
|
@ -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 行):① 加 `` 单行识别 → `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 层早就拦了)。
|
||||
|
|
|
|||
|
|
@ -0,0 +1,244 @@
|
|||
"""Smoke: render_docx.py 图片 + mermaid 缓存路径。
|
||||
|
||||
构造一个临时 sections/, figures/ 结构, 跑 render_docx, 验证:
|
||||
- mermaid 块 hash 在 figures/ 有对应 png → 走插图路径
|
||||
- mermaid 块 hash 在 figures/ 没 png → 走 ASCII fallback (不崩, 文本保留)
|
||||
-  直接图片 → 走插图路径
|
||||
- 图编号自增
|
||||
- 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()}
|
||||
```
|
||||
|
||||
再看一张直接引用的图:
|
||||
|
||||

|
||||
|
||||
末尾段。
|
||||
""",
|
||||
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\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()
|
||||
|
|
@ -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` ``、列表分行、`` 居中插图 + 图题自动编号、识别 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 选项)
|
||||
|
||||
|
|
@ -71,13 +72,16 @@ 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_docx.py <task_dir>/sections/ --fund-type key_rd -o <task_dir>/<topic>.docx
|
||||
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
|
||||

|
||||
```
|
||||
|
||||
写 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
|
||||
|
||||
## 输出
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -10,12 +10,17 @@
|
|||
- 内联 markdown 解析: **加粗** / *斜体* / `等宽`
|
||||
- 列表/引用文献项 ([N], 1., (1), 一、, -, *) 各自独立成段
|
||||
- markdown 表格自动识别, 包含分隔行 |---|---|
|
||||
- 图片  居中插入 + 图题自动编号 (图 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
|
||||
|
||||
|
||||
# ───────────────────────── 图片 + 图题 ─────────────────────────
|
||||
|
||||
#  或 
|
||||
_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 流程图 (```...``` 或 ~~~...~~~)
|
||||
# 图片  — 单独成行
|
||||
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 (或右键目录 -> 更新域) 生成实际目录。")
|
||||
|
|
|
|||
|
|
@ -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`,然后章节里 ``。代码骨架见 SKILL.md "插图" 段。
|
||||
|
||||
---
|
||||
|
||||
## 10_organization.md — 第五部分 组织实施、保障措施及风险分析
|
||||
|
|
|
|||
Loading…
Reference in New Issue