raggiecode 0.2.1__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.
- Agent/__init__.py +0 -0
- Agent/agent.py +891 -0
- Agent/chat_history_db.py +1500 -0
- Agent/command.py +49 -0
- Agent/config.py +46 -0
- Agent/effort_levels.py +33 -0
- Agent/git_manager.py +727 -0
- Agent/tools.py +35 -0
- Commands/__init__.py +18 -0
- Commands/effort.py +42 -0
- Commands/global_todo.py +23 -0
- Commands/help.py +22 -0
- Commands/reasoning.py +24 -0
- Commands/redo.py +11 -0
- Commands/reindex.py +27 -0
- Commands/shell.py +28 -0
- Commands/stream.py +24 -0
- Commands/undo.py +13 -0
- Commands/unlimited_effort.py +8 -0
- Commands/window_size.py +29 -0
- RAG/__init__.py +0 -0
- RAG/document.py +119 -0
- RAG/find.py +408 -0
- RAG/graph.py +231 -0
- Tools/GetFileCodeStructure.py +43 -0
- Tools/GetSymbolSourceCode.py +27 -0
- Tools/__init__.py +39 -0
- Tools/ask_user.py +102 -0
- Tools/dispatch_subagent.py +215 -0
- Tools/document.py +35 -0
- Tools/edit_symbol.py +250 -0
- Tools/fuzzy_search.py +119 -0
- Tools/list_dir.py +51 -0
- Tools/read.py +49 -0
- Tools/read_image.py +75 -0
- Tools/remove.py +75 -0
- Tools/replace.py +305 -0
- Tools/search.py +41 -0
- Tools/shell.py +149 -0
- Tools/shell_kill.py +87 -0
- Tools/temp_background_service.py +113 -0
- Tools/todo_list.py +481 -0
- Tools/utils.py +116 -0
- Tools/view_changes.py +179 -0
- Tools/walk_call_tree.py +30 -0
- Tools/web_fetch.py +175 -0
- Tools/web_search.py +69 -0
- Tools/write.py +48 -0
- cli.py +111 -0
- config/__init__.py +0 -0
- config/coder_system_prompt.md +119 -0
- config/roles.json +43 -0
- config/tools.json +709 -0
- indexing/__init__.py +0 -0
- indexing/cli.py +128 -0
- indexing/code_index_sdk.py +832 -0
- indexing/code_indexer.py +1763 -0
- indexing/db_schema.py +396 -0
- indexing/export_to_json.py +346 -0
- indexing/extractors.py +189 -0
- indexing/file_utils.py +97 -0
- indexing/frontend/__init__.py +0 -0
- indexing/frontend/css_extractor.py +195 -0
- indexing/frontend/css_parser.py +387 -0
- indexing/frontend/css_selector_utils.py +226 -0
- indexing/frontend/edit_safety.py +573 -0
- indexing/frontend/graph.py +838 -0
- indexing/frontend/html_extractor.py +496 -0
- indexing/frontend/html_parser.py +314 -0
- indexing/frontend/jsx_extractor.py +1204 -0
- indexing/frontend/location_lookup.py +247 -0
- indexing/frontend/resolver.py +485 -0
- indexing/frontend/runtime_resolver.py +862 -0
- indexing/frontend/semantic_output.py +705 -0
- indexing/frontend/source_location.py +69 -0
- indexing/frontend_config.py +72 -0
- indexing/frontend_models.py +347 -0
- indexing/language_config.py +360 -0
- indexing/models.py +284 -0
- indexing/node_utils.py +1112 -0
- indexing/parse_worker.py +1082 -0
- indexing/queries.py +1542 -0
- indexing/sdk_examples.py +426 -0
- interactive.py +248 -0
- raggie.py +673 -0
- raggiecode-0.2.1.dist-info/METADATA +944 -0
- raggiecode-0.2.1.dist-info/RECORD +93 -0
- raggiecode-0.2.1.dist-info/WHEEL +5 -0
- raggiecode-0.2.1.dist-info/entry_points.txt +2 -0
- raggiecode-0.2.1.dist-info/top_level.txt +10 -0
- skills/__init__.py +3 -0
- skills/manager.py +114 -0
- skills/tool.py +121 -0
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Runtime element resolution (Phase 9).
|
|
4
|
+
|
|
5
|
+
Accepts metadata from a browser extension (or any source) and resolves it
|
|
6
|
+
to the best matching source semantic entity in the index.
|
|
7
|
+
|
|
8
|
+
Resolution strategies (tried in priority order):
|
|
9
|
+
1. Exact source location (file + line + column)
|
|
10
|
+
2. Exact source file + component name
|
|
11
|
+
3. Component ancestry → walk render graph
|
|
12
|
+
4. Element ID → markup_elements.element_id_attr
|
|
13
|
+
5. Tag + class combination
|
|
14
|
+
6. Attributes
|
|
15
|
+
7. Text content
|
|
16
|
+
8. DOM ancestry → walk markup tree
|
|
17
|
+
9. Heuristic selector matching
|
|
18
|
+
|
|
19
|
+
All applicable strategies are run. Results are merged, deduplicated by
|
|
20
|
+
entity ID, and sorted by confidence score (highest first). Ambiguity is
|
|
21
|
+
never hidden — all plausible candidates are returned.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import sqlite3
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Dict, List, Optional, Any, Set, Tuple
|
|
28
|
+
|
|
29
|
+
from indexing.frontend.location_lookup import lookup_entity_at_location
|
|
30
|
+
from indexing.frontend.graph import (
|
|
31
|
+
traverse_render_graph,
|
|
32
|
+
traverse_markup_tree,
|
|
33
|
+
traverse_event_graph,
|
|
34
|
+
traverse_binding_graph,
|
|
35
|
+
traverse_style_graph,
|
|
36
|
+
)
|
|
37
|
+
from indexing.frontend.css_selector_utils import selector_matches_element
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ──────────────────────────────────────────────────────────────
|
|
41
|
+
# Data structures
|
|
42
|
+
# ──────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ResolutionCandidate:
|
|
46
|
+
"""A single candidate from runtime resolution."""
|
|
47
|
+
|
|
48
|
+
entity_type: str # "markup_element" or "component"
|
|
49
|
+
entity_id: int
|
|
50
|
+
confidence: float
|
|
51
|
+
strategies_matched: List[str] = field(default_factory=list)
|
|
52
|
+
component: Optional[Dict[str, Any]] = None
|
|
53
|
+
source_range: Optional[Dict[str, int]] = None
|
|
54
|
+
file_path: Optional[str] = None
|
|
55
|
+
tag_name: Optional[str] = None
|
|
56
|
+
static_classes: List[str] = field(default_factory=list)
|
|
57
|
+
element_id_attr: Optional[str] = None
|
|
58
|
+
events: List[Dict[str, Any]] = field(default_factory=list)
|
|
59
|
+
bindings: List[Dict[str, Any]] = field(default_factory=list)
|
|
60
|
+
styles: List[Dict[str, Any]] = field(default_factory=list)
|
|
61
|
+
rendering_parents: List[Dict[str, Any]] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
64
|
+
return {
|
|
65
|
+
"entity_type": self.entity_type,
|
|
66
|
+
"entity_id": self.entity_id,
|
|
67
|
+
"confidence": self.confidence,
|
|
68
|
+
"strategies_matched": self.strategies_matched,
|
|
69
|
+
"component": self.component,
|
|
70
|
+
"source_range": self.source_range,
|
|
71
|
+
"file_path": self.file_path,
|
|
72
|
+
"tag_name": self.tag_name,
|
|
73
|
+
"static_classes": self.static_classes,
|
|
74
|
+
"element_id_attr": self.element_id_attr,
|
|
75
|
+
"events": self.events,
|
|
76
|
+
"bindings": self.bindings,
|
|
77
|
+
"styles": self.styles,
|
|
78
|
+
"rendering_parents": self.rendering_parents,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class ResolutionResult:
|
|
84
|
+
"""Result of runtime element resolution."""
|
|
85
|
+
|
|
86
|
+
candidates: List[ResolutionCandidate] = field(default_factory=list)
|
|
87
|
+
ambiguity_explanation: Optional[str] = None
|
|
88
|
+
best_confidence: float = 0.0
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
91
|
+
return {
|
|
92
|
+
"candidates": [c.to_dict() for c in self.candidates],
|
|
93
|
+
"ambiguity_explanation": self.ambiguity_explanation,
|
|
94
|
+
"best_confidence": self.best_confidence,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ──────────────────────────────────────────────────────────────
|
|
99
|
+
# Confidence scores per strategy
|
|
100
|
+
# ──────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
_CONFIDENCE = {
|
|
103
|
+
"exact_location": 1.0,
|
|
104
|
+
"file_and_component": 0.9,
|
|
105
|
+
"component_ancestry": 0.8,
|
|
106
|
+
"element_id_unique": 0.85,
|
|
107
|
+
"element_id_duplicate": 0.5,
|
|
108
|
+
"tag_and_classes_full": 0.7,
|
|
109
|
+
"tag_and_classes_partial": 0.5,
|
|
110
|
+
"attributes": 0.6,
|
|
111
|
+
"text_content": 0.5,
|
|
112
|
+
"dom_ancestry_full": 0.65,
|
|
113
|
+
"dom_ancestry_partial": 0.4,
|
|
114
|
+
"selector_matching": 0.55,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _combine_confidence(a: float, b: float) -> float:
|
|
119
|
+
"""Combine two confidence scores using probabilistic OR."""
|
|
120
|
+
return 1.0 - (1.0 - a) * (1.0 - b)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ──────────────────────────────────────────────────────────────
|
|
124
|
+
# Helpers
|
|
125
|
+
# ──────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
def _parse_json(raw: Optional[str]) -> Optional[Any]:
|
|
128
|
+
if not raw:
|
|
129
|
+
return None
|
|
130
|
+
try:
|
|
131
|
+
return json.loads(raw)
|
|
132
|
+
except (json.JSONDecodeError, TypeError):
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _element_row_to_candidate(
|
|
137
|
+
row: sqlite3.Row,
|
|
138
|
+
confidence: float,
|
|
139
|
+
strategy: str,
|
|
140
|
+
) -> ResolutionCandidate:
|
|
141
|
+
"""Convert a markup_elements row to a ResolutionCandidate."""
|
|
142
|
+
source_range = _parse_json(row["source_range"]) if "source_range" in row.keys() else None
|
|
143
|
+
static_classes = _parse_json(row["static_classes"]) if "static_classes" in row.keys() else []
|
|
144
|
+
if static_classes is None:
|
|
145
|
+
static_classes = []
|
|
146
|
+
|
|
147
|
+
comp = None
|
|
148
|
+
if row["component_id"]:
|
|
149
|
+
comp_row = row.keys() and None # placeholder, enriched later
|
|
150
|
+
comp = {"id": row["component_id"]}
|
|
151
|
+
|
|
152
|
+
return ResolutionCandidate(
|
|
153
|
+
entity_type="markup_element",
|
|
154
|
+
entity_id=row["id"],
|
|
155
|
+
confidence=confidence,
|
|
156
|
+
strategies_matched=[strategy],
|
|
157
|
+
component=comp,
|
|
158
|
+
source_range=source_range,
|
|
159
|
+
file_path=row["file_path"] if "file_path" in row.keys() else None,
|
|
160
|
+
tag_name=row["tag_name"],
|
|
161
|
+
static_classes=static_classes,
|
|
162
|
+
element_id_attr=row["element_id_attr"] if "element_id_attr" in row.keys() else None,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _component_row_to_candidate(
|
|
167
|
+
row: sqlite3.Row,
|
|
168
|
+
confidence: float,
|
|
169
|
+
strategy: str,
|
|
170
|
+
) -> ResolutionCandidate:
|
|
171
|
+
"""Convert a frontend_components row to a ResolutionCandidate."""
|
|
172
|
+
source_range = _parse_json(row["source_range"]) if "source_range" in row.keys() else None
|
|
173
|
+
|
|
174
|
+
return ResolutionCandidate(
|
|
175
|
+
entity_type="component",
|
|
176
|
+
entity_id=row["id"],
|
|
177
|
+
confidence=confidence,
|
|
178
|
+
strategies_matched=[strategy],
|
|
179
|
+
component={"id": row["id"], "name": row["name"]},
|
|
180
|
+
source_range=source_range,
|
|
181
|
+
file_path=row["file_path"] if "file_path" in row.keys() else None,
|
|
182
|
+
tag_name=None,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ──────────────────────────────────────────────────────────────
|
|
187
|
+
# Strategy 1: Exact source location
|
|
188
|
+
# ──────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
def _try_exact_location(conn, meta: dict) -> List[ResolutionCandidate]:
|
|
191
|
+
source_file = meta.get("source_file")
|
|
192
|
+
source_line = meta.get("source_line")
|
|
193
|
+
source_column = meta.get("source_column")
|
|
194
|
+
|
|
195
|
+
if not source_file or source_line is None or source_column is None:
|
|
196
|
+
return []
|
|
197
|
+
|
|
198
|
+
matches = lookup_entity_at_location(
|
|
199
|
+
conn, source_file, source_line, source_column, include_backend=False
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
candidates = []
|
|
203
|
+
for m in matches:
|
|
204
|
+
c = ResolutionCandidate(
|
|
205
|
+
entity_type=m.entity_type,
|
|
206
|
+
entity_id=m.entity_id,
|
|
207
|
+
confidence=_CONFIDENCE["exact_location"],
|
|
208
|
+
strategies_matched=["exact_location"],
|
|
209
|
+
component={"id": m.extra.get("component_id")} if m.extra.get("component_id") else None,
|
|
210
|
+
source_range=m.source_range,
|
|
211
|
+
file_path=m.file_path,
|
|
212
|
+
tag_name=m.extra.get("tag_name"),
|
|
213
|
+
static_classes=_parse_json(m.extra.get("static_classes")) or [],
|
|
214
|
+
element_id_attr=m.extra.get("element_id_attr"),
|
|
215
|
+
)
|
|
216
|
+
candidates.append(c)
|
|
217
|
+
|
|
218
|
+
return candidates
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ──────────────────────────────────────────────────────────────
|
|
222
|
+
# Strategy 2: Source file + component name
|
|
223
|
+
# ──────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
def _try_file_and_component(conn, meta: dict) -> List[ResolutionCandidate]:
|
|
226
|
+
source_file = meta.get("source_file")
|
|
227
|
+
component_name = meta.get("component_name")
|
|
228
|
+
|
|
229
|
+
if not component_name:
|
|
230
|
+
return []
|
|
231
|
+
|
|
232
|
+
if source_file:
|
|
233
|
+
row = conn.execute(
|
|
234
|
+
"""SELECT c.*, f.path as file_path
|
|
235
|
+
FROM frontend_components c
|
|
236
|
+
JOIN files f ON c.file_id = f.id
|
|
237
|
+
WHERE c.name = ? AND f.path = ?
|
|
238
|
+
LIMIT 1""",
|
|
239
|
+
(component_name, source_file)
|
|
240
|
+
).fetchone()
|
|
241
|
+
else:
|
|
242
|
+
row = conn.execute(
|
|
243
|
+
"""SELECT c.*, f.path as file_path
|
|
244
|
+
FROM frontend_components c
|
|
245
|
+
JOIN files f ON c.file_id = f.id
|
|
246
|
+
WHERE c.name = ?
|
|
247
|
+
LIMIT 1""",
|
|
248
|
+
(component_name,)
|
|
249
|
+
).fetchone()
|
|
250
|
+
|
|
251
|
+
if not row:
|
|
252
|
+
return []
|
|
253
|
+
|
|
254
|
+
return [_component_row_to_candidate(row, _CONFIDENCE["file_and_component"], "file_and_component")]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ──────────────────────────────────────────────────────────────
|
|
258
|
+
# Strategy 3: Component ancestry → render graph
|
|
259
|
+
# ──────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
def _try_component_ancestry(conn, meta: dict) -> List[ResolutionCandidate]:
|
|
262
|
+
ancestry = meta.get("component_ancestry")
|
|
263
|
+
if not ancestry or not isinstance(ancestry, list) or len(ancestry) < 2:
|
|
264
|
+
return []
|
|
265
|
+
|
|
266
|
+
# ancestry is a list of component names from root to leaf, e.g. ["App", "Card", "Button"]
|
|
267
|
+
# We start from the root and walk the render graph to find the leaf
|
|
268
|
+
root_name = ancestry[0]
|
|
269
|
+
root_row = conn.execute(
|
|
270
|
+
"""SELECT c.id FROM frontend_components c WHERE c.name = ? LIMIT 1""",
|
|
271
|
+
(root_name,)
|
|
272
|
+
).fetchone()
|
|
273
|
+
if not root_row:
|
|
274
|
+
return []
|
|
275
|
+
|
|
276
|
+
# Walk render graph following ancestry names
|
|
277
|
+
current_id = root_row["id"]
|
|
278
|
+
for i in range(1, len(ancestry)):
|
|
279
|
+
name = ancestry[i]
|
|
280
|
+
children = conn.execute(
|
|
281
|
+
"""SELECT rr.child_component_id, c.name
|
|
282
|
+
FROM render_relationships rr
|
|
283
|
+
JOIN frontend_components c ON rr.child_component_id = c.id
|
|
284
|
+
WHERE rr.parent_component_id = ? AND c.name = ?
|
|
285
|
+
LIMIT 1""",
|
|
286
|
+
(current_id, name)
|
|
287
|
+
).fetchone()
|
|
288
|
+
if not children:
|
|
289
|
+
# Ancestry doesn't match — try without name match (fuzzy)
|
|
290
|
+
return []
|
|
291
|
+
current_id = children["child_component_id"]
|
|
292
|
+
|
|
293
|
+
# Found the leaf component
|
|
294
|
+
row = conn.execute(
|
|
295
|
+
"""SELECT c.*, f.path as file_path
|
|
296
|
+
FROM frontend_components c
|
|
297
|
+
JOIN files f ON c.file_id = f.id
|
|
298
|
+
WHERE c.id = ?""",
|
|
299
|
+
(current_id,)
|
|
300
|
+
).fetchone()
|
|
301
|
+
if not row:
|
|
302
|
+
return []
|
|
303
|
+
|
|
304
|
+
return [_component_row_to_candidate(row, _CONFIDENCE["component_ancestry"], "component_ancestry")]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ──────────────────────────────────────────────────────────────
|
|
308
|
+
# Strategy 4: Element ID
|
|
309
|
+
# ──────────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
def _try_element_id(conn, meta: dict) -> List[ResolutionCandidate]:
|
|
312
|
+
element_id_attr = meta.get("element_id")
|
|
313
|
+
if not element_id_attr:
|
|
314
|
+
return []
|
|
315
|
+
|
|
316
|
+
rows = conn.execute(
|
|
317
|
+
"""SELECT m.*, f.path as file_path
|
|
318
|
+
FROM markup_elements m
|
|
319
|
+
JOIN files f ON m.file_id = f.id
|
|
320
|
+
WHERE m.element_id_attr = ?
|
|
321
|
+
ORDER BY m.id""",
|
|
322
|
+
(element_id_attr,)
|
|
323
|
+
).fetchall()
|
|
324
|
+
|
|
325
|
+
if not rows:
|
|
326
|
+
return []
|
|
327
|
+
|
|
328
|
+
if len(rows) == 1:
|
|
329
|
+
return [_element_row_to_candidate(rows[0], _CONFIDENCE["element_id_unique"], "element_id_unique")]
|
|
330
|
+
else:
|
|
331
|
+
return [
|
|
332
|
+
_element_row_to_candidate(r, _CONFIDENCE["element_id_duplicate"], "element_id_duplicate")
|
|
333
|
+
for r in rows
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ──────────────────────────────────────────────────────────────
|
|
338
|
+
# Strategy 5: Tag + class combination
|
|
339
|
+
# ──────────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
def _try_tag_and_classes(conn, meta: dict) -> List[ResolutionCandidate]:
|
|
342
|
+
dom_tag = meta.get("dom_tag")
|
|
343
|
+
classes = meta.get("classes")
|
|
344
|
+
|
|
345
|
+
if not dom_tag or not classes or not isinstance(classes, list):
|
|
346
|
+
return []
|
|
347
|
+
|
|
348
|
+
# Query elements matching the tag
|
|
349
|
+
rows = conn.execute(
|
|
350
|
+
"""SELECT m.*, f.path as file_path
|
|
351
|
+
FROM markup_elements m
|
|
352
|
+
JOIN files f ON m.file_id = f.id
|
|
353
|
+
WHERE m.tag_name = ?
|
|
354
|
+
ORDER BY m.id""",
|
|
355
|
+
(dom_tag,)
|
|
356
|
+
).fetchall()
|
|
357
|
+
|
|
358
|
+
candidates = []
|
|
359
|
+
target_class_set = set(classes)
|
|
360
|
+
|
|
361
|
+
for row in rows:
|
|
362
|
+
elem_classes = _parse_json(row["static_classes"]) or []
|
|
363
|
+
elem_class_set = set(elem_classes)
|
|
364
|
+
|
|
365
|
+
if target_class_set.issubset(elem_class_set):
|
|
366
|
+
# All target classes present
|
|
367
|
+
c = _element_row_to_candidate(row, _CONFIDENCE["tag_and_classes_full"], "tag_and_classes_full")
|
|
368
|
+
candidates.append(c)
|
|
369
|
+
elif elem_class_set & target_class_set:
|
|
370
|
+
# Partial overlap
|
|
371
|
+
overlap = len(elem_class_set & target_class_set)
|
|
372
|
+
total = len(target_class_set)
|
|
373
|
+
score = _CONFIDENCE["tag_and_classes_partial"] * (overlap / total) if total else 0
|
|
374
|
+
c = _element_row_to_candidate(row, score, "tag_and_classes_partial")
|
|
375
|
+
candidates.append(c)
|
|
376
|
+
|
|
377
|
+
return candidates
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ──────────────────────────────────────────────────────────────
|
|
381
|
+
# Strategy 6: Attributes
|
|
382
|
+
# ──────────────────────────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
def _try_attributes(conn, meta: dict) -> List[ResolutionCandidate]:
|
|
385
|
+
attributes = meta.get("attributes")
|
|
386
|
+
dom_tag = meta.get("dom_tag")
|
|
387
|
+
|
|
388
|
+
if not attributes or not isinstance(attributes, dict):
|
|
389
|
+
return []
|
|
390
|
+
|
|
391
|
+
# Build a query that searches for elements with matching attributes
|
|
392
|
+
# attributes is a dict like {"data-testid": "submit", "role": "button"}
|
|
393
|
+
# We use JSON extraction to match
|
|
394
|
+
rows = conn.execute(
|
|
395
|
+
"""SELECT m.*, f.path as file_path
|
|
396
|
+
FROM markup_elements m
|
|
397
|
+
JOIN files f ON m.file_id = f.id
|
|
398
|
+
WHERE m.attributes IS NOT NULL
|
|
399
|
+
ORDER BY m.id"""
|
|
400
|
+
).fetchall()
|
|
401
|
+
|
|
402
|
+
candidates = []
|
|
403
|
+
for row in rows:
|
|
404
|
+
elem_attrs = _parse_json(row["attributes"]) or {}
|
|
405
|
+
if not isinstance(elem_attrs, dict):
|
|
406
|
+
continue
|
|
407
|
+
|
|
408
|
+
# Check how many attributes match
|
|
409
|
+
matched = 0
|
|
410
|
+
total = len(attributes)
|
|
411
|
+
for k, v in attributes.items():
|
|
412
|
+
if k in elem_attrs and str(elem_attrs[k]) == str(v):
|
|
413
|
+
matched += 1
|
|
414
|
+
|
|
415
|
+
if matched == 0:
|
|
416
|
+
continue
|
|
417
|
+
|
|
418
|
+
# If dom_tag is provided, also check tag
|
|
419
|
+
if dom_tag and row["tag_name"] != dom_tag:
|
|
420
|
+
continue
|
|
421
|
+
|
|
422
|
+
score = _CONFIDENCE["attributes"] * (matched / total) if total else 0
|
|
423
|
+
c = _element_row_to_candidate(row, score, "attributes")
|
|
424
|
+
candidates.append(c)
|
|
425
|
+
|
|
426
|
+
return candidates
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ──────────────────────────────────────────────────────────────
|
|
430
|
+
# Strategy 7: Text content
|
|
431
|
+
# ──────────────────────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
def _try_text_content(conn, meta: dict, project_root: str = None) -> List[ResolutionCandidate]:
|
|
434
|
+
text = meta.get("text")
|
|
435
|
+
if not text:
|
|
436
|
+
return []
|
|
437
|
+
|
|
438
|
+
# Text nodes are stored as element_type='text' but the actual text content
|
|
439
|
+
# is not stored in the DB — we need to read it from the source file using
|
|
440
|
+
# the element's source_range.
|
|
441
|
+
rows = conn.execute(
|
|
442
|
+
"""SELECT m.*, f.path as file_path
|
|
443
|
+
FROM markup_elements m
|
|
444
|
+
JOIN files f ON m.file_id = f.id
|
|
445
|
+
WHERE m.element_type = 'text' OR m.element_type = '#text'
|
|
446
|
+
ORDER BY m.id"""
|
|
447
|
+
).fetchall()
|
|
448
|
+
|
|
449
|
+
candidates = []
|
|
450
|
+
for row in rows:
|
|
451
|
+
# Try to extract text from the source file using source_range
|
|
452
|
+
elem_text = ""
|
|
453
|
+
source_range = _parse_json(row["source_range"]) if "source_range" in row.keys() else None
|
|
454
|
+
file_path = row["file_path"] if "file_path" in row.keys() else None
|
|
455
|
+
|
|
456
|
+
if source_range and file_path:
|
|
457
|
+
try:
|
|
458
|
+
import os
|
|
459
|
+
# file_path is project-relative; try resolving with project_root
|
|
460
|
+
candidates_paths = [file_path]
|
|
461
|
+
if project_root:
|
|
462
|
+
candidates_paths.append(os.path.join(project_root, file_path))
|
|
463
|
+
resolved = None
|
|
464
|
+
for p in candidates_paths:
|
|
465
|
+
if os.path.exists(p):
|
|
466
|
+
resolved = p
|
|
467
|
+
break
|
|
468
|
+
if resolved:
|
|
469
|
+
with open(resolved, "r", encoding="utf-8", errors="replace") as f:
|
|
470
|
+
lines = f.readlines()
|
|
471
|
+
start_line = source_range.get("start_line", 1) - 1
|
|
472
|
+
end_line = source_range.get("end_line", 1)
|
|
473
|
+
if 0 <= start_line < len(lines):
|
|
474
|
+
elem_text = "".join(lines[start_line:end_line]).strip()
|
|
475
|
+
except Exception:
|
|
476
|
+
pass
|
|
477
|
+
|
|
478
|
+
# Also check attributes as a fallback
|
|
479
|
+
if not elem_text:
|
|
480
|
+
attrs = _parse_json(row["attributes"]) or {}
|
|
481
|
+
if isinstance(attrs, dict):
|
|
482
|
+
elem_text = attrs.get("text", "") or attrs.get("content", "")
|
|
483
|
+
|
|
484
|
+
if not elem_text:
|
|
485
|
+
continue
|
|
486
|
+
|
|
487
|
+
# Check if the text matches (exact or contains)
|
|
488
|
+
if text in elem_text or elem_text in text:
|
|
489
|
+
# Get the parent element (the real element, not the text node)
|
|
490
|
+
parent_id = row["parent_element_id"]
|
|
491
|
+
if parent_id:
|
|
492
|
+
parent_row = conn.execute(
|
|
493
|
+
"""SELECT m.*, f.path as file_path
|
|
494
|
+
FROM markup_elements m
|
|
495
|
+
JOIN files f ON m.file_id = f.id
|
|
496
|
+
WHERE m.id = ?""",
|
|
497
|
+
(parent_id,)
|
|
498
|
+
).fetchone()
|
|
499
|
+
if parent_row:
|
|
500
|
+
c = _element_row_to_candidate(parent_row, _CONFIDENCE["text_content"], "text_content")
|
|
501
|
+
candidates.append(c)
|
|
502
|
+
continue
|
|
503
|
+
|
|
504
|
+
# Fallback: match on the text node itself
|
|
505
|
+
c = _element_row_to_candidate(row, _CONFIDENCE["text_content"] * 0.8, "text_content")
|
|
506
|
+
candidates.append(c)
|
|
507
|
+
|
|
508
|
+
return candidates
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# ──────────────────────────────────────────────────────────────
|
|
512
|
+
# Strategy 8: DOM ancestry → markup tree
|
|
513
|
+
# ──────────────────────────────────────────────────────────────
|
|
514
|
+
|
|
515
|
+
def _try_dom_ancestry(conn, meta: dict) -> List[ResolutionCandidate]:
|
|
516
|
+
dom_ancestry = meta.get("dom_ancestry")
|
|
517
|
+
if not dom_ancestry or not isinstance(dom_ancestry, list) or len(dom_ancestry) < 2:
|
|
518
|
+
return []
|
|
519
|
+
|
|
520
|
+
# dom_ancestry is a list of dicts: [{"tag": "body"}, {"tag": "div", "classes": ["container"]}, {"tag": "button", "classes": ["btn"]}]
|
|
521
|
+
# The last element is the target element
|
|
522
|
+
target = dom_ancestry[-1]
|
|
523
|
+
target_tag = target.get("tag", "")
|
|
524
|
+
target_classes = target.get("classes", [])
|
|
525
|
+
|
|
526
|
+
# Find candidate elements matching the target's tag
|
|
527
|
+
rows = conn.execute(
|
|
528
|
+
"""SELECT m.*, f.path as file_path
|
|
529
|
+
FROM markup_elements m
|
|
530
|
+
JOIN files f ON m.file_id = f.id
|
|
531
|
+
WHERE m.tag_name = ?
|
|
532
|
+
ORDER BY m.id""",
|
|
533
|
+
(target_tag,)
|
|
534
|
+
).fetchall()
|
|
535
|
+
|
|
536
|
+
candidates = []
|
|
537
|
+
for row in rows:
|
|
538
|
+
# Walk up the markup tree and check ancestry
|
|
539
|
+
ancestry_match = _check_ancestry(conn, row["id"], dom_ancestry[:-1])
|
|
540
|
+
if ancestry_match == "full":
|
|
541
|
+
c = _element_row_to_candidate(row, _CONFIDENCE["dom_ancestry_full"], "dom_ancestry_full")
|
|
542
|
+
candidates.append(c)
|
|
543
|
+
elif ancestry_match == "partial":
|
|
544
|
+
c = _element_row_to_candidate(row, _CONFIDENCE["dom_ancestry_partial"], "dom_ancestry_partial")
|
|
545
|
+
candidates.append(c)
|
|
546
|
+
|
|
547
|
+
return candidates
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _check_ancestry(conn, element_id: int, expected_ancestry: List[dict]) -> str:
|
|
551
|
+
"""Walk up the markup tree from element_id, checking against expected ancestry.
|
|
552
|
+
|
|
553
|
+
Returns "full" if all ancestors match, "partial" if some match, "" if none.
|
|
554
|
+
"""
|
|
555
|
+
if not expected_ancestry:
|
|
556
|
+
return "full"
|
|
557
|
+
|
|
558
|
+
current_id = element_id
|
|
559
|
+
matched = 0
|
|
560
|
+
expected_idx = len(expected_ancestry) - 1 # Start from the immediate parent
|
|
561
|
+
|
|
562
|
+
while current_id and expected_idx >= 0:
|
|
563
|
+
row = conn.execute(
|
|
564
|
+
"SELECT parent_element_id, tag_name, static_classes FROM markup_elements WHERE id = ?",
|
|
565
|
+
(current_id,)
|
|
566
|
+
).fetchone()
|
|
567
|
+
if not row:
|
|
568
|
+
break
|
|
569
|
+
|
|
570
|
+
parent_id = row["parent_element_id"]
|
|
571
|
+
if parent_id is None:
|
|
572
|
+
break
|
|
573
|
+
|
|
574
|
+
parent_row = conn.execute(
|
|
575
|
+
"SELECT tag_name, static_classes FROM markup_elements WHERE id = ?",
|
|
576
|
+
(parent_id,)
|
|
577
|
+
).fetchone()
|
|
578
|
+
if not parent_row:
|
|
579
|
+
break
|
|
580
|
+
|
|
581
|
+
expected = expected_ancestry[expected_idx]
|
|
582
|
+
expected_tag = expected.get("tag", "")
|
|
583
|
+
|
|
584
|
+
if parent_row["tag_name"] == expected_tag:
|
|
585
|
+
expected_classes = set(expected.get("classes", []))
|
|
586
|
+
if expected_classes:
|
|
587
|
+
parent_classes = set(_parse_json(parent_row["static_classes"]) or [])
|
|
588
|
+
if expected_classes.issubset(parent_classes):
|
|
589
|
+
matched += 1
|
|
590
|
+
else:
|
|
591
|
+
matched += 1
|
|
592
|
+
|
|
593
|
+
current_id = parent_id
|
|
594
|
+
expected_idx -= 1
|
|
595
|
+
|
|
596
|
+
if matched == len(expected_ancestry):
|
|
597
|
+
return "full"
|
|
598
|
+
elif matched > 0:
|
|
599
|
+
return "partial"
|
|
600
|
+
return ""
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
# ──────────────────────────────────────────────────────────────
|
|
604
|
+
# Strategy 9: Heuristic selector matching
|
|
605
|
+
# ──────────────────────────────────────────────────────────────
|
|
606
|
+
|
|
607
|
+
def _try_selector_matching(conn, meta: dict) -> List[ResolutionCandidate]:
|
|
608
|
+
dom_tag = meta.get("dom_tag")
|
|
609
|
+
classes = meta.get("classes", [])
|
|
610
|
+
element_id_attr = meta.get("element_id")
|
|
611
|
+
|
|
612
|
+
if not dom_tag and not classes and not element_id_attr:
|
|
613
|
+
return []
|
|
614
|
+
|
|
615
|
+
# First, check style_selector_matches for pre-computed matches
|
|
616
|
+
# If we have classes, find selectors that match those classes
|
|
617
|
+
rows = conn.execute(
|
|
618
|
+
"""SELECT m.*, f.path as file_path
|
|
619
|
+
FROM markup_elements m
|
|
620
|
+
JOIN files f ON m.file_id = f.id
|
|
621
|
+
WHERE m.tag_name = ?
|
|
622
|
+
ORDER BY m.id""",
|
|
623
|
+
(dom_tag,)
|
|
624
|
+
).fetchall() if dom_tag else []
|
|
625
|
+
|
|
626
|
+
candidates = []
|
|
627
|
+
for row in rows:
|
|
628
|
+
elem_classes = _parse_json(row["static_classes"]) or []
|
|
629
|
+
elem_id = row["element_id_attr"]
|
|
630
|
+
|
|
631
|
+
# Check if any selector in the index matches this element
|
|
632
|
+
# First check pre-computed matches
|
|
633
|
+
sm_rows = conn.execute(
|
|
634
|
+
"SELECT selector_id FROM style_selector_matches WHERE element_id = ? LIMIT 1",
|
|
635
|
+
(row["id"],)
|
|
636
|
+
).fetchall()
|
|
637
|
+
|
|
638
|
+
if sm_rows:
|
|
639
|
+
c = _element_row_to_candidate(row, _CONFIDENCE["selector_matching"], "selector_matching")
|
|
640
|
+
candidates.append(c)
|
|
641
|
+
continue
|
|
642
|
+
|
|
643
|
+
# Fallback: runtime selector matching against all selectors
|
|
644
|
+
all_selectors = conn.execute(
|
|
645
|
+
"SELECT selector_text FROM style_selectors"
|
|
646
|
+
).fetchall()
|
|
647
|
+
|
|
648
|
+
for sel_row in all_selectors:
|
|
649
|
+
if selector_matches_element(sel_row["selector_text"], dom_tag, elem_classes, elem_id):
|
|
650
|
+
c = _element_row_to_candidate(row, _CONFIDENCE["selector_matching"], "selector_matching")
|
|
651
|
+
candidates.append(c)
|
|
652
|
+
break
|
|
653
|
+
|
|
654
|
+
return candidates
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
# ──────────────────────────────────────────────────────────────
|
|
658
|
+
# Enrichment
|
|
659
|
+
# ──────────────────────────────────────────────────────────────
|
|
660
|
+
|
|
661
|
+
def _enrich_candidate(conn, candidate: ResolutionCandidate) -> None:
|
|
662
|
+
"""Enrich a candidate with events, bindings, styles, and rendering parents."""
|
|
663
|
+
if candidate.entity_type == "markup_element":
|
|
664
|
+
eid = candidate.entity_id
|
|
665
|
+
|
|
666
|
+
# Events
|
|
667
|
+
events_result = traverse_event_graph(conn, eid)
|
|
668
|
+
candidate.events = events_result.get("events", [])
|
|
669
|
+
|
|
670
|
+
# Bindings
|
|
671
|
+
bindings_result = traverse_binding_graph(conn, eid)
|
|
672
|
+
candidate.bindings = bindings_result.get("bindings", [])
|
|
673
|
+
|
|
674
|
+
# Styles (selectors matching this element)
|
|
675
|
+
styles_result = traverse_style_graph(conn, eid, "to_definition")
|
|
676
|
+
candidate.styles = styles_result.get("candidate_selectors", [])
|
|
677
|
+
|
|
678
|
+
# Owning component
|
|
679
|
+
if candidate.component and "id" in candidate.component:
|
|
680
|
+
comp_id = candidate.component["id"]
|
|
681
|
+
comp_row = conn.execute(
|
|
682
|
+
"""SELECT c.name, c.source_range, f.path as file_path
|
|
683
|
+
FROM frontend_components c
|
|
684
|
+
JOIN files f ON c.file_id = f.id
|
|
685
|
+
WHERE c.id = ?""",
|
|
686
|
+
(comp_id,)
|
|
687
|
+
).fetchone()
|
|
688
|
+
if comp_row:
|
|
689
|
+
candidate.component = {
|
|
690
|
+
"id": comp_id,
|
|
691
|
+
"name": comp_row["name"],
|
|
692
|
+
"source_range": _parse_json(comp_row["source_range"]),
|
|
693
|
+
"file_path": comp_row["file_path"],
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
# Rendering parents
|
|
697
|
+
parents_result = traverse_render_graph(conn, comp_id, "parents", max_depth=5)
|
|
698
|
+
candidate.rendering_parents = parents_result.get("children", [])
|
|
699
|
+
|
|
700
|
+
elif candidate.entity_type == "component":
|
|
701
|
+
comp_id = candidate.entity_id
|
|
702
|
+
|
|
703
|
+
# Rendering parents
|
|
704
|
+
parents_result = traverse_render_graph(conn, comp_id, "parents", max_depth=5)
|
|
705
|
+
candidate.rendering_parents = parents_result.get("children", [])
|
|
706
|
+
|
|
707
|
+
# Enrich component info
|
|
708
|
+
comp_row = conn.execute(
|
|
709
|
+
"""SELECT c.name, c.source_range, f.path as file_path
|
|
710
|
+
FROM frontend_components c
|
|
711
|
+
JOIN files f ON c.file_id = f.id
|
|
712
|
+
WHERE c.id = ?""",
|
|
713
|
+
(comp_id,)
|
|
714
|
+
).fetchone()
|
|
715
|
+
if comp_row:
|
|
716
|
+
candidate.component = {
|
|
717
|
+
"id": comp_id,
|
|
718
|
+
"name": comp_row["name"],
|
|
719
|
+
"source_range": _parse_json(comp_row["source_range"]),
|
|
720
|
+
"file_path": comp_row["file_path"],
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
# ──────────────────────────────────────────────────────────────
|
|
725
|
+
# Merge and score
|
|
726
|
+
# ──────────────────────────────────────────────────────────────
|
|
727
|
+
|
|
728
|
+
def _merge_candidates(candidates: List[ResolutionCandidate]) -> List[ResolutionCandidate]:
|
|
729
|
+
"""Merge candidates that refer to the same entity.
|
|
730
|
+
|
|
731
|
+
Combines confidence scores (probabilistic OR) and merges strategies_matched.
|
|
732
|
+
"""
|
|
733
|
+
merged: Dict[Tuple[str, int], ResolutionCandidate] = {}
|
|
734
|
+
|
|
735
|
+
for c in candidates:
|
|
736
|
+
key = (c.entity_type, c.entity_id)
|
|
737
|
+
if key in merged:
|
|
738
|
+
existing = merged[key]
|
|
739
|
+
existing.confidence = _combine_confidence(existing.confidence, c.confidence)
|
|
740
|
+
for s in c.strategies_matched:
|
|
741
|
+
if s not in existing.strategies_matched:
|
|
742
|
+
existing.strategies_matched.append(s)
|
|
743
|
+
# Keep the higher-confidence fields
|
|
744
|
+
if c.confidence > existing.confidence:
|
|
745
|
+
existing.source_range = c.source_range or existing.source_range
|
|
746
|
+
existing.file_path = c.file_path or existing.file_path
|
|
747
|
+
existing.tag_name = c.tag_name or existing.tag_name
|
|
748
|
+
existing.static_classes = c.static_classes or existing.static_classes
|
|
749
|
+
existing.element_id_attr = c.element_id_attr or existing.element_id_attr
|
|
750
|
+
else:
|
|
751
|
+
merged[key] = c
|
|
752
|
+
|
|
753
|
+
return list(merged.values())
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _build_ambiguity_explanation(candidates: List[ResolutionCandidate]) -> Optional[str]:
|
|
757
|
+
"""Build a human-readable explanation of ambiguity."""
|
|
758
|
+
if len(candidates) <= 1:
|
|
759
|
+
return None
|
|
760
|
+
|
|
761
|
+
# Group by strategy
|
|
762
|
+
strategies = set()
|
|
763
|
+
for c in candidates:
|
|
764
|
+
strategies.update(c.strategies_matched)
|
|
765
|
+
|
|
766
|
+
descriptions = []
|
|
767
|
+
for c in candidates[:5]: # Top 5
|
|
768
|
+
entity_desc = c.tag_name or c.entity_type
|
|
769
|
+
if c.static_classes:
|
|
770
|
+
entity_desc += "." + ".".join(c.static_classes[:3])
|
|
771
|
+
if c.element_id_attr:
|
|
772
|
+
entity_desc += f"#{c.element_id_attr}"
|
|
773
|
+
strategies_str = ", ".join(c.strategies_matched)
|
|
774
|
+
descriptions.append(
|
|
775
|
+
f"{entity_desc} (id={c.entity_id}, confidence={c.confidence:.2f}, strategies: {strategies_str})"
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
summary = f"{len(candidates)} candidates found. " + "; ".join(descriptions)
|
|
779
|
+
if len(candidates) > 5:
|
|
780
|
+
summary += f" ... and {len(candidates) - 5} more"
|
|
781
|
+
return summary
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
# ──────────────────────────────────────────────────────────────
|
|
785
|
+
# Main entry point
|
|
786
|
+
# ──────────────────────────────────────────────────────────────
|
|
787
|
+
|
|
788
|
+
def resolve_runtime_element(
|
|
789
|
+
conn: sqlite3.Connection,
|
|
790
|
+
metadata: Dict[str, Any],
|
|
791
|
+
project_root: str = None,
|
|
792
|
+
) -> Dict[str, Any]:
|
|
793
|
+
"""Resolve browser/runtime element metadata to source semantic entities.
|
|
794
|
+
|
|
795
|
+
Args:
|
|
796
|
+
conn: SQLite connection to the index database.
|
|
797
|
+
metadata: Dict with any combination of keys:
|
|
798
|
+
- source_file (str): Project-relative file path
|
|
799
|
+
- source_line (int): 1-indexed line number
|
|
800
|
+
- source_column (int): 0-indexed column number
|
|
801
|
+
- component_name (str): React/component name
|
|
802
|
+
- component_ancestry (list[str]): Component names from root to leaf
|
|
803
|
+
- dom_tag (str): HTML tag name
|
|
804
|
+
- element_id (str): Element's id attribute value
|
|
805
|
+
- classes (list[str]): CSS class names
|
|
806
|
+
- attributes (dict): Element attributes
|
|
807
|
+
- text (str): Text content
|
|
808
|
+
- dom_ancestry (list[dict]): DOM tree from root to target,
|
|
809
|
+
each dict has "tag" (str) and optionally "classes" (list[str])
|
|
810
|
+
project_root: Optional path to the project root directory, for resolving
|
|
811
|
+
project-relative file paths when reading source files.
|
|
812
|
+
|
|
813
|
+
Returns:
|
|
814
|
+
Dict with:
|
|
815
|
+
- candidates: list of candidate dicts sorted by confidence (highest first)
|
|
816
|
+
- ambiguity_explanation: str or None
|
|
817
|
+
- best_confidence: float
|
|
818
|
+
"""
|
|
819
|
+
all_candidates: List[ResolutionCandidate] = []
|
|
820
|
+
|
|
821
|
+
# Run all applicable strategies
|
|
822
|
+
strategies = [
|
|
823
|
+
_try_exact_location,
|
|
824
|
+
_try_file_and_component,
|
|
825
|
+
_try_component_ancestry,
|
|
826
|
+
_try_element_id,
|
|
827
|
+
_try_tag_and_classes,
|
|
828
|
+
_try_attributes,
|
|
829
|
+
_try_text_content,
|
|
830
|
+
_try_dom_ancestry,
|
|
831
|
+
_try_selector_matching,
|
|
832
|
+
]
|
|
833
|
+
|
|
834
|
+
for strategy_fn in strategies:
|
|
835
|
+
try:
|
|
836
|
+
if strategy_fn is _try_text_content:
|
|
837
|
+
results = strategy_fn(conn, metadata, project_root)
|
|
838
|
+
else:
|
|
839
|
+
results = strategy_fn(conn, metadata)
|
|
840
|
+
all_candidates.extend(results)
|
|
841
|
+
except Exception:
|
|
842
|
+
# A strategy failure should never prevent other strategies from running
|
|
843
|
+
continue
|
|
844
|
+
|
|
845
|
+
# Merge duplicates
|
|
846
|
+
merged = _merge_candidates(all_candidates)
|
|
847
|
+
|
|
848
|
+
# Enrich each candidate
|
|
849
|
+
for c in merged:
|
|
850
|
+
_enrich_candidate(conn, c)
|
|
851
|
+
|
|
852
|
+
# Sort by confidence (highest first)
|
|
853
|
+
merged.sort(key=lambda c: c.confidence, reverse=True)
|
|
854
|
+
|
|
855
|
+
# Build result
|
|
856
|
+
result = ResolutionResult(
|
|
857
|
+
candidates=merged,
|
|
858
|
+
ambiguity_explanation=_build_ambiguity_explanation(merged),
|
|
859
|
+
best_confidence=merged[0].confidence if merged else 0.0,
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
return result.to_dict()
|