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:
2026-08-07 11:19:14 +08:00
parent 46bc573777
commit 633ecff4e2
92 changed files with 13704 additions and 288 deletions

View File

@@ -0,0 +1,2 @@
"""Surface-independent UI Inspector domain and recording pipeline."""

View 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",
]

View File

@@ -0,0 +1,6 @@
"""Shared event, action, and evidence pipeline."""
from .event_journal import EventJournal
__all__ = ["EventJournal"]

View 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

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

View 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()

View 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