kernhell 0.1.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.

Potentially problematic release.


This version of kernhell might be problematic. Click here for more details.

Files changed (42) hide show
  1. kernhell/__init__.py +8 -0
  2. kernhell/__main__.py +6 -0
  3. kernhell/analyzer/analyzer.py +178 -0
  4. kernhell/analyzer/healer.py +249 -0
  5. kernhell/analyzer/prompts.py +89 -0
  6. kernhell/api/__init__.py +5 -0
  7. kernhell/api/server.py +367 -0
  8. kernhell/cache/__init__.py +13 -0
  9. kernhell/cache/cache_manager.py +221 -0
  10. kernhell/cache/embedder.py +104 -0
  11. kernhell/cache/schemas.py +42 -0
  12. kernhell/cli/__init__.py +1 -0
  13. kernhell/cli/app.py +62 -0
  14. kernhell/cli/commands/__init__.py +1 -0
  15. kernhell/cli/commands/cache.py +96 -0
  16. kernhell/cli/commands/config.py +111 -0
  17. kernhell/cli/commands/dashboard.py +55 -0
  18. kernhell/cli/commands/heal.py +141 -0
  19. kernhell/cli/commands/scan.py +87 -0
  20. kernhell/cli/ui.py +176 -0
  21. kernhell/core/__init__.py +1 -0
  22. kernhell/core/config.py +219 -0
  23. kernhell/core/exceptions.py +104 -0
  24. kernhell/core/models.py +174 -0
  25. kernhell/core/orchestrator.py +245 -0
  26. kernhell/patcher/patcher.py +215 -0
  27. kernhell/patcher/verifier.py +82 -0
  28. kernhell/pruner/__init__.py +1 -0
  29. kernhell/pruner/dom_pruner.py +240 -0
  30. kernhell/reporter/__init__.py +5 -0
  31. kernhell/reporter/reporter.py +124 -0
  32. kernhell/scanner/__init__.py +1 -0
  33. kernhell/scanner/parser.py +276 -0
  34. kernhell/scanner/runner.py +242 -0
  35. kernhell/utils/__init__.py +1 -0
  36. kernhell/utils/file_utils.py +117 -0
  37. kernhell/utils/logger.py +78 -0
  38. kernhell-0.1.0.dist-info/METADATA +245 -0
  39. kernhell-0.1.0.dist-info/RECORD +42 -0
  40. kernhell-0.1.0.dist-info/WHEEL +4 -0
  41. kernhell-0.1.0.dist-info/entry_points.txt +2 -0
  42. kernhell-0.1.0.dist-info/licenses/LICENSE +21 -0
kernhell/api/server.py ADDED
@@ -0,0 +1,367 @@
1
+ """FastAPI Mission Control Bridge for KernHell.
2
+
3
+ Provides REST API endpoints for:
4
+ - Cache telemetry & inspection
5
+ - Healing history & diff inspection
6
+ - Live selector healing & scan triggering
7
+ - Interactive test simulation playground
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Any, Optional
16
+
17
+ import structlog
18
+ from fastapi import FastAPI, HTTPException
19
+ from fastapi.middleware.cors import CORSMiddleware
20
+ from pydantic import BaseModel
21
+
22
+ from kernhell import __version__
23
+ from kernhell.analyzer.analyzer import FailureAnalyzer
24
+ from kernhell.analyzer.healer import SelectorHealer
25
+ from kernhell.cache.cache_manager import CacheManager
26
+ from kernhell.core.config import KernHellConfig, load_config
27
+ from kernhell.core.models import (
28
+ Diagnosis,
29
+ FailureContext,
30
+ FailureType,
31
+ HealingReport,
32
+ PrunedDOM,
33
+ SelectorFix,
34
+ SelectorStrategy,
35
+ SelectorType,
36
+ )
37
+ from kernhell.core.orchestrator import HealingOrchestrator
38
+ from kernhell.patcher.patcher import SurgicalPatcher
39
+ from kernhell.pruner.dom_pruner import prune_dom
40
+
41
+ logger = structlog.get_logger()
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # API Data Models
45
+ # ---------------------------------------------------------------------------
46
+
47
+
48
+ class ScanRequest(BaseModel):
49
+ test_file: str
50
+
51
+
52
+ class HealRequest(BaseModel):
53
+ test_file: str
54
+ dry_run: bool = False
55
+
56
+
57
+ class SimulateRequest(BaseModel):
58
+ broken_selector: str
59
+ dom_html: str
60
+ surrounding_code: str = ""
61
+ page_url: str = "https://example.com/checkout"
62
+
63
+
64
+ class SimulateResponse(BaseModel):
65
+ broken_selector: str
66
+ original_dom_bytes: int
67
+ pruned_dom_bytes: int
68
+ compression_percent: float
69
+ pruned_dom: str
70
+ diagnosis: Diagnosis
71
+ healed_selector: Optional[SelectorFix] = None
72
+ applied_diff: Optional[str] = None
73
+ from_cache: bool = False
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # History Manager
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ class RunHistoryManager:
82
+ """Stores and retrieves historical healing runs in JSON storage."""
83
+
84
+ def __init__(self, storage_dir: Path) -> None:
85
+ self.history_file = storage_dir / "healing_history.json"
86
+ self._ensure_file()
87
+
88
+ def _ensure_file(self) -> None:
89
+ if not self.history_file.is_file():
90
+ self.history_file.write_text("[]", encoding="utf-8")
91
+
92
+ def record_run(self, report: HealingReport, diff: str = "") -> None:
93
+ try:
94
+ runs = self.get_runs()
95
+ record = {
96
+ "test_file": str(report.test_file),
97
+ "total_failures": report.total_failures,
98
+ "healed_count": report.healed_count,
99
+ "skipped_count": report.skipped_count,
100
+ "cache_hits": report.cache_hits,
101
+ "llm_calls": report.llm_calls,
102
+ "verification_passed": report.verification_passed,
103
+ "duration_ms": report.duration_ms,
104
+ "diff": diff,
105
+ "results": [
106
+ {
107
+ "broken_selector": r.failure.broken_selector,
108
+ "line_number": r.failure.line_number,
109
+ "healed_selector": r.fix.new_selector if r.fix else None,
110
+ "strategy": r.fix.strategy.value if r.fix else None,
111
+ "confidence": r.fix.confidence if r.fix else None,
112
+ "from_cache": r.from_cache,
113
+ "skipped": r.skipped,
114
+ "skip_reason": r.skip_reason,
115
+ }
116
+ for r in report.results
117
+ ],
118
+ }
119
+ runs.insert(0, record)
120
+ # Cap at 50 recent runs
121
+ self.history_file.write_text(json.dumps(runs[:50], indent=2), encoding="utf-8")
122
+ except Exception as exc:
123
+ logger.warning("history.record_failed", error=str(exc))
124
+
125
+ def get_runs(self) -> list[dict[str, Any]]:
126
+ try:
127
+ if self.history_file.is_file():
128
+ return json.loads(self.history_file.read_text(encoding="utf-8"))
129
+ except Exception:
130
+ pass
131
+ return []
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # App Factory
136
+ # ---------------------------------------------------------------------------
137
+
138
+
139
+ def create_app(config: Optional[KernHellConfig] = None) -> FastAPI:
140
+ cfg = config or load_config()
141
+ cache_path = Path(os.path.expanduser(cfg.cache.cache_dir)).resolve()
142
+ cache_path.mkdir(parents=True, exist_ok=True)
143
+
144
+ cache_mgr = CacheManager(cfg)
145
+ history_mgr = RunHistoryManager(cache_path)
146
+ orchestrator = HealingOrchestrator(cfg)
147
+ analyzer = FailureAnalyzer(cfg)
148
+ healer = SelectorHealer(cfg)
149
+ patcher = SurgicalPatcher()
150
+
151
+ app = FastAPI(
152
+ title="KernHell Mission Control API",
153
+ description="REST bridge for KernHell Autonomous Self-Healing Testing Engine",
154
+ version=__version__,
155
+ )
156
+
157
+ # Enable CORS for Next.js development and production
158
+ app.add_middleware(
159
+ CORSMiddleware,
160
+ allow_origins=["*"],
161
+ allow_credentials=True,
162
+ allow_methods=["*"],
163
+ allow_headers=["*"],
164
+ )
165
+
166
+ @app.get("/api/health")
167
+ def health():
168
+ return {
169
+ "status": "healthy",
170
+ "app": "KernHell Mission Control",
171
+ "version": __version__,
172
+ }
173
+
174
+ @app.get("/api/stats")
175
+ def get_stats():
176
+ cache_stats = cache_mgr.get_stats()
177
+ runs = history_mgr.get_runs()
178
+
179
+ total_heals = sum(r.get("healed_count", 0) for r in runs)
180
+ total_skipped = sum(r.get("skipped_count", 0) for r in runs)
181
+
182
+ # Developer value calculation: avg 25 mins per broken selector debug
183
+ minutes_saved = total_heals * 25
184
+ hours_saved = round(minutes_saved / 60.0, 1)
185
+ dollars_saved = round(hours_saved * 85.0, 2) # $85/hr QA/dev rate
186
+
187
+ return {
188
+ "total_runs": len(runs),
189
+ "total_healed": total_heals,
190
+ "real_bugs_caught": total_skipped,
191
+ "cache_entries": cache_stats.total_entries,
192
+ "cache_hits": cache_stats.hits,
193
+ "cache_misses": cache_stats.misses,
194
+ "cache_efficiency_pct": cache_stats.hit_ratio,
195
+ "hours_saved": hours_saved,
196
+ "dollars_saved": dollars_saved,
197
+ }
198
+
199
+ @app.get("/api/history")
200
+ def get_history():
201
+ return history_mgr.get_runs()
202
+
203
+ @app.get("/api/cache")
204
+ def list_cache():
205
+ entries = cache_mgr.list_entries()
206
+ return [e.model_dump(mode="json") for e in entries]
207
+
208
+ @app.post("/api/cache/clear")
209
+ def clear_cache():
210
+ count = cache_mgr.clear()
211
+ return {"cleared_count": count, "message": "Cache successfully cleared"}
212
+
213
+ @app.post("/api/scan")
214
+ def trigger_scan(req: ScanRequest):
215
+ target = Path(req.test_file).resolve()
216
+ if not target.is_file():
217
+ raise HTTPException(status_code=404, detail=f"File not found: {req.test_file}")
218
+
219
+ try:
220
+ results = orchestrator.scan(target)
221
+ return {
222
+ "test_file": str(target),
223
+ "failures_detected": len(results),
224
+ "failures": [
225
+ {
226
+ "broken_selector": f.broken_selector,
227
+ "line_number": f.line_number,
228
+ "error_type": f.error_type.value,
229
+ "selector_type": f.selector_type.value,
230
+ "pruned_bytes": p.pruned_size if p else None,
231
+ "compression_pct": p.reduction_pct if p else None,
232
+ }
233
+ for f, p in results
234
+ ],
235
+ }
236
+ except Exception as exc:
237
+ raise HTTPException(status_code=500, detail=str(exc))
238
+
239
+ @app.post("/api/heal")
240
+ def trigger_heal(req: HealRequest):
241
+ target = Path(req.test_file).resolve()
242
+ if not target.is_file():
243
+ raise HTTPException(status_code=404, detail=f"File not found: {req.test_file}")
244
+
245
+ try:
246
+ report = orchestrator.heal(target, dry_run=req.dry_run)
247
+ history_mgr.record_run(report)
248
+ return report.model_dump(mode="json")
249
+ except Exception as exc:
250
+ raise HTTPException(status_code=500, detail=str(exc))
251
+
252
+ @app.post("/api/simulate", response_model=SimulateResponse)
253
+ def simulate_playground(req: SimulateRequest):
254
+ """Interactive Simulation: Prunes DOM, classifies, and heals in real time."""
255
+ pruned = prune_dom(req.dom_html)
256
+
257
+ dummy_ctx = FailureContext(
258
+ test_file=Path("playground_simulation.py"),
259
+ line_number=10,
260
+ broken_selector=req.broken_selector,
261
+ selector_type=SelectorType.CSS,
262
+ error_message=f"Timeout waiting for locator('{req.broken_selector}')",
263
+ live_dom=req.dom_html,
264
+ page_url=req.page_url,
265
+ surrounding_code=req.surrounding_code or f'page.click("{req.broken_selector}")',
266
+ )
267
+
268
+ # 1. Check cache first
269
+ cached_fix, score = cache_mgr.lookup(
270
+ req.broken_selector,
271
+ context=dummy_ctx.surrounding_code,
272
+ page_url=req.page_url,
273
+ )
274
+
275
+ if cached_fix:
276
+ diff_text = None
277
+ if req.surrounding_code:
278
+ try:
279
+ patched = patcher.apply_patch(
280
+ req.surrounding_code,
281
+ req.broken_selector,
282
+ cached_fix.new_selector,
283
+ )
284
+ import difflib
285
+
286
+ diff_lines = list(
287
+ difflib.unified_diff(
288
+ req.surrounding_code.splitlines(),
289
+ patched.splitlines(),
290
+ fromfile="broken_code.py",
291
+ tofile="healed_code.py",
292
+ lineterm="",
293
+ )
294
+ )
295
+ diff_text = "\n".join(diff_lines)
296
+ except Exception:
297
+ pass
298
+
299
+ return SimulateResponse(
300
+ broken_selector=req.broken_selector,
301
+ original_dom_bytes=pruned.original_size,
302
+ pruned_dom_bytes=pruned.pruned_size,
303
+ compression_percent=pruned.reduction_pct,
304
+ pruned_dom=pruned.html,
305
+ diagnosis=Diagnosis(
306
+ failure_type=FailureType.RENAME,
307
+ explanation=f"Found verified fix in Semantic Memory with {int(score * 100)}% cosine similarity.",
308
+ confidence=score,
309
+ ),
310
+ healed_selector=cached_fix,
311
+ applied_diff=diff_text,
312
+ from_cache=True,
313
+ )
314
+
315
+ # 2. AI Failure Diagnosis
316
+ diagnosis = analyzer.diagnose(dummy_ctx, pruned)
317
+
318
+ fix: Optional[SelectorFix] = None
319
+ diff_text = None
320
+
321
+ if diagnosis.failure_type != FailureType.MISSING:
322
+ try:
323
+ fix = healer.suggest_fix(dummy_ctx, diagnosis, pruned)
324
+ # Store in cache for future hits
325
+ cache_mgr.store(
326
+ broken_selector=req.broken_selector,
327
+ fix=fix,
328
+ context=dummy_ctx.surrounding_code,
329
+ page_url=req.page_url,
330
+ )
331
+ if req.surrounding_code:
332
+ try:
333
+ patched = patcher.apply_patch(
334
+ req.surrounding_code,
335
+ req.broken_selector,
336
+ fix.new_selector,
337
+ )
338
+ import difflib
339
+
340
+ diff_lines = list(
341
+ difflib.unified_diff(
342
+ req.surrounding_code.splitlines(),
343
+ patched.splitlines(),
344
+ fromfile="broken_code.py",
345
+ tofile="healed_code.py",
346
+ lineterm="",
347
+ )
348
+ )
349
+ diff_text = "\n".join(diff_lines)
350
+ except Exception:
351
+ pass
352
+ except Exception as exc:
353
+ logger.warning("simulate.healer_failed", error=str(exc))
354
+
355
+ return SimulateResponse(
356
+ broken_selector=req.broken_selector,
357
+ original_dom_bytes=pruned.original_size,
358
+ pruned_dom_bytes=pruned.pruned_size,
359
+ compression_percent=pruned.reduction_pct,
360
+ pruned_dom=pruned.html,
361
+ diagnosis=diagnosis,
362
+ healed_selector=fix,
363
+ applied_diff=diff_text,
364
+ from_cache=False,
365
+ )
366
+
367
+ return app
@@ -0,0 +1,13 @@
1
+ """Cache package — Semantic vector memory for selector healing."""
2
+
3
+ from kernhell.cache.cache_manager import CacheManager
4
+ from kernhell.cache.embedder import SemanticEmbedder, cosine_similarity
5
+ from kernhell.cache.schemas import CacheEntry, CacheStats
6
+
7
+ __all__ = [
8
+ "CacheManager",
9
+ "SemanticEmbedder",
10
+ "CacheEntry",
11
+ "CacheStats",
12
+ "cosine_similarity",
13
+ ]
@@ -0,0 +1,221 @@
1
+ """Semantic Vector Cache Manager — stores and retrieves verified selector fixes.
2
+
3
+ When a selector fails, KernHell checks this cache BEFORE calling any LLM.
4
+ If a semantically equivalent selector + context was healed previously,
5
+ it applies the fix with ZERO latency and ZERO LLM cost.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ import structlog
17
+
18
+ from kernhell.cache.embedder import SemanticEmbedder, cosine_similarity
19
+ from kernhell.cache.schemas import CacheEntry, CacheStats
20
+ from kernhell.core.config import KernHellConfig
21
+ from kernhell.core.models import SelectorFix, SelectorStrategy
22
+
23
+ logger = structlog.get_logger()
24
+
25
+
26
+ class CacheManager:
27
+ """Manages semantic vector memory and similarity retrieval for healed selectors."""
28
+
29
+ def __init__(self, config: KernHellConfig) -> None:
30
+ self.config = config
31
+ self.cache_cfg = config.cache
32
+ self.embedder = SemanticEmbedder(self.cache_cfg.embedding_model)
33
+
34
+ # Resolve cache storage directory
35
+ cache_path = os.path.expanduser(self.cache_cfg.cache_dir)
36
+ self.storage_dir = Path(cache_path).resolve()
37
+ self.storage_dir.mkdir(parents=True, exist_ok=True)
38
+
39
+ self.db_file = self.storage_dir / "semantic_index.json"
40
+ self.stats_file = self.storage_dir / "stats.json"
41
+
42
+ # In-memory storage: id -> (entry, vector)
43
+ self._entries: dict[str, CacheEntry] = {}
44
+ self._vectors: dict[str, list[float]] = {}
45
+ self._stats = CacheStats()
46
+
47
+ self._load()
48
+
49
+ def _make_key(self, selector: str, context: str, page_url: str) -> str:
50
+ """Create a deterministic hash for selector + intent context."""
51
+ raw = f"{selector.strip()}|{context.strip()}|{page_url.strip()}"
52
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
53
+
54
+ def lookup(
55
+ self,
56
+ broken_selector: str,
57
+ context: str = "",
58
+ page_url: str = "",
59
+ ) -> tuple[Optional[SelectorFix], float]:
60
+ """Look up a selector in semantic memory.
61
+
62
+ Returns
63
+ -------
64
+ tuple[Optional[SelectorFix], float]
65
+ (SelectorFix, similarity_score). If similarity < threshold or cache empty,
66
+ returns (None, score).
67
+ """
68
+ if not self.cache_cfg.enabled or not self._entries:
69
+ self._stats.misses += 1
70
+ self._save_stats()
71
+ return None, 0.0
72
+
73
+ query_text = f"{broken_selector} | {context} | {page_url}"
74
+ query_vec = self.embedder.embed(query_text)
75
+
76
+ best_score = -1.0
77
+ best_entry: Optional[CacheEntry] = None
78
+
79
+ for entry_id, vec in self._vectors.items():
80
+ score = cosine_similarity(query_vec, vec)
81
+ if score > best_score:
82
+ best_score = score
83
+ best_entry = self._entries[entry_id]
84
+
85
+ threshold = self.cache_cfg.similarity_threshold
86
+
87
+ if best_entry is not None and best_score >= threshold:
88
+ best_entry.hit_count += 1
89
+ self._stats.hits += 1
90
+ self._save()
91
+ logger.info(
92
+ "cache.hit",
93
+ broken=broken_selector,
94
+ healed=best_entry.new_selector,
95
+ score=round(best_score, 4),
96
+ )
97
+ fix = SelectorFix(
98
+ old_selector=best_entry.broken_selector,
99
+ new_selector=best_entry.new_selector,
100
+ strategy=best_entry.strategy,
101
+ confidence=round(best_entry.confidence, 2),
102
+ reasoning=f"Retrieved from Semantic Memory (similarity {best_score:.2f} >= {threshold:.2f}).",
103
+ verified=True,
104
+ )
105
+ return fix, best_score
106
+
107
+ self._stats.misses += 1
108
+ self._save_stats()
109
+ logger.debug(
110
+ "cache.miss",
111
+ broken=broken_selector,
112
+ best_score=round(best_score, 4),
113
+ threshold=threshold,
114
+ )
115
+ return None, best_score
116
+
117
+ def store(
118
+ self,
119
+ broken_selector: str,
120
+ fix: SelectorFix,
121
+ context: str = "",
122
+ page_url: str = "",
123
+ test_file: str = "",
124
+ ) -> CacheEntry:
125
+ """Store a verified selector fix in semantic memory."""
126
+ if not self.cache_cfg.enabled:
127
+ return CacheEntry(
128
+ id="disabled",
129
+ broken_selector=broken_selector,
130
+ new_selector=fix.new_selector,
131
+ confidence=fix.confidence,
132
+ )
133
+
134
+ entry_id = self._make_key(broken_selector, context, page_url)
135
+ entry_text = f"{broken_selector} | {context} | {page_url}"
136
+ vec = self.embedder.embed(entry_text)
137
+
138
+ entry = CacheEntry(
139
+ id=entry_id,
140
+ broken_selector=broken_selector,
141
+ new_selector=fix.new_selector,
142
+ strategy=fix.strategy,
143
+ confidence=fix.confidence,
144
+ context_snippet=context[:200] if context else "",
145
+ page_url=page_url,
146
+ test_file=test_file,
147
+ verified=fix.verified,
148
+ )
149
+
150
+ self._entries[entry_id] = entry
151
+ self._vectors[entry_id] = vec
152
+ self._stats.total_entries = len(self._entries)
153
+ self._save()
154
+
155
+ logger.info(
156
+ "cache.stored",
157
+ broken=broken_selector,
158
+ healed=fix.new_selector,
159
+ total_entries=len(self._entries),
160
+ )
161
+ return entry
162
+
163
+ def list_entries(self) -> list[CacheEntry]:
164
+ """Return all stored entries in cache."""
165
+ return list(self._entries.values())
166
+
167
+ def clear(self) -> int:
168
+ """Purge all entries from the vector cache."""
169
+ count = len(self._entries)
170
+ self._entries.clear()
171
+ self._vectors.clear()
172
+ self._stats = CacheStats()
173
+ self._save()
174
+ logger.info("cache.cleared", count=count)
175
+ return count
176
+
177
+ def get_stats(self) -> CacheStats:
178
+ """Get cache performance metrics."""
179
+ self._stats.total_entries = len(self._entries)
180
+ return self._stats
181
+
182
+ def _save(self) -> None:
183
+ """Persist cache entries and vectors to disk."""
184
+ data = {
185
+ "entries": {k: v.model_dump(mode="json") for k, v in self._entries.items()},
186
+ "vectors": self._vectors,
187
+ }
188
+ try:
189
+ self.db_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
190
+ self._save_stats()
191
+ except Exception as exc:
192
+ logger.warning("cache.save_failed", error=str(exc))
193
+
194
+ def _save_stats(self) -> None:
195
+ try:
196
+ self.stats_file.write_text(
197
+ self._stats.model_dump_json(indent=2), encoding="utf-8"
198
+ )
199
+ except Exception as exc:
200
+ logger.debug("cache.save_stats_failed", error=str(exc))
201
+
202
+ def _load(self) -> None:
203
+ """Load persisted cache entries and vectors from disk."""
204
+ if not self.db_file.is_file():
205
+ return
206
+
207
+ try:
208
+ raw = self.db_file.read_text(encoding="utf-8")
209
+ data = json.loads(raw)
210
+ for k, entry_dict in data.get("entries", {}).items():
211
+ self._entries[k] = CacheEntry.model_validate(entry_dict)
212
+ self._vectors = data.get("vectors", {})
213
+ self._stats.total_entries = len(self._entries)
214
+
215
+ if self.stats_file.is_file():
216
+ stats_raw = self.stats_file.read_text(encoding="utf-8")
217
+ self._stats = CacheStats.model_validate_json(stats_raw)
218
+
219
+ logger.info("cache.loaded", total_entries=len(self._entries))
220
+ except Exception as exc:
221
+ logger.warning("cache.load_failed", error=str(exc))
@@ -0,0 +1,104 @@
1
+ """Embedding generator and vector distance calculations for semantic caching.
2
+
3
+ Supports:
4
+ 1. `sentence-transformers` (`all-MiniLM-L6-v2`) for deep dense embeddings.
5
+ 2. Built-in 384-dimensional projected subword frequency embedder for zero-dependency offline mode.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import math
12
+ import re
13
+ from typing import Sequence
14
+
15
+ import structlog
16
+
17
+ logger = structlog.get_logger()
18
+
19
+ EMBEDDING_DIM = 384
20
+
21
+
22
+ def cosine_similarity(v1: Sequence[float], v2: Sequence[float]) -> float:
23
+ """Calculate the cosine similarity between two numeric vectors (range -1.0 to 1.0)."""
24
+ if len(v1) != len(v2) or not v1:
25
+ return 0.0
26
+
27
+ dot = sum(a * b for a, b in zip(v1, v2))
28
+ norm_a = math.sqrt(sum(a * a for a in v1))
29
+ norm_b = math.sqrt(sum(b * b for b in v2))
30
+
31
+ if norm_a == 0.0 or norm_b == 0.0:
32
+ return 0.0
33
+
34
+ return max(-1.0, min(1.0, dot / (norm_a * norm_b)))
35
+
36
+
37
+ class SemanticEmbedder:
38
+ """Generates 384-dimensional dense vectors for semantic selector caching."""
39
+
40
+ def __init__(self, model_name: str = "all-MiniLM-L6-v2") -> None:
41
+ self.model_name = model_name
42
+ self._st_model = None
43
+ self._tried_loading_st = False
44
+
45
+ def _get_sentence_transformer(self):
46
+ if not self._tried_loading_st:
47
+ self._tried_loading_st = True
48
+ try:
49
+ from sentence_transformers import SentenceTransformer
50
+ self._st_model = SentenceTransformer(self.model_name)
51
+ logger.info("embedder.sentence_transformers.loaded", model=self.model_name)
52
+ except Exception as exc:
53
+ logger.debug("embedder.sentence_transformers.unavailable", error=str(exc))
54
+ self._st_model = None
55
+ return self._st_model
56
+
57
+ def embed(self, text: str) -> list[float]:
58
+ """Compute 384-dimensional normalized vector for input text."""
59
+ model = self._get_sentence_transformer()
60
+ if model is not None:
61
+ try:
62
+ raw_vec = model.encode(text, normalize_embeddings=True)
63
+ return [float(x) for x in raw_vec]
64
+ except Exception as exc:
65
+ logger.warning("embedder.st_encode_failed.using_projected", error=str(exc))
66
+
67
+ return self._projected_dense_embed(text)
68
+
69
+ def _projected_dense_embed(self, text: str) -> list[float]:
70
+ """Deterministic subword feature hashing into a normalized 384D unit sphere.
71
+
72
+ Provides consistent semantic cosine similarity for identical and overlapping
73
+ selector intents without requiring external neural network weights.
74
+ """
75
+ vec = [0.0] * EMBEDDING_DIM
76
+ cleaned = text.lower().strip()
77
+ if not cleaned:
78
+ return vec
79
+
80
+ # Tokenize by alphanumeric words and punctuation
81
+ tokens = re.findall(r"\w+|[^\w\s]", cleaned)
82
+
83
+ # 1. Word token hashing
84
+ for token in tokens:
85
+ h = int(hashlib.md5(token.encode("utf-8")).hexdigest(), 16)
86
+ dim = h % EMBEDDING_DIM
87
+ sign = 1.0 if (h % 2 == 0) else -1.0
88
+ vec[dim] += sign * 1.5
89
+
90
+ # 2. Character n-gram hashing (captures partial renames like 'buy-now' vs 'purchase-btn')
91
+ for n in (3, 4):
92
+ for i in range(len(cleaned) - n + 1):
93
+ gram = cleaned[i : i + n]
94
+ h = int(hashlib.sha256(gram.encode("utf-8")).hexdigest(), 16)
95
+ dim = h % EMBEDDING_DIM
96
+ sign = 1.0 if (h % 2 == 0) else -1.0
97
+ vec[dim] += sign * 0.5
98
+
99
+ # Normalize vector to unit sphere (L2 norm)
100
+ norm = math.sqrt(sum(x * x for x in vec))
101
+ if norm > 0.0:
102
+ vec = [x / norm for x in vec]
103
+
104
+ return vec