self-healing-elements 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.
- self_healing/__init__.py +10 -0
- self_healing/__main__.py +5 -0
- self_healing/api.py +642 -0
- self_healing/capture.py +113 -0
- self_healing/cli.py +230 -0
- self_healing/driver.py +73 -0
- self_healing/engine.py +455 -0
- self_healing/llm_explainer.py +120 -0
- self_healing/logger.py +91 -0
- self_healing/matcher.py +239 -0
- self_healing/ml_neural_ranker.py +204 -0
- self_healing/ml_neural_trainer.py +171 -0
- self_healing/ml_ranker.py +177 -0
- self_healing/ml_trainer.py +186 -0
- self_healing/py.typed +0 -0
- self_healing/registry.py +221 -0
- self_healing/reporter.py +137 -0
- self_healing/semantic_ranker.py +140 -0
- self_healing_elements-0.1.0.dist-info/METADATA +350 -0
- self_healing_elements-0.1.0.dist-info/RECORD +23 -0
- self_healing_elements-0.1.0.dist-info/WHEEL +5 -0
- self_healing_elements-0.1.0.dist-info/entry_points.txt +2 -0
- self_healing_elements-0.1.0.dist-info/top_level.txt +1 -0
self_healing/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from .driver import SelfHealingDriver
|
|
2
|
+
from .registry import ElementRegistry
|
|
3
|
+
from .engine import HealingEngine
|
|
4
|
+
from .matcher import SimilarityMatcher
|
|
5
|
+
from .capture import capture_element_properties
|
|
6
|
+
from .logger import HealingLogger
|
|
7
|
+
from .reporter import HealingReporter
|
|
8
|
+
from .ml_neural_ranker import NeuralRanker
|
|
9
|
+
from .semantic_ranker import SemanticRanker
|
|
10
|
+
from .api import app
|
self_healing/__main__.py
ADDED
self_healing/api.py
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Optional, Dict, Any, List
|
|
3
|
+
from fastapi import FastAPI, HTTPException, Query, status
|
|
4
|
+
from fastapi.responses import HTMLResponse, PlainTextResponse
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from .registry import ElementRegistry
|
|
8
|
+
from .engine import HealingEngine
|
|
9
|
+
from .logger import HealingLogger
|
|
10
|
+
from .reporter import HealingReporter
|
|
11
|
+
|
|
12
|
+
app = FastAPI(
|
|
13
|
+
title="Self-Healing Element Engine Service",
|
|
14
|
+
description="REST API for real-time element locator self-healing and registry management.",
|
|
15
|
+
version="1.0.0"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Registry, Logger & Engine instantiation
|
|
19
|
+
REGISTRY_PATH = os.environ.get("HEALING_REGISTRY_PATH", "element_registry.db")
|
|
20
|
+
LOG_PATH = os.environ.get("HEALING_LOG_PATH", "healing_events.jsonl")
|
|
21
|
+
ENABLE_LLM = os.environ.get("HEALING_ENABLE_LLM", "false").lower() == "true"
|
|
22
|
+
LLM_MODEL = os.environ.get("HEALING_LLM_MODEL", "ollama/llama3.1")
|
|
23
|
+
|
|
24
|
+
registry = ElementRegistry(REGISTRY_PATH)
|
|
25
|
+
logger = HealingLogger(LOG_PATH)
|
|
26
|
+
engine = HealingEngine(
|
|
27
|
+
registry,
|
|
28
|
+
logger=logger,
|
|
29
|
+
enable_llm_explanations=ENABLE_LLM,
|
|
30
|
+
llm_model=LLM_MODEL
|
|
31
|
+
)
|
|
32
|
+
reporter = HealingReporter(registry=registry, logger=logger)
|
|
33
|
+
|
|
34
|
+
class LocatorModel(BaseModel):
|
|
35
|
+
type: str = Field(..., description="Locator strategy type (e.g. id, css selector, xpath)")
|
|
36
|
+
value: str = Field(..., description="Locator selector value")
|
|
37
|
+
|
|
38
|
+
class HealRequest(BaseModel):
|
|
39
|
+
logical_id: Optional[str] = Field(None, description="Logical ID of element (defaults to '{by}:{value}')")
|
|
40
|
+
by: str = Field(..., description="Original locator strategy")
|
|
41
|
+
value: str = Field(..., description="Original broken locator value")
|
|
42
|
+
page_source: str = Field(..., description="HTML source string of active page")
|
|
43
|
+
|
|
44
|
+
class XAIFeaturesModel(BaseModel):
|
|
45
|
+
tag_score: float = Field(..., description="Exact tag match (0.0 or 1.0)")
|
|
46
|
+
text_score: float = Field(..., description="Levenshtein ratio for text content")
|
|
47
|
+
class_score: float = Field(..., description="Jaccard similarity of class lists")
|
|
48
|
+
attributes_score: float = Field(..., description="Match ratio for key attributes (id, name, etc.)")
|
|
49
|
+
parent_score: float = Field(..., description="Similarity of the parent context")
|
|
50
|
+
|
|
51
|
+
class XAIFeatureImportanceModel(BaseModel):
|
|
52
|
+
tag: float = Field(..., description="Weighted contribution of the tag match to the rule score")
|
|
53
|
+
text: float = Field(..., description="Weighted contribution of the text similarity to the rule score")
|
|
54
|
+
class_: float = Field(..., alias="class", description="Weighted contribution of the class similarity to the rule score")
|
|
55
|
+
parent: float = Field(..., description="Weighted contribution of the parent-context similarity to the rule score")
|
|
56
|
+
attributes: Optional[float] = Field(None, description="Weighted contribution of the key-attribute match to the rule score")
|
|
57
|
+
|
|
58
|
+
class XAIScoresModel(BaseModel):
|
|
59
|
+
rule_score: float = Field(..., description="Heuristic score from matcher")
|
|
60
|
+
ml_score: float = Field(..., description="XGBoost probability")
|
|
61
|
+
nn_score: float = Field(..., description="PyTorch probability")
|
|
62
|
+
semantic_score: float = Field(..., description="SentenceTransformer cosine similarity")
|
|
63
|
+
final_score: float = Field(..., description="The final weighted blend score")
|
|
64
|
+
|
|
65
|
+
class XAIDecisionBreakdownModel(BaseModel):
|
|
66
|
+
rule_score: float = Field(..., description="Rule-based score before ML blending")
|
|
67
|
+
xgboost_score: float = Field(..., description="XGBoost model score")
|
|
68
|
+
neural_score: float = Field(..., description="Neural score")
|
|
69
|
+
semantic_score: float = Field(..., description="Semantic similarity score")
|
|
70
|
+
final_blended_score: float = Field(..., description="Final weighted blended score used for healing")
|
|
71
|
+
|
|
72
|
+
class XAIBreakdownModel(BaseModel):
|
|
73
|
+
features: XAIFeaturesModel = Field(..., description="Raw feature match scores per candidate")
|
|
74
|
+
feature_importance: XAIFeatureImportanceModel = Field(..., description="Weighted feature contribution scores per candidate")
|
|
75
|
+
decision_breakdown: XAIDecisionBreakdownModel = Field(..., description="Structured decision breakdown for rule, XGBoost, neural, semantic, and final scores")
|
|
76
|
+
scores: XAIScoresModel = Field(..., description="Decision breakdown of sub-scores")
|
|
77
|
+
confidence_tier: str = Field(..., description="HIGH, MEDIUM, or LOW confidence")
|
|
78
|
+
reasoning: str = Field(..., description="Reasoning for the confidence tier")
|
|
79
|
+
|
|
80
|
+
class HealResponse(BaseModel):
|
|
81
|
+
status: str = Field(..., description="Result status: HEALED, SUGGESTED, FAILED, or NOT_FOUND")
|
|
82
|
+
logical_id: str = Field(..., description="Logical ID of the element")
|
|
83
|
+
original_locator: LocatorModel = Field(..., description="Original locator strategy and value")
|
|
84
|
+
healed_locator: Optional[LocatorModel] = Field(None, description="Healed locator strategy and value")
|
|
85
|
+
confidence_score: float = Field(..., description="Confidence score between 0.0 and 1.0")
|
|
86
|
+
explanation: str = Field(..., description="Diagnostic summary explaining matching score")
|
|
87
|
+
xai_breakdown: Optional[XAIBreakdownModel] = Field(None, description="Structured explanation of decision factors")
|
|
88
|
+
|
|
89
|
+
class HealthResponse(BaseModel):
|
|
90
|
+
status: str = Field("ok", description="Service status")
|
|
91
|
+
version: str = Field("1.0.0", description="API version")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.get("/", response_class=HTMLResponse, include_in_schema=False, tags=["Dashboard"])
|
|
95
|
+
def dashboard_root():
|
|
96
|
+
"""Serves a single-page dashboard for live healing activity monitoring."""
|
|
97
|
+
return HTMLResponse(content='''
|
|
98
|
+
<!doctype html>
|
|
99
|
+
<html lang="en">
|
|
100
|
+
<head>
|
|
101
|
+
<meta charset="utf-8" />
|
|
102
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
103
|
+
<title>Self-Healing Dashboard</title>
|
|
104
|
+
<style>
|
|
105
|
+
:root {
|
|
106
|
+
--bg: #0f172a;
|
|
107
|
+
--panel: #111827;
|
|
108
|
+
--panel-alt: #1f2937;
|
|
109
|
+
--muted: #94a3b8;
|
|
110
|
+
--text: #e5eefb;
|
|
111
|
+
--accent: #60a5fa;
|
|
112
|
+
--green: #22c55e;
|
|
113
|
+
--amber: #f59e0b;
|
|
114
|
+
--red: #ef4444;
|
|
115
|
+
--blue: #3b82f6;
|
|
116
|
+
--border: rgba(148, 163, 184, 0.18);
|
|
117
|
+
--shadow: 0 10px 30px rgba(15, 23, 42, 0.35);
|
|
118
|
+
}
|
|
119
|
+
* { box-sizing: border-box; }
|
|
120
|
+
html, body {
|
|
121
|
+
margin: 0;
|
|
122
|
+
min-height: 100%;
|
|
123
|
+
background: linear-gradient(180deg, #0b1120 0%, #111827 100%);
|
|
124
|
+
color: var(--text);
|
|
125
|
+
font-family: "Segoe UI", Arial, sans-serif;
|
|
126
|
+
}
|
|
127
|
+
body {
|
|
128
|
+
padding: 24px;
|
|
129
|
+
}
|
|
130
|
+
.container {
|
|
131
|
+
max-width: 1400px;
|
|
132
|
+
margin: 0 auto;
|
|
133
|
+
}
|
|
134
|
+
.topbar {
|
|
135
|
+
display: flex;
|
|
136
|
+
justify-content: space-between;
|
|
137
|
+
align-items: center;
|
|
138
|
+
margin-bottom: 20px;
|
|
139
|
+
}
|
|
140
|
+
h1 {
|
|
141
|
+
margin: 0;
|
|
142
|
+
font-size: 2rem;
|
|
143
|
+
font-weight: 700;
|
|
144
|
+
}
|
|
145
|
+
.status-chip {
|
|
146
|
+
padding: 8px 12px;
|
|
147
|
+
border-radius: 999px;
|
|
148
|
+
background: rgba(34, 197, 94, 0.12);
|
|
149
|
+
color: #bbf7d0;
|
|
150
|
+
border: 1px solid rgba(34, 197, 94, 0.3);
|
|
151
|
+
font-size: 0.8rem;
|
|
152
|
+
letter-spacing: 0.04em;
|
|
153
|
+
display: inline-flex;
|
|
154
|
+
align-items: center;
|
|
155
|
+
gap: 8px;
|
|
156
|
+
}
|
|
157
|
+
.status-dot {
|
|
158
|
+
width: 8px;
|
|
159
|
+
height: 8px;
|
|
160
|
+
border-radius: 50%;
|
|
161
|
+
background: var(--green);
|
|
162
|
+
box-shadow: 0 0 12px rgba(34, 197, 94, 0.9);
|
|
163
|
+
}
|
|
164
|
+
.summary-grid {
|
|
165
|
+
display: grid;
|
|
166
|
+
grid-template-columns: repeat(4, minmax(180px, 1fr));
|
|
167
|
+
gap: 18px;
|
|
168
|
+
margin-bottom: 22px;
|
|
169
|
+
}
|
|
170
|
+
.card {
|
|
171
|
+
background: rgba(17, 24, 39, 0.9);
|
|
172
|
+
border: 1px solid var(--border);
|
|
173
|
+
border-radius: 16px;
|
|
174
|
+
box-shadow: var(--shadow);
|
|
175
|
+
}
|
|
176
|
+
.metric-card {
|
|
177
|
+
padding: 18px 18px 14px;
|
|
178
|
+
}
|
|
179
|
+
.metric-label {
|
|
180
|
+
color: var(--muted);
|
|
181
|
+
font-size: 0.8rem;
|
|
182
|
+
margin-bottom: 10px;
|
|
183
|
+
text-transform: uppercase;
|
|
184
|
+
letter-spacing: 0.08em;
|
|
185
|
+
}
|
|
186
|
+
.metric-value {
|
|
187
|
+
font-size: clamp(1.6rem, 2vw, 2.3rem);
|
|
188
|
+
font-weight: 700;
|
|
189
|
+
margin: 0;
|
|
190
|
+
}
|
|
191
|
+
.metric-sub {
|
|
192
|
+
color: var(--muted);
|
|
193
|
+
font-size: 0.82rem;
|
|
194
|
+
margin-top: 8px;
|
|
195
|
+
}
|
|
196
|
+
.distribution {
|
|
197
|
+
display: flex;
|
|
198
|
+
flex-wrap: wrap;
|
|
199
|
+
gap: 10px;
|
|
200
|
+
margin-top: 12px;
|
|
201
|
+
}
|
|
202
|
+
.pill {
|
|
203
|
+
display: inline-flex;
|
|
204
|
+
align-items: center;
|
|
205
|
+
gap: 8px;
|
|
206
|
+
border-radius: 999px;
|
|
207
|
+
padding: 7px 10px;
|
|
208
|
+
border: 1px solid var(--border);
|
|
209
|
+
font-size: 0.8rem;
|
|
210
|
+
color: var(--text);
|
|
211
|
+
}
|
|
212
|
+
.pill .dot {
|
|
213
|
+
width: 8px;
|
|
214
|
+
height: 8px;
|
|
215
|
+
border-radius: 50%;
|
|
216
|
+
display: inline-block;
|
|
217
|
+
}
|
|
218
|
+
.high { background: rgba(34, 197, 94, 0.12); }
|
|
219
|
+
.medium { background: rgba(245, 158, 11, 0.12); }
|
|
220
|
+
.low { background: rgba(239, 68, 68, 0.12); }
|
|
221
|
+
.main-grid {
|
|
222
|
+
display: grid;
|
|
223
|
+
grid-template-columns: 1.8fr 1fr;
|
|
224
|
+
gap: 20px;
|
|
225
|
+
}
|
|
226
|
+
.panel {
|
|
227
|
+
padding: 18px;
|
|
228
|
+
}
|
|
229
|
+
.panel h2 {
|
|
230
|
+
margin: 0 0 18px;
|
|
231
|
+
font-size: 1.1rem;
|
|
232
|
+
}
|
|
233
|
+
table {
|
|
234
|
+
width: 100%;
|
|
235
|
+
border-collapse: collapse;
|
|
236
|
+
font-size: 0.9rem;
|
|
237
|
+
}
|
|
238
|
+
th, td {
|
|
239
|
+
text-align: left;
|
|
240
|
+
padding: 11px 10px;
|
|
241
|
+
border-bottom: 1px solid var(--border);
|
|
242
|
+
vertical-align: top;
|
|
243
|
+
}
|
|
244
|
+
th {
|
|
245
|
+
color: var(--muted);
|
|
246
|
+
font-size: 0.75rem;
|
|
247
|
+
letter-spacing: 0.05em;
|
|
248
|
+
text-transform: uppercase;
|
|
249
|
+
font-weight: 600;
|
|
250
|
+
}
|
|
251
|
+
tbody tr:hover {
|
|
252
|
+
background: rgba(148, 163, 184, 0.04);
|
|
253
|
+
}
|
|
254
|
+
.status-badge {
|
|
255
|
+
display: inline-block;
|
|
256
|
+
padding: 5px 8px;
|
|
257
|
+
border-radius: 999px;
|
|
258
|
+
font-size: 0.73rem;
|
|
259
|
+
font-weight: 600;
|
|
260
|
+
letter-spacing: 0.04em;
|
|
261
|
+
text-transform: uppercase;
|
|
262
|
+
}
|
|
263
|
+
.status-healed { background: rgba(34, 197, 94, 0.14); color: #bbf7d0; }
|
|
264
|
+
.status-suggested { background: rgba(245, 158, 11, 0.14); color: #fcd34d; }
|
|
265
|
+
.status-failed { background: rgba(239, 68, 68, 0.14); color: #fca5a5; }
|
|
266
|
+
.registry-list {
|
|
267
|
+
display: grid;
|
|
268
|
+
gap: 12px;
|
|
269
|
+
max-height: 640px;
|
|
270
|
+
overflow-y: auto;
|
|
271
|
+
padding-right: 4px;
|
|
272
|
+
}
|
|
273
|
+
.registry-item {
|
|
274
|
+
border: 1px solid var(--border);
|
|
275
|
+
border-radius: 12px;
|
|
276
|
+
background: rgba(15, 23, 42, 0.45);
|
|
277
|
+
padding: 12px;
|
|
278
|
+
}
|
|
279
|
+
.registry-head {
|
|
280
|
+
display: flex;
|
|
281
|
+
justify-content: space-between;
|
|
282
|
+
align-items: center;
|
|
283
|
+
gap: 8px;
|
|
284
|
+
margin-bottom: 8px;
|
|
285
|
+
}
|
|
286
|
+
.logical-id {
|
|
287
|
+
font-weight: 700;
|
|
288
|
+
font-size: 0.95rem;
|
|
289
|
+
word-break: break-word;
|
|
290
|
+
}
|
|
291
|
+
.locator-box {
|
|
292
|
+
color: #bfdbfe;
|
|
293
|
+
font-size: 0.82rem;
|
|
294
|
+
background: rgba(59, 130, 246, 0.12);
|
|
295
|
+
border: 1px solid rgba(59, 130, 246, 0.25);
|
|
296
|
+
padding: 6px 8px;
|
|
297
|
+
border-radius: 8px;
|
|
298
|
+
display: inline-block;
|
|
299
|
+
margin-bottom: 8px;
|
|
300
|
+
}
|
|
301
|
+
.meta {
|
|
302
|
+
display: flex;
|
|
303
|
+
flex-wrap: wrap;
|
|
304
|
+
gap: 8px;
|
|
305
|
+
color: var(--muted);
|
|
306
|
+
font-size: 0.76rem;
|
|
307
|
+
}
|
|
308
|
+
.meta span {
|
|
309
|
+
background: rgba(148, 163, 184, 0.09);
|
|
310
|
+
border: 1px solid var(--border);
|
|
311
|
+
border-radius: 999px;
|
|
312
|
+
padding: 4px 8px;
|
|
313
|
+
}
|
|
314
|
+
.empty {
|
|
315
|
+
color: var(--muted);
|
|
316
|
+
padding: 18px 0;
|
|
317
|
+
}
|
|
318
|
+
.toolbar {
|
|
319
|
+
display: flex;
|
|
320
|
+
justify-content: space-between;
|
|
321
|
+
align-items: center;
|
|
322
|
+
gap: 10px;
|
|
323
|
+
margin-bottom: 12px;
|
|
324
|
+
}
|
|
325
|
+
.search {
|
|
326
|
+
width: 100%;
|
|
327
|
+
background: rgba(15, 23, 42, 0.8);
|
|
328
|
+
color: var(--text);
|
|
329
|
+
border: 1px solid var(--border);
|
|
330
|
+
border-radius: 10px;
|
|
331
|
+
padding: 10px 12px;
|
|
332
|
+
font-size: 0.9rem;
|
|
333
|
+
}
|
|
334
|
+
.muted {
|
|
335
|
+
color: var(--muted);
|
|
336
|
+
}
|
|
337
|
+
@media (max-width: 980px) {
|
|
338
|
+
.main-grid { grid-template-columns: 1fr; }
|
|
339
|
+
.summary-grid { grid-template-columns: repeat(2, minmax(180px, 1fr)); }
|
|
340
|
+
}
|
|
341
|
+
@media (max-width: 560px) {
|
|
342
|
+
body { padding: 14px; }
|
|
343
|
+
.summary-grid { grid-template-columns: 1fr; }
|
|
344
|
+
}
|
|
345
|
+
</style>
|
|
346
|
+
</head>
|
|
347
|
+
<body>
|
|
348
|
+
<div class="container">
|
|
349
|
+
<div class="topbar">
|
|
350
|
+
<h1>Self-Healing Dashboard</h1>
|
|
351
|
+
<div class="status-chip"><span class="status-dot"></span> Live • auto-refresh 30s</div>
|
|
352
|
+
</div>
|
|
353
|
+
|
|
354
|
+
<section class="summary-grid">
|
|
355
|
+
<div class="card metric-card">
|
|
356
|
+
<div class="metric-label">Total Heals</div>
|
|
357
|
+
<div id="total-heals" class="metric-value">0</div>
|
|
358
|
+
<div class="metric-sub">Successful repair events</div>
|
|
359
|
+
</div>
|
|
360
|
+
<div class="card metric-card">
|
|
361
|
+
<div class="metric-label">Success Rate</div>
|
|
362
|
+
<div id="success-rate" class="metric-value">0%</div>
|
|
363
|
+
<div class="metric-sub">HEALED vs all events</div>
|
|
364
|
+
</div>
|
|
365
|
+
<div class="card metric-card">
|
|
366
|
+
<div class="metric-label">High Confidence</div>
|
|
367
|
+
<div id="high-count" class="metric-value">0</div>
|
|
368
|
+
<div class="metric-sub">Auto-healing confidence</div>
|
|
369
|
+
</div>
|
|
370
|
+
<div class="card metric-card">
|
|
371
|
+
<div class="metric-label">Confidence Mix</div>
|
|
372
|
+
<div class="distribution" id="confidence-mix"></div>
|
|
373
|
+
</div>
|
|
374
|
+
</section>
|
|
375
|
+
|
|
376
|
+
<section class="main-grid">
|
|
377
|
+
<div class="card panel">
|
|
378
|
+
<h2>Healing Events</h2>
|
|
379
|
+
<div style="overflow:auto;">
|
|
380
|
+
<table>
|
|
381
|
+
<thead>
|
|
382
|
+
<tr>
|
|
383
|
+
<th>Time</th>
|
|
384
|
+
<th>Status</th>
|
|
385
|
+
<th>Logical ID</th>
|
|
386
|
+
<th>Locator</th>
|
|
387
|
+
<th>Confidence</th>
|
|
388
|
+
<th>Explanation</th>
|
|
389
|
+
</tr>
|
|
390
|
+
</thead>
|
|
391
|
+
<tbody id="events-body"></tbody>
|
|
392
|
+
</table>
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
<div class="card panel">
|
|
397
|
+
<div class="toolbar">
|
|
398
|
+
<h2 style="margin:0;">Registry Viewer</h2>
|
|
399
|
+
</div>
|
|
400
|
+
<input id="registry-search" class="search" type="text" placeholder="Search logical id or locator..." />
|
|
401
|
+
<div id="registry-body" class="registry-list" style="margin-top: 12px;"></div>
|
|
402
|
+
</div>
|
|
403
|
+
</section>
|
|
404
|
+
</div>
|
|
405
|
+
|
|
406
|
+
<script>
|
|
407
|
+
const eventsBody = document.getElementById('events-body');
|
|
408
|
+
const registryBody = document.getElementById('registry-body');
|
|
409
|
+
const registrySearch = document.getElementById('registry-search');
|
|
410
|
+
const totalHealsEl = document.getElementById('total-heals');
|
|
411
|
+
const successRateEl = document.getElementById('success-rate');
|
|
412
|
+
const highCountEl = document.getElementById('high-count');
|
|
413
|
+
const confMixEl = document.getElementById('confidence-mix');
|
|
414
|
+
|
|
415
|
+
function escapeHtml(value) {
|
|
416
|
+
if (value === null || value === undefined) return '';
|
|
417
|
+
return String(value)
|
|
418
|
+
.replace(/&/g, '&')
|
|
419
|
+
.replace(/</g, '<')
|
|
420
|
+
.replace(/>/g, '>')
|
|
421
|
+
.replace(/"/g, '"')
|
|
422
|
+
.replace(/'/g, ''');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function formatTimestamp(value) {
|
|
426
|
+
if (!value) return '—';
|
|
427
|
+
const date = new Date(value);
|
|
428
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
429
|
+
return date.toLocaleString([], { dateStyle: 'short', timeStyle: 'short' });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function formatLocator(locator) {
|
|
433
|
+
if (!locator) return '—';
|
|
434
|
+
return `${locator.type || 'unknown'}='${locator.value || ''}'`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function scoreTier(score) {
|
|
438
|
+
if (score >= 0.75) return 'HIGH';
|
|
439
|
+
if (score >= 0.5) return 'MEDIUM';
|
|
440
|
+
return 'LOW';
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function buildSummary(events) {
|
|
444
|
+
const total = events.length;
|
|
445
|
+
const healed = events.filter(e => (e.status || '').toUpperCase() === 'HEALED').length;
|
|
446
|
+
const successRate = total ? ((healed / total) * 100).toFixed(1) : '0.0';
|
|
447
|
+
const counts = { HIGH: 0, MEDIUM: 0, LOW: 0 };
|
|
448
|
+
|
|
449
|
+
events.forEach(event => {
|
|
450
|
+
const score = Number(event.confidence_score || 0);
|
|
451
|
+
const tier = scoreTier(score);
|
|
452
|
+
counts[tier] += 1;
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
totalHealsEl.textContent = String(healed);
|
|
456
|
+
successRateEl.textContent = `${successRate}%`;
|
|
457
|
+
highCountEl.textContent = String(counts.HIGH);
|
|
458
|
+
confMixEl.innerHTML = `
|
|
459
|
+
<span class="pill high"><span class="dot" style="background:#22c55e"></span>HIGH ${counts.HIGH}</span>
|
|
460
|
+
<span class="pill medium"><span class="dot" style="background:#f59e0b"></span>MEDIUM ${counts.MEDIUM}</span>
|
|
461
|
+
<span class="pill low"><span class="dot" style="background:#ef4444"></span>LOW ${counts.LOW}</span>
|
|
462
|
+
`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function renderEvents(events) {
|
|
466
|
+
if (!events.length) {
|
|
467
|
+
eventsBody.innerHTML = `<tr><td colspan="6" class="empty">No healing events yet.</td></tr>`;
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const rows = events.slice().reverse().map((event) => {
|
|
472
|
+
const status = (event.status || 'UNKNOWN').toUpperCase();
|
|
473
|
+
const score = Number(event.confidence_score || 0).toFixed(3);
|
|
474
|
+
const statusClass = status === 'HEALED' ? 'status-healed' : status === 'SUGGESTED' ? 'status-suggested' : 'status-failed';
|
|
475
|
+
const original = event.original_locator || {};
|
|
476
|
+
const healed = event.healed_locator || {};
|
|
477
|
+
const locator = healed && healed.type ? formatLocator(healed) : formatLocator(original);
|
|
478
|
+
|
|
479
|
+
return `
|
|
480
|
+
<tr>
|
|
481
|
+
<td>${escapeHtml(formatTimestamp(event.timestamp))}</td>
|
|
482
|
+
<td><span class="status-badge ${statusClass}">${escapeHtml(status)}</span></td>
|
|
483
|
+
<td>${escapeHtml(event.logical_id || '—')}</td>
|
|
484
|
+
<td>${escapeHtml(locator)}</td>
|
|
485
|
+
<td>${escapeHtml(score)}</td>
|
|
486
|
+
<td>${escapeHtml(event.explanation || '—')}</td>
|
|
487
|
+
</tr>
|
|
488
|
+
`;
|
|
489
|
+
}).join('');
|
|
490
|
+
|
|
491
|
+
eventsBody.innerHTML = rows;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function renderRegistry(registry) {
|
|
495
|
+
const items = Object.entries(registry || {});
|
|
496
|
+
const query = registrySearch.value.trim().toLowerCase();
|
|
497
|
+
const filtered = items.filter(([logicalId, profile]) => {
|
|
498
|
+
if (!query) return true;
|
|
499
|
+
const primary = profile?.primary_locator || {};
|
|
500
|
+
const attrs = profile?.attributes || {};
|
|
501
|
+
const haystack = [
|
|
502
|
+
logicalId,
|
|
503
|
+
primary.type,
|
|
504
|
+
primary.value,
|
|
505
|
+
attrs.tag_name,
|
|
506
|
+
attrs.id,
|
|
507
|
+
attrs.text,
|
|
508
|
+
attrs.class ? attrs.class.join(' ') : ''
|
|
509
|
+
].join(' ').toLowerCase();
|
|
510
|
+
return haystack.includes(query);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
if (!filtered.length) {
|
|
514
|
+
registryBody.innerHTML = '<div class="empty">No registry entries match the current filter.</div>';
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
registryBody.innerHTML = filtered.map(([logicalId, profile]) => {
|
|
519
|
+
const primary = profile?.primary_locator || {};
|
|
520
|
+
const attrs = profile?.attributes || {};
|
|
521
|
+
const ctx = profile?.context || {};
|
|
522
|
+
const historyCount = Array.isArray(profile?.history) ? profile.history.length : 0;
|
|
523
|
+
const tag = attrs.tag_name || 'unknown';
|
|
524
|
+
const id = attrs.id || '—';
|
|
525
|
+
const text = attrs.text || '—';
|
|
526
|
+
const classes = Array.isArray(attrs.class) ? attrs.class.join(', ') : (attrs.class || '—');
|
|
527
|
+
const parent = ctx.parent_tag ? `${ctx.parent_tag}` : '—';
|
|
528
|
+
|
|
529
|
+
return `
|
|
530
|
+
<div class="registry-item">
|
|
531
|
+
<div class="registry-head">
|
|
532
|
+
<div class="logical-id">${escapeHtml(logicalId)}</div>
|
|
533
|
+
<span class="status-badge status-healed">${historyCount} history</span>
|
|
534
|
+
</div>
|
|
535
|
+
<div class="locator-box">${escapeHtml(`${primary.type || 'locator'}='${primary.value || ''}'`)}</div>
|
|
536
|
+
<div class="meta">
|
|
537
|
+
<span>tag: ${escapeHtml(tag)}</span>
|
|
538
|
+
<span>id: ${escapeHtml(id)}</span>
|
|
539
|
+
<span>parent: ${escapeHtml(parent)}</span>
|
|
540
|
+
</div>
|
|
541
|
+
<div style="margin-top:10px; color:var(--muted); font-size:0.8rem;">
|
|
542
|
+
<div><strong>Text:</strong> ${escapeHtml(text)}</div>
|
|
543
|
+
<div><strong>Class:</strong> ${escapeHtml(classes)}</div>
|
|
544
|
+
</div>
|
|
545
|
+
</div>
|
|
546
|
+
`;
|
|
547
|
+
}).join('');
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function fetchDashboard() {
|
|
551
|
+
try {
|
|
552
|
+
const [logsResponse, registryResponse] = await Promise.all([
|
|
553
|
+
fetch('/logs?n=25'),
|
|
554
|
+
fetch('/registry')
|
|
555
|
+
]);
|
|
556
|
+
|
|
557
|
+
if (!logsResponse.ok || !registryResponse.ok) {
|
|
558
|
+
throw new Error('One or more data requests failed.');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const logs = await logsResponse.json();
|
|
562
|
+
const registry = await registryResponse.json();
|
|
563
|
+
buildSummary(Array.isArray(logs) ? logs : []);
|
|
564
|
+
renderEvents(Array.isArray(logs) ? logs : []);
|
|
565
|
+
renderRegistry(registry || {});
|
|
566
|
+
} catch (error) {
|
|
567
|
+
eventsBody.innerHTML = `<tr><td colspan="6" class="empty">Unable to load dashboard data. ${escapeHtml(error.message || 'Unknown error')}</td></tr>`;
|
|
568
|
+
registryBody.innerHTML = '<div class="empty">Registry unavailable.</div>';
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
registrySearch.addEventListener('input', () => {
|
|
573
|
+
fetch('/registry')
|
|
574
|
+
.then(response => response.json())
|
|
575
|
+
.then(data => renderRegistry(data || {}))
|
|
576
|
+
.catch(() => renderRegistry({}));
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
fetchDashboard();
|
|
580
|
+
setInterval(fetchDashboard, 30000);
|
|
581
|
+
</script>
|
|
582
|
+
</body>
|
|
583
|
+
</html>
|
|
584
|
+
''' )
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
@app.get("/health", response_model=HealthResponse, tags=["Health"])
|
|
588
|
+
def health_check():
|
|
589
|
+
"""Simple health check endpoint."""
|
|
590
|
+
return HealthResponse(status="ok", version="1.0.0")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@app.get("/registry", response_model=Dict[str, Any], tags=["Registry"])
|
|
594
|
+
def get_registry():
|
|
595
|
+
"""Returns full element registry state including historical heal logs."""
|
|
596
|
+
registry.load()
|
|
597
|
+
return registry.registry
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
@app.post("/heal", response_model=HealResponse, tags=["Healing"])
|
|
601
|
+
def heal_element(request: HealRequest):
|
|
602
|
+
"""
|
|
603
|
+
Accepts a broken locator and page HTML source, scans for candidates using similarity matching,
|
|
604
|
+
updates registry history, and returns the healed locator + confidence score.
|
|
605
|
+
"""
|
|
606
|
+
if not request.by or not request.value:
|
|
607
|
+
raise HTTPException(
|
|
608
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
609
|
+
detail="Fields 'by' and 'value' must not be empty."
|
|
610
|
+
)
|
|
611
|
+
if not request.page_source or not request.page_source.strip():
|
|
612
|
+
raise HTTPException(
|
|
613
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
614
|
+
detail="Field 'page_source' must not be empty."
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
result = engine.heal_from_html(
|
|
618
|
+
logical_id=request.logical_id,
|
|
619
|
+
original_by=request.by,
|
|
620
|
+
original_value=request.value,
|
|
621
|
+
page_source=request.page_source
|
|
622
|
+
)
|
|
623
|
+
return result
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
@app.get("/report", response_class=PlainTextResponse, tags=["Reporting"])
|
|
627
|
+
def get_report():
|
|
628
|
+
"""
|
|
629
|
+
Generates and returns a Markdown diagnostic report summarising healing rates,
|
|
630
|
+
confidence scores, and per-element history across the entire registry.
|
|
631
|
+
"""
|
|
632
|
+
content = reporter.generate_report()
|
|
633
|
+
return PlainTextResponse(content=content, media_type="text/markdown")
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
@app.get("/logs", response_model=List[Dict[str, Any]], tags=["Reporting"])
|
|
637
|
+
def get_logs(n: int = Query(default=20, ge=1, le=500, description="Number of recent events to return")):
|
|
638
|
+
"""
|
|
639
|
+
Returns the last *n* healing events recorded in the structured JSONL log
|
|
640
|
+
(default 20, max 500).
|
|
641
|
+
"""
|
|
642
|
+
return logger.read_events(last_n=n)
|