
Nsfc Ref Alignment
- 81 installs
- 2.6k repo stars
- Updated July 20, 2026
- huangwb8/chineseresearchlatex
Read-only audit of NSFC proposal citations vs references, checking bibkey existence, BibTeX fields, and DOI format for consistency and authenticity risks.
About
Checks whether proposal citations align with the reference list, verifying bibkey existence and BibTeX/DOI formatting, then produces structured input for the host AI to judge each citation's factual match. Developers use it as a read-only citation audit that outputs a review report without editing the proposal or .bib by default.
- Verifies bibkey existence and BibTeX/DOI field issues
- Read-only by default; emits a structured audit report
Nsfc Ref Alignment by the numbers
- 81 all-time installs (skills.sh)
- +2 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #690 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-ref-alignmentAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 81 |
|---|---|
| repo stars | ★ 2.6k |
| Last updated | July 20, 2026 |
| Repository | huangwb8/chineseresearchlatex ↗ |
What it does
Read-only audit of NSFC proposal citations vs references, checking bibkey existence, BibTeX fields, and DOI format for consistency and authenticity risks.
Files
NSFC Ref Alignment
与 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 仓库。
适用场景
- 你有一份 NSFC 标书(LaTeX 项目),希望检查:
.tex里所有\cite{...}/\citep{...}/\citet{...}等引用的 bibkey 是否都存在于.bib.bib条目的基础字段是否完备、格式是否明显错误(如 DOI 非法、年份缺失)- 引用所在句子的“语义主张”是否与该文献(至少从 title/author/year/venue/DOI 等元信息)相匹配,是否存在乱引/过度主张/张冠李戴风险
- 你希望只得到一份报告先人工审核(改
.bib/ 改正文属于大事)。
不适用
- 你希望“自动替换 bibkey / 自动改写正文”——本 skill 默认禁止直接修改(除非你明确要求)。
- 你只是想补齐 BibTeX 条目:优先使用
nsfc-bib-manager。
输入
project_root:标书项目根目录(如projects/NSFC_General)- 可选:
main_tex:主入口 tex(默认main.tex) - 可选:
report_dir:报告输出目录(默认./references,相对你运行 skill 的当前目录) - 可选:
verify_online:是否进行在线核验(默认 false;仅做确定性查询,失败降级)
输出(只读 + 可复现)
中间产物(强制)
所有中间文件必须托管在:
{project_root}/.bensz-api/skills/nsfc-ref-alignment/{yyyy-mm-dd-hh-mm}/
其中 run_{YYYYMMDDHHMMSS} 为时间戳;如同秒重复运行,脚本会追加 -2/-3/...,确保多次分析不冲突。
该目录至少包含:
ai_ref_alignment_input.json:结构化输入(引用位置 + 句子上下文 + 文献元信息 + 校验结果),供宿主 AI 做语义判断ref_integrity_report.md:确定性报告(缺失 bibkey、重复条目、字段缺失、DOI 格式问题等)citations.csv:逐条引用清单(file/line/bibkey/sentence)bib_inventory.json:BibTeX 清单(被引用条目与问题标注)
最终交付(默认)
仅输出一份供用户审核的报告(默认写入 ./references/;用户可指定其他目录):
NSFC-REF-ALIGNMENT-vYYYYMMDDHHMMSS.md(如同秒重复运行,脚本会追加-2/-3/...避免覆盖)
该报告必须是“只读审查报告”,不得直接修改标书正文或 .bib。
工作流(推荐)
步骤 1:预检与定位
1) 确认 project_root 存在且包含 main_tex。 2) 自动解析 main_tex 的 \input{} / \include{} 依赖树,收集所有涉及的 .tex 文件。 3) 自动发现 \bibliography{...} / \addbibresource{...} 指向的 .bib 文件;若发现 0 个 .bib,则回退为在 project_root 下搜索 *.bib 并给出 warning。
步骤 2:确定性抽取(脚本执行)
运行脚本生成结构化输入与确定性报告:
cd /path/to/ChineseResearchLaTeX
python3 skills/nsfc-ref-alignment/scripts/run_ref_alignment.py \
--project-root "projects/NSFC_General" \
--main-tex "main.tex" \
--report-dir "references" \
--prepare如需在线核验(建议只对最终稿/重点条目开启):
python3 skills/nsfc-ref-alignment/scripts/run_ref_alignment.py \
--project-root "projects/NSFC_General" \
--main-tex "main.tex" \
--report-dir "references" \
--prepare \
--verify-online步骤 3:宿主 AI 语义核查(本 skill 的核心)
宿主 AI 在读取 {run_dir}/ai_ref_alignment_input.json 后,逐条核查:
1) 真实性/存在性(P0)
- bibkey 缺失:正文引用了不存在的条目
- DOI/URL 明显无效或在线核验失败且元信息严重不一致
2) 语义不匹配风险(P0/P1)
- P0:句子对文献作出了“强断言”,但从元信息看高度不可能(如年份矛盾、领域完全不相关、明显张冠李戴)
- P1:可疑但证据不足(元信息不足、缺少 DOI/缺少 title/作者信息不全)
3) 过度主张/弱支撑(P1)
- 例如“首次/唯一/最优/显著优于”但缺乏足够支撑或疑似需要更强引用
证据优先级(强制):
在线核验结果(若开启) > BibTeX 的 title/abstract > 仅从句子推断(最低优先级)。
步骤 4:生成最终报告(只读)
在 report_dir 写入最终报告 NSFC-REF-ALIGNMENT-vYYYYMMDDHHMMSS.md,必须包含:
- Summary:总引用数、唯一 bibkey 数、缺失条目数、重复条目数、P0/P1 数
- P0(必须处理):缺失 bibkey、明显伪造/错误元信息、严重语义错配
- P1(建议处理):字段不全(缺 DOI/缺 year)、弱支撑/过度主张、需要人工复核
- 附录:逐条引用明细(至少包含 file/line、原句、bib 条目关键字段、风险判断与理由)
修改边界(强制)
- 默认不修改任何标书内容与配置:
- 禁止修改:
**/*.tex、**/*.bib、**/*.cls、**/*.sty - 允许写入:
{project_root}/.bensz-api/skills/nsfc-ref-alignment/**(中间产物)./references/**(最终报告;可由用户改到别处)- 若用户明确要求“修复引用/修复 bib”,必须先在报告里给出修改计划与影响面,再执行最小化修改(默认不做)。
与 check-review-alignment 的关系(经验复用)
- 共同点:脚本只做确定性抽取;“语义是否匹配”的判断由宿主 AI 完成;输出可追溯的结构化输入与报告。
- 不同点:本 skill 面向标书项目(多文件
\input{}结构),且默认不做任何自动改写(只输出审查报告)。
验证清单(静态自检)
{project_root}/.bensz-api/skills/nsfc-ref-alignment/{yyyy-mm-dd-hh-mm}/存在且包含 4 个核心产物(json/md/csv/json)。- 最终报告写入
report_dir,且没有任何.tex/.bib文件被修改。 - 报告对每个 P0/P1 给出:定位(file/line)+ 原句 + 依据 + 建议动作。
Changelog
All notable changes to this skill will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
[0.1.1] - 2026-02-27
Changed
- 交付报告与 run 目录命名改为秒级时间戳,并在同秒重复运行时自动追加
-2/-3/...,避免覆盖与冲突。 - Bib 发现逻辑改为整文件扫描(支持换行与
\\addbibresource[...]可选参数),回退扫描过滤.latex-cache/与.nsfc-ref-alignment/。 \\nocite{*}不再污染 citations.csv 与统计(保留 warning/flag)。- 输出路径尽量相对化;缺少
bibtexparser的 warning 降噪为仅提示一次。
Fixed
- DOI 在线核验支持常见 DOI 变体归一化,并调整 OpenAlex 编码策略降低假失败。
[0.1.0] - 2026-02-27
Added
- 初版:只读方式抽取 NSFC 标书引用与 BibTeX 清单,生成结构化输入与审核报告(中间产物隔离在
.nsfc-ref-alignment/run_{timestamp}/,最终报告默认写入./references/)。
# ================================
# nsfc-ref-alignment 默认配置(Single Source of Truth for version)
# ================================
skill_info:
name: nsfc-ref-alignment
version: 0.1.1
description: "NSFC 标书参考文献与正文引用一致性核查(只读):检查 bibkey 是否存在、BibTeX 字段基本完备性与格式(如 DOI)、并生成结构化输入供 AI 逐条评估“引用-语义”是否匹配;默认仅输出审核报告,不直接修改标书或参考文献(除非用户明确要求)。"
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
report_dir:
type: string
required: false
default: references
description: 报告输出目录(相对当前工作目录);默认 ./references
verify_online:
type: boolean
required: false
default: false
description: 是否进行在线核验(仅做确定性查询:优先用 DOI 到 Crossref/OpenAlex 检查“存在性/元信息一致性”;失败则降级为未核验)
citation_commands:
# 常见 natbib / plain LaTeX / ctex 场景
- cite
- citep
- citet
- citealp
- citeauthor
- Cite
- Citet
- Ccite
- nocite
bibliography_commands:
- bibliography
- addbibresource
ai:
input_limits:
max_sentence_chars: 500
max_title_chars: 400
max_bib_abstract_chars: 1200
max_entries: 500
max_citations: 2000
checks:
bib_required_fields:
common: [title, author]
year_like: [year, date]
doi:
enabled: true
regex: "(?i)\\b10\\.\\d{4,9}/[-._;()/:A-Z0-9]+\\b"
guardrails:
# 只读:脚本与 AI 都不得修改标书内容(tex/bib 等)。中间写入必须在 .bensz-api/skills/nsfc-ref-alignment 下;
# 最终报告默认写到 ./references(可由用户指定)。
allowed_write_globs:
- ".bensz-api/skills/nsfc-ref-alignment/**"
- "references/**"
forbidden_write_globs:
- "**/*.tex"
- "**/*.bib"
- "**/*.cls"
- "**/*.sty"
output_contract:
# run_dir = {project_root}/.bensz-api/skills/nsfc-ref-alignment/{run_id}
ai_input_json: "{run_dir}/ai_ref_alignment_input.json"
deterministic_report: "{run_dir}/ref_integrity_report.md"
citations_csv: "{run_dir}/citations.csv"
bib_inventory_json: "{run_dir}/bib_inventory.json"
nsfc-ref-alignment — 用户使用指南
本 README 面向使用者:如何触发并正确使用 nsfc-ref-alignment skill。 执行边界与硬性规范在 SKILL.md;默认参数在 config.yaml。
---
你会得到什么
- 一份只读审查报告(默认输出到
./references/):NSFC-REF-ALIGNMENT-v*.md - 一个可复现的“证据包”(强制隔离到标书目录下):
{project_root}/.bensz-api/skills/nsfc-ref-alignment/*/
该技能默认不修改任何标书正文或参考文献(*.tex/*.bib),只生成报告供你人工复核。
---
快速开始(推荐)
用法 1:最推荐(只读核查 + 报告输出到 ./references)
把 projects/NSFC_General 替换成你的标书目录:
请使用 nsfc-ref-alignment 检查以下标书引用是否可靠:
标书路径:projects/NSFC_General
输出:只生成报告,默认写入 ./references
约束:全程只读,不修改任何 .tex/.bib/.cls/.sty用法 2:开启 DOI 在线核验(更严格,但更慢)
请使用 nsfc-ref-alignment 检查 projects/NSFC_General 的引用真实性;
对 DOI 做在线核验,标注疑似伪造/不一致条目;
仍然只输出报告,不修改任何文件。用法 3:把报告输出到指定目录(避免污染仓库根目录 references)
请使用 nsfc-ref-alignment 检查 projects/NSFC_General;
将报告输出到 skills/nsfc-ref-alignment/tests/out;
全程只读。---
设计理念(为什么是“只读 + 报告制”)
在 NSFC 标书里,改正文或改参考文献通常牵一发而动全身。本技能默认采用更稳的策略:
1) 脚本做确定性抽取与校验(引用清单、bibkey 是否存在、BibTeX 字段与 DOI 格式等) 2) 宿主 AI 再做启发式/语义核查(“这句话是否真的在引用这篇论文”) 3) 最终只交付报告,由你人工确认后再决定是否要改正文/改 bib
---
输出文件说明
1) 中间产物(强制,写入标书目录)
位于:{project_root}/.bensz-api/skills/nsfc-ref-alignment/*/
典型文件:
ai_ref_alignment_input.json:结构化输入(引用位置 + 句子上下文 + 文献元信息 + 校验结果)ref_integrity_report.md:确定性报告(缺失 bibkey/重复条目/字段缺失/DOI 格式/可选在线核验摘要)citations.csv:逐条引用明细(file/line/bibkey/sentence)bib_inventory.json:被引用条目的 BibTeX 清单(含重复与缺失标注)
2) 最终交付(默认写入 ./references)
NSFC-REF-ALIGNMENT-vYYYYMMDDHHMMSS.md(如同秒重复运行会追加-2/-3/...)
---
备选用法(脚本/硬编码流程)
如果你希望不依赖对话触发,也可以直接运行脚本(仍然只读):
# 在仓库根目录运行
python3 skills/nsfc-ref-alignment/scripts/run_ref_alignment.py \
--project-root "projects/NSFC_General" \
--main-tex "main.tex" \
--report-dir "references" \
--prepare
# 可选:开启 DOI 在线核验(会更慢)
python3 skills/nsfc-ref-alignment/scripts/run_ref_alignment.py \
--project-root "projects/NSFC_General" \
--main-tex "main.tex" \
--report-dir "references" \
--prepare \
--verify-online---
参数与配置
参数默认值以 skills/nsfc-ref-alignment/config.yaml 为准,常用项:
| 参数 | 默认值 | 说明 |
|---|---|---|
project_root | (必填) | 标书项目根目录 |
main_tex | main.tex | 主入口 tex(相对 project_root) |
report_dir | references | 交付报告输出目录(相对当前工作目录) |
verify_online | false | 是否在线核验 DOI(Crossref/OpenAlex) |
---
常见问题(FAQ)
Q1:报告提示 “Missing BibKeys(P0)”,我该怎么办?
这通常意味着正文里出现了 \cite{somekey},但 .bib 里找不到 somekey。 建议你先用 nsfc-bib-manager 补齐或核对 BibTeX 条目,再决定是否要改正文引用。
Q2:为什么有时会提示缺少 bibtexparser?
脚本会优先使用 bibtexparser 提高 BibTeX 解析鲁棒性;如果环境里没有该包,会自动降级为“best-effort 手写解析器”。 降级不影响“缺失 bibkey/重复 key/部分字段检查”的核心能力,但复杂嵌套括号的字段解析可能不如 bibtexparser 稳定。
Q3:报告里说“语义不匹配风险(P0/P1)”,会自动帮我改正文吗?
默认不会。本技能只输出报告。你如果希望修复引用或改写句子,建议先让 AI 在报告里给出修改计划,你确认后再执行最小化修改。
---
WHICHMODEL(模型选择建议)
结论(怎么选)
该 skill 的”确定性抽取/校验”由脚本完成,LLM 主要负责两件事: 1) 语义核查:判断”包含引用的句子”是否真的在引用该文献(高风险、需要强推理、必须克制幻觉) 2) 报告写作:把确定性结果组织成可审阅的 P0/P1 清单与证据链
唯一推荐组合:GPT-5.2 high + Codex
其他模型(GPT-4o、GPT-4o mini、o4-mini、Claude 系列等)均不推荐用于本 skill。原因:语义引用核查对推理深度和幻觉抑制要求极高,低于 GPT-5.2 high 的模型在”不确定时倾向编造论文内容”的风险不可接受。
参数建议
- temperature:建议偏低(例如 0–0.3),降低”乱补细节/过度自信”的概率
- 工作方式:先让模型只做”判定 + 证据 + 风险等级”,不要先让它改正文;等你确认后再进入”修改计划/最小化修复”阶段
#!/usr/bin/env python3
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
_BIBTEXPARSER = None
_BIBTEXPARSER_ERR: Optional[str] = None
_WARNED_BIBTEXPARSER_MISSING = False
try:
import bibtexparser # type: ignore
_BIBTEXPARSER = bibtexparser
except Exception as e: # pragma: no cover
_BIBTEXPARSER = None
_BIBTEXPARSER_ERR = str(e)
@dataclass(frozen=True)
class BibEntry:
key: str
entry_type: str
fields: Dict[str, str] # lowercase keys
source: str # path string
def get(self, name: str, default: str = "") -> str:
return str(self.fields.get(name.lower(), default) or default)
def _normalize_field_value(v: Any) -> str:
s = str(v or "").strip()
# collapse whitespace
return " ".join(s.split())
def parse_bib_file(bib_path: Path) -> Tuple[Dict[str, BibEntry], List[str]]:
"""
Parse a bib file into dict[key -> BibEntry]. Best-effort.
"""
warnings: List[str] = []
text = bib_path.read_text(encoding="utf-8", errors="ignore")
out: Dict[str, BibEntry] = {}
# Prefer bibtexparser if available (more robust for nested braces).
global _WARNED_BIBTEXPARSER_MISSING
if _BIBTEXPARSER is not None:
try:
bib_db = _BIBTEXPARSER.loads(text)
for e in (bib_db.entries or []):
key = e.get("ID") or e.get("id")
if not key:
continue
entry_type = str(e.get("ENTRYTYPE") or e.get("entrytype") or "").strip().lower()
fields = {str(k).lower(): _normalize_field_value(v) for k, v in e.items()}
out[str(key)] = BibEntry(key=str(key), entry_type=entry_type, fields=fields, source=str(bib_path))
if out:
return out, warnings
except Exception as e:
warnings.append(f"bibtexparser failed to parse {bib_path.name}; fallback to manual parser ({e})")
else:
# Avoid spamming the same warning for every .bib file.
if not _WARNED_BIBTEXPARSER_MISSING:
warnings.append(f"bibtexparser unavailable ({_BIBTEXPARSER_ERR or 'unknown'}); using manual BibTeX parser (best-effort)")
_WARNED_BIBTEXPARSER_MISSING = True
# Fallback manual parsing (good enough for most clean BibTeX).
# This will not correctly parse all nested braces, but still catches missing keys / basic fields.
entry_re = re.compile(r"@(?P<typ>\w+)\s*\{\s*(?P<key>[^,]+)\s*,", re.MULTILINE)
matches = list(entry_re.finditer(text))
for i, m in enumerate(matches):
entry_type = m.group("typ").strip().lower()
key = m.group("key").strip()
start = m.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
body = text[start:end]
fields: Dict[str, str] = {}
# naive field pattern: name = {value} or "value"
for fm in re.finditer(r"(?P<name>\w+)\s*=\s*(?P<val>\{[^{}]*\}|\"[^\"]*\")\s*,?", body, re.MULTILINE):
name = fm.group("name").strip().lower()
val = fm.group("val").strip().strip(",").strip()
if val.startswith("{") and val.endswith("}"):
val = val[1:-1]
if val.startswith("\"") and val.endswith("\""):
val = val[1:-1]
fields[name] = _normalize_field_value(val)
out[key] = BibEntry(key=key, entry_type=entry_type, fields=fields, source=str(bib_path))
if not out:
warnings.append(f"no bib entries parsed from {bib_path}")
return out, warnings
def merge_bib_entries(bib_files: List[Path]) -> Tuple[Dict[str, BibEntry], Dict[str, List[str]], List[str]]:
"""
Merge bib entries from multiple bib files.
Returns (entries_by_key, duplicates, warnings).
duplicates: key -> list of sources (including the chosen one)
"""
warnings: List[str] = []
entries: Dict[str, BibEntry] = {}
sources: Dict[str, List[str]] = {}
for bib in bib_files:
if not bib.exists():
warnings.append(f"bib file not found: {bib}")
continue
parsed, w = parse_bib_file(bib)
warnings.extend(w)
for k, e in parsed.items():
sources.setdefault(k, []).append(str(bib))
if k not in entries:
entries[k] = e
duplicates = {k: v for k, v in sources.items() if len(v) > 1}
return entries, duplicates, warnings
def validate_doi(doi: str, doi_regex: str) -> bool:
if not doi:
return True
try:
d = doi.strip()
# Common variants in BibTeX exports
d = re.sub(r"^(?i)\s*doi\s*:\s*", "", d).strip()
m_url = re.search(r"(?i)doi\.org/(?P<doi>10\.\d{4,9}/.+)$", d)
if m_url:
d = m_url.group("doi").strip()
m = re.search(doi_regex, d)
return bool(m and m.start() == 0 and m.end() == len(d))
except Exception:
# if regex itself is invalid, don't block
return True
def required_field_issues(
entry: BibEntry,
required_common: List[str],
year_like: List[str],
) -> List[str]:
issues: List[str] = []
for f in required_common:
if not entry.get(f):
issues.append(f"missing field: {f}")
if year_like:
if not any(entry.get(f) for f in year_like):
issues.append(f"missing field: one of {year_like}")
return issues
#!/usr/bin/env python3
from __future__ import annotations
import bisect
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple
from runtime_utils import relpath_safe, sanitize_lines_for_parsing
_INPUT_RE = re.compile(r"\\(input|include)\s*\{(?P<path>[^}]+)\}")
_SECTION_RE = re.compile(r"\\(section|subsection|subsubsection|chapter|part)\*?\s*\{(?P<title>[^}]*)\}")
@dataclass(frozen=True)
class CitationHit:
bibkey: str
cite_command: str
file: str # relative to project_root when possible
line: int # 1-based
heading: str
sentence: str
def _normalize_tex_path(raw: str, current_dir: Path) -> Optional[Path]:
raw = raw.strip()
if not raw:
return None
# handle common \input{foo} where extension omitted
p = Path(raw)
if p.suffix.lower() != ".tex":
p = p.with_suffix(".tex")
if not p.is_absolute():
p = (current_dir / p)
return p
def discover_tex_dependency_tree(project_root: Path, main_tex: Path, max_files: int = 2000) -> Tuple[List[Path], List[str]]:
"""
Best-effort parse \input/\include dependency tree rooted at main_tex.
Returns (tex_files, warnings). Paths are absolute.
"""
warnings: List[str] = []
visited: Set[Path] = set()
ordered: List[Path] = []
stack: List[Path] = [main_tex]
while stack:
tex = stack.pop()
try:
tex = tex.resolve()
except Exception:
tex = tex
if tex in visited:
continue
visited.add(tex)
ordered.append(tex)
if len(visited) > max_files:
warnings.append(f"too many tex files discovered (> {max_files}); stop expanding dependency tree")
break
if not tex.exists():
warnings.append(f"missing tex file referenced: {tex}")
continue
try:
raw_lines = tex.read_text(encoding="utf-8", errors="ignore").splitlines()
except Exception as e:
warnings.append(f"failed to read tex file: {tex} ({e})")
continue
lines = sanitize_lines_for_parsing(raw_lines)
cur_dir = tex.parent
for line in lines:
for m in _INPUT_RE.finditer(line):
child = _normalize_tex_path(m.group("path"), cur_dir)
if child is None:
continue
# keep expansion within project_root as a safety heuristic
try:
child.resolve().relative_to(project_root.resolve())
except Exception:
# Safer default: do not read outside project_root (avoid surprises / leakage).
warnings.append(f"skip tex dependency outside project_root: {child}")
continue
stack.append(child)
# Ensure deterministic order: keep DFS discovery order but stable.
return ordered, warnings
def discover_bib_files(
project_root: Path,
tex_files: Sequence[Path],
bibliography_commands: Sequence[str],
) -> Tuple[List[Path], List[str]]:
warnings: List[str] = []
bibs: List[Path] = []
cmd_alt = "|".join(re.escape(c) for c in bibliography_commands)
# Allow optional arguments (mainly for biblatex's \addbibresource[...]{...})
bib_re = re.compile(
rf"\\(?:{cmd_alt})\s*(?:\[[^\]]*\]\s*)*\{{(?P<paths>[^}}]+)\}}",
flags=re.MULTILINE | re.DOTALL,
)
for tex in tex_files:
if not tex.exists():
continue
raw_lines = tex.read_text(encoding="utf-8", errors="ignore").splitlines()
lines = sanitize_lines_for_parsing(raw_lines)
sanitized_text = "\n".join(lines)
for m in bib_re.finditer(sanitized_text):
payload = (m.group("paths") or "").strip()
if not payload:
continue
# \bibliography{a,b,c} allows comma-separated list.
for part in [p.strip() for p in re.split(r"[,\n]", payload) if p.strip()]:
p = Path(part.strip().strip("{}").strip())
if p.suffix.lower() != ".bib":
p = p.with_suffix(".bib")
if not p.is_absolute():
# In practice, BibTeX resolves paths relative to the main build cwd (usually project_root),
# not the directory of the file that contains \bibliography{}.
p = (project_root / p)
bibs.append(p)
# de-dup while preserving order
uniq: List[Path] = []
seen: Set[str] = set()
for b in bibs:
key = str(b)
if key in seen:
continue
seen.add(key)
uniq.append(b)
# Fallback: search project_root for *.bib
if not uniq:
candidates = [
p
for p in sorted(project_root.rglob("*.bib"))
if ".nsfc-ref-alignment" not in str(p) and ".bensz-api" not in str(p) and ".latex-cache" not in str(p)
]
if candidates:
warnings.append("no bib file discovered from \\bibliography/\\addbibresource; fallback to scanning project_root/**/*.bib")
uniq = candidates
else:
warnings.append("no .bib file found (neither via commands nor filesystem)")
return uniq, warnings
def _build_line_starts(text: str) -> List[int]:
starts = [0]
for i, ch in enumerate(text):
if ch == "\n":
starts.append(i + 1)
return starts
def _idx_to_line(line_starts: List[int], idx: int) -> int:
# 1-based
pos = bisect.bisect_right(line_starts, idx) - 1
return pos + 1
def _extract_sentence(text: str, start: int, end: int, max_chars: int) -> str:
"""
Heuristic: find nearest sentence boundary around [start,end).
"""
# Treat line breaks as whitespace, not sentence boundaries.
boundaries = set(".!?。!?")
lo = start
while lo > 0 and text[lo - 1] not in boundaries:
lo -= 1
if start - lo > max_chars:
break
hi = end
while hi < len(text) and text[hi] not in boundaries:
hi += 1
if hi - end > max_chars:
break
snippet = text[lo:hi].replace("\n", " ").strip()
# collapse whitespace
snippet = " ".join(snippet.split())
if len(snippet) > max_chars:
snippet = snippet[:max_chars].rstrip() + " …"
return snippet
def _heading_by_line(lines: List[str]) -> List[str]:
"""
For each 0-based line index, record last seen heading title.
"""
cur = ""
out: List[str] = []
for line in lines:
m = _SECTION_RE.search(line)
if m:
cur = " ".join(m.group("title").split())
out.append(cur)
return out
def extract_citations(
project_root: Path,
tex_files: Sequence[Path],
citation_commands: Sequence[str],
max_sentence_chars: int,
) -> Tuple[List[CitationHit], List[str]]:
warnings: List[str] = []
hits: List[CitationHit] = []
if not citation_commands:
warnings.append("citation_commands is empty; no citations extracted")
return [], warnings
cmd_alt = "|".join(re.escape(c) for c in citation_commands)
cite_re = re.compile(
rf"\\(?P<cmd>{cmd_alt})\s*(?:\[[^\]]*\]\s*)*\{{(?P<keys>[^}}]+)\}}",
flags=re.MULTILINE | re.DOTALL,
)
for tex in tex_files:
if not tex.exists() or tex.suffix.lower() != ".tex":
continue
raw_lines = tex.read_text(encoding="utf-8", errors="ignore").splitlines()
lines = sanitize_lines_for_parsing(raw_lines)
sanitized_text = "\n".join(lines)
line_starts = _build_line_starts(sanitized_text)
headings = _heading_by_line(lines)
for m in cite_re.finditer(sanitized_text):
cmd = m.group("cmd")
keys_raw = m.group("keys")
if not keys_raw:
continue
keys = [k.strip() for k in re.split(r"[,\n]", keys_raw) if k.strip()]
if not keys:
continue
line_no = _idx_to_line(line_starts, m.start())
heading = headings[line_no - 1] if 0 < line_no <= len(headings) else ""
sentence = _extract_sentence(sanitized_text, m.start(), m.end(), max_chars=max_sentence_chars)
cite_cmd = "\\" + cmd + "{" + keys_raw.strip().replace("\n", " ") + "}"
for k in keys:
hits.append(
CitationHit(
bibkey=k,
cite_command=cite_cmd,
file=relpath_safe(tex, project_root),
line=line_no,
heading=heading,
sentence=sentence,
)
)
# common pitfall: \nocite{*}
if any(h.bibkey == "*" for h in hits):
warnings.append("found \\nocite{*}; unused-bibkey check will be less meaningful")
return hits, warnings
#!/usr/bin/env python3
from __future__ import annotations
import json
import re
import time
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple
def _http_get_json(url: str, timeout_s: int = 20) -> Tuple[Optional[dict], Optional[str]]:
req = urllib.request.Request(
url,
headers={
"User-Agent": "nsfc-ref-alignment/0.1 (mailto: none)",
"Accept": "application/json",
},
method="GET",
)
try:
with urllib.request.urlopen(req, timeout=timeout_s) as resp:
payload = resp.read().decode("utf-8", errors="ignore")
return json.loads(payload), None
except Exception as e:
return None, str(e)
def normalize_title(title: str) -> str:
t = str(title or "").strip().lower()
# remove common LaTeX braces/commands crudely
t = re.sub(r"\\[a-zA-Z]+\*?(?:\[[^\]]*\])?(?:\{[^}]*\})?", " ", t)
t = t.replace("{", " ").replace("}", " ")
t = re.sub(r"[^a-z0-9]+", " ", t)
return " ".join(t.split())
def title_similarity(a: str, b: str) -> float:
na = normalize_title(a)
nb = normalize_title(b)
if not na or not nb:
return 0.0
sa = set(na.split())
sb = set(nb.split())
if not sa or not sb:
return 0.0
inter = len(sa & sb)
union = len(sa | sb)
return inter / max(1, union)
@dataclass
class OnlineCheckResult:
ok: bool
doi: str
crossref_ok: bool
openalex_ok: bool
crossref_title: str
openalex_title: str
error: str
title_similarity_crossref: float
title_similarity_openalex: float
def to_dict(self) -> Dict[str, Any]:
return {
"ok": self.ok,
"doi": self.doi,
"crossref_ok": self.crossref_ok,
"openalex_ok": self.openalex_ok,
"crossref_title": self.crossref_title,
"openalex_title": self.openalex_title,
"error": self.error,
"title_similarity_crossref": self.title_similarity_crossref,
"title_similarity_openalex": self.title_similarity_openalex,
}
def check_doi_online(doi: str, bib_title: str, sleep_s: float = 0.0) -> OnlineCheckResult:
doi_norm = (doi or "").strip()
# Accept common variants
doi_norm = re.sub(r"^(?i)\s*doi\s*:\s*", "", doi_norm).strip()
m_url = re.search(r"(?i)doi\.org/(?P<doi>10\.\d{4,9}/.+)$", doi_norm)
if m_url:
doi_norm = m_url.group("doi").strip()
if not doi_norm:
return OnlineCheckResult(
ok=False,
doi="",
crossref_ok=False,
openalex_ok=False,
crossref_title="",
openalex_title="",
error="empty doi",
title_similarity_crossref=0.0,
title_similarity_openalex=0.0,
)
if sleep_s > 0:
time.sleep(sleep_s)
crossref_title = ""
openalex_title = ""
crossref_ok = False
openalex_ok = False
err_parts = []
doi_encoded_crossref = urllib.parse.quote(doi_norm, safe="")
# OpenAlex accepts DOI in the path form; keep '/' unescaped for readability (server should decode either way).
doi_encoded_openalex = urllib.parse.quote(doi_norm, safe="/")
# Crossref (existence + title)
crossref_url = f"https://api.crossref.org/works/{doi_encoded_crossref}"
crossref_json, crossref_err = _http_get_json(crossref_url)
if crossref_json and isinstance(crossref_json, dict):
msg = (crossref_json.get("message") or {}) if isinstance(crossref_json.get("message"), dict) else {}
titles = msg.get("title") or []
if isinstance(titles, list) and titles:
crossref_title = str(titles[0] or "")
crossref_ok = True
else:
err_parts.append(f"crossref: {crossref_err or 'unknown error'}")
# OpenAlex (existence + title)
openalex_url = f"https://api.openalex.org/works/https://doi.org/{doi_encoded_openalex}"
openalex_json, openalex_err = _http_get_json(openalex_url)
if openalex_json and isinstance(openalex_json, dict):
openalex_title = str(openalex_json.get("title") or "")
openalex_ok = True
else:
err_parts.append(f"openalex: {openalex_err or 'unknown error'}")
sim_crossref = title_similarity(bib_title, crossref_title) if crossref_title else 0.0
sim_openalex = title_similarity(bib_title, openalex_title) if openalex_title else 0.0
ok = crossref_ok or openalex_ok
return OnlineCheckResult(
ok=ok,
doi=doi_norm,
crossref_ok=crossref_ok,
openalex_ok=openalex_ok,
crossref_title=crossref_title,
openalex_title=openalex_title,
error="; ".join(err_parts),
title_similarity_crossref=sim_crossref,
title_similarity_openalex=sim_openalex,
)
#!/usr/bin/env python3
from __future__ import annotations
import csv
import json
from pathlib import Path
from typing import Any, Dict, Iterable, List, Tuple
from latex_scanner import CitationHit
def write_citations_csv(path: Path, hits: List[CitationHit]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["bibkey", "cite_command", "file", "line", "heading", "sentence"])
for h in hits:
w.writerow([h.bibkey, h.cite_command, h.file, h.line, h.heading, h.sentence])
def write_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def build_deterministic_report_md(summary: Dict[str, Any]) -> str:
"""
summary: dict produced by run_ref_alignment.py; deterministic content only.
"""
s = summary
def _fmt_list(items: List[str]) -> str:
if not items:
return "- (无)"
return "\n".join([f"- {x}" for x in items])
lines: List[str] = []
lines.append("# NSFC Ref Integrity Report(确定性)")
lines.append("")
lines.append(f"- generated_at: {s.get('generated_at','')}")
lines.append(f"- project_root: `{s.get('project_root','')}`")
lines.append(f"- main_tex: `{s.get('main_tex','')}`")
lines.append(f"- run_dir: `{s.get('run_dir','')}`")
lines.append("")
stats = s.get("stats") or {}
lines.append("## Summary")
lines.append("")
lines.append(f"- tex_files: {stats.get('tex_files', 0)}")
lines.append(f"- bib_files: {stats.get('bib_files', 0)}")
lines.append(f"- total_citations: {stats.get('total_citations', 0)}")
lines.append(f"- unique_cited_bibkeys: {stats.get('unique_cited_bibkeys', 0)}")
lines.append(f"- missing_bibkeys: {stats.get('missing_bibkeys', 0)}")
lines.append(f"- duplicate_bibkeys: {stats.get('duplicate_bibkeys', 0)}")
lines.append(f"- field_issues: {stats.get('field_issues', 0)}")
lines.append(f"- invalid_doi: {stats.get('invalid_doi', 0)}")
lines.append("")
warnings = list(s.get("warnings") or [])
if warnings:
lines.append("## Warnings")
lines.append("")
lines.append(_fmt_list(warnings))
lines.append("")
issues = s.get("issues") or {}
lines.append("## Missing BibKeys(P0)")
lines.append("")
missing = issues.get("missing_bibkeys_detail") or []
if missing:
for item in missing:
bibkey = item.get("bibkey")
count = item.get("count", 0)
examples = item.get("examples") or []
lines.append(f"- `{bibkey}` (count={count})")
for ex in examples[:3]:
lines.append(f" - {ex}")
else:
lines.append("- (无)")
lines.append("")
lines.append("## Duplicate BibKeys(P0/P1)")
lines.append("")
dup = issues.get("duplicate_bibkeys") or {}
if dup:
for k, srcs in dup.items():
lines.append(f"- `{k}`")
for s0 in srcs:
lines.append(f" - {s0}")
else:
lines.append("- (无)")
lines.append("")
lines.append("## Bib Field Issues(P1)")
lines.append("")
field_issues = issues.get("field_issues") or []
if field_issues:
for it in field_issues[:200]:
lines.append(f"- `{it.get('bibkey','')}`: {it.get('issues','')}")
else:
lines.append("- (无)")
lines.append("")
lines.append("## DOI Format Issues(P1)")
lines.append("")
doi_issues = issues.get("doi_issues") or []
if doi_issues:
for it in doi_issues[:200]:
lines.append(f"- `{it.get('bibkey','')}`: `{it.get('doi','')}`")
else:
lines.append("- (无)")
lines.append("")
online = s.get("online_verify") or {}
if online.get("enabled"):
lines.append("## Online Verification(确定性)")
lines.append("")
lines.append(f"- enabled: {online.get('enabled')}")
lines.append(f"- checked_doi: {online.get('checked', 0)}")
lines.append(f"- ok: {online.get('ok', 0)}")
lines.append(f"- failed: {online.get('failed', 0)}")
lines.append("")
failures = online.get("failures") or []
if failures:
lines.append("### Failures(P0/P1)")
lines.append("")
for f0 in failures[:200]:
lines.append(f"- `{f0.get('bibkey','')}` DOI=`{f0.get('doi','')}` error=`{f0.get('error','')}`")
lines.append("")
lines.append("## Next Step(AI 语义核查)")
lines.append("")
lines.append(
"本报告仅包含确定性检查结果。请结合 `ai_ref_alignment_input.json` 由宿主 AI 进一步逐条评估“正文表述是否与该文献匹配”,并在 report_dir(默认 `./references/`)输出最终审核报告。"
)
lines.append("")
return "\n".join(lines)
#!/usr/bin/env python3
"""
run_ref_alignment.py - nsfc-ref-alignment 的确定性入口(只读)
脚本职责(确定性):
- 解析 NSFC 标书 LaTeX 项目(多文件 \input/\include)
- 抽取所有引用(\cite{...} 等)与其句子级上下文
- 发现并解析 .bib(\bibliography / \addbibresource)
- 进行确定性完整性检查:缺失 bibkey / 重复条目 / 字段缺失 / DOI 格式
- 可选:在线核验 DOI(Crossref/OpenAlex,只做存在性与元信息粗比对;失败降级)
- 生成:
- 中间产物:{project_root}/.bensz-api/skills/nsfc-ref-alignment/{timestamp}/...
- 交付报告(确定性草稿):{report_dir}/NSFC-REF-ALIGNMENT-v{timestamp}.md
非职责(启发式/AI):
- 判断“引用-语义是否真的匹配”
- 给出“改正文/改 bib”的具体改动(默认禁止自动修改)
这些由宿主 AI 在执行 nsfc-ref-alignment skill 时完成。
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
from collections import Counter
from pathlib import Path
from typing import Any, Dict, List, Tuple
from bib_utils import BibEntry, merge_bib_entries, required_field_issues, validate_doi
from latex_scanner import CitationHit, discover_bib_files, discover_tex_dependency_tree, extract_citations
from online_verify import check_doi_online
from report_utils import build_deterministic_report_md, write_citations_csv, write_json
from runtime_utils import load_config, relpath_safe
def local_timestamp_minute() -> str:
return dt.datetime.now().strftime("%Y-%m-%d-%H-%M")
def _safe_get(cfg: Dict[str, Any], path: List[str], default: Any) -> Any:
cur: Any = cfg
for p in path:
if not isinstance(cur, dict) or p not in cur:
return default
cur = cur[p]
return cur
def _entry_digest(e: BibEntry, project_root: Path, limits: Dict[str, int]) -> Dict[str, Any]:
max_title = int(limits.get("max_title_chars", 400) or 400)
max_abs = int(limits.get("max_bib_abstract_chars", 1200) or 1200)
title = e.get("title")
abstract = e.get("abstract")
if len(title) > max_title:
title = title[:max_title].rstrip() + " …"
if len(abstract) > max_abs:
abstract = abstract[:max_abs].rstrip() + " …"
return {
"bibkey": e.key,
"entry_type": e.entry_type,
"source": relpath_safe(Path(e.source), project_root),
"title": title,
"author": e.get("author"),
"year": e.get("year") or e.get("date"),
"venue": e.get("journal") or e.get("booktitle") or e.get("publisher"),
"doi": e.get("doi"),
"url": e.get("url"),
"abstract": abstract,
}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--project-root", required=True, help="NSFC 标书项目根目录(如 projects/NSFC_General)")
ap.add_argument("--main-tex", default="main.tex", help="入口 tex(相对 project_root),默认 main.tex")
ap.add_argument("--report-dir", default="references", help="交付报告输出目录(相对当前工作目录),默认 ./references")
ap.add_argument("--prepare", action="store_true", help="生成结构化输入与确定性报告(默认执行)")
ap.add_argument("--verify-online", action="store_true", help="在线核验 DOI(Crossref/OpenAlex)")
args = ap.parse_args()
skill_root = Path(__file__).resolve().parents[1]
cfg, cfg_warnings = load_config(skill_root)
project_root = Path(args.project_root).expanduser().resolve()
main_tex = (project_root / args.main_tex).resolve()
report_dir = Path(args.report_dir).expanduser().resolve()
run_id = local_timestamp_minute()
runs_root = project_root / ".bensz-api" / "skills" / "nsfc-ref-alignment"
runs_root.mkdir(parents=True, exist_ok=True)
run_dir = None
# Use atomic mkdir to avoid collisions (even under concurrent runs).
for i in range(1, 100):
suffix = "" if i == 1 else f"-{i:02d}"
cand = runs_root / f"{run_id}{suffix}"
try:
cand.mkdir(parents=False, exist_ok=False)
run_dir = cand
break
except FileExistsError:
continue
if run_dir is None:
raise SystemExit(f"failed to allocate unique run_dir under {runs_root} for run_id={run_id}")
warnings: List[str] = []
warnings.extend(cfg_warnings)
if not project_root.exists():
raise SystemExit(f"project_root not found: {project_root}")
if not main_tex.exists():
raise SystemExit(f"main_tex not found: {main_tex}")
citation_commands = _safe_get(cfg, ["citation_commands"], []) or []
bibliography_commands = _safe_get(cfg, ["bibliography_commands"], ["bibliography", "addbibresource"]) or []
limits = _safe_get(cfg, ["ai", "input_limits"], {}) or {}
max_sentence_chars = int(limits.get("max_sentence_chars", 500) or 500)
doi_cfg = _safe_get(cfg, ["checks", "doi"], {}) or {}
doi_regex = str(doi_cfg.get("regex") or r"(?i)\b10\.\d{4,9}/[-._;()/:A-Z0-9]+\b")
tex_files, w1 = discover_tex_dependency_tree(project_root, main_tex)
warnings.extend(w1)
bib_files, w2 = discover_bib_files(project_root, tex_files, bibliography_commands=bibliography_commands)
warnings.extend(w2)
entries_by_key, duplicates, bib_warnings = merge_bib_entries(bib_files)
warnings.extend(bib_warnings)
hits, cite_warnings = extract_citations(project_root, tex_files, citation_commands=citation_commands, max_sentence_chars=max_sentence_chars)
warnings.extend(cite_warnings)
def _norm_src(s: str) -> str:
return relpath_safe(Path(s), project_root)
duplicates_norm = {k: [_norm_src(x) for x in v] for k, v in (duplicates or {}).items()}
hits_effective = [h for h in hits if h.bibkey and h.bibkey != "*"]
cited_keys = [h.bibkey for h in hits_effective]
cited_key_set = set(cited_keys)
nocite_star = any(h.bibkey == "*" for h in hits)
missing_keys = sorted(cited_key_set - set(entries_by_key.keys()))
# Unused keys is meaningful only if there isn't \nocite{*}
unused_keys: List[str] = []
if not nocite_star:
unused_keys = sorted(set(entries_by_key.keys()) - cited_key_set)
# Field issues for cited entries only (to avoid noise)
req_common = list(_safe_get(cfg, ["checks", "bib_required_fields", "common"], ["title", "author"]) or [])
req_year_like = list(_safe_get(cfg, ["checks", "bib_required_fields", "year_like"], ["year", "date"]) or [])
field_issues: List[Dict[str, Any]] = []
doi_issues: List[Dict[str, Any]] = []
bib_inventory: Dict[str, Any] = {"cited": [], "missing": missing_keys, "duplicates": duplicates_norm}
for k in sorted(cited_key_set):
e = entries_by_key.get(k)
if not e:
continue
issues = required_field_issues(e, required_common=req_common, year_like=req_year_like)
if issues:
field_issues.append({"bibkey": k, "issues": "; ".join(issues), "source": _norm_src(e.source)})
doi = e.get("doi")
if doi and not validate_doi(doi, doi_regex=doi_regex):
doi_issues.append({"bibkey": k, "doi": doi, "source": _norm_src(e.source)})
bib_inventory["cited"].append(_entry_digest(e, project_root=project_root, limits=limits))
# Missing detail: count and examples
counter = Counter(cited_keys)
missing_detail: List[Dict[str, Any]] = []
for k in missing_keys:
examples = []
for h in hits:
if h.bibkey == k:
examples.append(f"{h.file}:{h.line} {h.sentence}")
if len(examples) >= 5:
break
missing_detail.append({"bibkey": k, "count": int(counter.get(k, 0)), "examples": examples})
online_summary: Dict[str, Any] = {"enabled": bool(args.verify_online), "checked": 0, "ok": 0, "failed": 0, "failures": []}
online_results: Dict[str, Any] = {}
if args.verify_online:
# Only check DOIs for cited entries.
for k in sorted(cited_key_set):
e = entries_by_key.get(k)
if not e:
continue
doi = e.get("doi").strip()
if not doi:
continue
res = check_doi_online(doi, bib_title=e.get("title"), sleep_s=0.2)
online_summary["checked"] += 1
online_results[k] = res.to_dict()
# mark failures: no provider ok OR title mismatch suspicious (when both titles present)
title_mismatch = False
if res.crossref_ok and res.crossref_title and e.get("title"):
title_mismatch = res.title_similarity_crossref < 0.2
if res.openalex_ok and res.openalex_title and e.get("title"):
title_mismatch = title_mismatch or (res.title_similarity_openalex < 0.2)
if not res.ok or title_mismatch:
online_summary["failed"] += 1
online_summary["failures"].append(
{
"bibkey": k,
"doi": doi,
"error": res.error or ("title mismatch suspicious" if title_mismatch else ""),
"title_similarity_crossref": res.title_similarity_crossref,
"title_similarity_openalex": res.title_similarity_openalex,
}
)
else:
online_summary["ok"] += 1
# Build AI input JSON (bounded)
max_entries = int(limits.get("max_entries", 500) or 500)
max_citations = int(limits.get("max_citations", 2000) or 2000)
ai_input: Dict[str, Any] = {
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"skill_info": (_safe_get(cfg, ["skill_info"], {}) or {}),
"project_root": str(project_root),
"main_tex": relpath_safe(main_tex, project_root),
"run_dir": str(run_dir),
"policy": {
"read_only": True,
"do_not_modify": ["**/*.tex", "**/*.bib", "**/*.cls", "**/*.sty"],
"intermediate_dir": str(run_dir),
"deliver_dir_default": str(report_dir),
},
"discovery": {
"tex_files": [relpath_safe(p, project_root) for p in tex_files[:2000]],
"bib_files": [relpath_safe(p, project_root) for p in bib_files[:2000]],
},
"stats": {
"tex_files": len(tex_files),
"bib_files": len(bib_files),
"total_citations": len(hits_effective),
"unique_cited_bibkeys": len(cited_key_set),
"missing_bibkeys": len(missing_keys),
"duplicate_bibkeys": len(duplicates),
"field_issues": len(field_issues),
"invalid_doi": len(doi_issues),
"nocite_star_present": nocite_star,
"unused_bibkeys": len(unused_keys),
},
"warnings": warnings,
"issues": {
"missing_bibkeys": missing_keys,
"missing_bibkeys_detail": missing_detail,
"duplicate_bibkeys": duplicates_norm,
"unused_bibkeys": unused_keys[:1000],
"field_issues": field_issues[:1000],
"doi_issues": doi_issues[:1000],
},
"online_verify": online_summary,
"online_results_by_bibkey": online_results,
"citations": [
{
"bibkey": h.bibkey,
"cite_command": h.cite_command,
"file": h.file,
"line": h.line,
"heading": h.heading,
"sentence": h.sentence,
}
for h in hits_effective[:max_citations]
],
"bib_inventory_cited": bib_inventory["cited"][:max_entries],
}
# Deterministic report summary input
report_summary = {
"generated_at": ai_input["generated_at"],
"project_root": str(project_root),
"main_tex": str(ai_input["main_tex"]),
"run_dir": str(run_dir),
"warnings": warnings,
"stats": ai_input["stats"],
"issues": {
"missing_bibkeys_detail": missing_detail,
"duplicate_bibkeys": duplicates_norm,
"field_issues": field_issues,
"doi_issues": doi_issues,
},
"online_verify": online_summary,
}
# Write outputs (all writes are either run_dir or report_dir)
write_citations_csv(run_dir / "citations.csv", hits_effective)
write_json(run_dir / "bib_inventory.json", bib_inventory)
write_json(run_dir / "ai_ref_alignment_input.json", ai_input)
(run_dir / "ref_integrity_report.md").write_text(build_deterministic_report_md(report_summary), encoding="utf-8")
write_json(
run_dir / "run_manifest.json",
{
"args": vars(args),
"project_root": str(project_root),
"main_tex": str(main_tex),
"tex_files": [str(p) for p in tex_files],
"bib_files": [str(p) for p in bib_files],
"run_dir": str(run_dir),
},
)
# Delivery report (deterministic draft).
# Tie deliver filename to run_dir so it's unique even when run_id gets a -2/-3 suffix.
report_dir.mkdir(parents=True, exist_ok=True)
deliver_id = run_dir.name
deliver_path = report_dir / f"NSFC-REF-ALIGNMENT-v{deliver_id}.md"
draft = []
draft.append("# NSFC 参考文献与引用核查报告(草稿:确定性部分)")
draft.append("")
draft.append(f"- project_root: `{project_root}`")
draft.append(f"- main_tex: `{relpath_safe(main_tex, project_root)}`")
draft.append(f"- run_dir: `{run_dir}`")
draft.append("")
draft.append("本文件由脚本生成,包含“确定性检查”结果;请在执行 nsfc-ref-alignment skill 时由宿主 AI 补充“语义匹配核查”部分。")
draft.append("")
draft.append("---")
draft.append("")
draft.append(build_deterministic_report_md(report_summary))
deliver_path.write_text("\n".join(draft) + "\n", encoding="utf-8")
print(str(deliver_path))
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
from __future__ import annotations
import datetime as dt
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
def python_executable() -> str:
return sys.executable or "python3"
def utc_timestamp_compact() -> str:
# Use local time (not UTC) to match user expectations in filenames.
return dt.datetime.now().strftime("%Y%m%d%H%M%S")
def load_config(skill_root: Path) -> Tuple[Dict[str, Any], List[str]]:
"""
Best-effort load YAML config; fallback to minimal defaults if PyYAML is missing.
Returns (config, warnings).
"""
warnings: List[str] = []
cfg_path = skill_root / "config.yaml"
if not cfg_path.exists():
return {}, [f"config.yaml not found at {cfg_path}"]
text = cfg_path.read_text(encoding="utf-8", errors="ignore")
try:
import yaml # type: ignore
data = yaml.safe_load(text) or {}
if not isinstance(data, dict):
warnings.append("config.yaml parsed but is not a dict; fallback to empty config")
return {}, warnings
return data, warnings
except Exception as e:
warnings.append(f"PyYAML unavailable or failed to parse config.yaml ({e}); using fallback defaults")
return {}, warnings
def strip_latex_comment(line: str) -> str:
"""
Remove unescaped '%' comments from a LaTeX line while keeping content before it.
"""
for i, ch in enumerate(line):
if ch != "%":
continue
bs = 0
j = i - 1
while j >= 0 and line[j] == "\\":
bs += 1
j -= 1
if bs % 2 == 1:
continue
return line[:i].rstrip()
return line.rstrip("\n")
def sanitize_lines_for_parsing(lines: List[str]) -> List[str]:
"""
Preprocess lines for command extraction:
- strip comments
- blank out verbatim-like environments (keep line count stable)
"""
out: List[str] = []
in_verbatim = False
for line in lines:
stripped = line.strip()
if not in_verbatim and (
r"\begin{verbatim}" in stripped
or r"\begin{lstlisting}" in stripped
or r"\begin{minted}" in stripped
):
in_verbatim = True
out.append("")
continue
if in_verbatim and (
r"\end{verbatim}" in stripped
or r"\end{lstlisting}" in stripped
or r"\end{minted}" in stripped
):
in_verbatim = False
out.append("")
continue
if in_verbatim:
out.append("")
continue
out.append(strip_latex_comment(line))
return out
def relpath_safe(path: Path, base: Path) -> str:
try:
return str(path.resolve().relative_to(base.resolve()))
except Exception:
return str(path)