assistant/backend/browser/progress_guard.py

132 lines
6.2 KiB
Python

"""Task-local semantic no-progress protection, independent of DOM node indices."""
from __future__ import annotations
import hashlib
import json
import re
from urllib.parse import parse_qs, urlsplit
def _digest(value) -> str:
return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
def page_identity(url: str) -> str:
try:
parts = urlsplit(url)
keyword = (parse_qs(parts.query).get("keyword") or [""])[0] if parts.path.rstrip("/") in {"/search_result", "/search_result_ai"} else ""
return _digest([parts.hostname, parts.path.rstrip("/"), keyword])
except (TypeError, ValueError):
return _digest("unobserved")
def _node_text(node) -> str:
try:
value = node.get_all_children_text(max_depth=2)
except (AttributeError, TypeError, ValueError):
value = getattr(node, "node_value", "")
return re.sub(r"\s+", " ", str(value or "")).strip()[:512]
def _profile_target(node, artifacts: dict) -> str | None:
"""Guard repeated attempts only; this never verifies a person's identity."""
authors = {str(row.get("author")): row for row in artifacts.get("sources") or [] if row.get("author")}
for _ in range(3):
if node is None:
break
attrs = getattr(node, "attributes", {}) or {}
href = attrs.get("href", "")
try:
parts = urlsplit(href)
if re.fullmatch(r"/user/profile/[A-Fa-f0-9]{24}/?", parts.path) and parts.hostname in {None, "www.xiaohongshu.com", "xiaohongshu.com"}:
return _digest(["observed_profile", parts.path.rstrip("/")])
except (TypeError, ValueError):
pass
text = _node_text(node)
if text and text in authors:
return _digest(["observed_source_author_label", text])
node = getattr(node, "parent_node", None)
return None
def operation_identity(state, output, artifacts: dict) -> tuple[list[str], bool]:
rows = []
reset = False
selector_map = getattr(getattr(state, "dom_state", None), "selector_map", {}) or {}
for action in getattr(output, "action", None) or []:
value = action.model_dump(exclude_none=True)
for name, args in value.items():
args = args if isinstance(args, dict) else {}
if name in {"wait", "done"}:
continue
if name == "navigate":
rows.append(["navigate", page_identity(args.get("url", ""))])
continue
if name == "go_back":
rows.append(["go_back"])
continue
if name == "open_source_profile":
rows.append(["open_source_profile", _digest(args.get("source_id", "unknown"))])
continue
if name == "click":
node = selector_map.get(args.get("index"))
target = _profile_target(node, artifacts)
if target:
rows.append(["open_source_profile", target])
continue
if node is not None:
attrs = getattr(node, "attributes", {}) or {}
rows.append(["click", _digest([getattr(node, "node_name", ""), _node_text(node),
attrs.get("role"), attrs.get("aria-label"), page_identity(attrs.get("href", ""))])])
continue
# Index values and model wording cannot distinguish two attempts
# at the same program operation. Other parameter values stay only
# inside a digest, never in logs or knowledge.
stable = {key: item for key, item in args.items() if key not in {"index", "element_index", "coordinate_x", "coordinate_y"}}
rows.append([name, _digest(stable)])
return [_digest(row) for row in rows], reset
def evidence_identity(artifacts: dict) -> str:
sources = [(row.get("source_id"), bool(row.get("author_profile_url"))) for row in artifacts.get("sources") or []]
profiles = [(row.get("source_id"), bool(row.get("xiaohongshu_id"))) for row in artifacts.get("profiles") or []]
progress = [(row.get("capability"), row.get("object_key"), row.get("post_fingerprint"))
for row in artifacts.get("navigation_attempts") or []
if row.get("status") in {"comments_visible", "scrolled", "profile_opened"}]
expanded = [row.get("id") for row in artifacts.get("operation_evidence") or []
if row.get("capability") == "expand_replies" and row.get("outcome") == "success"]
return _digest([sources, profiles, expanded, progress])
class ProgressGuard:
def __init__(self, repeats: int = 5):
self.limit = repeats
self.context = None
self.counts: dict[str, int] = {}
self.repeated = 0
def observe(self, state, output, artifacts: dict) -> bool:
operations, reset = operation_identity(state, output, artifacts)
# DOM text is intentionally excluded: hovercards, temporary indices,
# animations and unrelated recommendations are not task progress.
info = getattr(state, "page_info", None)
# A requested scroll is not progress until an observed scroll position
# or a program-owned navigation fact actually changes. Inner note
# containers use the dedicated tool's verified scrolled fact.
position = tuple(value if type(value) is int else None for value in (
getattr(info, "scroll_x", None), getattr(info, "scroll_y", None), getattr(state, "pixels_above", None)))
context = (page_identity(getattr(state, "url", "")), evidence_identity(artifacts), position)
if reset or context != self.context:
self.context, self.counts, self.repeated = context, {}, 0
if reset:
return False
# Each operation/target has its own counter. A wait or an intervening
# observation cannot erase attempts to open the same author. Empty
# repeated inspections retain their existing independent protection.
for operation in operations:
self.counts[operation] = self.counts.get(operation, 0) + 1
self.repeated = self.counts[operation] - 1
if self.repeated >= self.limit:
return True
return False