diff --git a/.gitignore b/.gitignore index bedf58c..debd63b 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ untitled*.pptx 规划.docx cl.ps1 col.ps1 + +# brief skill 临时样例输出 (可由 skill 重新生成, 不入库) +.brief_out/ diff --git a/PROGRESS.md b/PROGRESS.md index 99ab6be..0e2f1ca 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-06-17(新增 paper skill:学术论文写作,中英双语 × 三类型 + 引文三角核验) +最后更新:2026-06-18(新增 brief skill:科研方向简报,三路检索 documents/research/web + 文献计量趋势型简报) --- @@ -21,6 +21,14 @@ ## 已完成关键能力 +### 2026-06-18 / brief skill:科研方向简报 + +- 需求:用户要"水泥/建材方向的科研简报"。联网调研简报类做法——Anthropic 官方 digest skill(办公活动聚合)+ Paper Digest(论文影响力周报)+ 文献计量趋势报告(热点聚类/新兴方法/地理格局)。结论:现有 skill 缺"某方向近期文献 → 有判断的趋势简报"这一环(research/documents 只取文献不组织、paper-review 出可投稿综述、analyze 拆问题不查文献)。 +- **方案**:新建自包含 `skills/brief/`,定位"文献计量趋势型简报",数据底座**三路并用**:documents(内部胶凝材料库取全文)/ research(补 DOI + year_gte 卡时间窗)/ web(政策·标准·产业动向,单列不混学术引文计数)。六阶段:定题对齐 spec(方向+边界/时间窗/受众/深度/源开关/语言/关注点)→ 三路检索取数(中→英术语转译 + 跨源去重,证据表 evidence.md)→ 趋势分析(3-7 热点簇,BLOCKING-lite 对齐)→ 逐段起草 → 引文核验(复用 paper 三层协议,CITATIONS.md)→ 渲染验收。 +- 深度三档 flash/standard/deep 配字数/簇数/引文数预算;骨架:TL;DR→概览→热点聚类→新兴方法→标志性进展→研究空白→产业政策动向(web)→参考文献。渲染早期复用 proposal,后改为自带 render_docx。 +- 文件:`SKILL.md` + `templates/{spec,brief_outline}.md` + `references/{search_strategy,citation_verify}.md` + `scripts/quality_check.py`(结构/簇数预算/过度宣称/**无源句式**/引文交叉核对)+ `scripts/render_docx.py`(简报专属:商务红主题 + 引文 [n]/[Wn] 上标并锚到文末 + DOI/URL 可点击超链接 + TL;DR/判断 callout 底纹)。 +- **顺带修 zcbot 全局「角标」问题**:水泥化学式在 docx 里平排数字(CO2/C3S/SO3...)是 paper/proposal 渲染器的老毛病。抽一份**化学式下标白名单**(长在前 + `\b` 防误伤 LC3/C595/Ca2+/2026,实测命中精确零误伤)统一补进 `paper`、`proposal`、`brief` 三个 `render_docx.py` 的 `add_inline` plain 分支(按"自包含 skill 脚本不跨 skill 引"的既有约定**各自复制同一份**,不建共享模块)。`core/export_docx.py` 是对话原文转录、非排版文档,不动。bump 0.17.0 → 0.18.0。 + ### 2026-06-17 / 任务软删除(留对话轨迹做语料 + 可恢复) - 背景:公测后目标转为沉淀用户对话/文件做训练研究语料;原"hard cascade"硬删任务会连带 messages/usage_events 永久丢失,推翻该决策(DESIGN §取舍同步标注)。 diff --git a/SKILL_LIST.md b/SKILL_LIST.md index cfd515e..2817f3a 100644 --- a/SKILL_LIST.md +++ b/SKILL_LIST.md @@ -1,8 +1,8 @@ # zcbot Skill 清单 服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材) -最后更新:2026-06-17 -Skill 总数:16 +最后更新:2026-06-18 +Skill 总数:17 zcbot 的"skill"是一份可加载的工作流脚本(`skills//SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。 @@ -23,6 +23,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills//SKILL.md` + | 演示出图 | [plot_pub](#plot_pub) | 出版级 matplotlib 学术图(中文 + viridis + 矢量 + 投稿级复合图设计纪律) | | 文献检索 | [research](#research) | 查 paper_server(OpenAlex 元数据 + Sci-Hub 下载) | | 文献检索 | [documents](#documents) | 查内部 7 学科材料知识库(100W+ 论文,跨语言检索;host-side tool 持 key) | +| 文献检索 | [brief](#brief) | 科研方向简报:三路检索(内部库 + research + web)→ 热点聚类趋势简报 | | 科研计算 | [pymatgen](#pymatgen) | 晶体结构 / XRD 模拟 / 相图 / Materials Project(host-side tool 持 key) | | 科研计算 | [stats_ml](#stats_ml) | 配方-性能建模与机器学习(三库分工) | | 内容生成 | [imagegen](#imagegen) | 豆包 Seedream 5.0 文生图 + 改图 i2i(¥0.22 / 张) | @@ -289,6 +290,36 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S --- +### brief +**生成科研方向简报(文献计量趋势型简报)。** + +给定一个研究方向 + 时间窗,用**三路真实数据**(documents 内部库取全文 / research 取近期 DOI 元数据 / web 取政策·会议·标准动向)产出一份**有判断、可溯源**的简报:热点聚类 + 新兴方法 + 关键进展 + 研究空白 + 产业政策动向。简报 ≠ 综述论文 —— 要**快、准、有取舍**("重要性优先于完整性"),帮决策者 / 课题组 5–20 分钟掌握一个方向近期态势。 + +**六阶段**:定题对齐 spec(方向+边界 / 时间窗 / 受众 / 深度 / 源开关 / 语言 / 关注点)→ 三路检索取数(中→英术语转译 + 跨源去重,证据表)→ 趋势分析(3–7 热点簇,对齐后再写)→ 逐段起草 → 引文核验(复用 paper 三层协议)→ 渲染验收。 + +**深度三档**:`flash` 快报(1–2 页)/ `standard` 标准(4–6 页)/ `deep` 深度(8+ 页,含机构-地理计量),各配字数 / 簇数 / 引文数预算。 + +**何时用**: +- ✅ 用户要"简报 / 方向简报 / 研究动态 / 趋势报告 / 调研快报 / 跟踪某领域最新研究 / 某方向近期进展" +- ✅ 立项前想快速摸清一个方向近期态势再决定要不要做(产出可喂 proposal / analyze) + +**何时不用**: +- ⛔ 只要文献清单 / DOI / PDF → research / documents +- ⛔ 要写可投稿的综述论文(几十页、定论)→ paper(review 类型) +- ⛔ 要把模糊科学问题拆成子问题 + 路线图 → analyze +- ⛔ 要写本子 → proposal + +**核心能力**: +- **三路检索分工 + 去重**(`search_strategy.md`):documents 全文首选、research 补 DOI 卡 year_gte、web 单列产业政策动向不混学术引文计数;中文方向→英文术语转译(SCM/LC3 等缩写展开) +- **趋势分析纪律**:热点聚成簇(主题句写判断不堆关键词)、新兴方法、争议空白、机构-地理格局(deep);取舍优先于穷尽 +- **引文核验**(复用 paper 三层协议):存在性 / 三角印证 / 支撑度,编造零容忍;web 来源标 URL + 日期 + 文号 +- `quality_check.py`:结构 / 簇数预算 / 过度宣称 / **无源句式**("据报道""有研究表明"无引文)/ 引文交叉核对 +- **自带 `render_docx.py`**:商务红主题 + 正文 `[n]`/`[Wn]` 引文上标并锚到文末 + DOI/URL 可点击超链接 + 化学式下标(CO₂/C₃S...,白名单不误伤 LC3/Ca2+)+ TL;DR / 判断 callout 底纹;做 deck 转 ppt + +**典型产物**:`<方向>-简报.md`(默认)+ `evidence.md`(证据表)+ `CITATIONS.md`(引文核验台账);可选转 docx / deck。 + +--- + ## 科研计算 ### pymatgen @@ -497,6 +528,7 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S - **写本子全流程**:analyze(拆问题) → research / documents(查文献) → stats_ml(算配方-性能模型出预实验数据) → plot_pub(出图) → proposal(写本子) → review(审稿) - **写专利全流程**:patent(挖点 + 检索 + 起草) → research(查现有技术) → plot_pub(出附图) → review(终审) - **写标准全流程**:analyze(定标准化对象) → stats_ml(配方-性能 / 精密度试验数据定指标) → research / documents(查国内外现有标准与现状) → standard(起草标准 + 编制说明) → plot_pub(出图) → review(送审前终审) +- **方向简报 → 立项**:brief(三路检索摸方向近期态势) → analyze(把方向拆成子问题 + 路线图) → proposal(写本子) / paper(写综述);简报要做成汇报 → ppt - **PPT 汇报**:analyze(提炼论点) → research / documents(找数据 + 引文) → plot_pub(出图) → ppt(组装 deck) → imagegen(可选,做封面 / 引子页) - **晶体计算**:pymatgen(算 XRD / 相图) → plot_pub(出图) → proposal / patent(写到本子 / 交底书里) - **定制能力**:skill-creator(fork 某内置 skill,如 ppt / proposal) → 改造成本组 / 本人专属版本(术语 / 模板 / 默认值),之后日常任务直接用改造版 diff --git a/core/__init__.py b/core/__init__.py index 2402258..7d9f014 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.17.0" +__version__ = "0.18.0" diff --git a/skills/brief/SKILL.md b/skills/brief/SKILL.md new file mode 100644 index 0000000..ff5c0d3 --- /dev/null +++ b/skills/brief/SKILL.md @@ -0,0 +1,114 @@ +--- +name: brief +description: 生成科研方向简报(research direction briefing / 文献计量趋势型简报)。给定一个研究方向 + 时间窗,用三路真实数据(documents 内部库取全文 / research 取近期 DOI 元数据 / web 取政策·会议·标准动向),产出一份热点聚类 + 新兴方法 + 关键进展 + 研究空白 + 产业政策动向的可读简报,每条论断可溯源、不编造引文。当用户要"简报 / 方向简报 / 研究动态 / 趋势报告 / 调研快报 / 某方向近期进展 / 文献综述快讯 / 跟踪某领域最新研究"时使用。 +--- + +# 科研方向简报 + +把"某研究方向最近发生了什么"变成一份**可读、可溯源、有判断**的简报。**先定题对齐 → 三路检索取数 → 趋势分析 → 逐段起草 → 引文核验渲染** —— 不要一口气出全文,定题和分析阶段先和用户对齐方向与边界。 + +简报 ≠ 综述论文(paper review):综述要全面、深、给定论;简报要**快、准、有取舍**——"重要性优先于完整性",帮决策者 / 课题组 5–20 分钟掌握一个方向近期态势。 + +进度展示建议:用 `task_progress` 标记「定题对齐 / 三路检索 / 趋势分析 / 逐段起草 / 引文核验 / 渲染」关键阶段。 + +## 边界(先划清,免得和别的 skill 撞) + +| 与谁区分 | 边界 | +|---|---| +| vs `research`/`documents` | 它们**只取文献**(候选清单 / 全文);brief 是消费方,把取回的文献**组织成有判断的趋势简报**,引文核验接到它们头上 | +| vs `paper`(review 类型) | paper-review 写**可投稿的综述论文**(IMRaD/主题式、几十页、定论);brief 出**轻量趋势简报**(几页、有取舍、面向决策),不投稿 | +| vs `analyze` | analyze 把**模糊科学问题**拆成子问题 + 路线图(不查文献);brief 围绕**一个已定方向**摸近期态势(重检索)。两者可互为上下游(先拆问题再摸态势,或先摸态势再拆) | +| vs `proposal` | proposal 写**本子**(立项依据);brief 只摸方向近期态势,不写立项依据。要立项 → 把简报喂给 proposal | + +**何时不用**:只要文献清单 / DOI / PDF → research/documents;要写可投稿综述 → paper(review);要拆科学问题 → analyze;要写本子 → proposal。 + +## 资源 + +下面所有路径相对 **``** —— `load_skill` 返回头里的 `[skill=brief, dir=<绝对路径>]`,用这个绝对路径拼脚本/资源,不要假设 cwd。 + +**先读(always)**: +- `/templates/brief_outline.md` —— 简报骨架 + 按深度(快报/标准/深度)的字数预算与簇数/引文数 +- `/references/search_strategy.md` —— 三路检索分工(documents/research/web)+ 跨源去重 + 中文方向→英文术语转译 + +**阶段五必读**: +- `/references/citation_verify.md` —— 引文核验协议(存在性 / 三角印证 / 支撑度,复用 paper 思路,接 documents/research/web) + +**模板**: +- `templates/spec.md` —— 七条定题对齐固定字段(复制到 task 级 spec 文件) + +**脚本**(`.venv/Scripts/python.exe /scripts/...`): +- `scripts/quality_check.py` —— `--depth {flash,standard,deep}`,结构完整性 / 占位符泄漏 / 过度宣称 / 无源句式 / 引文交叉核对(orphan/uncited/编号连续) +- `scripts/render_docx.py` —— md→docx,**简报专属版式**:商务红主题(`--no-color` 关)+ 正文 `[n]`/`[Wn]` 引文上标并锚到文末 + DOI/URL 可点击超链接 + 化学式下标白名单(CO2/C3S/Na2O...,不误伤 LC3/C595/Ca2+)+ TL;DR / 判断 行做底纹 callout + +**产物与渲染**:简报默认产物是 `.md`。要 docx → 本 skill 自带 `render_docx.py`(见上);要做成汇报 deck → 转 `ppt` skill。 + +## 阶段一:定题对齐(写 spec)BLOCKING + +产物:**task 级 spec 文件**,简报的"宪法",后续每阶段前重读。命名按 system prompt 的《task 级「宪法」文件命名约定》: + + /--.spec.md + +复制 `templates/spec.md` 填七条,**有歧义先反问,不要替用户拍板**: + +1. **方向 + 边界**:具体到子方向(不是"水泥"而是"低碳水泥 SCM");明确**纳入/排除**(如"只看辅助胶凝材料替代,不含碱激发") +2. **时间窗**:默认**近 3 年**;用户说"最新/近期"→ 近 1 年;"这两年"→ 近 2 年。换算成 `year_gte`(今年是 system prompt 给的当前年) +3. **受众**:院领导汇报 / 课题组内部 / 立项前调研 / 对外交流 —— 决定语气与详略 +4. **深度**:`flash` 快报(1–2 页)/ `standard` 标准(4–6 页)/ `deep` 深度(8+ 页,含机构-地理计量)—— 见 brief_outline.md 预算 +5. **数据源开关**:documents(内部库,材料类首选)/ research(补 DOI 与近期元数据)/ web(政策·会议·标准·产业动向)—— 默认三路并用,用户可关 +6. **语言**:中文(默认)/ 英文 +7. **特殊关注点**:用户特别想知道的(如"重点看 CCUS 与水泥结合""谁在做工业固废路线")—— 写进 spec,分析阶段重点回应 + +写完把 spec 七条**复述给用户确认**,认可后进阶段二。 + +## 阶段二:三路检索取数 + +**先读 `references/search_strategy.md`**(三路分工 + 中→英术语 + 去重)。流程: + +1. **中文方向 → 英文检索词组**:库里主语料是英文,`SCM` 这类要展开(supplementary cementitious materials / fly ash / GGBFS / calcined clay / limestone calcined clay cement / LC3 ...) +2. **documents**(材料类首选):语义检索,中英 query 都行;胶凝材料库(classification_id=1)。取 `md_content` 备引文核验 +3. **research**:`search(keyword=英文, year_gte=<窗>, limit=...)` 拉近期候选 + DOI;`has_pdf`/`is_oa` 按需 filter。看 list 自带 abstract 判切题 +4. **web**(可选):政策(双碳、水泥行业碳配额)、标准(新国标/团标)、行业会议、企业中试/产线 —— web 的东西**单独标"产业/政策动向",不混进学术引文计数** +5. 汇成**证据表** `/evidence.md`(仿 lit_matrix):一行一条 = 来源 | 标题 | 年 | 一句话 takeaway | 归属簇 | 引文可用性(documents全文/DOI/web) + +收 20–80 条(按深度),**不求穷尽**,够支撑各簇即可。命中 0 条先换同义词/放宽年份,3 次仍空如实告诉用户库未覆盖,**不脑补文献**。 + +## 阶段三:趋势分析(和用户对齐结构)BLOCKING-lite + +把证据表**聚成 3–7 个热点簇**(按深度),给用户看簇划分 + 每簇代表文献,认可后再起草。每簇判断: + +- **这个簇在做什么 / 解决什么问题**(一句话主题句,不是关键词堆砌) +- **代表性进展**(2–4 篇,带真实引文) +- **新兴方法 / 技术**(出现的新表征、新建模、新工艺) +- **争议 / 分歧 / 未解**(哪里还没共识) + +横向再扫:**研究空白**(大家都没做的)、**机构-地理格局**(deep 才做,元数据够时:谁在领跑、中国占比)、**产业/政策动向**(来自 web)。 + +> 取舍纪律:一个方向近期可能上百篇,简报只留**改变判断的**。重复验证性工作合并成一句"多篇验证了 X";边缘工作直接不收。宁缺毋滥。 + +## 阶段四:逐段起草 + +按 `brief_outline.md` 骨架写 `/sections/*.md`,**每段一个论断 + 证据**: + +- TL;DR 要点(5 行内,先给结论)→ 方向概览与边界 → 研究热点聚类(各簇)→ 新兴方法 → 近期标志性进展 → 研究空白与争议 → 产业/政策/标准动向(web,可选)→ 参考文献 +- 起草时引文用占位 `[CITE-]`,阶段五核验后映射真实条目并编号 +- 数字 / 定量结论必须挂引文;"据报道""有研究表明"这种无源句式禁止 + +## 阶段五:引文核验(渲染前必跑) + +**先读 `references/citation_verify.md`**,对所有引文逐条核验:存在性(两库/web 命中)→ 三角印证(关键论断 ≥2 源)→ 支撑度(抓原文锚点,partial 就改论断迁就证据)。台账写 `/CITATIONS.md`。 + +**铁律(同 paper)**:status 非 verified 的引文不得进最终稿;不为凑数编造文献;支撑不足改论断不改证据;两库/web 都查不到如实告诉用户。 + +## 阶段六:渲染验收 + +1. `quality_check.py --depth ` 跑 sections:结构 / 簇数预算 / 占位符 / 过度宣称 / 无源句式 / 引文交叉核对 +2. 用户要 docx → `.venv/Scripts/python.exe /scripts/render_docx.py -o <方向>-简报.docx`(商务红 + 引文上标超链接 + 化学式下标;`--no-color` 出黑白);要 deck → 转 ppt skill +4. 交付时一句话说清:覆盖了哪几路源、收了多少条证据、哪些被取舍、哪些点是单源待复核 + +## 反模式 + +- ❌ 跳过定题直接检索 —— 方向边界没定,检索词发散,收一堆不相关 +- ❌ 把命中的文献**全部**堆进简报 —— 简报是取舍的艺术,不是清单转储 +- ❌ web 抓的资讯当学术结论引 —— web 动向单列,学术论断要文献支撑 +- ❌ 编造 DOI / "据报道"无源句 —— 走 citation_verify,查不到就如实说 +- ❌ 用中文 keyword 搜英文库 —— 先转专业英文术语(见 search_strategy.md) diff --git a/skills/brief/references/citation_verify.md b/skills/brief/references/citation_verify.md new file mode 100644 index 0000000..d75f4f4 --- /dev/null +++ b/skills/brief/references/citation_verify.md @@ -0,0 +1,60 @@ +# 引文核验协议(简报版) + +与 `paper` skill 的引文三角核验**同一套思路**,这里按简报场景收口。简报虽轻,但**编造引文 / 引而不实**同样致命——决策者会照着简报判断方向,假信息代价更高。 + +> 协议不是脚本——你(模型)拿 host-side tool 逐条执行。quality_check.py 只做机械的 orphan/uncited/编号核对,真伪与支撑度靠本协议。 + +## 何时跑 + +阶段四逐段起草后、阶段六渲染前,对所有 `[CITE-xx]` 占位逐条核验。用户自带的引文也要跑。 + +## 三层核验(逐条) + +### 第 1 层 — 存在性 + +1. `documents` 语义检索 / `research` 的 `search()` / `get_paper(doi)` 确认文献真实存在 +2. 命中 → 以**库里返回字段为准**记 DOI/作者/年/期刊,不沿用记忆 +3. 两库都查不到 → 标 `[未核实]`,**不得编造**;告诉用户"这条找不到来源,请提供 DOI 或删去该论断" + +### 第 2 层 — 三角印证 + +关键论断(趋势判断、定量结论、"标志性进展")至少 **2 个独立源**一致才稳: + +- documents 命中 + research/DOI 一致 → 通过 +- 仅单一来源 → 标"单源,谨慎",简报交付时点出"此点单源待复核" +- 来源字段冲突 → 以可验证 DOI 元数据为准 + +### 第 3 层 — 支撑度 + +文献存在但**不支撑你写的那句话**是最容易翻车的: + +1. 抓 `md_content`(documents)/ `fetch_xml`/`fetch_pdf`(research) +2. 定位 ≤25 词原文锚点 + 段落位置 +3. 三档:**support** 通过;**partial/需限定** → 改写论断迁就证据;**not-support/反向** → 删引用或换文献 +4. 抓不到全文 → abstract 弱核验,标"仅摘要核验" + +## web 来源的核验(简报特有) + +web 资讯(政策/标准/产业)**不进学术引文三角**,但同样要可溯源: + +- 记**原始 URL + 访问日期 + 发布机构**;优先官方源(政府/标委会/期刊/企业官网),而非二手转载 +- 政策 / 标准类:能找到文号 / 标准号就记(如"GB/T xxxxx""国办发〔2025〕x号") +- web 信息标注"截至 <日期>",时效性内容明确边界——避免简报过期后误导 + +## 产出:核验台账 `CITATIONS.md` + +```markdown +# 引文核验台账 +- [1] , | exists:✓(documents+DOI) | triangulate:✓ | claim:support "<≤25词锚点>"(§x) | status: verified +- [2] | exists:✓ | claim:partial → 已把"大幅提升"改为"28d 提高约 15%" | status: verified-revised +- [W1] <机构> <标题>, , 访问 <日期> | 类型:政策动向 | status: web-sourced +- [3] | exists:✗ 两库未命中 | status: 待用户提供 +``` + +## 铁律(同 paper) + +- ❌ status 非 verified/verified-revised/用户确认的学术引文不得进最终稿 +- ❌ 不为凑数编造"看起来合理"的文献 +- ❌ web 资讯当学术结论引(单列动向段,标 URL+日期) +- ✅ 支撑不足**改论断迁就证据**,不是改证据迁就论断 +- ✅ 两库/web 都查不到如实告诉用户,给"提供来源 / 删论断"两个选项 diff --git a/skills/brief/references/search_strategy.md b/skills/brief/references/search_strategy.md new file mode 100644 index 0000000..0c8711e --- /dev/null +++ b/skills/brief/references/search_strategy.md @@ -0,0 +1,61 @@ +# 三路检索策略(documents / research / web) + +简报数据底座最多三路并用。**三路分工不同、去重要做、学术与资讯分开计数**。 + +## 三路分工 + +| 路 | 拿什么 | 怎么调 | 强项 | 注意 | +|---|---|---|---|---| +| **documents** | 材料学科论文**全文 md**(胶凝材料库 classification_id=1) | host-side tool `document_search`,中英 query 都行(后端跨语言语义检索) | LLM 直接读全文、引文核验抓锚点最顺;材料类首选 | 需宿主配 `DOCUMENT_SEARCH_API_KEY`;只覆盖预收的 7 学科 | +| **research** | OpenAlex 元数据 + DOI + abstract,可拉 PDF/XML | `from skills.research.paper import search, get_paper, fetch_xml`,`run_python` 调 | 补近期文献与 DOI、`year_gte` 卡时间窗、`is_oa`/`has_pdf` filter | keyword **英文为主**;SearchFilter 匹配 title/author 不含 abstract | +| **web** | 政策 / 标准 / 会议 / 产业动向 | WebSearch / WebFetch | 时效性最强,学术库覆盖不到的非论文信息 | **不当学术结论引**;单列"产业/政策动向"段,标来源 + 日期 | + +> documents / research 任一不可用(key 没配 / 服务器连不上)时**降级用另一路 + web**,别整体放弃。research 不持 key,通常是降级首选。 + +## 中文方向 → 英文术语(关键) + +库里 95%+ 文献 title 是英文,中文 keyword 命中率很低。**中文方向先转专业英文词组再搜**,缩写要展开: + +| 中文方向 | 英文检索词组(同义/展开) | +|---|---| +| 低碳水泥 | low-carbon cement / low-CO2 cement / clinker substitution | +| 辅助胶凝材料 SCM | supplementary cementitious materials / SCM / fly ash / GGBFS / ground granulated blast-furnace slag / calcined clay / silica fume / limestone powder | +| LC3 石灰石煅烧黏土水泥 | limestone calcined clay cement / LC3 / calcined clay cement | +| 水泥窑碳捕集 | cement CCUS / carbon capture cement kiln / oxyfuel cement | +| 工业固废资源化 | industrial solid waste / steel slag / red mud / phosphogypsum in cement | +| 碳化养护 | CO2 curing / carbonation curing / accelerated carbonation | +| 水化机理 | cement hydration / C-S-H / hydration kinetics | + +转译策略:用领域标准英文术语;不确定就先英文 keyword 试一次看返回 title 是否相关;多个同义词分别搜一遍合并去重;缩写(SCM/LC3/GGBFS)与全称都搜。 + +## research 调用范式 + +```python +from skills.research.paper import search + +# 时间窗 = 近1年 → year_gte=<当前年-1>;多个英文同义词分别搜 +for kw in ["limestone calcined clay cement", "LC3 cement", "supplementary cementitious materials"]: + papers = search(keyword=kw, year_gte=2025, limit=15) + for p in papers: + # list 已带 abstract,直接看前 200-400 字判切题,不必再 get_paper + print(p["publication_year"], p["title"], p["doi"]) + if p["abstract"]: + print(p["abstract"][:300]) +``` + +要全文做引文核验:`has_fulltext_xml=True` 先 `fetch_xml`(结构化,LLM 友好),否则 `fetch_pdf`。 + +## 跨源去重 + +同一篇 / 同一结论可能三路都出现: + +- **同 DOI** → 一条(documents 全文优先,记 DOI 来自 research) +- **同结论不同文献**(多篇验证同一现象)→ 合并成一句"多篇(refN, refN)验证了 X",不逐条铺开 +- **web 资讯 vs 学术论文** → 不去重、不混算;web 进"产业/政策动向"段,学术进簇与引文计数 +- 证据表 `evidence.md` 一行一条标"来源(documents/research/web)",去重在表上做完再起草 + +## 收多少 / 收到什么程度 + +- 按深度收 20–80 条候选(见 brief_outline 预算),**不求穷尽**——够支撑各簇判断即可 +- 每簇至少 2 篇代表文献(关键论断 ≥2 源,接 citation_verify 三角印证) +- 命中 0 条:换同义词 / 展开缩写 / 放宽年份;3 次仍空 → 如实告诉用户库未覆盖该窗口,**不脑补** diff --git a/skills/brief/scripts/quality_check.py b/skills/brief/scripts/quality_check.py new file mode 100644 index 0000000..9ab7cae --- /dev/null +++ b/skills/brief/scripts/quality_check.py @@ -0,0 +1,270 @@ +"""科研方向简报质量检查 — 渲染前跑一遍。 + +检查项: +- 结构完整性: 按深度(flash/standard/deep)必备段落是否齐全 +- 占位符泄漏: / [CITE-xx] 占位是否还在 +- 过度宣称: "国际领先 / 首次 / 颠覆 / unprecedented" 等无证据夸张词(简报要有判断但别吹) +- 无源句式: "据报道 / 有研究表明 / 业内普遍认为" 等不挂引文的论断(简报每条论断要可溯源) +- 引文交叉核对: 文中学术引 [n] 与参考文献清单 [n] 互查(orphan / uncited / 编号连续) +- web 来源计数: [W1].. 单独统计,提醒和学术引文分开 + +用法: + python quality_check.py --depth standard + python quality_check.py --depth flash --strict +""" +from __future__ import annotations +import argparse +import re +import sys +from pathlib import Path + + +# 各深度必备段落(stem 前缀匹配;references 单独判) +REQUIRED_SECTIONS: dict[str, list[str]] = { + "flash": ["00_tldr", "02_clusters", "references"], + "standard": ["00_tldr", "01_overview", "02_clusters", "05_gaps", "references"], + "deep": ["00_tldr", "01_overview", "02_clusters", "04_progress", "05_gaps", "references"], +} + +# 各深度热点簇数预算(02_clusters 里 '### 簇' 计数) +CLUSTER_BUDGET: dict[str, tuple[int, int]] = { + "flash": (2, 3), + "standard": (3, 5), + "deep": (5, 7), +} + +OVERCLAIM_PHRASES = [ + "国际领先", "国际一流", "世界领先", "世界一流", "填补空白", "重大突破", + "划时代", "前所未有", "颠覆性", "革命性", + "world-first", "world-leading", "unprecedented", "groundbreaking", + "revolutionary", "state of the art", +] + +# 无源句式: 出现这些但同段没有 [n]/[CITE-] 引文 → 论断悬空 +UNSOURCED_PHRASES = [ + "据报道", "有研究表明", "研究显示", "业内普遍认为", "众所周知", + "大量研究", "普遍认为", "据悉", +] + +PLACEHOLDER_PATTERNS = [ + r"]*>", + r"\[CITE-[A-Za-z0-9_\-]+\]", + r"\bXX+\b", +] + +_INTEXT_CITE_RE = re.compile(r"\[(\d[\d,\s\-]*)\]") # 学术引 [7] / [7-9] / [7,9] +_REF_ENTRY_RE = re.compile(r"^\s*\[(\d+)\]") # 参考文献条目 [n] +_WEB_CITE_RE = re.compile(r"\[W\d+\]") # web 来源 [W1] +_CITE_TOKEN_RE = re.compile(r"\[(?:\d[\d,\s\-]*|CITE-[A-Za-z0-9_\-]+)\]") + + +def _is_references_file(stem: str) -> bool: + s = stem.lower() + return "reference" in s or s.endswith("_refs") or "参考文献" in stem or "08_" in s + + +def check_structure(sections_dir: Path, depth: str) -> list[str]: + required = REQUIRED_SECTIONS.get(depth, []) + existing = {f.stem for f in sections_dir.glob("*.md")} + issues = [] + for req in required: + if req == "references": + if not any(_is_references_file(s) for s in existing): + issues.append("缺段落: 参考文献 (08_references)") + continue + if not any(s.startswith(req) for s in existing): + issues.append(f"缺段落: {req}") + return issues + + +def check_clusters(sections_dir: Path, depth: str) -> list[str]: + lo, hi = CLUSTER_BUDGET.get(depth, (0, 999)) + n = 0 + for md in sections_dir.glob("*.md"): + if md.stem.startswith("02_clusters"): + n += len(re.findall(r"^#{2,4}\s*簇", md.read_text(encoding="utf-8"), re.MULTILINE)) + if n == 0: + return ["02_clusters 里没找到 '### 簇N' 小节 — 热点聚类是简报主体"] + if n < lo: + return [f"热点簇 {n} 个, 少于 {depth} 档预算 {lo}-{hi} (簇太少, 方向覆盖可能不足)"] + if n > hi: + return [f"热点簇 {n} 个, 多于 {depth} 档预算 {lo}-{hi} (簇太多, 考虑合并 — 重要性优先于完整性)"] + return [] + + +def check_phrases(text: str, label: str) -> list[str]: + issues = [] + low = text.lower() + for phrase in OVERCLAIM_PHRASES: + if phrase in text or phrase.lower() in low: + issues.append(f"[{label}] 过度宣称: '{phrase}' — 换成可被数据支撑的具体表述") + return issues + + +def check_unsourced(text: str, label: str) -> list[str]: + """无源句式: 整段出现却无任何引文标记 → 悬空论断。按段落(空行分隔)判。""" + issues = [] + for para in re.split(r"\n\s*\n", text): + if _CITE_TOKEN_RE.search(para) or _WEB_CITE_RE.search(para): + continue # 本段有引文, 放过 + for phrase in UNSOURCED_PHRASES: + if phrase in para: + snippet = para.strip().replace("\n", " ")[:40] + issues.append(f"[{label}] 无源论断: '{phrase}' 所在段无引文标记 — 挂 [n] 或删 (\"{snippet}...\")") + break + return issues + + +def check_placeholders(text: str, label: str) -> list[str]: + issues = [] + for pat in PLACEHOLDER_PATTERNS: + for m in re.findall(pat, text): + issues.append(f"[{label}] 占位符未替换: '{m}'") + return issues + + +def _expand_cite_group(grp: str) -> set[int]: + out: set[int] = set() + for part in grp.split(","): + part = part.strip() + if not part: + continue + if "-" in part: + a, _, b = part.partition("-") + try: + lo, hi = int(a), int(b) + except ValueError: + continue + if 0 < lo <= hi <= 999: + out.update(range(lo, hi + 1)) + else: + try: + out.add(int(part)) + except ValueError: + continue + return out + + +def check_citations(sections_dir: Path) -> list[str]: + issues: list[str] = [] + cited: set[int] = set() + ref_nums: list[int] = [] + web_in_text = 0 + web_refs = 0 + + for md in sorted(sections_dir.glob("*.md")): + text = md.read_text(encoding="utf-8") + if _is_references_file(md.stem): + for ln in text.splitlines(): + m = _REF_ENTRY_RE.match(ln) + if m: + ref_nums.append(int(m.group(1))) + if re.match(r"^\s*\[W\d+\]", ln): + web_refs += 1 + else: + for grp in _INTEXT_CITE_RE.findall(text): + cited.update(_expand_cite_group(grp)) + web_in_text += len(_WEB_CITE_RE.findall(text)) + + if not ref_nums and not cited: + return ["未发现任何学术引文 (文中 [n] 和参考文献清单都为空) — 简报论断需文献支撑"] + + ref_set = set(ref_nums) + + orphan = sorted(cited - ref_set) + if orphan: + issues.append(f"orphan cite — 文中引了 {orphan} 但参考文献清单缺对应条目 (编造/漏排, 走 citation_verify)") + + uncited = sorted(ref_set - cited) + if uncited: + issues.append(f"uncited ref — 参考文献第 {uncited} 条正文从未引用 (删除或补引)") + + dups = sorted({n for n in ref_nums if ref_nums.count(n) > 1}) + if dups: + issues.append(f"参考文献编号重复: {dups}") + + if ref_set: + gaps = sorted(set(range(1, max(ref_set) + 1)) - ref_set) + if gaps: + issues.append(f"参考文献编号不连续, 缺号: {gaps} (顺序编码制需 1..N 连续)") + if 1 not in ref_set: + issues.append("参考文献编号未从 [1] 起") + + # web 来源只提示, 不判错(它们和学术引文分开计数) + if web_in_text and not web_refs: + issues.append(f"文中有 {web_in_text} 处 [W..] web 引用但参考文献无 [W..] 条目 — 补 web 来源(URL+日期)") + + return issues + + +def main() -> None: + ap = argparse.ArgumentParser(description="科研方向简报质量检查") + ap.add_argument("sections_dir", type=Path) + ap.add_argument("--depth", required=True, choices=list(REQUIRED_SECTIONS.keys())) + ap.add_argument("--strict", action="store_true", help="严格模式: 任何问题退出 1") + args = ap.parse_args() + + if not args.sections_dir.is_dir(): + print(f"[ERR] {args.sections_dir} not a directory", file=sys.stderr) + sys.exit(2) + + print(f"\n[简报质量检查] depth={args.depth}\n") + all_issues: list[str] = [] + + struct = check_structure(args.sections_dir, args.depth) + if struct: + print("[ERR] 结构问题:") + for s in struct: + print(f" - {s}") + all_issues.extend(struct) + else: + print("[OK] 结构完整") + + cl = check_clusters(args.sections_dir, args.depth) + if cl: + print("\n[WARN] 热点簇数:") + for s in cl: + print(f" - {s}") + all_issues.extend(cl) + else: + print("[OK] 热点簇数在预算内") + + files = sorted(args.sections_dir.glob("*.md")) + print(f"\n共 {len(files)} 个段落, 逐段扫描 (过度宣称 / 无源论断 / 占位符)...\n") + for f in files: + text = f.read_text(encoding="utf-8") + sub = (check_phrases(text, f.stem) + + check_unsourced(text, f.stem) + + check_placeholders(text, f.stem)) + if sub: + print(f"[WARN] {f.stem}:") + for s in sub: + print(f" - {s.split('] ', 1)[1] if '] ' in s else s}") + all_issues.extend(sub) + + cite_issues = check_citations(args.sections_dir) + if cite_issues: + print("\n[ERR] 引文交叉核对:") + for s in cite_issues: + print(f" - {s}") + all_issues.extend(cite_issues) + else: + print("\n[OK] 学术引文 [n] 与参考文献清单一致 (无 orphan / uncited, 编号连续)") + + print("\n" + "=" * 60) + if all_issues: + print(f"[WARN] 共发现 {len(all_issues)} 个问题。") + print("\n建议:") + print(" - 过度宣称 -> 换成数据支撑的具体表述") + print(" - 无源论断 -> 挂 [n] 引文或删该句") + print(" - 占位符未替换 -> 走 citation_verify 把 [CITE-] 映射成真实引文") + print(" - orphan cite -> 大概率编造, 走 citation_verify 三角核验") + print(" - uncited ref -> 删条目或正文补引") + if args.strict: + sys.exit(1) + else: + print("[OK] 未发现问题。") + + +if __name__ == "__main__": + main() diff --git a/skills/brief/scripts/render_docx.py b/skills/brief/scripts/render_docx.py new file mode 100644 index 0000000..dba8e4b --- /dev/null +++ b/skills/brief/scripts/render_docx.py @@ -0,0 +1,713 @@ +"""把 sections/*.md 渲染成科研方向简报 .docx(简报体例,区别于 paper 的投稿稿)。 + +相对 paper/render_docx.py 的简报专属增强: +- **商务红配色**(主色 #C00000):标题分级染色 + 标题下细色条;TL;DR / 「判断」行做浅红底纹 callout +- **引文上标 + 内部超链接**:正文 [1] / [W3] → 上标红色,点击锚到文末参考文献对应条目 +- **参考文献可点击**:DOI → https://doi.org/... 蓝色超链接;web 条目里的域名/路径 → https:// 超链接 +- **化学式下标(白名单)**:CO2 / C3S2 / Na2O / SO4 ... → 真实下标,**白名单精确匹配**,不误伤 LC3 / EN 197-5 / 8.5 Mt / 2026 + +字体规范同院内其它渲染:中文宋体小四 / 英文 Times New Roman 小四 / 行距 1.5 / 首行缩进 2 字符。 +支持 **加粗** / *斜体* / `等宽` / 列表 / 表格 / ![caption](png) 居中插图。 + +用法: + python render_docx.py -o + python render_docx.py --no-color -o # 关配色出纯黑白 +""" +from __future__ import annotations +import argparse +import re +import sys +from pathlib import Path + +from docx import Document +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.opc.constants import RELATIONSHIP_TYPE as RT +from docx.oxml import OxmlElement +from docx.oxml.ns import qn +from docx.shared import Cm, Pt, RGBColor + +# ───────────────────────── 主题色 ───────────────────────── + +PRIMARY = "C00000" # 商务红主色 +PRIMARY_RGB = RGBColor(0xC0, 0x00, 0x00) +TLDR_FILL = "FBE9E9" # TL;DR 浅红底纹 +CALLOUT_FILL = "F7DDDD" # 「判断」callout 底纹 +LINK_BLUE = "1155CC" # 超链接蓝 +TABLE_HEAD_FILL = "C00000" + + +# ───────────────────────── 字体 / 低层 OOXML 辅助 ───────────────────────── + +def _set_run_fonts(run, *, cn_font="宋体", en_font="Times New Roman") -> None: + rPr = run._element.get_or_add_rPr() + rFonts = rPr.find(qn("w:rFonts")) + if rFonts is None: + rFonts = OxmlElement("w:rFonts") + rPr.append(rFonts) + rFonts.set(qn("w:eastAsia"), cn_font) + rFonts.set(qn("w:ascii"), en_font) + rFonts.set(qn("w:hAnsi"), en_font) + + +def _set_style_fonts(style, *, cn_font="宋体", en_font="Times New Roman") -> None: + el = style.element + rPr = el.find(qn("w:rPr")) + if rPr is None: + rPr = OxmlElement("w:rPr") + el.insert(0, rPr) + rFonts = rPr.find(qn("w:rFonts")) + if rFonts is None: + rFonts = OxmlElement("w:rFonts") + rPr.append(rFonts) + rFonts.set(qn("w:eastAsia"), cn_font) + rFonts.set(qn("w:ascii"), en_font) + rFonts.set(qn("w:hAnsi"), en_font) + + +def _set_subscript(run) -> None: + rPr = run._element.get_or_add_rPr() + va = OxmlElement("w:vertAlign") + va.set(qn("w:val"), "subscript") + rPr.append(va) + + +def _para_shading(paragraph, fill: str) -> None: + pPr = paragraph._p.get_or_add_pPr() + shd = OxmlElement("w:shd") + shd.set(qn("w:val"), "clear") + shd.set(qn("w:color"), "auto") + shd.set(qn("w:fill"), fill) + pPr.append(shd) + + +def _para_border(paragraph, *, sides=("bottom",), color=PRIMARY, size=8, space=3) -> None: + pPr = paragraph._p.get_or_add_pPr() + pBdr = pPr.find(qn("w:pBdr")) + if pBdr is None: + pBdr = OxmlElement("w:pBdr") + pPr.append(pBdr) + for side in sides: + el = OxmlElement(f"w:{side}") + el.set(qn("w:val"), "single") + el.set(qn("w:sz"), str(size)) + el.set(qn("w:space"), str(space)) + el.set(qn("w:color"), color) + pBdr.append(el) + + +def _add_bookmark(paragraph, name: str, bm_id: int) -> None: + start = OxmlElement("w:bookmarkStart") + start.set(qn("w:id"), str(bm_id)) + start.set(qn("w:name"), name) + end = OxmlElement("w:bookmarkEnd") + end.set(qn("w:id"), str(bm_id)) + paragraph._p.insert(0, start) + paragraph._p.append(end) + + +def _mk_run_xml(text: str, *, size_pt: float, color=None, superscript=False, + underline=False, bold=False, cn_font="宋体", en_font="Times New Roman"): + r = OxmlElement("w:r") + rPr = OxmlElement("w:rPr") + rFonts = OxmlElement("w:rFonts") + rFonts.set(qn("w:eastAsia"), cn_font) + rFonts.set(qn("w:ascii"), en_font) + rFonts.set(qn("w:hAnsi"), en_font) + rPr.append(rFonts) + if bold: + rPr.append(OxmlElement("w:b")) + if color: + c = OxmlElement("w:color") + c.set(qn("w:val"), color) + rPr.append(c) + if underline: + u = OxmlElement("w:u") + u.set(qn("w:val"), "single") + rPr.append(u) + if superscript: + va = OxmlElement("w:vertAlign") + va.set(qn("w:val"), "superscript") + rPr.append(va) + sz = OxmlElement("w:sz") + sz.set(qn("w:val"), str(int(size_pt * 2))) + rPr.append(sz) + r.append(rPr) + t = OxmlElement("w:t") + t.set(qn("xml:space"), "preserve") + t.text = text + r.append(t) + return r + + +def add_internal_link(paragraph, anchor: str, text: str, *, size_pt: float, + color=PRIMARY, superscript=False) -> None: + h = OxmlElement("w:hyperlink") + h.set(qn("w:anchor"), anchor) + h.append(_mk_run_xml(text, size_pt=size_pt, color=color, superscript=superscript)) + paragraph._p.append(h) + + +def add_external_link(paragraph, url: str, text: str, *, size_pt: float) -> None: + part = paragraph.part + r_id = part.relate_to(url, RT.HYPERLINK, is_external=True) + h = OxmlElement("w:hyperlink") + h.set(qn("r:id"), r_id) + h.append(_mk_run_xml(text, size_pt=size_pt, color=LINK_BLUE, underline=True)) + paragraph._p.append(h) + + +# ───────────────────────── 文档初始化 ───────────────────────── + +def init_doc(color: bool) -> Document: + doc = Document() + section = doc.sections[0] + section.page_height = Cm(29.7) + section.page_width = Cm(21) + for m in ("top_margin", "bottom_margin", "left_margin", "right_margin"): + setattr(section, m, Cm(2.5)) + + normal = doc.styles["Normal"] + normal.font.name = "Times New Roman" + normal.font.size = Pt(12) + _set_style_fonts(normal, cn_font="宋体") + pf = normal.paragraph_format + pf.line_spacing = 1.5 + pf.space_before = Pt(0) + pf.space_after = Pt(0) + + head_color = PRIMARY_RGB if color else RGBColor(0, 0, 0) + for lvl, sz, cn in [(1, Pt(18), "黑体"), (2, Pt(14), "黑体"), (3, Pt(12), "黑体")]: + h = doc.styles[f"Heading {lvl}"] + h.font.name = "Times New Roman" + h.font.size = sz + h.font.bold = True + h.font.color.rgb = head_color + _set_style_fonts(h, cn_font=cn) + h.paragraph_format.line_spacing = 1.3 + h.paragraph_format.space_before = Pt(10 if lvl <= 2 else 6) + h.paragraph_format.space_after = Pt(4) + h.paragraph_format.first_line_indent = None + return doc + + +# ───────────────────────── 内联:bold/italic/code 切分 ───────────────────────── + +_INLINE_RE = re.compile( + r"(?P\*\*(?P[^*\n]+?)\*\*)" + r"|(?P(?[^*\n]+?)\*(?!\*))" + r"|(?P`(?P[^`\n]+?)`)" +) + +# 引文标记 [12] / [W3] +_CITE_RE = re.compile(r"\[(W?\d+)\]") + +# 化学式下标白名单(统一三处渲染器共用同一份;长的在前,\b 防误伤 LC3 / C595 / 2026; +# 不含 Ca2+ 这类带电荷的——它是上标不是下标,白名单不收即天然避开) +_CHEM_RE = re.compile( + r"Ca\(OH\)2|Mg\(OH\)2" + r"|\b(?:Al2O3|Fe2O3|Fe3O4|Mn2O3|Cr2O3|P2O5|Na2SO4|K2SO4|CaSO4|CaCO3|MgCO3|" + r"CaCl2|MgCl2|Na2O|K2O|SiO2|TiO2|ZrO2|SO4|SO3|SO2|CO3|CO2|NO3|NO2|PO4|" + r"H2O|NH3|CH4|C4AF|C3S2|C2AS|C3S|C2S|C3A|O2|N2|H2)\b" +) + + +def _emit_chem(paragraph, text: str, *, size_pt: float, cn_font: str) -> None: + """把白名单化学式里的数字渲成下标,其余正常。""" + pos = 0 + for m in _CHEM_RE.finditer(text): + if m.start() > pos: + _emit_plain_run(paragraph, text[pos:m.start()], size_pt=size_pt, cn_font=cn_font) + formula = m.group(0) + buf = "" + for ch in formula: + if ch.isdigit(): + if buf: + _emit_plain_run(paragraph, buf, size_pt=size_pt, cn_font=cn_font) + buf = "" + sub = paragraph.add_run(ch) + sub.font.size = Pt(size_pt) + _set_run_fonts(sub, cn_font=cn_font, en_font="Times New Roman") + _set_subscript(sub) + else: + buf += ch + if buf: + _emit_plain_run(paragraph, buf, size_pt=size_pt, cn_font=cn_font) + pos = m.end() + if pos < len(text): + _emit_plain_run(paragraph, text[pos:], size_pt=size_pt, cn_font=cn_font) + + +def _emit_plain_run(paragraph, text: str, *, size_pt: float, cn_font: str) -> None: + if not text: + return + run = paragraph.add_run(text) + run.font.size = Pt(size_pt) + _set_run_fonts(run, cn_font=cn_font, en_font="Times New Roman") + + +def _emit_plain_with_cites(paragraph, text: str, *, size_pt: float, cn_font: str, + make_citations: bool) -> None: + """plain 段:处理引文上标超链接 + 化学式下标。""" + if not make_citations: + _emit_chem(paragraph, text, size_pt=size_pt, cn_font=cn_font) + return + pos = 0 + prev_end = None + for m in _CITE_RE.finditer(text): + if m.start() > pos: + _emit_chem(paragraph, text[pos:m.start()], size_pt=size_pt, cn_font=cn_font) + # 连续 [1][3] 之间补一个上标逗号 + if prev_end is not None and m.start() == prev_end: + comma = paragraph.add_run(",") + comma.font.size = Pt(size_pt * 0.85) + comma.font.color.rgb = PRIMARY_RGB + _set_subscript_super(comma) + cid = m.group(1) + add_internal_link(paragraph, f"ref_{cid}", cid, size_pt=size_pt * 0.85, + color=PRIMARY, superscript=True) + prev_end = m.end() + pos = m.end() + if pos < len(text): + _emit_chem(paragraph, text[pos:], size_pt=size_pt, cn_font=cn_font) + + +def _set_subscript_super(run) -> None: + rPr = run._element.get_or_add_rPr() + va = OxmlElement("w:vertAlign") + va.set(qn("w:val"), "superscript") + rPr.append(va) + + +def add_inline_rich(paragraph, text: str, *, size_pt=12.0, cn_font="宋体", + make_citations=True) -> None: + pos = 0 + for m in _INLINE_RE.finditer(text): + if m.start() > pos: + _emit_plain_with_cites(paragraph, text[pos:m.start()], size_pt=size_pt, + cn_font=cn_font, make_citations=make_citations) + if m.group("bold"): + run = paragraph.add_run(m.group("bold_t")) + run.bold = True + run.font.size = Pt(size_pt) + _set_run_fonts(run, cn_font=cn_font) + elif m.group("italic"): + run = paragraph.add_run(m.group("italic_t")) + run.italic = True + run.font.size = Pt(size_pt) + _set_run_fonts(run, cn_font=cn_font) + elif m.group("code"): + run = paragraph.add_run(m.group("code_t")) + run.font.size = Pt(size_pt) + _set_run_fonts(run, cn_font=cn_font, en_font="Consolas") + pos = m.end() + if pos < len(text): + _emit_plain_with_cites(paragraph, text[pos:], size_pt=size_pt, + cn_font=cn_font, make_citations=make_citations) + + +# ───────────────────────── 标题 / 段落 ───────────────────────── + +def add_heading(doc: Document, text: str, level: int, color: bool) -> None: + p = doc.add_paragraph(style=f"Heading {level}") + p.paragraph_format.first_line_indent = None + sizes = {1: 18.0, 2: 14.0, 3: 12.0} + if level == 1: + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + add_inline_rich(p, text, size_pt=sizes[level], cn_font="黑体", make_citations=False) + for run in p.runs: + run.bold = True + if color and level <= 2: + _para_border(p, sides=("bottom",), color=PRIMARY, size=(12 if level == 1 else 6)) + elif color and level == 3: + p.paragraph_format.left_indent = Pt(8) + _para_border(p, sides=("left",), color=PRIMARY, size=20, space=6) + + +def add_body_paragraph(doc: Document, text: str, *, indent=True) -> None: + p = doc.add_paragraph() + pf = p.paragraph_format + pf.line_spacing = 1.5 + pf.first_line_indent = Pt(24) if indent else None + p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY + add_inline_rich(p, text) + + +def add_callout(doc: Document, text: str, fill: str, color: bool) -> None: + """判断 / 引用块类强调框:底纹 + 左红条。""" + p = doc.add_paragraph() + pf = p.paragraph_format + pf.line_spacing = 1.4 + pf.first_line_indent = None + pf.left_indent = Pt(8) + pf.space_before = Pt(3) + pf.space_after = Pt(3) + if color: + _para_shading(p, fill) + _para_border(p, sides=("left",), color=PRIMARY, size=22, space=5) + add_inline_rich(p, text) + + +def add_meta_band(doc: Document, text: str, color: bool) -> None: + """标题下方的信息带(方向/时间窗/深度/数据源/受众):居中浅红底纹 + 上下细线。""" + p = doc.add_paragraph() + pf = p.paragraph_format + pf.first_line_indent = None + pf.space_before = Pt(2) + pf.space_after = Pt(12) + pf.line_spacing = 1.35 + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + if color: + _para_shading(p, "F3DADA") + _para_border(p, sides=("top", "bottom"), color=PRIMARY, size=6, space=3) + add_inline_rich(p, text, size_pt=10.5, make_citations=False) + + +def add_tldr_card(doc: Document, text: str, color: bool) -> None: + """TL;DR 要点:每条做成浅红左条卡片,堆叠成卡片列。""" + p = doc.add_paragraph() + pf = p.paragraph_format + pf.first_line_indent = None + pf.left_indent = Pt(10) + pf.space_before = Pt(1) + pf.space_after = Pt(3) + pf.line_spacing = 1.3 + if color: + _para_shading(p, TLDR_FILL) + _para_border(p, sides=("left",), color=PRIMARY, size=26, space=6) + add_inline_rich(p, text, size_pt=11.0) + + +def _add_field(paragraph, instr: str) -> None: + run = paragraph.add_run() + for typ, payload in (("begin", None), ("instr", instr), ("separate", None), ("end", None)): + if typ == "instr": + el = OxmlElement("w:instrText") + el.set(qn("xml:space"), "preserve") + el.text = payload + else: + el = OxmlElement("w:fldChar") + el.set(qn("w:fldCharType"), typ) + run._r.append(el) + + +def add_page_footer(doc: Document, color: bool) -> None: + p = doc.sections[0].footer.paragraphs[0] + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + pre = p.add_run("第 ") + _add_field(p, " PAGE ") + post = p.add_run(" 页") + for r in p.runs: + r.font.size = Pt(9) + if color: + r.font.color.rgb = PRIMARY_RGB + _set_run_fonts(r, cn_font="宋体") + + +# ───────────────────────── 参考文献条目(可点击)───────────────────────── + +_REF_RE = re.compile(r"^\[(W?\d+)\]\s+(.+)$") +_DOI_RE = re.compile(r"^10\.\d{4,9}/\S+$") +_URL_TOKEN_RE = re.compile(r"([a-z0-9][\w.\-]*\.[a-z]{2,}(?:/[^\s]+)?)", re.IGNORECASE) + + +def add_reference_item(doc: Document, cid: str, value: str, bm_id: int, color: bool) -> None: + p = doc.add_paragraph() + pf = p.paragraph_format + pf.first_line_indent = None + pf.left_indent = Pt(18) + pf.line_spacing = 1.3 + _add_bookmark(p, f"ref_{cid}", bm_id) + # 编号标签 [n] + lab = p.add_run(f"[{cid}] ") + lab.bold = True + lab.font.size = Pt(10.5) + if color: + lab.font.color.rgb = PRIMARY_RGB + _set_run_fonts(lab, cn_font="宋体") + value = value.strip() + if _DOI_RE.match(value): + add_external_link(p, f"https://doi.org/{value}", value, size_pt=10.5) + return + # web 条目:把第一个像 URL 的 token 变成超链接 + m = _URL_TOKEN_RE.search(value) + if m and ("/" in m.group(1) or m.group(1).count(".") >= 1) and " " not in m.group(1): + pre, mid, post = value[:m.start()], m.group(1), value[m.end():] + _emit_plain_run(p, pre, size_pt=10.5, cn_font="宋体") + url = mid if mid.startswith("http") else f"https://{mid}" + add_external_link(p, url, mid, size_pt=10.5) + if post: + _emit_plain_run(p, post, size_pt=10.5, cn_font="宋体") + else: + _emit_plain_run(p, value, size_pt=10.5, cn_font="宋体") + + +# ───────────────────────── 行类型识别 ───────────────────────── + +_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$") +_TABLE_LINE_RE = re.compile(r"^\s*\|.*\|\s*$") +_BLOCKQUOTE_RE = re.compile(r"^\s*>\s?") +_HR_RE = re.compile(r"^\s*-{3,}\s*$|^\s*={3,}\s*$|^\s*_{3,}\s*$") +_FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})\s*(\S*)\s*$") +_IMAGE_LINE_RE = re.compile(r"^\s*!\[(?P[^\]]*)\]\((?P[^)\s]+)\)\s*$") +_MAX_IMG_WIDTH = Cm(15) + +_LIST_PATTERNS = [ + re.compile(r"^[-*+]\s"), + re.compile(r"^\d+[\.、.]\s*"), + re.compile(r"^\(\d+\)\s*"), + re.compile(r"^(\d+)\s*"), + re.compile(r"^[①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮]"), +] + + +def is_list_item(line: str) -> bool: + return any(p.match(line) for p in _LIST_PATTERNS) + + +# ───────────────────────── 表格 ───────────────────────── + +def _split_md_row(line: str) -> list[str]: + return [c.strip() for c in line.strip().strip("|").split("|")] + + +def _is_sep_row(cells: list[str]) -> bool: + return all(re.match(r"^[-:\s]+$", c) for c in cells if c != "") + + +def render_table(doc: Document, table_lines: list[str], color: bool) -> None: + rows = [] + for ln in table_lines: + cells = _split_md_row(ln) + if not cells or _is_sep_row(cells): + continue + rows.append(cells) + if not rows: + return + n_cols = max(len(r) for r in rows) + for r in rows: + while len(r) < n_cols: + r.append("") + table = doc.add_table(rows=len(rows), cols=n_cols) + try: + table.style = "Table Grid" + except KeyError: + pass + for ri, row in enumerate(rows): + for ci, val in enumerate(row): + cell = table.rows[ri].cells[ci] + cell.text = "" + p = cell.paragraphs[0] + p.paragraph_format.first_line_indent = None + p.paragraph_format.line_spacing = 1.2 + add_inline_rich(p, val, size_pt=10.5, cn_font="宋体", make_citations=False) + if ri == 0: + if color: + _para_shading(p, TABLE_HEAD_FILL) + for run in p.runs: + run.bold = True + if color: + run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) + + +# ───────────────────────── 图片 ───────────────────────── + +def _resolve_image_path(src: str, base_dir: Path) -> Path | None: + 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: + run.add_text(f"[image failed: {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_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="宋体") + + +# ───────────────────────── 主渲染 ───────────────────────── + +def render_md_block(doc: Document, md_text: str, ctx: dict) -> None: + color = ctx["color"] + lines = md_text.splitlines() + i, n = 0, len(lines) + in_refs = False # 进入「参考文献」段后,[n] 行按引文条目渲染 + expect_meta = False # 紧跟 H1 标题的信息带(方向/时间窗...) + in_tldr = False # 「一句话要点」段:列表项做卡片 + while i < n: + line = lines[i].rstrip() + if not line.strip(): + i += 1 + continue + + if _HR_RE.match(line): + i += 1 + continue + + m_img = _IMAGE_LINE_RE.match(line) + if m_img: + png = _resolve_image_path(m_img.group("src"), ctx["sections_dir"]) + if png is not None: + add_image(doc, png, m_img.group("cap").strip() or None, ctx) + else: + add_body_paragraph(doc, f"[image missing: {m_img.group('src')}]", indent=False) + i += 1 + continue + + m_fence = _FENCE_RE.match(line) + if m_fence: + fence = m_fence.group(1) + code = [] + i += 1 + while i < n: + 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 + code.append(lines[i]) + i += 1 + for ln in code: + p = doc.add_paragraph() + p.paragraph_format.first_line_indent = None + p.paragraph_format.line_spacing = 1.0 + run = p.add_run(ln if ln else " ") + run.font.size = Pt(10.5) + _set_run_fonts(run, cn_font="新宋体", en_font="Consolas") + continue + + if _TABLE_LINE_RE.match(line): + block = [] + while i < n and _TABLE_LINE_RE.match(lines[i]): + block.append(lines[i]) + i += 1 + render_table(doc, block, color) + continue + + m = _HEADING_RE.match(line) + if m: + title = m.group(2).strip() + level = min(len(m.group(1)), 3) + in_refs = "参考文献" in title + expect_meta = (level == 1) + if level <= 2: + in_tldr = ("要点" in title) or ("TL;DR" in title.upper()) + add_heading(doc, title, level, color) + i += 1 + continue + + if _BLOCKQUOTE_RE.match(line): + # 引用块:并合连续 > 行,做浅红 callout(说明 / 取舍纪律等) + buf = [_BLOCKQUOTE_RE.sub("", line).strip()] + i += 1 + while i < n and _BLOCKQUOTE_RE.match(lines[i]): + buf.append(_BLOCKQUOTE_RE.sub("", lines[i]).strip()) + i += 1 + add_callout(doc, " ".join(buf), TLDR_FILL, color) + continue + + # 参考文献条目 + if in_refs: + m_ref = _REF_RE.match(line.strip()) + if m_ref: + ctx["bm_id"] += 1 + add_reference_item(doc, m_ref.group(1), m_ref.group(2), ctx["bm_id"], color) + i += 1 + continue + + # 「判断」强调行 → callout + if line.strip().startswith("**判断**"): + add_callout(doc, line.strip(), CALLOUT_FILL, color) + i += 1 + continue + + if is_list_item(line): + if in_tldr: + add_tldr_card(doc, line.strip(), color) + else: + add_body_paragraph(doc, line.strip(), indent=False) + i += 1 + continue + + # 紧跟标题的信息带 + if expect_meta and ("时间窗" in line): + add_meta_band(doc, line.strip(), color) + expect_meta = False + i += 1 + continue + + # 普通段落:并合软换行 + buf = [line.strip()] + j = i + 1 + while j < n: + nxt = lines[j].rstrip() + if not nxt.strip() or _HEADING_RE.match(nxt) or _BLOCKQUOTE_RE.match(nxt) \ + or _TABLE_LINE_RE.match(nxt) or is_list_item(nxt) or _HR_RE.match(nxt): + break + buf.append(nxt.strip()) + j += 1 + add_body_paragraph(doc, " ".join(buf), indent=True) + i = j + + +def render_sections(sections_dir: Path, out: Path, color: bool) -> None: + if not sections_dir.is_dir(): + print(f"[ERR] sections dir not found: {sections_dir}", file=sys.stderr) + sys.exit(2) + md_files = sorted(sections_dir.glob("*.md")) + if not md_files: + print(f"[ERR] no .md found in {sections_dir}", file=sys.stderr) + sys.exit(2) + + ctx = { + "sections_dir": sections_dir, + "figures_dir": sections_dir.parent / "figures", + "fig_no": 0, + "bm_id": 0, + "color": color, + } + doc = init_doc(color) + add_page_footer(doc, color) + for idx, f in enumerate(md_files): + render_md_block(doc, f.read_text(encoding="utf-8"), ctx) + if idx != len(md_files) - 1: + doc.add_page_break() + + out.parent.mkdir(parents=True, exist_ok=True) + doc.save(str(out)) + + paras = sum(1 for _ in doc.paragraphs) + chars = sum(len(p.text) for p in doc.paragraphs) + print(f"[OK] rendered {len(md_files)} sections -> {out}") + print(f" paragraphs: {paras} | tables: {len(doc.tables)} | figures: {ctx['fig_no']} | chars: {chars}") + print(f" theme: {'商务红 #C00000' if color else '黑白'} | 引文上标+超链接 | 化学式下标白名单") + + +def main() -> None: + ap = argparse.ArgumentParser(description="渲染章节 md → 科研方向简报 docx") + ap.add_argument("sections_dir", type=Path, help="sections/*.md 目录") + ap.add_argument("--no-color", dest="color", action="store_false", + help="关配色,出纯黑白(默认商务红主题)") + ap.add_argument("-o", "--output", type=Path, required=True, help="输出 .docx 路径") + args = ap.parse_args() + render_sections(args.sections_dir, args.output, args.color) + + +if __name__ == "__main__": + main() diff --git a/skills/brief/templates/brief_outline.md b/skills/brief/templates/brief_outline.md new file mode 100644 index 0000000..29c74f6 --- /dev/null +++ b/skills/brief/templates/brief_outline.md @@ -0,0 +1,60 @@ +# 简报骨架 + 篇幅预算 + +简报按深度分三档,骨架同构、详略不同。`sections/` 一节一文件,文件名见各段标注。 + +## 篇幅预算(按深度) + +| 深度 | 页数 | 总字数(中) | 热点簇数 | 引文数(学术) | 机构-地理计量 | +|---|---|---|---|---|---| +| `flash` 快报 | 1–2 | 800–1500 | 2–3 | 8–15 | 不做 | +| `standard` 标准 | 4–6 | 2500–4000 | 3–5 | 20–40 | 可选(一句话点格局) | +| `deep` 深度 | 8+ | 6000+ | 5–7 | 40–80 | 做(发文量趋势 / 国别机构格局) | + +> 字数是预算不是硬指标;宁可短而准,不要灌水凑页。"重要性优先于完整性"。 + +## 骨架(各档同构) + +### `00_tldr.md` — 一句话要点(TL;DR) + +- 5 行内,**先给结论**:这个方向近期最值得知道的 3–5 件事,每行一句带判断 +- 决策者只读这段也能拿到核心态势 +- 例:`- LC3(石灰石煅烧黏土水泥)从中试走向标准化,近 1 年多国发布技术规程` + +### `01_overview.md` — 方向概览与边界 + +- 这个方向**解决什么问题**(1–2 段)、本简报的**纳入/排除边界**(照搬 spec) +- 时间窗 + 数据源覆盖说明(收了哪几路、多少条),让读者知道简报的"视野范围" + +### `02_clusters.md` — 研究热点聚类(主体) + +按 spec 深度分 3–7 簇,每簇一个 `###` 小节: + +``` +### 簇N:<主题句,写判断不写关键词> +- 在做什么 / 解决什么:<1–2 句> +- 代表性进展:<2–4 篇,带 [CITE-xx],各一句 takeaway + 关键数字> +- 新兴方法/技术:<若有> +- 争议/未解:<若有> +``` + +### `03_methods.md` — 新兴方法 / 技术(可并入 02,deep 单列) + +- 跨簇出现的新表征 / 新建模(如 ML 配比优化)/ 新工艺,各带代表文献 + +### `04_progress.md` — 近期标志性进展 + +- 近 N 年**改变判断**的标志性成果(里程碑论文 / 中试 / 突破),3–8 条,带引文与数字 + +### `05_gaps.md` — 研究空白与争议 + +- 大家都没做的(空白)、还没共识的(争议)、方法学局限 —— 这段是简报"有判断"的体现 + +### `06_industry.md` — 产业 / 政策 / 标准动向(web,可选) + +- 双碳政策 / 碳配额 / 新国标团标 / 行业会议 / 企业产线中试 —— **单列,标注来源为 web 资讯** +- 与学术引文分开计数;时效性内容注明日期 + +### `08_references.md` — 参考文献 + +- 仅 citation_verify 核验通过(verified / verified-revised / 用户确认)的进清单 +- 学术引文按文中首次出现顺序编 `[1][2]...`,带 DOI;web 来源另起一段标 URL + 访问日期 diff --git a/skills/brief/templates/spec.md b/skills/brief/templates/spec.md new file mode 100644 index 0000000..d9886d2 --- /dev/null +++ b/skills/brief/templates/spec.md @@ -0,0 +1,54 @@ +# 简报 spec(定题对齐"宪法") + +> 阶段一产物。写定后不再改,阶段二/三/四每阶段前都要 read。`` 是占位符,需用户明确填值,不要硬编。 + +## 1. 方向 + 边界 + +- 研究方向(具体到子方向):`` +- 纳入范围:`` +- 排除范围:`` + +## 2. 时间窗 + +- 口径:`` +- 换算 `year_gte`:``(当前年见 system prompt) + +## 3. 受众 + +- ``(决定语气与详略) + +## 4. 深度 + +- `` +- 对应字数预算 / 簇数 / 引文数见 brief_outline.md + +## 5. 数据源开关(默认三路并用) + +- documents(内部库,材料类首选):`` +- research(补 DOI 与近期元数据):`` +- web(政策·会议·标准·产业动向):`` + +## 6. 语言 + +- `` + +## 7. 特殊关注点 + +> 用户特别想知道的,分析阶段重点回应。 + +- `` + +## 检索词组(阶段二填,中→英) + +> 中文方向转成的英文检索词,库里主语料英文。 + +``` + +``` + +## 待用户提供 / TODO + +- [ ] `` +- [ ] `` diff --git a/skills/paper/scripts/render_docx.py b/skills/paper/scripts/render_docx.py index 99cb386..177da91 100644 --- a/skills/paper/scripts/render_docx.py +++ b/skills/paper/scripts/render_docx.py @@ -155,8 +155,54 @@ def parse_inline(text: str) -> list[tuple[str, str]]: return out or [("plain", text)] +# ── 化学式下标白名单(与 proposal/brief 三处渲染器共用同一份)── +# 长的在前,\b 防误伤 LC3 / C595 / 2026;不收 Ca2+ 这类带电荷的(那是上标,白名单不收即天然避开) +_CHEM_RE = re.compile( + r"Ca\(OH\)2|Mg\(OH\)2" + r"|\b(?:Al2O3|Fe2O3|Fe3O4|Mn2O3|Cr2O3|P2O5|Na2SO4|K2SO4|CaSO4|CaCO3|MgCO3|" + r"CaCl2|MgCl2|Na2O|K2O|SiO2|TiO2|ZrO2|SO4|SO3|SO2|CO3|CO2|NO3|NO2|PO4|" + r"H2O|NH3|CH4|C4AF|C3S2|C2AS|C3S|C2S|C3A|O2|N2|H2)\b" +) + + +def _set_subscript(run) -> None: + rPr = run._element.get_or_add_rPr() + va = OxmlElement("w:vertAlign") + va.set(qn("w:val"), "subscript") + rPr.append(va) + + +def _emit_plain_with_chem(paragraph, text: str, *, size, cn_font: str) -> None: + """plain 段:白名单化学式里的数字渲成下标,其余正常。无命中即一条普通 run。""" + def _run(seg: str, sub: bool = False): + if not seg: + return + r = paragraph.add_run(seg) + r.font.size = size + _set_run_fonts(r, cn_font=cn_font, en_font="Times New Roman") + if sub: + _set_subscript(r) + + pos = 0 + for m in _CHEM_RE.finditer(text): + _run(text[pos:m.start()]) + buf = "" + for ch in m.group(0): + if ch.isdigit(): + _run(buf); buf = "" + _run(ch, sub=True) + else: + buf += ch + _run(buf) + pos = m.end() + _run(text[pos:]) + + def add_inline(paragraph, text: str, *, size: Pt = Pt(12), cn_font: str = "宋体") -> None: for style, seg in parse_inline(text): + if style == "plain": + _emit_plain_with_chem(paragraph, seg, size=size, cn_font=cn_font) + continue run = paragraph.add_run(seg) run.font.size = size if style == "bold": @@ -167,8 +213,6 @@ def add_inline(paragraph, text: str, *, size: Pt = Pt(12), cn_font: str = "宋 _set_run_fonts(run, cn_font=cn_font, en_font="Times New Roman") elif style == "code": _set_run_fonts(run, cn_font=cn_font, en_font="Consolas") - else: - _set_run_fonts(run, cn_font=cn_font, en_font="Times New Roman") # ───────────────────────── 段落 / 标题 / 列表 ───────────────────────── diff --git a/skills/proposal/scripts/render_docx.py b/skills/proposal/scripts/render_docx.py index efdd3ef..93239c7 100644 --- a/skills/proposal/scripts/render_docx.py +++ b/skills/proposal/scripts/render_docx.py @@ -182,8 +182,54 @@ def parse_inline(text: str) -> list[tuple[str, str]]: return out or [("plain", text)] +# ── 化学式下标白名单(与 paper/brief 三处渲染器共用同一份)── +# 长的在前,\b 防误伤 LC3 / C595 / 2026;不收 Ca2+ 这类带电荷的(那是上标,白名单不收即天然避开) +_CHEM_RE = re.compile( + r"Ca\(OH\)2|Mg\(OH\)2" + r"|\b(?:Al2O3|Fe2O3|Fe3O4|Mn2O3|Cr2O3|P2O5|Na2SO4|K2SO4|CaSO4|CaCO3|MgCO3|" + r"CaCl2|MgCl2|Na2O|K2O|SiO2|TiO2|ZrO2|SO4|SO3|SO2|CO3|CO2|NO3|NO2|PO4|" + r"H2O|NH3|CH4|C4AF|C3S2|C2AS|C3S|C2S|C3A|O2|N2|H2)\b" +) + + +def _set_subscript(run) -> None: + rPr = run._element.get_or_add_rPr() + va = OxmlElement("w:vertAlign") + va.set(qn("w:val"), "subscript") + rPr.append(va) + + +def _emit_plain_with_chem(paragraph, text: str, *, size, cn_font: str) -> None: + """plain 段:白名单化学式里的数字渲成下标,其余正常。无命中即一条普通 run。""" + def _run(seg: str, sub: bool = False): + if not seg: + return + r = paragraph.add_run(seg) + r.font.size = size + _set_run_fonts(r, cn_font=cn_font, en_font="Times New Roman") + if sub: + _set_subscript(r) + + pos = 0 + for m in _CHEM_RE.finditer(text): + _run(text[pos:m.start()]) + buf = "" + for ch in m.group(0): + if ch.isdigit(): + _run(buf); buf = "" + _run(ch, sub=True) + else: + buf += ch + _run(buf) + pos = m.end() + _run(text[pos:]) + + def add_inline(paragraph, text: str, *, size: Pt = Pt(12), cn_font: str = "宋体") -> None: for style, seg in parse_inline(text): + if style == "plain": + _emit_plain_with_chem(paragraph, seg, size=size, cn_font=cn_font) + continue run = paragraph.add_run(seg) run.font.size = size if style == "bold": @@ -194,8 +240,6 @@ def add_inline(paragraph, text: str, *, size: Pt = Pt(12), cn_font: str = "宋 _set_run_fonts(run, cn_font=cn_font, en_font="Times New Roman") elif style == "code": _set_run_fonts(run, cn_font=cn_font, en_font="Consolas") - else: - _set_run_fonts(run, cn_font=cn_font, en_font="Times New Roman") # ───────────────────────── 段落 / 标题 / 列表 ─────────────────────────