tiphys.memory

Tiphys memory system.

Provides long-term memory storage with embedding support.

 1"""
 2Tiphys memory system.
 3
 4Provides long-term memory storage with embedding support.
 5"""
 6
 7from tiphys.memory.embeddings import (
 8    DummyEmbeddingProvider,
 9    EmbeddingCache,
10    EmbeddingProvider,
11    EmbeddingService,
12    cosine_similarity,
13    get_embedding_service,
14)
15from tiphys.memory.store import (
16    MemoryEntry,
17    MemoryStore,
18    SearchResult,
19    get_memory_store,
20)
21
22__all__ = [
23    # Store
24    "MemoryEntry",
25    "MemoryStore",
26    "SearchResult",
27    "get_memory_store",
28    # Embeddings
29    "EmbeddingProvider",
30    "DummyEmbeddingProvider",
31    "EmbeddingCache",
32    "EmbeddingService",
33    "cosine_similarity",
34    "get_embedding_service",
35]
@dataclass
class MemoryEntry:
20@dataclass
21class MemoryEntry:
22    """A single memory entry."""
23
24    id: str
25    content: str
26    metadata: dict[str, Any] = field(default_factory=dict)
27    created_at: datetime = field(default_factory=datetime.utcnow)
28    updated_at: datetime = field(default_factory=datetime.utcnow)
29    embedding: list[float] | None = None
30    content_hash: str = ""
31
32    def __post_init__(self) -> None:
33        """Calculate content hash if not provided."""
34        if not self.content_hash:
35            self.content_hash = hashlib.sha256(self.content.encode()).hexdigest()[:16]

A single memory entry.

MemoryEntry( id: str, content: str, metadata: dict[str, typing.Any] = <factory>, created_at: datetime.datetime = <factory>, updated_at: datetime.datetime = <factory>, embedding: list[float] | None = None, content_hash: str = '')
id: str
content: str
metadata: dict[str, typing.Any]
created_at: datetime.datetime
updated_at: datetime.datetime
embedding: list[float] | None = None
content_hash: str = ''
class MemoryStore:
 47class MemoryStore:
 48    """
 49    In-memory storage for agent memories.
 50
 51    Provides basic CRUD operations and keyword search.
 52    Can be extended with vector search when embeddings are available.
 53    """
 54
 55    def __init__(self) -> None:
 56        """Initialize the memory store."""
 57        self._entries: dict[str, MemoryEntry] = {}
 58        self._by_hash: dict[str, str] = {}  # content_hash -> id
 59
 60    def add(
 61        self,
 62        content: str,
 63        metadata: dict[str, Any] | None = None,
 64        entry_id: str | None = None,
 65        embedding: list[float] | None = None,
 66    ) -> MemoryEntry:
 67        """
 68        Add a memory entry.
 69
 70        Args:
 71            content: The memory content.
 72            metadata: Optional metadata dict.
 73            entry_id: Optional custom ID (generated if not provided).
 74            embedding: Optional pre-computed embedding vector.
 75
 76        Returns:
 77            The created memory entry.
 78        """
 79        entry_id = entry_id or str(uuid4())
 80
 81        entry = MemoryEntry(
 82            id=entry_id,
 83            content=content,
 84            metadata=metadata or {},
 85            embedding=embedding,
 86        )
 87
 88        # Check for duplicate content
 89        if entry.content_hash in self._by_hash:
 90            existing_id = self._by_hash[entry.content_hash]
 91            existing = self._entries.get(existing_id)
 92            if existing:
 93                # Update existing entry
 94                existing.updated_at = datetime.utcnow()
 95                if metadata:
 96                    existing.metadata.update(metadata)
 97                if embedding:
 98                    existing.embedding = embedding
 99                return existing
100
101        self._entries[entry_id] = entry
102        self._by_hash[entry.content_hash] = entry_id
103
104        return entry
105
106    def get(self, entry_id: str) -> MemoryEntry | None:
107        """Get a memory entry by ID."""
108        return self._entries.get(entry_id)
109
110    def update(
111        self,
112        entry_id: str,
113        content: str | None = None,
114        metadata: dict[str, Any] | None = None,
115        embedding: list[float] | None = None,
116    ) -> MemoryEntry | None:
117        """
118        Update a memory entry.
119
120        Args:
121            entry_id: ID of the entry to update.
122            content: New content (optional).
123            metadata: Metadata to merge (optional).
124            embedding: New embedding (optional).
125
126        Returns:
127            Updated entry or None if not found.
128        """
129        entry = self._entries.get(entry_id)
130        if not entry:
131            return None
132
133        if content is not None:
134            # Remove old hash mapping
135            self._by_hash.pop(entry.content_hash, None)
136
137            entry.content = content
138            entry.content_hash = hashlib.sha256(content.encode()).hexdigest()[:16]
139
140            # Add new hash mapping
141            self._by_hash[entry.content_hash] = entry_id
142
143        if metadata is not None:
144            entry.metadata.update(metadata)
145
146        if embedding is not None:
147            entry.embedding = embedding
148
149        entry.updated_at = datetime.utcnow()
150
151        return entry
152
153    def delete(self, entry_id: str) -> bool:
154        """
155        Delete a memory entry.
156
157        Args:
158            entry_id: ID of the entry to delete.
159
160        Returns:
161            True if deleted, False if not found.
162        """
163        entry = self._entries.pop(entry_id, None)
164        if entry:
165            self._by_hash.pop(entry.content_hash, None)
166            return True
167        return False
168
169    def search(
170        self,
171        query: str,
172        limit: int = 10,
173        metadata_filter: dict[str, Any] | None = None,
174    ) -> list[SearchResult]:
175        """
176        Search memories using keyword matching.
177
178        Args:
179            query: Search query string.
180            limit: Maximum results to return.
181            metadata_filter: Filter by metadata fields.
182
183        Returns:
184            List of search results sorted by relevance.
185        """
186        query_lower = query.lower()
187        query_words = set(query_lower.split())
188        results: list[SearchResult] = []
189
190        for entry in self._entries.values():
191            # Apply metadata filter
192            if metadata_filter and not all(
193                entry.metadata.get(k) == v for k, v in metadata_filter.items()
194            ):
195                continue
196
197            # Score based on keyword matching
198            content_lower = entry.content.lower()
199
200            # Exact phrase match
201            if query_lower in content_lower:
202                results.append(SearchResult(entry=entry, score=1.0, match_type="exact"))
203                continue
204
205            # Word overlap score
206            content_words = set(content_lower.split())
207            overlap = len(query_words & content_words)
208            if overlap > 0:
209                score = overlap / len(query_words)
210                results.append(SearchResult(entry=entry, score=score, match_type="keyword"))
211
212        # Sort by score descending
213        results.sort(key=lambda r: r.score, reverse=True)
214
215        return results[:limit]
216
217    def search_by_metadata(
218        self,
219        metadata_filter: dict[str, Any],
220        limit: int = 100,
221    ) -> list[MemoryEntry]:
222        """
223        Search memories by metadata fields.
224
225        Args:
226            metadata_filter: Metadata fields to match.
227            limit: Maximum results.
228
229        Returns:
230            List of matching entries.
231        """
232        results: list[MemoryEntry] = []
233
234        for entry in self._entries.values():
235            if all(entry.metadata.get(k) == v for k, v in metadata_filter.items()):
236                results.append(entry)
237                if len(results) >= limit:
238                    break
239
240        return results
241
242    def list_all(self, limit: int = 100, offset: int = 0) -> list[MemoryEntry]:
243        """
244        List all memory entries.
245
246        Args:
247            limit: Maximum entries to return.
248            offset: Number of entries to skip.
249
250        Returns:
251            List of memory entries.
252        """
253        entries = list(self._entries.values())
254        # Sort by created_at descending (newest first)
255        entries.sort(key=lambda e: e.created_at, reverse=True)
256        return entries[offset : offset + limit]
257
258    def count(self) -> int:
259        """Get total number of entries."""
260        return len(self._entries)
261
262    def clear(self) -> int:
263        """
264        Clear all entries.
265
266        Returns:
267            Number of entries cleared.
268        """
269        count = len(self._entries)
270        self._entries.clear()
271        self._by_hash.clear()
272        return count

In-memory storage for agent memories.

Provides basic CRUD operations and keyword search. Can be extended with vector search when embeddings are available.

MemoryStore()
55    def __init__(self) -> None:
56        """Initialize the memory store."""
57        self._entries: dict[str, MemoryEntry] = {}
58        self._by_hash: dict[str, str] = {}  # content_hash -> id

Initialize the memory store.

def add( self, content: str, metadata: dict[str, typing.Any] | None = None, entry_id: str | None = None, embedding: list[float] | None = None) -> MemoryEntry:
 60    def add(
 61        self,
 62        content: str,
 63        metadata: dict[str, Any] | None = None,
 64        entry_id: str | None = None,
 65        embedding: list[float] | None = None,
 66    ) -> MemoryEntry:
 67        """
 68        Add a memory entry.
 69
 70        Args:
 71            content: The memory content.
 72            metadata: Optional metadata dict.
 73            entry_id: Optional custom ID (generated if not provided).
 74            embedding: Optional pre-computed embedding vector.
 75
 76        Returns:
 77            The created memory entry.
 78        """
 79        entry_id = entry_id or str(uuid4())
 80
 81        entry = MemoryEntry(
 82            id=entry_id,
 83            content=content,
 84            metadata=metadata or {},
 85            embedding=embedding,
 86        )
 87
 88        # Check for duplicate content
 89        if entry.content_hash in self._by_hash:
 90            existing_id = self._by_hash[entry.content_hash]
 91            existing = self._entries.get(existing_id)
 92            if existing:
 93                # Update existing entry
 94                existing.updated_at = datetime.utcnow()
 95                if metadata:
 96                    existing.metadata.update(metadata)
 97                if embedding:
 98                    existing.embedding = embedding
 99                return existing
100
101        self._entries[entry_id] = entry
102        self._by_hash[entry.content_hash] = entry_id
103
104        return entry

Add a memory entry.

Args: content: The memory content. metadata: Optional metadata dict. entry_id: Optional custom ID (generated if not provided). embedding: Optional pre-computed embedding vector.

Returns: The created memory entry.

def get(self, entry_id: str) -> MemoryEntry | None:
106    def get(self, entry_id: str) -> MemoryEntry | None:
107        """Get a memory entry by ID."""
108        return self._entries.get(entry_id)

Get a memory entry by ID.

def update( self, entry_id: str, content: str | None = None, metadata: dict[str, typing.Any] | None = None, embedding: list[float] | None = None) -> MemoryEntry | None:
110    def update(
111        self,
112        entry_id: str,
113        content: str | None = None,
114        metadata: dict[str, Any] | None = None,
115        embedding: list[float] | None = None,
116    ) -> MemoryEntry | None:
117        """
118        Update a memory entry.
119
120        Args:
121            entry_id: ID of the entry to update.
122            content: New content (optional).
123            metadata: Metadata to merge (optional).
124            embedding: New embedding (optional).
125
126        Returns:
127            Updated entry or None if not found.
128        """
129        entry = self._entries.get(entry_id)
130        if not entry:
131            return None
132
133        if content is not None:
134            # Remove old hash mapping
135            self._by_hash.pop(entry.content_hash, None)
136
137            entry.content = content
138            entry.content_hash = hashlib.sha256(content.encode()).hexdigest()[:16]
139
140            # Add new hash mapping
141            self._by_hash[entry.content_hash] = entry_id
142
143        if metadata is not None:
144            entry.metadata.update(metadata)
145
146        if embedding is not None:
147            entry.embedding = embedding
148
149        entry.updated_at = datetime.utcnow()
150
151        return entry

Update a memory entry.

Args: entry_id: ID of the entry to update. content: New content (optional). metadata: Metadata to merge (optional). embedding: New embedding (optional).

Returns: Updated entry or None if not found.

def delete(self, entry_id: str) -> bool:
153    def delete(self, entry_id: str) -> bool:
154        """
155        Delete a memory entry.
156
157        Args:
158            entry_id: ID of the entry to delete.
159
160        Returns:
161            True if deleted, False if not found.
162        """
163        entry = self._entries.pop(entry_id, None)
164        if entry:
165            self._by_hash.pop(entry.content_hash, None)
166            return True
167        return False

Delete a memory entry.

Args: entry_id: ID of the entry to delete.

Returns: True if deleted, False if not found.

def search( self, query: str, limit: int = 10, metadata_filter: dict[str, typing.Any] | None = None) -> list[SearchResult]:
169    def search(
170        self,
171        query: str,
172        limit: int = 10,
173        metadata_filter: dict[str, Any] | None = None,
174    ) -> list[SearchResult]:
175        """
176        Search memories using keyword matching.
177
178        Args:
179            query: Search query string.
180            limit: Maximum results to return.
181            metadata_filter: Filter by metadata fields.
182
183        Returns:
184            List of search results sorted by relevance.
185        """
186        query_lower = query.lower()
187        query_words = set(query_lower.split())
188        results: list[SearchResult] = []
189
190        for entry in self._entries.values():
191            # Apply metadata filter
192            if metadata_filter and not all(
193                entry.metadata.get(k) == v for k, v in metadata_filter.items()
194            ):
195                continue
196
197            # Score based on keyword matching
198            content_lower = entry.content.lower()
199
200            # Exact phrase match
201            if query_lower in content_lower:
202                results.append(SearchResult(entry=entry, score=1.0, match_type="exact"))
203                continue
204
205            # Word overlap score
206            content_words = set(content_lower.split())
207            overlap = len(query_words & content_words)
208            if overlap > 0:
209                score = overlap / len(query_words)
210                results.append(SearchResult(entry=entry, score=score, match_type="keyword"))
211
212        # Sort by score descending
213        results.sort(key=lambda r: r.score, reverse=True)
214
215        return results[:limit]

Search memories using keyword matching.

Args: query: Search query string. limit: Maximum results to return. metadata_filter: Filter by metadata fields.

Returns: List of search results sorted by relevance.

def search_by_metadata( self, metadata_filter: dict[str, typing.Any], limit: int = 100) -> list[MemoryEntry]:
217    def search_by_metadata(
218        self,
219        metadata_filter: dict[str, Any],
220        limit: int = 100,
221    ) -> list[MemoryEntry]:
222        """
223        Search memories by metadata fields.
224
225        Args:
226            metadata_filter: Metadata fields to match.
227            limit: Maximum results.
228
229        Returns:
230            List of matching entries.
231        """
232        results: list[MemoryEntry] = []
233
234        for entry in self._entries.values():
235            if all(entry.metadata.get(k) == v for k, v in metadata_filter.items()):
236                results.append(entry)
237                if len(results) >= limit:
238                    break
239
240        return results

Search memories by metadata fields.

Args: metadata_filter: Metadata fields to match. limit: Maximum results.

Returns: List of matching entries.

def list_all( self, limit: int = 100, offset: int = 0) -> list[MemoryEntry]:
242    def list_all(self, limit: int = 100, offset: int = 0) -> list[MemoryEntry]:
243        """
244        List all memory entries.
245
246        Args:
247            limit: Maximum entries to return.
248            offset: Number of entries to skip.
249
250        Returns:
251            List of memory entries.
252        """
253        entries = list(self._entries.values())
254        # Sort by created_at descending (newest first)
255        entries.sort(key=lambda e: e.created_at, reverse=True)
256        return entries[offset : offset + limit]

List all memory entries.

Args: limit: Maximum entries to return. offset: Number of entries to skip.

Returns: List of memory entries.

def count(self) -> int:
258    def count(self) -> int:
259        """Get total number of entries."""
260        return len(self._entries)

Get total number of entries.

def clear(self) -> int:
262    def clear(self) -> int:
263        """
264        Clear all entries.
265
266        Returns:
267            Number of entries cleared.
268        """
269        count = len(self._entries)
270        self._entries.clear()
271        self._by_hash.clear()
272        return count

Clear all entries.

Returns: Number of entries cleared.

@dataclass
class SearchResult:
38@dataclass
39class SearchResult:
40    """Result from a memory search."""
41
42    entry: MemoryEntry
43    score: float = 1.0
44    match_type: str = "exact"  # "exact", "keyword", "semantic"

Result from a memory search.

SearchResult( entry: MemoryEntry, score: float = 1.0, match_type: str = 'exact')
entry: MemoryEntry
score: float = 1.0
match_type: str = 'exact'
def get_memory_store() -> tiphys.memory.store.ScopedMemoryStore:
457def get_memory_store() -> ScopedMemoryStore:
458    """Get the global memory store instance."""
459    global _memory_store
460    if _memory_store is None:
461        from pathlib import Path
462
463        path = str(Path.home() / ".tiphys" / "memory")
464        _memory_store = ScopedMemoryStore(base_path=path)
465    return _memory_store

Get the global memory store instance.

class EmbeddingProvider(abc.ABC):
17class EmbeddingProvider(ABC):
18    """Abstract base class for embedding providers."""
19
20    @property
21    @abstractmethod
22    def dimension(self) -> int:
23        """Get the embedding dimension."""
24        ...
25
26    @property
27    @abstractmethod
28    def model_name(self) -> str:
29        """Get the model name."""
30        ...
31
32    @abstractmethod
33    async def embed(self, text: str) -> list[float]:
34        """
35        Generate embedding for a single text.
36
37        Args:
38            text: Text to embed.
39
40        Returns:
41            Embedding vector.
42        """
43        ...
44
45    @abstractmethod
46    async def embed_batch(self, texts: list[str]) -> list[list[float]]:
47        """
48        Generate embeddings for multiple texts.
49
50        Args:
51            texts: List of texts to embed.
52
53        Returns:
54            List of embedding vectors.
55        """
56        ...

Abstract base class for embedding providers.

dimension: int
20    @property
21    @abstractmethod
22    def dimension(self) -> int:
23        """Get the embedding dimension."""
24        ...

Get the embedding dimension.

model_name: str
26    @property
27    @abstractmethod
28    def model_name(self) -> str:
29        """Get the model name."""
30        ...

Get the model name.

@abstractmethod
async def embed(self, text: str) -> list[float]:
32    @abstractmethod
33    async def embed(self, text: str) -> list[float]:
34        """
35        Generate embedding for a single text.
36
37        Args:
38            text: Text to embed.
39
40        Returns:
41            Embedding vector.
42        """
43        ...

Generate embedding for a single text.

Args: text: Text to embed.

Returns: Embedding vector.

@abstractmethod
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
45    @abstractmethod
46    async def embed_batch(self, texts: list[str]) -> list[list[float]]:
47        """
48        Generate embeddings for multiple texts.
49
50        Args:
51            texts: List of texts to embed.
52
53        Returns:
54            List of embedding vectors.
55        """
56        ...

Generate embeddings for multiple texts.

Args: texts: List of texts to embed.

Returns: List of embedding vectors.

class DummyEmbeddingProvider(tiphys.memory.EmbeddingProvider):
59class DummyEmbeddingProvider(EmbeddingProvider):
60    """
61    Dummy embedding provider for testing.
62
63    Generates deterministic hash-based embeddings.
64    """
65
66    def __init__(self, dimension: int = 384) -> None:
67        """Initialize with specified dimension."""
68        self._dimension = dimension
69
70    @property
71    def dimension(self) -> int:
72        """Get the embedding dimension."""
73        return self._dimension
74
75    @property
76    def model_name(self) -> str:
77        """Get the model name."""
78        return "dummy"
79
80    async def embed(self, text: str) -> list[float]:
81        """Generate a deterministic embedding based on text hash."""
82        # Use text hash to generate reproducible embedding
83        text_hash = hashlib.sha256(text.encode()).digest()
84
85        # Convert bytes to floats in range [-1, 1]
86        embedding: list[float] = []
87        for i in range(self._dimension):
88            byte_idx = i % len(text_hash)
89            # Normalize to [-1, 1]
90            value = (text_hash[byte_idx] / 127.5) - 1.0
91            embedding.append(value)
92
93        return embedding
94
95    async def embed_batch(self, texts: list[str]) -> list[list[float]]:
96        """Generate embeddings for multiple texts."""
97        return [await self.embed(text) for text in texts]

Dummy embedding provider for testing.

Generates deterministic hash-based embeddings.

DummyEmbeddingProvider(dimension: int = 384)
66    def __init__(self, dimension: int = 384) -> None:
67        """Initialize with specified dimension."""
68        self._dimension = dimension

Initialize with specified dimension.

dimension: int
70    @property
71    def dimension(self) -> int:
72        """Get the embedding dimension."""
73        return self._dimension

Get the embedding dimension.

model_name: str
75    @property
76    def model_name(self) -> str:
77        """Get the model name."""
78        return "dummy"

Get the model name.

async def embed(self, text: str) -> list[float]:
80    async def embed(self, text: str) -> list[float]:
81        """Generate a deterministic embedding based on text hash."""
82        # Use text hash to generate reproducible embedding
83        text_hash = hashlib.sha256(text.encode()).digest()
84
85        # Convert bytes to floats in range [-1, 1]
86        embedding: list[float] = []
87        for i in range(self._dimension):
88            byte_idx = i % len(text_hash)
89            # Normalize to [-1, 1]
90            value = (text_hash[byte_idx] / 127.5) - 1.0
91            embedding.append(value)
92
93        return embedding

Generate a deterministic embedding based on text hash.

async def embed_batch(self, texts: list[str]) -> list[list[float]]:
95    async def embed_batch(self, texts: list[str]) -> list[list[float]]:
96        """Generate embeddings for multiple texts."""
97        return [await self.embed(text) for text in texts]

Generate embeddings for multiple texts.

class EmbeddingCache:
159class EmbeddingCache:
160    """
161    Cache for embeddings indexed by content hash.
162
163    Avoids redundant embedding generation for repeated content.
164    """
165
166    def __init__(self) -> None:
167        """Initialize the cache."""
168        self._cache: dict[str, list[float]] = {}
169        self._hits = 0
170        self._misses = 0
171
172    def _hash_text(self, text: str) -> str:
173        """Generate hash for text."""
174        return hashlib.sha256(text.encode()).hexdigest()[:32]
175
176    def get(self, text: str) -> list[float] | None:
177        """
178        Get cached embedding for text.
179
180        Args:
181            text: The text to look up.
182
183        Returns:
184            Cached embedding or None if not found.
185        """
186        key = self._hash_text(text)
187        embedding = self._cache.get(key)
188
189        if embedding is not None:
190            self._hits += 1
191        else:
192            self._misses += 1
193
194        return embedding
195
196    def set(self, text: str, embedding: list[float]) -> None:
197        """
198        Cache an embedding.
199
200        Args:
201            text: The text that was embedded.
202            embedding: The embedding vector.
203        """
204        key = self._hash_text(text)
205        self._cache[key] = embedding
206
207    def clear(self) -> int:
208        """
209        Clear the cache.
210
211        Returns:
212            Number of entries cleared.
213        """
214        count = len(self._cache)
215        self._cache.clear()
216        return count
217
218    @property
219    def size(self) -> int:
220        """Get number of cached embeddings."""
221        return len(self._cache)
222
223    @property
224    def hit_rate(self) -> float:
225        """Get cache hit rate."""
226        total = self._hits + self._misses
227        if total == 0:
228            return 0.0
229        return self._hits / total

Cache for embeddings indexed by content hash.

Avoids redundant embedding generation for repeated content.

EmbeddingCache()
166    def __init__(self) -> None:
167        """Initialize the cache."""
168        self._cache: dict[str, list[float]] = {}
169        self._hits = 0
170        self._misses = 0

Initialize the cache.

def get(self, text: str) -> list[float] | None:
176    def get(self, text: str) -> list[float] | None:
177        """
178        Get cached embedding for text.
179
180        Args:
181            text: The text to look up.
182
183        Returns:
184            Cached embedding or None if not found.
185        """
186        key = self._hash_text(text)
187        embedding = self._cache.get(key)
188
189        if embedding is not None:
190            self._hits += 1
191        else:
192            self._misses += 1
193
194        return embedding

Get cached embedding for text.

Args: text: The text to look up.

Returns: Cached embedding or None if not found.

def set(self, text: str, embedding: list[float]) -> None:
196    def set(self, text: str, embedding: list[float]) -> None:
197        """
198        Cache an embedding.
199
200        Args:
201            text: The text that was embedded.
202            embedding: The embedding vector.
203        """
204        key = self._hash_text(text)
205        self._cache[key] = embedding

Cache an embedding.

Args: text: The text that was embedded. embedding: The embedding vector.

def clear(self) -> int:
207    def clear(self) -> int:
208        """
209        Clear the cache.
210
211        Returns:
212            Number of entries cleared.
213        """
214        count = len(self._cache)
215        self._cache.clear()
216        return count

Clear the cache.

Returns: Number of entries cleared.

size: int
218    @property
219    def size(self) -> int:
220        """Get number of cached embeddings."""
221        return len(self._cache)

Get number of cached embeddings.

hit_rate: float
223    @property
224    def hit_rate(self) -> float:
225        """Get cache hit rate."""
226        total = self._hits + self._misses
227        if total == 0:
228            return 0.0
229        return self._hits / total

Get cache hit rate.

class EmbeddingService:
232class EmbeddingService:
233    """
234    High-level service for generating embeddings with caching.
235
236    Combines a provider with a cache for efficient embedding generation.
237    """
238
239    def __init__(
240        self,
241        provider: EmbeddingProvider | None = None,
242        cache: EmbeddingCache | None = None,
243    ) -> None:
244        """
245        Initialize the embedding service.
246
247        Args:
248            provider: Embedding provider. Uses dummy provider if not provided.
249            cache: Embedding cache. Creates new cache if not provided.
250        """
251        self._provider = provider or DummyEmbeddingProvider()
252        self._cache = cache or EmbeddingCache()
253
254    @property
255    def provider(self) -> EmbeddingProvider:
256        """Get the embedding provider."""
257        return self._provider
258
259    @property
260    def cache(self) -> EmbeddingCache:
261        """Get the embedding cache."""
262        return self._cache
263
264    @property
265    def dimension(self) -> int:
266        """Get the embedding dimension."""
267        return self._provider.dimension
268
269    async def embed(self, text: str, use_cache: bool = True) -> list[float]:
270        """
271        Generate embedding for text.
272
273        Args:
274            text: Text to embed.
275            use_cache: Whether to use caching.
276
277        Returns:
278            Embedding vector.
279        """
280        if use_cache:
281            cached = self._cache.get(text)
282            if cached is not None:
283                return cached
284
285        embedding = await self._provider.embed(text)
286
287        if use_cache:
288            self._cache.set(text, embedding)
289
290        return embedding
291
292    async def embed_batch(self, texts: list[str], use_cache: bool = True) -> list[list[float]]:
293        """
294        Generate embeddings for multiple texts.
295
296        Uses cache for individual texts that have been seen before.
297
298        Args:
299            texts: List of texts to embed.
300            use_cache: Whether to use caching.
301
302        Returns:
303            List of embedding vectors.
304        """
305        if not use_cache:
306            return await self._provider.embed_batch(texts)
307
308        embeddings: list[list[float] | None] = []
309        texts_to_embed: list[tuple[int, str]] = []
310
311        # Check cache for each text
312        for i, text in enumerate(texts):
313            cached = self._cache.get(text)
314            if cached is not None:
315                embeddings.append(cached)
316            else:
317                embeddings.append(None)
318                texts_to_embed.append((i, text))
319
320        # Generate missing embeddings
321        if texts_to_embed:
322            missing_texts = [t[1] for t in texts_to_embed]
323            new_embeddings = await self._provider.embed_batch(missing_texts)
324
325            # Fill in results and cache
326            for (idx, text), embedding in zip(texts_to_embed, new_embeddings):
327                embeddings[idx] = embedding
328                self._cache.set(text, embedding)
329
330        # Type narrowing - all None values should be filled
331        return [e for e in embeddings if e is not None]

High-level service for generating embeddings with caching.

Combines a provider with a cache for efficient embedding generation.

EmbeddingService( provider: EmbeddingProvider | None = None, cache: EmbeddingCache | None = None)
239    def __init__(
240        self,
241        provider: EmbeddingProvider | None = None,
242        cache: EmbeddingCache | None = None,
243    ) -> None:
244        """
245        Initialize the embedding service.
246
247        Args:
248            provider: Embedding provider. Uses dummy provider if not provided.
249            cache: Embedding cache. Creates new cache if not provided.
250        """
251        self._provider = provider or DummyEmbeddingProvider()
252        self._cache = cache or EmbeddingCache()

Initialize the embedding service.

Args: provider: Embedding provider. Uses dummy provider if not provided. cache: Embedding cache. Creates new cache if not provided.

provider: EmbeddingProvider
254    @property
255    def provider(self) -> EmbeddingProvider:
256        """Get the embedding provider."""
257        return self._provider

Get the embedding provider.

cache: EmbeddingCache
259    @property
260    def cache(self) -> EmbeddingCache:
261        """Get the embedding cache."""
262        return self._cache

Get the embedding cache.

dimension: int
264    @property
265    def dimension(self) -> int:
266        """Get the embedding dimension."""
267        return self._provider.dimension

Get the embedding dimension.

async def embed(self, text: str, use_cache: bool = True) -> list[float]:
269    async def embed(self, text: str, use_cache: bool = True) -> list[float]:
270        """
271        Generate embedding for text.
272
273        Args:
274            text: Text to embed.
275            use_cache: Whether to use caching.
276
277        Returns:
278            Embedding vector.
279        """
280        if use_cache:
281            cached = self._cache.get(text)
282            if cached is not None:
283                return cached
284
285        embedding = await self._provider.embed(text)
286
287        if use_cache:
288            self._cache.set(text, embedding)
289
290        return embedding

Generate embedding for text.

Args: text: Text to embed. use_cache: Whether to use caching.

Returns: Embedding vector.

async def embed_batch(self, texts: list[str], use_cache: bool = True) -> list[list[float]]:
292    async def embed_batch(self, texts: list[str], use_cache: bool = True) -> list[list[float]]:
293        """
294        Generate embeddings for multiple texts.
295
296        Uses cache for individual texts that have been seen before.
297
298        Args:
299            texts: List of texts to embed.
300            use_cache: Whether to use caching.
301
302        Returns:
303            List of embedding vectors.
304        """
305        if not use_cache:
306            return await self._provider.embed_batch(texts)
307
308        embeddings: list[list[float] | None] = []
309        texts_to_embed: list[tuple[int, str]] = []
310
311        # Check cache for each text
312        for i, text in enumerate(texts):
313            cached = self._cache.get(text)
314            if cached is not None:
315                embeddings.append(cached)
316            else:
317                embeddings.append(None)
318                texts_to_embed.append((i, text))
319
320        # Generate missing embeddings
321        if texts_to_embed:
322            missing_texts = [t[1] for t in texts_to_embed]
323            new_embeddings = await self._provider.embed_batch(missing_texts)
324
325            # Fill in results and cache
326            for (idx, text), embedding in zip(texts_to_embed, new_embeddings):
327                embeddings[idx] = embedding
328                self._cache.set(text, embedding)
329
330        # Type narrowing - all None values should be filled
331        return [e for e in embeddings if e is not None]

Generate embeddings for multiple texts.

Uses cache for individual texts that have been seen before.

Args: texts: List of texts to embed. use_cache: Whether to use caching.

Returns: List of embedding vectors.

def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
334def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
335    """
336    Calculate cosine similarity between two vectors.
337
338    Args:
339        vec1: First vector.
340        vec2: Second vector.
341
342    Returns:
343        Cosine similarity in range [-1, 1].
344    """
345    if len(vec1) != len(vec2):
346        raise ValueError("Vectors must have the same dimension")
347
348    dot_product: float = sum(a * b for a, b in zip(vec1, vec2))
349    norm1: float = sum(a * a for a in vec1) ** 0.5
350    norm2: float = sum(b * b for b in vec2) ** 0.5
351
352    if norm1 == 0 or norm2 == 0:
353        return 0.0
354
355    result: float = dot_product / (norm1 * norm2)
356    return result

Calculate cosine similarity between two vectors.

Args: vec1: First vector. vec2: Second vector.

Returns: Cosine similarity in range [-1, 1].

def get_embedding_service() -> EmbeddingService:
363def get_embedding_service() -> EmbeddingService:
364    """Get the global embedding service instance."""
365    global _embedding_service
366    if _embedding_service is None:
367        _embedding_service = EmbeddingService()
368    return _embedding_service

Get the global embedding service instance.