Skip to content

Commit 712cd29

Browse files
committed
feat(ingest, backend): improve curation notification by adding diff to message
1 parent 46e7f99 commit 712cd29

7 files changed

Lines changed: 129 additions & 22 deletions

File tree

backend/src/main/kotlin/org/loculus/backend/controller/SubmissionController.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,10 @@ open class SubmissionController(
417417
@Parameter(
418418
description = "The metadata fields that should be returned. If not provided, all fields are returned.",
419419
) @RequestParam(required = false) fields: List<String>?,
420+
@Parameter(
421+
description = "Filter by accession versions in 'accession.version' format. " +
422+
"If not provided, all accession versions are considered.",
423+
) @RequestParam(required = false) accessionVersionsFilter: List<String>?,
420424
@Parameter(
421425
description = "Filter by group ids. If not provided, all groups are considered.",
422426
) @RequestParam(required = false) groupIdsFilter: List<Int>?,
@@ -432,11 +436,22 @@ open class SubmissionController(
432436
headers.add(HttpHeaders.CONTENT_ENCODING, compression.compressionName)
433437
}
434438

439+
val parsedAccessionVersions = accessionVersionsFilter?.takeIf { it.isNotEmpty() }?.map {
440+
val lastDot = it.lastIndexOf('.')
441+
if (lastDot <= 0 || lastDot == it.length - 1) {
442+
throw BadRequestException("Invalid accession version format '$it', expected 'accession.version'")
443+
}
444+
val version = it.substring(lastDot + 1).toLongOrNull()
445+
?: throw BadRequestException("Invalid version in accession version '$it', expected a number")
446+
AccessionVersion(it.substring(0, lastDot), version)
447+
}
448+
435449
val totalRecords = submissionDatabaseService.countUnprocessedMetadata(
436450
authenticatedUser,
437451
organism,
438452
groupIdsFilter?.takeIf { it.isNotEmpty() },
439453
statusesFilter?.takeIf { it.isNotEmpty() },
454+
parsedAccessionVersions,
440455
)
441456
headers.add(X_TOTAL_RECORDS, totalRecords.toString())
442457
// TODO(https://github.com/loculus-project/loculus/issues/2778)
@@ -452,6 +467,7 @@ open class SubmissionController(
452467
groupIdsFilter?.takeIf { it.isNotEmpty() },
453468
statusesFilter?.takeIf { it.isNotEmpty() },
454469
fields?.takeIf { it.isNotEmpty() },
470+
parsedAccessionVersions,
455471
)
456472
}
457473

backend/src/main/kotlin/org/loculus/backend/service/submission/SubmissionDatabaseService.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1228,6 +1228,7 @@ class SubmissionDatabaseService(
12281228
organism: Organism,
12291229
groupIdsFilter: List<Int>?,
12301230
statusesFilter: List<Status>?,
1231+
accessionVersionsFilter: List<AccessionVersion>?,
12311232
): Op<Boolean> {
12321233
val organismCondition = SequenceEntriesView.organismIs(organism)
12331234
val groupCondition = getGroupCondition(groupIdsFilter, authenticatedUser)
@@ -1236,7 +1237,12 @@ class SubmissionDatabaseService(
12361237
} else {
12371238
Op.TRUE
12381239
}
1239-
val conditions = organismCondition and groupCondition and statusCondition
1240+
val accessionVersionCondition = if (accessionVersionsFilter != null) {
1241+
SequenceEntriesView.accessionVersionIsIn(accessionVersionsFilter)
1242+
} else {
1243+
Op.TRUE
1244+
}
1245+
val conditions = organismCondition and groupCondition and statusCondition and accessionVersionCondition
12401246

12411247
return conditions
12421248
}
@@ -1246,6 +1252,7 @@ class SubmissionDatabaseService(
12461252
organism: Organism,
12471253
groupIdsFilter: List<Int>?,
12481254
statusesFilter: List<Status>?,
1255+
accessionVersionsFilter: List<AccessionVersion>?,
12491256
): Long = SequenceEntriesView
12501257
.selectAll()
12511258
.where(
@@ -1254,6 +1261,7 @@ class SubmissionDatabaseService(
12541261
organism,
12551262
groupIdsFilter,
12561263
statusesFilter,
1264+
accessionVersionsFilter,
12571265
),
12581266
)
12591267
.count()
@@ -1264,6 +1272,7 @@ class SubmissionDatabaseService(
12641272
groupIdsFilter: List<Int>?,
12651273
statusesFilter: List<Status>?,
12661274
fields: List<String>?,
1275+
accessionVersionsFilter: List<AccessionVersion>?,
12671276
): Sequence<AccessionVersionUnprocessedMetadata> {
12681277
val unprocessedMetadata = SequenceEntriesView.unprocessedDataColumn
12691278
// It's actually <Map<String, String>?> but exposed does not support nullable types here
@@ -1284,6 +1293,7 @@ class SubmissionDatabaseService(
12841293
organism,
12851294
groupIdsFilter,
12861295
statusesFilter,
1296+
accessionVersionsFilter,
12871297
),
12881298
)
12891299
.fetchSize(streamBatchSize)

backend/src/test/kotlin/org/loculus/backend/controller/submission/GetUnprocessedMetadataEndpointTest.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,29 @@ class GetUnprocessedMetadataEndpointTest(
190190
.andExpect(status().isOk)
191191
}
192192

193+
@Test
194+
fun `WHEN I filter by accessionVersions THEN should return only those entries`() {
195+
val entries = convenienceClient.prepareDefaultSequenceEntriesToApprovedForRelease()
196+
val target = entries.first()
197+
val accessionVersion = target.displayAccessionVersion()
198+
199+
val response = submissionControllerClient.getSubmittedMetadata(
200+
accessionVersionsFilter = listOf(accessionVersion),
201+
)
202+
response.andExpect(status().isOk)
203+
.andExpect(header().string("x-total-records", `is`("1")))
204+
val responseBody = response.expectNdjsonAndGetContent<AccessionVersionSubmittedMetadata>()
205+
assertThat(responseBody, hasSize(1))
206+
assertThat(responseBody[0].displayAccessionVersion(), `is`(accessionVersion))
207+
}
208+
209+
@Test
210+
fun `WHEN I filter by accessionVersions with invalid format THEN returns 400`() {
211+
submissionControllerClient.getSubmittedMetadata(
212+
accessionVersionsFilter = listOf("not-a-valid-accession-version"),
213+
).andExpect(status().isBadRequest)
214+
}
215+
193216
// Regression test for https://github.com/loculus-project/loculus/issues/4036
194217
@Test
195218
fun `GIVEN revoked sequences exist THEN endpoint does not throw exception`() {

backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionControllerClient.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ class SubmissionControllerClient(private val mockMvc: MockMvc, private val objec
307307
jwt: String? = jwtForDefaultUser,
308308
groupIdsFilter: List<Int>? = null,
309309
statusesFilter: List<Status>? = null,
310+
accessionVersionsFilter: List<String>? = null,
310311
fields: List<String>? = null,
311312
compression: String? = null,
312313
): ResultActions = mockMvc.perform(
@@ -320,6 +321,7 @@ class SubmissionControllerClient(private val mockMvc: MockMvc, private val objec
320321
}
321322
.param("groupIdsFilter", groupIdsFilter?.joinToString(",") { it.toString() })
322323
.param("statusesFilter", statusesFilter?.joinToString(",") { it.name })
324+
.param("accessionVersionsFilter", accessionVersionsFilter?.joinToString(","))
323325
.param("fields", fields?.joinToString(",")),
324326
)
325327

ingest/scripts/call_loculus.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,15 @@ def record_factory(*args, **kwargs):
106106

107107
if mode == "get-submitted":
108108
logger.info("Getting submitted sequences")
109-
get_submitted(config, output)
109+
if config.segmented:
110+
insdc_key = [
111+
"insdcAccessionBase" + "_" + segment for segment in config.nucleotide_sequences
112+
]
113+
else:
114+
insdc_key = ["insdcAccessionBase"]
115+
116+
fields = ["hash", *insdc_key]
117+
get_submitted(config, output, fields)
110118

111119

112120
if __name__ == "__main__":

ingest/scripts/compare_hashes.py

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import orjsonl
1010
import requests
1111
import yaml
12+
from loculus_client import get_submitted
1213

1314
logger = logging.getLogger(__name__)
1415
logging.basicConfig(
@@ -22,8 +23,10 @@
2223
@dataclass
2324
class Config:
2425
organism: str
25-
segmented: str
26+
segmented: bool
2627
nucleotide_sequences: list[str]
28+
group_name: str
29+
batch_chunk_size: int
2730
slack_hook: str = ""
2831

2932

@@ -86,16 +89,63 @@ def sample_out_hashed_records(
8689
return not keep
8790

8891

92+
def calculate_metadata_diff(
93+
config: Config, new_metadata: dict[str, Any], previous_entry: LatestLoculusVersion
94+
) -> dict[str, Any]:
95+
previous_metadata_list = get_submitted(
96+
config, # type: ignore
97+
None,
98+
fields=None,
99+
accessionVersionsFilter=[
100+
f"{previous_entry.loculus_accession}.{previous_entry.latest_version}"
101+
],
102+
)
103+
if not previous_metadata_list or len(previous_metadata_list) != 1:
104+
logger.warning(
105+
f"Could not retrieve previous metadata for {previous_entry.loculus_accession} "
106+
f"version {previous_entry.latest_version} to calculate metadata diff"
107+
)
108+
return {}
109+
previous_metadata = previous_metadata_list[0]
110+
111+
diff = {}
112+
113+
new_keys = set(new_metadata.keys())
114+
old_keys = set(previous_metadata.keys())
115+
116+
for key in new_keys | old_keys:
117+
key_in_new = key in new_metadata
118+
key_in_old = key in previous_metadata
119+
120+
if not key_in_new:
121+
print(f"Key only exists in previous metadata: {key}")
122+
diff[key] = {"new": None, "old": previous_metadata[key]}
123+
124+
elif not key_in_old:
125+
print(f"Key only exists in new metadata: {key}")
126+
diff[key] = {"new": new_metadata[key], "old": None}
127+
128+
else:
129+
new_value = new_metadata[key]
130+
old_value = previous_metadata[key]
131+
132+
if new_value != old_value:
133+
diff[key] = {"new": new_value, "old": old_value}
134+
135+
return diff
136+
137+
89138
def process_hashes(
90139
ingested_insdc_accession: InsdcAccession,
91140
metadata_id: SubmissionId,
92-
newly_ingested_hash: float | None,
141+
new_metadata: dict[str, Any],
93142
submitted: dict[InsdcAccession, LatestLoculusVersion],
94143
update_manager: SequenceUpdateManager,
95144
):
96145
"""
97146
Decide if metadata_id should be submitted, revised, or noop
98147
"""
148+
newly_ingested_hash: float | None = new_metadata.get("hash")
99149

100150
if ingested_insdc_accession not in submitted:
101151
update_manager.submit.append(metadata_id)
@@ -114,12 +164,15 @@ def process_hashes(
114164
return update_manager
115165

116166
if previously_submitted_entry.curated:
167+
metadata_diff = calculate_metadata_diff(
168+
update_manager.config, new_metadata, previously_submitted_entry
169+
)
117170
# Sequence has been curated before - special case
118171
notification = (
119172
f"Ingest: Sequence {corresponding_loculus_accession} with INSDC "
120173
f"accession {ingested_insdc_accession} has been curated before "
121174
f"- do not know how to proceed. New hash: {newly_ingested_hash}, "
122-
f"old hash: {previously_submitted_entry.hash}."
175+
f"old hash: {previously_submitted_entry.hash}. Metadata diff: {metadata_diff}"
123176
)
124177
logger.warning(notification)
125178
notify(update_manager.config, notification)
@@ -328,7 +381,6 @@ def main(
328381
for field in orjsonl.stream(metadata):
329382
metadata_id: SubmissionId = field["id"]
330383
record: dict[str, Any] = field["metadata"]
331-
ingested_hash: float | None = record.get("hash")
332384
if not config.segmented:
333385
insdc_accession_base = record["insdcAccessionBase"]
334386
if not insdc_accession_base:
@@ -337,9 +389,7 @@ def main(
337389
if sample_out_hashed_records(insdc_accession_base, subsample_fraction):
338390
update_manager.sampled_out.append(insdc_accession_base)
339391
continue
340-
process_hashes(
341-
insdc_accession_base, metadata_id, ingested_hash, submitted, update_manager
342-
)
392+
process_hashes(insdc_accession_base, metadata_id, record, submitted, update_manager)
343393
current_ingested_accessions.add(insdc_accession_base)
344394
continue
345395

@@ -366,7 +416,7 @@ def main(
366416
):
367417
# grouping is the same, can just look at first segment in group
368418
accession = insdc_accession_base_list[0]
369-
process_hashes(accession, metadata_id, ingested_hash, submitted, update_manager)
419+
process_hashes(accession, metadata_id, record, submitted, update_manager)
370420
continue
371421
# old group is subset of new group, new group has new segments
372422
old_submitted = [
@@ -381,7 +431,7 @@ def main(
381431
):
382432
# has a new segment, must be revised
383433
accession = old_submitted[0]
384-
process_hashes(accession, metadata_id, ingested_hash, submitted, update_manager)
434+
process_hashes(accession, metadata_id, record, submitted, update_manager)
385435
continue
386436
old_accessions: dict[LoculusAccession, JointInsdcAccession] = {
387437
submitted[a].loculus_accession: submitted[a].jointAccession

ingest/scripts/loculus_client.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -447,28 +447,22 @@ def get_sequence_status(config: Config):
447447
return result
448448

449449

450-
def get_submitted(config: Config, output: str):
450+
def get_submitted(config: Config, output: str | None, fields: list[str] | None = None, accessionVersionsFilter: list[str] | None = None):
451451
"""Get previously submitted sequences as ndjson
452452
This way we can avoid submitting the same sequences again
453453
Adds status to the output (as this is not returned by get-unprocessed-metadata)
454454
"""
455455

456456
url = f"{organism_url(config)}/get-unprocessed-metadata"
457457

458-
if config.segmented:
459-
insdc_key = [
460-
"insdcAccessionBase" + "_" + segment for segment in config.nucleotide_sequences
461-
]
462-
else:
463-
insdc_key = ["insdcAccessionBase"]
464-
465-
fields = ["hash", *insdc_key]
466-
467458
params = {
468-
"fields": fields,
469459
"groupIdsFilter": [],
470460
"statusesFilter": [],
471461
}
462+
if fields:
463+
params["fields"] = fields
464+
if accessionVersionsFilter:
465+
params["accessionVersionsFilter"] = accessionVersionsFilter
472466

473467
while True:
474468
logger.info("Getting previously submitted sequences")
@@ -499,6 +493,9 @@ def get_submitted(config: Config, output: str):
499493
statuses: dict[str, dict[int, str]] = get_sequence_status(config)
500494
logger.info(f"Got info on {len(statuses.keys())} previously submitted sequences/accessions")
501495

496+
if not output:
497+
return entries
498+
502499
for entry in entries:
503500
status = statuses.get(entry["accession"], {}).get(entry["version"], "UNKNOWN")
504501
entry_with_status = entry.copy()
@@ -508,3 +505,4 @@ def get_submitted(config: Config, output: str):
508505
if len(entries) == 0:
509506
with open(output, "w", encoding="utf-8"):
510507
pass
508+
return None

0 commit comments

Comments
 (0)