feat(papers): B-3 PR1 — DOI 정규화·dedup 코어 (normalize_doi 단일 함수 + 서지 holder 조회)
plan safety-library-b3-1 PR1 (keyless·마이그 0). 모든 논문 수집기·reconcile·구매 스탬프 공유 토대.
- normalize_doi(): 소문자·URL/doi: prefix 제거·인용 구두점(.,;) 정리. 저장=조회 단일 함수.
괄호 '()' 보존 — 과삭제는 다른 논문 병합(데이터 손상)이라 near-dup 보다 위험.
- paper_doi_hash(): 서지 holder file_hash 키 = sha256('paper|{doi}')[:32] (statute 다중부 키 선례).
- with_paper_doi/with_parent_doi/read_paper_doi: 2-Document 계약(holder doi / child parent_doi 상호배타) extract_meta 헬퍼 (merge-safe).
- find_paper_holder(): 공유 dedup 조회 — lower(extract_meta #>> '{paper,doi}'), .scalars().first()(BBC 다중행 선례),
EXPLAIN 으로 uq_documents_paper_doi(마이그 351 라이브) 인덱스 사용 확인.
단위 12 passed. holder DB 조회 = PR2 arXiv 실수집서 라이브 검증. 소비자 없는 순수 코드(배포·런타임 변화 0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
"""B-3 논문 수집 트랙 공유 모듈 (plan safety-library-b3-1).
|
||||||
|
|
||||||
|
doi — DOI 정규화·dedup 키·2-Document(holder/parent_doi child) extract_meta 계약 (순수).
|
||||||
|
holder — 서지 holder 공유 dedup 조회 (DB).
|
||||||
|
"""
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""B-3 논문 DOI 코어 — 정규화·dedup 키·2-Document(서지 holder / parent_doi child) 계약.
|
||||||
|
|
||||||
|
plan safety-library-b3-1 PR1 (keyless·마이그 0).
|
||||||
|
|
||||||
|
핵심 계약(모든 논문 수집기·reconcile·구매 PDF 스탬프가 공유):
|
||||||
|
- DOI 정규화는 이 단일 함수(normalize_doi) 경유 — **저장=조회 동일 함수**
|
||||||
|
(migration 351 주석 명시, news_collector._normalize_url 의 store=lookup 불변식 선례).
|
||||||
|
같은 논문이 다른 표기(https://doi.org/ vs doi: vs 대문자)로 들어와도 한 holder 로 붕괴.
|
||||||
|
- dedup 키 = lower(extract_meta #>> '{paper,doi}') — 라이브 partial-unique 인덱스
|
||||||
|
uq_documents_paper_doi(WHERE material_type='paper' AND ... IS NOT NULL)가 강제.
|
||||||
|
- 2-Document(R2-B1): paper.doi 는 **서지 Document 단일 보유**. OA/구매 전문 PDF 는
|
||||||
|
doi 없이 paper.parent_doi 로 holder 링크(NULL doi 라 인덱스 밖 → 다중행 무충돌).
|
||||||
|
holder 와 child 는 doi/parent_doi 를 **상호 배타**로 가진다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
# 소문자화 후 비교하므로 전부 소문자 prefix. 긴 것부터(dx.doi.org 가 doi.org 보다 먼저).
|
||||||
|
_DOI_PREFIXES = (
|
||||||
|
"https://dx.doi.org/",
|
||||||
|
"http://dx.doi.org/",
|
||||||
|
"https://doi.org/",
|
||||||
|
"http://doi.org/",
|
||||||
|
"dx.doi.org/",
|
||||||
|
"doi.org/",
|
||||||
|
"doi:",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_doi(raw: str | None) -> str | None:
|
||||||
|
"""DOI 정규화 — 소문자 + URL/doi: prefix 제거 + 양끝 공백·잡음 제거. 단일 함수(저장=조회).
|
||||||
|
|
||||||
|
유효 DOI(10. 으로 시작)가 아니면 None. 저장측·조회측·dedup 키 생성이 모두 이 함수를
|
||||||
|
공유해야 dedup 이 성립한다(raw 를 그대로 저장하고 정규화로 조회하면 영구 미스).
|
||||||
|
"""
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
s = raw.strip().lower()
|
||||||
|
for p in _DOI_PREFIXES:
|
||||||
|
if s.startswith(p):
|
||||||
|
s = s[len(p):]
|
||||||
|
break
|
||||||
|
s = s.strip()
|
||||||
|
# 인용문 끝 잡음(마침표/쉼표/세미콜론)만 제거. 괄호 '()' 는 DOI 일부일 수 있어 보존한다
|
||||||
|
# (예: 10.1016/s0010-8650(00)80003-2) — 과삭제는 서로 다른 논문을 한 holder 로 병합하는
|
||||||
|
# 데이터 손상이라 near-dup(과소삭제)보다 위험. API 소스(OpenAlex/arXiv)의 doi 는 이미 깨끗.
|
||||||
|
s = s.rstrip(".,;")
|
||||||
|
if not s.startswith("10."):
|
||||||
|
return None
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def paper_doi_hash(normalized_doi: str) -> str:
|
||||||
|
"""서지 holder 의 Document.file_hash — sha256('paper|{doi}')[:32].
|
||||||
|
|
||||||
|
statute 의 'statute|{jur}|{native_id}|{version_key}' 다중부 키 선례를 따른다.
|
||||||
|
인자는 normalize_doi() 출력(정규화 완료값)이어야 한다 — raw 를 넣으면 dedup 이 깨진다.
|
||||||
|
"""
|
||||||
|
if not normalized_doi:
|
||||||
|
raise ValueError("paper_doi_hash 는 정규화된 DOI 필요 (normalize_doi 먼저)")
|
||||||
|
return hashlib.sha256(f"paper|{normalized_doi}".encode()).hexdigest()[:32]
|
||||||
|
|
||||||
|
|
||||||
|
def read_paper_doi(extract_meta: dict | None) -> str | None:
|
||||||
|
"""holder 의 정규화 DOI 읽기 — 인덱스 식 lower(extract_meta #>> '{paper,doi}') 의 조회측 거울.
|
||||||
|
|
||||||
|
방어적 재정규화(이미 정규화돼 저장되지만 레거시·외부 주입 대비).
|
||||||
|
"""
|
||||||
|
if not extract_meta:
|
||||||
|
return None
|
||||||
|
paper = extract_meta.get("paper")
|
||||||
|
if not isinstance(paper, dict):
|
||||||
|
return None
|
||||||
|
return normalize_doi(paper.get("doi"))
|
||||||
|
|
||||||
|
|
||||||
|
def with_paper_doi(extract_meta: dict | None, normalized_doi: str) -> dict:
|
||||||
|
"""서지 holder 의 extract_meta 에 paper.doi 주입 (merge-safe, 타 키 보존).
|
||||||
|
|
||||||
|
holder 전용 — parent_doi 는 제거(상호 배타). 반환값은 새 dict(입력 비변경).
|
||||||
|
"""
|
||||||
|
if not normalized_doi:
|
||||||
|
raise ValueError("with_paper_doi 는 정규화된 DOI 필요")
|
||||||
|
meta = dict(extract_meta or {})
|
||||||
|
paper = dict(meta.get("paper") or {})
|
||||||
|
paper["doi"] = normalized_doi
|
||||||
|
paper.pop("parent_doi", None)
|
||||||
|
meta["paper"] = paper
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def with_parent_doi(extract_meta: dict | None, parent_normalized_doi: str) -> dict:
|
||||||
|
"""child(OA/구매 전문 PDF)의 extract_meta 에 paper.parent_doi 주입 (merge-safe, 타 키 보존).
|
||||||
|
|
||||||
|
child 는 paper.doi 를 갖지 않는다(NULL → partial-unique 인덱스 밖, 2-Document 무충돌).
|
||||||
|
반환값은 새 dict(입력 비변경).
|
||||||
|
"""
|
||||||
|
if not parent_normalized_doi:
|
||||||
|
raise ValueError("with_parent_doi 는 정규화된 DOI 필요")
|
||||||
|
meta = dict(extract_meta or {})
|
||||||
|
paper = dict(meta.get("paper") or {})
|
||||||
|
paper["parent_doi"] = parent_normalized_doi
|
||||||
|
paper.pop("doi", None)
|
||||||
|
meta["paper"] = paper
|
||||||
|
return meta
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""B-3 논문 서지 holder 공유 dedup 조회.
|
||||||
|
|
||||||
|
모든 논문 수집기(OpenAlex/arXiv/KoreaScience/J-STAGE)·reconcile·구매 PDF 스탬프가
|
||||||
|
ingest 전 이 함수로 holder 존재를 확인한다(있으면 skip 또는 child 링크).
|
||||||
|
|
||||||
|
- 조회 키 = lower(extract_meta #>> '{paper,doi}') == normalize_doi(...) — 라이브 partial-unique
|
||||||
|
인덱스 uq_documents_paper_doi 와 동일 식(인덱스 사용).
|
||||||
|
- .scalars().first() — 교차게시·다중 landing-page 로 2행 이상 매칭 시 MultipleResultsFound
|
||||||
|
raise 방지(scalar_one_or_none 금지, 2026-06 BBC 수집 중단 선례 / news_collector 동일 규율).
|
||||||
|
- 서지 holder Document 의 **생성**은 각 수집기/스탬프 경로가 소유한다(초록 signal 문서 vs 구매
|
||||||
|
최소 holder 로 shape 가 다름). 이 모듈은 dedup 조회만 공유한다.
|
||||||
|
|
||||||
|
DB 조회라 본 모듈은 PR2(arXiv 실수집)에서 라이브 검증한다 — PR1 단위 테스트 대상은 doi.py(순수).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from models.document import Document
|
||||||
|
from services.papers.doi import normalize_doi
|
||||||
|
|
||||||
|
# 인덱스 식과 동일: lower(extract_meta #>> '{paper,doi}')
|
||||||
|
_DOI_EXPR = func.lower(Document.extract_meta[("paper", "doi")].astext)
|
||||||
|
|
||||||
|
|
||||||
|
async def find_paper_holder(session, raw_or_normalized_doi):
|
||||||
|
"""정규화 DOI 로 서지 holder Document 조회. 없으면 None.
|
||||||
|
|
||||||
|
인자는 raw 든 정규화든 받아 normalize_doi 로 통일(저장=조회 동일 함수 보장).
|
||||||
|
"""
|
||||||
|
doi = normalize_doi(raw_or_normalized_doi)
|
||||||
|
if not doi:
|
||||||
|
return None
|
||||||
|
result = await session.execute(
|
||||||
|
select(Document)
|
||||||
|
.where(Document.material_type == "paper", _DOI_EXPR == doi)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""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
|
||||||
|
normalize_doi,
|
||||||
|
paper_doi_hash,
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user