Compare commits

..

No commits in common. "3ca37f704158413b5636eff94154662a11ca21da" and "9aa2efc335ce6673b8fbdea856bfc0595155857e" have entirely different histories.

10 changed files with 16 additions and 1008 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。
最后更新:2026-05-18(proposal skill 加 mermaid 管线:`render_diagrams.py` 预渲染 + `render_docx.py` 图片插入 + 图题自动编号;`key_rd.md` 占位 `[图 N-N ...]` 换成真 mermaid 例子) 最后更新:2026-05-18(system prompt skill 机制改"可选辅助",通用任务不必硬套 skill)
--- ---
@ -21,8 +21,6 @@
## 已完成关键能力 ## 已完成关键能力
- **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 / `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。
- **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 / 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 层早就拦了)。 - **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 层早就拦了)。
@ -118,10 +116,9 @@ web/app.py 889 ← /v1/ JSON API + user_id 隔离 + run lock +
web/auth.py 115 ← D' 过渡:PLATFORM_KEY → JWT 兑换 web/auth.py 115 ← D' 过渡:PLATFORM_KEY → JWT 兑换
web/broker.py 121 ← in-process pub/sub + cancel signal,全 task_id 索引(0004) web/broker.py 121 ← in-process pub/sub + cancel signal,全 task_id 索引(0004)
web/sinks.py 21 ← WebEventSink 绑 task_id(0004) web/sinks.py 21 ← WebEventSink 绑 task_id(0004)
web/static/dev.html 1516 ← D' dev SPA + 文件预览弹框(image/pdf/text/md/docx/xlsx) web/static/dev.html 1133 ← D' dev SPA + stop 按钮 + cancelled badge
web/static/vendor/ ~1 MB ← jszip 3.10.1 / docx-preview 0.3.6 / xlsx 0.18.5(office 预览 vendor)
───────────────────────────────── ─────────────────────────────────
Python 合计 ~3400 行(+ dev.html 1516 静态 + vendor 1MB)— 05-18 入口归位净减 ~400 行 REPL/CLI Python 合计 ~3400 行(+ dev.html 1133 静态)— 05-18 入口归位净减 ~400 行 REPL/CLI
``` ```
加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。 加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -222,74 +222,6 @@
#new-task-modal .err { color: var(--accent); font-size: 12px; margin-top: 8px; min-height: 1em; } #new-task-modal .err { color: var(--accent); font-size: 12px; margin-top: 8px; min-height: 1em; }
#new-task-modal .actions { margin-top: 14px; display: flex; gap: 8px; justify-content: flex-end; } #new-task-modal .actions { margin-top: 14px; display: flex; gap: 8px; justify-content: flex-end; }
/* ───── file preview modal ───── */
#file-preview-modal {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
display: none; align-items: center; justify-content: center; z-index: 90;
}
#file-preview-modal.show { display: flex; }
#file-preview-modal .card {
background: var(--panel); border-radius: 6px;
width: 90vw; height: 90vh; max-width: 1200px;
display: flex; flex-direction: column;
box-shadow: 0 8px 24px rgba(0,0,0,.2);
}
#file-preview-modal .hdr {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border-bottom: 1px solid var(--border);
}
#file-preview-modal .hdr .name {
flex: 1; font-weight: 500; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
#file-preview-modal .body {
flex: 1; overflow: auto; padding: 12px; position: relative;
}
#file-preview-modal .body.center {
display: flex; align-items: center; justify-content: center;
}
#file-preview-modal .body .ph {
color: var(--muted); font-size: 13px; text-align: center;
}
#file-preview-modal .body img.preview-img {
max-width: 100%; max-height: 100%; object-fit: contain;
display: block; margin: 0 auto;
}
#file-preview-modal .body iframe.preview-frame {
width: 100%; height: 100%; border: 0;
}
#file-preview-modal .body pre.preview-text {
margin: 0; padding: 8px; background: var(--code-bg);
border-radius: 4px; white-space: pre-wrap; word-break: break-word;
font-family: ui-monospace, "SF Mono", Consolas, monospace;
font-size: 12px; line-height: 1.5;
}
#file-preview-modal .body .md-render {
max-width: 860px; margin: 0 auto; line-height: 1.7;
}
#file-preview-modal .body .md-render pre {
background: var(--code-bg); padding: 10px; border-radius: 4px; overflow: auto;
}
#file-preview-modal .body .md-render code { background: var(--code-bg); padding: 1px 4px; border-radius: 3px; }
#file-preview-modal .body .md-render pre code { background: transparent; padding: 0; }
#file-preview-modal .body .docx-host { background: #fff; }
#file-preview-modal .body .xlsx-tabs {
display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px;
border-bottom: 1px solid var(--border); padding-bottom: 6px;
}
#file-preview-modal .body .xlsx-tabs button.active {
background: var(--accent-soft); border-color: var(--accent); color: var(--accent);
}
#file-preview-modal .body .xlsx-sheet {
overflow: auto;
}
#file-preview-modal .body .xlsx-sheet table {
border-collapse: collapse; font-size: 12px;
}
#file-preview-modal .body .xlsx-sheet td, #file-preview-modal .body .xlsx-sheet th {
border: 1px solid var(--border); padding: 4px 8px; white-space: nowrap;
}
.small { font-size: 12px; } .small { font-size: 12px; }
.muted { color: var(--muted); } .muted { color: var(--muted); }
</style> </style>
@ -425,19 +357,6 @@
</div> </div>
</div> </div>
<!-- ───── file preview modal ───── -->
<div id="file-preview-modal">
<div class="card">
<div class="hdr">
<span class="name" id="fp-name"></span>
<span class="small muted" id="fp-meta"></span>
<button class="small" id="fp-download" title="下载原文件">下载</button>
<button class="small" id="fp-close" title="关闭 (Esc)">×</button>
</div>
<div class="body" id="fp-body"></div>
</div>
</div>
<script> <script>
const SENTINEL = "00000000-0000-0000-0000-000000000000"; const SENTINEL = "00000000-0000-0000-0000-000000000000";
const LS_TOKEN = "zcbot.token"; const LS_TOKEN = "zcbot.token";
@ -1084,7 +1003,7 @@ function renderFiles(data) {
el.onclick = () => { el.onclick = () => {
const rel = el.dataset.rel; const rel = el.dataset.rel;
if (el.dataset.isdir === "true") { state.filesPath = rel; loadFiles(); } if (el.dataset.isdir === "true") { state.filesPath = rel; loadFiles(); }
else { openFilePreview(rel); } else { downloadFile(rel); }
}; };
}); });
$("file-list").querySelectorAll(".del-file").forEach((btn) => { $("file-list").querySelectorAll(".del-file").forEach((btn) => {
@ -1153,243 +1072,6 @@ function downloadFile(rel) {
}); });
} }
// ───── file preview ─────
const PREVIEW_TEXT_MAX = 2 * 1024 * 1024;
const PREVIEW_BIN_MAX = 50 * 1024 * 1024;
const _scriptCache = new Map();
function loadScript(src) {
if (_scriptCache.has(src)) return _scriptCache.get(src);
const p = new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = src;
s.onload = () => resolve();
s.onerror = () => { _scriptCache.delete(src); reject(new Error("load failed: " + src)); };
document.head.appendChild(s);
});
_scriptCache.set(src, p);
return p;
}
const _previewBlobUrls = new Set();
function _trackBlobUrl(blob, mime) {
const b = mime ? new Blob([blob], { type: mime }) : blob;
const url = URL.createObjectURL(b);
_previewBlobUrls.add(url);
return url;
}
function _flushBlobUrls() {
for (const u of _previewBlobUrls) URL.revokeObjectURL(u);
_previewBlobUrls.clear();
}
const _EXT_GROUPS = {
image: new Set(["jpg","jpeg","png","gif","webp","bmp","svg","ico"]),
pdf: new Set(["pdf"]),
md: new Set(["md","markdown"]),
text: new Set([
"txt","log","json","jsonl","yaml","yml","toml","ini","csv","tsv",
"py","js","mjs","ts","jsx","tsx","go","rs","java","c","cc","cpp","h","hpp",
"html","htm","xml","css","scss","sh","bash","zsh","sql","conf","env",
]),
docx: new Set(["docx"]),
xlsx: new Set(["xlsx","xls"]),
};
function _categorize(rel) {
const m = /\.([a-z0-9]+)$/i.exec(rel);
const ext = m ? m[1].toLowerCase() : "";
for (const [cat, set] of Object.entries(_EXT_GROUPS)) if (set.has(ext)) return cat;
return "fallback";
}
let _fpCurrentRel = null;
async function openFilePreview(rel) {
_fpCurrentRel = rel;
const name = rel.split("/").pop() || rel;
$("fp-name").textContent = name;
$("fp-meta").textContent = "";
const body = $("fp-body");
body.className = "body center";
body.innerHTML = `<div class="ph">加载中…</div>`;
$("file-preview-modal").classList.add("show");
const cat = _categorize(rel);
try {
const r = await fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
headers: { "Authorization": "Bearer " + state.token },
});
if (!r.ok) throw new Error("HTTP " + r.status);
const blob = await r.blob();
$("fp-meta").textContent = humanSize(blob.size);
if (cat === "text" || cat === "md") {
if (blob.size > PREVIEW_TEXT_MAX) {
_showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
return;
}
const text = await blob.text();
if (cat === "md") _showMarkdown(text);
else _showText(text);
return;
}
if (blob.size > PREVIEW_BIN_MAX) {
_showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
return;
}
if (cat === "image") _showImage(blob);
else if (cat === "pdf") _showPdf(blob);
else if (cat === "docx") await _showDocx(blob);
else if (cat === "xlsx") await _showXlsx(blob);
else _showFallback("暂不支持在线预览此格式,请下载查看");
} catch (e) {
if (e.status === 401) { closeFilePreview(); logout(); return; }
_showFallback("加载失败:" + e.message);
}
}
function _showImage(blob) {
const url = _trackBlobUrl(blob);
const body = $("fp-body");
body.className = "body center";
body.innerHTML = "";
const img = document.createElement("img");
img.className = "preview-img";
img.src = url;
body.appendChild(img);
}
function _showPdf(blob) {
const url = _trackBlobUrl(blob, "application/pdf");
const body = $("fp-body");
body.className = "body";
body.innerHTML = `<iframe class="preview-frame" src="${url}"></iframe>`;
}
function _showText(text) {
const body = $("fp-body");
body.className = "body";
body.innerHTML = "";
const pre = document.createElement("pre");
pre.className = "preview-text";
pre.textContent = text;
body.appendChild(pre);
}
function _showMarkdown(text) {
const body = $("fp-body");
body.className = "body";
body.innerHTML = `<div class="md-render">${renderMd(text)}</div>`;
highlightIn(body);
}
async function _showDocx(blob) {
const body = $("fp-body");
body.className = "body center";
body.innerHTML = `<div class="ph">解析 docx 中…</div>`;
try {
await loadScript("/static/vendor/jszip.min.js");
await loadScript("/static/vendor/docx-preview.min.js");
} catch (e) {
_showFallback("docx 解析库加载失败:" + e.message);
return;
}
if (!window.docx || !window.docx.renderAsync) {
_showFallback("docx 解析库不可用");
return;
}
body.className = "body";
body.innerHTML = `<div class="docx-host"></div>`;
try {
await window.docx.renderAsync(blob, body.querySelector(".docx-host"), null, {
inWrapper: false,
ignoreLastRenderedPageBreak: true,
});
} catch (e) {
_showFallback("docx 渲染失败:" + e.message);
}
}
async function _showXlsx(blob) {
const body = $("fp-body");
body.className = "body center";
body.innerHTML = `<div class="ph">解析表格中…</div>`;
try {
await loadScript("/static/vendor/xlsx.full.min.js");
} catch (e) {
_showFallback("xlsx 解析库加载失败:" + e.message);
return;
}
if (!window.XLSX || !window.XLSX.read) {
_showFallback("xlsx 解析库不可用");
return;
}
let wb;
try {
const ab = await blob.arrayBuffer();
wb = window.XLSX.read(ab, { type: "array" });
} catch (e) {
_showFallback("xlsx 解析失败:" + e.message);
return;
}
const names = wb.SheetNames || [];
if (!names.length) { _showFallback("xlsx 内无 sheet"); return; }
body.className = "body";
const tabsHtml = names.map((n, i) =>
`<button class="small xlsx-tab${i===0?" active":""}" data-i="${i}">${escapeHtml(n)}</button>`
).join("");
body.innerHTML = `<div class="xlsx-tabs">${tabsHtml}</div><div class="xlsx-sheet" id="fp-xlsx-sheet"></div>`;
const render = (i) => {
const ws = wb.Sheets[names[i]];
$("fp-xlsx-sheet").innerHTML = window.XLSX.utils.sheet_to_html(ws);
};
body.querySelectorAll(".xlsx-tab").forEach((btn) => {
btn.onclick = () => {
body.querySelectorAll(".xlsx-tab").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
render(parseInt(btn.dataset.i));
};
});
render(0);
}
function _showFallback(msg) {
const body = $("fp-body");
body.className = "body center";
body.innerHTML = "";
const ph = document.createElement("div");
ph.className = "ph";
ph.textContent = msg;
const br = document.createElement("br");
const dl = document.createElement("button");
dl.className = "primary";
dl.textContent = "下载原文件";
dl.style.marginTop = "12px";
dl.onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); };
ph.appendChild(document.createElement("br"));
ph.appendChild(br);
ph.appendChild(dl);
body.appendChild(ph);
}
function closeFilePreview() {
$("file-preview-modal").classList.remove("show");
$("fp-body").innerHTML = "";
_flushBlobUrls();
_fpCurrentRel = null;
}
$("fp-close").onclick = closeFilePreview;
$("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); };
$("file-preview-modal").addEventListener("click", (e) => {
if (e.target.id === "file-preview-modal") closeFilePreview();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && $("file-preview-modal").classList.contains("show")) {
closeFilePreview();
}
});
async function uploadSelected() { async function uploadSelected() {
const inp = $("upload-input"); const inp = $("upload-input");
const files = Array.from(inp.files || []); const files = Array.from(inp.files || []);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long