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.
- kernhell/__init__.py +8 -0
- kernhell/__main__.py +6 -0
- kernhell/analyzer/analyzer.py +178 -0
- kernhell/analyzer/healer.py +249 -0
- kernhell/analyzer/prompts.py +89 -0
- kernhell/api/__init__.py +5 -0
- kernhell/api/server.py +367 -0
- kernhell/cache/__init__.py +13 -0
- kernhell/cache/cache_manager.py +221 -0
- kernhell/cache/embedder.py +104 -0
- kernhell/cache/schemas.py +42 -0
- kernhell/cli/__init__.py +1 -0
- kernhell/cli/app.py +62 -0
- kernhell/cli/commands/__init__.py +1 -0
- kernhell/cli/commands/cache.py +96 -0
- kernhell/cli/commands/config.py +111 -0
- kernhell/cli/commands/dashboard.py +55 -0
- kernhell/cli/commands/heal.py +141 -0
- kernhell/cli/commands/scan.py +87 -0
- kernhell/cli/ui.py +176 -0
- kernhell/core/__init__.py +1 -0
- kernhell/core/config.py +219 -0
- kernhell/core/exceptions.py +104 -0
- kernhell/core/models.py +174 -0
- kernhell/core/orchestrator.py +245 -0
- kernhell/patcher/patcher.py +215 -0
- kernhell/patcher/verifier.py +82 -0
- kernhell/pruner/__init__.py +1 -0
- kernhell/pruner/dom_pruner.py +240 -0
- kernhell/reporter/__init__.py +5 -0
- kernhell/reporter/reporter.py +124 -0
- kernhell/scanner/__init__.py +1 -0
- kernhell/scanner/parser.py +276 -0
- kernhell/scanner/runner.py +242 -0
- kernhell/utils/__init__.py +1 -0
- kernhell/utils/file_utils.py +117 -0
- kernhell/utils/logger.py +78 -0
- kernhell-0.1.0.dist-info/METADATA +245 -0
- kernhell-0.1.0.dist-info/RECORD +42 -0
- kernhell-0.1.0.dist-info/WHEEL +4 -0
- kernhell-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|