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.
- ico_cache-1.0.0/LICENSE +1 -0
- ico_cache-1.0.0/PKG-INFO +92 -0
- ico_cache-1.0.0/README.md +46 -0
- ico_cache-1.0.0/pyproject.toml +63 -0
- ico_cache-1.0.0/setup.cfg +4 -0
- ico_cache-1.0.0/src/ico_cache/__init__.py +7 -0
- ico_cache-1.0.0/src/ico_cache/async_ingest.py +207 -0
- ico_cache-1.0.0/src/ico_cache/backends/base.py +47 -0
- ico_cache-1.0.0/src/ico_cache/backends/embedding/fastembed_embedder.py +10 -0
- ico_cache-1.0.0/src/ico_cache/backends/exact/redis_store.py +42 -0
- ico_cache-1.0.0/src/ico_cache/backends/exact/sqlite_store.py +43 -0
- ico_cache-1.0.0/src/ico_cache/backends/vector/lancedb_store.py +160 -0
- ico_cache-1.0.0/src/ico_cache/backends/vector/qdrant_store.py +90 -0
- ico_cache-1.0.0/src/ico_cache/client.py +20 -0
- ico_cache-1.0.0/src/ico_cache/core/cache_engine.py +686 -0
- ico_cache-1.0.0/src/ico_cache/core/config.py +5 -0
- ico_cache-1.0.0/src/ico_cache/core/metadata_guard.py +74 -0
- ico_cache-1.0.0/src/ico_cache/gc.py +0 -0
- ico_cache-1.0.0/src/ico_cache/invalidation.py +227 -0
- ico_cache-1.0.0/src/ico_cache/loaders/__init__.py +45 -0
- ico_cache-1.0.0/src/ico_cache/loaders/_common.py +60 -0
- ico_cache-1.0.0/src/ico_cache/loaders/auto_loader.py +185 -0
- ico_cache-1.0.0/src/ico_cache/loaders/base.py +29 -0
- ico_cache-1.0.0/src/ico_cache/loaders/code_loader.py +321 -0
- ico_cache-1.0.0/src/ico_cache/loaders/html_loader.py +77 -0
- ico_cache-1.0.0/src/ico_cache/loaders/image_loader.py +57 -0
- ico_cache-1.0.0/src/ico_cache/loaders/ocr.py +190 -0
- ico_cache-1.0.0/src/ico_cache/loaders/odf_loader.py +163 -0
- ico_cache-1.0.0/src/ico_cache/loaders/office_loader.py +135 -0
- ico_cache-1.0.0/src/ico_cache/loaders/pdf_loader.py +110 -0
- ico_cache-1.0.0/src/ico_cache/loaders/structured_loader.py +213 -0
- ico_cache-1.0.0/src/ico_cache/loaders/txt_loader.py +117 -0
- ico_cache-1.0.0/src/ico_cache/py.typed +1 -0
- ico_cache-1.0.0/src/ico_cache/rag/pipeline.py +184 -0
- ico_cache-1.0.0/src/ico_cache/rag/reranker.py +0 -0
- ico_cache-1.0.0/src/ico_cache/telemetry/langfuse.py +79 -0
- ico_cache-1.0.0/src/ico_cache/telemetry/logging.py +56 -0
- ico_cache-1.0.0/src/ico_cache/telemetry/metrics.py +112 -0
- ico_cache-1.0.0/src/ico_cache/telemetry/tracing.py +143 -0
- ico_cache-1.0.0/src/ico_cache.egg-info/PKG-INFO +92 -0
- ico_cache-1.0.0/src/ico_cache.egg-info/SOURCES.txt +54 -0
- ico_cache-1.0.0/src/ico_cache.egg-info/dependency_links.txt +1 -0
- ico_cache-1.0.0/src/ico_cache.egg-info/requires.txt +39 -0
- ico_cache-1.0.0/src/ico_cache.egg-info/top_level.txt +1 -0
- ico_cache-1.0.0/tests/test_api_auth.py +117 -0
- ico_cache-1.0.0/tests/test_cache_engine.py +127 -0
- ico_cache-1.0.0/tests/test_generic_schema.py +30 -0
- ico_cache-1.0.0/tests/test_invalidation.py +278 -0
- ico_cache-1.0.0/tests/test_llm_providers.py +95 -0
- ico_cache-1.0.0/tests/test_multitenancy.py +167 -0
- ico_cache-1.0.0/tests/test_observability.py +125 -0
- ico_cache-1.0.0/tests/test_security_hardening.py +115 -0
- ico_cache-1.0.0/tests/test_telemetry.py +116 -0
- ico_cache-1.0.0/tests/test_universal_loaders.py +670 -0
- ico_cache-1.0.0/tests/test_verify_all.py +264 -0
- ico_cache-1.0.0/tests/test_version_sync.py +27 -0
ico_cache-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
MIT License
|
ico_cache-1.0.0/PKG-INFO
ADDED
|
@@ -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,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
|
+
|