"""Thoth vector HTTP adapter using distinct read and write clients.""" import re from tht.ports.vector import ( VectorCapabilities, VectorHealth, VectorHit, VectorReadUnavailable, VectorStoreError, VectorWriteRecord, VectorWriteUnavailable, require_positive_limit, ) from tht.vectorstore.rest_client import VectorRestClient, VectorRestError from tht.vectorstore.store import hit_from_metadata from tht.adapters.vector.pgvector import ( _collection, _validate_collection_kinds, _validate_known_kinds, ) def _merge(hits: list[VectorHit], limit: int) -> list[VectorHit]: return sorted(hits, key=lambda hit: (-hit.similarity, hit.id))[:limit] class ThothHttpVectorStore: """Vector port backed by the existing allowlisted REST RPCs.""" def __init__( self, reader: VectorRestClient | None, writer: VectorRestClient | None, expected_dimension: int | None = None, ): self._reader = reader self._writer = writer self._expected_dimension = expected_dimension @property def capabilities(self) -> VectorCapabilities: writable = self._writer is not None return VectorCapabilities( search=self._reader is not None, existing_hashes=writable, upsert=writable, metadata_filter=self._reader is not None, delete_generation=writable, list_evidence_generations=writable, ) def health(self) -> VectorHealth: read_reachable, read_detail, read_tables = self._probe(self._reader) write_reachable, write_detail, write_tables = self._probe(self._writer) dimensions = tuple(sorted({ dimension for row in [*read_tables, *write_tables] if type(dimension := row.get("vector_dimensions")) is int })) compatible = ( None if self._expected_dimension is None or not dimensions else dimensions == (self._expected_dimension,) ) reachable = [ status for status in (read_reachable, write_reachable) if status is not None ] ok = bool(reachable) and all(reachable) and compatible is not False details = [detail for detail in (read_detail, write_detail) if detail] return VectorHealth( ok=ok, detail="; ".join(details) or None, read_configured=self._reader is not None, read_reachable=read_reachable, read_detail=read_detail, write_configured=self._writer is not None, write_reachable=write_reachable, write_detail=write_detail, expected_dimension=self._expected_dimension, observed_dimensions=dimensions, dimension_compatible=compatible, ) @staticmethod def _probe(client: VectorRestClient | None) -> tuple[bool | None, str | None, list[dict]]: if client is None: return None, None, [] try: return True, None, client.list_tables() except Exception as exc: return False, str(exc), [] def search( self, collections: list[str], embedding: list[float], *, limit: int, kinds: list[str] | None = None, metadata_filter: dict[str, object] | None = None, ) -> list[VectorHit]: require_positive_limit(limit) if self._reader is None: raise VectorReadUnavailable("Vector reader credential is not configured") if self._expected_dimension is not None and len(embedding) != self._expected_dimension: raise VectorStoreError("Query embedding dimension does not match configured dimension") if kinds: _validate_known_kinds(kinds) hits: list[VectorHit] = [] for collection in collections: _collection("vectors", collection) try: if metadata_filter is None: rows = self._reader.search_similar(collection, embedding, limit, kinds=kinds) else: rows = self._reader.search_similar( collection, embedding, limit, kinds=kinds, metadata_filter=metadata_filter, ) except VectorRestError as exc: raise VectorStoreError(str(exc)) from exc hits.extend( hit_from_metadata(row.get("similarity", 0.0), row.get("metadata")) for row in rows ) if kinds: allowed = set(kinds) hits = [hit for hit in hits if hit.kind in allowed] return _merge(hits, limit) def _require_writer(self) -> VectorRestClient: if self._writer is None: raise VectorWriteUnavailable("Vector writer credential is not configured") return self._writer def existing_hashes(self, collection: str, kinds: list[str]) -> dict[str, str]: _collection("vectors", collection) _validate_collection_kinds(collection, kinds) try: return self._require_writer().existing_hashes(collection, kinds) except VectorRestError as exc: raise VectorStoreError(str(exc)) from exc def upsert(self, collection: str, records: list[VectorWriteRecord]) -> int: writer = self._require_writer() _collection("vectors", collection) for record in records: _validate_collection_kinds(collection, [record.record.kind]) if ( self._expected_dimension is not None and len(record.embedding) != self._expected_dimension ): raise VectorStoreError("Embedding dimension does not match configured dimension") rows = [self._row(record) for record in records] try: return writer.upsert_records(collection, rows) except VectorRestError as exc: raise VectorStoreError(str(exc)) from exc def delete_generation(self, collection: str, generation: str, workspace_id: str) -> int: if collection != "evidence" or re.fullmatch(r"gen:[0-9a-f]{32}", generation) is None: raise VectorStoreError("Only exact Evidence generations may be deleted") try: return self._require_writer().delete_generation(collection, generation, workspace_id) except VectorRestError as exc: raise VectorStoreError(str(exc)) from exc def list_evidence_generations(self, collection: str, workspace_id: str) -> list[str]: if collection != "evidence": raise VectorStoreError("Only exact Evidence generations may be listed") try: return self._require_writer().list_evidence_generations(collection, workspace_id) except VectorRestError as exc: raise VectorWriteUnavailable("Vector generation inventory unavailable") from exc @staticmethod def _row(write_record: VectorWriteRecord) -> dict: record = write_record.record metadata = { "kind": record.kind, "ref": record.ref, "record_key": record.id, "title": record.title, "content": record.content, **record.metadata, } return { "record_key": record.id, "kind": record.kind, "content_hash": write_record.content_hash, "metadata": metadata, "embedding": write_record.embedding, }