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.
@@ -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