
Nsfc Qc
- 142 installs
- 2.6k repo stars
- Updated July 20, 2026
- huangwb8/chineseresearchlatex
Run quality checks on NSFC proposals for completeness, formatting, consistency, and common rejection risks before final submission.
About
Performs systematic quality control on NSFC grant proposals, checking sections, formatting, internal consistency, and frequent compliance issues so submissions meet foundation expectations and avoid preventable desk rejects.
- NSFC proposal QC checklist
- Formatting and completeness checks
- Consistency and compliance review
- Pre-submission defect detection
- Chinese research LaTeX standards
Nsfc Qc by the numbers
- 142 all-time installs (skills.sh)
- +4 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #581 of 1,879 Documentation 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-qcAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 142 |
|---|---|
| repo stars | ★ 2.6k |
| Last updated | July 20, 2026 |
| Repository | huangwb8/chineseresearchlatex ↗ |
What it does
Run quality checks on NSFC proposals for completeness, formatting, consistency, and common rejection risks before final submission.
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 仓库。
定位
- 只读 QC:不修改
.tex/.bib/.cls/.sty - 目标是产出标准化 QC 报告,而不是“顺手帮用户改文”
- 推荐布局:
deliver_dir/放交付物,project_root/.bensz-api/skills/nsfc-qc/{run_id}/放工作区
输入
最少需要:
project_root
建议同时提供:
main_tex,默认main.texthreads,默认读config.yamlexecution,默认serialdeliver_dir,推荐显式给,便于实例隔离
输出
标准交付物:
{run_dir}/final/nsfc-qc_report.md{run_dir}/final/nsfc-qc_metrics.json{run_dir}/final/nsfc-qc_findings.json{run_dir}/final/validation.json
常见预检产物:
precheck.jsoncitations_index.csvtex_lengths.csvreference_evidence.jsonlreference_evidence_summary.json
硬规则
- 禁止写入标书源文件。
- 文献真实性检查必须有证据链;不确定时标记
uncertain。 - 元数据获取是必选项:引用真伪核查必须联网抓取论文 metadata 并做 URL/title 比对。
nsfc-qc不负责正文改写;只负责发现问题与给出建议。
主流程
1. 定位 run 目录
- 优先用实例隔离布局:
deliver_dirworkspace_dir=project_root/.bensz-api/skills/nsfc-qc/{run_id}run_dir={workspace_dir}- 只有用户明确要求 legacy 或处理旧产物时才读取
project_root/.nsfc-qc/
2. 只读预检
- 自动检测主 tex
- 检查引用 key 是否存在
- 检查
.bib字段完整性 - 生成引用证据包:Crossref / arXiv / Unpaywall 等 metadata + URL 可访问性 + title 比对
- 输出篇幅分布、引号问题、缩略语与术语一致性初筛
3. 多线程独立 QC
- 优先用
parallel-vibe,并把.parallel-vibe/放到当前 run 内部 - snapshot 只包含最小必要副本:
*.tex/*.bib+ 预检证据包 - 每个 thread 至少覆盖:
- 文风与可读性
- 引用真伪与错引风险
- 篇幅与结构分布
- 逻辑与论证闭环
- 缩略语规范
- 术语一致性
- 至少 2 类其它 QC
4. 汇总聚合
- 主线程合并 threads 的
RESULT.md - 去重、冲突处理、按
P0/P1/P2排序 - 输出最小可执行修改路线
5. 标准化报告
最终报告必须包含:
1. 执行摘要 2. 范围与只读声明 3. 硬性问题(P0) 4. 重要建议(P1) 5. 可选优化(P2) 6. 引用核查清单 7. 篇幅与结构分布 8. 建议的最小修改路线图 9. 附录:复现信息
常用脚本
# 一键运行
python3 skills/nsfc-qc/scripts/nsfc_qc_run.py \
--project-root projects/NSFC_Young \
--main-tex main.tex \
--deliver-dir projects/NSFC_Young/QC/vYYYYMMDDHHMMSS \
--threads 5 \
--execution serial
# 预检
python3 skills/nsfc-qc/scripts/nsfc_qc_precheck.py --project-root projects/NSFC_Young --main-tex main.tex --out <artifacts_dir> --resolve-refs
# 并行 QC
python3 skills/nsfc-qc/scripts/run_parallel_qc.py --project-root projects/NSFC_Young --run-id vYYYYMMDDHHMMSS --threads 5 --execution serial
# 物化 final 输出
python3 skills/nsfc-qc/scripts/materialize_final_outputs.py --project-root projects/NSFC_Young --run-id vYYYYMMDDHHMMSS降级策略
- 若
parallel-vibe不可用,仍需完成同一套 QC 清单。 - 仍要输出标准化报告与 JSON。
- 在附录中说明未启用并行的原因。
非目标
nsfc-qc不是编译检查工具。- PDF 能否编译成功属于环境/工程质量,不是本技能的核心交付。
nsfc-qc 变更日志
本文档记录 nsfc-qc/ 的重要变更。格式遵循 Keep a Changelog。
[Unreleased]
Added(新增)
- 新增计划文档
plans/英文缩写检查-v202603080812.md:梳理nsfc-qc英文缩写检查的渲染顺序、全文唯一性与产物/文档同步优化方案,供后续实施参考。
Changed(变更)
- nsfc-qc v1.2.0 → v1.2.1:同步
parallel-vibe默认工作区目录变更 scripts/run_parallel_qc.py:说明与 snapshot 排除项改为新目录.parallel-vibe/,并继续排除 legacy.parallel_vibe/scripts/materialize_final_outputs.py:优先读取.parallel-vibe/,兼容读取旧.parallel_vibe/SKILL.md/README.md/templates/REPORT_TEMPLATE.md:同步复现路径与报告模板口径config.yaml:版本号更新至1.2.1
- nsfc-qc v1.1.0 → v1.2.0:英文缩写预检升级为“按 main.tex 实际渲染顺序 + 全文级缩写注册表”模型
scripts/nsfc_qc_precheck.py:新增 render stream / abbreviation registry 机制;首次出现按真实渲染顺序判断,不再按单文件整文件扫描;同一行缩写按实际列号顺序识别;新增late_definition、conflicting_english_full_name、conflicting_chinese_full、repeated_same_definition等 issue model- 新增工件:
abbreviation_registry.json、abbreviation_render_stream.jsonl scripts/materialize_final_outputs.py:final findings 改为分别聚合“首次引入问题 / 冲突定义 / 缺中文全称 / 重复同一定义”,不再只按旧的 P1/P2 粗分类汇总scripts/run_parallel_qc.py:thread prompt 改为消费 registry/render stream,并显式要求检查“冲突定义 / 定义滞后 / 重复同一定义”SKILL.md/README.md/references/qc_checklist.md/templates/REPORT_TEMPLATE.md:文档口径同步到新问题模型config.yaml:版本号更新至1.2.0
[1.1.0] - 2026-03-07
Changed(变更)
- 元数据获取强制启用:文献真实性检查现为 QC 核心功能,必须通过联网验证;移除
--no-resolve-refs选项,--resolve-refs默认启用且不可关闭。 scripts/nsfc_qc_precheck.py:增强_resolve_reference_evidence()函数:- 新增 URL 可访问性检查(HTTP HEAD 请求检查
.bib中url字段) - 新增 metadata 自动比对(bib title vs API title,支持 exact/fuzzy/mismatch 三级判断)
- 新增并发控制参数
max_concurrent(默认 5,避免 API 速率限制) - 输出增强:
reference_evidence.jsonl新增url_check和title_comparison字段;reference_evidence_summary.json新增url_checked/url_accessible/title_match_exact/title_match_fuzzy/title_mismatch统计 scripts/nsfc_qc_run.py、scripts/run_parallel_qc.py:移除--no-resolve-refs参数,新增--max-concurrent参数(默认 5)。config.yaml:版本号1.0.0 → 1.1.0;新增max_concurrent/unpaywall_email/fetch_pdf/timeout_s参数配置;更新 description 明确"元数据获取为必选项"。SKILL.md:- 硬约束新增"元数据获取为必选项"说明
- 预检清单更新:明确引用真伪检查为必选项,详细说明 URL 检查、title 比对、并发控制
- Thread 统一任务更新:引用真伪检查新增 URL 不可访问、title 不匹配等 P0/P1 问题类型,要求使用
url_check和title_comparison字段作为判断依据
Added(新增)
scripts/nsfc_qc_precheck.py:新增辅助函数_check_url_accessible()(URL 可访问性检查)、_normalize_title_for_comparison()(标题归一化)、_compare_titles()(标题相似度比对)。
[1.0.0] - 2026-02-24
Changed(变更)
config.yaml:版本号0.2.1 → 1.0.0,标记为正式稳定版本
[0.2.1] - 2026-02-22
Added(新增)
scripts/nsfc_qc_precheck.py:新增_detect_terminology_consistency()函数,启发式检测英文术语大小写/连字符不一致(如 "deep learning" vs "Deep Learning");输出terminology_issues.csv与terminology_issues_summary.json。SKILL.md:将"术语一致性"提升为 thread 统一任务的独立必检项(第 6 项),并补齐预检产物清单;原"其它 QC"降为第 7 项,要求从"至少 3 项"调整为"至少 2 项"。
Changed(变更)
scripts/run_parallel_qc.py:将terminology_issues.csv/terminology_issues_summary.json纳入 snapshot 证据包;在 thread prompt 中新增"术语一致性(必检,独立小节输出)"指令,要求在 RESULT.md 的「4) 可选优化(P2)」中输出### 术语一致性小节。config.yaml:版本号0.2.0 → 0.2.1。
[0.2.0] - 2026-02-21
Added(新增)
scripts/nsfc_qc_precheck.py:缩写预检新增abbreviation_issues_summary.json(便于 AI 快速消费的摘要 JSON)。SKILL.md:将“缩略语规范”提升为 thread 统一任务的独立必检项,并补齐预检产物清单。references/qc_checklist.md:新增“缩略语规范(独立章节)”,细化检查点与分级口径。
Changed(变更)
scripts/nsfc_qc_precheck.py:扩展缩写 stoplist(Fig/Tab/Sec 等),并增强误报过滤(清除\\label/\\ref/\\cite参数与\\begin/\\end环境名;过滤V2这类版本号 token)。scripts/run_parallel_qc.py:将abbreviation_issues_summary.json纳入 snapshot 证据包,并在 thread prompt 中要求独立输出“缩略语规范”小节(按文件/行号给出建议)。config.yaml:版本号0.1.9 → 0.2.0。
[0.1.9] - 2026-02-17
Changed(变更)
nsfc-qc:移除“编译是否成功/PDF 页数”相关流程与输出;skill 定位为“内容质量 QC”(标书写得怎么样),不再将编译成功与否作为 QC 结论的一部分。scripts/nsfc_qc_run.py、scripts/run_parallel_qc.py:移除--compile-last参数与相关逻辑。scripts/nsfc_qc_precheck.py:移除--compile与隔离编译逻辑,预检专注于引用/篇幅分布/排版与证据包。scripts/materialize_final_outputs.py、templates/REPORT_TEMPLATE.md:final 输出不再聚合 compile 信息;报告中“页数”改为提示用户自行编译核对。scripts/nsfc_qc_compile.py:保留为可选的手工调试脚本,但不再回填precheck.json/nsfc-qc_metrics.json。
[0.1.8] - 2026-02-17
Changed(变更)
scripts/run_parallel_qc.py:snapshot 由“全量拷贝项目目录”改为“仅拷贝*.tex/*.bib”(并跳过QC/等大目录),显著降低.nsfc-qc/中间产物体积与线程倍增开销。scripts/nsfc_qc_precheck.py、scripts/nsfc_qc_compile.py:隔离编译的 copytree ignore 列表加入QC/,避免把历史 QC 交付目录拷入compile/src导致体积膨胀。
[0.1.6] - 2026-02-17
Added(新增)
scripts/nsfc_qc_precheck.py:新增“全称与缩写规范”预检(启发式)。输出abbreviation_issues.csv,并在precheck.json中给出结构化统计与建议(首次出现建议“中文全称(English Full Name, ABBR)”;后文尽量仅用 ABBR)。scripts/run_parallel_qc.py:将abbreviation_issues.csv复制进 snapshot 的./.nsfc-qc/input/,供多线程 QC 只读引用;同时在 thread prompt 的“证据包”中显式提示该文件。
Changed(变更)
scripts/materialize_final_outputs.py:把缩写预检信号映射为确定性 findings(P1/P2),并纳入 metrics 的 precheck 聚合与 artifacts 索引。
[0.1.7] - 2026-02-17
Changed(变更)
scripts/run_parallel_qc.py:将 thread 的“证据包”目录统一到snapshot/.nsfc-qc/input/(位于.nsfc-qc/下),确保 snapshot 内的中间操作对用户更干净、更一致。
[0.1.5] - 2026-02-17
Changed(变更)
scripts/nsfc_qc_run.py:默认工作区从QC/{run_id}.nsfc-qc/调整为交付目录内的隐藏目录QC/{run_id}/.nsfc-qc/(更优雅,且更符合“交付目录内隐藏中间产物”的直觉)scripts/nsfc_qc_run.py:交付目录不再复制预检/编译等中间 artifacts(这些文件保留在工作区 run 目录下),交付目录只保留最终报告与结构化输出SKILL.md、README.md、config.yaml:同步更新默认产物布局说明与版本号
[0.1.4] - 2026-02-17
Added(新增)
scripts/nsfc_qc_run.py:新增“一键实例隔离”运行器,默认输出到QC/{run_id}/+ sidecar 工作区QC/{run_id}.nsfc-qc/,避免污染标书根目录scripts/validate_final_outputs.py:新增 final 输出一致性校验脚本(报告固定章节 + findings JSON 与表格行一致)final/validation.json:final 目录新增结构一致性校验结果(由materialize_final_outputs.py生成)
Changed(变更)
scripts/run_parallel_qc.py:支持--workspace-dir重定向所有中间产物;run_id 冲突自动后缀r1/r2...;parallel-vibe 不可用/plan-only 也会落盘 final 输出scripts/materialize_final_outputs.py:支持--run-dir模式;metrics/artifacts 路径改为相对 run_dir(更可搬运);把 precheck/compile 的确定性信号映射为“底线 findings”,并注入报告 P0/P1/P2 表格scripts/nsfc_qc_precheck.py、scripts/nsfc_qc_compile.py:compile.json 相关路径字段优先输出相对 out_dir(同时保留 *_abs),提升搬运一致性SKILL.md、README.md、config.yaml:更新默认产物布局与脚本用法说明
[0.1.2] - 2026-02-16
Added(新增)
scripts/nsfc_qc_precheck.py:新增直引号排版预检,检测"免疫景观"这类写法并输出quote_issues.csv与结构化统计(建议替换为 TeX 引号 ``免疫景观'')
Changed(变更)
scripts/materialize_final_outputs.py:metrics 聚合时纳入typography预检信息,并补齐quote_issues.csv产物索引SKILL.md/README.md/references/qc_checklist.md:补齐“中文双引号/直引号”排版检查项说明
[0.1.3] - 2026-02-16
Added(新增)
scripts/nsfc_qc_precheck.py:新增“引用证据包”硬编码抓取(题目/摘要/可选 OA PDF 片段)+ 标书内引用上下文提取,输出reference_evidence.jsonl/reference_evidence_summary.json,供 AI 做语义核查scripts/nsfc_qc_compile.py:新增“4 步法隔离编译”脚本(xelatex→bibtex→xelatex→xelatex),作为 QC 的最后一步执行,并回填 metrics
Changed(变更)
scripts/run_parallel_qc.py:默认先跑预检并把证据包复制到 snapshot 的./.nsfc-qc/input/,thread 可只读使用;可选--compile-last作为最后一步更新编译信息SKILL.md/README.md/references/qc_checklist.md:明确“引用真伪=硬编码证据 + AI 语义判断”“4 步法编译=QC 最后一步”
[0.1.1] - 2026-02-16
Changed(变更)
scripts/nsfc_qc_precheck.py:--compile在缺少 TeX 工具链时自动降级并记录missing_tools,不再崩溃scripts/run_parallel_qc.py:thread prompt 以 cwd 为根(project_root=.),避免引导绝对路径越界;移除用户目录硬编码,支持PARALLEL_VIBE_SCRIPT覆盖scripts/run_parallel_qc.py:snapshot 文件去写权限(目录保留可写),强化“只读 QC”约束落地SKILL.md:修正文档与产物命名不一致(tex_lengths.csv)
Added(新增)
scripts/materialize_final_outputs.py:确定性落盘标准化 final 输出骨架(report/metrics/findings),即使 threads 尚未运行也可生成
[0.1.0] - 2026-02-16
Added(新增)
- 新增
nsfc-qc:NSFC 标书只读质量控制 skill(多线程并行 QC + 标准化报告输出) SKILL.md:定义只读边界(不修改.tex/.bib/.cls/.sty)、中间文件归档到.nsfc-qc/、固定报告结构与 P0/P1/P2 分级config.yaml:提供默认参数(threads=5、execution=serial、page_limit_soft=30 等)与输出契约scripts/nsfc_qc_precheck.py:确定性预检(引用 key 完整性、粗略篇幅统计;可选隔离编译以估算页数)scripts/run_parallel_qc.py:在.nsfc-qc/内运行 parallel-vibe,并生成确定性 plan(各 thread 执行同一份 QC 清单)templates/:提供标准化报告模板与 findings JSON schemareferences/qc_checklist.md:给多线程 QC 的统一检查清单参考
# ================================
# nsfc-qc 默认配置(Single Source of Truth for version)
# ================================
skill_info:
name: nsfc-qc
version: 1.2.1
description: “NSFC 标书质量控制(只读):多线程检查文风生硬/引用真伪与一致性/篇幅与结构分布/逻辑清晰度等,输出标准化 QC 报告;缩写预检基于 main.tex 实际渲染顺序与全文级注册表检查定义滞后/冲突定义/重复同一定义;元数据获取为必选项;中间产物默认隔离到 project_root/.bensz-api/skills/nsfc-qc/{run_id}/(不污染标书根目录)”
category: writing
author: “Bensz Conan”
parameters:
project_root:
type: string
required: true
description: 标书项目根目录(如 projects/NSFC_Young 或你的标书目录)
main_tex:
type: string
required: false
default: main.tex
description: 主入口 tex 文件名(相对 project_root),默认 main.tex
threads:
type: integer
required: false
default: 5
description: parallel-vibe 线程数(默认 5,范围 1-9)
execution:
type: string
required: false
default: serial
allowed_values: [serial, parallel]
description: parallel-vibe 执行模式;默认 serial(串联),仅在用户明确要求并行时使用 parallel
runner_type:
type: string
required: false
default: codex
allowed_values: [codex, claude]
description: parallel-vibe runner.type(默认 codex;环境支持 claude 时可切换)
runner_profile:
type: string
required: false
default: deep
allowed_values: [fast, default, deep]
description: parallel-vibe runner.profile(默认 deep)
deliver_dir:
type: string
required: false
default: ""
description: 交付目录(面向人读)。为空则默认 `project_root/QC/{run_id}/`
workspace_dir:
type: string
required: false
default: ""
description: 工作区目录(中间产物 + 可复现)。为空则默认 `project_root/.bensz-api/skills/nsfc-qc/{run_id}/`
runs_root:
type: string
required: false
default: .bensz-api/skills/nsfc-qc
description: 运行目录根:未启用 workspace_dir 时相对 project_root(默认 .bensz-api/skills/nsfc-qc);启用 workspace_dir 时由主入口映射到精确 run 目录
max_concurrent:
type: integer
required: false
default: 5
description: 元数据获取时的最大并发请求数(默认 5,范围 1-10);用于避免 API 速率限制和 IP 封锁
unpaywall_email:
type: string
required: false
default: ""
description: Unpaywall API 所需的邮箱(可选,或通过环境变量 UNPAYWALL_EMAIL 设置)
fetch_pdf:
type: boolean
required: false
default: false
description: 是否尝试下载 OA PDF 并提取文本片段(默认 false)
timeout_s:
type: integer
required: false
default: 20
description: 网络请求超时时间(秒,默认 20)
guardrails:
# 只读:不允许修改标书内容(tex/bib 等)。所有中间写入必须在 .bensz-api/skills/nsfc-qc/ 下。
allowed_write_globs:
- ".bensz-api/skills/nsfc-qc/**"
- ".nsfc-qc/**"
- "QC/**"
- "**/*.nsfc-qc/**"
forbidden_write_globs:
- "**/*.tex"
- "**/*.bib"
- "**/*.cls"
- "**/*.sty"
output_contract:
# 标准输出(run_id 由脚本生成;run_dir 由 legacy 或 workspace 模式决定)
final_report: "{run_dir}/final/nsfc-qc_report.md"
final_metrics: "{run_dir}/final/nsfc-qc_metrics.json"
final_findings: "{run_dir}/final/nsfc-qc_findings.json"
nsfc-qc — 用户使用指南
本 README 面向使用者:如何触发并正确使用 nsfc-qc skill。 执行规范与硬性约束见 SKILL.md;默认参数见 config.yaml。
你会得到什么
对 NSFC 标书做只读质量控制(QC),输出一份可审核、可追溯的标准化 QC 报告(P0/P1/P2 分级):
- ✍️ 文风与可读性:生硬/模板味/冗长句 → 给“最小改写建议”(只写建议,不改稿)
- 📚 引用核查:假引/错引/缺失 bibkey/元信息异常 → 给证据链 + 复核路径
- 🧪 引用证据包(硬编码 + AI):先用脚本抓取“题目/摘要/(可选)OA PDF 片段”并提取标书引用上下文,再由 AI 做语义判断(不确定就标 uncertain)
- 📏 篇幅与结构:总页数(软约束 30 页)与章节分布是否失衡(建议性优化)
- 🧠 逻辑与论证链:是否闭环、是否跳步/歧义/概念偷换、关键对照与指标是否缺失
- 🧹 其它 QC:术语一致性、缩略语首次定义、图表/交叉引用、夸大措辞等
- 🧾 中文排版易错项(确定性预检):检测直引号
"免疫景观"这类写法,建议替换为 TeX 引号 ``免疫景观'' - 🔤 缩写规范(确定性预检):按
main.tex实际渲染顺序建立全文级缩写注册表,检查首次定义是否滞后、英文全称/中文解释是否冲突、同一定义是否重复展开
只读声明(重要)
nsfc-qc 不会修改你的标书内容(不改任何 .tex/.bib/.cls/.sty)。 默认使用“交付目录 + sidecar 工作区”隔离中间产物(避免污染标书根目录):
- 交付目录(面向人读):
{deliver_dir}/ - 工作区(面向复现/归档):
project_root/.bensz-api/skills/nsfc-qc/{run_id}/(包含 snapshot/.parallel-vibe/artifacts/final)
同时兼容 legacy:显式传入旧参数时仍可读取或写入 project_root/.nsfc-qc/。
快速开始(最推荐)
把下面这段原样发给 Codex:
请用 nsfc-qc 对 projects/NSFC_Young 做一次质量控制(只读)。要求:
- 开 5 个 thread(默认串联模式)
- 每个 thread 做同一份 QC 清单(文风/引用/篇幅/结构/逻辑等)
- 汇总输出标准化 QC 报告(P0/P1/P2)
- 严禁修改标书任何内容;只输出报告与建议如果你更偏好“一键落盘到 QC 实例目录(含 sidecar 工作区)”,也可以直接运行脚本(推荐):
python3 skills/nsfc-qc/scripts/nsfc_qc_run.py \
--project-root projects/NSFC_Young \
--main-tex main.tex \
--deliver-dir projects/NSFC_Young/QC/YYYY-MM-DD-HH-MM \
--threads 5 \
--execution serial常见用法(Prompt 模板)
1) 指定主入口 tex
请用 nsfc-qc 检查 projects/NSFC_Young,主文件是 main.tex(只读)。输出标准化 QC 报告。2) 重点核查“引用真伪/错引”(更严格)
请用 nsfc-qc 对 projects/NSFC_Young 做 QC(只读),并把“引用真伪/错引风险”作为最高优先级:
- P0:缺失 bibkey / 明显虚构条目 / 明显错引
- P1:疑似错引或支撑弱(不确定就标 uncertain 并给复核路径)3) 你明确要求并行(否则默认串联)
请用 nsfc-qc 对 projects/NSFC_Young 做 QC(只读):5 threads,并行跑(最多同时 3 个)。输出文件(你会在磁盘上看到什么)
每次运行会创建一个 run 目录(run_id 为时间戳)。推荐布局是:
- 交付目录:
QC/{run_id}/ - 工作区:
project_root/.bensz-api/skills/nsfc-qc/{run_id}/ - run 目录:
project_root/.bensz-api/skills/nsfc-qc/{run_id}/
| 产物 | 路径(相对 project_root) | 说明 |
|---|---|---|
| 最终报告 | QC/{run_id}/nsfc-qc_report.md | 人类可读,含 P0/P1/P2 与路线图 |
| 指标 | QC/{run_id}/nsfc-qc_metrics.json | 字符数/引用统计/预检信号聚合等 |
| 结构化问题清单 | QC/{run_id}/nsfc-qc_findings.json | 便于后续人工审核或二次处理 |
| 结构一致性校验 | QC/{run_id}/validation.json | report 与 findings JSON 的一致性校验 |
| 工作区 run 目录 | project_root/.bensz-api/skills/nsfc-qc/{run_id}/ | 完整可复现数据(snapshot/artifacts/final/.parallel-vibe 等都在这里) |
补充说明:
nsfc-qc_metrics.json的schema_version当前为 2(artifacts 路径以 run_dir 为基准的相对路径输出,便于搬运复现)。
设计理念(为什么这样做)
- 只读:QC 报告通常要进一步审核;“先报告、后改稿”更可控。
- 中间产物隔离:所有过程文件集中到
.bensz-api/skills/nsfc-qc/,不污染标书工程。 - 多线程独立:同一份清单多视角复核,减少漏检;最后再聚合去重。
- 确定性优先:能用脚本做的(引用 key 缺失、篇幅统计、引用证据包抓取)先脚本做,降低 AI 幻觉风险。
- 渲染顺序优先:缩写首次出现按
main.tex的真实展开顺序判断,不按文件名顺序猜测。 - 不做编译检查:
nsfc-qc只关注“标书写得怎么样”(内容质量);编译是否成功属于环境/工程质量,请在你的 TeX/Overleaf 环境自行验证。 - 中文排版先扫雷:直引号等“看起来没错但不规范/不美观”的问题,先确定性列出再人工改。
WHICHMODEL
最后更新:2026-02-16
覆盖厂商:Anthropic、OpenAI
适用前提:你使用 parallel-vibe 跑多 thread QC(或由 Codex/Claude CLI 驱动)。一句话选择
- 引用真伪/错引 + 逻辑闭环这类“高歧义、要证据链”的 QC:优先用更强推理/更高能力的模型(必要时牺牲速度/成本)。OpenAI 的建议是:复杂、模糊、需要大量判断的任务更适合其 reasoning 系列模型。
参考:OpenAI《Reasoning best practices》。https://platform.openai.com/docs/guides/reasoning-best-practices
- 工具/多步骤工作流(含文件扫描、生成结构化报告):Anthropic 的建议是:复杂工具与歧义查询优先用 Opus/Sonnet,简单直接任务可用 Haiku。
参考:Anthropic《Tool use / Choosing a model》。https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/implement-tool-use
实操建议:优先用 runner_profile=fast|default|deep 表达“强度档位”,避免在 README 里硬编码具体 model_id(模型名与可用性会随时间/账号/环境变化)。
按 nsfc-qc 的阶段选模型
| 阶段 | 你在做什么 | 推荐模型倾向 | 取舍理由(证据导向) |
|---|---|---|---|
| 预检(脚本) | 引用 key/篇幅/(可选)页数 | 不需要 LLM | 确定性检查更可靠;优先脚本 |
| 单个 thread QC | 找问题、给证据链与建议 | 中高能力(Sonnet/Opus;或 OpenAI 高能力/推理模型) | 需要“少误报 + 能解释证据链” |
| 汇总聚合(synthesis) | 去重、冲突消解、形成路线图 | 最强能力优先(Opus;或 OpenAI 更强推理/旗舰) | 需要稳健决策与一致性 |
Anthropic 的通用选型法(强证据)
Anthropic 官方建议从能力/速度/成本三者权衡,并提供两种常见起步策略: 1) 先用更快更省的模型快速迭代,不够再升级;2) 复杂任务从最强模型起步,再逐步降档优化。 参考:Anthropic《Choosing the right model》。https://docs.anthropic.com/en/docs/about-claude/models/choosing-a-model
OpenAI 的通用选型法(强证据)
OpenAI 将模型区分为 reasoning 与非 reasoning(GPT)两类,并强调:reasoning 更适合复杂规划/决策/歧义信息处理;同时建议对 reasoning 模型“提示词更直接、更少花活”。 参考:OpenAI《Reasoning best practices》。https://platform.openai.com/docs/guides/reasoning-best-practices
备选用法(脚本/硬编码流程)
如果你想先做一次“确定性预检”,再让 AI 去做深度 QC:
1) 预检(只写入 .bensz-api/skills/nsfc-qc/;包含“引用证据包”)
# 在 repo 根目录运行
python3 skills/nsfc-qc/scripts/nsfc_qc_precheck.py \
--project-root projects/NSFC_Young \
--main-tex main.tex \
--out projects/NSFC_Young/.bensz-api/skills/nsfc-qc/YYYY-MM-DD-HH-MM/artifacts \
--resolve-refs2) 运行 parallel-vibe(只写入 .bensz-api/skills/nsfc-qc/)
# 生成 snapshot + plan,并把 parallel-vibe 产物落在 projects/NSFC_Young/.bensz-api/skills/nsfc-qc/ 下
python3 skills/nsfc-qc/scripts/run_parallel_qc.py \
--project-root projects/NSFC_Young \
--run-id YYYY-MM-DD-HH-MM \
--threads 5 \
--execution serial3) 生成标准化 final 输出骨架(只写入 .bensz-api/skills/nsfc-qc/)
# 即使 threads 尚未运行,也可以先把标准输出文件落盘(供后续人工/AI 填充与审核)
python3 skills/nsfc-qc/scripts/materialize_final_outputs.py \
--project-root projects/NSFC_Young \
--run-id vYYYYMMDDHHMMSSFAQ
Q1:它真的不会修改我的标书吗?
A:不会。本 skill 的设计边界是“只读 QC”:只产出报告与建议,不允许修改 .tex/.bib/.cls/.sty。
Q2:为什么默认串联跑 threads?
A:串联更省资源、降低限流/失败率;只有你明确要求并行时才建议并行跑。
Q3:如果没有 parallel-vibe 或 runner CLI 怎么办?
A:会降级为单线程 QC,但仍输出同样的标准化报告,并在附录说明无法并行的原因。
nsfc-qc 检查清单(参考)
用途:给 nsfc-qc 的每个 thread 一个“同一份”可执行检查清单,避免漏项。注意:这是参考材料;最终以 SKILL.md 的硬约束与交付格式为准。文风与可读性
- 生硬句/机器翻译味/模板味:是否能像“领域专家”自然叙述?
- 冗长句:是否可拆句(主谓宾清晰)?
- 堆砌形容词/空话:是否可压缩为可检验事实?
- 绝对化/夸大措辞:首次/领先/填补空白/突破性等是否有证据支撑?
引用真伪与错引风险
- 引用 key 是否存在于
.bib(缺失必为 P0) .bib元信息是否完整(title/author/year/venue/doi/url)- 硬编码证据包是否完整:能否取到题目/摘要(或 OA PDF 片段);标书引用上下文是否可定位
- 正文断言是否“明显超出”引用能支持的范围(错引风险)
- 是否存在“看起来像占位符”的条目(例如 author=xxx、title=TBD)
篇幅与结构分布
- 总体:是否明显过短/过长(页数优先;无 PDF 时用字符数近似)
- 比例:立项依据/研究内容/研究基础三者是否失衡
- 重复:是否同一段落多处重复(不同章节重复论述)
逻辑与论证链
- 科学问题 → 假说 → 目标 → 子目标 → 方法 → 验证指标/对照 → 预期结果:是否闭环
- 是否存在跳步(直接给结论但无证据/无验证方案)
- 关键概念是否定义清楚,是否出现概念偷换/口径漂移
缩略语规范(独立章节)
- 首次出现位置是否按
main.tex的真实渲染顺序理解,而不是按文件名/扫描顺序猜测 - 重要专业术语首次出现:是否同时给出中文全称 + 英文全称 + 英文缩写
- 标准格式:
肿瘤微环境(Tumor Microenvironment, TME) - 仅有英文全称无中文:P1(重要建议)
- 仅有缩写无任何全称:P1(重要建议)
- 先裸用、后定义(late definition):P1(重要建议)
- 仅缺中文全称:P2(可选优化)
- 全文级唯一性:同一缩写是否出现多个英文全称 / 多个中文解释(冲突定义为 P1)
- 后文是否重复展开同一定义(同一缩写多次出现相同“全称(缩写)”):P2
- 领域常识缩写豁免(无需定义):DNA、RNA、PCR、ATP 等生物化学基础词;AI 线程自行判断
- 误报过滤:LaTeX 标签(
fig:ABC)、数学变量、图表编号不是缩写
一致性与格式
- 术语一致性:同一概念是否多种叫法(建议统一)
- 图表/公式:是否引用齐全(ref/label),是否有 caption
- 交叉引用:是否可能存在“未定义引用/空引用”
- 中文排版:是否误用直引号
"免疫景观"(建议用 TeX 引号 ``免疫景观'' 或中文引号“免疫景观”并保持全稿一致)
#!/usr/bin/env python3
"""
Materialize nsfc-qc standard outputs under:
<run_dir>/final/
This script is deterministic and safe:
- It never modifies proposal sources.
- It only writes/overwrites files under the run's final/ directory.
It can be run even if parallel threads were not executed yet.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, List, Optional, Tuple
def _read_json(path: Path) -> Optional[dict]:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
def _write_json(path: Path, obj: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(obj, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def _find_parallel_vibe_root(run_dir: Path) -> Optional[Path]:
for name in (".parallel-vibe", ".parallel_vibe"):
pv = run_dir / name
if pv.exists() and pv.is_dir():
return pv
return None
def _collect_thread_results(run_dir: Path) -> List[str]:
pv = _find_parallel_vibe_root(run_dir)
if not pv:
return []
results: List[str] = []
for p in pv.rglob("RESULT.md"):
try:
results.append(str(p.relative_to(run_dir)))
except Exception:
results.append(str(p))
return sorted(set(results))
def _load_report_template() -> Optional[str]:
# Resolve template relative to this script.
skill_root = Path(__file__).resolve().parents[1]
tpl = skill_root / "templates" / "REPORT_TEMPLATE.md"
try:
return tpl.read_text(encoding="utf-8")
except Exception:
return None
REQUIRED_HEADINGS = [
"执行摘要",
"范围与只读声明",
"硬性问题(P0)",
"重要建议(P1)",
"可选优化(P2)",
"引用核查清单",
"篇幅与结构分布",
"建议的最小修改路线图",
"附录:复现信息(命令/路径/产物索引)",
]
def _safe_rel_from(base: Path, p: Path) -> str:
try:
return str(p.resolve().relative_to(base.resolve()))
except Exception:
return str(p)
def _mk_finding(
*,
fid: str,
severity: str,
category: str,
path: str,
anchor: str,
problem: str,
evidence: List[dict],
recommendation: str,
status: str,
) -> dict:
return {
"id": fid,
"severity": severity,
"category": category,
"location": {"path": path, "anchor": anchor},
"problem": problem,
"evidence": evidence,
"recommendation": recommendation,
"status": status,
}
def _deterministic_findings(*, precheck: dict, artifacts: dict) -> List[dict]:
"""
Convert deterministic precheck signals into a baseline findings list.
This provides a "won't be wrong" floor even if AI threads are unavailable.
"""
out: List[dict] = []
citation_stats = precheck.get("citation_stats") or {}
missing_n = int(citation_stats.get("missing_bibkeys") or 0)
missing_list = citation_stats.get("missing_bibkeys_list") or []
incomplete_n = int(citation_stats.get("incomplete_bib_entries") or 0)
incomplete_list = citation_stats.get("incomplete_bibkeys_list") or []
# P0: missing bibkeys
if missing_n > 0:
keys_preview = ", ".join([str(x) for x in missing_list[:20]])
detail = f"missing_bibkeys={missing_n}; preview={keys_preview}"
out.append(
_mk_finding(
fid="P0-001",
severity="P0",
category="citation",
path="",
anchor="precheck.citation_stats",
problem=f"存在缺失的 bibkey(引用 key 在 .bib 中找不到):{missing_n} 个。",
evidence=[
{"type": "metric", "detail": detail},
{"type": "note", "detail": f"see `{artifacts.get('citations_index_csv','')}` for occurrences preview"},
],
recommendation="补齐缺失 bib 条目或修正 \\cite{...} 中的 bibkey;缺失 bibkey 视为阻塞性问题(P0)。",
status="open",
)
)
# P1: incomplete bib entries (best-effort)
if incomplete_n > 0:
keys_preview = ", ".join([str(x) for x in incomplete_list[:20]])
out.append(
_mk_finding(
fid="P1-001",
severity="P1",
category="citation",
path="",
anchor="precheck.citation_stats",
problem=f"存在明显不完整的 BibTeX 条目(缺 title/author/year 等):{incomplete_n} 个。",
evidence=[{"type": "metric", "detail": f"incomplete_bib_entries={incomplete_n}; preview={keys_preview}"}],
recommendation="优先补齐被引用频次高的条目字段(title/author/year/doi/journal);避免占位符/缺失字段导致评审印象扣分。",
status="needs_human_review",
)
)
# P1: straight quotes with CJK (typography)
typography = precheck.get("typography") or {}
q = (typography.get("straight_double_quotes_with_cjk") or {}) if isinstance(typography, dict) else {}
qn = int(q.get("count") or 0)
if qn > 0:
out.append(
_mk_finding(
fid="P1-002",
severity="P1",
category="style",
path="",
anchor="precheck.typography",
problem=f"检测到中文内容中使用直引号 \"...\":{qn} 处(可能影响中文排版与一致性)。",
evidence=[{"type": "note", "detail": f"see `{artifacts.get('quote_issues_csv','')}` for preview"}],
recommendation="将中文语境的直引号替换为 TeX 引号:``...''(或按模板规范统一)。",
status="open",
)
)
# P1/P2: abbreviation conventions (render-order + whole-document registry)
abbr = precheck.get("abbreviation_conventions") or {}
abbr_sum = (abbr.get("summary") or {}) if isinstance(abbr, dict) else {}
by_sev = (abbr_sum.get("issues_by_severity") or {}) if isinstance(abbr_sum, dict) else {}
by_kind = (abbr_sum.get("issues_by_kind") or {}) if isinstance(abbr_sum, dict) else {}
abbr_p1 = int(by_sev.get("P1") or 0)
abbr_p2 = int(by_sev.get("P2") or 0)
first_use_p1 = sum(
int(by_kind.get(key) or 0)
for key in ("bare_first_use", "late_definition", "missing_english_full")
)
conflicts_p1 = sum(
int(by_kind.get(key) or 0)
for key in ("conflicting_english_full_name", "conflicting_chinese_full")
)
missing_chinese_p2 = int(by_kind.get("missing_chinese_full") or 0)
repeated_same_p2 = int(by_kind.get("repeated_same_definition") or by_kind.get("repeated_expansion") or 0)
if first_use_p1 > 0:
out.append(
_mk_finding(
fid="P1-003",
severity="P1",
category="style",
path="",
anchor="precheck.abbreviation_conventions",
problem=(
f"检测到缩写首次引入/定义时机问题:P1 级 {first_use_p1} 项"
"(按 main.tex 实际渲染顺序判定,需人工复核)。"
),
evidence=[
{"type": "note", "detail": f"see `{artifacts.get('abbreviation_issues_summary_json','')}` for quick summary"},
{"type": "note", "detail": f"see `{artifacts.get('abbreviation_issues_csv','')}` for line-level preview"},
],
recommendation=(
"一般建议:重要概念首次出现写为“中文全称(English Full Name, ABBR)”;"
"若存在 late_definition,应把定义前移到第一次出现处。"
),
status="needs_human_review",
)
)
if conflicts_p1 > 0:
out.append(
_mk_finding(
fid="P1-004",
severity="P1",
category="consistency",
path="",
anchor="precheck.abbreviation_conventions",
problem=f"检测到同一缩写在全文中出现冲突定义:P1 级 {conflicts_p1} 项。",
evidence=[
{"type": "note", "detail": f"see `{artifacts.get('abbreviation_registry_json','')}` for whole-document registry"},
{"type": "note", "detail": f"see `{artifacts.get('abbreviation_issues_csv','')}` for conflicting locations"},
],
recommendation="同一缩写应统一对应唯一的英文全称/中文解释;建议以首次定义为准,最小化修订冲突位置。",
status="needs_human_review",
)
)
if missing_chinese_p2 > 0:
out.append(
_mk_finding(
fid="P2-003",
severity="P2",
category="style",
path="",
anchor="precheck.abbreviation_conventions",
problem=f"检测到首次定义缺中文全称的可选优化项:P2 级 {missing_chinese_p2} 项。",
evidence=[
{"type": "note", "detail": f"see `{artifacts.get('abbreviation_issues_summary_json','')}` for quick summary"},
{"type": "note", "detail": f"see `{artifacts.get('abbreviation_issues_csv','')}` for line-level preview"},
],
recommendation="按重要性逐条处理:若该缩写为评审不一定熟悉的关键术语,首次定义建议同时补上中文全称。",
status="open",
)
)
if repeated_same_p2 > 0:
out.append(
_mk_finding(
fid="P2-004",
severity="P2",
category="style",
path="",
anchor="precheck.abbreviation_conventions",
problem=f"检测到同一定义的重复展开:P2 级 {repeated_same_p2} 项。",
evidence=[
{"type": "note", "detail": f"see `{artifacts.get('abbreviation_registry_json','')}` for repeated-definition chains"},
{"type": "note", "detail": f"see `{artifacts.get('abbreviation_issues_csv','')}` for line-level preview"},
],
recommendation="保留首次完整定义,后文尽量直接使用缩写,避免重复展开影响篇幅与一致性。",
status="open",
)
)
return out
def _render_md_table_rows(findings: List[dict], *, severity: str) -> List[str]:
rows: List[str] = []
items = [f for f in findings if str(f.get("severity")) == severity]
if severity in ("P0", "P1"):
for f in items:
loc = f.get("location") or {}
loc_s = (loc.get("path") or "").strip()
anchor = (loc.get("anchor") or "").strip()
if loc_s and anchor:
loc_s = f"{loc_s}#{anchor}"
elif anchor and not loc_s:
loc_s = anchor
evidence = f.get("evidence") or []
ev_lines: List[str] = []
for e in evidence[:2]:
ev_lines.append(str(e.get("detail") or "").strip())
ev = "<br>".join([x for x in ev_lines if x]) or ""
rows.append(
"| {id} | {loc} | {prob} | {ev} | {rec} |".format(
id=str(f.get("id") or ""),
loc=loc_s,
prob=str(f.get("problem") or ""),
ev=ev,
rec=str(f.get("recommendation") or ""),
)
)
else:
for f in items:
loc = f.get("location") or {}
loc_s = (loc.get("path") or "").strip()
anchor = (loc.get("anchor") or "").strip()
if loc_s and anchor:
loc_s = f"{loc_s}#{anchor}"
elif anchor and not loc_s:
loc_s = anchor
rows.append(
"| {id} | {loc} | {prob} | {rec} |".format(
id=str(f.get("id") or ""),
loc=loc_s,
prob=str(f.get("problem") or ""),
rec=str(f.get("recommendation") or ""),
)
)
if not rows:
if severity in ("P0", "P1"):
rows = ["| - | | | | |"]
else:
rows = ["| - | | | |"]
return rows
def _inject_table_rows(report_md: str, *, section_title: str, rows: List[str]) -> str:
lines = report_md.splitlines()
heading = f"## {section_title}".strip()
try:
hi = next(i for i, ln in enumerate(lines) if ln.strip() == heading)
except StopIteration:
return report_md
# Find table header after heading.
th = None
for i in range(hi + 1, min(hi + 80, len(lines))):
if lines[i].lstrip().startswith("| ID |"):
th = i
break
if th is None or th + 1 >= len(lines):
return report_md
sep = th + 1
if not lines[sep].lstrip().startswith("|---"):
return report_md
body_start = sep + 1
body_end = body_start
for j in range(body_start, len(lines)):
if lines[j].strip() == "":
body_end = j
break
if lines[j].startswith("## "):
body_end = j
break
else:
body_end = len(lines)
new_lines = lines[:body_start] + rows + lines[body_end:]
return "\n".join(new_lines) + "\n"
def _validate_report_and_findings(report_text: str, findings_obj: dict) -> Tuple[bool, List[str]]:
errors: List[str] = []
for h in REQUIRED_HEADINGS:
if f"## {h}" not in report_text:
errors.append(f"missing_heading:{h}")
findings = findings_obj.get("findings") or []
for f in findings:
fid = str(f.get("id") or "").strip()
if not fid:
errors.append("finding_missing_id")
continue
if f"| {fid} |" not in report_text:
errors.append(f"missing_finding_in_report:{fid}")
return (len(errors) == 0), errors
def _patch_report_warning(report_path: Path, *, errors: List[str]) -> None:
try:
s = report_path.read_text(encoding="utf-8")
except Exception:
return
if "⚠️ 结构校验失败" in s:
return
lines = s.splitlines()
ins = "> ⚠️ 结构校验失败:请先修复报告结构/表格与 findings JSON 的一致性,再作为最终交付。\n"
ins += "> errors: " + ", ".join(errors[:12]) + ("\n" if len(errors) > 12 else "\n")
for i, ln in enumerate(lines):
if ln.startswith("# "):
lines = lines[: i + 1] + ["", ins.rstrip()] + lines[i + 1 :]
break
report_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--run-dir", default="", help="preferred: the run directory (contains artifacts/final/snapshot)")
ap.add_argument("--project-root", default="", help="optional; used for metadata only")
ap.add_argument("--run-id", default="", help="legacy mode: resolve run_dir from project_root + runs_root + run_id")
ap.add_argument("--runs-root", default=".bensz-api/skills/nsfc-qc", help="legacy mode: relative to project-root")
ap.add_argument("--deliver-dir", default="", help="optional; for report metadata only")
ap.add_argument("--overwrite", action="store_true", help="overwrite existing final outputs")
args = ap.parse_args()
project_root_resolved = Path(args.project_root).expanduser().resolve() if str(args.project_root or "").strip() else None
if str(args.run_dir or "").strip():
run_dir = Path(args.run_dir).expanduser().resolve()
run_id = run_dir.name
else:
# Legacy mode
if not project_root_resolved:
print("error: either --run-dir or --project-root must be provided", file=sys.stderr)
return 2
run_id = str(args.run_id).strip()
if Path(run_id).name != run_id or ("/" in run_id) or ("\\" in run_id):
print("error: --run-id must be a simple name (no path separators)", file=sys.stderr)
return 2
runs_root = Path(args.runs_root)
if runs_root.is_absolute() or ".." in runs_root.parts:
print("error: --runs-root must be a relative path without '..'", file=sys.stderr)
return 2
if not runs_root.parts or runs_root.parts[0] not in {".bensz-api", ".nsfc-qc"}:
print("error: --runs-root must start with .bensz-api/ or .nsfc-qc/ to keep artifacts isolated", file=sys.stderr)
return 2
run_dir = (project_root_resolved / runs_root / run_id).resolve()
final_dir = run_dir / "final"
artifacts_dir = run_dir / "artifacts"
final_dir.mkdir(parents=True, exist_ok=True)
precheck = _read_json(artifacts_dir / "precheck.json") or {}
run_meta = _read_json(artifacts_dir / "run_meta.json") or {}
main_tex = str(run_meta.get("main_tex") or precheck.get("main_tex") or "main.tex")
execution = str(run_meta.get("execution") or "")
thread_results = _collect_thread_results(run_dir)
pv_root = _find_parallel_vibe_root(run_dir)
pv_main_summary = ""
if pv_root:
for p in pv_root.rglob("@main/summary.md"):
try:
pv_main_summary = str(p.relative_to(run_dir))
except Exception:
pv_main_summary = str(p)
break
generated_at = datetime.now().isoformat(timespec="seconds")
artifacts_rel = {
"precheck_json": _safe_rel_from(run_dir, artifacts_dir / "precheck.json") if (artifacts_dir / "precheck.json").exists() else "",
"citations_index_csv": _safe_rel_from(run_dir, artifacts_dir / "citations_index.csv") if (artifacts_dir / "citations_index.csv").exists() else "",
"tex_lengths_csv": _safe_rel_from(run_dir, artifacts_dir / "tex_lengths.csv") if (artifacts_dir / "tex_lengths.csv").exists() else "",
"quote_issues_csv": _safe_rel_from(run_dir, artifacts_dir / "quote_issues.csv") if (artifacts_dir / "quote_issues.csv").exists() else "",
"abbreviation_issues_csv": _safe_rel_from(run_dir, artifacts_dir / "abbreviation_issues.csv") if (artifacts_dir / "abbreviation_issues.csv").exists() else "",
"abbreviation_issues_summary_json": _safe_rel_from(run_dir, artifacts_dir / "abbreviation_issues_summary.json") if (artifacts_dir / "abbreviation_issues_summary.json").exists() else "",
"abbreviation_registry_json": _safe_rel_from(run_dir, artifacts_dir / "abbreviation_registry.json") if (artifacts_dir / "abbreviation_registry.json").exists() else "",
"abbreviation_render_stream_jsonl": _safe_rel_from(run_dir, artifacts_dir / "abbreviation_render_stream.jsonl") if (artifacts_dir / "abbreviation_render_stream.jsonl").exists() else "",
"reference_evidence_jsonl": _safe_rel_from(run_dir, artifacts_dir / "reference_evidence.jsonl") if (artifacts_dir / "reference_evidence.jsonl").exists() else "",
"reference_evidence_summary_json": _safe_rel_from(run_dir, artifacts_dir / "reference_evidence_summary.json") if (artifacts_dir / "reference_evidence_summary.json").exists() else "",
"parallel_vibe_summary": pv_main_summary,
"thread_results": thread_results,
}
metrics = {
"schema_version": 2,
"run_id": run_id,
"generated_at": generated_at,
"project_root": str(project_root_resolved) if project_root_resolved else str(run_meta.get("project_root") or precheck.get("project_root") or ""),
"deliver_dir": str(Path(args.deliver_dir).expanduser().resolve()) if str(args.deliver_dir or "").strip() else "",
"main_tex": main_tex,
"threads": run_meta.get("threads"),
"execution": execution,
"paths_base": {
"run_dir": ".",
"artifacts_dir": "artifacts",
"final_dir": "final",
"snapshot_dir": "snapshot",
},
"precheck": {
"citation_stats": (precheck.get("citation_stats") or {}),
"typography": (precheck.get("typography") or {}),
"abbreviation_conventions": (precheck.get("abbreviation_conventions") or {}),
},
"artifacts": artifacts_rel,
"status": {
"threads_detected": bool(thread_results),
"note": "This file is a deterministic aggregation of artifacts; findings are produced by QC threads and/or human review.",
},
}
det_findings = _deterministic_findings(
precheck=precheck,
artifacts=artifacts_rel,
)
findings = {
"schema_version": 1,
"run_id": run_id,
"project_root": metrics.get("project_root") or "",
"generated_at": generated_at,
"findings": det_findings,
}
report_path = final_dir / "nsfc-qc_report.md"
metrics_path = final_dir / "nsfc-qc_metrics.json"
findings_path = final_dir / "nsfc-qc_findings.json"
validation_path = final_dir / "validation.json"
for p in (report_path, metrics_path, findings_path, validation_path):
if p.exists() and not bool(args.overwrite):
print(f"error: output already exists (use --overwrite): {p}", file=sys.stderr)
return 2
tpl = _load_report_template() or ""
if tpl:
class _SafeDict(dict):
def __missing__(self, key: str) -> str: # type: ignore[override]
return "{" + key + "}"
report_body = tpl.format_map(
_SafeDict(
run_id=run_id,
project_root=str(metrics.get("project_root") or ""),
main_tex=main_tex,
threads=str(run_meta.get("threads") or ""),
execution=execution,
)
)
else:
report_body = (
"# NSFC 标书 QC 报告(nsfc-qc)\n\n"
f"- run_id: `{run_id}`\n"
f"- project_root: `{metrics.get('project_root','')}`\n"
f"- main_tex: `{main_tex}`\n\n"
)
# Fill some stats/paths (best-effort).
deliver_dir_s = str(Path(args.deliver_dir).expanduser().resolve()) if str(args.deliver_dir or "").strip() else ""
report_body = re.sub(r"^- 产物目录:\s*$", f"- 产物目录:`{run_dir}`", report_body, flags=re.M)
if deliver_dir_s:
report_body = re.sub(r"^- 本次 QC 范围:\s*$", f"- 本次 QC 范围:项目只读检查;交付目录:`{deliver_dir_s}`", report_body, flags=re.M)
cs = precheck.get("citation_stats") or {}
if cs:
report_body = re.sub(r"^- 引用总数(去重 bibkey):\s*$", f"- 引用总数(去重 bibkey):{cs.get('unique_citations','')}", report_body, flags=re.M)
report_body = re.sub(r"^- 缺失 bibkey:\s*$", f"- 缺失 bibkey:{cs.get('missing_bibkeys','')}", report_body, flags=re.M)
# Inject deterministic findings into P0/P1/P2 tables.
report_body = _inject_table_rows(report_body, section_title="硬性问题(P0)", rows=_render_md_table_rows(det_findings, severity="P0"))
report_body = _inject_table_rows(report_body, section_title="重要建议(P1)", rows=_render_md_table_rows(det_findings, severity="P1"))
report_body = _inject_table_rows(report_body, section_title="可选优化(P2)", rows=_render_md_table_rows(det_findings, severity="P2"))
idx_lines: List[str] = []
idx_lines.append("\n## 产物索引(自动生成)\n")
idx_lines.append(f"- 生成时间:{generated_at}")
if pv_main_summary:
idx_lines.append(f"- parallel-vibe 汇总:`{pv_main_summary}`")
if thread_results:
idx_lines.append("- thread RESULT.md:")
for r in thread_results[:50]:
idx_lines.append(f" - `{r}`")
if len(thread_results) > 50:
idx_lines.append(f" - ...(and {len(thread_results) - 50} more)")
else:
idx_lines.append("- thread RESULT.md:未检测到(可能尚未运行 threads)")
idx_lines.append("\n> 提示:本报告包含“确定性预检→findings”的底线结论;更深入的问题应结合 threads 汇总与人工复核补齐。\n")
report_path.write_text(report_body.rstrip() + "\n" + "\n".join(idx_lines) + "\n", encoding="utf-8")
_write_json(metrics_path, metrics)
_write_json(findings_path, findings)
ok, errors = _validate_report_and_findings(report_path.read_text(encoding="utf-8"), findings)
_write_json(validation_path, {"ok": ok, "errors": errors, "generated_at": generated_at})
if not ok:
_patch_report_warning(report_path, errors=errors)
print(str(final_dir))
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""
Isolated 4-step compile for nsfc-qc.
Goal:
- Compile the proposal in an isolated copy (never touching proposal sources).
- Run the standard 4-step sequence:
xelatex -> bibtex -> xelatex -> xelatex
- Write all outputs under a user-provided --out directory (recommended: .../.bensz-api/skills/nsfc-qc/<run_id>/artifacts).
Note:
- `nsfc-qc` is positioned as "content quality QC"; compile success is an environment/engineering concern.
- This script is kept as an optional, manual debugging helper and is NOT used by the nsfc-qc runners.
"""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Set
def _rel_to_out(out_dir: Path, p: Path) -> str:
try:
return str(p.resolve().relative_to(out_dir.resolve()))
except Exception:
return str(p)
def _run(cmd: List[str], cwd: Path, log_path: Path) -> int:
log_path.parent.mkdir(parents=True, exist_ok=True)
with log_path.open("a", encoding="utf-8") as f:
f.write("\n$ " + " ".join(cmd) + "\n")
try:
p = subprocess.run(cmd, cwd=str(cwd), stdout=f, stderr=subprocess.STDOUT)
return int(p.returncode)
except FileNotFoundError:
f.write(f"[nsfc-qc] command not found: {cmd[0]}\n")
return 127
def _get_pdf_pages(pdf_path: Path) -> Optional[int]:
for tool in (["pdfinfo"], ["qpdf", "--show-npages"]):
try:
p = subprocess.run(
tool + [str(pdf_path)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
if p.returncode != 0:
continue
if tool[0] == "pdfinfo":
for line in p.stdout.splitlines():
if line.lower().startswith("pages:"):
return int(line.split(":", 1)[1].strip())
else:
return int(p.stdout.strip())
except Exception:
continue
return None
def _compile_isolated(project_root: Path, main_tex_rel: str, out_dir: Path) -> dict:
compile_dir = out_dir / "compile"
src = compile_dir / "src"
build = compile_dir / "build"
if compile_dir.exists():
shutil.rmtree(compile_dir)
compile_dir.mkdir(parents=True, exist_ok=True)
def ignore(_dir: str, names: List[str]) -> Set[str]:
bad = {
".git",
".bensz-api",
".nsfc-qc",
".parallel-vibe",
".parallel_vibe",
"__pycache__",
".DS_Store",
"node_modules",
".venv",
"venv",
"build",
"dist",
"target",
# In this repo, QC deliveries can be huge and are never needed for isolated compile.
"QC",
}
return {n for n in names if n in bad}
shutil.copytree(project_root, src, ignore=ignore, dirs_exist_ok=False)
build.mkdir(parents=True, exist_ok=True)
main_tex = src / main_tex_rel
if not main_tex.exists():
return {
"enabled": True,
"ok": False,
"error": f"main_tex not found in isolated src: {main_tex_rel}",
"compile_dir": _rel_to_out(out_dir, compile_dir),
"compile_dir_abs": str(compile_dir),
}
base = main_tex.stem
log = out_dir / "compile.log"
log_rel = _rel_to_out(out_dir, log)
missing_tools = [t for t in ("xelatex", "bibtex") if shutil.which(t) is None]
if missing_tools:
try:
log.write_text(
"[nsfc-qc] TeX toolchain not available; skip compile step.\n"
f"missing_tools={missing_tools}\n",
encoding="utf-8",
)
except Exception:
pass
return {
"enabled": True,
"ok": False,
"missing_tools": missing_tools,
"error": "TeX toolchain not available; skip compile step",
"log": log_rel,
"log_abs": str(log),
"compile_dir": _rel_to_out(out_dir, compile_dir),
"compile_dir_abs": str(compile_dir),
}
r1 = _run(["xelatex", "-interaction=nonstopmode", "-halt-on-error", f"-output-directory={build}", str(main_tex)], cwd=src, log_path=log)
r2 = _run(["bibtex", base], cwd=build, log_path=log) if r1 == 0 else 1
r3 = _run(["xelatex", "-interaction=nonstopmode", "-halt-on-error", f"-output-directory={build}", str(main_tex)], cwd=src, log_path=log) if r2 == 0 else 1
r4 = _run(["xelatex", "-interaction=nonstopmode", "-halt-on-error", f"-output-directory={build}", str(main_tex)], cwd=src, log_path=log) if r3 == 0 else 1
pdf_path = build / f"{base}.pdf"
pages = _get_pdf_pages(pdf_path) if pdf_path.exists() else None
return {
"enabled": True,
"ok": (r4 == 0 and pdf_path.exists()),
"pdf": _rel_to_out(out_dir, pdf_path) if pdf_path.exists() else "",
"pdf_abs": str(pdf_path) if pdf_path.exists() else "",
"pages": pages if pages is not None else None,
"steps_rc": {"xelatex1": r1, "bibtex": r2, "xelatex2": r3, "xelatex3": r4},
"log": log_rel,
"log_abs": str(log),
"compile_dir": _rel_to_out(out_dir, compile_dir),
"compile_dir_abs": str(compile_dir),
}
def _write_json(path: Path, obj: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(obj, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--project-root", required=True)
ap.add_argument("--main-tex", default="main.tex", help="relative to project-root")
ap.add_argument("--out", required=True, help="output directory (recommended: .../.bensz-api/skills/nsfc-qc/<run_id>/artifacts)")
args = ap.parse_args()
project_root = Path(args.project_root).expanduser().resolve()
out_dir = Path(args.out).expanduser().resolve()
out_dir.mkdir(parents=True, exist_ok=True)
info = _compile_isolated(project_root, str(Path(args.main_tex)), out_dir)
info["generated_at"] = datetime.now().isoformat(timespec="seconds")
_write_json(out_dir / "compile.json", info)
print(str(out_dir))
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""
High-level runner for nsfc-qc with "deliver dir + sidecar workspace" layout.
Default layout (when --deliver-dir/--workspace-dir not provided):
<project_root>/QC/<run_id>/ (deliver-dir; for humans)
<project_root>/.bensz-api/skills/nsfc-qc/<run_id>/ (workspace-dir; for reproducibility)
All QC intermediate products (runs/, snapshot/, .parallel-vibe/, artifacts) go to workspace-dir.
Deliver-dir receives a copy of final outputs for review.
This script is deterministic and does NOT modify proposal sources.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional, Tuple
# Accept the new minute-level id, plus legacy vYYYYMMDDHHMM[SS] ids.
RUN_ID_RE = re.compile(r"^(?:\d{4}-\d{2}-\d{2}-\d{2}-\d{2}(?:-\d{2})?|v\d{12}(?:\d{2})?(?:r\d+)?)$")
def _now_run_id() -> str:
return datetime.now().strftime("%Y-%m-%d-%H-%M")
def _ensure_unique_dir(path: Path) -> Path:
if not path.exists():
return path
base = path.name
parent = path.parent
for i in range(2, 100):
# Keep hidden directories hidden (e.g. ".bensz-api" roots stay under the hidden tree).
suffix = f".r{i}" if base.startswith(".") else f"-{i:02d}"
cand = parent / f"{base}{suffix}"
if not cand.exists():
return cand
raise RuntimeError("failed to pick a unique deliver/workspace directory after 99 attempts")
def _infer_run_id_from_deliver_dir(deliver_dir: Path) -> str:
name = deliver_dir.name.strip()
if RUN_ID_RE.match(name):
return name
return _now_run_id()
def _copy_if_exists(src: Path, dst: Path) -> None:
if not src.exists():
return
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
def _resolve_main_tex(project_root: Path, requested: str) -> Optional[Path]:
direct = (project_root / requested).resolve()
if direct.exists() and direct.is_file():
return direct
candidates = sorted(project_root.rglob("*.tex"))
if not candidates:
return None
def _score(path: Path) -> int:
score = 0
rel_parts = path.relative_to(project_root).parts
name = path.name.lower()
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except Exception:
text = ""
if "\\documentclass" in text:
score += 6
if "\\begin{document}" in text:
score += 4
if path.parent == project_root:
score += 2
if name in {"main.tex", "proposal.tex", "application.tex"}:
score += 2
if any(part in {"extratex", "template", "figures", "qc"} for part in map(str.lower, rel_parts[:-1])):
score -= 3
if name.startswith("@"):
score -= 2
return score
scored = sorted(((_score(path), path) for path in candidates), key=lambda item: (item[0], str(item[1])), reverse=True)
best_score, best_path = scored[0]
if best_score < 1:
return None
return best_path
def _run_cmd(cmd: list[str]) -> Tuple[int, str]:
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, check=False)
return int(p.returncode), (p.stdout or "")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--project-root", required=True)
ap.add_argument("--main-tex", default="main.tex")
ap.add_argument("--deliver-dir", default="", help="deliver directory (recommended: .../QC/YYYY-MM-DD-HH-MM)")
ap.add_argument("--workspace-dir", default="", help="workspace directory (recommended: <project-root>/.bensz-api/skills/nsfc-qc/<run-id>)")
ap.add_argument("--threads", type=int, default=5)
ap.add_argument("--execution", choices=["serial", "parallel"], default="serial")
ap.add_argument("--max-parallel", type=int, default=3)
ap.add_argument("--runner-type", choices=["codex", "claude"], default="codex")
ap.add_argument("--runner-profile", choices=["fast", "default", "deep"], default="deep")
ap.add_argument("--plan-only", action="store_true")
ap.add_argument("--no-precheck", dest="precheck", action="store_false", default=True)
# resolve_refs is now mandatory (no --no-resolve-refs option)
ap.add_argument("--fetch-pdf", action="store_true")
ap.add_argument("--max-concurrent", type=int, default=5, help="max concurrent network requests for reference resolution (default: 5)")
ap.add_argument("--unpaywall-email", default=os.environ.get("UNPAYWALL_EMAIL", ""))
ap.add_argument("--timeout-s", type=int, default=20)
args = ap.parse_args()
project_root = Path(args.project_root).expanduser().resolve()
if not project_root.exists():
print(f"error: project_root not found: {project_root}", file=sys.stderr)
return 2
main_tex = _resolve_main_tex(project_root, str(args.main_tex))
if not main_tex:
print(f"error: main_tex not found (or auto-detect failed): {project_root / args.main_tex}", file=sys.stderr)
return 2
try:
main_tex_rel = str(main_tex.relative_to(project_root))
except Exception:
main_tex_rel = str(main_tex)
skill_root = Path(__file__).resolve().parents[1]
run_parallel_py = skill_root / "scripts" / "run_parallel_qc.py"
materialize_py = skill_root / "scripts" / "materialize_final_outputs.py"
# Derive deliver/workspace directories.
if str(args.deliver_dir or "").strip():
deliver_dir = Path(args.deliver_dir).expanduser().resolve()
run_id = _infer_run_id_from_deliver_dir(deliver_dir)
else:
run_id = _now_run_id()
deliver_dir = (project_root / "QC" / run_id).resolve()
deliver_dir = _ensure_unique_dir(deliver_dir)
# If we auto-suffixed deliver dir, keep run_id in sync when the directory name is a run_id-like token.
if RUN_ID_RE.match(deliver_dir.name):
run_id = deliver_dir.name
if str(args.workspace_dir or "").strip():
workspace_dir = Path(args.workspace_dir).expanduser().resolve()
workspace_dir = _ensure_unique_dir(workspace_dir)
else:
workspace_dir = _ensure_unique_dir(project_root / ".bensz-api" / "skills" / "nsfc-qc" / run_id)
deliver_dir.mkdir(parents=True, exist_ok=True)
run_base_dir = workspace_dir.parent
run_base_dir.mkdir(parents=True, exist_ok=True)
run_id_for_workspace = workspace_dir.name
cmd = [
sys.executable,
str(run_parallel_py),
"--project-root",
str(project_root),
"--main-tex",
main_tex_rel,
"--workspace-dir",
str(run_base_dir),
"--runs-root",
".",
"--run-id",
run_id_for_workspace,
"--threads",
str(int(args.threads)),
"--execution",
str(args.execution),
"--max-parallel",
str(int(args.max_parallel)),
"--runner-type",
str(args.runner_type),
"--runner-profile",
str(args.runner_profile),
"--timeout-s",
str(int(args.timeout_s)),
"--max-concurrent",
str(int(args.max_concurrent)),
]
if bool(args.plan_only):
cmd.append("--plan-only")
if not bool(args.precheck):
cmd.append("--no-precheck")
# resolve_refs is now mandatory (always enabled)
if bool(args.fetch_pdf):
cmd.append("--fetch-pdf")
if str(args.unpaywall_email or "").strip():
cmd += ["--unpaywall-email", str(args.unpaywall_email).strip()]
rc, out = _run_cmd(cmd)
# run_parallel_qc prints run_dir path (last line).
run_dir_str = (out.strip().splitlines()[-1] if out.strip().splitlines() else "").strip()
run_dir = Path(run_dir_str).expanduser().resolve() if run_dir_str else None
if not run_dir or not run_dir.exists():
# Best-effort: try to materialize using expected run_dir.
expected = workspace_dir.resolve()
expected.mkdir(parents=True, exist_ok=True)
subprocess.run(
[sys.executable, str(materialize_py), "--run-dir", str(expected), "--project-root", str(project_root), "--deliver-dir", str(deliver_dir)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
run_dir = expected
# Re-materialize with deliver metadata (safe overwrite of final only if missing).
subprocess.run(
[sys.executable, str(materialize_py), "--run-dir", str(run_dir), "--project-root", str(project_root), "--deliver-dir", str(deliver_dir), "--overwrite"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
final_dir = run_dir / "final"
# Copy deliverables (final outputs).
_copy_if_exists(final_dir / "nsfc-qc_report.md", deliver_dir / "nsfc-qc_report.md")
_copy_if_exists(final_dir / "nsfc-qc_metrics.json", deliver_dir / "nsfc-qc_metrics.json")
_copy_if_exists(final_dir / "nsfc-qc_findings.json", deliver_dir / "nsfc-qc_findings.json")
_copy_if_exists(final_dir / "validation.json", deliver_dir / "validation.json")
# Write a small manifest for portability.
try:
ws_rel = os.path.relpath(str(workspace_dir), str(deliver_dir))
except Exception:
ws_rel = str(workspace_dir)
manifest = {
"run_id": run_dir.name,
"deliver_dir": str(deliver_dir),
"workspace_dir": str(workspace_dir),
"workspace_dir_rel_from_deliver": ws_rel,
"run_dir": str(run_dir),
"project_root": str(project_root),
"note": "deliver_dir contains copied final outputs; full reproducibility data (snapshot/artifacts/.parallel-vibe/final) is in workspace_dir.",
}
(deliver_dir / "nsfc-qc_manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
# Forward runner output for debugging.
if out.strip():
(deliver_dir / "runner.log").write_text(out, encoding="utf-8")
print(str(deliver_dir))
return rc
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""
Run nsfc-qc multi-thread QC via parallel-vibe.
This script supports two output layouts:
1) Default: all artifacts under <project_root>/.bensz-api/skills/nsfc-qc/<run_id>/
2) Workspace-driven: all artifacts under <workspace_dir>/<runs_root>/<run_id>/
3) Legacy-compatible: explicitly pass --runs-root .nsfc-qc/runs
This script:
- Creates a run directory (see above) with subfolders: artifacts/, final/, snapshot/
- (Default) Runs deterministic precheck and reference evidence collection into artifacts/
- Creates an isolated snapshot of the proposal (read-only) for thread workspaces
- Copies key artifacts into snapshot/.bensz-api/skills/nsfc-qc/input/ for threads to read
- Generates a deterministic parallel-vibe plan.json with N identical QC threads
- Executes parallel-vibe with --out-dir set to the run directory (so .parallel-vibe lives under the run)
Note: This script does NOT modify proposal source files. It only writes into the run directory.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Set
def _now_run_id() -> str:
return datetime.now().strftime("%Y-%m-%d-%H-%M")
def _write_json(path: Path, obj: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(obj, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def _find_parallel_vibe_script() -> Optional[Path]:
env = os.environ.get("PARALLEL_VIBE_SCRIPT", "").strip()
if env:
p = Path(env).expanduser()
if p.exists() and p.is_file():
return p.resolve()
# Prefer the canonical locations suggested by the parallel-vibe skill.
candidates = [
Path.home() / ".codex" / "skills" / "parallel-vibe" / "scripts" / "parallel_vibe.py",
Path.home() / ".claude" / "skills" / "parallel-vibe" / "scripts" / "parallel_vibe.py",
]
for c in candidates:
try:
if c.exists() and c.is_file():
return c
except Exception:
continue
return None
def _is_safe_rel_path(p: Path) -> bool:
return (not p.is_absolute()) and (".." not in p.parts)
def _resolve_main_tex(project_root: Path, requested: str) -> Optional[Path]:
direct = (project_root / requested).resolve()
if direct.exists() and direct.is_file():
return direct
candidates = sorted(project_root.rglob("*.tex"))
if not candidates:
return None
def _score(path: Path) -> int:
score = 0
rel_parts = path.relative_to(project_root).parts
name = path.name.lower()
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except Exception:
text = ""
if "\\documentclass" in text:
score += 6
if "\\begin{document}" in text:
score += 4
if path.parent == project_root:
score += 2
if name in {"main.tex", "proposal.tex", "application.tex"}:
score += 2
if any(part in {"extratex", "template", "figures", "qc"} for part in map(str.lower, rel_parts[:-1])):
score -= 3
if name.startswith("@"):
score -= 2
return score
scored = sorted(((_score(path), path) for path in candidates), key=lambda item: (item[0], str(item[1])), reverse=True)
best_score, best_path = scored[0]
if best_score < 1:
return None
return best_path
def _resolve_unique_run_dir(*, base_dir: Path, runs_root: Path, run_id: str) -> tuple[Path, str]:
"""
Resolve <base_dir>/<runs_root>/<run_id> while preventing directory traversal.
If the target directory already exists, auto-suffix with r1/r2/... to avoid overwriting.
"""
base_dir = base_dir.expanduser().resolve()
runs_root = Path(str(runs_root))
if not _is_safe_rel_path(runs_root):
raise ValueError("runs_root must be a relative path without '..'")
# Prevent path injection: run_id must be a simple name.
if Path(run_id).name != run_id or ("/" in run_id) or ("\\" in run_id):
raise ValueError("run_id must be a simple name (no path separators)")
def mk(rid: str) -> Path:
rd = (base_dir / runs_root / rid).resolve()
rd.relative_to(base_dir) # will raise if escapes
return rd
run_dir = mk(run_id)
if not run_dir.exists():
return run_dir, run_id
for i in range(1, 100):
rid2 = f"{run_id}r{i}"
rd2 = mk(rid2)
if not rd2.exists():
return rd2, rid2
raise RuntimeError("failed to pick a unique run directory after 99 attempts")
def _copy_snapshot(project_root: Path, snapshot_dir: Path) -> None:
if snapshot_dir.exists():
shutil.rmtree(snapshot_dir)
# Minimize snapshot size aggressively: only copy sources needed for "read-only QC".
# We intentionally do NOT copy compiled artifacts, figures, fonts, templates, etc.
snapshot_dir.mkdir(parents=True, exist_ok=True)
bad_dirs = {
".git",
".bensz-api",
".nsfc-qc",
".parallel-vibe",
".parallel_vibe",
"__pycache__",
".pytest_cache",
".mypy_cache",
".ruff_cache",
".cache",
".venv",
"venv",
"node_modules",
"dist",
"build",
"target",
# Common in this repo: previous QC deliveries can be huge; never snapshot them.
"QC",
}
for root, dirs, files in os.walk(project_root):
# Prune large / irrelevant directories early to avoid expensive traversal.
dirs[:] = [d for d in dirs if d not in bad_dirs]
for fn in files:
ext = Path(fn).suffix.lower()
if ext not in {".tex", ".bib"}:
continue
src = Path(root) / fn
try:
rel = src.relative_to(project_root)
except Exception:
continue
dst = snapshot_dir / rel
dst.parent.mkdir(parents=True, exist_ok=True)
try:
shutil.copy2(src, dst)
except Exception:
continue
# Enforce read-only at filesystem level (files only; keep directories writable so the run folder remains removable).
for p in snapshot_dir.rglob("*"):
try:
if p.is_file():
mode = p.stat().st_mode
os.chmod(p, mode & ~0o222) # drop write bits, keep exec bits
except Exception:
continue
def _mk_thread_prompt(*, main_tex: str) -> str:
# Keep this prompt stable and identical across threads.
return (
"你将对 NSFC 标书进行“只读质量控制(QC)”。\n"
"硬约束:\n"
"- 你的当前工作目录(cwd)就是标书项目根目录(来自原项目的 snapshot 拷贝)。\n"
"- 注意:snapshot 是“最小化副本”,通常只包含 `*.tex/*.bib` 与 `./.bensz-api/skills/nsfc-qc/input/` 证据包;缺失的图片/模板/字体/编译产物不影响你做文本 QC。\n"
"- 禁止修改任何已有文件(尤其是 .tex/.bib/.cls/.sty)。把建议写进 RESULT.md。\n"
"- 禁止访问父目录(..)与任何绝对路径写入。\n"
"- 不编造引用与论文内容;无法确定时标记为 uncertain,并给出可复核路径。\n\n"
"证据包(只读,可用于“引用真伪/错引风险”的语义核查):\n"
"- `./.bensz-api/skills/nsfc-qc/input/precheck.json`\n"
"- `./.bensz-api/skills/nsfc-qc/input/citations_index.csv`\n"
"- `./.bensz-api/skills/nsfc-qc/input/abbreviation_issues_summary.json`(缩写规范预检摘要:建议先读,快速定位高优先级项)\n"
"- `./.bensz-api/skills/nsfc-qc/input/abbreviation_issues.csv`(缩写规范预检明细:按行定位;注意过滤 LaTeX 标签/数学变量等误报)\n"
"- `./.bensz-api/skills/nsfc-qc/input/abbreviation_registry.json`(全文级缩写注册表:首次出现、全部出现、定义候选、唯一性状态)\n"
"- `./.bensz-api/skills/nsfc-qc/input/abbreviation_render_stream.jsonl`(按 main.tex 实际渲染顺序展开的调试流;仅在需要时抽查)\n"
"- `./.bensz-api/skills/nsfc-qc/input/terminology_issues_summary.json`(术语一致性预检摘要:英文术语大小写/连字符变体)\n"
"- `./.bensz-api/skills/nsfc-qc/input/terminology_issues.csv`(术语一致性预检明细:按 normalized_key 分组,列出所有变体)\n"
"- `./.bensz-api/skills/nsfc-qc/input/reference_evidence.jsonl`(硬编码抓取到的题目/摘要/可选 PDF 片段 + 标书内引用上下文)\n\n"
"缩略语规范(必检,独立小节输出):\n"
"- 以 `abbreviation_issues_summary.json` / `abbreviation_issues.csv` / `abbreviation_registry.json` 为起点,逐条核对。\n"
"- 首次出现必须按 `abbreviation_render_stream.jsonl` 对应的真实渲染顺序理解,不得按文件名或目录扫描顺序自行重排。\n"
"- 重点检查 3 类问题:冲突定义(`conflicting_english_full_name` / `conflicting_chinese_full`)、定义滞后(`late_definition`)、重复同一定义(`repeated_same_definition`)。\n"
"- 对 P1(`bare_first_use` / `late_definition` / `missing_english_full` / `conflicting_*`):确认是否为真正的重要专业术语,给出最小修改建议。\n"
"- 对 P2(`missing_chinese_full` / `repeated_same_definition`):确认是否确实缺中文全称或重复展开。\n"
"- 过滤误报:LaTeX 标签(如 `fig:ABC`)、图表编号、数学变量、bibkey/label 不是缩写。\n"
"- 你必须在 RESULT.md 的「3) 重要建议(P1)」中写一个二级标题:`### 缩略语规范`;若存在 P2 级缩写问题,在「4) 可选优化(P2)」里继续补充。\n\n"
"术语一致性(必检,独立小节输出):\n"
"- 以 `terminology_issues_summary.json` / `terminology_issues.csv` 为起点,逐条核对。\n"
"- 每条 `term_variant` 问题列出了同一概念的多种英文写法(大小写/连字符差异),请判断:\n"
" - 是否为真正的不一致(而非专有名词的合理变体,如 `T cell` vs `T-cell` 在不同语境下均可接受)。\n"
" - 建议统一使用哪种形式(通常选出现次数最多的)。\n"
"- 过滤误报:不同语境下合理的大小写差异(如句首大写)不算不一致。\n"
"- 你必须在 RESULT.md 的「4) 可选优化(P2)」中写一个二级标题:`### 术语一致性`,并给出可执行建议(只写建议,不改文件)。\n\n"
f"输入:\n- project_root: .\n- main_tex: {main_tex}\n\n"
"请在 RESULT.md 中按以下结构输出(标题必须一致):\n"
"1) 执行摘要\n"
"2) 硬性问题(P0)\n"
"3) 重要建议(P1)\n"
"4) 可选优化(P2)\n"
"5) 引用核查清单(硬编码证据 + 语义判断;含证据链)\n"
"6) 篇幅与结构分布(给出你观察到的不合理点)\n"
"7) 建议的最小修改路线图\n"
"8) 附录:你运行的命令(如有)与复核提示\n"
)
def _build_plan(
*,
prompt: str,
n_threads: int,
runner_type: str,
runner_profile: str,
) -> dict:
threads: List[dict] = []
for i in range(1, n_threads + 1):
tid = str(i).zfill(3)
threads.append(
{
"thread_id": tid,
"title": "QC",
"runner": {"type": runner_type, "profile": runner_profile, "model": "", "args": []},
"prompt": (
f"{prompt.strip()}\n\n"
"交付要求:\n"
"- 必须在当前工作目录写出 `RESULT.md`(Markdown)。\n"
"- 严格只读:不要修改任何已有文件。\n"
),
}
)
return {
"plan_version": 1,
"prompt": prompt,
"threads": threads,
"synthesis": {
"enabled": True,
"runner": {"type": runner_type, "profile": runner_profile, "model": "", "args": []},
"prompt": (
"请综合输入中的多 thread 产物,生成一份最终 QC 结论(面向用户)。\n"
"要求:\n"
"- 去重合并同类问题;冲突结论要显式标注,并说明你选择/不确定的依据。\n"
"- 按 P0/P1/P2 输出,并给出可执行的最小修改路线图。\n"
"- 引用问题必须提供证据链;不确定则标记 uncertain。\n"
"- 输出为 Markdown。\n"
),
},
}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--project-root", required=True)
ap.add_argument("--main-tex", default="main.tex")
ap.add_argument("--run-id", default="", help="default: auto timestamp like vYYYYMMDDHHMMSS")
ap.add_argument("--threads", type=int, default=5)
ap.add_argument("--execution", choices=["serial", "parallel"], default="serial")
ap.add_argument("--max-parallel", type=int, default=3)
ap.add_argument("--runner-type", choices=["codex", "claude"], default="codex")
ap.add_argument("--runner-profile", choices=["fast", "default", "deep"], default="deep")
ap.add_argument("--workspace-dir", default="", help="optional; if set, all outputs go under this directory")
ap.add_argument(
"--runs-root",
default="",
help="relative root for runs; defaults to '.bensz-api/skills/nsfc-qc' or 'runs' (when --workspace-dir is set)",
)
ap.add_argument("--plan-only", action="store_true", help="only write plan + snapshot; do not run threads")
ap.add_argument("--precheck", dest="precheck", action="store_true", default=True, help="run deterministic precheck before threads")
ap.add_argument("--no-precheck", dest="precheck", action="store_false", help="skip deterministic precheck")
# resolve_refs is now mandatory (always enabled; no --no-resolve-refs option)
ap.add_argument("--fetch-pdf", action="store_true", help="when resolving refs, attempt to download OA PDFs and extract a short text excerpt")
ap.add_argument("--max-concurrent", type=int, default=5, help="max concurrent network requests for reference resolution (default: 5)")
ap.add_argument("--unpaywall-email", default=os.environ.get("UNPAYWALL_EMAIL", ""), help="optional; required by Unpaywall API (or set env UNPAYWALL_EMAIL)")
ap.add_argument("--timeout-s", type=int, default=20, help="network timeout seconds for reference resolution")
args = ap.parse_args()
if args.threads < 1 or args.threads > 9:
print("error: --threads must be in [1, 9]", file=sys.stderr)
return 2
project_root = Path(args.project_root).expanduser().resolve()
if not project_root.exists():
print(f"error: project_root not found: {project_root}", file=sys.stderr)
return 2
main_tex = _resolve_main_tex(project_root, str(args.main_tex))
if not main_tex:
print(f"error: main_tex not found (or auto-detect failed): {project_root / args.main_tex}", file=sys.stderr)
return 2
try:
main_tex_rel = str(main_tex.relative_to(project_root))
except Exception:
main_tex_rel = str(main_tex)
requested_run_id = args.run_id.strip() or _now_run_id()
workspace_dir = Path(str(args.workspace_dir or "")).expanduser()
base_dir: Path
if str(args.workspace_dir or "").strip():
base_dir = workspace_dir.resolve()
if not base_dir.exists():
base_dir.mkdir(parents=True, exist_ok=True)
runs_root = Path(args.runs_root.strip() or "runs")
else:
base_dir = project_root
runs_root = Path(args.runs_root.strip() or ".bensz-api/skills/nsfc-qc")
# Keep artifacts isolated under a hidden intermediate root unless workspace-dir is used.
if not runs_root.parts or runs_root.parts[0] not in {".bensz-api", ".nsfc-qc"}:
print("error: --runs-root must start with .bensz-api/ or .nsfc-qc/ unless --workspace-dir is set", file=sys.stderr)
return 2
try:
run_dir, run_id = _resolve_unique_run_dir(base_dir=base_dir, runs_root=runs_root, run_id=requested_run_id)
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
return 2
except RuntimeError as e:
print(f"error: {e}", file=sys.stderr)
return 2
artifacts = run_dir / "artifacts"
final_dir = run_dir / "final"
snapshot_dir = run_dir / "snapshot"
artifacts.mkdir(parents=True, exist_ok=True)
final_dir.mkdir(parents=True, exist_ok=True)
# 0) Deterministic precheck (including reference evidence) BEFORE snapshot/threads.
skill_root = Path(__file__).resolve().parents[1]
if bool(args.precheck):
precheck_py = skill_root / "scripts" / "nsfc_qc_precheck.py"
cmd = [
sys.executable,
str(precheck_py),
"--project-root",
str(project_root),
"--main-tex",
main_tex_rel,
"--out",
str(artifacts),
"--timeout-s",
str(int(args.timeout_s)),
"--max-concurrent",
str(int(args.max_concurrent)),
]
# resolve_refs is now mandatory (always enabled)
cmd.append("--resolve-refs")
if str(args.unpaywall_email or "").strip():
cmd += ["--unpaywall-email", str(args.unpaywall_email).strip()]
if bool(args.fetch_pdf):
cmd.append("--fetch-pdf")
log_path = artifacts / "precheck_runner.log"
with log_path.open("w", encoding="utf-8") as f:
f.write("$ " + " ".join(cmd) + "\n\n")
subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT)
# Snapshot is the src_dir for parallel-vibe to keep workspaces clean.
_copy_snapshot(project_root, snapshot_dir)
# Make deterministic artifacts readable inside thread workspaces (still read-only).
qc_in = snapshot_dir / ".bensz-api" / "skills" / "nsfc-qc" / "input"
qc_in.mkdir(parents=True, exist_ok=True)
for name in (
"precheck.json",
"citations_index.csv",
"tex_lengths.csv",
"quote_issues.csv",
"abbreviation_issues.csv",
"abbreviation_issues_summary.json",
"abbreviation_registry.json",
"abbreviation_render_stream.jsonl",
"terminology_issues.csv",
"terminology_issues_summary.json",
"reference_evidence.jsonl",
"reference_evidence_summary.json",
):
src = artifacts / name
if src.exists():
dst = qc_in / name
try:
shutil.copy2(src, dst)
if dst.is_file():
mode = dst.stat().st_mode
os.chmod(dst, mode & ~0o222) # drop write bits
except Exception:
continue
base_prompt = _mk_thread_prompt(main_tex=main_tex_rel)
plan = _build_plan(
prompt=base_prompt,
n_threads=int(args.threads),
runner_type=args.runner_type,
runner_profile=args.runner_profile,
)
plan_path = artifacts / "parallel_vibe_plan.json"
_write_json(plan_path, plan)
meta = {
"run_id": run_id,
"requested_run_id": requested_run_id,
"project_root": str(project_root),
"main_tex": main_tex_rel,
"threads": int(args.threads),
"execution": args.execution,
"runner_type": args.runner_type,
"runner_profile": args.runner_profile,
"generated_at": datetime.now().isoformat(timespec="seconds"),
"artifacts_dir": str(artifacts),
"final_dir": str(final_dir),
"workspace_dir": str(base_dir) if base_dir != project_root else "",
"runs_root": str(runs_root),
}
_write_json(artifacts / "run_meta.json", meta)
pv = _find_parallel_vibe_script()
if not pv:
(final_dir / "parallel_vibe_unavailable.txt").write_text(
"parallel-vibe script not found. See skills/nsfc-qc/SKILL.md for downgrade strategy.\n",
encoding="utf-8",
)
# Still materialize deterministic final outputs (report/metrics/findings/validation).
materialize_py = skill_root / "scripts" / "materialize_final_outputs.py"
subprocess.run(
[
sys.executable,
str(materialize_py),
"--run-dir",
str(run_dir),
"--project-root",
str(project_root),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
print(str(run_dir))
return 0
if args.plan_only:
# Create deterministic final outputs even in plan-only mode.
materialize_py = skill_root / "scripts" / "materialize_final_outputs.py"
subprocess.run(
[
sys.executable,
str(materialize_py),
"--run-dir",
str(run_dir),
"--project-root",
str(project_root),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
print(str(run_dir))
return 0
cmd = [
sys.executable,
str(pv),
"--plan-file",
str(plan_path),
"--src-dir",
str(snapshot_dir),
"--out-dir",
str(run_dir),
]
if args.execution == "parallel":
cmd += ["--parallel", "--max-parallel", str(int(args.max_parallel))]
log_path = artifacts / "parallel_vibe_runner.log"
with log_path.open("w", encoding="utf-8") as f:
f.write("$ " + " ".join(cmd) + "\n\n")
p = subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT)
(artifacts / "parallel_vibe_exit_code.txt").write_text(str(int(p.returncode)) + "\n", encoding="utf-8")
# Materialize standard final outputs skeleton (safe, deterministic).
materialize_py = skill_root / "scripts" / "materialize_final_outputs.py"
subprocess.run(
[sys.executable, str(materialize_py), "--run-dir", str(run_dir), "--project-root", str(project_root)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
print(str(run_dir))
return int(p.returncode)
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""
Validate final outputs consistency for nsfc-qc.
Checks:
- final/nsfc-qc_report.md contains required headings
- final/nsfc-qc_report.md contains table rows for every finding id in final/nsfc-qc_findings.json
Exit code:
- 0: OK
- 2: FAIL
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import List, Tuple
REQUIRED_HEADINGS = [
"执行摘要",
"范围与只读声明",
"硬性问题(P0)",
"重要建议(P1)",
"可选优化(P2)",
"引用核查清单",
"篇幅与结构分布",
"建议的最小修改路线图",
"附录:复现信息(命令/路径/产物索引)",
]
def _read_text(p: Path) -> str:
return p.read_text(encoding="utf-8", errors="ignore")
def _read_json(p: Path) -> dict:
try:
return json.loads(_read_text(p))
except Exception:
return {}
def _validate(report_text: str, findings_obj: dict) -> Tuple[bool, List[str]]:
errors: List[str] = []
for h in REQUIRED_HEADINGS:
if f"## {h}" not in report_text:
errors.append(f"missing_heading:{h}")
findings = findings_obj.get("findings") or []
for f in findings:
fid = str(f.get("id") or "").strip()
if not fid:
errors.append("finding_missing_id")
continue
if f"| {fid} |" not in report_text:
errors.append(f"missing_finding_in_report:{fid}")
return (len(errors) == 0), errors
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--run-dir", required=True, help="run directory (contains final/)")
args = ap.parse_args()
run_dir = Path(args.run_dir).expanduser().resolve()
report = run_dir / "final" / "nsfc-qc_report.md"
findings = run_dir / "final" / "nsfc-qc_findings.json"
if not report.exists() or not findings.exists():
print("FAIL: missing final outputs", file=sys.stderr)
print(f"- report: {report} (exists={report.exists()})", file=sys.stderr)
print(f"- findings: {findings} (exists={findings.exists()})", file=sys.stderr)
return 2
ok, errors = _validate(_read_text(report), _read_json(findings))
if ok:
print("OK: final outputs are consistent.")
return 0
print("FAIL: final outputs validation failed:", file=sys.stderr)
for e in errors:
print(f"- {e}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""
Deterministic validation for the nsfc-qc skill folder.
Checks:
- SKILL.md frontmatter version matches config.yaml skill_info.version
- Key scripts/templates exist
Exit code:
- 0: OK
- 2: validation failed
"""
from __future__ import annotations
import re
import sys
import py_compile
from pathlib import Path
from typing import Optional, Tuple
def _read_text(path: Path) -> str:
return path.read_text(encoding="utf-8", errors="ignore")
def _extract_skill_md_version(skill_md: Path) -> Optional[str]:
s = _read_text(skill_md)
# Frontmatter is between the first two '---' lines.
if not s.startswith("---"):
return None
parts = s.split("\n---", 2)
if len(parts) < 2:
return None
front = parts[0]
m = re.search(r"^version:\s*([0-9]+(?:\.[0-9]+){1,3})\s*$", front, flags=re.M)
return m.group(1) if m else None
def _extract_config_version(cfg: Path) -> Optional[str]:
s = _read_text(cfg)
# Minimal, dependency-free parse for:
# skill_info:
# version: x.y.z
m = re.search(r"^\s*version:\s*([0-9]+(?:\.[0-9]+){1,3})\s*$", s, flags=re.M)
return m.group(1) if m else None
def main() -> int:
skill_root = Path(__file__).resolve().parents[1]
skill_md = skill_root / "SKILL.md"
cfg = skill_root / "config.yaml"
missing = []
for p in (skill_md, cfg):
if not p.exists():
missing.append(str(p))
if missing:
print("FAIL: missing required files:", file=sys.stderr)
for p in missing:
print(f"- {p}", file=sys.stderr)
return 2
v_skill = _extract_skill_md_version(skill_md)
v_cfg = _extract_config_version(cfg)
if not v_skill or not v_cfg:
print("FAIL: failed to extract versions", file=sys.stderr)
print(f"- SKILL.md version: {v_skill!r}", file=sys.stderr)
print(f"- config.yaml version: {v_cfg!r}", file=sys.stderr)
return 2
if v_skill != v_cfg:
print("FAIL: version mismatch (config.yaml is source of truth)", file=sys.stderr)
print(f"- SKILL.md: {v_skill}", file=sys.stderr)
print(f"- config.yaml: {v_cfg}", file=sys.stderr)
return 2
required_paths = [
skill_root / "scripts" / "nsfc_qc_precheck.py",
skill_root / "scripts" / "run_parallel_qc.py",
skill_root / "scripts" / "nsfc_qc_compile.py",
skill_root / "scripts" / "materialize_final_outputs.py",
skill_root / "scripts" / "nsfc_qc_run.py",
skill_root / "scripts" / "validate_final_outputs.py",
skill_root / "templates" / "REPORT_TEMPLATE.md",
skill_root / "templates" / "FINDINGS_SCHEMA.json",
skill_root / "references" / "qc_checklist.md",
]
missing2 = [str(p.relative_to(skill_root)) for p in required_paths if not p.exists()]
if missing2:
print("FAIL: missing expected paths:", file=sys.stderr)
for p in missing2:
print(f"- {p}", file=sys.stderr)
return 2
compile_targets = [
skill_root / "scripts" / "nsfc_qc_precheck.py",
skill_root / "scripts" / "run_parallel_qc.py",
skill_root / "scripts" / "nsfc_qc_run.py",
skill_root / "scripts" / "materialize_final_outputs.py",
]
for target in compile_targets:
try:
py_compile.compile(str(target), doraise=True)
except py_compile.PyCompileError as exc:
print("FAIL: python syntax check failed", file=sys.stderr)
print(f"- {target.relative_to(skill_root)}: {exc.msg}", file=sys.stderr)
return 2
print("OK: nsfc-qc skill structure looks valid.")
print(f"- version: {v_cfg}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
{
"schema_version": 1,
"description": "nsfc-qc findings schema (machine-readable). Keep JSON ASCII-only.",
"fields": {
"run_id": "string",
"project_root": "string",
"generated_at": "string (ISO-8601)",
"findings": [
{
"id": "string (e.g., P0-001)",
"severity": "P0|P1|P2",
"category": "style|citation|length|structure|logic|consistency|format|other",
"location": {
"path": "string (relative to project_root)",
"anchor": "string (e.g., section title or file:line)"
},
"problem": "string",
"evidence": [
{
"type": "quote|bibtex|metric|link|command_output|note",
"detail": "string"
}
],
"recommendation": "string",
"status": "open|uncertain|needs_human_review"
}
]
}
}
NSFC 标书 QC 报告(nsfc-qc)
run_id: {run_id}project_root: {project_root}main_tex: {main_tex}threads:{threads}(execution:{execution})
只读:是(未修改任何 .tex/.bib/.cls/.sty)执行摘要
- 总体结论:
- 最关键的 3 个问题:
- 建议的最小修改路线图(概览):
范围与只读声明
- 本次 QC 范围:
- 只读声明:
- 产物目录:
硬性问题(P0)
| ID | 位置(文件/章节) | 问题 | 证据(≤2 条) | 建议修复 |
|---|---|---|---|---|
| P0-001 |
重要建议(P1)
| ID | 位置(文件/章节) | 问题 | 证据(≤2 条) | 建议修复 |
|---|---|---|---|---|
| P1-001 |
可选优化(P2)
| ID | 位置(文件/章节) | 问题 | 建议优化 |
|---|---|---|---|
| P2-001 |
引用核查清单
统计
- 引用总数(去重 bibkey):
- 缺失 bibkey:
- 高风险(疑似假引/错引):
- 需人工复核(uncertain):
问题列表
| bibkey | 风险级别 | 位置(文件:行) | 现象 | 证据/复核路径 |
|---|---|---|---|---|
篇幅与结构分布
总体
- 页数提示:本技能不做编译检查;请你在自己的 TeX/Overleaf 环境编译后核对是否满足“原则上不超过 30 页”的要求(仅建议优化):
章节/文件分布(粗略)
| 章节/文件 | 字符数(近似) | 占比 | 备注 |
|---|---|---|---|
建议的最小修改路线图
1. 先修 P0(必须修复): 2. 再修 P1(优先级从高到低): 3. 最后处理 P2(有时间再做):
附录:复现信息(命令/路径/产物索引)
- run 目录:
- 若启用 parallel-vibe:
.parallel-vibe/<project_id>/@main/summary.md - 预检脚本(如有):
- 缩写注册表(如有):
artifacts/abbreviation_registry.json - 缩写渲染顺序调试(如有):
artifacts/abbreviation_render_stream.jsonl