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>
295 lines
13 KiB
Python
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
|