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.
@@ -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()