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 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
+ )