feat(skill): brief 科研方向简报(三路检索 documents/research/web)+ 全局化学式下标修复 + bump 0.18.0

新增 brief skill:给定研究方向 + 时间窗,用三路真实数据(documents 内部库取全文 /
research 取近期 DOI 元数据 / web 取政策·会议·标准动向)产出文献计量趋势型简报。
六阶段:定题对齐 spec → 三路检索取数(中→英术语 + 跨源去重)→ 趋势分析(3-7 热点簇)
→ 逐段起草 → 引文核验(复用 paper 三层协议)→ 渲染验收。深度三档 flash/standard/deep。

自带 render_docx.py(简报专属版式):商务红主题 + 正文 [n]/[Wn] 引文上标并锚到文末
+ DOI/URL 可点击超链接 + TL;DR 卡片 + 标题信息带 + 页脚页码。

顺带修 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 是对话原文转录、非排版文档,不动。

文件:skills/brief/{SKILL.md, templates/{spec,brief_outline}.md,
references/{search_strategy,citation_verify}.md, scripts/{quality_check,render_docx}.py};
SKILL_LIST.md(16→17)+ PROGRESS.md 同步。bump 0.17.0 → 0.18.0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-18 11:28:03 +08:00
parent e87daa7c89
commit 4f61b5fc56
13 changed files with 1471 additions and 8 deletions

3
.gitignore vendored
View File

@ -49,3 +49,6 @@ untitled*.pptx
规划.docx
cl.ps1
col.ps1
# brief skill 临时样例输出 (可由 skill 重新生成, 不入库)
.brief_out/

View File

@ -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 §取舍同步标注)。

View File

@ -1,8 +1,8 @@
# zcbot Skill 清单
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
最后更新:2026-06-17
Skill 总数:16
最后更新:2026-06-18
Skill 总数:17
zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。
@ -23,6 +23,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/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 取政策·会议·标准动向)产出一份**有判断、可溯源**的简报:热点聚类 + 新兴方法 + 关键进展 + 研究空白 + 产业政策动向。简报 ≠ 综述论文 —— 要**快、准、有取舍**("重要性优先于完整性"),帮决策者 / 课题组 520 分钟掌握一个方向近期态势。
**六阶段**:定题对齐 spec(方向+边界 / 时间窗 / 受众 / 深度 / 源开关 / 语言 / 关注点)→ 三路检索取数(中→英术语转译 + 跨源去重,证据表)→ 趋势分析(37 热点簇,对齐后再写)→ 逐段起草 → 引文核验(复用 paper 三层协议)→ 渲染验收。
**深度三档**:`flash` 快报(12 页)/ `standard` 标准(46 页)/ `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) → 改造成本组 / 本人专属版本(术语 / 模板 / 默认值),之后日常任务直接用改造版

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.17.0"
__version__ = "0.18.0"

114
skills/brief/SKILL.md Normal file
View File

@ -0,0 +1,114 @@
---
name: brief
description: 生成科研方向简报(research direction briefing / 文献计量趋势型简报)。给定一个研究方向 + 时间窗,用三路真实数据(documents 内部库取全文 / research 取近期 DOI 元数据 / web 取政策·会议·标准动向),产出一份热点聚类 + 新兴方法 + 关键进展 + 研究空白 + 产业政策动向的可读简报,每条论断可溯源、不编造引文。当用户要"简报 / 方向简报 / 研究动态 / 趋势报告 / 调研快报 / 某方向近期进展 / 文献综述快讯 / 跟踪某领域最新研究"时使用。
---
# 科研方向简报
把"某研究方向最近发生了什么"变成一份**可读、可溯源、有判断**的简报。**先定题对齐 → 三路检索取数 → 趋势分析 → 逐段起草 → 引文核验渲染** —— 不要一口气出全文,定题和分析阶段先和用户对齐方向与边界。
简报 ≠ 综述论文(paper review):综述要全面、深、给定论;简报要**快、准、有取舍**——"重要性优先于完整性",帮决策者 / 课题组 520 分钟掌握一个方向近期态势。
进度展示建议:用 `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。
## 资源
下面所有路径相对 **`<skill_dir>`** —— `load_skill` 返回头里的 `[skill=brief, dir=<绝对路径>]`,用这个绝对路径拼脚本/资源,不要假设 cwd。
**先读(always)**:
- `<skill_dir>/templates/brief_outline.md` —— 简报骨架 + 按深度(快报/标准/深度)的字数预算与簇数/引文数
- `<skill_dir>/references/search_strategy.md` —— 三路检索分工(documents/research/web)+ 跨源去重 + 中文方向→英文术语转译
**阶段五必读**:
- `<skill_dir>/references/citation_verify.md` —— 引文核验协议(存在性 / 三角印证 / 支撑度,复用 paper 思路,接 documents/research/web)
**模板**:
- `templates/spec.md` —— 七条定题对齐固定字段(复制到 task 级 spec 文件)
**脚本**(`.venv/Scripts/python.exe <skill_dir>/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 级「宪法」文件命名约定》:
<task_dir>/<today>-<task_short_id>-<task_name>.spec.md
复制 `templates/spec.md` 填七条,**有歧义先反问,不要替用户拍板**:
1. **方向 + 边界**:具体到子方向(不是"水泥"而是"低碳水泥 SCM");明确**纳入/排除**(如"只看辅助胶凝材料替代,不含碱激发")
2. **时间窗**:默认**近 3 年**;用户说"最新/近期"→ 近 1 年;"这两年"→ 近 2 年。换算成 `year_gte`(今年是 system prompt 给的当前年)
3. **受众**:院领导汇报 / 课题组内部 / 立项前调研 / 对外交流 —— 决定语气与详略
4. **深度**:`flash` 快报(12 页)/ `standard` 标准(46 页)/ `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. 汇成**证据表** `<task_dir>/evidence.md`(仿 lit_matrix):一行一条 = 来源 | 标题 | 年 | 一句话 takeaway | 归属簇 | 引文可用性(documents全文/DOI/web)
收 2080 条(按深度),**不求穷尽**,够支撑各簇即可。命中 0 条先换同义词/放宽年份,3 次仍空如实告诉用户库未覆盖,**不脑补文献**。
## 阶段三:趋势分析(和用户对齐结构)BLOCKING-lite
把证据表**聚成 37 个热点簇**(按深度),给用户看簇划分 + 每簇代表文献,认可后再起草。每簇判断:
- **这个簇在做什么 / 解决什么问题**(一句话主题句,不是关键词堆砌)
- **代表性进展**(24 篇,带真实引文)
- **新兴方法 / 技术**(出现的新表征、新建模、新工艺)
- **争议 / 分歧 / 未解**(哪里还没共识)
横向再扫:**研究空白**(大家都没做的)、**机构-地理格局**(deep 才做,元数据够时:谁在领跑、中国占比)、**产业/政策动向**(来自 web)。
> 取舍纪律:一个方向近期可能上百篇,简报只留**改变判断的**。重复验证性工作合并成一句"多篇验证了 X";边缘工作直接不收。宁缺毋滥。
## 阶段四:逐段起草
`brief_outline.md` 骨架写 `<task_dir>/sections/*.md`,**每段一个论断 + 证据**:
- TL;DR 要点(5 行内,先给结论)→ 方向概览与边界 → 研究热点聚类(各簇)→ 新兴方法 → 近期标志性进展 → 研究空白与争议 → 产业/政策/标准动向(web,可选)→ 参考文献
- 起草时引文用占位 `[CITE-<keyword>]`,阶段五核验后映射真实条目并编号
- 数字 / 定量结论必须挂引文;"据报道""有研究表明"这种无源句式禁止
## 阶段五:引文核验(渲染前必跑)
**先读 `references/citation_verify.md`**,对所有引文逐条核验:存在性(两库/web 命中)→ 三角印证(关键论断 ≥2 源)→ 支撑度(抓原文锚点,partial 就改论断迁就证据)。台账写 `<task_dir>/CITATIONS.md`
**铁律(同 paper)**:status 非 verified 的引文不得进最终稿;不为凑数编造文献;支撑不足改论断不改证据;两库/web 都查不到如实告诉用户。
## 阶段六:渲染验收
1. `quality_check.py --depth <flash|standard|deep>` 跑 sections:结构 / 簇数预算 / 占位符 / 过度宣称 / 无源句式 / 引文交叉核对
2. 用户要 docx → `.venv/Scripts/python.exe <skill_dir>/scripts/render_docx.py <sections_dir> -o <方向>-简报.docx`(商务红 + 引文上标超链接 + 化学式下标;`--no-color` 出黑白);要 deck → 转 ppt skill
4. 交付时一句话说清:覆盖了哪几路源、收了多少条证据、哪些被取舍、哪些点是单源待复核
## 反模式
- ❌ 跳过定题直接检索 —— 方向边界没定,检索词发散,收一堆不相关
- ❌ 把命中的文献**全部**堆进简报 —— 简报是取舍的艺术,不是清单转储
- ❌ web 抓的资讯当学术结论引 —— web 动向单列,学术论断要文献支撑
- ❌ 编造 DOI / "据报道"无源句 —— 走 citation_verify,查不到就如实说
- ❌ 用中文 keyword 搜英文库 —— 先转专业英文术语(见 search_strategy.md)

View File

@ -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""国办发2025x号")
- web 信息标注"截至 <日期>",时效性内容明确边界——避免简报过期后误导
## 产出:核验台账 `CITATIONS.md`
```markdown
# 引文核验台账
- [1] <author> <year>, <journal> | exists:✓(documents+DOI) | triangulate:✓ | claim:support "<≤25词锚点>"(§x) | status: verified
- [2] <author> <year> | exists:✓ | claim:partial → 已把"大幅提升"改为"28d 提高约 15%" | status: verified-revised
- [W1] <机构> <标题>, <URL>, 访问 <日期> | 类型:政策动向 | status: web-sourced
- [3] <author> <year> | exists:✗ 两库未命中 | status: 待用户提供
```
## 铁律(同 paper)
- ❌ status 非 verified/verified-revised/用户确认的学术引文不得进最终稿
- ❌ 不为凑数编造"看起来合理"的文献
- ❌ web 资讯当学术结论引(单列动向段,标 URL+日期)
- ✅ 支撑不足**改论断迁就证据**,不是改证据迁就论断
- ✅ 两库/web 都查不到如实告诉用户,给"提供来源 / 删论断"两个选项

View File

@ -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)",去重在表上做完再起草
## 收多少 / 收到什么程度
- 按深度收 2080 条候选(见 brief_outline 预算),**不求穷尽**——够支撑各簇判断即可
- 每簇至少 2 篇代表文献(关键论断 ≥2 源,接 citation_verify 三角印证)
- 命中 0 条:换同义词 / 展开缩写 / 放宽年份;3 次仍空 → 如实告诉用户库未覆盖该窗口,**不脑补**

View File

@ -0,0 +1,270 @@
"""科研方向简报质量检查 — 渲染前跑一遍。
检查项:
- 结构完整性: 按深度(flash/standard/deep)必备段落是否齐全
- 占位符泄漏: <TODO> / [CITE-xx] 占位是否还在
- 过度宣称: "国际领先 / 首次 / 颠覆 / unprecedented" 等无证据夸张词(简报要有判断但别吹)
- 无源句式: "据报道 / 有研究表明 / 业内普遍认为" 等不挂引文的论断(简报每条论断要可溯源)
- 引文交叉核对: 文中学术引 [n] 与参考文献清单 [n] 互查(orphan / uncited / 编号连续)
- web 来源计数: [W1].. 单独统计,提醒和学术引文分开
用法:
python quality_check.py <sections_dir> --depth standard
python quality_check.py <sections_dir> --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"<TODO[^>]*>",
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()

View File

@ -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 <sections_dir> -o <out.docx>
python render_docx.py <sections_dir> --no-color -o <out.docx> # 关配色出纯黑白
"""
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<bold>\*\*(?P<bold_t>[^*\n]+?)\*\*)"
r"|(?P<italic>(?<![\*\w])\*(?P<italic_t>[^*\n]+?)\*(?!\*))"
r"|(?P<code>`(?P<code_t>[^`\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<cap>[^\]]*)\]\((?P<src>[^)\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()

View File

@ -0,0 +1,60 @@
# 简报骨架 + 篇幅预算
简报按深度分三档,骨架同构、详略不同。`sections/` 一节一文件,文件名见各段标注。
## 篇幅预算(按深度)
| 深度 | 页数 | 总字数(中) | 热点簇数 | 引文数(学术) | 机构-地理计量 |
|---|---|---|---|---|---|
| `flash` 快报 | 12 | 8001500 | 23 | 815 | 不做 |
| `standard` 标准 | 46 | 25004000 | 35 | 2040 | 可选(一句话点格局) |
| `deep` 深度 | 8+ | 6000+ | 57 | 4080 | 做(发文量趋势 / 国别机构格局) |
> 字数是预算不是硬指标;宁可短而准,不要灌水凑页。"重要性优先于完整性"。
## 骨架(各档同构)
### `00_tldr.md` — 一句话要点(TL;DR)
- 5 行内,**先给结论**:这个方向近期最值得知道的 35 件事,每行一句带判断
- 决策者只读这段也能拿到核心态势
- 例:`- LC3(石灰石煅烧黏土水泥)从中试走向标准化,近 1 年多国发布技术规程`
### `01_overview.md` — 方向概览与边界
- 这个方向**解决什么问题**(12 段)、本简报的**纳入/排除边界**(照搬 spec)
- 时间窗 + 数据源覆盖说明(收了哪几路、多少条),让读者知道简报的"视野范围"
### `02_clusters.md` — 研究热点聚类(主体)
按 spec 深度分 37 簇,每簇一个 `###` 小节:
```
### 簇N:<主题句,写判断不写关键词>
- 在做什么 / 解决什么:<12 >
- 代表性进展:<24 , [CITE-xx],各一句 takeaway + 关键数字>
- 新兴方法/技术:<若有>
- 争议/未解:<若有>
```
### `03_methods.md` — 新兴方法 / 技术(可并入 02,deep 单列)
- 跨簇出现的新表征 / 新建模(如 ML 配比优化)/ 新工艺,各带代表文献
### `04_progress.md` — 近期标志性进展
- 近 N 年**改变判断**的标志性成果(里程碑论文 / 中试 / 突破),38 条,带引文与数字
### `05_gaps.md` — 研究空白与争议
- 大家都没做的(空白)、还没共识的(争议)、方法学局限 —— 这段是简报"有判断"的体现
### `06_industry.md` — 产业 / 政策 / 标准动向(web,可选)
- 双碳政策 / 碳配额 / 新国标团标 / 行业会议 / 企业产线中试 —— **单列,标注来源为 web 资讯**
- 与学术引文分开计数;时效性内容注明日期
### `08_references.md` — 参考文献
- 仅 citation_verify 核验通过(verified / verified-revised / 用户确认)的进清单
- 学术引文按文中首次出现顺序编 `[1][2]...`,带 DOI;web 来源另起一段标 URL + 访问日期

View File

@ -0,0 +1,54 @@
# 简报 spec(定题对齐"宪法")
> 阶段一产物。写定后不再改,阶段二/三/四每阶段前都要 read。`<TODO>` 是占位符,需用户明确填值,不要硬编。
## 1. 方向 + 边界
- 研究方向(具体到子方向):`<TODO 低碳水泥的辅助胶凝材料(SCM)路线,不是泛泛的"水泥">`
- 纳入范围:`<TODO 粉煤灰/矿渣/煅烧黏土/石灰石粉等 SCM 替代熟料>`
- 排除范围:`<TODO 碱激发/地聚物单独成体系的不收;CCUS 仅在与 SCM 协同时提及>`
## 2. 时间窗
- 口径:`<TODO 1 / 2 / 3 (默认)>`
- 换算 `year_gte`:`<TODO 当前年减窗口, 近1年2025>`(当前年见 system prompt)
## 3. 受众
- `<TODO 院领导汇报 / 课题组内部 / 立项前调研 / 对外交流>`(决定语气与详略)
## 4. 深度
- `<TODO flash 快报(12页) / standard 标准(46页,默认) / deep 深度(8+页,含机构-地理计量)>`
- 对应字数预算 / 簇数 / 引文数见 brief_outline.md
## 5. 数据源开关(默认三路并用)
- documents(内部库,材料类首选):`<TODO /;胶凝材料库 classification_id=1>`
- research(补 DOI 与近期元数据):`<TODO />`
- web(政策·会议·标准·产业动向):`<TODO />`
## 6. 语言
- `<TODO 中文(默认) / 英文>`
## 7. 特殊关注点
> 用户特别想知道的,分析阶段重点回应。
- `<TODO 如 重点看 CCUS 与水泥协同 / 谁在做工业固废路线 / 关注新国标动向>`
## 检索词组(阶段二填,中→英)
> 中文方向转成的英文检索词,库里主语料英文。
```
<TODO 主词 + 同义/展开词,如:
supplementary cementitious materials | SCM | fly ash | GGBFS / ground granulated blast-furnace slag |
calcined clay | limestone calcined clay cement | LC3 | clinker substitution | low-carbon cement>
```
## 待用户提供 / TODO
- [ ] `<TODO 如 确认时间窗口是近1年还是近2年>`
- [ ] `<TODO 如 确认是否纳入碱激发体系>`

View File

@ -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")
# ───────────────────────── 段落 / 标题 / 列表 ─────────────────────────

View File

@ -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")
# ───────────────────────── 段落 / 标题 / 列表 ─────────────────────────