From afc96a31cd3c2ee81cd28cae0d6bd0c03f54c378 Mon Sep 17 00:00:00 2001 From: premkumar777-sys Date: Sat, 12 Sep 2026 14:48:23 +0530 Subject: [PATCH] agent --- agent/__init__.py | 1 + agent/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 176 bytes agent/__pycache__/detector.cpython-314.pyc | Bin 0 -> 21129 bytes agent/__pycache__/recovery.cpython-314.pyc | Bin 0 -> 5728 bytes agent/detector.py | 452 ++++++++++++++++++ agent/recovery.py | 160 +++++++ ...ctor_recovery.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 21027 bytes .../test_detector_recovery.cpython-314.pyc | Bin 0 -> 3053 bytes agent/tests/test_detector_recovery.py | 146 ++++++ 9 files changed, 759 insertions(+) create mode 100644 agent/__init__.py create mode 100644 agent/__pycache__/__init__.cpython-314.pyc create mode 100644 agent/__pycache__/detector.cpython-314.pyc create mode 100644 agent/__pycache__/recovery.cpython-314.pyc create mode 100644 agent/detector.py create mode 100644 agent/recovery.py create mode 100644 agent/tests/__pycache__/test_detector_recovery.cpython-314-pytest-9.1.1.pyc create mode 100644 agent/tests/__pycache__/test_detector_recovery.cpython-314.pyc create mode 100644 agent/tests/test_detector_recovery.py diff --git a/agent/__init__.py b/agent/__init__.py new file mode 100644 index 000000000..7212a1e65 --- /dev/null +++ b/agent/__init__.py @@ -0,0 +1 @@ +"""Browser change-detection and recovery package.""" diff --git a/agent/__pycache__/__init__.cpython-314.pyc b/agent/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0185b0a97ddc7152551485bd6ad51c27ee739847 GIT binary patch literal 176 zcmdPq<6;1U0>-6Tra<~Jhyw$RP{wDFk_Lt#h7yJ#Mr8&~rYb$BqWtpW)FOrCjKsY3 zRNa)+lGNmq%=|os#Jm)RqSWO4vecqVg@VN7?8Nj`JwHvxTkP@iDf!9q@hcfVgN(f; z2$aYx(T|VM%*!l^kJl@xyv1RYo1apelWJGQ22=sEy_f??d|+l|WW3E_Qp5t}0083o BEF%B_ literal 0 HcmV?d00001 diff --git a/agent/__pycache__/detector.cpython-314.pyc b/agent/__pycache__/detector.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..47c63ff98665711687996351d0adfe8478b3455c GIT binary patch literal 21129 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 bool: + return isinstance(value, Mapping) + + +def _normalize_text(value: Any) -> str: + if value is None: + return "" + normalized = str(value).lower().translate(_PUNCT_TRANSLATION) + return re.sub(r"\s+", " ", normalized).strip() + + +def _tokenize(value: Any) -> list[str]: + normalized = _normalize_text(value) + return normalized.split() if normalized else [] + + +def _candidate_label(candidate: Any) -> str: + if not _is_mapping(candidate): + return str(candidate) + + for key in ("name", "text", "aria_label", "label", "title", "placeholder"): + value = candidate.get(key) + if value: + return str(value) + + return str(candidate.get("role", "")) + + +def _candidate_role(candidate: Any) -> str: + if not _is_mapping(candidate): + return "" + + for key in ("role", "aria_role", "tag", "type"): + value = candidate.get(key) + if value: + return _normalize_text(value) + + return "" + + +def _expected_label(expected: Any) -> str: + if _is_mapping(expected): + for key in ("name", "text", "aria_label", "label", "title", "placeholder"): + value = expected.get(key) + if value: + return str(value) + return str(expected) + + +def _expected_role(expected: Any) -> str: + if not _is_mapping(expected): + return "" + + for key in ("role", "aria_role", "tag", "type"): + value = expected.get(key) + if value: + return _normalize_text(value) + return "" + + +def _extract_candidates(current_elements: Any) -> list[Any]: + if current_elements is None: + return [] + + if _is_mapping(current_elements): + for key in ("elements", "candidates", "observed_elements", "nodes", "items"): + value = current_elements.get(key) + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return list(value) + if "role" in current_elements or "text" in current_elements or "name" in current_elements: + return [current_elements] + return [current_elements] + + if isinstance(current_elements, Sequence) and not isinstance(current_elements, (str, bytes, bytearray)): + return list(current_elements) + + return [current_elements] + + +def _synonym_similarity(left: str, right: str) -> float: + if not left or not right: + return 0.0 + if left == right: + return 1.0 + + for group in _ACTION_SYNONYMS: + if left in group and right in group: + return 0.85 + return 0.0 + + +def _token_similarity(expected_tokens: list[str], observed_tokens: list[str]) -> float: + if not expected_tokens and not observed_tokens: + return 1.0 + if not expected_tokens or not observed_tokens: + return 0.0 + + def _best_score(source: list[str], target: list[str]) -> float: + scores: list[float] = [] + for left in source: + best = 0.0 + for right in target: + best = max(best, _synonym_similarity(left, right)) + if best < 1.0: + best = max(best, SequenceMatcher(None, left, right).ratio()) + scores.append(best) + return sum(scores) / len(scores) if scores else 0.0 + + forward = _best_score(expected_tokens, observed_tokens) + reverse = _best_score(observed_tokens, expected_tokens) + return (forward + reverse) / 2.0 + + +def _label_similarity(expected_label: str, observed_label: str) -> tuple[float, str]: + expected_raw = str(expected_label).strip() + observed_raw = str(observed_label).strip() + if expected_raw == observed_raw: + return 1.0, "exact_match" + + expected_normalized = _normalize_text(expected_raw) + observed_normalized = _normalize_text(observed_raw) + if expected_normalized == observed_normalized: + return 0.99, "normalized_match" + + sequence_score = SequenceMatcher(None, expected_normalized, observed_normalized).ratio() + token_score = _token_similarity(_tokenize(expected_raw), _tokenize(observed_raw)) + score = (sequence_score * 0.45) + (token_score * 0.55) + return round(score, 4), "fuzzy_match" + + +def _structural_bonus(expected: Any, candidate: Any) -> float: + if not (_is_mapping(expected) and _is_mapping(candidate)): + return 0.0 + + bonus = 0.0 + expected_role = _expected_role(expected) + candidate_role = _candidate_role(candidate) + if expected_role and expected_role == candidate_role: + bonus += 0.06 + + for key in ("visible", "enabled", "focused", "selected", "checked"): + if key in expected and key in candidate and expected.get(key) == candidate.get(key): + bonus += 0.02 + + expected_parent = expected.get("parent") + candidate_parent = candidate.get("parent") + if expected_parent is not None and candidate_parent is not None and expected_parent == candidate_parent: + bonus += 0.05 + + expected_index = expected.get("index") + candidate_index = candidate.get("index") + if expected_index is not None and candidate_index is not None and expected_index == candidate_index: + bonus += 0.03 + + return min(bonus, 0.15) + + +def _score_candidate(expected: Any, candidate: Any) -> dict[str, Any]: + expected_label = _expected_label(expected) + observed_label = _candidate_label(candidate) + score, label_match_type = _label_similarity(expected_label, observed_label) + score = min(score + _structural_bonus(expected, candidate), 1.0) + + expected_role = _expected_role(expected) + candidate_role = _candidate_role(candidate) + role_match = bool(expected_role and expected_role == candidate_role) + + if label_match_type == "exact_match": + confidence = 1.0 + elif label_match_type == "normalized_match": + confidence = 0.99 + elif role_match and score >= 0.75: + confidence = round(min(score + 0.03, 1.0), 4) + else: + confidence = round(score, 4) + + if label_match_type == "exact_match": + match_type = "exact_match" + elif label_match_type == "normalized_match": + match_type = "normalized_match" + elif role_match and score >= 0.7: + match_type = "role_text_match" + elif score >= 0.7: + match_type = "fuzzy_match" + else: + match_type = "low_similarity" + + if match_type == "role_text_match" and role_match: + change_type = "structural_relocation" + elif match_type in {"exact_match", "normalized_match"}: + change_type = match_type + elif score >= 0.7: + change_type = "renamed_element" + else: + change_type = "no_suitable_candidate" + + return { + "expected": expected_label, + "observed": observed_label, + "score": confidence, + "match_type": match_type, + "change_type": change_type, + "role_match": role_match, + "candidate": candidate, + } + + +def detect_element_change(expected: Any, current_elements: Any) -> dict[str, Any]: + """Compare an expected element against the observed candidates. + + Returns a structured description of the strongest candidate and whether the + expected element appears unchanged, renamed, or absent. + """ + + candidates = _extract_candidates(current_elements) + if not candidates: + return { + "changed": True, + "expected": _expected_label(expected), + "observed_candidates": [], + "best_candidate": None, + "confidence": 0.0, + "change_type": "no_observations", + "matches": [], + } + + scored = [_score_candidate(expected, candidate) for candidate in candidates] + scored.sort(key=lambda item: (item["score"], item["change_type"] == "exact_match"), reverse=True) + best = scored[0] + + observed_labels = [item["observed"] for item in scored] + exact_hit = best["change_type"] == "exact_match" and best["expected"] == best["observed"] + normalized_hit = best["change_type"] == "normalized_match" + + if exact_hit: + changed = False + change_type = "exact_match" + elif normalized_hit: + changed = False + change_type = "normalized_match" + elif best["score"] >= 0.7: + changed = True + change_type = "renamed_element" if best["match_type"] != "role_text_match" else "structural_relocation" + else: + changed = True + change_type = "no_suitable_candidate" + + return { + "changed": changed, + "expected": _expected_label(expected), + "observed_candidates": observed_labels, + "best_candidate": best["observed"], + "confidence": best["score"], + "change_type": change_type, + "matches": scored, + } + + +def detect_page_change(expected_state: Any, current_state: Any) -> dict[str, Any]: + """Compare coarse page state dictionaries. + + The detector focuses on portable fields such as title, url, visible text, + and counts of interactive elements. Missing fields are ignored. + """ + + if not _is_mapping(expected_state) or not _is_mapping(current_state): + return { + "changed": False, + "change_type": "insufficient_state", + "confidence": 0.0, + "mismatches": {}, + } + + tracked_keys = ( + "url", + "title", + "route", + "screen", + "visible_text", + "page_text", + "interactive_count", + "element_count", + ) + mismatches: dict[str, dict[str, Any]] = {} + matches = 0 + considered = 0 + + for key in tracked_keys: + expected_value = expected_state.get(key) + current_value = current_state.get(key) + if expected_value is None or current_value is None: + continue + considered += 1 + if isinstance(expected_value, str) and isinstance(current_value, str): + if _normalize_text(expected_value) == _normalize_text(current_value): + matches += 1 + continue + elif expected_value == current_value: + matches += 1 + continue + mismatches[key] = {"expected": expected_value, "observed": current_value} + + confidence = round(matches / considered, 4) if considered else 0.0 + changed = bool(mismatches) + change_type = "page_changed" if changed else "page_stable" + return { + "changed": changed, + "change_type": change_type, + "confidence": confidence, + "mismatches": mismatches, + } + + +def detect_modal(current_state: Any) -> dict[str, Any]: + """Detect blocking modal/pop-up state from generic observations.""" + + if not _is_mapping(current_state): + return { + "detected": False, + "change_type": "insufficient_state", + "confidence": 0.0, + "modals": [], + } + + modal_entries: list[Any] = [] + for key in ("modals", "dialogs", "overlays", "popups"): + value = current_state.get(key) + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + modal_entries.extend(value) + + if not modal_entries and any(current_state.get(key) for key in ("modal", "dialog", "popup")): + modal_entries.append({key: current_state.get(key) for key in ("modal", "dialog", "popup") if current_state.get(key)}) + + if not modal_entries: + return { + "detected": False, + "change_type": "no_modal", + "confidence": 0.0, + "modals": [], + } + + labels = [_candidate_label(entry) for entry in modal_entries] + return { + "detected": True, + "change_type": "unexpected_modal", + "confidence": 1.0 if labels else 0.0, + "modals": labels, + } + + +def detect_error(current_state: Any) -> dict[str, Any]: + """Detect page or workflow errors from generic observations.""" + + if not _is_mapping(current_state): + return { + "detected": False, + "change_type": "insufficient_state", + "confidence": 0.0, + "messages": [], + } + + messages: list[str] = [] + for key in ("errors", "error_messages", "alerts", "warnings"): + value = current_state.get(key) + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + messages.extend(str(item) for item in value if item) + elif value: + messages.append(str(value)) + + text_blobs = [str(current_state.get(key)) for key in ("page_text", "visible_text", "status_text") if current_state.get(key)] + for blob in text_blobs: + normalized = _normalize_text(blob) + if any(term in normalized for term in ("error", "failed", "failure", "not found", "blocked", "forbidden", "timeout")): + messages.append(blob) + + if not messages: + return { + "detected": False, + "change_type": "no_error", + "confidence": 0.0, + "messages": [], + } + + return { + "detected": True, + "change_type": "unexpected_error", + "confidence": 1.0, + "messages": messages, + } + + +def detect_loading(current_state: Any) -> dict[str, Any]: + """Detect slow-loading or busy states from generic observations.""" + + if not _is_mapping(current_state): + return { + "detected": False, + "change_type": "insufficient_state", + "confidence": 0.0, + } + + explicit_flags = [current_state.get(key) for key in ("loading", "is_loading", "busy", "pending")] + if any(flag is True for flag in explicit_flags): + return { + "detected": True, + "change_type": "loading", + "confidence": 1.0, + } + + text_blobs = [str(current_state.get(key)) for key in ("page_text", "visible_text", "status_text") if current_state.get(key)] + for blob in text_blobs: + normalized = _normalize_text(blob) + if any(term in normalized for term in ("loading", "please wait", "working", "processing", "retrying")): + return { + "detected": True, + "change_type": "loading", + "confidence": 0.8, + } + + return { + "detected": False, + "change_type": "not_loading", + "confidence": 0.0, + } diff --git a/agent/recovery.py b/agent/recovery.py new file mode 100644 index 000000000..5b2f78505 --- /dev/null +++ b/agent/recovery.py @@ -0,0 +1,160 @@ +"""Recovery decision logic for WebCMD-driven browser workflows. + +This module never executes browser actions. It only analyzes observed state and +returns a recovery decision for the integration layer to execute via WebCMD. +""" + +from __future__ import annotations + +from typing import Any, Mapping + +from agent.detector import ( + detect_element_change, + detect_error, + detect_loading, + detect_modal, + detect_page_change, +) + + +def _as_mapping(value: Any) -> Mapping[str, Any] | None: + return value if isinstance(value, Mapping) else None + + +def _best_recovery_strategy(match: dict[str, Any]) -> str: + if match["change_type"] == "exact_match": + return "exact_match" + if match["change_type"] == "normalized_match": + return "normalized_match" + if match["change_type"] == "structural_relocation": + return "structural_relocation" + if match["score"] >= 0.8: + return "fuzzy_relocation" + return "no_suitable_candidate" + + +def recover_action( + expected: Any, + current_elements: Any = None, + *, + expected_state: Mapping[str, Any] | None = None, + current_state: Mapping[str, Any] | None = None, + confidence_threshold: float = 0.8, +) -> dict[str, Any]: + """Return a recovery decision without performing browser actions. + + The returned decision is intentionally generic so that an integration layer + can translate it into a WebCMD browser command. + """ + + element_change = detect_element_change(expected, current_elements) + + if not element_change["observed_candidates"]: + return { + "status": "needs_replan", + "strategy": "no_observations", + "original_target": element_change["expected"], + "replacement_target": None, + "confidence": 0.0, + "reason": "No observed elements were available for recovery", + } + + current_state_map = _as_mapping(current_state) + expected_state_map = _as_mapping(expected_state) + + if current_state_map is not None: + modal = detect_modal(current_state_map) + if modal["detected"]: + return { + "status": "needs_replan", + "strategy": "modal_blocked", + "original_target": element_change["expected"], + "replacement_target": None, + "confidence": modal["confidence"], + "reason": "Unexpected modal or popup is blocking the target", + } + + loading = detect_loading(current_state_map) + if loading["detected"]: + return { + "status": "needs_replan", + "strategy": "wait_for_loading", + "original_target": element_change["expected"], + "replacement_target": None, + "confidence": loading["confidence"], + "reason": "Page is still loading or busy", + } + + error = detect_error(current_state_map) + if error["detected"]: + return { + "status": "needs_replan", + "strategy": "error_detected", + "original_target": element_change["expected"], + "replacement_target": None, + "confidence": error["confidence"], + "reason": "Unexpected error state detected", + } + + if expected_state_map is not None and current_state_map is not None: + page_change = detect_page_change(expected_state_map, current_state_map) + if page_change["changed"] and ( + current_state_map.get("history_back_available") + or current_state_map.get("can_go_back") + or current_state_map.get("backtrack_available") + ): + return { + "status": "needs_backtrack", + "strategy": "backtrack_request", + "original_target": element_change["expected"], + "replacement_target": None, + "confidence": page_change["confidence"], + "reason": "Current page diverged from the expected state and back navigation is available", + } + + if element_change["change_type"] in {"exact_match", "normalized_match"} and not element_change["changed"]: + return { + "status": "recovered", + "strategy": element_change["change_type"], + "original_target": element_change["expected"], + "replacement_target": element_change["best_candidate"], + "confidence": element_change["confidence"], + "reason": "Expected target was found unchanged", + } + + best_match = next( + (match for match in element_change["matches"] if match["observed"] == element_change["best_candidate"]), + None, + ) + if best_match is None: + return { + "status": "needs_replan", + "strategy": "no_match", + "original_target": element_change["expected"], + "replacement_target": None, + "confidence": 0.0, + "reason": "No candidate could be scored", + } + + if best_match["score"] >= confidence_threshold and best_match["change_type"] != "no_suitable_candidate": + strategy = _best_recovery_strategy(best_match) + if strategy == "no_suitable_candidate": + strategy = "fuzzy_relocation" + reason = "Expected target was renamed" if best_match["change_type"] == "renamed_element" else "Expected target was relocated" + return { + "status": "recovered", + "strategy": strategy, + "original_target": element_change["expected"], + "replacement_target": element_change["best_candidate"], + "confidence": best_match["score"], + "reason": reason, + } + + return { + "status": "needs_replan", + "strategy": "low_confidence", + "original_target": element_change["expected"], + "replacement_target": None, + "confidence": best_match["score"], + "reason": "No sufficiently confident replacement found", + } diff --git a/agent/tests/__pycache__/test_detector_recovery.cpython-314-pytest-9.1.1.pyc b/agent/tests/__pycache__/test_detector_recovery.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3f4f5c6089ec38a2ac0b675dd009318b80170941 GIT binary patch literal 21027 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 0.8 + assert recovery["status"] == "recovered" + assert recovery["strategy"] in {"fuzzy_relocation", "normalized_match", "structural_relocation"} + assert recovery["replacement_target"] == "Send Enquiry" + + +def test_unrelated_elements_request_replan(): + expected = "Submit Enquiry" + current = ["Login", "Home", "Profile"] + + result = recover_action(expected, current) + + assert result["status"] == "needs_replan" + assert result["replacement_target"] is None + assert result["reason"] == "No sufficiently confident replacement found" + + +def test_multiple_candidates_choose_only_strong_enough_match(): + expected = "Submit Enquiry" + current = ["Send Enquiry", "Cancel", "Submit Application"] + + detection = detect_element_change(expected, current) + recovery = recover_action(expected, current) + + assert detection["best_candidate"] == "Send Enquiry" + assert detection["confidence"] >= 0.7 + assert recovery["status"] in {"recovered", "needs_replan"} + if recovery["status"] == "recovered": + assert recovery["replacement_target"] == "Send Enquiry" + assert recovery["confidence"] >= 0.7 + + +def test_unexpected_modal_requests_replan(): + expected = "Submit Enquiry" + current = ["Send Enquiry"] + current_state = { + "modals": [ + {"role": "dialog", "name": "International Shopping Transition Alert", "text": "Change Address"}, + ] + } + + result = recover_action(expected, current, current_state=current_state) + + assert result["status"] == "needs_replan" + assert result["strategy"] == "modal_blocked" + assert result["replacement_target"] is None + + +def test_loading_state_requests_replan(): + expected = "Submit Enquiry" + current = ["Send Enquiry"] + current_state = {"loading": True} + + result = recover_action(expected, current, current_state=current_state) + + assert result["status"] == "needs_replan" + assert result["strategy"] == "wait_for_loading" + assert result["replacement_target"] is None + + +def test_page_divergence_requests_backtrack_when_available(): + expected = {"url": "https://shop.example/cart", "title": "Cart"} + current = ["Proceed to checkout"] + current_state = { + "url": "https://shop.example/home", + "title": "Home", + "backtrack_available": True, + } + + result = recover_action( + "Submit Enquiry", + current, + expected_state=expected, + current_state=current_state, + ) + + assert result["status"] == "needs_backtrack" + assert result["strategy"] == "backtrack_request" + assert result["replacement_target"] is None + + +def test_low_confidence_candidate_requests_replan(): + expected = "Submit Enquiry" + current = ["Join Now", "Home", "Profile"] + + detection = detect_element_change(expected, current) + recovery = recover_action(expected, current) + + assert detection["change_type"] == "no_suitable_candidate" + assert detection["confidence"] < 0.7 + assert recovery["status"] == "needs_replan" + assert recovery["strategy"] == "low_confidence" + assert recovery["replacement_target"] is None + + +def test_case_and_whitespace_normalize_to_match(): + expected = "Submit Enquiry" + current = [" submit enquiry "] + + result = detect_element_change(expected, current) + + assert result["changed"] is False + assert result["change_type"] == "normalized_match" + assert result["best_candidate"] == " submit enquiry " + assert result["confidence"] == 0.99