ico-cache 1.0.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.
Files changed (39) hide show
  1. ico_cache/__init__.py +7 -0
  2. ico_cache/async_ingest.py +207 -0
  3. ico_cache/backends/base.py +47 -0
  4. ico_cache/backends/embedding/fastembed_embedder.py +10 -0
  5. ico_cache/backends/exact/redis_store.py +42 -0
  6. ico_cache/backends/exact/sqlite_store.py +43 -0
  7. ico_cache/backends/vector/lancedb_store.py +160 -0
  8. ico_cache/backends/vector/qdrant_store.py +90 -0
  9. ico_cache/client.py +20 -0
  10. ico_cache/core/cache_engine.py +686 -0
  11. ico_cache/core/config.py +5 -0
  12. ico_cache/core/metadata_guard.py +74 -0
  13. ico_cache/gc.py +0 -0
  14. ico_cache/invalidation.py +227 -0
  15. ico_cache/loaders/__init__.py +45 -0
  16. ico_cache/loaders/_common.py +60 -0
  17. ico_cache/loaders/auto_loader.py +185 -0
  18. ico_cache/loaders/base.py +29 -0
  19. ico_cache/loaders/code_loader.py +321 -0
  20. ico_cache/loaders/html_loader.py +77 -0
  21. ico_cache/loaders/image_loader.py +57 -0
  22. ico_cache/loaders/ocr.py +190 -0
  23. ico_cache/loaders/odf_loader.py +163 -0
  24. ico_cache/loaders/office_loader.py +135 -0
  25. ico_cache/loaders/pdf_loader.py +110 -0
  26. ico_cache/loaders/structured_loader.py +213 -0
  27. ico_cache/loaders/txt_loader.py +117 -0
  28. ico_cache/py.typed +1 -0
  29. ico_cache/rag/pipeline.py +184 -0
  30. ico_cache/rag/reranker.py +0 -0
  31. ico_cache/telemetry/langfuse.py +79 -0
  32. ico_cache/telemetry/logging.py +56 -0
  33. ico_cache/telemetry/metrics.py +112 -0
  34. ico_cache/telemetry/tracing.py +143 -0
  35. ico_cache-1.0.0.dist-info/METADATA +92 -0
  36. ico_cache-1.0.0.dist-info/RECORD +39 -0
  37. ico_cache-1.0.0.dist-info/WHEEL +5 -0
  38. ico_cache-1.0.0.dist-info/licenses/LICENSE +1 -0
  39. ico_cache-1.0.0.dist-info/top_level.txt +1 -0
ico_cache/__init__.py ADDED
@@ -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
+
@@ -0,0 +1,160 @@
1
+ import json
2
+ import re
3
+ import lancedb
4
+ from typing import Any, List, Optional
5
+ from ..base import BaseVectorStore
6
+
7
+ # Metadata keys/filter field names are identifiers, never expressions.
8
+ _IDENT_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
9
+
10
+
11
+ def _sql_str(value: Any) -> str:
12
+ """Render a value as a single-quoted SQL string literal, escaping quotes."""
13
+ return "'" + str(value).replace("'", "''") + "'"
14
+
15
+
16
+ class LanceDBStore(BaseVectorStore):
17
+ def __init__(self, uri: str = "./lancedb"):
18
+ self.db = lancedb.connect(uri)
19
+
20
+ def _table_names(self) -> List[str]:
21
+ if hasattr(self.db, "list_tables"):
22
+ res = self.db.list_tables()
23
+ if hasattr(res, "tables"):
24
+ return list(res.tables)
25
+ try:
26
+ return list(self.db.table_names())
27
+ except Exception:
28
+ return []
29
+
30
+ async def insert(self, collection: str, id: int, vector: Any, payload: dict):
31
+ if isinstance(vector, dict):
32
+ row = {"id": id}
33
+ for v_k, v_v in vector.items():
34
+ row[f"vector_{v_k}"] = v_v
35
+ row["vector"] = vector.get("query") or list(vector.values())[0]
36
+ else:
37
+ row = {"id": id, "vector": vector}
38
+
39
+ for k, v in payload.items():
40
+ # Structured values are stored as JSON text (LanceDB rows are flat),
41
+ # and decoded again on read. Never rely on Python repr().
42
+ if isinstance(v, (dict, list)):
43
+ row[k] = json.dumps(v, default=str)
44
+ else:
45
+ row[k] = v
46
+
47
+ if collection not in self._table_names():
48
+ self.db.create_table(collection, data=[row])
49
+ else:
50
+ table = self.db.open_table(collection)
51
+ table.add([row])
52
+
53
+ async def search(
54
+ self,
55
+ collection: str,
56
+ vector: Any,
57
+ query_filter: Any,
58
+ limit: int,
59
+ score_threshold: float,
60
+ using: Optional[str] = None,
61
+ **kwargs: Any,
62
+ ) -> List[Any]:
63
+ if collection not in self._table_names():
64
+ return []
65
+
66
+ table = self.db.open_table(collection)
67
+ schema_names = table.schema.names if hasattr(table, "schema") else []
68
+ vec_col = f"vector_{using}" if using and f"vector_{using}" in schema_names else "vector"
69
+
70
+ try:
71
+ res = table.search(vector, vector_column_name=vec_col).metric("cosine").limit(limit).to_list()
72
+ except Exception:
73
+ try:
74
+ res = table.search(vector).metric("cosine").limit(limit).to_list()
75
+ except Exception:
76
+ res = table.search(vector).limit(limit).to_list()
77
+
78
+ filter_tenant_id = kwargs.get("tenant_id")
79
+ if not filter_tenant_id and query_filter is not None and hasattr(query_filter, "must"):
80
+ for cond in (query_filter.must or []):
81
+ if getattr(cond, "key", None) == "tenant_id" and hasattr(cond, "match") and hasattr(cond.match, "value"):
82
+ filter_tenant_id = cond.match.value
83
+
84
+ class Hit:
85
+ def __init__(self, payload, id):
86
+ self.payload = payload
87
+ self.id = id
88
+
89
+ hits = []
90
+ for r in res:
91
+ hid = r.pop("id", None)
92
+ r.pop("vector", None)
93
+ r.pop("vector_query", None)
94
+ r.pop("vector_context", None)
95
+ dist = r.pop("_distance", 0.0)
96
+ similarity = 1.0 - dist
97
+ if score_threshold > 0 and similarity < score_threshold:
98
+ continue
99
+
100
+ # Reconstruct dict payload
101
+ payload_dict = {}
102
+ for k, v in r.items():
103
+ if k in ("meta", "answer") and isinstance(v, str):
104
+ try:
105
+ payload_dict[k] = json.loads(v)
106
+ except Exception:
107
+ payload_dict[k] = v
108
+ else:
109
+ payload_dict[k] = v
110
+
111
+ if filter_tenant_id and payload_dict.get("tenant_id") != filter_tenant_id:
112
+ continue
113
+
114
+ hits.append(Hit(payload=payload_dict, id=hid))
115
+ return hits
116
+
117
+ async def delete(self, collection: str, id: int):
118
+ if collection in self._table_names():
119
+ table = self.db.open_table(collection)
120
+ table.delete(f"id = {id}")
121
+
122
+ def collection_exists(self, collection: str) -> bool:
123
+ return collection in self._table_names()
124
+
125
+ def create_collection(self, collection: str, config: Any):
126
+ pass
127
+
128
+ def delete_collection(self, collection: str):
129
+ if collection in self._table_names():
130
+ self.db.drop_table(collection)
131
+
132
+ async def delete_matching(self, collection: str, filter_dict: Optional[dict] = None) -> int:
133
+ if collection not in self._table_names():
134
+ return 0
135
+ if not filter_dict:
136
+ self.db.drop_table(collection)
137
+ return -1
138
+ table = self.db.open_table(collection)
139
+ clauses = []
140
+ for k, v in filter_dict.items():
141
+ # Reject keys that could break out of the identifier/JSON context.
142
+ if not isinstance(k, str) or not _IDENT_RE.match(k):
143
+ continue
144
+ if not isinstance(v, (str, int, float, bool)):
145
+ continue
146
+ if k == "tenant_id":
147
+ clauses.append(f"tenant_id = {_sql_str(v)}")
148
+ else:
149
+ # LanceDB stores meta as a JSON string; match the escaped literal.
150
+ pattern = '%"' + str(k) + '": "' + str(v) + '"%'
151
+ clauses.append("meta LIKE " + _sql_str(pattern))
152
+ if not clauses:
153
+ return 0
154
+ where_clause = " AND ".join(clauses)
155
+ try:
156
+ table.delete(where_clause)
157
+ return len(clauses)
158
+ except Exception:
159
+ return 0
160
+
@@ -0,0 +1,90 @@
1
+ import asyncio
2
+ import threading
3
+ from typing import List, Any, Optional
4
+ from qdrant_client import QdrantClient, AsyncQdrantClient
5
+ from qdrant_client.models import VectorParams, Distance
6
+ from ..base import BaseVectorStore
7
+
8
+ class QdrantStore(BaseVectorStore):
9
+ def __init__(self, host: str = "localhost", port: int = 6333):
10
+ self.host = host
11
+ self.port = port
12
+ self.qc = QdrantClient(host, port=port, check_compatibility=False)
13
+ self._local = threading.local()
14
+
15
+ @property
16
+ def aqc(self) -> AsyncQdrantClient:
17
+ if not hasattr(self._local, "client"):
18
+ self._local.client = AsyncQdrantClient(self.host, port=self.port, check_compatibility=False)
19
+ return self._local.client
20
+
21
+ async def insert(self, collection: str, id: int, vector: Any, payload: dict):
22
+ await self.aqc.upsert(
23
+ collection_name=collection,
24
+ points=[{
25
+ "id": id,
26
+ "vector": vector,
27
+ "payload": payload
28
+ }]
29
+ )
30
+
31
+ async def search(self, collection: str, vector: Any, query_filter: Any, limit: int, score_threshold: float, using: Optional[str] = None, **kwargs: Any) -> List[Any]:
32
+ # Use the async client so the event loop is never blocked by network I/O.
33
+ if using:
34
+ res = await self.aqc.query_points(
35
+ collection_name=collection,
36
+ query=vector,
37
+ using=using,
38
+ query_filter=query_filter,
39
+ limit=limit,
40
+ score_threshold=score_threshold,
41
+ )
42
+ else:
43
+ res = await self.aqc.query_points(
44
+ collection_name=collection,
45
+ query=vector,
46
+ query_filter=query_filter,
47
+ limit=limit,
48
+ score_threshold=score_threshold,
49
+ )
50
+ return res.points
51
+
52
+ async def delete(self, collection: str, id: int):
53
+ await self.aqc.delete(collection_name=collection, points_selector=[id])
54
+
55
+ def collection_exists(self, collection: str) -> bool:
56
+ return self.qc.collection_exists(collection)
57
+
58
+ def create_collection(self, collection: str, config: Any = None):
59
+ if config is None:
60
+ config = VectorParams(size=384, distance=Distance.COSINE)
61
+ elif isinstance(config, dict):
62
+ # parse custom dict to VectorParams
63
+ new_config = {}
64
+ for k, v in config.items():
65
+ if isinstance(v, dict):
66
+ new_config[k] = VectorParams(size=v["size"], distance=Distance.COSINE)
67
+ else:
68
+ new_config[k] = v
69
+ config = new_config
70
+ self.qc.create_collection(collection, vectors_config=config)
71
+
72
+ def delete_collection(self, collection: str):
73
+ if self.qc.collection_exists(collection):
74
+ self.qc.delete_collection(collection)
75
+
76
+ async def delete_matching(self, collection: str, filter_dict: Optional[dict] = None) -> int:
77
+ if not await asyncio.to_thread(self.qc.collection_exists, collection):
78
+ return 0
79
+ from qdrant_client.http import models
80
+ if not filter_dict:
81
+ await asyncio.to_thread(self.qc.delete_collection, collection)
82
+ return -1
83
+ conditions = []
84
+ for k, v in filter_dict.items():
85
+ field_key = f"meta.{k}" if (not k.startswith("meta.") and k != "tenant_id" and k != "doc_id") else k
86
+ conditions.append(models.FieldCondition(key=field_key, match=models.MatchValue(value=v)))
87
+ q_filter = models.Filter(must=conditions)
88
+ await self.aqc.delete(collection_name=collection, points_selector=models.FilterSelector(filter=q_filter))
89
+ return len(conditions)
90
+
ico_cache/client.py ADDED
@@ -0,0 +1,20 @@
1
+ import requests
2
+
3
+ class IcoCache:
4
+ def __init__(self, base_url="http://localhost:8000", semantic_threshold=0.92):
5
+ self.base_url = base_url
6
+ self.semantic_threshold = semantic_threshold
7
+
8
+ def resolve(self, query: str, context: str, callback):
9
+ resp = requests.post(f"{self.base_url}/resolve", json={"query": query, "context": context}).json()
10
+ if resp.get("source") != "MISS":
11
+ return resp.get("response")
12
+
13
+ # MISS
14
+ generated = callback()
15
+
16
+ requests.post(f"{self.base_url}/ingest", json={"query": query, "context": context, "response": generated})
17
+ return generated
18
+
19
+ def ico_cache(base_url="http://localhost:8000", semantic_threshold=0.92):
20
+ return IcoCache(base_url, semantic_threshold)