ico-cache 1.0.0__tar.gz

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.
Files changed (56) hide show
  1. ico_cache-1.0.0/LICENSE +1 -0
  2. ico_cache-1.0.0/PKG-INFO +92 -0
  3. ico_cache-1.0.0/README.md +46 -0
  4. ico_cache-1.0.0/pyproject.toml +63 -0
  5. ico_cache-1.0.0/setup.cfg +4 -0
  6. ico_cache-1.0.0/src/ico_cache/__init__.py +7 -0
  7. ico_cache-1.0.0/src/ico_cache/async_ingest.py +207 -0
  8. ico_cache-1.0.0/src/ico_cache/backends/base.py +47 -0
  9. ico_cache-1.0.0/src/ico_cache/backends/embedding/fastembed_embedder.py +10 -0
  10. ico_cache-1.0.0/src/ico_cache/backends/exact/redis_store.py +42 -0
  11. ico_cache-1.0.0/src/ico_cache/backends/exact/sqlite_store.py +43 -0
  12. ico_cache-1.0.0/src/ico_cache/backends/vector/lancedb_store.py +160 -0
  13. ico_cache-1.0.0/src/ico_cache/backends/vector/qdrant_store.py +90 -0
  14. ico_cache-1.0.0/src/ico_cache/client.py +20 -0
  15. ico_cache-1.0.0/src/ico_cache/core/cache_engine.py +686 -0
  16. ico_cache-1.0.0/src/ico_cache/core/config.py +5 -0
  17. ico_cache-1.0.0/src/ico_cache/core/metadata_guard.py +74 -0
  18. ico_cache-1.0.0/src/ico_cache/gc.py +0 -0
  19. ico_cache-1.0.0/src/ico_cache/invalidation.py +227 -0
  20. ico_cache-1.0.0/src/ico_cache/loaders/__init__.py +45 -0
  21. ico_cache-1.0.0/src/ico_cache/loaders/_common.py +60 -0
  22. ico_cache-1.0.0/src/ico_cache/loaders/auto_loader.py +185 -0
  23. ico_cache-1.0.0/src/ico_cache/loaders/base.py +29 -0
  24. ico_cache-1.0.0/src/ico_cache/loaders/code_loader.py +321 -0
  25. ico_cache-1.0.0/src/ico_cache/loaders/html_loader.py +77 -0
  26. ico_cache-1.0.0/src/ico_cache/loaders/image_loader.py +57 -0
  27. ico_cache-1.0.0/src/ico_cache/loaders/ocr.py +190 -0
  28. ico_cache-1.0.0/src/ico_cache/loaders/odf_loader.py +163 -0
  29. ico_cache-1.0.0/src/ico_cache/loaders/office_loader.py +135 -0
  30. ico_cache-1.0.0/src/ico_cache/loaders/pdf_loader.py +110 -0
  31. ico_cache-1.0.0/src/ico_cache/loaders/structured_loader.py +213 -0
  32. ico_cache-1.0.0/src/ico_cache/loaders/txt_loader.py +117 -0
  33. ico_cache-1.0.0/src/ico_cache/py.typed +1 -0
  34. ico_cache-1.0.0/src/ico_cache/rag/pipeline.py +184 -0
  35. ico_cache-1.0.0/src/ico_cache/rag/reranker.py +0 -0
  36. ico_cache-1.0.0/src/ico_cache/telemetry/langfuse.py +79 -0
  37. ico_cache-1.0.0/src/ico_cache/telemetry/logging.py +56 -0
  38. ico_cache-1.0.0/src/ico_cache/telemetry/metrics.py +112 -0
  39. ico_cache-1.0.0/src/ico_cache/telemetry/tracing.py +143 -0
  40. ico_cache-1.0.0/src/ico_cache.egg-info/PKG-INFO +92 -0
  41. ico_cache-1.0.0/src/ico_cache.egg-info/SOURCES.txt +54 -0
  42. ico_cache-1.0.0/src/ico_cache.egg-info/dependency_links.txt +1 -0
  43. ico_cache-1.0.0/src/ico_cache.egg-info/requires.txt +39 -0
  44. ico_cache-1.0.0/src/ico_cache.egg-info/top_level.txt +1 -0
  45. ico_cache-1.0.0/tests/test_api_auth.py +117 -0
  46. ico_cache-1.0.0/tests/test_cache_engine.py +127 -0
  47. ico_cache-1.0.0/tests/test_generic_schema.py +30 -0
  48. ico_cache-1.0.0/tests/test_invalidation.py +278 -0
  49. ico_cache-1.0.0/tests/test_llm_providers.py +95 -0
  50. ico_cache-1.0.0/tests/test_multitenancy.py +167 -0
  51. ico_cache-1.0.0/tests/test_observability.py +125 -0
  52. ico_cache-1.0.0/tests/test_security_hardening.py +115 -0
  53. ico_cache-1.0.0/tests/test_telemetry.py +116 -0
  54. ico_cache-1.0.0/tests/test_universal_loaders.py +670 -0
  55. ico_cache-1.0.0/tests/test_verify_all.py +264 -0
  56. ico_cache-1.0.0/tests/test_version_sync.py +27 -0
@@ -0,0 +1 @@
1
+ MIT License
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: ico-cache
3
+ Version: 1.0.0
4
+ Summary: ICO-Cache: Generalized LLM Semantic Cache
5
+ Author: Dev
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: fastapi<1.0.0,>=0.110.0
10
+ Requires-Dist: uvicorn<1.0.0,>=0.28.0
11
+ Requires-Dist: qdrant-client<2.0.0,>=1.8.0
12
+ Requires-Dist: redis<6.0.0,>=5.0.0
13
+ Requires-Dist: fastembed<1.0.0,>=0.2.0
14
+ Requires-Dist: pydantic<3.0.0,>=2.6.0
15
+ Requires-Dist: pydantic-settings<3.0.0,>=2.2.0
16
+ Requires-Dist: PyPDF2<4.0.0,>=3.0.0
17
+ Requires-Dist: beautifulsoup4<5.0.0,>=4.12.0
18
+ Requires-Dist: lancedb<1.0.0,>=0.5.0
19
+ Requires-Dist: structlog<26.0.0,>=24.1.0
20
+ Requires-Dist: litellm>=1.40.0
21
+ Requires-Dist: prometheus-client<1.0.0,>=0.20.0
22
+ Requires-Dist: opentelemetry-api>=1.20.0
23
+ Requires-Dist: opentelemetry-sdk>=1.20.0
24
+ Provides-Extra: loaders
25
+ Requires-Dist: pymupdf>=1.24.0; extra == "loaders"
26
+ Requires-Dist: tree-sitter>=0.23.0; extra == "loaders"
27
+ Requires-Dist: tree-sitter-javascript>=0.23.0; extra == "loaders"
28
+ Requires-Dist: tree-sitter-go>=0.23.0; extra == "loaders"
29
+ Requires-Dist: pdf2image>=1.16.0; extra == "loaders"
30
+ Requires-Dist: pytesseract>=0.3.10; extra == "loaders"
31
+ Requires-Dist: Pillow>=10.0.0; extra == "loaders"
32
+ Requires-Dist: odfpy>=1.4.1; extra == "loaders"
33
+ Requires-Dist: python-docx>=1.1.0; extra == "loaders"
34
+ Requires-Dist: openpyxl>=3.1.0; extra == "loaders"
35
+ Requires-Dist: python-pptx>=0.6.23; extra == "loaders"
36
+ Provides-Extra: observability
37
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.20.0; extra == "observability"
38
+ Requires-Dist: langfuse>=2.0.0; extra == "observability"
39
+ Provides-Extra: dev
40
+ Requires-Dist: ico-cache[loaders,observability]; extra == "dev"
41
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
42
+ Requires-Dist: pytest-asyncio>=1.0.0; extra == "dev"
43
+ Requires-Dist: ruff>=0.4.0; extra == "dev"
44
+ Requires-Dist: mypy>=1.8.0; extra == "dev"
45
+ Dynamic: license-file
46
+
47
+ # ICO-Cache: Generalized LLM Semantic Cache
48
+
49
+ ICO-Cache is a high-performance, multi-layered semantic caching engine for Large Language Models. It minimizes redundant LLM calls by precisely matching queries contextually and semantically, while strictly guarding against false hits through centralized entity, topic, and quarter metadata filtering.
50
+
51
+ ## Quickstart (Zero-Infra Embedded Mode)
52
+
53
+ ```python
54
+ from ico_cache import ICOCache, ICOConfig
55
+ from ico_cache.backends.vector.lancedb_store import LanceDBStore
56
+ from ico_cache.backends.exact.sqlite_store import SQLiteStore
57
+ from ico_cache.backends.embedding.fastembed_embedder import FastEmbedder
58
+
59
+ # Initialize with embedded databases
60
+ engine = ICOCache(
61
+ embedder=FastEmbedder(),
62
+ vector_store=LanceDBStore(uri="./lancedb"),
63
+ exact_store=SQLiteStore(db_path="cache.db")
64
+ )
65
+
66
+ # Resolve query
67
+ result = await engine.resolve("What is Apple's revenue?", meta={"entity": "AAPL", "topic": "revenue"})
68
+ if result["source"] == "MISS":
69
+ # Generate and ingest...
70
+ ```
71
+
72
+ ## Performance & Reliability (Actual Measured Benchmarks)
73
+
74
+ Results from the `verify_all_5` suite run:
75
+
76
+ | Metric | Result | Detail |
77
+ |--------|--------|--------|
78
+ | **False-Hit Rate** | **0%** (0 / 100) | Tested on challenging near-miss negatives (entity/quarter/topic swaps). |
79
+ | **True-Hit Rate (L3)** | **100%** (10 / 10) | Correctly fires on same-context paraphrased queries. |
80
+ | **Hit Latency** | **~46ms** | 100 isolated pairs evaluated in 4.6s. |
81
+ | **Concurrency Guard** | **0 dupes** | Tested at 20, 50, and 100 concurrent identical queries (0 lock errors, max 1 generation). |
82
+
83
+ ## Architecture: Embedded vs Server Mode
84
+
85
+ | Feature | Embedded Mode | Server Mode |
86
+ |---------|---------------|-------------|
87
+ | **Vector Store** | LanceDB (Local) | Qdrant (Docker) |
88
+ | **Exact Store** | SQLite (Local) | Redis (Docker) |
89
+ | **Setup Complexity** | Zero (just `pip install`) | Requires Docker/Compose |
90
+ | **Best For** | Prototyping, small scripts, CLI apps | Production, multi-tenant APIs, heavy concurrency |
91
+
92
+ For Server Mode, see `docker-compose.yml` and `api/server.py`.
@@ -0,0 +1,46 @@
1
+ # ICO-Cache: Generalized LLM Semantic Cache
2
+
3
+ ICO-Cache is a high-performance, multi-layered semantic caching engine for Large Language Models. It minimizes redundant LLM calls by precisely matching queries contextually and semantically, while strictly guarding against false hits through centralized entity, topic, and quarter metadata filtering.
4
+
5
+ ## Quickstart (Zero-Infra Embedded Mode)
6
+
7
+ ```python
8
+ from ico_cache import ICOCache, ICOConfig
9
+ from ico_cache.backends.vector.lancedb_store import LanceDBStore
10
+ from ico_cache.backends.exact.sqlite_store import SQLiteStore
11
+ from ico_cache.backends.embedding.fastembed_embedder import FastEmbedder
12
+
13
+ # Initialize with embedded databases
14
+ engine = ICOCache(
15
+ embedder=FastEmbedder(),
16
+ vector_store=LanceDBStore(uri="./lancedb"),
17
+ exact_store=SQLiteStore(db_path="cache.db")
18
+ )
19
+
20
+ # Resolve query
21
+ result = await engine.resolve("What is Apple's revenue?", meta={"entity": "AAPL", "topic": "revenue"})
22
+ if result["source"] == "MISS":
23
+ # Generate and ingest...
24
+ ```
25
+
26
+ ## Performance & Reliability (Actual Measured Benchmarks)
27
+
28
+ Results from the `verify_all_5` suite run:
29
+
30
+ | Metric | Result | Detail |
31
+ |--------|--------|--------|
32
+ | **False-Hit Rate** | **0%** (0 / 100) | Tested on challenging near-miss negatives (entity/quarter/topic swaps). |
33
+ | **True-Hit Rate (L3)** | **100%** (10 / 10) | Correctly fires on same-context paraphrased queries. |
34
+ | **Hit Latency** | **~46ms** | 100 isolated pairs evaluated in 4.6s. |
35
+ | **Concurrency Guard** | **0 dupes** | Tested at 20, 50, and 100 concurrent identical queries (0 lock errors, max 1 generation). |
36
+
37
+ ## Architecture: Embedded vs Server Mode
38
+
39
+ | Feature | Embedded Mode | Server Mode |
40
+ |---------|---------------|-------------|
41
+ | **Vector Store** | LanceDB (Local) | Qdrant (Docker) |
42
+ | **Exact Store** | SQLite (Local) | Redis (Docker) |
43
+ | **Setup Complexity** | Zero (just `pip install`) | Requires Docker/Compose |
44
+ | **Best For** | Prototyping, small scripts, CLI apps | Production, multi-tenant APIs, heavy concurrency |
45
+
46
+ For Server Mode, see `docker-compose.yml` and `api/server.py`.
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ico-cache"
7
+ version = "1.0.0"
8
+ description = "ICO-Cache: Generalized LLM Semantic Cache"
9
+ authors = [{name = "Dev"}]
10
+ readme = "README.md"
11
+ requires-python = ">=3.11"
12
+ dependencies = [
13
+ "fastapi>=0.110.0,<1.0.0",
14
+ "uvicorn>=0.28.0,<1.0.0",
15
+ "qdrant-client>=1.8.0,<2.0.0",
16
+ "redis>=5.0.0,<6.0.0",
17
+ "fastembed>=0.2.0,<1.0.0",
18
+ "pydantic>=2.6.0,<3.0.0",
19
+ "pydantic-settings>=2.2.0,<3.0.0",
20
+ "PyPDF2>=3.0.0,<4.0.0",
21
+ "beautifulsoup4>=4.12.0,<5.0.0",
22
+ "lancedb>=0.5.0,<1.0.0",
23
+ "structlog>=24.1.0,<26.0.0",
24
+ "litellm>=1.40.0",
25
+ "prometheus-client>=0.20.0,<1.0.0",
26
+ "opentelemetry-api>=1.20.0",
27
+ "opentelemetry-sdk>=1.20.0",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ # Document format support (used by loaders and their tests)
32
+ loaders = [
33
+ "pymupdf>=1.24.0",
34
+ "tree-sitter>=0.23.0",
35
+ "tree-sitter-javascript>=0.23.0",
36
+ "tree-sitter-go>=0.23.0",
37
+ "pdf2image>=1.16.0",
38
+ "pytesseract>=0.3.10",
39
+ "Pillow>=10.0.0",
40
+ "odfpy>=1.4.1",
41
+ "python-docx>=1.1.0",
42
+ "openpyxl>=3.1.0",
43
+ "python-pptx>=0.6.23",
44
+ ]
45
+ # Tracing/metrics exporters
46
+ observability = [
47
+ "opentelemetry-exporter-otlp-proto-http>=1.20.0",
48
+ "langfuse>=2.0.0",
49
+ ]
50
+ # Full dev/test environment
51
+ dev = [
52
+ "ico-cache[loaders,observability]",
53
+ "pytest>=8.0.0",
54
+ "pytest-asyncio>=1.0.0",
55
+ "ruff>=0.4.0",
56
+ "mypy>=1.8.0",
57
+ ]
58
+
59
+ [tool.setuptools.packages.find]
60
+ where = ["src"]
61
+
62
+ [tool.setuptools.package-data]
63
+ ico_cache = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ from .core.cache_engine import CacheEngine, CacheEngine as ICOCache
2
+ from .core.config import ICOConfig
3
+ from .loaders.auto_loader import ingest, AutoLoader
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["CacheEngine", "ICOCache", "ICOConfig", "ingest", "AutoLoader", "__version__"]
@@ -0,0 +1,207 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import threading
5
+ import time
6
+ import uuid
7
+ from typing import Dict, Optional
8
+ from pydantic import BaseModel, Field
9
+
10
+ from .loaders.auto_loader import AutoLoader
11
+ from .core.cache_engine import CacheEngine
12
+ from .core.metadata_guard import MetadataSchema
13
+
14
+ logger = logging.getLogger("ico_cache.async_ingest")
15
+
16
+
17
+ class IngestionJob(BaseModel):
18
+ job_id: str
19
+ tenant_id: str
20
+ file_path: str
21
+ file_size_bytes: int
22
+ status: str = "queued" # queued, processing, completed, failed
23
+ is_async: bool = False
24
+ chunks_total: int = 0
25
+ chunks_processed: int = 0
26
+ created_at: float = Field(default_factory=time.time)
27
+ completed_at: Optional[float] = None
28
+ elapsed_s: float = 0.0
29
+ error: Optional[str] = None
30
+
31
+
32
+ class IngestionJobManager:
33
+ """
34
+ Manages synchronous and asynchronous document ingestion.
35
+ Files exceeding async_threshold_bytes (default 1MB) are ingested
36
+ asynchronously in background tasks with queryable job status.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ async_threshold_bytes: int = 1 * 1024 * 1024,
42
+ job_ttl_seconds: int = 3600,
43
+ max_jobs: int = 10000,
44
+ ):
45
+ self.async_threshold_bytes = async_threshold_bytes
46
+ self.job_ttl_seconds = job_ttl_seconds
47
+ self.max_jobs = max_jobs
48
+ self.jobs: Dict[str, IngestionJob] = {}
49
+ self._lock = threading.Lock()
50
+
51
+ def _cleanup_expired_unlocked(self, now: Optional[float] = None) -> int:
52
+ """Evicts completed/failed jobs older than job_ttl_seconds, and enforces max_jobs cap."""
53
+ ts = now if now is not None else time.time()
54
+ to_delete = []
55
+
56
+ # 1. Evict by TTL
57
+ for jid, job in self.jobs.items():
58
+ if job.status in ["completed", "failed"] and job.completed_at:
59
+ if (ts - job.completed_at) > self.job_ttl_seconds:
60
+ to_delete.append(jid)
61
+
62
+ for jid in to_delete:
63
+ del self.jobs[jid]
64
+
65
+ # 2. Evict oldest finished jobs if exceeding capacity cap
66
+ if len(self.jobs) > self.max_jobs:
67
+ finished = sorted(
68
+ [j for j in self.jobs.values() if j.status in ["completed", "failed"]],
69
+ key=lambda x: x.completed_at or 0.0,
70
+ )
71
+ excess = len(self.jobs) - self.max_jobs
72
+ for j in finished[:excess]:
73
+ if j.job_id in self.jobs:
74
+ del self.jobs[j.job_id]
75
+ to_delete.append(j.job_id)
76
+
77
+ return len(to_delete)
78
+
79
+ def cleanup_expired_jobs(self, now: Optional[float] = None) -> int:
80
+ """Public thread-safe cleanup method."""
81
+ with self._lock:
82
+ return self._cleanup_expired_unlocked(now=now)
83
+
84
+ def get_job(self, job_id: str) -> Optional[IngestionJob]:
85
+ with self._lock:
86
+ self._cleanup_expired_unlocked()
87
+ job = self.jobs.get(job_id)
88
+ if job and job.status == "processing":
89
+ job.elapsed_s = time.time() - job.created_at
90
+ return job
91
+
92
+ def list_jobs(self, tenant_id: Optional[str] = None) -> Dict[str, IngestionJob]:
93
+ with self._lock:
94
+ self._cleanup_expired_unlocked()
95
+ if tenant_id:
96
+ return {k: v for k, v in self.jobs.items() if v.tenant_id == tenant_id}
97
+ return dict(self.jobs)
98
+
99
+ def submit_ingest(
100
+ self,
101
+ file_path: str,
102
+ tenant_id: str,
103
+ cache_engine: CacheEngine,
104
+ schema: Optional[MetadataSchema] = None,
105
+ force_async: Optional[bool] = None,
106
+ ) -> IngestionJob:
107
+ if not os.path.exists(file_path):
108
+ raise FileNotFoundError(f"File not found: {file_path}")
109
+
110
+ file_size = os.path.getsize(file_path)
111
+ job_id = str(uuid.uuid4())
112
+ should_async = force_async if force_async is not None else (file_size >= self.async_threshold_bytes)
113
+
114
+ job = IngestionJob(
115
+ job_id=job_id,
116
+ tenant_id=tenant_id,
117
+ file_path=file_path,
118
+ file_size_bytes=file_size,
119
+ status="processing",
120
+ is_async=should_async,
121
+ created_at=time.time(),
122
+ )
123
+
124
+ with self._lock:
125
+ self.jobs[job_id] = job
126
+
127
+ if should_async:
128
+ # Dispatch background worker thread
129
+ worker = threading.Thread(
130
+ target=self._run_ingest,
131
+ args=(job_id, file_path, tenant_id, cache_engine, schema),
132
+ daemon=True,
133
+ )
134
+ worker.start()
135
+ return job
136
+ else:
137
+ # Run synchronously in an isolated thread to protect caller's event loop
138
+ worker = threading.Thread(
139
+ target=self._run_ingest,
140
+ args=(job_id, file_path, tenant_id, cache_engine, schema),
141
+ daemon=True,
142
+ )
143
+ worker.start()
144
+ worker.join()
145
+ with self._lock:
146
+ return self.jobs[job_id]
147
+
148
+ def _run_ingest(
149
+ self,
150
+ job_id: str,
151
+ file_path: str,
152
+ tenant_id: str,
153
+ cache_engine: CacheEngine,
154
+ schema: Optional[MetadataSchema] = None,
155
+ ):
156
+ start_t = time.time()
157
+ try:
158
+ loader = AutoLoader(schema=schema)
159
+ chunks = loader.load(file_path)
160
+
161
+ with self._lock:
162
+ job = self.jobs[job_id]
163
+ job.chunks_total = len(chunks)
164
+
165
+ loop = asyncio.new_event_loop()
166
+ asyncio.set_event_loop(loop)
167
+
168
+ try:
169
+ for idx, chunk in enumerate(chunks):
170
+ loader_kind = getattr(chunk, "loader_type", None) or (chunk.metadata.get("loader") if isinstance(chunk.metadata, dict) else "text")
171
+ dummy_resp = {
172
+ "content": chunk.text[:200],
173
+ "source": chunk.source_file,
174
+ "section": chunk.page_or_section,
175
+ "loader_type": loader_kind,
176
+ "extraction_method": chunk.metadata.get("extraction_method", "direct") if isinstance(chunk.metadata, dict) else "direct",
177
+ "metadata": chunk.metadata if isinstance(chunk.metadata, dict) else {},
178
+ }
179
+ cache_engine.set_l1(chunk.text[:100], dummy_resp, meta=chunk.metadata, tenant_id=tenant_id)
180
+ loop.run_until_complete(
181
+ cache_engine.async_write_l2(
182
+ chunk.text[:100], dummy_resp, meta=chunk.metadata, tenant_id=tenant_id
183
+ )
184
+ )
185
+ with self._lock:
186
+ job.chunks_processed = idx + 1
187
+ finally:
188
+ loop.close()
189
+
190
+ with self._lock:
191
+ job = self.jobs[job_id]
192
+ job.status = "completed"
193
+ job.completed_at = time.time()
194
+ job.elapsed_s = job.completed_at - start_t
195
+ logger.info(f"Ingestion job {job_id} completed: {job.chunks_processed} chunks in {job.elapsed_s:.2f}s")
196
+
197
+ except Exception as e:
198
+ with self._lock:
199
+ job = self.jobs[job_id]
200
+ job.status = "failed"
201
+ job.completed_at = time.time()
202
+ job.elapsed_s = job.completed_at - start_t
203
+ job.error = str(e)
204
+ logger.error(f"Ingestion job {job_id} failed: {e}", exc_info=True)
205
+
206
+
207
+ job_manager = IngestionJobManager()
@@ -0,0 +1,47 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import List, Any, Optional
3
+
4
+ class BaseEmbedder(ABC):
5
+ @abstractmethod
6
+ def embed(self, text: str) -> List[float]:
7
+ pass
8
+
9
+ class BaseVectorStore(ABC):
10
+ @abstractmethod
11
+ async def insert(self, collection: str, id: int, vector: Any, payload: dict):
12
+ pass
13
+
14
+ @abstractmethod
15
+ async def search(self, collection: str, vector: Any, query_filter: Any, limit: int, score_threshold: float, using: Optional[str] = None, **kwargs: Any) -> List[Any]:
16
+ pass
17
+
18
+ @abstractmethod
19
+ async def delete(self, collection: str, id: int):
20
+ pass
21
+
22
+ @abstractmethod
23
+ def collection_exists(self, collection: str) -> bool:
24
+ pass
25
+
26
+ @abstractmethod
27
+ def create_collection(self, collection: str, config: Any):
28
+ pass
29
+
30
+ def delete_collection(self, collection: str):
31
+ pass
32
+
33
+ async def delete_matching(self, collection: str, filter_dict: Optional[dict] = None) -> int:
34
+ return 0
35
+
36
+ class BaseExactStore(ABC):
37
+ @abstractmethod
38
+ def get(self, key: str) -> Optional[bytes]:
39
+ pass
40
+
41
+ @abstractmethod
42
+ def set(self, key: str, value: bytes, ex: Optional[int] = None, nx: bool = False):
43
+ pass
44
+
45
+ def delete_prefix(self, prefix: str) -> int:
46
+ return 0
47
+
@@ -0,0 +1,10 @@
1
+ from fastembed import TextEmbedding
2
+ from typing import List
3
+ from ..base import BaseEmbedder
4
+
5
+ class FastEmbedder(BaseEmbedder):
6
+ def __init__(self, model_name: str = "BAAI/bge-small-en-v1.5"):
7
+ self.model = TextEmbedding(model_name=model_name)
8
+
9
+ def embed(self, text: str) -> List[float]:
10
+ return list(self.model.embed([text]))[0].tolist()
@@ -0,0 +1,42 @@
1
+ import redis
2
+ from typing import Optional
3
+ from ..base import BaseExactStore
4
+
5
+ class RedisStore(BaseExactStore):
6
+ def __init__(self, host: str = "localhost", port: int = 6379, password: str = None):
7
+ self.r = redis.Redis(host=host, port=port, password=password)
8
+
9
+ @property
10
+ def client(self):
11
+ return self.r
12
+
13
+ def get(self, key: str) -> Optional[bytes]:
14
+ return self.r.get(key)
15
+
16
+ def set(self, key: str, value: bytes, ex: Optional[int] = None, nx: bool = False) -> bool:
17
+ # nx=True only writes when the key is absent (conditional write).
18
+ result = self.r.set(key, value, ex=ex, nx=nx)
19
+ return result is not None
20
+
21
+ def delete(self, key: str) -> bool:
22
+ return bool(self.r.delete(key))
23
+
24
+ def delete_prefix(self, prefix: str) -> int:
25
+ cursor = 0
26
+ deleted = 0
27
+ match_pattern = f"{prefix}*"
28
+ while True:
29
+ cursor, keys = self.r.scan(cursor=cursor, match=match_pattern, count=100) # type: ignore[misc]
30
+ if keys:
31
+ deleted += self.r.delete(*keys) # type: ignore[operator]
32
+ if cursor == 0:
33
+ break
34
+ return deleted
35
+
36
+ def xadd(self, stream: str, fields: dict) -> str:
37
+ res = self.r.xadd(stream, fields)
38
+ return res.decode() if isinstance(res, bytes) else str(res)
39
+
40
+ def xread(self, streams: dict, count: Optional[int] = None, block: Optional[int] = None):
41
+ return self.r.xread(streams, count=count, block=block)
42
+
@@ -0,0 +1,43 @@
1
+ import sqlite3
2
+ from typing import Optional
3
+ from ..base import BaseExactStore
4
+
5
+ class SQLiteStore(BaseExactStore):
6
+ def __init__(self, db_path: str = "cache.db"):
7
+ self.db_path = db_path
8
+ self._init_db()
9
+
10
+ def _init_db(self):
11
+ with sqlite3.connect(self.db_path) as conn:
12
+ conn.execute('''CREATE TABLE IF NOT EXISTS cache
13
+ (key TEXT PRIMARY KEY, value BLOB)''')
14
+
15
+ def get(self, key: str) -> Optional[bytes]:
16
+ with sqlite3.connect(self.db_path) as conn:
17
+ cursor = conn.cursor()
18
+ cursor.execute("SELECT value FROM cache WHERE key=?", (key,))
19
+ row = cursor.fetchone()
20
+ if row:
21
+ return row[0]
22
+ return None
23
+
24
+ def set(self, key: str, value: bytes, ex: Optional[int] = None, nx: bool = False) -> bool:
25
+ # Note: SQLite store doesn't support TTL out of the box in this simple
26
+ # implementation. nx=True performs a conditional (insert-if-absent) write.
27
+ verb = "INSERT OR IGNORE" if nx else "INSERT OR REPLACE"
28
+ with sqlite3.connect(self.db_path) as conn:
29
+ cur = conn.execute(f"{verb} INTO cache (key, value) VALUES (?, ?)", (key, value))
30
+ if nx:
31
+ return cur.rowcount > 0
32
+ return True
33
+
34
+ def delete(self, key: str) -> bool:
35
+ with sqlite3.connect(self.db_path) as conn:
36
+ cur = conn.execute("DELETE FROM cache WHERE key = ?", (key,))
37
+ return cur.rowcount > 0
38
+
39
+ def delete_prefix(self, prefix: str) -> int:
40
+ with sqlite3.connect(self.db_path) as conn:
41
+ cur = conn.execute("DELETE FROM cache WHERE key LIKE ?", (f"{prefix}%",))
42
+ return cur.rowcount
43
+