File size: 5,573 Bytes
b92b744
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
import json
import re
from typing import Any

from app.fulltext import normalize_pmcid
from app.ingest.common import NCT_PATTERN, empty_study, integer, pmid

NORMALIZER_VERSION = "openalex-v5-pmcid-locations"
PMC_LOCATION_RE = re.compile(
    r"(?:ncbi\.nlm\.nih\.gov/pmc|pmc\.ncbi\.nlm\.nih\.gov|europepmc\.org(?:/pmc)?)"
    r"/articles/(?:PMC)?(\d+)",
    re.IGNORECASE,
)
REVIEW_TITLE_RE = re.compile(
    r"\b(?:(?:systematic|scoping|narrative|umbrella|literature|integrative|rapid)\s+reviews?"
    r"|meta[-\s]?analys(?:is|es)"
    r"|(?:clinical\s+practice|consensus|evidence[- ]based)\s+(?:guidelines?|recommendations))\b"
    r"|^(?:(?:international|clinical|practice)\s+)?(?:guidelines?|recommendations)\s+(?:for|on|from)\b",
    re.IGNORECASE,
)


def is_nonprimary_work(work: dict, title: str) -> bool:
    """Recognize explicit synthesis/guideline titles without treating peer review as a review."""
    kind = work.get("type")
    return (kind is not None and kind not in {"article", "preprint", "dissertation"}) or bool(REVIEW_TITLE_RE.search(title))


def _decoded(value):
    if isinstance(value, str):
        try:
            return json.loads(value)
        except json.JSONDecodeError:
            return value
    return value


def _object(value) -> dict:
    parsed = _decoded(value)
    return parsed if isinstance(parsed, dict) else {}


def _array(value) -> list:
    parsed = _decoded(value)
    if isinstance(parsed, list):
        return parsed
    return [parsed] if isinstance(parsed, str) and parsed else []


def reconstruct_abstract(inverted: Any) -> str:
    """Rebuild sparse indexes without allocating according to an untrusted position.

    Parquet exports may encode the index as a JSON string or DuckDB MAP. Invalid
    positions are ignored; collisions choose the lexicographically first token.
    """
    if isinstance(inverted, str):
        try:
            inverted = json.loads(inverted)
        except json.JSONDecodeError:
            return ""
    if isinstance(inverted, list):
        try:
            inverted = dict(inverted)
        except (ValueError, TypeError):
            return ""
    if not isinstance(inverted, dict):
        return ""
    # Older Arrow MAP conversion produces parallel key/value lists.
    if set(inverted) == {"key", "value"}:
        inverted = dict(zip(inverted["key"], inverted["value"], strict=False))
    positions: dict[int, str] = {}
    for word, offsets in sorted(inverted.items(), key=lambda item: str(item[0])):
        if not isinstance(word, str) or not isinstance(offsets, (list, tuple)):
            continue
        for position in offsets:
            if isinstance(position, int) and not isinstance(position, bool) and position >= 0:
                positions.setdefault(position, word)
    return " ".join(positions[position] for position in sorted(positions))


def work_pmcid(work: dict, ids: dict) -> str | None:
    """PMCID from ``ids`` or, as the Parquet snapshot omits it there, a PMC location URL."""
    direct = normalize_pmcid(ids.get("pmcid") or work.get("pmcid"))
    if direct:
        return direct
    for location in _array(work.get("locations")):
        match = PMC_LOCATION_RE.search(str(_object(location).get("landing_page_url") or ""))
        if match:
            return normalize_pmcid(match.group(1))
    return None


def normalize_work(work: dict, *, require_abstract: bool = False) -> dict | None:
    abstract = reconstruct_abstract(work.get("abstract_inverted_index"))
    if require_abstract and not abstract:
        return None
    identifier = str(work.get("id", "")).rstrip("/").rsplit("/", 1)[-1]
    if not re.fullmatch(r"W\d+", identifier):
        raise ValueError("OpenAlex work requires a valid W identifier")
    study = empty_study(identifier, "openalex")
    ids = _object(work.get("ids"))
    paper_pmid = pmid(ids.get("pmid") or work.get("pmid"))
    title = work.get("title") or work.get("display_name") or "Untitled study"
    year = integer(work.get("publication_year"))
    location = _object(work.get("primary_location"))
    venue = _object(location.get("source"))
    study.update({
        "title": title, "abstract": abstract,
        "abstract_available": bool(abstract), "work_type": work.get("type") or "unknown",
        "authors": [author["display_name"]
                    for item in _array(work.get("authorships"))
                    if (author := _object(_object(item).get("author"))).get("display_name")],
        "venue": venue.get("display_name", ""),
        "url": ids.get("doi") or work.get("doi") or f"https://openalex.org/{identifier}",
        "year": year, "publication_date": f"{year:04d}-01-01" if year else None,
        "pmids": [paper_pmid] if paper_pmid else [],
        # PubMed Central ID enables query-time full text; absent for most works.
        "pmcid": work_pmcid(work, ids),
        "nct_ids": sorted({x.upper() for x in NCT_PATTERN.findall(abstract)}),
        "referenced_works": [str(x).rstrip("/").rsplit("/", 1)[-1]
                             for x in _array(work.get("referenced_works"))],
        "is_retracted": bool(work.get("is_retracted", False)),
        "is_review": is_nonprimary_work(work, title),
        "normalizer_version": NORMALIZER_VERSION,
        "snapshot_provenance": work.get("snapshot_provenance", {}),
        "cited_by_count": integer(work.get("cited_by_count")) or 0,
        "topics": [topic.get("display_name", "") if isinstance(topic, dict) else str(topic)
                   for topic in _array(work.get("topics"))],
    })
    return study