
Research Citation Check
- 25 installs
- 2.6k repo stars
- Updated July 20, 2026
- huangwb8/chineseresearchlatex
Semantically verify that in-text citations in a LaTeX review match the cited literature, minimally rewriting only sentences with fatal citation errors and rendering to PDF/Word.
About
Checks whether the body citations in a topic review .tex file actually match the cited papers, using the host AI's semantic understanding. A developer uses it to correct only fatal citation mismatches with minimal edits and re-render the review to PDF or Word.
- Semantic per-citation verification against source literature
- Minimal rewrites only for fatal errors, preserving unclear cases
Research Citation Check by the numbers
- 25 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #965 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 research-citation-checkAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 25 |
|---|---|
| repo stars | ★ 2.6k |
| Last updated | July 20, 2026 |
| Repository | huangwb8/chineseresearchlatex ↗ |
What it does
Semantically verify that in-text citations in a LaTeX review match the cited literature, minimally rewriting only sentences with fatal citation errors and rendering to PDF/Word.
Files
Research Citation Check
与 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 仓库。
定位
- 用于检查已有
{主题}_review.tex的正文引用是否真的与对应论文内容一致。 - 只在确认存在致命错误时最小化改写“包含该引用的句子”。
- 渲染 PDF/Word 优先依赖
research-literature-review;准备结构化输入不依赖该 skill。 - 兼容旧名
check-review-alignment的 prompt 触发;.check-review-alignment/仍是稳定历史工作区名。
输入
work_dir:包含*_review.tex与.bib- 可选
--tex:指定 tex 文件名
输出
{work_dir}/.check-review-alignment/ai_alignment_report.md{work_dir}/.check-review-alignment/ai_alignment_input.json- 修改后的
{主题}_review.tex - 新生成的
{主题}_review.pdf - 新生成的
{主题}_review.docx
修改边界
- P0:必须修,允许最小改写或修正错误 bibkey
- P1:仅警告,不改写
- P2:完全跳过
- 禁止:
- 改写未包含引用的句子
- 整段重写
- 引入新 bibkey(除非修复错误 key)
- 伪造论文内容
工作流
1. 依赖检查
- 只有执行渲染时才强制检查
research-literature-review,过渡期可 fallback 到systematic-literature-review - 若只是
--prepare,不要求渲染依赖可用
2. 预检与定位
- 找到
*_review.tex与对应.bib - 缺任何核心文件时立即停止
3. 结构化上下文抽取
cd /path/to/research-citation-check
python3 scripts/run_ai_alignment.py --work-dir "/path/to/work_dir" --prepare- 生成
ai_alignment_input.json - 输入中至少包含:句子、bibkey、文献元信息、DOI/URL、PDF 摘要段或 BibTeX 摘要
4. AI 语义核查
- 证据优先级:PDF 摘要段 > BibTeX abstract/title > 仅从句子推断
- 每条引用都要判断是否为:
fake_citationwrong_citationcontradictory_citationweak_supportoverclaimstyle_issue- 无法确认时保持原样,并记录到 Warnings
5. 报告
- 报告至少包含:
- Summary
- 具体细节
- Critical Fixes (P0)
- Warnings (P1)
- Rendering Result
- 每条引用的细节至少包含:标题、DOI、原句、文献实际内容、合理性评估、问题级别
6. 渲染
cd /path/to/research-citation-check
python3 scripts/run_ai_alignment.py --work-dir "/path/to/work_dir" --render核心原则
- 不为了改而改
- 无法确认时不动
- 只改必要句子
- 保留所有 LaTeX 命令结构
参考与验证
- 配置见
config.yaml - 脚本入口:
scripts/run_ai_alignment.py - 渲染依赖:
research-literature-review(fallback:systematic-literature-review)
Research Citation Check 变更日志
本文档记录 research-citation-check/ 的重要变更。历史记录中的 check-review-alignment 为旧名。格式遵循 Keep a Changelog。
[Unreleased]
(暂无)
[1.1.0] - 2026-06-14
Changed
- Skill 正式名从
check-review-alignment迁移为research-citation-check。 SKILL.md与README.md保留旧名 prompt 兼容说明;系统级旧目录交由install-bensz-skills清理。- 渲染依赖优先使用
research-literature-review,过渡期 fallback 到旧名systematic-literature-review。 - 历史工作区
.check-review-alignment/保持不变,避免破坏已有输出和脚本契约。
[1.0.2] - 2026-01-24
Added(新增)
check-review-alignment/scripts/run_ai_alignment.py:ai_alignment_input.json的papers[*]补充doi/url字段,提升引用核查可追溯性check-review-alignment/scripts/run_ai_alignment.py:缺失 bibkey 汇总到warnings,并在stats.missing_in_bib_bibkeys输出计数check-review-alignment/scripts/run_ai_alignment.py:PDF 路径位于 work_dir 外时输出 warning(不强制阻断)check-review-alignment/scripts/runtime_utils.py:当 work_dir 内存在多个 tex 候选时输出 warning,引导用户使用--tex指定check-review-alignment/SKILL.md:报告格式新增"具体细节"强制章节,要求每条引用记录四要素(原文内容/文献实际/引用合理性评估/问题级别),提升报告可追溯性
Changed(变更)
check-review-alignment/config.yaml:版本号1.0.1 → 1.0.2check-review-alignment/scripts/run_ai_alignment.py:--prepare不再强制依赖systematic-literature-review(依赖检查仅在--render路径执行)check-review-alignment/scripts/run_ai_alignment.py:中间文件(ai_alignment_input.json)改保存在{work_dir}/.check-review-alignment/隐藏文件夹,避免污染综述项目根目录check-review-alignment/SKILL.md:依赖检查口径调整为“仅渲染路径强制”,并同步说明结构化输入字段check-review-alignment/SKILL.md:同步更新输出路径说明(ai_alignment_report.md和ai_alignment_input.json均保存在.check-review-alignment/目录)check-review-alignment/README.md:同步更新快速开始与 FAQ 口径(只修复 P0、P1 仅警告;--prepare不依赖渲染 skill)check-review-alignment/README.md:同步更新"输出文件"章节与 FAQ,明确中间文件保存位置check-review-alignment/README.md:在"快速开始"章节新增"使用前必读"子章节,强调版本控制的重要性,并提供 Git 和手动备份两种方案check-review-alignment/README.md:在"常见问题"部分新增"如果修改不满意,如何恢复到之前的版本?" FAQ,详细说明回滚方法与最佳实践check-review-alignment/scripts/run_ai_alignment.py:缺少 tex/bib 时输出友好错误并返回退出码(避免 traceback)
Fixed(修复)
check-review-alignment/scripts/paragraph_analyzer.py:剔除行内%注释并忽略 verbatim-like 代码块,避免误抽取 citationscheck-review-alignment/scripts/paragraph_analyzer.py:修复CitationInContext.sentence_start/sentence_end语义(改为句子边界)
[1.0.1] - 2026-01-24
Changed(变更)
check-review-alignment/config.yaml:新增致命性错误优先级分级(P0/P1/P2),并将ai.paragraph_optimization.enabled默认改为false,确保“不为了改而改”check-review-alignment/SKILL.md:补齐“修改边界与优先级”(P0 必修 / P1 仅警告 / P2 跳过)与报告结构要求(Critical Fixes / Warnings)check-review-alignment/README.md:同步更新设计哲学、错误优先级说明与 FAQ 口径check-review-alignment/scripts/runtime_utils.py:增强--tex参数校验(仅允许文件名,避免路径遍历),并补齐默认ai.input_limitscheck-review-alignment/scripts/paragraph_analyzer.py:修复缩写保护逻辑,避免断句前置替换把正则转义字符写回正文
Added(新增)
check-review-alignment/scripts/run_ai_alignment.py:在ai_alignment_input.json中打包policy(修改策略/优先级配置),方便宿主 AI 严格按边界执行check-review-alignment/scripts/run_ai_alignment.py:新增配置健壮性解析(对pdf.max_pages/ai.input_limits.*/ai.modification.max_edits_per_sentence做安全回退并记录 warnings)check-review-alignment/scripts/run_ai_alignment.py:在ai_alignment_input.json中打包skill_info(name/version/description/category),便于宿主 AI 追溯版本与口径
[1.0.0] - 2026-01-24
Changed(变更)
- 破坏性重构:删除静态规则模式,统一为“纯 AI 语义核查与最小化改写”工作流
check-review-alignment/SKILL.md:新增依赖声明与强制依赖检查步骤,收敛为单一 AI 工作流(不再导出 ai_tasks)check-review-alignment/config.yaml:版本号0.2.0 → 1.0.0;精简配置结构,移除similarity.*、ai.enabled、ai.mode、ai.thresholds等静态/混合策略配置,并新增ai.input_limits控制输入截断check-review-alignment/README.md:更新为纯 AI 模式使用口径
Removed(移除)
- 删除静态规则实现与混合检查器:
check-review-alignment/scripts/run_alignment.py、check-review-alignment/scripts/hybrid_checker.py - 删除“任务导出型 AI 模块”和 prompts:
check-review-alignment/scripts/ai_*.py、check-review-alignment/prompts/
Added(新增)
check-review-alignment/scripts/run_ai_alignment.py:重写为确定性辅助入口(依赖检查/解析抽取/可选渲染),并新增ai_alignment_input.json输出check-review-alignment/scripts/runtime_utils.py、check-review-alignment/scripts/bib_utils.py:抽离确定性通用逻辑,便于脚本复用与测试- 新增 A/B 轮轻量测试会话:
check-review-alignment/tests/v202601241048/、check-review-alignment/tests/B轮-v202601241048/,以及对应规划文档:check-review-alignment/plans/v202601241048.md、check-review-alignment/plans/B轮-v202601241048.md
[0.2.0] - 2026-01-24
Added(新增)
- 新增 A/B 轮测试会话:
check-review-alignment/tests/v202601240925/、check-review-alignment/tests/B轮-v202601240925/ - 新增规划文档:
check-review-alignment/plans/v202601240925.md、check-review-alignment/plans/B轮-v202601240925.md check-review-alignment/config.yaml:新增skill_info(版本号唯一来源)- 新增用户文档:
check-review-alignment/README.md
Changed(变更)
check-review-alignment/SKILL.md:收敛触发边界与关键词(3-5 个核心关键词),并将 AI 模式描述调整为“导出 AI 复核任务”口径
Fixed(修复)
check-review-alignment/scripts/run_ai_alignment.py:修复 AI 模式开启后构造器参数不匹配导致的崩溃check-review-alignment/scripts/run_ai_alignment.py:修复--auto-apply的破坏性行为(无修改时不写回;有修改时先备份再应用)check-review-alignment/scripts/run_alignment.py:报告头部补充tex文件名,提升可追溯性check-review-alignment/scripts/run_alignment.py:find_tex_and_bib()对 glob 结果排序,提升可复现性check-review-alignment/scripts/run_alignment.py:增强find_pdf_for_entry(),避免file字段因:解析导致 Windows/Zotero 路径截断
[0.1.0] - 2026-01-03
Added(新增)
- 初始化技能,实现静态规则对齐报告与自动渲染流程
# research-citation-check 配置(纯 AI 模式)
#
# 设计原则:
# - 删除静态规则/阈值判断:吻合度与改写由宿主 AI 完成
# - 脚本只做确定性工作:解析/抽取/渲染/文件写回
# 技能基本信息(版本号唯一真相来源)
skill_info:
name: research-citation-check
version: 1.1.0
description: "综述引用语义核查:只修复致命性引用错误(虚假/错误/矛盾引用),并可选渲染 PDF/Word;兼容旧名 check-review-alignment 的 prompt 触发"
author: "Bensz Conan"
category: "文档质量"
# === 引用命令配置 ===
citation_commands:
- "cite"
- "citep"
- "citet"
- "citealp"
- "citeauthor"
- "Cite"
- "Citet"
# === PDF 抽取配置(用于提供额外上下文)===
pdf:
enabled: true
max_pages: 2
# === 渲染配置(复用依赖 skill 的渲染策略)===
render:
use_skill: research-literature-review
fallback_skills:
- systematic-literature-review
overwrite: true
# === AI 修改策略(宿主 AI 执行,脚本不调用 LLM API)===
ai:
input_limits:
max_abstract_chars: 2000
max_pdf_excerpt_chars: 3000
modification:
auto_apply: false
preserve_citations: true
max_edits_per_sentence: 3
# 错误优先级分级(宿主 AI 使用;脚本只负责打包,不做语义判断)
#
# - P0(must_fix):致命性错误,必须修复
# - P1(warn_only):仅警告,不改写
# - P2(skip):跳过,不触碰
error_priority:
# P0:致命性错误(必须修复)
- type: "fake_citation"
action: "must_fix"
description: "引用的文献根本不存在(.bib 中缺失或 bibkey 错误)"
- type: "wrong_citation"
action: "must_fix"
description: "引用命令中的 bibkey 与文意不符(张冠李戴)"
- type: "contradictory_citation"
action: "must_fix"
description: "正文描述与论文内容矛盾(如论文说男性,文写女性)"
# P1:仅警告,不改写
- type: "weak_support"
action: "warn_only"
description: "论文只是提及该概念,未深入论证或给出结论"
- type: "overclaim"
action: "warn_only"
description: "论文确实做了此事,但文中的“首创/首次/核心”等定位过度"
# P2:禁止修改
- type: "style_issue"
action: "skip"
description: "文体/表达优化(语序、措辞、行文润色)"
# 非致命错误(P1/P2)的处理策略
# - skip: 仅在报告中按需记录(默认)
# - warn: 一律记录为 Warnings
# - report: 输出更完整的“仅报告”条目
non_fatal_handling: "skip"
paragraph_optimization:
enabled: false
after_all_citations: false
research-citation-check — 用户使用指南
本 README 面向使用者:如何触发并正确使用 research-citation-check skill。 执行指令与硬性规范在 SKILL.md;默认参数在 config.yaml。
旧名兼容
- 当前正式名:
research-citation-check - 旧名:
check-review-alignment - 兼容状态:旧名 prompt 暂时仍会触发本 skill;安装新版本时系统级旧目录会被清理,不再保留 wrapper 目录。
- 历史工作区:
{work_dir}/.check-review-alignment/保持不变,这是稳定产物目录,不等同于 skill 新名称。
快速开始
在运行本技能之前,强烈建议为您的综述文件做好版本控制:
- 本技能会直接修改
{主题}_review.tex文件 - AI 的改写基于语义判断,可能存在误判或风格偏差
- 一旦修改不满意,需要有恢复原始版本的能力
最推荐用法
请用 research-citation-check 核查/优化 xxx 目录中的综述引用。p0-p1问题均须修复。兼容旧版 research-literature-review 的结果
如果之前用了较旧版本的 research-literature-review(bib 文献里没有 abstract 字段),可以这么用:
对于 xxx 项目, 请:
- 基于 research-literature-review 的原则,先为 bib 文件里的参考文献条目补齐 abstract 字段
- 再跑 research-citation-check:只修复 P0,P1 问题。它会使用 research-literature-review 的本地历史记录进行提取,一般也不需要连网。
其他常见场景
场景 1:指定 tex 文件名
请用 research-citation-check 核查/优化 xxx.tex 的引用。场景 2:结合 research-literature-review 流程
我已经用 research-literature-review 生成了综述,现在想用 research-citation-check 核核查引用是否正确。场景 3:修复特定引用问题
我怀疑综述里有"幻觉引用"(引用的论文没做过这件事),请用 research-citation-check 全面检查并修复。场景 4:希望非常激进地修改tex
请用 research-citation-check 优化 xxx 项目里的综述。请暂时忽略最小化改动原则,我允许你对综述进行大幅修改(只要你觉得有必要)。说明:本 skill 的设计边界是“只修复致命性引用错误(P0)并最小化改动”,不适合用来做整段润色或结构性重写。
设计理念
research-citation-check 是一个AI 驱动的引用语义核查工具,解决综述写作中的核心问题:
核心问题:综述正文中可能出现"错配引用"或"幻觉引用"——即引用的论文并未真正做过文中所声称的事情。
解决方案:
- 脚本做确定性工作:解析 LaTeX、提取引用与文献元信息、渲染 PDF/Word
- AI 做语义判断:理解句子含义、核对文献内容、最小化改写错配句子
设计哲学:
- 不为了改而改:只修复致命性错误(虚假/错误/矛盾引用),不触碰文体问题
- 保留 LaTeX 命令完整性(
\cite{}、\ref{}、\label{}等) - 最小化改动原则:只改写必要的句子,不整段重写
- 不引入新 bibkey、不伪造论文内容
- 不动如山原则:无法确定是否为致命性错误时,保留原样并报告为警告
功能概述
| 特性 | 说明 |
|---|---|
| 语义一致性核查 | AI 逐条检查引用是否与文献内容吻合 |
| 最小化改写 | 仅改写错配/幻觉引用的句子,保留 LaTeX 结构 |
| 结构化上下文 | 自动提取 PDF 摘要、BibTeX 元信息(含 DOI/URL、缺失 bibkey 提示),供 AI 核查 |
| 自动渲染 | 复用 research-literature-review 渲染脚本生成 PDF/Word |
| 可追溯报告 | 生成 ai_alignment_report.md 记录每条改动 |
错误优先级
本技能将问题分为三个优先级:
| 优先级 | 类型 | 说明 | 处理方式 |
|---|---|---|---|
| P0 | 致命性错误 | 虚假引用、错误引用、矛盾引用 | 必须改写 |
| P1 | 次要问题 | 支撑弱、定位偏差 | 仅警告,不改写 |
| P2 | 禁止修改 | 文体问题、未引用句子 | 跳过,不触碰 |
核心原则:只改 P0,报告 P1,跳过 P2。
提示词示例
示例 1:基础核查(最简单)
你:请用 research-citation-check 核查我工作目录中的综述引用。
技能:将执行以下步骤:
1. (仅渲染时)检查 research-literature-review 依赖是否可用
2. 定位 `*_review.tex` 和对应 `.bib`
3. 生成结构化输入(`ai_alignment_input.json`)
4. AI 逐条核查引用并最小化改写
5. 渲染 PDF/Word示例 2:指定 tex 文件
你:请用 research-citation-check 核查 HER2_review.tex 的引用。
技能:将使用指定的 `HER2_review.tex` 而非默认的 `*_review.tex`。示例 3:结合生成流程
你:我刚用 research-literature-review 生成了综述,现在想核查引用。
技能:将复用已生成的 tex 和 bib 文件,执行引用核查与优化。配置选项
| 参数 | 默认值 | 说明 |
|---|---|---|
citation_commands | cite, citep, citet, ... | 识别的 LaTeX 引用命令 |
pdf.enabled | true | 是否抽取 PDF 文本提供额外上下文 |
pdf.max_pages | 2 | PDF 抽取页数上限 |
render.use_skill | research-literature-review | 渲染依赖的 skill 名称 |
render.overwrite | true | 是否覆盖已生成的 PDF/Word |
ai.input_limits.max_abstract_chars | 2000 | BibTeX abstract 截断上限 |
ai.input_limits.max_pdf_excerpt_chars | 3000 | PDF 摘要段截断上限 |
ai.modification.auto_apply | false | 是否自动应用修改(推荐 false,由 AI 决定) |
ai.modification.error_priority | (见 config) | 错误分级:P0 必修 / P1 仅警告 / P2 跳过 |
ai.modification.non_fatal_handling | skip | 非致命问题(P1/P2)的处理策略 |
ai.paragraph_optimization.enabled | false | 是否启用段落优化(不推荐;容易变成文体改写) |
在 research-citation-check/config.yaml 中修改这些参数。
输出文件
所有中间文件保存在 {work_dir}/.check-review-alignment/ 隐藏文件夹中,避免污染综述项目根目录。
| 文件 | 说明 |
|---|---|
.check-review-alignment/ai_alignment_report.md | 核查报告:包含 Summary / Critical Fixes (P0) / Warnings (P1) / Rendering Result |
.check-review-alignment/ai_alignment_input.json | 结构化输入(含 DOI/URL、缺失 bibkey 标记与 warning),便于 AI 快速核查 |
{主题}_review.tex | 已优化的 LaTeX 正文(保存在 work_dir 根目录,保留 LaTeX 结构) |
{主题}_review.pdf | 渲染生成的 PDF(保存在 work_dir 根目录) |
{主题}_review.docx | 渲染生成的 Word(保存在 work_dir 根目录) |
备选用法(脚本/硬编码流程)
步骤 1:生成结构化输入(推荐)
# 进入 skill 根目录(安装后通常是 ~/.codex/skills/research-citation-check 或 ~/.claude/skills/research-citation-check)
cd /path/to/research-citation-check
# 生成结构化输入(供 AI 快速核查)
python3 scripts/run_ai_alignment.py --work-dir "/path/to/your_review_dir" --prepare说明:此步骤在 {work_dir}/.check-review-alignment/ 目录下生成 ai_alignment_input.json,包含每条引用的文献元信息和 PDF 摘要段,便于宿主 AI 快速、可追溯地逐条核查。 补充:若目录内存在多个 *_review.tex,脚本会给出 warning 并提示使用 --tex 指定目标文件名。
步骤 2:AI 语义核查与改写
在 Claude/Codex 中触发本 skill,AI 将: 1. 读取 .check-review-alignment/ai_alignment_input.json 2. 逐条核查引用是否与文献内容吻合 3. 最小化改写错配/幻觉引用的句子 4. 写入 .check-review-alignment/ai_alignment_report.md 并更新 tex 文件
步骤 3:渲染 PDF/Word
# 渲染生成的 PDF 和 Word
python3 scripts/run_ai_alignment.py --work-dir "/path/to/your_review_dir" --render说明:此步骤复用 research-literature-review 的渲染脚本,不直接调用 LLM API。
常见问题
Q:research-citation-check 和 research-literature-review 有什么区别?
A:两者用途不同:
- research-literature-review:从零开始生成综述正文(检索、筛选、阅读、写作)
- research-citation-check:核查已有综述的引用是否正确,并最小化改写错配句子
Q:技能没有被触发怎么办?
A:尝试用更具体的描述,如:
- "核查/优化
{主题}_review.tex的引用" - "运行 research-citation-check"
- "检查综述的引用是否与文献内容吻合"
Q:AI 会改写我的整篇综述吗?
A:不会。AI 遵循最小化改动原则:
- 只修复致命性错误(P0):虚假引用、错误引用、矛盾引用
- 仅警告次要问题(P1):支撑弱、定位偏差
- 禁止触碰文体问题(P2):表达不够优雅、语序可调整
- 不动如山:无法确定时保留原样
- 最小改动:只改写必要句子,不改写相邻无关句或整个段落
- 保留 LaTeX 命令完整性(
\cite{}、\ref{}、\label{}等)
Q:为什么需要 research-literature-review 依赖?
A:仅当你需要渲染 PDF/Word(--render 或工作流步骤 5)时,research-citation-check 才会复用 research-literature-review 的渲染脚本并强制检查依赖。仅生成结构化输入(--prepare)不依赖该 skill。
Q:如何理解 .check-review-alignment/ai_alignment_report.md?
A:报告包含三部分:
- Summary:段落数、引用数、P0 修改数、P1 警告数、P2 跳过数等统计
- Critical Fixes (P0):必须修复的致命性错误(原句/原因/新句/行号/优先级)
- Warnings (P1):仅警告的问题(原句/原因/建议/行号/优先级)
- Rendering Result:PDF/Word 路径或错误摘要
Q:PDF 文件太多会影响性能吗?
A:会。如果工作目录有大量 PDF,建议:
- 移动不相关的 PDF 到其他目录
- 或在
config.yaml中设置pdf.enabled: false禁用 PDF 抽取
Q:修改策略可以自定义吗?
A:可以。在 config.yaml 的 ai.modification 部分:
auto_apply: false:由 AI 决定是否应用修改(推荐)preserve_citations: true:保留 LaTeX 引用命令max_edits_per_sentence: 3:每句最多修改次数
Q:如果修改不满意,如何恢复到之前的版本?
A:取决于您使用的版本控制方式:
Git 用户(推荐):
# 查看修改历史
git diff {主题}_review.tex
# 恢复到上一个提交
git checkout HEAD -- {主题}_review.tex
# 或恢复到特定提交
git checkout <commit-hash> -- {主题}_review.tex手动备份用户:
# 恢复备份文件
cp {主题}_review.tex.backup {主题}_review.tex最佳实践:
- 每次运行本技能前都创建新的 Git commit
- 使用描述性的 commit 信息,如 "backup: 运行 research-citation-check 前的版本"
- 保留多个历史版本,方便对比和回滚
更多文档
SKILL.md— 技能执行指令与硬性规范config.yaml— 可配置参数与版本号research-literature-review— 依赖技能(用于渲染)
---
版本信息:见 research-citation-check/config.yaml:skill_info.version(唯一来源)。
#!/usr/bin/env python3
"""
bib_utils.py - BibTeX 解析与(可选)PDF 摘要段抽取
注意:
- 仅做确定性抽取;抽取失败应降级而非中断
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Dict, List, Optional
def parse_bib_file(bib_path: Path) -> Dict[str, dict]:
"""解析 BibTeX 为 dict[bibkey -> fields(lowercase)]."""
text = bib_path.read_text(encoding="utf-8", errors="ignore")
entries: Dict[str, dict] = {}
try:
import bibtexparser # type: ignore
bib_db = bibtexparser.loads(text)
for e in bib_db.entries:
key = e.get("ID") or e.get("id")
if not key:
continue
entries[key] = {k.lower(): v for k, v in e.items()}
if entries:
return entries
except Exception:
# fallback to manual parsing
pass
raw_entries = re.split(r"@\w+\s*\{", text)
for chunk in raw_entries[1:]:
if "}" not in chunk:
continue
key_part, rest = chunk.split(",", 1)
key = key_part.strip()
fields: Dict[str, str] = {}
for field_match in re.finditer(r"(\w+)\s*=\s*[{\"]([^}\"]+)", rest):
fields[field_match.group(1).lower()] = field_match.group(2).strip()
entries[key] = fields
return entries
def find_pdf_for_entry(entry: dict, base_dir: Path) -> Optional[Path]:
"""从 bib entry 的 file/pdf/url 字段里尽力解析出本地 PDF 路径。"""
file_field = entry.get("file") or entry.get("pdf") or entry.get("url")
if not file_field:
return None
raw = str(file_field)
# 常见 BibTeX `file` 字段格式:
# - "/abs/path/to/paper.pdf"
# - "relative/paper.pdf"
# - "/abs/path/paper.pdf:PDF" (Zotero)
# - "C:\\path\\paper.pdf:PDF" (Windows)
# - 多附件用 ';' 分隔
parts = [p.strip() for p in raw.split(";") if p.strip()]
for part in parts or [raw.strip()]:
m = re.search(r"(?P<path>(?:[A-Za-z]:)?[^;]*?\.pdf)", part, re.IGNORECASE)
candidate = (m.group("path") if m else part).strip().strip("{}").strip()
candidate = candidate.lstrip(":") # Zotero 有时会多一个 ':'
if not candidate:
continue
p = Path(candidate)
if not p.is_absolute():
p = base_dir / p
if p.exists() and p.suffix.lower() == ".pdf":
return p
return None
def extract_pdf_text(pdf_path: Path, max_pages: int, warnings: List[str]) -> str:
"""抽取 PDF 前 max_pages 页文本;失败则返回空串并写 warnings。"""
if not pdf_path.exists():
return ""
try:
import pdfplumber # type: ignore
text_parts: List[str] = []
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages[:max_pages]:
text_parts.append(page.extract_text() or "")
return "\n".join(text_parts).strip()
except Exception as e:
warnings.append(f"pdfplumber 不可用或解析失败,尝试 PyPDF2。原因: {e}")
try:
from PyPDF2 import PdfReader # type: ignore
reader = PdfReader(str(pdf_path))
texts: List[str] = []
for idx, page in enumerate(reader.pages):
if idx >= max_pages:
break
try:
texts.append(page.extract_text() or "")
except Exception as inner:
warnings.append(f"PyPDF2 解析第 {idx+1} 页失败: {inner}")
return "\n".join(texts).strip()
except Exception as e:
warnings.append(f"PyPDF2 不可用或解析失败: {e}")
return ""
#!/usr/bin/env python3
"""
paragraph_analyzer.py - LaTeX 段落分析器
核心功能:
1. 解析 LaTeX 文档,提取段落结构
2. 定位每个引用所在的句子
3. 建立引用 → 句子 → 段落的映射关系
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple
@dataclass
class CitationInContext:
"""引用及其上下文信息"""
bibkey: str
cite_command: str # 完整的 \cite{...} 命令
sentence: str # 包含该引用的完整句子
sentence_start: int # 句子在段落中的字符位置
sentence_end: int
line_number: int
paragraph_index: int # 所属段落索引
citation_index_in_para: int # 在段落中的第几个引用
citation_index_global: int # 全局第几个引用
@dataclass
class Paragraph:
"""LaTeX 段落"""
index: int
raw_text: str
start_line: int # 1-based, inclusive
end_line: int # 1-based, inclusive
sentences: List[str] = field(default_factory=list)
citations: List[CitationInContext] = field(default_factory=list)
@property
def has_citations(self) -> bool:
return len(self.citations) > 0
@property
def citation_count(self) -> int:
return len(self.citations)
@dataclass
class DocumentStructure:
"""LaTeX 文档结构"""
paragraphs: List[Paragraph] = field(default_factory=list)
citations: List[CitationInContext] = field(default_factory=list)
bibkey_to_citations: Dict[str, List[CitationInContext]] = field(default_factory=dict)
@property
def total_citations(self) -> int:
return len(self.citations)
@property
def total_paragraphs(self) -> int:
return len(self.paragraphs)
@property
def paragraphs_with_citations(self) -> List[Paragraph]:
return [p for p in self.paragraphs if p.has_citations]
def parse_latex_document(
tex_path: Path,
citation_commands: Optional[List[str]] = None
) -> DocumentStructure:
"""
解析 LaTeX 文档,提取段落和引用结构
Args:
tex_path: LaTeX 文件路径
citation_commands: 要识别的引用命令列表
Returns:
DocumentStructure: 文档结构对象
"""
if citation_commands is None:
citation_commands = ["cite", "citep", "citet", "citealp",
"citeauthor", "Cite", "Citet"]
text = tex_path.read_text(encoding="utf-8", errors="ignore")
lines = _sanitize_lines_for_analysis(text.splitlines())
# 提取文档主体(跳过导言区)
body_start = _find_document_body(lines) # 0-based line index where body starts
body_lines = lines[body_start:] if body_start > 0 else lines
# 提取段落(body_lines 已经切片过:用 1-based 行号偏移恢复到原文件行号)
paragraphs = _extract_paragraphs(body_lines, body_start + 1)
# 构建引用正则
cite_pattern = _build_citation_pattern(citation_commands)
# 分析每个段落的引用
all_citations: List[CitationInContext] = []
bibkey_map: Dict[str, List[CitationInContext]] = {}
global_cite_idx = 0
for para in paragraphs:
# 在段落中查找所有引用
para_citations = _extract_citations_from_paragraph(para, cite_pattern, global_cite_idx)
para.citations = para_citations
all_citations.extend(para_citations)
global_cite_idx += len(para_citations)
# 更新 bibkey 映射
for citation in para_citations:
bibkey_map.setdefault(citation.bibkey, []).append(citation)
return DocumentStructure(
paragraphs=paragraphs,
citations=all_citations,
bibkey_to_citations=bibkey_map
)
def _find_document_body(lines: List[str]) -> int:
"""查找 \\begin{document} 的位置"""
for i, line in enumerate(lines):
if r"\begin{document}" in line:
return i + 1
return 0
def _sanitize_lines_for_analysis(lines: List[str]) -> List[str]:
"""
为引用抽取做预处理:
- 剔除未转义的 % 注释(避免把注释里的 \\cite{} 当作真实引用)
- 将 verbatim-like 环境内容置空(避免从代码块里误抽取引用)
注意:这里保持行数不变,以确保行号映射仍然可靠。
"""
out: List[str] = []
in_verbatim = False
for line in lines:
stripped = line.strip()
# 粗粒度识别 verbatim-like 环境(覆盖常见代码块环境)
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 _strip_latex_comment(line: str) -> str:
"""
剔除 LaTeX 行内注释:未被转义的 '%' 之后的内容都视为注释。
规则:
- '\\%' 表示字面百分号,不开启注释
- '...\\\\%' 里 '%' 仍然是注释(连续反斜杠数为偶数时不算转义)
"""
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
def _extract_paragraphs(lines: List[str], offset_line_1based: int) -> List[Paragraph]:
"""
从 LaTeX 行列表中提取段落
注意:传入的 lines 应该已经是 \\begin{document} 之后的内容
规则:
- 空行分隔段落
- 跳过注释行(以 % 开头)
- 跳过纯命令行(如 \\section{...})
- 合并连续的非空行为段落
"""
paragraphs: List[Paragraph] = []
current_para_lines: List[str] = []
para_start_line = offset_line_1based
para_idx = 0
for i, line in enumerate(lines):
# 遇到 \end{document} 停止
if r"\end{document}" in line:
if current_para_lines:
para_text = "\n".join(current_para_lines)
if para_text.strip():
paragraphs.append(Paragraph(
index=para_idx,
raw_text=para_text,
start_line=para_start_line,
end_line=offset_line_1based + (i - 1),
sentences=_split_sentences(para_text)
))
para_idx += 1
current_para_lines = []
break
stripped = line.strip()
# 跳过空行和注释
if not stripped or stripped.startswith("%"):
if current_para_lines:
para_text = "\n".join(current_para_lines)
if para_text.strip():
paragraphs.append(Paragraph(
index=para_idx,
raw_text=para_text,
start_line=para_start_line,
end_line=offset_line_1based + (i - 1),
sentences=_split_sentences(para_text)
))
para_idx += 1
current_para_lines = []
continue
# 跳过纯命令
if re.match(r"\\(section|subsection|subsubsection|chapter|part|maketitle|setcounter)\s*\{?", stripped):
if current_para_lines:
para_text = "\n".join(current_para_lines)
if para_text.strip():
paragraphs.append(Paragraph(
index=para_idx,
raw_text=para_text,
start_line=para_start_line,
end_line=offset_line_1based + (i - 1),
sentences=_split_sentences(para_text)
))
para_idx += 1
current_para_lines = []
continue
# 保留内容
if not current_para_lines:
para_start_line = offset_line_1based + i
current_para_lines.append(line)
# 最后一个段落
if current_para_lines:
para_text = "\n".join(current_para_lines)
if para_text.strip():
paragraphs.append(Paragraph(
index=para_idx,
raw_text=para_text,
start_line=para_start_line,
end_line=offset_line_1based + (len(lines) - 1),
sentences=_split_sentences(para_text)
))
return paragraphs
def _split_sentences(text: str) -> List[str]:
"""
将文本分割为句子
简单规则:
- 按句号、问号、感叹号分割
- 保留 LaTeX 命令完整性
- 过滤空句子
"""
# 保护常见缩写(避免被断句切开),例如 "i.e.", "e.g.", "etc."
#
# 注意:这里必须使用“可还原的 token”,避免把正则转义字符写回正文导致污染。
abbrev_rules = [
(r"\betc\.", "etc."),
(r"\bi\.e\.", "i.e."),
(r"\be\.g\.", "e.g."),
(r"\bFig\.", "Fig."),
(r"\bFigs\.", "Figs."),
(r"\bvs\.", "vs."),
(r"\bapprox\.", "approx."),
(r"\bNo\.", "No."),
]
token_map: Dict[str, str] = {}
for idx, (pat, literal) in enumerate(abbrev_rules):
token = f"__ABBR{idx}__"
token_map[token] = literal
text = re.sub(pat, token, text, flags=re.IGNORECASE)
# 按标点分割
sentences = re.split(r'(?<=[.!?])\s+(?=[A-Z]|\\[\w]|[\u4e00-\u9fff])', text)
# 恢复缩写
for i, sent in enumerate(sentences):
for token, literal in token_map.items():
sent = sent.replace(token, literal)
sentences[i] = sent
# 过滤并清理
result = [s.strip() for s in sentences if s.strip()]
return result
def _build_citation_pattern(commands: List[str]) -> re.Pattern:
"""
构建识别引用命令的正则表达式
支持:
- \cite{key}
- \cite{key1,key2}
- \cite[p.1]{key}
- \cite*{key}
- 多行引用
"""
cmd_pattern = "|".join(re.escape(cmd) for cmd in commands)
# 修正:避免重复的量词
# 允许多个可选参数:\cite[see][p.3]{key}
pattern = rf"\\(?:{cmd_pattern})\*?\s*(?:\[[^\]]*\]\s*)*\{{[^}}]*?\}}"
return re.compile(pattern, re.MULTILINE | re.DOTALL)
def _extract_citations_from_paragraph(
paragraph: Paragraph,
cite_pattern: re.Pattern,
global_start_idx: int
) -> List[CitationInContext]:
"""从段落中提取所有引用及其上下文"""
citations: List[CitationInContext] = []
for match in cite_pattern.finditer(paragraph.raw_text):
cite_cmd = match.group(0)
# 计算引用所在行号(1-based)
# paragraph.start_line 已是文件内的 1-based 行号。
rel_line_offset = paragraph.raw_text[:match.start()].count("\n")
line_no = paragraph.start_line + rel_line_offset
# 提取 bibkey(可能有多个)
keys_match = re.search(r'\{([^}]*)\}', cite_cmd)
if not keys_match:
continue
keys_str = keys_match.group(1)
keys = [k.strip() for k in keys_str.split(",") if k.strip()]
# 提取引用所在句子(及其边界)
sentence, sent_start, sent_end = _extract_sentence_containing_citation(
paragraph.raw_text, match.start(), match.end()
)
for key in keys:
citation = CitationInContext(
bibkey=key,
cite_command=cite_cmd,
sentence=sentence,
sentence_start=sent_start,
sentence_end=sent_end,
line_number=line_no,
paragraph_index=paragraph.index,
citation_index_in_para=len(citations),
citation_index_global=global_start_idx + len(citations),
)
citations.append(citation)
return citations
def _extract_sentence_containing_citation(
text: str,
cite_start: int,
cite_end: int
) -> Tuple[str, int, int]:
"""
提取包含引用的完整句子
策略:
- 向前查找句子开始(句号、问号、感叹号或段落开头)
- 向后查找句子结束(句号、问号、感叹号或段落结尾)
- 保护 LaTeX 命令完整性
"""
# 为了避免把 "i.e." / "e.g." / "etc." 里的 '.' 当作断句标点,
# 在边界扫描时先对常见缩写做等长掩码(不改变字符串长度,保证索引可复用)。
scan_text = text
scan_text = re.sub(r"\betc\.", "etc_", scan_text, flags=re.IGNORECASE)
scan_text = re.sub(r"\bi\.e\.", "i_e_", scan_text, flags=re.IGNORECASE)
scan_text = re.sub(r"\be\.g\.", "e_g_", scan_text, flags=re.IGNORECASE)
scan_text = re.sub(r"\bFig\.", "Fig_", scan_text, flags=re.IGNORECASE)
scan_text = re.sub(r"\bFigs\.", "Figs_", scan_text, flags=re.IGNORECASE)
scan_text = re.sub(r"\bvs\.", "vs_", scan_text, flags=re.IGNORECASE)
scan_text = re.sub(r"\bapprox\.", "approx_", scan_text, flags=re.IGNORECASE)
scan_text = re.sub(r"\bNo\.", "No_", scan_text, flags=re.IGNORECASE)
# 向前查找
sentence_start = 0
for i in range(cite_start - 1, -1, -1):
if scan_text[i] in ".!?。?!":
sentence_start = i + 1
break
# 向后查找
sentence_end = len(text)
for i in range(cite_end, len(text)):
if scan_text[i] in ".!?。?!":
sentence_end = i + 1
break
sentence = text[sentence_start:sentence_end].strip()
# 合并多余空格
sentence = " ".join(sentence.split())
return sentence, sentence_start, sentence_end
def get_paragraph_by_index(
structure: DocumentStructure,
index: int
) -> Optional[Paragraph]:
"""按索引获取段落"""
if 0 <= index < len(structure.paragraphs):
return structure.paragraphs[index]
return None
def get_citations_by_bibkey(
structure: DocumentStructure,
bibkey: str
) -> List[CitationInContext]:
"""按 bibkey 获取所有引用"""
return structure.bibkey_to_citations.get(bibkey, [])
def format_paragraph_summary(paragraph: Paragraph) -> str:
"""格式化段落摘要(用于调试)"""
lines = [
f"=== 段落 #{paragraph.index} ===",
f"行范围: {paragraph.start_line}-{paragraph.end_line}",
f"引用数: {paragraph.citation_count}",
]
if paragraph.citations:
lines.append("引用列表:")
for cite in paragraph.citations:
lines.append(f" - [{cite.bibkey}] L{cite.line_number}: {cite.sentence[:80]}...")
else:
lines.append("(无引用)")
return "\n".join(lines)
# 导出
__all__ = [
"parse_latex_document",
"DocumentStructure",
"Paragraph",
"CitationInContext",
"get_paragraph_by_index",
"get_citations_by_bibkey",
"format_paragraph_summary",
]
#!/usr/bin/env python3
"""
run_ai_alignment.py - research-citation-check 的确定性入口(纯 AI 模式)
脚本职责(确定性):
- 依赖检查(research-literature-review)
- 定位 tex/bib
- 解析段落与引用,抽取 bib 元信息与(可选)PDF 摘要段
- 可选:渲染 PDF/Word(复用依赖 skill 的脚本)
非职责(启发式/AI):
- 判断“引用是否合理”、生成改写句子与段落
- 写 ai_alignment_report.md 的“问题原因/优化版本”等语义内容
这些由宿主 AI(Claude/Codex)在执行本 skill 时完成。
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Optional
from bib_utils import extract_pdf_text, find_pdf_for_entry, parse_bib_file
from paragraph_analyzer import parse_latex_document
from runtime_utils import find_tex_and_bib, load_config, python_executable
def _truncate(text: str, limit: int) -> str:
if not text:
return ""
t = " ".join(str(text).split())
if len(t) <= limit:
return t
return t[:limit].rstrip() + " …(truncated)"
def _safe_int(value: Any, default: int, warnings: List[str], name: str) -> int:
try:
return int(value)
except Exception:
warnings.append(f"invalid int for {name}: {value!r} (fallback to {default})")
return default
def build_ai_input(
work_dir: Path,
tex_path: Path,
bib_path: Path,
config: dict,
) -> Dict[str, Any]:
"""生成供宿主 AI 使用的结构化输入(ai_alignment_input.json)。"""
warnings: List[str] = []
ai_cfg = config.get("ai", {}) or {}
limits = ai_cfg.get("input_limits", {}) or {}
max_abstract_chars = _safe_int(limits.get("max_abstract_chars", 2000), 2000, warnings, "ai.input_limits.max_abstract_chars")
max_pdf_excerpt_chars = _safe_int(limits.get("max_pdf_excerpt_chars", 3000), 3000, warnings, "ai.input_limits.max_pdf_excerpt_chars")
# 将“修改策略”打包进输入,确保宿主 AI 在只读取 ai_alignment_input.json 时也能
# 严格遵循“只修复致命性错误,不为了改而改”的边界(脚本不做任何语义判断)。
mod_cfg = ai_cfg.get("modification", {}) or {}
para_cfg = ai_cfg.get("paragraph_optimization", {}) or {}
policy: Dict[str, Any] = {
"modification": {
"auto_apply": bool(mod_cfg.get("auto_apply", False)),
"preserve_citations": bool(mod_cfg.get("preserve_citations", True)),
"max_edits_per_sentence": _safe_int(mod_cfg.get("max_edits_per_sentence", 3), 3, warnings, "ai.modification.max_edits_per_sentence"),
"error_priority": list(mod_cfg.get("error_priority", []) or []),
"non_fatal_handling": str(mod_cfg.get("non_fatal_handling", "skip")),
},
"paragraph_optimization": {
"enabled": bool(para_cfg.get("enabled", False)),
"after_all_citations": bool(para_cfg.get("after_all_citations", False)),
},
}
doc = parse_latex_document(
tex_path,
citation_commands=config.get("citation_commands", []),
)
bib_entries = parse_bib_file(bib_path)
raw_skill_info = (config.get("skill_info", {}) or {}) if isinstance(config, dict) else {}
skill_info: Dict[str, Any] = {}
for k in ("name", "version", "description", "category"):
if k in raw_skill_info:
skill_info[k] = raw_skill_info.get(k)
cited_keys = sorted({c.bibkey for c in doc.citations})
pdf_cfg = config.get("pdf", {}) or {}
pdf_enabled = bool(pdf_cfg.get("enabled", True))
max_pages = _safe_int(pdf_cfg.get("max_pages", 2), 2, warnings, "pdf.max_pages")
papers: Dict[str, Dict[str, Any]] = {}
missing_in_bib_keys: List[str] = []
for key in cited_keys:
entry = bib_entries.get(key)
if not entry:
papers[key] = {"bibkey": key, "missing_in_bib": True}
missing_in_bib_keys.append(key)
continue
paper: Dict[str, Any] = {
"bibkey": key,
"missing_in_bib": False,
"title": entry.get("title", ""),
"author": entry.get("author", ""),
"year": entry.get("year", ""),
"journal": entry.get("journal", "") or entry.get("booktitle", ""),
"doi": entry.get("doi", "") or "",
"url": entry.get("url", "") or "",
"abstract": _truncate(entry.get("abstract", ""), max_abstract_chars),
}
if pdf_enabled:
pdf_path = find_pdf_for_entry(entry, base_dir=work_dir)
if pdf_path is not None:
try:
resolved_pdf = pdf_path.resolve()
resolved_work = work_dir.resolve()
# 如果 PDF 路径不在 work_dir 内,给出 warning(但不强制禁止,避免破坏用户工作流)
try:
resolved_pdf.relative_to(resolved_work)
except Exception:
warnings.append(f"PDF 路径不在 work_dir 内(仍会尝试读取):{resolved_pdf}")
except Exception:
# resolve 失败不影响主流程
pass
excerpt = extract_pdf_text(pdf_path, max_pages=max_pages, warnings=warnings)
if excerpt:
paper["pdf_path"] = str(pdf_path)
paper["pdf_excerpt"] = _truncate(excerpt, max_pdf_excerpt_chars)
papers[key] = paper
paragraphs: List[Dict[str, Any]] = []
for p in doc.paragraphs_with_citations:
paragraphs.append(
{
"index": p.index,
"start_line": p.start_line,
"end_line": p.end_line,
"citation_count": p.citation_count,
}
)
citations: List[Dict[str, Any]] = []
for c in doc.citations:
citations.append(
{
"bibkey": c.bibkey,
"line_number": c.line_number,
"paragraph_index": c.paragraph_index,
"citation_index_in_paragraph": c.citation_index_in_para,
"citation_index_global": c.citation_index_global,
"sentence": c.sentence,
"cite_command": c.cite_command,
}
)
citations.sort(
key=lambda x: (
int(x.get("line_number", 0)),
int(x.get("paragraph_index", 0)),
int(x.get("citation_index_in_paragraph", 0)),
str(x.get("bibkey", "")),
)
)
if missing_in_bib_keys:
warnings.append("以下 bibkey 在 .bib 中缺失(已标记 missing_in_bib=true): " + ", ".join(missing_in_bib_keys))
return {
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"skill_info": skill_info,
"work_dir": str(work_dir),
"tex": tex_path.name,
"bib": bib_path.name,
"policy": policy,
"stats": {
"total_paragraphs": doc.total_paragraphs,
"paragraphs_with_citations": len(doc.paragraphs_with_citations),
"total_citations": doc.total_citations,
"unique_cited_bibkeys": len(cited_keys),
"missing_in_bib_bibkeys": len(missing_in_bib_keys),
},
"warnings": warnings,
"paragraphs": paragraphs,
"citations": citations,
"papers": papers,
}
def render_outputs(tex_path: Path, bib_path: Path, config: dict) -> Dict[str, Any]:
"""渲染 PDF/Word(复用依赖 skill 的渲染脚本)。"""
render_cfg = config.get("render", {}) or {}
overwrite = bool(render_cfg.get("overwrite", True))
dep_skill = str(render_cfg.get("use_skill", "research-literature-review"))
fallback_skills = render_cfg.get("fallback_skills", ["systematic-literature-review"]) or []
fallback_skill_names = [str(item) for item in fallback_skills]
# 仅在渲染路径下检查依赖(prepare 不应被渲染依赖阻塞)
from runtime_utils import resolve_dependency_skill, resolve_render_scripts
resolved_skill, dep_root = resolve_dependency_skill(dep_skill, fallback_skill_names, reason="PDF/Word 渲染")
compile_script, word_script = resolve_render_scripts(dep_root, dep_skill_name=resolved_skill)
pdf_path = tex_path.with_suffix(".pdf")
docx_path = tex_path.with_suffix(".docx")
log: List[str] = []
result: Dict[str, Any] = {"ok": True, "pdf": None, "word": None, "log": log}
if overwrite or not pdf_path.exists():
proc_pdf = subprocess.run(
[python_executable(), str(compile_script), str(tex_path), str(pdf_path)],
text=True,
capture_output=True,
cwd=tex_path.parent,
)
if proc_pdf.stdout:
log.append(proc_pdf.stdout)
if proc_pdf.returncode != 0:
if proc_pdf.stderr:
log.append(proc_pdf.stderr)
result["ok"] = False
return result
result["pdf"] = str(pdf_path)
else:
log.append("skip PDF render: overwrite=false and target exists")
result["pdf"] = str(pdf_path)
if overwrite or not docx_path.exists():
proc_docx = subprocess.run(
[python_executable(), str(word_script), str(tex_path), str(bib_path), str(docx_path)],
text=True,
capture_output=True,
cwd=tex_path.parent,
)
if proc_docx.stdout:
log.append(proc_docx.stdout)
if proc_docx.returncode != 0:
if proc_docx.stderr:
log.append(proc_docx.stderr)
result["ok"] = False
return result
result["word"] = str(docx_path)
else:
log.append("skip Word render: overwrite=false and target exists")
result["word"] = str(docx_path)
return result
def main() -> int:
parser = argparse.ArgumentParser(description="research-citation-check (AI mode) helper runner")
parser.add_argument("--work-dir", required=True, type=Path, help="综述工作目录(包含 tex/bib)")
parser.add_argument("--tex", type=str, default=None, help="可选:指定 tex 文件名")
parser.add_argument("--prepare", action="store_true", help="生成 ai_alignment_input.json")
parser.add_argument("--render", action="store_true", help="渲染 PDF/Word(不做任何 AI 判断)")
args = parser.parse_args()
work_dir = args.work_dir.resolve()
skill_root = Path(__file__).resolve().parents[1]
config, cfg_warnings = load_config(skill_root / "config.yaml")
for w in cfg_warnings:
# config 读取警告仅用于告知,不影响执行
print(f"⚠️ {w}")
do_prepare = bool(args.prepare)
do_render = bool(args.render)
if not do_prepare and not do_render:
do_prepare = True
selection_warnings: List[str] = []
try:
tex_path, bib_path = find_tex_and_bib(work_dir, args.tex, warnings=selection_warnings)
except FileNotFoundError as e:
print(f"❌ {e}")
if args.tex:
print(" 提示:--tex 只支持传入文件名;并确保该文件存在于 work_dir 根目录。")
return 2
for w in selection_warnings:
# 这里的 warning 主要用于可预期性(例如同目录多 tex 的默认选择)
print(f"⚠️ {w}")
if "默认选择第一个" in w:
print(" 提示:可用 --tex <file.tex> 指定目标 tex 文件名。")
# 创建中间文件目录(隐藏文件夹)
intermediate_dir = work_dir / ".check-review-alignment"
intermediate_dir.mkdir(exist_ok=True)
if do_prepare:
payload = build_ai_input(work_dir, tex_path, bib_path, config=config)
out_path = intermediate_dir / "ai_alignment_input.json"
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"✅ 已生成: {out_path}")
if do_render:
print("🖨️ 开始渲染 PDF/Word(复用依赖 skill 脚本)...")
result = render_outputs(tex_path, bib_path, config=config)
if not result.get("ok"):
print("❌ 渲染失败(请检查 log)")
return 2
print(f"✅ PDF: {result.get('pdf')}")
print(f"✅ Word: {result.get('word')}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""
runtime_utils.py - research-citation-check 的确定性运行时工具
原则:
- 不调用任何 LLM API
- 只做路径解析 / 配置加载 / 依赖定位 / 文件发现等确定性工作
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
def merge_dict(base: dict, override: dict) -> dict:
merged = dict(base)
for k, v in override.items():
if isinstance(v, dict) and isinstance(merged.get(k), dict):
merged[k] = merge_dict(merged[k], v)
else:
merged[k] = v
return merged
def load_config(config_path: Path) -> Tuple[dict, List[str]]:
"""读取 config.yaml;缺依赖时降级到默认值。"""
default_cfg: Dict[str, Any] = {
"citation_commands": ["cite", "citep", "citet", "citealp", "citeauthor", "Cite", "Citet"],
"pdf": {"enabled": True, "max_pages": 2},
"render": {
"use_skill": "research-literature-review",
"fallback_skills": ["systematic-literature-review"],
"overwrite": True,
},
"ai": {
"input_limits": {"max_abstract_chars": 2000, "max_pdf_excerpt_chars": 3000},
"modification": {
"auto_apply": False,
"preserve_citations": True,
"max_edits_per_sentence": 3,
# 默认策略:只修复致命性错误(P0),P1 仅警告,P2 跳过
"error_priority": [
{"type": "fake_citation", "action": "must_fix", "description": "missing in .bib or wrong bibkey"},
{"type": "wrong_citation", "action": "must_fix", "description": "bibkey does not match the claim"},
{"type": "contradictory_citation", "action": "must_fix", "description": "claim contradicts the paper"},
{"type": "weak_support", "action": "warn_only", "description": "paper only weakly supports the claim"},
{"type": "overclaim", "action": "warn_only", "description": "claim strength is overstated"},
{"type": "style_issue", "action": "skip", "description": "style / wording only"},
],
"non_fatal_handling": "skip",
},
# 默认禁用段落优化:该技能只做“致命性引用错误修复”,避免文体改写
"paragraph_optimization": {"enabled": False, "after_all_citations": False},
},
}
warnings: List[str] = []
if not config_path.exists():
warnings.append("config.yaml 不存在,使用默认配置")
return default_cfg, warnings
try:
import yaml # type: ignore
loaded = yaml.safe_load(config_path.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
return merge_dict(default_cfg, loaded), warnings
warnings.append("config.yaml 解析结果非 dict,使用默认配置")
except ImportError:
warnings.append("缺少 PyYAML,无法读取 config.yaml,使用默认配置")
except Exception as e:
warnings.append(f"读取 config.yaml 失败,使用默认配置: {e}")
return default_cfg, warnings
def find_tex_and_bib(
work_dir: Path, tex_arg: Optional[str], warnings: Optional[List[str]] = None
) -> Tuple[Path, Path]:
"""在 work_dir 内定位 review.tex 与对应的 .bib。"""
tex_path: Optional[Path] = None
bib_path: Optional[Path] = None
if tex_arg:
# 安全与可预期性:--tex 只允许文件名(不允许路径),避免路径遍历/误读其他目录文件。
raw = Path(tex_arg)
if raw.is_absolute() or raw.name != tex_arg:
raise SystemExit("❌ 无效参数:--tex 只允许传入 tex 文件名(不支持路径)")
name = raw.name
if not name.lower().endswith(".tex"):
if raw.suffix == "":
name = f"{name}.tex"
else:
raise SystemExit("❌ 无效参数:--tex 必须是 .tex 文件名(不支持路径)")
candidate = work_dir / name
if candidate.exists():
tex_path = candidate
else:
review_candidates = sorted(work_dir.glob("*_review.tex"))
if review_candidates:
if warnings is not None and len(review_candidates) > 1:
names = ", ".join(p.name for p in review_candidates)
warnings.append(f"work_dir 内存在多个 *_review.tex,默认选择第一个(按文件名排序):{names}")
tex_path = review_candidates[0]
else:
tex_candidates = sorted(work_dir.glob("*.tex"))
if tex_candidates:
if warnings is not None and len(tex_candidates) > 1:
names = ", ".join(p.name for p in tex_candidates)
warnings.append(f"work_dir 内存在多个 *.tex,默认选择第一个(按文件名排序):{names}")
tex_path = tex_candidates[0]
if tex_path:
# 常见命名:topic_review.tex -> topic_review.bib(同名)或 topic.bib(去掉 _review)
candidates: List[Path] = []
candidates.append(tex_path.with_suffix(".bib"))
stem = tex_path.stem
if stem.endswith("_review") and len(stem) > len("_review"):
candidates.append((work_dir / f"{stem[:-len('_review')]}.bib"))
for c in candidates:
if c.exists():
bib_path = c
break
if bib_path is None:
for p in sorted(work_dir.glob("*.bib")):
bib_path = p
break
if not tex_path or not bib_path:
missing: List[str] = []
if not tex_path:
missing.append("tex")
if not bib_path:
missing.append("bib")
raise FileNotFoundError(f"找不到所需文件: {', '.join(missing)} (work_dir={work_dir})")
return tex_path, bib_path
def resolve_skill_root(skill_name: str) -> Optional[Path]:
"""尝试定位某个已安装的 skill 根目录。"""
# 1) 显式指定(最强)
env_key = f"{skill_name.upper().replace('-', '_')}_SKILL_DIR"
if os.environ.get(env_key):
p = Path(os.environ[env_key]).expanduser()
if p.is_dir():
return p
# 2) 同仓库相邻目录(开发时)
here = Path(__file__).resolve()
skill_root = here.parents[1] # {skill}/scripts/ -> {skill}/
sibling = (skill_root.parent / skill_name).resolve()
if sibling.is_dir():
return sibling
# 3) 常见安装目录
home = Path.home()
candidates = [
home / ".codex" / "skills" / skill_name,
home / ".claude" / "skills" / skill_name,
]
codex_home = os.environ.get("CODEX_HOME")
if codex_home:
candidates.insert(0, Path(codex_home) / "skills" / skill_name)
for c in candidates:
if c.is_dir():
return c
return None
def require_dependency_skill(skill_name: str, reason: str) -> Path:
"""强制依赖检查:找不到则按约定提示并停止。"""
root = resolve_skill_root(skill_name)
if root is not None:
return root
# 计划要求的固定提示口径
raise SystemExit(
"❌ 缺少依赖:research-citation-check 依赖 "
f"{skill_name} skill 进行 {reason}。请先安装 {skill_name} skill。"
)
def resolve_dependency_skill(skill_name: str, fallback_skill_names: List[str], reason: str) -> Tuple[str, Path]:
"""优先解析新 skill 名,过渡期允许旧名 fallback。"""
for candidate in [skill_name, *fallback_skill_names]:
root = resolve_skill_root(candidate)
if root is not None:
return candidate, root
candidates = ", ".join([skill_name, *fallback_skill_names])
raise SystemExit(
"❌ 缺少依赖:research-citation-check 依赖 "
f"{skill_name} skill 进行 {reason}。过渡期也可使用旧名 fallback:{candidates}。"
)
def resolve_render_scripts(dep_skill_root: Path, dep_skill_name: str = "research-literature-review") -> Tuple[Path, Path]:
"""从依赖 skill 定位渲染脚本路径。"""
compile_script = dep_skill_root / "scripts" / "compile_latex_with_bibtex.py"
word_script = dep_skill_root / "scripts" / "convert_latex_to_word.py"
if not compile_script.exists() or not word_script.exists():
raise SystemExit(
"❌ 缺少依赖:research-citation-check 依赖 "
f"{dep_skill_name} skill 进行 PDF/Word 渲染。请先安装 {dep_skill_name} skill。"
)
return compile_script, word_script
def python_executable() -> str:
# 允许宿主用不同 python,但优先当前解释器
return sys.executable or "python3"