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,214 @@
|
|
|
1
|
+
"""`lorebase-inference check` and `lorebase-inference embed`.
|
|
2
|
+
|
|
3
|
+
`check` probes the configured chat and embedding providers: it sends one tiny prompt to the
|
|
4
|
+
chat model and one short text to the embedding model, and prints the model, latency and vector
|
|
5
|
+
size. Exit status 0 means every probed provider answered correctly.
|
|
6
|
+
|
|
7
|
+
`embed TEXT` prints the vector of `TEXT` from the configured embedding model as JSON
|
|
8
|
+
(`{"model", "dims", "vector"}`). With `--query`, `embeddings.query_prefix` is put before the
|
|
9
|
+
text: that is the query vector of semantic and hybrid search, computed on the caller's side
|
|
10
|
+
because Keeper and Herald never call a model (for example
|
|
11
|
+
`lorebase-inference embed --query "late deliveries" | lorebase search "late deliveries" --mode
|
|
12
|
+
hybrid --vector-file -`). Documents are embedded by Scribe with `embeddings.document_prefix`.
|
|
13
|
+
|
|
14
|
+
These are the only code paths of the package meant to reach the network on demand.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import Sequence
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from lorebase_inference.chat import EndpointChatModel
|
|
30
|
+
from lorebase_inference.errors import InferenceError
|
|
31
|
+
from lorebase_inference.interfaces import ChatOptions, Message
|
|
32
|
+
from lorebase_inference.registry import create_chat_model, create_embedding_model
|
|
33
|
+
from lorebase_inference.settings import InferenceSettings, inference_settings
|
|
34
|
+
from lorebase_platform.logging import configure_logging
|
|
35
|
+
from lorebase_platform.settings import ConfigError, load_settings
|
|
36
|
+
|
|
37
|
+
SERVICE_NAME = "inference"
|
|
38
|
+
PROBE_SCHEMA: dict[str, Any] = {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"properties": {"ok": {"type": "boolean"}},
|
|
41
|
+
"required": ["ok"],
|
|
42
|
+
"additionalProperties": False,
|
|
43
|
+
}
|
|
44
|
+
PROBE_MESSAGES = (
|
|
45
|
+
Message("system", "You are a connectivity probe. Output JSON only."),
|
|
46
|
+
Message("user", 'Set "ok" to true.'),
|
|
47
|
+
)
|
|
48
|
+
PROBE_TEXT = "connectivity probe"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(slots=True)
|
|
52
|
+
class CheckResult:
|
|
53
|
+
"""Outcome of probing one component."""
|
|
54
|
+
|
|
55
|
+
component: str
|
|
56
|
+
ok: bool
|
|
57
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
58
|
+
error: str | None = None
|
|
59
|
+
|
|
60
|
+
def as_dict(self) -> dict[str, Any]:
|
|
61
|
+
return {
|
|
62
|
+
"component": self.component,
|
|
63
|
+
"ok": self.ok,
|
|
64
|
+
"details": self.details,
|
|
65
|
+
"error": self.error,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
def line(self) -> str:
|
|
69
|
+
status = "ok" if self.ok else "FAILED"
|
|
70
|
+
details = " ".join(f"{k}={v}" for k, v in self.details.items())
|
|
71
|
+
return f"{self.component:<11}{status:<7}{details if self.ok else self.error}".rstrip()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def check_chat(settings: InferenceSettings, task: str | None = None) -> CheckResult:
|
|
75
|
+
"""Ask the configured chat model for a trivial JSON answer."""
|
|
76
|
+
llm = settings.llm.for_task(task)
|
|
77
|
+
details: dict[str, Any] = {"provider": llm.provider, "model": llm.model}
|
|
78
|
+
model = create_chat_model(settings, task)
|
|
79
|
+
started = time.perf_counter()
|
|
80
|
+
try:
|
|
81
|
+
result = await model.complete_json(PROBE_MESSAGES, PROBE_SCHEMA, ChatOptions(task=task))
|
|
82
|
+
except (InferenceError, ValueError) as exc:
|
|
83
|
+
return CheckResult("chat", False, details, f"{type(exc).__name__}: {exc}")
|
|
84
|
+
finally:
|
|
85
|
+
if isinstance(model, EndpointChatModel):
|
|
86
|
+
await model.aclose()
|
|
87
|
+
details.update(
|
|
88
|
+
model=result.model,
|
|
89
|
+
latency_ms=round((time.perf_counter() - started) * 1000),
|
|
90
|
+
tokens=f"{result.usage.input_tokens}/{result.usage.output_tokens}",
|
|
91
|
+
)
|
|
92
|
+
if isinstance(model, EndpointChatModel):
|
|
93
|
+
details["structured_output"] = model.output_mode
|
|
94
|
+
return CheckResult("chat", True, details)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def check_embeddings(settings: InferenceSettings) -> CheckResult:
|
|
98
|
+
"""Embed a short text and compare the vector size with `embeddings.dims`."""
|
|
99
|
+
embeddings = settings.embeddings
|
|
100
|
+
details: dict[str, Any] = {"provider": embeddings.provider, "model": embeddings.model}
|
|
101
|
+
model = create_embedding_model(settings)
|
|
102
|
+
started = time.perf_counter()
|
|
103
|
+
try:
|
|
104
|
+
vectors = await model.embed([PROBE_TEXT])
|
|
105
|
+
except (InferenceError, ValueError) as exc:
|
|
106
|
+
return CheckResult("embeddings", False, details, f"{type(exc).__name__}: {exc}")
|
|
107
|
+
finally:
|
|
108
|
+
close = getattr(model, "aclose", None)
|
|
109
|
+
if close is not None:
|
|
110
|
+
await close()
|
|
111
|
+
dims = len(vectors[0]) if vectors else 0
|
|
112
|
+
details.update(dims=dims, latency_ms=round((time.perf_counter() - started) * 1000))
|
|
113
|
+
if dims != embeddings.dims:
|
|
114
|
+
return CheckResult(
|
|
115
|
+
"embeddings",
|
|
116
|
+
False,
|
|
117
|
+
details,
|
|
118
|
+
f"returned {dims} dimensions, embeddings.dims is {embeddings.dims}",
|
|
119
|
+
)
|
|
120
|
+
return CheckResult("embeddings", True, details)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def run_checks(
|
|
124
|
+
settings: InferenceSettings,
|
|
125
|
+
*,
|
|
126
|
+
task: str | None = None,
|
|
127
|
+
chat: bool = True,
|
|
128
|
+
embeddings: bool = True,
|
|
129
|
+
) -> list[CheckResult]:
|
|
130
|
+
"""Probe the selected components; a failure to even create a provider is a failed result."""
|
|
131
|
+
results: list[CheckResult] = []
|
|
132
|
+
probes = []
|
|
133
|
+
if chat:
|
|
134
|
+
probes.append(("chat", check_chat(settings, task)))
|
|
135
|
+
if embeddings:
|
|
136
|
+
probes.append(("embeddings", check_embeddings(settings)))
|
|
137
|
+
for component, probe in probes:
|
|
138
|
+
try:
|
|
139
|
+
results.append(await probe)
|
|
140
|
+
except (InferenceError, ValueError) as exc:
|
|
141
|
+
results.append(CheckResult(component, False, error=f"{type(exc).__name__}: {exc}"))
|
|
142
|
+
return results
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def embed_query(
|
|
146
|
+
settings: InferenceSettings, text: str, *, query: bool = False
|
|
147
|
+
) -> dict[str, Any]:
|
|
148
|
+
"""`{"model", "dims", "vector"}` of `text`, preceded by `embeddings.query_prefix` when
|
|
149
|
+
`query`; `dims` is checked against `embeddings.dims`."""
|
|
150
|
+
model = create_embedding_model(settings)
|
|
151
|
+
try:
|
|
152
|
+
prefix = settings.embeddings.query_prefix if query else ""
|
|
153
|
+
vectors = await model.embed([prefix + text])
|
|
154
|
+
finally:
|
|
155
|
+
close = getattr(model, "aclose", None)
|
|
156
|
+
if close is not None:
|
|
157
|
+
await close()
|
|
158
|
+
vector = vectors[0] if vectors else []
|
|
159
|
+
if len(vector) != settings.embeddings.dims:
|
|
160
|
+
raise InferenceError(
|
|
161
|
+
f"returned {len(vector)} dimensions, embeddings.dims is {settings.embeddings.dims}"
|
|
162
|
+
)
|
|
163
|
+
return {"model": model.name, "dims": len(vector), "vector": vector}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parser() -> argparse.ArgumentParser:
|
|
167
|
+
parser = argparse.ArgumentParser(prog="lorebase-inference", description=__doc__)
|
|
168
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
169
|
+
check = commands.add_parser(
|
|
170
|
+
"check", help="call the configured chat and embedding providers with a tiny prompt"
|
|
171
|
+
)
|
|
172
|
+
check.add_argument("--task", help="probe the chat model configured for this task name")
|
|
173
|
+
check.add_argument("--no-chat", action="store_true", help="skip the chat probe")
|
|
174
|
+
check.add_argument("--no-embeddings", action="store_true", help="skip the embeddings probe")
|
|
175
|
+
check.add_argument("--json", action="store_true", help="print the results as JSON")
|
|
176
|
+
embed = commands.add_parser(
|
|
177
|
+
"embed", help="print the query vector of a text from the configured embedding model"
|
|
178
|
+
)
|
|
179
|
+
embed.add_argument("text", help="text to embed")
|
|
180
|
+
embed.add_argument(
|
|
181
|
+
"--query",
|
|
182
|
+
action="store_true",
|
|
183
|
+
help="embed a search query: put embeddings.query_prefix before the text",
|
|
184
|
+
)
|
|
185
|
+
return parser
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
189
|
+
"""Entry point; exits with status 1 when a probe fails or the configuration is invalid."""
|
|
190
|
+
args = _parser().parse_args(argv)
|
|
191
|
+
try:
|
|
192
|
+
settings = load_settings()
|
|
193
|
+
inference = inference_settings(settings)
|
|
194
|
+
except ConfigError as exc:
|
|
195
|
+
sys.exit(str(exc))
|
|
196
|
+
configure_logging(settings.logging, service=SERVICE_NAME)
|
|
197
|
+
logging.getLogger("httpx").setLevel(logging.WARNING) # one INFO line per request is noise here
|
|
198
|
+
if args.command == "embed":
|
|
199
|
+
try:
|
|
200
|
+
print(json.dumps(asyncio.run(embed_query(inference, args.text, query=args.query))))
|
|
201
|
+
except (InferenceError, ValueError) as exc:
|
|
202
|
+
sys.exit(f"{type(exc).__name__}: {exc}")
|
|
203
|
+
sys.exit(0)
|
|
204
|
+
results = asyncio.run(
|
|
205
|
+
run_checks(
|
|
206
|
+
inference, task=args.task, chat=not args.no_chat, embeddings=not args.no_embeddings
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
if args.json:
|
|
210
|
+
print(json.dumps([r.as_dict() for r in results], indent=2))
|
|
211
|
+
else:
|
|
212
|
+
for result in results:
|
|
213
|
+
print(result.line())
|
|
214
|
+
sys.exit(0 if results and all(r.ok for r in results) else 1)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Errors raised by the inference providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from lorebase_inference.interfaces import Usage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InferenceError(Exception):
|
|
11
|
+
"""Base class of every error raised by this package."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EndpointError(InferenceError):
|
|
15
|
+
"""An HTTP model endpoint failed or answered with something unusable.
|
|
16
|
+
|
|
17
|
+
The message holds the status and the error text sent by the server, truncated and with
|
|
18
|
+
credential-like strings removed; for 401 and 403 it holds no server text at all. It never
|
|
19
|
+
holds prompts or credentials.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, message: str, *, status: int | None = None, detail: str = "") -> None:
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.status = status
|
|
25
|
+
self.detail = detail
|
|
26
|
+
"""Error text sent by the server, truncated and scrubbed (empty for 401 and 403); used
|
|
27
|
+
to recognize unsupported options."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InvalidOutputError(InferenceError):
|
|
31
|
+
"""The model answered, but not with JSON that satisfies the requested schema."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
message: str,
|
|
36
|
+
*,
|
|
37
|
+
problems: Sequence[str] = (),
|
|
38
|
+
attempts: int = 1,
|
|
39
|
+
usage: Usage | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
self.problems = tuple(problems)
|
|
43
|
+
"""Validation problems of the last answer: schema path and violated keyword."""
|
|
44
|
+
self.attempts = attempts
|
|
45
|
+
self.usage = usage or Usage()
|
|
46
|
+
"""Tokens spent on every attempt, so that callers can still count them."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DimensionMismatchError(InferenceError):
|
|
50
|
+
"""A model returned vectors whose size differs from the configured dimension."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ProviderUnavailableError(InferenceError):
|
|
54
|
+
"""A configured provider needs an optional dependency that is not installed."""
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Deterministic fake provider: chat, embeddings and spans without network access.
|
|
2
|
+
|
|
3
|
+
- Chat answers come from fixtures keyed by `prompt_hash`, or from a rule-based responder that
|
|
4
|
+
builds a schema-valid value out of the text quoted in the prompt.
|
|
5
|
+
- Embeddings are unit vectors of the configured dimension derived from `sha256(text)`.
|
|
6
|
+
- Spans come from dictionary matching.
|
|
7
|
+
|
|
8
|
+
Rule-based responder limits: it understands type, enum, const, properties, required, items,
|
|
9
|
+
min/max keywords, minLength/maxLength, uniqueItems, anyOf, oneOf, allOf and local `$ref`. It
|
|
10
|
+
does not satisfy `pattern`, `not` or conditional keywords; supply a fixture for such schemas.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import copy
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
import re
|
|
20
|
+
from collections.abc import Mapping, Sequence
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from lorebase_inference.interfaces import (
|
|
25
|
+
ChatOptions,
|
|
26
|
+
ChatResult,
|
|
27
|
+
JsonSchema,
|
|
28
|
+
Label,
|
|
29
|
+
Message,
|
|
30
|
+
Span,
|
|
31
|
+
Usage,
|
|
32
|
+
)
|
|
33
|
+
from lorebase_inference.settings import EmbeddingSettings, FakeSettings, LlmSettings
|
|
34
|
+
|
|
35
|
+
FAKE_MODEL_PREFIX = "fake:"
|
|
36
|
+
_QUOTED = re.compile(r'"([^"\n]{1,200})"|“([^”\n]{1,200})”')
|
|
37
|
+
_FORMATS = {
|
|
38
|
+
"date": "2000-01-01",
|
|
39
|
+
"date-time": "2000-01-01T00:00:00Z",
|
|
40
|
+
"time": "00:00:00Z",
|
|
41
|
+
"email": "user@example.org",
|
|
42
|
+
"uri": "https://example.org/",
|
|
43
|
+
"url": "https://example.org/",
|
|
44
|
+
"uuid": "00000000-0000-4000-8000-000000000000",
|
|
45
|
+
}
|
|
46
|
+
_MAX_EXTRA_DEPTH = 20
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def prompt_hash(messages: Sequence[Message]) -> str:
|
|
50
|
+
"""Hex SHA-256 of the messages; names the fixture file holding the canned answer."""
|
|
51
|
+
payload = json.dumps(
|
|
52
|
+
[[m.role, m.content] for m in messages], ensure_ascii=False, separators=(",", ":")
|
|
53
|
+
)
|
|
54
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _token_count(text: str) -> int:
|
|
58
|
+
return len(text.split())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class FakeChatModel:
|
|
62
|
+
"""`ChatModel` answering from fixtures or from a rule-based responder."""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
llm: LlmSettings,
|
|
67
|
+
fake: FakeSettings,
|
|
68
|
+
fixtures: Mapping[str, Any] | None = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
self._model = f"{FAKE_MODEL_PREFIX}{llm.model}"
|
|
71
|
+
self._fixtures_dir = fake.fixtures_dir
|
|
72
|
+
self._max_depth = fake.max_schema_depth
|
|
73
|
+
self._fixtures = dict(fixtures or {})
|
|
74
|
+
|
|
75
|
+
async def complete_json(
|
|
76
|
+
self,
|
|
77
|
+
messages: Sequence[Message],
|
|
78
|
+
schema: JsonSchema,
|
|
79
|
+
options: ChatOptions | None = None,
|
|
80
|
+
) -> ChatResult:
|
|
81
|
+
"""Return the fixture for these messages, else a value built from the schema."""
|
|
82
|
+
data = self._fixture(prompt_hash(messages))
|
|
83
|
+
if data is None:
|
|
84
|
+
quotes = _quoted_text(messages)
|
|
85
|
+
data = _Responder(schema, quotes, self._max_depth).build()
|
|
86
|
+
usage = Usage(
|
|
87
|
+
input_tokens=sum(_token_count(m.content) for m in messages),
|
|
88
|
+
output_tokens=_token_count(json.dumps(data, ensure_ascii=False)),
|
|
89
|
+
)
|
|
90
|
+
return ChatResult(data=data, usage=usage, model=self._model)
|
|
91
|
+
|
|
92
|
+
def _fixture(self, key: str) -> Any:
|
|
93
|
+
if key in self._fixtures:
|
|
94
|
+
return copy.deepcopy(self._fixtures[key])
|
|
95
|
+
if self._fixtures_dir is not None:
|
|
96
|
+
path = Path(self._fixtures_dir) / f"{key}.json"
|
|
97
|
+
if path.is_file():
|
|
98
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _quoted_text(messages: Sequence[Message]) -> list[str]:
|
|
103
|
+
"""Quoted passages of the prompt, else its distinct words, else a constant."""
|
|
104
|
+
text = "\n".join(m.content for m in messages)
|
|
105
|
+
quotes = [a or b for a, b in _QUOTED.findall(text) if (a or b).strip()]
|
|
106
|
+
if not quotes:
|
|
107
|
+
quotes = list(dict.fromkeys(re.findall(r"\w+", text)))
|
|
108
|
+
return quotes or ["fake"]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _Responder:
|
|
112
|
+
"""Builds a value that satisfies a JSON schema from a pool of strings."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, schema: JsonSchema, pool: list[str], max_depth: int) -> None:
|
|
115
|
+
self._root = schema
|
|
116
|
+
self._pool = pool
|
|
117
|
+
self._max_depth = max_depth
|
|
118
|
+
self._next = 0
|
|
119
|
+
|
|
120
|
+
def build(self) -> Any:
|
|
121
|
+
return self._value(self._root, 0)
|
|
122
|
+
|
|
123
|
+
def _text(self) -> str:
|
|
124
|
+
value = self._pool[self._next % len(self._pool)]
|
|
125
|
+
self._next += 1
|
|
126
|
+
return value
|
|
127
|
+
|
|
128
|
+
def _resolve(self, schema: Any) -> Any:
|
|
129
|
+
seen = 0
|
|
130
|
+
while isinstance(schema, Mapping) and "$ref" in schema:
|
|
131
|
+
ref = schema["$ref"]
|
|
132
|
+
if not isinstance(ref, str) or not ref.startswith("#"):
|
|
133
|
+
raise ValueError(f"unsupported $ref {ref!r}: only local references are resolved")
|
|
134
|
+
node: Any = self._root
|
|
135
|
+
for part in filter(None, ref[1:].split("/")):
|
|
136
|
+
node = node[part.replace("~1", "/").replace("~0", "~")]
|
|
137
|
+
schema = node
|
|
138
|
+
seen += 1
|
|
139
|
+
if seen > _MAX_EXTRA_DEPTH:
|
|
140
|
+
raise ValueError("cyclic $ref chain")
|
|
141
|
+
return schema
|
|
142
|
+
|
|
143
|
+
def _merged(self, schema: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
144
|
+
"""Flatten `allOf` into one schema."""
|
|
145
|
+
merged: dict[str, Any] = {k: v for k, v in schema.items() if k != "allOf"}
|
|
146
|
+
for part in schema.get("allOf", []):
|
|
147
|
+
part = self._merged(self._resolve(part))
|
|
148
|
+
for key, value in part.items():
|
|
149
|
+
if key == "properties":
|
|
150
|
+
merged["properties"] = {**merged.get("properties", {}), **value}
|
|
151
|
+
elif key == "required":
|
|
152
|
+
merged["required"] = [*merged.get("required", []), *value]
|
|
153
|
+
else:
|
|
154
|
+
merged.setdefault(key, value)
|
|
155
|
+
return merged
|
|
156
|
+
|
|
157
|
+
def _value(self, schema: Any, depth: int) -> Any:
|
|
158
|
+
if depth > self._max_depth + _MAX_EXTRA_DEPTH:
|
|
159
|
+
raise ValueError("schema recursion cannot be satisfied with finite output")
|
|
160
|
+
schema = self._resolve(schema)
|
|
161
|
+
if schema is True or not isinstance(schema, Mapping) or not schema:
|
|
162
|
+
return self._text()
|
|
163
|
+
schema = self._merged(schema)
|
|
164
|
+
if "const" in schema:
|
|
165
|
+
return copy.deepcopy(schema["const"])
|
|
166
|
+
if schema.get("enum"):
|
|
167
|
+
return copy.deepcopy(schema["enum"][0])
|
|
168
|
+
for keyword in ("anyOf", "oneOf"):
|
|
169
|
+
if keyword in schema:
|
|
170
|
+
return self._value(self._preferred(schema[keyword]), depth)
|
|
171
|
+
kind = self._kind(schema)
|
|
172
|
+
if kind == "object":
|
|
173
|
+
return self._object(schema, depth)
|
|
174
|
+
if kind == "array":
|
|
175
|
+
return self._array(schema, depth)
|
|
176
|
+
if kind == "string":
|
|
177
|
+
return self._string(schema)
|
|
178
|
+
if kind in ("integer", "number"):
|
|
179
|
+
return self._number(schema, integer=kind == "integer")
|
|
180
|
+
if kind == "boolean":
|
|
181
|
+
return True
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
def _preferred(self, branches: Sequence[Any]) -> Any:
|
|
185
|
+
for branch in branches:
|
|
186
|
+
resolved = self._resolve(branch)
|
|
187
|
+
if not (isinstance(resolved, Mapping) and resolved.get("type") == "null"):
|
|
188
|
+
return resolved
|
|
189
|
+
return branches[0]
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _kind(schema: Mapping[str, Any]) -> str:
|
|
193
|
+
declared = schema.get("type")
|
|
194
|
+
if isinstance(declared, list):
|
|
195
|
+
declared = next((t for t in declared if t != "null"), "null")
|
|
196
|
+
if isinstance(declared, str):
|
|
197
|
+
return declared
|
|
198
|
+
if "properties" in schema or "required" in schema:
|
|
199
|
+
return "object"
|
|
200
|
+
if "items" in schema or "prefixItems" in schema:
|
|
201
|
+
return "array"
|
|
202
|
+
return "string"
|
|
203
|
+
|
|
204
|
+
def _object(self, schema: Mapping[str, Any], depth: int) -> dict[str, Any]:
|
|
205
|
+
properties: Mapping[str, Any] = schema.get("properties", {})
|
|
206
|
+
required: list[str] = list(dict.fromkeys(schema.get("required", [])))
|
|
207
|
+
deep = depth >= self._max_depth
|
|
208
|
+
names = [n for n in properties if n in required] if deep else list(properties)
|
|
209
|
+
names += [n for n in required if n not in properties]
|
|
210
|
+
return {name: self._value(properties.get(name, {}), depth + 1) for name in names}
|
|
211
|
+
|
|
212
|
+
def _array(self, schema: Mapping[str, Any], depth: int) -> list[Any]:
|
|
213
|
+
minimum = int(schema.get("minItems", 0))
|
|
214
|
+
maximum = schema.get("maxItems")
|
|
215
|
+
count = minimum if depth >= self._max_depth else max(minimum, 1)
|
|
216
|
+
if maximum is not None:
|
|
217
|
+
count = min(count, int(maximum))
|
|
218
|
+
prefix = schema.get("prefixItems")
|
|
219
|
+
items = schema.get("items", {})
|
|
220
|
+
values: list[Any] = []
|
|
221
|
+
for index in range(count):
|
|
222
|
+
part = prefix[index] if prefix and index < len(prefix) else items
|
|
223
|
+
value = self._value(part, depth + 1)
|
|
224
|
+
if schema.get("uniqueItems") and isinstance(value, str):
|
|
225
|
+
while value in values:
|
|
226
|
+
value = f"{value} {index}"
|
|
227
|
+
values.append(value)
|
|
228
|
+
return values
|
|
229
|
+
|
|
230
|
+
def _string(self, schema: Mapping[str, Any]) -> str:
|
|
231
|
+
value = _FORMATS.get(str(schema.get("format"))) or self._text()
|
|
232
|
+
value = value.ljust(int(schema.get("minLength", 0)), "x")
|
|
233
|
+
maximum = schema.get("maxLength")
|
|
234
|
+
return value if maximum is None else value[: int(maximum)]
|
|
235
|
+
|
|
236
|
+
@staticmethod
|
|
237
|
+
def _number(schema: Mapping[str, Any], *, integer: bool) -> int | float:
|
|
238
|
+
low = schema.get("minimum", schema.get("exclusiveMinimum"))
|
|
239
|
+
high = schema.get("maximum", schema.get("exclusiveMaximum"))
|
|
240
|
+
low_open = "minimum" not in schema and low is not None
|
|
241
|
+
high_open = "maximum" not in schema and high is not None
|
|
242
|
+
if integer:
|
|
243
|
+
lo = None if low is None else math.floor(low) + 1 if low_open else math.ceil(low)
|
|
244
|
+
hi = None if high is None else math.ceil(high) - 1 if high_open else math.floor(high)
|
|
245
|
+
value = 0 if lo is None or lo <= 0 else lo
|
|
246
|
+
value = hi if hi is not None and value > hi else value
|
|
247
|
+
else:
|
|
248
|
+
if low is not None and high is not None:
|
|
249
|
+
value = (low + high) / 2
|
|
250
|
+
elif low is not None:
|
|
251
|
+
value = low + (1 if low_open else 0)
|
|
252
|
+
elif high is not None:
|
|
253
|
+
value = min(0, high - (1 if high_open else 0))
|
|
254
|
+
else:
|
|
255
|
+
value = 0
|
|
256
|
+
step = schema.get("multipleOf")
|
|
257
|
+
if step:
|
|
258
|
+
value = math.ceil(value / step) * step
|
|
259
|
+
if high is not None and value > high:
|
|
260
|
+
value = math.floor(high / step) * step
|
|
261
|
+
return int(value) if integer else float(value)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class FakeEmbeddingModel:
|
|
265
|
+
"""`EmbeddingModel` producing deterministic unit vectors from `sha256(text)`."""
|
|
266
|
+
|
|
267
|
+
def __init__(self, embeddings: EmbeddingSettings) -> None:
|
|
268
|
+
self.name = f"{FAKE_MODEL_PREFIX}{embeddings.model}"
|
|
269
|
+
self.dims = embeddings.dims
|
|
270
|
+
|
|
271
|
+
async def embed(self, texts: Sequence[str]) -> list[list[float]]:
|
|
272
|
+
"""One unit vector of `dims` floats per text."""
|
|
273
|
+
return [embed_text(text, self.dims) for text in texts]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def embed_text(text: str, dims: int) -> list[float]:
|
|
277
|
+
"""Unit vector of `dims` components from a counter-extended SHA-256 of `text`."""
|
|
278
|
+
seed = hashlib.sha256(text.encode("utf-8")).digest()
|
|
279
|
+
values: list[float] = []
|
|
280
|
+
counter = 0
|
|
281
|
+
while len(values) < dims:
|
|
282
|
+
block = hashlib.sha256(seed + counter.to_bytes(4, "big")).digest()
|
|
283
|
+
values.extend(
|
|
284
|
+
int.from_bytes(block[i : i + 4], "big") / 2**31 - 1.0 for i in range(0, 32, 4)
|
|
285
|
+
)
|
|
286
|
+
counter += 1
|
|
287
|
+
values = values[:dims]
|
|
288
|
+
norm = math.sqrt(sum(v * v for v in values))
|
|
289
|
+
if norm == 0:
|
|
290
|
+
return [1.0, *([0.0] * (dims - 1))]
|
|
291
|
+
return [v / norm for v in values]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class FakeSpanExtractor:
|
|
295
|
+
"""`SpanExtractor` matching dictionary terms as whole words, case-insensitively."""
|
|
296
|
+
|
|
297
|
+
def __init__(self, dictionary: Mapping[str, Sequence[str]] | None = None) -> None:
|
|
298
|
+
self._dictionary = {k: list(v) for k, v in (dictionary or {}).items()}
|
|
299
|
+
|
|
300
|
+
async def extract(self, text: str, labels: Sequence[Label]) -> list[Span]:
|
|
301
|
+
"""Non-overlapping spans, earliest then longest first, ordered by start offset."""
|
|
302
|
+
candidates: list[tuple[int, int, int, str]] = []
|
|
303
|
+
for index, label in enumerate(labels):
|
|
304
|
+
terms = {t for t in (*label.examples, *self._dictionary.get(label.name, ())) if t}
|
|
305
|
+
for term in terms:
|
|
306
|
+
pattern = re.compile(rf"(?<!\w){re.escape(term)}(?!\w)", re.IGNORECASE)
|
|
307
|
+
candidates.extend(
|
|
308
|
+
(m.start(), m.end(), index, label.name) for m in pattern.finditer(text)
|
|
309
|
+
)
|
|
310
|
+
candidates.sort(key=lambda c: (c[0], -(c[1] - c[0]), c[2]))
|
|
311
|
+
spans: list[Span] = []
|
|
312
|
+
last_end = 0
|
|
313
|
+
for start, end, _index, name in candidates:
|
|
314
|
+
if start >= last_end:
|
|
315
|
+
spans.append(Span(label=name, text=text[start:end], start=start, end=end))
|
|
316
|
+
last_end = end
|
|
317
|
+
return spans
|