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:
parent
3ca37f7041
commit
fafcb14d86
|
|
@ -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 行):① 加 `` 单行识别 → `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 行):① 加 `` 单行识别 → `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。
|
||||||
|
|
|
||||||
|
|
@ -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` ``、列表分行、`` 居中插图 + 图题自动编号、识别 mermaid 块查 `figures/` 缓存
|
- `<skill_dir>/scripts/render_docx.py` —— md→docx,自动加目录 / 解析 `**bold**`/`*italic*`/`` `code` `` / 列表分行 / `` 居中插图 + 图题自动编号 / 识别 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 里 `` |
|
||||||
| 甘特图 (进度安排) | 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
|
|
||||||

|
|
||||||
```
|
|
||||||
|
|
||||||
写 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
|
|
||||||
|
|
||||||
## 输出
|
## 输出
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"图全没挂上, 在对应章节加 "
|
||||||
|
)
|
||||||
|
|
||||||
|
for fname, lineno in ascii_art_blocks:
|
||||||
|
issues.append(
|
||||||
|
f"[{fname}:~{lineno}] 代码块里有 ASCII 字符画 (┌─┐│└─┘) — "
|
||||||
|
f"中文 Word 必错位, 改 ```mermaid 块或 "
|
||||||
|
)
|
||||||
|
|
||||||
|
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 块或 ")
|
||||||
print(" - 未覆盖指南指标 -> 在对应章节明确写出该指标的实现方式")
|
print(" - 未覆盖指南指标 -> 在对应章节明确写出该指标的实现方式")
|
||||||
if args.strict:
|
if args.strict:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
continue
|
if not cap:
|
||||||
for src in blocks:
|
missing_cap.append(md)
|
||||||
total += 1
|
continue
|
||||||
h = mermaid_hash(src)
|
blocks_meta.append((md, cap, src))
|
||||||
png = figures_dir / f"fig_{h}.png"
|
|
||||||
backend = render_one(src, png)
|
fatal = False
|
||||||
by_backend[backend] = by_backend.get(backend, 0) + 1
|
if missing_cap:
|
||||||
cap = extract_caption(src) or "(no caption)"
|
print("[ERR] 以下 md 里有 mermaid 块缺首行 '%% caption: <图题>':", file=sys.stderr)
|
||||||
mark = {"cache": "·", "mmdc": "+", "mermaid.ink": "+", "fail": "x"}[backend]
|
for md in missing_cap:
|
||||||
print(f" {mark} [{backend:11s}] {md.name} :: {h} :: {cap}")
|
print(f" - {md.name}", file=sys.stderr)
|
||||||
if backend == "fail":
|
fatal = True
|
||||||
fail_blocks.append((md, cap))
|
|
||||||
|
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)
|
||||||
|
by_backend[backend] = by_backend.get(backend, 0) + 1
|
||||||
|
mark = {"mmdc": "+", "mermaid.ink": "+", "fail": "x"}[backend]
|
||||||
|
print(f" {mark} [{backend:11s}] {md.name} :: {png.name} :: {cap}")
|
||||||
|
if backend == "fail":
|
||||||
|
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}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,16 +11,16 @@
|
||||||
- 列表/引用文献项 ([N], 1., (1), 一、, -, *) 各自独立成段
|
- 列表/引用文献项 ([N], 1., (1), 一、, -, *) 各自独立成段
|
||||||
- markdown 表格自动识别, 包含分隔行 |---|---|
|
- markdown 表格自动识别, 包含分隔行 |---|---|
|
||||||
- 图片  居中插入 + 图题自动编号 (图 1 / 图 2 / ...)
|
- 图片  居中插入 + 图题自动编号 (图 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 png.is_file():
|
if cap:
|
||||||
add_image(doc, png, extract_mermaid_caption(source), ctx)
|
stem = caption_to_stem(cap)
|
||||||
continue
|
if stem:
|
||||||
# else fall through to ASCII fallback (保留 mermaid 源文本)
|
png = ctx["figures_dir"] / f"{stem}.png"
|
||||||
|
if png.is_file():
|
||||||
|
add_image(doc, png, cap, ctx)
|
||||||
|
continue
|
||||||
|
# else fall through to ASCII fallback (无 caption / 未渲染)
|
||||||
add_code_block(doc, code, lang)
|
add_code_block(doc, code, lang)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue