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:
175
tools/ui_inspector/models/state.py
Normal file
175
tools/ui_inspector/models/state.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""UIState model + stable business fingerprint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
SCHEMA_STATE = "openclaw.ui_inspector.state.v1"
|
||||
|
||||
# Keys ignored when hashing business identity.
|
||||
_FINGERPRINT_EXCLUDE_TOP = {
|
||||
"captured_at",
|
||||
"id",
|
||||
"previous_state_id",
|
||||
"tree_path",
|
||||
"index_path",
|
||||
"locatable_path",
|
||||
"screenshot_path",
|
||||
"duplicate_of",
|
||||
"observation_count",
|
||||
"change_reason",
|
||||
}
|
||||
|
||||
_SESSION_KEYS = {
|
||||
"pid",
|
||||
"hwnd",
|
||||
"process_id",
|
||||
"thread_id",
|
||||
"elapsed_ms",
|
||||
"dump_ms",
|
||||
"warmup_ms",
|
||||
"timings",
|
||||
"screenshot",
|
||||
"screen_path",
|
||||
}
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now().astimezone().isoformat()
|
||||
|
||||
|
||||
def _new_id(prefix: str = "st") -> str:
|
||||
return f"{prefix}_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def _strip_session(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
out: dict[str, Any] = {}
|
||||
for k, v in obj.items():
|
||||
lk = str(k).lower()
|
||||
if lk in _SESSION_KEYS or lk.endswith("_ms") or lk.endswith("_path"):
|
||||
continue
|
||||
if lk in {"left", "top", "right", "bottom", "x", "y", "width", "height"}:
|
||||
# Absolute coordinates are not primary identity.
|
||||
continue
|
||||
if lk == "rect" or lk == "bounds" or lk == "bounding_rectangle":
|
||||
continue
|
||||
out[str(k)] = _strip_session(v)
|
||||
return out
|
||||
if isinstance(obj, list):
|
||||
return [_strip_session(x) for x in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def _locatable_signature(rows: list[dict[str, Any]], *, limit: int = 120) -> list[dict[str, Any]]:
|
||||
"""Compact visible-business signature for fingerprinting."""
|
||||
sig: list[dict[str, Any]] = []
|
||||
for r in rows[:limit]:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
entry = {
|
||||
"control_type": r.get("control_type") or r.get("role") or "",
|
||||
"name": r.get("name") or "",
|
||||
"value": r.get("value") or r.get("text") or "",
|
||||
"automation_id": r.get("automation_id") or r.get("test_id") or "",
|
||||
"class_name": r.get("class_name") or "",
|
||||
}
|
||||
# Drop empty noise rows
|
||||
if not any(str(v).strip() for v in entry.values()):
|
||||
continue
|
||||
sig.append(entry)
|
||||
# Stable order
|
||||
sig.sort(
|
||||
key=lambda e: (
|
||||
str(e.get("control_type") or ""),
|
||||
str(e.get("automation_id") or ""),
|
||||
str(e.get("name") or ""),
|
||||
str(e.get("value") or ""),
|
||||
)
|
||||
)
|
||||
return sig
|
||||
|
||||
|
||||
def compute_state_fingerprint(
|
||||
*,
|
||||
surface: str,
|
||||
driver: str = "",
|
||||
url: str = "",
|
||||
page_title: str = "",
|
||||
window: Optional[dict[str, Any]] = None,
|
||||
locatable: Optional[list[dict[str, Any]]] = None,
|
||||
app: Optional[dict[str, Any]] = None,
|
||||
extra_business: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
SHA-256 of stable business identity.
|
||||
|
||||
Excludes timestamps, pid/hwnd, screenshot paths, absolute coords, timings.
|
||||
"""
|
||||
win = dict(window or {})
|
||||
app_d = dict(app or {})
|
||||
payload = {
|
||||
"surface": surface or "",
|
||||
"driver": driver or "",
|
||||
"url": url or win.get("url") or app_d.get("url") or "",
|
||||
"page_title": page_title or "",
|
||||
"window": {
|
||||
"title": win.get("title") or app_d.get("title") or "",
|
||||
"class_name": win.get("class_name") or app_d.get("class_name") or "",
|
||||
"process_name": win.get("process_name") or app_d.get("process_name") or "",
|
||||
},
|
||||
"locatable": _locatable_signature(list(locatable or [])),
|
||||
"extra": _strip_session(extra_business or {}),
|
||||
}
|
||||
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass
|
||||
class UIState:
|
||||
schema: str = SCHEMA_STATE
|
||||
id: str = field(default_factory=lambda: _new_id("st"))
|
||||
surface: str = "windows"
|
||||
driver: str = ""
|
||||
captured_at: str = field(default_factory=_now_iso)
|
||||
fingerprint: str = ""
|
||||
previous_state_id: Optional[str] = None
|
||||
change_reason: str = ""
|
||||
app: dict[str, Any] = field(default_factory=dict)
|
||||
window: dict[str, Any] = field(default_factory=dict)
|
||||
url: str = ""
|
||||
page_title: str = ""
|
||||
tree_path: str = ""
|
||||
index_path: str = ""
|
||||
locatable_path: str = ""
|
||||
screenshot_path: str = ""
|
||||
stats: dict[str, Any] = field(default_factory=dict)
|
||||
selection: dict[str, Any] = field(default_factory=dict)
|
||||
duplicate_of: Optional[str] = None
|
||||
observation_count: int = 1
|
||||
may_contain_sensitive_data: bool = False
|
||||
# In-memory locatable used for fingerprint/diff (not always persisted as field).
|
||||
locatable: list[dict[str, Any]] = field(default_factory=list, repr=False)
|
||||
|
||||
def ensure_fingerprint(self) -> str:
|
||||
if self.fingerprint:
|
||||
return self.fingerprint
|
||||
self.fingerprint = compute_state_fingerprint(
|
||||
surface=self.surface,
|
||||
driver=self.driver,
|
||||
url=self.url,
|
||||
page_title=self.page_title,
|
||||
window=self.window,
|
||||
locatable=self.locatable,
|
||||
app=self.app,
|
||||
)
|
||||
return self.fingerprint
|
||||
|
||||
def bump_observation(self) -> None:
|
||||
self.observation_count = int(self.observation_count or 0) + 1
|
||||
self.captured_at = _now_iso()
|
||||
Reference in New Issue
Block a user