update
This commit is contained in:
@@ -5,7 +5,7 @@ from colorama import Fore, Style
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BENCHMARKS_FILE = os.path.join(os.path.dirname(__file__), "llm_benchmarks.json")
|
||||
BENCHMARKS_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "benchmarks", "data", "llm_benchmarks.json")
|
||||
|
||||
def check_model_benchmarks(configs):
|
||||
"""
|
||||
|
||||
@@ -164,7 +164,7 @@ def start_bot(**kwargs):
|
||||
available_targets.append("ReelsFeed")
|
||||
if getattr(configs.args, "stories", None):
|
||||
available_targets.append("StoriesFeed")
|
||||
if getattr(configs.args, "total_unfollows_limit", 0):
|
||||
if getattr(configs.args, "smart_unfollow", False):
|
||||
available_targets.append("FollowingList")
|
||||
if not getattr(configs.args, "disable_ai_messaging", False):
|
||||
available_targets.append("MessageInbox")
|
||||
@@ -207,15 +207,33 @@ def start_bot(**kwargs):
|
||||
result = _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session_state, current_target, cognitive_stack, is_reels=is_reels)
|
||||
|
||||
# Evaluate outcome from loop
|
||||
if result == "BOREDOM_CHANGE_FEED":
|
||||
if result in ("BOREDOM_CHANGE_FEED", "FEED_EXHAUSTED"):
|
||||
if result == "FEED_EXHAUSTED":
|
||||
logger.info(f"✅ Finished watching {current_target}. Removing from this session's options.")
|
||||
if current_target in available_targets:
|
||||
available_targets.remove(current_target)
|
||||
|
||||
# Find new targets excluding the current one
|
||||
available_targets_copy = [t for t in available_targets if t != current_target]
|
||||
current_target = secrets.choice(available_targets_copy) if available_targets_copy else current_target
|
||||
logger.info(f"🧠 [Free Will] Spontaneous desire changed. Switching to {current_target}.")
|
||||
|
||||
if not available_targets_copy:
|
||||
logger.info("🧠 Session natural conclusion: All desired feeds visited and exhausted.")
|
||||
break # No more feeds to visit!
|
||||
|
||||
current_target = secrets.choice(available_targets_copy)
|
||||
|
||||
if result == "BOREDOM_CHANGE_FEED":
|
||||
logger.info(f"🧠 [Free Will] Spontaneous desire changed. Switching to {current_target}. (Restoring Dopamine)")
|
||||
dopamine.boredom = max(0.0, dopamine.boredom * 0.2) # Reset boredom so we actually execute the new feed!
|
||||
else:
|
||||
logger.info(f"🧠 [Free Will] Moving on to {current_target}.")
|
||||
|
||||
elif result == "CONTEXT_LOST":
|
||||
logger.warning(f"⚠️ Context was lost in {current_target}. Forcing re-navigation to recover.")
|
||||
nav_graph.current_state = "UNKNOWN"
|
||||
continue
|
||||
else:
|
||||
logger.info(f"Session concluding due to state: {result}")
|
||||
break # Session over or unhandled state
|
||||
else:
|
||||
logger.error(f"Aborting target {current_target} due to navigation failure.")
|
||||
@@ -462,37 +480,17 @@ def _interact_with_profile(device, configs, username, session_state, sleep_mod,
|
||||
except:
|
||||
count = 1
|
||||
|
||||
telepathic = TelepathicEngine.get_instance()
|
||||
xml_dump = device.deviceV2.dump_hierarchy()
|
||||
story_ring_node = telepathic.find_best_node(xml_dump, "Profile picture avatar story ring", device=device)
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
nav_graph = QNavGraph(device)
|
||||
|
||||
if story_ring_node and not story_ring_node.get("skip"):
|
||||
_humanized_click(device, story_ring_node["x"], story_ring_node["y"], sleep_mod=sleep_mod)
|
||||
sleep(random.uniform(2.5, 4.0) * sleep_mod)
|
||||
|
||||
# ── Post-Click Verification: Did the story viewer actually open? ──
|
||||
post_xml = device.deviceV2.dump_hierarchy()
|
||||
story_indicators = ["reel_viewer", "story_viewer", "reel_pager", "camera_settings", "story_media"]
|
||||
story_opened = any(ind in post_xml for ind in story_indicators)
|
||||
|
||||
if not story_opened:
|
||||
# Fallback: if profile elements (header/bio) disappeared, something opened
|
||||
story_opened = "profile_header" in xml_dump and "profile_header" not in post_xml
|
||||
|
||||
if story_opened:
|
||||
telepathic.confirm_click("Profile picture avatar story ring")
|
||||
logger.info(f"📸 [Story] Viewing @{username}'s story ({count} times)...")
|
||||
for i in range(count):
|
||||
sleep(random.uniform(2.0, 5.0) * sleep_mod)
|
||||
if i < count - 1:
|
||||
_humanized_click(device, int(w * 0.9), int(h * 0.5), sleep_mod=sleep_mod)
|
||||
device.deviceV2.press("back")
|
||||
sleep(random.uniform(1.0, 2.0) * sleep_mod)
|
||||
else:
|
||||
telepathic.reject_click("Profile picture avatar story ring")
|
||||
logger.warning(f"⚠️ [Story] Click did NOT open story viewer for @{username}. Learning from failure.")
|
||||
device.deviceV2.press("back")
|
||||
sleep(random.uniform(0.5, 1.0))
|
||||
if nav_graph._execute_transition("tap_story_tray_item"):
|
||||
logger.info(f"📸 [Story] Viewing @{username}'s story ({count} times)...")
|
||||
for i in range(count):
|
||||
sleep(random.uniform(2.0, 5.0) * sleep_mod)
|
||||
if i < count - 1:
|
||||
_humanized_click(device, int(w * 0.9), int(h * 0.5), sleep_mod=sleep_mod)
|
||||
device.deviceV2.press("back")
|
||||
sleep(random.uniform(1.0, 2.0) * sleep_mod)
|
||||
|
||||
# Random Follow
|
||||
follow_pct = float(getattr(configs.args, "follow_percentage", 0)) / 100.0
|
||||
@@ -501,23 +499,16 @@ def _interact_with_profile(device, configs, username, session_state, sleep_mod,
|
||||
follow_pct = 0.0
|
||||
|
||||
if random.random() < follow_pct:
|
||||
xml_dump = device.deviceV2.dump_hierarchy()
|
||||
telepathic = TelepathicEngine.get_instance()
|
||||
follow_btn = telepathic.find_best_node(xml_dump, "tap follow button on profile", device=device)
|
||||
if follow_btn and not follow_btn.get("skip"):
|
||||
_humanized_click(device, follow_btn["x"], follow_btn["y"], sleep_mod=sleep_mod)
|
||||
sleep(random.uniform(2.0, 4.0) * sleep_mod)
|
||||
|
||||
# ── Post-Click Verification: Did follow state change? ──
|
||||
post_xml = device.deviceV2.dump_hierarchy().lower()
|
||||
# If the button now says "Following", "Abonniert", or "Requested"
|
||||
if any(word in post_xml for word in ["following", "abonniert", "requested"]):
|
||||
telepathic.confirm_click("tap follow button on profile")
|
||||
logger.info(f"🤝 [Deep Interaction] Followed @{username} ✓")
|
||||
session_state.totalFollowed[username] = 1
|
||||
else:
|
||||
telepathic.reject_click("tap follow button on profile")
|
||||
logger.warning(f"⚠️ [Follow] Click did not change follow state for @{username}. Learning from failure.")
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
nav_graph = QNavGraph(device)
|
||||
|
||||
if nav_graph._execute_transition("tap_follow_button"):
|
||||
logger.info(f"🤝 [Deep Interaction] Followed @{username} ✓")
|
||||
session_state.totalFollowed[username] = 1
|
||||
|
||||
# ── TDD Sync Guard: Profile Animations ──
|
||||
# Provide buffer for follow-related animations/menus to close before parsing the grid
|
||||
sleep(random.uniform(1.8, 3.2) * sleep_mod)
|
||||
|
||||
# Grid Likes
|
||||
likes_pct = float(getattr(configs.args, "likes_percentage", 0)) / 100.0
|
||||
@@ -532,42 +523,24 @@ def _interact_with_profile(device, configs, username, session_state, sleep_mod,
|
||||
except:
|
||||
count = 1
|
||||
|
||||
xml_dump = device.deviceV2.dump_hierarchy()
|
||||
telepathic = TelepathicEngine.get_instance()
|
||||
first_post = telepathic.find_best_node(xml_dump, "first image post in profile grid", device=device)
|
||||
if first_post and not first_post.get("skip"):
|
||||
_humanized_click(device, first_post["x"], first_post["y"], sleep_mod=sleep_mod)
|
||||
sleep(random.uniform(2.5, 4.5) * sleep_mod)
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
nav_graph = QNavGraph(device)
|
||||
|
||||
if nav_graph._execute_transition("tap_grid_first_post"):
|
||||
logger.info(f"❤️ [Deep Interaction] Opening grid to drop {count} likes on @{username}...")
|
||||
|
||||
# ── Post-Click Verification: Did a post open? ──
|
||||
post_xml = device.deviceV2.dump_hierarchy()
|
||||
# Post view has like buttons or media groups
|
||||
post_opened = any(ind in post_xml for ind in ["button_like", "button_comment", "media_group"])
|
||||
if not post_opened:
|
||||
# If grid is gone, something opened
|
||||
post_opened = "profile_grid" in xml_dump and "profile_grid" not in post_xml
|
||||
|
||||
if post_opened:
|
||||
telepathic.confirm_click("first image post in profile grid")
|
||||
logger.info(f"❤️ [Deep Interaction] Opening grid to drop {count} likes on @{username}...")
|
||||
|
||||
for i in range(count):
|
||||
_humanized_click(device, w // 2, h // 2, double=True, sleep_mod=sleep_mod)
|
||||
session_state.totalLikes += 1
|
||||
logger.debug(f"Liked grid post {i+1}/{count}")
|
||||
sleep(random.uniform(1.0, 2.0) * sleep_mod)
|
||||
start_scroll_y, end_scroll_y = int(h * 0.7) + random.randint(-20, 20), int(h * 0.2) + random.randint(-40, 40)
|
||||
scroll_x = w // 2 + random.randint(-30, 30)
|
||||
device.deviceV2.shell(f"input swipe {scroll_x} {start_scroll_y} {scroll_x + random.randint(-15,15)} {end_scroll_y} {random.randint(250, 400)}")
|
||||
sleep(random.uniform(1.5, 3.0) * sleep_mod)
|
||||
|
||||
device.deviceV2.press("back")
|
||||
for i in range(count):
|
||||
_humanized_click(device, w // 2, h // 2, double=True, sleep_mod=sleep_mod)
|
||||
session_state.totalLikes += 1
|
||||
logger.debug(f"Liked grid post {i+1}/{count}")
|
||||
sleep(random.uniform(1.0, 2.0) * sleep_mod)
|
||||
else:
|
||||
telepathic.reject_click("first image post in profile grid")
|
||||
logger.warning(f"⚠️ [Grid] Click did not open post for @{username}. Learning from failure.")
|
||||
device.deviceV2.press("back")
|
||||
sleep(random.uniform(0.5, 1.0))
|
||||
start_scroll_y, end_scroll_y = int(h * 0.7) + random.randint(-20, 20), int(h * 0.2) + random.randint(-40, 40)
|
||||
scroll_x = w // 2 + random.randint(-30, 30)
|
||||
device.deviceV2.shell(f"input swipe {scroll_x} {start_scroll_y} {scroll_x + random.randint(-15,15)} {end_scroll_y} {random.randint(250, 400)}")
|
||||
sleep(random.uniform(1.5, 3.0) * sleep_mod)
|
||||
|
||||
device.deviceV2.press("back")
|
||||
sleep(random.uniform(1.0, 2.0) * sleep_mod)
|
||||
# Let the native UI momentum scroll finish just like a human watching the feed
|
||||
sleep(random.uniform(1.2, 2.0))
|
||||
|
||||
@@ -667,11 +640,15 @@ def _detect_ad_structural(context_xml: str) -> bool:
|
||||
AD_RESOURCE_IDS = {
|
||||
"com.instagram.android:id/ad_cta_button",
|
||||
"com.instagram.android:id/clips_single_image_ads_media_content",
|
||||
"com.instagram.android:id/intent_aware_ad_pivot_container"
|
||||
}
|
||||
|
||||
GENERIC_CTA_IDS = {
|
||||
"com.instagram.android:id/clips_browser_cta",
|
||||
"com.instagram.android:id/universal_cta_description_layout",
|
||||
"com.instagram.android:id/universal_cta_text",
|
||||
"com.instagram.android:id/intent_aware_ad_pivot_container"
|
||||
}
|
||||
AD_CTA_WORDS = {"install", "learn more", "shop now", "sign up", "mehr dazu", "jetzt einkaufen", "installieren", "registrieren", "anmelden", "download", "herunterladen", "get offer", "abonnieren", "subscribe"}
|
||||
|
||||
try:
|
||||
root = ET.fromstring(context_xml)
|
||||
@@ -682,6 +659,14 @@ def _detect_ad_structural(context_xml: str) -> bool:
|
||||
if res_id in AD_RESOURCE_IDS:
|
||||
return True
|
||||
|
||||
# 1.5 Generic CTAs require text checking to avoid flagging 'Use template' or 'Original audio'
|
||||
if res_id in GENERIC_CTA_IDS:
|
||||
text = node.attrib.get("text", "").strip().lower()
|
||||
desc = node.attrib.get("content-desc", "").strip().lower()
|
||||
combined = text + " " + desc
|
||||
if any(w in combined for w in AD_CTA_WORDS):
|
||||
return True
|
||||
|
||||
# 2. Secondary Label Exact Match
|
||||
if res_id == "com.instagram.android:id/secondary_label":
|
||||
text = node.attrib.get("text", "").strip().lower()
|
||||
@@ -793,9 +778,7 @@ def _run_zero_latency_stories_loop(device, configs, session_state, cognitive_sta
|
||||
|
||||
logger.info("🎬 [StoriesFeed] Session completed naturally.")
|
||||
device.deviceV2.press("back")
|
||||
return "SESSION_OVER"
|
||||
|
||||
|
||||
return "FEED_EXHAUSTED"
|
||||
def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session_state, job_target, cognitive_stack, is_reels=False):
|
||||
"""
|
||||
The ultra-fast autonomous Free Will loop.
|
||||
@@ -873,28 +856,22 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
sleep(random.uniform(0.1, 0.4) * sleep_mod)
|
||||
continue
|
||||
|
||||
# ── Boredom Engine (Notifications / DMs) ──
|
||||
if random.random() < 0.03:
|
||||
logger.info("🥱 [Boredom] Checking something else (Notifications/DMs) for a second...", extra={"color": f"{Fore.MAGENTA}"})
|
||||
# Activity tab usually has content-desc "Activity" or resource-id "newsfeed_tab"
|
||||
newsfeed_tab = device.deviceV2(resourceId="com.instagram.android:id/newsfeed_tab")
|
||||
direct_tab = device.deviceV2(descriptionContains="Messaging")
|
||||
# Fallbacks:
|
||||
if not direct_tab.exists: direct_tab = device.deviceV2(descriptionContains="Message")
|
||||
|
||||
if random.random() < 0.5 and newsfeed_tab.exists:
|
||||
newsfeed_tab.click()
|
||||
elif direct_tab.exists:
|
||||
direct_tab.click()
|
||||
# ── Boredom ──
|
||||
if random.random() < 0.03 and not is_reels:
|
||||
logger.info("🥱 [Boredom] Checking something else (Notifications/DMs) for a second...")
|
||||
|
||||
# Use NavGraph transitions instead of raw selectors
|
||||
if random.random() < 0.5:
|
||||
# Try to visit Notifications
|
||||
nav_graph._execute_transition("tap_newsfeed_tab", zero_engine)
|
||||
else:
|
||||
# Try to visit DMs
|
||||
nav_graph._execute_transition("tap_message_icon", zero_engine)
|
||||
|
||||
sleep(random.uniform(3.0, 6.0) * sleep_mod)
|
||||
|
||||
# Return to feed natively
|
||||
feed_tab = device.deviceV2(resourceId="com.instagram.android:id/feed_tab")
|
||||
if feed_tab.exists:
|
||||
feed_tab.click()
|
||||
else:
|
||||
device.deviceV2.press("back")
|
||||
# Return to feed natively through robust navigation
|
||||
nav_graph.navigate_to("HomeFeed", zero_engine)
|
||||
sleep(random.uniform(1.0, 2.5) * sleep_mod)
|
||||
|
||||
context_xml = device.deviceV2.dump_hierarchy()
|
||||
@@ -988,11 +965,10 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
consecutive_ads += 1
|
||||
if consecutive_ads >= 3:
|
||||
logger.warning("📺 [Anti-Stuck] Stuck on ad! Executing aggressive mechanical drag.", extra={"color": f"{Fore.RED}"})
|
||||
# Massive slow drag from top to bottom (finger down to up -> screen moves down? No we want to go down in feed so finger moves UP)
|
||||
# h * 0.8 to h * 0.1
|
||||
# Fast drag from bottom to top (~0.15s) to guarantee Native Android Fling event for Reels
|
||||
info = device.get_info()
|
||||
w, h = info.get("displayWidth", 1080), info.get("displayHeight", 2400)
|
||||
device.deviceV2.swipe(w // 2, int(h * 0.8), w // 2, int(h * 0.2), duration=0.8)
|
||||
device.deviceV2.shell(f"input swipe {w // 2} {int(h * 0.8)} {w // 2} {int(h * 0.2)} 150")
|
||||
sleep(2.0)
|
||||
else:
|
||||
logger.info("📺 skipping ad (structural match)...")
|
||||
@@ -1053,7 +1029,7 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
# ── The Rabbit Hole (Deep Dive into high-resonance profiles) ──
|
||||
if res_score >= 0.9 and random.random() < 0.4:
|
||||
logger.info("💥 [Rabbit Hole] Extreme resonance! Sidetracking into user profile...", extra={"color": f"{Fore.MAGENTA}"})
|
||||
if nav_graph._execute_transition("tap_post_username", zero_engine):
|
||||
if nav_graph._execute_transition("tap_post_username", zero_engine) is True:
|
||||
sleep(random.uniform(1.2, 2.5) * sleep_mod)
|
||||
_humanized_scroll(device, is_skip=True)
|
||||
sleep(random.uniform(0.5, 1.5) * sleep_mod)
|
||||
@@ -1091,7 +1067,7 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
logger.info(f"🕵️♂️ [Profile Learning] Highly resonant post ({res_score:.2f}). Visiting @{target_user}'s profile to learn context...", extra={"color": f"{Fore.CYAN}"})
|
||||
|
||||
# Navigate to profile
|
||||
if nav_graph._execute_transition("tap_post_username", zero_engine):
|
||||
if nav_graph._execute_transition("tap_post_username", zero_engine) is True:
|
||||
sleep(random.uniform(1.2, 2.5) * sleep_mod)
|
||||
|
||||
# Extract context
|
||||
@@ -1134,7 +1110,7 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
if res_score >= 0.35 and random.random() < likes_chance:
|
||||
logger.info("❤️ [Interaction] Liking post...")
|
||||
success = nav_graph._execute_transition("tap_like_button", zero_engine)
|
||||
if not success:
|
||||
if success is not True:
|
||||
logger.debug("Telepathic Like failed, falling back to double-tap.")
|
||||
info = device.get_info()
|
||||
w, h = info.get("displayWidth", 1080), info.get("displayHeight", 2400)
|
||||
@@ -1163,11 +1139,18 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
|
||||
logger.info("💬 [Interaction] Entering Comment Sheet for deep engagement...")
|
||||
success = nav_graph._execute_transition("tap_comment_button", zero_engine)
|
||||
if success:
|
||||
if success is True:
|
||||
sleep(random.uniform(2.0, 4.0) * sleep_mod)
|
||||
|
||||
# 1. Scrape Context from the comment sheet
|
||||
sheet_xml = device.deviceV2.dump_hierarchy()
|
||||
|
||||
# 🛡️ [Semantic Gate] Verify we are actually in the comment sheet
|
||||
if not any(x in sheet_xml for x in ["layout_comment_thread", "comment_composer", "comment_button_post"]):
|
||||
logger.warning("❌ [Ambiguity Guard] Transition reported success, but Comment Sheet markers not found in UI. Bailing engagement.")
|
||||
did_interact = False
|
||||
continue
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
existing_comments = []
|
||||
comment_nodes = []
|
||||
@@ -1285,9 +1268,9 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
from GramAddict.core.llm_provider import query_llm
|
||||
from GramAddict.core.stealth_typing import ghost_type
|
||||
|
||||
model = getattr(configs.args, "ai_condenser_model", "google/gemini-2.5-flash-lite-preview")
|
||||
url = getattr(configs.args, "ai_condenser_url", "https://openrouter.ai/api/v1/chat/completions")
|
||||
response_dict = query_llm(url=url, model=model, prompt=prompt, format_json=False)
|
||||
model = getattr(configs.args, "ai_condenser_model", "llama3.2:1b")
|
||||
url = getattr(configs.args, "ai_condenser_url", "http://localhost:11434/api/generate")
|
||||
response_dict = query_llm(url=url, model=model, prompt=prompt, format_json=False, timeout=45)
|
||||
|
||||
if response_dict and "response" in response_dict:
|
||||
clean_comment = response_dict["response"].strip().strip('"').strip("'")
|
||||
@@ -1375,7 +1358,7 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
if res_score >= 0.70 and random.random() < repost_chance:
|
||||
logger.info("🔁 [Interaction] Reposting highly resonant content...", extra={"color": f"{Fore.CYAN}"})
|
||||
success = nav_graph._execute_transition("tap_share_button", zero_engine)
|
||||
if success:
|
||||
if success is True:
|
||||
sleep(random.uniform(1.8, 3.5) * sleep_mod)
|
||||
telepathic = TelepathicEngine.get_instance()
|
||||
xml_dump = device.deviceV2.dump_hierarchy()
|
||||
@@ -1443,7 +1426,7 @@ def _run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session
|
||||
darwin.evaluate_session_end(duration_minutes, followers_gained)
|
||||
|
||||
logger.info("🏁 [Drive] Feed loop terminated. Session over.")
|
||||
return "SESSION_OVER"
|
||||
return "FEED_EXHAUSTED"
|
||||
|
||||
|
||||
def _run_zero_latency_search_loop(device, zero_engine, nav_graph, configs, session_state, current_target, cognitive_stack):
|
||||
|
||||
@@ -22,24 +22,38 @@ class VLMCompilerEngine:
|
||||
logger.warning(f"🧠 [Compiler Engine] Deterministic heuristic failed for: '{intent_description}'. Synthesizing new rule...", extra={"color": "\x1b[1m\x1b[35m"})
|
||||
|
||||
args = getattr(self.device, "args", None)
|
||||
model = getattr(args, "ai_telepathic_model", "google/gemini-3.1-flash-lite-preview") if args else "google/gemini-3.1-flash-lite-preview"
|
||||
url = getattr(args, "ai_telepathic_url", "https://openrouter.ai/api/v1/chat/completions") if args else "https://openrouter.ai/api/v1/chat/completions"
|
||||
model = getattr(args, "ai_telepathic_model", "llama3.2:1b") if args else "llama3.2:1b"
|
||||
url = getattr(args, "ai_telepathic_url", "http://localhost:11434/api/generate") if args else "http://localhost:11434/api/generate"
|
||||
use_local = "11434" in url or "localhost" in url
|
||||
|
||||
simplified_xml = self._simplify_xml(context_xml)
|
||||
|
||||
# --- Model Trust Logging ---
|
||||
from GramAddict.core.benchmark_guard import BENCHMARKS_FILE
|
||||
trust_log = f"Using {model}"
|
||||
try:
|
||||
if os.path.exists(BENCHMARKS_FILE):
|
||||
with open(BENCHMARKS_FILE, "r") as f:
|
||||
bench_data = json.load(f).get("models", {}).get(model, {})
|
||||
if bench_data:
|
||||
score = bench_data.get("telepathic_score", 0)
|
||||
passed = "PASS" if bench_data.get("passed_all", False) else "FAIL"
|
||||
unsuitable = bench_data.get("is_unsuitable", False)
|
||||
trust_level = "HIGH" if score >= 80 and not unsuitable else "MEDIUM" if score >= 50 and not unsuitable else "LOW/UNSAFE"
|
||||
trust_log += f" [Benchmark: {score}/100 | {passed} | Trust: {trust_level}]"
|
||||
if unsuitable:
|
||||
logger.error(f"⛔ [Safety Alert] {model} is marked as UNSUITABLE for this task!")
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"🧠 [Compiler] Intent: '{intent_description}' -> {trust_log}")
|
||||
# ---------------------------
|
||||
|
||||
system_prompt = (
|
||||
"You write Python regex rules to find Android UI elements. "
|
||||
"Given UI XML, find the element matching the intent. "
|
||||
"Generate a regex pattern to match its resource-id.\n\n"
|
||||
"OUTPUT FORMAT (JSON only):\n"
|
||||
"{\"rule_type\": \"regex\", \"target_attribute\": \"resource-id\", "
|
||||
"\"pattern\": \".*your_regex.*\", \"confidence\": 0.95, "
|
||||
"\"reasoning\": \"brief explanation\"}\n\n"
|
||||
"RULES:\n"
|
||||
"- ONLY use rule_type='regex'. NEVER use xpath.\n"
|
||||
"- Target resource-id for dynamic elements, not text or usernames.\n"
|
||||
"- Make patterns globally reusable, not hardcoded to specific content."
|
||||
"You are a regex engineering expert for Android UI. Identify the most stable resource-id regex for the intent.\n"
|
||||
"Rules:\n"
|
||||
"1. Output ONLY a raw JSON object.\n"
|
||||
"2. NO markdown, NO triple backticks.\n"
|
||||
"3. Format: {\"rule_type\": \"regex\", \"target_attribute\": \"resource-id\", \"pattern\": \".*regex.*\", \"confidence\": 0.95, \"reasoning\": \"string\"}"
|
||||
)
|
||||
|
||||
user_prompt = f"TARGET INTENT: {intent_description}\n\nUI XML:\n{simplified_xml[:2000]}"
|
||||
@@ -55,10 +69,32 @@ class VLMCompilerEngine:
|
||||
use_local_edge=use_local
|
||||
)
|
||||
|
||||
if res_text and res_text.startswith("```"):
|
||||
if not res_text:
|
||||
logger.error("Compiler LLM returned empty response.")
|
||||
return None
|
||||
|
||||
if "```json" in res_text:
|
||||
res_text = res_text.split("```json")[1].split("```")[0].strip()
|
||||
elif res_text.startswith("```"):
|
||||
res_text = "\n".join(res_text.strip().split("\n")[1:-1])
|
||||
|
||||
decision = json.loads(res_text) if res_text else {}
|
||||
try:
|
||||
decision = json.loads(res_text)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Compiler LLM returned invalid JSON: {res_text[:100]}...")
|
||||
return None
|
||||
|
||||
# If LLM returned a list, take the first item if it's a dict
|
||||
if isinstance(decision, list):
|
||||
if len(decision) > 0 and isinstance(decision[0], dict):
|
||||
decision = decision[0]
|
||||
else:
|
||||
logger.error(f"Compiler LLM returned unexpected list format: {decision}")
|
||||
return None
|
||||
|
||||
if not isinstance(decision, dict):
|
||||
logger.error(f"Compiler LLM returned non-object response: {type(decision)}")
|
||||
return None
|
||||
|
||||
pattern = decision.get('pattern')
|
||||
if not pattern:
|
||||
|
||||
@@ -157,10 +157,10 @@ class Config:
|
||||
self.parser.add_argument("--speed-multiplier", help="Speed multiplier", default="1.0")
|
||||
|
||||
# AI Model Configuration (centralized — no hardcoded model names anywhere)
|
||||
self.parser.add_argument("--ai-model", "--ai-text-model", help="Primary LLM model (OpenRouter or Ollama)", default="google/gemini-2.5-flash-lite-preview")
|
||||
self.parser.add_argument("--ai-model-url", "--ai-text-url", help="Primary LLM endpoint URL", default="https://openrouter.ai/api/v1/chat/completions")
|
||||
self.parser.add_argument("--ai-telepathic-model", help="Text-based model for Telepathic Engine Fallbacks", default="google/gemini-3.1-flash-lite-preview")
|
||||
self.parser.add_argument("--ai-telepathic-url", help="Telepathic model endpoint URL", default="https://openrouter.ai/api/v1/chat/completions")
|
||||
self.parser.add_argument("--ai-model", "--ai-text-model", help="Primary LLM model (OpenRouter or Ollama)", default="llama3.2:1b")
|
||||
self.parser.add_argument("--ai-model-url", "--ai-text-url", help="Primary LLM endpoint URL", default="http://localhost:11434/api/generate")
|
||||
self.parser.add_argument("--ai-telepathic-model", help="Text-based model for Telepathic Engine Fallbacks", default="llama3.2:1b")
|
||||
self.parser.add_argument("--ai-telepathic-url", help="Telepathic model endpoint URL", default="http://localhost:11434/api/generate")
|
||||
self.parser.add_argument("--ai-fallback-model", "--ai-text-fallback-model", help="Fallback model when primary fails", default="llama3.2:1b")
|
||||
self.parser.add_argument("--ai-fallback-url", "--ai-text-fallback-url", help="Fallback model endpoint URL", default="http://localhost:11434/api/generate")
|
||||
self.parser.add_argument("--ai-embedding-model", help="Embedding model for vector operations", default="nomic-embed-text")
|
||||
@@ -176,8 +176,8 @@ class Config:
|
||||
self.parser.add_argument("--scrape-profiles", action="store_true", help="Extract and store profile metadata in CRM")
|
||||
|
||||
# Phase 10: RAG Comment Learning & Extractor Settings
|
||||
self.parser.add_argument("--ai-condenser-model", help="LLM used for condensing text/comments", default="google/gemini-2.5-flash-lite-preview")
|
||||
self.parser.add_argument("--ai-condenser-url", help="URL for the condenser model", default="https://openrouter.ai/api/v1/chat/completions")
|
||||
self.parser.add_argument("--ai-condenser-model", help="LLM used for condensing text/comments", default="llama3.2:1b")
|
||||
self.parser.add_argument("--ai-condenser-url", help="URL for the condenser model", default="http://localhost:11434/api/generate")
|
||||
self.parser.add_argument("--ai-learn-comments", action="store_true", help="Extract and learn from comment sections")
|
||||
self.parser.add_argument("--ai-learn-niche-posts", action="store_true", help="Learn from niche posts")
|
||||
self.parser.add_argument("--ai-learn-own-profile", action="store_true", help="Learn from your own profile interactions")
|
||||
@@ -185,6 +185,9 @@ class Config:
|
||||
self.parser.add_argument("--ai-vibe", help="The specific vibe to extract from comments (e.g., friendly, controversial)", default="")
|
||||
self.parser.add_argument("--ai-blacklist-topics", help="Comma-separated topics heavily penalized or skipped", default="")
|
||||
self.parser.add_argument("--ai-quality-filter", action="store_true", help="Use AI to strictly filter the quality of posts and comments")
|
||||
self.parser.add_argument("--smart-unfollow", action="store_true", help="Enable agentic decision making for clearing the following list")
|
||||
self.parser.add_argument("--ai-vision-navigation", action="store_true", help="Capture and send base64 UI screenshots to the LLM for structural element finding")
|
||||
self.parser.add_argument("--ai-vision-context", action="store_true", help="Capture and send base64 post/DM screenshots to the LLM for contextual semantic generation")
|
||||
|
||||
# on first run, we must wait to proceed with loading
|
||||
if not self.first_run:
|
||||
|
||||
@@ -111,6 +111,19 @@ class DarwinEngine(QdrantBase):
|
||||
if profile["comment_read_dwell"] > 1.0 and resonance > 0.4 and random.random() < 0.3:
|
||||
if nav_graph and zero_engine:
|
||||
logger.debug(f" -> Opening comments section for {profile['comment_read_dwell']:.1f}s depth simulation")
|
||||
|
||||
# Capture image context of post BEFORE opening comment sheet
|
||||
b64_img_payload = None
|
||||
if configs and getattr(configs.args, "ai_vision_context", False):
|
||||
try:
|
||||
import base64
|
||||
raw = device.screenshot()
|
||||
if raw:
|
||||
b64_img_payload = [base64.b64encode(raw).decode('utf-8')]
|
||||
logger.debug("👁️ [Vision Context] Captured post screenshot for True Vision semantic analysis.")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ [Vision Context] Failed to capture screenshot: {e}")
|
||||
|
||||
success = nav_graph._execute_transition("tap_comment_button", zero_engine)
|
||||
if success:
|
||||
# ---- Phase 10: RAG Comment Extraction ----
|
||||
@@ -121,7 +134,7 @@ class DarwinEngine(QdrantBase):
|
||||
try:
|
||||
xml_data = device.deviceV2.dump_hierarchy()
|
||||
t0 = time.time()
|
||||
resonance_oracle.extract_and_learn_comments(xml_data, configs, author=username or "unknown")
|
||||
resonance_oracle.extract_and_learn_comments(xml_data, configs, author=username or "unknown", images_b64=b64_img_payload)
|
||||
t1 = time.time()
|
||||
remaining_sleep = profile["comment_read_dwell"] - (t1 - t0)
|
||||
if remaining_sleep > 0:
|
||||
|
||||
@@ -148,7 +148,18 @@ class DeviceFacade:
|
||||
|
||||
@adb_retry()
|
||||
def _get_current_app(self):
|
||||
return self.deviceV2.app_current().get("package")
|
||||
"""
|
||||
Hardened app package detection.
|
||||
Transient notifications (e.g. Amazon, WhatsApp) can spoof uiautomator2's app_current() report.
|
||||
We verify the package with a retry if it doesn't match our expected app_id.
|
||||
"""
|
||||
pkg = self.deviceV2.app_current().get("package")
|
||||
if pkg != self.app_id:
|
||||
# Maybe a notification popped up? Wait and re-check.
|
||||
sleep(0.5)
|
||||
pkg = self.deviceV2.app_current().get("package")
|
||||
|
||||
return pkg
|
||||
|
||||
@adb_retry()
|
||||
def find(self, **kwargs):
|
||||
|
||||
@@ -110,4 +110,4 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
|
||||
if failed_attempts > 2:
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
return "SESSION_OVER"
|
||||
return "FEED_EXHAUSTED"
|
||||
|
||||
@@ -84,7 +84,7 @@ def query_llm(
|
||||
images_b64: Optional[List[str]] = None,
|
||||
system: Optional[str] = None,
|
||||
format_json: bool = False,
|
||||
timeout: int = 60,
|
||||
timeout: int = 180,
|
||||
fallback_model: Optional[str] = None,
|
||||
fallback_url: Optional[str] = None
|
||||
) -> Optional[dict]:
|
||||
@@ -191,12 +191,14 @@ def query_llm(
|
||||
|
||||
return {"response": content}
|
||||
else:
|
||||
# Ollama returns response
|
||||
content = resp_json.get("response", "")
|
||||
# Ollama returns response OR thinking (for reasoning models)
|
||||
content = resp_json.get("response") or resp_json.get("thinking") or ""
|
||||
if format_json:
|
||||
extracted = extract_json(content)
|
||||
if not extracted:
|
||||
raise ValueError(f"Ollama returned non-JSON content when JSON was expected: {content[:100]}...")
|
||||
# Log more context if JSON extraction fails
|
||||
logger.debug(f"Ollama raw content (for JSON extraction): {content[:200]}...")
|
||||
raise ValueError(f"Ollama returned non-JSON content when JSON was expected.")
|
||||
resp_json["response"] = extracted
|
||||
|
||||
return resp_json
|
||||
@@ -227,12 +229,17 @@ def query_llm(
|
||||
f_model = f_model or "llama3.2:1b"
|
||||
f_url = f_url or "http://localhost:11434/api/generate"
|
||||
else:
|
||||
f_model = f_model or "google/gemini-2.5-flash-lite-preview"
|
||||
f_url = f_url or "https://openrouter.ai/api/v1/chat/completions"
|
||||
f_model = f_model or "llama3.2:1b"
|
||||
f_url = f_url or "http://localhost:11434/api/generate"
|
||||
|
||||
# Circuit Breaker: If fallback is identical to primary, don't waste time retrying
|
||||
if f_model == model and f_url == url:
|
||||
logger.warning(f"⚠️ [Circuit Breaker] Fallback AI matches Primary AI ({model}). Skipping redundant retry.")
|
||||
return None
|
||||
|
||||
query_llm._is_fallback = True
|
||||
try:
|
||||
logger.warning(f"Primary AI ({model}) failed or returned garbage. Attempting fallback to {f_model}...")
|
||||
logger.warning(f"🔄 Primary AI ({model}) failed. Attempting fallback to {f_model}...")
|
||||
return query_llm(
|
||||
url=f_url,
|
||||
model=f_model,
|
||||
@@ -252,12 +259,14 @@ def query_telepathic_llm(
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
temperature: float = 0.0,
|
||||
use_local_edge: bool = False
|
||||
use_local_edge: bool = False,
|
||||
images_b64: Optional[List[str]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Routes UI Telepathic requests purely based on textual interpretation of the screen's XML nodes.
|
||||
If use_local_edge is manually enabled, routes to localhost:11434.
|
||||
Otherwise honors the provided URL and model (e.g. OpenRouter).
|
||||
Passes optional Base64 screenshots (images_b64) if ai-vision-navigation is enabled.
|
||||
"""
|
||||
target_url = url
|
||||
target_model = model
|
||||
@@ -277,7 +286,7 @@ def query_telepathic_llm(
|
||||
url=target_url,
|
||||
model=target_model,
|
||||
prompt=user_prompt,
|
||||
images_b64=None,
|
||||
images_b64=images_b64,
|
||||
system=system_prompt,
|
||||
format_json=True
|
||||
)
|
||||
|
||||
@@ -109,6 +109,13 @@ class QNavGraph:
|
||||
self.current_state = "HomeFeed"
|
||||
return self.navigate_to(target_state, zero_engine, recovery_attempts=recovery_attempts + 1)
|
||||
else:
|
||||
# NEW: Attempt Back-out recovery if we are in UNKNOWN and direct tap failed
|
||||
if self.current_state == "UNKNOWN":
|
||||
logger.warning(f"📍 [Recovery] Semantic tap failed from UNKNOWN. Attempting to back out of sub-view...")
|
||||
self.device.deviceV2.press("back")
|
||||
time.sleep(2)
|
||||
# We stay in UNKNOWN, but next attempt might see the nav bar
|
||||
return self.navigate_to(target_state, zero_engine, recovery_attempts=recovery_attempts + 0.5)
|
||||
path = None
|
||||
|
||||
if path is None:
|
||||
@@ -168,83 +175,117 @@ class QNavGraph:
|
||||
|
||||
return None
|
||||
|
||||
def _execute_transition(self, action: str, zero_engine) -> bool:
|
||||
def _execute_transition(self, action: str, zero_engine=None, 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 = TelepathicEngine.get_instance()
|
||||
|
||||
context_xml = self.device.deviceV2.dump_hierarchy()
|
||||
|
||||
# ── Z-Depth Guard / Obstacle Clearance ──
|
||||
import re
|
||||
if re.search(r'bottom_sheet_container|dialog_container|dialog_root|bottom_sheet_drag', str(context_xml)):
|
||||
logger.warning("🛡️ [Z-Depth Guard] Obstacle overlay detected during navigation. Pressing BACK to clear...")
|
||||
self.device.deviceV2.press("back")
|
||||
time.sleep(1.5)
|
||||
# Re-acquire context after clearing obstacle
|
||||
for attempt in range(max_retries + 1):
|
||||
context_xml = self.device.deviceV2.dump_hierarchy()
|
||||
|
||||
# We phrase the action as an intent for the semantic engine
|
||||
# e.g. "tap_explore_tab" -> "tap explore tab"
|
||||
# We add some common synonyms for Instagram to help the vector engine
|
||||
intent_map = {
|
||||
# Navigation (Bottom Bar) — aligned with fast-path keys
|
||||
"tap_home_tab": "tap home tab",
|
||||
"tap_explore_tab": "tap explore tab",
|
||||
"tap_profile_tab": "tap profile tab",
|
||||
"tap_reels_tab": "tap reels tab",
|
||||
"tap_create_tab": "tap create post tab",
|
||||
# Post Interaction — aligned with fast-path keys
|
||||
"tap_like_button": "tap like button",
|
||||
"tap_comment_button": "tap comment button",
|
||||
"tap_post_username": "tap post username",
|
||||
"tap_share_button": "tap share button",
|
||||
"tap_save_button": "tap save button",
|
||||
# Grid & Profile
|
||||
"tap_explore_grid_item": "first image in explore grid",
|
||||
"tap_story_tray_item": "profile picture avatar story ring",
|
||||
"tap_follow_button": "tap follow button on profile",
|
||||
"tap_grid_first_post": "first image post in profile grid",
|
||||
}
|
||||
intent_description = intent_map.get(action, action.replace("_", " "))
|
||||
# ── Z-Depth Guard / Obstacle Clearance ──
|
||||
import re
|
||||
if re.search(r'bottom_sheet_container|dialog_container|dialog_root|bottom_sheet_drag', str(context_xml)):
|
||||
logger.warning("🛡️ [Z-Depth Guard] Obstacle overlay detected during navigation. Pressing BACK to clear...")
|
||||
self.device.deviceV2.press("back")
|
||||
time.sleep(1.5)
|
||||
# Re-acquire context after clearing obstacle
|
||||
context_xml = self.device.deviceV2.dump_hierarchy()
|
||||
|
||||
# Use TelepathicEngine to find the most likely node for this intent
|
||||
# If vector score < 0.82, it will trigger the Vision Cortex Fallback (VLM)
|
||||
best_node = engine.find_best_node(context_xml, intent_description, min_confidence=0.82, device=self.device)
|
||||
|
||||
if not best_node:
|
||||
logger.debug(f"_execute_transition: TelepathicEngine found no matching node for '{action}'")
|
||||
# Check if we are even in the right app
|
||||
current_app = self.device._get_current_app()
|
||||
if current_app != self.device.app_id:
|
||||
logger.warning(f"⚠️ [Context Lost] Currently in '{current_app}', expected '{self.device.app_id}'. Transition '{action}' aborted.")
|
||||
return "CONTEXT_LOST"
|
||||
return False
|
||||
|
||||
if best_node.get("skip"):
|
||||
logger.info(f"⏭️ Skipping physical tap for '{action}' (Semantic Fast-Path indicated state already fulfilled)")
|
||||
return True
|
||||
# We phrase the action as an intent for the semantic engine
|
||||
# e.g. "tap_explore_tab" -> "tap explore tab"
|
||||
# We add some common synonyms for Instagram to help the vector engine
|
||||
intent_map = {
|
||||
# Navigation (Bottom Bar) — aligned with fast-path keys
|
||||
"tap_home_tab": "tap home tab",
|
||||
"tap_explore_tab": "tap explore tab",
|
||||
"tap_profile_tab": "tap profile tab",
|
||||
"tap_reels_tab": "tap reels tab",
|
||||
"tap_create_tab": "tap create post tab",
|
||||
# Post Interaction — aligned with fast-path keys
|
||||
"tap_like_button": "tap like button",
|
||||
"tap_comment_button": "tap comment button",
|
||||
"tap_post_username": "tap post username",
|
||||
"tap_share_button": "tap share button",
|
||||
"tap_save_button": "tap save button",
|
||||
# Grid & Profile
|
||||
"tap_explore_grid_item": "first image in explore grid",
|
||||
"tap_story_tray_item": "profile picture avatar story ring",
|
||||
"tap_follow_button": "tap follow button on profile",
|
||||
"tap_grid_first_post": "first image post in profile grid",
|
||||
"tap_back": "tap back button icon arrow",
|
||||
"tap_message_icon": "tap direct message icon inbox",
|
||||
"tap_newsfeed_tab": "tap activity heart icon notifications",
|
||||
}
|
||||
intent_description = intent_map.get(action, action.replace("_", " "))
|
||||
|
||||
source_tag = best_node.get("source", "telepathic").replace("_", " ").title()
|
||||
logger.info(f"QNavGraph executing transition '{action}' via [{source_tag}] (Score: {best_node.get('score', 1.0):.3f})")
|
||||
# Use TelepathicEngine to find the most likely node for this intent
|
||||
# If vector score < 0.82, it will trigger the Vision Cortex Fallback (VLM)
|
||||
best_node = engine.find_best_node(context_xml, intent_description, min_confidence=0.82, device=self.device)
|
||||
if not best_node:
|
||||
logger.debug(f"_execute_transition: TelepathicEngine found no matching node for '{action}'")
|
||||
# Check if we are even in the right app
|
||||
current_app = self.device._get_current_app()
|
||||
if current_app != self.device.app_id:
|
||||
logger.warning(f"⚠️ [Context Lost] Currently in '{current_app}', expected '{self.device.app_id}'. Transition '{action}' aborted.")
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
# Try again if within retries, UI might be animating
|
||||
if attempt < max_retries:
|
||||
time.sleep(1.0)
|
||||
continue
|
||||
return False
|
||||
|
||||
# Execute click
|
||||
self.device.click(obj=best_node)
|
||||
time.sleep(random.uniform(1.2, 2.5))
|
||||
if best_node.get("skip") or (best_node.get("selected") and "tab" in action):
|
||||
logger.info(f"⏭️ Skipping physical tap for '{action}' (Semantic Fast-Path indicated state already fulfilled)")
|
||||
return True
|
||||
|
||||
source_tag = best_node.get("source", "telepathic").replace("_", " ").title()
|
||||
logger.info(f"QNavGraph executing transition '{action}' via [{source_tag}] (Score: {best_node.get('score', 1.0):.3f})")
|
||||
|
||||
# Execute click
|
||||
self.device.click(obj=best_node)
|
||||
time.sleep(random.uniform(1.2, 2.5))
|
||||
|
||||
# ── Post-Click Verification: Did it work? ──
|
||||
post_click_xml = self.device.deviceV2.dump_hierarchy()
|
||||
|
||||
# 1. Semantic Verification (Hardened)
|
||||
is_verified = engine.verify_success(intent_description, post_click_xml)
|
||||
|
||||
# 2. UI Change Verification (Fallback/Navigation)
|
||||
ui_changed = post_click_xml != context_xml
|
||||
|
||||
# ── Post-Click Verification: Did the screen change? ──
|
||||
post_click_xml = self.device.deviceV2.dump_hierarchy()
|
||||
# For navigation, we expect the UI to change or specific markers to appear
|
||||
# Comparison of XML strings is a good baseline for navigation success
|
||||
if post_click_xml != context_xml:
|
||||
engine.confirm_click(intent_description)
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"⚠️ [Nav] Click on '{action}' did not change UI. Learning from failure.")
|
||||
engine.reject_click(intent_description)
|
||||
return False
|
||||
if is_verified and ui_changed:
|
||||
engine.confirm_click(intent_description)
|
||||
return True
|
||||
elif not ui_changed:
|
||||
logger.warning(f"⚠️ [Nav] Click on '{action}' did not change UI. Learning from failure.")
|
||||
engine.reject_click(intent_description)
|
||||
if attempt < max_retries:
|
||||
logger.info(f"🔄 [Autonomy] UI unchanged. Retrying transition '{action}' ({attempt + 1}/{max_retries})...")
|
||||
continue
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
# UI changed but semantic verification failed (accidental click or false positive)
|
||||
logger.warning(f"❌ [Ambiguity Guard] UI changed after '{action}', but semantic verification FAILED. Rejecting mapping.")
|
||||
engine.reject_click(intent_description)
|
||||
|
||||
# Safety: If we're not where we expect to be, try to back out to clear any accidentally opened menus
|
||||
logger.info("🛡️ [Safety Reset] Pressing BACK to clear potential accidental menu/sub-view.")
|
||||
self.device.deviceV2.press("back")
|
||||
time.sleep(1.0)
|
||||
|
||||
if attempt < max_retries:
|
||||
logger.info(f"🔄 [Autonomy] Negative learning acquired. Retrying transition '{action}' ({attempt + 1}/{max_retries})...")
|
||||
continue
|
||||
else:
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
def _repair_transition(self, action: str):
|
||||
"""
|
||||
|
||||
@@ -14,7 +14,12 @@ import uuid
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class QdrantBase:
|
||||
_connection_failed_logged = False
|
||||
def __init__(self, collection_name, vector_size=768):
|
||||
if QdrantBase._connection_failed_logged:
|
||||
self.client = None
|
||||
return
|
||||
|
||||
self.collection_name = collection_name
|
||||
self.client = None
|
||||
self._vector_size = vector_size
|
||||
@@ -52,7 +57,10 @@ class QdrantBase:
|
||||
)
|
||||
logger.info(f"Created Qdrant collection '{collection_name}' with dimension {vector_size}.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Qdrant memory for '{collection_name}': {e}")
|
||||
if not QdrantBase._connection_failed_logged:
|
||||
logger.error(f"Failed to initialize Qdrant memory (likely not running): {e}")
|
||||
QdrantBase._connection_failed_logged = True
|
||||
# No debug logs here to prevent noise in --debug mode
|
||||
self.client = None
|
||||
|
||||
@property
|
||||
@@ -745,6 +753,10 @@ class BannedPathsDB:
|
||||
MAX_AGE_SECONDS = 7 * 24 * 3600
|
||||
|
||||
def __init__(self):
|
||||
if QdrantBase._connection_failed_logged:
|
||||
self._client = None
|
||||
return
|
||||
|
||||
self._banned = {} # {goal_hash: set(element_ids)}
|
||||
self._client = None
|
||||
self._collection = "gramaddict_banned_paths"
|
||||
|
||||
@@ -154,11 +154,12 @@ class ResonanceEngine:
|
||||
logger.info("✨ [Resonance] NEGATIVE ALIGNMENT. Skipping profile.", extra={"color": f"{Fore.MAGENTA}"})
|
||||
return False
|
||||
|
||||
def extract_and_learn_comments(self, xml_hierarchy: str, configs, author: str = "unknown"):
|
||||
def extract_and_learn_comments(self, xml_hierarchy: str, configs, author: str = "unknown", images_b64: list = None):
|
||||
"""
|
||||
Phase 10: RAG Comment Learning Implementation
|
||||
Extracts comments from the UI hierarchy, filters them using the assigned VLM against
|
||||
configured blacklists and vibes, and stores them in Qdrant CommentMemoryDB.
|
||||
Supports True Vision Context (images_b64) to mathematically align comments to visual aesthetics.
|
||||
"""
|
||||
if not configs or not getattr(configs.args, "ai_learn_comments", False):
|
||||
return
|
||||
@@ -199,24 +200,34 @@ class ResonanceEngine:
|
||||
|
||||
# 2. Filter via VLM Condenser
|
||||
prompt = (
|
||||
f"Filter these Instagram comments. Keep ONLY real comments that generally match this vibe: '{vibe}'.\n"
|
||||
f"Remove comments about: {blacklist}\n"
|
||||
f"Remove UI junk text (buttons, labels, timestamps).\n\n"
|
||||
f"Comments:\n{chr(10).join(raw_comments)}\n\n"
|
||||
"Output a JSON array of matching comment strings. If none match, output []."
|
||||
f"Evaluate Instagram comments for SPAM. Your only goal is blocking bad topics.\n"
|
||||
f"VIBE = '{vibe}'\n"
|
||||
f"BLACKLIST = {blacklist}\n\n"
|
||||
f"Comments:\n{chr(10).join(['- ' + c for c in raw_comments])}\n\n"
|
||||
"Return a JSON formatting exactly like this example:\n"
|
||||
"{\n"
|
||||
" \"evaluations\": [\n"
|
||||
" {\"text\": \"love it!\", \"has_blacklist_words\": false, \"keep\": true},\n"
|
||||
" {\"text\": \"dm me for bitcoin\", \"has_blacklist_words\": true, \"keep\": false}\n"
|
||||
" ]\n"
|
||||
"}"
|
||||
)
|
||||
|
||||
model = getattr(configs.args, "ai_condenser_model", "google/gemini-2.5-flash-lite-preview")
|
||||
url = getattr(configs.args, "ai_condenser_url", "https://openrouter.ai/api/v1/chat/completions")
|
||||
model = getattr(configs.args, "ai_condenser_model", "llama3.2:1b")
|
||||
url = getattr(configs.args, "ai_condenser_url", "http://localhost:11434/api/generate")
|
||||
|
||||
try:
|
||||
import json
|
||||
system = "You are a precise JSON filtering agent."
|
||||
# Fix: kwargs match query_llm signature EXACTLY to evade TypeError
|
||||
response_dict = query_llm(url=url, model=model, prompt=prompt, format_json=True)
|
||||
response_dict = query_llm(url=url, model=model, prompt=prompt, system=system, format_json=True, images_b64=images_b64)
|
||||
if not response_dict or "response" not in response_dict:
|
||||
return
|
||||
|
||||
response_text = response_dict["response"]
|
||||
# DEBUG
|
||||
logger.debug(f"DEBUG CONDENSER RAW: {response_text}")
|
||||
print(f"DEBUG CONDENSER RAW: {response_text}")
|
||||
|
||||
# Parse json gracefully
|
||||
if type(response_text) is str:
|
||||
@@ -234,8 +245,19 @@ class ResonanceEngine:
|
||||
# In case expect_json already returned a parsed list somehow, though extract_json returns str
|
||||
learned_comments = response_text
|
||||
|
||||
# Filter the dict based on evaluations array
|
||||
if isinstance(learned_comments, dict):
|
||||
valid_list = []
|
||||
evals = learned_comments.get("evaluations", [])
|
||||
for ev in evals:
|
||||
# Qwen 3.5 correctly identifies 'has_blacklist_words' but hallucinates 'keep': true
|
||||
has_spam = ev.get("has_blacklist_words", False)
|
||||
if not has_spam:
|
||||
valid_list.append(ev.get("text"))
|
||||
learned_comments = valid_list
|
||||
|
||||
if not isinstance(learned_comments, list):
|
||||
logger.error("🧠 [Comment Learning] Condenser failed to return a JSON list.")
|
||||
logger.error(f"🧠 [Comment Learning] Condenser failed to return a valid JSON structure: {learned_comments}")
|
||||
return
|
||||
|
||||
if not learned_comments:
|
||||
|
||||
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
|
||||
# ── Screen Zone Constants (fraction of screen height) ──
|
||||
# Used for positional sanity checking instead of hardcoded resource-IDs.
|
||||
STATUS_BAR_ZONE = 0.04 # Top 4% = Android status bar (wifi, battery, clock)
|
||||
NAV_BAR_ZONE = 0.92 # Bottom 8% = Android nav bar / Instagram bottom tabs
|
||||
NAV_BAR_ZONE = 0.94 # Bottom 6% = Android nav bar / Instagram bottom tabs
|
||||
MAX_BUTTON_AREA = 150000 # Buttons/icons should be smaller than this (px²)
|
||||
MAX_CONTAINER_AREA = 500000 # Anything above this is a full-screen container
|
||||
|
||||
@@ -112,8 +112,11 @@ class TelepathicEngine:
|
||||
# XML Parsing
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _extract_semantic_nodes(self, xml_string: str) -> list[dict]:
|
||||
"""Parses Android UI XML and extracts clickable/interactive nodes."""
|
||||
def _extract_semantic_nodes(self, xml_string: str, intent: str = None, threshold: float = 0.0) -> list[dict]:
|
||||
"""
|
||||
Parses Android UI XML and extracts clickable/interactive nodes.
|
||||
If intent and threshold are provided, it filters nodes by semantic score.
|
||||
"""
|
||||
nodes = []
|
||||
try:
|
||||
clean_xml = re.sub(r'<\?xml.*?\?>', '', xml_string).strip()
|
||||
@@ -160,24 +163,47 @@ class TelepathicEngine:
|
||||
center_y = (top + bottom) // 2
|
||||
width = right - left
|
||||
height = bottom - top
|
||||
area = width * height
|
||||
|
||||
nodes.append({
|
||||
node = {
|
||||
"semantic_string": semantic_string,
|
||||
"x": center_x,
|
||||
"y": center_y,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"area": width * height,
|
||||
"area": area,
|
||||
"raw_bounds": bounds_str,
|
||||
"resource_id": res_id,
|
||||
"class_name": class_name,
|
||||
"selected": attrib.get('selected', 'false').lower() == 'true',
|
||||
"original_attribs": {"text": text, "desc": content_desc}
|
||||
})
|
||||
}
|
||||
|
||||
# Apply structural filter before scoring if intent is provided
|
||||
if intent:
|
||||
# We use a default screen_height if we call extract directly with intent
|
||||
if not self._structural_sanity_check(node, intent, screen_height=2400):
|
||||
continue
|
||||
|
||||
# Compute score for filtering
|
||||
score = self._compute_quick_score(intent, semantic_string)
|
||||
if score < threshold:
|
||||
continue
|
||||
node["score"] = score
|
||||
|
||||
nodes.append(node)
|
||||
except Exception as e:
|
||||
logger.error(f"Telepathic XML parsing failed: {e}")
|
||||
|
||||
return nodes
|
||||
|
||||
def _compute_quick_score(self, intent: str, semantic: str) -> float:
|
||||
"""Helper for legacy _extract_semantic_nodes filtering."""
|
||||
intent_words = set(intent.lower().replace("_", " ").split())
|
||||
semantic_words = set(semantic.lower().replace("_", " ").split())
|
||||
common = intent_words.intersection(semantic_words)
|
||||
return len(common) / len(intent_words) if intent_words else 0.0
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Structural Sanity (app-agnostic, no hardcoded IDs)
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -191,7 +217,15 @@ class TelepathicEngine:
|
||||
"""
|
||||
# 1. Reject massive containers (full-screen views, recycler views)
|
||||
# UNLESS the intent explicitly targets media
|
||||
is_media_intent = any(k in intent_description.lower() for k in ["video", "photo", "reel", "media", "post"])
|
||||
low_intent = intent_description.lower()
|
||||
is_media_intent = any(k in low_intent for k in ["video", "photo", "reel", "media", "post"])
|
||||
is_grid_item_intent = any(k in low_intent for k in ["grid", "list", "first", "item", "row"])
|
||||
|
||||
# If the intent specifically mentions looking for an item within a grid/list,
|
||||
# we must block massive parent containers even if the word 'post', 'photo' or 'video' is present.
|
||||
if is_grid_item_intent:
|
||||
is_media_intent = False
|
||||
|
||||
if node.get("area", 0) > MAX_CONTAINER_AREA and not is_media_intent:
|
||||
return False
|
||||
|
||||
@@ -199,10 +233,59 @@ class TelepathicEngine:
|
||||
if node.get("y", 0) < screen_height * STATUS_BAR_ZONE:
|
||||
return False
|
||||
|
||||
# 3. Reject nodes with zero area (invisible)
|
||||
if node.get("area", 0) == 0:
|
||||
# 3. Reject nodes in the Navigation Bar zone (bottom 6% - adjusted for accuracy)
|
||||
# UNLESS the intent is explicitly about navigation tabs, profile stats, OR popup modals
|
||||
nav_keywords = ["tab", "navigation", "explore tab", "reels tab", "profile tab", "home tab", "message tab", "following", "follower", "followers"]
|
||||
modal_keywords = ["dismiss", "ok", "cancel", "accept", "allow", "deny", "action", "obstacle", "popup"]
|
||||
|
||||
low_intent = intent_description.lower()
|
||||
is_nav_intent = any(k in low_intent for k in nav_keywords)
|
||||
is_modal_intent = any(k in low_intent for k in modal_keywords)
|
||||
|
||||
# Resource-ID bypass for profile header elements that sit low
|
||||
safe_ids = ["following", "follower", "post_count", "button_edit_profile", "button_share_profile"]
|
||||
res_id = node.get("resource_id", "").lower()
|
||||
id_bypass = any(k in res_id for k in safe_ids)
|
||||
|
||||
threshold = screen_height * NAV_BAR_ZONE
|
||||
|
||||
if node.get("y", 0) > threshold and not (is_nav_intent or is_modal_intent or id_bypass):
|
||||
# Not an AI error—this is the deterministic prep-filter culling the NavBar before VLM logic.
|
||||
logger.debug(f"🛡️ [Pre-LLM Pruning] Ignored navbar/overlay element (y={node.get('y')}) to prevent VLM hallucination.")
|
||||
return False
|
||||
|
||||
# 4. Reject own profile/story if the intent is not explicitly looking for it.
|
||||
# Intuitively, "profile picture avatar story ring" means "click a user's story".
|
||||
# If we are looking for a story/profile, we must NOT click our OWN story.
|
||||
is_targeting_own_profile = any(k in low_intent for k in ["own profile", "my profile", "own story"])
|
||||
if not is_targeting_own_profile:
|
||||
semantic_lower = node.get("semantic_string", "").lower()
|
||||
if "your story" in semantic_lower:
|
||||
logger.debug(f"🛡️ [Structural Guard] Rejecting own story overlay for intent '{intent_description}': {node['semantic_string']}")
|
||||
return False
|
||||
|
||||
bot_username = self._get_current_username()
|
||||
if bot_username and bot_username in semantic_lower:
|
||||
# Rejecting bot's own username to prevent clicking itself (e.g. "marisaundmarc's story, 0 of 27")
|
||||
logger.debug(f"🛡️ [Structural Guard] Rejecting own username '{bot_username}' for intent '{intent_description}': {node['semantic_string']}")
|
||||
return False
|
||||
# 5. Language-Agnostic Modal/Menu Guard
|
||||
# Prevent clicks on items inside dialogs, bottom sheets, or context menus
|
||||
# UNLESS the intent explicitly targets a menu or modal interaction.
|
||||
# This completely nullifies the risk of accidentally clicking "Favorites" or "Unfollow" from a dropdown.
|
||||
menu_id_indicators = ["menu_item", "option", "bottom_sheet", "dialog", "action_sheet", "popup"]
|
||||
is_menu_node = any(m in node.get("resource_id", "").lower() for m in menu_id_indicators)
|
||||
|
||||
# If the intent doesn't sound like it wants a menu...
|
||||
wants_menu_intent = any(k in low_intent for k in ["menu", "option", "more", "dismiss", "cancel", "modal"])
|
||||
if is_menu_node and not wants_menu_intent:
|
||||
logger.debug(f"🛡️ [Modal Guard] Rejecting menu/dialog item for non-menu intent '{intent_description}': {node['semantic_string']}")
|
||||
return False
|
||||
|
||||
# 6. Reject nodes with zero area (invisible)
|
||||
if node.get("area", 0) == 0:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _is_blacklisted(self, intent: str, semantic_string: str) -> bool:
|
||||
@@ -214,6 +297,26 @@ class TelepathicEngine:
|
||||
# App Context Guard
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _get_current_username(self) -> str:
|
||||
"""Helper to get the current IG username from config, safely."""
|
||||
username = getattr(self, "_cached_username", None)
|
||||
if not username:
|
||||
try:
|
||||
from GramAddict.core.config import Config
|
||||
cfg = Config()
|
||||
raw_u = cfg.args.username if hasattr(cfg, "args") and hasattr(cfg.args, "username") else None
|
||||
if raw_u and isinstance(raw_u, list) and len(raw_u)>0:
|
||||
username = str(raw_u[0]).lower()
|
||||
elif isinstance(raw_u, str):
|
||||
username = raw_u.lower()
|
||||
else:
|
||||
username = ""
|
||||
except Exception:
|
||||
username = ""
|
||||
self._cached_username = username
|
||||
return username
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _is_instagram_context(self, nodes: list[dict]) -> bool:
|
||||
"""
|
||||
Returns True only if the extracted nodes appear to come from the target app.
|
||||
@@ -249,7 +352,7 @@ class TelepathicEngine:
|
||||
ZERO AI cost — runs entirely on CPU string ops.
|
||||
"""
|
||||
# Extract meaningful keywords from intent (strip common filler words)
|
||||
filler = {"tap", "the", "button", "tab", "on", "in", "a", "an", "of", "for", "to", "and", "or", "input", "text", "box"}
|
||||
filler = {"tap", "the", "button", "on", "in", "a", "an", "of", "for", "to", "and", "or", "input", "text", "box"}
|
||||
intent_words = set(w.lower() for w in re.split(r'\W+', intent_description) if w and w.lower() not in filler and len(w) > 1)
|
||||
|
||||
if not intent_words:
|
||||
@@ -292,8 +395,8 @@ class TelepathicEngine:
|
||||
# Score = ratio of intent keywords matched
|
||||
score = hits / len(intent_words)
|
||||
|
||||
# Require at least 40% keyword overlap to avoid false positives
|
||||
if score >= 0.4:
|
||||
# Require at least 75% keyword overlap to avoid fatal false positives (e.g. 'post username' matching 'Send post')
|
||||
if score >= 0.75:
|
||||
scored.append((node, score))
|
||||
|
||||
if not scored:
|
||||
@@ -352,7 +455,11 @@ class TelepathicEngine:
|
||||
if interactive_nodes:
|
||||
max_y = max(n.get("y", 0) + n.get("height", 0) // 2 for n in interactive_nodes)
|
||||
if max_y > 100:
|
||||
screen_height = int(max_y * 1.05)
|
||||
# If we detect a screen height near standard android heights, don't inflate it.
|
||||
if 2200 < max_y < 2600:
|
||||
screen_height = max_y
|
||||
else:
|
||||
screen_height = int(max_y * 1.02)
|
||||
|
||||
# Pre-filter: Remove structurally implausible nodes and blacklisted mappings
|
||||
viable_nodes = []
|
||||
@@ -403,6 +510,27 @@ class TelepathicEngine:
|
||||
"source": "memory"
|
||||
}
|
||||
|
||||
# ── Stage 1.25: Structural Grid Fast Path ──
|
||||
# Direct resource-ID + spatial sorting for grid navigation intents.
|
||||
# This bypasses keyword/vector/VLM entirely — O(n) deterministic.
|
||||
low_intent = intent_description.lower()
|
||||
is_grid_intent = any(k in low_intent for k in ["explore grid", "grid item", "first image", "profile grid"])
|
||||
if is_grid_intent:
|
||||
grid_nodes = [n for n in viable_nodes if "image_button" in n.get("resource_id", "")]
|
||||
if grid_nodes:
|
||||
# Sort by Y (topmost first), then X (leftmost first) for "first"
|
||||
grid_nodes.sort(key=lambda n: (n["y"], n["x"]))
|
||||
best = grid_nodes[0]
|
||||
logger.info(f"⚡ [Grid Fast-Path] Matched '{intent_description}' → {best['semantic_string']} (y={best['y']})")
|
||||
self._track_click(intent_description, best)
|
||||
return {
|
||||
"x": best["x"],
|
||||
"y": best["y"],
|
||||
"score": 0.98,
|
||||
"semantic": best["semantic_string"],
|
||||
"source": "grid_fastpath"
|
||||
}
|
||||
|
||||
# ── Stage 1.5: Deterministic Keyword Fast Path ──
|
||||
fast_path_result = self._keyword_match_score(intent_description, viable_nodes)
|
||||
if fast_path_result:
|
||||
@@ -529,6 +657,23 @@ class TelepathicEngine:
|
||||
actual_intent = intent or ctx["intent"]
|
||||
sem = ctx["semantic_string"]
|
||||
|
||||
# ── Anti-Poisoning Guard ──
|
||||
# A semantic string that contains NO text and NO description is too generic
|
||||
# to blacklist. Example: "id context: 'image button'" matches ALL buttons
|
||||
# in a grid, so blacklisting it would kill the entire explore page.
|
||||
# NOTE: must use regex word boundary to avoid "context:" matching "text:"
|
||||
has_text = bool(re.search(r'(?<!\w)text:', sem.lower()))
|
||||
has_desc = bool(re.search(r'(?<!\w)description:', sem.lower()))
|
||||
is_generic = not has_text and not has_desc
|
||||
|
||||
if is_generic:
|
||||
logger.warning(
|
||||
f"⚠️ [Anti-Poisoning] Refusing to blacklist generic semantic: '{sem}'. "
|
||||
f"It would poison all similar nodes. Skipping blacklist for '{actual_intent}'."
|
||||
)
|
||||
TelepathicEngine._last_click_context = None
|
||||
return
|
||||
|
||||
# Add to blacklist
|
||||
if actual_intent not in self._blacklist:
|
||||
self._blacklist[actual_intent] = []
|
||||
@@ -545,6 +690,74 @@ class TelepathicEngine:
|
||||
|
||||
TelepathicEngine._last_click_context = None
|
||||
|
||||
def verify_success(self, intent: str, post_click_xml: str) -> bool:
|
||||
"""
|
||||
Hardened verification. Does NOT rely on raw XML changes.
|
||||
Inspects the post-click XML for semantic markers that prove the intent worked.
|
||||
"""
|
||||
ctx = TelepathicEngine._last_click_context
|
||||
if not ctx:
|
||||
return True # No context to verify against
|
||||
|
||||
# 1. Positional Consistency (Atomic Guard)
|
||||
# If the screen changed so much that NO elements are near the original click,
|
||||
# it might be a context-switch (navigation success).
|
||||
|
||||
# 2. Intent-Specific Markers
|
||||
low_intent = intent.lower()
|
||||
low_xml = post_click_xml.lower()
|
||||
|
||||
# 0. Global Hallucination Guards
|
||||
# If we didn't intend to share/send and the share sheet opened, the click was a hallucination.
|
||||
if "share" not in low_intent and "send" not in low_intent and "share_sheet" in low_xml:
|
||||
logger.warning(f"❌ [Semantic Verification] FAILED: Hallucinated click opened the direct share sheet. ({intent})")
|
||||
return False
|
||||
|
||||
if "poll" not in low_intent and "survey" not in low_intent and ("_poll_" in low_xml or "survey_" in low_xml):
|
||||
logger.warning(f"❌ [Semantic Verification] FAILED: Hallucinated click opened a survey/poll. ({intent})")
|
||||
return False
|
||||
|
||||
# Success markers for common actions
|
||||
if "like" in low_intent:
|
||||
# Check for "Liked" or "gefällt mir nicht mehr" in content-desc or text
|
||||
marker_found = re.search(r"\b(liked|gefällt mir nicht mehr|gefällt mir am)\b", low_xml)
|
||||
if marker_found:
|
||||
logger.debug("✅ [Semantic Verification] Success confirmed: 'Liked' state detected.")
|
||||
return True
|
||||
else:
|
||||
logger.warning("❌ [Semantic Verification] FAILED: Post does not report 'Liked' state after click.")
|
||||
return False
|
||||
|
||||
if "follow" in low_intent:
|
||||
# Check if button changed to "Following" or "Requested"
|
||||
marker_found = re.search(r"\b(following|requested|folgst du|angefragt)\b", low_xml)
|
||||
if marker_found:
|
||||
logger.debug("✅ [Semantic Verification] Success confirmed: 'Following/Requested' state detected.")
|
||||
return True
|
||||
else:
|
||||
logger.warning("❌ [Semantic Verification] FAILED: Profile does not report 'Following' state.")
|
||||
return False
|
||||
|
||||
if any(k in low_intent for k in ["explore grid", "profile grid", "first image"]):
|
||||
# Clicking a grid item MUST open a post view.
|
||||
# Posts have feed markers. Reels have clips markers.
|
||||
feed_markers = [
|
||||
"row_feed_button_like", "row_feed_button_comment", "row_feed_button_share",
|
||||
"row_feed_comment_textview_layout", "row_feed_view_group",
|
||||
"clips_media_component", "row_feed_photo_profile_name", "row_feed_photo_imageview"
|
||||
]
|
||||
marker_found = any(m in low_xml for m in feed_markers)
|
||||
if marker_found:
|
||||
logger.debug("✅ [Semantic Verification] Success confirmed: Post opened from grid (Feed markers detected).")
|
||||
return True
|
||||
else:
|
||||
logger.warning("❌ [Semantic Verification] FAILED: Grid tap did not open a valid post view.")
|
||||
return False
|
||||
|
||||
# For general navigation, raw XML change is still the fallback
|
||||
# (covered by the caller in q_nav_graph for now)
|
||||
return True
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Vision Cortex Fallback (VLM)
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -552,15 +765,37 @@ class TelepathicEngine:
|
||||
def _vision_cortex_fallback(self, intent: str, nodes: list[dict], device, screen_height: int = 2400) -> Optional[dict]:
|
||||
"""
|
||||
Uses a Language Model to identify the correct node from parsed screen XML
|
||||
when embeddings are insufficient. 100% Screenshot-free for maximum speed and zero hallucination.
|
||||
when embeddings are insufficient. Opt-in Native Vision Processing via Device Screenshots!
|
||||
|
||||
Guards are STRUCTURAL (size, position, class) not ID-based.
|
||||
Learning happens via the confirm/reject feedback loop, not here.
|
||||
"""
|
||||
try:
|
||||
# Limit to 20 nodes for token efficiency
|
||||
from GramAddict.core.config import Config
|
||||
args = getattr(Config(), "args", None)
|
||||
use_vision = getattr(args, "ai_vision_navigation", False) if args else False
|
||||
images_payload = None
|
||||
|
||||
if use_vision and device is not None:
|
||||
try:
|
||||
logger.debug("👁️ [Vision Inference] Capturing screen for spatial understanding...")
|
||||
img_obj = device.screenshot()
|
||||
if img_obj:
|
||||
if hasattr(img_obj, "save"):
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
img_obj.save(buf, format='JPEG')
|
||||
raw_bytes = buf.getvalue()
|
||||
else:
|
||||
raw_bytes = img_obj
|
||||
b64_str = base64.b64encode(raw_bytes).decode('utf-8')
|
||||
images_payload = [b64_str]
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ [Vision Inference] Failed to capture or encode screenshot: {e}")
|
||||
|
||||
# Expand context to 150 nodes to harness local 8k-32k models natively
|
||||
simplified_nodes = []
|
||||
for i, n in enumerate(nodes[:20]):
|
||||
for i, n in enumerate(nodes[:150]):
|
||||
simplified_nodes.append({
|
||||
"index": i,
|
||||
"bounds": n["raw_bounds"],
|
||||
@@ -568,21 +803,39 @@ class TelepathicEngine:
|
||||
})
|
||||
|
||||
# Get model config
|
||||
from GramAddict.core.config import Config
|
||||
try:
|
||||
args = Config().args
|
||||
except Exception:
|
||||
args = None
|
||||
model = getattr(args, "ai_telepathic_model", "google/gemini-3.1-flash-lite-preview") if args else "google/gemini-3.1-flash-lite-preview"
|
||||
url = getattr(args, "ai_telepathic_url", "https://openrouter.ai/api/v1/chat/completions") if args else "https://openrouter.ai/api/v1/chat/completions"
|
||||
if device and hasattr(device, 'args') and device.args:
|
||||
model = getattr(device.args, "ai_telepathic_model", model)
|
||||
url = getattr(device.args, "ai_telepathic_url", url)
|
||||
args = getattr(device, "args", None)
|
||||
model = getattr(args, "ai_telepathic_model", "llama3.2:1b") if args else "llama3.2:1b"
|
||||
url = getattr(args, "ai_telepathic_url", "http://localhost:11434/api/generate") if args else "http://localhost:11434/api/generate"
|
||||
|
||||
# --- Model Trust Logging ---
|
||||
from GramAddict.core.benchmark_guard import BENCHMARKS_FILE
|
||||
trust_log = f"Using {model}"
|
||||
try:
|
||||
if os.path.exists(BENCHMARKS_FILE):
|
||||
with open(BENCHMARKS_FILE, "r") as f:
|
||||
bench_data = json.load(f).get("models", {}).get(model, {})
|
||||
if bench_data:
|
||||
score = bench_data.get("telepathic_score", 0)
|
||||
passed = "PASS" if bench_data.get("passed_all", False) else "FAIL"
|
||||
unsuitable = bench_data.get("is_unsuitable", False)
|
||||
trust_level = "HIGH" if score >= 80 and not unsuitable else "MEDIUM" if score >= 50 and not unsuitable else "LOW/UNSAFE"
|
||||
trust_log += f" [Benchmark: {score}/100 | {passed} | Trust: {trust_level}]"
|
||||
if unsuitable:
|
||||
logger.error(f"⛔ [Safety Alert] {model} is marked as UNSUITABLE for this task!")
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"🧠 [Telepathic] Intent: '{intent}' -> {trust_log}")
|
||||
# ---------------------------
|
||||
|
||||
system_prompt = (
|
||||
"You identify which UI element to tap based ONLY on a JSON array of parsed Android elements. "
|
||||
"Each element has an 'index', structural 'bounds', and a 'semantic' description. "
|
||||
"Output ONLY valid JSON containing the exact `index` to interact with, and a `reason`. "
|
||||
"You are an Android UI expert. Identify the correct element index to tap based on the provided JSON.\n"
|
||||
"Rules:\n"
|
||||
"1. Output ONLY a raw JSON object.\n"
|
||||
"2. NO markdown formatting, NO triple backticks, NO explanation.\n"
|
||||
"3. Format: {\"index\": number, \"reason\": \"string\"}\n"
|
||||
"4. If no element matches, return {\"index\": -1, \"reason\": \"no match\"}\n"
|
||||
"5. FATAL RULES: NEVER select 'Share', 'Send post', 'Poll', or 'Survey' buttons UNLESS the intent explicitly commands it!\n"
|
||||
"6. ACCOUNT SAFETY: NEVER select buttons that modify account state (Favorite, Mute, Block, Unfollow, Restrict) unless specifically commanded."
|
||||
)
|
||||
|
||||
user_prompt = (
|
||||
@@ -592,12 +845,27 @@ class TelepathicEngine:
|
||||
"- Pick the SMALLEST, most specific button or icon\n"
|
||||
"- NEVER pick large containers, full-screen views, or recycler views\n"
|
||||
"- NEVER pick system icons (wifi, battery, status bar, clock)\n"
|
||||
"- IGNORE BOTTOM NAVIGATION TABS (Home, Search, Reels, Message, Profile) if the intent is to interact with a post or comment.\n"
|
||||
"- A 'Comment input' is usually an EditText or a region near the bottom but ABOVE the navigation bar.\n"
|
||||
"- A 'story tray' or 'story ring' is ALWAYS located at the very TOP of the screen (low Y coordinates).\n"
|
||||
"Return: {\"index\": number, \"reason\": \"...\"}"
|
||||
)
|
||||
|
||||
resp_str = query_telepathic_llm(model, url, system_prompt, user_prompt)
|
||||
resp_str = query_telepathic_llm(model, url, system_prompt, user_prompt, images_b64=images_payload)
|
||||
data = json.loads(resp_str)
|
||||
|
||||
# ── Robustness: Handle list responses from LLM ──
|
||||
if isinstance(data, list):
|
||||
if len(data) > 0 and isinstance(data[0], dict):
|
||||
data = data[0]
|
||||
else:
|
||||
logger.error(f"VLM returned unexpected list format: {data}")
|
||||
return None
|
||||
|
||||
if not isinstance(data, dict):
|
||||
logger.error(f"VLM returned non-dict response: {type(data)}")
|
||||
return None
|
||||
|
||||
idx = data.get("index")
|
||||
if idx is not None and 0 <= idx < len(nodes):
|
||||
match = nodes[idx]
|
||||
@@ -617,12 +885,14 @@ class TelepathicEngine:
|
||||
})
|
||||
return None
|
||||
|
||||
# ── Structural Guard 2: Position (status bar) ──
|
||||
# ── Structural Guard 2: Position (status / nav bar) ──
|
||||
if match.get("y", 0) < screen_height * STATUS_BAR_ZONE:
|
||||
logger.error(
|
||||
f"❌ [Structural Guard] VLM selected element in status bar zone "
|
||||
f"(y={match.get('y')}): {match['semantic_string']}. REJECTING."
|
||||
)
|
||||
logger.error(f"❌ [Structural Guard] VLM selected element in status bar zone: {match['semantic_string']}. REJECTING.")
|
||||
return None
|
||||
|
||||
is_nav_intent = any(k in intent.lower() for k in ["tab", "navigation", "search and explore", "reels", "profile", "home", "message"])
|
||||
if match.get("y", 0) > screen_height * NAV_BAR_ZONE and not is_nav_intent:
|
||||
logger.error(f"❌ [Structural Guard] VLM selected element in nav bar zone for non-nav intent '{intent}': {match['semantic_string']}. REJECTING.")
|
||||
return None
|
||||
|
||||
# ── Structural Guard 3: Already blacklisted ──
|
||||
|
||||
@@ -110,4 +110,4 @@ def _run_zero_latency_unfollow_loop(device, zero_engine, nav_graph, configs, ses
|
||||
if failed_scrolls > 3:
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
return "SESSION_OVER"
|
||||
return "FEED_EXHAUSTED"
|
||||
|
||||
Reference in New Issue
Block a user