
Nsfc Budget
- 58 installs
- 2.6k repo stars
- Updated July 20, 2026
- huangwb8/chineseresearchlatex
Produces a submittable NSFC budget justification as a LaTeX project and renders budget.pdf from the proposal body or supplementary material.
About
Generates a complete NSFC budget justification LaTeX project and renders it to budget.pdf based on the applicant's proposal text. Developers use it when they need a formal budget statement, and it pauses to require a working directory if none is given.
- Outputs a LaTeX budget project and rendered budget.pdf
- Excludes cases needing only budget numbers or lump-sum young-scientist grants
Nsfc Budget by the numbers
- 58 all-time installs (skills.sh)
- +3 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #361 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-budgetAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 58 |
|---|---|
| repo stars | ★ 2.6k |
| Last updated | July 20, 2026 |
| Repository | huangwb8/chineseresearchlatex ↗ |
What it does
Produces a submittable NSFC budget justification as a LaTeX project and renders budget.pdf from the proposal body or supplementary material.
Files
NSFC 预算说明书生成器
与 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 仓库。
目标:基于标书正文与补充材料,写出一份“经得起财务与学术双重审视”的预算说明书,并交付可编辑 LaTeX 项目与 budget.pdf。
先做适用性判断
- 如果用户没有指定工作目录:立即暂停,先让用户给出工作目录。
- 如果用户只是问“预算怎么写/有哪些原则”,直接回答或给建议,不启动本 skill。
- 如果用户是 2026 青年 A/B/C 且场景属于包干制:先明确提醒“通常无需预算说明书”;只有在用户明确说明是历史模板、特定单位要求或预算制场景时才继续。
必要输入
优先让用户按 skills/nsfc-budget/references/info_form.md 提供。最少要拿到:
- 工作目录(必需)
- 标书正文或其它材料
- 项目类型:
general | local | youth - 预算口径:至少说明“这是申请总额”还是“这是需要解释的直接费用口径”
若用户没给全,按下面规则处理:
- 总预算未给:按
config.yaml:defaults.total_budget_wan取默认值。 - 正文目标字数未给:按
config.yaml:defaults.target_chars推荐区间执行。 - 每节上限:按
config.yaml:defaults.per_section_max_chars。 - 模板未给:按
config.yaml:defaults.template_id。 - 预算模式合法值:见
config.yaml:rules.budget_modes。 - 预算口径合法值:见
config.yaml:rules.budget_scopes。
中间产物边界
- 所有中间文件只能放在
<workdir>/.bensz-api/skills/nsfc-budget/。 - 不要把草稿、日志、计划、截图、临时 JSON、编译中间文件散落到工作目录其它位置。
- 最终可见交付物只放在
<workdir>/<output_dirname>/(默认值见config.yaml:defaults.output_dirname)。 template_id、output_dirname、.template.yaml里的section_files/latex_entry/pdf_name都必须是相对安全路径;不得包含绝对路径、./..越界段。output_dirname不得指向工作目录根路径,也不得与隐藏工作区<workdir>/.bensz-api/skills/nsfc-budget/重叠。
工作流
1. 初始化 run
先创建隐藏工作区与 budget_spec.json 骨架:
python3 skills/nsfc-budget/scripts/init_budget_run.py \
--workdir <workdir> \
--project-type <general|local|youth> \
--template-id 01如用户已给材料路径,可追加多个 --material <path>。脚本会把材料快照复制到 .bensz-api/skills/nsfc-budget/{yyyy-mm-dd-hh-mm}/input/materials/。 若同一分钟重复初始化,脚本会自动追加后缀避让目录名冲突,避免 run 目录互相污染。
2. 吃透材料,形成“任务-需求-金额-依据”链
读取正文与补充材料后,先在隐藏工作区内形成内部判断,再填写 budget_spec.json:
- 每一笔钱都必须能追溯到具体研究任务。
- 每一节都要说明为什么要花、花在哪里、怎么测算、为什么这个数合理。
- 不能捏造设备、合作单位、测试次数、出差频次、劳务人数、价格依据。
- 证据不足时,要么追问用户,要么保守写“暂不列支/暂无合作转拨/暂无其他来源资金”,不要编造。
写作原则见:skills/nsfc-budget/references/budget-writing-rules.md。
3. 填写 budget_spec.json
脚本生成的 budget_spec.json 是唯一结构化中间稿。至少补齐:
meta:项目题目、项目类型、预算模式、工作目录、输出目录、模板 ID、字数目标budget:总预算口径、直接费用总额(若已知)、设备/业务/劳务/合作转拨/其他来源金额sections:五个部分的正文段落(数组)evidence:关键测算依据、必要假设、待确认点
要求:
设备费 + 业务费 + 劳务费 = 直接费用总额(若你已明确直接费用口径)budget.*_wan与sections.*.amount_wan必须保持一致,避免出现两份金额源漂移。合作研究转拨资金不能与前三项形成逻辑冲突其他来源资金必须写明来源与用途;若无,则显式写“无”- 金额、字数上限、容差等数值不得为负数;不合法时优先修正 JSON,而不是带病渲染。
4. 渲染、校验、迭代
用脚本把 JSON 渲染为 LaTeX 项目,并把校验报告与编译日志留在隐藏目录:
python3 skills/nsfc-budget/scripts/render_budget_project.py \
--spec <workdir>/.bensz-api/skills/nsfc-budget/{yyyy-mm-dd-hh-mm}/budget_spec.json脚本会:
- 复制模板到
<workdir>/<output_dirname>/ - 将五个 section 写入对应
extraTex/*.tex - 校验金额关系、段落长度、可见字符数与模板/路径约束
- 校验
budget_spec.json是否仍位于<workdir>/.bensz-api/skills/nsfc-budget/,保证隐藏工作区承诺不被破坏 - 自动转义常见 LaTeX 特殊字符(如
%、#、&、_),减少自然语言正文导致的编译失败 - 在隐藏目录保存
validation_report.md/json - 若校验失败,终端会直接给出首批错误摘要与
validation_report.md路径 - 编译输出
budget.pdf
如校验失败,先修 budget_spec.json 再重新运行脚本,直到通过。
5. 交付前人工复核
交付前必须至少复核这些点:
- 预算口径是否说清楚:申请总额 vs 直接费用
- 设备/测试/差旅/劳务是否真的与研究任务一一对应
- 是否出现“写得很满但没有证据”的句子
- 是否存在“金额能对上,但逻辑对不上”的隐性漏洞
- 是否存在“应该写无,却被硬凑了一段”的编造痕迹
写作策略
默认采用以下结构化策略:
- 总述从严:先交代预算遵循政策相符性、目标相关性、经济合理性。
- 逐项落地:每节至少讲清“用途 + 测算 + 必要性 + 依据”。
- 少说空话:不要写“为保证项目顺利开展”“具有重要意义”这类无信息量句子,除非后面紧跟具体任务与支出。
- 金额服务任务:说明书不是“财务散文”,每一段都要能回到研究方案。
- 宁缺毋滥:缺材料时,先保守、先追问、先明确边界;不要补脑。
输出
最终输出必须同时包含:
<workdir>/<output_dirname>/:完整 LaTeX 项目<workdir>/<output_dirname>/budget.pdf
中间过程保留在:
<workdir>/.bensz-api/skills/nsfc-budget/{yyyy-mm-dd-hh-mm}/
关键文件
skills/nsfc-budget/references/info_form.mdskills/nsfc-budget/references/budget-writing-rules.mdskills/nsfc-budget/scripts/init_budget_run.pyskills/nsfc-budget/scripts/render_budget_project.pyskills/nsfc-budget/models/01/.template.yaml
Changelog
本文件记录 nsfc-budget skill 的变更历史。
格式基于 Keep a Changelog。
[0.1.2] - 2026-03-07
Fixed
init_budget_run.py/render_budget_project.py:拒绝将output_dirname设为工作目录根路径或与.nsfc-budget/隐藏工作区重叠,修复--force可能误删工作目录/活动 run 的高风险边界init_budget_run.py:重复初始化同秒 run 时自动避让目录名冲突,修复隐藏工作区会话互相覆盖的问题render_budget_project.py:允许写入已存在但为空的输出目录,避免“空目录也报 File exists”导致的伪失败render_budget_project.py:校验失败时在终端直接输出首批错误摘要和validation_report.md路径,降低排障成本render_budget_project.py:新增meta.project_type/ 金额 / 字数 / 容差等非负与枚举校验,拒绝非法 spec 漏网通过render_budget_project.py:写入段落时自动转义%、#、&、_、{、}等常见 LaTeX 特殊字符,修复自然语言正文导致的编译失败
Changed
config.yaml:将项目类型、预算模式、预算口径集中到配置中,减少SKILL.md/ README / 脚本的枚举分叉SKILL.md/README.md/scripts/README.md:同步安全约束、目录覆盖规则、特殊字符转义行为与配置引用口径
[0.1.0] - 2026-03-07
Added
- 初始版本:新增
nsfc-budgetskill,用于基于 NSFC 标书正文/补充材料生成预算说明书 LaTeX 项目并渲染budget.pdf - 新增模板元数据:
skills/nsfc-budget/models/01/.template.yaml,为后续多模板扩展预留接口 - 新增初始化脚本:
skills/nsfc-budget/scripts/init_budget_run.py,统一创建.nsfc-budget/run_xxx/隐藏工作区与budget_spec.json - 新增渲染脚本:
skills/nsfc-budget/scripts/render_budget_project.py,负责校验预算结构、写入extraTex/*.tex、生成校验报告并编译 PDF - 新增参考文档:
skills/nsfc-budget/references/info_form.md与skills/nsfc-budget/references/budget-writing-rules.md - 新增回归测试:
skills/nsfc-budget/tests/test_render_budget_project.py
[0.1.1] - 2026-03-07
Fixed
render_budget_project.py:补齐output_dirname、template_id、--spec与模板元数据路径的安全校验,防止越界写入或读取render_budget_project.py:改为从config.yaml/.template.yaml读取section_files、zero_text、compile_runs、latex_entry、pdf_name,消除关键配置硬编码render_budget_project.py:强制校验budget.*_wan与sections.*.amount_wan一致,并在--skip-compile时明确返回pdf: nullrender_budget_project.py:xelatex缺失时给出明确错误信息,便于排障init_budget_run.py:改为从config.yaml读取默认值与中间目录名,并拒绝不安全的output_dirname/template_id
Added
- 新增运行时公共工具:
skills/nsfc-budget/scripts/runtime_utils.py - 新增路径安全与一致性回归测试:覆盖
output_dirname越界、spec越界、金额不一致、template_id越界、--skip-compilemanifest 等场景
skill_info:
name: nsfc-budget
version: 0.1.2
description: "基于 NSFC 标书正文或补充材料生成预算说明书 LaTeX 项目并渲染 budget.pdf(不适用:仅想了解预算原则、或 2026 青年 A/B/C 默认包干制且无需预算说明书的场景)"
author: "Bensz Conan"
category: writing
defaults:
template_id: "01"
output_dirname: "budget_output"
intermediate_dirname: ".bensz-api/skills/nsfc-budget"
project_type: general
total_budget_wan:
general: 50
local: 50
youth: 30
target_chars:
recommended_min: 800
recommended_max: 1000
recommended_default: 900
per_section_max_chars: 500
indirect_rate_hint:
general: 0.30
local: 0.30
youth: 0.30
requested_total_tolerance_wan: 1.0
compile_runs: 2
rules:
require_workdir: true
project_types:
- general
- local
- youth
budget_modes:
- budget_based
- package_based
- historical_budget_based
budget_scopes:
- direct
- total
- to_be_confirmed
budget_mode_default: budget_based
package_based_2026_project_types:
- youth_a
- youth_b
- youth_c
allowed_latex_commands:
- linebreak
- BudgetBold
zero_text:
equipment: "本项目不列支设备费。"
business: "本项目不列支业务费。"
labor: "本项目不列支劳务费。"
transfer: "本项目无合作研究转拨资金。"
other_source: "本项目无其他来源资金。"
validation:
equipment_ratio_warning: 0.50
require_section_text_when_amount_positive: true
output:
latex_entry: budget.tex
pdf_name: budget.pdf
section_files:
equipment: extraTex/1.1.设备费.tex
business: extraTex/1.2.业务费.tex
labor: extraTex/1.3.劳务费.tex
transfer: extraTex/2.1.合作研究转拨资金.tex
other_source: extraTex/3.1.其他来源资金.tex
template_id: "01"
name: "NSFC 预算说明书通用模板"
description: "适配设备费/业务费/劳务费/合作研究转拨资金/其他来源资金五段结构的预算说明书 LaTeX 模板"
default: true
latex_entry: "budget.tex"
pdf_name: "budget.pdf"
project_types: ["general", "local", "youth"]
section_files: {"equipment": "extraTex/1.1.设备费.tex", "business": "extraTex/1.2.业务费.tex", "labor": "extraTex/1.3.劳务费.tex", "transfer": "extraTex/2.1.合作研究转拨资金.tex", "other_source": "extraTex/3.1.其他来源资金.tex"}
%!TEX program = xelatex
\documentclass[12pt,UTF8,a4paper,fontset=none]{ctexart}
\input{extraTex/@config.tex}
\begin{document}
\pagestyle{empty}
\begin{center}
{\BudgetTitleFont 预算说明书}
\end{center}
\vspace{-1pt}
\begin{BudgetBox}
\BudgetSectionLine{1.科学基金资助项目直接费用}
\BudgetBoldParagraph{请按照《国家自然科学基金项目申请书预算编制说明》等有关要求,按照政策相符性、目标相关性和经济合\linebreak{}理性原则,实事求是编制项目预算。填报时,每个科目应结合科研任务按支出用途进行基本测算说明。}
\BudgetParagraph{1.1 设备费(是指在项目实施过程中购置或试制专用仪器设备,对现有仪器设备进行升级改造,以及租赁外单\linebreak{}位仪器设备而发生的费用。计算类仪器设备和软件工具可在设备费科目列支。填报时,应按照设备购置费、试制改\linebreak{}造费和租赁使用费的分类,提供设备支出的必要性及基本测算说明。单价大于50万元(含50万元)的设备需补充说\linebreak{}明设备的主要性能指标、主要技术参数等内容。)}
\input{extraTex/1.1.设备费.tex}
\BudgetParagraph{1.2 业务费(是指项目实施过程中消耗的各种材料、辅助材料等低值易耗品的采购、运输、装卸、整理等费用,\linebreak{}发生的测试化验加工、燃料动力、出版/文献/信息传播/知识产权事务、会议/差旅/国际合作交流等费用,以及其他\linebreak{}相关支出。填报时,应按照支出大类进行基本测算说明。)}
\input{extraTex/1.2.业务费.tex}
\BudgetParagraph{1.3 劳务费(是指在项目实施过程中支付给参与项目研究的研究生、博士后、访问学者以及项目聘用的研究人\linebreak{}员、科研辅助人员等的劳务性费用,以及支付给临时聘请的咨询专家的费用等。填报时,应综合考量劳务费支出对\linebreak{}象所承担研究任务的必要性、投入本项目的工作时长、费用标准的合理性等因素,按照人员类别进行基本测算说明。\linebreak{}专家咨询费应按照国家有关规定执行。)}
\input{extraTex/1.3.劳务费.tex}
\BudgetSectionLine{2.直接费用中合作研究转拨资金}
\BudgetParagraph{需对合作研究单位承担的研究任务做必要说明。直接费用转拨资金需经项目申请人与参与者协商一致,并按\hspace{-0.35em}设\linebreak{}备费、业务费、劳务费三个科目做预算说明。如存在多个合作研究单位,请分单位逐一说明。}
\input{extraTex/2.1.合作研究转拨资金.tex}
\BudgetSectionLine{3.其他来源资金}
\BudgetParagraph{对其他来源资金的资金来源、资金具体开支用途做简要说明。}
\input{extraTex/3.1.其他来源资金.tex}
\end{BudgetBox}
\end{document}
\usepackage[english]{babel}
\usepackage{xcolor}
\usepackage{geometry}
\usepackage{fontspec}
\usepackage{xeCJK}
\usepackage{titlesec}
\usepackage{calc}
\usepackage{mdframed}
\usepackage{ragged2e}
\geometry{left=2.51cm,right=2.34cm,top=2.85cm,bottom=1.75cm}
\definecolor{MsBlue}{RGB}{0,112,192}
% =========================
% 可调参数(建议优先改这里)
% =========================
% 中文假粗体强度。
% 用途:控制标题、提示语等中文加粗文字的“粗细程度”。
% 调参参考:1.2=很轻,1.5=偏轻,1.8=当前默认,2.0=偏明显,2.5=较重。
\newcommand{\BudgetCJKFakeBold}{0.1}
\IfFileExists{./fonts/TimesNewRoman.ttf}{
\setmainfont{TimesNewRoman}[Path=./fonts/,Extension=.ttf]
}{
\IfFontExistsTF{Times New Roman}{
\setmainfont{Times New Roman}
}{
\setmainfont{TeX Gyre Termes}
}
}
\IfFileExists{./fonts/FandolFang-Regular.otf}{
\setCJKmainfont{FandolFang-Regular}[Path=./fonts/,Extension=.otf]
}{
\setCJKmainfont{STSong}
}
% 页面标题“预算说明书”专用中文字体族。
% 按官方样式要求,封面标题使用黑体。
\IfFileExists{./fonts/FandolHei-Regular.otf}{
\newCJKfontfamily\budgettitlefont{FandolHei-Regular}[Path=./fonts/,Extension=.otf,AutoFakeBold=\BudgetCJKFakeBold]
}{
\newCJKfontfamily\budgettitlefont{Heiti SC}[AutoFakeBold=\BudgetCJKFakeBold]
}
% 正文中的中文加粗字体族。
% 当前策略:正文里的强调文字不切换到黑体,继续使用与正文相同的中文字体,只额外施加假粗体。
% 这样可以避免“加粗后字体风格突变”,更贴近正文原始字形。
\IfFileExists{./fonts/FandolFang-Regular.otf}{
\newCJKfontfamily\budgetboldfont{FandolFang-Regular}[Path=./fonts/,Extension=.otf,AutoFakeBold=\BudgetCJKFakeBold]
}{
\newCJKfontfamily\budgetboldfont{STSong}[AutoFakeBold=\BudgetCJKFakeBold]
}
\xeCJKsetup{CJKecglue={}}
\setlength{\parindent}{0pt}
\setlength{\parskip}{0pt}
\renewcommand{\baselinestretch}{1.35}
\setcounter{secnumdepth}{0}
\titleformat{\section}[block]
{\normalfont\budgetboldfont\bfseries\fontsize{9.03bp}{12.17bp}\selectfont}
{}
{0pt}
{}
\titlespacing*{\section}{0pt}{0.45\baselineskip}{0.10\baselineskip}
\titleformat{\subsection}[block]
{\normalfont\fontsize{9.03bp}{12.17bp}\selectfont}
{}
{0pt}
{}
\titlespacing*{\subsection}{0pt}{0.28\baselineskip}{0pt}
% 正文基础字号。
% 说明:预算说明书正文及多数段落宏都会复用这一字号设置。
\newcommand{\BudgetBodyFont}{\fontsize{9.03bp}{12.17bp}\selectfont}
% 页面标题“预算说明书”的字号与字体。
% 按官方样式使用“黑体四号加粗”;这里用 14pt 对应四号。
\newcommand{\BudgetTitleFont}{\budgettitlefont\bfseries\fontsize{14pt}{16pt}\selectfont}
% 行内手动加粗工具。
\newcommand{\BudgetBold}[1]{{\budgetboldfont\bfseries #1}}
% 占位空白:用于保持模板空白区域高度。
\newcommand{\BudgetPlaceholder}[1]{\vspace*{#1}}
% 正文整体左移补偿。
% 用途:微调整个段落块的视觉起点;一般不建议频繁改。
% 调参参考:数值更负=整体更靠左;更接近 0=整体更靠右。
\newcommand{\BudgetLeftShift}{-7pt}
% 段落首行缩进。
% 当前语义:严格按“2 个中文字符宽度”计算,缩进发生在序号前面。
% 若想改成 1.5 个或 3 个中文字符,可写成 1.5\ccwd 或 3\ccwd。
\newcommand{\BudgetIndent}{2\ccwd}
% 外框内部顶部到首个一级标题之间的微调量。
% 用途:单独控制 \begin{BudgetBox} 与首个 \BudgetSectionLine 之间的间隙。
% 调参参考:更负=间隙更小、内容更靠上;更接近 0=间隙更大。
\newcommand{\BudgetBoxTopGapAdjust}{-2pt}
% 一级标题行(如“1.科学基金资助项目直接费用”)。
\newcommand{\BudgetSectionLine}[1]{%
{\budgetboldfont\bfseries\BudgetBodyFont\noindent\hspace*{\BudgetIndent}#1\par}%
}
% 普通正文段落。
% 特点:首行缩进,续行顶格,两端对齐。
\newcommand{\BudgetParagraph}[1]{%
{\BudgetBodyFont\leftskip=\BudgetLeftShift\relax\justifying\parindent=\BudgetIndent #1\par}%
}
% 加粗正文段落。
% 用于顶部“请按照……”这类强调说明。
\newcommand{\BudgetBoldParagraph}[1]{%
{\budgetboldfont\bfseries\BudgetBodyFont\leftskip=\BudgetLeftShift\relax\justifying\parindent=\BudgetIndent #1\par}%
}
% 外框环境:控制边框、内边距、固定高度等。
% 若想调“框本身”的粗细或上下内边距,可优先查看这里的 mdframed 参数。
\newenvironment{BudgetBox}{%
\begin{mdframed}[
skipabove=0pt,
skipbelow=0pt,
leftmargin=2pt,
rightmargin=2pt,
innertopmargin=7pt,
innerbottommargin=7pt,
innerleftmargin=3pt,
innerrightmargin=3pt,
linewidth=0.8pt,
linecolor=black
]
\begin{minipage}[t][22.35cm][t]{\linewidth}
\vspace*{\BudgetBoxTopGapAdjust}
\BudgetBodyFont
\justifying
\setlength{\parindent}{\BudgetIndent}%
\setlength{\parskip}{0pt}%
}{%
\end{minipage}
\end{mdframed}
}
% 在此填写“设备费”预算说明;默认保留空白以贴近官方模板版式。
\BudgetPlaceholder{2.9\baselineskip}
% 在此填写“业务费”预算说明;默认保留空白以贴近官方模板版式。
\BudgetPlaceholder{2.9\baselineskip}
% 在此填写“劳务费”预算说明;默认保留空白以贴近官方模板版式。
\BudgetPlaceholder{3.6\baselineskip}
% 在此填写“合作研究转拨资金”预算说明;默认保留空白以贴近官方模板版式。
\BudgetPlaceholder{5.2\baselineskip}
% 在此填写“其他来源资金”预算说明;默认保留空白以贴近官方模板版式。
\BudgetPlaceholder{5.0\baselineskip}
Budget Justification
基于 projects/NSFC_Young 的项目骨架整理的“国家自然科学基金项目预算说明书”LaTeX 模板。
基准来源
- 官方基准 PDF:
template/国家自然科学基金项目项目预算说明书(除重大项目、国家重大科研仪器研制项目以外).pdf - 对齐副本:
template/baseline.pdf
目录说明
budget.tex:主模板文件.template.yaml:模板元数据(供nsfc-budget脚本选择模板与 section 文件映射)extraTex/@config.tex:页面、字体、标题等样式配置extraTex/*.tex:各预算科目的可编辑内容区
编译
cd skills/nsfc-budget/models/01
xelatex -interaction=nonstopmode budget.tex当前 template/baseline.pdf 仅作为 make-latex-model 的样式对齐基准;最终交付仍为可编辑的 LaTeX 模板与其编译得到的 budget.pdf。
约束:.template.yaml 中的 section_files、latex_entry、pdf_name 必须保持为模板目录内的相对安全路径。
使用方式
- 直接编辑各
extraTex/*.tex文件,替换默认空白占位区域 - 若仅需保留空模板样式,可不修改占位文件内容
nsfc-budget — 用户使用指南
本 README 面向使用者:如何触发并正确使用 nsfc-budget skill。
执行规范与硬约束以 skills/nsfc-budget/SKILL.md 为准;默认参数见 skills/nsfc-budget/config.yaml。
这是什么
nsfc-budget 用于根据你的 NSFC 标书正文或补充材料,生成一份预算说明书 LaTeX 项目,并渲染出 budget.pdf。
它特别适合这些场景:
- 你已经写完或基本写完正文,现在要补预算说明书
- 你有零散材料,希望先沉淀成规范的预算说明书项目
- 你希望所有中间文件都隐藏在工作目录下的
.bensz-api/skills/nsfc-budget/,不污染根目录
先注意
- 必须提供工作目录。如果你没给,skill 会先停下来问你。
- 2026 青年 A/B/C 通常是包干制,往往不需要预算说明书;如果你是这类场景,请先确认单位是否仍要求你写预算说明。
- 所有中间文件默认进入
<workdir>/.bensz-api/skills/nsfc-budget/。 - 最终交付默认输出到
<workdir>/<output_dirname>/,当前默认值以skills/nsfc-budget/config.yaml为准。 template_id、output_dirname与模板元数据中的输出路径都只接受相对安全路径,不能写绝对路径、.或..。- 输出目录不能写成工作目录根路径,也不能与
.bensz-api/skills/nsfc-budget/重叠。
你需要准备什么
最少准备:
- 工作目录
- 标书正文或其它材料
- 项目类型:面上 / 地区 / 青年
建议再补充:
- 预算口径:这是“申请总额”还是“需要解释的直接费用”
- 总预算
- 目标字数(若不提供,默认推荐区间见
skills/nsfc-budget/config.yaml) - 合作单位、其他来源资金、关键价格依据
可直接按 skills/nsfc-budget/references/info_form.md 填。
另外建议同时明确:
- 预算模式:合法值见
skills/nsfc-budget/config.yaml - 预算口径:合法值见
skills/nsfc-budget/config.yaml
快速开始
最短 Prompt
请使用 nsfc-budget skill,工作目录是 ./projects/my-proposal。
材料包括:正文 main.tex 和我补充的实验计划。
项目类型:面上。
总预算:50w。更稳妥的 Prompt
请使用 nsfc-budget 为我生成预算说明书。
工作目录:./projects/NSFC_General
项目类型:general
预算模式:budget_based
总预算口径:申请总额
总预算:50w
正文目标字数:900 左右
材料:
- ./projects/NSFC_General/main.tex
- ./notes/budget_notes.md
要求:
- 中间文件全部放到 ./.bensz-api/skills/nsfc-budget
- 最终输出 LaTeX 项目 + budget.pdf
- 不要捏造需求,不确定的地方先问我默认行为
如果你没有给全参数,skill 会按 skills/nsfc-budget/config.yaml 中的默认值启动;重点包括:
- 模板 ID
- 面上 / 地区 / 青年默认预算额
- 推荐总字数区间与默认中心值
- 每部分上限
- 输出目录名与隐藏中间目录名
工作流会做什么
1) 初始化隐藏工作区
脚本:skills/nsfc-budget/scripts/init_budget_run.py
会创建:
<workdir>/.bensz-api/skills/nsfc-budget/{yyyy-mm-dd-hh-mm}/<workdir>/.bensz-api/skills/nsfc-budget/{yyyy-mm-dd-hh-mm}/budget_spec.json<workdir>/.bensz-api/skills/nsfc-budget/{yyyy-mm-dd-hh-mm}/input/materials/
2) 形成结构化预算稿
skill 会把预算说明书拆成 5 段:
- 设备费
- 业务费
- 劳务费
- 合作研究转拨资金
- 其他来源资金
每段都要求讲清:
- 这笔钱为什么需要
- 对应哪个研究任务
- 怎么测算出来
- 为什么这个数合理
3) 渲染并校验
脚本:skills/nsfc-budget/scripts/render_budget_project.py
会输出:
<workdir>/<output_dirname>/:最终 LaTeX 项目<workdir>/<output_dirname>/budget.pdf<workdir>/.bensz-api/skills/nsfc-budget/{yyyy-mm-dd-hh-mm}/validation_report.md<workdir>/.bensz-api/skills/nsfc-budget/{yyyy-mm-dd-hh-mm}/validation_report.json
并额外强制检查:
budget_spec.json仍位于<workdir>/.bensz-api/skills/nsfc-budget/budget.*_wan与sections.*.amount_wan一致- 输出目录和模板路径不存在越界写入风险
- 输出目录不会落到工作目录根路径,也不会与隐藏工作区重叠
- 常见特殊字符(如
%、#、&、_)会在写入 LaTeX 前自动转义
目录结构
典型输出如下:
<workdir>/
├── .bensz-api/skills/nsfc-budget/
│ └── 2026-03-07-12-00/
│ ├── budget_spec.json
│ ├── input/materials/
│ ├── output/
│ ├── log/
│ ├── validation_report.md
│ └── validation_report.json
└── <output_dirname>/
├── budget.tex
├── budget.pdf
├── extraTex/
├── fonts/
└── template/命令行示例
初始化:
python3 skills/nsfc-budget/scripts/init_budget_run.py \
--workdir ./projects/NSFC_General \
--project-type general \
--material ./projects/NSFC_General/main.tex渲染:
python3 skills/nsfc-budget/scripts/render_budget_project.py \
--spec ./projects/NSFC_General/.bensz-api/skills/nsfc-budget/2026-03-07-12-00/budget_spec.json常见问题
为什么一定要工作目录?
因为这个 skill 会把所有中间过程隔离在 <workdir>/.bensz-api/skills/nsfc-budget/ 下;没有工作目录,就无法保证不污染你的其它目录。
为什么还要我确认“预算口径”?
因为“申请总额”和“需要解释的直接费用”不是一回事。预算说明书主要解释直接费用,若口径不清,很容易导致金额能对上、逻辑却对不上。
为什么要同时填 budget.*_wan 和 sections.*.amount_wan?
前者便于结构化校验,后者直接驱动正文段落;现在脚本会强制两者一致,避免“数字改了一处、另一处忘了改”。
如果没有合作研究或其他来源资金怎么办?
直接写“无”,不要硬凑。这个 skill 默认会为零金额场景写出规范的“无”型表述。
能不能只生成 LaTeX,不编译 PDF?
可以,脚本支持 --skip-compile。但正式交付前建议至少编译一次,确保 budget.pdf 可用。
如果输出目录已存在怎么办?
- 若目录非空,渲染时加
--force才会覆盖。 - 若目录为空,脚本现在会直接复用,不再因为“目录已存在”而失败。
正文里有 %、#、&、_ 这类符号怎么办?
可以直接写。脚本会在渲染阶段自动转义这些常见 LaTeX 特殊字符;若你主动写了允许的 \linebreak{} 或 \BudgetBold{},也会保留。
相关文件
skills/nsfc-budget/SKILL.mdskills/nsfc-budget/config.yamlskills/nsfc-budget/references/info_form.mdskills/nsfc-budget/references/budget-writing-rules.mdskills/nsfc-budget/scripts/README.md
NSFC 预算说明书写作规则
本文件将调研报告中的高价值策略沉淀为可复用规则,不依赖任何临时目录。
适用判断
- 预算说明书服务于预算制项目。
- 若是 2026 青年 A/B/C 包干制,通常不需要预算说明书;只有用户明确说明仍需预算制说明时才继续。
核心原则
政策相符性
- 支出科目必须放对位置。
- 预算说明书只写允许列支、且与项目相关的内容。
- 不把本应列入别的科目的支出混进当前科目。
目标相关性
- 每一笔钱都必须指向具体研究任务。
- 不能出现“正文里没有这个任务,预算里却有这笔钱”的错位。
- 不能出现“正文强调这是核心工作,预算却完全没有体现”的漏项。
经济合理性
- 金额要来自测算,而不是拍脑袋。
- 设备、测试、劳务、差旅等都要有价格依据或标准依据。
- 预算既不能明显偏低,也不能为了多拿钱而故意抬高。
五段写法
设备费
至少讲清:
- 买/试制/改造/租什么
- 用在哪个研究环节
- 为什么必须这样安排
- 价格依据是什么
常见漏洞:
- 单位明明已有类似设备,却没解释为什么还要买
- 只写“购置实验设备”,没有型号、用途、必要性
- 金额很大,但看不出与核心任务的关系
业务费
建议按大类组织:
- 材料与试剂
- 测试化验加工
- 燃料动力
- 差旅/会议/国际合作交流
- 出版/文献/信息传播/知识产权事务
每类至少要交代:用途、频次/数量、测算方法、价格依据。
劳务费
至少讲清:
- 给谁
- 多少人、多少月
- 按什么标准
- 具体承担什么工作
不要把不符合规定的人员硬写进劳务费。
合作研究转拨资金
至少讲清:
- 合作单位是谁
- 它承担什么任务
- 为什么必须转拨,而不是由本单位完成
- 转拨资金内部如何分到设备/业务/劳务
其他来源资金
至少讲清:
- 资金来自哪里
- 金额是多少
- 用在什么地方
- 与科学基金经费如何分工
若无,就明确写“无”。
反编造规则
- 不编设备名称、型号、性能指标。
- 不编测试单位、测试次数、差旅频次。
- 不编合作单位、配套经费来源、劳务人数。
- 不编“价格依据已充分调研”这类无证据句子。
- 信息不足时,先追问;若仍不足,则保守写法并记录假设边界。
风格要求
- 语言要像申请人自己算过账,而不是像模板拼装。
- 少用空泛套话,多用“任务—支出—测算—依据”闭环句。
- 默认总字数建议
800–1000,每部分默认不超过500。 - 可以简洁,但不能省略必要依据。
交付前检查
- 金额是否自洽
- 任务是否对位
- 依据是否充分
- 零金额部分是否老老实实写“无”
- 是否存在任何一处“评审一看就会追问但文本没有回答”的漏洞
官方信息要点
- 2026 年项目中,面上与地区项目通常仍属于预算制,青年 A/B/C 为包干制的情况需要先核实。
- 预算说明书的规范要求以当年指南、预算编制说明和依托单位要求为准;本 skill 的配置只做默认值与校验辅助,不替代当年官方文件。
nsfc-budget 信息表
请尽量一次性补齐以下信息;缺失越少,预算说明书越稳。
必填
- 工作目录:
- 项目类型:
general / local / youth - 预算模式:
budget_based / package_based / historical_budget_based - 预算口径:
direct / total / to_be_confirmed - 材料路径或正文内容:
推荐填写
- 项目题目:
- 总预算(万元):
- 直接费用总额(若已知,万元):
- 说明:若你按
direct口径填写,总额应与设备费 + 业务费 + 劳务费对齐 - 正文目标字数(若有特别要求):
- 设备费预计金额(万元):
- 业务费预计金额(万元):
- 劳务费预计金额(万元):
- 合作研究转拨资金(万元):
- 其他来源资金(万元):
研究任务与预算对应
请至少回答这些问题:
- 研究任务 1:做什么?最主要会花在哪些地方?
- 研究任务 2:做什么?最主要会花在哪些地方?
- 研究任务 3:做什么?最主要会花在哪些地方?
关键依据
- 是否需要购置/租赁/改造设备?为什么现有条件不够?
- 关键测试/化验/加工由谁完成?频次、单价和依据是什么?
- 差旅/会议/国际合作是否真的需要?对应什么任务?
- 劳务费给谁?人数、月数、标准、承担什么工作?
- 是否有合作单位?各自承担什么任务?为什么必须转拨?
- 是否有其他来源资金?来源是什么,具体用于什么?
不确定但必须说明的点
- 目前还缺哪些报价、测算表、单位要求或合作协议?
- 哪些金额只是初稿,需要二次确认?
- 哪些部分宁可先写“暂无/不安排”,也不能凭空编造?
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import shutil
import sys
from datetime import datetime
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from runtime_utils import dump_json, load_config, resolve_output_dir, resolve_under
def parse_args() -> argparse.Namespace:
skill_root = Path(__file__).resolve().parents[1]
config, _warnings = load_config(skill_root)
defaults = config.get("defaults") or {}
rules = config.get("rules") or {}
target_chars = defaults.get("target_chars") or {}
parser = argparse.ArgumentParser(description="Initialize a hidden nsfc-budget run directory.")
parser.add_argument("--workdir", required=True, help="User work directory.")
parser.add_argument(
"--project-type",
choices=sorted((rules.get("project_types") or (defaults.get("total_budget_wan") or {}).keys())),
default=str(defaults.get("project_type") or "general"),
)
parser.add_argument("--total-budget-wan", type=float)
parser.add_argument("--target-chars", type=int, default=int(target_chars.get("recommended_default") or 900))
parser.add_argument("--template-id", default=str(defaults.get("template_id") or "01"))
parser.add_argument("--output-dirname", default=str(defaults.get("output_dirname") or "budget_output"))
parser.add_argument("--material", action="append", default=[], help="Material file or directory to snapshot.")
return parser.parse_args()
def ensure_directory(path: Path) -> Path:
path.mkdir(parents=True, exist_ok=True)
return path
def unique_path(path: Path) -> Path:
if not path.exists():
return path
stem = path.stem
suffix = path.suffix
counter = 2
while True:
candidate = path.with_name(f"{stem}_{counter}{suffix}")
if not candidate.exists():
return candidate
counter += 1
def validate_args(args: argparse.Namespace) -> None:
if args.target_chars <= 0:
raise ValueError("target_chars 必须大于 0")
if args.total_budget_wan is not None and args.total_budget_wan < 0:
raise ValueError("total_budget_wan 不能为负数")
def copy_material(source: Path, destination_dir: Path) -> str:
target = unique_path(destination_dir / source.name)
if source.is_dir():
shutil.copytree(source, target)
else:
shutil.copy2(source, target)
return str(target)
def build_spec(args: argparse.Namespace, workdir: Path, run_dir: Path, snapped_materials: list[str], config: dict) -> dict:
defaults = config.get("defaults") or {}
total_budget = args.total_budget_wan
if total_budget is None:
total_budget = float(((defaults.get("total_budget_wan") or {}).get(args.project_type)) or 0)
indirect_rate_hint = float((((defaults.get("indirect_rate_hint") or {}).get(args.project_type)) or 0.30))
return {
"meta": {
"project_title": "",
"project_type": args.project_type,
"budget_mode": str((config.get("rules") or {}).get("budget_mode_default") or "budget_based"),
"budget_scope": "to_be_confirmed",
"requested_total_wan": total_budget,
"direct_costs_total_wan": None,
"indirect_costs_wan": None,
"target_chars_min": max(0, args.target_chars - 100),
"target_chars_max": args.target_chars + 100,
"per_section_max_chars": int(defaults.get("per_section_max_chars") or 500),
"template_id": args.template_id,
"workdir": str(workdir),
"output_dirname": args.output_dirname,
"run_dir": str(run_dir),
"materials": snapped_materials,
"notes": "",
},
"budget": {
"equipment_wan": 0.0,
"business_wan": 0.0,
"labor_wan": 0.0,
"transfer_wan": 0.0,
"other_source_wan": 0.0,
"requested_total_tolerance_wan": float(defaults.get("requested_total_tolerance_wan") or 1.0),
"indirect_rate_hint": indirect_rate_hint,
},
"sections": {
"equipment": {"amount_wan": 0.0, "paragraphs": []},
"business": {"amount_wan": 0.0, "paragraphs": []},
"labor": {"amount_wan": 0.0, "paragraphs": []},
"transfer": {"amount_wan": 0.0, "paragraphs": []},
"other_source": {"amount_wan": 0.0, "paragraphs": []},
},
"evidence": {
"pricing_basis": [],
"assumptions": [],
"items_to_confirm": [],
},
}
def main() -> int:
args = parse_args()
skill_root = Path(__file__).resolve().parents[1]
config, config_warnings = load_config(skill_root)
defaults = config.get("defaults") or {}
workdir = Path(args.workdir).expanduser().resolve()
if not workdir.exists() or not workdir.is_dir():
print(f"[nsfc-budget] workdir does not exist or is not a directory: {workdir}", file=sys.stderr)
return 2
try:
validate_args(args)
resolve_output_dir(
workdir,
args.output_dirname,
str(defaults.get("intermediate_dirname") or ".bensz-api/skills/nsfc-budget"),
label="output_dirname",
)
template_dir = resolve_under(skill_root / "models", args.template_id, label="template_id")
if not template_dir.exists() or not template_dir.is_dir():
raise ValueError(f"template_id 对应模板不存在:{args.template_id}")
except ValueError as exc:
print(f"[nsfc-budget] {exc}", file=sys.stderr)
return 2
intermediate_root = ensure_directory(workdir / str(defaults.get("intermediate_dirname") or ".bensz-api/skills/nsfc-budget"))
run_dir = unique_path(intermediate_root / datetime.now().strftime("%Y-%m-%d-%H-%M"))
ensure_directory(run_dir)
ensure_directory(run_dir / "input")
ensure_directory(run_dir / "output")
ensure_directory(run_dir / "log")
materials_dir = ensure_directory(run_dir / "input" / "materials")
ensure_directory(run_dir / "logs")
ensure_directory(run_dir / "build")
snapped_materials = []
for raw in args.material:
source = Path(raw).expanduser().resolve()
if not source.exists():
print(f"[nsfc-budget] warning: material not found, skipped: {source}", file=sys.stderr)
continue
snapped_materials.append(copy_material(source, materials_dir))
spec = build_spec(args, workdir, run_dir, snapped_materials, config)
spec_path = run_dir / "budget_spec.json"
dump_json(spec_path, spec)
manifest = {
"workdir": str(workdir),
"intermediate_root": str(intermediate_root),
"run_dir": str(run_dir),
"materials_dir": str(materials_dir),
"spec_path": str(spec_path),
"config_warnings": config_warnings,
}
dump_json(run_dir / "run_manifest.json", manifest)
(intermediate_root / "ACTIVE_RUN.txt").write_text(str(run_dir) + "\n", encoding="utf-8")
print(json.dumps(manifest, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
nsfc-budget scripts
init_budget_run.py
用途:在用户工作目录下创建隐藏工作区 .bensz-api/skills/nsfc-budget/{yyyy-mm-dd-hh-mm}/,并生成 budget_spec.json 骨架。
示例:
python3 skills/nsfc-budget/scripts/init_budget_run.py \
--workdir ./projects/NSFC_General \
--project-type general \
--material ./projects/NSFC_General/main.tex常用参数:
--workdir:工作目录,必需--project-type:合法值见skills/nsfc-budget/config.yaml--total-budget-wan:总预算,单位万元--target-chars:目标字数中心值(默认值见skills/nsfc-budget/config.yaml)--template-id:模板 ID(默认值见skills/nsfc-budget/config.yaml)--output-dirname:结果目录名(默认值见skills/nsfc-budget/config.yaml)--material:可重复传入,用于复制材料快照到隐藏目录的input/materials/
约束:
--output-dirname只能是工作目录下的相对安全路径,且不能是.或与.bensz-api/skills/nsfc-budget/重叠--template-id只能指向skills/nsfc-budget/models/下的模板目录- 同一分钟重复初始化时,脚本会自动追加后缀避让目录名冲突
render_budget_project.py
用途:读取 budget_spec.json,校验预算结构与文本长度,写入 LaTeX 项目并编译 budget.pdf。
示例:
python3 skills/nsfc-budget/scripts/render_budget_project.py \
--spec ./projects/NSFC_General/.bensz-api/skills/nsfc-budget/2026-03-07-12-00/budget_spec.json常用参数:
--spec:budget_spec.json路径,必需--force:若输出目录已存在,则先删除再重建--skip-compile:只生成 LaTeX 项目,不编译 PDF
输出:
<workdir>/<output_dirname>/:LaTeX 项目<workdir>/<output_dirname>/budget.pdf:最终 PDF(若未--skip-compile)<run_dir>/validation_report.md/validation_report.json:校验报告
额外说明:
--spec必须位于<workdir>/.bensz-api/skills/nsfc-budget/内,否则会被拒绝- 目录为空时可直接渲染;目录非空时需显式传
--force deliverables_manifest.json在--skip-compile时会返回"pdf": null与"pdf_generated": false- 运行时公共工具位于
skills/nsfc-budget/scripts/runtime_utils.py - 常见 LaTeX 特殊字符(
%、#、&、_等)会在写入段落时自动转义
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from runtime_utils import dump_json, load_config, load_template_meta, resolve_output_dir, resolve_under, safe_rel_path
SECTION_KEYS = ["equipment", "business", "labor", "transfer", "other_source"]
TEMPLATE_IGNORE = shutil.ignore_patterns(
".DS_Store",
"*.aux",
"*.log",
"*.synctex.gz",
"budget.pdf",
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Render NSFC budget project from budget_spec.json.")
parser.add_argument("--spec", required=True, help="Path to budget_spec.json.")
parser.add_argument("--force", action="store_true", help="Remove output directory before rendering.")
parser.add_argument("--skip-compile", action="store_true", help="Skip xelatex compilation.")
return parser.parse_args()
def load_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def save_json(path: Path, data: dict[str, Any]) -> None:
dump_json(path, data)
def collapse_whitespace(text: str) -> str:
return re.sub(r"\s+", " ", text).strip()
def escape_latex_text(text: str) -> str:
replacements = {
"\\": r"\textbackslash{}",
"&": r"\&",
"%": r"\%",
"$": r"\$",
"#": r"\#",
"_": r"\_",
"{": r"\{",
"}": r"\}",
"~": r"\textasciitilde{}",
"^": r"\textasciicircum{}",
}
return "".join(replacements.get(char, char) for char in text)
def sanitize_paragraph_latex(text: str) -> str:
tokens: dict[str, str] = {}
def reserve(value: str) -> str:
token = f"@@NSFCBUDGETTOKEN{len(tokens)}@@"
tokens[token] = value
return token
text = re.sub(r"\\linebreak\{\}", lambda _: reserve(r"\linebreak{}"), text)
def replace_bold(match: re.Match[str]) -> str:
inner = escape_latex_text(match.group(1))
return reserve(rf"\BudgetBold{{{inner}}}")
text = re.sub(r"\\BudgetBold\{([^{}]*)\}", replace_bold, text)
escaped = escape_latex_text(text)
for token, value in tokens.items():
escaped = escaped.replace(token, value)
return escaped
def strip_allowed_latex(text: str) -> str:
text = re.sub(r"\\linebreak\{\}", "", text)
text = re.sub(r"\\BudgetBold\{([^{}]*)\}", r"\1", text)
text = re.sub(r"[{}]", "", text)
return collapse_whitespace(text)
def visible_char_count(paragraphs: list[str]) -> int:
joined = "".join(strip_allowed_latex(item) for item in paragraphs)
return len(re.sub(r"\s+", "", joined))
def validate_latex_commands(paragraphs: list[str], allowed_commands: set[str]) -> list[str]:
errors = []
for index, paragraph in enumerate(paragraphs, start=1):
commands = re.findall(r"\\([A-Za-z]+)", paragraph)
disallowed = sorted({item for item in commands if item not in allowed_commands})
if disallowed:
errors.append(f"第 {index} 段包含未允许的 LaTeX 命令:{', '.join(disallowed)}")
return errors
def as_float(value: Any) -> float | None:
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str) and value.strip():
return float(value.strip())
return None
def render_paragraphs(paragraphs: list[str], fallback: str) -> str:
effective = [collapse_whitespace(item) for item in paragraphs if collapse_whitespace(item)]
if not effective:
effective = [fallback]
return "\n\n".join(f"\\BudgetParagraph{{{sanitize_paragraph_latex(item)}}}" for item in effective) + "\n"
def resolve_template(skill_root: Path, template_id: str) -> tuple[Path, dict[str, Any], list[str]]:
template_dir = resolve_under(skill_root / "models", template_id, label="template_id")
if not template_dir.exists():
raise FileNotFoundError(f"template not found: {template_dir}")
template_meta, warnings = load_template_meta(template_dir)
if not isinstance(template_meta, dict):
template_meta = {}
return template_dir, template_meta, warnings
def prepare_output_dir(output_dir: Path, template_dir: Path, force: bool) -> None:
if output_dir.exists():
if any(output_dir.iterdir()):
if not force:
raise FileExistsError(f"output directory already exists and is not empty: {output_dir}")
shutil.rmtree(output_dir)
else:
output_dir.rmdir()
shutil.copytree(template_dir, output_dir, ignore=TEMPLATE_IGNORE, dirs_exist_ok=False)
def validate_spec(spec_path: Path, spec: dict[str, Any], config: dict[str, Any], skill_root: Path) -> tuple[list[str], list[str], dict[str, Any]]:
errors = []
warnings = []
meta = spec.get("meta") or {}
budget = spec.get("budget") or {}
sections = spec.get("sections") or {}
defaults = config.get("defaults") or {}
rules = config.get("rules") or {}
validation_cfg = config.get("validation") or {}
allowed_commands = set(rules.get("allowed_latex_commands") or [])
project_types = set(rules.get("project_types") or (defaults.get("total_budget_wan") or {}).keys())
budget_modes = set(rules.get("budget_modes") or {"budget_based", "package_based", "historical_budget_based"})
budget_scopes = set(rules.get("budget_scopes") or {"direct", "total", "to_be_confirmed"})
project_type = str(meta.get("project_type") or defaults.get("project_type") or "general")
if project_types and project_type not in project_types:
errors.append(f"meta.project_type 非法:{project_type}")
budget_mode = str(meta.get("budget_mode") or rules.get("budget_mode_default") or "budget_based")
if budget_mode not in budget_modes:
errors.append(f"meta.budget_mode 非法:{budget_mode}")
workdir_raw = meta.get("workdir")
if not workdir_raw:
errors.append("meta.workdir 不能为空")
workdir = None
else:
workdir = Path(str(workdir_raw)).expanduser().resolve()
if not workdir.exists() or not workdir.is_dir():
errors.append(f"meta.workdir 不存在或不是目录:{workdir}")
budget_scope = str(meta.get("budget_scope") or "to_be_confirmed")
if budget_scope not in budget_scopes:
errors.append(f"meta.budget_scope 非法:{budget_scope}")
output_dirname = str(meta.get("output_dirname") or defaults.get("output_dirname") or "budget_output")
template_id = str(meta.get("template_id") or defaults.get("template_id") or "01")
if workdir is not None:
try:
resolve_output_dir(
workdir,
output_dirname,
str(defaults.get("intermediate_dirname") or ".bensz-api/skills/nsfc-budget"),
label="output_dirname",
)
except ValueError as exc:
errors.append(str(exc))
intermediate_dir = workdir / str(defaults.get("intermediate_dirname") or ".bensz-api/skills/nsfc-budget")
try:
spec_path.resolve().relative_to(intermediate_dir.resolve())
except Exception:
errors.append(f"spec 必须位于 {intermediate_dir} 内:{spec_path}")
try:
template_dir = resolve_under(skill_root / "models", template_id, label="template_id")
if not template_dir.exists() or not template_dir.is_dir():
errors.append(f"template_id 对应模板不存在:{template_id}")
except ValueError as exc:
errors.append(str(exc))
target_chars = defaults.get("target_chars") or {}
target_min = int(meta.get("target_chars_min", target_chars.get("recommended_min") or 800) or 800)
target_max = int(meta.get("target_chars_max", target_chars.get("recommended_max") or 1000) or 1000)
per_section_max = int(meta.get("per_section_max_chars", defaults.get("per_section_max_chars") or 500) or 500)
if target_min < 0:
errors.append(f"meta.target_chars_min 不能为负数:{target_min}")
if target_max <= 0:
errors.append(f"meta.target_chars_max 必须大于 0:{target_max}")
if target_max < target_min:
errors.append(f"meta.target_chars_max 不能小于 target_chars_min:{target_min} > {target_max}")
if per_section_max <= 0:
errors.append(f"meta.per_section_max_chars 必须大于 0:{per_section_max}")
budget_amount_keys = {
"equipment": "equipment_wan",
"business": "business_wan",
"labor": "labor_wan",
"transfer": "transfer_wan",
"other_source": "other_source_wan",
}
section_char_counts = {}
for key in SECTION_KEYS:
section = sections.get(key) or {}
paragraphs = section.get("paragraphs") or []
if not isinstance(paragraphs, list):
errors.append(f"sections.{key}.paragraphs 必须是数组")
paragraphs = []
command_errors = validate_latex_commands([str(item) for item in paragraphs], allowed_commands)
errors.extend(f"sections.{key}: {item}" for item in command_errors)
amount = as_float(section.get("amount_wan"))
if amount is None:
warnings.append(f"sections.{key}.amount_wan 未填写,按 0 处理")
amount = 0.0
if amount < 0:
errors.append(f"sections.{key}.amount_wan 不能为负数:{amount:.2f}w")
budget_amount = as_float(budget.get(budget_amount_keys[key]))
if budget_amount is not None and budget_amount < 0:
errors.append(f"budget.{budget_amount_keys[key]} 不能为负数:{budget_amount:.2f}w")
if budget_amount is not None and abs(budget_amount - amount) > 1e-6:
errors.append(f"budget.{budget_amount_keys[key]}={budget_amount:.2f}w 与 sections.{key}.amount_wan={amount:.2f}w 不一致")
if bool(validation_cfg.get("require_section_text_when_amount_positive", True)) and amount > 0 and not [item for item in paragraphs if collapse_whitespace(str(item))]:
errors.append(f"sections.{key} 金额大于 0,但正文段落为空")
count = visible_char_count([str(item) for item in paragraphs])
section_char_counts[key] = count
if count > per_section_max:
errors.append(f"sections.{key} 可见字符数 {count} 超过上限 {per_section_max}")
equipment = as_float((sections.get("equipment") or {}).get("amount_wan")) or 0.0
business = as_float((sections.get("business") or {}).get("amount_wan")) or 0.0
labor = as_float((sections.get("labor") or {}).get("amount_wan")) or 0.0
transfer = as_float((sections.get("transfer") or {}).get("amount_wan")) or 0.0
other_source = as_float((sections.get("other_source") or {}).get("amount_wan")) or 0.0
direct_sum = round(equipment + business + labor, 4)
direct_costs_total = as_float(meta.get("direct_costs_total_wan"))
requested_total = as_float(meta.get("requested_total_wan"))
tolerance = as_float(budget.get("requested_total_tolerance_wan")) or float(defaults.get("requested_total_tolerance_wan") or 1.0)
if direct_costs_total is not None and direct_costs_total < 0:
errors.append(f"meta.direct_costs_total_wan 不能为负数:{direct_costs_total:.2f}w")
if requested_total is not None and requested_total < 0:
errors.append(f"meta.requested_total_wan 不能为负数:{requested_total:.2f}w")
if tolerance < 0:
errors.append(f"budget.requested_total_tolerance_wan 不能为负数:{tolerance:.2f}w")
if direct_costs_total is not None and abs(direct_sum - direct_costs_total) > 1e-6:
errors.append(f"设备/业务/劳务之和为 {direct_sum:.2f}w,与 meta.direct_costs_total_wan={direct_costs_total:.2f}w 不一致")
if budget_scope == "direct" and requested_total is not None and abs(direct_sum - requested_total) > tolerance:
errors.append(f"预算口径为 direct,但直接费用合计 {direct_sum:.2f}w 与 requested_total_wan={requested_total:.2f}w 差值超过 {tolerance:.2f}w")
if budget_scope == "total" and direct_costs_total is None:
warnings.append("budget_scope=total,但未填写 direct_costs_total_wan,暂无法检查总额与说明书口径的一致性")
if transfer > direct_sum and direct_sum > 0:
errors.append(f"合作研究转拨资金 {transfer:.2f}w 不应大于直接费用合计 {direct_sum:.2f}w")
equipment_ratio_warning = float(validation_cfg.get("equipment_ratio_warning") or 0.50)
if direct_sum > 0 and equipment / direct_sum > equipment_ratio_warning:
warnings.append(f"设备费占直接费用比例约为 {equipment / direct_sum:.1%},请核对是否符合当年政策与单位要求")
total_chars = sum(section_char_counts.values())
if total_chars < target_min or total_chars > target_max:
warnings.append(f"正文总可见字符数为 {total_chars},当前推荐区间为 {target_min}–{target_max}")
normalized = {
"workdir": str(workdir) if workdir else "",
"output_dirname": output_dirname,
"template_id": template_id,
"section_char_counts": section_char_counts,
"total_chars": total_chars,
"direct_sum_wan": direct_sum,
"transfer_wan": transfer,
"other_source_wan": other_source,
"budget_scope": budget_scope,
}
return errors, warnings, normalized
def write_sections(output_dir: Path, sections: dict[str, Any], section_files: dict[str, str], zero_text: dict[str, str]) -> None:
for key, relative_path in section_files.items():
section = sections.get(key) or {}
paragraphs = [str(item) for item in (section.get("paragraphs") or [])]
content = render_paragraphs(paragraphs, str(zero_text[key]))
target_path = resolve_under(output_dir, relative_path, label=f"section_files.{key}")
target_path.write_text(content, encoding="utf-8")
def compile_project(output_dir: Path, latex_entry: str, build_dir: Path, runs: int) -> None:
try:
for _ in range(runs):
result = subprocess.run(
[
"xelatex",
"-interaction=nonstopmode",
"-halt-on-error",
f"-output-directory={build_dir}",
latex_entry,
],
cwd=output_dir,
capture_output=True,
text=True,
check=False,
)
(build_dir / "xelatex.stdout.log").write_text(result.stdout, encoding="utf-8")
(build_dir / "xelatex.stderr.log").write_text(result.stderr, encoding="utf-8")
if result.returncode != 0:
raise RuntimeError(f"xelatex failed with exit code {result.returncode}")
except FileNotFoundError as exc:
raise RuntimeError("xelatex 不可用,请先安装 TeX Live/MacTeX 并确保 xelatex 在 PATH 中") from exc
def write_report(run_dir: Path, spec: dict[str, Any], normalized: dict[str, Any], errors: list[str], warnings: list[str]) -> None:
report_json = {
"errors": errors,
"warnings": warnings,
"normalized": normalized,
}
save_json(run_dir / "validation_report.json", report_json)
lines = [
"# nsfc-budget 校验报告",
"",
f"- 模板 ID:`{normalized['template_id']}`",
f"- 输出目录:`{normalized['output_dirname']}`",
f"- 正文总可见字符数:`{normalized['total_chars']}`",
f"- 直接费用合计:`{normalized['direct_sum_wan']:.2f}w`",
"",
"## 各部分字符数",
"",
]
for key in SECTION_KEYS:
lines.append(f"- `{key}`:{normalized['section_char_counts'][key]}")
lines.extend(["", "## Errors", ""])
if errors:
lines.extend(f"- {item}" for item in errors)
else:
lines.append("- 无")
lines.extend(["", "## Warnings", ""])
if warnings:
lines.extend(f"- {item}" for item in warnings)
else:
lines.append("- 无")
(run_dir / "validation_report.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
save_json(run_dir / "budget_spec.snapshot.json", spec)
def render_from_spec(spec_path: Path, force: bool = False, skip_compile: bool = False) -> dict[str, Any]:
skill_root = Path(__file__).resolve().parents[1]
config, config_warnings = load_config(skill_root)
spec = load_json(spec_path)
errors, warnings, normalized = validate_spec(spec_path, spec, config, skill_root)
warnings.extend(config_warnings)
run_dir = spec_path.parent
template_dir = None
template_meta: dict[str, Any] = {}
if not errors:
try:
template_dir, template_meta, template_warnings = resolve_template(skill_root, normalized["template_id"])
warnings.extend(template_warnings)
except Exception as exc:
errors.append(str(exc))
write_report(run_dir, spec, normalized, errors, warnings)
if errors:
preview = ";".join(errors[:3])
suffix = ";…" if len(errors) > 3 else ""
raise ValueError(f"spec 校验失败:{preview}{suffix};详见 {run_dir / 'validation_report.md'}")
workdir = Path(normalized["workdir"])
output_dir = resolve_output_dir(
workdir,
normalized["output_dirname"],
str((config.get("defaults") or {}).get("intermediate_dirname") or ".bensz-api/skills/nsfc-budget"),
label="output_dirname",
)
assert template_dir is not None
prepare_output_dir(output_dir, template_dir, force=force)
output_cfg = config.get("output") or {}
rules_cfg = config.get("rules") or {}
section_files = template_meta.get("section_files") or output_cfg.get("section_files") or {}
if sorted(section_files.keys()) != sorted(SECTION_KEYS):
raise ValueError(f"section_files 配置不完整:{section_files}")
for key, relative_path in section_files.items():
safe_rel_path(str(relative_path), label=f"section_files.{key}")
zero_text = rules_cfg.get("zero_text") or {}
if sorted(zero_text.keys()) != sorted(SECTION_KEYS):
raise ValueError(f"zero_text 配置不完整:{zero_text}")
write_sections(output_dir, spec.get("sections") or {}, section_files, zero_text)
if not skip_compile:
latex_entry = str(template_meta.get("latex_entry") or output_cfg.get("latex_entry") or "budget.tex")
pdf_name = str(template_meta.get("pdf_name") or output_cfg.get("pdf_name") or "budget.pdf")
safe_rel_path(latex_entry, label="latex_entry")
safe_rel_path(pdf_name, label="pdf_name")
build_dir = run_dir / "build"
build_dir.mkdir(parents=True, exist_ok=True)
compile_runs = int((config.get("defaults") or {}).get("compile_runs") or 2)
if compile_runs <= 0:
raise ValueError(f"compile_runs 必须大于 0:{compile_runs}")
compile_project(output_dir, latex_entry, build_dir, runs=compile_runs)
compiled_pdf = build_dir / pdf_name
if not compiled_pdf.exists():
raise FileNotFoundError(f"compiled pdf not found: {compiled_pdf}")
shutil.copy2(compiled_pdf, output_dir / pdf_name)
manifest = {
"output_dir": str(output_dir),
"pdf": str(output_dir / (str(template_meta.get("pdf_name") or output_cfg.get("pdf_name") or "budget.pdf"))) if not skip_compile else None,
"pdf_generated": not skip_compile,
"validation_report": str(run_dir / "validation_report.md"),
}
save_json(run_dir / "deliverables_manifest.json", manifest)
return manifest
def main() -> int:
args = parse_args()
spec_path = Path(args.spec).expanduser().resolve()
if not spec_path.exists():
print(f"[nsfc-budget] spec not found: {spec_path}", file=sys.stderr)
return 2
try:
manifest = render_from_spec(spec_path, force=args.force, skip_compile=args.skip_compile)
except Exception as exc:
print(f"[nsfc-budget] {exc}", file=sys.stderr)
return 1
print(json.dumps(manifest, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List, Tuple
DEFAULT_CONFIG: Dict[str, Any] = {
"defaults": {
"template_id": "01",
"output_dirname": "budget_output",
"intermediate_dirname": ".bensz-api/skills/nsfc-budget",
"project_type": "general",
"total_budget_wan": {"general": 50, "local": 50, "youth": 30},
"target_chars": {"recommended_min": 800, "recommended_max": 1000, "recommended_default": 900},
"per_section_max_chars": 500,
"indirect_rate_hint": {"general": 0.30, "local": 0.30, "youth": 0.30},
"requested_total_tolerance_wan": 1.0,
"compile_runs": 2,
},
"rules": {
"require_workdir": True,
"project_types": ["general", "local", "youth"],
"budget_mode_default": "budget_based",
"budget_modes": ["budget_based", "package_based", "historical_budget_based"],
"budget_scopes": ["direct", "total", "to_be_confirmed"],
"allowed_latex_commands": ["linebreak", "BudgetBold"],
"zero_text": {
"equipment": "本项目不列支设备费。",
"business": "本项目不列支业务费。",
"labor": "本项目不列支劳务费。",
"transfer": "本项目无合作研究转拨资金。",
"other_source": "本项目无其他来源资金。",
},
},
"validation": {
"equipment_ratio_warning": 0.50,
"require_section_text_when_amount_positive": True,
},
"output": {
"latex_entry": "budget.tex",
"pdf_name": "budget.pdf",
"section_files": {
"equipment": "extraTex/1.1.设备费.tex",
"business": "extraTex/1.2.业务费.tex",
"labor": "extraTex/1.3.劳务费.tex",
"transfer": "extraTex/2.1.合作研究转拨资金.tex",
"other_source": "extraTex/3.1.其他来源资金.tex",
},
},
}
def merge_dict(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
merged: Dict[str, Any] = dict(base)
for key, value in override.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = merge_dict(merged[key], value)
else:
merged[key] = value
return merged
def load_yaml_file(path: Path) -> Tuple[Dict[str, Any], List[str]]:
warnings: List[str] = []
if not path.exists():
return {}, [f"YAML 文件不存在:{path}"]
try:
import yaml # type: ignore
loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(loaded, dict):
warnings.append(f"YAML 解析结果不是 dict:{path}")
return {}, warnings
return loaded, warnings
except ImportError:
warnings.append("缺少 PyYAML,回退到默认配置")
except Exception as exc:
warnings.append(f"读取 YAML 失败,回退到默认配置:{exc}")
return {}, warnings
def load_config(skill_root: Path) -> Tuple[Dict[str, Any], List[str]]:
loaded, warnings = load_yaml_file(skill_root / "config.yaml")
return merge_dict(DEFAULT_CONFIG, loaded), warnings
def load_template_meta(template_dir: Path) -> Tuple[Dict[str, Any], List[str]]:
return load_yaml_file(template_dir / ".template.yaml")
def safe_rel_path(raw: str, *, label: str) -> Path:
path = Path(raw)
if not raw or raw.strip() == "":
raise ValueError(f"{label} 不能为空")
if path.is_absolute():
raise ValueError(f"{label} 不能是绝对路径:{raw}")
if any(part in {"..", "", "."} for part in path.parts):
raise ValueError(f"{label} 不能包含越界路径段:{raw}")
return path
def resolve_under(base: Path, relative: str, *, label: str) -> Path:
rel = safe_rel_path(relative, label=label)
resolved = (base / rel).resolve()
try:
resolved.relative_to(base.resolve())
except ValueError as exc:
raise ValueError(f"{label} 解析后越界:{relative} -> {resolved}") from exc
return resolved
def paths_overlap(first: Path, second: Path) -> bool:
first_resolved = first.resolve()
second_resolved = second.resolve()
try:
first_resolved.relative_to(second_resolved)
return True
except ValueError:
pass
try:
second_resolved.relative_to(first_resolved)
return True
except ValueError:
return False
def resolve_output_dir(workdir: Path, output_dirname: str, intermediate_dirname: str, *, label: str = "output_dirname") -> Path:
output_dir = resolve_under(workdir, output_dirname, label=label)
workdir_resolved = workdir.resolve()
if output_dir == workdir_resolved:
raise ValueError(f"{label} 不能指向工作目录根路径:{output_dirname}")
intermediate_root = resolve_under(workdir, intermediate_dirname, label="intermediate_dirname")
if paths_overlap(output_dir, intermediate_root):
raise ValueError(
f"{label} 不能与隐藏工作区 {intermediate_dirname} 重叠:{output_dirname}"
)
return output_dir
def ensure_parent(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
def dump_json(path: Path, data: Dict[str, Any]) -> None:
ensure_parent(path)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")