chore: archive tools/ui_inspector snapshot before migration removal.
Keep the last in-template copy on main so history can restore it; product continues in jiangchang-ui-recorder. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
294
tools/ui_inspector/locators/catalog.py
Normal file
294
tools/ui_inspector/locators/catalog.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""Element Catalog: merge observations, locator history, parameterization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from models.element import ElementCatalog, ElementRef, element_fingerprint
|
||||
from models.locator import LocatorCandidate
|
||||
from models.serialize import to_dict, write_model_json
|
||||
from redaction import redact_element_fields
|
||||
from core.policies.element_identity import element_identity_key
|
||||
|
||||
from .base import rank_and_select_primary
|
||||
from .browser import compile_browser_locators
|
||||
from .validator import validate_locator_against_rows
|
||||
from .windows import compile_windows_locators
|
||||
|
||||
_DYNAMIC_VALUE_HINTS = re.compile(
|
||||
r"(联系人|搜索|search|contact|user|chat|title|name)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def suggest_logical_name(element: dict[str, Any]) -> str:
|
||||
aid = str(element.get("automation_id") or "").strip()
|
||||
name = str(element.get("name") or "").strip()
|
||||
role = str(element.get("control_type") or element.get("role") or "el").strip()
|
||||
text = str(element.get("text") or "").strip()
|
||||
semantic_name = str(element.get("semantic_name") or "").strip()
|
||||
if element.get("contenteditable"):
|
||||
base = aid or str(element.get("placeholder") or element.get("label") or "") or role
|
||||
else:
|
||||
base = aid or semantic_name or name or (text if 0 < len(text) <= 48 else "")
|
||||
if not base:
|
||||
for ancestor in element.get("parent_chain") or []:
|
||||
ancestor_id = str((ancestor or {}).get("id") or "").strip()
|
||||
ancestor_text = str((ancestor or {}).get("text") or "").strip()
|
||||
if ancestor_id:
|
||||
base = f"{ancestor_id}_{role}"
|
||||
break
|
||||
if ancestor_text and len(ancestor_text) <= 32:
|
||||
base = f"{ancestor_text}_{role}"
|
||||
break
|
||||
base = base or role
|
||||
base = re.sub(r"[^\w\u4e00-\u9fff]+", "_", base, flags=re.UNICODE).strip("_")
|
||||
base = base[:48] or "element"
|
||||
return base.lower() if base.isascii() else base
|
||||
|
||||
|
||||
def _maybe_parameterize(value: str, *, field: str) -> tuple[str, dict[str, str]]:
|
||||
"""Replace likely dynamic business values with ${param} placeholders."""
|
||||
v = (value or "").strip()
|
||||
if not v or len(v) > 64:
|
||||
return v, {}
|
||||
if field in {"value", "text", "name"} and _DYNAMIC_VALUE_HINTS.search(field):
|
||||
return "${value}", {"value": v}
|
||||
# Contact-like short Chinese/ASCII names in value when name is generic
|
||||
if field == "value" and 1 < len(v) <= 32 and not v.startswith("${"):
|
||||
# Only parameterize when looks like a person/search term (letters/CJK, not chrome)
|
||||
if re.fullmatch(r"[\w\u4e00-\u9fff \-_.@]+", v, flags=re.UNICODE):
|
||||
chrome = {"关闭", "最小化", "最大化", "取消", "确定", "发送", "搜索"}
|
||||
if v not in chrome:
|
||||
return "${contact_name}", {"contact_name": v}
|
||||
return v, {}
|
||||
|
||||
|
||||
class ElementCatalogService:
|
||||
def __init__(self, flow_dir: Path, *, flow_id: str = "") -> None:
|
||||
self.flow_dir = Path(flow_dir)
|
||||
self.elements_dir = self.flow_dir / "elements"
|
||||
self.elements_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.catalog_path = self.flow_dir / "element_catalog.json"
|
||||
self.catalog = ElementCatalog(flow_id=flow_id)
|
||||
self._by_fp: dict[str, ElementRef] = {}
|
||||
self._by_identity: dict[str, ElementRef] = {}
|
||||
self._by_name: dict[str, ElementRef] = {}
|
||||
|
||||
def load(self) -> None:
|
||||
if not self.catalog_path.exists():
|
||||
return
|
||||
import json
|
||||
from models.serialize import from_dict
|
||||
|
||||
data = json.loads(self.catalog_path.read_text(encoding="utf-8"))
|
||||
self.catalog.flow_id = data.get("flow_id") or self.catalog.flow_id
|
||||
for item in data.get("elements") or []:
|
||||
# Reconstruct LocatorCandidate lists lightly
|
||||
locs = []
|
||||
for lc in item.get("locator_candidates") or []:
|
||||
locs.append(from_dict(LocatorCandidate, lc))
|
||||
primary = None
|
||||
if item.get("primary_locator"):
|
||||
primary = from_dict(LocatorCandidate, item["primary_locator"])
|
||||
fallbacks = [
|
||||
from_dict(LocatorCandidate, x) for x in (item.get("fallback_locators") or [])
|
||||
]
|
||||
el = ElementRef(
|
||||
id=item.get("id") or "",
|
||||
logical_name=item.get("logical_name") or "",
|
||||
surface=item.get("surface") or "windows",
|
||||
backend=item.get("backend") or "",
|
||||
app=item.get("app") or {},
|
||||
window_scope=item.get("window_scope") or {},
|
||||
element_scope=item.get("element_scope") or {},
|
||||
role=item.get("role") or "",
|
||||
control_type=item.get("control_type") or "",
|
||||
name=item.get("name") or "",
|
||||
value=item.get("value") or "",
|
||||
automation_id=item.get("automation_id") or "",
|
||||
class_name=item.get("class_name") or "",
|
||||
framework_id=item.get("framework_id") or "",
|
||||
states=item.get("states") or {},
|
||||
bounds=item.get("bounds") or {},
|
||||
actions=list(item.get("actions") or []),
|
||||
parent_chain=list(item.get("parent_chain") or []),
|
||||
raw=item.get("raw") or {},
|
||||
fingerprint=item.get("fingerprint") or "",
|
||||
sensitive=bool(item.get("sensitive")),
|
||||
locator_candidates=locs,
|
||||
primary_locator=primary,
|
||||
fallback_locators=fallbacks,
|
||||
expected_count=int(item.get("expected_count") or 1),
|
||||
verified_count=item.get("verified_count"),
|
||||
verification_level=item.get("verification_level") or "none",
|
||||
stability_score=float(item.get("stability_score") or 0.0),
|
||||
verified_at=item.get("verified_at"),
|
||||
first_seen=item.get("first_seen") or "",
|
||||
last_seen=item.get("last_seen") or "",
|
||||
observation_count=int(item.get("observation_count") or 1),
|
||||
locator_history=list(item.get("locator_history") or []),
|
||||
parameterized=dict(item.get("parameterized") or {}),
|
||||
)
|
||||
if el.fingerprint:
|
||||
self._by_fp[el.fingerprint] = el
|
||||
identity = str((el.raw or {}).get("_identity_key") or "")
|
||||
if not identity:
|
||||
identity = element_identity_key(
|
||||
el.raw or to_dict(el),
|
||||
surface=el.surface,
|
||||
page_url=str((el.element_scope or {}).get("page_url") or ""),
|
||||
frame_name=str((el.element_scope or {}).get("frame_name") or ""),
|
||||
)
|
||||
self._by_identity[identity] = el
|
||||
if el.logical_name:
|
||||
self._by_name[el.logical_name] = el
|
||||
self.catalog.elements.append(el)
|
||||
|
||||
def save(self) -> None:
|
||||
from datetime import datetime
|
||||
|
||||
self.catalog.updated_at = datetime.now().astimezone().isoformat()
|
||||
write_model_json(self.catalog_path, self.catalog)
|
||||
for el in self.catalog.elements:
|
||||
write_model_json(self.elements_dir / f"{el.id}.json", el)
|
||||
|
||||
def upsert_from_snapshot(
|
||||
self,
|
||||
element: dict[str, Any],
|
||||
*,
|
||||
surface: str,
|
||||
backend: str = "",
|
||||
window_scope: Optional[dict[str, Any]] = None,
|
||||
rows: Optional[list[dict[str, Any]]] = None,
|
||||
logical_name: Optional[str] = None,
|
||||
frame: Optional[dict[str, Any]] = None,
|
||||
live_counts: Optional[dict[str, int]] = None,
|
||||
) -> ElementRef:
|
||||
cleaned, sensitive = redact_element_fields(dict(element or {}))
|
||||
win = dict(window_scope or {})
|
||||
value = str(cleaned.get("value") or "")
|
||||
is_editable = bool(cleaned.get("contenteditable"))
|
||||
param_value, params = _maybe_parameterize(value, field="value")
|
||||
if params:
|
||||
cleaned["value"] = param_value
|
||||
|
||||
fp = element_fingerprint(
|
||||
surface=surface,
|
||||
role=str(cleaned.get("control_type") or cleaned.get("role") or ""),
|
||||
name=str(cleaned.get("name") or ""),
|
||||
automation_id=str(cleaned.get("automation_id") or ""),
|
||||
class_name=str(cleaned.get("class_name") or ""),
|
||||
value=str(cleaned.get("value") or ""),
|
||||
window_scope=win,
|
||||
dom_fingerprint=str(cleaned.get("fingerprint") or cleaned.get("css") or ""),
|
||||
page_scope=str((frame or {}).get("url") or win.get("url") or ""),
|
||||
contenteditable=is_editable,
|
||||
)
|
||||
|
||||
identity = element_identity_key(
|
||||
cleaned,
|
||||
surface=surface,
|
||||
page_url=str((frame or {}).get("url") or win.get("url") or ""),
|
||||
frame_name=str((frame or {}).get("name") or ""),
|
||||
)
|
||||
cleaned["_identity_key"] = identity
|
||||
|
||||
existing = self._by_identity.get(identity) or self._by_fp.get(fp)
|
||||
if existing is None and logical_name and logical_name in self._by_name:
|
||||
existing = self._by_name[logical_name]
|
||||
|
||||
if surface == "browser":
|
||||
cands = compile_browser_locators(cleaned, rows=rows, frame=frame)
|
||||
else:
|
||||
cands = compile_windows_locators(cleaned, rows=rows, window_scope=win)
|
||||
|
||||
for c in cands:
|
||||
if rows is not None and not c.uses_coordinates:
|
||||
validate_locator_against_rows(c, rows, level="snapshot_only")
|
||||
if live_counts is not None and c.strategy in live_counts:
|
||||
c.mark_counts(int(live_counts[c.strategy]))
|
||||
c.validation = "live"
|
||||
|
||||
primary, fallbacks = rank_and_select_primary(cands)
|
||||
# Ambiguous / coordinates never primary
|
||||
if primary is not None and (primary.ambiguous or not primary.production_eligible):
|
||||
primary = None
|
||||
|
||||
lname = logical_name or (existing.logical_name if existing else "") or suggest_logical_name(
|
||||
cleaned
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Merge observation; keep locator history
|
||||
if primary is not None:
|
||||
old = existing.primary_locator
|
||||
if old is not None and to_dict(old) != to_dict(primary):
|
||||
existing.locator_history.append(
|
||||
{"replaced_at": existing.last_seen, "locator": to_dict(old)}
|
||||
)
|
||||
existing.primary_locator = primary
|
||||
existing.locator_candidates = cands
|
||||
existing.fallback_locators = fallbacks
|
||||
existing.touch()
|
||||
existing.raw = cleaned
|
||||
existing.bounds = dict(cleaned.get("bounds") or cleaned.get("rect") or existing.bounds or {})
|
||||
if params:
|
||||
existing.parameterized.update(params)
|
||||
existing.sensitive = existing.sensitive or sensitive
|
||||
existing.verification_level = "live" if live_counts else "snapshot_only"
|
||||
self._by_fp[fp] = existing
|
||||
self._by_identity[identity] = existing
|
||||
if primary and primary.actual_count is not None:
|
||||
existing.verified_count = primary.actual_count
|
||||
self.save()
|
||||
return existing
|
||||
|
||||
el = ElementRef(
|
||||
logical_name=lname,
|
||||
surface=surface,
|
||||
backend=backend,
|
||||
window_scope=win,
|
||||
element_scope={
|
||||
"page_url": str((frame or {}).get("url") or win.get("url") or ""),
|
||||
"frame_name": str((frame or {}).get("name") or ""),
|
||||
},
|
||||
role=str(cleaned.get("role") or ""),
|
||||
control_type=str(cleaned.get("control_type") or ""),
|
||||
name=str(cleaned.get("name") or ""),
|
||||
value=str(cleaned.get("value") or ""),
|
||||
automation_id=str(cleaned.get("automation_id") or ""),
|
||||
class_name=str(cleaned.get("class_name") or ""),
|
||||
framework_id=str(cleaned.get("framework_id") or ""),
|
||||
states={
|
||||
"is_enabled": cleaned.get("is_enabled"),
|
||||
"is_offscreen": cleaned.get("is_offscreen"),
|
||||
},
|
||||
bounds=dict(cleaned.get("bounds") or cleaned.get("rect") or {}),
|
||||
parent_chain=list(cleaned.get("parent_chain") or []),
|
||||
raw=cleaned,
|
||||
fingerprint=fp,
|
||||
sensitive=sensitive,
|
||||
locator_candidates=cands,
|
||||
primary_locator=primary,
|
||||
fallback_locators=fallbacks,
|
||||
verification_level="live" if live_counts else "snapshot_only",
|
||||
verified_count=(primary.actual_count if primary else None),
|
||||
parameterized=params,
|
||||
stability_score=float(primary.stability_score) if primary else 0.0,
|
||||
)
|
||||
# Ensure unique logical_name
|
||||
base = el.logical_name
|
||||
i = 2
|
||||
while el.logical_name in self._by_name:
|
||||
el.logical_name = f"{base}_{i}"
|
||||
i += 1
|
||||
|
||||
self.catalog.elements.append(el)
|
||||
self._by_fp[fp] = el
|
||||
self._by_identity[identity] = el
|
||||
self._by_name[el.logical_name] = el
|
||||
self.save()
|
||||
return el
|
||||
Reference in New Issue
Block a user