longcache 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.
- longcache/__init__.py +11 -0
- longcache/backends/__init__.py +21 -0
- longcache/backends/async_redis.py +274 -0
- longcache/backends/base.py +167 -0
- longcache/backends/memory.py +243 -0
- longcache/backends/redis.py +374 -0
- longcache/backends/sqlite.py +521 -0
- longcache/cli.py +216 -0
- longcache/config.py +127 -0
- longcache/core/__init__.py +8 -0
- longcache/core/base_embedder.py +43 -0
- longcache/core/cache.py +872 -0
- longcache/core/embedder.py +155 -0
- longcache/core/entry.py +122 -0
- longcache/core/index.py +272 -0
- longcache/integrations/__init__.py +32 -0
- longcache/integrations/langchain.py +316 -0
- longcache/integrations/langgraph.py +150 -0
- longcache/integrations/llamaindex.py +103 -0
- longcache/integrations/openai.py +146 -0
- longcache/middleware.py +366 -0
- longcache-0.1.0.dist-info/METADATA +942 -0
- longcache-0.1.0.dist-info/RECORD +26 -0
- longcache-0.1.0.dist-info/WHEEL +4 -0
- longcache-0.1.0.dist-info/entry_points.txt +2 -0
- longcache-0.1.0.dist-info/licenses/LICENSE +21 -0
longcache/cli.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LongCache CLI — Command-line interface for cache management.
|
|
3
|
+
|
|
4
|
+
Provides commands for viewing stats, clearing cache, inspecting
|
|
5
|
+
entries, and exporting cache data.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
longcache stats
|
|
9
|
+
longcache clear --namespace default
|
|
10
|
+
longcache clear --all
|
|
11
|
+
longcache inspect --query "refund"
|
|
12
|
+
longcache export --format json
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
import click
|
|
22
|
+
|
|
23
|
+
from longcache.config import CacheConfig
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_cache(config: Optional[CacheConfig] = None):
|
|
27
|
+
"""Get a LongCache instance, lazily initialized."""
|
|
28
|
+
from longcache import LongCache
|
|
29
|
+
|
|
30
|
+
return LongCache(config or CacheConfig())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@click.group()
|
|
34
|
+
@click.version_option(version="0.1.0", prog_name="longcache")
|
|
35
|
+
def main():
|
|
36
|
+
"""
|
|
37
|
+
LongCache — Semantic similarity cache for LLM/RAG pipelines.
|
|
38
|
+
|
|
39
|
+
View stats, manage entries, and configure your cache from the command line.
|
|
40
|
+
"""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@main.command()
|
|
45
|
+
@click.option("--namespace", default=None, help="Namespace to show stats for.")
|
|
46
|
+
@click.option("--db-path", default=".longcache/cache.db", help="Path to cache database.")
|
|
47
|
+
def stats(namespace: Optional[str], db_path: str):
|
|
48
|
+
"""
|
|
49
|
+
Show cache hit rate, estimated savings, and entry count.
|
|
50
|
+
|
|
51
|
+
Displays key metrics about your cache performance including
|
|
52
|
+
hit rate, estimated tokens saved, and estimated cost savings.
|
|
53
|
+
|
|
54
|
+
Examples:
|
|
55
|
+
|
|
56
|
+
longcache stats
|
|
57
|
+
|
|
58
|
+
longcache stats --namespace faq
|
|
59
|
+
"""
|
|
60
|
+
config = CacheConfig(db_path=db_path, namespace=namespace or "default")
|
|
61
|
+
cache = _get_cache(config)
|
|
62
|
+
|
|
63
|
+
s = cache.stats()
|
|
64
|
+
|
|
65
|
+
click.echo("=" * 50)
|
|
66
|
+
click.echo(" LongCache Statistics")
|
|
67
|
+
click.echo("=" * 50)
|
|
68
|
+
click.echo(f" Namespace: {config.namespace}")
|
|
69
|
+
click.echo(f" Cache Hits: {s['hits']}")
|
|
70
|
+
click.echo(f" Cache Misses: {s['misses']}")
|
|
71
|
+
click.echo(f" Hit Rate: {s['hit_rate']:.1%}")
|
|
72
|
+
click.echo(f" Total Entries: {s['total_entries']}")
|
|
73
|
+
click.echo(f" Est. Tokens Saved: {s['estimated_tokens_saved']:,}")
|
|
74
|
+
click.echo(f" Est. Cost Saved: ${s['estimated_cost_saved_usd']:.4f}")
|
|
75
|
+
click.echo("=" * 50)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@main.command()
|
|
79
|
+
@click.option("--namespace", default=None, help="Namespace to clear.")
|
|
80
|
+
@click.option("--all", "clear_all", is_flag=True, help="Clear ALL namespaces.")
|
|
81
|
+
@click.option("--db-path", default=".longcache/cache.db", help="Path to cache database.")
|
|
82
|
+
@click.confirmation_option(prompt="Are you sure you want to clear the cache?")
|
|
83
|
+
def clear(namespace: Optional[str], clear_all: bool, db_path: str):
|
|
84
|
+
"""
|
|
85
|
+
Clear cache entries by namespace or entirely.
|
|
86
|
+
|
|
87
|
+
Use --namespace to clear a specific namespace, or --all
|
|
88
|
+
to clear the entire cache across all namespaces.
|
|
89
|
+
|
|
90
|
+
Examples:
|
|
91
|
+
|
|
92
|
+
longcache clear --namespace faq
|
|
93
|
+
|
|
94
|
+
longcache clear --all
|
|
95
|
+
"""
|
|
96
|
+
config = CacheConfig(db_path=db_path)
|
|
97
|
+
cache = _get_cache(config)
|
|
98
|
+
|
|
99
|
+
if clear_all:
|
|
100
|
+
count = cache.clear()
|
|
101
|
+
click.echo(f"Cleared entire cache: {count} entries deleted.")
|
|
102
|
+
elif namespace:
|
|
103
|
+
count = cache.invalidate(namespace)
|
|
104
|
+
click.echo(f"Cleared namespace '{namespace}': {count} entries deleted.")
|
|
105
|
+
else:
|
|
106
|
+
count = cache.invalidate()
|
|
107
|
+
click.echo(f"Cleared namespace '{config.namespace}': {count} entries deleted.")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@main.command()
|
|
111
|
+
@click.option("--query", required=True, help="Query text to search for similar cached entries.")
|
|
112
|
+
@click.option("--namespace", default=None, help="Namespace to search in.")
|
|
113
|
+
@click.option("--db-path", default=".longcache/cache.db", help="Path to cache database.")
|
|
114
|
+
def inspect(query: str, namespace: Optional[str], db_path: str):
|
|
115
|
+
"""
|
|
116
|
+
Inspect the nearest cached entry for a query.
|
|
117
|
+
|
|
118
|
+
Shows the most similar cached entry regardless of the
|
|
119
|
+
similarity threshold. Useful for debugging and understanding
|
|
120
|
+
what would be cached for a given query.
|
|
121
|
+
|
|
122
|
+
Examples:
|
|
123
|
+
|
|
124
|
+
longcache inspect --query "What is the refund policy?"
|
|
125
|
+
|
|
126
|
+
longcache inspect --query "refund" --namespace faq
|
|
127
|
+
"""
|
|
128
|
+
config = CacheConfig(db_path=db_path, namespace=namespace or "default")
|
|
129
|
+
cache = _get_cache(config)
|
|
130
|
+
|
|
131
|
+
result = cache.inspect(query, namespace=namespace)
|
|
132
|
+
|
|
133
|
+
if result is None:
|
|
134
|
+
click.echo(f"No similar entries found for query: {query!r}")
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
click.echo("=" * 50)
|
|
138
|
+
click.echo(" Nearest Cached Entry")
|
|
139
|
+
click.echo("=" * 50)
|
|
140
|
+
click.echo(f" ID: {result['id']}")
|
|
141
|
+
click.echo(f" Cached Query: {result['query']!r}")
|
|
142
|
+
click.echo(f" Namespace: {result['namespace']}")
|
|
143
|
+
click.echo(f" Hit Count: {result['hit_count']}")
|
|
144
|
+
click.echo(f" Created At: {result['created_at']}")
|
|
145
|
+
click.echo(f" Expires At: {result['expires_at'] or 'Never'}")
|
|
146
|
+
click.echo(f" Is Expired: {result['is_expired']}")
|
|
147
|
+
click.echo("=" * 50)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@main.command()
|
|
151
|
+
@click.option(
|
|
152
|
+
"--format",
|
|
153
|
+
"export_format",
|
|
154
|
+
default="json",
|
|
155
|
+
type=click.Choice(["json"]),
|
|
156
|
+
help="Export format (currently only JSON supported).",
|
|
157
|
+
)
|
|
158
|
+
@click.option("--namespace", default=None, help="Only export entries from this namespace.")
|
|
159
|
+
@click.option("--db-path", default=".longcache/cache.db", help="Path to cache database.")
|
|
160
|
+
@click.option("--output", "-o", default=None, help="Output file path. Defaults to stdout.")
|
|
161
|
+
def export(export_format: str, namespace: Optional[str], db_path: str, output: Optional[str]):
|
|
162
|
+
"""
|
|
163
|
+
Export cache entries as JSON.
|
|
164
|
+
|
|
165
|
+
Dumps all cache entries (or entries from a specific namespace)
|
|
166
|
+
in JSON format. Useful for backup, migration, or analysis.
|
|
167
|
+
|
|
168
|
+
Examples:
|
|
169
|
+
|
|
170
|
+
longcache export --format json
|
|
171
|
+
|
|
172
|
+
longcache export --namespace faq -o faq_cache.json
|
|
173
|
+
"""
|
|
174
|
+
config = CacheConfig(db_path=db_path, namespace=namespace or "default")
|
|
175
|
+
cache = _get_cache(config)
|
|
176
|
+
|
|
177
|
+
# Access the backend directly for export
|
|
178
|
+
from longcache.backends.sqlite import SQLiteCacheBackend
|
|
179
|
+
|
|
180
|
+
backend = cache.backend
|
|
181
|
+
|
|
182
|
+
if isinstance(backend, SQLiteCacheBackend):
|
|
183
|
+
entries = backend.export_all()
|
|
184
|
+
else:
|
|
185
|
+
# Generic fallback
|
|
186
|
+
ns = namespace or config.namespace
|
|
187
|
+
entries_raw = backend.list_entries(ns)
|
|
188
|
+
entries = [
|
|
189
|
+
{
|
|
190
|
+
"id": e.id,
|
|
191
|
+
"namespace": e.namespace,
|
|
192
|
+
"query": e.query,
|
|
193
|
+
"response": str(e.response),
|
|
194
|
+
"created_at": e.created_at,
|
|
195
|
+
"expires_at": e.expires_at,
|
|
196
|
+
"hit_count": e.hit_count,
|
|
197
|
+
}
|
|
198
|
+
for e in entries_raw
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
# Filter by namespace if specified
|
|
202
|
+
if namespace:
|
|
203
|
+
entries = [e for e in entries if e.get("namespace") == namespace]
|
|
204
|
+
|
|
205
|
+
json_output = json.dumps(entries, indent=2, default=str)
|
|
206
|
+
|
|
207
|
+
if output:
|
|
208
|
+
with open(output, "w") as f:
|
|
209
|
+
f.write(json_output)
|
|
210
|
+
click.echo(f"Exported {len(entries)} entries to {output}")
|
|
211
|
+
else:
|
|
212
|
+
click.echo(json_output)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
main()
|
longcache/config.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration for LongCache.
|
|
3
|
+
|
|
4
|
+
Provides CacheConfig dataclass with sensible defaults,
|
|
5
|
+
validation, and factory methods for construction from dicts or env vars.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field, asdict
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class CacheConfig:
|
|
17
|
+
"""
|
|
18
|
+
Configuration for LongCache with sensible defaults.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
similarity_threshold: Cosine similarity threshold for cache hits (0.0-1.0).
|
|
22
|
+
Higher = stricter matching. 0.92 is a good default for most RAG use cases.
|
|
23
|
+
ttl_seconds: Time-to-live for cache entries in seconds. 0 = no expiry.
|
|
24
|
+
max_entries: Maximum number of entries per namespace. Oldest entries
|
|
25
|
+
are evicted first when the limit is reached.
|
|
26
|
+
namespace: Default namespace for cache entries. Namespaces isolate
|
|
27
|
+
different applications or use cases sharing the same backend.
|
|
28
|
+
embedder_model: Sentence-transformers model name for local embedding.
|
|
29
|
+
Must be compatible with sentence-transformers library.
|
|
30
|
+
backend: Storage backend type. One of: "sqlite", "redis", "memory".
|
|
31
|
+
db_path: Path to the SQLite database file (only used when backend="sqlite").
|
|
32
|
+
redis_url: Redis connection URL (only used when backend="redis").
|
|
33
|
+
track_stats: Whether to track hit/miss statistics internally.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
similarity_threshold: float = 0.92
|
|
37
|
+
ttl_seconds: int = 3600
|
|
38
|
+
max_entries: int = 10_000
|
|
39
|
+
namespace: str = "default"
|
|
40
|
+
embedder_model: str = "BAAI/bge-small-en-v1.5"
|
|
41
|
+
backend: str = "sqlite"
|
|
42
|
+
db_path: str = ".longcache/cache.db"
|
|
43
|
+
redis_url: str = "redis://localhost:6379/0"
|
|
44
|
+
track_stats: bool = True
|
|
45
|
+
faiss_index_type: str = "flat"
|
|
46
|
+
hnsw_m: int = 32
|
|
47
|
+
hnsw_ef_search: int = 64
|
|
48
|
+
model: str = ""
|
|
49
|
+
system_prompt: str = ""
|
|
50
|
+
temperature: float = -1.0
|
|
51
|
+
|
|
52
|
+
def __post_init__(self):
|
|
53
|
+
"""Validate configuration values after initialization."""
|
|
54
|
+
if not 0.0 <= self.similarity_threshold <= 1.0:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"similarity_threshold must be between 0.0 and 1.0, got {self.similarity_threshold}"
|
|
57
|
+
)
|
|
58
|
+
if self.ttl_seconds < 0:
|
|
59
|
+
raise ValueError(f"ttl_seconds must be >= 0, got {self.ttl_seconds}")
|
|
60
|
+
if self.max_entries < 1:
|
|
61
|
+
raise ValueError(f"max_entries must be >= 1, got {self.max_entries}")
|
|
62
|
+
if self.backend not in ("sqlite", "redis", "async_redis", "memory"):
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"backend must be one of 'sqlite', 'redis', 'async_redis', 'memory', "
|
|
65
|
+
f"got '{self.backend}'"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_dict(cls, data: dict) -> CacheConfig:
|
|
70
|
+
"""
|
|
71
|
+
Create a CacheConfig from a dictionary.
|
|
72
|
+
|
|
73
|
+
Only keys that match CacheConfig fields are used; others are ignored.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
data: Dictionary with configuration keys.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
CacheConfig instance with values from the dict.
|
|
80
|
+
"""
|
|
81
|
+
valid_keys = {f.name for f in cls.__dataclass_fields__.values()}
|
|
82
|
+
filtered = {k: v for k, v in data.items() if k in valid_keys}
|
|
83
|
+
return cls(**filtered)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_env(cls, prefix: str = "LONGCACHE_") -> CacheConfig:
|
|
87
|
+
"""
|
|
88
|
+
Create a CacheConfig from environment variables.
|
|
89
|
+
|
|
90
|
+
Environment variables are mapped by uppercasing the field name and
|
|
91
|
+
prepending the prefix. For example, similarity_threshold becomes
|
|
92
|
+
LONGCACHE_SIMILARITY_THRESHOLD.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
prefix: Environment variable prefix. Defaults to "LONGCACHE_".
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
CacheConfig instance with values from environment variables.
|
|
99
|
+
"""
|
|
100
|
+
field_types = {f.name: f.type for f in cls.__dataclass_fields__.values()}
|
|
101
|
+
env_values = {}
|
|
102
|
+
|
|
103
|
+
for field_name in cls.__dataclass_fields__:
|
|
104
|
+
env_key = f"{prefix}{field_name.upper()}"
|
|
105
|
+
env_val = os.environ.get(env_key)
|
|
106
|
+
if env_val is not None:
|
|
107
|
+
# Cast to appropriate type
|
|
108
|
+
field_type = field_types[field_name]
|
|
109
|
+
if field_type == "float":
|
|
110
|
+
env_values[field_name] = float(env_val)
|
|
111
|
+
elif field_type == "int":
|
|
112
|
+
env_values[field_name] = int(env_val)
|
|
113
|
+
elif field_type == "bool":
|
|
114
|
+
env_values[field_name] = env_val.lower() in ("true", "1", "yes")
|
|
115
|
+
else:
|
|
116
|
+
env_values[field_name] = env_val
|
|
117
|
+
|
|
118
|
+
return cls(**env_values)
|
|
119
|
+
|
|
120
|
+
def to_dict(self) -> dict:
|
|
121
|
+
"""
|
|
122
|
+
Convert the configuration to a dictionary.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Dictionary representation of the configuration.
|
|
126
|
+
"""
|
|
127
|
+
return asdict(self)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Core LongCache engine components."""
|
|
2
|
+
|
|
3
|
+
from longcache.core.cache import LongCache
|
|
4
|
+
from longcache.core.entry import CacheEntry
|
|
5
|
+
from longcache.core.embedder import LocalEmbedder
|
|
6
|
+
from longcache.core.index import SimilarityIndex
|
|
7
|
+
|
|
8
|
+
__all__ = ["LongCache", "CacheEntry", "LocalEmbedder", "SimilarityIndex"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Abstract base class for all embedder implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseEmbedder(ABC):
|
|
9
|
+
"""
|
|
10
|
+
Abstract interface for embedding models.
|
|
11
|
+
|
|
12
|
+
All embedders must produce L2-normalized float32 vectors
|
|
13
|
+
of consistent dimension. The dimension is used to initialize
|
|
14
|
+
the FAISS index and must not change between calls.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def dimension(self) -> int:
|
|
20
|
+
"""Return the dimensionality of the embedding vectors."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def embed(self, text: str) -> np.ndarray:
|
|
25
|
+
"""
|
|
26
|
+
Embed a single text string.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
L2-normalized float32 numpy array of shape (dimension,).
|
|
30
|
+
"""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
def embed_batch(self, texts: list[str]) -> np.ndarray:
|
|
34
|
+
"""
|
|
35
|
+
Embed multiple texts. Default: loop over embed().
|
|
36
|
+
Override for batched efficiency.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
float32 array of shape (len(texts), dimension).
|
|
40
|
+
"""
|
|
41
|
+
import numpy as np
|
|
42
|
+
|
|
43
|
+
return np.stack([self.embed(t) for t in texts]).astype(np.float32)
|