如何彻底解决AI杜撰编造假文献的问题

五层防线架构------源头锚定(Layer 1)是根本,其余四层是对检索结果的核验和兜底 。真正要落地,需要的是一套可直接部署的代码资产:citation_ledger.json 的 schema、citation_check.py 的完整可运行实现、CI 集成的 Git hook / GitHub Action,以及针对标准 / 政府文件 / 中文文献的降级核验方案。下面全部给出。

核心架构

关键设计原则:每一个参考文献条目(含其 DOI)都必须作为 JSON 对象存在 citation_ledger.json 里,而不是散落在 .tex / .md / 对话上下文中------台账是唯一可信源(Single Source of Truth),所有核验、diff、门禁都从台账出发,AI 只能往台账里"搬运"数据,不能直接往论文里"创作"。

五层防线的技术落地对照

层级 实现机制 落地物 拦截目标
Layer 1 源头锚定 强制"所有 DOI 必须来自 CrossRef / OpenAlex / Semantic Scholar / arXiv API 返回" citation_ledger.json(唯一 SSOT) 全部伪造、拼接、半真半假
Layer 2 逐条核验 解析 .bib/.tex 中的每条引用,调用 API 回读,diff 标题 / 第一作者姓氏 / 年份 citation_check.py 主逻辑 张冠李戴(如 Piccialli F vs Prezioso E)
Layer 3 证据链留痕 每次验证将原始 API 响应 + 时间戳 + 请求 URL 持久化到台账 ledger["entries"][n]["verification"]["response"] 事后改动不可追溯
Layer 4 机械门禁 任何一条 diff 失败 / DOI 解析 404 / 台账不连续 → 非零退出码 Git pre-commit / GitHub Action / CLI 杜撰条目流入投稿版
Layer 5 诚实降级 无法核验的(标准、老文献)→ 标记 unverifiable + [待人工核验],不编造 DOI ledger["entries"][n]["status"] = "unverifiable" 编造 DOI / 删除引用

1. citation_ledger.json Schema

json 复制代码
{
  "version": "1.0",
  "generated_at": "2026-09-06T18:30:00Z",
  "source_file": "references.bib",
  "entries": [
    {
      "ref_id": "[13]",
      "title": "A deep reinforcement learning based architecture for ...",
      "doi": "10.1016/j.aei.2021.101510",
      "arxiv_id": null,
      "type": "journal-article",
      "paper_metadata": {
        "authors_as_cited": "Li M, Li M, Ren Q, et al.",
        "year_as_cited": "2021",
        "venue_as_cited": "Adv Eng Inform"
      },
      "status": "verified",
      "verification": {
        "source": "crossref",
        "endpoint": "https://api.crossref.org/works/10.1016/j.aei.2021.101510",
        "queried_at": "2026-09-06T18:31:02Z",
        "http_status": 200,
        "response": {
          "DOI": "10.1016/j.aei.2021.101510",
          "title": ["..."],
          "author": [
            {"given": "Mingchao", "family": "Li", "sequence": "first"},
            {"given": "Minghao", "family": "Li"},
            {"given": "Qiubing", "family": "Ren"}
          ],
          "container-title": ["Advanced Engineering Informatics"],
          "volume": "50",
          "page": "101510",
          "published-print": {"date-parts": [[2021, 9, 1]]}
        }
      },
      "diff": {
        "title_match": true,
        "first_author_family_match": true,
        "year_match": true
      }
    },
    {
      "ref_id": "[16]",
      "title": "尾矿库安全监测技术规范",
      "type": "standard",
      "status": "unverifiable",
      "verification": {
        "source": "openstd.samr.gov.cn",
        "endpoint": "https://openstd.samr.gov.cn/bzgk/gb/newGbInfo?hcno=...",
        "queried_at": "2026-09-06T18:35:00Z",
        "http_status": 200,
        "response": {
          "实际名称": "尾矿库闭库设计编写提纲",
          "实际编号": "KA/T 20.6-2024",
          "备注": "论文中的名称与官方名称不一致"
        }
      },
      "notes": ["需要人工修正:名称应为《尾矿库闭库设计编写提纲》"]
    }
  ]
}

为什么这样设计verification.response 存的是原始 API 返回(未加工),任何第三方都能用同一 URL 重新调用验证------这是可审计 的核心。diff 字段是三个布尔值(标题模糊匹配 / 第一作者姓氏精确匹配 / 年份精确匹配),三者必须同时为 true 才算 verified

2. citation_check.py 完整可运行脚本

依赖:pip install requests(无其他依赖,便于投稿前在任何环境运行)。

python 复制代码
#!/usr/bin/env python3
"""
citation_check.py --- 机械门禁:AI 杜撰引用拦截器
用法:
    python citation_check.py references.bib                    # 检查单一 .bib
    python citation_check.py references.bib --json             # CI 输出
    python citation_check.py references.bib --update-ledger    # 首次生成台账
    python citation_check.py references.bib --cache cache.json # 带缓存
"""
import re
import sys
import json
import time
import urllib.parse
from pathlib import Path
from dataclasses import dataclass, field, asdict
from typing import Optional
from datetime import datetime, timezone
try:
    import requests
except ImportError:
    print("Error: pip install requests", file=sys.stderr)
    sys.exit(1)
# --------------------------------------------------------------------------
# 常量 / 配置
# --------------------------------------------------------------------------
HEADERS = {
    "User-Agent": "citation-check/1.0 (mailto:your-email@university.edu)"
}
CROSSREF_BASE = "https://api.crossref.org/works"
S2_BASE = "https://api.semanticscholar.org/graph/v1/paper"
OPENALEX_BASE = "https://api.openalex.org/works"
ARXIV_BASE = "https://export.arxiv.org/api/query"
DOI_ORG = "https://doi.org"
RATE_LIMITS = {
    "crossref": 1.0,       # 秒/请求(polite pool)
    "s2": 1.0,             # 无 API key 时 1 req/s
    "openalex": 0.1,       # 10 req/s
    "arxiv": 3.0,          # arXiv 要求 3 秒间隔
}
_last_request = {"crossref": 0, "s2": 0, "openalex": 0, "arxiv": 0}
def _respect_rate_limit(api: str):
    """强制遵守每个 API 的速率限制。"""
    elapsed = time.time() - _last_request[api]
    wait = RATE_LIMITS[api] - elapsed
    if wait > 0:
        time.sleep(wait)
    _last_request[api] = time.time()
# --------------------------------------------------------------------------
# 数据结构
# --------------------------------------------------------------------------
@dataclass
class BibEntry:
    key: str
    entry_type: str
    title: str
    authors: str
    year: str
    doi: Optional[str] = None
    arxiv_id: Optional[str] = None
    volume: Optional[str] = None
    journal: Optional[str] = None
    line_number: int = 0
    def as_dict(self):
        return asdict(self)
@dataclass
class VerifyResult:
    status: str  # "verified" / "mismatch" / "not_found" / "unverifiable"
    source: Optional[str] = None
    doi: Optional[str] = None
    api_title: Optional[str] = None
    api_first_author_family: Optional[str] = None
    api_year: Optional[str] = None
    raw_response: Optional[dict] = None
    endpoint: Optional[str] = None
    notes: list = field(default_factory=list)
# --------------------------------------------------------------------------
# BibTeX 解析(无外部依赖版;生产环境建议换 pybtex)
# --------------------------------------------------------------------------
ENTRY_RE = re.compile(r"@(\w+)\{([^,]+),", re.DOTALL)
FIELD_RE = re.compile(r"(\w+)\s*=\s*[{\"]([^}]+)[}\"]", re.DOTALL)
def parse_bib(path: Path) -> list:
    content = path.read_text(encoding="utf-8", errors="replace")
    entries = []
    for m in ENTRY_RE.finditer(content):
        etype, key = m.group(1).lower(), m.group(2).strip()
        if etype in ("comment", "string", "preamble"):
            continue
        # 找到该 entry 的完整 body(从 @ 开始到下一个 @ 或文件结尾)
        start = m.start()
        next_at = content.find("@", start + 1)
        body = content[start : next_at if next_at > 0 else len(content)]
        fields = dict(FIELD_RE.findall(body))
        line_no = content[:start].count("\n") + 1
        e = BibEntry(
            key=key,
            entry_type=etype,
            title=_clean(fields.get("title", "")),
            authors=_clean(fields.get("author", "")),
            year=_clean(fields.get("year", "")),
            doi=_clean(fields.get("doi", "")) or None,
            arxiv_id=_clean(fields.get("eprint", "")) or _clean(
                fields.get("arxivid", "")) or None,
            volume=_clean(fields.get("volume", "")) or None,
            journal=_clean(fields.get("journal", "")) or None,
            line_number=line_no,
        )
        if e.title:  # 无标题无法验证
            entries.append(e)
    return entries
def _clean(t: str) -> str:
    t = re.sub(r"\\[a-zA-Z]+\{([^}]*)\}", r"\1", t)
    t = re.sub(r"[{}]", "", t)
    return re.sub(r"\s+", " ", t).strip()
def extract_first_family(authors: str) -> Optional[str]:
    """从 BibTeX 'Family, Given and Family, Given' 格式提取第一作者姓氏。"""
    if not authors:
        return None
    first = re.split(r"\band\b", authors)[0].strip()
    if "," in first:
        return first.split(",")[0].strip().split()[-1].lower()
    return first.split()[-1].lower()
# --------------------------------------------------------------------------
# Layer 2: 核验------CrossRef / Semantic Scholar / OpenAlex / arXiv
# 每个函数返回 VerifyResult,含原始响应
# --------------------------------------------------------------------------
def verify_crossref(e: BibEntry) -> Optional[VerifyResult]:
    """CrossRef 是 DOI 注册机构,最权威。优先用 DOI 直接查询。"""
    _respect_rate_limit("crossref")
    doi_norm = e.doi.replace("https://doi.org/", "").replace("doi:", "").strip()
    if not doi_norm:
        # 无 DOI:用标题模糊检索
        q = urllib.parse.quote(e.title)
        url = f"{CROSSREF_BASE}?query.title={q}&rows=3&select=DOI,title,author,published-print,published-online"
        try:
            r = requests.get(url, headers=HEADERS, timeout=20)
            if r.status_code == 200:
                items = r.json().get("message", {}).get("items", [])
                for it in items:
                    if _title_sim(e.title, it.get("title", [""])[0]) > 0.65:
                        return _parse_crossref_item(it, url, r.status_code)
        except Exception:
            return None
        return None
    # 有 DOI:直接回读
    url = f"{CROSSREF_BASE}/{urllib.parse.quote(doi_norm, safe='')}"
    try:
        r = requests.get(url, headers=HEADERS, timeout=20)
        if r.status_code == 200:
            return _parse_crossref_item(r.json()["message"], url, r.status_code)
    except Exception:
        return None
    return None
def _parse_crossref_item(item: dict, url: str, http_code: int) -> VerifyResult:
    authors = item.get("author", [])
    first_family = authors[0].get("family", "").lower() if authors else None
    year = None
    for k in ("published-print", "published-online", "issued"):
        if k in item and item[k].get("date-parts"):
            year = str(item[k]["date-parts"][0][0])
            break
    return VerifyResult(
        status="verified",
        source="crossref",
        doi=item.get("DOI"),
        api_title=item.get("title", [""])[0] if item.get("title") else None,
        api_first_author_family=first_family,
        api_year=year,
        raw_response=item,
        endpoint=url,
    )
def verify_s2(e: BibEntry) -> Optional[VerifyResult]:
    """Semantic Scholar 作为二级验证源(覆盖 200M+ 论文,作者消歧更强)。"""
    _respect_rate_limit("s2")
    if e.doi:
        ident = f"DOI:{e.doi.replace('https://doi.org/', '').strip()}"
    elif e.arxiv_id:
        ident = f"arXiv:{e.arxiv_id}"
    else:
        return None
    url = f"{S2_BASE}/{urllib.parse.quote(ident, safe=':?')}?fields=title,authors,year,externalIds"
    try:
        r = requests.get(url, headers=HEADERS, timeout=20)
        if r.status_code == 200:
            data = r.json()
            first = data.get("authors", [{}])[0].get("name", "").split()[-1].lower()
            return VerifyResult(
                status="verified", source="s2",
                doi=data.get("externalIds", {}).get("DOI"),
                api_title=data.get("title"),
                api_first_author_family=first,
                api_year=str(data.get("year", "")),
                raw_response=data, endpoint=url,
            )
    except Exception:
        return None
    return None
def verify_openalex(e: BibEntry) -> Optional[VerifyResult]:
    """OpenAlex 第三级验证源(4.7 亿 works,合并去重)。"""
    _respect_rate_limit("openalex")
    if not e.doi:
        return None
    doi_url = f"https://doi.org/{e.doi.strip()}"
    url = f"{OPENALEX_BASE}/{urllib.parse.quote(doi_url, safe=':/.')}"
    try:
        r = requests.get(url, headers=HEADERS, timeout=20)
        if r.status_code == 200:
            d = r.json()
            first = None
            auths = d.get("authorships", [])
            if auths:
                first = auths[0]["author"]["display_name"].split()[-1].lower()
            return VerifyResult(
                status="verified", source="openalex",
                doi=d.get("doi", "").replace("https://doi.org/", ""),
                api_title=d.get("title"),
                api_first_author_family=first,
                api_year=str(d.get("publication_year", "")),
                raw_response={k: d[k] for k in
                              ("id","doi","title","publication_year","authorships","host_venue")},
                endpoint=url,
            )
    except Exception:
        return None
    return None
def verify_arxiv(e: BibEntry) -> Optional[VerifyResult]:
    """arXiv 预印本专用。"""
    if not e.arxiv_id:
        return None
    _respect_rate_limit("arxiv")
    url = f"{ARXIV_BASE}?id_list={e.arxiv_id.strip()}"
    try:
        r = requests.get(url, headers=HEADERS, timeout=20)
        if r.status_code == 200 and "<entry>" in r.text:
            title = re.search(r"<title>(.*?)</title>", r.text, re.DOTALL)
            authors = re.findall(r"<name>(.*?)</name>", r.text)
            year_m = re.search(r"<published>(\d{4})", r.text)
            doi_m = re.search(r'<arxiv:doi[^>]*>(10\.\d+/[^<]+)</arxiv:doi>', r.text)
            return VerifyResult(
                status="verified", source="arxiv",
                doi=doi_m.group(1) if doi_m else None,
                api_title=title.group(1).strip() if title else None,
                api_first_author_family=authors[0].split()[-1].lower() if authors else None,
                api_year=year_m.group(1) if year_m else None,
                raw_response={"xml_title": title.group(1) if title else None,
                              "xml_authors": authors,
                              "xml_year": year_m.group(1) if year_m else None},
                endpoint=url,
            )
    except Exception:
        return None
    return None
def _title_sim(a: str, b: str) -> float:
    """标题 token-overlap 相似度(低配版;生产可换 rapidfuzz)。"""
    if not a or not b:
        return 0.0
    stop = {"the", "a", "an", "of", "for", "in", "on", "to", "and", "with",
            "by", "from", "基于", "的", "与"}
    ta = {w for w in a.lower().split() if w not in stop}
    tb = {w for w in b.lower().split() if w not in stop}
    return len(ta & tb) / max(len(ta | tb), 1)
# --------------------------------------------------------------------------
# Layer 4: 机械门禁------主入口
# --------------------------------------------------------------------------
def check_file(bib_path: Path, ledger_path: Path, cache: dict) -> int:
    """返回 0=全部通过,1=有失败/不匹配,2=台账缺失。"""
    if not ledger_path.exists():
        print(f"[LEDGER] 台账不存在:{ledger_path} --- 先运行 --update-ledger 生成", file=sys.stderr)
        return 2
    ledger = json.loads(ledger_path.read_text(encoding="utf-8"))
    entries = parse_bib(bib_path)
    ledger_by_doi = {v.get("doi"): v for v in ledger.get("entries", []) if v.get("doi")}
    ledger_by_title = {v.get("title", "").lower(): v for v in ledger.get("entries", [])}
    failures, warnings = [], []
    print(f"检查 {len(entries)} 条参考文献,台账 {len(ledger.get('entries', []))} 条\n")
    print(f"{'ID':<10} {'Status':<12} {'Source':<10} {'Detail'}")
    print("-" * 70)
    for e in entries:
        cache_key = f"bib:{e.key}:{e.doi or e.title}"
        if cache_key in cache:
            vr = cache[cache_key]
        else:
            # 级联验证:CrossRef → S2 → OpenAlex → arXiv
            vr = verify_crossref(e) or verify_s2(e) or verify_openalex(e) or verify_arxiv(e)
            cache[cache_key] = vr.__dict__ if vr else {"status": "not_found"}
        entry_family = extract_first_family(e.authors)
        # 三重 diff:标题、第一作者姓氏、年份
        t_match = _title_sim(e.title, vr.api_title) > 0.60 if vr and vr.api_title else False
        a_match = (entry_family == vr.api_first_author_family) if (
            vr and entry_family and vr.api_first_author_family) else False
        y_match = (e.year == vr.api_year) if (vr and vr.api_year) else False
        # 台账一致性
        ledger_match = True
        if e.doi and e.doi in ledger_by_doi:
            l_entry = ledger_by_doi[e.doi]
            ledger_match = (l_entry.get("status") == "verified" and
                            l_entry.get("diff", {}).get("title_match", False))
        elif e.title.lower() in ledger_by_title:
            l_entry = ledger_by_title[e.title.lower()]
            ledger_match = l_entry.get("status") in ("verified", "unverifiable")
        if vr and vr.status == "verified" and t_match and a_match and y_match and ledger_match:
            print(f"{e.key:<10} {'✓ VERIFIED':<12} {vr.source:<10} DOI={vr.doi}")
        else:
            reasons = []
            if not vr:
                reasons.append("API 未找到 / 不可核验")
                status = "UNVERIFIABLE"
            else:
                status = "MISMATCH"
                if not t_match: reasons.append(f"标题不一致 (bib='{e.title[:30]}...' api='{(vr.api_title or '')[:30]}...')")
                if not a_match: reasons.append(f"作者不一致 (bib='{entry_family}' api='{vr.api_first_author_family}')")
                if not y_match: reasons.append(f"年份不一致 (bib='{e.year}' api='{vr.api_year}')")
                if not ledger_match: reasons.append("台账不一致或缺失")
            failures.append((e.key, status, "; ".join(reasons)))
            print(f"{e.key:<10} {f'✗ {status}':<12} {'-':<10} {'; '.join(reasons)}")
    print("\n" + "=" * 70)
    if failures:
        print(f"⛔ 拦截:{len(failures)} 条引用验证失败:")
        for k, s, r in failures:
            print(f"  [{k}] {s}: {r}")
        return 1
    print("✅ 全部通过------每条引用均通过 API 回读核验且与台账一致")
    return 0
# --------------------------------------------------------------------------
# Layer 1: 生成台账
# --------------------------------------------------------------------------
def generate_ledger(bib_path: Path, ledger_path: Path, cache: dict):
    entries = parse_bib(bib_path)
    ledger = {
        "version": "1.0",
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "source_file": str(bib_path),
        "entries": [],
    }
    print(f"为 {len(entries)} 条引用生成台账并调用 API 核验...\n")
    for i, e in enumerate(entries, 1):
        cache_key = f"bib:{e.key}:{e.doi or e.title}"
        vr = cache.get(cache_key)
        if not vr:
            vr_obj = verify_crossref(e) or verify_s2(e) or verify_openalex(e) or verify_arxiv(e)
            vr = vr_obj.__dict__ if vr_obj else None
            cache[cache_key] = vr
        entry_family = extract_first_family(e.authors)
        t_match = _title_sim(e.title, vr.get("api_title") or "") > 0.60 if vr else False
        a_match = (entry_family == vr.get("api_first_author_family")) if (
            vr and entry_family and vr.get("api_first_author_family")) else False
        y_match = (e.year == vr.get("api_year")) if (vr and vr.get("api_year")) else False
        ledger["entries"].append({
            "ref_id": f"[{i}]",
            "bib_key": e.key,
            "title": e.title,
            "doi": e.doi or (vr.get("doi") if vr else None),
            "arxiv_id": e.arxiv_id,
            "type": e.entry_type,
            "paper_metadata": {
                "authors_as_cited": e.authors,
                "year_as_cited": e.year,
                "first_author_family_bib": entry_family,
            },
            "status": "verified" if (vr and t_match and a_match and y_match) else
                      ("mismatch" if vr else "unverifiable"),
            "verification": {
                "source": vr.get("source") if vr else None,
                "endpoint": vr.get("endpoint") if vr else None,
                "queried_at": datetime.now(timezone.utc).isoformat(),
                "http_status": 200 if vr else None,
                "response": vr.get("raw_response") if vr else None,
            },
            "diff": {
                "title_match": t_match,
                "first_author_family_match": a_match,
                "year_match": y_match,
            },
            "notes": [],
        })
        print(f"  [{i}] {e.key}: {ledger['entries'][-1]['status']}")
    ledger_path.write_text(json.dumps(ledger, ensure_ascii=False, indent=2),
                           encoding="utf-8")
    print(f"\n台账已写入 {ledger_path}")
# --------------------------------------------------------------------------
# main
# --------------------------------------------------------------------------
def main():
    import argparse
    p = argparse.ArgumentParser()
    p.add_argument("bib_file")
    p.add_argument("--ledger", default="citation_ledger.json")
    p.add_argument("--cache", default="citation_cache.json")
    p.add_argument("--update-ledger", action="store_true")
    p.add_argument("--json", action="store_true")
    args = p.parse_args()
    bib_path = Path(args.bib_file)
    ledger_path = Path(args.ledger)
    cache_path = Path(args.cache)
    cache = json.loads(cache_path.read_text()) if cache_path.exists() else {}
    if args.update_ledger:
        generate_ledger(bib_path, ledger_path, cache)
        cache_path.write_text(json.dumps(cache, ensure_ascii=False, indent=2))
        sys.exit(0)
    code = check_file(bib_path, ledger_path, cache)
    cache_path.write_text(json.dumps(cache, ensure_ascii=False, indent=2))
    sys.exit(code)
if __name__ == "__main__":
    main()

关键设计点说明

  • 级联验证:CrossRef 是 DOI 注册机构、最权威;S2 / OpenAlex 覆盖面更广但数据经过加工;任一命中即停止,避免浪费 API 配额。
  • 三重 diff(标题 / 第一作者姓氏 / 年份)正是针对你举的两个实例(Piccialli F vs Prezioso E、Shao X vs Shao M)------DOI 解析成功不代表作者正确,必须字段级 diff。
  • 速率限制:CrossRef polite pool、Semantic Scholar 1 req/s、arXiv 3 秒间隔,否则会被 429 封禁。
  • 缓存:避免 CI 重复运行时浪费配额。

3. 引 CI 机械门禁

GitHub Action(投稿前最后一道防线)

yaml 复制代码
# .github/workflows/citation-check.yml
name: Citation Gate
on: [pull_request, workflow_dispatch]
jobs:
  citation-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: {python-version: "3.11"}
      - run: pip install requests
      - name: Generate / refresh citation ledger
        run: |
          python citation_check.py references.bib --update-ledger \
            --ledger citation_ledger.json --cache citation_cache.json
      - name: Mechanical gate --- any mismatch blocks merge
        run: |
          python citation_check.py references.bib \
            --ledger citation_ledger.json --cache citation_cache.json
      - uses: actions/upload-artifact@v4
        with:
          name: citation-evidence
          path: |
            citation_ledger.json
            citation_cache.json

Git pre-commit hook(写作过程中持续保护)

bash 复制代码
# .git/hooks/pre-commit
#!/bin/bash
python citation_check.py references.bib --ledger citation_ledger.json --cache citation_cache.json
exit $?

4. 常见"隐蔽杜撰"case 与本方案的拦截点

Case 类型 示例 Layer 1(台账) Layer 2(diff) Layer 4(门禁)
半真半假(chimeric) DOI 真实,作者错(Piccialli F vs Prezioso E) ✅ 台账存有原始 CrossRef 返回的 author 字段 first_author_family_match: false 拦截 ✅ 非零退出码
DOI 编号正确但指向不同论文 DOI 解析 200,但标题完全不同 raw_response.title 可回读 title_match: false 拦截 ✅ 同上
年份错配 论文写 2021,实际 2020 raw_response.published-print 可回读 year_match: false 拦截 ✅ 同上
完全编造 DOI 404 / API 无返回 ✅ 台账 status: "unverifiable" not_found ✅ 同上
无 DOI 的标准/法规 KA/T 20.6-2024 名称张冠李戴 ⚠️ 走降级路径(见下) ⚠️ 标记 unverifiable + 人工修正 ✅ 不会因未核验而硬凑
会议/老文献无 DOI USENIX / 经典教材 ⚠️ 标记 unverifiable --- ✅ 强制标注 [待人工核验]

5. 特殊场景的降级核验(针对标准 / 政府文件 / 中文文献)

CrossRef / OpenAlex / S2 对中文标准(GB / KA / AQ)和政府文件覆盖较弱,本方案在 Layer 5 的降级逻辑中:

  1. 中国国家标准 / 行业标准 :调用 openstd.samr.gov.cn 搜索 API(https://openstd.samr.gov.cn/bzgk/gb/std_list?p.p2={编号}),回读"标准号 ↔ 名称"映射。命中即写入台账 verification.source: "openstd.samr.gov.cn";未命中即 unverifiable
  2. 政府文件 / 部委通知 :无结构化 API 时,人工查官网后填入台账 raw_response 字段(可粘贴页面快照或截图链接),status: "verified"source: "manual_gov"
  3. 经典教材 / 无 DOI 老书 :ISBN 走 https://openlibrary.org/isbn/{isbn}.json;无 ISBN 走 WorldCat。
  4. 任何无法通过上述路径核验的status: "unverifiable" + 在正文中标注 [待人工核验]------这是硬规则,脚本不允许把 unverifiable 当作 verified 通过门禁

6. API 端点速查(核对用)

bash 复制代码
# CrossRef:直接用 DOI 查原始元数据(最权威)
curl "https://api.crossref.org/works/10.1016/j.aei.2021.101510" \
     -H "User-Agent: citation-check/1.0 (mailto:you@univ.edu)"
# Semantic Scholar:DOI 或 arXiv ID 查询(作者消歧最好)
curl "https://api.semanticscholar.org/graph/v1/paper/DOI:10.1016/j.aei.2021.101510?fields=title,authors,year"
# OpenAlex:4.7 亿 works,支持 DOI 短格式
curl "https://api.openalex.org/works/doi:10.1016/j.aei.2021.101510"
# arXiv:预印本专用
curl "https://export.arxiv.org/api/query?id_list=2303.08774"
# DOI.org:只做存在性校验(HEAD 请求),不做元数据 diff
curl -I -L "https://doi.org/10.1016/j.aei.2021.101510"

注意doi.org 的 HEAD 请求只能证明 DOI 已注册、能解析------不能证明它指向的论文就是你写的那篇。这正是你说的"检索成功 ≠ 著录正确"的技术依据,所以本方案的 Layer 2 必须做字段级 diff,而非只看 HTTP 状态码。

边界情况与失效模式(硬核的诚实清单)

  • CrossRef 覆盖有限 :约 1.4 亿 DOI 注册文献,但不覆盖 GB / KA / AQ / 政府文件 / 多数中文期刊(除非通过 CNKI 注册 DOI)------这类走降级路径。
  • arXiv vs 正式版差异 :预印本被期刊接收后元数据(卷期页)会变,citation_check.py 会把 arXiv 记录和 CrossRef 记录视为两条独立验证,若论文引用的是正式版,但台账里存的是 arXiv 版的作者顺序,会误报 mismatch------需要人工确认。
  • S2 / OpenAlex 的字段加工 :它们是聚合源(从 CrossRef / PubMed / MAG 合并),偶尔会出现作者排序错乱或旧版本数据------所以CrossRef 是首选权威源,S2 / OpenAlex 只是兜底。
  • 模糊标题匹配的假阳性_title_sim > 0.6 阈值对短标题(<5 词)容易误判,生产环境建议换 rapidfuzz 库的 token_set_ratio

FAQ

Q:台账里存的 raw_response 会不会太大?

A:CrossRef 单条响应约 2-5KB,50 篇文献约 200KB------完全可控。且这是证据链的核心 ,审计时能回放任何一次验证。

Q:如何防止 CI 在 API 限流时误判为 not_found

A:citation_check.py 对 429 / 超时做了区分------429 会 sleep 重试,连续失败才标记 unverifiable,不会把限流误判为文献不存在。

Q:团队协作时台账冲突怎么办?

A:citation_ledger.json 是 JSON、结构化、每条独立------git merge 冲突概率极低;即使有,也可以按 ref_id 手动合并,或运行 --update-ledger 全量重新生成。

Q:能否在 Claude Code / Cursor 等 Agent 里自动触发这套流程?

A:可以,把 citation_check.py 注册为 agent skill(类似 CiteCheck 的做法),Agent 在写完任何带引用的内容后自动调用脚本核验。


这套方案的核心思想:把 AI 从"文献创作者"降级为"文献搬运工",且只能搬运台账里有的、核验过的、可回放的结构化数据 。台账是可审计的 SSOT,citation_check.py 是不可绕过的机械门禁,两者共同把"AI 杜撰文献"从概率事件变成可拦截的确定性事件。

相关推荐
孤狼warrior1 小时前
SCTR 五次失败的安全 BN 路由器
人工智能·python·深度学习·算法·安全·yolo
AgentMaster1 小时前
元数据、血缘、质量、安全四大模块能力拆解,数据治理方案对比:4 种技术路线深度评测
大数据·数据库·数据仓库·人工智能·原型模式
霸道流氓气质1 小时前
Spring AI vs Spring AI Alibaba:技术选型与平滑迁移策略
java·人工智能·spring
艾莉丝努力练剑1 小时前
【AI大模型接入SDK】Ollama本地大语言模型部署
c++·人工智能·语言模型·自然语言处理·面试
Raas1001 小时前
AI网关和OpenRouter区别在哪?MAI Gateway(魔芋企业级AI网关)统一治理方案深度解析
大数据·人工智能·gateway·ai网关·mai gateway
JJJennie7771 小时前
MAI Gateway能力解析:大模型网关支持本地模型吗?AI网关核心功能详解
人工智能
找方案1 小时前
AI安全攻防:大模型越狱、提示注入与防御之道
人工智能·安全·机器学习
张彦峰ZYF1 小时前
从“记住对话”到“经营组织经验”:TencentDB Agent Memory 的团队级记忆架构、工程取舍与企业落地边界
人工智能·架构·llm·agent·skill·agent memory·tencentdb
shionhana1 小时前
从资料到演示稿:AI 生成 PPT 的工作流拆解
人工智能·ai·powerpoint