"""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