icycache 0.3.2__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.
- icycache/__init__.py +32 -0
- icycache/__main__.py +9 -0
- icycache/cli.py +171 -0
- icycache/config/icycache.gateway.json +111 -0
- icycache/config/icycache.json +149 -0
- icycache/config/normalization_rules.json +5832 -0
- icycache/config/semantic_cache.json +8 -0
- icycache/config/tools_registry.json +10 -0
- icycache/src/icy_cache_store.py +217 -0
- icycache/src/icy_core.py +380 -0
- icycache/src/icy_normalize.py +197 -0
- icycache/src/icy_paths.py +76 -0
- icycache/src/icy_semantic.py +197 -0
- icycache/src/mcp_gateway.py +360 -0
- icycache/src/mcp_server.py +1319 -0
- icycache/src/mcp_stdio_bridge.py +83 -0
- icycache/src/mcp_stdio_entry.py +23 -0
- icycache/src/prefix_proxy.py +336 -0
- icycache/src/server.py +1142 -0
- icycache/src/tool_proxy.py +655 -0
- icycache/src/upstream_client.py +98 -0
- icycache/src/upstream_utils.py +135 -0
- icycache/static/dashboard.html +2385 -0
- icycache/static/icywebgl.js +2190 -0
- icycache/static/logo.svg +58 -0
- icycache/static/three.min.js +6 -0
- icycache-0.3.2.dist-info/METADATA +422 -0
- icycache-0.3.2.dist-info/RECORD +31 -0
- icycache-0.3.2.dist-info/WHEEL +4 -0
- icycache-0.3.2.dist-info/entry_points.txt +2 -0
- icycache-0.3.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""IcyCache - moteur de normalisation autonome.
|
|
3
|
+
|
|
4
|
+
Port fidele de `server.NormalizationPipeline`, mais SANS effet de bord a l'import :
|
|
5
|
+
ni logging.basicConfig, ni DB, ni app FastAPI, ni client httpx.
|
|
6
|
+
Utilisable donc depuis la gateway (server_new.py) et les tests sans perturber
|
|
7
|
+
le serveur en cours.
|
|
8
|
+
|
|
9
|
+
Objectif : la cle de cache porte sur le payload NORMALISE, pas sur le payload
|
|
10
|
+
brut. "Bonjour !" et "bonjour" doivent tomber dans la meme entree.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import unicodedata
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, Optional
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Normalizer",
|
|
23
|
+
"normalize_payload",
|
|
24
|
+
"cache_key",
|
|
25
|
+
"canonical",
|
|
26
|
+
"text_blocks",
|
|
27
|
+
"get_default_normalizer",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
31
|
+
DEFAULT_RULES_PATH = PROJECT_ROOT / "config" / "normalization_rules.json"
|
|
32
|
+
|
|
33
|
+
# Champs sans effet sur la sortie du modele : exclus de la cle de cache.
|
|
34
|
+
CACHE_KEY_IGNORED = {"stream", "stream_options", "user", "metadata"}
|
|
35
|
+
|
|
36
|
+
# Bloc volatil injecte par Hermes en fin de message ("Retrieved skills (top-K relevant ...)").
|
|
37
|
+
# Il change a chaque requete et empeche tout HIT : retire de la CLE uniquement.
|
|
38
|
+
_VOLATILE_TAIL = re.compile(r"retrieved skills\W*top\W?k relevant to your query.*$", re.DOTALL)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def canonical(obj: Any) -> str:
|
|
42
|
+
"""JSON canonique (cles triees, separateurs compacts) -> hachage stable."""
|
|
43
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Normalizer:
|
|
47
|
+
"""Pipeline : NFKD -> lowercase -> Aho-Corasick (politesse/synonymes) -> accents -> ponctuation."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, rules_path: Path = DEFAULT_RULES_PATH):
|
|
50
|
+
self.rules_path = Path(rules_path)
|
|
51
|
+
self.config: Dict[str, Any] = {}
|
|
52
|
+
self.automaton = None
|
|
53
|
+
self.regex_compiled = []
|
|
54
|
+
self.unicodedata = unicodedata
|
|
55
|
+
# Ne supprime que la ponctuation de phrase. +-*/=<>%^&|()[]{} etc. sont conserves :
|
|
56
|
+
# "12+2" et "1+22" ne doivent PAS partager la meme cle de cache.
|
|
57
|
+
self.cleanup_regex = re.compile(r"[!?.,;:'\"`\u00b4\u201c\u201d\u2018\u2019\u00ab\u00bb\u00a1\u00bf\u2026]", re.UNICODE)
|
|
58
|
+
self.space_regex = re.compile(r"\s+")
|
|
59
|
+
|
|
60
|
+
if self.rules_path.exists():
|
|
61
|
+
with open(self.rules_path, "r", encoding="utf-8") as f:
|
|
62
|
+
self.config = json.load(f)
|
|
63
|
+
else: # degrade proprement plutot que de planter
|
|
64
|
+
self.config = {"rules": []}
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
import ahocorasick
|
|
68
|
+
self.automaton = ahocorasick.Automaton()
|
|
69
|
+
for rule in self.config.get("rules", []):
|
|
70
|
+
pattern = rule["pattern"].lower()
|
|
71
|
+
self.automaton.add_word(pattern, (pattern, rule["replace"]))
|
|
72
|
+
self.automaton.make_automaton()
|
|
73
|
+
except Exception:
|
|
74
|
+
self.automaton = None
|
|
75
|
+
for rule in self.config.get("rules", []):
|
|
76
|
+
self.regex_compiled.append(
|
|
77
|
+
(re.compile(r"\b" + re.escape(rule["pattern"].lower()) + r"\b", re.IGNORECASE), rule["replace"])
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# -- normalisation de texte brut -------------------------------------
|
|
81
|
+
def normalize(self, text: str) -> str:
|
|
82
|
+
if not isinstance(text, str) or not text:
|
|
83
|
+
return ""
|
|
84
|
+
text = self.unicodedata.normalize("NFKD", text)
|
|
85
|
+
text = text.lower()
|
|
86
|
+
text = self._replace_rules(text)
|
|
87
|
+
# DELTA-002: normalisation des nombres ("two plus two" → "2+2")
|
|
88
|
+
text = self._normalize_numbers(text)
|
|
89
|
+
text = "".join(
|
|
90
|
+
c for c in self.unicodedata.normalize("NFD", text) if self.unicodedata.category(c) != "Mn"
|
|
91
|
+
)
|
|
92
|
+
text = self.cleanup_regex.sub("", text)
|
|
93
|
+
text = self.space_regex.sub(" ", text).strip()
|
|
94
|
+
return _VOLATILE_TAIL.sub("", text).strip()
|
|
95
|
+
|
|
96
|
+
def _replace_rules(self, text: str) -> str:
|
|
97
|
+
if self.automaton:
|
|
98
|
+
def _isw(c: str) -> bool:
|
|
99
|
+
return c.isalnum() or c == "_" or self.unicodedata.category(c) == "Mn"
|
|
100
|
+
cands = []
|
|
101
|
+
for end_idx, (pattern, replacement) in self.automaton.iter(text):
|
|
102
|
+
s, e = end_idx - len(pattern) + 1, end_idx + 1
|
|
103
|
+
if s > 0 and _isw(pattern[0]) and _isw(text[s - 1]):
|
|
104
|
+
continue
|
|
105
|
+
if e < len(text) and _isw(pattern[-1]) and _isw(text[e]):
|
|
106
|
+
continue
|
|
107
|
+
cands.append((s, e, replacement))
|
|
108
|
+
cands.sort(key=lambda t: (t[0], -(t[1] - t[0])))
|
|
109
|
+
out, pos = [], 0
|
|
110
|
+
for s, e, rep in cands:
|
|
111
|
+
if s < pos:
|
|
112
|
+
continue
|
|
113
|
+
out.append(text[pos:s])
|
|
114
|
+
out.append(rep)
|
|
115
|
+
pos = e
|
|
116
|
+
out.append(text[pos:])
|
|
117
|
+
return "".join(out)
|
|
118
|
+
for pattern, replacement in self.regex_compiled:
|
|
119
|
+
text = pattern.sub(replacement, text)
|
|
120
|
+
return text
|
|
121
|
+
|
|
122
|
+
def _normalize_numbers(self, text: str) -> str:
|
|
123
|
+
"""DELTA-002: convertit mots-nombres → chiffres et opérateurs verbaux → symboles."""
|
|
124
|
+
_NUM_WORDS = {"zero": "0", "one": "1", "two": "2", "three": "3", "four": "4",
|
|
125
|
+
"five": "5", "six": "6", "seven": "7", "eight": "8", "nine": "9",
|
|
126
|
+
"ten": "10"}
|
|
127
|
+
for w, d in _NUM_WORDS.items():
|
|
128
|
+
text = re.sub(rf"\b{w}\b", d, text)
|
|
129
|
+
return (text.replace(" plus ", "+").replace(" minus ", "-")
|
|
130
|
+
.replace(" times ", "*").replace(" divided by ", "/"))
|
|
131
|
+
|
|
132
|
+
def get_hash(self, normalized_text: str) -> str:
|
|
133
|
+
return hashlib.sha256((normalized_text or "").encode("utf-8")).hexdigest()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
_DEFAULT: Optional[Normalizer] = None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_default_normalizer(rules_path: Path = DEFAULT_RULES_PATH) -> Normalizer:
|
|
140
|
+
"""Instance partagee (construction lazy, thread-safe par GIL sur l'assignation)."""
|
|
141
|
+
global _DEFAULT
|
|
142
|
+
if _DEFAULT is None:
|
|
143
|
+
_DEFAULT = Normalizer(rules_path)
|
|
144
|
+
return _DEFAULT
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def text_blocks(content: Any) -> list:
|
|
148
|
+
"""Normalise le contenu textuel d'un message ; les blocs non textuels sont intacts.
|
|
149
|
+
|
|
150
|
+
- str -> texte normalise
|
|
151
|
+
- list -> chaque bloc {"type": "text", "text": ...} est normalise, les autres
|
|
152
|
+
(image_url, input_audio, tool_use...) sont conserves tels quels.
|
|
153
|
+
"""
|
|
154
|
+
if isinstance(content, str):
|
|
155
|
+
return get_default_normalizer().normalize(content)
|
|
156
|
+
if isinstance(content, list):
|
|
157
|
+
out = []
|
|
158
|
+
for block in content:
|
|
159
|
+
if isinstance(block, dict) and block.get("type") == "text" and isinstance(block.get("text"), str):
|
|
160
|
+
b = dict(block)
|
|
161
|
+
b["text"] = get_default_normalizer().normalize(block["text"])
|
|
162
|
+
out.append(b)
|
|
163
|
+
else:
|
|
164
|
+
out.append(block)
|
|
165
|
+
return out
|
|
166
|
+
return content
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def normalize_payload(body: Dict[str, Any]) -> Dict[str, Any]:
|
|
170
|
+
"""Copie du body ou les contenus textuels sont normalises (pour la cle de cache).
|
|
171
|
+
|
|
172
|
+
Le body envoye a l'upstream n'est PAS modifie : seule la forme canonique
|
|
173
|
+
servant de cle de cache est normalisee.
|
|
174
|
+
"""
|
|
175
|
+
if not isinstance(body, dict):
|
|
176
|
+
return {}
|
|
177
|
+
out: Dict[str, Any] = {k: v for k, v in body.items() if k not in CACHE_KEY_IGNORED}
|
|
178
|
+
for key in ("messages", "system", "prompt", "input"):
|
|
179
|
+
if key in out:
|
|
180
|
+
val = out[key]
|
|
181
|
+
if key == "messages" and isinstance(val, list):
|
|
182
|
+
out[key] = [
|
|
183
|
+
{**m, "content": text_blocks(m.get("content"))} if isinstance(m, dict) else m for m in val
|
|
184
|
+
]
|
|
185
|
+
elif key == "system" and isinstance(val, list):
|
|
186
|
+
out[key] = text_blocks(val)
|
|
187
|
+
else:
|
|
188
|
+
out[key] = text_blocks(val)
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def cache_key(body: Dict[str, Any], scope: Optional[Dict[str, Any]] = None) -> str:
|
|
193
|
+
"""SHA256 du payload normalise (+ perimetre optionnel : upstream, type d'endpoint)."""
|
|
194
|
+
payload = normalize_payload(body or {})
|
|
195
|
+
if scope:
|
|
196
|
+
payload = {"__scope": scope, "body": payload}
|
|
197
|
+
return hashlib.sha256(canonical(payload).encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Chemins IcyCache: racine du projet et repertoire de donnees inscriptible.
|
|
2
|
+
|
|
3
|
+
Deux modes cohabitent:
|
|
4
|
+
|
|
5
|
+
- **Mode depot** (checkout source, `.git` present) : `PROJECT_ROOT` est le repo,
|
|
6
|
+
les donnees (`db/`, `logs/`) restent a cote du code. C'est le comportement
|
|
7
|
+
historique : `D:\\IcyCache\\db\\cache.db`, `D:\\IcyCache\\logs\\app.log`.
|
|
8
|
+
- **Mode installe** (pip / uvx) : `PROJECT_ROOT` est le repertoire du paquet
|
|
9
|
+
(site-packages), qui n'est pas inscriptible. Les donnees vont alors dans un
|
|
10
|
+
repertoire utilisateur : `%LOCALAPPDATA%\\IcyCache` sous Windows,
|
|
11
|
+
`~/.local/share/IcyCache` ailleurs.
|
|
12
|
+
|
|
13
|
+
Regle de resolution : un chemin **absolu** dans la config est utilise tel quel ;
|
|
14
|
+
un chemin **relatif** est resolu contre `DATA_DIR`. `ICYCACHE_HOME` force
|
|
15
|
+
`DATA_DIR`, `ICYCACHE_DB` force le chemin de base.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"PROJECT_ROOT",
|
|
24
|
+
"IS_SOURCE_CHECKOUT",
|
|
25
|
+
"DATA_DIR",
|
|
26
|
+
"default_data_dir",
|
|
27
|
+
"resolve_data_path",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# parent de src/ : le repo en mode depot, le paquet en mode installe.
|
|
31
|
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
32
|
+
|
|
33
|
+
IS_SOURCE_CHECKOUT = (PROJECT_ROOT / ".git").exists()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _writable(path: Path) -> bool:
|
|
37
|
+
try:
|
|
38
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
except OSError:
|
|
40
|
+
return False
|
|
41
|
+
return os.access(path, os.W_OK)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_in_site_packages(path: Path) -> bool:
|
|
45
|
+
parts = {p.lower() for p in path.parts}
|
|
46
|
+
return "site-packages" in parts or "dist-packages" in parts
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def default_data_dir() -> Path:
|
|
50
|
+
"""Repertoire inscriptible pour `db/` et `logs/`."""
|
|
51
|
+
env = os.environ.get("ICYCACHE_HOME")
|
|
52
|
+
if env:
|
|
53
|
+
return Path(env).expanduser()
|
|
54
|
+
|
|
55
|
+
if IS_SOURCE_CHECKOUT:
|
|
56
|
+
return PROJECT_ROOT
|
|
57
|
+
|
|
58
|
+
# Installation editable hors site-packages : on garde la racine du projet.
|
|
59
|
+
if not _is_in_site_packages(PROJECT_ROOT) and _writable(PROJECT_ROOT):
|
|
60
|
+
return PROJECT_ROOT
|
|
61
|
+
|
|
62
|
+
if os.name == "nt":
|
|
63
|
+
base = os.environ.get("LOCALAPPDATA") or (Path.home() / "AppData" / "Local")
|
|
64
|
+
else:
|
|
65
|
+
base = os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")
|
|
66
|
+
return Path(base) / "IcyCache"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
DATA_DIR = default_data_dir()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolve_data_path(value, default_relative: str) -> Path:
|
|
73
|
+
"""Absolu -> tel quel. Relatif (ou vide) -> resolu contre `DATA_DIR`."""
|
|
74
|
+
raw = str(value) if value else default_relative
|
|
75
|
+
path = Path(raw)
|
|
76
|
+
return path if path.is_absolute() else (DATA_DIR / path)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Cache sémantique hybride — DELTA-002 Volet B.
|
|
2
|
+
|
|
3
|
+
Qdrant est importé LAZILY: si qdrant-client n'est pas installé ou Qdrant est down,
|
|
4
|
+
tout échec = miss silencieux → upstream. Jamais de panne.
|
|
5
|
+
|
|
6
|
+
Embedding model: auto-discovery via /v1/models (pas de hardcodage).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import re
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
import threading
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("icycache.semantic")
|
|
17
|
+
|
|
18
|
+
# ---- Ancres (grounding anti-faux-positif) -----------------------------------
|
|
19
|
+
|
|
20
|
+
_HARD = [
|
|
21
|
+
("num", r"\b\d+(?:[.,]\d+)?\b"),
|
|
22
|
+
("quote", r"[\"'`]([^\"'`]{1,60})[\"'`]"),
|
|
23
|
+
("path", r"\b[A-Za-z]:\\[^\s]+|(?:\./|/)[^\s]+"),
|
|
24
|
+
("unit", r"\b\d+\s?(?:kg|km|cm|mm|mb|gb|tb|ms|min|h|%|°c|°f|usd|eur)\b"),
|
|
25
|
+
("url", r"https?://[^\s]+"),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
_NUM_WORDS = {"zero": "0", "one": "1", "two": "2", "three": "3", "four": "4",
|
|
29
|
+
"five": "5", "six": "6", "seven": "7", "eight": "8", "nine": "9",
|
|
30
|
+
"ten": "10"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def normalize_numbers(t: str) -> str:
|
|
34
|
+
"""Convertit mots-nombres → chiffres et opérateurs verbaux → symboles.
|
|
35
|
+
À appeler AVANT hash ET avant embedding."""
|
|
36
|
+
for w, d in _NUM_WORDS.items():
|
|
37
|
+
t = re.sub(rf"\b{w}\b", d, t)
|
|
38
|
+
return (t.replace(" plus ", "+").replace(" minus ", "-")
|
|
39
|
+
.replace(" times ", "*").replace(" divided by ", "/"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def extract_hard(text: str) -> frozenset[str]:
|
|
43
|
+
low = text.lower()
|
|
44
|
+
out = set()
|
|
45
|
+
for _, pat in _HARD:
|
|
46
|
+
out.update(m.group(0).strip() for m in re.finditer(pat, low))
|
|
47
|
+
return frozenset(out)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def extract_soft(text: str) -> frozenset[str]:
|
|
51
|
+
return frozenset(
|
|
52
|
+
m.group(0).lower()
|
|
53
|
+
for m in re.finditer(r"\b[A-Z][a-zà-ÿ]{2,}\b", text)
|
|
54
|
+
if m.start() > 0
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def accepts(query_norm: str, cand: dict, score: float,
|
|
59
|
+
HARD_MIN: float = 0.95, RELAX: float = 0.97) -> bool:
|
|
60
|
+
if score < HARD_MIN:
|
|
61
|
+
return False
|
|
62
|
+
qh, ch = extract_hard(query_norm), frozenset(cand.get("anchors_hard", []))
|
|
63
|
+
if qh != ch:
|
|
64
|
+
return False
|
|
65
|
+
qs, cs = extract_soft(query_norm), frozenset(cand.get("anchors_soft", []))
|
|
66
|
+
if qs == cs:
|
|
67
|
+
return True
|
|
68
|
+
# RELAX seulement si ancres hard présentes (grounding numérique/path);
|
|
69
|
+
# sans hard anchors, soft anchors doivent matcher exactement.
|
|
70
|
+
if qh and score >= RELAX:
|
|
71
|
+
return True
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---- Embedder (auto-discovery LM Studio) ------------------------------------
|
|
76
|
+
|
|
77
|
+
class Embedder:
|
|
78
|
+
def __init__(self, base_url: str, model: str | None = None, timeout: float = 10.0):
|
|
79
|
+
import httpx
|
|
80
|
+
self._c = httpx.Client(base_url=base_url, timeout=timeout)
|
|
81
|
+
self._m = model
|
|
82
|
+
if not self._m:
|
|
83
|
+
self._m = self._discover_model()
|
|
84
|
+
|
|
85
|
+
def _discover_model(self) -> str:
|
|
86
|
+
try:
|
|
87
|
+
r = self._c.get("/v1/models")
|
|
88
|
+
r.raise_for_status()
|
|
89
|
+
for m in r.json().get("data", []):
|
|
90
|
+
mid = m.get("id", "")
|
|
91
|
+
if "embed" in mid.lower() or "bge" in mid.lower() or "e5" in mid.lower():
|
|
92
|
+
return mid
|
|
93
|
+
# fallback: premier modèle dispo
|
|
94
|
+
models = r.json().get("data", [])
|
|
95
|
+
if models:
|
|
96
|
+
return models[0]["id"]
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logger.warning("Embedder auto-discovery failed: %s", e)
|
|
99
|
+
raise RuntimeError("No embedding model found via /v1/models")
|
|
100
|
+
|
|
101
|
+
def embed(self, text: str) -> list[float]:
|
|
102
|
+
r = self._c.post("/v1/embeddings", json={"model": self._m, "input": text})
|
|
103
|
+
r.raise_for_status()
|
|
104
|
+
v = r.json()["data"][0]["embedding"]
|
|
105
|
+
n = sum(x * x for x in v) ** 0.5 or 1.0
|
|
106
|
+
return [x / n for x in v]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---- Store Qdrant (lazy import) ---------------------------------------------
|
|
110
|
+
|
|
111
|
+
class SemanticStore:
|
|
112
|
+
COLL = "icycache_semantic"
|
|
113
|
+
_qdrant = None
|
|
114
|
+
_client = None
|
|
115
|
+
|
|
116
|
+
def __init__(self, url: str):
|
|
117
|
+
self._url = url
|
|
118
|
+
self._lock = threading.Lock()
|
|
119
|
+
self._init_qdrant()
|
|
120
|
+
|
|
121
|
+
def _init_qdrant(self):
|
|
122
|
+
try:
|
|
123
|
+
from qdrant_client import QdrantClient, models
|
|
124
|
+
SemanticStore._qdrant = (QdrantClient, models)
|
|
125
|
+
self._client = QdrantClient(url=self._url)
|
|
126
|
+
except ImportError:
|
|
127
|
+
logger.info("qdrant-client not installed — semantic cache disabled (miss silencieux)")
|
|
128
|
+
self._client = None
|
|
129
|
+
except Exception as e:
|
|
130
|
+
logger.warning("Qdrant unreachable: %s — semantic cache disabled", e)
|
|
131
|
+
self._client = None
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def available(self) -> bool:
|
|
135
|
+
return self._client is not None
|
|
136
|
+
|
|
137
|
+
def ensure(self, dim: int):
|
|
138
|
+
if not self._client:
|
|
139
|
+
return
|
|
140
|
+
try:
|
|
141
|
+
QdrantClient, models = SemanticStore._qdrant
|
|
142
|
+
if not self._client.collection_exists(self.COLL):
|
|
143
|
+
self._client.create_collection(
|
|
144
|
+
self.COLL,
|
|
145
|
+
vectors_config=models.VectorParams(size=dim, distance=models.Distance.COSINE),
|
|
146
|
+
)
|
|
147
|
+
except Exception as e:
|
|
148
|
+
logger.warning("Qdrant ensure collection failed: %s", e)
|
|
149
|
+
self._client = None
|
|
150
|
+
|
|
151
|
+
def search(self, sig: str, vec: list[float], seuil: float, top: int = 1):
|
|
152
|
+
if not self._client:
|
|
153
|
+
return None
|
|
154
|
+
try:
|
|
155
|
+
QdrantClient, models = SemanticStore._qdrant
|
|
156
|
+
res = self._client.query_points(
|
|
157
|
+
collection_name=self.COLL, query=vec, limit=top,
|
|
158
|
+
score_threshold=seuil,
|
|
159
|
+
query_filter=models.Filter(must=[models.FieldCondition(
|
|
160
|
+
key="sig", match=models.MatchValue(value=sig))]),
|
|
161
|
+
with_payload=True,
|
|
162
|
+
).points
|
|
163
|
+
return res[0] if res else None
|
|
164
|
+
except Exception as e:
|
|
165
|
+
logger.warning("Qdrant search failed: %s — miss silencieux", e)
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
def upsert(self, vec: list[float], payload: dict):
|
|
169
|
+
if not self._client:
|
|
170
|
+
return
|
|
171
|
+
try:
|
|
172
|
+
QdrantClient, models = SemanticStore._qdrant
|
|
173
|
+
with self._lock:
|
|
174
|
+
self._client.upsert(self.COLL, points=[models.PointStruct(
|
|
175
|
+
id=str(uuid.uuid4()), vector=vec, payload=payload)])
|
|
176
|
+
except Exception as e:
|
|
177
|
+
logger.warning("Qdrant upsert failed: %s — index skipped", e)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---- Indexation async (ne bloque jamais) ------------------------------------
|
|
181
|
+
|
|
182
|
+
def index_async(store: SemanticStore, embedder: Embedder, sig: str,
|
|
183
|
+
query_norm: str, resp_json: str):
|
|
184
|
+
def _job():
|
|
185
|
+
try:
|
|
186
|
+
v = embedder.embed(query_norm)
|
|
187
|
+
store.ensure(len(v))
|
|
188
|
+
store.upsert(v, {
|
|
189
|
+
"sig": sig,
|
|
190
|
+
"response": resp_json,
|
|
191
|
+
"anchors_hard": sorted(extract_hard(query_norm)),
|
|
192
|
+
"anchors_soft": sorted(extract_soft(query_norm)),
|
|
193
|
+
"ts": time.time(),
|
|
194
|
+
})
|
|
195
|
+
except Exception:
|
|
196
|
+
pass # index échoue → pas grave
|
|
197
|
+
threading.Thread(target=_job, daemon=True).start()
|