Compare commits
55 Commits
refactor/g
...
f384fbb749
| Author | SHA1 | Date | |
|---|---|---|---|
| f384fbb749 | |||
| 565bdaa568 | |||
| c641204a6b | |||
| 4b645c6fb2 | |||
| c7c7ce29f8 | |||
| b36dde77d8 | |||
| 93b2140844 | |||
| 67c3d464e0 | |||
| d298f03891 | |||
| 604f2d7341 | |||
| cd8f35056c | |||
| 800fb1da98 | |||
| 6cd068f951 | |||
| f46b0b7bcb | |||
| 5fbbe3d273 | |||
| f85d0a8a76 | |||
| f0a54d4e20 | |||
| 0a73c35809 | |||
| e535c10b65 | |||
| 91effbc843 | |||
| 3da3849ca1 | |||
| 51ee7a6793 | |||
| 936da47f61 | |||
| d1e0995148 | |||
| 2f8eebb7e9 | |||
| 9c6f80de9d | |||
| cff7e976e0 | |||
| f6f15ebd9a | |||
| 1cc367697e | |||
| 738a59ac8d | |||
| cd6cecbe27 | |||
| 4af4ddb060 | |||
| f32ee46d8c | |||
| d2de5f91de | |||
| 9a13216064 | |||
| aa5184786e | |||
| 2e1edec56a | |||
| df18a48a84 | |||
| f1a8573be8 | |||
| da7201117c | |||
| fddf14fd67 | |||
| 392abff313 | |||
| 556bd181fa | |||
| 2c44331f03 | |||
| b83b55e02b | |||
| db226ed7c2 | |||
| 47f94b699c | |||
| 96fdbd7db7 | |||
| ca91ae4b33 | |||
| a560225dc9 | |||
| 849fb63426 | |||
| fc44633ebc | |||
| 0f5b71708d | |||
| 0ef2840f79 | |||
| 068a6a616a |
11
.gitignore
vendored
11
.gitignore
vendored
@@ -13,6 +13,9 @@
|
||||
!tests/fixtures/*.xml
|
||||
!tests/fixtures/*.jpg
|
||||
!tests/fixtures/*.json
|
||||
!tests/e2e/fixtures/*.xml
|
||||
!tests/e2e/fixtures/*.jpg
|
||||
!tests/e2e/fixtures/*.json
|
||||
logs/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
@@ -28,8 +31,14 @@ Pipfile.lock
|
||||
*.ini
|
||||
*.db
|
||||
|
||||
# Debug artifacts
|
||||
# Debug artifacts & garbage scripts (Rule 5: KRIEG DEM MÜLL)
|
||||
scratch*.py
|
||||
rewrite_*.py
|
||||
test_*.py
|
||||
!tests/**
|
||||
update_*.py
|
||||
profile_dump.*
|
||||
resp_dump.*
|
||||
test_compress.py
|
||||
test_fixtures.py
|
||||
output.txt
|
||||
|
||||
@@ -29,6 +29,13 @@ Found in `sensors/honeypot_radome.py`.
|
||||
- **Ghost Engagement Guard**: Strips DOM nodes explicitly tagged with `visible-to-user="false"` to prevent triggering Accessibility Hooks.
|
||||
- **VLM Sanity Guard**: Woven into `telepathic_engine.py`, it sends semantic matches for destructive actions (Like/Follow) through a Vision Language Model step to prevent executing semantic "Bait and Switch" tricks.
|
||||
|
||||
### 🧠 Situational Awareness Engine (SAE)
|
||||
Found in `situational_awareness.py`. Handles autonomous obstacle detection, recovery, and learning without hardcoded rules.
|
||||
- **3-Layer Modal Fast-Path**: Eliminates LLM hallucination traps for Instagram-internal modals (surveys, rating prompts) via O(1) deterministic structural checks:
|
||||
1. **Resource-ID Guard**: Detects internal blocking overlays (e.g., `survey_overlay_container`, `nux_overlay`).
|
||||
2. **Dismiss-Button Heuristic**: Cross-validates typical negative actions ("Not Now", "Take Survey") with overlay structures to prevent false positives in post captions.
|
||||
3. **Zero-Deception Fallback**: If structural markers fail, falls back to `ScreenMemoryDB` and ultimately the LLM. Structured invariants always override the semantic cache.
|
||||
|
||||
### 🦾 Biometric Facade (Gaussian Clicks)
|
||||
Found in `device_facade.py`.
|
||||
- Human touches do not follow a flat mathematical uniform grid. The GramPilot simulates genuine **biometric dispersion** using `random.gauss(mu, sigma)`, strictly centering clicks inside a thumb-bias radius (bottom-left skew for right-handers). In tests, this hits a 68% standard deviation precision.
|
||||
|
||||
@@ -47,7 +47,7 @@ def verify_and_switch_account(device, nav_graph, target_username):
|
||||
telepath = TelepathicEngine.get_instance()
|
||||
|
||||
# We ask the semantic engine to find the profile tab, ensuring 100% ID-agnostic behavior
|
||||
profile_tab_node = telepath.find_best_node(xml_dump, "tap profile tab", min_threshold=0.3)
|
||||
profile_tab_node = telepath.find_best_node(xml_dump, "tap profile tab", min_threshold=0.3, device=device)
|
||||
if profile_tab_node:
|
||||
profile_tab = (profile_tab_node["x"], profile_tab_node["y"])
|
||||
except Exception as e:
|
||||
@@ -113,7 +113,7 @@ def verify_and_switch_account(device, nav_graph, target_username):
|
||||
dump_ui_state(
|
||||
device, "identity_guard", {"reason": "account_not_found_in_bottom_sheet", "target": target_username}
|
||||
)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
# Escape the bottom sheet
|
||||
device.press("back")
|
||||
|
||||
@@ -40,7 +40,15 @@ class DarwinDwellPlugin(BehaviorPlugin):
|
||||
logger.info("🐢 [DarwinDwell] Executing organic dwell behaviors...")
|
||||
darwin.execute_micro_wobble(ctx.device)
|
||||
res_score = ctx.shared_state.get("res_score", 1.0)
|
||||
darwin.execute_proof_of_resonance(ctx.device, res_score)
|
||||
darwin.execute_proof_of_resonance(
|
||||
ctx.device,
|
||||
res_score,
|
||||
nav_graph=ctx.cognitive_stack.get("nav_graph"),
|
||||
configs=ctx.configs,
|
||||
resonance_oracle=ctx.cognitive_stack.get("oracle"),
|
||||
username=ctx.username,
|
||||
context_xml=ctx.context_xml or ctx.device.dump_hierarchy(),
|
||||
)
|
||||
else:
|
||||
logger.info("🐢 [DarwinDwell] Darwin engine missing. Falling back to static sleep.")
|
||||
sleep(2.5 * ctx.sleep_mod)
|
||||
|
||||
@@ -49,7 +49,7 @@ class FollowPlugin(BehaviorPlugin):
|
||||
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
if nav_graph.do("tap 'Follow' button"):
|
||||
if nav_graph.do("tap 'Follow' button") or nav_graph.do("tap 'Following' button"):
|
||||
logger.info(f"🤝 [Follow] Followed @{ctx.username} ✓")
|
||||
ctx.session_state.add_interaction(source=ctx.username, succeed=True, followed=True, scraped=False)
|
||||
|
||||
|
||||
@@ -42,6 +42,21 @@ class ObstacleGuardPlugin(BehaviorPlugin):
|
||||
|
||||
misses = ctx.shared_state.get("consecutive_marker_misses", 0)
|
||||
|
||||
# ── System Dialog / Permission Modal (e.g. "Allow Instagram to record audio?") ──
|
||||
if situation == SituationType.OBSTACLE_SYSTEM:
|
||||
logger.warning("⚠️ [ObstacleGuard] System permission dialog detected. Dismissing with BACK...")
|
||||
ctx.device.press("back")
|
||||
sleep(1.5 * ctx.sleep_mod)
|
||||
return BehaviorResult(executed=True, should_skip=True)
|
||||
|
||||
# ── Foreign App Takeover (e.g. browser opened, wrong app in foreground) ──
|
||||
if situation == SituationType.OBSTACLE_FOREIGN_APP:
|
||||
logger.warning("⚠️ [ObstacleGuard] Foreign app detected. Pressing BACK to recover...")
|
||||
ctx.device.press("back")
|
||||
sleep(1.5 * ctx.sleep_mod)
|
||||
return BehaviorResult(executed=True, should_skip=True)
|
||||
|
||||
# ── Instagram Modal / Overlay (survey, "Not Now" prompt, creation flow) ──
|
||||
if situation == SituationType.OBSTACLE_MODAL:
|
||||
if misses >= 2:
|
||||
logger.error("🛑 [ObstacleGuard] Failed to recover from OBSTACLE_MODAL after multiple attempts.")
|
||||
@@ -56,7 +71,7 @@ class ObstacleGuardPlugin(BehaviorPlugin):
|
||||
# Check recovery
|
||||
new_xml = ctx.device.dump_hierarchy()
|
||||
tele = TelepathicEngine.get_instance()
|
||||
best_node = tele.find_best_node(new_xml, intent_description="Dismiss obstacle")
|
||||
best_node = tele.find_best_node(new_xml, intent_description="Dismiss obstacle", device=ctx.device)
|
||||
if best_node:
|
||||
ctx.device.click(best_node.get("x", 0), best_node.get("y", 0))
|
||||
|
||||
|
||||
@@ -29,9 +29,11 @@ class PerfectSnappingPlugin(BehaviorPlugin):
|
||||
if not getattr(self, "_enabled", True):
|
||||
return False
|
||||
|
||||
xml_lower = ctx.context_xml.lower()
|
||||
# Do not snap if we are on a profile page or grid, it's meant for posts.
|
||||
if "profile_tabs_container" in xml_lower or "explore_grid" in xml_lower:
|
||||
# Perfect snapping is only for feed posts.
|
||||
# Do not snap if we are on a profile page, explore grid, or modal.
|
||||
from GramAddict.core.perception.feed_analysis import has_feed_markers
|
||||
|
||||
if not has_feed_markers(ctx.context_xml):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -26,15 +26,24 @@ class PostDataExtractionPlugin(BehaviorPlugin):
|
||||
return 85
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
return getattr(self, "_enabled", True) and ctx.context_xml is not None
|
||||
from GramAddict.core.perception.feed_analysis import has_feed_markers
|
||||
|
||||
return getattr(self, "_enabled", True) and ctx.context_xml is not None and has_feed_markers(ctx.context_xml)
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
logger.debug("🧩 [PostDataExtraction] Extracting post metadata...")
|
||||
post_data = extract_post_content(ctx.context_xml)
|
||||
post_data = extract_post_content(ctx.context_xml, device=ctx.device)
|
||||
|
||||
if post_data:
|
||||
ctx.post_data = post_data
|
||||
ctx.username = post_data.get("username", "")
|
||||
|
||||
if post_data.get("username_missing") or not ctx.username:
|
||||
logger.error(
|
||||
"❌ [PostDataExtraction] FAILED: Post author username is empty or missing! Halting interaction."
|
||||
)
|
||||
return BehaviorResult(executed=False, metadata={"error": "Empty username extracted"})
|
||||
|
||||
logger.info(f"📝 [PostDataExtraction] Post by @{ctx.username} extracted.")
|
||||
return BehaviorResult(executed=True)
|
||||
|
||||
|
||||
@@ -50,8 +50,11 @@ class RepostPlugin(BehaviorPlugin):
|
||||
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
if nav_graph.do("share to story"):
|
||||
logger.info(f"📤 [Repost] Shared post by @{ctx.username} to story ✓")
|
||||
return BehaviorResult(executed=True, interactions=1)
|
||||
# We must click the send post button first
|
||||
if nav_graph.do("tap send post button"):
|
||||
# A modal should appear, now click add to story
|
||||
if nav_graph.do("tap add to story"):
|
||||
logger.info(f"📤 [Repost] Shared post by @{ctx.username} to story ✓")
|
||||
return BehaviorResult(executed=True, interactions=1)
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
|
||||
@@ -49,12 +49,36 @@ class ResonanceEvaluatorPlugin(BehaviorPlugin):
|
||||
tele = ctx.cognitive_stack.get("telepathic")
|
||||
if tele:
|
||||
logger.info("✨ [Resonance] Performing visual vibe check...")
|
||||
persona_interests = getattr(ctx.configs.args, "persona_interests", [])
|
||||
|
||||
# BUG 5 Fix: Read target_audience or persona_interests
|
||||
raw_interests = getattr(ctx.configs.args, "persona_interests", "")
|
||||
if not raw_interests:
|
||||
raw_interests = getattr(ctx.configs.args, "target_audience", "")
|
||||
|
||||
if isinstance(raw_interests, list):
|
||||
persona_interests = [str(i).strip() for i in raw_interests if str(i).strip()]
|
||||
else:
|
||||
persona_interests = [i.strip() for i in str(raw_interests).split(",") if i.strip()]
|
||||
|
||||
vibe = tele.evaluate_post_vibe(ctx.device, persona_interests)
|
||||
vibe_score = vibe.get("quality_score", 5) / 10.0
|
||||
if vibe.get("matches_niche"):
|
||||
vibe_score = min(1.0, vibe_score + 0.2)
|
||||
res_score = (res_score * 0.3) + (vibe_score * 0.7)
|
||||
if vibe is None:
|
||||
logger.warning(
|
||||
"✨ [Resonance] VLM vibe check returned None (truncated JSON?). Keeping neutral score."
|
||||
)
|
||||
else:
|
||||
if vibe.get("is_ad"):
|
||||
logger.info("🛡️ [Resonance Oracle] Visually identified post as an Ad! Skipping...")
|
||||
marker = vibe.get("ad_marker_text")
|
||||
if marker and marker.strip():
|
||||
from GramAddict.core.utils import learn_ad_marker
|
||||
learn_ad_marker(marker, ctx.context_xml)
|
||||
humanized_scroll(ctx.device)
|
||||
return BehaviorResult(executed=True, should_skip=True)
|
||||
|
||||
# BUG 6 Fix: VLM returns {"should_like": true/false}, not "quality_score"
|
||||
should_like = vibe.get("should_like", False)
|
||||
vibe_score = 1.0 if should_like else 0.2
|
||||
res_score = (res_score * 0.3) + (vibe_score * 0.7)
|
||||
|
||||
ctx.shared_state["res_score"] = res_score
|
||||
logger.info(f"📊 [Resonance] Post Score: {res_score:.2f}")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
|
||||
try:
|
||||
import psutil
|
||||
@@ -21,7 +22,6 @@ from GramAddict.core.dojo_engine import DojoEngine
|
||||
|
||||
# Cognitive Stack
|
||||
from GramAddict.core.dopamine_engine import DopamineEngine
|
||||
from GramAddict.core.goap import GoalExecutor
|
||||
from GramAddict.core.growth_brain import GrowthBrain
|
||||
from GramAddict.core.log import configure_logger
|
||||
from GramAddict.core.perception.feed_analysis import (
|
||||
@@ -93,9 +93,8 @@ def check_production_integrity():
|
||||
"""
|
||||
import sys
|
||||
|
||||
# If we are in a pytest session, we expect and allow mocks
|
||||
if "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ:
|
||||
return
|
||||
# We no longer skip this in tests. Production integrity must hold everywhere.
|
||||
pass
|
||||
|
||||
try:
|
||||
from unittest.mock import MagicMock
|
||||
@@ -177,6 +176,15 @@ def start_bot(**kwargs):
|
||||
)
|
||||
persona_interests = [p.strip() for p in persona_raw.split(",") if p.strip()] if persona_raw else []
|
||||
|
||||
global_goal = getattr(configs.args, "goal", None)
|
||||
if global_goal:
|
||||
persona_interests.insert(0, global_goal)
|
||||
logger.info(
|
||||
f"🎯 [Autonomous Directive] Overriding target audience with high-level goal: {global_goal}",
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.GREEN}"},
|
||||
)
|
||||
|
||||
from GramAddict.core.goap import GoalExecutor
|
||||
from GramAddict.core.interaction import LLMWriter
|
||||
from GramAddict.core.qdrant_memory import DMMemoryDB, ParasocialCRMDB
|
||||
from GramAddict.core.resonance_engine import ResonanceEngine
|
||||
@@ -298,7 +306,26 @@ def start_bot(**kwargs):
|
||||
cognitive_stack["dojo"] = dojo
|
||||
|
||||
try:
|
||||
bot_start_time = datetime.now()
|
||||
max_runtime = getattr(configs.args, "max_runtime_minutes", None)
|
||||
|
||||
dopamine.global_start_time = bot_start_time
|
||||
dopamine.global_max_runtime_minutes = max_runtime
|
||||
GoalExecutor.global_start_time = bot_start_time
|
||||
GoalExecutor.global_max_runtime_minutes = max_runtime
|
||||
|
||||
while True:
|
||||
if max_runtime:
|
||||
from datetime import timedelta
|
||||
|
||||
elapsed = datetime.now() - bot_start_time
|
||||
if elapsed > timedelta(minutes=max_runtime):
|
||||
logger.info(
|
||||
f"🛑 [Timeout] Maximum runtime of {max_runtime} minutes reached. Stopping bot.",
|
||||
extra={"color": f"{Fore.RED}"},
|
||||
)
|
||||
break
|
||||
|
||||
set_time_delta(configs.args)
|
||||
inside_working_hours, time_left = SessionState.inside_working_hours(
|
||||
configs.args.working_hours, configs.args.time_delta_session
|
||||
@@ -531,7 +558,9 @@ def start_bot(**kwargs):
|
||||
continue
|
||||
elif current_target == "StoriesFeed":
|
||||
logger.info("📱 Locating story tray on HomeFeed...")
|
||||
nav_graph.do("tap story ring avatar")
|
||||
if not nav_graph.do("tap story ring avatar"):
|
||||
logger.warning("❌ Failed to tap story ring avatar. Retrying next loop.")
|
||||
continue
|
||||
post_loaded = _wait_for_story_loaded(device, timeout=5)
|
||||
if not post_loaded:
|
||||
logger.warning("❌ Stories failed to open from HomeFeed. Retrying next loop.")
|
||||
@@ -656,6 +685,7 @@ def _interact_with_profile(device, configs, username, session_state, sleep_mod,
|
||||
|
||||
if cognitive_stack is None:
|
||||
cognitive_stack = {}
|
||||
_validate_cognitive_stack(cognitive_stack, "ProfileInteraction")
|
||||
|
||||
if hasattr(session_state, "my_username") and username == session_state.my_username:
|
||||
logger.info(f"🤝 [Deep Interaction] Skipping own profile @{username} to prevent self-interactions.")
|
||||
@@ -795,6 +825,7 @@ def _run_zero_latency_stories_loop(device, configs, session_state, cognitive_sta
|
||||
|
||||
from colorama import Fore
|
||||
|
||||
_validate_cognitive_stack(cognitive_stack, "StoriesLoop")
|
||||
logger.info("🎬 [StoriesFeed] Starting native story binging loop...", extra={"color": f"{Fore.CYAN}"})
|
||||
|
||||
dopamine = cognitive_stack.get("dopamine")
|
||||
@@ -829,6 +860,20 @@ def _run_zero_latency_stories_loop(device, configs, session_state, cognitive_sta
|
||||
logger.warning("Failed to dump UI hierarchy in StoriesFeed.")
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
# ── Perimeter Guard: Verify we're still inside Instagram ──
|
||||
# Production bug 2026-05-03: A story's swipe-up link opened the Play Store,
|
||||
# and the loop kept tapping blindly on com.android.vending for 5+ iterations.
|
||||
packages = set(re.findall(r'package="([^"]+)"', xml_dump))
|
||||
app_id = getattr(device, "app_id", "com.instagram.android")
|
||||
if packages and app_id not in packages:
|
||||
logger.error(
|
||||
f"🚨 [StoriesFeed] FOREIGN APP DETECTED! Packages: {packages}. "
|
||||
f"A story link likely opened an external app. Aborting loop."
|
||||
)
|
||||
device.press("back")
|
||||
sleep(1.5)
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
if getattr(configs.args, "ignore_close_friends", False):
|
||||
if "enge freunde" in xml_dump.lower() or "close friend" in xml_dump.lower():
|
||||
logger.info(
|
||||
@@ -850,6 +895,36 @@ def _run_zero_latency_stories_loop(device, configs, session_state, cognitive_sta
|
||||
return "FEED_EXHAUSTED"
|
||||
|
||||
|
||||
def _validate_cognitive_stack(cognitive_stack, context_name):
|
||||
"""
|
||||
Validates that the cognitive stack has all required engine dependencies
|
||||
injected properly. This is the ultimate zero-trust guard for plugins.
|
||||
"""
|
||||
if not isinstance(cognitive_stack, dict):
|
||||
raise TypeError(f"[{context_name}] CognitiveStack must be a dict, got {type(cognitive_stack)}")
|
||||
|
||||
required_engines = [
|
||||
"dopamine",
|
||||
"darwin",
|
||||
"resonance",
|
||||
"active_inference",
|
||||
"growth_brain",
|
||||
"swarm",
|
||||
"writer",
|
||||
"nav_graph",
|
||||
"zero_engine",
|
||||
"telepathic",
|
||||
]
|
||||
|
||||
missing = [eng for eng in required_engines if eng not in cognitive_stack or cognitive_stack[eng] is None]
|
||||
if missing:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"🚨 [{context_name}] CognitiveStack missing required engines: {missing}")
|
||||
raise ValueError(f"[{context_name}] CognitiveStack missing required engines: {missing}")
|
||||
|
||||
|
||||
def _run_zero_latency_feed_loop(
|
||||
device, zero_engine, nav_graph, configs, session_state, job_target, cognitive_stack, is_reels=False
|
||||
):
|
||||
@@ -863,6 +938,7 @@ def _run_zero_latency_feed_loop(
|
||||
- Darwin is the SOLE dwell controller → no duplicate sleep calls
|
||||
- SwarmProtocol emits pheromones after successful interactions
|
||||
"""
|
||||
_validate_cognitive_stack(cognitive_stack, "FeedLoop")
|
||||
logger.info(f"🔄 Entering Zero-Latency Interaction Pool. Feed: {job_target}")
|
||||
|
||||
dopamine = cognitive_stack.get("dopamine")
|
||||
@@ -898,6 +974,14 @@ def _run_zero_latency_feed_loop(
|
||||
|
||||
elif governance_decision == "CHECK_CURIOSITY":
|
||||
logger.info("👀 [Curiosity] Spontaneously checking DMs / Notifications...")
|
||||
|
||||
# 🛡️ Structural Guard: Curiosity targets (DMs, Notifications) are ONLY available on HomeFeed.
|
||||
# We must navigate there first, breaking current context.
|
||||
if not nav_graph.navigate_to("HomeFeed", zero_engine):
|
||||
logger.warning("❌ [Curiosity] Failed to navigate to HomeFeed. Aborting curiosity check.")
|
||||
continue
|
||||
sleep(random.uniform(1.0, 2.5))
|
||||
|
||||
dm_config = configs.get_plugin_config("dm_reply")
|
||||
if dm_config.get("enabled", False):
|
||||
explore_target = random.choice(["MessageInbox", "Notifications"])
|
||||
@@ -934,6 +1018,17 @@ def _run_zero_latency_feed_loop(
|
||||
if cognitive_stack.get("radome"):
|
||||
context_xml = cognitive_stack.get("radome").sanitize_xml(context_xml)
|
||||
|
||||
# ── Perimeter Guard: Verify we're still inside Instagram ──
|
||||
# Parity with story loop guard (production bug 2026-05-03).
|
||||
if context_xml:
|
||||
feed_packages = set(re.findall(r'package="([^"]+)"', context_xml))
|
||||
feed_app_id = getattr(device, "app_id", "com.instagram.android")
|
||||
if feed_packages and feed_app_id not in feed_packages:
|
||||
logger.error(f"🚨 [FeedLoop] FOREIGN APP DETECTED! Packages: {feed_packages}. Aborting loop.")
|
||||
device.press("back")
|
||||
sleep(1.5)
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
# ── Execute Plugin Registry Behaviors (Feed Level) ──
|
||||
from GramAddict.core.behaviors import BehaviorContext, PluginRegistry
|
||||
|
||||
@@ -993,6 +1088,7 @@ def _run_zero_latency_search_loop(
|
||||
"""
|
||||
Executes the autonomous Search & Interact logic.
|
||||
"""
|
||||
_validate_cognitive_stack(cognitive_stack, "SearchLoop")
|
||||
logger.info("🧠 [Search Engine] Initiating keyword discovery...", extra={"color": f"{Style.BRIGHT}{Fore.CYAN}"})
|
||||
|
||||
import random
|
||||
@@ -1018,6 +1114,16 @@ def _run_zero_latency_search_loop(
|
||||
xml = device.dump_hierarchy()
|
||||
telepathic = cognitive_stack.get("telepathic")
|
||||
|
||||
# ── Perimeter Guard: Verify we're still inside Instagram ──
|
||||
if xml:
|
||||
search_packages = set(re.findall(r'package="([^"]+)"', xml))
|
||||
search_app_id = getattr(device, "app_id", "com.instagram.android")
|
||||
if search_packages and search_app_id not in search_packages:
|
||||
logger.error(f"🚨 [SearchLoop] FOREIGN APP DETECTED! Packages: {search_packages}. Aborting loop.")
|
||||
device.press("back")
|
||||
sleep(1.5)
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
# Find search bar
|
||||
search_bar = telepathic.find_best_node(xml, "Search edit text box or magnifying glass input", device=device)
|
||||
if search_bar:
|
||||
|
||||
@@ -17,13 +17,7 @@ class Config:
|
||||
self.args = kwargs
|
||||
self.module = True
|
||||
else:
|
||||
# Avoid parsing sys.argv if we are running in a test environment (pytest)
|
||||
# as pytest arguments will cause argparse to fail with SystemExit: 2
|
||||
is_pytest = "pytest" in sys.modules
|
||||
if is_pytest:
|
||||
self.args = []
|
||||
else:
|
||||
self.args = list(sys.argv)
|
||||
self.args = list(sys.argv)
|
||||
self.module = False
|
||||
|
||||
if not self.module and "--config" not in self.args:
|
||||
@@ -144,6 +138,9 @@ class Config:
|
||||
self.parser.add_argument("--total-sessions", help="Total amount of sessions", default="-1")
|
||||
self.parser.add_argument("--working-hours", help="Working hours", default=None)
|
||||
self.parser.add_argument("--time-delta-session", help="Time delta between sessions", default=None)
|
||||
self.parser.add_argument(
|
||||
"--max-runtime-minutes", type=int, help="Maximum runtime in minutes before bot auto-exits", default=None
|
||||
)
|
||||
self.parser.add_argument("--restart-atx-agent", action="store_true", help="Restart atx agent")
|
||||
self.parser.add_argument("--allow-untested-ig-version", action="store_true", help="Allow untested IG version")
|
||||
self.parser.add_argument(
|
||||
@@ -152,6 +149,13 @@ class Config:
|
||||
help="Wipe all learned navigation and telepathic memories on boot to start 100%% blank.",
|
||||
)
|
||||
|
||||
self.parser.add_argument(
|
||||
"--goal",
|
||||
type=str,
|
||||
help="High-level autonomous goal for the bot (Tesla-style). Overrides config.yml goals.",
|
||||
default=None,
|
||||
)
|
||||
|
||||
# Interaction settings
|
||||
self.parser.add_argument("--likes-count", help="Likes count", default="2-3")
|
||||
self.parser.add_argument("--likes-percentage", help="Likes percentage", default="100")
|
||||
|
||||
@@ -84,7 +84,6 @@ class DarwinEngine(QdrantBase):
|
||||
resonance: float,
|
||||
text_length: int = 0,
|
||||
nav_graph=None,
|
||||
zero_engine=None,
|
||||
configs=None,
|
||||
resonance_oracle=None,
|
||||
username=None,
|
||||
@@ -142,12 +141,22 @@ class DarwinEngine(QdrantBase):
|
||||
cy = h // 2
|
||||
|
||||
dur_ms = int(random.uniform(200, 500))
|
||||
device.shell(f"input swipe {int(cx)} {int(cy)} {int(cx + noise_x)} {int(cy + slip_distance)} {dur_ms}")
|
||||
|
||||
# Use physics-based injector instead of algorithmic 'input swipe'
|
||||
body = PhysicsBody.get_session_instance(device)
|
||||
injector = SendEventInjector.get_instance(device)
|
||||
start_pt = (int(cx), int(cy))
|
||||
end_pt = (int(cx + noise_x), int(cy + slip_distance))
|
||||
|
||||
points = BezierGesture.scroll_curve(start_pt, end_pt, body, n_points=5)
|
||||
timing = BezierGesture.compute_sigmoid_timing(len(points), dur_ms)
|
||||
injector.inject_gesture(points, timing, touch_major=body.get_touch_major())
|
||||
|
||||
time.sleep(random.uniform(0.5, 1.2))
|
||||
|
||||
# 4. Comment depth simulation (probabilistic & resonance-correlated)
|
||||
if profile["comment_read_dwell"] > 1.0 and resonance > 0.4 and random.random() < 0.3:
|
||||
if nav_graph and zero_engine:
|
||||
if nav_graph:
|
||||
if not self._has_comments(context_xml):
|
||||
logger.debug(" -> 🚫 [Darwin Engine] Skipping comment depth simulation (Post has 0 comments).")
|
||||
else:
|
||||
@@ -334,27 +343,34 @@ class DarwinEngine(QdrantBase):
|
||||
"""
|
||||
Heuristic to check if a post actually has comments to read.
|
||||
If it has 0 comments, checking them is suspicious bot behavior.
|
||||
|
||||
Zero-Maintenance: Uses only English text and resource_id patterns.
|
||||
Resource IDs are locale-invariant. English text in content_desc
|
||||
is used by Instagram internally and is reliable.
|
||||
"""
|
||||
low_xml = xml_string.lower()
|
||||
|
||||
# 1. Explicit zero comments checks
|
||||
if re.search(r"\b0\s*kommentare?\b", low_xml) or re.search(r"\b0\s*comment(?:s)?\b", low_xml):
|
||||
# 1. Explicit zero comments check (resource_id based + English fallback)
|
||||
if re.search(r"\b0\s*comment(?:s)?\b", low_xml):
|
||||
return False
|
||||
|
||||
# 2. Check for "view all" or similar prominent comment link texts
|
||||
if "view all" in low_xml or ("alle " in low_xml and "kommentare ansehen" in low_xml):
|
||||
if "view all" in low_xml:
|
||||
return True
|
||||
if "view 1 comment" in low_xml or "1 kommentar ansehen" in low_xml:
|
||||
if "view 1 comment" in low_xml:
|
||||
return True
|
||||
if "comment number is" in low_xml:
|
||||
return True
|
||||
|
||||
# 3. Check for specific counter elements > 0 in content descriptors
|
||||
# e.g. "by username, 23 comments" or "1,234 comments"
|
||||
has_number_of_comments = re.search(r"\b([1-9][0-9.,]*)\s*(?:comment(?:s)?|kommentare?)\b", low_xml)
|
||||
# 3. Structural: comment_textview_layout is present with a count > 0
|
||||
has_number_of_comments = re.search(r"\b([1-9][0-9.,]*)\s*comment(?:s)?\b", low_xml)
|
||||
if has_number_of_comments:
|
||||
return True
|
||||
|
||||
# 4. Structural: The comment button resource_id exists and has content
|
||||
if "row_feed_comment_textview_layout" in low_xml:
|
||||
return True
|
||||
|
||||
# If no indicators are found, assume the post has 0 comments.
|
||||
# The comment button exists, but there are no comments to read.
|
||||
return False
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
|
||||
|
||||
identity_engine = ScreenIdentity(getattr(configs.args, "username", ""))
|
||||
identity_engine.device = device
|
||||
screen_info = identity_engine.identify(xml_dump)
|
||||
|
||||
screen_type = screen_info["screen_type"]
|
||||
@@ -157,6 +158,7 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
|
||||
# Generate response
|
||||
prompt = f"You are replying to a direct message on Instagram. The last message you received was: '{context_text}'. Keep it short, casual, and friendly. Do not use hashtags."
|
||||
|
||||
logger.info(">>> [DM Engine] ABOUT TO CALL LLM")
|
||||
response_dict = query_llm(
|
||||
url=url,
|
||||
model=model,
|
||||
@@ -166,6 +168,7 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
|
||||
max_tokens=100,
|
||||
temperature=0.7,
|
||||
)
|
||||
logger.info(f">>> [DM Engine] LLM RETURNED: {response_dict}")
|
||||
|
||||
if response_dict and "response" in response_dict:
|
||||
response_text = response_dict["response"].strip()
|
||||
@@ -218,6 +221,7 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
|
||||
|
||||
check_identity = ScreenIdentity(getattr(configs.args, "username", ""))
|
||||
check_identity.device = device
|
||||
check_screen = check_identity.identify(check_xml)
|
||||
|
||||
if check_screen["screen_type"] == ScreenType.DM_THREAD:
|
||||
@@ -244,6 +248,7 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
|
||||
|
||||
check_identity = ScreenIdentity(getattr(configs.args, "username", ""))
|
||||
check_identity.device = device
|
||||
check_screen = check_identity.identify(check_xml)
|
||||
|
||||
if check_screen["screen_type"] == ScreenType.DM_THREAD:
|
||||
|
||||
@@ -93,6 +93,18 @@ class DopamineEngine:
|
||||
)
|
||||
|
||||
def is_app_session_over(self):
|
||||
# Global Hard Kill check
|
||||
if getattr(self, "global_max_runtime_minutes", None):
|
||||
if hasattr(self, "global_start_time"):
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
if datetime.now() - self.global_start_time > timedelta(minutes=self.global_max_runtime_minutes):
|
||||
logger.info(
|
||||
f"🛑 [Timeout] Maximum runtime of {self.global_max_runtime_minutes} minutes reached (checked by DopamineEngine). Force-stopping session.",
|
||||
extra={"color": f"{Fore.RED}"},
|
||||
)
|
||||
return True
|
||||
|
||||
# True if we have scrolled too long or hit absolute burnout
|
||||
return (time.time() - self.session_start) > self.session_limit_seconds or self.boredom >= 100.0
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ class GoalExecutor:
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
global_start_time = None
|
||||
global_max_runtime_minutes = None
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls, device=None, bot_username=""):
|
||||
@@ -61,6 +63,7 @@ class GoalExecutor:
|
||||
self.device = device
|
||||
self.username = bot_username
|
||||
self.screen_id = ScreenIdentity(bot_username)
|
||||
self.screen_id.device = device
|
||||
self.planner = GoalPlanner(bot_username)
|
||||
self.path_memory = PathMemory(bot_username)
|
||||
self.max_steps = 15 # Safety: never execute more than 15 steps
|
||||
@@ -119,6 +122,19 @@ class GoalExecutor:
|
||||
explored_nav_actions = set()
|
||||
visited_screens = set()
|
||||
for step_num in range(max_steps):
|
||||
# ── Global Hard Kill Check ──
|
||||
max_rt = GoalExecutor.global_max_runtime_minutes
|
||||
start_time = GoalExecutor.global_start_time
|
||||
if max_rt and start_time:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
if datetime.now() - start_time > timedelta(minutes=max_rt):
|
||||
logger.error(
|
||||
f"🛑 [Timeout] Maximum runtime of {max_rt} minutes reached during GOAP execution. Hard stopping planner.",
|
||||
extra={"color": "\\033[31m"},
|
||||
)
|
||||
return False
|
||||
|
||||
# PERCEIVE
|
||||
screen = self.perceive()
|
||||
screen_type = screen["screen_type"]
|
||||
@@ -136,7 +152,7 @@ class GoalExecutor:
|
||||
original_available = screen.get("available_actions", []).copy()
|
||||
masked_available = []
|
||||
for act in original_available:
|
||||
fail_count = self.action_failures.get(act, 0)
|
||||
fail_count = self.action_failures.get((screen_type, act), 0)
|
||||
if fail_count >= MAX_RETRIES:
|
||||
logger.warning(
|
||||
f"🚫 [GOAP] Masking action '{act}' due to {fail_count} consecutive failures to prevent loops."
|
||||
@@ -158,13 +174,23 @@ class GoalExecutor:
|
||||
# SAE Feedback Loop!
|
||||
# If we hit this, the LAST action caused an obstacle! Mask it!
|
||||
if last_action and last_screen_type:
|
||||
self.action_failures[last_action] = (
|
||||
self.action_failures.get(last_action, 0) + MAX_RETRIES
|
||||
) # Instantly mask it
|
||||
self.planner.knowledge.learn_trap(last_screen_type, last_action, f"caused_obstacle_{obstacle_name}")
|
||||
logger.warning(
|
||||
f"🛡️ [SAE Feedback] Action '{last_action}' caused an obstacle. Masking aggressively and learned trap."
|
||||
)
|
||||
self.action_failures[(last_screen_type, last_action)] = (
|
||||
self.action_failures.get((last_screen_type, last_action), 0) + MAX_RETRIES
|
||||
) # Instantly mask it for this session
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
if ScreenTopology.is_structural_action(last_screen_type, last_action):
|
||||
logger.warning(
|
||||
f"🛡️ [SAE Feedback] Structural action '{last_action}' caused an obstacle. "
|
||||
f"Masking for this session. (Never burned permanently)"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"🛡️ [SAE Feedback] Content action '{last_action}' caused an obstacle. "
|
||||
f"Masking for this session to break loop, but preventing permanent Qdrant poisoning."
|
||||
)
|
||||
# We specifically DO NOT call self.planner.knowledge.learn_trap here anymore!
|
||||
# Burning dynamic actions like "tap follow button" permanently destroys the bot's capabilities across sessions.
|
||||
|
||||
if not self._get_sae().ensure_clear_screen():
|
||||
if screen_type == ScreenType.FOREIGN_APP:
|
||||
@@ -199,11 +225,20 @@ class GoalExecutor:
|
||||
|
||||
if success:
|
||||
steps_taken.append({"action": action})
|
||||
|
||||
if action == "force start instagram":
|
||||
logger.info("🔄 [GOAP State] App restarted. Purging memory/traps to attempt fresh routing.")
|
||||
self.action_failures.clear()
|
||||
explored_nav_actions.clear()
|
||||
visited_screens.clear()
|
||||
consecutive_back_presses = 0
|
||||
continue
|
||||
|
||||
# Check if it was a navigation action (vs a goal action). If we are not on the required screen,
|
||||
# any action taken is essentially a navigation attempt.
|
||||
explored_nav_actions.add(action)
|
||||
# Reset failures for this action since it eventually succeeded
|
||||
self.action_failures[action] = 0
|
||||
self.action_failures[(screen_type, action)] = 0
|
||||
|
||||
if "scroll" in action.lower():
|
||||
logger.debug(
|
||||
@@ -215,50 +250,55 @@ class GoalExecutor:
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
keys_to_clear = [
|
||||
k for k in self.action_failures.keys() if ScreenTopology.is_structural_action(screen_type, k)
|
||||
k
|
||||
for k in self.action_failures.keys()
|
||||
if k[0] == screen_type and ScreenTopology.is_structural_action(screen_type, k[1])
|
||||
]
|
||||
for k in keys_to_clear:
|
||||
del self.action_failures[k]
|
||||
|
||||
# ── Back-Press Circuit Breaker ──
|
||||
# ── Back-Press Circuit Breaker → Escalation ──
|
||||
if action == "press back":
|
||||
consecutive_back_presses += 1
|
||||
if consecutive_back_presses >= MAX_CONSECUTIVE_BACK:
|
||||
logger.error(
|
||||
logger.warning(
|
||||
f"🛑 [GOAP] Back-pressed {MAX_CONSECUTIVE_BACK} times with no screen transition. "
|
||||
f"Aborting goal '{goal}' to prevent app exit."
|
||||
f"Escalating to force restart."
|
||||
)
|
||||
self.path_memory.learn_path(goal, start_screen, steps_taken, False)
|
||||
|
||||
# Phase 3 GREEN: Unlearn the trap path
|
||||
# Unlearn the trap path
|
||||
from GramAddict.core.qdrant_memory import NavigationMemoryDB
|
||||
|
||||
# We don't know the exact action that got us here easily without analyzing steps_taken,
|
||||
# but we can grab the first action taken from start_screen in this chain if available.
|
||||
if len(steps_taken) > consecutive_back_presses:
|
||||
last_real_action = steps_taken[-consecutive_back_presses - 1]["action"]
|
||||
logger.debug(
|
||||
f"[GOAP Unlearn] last_real_action={last_real_action}, " f"start_screen={start_screen}"
|
||||
)
|
||||
NavigationMemoryDB().unlearn_transition(start_screen, last_real_action)
|
||||
else:
|
||||
logger.debug(
|
||||
f"[GOAP Unlearn] No real action to unlearn. "
|
||||
f"steps={len(steps_taken)}, back_presses={consecutive_back_presses}"
|
||||
)
|
||||
|
||||
return False
|
||||
# ── ESCALATION: Force restart instead of aborting ──
|
||||
app_id = getattr(self.device, "app_id", "com.instagram.android")
|
||||
self.device.app_start(app_id, use_monkey=True)
|
||||
random_sleep(2.0, 3.5)
|
||||
steps_taken.append({"action": "force start instagram"})
|
||||
|
||||
logger.info("🔄 [GOAP Escalation] App restarted. Purging all failure state for fresh attempt.")
|
||||
self.action_failures.clear()
|
||||
explored_nav_actions.clear()
|
||||
visited_screens.clear()
|
||||
consecutive_back_presses = 0
|
||||
continue
|
||||
else:
|
||||
consecutive_back_presses = 0
|
||||
else:
|
||||
self.action_failures[action] = self.action_failures.get(action, 0) + 1
|
||||
self.action_failures[(screen_type, action)] = self.action_failures.get((screen_type, action), 0) + 1
|
||||
# Track failed actions in explored_nav_actions so the planner
|
||||
# knows NOT to return the same synthetic intent again.
|
||||
# Without this, synthetic intents (not in available_actions)
|
||||
# bypass the masking logic and loop forever.
|
||||
explored_nav_actions.add(action)
|
||||
|
||||
if self.action_failures[action] >= MAX_RETRIES:
|
||||
if self.action_failures[(screen_type, action)] >= MAX_RETRIES:
|
||||
# ── Topology Guard: Never poison structural HD Map actions ──
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
@@ -360,7 +400,11 @@ class GoalExecutor:
|
||||
pre_action_screen_type = pre_action_screen["screen_type"]
|
||||
|
||||
# Determine if this was a navigation or an interaction
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
is_navigation = any(k in action.lower() for k in ["tab", "open", "go to", "navigate", "following list"])
|
||||
if not is_navigation:
|
||||
is_navigation = ScreenTopology.is_structural_action(pre_action_screen_type, action)
|
||||
action_success = False
|
||||
|
||||
# ── UI Change Detection with Noise Threshold ──
|
||||
@@ -427,7 +471,16 @@ class GoalExecutor:
|
||||
action_success = False
|
||||
else:
|
||||
# For interactions (like, follow) or unknown goals, use XML delta + semantic verify
|
||||
if ui_changed:
|
||||
# REGRESSION FIX 2026-05-01: Toggle actions (like/save) produce tiny XML deltas
|
||||
# (e.g. checked="false" → "true" = 1 byte). We must NOT gate interactions on
|
||||
# MIN_UI_CHANGE_BYTES. ANY change at all warrants semantic verification.
|
||||
interaction_xml_changed = post_xml != xml_dump
|
||||
if post_screen_type == ScreenType.FOREIGN_APP:
|
||||
logger.error(
|
||||
f"❌ [GOAP Verify] Interaction '{action}' caused navigation to FOREIGN_APP (e.g. Play Store). Rejecting as catastrophic failure."
|
||||
)
|
||||
action_success = False
|
||||
elif interaction_xml_changed:
|
||||
score = best_node.get("score", 0.0) if best_node else 0.0
|
||||
verification = engine.verify_success(action, post_xml, device=self.device, confidence=score)
|
||||
if verification is True:
|
||||
@@ -460,8 +513,12 @@ class GoalExecutor:
|
||||
return False
|
||||
else:
|
||||
# action_success is None (INCONCLUSIVE)
|
||||
# We decay the memory so it unlearns if it repeatedly fails to produce a definitive success.
|
||||
logger.warning(f"⚠️ [GOAP Execute] Applying AGGRESSIVE PENALTY for inconclusive action '{action}'.")
|
||||
engine.decay_click(action)
|
||||
# Double penalty to burn ambiguous paths faster (outer loop adds +1, so total +2 = instantly hits MAX_RETRIES)
|
||||
self.action_failures[(pre_action_screen_type, action)] = (
|
||||
self.action_failures.get((pre_action_screen_type, action), 0) + 1
|
||||
)
|
||||
return False
|
||||
|
||||
def _execute_recalled_path(self, steps: List[Dict], goal: str) -> bool:
|
||||
|
||||
@@ -134,9 +134,14 @@ class GoalPlanner:
|
||||
# Build avoid_actions for HD Map route planning
|
||||
avoid_actions = (explored_nav_actions or set()).copy()
|
||||
if action_failures:
|
||||
for act, count in action_failures.items():
|
||||
if count >= 2: # MAX_RETRIES is 2 in goap
|
||||
avoid_actions.add(act)
|
||||
for key, count in action_failures.items():
|
||||
if isinstance(key, tuple) and len(key) == 2:
|
||||
scr, act = key
|
||||
if scr == screen_type and count >= 2: # MAX_RETRIES is 2 in goap
|
||||
avoid_actions.add(act)
|
||||
else:
|
||||
if count >= 2:
|
||||
avoid_actions.add(key)
|
||||
|
||||
target_screen = ScreenTopology.goal_to_target_screen(goal)
|
||||
|
||||
@@ -151,17 +156,8 @@ class GoalPlanner:
|
||||
)
|
||||
return None
|
||||
|
||||
# ── 2. Brain-Driven Decision Making (Primary Strategy) ──
|
||||
# The user explicitly wants the AI to be the primary driver of goals.
|
||||
from GramAddict.core.navigation.brain import ask_brain_for_action
|
||||
|
||||
brain_action = ask_brain_for_action(goal, screen_type.name, available, avoid_actions)
|
||||
if brain_action:
|
||||
logger.info(f"🧠 [Brain] Decided to execute: '{brain_action}' (to achieve: '{goal}')")
|
||||
return brain_action
|
||||
|
||||
# ── 2. HD Map Routing (Fallback) ──
|
||||
# If the Brain doesn't know what to do, try the deterministic topological map.
|
||||
# ── 2. HD Map Routing (Primary Strategy for Navigation) ──
|
||||
# Ground UI transitions in structural invariants. If the topological map knows the route, use it.
|
||||
target_screen = ScreenTopology.goal_to_target_screen(goal)
|
||||
if target_screen and target_screen != screen_type:
|
||||
route = ScreenTopology.find_route(screen_type, target_screen, avoid_actions=avoid_actions)
|
||||
@@ -182,6 +178,15 @@ class GoalPlanner:
|
||||
f"🛡️ [HD Map] Route action '{next_action}' already explored and failed. Skipping HD Map."
|
||||
)
|
||||
|
||||
# ── 3. Brain-Driven Decision Making (Fallback / Discovery) ──
|
||||
# For non-navigation goals or when the HD Map is incomplete.
|
||||
from GramAddict.core.navigation.brain import ask_brain_for_action
|
||||
|
||||
brain_action = ask_brain_for_action(goal, screen_type.name, available, avoid_actions)
|
||||
if brain_action:
|
||||
logger.info(f"🧠 [Brain] Decided to execute: '{brain_action}' (to achieve: '{goal}')")
|
||||
return brain_action
|
||||
|
||||
# ── 2. Learned Knowledge (Qdrant) ──
|
||||
required_screens = self.knowledge.get_requirements(goal)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
@@ -5,6 +6,39 @@ from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_yes_no(response: str) -> Optional[bool]:
|
||||
"""Parses a VLM response to find a definitive YES or NO without substring-matching 'not' or 'now'."""
|
||||
text = response.strip()
|
||||
|
||||
# Try parsing as JSON first
|
||||
if text.startswith("{"):
|
||||
try:
|
||||
data = json.loads(text)
|
||||
for k, v in data.items():
|
||||
if str(k).strip().upper() == "YES" or str(v).strip().upper() == "YES":
|
||||
return True
|
||||
if str(k).strip().upper() == "NO" or str(v).strip().upper() == "NO":
|
||||
return False
|
||||
if str(k).strip().lower() == "success" and isinstance(v, bool):
|
||||
return v
|
||||
|
||||
# If it is valid JSON but we couldn't definitively find YES/NO,
|
||||
# do NOT fall through to text matching
|
||||
return None
|
||||
except Exception:
|
||||
# Prevent JSON parsing fall-throughs
|
||||
return None
|
||||
|
||||
text_lower = text.lower()
|
||||
if text_lower.startswith("yes"):
|
||||
return True
|
||||
if text_lower.startswith("no") and not text_lower.startswith("now") and not text_lower.startswith("not"):
|
||||
return False
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# Semantic Match Keywords — SSOT for intent → element validation
|
||||
# ═══════════════════════════════════════════════════════
|
||||
@@ -13,10 +47,12 @@ logger = logging.getLogger(__name__)
|
||||
# If the intent contains the key, the clicked element MUST
|
||||
# contain at least one of the corresponding markers in its
|
||||
# text, content_desc, or resource_id.
|
||||
# ZERO MAINTENANCE: Only English words and resource_id fragments allowed.
|
||||
# No localized strings — the bot must work on any device language.
|
||||
TOGGLE_INTENT_MARKERS = {
|
||||
"follow": ["follow", "gefolgt", "abonnieren"],
|
||||
"like": ["like", "heart", "gefällt"],
|
||||
"save": ["save", "saved", "bookmark", "speichern"],
|
||||
"follow": ["follow", "button_follow"],
|
||||
"like": ["like", "heart", "button_like"],
|
||||
"save": ["save", "saved", "bookmark"],
|
||||
}
|
||||
|
||||
|
||||
@@ -131,21 +167,36 @@ class ActionMemory:
|
||||
and "row_feed_button_like" not in post_xml_lower
|
||||
and "clips_viewer" not in post_xml_lower
|
||||
):
|
||||
return None # Still on grid, inconclusive
|
||||
logger.warning(f"⚠️ [ActionMemory] Still on grid after trying to '{intent}'. Verification FAIL.")
|
||||
return False # Still on grid, definitely failed
|
||||
|
||||
# Specific check for opening a profile
|
||||
if "profile" in intent_lower or "author" in intent_lower or "username" in intent_lower:
|
||||
if "profile_header_container" in post_xml_lower:
|
||||
logger.info("✅ [ActionMemory] Structural check confirmed profile navigation success.")
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
f"⚠️ [ActionMemory] Profile header NOT found after trying to '{intent}'. Verification FAIL."
|
||||
)
|
||||
return False
|
||||
|
||||
# Specific check for navigating to Home Feed
|
||||
if "home feed" in intent_lower or "home tab" in intent_lower:
|
||||
if "main_feed_action_bar" in post_xml_lower:
|
||||
logger.info("✅ [ActionMemory] Structural check confirmed Home Feed navigation success.")
|
||||
return True
|
||||
|
||||
# Specific check for navigating to Explore Feed
|
||||
if "explore feed" in intent_lower or "explore tab" in intent_lower or "search" in intent_lower:
|
||||
if "explore_action_bar" in post_xml_lower or "action_bar_search_edit_text" in post_xml_lower:
|
||||
logger.info("✅ [ActionMemory] Structural check confirmed Explore Feed navigation success.")
|
||||
return True
|
||||
|
||||
state_toggles = ["like", "save", "follow", "heart"]
|
||||
is_toggle = any(t in intent_lower for t in state_toggles)
|
||||
|
||||
# ── State-Specific Structural Verification ──
|
||||
# If it was a follow, the resulting XML MUST contain "Following", "Requested", "Abonniert" or "Angefragt"
|
||||
if "follow" in intent_lower:
|
||||
FOLLOW_SUCCESS_MARKERS = ["following", "requested", "abonniert", "angefragt", "gefolgt"]
|
||||
if any(m in post_xml_lower for m in FOLLOW_SUCCESS_MARKERS):
|
||||
logger.info("✅ [ActionMemory] Structural check confirmed follow success.")
|
||||
return True
|
||||
else:
|
||||
logger.warning("⚠️ [ActionMemory] Follow success markers NOT found in post-click XML.")
|
||||
# We don't return False immediately because it might take a second to update
|
||||
# ── VLM Verification Fallback ──
|
||||
|
||||
# If we are highly confident (e.g. pulled from Qdrant memory), bypass heavy VLM
|
||||
if device and confidence < 0.95:
|
||||
@@ -189,14 +240,23 @@ class ActionMemory:
|
||||
raise ValueError("No screenshot available from device")
|
||||
response = evaluator._query_vlm(prompt, screenshot)
|
||||
|
||||
if response and "yes" in response.lower() and "no" not in response.lower():
|
||||
decision = _parse_yes_no(response) if response else None
|
||||
|
||||
if decision is True:
|
||||
logger.debug(f"🧠 [ActionMemory] VLM visually confirmed success for '{intent}'.")
|
||||
return True
|
||||
else:
|
||||
elif decision is False:
|
||||
logger.warning(
|
||||
f"⚠️ [ActionMemory] VLM visual verification FAILED for '{intent}'. VLM replied: '{response}'"
|
||||
)
|
||||
return False
|
||||
else:
|
||||
# VLM returned ambiguous response (JSON, mixed signals, etc.)
|
||||
# Don't treat as hard failure — fall through to structural delta verification
|
||||
logger.info(
|
||||
f"🧠 [ActionMemory] VLM response for '{intent}' was not YES/NO "
|
||||
f"(got: '{response[:80]}...'). Falling through to structural verification."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to query VLM for visual verification: {e}")
|
||||
# Fallthrough to structural delta if VLM crashes
|
||||
@@ -224,7 +284,10 @@ class ActionMemory:
|
||||
if diff > 0:
|
||||
logger.debug(f"🧠 [ActionMemory] Structural delta detected for toggle '{intent}'. Verification PASS.")
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
f"⚠️ [ActionMemory] Zero structural shift (diff={diff}) for state-toggle '{intent}'. Verification FAIL."
|
||||
)
|
||||
return False
|
||||
# If the intent is an abstract goal (like "find customers"), diff > 50 is NOT enough.
|
||||
# We must force visual VLM confirmation because clicking the wrong thing (like "Create highlight")
|
||||
# also produces a large diff but achieves the wrong goal.
|
||||
@@ -258,10 +321,13 @@ class ActionMemory:
|
||||
prompt = f"The user just attempted to perform the action: '{intent}'. Does the current screen match the expected outcome? Answer ONLY with the word YES or NO."
|
||||
try:
|
||||
response = evaluator._query_vlm(prompt, device.get_screenshot_b64())
|
||||
if response and "yes" in response.lower() and "no" not in response.lower():
|
||||
decision = _parse_yes_no(response) if response else None
|
||||
if decision is True:
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"⚠️ [ActionMemory] VLM rejected success for abstract intent '{intent}'.")
|
||||
logger.warning(
|
||||
f"⚠️ [ActionMemory] VLM rejected success for abstract intent '{intent}'. Response: '{response}'"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"VLM visual verification failed: {e}")
|
||||
@@ -269,6 +335,12 @@ class ActionMemory:
|
||||
logger.warning(f"⚠️ [ActionMemory] Cannot visually verify abstract intent '{intent}'. Failing safe.")
|
||||
return False
|
||||
|
||||
# If diff <= 50 for non-toggle
|
||||
logger.warning(
|
||||
f"⚠️ [ActionMemory] Insufficient structural change (diff={diff}) for non-toggle '{intent}'. Verification FAIL."
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _intent_matches_node(intent: str, semantic_string: str) -> bool:
|
||||
"""Checks if the clicked element semantically matches the toggle intent.
|
||||
|
||||
@@ -46,15 +46,15 @@ def has_carousel_in_view(xml_dump: str) -> bool:
|
||||
return any(ind in xml_dump for ind in CAROUSEL_INDICATORS)
|
||||
|
||||
|
||||
def extract_post_content(context_xml: str) -> dict:
|
||||
def extract_post_content(context_xml: str, device=None) -> dict:
|
||||
"""
|
||||
Extracts meaningful content data from the current feed post's XML.
|
||||
This is the BOT'S EYES — what it actually "sees" about each post.
|
||||
|
||||
Returns:
|
||||
{'username': str, 'description': str, 'caption': str}
|
||||
{'username': str, 'description': str, 'caption': str, 'username_missing': bool}
|
||||
"""
|
||||
result = {"username": "", "description": "", "caption": ""}
|
||||
result = {"username": "", "description": "", "caption": "", "username_missing": False}
|
||||
|
||||
try:
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
@@ -62,16 +62,87 @@ def extract_post_content(context_xml: str) -> dict:
|
||||
telepath = TelepathicEngine.get_instance()
|
||||
|
||||
# 1. Learn/extract post author dynamically
|
||||
author_node = telepath.find_best_node(context_xml, "post author username header", min_confidence=0.75)
|
||||
# 🛡️ Structural Fast-Path: Prioritize deterministic IDs over AI guesses
|
||||
# Try structural ID fast-path first (100% deterministic)
|
||||
author_node = None
|
||||
try:
|
||||
root = ET.fromstring(context_xml)
|
||||
for node in root.iter("node"):
|
||||
res_id = node.attrib.get("resource-id", "")
|
||||
if "row_feed_photo_profile_name" in res_id or "clips_author_username" in res_id or "profile_header_name" in res_id:
|
||||
author_node = {"original_attribs": node.attrib}
|
||||
logger.debug(f"Identified author_node via structural ID: {res_id}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug(f"XML parse error in author structural fast-path: {e}")
|
||||
|
||||
# Fallback to Telepathic Engine if structural ID is missing
|
||||
if not author_node:
|
||||
author_node = telepath.find_best_node(
|
||||
context_xml, "post author username text (exclude bottom tabs)", min_confidence=0.75, device=device
|
||||
)
|
||||
logger.debug(f"Telepathic fallback for author_node: {author_node}")
|
||||
|
||||
# 🛡️ Anti-Hallucination Guard: Ensure we actually found text.
|
||||
if author_node and author_node.get("original_attribs", {}).get("text"):
|
||||
result["username"] = author_node["original_attribs"]["text"].strip()
|
||||
if author_node:
|
||||
attribs = author_node.get("original_attribs", {})
|
||||
text = attribs.get("text", "").strip()
|
||||
desc = attribs.get("content_desc", "").strip()
|
||||
|
||||
if text:
|
||||
result["username"] = text
|
||||
elif desc:
|
||||
result["username"] = desc
|
||||
else:
|
||||
# If the VLM selected a container (like clips_author_info_component),
|
||||
# extract text from its children.
|
||||
logger.debug("Author node lacks text/desc. Searching children for username...")
|
||||
bounds = attribs.get("bounds")
|
||||
if bounds:
|
||||
try:
|
||||
# Re-parse to find children within bounds
|
||||
import re
|
||||
match = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
|
||||
if match:
|
||||
l, t, r, b = map(int, match.groups())
|
||||
|
||||
# Fallback: scan all nodes in XML and see if they are inside these bounds
|
||||
possible_texts = []
|
||||
possible_descs = []
|
||||
for n in ET.fromstring(context_xml).iter("node"):
|
||||
child_bounds = n.attrib.get("bounds")
|
||||
child_text = n.attrib.get("text", "").strip()
|
||||
child_desc = n.attrib.get("content-desc", "").strip()
|
||||
|
||||
if child_bounds and (child_text or child_desc):
|
||||
cm = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", child_bounds)
|
||||
if cm:
|
||||
cl, ct, cr, cb = map(int, cm.groups())
|
||||
# Check if child is strictly inside the container
|
||||
if cl >= l and ct >= t and cr <= r and cb <= b:
|
||||
if child_text:
|
||||
possible_texts.append(child_text)
|
||||
if child_desc and "Profile picture" not in child_desc:
|
||||
possible_descs.append(child_desc)
|
||||
|
||||
if possible_texts:
|
||||
result["username"] = possible_texts[0]
|
||||
logger.debug(f"Extracted username '{result['username']}' from child node text.")
|
||||
elif possible_descs:
|
||||
result["username"] = possible_descs[0]
|
||||
logger.debug(f"Extracted username '{result['username']}' from child node desc.")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to extract username from children: {e}")
|
||||
|
||||
# 2. Learn/extract post media description dynamically
|
||||
media_node = telepath.find_best_node(context_xml, "post media content", min_confidence=0.35)
|
||||
if media_node and media_node.get("original_attribs", {}).get("desc"):
|
||||
result["description"] = media_node["original_attribs"]["desc"].strip()
|
||||
media_node = telepath.find_best_node(
|
||||
context_xml,
|
||||
"post media content (the actual image or video, exclude bottom tabs)",
|
||||
min_confidence=0.35,
|
||||
device=device,
|
||||
)
|
||||
if media_node and media_node.get("original_attribs", {}).get("content_desc"):
|
||||
result["description"] = media_node["original_attribs"]["content_desc"].strip()
|
||||
|
||||
# 3. Visible caption text (heuristic fallback if node isn't explicitly found)
|
||||
# Search all nodes for text that contains the username to find the caption body
|
||||
@@ -85,6 +156,11 @@ def extract_post_content(context_xml: str) -> dict:
|
||||
except Exception as e:
|
||||
logger.warning(f"Error extracting post content autonomously: {e}")
|
||||
|
||||
# REGRESSION FIX 2026-05-01: Flag unreliable data when username is empty
|
||||
if not result["username"]:
|
||||
result["username_missing"] = True
|
||||
logger.warning("⚠️ [PostDataExtraction] Username is empty — data may be unreliable.")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,157 @@ class IntentResolver:
|
||||
3. Fallback → text-based VLM (when no device/screenshot available)
|
||||
"""
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Structural Guards
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def filter_navigation_conflicts(
|
||||
self, candidates: List[SpatialNode], intent_description: str, screen_height: int = 2400
|
||||
) -> List[SpatialNode]:
|
||||
"""
|
||||
Prevents VLM from confusing navigation-bar buttons (Back, Close)
|
||||
with bottom tab-bar buttons (Home, Profile, Search).
|
||||
|
||||
Production bug 2026-04-30: VLM picked action_bar_button_back
|
||||
for "tap profile tab" → account switch failed.
|
||||
|
||||
Production bug 2026-05-01: VLM picked profile_tab (desc='Profile')
|
||||
for "post author username text" → navigated to own profile instead.
|
||||
|
||||
Rules:
|
||||
- For tab intents: exclude nodes with "back" in resource_id or
|
||||
content_desc == "Back"
|
||||
- For back/close intents: no filtering (Back is the correct target)
|
||||
- For author/username intents: exclude bottom navigation tabs
|
||||
"""
|
||||
intent_lower = intent_description.lower()
|
||||
|
||||
# Only apply for REAL tab navigation intents.
|
||||
# REGRESSION FIX 2026-05-02: "tab" as a substring was too broad.
|
||||
# Intent "post author username text (exclude bottom tabs)" matched
|
||||
# because it contained "tab" → Tab Height Guard nuked the author node.
|
||||
# Now we require specific tab navigation patterns:
|
||||
# - "tap profile tab", "tap home tab", "explore tab"
|
||||
# - NOT "exclude bottom tabs", "tabbar", random mentions
|
||||
import re
|
||||
|
||||
_TAB_PATTERN = re.compile(
|
||||
r"\btap\s+\w+\s+tab\b" # "tap profile tab", "tap home tab"
|
||||
r"|\b\w+\s+tab\b" # "profile tab", "explore tab"
|
||||
r"|^tab\b", # "tab" at start of intent
|
||||
re.IGNORECASE,
|
||||
)
|
||||
filtered = []
|
||||
is_tab_intent = bool(_TAB_PATTERN.search(intent_lower)) and "back" not in intent_lower
|
||||
is_create_intent = "create" in intent_lower or "camera" in intent_lower or "story" in intent_lower
|
||||
# REGRESSION FIX 2026-05-01: Author/username intents must never pick nav tabs
|
||||
is_author_intent = any(kw in intent_lower for kw in ["author", "username", "post media"])
|
||||
|
||||
# Known bottom navigation tab resource_id suffixes
|
||||
NAV_TAB_SUFFIXES = ("_tab", "tab_icon", "navigation_bar")
|
||||
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
desc = (node.content_desc or "").lower()
|
||||
|
||||
is_back = "back" in rid or desc == "back"
|
||||
is_close = "close" in rid or desc == "close"
|
||||
is_create = "camera" in rid or "create" in rid or desc == "camera" or desc == "create" or "creation" in rid
|
||||
is_nav_tab = any(rid.endswith(s) for s in NAV_TAB_SUFFIXES)
|
||||
|
||||
if is_tab_intent and (is_back or is_close):
|
||||
logger.debug(
|
||||
f"🛡️ [Nav Conflict Guard] Excluded '{node.resource_id}' "
|
||||
f"(desc='{node.content_desc}') for tab intent '{intent_description}'"
|
||||
)
|
||||
continue
|
||||
|
||||
# NEW REGRESSION FIX 2026-05-01: Tab intents must never pick top-screen elements or headers
|
||||
# UPDATE: Actually, tabs are always at the very bottom. Filter out anything above 85% of screen height.
|
||||
if is_tab_intent:
|
||||
is_not_at_bottom = node.center_y < (screen_height * 0.85)
|
||||
if is_not_at_bottom:
|
||||
logger.debug(
|
||||
f"🛡️ [Tab Height Guard] Excluded non-bottom element '{node.resource_id}' "
|
||||
f"(desc='{node.content_desc}', y={node.center_y}) for tab intent '{intent_description}'"
|
||||
)
|
||||
continue
|
||||
# Tab intents should NEVER be content items
|
||||
if any(kw in desc for kw in ["reel by", "photo by", "photos by", "row ", "column "]):
|
||||
logger.debug(
|
||||
f"🛡️ [Content Tab Guard] Excluded content item '{node.resource_id}' "
|
||||
f"(desc='{node.content_desc}') for tab intent '{intent_description}'"
|
||||
)
|
||||
continue
|
||||
|
||||
if is_author_intent and is_nav_tab:
|
||||
logger.debug(
|
||||
f"🛡️ [Author Tab Guard] Excluded nav tab '{node.resource_id}' "
|
||||
f"(desc='{node.content_desc}') for author intent '{intent_description}'"
|
||||
)
|
||||
continue
|
||||
|
||||
if not is_create_intent and is_create:
|
||||
logger.debug(
|
||||
f"🛡️ [Creation Conflict Guard] Excluded '{node.resource_id}' "
|
||||
f"(desc='{node.content_desc}') for intent '{intent_description}'"
|
||||
)
|
||||
continue
|
||||
|
||||
# NEW REGRESSION FIX: Exclude interaction buttons (comment, like, share) when looking for author or media
|
||||
# This prevents the weak VLM from hallucinating bounding box numbers that point to "Comment".
|
||||
interaction_suffixes = ["comment", "like", "share", "send", "save", "button_icon"]
|
||||
is_interaction = any(s in rid for s in interaction_suffixes) or any(s in desc for s in interaction_suffixes)
|
||||
node_text_lower = (node.text or "").lower()
|
||||
is_follow = "follow" in rid or "follow" in node_text_lower or "follow" in desc
|
||||
is_media_intent = "media content" in intent_lower or "image" in intent_lower or "video" in intent_lower
|
||||
|
||||
if is_author_intent and (is_interaction or is_follow):
|
||||
logger.debug(
|
||||
f"🛡️ [Author Interaction Guard] Excluded interaction/follow button '{node.resource_id}' "
|
||||
f"(desc='{node.content_desc}', text='{node.text}') for author intent '{intent_description}'"
|
||||
)
|
||||
continue
|
||||
|
||||
if is_media_intent and (is_interaction or is_follow):
|
||||
logger.debug(
|
||||
f"🛡️ [Media Interaction Guard] Excluded interaction/follow button '{node.resource_id}' "
|
||||
f"(desc='{node.content_desc}') for media intent '{intent_description}'"
|
||||
)
|
||||
continue
|
||||
|
||||
# NEW REGRESSION FIX: Exclude stories and reels tray when looking for a POST author
|
||||
# This prevents the VLM from selecting the user's own story at the top of the feed
|
||||
is_post_author_intent = is_author_intent and "post" in intent_lower
|
||||
node_text_lower = (node.text or "").lower()
|
||||
is_story_or_reel = (
|
||||
"story" in rid
|
||||
or "story" in desc
|
||||
or "story" in node_text_lower
|
||||
or "reel" in rid
|
||||
or "reel" in desc
|
||||
or "reel" in node_text_lower
|
||||
)
|
||||
|
||||
if is_post_author_intent and is_story_or_reel:
|
||||
logger.debug(
|
||||
f"🛡️ [Post Author Story Guard] Excluded story/reel '{node.resource_id}' "
|
||||
f"(desc='{node.content_desc}') for post author intent '{intent_description}'"
|
||||
)
|
||||
continue
|
||||
|
||||
# NEW REGRESSION FIX: Exclude action bar titles (like 'For you') when looking for an author
|
||||
if is_author_intent and "action_bar_title" in rid:
|
||||
logger.debug(
|
||||
f"🛡️ [Author Action Bar Guard] Excluded action bar title '{node.resource_id}' "
|
||||
f"(desc='{node.content_desc}') for author intent '{intent_description}'"
|
||||
)
|
||||
continue
|
||||
|
||||
filtered.append(node)
|
||||
|
||||
return filtered
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Public API
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -53,6 +204,176 @@ class IntentResolver:
|
||||
if intent_lower in abstract_goals:
|
||||
return None
|
||||
|
||||
# --- Strict Structural Fast-Paths ---
|
||||
# Bypass VLM for deterministically identifiable UI components
|
||||
if "message text box" in intent_lower or "message input" in intent_lower or "type message" in intent_lower:
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
text = (node.text or "").lower()
|
||||
if "composer_edittext" in rid or "message…" in text or "message..." in text:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found message input field: {rid}")
|
||||
return node
|
||||
|
||||
if "last received message text" in intent_lower or "received message" in intent_lower:
|
||||
# Gather all message text views
|
||||
msg_nodes = [n for n in candidates if "direct_text_message_text_view" in (n.resource_id or "").lower()]
|
||||
if msg_nodes:
|
||||
# The last one in the XML is typically the most recent message at the bottom of the screen
|
||||
latest_msg = msg_nodes[-1]
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found last received message text: '{latest_msg.text}'")
|
||||
return latest_msg
|
||||
|
||||
if "send message button" in intent_lower:
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
desc = (node.content_desc or "").lower()
|
||||
text = (node.text or "").lower()
|
||||
if "send" in rid or "composer_button" in rid:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found send button: {rid or desc or text}")
|
||||
return node
|
||||
|
||||
if "post author username" in intent_lower or "tap post username" in intent_lower:
|
||||
for node in candidates:
|
||||
if "row_feed_photo_profile_imageview" in (node.resource_id or "").lower():
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found post author avatar image: {node.content_desc}")
|
||||
return node
|
||||
for node in candidates:
|
||||
if "row_feed_photo_profile_name" in (node.resource_id or "").lower():
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found post author username text: {node.text}")
|
||||
return node
|
||||
|
||||
if "feed post content" in intent_lower or "post media content" in intent_lower:
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
if "row_feed_photo_imageview" in rid or "zoomable_view_container" in rid:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found feed post content: {rid}")
|
||||
return node
|
||||
|
||||
if "comment" in intent_lower and "button" in intent_lower:
|
||||
# First try the View all comments button
|
||||
for node in candidates:
|
||||
if (
|
||||
"view all comments" in (node.text or "").lower()
|
||||
or "view all comments" in (node.content_desc or "").lower()
|
||||
):
|
||||
logger.info(
|
||||
f"🎯 [Structural Fast-Path] Found comment button text: {node.text or node.content_desc}"
|
||||
)
|
||||
return node
|
||||
# Then try the icon itself if somehow clickable
|
||||
for node in candidates:
|
||||
if (
|
||||
"row_feed_button_comment" in (node.resource_id or "").lower()
|
||||
or "row_feed_textview_comments" in (node.resource_id or "").lower()
|
||||
):
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found comment button: {node.resource_id}")
|
||||
return node
|
||||
|
||||
if "like" in intent_lower and ("button" in intent_lower or "post" in intent_lower):
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
if "row_feed_button_like" in rid:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found like button: {rid}")
|
||||
return node
|
||||
|
||||
if ("send" in intent_lower or "share" in intent_lower) and "post" in intent_lower and "button" in intent_lower:
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
desc = (node.content_desc or "").lower()
|
||||
if "row_feed_button_share" in rid or "send post" in desc:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found send/share post button: {rid or desc}")
|
||||
return node
|
||||
|
||||
if "add to story" in intent_lower:
|
||||
# We skip structural fast-path for 'add to story' since it relies heavily on language/text strings
|
||||
# and let the VLM figure it out or rely on purely visual indicators.
|
||||
pass
|
||||
|
||||
if "share" in intent_lower and ("button" in intent_lower or "post" in intent_lower):
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
if "row_feed_button_share" in rid:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found share button: {rid}")
|
||||
return node
|
||||
|
||||
if "save" in intent_lower and ("button" in intent_lower or "post" in intent_lower):
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
if "row_feed_button_save" in rid:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found save button: {rid}")
|
||||
return node
|
||||
|
||||
if "follow" in intent_lower and "button" in intent_lower:
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
if "profile_header_follow_button" in rid or "inline_follow_button" in rid:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found follow/following button: {rid}")
|
||||
return node
|
||||
|
||||
if "first post" in intent_lower or "first item" in intent_lower or "first search result" in intent_lower:
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
if "grid_card_layout_container" in rid or "image_button" in rid or "row_search_user" in rid:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found first post/item: {rid}")
|
||||
return node
|
||||
|
||||
if "story ring" in intent_lower or "story tray" in intent_lower:
|
||||
story_nodes = []
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
desc = (node.content_desc or "").lower()
|
||||
text = (node.text or "").lower()
|
||||
# Instagram story tray avatars usually have this resource id and 'story' in the content description
|
||||
if ("avatar_image_view" in rid or "row_profile_header_imageview" in rid) and "story" in desc:
|
||||
# Ignore the user's explicit "Add to story" ring
|
||||
if "add to story" not in desc and "your story" not in text:
|
||||
story_nodes.append(node)
|
||||
|
||||
if story_nodes:
|
||||
# Sort horizontally (left-to-right)
|
||||
story_nodes.sort(key=lambda n: n.x1)
|
||||
|
||||
# Check if this is the home feed story tray (avatar_image_view without 'highlight' in desc)
|
||||
is_highlight = any("highlight" in (n.content_desc or "").lower() for n in story_nodes)
|
||||
if "avatar_image_view" in (story_nodes[0].resource_id or "").lower() and not is_highlight:
|
||||
if len(story_nodes) > 1:
|
||||
logger.info(
|
||||
f"🎯 [Structural Fast-Path] Found {len(story_nodes)} story rings. Skipping own profile. Picking second: '{story_nodes[1].content_desc}'"
|
||||
)
|
||||
return story_nodes[1]
|
||||
else:
|
||||
logger.warning(
|
||||
"🎯 [Structural Fast-Path] Only 1 story ring found on feed (likely own profile). Skipping to avoid modal trap."
|
||||
)
|
||||
return None
|
||||
else:
|
||||
# Profile header or other single-story views
|
||||
logger.info(
|
||||
f"🎯 [Structural Fast-Path] Found story ring avatar: {story_nodes[0].resource_id} (desc: '{story_nodes[0].content_desc}')"
|
||||
)
|
||||
return story_nodes[0]
|
||||
|
||||
# --- Navigation Tab Fast-Paths ---
|
||||
# Deterministically identify bottom navigation tabs to prevent VLM confusion
|
||||
tab_map = {
|
||||
"home tab": "feed_tab",
|
||||
"feed tab": "feed_tab",
|
||||
"reels tab": "clips_tab",
|
||||
"clips tab": "clips_tab",
|
||||
"explore tab": "search_tab",
|
||||
"search tab": "search_tab",
|
||||
"profile tab": "profile_tab",
|
||||
"message tab": "direct_tab",
|
||||
"direct tab": "direct_tab",
|
||||
}
|
||||
for intent_key, resource_suffix in tab_map.items():
|
||||
if intent_key in intent_lower:
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
if rid.endswith(f":id/{resource_suffix}"):
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found {intent_key}: {rid}")
|
||||
return node
|
||||
|
||||
# --- Semantic Match Guard ---
|
||||
# If the intent explicitly quotes a target (e.g., "tap 'New Message'"),
|
||||
# we strictly filter candidates to those whose text or content_desc contains the quote.
|
||||
@@ -61,13 +382,21 @@ class IntentResolver:
|
||||
quotes = re.findall(r"['\"](.*?)['\"]", intent_description)
|
||||
if quotes:
|
||||
target_text = quotes[0].lower()
|
||||
pattern = r"\b" + re.escape(target_text) + r"\b"
|
||||
|
||||
# Only use the exact target string (no manual localized translation dictionaries!)
|
||||
localized_targets = [target_text]
|
||||
|
||||
semantic_candidates = []
|
||||
for node in candidates:
|
||||
n_text = (node.text or "").lower()
|
||||
n_desc = (node.content_desc or "").lower()
|
||||
if re.search(pattern, n_text) or re.search(pattern, n_desc):
|
||||
semantic_candidates.append(node)
|
||||
|
||||
# Check if any of the localized targets match
|
||||
for loc_target in localized_targets:
|
||||
pattern = r"\b" + re.escape(loc_target) + r"\b"
|
||||
if re.search(pattern, n_text) or re.search(pattern, n_desc):
|
||||
semantic_candidates.append(node)
|
||||
break # Found a match, no need to check other localized targets
|
||||
|
||||
if semantic_candidates:
|
||||
if len(semantic_candidates) == 1:
|
||||
@@ -89,12 +418,11 @@ class IntentResolver:
|
||||
if device is not None and (
|
||||
hasattr(device, "screenshot") or hasattr(getattr(device, "deviceV2", None), "screenshot")
|
||||
):
|
||||
print(f"DEBUG_INTENT: Entering Visual Discovery for '{intent_description}'")
|
||||
logger.info("📸 Device screenshot capability detected. Enforcing visual discovery.")
|
||||
visual_res = self._visual_discovery(intent_description, candidates, device)
|
||||
if visual_res is not None:
|
||||
return visual_res
|
||||
logger.warning("👁️ [IntentResolver] Visual discovery yielded None. Falling back to text-based resolution.")
|
||||
return self._visual_discovery(intent_description, candidates, device, screen_height=screen_height)
|
||||
|
||||
print(f"DEBUG_INTENT: Falling back to Text-based VLM for '{intent_description}'")
|
||||
# --- Strict VLM Hallucination Guard (Text-only Fallback) ---
|
||||
# For known structural targets that the text-based VLM frequently hallucinates when they are missing,
|
||||
# we enforce a strict failure.
|
||||
@@ -107,7 +435,7 @@ class IntentResolver:
|
||||
|
||||
# ── FALLBACK: Text-based VLM resolution ──
|
||||
# Only used when device is unavailable (e.g., unit tests without screenshots).
|
||||
return self._text_based_resolve(intent_description, candidates, device)
|
||||
return self._text_based_resolve(intent_description, candidates, device, screen_height=screen_height)
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Visual Discovery (Set-of-Mark Prompting)
|
||||
@@ -235,7 +563,7 @@ class IntentResolver:
|
||||
return annotated_b64, box_map
|
||||
|
||||
def _visual_discovery(
|
||||
self, intent_description: str, candidates: List[SpatialNode], device
|
||||
self, intent_description: str, candidates: List[SpatialNode], device, screen_height: int = 2400
|
||||
) -> Optional[SpatialNode]:
|
||||
"""
|
||||
Vision-first intent resolution via Set-of-Mark (SoM) prompting.
|
||||
@@ -259,6 +587,11 @@ class IntentResolver:
|
||||
and "per cent" not in (n.content_desc or "").lower()
|
||||
]
|
||||
|
||||
# --- Navigation Conflict Guard ---
|
||||
# Prevents VLM from confusing Back buttons with tab buttons
|
||||
# Production bug 2026-04-30: VLM picked Back for "tap profile tab"
|
||||
candidates = self.filter_navigation_conflicts(candidates, intent_description, screen_height=screen_height)
|
||||
|
||||
# --- Strict Button Guard ---
|
||||
# If the intent specifically asks for a "button", "icon", or "tab",
|
||||
# filter out candidates that contain long text (e.g. captions, comments)
|
||||
@@ -288,9 +621,32 @@ class IntentResolver:
|
||||
logger.info(f"🎯 [Grid Guard] Filtered to {len(grid_candidates)} actual grid candidates.")
|
||||
candidates = grid_candidates
|
||||
|
||||
# --- Author/Username Guard ---
|
||||
# Prevents VLM from picking the "Profile" nav tab when asked for "post author username".
|
||||
if "author" in intent_lower or "username" in intent_lower or "profile name" in intent_lower:
|
||||
filtered_candidates = []
|
||||
for node in candidates:
|
||||
res_id = (node.resource_id or "").lower()
|
||||
desc = (node.content_desc or "").lower()
|
||||
if (
|
||||
"tab" in res_id
|
||||
or "navigation" in res_id
|
||||
or "tabbar" in res_id
|
||||
or desc in ["home", "search", "reels", "profile"]
|
||||
):
|
||||
logger.debug(
|
||||
f"🛡️ [Author Guard] Filtered out navigation tab: '{node.content_desc}' ({node.resource_id})"
|
||||
)
|
||||
else:
|
||||
filtered_candidates.append(node)
|
||||
candidates = filtered_candidates
|
||||
|
||||
try:
|
||||
annotated_b64, box_map = self._annotate_screenshot_with_candidates(device, candidates)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
logger.warning(f"⚠️ [Visual Discovery] Screenshot annotation failed: {e}")
|
||||
return None
|
||||
|
||||
@@ -318,8 +674,7 @@ class IntentResolver:
|
||||
label_parts.append("(no visible text)")
|
||||
box_legend_lines.append(f" [{idx}] {', '.join(label_parts)}")
|
||||
box_legend = "\n".join(box_legend_lines)
|
||||
print("BOX LEGEND:")
|
||||
print(box_legend)
|
||||
logger.debug(f"BOX LEGEND:\n{box_legend}")
|
||||
|
||||
prompt = (
|
||||
f"You are looking at a mobile app screenshot with numbered bounding boxes drawn around interactive UI elements.\n"
|
||||
@@ -350,7 +705,13 @@ class IntentResolver:
|
||||
f"9. If the intent is 'save post':\n"
|
||||
f" - The save icon is the bookmark icon on the bottom right of the post image/video.\n"
|
||||
f" - Usually has desc='Add to Saved' or 'Save'. Do NOT pick the post text or other action buttons.\n"
|
||||
f"10. If the exact control is NOT visible, return null. Do NOT guess.\n\n"
|
||||
f"10. DISTINGUISHING BOTTOM TABS vs CONTENT BUTTONS:\n"
|
||||
f" - Bottom Navigation Tabs (Home, Search, Reels, Profile) are ALWAYS at the very bottom (y > 2100).\n"
|
||||
f" - Content Interaction Buttons (Like, Comment, Share, Reactions, Message Input) are attached to posts or threads, NOT the bottom nav bar.\n"
|
||||
f" - If looking for 'message input' or 'type message', do NOT select 'reactions' or emoji icons. Look for an empty text box or 'Message...'.\n"
|
||||
f"11. If the intent is 'feed post content' or 'post media content':\n"
|
||||
f" - Pick the largest box that contains the actual image or video, usually described as 'Photo', 'Video', or 'Carousel'.\n"
|
||||
f"12. If the exact control is NOT visible, return null. Do NOT guess.\n\n"
|
||||
f'Reply ONLY with a valid JSON object: {{"box": <number>}} or {{"box": null}}'
|
||||
)
|
||||
|
||||
@@ -363,8 +724,15 @@ class IntentResolver:
|
||||
use_local_edge=True,
|
||||
images_b64=[annotated_b64],
|
||||
)
|
||||
print(f"DEBUG_INTENT: VLM RAW RESPONSE for '{intent_description}': {res}")
|
||||
data = json.loads(res)
|
||||
box_idx = data.get("box")
|
||||
if box_idx is None:
|
||||
box_idx = data.get("selected_index")
|
||||
if box_idx is None:
|
||||
box_idx = data.get("box_index")
|
||||
if box_idx is None:
|
||||
box_idx = data.get("index")
|
||||
|
||||
if box_idx is not None and box_idx in box_map:
|
||||
selected = box_map[box_idx]
|
||||
@@ -387,7 +755,7 @@ class IntentResolver:
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _text_based_resolve(
|
||||
self, intent_description: str, candidates: List[SpatialNode], device=None
|
||||
self, intent_description: str, candidates: List[SpatialNode], device=None, screen_height: int = 2400
|
||||
) -> Optional[SpatialNode]:
|
||||
"""
|
||||
Fallback resolution via text descriptions of XML nodes.
|
||||
@@ -399,6 +767,9 @@ class IntentResolver:
|
||||
intent_lower = intent_description.lower()
|
||||
|
||||
filtered_candidates = [n for n in candidates if n.area < 500000]
|
||||
filtered_candidates = self.filter_navigation_conflicts(
|
||||
filtered_candidates, intent_description, screen_height=screen_height
|
||||
)
|
||||
if "profile" in intent_lower:
|
||||
filtered_candidates = [
|
||||
n
|
||||
@@ -442,6 +813,7 @@ class IntentResolver:
|
||||
user_prompt=prompt,
|
||||
use_local_edge=True,
|
||||
)
|
||||
print(f"DEBUG_INTENT: TEXT LLM RAW RESPONSE for '{intent_description}': {res}")
|
||||
data = json.loads(res)
|
||||
idx = data.get("selected_index")
|
||||
if idx is not None and 0 <= idx < len(filtered_candidates):
|
||||
|
||||
@@ -43,7 +43,7 @@ class ScreenIdentity:
|
||||
except ImportError:
|
||||
self.screen_memory = None
|
||||
|
||||
def identify(self, xml_dump: str) -> Dict[str, Any]:
|
||||
def identify(self, xml_dump: str, screenshot_b64: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyzes an XML dump and returns a complete screen description.
|
||||
|
||||
@@ -116,6 +116,11 @@ class ScreenIdentity:
|
||||
}
|
||||
)
|
||||
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine
|
||||
|
||||
sae = SituationalAwarenessEngine.get_instance()
|
||||
signature = sae._compress_xml(xml_dump) if sae else self._compute_signature(resource_ids, content_descs, texts)
|
||||
|
||||
# ── Foreign app check ──
|
||||
if app_id not in packages:
|
||||
return {
|
||||
@@ -123,18 +128,16 @@ class ScreenIdentity:
|
||||
"available_actions": ["press back", "force start instagram"],
|
||||
"selected_tab": None,
|
||||
"context": {"packages": list(packages)},
|
||||
"signature": self._compute_signature(resource_ids, content_descs, texts),
|
||||
"signature": signature,
|
||||
}
|
||||
|
||||
desc_lower = " ".join(content_descs).lower()
|
||||
text_lower = " ".join(texts).lower()
|
||||
ids_str = " ".join(resource_ids).lower()
|
||||
|
||||
signature = self._compute_signature(resource_ids, content_descs, texts)
|
||||
|
||||
# ── Identify screen type from structural signals ──
|
||||
screen_type = self._classify_screen(
|
||||
resource_ids, content_descs, texts, selected_tab, desc_lower, text_lower, ids_str, signature
|
||||
resource_ids, content_descs, texts, selected_tab, desc_lower, text_lower, ids_str, signature, screenshot_b64
|
||||
)
|
||||
|
||||
# ── Extract available actions from clickable elements ──
|
||||
@@ -153,23 +156,45 @@ class ScreenIdentity:
|
||||
"signature": signature,
|
||||
}
|
||||
|
||||
def _classify_screen(self, ids, descs, texts, selected_tab, desc_lower, text_lower, ids_str, signature=None):
|
||||
"""Classify screen type using Semantic Memory with LLM fallback — NO hardcoded states."""
|
||||
def _classify_screen(
|
||||
self, ids, descs, texts, selected_tab, desc_lower, text_lower, ids_str, signature=None, screenshot_b64=None
|
||||
):
|
||||
"""
|
||||
Classify screen type using Semantic Memory with LLM fallback — NO hardcoded states."""
|
||||
|
||||
# Priority 0: Content-creation overlays that block ALL navigation.
|
||||
# Priority 0: Fetch Qdrant Semantic Cache
|
||||
# We fetch this early to see if there is a 'NORMAL' override for the MODAL check.
|
||||
# We DO NOT let this override deterministic structural heuristics! Fuzzy vector matching
|
||||
# can easily confuse HOME_FEED and OWN_PROFILE if the bottom navigation bar is identical.
|
||||
cached_type_str = None
|
||||
if signature and self.screen_memory and self.screen_memory.is_connected:
|
||||
cached_type_str = self.screen_memory.get_screen_type(signature, similarity_threshold=0.92)
|
||||
|
||||
is_normal_override = cached_type_str == "NORMAL"
|
||||
|
||||
# Priority 1: Content-creation overlays that block ALL navigation.
|
||||
# These full-screen Instagram UIs have no navigation tabs and trap the bot.
|
||||
# Structural detection is O(1), zero LLM calls, and cannot be fooled.
|
||||
creation_flow_markers = ("quick_capture", "gallery_cancel_button", "creation_flow", "reel_camera")
|
||||
if any(marker in ids_str for marker in creation_flow_markers):
|
||||
logger.info("🛡️ [ScreenIdentity] Content-creation overlay detected → MODAL")
|
||||
return ScreenType.MODAL
|
||||
if not is_normal_override:
|
||||
creation_flow_markers = ("quick_capture", "gallery_cancel_button", "creation_flow", "reel_camera")
|
||||
if any(marker in ids_str for marker in creation_flow_markers):
|
||||
logger.info("🛡️ [ScreenIdentity] Content-creation overlay detected → MODAL")
|
||||
return ScreenType.MODAL
|
||||
|
||||
# Priority 1: Structural Heuristics (100% Deterministic)
|
||||
# Priority 2: Structural Heuristics (100% Deterministic)
|
||||
if "unified_follow_list_tab_layout" in ids or "follow_list_container" in ids:
|
||||
return ScreenType.FOLLOW_LIST
|
||||
|
||||
if "profile_header_container" in ids:
|
||||
if selected_tab == "profile_tab":
|
||||
# Profile structural markers
|
||||
PROFILE_MARKERS = (
|
||||
"profile_header_container",
|
||||
"row_profile_header_imageview",
|
||||
"profile_tabs_container",
|
||||
"profile_header_name",
|
||||
)
|
||||
if any(marker in ids for marker in PROFILE_MARKERS):
|
||||
own_profile_texts = ("edit profile", "share profile", "profil bearbeiten", "profil teilen")
|
||||
if selected_tab == "profile_tab" or any(m in desc_lower or m in text_lower for m in own_profile_texts):
|
||||
return ScreenType.OWN_PROFILE
|
||||
return ScreenType.OTHER_PROFILE
|
||||
|
||||
@@ -184,17 +209,14 @@ class ScreenIdentity:
|
||||
if any(marker in texts for marker in chat_input_markers) or "direct_thread_header" in ids:
|
||||
return ScreenType.DM_THREAD
|
||||
|
||||
# Priority 2: Check Qdrant Semantic Cache (Fuzzy/VLM derived)
|
||||
if signature and self.screen_memory and self.screen_memory.is_connected:
|
||||
cached_type_str = self.screen_memory.get_screen_type(signature, similarity_threshold=0.92)
|
||||
if cached_type_str:
|
||||
try:
|
||||
return ScreenType[cached_type_str]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
if "row_feed_button_like" in ids and "row_feed_photo_profile_name" in ids and not selected_tab:
|
||||
return ScreenType.POST_DETAIL
|
||||
# POST_DETAIL vs HOME_FEED: Both have row_feed_* markers. The differentiator
|
||||
# is that HOME_FEED has the main_feed_action_bar (top bar with 'Instagram' title).
|
||||
# POST_DETAIL lacks this because it shows a single expanded post.
|
||||
# Note: We MUST NOT use `not selected_tab` here — posts opened from feed
|
||||
# retain the feed_tab as selected, which previously caused misclassification.
|
||||
if "row_feed_button_like" in ids and "row_feed_photo_profile_name" in ids:
|
||||
if "main_feed_action_bar" not in ids:
|
||||
return ScreenType.POST_DETAIL
|
||||
|
||||
# Story view structural markers — present in full-screen story viewer.
|
||||
# Stories hide the navigation tab bar, so selected_tab is always None.
|
||||
@@ -219,7 +241,7 @@ class ScreenIdentity:
|
||||
return ScreenType.REELS_FEED
|
||||
if selected_tab == "search_tab":
|
||||
return ScreenType.EXPLORE_GRID
|
||||
if "action_bar_search_edit_text" in ids and "search_tab" in ids:
|
||||
if "action_bar_search_edit_text" in ids:
|
||||
return ScreenType.EXPLORE_GRID
|
||||
if selected_tab == "profile_tab":
|
||||
return ScreenType.OWN_PROFILE
|
||||
@@ -228,41 +250,75 @@ class ScreenIdentity:
|
||||
if "message_input" in ids:
|
||||
return ScreenType.DM_INBOX # Fallback for DM thread as inbox
|
||||
|
||||
# Priority 3: Semantic VLM Classification Fallback
|
||||
# End of structural heuristics
|
||||
|
||||
# Priority 3: Cached Semantic Type (If deterministic heuristics failed)
|
||||
if cached_type_str and cached_type_str != "NORMAL":
|
||||
try:
|
||||
cached_type = ScreenType[cached_type_str]
|
||||
# Enforce absolute structural parity: Story and Reels must have their structural markers.
|
||||
# If they reached Priority 3, it means Priority 2 failed to find their markers.
|
||||
# Therefore, any cache telling us this is a Story/Reel without those markers is hallucinating.
|
||||
if cached_type in (ScreenType.STORY_VIEW, ScreenType.REELS_FEED):
|
||||
logger.warning(
|
||||
f"⚠️ [ScreenIdentity] Rejecting cached {cached_type.name} due to missing structural markers."
|
||||
)
|
||||
else:
|
||||
return cached_type
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# Priority 4: Semantic VLM Classification Fallback
|
||||
if not screenshot_b64 and getattr(self, "device", None) is not None:
|
||||
screenshot_b64 = self.device.get_screenshot_b64()
|
||||
|
||||
from GramAddict.core.config import Config
|
||||
from GramAddict.core.llm_provider import query_llm
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
|
||||
cfg = Config()
|
||||
url = (
|
||||
getattr(cfg.args, "ai_model_url", "http://localhost:11434/api/generate")
|
||||
getattr(cfg.args, "ai_telepathic_url", "http://localhost:11434/api/generate")
|
||||
if hasattr(cfg, "args")
|
||||
else "http://localhost:11434/api/generate"
|
||||
)
|
||||
model = getattr(cfg.args, "ai_model", "qwen3.5:latest") if hasattr(cfg, "args") else "qwen3.5:latest"
|
||||
model = getattr(cfg.args, "ai_telepathic_model", "llava:latest") if hasattr(cfg, "args") else "llava:latest"
|
||||
|
||||
layout_context = (
|
||||
f"Selected Tab: {selected_tab}\nResource IDs: {list(ids)}\nVisible Texts context: {texts[:10]}\n"
|
||||
)
|
||||
prompt = (
|
||||
f"Identify the Instagram screen layout type based on these DOM structural signals.\n"
|
||||
f"Identify the Instagram screen layout type based on the provided screenshot and structural signals.\n"
|
||||
f"Valid types: {[t.name for t in ScreenType]}\n"
|
||||
f"Context:\n{layout_context}\n"
|
||||
f"Reply ONLY with the exact matching enum Type Name string, or 'UNKNOWN' if no type matches."
|
||||
)
|
||||
|
||||
try:
|
||||
response = query_llm(
|
||||
url=url, model=model, prompt="Classify this screen layout.", system=prompt, format_json=False
|
||||
response = query_telepathic_llm(
|
||||
model=model,
|
||||
url=url,
|
||||
system_prompt=prompt,
|
||||
user_prompt="Classify this screen layout.",
|
||||
images_b64=[screenshot_b64] if screenshot_b64 else None,
|
||||
temperature=0.0,
|
||||
use_local_edge=True,
|
||||
)
|
||||
if response and isinstance(response, str):
|
||||
result = response.strip().upper()
|
||||
elif response and isinstance(response, dict) and "response" in response:
|
||||
result = response["response"].strip().upper()
|
||||
else:
|
||||
return ScreenType.UNKNOWN
|
||||
|
||||
result = response.strip().upper() if response else "UNKNOWN"
|
||||
|
||||
for t in ScreenType:
|
||||
if t.name in result:
|
||||
if is_normal_override and t == ScreenType.MODAL:
|
||||
# Prevent the LLM from hallucinating an obstacle if explicitly verified as NORMAL
|
||||
return ScreenType.UNKNOWN
|
||||
|
||||
# Enforce absolute structural parity: Story and Reels must have their structural markers.
|
||||
if t in (ScreenType.STORY_VIEW, ScreenType.REELS_FEED):
|
||||
logger.warning(
|
||||
f"⚠️ [ScreenIdentity] Rejecting VLM hallucinated {t.name} due to missing structural markers."
|
||||
)
|
||||
return ScreenType.UNKNOWN
|
||||
|
||||
if signature and self.screen_memory:
|
||||
self.screen_memory.store_screen(signature, t.name)
|
||||
return t
|
||||
@@ -303,8 +359,19 @@ class ScreenIdentity:
|
||||
actions.append("tap save button")
|
||||
if "back" in desc_lower:
|
||||
actions.append("tap back button")
|
||||
if any("follow" in e.get("text", "").lower() for e in clickable_elements):
|
||||
actions.append("tap 'Follow' button")
|
||||
has_following = any(
|
||||
"following" in e.get("text", "").lower() or "following" in e.get("desc", "").lower()
|
||||
for e in clickable_elements
|
||||
)
|
||||
if has_following:
|
||||
actions.append("tap following button")
|
||||
elif any(
|
||||
"follow" in e.get("text", "").lower()
|
||||
or "follow" in e.get("desc", "").lower()
|
||||
or "follow" in e.get("id", "").lower()
|
||||
for e in clickable_elements
|
||||
):
|
||||
actions.append("tap follow button")
|
||||
|
||||
if screen_type == ScreenType.OWN_PROFILE or screen_type == ScreenType.OTHER_PROFILE:
|
||||
if "message" in desc_lower or "nachricht" in desc_lower:
|
||||
|
||||
@@ -117,12 +117,13 @@ class SemanticEvaluator:
|
||||
You are a user with the following interests: {', '.join(persona_interests)}.
|
||||
You are looking at an Instagram post.
|
||||
Evaluate if this post is highly relevant to your interests and if you should like/comment on it.
|
||||
CRITICAL: Check if this post is an advertisement or sponsored content (look for "Sponsored", "Ad", or promotional product placement).
|
||||
|
||||
Reply ONLY in valid JSON format:
|
||||
{{
|
||||
"should_like": true/false,
|
||||
"should_comment": true/false,
|
||||
"reasoning": "brief explanation"
|
||||
"is_ad": true/false
|
||||
}}
|
||||
"""
|
||||
response = self._query_vlm(prompt, screenshot_b64)
|
||||
@@ -131,7 +132,17 @@ class SemanticEvaluator:
|
||||
json_str = response.split("```json")[1].split("```")[0].strip()
|
||||
else:
|
||||
json_str = response.strip()
|
||||
return json.loads(json_str)
|
||||
try:
|
||||
return json.loads(json_str)
|
||||
except json.JSONDecodeError:
|
||||
# Try to close potential unclosed JSON strings
|
||||
if not json_str.endswith("}"):
|
||||
json_str += "}"
|
||||
try:
|
||||
return json.loads(json_str)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
logger.warning(f"👁️ [Vision Core] VLM returned malformed JSON: {response}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to evaluate post vibe: {e}")
|
||||
return None
|
||||
|
||||
@@ -184,7 +184,7 @@ class SpatialParser:
|
||||
for n in all_nodes:
|
||||
has_semantic = bool(n.text or n.content_desc)
|
||||
semantic_res = n.resource_id and any(
|
||||
x in n.resource_id.lower() for x in ["button", "tab", "icon", "action", "menu"]
|
||||
x in n.resource_id.lower() for x in ["button", "tab", "icon", "action", "menu", "imageview"]
|
||||
)
|
||||
|
||||
if n.clickable or n.scrollable or semantic_res or (has_semantic and n.area < 500000 and n.area > 0):
|
||||
|
||||
@@ -13,7 +13,8 @@ class PersistentList(list):
|
||||
self.load()
|
||||
|
||||
def load(self):
|
||||
path = f"accounts/{self.filename}.json"
|
||||
base_dir = os.environ.get("GRAMADDICT_ACCOUNTS_DIR", "accounts")
|
||||
path = f"{base_dir}/{self.filename}.json"
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
@@ -27,9 +28,8 @@ class PersistentList(list):
|
||||
self.persist()
|
||||
|
||||
def persist(self, directory=None):
|
||||
if os.environ.get("PYTEST_CURRENT_TEST"):
|
||||
return
|
||||
folder = f"accounts/{directory}" if directory else "accounts"
|
||||
base_dir = os.environ.get("GRAMADDICT_ACCOUNTS_DIR", "accounts")
|
||||
folder = f"{base_dir}/{directory}" if directory else base_dir
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
path = f"{folder}/{self.filename}.json"
|
||||
try:
|
||||
|
||||
@@ -136,11 +136,12 @@ def align_active_post(device):
|
||||
aligned = False
|
||||
attempts = 0
|
||||
max_attempts = 5 # Increased for structural retry loop
|
||||
failed_bounds = set()
|
||||
|
||||
# Intents for structural discovery
|
||||
intents = [
|
||||
"post author username text (exclude follow buttons)",
|
||||
"post author header profile",
|
||||
"post username name",
|
||||
"row_feed_photo_profile_name", # ID fallback
|
||||
"clips_viewer_author_container", # Reels fallback
|
||||
"feed post content", # Final desperation
|
||||
@@ -150,13 +151,19 @@ def align_active_post(device):
|
||||
attempts += 1
|
||||
try:
|
||||
xml = device.dump_hierarchy()
|
||||
if "clips_video_container" in xml or "clips_viewer_container" in xml:
|
||||
logger.info("🎯 [Alignment] Reels view detected. Auto-snapping is native.")
|
||||
return True
|
||||
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
telepath = TelepathicEngine.get_instance()
|
||||
|
||||
target_node = None
|
||||
for intent in intents:
|
||||
target_node = telepath.find_best_node(xml, intent, min_confidence=0.35, device=device, track=False)
|
||||
target_node = telepath.find_best_node(
|
||||
xml, intent, min_confidence=0.35, device=device, track=False, exclude_bounds=list(failed_bounds)
|
||||
)
|
||||
if target_node:
|
||||
break
|
||||
|
||||
@@ -164,9 +171,11 @@ def align_active_post(device):
|
||||
original_attribs = target_node.get("original_attribs", {})
|
||||
bounds = original_attribs.get("bounds")
|
||||
|
||||
bounds_str = ""
|
||||
# If bounds is a tuple from SpatialNode.to_dict()
|
||||
if isinstance(bounds, (tuple, list)) and len(bounds) == 4:
|
||||
left, t, r, b = bounds
|
||||
bounds_str = f"[{left},{t}][{r},{b}]"
|
||||
else:
|
||||
# Fallback to string parsing
|
||||
if not bounds:
|
||||
@@ -174,6 +183,7 @@ def align_active_post(device):
|
||||
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", str(bounds))
|
||||
if m:
|
||||
left, t, r, b = map(int, m.groups())
|
||||
bounds_str = f"[{left},{t}][{r},{b}]"
|
||||
else:
|
||||
logger.warning(f"📐 [Alignment] Could not parse bounds: {bounds}")
|
||||
continue
|
||||
@@ -184,6 +194,7 @@ def align_active_post(device):
|
||||
h = info.get("displayHeight", 2400)
|
||||
if t > h * 0.85:
|
||||
logger.debug(f"📐 [Alignment] Rejecting node at y={t} (too low, likely bottom bar)")
|
||||
failed_bounds.add(bounds_str)
|
||||
continue
|
||||
|
||||
header_y = (t + b) // 2
|
||||
|
||||
@@ -121,32 +121,11 @@ class QNavGraph:
|
||||
GOAP-powered action execution.
|
||||
Replaces _execute_transition() for post interactions.
|
||||
|
||||
Screen-aware: refuses to attempt actions that don't exist on the current screen.
|
||||
|
||||
Usage:
|
||||
nav_graph.do("like this post") # instead of _execute_transition("tap_like_button")
|
||||
nav_graph.do("follow this user") # instead of _execute_transition("tap_follow_button")
|
||||
nav_graph.do("tap first grid item") # instead of _execute_transition("tap_explore_grid_item")
|
||||
"""
|
||||
# ── Screen sanity check: is this action possible here? ──
|
||||
screen = self.goap.perceive()
|
||||
available = screen.get("available_actions", [])
|
||||
screen_type = screen["screen_type"]
|
||||
|
||||
# Map goal to the action that should be available
|
||||
action_checks = {
|
||||
"like": "tap like button",
|
||||
"comment": "tap comment button",
|
||||
"share": "tap share button",
|
||||
"follow": "tap follow button",
|
||||
}
|
||||
for keyword, required_action in action_checks.items():
|
||||
if keyword in goal.lower() and required_action not in available:
|
||||
logger.warning(
|
||||
f"🚫 [GOAP] Cannot '{goal}' on {screen_type.value} "
|
||||
f"('{required_action}' not available on this screen)"
|
||||
)
|
||||
return False
|
||||
|
||||
return self.goap._execute_action(goal)
|
||||
|
||||
@@ -172,13 +151,13 @@ class QNavGraph:
|
||||
success = self.sae.ensure_clear_screen(max_attempts=max_attempts + 5, initial_xml=xml_dump)
|
||||
return success
|
||||
|
||||
def _execute_transition(self, action: str, mock_semantic_engine=None, max_retries: int = 2) -> bool:
|
||||
def _execute_transition(self, action: str, max_retries: int = 2) -> bool:
|
||||
"""
|
||||
Executes a transition (e.g. 'tap_explore_tab') using the Telepathic Semantic Engine.
|
||||
"""
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
engine = mock_semantic_engine or TelepathicEngine.get_instance()
|
||||
engine = TelepathicEngine.get_instance()
|
||||
|
||||
failed_positions = set() # Track (x, y) of clicks that failed, for grid retry diversity
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ class QdrantBase:
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=12,
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.debug(f"Embedding API Error {resp.status_code}: {resp.text}")
|
||||
@@ -184,6 +184,7 @@ class QdrantBase:
|
||||
self.client.upsert(
|
||||
collection_name=self.collection_name,
|
||||
points=[PointStruct(id=point_id, vector=safe_vector, payload=payload)],
|
||||
wait=True,
|
||||
)
|
||||
|
||||
# ABSOLUTE LOGGING: User requirement for full observability
|
||||
@@ -431,7 +432,7 @@ class UIMemoryDB(QdrantBase):
|
||||
if eval_result:
|
||||
logger.info(
|
||||
f"🧠 [Memory] Applying learned pattern for '{intent}' (EXACT MATCH, Confidence: {eval_result['effective_confidence']:.2f})",
|
||||
extra={"color": "\x1b[36m"} # Cyan color
|
||||
extra={"color": "\x1b[36m"}, # Cyan color
|
||||
)
|
||||
return eval_result["solution"]
|
||||
# If exact match failed evaluation (e.g. decayed), we shouldn't fall back to vector search because it's the exact intent!
|
||||
@@ -462,7 +463,7 @@ class UIMemoryDB(QdrantBase):
|
||||
if eval_result:
|
||||
logger.info(
|
||||
f"🧠 [Memory] Applying learned pattern for '{intent}' (VECTOR MATCH, Score: {results[0].score:.3f}, Confidence: {eval_result['effective_confidence']:.2f})",
|
||||
extra={"color": "\x1b[36m"} # Cyan color
|
||||
extra={"color": "\x1b[36m"}, # Cyan color
|
||||
)
|
||||
return eval_result["solution"]
|
||||
return None
|
||||
@@ -515,7 +516,7 @@ class UIMemoryDB(QdrantBase):
|
||||
)
|
||||
logger.info(
|
||||
f"📥 [Memory] Learned new pattern for '{intent}' and saved to Qdrant (ID: {point_id[:8]}...)",
|
||||
extra={"color": "\x1b[35m"} # Magenta color
|
||||
extra={"color": "\x1b[35m"}, # Magenta color
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Qdrant storage error: {e}")
|
||||
@@ -582,7 +583,7 @@ class UIMemoryDB(QdrantBase):
|
||||
symbol = "📈 [Memory] Positive Reinforcement:" if delta > 0 else "📉 [Memory] Negative Reinforcement:"
|
||||
logger.info(
|
||||
f"{symbol} Confidence for '{intent}' adjusted to {new_confidence:.2f} (delta: {delta:+.2f})",
|
||||
extra={"color": color}
|
||||
extra={"color": color},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Confidence adjustment error: {e}")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
from colorama import Fore
|
||||
@@ -331,14 +332,32 @@ class ResonanceEngine:
|
||||
is_comment_node = "comment" in res_id or "textview" in res_id
|
||||
|
||||
# 3. Block accessibility garbage & UI labels
|
||||
# Zero-Maintenance: Only structural patterns. Short strings
|
||||
# (< 5 chars) from UI buttons are blocked by length, not by
|
||||
# translating every possible language.
|
||||
is_ui_junk = (
|
||||
val.lower().startswith("go to")
|
||||
or val.lower().startswith("tap to")
|
||||
or "actions for this post" in val.lower()
|
||||
or len(val.strip()) < 3
|
||||
)
|
||||
|
||||
# Block known English UI action labels.
|
||||
# We intentionally do NOT add German/Spanish/etc translations.
|
||||
# Instead, we rely on the structural `is_comment_node` filter
|
||||
# above + length heuristic to catch non-comment UI elements.
|
||||
blocked_exact = [
|
||||
"reply",
|
||||
"like",
|
||||
"view replies",
|
||||
"see translation",
|
||||
"hide replies",
|
||||
"view all comments",
|
||||
"send",
|
||||
]
|
||||
|
||||
if val and len(val) > 2 and is_comment_node and not is_ui_junk:
|
||||
if val.lower() not in ["reply", "like", "view replies", "see translation", "hide replies"]:
|
||||
if val.lower() not in blocked_exact:
|
||||
raw_comments.append(val)
|
||||
except Exception as e:
|
||||
logger.error(f"🧠 [Comment Learning] Failed to parse XML: {e}")
|
||||
@@ -393,7 +412,7 @@ class ResonanceEngine:
|
||||
logger.debug(f"DEBUG CONDENSER RAW: {response_text}")
|
||||
|
||||
# Parse json gracefully
|
||||
if type(response_text) is str:
|
||||
if isinstance(response_text, str):
|
||||
clean_json = response_text.strip()
|
||||
if clean_json.startswith("```json"):
|
||||
clean_json = clean_json[7:]
|
||||
|
||||
@@ -63,7 +63,12 @@ class ScreenTopology:
|
||||
},
|
||||
ScreenType.OTHER_PROFILE: {
|
||||
"press back": ScreenType.HOME_FEED,
|
||||
},
|
||||
ScreenType.POST_DETAIL: {
|
||||
"tap home tab": ScreenType.HOME_FEED,
|
||||
"tap explore tab": ScreenType.EXPLORE_GRID,
|
||||
"tap profile tab": ScreenType.OWN_PROFILE,
|
||||
"tap reels tab": ScreenType.REELS_FEED,
|
||||
},
|
||||
ScreenType.UNKNOWN: {
|
||||
"tap home tab": ScreenType.HOME_FEED,
|
||||
|
||||
@@ -277,7 +277,29 @@ class SessionState:
|
||||
|
||||
|
||||
class SessionStateEncoder(JSONEncoder):
|
||||
"""JSON encoder for SessionState that is crash-proof against non-serializable types."""
|
||||
|
||||
_SAFE_TYPES = (str, int, float, bool, type(None))
|
||||
|
||||
@classmethod
|
||||
def _sanitize_value(cls, value):
|
||||
"""Convert any non-JSON-serializable value to a safe string representation."""
|
||||
if isinstance(value, cls._SAFE_TYPES):
|
||||
return value
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, dict):
|
||||
return {k: cls._sanitize_value(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [cls._sanitize_value(v) for v in value]
|
||||
# Last resort: stringify unknown objects to prevent json.dump mid-write crashes
|
||||
return str(value)
|
||||
|
||||
def default(self, session_state: SessionState):
|
||||
# Sanitize args dict — never trust raw __dict__, it may contain datetime or other garbage
|
||||
raw_args = session_state.args.__dict__ if hasattr(session_state.args, "__dict__") else {}
|
||||
safe_args = {k: self._sanitize_value(v) for k, v in raw_args.items()}
|
||||
|
||||
return {
|
||||
"id": session_state.id,
|
||||
"total_interactions": sum(session_state.totalInteractions.values()),
|
||||
@@ -291,7 +313,7 @@ class SessionStateEncoder(JSONEncoder):
|
||||
"total_scraped": session_state.totalScraped,
|
||||
"start_time": str(session_state.startTime),
|
||||
"finish_time": str(session_state.finishTime),
|
||||
"args": session_state.args.__dict__,
|
||||
"args": safe_args,
|
||||
"profile": {
|
||||
"posts": session_state.my_posts_count,
|
||||
"followers": session_state.my_followers_count,
|
||||
|
||||
@@ -270,7 +270,13 @@ class SituationalAwarenessEngine:
|
||||
if clickable == "true":
|
||||
parts.append("CLICKABLE")
|
||||
if bounds:
|
||||
parts.append(f"bounds={bounds}")
|
||||
nums = [int(n) for n in re.findall(r"\d+", bounds)]
|
||||
if len(nums) == 4:
|
||||
cx = (nums[0] + nums[2]) // 2
|
||||
cy = (nums[1] + nums[3]) // 2
|
||||
parts.append(f"bounds={bounds} center=({cx},{cy})")
|
||||
else:
|
||||
parts.append(f"bounds={bounds}")
|
||||
|
||||
elements.append(" | ".join(parts))
|
||||
|
||||
@@ -293,8 +299,6 @@ class SituationalAwarenessEngine:
|
||||
if not xml_dump or not isinstance(xml_dump, str):
|
||||
return SituationType.OBSTACLE_FOREIGN_APP
|
||||
|
||||
xml_dump.lower()
|
||||
|
||||
blocked_markers = [
|
||||
"try again later",
|
||||
"action blocked",
|
||||
@@ -346,8 +350,31 @@ class SituationalAwarenessEngine:
|
||||
is_foreign = True
|
||||
|
||||
if is_foreign:
|
||||
# We explicitly ask the TelepathicEngine to classify this to avoid writing brittle substring hacks
|
||||
# for Android System UI variations across different device manufacturers.
|
||||
# ── Tier 1: Known Foreign Packages (O(1) — ZERO LLM) ──
|
||||
# Production bug 2026-05-03: Play Store was detected via slow LLM path.
|
||||
# For these well-known packages, a set lookup is instant and infallible.
|
||||
KNOWN_FOREIGN_PACKAGES = {
|
||||
"com.android.vending", # Play Store
|
||||
"com.android.chrome", # Chrome
|
||||
"com.google.android.chrome", # Chrome (Google build)
|
||||
"com.google.android.youtube", # YouTube
|
||||
"org.mozilla.firefox", # Firefox
|
||||
"com.opera.browser", # Opera
|
||||
"com.brave.browser", # Brave
|
||||
"com.microsoft.emmx", # Edge
|
||||
"com.sec.android.app.sbrowser", # Samsung Browser
|
||||
}
|
||||
dominant_pkgs = packages - {"com.android.systemui"}
|
||||
fast_match = dominant_pkgs & KNOWN_FOREIGN_PACKAGES
|
||||
if fast_match:
|
||||
logger.info(
|
||||
f"🚨 [SAE Perceive] Known foreign package: {fast_match} → "
|
||||
f"OBSTACLE_FOREIGN_APP (O(1) fast-path, no LLM needed)"
|
||||
)
|
||||
return SituationType.OBSTACLE_FOREIGN_APP
|
||||
|
||||
# ── Tier 2: Unknown/Ambiguous Packages → LLM Classification ──
|
||||
# Only SystemUI-only or rare custom packages reach this path.
|
||||
try:
|
||||
from GramAddict.core.config import Config
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
@@ -406,11 +433,21 @@ class SituationalAwarenessEngine:
|
||||
|
||||
compressed = self._compress_xml(xml_dump)
|
||||
|
||||
cached_type = screen_memory.get_screen_type(compressed)
|
||||
|
||||
if cached_type:
|
||||
if cached_type == "OBSTACLE_MODAL":
|
||||
return SituationType.OBSTACLE_MODAL
|
||||
elif cached_type == "NORMAL":
|
||||
return SituationType.NORMAL
|
||||
|
||||
# ── Structural Fast-Check: Content-Creation Overlays ──
|
||||
# These full-screen overlays live INSIDE Instagram's package but block
|
||||
# all normal navigation. They are invisible to the foreign-app detector
|
||||
# and frequently fool the LLM into thinking they are "normal" browsing.
|
||||
# Detecting them structurally is O(1) and requires ZERO LLM calls.
|
||||
# This is checked AFTER Qdrant to ensure that if the LLM unlearned a false positive,
|
||||
# we respect the learned NORMAL state and don't infinite-loop.
|
||||
creation_flow_markers = (
|
||||
"quick_capture", # Camera / story capture overlay
|
||||
"gallery_cancel_button", # Story gallery "Back to Home" button
|
||||
@@ -427,13 +464,56 @@ class SituationalAwarenessEngine:
|
||||
screen_memory.store_screen(compressed, "OBSTACLE_MODAL")
|
||||
return SituationType.OBSTACLE_MODAL
|
||||
|
||||
cached_type = screen_memory.get_screen_type(compressed)
|
||||
# ── Structural Fast-Check: Instagram-Internal Modal Overlays ──
|
||||
# Surveys, rating prompts, and interstitial modals live INSIDE Instagram's
|
||||
# package but block normal interaction. They share a common structural
|
||||
# pattern: a container resource-id containing "survey", "interstitial",
|
||||
# or "nux_" (new-user-experience), plus dismiss buttons ("Not Now").
|
||||
# Detecting them structurally is O(1) and eliminates LLM hallucination risk.
|
||||
instagram_modal_markers = (
|
||||
"survey_overlay_container", # "How are you enjoying Instagram?" survey
|
||||
"survey_title", # Survey title text view
|
||||
"interstitial_container", # Generic interstitial blocker
|
||||
"mystery_interstitial", # Unknown/dynamic interstitials
|
||||
"nux_overlay", # New-user-experience onboarding modals
|
||||
"rating_prompt", # App Store rating prompt
|
||||
"feedback_dialog", # Feedback collection dialogs
|
||||
)
|
||||
if any(
|
||||
re.search(rf'resource-id="[^"]*{marker}[^"]*"', xml_dump, re.IGNORECASE)
|
||||
for marker in instagram_modal_markers
|
||||
):
|
||||
logger.info("🧠 [SAE Perceive] Instagram modal overlay detected structurally → OBSTACLE_MODAL")
|
||||
screen_memory.store_screen(compressed, "OBSTACLE_MODAL")
|
||||
return SituationType.OBSTACLE_MODAL
|
||||
|
||||
if cached_type:
|
||||
if cached_type == "OBSTACLE_MODAL":
|
||||
# Fallback heuristic: detect modals by dismiss-button text patterns.
|
||||
# If we see "Not Now" or "Take Survey" as button text inside Instagram, it's a modal.
|
||||
# Guard: match ONLY inside short text attributes (< 40 chars) to avoid caption false positives.
|
||||
dismiss_button_patterns = (
|
||||
r'text="Not Now"',
|
||||
r'text="not now"',
|
||||
r'text="Nicht jetzt"', # German: "Not Now"
|
||||
r'text="Take Survey"',
|
||||
r'text="rate \d+ stars?"', # "rate 5 stars"
|
||||
r'text="Bewerten"', # German: "Rate"
|
||||
)
|
||||
has_dismiss_button = any(re.search(p, xml_dump, re.IGNORECASE) for p in dismiss_button_patterns)
|
||||
if has_dismiss_button:
|
||||
# Cross-validate: must also have a container that looks like a dialog/overlay
|
||||
# (not just a random "Not Now" text in a DM thread or post caption)
|
||||
has_overlay_structure = bool(
|
||||
re.search(
|
||||
r'resource-id="[^"]*(?:overlay|dialog|interstitial|survey|sheet|prompt)[^"]*"',
|
||||
xml_dump,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
or re.search(r'resource-id="[^"]*button_(?:negative|positive)[^"]*"', xml_dump, re.IGNORECASE)
|
||||
)
|
||||
if has_overlay_structure:
|
||||
logger.info("🧠 [SAE Perceive] Instagram dismiss-button modal detected structurally → OBSTACLE_MODAL")
|
||||
screen_memory.store_screen(compressed, "OBSTACLE_MODAL")
|
||||
return SituationType.OBSTACLE_MODAL
|
||||
elif cached_type == "NORMAL":
|
||||
return SituationType.NORMAL
|
||||
|
||||
# If not cached, query LLM for autonomous structural classification
|
||||
try:
|
||||
@@ -442,7 +522,7 @@ class SituationalAwarenessEngine:
|
||||
|
||||
prompt = (
|
||||
"You are a Situation Classifier for a mobile automation agent.\n"
|
||||
"Analyze the given Android UI XML dump. Is there a blocking MODAL, DIALOG, or POPUP "
|
||||
"Analyze the given Android UI XML dump AND screenshot. Is there a blocking MODAL, DIALOG, or POPUP "
|
||||
"covering the screen that needs to be dismissed, or is this a NORMAL usable screen?\n"
|
||||
"A 'clean_sheet_container' with standard Instagram feed content is NORMAL.\n"
|
||||
"A survey, rating prompt, 'not now' prompt, or permission dialog is an OBSTACLE_MODAL.\n"
|
||||
@@ -459,11 +539,17 @@ class SituationalAwarenessEngine:
|
||||
args = Config().args
|
||||
except Exception:
|
||||
pass
|
||||
model = getattr(args, "ai_model", "qwen3.5:latest")
|
||||
url = getattr(args, "ai_model_url", "http://localhost:11434/api/generate")
|
||||
model = getattr(args, "ai_telepathic_model", "llava:latest")
|
||||
url = getattr(args, "ai_telepathic_url", "http://localhost:11434/api/generate")
|
||||
|
||||
screenshot_b64 = getattr(self.device, "get_screenshot_b64", lambda: None)()
|
||||
res = query_telepathic_llm(
|
||||
model=model, url=url, system_prompt="Strict JSON classifier.", user_prompt=prompt, use_local_edge=True
|
||||
model=model,
|
||||
url=url,
|
||||
system_prompt="Strict JSON classifier.",
|
||||
user_prompt=prompt,
|
||||
images_b64=[screenshot_b64] if screenshot_b64 else None,
|
||||
use_local_edge=True,
|
||||
)
|
||||
import json
|
||||
|
||||
@@ -504,27 +590,31 @@ class SituationalAwarenessEngine:
|
||||
Called ONLY when recall AND structural planning both miss.
|
||||
"""
|
||||
from GramAddict.core.config import Config
|
||||
from GramAddict.core.llm_provider import query_llm
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
|
||||
try:
|
||||
args = Config().args
|
||||
model = getattr(args, "ai_fallback_model", "llama3.2:1b")
|
||||
url = getattr(args, "ai_fallback_url", "http://localhost:11434/api/generate")
|
||||
model = getattr(args, "ai_telepathic_model", "llava:latest")
|
||||
url = getattr(args, "ai_telepathic_url", "http://localhost:11434/api/generate")
|
||||
except Exception:
|
||||
model = "llama3.2:1b"
|
||||
model = "llava:latest"
|
||||
url = "http://localhost:11434/api/generate"
|
||||
|
||||
system_prompt = (
|
||||
"You are an Android UI navigation agent. Your job is to escape obstacles "
|
||||
"(dialogs, modals, foreign apps, system popups) and return to Instagram. "
|
||||
"Analyze the screen content and return a JSON escape action.\n\n"
|
||||
"Analyze the screen content (Screenshot AND XML) and return a JSON escape action.\n\n"
|
||||
"Rules:\n"
|
||||
"- If you see a dismiss/close/cancel/skip/not now button, click it\n"
|
||||
"- If the Situation type is OBSTACLE_LOCKED_SCREEN, action must be 'unlock'\n"
|
||||
"- If the Situation type is OBSTACLE_FOREIGN_APP, action must be 'kill_foreign_apps'\n"
|
||||
"- If the Situation type is obstacle_locked_screen, action must be 'unlock'\n"
|
||||
"- If the Situation type is obstacle_foreign_app, action must be 'kill_foreign_apps'\n"
|
||||
"- If the Situation type is obstacle_system, you MUST look for 'Deny', 'Don't allow', or 'Cancel' and click it. \n"
|
||||
" NEVER click 'Allow', 'OK', or 'Confirm' on system permissions.\n"
|
||||
" If no negative action button exists, action must be 'back'\n"
|
||||
"- If there is NO obstacle and the screen is a normal Instagram view (false positive), action must be 'false_positive'\n"
|
||||
"- If nothing else works, suggest 'app_start' to force-reopen Instagram\n"
|
||||
"- NEVER click 'OK'/'Confirm'/'Accept' on surveys or prompts\n"
|
||||
"- When you choose to click, you MUST use the EXACT coordinates provided in `center=(x,y)` for that element in the XML\n"
|
||||
'- Return ONLY valid JSON: {"action": "click"|"back"|"app_start"|"unlock"|"kill_foreign_apps"|"false_positive", "x": N, "y": N, "reason": "..."}'
|
||||
)
|
||||
|
||||
@@ -535,20 +625,31 @@ class SituationalAwarenessEngine:
|
||||
user_prompt += "What action should I take to clear this obstacle and return to Instagram? Return JSON only."
|
||||
|
||||
try:
|
||||
resp = query_llm(
|
||||
screenshot_b64 = getattr(self.device, "get_screenshot_b64", lambda: None)()
|
||||
|
||||
resp = query_telepathic_llm(
|
||||
url=url,
|
||||
model=model,
|
||||
prompt=user_prompt,
|
||||
system=system_prompt,
|
||||
format_json=True,
|
||||
timeout=30,
|
||||
max_tokens=300,
|
||||
user_prompt=user_prompt,
|
||||
system_prompt=system_prompt,
|
||||
images_b64=[screenshot_b64] if screenshot_b64 else None,
|
||||
temperature=0.0,
|
||||
)
|
||||
if resp and "response" in resp:
|
||||
if resp:
|
||||
import json
|
||||
|
||||
data = json.loads(resp["response"])
|
||||
try:
|
||||
data = json.loads(resp)
|
||||
except json.JSONDecodeError:
|
||||
# Try extracting JSON via regex if LLM was chatty
|
||||
import re
|
||||
|
||||
match = re.search(r"\{.*\}", resp, re.DOTALL)
|
||||
if match:
|
||||
data = json.loads(match.group(0))
|
||||
else:
|
||||
raise ValueError(f"Could not parse JSON from: {resp}")
|
||||
|
||||
return EscapeAction(
|
||||
action_type=data.get("action", "back"),
|
||||
x=int(data.get("x", 0)),
|
||||
@@ -660,6 +761,24 @@ class SituationalAwarenessEngine:
|
||||
|
||||
logger.warning(f"🔍 [SAE] Obstacle detected: {situation.value} (attempt {attempt + 1}/{max_attempts})")
|
||||
|
||||
# ── O(1) Fast-Path for Foreign Apps ──
|
||||
if situation == SituationType.OBSTACLE_FOREIGN_APP:
|
||||
logger.warning("⚡ [SAE Fast-Path] Foreign App detected. Bypassing LLM and killing immediately.")
|
||||
action = EscapeAction("kill_foreign_apps", reason="O(1) fast-path to eliminate foreign app")
|
||||
self._execute_escape(action)
|
||||
|
||||
# Check if we recovered
|
||||
post_xml = self.device.dump_hierarchy()
|
||||
if self.perceive(post_xml) == SituationType.NORMAL:
|
||||
logger.info("✅ [SAE Fast-Path] Foreign App cleared successfully!")
|
||||
self._consecutive_failures = 0
|
||||
return True
|
||||
|
||||
# If we didn't recover, log it and let the loop continue
|
||||
logger.warning("⚠️ [SAE Fast-Path] kill_foreign_apps did not return to NORMAL. Retrying...")
|
||||
self._consecutive_failures += 1
|
||||
continue
|
||||
|
||||
# ── COMPRESS for memory lookup ──
|
||||
compressed = self._compress_xml(xml_dump)
|
||||
|
||||
|
||||
@@ -54,10 +54,14 @@ class TelepathicEngine:
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def find_best_node(
|
||||
self, xml_string: str, intent_description: str, device=None, track: bool = True, **kwargs
|
||||
self,
|
||||
xml_string: str,
|
||||
intent_description: str,
|
||||
device=None,
|
||||
track: bool = True,
|
||||
exclude_bounds: list[str] = None,
|
||||
**kwargs,
|
||||
) -> Optional[dict]:
|
||||
print("FIND_BEST_NODE CALLED")
|
||||
|
||||
"""
|
||||
Public facade for resolving a node.
|
||||
Translates Android UI bounds into standard GramAddict node dicts.
|
||||
@@ -73,6 +77,14 @@ class TelepathicEngine:
|
||||
# 2. Extract interactable candidates
|
||||
candidates = self._parser.get_clickable_nodes(root)
|
||||
|
||||
if exclude_bounds:
|
||||
filtered_candidates = []
|
||||
for c in candidates:
|
||||
bounds_str = f"[{c.x1},{c.y1}][{c.x2},{c.y2}]"
|
||||
if bounds_str not in exclude_bounds:
|
||||
filtered_candidates.append(c)
|
||||
candidates = filtered_candidates
|
||||
|
||||
# 3. Resolve intent against candidates
|
||||
best_node = self._resolver.resolve(intent_description, candidates, device=device)
|
||||
|
||||
@@ -80,13 +92,28 @@ class TelepathicEngine:
|
||||
logger.warning(f"No viable nodes found for intent: '{intent_description}'")
|
||||
return None
|
||||
|
||||
# 3.1 BUG 7 Fix: Semantic Guard for 'post media content'
|
||||
intent_lower = intent_description.lower()
|
||||
semantic_str = (
|
||||
(best_node.text or "") + " " + (best_node.content_desc or "") + " " + (best_node.resource_id or "")
|
||||
).lower()
|
||||
if "post media content" in intent_lower:
|
||||
if "follow" in semantic_str.replace("_", " "):
|
||||
logger.warning("🚫 [SpatialEngine] VLM selected a 'Follow' button for 'post media content'. Blocked.")
|
||||
return None
|
||||
|
||||
# 3.5 Following Button Guard
|
||||
if "follow" in intent_description.lower() and "unfollow" not in intent_description.lower():
|
||||
if (
|
||||
"follow" in intent_description.lower()
|
||||
and "unfollow" not in intent_description.lower()
|
||||
and "following" not in intent_description.lower()
|
||||
):
|
||||
semantic = (
|
||||
(best_node.text or "") + " " + (best_node.content_desc or "") + " " + (best_node.resource_id or "")
|
||||
)
|
||||
semantic = semantic.lower()
|
||||
if "following" in semantic or "gefolgt" in semantic or "requested" in semantic or "angefragt" in semantic:
|
||||
# Zero-Maintenance: Only English UI states. resource_id never changes with locale.
|
||||
if "following" in semantic or "requested" in semantic:
|
||||
return {"skip": True, "semantic": "already_followed"}
|
||||
|
||||
# 4. Track action
|
||||
@@ -202,13 +229,16 @@ class TelepathicEngine:
|
||||
semantic = (node.get("semantic_string", "") or "").lower()
|
||||
|
||||
# 1. Post Username Guard
|
||||
if "post username" in intent:
|
||||
if "post username" in intent or "author username" in intent:
|
||||
if "story" in semantic:
|
||||
# E.g. "Your Story" circle at the top
|
||||
return False
|
||||
# Prevent tapping a search list item when looking for a post username
|
||||
if "row search user container" in semantic.replace("_", " "):
|
||||
return False
|
||||
# Prevent tapping bottom tabs
|
||||
if "tab" in semantic and "exclude bottom tabs" in intent:
|
||||
return False
|
||||
return True
|
||||
|
||||
# 3.5 Media Content Guard
|
||||
@@ -216,6 +246,9 @@ class TelepathicEngine:
|
||||
# Prevent tapping a search keyword instead of a media post
|
||||
if "row search keyword title" in semantic.replace("_", " "):
|
||||
return False
|
||||
# Prevent tapping bottom tabs
|
||||
if "tab" in semantic and "exclude bottom tabs" in intent:
|
||||
return False
|
||||
|
||||
# 3.6 Post Author Username Header Guard
|
||||
if "post author username header" in intent:
|
||||
|
||||
@@ -65,6 +65,20 @@ def _run_zero_latency_unfollow_loop(
|
||||
try:
|
||||
xml_dump = device.dump_hierarchy()
|
||||
|
||||
# ── Perimeter Guard: Verify we're still inside Instagram ──
|
||||
if xml_dump:
|
||||
import re
|
||||
|
||||
unfollow_packages = set(re.findall(r'package="([^"]+)"', xml_dump))
|
||||
unfollow_app_id = getattr(device, "app_id", "com.instagram.android")
|
||||
if unfollow_packages and unfollow_app_id not in unfollow_packages:
|
||||
logger.error(
|
||||
f"🚨 [UnfollowLoop] FOREIGN APP DETECTED! Packages: {unfollow_packages}. Aborting loop."
|
||||
)
|
||||
device.press("back")
|
||||
random_sleep(1.0, 1.5)
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
# Autonomously identify user rows via Semantic Extraction
|
||||
telepathic = cognitive_stack.get("telepathic")
|
||||
nodes = []
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
from time import sleep
|
||||
|
||||
@@ -95,6 +97,62 @@ def get_value(count, name, default=0):
|
||||
return default
|
||||
|
||||
|
||||
_LEARNED_AD_MARKERS_FILE = os.path.join(os.getcwd(), "learned_ad_markers.json")
|
||||
_LEARNED_AD_MARKERS_CACHE = None
|
||||
|
||||
def get_learned_ad_markers() -> set:
|
||||
global _LEARNED_AD_MARKERS_CACHE
|
||||
if _LEARNED_AD_MARKERS_CACHE is not None:
|
||||
return _LEARNED_AD_MARKERS_CACHE
|
||||
|
||||
if os.path.exists(_LEARNED_AD_MARKERS_FILE):
|
||||
try:
|
||||
with open(_LEARNED_AD_MARKERS_FILE, "r") as f:
|
||||
_LEARNED_AD_MARKERS_CACHE = set(json.load(f))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load learned ad markers: {e}")
|
||||
_LEARNED_AD_MARKERS_CACHE = set()
|
||||
else:
|
||||
_LEARNED_AD_MARKERS_CACHE = set()
|
||||
|
||||
return _LEARNED_AD_MARKERS_CACHE
|
||||
|
||||
def learn_ad_marker(marker: str, xml_hierarchy: str):
|
||||
global _LEARNED_AD_MARKERS_CACHE
|
||||
if not marker or len(marker) > 30:
|
||||
return
|
||||
|
||||
marker = marker.strip().lower()
|
||||
|
||||
# Structural verification: the VLM-suggested marker MUST exist as an exact node text/desc in the current UI!
|
||||
import xml.etree.ElementTree as ET
|
||||
try:
|
||||
root = ET.fromstring(xml_hierarchy)
|
||||
found_in_ui = False
|
||||
for node in root.iter("node"):
|
||||
text = node.attrib.get("text", "").strip().lower()
|
||||
desc = node.attrib.get("content-desc", "").strip().lower()
|
||||
if text == marker or desc == marker:
|
||||
found_in_ui = True
|
||||
break
|
||||
|
||||
if not found_in_ui:
|
||||
logger.debug(f"🧠 [Autonomous FSD] Rejected hallucinated Ad marker '{marker}' (not found as exact node match in UI).")
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
|
||||
markers = get_learned_ad_markers()
|
||||
if marker not in markers and marker not in {"ad", "sponsored", "advertisement", "gesponsert", "anzeige", "werbung"}:
|
||||
markers.add(marker)
|
||||
logger.info(f"🧠 [Autonomous FSD] Verified and Learned new Ad marker: '{marker}'. Persisting for zero-latency detection.", extra={"color": f"{Style.BRIGHT}{Fore.GREEN}"})
|
||||
try:
|
||||
with open(_LEARNED_AD_MARKERS_FILE, "w") as f:
|
||||
json.dump(list(markers), f)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save learned ad markers: {e}")
|
||||
|
||||
|
||||
def is_ad(xml_hierarchy: str, cognitive_stack: dict = None) -> bool:
|
||||
"""
|
||||
Checks if the current view contains an advertisement using autonomous learning.
|
||||
@@ -125,26 +183,34 @@ def is_ad(xml_hierarchy: str, cognitive_stack: dict = None) -> bool:
|
||||
# Standalone label patterns: match only when the text/desc IS the ad marker,
|
||||
# not when "ad" appears inside longer phrases like "Create messaging ad"
|
||||
AD_EXACT_LABELS = {"ad", "sponsored", "advertisement", "gesponsert", "anzeige", "werbung"}
|
||||
AD_EXACT_LABELS.update(get_learned_ad_markers())
|
||||
|
||||
try:
|
||||
root = ET.fromstring(xml_hierarchy)
|
||||
|
||||
# Check if we are in a feed (to prevent false positives on profiles with 'Ad Tools' buttons)
|
||||
from GramAddict.core.perception.feed_analysis import FEED_MARKERS
|
||||
in_feed = any(marker in xml_hierarchy for marker in FEED_MARKERS)
|
||||
|
||||
for node in root.iter("node"):
|
||||
attrib = node.attrib
|
||||
content_desc = attrib.get("content-desc", "")
|
||||
text = attrib.get("text", "")
|
||||
res_id = attrib.get("resource-id", "")
|
||||
|
||||
# Structural check (Instagram specific)
|
||||
# Structural check (Instagram specific) is always trusted
|
||||
if any(marker_id in res_id for marker_id in AD_RESOURCE_IDS):
|
||||
return True
|
||||
|
||||
# Exact label match: only trigger when the entire text/desc
|
||||
# IS an ad marker (e.g. text="Ad", content-desc="Sponsored")
|
||||
# This prevents false positives from "Create messaging ad"
|
||||
if text.strip().lower() in AD_EXACT_LABELS:
|
||||
return True
|
||||
if content_desc.strip().lower() in AD_EXACT_LABELS:
|
||||
return True
|
||||
# We ONLY trust this if we are actually in a feed, to prevent triggering
|
||||
# on the "Ad Tools" / "Ad" buttons present on business profiles.
|
||||
if in_feed:
|
||||
if text.strip().lower() in AD_EXACT_LABELS:
|
||||
return True
|
||||
if content_desc.strip().lower() in AD_EXACT_LABELS:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ emoji==2.12.1
|
||||
langdetect==1.0.9
|
||||
atomicwrites==1.4.1
|
||||
spintax==1.0.4
|
||||
requests>=2.31.0
|
||||
requests>=2.32.0
|
||||
packaging>=23.0
|
||||
python-dotenv==1.0.1
|
||||
qdrant-client>=1.7.0
|
||||
|
||||
@@ -26,10 +26,26 @@ else
|
||||
elif [ -f "$core_test_file" ]; then
|
||||
TEST_TARGETS="$TEST_TARGETS $core_test_file"
|
||||
else
|
||||
# If no direct unit test, fallback to running all unit tests to be safe
|
||||
echo "⚠️ No direct unit test found for $file, falling back to all unit tests."
|
||||
TEST_TARGETS="tests/unit"
|
||||
break
|
||||
# Try to find matching e2e tests by searching for each word in the module name
|
||||
module_name="${filename%.py}"
|
||||
e2e_matches=""
|
||||
for word in $(echo "$module_name" | tr '_' '\n'); do
|
||||
if [ ${#word} -ge 4 ]; then # Only search meaningful words (4+ chars)
|
||||
found=$(find tests/e2e -name "test_*${word}*.py" 2>/dev/null | head -3)
|
||||
if [ -n "$found" ]; then
|
||||
e2e_matches="$e2e_matches $found"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
e2e_matches=$(echo "$e2e_matches" | xargs -n1 2>/dev/null | sort -u | head -3 | xargs 2>/dev/null)
|
||||
if [ -n "$e2e_matches" ]; then
|
||||
echo "⚠️ No direct unit test for $file, using matching E2E tests: $e2e_matches"
|
||||
TEST_TARGETS="$TEST_TARGETS $e2e_matches"
|
||||
else
|
||||
echo "⚠️ No direct unit test found for $file, falling back to all unit tests."
|
||||
TEST_TARGETS="tests/unit"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
BIN
tests/.DS_Store
vendored
Normal file
BIN
tests/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
tests/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
tests/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/conftest.cpython-311-pytest-8.3.5.pyc
Normal file
BIN
tests/__pycache__/conftest.cpython-311-pytest-8.3.5.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
tests/anomalies/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
tests/anomalies/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
tests/chaos/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
tests/chaos/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
tests/core/__pycache__/test_config.cpython-311-pytest-8.3.5.pyc
Normal file
BIN
tests/core/__pycache__/test_config.cpython-311-pytest-8.3.5.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
56
tests/core/test_action_memory_fast_path.py
Normal file
56
tests/core/test_action_memory_fast_path.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from GramAddict.core.perception.action_memory import ActionMemory
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
|
||||
|
||||
|
||||
def test_screen_identity_detects_other_profile_with_missing_header_container():
|
||||
"""
|
||||
RED: ScreenIdentity used to misclassify OTHER_PROFILE as UNKNOWN or POST_DETAIL
|
||||
if `profile_header_container` was missing, even though `row_profile_header_imageview`
|
||||
was present.
|
||||
GREEN: We added row_profile_header_imageview and profile_tabs_container.
|
||||
"""
|
||||
identity = ScreenIdentity(bot_username="my_bot")
|
||||
|
||||
# Simulate a profile screen that is missing the main container but has the imageview
|
||||
xml_dump = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/row_profile_header_imageview" bounds="[0,0][100,100]" clickable="true"/>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_tabs_container" bounds="[0,200][1080,300]"/>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/action_bar_title" text="justkay"/>
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
result = identity.identify(xml_dump)
|
||||
assert result["screen_type"] == ScreenType.OTHER_PROFILE
|
||||
|
||||
|
||||
def test_action_memory_verifies_profile_navigation_in_o1_without_vlm(monkeypatch):
|
||||
"""
|
||||
RED: ActionMemory.verify_success used to fallback to VLM because navigating to a profile
|
||||
caused a huge delta, but there was no explicit fast-path for 'profile', causing it
|
||||
to hit `confidence < 0.95` and invoke `evaluator._query_vlm`.
|
||||
GREEN: It now structurally verifies `profile_header_container` instantly.
|
||||
"""
|
||||
memory = ActionMemory()
|
||||
|
||||
class DummyDevice:
|
||||
def get_screenshot_b64(self):
|
||||
raise Exception("VLM SHOULD NOT BE CALLED!")
|
||||
|
||||
device = DummyDevice()
|
||||
|
||||
intent = "tap post username"
|
||||
pre_xml = "<hierarchy><node/></hierarchy>"
|
||||
# Post XML contains profile_header_container!
|
||||
post_xml = "<hierarchy><node resource-id='com.instagram.android:id/profile_header_container'/></hierarchy>"
|
||||
|
||||
# If the fast-path works, it will return True instantly and NOT call get_screenshot_b64
|
||||
success = memory.verify_success(
|
||||
intent=intent,
|
||||
pre_click_xml=pre_xml,
|
||||
post_click_xml=post_xml,
|
||||
device=device,
|
||||
confidence=0.0, # low confidence triggers VLM fallback if fast-path is missing
|
||||
)
|
||||
|
||||
assert success is True
|
||||
51
tests/core/test_intent_resolver_multilingual.py
Normal file
51
tests/core/test_intent_resolver_multilingual.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from GramAddict.core.perception.intent_resolver import IntentResolver
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
|
||||
def test_semantic_guard_allows_multilingual_follow_button():
|
||||
"""
|
||||
RED: The IntentResolver's Semantic Guard used to hard-filter for EXACT quotes.
|
||||
If the plugin requested "tap 'Follow' button", but the UI was in German ("Abonnieren"),
|
||||
the Semantic Guard would block it, causing the bot to never follow anyone.
|
||||
GREEN: We added multilingual equivalents to the Semantic Guard logic.
|
||||
"""
|
||||
resolver = IntentResolver()
|
||||
|
||||
intent = "tap 'Follow' button"
|
||||
|
||||
# Create a node that represents a German follow button
|
||||
german_node = SpatialNode(
|
||||
resource_id="com.instagram.android:id/profile_header_follow_button",
|
||||
content_desc="Abonnieren",
|
||||
text="Abonnieren",
|
||||
bounds=(0, 0, 100, 100),
|
||||
)
|
||||
|
||||
# Run the resolver without a device (forces semantic/structural resolution, bypasses VLM)
|
||||
result = resolver.resolve(intent, candidates=[german_node], device=None, screen_height=2000)
|
||||
|
||||
assert result is not None
|
||||
assert result.text == "Abonnieren"
|
||||
|
||||
|
||||
def test_semantic_guard_allows_multilingual_following_button():
|
||||
"""
|
||||
Ensures that "tap 'Following' button" resolves correctly for German UIs
|
||||
("Abonniert", "Gefolgt", "Angefragt").
|
||||
"""
|
||||
resolver = IntentResolver()
|
||||
|
||||
intent = "tap 'Following' button"
|
||||
|
||||
# Create a node that represents a German "Following" button
|
||||
german_node = SpatialNode(
|
||||
resource_id="com.instagram.android:id/profile_header_follow_button",
|
||||
content_desc="Abonniert",
|
||||
text="Abonniert",
|
||||
bounds=(0, 0, 100, 100),
|
||||
)
|
||||
|
||||
result = resolver.resolve(intent, candidates=[german_node], device=None, screen_height=2000)
|
||||
|
||||
assert result is not None
|
||||
assert result.text == "Abonniert"
|
||||
147
tests/core/test_zero_maintenance_strings.py
Normal file
147
tests/core/test_zero_maintenance_strings.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
TDD Tests: Zero-Maintenance String Compliance
|
||||
|
||||
These tests enforce that no hardcoded German/localized strings
|
||||
exist in navigation-critical code paths. The bot must rely
|
||||
exclusively on structural resource_id patterns, never on
|
||||
localized UI text that changes with device language.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 1. TOGGLE_INTENT_MARKERS must be language-agnostic
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestToggleIntentMarkersAreLanguageAgnostic:
|
||||
"""Ensure TOGGLE_INTENT_MARKERS contains zero localized strings."""
|
||||
|
||||
GERMAN_STRINGS = [
|
||||
"gefällt",
|
||||
"gefolgt",
|
||||
"abonnieren",
|
||||
"speichern",
|
||||
"gespeichert",
|
||||
"antworten",
|
||||
"kommentar",
|
||||
"beitrag",
|
||||
]
|
||||
|
||||
def test_no_german_strings_in_toggle_markers(self):
|
||||
from GramAddict.core.perception.action_memory import TOGGLE_INTENT_MARKERS
|
||||
|
||||
for intent_key, markers in TOGGLE_INTENT_MARKERS.items():
|
||||
for marker in markers:
|
||||
assert marker.lower() not in [
|
||||
g.lower() for g in self.GERMAN_STRINGS
|
||||
], f"TOGGLE_INTENT_MARKERS['{intent_key}'] contains German string '{marker}'!"
|
||||
|
||||
def test_markers_only_contain_english_or_resource_id_patterns(self):
|
||||
"""All markers must be English words or resource_id fragments."""
|
||||
from GramAddict.core.perception.action_memory import TOGGLE_INTENT_MARKERS
|
||||
|
||||
allowed_pattern = re.compile(r"^[a-z_]+$")
|
||||
for intent_key, markers in TOGGLE_INTENT_MARKERS.items():
|
||||
for marker in markers:
|
||||
assert allowed_pattern.match(
|
||||
marker
|
||||
), f"Marker '{marker}' in '{intent_key}' contains non-ASCII or non-ID characters!"
|
||||
|
||||
def test_intent_match_rejects_reel_message_composer(self):
|
||||
"""The semantic guard must reject reel message composer for 'like' intent."""
|
||||
from GramAddict.core.perception.action_memory import _intent_matches_node
|
||||
|
||||
# This was the exact production failure: VLM picked the message composer
|
||||
semantic = "text: 'Send message', desc: '', id: 'com.instagram.android:id/reel_viewer_message_composer_text'"
|
||||
assert _intent_matches_node("tap like button", semantic) is False
|
||||
|
||||
def test_intent_match_accepts_real_like_button(self):
|
||||
"""The semantic guard must accept a real like button by resource_id."""
|
||||
from GramAddict.core.perception.action_memory import _intent_matches_node
|
||||
|
||||
semantic = "text: '', desc: 'Like', id: 'com.instagram.android:id/row_feed_button_like'"
|
||||
assert _intent_matches_node("tap like button", semantic) is True
|
||||
|
||||
def test_intent_match_accepts_like_button_by_id_only(self):
|
||||
"""Even without text/desc, resource_id containing 'like' is enough."""
|
||||
from GramAddict.core.perception.action_memory import _intent_matches_node
|
||||
|
||||
semantic = "text: '', desc: '', id: 'com.instagram.android:id/row_feed_button_like'"
|
||||
assert _intent_matches_node("tap like button", semantic) is True
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 2. TelepathicEngine Following Guard must be structural
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestTelepathicEngineFollowingGuardIsStructural:
|
||||
"""The 'already followed' guard must not rely on German strings."""
|
||||
|
||||
def test_no_german_in_following_guard_source(self):
|
||||
"""Scan telepathic_engine.py for any German follow-state strings."""
|
||||
import inspect
|
||||
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
source = inspect.getsource(TelepathicEngine)
|
||||
german_terms = ["gefolgt", "angefragt", "abonniert", "abonnieren"]
|
||||
for term in german_terms:
|
||||
assert (
|
||||
term not in source
|
||||
), f"TelepathicEngine source contains German string '{term}'!"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 3. DarwinEngine comment detection must be structural
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestDarwinEngineCommentDetectionIsStructural:
|
||||
"""The _has_comments heuristic must not rely on German strings."""
|
||||
|
||||
def test_no_german_in_has_comments_source(self):
|
||||
import inspect
|
||||
|
||||
from GramAddict.core.darwin_engine import DarwinEngine
|
||||
|
||||
source = inspect.getsource(DarwinEngine._has_comments)
|
||||
german_terms = ["kommentar", "ansehen"]
|
||||
for term in german_terms:
|
||||
assert (
|
||||
term not in source
|
||||
), f"DarwinEngine._has_comments contains German string '{term}'!"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 4. ResonanceEngine comment filtering must be structural
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestResonanceEngineCommentFilteringIsStructural:
|
||||
"""Comment extraction blocked_exact list must not contain German strings."""
|
||||
|
||||
def test_no_german_in_resonance_source(self):
|
||||
import inspect
|
||||
|
||||
from GramAddict.core.resonance_engine import ResonanceEngine
|
||||
|
||||
source = inspect.getsource(ResonanceEngine.extract_and_learn_comments)
|
||||
german_terms = [
|
||||
"antworten",
|
||||
"gefällt mir",
|
||||
"antworten ansehen",
|
||||
"übersetzung anzeigen",
|
||||
"antworten verbergen",
|
||||
"alle kommentare ansehen",
|
||||
"absenden",
|
||||
"gehe zu",
|
||||
"tippe auf",
|
||||
"aktionen für diesen beitrag",
|
||||
]
|
||||
for term in german_terms:
|
||||
assert (
|
||||
term not in source
|
||||
), f"ResonanceEngine.extract_and_learn_comments contains German '{term}'!"
|
||||
BIN
tests/e2e/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
tests/e2e/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
tests/e2e/__pycache__/conftest.cpython-311-pytest-8.3.5.pyc
Normal file
BIN
tests/e2e/__pycache__/conftest.cpython-311-pytest-8.3.5.pyc
Normal file
Binary file not shown.
BIN
tests/e2e/__pycache__/conftest.cpython-311.pyc
Normal file
BIN
tests/e2e/__pycache__/conftest.cpython-311.pyc
Normal file
Binary file not shown.
BIN
tests/e2e/__pycache__/device_emulator.cpython-311.pyc
Normal file
BIN
tests/e2e/__pycache__/device_emulator.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
tests/e2e/__pycache__/test_debug.cpython-311-pytest-8.3.5.pyc
Normal file
BIN
tests/e2e/__pycache__/test_debug.cpython-311-pytest-8.3.5.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
tests/e2e/__pycache__/test_e2e_behaviors.cpython-311.pyc
Normal file
BIN
tests/e2e/__pycache__/test_e2e_behaviors.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
tests/e2e/__pycache__/test_e2e_goap.cpython-311-pytest-8.3.5.pyc
Normal file
BIN
tests/e2e/__pycache__/test_e2e_goap.cpython-311-pytest-8.3.5.pyc
Normal file
Binary file not shown.
BIN
tests/e2e/__pycache__/test_e2e_goap.cpython-311.pyc
Normal file
BIN
tests/e2e/__pycache__/test_e2e_goap.cpython-311.pyc
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user