celldn 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
celldn/io.py ADDED
@@ -0,0 +1,1463 @@
1
+ """BioC and JSON I/O utilities for CellDN.
2
+
3
+ It can be used by tests, converters, fetchers, and CLI
4
+ commands without loading the CellDN NER/NEN checkpoints.
5
+
6
+ BioC XML, BioC JSON, and generic JSON inputs are all parsed into the same
7
+ in-memory BioC collection and passage-record objects. Downstream code can then
8
+ operate on those shared records instead of branching on the original file
9
+ format.
10
+
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import tempfile
19
+ from contextlib import contextmanager
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any, Iterable, Iterator, Mapping, Sequence
23
+ from xml.etree import ElementTree as ET
24
+
25
+ PathLike = str | os.PathLike[str]
26
+ _GENERATED_ANNOTATION_KEY = "__celldn_generated_annotation"
27
+
28
+ BIOC_XML_FORMATS = {"xml", "bioc-xml", "bioc_xml"}
29
+ BIOC_JSON_FORMATS = {"bioc-json", "bioc_json"}
30
+ GENERIC_JSON_FORMATS = {"json", "generic-json", "generic_json"}
31
+ SUPPORTED_DOCUMENT_FORMATS = BIOC_XML_FORMATS | BIOC_JSON_FORMATS | GENERIC_JSON_FORMATS | {"auto"}
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Public lightweight prediction/record dataclasses retained from CellDN 0.1
36
+ # ---------------------------------------------------------------------------
37
+ @dataclass(slots=True)
38
+ class PredictedEntity:
39
+ """A predicted entity with absolute BioC offsets."""
40
+
41
+ document_id: str = ""
42
+ passage_id: int = 0
43
+ start: int = 0
44
+ end: int = 0
45
+ label: str = "cell_type"
46
+ text: str = ""
47
+ score: float | None = None
48
+ infons: dict[str, Any] = field(default_factory=dict)
49
+
50
+ @property
51
+ def length(self) -> int:
52
+ return max(0, int(self.end) - int(self.start))
53
+
54
+
55
+ @dataclass(slots=True)
56
+ class EntitySpan:
57
+ """An entity span relative to a passage unless converted to absolute."""
58
+
59
+ start: int
60
+ end: int
61
+ label: str = "cell_type"
62
+ text: str = ""
63
+ score: float | None = None
64
+ infons: dict[str, Any] = field(default_factory=dict)
65
+
66
+ @property
67
+ def length(self) -> int:
68
+ return max(0, int(self.end) - int(self.start))
69
+
70
+ def to_absolute(
71
+ self,
72
+ passage_offset: int,
73
+ *,
74
+ document_id: str = "",
75
+ passage_id: int = 0,
76
+ ) -> PredictedEntity:
77
+ """Return a :class:`PredictedEntity` using absolute BioC offsets."""
78
+
79
+ offset = int(passage_offset)
80
+ return PredictedEntity(
81
+ document_id=document_id,
82
+ passage_id=passage_id,
83
+ start=offset + int(self.start),
84
+ end=offset + int(self.end),
85
+ label=self.label,
86
+ text=self.text,
87
+ score=self.score,
88
+ infons=dict(self.infons),
89
+ )
90
+
91
+
92
+ @dataclass(slots=True)
93
+ class PassageRecord:
94
+ """A BioC passage and its optional entity annotations."""
95
+
96
+ record_id: int
97
+ document_id: str
98
+ passage_id: int
99
+ passage_offset: int
100
+ text: str
101
+ entities: list[EntitySpan] = field(default_factory=list)
102
+ infons: dict[str, str] = field(default_factory=dict)
103
+ source_path: str = ""
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # In-memory BioC representation for XML/JSON conversion
108
+ # ---------------------------------------------------------------------------
109
+ @dataclass(slots=True)
110
+ class BioCLocation:
111
+ offset: int
112
+ length: int
113
+
114
+ def to_json(self) -> dict[str, int]:
115
+ return {"offset": int(self.offset), "length": int(self.length)}
116
+
117
+
118
+ @dataclass(slots=True)
119
+ class BioCAnnotation:
120
+ id: str = ""
121
+ infons: dict[str, str] = field(default_factory=dict)
122
+ locations: list[BioCLocation] = field(default_factory=list)
123
+ text: str = ""
124
+
125
+ def primary_location(self) -> BioCLocation | None:
126
+ return self.locations[0] if self.locations else None
127
+
128
+ def duplicate_key(self) -> tuple[Any, ...]:
129
+ location = self.primary_location()
130
+ return (
131
+ location.offset if location else None,
132
+ location.length if location else None,
133
+ self.text,
134
+ self.infons.get("type") or self.infons.get("label") or self.infons.get("entity_type"),
135
+ self.infons.get("identifier") or self.infons.get("cl_id") or self.infons.get("MESH") or "",
136
+ )
137
+
138
+ def to_json(self) -> dict[str, Any]:
139
+ payload: dict[str, Any] = {
140
+ "id": self.id,
141
+ "infons": dict(self.infons),
142
+ "locations": [location.to_json() for location in self.locations],
143
+ "text": self.text,
144
+ }
145
+ return {key: value for key, value in payload.items() if value not in (None, {}, [], "")}
146
+
147
+
148
+ @dataclass(slots=True)
149
+ class BioCPassage:
150
+ infons: dict[str, str] = field(default_factory=dict)
151
+ offset: int = 0
152
+ text: str = ""
153
+ annotations: list[BioCAnnotation] = field(default_factory=list)
154
+
155
+ def to_json(self) -> dict[str, Any]:
156
+ payload: dict[str, Any] = {
157
+ "infons": dict(self.infons),
158
+ "offset": int(self.offset),
159
+ "text": self.text,
160
+ "annotations": [annotation.to_json() for annotation in self.annotations],
161
+ }
162
+ return {key: value for key, value in payload.items() if value not in (None, {}, [])}
163
+
164
+
165
+ @dataclass(slots=True)
166
+ class BioCDocument:
167
+ id: str = ""
168
+ infons: dict[str, str] = field(default_factory=dict)
169
+ passages: list[BioCPassage] = field(default_factory=list)
170
+
171
+ def to_json(self) -> dict[str, Any]:
172
+ payload: dict[str, Any] = {
173
+ "id": self.id,
174
+ "infons": dict(self.infons),
175
+ "passages": [passage.to_json() for passage in self.passages],
176
+ }
177
+ return {key: value for key, value in payload.items() if value not in (None, {}, [], "")}
178
+
179
+
180
+ @dataclass(slots=True)
181
+ class BioCCollection:
182
+ source: str = "CellDN"
183
+ date: str = ""
184
+ key: str = "cell-type-extraction"
185
+ infons: dict[str, str] = field(default_factory=dict)
186
+ documents: list[BioCDocument] = field(default_factory=list)
187
+
188
+ def to_json(self) -> dict[str, Any]:
189
+ payload: dict[str, Any] = {
190
+ "source": self.source,
191
+ "date": self.date,
192
+ "key": self.key,
193
+ "infons": dict(self.infons),
194
+ "documents": [document.to_json() for document in self.documents],
195
+ }
196
+ return {key: value for key, value in payload.items() if value not in (None, {}, [])}
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Format helpers
201
+ # ---------------------------------------------------------------------------
202
+ def canonical_document_format(fmt: str | None, *, path: PathLike | None = None) -> str:
203
+ """Return ``bioc-xml``, ``bioc-json``, ``json``, or auto-detected format."""
204
+
205
+ fmt_norm = (fmt or "auto").strip().lower()
206
+ if fmt_norm in {"auto", ""}:
207
+ if path is None:
208
+ raise ValueError("format='auto' requires a file path or explicit format")
209
+ return detect_document_format(path)
210
+ if fmt_norm in BIOC_XML_FORMATS:
211
+ return "bioc-xml"
212
+ if fmt_norm in BIOC_JSON_FORMATS:
213
+ return "bioc-json"
214
+ if fmt_norm in GENERIC_JSON_FORMATS:
215
+ return "json"
216
+ raise ValueError(
217
+ f"Unsupported document format: {fmt!r}. "
218
+ "Use one of: auto, bioc-xml, bioc-json, json."
219
+ )
220
+
221
+
222
+ def canonical_bioc_format(fmt: str | None, *, path: PathLike | None = None) -> str:
223
+ """Return ``bioc-xml`` or ``bioc-json`` for output formats."""
224
+
225
+ resolved = (
226
+ canonical_document_format(fmt, path=path)
227
+ if (fmt or "auto") == "auto"
228
+ else canonical_document_format(fmt)
229
+ )
230
+ if resolved == "json":
231
+ raise ValueError("JSON is supported as input; output must be bioc-xml or bioc-json.")
232
+ return resolved
233
+
234
+
235
+ def detect_document_format(path: PathLike) -> str:
236
+ """Detect BioC XML, BioC JSON, or generic JSON."""
237
+
238
+ p = Path(path)
239
+ suffixes = "".join(p.suffixes).lower()
240
+ if suffixes.endswith(".bioc.json"):
241
+ return "bioc-json"
242
+ if suffixes.endswith(".json"):
243
+ return _detect_json_file_format(p)
244
+ if suffixes.endswith((".bioc.xml", ".xml")):
245
+ return "bioc-xml"
246
+
247
+ with p.open("r", encoding="utf-8") as handle:
248
+ sample = handle.read(4096).lstrip("\ufeff\n\r\t ")
249
+ if not sample:
250
+ raise ValueError(f"Cannot detect format of empty file: {p}")
251
+ if sample.startswith("<"):
252
+ return "bioc-xml"
253
+ if sample.startswith("{") or sample.startswith("["):
254
+ return _detect_json_file_format(p)
255
+ raise ValueError(f"Could not auto-detect input format for {p}. Pass --input-format explicitly.")
256
+
257
+
258
+ def _detect_json_file_format(path: Path) -> str:
259
+ payload = _load_json_file(path)
260
+ return "bioc-json" if _looks_like_bioc_json_obj(payload) else "json"
261
+
262
+
263
+ def _looks_like_bioc_json_obj(obj: Any) -> bool:
264
+ obj = _unwrap_bioc_json_variant(obj)
265
+ if isinstance(obj, list):
266
+ return all(isinstance(item, Mapping) and ("passages" in item or "passage" in item) for item in obj)
267
+ if not isinstance(obj, Mapping):
268
+ return False
269
+ if "collection" in obj and isinstance(obj["collection"], Mapping):
270
+ obj = obj["collection"]
271
+ return any(key in obj for key in ("documents", "document", "passages", "passage"))
272
+
273
+
274
+ def output_format_from_path(path: PathLike, *, default: str = "bioc-xml") -> str:
275
+ """Infer BioC output format from extension, falling back to ``default``."""
276
+
277
+ p = Path(path)
278
+ suffixes = "".join(p.suffixes).lower()
279
+ if suffixes.endswith((".json", ".bioc.json")):
280
+ return "bioc-json"
281
+ if suffixes.endswith((".xml", ".bioc.xml")):
282
+ return "bioc-xml"
283
+ return canonical_bioc_format(default)
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # BioC XML/JSON readers and writers
288
+ # ---------------------------------------------------------------------------
289
+ def read_bioc_collection(path: PathLike, *, input_format: str = "auto") -> BioCCollection:
290
+ """Read BioC XML, BioC JSON, or generic JSON into a BioC collection."""
291
+
292
+ fmt = canonical_document_format(input_format, path=path)
293
+ p = Path(path)
294
+ if fmt == "bioc-xml":
295
+ return read_bioc_xml(p)
296
+ if fmt == "bioc-json":
297
+ return read_bioc_json(p)
298
+ if fmt == "json":
299
+ return read_generic_json(p)
300
+ raise AssertionError(fmt)
301
+
302
+
303
+ def read_bioc_collection_from_string(text: str, *, input_format: str) -> BioCCollection:
304
+ """Read a BioC collection from a string payload."""
305
+
306
+ fmt = canonical_document_format(input_format)
307
+ if fmt == "bioc-xml":
308
+ root = ET.fromstring(text.encode("utf-8"))
309
+ return _collection_from_xml_root(root)
310
+ if fmt == "bioc-json":
311
+ return _collection_from_json_obj(json.loads(text))
312
+ if fmt == "json":
313
+ return _collection_from_generic_json_obj(json.loads(text))
314
+ raise AssertionError(fmt)
315
+
316
+
317
+ def write_bioc_collection(
318
+ collection: BioCCollection,
319
+ output_path: PathLike,
320
+ *,
321
+ output_format: str = "auto",
322
+ ) -> Path:
323
+ """Write a BioC collection as XML or JSON."""
324
+
325
+ output = Path(output_path)
326
+ fmt = (
327
+ output_format_from_path(output)
328
+ if output_format in (None, "", "auto")
329
+ else canonical_bioc_format(output_format)
330
+ )
331
+ output.parent.mkdir(parents=True, exist_ok=True)
332
+ if fmt == "bioc-xml":
333
+ return write_bioc_xml(collection, output)
334
+ if fmt == "bioc-json":
335
+ return write_bioc_json(collection, output)
336
+ raise AssertionError(fmt)
337
+
338
+
339
+ def merge_bioc_files(
340
+ input_paths: Iterable[PathLike],
341
+ output_path: PathLike,
342
+ *,
343
+ input_format: str = "auto",
344
+ output_format: str = "auto",
345
+ renumber_generated_annotations: bool = False,
346
+ ) -> Path:
347
+ """Merge BioC files while keeping only one input chunk in memory.
348
+
349
+ Documents are written in input-file order. The first collection header is
350
+ preserved, stable CellDN metadata is retained, normalization mention
351
+ counts are summed, and timing fields are omitted. Temporary generated
352
+ annotation markers can be finalized while the documents are streamed.
353
+ """
354
+
355
+ paths = [Path(path) for path in input_paths]
356
+ if not paths:
357
+ raise ValueError("At least one BioC input file is required.")
358
+ for path in paths:
359
+ if not path.is_file():
360
+ raise FileNotFoundError(f"Input file does not exist: {path}")
361
+
362
+ first_header = read_bioc_collection(paths[0], input_format=input_format)
363
+ header = BioCCollection(
364
+ source=first_header.source,
365
+ date=first_header.date,
366
+ key=first_header.key,
367
+ )
368
+ del first_header
369
+ unique_mentions = 0
370
+ saw_unique_mentions = False
371
+
372
+ for path in paths:
373
+ collection = read_bioc_collection(path, input_format=input_format)
374
+ if not header.source and collection.source:
375
+ header.source = collection.source
376
+ if not header.date and collection.date:
377
+ header.date = collection.date
378
+ if not header.key and collection.key:
379
+ header.key = collection.key
380
+ for key, value in collection.infons.items():
381
+ key_text = str(key)
382
+ key_folded = key_text.casefold()
383
+ if "elapsed" in key_folded or "runtime" in key_folded:
384
+ continue
385
+ if key_text == "CellDN_normalization_unique_mentions":
386
+ try:
387
+ unique_mentions += int(value)
388
+ saw_unique_mentions = True
389
+ except (TypeError, ValueError):
390
+ pass
391
+ continue
392
+ header.infons.setdefault(key_text, str(value))
393
+
394
+ if saw_unique_mentions:
395
+ header.infons["CellDN_normalization_unique_mentions"] = str(
396
+ unique_mentions
397
+ )
398
+
399
+ annotation_index = 1
400
+
401
+ def _documents() -> Iterator[BioCDocument]:
402
+ nonlocal annotation_index
403
+ for path in paths:
404
+ collection = read_bioc_collection(path, input_format=input_format)
405
+ for document in collection.documents:
406
+ if renumber_generated_annotations:
407
+ annotation_index = _finalize_generated_annotation_ids(
408
+ document,
409
+ start=annotation_index,
410
+ )
411
+ yield document
412
+
413
+ return write_bioc_document_stream(
414
+ header,
415
+ _documents(),
416
+ output_path,
417
+ output_format=output_format,
418
+ )
419
+
420
+
421
+ def _finalize_generated_annotation_ids(
422
+ document: BioCDocument,
423
+ *,
424
+ start: int,
425
+ ) -> int:
426
+ """Renumber marked annotations and return the next available index."""
427
+
428
+ annotation_index = start
429
+ for passage in document.passages:
430
+ for annotation in passage.annotations:
431
+ if annotation.infons.pop(_GENERATED_ANNOTATION_KEY, None) is None:
432
+ continue
433
+ annotation.id = f"T{annotation_index}"
434
+ annotation_index += 1
435
+ return annotation_index
436
+
437
+
438
+ def write_bioc_document_stream(
439
+ header: BioCCollection,
440
+ documents: Iterable[BioCDocument],
441
+ output_path: PathLike,
442
+ *,
443
+ output_format: str = "auto",
444
+ ) -> Path:
445
+ """Write BioC documents incrementally using collection-level metadata."""
446
+
447
+ output = Path(output_path)
448
+ fmt = (
449
+ output_format_from_path(output)
450
+ if output_format in (None, "", "auto")
451
+ else canonical_bioc_format(output_format)
452
+ )
453
+ output.parent.mkdir(parents=True, exist_ok=True)
454
+
455
+ file_descriptor, temporary_name = tempfile.mkstemp(
456
+ prefix=f".{output.name}.",
457
+ suffix=".tmp",
458
+ dir=output.parent,
459
+ )
460
+ os.close(file_descriptor)
461
+ temporary_path = Path(temporary_name)
462
+ try:
463
+ if fmt == "bioc-xml":
464
+ _write_bioc_xml_stream(header, documents, temporary_path)
465
+ elif fmt == "bioc-json":
466
+ _write_bioc_json_stream(header, documents, temporary_path)
467
+ else: # pragma: no cover - canonical_bioc_format guards this.
468
+ raise AssertionError(fmt)
469
+ os.replace(temporary_path, output)
470
+ finally:
471
+ temporary_path.unlink(missing_ok=True)
472
+ return output
473
+
474
+
475
+ def _write_bioc_xml_stream(
476
+ header: BioCCollection,
477
+ documents: Iterable[BioCDocument],
478
+ output: Path,
479
+ ) -> None:
480
+ """Write a BioC XML collection one document at a time."""
481
+
482
+ root = ET.Element("collection")
483
+ for key, value in header.infons.items():
484
+ _add_infon(root, key, value)
485
+ ET.SubElement(root, "source").text = header.source
486
+ ET.SubElement(root, "date").text = header.date
487
+ ET.SubElement(root, "key").text = header.key
488
+ serialized_header = ET.tostring(
489
+ root,
490
+ encoding="unicode",
491
+ short_empty_elements=False,
492
+ )
493
+ prefix, separator, _ = serialized_header.rpartition("</collection>")
494
+ if not separator: # pragma: no cover - root is always a collection.
495
+ raise RuntimeError("Could not serialize BioC collection header")
496
+
497
+ with output.open("w", encoding="utf-8") as handle:
498
+ handle.write('<?xml version="1.0" encoding="utf-8"?>\n')
499
+ handle.write(prefix)
500
+ for document in documents:
501
+ element = _document_to_xml_element(document)
502
+ ET.indent(element, space=" ", level=1)
503
+ handle.write("\n")
504
+ handle.write(ET.tostring(element, encoding="unicode"))
505
+ handle.write("\n</collection>\n")
506
+
507
+
508
+ def _document_to_xml_element(document: BioCDocument) -> ET.Element:
509
+ """Convert one in-memory document to a BioC XML element."""
510
+
511
+ document_element = ET.Element("document")
512
+ for key, value in document.infons.items():
513
+ _add_infon(document_element, key, value)
514
+ ET.SubElement(document_element, "id").text = document.id
515
+ for passage in document.passages:
516
+ passage_element = ET.SubElement(document_element, "passage")
517
+ for key, value in passage.infons.items():
518
+ _add_infon(passage_element, key, value)
519
+ ET.SubElement(passage_element, "offset").text = str(int(passage.offset))
520
+ ET.SubElement(passage_element, "text").text = passage.text
521
+ for index, annotation in enumerate(passage.annotations):
522
+ annotation_id = annotation.id or f"A{index}"
523
+ annotation_element = ET.SubElement(
524
+ passage_element,
525
+ "annotation",
526
+ {"id": str(annotation_id)},
527
+ )
528
+ for key, value in annotation.infons.items():
529
+ _add_infon(annotation_element, key, value)
530
+ for location in annotation.locations:
531
+ ET.SubElement(
532
+ annotation_element,
533
+ "location",
534
+ {
535
+ "offset": str(int(location.offset)),
536
+ "length": str(int(location.length)),
537
+ },
538
+ )
539
+ ET.SubElement(annotation_element, "text").text = annotation.text
540
+ return document_element
541
+
542
+
543
+ def _write_bioc_json_stream(
544
+ header: BioCCollection,
545
+ documents: Iterable[BioCDocument],
546
+ output: Path,
547
+ ) -> None:
548
+ """Write a BioC JSON collection one document at a time."""
549
+
550
+ header_payload = header.to_json()
551
+ header_payload.pop("documents", None)
552
+ with output.open("w", encoding="utf-8") as handle:
553
+ handle.write("{\n")
554
+ items = list(header_payload.items())
555
+ for index, (key, value) in enumerate(items):
556
+ handle.write(" ")
557
+ handle.write(json.dumps(str(key), ensure_ascii=False))
558
+ handle.write(": ")
559
+ handle.write(json.dumps(value, ensure_ascii=False, indent=2))
560
+ handle.write(",\n")
561
+ handle.write(' "documents": [')
562
+ first = True
563
+ for document in documents:
564
+ if first:
565
+ handle.write("\n")
566
+ first = False
567
+ else:
568
+ handle.write(",\n")
569
+ payload = json.dumps(
570
+ document.to_json(),
571
+ ensure_ascii=False,
572
+ indent=2,
573
+ )
574
+ handle.write(" ")
575
+ handle.write(payload.replace("\n", "\n "))
576
+ if not first:
577
+ handle.write("\n ")
578
+ handle.write("]\n}\n")
579
+
580
+
581
+ def read_bioc_xml(path: PathLike) -> BioCCollection:
582
+ return _collection_from_xml_root(ET.parse(path).getroot())
583
+
584
+
585
+ def _collection_from_xml_root(root: ET.Element) -> BioCCollection:
586
+ source = _text_of(root, "source", "")
587
+ date = _text_of(root, "date", "")
588
+ key = _text_of(root, "key", "")
589
+ collection = BioCCollection(source=source, date=date, key=key, infons=read_infons(root))
590
+
591
+ for document_element in root.findall("document"):
592
+ document = BioCDocument(
593
+ id=_text_of(document_element, "id", ""),
594
+ infons=read_infons(document_element),
595
+ )
596
+ for passage_element in document_element.findall("passage"):
597
+ passage = BioCPassage(
598
+ infons=read_infons(passage_element),
599
+ offset=_int_text_of(passage_element, "offset", 0),
600
+ text=_text_of(passage_element, "text", ""),
601
+ )
602
+ for annotation_element in passage_element.findall("annotation"):
603
+ locations = [
604
+ BioCLocation(
605
+ offset=_safe_int(location.attrib.get("offset"), default=0) or 0,
606
+ length=_safe_int(location.attrib.get("length"), default=0) or 0,
607
+ )
608
+ for location in annotation_element.findall("location")
609
+ ]
610
+ passage.annotations.append(
611
+ BioCAnnotation(
612
+ id=annotation_element.attrib.get("id", ""),
613
+ infons=read_infons(annotation_element),
614
+ locations=locations,
615
+ text=_text_of(annotation_element, "text", ""),
616
+ )
617
+ )
618
+ document.passages.append(passage)
619
+ collection.documents.append(document)
620
+ return collection
621
+
622
+
623
+ def write_bioc_xml(collection: BioCCollection, output_xml: PathLike) -> Path:
624
+ output = Path(output_xml)
625
+ output.parent.mkdir(parents=True, exist_ok=True)
626
+
627
+ root = ET.Element("collection")
628
+ for key, value in collection.infons.items():
629
+ _add_infon(root, key, value)
630
+ ET.SubElement(root, "source").text = collection.source
631
+ ET.SubElement(root, "date").text = collection.date
632
+ ET.SubElement(root, "key").text = collection.key
633
+
634
+ for document in collection.documents:
635
+ document_element = ET.SubElement(root, "document")
636
+ for key, value in document.infons.items():
637
+ _add_infon(document_element, key, value)
638
+ ET.SubElement(document_element, "id").text = document.id
639
+ for passage in document.passages:
640
+ passage_element = ET.SubElement(document_element, "passage")
641
+ for key, value in passage.infons.items():
642
+ _add_infon(passage_element, key, value)
643
+ ET.SubElement(passage_element, "offset").text = str(int(passage.offset))
644
+ ET.SubElement(passage_element, "text").text = passage.text
645
+ for index, annotation in enumerate(passage.annotations):
646
+ annotation_id = annotation.id or f"A{index}"
647
+ annotation_element = ET.SubElement(passage_element, "annotation", {"id": str(annotation_id)})
648
+ for key, value in annotation.infons.items():
649
+ _add_infon(annotation_element, key, value)
650
+ for location in annotation.locations:
651
+ ET.SubElement(
652
+ annotation_element,
653
+ "location",
654
+ {"offset": str(int(location.offset)), "length": str(int(location.length))},
655
+ )
656
+ ET.SubElement(annotation_element, "text").text = annotation.text
657
+
658
+ _indent_tree(root)
659
+ ET.ElementTree(root).write(output, encoding="utf-8", xml_declaration=True)
660
+ return output
661
+
662
+
663
+ def read_bioc_json(path: PathLike) -> BioCCollection:
664
+ return _collection_from_json_obj(_load_json_file(path))
665
+
666
+
667
+ def _collection_from_json_obj(obj: Any) -> BioCCollection:
668
+ """Parse common BioC JSON variants."""
669
+
670
+ obj = _unwrap_bioc_json_variant(obj)
671
+ if isinstance(obj, list):
672
+ obj = {"documents": obj}
673
+ if not isinstance(obj, Mapping):
674
+ raise ValueError("BioC JSON payload must be an object or a list of documents.")
675
+
676
+ if "collection" in obj and isinstance(obj["collection"], Mapping):
677
+ obj = obj["collection"]
678
+ elif "passages" in obj or "passage" in obj:
679
+ obj = {"documents": [obj]}
680
+
681
+ collection = BioCCollection(
682
+ source=str(obj.get("source") or ""),
683
+ date=str(obj.get("date") or ""),
684
+ key=str(obj.get("key") or ""),
685
+ infons=_coerce_infons(obj.get("infons") or obj.get("infon") or {}),
686
+ )
687
+
688
+ raw_documents = _mapping_list(obj.get("documents") or obj.get("document") or [])
689
+ for doc_obj in raw_documents:
690
+ document = BioCDocument(
691
+ id=str(doc_obj.get("id") or doc_obj.get("pmid") or doc_obj.get("pmcid") or ""),
692
+ infons=_coerce_infons(doc_obj.get("infons") or doc_obj.get("infon") or {}),
693
+ )
694
+ raw_passages = _mapping_list(doc_obj.get("passages") or doc_obj.get("passage") or [])
695
+ for passage_obj in raw_passages:
696
+ passage = BioCPassage(
697
+ infons=_coerce_infons(passage_obj.get("infons") or passage_obj.get("infon") or {}),
698
+ offset=_safe_int(passage_obj.get("offset"), default=0) or 0,
699
+ text=str(passage_obj.get("text") or ""),
700
+ )
701
+ raw_annotations = _mapping_list(passage_obj.get("annotations") or passage_obj.get("annotation") or [])
702
+ for annotation_obj in raw_annotations:
703
+ raw_locations = _mapping_list(annotation_obj.get("locations") or annotation_obj.get("location") or [])
704
+ locations = [
705
+ BioCLocation(
706
+ offset=_safe_int(location.get("offset"), default=0) or 0,
707
+ length=_safe_int(location.get("length"), default=0) or 0,
708
+ )
709
+ for location in raw_locations
710
+ if isinstance(location, Mapping)
711
+ ]
712
+ passage.annotations.append(
713
+ BioCAnnotation(
714
+ id=str(annotation_obj.get("id") or ""),
715
+ infons=_coerce_infons(annotation_obj.get("infons") or annotation_obj.get("infon") or {}),
716
+ locations=locations,
717
+ text=str(annotation_obj.get("text") or ""),
718
+ )
719
+ )
720
+ document.passages.append(passage)
721
+ collection.documents.append(document)
722
+ return collection
723
+
724
+
725
+ def _unwrap_bioc_json_variant(obj: Any) -> Any:
726
+ """Normalize wrapped BioC JSON payloads into the common parser shapes."""
727
+
728
+ if not isinstance(obj, Mapping):
729
+ return obj
730
+
731
+ pubtator_payload = obj.get("PubTator3")
732
+ if isinstance(pubtator_payload, list):
733
+ return {"documents": pubtator_payload}
734
+ if isinstance(pubtator_payload, Mapping):
735
+ return pubtator_payload
736
+
737
+ return obj
738
+
739
+
740
+ def read_generic_json(path: PathLike) -> BioCCollection:
741
+ return _collection_from_generic_json_obj(_load_json_file(path))
742
+
743
+
744
+ def _collection_from_generic_json_obj(obj: Any) -> BioCCollection:
745
+ """Parse generic document JSON such as BERN2 or HunFlair2-style outputs."""
746
+
747
+ collection_source = ""
748
+ raw_documents: list[Any]
749
+
750
+ if isinstance(obj, list):
751
+ raw_documents = list(obj)
752
+ elif isinstance(obj, Mapping):
753
+ if "documents" in obj and isinstance(obj["documents"], list) and not _looks_like_bioc_json_obj(obj):
754
+ raw_documents = list(obj["documents"])
755
+ collection_source = str(obj.get("source") or "")
756
+ else:
757
+ raw_documents = [obj]
758
+ collection_source = str(obj.get("source") or "")
759
+ else:
760
+ raise ValueError("JSON payload must be an object or a list of objects.")
761
+
762
+ collection = BioCCollection(source=collection_source, date="", key="generic JSON")
763
+ for index, doc_obj in enumerate(raw_documents, start=1):
764
+ if not isinstance(doc_obj, Mapping):
765
+ continue
766
+ document = _generic_json_document_to_bioc(doc_obj, default_id=f"doc{index}")
767
+ collection.documents.append(document)
768
+ return collection
769
+
770
+
771
+ def _generic_json_document_to_bioc(doc_obj: Mapping[str, Any], *, default_id: str) -> BioCDocument:
772
+ document_id = str(
773
+ doc_obj.get("id")
774
+ or doc_obj.get("document_id")
775
+ or doc_obj.get("doc_id")
776
+ or doc_obj.get("pmid")
777
+ or doc_obj.get("pmcid")
778
+ or default_id
779
+ )
780
+ document = BioCDocument(id=document_id, infons=_coerce_infons(doc_obj.get("infons") or {}))
781
+
782
+ title = str(doc_obj.get("title") or "")
783
+ abstract = str(doc_obj.get("abstract") or doc_obj.get("abstract_text") or "")
784
+ text = str(doc_obj.get("text") or doc_obj.get("content") or doc_obj.get("body") or "")
785
+ if not text:
786
+ sentences = doc_obj.get("sentences")
787
+ if isinstance(sentences, list):
788
+ text = " ".join(
789
+ str(
790
+ sentence.get("text")
791
+ if isinstance(sentence, Mapping)
792
+ else sentence or ""
793
+ )
794
+ for sentence in sentences
795
+ ).strip()
796
+ if not text:
797
+ text = f"{title} {abstract}".strip() if title and abstract else title or abstract
798
+
799
+ passage = BioCPassage(
800
+ infons=_coerce_infons(doc_obj.get("passage_infons") or {}),
801
+ offset=0,
802
+ text=text,
803
+ )
804
+
805
+ raw_annotations = _mapping_list(
806
+ doc_obj.get("entities")
807
+ or doc_obj.get("annotations")
808
+ or doc_obj.get("denotations")
809
+ or doc_obj.get("spans")
810
+ or []
811
+ )
812
+ for annotation_index, annotation_obj in enumerate(raw_annotations, start=1):
813
+ start, end = _generic_json_span(annotation_obj)
814
+ label, score = _generic_json_label_score(annotation_obj)
815
+ annotation_text = str(annotation_obj.get("text") or annotation_obj.get("mention") or "")
816
+ if not annotation_text and start is not None and end is not None and text:
817
+ annotation_text = text[max(0, start):max(0, end)]
818
+
819
+ infons = _coerce_infons(annotation_obj.get("infons") or {})
820
+ if label and "type" not in infons:
821
+ infons["type"] = label
822
+ if score is not None and "score" not in infons:
823
+ infons["score"] = score
824
+
825
+ locations: list[BioCLocation] = []
826
+ if start is not None and end is not None and end >= start:
827
+ locations.append(BioCLocation(offset=int(start), length=int(end) - int(start)))
828
+
829
+ passage.annotations.append(
830
+ BioCAnnotation(
831
+ id=str(annotation_obj.get("id") or f"A{annotation_index}"),
832
+ infons=infons,
833
+ locations=locations,
834
+ text=annotation_text,
835
+ )
836
+ )
837
+
838
+ document.passages.append(passage)
839
+ return document
840
+
841
+
842
+ def _generic_json_span(annotation_obj: Mapping[str, Any]) -> tuple[int | None, int | None]:
843
+ span = annotation_obj.get("span")
844
+ if isinstance(span, Mapping):
845
+ start = _safe_int(span.get("begin"), default=None)
846
+ if start is None:
847
+ start = _safe_int(span.get("start"), default=None)
848
+ if start is None:
849
+ start = _safe_int(span.get("offset"), default=None)
850
+ end = _safe_int(span.get("end"), default=None)
851
+ if end is None:
852
+ length = _safe_int(span.get("length"), default=None)
853
+ if start is not None and length is not None:
854
+ end = start + length
855
+ return start, end
856
+
857
+ start = _safe_int(annotation_obj.get("start"), default=None)
858
+ if start is None:
859
+ start = _safe_int(annotation_obj.get("begin"), default=None)
860
+ if start is None:
861
+ start = _safe_int(annotation_obj.get("start_pos"), default=None)
862
+ if start is None:
863
+ start = _safe_int(annotation_obj.get("offset"), default=None)
864
+
865
+ end = _safe_int(annotation_obj.get("end"), default=None)
866
+ if end is None:
867
+ end = _safe_int(annotation_obj.get("end_pos"), default=None)
868
+ if end is None:
869
+ length = _safe_int(annotation_obj.get("length"), default=None)
870
+ if start is not None and length is not None:
871
+ end = start + length
872
+
873
+ return start, end
874
+
875
+
876
+ def _generic_json_label_score(annotation_obj: Mapping[str, Any]) -> tuple[str, float | None]:
877
+ labels = annotation_obj.get("labels")
878
+ if isinstance(labels, list) and labels:
879
+ first = labels[0]
880
+ if isinstance(first, Mapping):
881
+ label = str(first.get("value") or first.get("label") or first.get("name") or "")
882
+ score = _safe_float(first.get("score"), default=None)
883
+ return label, score
884
+
885
+ label = str(
886
+ annotation_obj.get("obj")
887
+ or annotation_obj.get("type")
888
+ or annotation_obj.get("entity_type")
889
+ or annotation_obj.get("label")
890
+ or ""
891
+ )
892
+ score = _safe_float(
893
+ annotation_obj.get("score") if annotation_obj.get("score") is not None else annotation_obj.get("confidence"),
894
+ default=None,
895
+ )
896
+ return label, score
897
+
898
+
899
+ def write_bioc_json(collection: BioCCollection, output_json: PathLike) -> Path:
900
+ output = Path(output_json)
901
+ output.parent.mkdir(parents=True, exist_ok=True)
902
+ with output.open("w", encoding="utf-8") as handle:
903
+ json.dump(collection.to_json(), handle, ensure_ascii=False, indent=2)
904
+ handle.write("\n")
905
+ return output
906
+
907
+ # ---------------------------------------------------------------------------
908
+ # Conversion and temporary XML helpers
909
+ # ---------------------------------------------------------------------------
910
+ def convert_bioc_file(
911
+ input_path: PathLike,
912
+ output_path: PathLike,
913
+ *,
914
+ input_format: str = "auto",
915
+ output_format: str = "auto",
916
+ ) -> Path:
917
+ """Convert BioC XML/BioC JSON/JSON input to BioC XML or BioC JSON."""
918
+
919
+ collection = read_bioc_collection(input_path, input_format=input_format)
920
+ return write_bioc_collection(collection, output_path, output_format=output_format)
921
+
922
+
923
+ @contextmanager
924
+ def bioc_file_as_xml(input_path: PathLike, *, input_format: str = "auto") -> Iterator[Path]:
925
+ """Yield a BioC XML path for any supported document input format.
926
+
927
+ If ``input_path`` is already BioC XML it is yielded unchanged. Otherwise a
928
+ temporary XML copy is created and deleted when the context exits.
929
+ """
930
+
931
+ fmt = canonical_document_format(input_format, path=input_path)
932
+ p = Path(input_path)
933
+ if fmt == "bioc-xml":
934
+ yield p
935
+ return
936
+ with tempfile.TemporaryDirectory(prefix="celldn_input_bioc_") as tmp:
937
+ xml_path = Path(tmp) / "input.xml"
938
+ convert_bioc_file(p, xml_path, input_format=fmt, output_format="bioc-xml")
939
+ yield xml_path
940
+
941
+
942
+ def ensure_bioc_xml_file(
943
+ input_path: PathLike,
944
+ output_xml: PathLike,
945
+ *,
946
+ input_format: str = "auto",
947
+ ) -> Path:
948
+ """Create a BioC XML copy of any supported input file."""
949
+
950
+ return convert_bioc_file(input_path, output_xml, input_format=input_format, output_format="bioc-xml")
951
+
952
+
953
+ def merge_bioc_collections(collections: Iterable[BioCCollection]) -> BioCCollection:
954
+ """Merge several BioC collections into one without modifying predictions."""
955
+
956
+ merged = BioCCollection(source="CellDN", date="", key="merged BioC collection")
957
+ for collection in collections:
958
+ if not merged.source and collection.source:
959
+ merged.source = collection.source
960
+ if not merged.date and collection.date:
961
+ merged.date = collection.date
962
+ if not merged.key and collection.key:
963
+ merged.key = collection.key
964
+ merged.documents.extend(collection.documents)
965
+ return merged
966
+
967
+
968
+ def merge_existing_annotations(
969
+ prediction_path: PathLike,
970
+ original_path: PathLike,
971
+ output_path: PathLike,
972
+ *,
973
+ prediction_format: str = "auto",
974
+ original_format: str = "auto",
975
+ output_format: str = "auto",
976
+ keep_duplicates: bool = False,
977
+ ) -> Path:
978
+ """Copy annotations from ``original_path`` into ``prediction_path`` when missing.
979
+
980
+ This is useful for BERN2-style outputs: CellDN can add cell-type
981
+ annotations while preserving gene/disease/chemical/species annotations that
982
+ were present before prediction.
983
+ """
984
+
985
+ predictions = read_bioc_collection(prediction_path, input_format=prediction_format)
986
+ original = read_bioc_collection(original_path, input_format=original_format)
987
+ _merge_annotations_into_collection(predictions, original, keep_duplicates=keep_duplicates)
988
+ return write_bioc_collection(predictions, output_path, output_format=output_format)
989
+
990
+
991
+ def merge_annotations_into_collection(
992
+ target: BioCCollection,
993
+ source: BioCCollection,
994
+ *,
995
+ keep_duplicates: bool = False,
996
+ ) -> BioCCollection:
997
+ """Merge annotations from one in-memory collection into another."""
998
+
999
+ _merge_annotations_into_collection(target, source, keep_duplicates=keep_duplicates)
1000
+ return target
1001
+
1002
+
1003
+ def _merge_annotations_into_collection(
1004
+ target: BioCCollection,
1005
+ source: BioCCollection,
1006
+ *,
1007
+ keep_duplicates: bool = False,
1008
+ ) -> None:
1009
+ source_by_doc = {document.id: document for document in source.documents}
1010
+ for target_doc in target.documents:
1011
+ source_doc = source_by_doc.get(target_doc.id)
1012
+ if source_doc is None:
1013
+ continue
1014
+ for passage_index, target_passage in enumerate(target_doc.passages):
1015
+ if passage_index >= len(source_doc.passages):
1016
+ continue
1017
+ source_passage = source_doc.passages[passage_index]
1018
+ existing_keys = {annotation.duplicate_key() for annotation in target_passage.annotations}
1019
+ next_id = len(target_passage.annotations) + 1
1020
+ for annotation in source_passage.annotations:
1021
+ if not keep_duplicates and annotation.duplicate_key() in existing_keys:
1022
+ continue
1023
+ new_annotation = BioCAnnotation(
1024
+ id=annotation.id or f"S{next_id}",
1025
+ infons=dict(annotation.infons),
1026
+ locations=[BioCLocation(location.offset, location.length) for location in annotation.locations],
1027
+ text=annotation.text,
1028
+ )
1029
+ if new_annotation.id and any(
1030
+ existing.id == new_annotation.id
1031
+ for existing in target_passage.annotations
1032
+ ):
1033
+ new_annotation.id = f"S{next_id}"
1034
+ target_passage.annotations.append(new_annotation)
1035
+ existing_keys.add(new_annotation.duplicate_key())
1036
+ next_id += 1
1037
+
1038
+
1039
+ # ---------------------------------------------------------------------------
1040
+ # Existing XML-oriented CellDN helpers retained for compatibility
1041
+ # ---------------------------------------------------------------------------
1042
+ def _coerce_paths(paths: PathLike | Sequence[PathLike]) -> list[Path]:
1043
+ if isinstance(paths, (str, os.PathLike)):
1044
+ return [Path(paths)]
1045
+ return [Path(path) for path in paths]
1046
+
1047
+
1048
+ def _text_of(parent: ET.Element, tag: str, default: str = "") -> str:
1049
+ child = parent.find(tag)
1050
+ if child is None or child.text is None:
1051
+ return default
1052
+ return child.text
1053
+
1054
+
1055
+ def _int_text_of(parent: ET.Element, tag: str, default: int = 0) -> int:
1056
+ value = _text_of(parent, tag, str(default)).strip()
1057
+ try:
1058
+ return int(value)
1059
+ except ValueError:
1060
+ return default
1061
+
1062
+
1063
+ def _indent_tree(root: ET.Element) -> None:
1064
+ ET.indent(root, space=" ")
1065
+
1066
+
1067
+ def read_infons(element: ET.Element) -> dict[str, str]:
1068
+ """Read BioC ``infon`` children from an XML element."""
1069
+
1070
+ infons: dict[str, str] = {}
1071
+ for infon in element.findall("infon"):
1072
+ key = infon.attrib.get("key", "")
1073
+ if not key:
1074
+ continue
1075
+ infons[key] = infon.text or ""
1076
+ return infons
1077
+
1078
+
1079
+ def write_text_as_bioc_xml(
1080
+ text: str,
1081
+ output_xml: PathLike,
1082
+ *,
1083
+ document_id: str = "doc0",
1084
+ passage_offset: int = 0,
1085
+ source: str = "CellDN",
1086
+ key: str = "cell-type-extraction",
1087
+ passage_type: str | None = None,
1088
+ ) -> Path:
1089
+ """Write plain text as a one-document, one-passage BioC XML file."""
1090
+
1091
+ collection = _single_passage_collection(
1092
+ text,
1093
+ document_id=document_id,
1094
+ passage_offset=passage_offset,
1095
+ source=source,
1096
+ key=key,
1097
+ passage_type=passage_type,
1098
+ )
1099
+ return write_bioc_xml(collection, output_xml)
1100
+
1101
+
1102
+ def write_text_as_bioc_json(
1103
+ text: str,
1104
+ output_json: PathLike,
1105
+ *,
1106
+ document_id: str = "doc0",
1107
+ passage_offset: int = 0,
1108
+ source: str = "CellDN",
1109
+ key: str = "cell-type-extraction",
1110
+ passage_type: str | None = None,
1111
+ ) -> Path:
1112
+ """Write plain text as a one-document, one-passage BioC JSON file."""
1113
+
1114
+ collection = _single_passage_collection(
1115
+ text,
1116
+ document_id=document_id,
1117
+ passage_offset=passage_offset,
1118
+ source=source,
1119
+ key=key,
1120
+ passage_type=passage_type,
1121
+ )
1122
+ return write_bioc_json(collection, output_json)
1123
+
1124
+
1125
+ def _annotation_to_entity_span(annotation: BioCAnnotation, passage_offset: int) -> EntitySpan:
1126
+ infons = dict(annotation.infons)
1127
+ label = infons.get("type") or infons.get("label") or infons.get("entity_type") or ""
1128
+ text = annotation.text
1129
+ location = annotation.primary_location()
1130
+ if location is None:
1131
+ absolute_start = passage_offset
1132
+ length = len(text)
1133
+ else:
1134
+ absolute_start = int(location.offset)
1135
+ length = int(location.length)
1136
+ relative_start = absolute_start - int(passage_offset)
1137
+ relative_end = relative_start + length
1138
+ score: float | None = None
1139
+ if "score" in infons:
1140
+ score = _safe_float(infons["score"], default=None)
1141
+ return EntitySpan(
1142
+ start=relative_start,
1143
+ end=relative_end,
1144
+ label=label,
1145
+ text=text,
1146
+ score=score,
1147
+ infons=infons,
1148
+ )
1149
+
1150
+
1151
+ def iter_bioc_passage_records(
1152
+ input_xml: PathLike | Sequence[PathLike],
1153
+ *,
1154
+ include_entities: bool = True,
1155
+ input_format: str = "auto",
1156
+ ) -> Iterator[PassageRecord]:
1157
+ """Yield passage records from one or more BioC XML/JSON files."""
1158
+
1159
+ record_id = 0
1160
+ for path in _coerce_paths(input_xml):
1161
+ collection = read_bioc_collection(path, input_format=input_format)
1162
+ for record in iter_collection_passage_records(
1163
+ collection,
1164
+ include_entities=include_entities,
1165
+ source_path=str(path),
1166
+ start_record_id=record_id,
1167
+ ):
1168
+ yield record
1169
+ record_id = record.record_id + 1
1170
+
1171
+
1172
+ def iter_collection_passage_records(
1173
+ collection: BioCCollection,
1174
+ *,
1175
+ include_entities: bool = True,
1176
+ source_path: str = "",
1177
+ start_record_id: int = 0,
1178
+ ) -> Iterator[PassageRecord]:
1179
+ """Yield passage records from an in-memory BioC collection."""
1180
+
1181
+ record_id = start_record_id
1182
+ for document in collection.documents:
1183
+ for passage_id, passage in enumerate(document.passages):
1184
+ entities = []
1185
+ if include_entities:
1186
+ entities = [
1187
+ _annotation_to_entity_span(annotation, passage.offset)
1188
+ for annotation in passage.annotations
1189
+ ]
1190
+ yield PassageRecord(
1191
+ record_id=record_id,
1192
+ document_id=document.id,
1193
+ passage_id=passage_id,
1194
+ passage_offset=passage.offset,
1195
+ text=passage.text,
1196
+ entities=entities,
1197
+ infons=dict(passage.infons),
1198
+ source_path=source_path,
1199
+ )
1200
+ record_id += 1
1201
+
1202
+
1203
+ def read_bioc_annotations(
1204
+ input_xml: PathLike | Sequence[PathLike],
1205
+ *,
1206
+ input_format: str = "auto",
1207
+ ) -> list[PredictedEntity]:
1208
+ """Read all BioC annotations as entities with absolute offsets."""
1209
+
1210
+ annotations: list[PredictedEntity] = []
1211
+ for record in iter_bioc_passage_records(input_xml, include_entities=True, input_format=input_format):
1212
+ for entity in record.entities:
1213
+ annotations.append(
1214
+ entity.to_absolute(
1215
+ record.passage_offset,
1216
+ document_id=record.document_id,
1217
+ passage_id=record.passage_id,
1218
+ )
1219
+ )
1220
+ return annotations
1221
+
1222
+
1223
+ def _remove_existing_annotations(root: ET.Element) -> None:
1224
+ for passage in root.findall(".//passage"):
1225
+ for annotation in list(passage.findall("annotation")):
1226
+ passage.remove(annotation)
1227
+
1228
+
1229
+ def _group_predictions_by_passage(
1230
+ predicted_entities: Iterable[PredictedEntity],
1231
+ ) -> dict[tuple[str, int], list[PredictedEntity]]:
1232
+ grouped: dict[tuple[str, int], list[PredictedEntity]] = {}
1233
+ for entity in predicted_entities:
1234
+ key = (entity.document_id, int(entity.passage_id))
1235
+ grouped.setdefault(key, []).append(entity)
1236
+ for entities in grouped.values():
1237
+ entities.sort(key=lambda item: (int(item.start), int(item.end), str(item.label), str(item.text)))
1238
+ return grouped
1239
+
1240
+
1241
+ def apply_predicted_entities_to_collection(
1242
+ collection: BioCCollection,
1243
+ predicted_entities: Iterable[PredictedEntity],
1244
+ *,
1245
+ clear_existing: bool = True,
1246
+ ) -> BioCCollection:
1247
+ """Apply predicted entities to an in-memory BioC collection."""
1248
+
1249
+ grouped = _group_predictions_by_passage(predicted_entities)
1250
+ annotation_index = 1
1251
+
1252
+ for document in collection.documents:
1253
+ for passage_id, passage in enumerate(document.passages):
1254
+ if clear_existing:
1255
+ passage.annotations = []
1256
+
1257
+ candidates = list(grouped.get((document.id, passage_id), []))
1258
+ if document.id:
1259
+ candidates.extend(grouped.get(("", passage_id), []))
1260
+
1261
+ for entity in candidates:
1262
+ passage.annotations.append(
1263
+ BioCAnnotation(
1264
+ id=f"T{annotation_index}",
1265
+ infons={
1266
+ "type": str(entity.label),
1267
+ **{
1268
+ str(key): "" if value is None else str(value)
1269
+ for key, value in entity.infons.items()
1270
+ if str(key) != "type"
1271
+ },
1272
+ },
1273
+ locations=[
1274
+ BioCLocation(
1275
+ offset=int(entity.start),
1276
+ length=max(0, int(entity.end) - int(entity.start)),
1277
+ )
1278
+ ],
1279
+ text=entity.text,
1280
+ )
1281
+ )
1282
+ annotation_index += 1
1283
+
1284
+ return collection
1285
+
1286
+
1287
+ def _add_infon(parent: ET.Element, key: str, value: Any) -> None:
1288
+ ET.SubElement(parent, "infon", {"key": str(key)}).text = "" if value is None else str(value)
1289
+
1290
+
1291
+ def write_predictions_to_bioc_xml(
1292
+ input_xml: PathLike,
1293
+ output_xml: PathLike,
1294
+ *,
1295
+ predicted_entities: Iterable[PredictedEntity],
1296
+ clear_existing: bool = True,
1297
+ ) -> Path:
1298
+ """Write predicted entities into a BioC XML file."""
1299
+
1300
+ collection = read_bioc_collection(input_xml, input_format="bioc-xml")
1301
+ apply_predicted_entities_to_collection(
1302
+ collection,
1303
+ predicted_entities,
1304
+ clear_existing=clear_existing,
1305
+ )
1306
+ return write_bioc_collection(collection, output_xml, output_format="bioc-xml")
1307
+
1308
+
1309
+ def collection_summary(
1310
+ input_xml: PathLike | Sequence[PathLike],
1311
+ *,
1312
+ input_format: str = "auto",
1313
+ ) -> dict[str, int]:
1314
+ """Return simple counts for BioC XML/JSON files."""
1315
+
1316
+ documents = 0
1317
+ passages = 0
1318
+ annotations = 0
1319
+ for path in _coerce_paths(input_xml):
1320
+ collection = read_bioc_collection(path, input_format=input_format)
1321
+ documents += len(collection.documents)
1322
+ for document in collection.documents:
1323
+ passages += len(document.passages)
1324
+ annotations += sum(len(passage.annotations) for passage in document.passages)
1325
+ return {"documents": documents, "passages": passages, "annotations": annotations}
1326
+
1327
+
1328
+ # ---------------------------------------------------------------------------
1329
+ # Small helpers
1330
+ # ---------------------------------------------------------------------------
1331
+ def _load_json_file(path: PathLike) -> Any:
1332
+ with Path(path).open("r", encoding="utf-8") as handle:
1333
+ return json.load(handle)
1334
+
1335
+
1336
+ def _mapping_list(value: Any) -> list[Mapping[str, Any]]:
1337
+ if isinstance(value, Mapping):
1338
+ return [value]
1339
+ if isinstance(value, list):
1340
+ return [item for item in value if isinstance(item, Mapping)]
1341
+ return []
1342
+
1343
+
1344
+ def _single_passage_collection(
1345
+ text: str,
1346
+ *,
1347
+ document_id: str,
1348
+ passage_offset: int,
1349
+ source: str,
1350
+ key: str,
1351
+ passage_type: str | None,
1352
+ ) -> BioCCollection:
1353
+ return BioCCollection(
1354
+ source=source,
1355
+ date="",
1356
+ key=key,
1357
+ documents=[
1358
+ BioCDocument(
1359
+ id=document_id,
1360
+ passages=[
1361
+ BioCPassage(
1362
+ infons=({"type": passage_type} if passage_type is not None else {}),
1363
+ offset=int(passage_offset),
1364
+ text=text,
1365
+ )
1366
+ ],
1367
+ )
1368
+ ],
1369
+ )
1370
+
1371
+
1372
+ def _coerce_infons(value: Any) -> dict[str, str]:
1373
+ if value is None:
1374
+ return {}
1375
+ if isinstance(value, Mapping):
1376
+ return {str(key): "" if val is None else str(val) for key, val in value.items()}
1377
+ if isinstance(value, list):
1378
+ infons: dict[str, str] = {}
1379
+ for item in value:
1380
+ if isinstance(item, Mapping):
1381
+ key = item.get("key") or item.get("name")
1382
+ val = item.get("value") or item.get("text") or item.get("content") or ""
1383
+ if key is not None:
1384
+ infons[str(key)] = str(val)
1385
+ return infons
1386
+ return {}
1387
+
1388
+
1389
+ def _safe_int(value: Any, default: int | None = None) -> int | None:
1390
+ if value is None:
1391
+ return default
1392
+ try:
1393
+ return int(value)
1394
+ except (TypeError, ValueError):
1395
+ return default
1396
+
1397
+
1398
+ def _safe_float(value: Any, default: float | None = None) -> float | None:
1399
+ if value is None:
1400
+ return default
1401
+ try:
1402
+ return float(value)
1403
+ except (TypeError, ValueError):
1404
+ return default
1405
+
1406
+
1407
+ def clean_pmid_list(ids: Iterable[str] | str) -> list[str]:
1408
+ """Normalize a comma/space/newline separated PMID list."""
1409
+
1410
+ if isinstance(ids, str):
1411
+ candidates = re.split(r"[\s,;]+", ids.strip())
1412
+ else:
1413
+ candidates = []
1414
+ for item in ids:
1415
+ candidates.extend(re.split(r"[\s,;]+", str(item).strip()))
1416
+ seen: set[str] = set()
1417
+ result: list[str] = []
1418
+ for candidate in candidates:
1419
+ if not candidate or candidate in seen:
1420
+ continue
1421
+ seen.add(candidate)
1422
+ result.append(candidate)
1423
+ return result
1424
+
1425
+
1426
+ __all__ = [
1427
+ "BIOC_JSON_FORMATS",
1428
+ "BIOC_XML_FORMATS",
1429
+ "SUPPORTED_DOCUMENT_FORMATS",
1430
+ "BioCAnnotation",
1431
+ "BioCCollection",
1432
+ "BioCDocument",
1433
+ "BioCLocation",
1434
+ "BioCPassage",
1435
+ "EntitySpan",
1436
+ "PassageRecord",
1437
+ "PathLike",
1438
+ "PredictedEntity",
1439
+ "bioc_file_as_xml",
1440
+ "canonical_bioc_format",
1441
+ "canonical_document_format",
1442
+ "clean_pmid_list",
1443
+ "collection_summary",
1444
+ "convert_bioc_file",
1445
+ "detect_document_format",
1446
+ "ensure_bioc_xml_file",
1447
+ "iter_bioc_passage_records",
1448
+ "merge_bioc_collections",
1449
+ "merge_bioc_files",
1450
+ "merge_existing_annotations",
1451
+ "output_format_from_path",
1452
+ "read_bioc_annotations",
1453
+ "read_bioc_collection",
1454
+ "read_bioc_collection_from_string",
1455
+ "read_infons",
1456
+ "write_bioc_collection",
1457
+ "write_bioc_document_stream",
1458
+ "write_bioc_json",
1459
+ "write_bioc_xml",
1460
+ "write_predictions_to_bioc_xml",
1461
+ "write_text_as_bioc_json",
1462
+ "write_text_as_bioc_xml",
1463
+ ]