Skip to content

Commit 3be508d

Browse files
author
Mohammed Taha Khan
committed
fix(harvester): map CERN/EP report numbers and match by apprn
1 parent ccaa0a5 commit 3be508d

3 files changed

Lines changed: 160 additions & 3 deletions

File tree

site/cds_rdm/inspire_harvester/load/matcher.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,27 @@ def query(self):
104104

105105
@dataclass(frozen=True)
106106
class ReportNumberMatchFilter(FilterCandidate):
107-
"""Match a CDS report number."""
107+
"""Match a CDS report number in metadata.identifiers."""
108108

109109
@property
110110
def query(self):
111111
"""Build the CDS report number query."""
112+
return [
113+
dsl.Q("term", **{"metadata.identifiers.scheme": "cdsrn"}),
114+
dsl.Q(
115+
"term",
116+
**{"metadata.identifiers.identifier": self.value},
117+
),
118+
]
119+
120+
121+
@dataclass(frozen=True)
122+
class RelatedReportNumberMatchFilter(FilterCandidate):
123+
"""Match a CDS report number in metadata.related_identifiers."""
124+
125+
@property
126+
def query(self):
127+
"""Build the related CDS report number query."""
112128
return [
113129
dsl.Q("term", **{"metadata.related_identifiers.scheme": "cdsrn"}),
114130
dsl.Q(
@@ -118,6 +134,22 @@ def query(self):
118134
]
119135

120136

137+
@dataclass(frozen=True)
138+
class ApprovalReportNumberMatchFilter(FilterCandidate):
139+
"""Match an EP/approval report number (apprn) in metadata.identifiers."""
140+
141+
@property
142+
def query(self):
143+
"""Build the approval report number query."""
144+
return [
145+
dsl.Q("term", **{"metadata.identifiers.scheme": "apprn"}),
146+
dsl.Q(
147+
"term",
148+
**{"metadata.identifiers.identifier": self.value},
149+
),
150+
]
151+
152+
121153
class RecordMatcher:
122154
"""Finds existing CDS records that match an incoming INSPIRE entry."""
123155

@@ -163,16 +195,21 @@ def _retrieve_identifier(self, identifiers, scheme) -> Optional[str]:
163195
def _build_filter_priority(self, entry, inspire_id, cdsrdm_id):
164196
"""Build the priority-ordered record match candidates."""
165197
doi = entry.get("pids", {}).get("doi", {}).get("identifier")
198+
identifiers = entry["metadata"].get("identifiers", [])
166199
related_identifiers = entry["metadata"].get("related_identifiers", [])
167200

168201
cds_id = self._retrieve_identifier(related_identifiers, "cds")
169202
arxiv_id = self._retrieve_identifier(related_identifiers, "arxiv")
170-
report_number = self._retrieve_identifier(related_identifiers, "cdsrn")
203+
report_number = self._retrieve_identifier(identifiers, "cdsrn")
204+
related_report_number = self._retrieve_identifier(related_identifiers, "cdsrn")
205+
approval_report_number = self._retrieve_identifier(identifiers, "apprn")
171206
return [
172207
ParentMatchFilter(value=cdsrdm_id),
173208
CDSIdentifierMatchFilter(value=cds_id),
174209
DOIMatchFilter(value=doi),
175210
InspireIdentifierMatchFilter(value=inspire_id),
176211
ArxivIdentifierMatchFilter(value=arxiv_id),
177212
ReportNumberMatchFilter(value=report_number),
213+
RelatedReportNumberMatchFilter(value=related_report_number),
214+
ApprovalReportNumberMatchFilter(value=approval_report_number),
178215
]

site/cds_rdm/inspire_harvester/transform/mappers/identifiers.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,34 @@
1414
from idutils.normalizers import normalize_isbn
1515
from idutils.validators import is_doi
1616

17+
from cds_rdm import schemes
1718
from cds_rdm.inspire_harvester.transform.mappers.mapper import MapperBase
1819

1920

21+
def _committee_approval_prefixes():
22+
"""Return configured committee approval report-number prefixes."""
23+
communities = current_app.config.get("CDS_COMMITTEE_APPROVAL_COMMUNITIES", {})
24+
return {
25+
cfg.get("report_number", {}).get("prefix")
26+
for cfg in communities.values()
27+
if cfg.get("report_number", {}).get("prefix")
28+
}
29+
30+
31+
def _is_approval_report_number(value):
32+
"""Return True if value is a valid EP/approval report number.
33+
34+
Requires both a configured committee prefix and a value accepted by the
35+
``apprn`` scheme validator, so prefix look-alikes fall back to ``cdsrn``
36+
instead of failing record validation.
37+
"""
38+
if not value:
39+
return False
40+
if not schemes.is_approval_report_number(value):
41+
return False
42+
return any(value.startswith(prefix) for prefix in _committee_approval_prefixes())
43+
44+
2045
@dataclass(frozen=True)
2146
class DOIMapper(MapperBase):
2247
"""Mapper for DOI identifiers."""
@@ -104,6 +129,19 @@ def map_value(self, src_record, ctx, logger):
104129
"Unexpected schema in external_system_identifiers. "
105130
f"| details: schema={schema}, value={value}"
106131
)
132+
133+
# Report numbers on the record itself:
134+
# - EP/approval numbers (configured prefixes) → apprn
135+
# - other CERN- report numbers → cdsrn
136+
for rn in src_metadata.get("report_numbers", []):
137+
report_number = rn.get("value")
138+
if not report_number:
139+
continue
140+
if _is_approval_report_number(report_number):
141+
identifiers.append({"identifier": report_number, "scheme": "apprn"})
142+
elif report_number.startswith("CERN-"):
143+
identifiers.append({"identifier": report_number, "scheme": "cdsrn"})
144+
107145
unique_ids = [dict(t) for t in {tuple(sorted(d.items())) for d in identifiers}]
108146
return unique_ids
109147

@@ -209,12 +247,21 @@ def map_value(self, src_record, ctx, logger):
209247
}
210248
)
211249

250+
# Non-CERN- / non-approval report numbers stay related (scheme cdsrn).
251+
# CERN- cdsrn and apprn values are handled by IdentifiersMapper.
212252
report_numbers = src_metadata.get("report_numbers", [])
213253
for rn in report_numbers:
254+
report_number = rn.get("value")
255+
if (
256+
not report_number
257+
or report_number.startswith("CERN-")
258+
or _is_approval_report_number(report_number)
259+
):
260+
continue
214261
identifiers.append(
215262
{
216263
"scheme": "cdsrn",
217-
"identifier": f"{rn['value']}",
264+
"identifier": report_number,
218265
"relation_type": {"id": "isvariantformof"},
219266
"resource_type": {"id": ctx.resource_type.value},
220267
}

site/tests/inspire_harvester/test_transformer.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,79 @@ def test_transform_funding_missing_award_errors():
217217
assert "Award not found in vocabulary" in ctx.errors[0]
218218

219219

220+
def test_transform_report_numbers_as_identifiers(running_app):
221+
"""CERN- report numbers go to identifiers; EP prefixes use apprn."""
222+
from flask import current_app
223+
224+
current_app.config["CDS_COMMITTEE_APPROVAL_COMMUNITIES"] = {
225+
"ep-community": {
226+
"report_number": {"prefix": "CERN-EP"},
227+
}
228+
}
229+
src_metadata = {
230+
"report_numbers": [
231+
{"value": "CERN-EP-2026-001"},
232+
{"value": "CERN-THESIS-2010-364"},
233+
{"value": "DESY-24-001"},
234+
]
235+
}
236+
ctx = MetadataSerializationContext(
237+
resource_type=ResourceType.OTHER, inspire_id="12345"
238+
)
239+
logger = Logger(inspire_id="12345")
240+
241+
identifiers = IdentifiersMapper().map_value(
242+
{"metadata": src_metadata, "created": "2023-01-01"}, ctx, logger
243+
)
244+
related = RelatedIdentifiersMapper().map_value(
245+
{"metadata": src_metadata, "created": "2023-01-01"}, ctx, logger
246+
)
247+
248+
assert {"identifier": "CERN-EP-2026-001", "scheme": "apprn"} in identifiers
249+
assert {"identifier": "CERN-THESIS-2010-364", "scheme": "cdsrn"} in identifiers
250+
assert not any(i.get("scheme") == "cdsrn" and i.get("identifier") == "CERN-EP-2026-001" for i in identifiers)
251+
assert any(
252+
i.get("scheme") == "cdsrn" and i.get("identifier") == "DESY-24-001"
253+
for i in related
254+
)
255+
assert not any(i.get("identifier") == "CERN-EP-2026-001" for i in related)
256+
assert not any(i.get("identifier") == "CERN-THESIS-2010-364" for i in related)
257+
258+
259+
def test_matcher_includes_approval_report_number(running_app):
260+
"""Matcher searches by apprn as well as cdsrn."""
261+
from cds_rdm.inspire_harvester.load.matcher import (
262+
ApprovalReportNumberMatchFilter,
263+
RecordMatcher,
264+
RelatedReportNumberMatchFilter,
265+
ReportNumberMatchFilter,
266+
)
267+
268+
entry = {
269+
"pids": {},
270+
"metadata": {
271+
"identifiers": [
272+
{"scheme": "cdsrn", "identifier": "CERN-THESIS-2010-364"},
273+
{"scheme": "apprn", "identifier": "CERN-EP-2026-001"},
274+
],
275+
"related_identifiers": [
276+
{"scheme": "cdsrn", "identifier": "DESY-24-001"},
277+
{"scheme": "inspire", "identifier": "12345"},
278+
],
279+
},
280+
}
281+
candidates = RecordMatcher()._build_filter_priority(entry, "12345", None)
282+
by_type = {type(c): c for c in candidates if c.value}
283+
284+
assert by_type[ReportNumberMatchFilter].value == "CERN-THESIS-2010-364"
285+
assert by_type[RelatedReportNumberMatchFilter].value == "DESY-24-001"
286+
assert by_type[ApprovalReportNumberMatchFilter].value == "CERN-EP-2026-001"
287+
assert any(
288+
q.to_dict() == {"term": {"metadata.identifiers.scheme": "apprn"}}
289+
for q in by_type[ApprovalReportNumberMatchFilter].query
290+
)
291+
292+
220293
@patch("cds_rdm.inspire_harvester.transform.mappers.identifiers.is_doi")
221294
def test_transform_dois_valid_external(mock_is_doi, running_app):
222295
"""Test DOIMapper with valid external DOI."""

0 commit comments

Comments
 (0)