244d526ae2
plan safety-library-b3-1 PR4. paper.doi 없는 paper 행을 arXiv DataCite DOI 로 스탬프해
partial-unique 인덱스 편입 → 재유입 차단('동일-DOI 재유입 차단만').
- doi.py: parse_arxiv_id(본문→arXiv id) + arxiv_doi(10.48550/arxiv.{id}, OpenAlex canonical 실측 일치).
- ★arXiv DOI 통일: arxiv_collector 도 프리프린트(저널 DOI 없음)에 arxiv_doi 부여 → PR2/PR3/PR4 가 같은
함수로 같은 paper.doi → 교차소스 dedup 성립(이전엔 프리프린트 paper.doi 부재로 PR2↔PR3 dup 갭).
- paper_doi_reconcile.py: 전용 worker(dedup_reconcile=file_hash 캐시와 별개 — 적대리뷰 B·C major).
keyless·결정적(OpenAlex 호출 0)·in-DB·enqueue 0(콘텐츠 무변경). 선재 DOI holder 시 parent_doi
마킹(unique 위반 회피). add_job daily 03:50 KST. __main__ CLI.
단위 28 passed(+parse_arxiv_id·arxiv_doi). 라이브 PASS (prod, running fastapi 무접촉):
레거시 197행 arXiv DataCite 스탬프·ASME 2행 skip·선재중복 0 / dedup 불변식 206 distinct 206(인덱스 무위반) /
paper summarize active 0(signal-only). 멱등.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
132 lines
5.1 KiB
Python
132 lines
5.1 KiB
Python
"""B-3 PR1 — 논문 DOI 코어 순수 단위 테스트 (plan safety-library-b3-1).
|
|
|
|
holder.find_paper_holder(DB 조회)는 PR2 arXiv 실수집 시 라이브 검증 — 여기선 순수 함수만.
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "app"))
|
|
|
|
from services.papers.doi import ( # noqa: E402
|
|
arxiv_doi,
|
|
normalize_doi,
|
|
paper_doi_hash,
|
|
parse_arxiv_id,
|
|
read_paper_doi,
|
|
with_paper_doi,
|
|
with_parent_doi,
|
|
)
|
|
|
|
|
|
# ─── normalize_doi: 단일 함수(저장=조회) ───
|
|
|
|
def test_normalize_strips_url_and_lowercases():
|
|
assert normalize_doi("https://doi.org/10.1585/PFR.15.2402039") == "10.1585/pfr.15.2402039"
|
|
assert normalize_doi("http://dx.doi.org/10.1115/1.4045678") == "10.1115/1.4045678"
|
|
assert normalize_doi("doi:10.1016/j.jlp.2020.104321") == "10.1016/j.jlp.2020.104321"
|
|
assert normalize_doi("DOI: 10.1234/ABC") == "10.1234/abc"
|
|
|
|
|
|
def test_normalize_trims_whitespace_and_citation_noise():
|
|
assert normalize_doi(" https://doi.org/10.1234/abc ") == "10.1234/abc"
|
|
assert normalize_doi("10.1234/abc.") == "10.1234/abc"
|
|
assert normalize_doi("10.1234/abc;") == "10.1234/abc"
|
|
|
|
|
|
def test_normalize_preserves_parens_in_doi():
|
|
# 괄호는 DOI 일부일 수 있어 보존 (과삭제 = 다른 논문 병합 = 데이터 손상, near-dup 보다 위험)
|
|
assert normalize_doi("10.1016/s0010-8650(00)80003-2") == "10.1016/s0010-8650(00)80003-2"
|
|
assert normalize_doi("https://doi.org/10.1016/S0010-8650(00)80003-2") == "10.1016/s0010-8650(00)80003-2"
|
|
|
|
|
|
def test_normalize_rejects_non_doi():
|
|
assert normalize_doi(None) is None
|
|
assert normalize_doi("") is None
|
|
assert normalize_doi(" ") is None
|
|
assert normalize_doi("not-a-doi") is None
|
|
assert normalize_doi("arXiv:2606.08108") is None # arXiv id 는 DOI 아님
|
|
|
|
|
|
def test_normalize_is_idempotent_store_equals_lookup():
|
|
# 저장측·조회측이 같은 함수를 거치면 표기 차이가 한 값으로 붕괴 (dedup 성립 조건)
|
|
forms = [
|
|
"https://doi.org/10.1/X",
|
|
"doi:10.1/x",
|
|
"10.1/X",
|
|
" HTTPS://DOI.ORG/10.1/x ",
|
|
]
|
|
assert {normalize_doi(f) for f in forms} == {"10.1/x"}
|
|
assert normalize_doi(normalize_doi("https://doi.org/10.1/X")) == "10.1/x" # 멱등
|
|
|
|
|
|
# ─── paper_doi_hash: holder file_hash 키 ───
|
|
|
|
def test_paper_doi_hash_deterministic_len32():
|
|
h = paper_doi_hash("10.1234/abc")
|
|
assert len(h) == 32
|
|
assert h == paper_doi_hash("10.1234/abc")
|
|
|
|
|
|
def test_paper_doi_hash_distinct_per_doi():
|
|
assert paper_doi_hash("10.1/a") != paper_doi_hash("10.1/b")
|
|
|
|
|
|
# ─── 2-Document extract_meta 계약 (holder doi / child parent_doi 상호 배타) ───
|
|
|
|
def test_with_paper_doi_holder_shape_and_merge_safe():
|
|
meta = with_paper_doi({"license": {"scheme": "cc_by"}, "source_id": 7}, "10.1/x")
|
|
assert meta["paper"]["doi"] == "10.1/x"
|
|
assert "parent_doi" not in meta["paper"]
|
|
assert meta["license"]["scheme"] == "cc_by" # 타 키 보존
|
|
assert meta["source_id"] == 7
|
|
|
|
|
|
def test_with_parent_doi_child_shape_no_doi():
|
|
meta = with_parent_doi({"license": {"scheme": "proprietary"}}, "10.1/holder")
|
|
assert meta["paper"]["parent_doi"] == "10.1/holder"
|
|
assert "doi" not in meta["paper"] # child 는 doi 미보유 (partial-unique 인덱스 밖)
|
|
assert meta["license"]["scheme"] == "proprietary"
|
|
|
|
|
|
def test_holder_child_mutually_exclusive():
|
|
child = with_parent_doi({}, "10.1/p")
|
|
promoted = with_paper_doi(child, "10.1/self")
|
|
assert promoted["paper"]["doi"] == "10.1/self"
|
|
assert "parent_doi" not in promoted["paper"]
|
|
|
|
|
|
def test_input_not_mutated():
|
|
src = {"paper": {"doi": "10.1/old"}}
|
|
with_parent_doi(src, "10.1/new")
|
|
assert src["paper"]["doi"] == "10.1/old" # 원본 dict 불변
|
|
|
|
|
|
# ─── read_paper_doi: 인덱스 식의 조회측 거울 ───
|
|
|
|
def test_read_paper_doi():
|
|
assert read_paper_doi({"paper": {"doi": "10.1/x"}}) == "10.1/x"
|
|
assert read_paper_doi({"paper": {"doi": "https://doi.org/10.1/X"}}) == "10.1/x" # 방어적 재정규화
|
|
assert read_paper_doi({}) is None
|
|
assert read_paper_doi(None) is None
|
|
assert read_paper_doi({"paper": {"parent_doi": "10.1/p"}}) is None # child 는 doi 없음
|
|
assert read_paper_doi({"paper": {}}) is None
|
|
|
|
|
|
# ─── PR4: arXiv id 파싱 + arXiv DataCite DOI (교차소스 dedup 통일 키) ───
|
|
|
|
def test_parse_arxiv_id():
|
|
assert parse_arxiv_id("Title arXiv:2606.10236v1 Announce Type: new Abstract") == "2606.10236"
|
|
assert parse_arxiv_id("see arXiv:2601.02852 for details") == "2601.02852"
|
|
assert parse_arxiv_id("arXiv:cond-mat/0703470v2") == "cond-mat/0703470"
|
|
assert parse_arxiv_id("no arxiv here") is None
|
|
assert parse_arxiv_id(None) is None
|
|
|
|
|
|
def test_arxiv_doi_canonical():
|
|
# OpenAlex canonical 실측 일치: 10.48550/arxiv.{id} (소문자)
|
|
assert arxiv_doi("2606.10236") == "10.48550/arxiv.2606.10236"
|
|
assert arxiv_doi(None) is None
|
|
# 수집기·reconcile 가 같은 함수 → 같은 paper.doi (교차소스 dedup 성립)
|
|
assert arxiv_doi(parse_arxiv_id("x arXiv:2606.10236v1 y")) == "10.48550/arxiv.2606.10236"
|