
Nsfc Abstract
- 180 installs
- 2.6k repo stars
- Updated July 20, 2026
- huangwb8/chineseresearchlatex
Draft NSFC grant abstracts in Chinese research LaTeX style so investigators can articulate aims, innovation, and impact before full proposal writing.
About
Helps researchers compose National Natural Science Foundation of China (NSFC) proposal abstracts with correct structure, terminology, and LaTeX-oriented academic style so early grant narratives are clear and review-ready.
- NSFC abstract structure
- Chinese research LaTeX style
- Aims and innovation framing
- Impact and significance wording
- Academic grant narrative drafting
Nsfc Abstract by the numbers
- 180 all-time installs (skills.sh)
- +4 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #265 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-abstractAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 180 |
|---|---|
| repo stars | ★ 2.6k |
| Last updated | July 20, 2026 |
| Repository | huangwb8/chineseresearchlatex ↗ |
What it does
Draft NSFC grant abstracts in Chinese research LaTeX style so investigators can articulate aims, innovation, and impact before full proposal writing.
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 仓库。
目标:写出“评审一眼读懂”的五句式摘要,做到重要性、科学问题、可行性证据、研究内容、科学意义五个要素齐全;并输出与之一致的英文翻译。
输入(先要信息,缺啥就问啥)
优先让用户按信息表提供(见 skills/nsfc-abstract/references/info_form.md)。若用户只给了零散信息,先用 3-6 个问题补齐最关键缺口:
- 你希望题目更偏“机制向 / 方法向 / 转化向 / 场景向”哪一类?(不确定可不选,默认给混合候选)
- 研究对象/场景是什么?(疾病/材料/系统/任务)
- 领域痛点与未解决的科学问题是什么?(一句话)
- 你们的关键前期发现/预实验/数据点是什么?(1-2 条,可定量)
- 拟验证的科学假说/核心判断是什么?(一句话)
- 研究内容 3-4 点分别要做什么、用什么方法、得到什么可验证结果?
- 预期科学意义/应用价值是什么?(避免空话)
输出要求(硬约束)
- 必须同时给出中文与英文;英文是中文的忠实翻译(不扩写、不新增假设、不引入新结果)。
- 中文摘要使用中文标点。
- 引号硬约束(不可绕过):中文摘要中若需引号,唯一合法形式为中文弯引号
”(U+201C)与”(U+201D)。以下形式一律禁止:ASCII 双引号”、全角引号"、直角引号「」/『』、单引号'/',以及任何其他替代形式。 - 中文摘要正文为纯文本:不得含有 Markdown 标记(
**、*、_、#等),用户会直接将正文 copy 到只支持纯文本的系统中。 - 中文摘要数字不使用千分位逗号:写
1000,不要写1,000或1,000。 - 必须给出”主要研究领域”:放在英文摘要之后,一句话(汉字数 ≤ 25),精简表达核心研究方向,不得写成多条或列表。
- 写入
NSFC-ABSTRACTS.md时,中文摘要正文末尾与英文摘要分段标题之间不留空行。 - 必须给出标题建议:遵循
skills/nsfc-abstract/references/title-rules.md的”中标题目”结构偏好;至少包含: 推荐标题:...(中文)Recommended Title: ...(英文,紧跟推荐标题下一行)1) ... —— 理由:... / EN: ...(至少 5 条;数量以config.yaml:title.title_candidates_default为准;每条末尾附英文翻译)- 中文摘要:默认 ≤ 400 字符(含标点);推荐 5 句(以
config.yaml:limits.zh_recommended_sentences为准),每句 1 个功能。 - 英文摘要:≤ 4000 字符(含标点);语法正确、术语一致。
- 不使用夸大/营销式表达(如“国际领先/填补空白/首创”),除非用户提供可核验依据且明确要求保留。
- 不堆砌背景,不罗列方法学名词;每个方法名词必须服务于“要验证什么”。
推荐结构(五句式,默认采用)
篇幅比例原则:资深评审希望尽快看到申请人”进入正题”,背景铺垫过多是专业度不足的信号。各句目标占比如下(超出即压缩):
| 句子 | 功能 | 目标占比 |
|---|---|---|
| ① 背景 | 入场券 | ≤ 15% |
| ② 科学问题 | 定位缺口 | 15–20% |
| ③ 前期研究 | 建立可信度 | 20–25% |
| ④ 研究内容 | 主体 | 35–40% |
| ⑤ 意义 | 闭环 | 10–15% |
1) 重要性/背景(≤ 15%):严格 1 句,点明研究对象的重要性与核心瓶颈,不展开背景综述。 2) 科学问题(15–20%):指出关键未知与当前不足(具体到机制/指标/因果链的缺口)。 3) 可行性证据(前期研究)(20–25%):给出 1-2 条前期发现/预实验(尽量可定量),并顺势提出科学假说/核心判断。 4) 研究内容(3-4 点)(35–40%):用动词开头,写清”做什么→怎么做→要验证什么/得到什么判据”(点数默认以 config.yaml:limits.content_points_min/max 为准);此句是摘要主体,应获得最多篇幅。 5) 意义/价值(10–15%):总结预期贡献(科学机制 + 方法/策略/应用潜力),与第 2 句的问题形成闭环。
输出格式(便于粘贴与校验)
按如下格式写入 NSFC-ABSTRACTS.md(标题文本以 config.yaml:output.zh_heading/en_heading 为准)。在末尾给出长度自检(字符数计数口径:把连续空白折叠为单个空格后计数,含标点):
# 标题建议
推荐标题:...
Recommended Title: ...
1) ... —— 理由:... / EN: ...
2) ... —— 理由:... / EN: ...
3) ... —— 理由:... / EN: ...
4) ... —— 理由:... / EN: ...
5) ... —— 理由:... / EN: ...
# 中文摘要
(正文)
# English Abstract
(translation)
# 主要研究领域
(一句话,≤ 25 个汉字)
## 长度自检
- 中文摘要字符数:N/400
- 英文摘要字符数:M/4000写入文件后,在对话中不要重复粘贴全文(除非用户明确要求),只需回报:
- 写入的文件路径(默认
./NSFC-ABSTRACTS.md) - 标题候选数量是否满足默认要求
- 中文/英文字符数与是否超限
字数超限处理(闭环,最多 3 轮)
当用户明确要求“中文≤400字符/英文≤4000字符”等硬约束时,必须走闭环:先检测,再压缩,再检测,合格后再写入。
1) 先生成一个“可读但可能超限”的初稿(五句式要素齐全)。 2) 用确定性脚本检测(必须用 --json,让硬编码负责精确计数):
python3 skills/nsfc-abstract/scripts/validate_abstract.py - --json --diff --strict- 输入可通过 stdin(无需先写文件),或在写入
NSFC-ABSTRACTS.md后对文件校验。
3) 若超限:按“压缩优先级”执行压缩,再回到步骤 2,最多 3 轮。 4) 连续 3 轮仍超限:停止自动压缩,向用户说明当前超限数(ZH/EN exceeded),并请用户选择:
- 允许删减某些信息(哪些可删)
- 放宽字数上限(修改
config.yaml) - 人工给出更短的关键信息摘要(例如只保留最关键 1 条证据 + 3 点研究内容)
压缩优先级(从上到下)
- P0(必须保留):研究对象/关键科学问题缺口/定量证据(若有)/核心方法名称/预期贡献闭环
- P1(优先精简):程度副词、形容词修饰、”我们将/本研究”主语、过程性描述、”通过/采用”等介词短语、并列重复表达
- P2(优先删除):背景超出 15% 的部分(首先压缩)、空洞评价(无定量支撑的”显著/重要/领先”等)、重复句、与科学问题无关的背景扩展
如需确定性写入/校验,可使用脚本:
- 写入:
python3 skills/nsfc-abstract/scripts/write_abstracts_md.py <input> --strict - 校验:
python3 skills/nsfc-abstract/scripts/validate_abstract.py NSFC-ABSTRACTS.md --json --diff --strict
Changelog
All notable changes to the nsfc-abstract skill will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
[2.1.2] - 2026-03-04
Changed
SKILL.md:推荐结构改用比例约束替代文字描述——各句目标占比以表格形式呈现(背景 ≤15%、科学问题 15–20%、前期研究 20–25%、研究内容 35–40%、意义 10–15%),每句标题内联占比提示SKILL.md:压缩优先级 P2 改为"背景超出 15% 的部分(首先压缩)",与比例约束对齐
Changed
SKILL.md:推荐结构新增篇幅比例原则——背景(第 1 句)严格限制在 1 句内占比最小;前期研究(第 3 句)与研究内容(第 4 句)为核心,应获得更多篇幅;并说明背景铺垫过多是专业度不足的信号SKILL.md:压缩优先级调整——"背景铺垫(超出 1 句的部分)"从 P1 升至 P2(优先删除),自动压缩时优先砍背景
[2.1.0] - 2026-03-04
Changed
SKILL.md:主要研究领域从"3–5 个要点"改为一句话(汉字数 ≤ 25),输出格式示例同步更新SKILL.md:中文摘要引号规范加强——只允许"..."(U+201C/U+201D),明确列出其他禁用形式(ASCII"、全角"、「」、『』)SKILL.md:新增"中文摘要正文为纯文本"约束(禁止 Markdown 标记),因输出将直接被用户 copy 到纯文本系统references/info_form.md:研究领域采集项描述更新,说明一句话且不超过 25 个汉字config.yaml:field节新增field_max_chars: 25validate_abstract.py:build_field_report新增max_chars参数,加入 CJK 字数校验,结果字段增加cjk_chars/len_ok;新增辅助函数_count_cjk_chars与_count_other_nonstandard_quotes;FIELD 打印行显示cjk/max计数write_abstracts_md.py:SkillConfig与_load_config新增field_max_chars;将"缺失"与"超限"拆分为独立错误路径;超限时作为硬约束(与引号检查同级)拒绝写入
[2.0.1] - 2026-03-04
Fixed
write_abstracts_md.py:输出文件名安全校验,--out仅允许当前目录下的单文件名(拒绝../等路径),避免越界写入write_abstracts_md.py:中文摘要出现英文双引号"或数字千分位逗号1,000/1,000时,无论是否--strict均拒绝写入(避免写出不可提交文件)validate_abstract.py:CLI 文案/修复提示补齐 FIELD 分段,并在输出中显示 FIELD markers/headings,便于排障
Added
- 回归测试:新增
--out非法路径拒绝的 unittest 覆盖
[2.0.0] - 2026-03-04
Added
- 输出新增
# 主要研究领域分段(位于英文摘要之后;也支持标记格式[FIELD]...[/FIELD]) - 信息表
references/info_form.md:新增“主要研究领域”采集项(3–5 个关键词/要点)
Changed
- Breaking:默认要求“主要研究领域”分段(由
config.yaml:field.field_required控制,默认true) validate_abstract.py:新增--no-field(向后兼容旧输出);并在需要时强制校验该分段存在write_abstracts_md.py:写入NSFC-ABSTRACTS.md时加入“主要研究领域”分段;缺失且 required 时拒绝写入
[1.0.3] - 2026-03-04
Fixed
validate_abstract.py/write_abstracts_md.py:--strict下增加“中文摘要数字不允许出现数字,数字/数字,数字”的确定性检查(避免1,000,建议写1000)
[1.0.2] - 2026-03-04
Fixed
validate_abstract.py/write_abstracts_md.py:--strict下增加“中文摘要不允许英文双引号\"”的确定性检查(避免出现 "...",应使用 “...”)
[1.0.1] - 2026-03-04
Fixed
write_abstracts_md.py:写入NSFC-ABSTRACTS.md时,中文摘要与英文摘要分段之间不再插入空行(避免提交系统/表单里出现额外间距)
Changed
SKILL.md/README.md:输出格式示例同步更新(中英之间无空行)- 回归测试:新增断言确保中文与英文分段之间无空行
[1.0.0] - 2026-02-24
Changed
config.yaml:版本号0.3.1 → 1.0.0,标记为正式稳定版本
[0.3.1] - 2026-02-24
Changed
SKILL.md:标题建议输出格式新增英文版;推荐标题下方增加Recommended Title: ...行,每个候选条目末尾附/ EN: ...英文翻译examples/demo_output.txt:更新示例以对应新格式(含英文推荐标题与候选英文翻译)
[0.3.0] - 2026-02-11
Added
- 标题建议输出:默认 1 个推荐标题 + 5 个候选标题及理由(以
config.yaml:title.*为准) - 标题写作规则沉淀:新增
references/title-rules.md(基于 2016–2023 立项题目统计总结) validate_abstract.py:新增标题建议分段校验(可用--no-title向后兼容旧输出)write_abstracts_md.py:支持写入# 标题建议分段,并在标题不合格时拒绝写入
Changed
config.yaml:版本号升至0.3.0,新增title.*配置项并默认要求标题建议分段SKILL.md/README.md/scripts/README.md:输出格式与脚本用法同步更新(加入标题建议)- 回归测试:更新 stdlib unittest 用例以覆盖“标题建议必需 + 候选数量检查”
[0.2.0] - 2026-02-03
Added
validate_abstract.py:新增--json(机器可读输出)与--diff(exceeded 差值)参数write_abstracts_md.py:新增--json输出与--auto-compress占位参数(当前仅提示,不执行自动压缩)SKILL.md:新增“字数超限处理(闭环,最多 3 轮)”章节,明确检测→压缩→再检测流程- 新增 repo 级回归脚本与 fixtures:
tests/字数压缩/test_workflow.sh、tests/字数压缩/fixtures/*
Changed
write_abstracts_md.py:--strict模式下若超限则不写入输出文件(与 README 约定一致)
Fixed
validate_abstract.py:当缺失config.yaml时,默认返回完整的 limits/markers/headings/output 配置(避免返回值数量不一致)
[0.1.1] - 2026-02-03
Added
- 输出写入工作目录
NSFC-ABSTRACTS.md(中文/英文各一个#标题) - 新增写入脚本:
skills/nsfc-abstract/scripts/write_abstracts_md.py - 新增回归测试:
skills/nsfc-abstract/tests/test_validate_abstract.py、skills/nsfc-abstract/tests/test_write_abstracts_md.py(stdlib unittest) - 新增脚本文档:
skills/nsfc-abstract/scripts/README.md
Changed
- 收窄
SKILL.md的 triggers,避免过宽的“摘要”导致误触发 - README 补充内置 demo 文件提示,便于验证输出格式与长度校验脚本
Fixed
validate_abstract.py:兼容config.yaml中字符串标量带引号/不带引号两种写法(避免#被误判为注释)validate_abstract.py:支持两种输入格式校验([ZH]/[EN]标记或# 中文摘要/# English Abstract标题)validate_abstract.py:缺失分段标记时给出可操作的最小修复示例,并在--help中明确退出码validate_abstract.py:长度计数前折叠连续空白,减少换行/多空格导致的意外超限;输出中显示 limits/markers/raw count 便于复现validate_abstract.py:避免使用 Python 3.10+ 专用类型注解语法(提高脚本可移植性)
[0.1.0] - 2026-02-03
Added
- 初始版本:生成 NSFC 中文摘要(≤400字,含标点)与对应英文摘要(≤4000字符,含标点;英文为中文的忠实翻译)
- 提供长度校验脚本:
skills/nsfc-abstract/scripts/validate_abstract.py
skill_info:
name: nsfc-abstract
version: 2.1.2
description: NSFC 标书中英文摘要生成(英文为中文的忠实翻译)与长度校验
author: "Bensz Conan"
category: writing
limits:
zh_max_chars: 400
en_max_chars: 4000
content_points_min: 3
content_points_max: 4
zh_recommended_sentences: 5
format:
# 用于脚本与示例的分段标记;不要求用户在最终标书里保留这些标记
zh_begin: "[ZH]"
zh_end: "[/ZH]"
en_begin: "[EN]"
en_end: "[/EN]"
field_begin: "[FIELD]"
field_end: "[/FIELD]"
output:
filename: "NSFC-ABSTRACTS.md"
zh_heading: "# 中文摘要"
en_heading: "# English Abstract"
field_heading: "# 主要研究领域"
title:
# Whether title suggestions are required in the final output.
title_required: true
# Default number of title candidates to provide (in addition to the recommended title line).
title_candidates_default: 5
# Optional marker-format for machine parsing.
title_begin: "[TITLE]"
title_end: "[/TITLE]"
# Markdown heading for the title section in NSFC-ABSTRACTS.md
title_heading: "# 标题建议"
field:
# Whether "主要研究领域" is required in the final output.
field_required: true
# Max Chinese characters (汉字) for the field sentence; 0 = no limit.
field_max_chars: 25
(示例:仅用于演示输出格式与长度校验,不代表真实数据/结论)
标题建议:
[TITLE]
推荐标题:跨尺度可解释特征驱动的肿瘤免疫治疗反应预测与机制溯源
Recommended Title: Cross-scale Interpretable Feature-driven Prediction and Mechanism Tracing of Tumor Immunotherapy Response
1) 多模态影像与循环标志物联合特征用于免疫治疗应答预测及机制解析 —— 理由:对象与判据清楚,强调可验证终点 / EN: Multimodal Imaging and Circulating Biomarker Combined Features for Immunotherapy Response Prediction and Mechanism Analysis
2) 面向免疫治疗分层的可解释表征模型构建与关键通路验证 —— 理由:突出方法创新与验证闭环 / EN: Construction of Interpretable Representation Models and Key Pathway Validation for Immunotherapy Stratification
3) 肿瘤微环境时空异质性表征及其与免疫治疗反应的因果链研究 —— 理由:机制向,贴合生医评审口味 / EN: Characterization of Tumor Microenvironment Spatiotemporal Heterogeneity and Its Causal Links to Immunotherapy Response
4) 跨中心多模态数据标准化与免疫治疗反应评估工具开发 —— 理由:强调可行性与工程交付 / EN: Cross-center Multimodal Data Standardization and Development of Immunotherapy Response Assessment Tools
5) 可解释联合特征揭示免疫抑制通路动态变化并指导分层干预 —— 理由:整合机制+应用,但仍保留”可验证”语义 / EN: Interpretable Combined Features Reveal Dynamic Immunosuppressive Pathway Changes to Guide Stratified Intervention
[/TITLE]
中文摘要:
[ZH]
肿瘤免疫治疗显著改善部分患者预后,但对多数实体瘤仍存在应答率低与耐药等瓶颈。当前缺乏能够同时表征肿瘤微环境时空异质性与治疗反应的可解释指标体系,导致机制判断与个体化决策受限。我们前期在多中心队列中发现,基于多模态影像与循环分子标志物的联合特征可稳定区分应答/非应答人群,并提示关键免疫抑制通路的动态变化,据此提出“跨尺度可解释特征驱动的反应预测与机制溯源”假说。本项目拟:(1)构建跨中心的多模态数据规范与质控流程;(2)建立可解释表征模型,解析与免疫反应相关的关键特征;(3)在动物模型与前瞻性队列中验证关键通路与预测口径;(4)形成可复用的评估工具与干预靶点线索。预期阐明影响免疫治疗反应的关键机制并提供可落地的分层评估策略,为提升实体瘤治疗获益提供新思路。
[/ZH]
English Abstract:
[EN]
Immunotherapy has improved outcomes for some patients, yet most solid tumors still suffer from low response rates and drug resistance. A major limitation is the lack of interpretable metrics that jointly capture the spatiotemporal heterogeneity of the tumor microenvironment and treatment responses, which hampers mechanistic understanding and personalized decision-making. In our multicenter pilot cohorts, we found that combined features from multimodal imaging and circulating molecular biomarkers can robustly distinguish responders from non-responders and indicate dynamic changes in key immunosuppressive pathways; therefore, we hypothesize that cross-scale interpretable features can drive response prediction and mechanism tracing. This project will (1) build cross-center standards and quality control for multimodal data, (2) develop interpretable representation models to identify key features associated with immune responses, (3) validate critical pathways and prediction criteria in animal models and prospective cohorts, and (4) deliver reusable assessment tools and actionable clues for intervention targets. We expect to elucidate key mechanisms underlying immunotherapy responses and provide practical stratification strategies to improve clinical benefits for solid tumors.
[/EN]
主要研究领域:
[FIELD]
肿瘤微环境免疫调控与免疫治疗反应预测
[/FIELD]
nsfc-abstract — 用户使用指南
本 README 面向使用者:如何触发并正确使用 nsfc-abstract。 执行指令与硬性规范以 skills/nsfc-abstract/SKILL.md 为准;默认参数见 skills/nsfc-abstract/config.yaml。
你会得到什么
- 标题建议:
推荐标题+ 默认5个候选标题(每条附理由;规则见skills/nsfc-abstract/references/title-rules.md) - 中文摘要:默认 ≤
400字符,推荐“五句式”(重要性→科学问题→可行性证据→研究内容→意义) - 英文摘要:默认 ≤
4000字符;必须是中文的忠实翻译(不扩写、不新增信息/假设) - 主要研究领域:放在英文摘要之后,建议
3–5个要点(每条尽量短) - 输出文件:写入当前工作目录下的
NSFC-ABSTRACTS.md(文件名与分段标题可在config.yaml调整)
什么时候用
当你希望 AI 帮你:
- 写/润色 NSFC 标书中文摘要 + 英文摘要
- 把中文摘要翻成忠实不扩写的英文摘要
- 生成“更像中标题目”的标题建议(推荐 + 候选 + 理由)
- 在最后用脚本做确定性长度校验(可复现、可自动化)
快速开始(推荐 Prompt)
仅建议使用 Claude Code + Claude Opus 4.6 thinking 或者 OpenAI Codex CLI + GPT-5.2 High 这两种模式来使用
1) 开发者推荐
先填好 skills/nsfc-abstract/references/info_form.md,然后用:
请基于 nsfc-abstract skill 为我的标书生成中英文摘要和标题2) 零散信息驱动(没空填表也能用)
请为我的 NSFC 标书生成中英文摘要并给出标题建议,要求同上。
研究对象/场景:
领域痛点/科学缺口(一句话):
前期发现/证据(1-2 条,尽量定量):
科学假说/核心判断(一句话):
研究内容(3-4 点;每点写清:做什么→怎么做→验证什么/判据是什么):
预期意义(科学机制 + 方法/策略/应用潜力,避免空话):3) 已有中文摘要:生成英文忠实翻译
请将以下中文摘要翻译为 NSFC 英文摘要(≤4000 字符),要求:
- 忠实翻译,不新增信息、不扩写、不引入新结果
- 术语一致、语法正确
- 输出前给出英文字符数统计
[粘贴中文摘要]4) 已有中英草稿:只润色 + 严格控长
请润色以下 NSFC 摘要(中英双语),要求:
- 中文 ≤400 字符、英文 ≤4000 字符(含标点;连续空白按一个空格计数)
- 不改变核心观点;优先删减空洞背景与修饰语,避免夸大表述
- 英文必须是中文的忠实翻译
- 末尾输出长度自检(中英文字符数)
[粘贴你的中英文摘要草稿]输出文件
| 文件 | 默认路径 | 说明 |
|---|---|---|
NSFC-ABSTRACTS.md | ./NSFC-ABSTRACTS.md | 标题建议 + 中英文摘要 + 长度自检(写在当前工作目录) |
输出格式示例:
# 标题建议
推荐标题:...
1) ... —— 理由:...
2) ... —— 理由:...
3) ... —— 理由:...
4) ... —— 理由:...
5) ... —— 理由:...
# 中文摘要
(正文内容,≤400字符)
# English Abstract
(Translation, ≤4000 characters)
# 主要研究领域
- ...
## 长度自检
- 中文摘要字符数:N/400
- 英文摘要字符数:M/4000关键约束(必须遵守)
- 英文必须是中文的忠实翻译:不扩写、不新增信息/假设、不引入新结果。
- 中文摘要使用中文标点;如需引号,使用中文双引号“...”,不要使用英文双引号"..."。
- 中文摘要数字不使用千分位逗号:写
1000,不要写1,000或1,000。 - 必须给出“主要研究领域”:放在英文摘要之后,建议 3–5 个要点(每条尽量短)。
- 写入
NSFC-ABSTRACTS.md时,中文摘要正文末尾与英文摘要分段标题之间不留空行。 - 默认要求“标题建议”分段(是否必需由
config.yaml:title.title_required决定)。 - 中文默认 5 句、研究内容默认 3-4 点;可在
config.yaml:limits调整。 - 若你给的信息不完整:更稳妥的做法是让 AI 先追问缺口,而不是直接“补写事实”。
配置选项
以下参数可在 skills/nsfc-abstract/config.yaml 中调整:
| 参数 | 默认值 | 说明 |
|---|---|---|
limits.zh_max_chars | 400 | 中文摘要最大字符数 |
limits.en_max_chars | 4000 | 英文摘要最大字符数 |
limits.zh_recommended_sentences | 5 | 中文摘要推荐句数(五句式) |
limits.content_points_min | 3 | 研究内容点数下限 |
limits.content_points_max | 4 | 研究内容点数上限 |
output.filename | NSFC-ABSTRACTS.md | 输出文件名(写到当前工作目录) |
output.zh_heading | # 中文摘要 | 中文分段标题 |
output.en_heading | # English Abstract | 英文分段标题 |
output.field_heading | # 主要研究领域 | “主要研究领域”分段标题 |
title.title_required | true | 是否要求输出标题建议分段 |
title.title_candidates_default | 5 | 默认标题候选数量 |
title.title_heading | # 标题建议 | 标题建议分段的 Markdown 标题 |
field.field_required | true | 是否要求输出“主要研究领域”分段 |
备选用法(脚本:确定性校验/写入)
当你需要“严格控长 + 可复现校验”时,用脚本做最终把关(更完整用法见 skills/nsfc-abstract/scripts/README.md):
# 严格校验(超限返回 1;格式不对返回 2)
python3 skills/nsfc-abstract/scripts/validate_abstract.py NSFC-ABSTRACTS.md --strict
# 严格写入(超限返回 1;输入可为文件或 stdin)
python3 skills/nsfc-abstract/scripts/write_abstracts_md.py your_abstract.txt --strict提示词示例(完整示例)
示例 1:完整信息表(推荐)
请根据以下信息生成 NSFC 标书中英文摘要,并给出标题建议:
研究对象:非小细胞肺癌患者
领域痛点:免疫治疗应答率低(约20%),缺乏可靠预测标志物
前期发现:
1. 回顾性队列(n=156)发现CT影像特征与PD-L1表达相关性(AUC=0.78)
2. 单细胞测序提示肿瘤间质比与T细胞浸润呈负相关(r=-0.62)
科学假说:CT影像组学特征可反映肿瘤免疫微环境状态,预测免疫治疗反应
研究内容:
1. 构建多中心CT影像组学队列(n≥500)
2. 结合病理与单细胞数据解析影像-免疫关联机制
3. 建立并验证影像组学预测模型
4. 开发自动化分析软件工具
预期意义:为非小细胞肺癌免疫治疗提供无创预测工具
要求:
- 推荐标题 + 5 个候选标题(每条附理由)
- 中文≤400字符,五句式结构
- 英文≤4000字符,英文忠实翻译
- 末尾输出长度自检(中英文字符数)示例 2:从立项依据/正文提炼摘要
请根据以下立项依据/正文提炼 NSFC 摘要,并给出标题建议:
[粘贴你的立项依据或研究内容正文]
要求:
- 提炼核心:重要性→问题→证据/假说→内容→意义
- 研究内容压缩为 3-4 点(每点都要有“判据/验证什么”)
- 中文≤400字符,英文≤4000字符(英文忠实翻译)
- 末尾输出长度自检示例 3:仅润色(尽量保留原结构)
请润色以下 NSFC 摘要,尽量保持原有结构,仅优化表达并校验长度:
[粘贴你的中英文摘要草稿]
要求:
- 中文≤400字符,英文≤4000字符
- 不改变核心观点与逻辑结构
- 去除“国际领先/填补空白”等夸大表达
- 英文忠实翻译
- 末尾输出长度自检示例 4:只做英文忠实翻译
请将以下中文摘要翻译为 NSFC 英文摘要(≤4000字符),要求忠实翻译、不新增信息,并输出英文字符数:
[粘贴中文摘要]常见问题(FAQ)
Q:输出写到哪里?
A:默认写到你当前的工作目录,文件名为 NSFC-ABSTRACTS.md。例如你在仓库根目录运行/触发,本文件就是 ./NSFC-ABSTRACTS.md。
Q:为什么中文默认只有 400 字符?
A:这是青年基金/地区基金常见限制。面上项目或其他项目类别可能不同:你可以在 skills/nsfc-abstract/config.yaml 调整 limits.zh_max_chars 与 limits.en_max_chars。
Q:一定要用“五句式”吗?
A:五句式是推荐结构,不是硬性要求;但无论结构怎么变,都要覆盖“重要性、科学问题、证据/假说、研究内容、意义”五要素。
Q:信息表必须完全填满吗?
A:不需要。信息越完整,摘要质量越高;如果缺关键信息(如核心假说、前期证据、研究内容判据),更建议让 AI 先追问你补齐,而不是直接“补写事实”。
Q:如何写入 LaTeX 正文?
A:本技能默认输出 Markdown,通常用于你在系统/表单里直接粘贴与归档。若你需要 LaTeX 格式,可以在拿到摘要后再请求:
请将上述摘要转换为 LaTeX 格式,使用 \\begin{abstract}...\\end{abstract} 包裹(只包裹中文或按我指定)。WHICHMODEL(可选)
如果你想要“按场景选模型/档位”的建议,见 skills/nsfc-abstract/WHICHMODEL_section.md(这类建议具有时效性,仅作参考)。
更多文档
skills/nsfc-abstract/SKILL.md— 执行指令与硬性规范skills/nsfc-abstract/config.yaml— 默认参数与限制skills/nsfc-abstract/references/info_form.md— 信息表模板skills/nsfc-abstract/references/title-rules.md— 中标题目写作规则skills/nsfc-abstract/scripts/README.md— 脚本使用说明skills/nsfc-abstract/examples/demo_output.txt— 输出示例
信息表(nsfc-abstract)
请尽量用“可核验、可量化”的表达填写;未知项可留空,但会影响摘要质量。
基本信息
- 项目题目(中文,可选):
- 题目风格偏好(可选:机制向 / 方法向 / 转化向 / 场景向;不填则默认给混合候选):
- 研究对象/场景(疾病/材料/系统/任务):
- 主要研究领域(一句话,不超过 25 个汉字,精简表达核心研究方向):
- 研究周期与总体目标(一句话):
科学问题与假说(摘要的灵魂)
- 领域痛点/重要性(一句话):
- 关键未解科学问题(尽量具体到机制/因果链/指标缺口,一句话):
- 拟验证的科学假说/核心判断(一句话):
前期基础(可行性证据)
请给 1-2 条最有力证据(优先定量):
- 证据 1:
- 证据 2:
拟开展研究内容(3-4 点)
每点请写清三要素:做什么 / 怎么做 / 验证口径(判据/指标/预期观察)
- 内容 1:
- 内容 2:
- 内容 3:
- 内容 4(如需要):
预期成果与科学意义
- 预期关键结论/机制性认识(1 句):
- 预期应用价值/策略/工具(1 句,可选):
NSFC 中标题目写作规则(基于 2016–2023 立项题目统计)
本规则用于给 NSFC 申请书生成“更像中标题目”的标题候选,并解释每个候选标题的取舍理由。
快速结论(最重要的共性)
- 题目长度:中位数约 29 字符,约 90% 不超过 43 字符(去空白计数)。建议默认控制在 25–40 字符区间,超过 45 字符通常需要压缩信息密度或拆掉从句。
- “研究对象 + 关键科学问题/机制 + 可验证终点”三件套是最稳定的结构。
- 不同学部的“题目偏好”差异显著:
- 医学/生命:高度偏好 机制/调控/作用 叙事(如医学科学部题目中约 79% 含“机制”)。
- 工程材料/化学:更常出现 方法/制备/构建/性能,题目更“工程化可验收”。
- 信息/数理:更常出现 方法/模型/算法/预测,题目更“问题定义清晰 + 方法路径明确”。
通用写作规则(跨学科)
信息密度规则
题目需要同时回答三件事(越靠前越重要): 1) 研究对象/系统:材料/器件/疾病/通路/过程/场景/任务 2) 核心动作/关系:调控、影响、耦合、构建、优化、识别、预测、评估…… 3) 科学问题或可验证终点:机制阐明、因果链、关键判据、性能边界、可解释性、稳健性、泛化性、转化/应用场景……
用词与结构规则
- 动词驱动:优先使用可验证的动词(“解析/揭示/阐明/构建/开发/验证/评估/预测/优化”),少用空泛动词(“探索/探讨/研究进展”)作为核心承载。
- 少而准的限定词:限定词只保留“会影响评审判断”的那一个(例如“时空”“跨尺度”“多中心”“多模态”“可解释”);避免堆叠形容词。
- 术语一致:题目中出现的关键名词,应与摘要里的对象/假说/指标一致(避免题目说 A,摘要主线写 B)。
- 避免营销词:除非用户给出可核验依据并明确要求保留,否则默认不使用“国际领先/填补空白/首创”等。
“中标题目”常见模板(可直接套用)
以下模板中的括号为可替换槽位:
1) 机制主线(生医/生命最常见)
- (对象/通路/细胞)通过(关键因子/过程)调控(表型/过程)的机制研究
- (因素)对(表型/疾病进程)的影响及机制研究
2) 方法-问题对齐(信息/工程常见)
- 面向(任务/场景)的(方法/模型/系统)构建与(性能/稳健性/可解释性)研究
- (约束/噪声/不确定性)下(任务)的(模型/算法)与理论分析
3) 工程实现(工程材料/化学常见)
- (材料/结构)的设计、制备及(性能/机理/应用)研究
- (器件/体系)的构建与(关键性能指标)调控机理
4) 转化与应用(医学常见但需克制)
- (机制/标志物/模型)驱动的(诊断/预测/分层/干预)策略及验证
- (技术/平台)在(疾病/场景)中的应用与评价
学科偏好(用于“题目风格”对齐)
以下为从本地 CSV 做的粗统计(同一题目可能同时命中多个关键词):
- 全体题目:含“机制”约 45%;含“调控”约 21%;含“基于”约 16%。
- 医学科学部(n≈10259):含“机制”约 79%;“调控”约 34%;“治疗”约 10%。
- 工程与材料科学部(n≈5742):“方法”约 16%;“性能”约 13%;“机制”约 24%。
- 信息科学部(n≈3737):“方法”约 26%;“模型”约 6%;“预测”约 3%。
用法:当用户给出学科/学部时,标题候选应“向该学部偏好靠拢”;当用户未给出学科时,默认生成一组“机制向 + 方法向 + 应用向”的混合候选,便于选择。
生成 5 个标题候选(默认策略)
默认输出 5 个中文候选标题,并给出一句话理由(理由要可对比,避免重复):
1) 机制向:突出“因果链/通路/调控关系”,适合医学/生命学部评审口味。 2) 判据向:突出“关键指标/可验证终点/评价口径”,强调可检验性。 3) 方法向:突出“技术路线/模型/平台/算法”,强调可实现性与创新方法。 4) 场景向:突出“疾病亚型/应用场景/边界条件”,强调对准真实问题。 5) 整合向:突出“跨尺度/多模态/时空动态/系统机制”,强调整体性(但要控制字数)。
筛选优先级(从高到低):
- P0:对象清楚 + 科学问题清楚 + 可验证终点清楚
- P1:与摘要主线一致;术语统一;没有“空话形容词堆叠”
- P2:长度控制;读起来顺;避免生硬缩写堆叠
常见错误(需要在候选理由里主动规避)
- 对象不具体:只写“大数据/新材料/免疫治疗”等泛对象,评审无法判断可行性与边界。
- 动词不落地:通篇“探索/研究”但没有可验证动作(调控/构建/评估/验证等)。
- 从句过长:一个题目塞进 3–4 个并列从句,读起来像摘要第一句。
- 题目与摘要脱节:题目写机制,摘要主线是方法学;或题目写转化应用,摘要没有验证路径。
变更历史记录在项目根级 CHANGELOG.md。scripts(nsfc-abstract)
validate_abstract.py
对 nsfc-abstract 的输出做确定性约束校验(长度 + 分段完整性 + 严格模式下的中文格式):
- 解析标题建议分段(
[TITLE]...[/TITLE]或# 标题建议) - 解析
[ZH]...[/ZH]与[EN]...[/EN](或# 中文摘要/# English Abstract标题格式) - 解析“主要研究领域”分段(
[FIELD]...[/FIELD]或# 主要研究领域) - 默认在计数前将连续空白折叠为单个空格(减少换行/多空格导致的意外超限)
--strict下额外检查:中文摘要中不应出现英文双引号"(应改为中文引号“...”)--strict下额外检查:中文摘要数字不应使用千分位逗号(如1,000/1,000,应改为1000)
用法
python3 skills/nsfc-abstract/scripts/validate_abstract.py path/to/output.txt
python3 skills/nsfc-abstract/scripts/validate_abstract.py path/to/output.txt --strict
cat path/to/output.txt | python3 skills/nsfc-abstract/scripts/validate_abstract.py -
# JSON(机器可读)+ diff(超出字符数)
python3 skills/nsfc-abstract/scripts/validate_abstract.py path/to/output.txt --json --diff
# 向后兼容旧输出(不要求标题分段)
python3 skills/nsfc-abstract/scripts/validate_abstract.py path/to/output.txt --no-title
# 向后兼容旧输出(不要求“主要研究领域”分段)
python3 skills/nsfc-abstract/scripts/validate_abstract.py path/to/output.txt --no-field退出码
0:成功(且在--strict下无约束违规)1:--strict下存在约束违规(超限/中文标点/数字格式等)2:输入格式错误或缺失必需分段(如缺 TITLE/FIELD 等)
write_abstracts_md.py
把输入内容写成工作目录下的 NSFC-ABSTRACTS.md(文件名与标题文本以 config.yaml:output 为准),并在末尾追加长度自检。
同时支持把标题建议分段写入 # 标题建议(标题分段默认是必需项,取决于 config.yaml:title.title_required),并写入“主要研究领域”分段 # 主要研究领域(是否必需取决于 config.yaml:field.field_required)。
额外硬约束(为了避免写出不可提交文件):
- 中文摘要出现英文双引号
"或数字千分位逗号1,000/1,000时,无论是否 `--strict` 均拒绝写入 --out仅允许单文件名(不允许../或a/b.md这类路径),保证输出留在当前工作目录
用法
python3 skills/nsfc-abstract/scripts/write_abstracts_md.py your_output.txt
python3 skills/nsfc-abstract/scripts/write_abstracts_md.py your_output.txt --strict
cat your_output.txt | python3 skills/nsfc-abstract/scripts/write_abstracts_md.py -
# auto-compress(占位:当前版本不自动压缩,超限则提示并返回 1)
python3 skills/nsfc-abstract/scripts/write_abstracts_md.py your_output.txt --auto-compress
# JSON 报告(stdout 仅打印 JSON;写入路径输出到 stderr)
python3 skills/nsfc-abstract/scripts/write_abstracts_md.py your_output.txt --json退出码
0:写入成功(且在--strict下无长度超限)1:约束违规导致拒绝写入(例如:--strict下超限,或中文摘要包含"/1,000)2:输入格式错误或非法输出文件名(缺失必要分段、无法解析中英文摘要、--out非单文件名等)
#!/usr/bin/env python3
"""
Validate abstract length constraints for nsfc-abstract skill output.
Expected input contains:
[TITLE] ... [/TITLE] (optional / configurable)
[ZH] ... [/ZH]
[EN] ... [/EN]
[FIELD] ... [/FIELD] ("主要研究领域" section; optional / configurable)
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Tuple
def _read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _extract_block(text: str, begin: str, end: str) -> str:
# Non-greedy across newlines
pattern = re.compile(re.escape(begin) + r"(.*?)" + re.escape(end), re.DOTALL)
m = pattern.search(text)
if not m:
raise ValueError(f"未找到分段标记:{begin}...{end}")
return m.group(1).strip()
def _extract_blocks_by_headings(text: str, zh_heading: str, en_heading: str) -> Tuple[str, str]:
lines = text.splitlines()
def _is_heading(line: str, heading: str) -> bool:
return line.strip() == heading.strip()
zh_i = None
en_i = None
for i, line in enumerate(lines):
if zh_i is None and _is_heading(line, zh_heading):
zh_i = i
continue
if en_i is None and _is_heading(line, en_heading):
en_i = i
continue
if zh_i is None or en_i is None or zh_i > en_i:
raise ValueError(f"未找到标题分段:{zh_heading} 与 {en_heading}")
zh = "\n".join(lines[zh_i + 1 : en_i]).strip()
# EN content ends at the next markdown heading (any level) if present,
# e.g. "## 长度自检" appended by writer script.
en_lines = []
for line in lines[en_i + 1 :]:
if line.lstrip().startswith("#"):
break
en_lines.append(line)
en = "\n".join(en_lines).strip()
if not zh or not en:
raise ValueError("标题分段已找到,但内容为空(请在标题下方写入正文)")
return zh, en
def _normalize_for_count(text: str) -> str:
# Abstracts are typically submitted as a single paragraph. Users may format
# outputs with newlines/spaces for readability; count after collapsing
# whitespace to reduce surprises.
return re.sub(r"\s+", " ", text).strip()
def _count_ascii_double_quotes(text: str) -> int:
# In Chinese abstracts, avoid ASCII double quotes. Prefer the Chinese form instead.
return text.count('"') # ASCII double-quote U+0022
def _count_other_nonstandard_quotes(text: str) -> int:
# Detect non-standard double-quote-like chars other than ASCII “ and the allowed “...” (U+201C/U+201D).
# Includes: fullwidth " (U+FF02), decorative 〝〞, corner brackets 「」『』.
bad = '\uff02\u301d\u301e\u300c\u300d\u300e\u300f'
return sum(text.count(c) for c in bad)
def _count_numeric_commas(text: str) -> int:
# In Chinese abstracts, avoid comma as number separator, e.g. "1,000" / "1,000".
# Only count commas that appear *between* digits to avoid false positives.
return len(re.findall(r"(?<=\d)[,,](?=\d)", text))
def _count_cjk_chars(text: str) -> int:
"""Count Chinese/CJK unified ideograph characters only (汉字)."""
return sum(
1 for ch in text
if '\u4e00' <= ch <= '\u9fff'
or '\u3400' <= ch <= '\u4dbf'
or '\U00020000' <= ch <= '\U0002a6df'
)
def _parse_bool(v: Optional[str], default: bool) -> bool:
if v is None:
return default
s = v.strip().lower()
if s in {"1", "true", "yes", "y", "on"}:
return True
if s in {"0", "false", "no", "n", "off"}:
return False
return default
@dataclass(frozen=True)
class SkillConfig:
zh_max: int
en_max: int
zh_begin: str
zh_end: str
en_begin: str
en_end: str
zh_heading: str
en_heading: str
out_name: str
title_required: bool
title_candidates_default: int
title_begin: str
title_end: str
title_heading: str
field_required: bool
field_max_chars: int
field_begin: str
field_end: str
field_heading: str
def _load_limits_from_config(skill_root: Path) -> SkillConfig:
"""
Avoid external deps (PyYAML). We only need a few scalar fields, so parse by regex.
"""
config_path = skill_root / "config.yaml"
if not config_path.exists():
return SkillConfig(
zh_max=400,
en_max=4000,
zh_begin="[ZH]",
zh_end="[/ZH]",
en_begin="[EN]",
en_end="[/EN]",
zh_heading="# 中文摘要",
en_heading="# English Abstract",
out_name="NSFC-ABSTRACTS.md",
title_required=False,
title_candidates_default=5,
title_begin="[TITLE]",
title_end="[/TITLE]",
title_heading="# 标题建议",
field_required=False,
field_max_chars=25,
field_begin="[FIELD]",
field_end="[/FIELD]",
field_heading="# 主要研究领域",
)
raw = config_path.read_text(encoding="utf-8")
def _strip_inline_comment(value: str) -> str:
v = value.lstrip()
if not v:
return v
if v[0] in {"\"", "'"}:
q = v[0]
out = []
escaped = False
for ch in v[1:]:
if escaped:
out.append(ch)
escaped = False
continue
if ch == "\\":
escaped = True
continue
if ch == q:
break
out.append(ch)
return "".join(out)
return v.split("#", 1)[0].rstrip()
def _get_scalar(key: str) -> Optional[str]:
# NOTE: use real whitespace class `[ \t]` (not `\\t`), otherwise it would also match literal 't'.
m = re.search(rf"^[ \t]*{re.escape(key)}:[ \t]*(.+?)\s*$", raw, re.M)
if not m:
return None
return _strip_inline_comment(m.group(1)).strip()
def _int(key: str, default: int) -> int:
v = _get_scalar(key)
if v is None:
return default
return int(v) if v.isdigit() else default
def _str(key: str, default: str) -> str:
v = _get_scalar(key)
return v if v is not None and v != "" else default
zh_max = _int("zh_max_chars", 400)
en_max = _int("en_max_chars", 4000)
zh_begin = _str("zh_begin", "[ZH]")
zh_end = _str("zh_end", "[/ZH]")
en_begin = _str("en_begin", "[EN]")
en_end = _str("en_end", "[/EN]")
zh_heading = _str("zh_heading", "# 中文摘要")
en_heading = _str("en_heading", "# English Abstract")
out_name = _str("filename", "NSFC-ABSTRACTS.md")
title_required = _parse_bool(_get_scalar("title_required"), False)
title_candidates_default = _int("title_candidates_default", 5)
title_begin = _str("title_begin", "[TITLE]")
title_end = _str("title_end", "[/TITLE]")
title_heading = _str("title_heading", "# 标题建议")
field_required = _parse_bool(_get_scalar("field_required"), False)
field_max_chars = _int("field_max_chars", 25)
field_begin = _str("field_begin", "[FIELD]")
field_end = _str("field_end", "[/FIELD]")
field_heading = _str("field_heading", "# 主要研究领域")
return SkillConfig(
zh_max=zh_max,
en_max=en_max,
zh_begin=zh_begin,
zh_end=zh_end,
en_begin=en_begin,
en_end=en_end,
zh_heading=zh_heading,
en_heading=en_heading,
out_name=out_name,
title_required=title_required,
title_candidates_default=title_candidates_default,
title_begin=title_begin,
title_end=title_end,
title_heading=title_heading,
field_required=field_required,
field_max_chars=field_max_chars,
field_begin=field_begin,
field_end=field_end,
field_heading=field_heading,
)
def _extract_section_by_heading(text: str, heading: str) -> Optional[str]:
lines = text.splitlines()
def _is_heading(line: str, h: str) -> bool:
return line.strip() == h.strip()
start_i = None
for i, line in enumerate(lines):
if _is_heading(line, heading):
start_i = i
break
if start_i is None:
return None
body_lines = []
for line in lines[start_i + 1 :]:
if line.lstrip().startswith("#"):
break
body_lines.append(line)
body = "\n".join(body_lines).strip()
return body or None
def _extract_title_by_headings(text: str, title_heading: str, zh_heading: str) -> Optional[str]:
lines = text.splitlines()
def _is_heading(line: str, heading: str) -> bool:
return line.strip() == heading.strip()
t_i = None
zh_i = None
for i, line in enumerate(lines):
if t_i is None and _is_heading(line, title_heading):
t_i = i
continue
if zh_i is None and _is_heading(line, zh_heading):
zh_i = i
continue
if t_i is None:
return None
end_i = zh_i if (zh_i is not None and zh_i > t_i) else len(lines)
body = "\n".join(lines[t_i + 1 : end_i]).strip()
return body or None
def build_title_report(title_raw: Optional[str], *, required: bool, min_candidates: int) -> dict:
title = (title_raw or "").strip()
present = bool(title)
recommended_ok = bool(re.search(r"(?m)^\s*推荐标题\s*[::]\s*\S+", title))
cand_lines = re.findall(r"(?m)^\s*(?:[-*]\s*)?\d+[\)\.、]\s+\S+", title)
cand_n = len(cand_lines)
ok = (not required) or (present and recommended_ok and cand_n >= int(min_candidates))
return {
"present": present,
"required": bool(required),
"has_recommended": recommended_ok,
"candidates": cand_n,
"min_candidates": int(min_candidates),
"ok": ok,
}
def build_field_report(field_raw: Optional[str], *, required: bool, max_chars: int = 25) -> dict:
field = (field_raw or "").strip()
present = bool(field)
cjk_count = _count_cjk_chars(field) if field else 0
len_ok = (max_chars <= 0) or (cjk_count <= max_chars)
ok = ((not required) or present) and len_ok
return {
"present": present,
"required": bool(required),
"cjk_chars": cjk_count,
"max_chars": max_chars,
"len_ok": len_ok,
"ok": ok,
}
def _build_report(zh_raw: str, en_raw: str, *, zh_max: int, en_max: int) -> dict:
"""
Build a machine-readable report.
Note: length counting collapses consecutive whitespace into a single space.
"""
zh = _normalize_for_count(zh_raw)
en = _normalize_for_count(en_raw)
zh_len = len(zh)
en_len = len(en)
zh_exceeded = max(0, zh_len - zh_max)
en_exceeded = max(0, en_len - en_max)
zh_ascii_dq = _count_ascii_double_quotes(zh_raw)
zh_numeric_commas = _count_numeric_commas(zh_raw)
zh_punct_ok = zh_ascii_dq == 0
zh_num_ok = zh_numeric_commas == 0
zh_len_ok = zh_exceeded == 0
en_len_ok = en_exceeded == 0
return {
"zh": {
"len": zh_len,
"max": zh_max,
"exceeded": zh_exceeded,
"len_ok": zh_len_ok,
"ascii_double_quotes": zh_ascii_dq,
"numeric_commas": zh_numeric_commas,
"punct_ok": zh_punct_ok,
"num_ok": zh_num_ok,
"ok": zh_len_ok and zh_punct_ok and zh_num_ok,
},
"en": {
"len": en_len,
"max": en_max,
"exceeded": en_exceeded,
"len_ok": en_len_ok,
"ok": en_len_ok,
},
}
# Public alias for reuse by other scripts (write_abstracts_md.py).
build_report = _build_report
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(
formatter_class=argparse.RawTextHelpFormatter,
description=(
"Validate nsfc-abstract output constraints.\n"
"It extracts ZH/EN blocks, then counts characters. Title/Field sections may be required by config.\n"
"Counting collapses consecutive whitespace into a single space by default.\n"
"In --strict mode, it also enforces: ZH no ASCII double quotes (\") and no digit-comma-digit (1,000)."
),
epilog=(
"Exit codes:\n"
" 0 success (and within limits when --strict is used)\n"
" 1 constraint violations with --strict\n"
" 2 input format error / missing required sections\n"
),
)
ap.add_argument("file", help="包含 [ZH]/[EN] 分段标记的文本文件路径(或 - 表示 stdin)")
ap.add_argument("--strict", action="store_true", help="严格模式:超限/中文标点/数字格式不合规则返回非 0")
ap.add_argument("--json", action="store_true", help="以 JSON 输出结果(机器可读;仅打印 JSON)")
ap.add_argument("--diff", action="store_true", help="输出超出字符数(exceeded= max(0, len-max))")
ap.add_argument(
"--require-title",
action="store_true",
help="要求存在标题建议分段(默认取 config.yaml:title.title_required)",
)
ap.add_argument(
"--no-title",
action="store_true",
help="忽略标题建议分段(不做检查;向后兼容旧输出)",
)
ap.add_argument(
"--min-title-candidates",
type=int,
default=0,
help="标题候选最少数量(默认取 config.yaml:title.title_candidates_default;0 表示使用默认值)",
)
ap.add_argument(
"--require-field",
action="store_true",
help="要求存在“主要研究领域”分段(默认取 config.yaml:field.field_required)",
)
ap.add_argument(
"--no-field",
action="store_true",
help="忽略“主要研究领域”分段(不做检查;向后兼容旧输出)",
)
args = ap.parse_args(argv)
skill_root = Path(__file__).resolve().parents[1]
cfg = _load_limits_from_config(skill_root)
if args.file == "-":
text = sys.stdin.read()
else:
text = _read_text(Path(args.file))
zh_raw = None
en_raw = None
try:
zh_raw = _extract_block(text, cfg.zh_begin, cfg.zh_end)
en_raw = _extract_block(text, cfg.en_begin, cfg.en_end)
except ValueError:
# Fall back to heading-based extraction.
try:
zh_raw, en_raw = _extract_blocks_by_headings(text, cfg.zh_heading, cfg.en_heading)
except ValueError as e:
print(f"[ERROR] {e}", file=sys.stderr)
print("", file=sys.stderr)
print("修复提示(两种任选其一):", file=sys.stderr)
print("A) 标记格式:", file=sys.stderr)
print(f"{cfg.zh_begin}\n(中文摘要正文)\n{cfg.zh_end}", file=sys.stderr)
print(f"{cfg.en_begin}\n(English abstract translation)\n{cfg.en_end}", file=sys.stderr)
print(f"{cfg.field_begin}\n- (主要研究领域要点)\n{cfg.field_end}", file=sys.stderr)
print("", file=sys.stderr)
print("B) 标题格式(推荐,便于直接保存为文件):", file=sys.stderr)
print(
f"{cfg.zh_heading}\n(中文摘要正文)\n\n{cfg.en_heading}\n(English abstract translation)\n\n{cfg.field_heading}\n- (主要研究领域要点)",
file=sys.stderr,
)
print("", file=sys.stderr)
print(f"提示:本 skill 约定输出文件为工作目录下的 `{cfg.out_name}`(可用 write_abstracts_md.py 生成)。", file=sys.stderr)
return 2
zh_raw_len = len(zh_raw)
en_raw_len = len(en_raw)
report = _build_report(zh_raw, en_raw, zh_max=cfg.zh_max, en_max=cfg.en_max)
zh_ok = bool(report["zh"]["ok"])
en_ok = bool(report["en"]["ok"])
# Title section (optional, but required by config/flags).
title_required = bool(args.require_title or cfg.title_required) and (not args.no_title)
min_cands = int(args.min_title_candidates) if int(args.min_title_candidates) > 0 else int(cfg.title_candidates_default)
title_raw: Optional[str] = None
if not args.no_title:
try:
title_raw = _extract_block(text, cfg.title_begin, cfg.title_end)
except ValueError:
title_raw = _extract_title_by_headings(text, cfg.title_heading, cfg.zh_heading)
title_report = build_title_report(title_raw, required=title_required, min_candidates=min_cands)
report["title"] = title_report
# Field section (optional, but may be required by config/flags).
field_required = bool(args.require_field or cfg.field_required) and (not args.no_field)
field_raw: Optional[str] = None
if not args.no_field:
try:
field_raw = _extract_block(text, cfg.field_begin, cfg.field_end)
except ValueError:
field_raw = _extract_section_by_heading(text, cfg.field_heading)
field_report = build_field_report(field_raw, required=field_required, max_chars=cfg.field_max_chars)
report["field"] = field_report
if title_required and not title_report["present"]:
if args.json:
# Still emit the report for machine debugging.
sys.stdout.write(json.dumps(report, ensure_ascii=False))
sys.stdout.write("\n")
else:
print("", file=sys.stderr)
print("[ERROR] 缺少标题建议分段。修复提示:", file=sys.stderr)
print(f"{cfg.title_begin}\n推荐标题:...\n1) ... —— 理由:...\n2) ... —— 理由:...\n...\n{cfg.title_end}", file=sys.stderr)
print("", file=sys.stderr)
print("或使用 Markdown 标题:", file=sys.stderr)
print(
f"{cfg.title_heading}\n推荐标题:...\n1) ... —— 理由:...\n...\n\n{cfg.zh_heading}\n(中文摘要正文)",
file=sys.stderr,
)
return 2
if field_required and not field_report["present"]:
if args.json:
sys.stdout.write(json.dumps(report, ensure_ascii=False))
sys.stdout.write("\n")
else:
print("", file=sys.stderr)
print("[ERROR] 缺少“主要研究领域”分段。修复提示:", file=sys.stderr)
print(f"{cfg.field_begin}\n- ...\n{cfg.field_end}", file=sys.stderr)
print("", file=sys.stderr)
print("或使用 Markdown 标题:", file=sys.stderr)
print(f"{cfg.field_heading}\n- ...\n\n{cfg.zh_heading}\n(中文摘要正文)", file=sys.stderr)
return 2
if args.json:
# Keep stdout strictly machine-readable.
sys.stdout.write(json.dumps(report, ensure_ascii=False))
sys.stdout.write("\n")
if args.strict and (not zh_ok or not en_ok or not title_report["ok"] or not field_report["ok"]):
return 1
return 0
print("Length Check (whitespace-collapsed)")
print(f"- Limits: ZH<= {cfg.zh_max}, EN<= {cfg.en_max}")
print(
f"- Markers: ZH {cfg.zh_begin}..{cfg.zh_end}; EN {cfg.en_begin}..{cfg.en_end}; FIELD {cfg.field_begin}..{cfg.field_end}"
)
print(f"- Headings: ZH {cfg.zh_heading}; EN {cfg.en_heading}; FIELD {cfg.field_heading}")
def _status(lang_report: dict) -> str:
if not lang_report.get("len_ok", True):
return "EXCEEDED"
if lang_report.get("punct_ok", True) is False:
return "INVALID_PUNCT"
if lang_report.get("num_ok", True) is False:
return "INVALID_NUM"
return "OK"
print(
f"- ZH: {report['zh']['len']}/{cfg.zh_max} ({_status(report['zh'])}) [raw={zh_raw_len}]"
+ (f" [exceeded={report['zh']['exceeded']}]" if args.diff else "")
)
if report["zh"].get("ascii_double_quotes", 0) > 0:
print(f"- ZH quotes: ASCII (\") = {report['zh']['ascii_double_quotes']} (请改为中文引号“...”)")
if report["zh"].get("numeric_commas", 0) > 0:
print(f"- ZH numbers: digit-comma-digit = {report['zh']['numeric_commas']} (请改为 1000 而不是 1,000)")
print(
f"- EN: {report['en']['len']}/{cfg.en_max} ({_status(report['en'])}) [raw={en_raw_len}]"
+ (f" [exceeded={report['en']['exceeded']}]" if args.diff else "")
)
if not args.no_title:
print(
f"- TITLE: {'OK' if title_report['ok'] else 'INVALID'} "
f"[required={title_required}, candidates={title_report['candidates']}/{min_cands}, "
f"recommended={title_report['has_recommended']}]"
)
if not args.no_field:
print(
f"- FIELD: {'OK' if field_report['ok'] else 'INVALID'} "
f"[required={field_required}, cjk={field_report.get('cjk_chars', 0)}/{cfg.field_max_chars}]"
)
if args.strict and (not zh_ok or not en_ok or not title_report["ok"] or not field_report["ok"]):
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
#!/usr/bin/env python3
"""
Write NSFC-ABSTRACTS.md in the current working directory.
Input formats supported:
1) Marker format: [ZH]...[/ZH] and [EN]...[/EN]
2) Markdown headings: "# 中文摘要" and "# English Abstract" (headings configurable)
This script is intentionally stdlib-only (no PyYAML).
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Tuple
def _extract_block_by_markers(text: str, begin: str, end: str) -> Optional[str]:
pattern = re.compile(re.escape(begin) + r"(.*?)" + re.escape(end), re.DOTALL)
m = pattern.search(text)
return m.group(1).strip() if m else None
def _extract_block_by_headings(text: str, zh_heading: str, en_heading: str) -> Optional[Tuple[str, str]]:
# Normalize line endings and keep original content lines.
lines = text.splitlines()
def _is_heading(line: str, heading: str) -> bool:
return line.strip() == heading.strip()
zh_i = None
en_i = None
for i, line in enumerate(lines):
if zh_i is None and _is_heading(line, zh_heading):
zh_i = i
continue
if en_i is None and _is_heading(line, en_heading):
en_i = i
continue
if zh_i is None or en_i is None:
return None
if zh_i > en_i:
# If swapped, treat as invalid format.
return None
zh_lines = lines[zh_i + 1 : en_i]
zh = "\n".join(zh_lines).strip()
# EN content ends at the next markdown heading (any level) if present,
# e.g. "## 长度自检" appended by this script itself.
en_lines = []
for line in lines[en_i + 1 :]:
if line.lstrip().startswith("#"):
break
en_lines.append(line)
en = "\n".join(en_lines).strip()
return zh, en
def _normalize_for_count(text: str) -> str:
return re.sub(r"\s+", " ", text).strip()
def _read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _is_safe_filename(name: str) -> bool:
"""
Ensure output stays in current working directory.
Reject absolute paths, parent segments, or nested paths like "a/b.md".
"""
s = (name or "").strip()
if not s or s in {".", ".."}:
return False
p = Path(s)
if p.is_absolute():
return False
if len(p.parts) != 1:
return False
if any(part in {".", ".."} for part in p.parts):
return False
return True
def _parse_bool(v: Optional[str], default: bool) -> bool:
if v is None:
return default
s = v.strip().lower()
if s in {"1", "true", "yes", "y", "on"}:
return True
if s in {"0", "false", "no", "n", "off"}:
return False
return default
@dataclass(frozen=True)
class SkillConfig:
zh_max: int
en_max: int
zh_begin: str
zh_end: str
en_begin: str
en_end: str
out_name: str
zh_heading: str
en_heading: str
title_required: bool
title_candidates_default: int
title_begin: str
title_end: str
title_heading: str
field_required: bool
field_max_chars: int
field_begin: str
field_end: str
field_heading: str
def _load_config(skill_root: Path) -> SkillConfig:
"""
Minimal config reader (no YAML deps). It searches for scalar keys by regex.
"""
config_path = skill_root / "config.yaml"
raw = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
def _strip_inline_comment(value: str) -> str:
v = value.lstrip()
if not v:
return v
if v[0] in {"\"", "'"}:
q = v[0]
out = []
escaped = False
for ch in v[1:]:
if escaped:
out.append(ch)
escaped = False
continue
if ch == "\\":
escaped = True
continue
if ch == q:
break
out.append(ch)
return "".join(out)
return v.split("#", 1)[0].rstrip()
def _get_scalar(key: str) -> Optional[str]:
m = re.search(rf"^[ \t]*{re.escape(key)}:[ \t]*(.+?)\s*$", raw, re.M)
if not m:
return None
return _strip_inline_comment(m.group(1)).strip()
def _int(key: str, default: int) -> int:
v = _get_scalar(key)
if v is None:
return default
return int(v) if v.isdigit() else default
def _str(key: str, default: str) -> str:
v = _get_scalar(key)
return v if v is not None and v != "" else default
zh_max = _int("zh_max_chars", 400)
en_max = _int("en_max_chars", 4000)
zh_begin = _str("zh_begin", "[ZH]")
zh_end = _str("zh_end", "[/ZH]")
en_begin = _str("en_begin", "[EN]")
en_end = _str("en_end", "[/EN]")
out_name = _str("filename", "NSFC-ABSTRACTS.md")
zh_heading = _str("zh_heading", "# 中文摘要")
en_heading = _str("en_heading", "# English Abstract")
title_required = _parse_bool(_get_scalar("title_required"), False)
title_candidates_default = _int("title_candidates_default", 5)
title_begin = _str("title_begin", "[TITLE]")
title_end = _str("title_end", "[/TITLE]")
title_heading = _str("title_heading", "# 标题建议")
field_required = _parse_bool(_get_scalar("field_required"), False)
field_max_chars = _int("field_max_chars", 25)
field_begin = _str("field_begin", "[FIELD]")
field_end = _str("field_end", "[/FIELD]")
field_heading = _str("field_heading", "# 主要研究领域")
return SkillConfig(
zh_max=zh_max,
en_max=en_max,
zh_begin=zh_begin,
zh_end=zh_end,
en_begin=en_begin,
en_end=en_end,
out_name=out_name,
zh_heading=zh_heading,
en_heading=en_heading,
title_required=title_required,
title_candidates_default=title_candidates_default,
title_begin=title_begin,
title_end=title_end,
title_heading=title_heading,
field_required=field_required,
field_max_chars=field_max_chars,
field_begin=field_begin,
field_end=field_end,
field_heading=field_heading,
)
def _extract_section_by_heading(text: str, heading: str) -> Optional[str]:
lines = text.splitlines()
def _is_heading(line: str, h: str) -> bool:
return line.strip() == h.strip()
start_i = None
for i, line in enumerate(lines):
if _is_heading(line, heading):
start_i = i
break
if start_i is None:
return None
body_lines = []
for line in lines[start_i + 1 :]:
if line.lstrip().startswith("#"):
break
body_lines.append(line)
body = "\n".join(body_lines).strip()
return body or None
def _extract_title_by_headings(text: str, title_heading: str, zh_heading: str) -> Optional[str]:
lines = text.splitlines()
def _is_heading(line: str, heading: str) -> bool:
return line.strip() == heading.strip()
t_i = None
zh_i = None
for i, line in enumerate(lines):
if t_i is None and _is_heading(line, title_heading):
t_i = i
continue
if zh_i is None and _is_heading(line, zh_heading):
zh_i = i
continue
if t_i is None:
return None
end_i = zh_i if (zh_i is not None and zh_i > t_i) else len(lines)
body = "\n".join(lines[t_i + 1 : end_i]).strip()
return body or None
def _parse_input(
text: str,
*,
title_begin: str,
title_end: str,
title_heading: str,
title_required: bool,
title_candidates_default: int,
zh_begin: str,
zh_end: str,
en_begin: str,
en_end: str,
field_begin: str,
field_end: str,
field_heading: str,
zh_heading: str,
en_heading: str,
) -> Tuple[Optional[str], str, str, Optional[str]]:
title = _extract_block_by_markers(text, title_begin, title_end)
zh = _extract_block_by_markers(text, zh_begin, zh_end)
en = _extract_block_by_markers(text, en_begin, en_end)
field = _extract_block_by_markers(text, field_begin, field_end)
if zh is not None and en is not None:
return title, zh, en, field
by_h = _extract_block_by_headings(text, zh_heading, en_heading)
if by_h is not None:
zh_h, en_h = by_h
title_h = _extract_title_by_headings(text, title_heading, zh_heading)
field_h = _extract_section_by_heading(text, field_heading)
return title_h, zh_h, en_h, field_h
if title_required:
raise ValueError(
"无法解析输入(标题+中英文摘要是必需的;“主要研究领域”是否必需取决于 config.yaml)。请使用以下任一格式:\n"
f"1) 标记格式:{title_begin}...{title_end} 与 {zh_begin}...{zh_end} 与 {en_begin}...{en_end}(可选:{field_begin}...{field_end})\n"
f"2) 标题格式:{title_heading} 与 {zh_heading} 与 {en_heading}(可选:{field_heading})\n"
f"并确保标题候选不少于 {title_candidates_default} 个,且包含“推荐标题:...”一行。"
)
raise ValueError(
"无法解析输入。请使用以下任一格式:\n"
f"1) 标记格式:{zh_begin}...{zh_end} 与 {en_begin}...{en_end}(可选:{field_begin}...{field_end})\n"
f"2) 标题格式:{zh_heading} 与 {en_heading}(可选:{field_heading})"
)
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter)
ap.add_argument("input", help="包含摘要内容的输入文件路径(或 - 表示 stdin)")
ap.add_argument("--out", default=None, help="输出 Markdown 文件名(默认取 config.yaml:output.filename)")
ap.add_argument("--strict", action="store_true", help="严格模式:超限等约束不满足则返回非 0")
ap.add_argument(
"--auto-compress",
action="store_true",
help="超限时自动压缩(占位:当前版本不执行压缩,仅提示并返回 1)",
)
ap.add_argument("--json", action="store_true", help="输出 JSON 报告(stdout 仅打印 JSON)")
args = ap.parse_args(argv)
skill_root = Path(__file__).resolve().parents[1]
cfg = _load_config(skill_root)
if args.input == "-":
raw = sys.stdin.read()
else:
raw = _read_text(Path(args.input))
title_raw, zh_raw, en_raw, field_raw = _parse_input(
raw,
title_begin=cfg.title_begin,
title_end=cfg.title_end,
title_heading=cfg.title_heading,
title_required=cfg.title_required,
title_candidates_default=cfg.title_candidates_default,
zh_begin=cfg.zh_begin,
zh_end=cfg.zh_end,
en_begin=cfg.en_begin,
en_end=cfg.en_end,
field_begin=cfg.field_begin,
field_end=cfg.field_end,
field_heading=cfg.field_heading,
zh_heading=cfg.zh_heading,
en_heading=cfg.en_heading,
)
zh = zh_raw.strip()
en = en_raw.strip()
title = (title_raw or "").strip()
field = (field_raw or "").strip()
# Reuse validator's counting semantics for consistency.
try:
import validate_abstract # type: ignore
report = validate_abstract.build_report(zh, en, zh_max=cfg.zh_max, en_max=cfg.en_max)
title_report = validate_abstract.build_title_report(
title_raw,
required=cfg.title_required,
min_candidates=cfg.title_candidates_default,
)
field_report = validate_abstract.build_field_report(field_raw, required=cfg.field_required, max_chars=cfg.field_max_chars)
except Exception:
# Fallback: keep behavior even if import fails for any reason.
zh_len = len(_normalize_for_count(zh))
en_len = len(_normalize_for_count(en))
zh_ascii_dq = zh.count("\"")
zh_numeric_commas = len(re.findall(r"(?<=\d)[,,](?=\d)", zh))
zh_punct_ok = zh_ascii_dq == 0
zh_num_ok = zh_numeric_commas == 0
zh_len_ok = zh_len <= cfg.zh_max
en_len_ok = en_len <= cfg.en_max
report = {
"zh": {
"len": zh_len,
"max": cfg.zh_max,
"exceeded": max(0, zh_len - cfg.zh_max),
"len_ok": zh_len_ok,
"ascii_double_quotes": zh_ascii_dq,
"numeric_commas": zh_numeric_commas,
"punct_ok": zh_punct_ok,
"num_ok": zh_num_ok,
"ok": zh_len_ok and zh_punct_ok and zh_num_ok,
},
"en": {
"len": en_len,
"max": cfg.en_max,
"exceeded": max(0, en_len - cfg.en_max),
"len_ok": en_len_ok,
"ok": en_len_ok,
},
}
title_report = {
"present": bool(title),
"required": bool(cfg.title_required),
"has_recommended": bool(re.search(r"(?m)^\s*推荐标题\s*[::]\s*\S+", title)),
"candidates": len(re.findall(r"(?m)^\s*(?:[-*]\s*)?\d+[\)\.、]\s+\S+", title)),
"min_candidates": int(cfg.title_candidates_default),
"ok": (not cfg.title_required)
or (
bool(title)
and bool(re.search(r"(?m)^\s*推荐标题\s*[::]\s*\S+", title))
and len(re.findall(r"(?m)^\s*(?:[-*]\s*)?\d+[\)\.、]\s+\S+", title)) >= int(cfg.title_candidates_default)
),
}
def _count_cjk(t: str) -> int:
return sum(
1 for ch in t
if '\u4e00' <= ch <= '\u9fff'
or '\u3400' <= ch <= '\u4dbf'
)
field_cjk = _count_cjk(field)
field_len_ok = (cfg.field_max_chars <= 0) or (field_cjk <= cfg.field_max_chars)
field_report = {
"present": bool(field),
"required": bool(cfg.field_required),
"cjk_chars": field_cjk,
"max_chars": cfg.field_max_chars,
"len_ok": field_len_ok,
"ok": ((not cfg.field_required) or bool(field)) and field_len_ok,
}
zh_ok = bool(report["zh"]["ok"])
en_ok = bool(report["en"]["ok"])
zh_len_ok = bool(report["zh"].get("len_ok", zh_ok))
en_len_ok = bool(report["en"].get("len_ok", en_ok))
zh_punct_ok = bool(report["zh"].get("punct_ok", True))
zh_ascii_dq = int(report["zh"].get("ascii_double_quotes", 0) or 0)
zh_num_ok = bool(report["zh"].get("num_ok", True))
zh_numeric_commas = int(report["zh"].get("numeric_commas", 0) or 0)
title_ok = bool(title_report.get("ok"))
field_ok = bool(field_report.get("ok"))
report["title"] = title_report
report["field"] = field_report
out_name = args.out or cfg.out_name
if not _is_safe_filename(out_name):
sys.stderr.write(f"[ERROR] 非法输出文件名:{out_name!r}(必须是当前目录下的单个文件名,不允许路径)。\n")
if args.json:
sys.stdout.write(json.dumps(report, ensure_ascii=False))
sys.stdout.write("\n")
return 2
out_path = Path.cwd() / out_name
if cfg.title_required and not title_ok:
sys.stderr.write(
"[ERROR] 缺少或不合格的标题建议分段(不写入)。\n"
f"- required: {cfg.title_required}\n"
f"- min candidates: {cfg.title_candidates_default}\n"
"修复提示:在输入中加入 [TITLE]...[/TITLE] 或 \"# 标题建议\" 分段,并包含“推荐标题:...”与不少于 5 个候选条目。\n"
)
if args.json:
sys.stdout.write(json.dumps(report, ensure_ascii=False))
sys.stdout.write("\n")
return 2
if cfg.field_required and not field_report.get("present"):
sys.stderr.write("[ERROR] 缺少“主要研究领域”分段(不写入)。\n")
sys.stderr.write(f"- required: {cfg.field_required}\n")
sys.stderr.write(f"修复提示:在输入中加入 {cfg.field_begin}...{cfg.field_end} 或 \"{cfg.field_heading}\" 分段。\n")
if args.json:
sys.stdout.write(json.dumps(report, ensure_ascii=False))
sys.stdout.write("\n")
return 2
# Hard constraints for CN abstract formatting: always refuse to write an invalid output file.
# Field length is always enforced (regardless of field_required) when field is present.
if field and not field_report.get("len_ok", True):
cjk_n = field_report.get("cjk_chars", 0)
max_n = field_report.get("max_chars", cfg.field_max_chars)
sys.stderr.write(f"[ERROR] “主要研究领域”超限:{cjk_n}/{max_n} 个汉字(不写入)。\n")
sys.stderr.write(f"修复提示:压缩至 {max_n} 个汉字以内的一句话。\n")
if args.json:
sys.stdout.write(json.dumps(report, ensure_ascii=False))
sys.stdout.write("\n")
return 1
if not zh_punct_ok:
sys.stderr.write(f"[ERROR] 中文摘要包含英文双引号(\\\")共 {zh_ascii_dq} 处(不写入)。\n")
sys.stderr.write("修复提示:将英文双引号替换为中文引号“...”,避免出现 \"...\"。\n")
if args.json:
sys.stdout.write(json.dumps(report, ensure_ascii=False))
sys.stdout.write("\n")
return 1
if not zh_num_ok:
sys.stderr.write(f"[ERROR] 中文摘要数字格式包含“数字,数字”共 {zh_numeric_commas} 处(不写入)。\n")
sys.stderr.write("修复提示:将 1,000 / 1,000 改为 1000(中文摘要通常不写千分位逗号)。\n")
if args.json:
sys.stdout.write(json.dumps(report, ensure_ascii=False))
sys.stdout.write("\n")
return 1
if (args.strict or args.auto_compress) and (not zh_len_ok or not en_len_ok):
# In strict mode, do not write an invalid output file.
mode = "strict" if args.strict else "auto-compress"
msg = (
f"[ERROR] 摘要超限({mode} 模式不写入):ZH {report['zh']['len']}/{cfg.zh_max};EN {report['en']['len']}/{cfg.en_max}\n"
f"- exceeded: ZH {report['zh']['exceeded']}, EN {report['en']['exceeded']}\n"
)
sys.stderr.write(msg)
if args.auto_compress:
sys.stderr.write(
"\n[HINT] --auto-compress 当前为占位参数:不会自动压缩。\n"
"请按 SKILL.md 的“字数超限处理”策略手工压缩后重试。\n"
)
if args.json:
sys.stdout.write(json.dumps(report, ensure_ascii=False))
sys.stdout.write("\n")
return 1
title_text = ""
if title:
title_text = f"{cfg.title_heading}\n\n{title}\n\n"
field_text = ""
if field:
field_text = f"{cfg.field_heading}\n\n{field}\n"
out_text = (
title_text
+ f"{cfg.zh_heading}\n\n{zh}\n"
+ f"{cfg.en_heading}\n\n{en}\n"
+ field_text
+ "## 长度自检\n"
+ f"- 中文摘要字符数:{report['zh']['len']}/{cfg.zh_max}\n"
+ f"- 英文摘要字符数:{report['en']['len']}/{cfg.en_max}\n"
)
out_path.write_text(out_text, encoding="utf-8")
if args.json:
# Keep stdout strictly machine-readable.
sys.stdout.write(json.dumps(report, ensure_ascii=False))
sys.stdout.write("\n")
print(f"[OK] Wrote: {out_path}", file=sys.stderr)
else:
print(f"[OK] Wrote: {out_path}")
print(f"- TITLE: {'OK' if title_ok else 'INVALID'}")
print(f"- FIELD: {'OK' if field_ok else 'INVALID'}")
zh_status = "OK"
if not zh_len_ok:
zh_status = "EXCEEDED"
elif not zh_punct_ok:
zh_status = "INVALID_PUNCT"
elif not zh_num_ok:
zh_status = "INVALID_NUM"
en_status = "OK" if en_len_ok else "EXCEEDED"
print(f"- ZH: {report['zh']['len']}/{cfg.zh_max} ({zh_status})")
print(f"- EN: {report['en']['len']}/{cfg.en_max} ({en_status})")
if args.strict and (not zh_ok or not en_ok):
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))