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