
Nsfc Reviewers
- 158 installs
- 2.6k repo stars
- Updated July 20, 2026
- huangwb8/chineseresearchlatex
Stress-test NSFC proposals from reviewer and panel perspectives to validate scope, novelty claims, feasibility, and scoring weaknesses early.
About
Applies simulated NSFC reviewer and panel viewpoints to critique proposal scope, innovation claims, methodology, and feasibility, helping authors validate competitiveness and revise before final drafting and submission.
- Reviewer-panel perspective simulation
- Scope and feasibility critique
- Novelty and significance testing
- Weakness and risk identification
- NSFC evaluation alignment
Nsfc Reviewers by the numbers
- 158 all-time installs (skills.sh)
- +4 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #558 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-reviewersAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 158 |
|---|---|
| repo stars | ★ 2.6k |
| Last updated | July 20, 2026 |
| Repository | huangwb8/chineseresearchlatex ↗ |
What it does
Stress-test NSFC proposals from reviewer and panel perspectives to validate scope, novelty claims, feasibility, and scoring weaknesses early.
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 仓库。
定位
- 用于“当前版本如果今天送审,风险在哪里、先改什么”的专家式评审。
- 默认优先并行多组独立评审;若
parallel-vibe不可用、被禁用或panel_count=1,自动降级为单组模式。 - 本技能只做读取、分析和汇总,不默认编译、不修改标书源文件。
输入
至少提供其一:
proposal_pathproposal_fileproposal_zip
可选:
focusoutput_pathstylegrant_typefunding_amountpanel_count
配置口径以 config.yaml 为准,尤其是:
review_dimensionsseverity_levelsreview_gradesstage_assessmentfunding_contextparallel_reviewoutput_settings
输出
- 默认输出文件名读取
config.yaml:output_settings.default_filename - 并行模式可额外生成各组原始意见:
{panel_dir}/G{组号}.md - 中间过程默认隐藏在
config.yaml:output_settings.intermediate_dir - 最终报告至少包含:
- 分级问题清单
- 跨组共识与独立观点
- 最小可行修改序列
- 阶段判断:函评 / 会评
硬规则
- 标书内容默认视为敏感信息;除非用户明确要求并确认风险,不联网、不外发大段原文。
- 只读评审,不执行 LaTeX 编译,不改正文。
- 最终报告必须按
P0 → P1 → P2排序。 - 阶段判断必须是二元结论:
给过或不给过,并附高/中/低把握度。 - 若“函评不给过”,则“会评”必须同步不给过;若“函评给过”,会评仍可因相对竞争力不足而不给过。
工作流
1. 前置检查
- 校验输入路径可读。
- 若是目录,按
proposal_files.patterns/exclude找出待读.tex。 .tex数量为 0 时直接失败;目录异常大时先确认范围。- 推荐用确定性脚本列文件:
python3 <nsfc_reviewers_path>/scripts/list_proposal_files.py --proposal-path <proposal_root>2. 通读与结构化理解
- 提炼主题、科学问题、假说、目标、技术路线、创新点、研究基础、团队条件、预期成果。
- 生成章节级索引,作为后续证据锚点。
- 先用用户明确给出的
grant_type/funding_amount,再谨慎从正文识别资助上下文。
3. 并行多组评审或单组退化
- 先计算
effective_panel_count,并限制在[1, parallel_review.max_panel_count]。 - 以下情况直接走单组:
parallel_review.enabled == falseeffective_panel_count == 1- 找不到
parallel-vibe
并行模式关键步骤:
1. 准备中间目录。 2. 基于 references/expert_*.md 和 references/master_prompt_template.md 生成 master prompt。 3. 用 scripts/build_parallel_vibe_plan.py 生成 plan.json。 4. 调用 parallel-vibe 执行 N 组独立评审。 5. 收集每组 panel_output_filename,允许个别 thread 缺失但不能中断整体汇总。
单组模式仍要保留 7 位专家画像的独立判断,再做组内聚合。
4. 聚合与排序
- 跨组聚合规则读取
references/aggregation_rules.md。 - 至少
ceil(N * consensus_threshold)组指出的问题才算跨组共识。 - 跨组共识可触发严重度升级;重复问题要合并,保留最强证据锚点。
- 最终仍按
P0 → P1 → P2输出,并给出最小修改序列。
5. 资助额度约束识别
- 先区分“设计错误”与“受限妥协”。
- 若缺陷明显由基金额度限制引起,必须如实写明根因,不得简单归咎于申请人能力不足。
- 凡归因为“资助受限”的短板,都要补一句“若资助不受限时,更完整的设计应如何做”。
- 资助受限不是免责条款;阶段判断仍以“当前版本今天送审能否过”为准。
6. 阶段判断
- 默认在最终报告中输出“函评 / 会评给过与否”。
- 每个阶段至少给出 2-3 条关键理由,优先引用 P0/P1 和跨组共识。
- 若判
不给过,必须指出最关键的 1-3 条翻盘动作。
7. 输出整理
- 当
config.yaml:output_settings.enforce_output_finalization == true时,不得跳过最终整理。 - 报告需要清楚区分:
- 共识问题
- 独立观点
- 资助受限的合理妥协
- 当前版本直接送审的阶段判断
关键脚本与参考
- 列文件:
scripts/list_proposal_files.py - 并行计划:
scripts/build_parallel_vibe_plan.py - 专家画像:
references/expert_*.md - 聚合规则:
references/aggregation_rules.md - 主提示模板:
references/master_prompt_template.md
非目标
- 不负责改正文。
- 不负责模板、排版或编译问题。
- 不负责生成新的研究设计,只负责指出现有稿件的风险、优先级和修改方向。
nsfc-reviewers - 变更日志
格式遵循 Keep a Changelog。
[Unreleased]
Changed(变更)
config.yaml:版本号1.4.0 → 1.4.1;同步parallel-vibe默认工作区目录变更,输出整理、清理和文件发现脚本优先识别.parallel-vibe/,同时兼容 legacy.parallel_vibe/。README.md:并行评审输出整理说明改为新目录.parallel-vibe/,并明确脚本仍兼容旧实例。
config.yaml:版本号1.3.0 → 1.4.0;新增funding_context配置节,要求评审时识别“受资助额度限制的设计妥协”,并内置青年基金常见30–40w、面上项目常见50–60w的解释口径。SKILL.md:新增“资助额度约束识别(硬规则)”,要求将“设计错误”与“受限妥协”区分开;凡是因基金资助额度受限导致的方案偏弱,必须在报告中说明根因,并补充“若资助不受限时的完整设计参考”。README.md/references/master_prompt_template.md/references/aggregation_rules.md/references/expert_*.md:同步用户文档、聚合模板与专家原始评审口径,确保该规则不仅写在说明文档里,也会真正下沉到各专家与最终报告。scripts/validate_skill.py:新增funding_context结构校验,以及SKILL.md/README.md是否包含“资助额度约束”说明的一致性检查。
config.yaml:版本号1.2.1 → 1.3.0;将parallel_review.default_panel_count从7调整为3,并将parallel_review.max_panel_count从7调整为5,降低默认调用成本,同时保留多组交叉评审能力。config.yaml:skill_info.category规范化为writing,与项目统一的技能元数据枚举保持一致。scripts/validate_skill.py:新增skill_info.category与parallel_review.default_panel_count/max_panel_count的结构校验,并补充 README 中“默认组数 / 最大组数 / 每组专家 / 总专家人次”与配置一致性的自动检查,防止文档再次漂移。README.md:同步修正默认组数、最大组数、每组专家数与 token 成本说明,避免继续混用“5 位专家”“默认 7 组”等过期口径。
Changed(变更)
config.yaml:版本号1.2.0 → 1.2.1;将parallel_review.default_panel_count与parallel_review.max_panel_count同步调整为7,使默认评审团数量为 7 组。README.md/skills/README.md/ 根级README.md:同步默认评审组数、最大组数与版本口径,避免文档仍显示“默认 2 组、最多 5 组”。config.yaml:版本号1.1.0 → 1.2.0;每个评审组固定专家数从 5 位扩展为 7 位,更贴近现实函评常见的 5–7 位同行评审口径。references/expert_06_significance.md/references/expert_07_clarity.md:新增“科学意义与领域影响专家”“可读性与论证清晰度专家”,补足现实评审中对项目价值与可读性的审视维度。SKILL.md/README.md/references/master_prompt_template.md/references/aggregation_rules.md:统一改为 7 位专家口径;组内共识阈值相应从“至少 3 位”提高为“至少 4 位”。scripts/validate_skill.py:校验规则同步升级为要求 7 个专家画像文件,避免后续回退到 5 人配置。config.yaml:新增stage_assessment配置节,并将版本号1.0.0 → 1.1.0;默认要求在报告中输出“基于当前版本直接送审”的函评/会评二元判断。SKILL.md:将“阶段判断”升级为默认输出,要求 AI 必须明确写出“函评给过/不给过”“会评给过/不给过”,并补充把握度、主要依据与翻盘关键。references/master_prompt_template.md/references/aggregation_rules.md:补齐组内与跨组的函评/会评聚合格式,确保最终报告先给用户一个明确底判断。references/expert_*.md:要求每位专家原始意见结尾单独给出函评/会评二元结论,避免只有汇总判断没有专家立场。README.md:同步用户文档与报告结构示例,解释为什么默认给出“函评/会评给不过”判断。
[1.0.0] - 2026-02-24
Changed(变更)
config.yaml:版本号0.5.0 → 1.0.0,标记为正式稳定版本
[0.5.0] - 2026-02-14
Added(新增)
- 新增
scripts/finalize_output.py:将“输出整理”确定性脚本化(支持 DRY-RUN /--apply),自动创建.nsfc-reviewers/目录结构、归档master_prompt.txt与plan*.json、并将 legacy.parallel_vibe/运行环境迁移到.nsfc-reviewers/parallel-vibe/,提高并行评审可追溯性。 - 新增轻量测试会话
tests/并行硬编码-优化-v202602142238/:覆盖并行/串行两种整理路径的最小可复现验证。
Changed(变更)
SKILL.md:将“阶段五:输出整理”升级为强制执行,并补齐“并行/串行模式”前置判断、脚本推荐用法与验证清单,降低 AI 跳过整理步骤的概率。config.yaml:output_settings新增输出整理校验开关(enforce_output_finalization/warn_missing_intermediate/validation_level);版本号0.4.1 → 0.5.0。README.md:新增输出整理脚本使用说明,明确中间目录的归档内容与触发时机。
[0.4.1] - 2026-02-14
Added(新增)
- 新增
scripts/list_proposal_files.py:确定性递归发现待评审.tex文件,并默认跳过panels/、.nsfc-reviewers/、.parallel_vibe/等中间/交付目录,降低误扫与递归污染风险。 - 新增轻量测试会话
tests/v202602140830/与tests/B轮-v202602140830/:覆盖文件发现脚本、路径安全门禁与会话结构自检(PLAN/REPORT + artifacts)。
Fixed(修复)
config.yaml:不再默认排除main.tex,避免目录模式下误判“无 .tex 文件”导致阻塞;版本号0.4.0 → 0.4.1。scripts/cleanup_intermediate.py:对--intermediate-dir增加 fail-fast 校验(相对路径、无..、不含斜杠)。scripts/validate_skill.py:新增对output_settings.panel_dir/intermediate_dir的路径安全校验,并纳入list_proposal_files.py存在性检查。
Changed(变更)
SKILL.md:阶段一补充确定性文件发现命令;并行阶段补齐中间目录预创建与快照提示,减少执行漂移。- 清理仓库内残留
.DS_Store文件,减少无意义 diff 噪声。
[0.4.0] - 2026-02-14
Added(新增)
- 新增“输出整理”阶段:将最终交付(聚合报告 +
panels/)与中间过程(并行环境/日志/快照)显式隔离到config.yaml:output_settings.intermediate_dir。 - 新增
scripts/cleanup_intermediate.py:清理.nsfc-reviewers/中的并行运行环境与过期日志(默认 DRY-RUN,--apply才执行删除)。 - 新增轻量测试会话
tests/实例辅助优化-v202602140804/(PLAN.md、REPORT.md与_artifacts/)。
Changed(变更)
config.yaml:新增output_settings.panel_dir/hide_intermediate/intermediate_dir与maintenance.cleanup;版本号0.3.1 → 0.4.0。SKILL.md:并行模式默认将.parallel_vibe落到中间目录并给出输出整理与迁移口径(兼容旧实例)。README.md:补充“最终交付 vs 中间过程”目录结构与清理脚本用法说明。
[0.3.1] - 2026-02-13
Added(新增)
- 新增
scripts/build_parallel_vibe_plan.py:为并行多组评审生成parallel-vibe --plan-file所需的plan.json(禁用 synth,避免额外消耗)。
Changed(变更)
SKILL.md:并行模式改为使用parallel-vibe --plan-file+--src-dir/--out-dir(适配parallel-vibe新工作流),并明确 thread 输出位于workspace/。references/master_prompt_template.md:输出文件名改为{panel_output_filename}占位符,避免与config.yaml重复硬编码。config.yaml:新增parallel_review.runner_profile,用于计划文件中的runner.profile;版本号0.3.0 → 0.3.1。README.md:补充并行模式与parallel-vibe --plan-file的关系说明。scripts/validate_skill.py:增强校验(要求--plan-file工作流、禁止旧--runner旗标、校验模板占位符存在)。
[0.3.0] - 2026-02-10
Added(新增)
- 支持“多组独立评审委员会并行评审”:每组固定 5 位专家,组内聚合后再跨组聚合输出。
- 新增
references/:专家画像、master prompt 模板与跨组聚合规则从配置中分离为可维护的 Markdown 文件。 - 新增轻量测试会话
tests/独立专家-v202602110611/(PLAN.md、REPORT.md与_artifacts/)。
Changed(变更)
config.yaml:并行参数从reviewer_count语义升级为panel_count(组数),并通过prompt_file引用references/模板;版本号0.2.0 → 0.3.0。SKILL.md:工作流从“每 thread 一个专家”改为“每 thread 一组专家(5 位)”,并补齐跨组共识聚合口径。README.md:更新为多组评审模式用户指南,补充成本说明与输出解读。scripts/validate_skill.py:增加对references/与新配置结构的校验,防止 prompt 回流到config.yaml。
Removed(移除)
- 移除
config.yaml中内联保存的专家 prompt 文本(原style字段)。
[0.2.0] - 2026-02-10
Added(新增)
- 新增并行独立评审能力:支持多位虚拟专家并行评审后聚合输出。
config.yaml新增parallel_review配置节:并行开关、专家数、专家画像、聚合规则。SKILL.md新增reviewer_count输入参数与并行报告结构规范。SKILL.md新增对parallel-vibe的依赖说明及脚本路径发现顺序。- 新增轻量测试会话
tests/v202602101947/(TEST_PLAN.md、TEST_REPORT.md与_artifacts/)。
Changed(变更)
SKILL.md阶段三重构为“并行优先 + 串行退化”双路径,阶段四改为共识聚合流程。README.md更新为并行模式用户指南,补充并行触发示例、退化机制和参数说明。config.yaml版本号从0.1.1升级到0.2.0(向下兼容的功能性新增)。
[0.1.1] - 2026-02-10
Added(新增)
- 新增
CHANGELOG.md,用于记录技能级变更(版本号以config.yaml:skill_info.version为单一真相来源)。 - 新增
scripts/validate_skill.py:面向维护者的轻量一致性自检(frontmatter/config/通用性约束)。
Changed(变更)
- 重写
SKILL.md:增加“非官方声明”“安全与隐私”“输入契约/Fail Fast”“证据锚点硬门槛”,并要求以config.yaml作为执行时的清单来源。 - 更新
README.md:移除具体日期与疑似真实示例内容,补充隐私提示,并将“评审等级/资助建议”明确为可选输出。 README.md:新增“维护者自检”指引(python3 scripts/validate_skill.py)。
Fixed(修复)
- 修复“遵循/官方评审标准”这类可能造成权威背书错觉的表述,统一改为经验性改进清单口径。
[0.1.0] - 2026-02-10
Added(新增)
- 初始化技能:提供 NSFC 标书多维度评审流程、问题分级与默认输出约定。
# nsfc-reviewers 配置文件
# 版本号管理:本文件是版本信息的唯一来源(Single Source of Truth)
# 技能基本信息
skill_info:
name: nsfc-reviewers
version: 1.4.1
description: "模拟领域专家视角对 NSFC 标书进行多维度评审,输出分级问题与可执行修改建议,并识别受资助额度限制的设计妥协"
author: "Bensz Conan"
category: writing
# 阶段性过会判断(默认写入报告)
stage_assessment:
enabled: true
include_in_report: true
# 必须基于“当前版本若直接送审”进行判断,不允许假设后续还能修改
judge_current_draft_only: true
# 必须给出二元结论;如不确定,用 confidence 表达把握,不允许只写“边缘/不好说”
require_binary_verdict: true
confidence_levels: [高, 中, 低]
stages:
letter_review:
name: 函评
pass_label: 给过
fail_label: 不给过
key_checks:
- 邻近领域专家能否在独立阅读中快速理解项目价值
- 创新点、科学问题与技术路线是否能在首次通读时成立
- 研究基础与申请人能力是否足以支撑“建议资助”判断
panel_review:
name: 会评
pass_label: 给过
fail_label: 不给过
stricter_than_letter_review: true
key_checks:
- 项目是否具备相对竞争力,能在同场比较中进入前列
- 核心亮点是否足够鲜明,能支撑专家讨论与投票
- 现有短板是否会在会评讨论中被放大
# 资助额度约束识别(默认写入相关问题)
funding_context:
enabled: true
require_contextualized_judgement: true
# 若无法识别项目类型/金额,默认保守处理:不硬套额度区间
unknown_policy: conservative
project_types:
youth:
labels: [青年基金, 青年项目, NSFC_Young, Young]
typical_budget_wan_range: [30, 40]
interpretation: 优先支持单一主问题与最小可行验证闭环,不宜按“大课题/平台型项目”标准苛责完整度
general:
labels: [面上项目, 面上, NSFC_General, General]
typical_budget_wan_range: [50, 60]
interpretation: 可支持较完整研究链条,但通常不足以覆盖大队列、多中心、全机制打通或重资产平台化设计
report_requirements:
- 当某项短板明显与资助额度约束有关时,必须显式标注“受资助额度限制”
- 不得将受限妥协简单归因为申请人能力不足、视野不足或设计偷懒
- 必须同时说明该受限设计在当前基金框架下的合理性边界
- 必须补充“若资助不受限时的完整设计”作为对照,提醒用户理想完整版应包含什么
# 评审维度配置
review_dimensions:
- id: innovation
name: 创新性评价
weight: 25
key_points:
- 科学问题的原创性和前沿性
- 是否提出颠覆性假说或新视角
- 是否存在"新瓶装旧酒"问题
- 创新点是否明确可验证
common_issues:
- 创新性不足,仅是简单的方法移植
- 科学问题过于简单,缺乏深度
- 创新点表述模糊,难以评估
- id: hypothesis
name: 科学假说与科学问题
weight: 20
key_points:
- 科学假说是否明确可检验
- 科学问题是否清晰聚焦
- 研究目标与科学问题是否一致
- 假说的理论依据是否充分
common_issues:
- 科学假说太浅,没有资助价值
- 科学问题不清晰,让评审专家猜测
- 研究目标与假说脱节
- id: methodology
name: 研究方案与可行性
weight: 20
key_points:
- 技术路线是否合理清晰
- 研究方法是否先进可行
- 关键科学问题的解决方案
- 是否有备选方案应对风险
common_issues:
- 技术路线不清晰,逻辑混乱
- 研究方法过于简单或过于复杂
- 缺乏可行性分析
- 没有考虑潜在风险和应对措施
- id: foundation
name: 研究基础
weight: 15
key_points:
- 前期工作是否与本项目相关
- 是否有支撑性的实验结果
- 发表论文的质量和相关性
- 申请人在该领域的积累
common_issues:
- 研究基础薄弱,没有相关实验结果
- 前期工作与本项目关联性差
- 缺乏高质量的相关论文支撑
- id: team
name: 研究团队
weight: 10
key_points:
- 申请人的研究经历和能力
- 团队成员的专业背景和分工
- 团队协作能力
- 是否具备完成项目的条件
common_issues:
- 团队成员专业背景与项目不匹配
- 分工不明确
- 缺乏关键技术人员
- id: outcomes
name: 预期成果与科学意义
weight: 10
key_points:
- 预期成果是否明确可量化
- 科学意义是否重要
- 应用前景是否合理
- 成果与投入是否匹配
common_issues:
- 预期成果过于宏大或过于保守
- 科学意义阐述不清
- 应用前景脱离实际
# 问题严重程度定义
severity_levels:
P0:
name: 致命问题
description: 严重影响资助决策的问题,必须修改
color: red
P1:
name: 重要问题
description: 需要重点修改的问题
color: orange
P2:
name: 建议改进
description: 可以改进的细节问题
color: yellow
# 评审等级定义
review_grades:
excellent:
name: 优
description: 创新性强,科学意义重要,方案可行,基础扎实
funding_suggestion: 优先资助
good:
name: 良
description: 立意新颖,有一定科学意义,方案较好
funding_suggestion: 可资助
fair:
name: 中
description: 有一定研究价值,但存在明显不足
funding_suggestion: 可资助(需重点修改后再评估)
poor:
name: 差
description: 创新性不足或可行性差,不建议资助
funding_suggestion: 不予资助
# 并行独立评审配置
parallel_review:
# 是否启用并行评审模式
enabled: true
# 默认评审组数量(每组固定 7 位专家;默认取 3 组以平衡成本与共识稳定性)
default_panel_count: 3
# 最大评审组数量(超过 5 组通常收益递减、成本显著上升)
max_panel_count: 5
# parallel-vibe plan.threads[*].runner.type(评审任务推荐 claude)
runner: claude
# parallel-vibe plan.threads[*].runner.profile(default|fast|deep)
runner_profile: deep
# 超时时间(秒),0 表示不限时
timeout_seconds: 0
# 各组评审输出的文件名
panel_output_filename: panel_review.md
# 专家画像定义(仅参数;prompt 内容见 prompt_file 引用)
reviewer_personas:
- id: innovation_expert
name: 学术前沿与创新性专家
prompt_file: references/expert_01_innovation.md
primary_dimensions: [innovation, hypothesis]
secondary_dimensions: [outcomes]
- id: methodology_expert
name: 研究方法与可行性专家
prompt_file: references/expert_02_methodology.md
primary_dimensions: [methodology, outcomes]
secondary_dimensions: [hypothesis]
- id: foundation_expert
name: 研究基础与团队评估专家
prompt_file: references/expert_03_foundation.md
primary_dimensions: [foundation, team]
secondary_dimensions: [methodology]
- id: critical_reviewer
name: 严格综合评审专家
prompt_file: references/expert_04_critical.md
primary_dimensions: [innovation, methodology, hypothesis]
secondary_dimensions: [foundation, team, outcomes]
- id: constructive_reviewer
name: 建设性评审专家
prompt_file: references/expert_05_constructive.md
primary_dimensions: [methodology, foundation, outcomes]
secondary_dimensions: [innovation, hypothesis, team]
- id: significance_reviewer
name: 科学意义与领域影响专家
prompt_file: references/expert_06_significance.md
primary_dimensions: [innovation, outcomes]
secondary_dimensions: [hypothesis, foundation]
- id: clarity_reviewer
name: 可读性与论证清晰度专家
prompt_file: references/expert_07_clarity.md
primary_dimensions: [hypothesis, methodology, team]
secondary_dimensions: [innovation, outcomes, foundation]
# 聚合策略
aggregation:
# 跨组共识阈值(0.0-1.0),默认 60% 以上组一致
consensus_threshold: 0.6
# 是否保留各专家原始评审意见作为附录
keep_individual_reviews: true
# 输出设置
output_settings:
default_filename: comments-from-nsfc-reviewers.md
panel_dir: panels
format: markdown
# 是否将中间过程文件隐藏到 intermediate_dir(推荐开启,减少根目录噪声)
hide_intermediate: true
# 中间过程统一托管目录(相对 review 根目录,放置 parallel-vibe 环境、日志与快照)
intermediate_dir: .bensz-api/skills/nsfc-reviewers
# 是否强制执行“输出整理”阶段(强烈建议开启,保证并行环境/计划文件可追溯)
enforce_output_finalization: true
# 若检测到多组评审但未生成 intermediate_dir(或缺少 parallel-vibe 环境),是否发出警告
warn_missing_intermediate: true
# 校验级别:warn(仅警告)或 error(中断执行)
validation_level: warn
include_sections:
- basic_info
- stage_assessment
- overall_evaluation
- dimension_reviews
- suggestions_summary
- expert_comments
# 标书文件识别
proposal_files:
# 常见的标书章节文件名模式
patterns:
- "*.tex"
- "摘要*.tex"
- "立项依据*.tex"
- "研究内容*.tex"
- "研究方案*.tex"
- "研究基础*.tex"
- "工作条件*.tex"
- "特色与创新*.tex"
- "研究计划*.tex"
# 排除的文件
exclude:
- "preamble.tex"
- "*.cls"
- "*.sty"
# 维护与清理策略(供 scripts/cleanup_intermediate.py 使用)
maintenance:
cleanup:
# 仅清理 logs/ 下非 plans 的旧文件;plans 默认保留以便复现
logs_max_age_days: 14
keep_plans: true
# snapshot 默认保留(通常较小且可追溯)
keep_snapshot: true
nsfc-reviewers — 用户使用指南
本 README 面向使用者:如何触发并正确使用 nsfc-reviewers skill。 执行规范见 SKILL.md;默认参数见 config.yaml。
这是什么
模拟领域专家视角对 NSFC 标书进行多维度评审,输出分级问题与可执行修改建议。
核心价值:
- 7 位专家角色(创新性/可行性/基础/严格/建设性/科学意义/清晰度)独立评审
- 支持多组并行评审(默认 3 组、最多 5 组,共 21–35 人次)
- 默认给出“基于当前版本直接送审”的函评/会评二元判断(给过 / 不给过)
- 问题分级(P0 致命 → P1 重要 → P2 建议)+ 证据锚点
- 会识别“受资助额度限制的设计妥协”,避免把预算约束误判为纯粹能力问题
- 跨组共识识别 + 最小修改序列
重要声明:本技能输出仅用于写作改进与自查;其中“函评/会评给不过”属于当前版本送审风险预估,不代表任何官方评审口径,也不构成资助结论。
快速开始
开发者推荐
使用 nsfc-reviewers skill 开 3 组评审团对本标书进行评审,按默认规则将结果保存到标书目录最推荐:让 AI 直接评审
请评审 /path/to/your/nsfc_proposal 这个国自然标书指定评审组数(增强置信度)
请用 3 组评审专家评审 /path/to/your/nsfc_proposal保存到指定路径
请评审 /path/to/your/nsfc_proposal,把意见保存到 /path/to/review_report.md重点关注某维度
请评审 /path/to/your/nsfc_proposal,重点关注创新性和可行性使用场景
| 你的需求 | 推荐用法 | 说明 |
|---|---|---|
| 提交前快速自查 | 1 组评审 | 7 位专家,适合早期版本 |
| 正式提交前准备 | 2-3 组评审 | 跨组共识更可靠 |
| 关键标书深度打磨 | 5 组评审 | 最大覆盖,但成本较高 |
| 定向改进某方面 | focus=创新性 | 聚焦特定维度 |
资助约束识别
本技能会区分两类问题:
- 真正的设计错误:逻辑不成立、假说不可检验、证据链断裂,这类问题会直接影响送审判断。
- 受资助额度限制的现实妥协:例如青年基金常见
30–40w、面上项目常见50–60w的资助框架下,研究内容只能聚焦单一主问题、样本/平台/验证层级只能做到“最小可行闭环”。
当技能判断某个短板与资助额度有关时,报告会:
- 明确写出“受基金所限,此处设计偏弱/存在缺陷”
- 解释为什么这是现实约束下的妥协,而不是简单的“不会设计”
- 同时补充“若资助不受限,一个更完整的设计应如何展开”
这条规则不会替当前版本免责:函评/会评判断仍然只看“如果今天就提交,能不能过”。
并行评审模式
默认启用并行多组评审:
- 默认组数:3 组(21 位专家人次)
- 最大组数:5 组(35 位专家人次)
- 每组专家:7 位(创新性/可行性/基础与团队/严格综合/建设性/科学意义/清晰度)
- 依赖技能:
parallel-vibe(若不可用会自动退化为单组模式)
成本参考:
- 每组评审约相当于 7 位独立专家各完成一次完整评审
- 建议:1 组自查 → 2-3 组正式准备 → 5 组关键标书
输出文件
默认输出到标书目录下(最终交付一眼可见;中间过程统一隐藏):
/path/to/your/nsfc_proposal/
├── comments-from-nsfc-reviewers.md # 【最终交付】跨组聚合报告
├── panels/ # 【最终交付】各组原始评审
│ ├── G001.md
│ ├── G002.md
│ └── ...
└── .bensz-api/skills/nsfc-reviewers/ # 【中间过程】parallel-vibe 环境、日志与快照
├── parallel-vibe/ # 并行运行环境(按 project_id 归档)
├── logs/ # master prompt 与计划文件(用于追溯)
│ ├── master_prompt.txt
│ └── plans/
└── snapshot/ # 可选:标书快照说明:
comments-from-nsfc-reviewers.md的默认文件名见config.yaml:output_settings.default_filename。panels/目录名见config.yaml:output_settings.panel_dir。.bensz-api/skills/nsfc-reviewers/目录名见config.yaml:output_settings.intermediate_dir。
输出整理(强制,推荐脚本)
当你使用并行评审(多组)时,根目录可能会出现 .parallel-vibe/、master_prompt.txt、plan*.json 等中间文件。为保证评审过程可追溯,推荐在最后统一“输出整理”(脚本仍兼容旧 .parallel_vibe/ 实例):
# DRY-RUN:仅打印动作
python3 scripts/finalize_output.py --review-path /path/to/your/nsfc_proposal --panel-count 3
# APPLY:实际整理(会将并行环境/日志/快照迁移到 .bensz-api/skills/nsfc-reviewers/)
python3 scripts/finalize_output.py --review-path /path/to/your/nsfc_proposal --panel-count 3 --apply清理中间文件(可选)
当你确认不再需要复现 parallel-vibe 运行过程,或中间文件过大时,可用本技能自带脚本清理:
# 默认 DRY-RUN:只输出将要执行的动作
python3 scripts/cleanup_intermediate.py --review-path /path/to/your/nsfc_proposal --delete-parallel-vibe
# 实际执行删除(不可逆)
python3 scripts/cleanup_intermediate.py --review-path /path/to/your/nsfc_proposal --delete-parallel-vibe --apply报告结构(并行模式):
# 国自然标书评审意见
## 评审配置
- 评审组数:N 组
- 每组专家:7 位
- 总专家人次:N×7 人次
## 阶段判断(基于当前版本直接送审)
### 函评
- 结论:给过 / 不给过
- 判断把握:高 / 中 / 低
- 评审组倾向:X/N 组判给过,Y/N 组判不给过
- 主要依据:...
### 会评
- 结论:给过 / 不给过
- 判断把握:高 / 中 / 低
- 评审组倾向:X/N 组判给过,Y/N 组判不给过
- 主要依据:...
## 跨组共识(多组一致指出)
### P0 级(致命问题)
### P1 级(重要问题)
## 独立观点(单一组提出)
### 来自组 G001
## 修改建议汇总
(按 P0 → P1 → P2 排序的最小修改序列)
## 附录:各组原始评审报告(可选)评审维度
来自 config.yaml:review_dimensions:
| 维度 | 权重 | 关注点 |
|---|---|---|
| 创新性评价 | 25% | 原创性、前沿性、颠覆性假说 |
| 科学假说与问题 | 20% | 假说可检验性、问题聚焦度 |
| 研究方案与可行性 | 20% | 技术路线、方法先进性、风险预案 |
| 研究基础 | 15% | 前期工作、论文支撑 |
| 研究团队 | 10% | 背景、分工、协作能力 |
| 预期成果与科学意义 | 10% | 成果可量化、应用前景 |
问题分级
| 级别 | 含义 | 处理优先级 |
|---|---|---|
| P0 | 致命问题,严重影响资助判断 | 必须修改 |
| P1 | 重要问题,显著影响专业印象 | 重点修改 |
| P2 | 建议改进,不影响主体成立 | 可选优化 |
每条 P0/P1 问题包含:
- 证据锚点:文件名 + 章节标题/关键句
- 现象:为什么这是问题
- 影响:如何影响评审判断
- 建议:可执行的修改方案
- 验证:改完如何自检
- 资助约束说明(如适用):说明该缺陷是否与青年/面上等有限资助额度有关
- 完整设计参考(如适用):若资助不受限,一个更完整的研究设计应包含什么
隐私与边界
- 默认将标书视为敏感内容,仅处理你明确给出的路径
- 除非你明确要求,默认不联网、不外发原文大段内容
- 本技能只做文本读取与评审,不执行 LaTeX 编译或其他脚本
常见问题
Q:评审需要多长时间?
单组评审约需 5-10 分钟(取决于标书长度);多组并行评审会叠加时间,但并行模式下各组可同时执行。
Q:专家画像是什么?
7 位专家角色分别关注不同维度: 1. 学术前沿与创新性专家:创新性、科学假说 2. 研究方法与可行性专家:方法、预期成果 3. 研究基础与团队评估专家:基础、团队 4. 严格综合评审专家:全局视角,更挑剔 5. 建设性评审专家:关注改进空间 6. 科学意义与领域影响专家:重要性、产出牵引力 7. 可读性与论证清晰度专家:邻域可理解性、结构清晰度
Q:跨组共识如何判定?
默认阈值 60%(见 config.yaml:parallel_review.aggregation.consensus_threshold),即 N 组中至少 ceil(N × 0.6) 组指出同一问题才认定为跨组共识。
Q:如何只看 P0 问题?
评审报告按 P0 → P1 → P2 顺序组织,你可以只关注 P0 章节。
Q:为什么现在会直接写“函评给不过 / 会评给不过”?
这是为了让你对当前版本的送审风险有更直观的判断。技能会强制基于“如果今天就提交”来做二元结论,并配上把握度与关键依据;如果判“不过”,还会指出最优先的翻盘修改点。
Q:为什么报告会说“受基金所限,此处设计偏弱”?
因为青年基金、面上项目的资助额度本来就有限。某些实验链条、样本规模或平台建设不够“豪华”,未必是申请人不会设计,而可能是预算约束下的现实取舍。技能会把这种情况单独标出来,同时告诉你“如果经费不受限,完整版设计应长什么样”。
配置说明
配置文件位于 config.yaml:
review_dimensions:评审维度、权重、要点severity_levels:P0/P1/P2 分级口径stage_assessment:函评/会评判断规则、标签与把握度口径funding_context:资助额度约束识别规则,以及“受限妥协”如何写入报告parallel_review:并行评审配置(组数、专家画像引用、聚合策略)output_settings:输出文件名、panels/目录、中间过程隐藏目录与章节开关;以及输出整理校验(enforce_output_finalization/validation_level)
专家画像模板位于 references/expert_*.md。
相关技能
parallel-vibe:并行多工作区执行基础设施nsfc-justification-writer:立项依据写作nsfc-research-content-writer:研究内容写作nsfc-research-foundation-writer:研究基础写作
---
版本信息见 config.yaml:skill_info.version。
跨组聚合规则
此文件定义如何聚合多个评审组(多个 thread)的 panel_review.md 输出。
聚合流程
1. 读取所有组报告:从 N 个 thread 读取 panel_review.md 2. 跨组共识识别:
- 至少
min_panels个组指出同一问题 → 跨组共识 min_panels = ceil(N * consensus_threshold)(默认consensus_threshold = 0.6)
3. 严重度二次升级:
- 跨组共识问题:P2→P1→P0(P0 不再升级)
4. 去重合并:
- 相同问题的不同表述合并为一条
- 保留最丰富的证据锚点与最详细的建议
5. 来源标注:
- 跨组共识:标注“跨组共识(N 组)”
- 单一组意见:标注“来自组 GXXX”
6. 阶段判断聚合:
- 对函评/会评分别统计各组“给过/不给过”结论
- 若最终“函评不给过”,则“会评”必须同步输出“不给过”
- 会评判断要比函评更严格,可因相对竞争力不足而单独判“不给过”
7. 资助约束保真:若某条问题被多组判断为“受资助额度限制的设计妥协”,聚合时必须保留这层归因,并同步保留“完整设计参考”对照说明 8. 优先级排序:
- P0(跨组共识)→ P0(单组)→ P1(跨组共识)→ P1(单组)→ P2
输出结构(markdown)
# 国自然标书评审意见(N 组独立专家,每组 7 位专家)
## 评审配置
- 评审组数:{panel_count} 组
- 每组专家:7 位
- 总专家人次:{panel_count * 7} 人次
## 阶段判断(基于当前版本直接送审)
### 函评
- 结论:给过 / 不给过
- 判断把握:高 / 中 / 低
- 评审组倾向:X/N 组判给过,Y/N 组判不给过
- 主要依据:...
- 翻盘关键(仅在不给过时必写):...
### 会评
- 结论:给过 / 不给过
- 判断把握:高 / 中 / 低
- 评审组倾向:X/N 组判给过,Y/N 组判不给过
- 主要依据:...
- 翻盘关键(仅在不给过时必写):...
## 跨组共识(多组一致指出)
### P0 级(致命问题,必须修改)
### P1 级(重要问题,建议修改)
### P2 级(可选改进)
## 独立观点(单一组提出)
### 来自组 G001
## 修改建议汇总(最小可行修改序列)
## 附录:各组原始评审报告对任何“受资助额度限制”的问题,聚合后不得只保留“方案偏弱”这类空泛结论;必须保留资助约束归因与“若资助不受限时的完整设计参考”。
专家画像:学术前沿与创新性评审专家
角色定位
你是一位学术前沿与创新性评审专家。你尤其关注科学问题的原创性和前沿性,善于识别“新瓶装旧酒”和“伪创新”,对科学假说的深度和可检验性要求严格。
评审维度
主要维度:
- 创新性评价
- 科学假说与科学问题
次要维度:
- 预期成果与科学意义
评审风格
1. 原创性审视:科学问题是否具有真正的原创性,还是仅仅是已知方法的简单移植 2. 前沿性判断:是否处于学科前沿,是否有潜力推动领域发展 3. 假说深度:科学假说是否有足够的深度,能否承载项目的研究目标 4. 伪创新识别:警惕“新术语包装旧内容”“技术堆砌无理论支撑”等问题 5. 可检验性:假说是否明确可检验,研究目标与假说是否一致
输出要求
每条问题必须包含:
- 证据锚点:文件名 + 章节标题/关键句
- 现象描述:为什么这是问题
- 影响分析:会如何影响评审判断
- 修改建议:可执行的修改方案
- 验证方式:改完如何自检
- 若与资助额度限制相关:补充“资助约束说明”与“若资助不受限时的完整设计参考”
原始评审结尾必须补充“基于当前版本直接送审”的阶段判断:
- 函评:给过 / 不给过
- 会评:给过 / 不给过
- 判断把握:高 / 中 / 低
- 一句话依据:必须点明最关键的支持点或卡点
专家画像:研究方法与可行性评审专家
角色定位
你是一位研究方法与可行性评审专家。你注重技术路线的严谨性和逻辑闭环,善于发现方案中的逻辑漏洞、可行性风险和潜在的技术障碍。
评审维度
主要维度:
- 研究方案与可行性
- 预期成果与科学意义
次要维度:
- 科学假说与科学问题
评审风格
1. 逻辑闭环:技术路线是否逻辑完整,是否存在断点 2. 可行性评估:研究方法是否先进可行,是否有备选方案 3. 风险识别:潜在技术障碍是否被识别,应对措施是否充分 4. 方案匹配度:研究方法是否与科学问题匹配,是否存在过度设计
输出要求
每条问题必须包含:
- 证据锚点:文件名 + 章节标题/关键句
- 现象描述:为什么这是问题
- 影响分析:会如何影响评审判断
- 修改建议:可执行的修改方案
- 验证方式:改完如何自检
- 若与资助额度限制相关:补充“资助约束说明”与“若资助不受限时的完整设计参考”
原始评审结尾必须补充“基于当前版本直接送审”的阶段判断:
- 函评:给过 / 不给过
- 会评:给过 / 不给过
- 判断把握:高 / 中 / 低
- 一句话依据:必须点明最关键的支持点或卡点
专家画像:研究基础与团队评估专家
角色定位
你是一位研究基础与团队评估专家。你重点关注申请人的前期工作积累、团队能力与项目匹配度,善于评估项目能否按计划完成。
评审维度
主要维度:
- 研究基础
- 研究团队
次要维度:
- 研究方案与可行性
评审风格
1. 相关性审视:前期工作是否与本项目直接相关,是否能支撑关键技术路线 2. 证据链评估:是否有支撑性的实验结果/原型/数据,证据是否可追溯 3. 团队匹配度:成员背景与分工是否对位研究内容,关键能力是否覆盖 4. 完成能力:工作条件/平台资源是否到位,计划是否现实可执行
输出要求
每条问题必须包含:
- 证据锚点:文件名 + 章节标题/关键句
- 现象描述:为什么这是问题
- 影响分析:会如何影响评审判断
- 修改建议:可执行的修改方案
- 验证方式:改完如何自检
- 若与资助额度限制相关:补充“资助约束说明”与“若资助不受限时的完整设计参考”
原始评审结尾必须补充“基于当前版本直接送审”的阶段判断:
- 函评:给过 / 不给过
- 会评:给过 / 不给过
- 判断把握:高 / 中 / 低
- 一句话依据:必须点明最关键的支持点或卡点
专家画像:严格综合评审专家
角色定位
你是一位以高标准著称的严格评审专家。你对每个维度都以顶级标准审视,倾向于发现深层结构性问题,不放过任何逻辑薄弱环节。
评审维度
主要维度:
- 创新性评价
- 研究方案与可行性
- 科学假说与科学问题
次要维度:
- 研究基础
- 研究团队
- 预期成果与科学意义
评审风格
1. 顶级标准:以领域顶刊/重点项目的完整性标准审视 2. 结构性问题:关注标书结构、论证链条、目标-方法-验证的一致性 3. 薄弱环节:不放过任何“看似合理但不可检验/不可复现/不可落地”的环节 4. 深度质疑:对关键论断做可证伪式质疑,指出缺失的对照与关键证据
输出要求
每条问题必须包含:
- 证据锚点:文件名 + 章节标题/关键句
- 现象描述:为什么这是问题
- 影响分析:会如何影响评审判断
- 修改建议:可执行的修改方案
- 验证方式:改完如何自检
- 若与资助额度限制相关:补充“资助约束说明”与“若资助不受限时的完整设计参考”
原始评审结尾必须补充“基于当前版本直接送审”的阶段判断:
- 函评:给过 / 不给过
- 会评:给过 / 不给过
- 判断把握:高 / 中 / 低
- 一句话依据:必须点明最关键的支持点或卡点
专家画像:建设性评审专家
角色定位
你是一位建设性评审专家。你在指出问题的同时提供详细、可操作的改进方案,注重建议的落地可行性,帮助申请人找到最小修改路径。
评审维度
主要维度:
- 研究方案与可行性
- 研究基础
- 预期成果与科学意义
次要维度:
- 创新性评价
- 科学假说与科学问题
- 研究团队
评审风格
1. 问题-方案配对:每个问题都配对具体、可操作的改进方案 2. 最小修改路径:优先给出“最小修改即可显著提升”的改法 3. 落地可行性:建议必须是申请人在现有条件下可执行的 4. 优先级排序:指出哪些修改是必须的,哪些是锦上添花
输出要求
每条问题必须包含:
- 证据锚点:文件名 + 章节标题/关键句
- 现象描述:为什么这是问题
- 影响分析:会如何影响评审判断
- 修改建议:可执行的修改方案(尽量写到“怎么改”和“改到什么程度算够”)
- 验证方式:改完如何自检
- 若与资助额度限制相关:补充“资助约束说明”与“若资助不受限时的完整设计参考”
原始评审结尾必须补充“基于当前版本直接送审”的阶段判断:
- 函评:给过 / 不给过
- 会评:给过 / 不给过
- 判断把握:高 / 中 / 低
- 一句话依据:必须点明最关键的支持点或卡点
专家画像:科学意义与领域影响专家
角色定位
你是一位关注“为什么值得资助”的科学意义与领域影响专家。你重点判断项目是否真的解决重要问题、是否能推动领域发展,以及其预期成果是否具有足够的学术牵引力。
评审维度
主要维度:
- 创新性评价
- 预期成果与科学意义
次要维度:
- 科学假说与科学问题
- 研究基础
评审风格
1. 重要性优先:先看问题是否真重要,而不是技术堆得是否复杂 2. 领域位置判断:评估项目在当前领域中的相对位置,是补充性工作还是可能改变认知/方法版图 3. 产出牵引力:关注预期成果是否能形成有说服力的学术输出,而非空泛愿景 4. 价值-投入匹配:如果工作量很大但科学增量有限,要明确指出性价比问题
输出要求
每条问题必须包含:
- 证据锚点:文件名 + 章节标题/关键句
- 现象描述:为什么这是问题
- 影响分析:会如何影响评审判断
- 修改建议:可执行的修改方案
- 验证方式:改完如何自检
- 若与资助额度限制相关:补充“资助约束说明”与“若资助不受限时的完整设计参考”
原始评审结尾必须补充“基于当前版本直接送审”的阶段判断:
- 函评:给过 / 不给过
- 会评:给过 / 不给过
- 判断把握:高 / 中 / 低
- 一句话依据:必须点明最关键的支持点或卡点
专家画像:可读性与论证清晰度专家
角色定位
你是一位高度重视“邻近领域专家是否看得懂、信得过”的可读性与论证清晰度专家。你重点评估标书结构是否清楚、关键论证是否顺滑,以及核心卖点能否在有限阅读时间内被快速抓住。
评审维度
主要维度:
- 科学假说与科学问题
- 研究方案与可行性
- 研究团队
次要维度:
- 创新性评价
- 预期成果与科学意义
- 研究基础
评审风格
1. 首读友好:判断专家首次通读时能否迅速抓住核心问题、创新点与路线图 2. 结构连续性:关注标题、段落、图表与论证链是否彼此呼应,避免“写了很多但没有说服力” 3. 信息负荷控制:警惕术语堆砌、句子过长、段落过密、逻辑跳跃 4. 邻域可理解性:默认读者不是最细分方向同行,强调表达清楚胜过术语炫技
输出要求
每条问题必须包含:
- 证据锚点:文件名 + 章节标题/关键句
- 现象描述:为什么这是问题
- 影响分析:会如何影响评审判断
- 修改建议:可执行的修改方案
- 验证方式:改完如何自检
- 若与资助额度限制相关:补充“资助约束说明”与“若资助不受限时的完整设计参考”
原始评审结尾必须补充“基于当前版本直接送审”的阶段判断:
- 函评:给过 / 不给过
- 会评:给过 / 不给过
- 判断把握:高 / 中 / 低
- 一句话依据:必须点明最关键的支持点或卡点
Master Prompt 模板
供 nsfc-reviewers 生成每个评审组的统一任务说明。这里只保留必须注入的结构和硬约束。必填占位符
{expert_list}{proposal_summary}{panel_output_filename}
Prompt 骨架
# NSFC 标书专家组评审任务
你是一个由 7 位独立专家组成的评审组:
{expert_list}
以下是标书摘要与结构索引:
{proposal_summary}
硬规则:
1. 每位专家独立评审,不参考其他专家意见
2. 每条问题都要有证据锚点
3. 必须按 P0/P1/P2 分级
4. 每位专家都要给函评/会评二元结论
5. 涉及资助约束时,要明确写“受基金所限”的现实取舍
组内聚合:
1. 至少 4 位专家指出的问题视为组内共识
2. 共识问题可升级严重度
3. 去重合并相同问题
4. 聚合函评/会评结论
输出到 `{panel_output_filename}`,并至少包含:
- 阶段判断
- 专家共识
- 独立观点
- 修改建议汇总
- 附录:7 位专家原始意见结果底线
- 每位专家原始意见末尾都必须有:
- 函评:给过 / 不给过
- 会评:给过 / 不给过
- 判断把握:高 / 中 / 低
- 一句话依据
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import yaml
def _read_text(p: Path) -> str:
return p.read_text(encoding="utf-8")
def _load_yaml(p: Path) -> dict:
try:
obj = yaml.safe_load(_read_text(p)) or {}
except Exception as e:
raise ValueError(f"failed to read yaml: {p}: {e}") from e
if not isinstance(obj, dict):
raise ValueError(f"expected yaml mapping: {p}")
return obj
def _discover_parallel_vibe_config_path(skill_root: Path) -> Path | None:
candidates = [
Path.home() / ".claude" / "skills" / "parallel-vibe" / "config.yaml",
Path.home() / ".codex" / "skills" / "parallel-vibe" / "config.yaml",
# In-repo layout: skills/nsfc-reviewers and skills/parallel-vibe are siblings.
skill_root.parent / "parallel-vibe" / "config.yaml",
]
for p in candidates:
try:
if p.exists():
return p
except Exception:
continue
return None
def _thread_id_width(skill_root: Path) -> int:
cfg_path = _discover_parallel_vibe_config_path(skill_root)
if not cfg_path:
return 3
try:
cfg = _load_yaml(cfg_path)
except Exception:
return 3
defaults = cfg.get("defaults") if isinstance(cfg, dict) else None
if not isinstance(defaults, dict):
return 3
w = defaults.get("thread_id_width", 3)
try:
w_i = int(w)
except Exception:
return 3
return w_i if 1 <= w_i <= 6 else 3
def main(argv: list[str] | None = None) -> int:
skill_root = Path(__file__).resolve().parents[1]
cfg_path = skill_root / "config.yaml"
if not cfg_path.exists():
print(f"error: missing config.yaml at {cfg_path}", file=sys.stderr)
return 2
cfg = _load_yaml(cfg_path)
pr = cfg.get("parallel_review") if isinstance(cfg, dict) else None
if not isinstance(pr, dict):
print("error: config.yaml missing parallel_review", file=sys.stderr)
return 2
p = argparse.ArgumentParser(prog="build_parallel_vibe_plan.py")
p.add_argument("--panel-count", type=int, required=True, help="评审组数(= threads 数)")
p.add_argument("--master-prompt-file", required=True, help="包含 master prompt 的文本文件路径(UTF-8)")
p.add_argument("--out", required=True, help="输出 plan.json 路径")
p.add_argument("--runner-type", default="", help="覆盖 config.yaml:parallel_review.runner(claude|codex|shell|local)")
p.add_argument("--runner-profile", default="", help="覆盖 config.yaml:parallel_review.runner_profile(default|fast|deep)")
p.add_argument("--runner-model", default="", help="显式指定 runner.model(留空表示使用 CLI 默认或 parallel-vibe config 映射)")
p.add_argument(
"--shell-cmd-template",
default="",
help="当 runner-type=shell 时必填:一条命令模板(必须包含 {prompt} 占位符)",
)
args = p.parse_args(argv)
panel_count = int(args.panel_count)
max_panels = int(pr.get("max_panel_count", 1) or 1)
if panel_count < 1:
print("error: --panel-count must be >= 1", file=sys.stderr)
return 2
if panel_count > max_panels:
print(f"error: --panel-count must be <= {max_panels} (config.yaml:parallel_review.max_panel_count)", file=sys.stderr)
return 2
runner_type = str(args.runner_type or pr.get("runner") or "claude").strip().lower()
if runner_type not in {"claude", "codex", "shell", "local"}:
print("error: --runner-type must be one of claude|codex|shell|local", file=sys.stderr)
return 2
runner_profile = str(args.runner_profile or pr.get("runner_profile") or "default").strip().lower()
if runner_profile not in {"default", "fast", "deep"}:
print("error: --runner-profile must be one of default|fast|deep", file=sys.stderr)
return 2
panel_output_filename = str(pr.get("panel_output_filename") or "panel_review.md").strip()
if not panel_output_filename:
print("error: config.yaml:parallel_review.panel_output_filename is empty", file=sys.stderr)
return 2
prompt_path = Path(str(args.master_prompt_file)).expanduser()
if not prompt_path.exists():
print(f"error: master prompt file not found: {prompt_path}", file=sys.stderr)
return 2
master_prompt = _read_text(prompt_path).strip() + "\n"
# Best-effort placeholder fill to keep the template/config consistent.
master_prompt = master_prompt.replace("{panel_output_filename}", panel_output_filename)
width = _thread_id_width(skill_root)
threads: list[dict] = []
for i in range(1, panel_count + 1):
tid = str(i).zfill(width)
runner_obj: dict = {
"type": runner_type,
"profile": runner_profile,
"model": str(args.runner_model or "").strip(),
"args": [],
}
if runner_type == "shell":
tmpl = str(args.shell_cmd_template or "").strip()
if not tmpl:
print("error: --shell-cmd-template is required when --runner-type=shell", file=sys.stderr)
return 2
if "{prompt}" not in tmpl:
print("error: --shell-cmd-template must contain a '{prompt}' placeholder", file=sys.stderr)
return 2
runner_obj["cmd_template"] = tmpl
threads.append(
{
"thread_id": tid,
"title": f"Panel G{tid}",
"runner": runner_obj,
"prompt": master_prompt,
}
)
plan = {
"plan_version": 1,
"prompt": "nsfc-reviewers parallel panels",
"threads": threads,
"synthesis": {"enabled": False},
}
out_path = Path(str(args.out)).expanduser()
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(str(out_path))
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import shutil
import sys
import time
from pathlib import Path
import yaml
def _read_text(p: Path) -> str:
return p.read_text(encoding="utf-8")
def _load_yaml(p: Path) -> dict:
try:
obj = yaml.safe_load(_read_text(p)) or {}
except Exception as e:
raise ValueError(f"failed to read yaml: {p}: {e}") from e
if not isinstance(obj, dict):
raise ValueError(f"expected yaml mapping: {p}")
return obj
def _within(base: Path, child: Path) -> bool:
try:
child.resolve().relative_to(base.resolve())
return True
except Exception:
return False
def _rm_tree(p: Path, *, apply: bool) -> None:
if not p.exists():
return
if apply:
shutil.rmtree(p)
def _rm_file(p: Path, *, apply: bool) -> None:
if not p.exists():
return
if apply:
p.unlink()
def _pr(msg: str) -> None:
print(msg)
def main(argv: list[str] | None = None) -> int:
skill_root = Path(__file__).resolve().parents[1]
cfg_path = skill_root / "config.yaml"
if not cfg_path.exists():
print(f"error: missing config.yaml at {cfg_path}", file=sys.stderr)
return 2
cfg = _load_yaml(cfg_path)
p = argparse.ArgumentParser(prog="cleanup_intermediate.py")
p.add_argument("--review-path", required=True, help="评审会话目录(通常是标书目录或 review session 根目录)")
p.add_argument(
"--intermediate-dir",
default="",
help="覆盖 config.yaml:output_settings.intermediate_dir(默认 .bensz-api/skills/nsfc-reviewers)",
)
p.add_argument(
"--logs-max-age-days",
type=int,
default=0,
help="覆盖 config.yaml:maintenance.cleanup.logs_max_age_days;仅清理 logs/ 下非 plans 的旧文件",
)
p.add_argument(
"--delete-parallel-vibe",
action="store_true",
help="删除 intermediate_dir 下的 parallel-vibe/、.parallel-vibe/ 与 legacy .parallel_vibe/(以及 review 根目录 legacy .parallel_vibe/,若存在)",
)
p.add_argument(
"--delete-snapshot",
action="store_true",
help="删除 intermediate_dir 下的 snapshot/(默认保留以便追溯)",
)
p.add_argument(
"--apply",
action="store_true",
help="实际执行删除操作(默认仅输出将要执行的动作)",
)
args = p.parse_args(argv)
review_root = Path(str(args.review_path)).expanduser().resolve()
if not review_root.exists() or not review_root.is_dir():
print(f"error: review path is not a directory: {review_root}", file=sys.stderr)
return 2
os_cfg = cfg.get("output_settings") if isinstance(cfg, dict) else None
os_cfg = os_cfg if isinstance(os_cfg, dict) else {}
intermediate_dir_name = str(
args.intermediate_dir or os_cfg.get("intermediate_dir") or ".bensz-api/skills/nsfc-reviewers"
).strip()
if not intermediate_dir_name:
print("error: intermediate_dir is empty", file=sys.stderr)
return 2
p_intermediate = Path(intermediate_dir_name)
if p_intermediate.is_absolute() or ".." in p_intermediate.parts:
print(f"error: intermediate_dir must be a relative name without '..': {intermediate_dir_name!r}", file=sys.stderr)
return 2
intermediate_root = (review_root / intermediate_dir_name).resolve()
if not _within(review_root, intermediate_root):
print(
f"error: intermediate_dir escapes review root: review_root={review_root} intermediate_root={intermediate_root}",
file=sys.stderr,
)
return 2
m_cfg = cfg.get("maintenance") if isinstance(cfg, dict) else None
m_cfg = m_cfg if isinstance(m_cfg, dict) else {}
c_cfg = m_cfg.get("cleanup") if isinstance(m_cfg, dict) else None
c_cfg = c_cfg if isinstance(c_cfg, dict) else {}
logs_max_age_days = int(args.logs_max_age_days or c_cfg.get("logs_max_age_days") or 14)
keep_plans = bool(c_cfg.get("keep_plans", True))
apply = bool(args.apply)
mode = "APPLY" if apply else "DRY-RUN"
_pr(f"[cleanup_intermediate] mode={mode}")
_pr(f"[cleanup_intermediate] review_root={review_root}")
_pr(f"[cleanup_intermediate] intermediate_root={intermediate_root}")
if not intermediate_root.exists():
_pr(f"[cleanup_intermediate] skip: intermediate_root not found: {intermediate_root}")
return 0
# 1) parallel-vibe environment cleanup (optional)
if args.delete_parallel_vibe:
candidates = [
intermediate_root / "parallel-vibe",
intermediate_root / ".parallel-vibe",
intermediate_root / ".parallel_vibe",
review_root / ".parallel-vibe",
review_root / ".parallel_vibe",
]
for d in candidates:
if not d.exists():
continue
if not _within(review_root, d):
print(f"error: refusing to delete outside review_root: {d}", file=sys.stderr)
return 2
_pr(f"[cleanup_intermediate] delete dir: {d}")
_rm_tree(d, apply=apply)
# 2) logs cleanup (age-based, keep logs/plans by default)
logs_dir = intermediate_root / "logs"
plans_dir = logs_dir / "plans"
if logs_dir.exists():
cutoff = time.time() - (max(0, logs_max_age_days) * 86400)
_pr(f"[cleanup_intermediate] logs_max_age_days={logs_max_age_days} cutoff_epoch={int(cutoff)} keep_plans={keep_plans}")
to_delete: list[Path] = []
for f in logs_dir.rglob("*"):
if not f.is_file():
continue
if keep_plans and plans_dir.exists():
try:
f.resolve().relative_to(plans_dir.resolve())
continue
except Exception:
pass
try:
st = f.stat()
except Exception:
continue
if st.st_mtime < cutoff:
to_delete.append(f)
for f in sorted(to_delete):
if not _within(review_root, f):
print(f"error: refusing to delete outside review_root: {f}", file=sys.stderr)
return 2
_pr(f"[cleanup_intermediate] delete file: {f}")
_rm_file(f, apply=apply)
# 3) snapshot cleanup (optional)
snapshot_dir = intermediate_root / "snapshot"
if args.delete_snapshot and snapshot_dir.exists():
if not _within(review_root, snapshot_dir):
print(f"error: refusing to delete outside review_root: {snapshot_dir}", file=sys.stderr)
return 2
_pr(f"[cleanup_intermediate] delete dir: {snapshot_dir}")
_rm_tree(snapshot_dir, apply=apply)
_pr("[cleanup_intermediate] done")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import shutil
import sys
from pathlib import Path
import yaml
def _read_text(p: Path) -> str:
return p.read_text(encoding="utf-8")
def _load_yaml(p: Path) -> dict:
try:
obj = yaml.safe_load(_read_text(p)) or {}
except Exception as e:
raise ValueError(f"failed to read yaml: {p}: {e}") from e
if not isinstance(obj, dict):
raise ValueError(f"expected yaml mapping: {p}")
return obj
def _within(base: Path, child: Path) -> bool:
try:
child.resolve().relative_to(base.resolve())
return True
except Exception:
return False
def _pr(msg: str) -> None:
print(msg)
def _warn(msg: str) -> None:
print(f"warning: {msg}", file=sys.stderr)
def _err(msg: str) -> None:
print(f"error: {msg}", file=sys.stderr)
def _ensure_relative_dir_path(name: str, *, label: str) -> str:
n = str(name or "").strip()
if not n:
raise ValueError(f"{label} is empty")
p = Path(n)
if p.is_absolute() or ".." in p.parts:
raise ValueError(f"{label} must be a relative name without '..': {n!r}")
return n
def _ensure_dir(p: Path, *, apply: bool) -> None:
if p.exists():
return
_pr(f"[finalize_output] mkdir -p {p}")
if apply:
p.mkdir(parents=True, exist_ok=True)
def _move_children(src: Path, dest_dir: Path, *, apply: bool, review_root: Path) -> None:
if not src.exists() or not src.is_dir():
return
if not _within(review_root, src):
raise ValueError(f"refusing to move from outside review_root: {src}")
for child in sorted(src.iterdir()):
dest = dest_dir / child.name
if dest.exists():
_warn(f"skip move (dest exists): {child} -> {dest}")
continue
if not _within(review_root, dest):
raise ValueError(f"refusing to move to outside review_root: {dest}")
_pr(f"[finalize_output] move: {child} -> {dest}")
if apply:
shutil.move(str(child), str(dest))
# Clean up the now-empty directory (best-effort).
try:
if apply:
src.rmdir()
_pr(f"[finalize_output] rmdir: {src}")
except Exception:
pass
def _stage_file(
src: Path,
dest: Path,
*,
apply: bool,
review_root: Path,
mode: str,
) -> None:
if not src.exists() or not src.is_file():
return
if not _within(review_root, src):
# Allow staging from outside review_root (copy-only), but still keep destination under review_root.
if mode == "move":
mode = "copy"
if not _within(review_root, dest):
raise ValueError(f"refusing to stage to outside review_root: {dest}")
if dest.exists():
_warn(f"skip {mode} (dest exists): {dest}")
return
dest.parent.mkdir(parents=True, exist_ok=True) if apply else None
_pr(f"[finalize_output] {mode}: {src} -> {dest}")
if not apply:
return
if mode == "move":
shutil.move(str(src), str(dest))
else:
shutil.copy2(str(src), str(dest))
def _stage_dir(src: Path, dest_dir: Path, *, apply: bool, review_root: Path) -> None:
if not src.exists() or not src.is_dir():
return
if not _within(review_root, src):
raise ValueError(f"refusing to move dir from outside review_root: {src}")
dest = dest_dir / src.name
if dest.exists():
_warn(f"skip move dir (dest exists): {src} -> {dest}")
return
if not _within(review_root, dest):
raise ValueError(f"refusing to move dir to outside review_root: {dest}")
_pr(f"[finalize_output] move dir: {src} -> {dest}")
if apply:
shutil.move(str(src), str(dest))
def _discover_parallel_vibe_roots(review_root: Path, intermediate_root: Path, extra: Path | None) -> list[Path]:
roots: list[Path] = []
candidates = [
intermediate_root / ".parallel-vibe",
intermediate_root / ".parallel_vibe",
review_root / ".parallel-vibe",
review_root / ".parallel_vibe",
]
if extra:
if extra.is_dir() and extra.name in {".parallel-vibe", ".parallel_vibe"}:
candidates.append(extra)
elif extra.is_dir() and (extra / ".parallel-vibe").exists():
candidates.append(extra / ".parallel-vibe")
elif extra.is_dir() and (extra / ".parallel_vibe").exists():
candidates.append(extra / ".parallel_vibe")
else:
candidates.append(extra)
for p in candidates:
try:
if p.exists() and p.is_dir() and p.name in {".parallel-vibe", ".parallel_vibe"}:
roots.append(p)
except Exception:
continue
# De-dup (resolve is best-effort; keep stable order).
seen: set[str] = set()
out: list[Path] = []
for p in roots:
key = str(p.resolve()) if p.exists() else str(p)
if key in seen:
continue
seen.add(key)
out.append(p)
return out
def main(argv: list[str] | None = None) -> int:
skill_root = Path(__file__).resolve().parents[1]
cfg_path = skill_root / "config.yaml"
if not cfg_path.exists():
_err(f"missing config.yaml at {cfg_path}")
return 2
try:
cfg = _load_yaml(cfg_path)
except Exception as e:
_err(str(e))
return 2
os_cfg = cfg.get("output_settings") if isinstance(cfg, dict) else None
os_cfg = os_cfg if isinstance(os_cfg, dict) else {}
default_intermediate = str(os_cfg.get("intermediate_dir") or ".bensz-api/skills/nsfc-reviewers")
default_panel_dir = str(os_cfg.get("panel_dir") or "panels")
default_filename = str(os_cfg.get("default_filename") or "comments-from-nsfc-reviewers.md")
warn_missing_intermediate = bool(os_cfg.get("warn_missing_intermediate", True))
cfg_validation_level = str(os_cfg.get("validation_level") or "warn").strip().lower()
p = argparse.ArgumentParser(prog="finalize_output.py")
p.add_argument("--review-path", required=True, help="标书目录 / 本次评审会话根目录(交付文件所在目录)")
p.add_argument("--panel-count", type=int, default=1, help="本次评审组数(用于校验并行产物是否齐全)")
p.add_argument(
"--intermediate-dir",
default=default_intermediate,
help="覆盖 config.yaml:output_settings.intermediate_dir(默认 .bensz-api/skills/nsfc-reviewers)",
)
p.add_argument(
"--master-prompt-file",
default="",
help="可选:master prompt 文本文件路径;若提供则会归档到 logs/master_prompt.txt(优先 copy,必要时 move)",
)
p.add_argument(
"--plan-file",
default="",
help="可选:plan.json 路径;若提供则会归档到 logs/plans/(优先 copy,必要时 move)",
)
p.add_argument(
"--parallel-vibe-path",
default="",
help="可选:额外的 .parallel-vibe/ 或 legacy .parallel_vibe/ 位置;可指向目录本身或其父目录",
)
p.add_argument(
"--validation-level",
default="",
help="覆盖 config.yaml:output_settings.validation_level(warn|error)",
)
p.add_argument("--apply", action="store_true", help="实际执行移动/复制(默认仅 DRY-RUN)")
args = p.parse_args(argv)
apply = bool(args.apply)
mode = "APPLY" if apply else "DRY-RUN"
review_root = Path(str(args.review_path)).expanduser().resolve()
if not review_root.exists() or not review_root.is_dir():
_err(f"review path is not a directory: {review_root}")
return 2
try:
intermediate_dir_name = _ensure_relative_dir_path(str(args.intermediate_dir), label="--intermediate-dir")
panel_dir_name = _ensure_relative_dir_path(default_panel_dir, label="config.yaml:output_settings.panel_dir")
except Exception as e:
_err(str(e))
return 2
validation_level = str(args.validation_level or cfg_validation_level or "warn").strip().lower()
if validation_level not in {"warn", "error"}:
_err("--validation-level must be warn|error")
return 2
intermediate_root = (review_root / intermediate_dir_name).resolve()
if not _within(review_root, intermediate_root):
_err(f"intermediate_dir escapes review root: review_root={review_root} intermediate_root={intermediate_root}")
return 2
_pr(f"[finalize_output] mode={mode}")
_pr(f"[finalize_output] review_root={review_root}")
_pr(f"[finalize_output] intermediate_root={intermediate_root}")
# 1) Create the standard intermediate directory structure.
_ensure_dir(intermediate_root / "parallel-vibe", apply=apply)
_ensure_dir(intermediate_root / "logs" / "plans", apply=apply)
_ensure_dir(intermediate_root / "snapshot", apply=apply)
# 2) Move/collect parallel-vibe environment(s) into intermediate_root/parallel-vibe/
extra_parallel = Path(str(args.parallel_vibe_path)).expanduser().resolve() if str(args.parallel_vibe_path).strip() else None
pv_roots = _discover_parallel_vibe_roots(review_root, intermediate_root, extra_parallel)
for src in pv_roots:
_pr(f"[finalize_output] collect parallel-vibe env from: {src}")
_move_children(src, intermediate_root / "parallel-vibe", apply=apply, review_root=review_root)
# 3) Snapshot (legacy)
_stage_dir(review_root / "proposal_snapshot", intermediate_root / "snapshot", apply=apply, review_root=review_root)
# 4) Logs: master prompt + plan files
# master prompt: prefer explicit, else legacy review_root/master_prompt.txt
mp_src = Path(str(args.master_prompt_file)).expanduser().resolve() if str(args.master_prompt_file).strip() else None
if not mp_src:
mp_src = (review_root / "master_prompt.txt") if (review_root / "master_prompt.txt").exists() else None
if mp_src:
_stage_file(
mp_src,
intermediate_root / "logs" / "master_prompt.txt",
apply=apply,
review_root=review_root,
mode="move",
)
# plan file: explicit single file + auto-discovery of review_root/plan*.json
plan_src = Path(str(args.plan_file)).expanduser().resolve() if str(args.plan_file).strip() else None
if plan_src and plan_src.exists():
_stage_file(
plan_src,
intermediate_root / "logs" / "plans" / plan_src.name,
apply=apply,
review_root=review_root,
mode="move",
)
for f in sorted(review_root.glob("plan*.json")):
# Avoid double-staging an explicit plan file.
if plan_src and f.resolve() == plan_src.resolve():
continue
_stage_file(
f,
intermediate_root / "logs" / "plans" / f.name,
apply=apply,
review_root=review_root,
mode="move",
)
# 5) Validations (warn by default; error if configured).
warnings: list[str] = []
panel_count = int(args.panel_count or 1)
pv_dest = intermediate_root / "parallel-vibe"
has_parallel_env = pv_dest.exists() and any(pv_dest.iterdir())
parallel_root_exists = any(
p.exists()
for p in (
review_root / ".parallel-vibe",
intermediate_root / ".parallel-vibe",
review_root / ".parallel_vibe",
intermediate_root / ".parallel_vibe",
)
)
any_parallel_seen = has_parallel_env or parallel_root_exists
# In DRY-RUN, source parallel-vibe roots are expected to remain; only warn when nothing is found at all.
if panel_count > 1 and warn_missing_intermediate and (not any_parallel_seen):
warnings.append(f"panel_count={panel_count} but no parallel-vibe env found (.parallel-vibe missing)")
if warn_missing_intermediate:
if not (intermediate_root / "logs").exists() and apply:
warnings.append(f"missing logs dir after apply: {intermediate_root / 'logs'}")
final_report = review_root / default_filename
if not final_report.exists():
warnings.append(f"final report not found (expected): {final_report}")
panels_dir = review_root / panel_dir_name
if panel_count > 1:
if not panels_dir.exists():
warnings.append(f"panels dir not found (expected): {panels_dir}")
else:
has_panel_files = any(p.name.startswith("G") and p.suffix.lower() == ".md" for p in panels_dir.iterdir() if p.is_file())
if not has_panel_files:
warnings.append(f"panels dir has no G*.md files: {panels_dir}")
for w in warnings:
_warn(w)
if warnings and validation_level == "error":
return 3
_pr("[finalize_output] done")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import fnmatch
import sys
from pathlib import Path
import yaml
def _read_text(p: Path) -> str:
return p.read_text(encoding="utf-8")
def _load_yaml(p: Path) -> dict:
try:
obj = yaml.safe_load(_read_text(p)) or {}
except Exception as e:
raise ValueError(f"failed to read yaml: {p}: {e}") from e
if not isinstance(obj, dict):
raise ValueError(f"expected yaml mapping: {p}")
return obj
def _is_safe_relpath(p: Path) -> bool:
if p.is_absolute():
return False
# Reject any ".." segment to keep path derivations predictable.
return ".." not in p.parts
def _load_discovery_config(cfg: dict) -> tuple[list[str], list[str], str, str]:
pf = cfg.get("proposal_files") if isinstance(cfg, dict) else None
pf = pf if isinstance(pf, dict) else {}
patterns = pf.get("patterns") or []
exclude = pf.get("exclude") or []
if not isinstance(patterns, list) or not all(isinstance(x, str) for x in patterns):
raise ValueError("config.yaml: proposal_files.patterns must be a list of strings")
if not isinstance(exclude, list) or not all(isinstance(x, str) for x in exclude):
raise ValueError("config.yaml: proposal_files.exclude must be a list of strings")
os_cfg = cfg.get("output_settings") if isinstance(cfg, dict) else None
os_cfg = os_cfg if isinstance(os_cfg, dict) else {}
panel_dir = str(os_cfg.get("panel_dir") or "panels").strip()
intermediate_dir = str(os_cfg.get("intermediate_dir") or ".bensz-api/skills/nsfc-reviewers").strip()
if not panel_dir or not intermediate_dir:
raise ValueError("config.yaml: output_settings.panel_dir/intermediate_dir must be non-empty")
return patterns, exclude, panel_dir, intermediate_dir
def _should_skip_path(
*,
rel: Path,
panel_dir: str,
intermediate_dir: str,
) -> bool:
# Skip any file that is under intermediate outputs / final panels / legacy parallel-vibe roots.
intermediate_parts = tuple(Path(intermediate_dir).parts)
if intermediate_parts and rel.parts[: len(intermediate_parts)] == intermediate_parts:
return True
skip_dir_names = {
panel_dir,
intermediate_dir,
".bensz-api",
".nsfc-reviewers",
"parallel-vibe",
".parallel-vibe",
".parallel_vibe",
".parallel_vibe".lstrip("."), # defensive; unlikely
".parallel_vibe".strip("/"),
}
for part in rel.parts[:-1]:
if part in skip_dir_names:
return True
return False
def _matches_exclude(rel: Path, excludes: list[str]) -> bool:
rel_posix = rel.as_posix()
name = rel.name
for pat in excludes:
if fnmatch.fnmatch(name, pat) or fnmatch.fnmatch(rel_posix, pat):
return True
return False
def main(argv: list[str] | None = None) -> int:
skill_root = Path(__file__).resolve().parents[1]
cfg_path = skill_root / "config.yaml"
if not cfg_path.exists():
print(f"error: missing config.yaml at {cfg_path}", file=sys.stderr)
return 2
cfg = _load_yaml(cfg_path)
try:
patterns, excludes, panel_dir, intermediate_dir = _load_discovery_config(cfg)
except Exception as e:
print(f"error: {e}", file=sys.stderr)
return 2
p = argparse.ArgumentParser(prog="list_proposal_files.py")
p.add_argument("--proposal-path", required=True, help="标书目录或单个 .tex 文件路径")
p.add_argument(
"--max-files",
type=int,
default=0,
help="最多允许输出的文件数(0 表示不限制);超限则退出码=3 并提示 count",
)
p.add_argument("--json", action="store_true", help="以 JSON 数组输出(默认逐行输出)")
args = p.parse_args(argv)
proposal_path = Path(str(args.proposal_path)).expanduser().resolve()
if not proposal_path.exists():
print(f"error: path not found: {proposal_path}", file=sys.stderr)
return 2
files: set[Path] = set()
if proposal_path.is_file():
if proposal_path.suffix.lower() != ".tex":
print(f"error: proposal file must be a .tex file, got: {proposal_path.name}", file=sys.stderr)
return 2
files.add(proposal_path)
else:
# Recursive discovery (rglob) is more robust than only scanning the root.
for pat in patterns:
for f in proposal_path.rglob(pat):
if not f.is_file():
continue
if f.suffix.lower() != ".tex":
continue
try:
rel = f.resolve().relative_to(proposal_path)
except Exception:
continue
if _should_skip_path(rel=rel, panel_dir=panel_dir, intermediate_dir=intermediate_dir):
continue
if _matches_exclude(rel, excludes):
continue
files.add(f.resolve())
out = sorted(files)
if args.max_files and len(out) > int(args.max_files):
print(f"error: too many tex files: {len(out)} (max={args.max_files})", file=sys.stderr)
return 3
if args.json:
import json
print(json.dumps([str(p) for p in out], ensure_ascii=False, indent=2))
else:
for f in out:
print(str(f))
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
from __future__ import annotations
import re
import sys
from pathlib import Path
import yaml
def _read_text(p: Path) -> str:
return p.read_text(encoding="utf-8")
def _parse_frontmatter(md_text: str) -> dict:
if not md_text.startswith("---\n"):
raise ValueError("SKILL.md must start with YAML frontmatter ('---')")
m = re.match(r"\A---\n(.*?)\n---\n", md_text, flags=re.DOTALL)
if not m:
raise ValueError("SKILL.md YAML frontmatter must be closed by a second '---' line")
return yaml.safe_load(m.group(1)) or {}
def main() -> int:
# scripts/*.py -> skill root
skill_root = Path(__file__).resolve().parents[1]
config_path = skill_root / "config.yaml"
skill_md_path = skill_root / "SKILL.md"
readme_path = skill_root / "README.md"
errors: list[str] = []
if not config_path.exists():
errors.append(f"Missing {config_path}")
return _finish(errors)
if not skill_md_path.exists():
errors.append(f"Missing {skill_md_path}")
return _finish(errors)
cfg = yaml.safe_load(_read_text(config_path)) or {}
cfg_skill = (cfg.get("skill_info") or {}) if isinstance(cfg, dict) else {}
cfg_name = cfg_skill.get("name")
cfg_ver = cfg_skill.get("version")
if cfg_name != "nsfc-reviewers":
errors.append(f"config.yaml: skill_info.name expected 'nsfc-reviewers', got {cfg_name!r}")
if not cfg_ver:
errors.append("config.yaml: missing skill_info.version")
_validate_skill_info(cfg, errors)
skill_md_text = _read_text(skill_md_path)
fm = _parse_frontmatter(skill_md_text)
fm_name = fm.get("name")
if fm_name != cfg_name:
errors.append(
f"SKILL.md frontmatter: name {fm_name!r} does not match config.yaml skill_info.name {cfg_name!r}"
)
kw = ((fm.get("metadata") or {}).get("keywords") or []) if isinstance(fm, dict) else []
if not isinstance(kw, list):
errors.append("SKILL.md frontmatter: metadata.keywords must be a list")
else:
if not (3 <= len(kw) <= 5):
errors.append(f"SKILL.md frontmatter: metadata.keywords should be 3-5 items, got {len(kw)}")
# Generality checks for user-facing docs
for p in [skill_md_path, readme_path]:
if not p.exists():
continue
t = _read_text(p)
if "/Users/" in t:
errors.append(f"{p.name}: contains a personal path '/Users/'")
if re.search(r"\b20\d{2}-\d{2}-\d{2}\b", t):
errors.append(f"{p.name}: contains a concrete date (use placeholders like YYYY-MM-DD in examples)")
# SKILL.md length guardrail
skill_lines = skill_md_text.count("\n") + 1
if skill_lines > 500:
errors.append(f"SKILL.md too long: {skill_lines} lines (> 500)")
# Version should not be hardcoded in SKILL/README
if cfg_ver:
for p in [skill_md_path, readme_path]:
if p.exists() and str(cfg_ver) in _read_text(p):
errors.append(f"{p.name}: hardcodes version {cfg_ver} (should only live in config.yaml)")
_validate_output_settings(cfg, errors)
_validate_stage_assessment(cfg, errors)
_validate_funding_context(cfg, errors)
_validate_parallel_review(cfg, skill_root, errors)
_validate_scripts(skill_root, errors)
_validate_references(skill_root, errors)
_validate_docs_consistency(cfg, skill_md_text, _read_text(readme_path) if readme_path.exists() else "", errors)
return _finish(errors)
def _validate_skill_info(cfg: dict, errors: list[str]) -> None:
skill = cfg.get("skill_info") if isinstance(cfg, dict) else None
if not isinstance(skill, dict):
errors.append("config.yaml: missing skill_info (dict)")
return
category = str(skill.get("category") or "").strip()
if category not in {"writing", "development", "normal"}:
errors.append("config.yaml: skill_info.category must be one of writing|development|normal")
description = str(skill.get("description") or "")
if not description:
errors.append("config.yaml: skill_info.description is empty")
def _validate_parallel_review(cfg: dict, skill_root: Path, errors: list[str]) -> None:
pr = cfg.get("parallel_review") if isinstance(cfg, dict) else None
if not isinstance(pr, dict):
errors.append("config.yaml: missing parallel_review (dict)")
return
for k in ["default_panel_count", "max_panel_count", "panel_output_filename", "runner", "runner_profile", "timeout_seconds"]:
if k not in pr:
errors.append(f"config.yaml: parallel_review missing {k}")
default_panel_count = pr.get("default_panel_count")
max_panel_count = pr.get("max_panel_count")
if not isinstance(default_panel_count, int) or default_panel_count < 1:
errors.append("config.yaml: parallel_review.default_panel_count must be an integer >= 1")
if not isinstance(max_panel_count, int) or max_panel_count < 1:
errors.append("config.yaml: parallel_review.max_panel_count must be an integer >= 1")
if isinstance(default_panel_count, int) and isinstance(max_panel_count, int) and default_panel_count > max_panel_count:
errors.append("config.yaml: parallel_review.default_panel_count must be <= max_panel_count")
if "default_reviewer_count" in pr or "max_reviewer_count" in pr or "thread_output_filename" in pr:
errors.append("config.yaml: parallel_review still contains old reviewer_count/thread_output_filename fields")
rp = str(pr.get("runner_profile") or "").strip().lower()
if rp and rp not in {"default", "fast", "deep"}:
errors.append("config.yaml: parallel_review.runner_profile must be one of default|fast|deep")
agg = pr.get("aggregation")
if not isinstance(agg, dict):
errors.append("config.yaml: parallel_review.aggregation missing (dict)")
else:
ct = agg.get("consensus_threshold")
if not isinstance(ct, (int, float)) or not (0.0 < float(ct) <= 1.0):
errors.append("config.yaml: parallel_review.aggregation.consensus_threshold must be in (0, 1]")
personas = pr.get("reviewer_personas")
if not isinstance(personas, list) or len(personas) != 7:
errors.append("config.yaml: parallel_review.reviewer_personas must be a list of 7 personas")
return
for i, persona in enumerate(personas):
if not isinstance(persona, dict):
errors.append(f"config.yaml: reviewer_personas[{i}] must be a dict")
continue
if "style" in persona:
errors.append(f"config.yaml: reviewer_personas[{i}] must not contain inline prompt field 'style'")
pf = persona.get("prompt_file")
if not pf or not isinstance(pf, str):
errors.append(f"config.yaml: reviewer_personas[{i}] missing prompt_file")
continue
p = (skill_root / pf).resolve()
try:
p.relative_to(skill_root.resolve())
except Exception:
errors.append(f"config.yaml: reviewer_personas[{i}].prompt_file points outside skill root: {pf!r}")
continue
if not p.exists():
errors.append(f"config.yaml: reviewer_personas[{i}].prompt_file not found: {pf!r}")
def _validate_output_settings(cfg: dict, errors: list[str]) -> None:
os_cfg = cfg.get("output_settings") if isinstance(cfg, dict) else None
if not isinstance(os_cfg, dict):
errors.append("config.yaml: missing output_settings (dict)")
return
for k in ["default_filename", "panel_dir", "hide_intermediate", "intermediate_dir"]:
if k not in os_cfg:
errors.append(f"config.yaml: output_settings missing {k}")
if not str(os_cfg.get("default_filename") or "").strip():
errors.append("config.yaml: output_settings.default_filename is empty")
panel_dir = str(os_cfg.get("panel_dir") or "").strip()
if not panel_dir:
errors.append("config.yaml: output_settings.panel_dir is empty")
intermediate_dir = str(os_cfg.get("intermediate_dir") or "").strip()
if not intermediate_dir:
errors.append("config.yaml: output_settings.intermediate_dir is empty")
# Keep these as simple directory names to avoid path surprises.
for label, val in [("panel_dir", panel_dir), ("intermediate_dir", intermediate_dir)]:
if not val:
continue
if Path(val).is_absolute():
errors.append(f"config.yaml: output_settings.{label} must be a relative name, got an absolute path: {val!r}")
continue
if ".." in Path(val).parts:
errors.append(f"config.yaml: output_settings.{label} must not contain '..': {val!r}")
continue
if len(Path(val).parts) != 1:
errors.append(f"config.yaml: output_settings.{label} should be a single directory name (no slashes): {val!r}")
# Optional-but-recommended validation knobs for deterministic output finalization.
if "enforce_output_finalization" in os_cfg and not isinstance(os_cfg.get("enforce_output_finalization"), bool):
errors.append("config.yaml: output_settings.enforce_output_finalization must be a bool")
if "warn_missing_intermediate" in os_cfg and not isinstance(os_cfg.get("warn_missing_intermediate"), bool):
errors.append("config.yaml: output_settings.warn_missing_intermediate must be a bool")
if "validation_level" in os_cfg:
vl = str(os_cfg.get("validation_level") or "").strip().lower()
if vl and vl not in {"warn", "error"}:
errors.append("config.yaml: output_settings.validation_level must be warn|error")
def _validate_stage_assessment(cfg: dict, errors: list[str]) -> None:
sa = cfg.get("stage_assessment") if isinstance(cfg, dict) else None
if not isinstance(sa, dict):
errors.append("config.yaml: missing stage_assessment (dict)")
return
for k in ["enabled", "include_in_report", "judge_current_draft_only", "require_binary_verdict", "confidence_levels", "stages"]:
if k not in sa:
errors.append(f"config.yaml: stage_assessment missing {k}")
for k in ["enabled", "include_in_report", "judge_current_draft_only", "require_binary_verdict"]:
if k in sa and not isinstance(sa.get(k), bool):
errors.append(f"config.yaml: stage_assessment.{k} must be a bool")
cls = sa.get("confidence_levels")
if not isinstance(cls, list) or [str(x) for x in cls] != ["高", "中", "低"]:
errors.append("config.yaml: stage_assessment.confidence_levels must be ['高', '中', '低']")
stages = sa.get("stages")
if not isinstance(stages, dict):
errors.append("config.yaml: stage_assessment.stages must be a dict")
return
for stage_id in ["letter_review", "panel_review"]:
stage = stages.get(stage_id)
if not isinstance(stage, dict):
errors.append(f"config.yaml: stage_assessment.stages.{stage_id} must be a dict")
continue
for k in ["name", "pass_label", "fail_label", "key_checks"]:
if k not in stage:
errors.append(f"config.yaml: stage_assessment.stages.{stage_id} missing {k}")
if stage.get("pass_label") != "给过" or stage.get("fail_label") != "不给过":
errors.append(f"config.yaml: stage_assessment.stages.{stage_id} must use pass_label=给过 and fail_label=不给过")
kc = stage.get("key_checks")
if not isinstance(kc, list) or len(kc) < 2:
errors.append(f"config.yaml: stage_assessment.stages.{stage_id}.key_checks must be a list with at least 2 items")
def _validate_funding_context(cfg: dict, errors: list[str]) -> None:
fc = cfg.get("funding_context") if isinstance(cfg, dict) else None
if not isinstance(fc, dict):
errors.append("config.yaml: missing funding_context (dict)")
return
for k in ["enabled", "require_contextualized_judgement", "unknown_policy", "project_types", "report_requirements"]:
if k not in fc:
errors.append(f"config.yaml: funding_context missing {k}")
for k in ["enabled", "require_contextualized_judgement"]:
if k in fc and not isinstance(fc.get(k), bool):
errors.append(f"config.yaml: funding_context.{k} must be a bool")
unknown_policy = str(fc.get("unknown_policy") or "").strip().lower()
if unknown_policy and unknown_policy not in {"conservative"}:
errors.append("config.yaml: funding_context.unknown_policy must be conservative")
project_types = fc.get("project_types")
if not isinstance(project_types, dict):
errors.append("config.yaml: funding_context.project_types must be a dict")
return
for project_type in ["youth", "general"]:
item = project_types.get(project_type)
if not isinstance(item, dict):
errors.append(f"config.yaml: funding_context.project_types.{project_type} must be a dict")
continue
labels = item.get("labels")
budget_range = item.get("typical_budget_wan_range")
interpretation = str(item.get("interpretation") or "").strip()
if not isinstance(labels, list) or not labels:
errors.append(f"config.yaml: funding_context.project_types.{project_type}.labels must be a non-empty list")
if not isinstance(budget_range, list) or len(budget_range) != 2:
errors.append(
f"config.yaml: funding_context.project_types.{project_type}.typical_budget_wan_range must be a 2-item list"
)
elif not all(isinstance(x, (int, float)) for x in budget_range) or float(budget_range[0]) > float(budget_range[1]):
errors.append(
f"config.yaml: funding_context.project_types.{project_type}.typical_budget_wan_range must be ascending numbers"
)
if not interpretation:
errors.append(f"config.yaml: funding_context.project_types.{project_type}.interpretation is empty")
report_requirements = fc.get("report_requirements")
if not isinstance(report_requirements, list) or len(report_requirements) < 3:
errors.append("config.yaml: funding_context.report_requirements must be a list with at least 3 items")
def _validate_scripts(skill_root: Path, errors: list[str]) -> None:
required = [
"scripts/build_parallel_vibe_plan.py",
"scripts/cleanup_intermediate.py",
"scripts/finalize_output.py",
"scripts/list_proposal_files.py",
"scripts/validate_skill.py",
]
for rel in required:
if not (skill_root / rel).exists():
errors.append(f"Missing {rel}")
def _validate_references(skill_root: Path, errors: list[str]) -> None:
refs = skill_root / "references"
required = [
"expert_01_innovation.md",
"expert_02_methodology.md",
"expert_03_foundation.md",
"expert_04_critical.md",
"expert_05_constructive.md",
"expert_06_significance.md",
"expert_07_clarity.md",
"master_prompt_template.md",
"aggregation_rules.md",
]
if not refs.exists():
errors.append("Missing references/ directory")
return
for f in required:
p = refs / f
if not p.exists():
errors.append(f"Missing references/{f}")
# Ensure master prompt template can stay consistent with config.yaml.
tmpl = refs / "master_prompt_template.md"
if tmpl.exists():
t = _read_text(tmpl)
if "{panel_output_filename}" not in t:
errors.append("references/master_prompt_template.md: missing placeholder {panel_output_filename}")
def _extract_doc_number(text: str, pattern: str) -> int | None:
m = re.search(pattern, text)
if not m:
return None
try:
return int(m.group(1))
except Exception:
return None
def _validate_docs_consistency(cfg: dict, skill_md_text: str, readme_text: str, errors: list[str]) -> None:
for label, t in [("SKILL.md", skill_md_text), ("README.md", readme_text)]:
if "reviewer_count" in t:
errors.append(f"{label}: still mentions old parameter reviewer_count (should use panel_count)")
if "thread_output_filename" in t:
errors.append(f"{label}: still mentions old thread_output_filename (should use panel_output_filename)")
if "--runner" in t:
errors.append(f"{label}: still mentions old parallel-vibe flag --runner (should use --plan-file)")
if "--workdir" in t:
errors.append(f"{label}: mentions parallel-vibe --workdir (prefer --out-dir/--src-dir)")
# parallel-vibe major updates: ensure SKILL.md documents the plan-file workflow.
if "--plan-file" not in skill_md_text:
errors.append("SKILL.md: missing parallel-vibe --plan-file workflow (required for parallel panels)")
for label, t in [("SKILL.md", skill_md_text), ("README.md", readme_text)]:
if "## 阶段判断(基于当前版本直接送审)" not in t:
errors.append(f"{label}: missing default 函评/会评 stage assessment section")
if "给过 / 不给过" not in t:
errors.append(f"{label}: missing explicit binary verdict wording '给过 / 不给过'")
if "资助额度" not in t:
errors.append(f"{label}: missing funding-constraint guidance")
pr = cfg.get("parallel_review") if isinstance(cfg, dict) else None
pr = pr if isinstance(pr, dict) else {}
expected_reviewer_count = len(pr.get("reviewer_personas") or []) if isinstance(pr.get("reviewer_personas"), list) else None
expected_default_panels = pr.get("default_panel_count") if isinstance(pr.get("default_panel_count"), int) else None
expected_max_panels = pr.get("max_panel_count") if isinstance(pr.get("max_panel_count"), int) else None
readme_checks = [
(r"每组专家:\s*(\d+)\s*位", expected_reviewer_count, "README.md: 每组专家数量与 config.yaml:parallel_review.reviewer_personas 不一致"),
(r"总专家人次:N×\s*(\d+)\s*人次", expected_reviewer_count, "README.md: 总专家人次公式与每组专家数不一致"),
(r"默认组数[^\n]*?(\d+)\s*组", expected_default_panels, "README.md: 默认组数与 config.yaml:parallel_review.default_panel_count 不一致"),
(r"最大组数[^\n]*?(\d+)\s*组", expected_max_panels, "README.md: 最大组数与 config.yaml:parallel_review.max_panel_count 不一致"),
(r"最多\s*(\d+)\s*组", expected_max_panels, "README.md: 最多组数与 config.yaml:parallel_review.max_panel_count 不一致"),
]
for pattern, expected, message in readme_checks:
if expected is None:
continue
found = _extract_doc_number(readme_text, pattern)
if found is not None and found != expected:
errors.append(f"{message}(文档={found},配置={expected})")
def _finish(errors: list[str]) -> int:
if errors:
print("FAIL")
for e in errors:
print(f"- {e}")
return 1
print("PASS")
return 0
if __name__ == "__main__":
raise SystemExit(main())