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/__init__.py +40 -0
- celldn/cli.py +424 -0
- celldn/io.py +1463 -0
- celldn/mics/output_display.py +270 -0
- celldn/mics/pubtator3_celldn_display.py +347 -0
- celldn/normalization/__init__.py +38 -0
- celldn/normalization/abbreviations.py +351 -0
- celldn/normalization/linker.py +1281 -0
- celldn/normalization/ontology.py +145 -0
- celldn/normalization/stemmer.py +58 -0
- celldn/pipeline.py +1831 -0
- celldn/recognition/__init__.py +20 -0
- celldn/recognition/bioc.py +187 -0
- celldn/recognition/hf_utils.py +224 -0
- celldn/recognition/ner_common.py +446 -0
- celldn/recognition/predict.py +486 -0
- celldn/resources/abbreviations.tsv +2125 -0
- celldn/resources/cell_ontology_v2025-12-17.jsonl +3237 -0
- celldn/retrieval.py +334 -0
- celldn/runtime_logging.py +68 -0
- celldn-1.0.0.dist-info/METADATA +90 -0
- celldn-1.0.0.dist-info/RECORD +26 -0
- celldn-1.0.0.dist-info/WHEEL +5 -0
- celldn-1.0.0.dist-info/entry_points.txt +2 -0
- celldn-1.0.0.dist-info/licenses/LICENSE.txt +674 -0
- celldn-1.0.0.dist-info/top_level.txt +1 -0
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
|
+
]
|