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:
2
tools/ui_inspector/core/__init__.py
Normal file
2
tools/ui_inspector/core/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Surface-independent UI Inspector domain and recording pipeline."""
|
||||
|
||||
16
tools/ui_inspector/core/models/__init__.py
Normal file
16
tools/ui_inspector/core/models/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Canonical model exports.
|
||||
|
||||
The compatibility ``models`` package remains importable while callers migrate
|
||||
to this namespace.
|
||||
"""
|
||||
|
||||
from models.action import ActionFlow, ActionRecord
|
||||
from models.element import ElementCatalog, ElementRef
|
||||
from models.locator import LocatorCandidate
|
||||
from models.observation import UIObservation
|
||||
from models.state import UIState
|
||||
|
||||
__all__ = [
|
||||
"ActionFlow", "ActionRecord", "ElementCatalog", "ElementRef",
|
||||
"LocatorCandidate", "UIObservation", "UIState",
|
||||
]
|
||||
6
tools/ui_inspector/core/pipeline/__init__.py
Normal file
6
tools/ui_inspector/core/pipeline/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Shared event, action, and evidence pipeline."""
|
||||
|
||||
from .event_journal import EventJournal
|
||||
|
||||
__all__ = ["EventJournal"]
|
||||
|
||||
49
tools/ui_inspector/core/pipeline/event_journal.py
Normal file
49
tools/ui_inspector/core/pipeline/event_journal.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Append-only raw interaction journal.
|
||||
|
||||
Raw events are facts. They are persisted before semantic reduction so a
|
||||
future reducer can rebuild Action Flow without asking the user to repeat the
|
||||
workflow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from redaction import sanitize_artifact
|
||||
|
||||
|
||||
class EventJournal:
|
||||
def __init__(self, flow_dir: Path) -> None:
|
||||
self.path = Path(flow_dir) / "journal" / "raw_events.jsonl"
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.Lock()
|
||||
self._sequence = 0
|
||||
|
||||
def append(self, event: Any, *, channel: str = "surface") -> int:
|
||||
if is_dataclass(event) and not isinstance(event, type):
|
||||
payload = asdict(event)
|
||||
elif isinstance(event, dict):
|
||||
payload = dict(event)
|
||||
else:
|
||||
payload = dict(getattr(event, "__dict__", {}) or {})
|
||||
with self._lock:
|
||||
self._sequence += 1
|
||||
record = {
|
||||
"journal_sequence": self._sequence,
|
||||
"received_at": datetime.now().astimezone().isoformat(),
|
||||
"channel": channel,
|
||||
"event": sanitize_artifact(payload),
|
||||
}
|
||||
line = json.dumps(
|
||||
record, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
with self.path.open("a", encoding="utf-8", newline="\n") as stream:
|
||||
stream.write(line + "\n")
|
||||
stream.flush()
|
||||
return self._sequence
|
||||
|
||||
7
tools/ui_inspector/core/policies/__init__.py
Normal file
7
tools/ui_inspector/core/policies/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Surface-independent decision policies."""
|
||||
|
||||
from .element_identity import element_identity_key
|
||||
from .target_scoring import resolve_browser_target
|
||||
|
||||
__all__ = ["element_identity_key", "resolve_browser_target"]
|
||||
|
||||
62
tools/ui_inspector/core/policies/element_identity.py
Normal file
62
tools/ui_inspector/core/policies/element_identity.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Stable element identity independent of transient value/focus classes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
def _origin(url: str) -> str:
|
||||
try:
|
||||
parts = urlsplit(url or "")
|
||||
return f"{parts.scheme}://{parts.netloc}" if parts.netloc else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _structural_css(css: str) -> str:
|
||||
# Classes frequently encode focus/hover/build hashes. Keep tag depth and
|
||||
# stable IDs; that distinguishes nested anonymous nodes without freezing a
|
||||
# generated class list into identity.
|
||||
parts = []
|
||||
for raw in (css or "").split(">"):
|
||||
part = raw.strip()
|
||||
tag_match = re.match(r"^[A-Za-z][\w-]*", part)
|
||||
id_match = re.search(r"#[A-Za-z0-9_-]+", part)
|
||||
tag = tag_match.group(0).lower() if tag_match else "*"
|
||||
parts.append(tag + (id_match.group(0) if id_match else ""))
|
||||
return ">".join(parts)
|
||||
|
||||
|
||||
def element_identity_key(
|
||||
element: dict[str, Any], *, surface: str, page_url: str = "", frame_name: str = ""
|
||||
) -> str:
|
||||
el = dict(element or {})
|
||||
role = str(el.get("control_type") or el.get("role") or el.get("tag") or "")
|
||||
anchor = ""
|
||||
for key in (
|
||||
"test_id", "automation_id", "id", "name_attr", "href",
|
||||
"placeholder", "aria_label", "label",
|
||||
):
|
||||
value = str(el.get(key) or "").strip()
|
||||
if value:
|
||||
anchor = f"{key}:{value}"
|
||||
break
|
||||
if not anchor and not el.get("contenteditable"):
|
||||
name = str(el.get("semantic_name") or el.get("name") or "").strip()
|
||||
if name and len(name) <= 96:
|
||||
anchor = f"name:{name}"
|
||||
if not anchor:
|
||||
anchor = f"structure:{_structural_css(str(el.get('css') or ''))}"
|
||||
payload = {
|
||||
"surface": surface or "",
|
||||
"origin": _origin(page_url) if surface == "browser" else "",
|
||||
"frame": frame_name or "",
|
||||
"role": role.lower(),
|
||||
"anchor": anchor,
|
||||
}
|
||||
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
145
tools/ui_inspector/core/policies/target_scoring.py
Normal file
145
tools/ui_inspector/core/policies/target_scoring.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Distance- and geometry-aware target selection.
|
||||
|
||||
The resolver never relies on a product selector. It ranks the composed path
|
||||
using native semantics, explicit accessibility metadata, distance from the hit
|
||||
node, and geometry. Huge ancestors are evidence containers, not controls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Iterable
|
||||
|
||||
_NATIVE_TAGS = {"button", "a", "input", "textarea", "select", "summary", "label"}
|
||||
_INTERACTIVE_ROLES = {
|
||||
"button", "link", "textbox", "combobox", "checkbox", "radio",
|
||||
"menuitem", "option", "tab", "switch", "slider", "spinbutton",
|
||||
}
|
||||
_ACTION_CLASS_TOKENS = {
|
||||
"button", "btn", "submit", "send", "publish", "post", "play",
|
||||
"pause", "action", "click", "toggle", "menu", "option", "tab",
|
||||
}
|
||||
_GRAPHIC_LEAF_TAGS = {"path", "use", "g", "svg"}
|
||||
|
||||
|
||||
def _tokens(value: str) -> set[str]:
|
||||
value = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", value or "")
|
||||
return {x.lower() for x in re.split(r"[^A-Za-z0-9]+", value) if x}
|
||||
|
||||
|
||||
def _area(rect: dict[str, Any]) -> float:
|
||||
try:
|
||||
return max(0.0, float(rect.get("width") or 0)) * max(
|
||||
0.0, float(rect.get("height") or 0)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _contains(
|
||||
rect: dict[str, Any], x: int | None, y: int | None, *, slop: float = 12.0
|
||||
) -> bool:
|
||||
if x is None or y is None or not rect:
|
||||
return True
|
||||
try:
|
||||
return (
|
||||
float(rect.get("left")) - slop <= x <= float(rect.get("right")) + slop
|
||||
and float(rect.get("top")) - slop <= y <= float(rect.get("bottom")) + slop
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return True
|
||||
|
||||
|
||||
def resolve_browser_target(
|
||||
hit_target: dict[str, Any],
|
||||
candidates: Iterable[dict[str, Any]],
|
||||
*,
|
||||
x: int | None = None,
|
||||
y: int | None = None,
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
"""Return the best actionable node and an auditable scored candidate list."""
|
||||
chain = [dict(item or {}) for item in candidates if isinstance(item, dict)]
|
||||
if not chain:
|
||||
chain = [dict(hit_target or {})]
|
||||
hit = dict(hit_target or chain[0] or {})
|
||||
hit_area = max(1.0, _area(hit.get("rect") or {}))
|
||||
scored: list[dict[str, Any]] = []
|
||||
|
||||
for depth, item in enumerate(chain):
|
||||
tag = str(item.get("tag") or "").lower()
|
||||
role = str(item.get("role") or "").lower()
|
||||
explicit_role = str(item.get("explicit_role") or "").lower()
|
||||
rect = dict(item.get("rect") or {})
|
||||
area = _area(rect)
|
||||
score = 24.0 if depth == 0 else 0.0
|
||||
reasons: list[str] = [f"depth={depth}"]
|
||||
|
||||
if tag in _NATIVE_TAGS:
|
||||
score = max(score, 100.0)
|
||||
reasons.append("native_control")
|
||||
if item.get("contenteditable_attr"):
|
||||
score = max(score, 99.0)
|
||||
reasons.append("editable_root")
|
||||
elif item.get("contenteditable"):
|
||||
score = max(score, 82.0)
|
||||
reasons.append("editable_descendant")
|
||||
if explicit_role in _INTERACTIVE_ROLES:
|
||||
score = max(score, 97.0)
|
||||
reasons.append("explicit_interactive_role")
|
||||
elif role in _INTERACTIVE_ROLES:
|
||||
score = max(score, 88.0)
|
||||
reasons.append("computed_interactive_role")
|
||||
if item.get("test_id") or item.get("aria_label"):
|
||||
score = max(score, 91.0)
|
||||
reasons.append("stable_semantic_attribute")
|
||||
if item.get("tabindex") not in (None, ""):
|
||||
score = max(score, 76.0)
|
||||
reasons.append("keyboard_focusable")
|
||||
if _tokens(str(item.get("class_name") or "")) & _ACTION_CLASS_TOKENS:
|
||||
score = max(score, 82.0)
|
||||
reasons.append("action_class_token")
|
||||
if str(item.get("cursor") or "").lower() == "pointer":
|
||||
score = max(score, 68.0)
|
||||
reasons.append("pointer_cursor")
|
||||
|
||||
# Target selection and locator quality are one decision. Event-time
|
||||
# counts are captured before a click can navigate or mutate the DOM.
|
||||
counts = [
|
||||
item.get("stable_css_count"),
|
||||
item.get("href_count"),
|
||||
item.get("css_count"),
|
||||
]
|
||||
known_counts = [int(value) for value in counts if value is not None]
|
||||
if 1 in known_counts:
|
||||
score += 22.0
|
||||
reasons.append("event_time_unique_locator")
|
||||
elif known_counts and min(known_counts) > 1:
|
||||
score -= 18.0
|
||||
reasons.append("event_time_ambiguous_locator")
|
||||
|
||||
if tag in _GRAPHIC_LEAF_TAGS:
|
||||
score -= 28.0
|
||||
reasons.append("graphic_leaf")
|
||||
elif (
|
||||
str(item.get("cursor") or "").lower() == "pointer"
|
||||
and 20.0 <= float(rect.get("width") or 0.0) <= 96.0
|
||||
and 20.0 <= float(rect.get("height") or 0.0) <= 96.0
|
||||
):
|
||||
score += 14.0
|
||||
reasons.append("compact_clickable_wrapper")
|
||||
|
||||
score -= depth * 3.5
|
||||
if not _contains(rect, x, y):
|
||||
score -= 100.0
|
||||
reasons.append("outside_pointer")
|
||||
if area and area > max(hit_area * 25.0, 200_000.0):
|
||||
score -= 70.0
|
||||
reasons.append("oversized_ancestor")
|
||||
elif area and area > max(hit_area * 8.0, 60_000.0):
|
||||
score -= 25.0
|
||||
reasons.append("large_ancestor")
|
||||
|
||||
scored.append({"depth": depth, "score": round(score, 3), "reasons": reasons, "target": item})
|
||||
|
||||
winner = max(scored, key=lambda row: (row["score"], -row["depth"]))
|
||||
return dict(winner["target"]), scored
|
||||
Reference in New Issue
Block a user