prismlib-plus 0.7.0__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.
- prism/__init__.py +9 -0
- prism/api/__init__.py +83 -0
- prism/api/auth.py +127 -0
- prism/api/consumer.py +573 -0
- prism/api/integrations/__init__.py +30 -0
- prism/api/integrations/langgraph.py +382 -0
- prism/api/mcp.py +341 -0
- prism/api/multi_provider.py +315 -0
- prism/api/provider.py +496 -0
- prism/api/schema.py +296 -0
- prism/bridge/__init__.py +32 -0
- prism/bridge/vector.py +704 -0
- prism/cache/__init__.py +54 -0
- prism/cache/cache.py +637 -0
- prism/cache/embedder.py +438 -0
- prism/cache/metrics.py +273 -0
- prism/cache/store.py +370 -0
- prism/cluster/__init__.py +20 -0
- prism/cluster/alerts.py +550 -0
- prism/cluster/cache.py +480 -0
- prism/cluster/health.py +87 -0
- prism/cluster/node.py +288 -0
- prism/cluster/transport.py +81 -0
- prism/enterprise/__init__.py +5 -0
- prism/enterprise/app.py +70 -0
- prism/ffi/__init__.py +34 -0
- prism/ffi/bindings.py +1055 -0
- prism/ffi/grpc_client.py +121 -0
- prism/lib/__init__.py +17 -0
- prism/lib/fabric.py +1141 -0
- prism/lib/lang.py +531 -0
- prism/lib/resonance.py +691 -0
- prism/observability/__init__.py +110 -0
- prism/observability/otel.py +85 -0
- prism/observability/prometheus.py +11 -0
- prism/security/__init__.py +17 -0
- prism/security/audit.py +80 -0
- prism/security/rate_limit.py +74 -0
- prism/security/tls.py +48 -0
- prism/wrapper/__init__.py +49 -0
- prism/wrapper/config.py +132 -0
- prism/wrapper/daemon.py +261 -0
- prism/wrapper/grpc_server.py +248 -0
- prism/wrapper/interceptor.py +543 -0
- prism/wrapper/main.py +5 -0
- prism/wrapper/proto/__init__.py +0 -0
- prism/wrapper/proto/chorus_pb2.py +71 -0
- prism/wrapper/proto/chorus_pb2_grpc.py +454 -0
- prism/wrapper/publisher.py +255 -0
- prism/wrapper/row_events.py +129 -0
- prism/wrapper/subscribe_server.py +78 -0
- prismlib_plus-0.7.0.dist-info/METADATA +748 -0
- prismlib_plus-0.7.0.dist-info/RECORD +56 -0
- prismlib_plus-0.7.0.dist-info/WHEEL +5 -0
- prismlib_plus-0.7.0.dist-info/entry_points.txt +2 -0
- prismlib_plus-0.7.0.dist-info/top_level.txt +1 -0
prism/api/consumer.py
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""
|
|
2
|
+
prism.api.consumer — PrismAPIClient and LangGraphTool
|
|
3
|
+
======================================================
|
|
4
|
+
|
|
5
|
+
The consumer side of PrismAPI. An agent (e.g. in LangGraph) uses
|
|
6
|
+
PrismAPIClient instead of a plain HTTP client to query a PrismAPIProvider
|
|
7
|
+
endpoint. The difference:
|
|
8
|
+
|
|
9
|
+
Plain HTTP client:
|
|
10
|
+
agent → HTTP GET /search?q=... → JSON body with text
|
|
11
|
+
agent → embed(text) → float32 vector
|
|
12
|
+
agent → retrieval step with vector
|
|
13
|
+
Cost: 1 HTTP call + 1 embedding call per search
|
|
14
|
+
|
|
15
|
+
PrismAPIClient:
|
|
16
|
+
agent → CHORUS API_REQUEST (query vector) → API_RESPONSE (float32 vectors)
|
|
17
|
+
agent → retrieval step with vectors (already embedded + projected)
|
|
18
|
+
Cost: 1 CHORUS frame round-trip, 0 embedding calls on the consumer side
|
|
19
|
+
|
|
20
|
+
The embedding call is not eliminated globally — the PROVIDER embeds the
|
|
21
|
+
content once when it indexes it. What is eliminated is the CONSUMER re-
|
|
22
|
+
embedding on every retrieval. At scale, a consumer that handles 1,000 queries
|
|
23
|
+
per second and gets 10 results per query avoids 10,000 embedding API calls
|
|
24
|
+
per second.
|
|
25
|
+
|
|
26
|
+
In-process vs networked
|
|
27
|
+
-----------------------
|
|
28
|
+
PrismAPIClient works in two modes:
|
|
29
|
+
|
|
30
|
+
Networked (production):
|
|
31
|
+
Pass `host` and `port` pointing at a server running ASGIAdapter.
|
|
32
|
+
Frames travel over HTTP(S) with Content-Type: application/x-chorus-frame.
|
|
33
|
+
(Full gRPC support is on the roadmap; HTTP transport is sufficient for
|
|
34
|
+
throughput at typical retrieval scales.)
|
|
35
|
+
|
|
36
|
+
In-process (loopback):
|
|
37
|
+
Pass a PrismAPIProvider directly as `loopback_provider`.
|
|
38
|
+
Frames are serialised and deserialised in memory — identical wire path,
|
|
39
|
+
no network involved. Used in benchmarks and tests.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import http.client
|
|
45
|
+
import logging
|
|
46
|
+
import time
|
|
47
|
+
import uuid
|
|
48
|
+
from dataclasses import dataclass, field
|
|
49
|
+
from typing import Any, Callable, Optional, Sequence
|
|
50
|
+
|
|
51
|
+
import numpy as np
|
|
52
|
+
|
|
53
|
+
from prism.lib.fabric import CHORUSFrame, FabricConfig, FrameType, TensorCipher
|
|
54
|
+
from prism.lib.lang import PrismProjector, ProjectionConfig
|
|
55
|
+
from prism.api.schema import (
|
|
56
|
+
APIRequest,
|
|
57
|
+
APIResponse,
|
|
58
|
+
Embedder,
|
|
59
|
+
ExactSidecar,
|
|
60
|
+
SemanticItem,
|
|
61
|
+
unpack_response_payload,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
logger = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Retry configuration
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class RetryConfig:
|
|
74
|
+
"""
|
|
75
|
+
Controls retry behaviour for the production HTTP client.
|
|
76
|
+
|
|
77
|
+
Attributes
|
|
78
|
+
----------
|
|
79
|
+
max_retries:
|
|
80
|
+
Maximum number of retry attempts after the initial failure (0 = no retry).
|
|
81
|
+
backoff_base:
|
|
82
|
+
Base sleep duration in seconds. Sleep doubles each retry:
|
|
83
|
+
backoff_base, 2×, 4× ... Capped at backoff_max.
|
|
84
|
+
backoff_max:
|
|
85
|
+
Maximum sleep between retries.
|
|
86
|
+
timeout_connect:
|
|
87
|
+
TCP connect timeout in seconds.
|
|
88
|
+
timeout_read:
|
|
89
|
+
Socket read timeout in seconds.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
max_retries: int = 3
|
|
93
|
+
backoff_base: float = 0.5
|
|
94
|
+
backoff_max: float = 8.0
|
|
95
|
+
timeout_connect: float = 5.0
|
|
96
|
+
timeout_read: float = 30.0
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def timeout(self) -> float:
|
|
100
|
+
"""Total socket timeout (connect + read)."""
|
|
101
|
+
return self.timeout_connect + self.timeout_read
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Errors
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ConsumerError(Exception):
|
|
110
|
+
"""Base error for PrismAPIClient operations."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class FrameTypeError(ConsumerError):
|
|
114
|
+
"""Raised when the server returns an unexpected frame type."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TransportError(ConsumerError):
|
|
118
|
+
"""Raised on network-level failures."""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# PrismAPIClient
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class PrismAPIClient:
|
|
127
|
+
"""
|
|
128
|
+
Connects to a PrismAPIProvider endpoint and retrieves float32 vectors
|
|
129
|
+
plus exact sidecar metadata — no re-embedding on the consumer side.
|
|
130
|
+
|
|
131
|
+
Parameters
|
|
132
|
+
----------
|
|
133
|
+
projector:
|
|
134
|
+
PrismProjector for the consumer's tenant. Used to project the
|
|
135
|
+
query embedding into the provider's target_dim space before sending.
|
|
136
|
+
embedder:
|
|
137
|
+
Embedder used for the query text. The consumer still embeds its
|
|
138
|
+
OWN query — what it avoids is embedding the RESULTS it gets back.
|
|
139
|
+
loopback_provider:
|
|
140
|
+
If provided, frames are exchanged in-process (for tests / benchmarks).
|
|
141
|
+
Mutually exclusive with host/port.
|
|
142
|
+
host, port:
|
|
143
|
+
Remote PrismAPIProvider address for networked mode.
|
|
144
|
+
source_field:
|
|
145
|
+
Label to attach to returned SemanticItems (informational only).
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def __init__(
|
|
149
|
+
self,
|
|
150
|
+
projector: PrismProjector,
|
|
151
|
+
embedder: Embedder,
|
|
152
|
+
loopback_provider: Optional[Any] = None, # PrismAPIProvider
|
|
153
|
+
host: str = "localhost",
|
|
154
|
+
port: int = 9100,
|
|
155
|
+
source_field: str = "body",
|
|
156
|
+
retry: Optional[RetryConfig] = None,
|
|
157
|
+
chorus_path: str = "/chorus/search",
|
|
158
|
+
api_key: Optional[str] = None,
|
|
159
|
+
bearer_token: Optional[str] = None,
|
|
160
|
+
) -> None:
|
|
161
|
+
self._projector = projector
|
|
162
|
+
self._embedder = embedder
|
|
163
|
+
self._loopback = loopback_provider
|
|
164
|
+
self._host = host
|
|
165
|
+
self._port = port
|
|
166
|
+
self._source_field = source_field
|
|
167
|
+
self._retry = retry or RetryConfig()
|
|
168
|
+
self._chorus_path = chorus_path
|
|
169
|
+
self._api_key = api_key
|
|
170
|
+
self._bearer_token = bearer_token
|
|
171
|
+
|
|
172
|
+
# Persistent HTTP connection (keep-alive, reused across requests)
|
|
173
|
+
self._conn: Optional[http.client.HTTPConnection] = None
|
|
174
|
+
|
|
175
|
+
# Cipher for signing outbound request frames
|
|
176
|
+
dim = projector._cfg.target_dim
|
|
177
|
+
self._cipher = TensorCipher(dim=dim, ttl_seconds=3600.0)
|
|
178
|
+
self._cipher.rotate_key()
|
|
179
|
+
self._seq = 0
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
# Connection management
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def _get_conn(self) -> http.client.HTTPConnection:
|
|
186
|
+
"""Return a live persistent connection, creating one if needed."""
|
|
187
|
+
if self._conn is None:
|
|
188
|
+
self._conn = http.client.HTTPConnection(
|
|
189
|
+
self._host,
|
|
190
|
+
self._port,
|
|
191
|
+
timeout=self._retry.timeout,
|
|
192
|
+
)
|
|
193
|
+
return self._conn
|
|
194
|
+
|
|
195
|
+
def _close_conn(self) -> None:
|
|
196
|
+
if self._conn is not None:
|
|
197
|
+
try:
|
|
198
|
+
self._conn.close()
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
self._conn = None
|
|
202
|
+
|
|
203
|
+
def close(self) -> None:
|
|
204
|
+
"""Close the persistent HTTP connection."""
|
|
205
|
+
self._close_conn()
|
|
206
|
+
|
|
207
|
+
def __enter__(self) -> "PrismAPIClient":
|
|
208
|
+
return self
|
|
209
|
+
|
|
210
|
+
def __exit__(self, *_: Any) -> None:
|
|
211
|
+
self.close()
|
|
212
|
+
|
|
213
|
+
def health_check(self) -> bool:
|
|
214
|
+
"""
|
|
215
|
+
Ping the server's /health endpoint.
|
|
216
|
+
|
|
217
|
+
Returns True if the server responds 200 OK, False otherwise.
|
|
218
|
+
"""
|
|
219
|
+
try:
|
|
220
|
+
conn = self._get_conn()
|
|
221
|
+
conn.request("GET", "/health")
|
|
222
|
+
resp = conn.getresponse()
|
|
223
|
+
resp.read()
|
|
224
|
+
return resp.status == 200
|
|
225
|
+
except Exception:
|
|
226
|
+
self._close_conn()
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
# Main query interface
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def query(
|
|
234
|
+
self,
|
|
235
|
+
query_text: str,
|
|
236
|
+
top_k: int = 10,
|
|
237
|
+
extra_context: Optional[dict[str, Any]] = None,
|
|
238
|
+
) -> APIResponse:
|
|
239
|
+
"""
|
|
240
|
+
Embed ``query_text``, send as a CHORUS API_REQUEST, return an
|
|
241
|
+
APIResponse with pre-projected float32 vectors.
|
|
242
|
+
|
|
243
|
+
The consumer embeds the query once. The provider returns already-
|
|
244
|
+
projected result vectors — no second embed() call is made here.
|
|
245
|
+
|
|
246
|
+
Parameters
|
|
247
|
+
----------
|
|
248
|
+
query_text:
|
|
249
|
+
The agent's natural-language query.
|
|
250
|
+
top_k:
|
|
251
|
+
Number of results to request.
|
|
252
|
+
extra_context:
|
|
253
|
+
Additional query parameters forwarded to the provider.
|
|
254
|
+
|
|
255
|
+
Returns
|
|
256
|
+
-------
|
|
257
|
+
APIResponse — results are (SemanticItem, ExactSidecar) pairs.
|
|
258
|
+
Use response.vectors for a stacked (N, dim) array ready for
|
|
259
|
+
PrismResonance retrieval.
|
|
260
|
+
"""
|
|
261
|
+
from prism.observability.otel import trace_span
|
|
262
|
+
|
|
263
|
+
with trace_span(
|
|
264
|
+
"prism.api.client.query",
|
|
265
|
+
host=self._host,
|
|
266
|
+
port=self._port,
|
|
267
|
+
loopback=self._loopback is not None,
|
|
268
|
+
):
|
|
269
|
+
t0 = time.perf_counter()
|
|
270
|
+
|
|
271
|
+
# Embed query (one call — the only embedding on the consumer side)
|
|
272
|
+
raw_emb = self._embedder.embed([query_text])[0] # (embed_dim,)
|
|
273
|
+
envelope = self._projector.project(raw_emb)
|
|
274
|
+
query_vec = envelope.vector # (target_dim,) float32
|
|
275
|
+
|
|
276
|
+
context: dict[str, Any] = {
|
|
277
|
+
"query_text": query_text,
|
|
278
|
+
"top_k": top_k,
|
|
279
|
+
**(extra_context or {}),
|
|
280
|
+
}
|
|
281
|
+
request = APIRequest(query_vector=query_vec, context=context)
|
|
282
|
+
|
|
283
|
+
# Exchange frames
|
|
284
|
+
resp_frame = self._exchange(request)
|
|
285
|
+
|
|
286
|
+
# Decode response
|
|
287
|
+
raw_results = resp_frame.decode_api_response()
|
|
288
|
+
result_pairs = unpack_response_payload(raw_results, source_field=self._source_field)
|
|
289
|
+
|
|
290
|
+
latency_ms = (time.perf_counter() - t0) * 1000.0
|
|
291
|
+
return APIResponse(
|
|
292
|
+
results=result_pairs,
|
|
293
|
+
provider_id="remote" if self._loopback is None else self._loopback._provider_id,
|
|
294
|
+
request_id=request.request_id,
|
|
295
|
+
latency_ms=latency_ms,
|
|
296
|
+
embedding_calls_saved=len(result_pairs),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def query_vector(
|
|
300
|
+
self,
|
|
301
|
+
query_vector: np.ndarray,
|
|
302
|
+
top_k: int = 10,
|
|
303
|
+
extra_context: Optional[dict[str, Any]] = None,
|
|
304
|
+
) -> APIResponse:
|
|
305
|
+
"""
|
|
306
|
+
Send a pre-computed query vector (already in target_dim space)
|
|
307
|
+
without any embedding step.
|
|
308
|
+
|
|
309
|
+
Use this when the agent already has a vector (e.g. from a previous
|
|
310
|
+
retrieval step) and wants to find similar content from the provider.
|
|
311
|
+
Zero embedding calls.
|
|
312
|
+
"""
|
|
313
|
+
t0 = time.perf_counter()
|
|
314
|
+
|
|
315
|
+
context: dict[str, Any] = {"top_k": top_k, **(extra_context or {})}
|
|
316
|
+
request = APIRequest(query_vector=query_vector, context=context)
|
|
317
|
+
resp_frame = self._exchange(request)
|
|
318
|
+
raw_results = resp_frame.decode_api_response()
|
|
319
|
+
result_pairs = unpack_response_payload(raw_results, source_field=self._source_field)
|
|
320
|
+
|
|
321
|
+
latency_ms = (time.perf_counter() - t0) * 1000.0
|
|
322
|
+
return APIResponse(
|
|
323
|
+
results=result_pairs,
|
|
324
|
+
provider_id="remote" if self._loopback is None else self._loopback._provider_id,
|
|
325
|
+
request_id=request.request_id,
|
|
326
|
+
latency_ms=latency_ms,
|
|
327
|
+
embedding_calls_saved=len(result_pairs),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# ------------------------------------------------------------------
|
|
331
|
+
# Frame exchange
|
|
332
|
+
# ------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
def _exchange(self, request: APIRequest) -> CHORUSFrame:
|
|
335
|
+
"""
|
|
336
|
+
Serialise the request as a CHORUSFrame and return the response frame.
|
|
337
|
+
|
|
338
|
+
In loopback mode: in-process frame serialisation → provider → frame bytes.
|
|
339
|
+
In networked mode: HTTP POST with Content-Type: application/x-chorus-frame.
|
|
340
|
+
"""
|
|
341
|
+
key = self._cipher._active_key # type: ignore[union-attr]
|
|
342
|
+
|
|
343
|
+
# Build request frame
|
|
344
|
+
req_frame = CHORUSFrame.from_api_request(
|
|
345
|
+
key_id=key.key_id,
|
|
346
|
+
seq=self._seq,
|
|
347
|
+
watermark=b"\x00" * 32, # consumer watermark — provider verifies its own
|
|
348
|
+
query_vector=request.query_vector,
|
|
349
|
+
context=request.context,
|
|
350
|
+
)
|
|
351
|
+
self._seq += 1
|
|
352
|
+
|
|
353
|
+
if self._loopback is not None:
|
|
354
|
+
return self._loopback_exchange(req_frame, request.context)
|
|
355
|
+
return self._http_exchange(req_frame)
|
|
356
|
+
|
|
357
|
+
def _loopback_exchange(
|
|
358
|
+
self, req_frame: CHORUSFrame, context: dict[str, Any]
|
|
359
|
+
) -> CHORUSFrame:
|
|
360
|
+
"""In-process exchange: serialise → provider.handle → deserialise."""
|
|
361
|
+
# Wire path: to_bytes() → from_bytes() exercises full serialisation
|
|
362
|
+
wire_bytes = req_frame.to_bytes()
|
|
363
|
+
decoded_req = CHORUSFrame.from_bytes(wire_bytes)
|
|
364
|
+
query_vec, ctx = decoded_req.decode_api_request()
|
|
365
|
+
|
|
366
|
+
query_text = ctx.get("query_text", "")
|
|
367
|
+
top_k = int(ctx.get("top_k", 10))
|
|
368
|
+
|
|
369
|
+
# Invoke the loopback provider directly
|
|
370
|
+
result_dicts = self._loopback._invoke_handler(query_text, query_vec, top_k)
|
|
371
|
+
resp_frame = self._loopback.as_chorus_frame(result_dicts)
|
|
372
|
+
|
|
373
|
+
# Round-trip through bytes to include wire overhead in timing
|
|
374
|
+
return CHORUSFrame.from_bytes(resp_frame.to_bytes())
|
|
375
|
+
|
|
376
|
+
def _http_exchange(self, req_frame: CHORUSFrame) -> CHORUSFrame:
|
|
377
|
+
"""
|
|
378
|
+
Networked exchange over HTTP with retry and persistent connection.
|
|
379
|
+
|
|
380
|
+
Uses http.client.HTTPConnection with keep-alive for connection reuse.
|
|
381
|
+
Retries on transient errors (ConnectionError, timeout) with exponential
|
|
382
|
+
backoff. Re-establishes connection after any transport failure.
|
|
383
|
+
"""
|
|
384
|
+
data = req_frame.to_bytes()
|
|
385
|
+
headers = {
|
|
386
|
+
"Content-Type": "application/x-chorus-frame",
|
|
387
|
+
"Content-Length": str(len(data)),
|
|
388
|
+
"Connection": "keep-alive",
|
|
389
|
+
}
|
|
390
|
+
if self._api_key:
|
|
391
|
+
headers["X-API-Key"] = self._api_key
|
|
392
|
+
if self._bearer_token:
|
|
393
|
+
headers["Authorization"] = f"Bearer {self._bearer_token}"
|
|
394
|
+
|
|
395
|
+
last_exc: Optional[Exception] = None
|
|
396
|
+
for attempt in range(self._retry.max_retries + 1):
|
|
397
|
+
if attempt > 0:
|
|
398
|
+
sleep_s = min(
|
|
399
|
+
self._retry.backoff_base * (2 ** (attempt - 1)),
|
|
400
|
+
self._retry.backoff_max,
|
|
401
|
+
)
|
|
402
|
+
logger.warning(
|
|
403
|
+
"PrismAPIClient: retry %d/%d after %.1fs (error: %s)",
|
|
404
|
+
attempt,
|
|
405
|
+
self._retry.max_retries,
|
|
406
|
+
sleep_s,
|
|
407
|
+
last_exc,
|
|
408
|
+
)
|
|
409
|
+
time.sleep(sleep_s)
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
conn = self._get_conn()
|
|
413
|
+
conn.request("POST", self._chorus_path, body=data, headers=headers)
|
|
414
|
+
resp = conn.getresponse()
|
|
415
|
+
body = resp.read()
|
|
416
|
+
|
|
417
|
+
if resp.status != 200:
|
|
418
|
+
raise TransportError(
|
|
419
|
+
f"Server returned HTTP {resp.status}: {body[:200]}"
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
frame = CHORUSFrame.from_bytes(body)
|
|
423
|
+
if frame.frame_type != FrameType.API_RESPONSE:
|
|
424
|
+
raise FrameTypeError(
|
|
425
|
+
f"Expected API_RESPONSE, got {frame.frame_type.name}"
|
|
426
|
+
)
|
|
427
|
+
return frame
|
|
428
|
+
|
|
429
|
+
except (FrameTypeError, TransportError):
|
|
430
|
+
# Non-retryable protocol errors — surface immediately
|
|
431
|
+
raise
|
|
432
|
+
except Exception as exc:
|
|
433
|
+
last_exc = exc
|
|
434
|
+
# Connection-level error — drop and reconnect on next attempt
|
|
435
|
+
self._close_conn()
|
|
436
|
+
|
|
437
|
+
raise TransportError(
|
|
438
|
+
f"HTTP exchange failed after {self._retry.max_retries + 1} attempts: {last_exc}"
|
|
439
|
+
) from last_exc
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# ---------------------------------------------------------------------------
|
|
443
|
+
# LangGraphTool — thin adapter for LangGraph agent nodes
|
|
444
|
+
# ---------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
class LangGraphTool:
|
|
448
|
+
"""
|
|
449
|
+
Exposes PrismAPIClient as a LangGraph-compatible tool node.
|
|
450
|
+
|
|
451
|
+
The agent calls the tool by name; the tool queries the PrismAPIProvider
|
|
452
|
+
and returns a dict with ``vectors`` (np.ndarray) and ``results``
|
|
453
|
+
(list of sidecar dicts) that the agent can use directly.
|
|
454
|
+
|
|
455
|
+
Usage in a LangGraph graph::
|
|
456
|
+
|
|
457
|
+
from prism.api.consumer import LangGraphTool
|
|
458
|
+
|
|
459
|
+
tool = LangGraphTool(
|
|
460
|
+
name="semantic_search",
|
|
461
|
+
description="Search the knowledge base by semantic meaning.",
|
|
462
|
+
client=my_prism_client,
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
# In a LangGraph node:
|
|
466
|
+
result = tool.invoke({"query": "how does inflation affect bonds?"})
|
|
467
|
+
# result["vectors"] — np.ndarray (N, 64), ready for PrismResonance
|
|
468
|
+
# result["sidecars"] — list of exact metadata dicts
|
|
469
|
+
# result["top_k"] — int, number of results
|
|
470
|
+
|
|
471
|
+
LangGraph integration (optional import)::
|
|
472
|
+
|
|
473
|
+
# If langgraph is installed, tool.as_langgraph_node() returns a node
|
|
474
|
+
# function compatible with StateGraph.add_node().
|
|
475
|
+
node = tool.as_langgraph_node()
|
|
476
|
+
"""
|
|
477
|
+
|
|
478
|
+
def __init__(
|
|
479
|
+
self,
|
|
480
|
+
name: str,
|
|
481
|
+
description: str,
|
|
482
|
+
client: PrismAPIClient,
|
|
483
|
+
top_k: int = 10,
|
|
484
|
+
) -> None:
|
|
485
|
+
self.name = name
|
|
486
|
+
self.description = description
|
|
487
|
+
self._client = client
|
|
488
|
+
self._top_k = top_k
|
|
489
|
+
|
|
490
|
+
def invoke(self, input_dict: dict[str, Any]) -> dict[str, Any]:
|
|
491
|
+
"""
|
|
492
|
+
Synchronous tool invocation.
|
|
493
|
+
|
|
494
|
+
Parameters
|
|
495
|
+
----------
|
|
496
|
+
input_dict:
|
|
497
|
+
Must contain "query" (str). Optional: "top_k" (int).
|
|
498
|
+
|
|
499
|
+
Returns
|
|
500
|
+
-------
|
|
501
|
+
dict with keys:
|
|
502
|
+
"vectors" — np.ndarray (N, dim), stacked result vectors
|
|
503
|
+
"sidecars" — list[dict], exact metadata for each result
|
|
504
|
+
"top_k" — int, actual number of results returned
|
|
505
|
+
"latency_ms" — float, end-to-end latency
|
|
506
|
+
"""
|
|
507
|
+
query = str(input_dict.get("query", ""))
|
|
508
|
+
top_k = int(input_dict.get("top_k", self._top_k))
|
|
509
|
+
if not query:
|
|
510
|
+
return {"vectors": np.empty((0,), np.float32), "sidecars": [], "top_k": 0}
|
|
511
|
+
|
|
512
|
+
response = self._client.query(query, top_k=top_k)
|
|
513
|
+
return {
|
|
514
|
+
"vectors": response.vectors,
|
|
515
|
+
"sidecars": response.sidecars,
|
|
516
|
+
"top_k": len(response.results),
|
|
517
|
+
"latency_ms": response.latency_ms,
|
|
518
|
+
"embedding_calls_saved": response.embedding_calls_saved,
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
def as_langgraph_node(self) -> Callable:
|
|
522
|
+
"""
|
|
523
|
+
Return a LangGraph node function.
|
|
524
|
+
|
|
525
|
+
Requires: pip install langgraph
|
|
526
|
+
The returned function signature is ``node(state: dict) -> dict``,
|
|
527
|
+
compatible with ``StateGraph.add_node(name, node)``.
|
|
528
|
+
"""
|
|
529
|
+
try:
|
|
530
|
+
from langgraph.graph import StateGraph # type: ignore[import] # noqa: F401
|
|
531
|
+
except ImportError:
|
|
532
|
+
logger.warning(
|
|
533
|
+
"langgraph not installed — as_langgraph_node() returns a plain "
|
|
534
|
+
"callable usable as a node function, but graph registration "
|
|
535
|
+
"requires: pip install langgraph"
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
tool = self
|
|
539
|
+
|
|
540
|
+
def node(state: dict[str, Any]) -> dict[str, Any]:
|
|
541
|
+
query = state.get("query", state.get("input", ""))
|
|
542
|
+
result = tool.invoke({"query": query})
|
|
543
|
+
return {**state, "prismapi_result": result}
|
|
544
|
+
|
|
545
|
+
node.__name__ = self.name
|
|
546
|
+
return node
|
|
547
|
+
|
|
548
|
+
# ------------------------------------------------------------------
|
|
549
|
+
# Tool schema for MCP / OpenAI function-calling
|
|
550
|
+
# ------------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
@property
|
|
553
|
+
def tool_schema(self) -> dict[str, Any]:
|
|
554
|
+
"""JSON schema describing this tool for MCP or function-calling."""
|
|
555
|
+
return {
|
|
556
|
+
"name": self.name,
|
|
557
|
+
"description": self.description,
|
|
558
|
+
"parameters": {
|
|
559
|
+
"type": "object",
|
|
560
|
+
"properties": {
|
|
561
|
+
"query": {
|
|
562
|
+
"type": "string",
|
|
563
|
+
"description": "Natural-language search query.",
|
|
564
|
+
},
|
|
565
|
+
"top_k": {
|
|
566
|
+
"type": "integer",
|
|
567
|
+
"description": "Number of results to return.",
|
|
568
|
+
"default": self._top_k,
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
"required": ["query"],
|
|
572
|
+
},
|
|
573
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
prism.api.integrations — Framework adapters for PrismAPI
|
|
3
|
+
=========================================================
|
|
4
|
+
|
|
5
|
+
Available integrations:
|
|
6
|
+
|
|
7
|
+
from prism.api.integrations.langgraph import (
|
|
8
|
+
PrismRetrieverNode,
|
|
9
|
+
MultiProviderRetrieverNode,
|
|
10
|
+
create_retriever_node,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from prism.api.integrations.langchain import PrismRetriever
|
|
14
|
+
|
|
15
|
+
Each integration is an optional import — the framework dependency is not
|
|
16
|
+
required at install time. Missing framework raises ImportError with a
|
|
17
|
+
clear install instruction.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from prism.api.integrations.langgraph import (
|
|
21
|
+
MultiProviderRetrieverNode,
|
|
22
|
+
PrismRetrieverNode,
|
|
23
|
+
create_retriever_node,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"PrismRetrieverNode",
|
|
28
|
+
"MultiProviderRetrieverNode",
|
|
29
|
+
"create_retriever_node",
|
|
30
|
+
]
|