Files
skill-template/tools/ui_inspector/locators/catalog.py
chendelian 633ecff4e2 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>
2026-08-07 11:19:14 +08:00

295 lines
13 KiB
Python

"""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