spanmark 0.1.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.
- spanmark/__init__.py +17 -0
- spanmark/_autosave.py +237 -0
- spanmark/_model.py +290 -0
- spanmark/_session.py +896 -0
- spanmark/_source.py +179 -0
- spanmark/_storage.py +97 -0
- spanmark/_version.py +24 -0
- spanmark/_widget.py +41 -0
- spanmark/static/widget.css +308 -0
- spanmark/static/widget.js +851 -0
- spanmark-0.1.0.dist-info/METADATA +436 -0
- spanmark-0.1.0.dist-info/RECORD +14 -0
- spanmark-0.1.0.dist-info/WHEEL +4 -0
- spanmark-0.1.0.dist-info/licenses/LICENSE +21 -0
spanmark/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""spanmark: a span annotation widget."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from spanmark._session import AnnotationSession
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("spanmark")
|
|
9
|
+
except PackageNotFoundError:
|
|
10
|
+
__version__ = "unknown"
|
|
11
|
+
finally:
|
|
12
|
+
del PackageNotFoundError, version
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AnnotationSession",
|
|
16
|
+
"__version__",
|
|
17
|
+
]
|
spanmark/_autosave.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Private append-only autosave overlay for annotation state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
from spanmark._storage import FileFingerprint, _fsync_directory
|
|
13
|
+
|
|
14
|
+
AUTOSAVE_CHECKPOINT_BYTES = 16 * 1024 * 1024
|
|
15
|
+
_AUTOSAVE_VERSION = 1
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class AutosaveRecovery:
|
|
20
|
+
"""Latest complete annotation states recovered from an autosave overlay."""
|
|
21
|
+
|
|
22
|
+
base: FileFingerprint
|
|
23
|
+
annotations: Mapping[str, Mapping[str, Any]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AutosaveOverlay:
|
|
27
|
+
"""An append-only overlay of per-document states beside a JSONL checkpoint."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, output_path: Path) -> None:
|
|
30
|
+
self.path = output_path.with_name(f".{output_path.name}.spanmark-autosave")
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def exists(self) -> bool:
|
|
34
|
+
return self.path.exists()
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def size(self) -> int:
|
|
38
|
+
try:
|
|
39
|
+
return self.path.stat().st_size
|
|
40
|
+
except FileNotFoundError:
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
def append(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
base: FileFingerprint,
|
|
47
|
+
document_id: str,
|
|
48
|
+
annotation: Mapping[str, Any],
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Durably append the latest complete annotation state for one document."""
|
|
51
|
+
if self.exists:
|
|
52
|
+
if self._read_header() != base:
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
f"Autosave overlay {self.path} does not match the current "
|
|
55
|
+
"JSONL checkpoint"
|
|
56
|
+
)
|
|
57
|
+
else:
|
|
58
|
+
self._write_header(base)
|
|
59
|
+
|
|
60
|
+
self._append_line(
|
|
61
|
+
{
|
|
62
|
+
"type": "state",
|
|
63
|
+
"id": document_id,
|
|
64
|
+
"annotation": dict(annotation),
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def recover(self) -> AutosaveRecovery | None:
|
|
69
|
+
"""Read complete overlay records, ignoring only a torn final line."""
|
|
70
|
+
try:
|
|
71
|
+
data = self.path.read_bytes()
|
|
72
|
+
except FileNotFoundError:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
lines = data.splitlines(keepends=True)
|
|
76
|
+
complete_lines: list[bytes] = []
|
|
77
|
+
for index, line in enumerate(lines):
|
|
78
|
+
if line.endswith(b"\n"):
|
|
79
|
+
complete_lines.append(line)
|
|
80
|
+
continue
|
|
81
|
+
if index == len(lines) - 1:
|
|
82
|
+
break
|
|
83
|
+
raise ValueError(f"Autosave overlay {self.path} contains a malformed line")
|
|
84
|
+
|
|
85
|
+
if not complete_lines:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
header = _decode_object(complete_lines[0], path=self.path, line_number=1)
|
|
89
|
+
base = _parse_header(header, path=self.path)
|
|
90
|
+
annotations: dict[str, Mapping[str, Any]] = {}
|
|
91
|
+
|
|
92
|
+
for line_number, raw_line in enumerate(complete_lines[1:], start=2):
|
|
93
|
+
raw = _decode_object(
|
|
94
|
+
raw_line,
|
|
95
|
+
path=self.path,
|
|
96
|
+
line_number=line_number,
|
|
97
|
+
)
|
|
98
|
+
if raw.get("type") != "state":
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"Invalid autosave record in {self.path} line {line_number}: "
|
|
101
|
+
"expected type 'state'"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
document_id = raw.get("id")
|
|
105
|
+
if not isinstance(document_id, str) or not document_id:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"Invalid autosave record in {self.path} line {line_number}: "
|
|
108
|
+
"id must be a non-empty string"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
annotation = raw.get("annotation")
|
|
112
|
+
if not isinstance(annotation, Mapping):
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"Invalid autosave record in {self.path} line {line_number}: "
|
|
115
|
+
"annotation must be an object"
|
|
116
|
+
)
|
|
117
|
+
annotations[document_id] = cast(Mapping[str, Any], annotation)
|
|
118
|
+
|
|
119
|
+
return AutosaveRecovery(base=base, annotations=annotations)
|
|
120
|
+
|
|
121
|
+
def discard(self) -> None:
|
|
122
|
+
"""Remove the overlay after its state is present in the JSONL checkpoint."""
|
|
123
|
+
try:
|
|
124
|
+
self.path.unlink()
|
|
125
|
+
except FileNotFoundError:
|
|
126
|
+
return
|
|
127
|
+
_fsync_directory(self.path.parent)
|
|
128
|
+
|
|
129
|
+
def _write_header(self, base: FileFingerprint) -> None:
|
|
130
|
+
header = _encode_line(
|
|
131
|
+
{
|
|
132
|
+
"type": "header",
|
|
133
|
+
"version": _AUTOSAVE_VERSION,
|
|
134
|
+
"base": {"size": base.size, "sha256": base.sha256},
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
created = False
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
with self.path.open("xb") as file:
|
|
141
|
+
created = True
|
|
142
|
+
file.write(header)
|
|
143
|
+
file.flush()
|
|
144
|
+
os.fsync(file.fileno())
|
|
145
|
+
_fsync_directory(self.path.parent)
|
|
146
|
+
except BaseException:
|
|
147
|
+
if created:
|
|
148
|
+
try:
|
|
149
|
+
self.path.unlink(missing_ok=True)
|
|
150
|
+
_fsync_directory(self.path.parent)
|
|
151
|
+
except OSError:
|
|
152
|
+
pass
|
|
153
|
+
raise
|
|
154
|
+
|
|
155
|
+
def _read_header(self) -> FileFingerprint:
|
|
156
|
+
with self.path.open("rb") as file:
|
|
157
|
+
raw_line = file.readline()
|
|
158
|
+
|
|
159
|
+
if not raw_line.endswith(b"\n"):
|
|
160
|
+
raise ValueError(f"Autosave overlay {self.path} has an incomplete header")
|
|
161
|
+
|
|
162
|
+
raw = _decode_object(raw_line, path=self.path, line_number=1)
|
|
163
|
+
return _parse_header(raw, path=self.path)
|
|
164
|
+
|
|
165
|
+
def _append_line(self, record: Mapping[str, Any]) -> None:
|
|
166
|
+
line = _encode_line(record)
|
|
167
|
+
previous_size = self.path.stat().st_size
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
with self.path.open("ab") as file:
|
|
171
|
+
file.write(line)
|
|
172
|
+
file.flush()
|
|
173
|
+
os.fsync(file.fileno())
|
|
174
|
+
except BaseException:
|
|
175
|
+
try:
|
|
176
|
+
with self.path.open("r+b") as file:
|
|
177
|
+
file.truncate(previous_size)
|
|
178
|
+
file.flush()
|
|
179
|
+
os.fsync(file.fileno())
|
|
180
|
+
except OSError:
|
|
181
|
+
pass
|
|
182
|
+
raise
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _encode_line(record: Mapping[str, Any]) -> bytes:
|
|
186
|
+
return (
|
|
187
|
+
json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
188
|
+
).encode("utf-8")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _decode_object(
|
|
192
|
+
raw_line: bytes,
|
|
193
|
+
*,
|
|
194
|
+
path: Path,
|
|
195
|
+
line_number: int,
|
|
196
|
+
) -> Mapping[str, Any]:
|
|
197
|
+
try:
|
|
198
|
+
text = raw_line.decode("utf-8")
|
|
199
|
+
except UnicodeDecodeError as exc:
|
|
200
|
+
raise ValueError(
|
|
201
|
+
f"Invalid UTF-8 in autosave overlay {path} line {line_number}"
|
|
202
|
+
) from exc
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
raw: object = json.loads(text)
|
|
206
|
+
except json.JSONDecodeError as exc:
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f"Invalid JSON in autosave overlay {path} line {line_number}"
|
|
209
|
+
) from exc
|
|
210
|
+
|
|
211
|
+
if not isinstance(raw, Mapping):
|
|
212
|
+
raise ValueError(
|
|
213
|
+
f"Autosave overlay {path} line {line_number} must contain an object"
|
|
214
|
+
)
|
|
215
|
+
return cast(Mapping[str, Any], raw)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _parse_header(raw: Mapping[str, Any], *, path: Path) -> FileFingerprint:
|
|
219
|
+
if raw.get("type") != "header" or raw.get("version") != _AUTOSAVE_VERSION:
|
|
220
|
+
raise ValueError(f"Autosave overlay {path} has an unsupported header")
|
|
221
|
+
|
|
222
|
+
base = raw.get("base")
|
|
223
|
+
if not isinstance(base, Mapping):
|
|
224
|
+
raise ValueError(f"Autosave overlay {path} header has no valid base")
|
|
225
|
+
|
|
226
|
+
size = base.get("size")
|
|
227
|
+
digest = base.get("sha256")
|
|
228
|
+
if (
|
|
229
|
+
not isinstance(size, int)
|
|
230
|
+
or size < 0
|
|
231
|
+
or not isinstance(digest, str)
|
|
232
|
+
or len(digest) != 64
|
|
233
|
+
or any(character not in "0123456789abcdef" for character in digest.lower())
|
|
234
|
+
):
|
|
235
|
+
raise ValueError(f"Autosave overlay {path} header has an invalid base")
|
|
236
|
+
|
|
237
|
+
return FileFingerprint(size=size, sha256=digest)
|
spanmark/_model.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Immutable annotation-domain models and validation helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
ANNOTATION_SOURCES = frozenset({"suggestion", "user"})
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class Span:
|
|
14
|
+
"""A character span using Python half-open offsets.
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
The right-open span [2, 6):
|
|
18
|
+
|
|
19
|
+
start=2 end=6
|
|
20
|
+
↓ ↓
|
|
21
|
+
┌─┐ ┌─┐│┌─┐ ┌─┐ ┌─┐ ┌─┐│┌─┐ ┌─┐ ┌─┐
|
|
22
|
+
│0│ │1│││2│ │3│ │4│ │5│││6│ │7│ │8│
|
|
23
|
+
└─┘ └─┘│└─┘ └─┘ └─┘ └─┘│└─┘ └─┘ └─┘
|
|
24
|
+
└───────────────┘
|
|
25
|
+
covers 2, 3, 4, 5 (length 6 − 2 = 4)
|
|
26
|
+
```
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
start: int
|
|
30
|
+
end: int
|
|
31
|
+
label: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class SuggestionSpan(Span):
|
|
36
|
+
"""A model-provided suggestion, optionally carrying score/id metadata."""
|
|
37
|
+
|
|
38
|
+
score: float | None = None
|
|
39
|
+
id: str | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class WorkingSpan(Span):
|
|
44
|
+
"""A span as represented while annotating."""
|
|
45
|
+
|
|
46
|
+
source: str = "user"
|
|
47
|
+
id: str = ""
|
|
48
|
+
score: float | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class Document:
|
|
53
|
+
"""One annotation example."""
|
|
54
|
+
|
|
55
|
+
id: str
|
|
56
|
+
text: str
|
|
57
|
+
meta: Mapping[str, Any]
|
|
58
|
+
suggestions: tuple[SuggestionSpan, ...] = ()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class DocumentState:
|
|
63
|
+
"""Current immutable annotation state for one document."""
|
|
64
|
+
|
|
65
|
+
spans: tuple[WorkingSpan, ...] = ()
|
|
66
|
+
answer: str = ""
|
|
67
|
+
flagged: bool = False
|
|
68
|
+
materialized: bool = False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def normalize_document(raw: Mapping[str, Any], index: int) -> Document:
|
|
72
|
+
"""Validate and normalize one input record into the annotation view."""
|
|
73
|
+
if not isinstance(raw, Mapping):
|
|
74
|
+
raise TypeError(f"Document at index {index} must be a mapping")
|
|
75
|
+
if "id" not in raw:
|
|
76
|
+
raise ValueError(f"Document at index {index} has no 'id' field")
|
|
77
|
+
if "text" not in raw:
|
|
78
|
+
raise ValueError(f"Document at index {index} has no 'text' field")
|
|
79
|
+
|
|
80
|
+
raw_id = raw["id"]
|
|
81
|
+
if not isinstance(raw_id, str):
|
|
82
|
+
raise TypeError(f"Document at index {index} 'id' must be a string")
|
|
83
|
+
if not raw_id:
|
|
84
|
+
raise ValueError(f"Document at index {index} has an empty 'id' field")
|
|
85
|
+
doc_id = raw_id
|
|
86
|
+
|
|
87
|
+
raw_text = raw["text"]
|
|
88
|
+
if not isinstance(raw_text, str):
|
|
89
|
+
raise TypeError(f"Document {doc_id!r} 'text' must be a string")
|
|
90
|
+
text = raw_text
|
|
91
|
+
|
|
92
|
+
raw_meta = raw.get("meta")
|
|
93
|
+
if raw_meta is None:
|
|
94
|
+
meta: dict[str, Any] = {}
|
|
95
|
+
elif isinstance(raw_meta, Mapping):
|
|
96
|
+
meta = dict(raw_meta)
|
|
97
|
+
else:
|
|
98
|
+
raise TypeError(f"Document {doc_id!r} 'meta' must be an object or null")
|
|
99
|
+
|
|
100
|
+
raw_suggestions = raw.get("suggestions")
|
|
101
|
+
suggestions: list[dict[str, int | str | None]] | Any = (
|
|
102
|
+
[] if raw_suggestions is None else raw_suggestions
|
|
103
|
+
)
|
|
104
|
+
if isinstance(suggestions, (str, bytes, Mapping)) or not isinstance(
|
|
105
|
+
suggestions, Iterable
|
|
106
|
+
):
|
|
107
|
+
raise TypeError(
|
|
108
|
+
f"Document {doc_id!r} 'suggestions' must be a sequence of mappings"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return Document(
|
|
112
|
+
id=doc_id,
|
|
113
|
+
text=text,
|
|
114
|
+
meta=meta,
|
|
115
|
+
suggestions=normalize_suggestions(
|
|
116
|
+
cast(Iterable[Mapping[str, Any]], suggestions),
|
|
117
|
+
text=text,
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def normalize_suggestions(
|
|
123
|
+
suggestions: Iterable[Mapping[str, Any]],
|
|
124
|
+
*,
|
|
125
|
+
text: str,
|
|
126
|
+
) -> tuple[SuggestionSpan, ...]:
|
|
127
|
+
"""Validate and normalize model suggestions."""
|
|
128
|
+
result: list[SuggestionSpan] = []
|
|
129
|
+
|
|
130
|
+
for i, raw in enumerate(suggestions):
|
|
131
|
+
if not isinstance(raw, Mapping):
|
|
132
|
+
raise TypeError(f"Suggestion {i} must be a mapping")
|
|
133
|
+
|
|
134
|
+
start = int(raw["start"])
|
|
135
|
+
end = int(raw["end"])
|
|
136
|
+
label = str(raw["label"])
|
|
137
|
+
|
|
138
|
+
_validate_offsets(start, end, text=text, context=f"suggestion {i}")
|
|
139
|
+
|
|
140
|
+
result.append(
|
|
141
|
+
SuggestionSpan(
|
|
142
|
+
start=start,
|
|
143
|
+
end=end,
|
|
144
|
+
label=label,
|
|
145
|
+
score=(float(raw["score"]) if raw.get("score") is not None else None),
|
|
146
|
+
id=(str(raw["id"]) if raw.get("id") is not None else None),
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return tuple(sorted(result, key=lambda span: (span.start, span.end, span.label)))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def suggestion_to_working_span(
|
|
154
|
+
suggestion: SuggestionSpan,
|
|
155
|
+
index: int,
|
|
156
|
+
) -> WorkingSpan:
|
|
157
|
+
"""Convert a model suggestion into editable working state."""
|
|
158
|
+
working_id = f"suggestion-{index}"
|
|
159
|
+
if suggestion.id is not None:
|
|
160
|
+
working_id = f"{working_id}:{suggestion.id}"
|
|
161
|
+
|
|
162
|
+
return WorkingSpan(
|
|
163
|
+
start=suggestion.start,
|
|
164
|
+
end=suggestion.end,
|
|
165
|
+
label=suggestion.label,
|
|
166
|
+
source="suggestion",
|
|
167
|
+
id=working_id,
|
|
168
|
+
score=suggestion.score,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def validate_working_spans(
|
|
173
|
+
raw_spans: Iterable[Mapping[str, Any] | WorkingSpan],
|
|
174
|
+
*,
|
|
175
|
+
text: str,
|
|
176
|
+
labels: Sequence[str],
|
|
177
|
+
allow_overlaps: bool,
|
|
178
|
+
) -> tuple[WorkingSpan, ...]:
|
|
179
|
+
"""Validate browser or persisted annotation spans."""
|
|
180
|
+
clean: list[WorkingSpan] = []
|
|
181
|
+
allowed = set(labels)
|
|
182
|
+
|
|
183
|
+
for i, raw in enumerate(raw_spans):
|
|
184
|
+
if isinstance(raw, WorkingSpan):
|
|
185
|
+
span = raw
|
|
186
|
+
else:
|
|
187
|
+
start = int(raw["start"])
|
|
188
|
+
end = int(raw["end"])
|
|
189
|
+
label = str(raw["label"])
|
|
190
|
+
source = str(raw.get("source", "user"))
|
|
191
|
+
|
|
192
|
+
span = WorkingSpan(
|
|
193
|
+
start=start,
|
|
194
|
+
end=end,
|
|
195
|
+
label=label,
|
|
196
|
+
source=source,
|
|
197
|
+
id=str(raw.get("_id", raw.get("id", f"span-{i}"))),
|
|
198
|
+
score=(float(raw["score"]) if raw.get("score") is not None else None),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if span.label not in allowed:
|
|
202
|
+
raise ValueError(
|
|
203
|
+
f"Span {i} uses unknown label {span.label!r}. "
|
|
204
|
+
f"Allowed labels: {list(labels)}"
|
|
205
|
+
)
|
|
206
|
+
if span.source not in ANNOTATION_SOURCES:
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f"Span {i} has invalid source {span.source!r}. "
|
|
209
|
+
"Expected 'suggestion' or 'user'."
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
_validate_offsets(
|
|
213
|
+
span.start,
|
|
214
|
+
span.end,
|
|
215
|
+
text=text,
|
|
216
|
+
context=f"span {i}",
|
|
217
|
+
)
|
|
218
|
+
clean.append(span)
|
|
219
|
+
|
|
220
|
+
ordered = tuple(
|
|
221
|
+
sorted(clean, key=lambda span: (span.start, span.end, span.label, span.id))
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if not allow_overlaps:
|
|
225
|
+
raise_if_overlapping(ordered)
|
|
226
|
+
|
|
227
|
+
return ordered
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def raise_if_overlapping(
|
|
231
|
+
spans: Iterable[Span],
|
|
232
|
+
*,
|
|
233
|
+
context: str = "spans",
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Raise if any two spans overlap."""
|
|
236
|
+
ordered = sorted(spans, key=lambda span: (span.start, span.end, span.label))
|
|
237
|
+
for left, right in zip(ordered, ordered[1:]):
|
|
238
|
+
if left.end > right.start:
|
|
239
|
+
raise ValueError(
|
|
240
|
+
f"Overlapping {context} require allow_overlaps=True: "
|
|
241
|
+
f"{span_to_dict(left)} overlaps {span_to_dict(right)}"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def span_to_dict(span: Span) -> dict[str, Any]:
|
|
246
|
+
"""Serialize the common span fields."""
|
|
247
|
+
return {
|
|
248
|
+
"start": span.start,
|
|
249
|
+
"end": span.end,
|
|
250
|
+
"label": span.label,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def suggestion_to_dict(span: SuggestionSpan) -> dict[str, Any]:
|
|
255
|
+
"""Serialize a model suggestion."""
|
|
256
|
+
result = span_to_dict(span)
|
|
257
|
+
if span.score is not None:
|
|
258
|
+
result["score"] = span.score
|
|
259
|
+
if span.id is not None:
|
|
260
|
+
result["id"] = span.id
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def working_span_to_dict(span: WorkingSpan) -> dict[str, Any]:
|
|
265
|
+
"""Serialize a working span for the browser widget."""
|
|
266
|
+
result = annotation_span_to_dict(span)
|
|
267
|
+
result["_id"] = span.id
|
|
268
|
+
return result
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def annotation_span_to_dict(span: WorkingSpan) -> dict[str, Any]:
|
|
272
|
+
"""Serialize a working span into the persisted annotation schema."""
|
|
273
|
+
result = span_to_dict(span)
|
|
274
|
+
result["source"] = span.source
|
|
275
|
+
if span.score is not None:
|
|
276
|
+
result["score"] = span.score
|
|
277
|
+
return result
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _validate_offsets(
|
|
281
|
+
start: int,
|
|
282
|
+
end: int,
|
|
283
|
+
*,
|
|
284
|
+
text: str,
|
|
285
|
+
context: str,
|
|
286
|
+
) -> None:
|
|
287
|
+
if not (0 <= start < end <= len(text)):
|
|
288
|
+
raise ValueError(
|
|
289
|
+
f"Invalid {context} offsets ({start}, {end}) for text of length {len(text)}"
|
|
290
|
+
)
|