langchain-vectoramp 0.1.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.
- langchain_vectoramp/__init__.py +7 -0
- langchain_vectoramp/client.py +333 -0
- langchain_vectoramp/intelligence.py +196 -0
- langchain_vectoramp/loaders.py +128 -0
- langchain_vectoramp/py.typed +0 -0
- langchain_vectoramp/vectorstores.py +328 -0
- langchain_vectoramp-0.1.0.dist-info/METADATA +302 -0
- langchain_vectoramp-0.1.0.dist-info/RECORD +11 -0
- langchain_vectoramp-0.1.0.dist-info/WHEEL +4 -0
- langchain_vectoramp-0.1.0.dist-info/licenses/LICENSE +201 -0
- langchain_vectoramp-0.1.0.dist-info/licenses/NOTICE +8 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Small internal VectorAmp HTTP client used by the LangChain adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from typing import Any, Optional, Union
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
JSON = dict[str, Any]
|
|
13
|
+
FilterValue = Union[str, int, float, bool, None]
|
|
14
|
+
Filters = Mapping[str, FilterValue]
|
|
15
|
+
|
|
16
|
+
# A vector record id may be a string or an integer. Integer ids must round-trip
|
|
17
|
+
# to the API as JSON numbers (not strings), otherwise the server rewrites them.
|
|
18
|
+
VectorId = Union[str, int]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VectorAmpClientError(RuntimeError):
|
|
22
|
+
"""Raised when the VectorAmp API returns an error."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class VectorAmpHTTPClient:
|
|
26
|
+
"""Minimal client for dataset text insertion and text search endpoints."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
api_key: Optional[str] = None,
|
|
32
|
+
base_url: str = "https://api.vectoramp.com",
|
|
33
|
+
timeout: float = 30.0,
|
|
34
|
+
http_client: Optional[httpx.Client] = None,
|
|
35
|
+
async_http_client: Optional[httpx.AsyncClient] = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.api_key = api_key or os.getenv("VECTORAMP_API_KEY") or ""
|
|
38
|
+
if not self.api_key:
|
|
39
|
+
raise ValueError("api_key is required or VECTORAMP_API_KEY must be set.")
|
|
40
|
+
self.base_url = base_url.rstrip("/")
|
|
41
|
+
self._owns_client = http_client is None
|
|
42
|
+
self._owns_async_client = async_http_client is None
|
|
43
|
+
self._client = http_client or httpx.Client(timeout=timeout)
|
|
44
|
+
self._async_client = async_http_client or httpx.AsyncClient(timeout=timeout)
|
|
45
|
+
|
|
46
|
+
def close(self) -> None:
|
|
47
|
+
if self._owns_client:
|
|
48
|
+
self._client.close()
|
|
49
|
+
|
|
50
|
+
async def aclose(self) -> None:
|
|
51
|
+
if self._owns_async_client:
|
|
52
|
+
await self._async_client.aclose()
|
|
53
|
+
|
|
54
|
+
def get_dataset_id(self, *, dataset_id: Optional[str], dataset_name: Optional[str]) -> str:
|
|
55
|
+
if dataset_id:
|
|
56
|
+
return dataset_id
|
|
57
|
+
if not dataset_name:
|
|
58
|
+
raise ValueError("dataset_id or dataset_name is required.")
|
|
59
|
+
page = self._request("GET", "/datasets", params={"limit": 100, "offset": 0})
|
|
60
|
+
for dataset in page.get("datasets", []):
|
|
61
|
+
if dataset.get("name") == dataset_name:
|
|
62
|
+
value = dataset.get("id") or dataset.get("dataset_id")
|
|
63
|
+
if value is not None:
|
|
64
|
+
return str(value)
|
|
65
|
+
raise ValueError(f"Dataset named {dataset_name!r} was not found.")
|
|
66
|
+
|
|
67
|
+
async def aget_dataset_id(
|
|
68
|
+
self, *, dataset_id: Optional[str], dataset_name: Optional[str]
|
|
69
|
+
) -> str:
|
|
70
|
+
if dataset_id:
|
|
71
|
+
return dataset_id
|
|
72
|
+
if not dataset_name:
|
|
73
|
+
raise ValueError("dataset_id or dataset_name is required.")
|
|
74
|
+
page = await self._arequest("GET", "/datasets", params={"limit": 100, "offset": 0})
|
|
75
|
+
for dataset in page.get("datasets", []):
|
|
76
|
+
if dataset.get("name") == dataset_name:
|
|
77
|
+
value = dataset.get("id") or dataset.get("dataset_id")
|
|
78
|
+
if value is not None:
|
|
79
|
+
return str(value)
|
|
80
|
+
raise ValueError(f"Dataset named {dataset_name!r} was not found.")
|
|
81
|
+
|
|
82
|
+
def add_texts(
|
|
83
|
+
self,
|
|
84
|
+
dataset_id: str,
|
|
85
|
+
texts: Sequence[str],
|
|
86
|
+
*,
|
|
87
|
+
ids: Optional[Sequence[VectorId]] = None,
|
|
88
|
+
metadatas: Optional[Sequence[Mapping[str, Any]]] = None,
|
|
89
|
+
) -> list[VectorId]:
|
|
90
|
+
embeddings = self._embed(dataset_id, texts)
|
|
91
|
+
vector_ids = self._build_ids(texts, ids)
|
|
92
|
+
self._insert_vectors(dataset_id, texts, embeddings, vector_ids, metadatas)
|
|
93
|
+
return vector_ids
|
|
94
|
+
|
|
95
|
+
async def aadd_texts(
|
|
96
|
+
self,
|
|
97
|
+
dataset_id: str,
|
|
98
|
+
texts: Sequence[str],
|
|
99
|
+
*,
|
|
100
|
+
ids: Optional[Sequence[VectorId]] = None,
|
|
101
|
+
metadatas: Optional[Sequence[Mapping[str, Any]]] = None,
|
|
102
|
+
) -> list[VectorId]:
|
|
103
|
+
embeddings = await self._aembed(dataset_id, texts)
|
|
104
|
+
vector_ids = self._build_ids(texts, ids)
|
|
105
|
+
await self._ainsert_vectors(dataset_id, texts, embeddings, vector_ids, metadatas)
|
|
106
|
+
return vector_ids
|
|
107
|
+
|
|
108
|
+
def search(self, dataset_id: str, query: str, *, k: int, **kwargs: Any) -> JSON:
|
|
109
|
+
return self._request(
|
|
110
|
+
"POST", f"/datasets/{dataset_id}/search", json=self._search_body(query, k, kwargs)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def intelligence_query(self, body: Mapping[str, Any]) -> JSON:
|
|
114
|
+
"""POST an Intelligence/RAG query and return the JSON answer."""
|
|
115
|
+
return self._request("POST", "/intelligence/query", json=dict(body))
|
|
116
|
+
|
|
117
|
+
async def aintelligence_query(self, body: Mapping[str, Any]) -> JSON:
|
|
118
|
+
"""Async variant of :meth:`intelligence_query`."""
|
|
119
|
+
return await self._arequest("POST", "/intelligence/query", json=dict(body))
|
|
120
|
+
|
|
121
|
+
def list_documents(
|
|
122
|
+
self,
|
|
123
|
+
dataset_id: str,
|
|
124
|
+
*,
|
|
125
|
+
limit: int = 50,
|
|
126
|
+
cursor: Optional[str] = None,
|
|
127
|
+
status: Optional[str] = None,
|
|
128
|
+
) -> JSON:
|
|
129
|
+
return self._request(
|
|
130
|
+
"GET",
|
|
131
|
+
f"/datasets/{dataset_id}/documents",
|
|
132
|
+
params={"limit": limit, "cursor": cursor, "status": status},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def retry_job(self, job_id: str) -> JSON:
|
|
136
|
+
"""Queue a fresh full-rerun job from an eligible failed or cancelled job."""
|
|
137
|
+
return self._request("POST", f"/ingestion/jobs/{job_id}/retry")
|
|
138
|
+
|
|
139
|
+
async def aretry_job(self, job_id: str) -> JSON:
|
|
140
|
+
"""Async variant of :meth:`retry_job`."""
|
|
141
|
+
return await self._arequest("POST", f"/ingestion/jobs/{job_id}/retry")
|
|
142
|
+
|
|
143
|
+
def download_document(self, dataset_id: str, document_id: str) -> bytes:
|
|
144
|
+
response = self._client.request(
|
|
145
|
+
"GET",
|
|
146
|
+
f"{self.base_url}/datasets/{dataset_id}/documents/{document_id}/download",
|
|
147
|
+
headers=self._headers(),
|
|
148
|
+
follow_redirects=True,
|
|
149
|
+
)
|
|
150
|
+
if response.status_code >= 300:
|
|
151
|
+
raise VectorAmpClientError(
|
|
152
|
+
f"VectorAmp API error {response.status_code}: {response.text}"
|
|
153
|
+
)
|
|
154
|
+
return response.content
|
|
155
|
+
|
|
156
|
+
async def asearch(self, dataset_id: str, query: str, *, k: int, **kwargs: Any) -> JSON:
|
|
157
|
+
return await self._arequest(
|
|
158
|
+
"POST", f"/datasets/{dataset_id}/search", json=self._search_body(query, k, kwargs)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _embed(self, dataset_id: str, texts: Sequence[str]) -> list[Sequence[float]]:
|
|
162
|
+
response = self._request(
|
|
163
|
+
"POST", f"/datasets/{dataset_id}/embed", json={"texts": list(texts)}
|
|
164
|
+
)
|
|
165
|
+
return self._extract_embeddings(response, len(texts))
|
|
166
|
+
|
|
167
|
+
async def _aembed(self, dataset_id: str, texts: Sequence[str]) -> list[Sequence[float]]:
|
|
168
|
+
response = await self._arequest(
|
|
169
|
+
"POST", f"/datasets/{dataset_id}/embed", json={"texts": list(texts)}
|
|
170
|
+
)
|
|
171
|
+
return self._extract_embeddings(response, len(texts))
|
|
172
|
+
|
|
173
|
+
def _insert_vectors(
|
|
174
|
+
self,
|
|
175
|
+
dataset_id: str,
|
|
176
|
+
texts: Sequence[str],
|
|
177
|
+
embeddings: Sequence[Sequence[float]],
|
|
178
|
+
ids: Sequence[VectorId],
|
|
179
|
+
metadatas: Optional[Sequence[Mapping[str, Any]]],
|
|
180
|
+
) -> JSON:
|
|
181
|
+
return self._request(
|
|
182
|
+
"POST",
|
|
183
|
+
f"/datasets/{dataset_id}/insert",
|
|
184
|
+
json={"vectors": self._vectors(texts, embeddings, ids, metadatas)},
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
async def _ainsert_vectors(
|
|
188
|
+
self,
|
|
189
|
+
dataset_id: str,
|
|
190
|
+
texts: Sequence[str],
|
|
191
|
+
embeddings: Sequence[Sequence[float]],
|
|
192
|
+
ids: Sequence[VectorId],
|
|
193
|
+
metadatas: Optional[Sequence[Mapping[str, Any]]],
|
|
194
|
+
) -> JSON:
|
|
195
|
+
return await self._arequest(
|
|
196
|
+
"POST",
|
|
197
|
+
f"/datasets/{dataset_id}/insert",
|
|
198
|
+
json={"vectors": self._vectors(texts, embeddings, ids, metadatas)},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _request(
|
|
202
|
+
self,
|
|
203
|
+
method: str,
|
|
204
|
+
path: str,
|
|
205
|
+
*,
|
|
206
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
207
|
+
json: Optional[Mapping[str, Any]] = None,
|
|
208
|
+
) -> JSON:
|
|
209
|
+
response = self._client.request(
|
|
210
|
+
method,
|
|
211
|
+
f"{self.base_url}{path}",
|
|
212
|
+
headers=self._headers(),
|
|
213
|
+
params=params,
|
|
214
|
+
json=json,
|
|
215
|
+
)
|
|
216
|
+
return self._decode_response(response)
|
|
217
|
+
|
|
218
|
+
async def _arequest(
|
|
219
|
+
self,
|
|
220
|
+
method: str,
|
|
221
|
+
path: str,
|
|
222
|
+
*,
|
|
223
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
224
|
+
json: Optional[Mapping[str, Any]] = None,
|
|
225
|
+
) -> JSON:
|
|
226
|
+
response = await self._async_client.request(
|
|
227
|
+
method,
|
|
228
|
+
f"{self.base_url}{path}",
|
|
229
|
+
headers=self._headers(),
|
|
230
|
+
params=params,
|
|
231
|
+
json=json,
|
|
232
|
+
)
|
|
233
|
+
return self._decode_response(response)
|
|
234
|
+
|
|
235
|
+
def _headers(self) -> dict[str, str]:
|
|
236
|
+
return {"x-api-key": self.api_key, "authorization": f"Bearer {self.api_key}"}
|
|
237
|
+
|
|
238
|
+
@staticmethod
|
|
239
|
+
def _decode_response(response: httpx.Response) -> JSON:
|
|
240
|
+
if response.status_code >= 400:
|
|
241
|
+
raise VectorAmpClientError(
|
|
242
|
+
f"VectorAmp API error {response.status_code}: {response.text}"
|
|
243
|
+
)
|
|
244
|
+
if response.status_code == 204 or not response.content:
|
|
245
|
+
return {}
|
|
246
|
+
data = response.json()
|
|
247
|
+
if not isinstance(data, dict):
|
|
248
|
+
raise VectorAmpClientError("VectorAmp API returned a non-object JSON response.")
|
|
249
|
+
return data
|
|
250
|
+
|
|
251
|
+
@staticmethod
|
|
252
|
+
def _extract_embeddings(response: Mapping[str, Any], count: int) -> list[Sequence[float]]:
|
|
253
|
+
embeddings = response.get("embeddings")
|
|
254
|
+
if not isinstance(embeddings, list) or len(embeddings) != count:
|
|
255
|
+
raise VectorAmpClientError("Embedding response length did not match input texts.")
|
|
256
|
+
return embeddings
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def _build_ids(texts: Sequence[str], ids: Optional[Sequence[VectorId]]) -> list[VectorId]:
|
|
260
|
+
if ids is not None:
|
|
261
|
+
if len(ids) != len(texts):
|
|
262
|
+
raise ValueError("ids length must match texts length.")
|
|
263
|
+
# Preserve the caller's id types. Integer ids stay integers so they
|
|
264
|
+
# serialize as JSON numbers; everything else is coerced to a string.
|
|
265
|
+
# (bool is a subclass of int but is not a valid id, so it is excluded.)
|
|
266
|
+
return [
|
|
267
|
+
value if isinstance(value, int) and not isinstance(value, bool) else str(value)
|
|
268
|
+
for value in ids
|
|
269
|
+
]
|
|
270
|
+
return [str(uuid.uuid4()) for _ in texts]
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _vectors(
|
|
274
|
+
texts: Sequence[str],
|
|
275
|
+
embeddings: Sequence[Sequence[float]],
|
|
276
|
+
ids: Sequence[VectorId],
|
|
277
|
+
metadatas: Optional[Sequence[Mapping[str, Any]]],
|
|
278
|
+
) -> list[JSON]:
|
|
279
|
+
if metadatas is not None and len(metadatas) != len(texts):
|
|
280
|
+
raise ValueError("metadatas length must match texts length.")
|
|
281
|
+
vectors: list[JSON] = []
|
|
282
|
+
for index, (text, values) in enumerate(zip(texts, embeddings, strict=True)):
|
|
283
|
+
metadata = dict(metadatas[index]) if metadatas is not None else {}
|
|
284
|
+
metadata.setdefault("text", text)
|
|
285
|
+
# ``ids[index]`` keeps its original type (int stays int -> JSON number,
|
|
286
|
+
# str stays str) so the API never rewrites integer ids.
|
|
287
|
+
vectors.append({"id": ids[index], "values": list(values), "metadata": metadata})
|
|
288
|
+
return vectors
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def _search_body(query: str, k: int, kwargs: Mapping[str, Any]) -> JSON:
|
|
292
|
+
# Metadata filters accept ``filter`` (preferred, LangChain convention) or
|
|
293
|
+
# ``filters`` (SDK parity). Both map to the API's ``filters`` field; pass
|
|
294
|
+
# only one.
|
|
295
|
+
search_text = kwargs.get("search_text")
|
|
296
|
+
if search_text is not None and search_text != query:
|
|
297
|
+
raise ValueError(
|
|
298
|
+
"Use the LangChain query argument for search text, "
|
|
299
|
+
"not a separate search_text value."
|
|
300
|
+
)
|
|
301
|
+
body: JSON = {"query_text": query, "top_k": k, "include_documents": True}
|
|
302
|
+
filter_value = kwargs.get("filter")
|
|
303
|
+
filters_value = kwargs.get("filters")
|
|
304
|
+
if filter_value is not None and filters_value is not None:
|
|
305
|
+
raise ValueError("Use only one of filter or filters.")
|
|
306
|
+
filters = filter_value if filter_value is not None else filters_value
|
|
307
|
+
if filters is not None:
|
|
308
|
+
body["filters"] = dict(filters)
|
|
309
|
+
passthrough = {
|
|
310
|
+
"advanced_filters",
|
|
311
|
+
"embedding_provider",
|
|
312
|
+
"embedding_model",
|
|
313
|
+
"nprobe_override",
|
|
314
|
+
"rerank_depth_override",
|
|
315
|
+
"hybrid",
|
|
316
|
+
"sparse_query",
|
|
317
|
+
"alpha",
|
|
318
|
+
"include_embeddings",
|
|
319
|
+
"include_documents",
|
|
320
|
+
"include_metadata",
|
|
321
|
+
"rerank",
|
|
322
|
+
}
|
|
323
|
+
for key in passthrough:
|
|
324
|
+
if key in kwargs and kwargs[key] is not None:
|
|
325
|
+
body[key] = kwargs[key]
|
|
326
|
+
# Ergonomic: ``rerank=True`` expands to the full VectorAmp rerank object.
|
|
327
|
+
if body.get("rerank") is True:
|
|
328
|
+
body["rerank"] = {
|
|
329
|
+
"enabled": True,
|
|
330
|
+
"provider": "vectoramp",
|
|
331
|
+
"model": "VectorAmp-Rerank-v1",
|
|
332
|
+
}
|
|
333
|
+
return body
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""LangChain Intelligence (RAG) interface for VectorAmp.
|
|
2
|
+
|
|
3
|
+
Designed for minimal-friction usage: a one-liner answers a question, queries run
|
|
4
|
+
across all datasets by default, and multi-turn conversations "just work" because
|
|
5
|
+
LangChain message history is mapped to the API's ``conversation_history``
|
|
6
|
+
automatically. The caller decides how many prior messages to include simply by
|
|
7
|
+
how many they pass.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from typing import Any, Optional, Union
|
|
14
|
+
|
|
15
|
+
from langchain_core.messages import BaseMessage
|
|
16
|
+
from langchain_core.runnables import Runnable, RunnableConfig
|
|
17
|
+
|
|
18
|
+
from .client import VectorAmpHTTPClient
|
|
19
|
+
|
|
20
|
+
JSON = dict[str, Any]
|
|
21
|
+
Turn = dict[str, str]
|
|
22
|
+
Message = Union[BaseMessage, dict[str, Any], str]
|
|
23
|
+
|
|
24
|
+
# LangChain message ``type`` -> Intelligence ``role``.
|
|
25
|
+
_ROLE_BY_TYPE = {"human": "user", "ai": "assistant", "system": "system", "tool": "tool"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _content_to_text(content: Any) -> str:
|
|
29
|
+
if isinstance(content, str):
|
|
30
|
+
return content
|
|
31
|
+
if isinstance(content, list): # LangChain content blocks
|
|
32
|
+
parts = [
|
|
33
|
+
block.get("text", "") if isinstance(block, dict) else str(block)
|
|
34
|
+
for block in content
|
|
35
|
+
]
|
|
36
|
+
return "".join(parts)
|
|
37
|
+
return str(content)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _to_turn(message: Message) -> Turn:
|
|
41
|
+
if isinstance(message, BaseMessage):
|
|
42
|
+
return {
|
|
43
|
+
"role": _ROLE_BY_TYPE.get(message.type, "user"),
|
|
44
|
+
"content": _content_to_text(message.content),
|
|
45
|
+
}
|
|
46
|
+
if isinstance(message, dict):
|
|
47
|
+
role = message.get("role") or _ROLE_BY_TYPE.get(str(message.get("type", "")), "user")
|
|
48
|
+
return {"role": role, "content": _content_to_text(message.get("content", ""))}
|
|
49
|
+
return {"role": "user", "content": str(message)}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class VectorAmpIntelligence(Runnable[Any, str]):
|
|
53
|
+
"""Ask VectorAmp Intelligence (RAG) from LangChain.
|
|
54
|
+
|
|
55
|
+
The simplest usage needs only a question::
|
|
56
|
+
|
|
57
|
+
intel = VectorAmpIntelligence() # uses VECTORAMP_API_KEY, all datasets
|
|
58
|
+
print(intel.ask("What changed in Q4 planning?"))
|
|
59
|
+
|
|
60
|
+
Multi-turn follow-ups: pass prior LangChain messages (or plain dicts); they
|
|
61
|
+
are forwarded as ``conversation_history`` automatically::
|
|
62
|
+
|
|
63
|
+
from langchain_core.messages import HumanMessage, AIMessage
|
|
64
|
+
history = [HumanMessage("What is VectorAmp?"), AIMessage("A vector DB platform.")]
|
|
65
|
+
intel.ask("Does it support hybrid search?", history=history)
|
|
66
|
+
|
|
67
|
+
As a LangChain ``Runnable`` it composes in chains and works with
|
|
68
|
+
``RunnableWithMessageHistory`` (pass a list of messages; the last human
|
|
69
|
+
message is the question and the rest become conversation history)::
|
|
70
|
+
|
|
71
|
+
intel.invoke("Summarize the docs")
|
|
72
|
+
intel.invoke([HumanMessage("hi"), AIMessage("hello"), HumanMessage("and bye?")])
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
dataset_id: Optional[str] = None,
|
|
79
|
+
dataset_name: Optional[str] = None,
|
|
80
|
+
api_key: Optional[str] = None,
|
|
81
|
+
base_url: str = "https://api.vectoramp.com",
|
|
82
|
+
client: Optional[VectorAmpHTTPClient] = None,
|
|
83
|
+
top_k: Optional[int] = None,
|
|
84
|
+
include_sources: bool = True,
|
|
85
|
+
) -> None:
|
|
86
|
+
self._client = client or VectorAmpHTTPClient(api_key=api_key, base_url=base_url)
|
|
87
|
+
self._dataset_id = dataset_id
|
|
88
|
+
self._dataset_name = dataset_name
|
|
89
|
+
self._top_k = top_k
|
|
90
|
+
self._include_sources = include_sources
|
|
91
|
+
|
|
92
|
+
def _dataset(self) -> str:
|
|
93
|
+
# Default to every accessible dataset; only resolve a name/id when given.
|
|
94
|
+
if self._dataset_id or self._dataset_name:
|
|
95
|
+
return self._client.get_dataset_id(
|
|
96
|
+
dataset_id=self._dataset_id, dataset_name=self._dataset_name
|
|
97
|
+
)
|
|
98
|
+
return "all"
|
|
99
|
+
|
|
100
|
+
def _body(
|
|
101
|
+
self,
|
|
102
|
+
query: str,
|
|
103
|
+
history: Optional[Sequence[Message]],
|
|
104
|
+
overrides: dict[str, Any],
|
|
105
|
+
) -> JSON:
|
|
106
|
+
body: JSON = {
|
|
107
|
+
"query": query,
|
|
108
|
+
"dataset_id": overrides.get("dataset_id") or self._dataset(),
|
|
109
|
+
"include_sources": overrides.get("include_sources", self._include_sources),
|
|
110
|
+
"stream": False,
|
|
111
|
+
}
|
|
112
|
+
top_k = overrides.get("top_k", self._top_k)
|
|
113
|
+
if top_k is not None:
|
|
114
|
+
body["top_k"] = top_k
|
|
115
|
+
turns = [_to_turn(message) for message in (history or [])]
|
|
116
|
+
if turns:
|
|
117
|
+
body["conversation_history"] = turns
|
|
118
|
+
return body
|
|
119
|
+
|
|
120
|
+
def ask(
|
|
121
|
+
self,
|
|
122
|
+
query: str,
|
|
123
|
+
*,
|
|
124
|
+
history: Optional[Sequence[Message]] = None,
|
|
125
|
+
**overrides: Any,
|
|
126
|
+
) -> str:
|
|
127
|
+
"""Ask a question and return the answer text."""
|
|
128
|
+
response = self._client.intelligence_query(self._body(query, history, overrides))
|
|
129
|
+
return str(response.get("answer", ""))
|
|
130
|
+
|
|
131
|
+
async def aask(
|
|
132
|
+
self,
|
|
133
|
+
query: str,
|
|
134
|
+
*,
|
|
135
|
+
history: Optional[Sequence[Message]] = None,
|
|
136
|
+
**overrides: Any,
|
|
137
|
+
) -> str:
|
|
138
|
+
"""Async variant of :meth:`ask`."""
|
|
139
|
+
data = await self._client.aintelligence_query(self._body(query, history, overrides))
|
|
140
|
+
return str(data.get("answer", ""))
|
|
141
|
+
|
|
142
|
+
def ask_with_sources(
|
|
143
|
+
self,
|
|
144
|
+
query: str,
|
|
145
|
+
*,
|
|
146
|
+
history: Optional[Sequence[Message]] = None,
|
|
147
|
+
**overrides: Any,
|
|
148
|
+
) -> JSON:
|
|
149
|
+
"""Return the full Intelligence response (answer, sources, metadata)."""
|
|
150
|
+
return self._client.intelligence_query(self._body(query, history, overrides))
|
|
151
|
+
|
|
152
|
+
# --- Runnable interface -------------------------------------------------
|
|
153
|
+
def invoke(
|
|
154
|
+
self,
|
|
155
|
+
input: Any,
|
|
156
|
+
config: Optional[RunnableConfig] = None,
|
|
157
|
+
**kwargs: Any,
|
|
158
|
+
) -> str:
|
|
159
|
+
query, history = self._split(input)
|
|
160
|
+
return self.ask(query, history=history)
|
|
161
|
+
|
|
162
|
+
async def ainvoke(
|
|
163
|
+
self,
|
|
164
|
+
input: Any,
|
|
165
|
+
config: Optional[RunnableConfig] = None,
|
|
166
|
+
**kwargs: Any,
|
|
167
|
+
) -> str:
|
|
168
|
+
query, history = self._split(input)
|
|
169
|
+
return await self.aask(query, history=history)
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _split(input: Any) -> tuple[str, list[Message]]:
|
|
173
|
+
"""Normalize Runnable input into (query, history).
|
|
174
|
+
|
|
175
|
+
Accepts a plain string, a dict
|
|
176
|
+
({query|question|input, history|chat_history|messages}), or a sequence of
|
|
177
|
+
messages where the last one is the question.
|
|
178
|
+
"""
|
|
179
|
+
if isinstance(input, str):
|
|
180
|
+
return input, []
|
|
181
|
+
if isinstance(input, dict):
|
|
182
|
+
query = input.get("query") or input.get("question") or input.get("input") or ""
|
|
183
|
+
history = list(
|
|
184
|
+
input.get("history") or input.get("chat_history") or input.get("messages") or []
|
|
185
|
+
)
|
|
186
|
+
if not query and history:
|
|
187
|
+
last = history.pop()
|
|
188
|
+
return _to_turn(last)["content"], history
|
|
189
|
+
return str(query), history
|
|
190
|
+
if isinstance(input, Sequence):
|
|
191
|
+
messages = list(input)
|
|
192
|
+
if not messages:
|
|
193
|
+
return "", []
|
|
194
|
+
last = messages[-1]
|
|
195
|
+
return _to_turn(last)["content"], messages[:-1]
|
|
196
|
+
return str(input), []
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""LangChain document loader for VectorAmp dataset documents or search results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Mapping
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from langchain_core.document_loaders import BaseLoader
|
|
9
|
+
from langchain_core.documents import Document
|
|
10
|
+
|
|
11
|
+
from .client import VectorAmpHTTPClient
|
|
12
|
+
from .vectorstores import VectorAmpVectorStore
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class VectorAmpLoader(BaseLoader):
|
|
16
|
+
"""Load LangChain documents from VectorAmp retained documents or search results.
|
|
17
|
+
|
|
18
|
+
When ``query`` is omitted, the loader uses VectorAmp's public
|
|
19
|
+
``/datasets/{id}/documents`` endpoint to iterate retained source documents.
|
|
20
|
+
When ``query`` is provided, it remains semantic-search-backed and returns the
|
|
21
|
+
top ``k`` matching documents.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
api_key: Optional[str] = None,
|
|
28
|
+
base_url: str = "https://api.vectoramp.com",
|
|
29
|
+
dataset_id: str,
|
|
30
|
+
client: Optional[Any] = None,
|
|
31
|
+
query: Optional[str] = None,
|
|
32
|
+
filter: Optional[Mapping[str, Any]] = None,
|
|
33
|
+
k: int = 10,
|
|
34
|
+
metadata: Optional[Mapping[str, Any]] = None,
|
|
35
|
+
timeout: float = 30.0,
|
|
36
|
+
**search_kwargs: Any,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.dataset_id = dataset_id
|
|
39
|
+
self.query = query
|
|
40
|
+
self.filter = dict(filter) if filter is not None else None
|
|
41
|
+
self.k = k
|
|
42
|
+
self.metadata = dict(metadata) if metadata is not None else {}
|
|
43
|
+
self.search_kwargs = dict(search_kwargs)
|
|
44
|
+
self._external_client = client
|
|
45
|
+
self._client = client or VectorAmpHTTPClient(
|
|
46
|
+
api_key=api_key,
|
|
47
|
+
base_url=base_url,
|
|
48
|
+
timeout=timeout,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def lazy_load(self) -> Iterator[Document]:
|
|
52
|
+
"""Yield retained dataset documents or semantic-search matches."""
|
|
53
|
+
if not self.query:
|
|
54
|
+
yield from self._lazy_load_documents()
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
kwargs = dict(self.search_kwargs)
|
|
58
|
+
if self.filter is not None:
|
|
59
|
+
kwargs["filter"] = self.filter
|
|
60
|
+
kwargs.setdefault("include_documents", True)
|
|
61
|
+
kwargs.setdefault("include_metadata", True)
|
|
62
|
+
store = VectorAmpVectorStore(client=self._client, dataset_id=self.dataset_id)
|
|
63
|
+
for document in store.similarity_search(self.query, k=self.k, **kwargs):
|
|
64
|
+
if self.metadata:
|
|
65
|
+
document.metadata = {**self.metadata, **document.metadata}
|
|
66
|
+
yield document
|
|
67
|
+
|
|
68
|
+
def _lazy_load_documents(self) -> Iterator[Document]:
|
|
69
|
+
if not hasattr(self._client, "list_documents"):
|
|
70
|
+
raise ValueError(
|
|
71
|
+
"VectorAmpLoader requires query when the client cannot list documents."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
cursor: Optional[str] = None
|
|
75
|
+
while True:
|
|
76
|
+
page = self._client.list_documents(
|
|
77
|
+
self.dataset_id, limit=self.k, cursor=cursor, status="ready"
|
|
78
|
+
)
|
|
79
|
+
documents = page.get("documents") or page.get("data") or page.get("items") or []
|
|
80
|
+
if not documents:
|
|
81
|
+
break
|
|
82
|
+
for item in documents:
|
|
83
|
+
if not isinstance(item, Mapping):
|
|
84
|
+
continue
|
|
85
|
+
content = self._document_content(item)
|
|
86
|
+
if content is None:
|
|
87
|
+
if not hasattr(self._client, "download_document"):
|
|
88
|
+
continue
|
|
89
|
+
document_id = item.get("id") or item.get("document_id")
|
|
90
|
+
if document_id is None:
|
|
91
|
+
continue
|
|
92
|
+
content = self._client.download_document(
|
|
93
|
+
self.dataset_id, str(document_id)
|
|
94
|
+
).decode("utf-8", errors="replace")
|
|
95
|
+
metadata = {
|
|
96
|
+
key: value
|
|
97
|
+
for key, value in item.items()
|
|
98
|
+
if key not in {"text", "content", "page_content", "doc_value"}
|
|
99
|
+
}
|
|
100
|
+
if self.metadata:
|
|
101
|
+
metadata = {**self.metadata, **metadata}
|
|
102
|
+
doc_id = item.get("id") or item.get("document_id")
|
|
103
|
+
yield Document(
|
|
104
|
+
page_content=str(content),
|
|
105
|
+
metadata=metadata,
|
|
106
|
+
id=str(doc_id) if doc_id is not None else None,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
cursor = page.get("next_cursor") or page.get("nextCursor")
|
|
110
|
+
if not cursor:
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _document_content(item: Mapping[str, Any]) -> Any:
|
|
115
|
+
for key in ("text", "content", "page_content", "doc_value"):
|
|
116
|
+
value = item.get(key)
|
|
117
|
+
if value is not None:
|
|
118
|
+
return value
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def load(self) -> list[Document]:
|
|
122
|
+
"""Return all documents from :meth:`lazy_load`."""
|
|
123
|
+
return list(self.lazy_load())
|
|
124
|
+
|
|
125
|
+
def close(self) -> None:
|
|
126
|
+
"""Close owned HTTP resources."""
|
|
127
|
+
if self._external_client is None and hasattr(self._client, "close"):
|
|
128
|
+
self._client.close()
|
|
File without changes
|