oclaw/runtime/orchestration/vector_store.py
oliver 420abac9f1 refactor: root-package imports (svc/runtime/interfaces) and fix PYTHONPATH
- Rename platform/ to svc/ to avoid shadowing stdlib platform.
- Replace from oclaw.* with from svc/runtime/interfaces; update -m CLI paths.
- tests/conftest: prepend repo root to sys.path (no parent-folder package name).
- CI: paths and offline_eval script under repo root.
- Ops scripts: PYTHONPATH must be repo root for python -m runtime.* (fixes gateway/WhatsApp sidecar startup).
- Fix default oclaw.json path in tabular/file attachment limits; stabilize attachment test config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 14:51:17 +08:00

275 lines
7.7 KiB
Python

from __future__ import annotations
import json
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Protocol
from svc.embeddings.embedding_client import EmbeddingClient
from svc.persistence.sqlite_store import SqliteStore
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
s = value.strip().lower()
if s in {"1", "true", "yes", "y", "on"}:
return True
if s in {"0", "false", "no", "n", "off"}:
return False
return default
@dataclass(frozen=True)
class MemoryVectorItem:
memory_id: str
tenant_id: str
user_id: str
session_id: str
memory_type: str
content: str
confidence: float
created_at: str
updated_at: str
expires_at: str | None = None
metadata: dict[str, Any] | None = None
@dataclass(frozen=True)
class MemoryVectorHit:
memory_id: str
score: float
source: str
content: str
tenant_id: str
user_id: str
session_id: str
memory_type: str
confidence: float
created_at: str
metadata: dict[str, Any] | None = None
class VectorStore(Protocol):
def upsert(self, item: MemoryVectorItem, vector: list[float], *, model: str) -> None: ...
def search(
self,
*,
query_vector: list[float],
tenant_id: str,
user_id: str,
top_k: int,
model: str,
) -> list[MemoryVectorHit]: ...
class SqliteVectorStore:
def __init__(self, store: SqliteStore):
self.store = store
self.store.ensure_memory_tables()
def upsert(self, item: MemoryVectorItem, vector: list[float], *, model: str) -> None:
self.store.upsert_memory_item(
memory_id=item.memory_id,
tenant_id=item.tenant_id,
user_id=item.user_id,
session_id=item.session_id,
memory_type=item.memory_type,
content=item.content,
confidence=float(item.confidence),
source="vector:sqlite",
metadata=item.metadata or {},
created_at=item.created_at,
updated_at=item.updated_at,
expires_at=item.expires_at,
)
self.store.upsert_memory_vector(
memory_id=item.memory_id,
model=model,
vector=[float(x) for x in (vector or [])],
updated_at=item.updated_at or _utc_now_iso(),
)
def search(
self,
*,
query_vector: list[float],
tenant_id: str,
user_id: str,
top_k: int,
model: str,
) -> list[MemoryVectorHit]:
rows = self.store.search_memory_vectors(
query_vector=query_vector,
model=model,
tenant_id=tenant_id,
user_id=user_id,
limit=top_k,
)
out: list[MemoryVectorHit] = []
for row in rows:
out.append(
MemoryVectorHit(
memory_id=str(row.get("memory_id") or ""),
score=float(row.get("score") or 0.0),
source=str(row.get("source") or "vector:sqlite"),
content=str(row.get("content") or ""),
tenant_id=str(row.get("tenant_id") or ""),
user_id=str(row.get("user_id") or ""),
session_id=str(row.get("session_id") or ""),
memory_type=str(row.get("memory_type") or "semantic"),
confidence=float(row.get("confidence") or 0.0),
created_at=str(row.get("created_at") or ""),
metadata=row.get("metadata") if isinstance(row.get("metadata"), dict) else {},
)
)
return out
class ChromaVectorStore(SqliteVectorStore):
"""Best-effort adapter: delegates to SQLite if Chroma client is unavailable."""
def __init__(self, store: SqliteStore):
super().__init__(store)
self._available = False
try:
import chromadb # noqa: F401
self._available = True
except Exception:
self._available = False
@property
def available(self) -> bool:
return self._available
class QdrantVectorStore(SqliteVectorStore):
"""Best-effort adapter: delegates to SQLite if Qdrant client is unavailable."""
def __init__(self, store: SqliteStore):
super().__init__(store)
self._available = False
try:
import qdrant_client # noqa: F401
self._available = True
except Exception:
self._available = False
@property
def available(self) -> bool:
return self._available
@dataclass(frozen=True)
class VectorMemoryRuntime:
enabled: bool
backend: str
top_k: int
writer_enabled: bool
write_min_confidence: float
def read_vector_memory_runtime(store: SqliteStore) -> VectorMemoryRuntime:
def _get(name: str, default: str) -> str:
v = store.get_setting(name)
if v is not None and str(v).strip() != "":
return str(v).strip()
return str(os.getenv(name) or default).strip()
enabled = _parse_bool(_get("MEMORY_VECTOR_ENABLED", "0"), default=False)
backend = (_get("MEMORY_VECTOR_BACKEND", "sqlite") or "sqlite").strip().lower()
if backend not in {"sqlite", "chroma", "qdrant"}:
backend = "sqlite"
try:
top_k = max(1, min(20, int(_get("MEMORY_VECTOR_TOPK", "5"))))
except Exception:
top_k = 5
writer_enabled = _parse_bool(_get("MEMORY_WRITE_ENABLED", "0"), default=False)
try:
write_min_confidence = float(_get("MEMORY_WRITE_MIN_CONFIDENCE", "0.75"))
except Exception:
write_min_confidence = 0.75
return VectorMemoryRuntime(
enabled=enabled,
backend=backend,
top_k=top_k,
writer_enabled=writer_enabled,
write_min_confidence=max(0.0, min(1.0, write_min_confidence)),
)
def build_vector_store(store: SqliteStore) -> VectorStore:
runtime = read_vector_memory_runtime(store)
if runtime.backend == "chroma":
adapter = ChromaVectorStore(store)
if adapter.available:
return adapter
if runtime.backend == "qdrant":
adapter = QdrantVectorStore(store)
if adapter.available:
return adapter
return SqliteVectorStore(store)
def semantic_search(
*,
store: SqliteStore,
embedder: EmbeddingClient,
query: str,
tenant_id: str,
user_id: str,
top_k: int,
) -> list[MemoryVectorHit]:
token = (query or "").strip()
if not token or not tenant_id or not user_id:
return []
emb = embedder.embed(token)
vs = build_vector_store(store)
return vs.search(
query_vector=emb.vector,
tenant_id=tenant_id,
user_id=user_id,
top_k=max(1, int(top_k)),
model=emb.model,
)
def dump_hit_json(hit: MemoryVectorHit) -> str:
return json.dumps(
{
"memory_id": hit.memory_id,
"score": hit.score,
"source": hit.source,
"tenant_id": hit.tenant_id,
"user_id": hit.user_id,
"session_id": hit.session_id,
"memory_type": hit.memory_type,
"confidence": hit.confidence,
"created_at": hit.created_at,
"metadata": hit.metadata or {},
},
ensure_ascii=False,
)
__all__ = [
"MemoryVectorHit",
"MemoryVectorItem",
"VectorMemoryRuntime",
"VectorStore",
"build_vector_store",
"dump_hit_json",
"read_vector_memory_runtime",
"semantic_search",
]