projectdavid 1.29.9__py3-none-any.whl → 1.38.1__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.
@@ -0,0 +1,1058 @@
1
+ """projectdavid.clients.vector_store_client
2
+ ---------------------------------------
3
+
4
+ Token-scoped HTTP client + local Qdrant helper for vector-store operations.
5
+ """
6
+
7
+ import asyncio
8
+ import os
9
+ import uuid
10
+ import warnings
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional, Union
13
+
14
+ import httpx
15
+ from dotenv import load_dotenv
16
+ from PIL import Image
17
+ from projectdavid_common import UtilsInterface, ValidationInterface
18
+ from pydantic import BaseModel, Field
19
+ from qdrant_client.http import models as qdrant
20
+
21
+ from projectdavid.clients.file_processor import FileProcessor
22
+ from projectdavid.clients.vector_store_manager import VectorStoreManager
23
+ from projectdavid.decorators import experimental
24
+ from projectdavid.synthesis import reranker, retriever
25
+ from projectdavid.synthesis.llm_synthesizer import synthesize_envelope
26
+ from projectdavid.utils.vector_search_formatter import make_envelope
27
+
28
+ load_dotenv()
29
+ log = UtilsInterface.LoggingUtility()
30
+
31
+
32
+ def summarize_hits(query: str, hits: List[Dict[str, Any]]) -> str:
33
+ lines = [f"• {h['meta_data']['file_name']} (score {h['score']:.2f})" for h in hits]
34
+ return f"Top files for **{query}**:\n" + "\n".join(lines)
35
+
36
+
37
+ # --------------------------------------------------------------------------- #
38
+ # Exceptions
39
+ # --------------------------------------------------------------------------- #
40
+ class VectorStoreClientError(Exception):
41
+ """Raised on any client-side or API error."""
42
+
43
+
44
+ # --------------------------------------------------------------------------- #
45
+ # Helper schema
46
+ # --------------------------------------------------------------------------- #
47
+ class VectorStoreFileUpdateStatusInput(BaseModel):
48
+ status: ValidationInterface.StatusEnum = Field(
49
+ ..., description="The new status for the file record."
50
+ )
51
+ error_message: Optional[str] = Field(
52
+ None, description="Error message if status is 'failed'."
53
+ )
54
+
55
+
56
+ # --------------------------------------------------------------------------- #
57
+ # Main client
58
+ # --------------------------------------------------------------------------- #
59
+ class VectorStoreClient:
60
+ """
61
+ Thin HTTP+Qdrant wrapper.
62
+
63
+ • All API requests scoped by X-API-Key.
64
+ • create_vector_store() no longer takes user_id; ownership from token.
65
+ """
66
+
67
+ # ------------------------------------------------------------------ #
68
+ # Construction / cleanup
69
+ # ------------------------------------------------------------------ #
70
+ def __init__(
71
+ self,
72
+ base_url: Optional[str] = None,
73
+ api_key: Optional[str] = None,
74
+ *,
75
+ vector_store_host: str = "localhost",
76
+ file_processor_kwargs: Optional[dict] = None,
77
+ ):
78
+
79
+ self.base_url = (base_url or os.getenv("BASE_URL", "")).rstrip("/")
80
+ self.api_key = api_key or os.getenv("API_KEY")
81
+ if not self.base_url:
82
+ raise VectorStoreClientError("BASE_URL is required.")
83
+
84
+ self._base_headers: Dict[str, str] = {"Content-Type": "application/json"}
85
+ if self.api_key:
86
+ self._base_headers["X-API-Key"] = self.api_key
87
+ else:
88
+ log.warning("No API key — protected routes will fail.")
89
+
90
+ self._sync_api_client = httpx.Client(
91
+ base_url=self.base_url, headers=self._base_headers, timeout=30.0
92
+ )
93
+
94
+ # Local helpers ---------------------------------------------------
95
+ self.vector_manager = VectorStoreManager(vector_store_host=vector_store_host)
96
+ self.identifier_service = UtilsInterface.IdentifierService()
97
+
98
+ # 🔶 forward kwargs into the upgraded FileProcessor
99
+
100
+ self.file_processor = FileProcessor(
101
+ **(
102
+ file_processor_kwargs
103
+ or {
104
+ "use_gpu": False,
105
+ "use_detection": True,
106
+ "use_geo": True,
107
+ "use_ocr": True,
108
+ }
109
+ )
110
+ )
111
+
112
+ log.info("VectorStoreClient → %s", self.base_url)
113
+
114
+ # Context support ------------------------------------------------------ #
115
+ def __enter__(self):
116
+ return self
117
+
118
+ def __exit__(self, *_exc):
119
+ self.close()
120
+
121
+ async def __aenter__(self):
122
+ return self
123
+
124
+ async def __aexit__(self, *_exc):
125
+ await self.aclose()
126
+
127
+ # Cleanup -------------------------------------------------------------- #
128
+ async def aclose(self):
129
+ await asyncio.to_thread(self._sync_api_client.close)
130
+
131
+ def close(self):
132
+ try:
133
+ loop = asyncio.get_running_loop()
134
+ if loop.is_running():
135
+ warnings.warn(
136
+ "close() inside running loop — use `await aclose()`",
137
+ RuntimeWarning,
138
+ )
139
+ self._sync_api_client.close()
140
+ return
141
+ except RuntimeError:
142
+ pass
143
+ asyncio.run(self.aclose())
144
+
145
+ # Low-level HTTP helpers ---------------------------------------------- #
146
+ async def _parse_response(self, resp: httpx.Response) -> Any:
147
+ try:
148
+ resp.raise_for_status()
149
+ return None if resp.status_code == 204 else resp.json()
150
+ except httpx.HTTPStatusError as exc:
151
+ log.error("API %d – %s", exc.response.status_code, exc.response.text)
152
+ raise VectorStoreClientError(
153
+ f"API {exc.response.status_code}: {exc.response.text}"
154
+ ) from exc
155
+ except Exception as exc:
156
+ raise VectorStoreClientError(f"Invalid response: {resp.text}") from exc
157
+
158
+ async def _request(self, method: str, url: str, **kwargs) -> Any:
159
+ retries = 3
160
+ for attempt in range(1, retries + 1):
161
+ try:
162
+ async with httpx.AsyncClient(
163
+ base_url=self.base_url,
164
+ headers=self._base_headers,
165
+ timeout=30.0,
166
+ ) as client:
167
+ resp = await client.request(method, url, **kwargs)
168
+ return await self._parse_response(resp)
169
+ except (
170
+ httpx.TimeoutException,
171
+ httpx.NetworkError,
172
+ httpx.HTTPStatusError,
173
+ ) as exc:
174
+ retryable = isinstance(
175
+ exc, (httpx.TimeoutException, httpx.NetworkError)
176
+ ) or (
177
+ isinstance(exc, httpx.HTTPStatusError)
178
+ and exc.response.status_code >= 500
179
+ )
180
+ if retryable and attempt < retries:
181
+ backoff = 2 ** (attempt - 1)
182
+ log.warning(
183
+ "Retry %d/%d %s %s in %ds – %s",
184
+ attempt,
185
+ retries,
186
+ method,
187
+ url,
188
+ backoff,
189
+ exc,
190
+ )
191
+ await asyncio.sleep(backoff)
192
+ continue
193
+ raise VectorStoreClientError(str(exc)) from exc
194
+ raise VectorStoreClientError("Request failed after retries")
195
+
196
+ # Internal async ops -------------------------------------------------- #
197
+ async def _create_vs_async(
198
+ self,
199
+ name: str,
200
+ vector_size: int,
201
+ distance_metric: str,
202
+ config: Optional[Dict[str, Any]],
203
+ vectors_config: Optional[Dict[str, qdrant.VectorParams]] = None, # ← NEW
204
+ ) -> ValidationInterface.VectorStoreRead:
205
+ shared_id = self.identifier_service.generate_vector_id()
206
+ # forward multi-vector schema if given
207
+ self.vector_manager.create_store(
208
+ collection_name=shared_id,
209
+ vector_size=vector_size,
210
+ distance=distance_metric.upper(),
211
+ vectors_config=vectors_config,
212
+ )
213
+
214
+ payload = {
215
+ "shared_id": shared_id,
216
+ "name": name,
217
+ "vector_size": vector_size,
218
+ "distance_metric": distance_metric.upper(),
219
+ "config": config or {},
220
+ }
221
+ resp = await self._request("POST", "/v1/vector-stores", json=payload)
222
+ return ValidationInterface.VectorStoreRead.model_validate(resp)
223
+
224
+ # ------------------------------------------------------------------ #
225
+ # NEW admin‑aware creation helper
226
+ # ------------------------------------------------------------------ #
227
+ async def _create_vs_for_user_async(
228
+ self,
229
+ owner_id: str,
230
+ name: str,
231
+ vector_size: int,
232
+ distance_metric: str,
233
+ config: Optional[Dict[str, Any]],
234
+ vectors_config: Optional[Dict[str, qdrant.VectorParams]] = None, # ← NEW
235
+ ) -> ValidationInterface.VectorStoreRead:
236
+ shared_id = self.identifier_service.generate_vector_id()
237
+ # forward multi-vector schema if given
238
+ self.vector_manager.create_store(
239
+ collection_name=shared_id,
240
+ vector_size=vector_size,
241
+ distance=distance_metric.upper(),
242
+ vectors_config=vectors_config,
243
+ )
244
+
245
+ payload = {
246
+ "shared_id": shared_id,
247
+ "name": name,
248
+ "vector_size": vector_size,
249
+ "distance_metric": distance_metric.upper(),
250
+ "config": config or {},
251
+ }
252
+ resp = await self._request(
253
+ "POST",
254
+ "/v1/vector-stores",
255
+ json=payload,
256
+ params={"owner_id": owner_id},
257
+ )
258
+ return ValidationInterface.VectorStoreRead.model_validate(resp)
259
+
260
+ async def _add_file_async(
261
+ self, vector_store_id: str, p: Path, meta: Optional[Dict[str, Any]]
262
+ ) -> ValidationInterface.VectorStoreFileRead:
263
+ processed = await self.file_processor.process_file(p)
264
+ texts, vectors = processed["chunks"], processed["vectors"]
265
+ line_data = processed.get("line_data") or [] # ← NEW
266
+
267
+ base_md = meta or {}
268
+ base_md.update({"source": str(p), "file_name": p.name})
269
+
270
+ file_record_id = f"vsf_{uuid.uuid4()}"
271
+
272
+ # Build per‑chunk payload, now including page/lines if present
273
+ chunk_md = []
274
+ for i in range(len(texts)):
275
+ payload = {
276
+ **base_md,
277
+ "chunk_index": i,
278
+ "file_id": file_record_id,
279
+ }
280
+ if i < len(line_data): # ← NEW
281
+ payload.update(line_data[i]) # {'page': …, 'lines': …}
282
+ chunk_md.append(payload)
283
+
284
+ self.vector_manager.add_to_store(
285
+ store_name=vector_store_id,
286
+ texts=texts,
287
+ vectors=vectors,
288
+ metadata=chunk_md,
289
+ )
290
+
291
+ resp = await self._request(
292
+ "POST",
293
+ f"/v1/vector-stores/{vector_store_id}/files",
294
+ json={
295
+ "file_id": file_record_id,
296
+ "file_name": p.name,
297
+ "file_path": str(p),
298
+ "status": "completed",
299
+ "meta_data": meta or {},
300
+ },
301
+ )
302
+ return ValidationInterface.VectorStoreFileRead.model_validate(resp)
303
+
304
+ async def _search_vs_async(
305
+ self,
306
+ vector_store_id: str,
307
+ query_text: Union[str, List[float]],
308
+ top_k: int,
309
+ filters: Optional[Dict] = None,
310
+ vector_store_host: Optional[str] = None,
311
+ vector_field: Optional[str] = None, # allow caller override
312
+ ) -> List[Dict[str, Any]]:
313
+ """
314
+ Internal: run ANN search against the specified vector field or auto-detect by store size.
315
+
316
+ If `vector_field` is provided, it will be used directly. Otherwise:
317
+ • 1024-D → caption_vector
318
+ • 3-D → geo_vector
319
+ • others → default vector (text)
320
+ """
321
+ # pick local vs. override host
322
+ vector_manager = (
323
+ VectorStoreManager(vector_store_host=vector_store_host)
324
+ if vector_store_host
325
+ else self.vector_manager
326
+ )
327
+
328
+ # fetch store info to inspect schema
329
+ store = self.retrieve_vector_store_sync(vector_store_id)
330
+
331
+ # determine the query vector and target field
332
+ if vector_field is not None:
333
+ # if caller passed a raw vector list, use it; otherwise treat as caption search
334
+ if isinstance(query_text, list):
335
+ vec = query_text
336
+ else:
337
+ vec = self.file_processor.encode_clip_text(query_text).tolist()
338
+ else:
339
+ # auto-detect based on stored vector dimensionality
340
+ if store.vector_size == 1024:
341
+ # image/caption space
342
+ vec = self.file_processor.encode_clip_text(query_text).tolist()
343
+ vector_field = "caption_vector"
344
+ elif store.vector_size == 3:
345
+ # geo space; query_text must be a raw 3-D list
346
+ if not isinstance(query_text, list):
347
+ raise VectorStoreClientError(
348
+ "Geo search requires a 3-element vector; pass raw unit-sphere list"
349
+ )
350
+ vec = query_text
351
+ vector_field = "geo_vector"
352
+ else:
353
+ # fallback to text embedding
354
+ vec = self.file_processor.encode_text(query_text).tolist()
355
+ vector_field = None # use default
356
+
357
+ # perform the search on the selected vector column
358
+ return vector_manager.query_store(
359
+ store_name=store.collection_name,
360
+ query_vector=vec,
361
+ top_k=top_k,
362
+ filters=filters,
363
+ vector_field=vector_field,
364
+ )
365
+
366
+ async def _delete_vs_async(
367
+ self, vector_store_id: str, permanent: bool
368
+ ) -> Dict[str, Any]:
369
+ qres = self.vector_manager.delete_store(vector_store_id)
370
+ await self._request(
371
+ "DELETE",
372
+ f"/v1/vector-stores/{vector_store_id}",
373
+ params={"permanent": permanent},
374
+ )
375
+ return {
376
+ "vector_store_id": vector_store_id,
377
+ "status": "deleted",
378
+ "permanent": permanent,
379
+ "qdrant_result": qres,
380
+ }
381
+
382
+ async def _delete_file_async(
383
+ self, vector_store_id: str, file_path: str
384
+ ) -> Dict[str, Any]:
385
+ fres = self.vector_manager.delete_file_from_store(vector_store_id, file_path)
386
+ await self._request(
387
+ "DELETE",
388
+ f"/v1/vector-stores/{vector_store_id}/files",
389
+ params={"file_path": file_path},
390
+ )
391
+ return {
392
+ "vector_store_id": vector_store_id,
393
+ "file_path": file_path,
394
+ "status": "deleted",
395
+ "qdrant_result": fres,
396
+ }
397
+
398
+ async def _list_store_files_async(
399
+ self, vector_store_id: str
400
+ ) -> List[ValidationInterface.VectorStoreFileRead]:
401
+ resp = await self._request("GET", f"/v1/vector-stores/{vector_store_id}/files")
402
+ return [
403
+ ValidationInterface.VectorStoreFileRead.model_validate(item)
404
+ for item in resp
405
+ ]
406
+
407
+ async def _update_file_status_async(
408
+ self,
409
+ vector_store_id: str,
410
+ file_id: str,
411
+ status: ValidationInterface.StatusEnum,
412
+ error_message: Optional[str] = None,
413
+ ) -> ValidationInterface.VectorStoreFileRead:
414
+ payload = VectorStoreFileUpdateStatusInput(
415
+ status=status, error_message=error_message
416
+ ).model_dump(exclude_none=True)
417
+ resp = await self._request(
418
+ "PATCH",
419
+ f"/v1/vector-stores/{vector_store_id}/files/{file_id}",
420
+ json=payload,
421
+ )
422
+ return ValidationInterface.VectorStoreFileRead.model_validate(resp)
423
+
424
+ async def _get_assistant_vs_async(
425
+ self, assistant_id: str
426
+ ) -> List[ValidationInterface.VectorStoreRead]:
427
+ resp = await self._request(
428
+ "GET", f"/v1/assistants/{assistant_id}/vector-stores"
429
+ )
430
+ return [
431
+ ValidationInterface.VectorStoreRead.model_validate(item) for item in resp
432
+ ]
433
+
434
+ async def _attach_vs_async(self, vector_store_id: str, assistant_id: str) -> bool:
435
+ await self._request(
436
+ "POST",
437
+ f"/v1/assistants/{assistant_id}/vector-stores/{vector_store_id}/attach",
438
+ )
439
+ return True
440
+
441
+ async def _detach_vs_async(self, vector_store_id: str, assistant_id: str) -> bool:
442
+ await self._request(
443
+ "DELETE",
444
+ f"/v1/assistants/{assistant_id}/vector-stores/{vector_store_id}/detach",
445
+ )
446
+ return True
447
+
448
+ # Sync facade helpers ------------------------------------------------ #
449
+ def _run_sync(self, coro):
450
+ try:
451
+ loop = asyncio.get_running_loop()
452
+ if loop.is_running():
453
+ raise VectorStoreClientError("Sync call inside running loop")
454
+ except RuntimeError:
455
+ pass
456
+ return asyncio.run(coro)
457
+
458
+ # ──────────────────────────────────────────────────────────────────
459
+ # Helpers (private)
460
+ # ──────────────────────────────────────────────────────────────────
461
+ @staticmethod
462
+ def _normalise_hits(raw_hits: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
463
+ """
464
+ Ensure each hit dict contains a top‑level 'meta_data' key so that all
465
+ downstream components (reranker, synthesizer, envelope builder) can
466
+ rely on a stable schema.
467
+ """
468
+ normalised: List[Dict[str, Any]] = []
469
+ for h in raw_hits:
470
+ md = h.get("meta_data") or h.get("metadata") or {}
471
+ normalised.append(
472
+ {
473
+ "text": h["text"],
474
+ "score": h["score"],
475
+ "meta_data": md,
476
+ "vector_id": h.get("vector_id"),
477
+ "store_id": h.get("store_id"),
478
+ }
479
+ )
480
+ return normalised
481
+
482
+ # Public API ---------------------------------------------------------- #
483
+ def create_vector_store(
484
+ self,
485
+ name: str,
486
+ *,
487
+ vector_size: int = 384,
488
+ distance_metric: str = "Cosine",
489
+ config: Optional[Dict[str, Any]] = None,
490
+ vectors_config: Optional[Dict[str, qdrant.VectorParams]] = None, # ← NEW
491
+ ) -> ValidationInterface.VectorStoreRead:
492
+ """
493
+ Create a new store owned by this API key.
494
+
495
+ If `vectors_config` is provided, it should map each vector
496
+ field name to its Qdrant VectorParams (size + distance).
497
+ """
498
+ return self._run_sync(
499
+ self._create_vs_async(
500
+ name,
501
+ vector_size,
502
+ distance_metric,
503
+ config,
504
+ vectors_config,
505
+ )
506
+ )
507
+
508
+ @experimental
509
+ def create_vector_vision_store(
510
+ self,
511
+ name: str,
512
+ *,
513
+ vector_size: int = 384,
514
+ distance_metric: str = "Cosine",
515
+ config: Optional[Dict[str, Any]] = None,
516
+ vectors_config: Optional[Dict[str, qdrant.VectorParams]] = None, # ← NEW
517
+ ) -> ValidationInterface.VectorStoreRead:
518
+
519
+ if not vectors_config:
520
+ vectors_config = {
521
+ # Raw visual embeddings (OpenCLIP ViT-H/14 → 1024-D)
522
+ "image_vector": qdrant.VectorParams(
523
+ size=1024, distance=qdrant.Distance.COSINE
524
+ ),
525
+ # Language embeddings of your BLIP-2 captions → 1024-D
526
+ "caption_vector": qdrant.VectorParams(
527
+ size=1024, distance=qdrant.Distance.COSINE
528
+ ),
529
+ # Object-region embeddings (YOLO crop + Sentence-BERT) → 1024-D
530
+ "region_vector": qdrant.VectorParams(
531
+ size=1024, distance=qdrant.Distance.COSINE
532
+ ),
533
+ # Geo-location unit vectors (RegioNet) → 3-D
534
+ "geo_vector": qdrant.VectorParams(
535
+ size=3, distance=qdrant.Distance.COSINE
536
+ ),
537
+ }
538
+
539
+ return self._run_sync(
540
+ self._create_vs_async(
541
+ name,
542
+ vector_size,
543
+ distance_metric,
544
+ config,
545
+ vectors_config,
546
+ )
547
+ )
548
+
549
+ @experimental
550
+ def create_vector_vision_store_for_user(
551
+ self,
552
+ owner_id: str,
553
+ name: str,
554
+ *,
555
+ vector_size: int = 384,
556
+ distance_metric: str = "Cosine",
557
+ config: Optional[Dict[str, Any]] = None,
558
+ vectors_config: Optional[Dict[str, qdrant.VectorParams]] = None, # ← NEW
559
+ ) -> ValidationInterface.VectorStoreRead:
560
+ """
561
+ Admin-only: create a store on behalf of another user.
562
+ Pass `vectors_config` to define a multi-vector schema.
563
+ """
564
+ if not vectors_config:
565
+
566
+ vectors_config = {
567
+ # Raw visual embeddings (OpenCLIP ViT-H/14 → 1024-D)
568
+ "image_vector": qdrant.VectorParams(
569
+ size=1024, distance=qdrant.Distance.COSINE
570
+ ),
571
+ # Language embeddings of your BLIP-2 captions → 1024-D
572
+ "caption_vector": qdrant.VectorParams(
573
+ size=1024, distance=qdrant.Distance.COSINE
574
+ ),
575
+ # Object-region embeddings (YOLO crop + Sentence-BERT) → 1024-D
576
+ "region_vector": qdrant.VectorParams(
577
+ size=1024, distance=qdrant.Distance.COSINE
578
+ ),
579
+ # Geo-location unit vectors (RegioNet) → 3-D
580
+ "geo_vector": qdrant.VectorParams(
581
+ size=3, distance=qdrant.Distance.COSINE
582
+ ),
583
+ }
584
+
585
+ return self._run_sync(
586
+ self._create_vs_for_user_async(
587
+ owner_id,
588
+ name,
589
+ vector_size,
590
+ distance_metric,
591
+ config,
592
+ vectors_config,
593
+ )
594
+ )
595
+
596
+ def create_vector_store_for_user(
597
+ self,
598
+ owner_id: str,
599
+ name: str,
600
+ *,
601
+ vector_size: int = 384,
602
+ distance_metric: str = "Cosine",
603
+ config: Optional[Dict[str, Any]] = None,
604
+ vectors_config: Optional[Dict[str, qdrant.VectorParams]] = None, # ← NEW
605
+ ) -> ValidationInterface.VectorStoreRead:
606
+ """
607
+ Admin-only: create a store on behalf of another user.
608
+ Pass `vectors_config` to define a multi-vector schema.
609
+ """
610
+ return self._run_sync(
611
+ self._create_vs_for_user_async(
612
+ owner_id,
613
+ name,
614
+ vector_size,
615
+ distance_metric,
616
+ config,
617
+ vectors_config,
618
+ )
619
+ )
620
+
621
+ # ───────────────────────────────────────────────────────────────
622
+ # Convenience: ensure a per-user “file_search” store exists
623
+ # ───────────────────────────────────────────────────────────────
624
+ # unchanged … (get_or_create_file_search_store)
625
+
626
+ def list_my_vector_stores(self) -> List[ValidationInterface.VectorStoreRead]:
627
+ """List all non-deleted stores owned by *this* API-key’s user."""
628
+ return self._run_sync(self._list_my_vs_async())
629
+
630
+ # ───────────────────────────────────────────────────────────────
631
+ # NEW: real per-user listing (admin-only)
632
+ # ───────────────────────────────────────────────────────────────
633
+ async def _list_vs_by_user_async(self, user_id: str):
634
+ resp = await self._request(
635
+ "GET",
636
+ "/v1/vector-stores/admin/by-user",
637
+ params={"owner_id": user_id},
638
+ )
639
+ return [ValidationInterface.VectorStoreRead.model_validate(r) for r in resp]
640
+
641
+ def get_stores_by_user(
642
+ self,
643
+ _user_id: str,
644
+ ) -> List[ValidationInterface.VectorStoreRead]: # noqa: ARG002
645
+ """
646
+ ⚠️ **Deprecated** – prefer impersonating the user’s API-key or using
647
+ the newer RBAC endpoints, but keep working for legacy code.
648
+ """
649
+ warnings.warn(
650
+ "`get_stores_by_user()` is deprecated; use `list_my_vector_stores()` or "
651
+ "`VectorStoreClient(list_my_vector_stores)` with an impersonated key.",
652
+ DeprecationWarning,
653
+ stacklevel=2,
654
+ )
655
+ return self._run_sync(self._list_vs_by_user_async(_user_id))
656
+
657
+ # ───────────────────────────────────────────────────────────────
658
+ # Convenience: ensure a per-user “file_search” store exists
659
+ # ───────────────────────────────────────────────────────────────
660
+ def get_or_create_file_search_store(self, user_id: Optional[str] = None) -> str:
661
+ """
662
+ Return the *oldest* vector-store named **file_search** for ``user_id``;
663
+ create one if none exist.
664
+
665
+ Parameters
666
+ ----------
667
+ user_id : Optional[str]
668
+ • If **None** → operate on *this* API-key’s stores
669
+ • If not None → *admin-only* – look up / create on behalf of ``user_id``
670
+
671
+ Returns
672
+ -------
673
+ str
674
+ The vector-store **id**.
675
+ """
676
+
677
+ # 1️⃣ Fetch candidate stores
678
+ if user_id is None:
679
+ # Normal user context – only see caller-owned stores
680
+ stores = self.list_my_vector_stores()
681
+ else:
682
+ # Admin context – may inspect another user’s stores
683
+ stores = self.get_stores_by_user(_user_id=user_id)
684
+
685
+ file_search_stores = [s for s in stores if s.name == "file_search"]
686
+
687
+ if file_search_stores:
688
+ # 2️⃣ Pick the *earliest* (oldest created_at) to keep things stable
689
+ chosen = min(
690
+ file_search_stores,
691
+ key=lambda s: (s.created_at or 0),
692
+ )
693
+ log.info(
694
+ "Re-using existing 'file_search' store %s for user %s",
695
+ chosen.id,
696
+ user_id or "<self>",
697
+ )
698
+ return chosen.id
699
+
700
+ # 3️⃣ Nothing found → create a fresh store
701
+ if user_id is None:
702
+ new_store = self.create_vector_store(name="file_search")
703
+ else:
704
+ # Requires admin API-key
705
+ new_store = self.create_vector_store_for_user(
706
+ owner_id=user_id,
707
+ name="file_search",
708
+ )
709
+
710
+ log.info(
711
+ "Created new 'file_search' store %s for user %s",
712
+ new_store.id,
713
+ user_id or "<self>",
714
+ )
715
+ return new_store.id
716
+
717
+ def add_file_to_vector_store(
718
+ self,
719
+ vector_store_id: str,
720
+ file_path: Union[str, Path],
721
+ user_metadata: Optional[Dict[str, Any]] = None,
722
+ ) -> ValidationInterface.VectorStoreFileRead:
723
+ p = Path(file_path)
724
+ if not p.is_file():
725
+ raise FileNotFoundError(f"File not found: {p}")
726
+ return self._run_sync(self._add_file_async(vector_store_id, p, user_metadata))
727
+
728
+ def delete_vector_store(
729
+ self,
730
+ vector_store_id: str,
731
+ permanent: bool = False,
732
+ ) -> Dict[str, Any]:
733
+ return self._run_sync(self._delete_vs_async(vector_store_id, permanent))
734
+
735
+ def delete_file_from_vector_store(
736
+ self,
737
+ vector_store_id: str,
738
+ file_path: str,
739
+ ) -> Dict[str, Any]:
740
+ return self._run_sync(self._delete_file_async(vector_store_id, file_path))
741
+
742
+ def list_store_files(
743
+ self,
744
+ vector_store_id: str,
745
+ ) -> List[ValidationInterface.VectorStoreFileRead]:
746
+ return self._run_sync(self._list_store_files_async(vector_store_id))
747
+
748
+ def update_vector_store_file_status(
749
+ self,
750
+ vector_store_id: str,
751
+ file_id: str,
752
+ status: ValidationInterface.StatusEnum,
753
+ error_message: Optional[str] = None,
754
+ ) -> ValidationInterface.VectorStoreFileRead:
755
+ return self._run_sync(
756
+ self._update_file_status_async(
757
+ vector_store_id, file_id, status, error_message
758
+ )
759
+ )
760
+
761
+ def get_vector_stores_for_assistant(
762
+ self,
763
+ assistant_id: str,
764
+ ) -> List[ValidationInterface.VectorStoreRead]:
765
+ return self._run_sync(self._get_assistant_vs_async(assistant_id))
766
+
767
+ def attach_vector_store_to_assistant(
768
+ self,
769
+ vector_store_id: str,
770
+ assistant_id: str,
771
+ ) -> bool:
772
+ return self._run_sync(self._attach_vs_async(vector_store_id, assistant_id))
773
+
774
+ def detach_vector_store_from_assistant(
775
+ self,
776
+ vector_store_id: str,
777
+ assistant_id: str,
778
+ ) -> bool:
779
+ return self._run_sync(self._detach_vs_async(vector_store_id, assistant_id))
780
+
781
+ def retrieve_vector_store_sync(
782
+ self,
783
+ vector_store_id: str,
784
+ ) -> ValidationInterface.VectorStoreRead:
785
+ resp = self._sync_api_client.get(f"/v1/vector-stores/{vector_store_id}")
786
+ resp.raise_for_status()
787
+ return ValidationInterface.VectorStoreRead.model_validate(resp.json())
788
+
789
+ def vector_file_search_raw(
790
+ self,
791
+ vector_store_id: str,
792
+ query_text: str,
793
+ top_k: int = 5,
794
+ filters: Optional[Dict] = None,
795
+ vector_store_host: Optional[str] = None,
796
+ vector_field: Optional[str] = None, # ← NEW
797
+ ) -> List[Dict[str, Any]]:
798
+ return self._run_sync(
799
+ self._search_vs_async(
800
+ vector_store_id,
801
+ query_text,
802
+ top_k,
803
+ filters,
804
+ vector_store_host,
805
+ vector_field,
806
+ )
807
+ )
808
+
809
+ # ─────────────────────────────────────────────────────────────────────────────
810
+ # MID‑LEVEL: envelope but **no** rerank / synthesis
811
+ # ─────────────────────────────────────────────────────────────────────────────
812
+ def simple_vector_file_search(
813
+ self,
814
+ vector_store_id: str,
815
+ query_text: str,
816
+ top_k: int = 5,
817
+ filters: Optional[Dict] = None,
818
+ ) -> Dict[str, Any]:
819
+ """
820
+ Run a semantic search against *vector_store_id* and return the results
821
+ wrapped in an OpenAI‑compatible envelope (file_search_call + assistant
822
+ message with file_citation annotations).
823
+
824
+ Args:
825
+ vector_store_id: The store ID to query.
826
+ query_text: Natural‑language search text.
827
+ top_k: Maximum hits to retrieve.
828
+ filters: Optional Qdrant payload filter dict.
829
+
830
+ Returns:
831
+ dict: JSON‑serialisable envelope identical to the OpenAI format.
832
+ """
833
+ # 1️⃣ Raw hits (list[dict] from VectorStoreManager.query_store)
834
+ raw_hits = self.vector_file_search_raw(
835
+ vector_store_id=vector_store_id,
836
+ query_text=query_text,
837
+ top_k=top_k,
838
+ filters=filters,
839
+ )
840
+
841
+ # 2️⃣ Normalise / enrich each hit so downstream code never crashes
842
+ hits: List[Dict[str, Any]] = []
843
+ for h in raw_hits:
844
+ md = h.get("meta_data") or h.get("metadata") or {}
845
+ hits.append(
846
+ {
847
+ "text": h["text"],
848
+ "score": h["score"],
849
+ "meta_data": md,
850
+ "vector_id": h.get("vector_id"),
851
+ "store_id": h.get("store_id"),
852
+ }
853
+ )
854
+
855
+ # 3️⃣ Generate human‑friendly answer text (LLM call or simple template)
856
+ answer_text = summarize_hits(query_text, hits)
857
+
858
+ # 4️⃣ Wrap everything into an OpenAI envelope
859
+ return make_envelope(query_text, hits, answer_text)
860
+
861
+ # ────────────────────────────────────────────────────────────────
862
+ # End‑to‑end: retrieve → (rerank) → synthesize → envelope
863
+ # ────────────────────────────────────────────────────────────────
864
+ def attended_file_search(
865
+ self,
866
+ vector_store_id: str,
867
+ query_text: str,
868
+ k: int = 20,
869
+ vector_store_host: Optional[str] = None,
870
+ ) -> Dict[str, Any]:
871
+ """
872
+ Run a full file search with optional cross-encoder rerank and envelope synthesis.
873
+
874
+ Parameters
875
+ ----------
876
+ vector_store_id : str
877
+ The ID of the target vector store to query.
878
+ query_text : str
879
+ The natural-language search text.
880
+ k : int, optional
881
+ The maximum number of hits to retrieve (default is 20).
882
+ vector_store_host : Optional[str], optional
883
+ An optional override for the default vector store host.
884
+
885
+ Returns
886
+ -------
887
+ Dict[str, Any]
888
+ An OpenAI-style envelope containing the synthesized response.
889
+ """
890
+
891
+ # 1️⃣ Retrieve initial candidates (now with optional vector_store_host passthrough)
892
+ hits = retriever.retrieve(
893
+ self,
894
+ vector_store_id=vector_store_id,
895
+ query=query_text,
896
+ k=k,
897
+ vector_store_host=vector_store_host,
898
+ )
899
+
900
+ # 2️⃣ Optional cross-encoder / LLM rerank
901
+ hits = reranker.rerank(query_text, hits, top_k=min(len(hits), 10))
902
+
903
+ # 3️⃣ Normalize schema (guarantee 'meta_data')
904
+ hits = self._normalise_hits(hits)
905
+
906
+ # 4️⃣ Abstractive synthesis → OpenAI-style envelope
907
+ return synthesize_envelope(
908
+ query_text,
909
+ hits,
910
+ api_key=self.api_key, # Project-David key
911
+ base_url=self.base_url, # Same backend
912
+ provider_api_key=os.getenv("HYPERBOLIC_API_KEY"), # Hyperbolic key
913
+ )
914
+
915
+ # ────────────────────────────────────────────────────────────────
916
+ # End‑to‑end: retrieve → (rerank) → synthesize → envelope
917
+ # ────────────────────────────────────────────────────────────────
918
+ def unattended_file_search(
919
+ self,
920
+ vector_store_id: str,
921
+ query_text: str,
922
+ k: int = 20,
923
+ vector_store_host: Optional[str] = None,
924
+ ) -> Dict[str, Any]:
925
+ """
926
+ Perform a search over the file vector store and return normalized retrieval hits.
927
+
928
+ This method executes a bare search pipeline: it retrieves vector-based candidates
929
+ using semantic similarity, optionally applies reranking (e.g., cross-encoder or LLM-based),
930
+ and normalizes the result schema. It does not perform synthesis or construct an OpenAI-style envelope.
931
+
932
+ Use this when you want direct access to retrieved content for custom downstream handling,
933
+ logging, inspection, or separate orchestration logic.
934
+
935
+ Parameters
936
+ ----------
937
+ vector_store_id : str
938
+ The ID of the vector store to search within.
939
+ query_text : str
940
+ The user query in natural language.
941
+ k : int, optional
942
+ The number of top hits to retrieve (default is 20).
943
+ vector_store_host : Optional[str], optional
944
+ Optional override for the vector store host (e.g., when calling remote Qdrant).
945
+
946
+ Returns
947
+ -------
948
+ Dict[str, Any]
949
+ A normalized list of retrieval results (each with metadata and score),
950
+ without abstraction, synthesis, or formatting.
951
+ """
952
+
953
+ # 1️⃣ Retrieve initial candidates (now with optional vector_store_host passthrough)
954
+ hits = retriever.retrieve(
955
+ self,
956
+ vector_store_id=vector_store_id,
957
+ query=query_text,
958
+ k=k,
959
+ vector_store_host=vector_store_host,
960
+ )
961
+
962
+ # 2️⃣ Optional cross-encoder / LLM rerank
963
+ hits = reranker.rerank(query_text, hits, top_k=min(len(hits), 10))
964
+
965
+ # 3️⃣ Normalize schema (guarantee 'meta_data')
966
+ hits = self._normalise_hits(hits)
967
+
968
+ return hits
969
+
970
+ @experimental
971
+ def image_similarity_search(
972
+ self,
973
+ vector_store_id: str,
974
+ img: Image.Image,
975
+ k: int = 10,
976
+ vector_store_host: Optional[str] = None,
977
+ ) -> List[Dict[str, Any]]:
978
+ vec = self.file_processor.encode_image(img).tolist()
979
+ return self.vector_file_search_raw(
980
+ vector_store_id=vector_store_id,
981
+ query_text=vec,
982
+ top_k=k,
983
+ filters=None,
984
+ vector_store_host=vector_store_host,
985
+ vector_field="image_vector",
986
+ )
987
+
988
+ @experimental
989
+ def search_images(
990
+ self,
991
+ vector_store_id: str,
992
+ query: Union[str, Image.Image, List[float]],
993
+ *,
994
+ modality: Optional[str] = None,
995
+ k: int = 10,
996
+ vector_store_host: Optional[str] = None,
997
+ ) -> List[Dict[str, Any]]:
998
+ """
999
+ Unified image search across multiple modalities, with appropriate reranking:
1000
+
1001
+ - If `query` is a str → caption search (reranked)
1002
+ - If `query` is a PIL.Image.Image → visual search (no rerank)
1003
+ - If `query` is a list[float] → raw vector search
1004
+ - `modality` override: one of 'caption', 'image', 'region', 'geo'
1005
+ """
1006
+ # Map modality to (vector_field, encoder)
1007
+ field_map = {
1008
+ "caption": (
1009
+ "caption_vector",
1010
+ lambda q: self.file_processor.encode_clip_text(q).tolist(),
1011
+ ),
1012
+ "image": (
1013
+ "image_vector",
1014
+ lambda q: self.file_processor.encode_image(q).tolist(),
1015
+ ),
1016
+ "region": (
1017
+ "region_vector",
1018
+ lambda q: self.file_processor.encode_text(q).tolist(),
1019
+ ),
1020
+ "geo": ("geo_vector", lambda q: q), # assume q is raw 3-D vector
1021
+ }
1022
+
1023
+ # Auto-detect if not provided
1024
+ if modality is None:
1025
+ if isinstance(query, str):
1026
+ modality = "caption"
1027
+ elif isinstance(query, Image.Image):
1028
+ modality = "image"
1029
+ elif isinstance(query, list):
1030
+ modality = "image"
1031
+ else:
1032
+ raise VectorStoreClientError(f"Unsupported query type: {type(query)}")
1033
+
1034
+ modality = modality.lower()
1035
+ if modality not in field_map:
1036
+ raise VectorStoreClientError(f"Unknown modality '{modality}'")
1037
+
1038
+ vector_field, encoder = field_map[modality]
1039
+ vec = encoder(query)
1040
+
1041
+ # 1️⃣ ANN search
1042
+ hits = self.vector_file_search_raw(
1043
+ vector_store_id=vector_store_id,
1044
+ query_text=vec,
1045
+ top_k=k,
1046
+ filters=None,
1047
+ vector_store_host=vector_store_host,
1048
+ vector_field=vector_field,
1049
+ )
1050
+
1051
+ # 2️⃣ Rerank for text-based modalities
1052
+ if modality in ("caption", "region"):
1053
+ hits = reranker.rerank(
1054
+ query if isinstance(query, str) else "", hits, top_k=min(len(hits), k)
1055
+ )
1056
+
1057
+ # 3️⃣ Normalize and return
1058
+ return self._normalise_hits(hits)