@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.
Files changed (131) hide show
  1. package/README.md +3 -3
  2. package/bin/cli.js +1 -1
  3. package/bin/commands/config.js +1 -1
  4. package/dist/index.cjs +39 -72
  5. package/dist/index.js +36 -69
  6. package/package.json +10 -3
  7. package/packages/doctor/src/checks/local-memory.js +2 -2
  8. package/packages/memory/README.md +2 -2
  9. package/packages/memory/openclaw-plugin/README.md +2 -2
  10. package/packages/memory/openclaw-plugin/openclaw.plugin.json +1 -1
  11. package/packages/memory/package-lock.json +49 -33
  12. package/packages/memory/package.json +4 -1
  13. package/packages/memory/src/__tests__/engine.test.js +40 -5
  14. package/packages/memory/src/engine.js +38 -3
  15. package/packages/memory/src/server.js +2 -2
  16. package/packages/memory-engine-v2/.env.example +30 -0
  17. package/packages/memory-engine-v2/README.md +125 -0
  18. package/packages/memory-engine-v2/compat/Dockerfile +11 -0
  19. package/packages/memory-engine-v2/compat/requirements.txt +6 -0
  20. package/packages/memory-engine-v2/compat/server.py +1047 -0
  21. package/packages/memory-engine-v2/docker-compose.aws.yml +78 -0
  22. package/packages/memory-engine-v2/docker-compose.yml +206 -0
  23. package/packages/memory-engine-v2/extractor-async/Dockerfile +14 -0
  24. package/packages/memory-engine-v2/extractor-async/confidence.py +62 -0
  25. package/packages/memory-engine-v2/extractor-async/noise_filter.py +144 -0
  26. package/packages/memory-engine-v2/extractor-async/requirements.txt +2 -0
  27. package/packages/memory-engine-v2/extractor-async/test_confidence.py +76 -0
  28. package/packages/memory-engine-v2/extractor-async/test_noise_filter.py +177 -0
  29. package/packages/memory-engine-v2/extractor-async/worker.py +797 -0
  30. package/packages/memory-engine-v2/extractor-sync/Dockerfile +11 -0
  31. package/packages/memory-engine-v2/extractor-sync/requirements.txt +4 -0
  32. package/packages/memory-engine-v2/extractor-sync/server.py +424 -0
  33. package/packages/memory-engine-v2/org-model/migrations/001_init.sql +390 -0
  34. package/packages/memory-engine-v2/tests/e2e_smoke.py +356 -0
  35. package/packages/memory-engine-v2/tests/fixtures/generate_synthetic_corpus.py +758 -0
  36. package/packages/memory-engine/.env.example +0 -13
  37. package/packages/memory-engine/MIGRATION.md +0 -219
  38. package/packages/memory-engine/README.md +0 -145
  39. package/packages/memory-engine/bench/README.md +0 -99
  40. package/packages/memory-engine/bench/scorecards-engine/agent-coding__pentatonic-baseline__20260427-142523.json +0 -1115
  41. package/packages/memory-engine/bench/scorecards-engine/chat-recall__pentatonic-baseline__20260427-142648.json +0 -819
  42. package/packages/memory-engine/bench/scorecards-engine/circular-economy__pentatonic-baseline__20260427-142757.json +0 -1278
  43. package/packages/memory-engine/bench/scorecards-engine/customer-support__pentatonic-baseline__20260427-142900.json +0 -1018
  44. package/packages/memory-engine/bench/scorecards-engine/marketplace-ops__pentatonic-baseline__20260427-142957.json +0 -1038
  45. package/packages/memory-engine/bench/scorecards-engine/product-catalogue__pentatonic-baseline__20260427-143122.json +0 -961
  46. package/packages/memory-engine/bench/scorecards-engine-via-docker/agent-coding__pentatonic-memory__20260427-161812.json +0 -1115
  47. package/packages/memory-engine/bench/scorecards-engine-via-docker/chat-recall__pentatonic-memory__20260427-161701.json +0 -819
  48. package/packages/memory-engine/bench/scorecards-engine-via-docker/circular-economy__pentatonic-memory__20260427-161713.json +0 -1278
  49. package/packages/memory-engine/bench/scorecards-engine-via-docker/customer-support__pentatonic-memory__20260427-161723.json +0 -1018
  50. package/packages/memory-engine/bench/scorecards-engine-via-docker/marketplace-ops__pentatonic-memory__20260427-161732.json +0 -1038
  51. package/packages/memory-engine/bench/scorecards-engine-via-docker/product-catalogue__pentatonic-memory__20260427-161741.json +0 -937
  52. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/agent-coding__pentatonic-memory__20260427-184718.json +0 -1115
  53. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/chat-recall__pentatonic-memory__20260427-184614.json +0 -819
  54. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/circular-economy__pentatonic-memory__20260427-184809.json +0 -1278
  55. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/customer-support__pentatonic-memory__20260427-184854.json +0 -1018
  56. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/marketplace-ops__pentatonic-memory__20260427-184929.json +0 -1038
  57. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/product-catalogue__pentatonic-memory__20260427-185015.json +0 -961
  58. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/agent-coding__pentatonic-memory__20260427-175252.json +0 -1115
  59. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/chat-recall__pentatonic-memory__20260427-175312.json +0 -819
  60. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/circular-economy__pentatonic-memory__20260427-175335.json +0 -1278
  61. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/customer-support__pentatonic-memory__20260427-175355.json +0 -1018
  62. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/marketplace-ops__pentatonic-memory__20260427-175413.json +0 -1038
  63. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/product-catalogue__pentatonic-memory__20260427-175430.json +0 -883
  64. package/packages/memory-engine/bench/scorecards-engine-via-shim/agent-coding__pentatonic-memory__20260427-155409.json +0 -1115
  65. package/packages/memory-engine/bench/scorecards-engine-via-shim/chat-recall__pentatonic-memory__20260427-155421.json +0 -819
  66. package/packages/memory-engine/bench/scorecards-engine-via-shim/circular-economy__pentatonic-memory__20260427-155433.json +0 -1278
  67. package/packages/memory-engine/bench/scorecards-engine-via-shim/customer-support__pentatonic-memory__20260427-155443.json +0 -1018
  68. package/packages/memory-engine/bench/scorecards-engine-via-shim/marketplace-ops__pentatonic-memory__20260427-155453.json +0 -1038
  69. package/packages/memory-engine/bench/scorecards-engine-via-shim/product-catalogue__pentatonic-memory__20260427-155503.json +0 -937
  70. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory-latest__20260427-145103.json +0 -1115
  71. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory__20260427-144909.json +0 -1115
  72. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory-latest__20260427-145153.json +0 -819
  73. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory__20260427-145120.json +0 -542
  74. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory-latest__20260427-145313.json +0 -1278
  75. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory__20260427-145207.json +0 -894
  76. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory-latest__20260427-145412.json +0 -1018
  77. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory__20260427-145327.json +0 -680
  78. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory-latest__20260427-145517.json +0 -1038
  79. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory__20260427-145422.json +0 -693
  80. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory-latest__20260427-145616.json +0 -961
  81. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory__20260427-145528.json +0 -727
  82. package/packages/memory-engine/compat/Dockerfile +0 -22
  83. package/packages/memory-engine/compat/server.py +0 -1255
  84. package/packages/memory-engine/docker-compose.test.yml +0 -59
  85. package/packages/memory-engine/docker-compose.yml +0 -240
  86. package/packages/memory-engine/engine/README.md +0 -52
  87. package/packages/memory-engine/engine/l2-hybridrag-proxy.py +0 -1543
  88. package/packages/memory-engine/engine/l5-comms-layer.py +0 -663
  89. package/packages/memory-engine/engine/l6-document-store.py +0 -1018
  90. package/packages/memory-engine/engine/services/_shared/__init__.py +0 -1
  91. package/packages/memory-engine/engine/services/_shared/embed_provider.py +0 -468
  92. package/packages/memory-engine/engine/services/l2/Dockerfile +0 -50
  93. package/packages/memory-engine/engine/services/l2/init_databases.py +0 -81
  94. package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +0 -2721
  95. package/packages/memory-engine/engine/services/l5/Dockerfile +0 -11
  96. package/packages/memory-engine/engine/services/l5/l5-comms-layer.py +0 -808
  97. package/packages/memory-engine/engine/services/l6/Dockerfile +0 -30
  98. package/packages/memory-engine/engine/services/l6/l6-document-store.py +0 -1221
  99. package/packages/memory-engine/engine/services/nv-embed/Dockerfile +0 -28
  100. package/packages/memory-engine/engine/services/nv-embed/server.py +0 -152
  101. package/packages/memory-engine/pme_memory/__init__.py +0 -0
  102. package/packages/memory-engine/pme_memory/__main__.py +0 -129
  103. package/packages/memory-engine/pme_memory/artifacts.py +0 -95
  104. package/packages/memory-engine/pme_memory/embed.py +0 -74
  105. package/packages/memory-engine/pme_memory/health.py +0 -36
  106. package/packages/memory-engine/pme_memory/hygiene.py +0 -159
  107. package/packages/memory-engine/pme_memory/indexer.py +0 -200
  108. package/packages/memory-engine/pme_memory/needs.py +0 -55
  109. package/packages/memory-engine/pme_memory/provenance.py +0 -80
  110. package/packages/memory-engine/pme_memory/scoring.py +0 -168
  111. package/packages/memory-engine/pme_memory/search.py +0 -52
  112. package/packages/memory-engine/pme_memory/store.py +0 -86
  113. package/packages/memory-engine/pme_memory/synthesis.py +0 -114
  114. package/packages/memory-engine/pyproject.toml +0 -65
  115. package/packages/memory-engine/scripts/kg-extractor.py +0 -557
  116. package/packages/memory-engine/scripts/kg-preflexor-v2.py +0 -738
  117. package/packages/memory-engine/scripts/wipe-legacy-l3-entities.py +0 -128
  118. package/packages/memory-engine/tests/e2e_arena.sh +0 -259
  119. package/packages/memory-engine/tests/embed_stub/Dockerfile +0 -13
  120. package/packages/memory-engine/tests/embed_stub/server.py +0 -80
  121. package/packages/memory-engine/tests/test_aggregate.py +0 -333
  122. package/packages/memory-engine/tests/test_api_contract.sh +0 -57
  123. package/packages/memory-engine/tests/test_arena_safety.py +0 -232
  124. package/packages/memory-engine/tests/test_channel_stat_reader.py +0 -437
  125. package/packages/memory-engine/tests/test_channel_stat_rollups.py +0 -308
  126. package/packages/memory-engine/tests/test_compat_nv_embed_probe.py +0 -48
  127. package/packages/memory-engine/tests/test_embed_provider.py +0 -492
  128. package/packages/memory-engine/tests/test_l2_qmd_vec_search.py +0 -280
  129. package/packages/memory-engine/tests/test_l3_arena_isolation.py +0 -412
  130. package/packages/memory-engine/tests/test_l6_module_load.py +0 -84
  131. 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())