bf0348a3e0
plan safety-library-b3-1 PR5. Papers_Purchased 수동 드롭 PDF(license.restricted=true)를 서지 holder 에 연결: 본문 DOI 파싱 → paper.parent_doi 링크(child, doi 미보유=인덱스 밖, unique 무충돌). - doi.py: parse_doi_from_text(본문 전체 DOI 정규식 — PDF 구조 무관). - paper_doi_reconcile: restricted 분기 — restricted 행은 본문 DOI→parent_doi(child), 그 외(레거시 arXiv)는 holder 스탬프(PR4). 쿼리에 parent_doi IS NULL 추가(링크분 재처리 회피). - file_watcher merge-only license 주입 clobber-safe 존중. enqueue 0(콘텐츠 무변경). 단위 29 passed(+parse_doi_from_text). ephemeral PASS: 합성 restricted 행 → parent_doi 링크· paper.doi 부재·restricted 보존·스키마 수용(insert+rollback). reconcile 멱등(재실행 0 변경). 실 구매 PDF 라이브 검증 = 사용자 첫 논문 구매·드롭 시(로직 검증 완료). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
142 lines
5.8 KiB
Python
142 lines
5.8 KiB
Python
"""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
|
|
import re
|
|
|
|
# 소문자화 후 비교하므로 전부 소문자 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
|
|
|
|
|
|
# arXiv id: 신형 'YYMM.NNNNN'(+vN) 또는 구형 'archive(.SUBJ)/NNNNNNN'. 'arXiv:' 접두 흡수.
|
|
_ARXIV_ID_RE = re.compile(
|
|
r"arxiv:\s*([a-z\-]+(?:\.[a-z]{2})?/\d{7}|\d{4}\.\d{4,5})(v\d+)?", re.IGNORECASE
|
|
)
|
|
|
|
|
|
def parse_arxiv_id(text: str | None) -> str | None:
|
|
"""본문/제목에서 arXiv id(versionless) 추출. 없으면 None. 레거시 reconcile 의 입력."""
|
|
if not text:
|
|
return None
|
|
m = _ARXIV_ID_RE.search(text)
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def arxiv_doi(arxiv_id: str | None) -> str | None:
|
|
"""arXiv DataCite DOI = 10.48550/arxiv.{id} (정규화). 저널 DOI 없는 프리프린트의 canonical
|
|
paper.doi 통일 키 — OpenAlex 가 프리프린트에 동일 DOI 부여(실측 확인). 모든 수집기·reconcile 가
|
|
같은 함수로 같은 DOI 를 써야 교차소스 dedup 이 성립."""
|
|
if not arxiv_id:
|
|
return None
|
|
return normalize_doi(f"10.48550/arXiv.{arxiv_id}")
|
|
|
|
|
|
_DOI_IN_TEXT_RE = re.compile(r"10\.\d{4,9}/[^\s\"'<>]+", re.IGNORECASE)
|
|
|
|
|
|
def parse_doi_from_text(text: str | None) -> str | None:
|
|
"""본문에서 첫 DOI 추출(정규화). 구매 PDF 의 paper.parent_doi 링크용(PDF 구조 무관 — 전체 스캔).
|
|
DOI 끝 구두점은 normalize_doi 가 정리. 없으면 None."""
|
|
if not text:
|
|
return None
|
|
m = _DOI_IN_TEXT_RE.search(text)
|
|
return normalize_doi(m.group(0)) if m else None
|
|
|
|
|
|
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
|