skill(proposal): mermaid 文件名 hash→caption + quality_check 加图相关 4 拦截 + SKILL.md 精简; web cache fix

用户报"图没渲染到 docx",诊断后修三件事(同一根因链):
- web/app.py /v1/files/download 加 Cache-Control: no-cache
  Starlette FileResponse 只发 ETag/Last-Modified, 浏览器走启发式缓存,
  workspace 文件改了 SPA 预览看不到新版
- quality_check 新 check_figures(): 4 条规则
  1) figures/ 有 png 但 sections 0 个 ![]() 引用
  2) fenced 代码块出现 box-drawing 字符 (┌─┐│└─┘ 等)
  3) mermaid 块必须有首行 %% caption: <题>
  4) 同 task 内 mermaid caption 不能撞名
- render_diagrams.py: hash → caption 命名
  pass-1 验证 caption 完整 + 全 task 唯一, 缺/撞 退 2
  pass-2 渲染落 fig_<sanitized>.png, 总是覆盖
- render_docx.py: mermaid 块按 caption 查 fig_<caption>.png
  无 caption / 清洗空 / png 缺 → ASCII fallback
- SKILL.md ~193 → ~160 行:
  插图段 49→22 行(压 matplotlib 细节 + 删类型选择展开)
  反模式合并 ASCII/占位/手写图编号/缺 caption/撞名
  删"为什么两段式"长说理段

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-19 10:19:09 +08:00
parent 3ca37f7041
commit fafcb14d86
6 changed files with 243 additions and 105 deletions

View File

@ -21,6 +21,7 @@
## 已完成关键能力 ## 已完成关键能力
- **05-19 / dev SPA `/v1/files/download` 加 `Cache-Control: no-cache` + proposal skill mermaid 文件名 hash → caption + quality_check 加图相关 4 条拦截 + SKILL.md 精简 ~30%**:用户反馈"申报 skill 生成的图没有渲染到 docx 里"。诊断分两层:① 当下这次的真因不是 hash 也不是渲染管线 —— 是模型在 sections 里全写 ASCII 字符画(`┌─┐│`)+ 裸 ```...``` 围栏,从未用 mermaid + `![]()`,matplotlib 生成的 `figures/fig*.png` 静静躺着没人引用,render_docx 按规矩把 ASCII 当代码块原样画上,看起来"没图";② 接着用户反馈"实际文件已更新但浏览器还是旧版,新浏览器能看到新版"——SPA 预览端 fetch `/v1/files/download` 命中浏览器**启发式缓存**(Starlette FileResponse 只发 Last-Modified/ETag,无 Cache-Control,RFC 7234 默认按 mtime 启发式可缓数小时),旧浏览器没 conditional revalidation 就拿了缓存。**修法**:① `web/app.py::download_file``headers={"Cache-Control": "no-cache"}` —— 浏览器每次都重取(Starlette 不实现服务端 304,no-cache 在这里等价 no-store,workspace 文件小可接受;以后真要省流量再加 If-None-Match 处理);② `skills/proposal/scripts/quality_check.py::check_figures` 新加(共 4 条):**1) `figures/` 有 png 但 sections 0 个 `![](...)` 引用 → 图全没挂上**,2) 任何 fenced 代码块里出现 box-drawing 字符(`┌┐└┘├┤┬┴┼─│╔╗╚╝╠╣╦╩╬═║▲▼◀▶`)→ ASCII 字符画当图,3) mermaid 块必须有首行 `%% caption: <题>`,4) 同 task 内 mermaid caption 不能撞名;③ **hash → caption 命名重构**(讨论中用户先反对单字段 caption 想用 png 内容,后我提两字段 name+caption,用户最终拍板回归单字段 caption 简化):`render_diagrams.py` 删 `mermaid_hash()` + 改 caption 必填(缺 → 退 2)+ 全 task caption 唯一(撞名 → 退 2)+ 新 `caption_to_stem()` 清洗(保留 CJK/字母/数字,其它折 `_`,截 40 字)+ pass-1 验证 / pass-2 渲染两段式 + 总是覆盖渲染(去 cache 防 caption 不变源变了的孤儿);`render_docx.py` 删 `mermaid_hash()` + 改 caption 查表(同清洗规则),无 caption / 清洗空 / png 缺 → 走原 ASCII fallback;④ **SKILL.md 精简**(~193 行 → ~160 行):资源段更新 4 条脚本描述(render_diagrams 现在 caption 命名 / quality_check 现在 5 类拦截)+ 阶段三段不再吹 "render_diagrams 是可选前置"(改 caption 强制约定段)+ 插图段从 49 行压到 ~22 行(删类型选择细节展开 / 删 matplotlib 配色 dpi figsize 大段细节 → 一行;删 "为什么两段式"长说理段;反模式段合并 ASCII / 占位 / 手写图编号 / 缺 caption / 撞名为一条 "插图相关(`quality_check` 会拦)")。**为什么这一波改这么散**:四件事其实是一根线 —— 用户最初观察"图没出来"实际上是两个 bug 叠加(模型没用 mermaid + 浏览器缓存),修缓存是表层,加 quality_check 是防再犯,caption 命名是顺手把 hash 这层不可读性也清掉,SKILL.md 精简是承接两次改完后该删的冗余。**端到端 smoke**(`/tmp/zcbot_repro` 临时 task):mermaid 块 `%% caption: 总体架构``figures/fig_总体架构.png` 落盘 → docx `figures: 1` 报告对、`word/media/image1.png` 1278 bytes 嵌入;negative:缺 caption 退 2 / 撞名退 2(列出 md 位置 + 改名建议);quality_check 拦四条全打:`figures/ 有 N 张 png 0 个 ![]()` / `[md:L] ASCII 字符画 ┌─┐│└─┘` / `[md:L] mermaid 缺首行 %% caption` / `mermaid caption 撞名 X 出现在 md1:L1, md2:L2`。**没动**:`render_docx.py` 主体渲染逻辑(只换 mermaid 块查表那 ~5 行)/ matplotlib 章节生成的 png 命名习惯(`fig1_xxx.png` 风格留着,反正不冲突,`figures/` 同时存在 mermaid 的 `fig_<caption>.png` 与 matplotlib 的 `fig<N>_<desc>.png` 两种风格)/ `templates/*.md` 里 mermaid 示例首行 `%% caption:` 本来就有(只是历史可选,现在强制约定到位)。**hash → caption 兼容性**:dev phase no compat,直接切;旧 task 里若有 hash 命名的 png 留着,render_docx 找不到对应 `fig_<caption>.png` 就走 ASCII fallback,用户重跑 render_diagrams 自动按新规则落 png 即可。**文档**:**只动 PROGRESS + skills/proposal/SKILL.md**(skill 内容/脚本接口变化按 CLAUDE.md 规则不动 DESIGN/RUN —— skill 不是 zcbot 对外 CLI/env/文件布局;但 `Cache-Control` 改动是 `/v1/files/download` 行为微调,客户端无感、文档化为后续 follow-up 可选)。
- **05-19 / dev SPA 文件预览弹框**:用户提:"web 右侧点击文件可以弹框加载预览,带下载按钮"。原行为是 click → 直接 `downloadFile`(走 `/v1/files/download`)落盘,不能在线看。**方案**:复用现有 `/v1/files/download`(blob URL 绕过 auth header 限制,不动后端),前端按扩展名分派渲染器。新加 `#file-preview-modal`(90vw × 90vh,max 1200px),头部 filename + 下载 + × 关,body 按 cat 切不同布局。**分派**:① image(jpg/png/gif/webp/bmp/svg/ico)→ `<img>` blob URL;② pdf → `<iframe>` blob URL 强制 `application/pdf` mime,浏览器内置 PDF viewer;③ text 类(txt/log/json/yaml/csv/py/js/ts/go 等近 30 种)→ `<pre>` textContent,2MB 上限超限 fallback;④ md / markdown → 复用现有 `renderMd`(marked + DOMPurify + hljs);⑤ docx → 懒加载 `/static/vendor/jszip.min.js` + `docx-preview.min.js``window.docx.renderAsync(blob, host)` 渲染到 DOM,带表格 / 图片 / 样式还原;⑥ xlsx / xls → 懒加载 `xlsx.full.min.js`(SheetJS 社区版),多 sheet 出 tab 切换,`sheet_to_html` 直接出表格;⑦ 其它(pptx / doc / ppt / 未识别)→ fallback "暂不支持在线预览,请下载查看" + 大号下载按钮。**机制**:`loadScript()` 懒加载只在首次访问 office 文件才拉 1MB vendor;`_trackBlobUrl` + `_flushBlobUrls` 弹框关时统一 revoke 防漏;Esc / 点 backdrop 关弹框;auth 401 → logout;binary 50MB 上限兜底防 OOM。**库选型**:① docx 用 docx-preview(Apache-2.0,2k star,2025-07 还在发版,UMD/CDN OK,只依赖 JSZip,DOM 渲染,fidelity 显著优于 mammoth.js)② xlsx 用 SheetJS 社区版(Apache-2.0,长期维护,单文件 UMD,`sheet_to_html` 直出)③ pptx 整个社区 JS 库都不成熟(pptx-preview / PptxViewJS 都对动画 / 复杂版式失真),先 fallback,真有需求再上服务端 LibreOffice 转 PDF 统一处理。**新增 `web/static/vendor/`**(入 git,项目无 npm 工具链就是直 vendor;锁版本好处:复现部署一致 + 安全审计直观;~1MB 可接受):jszip 3.10.1 / docx-preview 0.3.6 / xlsx 0.18.5。**改 `web/static/dev.html`**(+~240 行 JS + ~60 行 CSS):file row .name onclick 从 downloadFile 切到 openFilePreview;现有 downloadFile 保留供 fallback / 头部下载按钮直接复用。**没动**:后端 app.py(blob URL 路径足够;弹框关闭统一 revoke 避免 URL 泄漏);DESIGN(纯 UI 增强非架构变化);RUN(无 CLI / env / 文件布局变化)。**文档**:**只动 PROGRESS + 文件清单加 vendor/ 目录**(按 CLAUDE.md 三文档边界)。 - **05-19 / dev SPA 文件预览弹框**:用户提:"web 右侧点击文件可以弹框加载预览,带下载按钮"。原行为是 click → 直接 `downloadFile`(走 `/v1/files/download`)落盘,不能在线看。**方案**:复用现有 `/v1/files/download`(blob URL 绕过 auth header 限制,不动后端),前端按扩展名分派渲染器。新加 `#file-preview-modal`(90vw × 90vh,max 1200px),头部 filename + 下载 + × 关,body 按 cat 切不同布局。**分派**:① image(jpg/png/gif/webp/bmp/svg/ico)→ `<img>` blob URL;② pdf → `<iframe>` blob URL 强制 `application/pdf` mime,浏览器内置 PDF viewer;③ text 类(txt/log/json/yaml/csv/py/js/ts/go 等近 30 种)→ `<pre>` textContent,2MB 上限超限 fallback;④ md / markdown → 复用现有 `renderMd`(marked + DOMPurify + hljs);⑤ docx → 懒加载 `/static/vendor/jszip.min.js` + `docx-preview.min.js``window.docx.renderAsync(blob, host)` 渲染到 DOM,带表格 / 图片 / 样式还原;⑥ xlsx / xls → 懒加载 `xlsx.full.min.js`(SheetJS 社区版),多 sheet 出 tab 切换,`sheet_to_html` 直接出表格;⑦ 其它(pptx / doc / ppt / 未识别)→ fallback "暂不支持在线预览,请下载查看" + 大号下载按钮。**机制**:`loadScript()` 懒加载只在首次访问 office 文件才拉 1MB vendor;`_trackBlobUrl` + `_flushBlobUrls` 弹框关时统一 revoke 防漏;Esc / 点 backdrop 关弹框;auth 401 → logout;binary 50MB 上限兜底防 OOM。**库选型**:① docx 用 docx-preview(Apache-2.0,2k star,2025-07 还在发版,UMD/CDN OK,只依赖 JSZip,DOM 渲染,fidelity 显著优于 mammoth.js)② xlsx 用 SheetJS 社区版(Apache-2.0,长期维护,单文件 UMD,`sheet_to_html` 直出)③ pptx 整个社区 JS 库都不成熟(pptx-preview / PptxViewJS 都对动画 / 复杂版式失真),先 fallback,真有需求再上服务端 LibreOffice 转 PDF 统一处理。**新增 `web/static/vendor/`**(入 git,项目无 npm 工具链就是直 vendor;锁版本好处:复现部署一致 + 安全审计直观;~1MB 可接受):jszip 3.10.1 / docx-preview 0.3.6 / xlsx 0.18.5。**改 `web/static/dev.html`**(+~240 行 JS + ~60 行 CSS):file row .name onclick 从 downloadFile 切到 openFilePreview;现有 downloadFile 保留供 fallback / 头部下载按钮直接复用。**没动**:后端 app.py(blob URL 路径足够;弹框关闭统一 revoke 避免 URL 泄漏);DESIGN(纯 UI 增强非架构变化);RUN(无 CLI / env / 文件布局变化)。**文档**:**只动 PROGRESS + 文件清单加 vendor/ 目录**(按 CLAUDE.md 三文档边界)。
- **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 / 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 / `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。

View File

@ -17,10 +17,10 @@ description: 撰写中国科研项目申报书 / 课题任务书 (国家重点
- `<skill_dir>/references/budget_rules.md` —— 间接费用台阶 + B1-B4 表 - `<skill_dir>/references/budget_rules.md` —— 间接费用台阶 + B1-B4 表
- `<skill_dir>/templates/spec_lock.md` —— 阶段一八条对齐的固定字段模板 (复制到 `<task_dir>/spec_lock.md`) - `<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>/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` ``、列表分行、`![](path)` 居中插图 + 图题自动编号、识别 mermaid 块查 `figures/` 缓存 - `<skill_dir>/scripts/render_docx.py` —— md→docx,自动加目录 / 解析 `**bold**`/`*italic*`/`` `code` `` / 列表分行 / `![](path)` 居中插图 + 图题自动编号 / 识别 mermaid 块按 caption 查 `figures/fig_<caption>.png`
- `<skill_dir>/scripts/render_diagrams.py` —— sections/*.md 里的 ```mermaid``` 块预渲染成 `<task_dir>/figures/fig_<hash>.png`(优先用本地 `mmdc`,回退 `mermaid.ink` 公网 API,失败留警告) - `<skill_dir>/scripts/render_diagrams.py` —— sections/*.md 里的 ```mermaid``` 块预渲染成 `<task_dir>/figures/fig_<caption>.png`(caption 必填 + 全 task 唯一,优先 `mmdc`、回退 `mermaid.ink`)
- `<skill_dir>/scripts/word_count.py` —— 章节字数 vs 预算 - `<skill_dir>/scripts/word_count.py` —— 章节字数 vs 预算
- `<skill_dir>/scripts/quality_check.py` —— 结构完整性 / 假大空话术 / 占位符未替换 / 指南覆盖度 (--spec 选项) - `<skill_dir>/scripts/quality_check.py` —— 结构完整性 / 假大空 / 占位符 / 指南覆盖度(`--spec`)/ 插图(无 `![]()` 引用 / ASCII 字符画 / mermaid 缺 caption / caption 撞名)
## 阶段零: 摄取素材 (有 PDF/DOCX/XLSX/URL 时才走) ## 阶段零: 摄取素材 (有 PDF/DOCX/XLSX/URL 时才走)
@ -65,22 +65,24 @@ markitdown https://example.com/x -o <task_dir>/source/policy.md
- 提问: "本段可以了吗?下一段要点要改 / 加 / 删什么?" - 提问: "本段可以了吗?下一段要点要改 / 加 / 删什么?"
7. ⛔ **BLOCKING:停下来等用户明确反馈** ("OK"、"下一段"、"继续") 后才动笔。"看起来不错"、沉默、追问都不算确认 —— 用户对下一段要点没异议也算默认通过,但**字数 / 与指南对齐异常**时必须主动追问 7. ⛔ **BLOCKING:停下来等用户明确反馈** ("OK"、"下一段"、"继续") 后才动笔。"看起来不错"、沉默、追问都不算确认 —— 用户对下一段要点没异议也算默认通过,但**字数 / 与指南对齐异常**时必须主动追问
**为什么两段式 + 强等 + 下一段预告?** 申报书 1.5-3 万字,模型连续生成容易自我加速、把错方向推到底。要点阶段拦得早,关键章节段段卡可以在第 2 段被用户拦下。**预告下一段要点**让用户在下一段还没动笔时就能改方向 —— 比读完正文再返工成本低一个量级 两段式 + 段段卡 + 预告下一段是为了拦早 —— 申报书 1.5-3 万字,模型连续生成容易把错方向推到底
**例外**: 用户**主动且明确**说"别问,直接全做"或"一气呵成" —— 才能一次跑完,跑完必须 `quality_check.py`。"逐章太慢"/"段段太碎"之类抱怨**不算**例外指令,继续问。 **例外**: 用户**主动且明确**说"别问,直接全做"或"一气呵成" —— 才能一次跑完,跑完必须 `quality_check.py`。"太慢"/"太碎"之类抱怨**不算**例外指令,继续问。
## 阶段三: 验收 + 渲染 ## 阶段三: 验收 + 渲染
```bash ```bash
python <skill_dir>/scripts/word_count.py <task_dir>/sections/ --fund-type key_rd 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/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_diagrams.py <task_dir>/sections/ # 章节有 ```mermaid 块就跑
python <skill_dir>/scripts/render_docx.py <task_dir>/sections/ --fund-type key_rd -o <task_dir>/<topic>.docx python <skill_dir>/scripts/render_docx.py <task_dir>/sections/ --fund-type key_rd -o <task_dir>/<topic>.docx
``` ```
`--spec` 让质量检查交叉对照 spec_lock,提示哪些指南考核指标在 sections 里没出现。不通过的项,回头 edit 对应章节 `quality_check` 不通过的项回头 edit 章节再跑
`render_diagrams.py` 是**可选**前置 —— 章节里没 mermaid 块就跳过。即使跑了但 mmdc 没装 + 联不上 mermaid.ink,`render_docx.py` 会把 mermaid 源文本以 ASCII 等宽兜底显示(评审能看,但不漂亮)。**只要 mermaid 块就能直接交给 `render_docx.py`,figures/ 没有就走 ASCII fallback,不会崩。** `render_diagrams.py` 把 ```mermaid 块预渲染成 `figures/fig_<caption>.png`(caption 取 mermaid 块首行 `%% caption:` 注释,清洗成文件名)。优先 `mmdc`、回退 `mermaid.ink`。没装也没联网时,`render_docx.py` 走 ASCII 等宽兜底(评审能看,但不漂亮)。
caption 必须写、必须全 task 唯一 —— render_diagrams / quality_check 都会拦,撞了改成更具体的题文(e.g. "训练阶段架构" / "推理阶段架构")。
## 工作目录 ## 工作目录
@ -108,53 +110,26 @@ python <skill_dir>/scripts/render_docx.py <task_dir>/sections/ --fund-type k
## 插图 (流程图 / 结构图 / 技术路线图 / 甘特图) ## 插图 (流程图 / 结构图 / 技术路线图 / 甘特图)
**禁止把 ASCII 字符画 (`┌─┐│└─┘ →`) 当真图交差** —— Word 里 box-drawing 与 CJK 不真等宽,标签一长就错位,评审看到直接扣印象分 **所有图都要落成 png 并在 md 里 `![](...)` 引用**;ASCII 字符画 (`┌─┐│`) Word 里必错位,`quality_check.py` 会 fail。图编号 `render_docx.py` 全局自增(图 1 / 图 2 ...),**不要手写"图 2-2"**
### 类型选择 | 图类型 | 工具 | 怎么用 |
| 图类型 | 用法 | 工具 |
|---|---|---| |---|---|---|
| 流程图 / 结构图 / 关系图 / 技术路线图 | flowchart / graph | **mermaid** (写在 md 里) | | 流程 / 结构 / 关系 / 技术路线 / 时序 / 甘特(也行) | **mermaid** | 直接写 ```mermaid 块,**首行必须** `%% caption: <题>`(必填 + 全 task 唯一,既是文件名也是 docx 图题),`render_diagrams.py` 渲成 `figures/fig_<caption>.png` |
| 时序图 (调用关系) | sequenceDiagram | **mermaid** | | 数据图表 (柱/折/饼) / 甘特(更适合打印) | **matplotlib** | 落到 `<task_dir>/figures/<name>.png`,md 里 `![desc](figures/<name>.png)` |
| 甘特图 (进度安排) | gantt | **mermaid** 或 matplotlib `barh` (matplotlib 更适合打印) | | 已有 png/jpg (截图/设计稿) | 直接 | 同上 `![]()` |
| 数据图表 (柱/折/饼) | 数据可视化 | **matplotlib**(配 spec_lock 主色,生成 png 后 ![]() 引用) |
| 已有图片 (截图 / 设计稿 / 厂商架构图) | 直接 ![]() | 用户提供 png/jpg |
### mermaid 块约定 mermaid 块示例:
直接写进章节 `.md` 里。**第一行加 `%% caption: <图题>` 注释**(题号 render_docx.py 自动加),mermaid 当注释跳过,render_docx 当题文用:
````markdown ````markdown
```mermaid ```mermaid
%% caption: 关键技术与研究内容映射 %% caption: 关键技术与研究内容映射
flowchart LR flowchart LR
Q1[关键问题 1<br/>多源异构数据融合] --> T1[技术 1<br/>跨模态对齐] Q1[问题1] --> T1[技术1] & T2[技术2]
Q1 --> T2[技术 2<br/>实体消歧] T1 & T2 --> P[平台]
Q2[关键问题 2<br/>实时响应] --> T3[技术 3<br/>增量索引]
T1 & T2 & T3 --> P[支撑平台]
``` ```
```` ````
`render_diagrams.py` 一遍 → `figures/fig_<hash>.png` 落盘 → 再跑 `render_docx.py` 会把这个块替换成"图 N 关键技术与研究内容映射"居中插图。 matplotlib 注意:中文字体 `plt.rcParams['font.sans-serif']=['SimHei','Microsoft YaHei']` + `axes.unicode_minus=False`;`figsize=(10,4)` / `dpi=150` / `bbox_inches='tight'`;颜色用 spec_lock 主色而非默认色板;`render_docx` 自动限宽 15cm。
### 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 编号等真有需要再扩。
## 硬规则速查 (违反即扣分) ## 硬规则速查 (违反即扣分)
@ -169,18 +144,13 @@ flowchart LR
## 反模式 ## 反模式
- 未 spec_lock 就开始硬编正文 - 未 spec_lock 就硬编正文 / 一次性出全文 / 跳过"列要点"直接写正文
- 一次性出全文 (中途改向就全推翻) - 关键章节(立项依据/研究方案/技术路线/考核指标)整章一次出 —— 必须段段卡
- 跳过"列要点"直接写正文 —— 即使只有一章也要先列要点过一遍 - **基于"通用模板"自行套基金类型** —— 重大专项 vs 国自然结构完全不同,先查 `fund_types.md`
- 关键章节 (立项依据 / 研究方案 / 技术路线 / 考核指标) 整章一次出 —— 必须段段卡 - **自己造数据/指标/单位/经费** —— 不知道就 `<TODO 待用户提供>`
- **基于"通用模板"自行套基金类型** —— 重大专项任务书与国自然申请书结构完全不同,**先查 `fund_types.md`**
- **自己造数据/指标/单位/经费** —— 不知道就 `<TODO 待用户提供>`,不要硬编
- 引文写"[Smith et al., 2023]" 但其实没这篇文献 - 引文写"[Smith et al., 2023]" 但其实没这篇文献
- 不跑 `quality_check.py` 就交付 - 不跑 `quality_check.py` 就交付 / 文件名 `output.docx` / `申报书.docx`(按主题命名)
- 文件名 `output.docx` / `申报书.docx` —— 按主题命名 - 插图相关(`quality_check.py` 会拦):ASCII 字符画当图、`[图 2-2 xxx]` 裸占位、手写图编号、mermaid 块缺 caption、caption 撞名
- **用 ASCII 字符画 `┌─┐│└─┘ →` 当流程图/结构图** —— 中文 Word 里必错位,看插图段
- **手写图编号 (图 2-2 / 图 3-1)** —— render_docx 自动全局编号,手写会撞
- **`[图 2-2 关键技术架构]` 这种裸占位** —— 占位等于没图,要么写 mermaid 块要么 ![]() 引 png
## 输出 ## 输出

View File

@ -6,6 +6,7 @@
- 指标可考核性: 是否有"显著 / 大幅 / 优异"等不可量化词 - 指标可考核性: 是否有"显著 / 大幅 / 优异"等不可量化词
- 引文真实性: 占位符 [REF-xx] / [Smith et al., 2023] / <TODO> 是否还在 - 引文真实性: 占位符 [REF-xx] / [Smith et al., 2023] / <TODO> 是否还在
- 经费表占位符: 总预算 / 中央财政 等是否还是空格 - 经费表占位符: 总预算 / 中央财政 等是否还是空格
- 插图: figures/ png sections 里没 ![]() 引用; 代码块里出现 ASCII 字符画
用法: 用法:
python quality_check.py <sections_dir> --fund-type key_rd python quality_check.py <sections_dir> --fund-type key_rd
@ -57,6 +58,21 @@ PLACEHOLDER_PATTERNS = [
] ]
# 插图相关
_BOX_DRAWING_RE = re.compile(r"[┌┐└┘├┤┬┴┼─│╔╗╚╝╠╣╦╩╬═║▲▼◀▶]")
_IMAGE_REF_RE = re.compile(r"!\[[^\]]*\]\([^)\s]+\)")
_FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})\s*(\S*)\s*$")
_MERMAID_CAPTION_RE = re.compile(r"^\s*%%\s*caption\s*:\s*(.+?)\s*$", re.IGNORECASE)
def _extract_mermaid_caption(block_lines: list[str]) -> str | None:
for ln in block_lines:
m = _MERMAID_CAPTION_RE.match(ln)
if m:
return m.group(1).strip()
return None
def check_structure(sections_dir: Path, fund_type: str) -> list[str]: def check_structure(sections_dir: Path, fund_type: str) -> list[str]:
required = REQUIRED_SECTIONS.get(fund_type, []) required = REQUIRED_SECTIONS.get(fund_type, [])
existing = {f.stem for f in sections_dir.glob("*.md")} existing = {f.stem for f in sections_dir.glob("*.md")}
@ -118,6 +134,85 @@ def parse_spec_metrics(spec_path: Path) -> list[str]:
return out return out
def check_figures(sections_dir: Path) -> list[str]:
"""四条插图规则:
1) figures/ png sections 0 ![](...) 引用 -> 图全没挂上
2) 任何 fenced 代码块里出现 box-drawing 字符 -> ASCII 字符画当图, Word 必错位
3) mermaid 块必须有首行 '%% caption: <题>' -> render_diagrams 靠它命名
4) task mermaid caption 不能撞名 -> 文件名冲突
"""
issues: list[str] = []
figures_dir = sections_dir.parent / "figures"
pngs = list(figures_dir.glob("*.png")) if figures_dir.is_dir() else []
total_img_refs = 0
ascii_art_blocks: list[tuple[str, int]] = []
mermaid_no_caption: list[tuple[str, int]] = []
mermaid_captions: dict[str, list[str]] = {} # caption -> [md:line, ...]
for md in sorted(sections_dir.glob("*.md")):
text = md.read_text(encoding="utf-8")
total_img_refs += len(_IMAGE_REF_RE.findall(text))
lines = text.splitlines()
i = 0
while i < len(lines):
m = _FENCE_RE.match(lines[i])
if not m:
i += 1
continue
fence = m.group(1)
lang = (m.group(2) or "").lower()
block_line = i + 1
i += 1
buf: list[str] = []
while i < len(lines):
mc = _FENCE_RE.match(lines[i])
if mc and mc.group(1)[0] == fence[0] and len(mc.group(1)) >= len(fence):
i += 1
break
buf.append(lines[i])
i += 1
if lang == "mermaid":
cap = _extract_mermaid_caption(buf)
if not cap:
mermaid_no_caption.append((md.name, block_line))
else:
mermaid_captions.setdefault(cap, []).append(f"{md.name}:{block_line}")
continue
if any(_BOX_DRAWING_RE.search(ln) for ln in buf):
ascii_art_blocks.append((md.name, block_line))
if pngs and total_img_refs == 0:
names = ", ".join(p.name for p in pngs[:4])
more = f" ... +{len(pngs) - 4}" if len(pngs) > 4 else ""
issues.append(
f"figures/ 有 {len(pngs)} 张 png ({names}{more}) 但 sections 里 0 个 ![](...) 引用 — "
f"图全没挂上, 在对应章节加 ![desc](figures/<name>.png)"
)
for fname, lineno in ascii_art_blocks:
issues.append(
f"[{fname}:~{lineno}] 代码块里有 ASCII 字符画 (┌─┐│└─┘) — "
f"中文 Word 必错位, 改 ```mermaid 块或 ![](figures/x.png)"
)
for fname, lineno in mermaid_no_caption:
issues.append(
f"[{fname}:~{lineno}] mermaid 块缺首行 '%% caption: <图题>'"
f"render_diagrams 靠 caption 命名 png, 没 caption 不渲染"
)
for cap, locs in mermaid_captions.items():
if len(locs) > 1:
issues.append(
f"mermaid caption 撞名: {cap!r} 出现在 {', '.join(locs)}"
f"caption 必须全 task 唯一, 改成更具体的题文"
)
return issues
def check_spec_coverage(sections_dir: Path, spec_path: Path) -> list[str]: def check_spec_coverage(sections_dir: Path, spec_path: Path) -> list[str]:
"""每条指南考核指标必须在某个章节里以**关键词**形式出现 (>=2 个核心词命中)。""" """每条指南考核指标必须在某个章节里以**关键词**形式出现 (>=2 个核心词命中)。"""
metrics = parse_spec_metrics(spec_path) metrics = parse_spec_metrics(spec_path)
@ -181,7 +276,17 @@ def main() -> None:
print(f" -{s.split('] ', 1)[1]}") print(f" -{s.split('] ', 1)[1]}")
all_issues.extend(sub_issues) all_issues.extend(sub_issues)
# 5. 指南覆盖度 (--spec 提供时) # 5. 插图引用 / ASCII 字符画
fig_issues = check_figures(args.sections_dir)
if fig_issues:
print("\n[ERR] 插图问题:")
for s in fig_issues:
print(f" -{s}")
all_issues.extend(fig_issues)
else:
print("\n[OK] 插图引用 / 无 ASCII 字符画")
# 6. 指南覆盖度 (--spec 提供时)
if args.spec: if args.spec:
if not args.spec.exists(): if not args.spec.exists():
print(f"\n[ERR] spec 文件不存在: {args.spec}") print(f"\n[ERR] spec 文件不存在: {args.spec}")
@ -204,6 +309,7 @@ def main() -> None:
print(" - 假大空词组 -> 换成具体数字 / 对比") print(" - 假大空词组 -> 换成具体数字 / 对比")
print(" - 不可考核词 -> 量化指标 (TPS / 准确率 / 万元 / N 篇)") print(" - 不可考核词 -> 量化指标 (TPS / 准确率 / 万元 / N 篇)")
print(" - 占位符未替换 -> 找用户提供真实数据 / 替换 <TODO>") print(" - 占位符未替换 -> 找用户提供真实数据 / 替换 <TODO>")
print(" - 插图未挂 / ASCII 字符画 -> ```mermaid 块或 ![](figures/x.png)")
print(" - 未覆盖指南指标 -> 在对应章节明确写出该指标的实现方式") print(" - 未覆盖指南指标 -> 在对应章节明确写出该指标的实现方式")
if args.strict: if args.strict:
sys.exit(1) sys.exit(1)

View File

@ -1,18 +1,22 @@
"""预处理 sections/*.md 里的 mermaid 块 → 缓存为 figures/fig_<hash>.png。 """预处理 sections/*.md 里的 mermaid 块 → 渲染为 figures/fig_<caption>.png。
不改动 sections/*.md(mermaid 源是真相,留着方便迭代),只往 <sections_dir>/../figures/ 不改动 sections/*.md, 只往 <sections_dir>/../figures/ 下落 PNG
下落 PNG 缓存, mermaid 源的 sha1 前缀为文件名render_docx.py 在遇到 ```mermaid render_docx.py 在遇到 ```mermaid 块时按相同 caption figures/, 有就插图 + 图题,
时按相同 hash figures/,有就插图 + 图题,没有就 ASCII 兜底 没有就 ASCII 兜底
caption 命名规则:
- 每个 mermaid **必须**有首行注释 `%% caption: <图题>`, 否则直接报错退出
- caption 在全 task 内必须唯一, 撞了就报错 (强制起更具体的题, e.g.
"训练阶段架构" / "推理阶段架构")
- 文件名 = caption 清洗后 (保留 CJK / 字母 / 数字, 其它字符 '_', 40 )
前缀加 'fig_', e.g. caption "关键技术映射" figures/fig_关键技术映射.png
渲染后端选择 (按优先级): 渲染后端选择 (按优先级):
1. 本地 mmdc (mermaid-cli) 最高质量, Node.js + npm i -g @mermaid-js/mermaid-cli 1. 本地 mmdc (mermaid-cli) 最高质量, Node.js + npm i -g @mermaid-js/mermaid-cli
2. mermaid.ink 公网 API 不装东西,要联网 2. mermaid.ink 公网 API 不装东西,要联网
两种都没,留警告退出 0(让流水线继续),render_docx.py ASCII fallback 两种都没, 留警告退出 0 (让流水线继续), render_docx.py ASCII fallback
caption / caption 撞名直接退出 2 (硬错, 必须改 md)
题注约定:mermaid 块第一行可写
%% caption: 关键技术关系架构
caption 不写也能渲染,题号自动在 render_docx.py 里递增
用法: 用法:
python render_diagrams.py <task_dir>/sections/ python render_diagrams.py <task_dir>/sections/
@ -21,7 +25,6 @@ from __future__ import annotations
import argparse import argparse
import base64 import base64
import hashlib
import re import re
import shutil import shutil
import subprocess import subprocess
@ -29,18 +32,27 @@ import sys
import tempfile import tempfile
import urllib.error import urllib.error
import urllib.request import urllib.request
from collections import defaultdict
from pathlib import Path from pathlib import Path
_FENCE_OPEN_RE = re.compile(r"^\s*```\s*mermaid\s*$") _FENCE_OPEN_RE = re.compile(r"^\s*```\s*mermaid\s*$")
_FENCE_CLOSE_RE = re.compile(r"^\s*```\s*$") _FENCE_CLOSE_RE = re.compile(r"^\s*```\s*$")
_CAPTION_RE = re.compile(r"^\s*%%\s*caption\s*:\s*(.+?)\s*$", re.IGNORECASE) _CAPTION_RE = re.compile(r"^\s*%%\s*caption\s*:\s*(.+?)\s*$", re.IGNORECASE)
_FILENAME_INVALID_RE = re.compile(r"[^一-鿿A-Za-z0-9]+")
MERMAID_INK_URL = "https://mermaid.ink/img/{payload}?type=png&bgColor=FFFFFF" MERMAID_INK_URL = "https://mermaid.ink/img/{payload}?type=png&bgColor=FFFFFF"
def mermaid_hash(source: str) -> str: def caption_to_stem(caption: str) -> str:
"""对 mermaid 源算 sha1, 取前 10 位作为文件名稳定 id。""" """caption → 'fig_<sanitized>' (无扩展名).
return hashlib.sha1(source.strip().encode("utf-8")).hexdigest()[:10]
保留 CJK / 拉丁字母 / 数字, 其它字符折成 '_', 头尾去 '_', 40
清洗后为空 ValueError
"""
cleaned = _FILENAME_INVALID_RE.sub("_", caption).strip("_")[:40]
if not cleaned:
raise ValueError(f"caption sanitizes to empty: {caption!r}")
return f"fig_{cleaned}"
def extract_caption(source: str) -> str | None: def extract_caption(source: str) -> str | None:
@ -122,9 +134,10 @@ def render_via_mermaid_ink(source: str, out_png: Path) -> bool:
def render_one(source: str, out_png: Path) -> str: def render_one(source: str, out_png: Path) -> str:
"""渲染一块 mermaid → png。返回使用的后端名 / "skip" / "fail"""" """渲染一块 mermaid → png。返回使用的后端名 / "fail"
if out_png.exists():
return "cache" 总是覆盖渲染 (caption 没改但 mermaid 源改了的情况下也能更新 png)
"""
if render_via_mmdc(source, out_png): if render_via_mmdc(source, out_png):
return "mmdc" return "mmdc"
if render_via_mermaid_ink(source, out_png): if render_via_mermaid_ink(source, out_png):
@ -145,29 +158,62 @@ def render_sections(sections_dir: Path) -> int:
print(f"[ERR] no .md found in {sections_dir}", file=sys.stderr) print(f"[ERR] no .md found in {sections_dir}", file=sys.stderr)
return 2 return 2
total = 0 # Pass 1: 收集所有块 + 验证 caption 完整性 / 唯一性
by_backend: dict[str, int] = {} blocks_meta: list[tuple[Path, str, str]] = [] # (md, caption, source)
fail_blocks: list[tuple[Path, str]] = [] missing_cap: list[Path] = []
for md in md_files: for md in md_files:
text = md.read_text(encoding="utf-8") text = md.read_text(encoding="utf-8")
blocks = find_mermaid_blocks(text) for src in find_mermaid_blocks(text):
if not blocks: cap = extract_caption(src)
if not cap:
missing_cap.append(md)
continue continue
for src in blocks: blocks_meta.append((md, cap, src))
total += 1
h = mermaid_hash(src) fatal = False
png = figures_dir / f"fig_{h}.png" if missing_cap:
print("[ERR] 以下 md 里有 mermaid 块缺首行 '%% caption: <图题>':", file=sys.stderr)
for md in missing_cap:
print(f" - {md.name}", file=sys.stderr)
fatal = True
by_cap: dict[str, list[str]] = defaultdict(list)
for md, cap, _ in blocks_meta:
by_cap[cap].append(md.name)
dups = [(c, mds) for c, mds in by_cap.items() if len(mds) > 1]
if dups:
print("[ERR] caption 在全 task 内必须唯一, 以下撞名:", file=sys.stderr)
for c, mds in dups:
print(f" - {c!r} 出现在: {', '.join(mds)}", file=sys.stderr)
print(" 改成更具体的题文 (e.g. '训练阶段架构' / '推理阶段架构')", file=sys.stderr)
fatal = True
if fatal:
return 2
# Pass 2: 渲染
if not blocks_meta:
print(f"[OK] no mermaid block found in {sections_dir} (nothing to render)")
return 0
by_backend: dict[str, int] = {}
fail_blocks: list[tuple[Path, str]] = []
for md, cap, src in blocks_meta:
try:
stem = caption_to_stem(cap)
except ValueError as e:
print(f"[ERR] {md.name}: {e}", file=sys.stderr)
return 2
png = figures_dir / f"{stem}.png"
backend = render_one(src, png) backend = render_one(src, png)
by_backend[backend] = by_backend.get(backend, 0) + 1 by_backend[backend] = by_backend.get(backend, 0) + 1
cap = extract_caption(src) or "(no caption)" mark = {"mmdc": "+", "mermaid.ink": "+", "fail": "x"}[backend]
mark = {"cache": "·", "mmdc": "+", "mermaid.ink": "+", "fail": "x"}[backend] print(f" {mark} [{backend:11s}] {md.name} :: {png.name} :: {cap}")
print(f" {mark} [{backend:11s}] {md.name} :: {h} :: {cap}")
if backend == "fail": if backend == "fail":
fail_blocks.append((md, cap)) fail_blocks.append((md, cap))
print() print()
print(f"[OK] processed {total} mermaid block(s) -> {figures_dir}") print(f"[OK] processed {len(blocks_meta)} mermaid block(s) -> {figures_dir}")
for b, c in sorted(by_backend.items()): for b, c in sorted(by_backend.items()):
print(f" {b}: {c}") print(f" {b}: {c}")

View File

@ -11,16 +11,16 @@
- 列表/引用文献项 ([N], 1., (1), , -, *) 各自独立成段 - 列表/引用文献项 ([N], 1., (1), , -, *) 各自独立成段
- markdown 表格自动识别, 包含分隔行 |---|---| - markdown 表格自动识别, 包含分隔行 |---|---|
- 图片 ![caption](path.png) 居中插入 + 图题自动编号 ( 1 / 2 / ...) - 图片 ![caption](path.png) 居中插入 + 图题自动编号 ( 1 / 2 / ...)
- ```mermaid``` : sha1 <sections_dir>/../figures/fig_<hash>.png, - ```mermaid``` : 读首行 `%% caption: <>`, caption 清洗后查
命中走图 + 图题; 未命中走 ASCII fallback (等宽字体保留 box drawing) <sections_dir>/../figures/fig_<caption>.png, 命中走图 + 图题;
PNG render_diagrams.py 预生成,本脚本只做查表 + 插入 未命中走 ASCII fallback (等宽字体保留 box drawing)
PNG render_diagrams.py 预生成, 本脚本只做查表 + 插入
用法: 用法:
python render_docx.py <sections_dir> --fund-type key_rd -o <out.docx> python render_docx.py <sections_dir> --fund-type key_rd -o <out.docx>
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import hashlib
import re import re
import sys import sys
from pathlib import Path from pathlib import Path
@ -339,14 +339,18 @@ _IMAGE_LINE_RE = re.compile(r"^\s*!\[(?P<cap>[^\]]*)\]\((?P<src>[^)\s]+)\)\s*$")
# mermaid 块里第一行 %% caption: 关键技术关系架构 # mermaid 块里第一行 %% caption: 关键技术关系架构
_MERMAID_CAPTION_RE = re.compile(r"^\s*%%\s*caption\s*:\s*(.+?)\s*$", re.IGNORECASE) _MERMAID_CAPTION_RE = re.compile(r"^\s*%%\s*caption\s*:\s*(.+?)\s*$", re.IGNORECASE)
_FILENAME_INVALID_RE = re.compile(r"[^一-鿿A-Za-z0-9]+")
# 申报书正文最大图宽 (A4 - 左 3 - 右 2 = 16 cm,留 1 cm 边距更稳) # 申报书正文最大图宽 (A4 - 左 3 - 右 2 = 16 cm,留 1 cm 边距更稳)
_MAX_IMG_WIDTH = Cm(15) _MAX_IMG_WIDTH = Cm(15)
def mermaid_hash(source: str) -> str: def caption_to_stem(caption: str) -> str:
"""与 render_diagrams.py 同算法: sha1 前 10 位。""" """与 render_diagrams.caption_to_stem 同规则: 清洗后取 'fig_<sanitized>'"""
return hashlib.sha1(source.strip().encode("utf-8")).hexdigest()[:10] cleaned = _FILENAME_INVALID_RE.sub("_", caption).strip("_")[:40]
if not cleaned:
return ""
return f"fig_{cleaned}"
def extract_mermaid_caption(source: str) -> str | None: def extract_mermaid_caption(source: str) -> str | None:
@ -442,14 +446,18 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
code.append(lines[i]) # 不 rstrip, 保留原始空格 code.append(lines[i]) # 不 rstrip, 保留原始空格
i += 1 i += 1
# mermaid 块: 查 figures/fig_<hash>.png 缓存, 命中走图 + 题 # mermaid 块: 按 caption 清洗后查 figures/fig_<caption>.png
if lang.lower() == "mermaid": if lang.lower() == "mermaid":
source = "\n".join(code) source = "\n".join(code)
png = ctx["figures_dir"] / f"fig_{mermaid_hash(source)}.png" cap = extract_mermaid_caption(source)
if cap:
stem = caption_to_stem(cap)
if stem:
png = ctx["figures_dir"] / f"{stem}.png"
if png.is_file(): if png.is_file():
add_image(doc, png, extract_mermaid_caption(source), ctx) add_image(doc, png, cap, ctx)
continue continue
# else fall through to ASCII fallback (保留 mermaid 源文本) # else fall through to ASCII fallback (无 caption / 未渲染)
add_code_block(doc, code, lang) add_code_block(doc, code, lang)
continue continue

View File

@ -820,7 +820,14 @@ def create_app() -> FastAPI:
raise HTTPException(404, f"file not found: {path}") raise HTTPException(404, f"file not found: {path}")
if not target.is_file(): if not target.is_file():
raise HTTPException(400, f"not a file: {path}") raise HTTPException(400, f"not a file: {path}")
return FileResponse(path=str(target), filename=target.name) # workspace 文件可变, 禁浏览器启发式缓存 (RFC 7234 默认能缓数小时)
# 否则文件改了 SPA 预览还是旧内容
# (Starlette FileResponse 不实现 304, 总是 200 全量; workspace 文件小, 可接受)
return FileResponse(
path=str(target),
filename=target.name,
headers={"Cache-Control": "no-cache"},
)
@app.post("/v1/files/upload", tags=["files"]) @app.post("/v1/files/upload", tags=["files"])
async def upload_files( async def upload_files(