From 345e2cedf0313b93f4f511c2aec2dc193cbfaf6f Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 13 Jun 2026 21:50:09 +0000 Subject: [PATCH] =?UTF-8?q?feat(papers):=20B-3=20PR1=20=E2=80=94=20DOI=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=ED=99=94=C2=B7dedup=20=EC=BD=94=EC=96=B4=20(?= =?UTF-8?q?normalize=5Fdoi=20=EB=8B=A8=EC=9D=BC=20=ED=95=A8=EC=88=98=20+?= =?UTF-8?q?=20=EC=84=9C=EC=A7=80=20holder=20=EC=A1=B0=ED=9A=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/services/papers/__init__.py | 5 ++ app/services/papers/doi.py | 105 ++++++++++++++++++++++++++++++ app/services/papers/holder.py | 38 +++++++++++ tests/test_paper_doi_units.py | 111 ++++++++++++++++++++++++++++++++ 4 files changed, 259 insertions(+) create mode 100644 app/services/papers/__init__.py create mode 100644 app/services/papers/doi.py create mode 100644 app/services/papers/holder.py create mode 100644 tests/test_paper_doi_units.py diff --git a/app/services/papers/__init__.py b/app/services/papers/__init__.py new file mode 100644 index 0000000..a526dd6 --- /dev/null +++ b/app/services/papers/__init__.py @@ -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). +""" diff --git a/app/services/papers/doi.py b/app/services/papers/doi.py new file mode 100644 index 0000000..99ed8be --- /dev/null +++ b/app/services/papers/doi.py @@ -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 diff --git a/app/services/papers/holder.py b/app/services/papers/holder.py new file mode 100644 index 0000000..2455dc5 --- /dev/null +++ b/app/services/papers/holder.py @@ -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() diff --git a/tests/test_paper_doi_units.py b/tests/test_paper_doi_units.py new file mode 100644 index 0000000..ca3b27c --- /dev/null +++ b/tests/test_paper_doi_units.py @@ -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