lorebase-inference 0.2.3__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.
- lorebase_inference/__init__.py +70 -0
- lorebase_inference/__main__.py +6 -0
- lorebase_inference/chat.py +447 -0
- lorebase_inference/cli.py +214 -0
- lorebase_inference/errors.py +54 -0
- lorebase_inference/fake.py +317 -0
- lorebase_inference/interfaces.py +151 -0
- lorebase_inference/local_embeddings.py +58 -0
- lorebase_inference/local_relations.py +157 -0
- lorebase_inference/local_spans.py +93 -0
- lorebase_inference/registry.py +313 -0
- lorebase_inference/remote_embeddings.py +100 -0
- lorebase_inference/settings.py +338 -0
- lorebase_inference/transport.py +233 -0
- lorebase_inference-0.2.3.dist-info/METADATA +13 -0
- lorebase_inference-0.2.3.dist-info/RECORD +18 -0
- lorebase_inference-0.2.3.dist-info/WHEEL +4 -0
- lorebase_inference-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Vendor-neutral model interfaces and their result types (design 9.4).
|
|
2
|
+
|
|
3
|
+
Callers depend on these protocols only; the provider behind them is chosen by configuration.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Literal, Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
JsonSchema = Mapping[str, Any]
|
|
13
|
+
"""A JSON Schema document describing the expected structure of a model answer."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class Message:
|
|
18
|
+
"""One chat message."""
|
|
19
|
+
|
|
20
|
+
role: Literal["system", "user", "assistant"]
|
|
21
|
+
content: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class ChatOptions:
|
|
26
|
+
"""Per-call options; unset values fall back to the configured defaults."""
|
|
27
|
+
|
|
28
|
+
task: str | None = None
|
|
29
|
+
"""Task name used for per-task routing (for example `extraction`)."""
|
|
30
|
+
temperature: float | None = None
|
|
31
|
+
max_tokens: int | None = None
|
|
32
|
+
seed: int | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class Usage:
|
|
37
|
+
"""Token accounting for one call."""
|
|
38
|
+
|
|
39
|
+
input_tokens: int = 0
|
|
40
|
+
output_tokens: int = 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class ChatResult:
|
|
45
|
+
"""Parsed JSON answer with accounting."""
|
|
46
|
+
|
|
47
|
+
data: Any
|
|
48
|
+
"""The answer parsed from JSON; conforms to the requested schema."""
|
|
49
|
+
usage: Usage
|
|
50
|
+
model: str
|
|
51
|
+
"""Identifier of the model that produced the answer."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class Label:
|
|
56
|
+
"""An entity type a span extractor should look for."""
|
|
57
|
+
|
|
58
|
+
name: str
|
|
59
|
+
description: str = ""
|
|
60
|
+
examples: tuple[str, ...] = field(default_factory=tuple)
|
|
61
|
+
"""Surface forms known for this label; used by dictionary-based extractors."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class Span:
|
|
66
|
+
"""A labelled text span with character offsets (`end` exclusive) into the input text."""
|
|
67
|
+
|
|
68
|
+
label: str
|
|
69
|
+
text: str
|
|
70
|
+
start: int
|
|
71
|
+
end: int
|
|
72
|
+
score: float = 1.0
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
if not 0 <= self.start <= self.end:
|
|
76
|
+
raise ValueError(f"invalid span offsets {self.start}..{self.end}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class RelationLabel:
|
|
81
|
+
"""A relation type a relation extractor should look for between spans."""
|
|
82
|
+
|
|
83
|
+
name: str
|
|
84
|
+
description: str = ""
|
|
85
|
+
head_labels: tuple[str, ...] = field(default_factory=tuple)
|
|
86
|
+
"""Span labels allowed as head; empty allows any."""
|
|
87
|
+
tail_labels: tuple[str, ...] = field(default_factory=tuple)
|
|
88
|
+
"""Span labels allowed as tail; empty allows any."""
|
|
89
|
+
|
|
90
|
+
def allows(self, head: Span, tail: Span) -> bool:
|
|
91
|
+
"""True when the span labels satisfy the head and tail constraints."""
|
|
92
|
+
return (not self.head_labels or head.label in self.head_labels) and (
|
|
93
|
+
not self.tail_labels or tail.label in self.tail_labels
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class SpanRelation:
|
|
99
|
+
"""A candidate relation `label` between two spans of the same text."""
|
|
100
|
+
|
|
101
|
+
label: str
|
|
102
|
+
head: Span
|
|
103
|
+
tail: Span
|
|
104
|
+
score: float = 1.0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@runtime_checkable
|
|
108
|
+
class ChatModel(Protocol):
|
|
109
|
+
"""Chat completion constrained to a JSON schema."""
|
|
110
|
+
|
|
111
|
+
async def complete_json(
|
|
112
|
+
self,
|
|
113
|
+
messages: Sequence[Message],
|
|
114
|
+
schema: JsonSchema,
|
|
115
|
+
options: ChatOptions | None = None,
|
|
116
|
+
) -> ChatResult:
|
|
117
|
+
"""Answer `messages` with JSON that conforms to `schema`."""
|
|
118
|
+
...
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@runtime_checkable
|
|
122
|
+
class EmbeddingModel(Protocol):
|
|
123
|
+
"""Text embeddings of a fixed dimension."""
|
|
124
|
+
|
|
125
|
+
name: str
|
|
126
|
+
"""Model name; vectors are stored keyed by it."""
|
|
127
|
+
dims: int
|
|
128
|
+
|
|
129
|
+
async def embed(self, texts: Sequence[str]) -> list[list[float]]:
|
|
130
|
+
"""One vector of `dims` floats per input text, in order."""
|
|
131
|
+
...
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@runtime_checkable
|
|
135
|
+
class SpanExtractor(Protocol):
|
|
136
|
+
"""Tier-1 extraction of labelled spans from text."""
|
|
137
|
+
|
|
138
|
+
async def extract(self, text: str, labels: Sequence[Label]) -> list[Span]:
|
|
139
|
+
"""Spans found in `text` for the given labels, ordered by start offset."""
|
|
140
|
+
...
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@runtime_checkable
|
|
144
|
+
class RelationExtractor(Protocol):
|
|
145
|
+
"""Tier-1 extraction of candidate relations between already extracted spans."""
|
|
146
|
+
|
|
147
|
+
async def extract(
|
|
148
|
+
self, text: str, spans: Sequence[Span], labels: Sequence[RelationLabel]
|
|
149
|
+
) -> list[SpanRelation]:
|
|
150
|
+
"""Relations between `spans` (offsets into `text`), ordered by head then tail start."""
|
|
151
|
+
...
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""In-process CPU embeddings: lazy model load, batching, dimension check.
|
|
2
|
+
|
|
3
|
+
The model library is reached through a backend callable supplied by the registry, so this
|
|
4
|
+
module imports nothing heavy. The backend is built on first use (loading or downloading the
|
|
5
|
+
model takes seconds), in a worker thread so that the event loop is never blocked.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import threading
|
|
12
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
13
|
+
|
|
14
|
+
from lorebase_inference.errors import DimensionMismatchError
|
|
15
|
+
from lorebase_inference.settings import EmbeddingSettings
|
|
16
|
+
|
|
17
|
+
EmbedBackend = Callable[[Sequence[str]], Iterable[Sequence[float]]]
|
|
18
|
+
"""Embeds one batch of texts; yields one vector per text."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LocalEmbeddingModel:
|
|
22
|
+
"""`EmbeddingModel` running a local model through a backend."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self, embeddings: EmbeddingSettings, backend_factory: Callable[[], EmbedBackend]
|
|
26
|
+
) -> None:
|
|
27
|
+
self.name = embeddings.model
|
|
28
|
+
self.dims = embeddings.dims
|
|
29
|
+
self._batch_size = embeddings.batch_size
|
|
30
|
+
self._factory = backend_factory
|
|
31
|
+
self._backend: EmbedBackend | None = None
|
|
32
|
+
self._lock = threading.Lock()
|
|
33
|
+
|
|
34
|
+
async def embed(self, texts: Sequence[str]) -> list[list[float]]:
|
|
35
|
+
"""One vector of `dims` floats per text, in order."""
|
|
36
|
+
if not texts:
|
|
37
|
+
return []
|
|
38
|
+
return await asyncio.to_thread(self._embed, list(texts))
|
|
39
|
+
|
|
40
|
+
def _embed(self, texts: list[str]) -> list[list[float]]:
|
|
41
|
+
with self._lock:
|
|
42
|
+
if self._backend is None:
|
|
43
|
+
self._backend = self._factory()
|
|
44
|
+
vectors: list[list[float]] = []
|
|
45
|
+
for start in range(0, len(texts), self._batch_size):
|
|
46
|
+
batch = texts[start : start + self._batch_size]
|
|
47
|
+
vectors.extend([float(v) for v in vector] for vector in self._backend(batch))
|
|
48
|
+
if len(vectors) != len(texts):
|
|
49
|
+
raise DimensionMismatchError(
|
|
50
|
+
f"model {self.name!r} returned {len(vectors)} vectors for {len(texts)} texts"
|
|
51
|
+
)
|
|
52
|
+
for vector in vectors:
|
|
53
|
+
if len(vector) != self.dims:
|
|
54
|
+
raise DimensionMismatchError(
|
|
55
|
+
f"model {self.name!r} returned {len(vector)} dimensions, "
|
|
56
|
+
f"embeddings.dims is {self.dims}"
|
|
57
|
+
)
|
|
58
|
+
return vectors
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Tier-1 relation candidates between spans: a local model, or a deterministic fallback.
|
|
2
|
+
|
|
3
|
+
`LocalRelationExtractor` runs a zero-shot relation model behind a backend callable supplied by
|
|
4
|
+
the registry; this module tokenizes the text, maps spans to token ranges and maps predictions
|
|
5
|
+
back to spans.
|
|
6
|
+
|
|
7
|
+
`CooccurrenceRelationExtractor` needs no model and is the documented fallback when the
|
|
8
|
+
relation model cannot be installed (it is an optional extra, and its dependencies are heavy).
|
|
9
|
+
It pairs spans that share a sentence and emits every relation label whose head and tail
|
|
10
|
+
constraints the pair satisfies. It knows nothing about meaning: the score is a proximity value
|
|
11
|
+
`1 / (1 + words between the spans)`, not a probability, and the output is a superset of what a
|
|
12
|
+
model would propose. Downstream validation, evidence checks and review decide. Sentences end at
|
|
13
|
+
`.`, `!` or `?` followed by whitespace, and at blank lines; abbreviations therefore split
|
|
14
|
+
sentences, which only loses candidates.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import re
|
|
21
|
+
import threading
|
|
22
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from lorebase_inference.interfaces import RelationLabel, Span, SpanRelation
|
|
26
|
+
from lorebase_inference.local_spans import split_windows
|
|
27
|
+
from lorebase_inference.settings import CooccurrenceSettings, ExtractorSettings
|
|
28
|
+
|
|
29
|
+
RelationBackend = Callable[[list[str], list[list[Any]], list[str], float], list[Mapping[str, Any]]]
|
|
30
|
+
"""`(tokens, entities, label names, threshold)` to relations.
|
|
31
|
+
|
|
32
|
+
Entities are `[first token, last token (inclusive), span label, text]`. Each relation holds
|
|
33
|
+
`head_pos` and `tail_pos` (`[first token, end token (exclusive)]`), `label` and `score`.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
_TOKEN = re.compile(r"\w+(?:['\u2019-]\w+)*|[^\w\s]")
|
|
37
|
+
_SENTENCE_BREAK = re.compile(r"(?<=[.!?])\s+|\n\s*\n")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def sentence_bounds(text: str) -> list[tuple[int, int]]:
|
|
41
|
+
"""`(start, end)` offsets of the sentences of `text`, whitespace excluded."""
|
|
42
|
+
bounds: list[tuple[int, int]] = []
|
|
43
|
+
start = 0
|
|
44
|
+
for match in [*_SENTENCE_BREAK.finditer(text), None]:
|
|
45
|
+
end = match.start() if match else len(text)
|
|
46
|
+
if text[start:end].strip():
|
|
47
|
+
bounds.append((start, end))
|
|
48
|
+
start = match.end() if match else len(text)
|
|
49
|
+
return bounds
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CooccurrenceRelationExtractor:
|
|
53
|
+
"""`RelationExtractor` pairing spans of the same sentence, deterministically."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, settings: CooccurrenceSettings | None = None) -> None:
|
|
56
|
+
self._limit = (settings or CooccurrenceSettings()).max_pairs_per_sentence
|
|
57
|
+
|
|
58
|
+
async def extract(
|
|
59
|
+
self, text: str, spans: Sequence[Span], labels: Sequence[RelationLabel]
|
|
60
|
+
) -> list[SpanRelation]:
|
|
61
|
+
"""Candidate relations, ordered by head start, tail start and label."""
|
|
62
|
+
relations: list[SpanRelation] = []
|
|
63
|
+
ordered = sorted(spans, key=lambda s: (s.start, s.end, s.label))
|
|
64
|
+
for start, end in sentence_bounds(text):
|
|
65
|
+
inside = [s for s in ordered if s.start >= start and s.end <= end]
|
|
66
|
+
pairs = [
|
|
67
|
+
(a, b) for i, a in enumerate(inside) for b in inside[i + 1 :] if b.start >= a.end
|
|
68
|
+
]
|
|
69
|
+
pairs.sort(key=lambda p: (_gap_words(text, *p), p[0].start, p[1].start))
|
|
70
|
+
for head, tail in pairs[: self._limit]:
|
|
71
|
+
score = 1 / (1 + _gap_words(text, head, tail))
|
|
72
|
+
for label in labels:
|
|
73
|
+
if label.allows(head, tail):
|
|
74
|
+
relations.append(SpanRelation(label.name, head, tail, score))
|
|
75
|
+
elif label.allows(tail, head):
|
|
76
|
+
relations.append(SpanRelation(label.name, tail, head, score))
|
|
77
|
+
relations.sort(key=lambda r: (r.head.start, r.tail.start, r.label))
|
|
78
|
+
return relations
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _gap_words(text: str, head: Span, tail: Span) -> int:
|
|
82
|
+
return len(_TOKEN.findall(text[head.end : tail.start]))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class LocalRelationExtractor:
|
|
86
|
+
"""`RelationExtractor` running a local zero-shot relation model through a backend."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self, settings: ExtractorSettings, backend_factory: Callable[[], RelationBackend]
|
|
90
|
+
) -> None:
|
|
91
|
+
self._settings = settings
|
|
92
|
+
self._factory = backend_factory
|
|
93
|
+
self._backend: RelationBackend | None = None
|
|
94
|
+
self._lock = threading.Lock()
|
|
95
|
+
|
|
96
|
+
async def extract(
|
|
97
|
+
self, text: str, spans: Sequence[Span], labels: Sequence[RelationLabel]
|
|
98
|
+
) -> list[SpanRelation]:
|
|
99
|
+
"""Candidate relations between `spans`, ordered by head start, tail start and label."""
|
|
100
|
+
if not labels or len(spans) < 2:
|
|
101
|
+
return []
|
|
102
|
+
return await asyncio.to_thread(self._extract, text, list(spans), list(labels))
|
|
103
|
+
|
|
104
|
+
def _extract(
|
|
105
|
+
self, text: str, spans: list[Span], labels: list[RelationLabel]
|
|
106
|
+
) -> list[SpanRelation]:
|
|
107
|
+
spans = sorted(spans, key=lambda s: (s.start, s.end, s.label))
|
|
108
|
+
by_name = {label.name.casefold(): label for label in labels}
|
|
109
|
+
names = [label.name for label in labels]
|
|
110
|
+
found: dict[tuple[int, int, str], SpanRelation] = {}
|
|
111
|
+
with self._lock:
|
|
112
|
+
if self._backend is None:
|
|
113
|
+
self._backend = self._factory()
|
|
114
|
+
backend = self._backend
|
|
115
|
+
for offset, window in split_windows(text, self._settings.window_chars):
|
|
116
|
+
local = [s for s in spans if s.start >= offset and s.end <= offset + len(window)]
|
|
117
|
+
if len(local) < 2:
|
|
118
|
+
continue
|
|
119
|
+
tokens = [(m.group(), m.start(), m.end()) for m in _TOKEN.finditer(window)]
|
|
120
|
+
placed = _place(tokens, local, offset)
|
|
121
|
+
if len(placed) < 2:
|
|
122
|
+
continue
|
|
123
|
+
entities = [[first, last, span.label, span.text] for span, first, last in placed]
|
|
124
|
+
lookup = {(first, last): span for span, first, last in placed}
|
|
125
|
+
raw = backend([t[0] for t in tokens], entities, names, self._settings.threshold)
|
|
126
|
+
for item in raw:
|
|
127
|
+
label = by_name.get(str(item.get("label", "")).casefold())
|
|
128
|
+
head = _find(lookup, item.get("head_pos"))
|
|
129
|
+
tail = _find(lookup, item.get("tail_pos"))
|
|
130
|
+
if label is None or head is None or tail is None or head == tail:
|
|
131
|
+
continue
|
|
132
|
+
if not label.allows(head, tail):
|
|
133
|
+
continue
|
|
134
|
+
key = (head.start, tail.start, label.name)
|
|
135
|
+
score = float(item.get("score", 1.0))
|
|
136
|
+
if key not in found or found[key].score < score:
|
|
137
|
+
found[key] = SpanRelation(label.name, head, tail, score)
|
|
138
|
+
return sorted(found.values(), key=lambda r: (r.head.start, r.tail.start, r.label))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _place(
|
|
142
|
+
tokens: list[tuple[str, int, int]], spans: list[Span], offset: int
|
|
143
|
+
) -> list[tuple[Span, int, int]]:
|
|
144
|
+
"""Spans of the window with the inclusive `(first, last)` token indices covering them."""
|
|
145
|
+
placed: list[tuple[Span, int, int]] = []
|
|
146
|
+
for span in spans:
|
|
147
|
+
start, end = span.start - offset, span.end - offset
|
|
148
|
+
covered = [i for i, (_t, s, e) in enumerate(tokens) if e > start and s < end]
|
|
149
|
+
if covered:
|
|
150
|
+
placed.append((span, covered[0], covered[-1]))
|
|
151
|
+
return placed
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _find(lookup: dict[tuple[int, int], Span], position: Any) -> Span | None:
|
|
155
|
+
if isinstance(position, Sequence) and len(position) == 2:
|
|
156
|
+
return lookup.get((int(position[0]), int(position[1]) - 1))
|
|
157
|
+
return None
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Tier-1 zero-shot span extraction with a local model, behind a backend callable.
|
|
2
|
+
|
|
3
|
+
The registry supplies the backend (the only code that touches the model library); this module
|
|
4
|
+
handles what is independent of it: windowing of long texts, mapping labels, translating
|
|
5
|
+
offsets back to the input text, and running the model in a worker thread.
|
|
6
|
+
|
|
7
|
+
The models read a few hundred tokens at a time. Texts longer than `window_chars` are cut at
|
|
8
|
+
sentence ends or whitespace into consecutive windows, so an entity that straddles a cut is
|
|
9
|
+
missed; callers are expected to pass chunks (design 9.2, step 3) and the cut is a safety net.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import re
|
|
16
|
+
import threading
|
|
17
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from lorebase_inference.interfaces import Label, Span
|
|
21
|
+
from lorebase_inference.settings import ExtractorSettings
|
|
22
|
+
|
|
23
|
+
SpanBackend = Callable[[str, list[str], float], list[Mapping[str, Any]]]
|
|
24
|
+
"""`(text, label names, threshold)` to entities with `start`, `end`, `label` and `score`."""
|
|
25
|
+
|
|
26
|
+
_SENTENCE_END = re.compile(r"[.!?]\s|\n")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def split_windows(text: str, size: int) -> Iterator[tuple[int, str]]:
|
|
30
|
+
"""Consecutive `(offset, window)` pieces of at most `size` characters covering `text`.
|
|
31
|
+
|
|
32
|
+
A cut prefers the last sentence end in the second half of the window, then the last
|
|
33
|
+
whitespace, then falls back to a hard cut. Windows that are only whitespace are skipped.
|
|
34
|
+
"""
|
|
35
|
+
position, length = 0, len(text)
|
|
36
|
+
while position < length:
|
|
37
|
+
end = min(length, position + size)
|
|
38
|
+
cut = end - position
|
|
39
|
+
if end < length:
|
|
40
|
+
piece = text[position:end]
|
|
41
|
+
ends = [m.end() for m in _SENTENCE_END.finditer(piece) if m.end() > size // 2]
|
|
42
|
+
if ends:
|
|
43
|
+
cut = ends[-1]
|
|
44
|
+
else:
|
|
45
|
+
space = max(piece.rfind(" "), piece.rfind("\n"), piece.rfind("\t"))
|
|
46
|
+
cut = space + 1 if space > 0 else cut
|
|
47
|
+
if text[position : position + cut].strip():
|
|
48
|
+
yield position, text[position : position + cut]
|
|
49
|
+
position += cut
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LocalSpanExtractor:
|
|
53
|
+
"""`SpanExtractor` running a local zero-shot model through a backend."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self, settings: ExtractorSettings, backend_factory: Callable[[], SpanBackend]
|
|
57
|
+
) -> None:
|
|
58
|
+
self._settings = settings
|
|
59
|
+
self._factory = backend_factory
|
|
60
|
+
self._backend: SpanBackend | None = None
|
|
61
|
+
self._lock = threading.Lock()
|
|
62
|
+
|
|
63
|
+
async def extract(self, text: str, labels: Sequence[Label]) -> list[Span]:
|
|
64
|
+
"""Spans of the given labels in `text`, ordered by start offset."""
|
|
65
|
+
if not labels or not text.strip():
|
|
66
|
+
return []
|
|
67
|
+
return await asyncio.to_thread(self._extract, text, list(labels))
|
|
68
|
+
|
|
69
|
+
def _extract(self, text: str, labels: list[Label]) -> list[Span]:
|
|
70
|
+
names = list(dict.fromkeys(label.name for label in labels))
|
|
71
|
+
by_folded = {name.casefold(): name for name in names}
|
|
72
|
+
spans: list[Span] = []
|
|
73
|
+
with self._lock:
|
|
74
|
+
if self._backend is None:
|
|
75
|
+
self._backend = self._factory()
|
|
76
|
+
backend = self._backend
|
|
77
|
+
for offset, window in split_windows(text, self._settings.window_chars):
|
|
78
|
+
for entity in backend(window, names, self._settings.threshold):
|
|
79
|
+
name = by_folded.get(str(entity.get("label", "")).casefold())
|
|
80
|
+
start, end = int(entity["start"]) + offset, int(entity["end"]) + offset
|
|
81
|
+
if name is None or not 0 <= start < end <= len(text):
|
|
82
|
+
continue
|
|
83
|
+
spans.append(
|
|
84
|
+
Span(
|
|
85
|
+
label=name,
|
|
86
|
+
text=text[start:end],
|
|
87
|
+
start=start,
|
|
88
|
+
end=end,
|
|
89
|
+
score=float(entity.get("score", 1.0)),
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
spans.sort(key=lambda s: (s.start, s.end, s.label))
|
|
93
|
+
return spans
|