
Nsfc Length Aligner
- 90 installs
- 2.6k repo stars
- Updated July 20, 2026
- huangwb8/chineseresearchlatex
Checks an NSFC proposal's length against budget standards and expands or compresses sections to hit targets while preserving meaning.
About
Measures a target NSFC proposal's length against length-budget standards, summarizes the gap, and gives targeted optimization advice. Developers use it to expand or compress text to meet length requirements without altering the original meaning.
- Compares against NSFC length-budget standards and summarizes the gap
- Expands or compresses while preserving original meaning
Nsfc Length Aligner by the numbers
- 90 all-time installs (skills.sh)
- +2 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #336 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/huangwb8/chineseresearchlatex --skill nsfc-length-alignerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 90 |
|---|---|
| repo stars | ★ 2.6k |
| Last updated | July 20, 2026 |
| Repository | huangwb8/chineseresearchlatex ↗ |
What it does
Checks an NSFC proposal's length against budget standards and expands or compresses sections to hit targets while preserving meaning.
Files
nsfc-length-aligner
与 bensz-collect-bugs 的协作约定
- 当用户环境中出现因本 skill 设计缺陷导致的 bug 时,优先使用
bensz-collect-bugs按规范记录到~/.bensz-skills/bugs/,严禁直接修改用户本地 Claude Code / Codex 中已安装的 skill 源码。 - 若 AI 仍可通过 workaround 继续完成用户任务,应先记录 bug,再继续完成当前任务。
- 当用户明确要求“report bensz skills bugs”等公开上报动作时,调用本地
gh与bensz-collect-bugs,仅上传新增 bug 到huangwb8/bensz-bugs;不要 pull / clone 整个 bug 仓库。
目标:把“篇幅”从主观感觉变成可量化、可闭环的指标,并围绕预算(budget)指导扩写/压缩。
适用场景
- 你有一份国自然标书,想快速判断是否“某些部分偏短/偏长”
- 你需要按模板的硬性篇幅要求(页数/字数/字符数)对齐
- 你希望尽量不改变原意地扩写或压缩(保持论证主线与证据链)
不适用场景
- 仅需要“统计字数”而不关心预算与改写闭环(可用更简单的脚本即可)
- 标书不在本地(无法提供文本/文件/路径)
工作流(强烈建议按顺序执行)
0) 锁定隐藏工作区(先做)
- 以标书工作目录为根,统一使用
<workdir>/.bensz-api/skills/nsfc-length-aligner/托管所有中间文件与报告 - 不要把
length_report.*、临时分析稿、计划文件写到工作目录根层或仓库其他位置 - 若显式传入
--out-dir,优先使用相对路径.bensz-api/skills/nsfc-length-aligner;脚本会将相对--out-dir解析到--input对应的工作目录,而不是 shell 当前目录 - 若工作目录本身不可写,应先切换到可写副本后再运行;不要为了省事把中间文件散落到项目外部
1) 需求确认(预算口径)
先确认你要对齐的“硬标准”是什么:
- 2026 调研共识的“黄金比例”(面上/青基 C 类,供校对用):立项依据 30%(6–10 页,约 8000–10000 字)/ 研究内容 50%(12–15 页,约 12000–15000 字)/ 研究基础 20%(5–8 页,约 5000–6000 字);合计建议 ≤28 页留缓冲(原则上不超过 30 页)
- 页数(硬约束):2026+ 改版后“原则上不超过 30 页”,实操建议 ≤28 页留缓冲;不要通过缩小字体/行距“挤页数”
- 字符预算(代理指标):中文字符 / 总字符等,用于“改写→复检”的确定性闭环(页数最终以 PDF 复核)
- 预算范围:总篇幅 + 各部分/关键章节预算(至少覆盖:立项依据/研究内容/研究基础)
说明:本 skill 默认使用 config.yaml:length_standard 的示例口径(已对齐 2026 调研建议)。你应按当年指南/模板校对后再使用。
2) 运行篇幅检查(确定性)
对目标标书目录(或单文件)运行检查脚本,生成报告:
python3 scripts/check_length.py --input <目标标书路径> --config config.yaml如需显式声明输出目录,请使用:
python3 scripts/check_length.py --input <目标标书路径> --config config.yaml --out-dir .bensz-api/skills/nsfc-length-aligner如果你的标书基于 NSFC_Young / NSFC_General 模板(项目根目录包含 main.tex),建议把 --input 指向项目根目录:脚本会自动沿 main.tex 的 \input/\include 依赖树收集“实际会编译进 PDF 的文件”,并忽略被注释掉的 \input{...}(避免把可选章节误计入篇幅)。
如果你已编译出最终 PDF(推荐;页数是硬约束),把 PDF 一并传入做页数统计:
python3 scripts/check_length.py --input <目标标书路径> --config config.yaml --pdf <标书.pdf>输出:
- 控制台摘要(总篇幅、超/欠预算项)
<input>/.bensz-api/skills/nsfc-length-aligner/length_report.md(默认输出目录;可用--out-dir自定义)<input>/.bensz-api/skills/nsfc-length-aligner/length_report.json(默认输出目录;可用--out-dir自定义)
注意:--out-dir 若使用相对路径,会被解析到 <input> 对应的工作目录下;这能避免从其他目录启动命令时把报告误写到 shell 当前目录。
运行完成后,必须读取 length_report.md(必要时辅助读取 length_report.json),将“文件级偏差表 +(可选)章节级统计”作为步骤 3 的输入。
3) 解读差距(差在什么地方)
基于报告做 3 件事:
1. 定位“超长/偏短”的文件或章节 2. 判断差距属于:
- 证据链不足(需要补数据/对照/局限)
- 逻辑跳跃(需要补过渡/定义/假设)
- 冗余重复(需要合并/删减)
3. 生成行动清单(扩写/压缩的优先级)
章节级数据用法(更精准定位):
- 若
length_report.md出现章节表格(或 JSON 中存在sections字段),优先在“超长/偏短”的文件内,定位到贡献最大的具体章节,再做定点改写,而不是只在文件级做平均删改 - 当某个文件超长/偏短时:对比其章节统计,若差距主要集中在 1–2 个章节,优先只改这 1–2 节(更容易保持原意与结构稳定)
参考:references/MEANING_PRESERVING_REWRITE_RUBRIC.md
4) 扩写/压缩(尽量不改变原意)
扩写策略(偏短时)
- 先补“可验证信息密度”:定义、假设、对照、消融、风险与备选方案
- 再补“论证闭环”:为什么做 → 怎么做 → 预期怎么验证 → 失败怎么办
- 避免空泛扩写:不引入新主张、不堆形容词
压缩策略(偏长时)
- 去重复:同一论点只保留一次最强表达
- 去背景:把泛背景压成 1-2 句,把篇幅留给“问题-方法-验证”
- 结构化改写:把长段拆成要点(不改变事实顺序)
⚠️ 改写完成后,必须执行步骤 5 复检,确认偏差已消除。未复检视为未完成。
2026 三部分“该瘦/该厚”清单(用于排优先级)
用法(把“静态建议”变成“按差距触发”):
- 先看报告里对应文件的偏差
delta:+N表示超长(优先“该瘦”);-N表示偏短(优先“该厚”);OK表示该部分无需为了预算而改动 delta的绝对值越大,越优先处理;处理顺序建议:先改|delta|最大的文件,再做次大项
立项依据(为什么做):
- 该瘦:教科书式科普、泛化综述、弱相关“国家需求”铺陈、重复意义、文献凑数
- 该厚:Gap(卡点)→ Key Idea(突破口)→ 价值论证(为什么值得做)
研究内容(做什么/怎么做):
- 该瘦:重复表述、过细操作细节、罗列式方法堆砌
- 该厚:逻辑框架、关键实验设计与对照/消融、预期结果与可验证指标、用图说话
研究基础(为什么你能做):
- 该瘦:无关成果堆砌、过度铺垫背景
- 该厚:强相关预实验数据、核心技术能力、平台条件(与研究内容对位)
5) 复检闭环
改完必须再次运行脚本,确认“达标且不超标”:
python3 scripts/check_length.py --input <目标标书路径> --config config.yaml格式红线(2026+ 常见)
- 不缩小字体、不缩小行距来“挤页数”(页数要求是评审风险点)
- 不顶格写到 30 页:建议 ≤28 页留缓冲
- 若当年指南要求声明生成式 AI 使用情况:务必按要求如实说明(合规项)
约定与输出格式
- 报告以“文件级 +(可选)章节级”呈现
- 预算以
config.yaml:length_standard为唯一真相来源 - 中间文件统一托管到
config.yaml:output_settings.intermediate_dir(默认.bensz-api/skills/nsfc-length-aligner) - 所有改写应遵循“最小改动、保持原意”的准则(见 references)
Changelog
All notable changes to this skill will be documented in this file.
The format is based on Keep a Changelog, and this skill adheres to Semantic Versioning.
[Unreleased]
Added
- 轻量测试会话:新增
tests/硬编码与AI规划-人工优化-v202602212332/,验证“报告产物可完全落在 tests 目录内(--out-dir / 默认输出)”且章节级统计可用。
Changed
SKILL.md:明确“步骤 2 → 读取报告 → 步骤 3”的显式交接指令,避免跳过报告直接改写。SKILL.md:补充章节级统计(sections)的使用口径,指导在文件内做定点改写而非平均删改。SKILL.md:在改写步骤末尾增加强制复检提示,强化闭环。SKILL.md:为“三部分该瘦/该厚清单”增加按delta触发的使用方式,降低泛化建议风险。
[0.3.1] - 2026-03-08
Changed
config.yaml:新增output_settings.intermediate_dir=.nsfc-length-aligner,将隐藏工作区目录名收敛为配置项;checker.exclude_globs同步排除.nsfc-length-aligner/,避免复检时误扫中间产物。SKILL.md/README.md:明确“所有中间文件严格托管到工作目录.nsfc-length-aligner/”的运行约定,并补充相对--out-dir的安全用法说明。
Fixed
scripts/check_length.py:默认输出目录从<input>/_artifacts/nsfc-length-aligner/改为<input>/.nsfc-length-aligner/,减少项目根层噪声。scripts/check_length.py:相对--out-dir现改为相对--input工作目录解析,而非 shell 当前目录,修复从仓库根目录运行时报告泄露到错误位置的问题。
[0.3.0] - 2026-02-21
Added
scripts/check_length.py:新增 LaTeX “主入口解析”能力:当输入目录可识别main.tex(或其他包含\documentclass+\begin{document}的入口文件)时,沿\input/\include/\subfile依赖树收集“实际会编译进 PDF 的文件”,并忽略被注释掉的\input{...}(避免把可选章节误计入篇幅)。scripts/check_length.py:章节切分改为“支持跨行 + 嵌套花括号”的最小解析器(更贴近NSFC_Young/NSFC_General模板的\texorpdfstring{...}{...}等写法)。templates/LENGTH_REPORT_TEMPLATE.md:报告总览新增“文件发现模式 / main.tex”字段,便于解释统计范围(filesystem 扫描 vs latex_inputs)。
Changed
config.yaml:默认只统计*.tex(避免把同目录 README/笔记等 Markdown 误计入篇幅);并新增checker.latex.follow_inputs=auto以适配 NSFC_Young / NSFC_General 模板的真实编译结构。README.md/SKILL.md:补充针对 NSFC_Young / NSFC_General 模板的推荐用法说明。
Fixed
scripts/check_length.py:当默认输出目录不可写(例如模板仓库只读)时,给出明确错误与--out-dir提示并以退出码=2 退出(避免直接抛 traceback)。
[0.2.1] - 2026-02-21
Changed
config.yaml:补充“创新/计划拆分成独立文件时需从研究内容预算等额扣除”的口径,避免双重计量导致误判。SKILL.md:新增 2026 三部分(立项依据/研究内容/研究基础)“该瘦/该厚”优先级清单,便于按差距快速定位改写方向。
[0.2.0] - 2026-02-21
Added
scripts/check_length.py:新增--pdf(可选)统计 PDF 页数;在报告中输出页数预算(建议 max / 硬上限 hard_max)与页数偏差。
Changed
config.yaml:默认篇幅口径对齐 2026 调研建议(三大部分 + 总预算区间),新增length_standard.pages(页数硬约束)。scripts/check_length.py:length_standard.overall支持min/max;并在 JSON/MD 报告中结构化输出 overall/page 预算信息。templates/LENGTH_REPORT_TEMPLATE.md:总览补齐 PDF/页数预算与偏差信息,明确“页数硬约束、字符预算为可复检代理指标”的口径。references/DEFAULT_STANDARD_NOTES.md/README.md/SKILL.md:同步更新 2026+ 页数优先策略与使用说明。
Fixed
scripts/check_length.py:checker.latex.section_commands真正生效;跳过空章节段,避免 “(no section)” 噪音。scripts/check_length.py:复检时默认排除_artifacts/,避免把旧报告计入篇幅;glob 匹配兼容**/前缀与 root-relative 路径,确保排除规则生效。
[0.1.0] - 2026-02-21
Added
- 初始化
nsfc-length-alignerdemo:内置示例篇幅标准、篇幅检查脚本、差距报告模板与使用说明。
# nsfc-length-aligner 配置文件(Single Source of Truth)
#
# 说明:
# - 本 skill 以“篇幅预算(budget)”为核心:先量化,再对齐。
# - 默认预算为“示例标准”,务必按当年指南/模板要求人工校对后再使用。
skill_info:
name: "nsfc-length-aligner"
version: "0.3.1"
description: "国自然标书篇幅对齐:内置(可配置)2026版30页改革的推荐篇幅口径(页数为硬约束,字符预算为可复检代理指标),自动检查目标标书篇幅并生成差距报告,指导在尽量不改变原意的前提下扩写/压缩到达标。"
author: "Bensz Conan"
category: "normal"
directories:
plans: "plans"
tests: "tests"
output_settings:
intermediate_dir: ".bensz-api/skills/nsfc-length-aligner"
# ============================================================================
# 篇幅标准(示例)
# ============================================================================
#
# unit:
# - cjk_chars: 主要面向中文正文(更稳定)
# - chars: 去空白后的字符数(中英文混合时可用)
#
# budgets.files: 通过 glob 匹配文件路径(相对 input 目录或文件的路径)
# - match: glob(使用 Python fnmatch 规则)
# - min/max: 允许区间(闭区间);可只填 target,脚本会用 +/- tolerance_ratio 生成区间
#
length_standard:
unit: "cjk_chars"
tolerance_ratio: 0.08
pages:
# 2026 改版:正文原则上不超过 30 页;实操建议控制在 28 页以内留缓冲。
# 说明:页数受模板/字体/行距/图表影响很大;不要通过缩小字体/行距“挤页数”。
max: 28
hard_max: 30
notes: "页数为硬约束:建议 ≤28 页留缓冲;原则上不超过 30 页。"
overall:
# 2026 调研口径(面上/青基 C 类):
# - 合计约 25000–31000 字(建议与页数联动校对:总页数≤28)
# 注意:这里的“字”是写作口径;脚本的 cjk_chars 只统计中日韩统一表意文字,英文/数字/标点会被低估。
min: 25000
max: 31000
notes: "2026 调研示例:合计约 25000–31000 字;建议总页数≤28 留缓冲。请按当年指南/模板校对。"
files:
- name: "立项依据"
match: "*立项依据*.tex"
min: 8000
max: 10000
notes: "6–10 页(约 8000–10000 字):穿透式,不铺陈;聚焦 Gap/Key Idea/价值论证。"
- name: "研究内容"
match: "*研究内容*.tex"
min: 12000
max: 15000
notes: "12–15 页(约 12000–15000 字):绝对核心;逻辑框架 + 关键设计 + 图表表达;应提炼创新点与年度计划(如把创新/计划拆到独立文件,请从本预算中等额扣除,保持总量不变)。"
- name: "研究基础"
match: "*研究基础*.tex"
min: 5000
max: 6000
notes: "5–8 页(约 5000–6000 字):证据链证明可行性;强相关预实验数据 + 核心技术能力 + 平台条件。"
- name: "特色与创新(通常并入研究内容)"
match: "*创新*.tex"
min: 800
max: 1500
notes: "2026 新版通常并入“研究内容”撰写;如单独成文件,控制在 0.5–1.5 页,聚焦 1–2 条高含金量创新,避免绝对化措辞。"
- name: "年度研究计划(通常并入研究内容)"
match: "*研究计划*.tex"
min: 800
max: 1500
notes: "2026 新版通常并入“研究内容”撰写;如单独成文件,控制在 0.5–1.5 页,按整年写三年计划;里程碑与可交付成果对齐。"
# ============================================================================
# 检查器行为
# ============================================================================
checker:
include_globs:
# 默认只统计会进入 PDF 的 LaTeX 源文件;如你在同目录还放了 README/笔记等 Markdown,
# 避免误计入篇幅。若确实需要统计 Markdown,可自行加入 "*.md" / "*.markdown"。
- "*.tex"
exclude_globs:
- "**/.bensz-api/**"
- "**/tests/**"
- "**/plans/**"
- "**/_artifacts/**"
- "**/.nsfc-length-aligner/**"
- "**/.git/**"
latex:
# 当输入是一个标书目录且能识别出 main.tex 时:
# - 自动沿着 \input/\include 的依赖树收集“实际会被编译进 PDF 的文件”
# - 忽略被注释掉的 \input(避免把可选章节误计入篇幅)
# 值:auto/true/false
follow_inputs: "auto"
split_sections: true
section_commands:
- "section"
- "subsection"
- "subsubsection"
# 如你的模板用自定义命令(例如 \sec{...}),可把它加入列表(只写命令名,不含反斜杠)。
# 估算“可见文本”的启发式规则:会忽略常见 LaTeX 命令/数学环境/注释
strip_math: true
strip_commands: true
% demo only
\section{特色与创新}
创新点1:提出跨模态一致口径的表型定义框架。创新点2:建立“预测-解释-验证”闭环以提升可转化性。
% demo only
\section{研究内容与目标}
研究内容1:构建高质量队列与一致的表型口径;研究内容2:建立预测模型并做外部验证;研究内容3:开展机制验证与消融实验。
\subsection{关键科学问题}
问题1:如何定义稳定、可复现的表型?问题2:哪些特征对预测贡献最大且可解释?问题3:关键机制是否可被实验验证?
% demo only
\section{前期工作基础}
我们已完成初步数据收集与清洗流程,并在小样本上验证了特征工程与模型训练的可行性。
% demo only
\section{研究计划}
第1年:队列建设与口径统一;第2年:模型训练与外部验证;第3年:机制验证与成果固化。
% demo only
\section{研究背景与意义}
围绕某临床痛点,本项目拟解决关键科学问题A与B。已有研究提示机制X可能是核心环节,但证据链仍不完整。
\subsection{国内外现状与不足}
现有方法多依赖单中心小样本,外部验证不足;同时缺少针对关键亚型的分层策略,导致可转化性受限。
\subsection{本项目切入点}
我们提出基于数据驱动的分层建模与机制验证闭环:先建立可复现的表型定义,再构建可验证的预测模型,并通过对照实验验证关键假设。
nsfc-length-aligner
国自然标书“篇幅对齐”技能:基于内置(可配置)的篇幅预算,检查目标标书各部分篇幅,输出差距与针对性建议,并指导扩写/压缩到达标。
你会得到什么
- 一份可复现的篇幅报告(按文件/按章节统计)
- 差距总结(哪些部分偏短/偏长、偏差比例)
- 扩写/压缩的操作建议与校验闭环(改完再检查)
快速开始
依赖:
- Python 3
pyyaml(用于读取config.yaml):pip install pyyaml
1) 把你的标书(通常是 .tex / .md)准备在一个目录里 2) 运行检查:
python3 scripts/check_length.py --input /path/to/proposal --config config.yaml默认会在 /path/to/proposal/.bensz-api/skills/nsfc-length-aligner/ 生成报告(可用 --out-dir 自定义)。 如你希望避免覆盖旧报告,可加 --fail-if-exists。
如需显式指定隐藏工作区,推荐这样写:
python3 scripts/check_length.py --input /path/to/proposal --config config.yaml --out-dir .bensz-api/skills/nsfc-length-aligner说明:相对 --out-dir 会自动按 --input 对应的工作目录解析,而不是按你当前 shell 所在目录解析;这样即使你从别处启动命令,也不会把报告泄露到仓库根目录或其他位置。
针对 NSFC_Young / NSFC_General 模板(推荐用法)
如果你的标书是基于这两套模板(项目根目录包含 main.tex):
- 直接把
--input指向项目根目录 - 脚本会自动沿着
main.tex的\input/\include依赖树收集“实际会编译进 PDF 的文件” - 被注释掉的
\input{...}不会被统计(避免把可选章节误计入篇幅)
可选:如果你已经用最终模板编译出了 PDF(页数是 2026+ 的硬约束),可以把 PDF 也传入做页数统计:
python3 scripts/check_length.py --input /path/to/proposal --config config.yaml --pdf /path/to/proposal.pdf3) 根据报告提示扩写/压缩后再次运行检查,直到达标
配置篇幅标准
编辑 config.yaml 的 length_standard,把示例口径改成你当年模板的真实要求(尤其是 length_standard.pages 的页数硬约束)。
说明:默认只统计 .tex(见 config.yaml:checker.include_globs),避免把同目录的 README/笔记等 Markdown 误计入篇幅;如确实需要统计 Markdown,可自行把 *.md / *.markdown 加回 include globs。
另外,默认排除 config.yaml:output_settings.intermediate_dir(默认 .bensz-api/skills/nsfc-length-aligner),避免复检时把隐藏工作区中的历史报告再次计入扫描范围。
示例篇幅标准说明(重要)
config.yaml:length_standard 里的预算是“示例标准”,用于演示工作流与工具链:
- 不同年份、不同基金类型、不同模板对“页数/字数/章节结构”的要求可能不同
- 2026+ 改版后,“页数”变成显式硬约束(原则上不超过 30 页;实操建议 ≤28 页留缓冲)
- LaTeX/Word 的页数受字体、行距、图表、模板影响很大:字符预算适合作为可复检的代理指标,但不要通过缩小字体/行距来“挤页数”
建议你在正式使用前:
1. 把当年指南/模板要求转成“总预算 + 关键章节预算” 2. 同时明确“页数硬上限/建议上限”(可在 length_standard.pages 配置;并在最终 PDF 上复核页数) 3. 填入 config.yaml:length_standard(作为唯一真相) 4. 用同一套脚本做“改写→复检”的闭环
保持原意的扩写/压缩 Rubric
目标:在尽量不改变原意的前提下调整篇幅到预算范围。
不改变原意的底线
- 不新增“未经原文支持”的结论或主张
- 不改变因果方向、比较对象、时间范围、研究对象/人群
- 不改变关键术语定义(若补定义,必须与原意一致)
扩写(偏短)优先顺序
1. 定义与边界:关键概念、研究对象、适用范围、排除条件 2. 证据链补全:已有结果的指标、对照、统计口径、局限 3. 方法可复现性:关键参数、数据来源、质量控制、失败判据 4. 验证闭环:预期结果如何验证、备选方案与风险应对
压缩(偏长)优先顺序
1. 去重复:同一论点的重复论证(保留最强证据/最清晰表述) 2. 去泛背景:与本项目无关或过于教科书的段落 3. 段落结构化:长段改成 3-5 个要点句(保序、不改事实) 4. 术语统一:用同一术语替换近义反复
复检要点
- 复检篇幅达标(脚本)
- 复检逻辑不断裂:每个“因此/所以/从而”前都有可追溯前提
- 复检证据不丢失:指标/对照/结论仍完整
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import fnmatch
import json
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
class _ConfigError(RuntimeError):
pass
def _read_text(path: Path) -> str:
return path.read_text(encoding="utf-8", errors="replace")
def _ensure_dir(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
def _resolve_report_out_dir(input_path: Path, out_dir_arg: str, cfg: dict[str, Any]) -> Path:
output_settings = cfg.get("output_settings") or {}
if not isinstance(output_settings, dict):
output_settings = {}
default_dirname = str(output_settings.get("intermediate_dir") or ".bensz-api/skills/nsfc-length-aligner").strip()
if not default_dirname:
default_dirname = ".bensz-api/skills/nsfc-length-aligner"
base = input_path if input_path.is_dir() else input_path.parent
if not out_dir_arg:
root = (base / default_dirname).resolve()
stamp = dt.datetime.now().strftime("%Y-%m-%d-%H-%M")
candidate = root / stamp
if not candidate.exists():
return candidate
for idx in range(2, 100):
candidate = root / f"{stamp}-{idx:02d}"
if not candidate.exists():
return candidate
raise _ConfigError(f"cannot allocate unique output dir under {root}")
candidate = Path(out_dir_arg).expanduser()
if candidate.is_absolute():
return candidate.resolve()
return (base / candidate).resolve()
def _load_yaml(path: Path) -> dict[str, Any]:
text = _read_text(path)
try:
import yaml # type: ignore
except Exception as e: # pragma: no cover
raise _ConfigError(
"PyYAML is required to run this checker. Install it with: pip install pyyaml"
) from e
data = yaml.safe_load(text)
if not isinstance(data, dict):
raise _ConfigError(f"Invalid config.yaml: expected mapping at root: {path}")
return data
def _strip_tex_comments(text: str) -> str:
out_lines: list[str] = []
for line in text.splitlines():
buf: list[str] = []
escaped = False
for ch in line:
if escaped:
buf.append(ch)
escaped = False
continue
if ch == "\\":
buf.append(ch)
escaped = True
continue
if ch == "%":
break
buf.append(ch)
out_lines.append("".join(buf))
return "\n".join(out_lines)
def _mask_tex_comments(text: str) -> str:
"""Replace TeX comments with spaces, preserving string length.
Useful when we need stable character offsets (e.g. for section splitting).
"""
out_lines: list[str] = []
for line in text.splitlines(keepends=False):
buf: list[str] = []
escaped = False
in_comment = False
for ch in line:
if in_comment:
buf.append(" ")
continue
if escaped:
buf.append(ch)
escaped = False
continue
if ch == "\\":
buf.append(ch)
escaped = True
continue
if ch == "%":
buf.append(" ")
in_comment = True
continue
buf.append(ch)
out_lines.append("".join(buf))
return "\n".join(out_lines)
_RE_MATH_INLINE = re.compile(r"\$(?:\\.|[^$\\])*\$")
_RE_MATH_PAREN = re.compile(r"\\\((?:.|\n)*?\\\)")
_RE_MATH_BRACK = re.compile(r"\\\[(?:.|\n)*?\\\]")
_RE_COMMAND = re.compile(r"\\[A-Za-z@]+\*?")
_RE_ENV = re.compile(r"\\(begin|end)\s*\{[^}]+\}")
def _tex_visible_text(text: str, *, strip_math: bool, strip_commands: bool) -> str:
text = _strip_tex_comments(text)
text = _RE_ENV.sub(" ", text)
if strip_math:
text = _RE_MATH_BRACK.sub(" ", text)
text = _RE_MATH_PAREN.sub(" ", text)
text = _RE_MATH_INLINE.sub(" ", text)
if strip_commands:
text = _RE_COMMAND.sub(" ", text)
text = text.replace("~", " ")
text = text.replace("\\\\", " ")
text = re.sub(r"[{}\[\]]", " ", text)
text = re.sub(r"\s+", " ", text)
return text.strip()
def _md_visible_text(text: str) -> str:
# Extremely lightweight markdown text extraction.
text = re.sub(r"```(?:.|\n)*?```", " ", text)
text = re.sub(r"`[^`]*`", " ", text)
text = re.sub(r"^#+\s*", "", text, flags=re.M)
text = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", text)
text = re.sub(r"!\[[^\]]*\]\([^\)]+\)", " ", text)
text = re.sub(r"\s+", " ", text)
return text.strip()
def _count_unit(text: str, unit: str) -> int:
if not text:
return 0
if unit == "cjk_chars":
return len(re.findall(r"[\u4e00-\u9fff]", text))
if unit == "chars":
return len(re.sub(r"\s+", "", text))
raise _ConfigError(f"Unsupported unit: {unit!r} (supported: cjk_chars, chars)")
def _count_all_units(text: str) -> dict[str, int]:
return {
"cjk_chars": _count_unit(text, "cjk_chars"),
"chars": _count_unit(text, "chars"),
}
def _read_balanced_braces(text: str, brace_start: int) -> tuple[str, int] | None:
"""Return (inner_text, end_idx_after_closing_brace) for a {...} block.
This is a minimal TeX-ish brace reader to make section title parsing robust
against nested braces (e.g. \\texorpdfstring{...}{...}) and line breaks.
"""
if brace_start < 0 or brace_start >= len(text) or text[brace_start] != "{":
return None
depth = 1
i = brace_start + 1
buf: list[str] = []
while i < len(text):
ch = text[i]
if ch == "\\":
# Preserve escapes so downstream stripping stays consistent.
buf.append(ch)
i += 1
if i < len(text):
buf.append(text[i])
i += 1
continue
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return "".join(buf), i + 1
buf.append(ch)
i += 1
return None
def _split_tex_sections(
raw_tex: str,
*,
commands: list[str],
strip_math: bool,
strip_commands: bool,
) -> list[tuple[str, str]]:
safe = [c.strip() for c in commands if c and str(c).strip()]
if not safe:
safe = ["section", "subsection", "subsubsection"]
joined = "|".join(re.escape(c) for c in safe)
marker_re = re.compile(rf"\\({joined})\*?\s*\{{")
markers: list[tuple[int, int, str]] = []
# Mask comments (preserve offsets) to avoid treating commented-out headings as real.
hay = _mask_tex_comments(raw_tex)
for m in marker_re.finditer(hay):
brace_start = m.end() - 1 # points to the '{' matched by the pattern
parsed = _read_balanced_braces(hay, brace_start)
if not parsed:
continue
title_raw, title_end = parsed
title = _tex_visible_text(title_raw, strip_math=strip_math, strip_commands=strip_commands) or m.group(1)
markers.append((m.start(), title_end, title))
if not markers:
return []
sections: list[tuple[str, str]] = []
last_title = "(no section)"
last_start = 0
for start_pos, title_end, title in markers:
sections.append((last_title, raw_tex[last_start:start_pos]))
last_title = title
last_start = title_end
sections.append((last_title, raw_tex[last_start:]))
# If we have real sections, drop the leading "(no section)" chunk to reduce noise.
if len(sections) > 1 and sections and sections[0][0] == "(no section)":
sections = sections[1:]
return sections
@dataclass(frozen=True)
class _Budget:
name: str
match: str
target: int | None
min_value: int | None
max_value: int | None
notes: str | None
def bounds(self, *, tolerance_ratio: float) -> tuple[int | None, int | None]:
if self.min_value is not None or self.max_value is not None:
return self.min_value, self.max_value
if self.target is None:
return None, None
delta = max(1, int(round(self.target * tolerance_ratio)))
return self.target - delta, self.target + delta
def _iter_input_files(
root: Path,
*,
include_globs: list[str],
exclude_globs: list[str],
) -> Iterable[Path]:
def _matches(path: str, pattern: str) -> bool:
# Make common glob patterns behave intuitively for root-relative paths:
# - "**/foo/**" should also match "foo/..."
# - "./foo/**" should also match "foo/..."
if fnmatch.fnmatch(path, pattern):
return True
if pattern.startswith("**/") and fnmatch.fnmatch(path, pattern[3:]):
return True
if pattern.startswith("./") and fnmatch.fnmatch(path, pattern[2:]):
return True
return False
if root.is_file():
rel = root.name
if any(_matches(rel, g) for g in exclude_globs):
return []
if any(_matches(rel, g) for g in include_globs):
return [root]
return []
out: list[Path] = []
for p in sorted(root.rglob("*")):
if not p.is_file():
continue
rel = str(p.relative_to(root))
if any(_matches(rel, g) for g in exclude_globs):
continue
if any(_matches(p.name, g) for g in include_globs) or any(_matches(rel, g) for g in include_globs):
out.append(p)
return out
_RE_DOC_CLASS = re.compile(r"\\documentclass\b")
_RE_BEGIN_DOC = re.compile(r"\\begin\s*\{document\}")
_RE_INPUT = re.compile(r"\\(input|include|subfile)\s*\{([^}]+)\}")
def _looks_like_main_tex(text: str) -> bool:
# Heuristic: a main TeX file usually has both markers.
return bool(_RE_DOC_CLASS.search(text) and _RE_BEGIN_DOC.search(text))
def _detect_main_tex(root: Path, *, exclude_globs: list[str]) -> Path | None:
"""Best-effort detection of the proposal's main LaTeX file.
Preference order:
1) <root>/main.tex (common in both NSFC_Young and NSFC_General templates)
2) any .tex under root that contains both \\documentclass and \\begin{document}
"""
def _glob_match(path: str, pattern: str) -> bool:
if fnmatch.fnmatch(path, pattern):
return True
if pattern.startswith("**/") and fnmatch.fnmatch(path, pattern[3:]):
return True
if pattern.startswith("./") and fnmatch.fnmatch(path, pattern[2:]):
return True
return False
def _excluded(p: Path) -> bool:
rel = str(p.relative_to(root))
return any(_glob_match(rel, g) or _glob_match(p.name, g) for g in exclude_globs)
if root.is_file():
if root.suffix.lower() != ".tex":
return None
if _looks_like_main_tex(_read_text(root)):
return root
return None
main = root / "main.tex"
if main.exists() and main.is_file() and not _excluded(main):
if _looks_like_main_tex(_read_text(main)):
return main
# Fallback: scan for a file that looks like a main entry.
candidates = [p for p in sorted(root.rglob("*.tex")) if p.is_file() and not _excluded(p)]
for p in candidates:
try:
if _looks_like_main_tex(_read_text(p)):
return p
except Exception:
continue
return None
def _resolve_input_path(current_file: Path, arg: str) -> Path | None:
arg = arg.strip()
if not arg or "\\" in arg:
# Likely a macro, or an invalid path for our simple resolver.
return None
# TeX allows extensionless inputs.
candidates = [arg]
if not Path(arg).suffix:
candidates.append(arg + ".tex")
for cand in candidates:
p = (current_file.parent / cand).resolve()
if p.exists() and p.is_file():
return p
return None
def _collect_included_files(main_tex: Path) -> list[Path]:
"""Collect TeX/MD files reachable from a main TeX via \\input/\\include.
Notes:
- Commented-out \\input lines are ignored.
- This is a best-effort resolver; it intentionally skips macro-driven inputs.
"""
seen: set[Path] = set()
order: list[Path] = []
queue: list[Path] = [main_tex]
while queue:
cur = queue.pop(0)
if cur in seen:
continue
seen.add(cur)
order.append(cur)
try:
raw = _read_text(cur)
except Exception:
continue
# Strip comments first so optional blocks (commented inputs) won't be counted.
for _, arg in _RE_INPUT.findall(_strip_tex_comments(raw)):
resolved = _resolve_input_path(cur, arg)
if not resolved:
continue
if resolved.suffix.lower() in {".tex", ".md", ".markdown"}:
queue.append(resolved)
return order
def _load_budgets(
cfg: dict[str, Any],
) -> tuple[str, float, dict[str, Any], dict[str, Any], list[_Budget]]:
std = cfg.get("length_standard") or {}
if not isinstance(std, dict):
raise _ConfigError("config.yaml:length_standard must be a mapping")
unit = str(std.get("unit") or "cjk_chars")
tolerance_ratio = float(std.get("tolerance_ratio") or 0.08)
overall = std.get("overall") or {}
if not isinstance(overall, dict):
overall = {}
pages = std.get("pages") or {}
if not isinstance(pages, dict):
pages = {}
file_rules_raw = std.get("files") or []
if not isinstance(file_rules_raw, list):
raise _ConfigError("config.yaml:length_standard.files must be a list")
budgets: list[_Budget] = []
for item in file_rules_raw:
if not isinstance(item, dict):
continue
budgets.append(
_Budget(
name=str(item.get("name") or item.get("match") or "unknown"),
match=str(item.get("match") or ""),
target=int(item["target"]) if "target" in item and item["target"] is not None else None,
min_value=int(item["min"]) if "min" in item and item["min"] is not None else None,
max_value=int(item["max"]) if "max" in item and item["max"] is not None else None,
notes=str(item.get("notes")) if item.get("notes") else None,
)
)
return unit, tolerance_ratio, overall, pages, budgets
def _to_int(v: Any) -> int | None:
if v is None:
return None
try:
return int(v)
except Exception:
return None
def _count_pdf_pages(pdf_path: Path) -> int | None:
# Best-effort page counting:
# - Prefer pure-Python libraries if installed.
# - Fall back to `pdfinfo` if available.
try:
from pypdf import PdfReader # type: ignore
reader = PdfReader(str(pdf_path))
return len(reader.pages)
except Exception:
pass
try:
import PyPDF2 # type: ignore
reader = PyPDF2.PdfReader(str(pdf_path))
return len(reader.pages)
except Exception:
pass
try:
cp = subprocess.run(
["pdfinfo", str(pdf_path)],
check=False,
capture_output=True,
text=True,
)
if cp.returncode == 0 and cp.stdout:
for line in cp.stdout.splitlines():
if line.lower().startswith("pages:"):
parts = line.split(":", 1)
if len(parts) == 2:
return _to_int(parts[1].strip())
except Exception:
pass
return None
def _pick_budget(rel_path: str, budgets: list[_Budget]) -> _Budget | None:
for b in budgets:
if not b.match:
continue
if fnmatch.fnmatch(rel_path, b.match) or fnmatch.fnmatch(Path(rel_path).name, b.match):
return b
return None
def _render_table(rows: list[list[str]]) -> str:
if not rows:
return ""
widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
out: list[str] = []
for idx, row in enumerate(rows):
out.append("| " + " | ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)) + " |")
if idx == 0:
out.append("| " + " | ".join("-" * widths[i] for i in range(len(widths))) + " |")
return "\n".join(out)
def _load_template(skill_root: Path) -> str:
template = skill_root / "templates" / "LENGTH_REPORT_TEMPLATE.md"
if not template.exists():
return "# 篇幅对齐报告\n\n(未找到模板)\n"
return _read_text(template)
def _render_template(text: str, values: dict[str, str]) -> str:
rendered = text
for k, v in values.items():
rendered = rendered.replace(f"{{{{{k}}}}}", v)
return rendered
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description="NSFC proposal length checker (file/section budgets)")
parser.add_argument("--input", required=True, help="Path to proposal dir or a single file")
parser.add_argument("--config", default="", help="Path to config.yaml (default: skill config.yaml)")
parser.add_argument(
"--pdf",
default="",
help="Optional PDF to count pages for (page limit is a hard constraint in 2026+ templates)",
)
parser.add_argument(
"--out-dir",
default="",
help=(
"Output directory for reports "
"(default: <input>/.bensz-api/skills/nsfc-length-aligner/<yyyy-mm-dd-hh-mm>; relative paths are resolved from --input)"
),
)
parser.add_argument(
"--fail-if-exists",
action="store_true",
help="Fail if report files already exist (prevents accidental overwrite)",
)
args = parser.parse_args(argv)
input_path = Path(args.input).expanduser().resolve()
if not input_path.exists():
print(f"error: input not found: {input_path}", file=sys.stderr)
return 2
config_path = (
Path(args.config).expanduser().resolve()
if str(args.config or "").strip()
else (Path(__file__).resolve().parents[1] / "config.yaml")
)
if not config_path.exists():
print(f"error: config not found: {config_path}", file=sys.stderr)
return 2
cfg = _load_yaml(config_path)
unit, tolerance_ratio, overall, pages_cfg, budgets = _load_budgets(cfg)
checker = cfg.get("checker") or {}
if not isinstance(checker, dict):
checker = {}
include_globs = [str(x) for x in (checker.get("include_globs") or ["*.tex", "*.md"])]
exclude_globs = [str(x) for x in (checker.get("exclude_globs") or [])]
latex_cfg = checker.get("latex") or {}
if not isinstance(latex_cfg, dict):
latex_cfg = {}
follow_inputs_raw = latex_cfg.get("follow_inputs", "auto")
follow_inputs: str | bool
if isinstance(follow_inputs_raw, bool):
follow_inputs = follow_inputs_raw
else:
follow_inputs = str(follow_inputs_raw).strip().lower() if follow_inputs_raw is not None else "auto"
section_commands = latex_cfg.get("section_commands") or ["section", "subsection", "subsubsection"]
if not isinstance(section_commands, list):
section_commands = ["section", "subsection", "subsubsection"]
section_commands = [str(x) for x in section_commands]
discovery: dict[str, Any] = {"mode": "filesystem"}
base_dir = input_path if input_path.is_dir() else input_path.parent
files: list[Path] = []
main_tex: Path | None = None
if (follow_inputs is True) or (follow_inputs == "auto"):
main_tex = _detect_main_tex(input_path, exclude_globs=exclude_globs)
if main_tex is not None:
included = _collect_included_files(main_tex)
def _glob_match(path: str, pattern: str) -> bool:
if fnmatch.fnmatch(path, pattern):
return True
if pattern.startswith("**/") and fnmatch.fnmatch(path, pattern[3:]):
return True
if pattern.startswith("./") and fnmatch.fnmatch(path, pattern[2:]):
return True
return False
def _matches_any(path: str, patterns: list[str]) -> bool:
name = Path(path).name
return any(_glob_match(path, g) or _glob_match(name, g) for g in patterns)
filtered: list[Path] = []
for p in included:
try:
rel = str(p.relative_to(base_dir))
except Exception:
rel = p.name
if _matches_any(rel, exclude_globs):
continue
if _matches_any(rel, include_globs):
filtered.append(p)
files = filtered
discovery = {
"mode": "latex_inputs",
"main_tex": str(main_tex),
"included_count": len(included),
"counted_count": len(files),
}
if not files:
files = list(_iter_input_files(input_path, include_globs=include_globs, exclude_globs=exclude_globs))
discovery = {"mode": "filesystem", "counted_count": len(files)}
file_results: list[dict[str, Any]] = []
section_results: list[dict[str, Any]] = []
unmatched_budget_files: list[str] = []
total_value = 0
totals_all = {"cjk_chars": 0, "chars": 0}
for f in files:
rel = str(f.relative_to(base_dir))
raw = _read_text(f)
visible = ""
sections: list[tuple[str, str]] = []
if f.suffix.lower() == ".tex":
visible = _tex_visible_text(
raw,
strip_math=bool(latex_cfg.get("strip_math", True)),
strip_commands=bool(latex_cfg.get("strip_commands", True)),
)
if bool(latex_cfg.get("split_sections", True)):
sections = _split_tex_sections(
raw,
commands=section_commands,
strip_math=bool(latex_cfg.get("strip_math", True)),
strip_commands=bool(latex_cfg.get("strip_commands", True)),
)
elif f.suffix.lower() in {".md", ".markdown"}:
visible = _md_visible_text(raw)
else:
continue
all_counts = _count_all_units(visible)
value = all_counts[unit]
total_value += value
totals_all["cjk_chars"] += all_counts["cjk_chars"]
totals_all["chars"] += all_counts["chars"]
budget = _pick_budget(rel, budgets)
min_v, max_v = (None, None)
budget_name = None
budget_notes = None
if budget:
min_v, max_v = budget.bounds(tolerance_ratio=tolerance_ratio)
budget_name = budget.name
budget_notes = budget.notes
else:
unmatched_budget_files.append(rel)
file_results.append(
{
"path": rel,
"value": value,
"unit": unit,
"values": all_counts,
"budget": {
"name": budget_name,
"min": min_v,
"max": max_v,
"target": budget.target if budget else None,
"notes": budget_notes,
"match": budget.match if budget else None,
}
if budget
else None,
}
)
if sections:
for title, seg_raw in sections:
seg_visible = _tex_visible_text(
seg_raw,
strip_math=bool(latex_cfg.get("strip_math", True)),
strip_commands=bool(latex_cfg.get("strip_commands", True)),
)
if not seg_visible:
continue
section_results.append(
{
"file": rel,
"section": title,
"value": _count_unit(seg_visible, unit),
"unit": unit,
}
)
# Overall (text) budget can be expressed as:
# - min/max (explicit), or
# - target (+/- tolerance_ratio)
overall_target = _to_int(overall.get("target"))
overall_min_cfg = _to_int(overall.get("min"))
overall_max_cfg = _to_int(overall.get("max"))
overall_bounds = None
overall_delta = None
overall_budget_notes = overall.get("notes")
overall_min = overall_min_cfg
overall_max = overall_max_cfg
if overall_min is None and overall_max is None and overall_target is not None:
overall_min = overall_target - max(1, int(round(overall_target * tolerance_ratio)))
overall_max = overall_target + max(1, int(round(overall_target * tolerance_ratio)))
if overall_min is not None or overall_max is not None or overall_target is not None:
overall_bounds = {"min": overall_min, "max": overall_max, "target": overall_target, "notes": overall_budget_notes}
if overall_min is not None and total_value < overall_min:
overall_delta = f"-{overall_min - total_value}"
elif overall_max is not None and total_value > overall_max:
overall_delta = f"+{total_value - overall_max}"
else:
overall_delta = "OK"
# Optional page budget (PDF-based).
pdf_path = Path(args.pdf).expanduser().resolve() if args.pdf else None
page_count = None
pdf_error = None
if pdf_path:
if not pdf_path.exists():
pdf_error = f"pdf not found: {pdf_path}"
elif pdf_path.suffix.lower() != ".pdf":
pdf_error = f"not a pdf: {pdf_path}"
else:
page_count = _count_pdf_pages(pdf_path)
if page_count is None:
pdf_error = "failed to count pages (install `pypdf` or `PyPDF2`, or ensure `pdfinfo` is available)"
pages_max = _to_int(pages_cfg.get("max"))
pages_min = _to_int(pages_cfg.get("min"))
pages_target = _to_int(pages_cfg.get("target"))
pages_hard_max = _to_int(pages_cfg.get("hard_max"))
pages_notes = pages_cfg.get("notes")
# If only target is given, interpret it as "recommended max" by default.
if pages_max is None and pages_target is not None:
pages_max = pages_target
page_delta = None
if page_count is not None:
if pages_hard_max is not None and page_count > pages_hard_max:
page_delta = f"FAIL:+{page_count - pages_hard_max} (over hard_max)"
elif pages_max is not None and page_count > pages_max:
page_delta = f"WARN:+{page_count - pages_max} (over max)"
elif pages_min is not None and page_count < pages_min:
page_delta = f"-{pages_min - page_count}"
else:
page_delta = "OK"
report = {
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"unit": unit,
"tolerance_ratio": tolerance_ratio,
"input": str(input_path),
"discovery": discovery,
"pdf": {
"path": str(pdf_path) if pdf_path else None,
"page_count": page_count,
"error": pdf_error,
},
"file_count": len(file_results),
"total_value": total_value,
"total_values": dict(totals_all),
"overall_budget": {"min": overall_min_cfg, "max": overall_max_cfg, "target": overall_target, "notes": overall_budget_notes},
"overall_bounds": overall_bounds,
"overall_delta": overall_delta,
"page_budget": {"min": pages_min, "max": pages_max, "target": pages_target, "hard_max": pages_hard_max, "notes": pages_notes},
"page_delta": page_delta,
"unmatched_budget_files": unmatched_budget_files,
"files": file_results,
"sections": section_results,
}
out_dir = _resolve_report_out_dir(input_path, args.out_dir, cfg)
try:
_ensure_dir(out_dir)
except OSError as e:
print(f"error: cannot create out dir: {out_dir} ({e})", file=sys.stderr)
print(
"hint: ensure the proposal workdir is writable, or pass an explicit absolute --out-dir",
file=sys.stderr,
)
return 2
json_path = out_dir / "length_report.json"
md_path = out_dir / "length_report.md"
if args.fail_if_exists and (json_path.exists() or md_path.exists()):
print(f"error: report already exists (use a different --out-dir): {out_dir}", file=sys.stderr)
return 2
try:
json_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
except OSError as e:
print(f"error: cannot write json report: {json_path} ({e})", file=sys.stderr)
print(
"hint: ensure the proposal workdir is writable, or pass an explicit absolute --out-dir",
file=sys.stderr,
)
return 2
table_rows: list[list[str]] = [["文件", unit, "预算(min~max)", "偏差"]]
for r in file_results:
b = r.get("budget")
bounds = "-"
delta = "-"
if b and b.get("min") is not None and b.get("max") is not None:
bounds = f"{b['min']}~{b['max']}"
if r["value"] < b["min"]:
delta = f"-{b['min'] - r['value']}"
elif r["value"] > b["max"]:
delta = f"+{r['value'] - b['max']}"
else:
delta = "OK"
table_rows.append([str(r["path"]), str(r["value"]), bounds, delta])
file_table = _render_table(table_rows)
section_table = ""
if section_results:
s_rows: list[list[str]] = [["文件", "章节", unit]]
for s in section_results:
s_rows.append([s["file"], s["section"], str(s["value"])])
section_table = _render_table(s_rows)
template_text = _load_template(Path(__file__).resolve().parents[1])
overall_bounds_text = "-"
if overall_bounds and overall_bounds.get("min") is not None and overall_bounds.get("max") is not None:
overall_bounds_text = f"{overall_bounds['min']}~{overall_bounds['max']}"
unmatched_text = "(无)"
if unmatched_budget_files:
unmatched_text = ", ".join(unmatched_budget_files[:20])
if len(unmatched_budget_files) > 20:
unmatched_text += f" ... (+{len(unmatched_budget_files) - 20})"
md = _render_template(
template_text,
{
"generated_at": report["generated_at"],
"unit": unit,
"file_count": str(len(file_results)),
"discovery_mode": str((report.get("discovery") or {}).get("mode") or "-"),
"main_tex": str((report.get("discovery") or {}).get("main_tex") or "-"),
"total_value": str(total_value),
"total_cjk_chars": str(totals_all["cjk_chars"]),
"total_chars": str(totals_all["chars"]),
"overall_budget": str(overall_target) if overall_target is not None else "-",
"overall_bounds": overall_bounds_text,
"overall_delta": str(overall_delta) if overall_delta is not None else "-",
"pdf_path": str(pdf_path) if pdf_path else "-",
"page_count": str(page_count) if page_count is not None else "-",
"page_budget_max": str(pages_max) if pages_max is not None else "-",
"page_budget_hard_max": str(pages_hard_max) if pages_hard_max is not None else "-",
"page_delta": str(page_delta) if page_delta is not None else "-",
"page_notes": str(pages_notes) if pages_notes else "-",
"unmatched_files": unmatched_text,
"file_table": file_table or "(无)",
"section_table": section_table or "(未启用或无可解析章节)",
},
)
try:
md_path.write_text(md, encoding="utf-8")
except OSError as e:
print(f"error: cannot write md report: {md_path} ({e})", file=sys.stderr)
print(
"hint: ensure the proposal workdir is writable, or pass an explicit absolute --out-dir",
file=sys.stderr,
)
return 2
print(f"OK: total {unit}={total_value} files={len(file_results)}")
if page_count is not None:
print(f"- pdf pages: {page_count} (delta={page_delta or '-'})")
elif pdf_path and pdf_error:
print(f"- pdf pages: - ({pdf_error})")
print(f"- json: {json_path}")
print(f"- md: {md_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
篇幅对齐报告
生成时间:{{generated_at}}
单位:{{unit}}
总览
- 统计文件数:{{file_count}}
- 文件发现模式:{{discovery_mode}}
- 主入口(main.tex,若适用):{{main_tex}}
- 总篇幅({{unit}}):{{total_value}}
- 总篇幅(cjk_chars / chars):{{total_cjk_chars}} / {{total_chars}}
- PDF(可选):{{pdf_path}}
- PDF 页数(可选):{{page_count}}
- 页数预算(建议 max / 硬上限 hard_max):{{page_budget_max}} / {{page_budget_hard_max}}
- 页数偏差:{{page_delta}}
- 页数备注:{{page_notes}}
- 总预算(target):{{overall_budget}}
- 总预算(min~max):{{overall_bounds}}
- 总预算偏差:{{overall_delta}}
- 未匹配预算的文件:{{unmatched_files}}
- 提醒:页数是硬约束(尤其 2026+);字符预算是可复检的代理指标。本报告使用的
length_standard默认为“示例口径”,请按当年指南/模板校对后再使用;不要通过缩小字体/行距“挤页数”。
文件级差距
{{file_table}}
章节级(如启用)
{{section_table}}
建议(由 AI 生成)
1. 偏短部分:优先补“证据链/可验证指标/风险应对” 2. 偏长部分:优先删“泛背景/重复论证”,用要点化压缩段落