@pentatonic-ai/ai-agent-sdk 0.9.5 → 0.10.0
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.
- package/README.md +3 -3
- package/bin/cli.js +1 -1
- package/bin/commands/config.js +1 -1
- package/dist/index.cjs +39 -72
- package/dist/index.js +36 -69
- package/package.json +10 -3
- package/packages/doctor/src/checks/local-memory.js +2 -2
- package/packages/memory/README.md +2 -2
- package/packages/memory/openclaw-plugin/README.md +2 -2
- package/packages/memory/openclaw-plugin/openclaw.plugin.json +1 -1
- package/packages/memory/package-lock.json +49 -33
- package/packages/memory/package.json +4 -1
- package/packages/memory/src/__tests__/engine.test.js +40 -5
- package/packages/memory/src/engine.js +38 -3
- package/packages/memory/src/server.js +2 -2
- package/packages/memory-engine-v2/.env.example +30 -0
- package/packages/memory-engine-v2/README.md +125 -0
- package/packages/memory-engine-v2/compat/Dockerfile +11 -0
- package/packages/memory-engine-v2/compat/requirements.txt +6 -0
- package/packages/memory-engine-v2/compat/server.py +1047 -0
- package/packages/memory-engine-v2/docker-compose.aws.yml +78 -0
- package/packages/memory-engine-v2/docker-compose.yml +206 -0
- package/packages/memory-engine-v2/extractor-async/Dockerfile +14 -0
- package/packages/memory-engine-v2/extractor-async/confidence.py +62 -0
- package/packages/memory-engine-v2/extractor-async/noise_filter.py +144 -0
- package/packages/memory-engine-v2/extractor-async/requirements.txt +2 -0
- package/packages/memory-engine-v2/extractor-async/test_confidence.py +76 -0
- package/packages/memory-engine-v2/extractor-async/test_noise_filter.py +177 -0
- package/packages/memory-engine-v2/extractor-async/worker.py +797 -0
- package/packages/memory-engine-v2/extractor-sync/Dockerfile +11 -0
- package/packages/memory-engine-v2/extractor-sync/requirements.txt +4 -0
- package/packages/memory-engine-v2/extractor-sync/server.py +424 -0
- package/packages/memory-engine-v2/org-model/migrations/001_init.sql +390 -0
- package/packages/memory-engine-v2/tests/e2e_smoke.py +356 -0
- package/packages/memory-engine-v2/tests/fixtures/generate_synthetic_corpus.py +758 -0
- package/packages/memory-engine/.env.example +0 -13
- package/packages/memory-engine/MIGRATION.md +0 -219
- package/packages/memory-engine/README.md +0 -145
- package/packages/memory-engine/bench/README.md +0 -99
- package/packages/memory-engine/bench/scorecards-engine/agent-coding__pentatonic-baseline__20260427-142523.json +0 -1115
- package/packages/memory-engine/bench/scorecards-engine/chat-recall__pentatonic-baseline__20260427-142648.json +0 -819
- package/packages/memory-engine/bench/scorecards-engine/circular-economy__pentatonic-baseline__20260427-142757.json +0 -1278
- package/packages/memory-engine/bench/scorecards-engine/customer-support__pentatonic-baseline__20260427-142900.json +0 -1018
- package/packages/memory-engine/bench/scorecards-engine/marketplace-ops__pentatonic-baseline__20260427-142957.json +0 -1038
- package/packages/memory-engine/bench/scorecards-engine/product-catalogue__pentatonic-baseline__20260427-143122.json +0 -961
- package/packages/memory-engine/bench/scorecards-engine-via-docker/agent-coding__pentatonic-memory__20260427-161812.json +0 -1115
- package/packages/memory-engine/bench/scorecards-engine-via-docker/chat-recall__pentatonic-memory__20260427-161701.json +0 -819
- package/packages/memory-engine/bench/scorecards-engine-via-docker/circular-economy__pentatonic-memory__20260427-161713.json +0 -1278
- package/packages/memory-engine/bench/scorecards-engine-via-docker/customer-support__pentatonic-memory__20260427-161723.json +0 -1018
- package/packages/memory-engine/bench/scorecards-engine-via-docker/marketplace-ops__pentatonic-memory__20260427-161732.json +0 -1038
- package/packages/memory-engine/bench/scorecards-engine-via-docker/product-catalogue__pentatonic-memory__20260427-161741.json +0 -937
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/agent-coding__pentatonic-memory__20260427-184718.json +0 -1115
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/chat-recall__pentatonic-memory__20260427-184614.json +0 -819
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/circular-economy__pentatonic-memory__20260427-184809.json +0 -1278
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/customer-support__pentatonic-memory__20260427-184854.json +0 -1018
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/marketplace-ops__pentatonic-memory__20260427-184929.json +0 -1038
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/product-catalogue__pentatonic-memory__20260427-185015.json +0 -961
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/agent-coding__pentatonic-memory__20260427-175252.json +0 -1115
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/chat-recall__pentatonic-memory__20260427-175312.json +0 -819
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/circular-economy__pentatonic-memory__20260427-175335.json +0 -1278
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/customer-support__pentatonic-memory__20260427-175355.json +0 -1018
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/marketplace-ops__pentatonic-memory__20260427-175413.json +0 -1038
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/product-catalogue__pentatonic-memory__20260427-175430.json +0 -883
- package/packages/memory-engine/bench/scorecards-engine-via-shim/agent-coding__pentatonic-memory__20260427-155409.json +0 -1115
- package/packages/memory-engine/bench/scorecards-engine-via-shim/chat-recall__pentatonic-memory__20260427-155421.json +0 -819
- package/packages/memory-engine/bench/scorecards-engine-via-shim/circular-economy__pentatonic-memory__20260427-155433.json +0 -1278
- package/packages/memory-engine/bench/scorecards-engine-via-shim/customer-support__pentatonic-memory__20260427-155443.json +0 -1018
- package/packages/memory-engine/bench/scorecards-engine-via-shim/marketplace-ops__pentatonic-memory__20260427-155453.json +0 -1038
- package/packages/memory-engine/bench/scorecards-engine-via-shim/product-catalogue__pentatonic-memory__20260427-155503.json +0 -937
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory-latest__20260427-145103.json +0 -1115
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory__20260427-144909.json +0 -1115
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory-latest__20260427-145153.json +0 -819
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory__20260427-145120.json +0 -542
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory-latest__20260427-145313.json +0 -1278
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory__20260427-145207.json +0 -894
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory-latest__20260427-145412.json +0 -1018
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory__20260427-145327.json +0 -680
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory-latest__20260427-145517.json +0 -1038
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory__20260427-145422.json +0 -693
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory-latest__20260427-145616.json +0 -961
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory__20260427-145528.json +0 -727
- package/packages/memory-engine/compat/Dockerfile +0 -22
- package/packages/memory-engine/compat/server.py +0 -1255
- package/packages/memory-engine/docker-compose.test.yml +0 -59
- package/packages/memory-engine/docker-compose.yml +0 -240
- package/packages/memory-engine/engine/README.md +0 -52
- package/packages/memory-engine/engine/l2-hybridrag-proxy.py +0 -1543
- package/packages/memory-engine/engine/l5-comms-layer.py +0 -663
- package/packages/memory-engine/engine/l6-document-store.py +0 -1018
- package/packages/memory-engine/engine/services/_shared/__init__.py +0 -1
- package/packages/memory-engine/engine/services/_shared/embed_provider.py +0 -468
- package/packages/memory-engine/engine/services/l2/Dockerfile +0 -50
- package/packages/memory-engine/engine/services/l2/init_databases.py +0 -81
- package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +0 -2721
- package/packages/memory-engine/engine/services/l5/Dockerfile +0 -11
- package/packages/memory-engine/engine/services/l5/l5-comms-layer.py +0 -808
- package/packages/memory-engine/engine/services/l6/Dockerfile +0 -30
- package/packages/memory-engine/engine/services/l6/l6-document-store.py +0 -1221
- package/packages/memory-engine/engine/services/nv-embed/Dockerfile +0 -28
- package/packages/memory-engine/engine/services/nv-embed/server.py +0 -152
- package/packages/memory-engine/pme_memory/__init__.py +0 -0
- package/packages/memory-engine/pme_memory/__main__.py +0 -129
- package/packages/memory-engine/pme_memory/artifacts.py +0 -95
- package/packages/memory-engine/pme_memory/embed.py +0 -74
- package/packages/memory-engine/pme_memory/health.py +0 -36
- package/packages/memory-engine/pme_memory/hygiene.py +0 -159
- package/packages/memory-engine/pme_memory/indexer.py +0 -200
- package/packages/memory-engine/pme_memory/needs.py +0 -55
- package/packages/memory-engine/pme_memory/provenance.py +0 -80
- package/packages/memory-engine/pme_memory/scoring.py +0 -168
- package/packages/memory-engine/pme_memory/search.py +0 -52
- package/packages/memory-engine/pme_memory/store.py +0 -86
- package/packages/memory-engine/pme_memory/synthesis.py +0 -114
- package/packages/memory-engine/pyproject.toml +0 -65
- package/packages/memory-engine/scripts/kg-extractor.py +0 -557
- package/packages/memory-engine/scripts/kg-preflexor-v2.py +0 -738
- package/packages/memory-engine/scripts/wipe-legacy-l3-entities.py +0 -128
- package/packages/memory-engine/tests/e2e_arena.sh +0 -259
- package/packages/memory-engine/tests/embed_stub/Dockerfile +0 -13
- package/packages/memory-engine/tests/embed_stub/server.py +0 -80
- package/packages/memory-engine/tests/test_aggregate.py +0 -333
- package/packages/memory-engine/tests/test_api_contract.sh +0 -57
- package/packages/memory-engine/tests/test_arena_safety.py +0 -232
- package/packages/memory-engine/tests/test_channel_stat_reader.py +0 -437
- package/packages/memory-engine/tests/test_channel_stat_rollups.py +0 -308
- package/packages/memory-engine/tests/test_compat_nv_embed_probe.py +0 -48
- package/packages/memory-engine/tests/test_embed_provider.py +0 -492
- package/packages/memory-engine/tests/test_l2_qmd_vec_search.py +0 -280
- package/packages/memory-engine/tests/test_l3_arena_isolation.py +0 -412
- package/packages/memory-engine/tests/test_l6_module_load.py +0 -84
- package/packages/memory-engine/tests/test_people_list_reader.py +0 -432
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""Shared utilities used across the memory-engine layer services."""
|
|
@@ -1,468 +0,0 @@
|
|
|
1
|
-
"""Embedding provider abstraction for memory-engine layer services.
|
|
2
|
-
|
|
3
|
-
Different embedding gateways disagree on three things:
|
|
4
|
-
1. Auth scheme (Authorization: Bearer ... vs X-API-Key: ...)
|
|
5
|
-
2. URL path (/v1/embeddings vs /v1/embed vs vendor-specific)
|
|
6
|
-
3. Request shape (OpenAI {"input": [...]} vs Cohere {"texts": [...], "input_type": ...})
|
|
7
|
-
4. Response shape ({"data": [{"embedding": ...}]} vs {"embeddings": [...]})
|
|
8
|
-
|
|
9
|
-
`EmbedProvider` captures all four for one gateway. Built-in profiles cover
|
|
10
|
-
OpenAI-compatible, Pentatonic AI Gateway, and Cohere. A "custom" profile lets
|
|
11
|
-
you override auth + path via env without code changes.
|
|
12
|
-
|
|
13
|
-
Auto-detect: when the configured provider returns 401, the client probes the
|
|
14
|
-
other built-in profiles once and caches the winner for the rest of the
|
|
15
|
-
process. Disabled with `EMBED_AUTODETECT=false` per layer.
|
|
16
|
-
|
|
17
|
-
Usage from a layer service:
|
|
18
|
-
|
|
19
|
-
from _shared.embed_provider import EmbedClient
|
|
20
|
-
|
|
21
|
-
embed = EmbedClient.from_env(prefix="L4_")
|
|
22
|
-
vectors = embed.embed_batch(["hello", "world"])
|
|
23
|
-
"""
|
|
24
|
-
|
|
25
|
-
from __future__ import annotations
|
|
26
|
-
|
|
27
|
-
import logging
|
|
28
|
-
import os
|
|
29
|
-
from dataclasses import dataclass
|
|
30
|
-
from typing import Any, Callable
|
|
31
|
-
from urllib.parse import urlparse, urlunparse
|
|
32
|
-
|
|
33
|
-
import httpx
|
|
34
|
-
|
|
35
|
-
log = logging.getLogger("embed_provider")
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
# ----------------------------------------------------------------------
|
|
39
|
-
# Body builders + response parsers
|
|
40
|
-
# ----------------------------------------------------------------------
|
|
41
|
-
|
|
42
|
-
def _openai_body(texts: list[str], model: str) -> dict[str, Any]:
|
|
43
|
-
return {"input": texts, "model": model}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def _openai_response(payload: dict[str, Any]) -> list[list[float]]:
|
|
47
|
-
return [d["embedding"] for d in payload["data"]]
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def _pentatonic_response(payload: dict[str, Any]) -> list[list[float]]:
|
|
51
|
-
"""Pentatonic AI Gateway returns {"embeddings": [...]} on /v1/embed
|
|
52
|
-
and OpenAI-shaped {"data": [{"embedding": ...}]} on /v1/embeddings.
|
|
53
|
-
Accept either."""
|
|
54
|
-
if "data" in payload:
|
|
55
|
-
return [d["embedding"] for d in payload["data"]]
|
|
56
|
-
return payload["embeddings"]
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _cohere_body(texts: list[str], model: str) -> dict[str, Any]:
|
|
60
|
-
return {"texts": texts, "model": model, "input_type": "search_document"}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _cohere_response(payload: dict[str, Any]) -> list[list[float]]:
|
|
64
|
-
return payload["embeddings"]
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
_BODY_BUILDERS: dict[str, Callable[[list[str], str], dict[str, Any]]] = {
|
|
68
|
-
"openai": _openai_body,
|
|
69
|
-
"cohere": _cohere_body,
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
_RESPONSE_PARSERS: dict[str, Callable[[dict[str, Any]], list[list[float]]]] = {
|
|
73
|
-
"openai": _openai_response,
|
|
74
|
-
"pentatonic": _pentatonic_response,
|
|
75
|
-
"cohere": _cohere_response,
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
# ----------------------------------------------------------------------
|
|
80
|
-
# Provider profiles
|
|
81
|
-
# ----------------------------------------------------------------------
|
|
82
|
-
|
|
83
|
-
@dataclass(frozen=True)
|
|
84
|
-
class EmbedProvider:
|
|
85
|
-
name: str
|
|
86
|
-
auth_header: str
|
|
87
|
-
auth_format: str # f-string-ish; "{key}" placeholder substituted at request time
|
|
88
|
-
path_default: str
|
|
89
|
-
body_builder: Callable[[list[str], str], dict[str, Any]]
|
|
90
|
-
response_parser: Callable[[dict[str, Any]], list[list[float]]]
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
PROVIDERS: dict[str, EmbedProvider] = {
|
|
94
|
-
"openai": EmbedProvider(
|
|
95
|
-
name="openai",
|
|
96
|
-
auth_header="Authorization",
|
|
97
|
-
auth_format="Bearer {key}",
|
|
98
|
-
path_default="/v1/embeddings",
|
|
99
|
-
body_builder=_openai_body,
|
|
100
|
-
response_parser=_openai_response,
|
|
101
|
-
),
|
|
102
|
-
"pentatonic-gateway": EmbedProvider(
|
|
103
|
-
name="pentatonic-gateway",
|
|
104
|
-
auth_header="X-API-Key",
|
|
105
|
-
auth_format="{key}",
|
|
106
|
-
path_default="/v1/embed",
|
|
107
|
-
body_builder=_openai_body,
|
|
108
|
-
response_parser=_pentatonic_response,
|
|
109
|
-
),
|
|
110
|
-
"cohere": EmbedProvider(
|
|
111
|
-
name="cohere",
|
|
112
|
-
auth_header="Authorization",
|
|
113
|
-
auth_format="Bearer {key}",
|
|
114
|
-
path_default="/v1/embed",
|
|
115
|
-
body_builder=_cohere_body,
|
|
116
|
-
response_parser=_cohere_response,
|
|
117
|
-
),
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def _build_custom_provider(env_prefix: str) -> EmbedProvider:
|
|
122
|
-
"""Build a custom provider from env vars.
|
|
123
|
-
|
|
124
|
-
Env vars (env_prefix is e.g. 'L4_'):
|
|
125
|
-
{prefix}EMBED_AUTH_HEADER default Authorization
|
|
126
|
-
{prefix}EMBED_AUTH_FORMAT default Bearer {key}
|
|
127
|
-
{prefix}EMBED_PATH_DEFAULT default /v1/embeddings
|
|
128
|
-
{prefix}EMBED_BODY_SHAPE "openai" | "cohere" default openai
|
|
129
|
-
{prefix}EMBED_RESPONSE_SHAPE "openai" | "pentatonic" | "cohere" default openai
|
|
130
|
-
"""
|
|
131
|
-
body_shape = os.environ.get(f"{env_prefix}EMBED_BODY_SHAPE", "openai")
|
|
132
|
-
response_shape = os.environ.get(f"{env_prefix}EMBED_RESPONSE_SHAPE", "openai")
|
|
133
|
-
return EmbedProvider(
|
|
134
|
-
name="custom",
|
|
135
|
-
auth_header=os.environ.get(f"{env_prefix}EMBED_AUTH_HEADER", "Authorization"),
|
|
136
|
-
auth_format=os.environ.get(f"{env_prefix}EMBED_AUTH_FORMAT", "Bearer {key}"),
|
|
137
|
-
path_default=os.environ.get(f"{env_prefix}EMBED_PATH_DEFAULT", "/v1/embeddings"),
|
|
138
|
-
body_builder=_BODY_BUILDERS.get(body_shape, _openai_body),
|
|
139
|
-
response_parser=_RESPONSE_PARSERS.get(response_shape, _openai_response),
|
|
140
|
-
)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def resolve_provider(name: str, env_prefix: str = "") -> EmbedProvider:
|
|
144
|
-
"""Look up a built-in provider, or build a custom one from env."""
|
|
145
|
-
if name in PROVIDERS:
|
|
146
|
-
return PROVIDERS[name]
|
|
147
|
-
if name == "custom":
|
|
148
|
-
return _build_custom_provider(env_prefix)
|
|
149
|
-
raise ValueError(
|
|
150
|
-
f"Unknown EMBED_PROVIDER {name!r}. "
|
|
151
|
-
f"Built-ins: {sorted(PROVIDERS)} or 'custom'."
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
# ----------------------------------------------------------------------
|
|
156
|
-
# URL helpers
|
|
157
|
-
# ----------------------------------------------------------------------
|
|
158
|
-
|
|
159
|
-
def _swap_path(url: str, new_path: str) -> str:
|
|
160
|
-
"""Replace the path component of `url` with `new_path`. Empty path on the
|
|
161
|
-
input URL is treated as 'no path provided' and gets `new_path` appended."""
|
|
162
|
-
p = urlparse(url)
|
|
163
|
-
return urlunparse((p.scheme, p.netloc, new_path, "", p.query, p.fragment))
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def _resolved_url(url: str, provider: EmbedProvider) -> str:
|
|
167
|
-
"""If `url` has no path, append the provider's default path; otherwise
|
|
168
|
-
leave as-is (operator chose a specific path)."""
|
|
169
|
-
p = urlparse(url)
|
|
170
|
-
if not p.path or p.path == "/":
|
|
171
|
-
return _swap_path(url, provider.path_default)
|
|
172
|
-
return url
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
# ----------------------------------------------------------------------
|
|
176
|
-
# Client
|
|
177
|
-
# ----------------------------------------------------------------------
|
|
178
|
-
|
|
179
|
-
class EmbedAuthError(RuntimeError):
|
|
180
|
-
"""Raised when every candidate provider returns 401."""
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
class EmbedHTTPError(RuntimeError):
|
|
184
|
-
"""Raised on non-401 HTTP errors from the embedding endpoint."""
|
|
185
|
-
|
|
186
|
-
def __init__(self, status: int, body: str):
|
|
187
|
-
super().__init__(f"HTTP {status}: {body[:200]}")
|
|
188
|
-
self.status = status
|
|
189
|
-
self.body = body
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
class EmbedClient:
|
|
193
|
-
"""Sync + async embedding client with provider auto-detection.
|
|
194
|
-
|
|
195
|
-
Construct via `EmbedClient.from_env(prefix="L4_")` so each layer service
|
|
196
|
-
reads its own env-var namespace; or call the constructor directly for
|
|
197
|
-
tests.
|
|
198
|
-
|
|
199
|
-
The client is stateful: after a successful auto-detect, the winning
|
|
200
|
-
provider is cached for the rest of the process lifetime. Set
|
|
201
|
-
`autodetect=False` (or env `{prefix}EMBED_AUTODETECT=false`) to disable.
|
|
202
|
-
"""
|
|
203
|
-
|
|
204
|
-
def __init__(
|
|
205
|
-
self,
|
|
206
|
-
*,
|
|
207
|
-
url: str,
|
|
208
|
-
api_key: str,
|
|
209
|
-
model: str,
|
|
210
|
-
provider: EmbedProvider,
|
|
211
|
-
autodetect: bool = True,
|
|
212
|
-
timeout: float = 120.0,
|
|
213
|
-
env_prefix: str = "",
|
|
214
|
-
max_batch: int = 5,
|
|
215
|
-
) -> None:
|
|
216
|
-
self._configured_provider = provider
|
|
217
|
-
self._provider = provider
|
|
218
|
-
self._configured_url = url
|
|
219
|
-
self._url = _resolved_url(url, provider)
|
|
220
|
-
self._api_key = api_key
|
|
221
|
-
self._model = model
|
|
222
|
-
self._timeout = timeout
|
|
223
|
-
self._autodetect = autodetect
|
|
224
|
-
self._env_prefix = env_prefix
|
|
225
|
-
self._detected = False
|
|
226
|
-
# 0 = unlimited (no chunking). Positive = max texts per upstream call;
|
|
227
|
-
# larger inputs are split into multiple calls (concurrent in async path)
|
|
228
|
-
# and the results concatenated. Defaults to 5 because that's the per-call
|
|
229
|
-
# cap observed on Pentatonic AI Gateway — above which it 502s and the
|
|
230
|
-
# caller silently loses vector writes (see test_chunking_* tests).
|
|
231
|
-
self._max_batch = max(0, max_batch)
|
|
232
|
-
|
|
233
|
-
# ------------------------------------------------------------------
|
|
234
|
-
# Construction
|
|
235
|
-
# ------------------------------------------------------------------
|
|
236
|
-
|
|
237
|
-
@classmethod
|
|
238
|
-
def from_env(
|
|
239
|
-
cls,
|
|
240
|
-
prefix: str,
|
|
241
|
-
*,
|
|
242
|
-
url_var: str | None = None,
|
|
243
|
-
key_var: str | None = None,
|
|
244
|
-
model_var: str | None = None,
|
|
245
|
-
default_url: str = "",
|
|
246
|
-
default_model: str = "nv-embed-v2",
|
|
247
|
-
) -> "EmbedClient":
|
|
248
|
-
"""Build an EmbedClient from layer-prefixed env vars.
|
|
249
|
-
|
|
250
|
-
Layer services use `EmbedClient.from_env(prefix="L4_")` and the
|
|
251
|
-
client reads:
|
|
252
|
-
{prefix}NV_EMBED_URL (override with `url_var=...`)
|
|
253
|
-
{prefix}EMBED_API_KEY
|
|
254
|
-
{prefix}EMBED_MODEL
|
|
255
|
-
{prefix}EMBED_PROVIDER default 'openai'
|
|
256
|
-
{prefix}EMBED_AUTODETECT default 'true'
|
|
257
|
-
{prefix}EMBED_TIMEOUT default '120'
|
|
258
|
-
{prefix}EMBED_MAX_BATCH default '5' (gateway-safe; '0' disables chunking)
|
|
259
|
-
"""
|
|
260
|
-
url_var = url_var or f"{prefix}NV_EMBED_URL"
|
|
261
|
-
key_var = key_var or f"{prefix}EMBED_API_KEY"
|
|
262
|
-
model_var = model_var or f"{prefix}EMBED_MODEL"
|
|
263
|
-
|
|
264
|
-
url = os.environ.get(url_var, default_url)
|
|
265
|
-
api_key = os.environ.get(key_var, "")
|
|
266
|
-
model = os.environ.get(model_var, default_model)
|
|
267
|
-
provider_name = os.environ.get(f"{prefix}EMBED_PROVIDER", "openai")
|
|
268
|
-
autodetect = os.environ.get(f"{prefix}EMBED_AUTODETECT", "true").lower() == "true"
|
|
269
|
-
timeout = float(os.environ.get(f"{prefix}EMBED_TIMEOUT", "120"))
|
|
270
|
-
max_batch = int(os.environ.get(f"{prefix}EMBED_MAX_BATCH", "5"))
|
|
271
|
-
|
|
272
|
-
provider = resolve_provider(provider_name, env_prefix=prefix)
|
|
273
|
-
return cls(
|
|
274
|
-
url=url,
|
|
275
|
-
api_key=api_key,
|
|
276
|
-
model=model,
|
|
277
|
-
provider=provider,
|
|
278
|
-
autodetect=autodetect,
|
|
279
|
-
timeout=timeout,
|
|
280
|
-
env_prefix=prefix,
|
|
281
|
-
max_batch=max_batch,
|
|
282
|
-
)
|
|
283
|
-
|
|
284
|
-
# ------------------------------------------------------------------
|
|
285
|
-
# Internals
|
|
286
|
-
# ------------------------------------------------------------------
|
|
287
|
-
|
|
288
|
-
def _headers(self, provider: EmbedProvider) -> dict[str, str]:
|
|
289
|
-
if not self._api_key:
|
|
290
|
-
return {}
|
|
291
|
-
return {provider.auth_header: provider.auth_format.format(key=self._api_key)}
|
|
292
|
-
|
|
293
|
-
def _candidate_url(self, provider: EmbedProvider) -> str:
|
|
294
|
-
"""URL to try for this provider. The configured provider keeps the
|
|
295
|
-
operator's chosen URL; auto-detect candidates swap in their own
|
|
296
|
-
path_default since the operator's path was wrong for them."""
|
|
297
|
-
if provider.name == self._configured_provider.name:
|
|
298
|
-
return self._url
|
|
299
|
-
return _swap_path(self._configured_url, provider.path_default)
|
|
300
|
-
|
|
301
|
-
def _switch_to(self, provider: EmbedProvider) -> None:
|
|
302
|
-
"""Make `provider` the active provider for future requests."""
|
|
303
|
-
log.warning(
|
|
304
|
-
"embed_provider auto-detect switched: configured=%s -> active=%s. "
|
|
305
|
-
"Set %sEMBED_PROVIDER=%s to silence this.",
|
|
306
|
-
self._configured_provider.name,
|
|
307
|
-
provider.name,
|
|
308
|
-
self._env_prefix,
|
|
309
|
-
provider.name,
|
|
310
|
-
)
|
|
311
|
-
self._provider = provider
|
|
312
|
-
self._url = self._candidate_url(provider)
|
|
313
|
-
self._detected = True
|
|
314
|
-
|
|
315
|
-
# ------------------------------------------------------------------
|
|
316
|
-
# Sync API
|
|
317
|
-
# ------------------------------------------------------------------
|
|
318
|
-
|
|
319
|
-
def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
|
320
|
-
"""Embed a list of texts. Empty list returns empty list.
|
|
321
|
-
|
|
322
|
-
Splits into chunks of `max_batch` (default 5) and posts each
|
|
323
|
-
sequentially when the input exceeds the limit. Results are
|
|
324
|
-
concatenated in input order. `max_batch=0` disables chunking.
|
|
325
|
-
"""
|
|
326
|
-
if not texts:
|
|
327
|
-
return []
|
|
328
|
-
if self._max_batch == 0 or len(texts) <= self._max_batch:
|
|
329
|
-
return self._post_with_autodetect(texts, async_mode=False)
|
|
330
|
-
out: list[list[float]] = []
|
|
331
|
-
for start in range(0, len(texts), self._max_batch):
|
|
332
|
-
chunk = texts[start:start + self._max_batch]
|
|
333
|
-
out.extend(self._post_with_autodetect(chunk, async_mode=False))
|
|
334
|
-
return out
|
|
335
|
-
|
|
336
|
-
def embed_one(self, text: str) -> list[float]:
|
|
337
|
-
return self.embed_batch([text])[0]
|
|
338
|
-
|
|
339
|
-
# ------------------------------------------------------------------
|
|
340
|
-
# Async API
|
|
341
|
-
# ------------------------------------------------------------------
|
|
342
|
-
|
|
343
|
-
async def embed_batch_async(self, texts: list[str]) -> list[list[float]]:
|
|
344
|
-
"""Async embed. Chunks are fired concurrently via asyncio.gather
|
|
345
|
-
when the input exceeds `max_batch`; raises the first error if any
|
|
346
|
-
chunk fails (matching the un-chunked semantics)."""
|
|
347
|
-
if not texts:
|
|
348
|
-
return []
|
|
349
|
-
if self._max_batch == 0 or len(texts) <= self._max_batch:
|
|
350
|
-
return await self._post_with_autodetect_async(texts)
|
|
351
|
-
import asyncio
|
|
352
|
-
chunks = [
|
|
353
|
-
texts[start:start + self._max_batch]
|
|
354
|
-
for start in range(0, len(texts), self._max_batch)
|
|
355
|
-
]
|
|
356
|
-
results = await asyncio.gather(
|
|
357
|
-
*(self._post_with_autodetect_async(chunk) for chunk in chunks)
|
|
358
|
-
)
|
|
359
|
-
out: list[list[float]] = []
|
|
360
|
-
for r in results:
|
|
361
|
-
out.extend(r)
|
|
362
|
-
return out
|
|
363
|
-
|
|
364
|
-
async def embed_one_async(self, text: str) -> list[float]:
|
|
365
|
-
out = await self.embed_batch_async([text])
|
|
366
|
-
return out[0]
|
|
367
|
-
|
|
368
|
-
# ------------------------------------------------------------------
|
|
369
|
-
# Request paths
|
|
370
|
-
# ------------------------------------------------------------------
|
|
371
|
-
|
|
372
|
-
def _post_with_autodetect(self, texts: list[str], *, async_mode: bool) -> list[list[float]]:
|
|
373
|
-
del async_mode # kept for symmetry; sync path is its own method
|
|
374
|
-
body = self._provider.body_builder(texts, self._model)
|
|
375
|
-
headers = self._headers(self._provider)
|
|
376
|
-
try:
|
|
377
|
-
r = httpx.post(self._url, json=body, headers=headers, timeout=self._timeout)
|
|
378
|
-
except httpx.HTTPError as exc:
|
|
379
|
-
raise EmbedHTTPError(0, str(exc)) from exc
|
|
380
|
-
|
|
381
|
-
if r.status_code == 401 and self._autodetect and not self._detected:
|
|
382
|
-
return self._autodetect_and_retry(texts, last_body=r.text)
|
|
383
|
-
|
|
384
|
-
if r.status_code == 401:
|
|
385
|
-
raise EmbedAuthError(r.text)
|
|
386
|
-
if not r.is_success:
|
|
387
|
-
raise EmbedHTTPError(r.status_code, r.text)
|
|
388
|
-
return self._provider.response_parser(r.json())
|
|
389
|
-
|
|
390
|
-
async def _post_with_autodetect_async(self, texts: list[str]) -> list[list[float]]:
|
|
391
|
-
body = self._provider.body_builder(texts, self._model)
|
|
392
|
-
headers = self._headers(self._provider)
|
|
393
|
-
try:
|
|
394
|
-
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
395
|
-
r = await client.post(self._url, json=body, headers=headers)
|
|
396
|
-
except httpx.HTTPError as exc:
|
|
397
|
-
raise EmbedHTTPError(0, str(exc)) from exc
|
|
398
|
-
|
|
399
|
-
if r.status_code == 401 and self._autodetect and not self._detected:
|
|
400
|
-
return await self._autodetect_and_retry_async(texts, last_body=r.text)
|
|
401
|
-
|
|
402
|
-
if r.status_code == 401:
|
|
403
|
-
raise EmbedAuthError(r.text)
|
|
404
|
-
if not r.is_success:
|
|
405
|
-
raise EmbedHTTPError(r.status_code, r.text)
|
|
406
|
-
return self._provider.response_parser(r.json())
|
|
407
|
-
|
|
408
|
-
# ------------------------------------------------------------------
|
|
409
|
-
# Auto-detect
|
|
410
|
-
# ------------------------------------------------------------------
|
|
411
|
-
|
|
412
|
-
def _candidates(self) -> list[EmbedProvider]:
|
|
413
|
-
return [p for p in PROVIDERS.values() if p.name != self._provider.name]
|
|
414
|
-
|
|
415
|
-
def _autodetect_and_retry(self, texts: list[str], *, last_body: str) -> list[list[float]]:
|
|
416
|
-
for candidate in self._candidates():
|
|
417
|
-
url = self._candidate_url(candidate)
|
|
418
|
-
body = candidate.body_builder(texts, self._model)
|
|
419
|
-
headers = (
|
|
420
|
-
{candidate.auth_header: candidate.auth_format.format(key=self._api_key)}
|
|
421
|
-
if self._api_key
|
|
422
|
-
else {}
|
|
423
|
-
)
|
|
424
|
-
try:
|
|
425
|
-
r = httpx.post(url, json=body, headers=headers, timeout=self._timeout)
|
|
426
|
-
except httpx.HTTPError:
|
|
427
|
-
continue
|
|
428
|
-
if r.is_success:
|
|
429
|
-
self._switch_to(candidate)
|
|
430
|
-
return candidate.response_parser(r.json())
|
|
431
|
-
raise EmbedAuthError(
|
|
432
|
-
f"all providers returned auth or transport errors. "
|
|
433
|
-
f"last 401 body: {last_body[:200]}"
|
|
434
|
-
)
|
|
435
|
-
|
|
436
|
-
async def _autodetect_and_retry_async(self, texts: list[str], *, last_body: str) -> list[list[float]]:
|
|
437
|
-
for candidate in self._candidates():
|
|
438
|
-
url = self._candidate_url(candidate)
|
|
439
|
-
body = candidate.body_builder(texts, self._model)
|
|
440
|
-
headers = (
|
|
441
|
-
{candidate.auth_header: candidate.auth_format.format(key=self._api_key)}
|
|
442
|
-
if self._api_key
|
|
443
|
-
else {}
|
|
444
|
-
)
|
|
445
|
-
try:
|
|
446
|
-
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
447
|
-
r = await client.post(url, json=body, headers=headers)
|
|
448
|
-
except httpx.HTTPError:
|
|
449
|
-
continue
|
|
450
|
-
if r.is_success:
|
|
451
|
-
self._switch_to(candidate)
|
|
452
|
-
return candidate.response_parser(r.json())
|
|
453
|
-
raise EmbedAuthError(
|
|
454
|
-
f"all providers returned auth or transport errors. "
|
|
455
|
-
f"last 401 body: {last_body[:200]}"
|
|
456
|
-
)
|
|
457
|
-
|
|
458
|
-
# ------------------------------------------------------------------
|
|
459
|
-
# Introspection (handy for /health and tests)
|
|
460
|
-
# ------------------------------------------------------------------
|
|
461
|
-
|
|
462
|
-
@property
|
|
463
|
-
def active_provider(self) -> str:
|
|
464
|
-
return self._provider.name
|
|
465
|
-
|
|
466
|
-
@property
|
|
467
|
-
def active_url(self) -> str:
|
|
468
|
-
return self._url
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
FROM python:3.12-slim
|
|
2
|
-
|
|
3
|
-
WORKDIR /app
|
|
4
|
-
|
|
5
|
-
# System deps for sentence-transformers + neo4j driver
|
|
6
|
-
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
7
|
-
gcc g++ git curl \
|
|
8
|
-
&& rm -rf /var/lib/apt/lists/*
|
|
9
|
-
|
|
10
|
-
# Reranker = sentence-transformers MiniLM cross-encoder.
|
|
11
|
-
# Torch CPU wheels are fine — reranker is small enough to be CPU-bound.
|
|
12
|
-
#
|
|
13
|
-
# sqlite-vec 0.1.9: native KNN over packed-f32 vectors stored in a vec0
|
|
14
|
-
# virtual table. Replaces the legacy hand-rolled Python cosine loop over
|
|
15
|
-
# JSON-serialised embeddings in search_qmd_informed (~15s timeout at 450k
|
|
16
|
-
# rows → ~50ms native MATCH). Pin to 0.1.9 — that's the version probed
|
|
17
|
-
# against L4 QMD's wire format (struct.pack f32 + cosine distance_metric).
|
|
18
|
-
RUN pip install --no-cache-dir \
|
|
19
|
-
fastapi "uvicorn[standard]" httpx requests pydantic \
|
|
20
|
-
neo4j \
|
|
21
|
-
sqlite-vec==0.1.9 \
|
|
22
|
-
"sentence-transformers" \
|
|
23
|
-
"torch" --extra-index-url https://download.pytorch.org/whl/cpu
|
|
24
|
-
|
|
25
|
-
# Shared embed_provider module (build context is engine/services).
|
|
26
|
-
COPY _shared /app/_shared
|
|
27
|
-
COPY l2/l2-hybridrag-proxy.py /app/server.py
|
|
28
|
-
COPY l2/init_databases.py /app/init_databases.py
|
|
29
|
-
|
|
30
|
-
# Pre-create empty L0 BM25 SQLite + empty workspace
|
|
31
|
-
RUN mkdir -p /data/workspace /data/.pentatonic/memory /data/.cache && \
|
|
32
|
-
python init_databases.py /data
|
|
33
|
-
|
|
34
|
-
# L2 proxy expects various data dirs — point them at /data
|
|
35
|
-
ENV PME_WORKSPACE=/data/workspace
|
|
36
|
-
ENV PME_QMD_DB=/data/qmd.sqlite
|
|
37
|
-
ENV PME_MEMORY_DB=/data/workspace.db
|
|
38
|
-
ENV HF_HOME=/data/.cache/huggingface
|
|
39
|
-
|
|
40
|
-
# OSS-friendly defaults: HyDE off (per request), reranker on
|
|
41
|
-
ENV PME_HYDE_ENABLED=false
|
|
42
|
-
ENV PME_RERANK_ENABLED=true
|
|
43
|
-
ENV PME_NV_EMBED_ENABLED=true
|
|
44
|
-
|
|
45
|
-
EXPOSE 8031
|
|
46
|
-
|
|
47
|
-
# Pre-download the MiniLM cross-encoder so cold-start is fast
|
|
48
|
-
RUN python -c "from sentence_transformers import CrossEncoder; CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2', cache_folder='/data/.cache/huggingface')"
|
|
49
|
-
|
|
50
|
-
CMD ["python", "server.py", "--port", "8031", "--host", "0.0.0.0"]
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
"""Initialise empty L0 BM25 SQLite + L4 QMD SQLite at container startup.
|
|
2
|
-
|
|
3
|
-
The L2 proxy reads from two SQLite files that don't otherwise exist
|
|
4
|
-
in a fresh container — so opening them via sqlite3.connect() fails
|
|
5
|
-
with "unable to open database file." Pre-creating them with the
|
|
6
|
-
schemas the proxy expects lets the proxy come up cleanly and return
|
|
7
|
-
empty results from those layers (instead of crashing) until the shim
|
|
8
|
-
populates them through /index-batch.
|
|
9
|
-
|
|
10
|
-
Schemas mirror what the production ingester writes.
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
import os
|
|
14
|
-
import sqlite3
|
|
15
|
-
import sys
|
|
16
|
-
from pathlib import Path
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def init_l0_bm25(db_path: Path) -> None:
|
|
20
|
-
"""L0 — workspace BM25 over chunked markdown."""
|
|
21
|
-
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
-
conn = sqlite3.connect(str(db_path))
|
|
23
|
-
conn.execute("PRAGMA journal_mode=WAL")
|
|
24
|
-
conn.execute("""
|
|
25
|
-
CREATE TABLE IF NOT EXISTS chunks (
|
|
26
|
-
id INTEGER PRIMARY KEY,
|
|
27
|
-
path TEXT,
|
|
28
|
-
text TEXT,
|
|
29
|
-
file_type TEXT,
|
|
30
|
-
chunk_index INTEGER,
|
|
31
|
-
created_at TEXT
|
|
32
|
-
)
|
|
33
|
-
""")
|
|
34
|
-
conn.execute("""
|
|
35
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
|
|
36
|
-
path, text, file_type,
|
|
37
|
-
content='chunks',
|
|
38
|
-
content_rowid='id'
|
|
39
|
-
)
|
|
40
|
-
""")
|
|
41
|
-
conn.execute("""
|
|
42
|
-
CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
|
|
43
|
-
INSERT INTO chunks_fts(rowid, path, text, file_type)
|
|
44
|
-
VALUES (new.id, new.path, new.text, new.file_type);
|
|
45
|
-
END
|
|
46
|
-
""")
|
|
47
|
-
conn.commit()
|
|
48
|
-
conn.close()
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def init_l4_qmd(db_path: Path) -> None:
|
|
52
|
-
"""L4 — QMD vector store, JSONB embeddings stored alongside text."""
|
|
53
|
-
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
-
conn = sqlite3.connect(str(db_path))
|
|
55
|
-
conn.execute("PRAGMA journal_mode=WAL")
|
|
56
|
-
conn.execute("""
|
|
57
|
-
CREATE TABLE IF NOT EXISTS chunks (
|
|
58
|
-
id INTEGER PRIMARY KEY,
|
|
59
|
-
path TEXT,
|
|
60
|
-
text TEXT,
|
|
61
|
-
embedding TEXT,
|
|
62
|
-
embedding_model TEXT,
|
|
63
|
-
embedding_dim INTEGER,
|
|
64
|
-
chunk_index INTEGER,
|
|
65
|
-
created_at TEXT
|
|
66
|
-
)
|
|
67
|
-
""")
|
|
68
|
-
conn.commit()
|
|
69
|
-
conn.close()
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def main() -> int:
|
|
73
|
-
base = Path(sys.argv[1] if len(sys.argv) > 1 else "/data")
|
|
74
|
-
init_l0_bm25(base / "workspace.db")
|
|
75
|
-
init_l4_qmd(base / "qmd.sqlite")
|
|
76
|
-
print(f"initialised L0 + L4 QMD databases under {base}")
|
|
77
|
-
return 0
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if __name__ == "__main__":
|
|
81
|
-
sys.exit(main())
|