Compare commits
3 Commits
fix/autono
...
refactor/p
| Author | SHA1 | Date | |
|---|---|---|---|
| 42eabb7bda | |||
| 144d6401b5 | |||
| 77e8251aa7 |
@@ -1,8 +1,8 @@
|
||||
"""Human-like Instagram bot powered by UIAutomator2"""
|
||||
|
||||
from GramAddict.core.version import __version__, __tested_ig_version__
|
||||
|
||||
from GramAddict.core.bot_flow import start_bot
|
||||
from GramAddict.core.version import __tested_ig_version__, __version__
|
||||
|
||||
|
||||
def run(**kwargs):
|
||||
start_bot(**kwargs)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from GramAddict.core.agentic_views import *
|
||||
import argparse
|
||||
from os import getcwd, path
|
||||
|
||||
from GramAddict import __version__
|
||||
from GramAddict.core.agentic_views import *
|
||||
from GramAddict.core.bot_flow import start_bot
|
||||
from GramAddict.core.download_from_github import download_from_github
|
||||
|
||||
@@ -13,9 +13,7 @@ def cmd_init(args):
|
||||
for username in args.account_name:
|
||||
if not path.exists("./run.py"):
|
||||
print("Creating run.py ...")
|
||||
download_from_github(
|
||||
"https://github.com/GramAddict/bot/blob/master/run.py"
|
||||
)
|
||||
download_from_github("https://github.com/GramAddict/bot/blob/master/run.py")
|
||||
if not path.exists(f"./accounts/{username}"):
|
||||
print(
|
||||
f"Creating 'accounts/{username}' folder with a config starting point inside. You have to edit these files according with https://docs.gramaddict.org/#/configuration"
|
||||
@@ -53,8 +51,10 @@ def cmd_dump(args):
|
||||
os.popen("adb shell pkill atx-agent").close()
|
||||
try:
|
||||
d = u2.connect(args.device)
|
||||
except RuntimeError as err:
|
||||
raise SystemExit(err)
|
||||
except Exception as err:
|
||||
raise SystemExit(
|
||||
f"⚠️ [ADB ConnectError] Could not connect to device: {err}\nPlease check if ADB is running and your device is authorized."
|
||||
)
|
||||
|
||||
def dump_hierarchy(device, path):
|
||||
xml_dump = device.dump_hierarchy()
|
||||
@@ -71,11 +71,7 @@ def cmd_dump(args):
|
||||
dump_hierarchy(d, "dump/cur/hierarchy.xml")
|
||||
archive_name = int(time.time())
|
||||
make_archive(archive_name)
|
||||
print(
|
||||
Fore.GREEN
|
||||
+ Style.BRIGHT
|
||||
+ "\nCurrent screen dump generated successfully! Please, send me this file:"
|
||||
)
|
||||
print(Fore.GREEN + Style.BRIGHT + "\nCurrent screen dump generated successfully! Please, send me this file:")
|
||||
print(Fore.BLUE + Style.BRIGHT + f"{os.getcwd()}\\screen_{archive_name}.zip")
|
||||
|
||||
|
||||
@@ -126,9 +122,7 @@ def main() -> None:
|
||||
prog="GramAddict",
|
||||
description="free human-like Instagram bot",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--version", action="version", version=f"{parser.prog} {__version__}"
|
||||
)
|
||||
parser.add_argument("-v", "--version", action="version", version=f"{parser.prog} {__version__}")
|
||||
subparser = parser.add_subparsers(dest="subparser")
|
||||
actions = {}
|
||||
for c in _commands:
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def verify_and_switch_account(device, nav_graph, target_username):
|
||||
logger.info(f"🛂 [Identity Guard] Verifying if active account matches target: '{target_username}'")
|
||||
|
||||
|
||||
# 1. Navigate to OwnProfile to reliably check identity
|
||||
success = nav_graph.navigate_to("OwnProfile", zero_engine=None)
|
||||
if not success:
|
||||
logger.error("❌ [Identity Guard] Failed to reach OwnProfile to verify account.")
|
||||
return False
|
||||
|
||||
|
||||
time.sleep(2.0)
|
||||
xml_dump = device.dump_hierarchy()
|
||||
|
||||
|
||||
# 2. Check if already active
|
||||
# The action_bar_title on OwnProfile contains the username.
|
||||
is_active = False
|
||||
@@ -31,34 +32,35 @@ def verify_and_switch_account(device, nav_graph, target_username):
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing XML for identity check: {e}")
|
||||
|
||||
|
||||
if is_active:
|
||||
logger.info(f"✅ [Identity Guard] Successfully verified active account is already '{target_username}'.")
|
||||
return True
|
||||
|
||||
|
||||
logger.warning(f"🔄 [Identity Guard] Account mismatch detected! Switching to '{target_username}'...")
|
||||
|
||||
|
||||
# 3. Find the Profile Tab to long press using Telepathic Engine (Blank Start)
|
||||
profile_tab = None
|
||||
try:
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
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)
|
||||
if profile_tab_node:
|
||||
profile_tab = (profile_tab_node["x"], profile_tab_node["y"])
|
||||
except Exception as e:
|
||||
logger.warning(f"Error resolving profile tab via telepathic engine: {e}")
|
||||
|
||||
|
||||
if not profile_tab:
|
||||
logger.error("❌ [Identity Guard] Cannot find profile_tab semantically to initiate account switch!")
|
||||
return False
|
||||
|
||||
|
||||
# Long press to open account selector
|
||||
device.long_click(profile_tab[0], profile_tab[1], 1.5)
|
||||
time.sleep(3.0)
|
||||
|
||||
|
||||
# 4. Find the target account in the selector list
|
||||
xml_dump = device.dump_hierarchy()
|
||||
account_node = None
|
||||
@@ -68,11 +70,11 @@ def verify_and_switch_account(device, nav_graph, target_username):
|
||||
for elem in root.iter("node"):
|
||||
text = elem.attrib.get("text", "").lower()
|
||||
content_desc = elem.attrib.get("content-desc", "").lower()
|
||||
|
||||
|
||||
# Exact match or starts with username followed by spaces/punctuation
|
||||
target_l = target_username.lower()
|
||||
is_match = False
|
||||
|
||||
|
||||
if text == target_l or content_desc == target_l:
|
||||
is_match = True
|
||||
elif target_l in text.split() or target_l in content_desc.split():
|
||||
@@ -82,7 +84,7 @@ def verify_and_switch_account(device, nav_graph, target_username):
|
||||
elif target_l in text or target_l in content_desc:
|
||||
# Fallback purely to literal inclusion (might match backups, but better than failing)
|
||||
is_match = True
|
||||
|
||||
|
||||
if is_match:
|
||||
bounds_str = elem.attrib.get("bounds")
|
||||
if bounds_str:
|
||||
@@ -94,21 +96,25 @@ def verify_and_switch_account(device, nav_graph, target_username):
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if account_node:
|
||||
logger.info(f"🖱️ [Identity Guard] Found account '{target_username}' in selector. Tapping!")
|
||||
device.click(account_node[0], account_node[1])
|
||||
time.sleep(6.0) # Wait heavily for app to reload context
|
||||
nav_graph.current_state = "UNKNOWN" # Force graph to re-evaluate after massive state shift
|
||||
time.sleep(6.0) # Wait heavily for app to reload context
|
||||
nav_graph.current_state = "UNKNOWN" # Force graph to re-evaluate after massive state shift
|
||||
return True
|
||||
else:
|
||||
logger.error(f"❌ [Identity Guard] Target account '{target_username}' not found in the account switcher! Is it logged in?")
|
||||
logger.error(
|
||||
f"❌ [Identity Guard] Target account '{target_username}' not found in the account switcher! Is it logged in?"
|
||||
)
|
||||
try:
|
||||
from GramAddict.core.diagnostic_dump import dump_ui_state
|
||||
dump_ui_state(device, "identity_guard", {"reason": "account_not_found_in_bottom_sheet", "target": target_username})
|
||||
|
||||
dump_ui_state(
|
||||
device, "identity_guard", {"reason": "account_not_found_in_bottom_sheet", "target": target_username}
|
||||
)
|
||||
except:
|
||||
pass
|
||||
# Escape the bottom sheet
|
||||
device.press("back")
|
||||
return False
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ v2 Enhancements:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import math
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
from colorama import Fore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -24,14 +24,15 @@ logger = logging.getLogger(__name__)
|
||||
class ActiveInferenceEngine:
|
||||
"""
|
||||
Bayesian Active Inference Engine.
|
||||
Calculates Free Energy (Surprise) based on prediction errors in the
|
||||
Calculates Free Energy (Surprise) based on prediction errors in the
|
||||
Instagram environment. Steers the agent's 'Thermodynamic Policy'.
|
||||
|
||||
|
||||
Policies:
|
||||
- STABLE: Free energy < 0.75. Normal operation. All interactions enabled.
|
||||
- CAUTIOUS: Free energy 0.75-1.2. Reduced interaction probability. Longer waits.
|
||||
- DORMANT: Free energy > 1.2. Minimal interactions. Maximum sleep. May recommend abort.
|
||||
"""
|
||||
|
||||
def __init__(self, username):
|
||||
self.username = username
|
||||
self.free_energy = 0.0
|
||||
@@ -39,30 +40,30 @@ class ActiveInferenceEngine:
|
||||
self.last_update = time.time()
|
||||
self.policy = "STABLE" # STABLE, CAUTIOUS, DORMANT
|
||||
self.expectation_history = []
|
||||
|
||||
|
||||
# v2: Consecutive error tracking for escalation
|
||||
self._consecutive_prediction_errors = 0
|
||||
self._total_predictions = 0
|
||||
self._total_errors = 0
|
||||
self._session_start = time.time()
|
||||
|
||||
|
||||
def calculate_surprise(self, predicted_outcome: float, observed_outcome: float):
|
||||
"""
|
||||
Bayesian surprise calculation (simplified Kullback-Leibler divergence).
|
||||
"""
|
||||
# prediction error
|
||||
error = abs(predicted_outcome - observed_outcome)
|
||||
|
||||
|
||||
# Free energy accumulation
|
||||
self.free_energy = (self.free_energy * 0.7) + (error * 0.3)
|
||||
|
||||
|
||||
# Decay free energy over time (Thermodynamic relaxation)
|
||||
now = time.time()
|
||||
hours_passed = (now - self.last_update) / 3600.0
|
||||
decay = math.exp(-0.1 * hours_passed)
|
||||
self.free_energy *= decay
|
||||
self.last_update = now
|
||||
|
||||
|
||||
# Policy steering
|
||||
if self.free_energy > 1.2:
|
||||
self.policy = "DORMANT"
|
||||
@@ -70,8 +71,11 @@ class ActiveInferenceEngine:
|
||||
self.policy = "CAUTIOUS"
|
||||
else:
|
||||
self.policy = "STABLE"
|
||||
|
||||
logger.info(f"⚖️ [Active Inference] Surprise: {self.free_energy:.4f} | Policy: {self.policy}", extra={"color": f"{Fore.BLUE}"})
|
||||
|
||||
logger.info(
|
||||
f"⚖️ [Active Inference] Surprise: {self.free_energy:.4f} | Policy: {self.policy}",
|
||||
extra={"color": f"{Fore.BLUE}"},
|
||||
)
|
||||
return self.free_energy
|
||||
|
||||
def predict_state(self, expected_signature: list):
|
||||
@@ -80,22 +84,24 @@ class ActiveInferenceEngine:
|
||||
expected_signature: list of terms expected in the resulting XML.
|
||||
"""
|
||||
self.expectation_history.append(expected_signature)
|
||||
logger.debug(f"⚖️ [Shadow Mode] Predicting future state containing: {expected_signature}", extra={"color": f"{Fore.BLUE}"})
|
||||
logger.debug(
|
||||
f"⚖️ [Shadow Mode] Predicting future state containing: {expected_signature}", extra={"color": f"{Fore.BLUE}"}
|
||||
)
|
||||
|
||||
def evaluate_prediction(self, context_xml: str) -> bool:
|
||||
"""
|
||||
Evaluates the last prediction against reality.
|
||||
Returns True if reality matches prediction, False otherwise (Prediction Error).
|
||||
|
||||
|
||||
v2: Tracks consecutive errors and escalates policy automatically.
|
||||
"""
|
||||
if not self.expectation_history:
|
||||
return True
|
||||
|
||||
|
||||
expected_signature = self.expectation_history.pop()
|
||||
self._total_predictions += 1
|
||||
matched = any(sig.lower() in context_xml.lower() for sig in expected_signature)
|
||||
|
||||
|
||||
if matched:
|
||||
self._consecutive_prediction_errors = 0
|
||||
self.calculate_surprise(1.0, 1.0)
|
||||
@@ -103,42 +109,46 @@ class ActiveInferenceEngine:
|
||||
else:
|
||||
self._consecutive_prediction_errors += 1
|
||||
self._total_errors += 1
|
||||
logger.warning(f"⚖️ [Shadow Mode] Prediction Error #{self._consecutive_prediction_errors}! "
|
||||
f"Did not find {expected_signature} in resulting UI.", extra={"color": f"{Fore.RED}"})
|
||||
logger.warning(
|
||||
f"⚖️ [Shadow Mode] Prediction Error #{self._consecutive_prediction_errors}! "
|
||||
f"Did not find {expected_signature} in resulting UI.",
|
||||
extra={"color": f"{Fore.RED}"},
|
||||
)
|
||||
self.calculate_surprise(1.0, 0.0)
|
||||
|
||||
|
||||
# v2: Consecutive error escalation
|
||||
if self._consecutive_prediction_errors >= 5:
|
||||
self.policy = "DORMANT"
|
||||
logger.error(
|
||||
f"🚨 [Active Inference] {self._consecutive_prediction_errors} consecutive prediction errors! "
|
||||
f"Environment is fundamentally unstable. DORMANT mode engaged.",
|
||||
extra={"color": f"{Fore.RED}"}
|
||||
extra={"color": f"{Fore.RED}"},
|
||||
)
|
||||
elif self._consecutive_prediction_errors >= 3:
|
||||
self.policy = "CAUTIOUS"
|
||||
logger.warning(
|
||||
f"⚠️ [Active Inference] {self._consecutive_prediction_errors} consecutive errors. "
|
||||
f"Switching to CAUTIOUS policy.",
|
||||
extra={"color": f"{Fore.YELLOW}"}
|
||||
extra={"color": f"{Fore.YELLOW}"},
|
||||
)
|
||||
|
||||
|
||||
# ── Dojo Data Engine Hook ──
|
||||
# When prediction fails, explicitly submit the snapshot for shadow-compilation
|
||||
try:
|
||||
from GramAddict.core.dojo_engine import DojoEngine
|
||||
|
||||
# Note: get_instance() works without passing device as it was already initialized in bot_flow by this point.
|
||||
dojo = DojoEngine.get_instance()
|
||||
dojo.submit_snapshot(
|
||||
heuristic_name=str(expected_signature),
|
||||
context_xml=context_xml,
|
||||
intent_prompt=f"Locate the missing elements or correct the heuristic predicting state: {expected_signature}"
|
||||
intent_prompt=f"Locate the missing elements or correct the heuristic predicting state: {expected_signature}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to offload snapshot to Dojo Engine: {e}")
|
||||
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_sleep_modifier(self):
|
||||
"""
|
||||
Returns a multiplier for sleep durations based on surprise.
|
||||
@@ -156,11 +166,11 @@ class ActiveInferenceEngine:
|
||||
def get_interaction_probability(self) -> float:
|
||||
"""
|
||||
Returns a probability multiplier [0.0 - 1.0] for interaction decisions.
|
||||
|
||||
|
||||
Under STABLE: 1.0 (full interaction rate)
|
||||
Under CAUTIOUS: 0.5 (halved interaction rate)
|
||||
Under DORMANT: 0.1 (minimal interaction — only high-confidence targets)
|
||||
|
||||
|
||||
This directly modifies follow/like/comment probability in the feed loop.
|
||||
"""
|
||||
if self.policy == "DORMANT":
|
||||
@@ -172,11 +182,11 @@ class ActiveInferenceEngine:
|
||||
def should_abort_session(self) -> bool:
|
||||
"""
|
||||
Recommends session abort when the environment is fundamentally broken.
|
||||
|
||||
|
||||
Triggers:
|
||||
- 5+ consecutive prediction errors (UI is completely unexpected)
|
||||
- Free energy > 2.0 (accumulated instability beyond recovery)
|
||||
|
||||
|
||||
The caller (bot_flow) can choose to honor this or override.
|
||||
"""
|
||||
if self._consecutive_prediction_errors >= 5:
|
||||
|
||||
@@ -18,7 +18,7 @@ Tesla analogy: Instead of one "drive" function, there are composable behaviors
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,15 +29,17 @@ class BehaviorContext:
|
||||
Shared context passed to every behavior plugin.
|
||||
Contains everything a behavior needs to make decisions and act.
|
||||
"""
|
||||
device: Any # Android device facade
|
||||
configs: Any # User configuration
|
||||
session_state: Any # Current session state
|
||||
cognitive_stack: Dict[str, Any] # Cognitive engines (growth, resonance, etc.)
|
||||
context_xml: str = "" # Current screen XML dump
|
||||
sleep_mod: float = 1.0 # Active Inference sleep multiplier
|
||||
post_data: Optional[Dict] = None # Extracted post content
|
||||
username: str = "" # Current target username (if applicable)
|
||||
|
||||
|
||||
device: Any # Android device facade
|
||||
configs: Any # User configuration
|
||||
session_state: Any # Current session state
|
||||
cognitive_stack: Dict[str, Any] # Cognitive engines (growth, resonance, etc.)
|
||||
shared_state: Dict[str, Any] = field(default_factory=dict) # State shared between plugins
|
||||
context_xml: str = "" # Current screen XML dump
|
||||
sleep_mod: float = 1.0 # Active Inference sleep multiplier
|
||||
post_data: Optional[Dict] = None # Extracted post content
|
||||
username: str = "" # Current target username (if applicable)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BehaviorResult:
|
||||
@@ -45,39 +47,40 @@ class BehaviorResult:
|
||||
Result returned by a behavior plugin after execution.
|
||||
Used by the orchestrator to decide what happens next.
|
||||
"""
|
||||
executed: bool = False # Did the behavior actually do something?
|
||||
should_continue: bool = True # Should the feed loop continue to next post?
|
||||
should_skip: bool = False # Should we skip to the next post immediately?
|
||||
interactions: int = 0 # Number of interactions performed
|
||||
|
||||
executed: bool = False # Did the behavior actually do something?
|
||||
should_continue: bool = True # Should the feed loop continue to next post?
|
||||
should_skip: bool = False # Should we skip to the next post immediately?
|
||||
interactions: int = 0 # Number of interactions performed
|
||||
metadata: Dict[str, Any] = field(default_factory=dict) # Plugin-specific data
|
||||
|
||||
|
||||
class BehaviorPlugin(ABC):
|
||||
"""
|
||||
Base class for all behavior plugins.
|
||||
|
||||
|
||||
Lifecycle:
|
||||
1. `can_activate(ctx)` — Should this behavior fire for this context?
|
||||
2. `priority` — If multiple behaviors can activate, higher priority goes first.
|
||||
3. `execute(ctx)` — Run the behavior.
|
||||
|
||||
|
||||
Rules:
|
||||
- Plugins must be stateless between posts (state lives in session_state)
|
||||
- Plugins must handle their own errors (never crash the feed loop)
|
||||
- Plugins must respect session limits via ctx.session_state
|
||||
"""
|
||||
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Unique identifier for this behavior."""
|
||||
...
|
||||
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
"""
|
||||
Execution priority. Higher = runs first.
|
||||
|
||||
|
||||
Guidelines:
|
||||
- 100+: Safety/guard behaviors (ad detection, block detection)
|
||||
- 50-99: Primary interactions (like, follow, comment)
|
||||
@@ -85,7 +88,7 @@ class BehaviorPlugin(ABC):
|
||||
- 1-9: Observational behaviors (scraping, analytics)
|
||||
"""
|
||||
return 50
|
||||
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
"""
|
||||
@@ -93,7 +96,7 @@ class BehaviorPlugin(ABC):
|
||||
Used for guard behaviors that abort interaction (e.g., ad detection).
|
||||
"""
|
||||
return False
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
"""
|
||||
@@ -101,7 +104,7 @@ class BehaviorPlugin(ABC):
|
||||
Must be cheap to evaluate (no device interactions).
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
"""
|
||||
@@ -109,7 +112,11 @@ class BehaviorPlugin(ABC):
|
||||
Returns a BehaviorResult describing what happened.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def get_config(self, ctx: BehaviorContext) -> dict:
|
||||
"""Helper to retrieve plugin-specific configuration."""
|
||||
return ctx.configs.get_plugin_config(self.name)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.__class__.__name__} name={self.name} priority={self.priority}>"
|
||||
|
||||
@@ -117,27 +124,28 @@ class BehaviorPlugin(ABC):
|
||||
class PluginRegistry:
|
||||
"""
|
||||
Central registry for behavior plugins.
|
||||
|
||||
|
||||
Manages plugin registration, priority sorting, and orchestrated execution.
|
||||
Thread-safe singleton.
|
||||
"""
|
||||
|
||||
|
||||
_instance = None
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> "PluginRegistry":
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
|
||||
@classmethod
|
||||
def reset(cls):
|
||||
"""Wipe the registry singleton instance."""
|
||||
cls._instance = None
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self._plugins: List[BehaviorPlugin] = []
|
||||
self._sorted = False
|
||||
|
||||
|
||||
def register(self, plugin: BehaviorPlugin):
|
||||
"""Register a behavior plugin."""
|
||||
# Prevent duplicate registration
|
||||
@@ -145,28 +153,28 @@ class PluginRegistry:
|
||||
if existing.name == plugin.name:
|
||||
logger.debug(f"Plugin '{plugin.name}' already registered. Skipping.")
|
||||
return
|
||||
|
||||
|
||||
self._plugins.append(plugin)
|
||||
self._sorted = False
|
||||
logger.info(f"🧩 [Plugin] Registered: {plugin.name} (priority={plugin.priority})")
|
||||
|
||||
|
||||
def unregister(self, name: str):
|
||||
"""Remove a plugin by name."""
|
||||
self._plugins = [p for p in self._plugins if p.name != name]
|
||||
self._sorted = False
|
||||
|
||||
|
||||
def _ensure_sorted(self):
|
||||
"""Sort plugins by priority (highest first)."""
|
||||
if not self._sorted:
|
||||
self._plugins.sort(key=lambda p: p.priority, reverse=True)
|
||||
self._sorted = True
|
||||
|
||||
|
||||
@property
|
||||
def plugins(self) -> List[BehaviorPlugin]:
|
||||
"""Returns all plugins, sorted by priority."""
|
||||
self._ensure_sorted()
|
||||
return list(self._plugins)
|
||||
|
||||
|
||||
def get_active_plugins(self, ctx: BehaviorContext) -> List[BehaviorPlugin]:
|
||||
"""Returns plugins that can activate for the given context, sorted by priority."""
|
||||
self._ensure_sorted()
|
||||
@@ -178,38 +186,66 @@ class PluginRegistry:
|
||||
except Exception as e:
|
||||
logger.error(f"🧩 [Plugin] Error checking {plugin.name}.can_activate: {e}")
|
||||
return active
|
||||
|
||||
|
||||
def execute_all(self, ctx: BehaviorContext) -> List[BehaviorResult]:
|
||||
"""
|
||||
Execute all active plugins in priority order.
|
||||
|
||||
|
||||
Stops early if an exclusive plugin fires (e.g., ad guard).
|
||||
Returns list of results from all executed plugins.
|
||||
"""
|
||||
self._ensure_sorted()
|
||||
results = []
|
||||
|
||||
|
||||
for plugin in self._plugins:
|
||||
try:
|
||||
if not plugin.can_activate(ctx):
|
||||
continue
|
||||
|
||||
logger.debug(f"🧩 [Plugin] Executing: {plugin.name}")
|
||||
|
||||
logger.debug(f"🧩 [PluginRegistry] TRACE: Calling execute() on {plugin.name}")
|
||||
result = plugin.execute(ctx)
|
||||
results.append(result)
|
||||
|
||||
if plugin.exclusive and result.executed:
|
||||
logger.debug(f"🧩 [Plugin] {plugin.name} is exclusive. Stopping chain.")
|
||||
|
||||
if (plugin.exclusive and result.executed) or result.should_skip:
|
||||
logger.debug(
|
||||
f"🧩 [Plugin] {plugin.name} triggered chain termination (exclusive={plugin.exclusive}, should_skip={result.should_skip})."
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"🧩 [Plugin] Error executing {plugin.name}: {e}")
|
||||
results.append(BehaviorResult(executed=False, metadata={"error": str(e)}))
|
||||
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def __len__(self):
|
||||
return len(self._plugins)
|
||||
|
||||
|
||||
def __contains__(self, name: str):
|
||||
return any(p.name == name for p in self._plugins)
|
||||
|
||||
|
||||
# Import plugins at the bottom to avoid circular imports
|
||||
from GramAddict.core.behaviors.ad_guard import AdGuardPlugin as AdGuardPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.anomaly_handler import AnomalyHandlerPlugin as AnomalyHandlerPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.close_friends_guard import (
|
||||
CloseFriendsGuardPlugin as CloseFriendsGuardPlugin, # noqa: E402
|
||||
)
|
||||
from GramAddict.core.behaviors.comment import CommentPlugin as CommentPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.darwin_dwell import DarwinDwellPlugin as DarwinDwellPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.like import LikePlugin as LikePlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.obstacle_guard import ObstacleGuardPlugin as ObstacleGuardPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.perfect_snapping import PerfectSnappingPlugin as PerfectSnappingPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.post_data_extraction import (
|
||||
PostDataExtractionPlugin as PostDataExtractionPlugin, # noqa: E402
|
||||
)
|
||||
from GramAddict.core.behaviors.post_interaction import PostInteractionPlugin as PostInteractionPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.profile_visit import ProfileVisitPlugin as ProfileVisitPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.rabbit_hole import RabbitHolePlugin as RabbitHolePlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.repost import RepostPlugin as RepostPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.resonance_evaluator import (
|
||||
ResonanceEvaluatorPlugin as ResonanceEvaluatorPlugin, # noqa: E402
|
||||
)
|
||||
|
||||
# Note: We do not automatically instantiate all of them globally here to avoid circular
|
||||
# dependencies during initial load. The bot_flow.py engine should explicitly register them.
|
||||
|
||||
74
GramAddict/core/behaviors/ad_guard.py
Normal file
74
GramAddict/core/behaviors/ad_guard.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import logging
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.physics.humanized_input import humanized_scroll
|
||||
from GramAddict.core.utils import is_ad
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdGuardPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Checks for ads in the feed and scrolls past them.
|
||||
Implements a deadlock escape after 5 consecutive ads.
|
||||
|
||||
Priority: 100 (Safety guard, runs first).
|
||||
Exclusive: True (if ad detected, stop other interactions).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
self.consecutive_ads = 0
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "ad_guard"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 100
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
return True
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
if not getattr(self, "_enabled", True):
|
||||
return False
|
||||
|
||||
# We check for ad presence here to decide if we activate.
|
||||
# This is a bit more expensive than a percentage check but necessary for a guard.
|
||||
# Optimization: Only check if context_xml is available or do a quick string search.
|
||||
if ctx.context_xml:
|
||||
return is_ad(ctx.context_xml, ctx.cognitive_stack)
|
||||
|
||||
return False
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
self.consecutive_ads += 1
|
||||
|
||||
if self.consecutive_ads >= 5:
|
||||
logger.warning("🛡️ [AdGuard] Deadlock detected: 5 consecutive ads. Escaping to HomeFeed.")
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
zero_engine = ctx.cognitive_stack.get("zero_latency_engine")
|
||||
if nav_graph:
|
||||
nav_graph.navigate_to("HomeFeed", zero_engine)
|
||||
self.consecutive_ads = 0
|
||||
return BehaviorResult(executed=True, should_skip=True)
|
||||
|
||||
logger.info(f"🛡️ [AdGuard] Ad detected ({self.consecutive_ads}). Scrolling past it...")
|
||||
humanized_scroll(ctx.device, is_skip=True)
|
||||
sleep(1.0 * ctx.sleep_mod)
|
||||
|
||||
# Aggressive double skip for triple ad
|
||||
if self.consecutive_ads >= 3:
|
||||
logger.info("🛡️ [AdGuard] Aggressive skip for consecutive ads.")
|
||||
humanized_scroll(ctx.device, is_skip=True)
|
||||
sleep(1.0 * ctx.sleep_mod)
|
||||
|
||||
return BehaviorResult(executed=True, should_skip=True)
|
||||
|
||||
def reset_counter(self):
|
||||
self.consecutive_ads = 0
|
||||
48
GramAddict/core/behaviors/anomaly_handler.py
Normal file
48
GramAddict/core/behaviors/anomaly_handler.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import logging
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.physics.humanized_input import humanized_scroll
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnomalyHandlerPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Handles anomalies like zero interactive nodes on screen.
|
||||
|
||||
Priority: 98 (Runs after AdGuard, before others).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "anomaly_handler"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 98
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
return getattr(self, "_enabled", True)
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
telepathic = TelepathicEngine.get_instance()
|
||||
xml = ctx.context_xml if ctx.context_xml else ctx.device.dump_hierarchy()
|
||||
nodes = telepathic._extract_semantic_nodes(xml)
|
||||
|
||||
ctx.shared_state["interactive_nodes"] = nodes
|
||||
|
||||
if len(nodes) == 0:
|
||||
logger.warning("🚨 [Anomaly] Zero interactive nodes found. Executing recovery...")
|
||||
ctx.device.press("back")
|
||||
sleep(1.0 * ctx.sleep_mod)
|
||||
humanized_scroll(ctx.device)
|
||||
sleep(1.0 * ctx.sleep_mod)
|
||||
return BehaviorResult(executed=True, should_skip=True)
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
@@ -9,7 +9,7 @@ import logging
|
||||
import random
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorPlugin, BehaviorContext, BehaviorResult
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.perception.feed_analysis import has_carousel_in_view
|
||||
from GramAddict.core.physics.humanized_input import humanized_horizontal_swipe
|
||||
|
||||
@@ -19,85 +19,69 @@ logger = logging.getLogger(__name__)
|
||||
class CarouselBrowsingPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Browses carousel posts with humanized swiping and curiosity dwells.
|
||||
|
||||
Activation: When a carousel indicator is present in the current XML.
|
||||
Priority: 20 (secondary interaction — runs after primary like/follow decisions).
|
||||
|
||||
Priority: 70 (Primary interaction).
|
||||
"""
|
||||
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "carousel_browsing"
|
||||
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 20 # Secondary interaction tier
|
||||
|
||||
return 70
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
"""Activates when carousel indicators are present on screen."""
|
||||
if not ctx.context_xml:
|
||||
if not getattr(self, "_enabled", True):
|
||||
return False
|
||||
# Check config — carousel_percentage controls activation probability
|
||||
carousel_pct = float(getattr(ctx.configs.args, "carousel_percentage", 0)) / 100.0
|
||||
if carousel_pct <= 0:
|
||||
|
||||
# Analysis requires XML
|
||||
xml = ctx.context_xml if ctx.context_xml else ctx.device.dump_hierarchy()
|
||||
if not has_carousel_in_view(xml):
|
||||
return False
|
||||
return has_carousel_in_view(ctx.context_xml)
|
||||
|
||||
|
||||
config = self.get_config(ctx)
|
||||
percentage = float(config.get("percentage", getattr(ctx.configs.args, "carousel_percentage", 0)))
|
||||
return random.random() < (percentage / 100.0)
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
"""Browse carousel with humanized swiping."""
|
||||
from colorama import Fore
|
||||
|
||||
carousel_pct = float(getattr(ctx.configs.args, "carousel_percentage", 0)) / 100.0
|
||||
|
||||
# Probabilistic execution (config controls how often we interact)
|
||||
if random.random() >= carousel_pct:
|
||||
return BehaviorResult(executed=False)
|
||||
|
||||
# Parse swipe count from config
|
||||
carousel_count_str = getattr(ctx.configs.args, "carousel_count", "1-2")
|
||||
|
||||
config = self.get_config(ctx)
|
||||
carousel_count_str = config.get("count", getattr(ctx.configs.args, "carousel_count", "1-2"))
|
||||
try:
|
||||
min_c, max_c = map(int, carousel_count_str.split('-'))
|
||||
min_c, max_c = map(int, carousel_count_str.split("-"))
|
||||
count = random.randint(min_c, max_c)
|
||||
except Exception:
|
||||
count = 1
|
||||
|
||||
|
||||
logger.info(
|
||||
f"📸 [Carousel] Interacting with carousel. Swiping {count} times...",
|
||||
extra={"color": f"{Fore.CYAN}"}
|
||||
f"📸 [Carousel] Interacting with carousel. Swiping {count} times...", extra={"color": f"{Fore.CYAN}"}
|
||||
)
|
||||
|
||||
|
||||
info = ctx.device.get_info()
|
||||
w = info.get("displayWidth", 1080)
|
||||
h = info.get("displayHeight", 2400)
|
||||
|
||||
|
||||
# Curiosity Peak: One slide gets extra attention
|
||||
curiosity_slide = random.randint(0, count - 1) if count > 0 else 0
|
||||
|
||||
|
||||
for i in range(count):
|
||||
# Normal transition wait
|
||||
sleep(random.uniform(1.5, 3.5) * ctx.sleep_mod)
|
||||
|
||||
|
||||
# ── Curiosity Dwell ──
|
||||
if i == curiosity_slide:
|
||||
dwell = random.uniform(3.0, 7.0)
|
||||
logger.debug(
|
||||
f"📸 [Carousel] Curiosity Peak hit on slide {i+1}. "
|
||||
f"Gazing for {dwell:.1f}s..."
|
||||
)
|
||||
logger.debug(f"📸 [Carousel] Curiosity Peak hit on slide {i+1}. " f"Gazing for {dwell:.1f}s...")
|
||||
sleep(dwell * ctx.sleep_mod)
|
||||
|
||||
|
||||
# Horizontal swipe: Right to left
|
||||
humanized_horizontal_swipe(
|
||||
ctx.device,
|
||||
start_x=w * 0.8,
|
||||
end_x=w * 0.2,
|
||||
y=h * 0.5,
|
||||
duration_ms=250
|
||||
)
|
||||
|
||||
humanized_horizontal_swipe(ctx.device, start_x=w * 0.8, end_x=w * 0.2, y=h * 0.5, duration_ms=250)
|
||||
|
||||
sleep(random.uniform(1.0, 2.0) * ctx.sleep_mod)
|
||||
|
||||
|
||||
return BehaviorResult(
|
||||
executed=True,
|
||||
interactions=count,
|
||||
metadata={"slides_viewed": count, "curiosity_slide": curiosity_slide}
|
||||
executed=True, interactions=count, metadata={"slides_viewed": count, "curiosity_slide": curiosity_slide}
|
||||
)
|
||||
|
||||
44
GramAddict/core/behaviors/close_friends_guard.py
Normal file
44
GramAddict/core/behaviors/close_friends_guard.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import logging
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.physics.humanized_input import humanized_scroll
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloseFriendsGuardPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Checks for close friends badge and skips.
|
||||
|
||||
Priority: 99.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "close_friends_guard"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 99
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
return True
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
if not getattr(self, "_enabled", True):
|
||||
return False
|
||||
|
||||
xml = ctx.context_xml if ctx.context_xml else ctx.device.dump_hierarchy()
|
||||
return "enge freunde" in xml.lower() or "close friend" in xml.lower()
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
logger.info("💚 [CloseFriendsGuard] Close friends post detected. Skipping...")
|
||||
humanized_scroll(ctx.device, is_skip=True)
|
||||
sleep(1.0 * ctx.sleep_mod)
|
||||
return BehaviorResult(executed=True, should_skip=True)
|
||||
85
GramAddict/core/behaviors/comment.py
Normal file
85
GramAddict/core/behaviors/comment.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import logging
|
||||
import random
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CommentPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Handles commenting on posts.
|
||||
|
||||
Priority: 55.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "comment"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 55
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
"""Determines if we should comment on this post."""
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
if ctx.session_state.check_limit(SessionState.Limit.COMMENTS):
|
||||
return False
|
||||
|
||||
config = self.get_config(ctx)
|
||||
comment_pct = float(config.get("percentage", getattr(ctx.configs.args, "comment_percentage", 0))) / 100.0
|
||||
|
||||
if comment_pct <= 0:
|
||||
return False
|
||||
|
||||
# Probability gate (includes resonance weighting if available in shared_state)
|
||||
res_score = ctx.shared_state.get("res_score", 1.0)
|
||||
chance = comment_pct * res_score
|
||||
|
||||
if random.random() >= chance:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
"""Comment on the current post."""
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if not nav_graph:
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
config = self.get_config(ctx)
|
||||
|
||||
# 1. Open comment section
|
||||
if nav_graph.do("open comments"):
|
||||
# 2. Generate comment text
|
||||
writer = ctx.cognitive_stack.get("writer")
|
||||
if not writer:
|
||||
logger.warning("✍️ [Comment] No 'writer' found in cognitive stack. Cannot generate comment.")
|
||||
ctx.device.press("back")
|
||||
return BehaviorResult(executed=False)
|
||||
|
||||
text = writer.generate_comment(ctx.post_data)
|
||||
logger.info(f"✍️ [Comment] Generated: '{text}'")
|
||||
|
||||
# 3. Handle Dry Run
|
||||
if config.get("dry_run", getattr(ctx.configs.args, "dry_run_comments", False)):
|
||||
logger.info("🧪 [Comment] Dry run enabled. Skipping actual post.")
|
||||
ctx.device.press("back")
|
||||
return BehaviorResult(executed=True, interactions=0, metadata={"text": text, "dry_run": True})
|
||||
|
||||
# 4. Type and post
|
||||
if nav_graph.do("type and post comment", text=text):
|
||||
logger.info(f"💬 [Comment] Posted to @{ctx.username} ✓")
|
||||
ctx.session_state.add_interaction(source=ctx.username, succeed=True, followed=False, liked=False)
|
||||
ctx.session_state.totalComments += 1
|
||||
return BehaviorResult(executed=True, interactions=1, metadata={"text": text})
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
48
GramAddict/core/behaviors/darwin_dwell.py
Normal file
48
GramAddict/core/behaviors/darwin_dwell.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import logging
|
||||
import random
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DarwinDwellPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Simulates human dwelling using the Darwin engine.
|
||||
|
||||
Priority: 60 (Runs after evaluation, before interactions).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "darwin_dwell"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 60
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
if not getattr(self, "_enabled", True):
|
||||
return False
|
||||
|
||||
config = self.get_config(ctx)
|
||||
percentage = float(config.get("percentage", 100))
|
||||
return random.random() < (percentage / 100.0)
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
darwin = ctx.cognitive_stack.get("darwin")
|
||||
if darwin:
|
||||
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)
|
||||
else:
|
||||
logger.info("🐢 [DarwinDwell] Darwin engine missing. Falling back to static sleep.")
|
||||
sleep(2.5 * ctx.sleep_mod)
|
||||
|
||||
return BehaviorResult(executed=True)
|
||||
@@ -1,73 +1,61 @@
|
||||
"""
|
||||
Follow Behavior — Plugin Implementation.
|
||||
|
||||
Follows a target user's profile with session limit awareness.
|
||||
Migrated from the follow section of _interact_with_profile.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorPlugin, BehaviorContext, BehaviorResult
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FollowPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Follows a target user from their profile page.
|
||||
|
||||
Activation: When follow_percentage > 0 and session follow limit not reached.
|
||||
Priority: 60 (primary interaction tier).
|
||||
Follows a target user from their profile page or feed.
|
||||
|
||||
Priority: 40.
|
||||
"""
|
||||
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "follow"
|
||||
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 60 # Primary interaction tier
|
||||
|
||||
return 40
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
"""Activates when follow is enabled and limits not reached."""
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
follow_pct = float(getattr(ctx.configs.args, "follow_percentage", 0)) / 100.0
|
||||
|
||||
config = self.get_config(ctx)
|
||||
follow_pct = float(config.get("percentage", getattr(ctx.configs.args, "follow_percentage", 0))) / 100.0
|
||||
|
||||
if follow_pct <= 0:
|
||||
return False
|
||||
|
||||
if ctx.session_state.check_limit(SessionState.Limit.FOLLOWS):
|
||||
return False
|
||||
|
||||
# Probability gate
|
||||
if random.random() >= follow_pct:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
"""Follow the target user."""
|
||||
follow_pct = float(getattr(ctx.configs.args, "follow_percentage", 0)) / 100.0
|
||||
|
||||
rnd = random.random()
|
||||
logger.info(
|
||||
f"⚙️ [Decision] Profile Follow -> Config: {follow_pct*100}% "
|
||||
f"(Roll: {rnd:.2f}) -> Proceed: {rnd < follow_pct}"
|
||||
)
|
||||
|
||||
if rnd >= follow_pct:
|
||||
return BehaviorResult(executed=False)
|
||||
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if not nav_graph:
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
if nav_graph.do("tap follow button"):
|
||||
logger.info(f"🤝 [Follow] Followed @{ctx.username} ✓")
|
||||
ctx.session_state.totalFollowed[ctx.username] = 1
|
||||
|
||||
ctx.session_state.add_interaction(source=ctx.username, succeed=True, followed=True, liked=False)
|
||||
|
||||
# Buffer for follow animations to close
|
||||
sleep(random.uniform(1.8, 3.2) * ctx.sleep_mod)
|
||||
|
||||
return BehaviorResult(
|
||||
executed=True,
|
||||
interactions=1,
|
||||
metadata={"followed": ctx.username}
|
||||
)
|
||||
|
||||
|
||||
return BehaviorResult(executed=True, interactions=1, metadata={"followed": ctx.username})
|
||||
|
||||
return BehaviorResult(executed=False, metadata={"reason": "nav_failed"})
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
"""
|
||||
Grid Like Behavior — Plugin Implementation.
|
||||
|
||||
Likes posts from a target user's profile grid.
|
||||
Migrated from the grid-likes section of _interact_with_profile.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorPlugin, BehaviorContext, BehaviorResult
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.physics.humanized_input import humanized_click, humanized_scroll
|
||||
from GramAddict.core.physics.timing import wait_for_post_loaded
|
||||
|
||||
@@ -19,108 +12,101 @@ logger = logging.getLogger(__name__)
|
||||
class GridLikePlugin(BehaviorPlugin):
|
||||
"""
|
||||
Opens profile grid and likes posts with humanized behavior.
|
||||
|
||||
Activation: When likes_percentage > 0 and session like limit not reached.
|
||||
Priority: 50 (primary interaction tier, after follow).
|
||||
|
||||
Priority: 30.
|
||||
"""
|
||||
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "grid_like"
|
||||
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 50 # Primary interaction, after follow
|
||||
|
||||
return 30
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
"""Activates when likes are enabled, limits not reached, and we are on a profile."""
|
||||
"""Activates when likes are enabled, limits not reached, and probability met."""
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
likes_pct = float(getattr(ctx.configs.args, "likes_percentage", 0)) / 100.0
|
||||
|
||||
config = self.get_config(ctx)
|
||||
likes_pct = float(config.get("percentage", getattr(ctx.configs.args, "likes_percentage", 0))) / 100.0
|
||||
|
||||
if likes_pct <= 0:
|
||||
return False
|
||||
|
||||
if ctx.session_state.check_limit(SessionState.Limit.LIKES):
|
||||
return False
|
||||
|
||||
|
||||
# ── STRUCTURAL GUARD ──
|
||||
# Prevent execution in Reels/HomeFeed. Must be on a profile.
|
||||
if ctx.context_xml:
|
||||
if "profile_header" not in ctx.context_xml.lower() and "followers" not in ctx.context_xml.lower():
|
||||
return False
|
||||
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if nav_graph and nav_graph.current_state != "ProfileView":
|
||||
return False
|
||||
|
||||
# Fallback XML check
|
||||
xml = ctx.context_xml if ctx.context_xml else ctx.device.dump_hierarchy()
|
||||
xml_lower = xml.lower()
|
||||
if "followers" not in xml_lower and "beiträge" not in xml_lower and "posts" not in xml_lower:
|
||||
return False
|
||||
|
||||
# Probability gate
|
||||
if random.random() >= likes_pct:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
"""Open grid and like posts."""
|
||||
likes_pct = float(getattr(ctx.configs.args, "likes_percentage", 0)) / 100.0
|
||||
|
||||
rnd = random.random()
|
||||
logger.info(
|
||||
f"⚙️ [Decision] Profile Grid Likes -> Config: {likes_pct*100}% "
|
||||
f"(Roll: {rnd:.2f}) -> Proceed: {rnd < likes_pct}"
|
||||
)
|
||||
|
||||
if rnd >= likes_pct:
|
||||
return BehaviorResult(executed=False)
|
||||
|
||||
config = self.get_config(ctx)
|
||||
|
||||
# Parse like count
|
||||
likes_count_str = getattr(ctx.configs.args, "likes_count", "1-2")
|
||||
likes_count_str = config.get("count", getattr(ctx.configs.args, "likes_count", "1-2"))
|
||||
try:
|
||||
min_l, max_l = map(int, likes_count_str.split('-'))
|
||||
count = random.randint(min_l, max_l)
|
||||
if "-" in likes_count_str:
|
||||
min_l, max_l = map(int, likes_count_str.split("-"))
|
||||
count = random.randint(min_l, max_l)
|
||||
else:
|
||||
count = int(likes_count_str)
|
||||
except Exception:
|
||||
count = 1
|
||||
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if not nav_graph:
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
if not nav_graph.do("tap first image post in profile grid"):
|
||||
return BehaviorResult(executed=False, metadata={"reason": "grid_nav_failed"})
|
||||
|
||||
|
||||
if not wait_for_post_loaded(ctx.device, timeout=5):
|
||||
logger.warning(f"❌ Post failed to open from profile grid of @{ctx.username}.")
|
||||
logger.warning(f"❌ [GridLike] Post failed to open from profile grid of @{ctx.username}.")
|
||||
return BehaviorResult(executed=False, metadata={"reason": "post_load_failed"})
|
||||
|
||||
logger.info(
|
||||
f"❤️ [Grid Like] Opening grid to drop {count} likes on @{ctx.username}..."
|
||||
)
|
||||
|
||||
|
||||
logger.info(f"❤️ [GridLike] Dropping {count} likes on @{ctx.username} profile grid...")
|
||||
|
||||
info = ctx.device.get_info()
|
||||
w = info.get("displayWidth", 1080)
|
||||
h = info.get("displayHeight", 2400)
|
||||
|
||||
|
||||
growth = ctx.cognitive_stack.get("growth_brain")
|
||||
total_liked = 0
|
||||
|
||||
|
||||
for i in range(count):
|
||||
xml_dump = ctx.device.dump_hierarchy()
|
||||
if not isinstance(xml_dump, str):
|
||||
xml_dump = ""
|
||||
xml_dump_lower = xml_dump.lower()
|
||||
|
||||
|
||||
is_reel = "reel_viewer" in xml_dump_lower or "clips_viewer" in xml_dump_lower
|
||||
is_liked = (
|
||||
"gefällt mir nicht mehr" in xml_dump_lower or
|
||||
"unlike" in xml_dump_lower or
|
||||
'content-desc="liked"' in xml_dump_lower
|
||||
)
|
||||
|
||||
# Double-tap ~40% of the time on standard images
|
||||
|
||||
# Use growth brain for decision making (double tap vs heart button)
|
||||
use_double_tap = growth.wants_to_double_tap(is_reel=is_reel) if growth else False
|
||||
|
||||
|
||||
if use_double_tap:
|
||||
if is_liked:
|
||||
logger.debug(f"Skipped liking grid post {i+1}/{count} (already liked)")
|
||||
else:
|
||||
offset_x = random.randint(int(w * 0.2), int(w * 0.8))
|
||||
offset_y = random.randint(int(h * 0.3), int(h * 0.7))
|
||||
logger.info(
|
||||
f"❤️ [Grid Like] Double-Tapping organically at ({offset_x}, {offset_y})"
|
||||
)
|
||||
humanized_click(ctx.device, offset_x, offset_y, double=True, sleep_mod=ctx.sleep_mod)
|
||||
ctx.session_state.totalLikes += 1
|
||||
total_liked += 1
|
||||
logger.debug(f"Liked grid post {i+1}/{count} via Double-Tap")
|
||||
offset_x = random.randint(int(w * 0.2), int(w * 0.8))
|
||||
offset_y = random.randint(int(h * 0.3), int(h * 0.7))
|
||||
humanized_click(ctx.device, offset_x, offset_y, double=True, sleep_mod=ctx.sleep_mod)
|
||||
ctx.session_state.totalLikes += 1
|
||||
total_liked += 1
|
||||
logger.debug(f"Liked grid post {i+1}/{count} via Double-Tap")
|
||||
else:
|
||||
if nav_graph.do("tap like button"):
|
||||
ctx.session_state.totalLikes += 1
|
||||
@@ -128,22 +114,19 @@ class GridLikePlugin(BehaviorPlugin):
|
||||
logger.debug(f"Liked grid post {i+1}/{count} via Heart Button")
|
||||
else:
|
||||
logger.debug(f"Skipped liking grid post {i+1}/{count}")
|
||||
|
||||
|
||||
sleep(random.uniform(1.0, 2.0) * ctx.sleep_mod)
|
||||
|
||||
if is_reel:
|
||||
logger.debug("🎬 Detected Reel. Swiping full-screen up.")
|
||||
humanized_scroll(ctx.device, is_skip=True)
|
||||
else:
|
||||
humanized_scroll(ctx.device, is_skip=False)
|
||||
|
||||
sleep(random.uniform(1.5, 3.0) * ctx.sleep_mod)
|
||||
|
||||
|
||||
if i < count - 1:
|
||||
if is_reel:
|
||||
humanized_scroll(ctx.device, is_skip=True)
|
||||
else:
|
||||
humanized_scroll(ctx.device, is_skip=False)
|
||||
sleep(random.uniform(1.5, 3.0) * ctx.sleep_mod)
|
||||
|
||||
ctx.device.press("back")
|
||||
sleep(random.uniform(1.0, 2.0) * ctx.sleep_mod)
|
||||
|
||||
|
||||
return BehaviorResult(
|
||||
executed=True,
|
||||
interactions=total_liked,
|
||||
metadata={"posts_viewed": count, "posts_liked": total_liked}
|
||||
executed=True, interactions=total_liked, metadata={"posts_viewed": count, "posts_liked": total_liked}
|
||||
)
|
||||
|
||||
60
GramAddict/core/behaviors/like.py
Normal file
60
GramAddict/core/behaviors/like.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import logging
|
||||
import random
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LikePlugin(BehaviorPlugin):
|
||||
"""
|
||||
Handles liking posts.
|
||||
|
||||
Priority: 50.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "likes"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 50
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
"""Determines if we should like this post."""
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
if ctx.session_state.check_limit(SessionState.Limit.LIKES):
|
||||
return False
|
||||
|
||||
config = self.get_config(ctx)
|
||||
likes_pct = float(config.get("percentage", getattr(ctx.configs.args, "likes_percentage", 80))) / 100.0
|
||||
|
||||
if likes_pct <= 0:
|
||||
return False
|
||||
|
||||
# Probability gate
|
||||
if random.random() >= likes_pct:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
"""Like the current post."""
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if not nav_graph:
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
if nav_graph.do("tap like button"):
|
||||
logger.info(f"❤️ [Like] Liked post by @{ctx.username} ✓")
|
||||
ctx.session_state.add_interaction(source=ctx.username, succeed=True, followed=False, liked=True)
|
||||
return BehaviorResult(executed=True, interactions=1)
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
81
GramAddict/core/behaviors/obstacle_guard.py
Normal file
81
GramAddict/core/behaviors/obstacle_guard.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import logging
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.diagnostic_dump import dump_ui_state
|
||||
from GramAddict.core.physics.humanized_input import humanized_scroll
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ObstacleGuardPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Guards against modals and checks marker presence to prevent infinite loops.
|
||||
|
||||
Priority: 95.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "obstacle_guard"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 95
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
return True
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
return getattr(self, "_enabled", True)
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
sae = SituationalAwarenessEngine.get_instance(ctx.device)
|
||||
xml = ctx.context_xml if ctx.context_xml else ctx.device.dump_hierarchy()
|
||||
situation = sae.perceive(xml)
|
||||
|
||||
misses = ctx.shared_state.get("consecutive_marker_misses", 0)
|
||||
|
||||
if situation == SituationType.OBSTACLE_MODAL:
|
||||
if misses >= 2:
|
||||
logger.error("🛑 [ObstacleGuard] Failed to recover from OBSTACLE_MODAL after multiple attempts.")
|
||||
sae.unlearn_current_state()
|
||||
dump_ui_state(ctx.device, f"fatal_obstacle_{ctx.session_state.job_target}")
|
||||
return BehaviorResult(executed=True, should_skip=True, metadata={"return_code": "CONTEXT_LOST"})
|
||||
|
||||
logger.warning("⚠️ [ObstacleGuard] OBSTACLE_MODAL detected. Attempting to dismiss...")
|
||||
ctx.device.press("back")
|
||||
sleep(1.5 * ctx.sleep_mod)
|
||||
|
||||
# Check recovery
|
||||
new_xml = ctx.device.dump_hierarchy()
|
||||
tele = TelepathicEngine.get_instance()
|
||||
best_node = tele.find_best_node(new_xml, intent_description="Dismiss obstacle")
|
||||
if best_node:
|
||||
ctx.device.click(best_node.get("x", 0), best_node.get("y", 0))
|
||||
|
||||
if "row_feed_button_like" in new_xml:
|
||||
logger.info("✅ [ObstacleGuard] Successfully recovered from OBSTACLE_MODAL.")
|
||||
ctx.shared_state["consecutive_marker_misses"] = 0
|
||||
else:
|
||||
ctx.shared_state["consecutive_marker_misses"] = misses + 1
|
||||
|
||||
return BehaviorResult(executed=True, should_skip=True) # Restart loop for same post or next
|
||||
|
||||
else: # SituationType.NORMAL
|
||||
if "row_feed_button_like" not in xml:
|
||||
logger.info("🧩 [ObstacleGuard] Missing feed markers. Scrolling...")
|
||||
ctx.shared_state["consecutive_marker_misses"] = misses + 1
|
||||
humanized_scroll(ctx.device)
|
||||
return BehaviorResult(executed=True, should_skip=True)
|
||||
else:
|
||||
ctx.shared_state["consecutive_marker_misses"] = 0
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
42
GramAddict/core/behaviors/perfect_snapping.py
Normal file
42
GramAddict/core/behaviors/perfect_snapping.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import logging
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.bot_flow import _align_active_post
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PerfectSnappingPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Aligns the current post in the viewport.
|
||||
|
||||
Priority: 90 (Runs after guards, before extraction).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "perfect_snapping"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 90
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
return getattr(self, "_enabled", True)
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
aligned = _align_active_post(ctx.device)
|
||||
if aligned:
|
||||
logger.info("🎯 [PerfectSnapping] Post aligned. Refreshing context XML...")
|
||||
new_xml = ctx.device.dump_hierarchy()
|
||||
radome = ctx.cognitive_stack.get("radome")
|
||||
if radome:
|
||||
new_xml = radome.sanitize_xml(new_xml)
|
||||
ctx.context_xml = new_xml
|
||||
return BehaviorResult(executed=True)
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
41
GramAddict/core/behaviors/post_data_extraction.py
Normal file
41
GramAddict/core/behaviors/post_data_extraction.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import logging
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.perception.feed_analysis import extract_post_content
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PostDataExtractionPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Extracts post data (caption, hashtags, user) for later evaluation.
|
||||
|
||||
Priority: 85 (Runs after guards, before evaluation).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "post_data_extraction"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 85
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
return getattr(self, "_enabled", True) and ctx.context_xml is not None
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
logger.debug("🧩 [PostDataExtraction] Extracting post metadata...")
|
||||
post_data = extract_post_content(ctx.context_xml)
|
||||
|
||||
if post_data:
|
||||
ctx.post_data = post_data
|
||||
ctx.username = post_data.get("username", "")
|
||||
logger.info(f"📝 [PostDataExtraction] Post by @{ctx.username} extracted.")
|
||||
return BehaviorResult(executed=True)
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
51
GramAddict/core/behaviors/post_interaction.py
Normal file
51
GramAddict/core/behaviors/post_interaction.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import logging
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.physics.humanized_input import humanized_scroll
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PostInteractionPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Runs after all interactions on a post are complete.
|
||||
Handles scrolling to the next post and logging outcomes.
|
||||
|
||||
Priority: 10 (lowest, runs last).
|
||||
Exclusive: True (ends the behavior chain for this post).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "post_interaction"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 10 # Lowest priority, runs last
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
return True # Ends the behavior chain for this post
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
return getattr(self, "_enabled", True)
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
logger.info("🏁 [PostInteraction] Interactions complete. Moving to next post...")
|
||||
|
||||
# Log to CRM or telemetry if active
|
||||
telemetry = ctx.cognitive_stack.get("telemetry")
|
||||
if telemetry:
|
||||
telemetry.log_post_interaction(ctx.post_data, ctx.shared_state.get("session_outcomes", []))
|
||||
|
||||
humanized_scroll(ctx.device)
|
||||
sleep(1.5 * ctx.sleep_mod)
|
||||
|
||||
return BehaviorResult(
|
||||
executed=True, should_skip=True
|
||||
) # should_skip=True signals the feed loop to restart for the next post
|
||||
@@ -12,7 +12,7 @@ Priority 100 (highest, exclusive) — if a guard fires, no other behavior runs.
|
||||
|
||||
import logging
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorPlugin, BehaviorContext, BehaviorResult
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,80 +22,65 @@ class ProfileGuardPlugin(BehaviorPlugin):
|
||||
Guards against interacting with profiles that should be skipped.
|
||||
Exclusive: if this fires, no further interactions happen on this profile.
|
||||
"""
|
||||
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "profile_guard"
|
||||
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 100 # Highest — runs before everything
|
||||
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
return True # Stop all other plugins if guard fires
|
||||
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
"""Only activates on Profile screens to prevent false-positives in Feed/Reels."""
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
is_profile = nav_graph and nav_graph.current_state == "ProfileView"
|
||||
return bool(ctx.username) and is_profile
|
||||
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
"""Check profile guards. Returns executed=True + should_skip=True if rejected."""
|
||||
from colorama import Fore
|
||||
|
||||
|
||||
xml_check = ctx.context_xml
|
||||
if not xml_check:
|
||||
return BehaviorResult(executed=False)
|
||||
|
||||
|
||||
xml_check_lower = xml_check.lower()
|
||||
|
||||
|
||||
# Self-interaction guard
|
||||
if (hasattr(ctx.session_state, 'my_username') and
|
||||
ctx.username == ctx.session_state.my_username):
|
||||
logger.info(
|
||||
f"🤝 [Profile Guard] Skipping own profile @{ctx.username}."
|
||||
)
|
||||
return BehaviorResult(executed=True, should_skip=True,
|
||||
metadata={"reason": "self_profile"})
|
||||
|
||||
if hasattr(ctx.session_state, "my_username") and ctx.username == ctx.session_state.my_username:
|
||||
logger.info(f"🤝 [Profile Guard] Skipping own profile @{ctx.username}.")
|
||||
return BehaviorResult(executed=True, should_skip=True, metadata={"reason": "self_profile"})
|
||||
|
||||
# Private account guard
|
||||
if ("this account is private" in xml_check_lower or
|
||||
"konto ist privat" in xml_check_lower):
|
||||
logger.info(
|
||||
f"🔒 [Profile Guard] @{ctx.username} is private.",
|
||||
extra={"color": f"{Fore.YELLOW}"}
|
||||
)
|
||||
return BehaviorResult(executed=True, should_skip=True,
|
||||
metadata={"reason": "private"})
|
||||
|
||||
if "this account is private" in xml_check_lower or "konto ist privat" in xml_check_lower:
|
||||
logger.info(f"🔒 [Profile Guard] @{ctx.username} is private.", extra={"color": f"{Fore.YELLOW}"})
|
||||
return BehaviorResult(executed=True, should_skip=True, metadata={"reason": "private"})
|
||||
|
||||
# Empty account guard
|
||||
if ("no posts yet" in xml_check_lower or
|
||||
"noch keine beiträge" in xml_check_lower):
|
||||
logger.info(
|
||||
f"📭 [Profile Guard] @{ctx.username} has no posts.",
|
||||
extra={"color": f"{Fore.YELLOW}"}
|
||||
)
|
||||
return BehaviorResult(executed=True, should_skip=True,
|
||||
metadata={"reason": "empty"})
|
||||
|
||||
if "no posts yet" in xml_check_lower or "noch keine beiträge" in xml_check_lower:
|
||||
logger.info(f"📭 [Profile Guard] @{ctx.username} has no posts.", extra={"color": f"{Fore.YELLOW}"})
|
||||
return BehaviorResult(executed=True, should_skip=True, metadata={"reason": "empty"})
|
||||
|
||||
# Close friends guard
|
||||
if getattr(ctx.configs.args, "ignore_close_friends", False):
|
||||
if ("enge freunde" in xml_check_lower or
|
||||
"close friend" in xml_check_lower):
|
||||
if "enge freunde" in xml_check_lower or "close friend" in xml_check_lower:
|
||||
logger.info(
|
||||
f"💚 [Profile Guard] @{ctx.username} is a Close Friend. Ignoring.",
|
||||
extra={"color": "\033[32m"}
|
||||
f"💚 [Profile Guard] @{ctx.username} is a Close Friend. Ignoring.", extra={"color": "\033[32m"}
|
||||
)
|
||||
return BehaviorResult(executed=True, should_skip=True,
|
||||
metadata={"reason": "close_friend"})
|
||||
|
||||
return BehaviorResult(executed=True, should_skip=True, metadata={"reason": "close_friend"})
|
||||
|
||||
# Visual Vibe Check (AI Aesthetic Quality Guard)
|
||||
import random
|
||||
|
||||
vibe_check_pct = float(getattr(ctx.configs.args, "visual_vibe_check_percentage", 0)) / 100.0
|
||||
if vibe_check_pct > 0 and random.random() < vibe_check_pct:
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
telepathic = ctx.cognitive_stack.get("telepathic") or TelepathicEngine.get_instance()
|
||||
persona_interests = ctx.cognitive_stack.get("persona_interests", []) if ctx.cognitive_stack else []
|
||||
vibe_result = telepathic.evaluate_profile_vibe(ctx.device, persona_interests)
|
||||
@@ -107,13 +92,14 @@ class ProfileGuardPlugin(BehaviorPlugin):
|
||||
logger.warning(
|
||||
f"🚫 [Vibe Check] Profile @{ctx.username} rejected (Score: {score}, Niche: {matches_niche}). Reason: {vibe_result.get('reason')}"
|
||||
)
|
||||
return BehaviorResult(executed=True, should_skip=True,
|
||||
metadata={"reason": "vibe_check_failed", "score": score})
|
||||
return BehaviorResult(
|
||||
executed=True, should_skip=True, metadata={"reason": "vibe_check_failed", "score": score}
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"✅ [Vibe Check] Profile @{ctx.username} approved (Score: {score}). Continuing interaction.",
|
||||
extra={"color": "\033[36m"}
|
||||
extra={"color": "\033[36m"},
|
||||
)
|
||||
|
||||
|
||||
# All guards passed — don't block further plugins
|
||||
return BehaviorResult(executed=False)
|
||||
|
||||
102
GramAddict/core/behaviors/profile_visit.py
Normal file
102
GramAddict/core/behaviors/profile_visit.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import logging
|
||||
import random
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProfileVisitPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Handles visiting a user's profile from the feed.
|
||||
|
||||
Priority: 35.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "profile_visit"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 35
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
"""Determines if we should visit the profile."""
|
||||
if not getattr(self, "_enabled", True):
|
||||
return False
|
||||
|
||||
# 1. Guard against recursive calls or being already on profile
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if nav_graph and nav_graph.current_state == "ProfileView":
|
||||
return False
|
||||
|
||||
# 2. Probability gate
|
||||
config = self.get_config(ctx)
|
||||
visit_pct = float(config.get("percentage", getattr(ctx.configs.args, "profile_visit_percentage", 30))) / 100.0
|
||||
|
||||
if visit_pct <= 0:
|
||||
return False
|
||||
|
||||
# 3. Probability gate (weighted by resonance)
|
||||
res_score = ctx.shared_state.get("res_score", 1.0)
|
||||
chance = visit_pct * res_score
|
||||
|
||||
if random.random() >= chance:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
"""Visit the user's profile and execute nested plugins."""
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if not nav_graph:
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
if nav_graph.do("tap post username"):
|
||||
logger.info(f"👤 [ProfileVisit] Visiting @{ctx.username}...")
|
||||
sleep(2.0 * ctx.sleep_mod)
|
||||
|
||||
# Create a new context for the profile interaction
|
||||
from GramAddict.core.behaviors import BehaviorContext, PluginRegistry
|
||||
|
||||
# Update nav state to ProfileView
|
||||
original_state = nav_graph.current_state
|
||||
nav_graph.current_state = "ProfileView"
|
||||
|
||||
profile_xml = ctx.device.dump_hierarchy()
|
||||
profile_ctx = BehaviorContext(
|
||||
device=ctx.device,
|
||||
configs=ctx.configs,
|
||||
session_state=ctx.session_state,
|
||||
cognitive_stack=ctx.cognitive_stack,
|
||||
context_xml=profile_xml,
|
||||
sleep_mod=ctx.sleep_mod,
|
||||
post_data=ctx.post_data,
|
||||
username=ctx.username,
|
||||
shared_state=ctx.shared_state,
|
||||
)
|
||||
|
||||
logger.info(f"🕵️ [ProfileVisit] Executing interactions on @{ctx.username}'s profile...")
|
||||
registry = PluginRegistry.get_instance()
|
||||
|
||||
# Execute all active plugins on the profile view (including ProfileGuard)
|
||||
registry.execute_all(profile_ctx)
|
||||
|
||||
# Restore nav state
|
||||
nav_graph.current_state = original_state
|
||||
|
||||
logger.info(f"🔙 [ProfileVisit] Returning from @{ctx.username}.")
|
||||
ctx.device.press("back")
|
||||
sleep(1.0 * ctx.sleep_mod)
|
||||
|
||||
return BehaviorResult(executed=True, interactions=1)
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
53
GramAddict/core/behaviors/rabbit_hole.py
Normal file
53
GramAddict/core/behaviors/rabbit_hole.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import logging
|
||||
import random
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RabbitHolePlugin(BehaviorPlugin):
|
||||
"""
|
||||
Randomly jumps into a user's profile if resonance is high.
|
||||
|
||||
Priority: 20 (Secondary interaction).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "rabbit_hole"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 20
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
if not getattr(self, "_enabled", True):
|
||||
return False
|
||||
|
||||
res_score = ctx.shared_state.get("res_score", 0.0)
|
||||
if res_score < 0.8:
|
||||
return False
|
||||
|
||||
config = self.get_config(ctx)
|
||||
percentage = float(config.get("percentage", 15))
|
||||
return random.random() < (percentage / 100.0)
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
logger.info("🕳️ [RabbitHole] Falling down the rabbit hole! Investigating user profile...")
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if nav_graph:
|
||||
success = nav_graph.do("tap post username")
|
||||
if success:
|
||||
sleep(2.0 * ctx.sleep_mod)
|
||||
# Just a quick peek
|
||||
ctx.device.press("back")
|
||||
sleep(1.0 * ctx.sleep_mod)
|
||||
return BehaviorResult(executed=True)
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
57
GramAddict/core/behaviors/repost.py
Normal file
57
GramAddict/core/behaviors/repost.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import logging
|
||||
import random
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RepostPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Handles reposting (sharing to story) for posts.
|
||||
|
||||
Priority: 45.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "repost"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 45
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
"""Determines if we should repost this post."""
|
||||
if not getattr(self, "_enabled", True):
|
||||
return False
|
||||
|
||||
config = self.get_config(ctx)
|
||||
repost_pct = float(config.get("percentage", getattr(ctx.configs.args, "repost_percentage", 20))) / 100.0
|
||||
|
||||
if repost_pct <= 0:
|
||||
return False
|
||||
|
||||
# Probability gate
|
||||
if random.random() >= repost_pct:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
"""Repost the current post."""
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if not nav_graph:
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
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)
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
85
GramAddict/core/behaviors/resonance_evaluator.py
Normal file
85
GramAddict/core/behaviors/resonance_evaluator.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import logging
|
||||
import random
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.physics.humanized_input import humanized_scroll
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResonanceEvaluatorPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Evaluates how much the bot likes a post based on its descriptions, vibes, etc.
|
||||
Decides whether to proceed with interactions or skip the post.
|
||||
|
||||
Priority: 80 (Runs after data extraction).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "resonance_evaluator"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 80
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
return getattr(self, "_enabled", True)
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
resonance = ctx.cognitive_stack.get("resonance")
|
||||
if not resonance:
|
||||
logger.warning("🧠 [Resonance] Engine missing. Defaulting to 1.0")
|
||||
res_score = 1.0
|
||||
else:
|
||||
post_data = ctx.post_data or {}
|
||||
res_score = resonance.calculate_resonance(post_data)
|
||||
|
||||
# Check visual vibe
|
||||
config = self.get_config(ctx)
|
||||
visual_chance = float(
|
||||
config.get("visual_vibe_check_percentage", getattr(ctx.configs.args, "visual_vibe_check_percentage", 0))
|
||||
)
|
||||
if visual_chance > 0 and random.random() < (visual_chance / 100.0):
|
||||
tele = ctx.cognitive_stack.get("telepathic")
|
||||
if tele:
|
||||
logger.info("✨ [Resonance] Performing visual vibe check...")
|
||||
vibe = tele.evaluate_post_vibe()
|
||||
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)
|
||||
|
||||
ctx.shared_state["res_score"] = res_score
|
||||
logger.info(f"📊 [Resonance] Post Score: {res_score:.2f}")
|
||||
|
||||
interact_chance = float(getattr(ctx.configs.args, "interact_percentage", 100))
|
||||
|
||||
# Determine if we should skip the entire post
|
||||
# Threshold could be dynamic, but let's say 0.2 is the floor for absolute garbage
|
||||
if res_score < 0.2 or random.random() >= (interact_chance / 100.0):
|
||||
logger.info(f"⏭️ [Resonance] Skipping post (score={res_score:.2f}, chance check failed).")
|
||||
|
||||
if "session_outcomes" not in ctx.shared_state:
|
||||
ctx.shared_state["session_outcomes"] = []
|
||||
|
||||
ctx.shared_state["session_outcomes"].append(
|
||||
{"username": ctx.username, "resonance": res_score, "action": "skip"}
|
||||
)
|
||||
|
||||
# If we skip here, we MUST scroll to next post and terminate chain
|
||||
humanized_scroll(ctx.device)
|
||||
sleep(1.0 * ctx.sleep_mod)
|
||||
return BehaviorResult(executed=True, should_skip=True)
|
||||
|
||||
dopamine = ctx.cognitive_stack.get("dopamine")
|
||||
if dopamine:
|
||||
quality = "high" if res_score > 0.7 else ("medium" if res_score > 0.4 else "low")
|
||||
dopamine.process_content({"score": res_score * 10, "quality": quality})
|
||||
|
||||
return BehaviorResult(executed=True, should_skip=False)
|
||||
@@ -1,15 +1,8 @@
|
||||
"""
|
||||
Story Viewing Behavior — Plugin Implementation.
|
||||
|
||||
Watches a target user's stories with humanized timing and navigation.
|
||||
Migrated from the story-viewing section of _interact_with_profile.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorPlugin, BehaviorContext, BehaviorResult
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.physics.humanized_input import humanized_click
|
||||
from GramAddict.core.physics.timing import wait_for_story_loaded
|
||||
|
||||
@@ -19,83 +12,87 @@ logger = logging.getLogger(__name__)
|
||||
class StoryViewPlugin(BehaviorPlugin):
|
||||
"""
|
||||
Views a target user's stories from their profile.
|
||||
|
||||
Activation: When stories_percentage > 0 and user has stories.
|
||||
Priority: 40 (runs before likes/follows since it navigates away from profile).
|
||||
|
||||
Priority: 25.
|
||||
"""
|
||||
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "story_view"
|
||||
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 40 # Before likes/follows (since it navigates away)
|
||||
|
||||
return 25
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
"""Activates when story viewing is enabled in config."""
|
||||
stories_pct = float(getattr(ctx.configs.args, "stories_percentage", 0)) / 100.0
|
||||
return stories_pct > 0
|
||||
|
||||
"""Activates when story viewing is enabled and probability met."""
|
||||
config = self.get_config(ctx)
|
||||
stories_pct = float(config.get("percentage", getattr(ctx.configs.args, "stories_percentage", 0))) / 100.0
|
||||
|
||||
if stories_pct <= 0:
|
||||
return False
|
||||
|
||||
# Probability gate
|
||||
if random.random() >= stories_pct:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
"""View stories with humanized timing."""
|
||||
from colorama import Fore
|
||||
|
||||
stories_pct = float(getattr(ctx.configs.args, "stories_percentage", 0)) / 100.0
|
||||
|
||||
# Probabilistic check
|
||||
if random.random() >= stories_pct:
|
||||
return BehaviorResult(executed=False)
|
||||
|
||||
config = self.get_config(ctx)
|
||||
|
||||
# Parse story count
|
||||
stories_count_str = getattr(ctx.configs.args, "stories_count", "1-2")
|
||||
stories_count_str = config.get("count", getattr(ctx.configs.args, "stories_count", "1-2"))
|
||||
try:
|
||||
min_st, max_st = map(int, stories_count_str.split('-'))
|
||||
count = random.randint(min_st, max_st)
|
||||
if "-" in stories_count_str:
|
||||
min_st, max_st = map(int, stories_count_str.split("-"))
|
||||
count = random.randint(min_st, max_st)
|
||||
else:
|
||||
count = int(stories_count_str)
|
||||
except Exception:
|
||||
count = 1
|
||||
|
||||
|
||||
# Check for story ring
|
||||
xml_dump = ctx.context_xml or ctx.device.dump_hierarchy()
|
||||
xml_lower = xml_dump.lower()
|
||||
xml = ctx.context_xml or ctx.device.dump_hierarchy()
|
||||
xml_lower = xml.lower()
|
||||
has_story = (
|
||||
"reel_ring" in xml_dump or
|
||||
"'s unseen story" in xml_lower or
|
||||
"has a new story" in xml_lower or
|
||||
"story von" in xml_lower
|
||||
"reel_ring" in xml
|
||||
or "has an unseen story" in xml_lower
|
||||
or "has a new story" in xml_lower
|
||||
or "story von" in xml_lower
|
||||
)
|
||||
|
||||
|
||||
if not has_story:
|
||||
return BehaviorResult(executed=False, metadata={"reason": "no_story"})
|
||||
|
||||
|
||||
# Navigate to story
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if not nav_graph:
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
if not nav_graph.do("tap story ring avatar"):
|
||||
return BehaviorResult(executed=False, metadata={"reason": "nav_failed"})
|
||||
|
||||
|
||||
# Wait for story to load
|
||||
if not wait_for_story_loaded(ctx.device, timeout=5):
|
||||
logger.warning(f"❌ Story failed to open for @{ctx.username}.")
|
||||
logger.warning(f"❌ [StoryView] Story failed to open for @{ctx.username}.")
|
||||
return BehaviorResult(executed=False, metadata={"reason": "load_timeout"})
|
||||
|
||||
logger.info(f"📸 [Story] Viewing @{ctx.username}'s story ({count} times)...")
|
||||
|
||||
|
||||
logger.info(f"📸 [StoryView] Viewing @{ctx.username}'s story ({count} segments)...")
|
||||
|
||||
info = ctx.device.get_info()
|
||||
w = info.get("displayWidth", 1080)
|
||||
h = info.get("displayHeight", 2400)
|
||||
|
||||
|
||||
for i in range(count):
|
||||
sleep(random.uniform(2.0, 5.0) * ctx.sleep_mod)
|
||||
if i < count - 1:
|
||||
humanized_click(ctx.device, int(w * 0.9), int(h * 0.5), sleep_mod=ctx.sleep_mod)
|
||||
|
||||
|
||||
ctx.device.press("back")
|
||||
sleep(random.uniform(1.0, 2.0) * ctx.sleep_mod)
|
||||
|
||||
return BehaviorResult(
|
||||
executed=True,
|
||||
interactions=count,
|
||||
metadata={"stories_viewed": count}
|
||||
)
|
||||
|
||||
return BehaviorResult(executed=True, interactions=count, metadata={"stories_viewed": count})
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from colorama import Fore, Style
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BENCHMARKS_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "benchmarks", "data", "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):
|
||||
"""
|
||||
@@ -27,17 +31,17 @@ def check_model_benchmarks(configs):
|
||||
def _eval_model(model_name: str, context: str):
|
||||
if not model_name:
|
||||
return
|
||||
|
||||
|
||||
if model_name not in benchmarks:
|
||||
logger.warning(
|
||||
f"⚠️ [Benchmark Guard] Model '{model_name}' (for {context}) is COMPLETELY UNTESTED "
|
||||
f"for the Agent. Expect severe hallucinations or crashed agents.",
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.RED}"}
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.RED}"},
|
||||
)
|
||||
return
|
||||
|
||||
scores = benchmarks[model_name]
|
||||
|
||||
|
||||
# Telepathic/Vision tasks require high structural strictness
|
||||
if context == "Vision/Telepathic":
|
||||
score = scores.get("telepathic_score", 0)
|
||||
@@ -48,29 +52,29 @@ def check_model_benchmarks(configs):
|
||||
logger.error(
|
||||
f"⛔ [Benchmark Guard] Model '{model_name}' (for {context}) achieved a CRITICAL FAILURE score "
|
||||
f"of {score}/100. Autonomous safety is compromised. DO NOT RUN UNATTENDED.",
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.RED}"}
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.RED}"},
|
||||
)
|
||||
elif score < 80:
|
||||
logger.warning(
|
||||
f"⚠️ [Benchmark Guard] Model '{model_name}' (for {context}) achieved a SUB-STANDARD score "
|
||||
f"of {score}/100. It may occasionally hallucinate UI elements or misinterpret semantics.",
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.YELLOW}"}
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.YELLOW}"},
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"✅ [Benchmark Guard] Model '{model_name}' (for {context}) passes safety benchmarks ({score}/100).",
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.GREEN}"}
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.GREEN}"},
|
||||
)
|
||||
|
||||
# Which models did the user configure?
|
||||
telepathic_model = getattr(configs.args, "ai_telepathic_model", None)
|
||||
text_model = getattr(configs.args, "ai_model", None)
|
||||
condenser_model = getattr(configs.args, "ai_condenser_model", None)
|
||||
|
||||
|
||||
_eval_model(telepathic_model, "Vision/Telepathic")
|
||||
|
||||
|
||||
if text_model and text_model != telepathic_model:
|
||||
_eval_model(text_model, "Dopamine/Resonance")
|
||||
|
||||
|
||||
if condenser_model and condenser_model != text_model and condenser_model != telepathic_model:
|
||||
_eval_model(condenser_model, "Context Condensation")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
import logging
|
||||
import json
|
||||
from io import BytesIO
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VLMCompilerEngine:
|
||||
"""
|
||||
The Self-Compiling Heuristics Engine
|
||||
@@ -11,6 +11,7 @@ class VLMCompilerEngine:
|
||||
It takes a screenshot + XML dump, finds the missing intent, and generates a new,
|
||||
blazing-fast deterministic Regex/XPath rule to be cached and executed next time.
|
||||
"""
|
||||
|
||||
def __init__(self, device):
|
||||
self.device = device
|
||||
|
||||
@@ -23,18 +24,26 @@ class VLMCompilerEngine:
|
||||
clean_intent = intent_description
|
||||
if "['" in clean_intent:
|
||||
clean_intent = clean_intent.replace("['", "").replace("']", "").replace("', '", " AND ")
|
||||
|
||||
logger.warning(f"🧠 [Compiler Engine] Deterministic heuristic failed for: '{clean_intent}'. Synthesizing new rule...", extra={"color": "\x1b[1m\x1b[35m"})
|
||||
|
||||
|
||||
logger.warning(
|
||||
f"🧠 [Compiler Engine] Deterministic heuristic failed for: '{clean_intent}'. Synthesizing new rule...",
|
||||
extra={"color": "\x1b[1m\x1b[35m"},
|
||||
)
|
||||
|
||||
args = getattr(self.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"
|
||||
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):
|
||||
@@ -44,7 +53,13 @@ class VLMCompilerEngine:
|
||||
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_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!")
|
||||
@@ -58,37 +73,38 @@ class VLMCompilerEngine:
|
||||
"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\"}"
|
||||
'3. Format: {"rule_type": "regex", "target_attribute": "resource-id", "pattern": ".*regex.*", "confidence": 0.95, "reasoning": "string"}'
|
||||
)
|
||||
|
||||
user_prompt = f"TARGET INTENT: {clean_intent}\n\nUI XML:\n{simplified_xml[:2000]}"
|
||||
|
||||
try:
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
|
||||
res_text = query_telepathic_llm(
|
||||
model=model,
|
||||
url=url,
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
temperature=0.1,
|
||||
use_local_edge=use_local
|
||||
use_local_edge=use_local,
|
||||
)
|
||||
|
||||
|
||||
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])
|
||||
|
||||
|
||||
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):
|
||||
@@ -96,26 +112,31 @@ class VLMCompilerEngine:
|
||||
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')
|
||||
|
||||
pattern = decision.get("pattern")
|
||||
if not pattern:
|
||||
logger.error("Compiler LLM returned empty rule pattern. Aborting heuristic generation.")
|
||||
return None
|
||||
|
||||
logger.info(f"✨ [Compiler] New Heuristic Synthesized! Rule: {decision.get('rule_type')} -> {pattern}", extra={"color": "\x1b[1m\x1b[32m"})
|
||||
|
||||
|
||||
logger.info(
|
||||
f"✨ [Compiler] New Heuristic Synthesized! Rule: {decision.get('rule_type')} -> {pattern}",
|
||||
extra={"color": "\x1b[1m\x1b[32m"},
|
||||
)
|
||||
|
||||
if decision.get("rule_type") == "xpath":
|
||||
logger.error("Compiler LLM returned 'xpath'. Rejecting rule because it causes xml.etree crashes. Will fallback/retry.")
|
||||
logger.error(
|
||||
"Compiler LLM returned 'xpath'. Rejecting rule because it causes xml.etree crashes. Will fallback/retry."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
return {
|
||||
"rule_type": "regex",
|
||||
"target_attribute": decision.get("target_attribute", "text"),
|
||||
"pattern": pattern
|
||||
"pattern": pattern,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -124,6 +145,7 @@ class VLMCompilerEngine:
|
||||
|
||||
def _simplify_xml(self, xml_tree: str) -> str:
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
nodes = []
|
||||
try:
|
||||
root = ET.fromstring(xml_tree)
|
||||
|
||||
@@ -99,9 +99,7 @@ class Config:
|
||||
|
||||
# Configure ArgParse
|
||||
self.parser = configargparse.ArgumentParser(
|
||||
config_file_open_func=lambda filename: open(
|
||||
filename, "r+", encoding="utf-8"
|
||||
),
|
||||
config_file_open_func=lambda filename: open(filename, "r+", encoding="utf-8"),
|
||||
description="GramAddict Instagram Bot",
|
||||
)
|
||||
self.parser.add_argument(
|
||||
@@ -129,7 +127,7 @@ class Config:
|
||||
action="store_true",
|
||||
help="Enable Tesla E2E Vision 'Shadow Mode' Telemetry daemon.",
|
||||
)
|
||||
|
||||
|
||||
# Core Singularity Jobs
|
||||
self.parser.add_argument("--feed", help="Amount of feed posts to interact with", default=None)
|
||||
self.parser.add_argument("--explore", help="Amount of explore posts to interact with", default=None)
|
||||
@@ -141,14 +139,18 @@ class Config:
|
||||
self.parser.add_argument("--time-delta-session", help="Time delta between sessions", 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("--blank-start", action="store_true", help="Wipe all learned navigation and telepathic memories on boot to start 100% blank.")
|
||||
self.parser.add_argument(
|
||||
"--blank-start",
|
||||
action="store_true",
|
||||
help="Wipe all learned navigation and telepathic memories on boot to start 100% blank.",
|
||||
)
|
||||
|
||||
# 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")
|
||||
self.parser.add_argument("--stories-count", help="Stories count", default="0")
|
||||
self.parser.add_argument("--stories-percentage", help="Stories percentage", default="0")
|
||||
|
||||
|
||||
# Total Limits (Legacy names preserved for SessionState compatibility)
|
||||
self.parser.add_argument("--total-likes-limit", help="Total likes limit", default="300")
|
||||
self.parser.add_argument("--total-follows-limit", help="Total follows limit", default="50")
|
||||
@@ -156,53 +158,137 @@ class Config:
|
||||
self.parser.add_argument("--total-comments-limit", help="Total comments limit", default="10")
|
||||
self.parser.add_argument("--total-pm-limit", help="Total pm limit", default="10")
|
||||
self.parser.add_argument("--total-watches-limit", help="Total watches limit", default="50")
|
||||
self.parser.add_argument("--total-successful-interactions-limit", help="Total successful interactions limit", default="100")
|
||||
self.parser.add_argument(
|
||||
"--total-successful-interactions-limit", help="Total successful interactions limit", default="100"
|
||||
)
|
||||
self.parser.add_argument("--total-interactions-limit", help="Total interactions limit", default="1000")
|
||||
self.parser.add_argument("--total-scraped-limit", help="Total scraped limit", default="200")
|
||||
self.parser.add_argument("--total-crashes-limit", help="Total crashes limit", default="5")
|
||||
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="qwen3.5:latest")
|
||||
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="qwen3.5:latest")
|
||||
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="qwen3.5:latest")
|
||||
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")
|
||||
self.parser.add_argument("--ai-embedding-url", help="Embedding endpoint URL", default="http://localhost:11434/api/embeddings")
|
||||
|
||||
self.parser.add_argument(
|
||||
"--ai-model", "--ai-text-model", help="Primary LLM model (OpenRouter or Ollama)", default="qwen3.5:latest"
|
||||
)
|
||||
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="qwen3.5:latest"
|
||||
)
|
||||
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="qwen3.5:latest",
|
||||
)
|
||||
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"
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ai-embedding-url", help="Embedding endpoint URL", default="http://localhost:11434/api/embeddings"
|
||||
)
|
||||
|
||||
# Persona & Resonance (drives ALL content evaluation and interaction decisions)
|
||||
self.parser.add_argument("--persona-interests", help="Comma-separated niche interests for content matching", default="")
|
||||
self.parser.add_argument("--ai-target-audience", help="Target audience used interchangeably with persona interests", default="")
|
||||
self.parser.add_argument("--target-audience", help="Target audience used interchangeably with persona interests", default="")
|
||||
self.parser.add_argument("--interact-percentage", help="Overall interaction probability percentage", default="80")
|
||||
self.parser.add_argument(
|
||||
"--persona-interests", help="Comma-separated niche interests for content matching", default=""
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ai-target-audience", help="Target audience used interchangeably with persona interests", default=""
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--target-audience", help="Target audience used interchangeably with persona interests", default=""
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--interact-percentage", help="Overall interaction probability percentage", default="80"
|
||||
)
|
||||
self.parser.add_argument("--comment-percentage", help="Comment probability percentage", default="0")
|
||||
self.parser.add_argument("--follow-percentage", help="Follow probability percentage", default="0")
|
||||
self.parser.add_argument("--dry-run-comments", action="store_true", help="Generate AI comments but do not actually post them (debug/logging only)")
|
||||
self.parser.add_argument(
|
||||
"--dry-run-comments",
|
||||
action="store_true",
|
||||
help="Generate AI comments but do not actually post them (debug/logging only)",
|
||||
)
|
||||
self.parser.add_argument("--search", help="Comma-separated keywords to search for", default="")
|
||||
self.parser.add_argument("--scrape-profiles", action="store_true", help="Extract and store profile metadata in CRM")
|
||||
self.parser.add_argument("--profile-learning-percentage", help="Percentage of profiles to deeply scan before engaging", default="0")
|
||||
self.parser.add_argument("--visual-vibe-check-percentage", help="Percentage of profiles to visually evaluate via screenshot before engaging", default="0")
|
||||
self.parser.add_argument("--ignore-close-friends", action="store_true", help="Completely ignore posts, stories, and profiles of Close Friends (Enge Freunde)")
|
||||
self.parser.add_argument(
|
||||
"--scrape-profiles", action="store_true", help="Extract and store profile metadata in CRM"
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--profile-learning-percentage", help="Percentage of profiles to deeply scan before engaging", default="0"
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--visual-vibe-check-percentage",
|
||||
help="Percentage of profiles to visually evaluate via screenshot before engaging",
|
||||
default="0",
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ignore-close-friends",
|
||||
action="store_true",
|
||||
help="Completely ignore posts, stories, and profiles of Close Friends (Enge Freunde)",
|
||||
)
|
||||
|
||||
# Biomechanical Physics
|
||||
self.parser.add_argument("--handedness", help="Dominant hand: 'right' or 'left'. Affects thumb arc direction and tap bias.", default="right")
|
||||
self.parser.add_argument(
|
||||
"--handedness",
|
||||
help="Dominant hand: 'right' or 'left'. Affects thumb arc direction and tap bias.",
|
||||
default="right",
|
||||
)
|
||||
|
||||
|
||||
# Phase 10: RAG Comment Learning & Extractor Settings
|
||||
self.parser.add_argument("--ai-condenser-model", help="LLM used for condensing text/comments", default="qwen3.5:latest")
|
||||
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-condenser-model", help="LLM used for condensing text/comments", default="qwen3.5:latest"
|
||||
)
|
||||
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")
|
||||
self.parser.add_argument("--ai-learn-only", action="store_true", help="Run the bot in a pure read-only learning mode")
|
||||
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")
|
||||
self.parser.add_argument(
|
||||
"--ai-learn-own-profile", action="store_true", help="Learn from your own profile interactions"
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ai-learn-only", action="store_true", help="Run the bot in a pure read-only learning mode"
|
||||
)
|
||||
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:
|
||||
@@ -227,32 +313,33 @@ class Config:
|
||||
exit(0)
|
||||
if self.config:
|
||||
cleaned_config = {}
|
||||
|
||||
def flatten_dict(d, parent_key='', sep='_'):
|
||||
|
||||
def flatten_dict(d, parent_key="", sep="_"):
|
||||
items = []
|
||||
for k, v in d.items():
|
||||
# For users specifying account-specific overrides, preserve the dictionary structure
|
||||
# But for generic nested config like 'mission' or 'identity', flatten the keys
|
||||
if isinstance(v, dict) and any(not isinstance(sub_v, dict) for sub_v in v.values()):
|
||||
# Check if this is an account override dict (keys are usernames)
|
||||
# We assume if all values are dicts or strings, but we just flatten normally.
|
||||
# Wait, Gramaddict uses dicts for account overrides!
|
||||
# If a key is 'username' or the value has a list, it's not an override.
|
||||
pass
|
||||
|
||||
if isinstance(v, dict) and k not in ['username', 'passwords']:
|
||||
items.extend(flatten_dict(v, '', sep=sep).items())
|
||||
# Special handling for 'plugins' key: we want 'like: count' to become 'like_count'
|
||||
if k == "plugins" and not parent_key:
|
||||
if isinstance(v, dict):
|
||||
for pk, pv in v.items():
|
||||
items.extend(flatten_dict(pv, pk, sep=sep).items())
|
||||
continue
|
||||
|
||||
if isinstance(v, dict) and k not in ["username", "passwords"]:
|
||||
# If we are inside a plugin, continue prefixing
|
||||
next_prefix = f"{parent_key}{sep}{k}" if parent_key else ""
|
||||
items.extend(flatten_dict(v, next_prefix, sep=sep).items())
|
||||
else:
|
||||
items.append((k, v))
|
||||
full_key = f"{parent_key}{sep}{k}" if parent_key else k
|
||||
items.append((full_key, v))
|
||||
return dict(items)
|
||||
|
||||
flat_config = flatten_dict(self.config)
|
||||
|
||||
|
||||
for k, v in flat_config.items():
|
||||
val = v
|
||||
if isinstance(v, dict):
|
||||
val = "SPECIALIZED"
|
||||
|
||||
|
||||
cleaned_config[k.replace("-", "_")] = val
|
||||
self.parser.set_defaults(**cleaned_config)
|
||||
|
||||
@@ -265,12 +352,14 @@ class Config:
|
||||
self.args, self.unknown_args = self.parser.parse_known_args(args=arg_str)
|
||||
else:
|
||||
self.args, self.unknown_args = self.parser.parse_known_args()
|
||||
|
||||
|
||||
self.device_id = self.args.device
|
||||
|
||||
|
||||
# Map actions
|
||||
if getattr(self.args, "feed", None): self.enabled.append("feed")
|
||||
if getattr(self.args, "explore", None): self.enabled.append("explore")
|
||||
if getattr(self.args, "feed", None):
|
||||
self.enabled.append("feed")
|
||||
if getattr(self.args, "explore", None):
|
||||
self.enabled.append("explore")
|
||||
|
||||
def specialize(self, username):
|
||||
if self.config is None:
|
||||
@@ -291,6 +380,32 @@ class Config:
|
||||
# Handle the case where username itself is a list - we specialize it to the current target
|
||||
self.args.username = [username] if isinstance(self.args.username, list) else username
|
||||
|
||||
def get_plugin_config(self, plugin_name: str) -> dict:
|
||||
"""
|
||||
Retrieves configuration for a specific plugin.
|
||||
First checks the 'plugins' dict. If not found, falls back to flat config values
|
||||
using the plugin_name as a prefix for backward compatibility.
|
||||
"""
|
||||
if self.config and "plugins" in self.config:
|
||||
plugin_dict = self.config["plugins"].get(plugin_name, {})
|
||||
if plugin_dict:
|
||||
return plugin_dict
|
||||
|
||||
# Backward compatibility / flat config fallback
|
||||
# e.g., for "follow" plugin, check if "follow_percentage" exists
|
||||
fallback = {}
|
||||
if hasattr(self.args, f"{plugin_name}_percentage"):
|
||||
fallback["percentage"] = getattr(self.args, f"{plugin_name}_percentage")
|
||||
|
||||
# specific hardcoded fallbacks
|
||||
if plugin_name == "close_friends_guard" and hasattr(self.args, "ignore_close_friends"):
|
||||
fallback["enabled"] = getattr(self.args, "ignore_close_friends")
|
||||
|
||||
if plugin_name == "comment_interaction" and hasattr(self.args, "dry_run_comments"):
|
||||
fallback["dry_run"] = getattr(self.args, "dry_run_comments")
|
||||
|
||||
return fallback
|
||||
|
||||
|
||||
def get_time_last_save(file_path) -> str:
|
||||
try:
|
||||
|
||||
@@ -1,121 +1,137 @@
|
||||
import logging
|
||||
import random
|
||||
import os
|
||||
import re
|
||||
import math
|
||||
import uuid
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from GramAddict.core.qdrant_memory import QdrantBase
|
||||
from GramAddict.core.physics.biomechanics import PhysicsBody, BezierGesture
|
||||
from GramAddict.core.physics.sendevent_injector import SendEventInjector
|
||||
|
||||
from GramAddict.core.physics.biomechanics import BezierGesture, PhysicsBody
|
||||
from GramAddict.core.physics.sendevent_injector import SendEventInjector
|
||||
from GramAddict.core.qdrant_memory import QdrantBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DarwinEngine(QdrantBase):
|
||||
"""
|
||||
Project Singularity: Continuous Bayesian Evolutionary Engine V3 (Proof of Resonance).
|
||||
Determines mathematically how to act on a per-post basis, generating custom
|
||||
Dwell Times and nonlinear scroll sequences to maximize the RL Reward Matrix.
|
||||
"""
|
||||
|
||||
def __init__(self, username: str, config_path: str = "config.yml"):
|
||||
self.username = username
|
||||
self.config_path = config_path
|
||||
super().__init__(collection_name="bot_darwin_mdp_resonance", vector_size=5) # 5 corresponds to behavior_bounds length
|
||||
|
||||
super().__init__(
|
||||
collection_name="bot_darwin_mdp_resonance", vector_size=5
|
||||
) # 5 corresponds to behavior_bounds length
|
||||
|
||||
# We replace naive percentages with Markovian Dwell Behaviors
|
||||
self.behavior_bounds = {
|
||||
"initial_dwell_sec": (1.0, 15.0, 2.0),
|
||||
"scroll_velocity": (0.1, 2.0, 0.3), # 1.0 is normal
|
||||
"scroll_velocity": (0.1, 2.0, 0.3), # 1.0 is normal
|
||||
"back_swipe_prob": (0.0, 0.4, 0.1),
|
||||
"profile_visit_prob": (0.0, 0.8, 0.2),
|
||||
"comment_read_dwell": (0.0, 20.0, 4.0)
|
||||
"comment_read_dwell": (0.0, 20.0, 4.0),
|
||||
}
|
||||
self.current_behavior = {}
|
||||
|
||||
def synthesize_interaction_profile(self, target_resonance: float, text_length: int = 0) -> dict:
|
||||
"""
|
||||
Given an AI aesthetic resonance score (0.0 to 1.0) and caption length,
|
||||
Given an AI aesthetic resonance score (0.0 to 1.0) and caption length,
|
||||
this generates a deterministic topological interaction behavior.
|
||||
"""
|
||||
history = self._get_historical_landscape()
|
||||
epsilon = 0.15 # 15% pure exploration
|
||||
|
||||
epsilon = 0.15 # 15% pure exploration
|
||||
|
||||
if not history or random.random() < epsilon:
|
||||
logger.info("🧬 [Darwin Engine] EXPLORE: Generating chaotic non-linear behavioral vector.")
|
||||
center = {k: (v[0]+v[1])/2 for k, v in self.behavior_bounds.items()}
|
||||
center = {k: (v[0] + v[1]) / 2 for k, v in self.behavior_bounds.items()}
|
||||
self.current_behavior = self._mutate(center)
|
||||
else:
|
||||
# Exploitation: Nearest neighbor matching the resonance profile closely
|
||||
best_node = max(history, key=lambda x: x[1]) # x[1] is the Reward
|
||||
best_node = max(history, key=lambda x: x[1]) # x[1] is the Reward
|
||||
best_params = best_node[0]
|
||||
logger.info(f"🧬 [Darwin Engine] EXPLOIT: Adapting proven behavioral vector from highest Peak Reward ({best_node[1]:.2f}).")
|
||||
logger.info(
|
||||
f"🧬 [Darwin Engine] EXPLOIT: Adapting proven behavioral vector from highest Peak Reward ({best_node[1]:.2f})."
|
||||
)
|
||||
self.current_behavior = self._mutate(best_params)
|
||||
|
||||
|
||||
# Modulate behavior directly by resonance
|
||||
# E.g., if resonance is 0.9 (amazing post), read comments longer!
|
||||
self.current_behavior["initial_dwell_sec"] *= max(0.5, target_resonance * 1.5)
|
||||
self.current_behavior["profile_visit_prob"] *= max(0.2, target_resonance * 2.0)
|
||||
|
||||
|
||||
# ── Generative Dwell-Time ──
|
||||
# Humans take longer to finish "reading" long captions.
|
||||
# Average reading speed is ~15-20 chars per second.
|
||||
if text_length > 20:
|
||||
reading_latency = min(15.0, text_length / 25.0) # Cap extra reading time at 15s
|
||||
logger.debug(f"🧬 [Darwin Engine] Generative Dwell spike: +{reading_latency:.1f}s (Caption: {text_length} chars)")
|
||||
reading_latency = min(15.0, text_length / 25.0) # Cap extra reading time at 15s
|
||||
logger.debug(
|
||||
f"🧬 [Darwin Engine] Generative Dwell spike: +{reading_latency:.1f}s (Caption: {text_length} chars)"
|
||||
)
|
||||
self.current_behavior["initial_dwell_sec"] += reading_latency
|
||||
|
||||
# Clip bounds
|
||||
for k, (b_min, b_max, _) in self.behavior_bounds.items():
|
||||
self.current_behavior[k] = max(b_min, min(b_max, self.current_behavior[k]))
|
||||
|
||||
|
||||
return self.current_behavior
|
||||
|
||||
def execute_proof_of_resonance(self, device, resonance: float, text_length: int = 0, nav_graph=None, zero_engine=None, configs=None, resonance_oracle=None, username=None, context_xml: str = ""):
|
||||
def execute_proof_of_resonance(
|
||||
self,
|
||||
device,
|
||||
resonance: float,
|
||||
text_length: int = 0,
|
||||
nav_graph=None,
|
||||
zero_engine=None,
|
||||
configs=None,
|
||||
resonance_oracle=None,
|
||||
username=None,
|
||||
context_xml: str = "",
|
||||
):
|
||||
"""
|
||||
Translates the mathematical interaction profile directly into device actions
|
||||
Translates the mathematical interaction profile directly into device actions
|
||||
to prove engagement to the platform's anti-bot heuristic algorithm.
|
||||
"""
|
||||
profile = self.synthesize_interaction_profile(resonance, text_length=text_length)
|
||||
|
||||
|
||||
logger.info("🧬 [Darwin MDP] Executing Proof of Resonance Sequence...")
|
||||
|
||||
|
||||
# Pre-compute screen dimensions for all sub-phases
|
||||
info = device.get_info()
|
||||
h = info.get("displayHeight", 2400)
|
||||
w = info.get("displayWidth", 1080)
|
||||
|
||||
|
||||
# 1. Initial Dwell
|
||||
dwell = profile["initial_dwell_sec"]
|
||||
logger.debug(f" -> Dwelling for {dwell:.1f}s")
|
||||
time.sleep(dwell)
|
||||
|
||||
|
||||
# 2. Non-linear cognitive latency (Micro-Jitters)
|
||||
if profile["scroll_velocity"] != 1.0:
|
||||
logger.debug(f" -> Simulating cognitive read latency (Micro-Jitters, Velocity: {profile['scroll_velocity']:.2f})")
|
||||
logger.debug(
|
||||
f" -> Simulating cognitive read latency (Micro-Jitters, Velocity: {profile['scroll_velocity']:.2f})"
|
||||
)
|
||||
body = PhysicsBody.get_session_instance(device)
|
||||
injector = SendEventInjector.get_instance(device)
|
||||
|
||||
# Thumb starts on the right side of the screen to avoid clicking polls/tags in the center
|
||||
cx = int(w * 0.8) + device.cm_to_pixels(random.uniform(-0.3, 0.3))
|
||||
cy = h // 2
|
||||
|
||||
|
||||
# Keep distance microscopic (0.1 to 0.3 cm) so we DO NOT lose visual alignment
|
||||
distance = device.cm_to_pixels(random.uniform(0.1, 0.3))
|
||||
duration = max(0.5, 1.0 / max(0.1, profile["scroll_velocity"]))
|
||||
start_y = int(cy + distance / 2)
|
||||
end_y = int(cy - distance / 2)
|
||||
|
||||
|
||||
# Use Bézier curve for the jitter
|
||||
points = BezierGesture.scroll_curve(
|
||||
(cx, start_y), (cx, end_y), body, n_points=6
|
||||
)
|
||||
points = BezierGesture.scroll_curve((cx, start_y), (cx, end_y), body, n_points=6)
|
||||
timing = BezierGesture.compute_sigmoid_timing(len(points), duration * 1000)
|
||||
|
||||
|
||||
injector.inject_gesture(points, timing, touch_major=body.get_touch_major())
|
||||
|
||||
|
||||
# 3. Micro Back-swipe (The Human Wobble)
|
||||
if random.random() < profile["back_swipe_prob"]:
|
||||
logger.debug(" -> Executing cognitive wobble (Trace swipe)")
|
||||
@@ -124,78 +140,91 @@ class DarwinEngine(QdrantBase):
|
||||
noise_x = device.cm_to_pixels(random.uniform(-0.2, 0.2))
|
||||
cx = w // 2 + device.cm_to_pixels(random.uniform(-0.5, 0.5))
|
||||
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}")
|
||||
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 not self._has_comments(context_xml):
|
||||
logger.debug(" -> 🚫 [Darwin Engine] Skipping comment depth simulation (Post has 0 comments).")
|
||||
else:
|
||||
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:
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
raw.save(buf, format='JPEG')
|
||||
b64_img_payload = [base64.b64encode(buf.getvalue()).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.do("tap comment button")
|
||||
if success:
|
||||
# ---- Phase 10: RAG Comment Extraction ----
|
||||
if configs and resonance_oracle and getattr(configs.args, "ai_learn_comments", False):
|
||||
# Limit scraping to 15% to avoid mechanical persistence
|
||||
if random.random() < 0.05:
|
||||
logger.debug(" -> Dumping UI hierarchy for Comment Extraction...")
|
||||
try:
|
||||
xml_data = device.dump_hierarchy()
|
||||
t0 = time.time()
|
||||
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:
|
||||
time.sleep(remaining_sleep)
|
||||
except Exception as e:
|
||||
logger.error(f" -> Comment extraction failed: {e}")
|
||||
time.sleep(profile["comment_read_dwell"])
|
||||
else:
|
||||
logger.debug(" -> Skipping RAG Extraction (Probabilistic Evasion)")
|
||||
time.sleep(profile["comment_read_dwell"])
|
||||
else:
|
||||
time.sleep(profile["comment_read_dwell"])
|
||||
# ------------------------------------------
|
||||
|
||||
logger.debug(" -> Closing comments section")
|
||||
device.press("back")
|
||||
time.sleep(1.0)
|
||||
# Instead of relying on a fragile bottom_sheet_container ID,
|
||||
# we verify if the feed is visible. If not, the comment sheet is still open (or keyboard).
|
||||
ui_dump = device.dump_hierarchy()
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
telepath = TelepathicEngine.get_instance()
|
||||
if not telepath.find_best_node(ui_dump, "post like button heart", min_confidence=0.4, device=device):
|
||||
logger.debug(" -> Not back on Home feed, pressing back again to close comment sheet/keyboard")
|
||||
device.press("back")
|
||||
time.sleep(1.0)
|
||||
else:
|
||||
logger.debug(f" -> Could not find comment button, falling back to dwell simulation for {profile['comment_read_dwell']:.1f}s")
|
||||
time.sleep(profile["comment_read_dwell"])
|
||||
else:
|
||||
logger.debug(f" -> Simulating comment section processing for {profile['comment_read_dwell']:.1f}s")
|
||||
time.sleep(profile["comment_read_dwell"])
|
||||
|
||||
if nav_graph and zero_engine:
|
||||
if not self._has_comments(context_xml):
|
||||
logger.debug(" -> 🚫 [Darwin Engine] Skipping comment depth simulation (Post has 0 comments).")
|
||||
else:
|
||||
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:
|
||||
import io
|
||||
|
||||
buf = io.BytesIO()
|
||||
raw.save(buf, format="JPEG")
|
||||
b64_img_payload = [base64.b64encode(buf.getvalue()).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.do("tap comment button")
|
||||
if success:
|
||||
# ---- Phase 10: RAG Comment Extraction ----
|
||||
if configs and resonance_oracle and getattr(configs.args, "ai_learn_comments", False):
|
||||
# Limit scraping to 15% to avoid mechanical persistence
|
||||
if random.random() < 0.05:
|
||||
logger.debug(" -> Dumping UI hierarchy for Comment Extraction...")
|
||||
try:
|
||||
xml_data = device.dump_hierarchy()
|
||||
t0 = time.time()
|
||||
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:
|
||||
time.sleep(remaining_sleep)
|
||||
except Exception as e:
|
||||
logger.error(f" -> Comment extraction failed: {e}")
|
||||
time.sleep(profile["comment_read_dwell"])
|
||||
else:
|
||||
logger.debug(" -> Skipping RAG Extraction (Probabilistic Evasion)")
|
||||
time.sleep(profile["comment_read_dwell"])
|
||||
else:
|
||||
time.sleep(profile["comment_read_dwell"])
|
||||
# ------------------------------------------
|
||||
|
||||
logger.debug(" -> Closing comments section")
|
||||
device.press("back")
|
||||
time.sleep(1.0)
|
||||
# Instead of relying on a fragile bottom_sheet_container ID,
|
||||
# we verify if the feed is visible. If not, the comment sheet is still open (or keyboard).
|
||||
ui_dump = device.dump_hierarchy()
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
telepath = TelepathicEngine.get_instance()
|
||||
if not telepath.find_best_node(
|
||||
ui_dump, "post like button heart", min_confidence=0.4, device=device
|
||||
):
|
||||
logger.debug(" -> Not back on Home feed, pressing back again to close comment sheet/keyboard")
|
||||
device.press("back")
|
||||
time.sleep(1.0)
|
||||
else:
|
||||
logger.debug(
|
||||
f" -> Could not find comment button, falling back to dwell simulation for {profile['comment_read_dwell']:.1f}s"
|
||||
)
|
||||
time.sleep(profile["comment_read_dwell"])
|
||||
else:
|
||||
logger.debug(f" -> Simulating comment section processing for {profile['comment_read_dwell']:.1f}s")
|
||||
time.sleep(profile["comment_read_dwell"])
|
||||
|
||||
logger.info("🧬 [Darwin MDP] Interaction sequence completed safely.")
|
||||
return profile
|
||||
|
||||
@@ -211,21 +240,21 @@ class DarwinEngine(QdrantBase):
|
||||
injector = SendEventInjector.get_instance(device)
|
||||
|
||||
info = device.get_info()
|
||||
w = info.get("displayWidth", 1080)
|
||||
info.get("displayWidth", 1080)
|
||||
h = info.get("displayHeight", 2400)
|
||||
|
||||
# Start position from body (session-aware)
|
||||
cx, cy = body.get_scroll_start()
|
||||
# Override Y to center for wobble
|
||||
cy = h // 2
|
||||
|
||||
|
||||
# Fatigue scales wobble amplitude (tired = more sloppy)
|
||||
amplitude = 1.0 + body.fatigue * 0.5
|
||||
|
||||
|
||||
# Keep the shift small but above Android's touch slop threshold (~8dp)
|
||||
y_shift = device.cm_to_pixels(random.uniform(0.3, 0.6) * amplitude) * random.choice([1, -1])
|
||||
x_shift = device.cm_to_pixels(random.uniform(-0.2, 0.2) * amplitude)
|
||||
|
||||
|
||||
# Handedness bias: right-handers wobble right-down, left-handers left-down
|
||||
if body.handedness == "right":
|
||||
x_shift += device.cm_to_pixels(random.uniform(0, 0.1))
|
||||
@@ -235,22 +264,15 @@ class DarwinEngine(QdrantBase):
|
||||
end_x = int(cx + x_shift)
|
||||
end_y = int(cy + y_shift)
|
||||
|
||||
points = BezierGesture.scroll_curve(
|
||||
(cx, cy), (end_x, end_y), body, n_points=5
|
||||
)
|
||||
points = BezierGesture.scroll_curve((cx, cy), (end_x, end_y), body, n_points=5)
|
||||
duration_ms = random.uniform(150, 300)
|
||||
timing = BezierGesture.compute_sigmoid_timing(len(points), duration_ms)
|
||||
|
||||
|
||||
injector.inject_gesture(points, timing, touch_major=body.get_touch_major())
|
||||
|
||||
def _get_historical_landscape(self):
|
||||
try:
|
||||
records = self.client.scroll(
|
||||
collection_name=self.collection_name,
|
||||
limit=1000,
|
||||
with_payload=True
|
||||
)[0]
|
||||
records = self.client.scroll(collection_name=self.collection_name, limit=1000, with_payload=True)[0]
|
||||
return [(r.payload.get("params", {}), r.payload.get("reward", 0.0)) for r in records]
|
||||
except Exception:
|
||||
return []
|
||||
@@ -265,25 +287,28 @@ class DarwinEngine(QdrantBase):
|
||||
|
||||
def select_arm_and_apply(self, args):
|
||||
"""
|
||||
Multi-Armed Bandit (MAB) logic to select the most promising behavioral
|
||||
Multi-Armed Bandit (MAB) logic to select the most promising behavioral
|
||||
mutation strategy for the current account phase.
|
||||
"""
|
||||
logger.info(f"🧬 [Darwin Engine] Applying MDP State channel for @{self.username}...")
|
||||
self.synthesize_interaction_profile(target_resonance=0.5) # Initial neutral bias
|
||||
self.synthesize_interaction_profile(target_resonance=0.5) # Initial neutral bias
|
||||
|
||||
def evaluate_session_end(self, duration_minutes: float, followers_gained: int):
|
||||
if duration_minutes <= 0: duration_minutes = 1.0
|
||||
if duration_minutes <= 0:
|
||||
duration_minutes = 1.0
|
||||
reward = (followers_gained / duration_minutes) * 10.0
|
||||
logger.info(f"🧬 [Darwin Engine] Session Evaluation: {followers_gained} followers gained in {duration_minutes:.1f}m. Reward: {reward:.2f}")
|
||||
logger.info(
|
||||
f"🧬 [Darwin Engine] Session Evaluation: {followers_gained} followers gained in {duration_minutes:.1f}m. Reward: {reward:.2f}"
|
||||
)
|
||||
self.emit_reward_signal(followers_gained=followers_gained, block_warnings_seen=0)
|
||||
|
||||
def emit_reward_signal(self, followers_gained: int, block_warnings_seen: int):
|
||||
if not self.current_behavior:
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
reward = followers_gained - (block_warnings_seen * 50)
|
||||
|
||||
|
||||
vector = []
|
||||
for k, (p_min, p_max, _) in self.behavior_bounds.items():
|
||||
val = self.current_behavior.get(k, p_min)
|
||||
@@ -298,24 +323,24 @@ class DarwinEngine(QdrantBase):
|
||||
"username": self.username,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"params": self.current_behavior,
|
||||
"reward": reward
|
||||
"reward": reward,
|
||||
},
|
||||
log_success=f"🧬 [Darwin Engine V3] MDP Reward Matrix stored. Reward Value: {reward:.2f}"
|
||||
log_success=f"🧬 [Darwin Engine V3] MDP Reward Matrix stored. Reward Value: {reward:.2f}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"🧬 [Darwin Engine] Failed to record reward: {e}")
|
||||
|
||||
def _has_comments(self, xml_string: str) -> bool:
|
||||
"""
|
||||
Heuristic to check if a post actually has comments to read.
|
||||
Heuristic to check if a post actually has comments to read.
|
||||
If it has 0 comments, checking them is suspicious bot behavior.
|
||||
"""
|
||||
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):
|
||||
if re.search(r"\b0\s*kommentare?\b", low_xml) or 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):
|
||||
return True
|
||||
@@ -323,15 +348,13 @@ class DarwinEngine(QdrantBase):
|
||||
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)
|
||||
has_number_of_comments = re.search(r"\b([1-9][0-9.,]*)\s*(?:comment(?:s)?|kommentare?)\b", low_xml)
|
||||
if has_number_of_comments:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import uiautomator2 as u2
|
||||
from time import sleep, time
|
||||
from random import uniform
|
||||
from GramAddict.core.utils import random_sleep
|
||||
from functools import wraps
|
||||
from random import uniform
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.physics.biomechanics import PhysicsBody, BezierGesture
|
||||
import uiautomator2 as u2
|
||||
|
||||
from GramAddict.core.physics.biomechanics import BezierGesture, PhysicsBody
|
||||
from GramAddict.core.physics.sendevent_injector import SendEventInjector
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def adb_retry(retries=3, delay=2.0):
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
@@ -28,18 +26,38 @@ def adb_retry(retries=3, delay=2.0):
|
||||
sleep(delay * (attempt + 1)) # Exponential backoff
|
||||
logger.error(f"❌ ADB action {func.__name__} failed after {retries} retries. Crashing gracefully.")
|
||||
raise last_err
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def create_device(device_id, app_id, args=None):
|
||||
try:
|
||||
return DeviceFacade(device_id, app_id, args)
|
||||
except Exception as e:
|
||||
str(e)
|
||||
err_type = str(type(e))
|
||||
if (
|
||||
"ConnectError" in err_type
|
||||
or "ConnectionRefusedError" in err_type
|
||||
or "ConnectionError" in err_type
|
||||
or "Timeout" in err_type
|
||||
):
|
||||
logger.error(f"⚠️ [ADB ConnectError] Could not connect to device '{device_id}'.")
|
||||
logger.error("👉 Please verify:")
|
||||
logger.error(" 1. Your phone is connected via USB or Wi-Fi.")
|
||||
logger.error(" 2. 'USB Debugging' is enabled in Developer Options.")
|
||||
logger.error(" 3. You have authorized this computer on your phone's screen.")
|
||||
logger.error(" 4. The adb server is running ('adb devices').")
|
||||
raise SystemExit(1)
|
||||
|
||||
logger.error(f"Failed to create device: {e}")
|
||||
# We don't want to just return None and crash later.
|
||||
# We don't want to just return None and crash later.
|
||||
# We should raise so the orchestrator knows it's a fatal boot error.
|
||||
raise e
|
||||
|
||||
|
||||
def get_device_info(device):
|
||||
if not device or not device.deviceV2:
|
||||
logger.error("Cannot get device info: Device not initialized.")
|
||||
@@ -47,32 +65,29 @@ def get_device_info(device):
|
||||
info = device.info
|
||||
logger.debug(f"Device Info: {info.get('productName')} | SDK: {info.get('sdkInt')}")
|
||||
|
||||
|
||||
class DeviceFacade:
|
||||
deviceV2 = None
|
||||
app_id = None
|
||||
device_id = None
|
||||
|
||||
|
||||
def __init__(self, device_id, app_id, args):
|
||||
self.device_id = device_id
|
||||
self.app_id = app_id
|
||||
self.args = args
|
||||
self.deviceV2 = u2.connect(device_id)
|
||||
|
||||
|
||||
# Configure uiautomator2
|
||||
self.deviceV2.settings["wait_timeout"] = 3.0
|
||||
self.deviceV2.settings["post_delay"] = 0.5
|
||||
|
||||
|
||||
# System dialog handler (language-agnostic via resource-id, not text)
|
||||
try:
|
||||
# u2 v3.x: named watchers with xpath selectors
|
||||
# android:id/aerr_close = App crash "Close" button (all languages)
|
||||
self.deviceV2.watcher("crash_dialog").when(
|
||||
xpath='//*[@resource-id="android:id/aerr_close"]'
|
||||
).click()
|
||||
self.deviceV2.watcher("crash_dialog").when(xpath='//*[@resource-id="android:id/aerr_close"]').click()
|
||||
# android:id/button1 = positive system dialog button (all languages)
|
||||
self.deviceV2.watcher("system_dialog").when(
|
||||
xpath='//*[@resource-id="android:id/button1"]'
|
||||
).click()
|
||||
self.deviceV2.watcher("system_dialog").when(xpath='//*[@resource-id="android:id/button1"]').click()
|
||||
self.deviceV2.watcher.start()
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not start system watcher: {e}")
|
||||
@@ -88,7 +103,7 @@ class DeviceFacade:
|
||||
@adb_retry()
|
||||
def cm_to_pixels(self, cm: float) -> int:
|
||||
info = self.deviceV2.info
|
||||
dpx = info.get("displaySizeDpX", 400)
|
||||
dpx = info.get("displaySizeDpX", 400)
|
||||
width = info.get("displayWidth", 1080)
|
||||
# Android baseline: 1 dp = 1/160 inch. 1 inch = 2.54 cm
|
||||
# PPCM (Pixels Per CM) = (width / dpx) * (160 / 2.54)
|
||||
@@ -106,7 +121,6 @@ class DeviceFacade:
|
||||
def unlock(self):
|
||||
self.deviceV2.unlock()
|
||||
|
||||
|
||||
@property
|
||||
def info(self):
|
||||
return self.deviceV2.info
|
||||
@@ -132,7 +146,8 @@ class DeviceFacade:
|
||||
def swipe(self, sx, sy, ex, ey, duration=None):
|
||||
"""Pass-through strictly for non-biological bezier swiping (e.g., darwin_engine noise correction)"""
|
||||
kwargs = {}
|
||||
if duration is not None: kwargs["duration"] = duration
|
||||
if duration is not None:
|
||||
kwargs["duration"] = duration
|
||||
self.deviceV2.swipe(sx, sy, ex, ey, **kwargs)
|
||||
|
||||
@adb_retry()
|
||||
@@ -146,14 +161,14 @@ class DeviceFacade:
|
||||
@adb_retry()
|
||||
def click(self, x=None, y=None, obj=None):
|
||||
if obj:
|
||||
if isinstance(obj, dict) and 'x' in obj and 'y' in obj:
|
||||
self.human_click(obj['x'], obj['y'])
|
||||
if isinstance(obj, dict) and "x" in obj and "y" in obj:
|
||||
self.human_click(obj["x"], obj["y"])
|
||||
return
|
||||
try:
|
||||
left, top, right, bottom = obj.bounds()
|
||||
w = right - left
|
||||
h = bottom - top
|
||||
|
||||
|
||||
# Biological fingerprint via PhysicsBody
|
||||
body = PhysicsBody.get_session_instance(self)
|
||||
# Thumb bias: right-handers land slightly left-below center
|
||||
@@ -163,20 +178,21 @@ class DeviceFacade:
|
||||
else:
|
||||
cx_base = left + (w * 0.55)
|
||||
cy_base = top + (h * 0.55)
|
||||
|
||||
|
||||
from random import gauss
|
||||
|
||||
# Fatigue increases spread
|
||||
fatigue_mult = 1.0 + body.fatigue * 0.3
|
||||
sigma_x = max(1, w * 0.15 * fatigue_mult)
|
||||
sigma_y = max(1, h * 0.15 * fatigue_mult)
|
||||
|
||||
|
||||
cx = int(gauss(cx_base, sigma_x))
|
||||
cy = int(gauss(cy_base, sigma_y))
|
||||
|
||||
|
||||
# Math constraint to ensure it physically lands on the button
|
||||
cx = max(left + 1, min(cx, right - 1))
|
||||
cy = max(top + 1, min(cy, bottom - 1))
|
||||
|
||||
|
||||
self.human_click(cx, cy)
|
||||
except Exception as e:
|
||||
logger.debug(f"Bounds extraction failed, fallback to native click: {e}")
|
||||
@@ -193,7 +209,6 @@ class DeviceFacade:
|
||||
self.deviceV2.shell(f"input tap {int(x)} {int(y)}")
|
||||
return
|
||||
|
||||
from random import uniform
|
||||
try:
|
||||
body = PhysicsBody.get_session_instance(self)
|
||||
injector = SendEventInjector.get_instance(self)
|
||||
@@ -201,7 +216,6 @@ class DeviceFacade:
|
||||
tap_duration = uniform(40, 90)
|
||||
timing = BezierGesture.compute_sigmoid_timing(len(points), tap_duration)
|
||||
|
||||
|
||||
injector.inject_gesture(points, timing, touch_major=body.get_touch_major())
|
||||
except Exception as e:
|
||||
logger.debug(f"human_click biomechanics failed, fallback: {e}")
|
||||
@@ -234,18 +248,17 @@ class DeviceFacade:
|
||||
try:
|
||||
body = PhysicsBody.get_session_instance(self)
|
||||
injector = SendEventInjector.get_instance(self)
|
||||
|
||||
|
||||
# Use scroll_curve for vertical swipes, horizontal_swipe_curve for horizontal
|
||||
is_horizontal = abs(end_x - start_x) > abs(end_y - start_y)
|
||||
if is_horizontal:
|
||||
points = BezierGesture.horizontal_swipe_curve((start_x, start_y), (end_x, end_y), body)
|
||||
else:
|
||||
points = BezierGesture.scroll_curve((start_x, start_y), (end_x, end_y), body)
|
||||
|
||||
|
||||
# Use fling timing (J-curve) to ensure high terminal velocity so Android scroll physics works natively
|
||||
timing = BezierGesture.compute_fling_timing(len(points), dur_ms)
|
||||
|
||||
|
||||
injector.inject_gesture(points, timing, touch_major=body.get_touch_major())
|
||||
except Exception as e:
|
||||
logger.debug(f"human_swipe biomechanics failed, fallback to native swipe: {e}")
|
||||
@@ -263,14 +276,14 @@ class DeviceFacade:
|
||||
pkg = self.deviceV2.app_current().get("package")
|
||||
if pkg == self.app_id:
|
||||
return pkg
|
||||
|
||||
|
||||
# Brief retry: many false positives come from <500ms notification banners
|
||||
# A single short wait handles ALL transient overlays regardless of source app
|
||||
sleep(0.5)
|
||||
pkg = self.deviceV2.app_current().get("package")
|
||||
|
||||
|
||||
# If still not our app, check if it's just SystemUI (always present, never a real takeover)
|
||||
if pkg in ('com.android.systemui', 'android'):
|
||||
if pkg in ("com.android.systemui", "android"):
|
||||
return self.app_id
|
||||
|
||||
return pkg
|
||||
@@ -284,38 +297,41 @@ class DeviceFacade:
|
||||
def dump_hierarchy(self):
|
||||
# Compressed=True dramatically speeds up UIAutomator2 dumps by skipping invisible elements!
|
||||
xml = self.deviceV2.dump_hierarchy(compressed=True)
|
||||
|
||||
|
||||
# Continuous Session Tracing
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
if not hasattr(self, "_trace_counter"):
|
||||
self._trace_counter = 0
|
||||
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
self._trace_dir = os.path.join("debug", "session_traces", ts)
|
||||
os.makedirs(self._trace_dir, exist_ok=True)
|
||||
|
||||
|
||||
self._trace_counter += 1
|
||||
trace_path = os.path.join(self._trace_dir, f"{self._trace_counter:05d}.xml")
|
||||
with open(trace_path, "w", encoding="utf-8") as f:
|
||||
f.write(xml)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to write session trace: {e}")
|
||||
|
||||
|
||||
return xml
|
||||
|
||||
@adb_retry()
|
||||
def get_screenshot_b64(self):
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
||||
img = self.deviceV2.screenshot()
|
||||
buffered = BytesIO()
|
||||
img.save(buffered, format="JPEG", quality=70) # Compressed for target latency
|
||||
return base64.b64encode(buffered.getvalue()).decode('utf-8')
|
||||
img.save(buffered, format="JPEG", quality=70) # Compressed for target latency
|
||||
return base64.b64encode(buffered.getvalue()).decode("utf-8")
|
||||
|
||||
# Telepathic Semantic UI Integration
|
||||
@adb_retry()
|
||||
def find_semantic(self, intent_description: str):
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
engine = TelepathicEngine.get_instance()
|
||||
xml = self.dump_hierarchy()
|
||||
# Passing self (DeviceFacade) enables the Vision Cortex VLM fallback
|
||||
|
||||
@@ -9,9 +9,10 @@ and a structured reason tag for easy triage.
|
||||
|
||||
Retention: Keeps the last 50 dumps per reason category to avoid disk bloat.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -23,7 +24,7 @@ MAX_DUMPS_PER_CATEGORY = 50
|
||||
def dump_ui_state(device, reason: str, extra_context: dict = None):
|
||||
"""
|
||||
Capture and save the current UI hierarchy to disk for debugging.
|
||||
|
||||
|
||||
Args:
|
||||
device: The uiautomator2 device facade.
|
||||
reason: Short tag for the failure type. Used for filename grouping.
|
||||
@@ -33,20 +34,20 @@ def dump_ui_state(device, reason: str, extra_context: dict = None):
|
||||
"""
|
||||
try:
|
||||
os.makedirs(DUMP_DIR, exist_ok=True)
|
||||
|
||||
|
||||
# Capture hierarchy
|
||||
xml = device.dump_hierarchy()
|
||||
|
||||
|
||||
# Generate filename: reason__2026-04-13_17-41-39.xml
|
||||
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
safe_reason = reason.replace(" ", "_").replace("/", "_")[:40]
|
||||
filename = f"{safe_reason}__{ts}.xml"
|
||||
filepath = os.path.join(DUMP_DIR, filename)
|
||||
|
||||
|
||||
# Write XML
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(xml)
|
||||
|
||||
|
||||
# Write companion metadata JSON
|
||||
meta = {
|
||||
"reason": reason,
|
||||
@@ -56,7 +57,9 @@ def dump_ui_state(device, reason: str, extra_context: dict = None):
|
||||
# Capture the session log if available
|
||||
try:
|
||||
import shutil
|
||||
|
||||
from GramAddict.core.log import get_log_file_config
|
||||
|
||||
log_name, log_dir, _, _ = get_log_file_config()
|
||||
if log_name and log_dir:
|
||||
active_log = os.path.join(log_dir, log_name)
|
||||
@@ -69,18 +72,18 @@ def dump_ui_state(device, reason: str, extra_context: dict = None):
|
||||
|
||||
if extra_context:
|
||||
meta["context"] = extra_context
|
||||
|
||||
|
||||
meta_path = filepath.replace(".xml", ".meta.json")
|
||||
with open(meta_path, "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
logger.info(f"📸 [Diagnostic] UI state and session log dumped for '{reason}': {filepath}")
|
||||
|
||||
|
||||
# Rotate old dumps for this category
|
||||
_rotate_dumps(safe_reason)
|
||||
|
||||
|
||||
return filepath
|
||||
|
||||
|
||||
except Exception as e:
|
||||
# Dumping must NEVER crash the bot
|
||||
logger.debug(f"[Diagnostic] Could not dump UI state: {e}")
|
||||
@@ -90,13 +93,10 @@ def dump_ui_state(device, reason: str, extra_context: dict = None):
|
||||
def _rotate_dumps(category_prefix: str):
|
||||
"""Keep only the last MAX_DUMPS_PER_CATEGORY dumps per category."""
|
||||
try:
|
||||
all_files = sorted([
|
||||
f for f in os.listdir(DUMP_DIR)
|
||||
if f.startswith(category_prefix) and f.endswith(".xml")
|
||||
])
|
||||
|
||||
all_files = sorted([f for f in os.listdir(DUMP_DIR) if f.startswith(category_prefix) and f.endswith(".xml")])
|
||||
|
||||
if len(all_files) > MAX_DUMPS_PER_CATEGORY:
|
||||
files_to_remove = all_files[:len(all_files) - MAX_DUMPS_PER_CATEGORY]
|
||||
files_to_remove = all_files[: len(all_files) - MAX_DUMPS_PER_CATEGORY]
|
||||
for f in files_to_remove:
|
||||
xml_path = os.path.join(DUMP_DIR, f)
|
||||
meta_path = xml_path.replace(".xml", ".meta.json")
|
||||
|
||||
@@ -1,31 +1,37 @@
|
||||
import logging
|
||||
import random
|
||||
|
||||
from colorama import Fore, Style
|
||||
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_state, current_target, cognitive_stack):
|
||||
"""
|
||||
Executes the autonomous Direct Messaging logic in the Zero-Latency architecture.
|
||||
Assumes the bot is already at the "MessageInbox" UI state.
|
||||
"""
|
||||
logger.info(f"🧠 [DM Engine] Initiating inbox processing in {current_target}...", extra={"color": f"{Style.BRIGHT}{Fore.CYAN}"})
|
||||
|
||||
logger.info(
|
||||
f"🧠 [DM Engine] Initiating inbox processing in {current_target}...",
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.CYAN}"},
|
||||
)
|
||||
|
||||
telepathic = cognitive_stack.get("telepathic")
|
||||
dopamine = cognitive_stack.get("dopamine")
|
||||
crm = cognitive_stack.get("crm")
|
||||
|
||||
from GramAddict.core.bot_flow import sleep, dump_ui_state, _humanized_click
|
||||
|
||||
from GramAddict.core.bot_flow import _humanized_click, sleep
|
||||
from GramAddict.core.llm_provider import query_llm
|
||||
from GramAddict.core.stealth_typing import ghost_type
|
||||
|
||||
|
||||
# Initialize session limits if missing
|
||||
if not hasattr(session_state, 'totalMessages'):
|
||||
if not hasattr(session_state, "totalMessages"):
|
||||
session_state.totalMessages = 0
|
||||
|
||||
|
||||
failed_attempts = 0
|
||||
|
||||
|
||||
while not dopamine.is_app_session_over():
|
||||
# Limits check
|
||||
limit_val = session_state.check_limit(SessionState.Limit.PM)
|
||||
@@ -34,79 +40,97 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
elif limit_val is True:
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
|
||||
|
||||
try:
|
||||
xml_dump = device.dump_hierarchy()
|
||||
|
||||
|
||||
# Step 1: Find unread conversation threads
|
||||
unread_threads = telepathic._extract_semantic_nodes(xml_dump, "find unread message threads or unread badges", threshold=0.7)
|
||||
|
||||
unread_threads = telepathic._extract_semantic_nodes(
|
||||
xml_dump, "find unread message threads or unread badges", threshold=0.7
|
||||
)
|
||||
|
||||
if unread_threads and not unread_threads[0].get("skip"):
|
||||
target_node = unread_threads[0]
|
||||
logger.info(f"📨 Found unread message thread. Opening.")
|
||||
logger.info("📨 Found unread message thread. Opening.")
|
||||
_humanized_click(device, target_node["x"], target_node["y"])
|
||||
sleep(2.0)
|
||||
|
||||
|
||||
# Step 2: Read the conversation context
|
||||
thread_xml = device.dump_hierarchy()
|
||||
msg_nodes = telepathic._extract_semantic_nodes(thread_xml, "find the last received message text", threshold=0.6)
|
||||
|
||||
msg_nodes = telepathic._extract_semantic_nodes(
|
||||
thread_xml, "find the last received message text", threshold=0.6
|
||||
)
|
||||
|
||||
context_text = "No previous context"
|
||||
if msg_nodes and not msg_nodes[0].get("skip") and msg_nodes[0].get("text"):
|
||||
context_text = msg_nodes[0].get("text")
|
||||
|
||||
|
||||
logger.debug(f"Last received message context: {context_text}")
|
||||
|
||||
|
||||
# Verify we aren't at limits before sending
|
||||
if not getattr(configs.args, "disable_ai_messaging", False):
|
||||
# Configure models
|
||||
model = getattr(configs.args, "ai_condenser_model", "llama3.2:1b")
|
||||
url = getattr(configs.args, "ai_condenser_url", "http://localhost:11434/api/generate")
|
||||
|
||||
|
||||
# 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."
|
||||
|
||||
response_dict = query_llm(url=url, model=model, prompt=prompt, format_json=False, timeout=120, max_tokens=100, temperature=0.7)
|
||||
|
||||
|
||||
response_dict = query_llm(
|
||||
url=url,
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
format_json=False,
|
||||
timeout=120,
|
||||
max_tokens=100,
|
||||
temperature=0.7,
|
||||
)
|
||||
|
||||
if response_dict and "response" in response_dict:
|
||||
response_text = response_dict["response"].strip()
|
||||
# Find the input field
|
||||
input_nodes = telepathic._extract_semantic_nodes(thread_xml, "find the message input text field", threshold=0.7)
|
||||
input_nodes = telepathic._extract_semantic_nodes(
|
||||
thread_xml, "find the message input text field", threshold=0.7
|
||||
)
|
||||
if input_nodes and not input_nodes[0].get("skip"):
|
||||
in_node = input_nodes[0]
|
||||
_humanized_click(device, in_node["x"], in_node["y"])
|
||||
sleep(1.0)
|
||||
|
||||
|
||||
# Type the message
|
||||
ghost_type(device, response_text, speed="fast")
|
||||
sleep(1.0)
|
||||
|
||||
|
||||
# Find Send button
|
||||
send_xml = device.dump_hierarchy()
|
||||
send_nodes = telepathic._extract_semantic_nodes(send_xml, "find the send message button", threshold=0.8)
|
||||
|
||||
send_nodes = telepathic._extract_semantic_nodes(
|
||||
send_xml, "find the send message button", threshold=0.8
|
||||
)
|
||||
|
||||
if send_nodes and not send_nodes[0].get("skip"):
|
||||
s_node = send_nodes[0]
|
||||
_humanized_click(device, s_node["x"], s_node["y"])
|
||||
logger.info("✅ [DM Engine] Successfully sent a generated reply.", extra={"color": Fore.GREEN})
|
||||
|
||||
logger.info(
|
||||
"✅ [DM Engine] Successfully sent a generated reply.", extra={"color": Fore.GREEN}
|
||||
)
|
||||
|
||||
session_state.totalMessages += 1
|
||||
if crm:
|
||||
crm.log_sent_dm("unknown_target", response_text, "", [])
|
||||
|
||||
|
||||
# Return back to inbox
|
||||
device.press("back")
|
||||
sleep(1.0)
|
||||
|
||||
|
||||
dopamine.boredom += random.uniform(5.0, 15.0)
|
||||
failed_attempts = 0
|
||||
else:
|
||||
logger.info("📭 No unread threads found. Inbox clear.")
|
||||
dopamine.boredom += 50.0 # Inbox clear = massive boredom = change feed
|
||||
|
||||
|
||||
if dopamine.wants_to_change_feed() or dopamine.boredom >= 100:
|
||||
logger.info("🧠 [DM Engine] Interaction complete. Transitioning back from inbox.")
|
||||
device.press("back") # Go back from inbox
|
||||
device.press("back") # Go back from inbox
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
|
||||
except Exception as e:
|
||||
@@ -114,9 +138,9 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
|
||||
device.press("back")
|
||||
failed_attempts += 1
|
||||
if failed_attempts > 2:
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
if dopamine.is_app_session_over():
|
||||
return "SESSION_OVER"
|
||||
|
||||
|
||||
return "FEED_EXHAUSTED"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
from colorama import Fore
|
||||
|
||||
# Import existing VLM engine and Qdrant DB for operations
|
||||
@@ -12,17 +11,19 @@ from GramAddict.core.qdrant_memory import HeuristicMemoryDB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DojoEngine:
|
||||
"""
|
||||
Project Dojo: The Data Engine.
|
||||
Handles asynchronous learning from failures (Prediction Errors).
|
||||
Instead of blocking the bot when an element is not found, the bot
|
||||
offloads the snapshot to this queue. The DojoEngine recompiles the
|
||||
offloads the snapshot to this queue. The DojoEngine recompiles the
|
||||
heuristic using a heavy VLM model in the background and updates the DB.
|
||||
"Never make a mistake twice."
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls, device=None):
|
||||
if cls._instance is None:
|
||||
@@ -43,7 +44,9 @@ class DojoEngine:
|
||||
self.is_running = True
|
||||
self.worker_thread = threading.Thread(target=self._process_queue, daemon=True)
|
||||
self.worker_thread.start()
|
||||
logger.info("⛩️ [Dojo Data Engine] Background learning pipeline initialized.", extra={"color": f"{Fore.CYAN}"})
|
||||
logger.info(
|
||||
"⛩️ [Dojo Data Engine] Background learning pipeline initialized.", extra={"color": f"{Fore.CYAN}"}
|
||||
)
|
||||
|
||||
def stop(self):
|
||||
self.is_running = False
|
||||
@@ -58,10 +61,13 @@ class DojoEngine:
|
||||
"name": heuristic_name,
|
||||
"xml": context_xml,
|
||||
"intent": intent_prompt,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
self.learning_queue.put(snapshot)
|
||||
logger.info(f"⛩️ [Dojo] Snapshot for '{heuristic_name}' enqueued for shadow-compilation.", extra={"color": f"{Fore.CYAN}"})
|
||||
logger.info(
|
||||
f"⛩️ [Dojo] Snapshot for '{heuristic_name}' enqueued for shadow-compilation.",
|
||||
extra={"color": f"{Fore.CYAN}"},
|
||||
)
|
||||
|
||||
def _process_queue(self):
|
||||
"""
|
||||
@@ -71,24 +77,29 @@ class DojoEngine:
|
||||
try:
|
||||
# Wait for a job
|
||||
snapshot = self.learning_queue.get(timeout=5.0)
|
||||
h_name = snapshot['name']
|
||||
xml = snapshot['xml']
|
||||
intent = snapshot['intent']
|
||||
|
||||
h_name = snapshot["name"]
|
||||
xml = snapshot["xml"]
|
||||
intent = snapshot["intent"]
|
||||
|
||||
logger.info(f"⛩️ [Dojo] Processing auto-labeling job: {h_name}...", extra={"color": f"{Fore.CYAN}"})
|
||||
|
||||
|
||||
# Heavy compilation
|
||||
new_rule = self.compiler.generate_heuristic(intent, xml)
|
||||
|
||||
|
||||
if new_rule:
|
||||
# Overwrite legacy rule in Database (Fleet update)
|
||||
self.db.cache_heuristic(h_name, new_rule)
|
||||
logger.info(f"⛩️ [Dojo] SUCCESS! Fleet Memory updated with robust heuristic for '{h_name}'.", extra={"color": f"{Fore.GREEN}"})
|
||||
logger.info(
|
||||
f"⛩️ [Dojo] SUCCESS! Fleet Memory updated with robust heuristic for '{h_name}'.",
|
||||
extra={"color": f"{Fore.GREEN}"},
|
||||
)
|
||||
else:
|
||||
logger.warning(f"⛩️ [Dojo] FAILED to compile robust heuristic for '{h_name}'.", extra={"color": f"{Fore.RED}"})
|
||||
|
||||
logger.warning(
|
||||
f"⛩️ [Dojo] FAILED to compile robust heuristic for '{h_name}'.", extra={"color": f"{Fore.RED}"}
|
||||
)
|
||||
|
||||
self.learning_queue.task_done()
|
||||
|
||||
|
||||
except queue.Empty:
|
||||
continue
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,41 +1,50 @@
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
|
||||
from colorama import Fore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DopamineEngine:
|
||||
"""
|
||||
Simulation of human neurochemistry.
|
||||
Manages boredom levels and interest-based interaction pacing.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.boredom = 0.0 # 0.0 to 100.0
|
||||
self.boredom = 0.0 # 0.0 to 100.0
|
||||
self.spike_threshold = 7.0
|
||||
self.homeostasis_rate = 0.05 # decay per minute
|
||||
self.homeostasis_rate = 0.05 # decay per minute
|
||||
self.last_spike = time.time()
|
||||
self.session_start = time.time()
|
||||
self.session_limit_seconds = random.uniform(10 * 60, 35 * 60) # 10-35 mins session
|
||||
|
||||
self.session_limit_seconds = random.uniform(10 * 60, 35 * 60) # 10-35 mins session
|
||||
|
||||
def process_content(self, classification: dict):
|
||||
"""
|
||||
classification: {'quality': 'high'|'low', 'type': 'meme'|'aesthetic'|'ad', 'score': 0-10}
|
||||
"""
|
||||
score = classification.get("score", 5.0)
|
||||
quality = classification.get("quality", "medium")
|
||||
|
||||
|
||||
# Calculate spike
|
||||
spike = score * 1.5 if quality == "high" else score * 0.5
|
||||
|
||||
|
||||
# Update boredom: negative correlation with high quality content
|
||||
if spike > self.spike_threshold:
|
||||
self.boredom = max(0.0, self.boredom - (spike * 0.2))
|
||||
logger.info(f"💉 [Dopamine] Spike detected! Interest high. Boredom decreased to {self.boredom:.1f}%", extra={"color": f"{Fore.YELLOW}"})
|
||||
logger.info(
|
||||
f"💉 [Dopamine] Spike detected! Interest high. Boredom decreased to {self.boredom:.1f}%",
|
||||
extra={"color": f"{Fore.YELLOW}"},
|
||||
)
|
||||
else:
|
||||
self.boredom = min(100.0, self.boredom + 5.0)
|
||||
logger.info(f"💉 [Dopamine] Low interest content. Boredom increased to {self.boredom:.1f}%", extra={"color": f"{Fore.YELLOW}"})
|
||||
|
||||
logger.info(
|
||||
f"💉 [Dopamine] Low interest content. Boredom increased to {self.boredom:.1f}%",
|
||||
extra={"color": f"{Fore.YELLOW}"},
|
||||
)
|
||||
|
||||
self.last_spike = time.time()
|
||||
return self.is_bored()
|
||||
|
||||
@@ -52,13 +61,13 @@ class DopamineEngine:
|
||||
self.boredom = max(70.0, self.boredom - 1.5)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def wants_to_change_feed(self):
|
||||
# Engage context shift if highly bored
|
||||
if 80.0 < self.boredom < 100.0:
|
||||
return random.random() < 0.4
|
||||
return False
|
||||
|
||||
|
||||
def reset_boredom(self, decay=0.2):
|
||||
"""
|
||||
Resets boredom after a successful context shift.
|
||||
@@ -66,7 +75,10 @@ class DopamineEngine:
|
||||
"""
|
||||
old = self.boredom
|
||||
self.boredom = max(0.0, self.boredom * decay)
|
||||
logger.info(f"💉 [Dopamine] Context shifted. Boredom cooled: {old:.1f}% -> {self.boredom:.1f}%", extra={"color": f"{Fore.YELLOW}"})
|
||||
logger.info(
|
||||
f"💉 [Dopamine] Context shifted. Boredom cooled: {old:.1f}% -> {self.boredom:.1f}%",
|
||||
extra={"color": f"{Fore.YELLOW}"},
|
||||
)
|
||||
|
||||
def reset_session(self):
|
||||
"""
|
||||
@@ -76,7 +88,9 @@ class DopamineEngine:
|
||||
self.session_start = time.time()
|
||||
self.last_spike = time.time()
|
||||
self.session_limit_seconds = random.uniform(10 * 60, 35 * 60)
|
||||
logger.info("💉 [Dopamine] Session limits and neurochemistry reset to baseline.", extra={"color": f"{Fore.YELLOW}"})
|
||||
logger.info(
|
||||
"💉 [Dopamine] Session limits and neurochemistry reset to baseline.", extra={"color": f"{Fore.YELLOW}"}
|
||||
)
|
||||
|
||||
def is_app_session_over(self):
|
||||
# True if we have scrolled too long or hit absolute burnout
|
||||
@@ -88,9 +102,9 @@ class DopamineEngine:
|
||||
High dopamine (high interest) = longer viewing time.
|
||||
"""
|
||||
if base_score > 8:
|
||||
return random.uniform(2.0, 4.0) # Entranced
|
||||
return random.uniform(2.0, 4.0) # Entranced
|
||||
if base_score < 3:
|
||||
return random.uniform(0.1, 0.4) # Fast-swipe
|
||||
return random.uniform(0.1, 0.4) # Fast-swipe
|
||||
return 1.0
|
||||
|
||||
def decay(self):
|
||||
|
||||
@@ -16,8 +16,8 @@ All parameters persist in Qdrant, surviving restarts.
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,13 +27,13 @@ logger = logging.getLogger(__name__)
|
||||
# Think of them as the "physics" constraints of the system.
|
||||
SAFETY_BOUNDS = {
|
||||
"scroll_correction_probability": (0.05, 0.35), # Never below 5%, never above 35%
|
||||
"boredom_decay_rate": (0.05, 0.5), # How fast boredom accumulates
|
||||
"resonance_threshold": (0.3, 0.9), # Content quality filter
|
||||
"interaction_cooldown_seconds": (1.0, 10.0), # Min pause between interactions
|
||||
"max_follows_per_session": (5, 40), # Absolute follow cap
|
||||
"max_likes_per_session": (10, 80), # Absolute like cap
|
||||
"session_duration_target_minutes": (15, 120), # Session length target
|
||||
"story_view_probability": (0.1, 0.8), # How often to view stories
|
||||
"boredom_decay_rate": (0.05, 0.5), # How fast boredom accumulates
|
||||
"resonance_threshold": (0.3, 0.9), # Content quality filter
|
||||
"interaction_cooldown_seconds": (1.0, 10.0), # Min pause between interactions
|
||||
"max_follows_per_session": (5, 40), # Absolute follow cap
|
||||
"max_likes_per_session": (10, 80), # Absolute like cap
|
||||
"session_duration_target_minutes": (15, 120), # Session length target
|
||||
"story_view_probability": (0.1, 0.8), # How often to view stories
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ class Genome:
|
||||
The bot's behavioral DNA — a set of evolvable parameters.
|
||||
Each parameter has a current value and respects hard safety bounds.
|
||||
"""
|
||||
|
||||
scroll_correction_probability: float = 0.15
|
||||
boredom_decay_rate: float = 0.2
|
||||
resonance_threshold: float = 0.7
|
||||
@@ -51,15 +52,15 @@ class Genome:
|
||||
max_likes_per_session: int = 30
|
||||
session_duration_target_minutes: float = 45.0
|
||||
story_view_probability: float = 0.4
|
||||
|
||||
|
||||
# Metadata
|
||||
generation: int = 0
|
||||
best_fitness: float = 0.0
|
||||
last_updated: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Genome":
|
||||
# Filter out unknown keys for forward-compatibility
|
||||
@@ -74,6 +75,7 @@ class SessionResult:
|
||||
Outcome metrics from a completed session.
|
||||
Used to calculate fitness for the current genome.
|
||||
"""
|
||||
|
||||
follows_gained: int = 0
|
||||
likes_given: int = 0
|
||||
stories_viewed: int = 0
|
||||
@@ -86,7 +88,7 @@ class SessionResult:
|
||||
class EvolutionEngine:
|
||||
"""
|
||||
Genetic algorithm for behavioral parameter optimization.
|
||||
|
||||
|
||||
Lifecycle:
|
||||
1. Load genome from Qdrant (or use defaults)
|
||||
2. Bot uses genome parameters during session
|
||||
@@ -95,49 +97,50 @@ class EvolutionEngine:
|
||||
5. If fitness decreased → mutate genome (try new params)
|
||||
6. Persist genome to Qdrant
|
||||
"""
|
||||
|
||||
|
||||
_instance = None
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls, username: str = None) -> "EvolutionEngine":
|
||||
if cls._instance is None:
|
||||
cls._instance = cls(username or "default")
|
||||
return cls._instance
|
||||
|
||||
|
||||
@classmethod
|
||||
def reset(cls):
|
||||
cls._instance = None
|
||||
|
||||
|
||||
def __init__(self, username: str):
|
||||
self.username = username
|
||||
self.genome = Genome()
|
||||
self._qdrant_connected = False
|
||||
self._load_genome()
|
||||
|
||||
|
||||
def _load_genome(self):
|
||||
"""Load persisted genome from Qdrant, or use defaults."""
|
||||
try:
|
||||
from GramAddict.core.qdrant_memory import QdrantBase
|
||||
|
||||
self._db = QdrantBase("evolution_genomes_v1", vector_size=128)
|
||||
|
||||
|
||||
if not self._db.is_connected:
|
||||
logger.debug("[Evolution] Qdrant not available. Using default genome.")
|
||||
return
|
||||
|
||||
|
||||
self._qdrant_connected = True
|
||||
|
||||
|
||||
# Try to recall existing genome
|
||||
vec = self._db._get_embedding(f"genome_{self.username}")
|
||||
if not vec:
|
||||
return
|
||||
|
||||
|
||||
results = self._db.client.query_points(
|
||||
collection_name=self._db.collection_name,
|
||||
query=vec,
|
||||
limit=1,
|
||||
score_threshold=0.95,
|
||||
).points
|
||||
|
||||
|
||||
if results:
|
||||
payload = results[0].payload
|
||||
genome_data = payload.get("genome", {})
|
||||
@@ -149,93 +152,93 @@ class EvolutionEngine:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Evolution] Failed to load genome: {e}")
|
||||
|
||||
|
||||
def _save_genome(self):
|
||||
"""Persist genome to Qdrant."""
|
||||
if not self._qdrant_connected:
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
vec = self._db._get_embedding(f"genome_{self.username}")
|
||||
if not vec:
|
||||
return
|
||||
|
||||
|
||||
self.genome.last_updated = time.time()
|
||||
payload = {
|
||||
"username": self.username,
|
||||
"genome": self.genome.to_dict(),
|
||||
}
|
||||
|
||||
|
||||
self._db.upsert_point(
|
||||
f"genome_{self.username}",
|
||||
payload,
|
||||
vector=vec,
|
||||
log_success=f"🧬 [Evolution] Saved genome generation {self.genome.generation}"
|
||||
log_success=f"🧬 [Evolution] Saved genome generation {self.genome.generation}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Evolution] Failed to save genome: {e}")
|
||||
|
||||
|
||||
def compute_fitness(self, result: SessionResult) -> float:
|
||||
"""
|
||||
Computes a fitness score [0.0 - 1.0] from session outcomes.
|
||||
|
||||
|
||||
Reward:
|
||||
- Follows gained (high value)
|
||||
- Likes given (medium value)
|
||||
- Stories viewed (low value)
|
||||
- Longer sessions (moderate value)
|
||||
|
||||
|
||||
Penalty:
|
||||
- Blocks received (SEVERE penalty — 50% fitness reduction per block)
|
||||
- High prediction error rate (moderate penalty)
|
||||
"""
|
||||
if result.blocks_received > 0:
|
||||
# Blocks are catastrophic — any genome that triggers a block is unfit
|
||||
block_penalty = 0.5 ** result.blocks_received
|
||||
block_penalty = 0.5**result.blocks_received
|
||||
logger.warning(
|
||||
f"🧬 [Evolution] BLOCK PENALTY: {result.blocks_received} blocks → "
|
||||
f"fitness multiplier {block_penalty:.3f}"
|
||||
)
|
||||
else:
|
||||
block_penalty = 1.0
|
||||
|
||||
|
||||
# Normalize outcomes to [0, 1] range
|
||||
follow_score = min(result.follows_gained / 20.0, 1.0) # Cap at 20
|
||||
like_score = min(result.likes_given / 50.0, 1.0) # Cap at 50
|
||||
story_score = min(result.stories_viewed / 20.0, 1.0) # Cap at 20
|
||||
like_score = min(result.likes_given / 50.0, 1.0) # Cap at 50
|
||||
story_score = min(result.stories_viewed / 20.0, 1.0) # Cap at 20
|
||||
duration_score = min(result.duration_minutes / 60.0, 1.0) # Cap at 60 min
|
||||
|
||||
|
||||
# Prediction accuracy bonus
|
||||
accuracy_bonus = 1.0 - result.prediction_error_rate
|
||||
|
||||
|
||||
# Weighted fitness
|
||||
raw_fitness = (
|
||||
follow_score * 0.35 + # Follows are most valuable
|
||||
like_score * 0.20 + # Likes are secondary
|
||||
story_score * 0.05 + # Stories are minor
|
||||
duration_score * 0.15 + # Session stability matters
|
||||
accuracy_bonus * 0.25 # Prediction accuracy = environmental mastery
|
||||
follow_score * 0.35 # Follows are most valuable
|
||||
+ like_score * 0.20 # Likes are secondary
|
||||
+ story_score * 0.05 # Stories are minor
|
||||
+ duration_score * 0.15 # Session stability matters
|
||||
+ accuracy_bonus * 0.25 # Prediction accuracy = environmental mastery
|
||||
)
|
||||
|
||||
|
||||
fitness = raw_fitness * block_penalty
|
||||
fitness = max(0.0, min(1.0, fitness)) # Clamp to [0, 1]
|
||||
|
||||
|
||||
return round(fitness, 4)
|
||||
|
||||
|
||||
def evolve(self, result: SessionResult):
|
||||
"""
|
||||
Evaluate session and evolve the genome.
|
||||
|
||||
|
||||
If fitness improved → lock parameters (exploitation)
|
||||
If fitness decreased → mutate parameters (exploration)
|
||||
"""
|
||||
fitness = self.compute_fitness(result)
|
||||
|
||||
|
||||
logger.info(
|
||||
f"🧬 [Evolution] Generation {self.genome.generation} fitness: {fitness:.4f} "
|
||||
f"(best: {self.genome.best_fitness:.4f})"
|
||||
)
|
||||
|
||||
|
||||
if fitness >= self.genome.best_fitness:
|
||||
# ── Exploitation: Lock winning parameters ──
|
||||
logger.info(f"🧬 [Evolution] ✅ Fitness improved! Locking generation {self.genome.generation}.")
|
||||
@@ -244,41 +247,41 @@ class EvolutionEngine:
|
||||
# ── Exploration: Mutate parameters ──
|
||||
logger.info(f"🧬 [Evolution] 🔀 Fitness regressed. Mutating for generation {self.genome.generation + 1}.")
|
||||
self._mutate()
|
||||
|
||||
|
||||
self.genome.generation += 1
|
||||
self._save_genome()
|
||||
|
||||
|
||||
def _mutate(self, mutation_rate: float = 0.15):
|
||||
"""
|
||||
Mutate genome parameters within safety bounds.
|
||||
|
||||
|
||||
Each parameter has a `mutation_rate` chance of being modified.
|
||||
Mutations are small (±10-20% of current value) to ensure gradual evolution.
|
||||
"""
|
||||
for param_name, (low, high) in SAFETY_BOUNDS.items():
|
||||
if random.random() > mutation_rate:
|
||||
continue
|
||||
|
||||
|
||||
current = getattr(self.genome, param_name, None)
|
||||
if current is None:
|
||||
continue
|
||||
|
||||
|
||||
# Mutation: ±10-20% of range
|
||||
param_range = high - low
|
||||
delta = random.uniform(-0.2, 0.2) * param_range
|
||||
|
||||
|
||||
new_value = current + delta
|
||||
|
||||
|
||||
# Clamp to safety bounds
|
||||
if isinstance(current, int):
|
||||
new_value = int(max(low, min(high, round(new_value))))
|
||||
else:
|
||||
new_value = max(low, min(high, new_value))
|
||||
|
||||
|
||||
old_value = current
|
||||
setattr(self.genome, param_name, new_value)
|
||||
logger.debug(f"🧬 [Mutation] {param_name}: {old_value} → {new_value}")
|
||||
|
||||
|
||||
def get_param(self, name: str, default: Any = None) -> Any:
|
||||
"""Get a parameter value from the current genome."""
|
||||
return getattr(self.genome, name, default)
|
||||
|
||||
@@ -13,858 +13,25 @@ Like a GPS navigation system:
|
||||
- It remembers shortcuts (learn)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from GramAddict.core.qdrant_memory import QdrantBase
|
||||
from GramAddict.core.utils import random_sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════
|
||||
# 1. SCREEN IDENTITY — "Where am I?"
|
||||
# ══════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class ScreenType(Enum):
|
||||
HOME_FEED = "home_feed"
|
||||
EXPLORE_GRID = "explore_grid"
|
||||
REELS_FEED = "reels_feed"
|
||||
OWN_PROFILE = "own_profile"
|
||||
OTHER_PROFILE = "other_profile"
|
||||
POST_DETAIL = "post_detail"
|
||||
STORY_VIEW = "story_view"
|
||||
DM_INBOX = "dm_inbox"
|
||||
DM_THREAD = "dm_thread"
|
||||
SEARCH_RESULTS = "search_results"
|
||||
FOLLOW_LIST = "follow_list"
|
||||
COMMENTS = "comments"
|
||||
MODAL = "modal"
|
||||
FOREIGN_APP = "foreign_app"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class ScreenIdentity:
|
||||
"""
|
||||
Understands what screen the bot is on by analyzing the XML dump.
|
||||
NO hardcoded states — purely structural analysis.
|
||||
|
||||
This is the bot's EYES. It answers: "What do I see right now?"
|
||||
"""
|
||||
|
||||
def __init__(self, bot_username: str):
|
||||
self.bot_username = bot_username.lower()
|
||||
try:
|
||||
from GramAddict.core.qdrant_memory import ScreenMemoryDB
|
||||
|
||||
self.screen_memory = ScreenMemoryDB()
|
||||
except ImportError:
|
||||
self.screen_memory = None
|
||||
|
||||
def identify(self, xml_dump: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyzes an XML dump and returns a complete screen description.
|
||||
|
||||
Returns:
|
||||
{
|
||||
'screen_type': ScreenType,
|
||||
'available_actions': ['tap like button', 'tap explore tab', ...],
|
||||
'selected_tab': 'feed_tab' | 'search_tab' | ...,
|
||||
'context': {'username': '...', 'post_count': '...', ...}
|
||||
}
|
||||
"""
|
||||
if not xml_dump or not isinstance(xml_dump, str):
|
||||
return self._empty_screen()
|
||||
|
||||
try:
|
||||
clean = re.sub(r"<\?xml.*?\?>", "", xml_dump).strip()
|
||||
root = ET.fromstring(clean)
|
||||
except Exception:
|
||||
return self._empty_screen()
|
||||
|
||||
# Extract structural signals
|
||||
packages = set()
|
||||
resource_ids = set()
|
||||
content_descs = []
|
||||
texts = []
|
||||
selected_tab = None
|
||||
clickable_elements = []
|
||||
|
||||
app_id = "com.instagram.android"
|
||||
|
||||
for elem in root.iter("node"):
|
||||
pkg = elem.get("package", "")
|
||||
if pkg:
|
||||
packages.add(pkg)
|
||||
|
||||
rid = elem.get("resource-id", "").strip()
|
||||
text = elem.get("text", "").strip()
|
||||
desc = elem.get("content-desc", "").strip()
|
||||
clickable = elem.get("clickable", "false") == "true"
|
||||
selected = elem.get("selected", "false") == "true"
|
||||
bounds = elem.get("bounds", "")
|
||||
|
||||
if rid:
|
||||
# Normalize: "com.instagram.android:id/feed_tab" → "feed_tab"
|
||||
short_id = rid.split("/")[-1] if "/" in rid else rid
|
||||
resource_ids.add(short_id)
|
||||
|
||||
# Track which tab is selected
|
||||
if selected and short_id in ("feed_tab", "search_tab", "clips_tab", "profile_tab", "direct_tab"):
|
||||
selected_tab = short_id
|
||||
|
||||
if text:
|
||||
texts.append(text)
|
||||
if desc:
|
||||
content_descs.append(desc)
|
||||
|
||||
if clickable and bounds:
|
||||
match = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
|
||||
if match:
|
||||
left, t, r, b = map(int, match.groups())
|
||||
cx, cy = (left + r) // 2, (t + b) // 2
|
||||
clickable_elements.append(
|
||||
{
|
||||
"text": text,
|
||||
"desc": desc,
|
||||
"id": rid.split("/")[-1] if "/" in rid else rid,
|
||||
"x": cx,
|
||||
"y": cy,
|
||||
"bounds": bounds,
|
||||
}
|
||||
)
|
||||
|
||||
# ── Foreign app check ──
|
||||
if app_id not in packages:
|
||||
return {
|
||||
"screen_type": ScreenType.FOREIGN_APP,
|
||||
"available_actions": ["press back", "force start instagram"],
|
||||
"selected_tab": None,
|
||||
"context": {"packages": list(packages)},
|
||||
"signature": self._compute_signature(resource_ids, content_descs, texts),
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
# ── Extract available actions from clickable elements ──
|
||||
available_actions = self._extract_available_actions(
|
||||
clickable_elements, resource_ids, content_descs, texts, screen_type
|
||||
)
|
||||
|
||||
# ── Extract context ──
|
||||
context = self._extract_context(content_descs, texts, resource_ids, screen_type)
|
||||
|
||||
return {
|
||||
"screen_type": screen_type,
|
||||
"available_actions": available_actions,
|
||||
"selected_tab": selected_tab,
|
||||
"context": context,
|
||||
"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."""
|
||||
|
||||
# Priority 0: 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
|
||||
|
||||
# Priority 1: Check Qdrant Semantic Cache
|
||||
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
|
||||
|
||||
# Priority 2: Structural Heuristics (Instant, for core tabs)
|
||||
if "unified_follow_list_tab_layout" in ids or "follow_list_container" in ids:
|
||||
return ScreenType.FOLLOW_LIST
|
||||
|
||||
if "profile_header_container" in ids:
|
||||
return ScreenType.OTHER_PROFILE
|
||||
|
||||
# Reels structural markers — present even when Instagram hides the tab bar
|
||||
# in full-screen Reels viewing. Without this, selected_tab=None → UNKNOWN.
|
||||
REELS_MARKERS = ("clips_viewer_container", "root_clips_layout", "clips_linear_layout_container")
|
||||
if any(marker in ids for marker in REELS_MARKERS):
|
||||
return ScreenType.REELS_FEED
|
||||
|
||||
# DM thread detection — structural markers present inside DM conversations
|
||||
if "direct_thread_header" in ids or "row_thread_composer_edittext" in ids:
|
||||
return ScreenType.DM_THREAD
|
||||
|
||||
if "row_feed_button_like" in ids and "row_feed_photo_profile_name" in ids and not selected_tab:
|
||||
return ScreenType.POST_DETAIL
|
||||
|
||||
if selected_tab == "feed_tab":
|
||||
return ScreenType.HOME_FEED
|
||||
if selected_tab == "clips_tab":
|
||||
return ScreenType.REELS_FEED
|
||||
if selected_tab == "search_tab":
|
||||
return ScreenType.EXPLORE_GRID
|
||||
if selected_tab == "profile_tab":
|
||||
return ScreenType.OWN_PROFILE
|
||||
if selected_tab == "direct_tab":
|
||||
return ScreenType.DM_INBOX
|
||||
if "message_input" in ids:
|
||||
return ScreenType.DM_INBOX # Fallback for DM thread as inbox
|
||||
|
||||
# Priority 3: Semantic VLM Classification Fallback
|
||||
from GramAddict.core.config import Config
|
||||
from GramAddict.core.llm_provider import query_llm
|
||||
|
||||
cfg = Config()
|
||||
url = (
|
||||
getattr(cfg.args, "ai_embedding_url", "http://localhost:11434/api/chat")
|
||||
if hasattr(cfg, "args")
|
||||
else "http://localhost:11434/api/chat"
|
||||
)
|
||||
model = getattr(cfg.args, "ai_embedding_model", "llama3") if hasattr(cfg, "args") else "llama3"
|
||||
|
||||
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"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
|
||||
)
|
||||
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
|
||||
|
||||
for t in ScreenType:
|
||||
if t.name in result:
|
||||
if signature and self.screen_memory:
|
||||
self.screen_memory.store_screen(signature, t.name)
|
||||
return t
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).debug(f"LLM Classification failed: {e}")
|
||||
|
||||
return ScreenType.UNKNOWN
|
||||
|
||||
def _extract_available_actions(self, clickable_elements, resource_ids, content_descs, texts, screen_type):
|
||||
"""Discover what actions are possible on this screen."""
|
||||
actions = []
|
||||
|
||||
# Navigation tabs (always available when visible)
|
||||
tab_map = {
|
||||
"feed_tab": "tap home tab",
|
||||
"search_tab": "tap explore tab",
|
||||
"clips_tab": "tap reels tab",
|
||||
"profile_tab": "tap profile tab",
|
||||
"direct_tab": "tap messages tab",
|
||||
}
|
||||
for tab_id, action in tab_map.items():
|
||||
if tab_id in resource_ids:
|
||||
actions.append(action)
|
||||
|
||||
# Screen-specific actions
|
||||
desc_lower = " ".join(content_descs).lower()
|
||||
text_lower = " ".join(texts).lower()
|
||||
|
||||
if "like" in desc_lower:
|
||||
actions.append("tap like button")
|
||||
if "comment" in desc_lower:
|
||||
actions.append("tap comment button")
|
||||
if "share" in desc_lower:
|
||||
actions.append("tap share button")
|
||||
if "save" in desc_lower or "bookmark" in desc_lower:
|
||||
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")
|
||||
|
||||
if screen_type == ScreenType.OWN_PROFILE or screen_type == ScreenType.OTHER_PROFILE:
|
||||
if "message" in desc_lower or "nachricht" in desc_lower:
|
||||
actions.append("tap message button")
|
||||
if (
|
||||
"following" in desc_lower
|
||||
or "abonniert" in desc_lower
|
||||
or "following" in text_lower
|
||||
or "profile_header_following" in " ".join(resource_ids).lower()
|
||||
):
|
||||
actions.append("tap following list")
|
||||
|
||||
# Grid items
|
||||
if screen_type == ScreenType.EXPLORE_GRID:
|
||||
actions.append("tap first grid item")
|
||||
|
||||
# Scroll
|
||||
actions.append("scroll down")
|
||||
actions.append("press back")
|
||||
|
||||
return list(set(actions)) # Deduplicate
|
||||
|
||||
def _extract_context(self, content_descs, texts, resource_ids, screen_type):
|
||||
"""Extract meaningful context from the screen."""
|
||||
context = {}
|
||||
|
||||
desc_text = " ".join(content_descs)
|
||||
|
||||
# Username on profile
|
||||
username_match = re.search(r"(\w+)'s (?:profile|story|unseen story)", desc_text)
|
||||
if username_match:
|
||||
context["username"] = username_match.group(1)
|
||||
|
||||
# Post/follower counts
|
||||
for d in content_descs:
|
||||
m = re.match(r"([\d,.]+K?M?)(\s*)(posts?|followers?|following)", d, re.IGNORECASE)
|
||||
if m:
|
||||
context[m.group(3).lower()] = m.group(1)
|
||||
|
||||
# Like state
|
||||
for d in content_descs:
|
||||
if d.lower() == "liked":
|
||||
context["is_liked"] = True
|
||||
elif d.lower() == "like":
|
||||
context["is_liked"] = False
|
||||
|
||||
return context
|
||||
|
||||
def _compute_signature(self, resource_ids, content_descs, texts):
|
||||
"""Compute a stable hash for this screen state (for Qdrant lookup)."""
|
||||
# Use sorted IDs + key content for stability
|
||||
sig_parts = sorted(resource_ids)[:20]
|
||||
sig_parts.extend(sorted(set(d.lower()[:30] for d in content_descs if len(d) > 2))[:10])
|
||||
sig = "|".join(sig_parts)
|
||||
return hashlib.sha256(sig.encode()).hexdigest()[:24]
|
||||
|
||||
def _empty_screen(self):
|
||||
return {
|
||||
"screen_type": ScreenType.FOREIGN_APP,
|
||||
"available_actions": ["press back", "force start instagram"],
|
||||
"selected_tab": None,
|
||||
"context": {},
|
||||
"signature": "empty",
|
||||
}
|
||||
from GramAddict.core.navigation.knowledge import NavigationKnowledge
|
||||
from GramAddict.core.navigation.path_memory import PathMemory
|
||||
from GramAddict.core.navigation.planner import GoalPlanner
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
|
||||
|
||||
# Re-export for backward compatibility (optional but helps minimize import breakage)
|
||||
__all__ = ["GoalExecutor", "ScreenIdentity", "ScreenType", "PathMemory", "NavigationKnowledge", "GoalPlanner"]
|
||||
|
||||
# ══════════════════════════════════════════════════════
|
||||
# 2. PATH MEMORY — "How did I get there last time?"
|
||||
# ══════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class PathMemory:
|
||||
"""
|
||||
Qdrant-backed memory for successful navigation paths.
|
||||
|
||||
Stores: goal → [step1, step2, ...] → success
|
||||
Enables instant recall for known goals.
|
||||
"""
|
||||
|
||||
def __init__(self, username: str = ""):
|
||||
self.username = username
|
||||
try:
|
||||
suffix = f"_{username}" if username else ""
|
||||
self._db = QdrantBase(f"goap_paths_v1{suffix}", vector_size=768)
|
||||
except Exception:
|
||||
self._db = None
|
||||
|
||||
def wipe(self):
|
||||
"""Wipe all learned navigation paths from Qdrant."""
|
||||
if self._db and self._db.is_connected:
|
||||
try:
|
||||
self._db.wipe_collection()
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ [PathMemory] Could not wipe collection: {e}")
|
||||
|
||||
def recall_path(self, goal: str, current_screen_type: str) -> Optional[List[Dict]]:
|
||||
"""
|
||||
Recall a previously successful path for this goal from this screen type.
|
||||
Returns list of steps or None.
|
||||
"""
|
||||
if not self._db or not self._db.is_connected:
|
||||
return None
|
||||
|
||||
query = f"goal: {goal} | from: {current_screen_type}"
|
||||
vec = self._db._get_embedding(query)
|
||||
if not vec:
|
||||
return None
|
||||
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
results = self._db.client.query_points(
|
||||
collection_name=self._db.collection_name,
|
||||
query=vec,
|
||||
query_filter=Filter(
|
||||
must=[FieldCondition(key="start_screen", match=MatchValue(value=current_screen_type))]
|
||||
),
|
||||
limit=3,
|
||||
score_threshold=0.85,
|
||||
).points
|
||||
|
||||
for r in results:
|
||||
p = r.payload
|
||||
if p.get("success") and p.get("steps"):
|
||||
logger.info(
|
||||
f"🧠 [GOAP Recall] Found path for '{goal}': "
|
||||
f"{len(p['steps'])} steps (confidence: {p.get('confidence', 0):.2f})"
|
||||
)
|
||||
return p["steps"]
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"GOAP recall error: {e}")
|
||||
return None
|
||||
|
||||
def learn_path(self, goal: str, start_screen: str, steps: List[Dict], success: bool):
|
||||
"""Store a navigation path in Qdrant."""
|
||||
if not self._db or not self._db.is_connected:
|
||||
return
|
||||
|
||||
query = f"goal: {goal} | from: {start_screen}"
|
||||
vec = self._db._get_embedding(query)
|
||||
if not vec:
|
||||
return
|
||||
|
||||
seed = f"{goal}|{start_screen}"
|
||||
payload = {
|
||||
"goal": goal,
|
||||
"start_screen": start_screen,
|
||||
"steps": steps,
|
||||
"step_count": len(steps),
|
||||
"success": success,
|
||||
"confidence": 0.85 if success else 0.0,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
outcome = "✅" if success else "❌"
|
||||
self._db.upsert_point(
|
||||
seed,
|
||||
payload,
|
||||
vector=vec,
|
||||
log_success=f"🧠 [GOAP Learn] {outcome} Path for '{goal}': {len(steps)} steps from {start_screen}",
|
||||
)
|
||||
|
||||
def forget_path(self, goal: str, start_screen: str):
|
||||
"""Remove a cached path to force re-discovery."""
|
||||
if not self._db or not self._db.is_connected:
|
||||
return
|
||||
|
||||
seed = f"{goal}|{start_screen}"
|
||||
try:
|
||||
from qdrant_client import models
|
||||
|
||||
point_id = self._db._get_id(seed)
|
||||
self._db.client.delete(
|
||||
collection_name=self._db.collection_name, points_selector=models.PointIdsList(points=[point_id])
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to forget path: {e}")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════
|
||||
# 3. GOAL PLANNER — "What should I do next?"
|
||||
# ══════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class NavigationKnowledge:
|
||||
"""
|
||||
Manages the bot's learned understanding of the Instagram UI.
|
||||
Discovered dynamically through exploration and success.
|
||||
"""
|
||||
|
||||
def __init__(self, username: str):
|
||||
self.username = username
|
||||
try:
|
||||
self._db = QdrantBase("navigation_knowledge", vector_size=768)
|
||||
except Exception:
|
||||
self._db = None
|
||||
|
||||
# In-memory cache for rapidly avoiding traps during exploration
|
||||
# In-memory cache for rapidly avoiding traps during exploration
|
||||
self._learned_screen_mappings = {}
|
||||
self._learned_traps = set()
|
||||
|
||||
def wipe(self):
|
||||
"""Wipe all learned knowledge from Qdrant."""
|
||||
if self._db and self._db.is_connected:
|
||||
try:
|
||||
self._db.wipe_collection()
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ [NavigationKnowledge] Could not wipe knowledge: {e}")
|
||||
|
||||
def update_username(self, username: str):
|
||||
"""Update username and reconnect DB if needed."""
|
||||
if self.username != username:
|
||||
self.username = username
|
||||
try:
|
||||
self._db = QdrantBase("navigation_knowledge", vector_size=768)
|
||||
except Exception:
|
||||
self._db = None
|
||||
|
||||
def get_requirements(self, goal: str) -> List[ScreenType]:
|
||||
"""Get required screens for a goal. Returns known requirements or empty list."""
|
||||
if not self._db or not self._db.is_connected:
|
||||
return []
|
||||
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
results = self._db.client.scroll(
|
||||
collection_name=self._db.collection_name,
|
||||
scroll_filter=Filter(must=[FieldCondition(key="goal", match=MatchValue(value=goal))]),
|
||||
limit=1,
|
||||
)[0]
|
||||
if results:
|
||||
screen_name = results[0].payload.get("required_screen")
|
||||
logger.debug(f"🧠 [Nav Knowledge] Found requirement for '{goal}': {screen_name}")
|
||||
if screen_name:
|
||||
return [ScreenType[screen_name]]
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ [Nav Knowledge] Search error: {e}")
|
||||
return []
|
||||
|
||||
def learn_goal_requirement(self, goal: str, screen_type: ScreenType):
|
||||
"""Learn that achieving 'goal' lands us on 'screen_type'."""
|
||||
if not self._db or not self._db.is_connected:
|
||||
logger.warning("⚠️ [Nav Knowledge] Cannot learn: DB not connected")
|
||||
return
|
||||
|
||||
seed = f"req_{goal}"
|
||||
vec = self._db._get_embedding(f"goal_requirement: {goal}")
|
||||
payload = {"goal": goal, "required_screen": screen_type.name, "timestamp": time.time()}
|
||||
self._db.upsert_point(seed, payload, vector=vec)
|
||||
logger.info(f"🧠 [Nav Knowledge] Learned: '{goal}' → {screen_type.name}")
|
||||
|
||||
def get_action_for_screen(self, target_screen: ScreenType) -> Optional[str]:
|
||||
"""Find which action leads to this screen."""
|
||||
for action, screen in self._learned_screen_mappings.items():
|
||||
if screen == target_screen:
|
||||
return action
|
||||
|
||||
if not self._db or not self._db.is_connected:
|
||||
return None
|
||||
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
results = self._db.client.scroll(
|
||||
collection_name=self._db.collection_name,
|
||||
scroll_filter=Filter(
|
||||
must=[FieldCondition(key="result_screen", match=MatchValue(value=target_screen.name))]
|
||||
),
|
||||
limit=1,
|
||||
)[0]
|
||||
if results:
|
||||
return results[0].payload.get("action")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def get_screen_for_action(self, action: str) -> Optional[ScreenType]:
|
||||
"""Find where this action leads to to avoid looping traps."""
|
||||
if action in self._learned_screen_mappings:
|
||||
return self._learned_screen_mappings[action]
|
||||
|
||||
if not self._db or not self._db.is_connected:
|
||||
return None
|
||||
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
results = self._db.client.scroll(
|
||||
collection_name=self._db.collection_name,
|
||||
scroll_filter=Filter(must=[FieldCondition(key="action", match=MatchValue(value=action))]),
|
||||
limit=1,
|
||||
)[0]
|
||||
if results:
|
||||
screen_name = results[0].payload.get("result_screen")
|
||||
if screen_name:
|
||||
return ScreenType[screen_name]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def learn_screen_mapping(self, action: str, result_screen: ScreenType):
|
||||
"""Learn that taking 'action' leads to 'result_screen'."""
|
||||
if not self._db or not self._db.is_connected:
|
||||
return
|
||||
|
||||
seed = f"map_{action}"
|
||||
vec = self._db._get_embedding(f"screen_mapping: {result_screen.name}")
|
||||
payload = {"action": action, "result_screen": result_screen.name, "timestamp": time.time()}
|
||||
|
||||
self._learned_screen_mappings[action] = result_screen
|
||||
|
||||
self._db.upsert_point(seed, payload, vector=vec)
|
||||
logger.info(f"🧠 [Nav Knowledge] Learned Mapping: '{action}' → {result_screen.name}")
|
||||
|
||||
def get_screen_for_tab(self, tab_id: str) -> Optional[ScreenType]:
|
||||
"""Find where this tab leads to to avoid looping traps."""
|
||||
if tab_id in self._learned_screen_mappings:
|
||||
return self._learned_screen_mappings[tab_id]
|
||||
|
||||
if not self._db or not self._db.is_connected:
|
||||
return None
|
||||
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
results = self._db.client.scroll(
|
||||
collection_name=self._db.collection_name,
|
||||
scroll_filter=Filter(must=[FieldCondition(key="tab_id", match=MatchValue(value=tab_id))]),
|
||||
limit=1,
|
||||
)[0]
|
||||
if results:
|
||||
s_name = results[0].payload.get("result_screen")
|
||||
if s_name:
|
||||
return ScreenType[s_name]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def learn_trap(self, screen_type: ScreenType, action: str, trap_reason: str = "softlock"):
|
||||
"""Aversively learn that an action on a screen is dangerous/useless."""
|
||||
trap_key = f"{screen_type.name}_{action}"
|
||||
self._learned_traps.add(trap_key)
|
||||
|
||||
if not self._db or not self._db.is_connected:
|
||||
return
|
||||
|
||||
seed = f"trap_{trap_key}"
|
||||
# Aversive vector is completely orthogonal to normal goals to prevent retrieval overlap
|
||||
vec = self._db._get_embedding(f"trap_avoidance: {trap_key} {trap_reason}")
|
||||
payload = {
|
||||
"trap_screen": screen_type.name,
|
||||
"trap_action": action,
|
||||
"trap_reason": trap_reason,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
self._db.upsert_point(seed, payload, vector=vec)
|
||||
logger.error(f"💀 [Aversive Learning] BURNED action '{action}' on {screen_type.name} due to: {trap_reason}")
|
||||
|
||||
def is_trap(self, screen_type: ScreenType, action: str) -> bool:
|
||||
"""Check if an action on this screen is a known trap."""
|
||||
trap_key = f"{screen_type.name}_{action}"
|
||||
if trap_key in self._learned_traps:
|
||||
return True
|
||||
|
||||
if not self._db or not self._db.is_connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
results = self._db.client.scroll(
|
||||
collection_name=self._db.collection_name,
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(key="trap_screen", match=MatchValue(value=screen_type.name)),
|
||||
FieldCondition(key="trap_action", match=MatchValue(value=action)),
|
||||
]
|
||||
),
|
||||
limit=1,
|
||||
)[0]
|
||||
if results:
|
||||
self._learned_traps.add(trap_key)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
class GoalPlanner:
|
||||
"""
|
||||
Given a goal and current screen state, plans the next action.
|
||||
|
||||
Uses Dynamic Discovery to navigate without hardcoded maps.
|
||||
"""
|
||||
|
||||
def __init__(self, username: str):
|
||||
self.knowledge = NavigationKnowledge(username)
|
||||
|
||||
def plan_next_step(self, goal: str, screen: Dict[str, Any], explored_nav_actions: set = None) -> Optional[str]:
|
||||
"""Plans the NEXT single action to take toward the goal."""
|
||||
screen_type = screen["screen_type"]
|
||||
available = screen.get("available_actions", [])
|
||||
context = screen.get("context", {})
|
||||
goal_lower = goal.lower()
|
||||
|
||||
# ── 1. Check if goal is ALREADY achieved ──
|
||||
if self._is_goal_achieved(goal_lower, screen_type, context):
|
||||
logger.info(f"🎯 [GOAP] Goal '{goal}' already achieved on {screen_type.value}!")
|
||||
return None
|
||||
|
||||
# (Phase 5: legacy _plan_goal_action static heuristics purged,
|
||||
# all intents fall through to VLM-driven Discovery in _plan_navigation)
|
||||
|
||||
# ── 3. Am I on the right screen? If not, navigate there ──
|
||||
selected_tab = screen.get("selected_tab")
|
||||
nav_action = self._plan_navigation(goal_lower, screen_type, available, selected_tab, explored_nav_actions)
|
||||
if nav_action:
|
||||
return nav_action
|
||||
|
||||
# Final fallback: back-track, UNLESS back-tracking is a known trap on this screen!
|
||||
if not self.knowledge.is_trap(screen_type, "press back"):
|
||||
return "press back"
|
||||
|
||||
# We are trapped! Can't go forward, can't go back!
|
||||
logger.error(f"💀 [GOAP] Completely trapped on {screen_type.name}. Forcing Instagram restart.")
|
||||
return "force start instagram"
|
||||
|
||||
def _is_goal_achieved(self, goal: str, screen_type: ScreenType, context: dict) -> bool:
|
||||
"""Check if the goal is already satisfied. Delegates to ScreenTopology SSOT."""
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
# Interaction goals (context-specific, not navigation)
|
||||
if "like" in goal and context.get("is_liked") is True:
|
||||
return True
|
||||
if "view profile" in goal and screen_type in (ScreenType.OWN_PROFILE, ScreenType.OTHER_PROFILE):
|
||||
return True
|
||||
|
||||
# Navigation goals — delegate to SSOT
|
||||
target = ScreenTopology.goal_to_target_screen(goal)
|
||||
if target and screen_type == target:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _plan_navigation(
|
||||
self,
|
||||
goal: str,
|
||||
screen_type: ScreenType,
|
||||
available: List[str],
|
||||
selected_tab: Optional[str] = None,
|
||||
explored_nav_actions: set = None,
|
||||
) -> Optional[str]:
|
||||
"""If we're on the wrong screen, figure out how to navigate.
|
||||
|
||||
Strategy (priority order):
|
||||
1. HD Map (ScreenTopology BFS) — deterministic, pre-computed routes
|
||||
2. Learned Knowledge (Qdrant) — dynamic discovery from past sessions
|
||||
3. Autonomous Discovery — linguistic matching + VLM intent
|
||||
"""
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
# 0. Aversive Filter: Remove known traps from available actions
|
||||
safe_available = []
|
||||
for action in available:
|
||||
if not self.knowledge.is_trap(screen_type, action):
|
||||
safe_available.append(action)
|
||||
else:
|
||||
logger.debug(f"🛡️ [Aversive Filter] Masking trapped action: '{action}'")
|
||||
available = safe_available
|
||||
|
||||
# ── 1. HD Map Routing (Primary Strategy) ──
|
||||
target_screen = ScreenTopology.goal_to_target_screen(goal)
|
||||
if target_screen and target_screen != screen_type:
|
||||
route = ScreenTopology.find_route(screen_type, target_screen)
|
||||
if route:
|
||||
next_action, next_screen = route[0]
|
||||
# Verify action isn't explored/trapped
|
||||
if next_action not in (explored_nav_actions or set()):
|
||||
if not self.knowledge.is_trap(screen_type, next_action):
|
||||
route_desc = " → ".join(s.name for _, s in route)
|
||||
logger.info(
|
||||
f"🗺️ [HD Map] Route: {screen_type.name} → {route_desc}. " f"Next action: '{next_action}'"
|
||||
)
|
||||
return next_action
|
||||
else:
|
||||
logger.warning(f"🛡️ [HD Map] Route action '{next_action}' is trapped. Falling back.")
|
||||
else:
|
||||
logger.debug(f"🛡️ [HD Map] Route action '{next_action}' already explored. Falling back.")
|
||||
|
||||
# ── 2. Learned Knowledge (Qdrant) ──
|
||||
required_screens = self.knowledge.get_requirements(goal)
|
||||
|
||||
# ── 3. Autonomous Discovery (Blank Start fallback) ──
|
||||
if not required_screens:
|
||||
logger.info(f"🧠 [Nav Discovery] No known requirements for '{goal}'. Will attempt autonomous discovery.")
|
||||
|
||||
# Return raw intent for TelepathicEngine discovery (VLM)
|
||||
if explored_nav_actions and goal in explored_nav_actions:
|
||||
logger.info(
|
||||
f"🛑 [Nav Discovery] Autonomous intent '{goal}' already tried and failed/trapped. Yielding to back-tracking."
|
||||
)
|
||||
return None # Don't return goal again — force fallback to press back
|
||||
else:
|
||||
return goal
|
||||
|
||||
# 4. If we're already on an acceptable screen, no navigation needed
|
||||
if screen_type in required_screens:
|
||||
return None
|
||||
|
||||
# 5. Find the action we need to take (from learned knowledge or HD map)
|
||||
for target_screen in required_screens:
|
||||
# Try HD Map first!
|
||||
route = ScreenTopology.find_route(screen_type, target_screen)
|
||||
if route:
|
||||
next_action, next_screen = route[0]
|
||||
if next_action not in (explored_nav_actions or set()):
|
||||
if not self.knowledge.is_trap(screen_type, next_action):
|
||||
logger.info(f"🧭 [Nav HD Map] Routing to required {target_screen.name} via '{next_action}'")
|
||||
return next_action
|
||||
|
||||
known_action = self.knowledge.get_action_for_screen(target_screen)
|
||||
|
||||
if not known_action:
|
||||
logger.info(f"🧭 [Nav Discovery] Don't know action to reach {target_screen.name}. Asking VLM...")
|
||||
|
||||
screen_friendly_name = target_screen.name.replace("_", " ").lower()
|
||||
goal_words = [w.rstrip("s") for w in screen_friendly_name.split() if len(w) > 3]
|
||||
|
||||
for action in available:
|
||||
if any(w in action.lower() for w in goal_words):
|
||||
known_target = self.knowledge.get_screen_for_action(action)
|
||||
if known_target and known_target != target_screen:
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"🎯 [Nav Discovery] Linguistic match on available action! '{action}' aligns with '{screen_friendly_name}'"
|
||||
)
|
||||
return action
|
||||
|
||||
return f"navigate to {screen_friendly_name}"
|
||||
else:
|
||||
if known_action in available:
|
||||
logger.info(f"🧭 [Nav Knowledge] Navigating to {target_screen.name} via '{known_action}'")
|
||||
return known_action
|
||||
|
||||
# If no targeted navigation works, try going back first
|
||||
if "press back" in available:
|
||||
return "press back"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════
|
||||
# 4. GOAL EXECUTOR — The Main Brain Loop
|
||||
# GOAL EXECUTOR — The Main Brain Loop
|
||||
# ══════════════════════════════════════════════════════
|
||||
|
||||
|
||||
|
||||
@@ -1,66 +1,71 @@
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
from colorama import Fore
|
||||
|
||||
from GramAddict.core.qdrant_memory import PersonaMemoryDB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GrowthBrain:
|
||||
"""
|
||||
Biological Feedback and Persona Management.
|
||||
|
||||
|
||||
Two critical functions:
|
||||
1. Circadian Rhythm — modulates ALL sleep/dwell times based on time of day
|
||||
2. Persona Refinement — learns from interaction outcomes and stores insights
|
||||
"""
|
||||
|
||||
def __init__(self, username: str, persona_interests: list[str] = None):
|
||||
self.username = username
|
||||
self.persona_memory = PersonaMemoryDB()
|
||||
self.persona_interests = persona_interests or []
|
||||
self.strategy = "aggressive_growth" # Will be updated by orchestrator
|
||||
self.strategy = "aggressive_growth" # Will be updated by orchestrator
|
||||
self.last_learning_at = datetime.now()
|
||||
|
||||
|
||||
def evaluate_governance(self, dopamine_engine, job_target: str, is_reels: bool = False) -> str:
|
||||
"""
|
||||
Global Strategy Oracle.
|
||||
Decides if the bot should stay in the current feed, check curiosity targets,
|
||||
or escape due to boredom.
|
||||
|
||||
|
||||
Returns: "STAY", "SHIFT_CONTEXT", "CHECK_CURIOSITY"
|
||||
"""
|
||||
# 1. Boredom Check (Priority 1)
|
||||
if dopamine_engine.boredom > 85.0 and random.random() < 0.2:
|
||||
logger.info("🧠 [GrowthBrain] Supreme boredom reached or periodic shift triggered. Decision: SHIFT_CONTEXT.")
|
||||
logger.info(
|
||||
"🧠 [GrowthBrain] Supreme boredom reached or periodic shift triggered. Decision: SHIFT_CONTEXT."
|
||||
)
|
||||
return "SHIFT_CONTEXT"
|
||||
|
||||
|
||||
# 2. Curiosity Check (Priority 2)
|
||||
# Only in main feeds, not during deep reels sessions
|
||||
if job_target.lower() in ("homefeed", "feed", "home") and not is_reels:
|
||||
if random.random() < 0.06:
|
||||
logger.info("🧠 [GrowthBrain] Spontaneous curiosity spike. Decision: CHECK_CURIOSITY.")
|
||||
return "CHECK_CURIOSITY"
|
||||
|
||||
|
||||
return "STAY"
|
||||
|
||||
def get_current_desire(self, dopamine_engine, available_targets=None) -> str:
|
||||
"""
|
||||
Agent Core: Determines what the bot actually WANTS to do right now,
|
||||
based on strategy, circadian rhythm, and dopamine/boredom levels.
|
||||
|
||||
|
||||
Returns a high-level semantic Desire string.
|
||||
"""
|
||||
if dopamine_engine.boredom > 80.0:
|
||||
logger.info("🧠 [GrowthBrain] Internal drive: Context shift required.")
|
||||
return "ShiftContext"
|
||||
|
||||
|
||||
weights = {}
|
||||
if self.strategy == "aggressive_growth":
|
||||
weights = {
|
||||
"DiscoverNewContent": 60, # Explore, Reels
|
||||
"NurtureCommunity": 15, # HomeFeed
|
||||
"SocialReciprocity": 25, # Follow list, DMs
|
||||
"NurtureCommunity": 15, # HomeFeed
|
||||
"SocialReciprocity": 25, # Follow list, DMs
|
||||
}
|
||||
elif self.strategy == "community_builder":
|
||||
weights = {
|
||||
@@ -74,31 +79,31 @@ class GrowthBrain:
|
||||
"NurtureCommunity": 20,
|
||||
"SocialReciprocity": 0,
|
||||
}
|
||||
else: # stealth_lurker
|
||||
else: # stealth_lurker
|
||||
weights = {
|
||||
"DiscoverNewContent": 40,
|
||||
"NurtureCommunity": 50,
|
||||
"SocialReciprocity": 10,
|
||||
}
|
||||
|
||||
|
||||
choices = []
|
||||
for desire, weight in weights.items():
|
||||
choices.extend([desire] * weight)
|
||||
|
||||
|
||||
selected_desire = random.choice(choices)
|
||||
logger.info(f"🧠 [GrowthBrain] Strategy '{self.strategy}' dictated Desire: {selected_desire}")
|
||||
return selected_desire
|
||||
|
||||
|
||||
def get_circadian_pacing(self) -> float:
|
||||
"""
|
||||
Adjusts activity levels based on the current local time
|
||||
Adjusts activity levels based on the current local time
|
||||
to simulate human sleep/wake cycles.
|
||||
|
||||
|
||||
Returns a multiplier (0.1 to 1.0) that should be applied to ALL sleep durations.
|
||||
Lower = slower (more human-like during off-hours).
|
||||
"""
|
||||
hour = datetime.now().hour
|
||||
|
||||
|
||||
# Determine current pacing state
|
||||
if 2 <= hour <= 5:
|
||||
pacing = 0.1
|
||||
@@ -120,39 +125,39 @@ class GrowthBrain:
|
||||
pacing = 1.0
|
||||
state_id = "peak_hours"
|
||||
msg = "🧠 [GrowthBrain] Peak metabolic rate. Performance 100%."
|
||||
|
||||
|
||||
# Log intelligently (only info log on state change)
|
||||
if not hasattr(self, '_last_pacing_state') or getattr(self, '_last_pacing_state') != state_id:
|
||||
if not hasattr(self, "_last_pacing_state") or getattr(self, "_last_pacing_state") != state_id:
|
||||
logger.info(msg, extra={"color": f"{Fore.GREEN}"})
|
||||
self._last_pacing_state = state_id
|
||||
else:
|
||||
logger.debug(msg)
|
||||
|
||||
|
||||
return pacing
|
||||
|
||||
def refine_persona(self, interaction_outcomes: list[dict]):
|
||||
"""
|
||||
Learns from interaction outcomes to refine persona understanding.
|
||||
|
||||
|
||||
interaction_outcomes: [{'username': str, 'action': 'like'|'comment'|'skip', 'resonance': float}]
|
||||
|
||||
|
||||
Stores high-performing interaction patterns in PersonaMemoryDB.
|
||||
"""
|
||||
if not interaction_outcomes:
|
||||
return
|
||||
|
||||
|
||||
# Find interactions that had high resonance (those are our niche)
|
||||
high_res = [o for o in interaction_outcomes if o.get("resonance", 0) > 0.7]
|
||||
low_res = [o for o in interaction_outcomes if o.get("resonance", 0) < 0.3]
|
||||
|
||||
|
||||
if high_res:
|
||||
insight = f"High-resonance interactions in this session: {len(high_res)} posts matched niche."
|
||||
self.persona_memory.store_persona_insight("session_learning", insight)
|
||||
logger.info(
|
||||
f"🧠 [GrowthBrain] Session learning: {len(high_res)} high-resonance, {len(low_res)} low-resonance posts.",
|
||||
extra={"color": f"{Fore.GREEN}"}
|
||||
extra={"color": f"{Fore.GREEN}"},
|
||||
)
|
||||
|
||||
|
||||
self.last_learning_at = datetime.now()
|
||||
|
||||
def get_persona_context(self) -> str:
|
||||
@@ -160,7 +165,7 @@ class GrowthBrain:
|
||||
base = ""
|
||||
if self.persona_interests:
|
||||
base = f"Core interests: {', '.join(self.persona_interests)}"
|
||||
|
||||
|
||||
learned = self.persona_memory.get_persona_context()
|
||||
if learned:
|
||||
return f"{base}\n{learned}" if base else learned
|
||||
@@ -171,7 +176,8 @@ class GrowthBrain:
|
||||
def wants_to_double_tap(self, is_reel: bool = False) -> bool:
|
||||
"""Determines if the bot should use double-tap for likes."""
|
||||
prob = 0.45 if self.strategy == "aggressive_growth" else 0.25
|
||||
if is_reel: prob += 0.20 # People double-tap reels more often
|
||||
if is_reel:
|
||||
prob += 0.20 # People double-tap reels more often
|
||||
return random.random() < prob
|
||||
|
||||
def evaluate_hesitation(self) -> bool:
|
||||
@@ -182,6 +188,7 @@ class GrowthBrain:
|
||||
|
||||
def wants_to_repost(self, resonance_score: float) -> bool:
|
||||
"""Decides if content is worthy of a repost."""
|
||||
if resonance_score < 0.85: return False
|
||||
if resonance_score < 0.85:
|
||||
return False
|
||||
prob = 0.3 if self.strategy == "aggressive_growth" else 0.1
|
||||
return random.random() < prob
|
||||
|
||||
@@ -1,71 +1,77 @@
|
||||
import re
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
import logging
|
||||
from typing import Optional, List, Dict
|
||||
import os
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_json(text: str) -> Optional[str]:
|
||||
"""
|
||||
Robustly extracts the first JSON object or array from a string that may contain
|
||||
Robustly extracts the first JSON object or array from a string that may contain
|
||||
natural language prefix/suffix. Also purges <think> blocks and markdown ticks.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
|
||||
# 100% Autonomous: Scrub model's internal thinking process
|
||||
if "<think>" in text:
|
||||
text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
|
||||
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
|
||||
logger.debug("🧠 [LLM] Scoped thinking block detected and purged.")
|
||||
|
||||
# Remove markdown code block formats
|
||||
text = re.sub(r'^```json\s*', '', text, flags=re.MULTILINE)
|
||||
text = re.sub(r'^```\s*', '', text, flags=re.MULTILINE)
|
||||
text = re.sub(r"^```json\s*", "", text, flags=re.MULTILINE)
|
||||
text = re.sub(r"^```\s*", "", text, flags=re.MULTILINE)
|
||||
|
||||
# Try perfect json block extraction first
|
||||
match = re.search(r'(\{.*\}|\[.*\])', text, re.DOTALL)
|
||||
match = re.search(r"(\{.*\}|\[.*\])", text, re.DOTALL)
|
||||
if match:
|
||||
candidate = match.group(0)
|
||||
try:
|
||||
import json
|
||||
|
||||
json.loads(candidate)
|
||||
return candidate
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Smart Fallback: Truncated JSON Healing
|
||||
# If standard validation fails (e.g., due to EOF truncation by local models),
|
||||
# run a regex extraction pass over the raw generated text to safely salvage
|
||||
# If standard validation fails (e.g., due to EOF truncation by local models),
|
||||
# run a regex extraction pass over the raw generated text to safely salvage
|
||||
# all key-value pairs that *were* successfully completed before the truncation.
|
||||
import json
|
||||
|
||||
matches = re.findall(r'"([a-zA-Z0-9_]+)"\s*:\s*(?:([0-9.-]+)|"([^"\\]*(?:\\.[^"\\]*)*)")', text)
|
||||
if matches:
|
||||
res = {}
|
||||
for k, num, obj in matches:
|
||||
if num:
|
||||
try:
|
||||
res[k] = float(num) if '.' in num else int(num)
|
||||
res[k] = float(num) if "." in num else int(num)
|
||||
except ValueError:
|
||||
res[k] = num
|
||||
else:
|
||||
res[k] = obj.replace('\\"', '"')
|
||||
|
||||
|
||||
recovered_json = json.dumps(res)
|
||||
logger.warning(f"🔧 [Fuzzy Parse] Successfully salvaged {len(res)} keys from heavily truncated LLM output.")
|
||||
return recovered_json
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
_MODEL_PRICING_CACHE = None
|
||||
|
||||
|
||||
def get_model_pricing(model_id: str) -> dict:
|
||||
global _MODEL_PRICING_CACHE
|
||||
if _MODEL_PRICING_CACHE is None:
|
||||
@@ -78,118 +84,128 @@ def get_model_pricing(model_id: str) -> dict:
|
||||
_MODEL_PRICING_CACHE = {}
|
||||
except Exception:
|
||||
_MODEL_PRICING_CACHE = {}
|
||||
|
||||
|
||||
# Check if exact match exists, if not, try partial matches (e.g., if version suffixes differ)
|
||||
if _MODEL_PRICING_CACHE and model_id not in _MODEL_PRICING_CACHE:
|
||||
for k, v in _MODEL_PRICING_CACHE.items():
|
||||
if model_id in k or k in model_id:
|
||||
return v
|
||||
|
||||
|
||||
return _MODEL_PRICING_CACHE.get(model_id, {})
|
||||
|
||||
|
||||
def prewarm_ollama_models(configs):
|
||||
"""
|
||||
Sends a dummy request to the configured local Ollama API endpoints via a background thread
|
||||
Sends a dummy request to the configured local Ollama API endpoints via a background thread
|
||||
to force the models to load into VRAM during bot startup, minimizing initial connection latency
|
||||
and avoiding timeouts downstream.
|
||||
"""
|
||||
args = configs.args
|
||||
|
||||
|
||||
def _warmup():
|
||||
import threading
|
||||
models_to_warm = set()
|
||||
|
||||
|
||||
# Collect unique local models
|
||||
for attr, url_attr in [
|
||||
("ai_telepathic_model", "ai_telepathic_url"),
|
||||
("ai_fallback_model", "ai_fallback_url"),
|
||||
("ai_condenser_model", "ai_condenser_url"),
|
||||
("ai_model", "ai_model_url")
|
||||
("ai_model", "ai_model_url"),
|
||||
]:
|
||||
url = getattr(args, url_attr, "")
|
||||
model = getattr(args, attr, "")
|
||||
if model and url and ("localhost" in url or "127.0.0.1" in url):
|
||||
models_to_warm.add((url, model))
|
||||
|
||||
|
||||
for url, model in models_to_warm:
|
||||
logger.info(f"🔥 [VRAM Pre-Warm] Instructing local Ollama engine to load {model} into memory in the background...")
|
||||
logger.info(
|
||||
f"🔥 [VRAM Pre-Warm] Instructing local Ollama engine to load {model} into memory in the background..."
|
||||
)
|
||||
try:
|
||||
# Fire an ultra-short generation to force it into VRAM
|
||||
requests.post(
|
||||
url,
|
||||
json={"model": model, "prompt": "Hi", "stream": False, "options": {"num_predict": 1}},
|
||||
timeout=120
|
||||
url,
|
||||
json={"model": model, "prompt": "Hi", "stream": False, "options": {"num_predict": 1}},
|
||||
timeout=120,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if hasattr(args, "ai_telepathic_model"):
|
||||
import threading
|
||||
|
||||
threading.Thread(target=_warmup, daemon=True).start()
|
||||
|
||||
|
||||
def unload_ollama_models(configs):
|
||||
"""
|
||||
Sends keep_alive: 0 to all configured local Ollama API endpoints via a background thread
|
||||
Sends keep_alive: 0 to all configured local Ollama API endpoints via a background thread
|
||||
to force the models to unload from VRAM during bot shutdown.
|
||||
"""
|
||||
args = configs.args
|
||||
|
||||
|
||||
def _unload():
|
||||
import threading
|
||||
models_to_unload = set()
|
||||
|
||||
|
||||
# Collect unique local models
|
||||
for attr, url_attr in [
|
||||
("ai_telepathic_model", "ai_telepathic_url"),
|
||||
("ai_fallback_model", "ai_fallback_url"),
|
||||
("ai_condenser_model", "ai_condenser_url"),
|
||||
("ai_model", "ai_model_url")
|
||||
("ai_model", "ai_model_url"),
|
||||
]:
|
||||
url = getattr(args, url_attr, "")
|
||||
model = getattr(args, attr, "")
|
||||
if model and url and ("localhost" in url or "127.0.0.1" in url):
|
||||
models_to_unload.add((url, model))
|
||||
|
||||
|
||||
for url, model in models_to_unload:
|
||||
logger.info(f"❄️ [VRAM Cleanup] Instructing local Ollama engine to unload {model} from memory...")
|
||||
try:
|
||||
# Fire keep_alive: 0 to unload it from VRAM
|
||||
requests.post(
|
||||
url,
|
||||
json={"model": model, "keep_alive": 0},
|
||||
timeout=5
|
||||
)
|
||||
requests.post(url, json={"model": model, "keep_alive": 0}, timeout=5)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to unload {model}: {e}")
|
||||
|
||||
|
||||
if hasattr(args, "ai_telepathic_model"):
|
||||
import threading
|
||||
|
||||
threading.Thread(target=_unload, daemon=True).start()
|
||||
|
||||
|
||||
def log_openrouter_burn():
|
||||
"""Fetches and logs the current OpenRouter API key usage (money burned) ONLY if OpenRouter is actively used."""
|
||||
key = os.environ.get("OPENROUTER_API_KEY")
|
||||
if not key:
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
from GramAddict.core.config import Config
|
||||
|
||||
args = Config().args
|
||||
uses_openrouter = False
|
||||
|
||||
|
||||
# Check all possible model/url endpoints for 'openrouter'
|
||||
for attr in ["ai_model", "ai_model_url", "ai_telepathic_model", "ai_telepathic_url",
|
||||
"ai_fallback_model", "ai_fallback_url", "ai_condenser_model", "ai_condenser_url"]:
|
||||
for attr in [
|
||||
"ai_model",
|
||||
"ai_model_url",
|
||||
"ai_telepathic_model",
|
||||
"ai_telepathic_url",
|
||||
"ai_fallback_model",
|
||||
"ai_fallback_url",
|
||||
"ai_condenser_model",
|
||||
"ai_condenser_url",
|
||||
]:
|
||||
val = getattr(args, attr, "")
|
||||
if val and "openrouter" in str(val).lower():
|
||||
uses_openrouter = True
|
||||
break
|
||||
|
||||
|
||||
if not uses_openrouter:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
try:
|
||||
r = requests.get("https://openrouter.ai/api/v1/auth/key", headers={"Authorization": f"Bearer {key}"}, timeout=5)
|
||||
if r.status_code == 200:
|
||||
@@ -197,11 +213,16 @@ def log_openrouter_burn():
|
||||
total_spent = data.get("usage", 0.0)
|
||||
daily_spent = data.get("usage_daily", 0.0)
|
||||
limit = data.get("limit")
|
||||
|
||||
logger.info(f"🔥 [OpenRouter Burn Rate] Daily: ${daily_spent:.4f} | Total: ${total_spent:.4f}" + (f" | Limit: ${limit}" if limit else ""), extra={"color": "\x1b[38;5;208m\x1b[1m"})
|
||||
|
||||
logger.info(
|
||||
f"🔥 [OpenRouter Burn Rate] Daily: ${daily_spent:.4f} | Total: ${total_spent:.4f}"
|
||||
+ (f" | Limit: ${limit}" if limit else ""),
|
||||
extra={"color": "\x1b[38;5;208m\x1b[1m"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not fetch OpenRouter burn rate: {e}")
|
||||
|
||||
|
||||
def query_llm(
|
||||
url: str,
|
||||
model: str,
|
||||
@@ -213,16 +234,16 @@ def query_llm(
|
||||
fallback_model: Optional[str] = None,
|
||||
fallback_url: Optional[str] = None,
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: Optional[int] = None
|
||||
max_tokens: Optional[int] = None,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Unified LLM API Caller with configurable fallback.
|
||||
"""
|
||||
openrouter_key = os.environ.get("OPENROUTER_API_KEY")
|
||||
|
||||
|
||||
# URL-based provider detection (not model-name based — works for any model)
|
||||
is_openai_compat = "/v1/chat/completions" in url or "openrouter.ai" in url.lower() or "openai.com" in url.lower()
|
||||
|
||||
|
||||
# If using a cloud model but a local URL was passed, fix it
|
||||
if not is_openai_compat and ("openrouter" in model.lower() or "/" in model):
|
||||
# Model looks like "org/model-name" which is OpenRouter format
|
||||
@@ -230,54 +251,43 @@ def query_llm(
|
||||
url = "https://openrouter.ai/api/v1/chat/completions"
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
if is_openai_compat:
|
||||
if openrouter_key:
|
||||
headers["Authorization"] = f"Bearer {openrouter_key}"
|
||||
|
||||
|
||||
messages = []
|
||||
if system:
|
||||
messages.append({"role": "system", "content": system})
|
||||
|
||||
|
||||
user_content = []
|
||||
if prompt:
|
||||
user_content.append({"type": "text", "text": prompt})
|
||||
|
||||
|
||||
if images_b64:
|
||||
for img in images_b64:
|
||||
user_content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{img}"}
|
||||
})
|
||||
|
||||
user_content.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img}"}})
|
||||
|
||||
messages.append({"role": "user", "content": user_content if len(user_content) > 1 else prompt})
|
||||
|
||||
req_data = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": False
|
||||
}
|
||||
|
||||
req_data = {"model": model, "messages": messages, "stream": False}
|
||||
if format_json:
|
||||
req_data["response_format"] = {"type": "json_object"}
|
||||
if temperature is not None:
|
||||
req_data["temperature"] = temperature
|
||||
if max_tokens is not None:
|
||||
req_data["max_tokens"] = max_tokens
|
||||
|
||||
|
||||
else:
|
||||
# Ollama /generate API
|
||||
req_data = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": False
|
||||
}
|
||||
req_data = {"model": model, "prompt": prompt, "stream": False}
|
||||
if system:
|
||||
req_data["system"] = system
|
||||
if images_b64:
|
||||
req_data["images"] = images_b64
|
||||
if format_json:
|
||||
req_data["format"] = "json"
|
||||
|
||||
|
||||
# Ollama passes configs inside 'options'
|
||||
if temperature is not None or max_tokens is not None:
|
||||
req_data["options"] = {}
|
||||
@@ -290,12 +300,12 @@ def query_llm(
|
||||
response = requests.post(url, json=req_data, headers=headers, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
resp_json = response.json()
|
||||
|
||||
|
||||
# Normalize response payload so callers don't have to distinguish
|
||||
if is_openai_compat:
|
||||
# OpenRouter returns choices[0].message.content
|
||||
content = resp_json.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
|
||||
|
||||
usage = resp_json.get("usage", {})
|
||||
if usage:
|
||||
cost_str = ""
|
||||
@@ -306,26 +316,29 @@ def query_llm(
|
||||
pricing = get_model_pricing(model)
|
||||
if pricing:
|
||||
try:
|
||||
p_cost = float(pricing.get("prompt", 0)) * usage.get('prompt_tokens', 0)
|
||||
c_cost = float(pricing.get("completion", 0)) * usage.get('completion_tokens', 0)
|
||||
p_cost = float(pricing.get("prompt", 0)) * usage.get("prompt_tokens", 0)
|
||||
c_cost = float(pricing.get("completion", 0)) * usage.get("completion_tokens", 0)
|
||||
calc_cost = p_cost + c_cost
|
||||
if calc_cost > 0:
|
||||
cost_str = f" | 💸 Cost: ${calc_cost:.6f}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
p_tokens = usage.get('prompt_tokens', 0)
|
||||
c_tokens = usage.get('completion_tokens', 0)
|
||||
t_tokens = usage.get('total_tokens', 0)
|
||||
|
||||
|
||||
p_tokens = usage.get("prompt_tokens", 0)
|
||||
c_tokens = usage.get("completion_tokens", 0)
|
||||
t_tokens = usage.get("total_tokens", 0)
|
||||
|
||||
# Make it stand out!
|
||||
logger.info(f"🪙 [LLM Burn] {model} -> In: {p_tokens} | Out: {c_tokens} | Total: {t_tokens}{cost_str}", extra={"color": "\x1b[38;5;208m\x1b[1m"})
|
||||
|
||||
logger.info(
|
||||
f"🪙 [LLM Burn] {model} -> In: {p_tokens} | Out: {c_tokens} | Total: {t_tokens}{cost_str}",
|
||||
extra={"color": "\x1b[38;5;208m\x1b[1m"},
|
||||
)
|
||||
|
||||
# Validation: if JSON was expected, try to extract it
|
||||
if format_json:
|
||||
extracted = extract_json(content)
|
||||
if not extracted:
|
||||
raise ValueError(f"OpenRouter returned non-JSON content when JSON was expected: {content[:100]}...")
|
||||
raise ValueError(f"OpenRouter returned non-JSON content when JSON was expected: {content[:100]}...")
|
||||
content = extracted
|
||||
|
||||
return {"response": content}
|
||||
@@ -337,31 +350,34 @@ def query_llm(
|
||||
if not extracted:
|
||||
# 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.")
|
||||
raise ValueError("Ollama returned non-JSON content when JSON was expected.")
|
||||
resp_json["response"] = extracted
|
||||
|
||||
return resp_json
|
||||
except requests.exceptions.ConnectionError:
|
||||
logger.error(f"⚠️ [LLM Provider] Connection refused for {model} at {url}. Is the service running?")
|
||||
except Exception as e:
|
||||
logger.error(f"LLM Provider Error with {model}: {e}")
|
||||
|
||||
|
||||
# Prevent infinite fallback loops
|
||||
if getattr(query_llm, "_is_fallback", False):
|
||||
return None
|
||||
|
||||
|
||||
# Decide on fallback model/url
|
||||
f_model = fallback_model
|
||||
f_url = fallback_url
|
||||
|
||||
|
||||
# Read fallback config from args if available
|
||||
if not f_model or not f_url:
|
||||
from GramAddict.core.config import Config
|
||||
|
||||
try:
|
||||
args = Config().args
|
||||
f_model = f_model or getattr(args, "ai_fallback_model", None)
|
||||
f_url = f_url or getattr(args, "ai_fallback_url", None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Last resort defaults
|
||||
if not f_model or not f_url:
|
||||
if is_openai_compat:
|
||||
@@ -388,12 +404,13 @@ def query_llm(
|
||||
format_json=format_json,
|
||||
timeout=timeout,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
finally:
|
||||
query_llm._is_fallback = False
|
||||
return None
|
||||
|
||||
|
||||
def query_telepathic_llm(
|
||||
model: str,
|
||||
url: str,
|
||||
@@ -401,7 +418,7 @@ def query_telepathic_llm(
|
||||
user_prompt: str,
|
||||
temperature: float = 0.0,
|
||||
use_local_edge: bool = False,
|
||||
images_b64: Optional[List[str]] = None
|
||||
images_b64: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Routes UI Telepathic requests purely based on textual interpretation of the screen's XML nodes.
|
||||
@@ -415,10 +432,13 @@ def query_telepathic_llm(
|
||||
if use_local_edge:
|
||||
is_already_local = "localhost" in url or "127.0.0.1" in url
|
||||
if is_already_local:
|
||||
logger.debug(f"⚡ [Edge Inference] Primary model {model} is already local. Using it directly to prevent VRAM thrashing.")
|
||||
logger.debug(
|
||||
f"⚡ [Edge Inference] Primary model {model} is already local. Using it directly to prevent VRAM thrashing."
|
||||
)
|
||||
else:
|
||||
logger.info("⚡ [Edge Inference] Routing telepathic request to local Ollama host (0ms latency target).")
|
||||
from GramAddict.core.config import Config
|
||||
|
||||
try:
|
||||
args = Config().args
|
||||
target_url = getattr(args, "ai_fallback_url", "http://localhost:11434/api/generate")
|
||||
@@ -426,10 +446,10 @@ def query_telepathic_llm(
|
||||
except Exception:
|
||||
target_url = "http://localhost:11434/api/generate"
|
||||
target_model = "llama3.2:1b"
|
||||
|
||||
|
||||
is_local = "localhost" in target_url or "127.0.0.1" in target_url
|
||||
calc_timeout = 180 if is_local else 45
|
||||
|
||||
|
||||
ans = query_llm(
|
||||
url=target_url,
|
||||
model=target_model,
|
||||
@@ -439,7 +459,7 @@ def query_telepathic_llm(
|
||||
format_json=True,
|
||||
timeout=calc_timeout, # Navigation VLM must fail fast for Cloud, but wait for Local VRAM loads
|
||||
temperature=temperature,
|
||||
max_tokens=150 # Hard stop to prevent VLM from endlessly hallucinating UI elements
|
||||
max_tokens=150, # Hard stop to prevent VLM from endlessly hallucinating UI elements
|
||||
)
|
||||
if ans and "response" in ans:
|
||||
return ans["response"]
|
||||
|
||||
1
GramAddict/core/navigation/__init__.py
Normal file
1
GramAddict/core/navigation/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Navigation domain package
|
||||
214
GramAddict/core/navigation/knowledge.py
Normal file
214
GramAddict/core/navigation/knowledge.py
Normal file
@@ -0,0 +1,214 @@
|
||||
import logging
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
from GramAddict.core.perception.screen_identity import ScreenType
|
||||
from GramAddict.core.qdrant_memory import QdrantBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NavigationKnowledge:
|
||||
"""
|
||||
Manages the bot's learned understanding of the Instagram UI.
|
||||
Discovered dynamically through exploration and success.
|
||||
"""
|
||||
|
||||
def __init__(self, username: str):
|
||||
self.username = username
|
||||
try:
|
||||
self._db = QdrantBase("navigation_knowledge", vector_size=768)
|
||||
except Exception:
|
||||
self._db = None
|
||||
|
||||
# In-memory cache for rapidly avoiding traps during exploration
|
||||
# In-memory cache for rapidly avoiding traps during exploration
|
||||
self._learned_screen_mappings = {}
|
||||
self._learned_traps = set()
|
||||
|
||||
def wipe(self):
|
||||
"""Wipe all learned knowledge from Qdrant."""
|
||||
if self._db and self._db.is_connected:
|
||||
try:
|
||||
self._db.wipe_collection()
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ [NavigationKnowledge] Could not wipe knowledge: {e}")
|
||||
|
||||
def update_username(self, username: str):
|
||||
"""Update username and reconnect DB if needed."""
|
||||
if self.username != username:
|
||||
self.username = username
|
||||
try:
|
||||
self._db = QdrantBase("navigation_knowledge", vector_size=768)
|
||||
except Exception:
|
||||
self._db = None
|
||||
|
||||
def get_requirements(self, goal: str) -> List[ScreenType]:
|
||||
"""Get required screens for a goal. Returns known requirements or empty list."""
|
||||
if not self._db or not self._db.is_connected:
|
||||
return []
|
||||
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
results = self._db.client.scroll(
|
||||
collection_name=self._db.collection_name,
|
||||
scroll_filter=Filter(must=[FieldCondition(key="goal", match=MatchValue(value=goal))]),
|
||||
limit=1,
|
||||
)[0]
|
||||
if results:
|
||||
screen_name = results[0].payload.get("required_screen")
|
||||
logger.debug(f"🧠 [Nav Knowledge] Found requirement for '{goal}': {screen_name}")
|
||||
if screen_name:
|
||||
return [ScreenType[screen_name]]
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ [Nav Knowledge] Search error: {e}")
|
||||
return []
|
||||
|
||||
def learn_goal_requirement(self, goal: str, screen_type: ScreenType):
|
||||
"""Learn that achieving 'goal' lands us on 'screen_type'."""
|
||||
if not self._db or not self._db.is_connected:
|
||||
logger.warning("⚠️ [Nav Knowledge] Cannot learn: DB not connected")
|
||||
return
|
||||
|
||||
seed = f"req_{goal}"
|
||||
vec = self._db._get_embedding(f"goal_requirement: {goal}")
|
||||
payload = {"goal": goal, "required_screen": screen_type.name, "timestamp": time.time()}
|
||||
self._db.upsert_point(seed, payload, vector=vec)
|
||||
logger.info(f"🧠 [Nav Knowledge] Learned: '{goal}' → {screen_type.name}")
|
||||
|
||||
def get_action_for_screen(self, target_screen: ScreenType) -> Optional[str]:
|
||||
"""Find which action leads to this screen."""
|
||||
for action, screen in self._learned_screen_mappings.items():
|
||||
if screen == target_screen:
|
||||
return action
|
||||
|
||||
if not self._db or not self._db.is_connected:
|
||||
return None
|
||||
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
results = self._db.client.scroll(
|
||||
collection_name=self._db.collection_name,
|
||||
scroll_filter=Filter(
|
||||
must=[FieldCondition(key="result_screen", match=MatchValue(value=target_screen.name))]
|
||||
),
|
||||
limit=1,
|
||||
)[0]
|
||||
if results:
|
||||
return results[0].payload.get("action")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def get_screen_for_action(self, action: str) -> Optional[ScreenType]:
|
||||
"""Find where this action leads to to avoid looping traps."""
|
||||
if action in self._learned_screen_mappings:
|
||||
return self._learned_screen_mappings[action]
|
||||
|
||||
if not self._db or not self._db.is_connected:
|
||||
return None
|
||||
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
results = self._db.client.scroll(
|
||||
collection_name=self._db.collection_name,
|
||||
scroll_filter=Filter(must=[FieldCondition(key="action", match=MatchValue(value=action))]),
|
||||
limit=1,
|
||||
)[0]
|
||||
if results:
|
||||
screen_name = results[0].payload.get("result_screen")
|
||||
if screen_name:
|
||||
return ScreenType[screen_name]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def learn_screen_mapping(self, action: str, result_screen: ScreenType):
|
||||
"""Learn that taking 'action' leads to 'result_screen'."""
|
||||
if not self._db or not self._db.is_connected:
|
||||
return
|
||||
|
||||
seed = f"map_{action}"
|
||||
vec = self._db._get_embedding(f"screen_mapping: {result_screen.name}")
|
||||
payload = {"action": action, "result_screen": result_screen.name, "timestamp": time.time()}
|
||||
|
||||
self._learned_screen_mappings[action] = result_screen
|
||||
|
||||
self._db.upsert_point(seed, payload, vector=vec)
|
||||
logger.info(f"🧠 [Nav Knowledge] Learned Mapping: '{action}' → {result_screen.name}")
|
||||
|
||||
def get_screen_for_tab(self, tab_id: str) -> Optional[ScreenType]:
|
||||
"""Find where this tab leads to to avoid looping traps."""
|
||||
if tab_id in self._learned_screen_mappings:
|
||||
return self._learned_screen_mappings[tab_id]
|
||||
|
||||
if not self._db or not self._db.is_connected:
|
||||
return None
|
||||
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
results = self._db.client.scroll(
|
||||
collection_name=self._db.collection_name,
|
||||
scroll_filter=Filter(must=[FieldCondition(key="tab_id", match=MatchValue(value=tab_id))]),
|
||||
limit=1,
|
||||
)[0]
|
||||
if results:
|
||||
s_name = results[0].payload.get("result_screen")
|
||||
if s_name:
|
||||
return ScreenType[s_name]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def learn_trap(self, screen_type: ScreenType, action: str, trap_reason: str = "softlock"):
|
||||
"""Aversively learn that an action on a screen is dangerous/useless."""
|
||||
trap_key = f"{screen_type.name}_{action}"
|
||||
self._learned_traps.add(trap_key)
|
||||
|
||||
if not self._db or not self._db.is_connected:
|
||||
return
|
||||
|
||||
seed = f"trap_{trap_key}"
|
||||
# Aversive vector is completely orthogonal to normal goals to prevent retrieval overlap
|
||||
vec = self._db._get_embedding(f"trap_avoidance: {trap_key} {trap_reason}")
|
||||
payload = {
|
||||
"trap_screen": screen_type.name,
|
||||
"trap_action": action,
|
||||
"trap_reason": trap_reason,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
self._db.upsert_point(seed, payload, vector=vec)
|
||||
logger.error(f"💀 [Aversive Learning] BURNED action '{action}' on {screen_type.name} due to: {trap_reason}")
|
||||
|
||||
def is_trap(self, screen_type: ScreenType, action: str) -> bool:
|
||||
"""Check if an action on this screen is a known trap."""
|
||||
trap_key = f"{screen_type.name}_{action}"
|
||||
if trap_key in self._learned_traps:
|
||||
return True
|
||||
|
||||
if not self._db or not self._db.is_connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
results = self._db.client.scroll(
|
||||
collection_name=self._db.collection_name,
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(key="trap_screen", match=MatchValue(value=screen_type.name)),
|
||||
FieldCondition(key="trap_action", match=MatchValue(value=action)),
|
||||
]
|
||||
),
|
||||
limit=1,
|
||||
)[0]
|
||||
if results:
|
||||
self._learned_traps.add(trap_key)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
117
GramAddict/core/navigation/path_memory.py
Normal file
117
GramAddict/core/navigation/path_memory.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from GramAddict.core.qdrant_memory import QdrantBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PathMemory:
|
||||
"""
|
||||
Qdrant-backed memory for successful navigation paths.
|
||||
|
||||
Stores: goal → [step1, step2, ...] → success
|
||||
Enables instant recall for known goals.
|
||||
"""
|
||||
|
||||
def __init__(self, username: str = ""):
|
||||
self.username = username
|
||||
try:
|
||||
suffix = f"_{username}" if username else ""
|
||||
self._db = QdrantBase(f"goap_paths_v1{suffix}", vector_size=768)
|
||||
except Exception:
|
||||
self._db = None
|
||||
|
||||
def wipe(self):
|
||||
"""Wipe all learned navigation paths from Qdrant."""
|
||||
if self._db and self._db.is_connected:
|
||||
try:
|
||||
self._db.wipe_collection()
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ [PathMemory] Could not wipe collection: {e}")
|
||||
|
||||
def recall_path(self, goal: str, current_screen_type: str) -> Optional[List[Dict]]:
|
||||
"""
|
||||
Recall a previously successful path for this goal from this screen type.
|
||||
Returns list of steps or None.
|
||||
"""
|
||||
if not self._db or not self._db.is_connected:
|
||||
return None
|
||||
|
||||
query = f"goal: {goal} | from: {current_screen_type}"
|
||||
vec = self._db._get_embedding(query)
|
||||
if not vec:
|
||||
return None
|
||||
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
results = self._db.client.query_points(
|
||||
collection_name=self._db.collection_name,
|
||||
query=vec,
|
||||
query_filter=Filter(
|
||||
must=[FieldCondition(key="start_screen", match=MatchValue(value=current_screen_type))]
|
||||
),
|
||||
limit=3,
|
||||
score_threshold=0.85,
|
||||
).points
|
||||
|
||||
for r in results:
|
||||
p = r.payload
|
||||
if p.get("success") and p.get("steps"):
|
||||
logger.info(
|
||||
f"🧠 [GOAP Recall] Found path for '{goal}': "
|
||||
f"{len(p['steps'])} steps (confidence: {p.get('confidence', 0):.2f})"
|
||||
)
|
||||
return p["steps"]
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"GOAP recall error: {e}")
|
||||
return None
|
||||
|
||||
def learn_path(self, goal: str, start_screen: str, steps: List[Dict], success: bool):
|
||||
"""Store a navigation path in Qdrant."""
|
||||
if not self._db or not self._db.is_connected:
|
||||
return
|
||||
|
||||
query = f"goal: {goal} | from: {start_screen}"
|
||||
vec = self._db._get_embedding(query)
|
||||
if not vec:
|
||||
return
|
||||
|
||||
seed = f"{goal}|{start_screen}"
|
||||
payload = {
|
||||
"goal": goal,
|
||||
"start_screen": start_screen,
|
||||
"steps": steps,
|
||||
"step_count": len(steps),
|
||||
"success": success,
|
||||
"confidence": 0.85 if success else 0.0,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
outcome = "✅" if success else "❌"
|
||||
self._db.upsert_point(
|
||||
seed,
|
||||
payload,
|
||||
vector=vec,
|
||||
log_success=f"🧠 [GOAP Learn] {outcome} Path for '{goal}': {len(steps)} steps from {start_screen}",
|
||||
)
|
||||
|
||||
def forget_path(self, goal: str, start_screen: str):
|
||||
"""Remove a cached path to force re-discovery."""
|
||||
if not self._db or not self._db.is_connected:
|
||||
return
|
||||
|
||||
seed = f"{goal}|{start_screen}"
|
||||
try:
|
||||
from qdrant_client import models
|
||||
|
||||
point_id = self._db._get_id(seed)
|
||||
self._db.client.delete(
|
||||
collection_name=self._db.collection_name, points_selector=models.PointIdsList(points=[point_id])
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to forget path: {e}")
|
||||
171
GramAddict/core/navigation/planner.py
Normal file
171
GramAddict/core/navigation/planner.py
Normal file
@@ -0,0 +1,171 @@
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from GramAddict.core.navigation.knowledge import NavigationKnowledge
|
||||
from GramAddict.core.perception.screen_identity import ScreenType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GoalPlanner:
|
||||
"""
|
||||
Given a goal and current screen state, plans the next action.
|
||||
|
||||
Uses Dynamic Discovery to navigate without hardcoded maps.
|
||||
"""
|
||||
|
||||
def __init__(self, username: str):
|
||||
self.knowledge = NavigationKnowledge(username)
|
||||
|
||||
def plan_next_step(self, goal: str, screen: Dict[str, Any], explored_nav_actions: set = None) -> Optional[str]:
|
||||
"""Plans the NEXT single action to take toward the goal."""
|
||||
screen_type = screen["screen_type"]
|
||||
available = screen.get("available_actions", [])
|
||||
context = screen.get("context", {})
|
||||
goal_lower = goal.lower()
|
||||
|
||||
# ── 1. Check if goal is ALREADY achieved ──
|
||||
if self._is_goal_achieved(goal_lower, screen_type, context):
|
||||
logger.info(f"🎯 [GOAP] Goal '{goal}' already achieved on {screen_type.value}!")
|
||||
return None
|
||||
|
||||
# (Phase 5: legacy _plan_goal_action static heuristics purged,
|
||||
# all intents fall through to VLM-driven Discovery in _plan_navigation)
|
||||
|
||||
# ── 3. Am I on the right screen? If not, navigate there ──
|
||||
selected_tab = screen.get("selected_tab")
|
||||
nav_action = self._plan_navigation(goal_lower, screen_type, available, selected_tab, explored_nav_actions)
|
||||
if nav_action:
|
||||
return nav_action
|
||||
|
||||
# Final fallback: back-track, UNLESS back-tracking is a known trap on this screen!
|
||||
if not self.knowledge.is_trap(screen_type, "press back"):
|
||||
return "press back"
|
||||
|
||||
# We are trapped! Can't go forward, can't go back!
|
||||
logger.error(f"💀 [GOAP] Completely trapped on {screen_type.name}. Forcing Instagram restart.")
|
||||
return "force start instagram"
|
||||
|
||||
def _is_goal_achieved(self, goal: str, screen_type: ScreenType, context: dict) -> bool:
|
||||
"""Check if the goal is already satisfied. Delegates to ScreenTopology SSOT."""
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
# Interaction goals (context-specific, not navigation)
|
||||
if "like" in goal and context.get("is_liked") is True:
|
||||
return True
|
||||
if "view profile" in goal and screen_type in (ScreenType.OWN_PROFILE, ScreenType.OTHER_PROFILE):
|
||||
return True
|
||||
|
||||
# Navigation goals — delegate to SSOT
|
||||
target = ScreenTopology.goal_to_target_screen(goal)
|
||||
if target and screen_type == target:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _plan_navigation(
|
||||
self,
|
||||
goal: str,
|
||||
screen_type: ScreenType,
|
||||
available: List[str],
|
||||
selected_tab: Optional[str] = None,
|
||||
explored_nav_actions: set = None,
|
||||
) -> Optional[str]:
|
||||
"""If we're on the wrong screen, figure out how to navigate.
|
||||
|
||||
Strategy (priority order):
|
||||
1. HD Map (ScreenTopology BFS) — deterministic, pre-computed routes
|
||||
2. Learned Knowledge (Qdrant) — dynamic discovery from past sessions
|
||||
3. Autonomous Discovery — linguistic matching + VLM intent
|
||||
"""
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
# 0. Aversive Filter: Remove known traps from available actions
|
||||
safe_available = []
|
||||
for action in available:
|
||||
if not self.knowledge.is_trap(screen_type, action):
|
||||
safe_available.append(action)
|
||||
else:
|
||||
logger.debug(f"🛡️ [Aversive Filter] Masking trapped action: '{action}'")
|
||||
available = safe_available
|
||||
|
||||
# ── 1. HD Map Routing (Primary Strategy) ──
|
||||
target_screen = ScreenTopology.goal_to_target_screen(goal)
|
||||
if target_screen and target_screen != screen_type:
|
||||
route = ScreenTopology.find_route(screen_type, target_screen)
|
||||
if route:
|
||||
next_action, next_screen = route[0]
|
||||
# Verify action isn't explored/trapped
|
||||
if next_action not in (explored_nav_actions or set()):
|
||||
if not self.knowledge.is_trap(screen_type, next_action):
|
||||
route_desc = " → ".join(s.name for _, s in route)
|
||||
logger.info(
|
||||
f"🗺️ [HD Map] Route: {screen_type.name} → {route_desc}. " f"Next action: '{next_action}'"
|
||||
)
|
||||
return next_action
|
||||
else:
|
||||
logger.warning(f"🛡️ [HD Map] Route action '{next_action}' is trapped. Falling back.")
|
||||
else:
|
||||
logger.debug(f"🛡️ [HD Map] Route action '{next_action}' already explored. Falling back.")
|
||||
|
||||
# ── 2. Learned Knowledge (Qdrant) ──
|
||||
required_screens = self.knowledge.get_requirements(goal)
|
||||
|
||||
# ── 3. Autonomous Discovery (Blank Start fallback) ──
|
||||
if not required_screens:
|
||||
logger.info(f"🧠 [Nav Discovery] No known requirements for '{goal}'. Will attempt autonomous discovery.")
|
||||
|
||||
# Return raw intent for TelepathicEngine discovery (VLM)
|
||||
if explored_nav_actions and goal in explored_nav_actions:
|
||||
logger.info(
|
||||
f"🛑 [Nav Discovery] Autonomous intent '{goal}' already tried and failed/trapped. Yielding to back-tracking."
|
||||
)
|
||||
return None # Don't return goal again — force fallback to press back
|
||||
else:
|
||||
return goal
|
||||
|
||||
# 4. If we're already on an acceptable screen, no navigation needed
|
||||
if screen_type in required_screens:
|
||||
return None
|
||||
|
||||
# 5. Find the action we need to take (from learned knowledge or HD map)
|
||||
for target_screen in required_screens:
|
||||
# Try HD Map first!
|
||||
route = ScreenTopology.find_route(screen_type, target_screen)
|
||||
if route:
|
||||
next_action, next_screen = route[0]
|
||||
if next_action not in (explored_nav_actions or set()):
|
||||
if not self.knowledge.is_trap(screen_type, next_action):
|
||||
logger.info(f"🧭 [Nav HD Map] Routing to required {target_screen.name} via '{next_action}'")
|
||||
return next_action
|
||||
|
||||
known_action = self.knowledge.get_action_for_screen(target_screen)
|
||||
|
||||
if not known_action:
|
||||
logger.info(f"🧭 [Nav Discovery] Don't know action to reach {target_screen.name}. Asking VLM...")
|
||||
|
||||
screen_friendly_name = target_screen.name.replace("_", " ").lower()
|
||||
goal_words = [w.rstrip("s") for w in screen_friendly_name.split() if len(w) > 3]
|
||||
|
||||
for action in available:
|
||||
if any(w in action.lower() for w in goal_words):
|
||||
known_target = self.knowledge.get_screen_for_action(action)
|
||||
if known_target and known_target != target_screen:
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"🎯 [Nav Discovery] Linguistic match on available action! '{action}' aligns with '{screen_friendly_name}'"
|
||||
)
|
||||
return action
|
||||
|
||||
return f"navigate to {screen_friendly_name}"
|
||||
else:
|
||||
if known_action in available:
|
||||
logger.info(f"🧭 [Nav Knowledge] Navigating to {target_screen.name} via '{known_action}'")
|
||||
return known_action
|
||||
|
||||
# If no targeted navigation works, try going back first
|
||||
if "press back" in available:
|
||||
return "press back"
|
||||
|
||||
return None
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Perception — Feed and Content Analysis."""
|
||||
|
||||
from GramAddict.core.perception.feed_analysis import (
|
||||
FEED_MARKERS,
|
||||
CAROUSEL_INDICATORS,
|
||||
has_carousel_in_view,
|
||||
FEED_MARKERS,
|
||||
extract_post_content,
|
||||
has_carousel_in_view,
|
||||
has_feed_markers,
|
||||
)
|
||||
|
||||
|
||||
96
GramAddict/core/perception/action_memory.py
Normal file
96
GramAddict/core/perception/action_memory.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ActionMemory:
|
||||
"""
|
||||
Handles the caching, tracking, and negative reinforcement (unlearning) of UI interactions.
|
||||
Decouples the memory layer from the core parsing engine.
|
||||
"""
|
||||
|
||||
def __init__(self, ui_memory=None):
|
||||
# We optionally inject UIMemoryDB to decouple tests
|
||||
if ui_memory is None:
|
||||
from GramAddict.core.qdrant_memory import UIMemoryDB
|
||||
|
||||
self.ui_memory = UIMemoryDB()
|
||||
else:
|
||||
self.ui_memory = ui_memory
|
||||
|
||||
self._last_click_context: Optional[Dict[str, Any]] = None
|
||||
|
||||
def track_click(self, intent: str, node: SpatialNode, xml_context: str = ""):
|
||||
"""Stores the context of a click before it's actually performed."""
|
||||
semantic_string = f"text: '{node.text}', desc: '{node.content_desc}', id: '{node.resource_id}'"
|
||||
|
||||
self._last_click_context = {
|
||||
"intent": intent,
|
||||
"node_dict": node.to_dict(),
|
||||
"semantic_string": semantic_string,
|
||||
"xml_context": xml_context,
|
||||
}
|
||||
logger.debug(f"🧠 [ActionMemory] Tracking tentative click for intent: '{intent}' -> {semantic_string}")
|
||||
|
||||
def confirm_click(self, intent: str = None):
|
||||
"""Positive Reinforcement: Confirms the last click was successful."""
|
||||
ctx = self._last_click_context
|
||||
if not ctx:
|
||||
return
|
||||
|
||||
if intent and ctx["intent"] != intent:
|
||||
return
|
||||
|
||||
logger.info(f"✅ [ActionMemory] Confirming success for '{ctx['intent']}'. Boosting confidence.")
|
||||
|
||||
# Store or boost in Qdrant
|
||||
try:
|
||||
# Check if it exists first
|
||||
existing = self.ui_memory.retrieve_memory(ctx["intent"], ctx["xml_context"])
|
||||
if existing:
|
||||
self.ui_memory.boost_confidence(ctx["intent"], ctx["xml_context"])
|
||||
else:
|
||||
self.ui_memory.store_memory(ctx["intent"], ctx["xml_context"], ctx["node_dict"])
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to confirm click in Qdrant: {e}")
|
||||
|
||||
self._last_click_context = None
|
||||
|
||||
def reject_click(self, intent: str = None):
|
||||
"""Negative Reinforcement: Penalizes a failed click (Unlearning)."""
|
||||
ctx = self._last_click_context
|
||||
if not ctx:
|
||||
return
|
||||
|
||||
if intent and ctx["intent"] != intent:
|
||||
return
|
||||
|
||||
logger.warning(f"❌ [ActionMemory] Click failed for '{ctx['intent']}'. Applying penalty.")
|
||||
|
||||
try:
|
||||
self.ui_memory.decay_confidence(ctx["intent"], ctx["xml_context"])
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decay confidence in Qdrant: {e}")
|
||||
|
||||
self._last_click_context = None
|
||||
|
||||
def verify_success(self, intent: str, pre_click_xml: str, post_click_xml: str) -> Optional[bool]:
|
||||
"""
|
||||
Structural verification: Did the UI actually change after the click?
|
||||
"""
|
||||
# Specific check for explore grid
|
||||
if "first image in explore grid" in intent or "grid item" in intent:
|
||||
if "row_feed_photo_imageview" in post_click_xml or "row_feed_button_like" in post_click_xml:
|
||||
return True
|
||||
if "explore_action_bar" in post_click_xml and "row_feed_button_like" not in post_click_xml:
|
||||
return None # Still on grid, inconclusive
|
||||
|
||||
if abs(len(pre_click_xml) - len(post_click_xml)) > 50:
|
||||
logger.debug(f"🧠 [ActionMemory] Structural change detected for '{intent}'. Verification PASS.")
|
||||
return True
|
||||
|
||||
logger.warning(f"⚠️ [ActionMemory] No structural change detected for '{intent}'. Verification FAIL.")
|
||||
return False
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Perception — Feed Content Analysis.
|
||||
|
||||
Structural analysis of the feed: detecting markers, carousels,
|
||||
Structural analysis of the feed: detecting markers, carousels,
|
||||
extracting post content. Zero-AI, pure structural parsing.
|
||||
|
||||
Extracted from bot_flow.py to enable isolated testing.
|
||||
@@ -27,14 +27,14 @@ FEED_MARKERS = [
|
||||
"clips_linear_layout_container",
|
||||
"zoomable_view_container",
|
||||
"feed_action_row",
|
||||
"carousel_viewpager"
|
||||
"carousel_viewpager",
|
||||
]
|
||||
|
||||
# ── Carousel Detection ──
|
||||
CAROUSEL_INDICATORS = [
|
||||
"com.instagram.android:id/carousel_page_indicator",
|
||||
"com.instagram.android:id/carousel_media_group",
|
||||
"com.instagram.android:id/carousel_viewpager"
|
||||
"com.instagram.android:id/carousel_viewpager",
|
||||
]
|
||||
|
||||
|
||||
@@ -50,26 +50,29 @@ def extract_post_content(context_xml: str) -> 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}
|
||||
"""
|
||||
result = {"username": "", "description": "", "caption": ""}
|
||||
|
||||
|
||||
try:
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
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)
|
||||
if author_node and author_node.get("original_attribs", {}).get("text"):
|
||||
|
||||
# 🛡️ Anti-Hallucination Guard: The author header is always near the top. Ignore names in the comment section.
|
||||
if author_node and author_node.get("y", 0) < 1000 and author_node.get("original_attribs", {}).get("text"):
|
||||
result["username"] = author_node["original_attribs"]["text"].strip()
|
||||
|
||||
|
||||
# 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()
|
||||
|
||||
|
||||
# 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
|
||||
root = ET.fromstring(context_xml)
|
||||
@@ -78,13 +81,51 @@ def extract_post_content(context_xml: str) -> dict:
|
||||
if result["username"] and len(text) > 20 and result["username"] in text:
|
||||
result["caption"] = text
|
||||
break
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error extracting post content autonomously: {e}")
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _parse_number_from_text(text: str) -> int:
|
||||
"""Extracts numeric value from strings like '1,234 likes', '1.5M views', 'Gefällt 12.345 Mal'."""
|
||||
text = text.lower()
|
||||
|
||||
# Clean up purely thousands separators but keep decimals
|
||||
# If there is a 'm' or 'k', a period is usually a decimal (e.g. 1.5m).
|
||||
# If no 'm' or 'k', a period might be a German thousands separator (12.345).
|
||||
# We will let the regex handle decimals.
|
||||
|
||||
# Remove commas (usually thousands separator in English)
|
||||
text = text.replace(",", "")
|
||||
|
||||
# Find all numbers, potentially with k or m
|
||||
matches = re.findall(r"(\d+(?:\.\d+)?)\s*([km])?", text)
|
||||
if not matches:
|
||||
return 0
|
||||
|
||||
best_val = 0
|
||||
for num_str, multiplier in matches:
|
||||
val = float(num_str)
|
||||
if multiplier == "k":
|
||||
val *= 1000
|
||||
elif multiplier == "m":
|
||||
val *= 1000000
|
||||
else:
|
||||
# If no multiplier, a period in num_str might be a German thousands separator
|
||||
if "." in num_str and val < 1000:
|
||||
# E.g. '12.345' became 12.345. Since no multiplier, it's actually 12345.
|
||||
# Heuristic: If it has 3 decimal places, it's a thousands separator.
|
||||
parts = num_str.split(".")
|
||||
if len(parts[1]) == 3:
|
||||
val = float(num_str.replace(".", ""))
|
||||
|
||||
best_val = max(best_val, int(val))
|
||||
|
||||
return best_val
|
||||
|
||||
|
||||
def has_feed_markers(xml_dump: str) -> bool:
|
||||
"""Quick check: does this XML contain any feed presence markers?"""
|
||||
return any(marker in xml_dump for marker in FEED_MARKERS)
|
||||
|
||||
349
GramAddict/core/perception/screen_identity.py
Normal file
349
GramAddict/core/perception/screen_identity.py
Normal file
@@ -0,0 +1,349 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from enum import Enum
|
||||
from typing import Any, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScreenType(Enum):
|
||||
HOME_FEED = "home_feed"
|
||||
EXPLORE_GRID = "explore_grid"
|
||||
REELS_FEED = "reels_feed"
|
||||
OWN_PROFILE = "own_profile"
|
||||
OTHER_PROFILE = "other_profile"
|
||||
POST_DETAIL = "post_detail"
|
||||
STORY_VIEW = "story_view"
|
||||
DM_INBOX = "dm_inbox"
|
||||
DM_THREAD = "dm_thread"
|
||||
SEARCH_RESULTS = "search_results"
|
||||
FOLLOW_LIST = "follow_list"
|
||||
COMMENTS = "comments"
|
||||
MODAL = "modal"
|
||||
FOREIGN_APP = "foreign_app"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class ScreenIdentity:
|
||||
"""
|
||||
Understands what screen the bot is on by analyzing the XML dump.
|
||||
NO hardcoded states — purely structural analysis.
|
||||
|
||||
This is the bot's EYES. It answers: "What do I see right now?"
|
||||
"""
|
||||
|
||||
def __init__(self, bot_username: str):
|
||||
self.bot_username = bot_username.lower()
|
||||
try:
|
||||
from GramAddict.core.qdrant_memory import ScreenMemoryDB
|
||||
|
||||
self.screen_memory = ScreenMemoryDB()
|
||||
except ImportError:
|
||||
self.screen_memory = None
|
||||
|
||||
def identify(self, xml_dump: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyzes an XML dump and returns a complete screen description.
|
||||
|
||||
Returns:
|
||||
{
|
||||
'screen_type': ScreenType,
|
||||
'available_actions': ['tap like button', 'tap explore tab', ...],
|
||||
'selected_tab': 'feed_tab' | 'search_tab' | ...,
|
||||
'context': {'username': '...', 'post_count': '...', ...}
|
||||
}
|
||||
"""
|
||||
if not xml_dump or not isinstance(xml_dump, str):
|
||||
return self._empty_screen()
|
||||
|
||||
try:
|
||||
clean = re.sub(r"<\?xml.*?\?>", "", xml_dump).strip()
|
||||
root = ET.fromstring(clean)
|
||||
except Exception:
|
||||
return self._empty_screen()
|
||||
|
||||
# Extract structural signals
|
||||
packages = set()
|
||||
resource_ids = set()
|
||||
content_descs = []
|
||||
texts = []
|
||||
selected_tab = None
|
||||
clickable_elements = []
|
||||
|
||||
app_id = "com.instagram.android"
|
||||
|
||||
for elem in root.iter("node"):
|
||||
pkg = elem.get("package", "")
|
||||
if pkg:
|
||||
packages.add(pkg)
|
||||
|
||||
rid = elem.get("resource-id", "").strip()
|
||||
text = elem.get("text", "").strip()
|
||||
desc = elem.get("content-desc", "").strip()
|
||||
clickable = elem.get("clickable", "false") == "true"
|
||||
selected = elem.get("selected", "false") == "true"
|
||||
bounds = elem.get("bounds", "")
|
||||
|
||||
if rid:
|
||||
# Normalize: "com.instagram.android:id/feed_tab" → "feed_tab"
|
||||
short_id = rid.split("/")[-1] if "/" in rid else rid
|
||||
resource_ids.add(short_id)
|
||||
|
||||
# Track which tab is selected
|
||||
if selected and short_id in ("feed_tab", "search_tab", "clips_tab", "profile_tab", "direct_tab"):
|
||||
selected_tab = short_id
|
||||
|
||||
if text:
|
||||
texts.append(text)
|
||||
if desc:
|
||||
content_descs.append(desc)
|
||||
|
||||
if clickable and bounds:
|
||||
match = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
|
||||
if match:
|
||||
left, t, r, b = map(int, match.groups())
|
||||
cx, cy = (left + r) // 2, (t + b) // 2
|
||||
clickable_elements.append(
|
||||
{
|
||||
"text": text,
|
||||
"desc": desc,
|
||||
"id": rid.split("/")[-1] if "/" in rid else rid,
|
||||
"x": cx,
|
||||
"y": cy,
|
||||
"bounds": bounds,
|
||||
}
|
||||
)
|
||||
|
||||
# ── Foreign app check ──
|
||||
if app_id not in packages:
|
||||
return {
|
||||
"screen_type": ScreenType.FOREIGN_APP,
|
||||
"available_actions": ["press back", "force start instagram"],
|
||||
"selected_tab": None,
|
||||
"context": {"packages": list(packages)},
|
||||
"signature": self._compute_signature(resource_ids, content_descs, texts),
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
# ── Extract available actions from clickable elements ──
|
||||
available_actions = self._extract_available_actions(
|
||||
clickable_elements, resource_ids, content_descs, texts, screen_type
|
||||
)
|
||||
|
||||
# ── Extract context ──
|
||||
context = self._extract_context(content_descs, texts, resource_ids, screen_type)
|
||||
|
||||
return {
|
||||
"screen_type": screen_type,
|
||||
"available_actions": available_actions,
|
||||
"selected_tab": selected_tab,
|
||||
"context": context,
|
||||
"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."""
|
||||
|
||||
# Priority 0: 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
|
||||
|
||||
# Priority 1: Check Qdrant Semantic Cache
|
||||
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
|
||||
|
||||
# Priority 2: Structural Heuristics (Instant, for core tabs)
|
||||
if "unified_follow_list_tab_layout" in ids or "follow_list_container" in ids:
|
||||
return ScreenType.FOLLOW_LIST
|
||||
|
||||
if "profile_header_container" in ids:
|
||||
return ScreenType.OTHER_PROFILE
|
||||
|
||||
# Reels structural markers — present even when Instagram hides the tab bar
|
||||
# in full-screen Reels viewing. Without this, selected_tab=None → UNKNOWN.
|
||||
REELS_MARKERS = ("clips_viewer_container", "root_clips_layout", "clips_linear_layout_container")
|
||||
if any(marker in ids for marker in REELS_MARKERS):
|
||||
return ScreenType.REELS_FEED
|
||||
|
||||
# DM thread detection — structural markers present inside DM conversations
|
||||
if "direct_thread_header" in ids or "row_thread_composer_edittext" in ids:
|
||||
return ScreenType.DM_THREAD
|
||||
|
||||
if "row_feed_button_like" in ids and "row_feed_photo_profile_name" in ids and not selected_tab:
|
||||
return ScreenType.POST_DETAIL
|
||||
|
||||
if selected_tab == "feed_tab":
|
||||
return ScreenType.HOME_FEED
|
||||
if selected_tab == "clips_tab":
|
||||
return ScreenType.REELS_FEED
|
||||
if selected_tab == "search_tab":
|
||||
return ScreenType.EXPLORE_GRID
|
||||
if selected_tab == "profile_tab":
|
||||
return ScreenType.OWN_PROFILE
|
||||
if selected_tab == "direct_tab":
|
||||
return ScreenType.DM_INBOX
|
||||
if "message_input" in ids:
|
||||
return ScreenType.DM_INBOX # Fallback for DM thread as inbox
|
||||
|
||||
# Priority 3: Semantic VLM Classification Fallback
|
||||
from GramAddict.core.config import Config
|
||||
from GramAddict.core.llm_provider import query_llm
|
||||
|
||||
cfg = Config()
|
||||
url = (
|
||||
getattr(cfg.args, "ai_embedding_url", "http://localhost:11434/api/chat")
|
||||
if hasattr(cfg, "args")
|
||||
else "http://localhost:11434/api/chat"
|
||||
)
|
||||
model = getattr(cfg.args, "ai_embedding_model", "llama3") if hasattr(cfg, "args") else "llama3"
|
||||
|
||||
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"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
|
||||
)
|
||||
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
|
||||
|
||||
for t in ScreenType:
|
||||
if t.name in result:
|
||||
if signature and self.screen_memory:
|
||||
self.screen_memory.store_screen(signature, t.name)
|
||||
return t
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).debug(f"LLM Classification failed: {e}")
|
||||
|
||||
return ScreenType.UNKNOWN
|
||||
|
||||
def _extract_available_actions(self, clickable_elements, resource_ids, content_descs, texts, screen_type):
|
||||
"""Discover what actions are possible on this screen."""
|
||||
actions = []
|
||||
|
||||
# Navigation tabs (always available when visible)
|
||||
tab_map = {
|
||||
"feed_tab": "tap home tab",
|
||||
"search_tab": "tap explore tab",
|
||||
"clips_tab": "tap reels tab",
|
||||
"profile_tab": "tap profile tab",
|
||||
"direct_tab": "tap messages tab",
|
||||
}
|
||||
for tab_id, action in tab_map.items():
|
||||
if tab_id in resource_ids:
|
||||
actions.append(action)
|
||||
|
||||
# Screen-specific actions
|
||||
desc_lower = " ".join(content_descs).lower()
|
||||
text_lower = " ".join(texts).lower()
|
||||
|
||||
if "like" in desc_lower:
|
||||
actions.append("tap like button")
|
||||
if "comment" in desc_lower:
|
||||
actions.append("tap comment button")
|
||||
if "share" in desc_lower:
|
||||
actions.append("tap share button")
|
||||
if "save" in desc_lower or "bookmark" in desc_lower:
|
||||
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")
|
||||
|
||||
if screen_type == ScreenType.OWN_PROFILE or screen_type == ScreenType.OTHER_PROFILE:
|
||||
if "message" in desc_lower or "nachricht" in desc_lower:
|
||||
actions.append("tap message button")
|
||||
if (
|
||||
"following" in desc_lower
|
||||
or "abonniert" in desc_lower
|
||||
or "following" in text_lower
|
||||
or "profile_header_following" in " ".join(resource_ids).lower()
|
||||
):
|
||||
actions.append("tap following list")
|
||||
|
||||
# Grid items
|
||||
if screen_type == ScreenType.EXPLORE_GRID:
|
||||
actions.append("tap first grid item")
|
||||
|
||||
# Scroll
|
||||
actions.append("scroll down")
|
||||
actions.append("press back")
|
||||
|
||||
return list(set(actions)) # Deduplicate
|
||||
|
||||
def _extract_context(self, content_descs, texts, resource_ids, screen_type):
|
||||
"""Extract meaningful context from the screen."""
|
||||
context = {}
|
||||
|
||||
desc_text = " ".join(content_descs)
|
||||
|
||||
# Username on profile
|
||||
username_match = re.search(r"(\w+)'s (?:profile|story|unseen story)", desc_text)
|
||||
if username_match:
|
||||
context["username"] = username_match.group(1)
|
||||
|
||||
# Post/follower counts
|
||||
for d in content_descs:
|
||||
m = re.match(r"([\d,.]+K?M?)(\s*)(posts?|followers?|following)", d, re.IGNORECASE)
|
||||
if m:
|
||||
context[m.group(3).lower()] = m.group(1)
|
||||
|
||||
# Like state
|
||||
for d in content_descs:
|
||||
if d.lower() == "liked":
|
||||
context["is_liked"] = True
|
||||
elif d.lower() == "like":
|
||||
context["is_liked"] = False
|
||||
|
||||
return context
|
||||
|
||||
def _compute_signature(self, resource_ids, content_descs, texts):
|
||||
"""Compute a stable hash for this screen state (for Qdrant lookup)."""
|
||||
# Use sorted IDs + key content for stability
|
||||
sig_parts = sorted(resource_ids)[:20]
|
||||
sig_parts.extend(sorted(set(d.lower()[:30] for d in content_descs if len(d) > 2))[:10])
|
||||
sig = "|".join(sig_parts)
|
||||
return hashlib.sha256(sig.encode()).hexdigest()[:24]
|
||||
|
||||
def _empty_screen(self):
|
||||
return {
|
||||
"screen_type": ScreenType.FOREIGN_APP,
|
||||
"available_actions": ["press back", "force start instagram"],
|
||||
"selected_tab": None,
|
||||
"context": {},
|
||||
"signature": "empty",
|
||||
}
|
||||
144
GramAddict/core/perception/semantic_evaluator.py
Normal file
144
GramAddict/core/perception/semantic_evaluator.py
Normal file
@@ -0,0 +1,144 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SemanticEvaluator:
|
||||
"""
|
||||
Handles LLM/VLM interaction for high-level semantic analysis of the UI.
|
||||
Delegates vision processing and prompt engineering out of the core routing engine.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
from GramAddict.core.config import Config
|
||||
|
||||
try:
|
||||
self.args = Config().args
|
||||
except Exception:
|
||||
self.args = None
|
||||
|
||||
def _query_vlm(self, prompt: str, screenshot_b64: str) -> Optional[str]:
|
||||
if not self.args:
|
||||
logger.warning("👁️ [Vision Core] No config available. Cannot query VLM.")
|
||||
return None
|
||||
|
||||
model = getattr(self.args, "ai_telepathic_model", "llama3.2-vision")
|
||||
url = getattr(self.args, "ai_telepathic_url", "http://localhost:11434/api/generate")
|
||||
|
||||
try:
|
||||
res = query_telepathic_llm(
|
||||
model=model,
|
||||
url=url,
|
||||
system_prompt="You are an expert Instagram assistant.",
|
||||
user_prompt=prompt,
|
||||
images_b64=[screenshot_b64],
|
||||
)
|
||||
return res
|
||||
except Exception as e:
|
||||
logger.error(f"👁️ [Vision Core] LLM query failed: {e}")
|
||||
return None
|
||||
|
||||
def evaluate_grid_visuals(
|
||||
self, device, persona_interests: list[str], grid_nodes: List[SpatialNode]
|
||||
) -> Optional[SpatialNode]:
|
||||
"""
|
||||
Takes the spatial grid nodes and asks the VLM which one best matches the persona.
|
||||
"""
|
||||
logger.info(f"👁️ [Vision Core] Analyzing grid aesthetics against niche interests: {persona_interests}...")
|
||||
|
||||
if not grid_nodes:
|
||||
return None
|
||||
|
||||
# Take a screenshot
|
||||
try:
|
||||
screenshot_b64 = device.get_screenshot_b64()
|
||||
except Exception as e:
|
||||
logger.error(f"👁️ [Vision Core] Failed to capture screenshot: {e}")
|
||||
return None
|
||||
|
||||
simplified_nodes = []
|
||||
for i, node in enumerate(grid_nodes[:9]): # Limit to 9 to save tokens
|
||||
simplified_nodes.append({"index": i, "bounds": node.bounds})
|
||||
|
||||
prompt = f"""
|
||||
You are a highly perceptive Instagram user with the following interests: {', '.join(persona_interests)}.
|
||||
Look at the provided screenshot of the Instagram Explore/Profile grid.
|
||||
Below are the bounding boxes for the top grid posts currently visible.
|
||||
|
||||
{simplified_nodes}
|
||||
|
||||
Your task:
|
||||
1. Identify which of these posts visually aligns BEST with your interests.
|
||||
2. Reply ONLY in JSON format: {{"index": <int>}}
|
||||
3. If absolutely none of them are relevant, reply with {{"index": -1}}.
|
||||
"""
|
||||
|
||||
try:
|
||||
response = self._query_vlm(prompt, screenshot_b64)
|
||||
if not response:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(response)
|
||||
idx = data.get("index", -1)
|
||||
if idx == -1:
|
||||
logger.info("👁️ [Vision Core] VLM rejected all grid items. Will scroll down.")
|
||||
return None
|
||||
|
||||
if 0 <= idx < len(grid_nodes):
|
||||
logger.info(f"👁️ [Vision Core] VLM selected grid item index [{idx}] as the best match.")
|
||||
return grid_nodes[idx]
|
||||
except json.JSONDecodeError:
|
||||
# Fallback to fuzzy
|
||||
clean_res = response.strip().upper()
|
||||
match = re.search(r"\d+", clean_res)
|
||||
if match:
|
||||
idx = int(match.group())
|
||||
if 0 <= idx < len(grid_nodes):
|
||||
logger.info(f"👁️ [Vision Core] VLM selected grid item index [{idx}] as the best match.")
|
||||
return grid_nodes[idx]
|
||||
except Exception as e:
|
||||
logger.warning(f"👁️ [Vision Core] Exception during grid evaluation: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def evaluate_post_vibe(self, device, persona_interests: list[str]) -> Optional[dict]:
|
||||
"""Evaluates whether the currently viewed post aligns with persona interests."""
|
||||
logger.info(f"👁️ [Vision Core] Evaluating post vibe against: {persona_interests}")
|
||||
try:
|
||||
screenshot_b64 = device.get_screenshot_b64()
|
||||
prompt = f"""
|
||||
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.
|
||||
|
||||
Reply ONLY in valid JSON format:
|
||||
{{
|
||||
"should_like": true/false,
|
||||
"should_comment": true/false,
|
||||
"reasoning": "brief explanation"
|
||||
}}
|
||||
"""
|
||||
response = self._query_vlm(prompt, screenshot_b64)
|
||||
if response:
|
||||
if "```json" in response:
|
||||
json_str = response.split("```json")[1].split("```")[0].strip()
|
||||
else:
|
||||
json_str = response.strip()
|
||||
return json.loads(json_str)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to evaluate post vibe: {e}")
|
||||
return None
|
||||
|
||||
def evaluate_profile_vibe(self, device, persona_interests: list[str]) -> Optional[dict]:
|
||||
"""Evaluates if a profile is worth following."""
|
||||
pass
|
||||
|
||||
def classify_screen_content(self, xml_hierarchy: str, target_class: str) -> Optional[str]:
|
||||
pass
|
||||
194
GramAddict/core/perception/spatial_parser.py
Normal file
194
GramAddict/core/perception/spatial_parser.py
Normal file
@@ -0,0 +1,194 @@
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpatialNode:
|
||||
"""A single node in the Spatial Graph, representing a UI element and its geometry."""
|
||||
|
||||
bounds: Tuple[int, int, int, int] # (x1, y1, x2, y2)
|
||||
node_id: str = ""
|
||||
class_name: str = ""
|
||||
text: str = ""
|
||||
content_desc: str = ""
|
||||
resource_id: str = ""
|
||||
clickable: bool = False
|
||||
scrollable: bool = False
|
||||
|
||||
# Spatial Properties
|
||||
children: List["SpatialNode"] = field(default_factory=list)
|
||||
parent: Optional["SpatialNode"] = None
|
||||
|
||||
@property
|
||||
def x1(self) -> int:
|
||||
return self.bounds[0]
|
||||
|
||||
@property
|
||||
def y1(self) -> int:
|
||||
return self.bounds[1]
|
||||
|
||||
@property
|
||||
def x2(self) -> int:
|
||||
return self.bounds[2]
|
||||
|
||||
@property
|
||||
def y2(self) -> int:
|
||||
return self.bounds[3]
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
return self.x2 - self.x1
|
||||
|
||||
@property
|
||||
def height(self) -> int:
|
||||
return self.y2 - self.y1
|
||||
|
||||
@property
|
||||
def center_x(self) -> int:
|
||||
return self.x1 + (self.width // 2)
|
||||
|
||||
@property
|
||||
def center_y(self) -> int:
|
||||
return self.y1 + (self.height // 2)
|
||||
|
||||
@property
|
||||
def area(self) -> int:
|
||||
return self.width * self.height
|
||||
|
||||
def contains(self, other: "SpatialNode") -> bool:
|
||||
"""Returns True if this node completely encompasses the other node geometrically."""
|
||||
return self.x1 <= other.x1 and self.y1 <= other.y1 and self.x2 >= other.x2 and self.y2 >= other.y2
|
||||
|
||||
def intersects(self, other: "SpatialNode") -> bool:
|
||||
"""Returns True if this node's bounding box overlaps with the other's bounding box."""
|
||||
if self.x1 >= other.x2 or other.x1 >= self.x2:
|
||||
return False
|
||||
if self.y1 >= other.y2 or other.y1 >= self.y2:
|
||||
return False
|
||||
return True
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.node_id,
|
||||
"class": self.class_name,
|
||||
"text": self.text,
|
||||
"content_desc": self.content_desc,
|
||||
"resource_id": self.resource_id,
|
||||
"bounds": self.bounds,
|
||||
"clickable": self.clickable,
|
||||
"scrollable": self.scrollable,
|
||||
"center": (self.center_x, self.center_y),
|
||||
}
|
||||
|
||||
|
||||
class SpatialParser:
|
||||
"""
|
||||
Parses Android UI XML into a structured 2D Spatial Tree.
|
||||
Calculates parent-child relationships structurally, not just based on XML nesting.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._node_counter = 0
|
||||
|
||||
def parse(self, xml_string: str) -> Optional[SpatialNode]:
|
||||
"""Parses the raw XML dump into a Spatial Graph."""
|
||||
try:
|
||||
clean_xml = re.sub(r"<\?xml.*?\?>", "", xml_string).strip()
|
||||
if not clean_xml:
|
||||
return None
|
||||
root_elem = ET.fromstring(clean_xml)
|
||||
|
||||
# 1. First Pass: Create flat list of spatial nodes
|
||||
all_nodes = []
|
||||
self._flatten_xml(root_elem, all_nodes)
|
||||
|
||||
if not all_nodes:
|
||||
return None
|
||||
|
||||
# 2. Second Pass: Reconstruct tree based on strict spatial containment
|
||||
# Sort nodes by area descending (largest first)
|
||||
all_nodes.sort(key=lambda n: n.area, reverse=True)
|
||||
|
||||
root_node = all_nodes[0]
|
||||
|
||||
for i in range(1, len(all_nodes)):
|
||||
child = all_nodes[i]
|
||||
# Find the smallest node that contains this child
|
||||
# Since we sorted by area descending, we search backwards to find the tightest fit
|
||||
parent_found = False
|
||||
for j in range(i - 1, -1, -1):
|
||||
potential_parent = all_nodes[j]
|
||||
if potential_parent.contains(child):
|
||||
potential_parent.children.append(child)
|
||||
child.parent = potential_parent
|
||||
parent_found = True
|
||||
break
|
||||
|
||||
# Fallback to root if no parent found (floating node)
|
||||
if not parent_found and child != root_node:
|
||||
root_node.children.append(child)
|
||||
child.parent = root_node
|
||||
|
||||
return root_node
|
||||
|
||||
except ET.ParseError:
|
||||
return None
|
||||
|
||||
def _flatten_xml(self, element: ET.Element, nodes_list: List[SpatialNode]):
|
||||
"""Recursively traverses the XML and creates a flat list of SpatialNodes."""
|
||||
attrib = element.attrib
|
||||
|
||||
bounds_str = attrib.get("bounds", "")
|
||||
match = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds_str)
|
||||
|
||||
if match:
|
||||
left, top, right, bottom = map(int, match.groups())
|
||||
|
||||
# Filter zero-area nodes early
|
||||
if right > left and bottom > top:
|
||||
self._node_counter += 1
|
||||
node = SpatialNode(
|
||||
node_id=f"n_{self._node_counter}",
|
||||
class_name=attrib.get("class", ""),
|
||||
text=attrib.get("text", "").strip(),
|
||||
content_desc=attrib.get("content-desc", "").strip(),
|
||||
resource_id=attrib.get("resource-id", "").strip(),
|
||||
bounds=(left, top, right, bottom),
|
||||
clickable=attrib.get("clickable", "false") == "true",
|
||||
scrollable=attrib.get("scrollable", "false") == "true",
|
||||
)
|
||||
nodes_list.append(node)
|
||||
|
||||
for child in element:
|
||||
self._flatten_xml(child, nodes_list)
|
||||
|
||||
def get_all_nodes(self, root: SpatialNode) -> List[SpatialNode]:
|
||||
"""Flattens the Spatial Tree into a list for easy filtering."""
|
||||
result = [root]
|
||||
for child in root.children:
|
||||
result.extend(self.get_all_nodes(child))
|
||||
return result
|
||||
|
||||
def get_clickable_nodes(self, root: SpatialNode) -> List[SpatialNode]:
|
||||
"""Returns all nodes that are clickable or have strong semantic meaning."""
|
||||
all_nodes = self.get_all_nodes(root)
|
||||
clickables = []
|
||||
|
||||
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"]
|
||||
)
|
||||
|
||||
if n.clickable or n.scrollable or semantic_res or (has_semantic and n.area < 500000 and n.area > 0):
|
||||
# Filter out pure massive containers (like whole screen) if they aren't explicitly clickable
|
||||
if not n.clickable and not n.scrollable and n.area > 2000000:
|
||||
continue
|
||||
# Also exclude if it's just a ViewGroup with a description but no action
|
||||
if not n.clickable and n.class_name == "android.view.ViewGroup":
|
||||
continue
|
||||
clickables.append(n)
|
||||
|
||||
return clickables
|
||||
@@ -1,9 +1,10 @@
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PersistentList(list):
|
||||
def __init__(self, filename, encoder=None):
|
||||
super().__init__()
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
"""Physics — Humanized Input Simulation, Biomechanics & UI Timing."""
|
||||
|
||||
from GramAddict.core.physics.biomechanics import (
|
||||
BezierGesture,
|
||||
PhysicsBody,
|
||||
)
|
||||
from GramAddict.core.physics.humanized_input import (
|
||||
humanized_scroll,
|
||||
humanized_click,
|
||||
humanized_horizontal_swipe,
|
||||
)
|
||||
from GramAddict.core.physics.timing import (
|
||||
wait_for_post_loaded,
|
||||
wait_for_story_loaded,
|
||||
align_active_post,
|
||||
)
|
||||
from GramAddict.core.physics.biomechanics import (
|
||||
PhysicsBody,
|
||||
BezierGesture,
|
||||
humanized_scroll,
|
||||
)
|
||||
from GramAddict.core.physics.sendevent_injector import SendEventInjector
|
||||
|
||||
from GramAddict.core.physics.timing import (
|
||||
align_active_post,
|
||||
wait_for_post_loaded,
|
||||
wait_for_story_loaded,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"humanized_scroll",
|
||||
@@ -26,5 +26,4 @@ __all__ = [
|
||||
"PhysicsBody",
|
||||
"BezierGesture",
|
||||
"SendEventInjector",
|
||||
|
||||
]
|
||||
|
||||
@@ -253,27 +253,15 @@ class BezierGesture:
|
||||
t = i / n_points
|
||||
|
||||
# Cubic Bézier interpolation
|
||||
x = (
|
||||
(1 - t) ** 3 * sx
|
||||
+ 3 * (1 - t) ** 2 * t * cp1_x
|
||||
+ 3 * (1 - t) * t ** 2 * cp2_x
|
||||
+ t ** 3 * ex
|
||||
)
|
||||
y = (
|
||||
(1 - t) ** 3 * sy
|
||||
+ 3 * (1 - t) ** 2 * t * cp1_y
|
||||
+ 3 * (1 - t) * t ** 2 * cp2_y
|
||||
+ t ** 3 * ey
|
||||
)
|
||||
x = (1 - t) ** 3 * sx + 3 * (1 - t) ** 2 * t * cp1_x + 3 * (1 - t) * t**2 * cp2_x + t**3 * ex
|
||||
y = (1 - t) ** 3 * sy + 3 * (1 - t) ** 2 * t * cp1_y + 3 * (1 - t) * t**2 * cp2_y + t**3 * ey
|
||||
|
||||
# Micro-noise on each point (finger vibration)
|
||||
x += random.gauss(0, 1.5)
|
||||
y += random.gauss(0, 1.5)
|
||||
|
||||
# Pressure curve: Gaussian peak around t=0.4 (peak contact mid-gesture)
|
||||
pressure = pressure_baseline + 0.3 * math.exp(
|
||||
-((t - 0.4) ** 2) / 0.1
|
||||
)
|
||||
pressure = pressure_baseline + 0.3 * math.exp(-((t - 0.4) ** 2) / 0.1)
|
||||
pressure += random.uniform(-0.04, 0.04)
|
||||
pressure = max(0.08, min(0.92, pressure))
|
||||
|
||||
@@ -343,24 +331,12 @@ class BezierGesture:
|
||||
|
||||
for i in range(n_points + 1):
|
||||
t = i / n_points
|
||||
x = (
|
||||
(1 - t) ** 3 * sx
|
||||
+ 3 * (1 - t) ** 2 * t * cp1_x
|
||||
+ 3 * (1 - t) * t ** 2 * cp2_x
|
||||
+ t ** 3 * ex
|
||||
)
|
||||
y = (
|
||||
(1 - t) ** 3 * sy
|
||||
+ 3 * (1 - t) ** 2 * t * cp1_y
|
||||
+ 3 * (1 - t) * t ** 2 * cp2_y
|
||||
+ t ** 3 * ey
|
||||
)
|
||||
x = (1 - t) ** 3 * sx + 3 * (1 - t) ** 2 * t * cp1_x + 3 * (1 - t) * t**2 * cp2_x + t**3 * ex
|
||||
y = (1 - t) ** 3 * sy + 3 * (1 - t) ** 2 * t * cp1_y + 3 * (1 - t) * t**2 * cp2_y + t**3 * ey
|
||||
x += random.gauss(0, 2)
|
||||
y += random.gauss(0, 2)
|
||||
|
||||
pressure = pressure_baseline + 0.25 * math.exp(
|
||||
-((t - 0.45) ** 2) / 0.12
|
||||
)
|
||||
pressure = pressure_baseline + 0.25 * math.exp(-((t - 0.45) ** 2) / 0.12)
|
||||
pressure += random.uniform(-0.04, 0.04)
|
||||
pressure = max(0.08, min(0.92, pressure))
|
||||
|
||||
@@ -390,7 +366,7 @@ class BezierGesture:
|
||||
t = i / (n_points - 1) if n_points > 1 else 0.5
|
||||
# Inverted sigmoid: fast in middle, slow at edges
|
||||
# Higher value = longer delay = slower movement
|
||||
sigmoid = 1.0 / (1.0 + math.exp(-8 * (t - 0.5)))
|
||||
1.0 / (1.0 + math.exp(-8 * (t - 0.5)))
|
||||
# U-shaped: slow at start & end, fast in middle
|
||||
speed_factor = 0.4 + 1.2 * (4 * (t - 0.5) ** 2)
|
||||
raw_intervals.append(speed_factor)
|
||||
@@ -401,9 +377,7 @@ class BezierGesture:
|
||||
intervals = [(r / total_raw) * total_sec for r in raw_intervals]
|
||||
|
||||
# Add micro-jitter to timing (humans are never perfectly rhythmic)
|
||||
intervals = [
|
||||
max(0.002, i + random.uniform(-0.003, 0.003)) for i in intervals
|
||||
]
|
||||
intervals = [max(0.002, i + random.uniform(-0.003, 0.003)) for i in intervals]
|
||||
|
||||
return intervals
|
||||
|
||||
@@ -412,8 +386,8 @@ class BezierGesture:
|
||||
"""
|
||||
Generates a J-curve timing schedule for flick/swipe gestures.
|
||||
|
||||
Unlike the sigmoid (which slows down at the end), this curve
|
||||
accelerates through the middle and maintains high velocity
|
||||
Unlike the sigmoid (which slows down at the end), this curve
|
||||
accelerates through the middle and maintains high velocity
|
||||
until the very last point to simulate a sudden 'liftoff' flick.
|
||||
This allows Android's ScrollView to register a high fling velocity.
|
||||
|
||||
@@ -427,7 +401,7 @@ class BezierGesture:
|
||||
for i in range(n_points):
|
||||
t = i / (n_points - 1)
|
||||
# Starts slow (larger delay), speeds up continuously (smaller delay)
|
||||
speed_factor = 1.0 - (0.8 * t)
|
||||
speed_factor = 1.0 - (0.8 * t)
|
||||
raw_intervals.append(speed_factor)
|
||||
|
||||
total_raw = sum(raw_intervals)
|
||||
|
||||
@@ -15,10 +15,9 @@ import logging
|
||||
import random
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.physics.biomechanics import PhysicsBody, BezierGesture
|
||||
from GramAddict.core.physics.biomechanics import BezierGesture, PhysicsBody
|
||||
from GramAddict.core.physics.sendevent_injector import SendEventInjector
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -51,9 +50,8 @@ def humanized_scroll(device, is_skip=False, resonance_score=None):
|
||||
if is_skip:
|
||||
# Aggressive fast fling to skip quickly. NO CORRECTIONS.
|
||||
distance = int(h * random.uniform(0.6, 0.75))
|
||||
duration = random.uniform(150, 250) # slightly longer to ensure smooth fling registration
|
||||
duration = random.uniform(150, 250) # slightly longer to ensure smooth fling registration
|
||||
end_y = start_y - distance
|
||||
do_correction = False # Force false
|
||||
else:
|
||||
# Playful, organic human scrolling
|
||||
play_choice = random.random()
|
||||
@@ -111,9 +109,7 @@ def humanized_scroll(device, is_skip=False, resonance_score=None):
|
||||
logger.debug("🦴 [Biomechanics] Mid-scroll reading pause")
|
||||
|
||||
# --- Generate Bézier Curve ---
|
||||
points = BezierGesture.scroll_curve(
|
||||
(start_x, start_y), (int(end_x), end_y), body
|
||||
)
|
||||
points = BezierGesture.scroll_curve((start_x, start_y), (int(end_x), end_y), body)
|
||||
timing = BezierGesture.compute_sigmoid_timing(len(points), duration)
|
||||
|
||||
# Pre-touch dwell: hold finger on glass before moving
|
||||
@@ -131,8 +127,6 @@ def humanized_scroll(device, is_skip=False, resonance_score=None):
|
||||
points.insert(mid + 1, pause_point)
|
||||
timing.insert(mid, pause_duration)
|
||||
|
||||
|
||||
|
||||
# --- Inject Gesture ---
|
||||
injector.inject_gesture(points, timing, touch_major=body.get_touch_major())
|
||||
|
||||
@@ -148,12 +142,10 @@ def humanized_scroll(device, is_skip=False, resonance_score=None):
|
||||
corr_end_y = corr_start_y - corr_distance # Scroll back down
|
||||
|
||||
corr_points = BezierGesture.scroll_curve(
|
||||
(corr_start_x, corr_start_y), (corr_start_x, corr_end_y), body,
|
||||
n_points=6
|
||||
(corr_start_x, corr_start_y), (corr_start_x, corr_end_y), body, n_points=6
|
||||
)
|
||||
corr_timing = BezierGesture.compute_sigmoid_timing(len(corr_points), 200)
|
||||
|
||||
|
||||
injector.inject_gesture(corr_points, corr_timing, touch_major=body.get_touch_major())
|
||||
|
||||
|
||||
@@ -168,7 +160,6 @@ def humanized_click(device, x, y, double=False, sleep_mod=1.0):
|
||||
tap_duration = random.uniform(40, 90)
|
||||
timing = BezierGesture.compute_sigmoid_timing(len(points), tap_duration)
|
||||
|
||||
|
||||
injector.inject_gesture(points, timing, touch_major=body.get_touch_major())
|
||||
|
||||
if double:
|
||||
@@ -194,14 +185,9 @@ def humanized_horizontal_swipe(device, start_x, end_x, y, duration_ms):
|
||||
# Timing wobble (+/- 30%)
|
||||
actual_duration = int(duration_ms * random.uniform(0.7, 1.3))
|
||||
|
||||
points = BezierGesture.horizontal_swipe_curve(
|
||||
(actual_start_x, actual_y), (actual_end_x, actual_y), body
|
||||
)
|
||||
points = BezierGesture.horizontal_swipe_curve((actual_start_x, actual_y), (actual_end_x, actual_y), body)
|
||||
timing = BezierGesture.compute_sigmoid_timing(len(points), actual_duration)
|
||||
|
||||
direction = "→" if end_x > start_x else "←"
|
||||
|
||||
|
||||
injector.inject_gesture(points, timing, touch_major=body.get_touch_major())
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ correct /dev/input/eventX and the axis ranges on first use.
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from time import sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -41,9 +40,9 @@ class SendEventInjector:
|
||||
|
||||
# Multitouch protocol B codes (most modern Android devices)
|
||||
ABS_MT_TRACKING_ID = 0x39 # 57
|
||||
ABS_MT_POSITION_X = 0x35 # 53
|
||||
ABS_MT_POSITION_Y = 0x36 # 54
|
||||
ABS_MT_PRESSURE = 0x3A # 58
|
||||
ABS_MT_POSITION_X = 0x35 # 53
|
||||
ABS_MT_POSITION_Y = 0x36 # 54
|
||||
ABS_MT_PRESSURE = 0x3A # 58
|
||||
ABS_MT_TOUCH_MAJOR = 0x30 # 48
|
||||
|
||||
SYN_REPORT = 0
|
||||
@@ -88,16 +87,14 @@ class SendEventInjector:
|
||||
line = line.strip()
|
||||
|
||||
# Device header: /dev/input/eventX
|
||||
dev_match = re.match(r'add device \d+:\s*(/dev/input/event\d+)', line)
|
||||
dev_match = re.match(r"add device \d+:\s*(/dev/input/event\d+)", line)
|
||||
if dev_match:
|
||||
current_device = dev_match.group(1)
|
||||
|
||||
# Check for multitouch capability
|
||||
if current_device and "ABS_MT_POSITION_X" in line:
|
||||
self.event_device = current_device
|
||||
logger.info(
|
||||
f"🖐️ [SendEvent] Touch device detected: {self.event_device}"
|
||||
)
|
||||
logger.info(f"🖐️ [SendEvent] Touch device detected: {self.event_device}")
|
||||
|
||||
# Parse axis ranges from the same section
|
||||
self._parse_axis_ranges(result, current_device)
|
||||
@@ -105,17 +102,11 @@ class SendEventInjector:
|
||||
return
|
||||
|
||||
# If no MT device found, try fallback pattern
|
||||
logger.debug(
|
||||
"⚠️ [SendEvent] No multitouch device found. "
|
||||
"Falling back to `input swipe` mode."
|
||||
)
|
||||
logger.debug("⚠️ [SendEvent] No multitouch device found. " "Falling back to `input swipe` mode.")
|
||||
self._fallback_mode = True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"⚠️ [SendEvent] Device detection failed: {e}. "
|
||||
f"Falling back to `input swipe` mode."
|
||||
)
|
||||
logger.warning(f"⚠️ [SendEvent] Device detection failed: {e}. " f"Falling back to `input swipe` mode.")
|
||||
self._fallback_mode = True
|
||||
|
||||
def _parse_axis_ranges(self, getevent_output, device_path):
|
||||
@@ -134,19 +125,19 @@ class SendEventInjector:
|
||||
|
||||
if in_device:
|
||||
if "ABS_MT_POSITION_X" in line:
|
||||
m = re.search(r'max\s+(\d+)', line)
|
||||
m = re.search(r"max\s+(\d+)", line)
|
||||
if m:
|
||||
self.x_max = int(m.group(1))
|
||||
elif "ABS_MT_POSITION_Y" in line:
|
||||
m = re.search(r'max\s+(\d+)', line)
|
||||
m = re.search(r"max\s+(\d+)", line)
|
||||
if m:
|
||||
self.y_max = int(m.group(1))
|
||||
elif "ABS_MT_PRESSURE" in line:
|
||||
m = re.search(r'max\s+(\d+)', line)
|
||||
m = re.search(r"max\s+(\d+)", line)
|
||||
if m:
|
||||
self.pressure_max = int(m.group(1))
|
||||
elif "ABS_MT_TOUCH_MAJOR" in line:
|
||||
m = re.search(r'max\s+(\d+)', line)
|
||||
m = re.search(r"max\s+(\d+)", line)
|
||||
if m:
|
||||
self.touch_major_max = int(m.group(1))
|
||||
|
||||
@@ -262,6 +253,4 @@ class SendEventInjector:
|
||||
ex, ey, _ = points[-1]
|
||||
total_ms = int(sum(timing_intervals) * 1000) if timing_intervals else 300
|
||||
|
||||
self.device.shell(
|
||||
f"input swipe {int(sx)} {int(sy)} {int(ex)} {int(ey)} {total_ms}"
|
||||
)
|
||||
self.device.shell(f"input swipe {int(sx)} {int(sy)} {int(ex)} {int(ey)} {total_ms}")
|
||||
|
||||
@@ -13,8 +13,8 @@ import re
|
||||
import time
|
||||
from time import sleep
|
||||
|
||||
from GramAddict.core.perception.feed_analysis import FEED_MARKERS
|
||||
from GramAddict.core.diagnostic_dump import dump_ui_state
|
||||
from GramAddict.core.perception.feed_analysis import FEED_MARKERS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,13 +22,13 @@ logger = logging.getLogger(__name__)
|
||||
def wait_for_post_loaded(device, timeout=5, nav_graph=None):
|
||||
"""
|
||||
Polls the UI hierarchy until feed markers appear, confirming a post is on screen.
|
||||
|
||||
|
||||
If timeout is reached, attempts Adaptive Snap recovery:
|
||||
1. Detects trap states (Story/Reel viewer, Profile)
|
||||
2. Presses BACK to escape
|
||||
3. Micro-wobbles to force render
|
||||
"""
|
||||
|
||||
|
||||
start = time.time()
|
||||
xml = ""
|
||||
while time.time() - start < timeout:
|
||||
@@ -37,7 +37,7 @@ def wait_for_post_loaded(device, timeout=5, nav_graph=None):
|
||||
if any(marker in xml for marker in FEED_MARKERS):
|
||||
logger.debug("📱 Post loaded successfully.")
|
||||
return True
|
||||
|
||||
|
||||
# Handle high-latency loads
|
||||
if "android.widget.ProgressBar" in xml or "loading_spinner" in xml.lower():
|
||||
# Extend timeout by 5 seconds if we're about to time out and still loading
|
||||
@@ -47,10 +47,10 @@ def wait_for_post_loaded(device, timeout=5, nav_graph=None):
|
||||
except Exception:
|
||||
pass
|
||||
sleep(0.5)
|
||||
|
||||
|
||||
logger.warning("⚠️ Post did not load within timeout. Attempting Adaptive Snap.")
|
||||
dump_ui_state(device, "post_load_timeout", {"timeout_sec": timeout})
|
||||
|
||||
|
||||
try:
|
||||
xml_lower = xml.lower()
|
||||
# 1. Trapped in a Story or Reel viewer? Press back.
|
||||
@@ -63,29 +63,29 @@ def wait_for_post_loaded(device, timeout=5, nav_graph=None):
|
||||
if any(marker in xml for marker in FEED_MARKERS):
|
||||
logger.info("✅ Recovered to Feed.")
|
||||
return True
|
||||
|
||||
|
||||
# 2. Trapped in Profile?
|
||||
if "profile_header" in xml_lower and "row_feed_photo_profile_name" not in xml_lower:
|
||||
logger.warning("🧗 [Adaptive Snap] Trapped in Profile. Pressing BACK.")
|
||||
device.press("back")
|
||||
sleep(1.5)
|
||||
|
||||
|
||||
# 3. Stuck on Grid? The tap didn't register. Do not wobble.
|
||||
grid_markers = ["explore_tab", "explore_grid", "grid_card_layout_container", "profile_tabs_container"]
|
||||
if any(m in xml_lower for m in grid_markers):
|
||||
logger.warning("🧗 [Adaptive Snap] Detected bot is STILL on the Grid. Tap likely missed. Aborting snap.")
|
||||
return False
|
||||
|
||||
|
||||
# 4. Stuck between posts (Feed markers not fully visible)? Micro-wobble.
|
||||
info = device.get_info()
|
||||
w, h = info.get("displayWidth", 1080), info.get("displayHeight", 2400)
|
||||
logger.warning("🧗 [Adaptive Snap] Wobbling to force render.")
|
||||
device.swipe(int(w/2), int(h/2), int(w/2), int(h/2) - 100, 0.1)
|
||||
device.swipe(int(w / 2), int(h / 2), int(w / 2), int(h / 2) - 100, 0.1)
|
||||
sleep(0.5)
|
||||
device.swipe(int(w/2), int(h/2) - 100, int(w/2), int(h/2), 0.1)
|
||||
device.swipe(int(w / 2), int(h / 2) - 100, int(w / 2), int(h / 2), 0.1)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ [Adaptive Snap] Failed: {e}")
|
||||
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -101,16 +101,18 @@ def wait_for_story_loaded(device, timeout=5):
|
||||
except Exception:
|
||||
pass
|
||||
sleep(0.5)
|
||||
|
||||
|
||||
logger.warning("⚠️ Story did not load within timeout.")
|
||||
return False
|
||||
|
||||
|
||||
def wait_for_profile_loaded(device, timeout=5):
|
||||
"""Polls the UI hierarchy until profile markers appear."""
|
||||
import time
|
||||
|
||||
start = time.time()
|
||||
PROFILE_MARKERS = ["profile_header", "action_bar_title", "profile_tabs_container"]
|
||||
|
||||
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
xml_lower = device.dump_hierarchy().lower()
|
||||
@@ -120,12 +122,11 @@ def wait_for_profile_loaded(device, timeout=5):
|
||||
except Exception:
|
||||
pass
|
||||
sleep(0.5)
|
||||
|
||||
|
||||
logger.warning("⚠️ Profile did not load within timeout.")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
def align_active_post(device):
|
||||
"""
|
||||
Programmatic snapping correction. Finds the nearest post header and perfectly
|
||||
@@ -135,41 +136,39 @@ def align_active_post(device):
|
||||
aligned = False
|
||||
attempts = 0
|
||||
max_attempts = 3
|
||||
|
||||
|
||||
while not aligned and attempts < max_attempts:
|
||||
attempts += 1
|
||||
try:
|
||||
xml = device.dump_hierarchy()
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
telepath = TelepathicEngine.get_instance()
|
||||
target_node = telepath.find_best_node(
|
||||
xml, "post author header profile",
|
||||
min_confidence=0.4, device=device
|
||||
)
|
||||
|
||||
target_node = telepath.find_best_node(xml, "post author header profile", min_confidence=0.4, device=device)
|
||||
|
||||
if target_node:
|
||||
original_attribs = target_node.get('original_attribs', {})
|
||||
bounds = original_attribs.get('bounds', '')
|
||||
original_attribs = target_node.get("original_attribs", {})
|
||||
bounds = original_attribs.get("bounds", "")
|
||||
if not bounds:
|
||||
bounds = target_node.get('bounds', '')
|
||||
|
||||
m = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds)
|
||||
bounds = target_node.get("bounds", "")
|
||||
|
||||
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
|
||||
if m:
|
||||
l, t, r, b = map(int, m.groups())
|
||||
header_y = (t + b) // 2
|
||||
|
||||
|
||||
# Instagram's optimal top margin for a snapped post is ~200-280px
|
||||
target_y = 250
|
||||
diff = header_y - target_y
|
||||
|
||||
|
||||
# If target is off-center (> 100px), execute precise correction swipe
|
||||
if abs(diff) > 100:
|
||||
info = device.get_info()
|
||||
w, h = info.get("displayWidth", 1080), info.get("displayHeight", 2400)
|
||||
cx = w // 2
|
||||
|
||||
|
||||
max_safe_swipe = int(h * 0.4)
|
||||
|
||||
|
||||
if diff > 0:
|
||||
# Content is too LOW. Move it UP.
|
||||
dist = min(diff, max_safe_swipe)
|
||||
@@ -180,7 +179,7 @@ def align_active_post(device):
|
||||
dist = min(abs(diff), max_safe_swipe)
|
||||
start_y = int(h * 0.3)
|
||||
end_y = start_y + dist
|
||||
|
||||
|
||||
# Duration 1.0s = precise mechanical drag with ZERO momentum
|
||||
device.swipe(cx, start_y, cx, end_y, duration=1.0)
|
||||
sleep(1.0)
|
||||
@@ -192,7 +191,7 @@ def align_active_post(device):
|
||||
except Exception as e:
|
||||
logger.debug(f"📐 [Alignment] Snapping correction failed: {e}")
|
||||
break
|
||||
|
||||
|
||||
if aligned and attempts > 1:
|
||||
logger.debug(f"📐 [Alignment] Snapped post cleanly into view after {attempts} attempts.")
|
||||
return True
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
import time
|
||||
import random
|
||||
from GramAddict.core.utils import random_sleep
|
||||
from GramAddict.core.compiler_engine import VLMCompilerEngine
|
||||
from GramAddict.core.qdrant_memory import NavigationMemoryDB
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
|
||||
from GramAddict.core.goap import GoalExecutor, ScreenType
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
import time
|
||||
|
||||
from GramAddict.core.compiler_engine import VLMCompilerEngine
|
||||
from GramAddict.core.goap import GoalExecutor, ScreenType
|
||||
from GramAddict.core.qdrant_memory import NavigationMemoryDB
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
|
||||
from GramAddict.core.utils import random_sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Node:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.transitions = {} # Action (e.g. "tap_search") -> Node
|
||||
self.transitions = {} # Action (e.g. "tap_search") -> Node
|
||||
|
||||
|
||||
class QNavGraph:
|
||||
"""
|
||||
Topological Navigation Map
|
||||
Maintains a directed graph of UI states. Instead of hardcoded navigation scripts,
|
||||
Maintains a directed graph of UI states. Instead of hardcoded navigation scripts,
|
||||
the bot traverses this graph. If a path fails, it invokes the VLMCompilerEngine to repair it.
|
||||
"""
|
||||
|
||||
def __init__(self, device):
|
||||
self.device = device
|
||||
self.nodes = {}
|
||||
@@ -32,11 +32,10 @@ class QNavGraph:
|
||||
self.nav_memory = NavigationMemoryDB()
|
||||
self.sae = SituationalAwarenessEngine.get_instance(device)
|
||||
self.goap = GoalExecutor.get_instance(device)
|
||||
|
||||
|
||||
self.compiler = VLMCompilerEngine(device)
|
||||
self._load_graph()
|
||||
|
||||
|
||||
def _load_graph(self):
|
||||
"""Loads the topological map from Qdrant. Merges with core seeds from ScreenTopology (SSOT)."""
|
||||
logger.debug("🌐 [NavGraph] Syncing topological map with Qdrant...")
|
||||
@@ -46,8 +45,11 @@ class QNavGraph:
|
||||
core_nodes = {}
|
||||
for screen_type, transitions in ScreenTopology.TRANSITIONS.items():
|
||||
# Reverse lookup: ScreenType → QNavGraph string name from SSOT
|
||||
screen_name_map = {v: k for k, v in ScreenTopology.SCREEN_NAME_MAP.items()
|
||||
if v not in (ScreenType.HOME_FEED, ScreenType.EXPLORE_GRID) or k not in ("StoriesFeed", "SearchFeed")}
|
||||
screen_name_map = {
|
||||
v: k
|
||||
for k, v in ScreenTopology.SCREEN_NAME_MAP.items()
|
||||
if v not in (ScreenType.HOME_FEED, ScreenType.EXPLORE_GRID) or k not in ("StoriesFeed", "SearchFeed")
|
||||
}
|
||||
node_name = screen_name_map.get(screen_type)
|
||||
if not node_name:
|
||||
continue
|
||||
@@ -72,7 +74,6 @@ class QNavGraph:
|
||||
"""Deprecated: Navigation state is now persisted per-transition in Qdrant."""
|
||||
pass
|
||||
|
||||
|
||||
def navigate_to(self, target_state: str, zero_engine, recovery_attempts: int = 0):
|
||||
"""
|
||||
GOAP-powered autonomous navigation.
|
||||
@@ -80,18 +81,19 @@ class QNavGraph:
|
||||
using hardcoded state machines and BFS pathfinding.
|
||||
"""
|
||||
logger.info(f"📍 [GOAP] Navigating autonomously to: {target_state}")
|
||||
|
||||
|
||||
# Set bot username for screen identity
|
||||
try:
|
||||
from GramAddict.core.config import Config
|
||||
args = getattr(Config(), 'args', None)
|
||||
if args and hasattr(args, 'username'):
|
||||
|
||||
args = getattr(Config(), "args", None)
|
||||
if args and hasattr(args, "username"):
|
||||
self.goap.screen_id.bot_username = args.username.lower()
|
||||
except Exception as e:
|
||||
logger.debug(f"⚠️ [GOAP] Skipping username sync: {e}")
|
||||
|
||||
|
||||
success = self.goap.navigate_to_screen(target_state)
|
||||
|
||||
|
||||
if success:
|
||||
self.current_state = target_state
|
||||
logger.info(f"✅ [GOAP] Reached {target_state}")
|
||||
@@ -99,24 +101,28 @@ class QNavGraph:
|
||||
logger.error(f"❌ [GOAP] Failed to reach {target_state}")
|
||||
# Final fallback: force app start and reset
|
||||
if recovery_attempts < 2:
|
||||
logger.warning(f"🔄 [GOAP Recovery] Step {recovery_attempts + 1}: Attempting app restart to escape softlock...")
|
||||
logger.warning(
|
||||
f"🔄 [GOAP Recovery] Step {recovery_attempts + 1}: Attempting app restart to escape softlock..."
|
||||
)
|
||||
self.device.app_start(self.device.app_id, use_monkey=True)
|
||||
random_sleep(3.0, 4.5)
|
||||
self.current_state = "HomeFeed"
|
||||
# Clear GOAP status for fresh attempt
|
||||
return self.navigate_to(target_state, zero_engine, recovery_attempts + 1)
|
||||
else:
|
||||
logger.critical(f"🛑 [GOAP Recovery] Max recovery attempts reached. Navigation to {target_state} aborted.")
|
||||
|
||||
logger.critical(
|
||||
f"🛑 [GOAP Recovery] Max recovery attempts reached. Navigation to {target_state} aborted."
|
||||
)
|
||||
|
||||
return success
|
||||
|
||||
def do(self, goal: str) -> bool:
|
||||
"""
|
||||
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")
|
||||
@@ -124,14 +130,14 @@ class QNavGraph:
|
||||
"""
|
||||
# ── Screen sanity check: is this action possible here? ──
|
||||
screen = self.goap.perceive()
|
||||
available = screen.get('available_actions', [])
|
||||
screen_type = screen['screen_type']
|
||||
|
||||
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',
|
||||
"like": "tap like button",
|
||||
"comment": "tap comment button",
|
||||
"share": "tap share button",
|
||||
}
|
||||
for keyword, required_action in action_checks.items():
|
||||
if keyword in goal.lower() and required_action not in available:
|
||||
@@ -140,7 +146,7 @@ class QNavGraph:
|
||||
f"('{required_action}' not available on this screen)"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
return self.goap._execute_action(goal)
|
||||
|
||||
def _find_path(self, start: str, end: str):
|
||||
@@ -170,19 +176,20 @@ class QNavGraph:
|
||||
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()
|
||||
|
||||
|
||||
failed_positions = set() # Track (x, y) of clicks that failed, for grid retry diversity
|
||||
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
context_xml = self.device.dump_hierarchy()
|
||||
|
||||
|
||||
# ── Z-Depth Guard / Anomaly Obstacle Clearance ──
|
||||
cleared_something = self._clear_anomaly_obstacles(xml_dump=context_xml)
|
||||
if cleared_something:
|
||||
# Re-acquire context after clearing obstacle
|
||||
context_xml = self.device.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
|
||||
@@ -209,21 +216,31 @@ class QNavGraph:
|
||||
"tap_newsfeed_tab": "tap activity heart icon notifications",
|
||||
}
|
||||
intent_description = intent_map.get(action, action.replace("_", " "))
|
||||
|
||||
|
||||
# Use TelepathicEngine to find the most likely node for this intent
|
||||
# If vector score < 0.82, it will trigger the Vision Cortex Fallback (VLM)
|
||||
# Pass failed_positions so grid fast-path picks a different item on retry
|
||||
best_node = engine.find_best_node(context_xml, intent_description, min_confidence=0.82, device=self.device, skip_positions=failed_positions)
|
||||
|
||||
best_node = engine.find_best_node(
|
||||
context_xml,
|
||||
intent_description,
|
||||
min_confidence=0.82,
|
||||
device=self.device,
|
||||
skip_positions=failed_positions,
|
||||
)
|
||||
|
||||
# ── Blocked by Modal Recovery ──
|
||||
if best_node and best_node.get("blocked_by_modal"):
|
||||
logger.warning(f"🛡️ [Modal Recovery] Navigation '{action}' is blocked by a modal. Attempting anomaly clearance...")
|
||||
logger.warning(
|
||||
f"🛡️ [Modal Recovery] Navigation '{action}' is blocked by a modal. Attempting anomaly clearance..."
|
||||
)
|
||||
self._clear_anomaly_obstacles()
|
||||
if attempt < max_retries:
|
||||
context_xml = self.device.dump_hierarchy()
|
||||
continue
|
||||
else:
|
||||
logger.error(f"❌ [Modal Recovery] Persistent blockage for '{action}'. Escalating to Context Lost (App Restart).")
|
||||
logger.error(
|
||||
f"❌ [Modal Recovery] Persistent blockage for '{action}'. Escalating to Context Lost (App Restart)."
|
||||
)
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
if not best_node:
|
||||
@@ -231,62 +248,76 @@ class QNavGraph:
|
||||
# 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.")
|
||||
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
|
||||
|
||||
# FINAL ATTEMPT ESCAPE:
|
||||
# If we are looking for the 'Home' tab (our baseline) and everything failed,
|
||||
|
||||
# FINAL ATTEMPT ESCAPE:
|
||||
# If we are looking for the 'Home' tab (our baseline) and everything failed,
|
||||
# we might be in an unknown sub-view. Try one last 'BACK' press.
|
||||
if action == "tap_home_tab":
|
||||
logger.warning("📍 [Escape] Home tab not found after all retries. Attempting final BACK press to escape sub-view...")
|
||||
logger.warning(
|
||||
"📍 [Escape] Home tab not found after all retries. Attempting final BACK press to escape sub-view..."
|
||||
)
|
||||
self.device.press("back")
|
||||
time.sleep(2.0)
|
||||
|
||||
|
||||
return False
|
||||
|
||||
|
||||
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)")
|
||||
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})")
|
||||
|
||||
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.6, 2.8))
|
||||
|
||||
|
||||
# ── Post-Click Verification: Did it work? ──
|
||||
post_click_xml = self.device.dump_hierarchy()
|
||||
|
||||
|
||||
# ── App Perimeter Guard (SAE-powered) ──
|
||||
post_situation = self.sae.perceive(post_click_xml)
|
||||
if post_situation in (SituationType.OBSTACLE_FOREIGN_APP, SituationType.OBSTACLE_SYSTEM, SituationType.OBSTACLE_MODAL):
|
||||
logger.warning(f"🚨 [SAE Perimeter] Transition '{action}' caused drift ({post_situation.value}). Initiating autonomous recovery...")
|
||||
if post_situation in (
|
||||
SituationType.OBSTACLE_FOREIGN_APP,
|
||||
SituationType.OBSTACLE_SYSTEM,
|
||||
SituationType.OBSTACLE_MODAL,
|
||||
):
|
||||
logger.warning(
|
||||
f"🚨 [SAE Perimeter] Transition '{action}' caused drift ({post_situation.value}). Initiating autonomous recovery..."
|
||||
)
|
||||
failed_positions.add((best_node["x"], best_node["y"]))
|
||||
engine.reject_click(intent_description)
|
||||
|
||||
|
||||
# Let SAE handle recovery autonomously
|
||||
recovered = self.sae.ensure_clear_screen(max_attempts=5)
|
||||
if not recovered:
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
|
||||
# Screen is clear but the transition itself failed — retry
|
||||
if attempt < max_retries:
|
||||
logger.info(f"🔄 [SAE Recovery] Screen recovered. Retrying transition '{action}'...")
|
||||
continue
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
if is_verified and ui_changed:
|
||||
engine.confirm_click(intent_description)
|
||||
return True
|
||||
@@ -295,27 +326,33 @@ class QNavGraph:
|
||||
failed_positions.add((best_node["x"], best_node["y"]))
|
||||
engine.reject_click(intent_description)
|
||||
if attempt < max_retries:
|
||||
logger.info(f"🔄 [Autonomy] UI unchanged. Retrying transition '{action}' ({attempt + 1}/{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.")
|
||||
logger.warning(
|
||||
f"❌ [Ambiguity Guard] UI changed after '{action}', but semantic verification FAILED. Rejecting mapping."
|
||||
)
|
||||
failed_positions.add((best_node["x"], best_node["y"]))
|
||||
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.press("back")
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
if attempt < max_retries:
|
||||
logger.info(f"🔄 [Autonomy] Negative learning acquired. Retrying transition '{action}' ({attempt + 1}/{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):
|
||||
@@ -324,13 +361,14 @@ class QNavGraph:
|
||||
and write a new rule for `action`.
|
||||
"""
|
||||
from GramAddict.core.dojo_engine import DojoEngine
|
||||
|
||||
dojo = DojoEngine.get_instance(self.device)
|
||||
|
||||
logger.warning(f"⛩️ [Dojo] Enqueuing auto-labeling job for missing '{action}'.", extra={"color": f"\x1b[36m"})
|
||||
|
||||
logger.warning(f"⛩️ [Dojo] Enqueuing auto-labeling job for missing '{action}'.", extra={"color": "\x1b[36m"})
|
||||
context_xml = self.device.dump_hierarchy()
|
||||
|
||||
|
||||
dojo.submit_snapshot(
|
||||
heuristic_name=action,
|
||||
context_xml=context_xml,
|
||||
intent_prompt=f"Find the button that performs: {action}. Be extremely robust against structural UI changes."
|
||||
intent_prompt=f"Find the button that performs: {action}. Be extremely robust against structural UI changes.",
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,22 @@
|
||||
import logging
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from colorama import Fore
|
||||
|
||||
from GramAddict.core.qdrant_memory import ContentMemoryDB, PersonaMemoryDB, ParasocialCRMDB, CommentMemoryDB
|
||||
from GramAddict.core.llm_provider import query_llm
|
||||
from GramAddict.core.qdrant_memory import CommentMemoryDB, ContentMemoryDB, ParasocialCRMDB, PersonaMemoryDB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResonanceEngine:
|
||||
"""
|
||||
The Aesthetic Oracle — Real AI Content Evaluation.
|
||||
|
||||
|
||||
Calculates semantic alignment (Resonance Score) between the bot's
|
||||
configured persona interests and target content using vector embeddings.
|
||||
|
||||
|
||||
This drives ALL downstream decisions:
|
||||
- Like probability (score >= 0.35)
|
||||
- Comment probability (score >= 0.8)
|
||||
@@ -22,6 +24,7 @@ class ResonanceEngine:
|
||||
- Dopamine spike intensity
|
||||
- Darwin dwell time modulation
|
||||
"""
|
||||
|
||||
def __init__(self, my_username: str, persona_interests: list[str] = None, crm: ParasocialCRMDB = None):
|
||||
self.my_username = my_username
|
||||
self.content_memory = ContentMemoryDB()
|
||||
@@ -29,12 +32,11 @@ class ResonanceEngine:
|
||||
self.crm = crm
|
||||
self.threshold = 0.5
|
||||
|
||||
|
||||
# The persona vector is the mathematical identity of what content we care about.
|
||||
# It's generated from config's persona_interests and cached for the entire session.
|
||||
self._persona_vector: Optional[list] = None
|
||||
self._persona_interests = persona_interests or []
|
||||
|
||||
|
||||
# Bootstrap persona on init
|
||||
if self._persona_interests:
|
||||
self._bootstrap_persona()
|
||||
@@ -46,48 +48,46 @@ class ResonanceEngine:
|
||||
"""
|
||||
persona_text = f"Content about: {', '.join(self._persona_interests)}"
|
||||
self._persona_vector = self.content_memory._get_embedding(persona_text)
|
||||
|
||||
|
||||
if self._persona_vector:
|
||||
# Store in PersonaMemoryDB for persistence across sessions
|
||||
self.persona_memory.store_persona_insight(
|
||||
"interests",
|
||||
f"Core niche interests: {', '.join(self._persona_interests)}"
|
||||
"interests", f"Core niche interests: {', '.join(self._persona_interests)}"
|
||||
)
|
||||
logger.info(
|
||||
f"✨ [Resonance Oracle] Persona vector initialized from config: {self._persona_interests}",
|
||||
extra={"color": f"{Fore.MAGENTA}"}
|
||||
extra={"color": f"{Fore.MAGENTA}"},
|
||||
)
|
||||
else:
|
||||
logger.warning("✨ [Resonance Oracle] Could not generate persona embedding. Falling back to neutral scoring.")
|
||||
logger.warning(
|
||||
"✨ [Resonance Oracle] Could not generate persona embedding. Falling back to neutral scoring."
|
||||
)
|
||||
|
||||
def update_identity(self, persona: list, vibe: str):
|
||||
"""Dynamically update the core agent identity and embeddings during a session"""
|
||||
self._persona_interests = persona
|
||||
|
||||
|
||||
# Build embedding for updated persona
|
||||
combined_text = " ".join(self._persona_interests)
|
||||
new_vector = self.content_memory._get_embedding(combined_text)
|
||||
|
||||
|
||||
if new_vector:
|
||||
self._persona_vector = new_vector
|
||||
self.persona_memory.store_persona_insight(
|
||||
"interests",
|
||||
f"Dynamically updated interests: {', '.join(self._persona_interests)}"
|
||||
"interests", f"Dynamically updated interests: {', '.join(self._persona_interests)}"
|
||||
)
|
||||
logger.info(
|
||||
f"✨ [Resonance Oracle] Identity dynamically updated! New Persona: {self._persona_interests} | Vibe: {vibe}",
|
||||
extra={"color": f"{Fore.MAGENTA}"}
|
||||
extra={"color": f"{Fore.MAGENTA}"},
|
||||
)
|
||||
else:
|
||||
logger.warning("✨ [Resonance Oracle] Failed to build embedding for new identity. Retaining previous state.")
|
||||
logger.warning(
|
||||
"✨ [Resonance Oracle] Failed to build embedding for new identity. Retaining previous state."
|
||||
)
|
||||
|
||||
def _classification_to_score(self, classification: str) -> float:
|
||||
"""Maps semantic classification labels to numerical scores."""
|
||||
mapping = {
|
||||
"high": 0.85,
|
||||
"medium": 0.5,
|
||||
"low": 0.2
|
||||
}
|
||||
mapping = {"high": 0.85, "medium": 0.5, "low": 0.2}
|
||||
return mapping.get(classification.lower(), 0.5)
|
||||
|
||||
def _cosine_similarity(self, v1: list, v2: list) -> float:
|
||||
@@ -107,73 +107,73 @@ class ResonanceEngine:
|
||||
"""
|
||||
username = post_content.get("username", "Unknown")
|
||||
description = post_content.get("description", "")
|
||||
|
||||
|
||||
logger.info(f"✨ [Resonance Oracle] Evaluating content from @{username}...", extra={"color": f"{Fore.MAGENTA}"})
|
||||
|
||||
|
||||
# Build a rich text representation of the post
|
||||
|
||||
description = post_content.get("description", "")
|
||||
caption = post_content.get("caption", "")
|
||||
username = post_content.get("username", "")
|
||||
|
||||
|
||||
content_text = " ".join(filter(None, [description, caption])).strip()
|
||||
|
||||
|
||||
if not content_text or len(content_text) < 5:
|
||||
logger.debug("✨ [Resonance] Post has no extractable content. Neutral score.")
|
||||
return 0.5 # Neutral — can't evaluate what we can't see
|
||||
|
||||
|
||||
# 0. Ads are now checked upstream structurally via `is_ad(xml)` in bot_flow.
|
||||
# This prevents false positives from users writing 'Werbung' in non-ad contexts.
|
||||
|
||||
|
||||
# 1. Check ContentMemoryDB cache — have we seen nearly identical content?
|
||||
cached = self.content_memory.get_cached_evaluation(content_text)
|
||||
if cached:
|
||||
score = self._classification_to_score(cached.get("classification", "medium"))
|
||||
logger.info(
|
||||
f"✨ [Resonance Cache Hit] '{content_text[:40]}...' → {score*100:.1f}%",
|
||||
extra={"color": f"{Fore.MAGENTA}"}
|
||||
extra={"color": f"{Fore.MAGENTA}"},
|
||||
)
|
||||
return score
|
||||
|
||||
|
||||
# 2. No persona vector? Can't do real evaluation.
|
||||
if not self._persona_vector:
|
||||
logger.debug("✨ [Resonance] No persona vector. Configure persona_interests in config.yml.")
|
||||
return 0.5
|
||||
|
||||
|
||||
# 3. Generate embedding of the post content
|
||||
post_vector = self.content_memory._get_embedding(content_text)
|
||||
if not post_vector:
|
||||
return 0.5
|
||||
|
||||
|
||||
# 4. Cosine similarity against persona = resonance score
|
||||
raw_score = self._cosine_similarity(post_vector, self._persona_vector)
|
||||
|
||||
|
||||
# Normalize: text-embedding-3-small cosine similarity for text embeddings typically ranges 0.15 (completely distinct) to 0.55 (very matched, but not literal identical copies)
|
||||
# Map this to a more useful 0.0-1.0 range
|
||||
score = max(0.0, min(1.0, (raw_score - 0.15) / 0.30))
|
||||
|
||||
|
||||
# ── Contextual Empathy Filter ──
|
||||
# If the content is tragic or highly controversial, we must NOT like it, regardless of interest alignment.
|
||||
score = self._apply_empathy_filter(content_text, score)
|
||||
|
||||
|
||||
# 5. Store evaluation in ContentMemoryDB for future cache hits
|
||||
classification = "high" if score > 0.7 else "medium" if score > 0.4 else "low"
|
||||
self.content_memory.store_evaluation(
|
||||
content_text[:500], # Cap length for storage
|
||||
classification,
|
||||
f"Resonance: {score:.3f} (raw cosine: {raw_score:.3f})"
|
||||
f"Resonance: {score:.3f} (raw cosine: {raw_score:.3f})",
|
||||
)
|
||||
|
||||
|
||||
# 6. Feed the Parasocial CRM
|
||||
if self.crm and username:
|
||||
intent = f"aesthetic_evaluation_{classification}"
|
||||
# Stage mapping: high resonance -> stage 1 (Curiosity)
|
||||
new_stage = 1 if classification == "high" else None
|
||||
self.crm.log_interaction(username, intent, new_stage=new_stage)
|
||||
|
||||
|
||||
logger.info(
|
||||
f"✨ [Resonance Oracle] '{content_text[:50]}...' → {score*100:.1f}% ({classification})",
|
||||
extra={"color": f"{Fore.MAGENTA}"}
|
||||
extra={"color": f"{Fore.MAGENTA}"},
|
||||
)
|
||||
return score
|
||||
|
||||
@@ -184,21 +184,40 @@ class ResonanceEngine:
|
||||
"""
|
||||
tragic_keywords = [
|
||||
# English
|
||||
"rip", "rest in peace", "tragedy", "died", "killed", "accident", "shooting",
|
||||
"funeral", "sad news", "memorial", "cancer", "disease", "breaking news",
|
||||
"rip",
|
||||
"rest in peace",
|
||||
"tragedy",
|
||||
"died",
|
||||
"killed",
|
||||
"accident",
|
||||
"shooting",
|
||||
"funeral",
|
||||
"sad news",
|
||||
"memorial",
|
||||
"cancer",
|
||||
"disease",
|
||||
"breaking news",
|
||||
# German
|
||||
"ruhe in frieden", "verstorben", "tragödie", "unfall", "tot", "beerdigung",
|
||||
"trauer", "krebs", "krankheit"
|
||||
"ruhe in frieden",
|
||||
"verstorben",
|
||||
"tragödie",
|
||||
"unfall",
|
||||
"tot",
|
||||
"beerdigung",
|
||||
"trauer",
|
||||
"krebs",
|
||||
"krankheit",
|
||||
]
|
||||
|
||||
|
||||
text_lower = text.lower()
|
||||
if any(f" {word} " in f" {text_lower} " for word in tragic_keywords):
|
||||
logger.warning("🛡️ [Empathy Filter] Tragic/Sensitive content detected. Suppressing resonance to prevent blind liking.")
|
||||
logger.warning(
|
||||
"🛡️ [Empathy Filter] Tragic/Sensitive content detected. Suppressing resonance to prevent blind liking."
|
||||
)
|
||||
# Drastically reduce score to "low resonance" zone (avoid liking)
|
||||
return min(current_score, 0.2)
|
||||
|
||||
return current_score
|
||||
|
||||
return current_score
|
||||
|
||||
def judge_interaction(self, score: float) -> bool:
|
||||
"""
|
||||
@@ -220,46 +239,55 @@ class ResonanceEngine:
|
||||
def get_suggested_action(self, username: str, base_resonance: float) -> str:
|
||||
"""
|
||||
[Phase 2] High-fidelity relationship escalation.
|
||||
Determines the 'best' interaction based on content resonance AND
|
||||
Determines the 'best' interaction based on content resonance AND
|
||||
past engagement history (CRM).
|
||||
"""
|
||||
if not self.crm or not username:
|
||||
# Default logic: Like if resonance is good enough
|
||||
if base_resonance >= 0.7: return "LIKE"
|
||||
if base_resonance >= 0.7:
|
||||
return "LIKE"
|
||||
return "SKIP"
|
||||
|
||||
relationship = self.crm.get_relationship_stage(username)
|
||||
stage = relationship.get("stage", 0)
|
||||
|
||||
|
||||
# ── Escalation Logic ──
|
||||
# Stage 0: Awareness (Seen/Cold) -> Only Like
|
||||
# Stage 1: Curiosity (Interacted once) -> Like + Comment
|
||||
# Stage 2: Rapport (Multiple interactions) -> Like + Comment + Follow
|
||||
# Stage 3: Conversion (Max relationship) -> High-frequency engagement
|
||||
|
||||
|
||||
if stage == 0:
|
||||
if base_resonance >= 0.85: return "COMMENT" # Instant hook if amazing
|
||||
if base_resonance >= 0.60: return "LIKE"
|
||||
if base_resonance >= 0.85:
|
||||
return "COMMENT" # Instant hook if amazing
|
||||
if base_resonance >= 0.60:
|
||||
return "LIKE"
|
||||
elif stage == 1:
|
||||
if base_resonance >= 0.70: return "COMMENT"
|
||||
if base_resonance >= 0.40: return "LIKE"
|
||||
if base_resonance >= 0.70:
|
||||
return "COMMENT"
|
||||
if base_resonance >= 0.40:
|
||||
return "LIKE"
|
||||
elif stage >= 2:
|
||||
if base_resonance >= 0.60: return "COMMENT"
|
||||
if base_resonance >= 0.30: return "LIKE"
|
||||
|
||||
if base_resonance >= 0.60:
|
||||
return "COMMENT"
|
||||
if base_resonance >= 0.30:
|
||||
return "LIKE"
|
||||
|
||||
return "SKIP"
|
||||
|
||||
# ── [Phase 3] Engagement Decision Logic ──
|
||||
|
||||
def wants_to_reply(self, base_resonance: float) -> bool:
|
||||
"""Decides if the bot should reply to a comment."""
|
||||
if base_resonance < 0.75: return False
|
||||
if base_resonance < 0.75:
|
||||
return False
|
||||
# CRM stage 1+ increases reply chance
|
||||
return random.random() < 0.35
|
||||
|
||||
def wants_to_deep_engage(self, base_resonance: float) -> bool:
|
||||
"""Decides if the bot should click through to a commenter profile."""
|
||||
if base_resonance < 0.8: return False
|
||||
if base_resonance < 0.8:
|
||||
return False
|
||||
return random.random() < 0.25
|
||||
|
||||
def extract_and_learn_comments(self, xml_hierarchy: str, configs, author: str = "unknown", images_b64: list = None):
|
||||
@@ -271,36 +299,43 @@ class ResonanceEngine:
|
||||
"""
|
||||
if not configs or not getattr(configs.args, "ai_learn_comments", False):
|
||||
return
|
||||
|
||||
|
||||
vibe = getattr(configs.args, "ai_vibe", "")
|
||||
blacklist = getattr(configs.args, "ai_blacklist_topics", "")
|
||||
if not vibe:
|
||||
return # No vibe to learn
|
||||
|
||||
logger.info(f"🧠 [Comment Learning] Extracting comments matching vibe: '{vibe}'...", extra={"color": f"{Fore.CYAN}"})
|
||||
|
||||
|
||||
logger.info(
|
||||
f"🧠 [Comment Learning] Extracting comments matching vibe: '{vibe}'...", extra={"color": f"{Fore.CYAN}"}
|
||||
)
|
||||
|
||||
# 1. Very basic semantic extraction (grab text nodes that look like comments)
|
||||
raw_comments = []
|
||||
|
||||
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
root = ET.fromstring(xml_hierarchy)
|
||||
for node in root.iter('node'):
|
||||
for node in root.iter("node"):
|
||||
# 1. Block System UI (Notifications, WiFi, etc)
|
||||
pkg = node.get("package", "").lower()
|
||||
if pkg != "com.instagram.android":
|
||||
continue
|
||||
|
||||
|
||||
text = node.get("text", "")
|
||||
content_desc = node.get("content-desc", "")
|
||||
val = (text if text else content_desc).strip()
|
||||
res_id = node.get("resource-id", "").lower()
|
||||
|
||||
|
||||
# 2. Heuristics: Only target comment text views
|
||||
is_comment_node = "comment" in res_id or "textview" in res_id
|
||||
|
||||
|
||||
# 3. Block accessibility garbage & UI labels
|
||||
is_ui_junk = val.lower().startswith("go to") or val.lower().startswith("tap to") or "actions for this post" in val.lower()
|
||||
is_ui_junk = (
|
||||
val.lower().startswith("go to")
|
||||
or val.lower().startswith("tap to")
|
||||
or "actions for this post" in val.lower()
|
||||
)
|
||||
|
||||
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"]:
|
||||
@@ -308,17 +343,19 @@ class ResonanceEngine:
|
||||
except Exception as e:
|
||||
logger.error(f"🧠 [Comment Learning] Failed to parse XML: {e}")
|
||||
return
|
||||
|
||||
|
||||
if not raw_comments:
|
||||
logger.debug("🧠 [Comment Learning] No legible comments found in UI.")
|
||||
return
|
||||
|
||||
|
||||
# Deduplicate and limit
|
||||
raw_comments = list(set(raw_comments))[:10]
|
||||
logger.debug(f"🧠 [Comment Learning] Scraped {len(raw_comments)} potential comment nodes. Passing to Condenser...")
|
||||
|
||||
logger.debug(
|
||||
f"🧠 [Comment Learning] Scraped {len(raw_comments)} potential comment nodes. Passing to Condenser..."
|
||||
)
|
||||
|
||||
logger.debug(f"🧠 [Comment Learning] Raw texts passed to Condenser:\n{chr(10).join(raw_comments)}")
|
||||
|
||||
|
||||
# 2. Filter via VLM Condenser
|
||||
prompt = (
|
||||
f"Evaluate these Instagram comments. Your goal is to identify comments that generally match this vibe while blocking SPAM, UI junk, and harmful topics.\n"
|
||||
@@ -329,22 +366,32 @@ class ResonanceEngine:
|
||||
"Set 'keep' to true if the comment feels authentic and matches the vibe.\n"
|
||||
"Set 'keep' to false only for clear spam, bots, UI buttons, or blacklist violations.\n"
|
||||
)
|
||||
|
||||
|
||||
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, system=system, format_json=True, images_b64=images_b64, max_tokens=600, temperature=0.1)
|
||||
response_dict = query_llm(
|
||||
url=url,
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
system=system,
|
||||
format_json=True,
|
||||
images_b64=images_b64,
|
||||
max_tokens=600,
|
||||
temperature=0.1,
|
||||
)
|
||||
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}")
|
||||
|
||||
|
||||
# Parse json gracefully
|
||||
if type(response_text) is str:
|
||||
clean_json = response_text.strip()
|
||||
@@ -360,7 +407,7 @@ class ResonanceEngine:
|
||||
else:
|
||||
# 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 = []
|
||||
@@ -372,17 +419,25 @@ class ResonanceEngine:
|
||||
if not has_spam and keep:
|
||||
valid_list.append(ev.get("text"))
|
||||
learned_comments = valid_list
|
||||
|
||||
|
||||
if not isinstance(learned_comments, list):
|
||||
logger.error(f"🧠 [Comment Learning] Condenser failed to return a valid JSON structure: {learned_comments}")
|
||||
logger.error(
|
||||
f"🧠 [Comment Learning] Condenser failed to return a valid JSON structure: {learned_comments}"
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
if not learned_comments:
|
||||
logger.info("🧠 [Comment Learning] Condenser rejected all scraped comments (did not align with vibe or hit blacklist).", extra={"color": f"{Fore.YELLOW}"})
|
||||
logger.info(
|
||||
"🧠 [Comment Learning] Condenser rejected all scraped comments (did not align with vibe or hit blacklist).",
|
||||
extra={"color": f"{Fore.YELLOW}"},
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"🧠 [Comment Learning] Condenser approved {len(learned_comments)} comments. Persisting to Qdrant...", extra={"color": f"{Fore.GREEN}"})
|
||||
|
||||
|
||||
logger.info(
|
||||
f"🧠 [Comment Learning] Condenser approved {len(learned_comments)} comments. Persisting to Qdrant...",
|
||||
extra={"color": f"{Fore.GREEN}"},
|
||||
)
|
||||
|
||||
# 3. Store the passing comments into Qdrant
|
||||
comment_db = CommentMemoryDB()
|
||||
stored = 0
|
||||
@@ -391,9 +446,12 @@ class ResonanceEngine:
|
||||
logger.debug(f" 👉 Storing: '{c}'")
|
||||
comment_db.store_comment(text=c, vibe=vibe, author=author)
|
||||
stored += 1
|
||||
|
||||
|
||||
if stored > 0:
|
||||
logger.info(f"✅ [Comment Vector Sync] Successfully embedded {stored} high-vibe comments into memory.", extra={"color": f"{Fore.GREEN}"})
|
||||
|
||||
logger.info(
|
||||
f"✅ [Comment Vector Sync] Successfully embedded {stored} high-vibe comments into memory.",
|
||||
extra={"color": f"{Fore.GREEN}"},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"🧠 [Comment Learning] Condenser failed: {e}")
|
||||
|
||||
@@ -8,8 +8,8 @@ This is the bot's GPS: it knows HOW to get from screen A to screen B
|
||||
before the bot starts moving. The GOAP planner consults this map
|
||||
as its primary routing strategy.
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from GramAddict.core.goap import ScreenType
|
||||
@@ -38,6 +38,7 @@ class ScreenTopology:
|
||||
"tap home tab": ScreenType.HOME_FEED,
|
||||
"tap profile tab": ScreenType.OWN_PROFILE,
|
||||
"tap reels tab": ScreenType.REELS_FEED,
|
||||
"view a post": ScreenType.POST_DETAIL,
|
||||
},
|
||||
ScreenType.REELS_FEED: {
|
||||
"tap home tab": ScreenType.HOME_FEED,
|
||||
@@ -78,12 +79,16 @@ class ScreenTopology:
|
||||
"open messages": ScreenType.DM_INBOX,
|
||||
"open following list": ScreenType.FOLLOW_LIST,
|
||||
"open followers list": ScreenType.FOLLOW_LIST,
|
||||
"view a post": ScreenType.POST_DETAIL,
|
||||
"open post": ScreenType.POST_DETAIL,
|
||||
"open post author profile": ScreenType.OTHER_PROFILE,
|
||||
"view the user profile": ScreenType.OTHER_PROFILE,
|
||||
"view user profile": ScreenType.OTHER_PROFILE,
|
||||
"open user profile": ScreenType.OTHER_PROFILE,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def find_route(
|
||||
cls, from_screen: ScreenType, to_screen: ScreenType
|
||||
) -> Optional[List[Tuple[str, ScreenType]]]:
|
||||
def find_route(cls, from_screen: ScreenType, to_screen: ScreenType) -> Optional[List[Tuple[str, ScreenType]]]:
|
||||
"""
|
||||
BFS shortest path from from_screen to to_screen.
|
||||
|
||||
@@ -171,9 +176,7 @@ class ScreenTopology:
|
||||
return f"navigate to {screen_name}"
|
||||
|
||||
@classmethod
|
||||
def expected_screen_for_action(
|
||||
cls, action: str, from_screen: ScreenType
|
||||
) -> Optional[ScreenType]:
|
||||
def expected_screen_for_action(cls, action: str, from_screen: ScreenType) -> Optional[ScreenType]:
|
||||
"""What screen should we land on after this action from this screen?
|
||||
|
||||
Used by _execute_action to validate INTERMEDIATE navigation steps.
|
||||
|
||||
@@ -4,18 +4,19 @@ import xml.etree.ElementTree as ET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HoneypotRadome:
|
||||
"""
|
||||
Project Dojo: The Anti-Test Sensor.
|
||||
Filters the Android XML Hierarchy to remove "invisible traps" and honeypots
|
||||
that Instagram uses to detect deterministic bots (e.g., 1x1 pixel buttons,
|
||||
Filters the Android XML Hierarchy to remove "invisible traps" and honeypots
|
||||
that Instagram uses to detect deterministic bots (e.g., 1x1 pixel buttons,
|
||||
off-screen elements with clickable=True).
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, display_width=1080, display_height=2400):
|
||||
self.display_width = display_width
|
||||
self.display_height = display_height
|
||||
self.bounds_pattern = re.compile(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]')
|
||||
self.bounds_pattern = re.compile(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]")
|
||||
|
||||
def sanitize_xml(self, xml_string: str) -> str:
|
||||
"""
|
||||
@@ -23,19 +24,22 @@ class HoneypotRadome:
|
||||
Returns the sanitized XML string.
|
||||
"""
|
||||
try:
|
||||
# Android XML dumps often have multiple root nodes or formatting issues,
|
||||
# Android XML dumps often have multiple root nodes or formatting issues,
|
||||
# let's try reading it safely.
|
||||
# Handle potential encoding issues from dump_hierarchy
|
||||
clean_xml = xml_string.replace(' ', '').replace(' ', '')
|
||||
|
||||
clean_xml = xml_string.replace(" ", "").replace(" ", "")
|
||||
|
||||
root = ET.fromstring(clean_xml)
|
||||
removed_count = self._filter_node(root)
|
||||
|
||||
|
||||
if removed_count > 0:
|
||||
logger.info(f"🛡️ [Honeypot Radome] Stripped {removed_count} phantom nodes from view.", extra={"color": "\x1b[33m"})
|
||||
|
||||
logger.info(
|
||||
f"🛡️ [Honeypot Radome] Stripped {removed_count} phantom nodes from view.",
|
||||
extra={"color": "\x1b[33m"},
|
||||
)
|
||||
|
||||
# Convert back to string
|
||||
return ET.tostring(root, encoding='unicode')
|
||||
return ET.tostring(root, encoding="unicode")
|
||||
except Exception as e:
|
||||
logger.warning(f"🛡️ [Honeypot Radome] XML Parse failed, returning raw. Err: {e}")
|
||||
return xml_string
|
||||
@@ -43,17 +47,17 @@ class HoneypotRadome:
|
||||
def _filter_node(self, node: ET.Element) -> int:
|
||||
removed = 0
|
||||
children_to_remove = []
|
||||
|
||||
|
||||
for child in node:
|
||||
if self._is_honeypot(child):
|
||||
children_to_remove.append(child)
|
||||
removed += 1
|
||||
else:
|
||||
removed += self._filter_node(child)
|
||||
|
||||
|
||||
for child in children_to_remove:
|
||||
node.remove(child)
|
||||
|
||||
|
||||
return removed
|
||||
|
||||
def _is_honeypot(self, node: ET.Element) -> bool:
|
||||
@@ -63,33 +67,33 @@ class HoneypotRadome:
|
||||
bounds = node.get("bounds")
|
||||
if not bounds:
|
||||
return False
|
||||
|
||||
|
||||
match = self.bounds_pattern.match(bounds)
|
||||
if not match:
|
||||
return False
|
||||
|
||||
|
||||
x1, y1, x2, y2 = map(int, match.groups())
|
||||
width = x2 - x1
|
||||
height = y2 - y1
|
||||
|
||||
|
||||
is_clickable = node.get("clickable", "false").lower() == "true"
|
||||
|
||||
|
||||
# Rule 1: The Zero-Point Trap (Element is exactly on 0,0 with no dimensions)
|
||||
if x1 == 0 and y1 == 0 and x2 == 0 and y2 == 0:
|
||||
return True
|
||||
|
||||
|
||||
# Rule 2: The Micro-Pixel Trap (Bot detectors often use 1x1 or 2x2 clickable overlay pixels)
|
||||
if is_clickable and width <= 2 and height <= 2:
|
||||
return True
|
||||
|
||||
|
||||
# Rule 3: The Off-Screen Trap (Buttons rendered wildly out of bounds to bait mindless loops)
|
||||
if x1 >= self.display_width or y1 >= self.display_height:
|
||||
return True
|
||||
|
||||
|
||||
# Rule 4: The Negative Coordinate Trap
|
||||
if x2 <= 0 or y2 <= 0:
|
||||
return True
|
||||
|
||||
|
||||
# Rule 5: The Transparent Interceptor (Giant invisible overlays capturing touches)
|
||||
# If a clickable element takes up >90% of screen but has no text, description, or id, it's a touch trap.
|
||||
has_text = bool(node.get("text", ""))
|
||||
@@ -98,10 +102,10 @@ class HoneypotRadome:
|
||||
if is_clickable and width >= (self.display_width * 0.9) and height >= (self.display_height * 0.9):
|
||||
if not has_text and not has_desc and not has_id:
|
||||
return True
|
||||
|
||||
|
||||
# Rule 6: Android Accessibility Trap (A node is clickable but explicitly not visible)
|
||||
# Sometimes uiautomator injects 'visible-to-user' manually, or it has bounds but isn't enabled.
|
||||
if is_clickable and node.get("visible-to-user", "true").lower() == "false":
|
||||
return True
|
||||
|
||||
|
||||
return False
|
||||
|
||||
@@ -80,64 +80,40 @@ class SessionState:
|
||||
self,
|
||||
):
|
||||
"""set the limits for current session"""
|
||||
self.args.current_likes_limit = get_value(
|
||||
getattr(self.args, "total_likes_limit", 300), None, 300
|
||||
)
|
||||
self.args.current_follow_limit = get_value(
|
||||
getattr(self.args, "total_follows_limit", 50), None, 50
|
||||
)
|
||||
self.args.current_unfollow_limit = get_value(
|
||||
getattr(self.args, "total_unfollows_limit", 50), None, 50
|
||||
)
|
||||
self.args.current_comments_limit = get_value(
|
||||
getattr(self.args, "total_comments_limit", 10), None, 10
|
||||
)
|
||||
self.args.current_likes_limit = get_value(getattr(self.args, "total_likes_limit", 300), None, 300)
|
||||
self.args.current_follow_limit = get_value(getattr(self.args, "total_follows_limit", 50), None, 50)
|
||||
self.args.current_unfollow_limit = get_value(getattr(self.args, "total_unfollows_limit", 50), None, 50)
|
||||
self.args.current_comments_limit = get_value(getattr(self.args, "total_comments_limit", 10), None, 10)
|
||||
self.args.current_pm_limit = get_value(getattr(self.args, "total_pm_limit", 10), None, 10)
|
||||
self.args.current_watch_limit = get_value(
|
||||
getattr(self.args, "total_watches_limit", 50), None, 50
|
||||
)
|
||||
self.args.current_watch_limit = get_value(getattr(self.args, "total_watches_limit", 50), None, 50)
|
||||
self.args.current_success_limit = get_value(
|
||||
getattr(self.args, "total_successful_interactions_limit", 100), None, 100
|
||||
)
|
||||
self.args.current_total_limit = get_value(
|
||||
getattr(self.args, "total_interactions_limit", 1000), None, 1000
|
||||
)
|
||||
self.args.current_scraped_limit = get_value(
|
||||
getattr(self.args, "total_scraped_limit", 200), None, 200
|
||||
)
|
||||
self.args.current_crashes_limit = get_value(
|
||||
getattr(self.args, "total_crashes_limit", 5), None, 5
|
||||
)
|
||||
self.args.current_total_limit = get_value(getattr(self.args, "total_interactions_limit", 1000), None, 1000)
|
||||
self.args.current_scraped_limit = get_value(getattr(self.args, "total_scraped_limit", 200), None, 200)
|
||||
self.args.current_crashes_limit = get_value(getattr(self.args, "total_crashes_limit", 5), None, 5)
|
||||
|
||||
def check_limit(self, limit_type=None, output=False):
|
||||
"""Returns True if limit reached - else False"""
|
||||
limit_type = SessionState.Limit.ALL if limit_type is None else limit_type
|
||||
# check limits
|
||||
total_likes = self.totalLikes >= int(self.args.current_likes_limit)
|
||||
total_followed = sum(self.totalFollowed.values()) >= int(
|
||||
self.args.current_follow_limit
|
||||
)
|
||||
total_followed = sum(self.totalFollowed.values()) >= int(self.args.current_follow_limit)
|
||||
total_unfollowed = self.totalUnfollowed >= int(self.args.current_unfollow_limit)
|
||||
total_comments = self.totalComments >= int(self.args.current_comments_limit)
|
||||
total_pm = self.totalPm >= int(self.args.current_pm_limit)
|
||||
total_watched = self.totalWatched >= int(self.args.current_watch_limit)
|
||||
total_successful = sum(self.successfulInteractions.values()) >= int(
|
||||
self.args.current_success_limit
|
||||
)
|
||||
total_interactions = sum(self.totalInteractions.values()) >= int(
|
||||
self.args.current_total_limit
|
||||
)
|
||||
total_successful = sum(self.successfulInteractions.values()) >= int(self.args.current_success_limit)
|
||||
total_interactions = sum(self.totalInteractions.values()) >= int(self.args.current_total_limit)
|
||||
|
||||
total_scraped = sum(self.totalScraped.values()) >= int(
|
||||
self.args.current_scraped_limit
|
||||
)
|
||||
total_scraped = sum(self.totalScraped.values()) >= int(self.args.current_scraped_limit)
|
||||
|
||||
total_crashes = self.totalCrashes >= int(self.args.current_crashes_limit)
|
||||
|
||||
session_info = [
|
||||
"Checking session limits:",
|
||||
f"- Total Likes:\t\t\t\t{'Limit Reached' if total_likes else 'OK'} ({self.totalLikes}/{self.args.current_likes_limit})",
|
||||
f"- Total Comments:\t\t\t\t{'Limit Reached' if total_comments else 'OK'} ({self.totalComments}/{self.args.current_comments_limit})",
|
||||
f"- Session Likes Given:\t\t{'Limit Reached' if total_likes else 'OK'} ({self.totalLikes}/{self.args.current_likes_limit})",
|
||||
f"- Session Comments Given:\t{'Limit Reached' if total_comments else 'OK'} ({self.totalComments}/{self.args.current_comments_limit})",
|
||||
f"- Total PM:\t\t\t\t\t{'Limit Reached' if total_pm else 'OK'} ({self.totalPm}/{self.args.current_pm_limit})",
|
||||
f"- Total Followed:\t\t\t\t{'Limit Reached' if total_followed else 'OK'} ({sum(self.totalFollowed.values())}/{self.args.current_follow_limit})",
|
||||
f"- Total Unfollowed:\t\t\t\t{'Limit Reached' if total_unfollowed else 'OK'} ({self.totalUnfollowed}/{self.args.current_unfollow_limit})",
|
||||
@@ -154,11 +130,16 @@ class SessionState:
|
||||
logger.info(line)
|
||||
|
||||
return (
|
||||
total_likes and getattr(self.args, "end_if_likes_limit_reached", False)
|
||||
or total_followed and getattr(self.args, "end_if_follows_limit_reached", False)
|
||||
or total_watched and getattr(self.args, "end_if_watches_limit_reached", False)
|
||||
or total_comments and getattr(self.args, "end_if_comments_limit_reached", False)
|
||||
or total_pm and getattr(self.args, "end_if_pm_limit_reached", False),
|
||||
total_likes
|
||||
and getattr(self.args, "end_if_likes_limit_reached", False)
|
||||
or total_followed
|
||||
and getattr(self.args, "end_if_follows_limit_reached", False)
|
||||
or total_watched
|
||||
and getattr(self.args, "end_if_watches_limit_reached", False)
|
||||
or total_comments
|
||||
and getattr(self.args, "end_if_comments_limit_reached", False)
|
||||
or total_pm
|
||||
and getattr(self.args, "end_if_pm_limit_reached", False),
|
||||
total_unfollowed,
|
||||
total_interactions or total_successful or total_scraped,
|
||||
)
|
||||
@@ -247,20 +228,20 @@ class SessionState:
|
||||
delta = timedelta(seconds=delta_sec)
|
||||
if not working_hours:
|
||||
return True, 0
|
||||
|
||||
|
||||
for n in working_hours:
|
||||
today = current_time.strftime("%Y-%m-%d")
|
||||
# 100% Autonomous: Hybrid Time Format Support (Legacy . vs Modern :)
|
||||
h_start = n.split('-')[0].replace(":", ".")
|
||||
h_end = n.split('-')[1].replace(":", ".")
|
||||
|
||||
h_start = n.split("-")[0].replace(":", ".")
|
||||
h_end = n.split("-")[1].replace(":", ".")
|
||||
|
||||
inf_value = f"{h_start} {today}"
|
||||
inf = datetime.strptime(inf_value, "%H.%M %Y-%m-%d") + delta
|
||||
sup_value = f"{h_end} {today}"
|
||||
sup = datetime.strptime(sup_value, "%H.%M %Y-%m-%d") + delta
|
||||
if sup - inf + timedelta(minutes=1) == timedelta(
|
||||
days=1
|
||||
) or sup - inf + timedelta(minutes=1) == timedelta(days=0):
|
||||
if sup - inf + timedelta(minutes=1) == timedelta(days=1) or sup - inf + timedelta(minutes=1) == timedelta(
|
||||
days=0
|
||||
):
|
||||
logger.debug("Whole day mode.")
|
||||
return True, 0
|
||||
if time_in_range(inf.time(), sup.time(), current_time.time()):
|
||||
@@ -300,9 +281,7 @@ class SessionStateEncoder(JSONEncoder):
|
||||
return {
|
||||
"id": session_state.id,
|
||||
"total_interactions": sum(session_state.totalInteractions.values()),
|
||||
"successful_interactions": sum(
|
||||
session_state.successfulInteractions.values()
|
||||
),
|
||||
"successful_interactions": sum(session_state.successfulInteractions.values()),
|
||||
"total_followed": sum(session_state.totalFollowed.values()),
|
||||
"total_likes": session_state.totalLikes,
|
||||
"total_comments": session_state.totalComments,
|
||||
|
||||
@@ -10,13 +10,13 @@ After initial learning, 95%+ of situations are handled from memory
|
||||
alone with ZERO LLM calls. This is "Tesla fleet learning" for bots.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import hashlib
|
||||
import time
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional, Dict, Any
|
||||
from enum import Enum
|
||||
from typing import Dict, Optional
|
||||
|
||||
from GramAddict.core.utils import random_sleep
|
||||
|
||||
@@ -34,6 +34,7 @@ class SituationType(Enum):
|
||||
|
||||
class EscapeAction:
|
||||
"""Represents a planned escape action."""
|
||||
|
||||
def __init__(self, action_type: str, x: int = 0, y: int = 0, reason: str = "", resource_id: str = ""):
|
||||
self.action_type = action_type # 'click', 'back', 'app_start', 'home_then_app'
|
||||
self.x = x
|
||||
@@ -42,11 +43,19 @@ class EscapeAction:
|
||||
self.resource_id = resource_id
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"action_type": self.action_type, "x": self.x, "y": self.y, "reason": self.reason, "resource_id": self.resource_id}
|
||||
return {
|
||||
"action_type": self.action_type,
|
||||
"x": self.x,
|
||||
"y": self.y,
|
||||
"reason": self.reason,
|
||||
"resource_id": self.resource_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "EscapeAction":
|
||||
return cls(d.get("action_type", "back"), d.get("x", 0), d.get("y", 0), d.get("reason", ""), d.get("resource_id", ""))
|
||||
return cls(
|
||||
d.get("action_type", "back"), d.get("x", 0), d.get("y", 0), d.get("reason", ""), d.get("resource_id", "")
|
||||
)
|
||||
|
||||
|
||||
class SituationEpisodeDB:
|
||||
@@ -56,8 +65,10 @@ class SituationEpisodeDB:
|
||||
Enables instant recall for known situations (0 LLM calls).
|
||||
Stores BOTH positive and negative episodes for full learning.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
from GramAddict.core.qdrant_memory import QdrantBase
|
||||
|
||||
self._db = QdrantBase("sae_episodes_v1", vector_size=768)
|
||||
|
||||
def recall(self, situation_signature: str) -> Optional[Dict]:
|
||||
@@ -126,9 +137,31 @@ class SituationEpisodeDB:
|
||||
if not vec:
|
||||
return
|
||||
|
||||
# Unique key: situation + action type + success flag
|
||||
seed = f"{situation_signature}|{action.action_type}|{action.x},{action.y}|{success}"
|
||||
confidence = 0.8 if success else 0.0
|
||||
# Unique key: situation + action type (ignoring success flag for the seed so we update the same entry)
|
||||
seed = f"{situation_signature}|{action.action_type}|{action.x},{action.y}"
|
||||
point_id = self._db.generate_uuid(seed)
|
||||
|
||||
current_conf = 0.0
|
||||
has_existing = False
|
||||
try:
|
||||
points = self._db.client.retrieve(
|
||||
collection_name=self._db.collection_name, ids=[point_id], with_payload=True, with_vectors=False
|
||||
)
|
||||
if points:
|
||||
has_existing = True
|
||||
current_conf = points[0].payload.get("confidence", 0.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if success:
|
||||
confidence = min(1.0, current_conf + 0.5) if has_existing else 0.8
|
||||
else:
|
||||
confidence = current_conf - 0.5 if has_existing else -0.5
|
||||
|
||||
if confidence < 0.1 and not success:
|
||||
self._db.client.delete(collection_name=self._db.collection_name, points_selector=[point_id])
|
||||
logger.info("🗑️ [SAE Learn] Action decayed below threshold. Deleted from memory.")
|
||||
return
|
||||
|
||||
payload = {
|
||||
"situation": situation_signature[:500],
|
||||
@@ -141,8 +174,10 @@ class SituationEpisodeDB:
|
||||
|
||||
outcome = "✅ SUCCESS" if success else "❌ FAILURE"
|
||||
self._db.upsert_point(
|
||||
seed, payload, vector=vec,
|
||||
log_success=f"🧠 [SAE Learn] {outcome}: '{action.reason}' → Stored for future recall"
|
||||
seed,
|
||||
payload,
|
||||
vector=vec,
|
||||
log_success=f"🧠 [SAE Learn] {outcome}: '{action.reason}' → Stored for future recall",
|
||||
)
|
||||
|
||||
def boost(self, situation_signature: str, action: EscapeAction):
|
||||
@@ -193,7 +228,7 @@ class SituationalAwarenessEngine:
|
||||
|
||||
try:
|
||||
# Remove XML declaration
|
||||
clean = re.sub(r'<\?xml.*?\?>', '', xml_dump).strip()
|
||||
clean = re.sub(r"<\?xml.*?\?>", "", xml_dump).strip()
|
||||
root = ET.fromstring(clean)
|
||||
except Exception:
|
||||
# If XML is broken, extract what we can with regex
|
||||
@@ -205,17 +240,17 @@ class SituationalAwarenessEngine:
|
||||
packages = set()
|
||||
elements = []
|
||||
|
||||
for elem in root.iter('node'):
|
||||
for elem in root.iter("node"):
|
||||
a = elem.attrib
|
||||
pkg = a.get('package', '')
|
||||
pkg = a.get("package", "")
|
||||
if pkg:
|
||||
packages.add(pkg)
|
||||
|
||||
rid = a.get('resource-id', '').strip()
|
||||
text = a.get('text', '').strip()
|
||||
desc = a.get('content-desc', '').strip()
|
||||
bounds = a.get('bounds', '')
|
||||
clickable = a.get('clickable', 'false')
|
||||
rid = a.get("resource-id", "").strip()
|
||||
text = a.get("text", "").strip()
|
||||
desc = a.get("content-desc", "").strip()
|
||||
bounds = a.get("bounds", "")
|
||||
clickable = a.get("clickable", "false")
|
||||
|
||||
# Only keep nodes with meaningful content
|
||||
if not rid and not text and not desc:
|
||||
@@ -225,10 +260,14 @@ class SituationalAwarenessEngine:
|
||||
if rid:
|
||||
parts.append(f"id={rid.split('/')[-1]}")
|
||||
if text:
|
||||
parts.append(f"text='{text[:60]}'")
|
||||
if len(text) > 20:
|
||||
text = text[:10] + "..." + text[-10:]
|
||||
parts.append(f"text='{text}'")
|
||||
if desc:
|
||||
parts.append(f"desc='{desc[:60]}'")
|
||||
if clickable == 'true':
|
||||
if len(desc) > 20:
|
||||
desc = desc[:10] + "..." + desc[-10:]
|
||||
parts.append(f"desc='{desc}'")
|
||||
if clickable == "true":
|
||||
parts.append("CLICKABLE")
|
||||
if bounds:
|
||||
parts.append(f"bounds={bounds}")
|
||||
@@ -236,14 +275,14 @@ class SituationalAwarenessEngine:
|
||||
elements.append(" | ".join(parts))
|
||||
|
||||
sig = f"PACKAGES: {', '.join(sorted(packages))}\n"
|
||||
sig += "\n".join(elements[:50]) # Cap at 50 elements
|
||||
sig += "\n".join(elements[-50:]) # Keep the last 50 elements (highest Z-index/foreground)
|
||||
return sig[:3000]
|
||||
|
||||
def _compute_situation_hash(self, compressed: str) -> str:
|
||||
"""Deterministic hash for situation dedup."""
|
||||
# Remove volatile parts (timestamps, counters) but keep structural identity
|
||||
stable = re.sub(r'\d{2}:\d{2}', 'HH:MM', compressed)
|
||||
stable = re.sub(r'Battery \d+ per cent', 'Battery NN per cent', stable)
|
||||
stable = re.sub(r"\d{2}:\d{2}", "HH:MM", compressed)
|
||||
stable = re.sub(r"Battery \d+ per cent", "Battery NN per cent", stable)
|
||||
return hashlib.sha256(stable.encode()).hexdigest()[:32]
|
||||
|
||||
def perceive(self, xml_dump: str) -> SituationType:
|
||||
@@ -254,15 +293,20 @@ class SituationalAwarenessEngine:
|
||||
if not xml_dump or not isinstance(xml_dump, str):
|
||||
return SituationType.OBSTACLE_FOREIGN_APP
|
||||
|
||||
xml_lower = xml_dump.lower()
|
||||
xml_dump.lower()
|
||||
|
||||
blocked_markers = [
|
||||
"try again later", "action blocked", "restrict certain activity",
|
||||
"help us confirm you own", "confirm it's you",
|
||||
"später erneut versuchen", "bestätige, dass du es bist",
|
||||
"handlung blockiert", "eingeschränkt",
|
||||
"try again later",
|
||||
"action blocked",
|
||||
"restrict certain activity",
|
||||
"help us confirm you own",
|
||||
"confirm it's you",
|
||||
"später erneut versuchen",
|
||||
"bestätige, dass du es bist",
|
||||
"handlung blockiert",
|
||||
"eingeschränkt",
|
||||
]
|
||||
|
||||
|
||||
# Guard: Check if the text matches are relatively isolated (e.g. short strings).
|
||||
# If the string is buried inside a 200-character caption, it's a false positive.
|
||||
# We can regex match text="..." attributes that are less than 60 characters total,
|
||||
@@ -274,18 +318,18 @@ class SituationalAwarenessEngine:
|
||||
return SituationType.DANGER_ACTION_BLOCKED
|
||||
|
||||
# ── Hardware Guard: Screen Off / Locked ──
|
||||
if not getattr(self.device.deviceV2, 'info', {}).get("screenOn", True):
|
||||
if not getattr(self.device.deviceV2, "info", {}).get("screenOn", True):
|
||||
logger.info("📱 [SAE Perceive] Screen is physically OFF.")
|
||||
return SituationType.OBSTACLE_LOCKED_SCREEN
|
||||
|
||||
# ── System Dialog / Permission Detect (Fast Path) ──
|
||||
packages = set(re.findall(r'package=["\']([^"\']+)["\']', xml_dump))
|
||||
app_id = getattr(self.device, 'app_id', 'com.instagram.android')
|
||||
app_id = getattr(self.device, "app_id", "com.instagram.android")
|
||||
|
||||
system_dialog_pkgs = {
|
||||
'com.google.android.permissioncontroller',
|
||||
'com.android.permissioncontroller',
|
||||
'com.samsung.android.permissioncontroller'
|
||||
"com.google.android.permissioncontroller",
|
||||
"com.android.permissioncontroller",
|
||||
"com.samsung.android.permissioncontroller",
|
||||
}
|
||||
if any(pkg in system_dialog_pkgs for pkg in packages):
|
||||
logger.info("📱 [SAE Perceive] System permission dialog explicitly detected.")
|
||||
@@ -294,7 +338,7 @@ class SituationalAwarenessEngine:
|
||||
# ── Foreign Environment Detection (package-based) ──
|
||||
# If the main app package is completely absent from the UI hierarchy,
|
||||
# OR if there's a dominant foreign package and no app package, we might have lost the app.
|
||||
|
||||
|
||||
# If our app is on screen, we trust we are in the app (even if a custom keyboard is open).
|
||||
# We only trigger foreign app classification if our app is completely missing from the screen.
|
||||
is_foreign = False
|
||||
@@ -305,11 +349,11 @@ class SituationalAwarenessEngine:
|
||||
# We explicitly ask the TelepathicEngine to classify this to avoid writing brittle substring hacks
|
||||
# for Android System UI variations across different device manufacturers.
|
||||
try:
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
from GramAddict.core.config import Config
|
||||
|
||||
screen_off = not getattr(self.device.deviceV2, 'info', {}).get("screenOn", True)
|
||||
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
|
||||
screen_off = not getattr(self.device.deviceV2, "info", {}).get("screenOn", True)
|
||||
|
||||
prompt = (
|
||||
"You are a Situation Classifier for a mobile automation agent.\n"
|
||||
"Analyze the given Android UI XML dump. Is this a physical DEVICE_LOCK_SCREEN, "
|
||||
@@ -319,18 +363,27 @@ class SituationalAwarenessEngine:
|
||||
"{\"situation\": \"OBSTACLE_LOCKED_SCREEN\" | \"OBSTACLE_SYSTEM\" | \"OBSTACLE_FOREIGN_APP\"}\n\n"
|
||||
f"XML:\n{self._compress_xml(xml_dump)[:2500]}"
|
||||
)
|
||||
|
||||
|
||||
args = {}
|
||||
try: args = Config().args
|
||||
except Exception: pass
|
||||
try:
|
||||
args = Config().args
|
||||
except Exception:
|
||||
pass
|
||||
model = getattr(args, "ai_telepathic_model", "qwen3.5:latest")
|
||||
url = getattr(args, "ai_telepathic_url", "http://localhost:11434/api/generate")
|
||||
|
||||
res = query_telepathic_llm(model=model, url=url, system_prompt="Strict JSON classifier.", user_prompt=prompt, use_local_edge=True)
|
||||
|
||||
res = query_telepathic_llm(
|
||||
model=model,
|
||||
url=url,
|
||||
system_prompt="Strict JSON classifier.",
|
||||
user_prompt=prompt,
|
||||
use_local_edge=True,
|
||||
)
|
||||
import json
|
||||
|
||||
data = json.loads(res)
|
||||
situ_str = data.get("situation", "")
|
||||
|
||||
|
||||
if situ_str == "OBSTACLE_LOCKED_SCREEN":
|
||||
logger.info("🧠 [Smart Perceive] SystemUI definitively classified as: LOCKED_SCREEN.")
|
||||
return SituationType.OBSTACLE_LOCKED_SCREEN
|
||||
@@ -348,8 +401,9 @@ class SituationalAwarenessEngine:
|
||||
# We explicitly query ScreenMemoryDB. If unknown, we ask the LLM.
|
||||
# This replaces ALL brittle string/ID matching for modals.
|
||||
from GramAddict.core.qdrant_memory import ScreenMemoryDB
|
||||
|
||||
screen_memory = ScreenMemoryDB()
|
||||
|
||||
|
||||
compressed = self._compress_xml(xml_dump)
|
||||
|
||||
# ── Structural Fast-Check: Content-Creation Overlays ──
|
||||
@@ -358,21 +412,21 @@ class SituationalAwarenessEngine:
|
||||
# and frequently fool the LLM into thinking they are "normal" browsing.
|
||||
# Detecting them structurally is O(1) and requires ZERO LLM calls.
|
||||
creation_flow_markers = (
|
||||
'quick_capture', # Camera / story capture overlay
|
||||
'gallery_cancel_button', # Story gallery "Back to Home" button
|
||||
'creation_flow', # Post creation wizard
|
||||
'reel_camera', # Reel recording interface
|
||||
"quick_capture", # Camera / story capture overlay
|
||||
"gallery_cancel_button", # Story gallery "Back to Home" button
|
||||
"creation_flow", # Post creation wizard
|
||||
"reel_camera", # Reel recording interface
|
||||
)
|
||||
|
||||
# Guard: Check against compressed string to ensure these markers ONLY appear
|
||||
# as resource IDs (e.g. "id=quick_capture_...") and not as plain text in
|
||||
|
||||
# Guard: Check against compressed string to ensure these markers ONLY appear
|
||||
# as resource IDs (e.g. "id=quick_capture_...") and not as plain text in
|
||||
# user comments/bios (which would look like "text='... creation_flow ...'")
|
||||
if any(re.search(rf'id=[^\s|]*{marker}', compressed, re.IGNORECASE) for marker in creation_flow_markers):
|
||||
if any(re.search(rf"id=[^\s|]*{marker}", compressed, re.IGNORECASE) for marker in creation_flow_markers):
|
||||
logger.info("🧠 [SAE Perceive] Content-creation overlay detected structurally → OBSTACLE_MODAL")
|
||||
screen_memory.store_screen(compressed, "OBSTACLE_MODAL")
|
||||
return SituationType.OBSTACLE_MODAL
|
||||
cached_type = screen_memory.get_screen_type(compressed)
|
||||
|
||||
|
||||
if cached_type:
|
||||
if cached_type == "OBSTACLE_MODAL":
|
||||
return SituationType.OBSTACLE_MODAL
|
||||
@@ -381,9 +435,9 @@ class SituationalAwarenessEngine:
|
||||
|
||||
# If not cached, query LLM for autonomous structural classification
|
||||
try:
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
from GramAddict.core.config import Config
|
||||
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
|
||||
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 "
|
||||
@@ -394,21 +448,26 @@ class SituationalAwarenessEngine:
|
||||
"or ANY content-creation flow (reel recording, post editor, live mode) is an OBSTACLE_MODAL — "
|
||||
"it blocks normal navigation and must be dismissed.\n"
|
||||
"Respond ONLY with a valid JSON object strictly matching this schema: "
|
||||
"{\"situation\": \"OBSTACLE_MODAL\" | \"NORMAL\"}\n\n"
|
||||
'{"situation": "OBSTACLE_MODAL" | "NORMAL"}\n\n'
|
||||
f"XML:\n{compressed[:2500]}"
|
||||
)
|
||||
|
||||
|
||||
args = {}
|
||||
try: args = Config().args
|
||||
except Exception: pass
|
||||
try:
|
||||
args = Config().args
|
||||
except Exception:
|
||||
pass
|
||||
model = getattr(args, "ai_telepathic_model", "qwen3.5:latest")
|
||||
url = getattr(args, "ai_telepathic_url", "http://localhost:11434/api/generate")
|
||||
|
||||
res = query_telepathic_llm(model=model, url=url, system_prompt="Strict JSON classifier.", user_prompt=prompt, use_local_edge=True)
|
||||
|
||||
res = query_telepathic_llm(
|
||||
model=model, url=url, system_prompt="Strict JSON classifier.", user_prompt=prompt, use_local_edge=True
|
||||
)
|
||||
import json
|
||||
|
||||
data = json.loads(res)
|
||||
situ_str = data.get("situation", "NORMAL")
|
||||
|
||||
|
||||
if situ_str == "OBSTACLE_MODAL":
|
||||
logger.info("🧠 [Smart Perceive] Screen classified as: OBSTACLE_MODAL.")
|
||||
screen_memory.store_screen(compressed, "OBSTACLE_MODAL")
|
||||
@@ -422,19 +481,28 @@ class SituationalAwarenessEngine:
|
||||
|
||||
return SituationType.NORMAL
|
||||
|
||||
def unlearn_current_state(self, xml_dump: str):
|
||||
"""Purges the current screen's signature from Qdrant to self-heal from hallucinations."""
|
||||
compressed = self._compress_xml(xml_dump)
|
||||
from GramAddict.core.qdrant_memory import ScreenMemoryDB
|
||||
|
||||
screen_memory = ScreenMemoryDB()
|
||||
screen_memory.purge_screen(compressed)
|
||||
logger.info("🗑️ [Smart Perceive] Purged cached screen signature to force autonomous re-evaluation.")
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 2. PLAN: AI-driven escape strategy
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
def _plan_escape_via_llm(self, xml_dump: str, compressed: str, situation_type: SituationType, failed_actions: set = None) -> Optional[EscapeAction]:
|
||||
def _plan_escape_via_llm(
|
||||
self, xml_dump: str, compressed: str, situation_type: SituationType, failed_actions: set = None
|
||||
) -> Optional[EscapeAction]:
|
||||
"""
|
||||
LLM-powered escape planning for situations where structural scan fails.
|
||||
Called ONLY when recall AND structural planning both miss.
|
||||
"""
|
||||
from GramAddict.core.llm_provider import query_llm
|
||||
from GramAddict.core.config import Config
|
||||
from GramAddict.core.llm_provider import query_llm
|
||||
|
||||
try:
|
||||
args = Config().args
|
||||
@@ -455,29 +523,35 @@ class SituationalAwarenessEngine:
|
||||
"- 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"
|
||||
"- Return ONLY valid JSON: {\"action\": \"click\"|\"back\"|\"app_start\"|\"unlock\"|\"kill_foreign_apps\"|\"false_positive\", \"x\": N, \"y\": N, \"reason\": \"...\"}"
|
||||
'- Return ONLY valid JSON: {"action": "click"|"back"|"app_start"|"unlock"|"kill_foreign_apps"|"false_positive", "x": N, "y": N, "reason": "..."}'
|
||||
)
|
||||
|
||||
user_prompt = (
|
||||
f"Situation type: {situation_type.value}\n\n"
|
||||
f"Screen content:\n{compressed}\n\n"
|
||||
)
|
||||
user_prompt = f"Situation type: {situation_type.value}\n\n" f"Screen content:\n{compressed}\n\n"
|
||||
if failed_actions:
|
||||
user_prompt += f"Failed actions this session (DO NOT REPEAT): {list(failed_actions)}\n\n"
|
||||
|
||||
|
||||
user_prompt += "What action should I take to clear this obstacle and return to Instagram? Return JSON only."
|
||||
|
||||
try:
|
||||
resp = query_llm(url=url, model=model, prompt=user_prompt, system=system_prompt,
|
||||
format_json=True, timeout=30, max_tokens=300, temperature=0.0)
|
||||
resp = query_llm(
|
||||
url=url,
|
||||
model=model,
|
||||
prompt=user_prompt,
|
||||
system=system_prompt,
|
||||
format_json=True,
|
||||
timeout=30,
|
||||
max_tokens=300,
|
||||
temperature=0.0,
|
||||
)
|
||||
if resp and "response" in resp:
|
||||
import json
|
||||
|
||||
data = json.loads(resp["response"])
|
||||
return EscapeAction(
|
||||
action_type=data.get("action", "back"),
|
||||
x=int(data.get("x", 0)),
|
||||
y=int(data.get("y", 0)),
|
||||
reason=data.get("reason", "LLM-planned escape")
|
||||
reason=data.get("reason", "LLM-planned escape"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"🧠 [SAE] LLM escape planning failed: {e}")
|
||||
@@ -504,24 +578,24 @@ class SituationalAwarenessEngine:
|
||||
logger.info(f"🔓 [SAE Act] Unlocking device: {action.reason}")
|
||||
self.device.unlock()
|
||||
random_sleep(1.0, 2.0)
|
||||
app_id = getattr(self.device, 'app_id', 'com.instagram.android')
|
||||
app_id = getattr(self.device, "app_id", "com.instagram.android")
|
||||
self.device.app_start(app_id, use_monkey=True)
|
||||
random_sleep(1.5, 2.5)
|
||||
|
||||
elif action.action_type == "app_start":
|
||||
logger.info(f"🚀 [SAE Act] Force-starting app: {action.reason}")
|
||||
app_id = getattr(self.device, 'app_id', 'com.instagram.android')
|
||||
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)
|
||||
|
||||
elif action.action_type == "kill_foreign_apps":
|
||||
logger.info(f"🔪 [SAE Act] Killing foreign apps: {action.reason}")
|
||||
# The reason string will contain the package name or 'all'
|
||||
app_id = getattr(self.device, 'app_id', 'com.instagram.android')
|
||||
app_id = getattr(self.device, "app_id", "com.instagram.android")
|
||||
try:
|
||||
# We can dump current package again, or just get it from device
|
||||
current_pkg = self.device.deviceV2.app_current().get("package")
|
||||
if current_pkg and current_pkg != app_id and current_pkg not in ('com.android.systemui', 'android'):
|
||||
if current_pkg and current_pkg != app_id and current_pkg not in ("com.android.systemui", "android"):
|
||||
logger.info(f"🔪 Stopping {current_pkg}")
|
||||
self.device.app_stop(current_pkg)
|
||||
random_sleep(1.0, 2.0)
|
||||
@@ -535,7 +609,7 @@ class SituationalAwarenessEngine:
|
||||
logger.info(f"🏠 [SAE Act] HOME → App Start: {action.reason}")
|
||||
self.device.press("home")
|
||||
random_sleep(0.5, 1.0)
|
||||
app_id = getattr(self.device, 'app_id', 'com.instagram.android')
|
||||
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)
|
||||
|
||||
@@ -550,9 +624,9 @@ class SituationalAwarenessEngine:
|
||||
Returns True if an obstacle was successfully cleared, False if already clear or failed.
|
||||
"""
|
||||
from GramAddict.core.exceptions import ActionBlockedError
|
||||
|
||||
failed_this_session = set()
|
||||
cleared_something = False
|
||||
|
||||
|
||||
last_situation = None
|
||||
situation_attempts = 0
|
||||
|
||||
@@ -562,9 +636,9 @@ class SituationalAwarenessEngine:
|
||||
xml_dump = initial_xml
|
||||
else:
|
||||
xml_dump = self.device.dump_hierarchy()
|
||||
|
||||
|
||||
situation = self.perceive(xml_dump)
|
||||
|
||||
|
||||
if last_situation != situation:
|
||||
situation_attempts = 0
|
||||
last_situation = situation
|
||||
@@ -582,13 +656,11 @@ class SituationalAwarenessEngine:
|
||||
logger.error("🚫 [SAE CRITICAL] Instagram Action Block detected! Halting to protect account.")
|
||||
raise ActionBlockedError("Instagram action block detected by SAE.")
|
||||
|
||||
logger.warning(
|
||||
f"🔍 [SAE] Obstacle detected: {situation.value} (attempt {attempt + 1}/{max_attempts})"
|
||||
)
|
||||
logger.warning(f"🔍 [SAE] Obstacle detected: {situation.value} (attempt {attempt + 1}/{max_attempts})")
|
||||
|
||||
# ── COMPRESS for memory lookup ──
|
||||
compressed = self._compress_xml(xml_dump)
|
||||
|
||||
|
||||
# ── RECALL from memory ──
|
||||
recalled = self.episodes.recall(compressed)
|
||||
if recalled:
|
||||
@@ -597,7 +669,7 @@ class SituationalAwarenessEngine:
|
||||
action = EscapeAction.from_dict(recalled)
|
||||
logger.info(f"🧠 [SAE] Using recalled strategy: {action.reason}")
|
||||
else:
|
||||
logger.info(f"🧠 [SAE] Recalled strategy already failed this session. Using LLM planning.")
|
||||
logger.info("🧠 [SAE] Recalled strategy already failed this session. Using LLM planning.")
|
||||
recalled = None
|
||||
|
||||
if not recalled:
|
||||
@@ -605,26 +677,34 @@ class SituationalAwarenessEngine:
|
||||
logger.info("🧠 [SAE] Autonomous Blank Start: Escalating to LLM-assisted escape planning...")
|
||||
action = self._plan_escape_via_llm(xml_dump, compressed, situation, failed_this_session)
|
||||
elif situation_attempts == 3:
|
||||
action = EscapeAction("app_start", reason=f"Escalation level 4: force app restart after {situation_attempts} failed attempts on this situation")
|
||||
action = EscapeAction(
|
||||
"app_start",
|
||||
reason=f"Escalation level 4: force app restart after {situation_attempts} failed attempts on this situation",
|
||||
)
|
||||
else:
|
||||
action = EscapeAction("home_then_app", reason=f"Nuclear escalation: HOME + app_start after {situation_attempts} failed attempts on this situation")
|
||||
action = EscapeAction(
|
||||
"home_then_app",
|
||||
reason=f"Nuclear escalation: HOME + app_start after {situation_attempts} failed attempts on this situation",
|
||||
)
|
||||
|
||||
# ── EXECUTE ──
|
||||
if action.action_type == "false_positive":
|
||||
logger.warning(f"🧠 [SAE Unlearn] LLM identified false positive obstacle. Overwriting Qdrant memory to NORMAL.")
|
||||
logger.warning(
|
||||
"🧠 [SAE Unlearn] LLM identified false positive obstacle. Overwriting Qdrant memory to NORMAL."
|
||||
)
|
||||
from GramAddict.core.qdrant_memory import ScreenMemoryDB
|
||||
|
||||
ScreenMemoryDB().store_screen(compressed, "NORMAL")
|
||||
self._consecutive_failures = 0
|
||||
return True
|
||||
else:
|
||||
self._execute_escape(action)
|
||||
cleared_something = True
|
||||
|
||||
# ── VERIFY ──
|
||||
post_xml = self.device.dump_hierarchy()
|
||||
post_situation = self.perceive(post_xml)
|
||||
reached_normal = (post_situation == SituationType.NORMAL)
|
||||
situation_changed = (post_situation != situation)
|
||||
reached_normal = post_situation == SituationType.NORMAL
|
||||
situation_changed = post_situation != situation
|
||||
|
||||
if reached_normal:
|
||||
# ── LEARN FULL SUCCESS ──
|
||||
@@ -635,7 +715,9 @@ class SituationalAwarenessEngine:
|
||||
elif situation_changed:
|
||||
# ── LEARN PARTIAL SUCCESS ──
|
||||
self.episodes.learn(compressed, action, True)
|
||||
logger.info(f"🔄 [SAE] Situation changed from {situation.value} to {post_situation.value}. Continuing recovery...")
|
||||
logger.info(
|
||||
f"🔄 [SAE] Situation changed from {situation.value} to {post_situation.value}. Continuing recovery..."
|
||||
)
|
||||
# We do not increment consecutive_failures or situation_attempts because we made progress
|
||||
# The next loop iteration will clear failed_this_session since last_situation != situation
|
||||
else:
|
||||
|
||||
@@ -4,6 +4,7 @@ from time import sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ghost_type(device, text: str):
|
||||
"""
|
||||
Tesla Stealth Ghost Keyboard.
|
||||
@@ -12,59 +13,60 @@ def ghost_type(device, text: str):
|
||||
"""
|
||||
if not text:
|
||||
return
|
||||
|
||||
|
||||
logger.info(f"⌨️ [Ghost Keyboard] Initiating stealth injection ({len(text)} chars)...")
|
||||
|
||||
|
||||
# We slice text into variable-sized human bursts
|
||||
chunks = []
|
||||
i = 0
|
||||
while i < len(text):
|
||||
if random.random() < 0.15:
|
||||
chunk_size = 1 # single letter hunting
|
||||
chunk_size = 1 # single letter hunting
|
||||
else:
|
||||
chunk_size = random.randint(2, 6) # fluid typing bursts
|
||||
|
||||
chunks.append(text[i:i+chunk_size])
|
||||
chunk_size = random.randint(2, 6) # fluid typing bursts
|
||||
|
||||
chunks.append(text[i : i + chunk_size])
|
||||
i += chunk_size
|
||||
|
||||
for idx, chunk in enumerate(chunks):
|
||||
# 5% chance of a typo if it's an alphabetical chunk
|
||||
if random.random() < 0.05 and len(chunk) >= 2 and chunk[-1].isalpha():
|
||||
typo_letter = random.choice('abcdefghijklmnopqrstuvwxyz')
|
||||
typo_letter = random.choice("abcdefghijklmnopqrstuvwxyz")
|
||||
# Add typo instead of actual last letter
|
||||
typo_chunk = chunk[:-1] + typo_letter
|
||||
_adb_inject_text(device, typo_chunk)
|
||||
|
||||
|
||||
# Realize mistake
|
||||
sleep(random.uniform(0.2, 0.45))
|
||||
|
||||
|
||||
# Send Backspace (KEYCODE_DEL = 67)
|
||||
device.shell("input keyevent 67")
|
||||
sleep(random.uniform(0.1, 0.25))
|
||||
|
||||
|
||||
# Inject the correct character
|
||||
_adb_inject_text(device, chunk[-1])
|
||||
else:
|
||||
_adb_inject_text(device, chunk)
|
||||
|
||||
|
||||
# Realistic pause between semantic bursts (humans think while typing)
|
||||
if chunk.endswith((" ", ".", ",", "!", "?")):
|
||||
sleep(random.uniform(0.2, 0.5))
|
||||
else:
|
||||
sleep(random.uniform(0.05, 0.18))
|
||||
|
||||
|
||||
logger.debug("⌨️ [Ghost Keyboard] Injection complete.")
|
||||
|
||||
|
||||
|
||||
def _adb_inject_text(device, text: str):
|
||||
if not text:
|
||||
return
|
||||
|
||||
|
||||
# For Android `input text`, spaces must be mapped to %s
|
||||
# Single quotes need to be bash escaped since we wrap the string in ''
|
||||
# Special characters like & | > < \ ( ) { } ! must be carefully handled.
|
||||
# The safest way is to let shell loop over characters or strictly replace.
|
||||
safe_text = text.replace(" ", "%s").replace("'", "\\'")
|
||||
|
||||
|
||||
# Send through Android's native InputManager
|
||||
try:
|
||||
device.shell(["input", "text", safe_text])
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import logging
|
||||
import os
|
||||
import hashlib
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from colorama import Fore
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from GramAddict.core.qdrant_memory import QdrantBase
|
||||
from qdrant_client.models import PointStruct, Filter, FieldCondition, MatchValue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -13,18 +13,18 @@ logger = logging.getLogger(__name__)
|
||||
class SwarmProtocol(QdrantBase):
|
||||
"""
|
||||
Decentralized Markov state-channel for P2P knowledge sharing.
|
||||
|
||||
|
||||
Manages 'Pheromones' (successful UI transitions and interactions)
|
||||
and 'BannedPaths' (failed attempts) across bot sessions.
|
||||
|
||||
This creates a Fleet Learning effect: every session learns from
|
||||
|
||||
This creates a Fleet Learning effect: every session learns from
|
||||
every previous session's successes and failures.
|
||||
"""
|
||||
|
||||
def __init__(self, username: str):
|
||||
self.username = username
|
||||
super().__init__(collection_name="gramaddict_swarm_pheromones", vector_size=4)
|
||||
|
||||
|
||||
def emit_pheromone(self, path_hash: str, outcome: str):
|
||||
"""
|
||||
Broadcasting a successful UI transition or interaction to the fleet memory.
|
||||
@@ -32,7 +32,7 @@ class SwarmProtocol(QdrantBase):
|
||||
"""
|
||||
if not self.is_connected or not self.client:
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
self.upsert_point(
|
||||
seed_string=f"{path_hash}_{outcome}",
|
||||
@@ -44,12 +44,11 @@ class SwarmProtocol(QdrantBase):
|
||||
"timestamp": time.time(),
|
||||
"count": 1,
|
||||
},
|
||||
log_success=f"🌐 [Swarm] ⚡ Pheromone emitted: {path_hash[:16]} → {outcome}"
|
||||
log_success=f"🌐 [Swarm] ⚡ Pheromone emitted: {path_hash[:16]} → {outcome}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Swarm] Pheromone emit failed: {e}")
|
||||
|
||||
|
||||
def query_consensus(self, path_hash: str) -> Optional[str]:
|
||||
"""
|
||||
Queries the swarm for historical outcomes of a specific path.
|
||||
@@ -57,32 +56,22 @@ class SwarmProtocol(QdrantBase):
|
||||
"""
|
||||
if not self.is_connected or not self.client:
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
points, _ = self.client.scroll(
|
||||
collection_name=self.collection_name,
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(
|
||||
key="path_hash",
|
||||
match=MatchValue(value=path_hash)
|
||||
)
|
||||
]
|
||||
),
|
||||
scroll_filter=Filter(must=[FieldCondition(key="path_hash", match=MatchValue(value=path_hash))]),
|
||||
limit=1,
|
||||
with_payload=True,
|
||||
)
|
||||
|
||||
|
||||
if points:
|
||||
outcome = points[0].payload.get("outcome")
|
||||
logger.info(
|
||||
f"🌐 [Swarm] Consensus for {path_hash[:16]}: {outcome}",
|
||||
extra={"color": f"{Fore.CYAN}"}
|
||||
)
|
||||
logger.info(f"🌐 [Swarm] Consensus for {path_hash[:16]}: {outcome}", extra={"color": f"{Fore.CYAN}"})
|
||||
return outcome
|
||||
except Exception as e:
|
||||
logger.debug(f"[Swarm] Consensus query failed: {e}")
|
||||
|
||||
|
||||
return None
|
||||
|
||||
def sync_banned_paths(self, banned_paths_db):
|
||||
@@ -92,22 +81,15 @@ class SwarmProtocol(QdrantBase):
|
||||
"""
|
||||
if not self.is_connected or not self.client:
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
points, _ = self.client.scroll(
|
||||
collection_name=self.collection_name,
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(
|
||||
key="outcome",
|
||||
match=MatchValue(value="banned")
|
||||
)
|
||||
]
|
||||
),
|
||||
scroll_filter=Filter(must=[FieldCondition(key="outcome", match=MatchValue(value="banned"))]),
|
||||
limit=100,
|
||||
with_payload=True,
|
||||
)
|
||||
|
||||
|
||||
synced = 0
|
||||
for pt in points:
|
||||
payload = pt.payload or {}
|
||||
@@ -115,11 +97,10 @@ class SwarmProtocol(QdrantBase):
|
||||
if path and banned_paths_db:
|
||||
banned_paths_db.ban(path, "swarm_synced", reason="Synced from fleet memory")
|
||||
synced += 1
|
||||
|
||||
|
||||
if synced > 0:
|
||||
logger.info(
|
||||
f"🌐 [Swarm] Synced {synced} banned paths from fleet memory.",
|
||||
extra={"color": f"{Fore.CYAN}"}
|
||||
f"🌐 [Swarm] Synced {synced} banned paths from fleet memory.", extra={"color": f"{Fore.CYAN}"}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Swarm] Banned path sync failed: {e}")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,13 @@
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
|
||||
from colorama import Fore, Style
|
||||
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _humanized_scroll_down(device):
|
||||
# Same as bot_flow._humanized_scroll but strictly downward
|
||||
info = device.get_info()
|
||||
@@ -16,29 +18,36 @@ def _humanized_scroll_down(device):
|
||||
duration = random.uniform(0.08, 0.12)
|
||||
device.swipe(start_x, start_y, start_x, end_y, duration)
|
||||
from GramAddict.core.utils import random_sleep
|
||||
|
||||
random_sleep(0.8, 1.5)
|
||||
|
||||
def _run_zero_latency_unfollow_loop(device, zero_engine, nav_graph, configs, session_state, current_target, cognitive_stack):
|
||||
|
||||
def _run_zero_latency_unfollow_loop(
|
||||
device, zero_engine, nav_graph, configs, session_state, current_target, cognitive_stack
|
||||
):
|
||||
"""
|
||||
Executes the autonomous Unfollow logic in the Zero-Latency architecture.
|
||||
Assumes the bot is already at the "FollowingList" UI state.
|
||||
"""
|
||||
logger.info(f"🧠 [Unfollow Engine] Initiating cleanup routine in {current_target}...", extra={"color": f"{Style.BRIGHT}{Fore.CYAN}"})
|
||||
|
||||
logger.info(
|
||||
f"🧠 [Unfollow Engine] Initiating cleanup routine in {current_target}...",
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.CYAN}"},
|
||||
)
|
||||
|
||||
telepathic = cognitive_stack.get("telepathic")
|
||||
dopamine = cognitive_stack.get("dopamine")
|
||||
|
||||
|
||||
unfollow_limit = int(getattr(configs.args, "total_unfollows_limit", 50))
|
||||
failed_scrolls = 0
|
||||
total_unfollowed_this_session = 0
|
||||
|
||||
from GramAddict.core.bot_flow import dump_ui_state, _humanized_click
|
||||
|
||||
from GramAddict.core.bot_flow import _humanized_click
|
||||
from GramAddict.core.utils import random_sleep
|
||||
|
||||
|
||||
# Initialize basic tuple if it's missing (helps with tests and initializations)
|
||||
if not hasattr(session_state, 'totalUnfollowed'):
|
||||
if not hasattr(session_state, "totalUnfollowed"):
|
||||
session_state.totalUnfollowed = 0
|
||||
|
||||
|
||||
while not dopamine.is_app_session_over():
|
||||
# Check global limit tuple logic
|
||||
limit_val = session_state.check_limit(SessionState.Limit.UNFOLLOWS)
|
||||
@@ -48,90 +57,104 @@ def _run_zero_latency_unfollow_loop(device, zero_engine, nav_graph, configs, ses
|
||||
elif limit_val is True:
|
||||
logger.info("🛑 Unfollow limit reached for session. Yielding control.")
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
|
||||
|
||||
if total_unfollowed_this_session >= unfollow_limit:
|
||||
logger.info("🛑 Configured unfollow limit reached. Yielding control.")
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
|
||||
logger.info("🛑 Configured unfollow limit reached. Yielding control.")
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
|
||||
try:
|
||||
xml_dump = device.dump_hierarchy()
|
||||
|
||||
|
||||
# Smart Unfollow Phase 1: Find user rows instead of just clicking "Following"
|
||||
nodes = telepathic._extract_semantic_nodes(xml_dump, "find user profile rows in list", threshold=0.7)
|
||||
|
||||
|
||||
action_taken = False
|
||||
for node in nodes:
|
||||
if node.get("skip") or not node.get("bounds"):
|
||||
continue
|
||||
|
||||
|
||||
# 1. Tap the profile row to navigate to their page
|
||||
_humanized_click(device, node["x"], node["y"])
|
||||
action_taken = True
|
||||
logger.debug(f"👆 Tapped profile row at ({node['x']}, {node['y']})")
|
||||
|
||||
|
||||
# Wait for profile to load
|
||||
random_sleep(1.5, 2.5)
|
||||
profile_xml = device.dump_hierarchy()
|
||||
|
||||
|
||||
# 2. Close Friend Guard
|
||||
profile_text = profile_xml.lower()
|
||||
if "enge freunde" in profile_text or "close friend" in profile_text:
|
||||
logger.info("💚 [Anti-Friend] Profile is a Close Friend. Skipping unfollow.", extra={"color": Fore.GREEN})
|
||||
logger.info(
|
||||
"💚 [Anti-Friend] Profile is a Close Friend. Skipping unfollow.", extra={"color": Fore.GREEN}
|
||||
)
|
||||
device.back()
|
||||
random_sleep(0.8, 1.5)
|
||||
break # Go next in loop
|
||||
|
||||
break # Go next in loop
|
||||
|
||||
# 3. Resonance Evaluation
|
||||
resonance = cognitive_stack.get("resonance")
|
||||
res_score = 0.5
|
||||
if resonance:
|
||||
# Parse the description from the XML (rough pass, ResonanceEngine handles noise)
|
||||
res_score = resonance.calculate_resonance({"description": profile_xml})
|
||||
|
||||
|
||||
# 4. Decision: If < 0.4, Unfollow. Else Keep.
|
||||
if res_score < 0.4:
|
||||
logger.info(f"🗑️ [Smart Cleanup] Resonance is low ({res_score:.2f}). Unfollowing.", extra={"color": Fore.YELLOW})
|
||||
|
||||
logger.info(
|
||||
f"🗑️ [Smart Cleanup] Resonance is low ({res_score:.2f}). Unfollowing.",
|
||||
extra={"color": Fore.YELLOW},
|
||||
)
|
||||
|
||||
# Find 'Following' button on their profile
|
||||
following_nodes = telepathic._extract_semantic_nodes(profile_xml, "find 'Following' button", threshold=0.7)
|
||||
following_nodes = telepathic._extract_semantic_nodes(
|
||||
profile_xml, "find 'Following' button", threshold=0.7
|
||||
)
|
||||
if following_nodes and not following_nodes[0].get("skip"):
|
||||
f_node = following_nodes[0]
|
||||
_humanized_click(device, f_node["x"], f_node["y"])
|
||||
random_sleep(1.0, 2.0)
|
||||
|
||||
|
||||
# Find 'Unfollow' confirm
|
||||
confirm_xml = device.dump_hierarchy()
|
||||
confirm_nodes = telepathic._extract_semantic_nodes(confirm_xml, "find 'Unfollow' confirmation button", threshold=0.8)
|
||||
|
||||
confirm_nodes = telepathic._extract_semantic_nodes(
|
||||
confirm_xml, "find 'Unfollow' confirmation button", threshold=0.8
|
||||
)
|
||||
|
||||
if confirm_nodes and not confirm_nodes[0].get("skip"):
|
||||
c_node = confirm_nodes[0]
|
||||
_humanized_click(device, c_node["x"], c_node["y"])
|
||||
random_sleep(0.8, 1.5)
|
||||
|
||||
|
||||
logger.info("✅ [Unfollow Engine] Unfollowed a user.", extra={"color": Fore.GREEN})
|
||||
session_state.totalUnfollowed += 1
|
||||
total_unfollowed_this_session += 1
|
||||
failed_scrolls = 0
|
||||
dopamine.boredom += random.uniform(1.0, 3.0)
|
||||
dopamine.boredom += random.uniform(1.0, 3.0)
|
||||
else:
|
||||
logger.info(f"✨ [Smart Cleanup] Resonance is high ({res_score:.2f}). Keeping subscription.", extra={"color": Fore.MAGENTA})
|
||||
logger.info(
|
||||
f"✨ [Smart Cleanup] Resonance is high ({res_score:.2f}). Keeping subscription.",
|
||||
extra={"color": Fore.MAGENTA},
|
||||
)
|
||||
failed_scrolls = 0
|
||||
|
||||
|
||||
# 5. Always return to the Following list
|
||||
device.back()
|
||||
random_sleep(1.0, 2.0)
|
||||
break
|
||||
|
||||
|
||||
if not action_taken:
|
||||
# No following buttons in view, scroll down to find more
|
||||
_humanized_scroll_down(device)
|
||||
dopamine.boredom += 0.5
|
||||
failed_scrolls += 1
|
||||
|
||||
|
||||
if failed_scrolls > 5:
|
||||
logger.warning("⚠️ [Unfollow Engine] No 'Following' buttons found after multiple scrolls. Aborting or reaching bottom.")
|
||||
logger.warning(
|
||||
"⚠️ [Unfollow Engine] No 'Following' buttons found after multiple scrolls. Aborting or reaching bottom."
|
||||
)
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
|
||||
|
||||
if dopamine.wants_to_change_feed():
|
||||
logger.info("🧠 [Unfollow Engine] Desire to clean up following list satisfied. Navigating elsewhere.")
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
@@ -141,6 +164,6 @@ def _run_zero_latency_unfollow_loop(device, zero_engine, nav_graph, configs, ses
|
||||
_humanized_scroll_down(device)
|
||||
failed_scrolls += 1
|
||||
if failed_scrolls > 3:
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
return "FEED_EXHAUSTED"
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import logging
|
||||
import random
|
||||
import requests
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from time import sleep
|
||||
|
||||
from colorama import Fore, Style
|
||||
from packaging.version import parse as parse_version
|
||||
|
||||
from GramAddict.core.version import __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sanitize_text(text):
|
||||
return (text or "").strip()
|
||||
|
||||
|
||||
def random_sleep(inf=1.0, sup=3.0, modulable=True):
|
||||
from GramAddict.core.config import Config
|
||||
|
||||
configs = Config()
|
||||
try:
|
||||
multiplier = float(getattr(configs.args, "speed_multiplier", 1.0))
|
||||
@@ -23,21 +24,26 @@ def random_sleep(inf=1.0, sup=3.0, modulable=True):
|
||||
delay = random.uniform(inf, sup) / (multiplier if modulable else 1.0)
|
||||
sleep(max(delay, 0.2))
|
||||
|
||||
|
||||
def config_examples():
|
||||
logger.debug("Config examples handled by documentation.")
|
||||
|
||||
|
||||
def check_if_updated():
|
||||
logger.info(f"GramAddict v.{__version__}", extra={"color": f"{Style.BRIGHT}{Fore.MAGENTA}"})
|
||||
|
||||
|
||||
def get_instagram_version(device):
|
||||
try:
|
||||
output = device.shell(f"dumpsys package {device.app_id}").output
|
||||
import re
|
||||
|
||||
version_match = re.findall("versionName=(\\S+)", output)
|
||||
return version_match[0] if version_match else "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def close_instagram(device, force_kill=False):
|
||||
if force_kill:
|
||||
logger.info("Force-closing Instagram app to clean session state.")
|
||||
@@ -52,6 +58,7 @@ def close_instagram(device, force_kill=False):
|
||||
except Exception as e:
|
||||
logger.debug(f"Error pressing home: {e}")
|
||||
|
||||
|
||||
def open_instagram(device, force_restart=False):
|
||||
if force_restart:
|
||||
logger.info("Opening Instagram app (Fresh Start).")
|
||||
@@ -64,16 +71,21 @@ def open_instagram(device, force_restart=False):
|
||||
random_sleep(1, 2, modulable=False)
|
||||
return True
|
||||
|
||||
|
||||
def set_time_delta(args):
|
||||
args.time_delta_session = random.randint(-300, 300)
|
||||
|
||||
|
||||
def wait_for_next_session(time_left, session_state, sessions, device):
|
||||
logger.info(f"Waiting {time_left} until next working hours.")
|
||||
sleep(60)
|
||||
|
||||
|
||||
def get_value(count, name, default=0):
|
||||
if count is None: return default
|
||||
if isinstance(count, (int, float)): return count
|
||||
if count is None:
|
||||
return default
|
||||
if isinstance(count, (int, float)):
|
||||
return count
|
||||
try:
|
||||
if "-" in str(count):
|
||||
parts = str(count).split("-")
|
||||
@@ -82,15 +94,16 @@ def get_value(count, name, default=0):
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def is_ad(xml_hierarchy: str, cognitive_stack: dict = None) -> bool:
|
||||
"""
|
||||
Checks if the current view contains an advertisement using autonomous learning.
|
||||
|
||||
|
||||
If a cognitive_stack is provided, it uses the Telepathic Engine for
|
||||
semantic classification (Zero-Latency vector lookup).
|
||||
"""
|
||||
import xml.etree.ElementTree as ET
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
if cognitive_stack:
|
||||
telepathic = cognitive_stack.get("telepathic")
|
||||
@@ -103,18 +116,15 @@ def is_ad(xml_hierarchy: str, cognitive_stack: dict = None) -> bool:
|
||||
# --- Legacy Fallback ---
|
||||
# Regex word boundaries prevent false positives like 'brunette_abroad'
|
||||
AD_RESOURCE_IDS = [
|
||||
'com.instagram.android:id/ad_cta_button',
|
||||
'com.instagram.android:id/sponsored_label',
|
||||
'com.instagram.android:id/clips_single_image_ads_media_content',
|
||||
'com.instagram.android:id/ads_carousel_progress_bar',
|
||||
'com.instagram.android:id/ad_not_interested_button'
|
||||
"com.instagram.android:id/ad_cta_button",
|
||||
"com.instagram.android:id/sponsored_label",
|
||||
"com.instagram.android:id/clips_single_image_ads_media_content",
|
||||
"com.instagram.android:id/ads_carousel_progress_bar",
|
||||
"com.instagram.android:id/ad_not_interested_button",
|
||||
]
|
||||
|
||||
AD_MARKERS = [
|
||||
r'\b(sponsored|ad|advertisement)\b',
|
||||
r'\b(gesponsert|anzeige|werbung)\b'
|
||||
]
|
||||
|
||||
|
||||
AD_MARKERS = [r"\b(sponsored|ad|advertisement)\b", r"\b(gesponsert|anzeige|werbung)\b"]
|
||||
|
||||
try:
|
||||
root = ET.fromstring(xml_hierarchy)
|
||||
for node in root.iter("node"):
|
||||
|
||||
@@ -4,16 +4,18 @@ import xml.etree.ElementTree as ET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZeroLatencyEngine:
|
||||
"""
|
||||
The Zero-Latency Executor
|
||||
This engine receives a pre-compiled heuristic (Regex/XPath) from the memory cache
|
||||
and executes it against the local XML layout in under 5ms.
|
||||
and executes it against the local XML layout in under 5ms.
|
||||
It is completely deterministic. No LLM calls happen here.
|
||||
"""
|
||||
|
||||
def __init__(self, device):
|
||||
self.device = device
|
||||
|
||||
|
||||
def evaluate_heuristic(self, rule: dict, context_xml: str):
|
||||
"""
|
||||
Executes a compiled heuristic rule against the provided XML dump.
|
||||
@@ -22,20 +24,20 @@ class ZeroLatencyEngine:
|
||||
"""
|
||||
if not rule or not context_xml:
|
||||
return None
|
||||
|
||||
|
||||
rule_type = rule.get("rule_type", "regex")
|
||||
target_attr = rule.get("target_attribute", "text")
|
||||
pattern = rule.get("pattern", "")
|
||||
|
||||
|
||||
if not pattern:
|
||||
return None
|
||||
|
||||
try:
|
||||
root = ET.fromstring(context_xml)
|
||||
|
||||
|
||||
if rule_type == "regex":
|
||||
# Remove (?i) if present because we compile with re.IGNORECASE anyway
|
||||
clean_pattern = pattern.replace('(?i)', '')
|
||||
clean_pattern = pattern.replace("(?i)", "")
|
||||
regex = re.compile(clean_pattern, re.IGNORECASE)
|
||||
for node in root.iter("node"):
|
||||
val = ""
|
||||
@@ -55,17 +57,17 @@ class ZeroLatencyEngine:
|
||||
match = regex.search(val)
|
||||
if match:
|
||||
if len(match.groups()) > 0:
|
||||
return match.group(1) # Return captured group (e.g., username)
|
||||
return True # Return boolean existence (e.g. is_ad)
|
||||
|
||||
return match.group(1) # Return captured group (e.g., username)
|
||||
return True # Return boolean existence (e.g. is_ad)
|
||||
|
||||
elif rule_type == "xpath":
|
||||
# Basic xpath parsing over ET
|
||||
nodes = root.findall(pattern)
|
||||
if nodes:
|
||||
return nodes[0].attrib.get(target_attr, "")
|
||||
|
||||
return False # Rule ran but found nothing
|
||||
|
||||
|
||||
return False # Rule ran but found nothing
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"ZeroLatencyEngine failed to evaluate rule {pattern}: {e}")
|
||||
return None
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
|
||||
# Root path alignment
|
||||
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.append(ROOT_DIR)
|
||||
|
||||
|
||||
def colored(text, color, attrs=None):
|
||||
colors = {
|
||||
"red": "\033[91m", "green": "\033[92m", "yellow": "\033[93m",
|
||||
"blue": "\033[94m", "magenta": "\033[95m", "cyan": "\033[96m",
|
||||
"white": "\033[97m"
|
||||
"red": "\033[91m",
|
||||
"green": "\033[92m",
|
||||
"yellow": "\033[93m",
|
||||
"blue": "\033[94m",
|
||||
"magenta": "\033[95m",
|
||||
"cyan": "\033[96m",
|
||||
"white": "\033[97m",
|
||||
}
|
||||
reset = "\033[0m"
|
||||
bold = "\033[1m" if attrs and "bold" in attrs else ""
|
||||
return f"{bold}{colors.get(color, '')}{text}{reset}"
|
||||
|
||||
|
||||
from GramAddict.core.config import Config
|
||||
from GramAddict.core.qdrant_memory import ParasocialCRMDB, CommentMemoryDB
|
||||
from GramAddict.core.qdrant_memory import CommentMemoryDB, ParasocialCRMDB
|
||||
from GramAddict.core.resonance_engine import ResonanceEngine
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
class MockArgs:
|
||||
def __init__(self):
|
||||
@@ -31,10 +38,12 @@ class MockArgs:
|
||||
self.ai_vision_navigation = False
|
||||
self.ai_vision_context = False
|
||||
|
||||
|
||||
class MockConfig:
|
||||
def __init__(self):
|
||||
self.args = MockArgs()
|
||||
|
||||
|
||||
class AIMemoryDiagnosticRunner:
|
||||
def __init__(self):
|
||||
self.configs = MockConfig()
|
||||
@@ -42,7 +51,7 @@ class AIMemoryDiagnosticRunner:
|
||||
self.crm_db = ParasocialCRMDB()
|
||||
self.comment_db = CommentMemoryDB()
|
||||
self.resonance_oracle = ResonanceEngine("benchmark_agent", crm=self.crm_db)
|
||||
|
||||
|
||||
def setup(self):
|
||||
print(colored("🧹 Initializing benchmark data...", "cyan"))
|
||||
# We handle unique targets so we don't wipe the DB
|
||||
@@ -56,19 +65,24 @@ class AIMemoryDiagnosticRunner:
|
||||
fixture_path = os.path.join(ROOT_DIR, "tests", "fixtures", "comments_mock.xml")
|
||||
with open(fixture_path, "r", encoding="utf-8") as f:
|
||||
xml_data = f.read()
|
||||
|
||||
print(colored(f" -> Extracing comments using RAG Condenser ({self.configs.args.ai_condenser_model})...", "yellow"))
|
||||
|
||||
print(
|
||||
colored(
|
||||
f" -> Extracing comments using RAG Condenser ({self.configs.args.ai_condenser_model})...", "yellow"
|
||||
)
|
||||
)
|
||||
start = time.time()
|
||||
|
||||
|
||||
# Intercept the database write to bypass Qdrant indexing limits and solely test RAG filter logic
|
||||
intercepted_comments = []
|
||||
|
||||
|
||||
def mock_log(self, text: str, vibe: str, author: str = "unknown"):
|
||||
intercepted_comments.append(text)
|
||||
|
||||
|
||||
try:
|
||||
from unittest.mock import patch
|
||||
with patch.object(CommentMemoryDB, 'store_comment', new=mock_log):
|
||||
|
||||
with patch.object(CommentMemoryDB, "store_comment", new=mock_log):
|
||||
# Override the author logic
|
||||
test_author = f"benchmark_source_{int(time.time())}"
|
||||
self.resonance_oracle.extract_and_learn_comments(xml_data, self.configs, author=test_author)
|
||||
@@ -76,26 +90,41 @@ class AIMemoryDiagnosticRunner:
|
||||
except Exception as e:
|
||||
print(f"❌ EXCEPTION: {e}")
|
||||
return {"passed": False, "reason": str(e)}
|
||||
|
||||
|
||||
try:
|
||||
learned_texts = [c.lower() for c in intercepted_comments]
|
||||
dur = time.time() - start
|
||||
print(colored(f" -> Intercepted: {learned_texts}", "yellow"))
|
||||
|
||||
|
||||
toxic_count = sum(1 for t in learned_texts if "onlyfans" in t or "bitcoin" in t or "dm" in t or "$" in t)
|
||||
good_count = sum(1 for t in learned_texts if "majestic" in t or "lighting" in t)
|
||||
|
||||
|
||||
if toxic_count > 0:
|
||||
print(colored(" ❌ [Sub-Test] LLM Condenser hallucinated or failed to block toxic queries (OnlyFans/Crypto).", "red"))
|
||||
print(
|
||||
colored(
|
||||
" ❌ [Sub-Test] LLM Condenser hallucinated or failed to block toxic queries (OnlyFans/Crypto).",
|
||||
"red",
|
||||
)
|
||||
)
|
||||
return {"passed": False, "reason": "Toxic comments leaked"}
|
||||
|
||||
|
||||
if good_count == 0:
|
||||
print(colored(" ❌ [Sub-Test] LLM Condenser stripped everything or crashed. No good comments persisted.", "red"))
|
||||
print(
|
||||
colored(
|
||||
" ❌ [Sub-Test] LLM Condenser stripped everything or crashed. No good comments persisted.",
|
||||
"red",
|
||||
)
|
||||
)
|
||||
return {"passed": False, "reason": "Good comments dropped"}
|
||||
|
||||
print(colored(f" ✅ [Sub-Test] RAG Filter passed! 0 toxic comments, {good_count} valid comments mapped. Latency {dur:.2f}s", "green"))
|
||||
|
||||
print(
|
||||
colored(
|
||||
f" ✅ [Sub-Test] RAG Filter passed! 0 toxic comments, {good_count} valid comments mapped. Latency {dur:.2f}s",
|
||||
"green",
|
||||
)
|
||||
)
|
||||
return {"passed": True, "reason": "Toxic filtered, good preserved."}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
return {"passed": False, "reason": f"DB Error: {e}"}
|
||||
|
||||
@@ -105,18 +134,18 @@ class AIMemoryDiagnosticRunner:
|
||||
"""
|
||||
target = "benchmark_target"
|
||||
context_string = "234 Posts | 1.2M Followers | 🏔️ Alpine Photographer | Link in bio"
|
||||
|
||||
|
||||
try:
|
||||
self.crm_db.log_profile_context(target, context_string)
|
||||
time.sleep(0.5) # indexing buffer
|
||||
|
||||
time.sleep(0.5) # indexing buffer
|
||||
|
||||
history = self.crm_db.get_conversation_context(target)
|
||||
if context_string in history or "1.2M Followers" in history:
|
||||
print(colored(" ✅ [Sub-Test] Profile context cleanly injected into RAG CRM payload.", "green"))
|
||||
return {"passed": True, "reason": "Context string found."}
|
||||
else:
|
||||
return {"passed": False, "reason": "Profile context missing from CRM retrieval."}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
return {"passed": False, "reason": str(e)}
|
||||
|
||||
@@ -136,61 +165,60 @@ class AIMemoryDiagnosticRunner:
|
||||
self.crm_db.log_generated_comment(target, "Wow great photo!")
|
||||
self.crm_db.log_interaction(target, "tap_comment_button", new_stage=3)
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
stage_info = self.crm_db.get_relationship_stage(target)
|
||||
stage = stage_info.get("stage", 0)
|
||||
|
||||
|
||||
if stage >= 3:
|
||||
print(colored(f" ✅ [Sub-Test] CRM safely advanced state memory to Stage {stage}.", "green"))
|
||||
return {"passed": True, "reason": "Evolution logic passed."}
|
||||
else:
|
||||
print(colored(f" ❌ [Sub-Test] CRM stalled at Stage {stage}!", "red"))
|
||||
return {"passed": False, "reason": "Failed to evolve stage"}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
return {"passed": False, "reason": str(e)}
|
||||
|
||||
def execute_all(self):
|
||||
self.setup()
|
||||
results = {
|
||||
"timestamp": time.time(),
|
||||
"model": self.configs.args.ai_condenser_model,
|
||||
"scenarios": {}
|
||||
}
|
||||
|
||||
results = {"timestamp": time.time(), "model": self.configs.args.ai_condenser_model, "scenarios": {}}
|
||||
|
||||
def run_and_log(name, func):
|
||||
print(colored(f"\n--- SCENARIO: {name} ---", "magenta"))
|
||||
start_time = time.time()
|
||||
data = {"passed": False, "reason": "Unknown error", "latency_ms": 0}
|
||||
try:
|
||||
res = func()
|
||||
if isinstance(res, dict): data.update(res)
|
||||
elif res is True: data["passed"] = True
|
||||
if isinstance(res, dict):
|
||||
data.update(res)
|
||||
elif res is True:
|
||||
data["passed"] = True
|
||||
except Exception as e:
|
||||
print(colored(f"❌ EXCEPTION: {e}", "red"))
|
||||
data["reason"] = str(e)
|
||||
|
||||
|
||||
dur = time.time() - start_time
|
||||
data["latency_ms"] = int(dur * 1000)
|
||||
results["scenarios"][name] = data
|
||||
|
||||
|
||||
if data["passed"]:
|
||||
print(colored(f"🏁 {name} completed successfully in {dur:.2f}s", "green"))
|
||||
else:
|
||||
print(colored(f"🚨 {name} FAILED! (Elapsed: {dur:.2f}s)", "red", attrs=["bold"]))
|
||||
print(colored(f" Reason: {data['reason']}", "yellow"))
|
||||
|
||||
|
||||
run_and_log("RAG Comment Blacklist Extraction", self.test_rag_comment_extraction)
|
||||
run_and_log("CRM Profile Context Injection", self.test_crm_profile_context)
|
||||
run_and_log("CRM Sequential Evolution", self.test_crm_interaction_evolution)
|
||||
|
||||
self.setup() # Teardown
|
||||
|
||||
|
||||
self.setup() # Teardown
|
||||
|
||||
out_path = os.path.join(ROOT_DIR, "benchmarks", "data", "ai_memory_results.json")
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(results, f, indent=4)
|
||||
print(colored(f"\n📄 Saved AI Memory Benchmark results to: {out_path}", "cyan", attrs=["bold"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
runner = AIMemoryDiagnosticRunner()
|
||||
runner.execute_all()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
import json
|
||||
|
||||
from colorama import Fore, Style, init
|
||||
|
||||
# Init Colorama for cross-platform color support
|
||||
@@ -12,8 +13,8 @@ init(autoreset=True)
|
||||
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, ROOT_DIR)
|
||||
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
from GramAddict.core.qdrant_memory import UIMemoryDB
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
# Mute noisy loggers
|
||||
logging.getLogger("requests").setLevel(logging.WARNING)
|
||||
@@ -21,6 +22,7 @@ logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def colored(text, color, attrs=None):
|
||||
c = getattr(Fore, color.upper(), "")
|
||||
attr_str = ""
|
||||
@@ -28,6 +30,7 @@ def colored(text, color, attrs=None):
|
||||
attr_str = Style.BRIGHT
|
||||
return f"{attr_str}{c}{text}"
|
||||
|
||||
|
||||
class MockArgs:
|
||||
def __init__(self):
|
||||
self.ai_telepathic_model = "qwen3.5:latest"
|
||||
@@ -37,46 +40,55 @@ class MockArgs:
|
||||
self.ai_vision_navigation = True
|
||||
self.ai_vision_context = True
|
||||
|
||||
|
||||
import base64
|
||||
|
||||
|
||||
class MockDevice:
|
||||
def __init__(self):
|
||||
self.args = MockArgs()
|
||||
self.app_id = "com.instagram.android"
|
||||
|
||||
|
||||
def screenshot(self):
|
||||
# Return a simple 1x1 black pixel PNG to test the True Vision payload mapping
|
||||
# without crashing on invalid image data.
|
||||
return base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAXSURBVBhXY3jP4PgfAAWEAziO3O8MAAAAASUVORK5CYII=")
|
||||
return base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAXSURBVBhXY3jP4PgfAAWEAziO3O8MAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
from GramAddict.core.config import Config
|
||||
|
||||
Config().args = MockArgs()
|
||||
|
||||
|
||||
class BrainDiagnosticRunner:
|
||||
"""
|
||||
Professional diagnostic suite for Live integration testing of the
|
||||
Singularity LLM Cognitive Stack and Vector DB (Qdrant) persistence.
|
||||
Tested against heavy real-world XML dumps from Instagram.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.device = MockDevice()
|
||||
self.engine = TelepathicEngine.get_instance()
|
||||
self.mem_db = UIMemoryDB()
|
||||
|
||||
|
||||
# Test Namespaces
|
||||
self.intents = {
|
||||
"modal": "diagnostics_dismiss_obstacle",
|
||||
"ad": "diagnostics_find_sponsored",
|
||||
"hallucination": "diagnostics_tap_like_button",
|
||||
"unfollow": "diagnostics_tap_following_button"
|
||||
"unfollow": "diagnostics_tap_following_button",
|
||||
}
|
||||
|
||||
|
||||
# Load heavy real-world XML files
|
||||
self.fixtures_dir = os.path.join(ROOT_DIR, "tests", "fixtures")
|
||||
self.xmls = {
|
||||
"modal": self._load_fixture("blocked_ui.xml"),
|
||||
"ad": self._load_fixture("peugeot_ad.xml"),
|
||||
"hallucination": self._load_fixture("vlm_hallucination.xml"),
|
||||
"unfollow": self._load_fixture("unfollow_list_dump.xml")
|
||||
"unfollow": self._load_fixture("unfollow_list_dump.xml"),
|
||||
}
|
||||
|
||||
def _load_fixture(self, filename) -> str:
|
||||
@@ -91,7 +103,7 @@ class BrainDiagnosticRunner:
|
||||
if not self.mem_db.is_connected:
|
||||
logger.error("❌ Qdrant is offline! Diagnostics cannot proceed.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
print(colored("🧹 Initializing diagnostic namespace (clearing old cache)...", "yellow"))
|
||||
for intent in self.intents.values():
|
||||
pt_id = self.mem_db._deterministic_id(intent)
|
||||
@@ -111,20 +123,20 @@ class BrainDiagnosticRunner:
|
||||
xml = self.xmls["modal"]
|
||||
intent = self.intents["modal"]
|
||||
node = self.engine.find_best_node(xml, intent, min_confidence=0.8, device=self.device)
|
||||
|
||||
|
||||
if not node:
|
||||
print(colored(" ❌ LLM failed to find the dismiss button entirely.", "red"))
|
||||
return {"passed": False, "reason": "No node found"}
|
||||
|
||||
|
||||
semantic = str(node.get("semantic", "")).lower()
|
||||
if "try again later" in semantic or "action block" in semantic:
|
||||
print(colored(" ❌ LLM selected the title text instead of the dismiss button.", "red"))
|
||||
return {"passed": False, "reason": "Selected title instead of button"}
|
||||
|
||||
|
||||
if "dismiss" in semantic or "ok" in semantic:
|
||||
print(colored(f" ✅ VLM correctly reasoned the popup OK/Dismiss button: {semantic}", "green"))
|
||||
return {"passed": True, "reason": f"Found correct button: {semantic}"}
|
||||
|
||||
|
||||
return {"passed": False, "reason": f"Selected unrelated element: {semantic}"}
|
||||
|
||||
def test_ad_deception(self) -> dict:
|
||||
@@ -134,20 +146,21 @@ class BrainDiagnosticRunner:
|
||||
xml = self.xmls["ad"]
|
||||
intent = self.intents["ad"]
|
||||
node = self.engine.find_best_node(xml, intent, min_confidence=0.8, device=self.device)
|
||||
|
||||
|
||||
if not node:
|
||||
print(colored(" ❌ LLM failed to identify the sponsored indicator.", "red"))
|
||||
return {"passed": False, "reason": "Missed sponsored text"}
|
||||
|
||||
|
||||
semantic = str(node.get("semantic", "")).lower()
|
||||
if "sponsored" in semantic:
|
||||
print(colored(" ✅ VLM correctly identified the tiny 'Sponsored' label amidst a huge post.", "green"))
|
||||
|
||||
|
||||
# --- Test Fast Path Recall Sub-Scenario ---
|
||||
# Save it
|
||||
self.engine.confirm_click(intent)
|
||||
self.mem_db.store_memory(intent, xml, node)
|
||||
import time
|
||||
|
||||
time.sleep(0.5)
|
||||
# Try to grab it again
|
||||
start = time.time()
|
||||
@@ -159,7 +172,7 @@ class BrainDiagnosticRunner:
|
||||
else:
|
||||
print(colored(" ❌ [Sub-Test] Memory recall failed.", "red"))
|
||||
return {"passed": False, "reason": "Found ad, but memory persistence failed."}
|
||||
|
||||
|
||||
return {"passed": False, "reason": f"Picked wrong node: {semantic}"}
|
||||
|
||||
def test_vlm_hallucination(self) -> dict:
|
||||
@@ -169,63 +182,66 @@ class BrainDiagnosticRunner:
|
||||
xml = self.xmls["hallucination"]
|
||||
intent = self.intents["hallucination"]
|
||||
node = self.engine.find_best_node(xml, intent, min_confidence=0.8, device=self.device)
|
||||
|
||||
|
||||
if not node:
|
||||
print(colored(" ❌ LLM failed to find any like button.", "red"))
|
||||
return {"passed": False, "reason": "No node found"}
|
||||
|
||||
|
||||
semantic = str(node.get("semantic", "")).lower()
|
||||
|
||||
|
||||
is_caption = ("double tap" in semantic or "like" in semantic) and "row feed button" not in semantic
|
||||
if is_caption:
|
||||
print(colored(" ❌ LLM fell for the semantic hallucination gap and selected the text caption!", "red"))
|
||||
return {"passed": False, "reason": "Fell for caption text trap"}
|
||||
|
||||
|
||||
if "row feed button like" in semantic or "heart" in semantic:
|
||||
print(colored(" ✅ VLM successfully ignored the deceptive caption and found the structural like button.", "green"))
|
||||
print(
|
||||
colored(
|
||||
" ✅ VLM successfully ignored the deceptive caption and found the structural like button.", "green"
|
||||
)
|
||||
)
|
||||
return {"passed": True, "reason": "Ignored text trap, clicked structural button"}
|
||||
|
||||
|
||||
return {"passed": False, "reason": f"Picked unrelated node: {semantic}"}
|
||||
|
||||
def execute_all(self):
|
||||
self.setup()
|
||||
results = {
|
||||
"timestamp": time.time(),
|
||||
"model": self.device.args.ai_telepathic_model,
|
||||
"scenarios": {}
|
||||
}
|
||||
|
||||
results = {"timestamp": time.time(), "model": self.device.args.ai_telepathic_model, "scenarios": {}}
|
||||
|
||||
def run_and_log(name, func):
|
||||
print(colored(f"\n--- SCENARIO: {name} ---", "magenta"))
|
||||
start_time = time.time()
|
||||
data = {"passed": False, "reason": "Unknown error", "latency_ms": 0}
|
||||
try:
|
||||
res = func()
|
||||
if isinstance(res, dict): data.update(res)
|
||||
elif res is True: data["passed"] = True
|
||||
if isinstance(res, dict):
|
||||
data.update(res)
|
||||
elif res is True:
|
||||
data["passed"] = True
|
||||
except Exception as e:
|
||||
print(colored(f"❌ EXCEPTION: {e}", "red"))
|
||||
data["reason"] = str(e)
|
||||
|
||||
|
||||
dur = time.time() - start_time
|
||||
data["latency_ms"] = int(dur * 1000)
|
||||
results["scenarios"][name] = data
|
||||
|
||||
|
||||
if data["passed"]:
|
||||
print(colored(f"🏁 {name} completed successfully in {dur:.2f}s", "green"))
|
||||
else:
|
||||
print(colored(f"🚨 {name} FAILED! (Elapsed: {dur:.2f}s)", "red", attrs=["bold"]))
|
||||
|
||||
|
||||
run_and_log("The Modal Trap (Blocked UI)", self.test_modal_trap)
|
||||
run_and_log("The Ad Deception (Sponsored)", self.test_ad_deception)
|
||||
run_and_log("The VLM Hallucination Gap (Text Trap)", self.test_vlm_hallucination)
|
||||
self.teardown()
|
||||
|
||||
|
||||
out_path = os.path.join(ROOT_DIR, "benchmarks", "data", "live_learning_results.json")
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(results, f, indent=4)
|
||||
print(colored(f"\n📄 Saved intensive learning results to: {out_path}", "cyan", attrs=["bold"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
runner = BrainDiagnosticRunner()
|
||||
runner.execute_all()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
# Add root project path so we can import internal modules safely
|
||||
@@ -14,6 +14,7 @@ from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
BENCHMARKS_FILE = os.path.join(os.path.dirname(__file__), "data/llm_benchmarks.json")
|
||||
SCENARIOS_FILE = os.path.join(os.path.dirname(__file__), "data/benchmark_scenarios.json")
|
||||
|
||||
|
||||
def load_json(path):
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
@@ -23,22 +24,24 @@ def load_json(path):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def save_json(path, data):
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
|
||||
|
||||
def normalize_scores(db):
|
||||
if not db.get("models"):
|
||||
return db
|
||||
|
||||
|
||||
# 1. Find the highest raw score across all models
|
||||
max_raw = 0
|
||||
leader_model = None
|
||||
|
||||
|
||||
for name, data in db["models"].items():
|
||||
if data.get("is_unsuitable"):
|
||||
continue
|
||||
|
||||
|
||||
raw = data.get("raw_score", 0)
|
||||
if raw > max_raw:
|
||||
max_raw = raw
|
||||
@@ -57,10 +60,11 @@ def normalize_scores(db):
|
||||
for name, data in db["models"].items():
|
||||
raw = data.get("raw_score", 0)
|
||||
data["relative_performance_pct"] = round((raw / max_raw) * 100, 1)
|
||||
data["is_leader"] = (name == leader_model)
|
||||
|
||||
data["is_leader"] = name == leader_model
|
||||
|
||||
return db
|
||||
|
||||
|
||||
def get_installed_ollama_models():
|
||||
"""
|
||||
Finds truly local Ollama models by parsing 'ollama list'.
|
||||
@@ -76,25 +80,26 @@ def get_installed_ollama_models():
|
||||
if len(parts) >= 3:
|
||||
name = parts[0]
|
||||
size = parts[2]
|
||||
|
||||
|
||||
# 1. Skip if size is '-' (remote/cloud model)
|
||||
if size == "-":
|
||||
continue
|
||||
|
||||
|
||||
# 2. Skip ':cloud' tagged models explicitly
|
||||
if ":cloud" in name:
|
||||
continue
|
||||
|
||||
|
||||
# 3. Filter out purely embedding models
|
||||
if any(k in name.lower() for k in ["embed", "minilm", "rerank"]):
|
||||
continue
|
||||
|
||||
|
||||
models.append(name)
|
||||
return models
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not list Ollama models: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def benchmark_model(model_name: str, url: str, force: bool = False):
|
||||
db = load_json(BENCHMARKS_FILE) or {"models": {}}
|
||||
scenarios_data = load_json(SCENARIOS_FILE)
|
||||
@@ -105,26 +110,25 @@ def benchmark_model(model_name: str, url: str, force: bool = False):
|
||||
if not force and model_name in db.get("models", {}):
|
||||
pct = db["models"][model_name].get("relative_performance_pct", "N/A")
|
||||
if not db["models"][model_name].get("is_unsuitable"):
|
||||
print(f"Typical execution skip for {model_name} (Rel: {pct}%). Use --force.")
|
||||
return
|
||||
print(f"Typical execution skip for {model_name} (Rel: {pct}%). Use --force.")
|
||||
return
|
||||
|
||||
print(f"\n🚀 [Competitive Benchmarking] Model: {model_name}")
|
||||
|
||||
|
||||
total_raw = 0
|
||||
total_latency = 0
|
||||
results_detail = {}
|
||||
passed_all = True
|
||||
|
||||
blank_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
|
||||
|
||||
system_prompt = (
|
||||
"You identify which UI element to tap based ONLY on a JSON array of parsed Android elements. "
|
||||
"Output ONLY valid JSON: {\"index\": number, \"reason\": \"brief reason\"}"
|
||||
'Output ONLY valid JSON: {"index": number, "reason": "brief reason"}'
|
||||
)
|
||||
|
||||
scenarios = scenarios_data["scenarios"]
|
||||
for scenario in scenarios:
|
||||
print(f"--- Running: {scenario['name']} ---")
|
||||
|
||||
|
||||
user_prompt = (
|
||||
f"Which element should I tap to: {scenario['task']}\n\n"
|
||||
f"Elements:\n{json.dumps(scenario['nodes'], indent=1)}\n\n"
|
||||
@@ -147,14 +151,16 @@ def benchmark_model(model_name: str, url: str, force: bool = False):
|
||||
raw_points = 0
|
||||
try:
|
||||
clean = resp_str.strip()
|
||||
if clean.startswith("```json"): clean = clean[7:]
|
||||
if clean.endswith("```"): clean = clean[:-3]
|
||||
if clean.startswith("```json"):
|
||||
clean = clean[7:]
|
||||
if clean.endswith("```"):
|
||||
clean = clean[:-3]
|
||||
data = json.loads(clean)
|
||||
|
||||
|
||||
# Points for structural adherence
|
||||
if "index" in data and "reason" in data:
|
||||
raw_points += 40
|
||||
|
||||
|
||||
# Points for correctness
|
||||
if data["index"] == scenario["target_index"]:
|
||||
raw_points += 60
|
||||
@@ -173,39 +179,44 @@ def benchmark_model(model_name: str, url: str, force: bool = False):
|
||||
total_raw += raw_points
|
||||
|
||||
avg_latency = total_latency // len(scenarios) if scenarios else 0
|
||||
print(f"\n📊 {model_name} Result: {'PASS' if passed_all else 'FAIL'} | Score: {total_raw} | Latency: {avg_latency}ms")
|
||||
|
||||
print(
|
||||
f"\n📊 {model_name} Result: {'PASS' if passed_all else 'FAIL'} | Score: {total_raw} | Latency: {avg_latency}ms"
|
||||
)
|
||||
|
||||
if model_name not in db["models"]:
|
||||
db["models"][model_name] = {}
|
||||
|
||||
db["models"][model_name].update({
|
||||
"raw_score": total_raw,
|
||||
"telepathic_score": int((total_raw / (len(scenarios) * 100)) * 100) if scenarios else 0,
|
||||
"latency_ms": avg_latency,
|
||||
"last_tested": datetime.utcnow().isoformat() + "Z",
|
||||
"details": results_detail,
|
||||
"passed_all": passed_all,
|
||||
"is_unsuitable": not passed_all
|
||||
})
|
||||
|
||||
|
||||
db["models"][model_name].update(
|
||||
{
|
||||
"raw_score": total_raw,
|
||||
"telepathic_score": int((total_raw / (len(scenarios) * 100)) * 100) if scenarios else 0,
|
||||
"latency_ms": avg_latency,
|
||||
"last_tested": datetime.utcnow().isoformat() + "Z",
|
||||
"details": results_detail,
|
||||
"passed_all": passed_all,
|
||||
"is_unsuitable": not passed_all,
|
||||
}
|
||||
)
|
||||
|
||||
# Recalculate relative scores across all models
|
||||
db = normalize_scores(db)
|
||||
save_json(BENCHMARKS_FILE, db)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from GramAddict.core.config import Config
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser(description="Competitive Benchmark for Singularity", add_help=False)
|
||||
parser.add_argument("--config", type=str, help="Bot config file")
|
||||
parser.add_argument("--model", type=str, help="Explicit model name")
|
||||
parser.add_argument("--url", type=str, help="Explicit endpoint URL")
|
||||
parser.add_argument("--force", action="store_true", help="Force re-testing")
|
||||
parser.add_argument("--all-ollama", action="store_true", help="Automatically find and test all local Ollama models")
|
||||
|
||||
|
||||
args, unknown = parser.parse_known_args()
|
||||
|
||||
|
||||
models_to_test = []
|
||||
|
||||
|
||||
if args.all_ollama:
|
||||
ollama_models = get_installed_ollama_models()
|
||||
for m in ollama_models:
|
||||
@@ -215,8 +226,12 @@ if __name__ == "__main__":
|
||||
elif args.config:
|
||||
configs = Config(first_run=True, config=args.config)
|
||||
configs.parse_args()
|
||||
|
||||
for attr, pref in [("ai_telepathic_model", "ai_telepathic_url"), ("ai_model", "ai_model_url"), ("ai_condenser_model", "ai_condenser_url")]:
|
||||
|
||||
for attr, pref in [
|
||||
("ai_telepathic_model", "ai_telepathic_url"),
|
||||
("ai_model", "ai_model_url"),
|
||||
("ai_condenser_model", "ai_condenser_url"),
|
||||
]:
|
||||
m = getattr(configs.args, attr, None)
|
||||
u = getattr(configs.args, pref, "http://localhost:11434/api/generate")
|
||||
if m:
|
||||
@@ -224,7 +239,7 @@ if __name__ == "__main__":
|
||||
else:
|
||||
print("❌ Syntax: --all-ollama OR --config test_config.yml OR --model x --url y")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
for m, u in set(models_to_test):
|
||||
benchmark_model(m, u, args.force)
|
||||
time.sleep(1)
|
||||
|
||||
@@ -52,6 +52,7 @@ markers = [
|
||||
"live: tests requiring a live ADB device",
|
||||
"chaos: chaos engineering / corruption tests",
|
||||
"property: hypothesis property-based tests",
|
||||
"live_llm: tests requiring a live local LLM via Ollama",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
|
||||
@@ -11,3 +11,4 @@ requests>=2.31.0
|
||||
packaging>=23.0
|
||||
python-dotenv==1.0.1
|
||||
qdrant-client>=1.7.0
|
||||
psutil==5.9.5
|
||||
|
||||
2
run.py
2
run.py
@@ -1,10 +1,12 @@
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import GramAddict
|
||||
|
||||
warnings.filterwarnings("ignore", category=UserWarning, module="urllib3")
|
||||
try:
|
||||
from urllib3.exceptions import NotOpenSSLWarning
|
||||
|
||||
warnings.filterwarnings("ignore", category=NotOpenSSLWarning)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
import os, json
|
||||
|
||||
DUMP_PATH = "/Volumes/Alpha SSD/Coding/bot/debug/xml_dumps/post_load_timeout__2026-04-17_15-02-36.xml"
|
||||
with open(DUMP_PATH, "r") as f:
|
||||
xml_content = f.read()
|
||||
@@ -8,15 +8,13 @@ engine = TelepathicEngine.get_instance()
|
||||
nodes = engine._extract_semantic_nodes(xml_content)
|
||||
grid_nodes = []
|
||||
for node in nodes:
|
||||
if node.get("resource_id") in ["com.instagram.android:id/grid_card_layout_container", "com.instagram.android:id/image_button"]:
|
||||
if node.get("resource_id") in [
|
||||
"com.instagram.android:id/grid_card_layout_container",
|
||||
"com.instagram.android:id/image_button",
|
||||
]:
|
||||
grid_nodes.append(node)
|
||||
|
||||
grid_nodes.sort(key=lambda n: (
|
||||
round(n["y"] / 5) * 5,
|
||||
n["x"],
|
||||
n["naf"],
|
||||
-n["area"]
|
||||
))
|
||||
grid_nodes.sort(key=lambda n: (round(n["y"] / 5) * 5, n["x"], n["naf"], -n["area"]))
|
||||
|
||||
for n in grid_nodes[:5]:
|
||||
print(f"Y={n['y']} (rnd={round(n['y']/5)*5}), NAF={n['naf']}, Area={n['area']}, ID={n['resource_id']}")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
import os
|
||||
|
||||
DUMP_PATH = "/Volumes/Alpha SSD/Coding/bot/debug/xml_dumps/post_load_timeout__2026-04-17_15-02-36.xml"
|
||||
with open(DUMP_PATH, "r") as f:
|
||||
xml_content = f.read()
|
||||
@@ -8,15 +8,27 @@ engine = TelepathicEngine.get_instance()
|
||||
TelepathicEngine._last_click_context = {"x": 178, "y": 558}
|
||||
intent = "first image in explore grid"
|
||||
|
||||
print(f"Any grid check: {any(k in intent.lower() for k in ['explore grid', 'profile grid', 'first image', 'grid item'])}")
|
||||
print(
|
||||
f"Any grid check: {any(k in intent.lower() for k in ['explore grid', 'profile grid', 'first image', 'grid item'])}"
|
||||
)
|
||||
post_markers = [
|
||||
"row_feed_button_like", "row_feed_button_comment", "row_feed_button_share",
|
||||
"row_feed_comment_textview_layout", "row_feed_view_group",
|
||||
"row_feed_photo_profile_name", "row_feed_photo_imageview",
|
||||
"clips_media_component", "clips_viewer", "clips_like_button",
|
||||
"clips_comment_button", "reel_viewer", "clips_music_attribution",
|
||||
"carousel_page_indicator", "media_set_page_indicator",
|
||||
"action_bar_original_title", "media_header_user",
|
||||
"row_feed_button_like",
|
||||
"row_feed_button_comment",
|
||||
"row_feed_button_share",
|
||||
"row_feed_comment_textview_layout",
|
||||
"row_feed_view_group",
|
||||
"row_feed_photo_profile_name",
|
||||
"row_feed_photo_imageview",
|
||||
"clips_media_component",
|
||||
"clips_viewer",
|
||||
"clips_like_button",
|
||||
"clips_comment_button",
|
||||
"reel_viewer",
|
||||
"clips_music_attribution",
|
||||
"carousel_page_indicator",
|
||||
"media_set_page_indicator",
|
||||
"action_bar_original_title",
|
||||
"media_header_user",
|
||||
]
|
||||
print(f"Marker found check: {any(m in xml_content.lower() for m in post_markers)}")
|
||||
|
||||
|
||||
@@ -1,40 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import time
|
||||
import logging
|
||||
|
||||
# Ensure GramAddict is in PYTHONPATH if script is run directly
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from GramAddict.core.device_facade import create_device
|
||||
from GramAddict.core.config import Config
|
||||
|
||||
# Setup a clean logger for the toolkit
|
||||
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] %(levelname)s | %(message)s', datefmt='%H:%M:%S')
|
||||
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] %(levelname)s | %(message)s", datefmt="%H:%M:%S")
|
||||
logger = logging.getLogger("TestingToolkit")
|
||||
|
||||
|
||||
def _save_dump(device, fixture_dir, filename, description):
|
||||
logger.info(f"⏳ Waiting for UI to settle for [{description}]...")
|
||||
time.sleep(3.5) # ensure animations finish
|
||||
time.sleep(3.5) # ensure animations finish
|
||||
xml_data = device.dump_hierarchy()
|
||||
|
||||
|
||||
if not xml_data or len(xml_data) < 100:
|
||||
logger.warning(f"⚠️ Received empty or exceptionally small XML dump for {filename}. Is the app open?")
|
||||
|
||||
|
||||
path = os.path.join(fixture_dir, filename)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(xml_data)
|
||||
logger.info(f"✅ Saved REAL DUMP to {filename} ({len(xml_data)} bytes)")
|
||||
|
||||
|
||||
def run_interactive_guide(device, fixture_dir):
|
||||
print("\n" + "="*60)
|
||||
print("\n" + "=" * 60)
|
||||
print("🤖 AUTOMATED E2E DUMP CAPTURE GUIDE")
|
||||
print("="*60)
|
||||
print("=" * 60)
|
||||
print("This guide will help you refresh the golden fixtures for the E2E suite.")
|
||||
print("Please follow the instructions on your phone/emulator.")
|
||||
print("="*60 + "\n")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
steps = [
|
||||
("comment_sheet.xml", "Post Comment Sheet", "Open any post and open its comment section."),
|
||||
@@ -50,7 +51,7 @@ def run_interactive_guide(device, fixture_dir):
|
||||
("followers_list_dump.xml", "Followers List", "On that user's profile, tap 'Followers'."),
|
||||
("carousel_post_dump.xml", "Carousel Post", "Find a post with multiple images on your feed."),
|
||||
("home_feed_with_ad.xml", "Sponsored Ad", "Scroll until you see a 'Sponsored' post."),
|
||||
("dm_thread_dump.xml", "DM Chat Thread", "Open any specific chat conversation.")
|
||||
("dm_thread_dump.xml", "DM Chat Thread", "Open any specific chat conversation."),
|
||||
]
|
||||
|
||||
for i, (fname, desc, instr) in enumerate(steps, 1):
|
||||
@@ -63,9 +64,10 @@ def run_interactive_guide(device, fixture_dir):
|
||||
print("\n🛑 Guide interrupted.")
|
||||
return
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("\n" + "=" * 60)
|
||||
logger.info("🎉 Capture Sequence Complete! All fixtures have been updated.")
|
||||
print("="*60 + "\n")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Instagram Bot Testing Toolkit - Fixture Management")
|
||||
@@ -76,14 +78,15 @@ def main():
|
||||
args = parser.parse_args()
|
||||
|
||||
device_id = args.device
|
||||
|
||||
|
||||
# Try to extract device from config if provided
|
||||
if args.config:
|
||||
try:
|
||||
import yaml
|
||||
with open(args.config, 'r', encoding='utf-8') as f:
|
||||
|
||||
with open(args.config, "r", encoding="utf-8") as f:
|
||||
config_data = yaml.safe_load(f)
|
||||
device_id = config_data.get('device') or device_id
|
||||
device_id = config_data.get("device") or device_id
|
||||
logger.info(f"Loaded device ID from config: {device_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read config file {args.config}: {e}")
|
||||
@@ -108,11 +111,13 @@ def main():
|
||||
run_interactive_guide(device, fixture_dir)
|
||||
elif args.fixture:
|
||||
fname = args.fixture
|
||||
if not fname.endswith(".xml"): fname += ".xml"
|
||||
if not fname.endswith(".xml"):
|
||||
fname += ".xml"
|
||||
_save_dump(device, fixture_dir, fname, f"Single Fixture: {fname}")
|
||||
else:
|
||||
logger.info("Nothing to do. Use --interactive or --fixture <name>.")
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
171
test_config.yml
171
test_config.yml
@@ -1,124 +1,107 @@
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 🤖 AUTONOMOUS AGENT CONFIGURATION (Full Options Reference)
|
||||
# 🤖 ANTIGRAVITY ELITE CONFIGURATION (Plugin-Based Architecture)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# Das ist das "Brain" deines Bots. Keine abstrakten Klick-Raten oder
|
||||
# Prozentwerte mehr. Sag dem Bot einfach, wer er ist und was er tun soll.
|
||||
# Dieses Brain ist modular aufgebaut. Jedes Verhalten ist ein autonomes Plugin.
|
||||
# Einstellungen können global oder spezifisch für jedes Plugin definiert werden.
|
||||
# Design-Prinzip: Zero Trust & Fail Fast.
|
||||
|
||||
identity:
|
||||
# Unter welchem Account operiert der Bot?
|
||||
username: "marisaundmarc"
|
||||
|
||||
# Wer ist der Bot? (Wichtig für die KI-Kommentare und Profil-Analyse)
|
||||
persona: "Travel blogger, landscape photographer, and outdoors enthusiast"
|
||||
vibe: "friendly, authentic, helpful, and appreciative of good art"
|
||||
|
||||
mission:
|
||||
# Wie soll sich der Bot generell verhalten?
|
||||
# - aggressive_growth: Sucht permanent nach neuen Profilen (Explore/Reels)
|
||||
# - community_builder: Fokussiert sich stark auf den eigenen Feed und Home-Tab
|
||||
# - stealth_lurker: Liest viel, interagiert aber nur bei extrem hoher Relevanz
|
||||
# - passive_learning: "Dry-Run" Modus. Bot navigiert und lernt, führt aber NIE Aktionen aus.
|
||||
strategy: "aggressive_growth"
|
||||
|
||||
# Wie kritisch ist der Bot bei fremden Posts? (Hoch = nur Meisterwerke, Niedrig = fast alles)
|
||||
selectivity_threshold: "high"
|
||||
|
||||
# Wen sucht der Bot? (Alias für target-audience)
|
||||
selectivity_threshold: "high"
|
||||
target_audience: "travel, landscape, nature, mountain photography, wanderlust"
|
||||
# persona_interests: "travel, landscape, nature" # Alternative zu target_audience
|
||||
|
||||
# Was hasst der Bot absolut? (Sofortiger Skip)
|
||||
blacklist_topics: "onlyfans, nsfw, sale, discount, promo, 18+, giveaway, crypto"
|
||||
|
||||
# ── Core Actions / Jobs (Welche Bereiche sollen besucht werden) ──
|
||||
# actions:
|
||||
# feed: "5-10" # Anzahl der Posts im Home-Feed
|
||||
# explore: "5-10" # Anzahl der Posts im Explore-Grid
|
||||
# reels: "5-10" # Anzahl der Reels im nativen Reels-Tab
|
||||
# stories: "3-5" # Anzahl der Story-Loops, die nativ geschaut werden
|
||||
# search: "landscape" # Komma-getrennte Suchbegriffe für die Suchleiste
|
||||
# repeat: 1 # Wie oft soll die komplette Schleife wiederholt werden
|
||||
# ── Core Action Jobs (Wann soll der Bot wo aktiv werden?) ──
|
||||
actions:
|
||||
feed: "5-10" # Anzahl der Posts im Home-Feed pro Session
|
||||
explore: "5-10" # Anzahl der Posts im Explore-Grid
|
||||
# reels: "5-10" # In Entwicklung
|
||||
# stories: "3-5" # In Entwicklung
|
||||
|
||||
interactions:
|
||||
# Grund-Wahrscheinlichkeit für Aktionen (unabhängig von der strict Resonance)
|
||||
interact_percentage: 100 # Globale Chance, ob überhaupt interagiert wird
|
||||
likes_percentage: 100
|
||||
comment_percentage: 40 # Moderater Wert, da Kommentare "dry" sind
|
||||
follow_percentage: 100 # IMMER folgen, wenn das Profil als relevant bewertet wurde
|
||||
stories_percentage: 100 # IMMER Stories schauen, um menschlich zu wirken
|
||||
|
||||
# Detail-Limits pro Profil/Post
|
||||
likes_count: "2-3" # 2-3 schnelle Likes auf dem Profil hinterlassen (sehr starkes Signal)
|
||||
stories_count: "1-2" # 1-2 Stories anschauen (sehr menschliches Verhalten)
|
||||
# ── Plugin Configuration (Das Herzstück der Verhaltenssteuerung) ──
|
||||
plugins:
|
||||
# 🛡️ Guards & Safety (Filtern, bevor Interaktion passiert)
|
||||
ad_guard:
|
||||
enabled: true
|
||||
|
||||
# Comment Dry Run: Wenn true, überlegt sich die AI geniale Kommentare, postet sie aber nicht in echt.
|
||||
dry_run_comments: true
|
||||
|
||||
# Wahrscheinlichkeit (in Prozent), fremde Profile VOR dem Kommentieren tiefgründig zu analysieren
|
||||
profile_learning_percentage: 100 # IMMER Profile analysieren -> Trigger für den Follow/Like Flow
|
||||
|
||||
# Wahrscheinlichkeit (in Prozent), das Bild visuell zu analysieren (Screenshot -> LLM), bevor interagiert wird
|
||||
visual_vibe_check_percentage: 100
|
||||
close_friends_guard:
|
||||
enabled: true # Postings von 'Engen Freunden' ignorieren
|
||||
|
||||
# ── AI Learning & Perception ──
|
||||
ai_learning:
|
||||
# Soll der Bot zum Start der Session sein eigenes Profil lesen und Persona/Vibe anpassen?
|
||||
ai_learn_own_profile: true
|
||||
# ai_learn_comments: false # Kommentare extrahieren und in die Qdrant DB aufnehmen
|
||||
# ai_learn_niche_posts: false # Nischen-spezifische Texte und Posts in die DB lernen
|
||||
# ai_learn_only: false # Nur umherwandern und Content absaugen/lernen (kein Posten)
|
||||
# ai_quality_filter: false # Rigorose AI-Prüfung aller Posts vor Interaktion
|
||||
# ai_vision_navigation: false # Nutze VLM, um UI Buttons auf dem Bildschirm zu finden (teuer!)
|
||||
# ai_vision_context: false # Nutze VLM, um DMs und Posts semantisch in voller Tiefe zu begreifen
|
||||
obstacle_guard:
|
||||
enabled: true # Popups, Update-Dialoge etc. wegräumen
|
||||
|
||||
anomaly_handler:
|
||||
enabled: true # Erkennt Blockierungen oder Captchas sofort (Fail Fast)
|
||||
|
||||
# 🧠 Perception & Evaluation (Vorverarbeitung)
|
||||
post_data_extraction:
|
||||
enabled: true # Extrahiert Text, Hashtags und Metadata
|
||||
|
||||
resonance_evaluator:
|
||||
visual_vibe_check_percentage: 100
|
||||
selectivity_threshold: "high"
|
||||
|
||||
# ⚡ Interactions (Die eigentlichen Aktionen)
|
||||
likes:
|
||||
percentage: 100 # Wahrscheinlichkeit pro Post
|
||||
count: "2-3" # Falls im Grid, wie viele?
|
||||
|
||||
comment:
|
||||
percentage: 40
|
||||
dry_run: true # Generiert KI-Kommentare ohne zu posten (Review-Mode)
|
||||
|
||||
follow:
|
||||
percentage: 100
|
||||
|
||||
repost:
|
||||
percentage: 20 # Teilen in die eigene Story
|
||||
|
||||
story_view:
|
||||
percentage: 80
|
||||
count: "1-3" # Wie viele Stories pro User schauen?
|
||||
|
||||
profile_visit:
|
||||
percentage: 100 # Wahrscheinlichkeit, vom Feed ins Profil zu gehen
|
||||
learn_own_profile: true
|
||||
|
||||
grid_like:
|
||||
percentage: 60 # Liked Posts aus dem Profil-Grid des Users
|
||||
count: "1-3"
|
||||
|
||||
# 🎢 Special Behaviors
|
||||
carousel_browsing:
|
||||
percentage: 100 # Erkennt Carousels und swiped durch
|
||||
count: "2-4" # Wie viele Slides pro Post?
|
||||
|
||||
rabbit_hole:
|
||||
percentage: 30 # Geht tiefer in verwandte Profile (Inception-Mode)
|
||||
|
||||
darwin_dwell:
|
||||
percentage: 50 # Simuliert unregelmäßige Lesezeiten (Biometrie)
|
||||
|
||||
# ── Limits & Budget ──
|
||||
limits:
|
||||
# Wie viele Stunden am Tag darf der Bot maximal arbeiten?
|
||||
daily_budget_hours: 2.5
|
||||
# working_hours: "09:00-21:00" # In welchem Fenster der Bot laufen darf
|
||||
# time_delta_session: "60-120" # Minuten Pause zwischen Sessions
|
||||
|
||||
# Absolute Sicherheitsnetze pro Tag/Lauf
|
||||
max_comments_per_day: 40
|
||||
# total_likes_limit: 300
|
||||
# total_follows_limit: 50
|
||||
# total_unfollows_limit: 50
|
||||
# total_pm_limit: 10
|
||||
# total_watches_limit: 50
|
||||
# total_successful_interactions_limit: 100
|
||||
# total_interactions_limit: 1000
|
||||
# total_scraped_limit: 200
|
||||
# total_crashes_limit: 5
|
||||
# total_sessions: -1
|
||||
total_likes_limit: 300
|
||||
total_follows_limit: 50
|
||||
speed_multiplier: 1.0
|
||||
|
||||
# ── CRM & Advanced Features ──
|
||||
# features:
|
||||
# scrape_profiles: false # Extrahiere Profil-Bio und speichere im CRM
|
||||
# smart_unfollow: false # AI-Agentic Unfollow von Leuten, die nicht zurückfolgen
|
||||
ignore_close_friends: true # Ignoriere alles (Posts/Stories) von "Enge Freunde"
|
||||
|
||||
# ── Infrastructure & System (Nur für Entwickler) ──
|
||||
device: 192.168.1.206:34201
|
||||
# ── Infrastructure & System ──
|
||||
device: 192.168.1.206:40505
|
||||
app-id: com.instagram.android
|
||||
debug: true
|
||||
blank_start: true
|
||||
|
||||
speed-multiplier: 1.0 # >1.0 macht den Bot schneller (Achtung: unnatürlich)
|
||||
# handedness: "right" # "right" oder "left", beeinflusst die Krümmung des Swipes
|
||||
# restart_atx_agent: false # UIA2 Server auf dem Handy neu starten bei Problemen
|
||||
# allow_untested_ig_version: false
|
||||
blank_start: true # ACHTUNG: Löscht die komplette Qdrant Navigations-Memory beim Start!
|
||||
|
||||
# ── AI Model Endpoints (Explicit configuration, no defaults) ──
|
||||
# ── AI Model Endpoints (Ollama / OpenRouter) ──
|
||||
ai-model: qwen3.5:latest
|
||||
ai-model-url: http://localhost:11434/api/generate
|
||||
|
||||
ai-telepathic-model: llama3.2-vision
|
||||
ai-telepathic-url: http://localhost:11434/api/generate
|
||||
|
||||
ai-condenser-model: qwen3.5:latest
|
||||
ai-condenser-url: http://localhost:11434/api/generate
|
||||
|
||||
ai-embedding-model: nomic-embed-text
|
||||
ai-embedding-url: http://localhost:11434/api/embeddings
|
||||
|
||||
ai-fallback-model: qwen3.5:latest
|
||||
ai-fallback-url: http://localhost:11434/api/generate
|
||||
|
||||
@@ -1,145 +1,159 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import sys
|
||||
# Force mock qdrant_client before importing any core modules that depend on it
|
||||
|
||||
from GramAddict.core.bot_flow import _extract_post_content, _run_zero_latency_feed_loop
|
||||
|
||||
|
||||
class TestBotFlowEdgeCases:
|
||||
|
||||
@patch('GramAddict.core.telepathic_engine.TelepathicEngine.get_instance')
|
||||
@patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance")
|
||||
def test_extract_post_content_edge_cases(self, mock_get_telepathic):
|
||||
mock_engine = MagicMock()
|
||||
mock_get_telepathic.return_value = mock_engine
|
||||
|
||||
|
||||
# 1. Empty string / Invalid XML should not crash (mock finds nothing)
|
||||
mock_engine.find_best_node.return_value = None
|
||||
res = _extract_post_content("")
|
||||
assert res.get("username") == ""
|
||||
assert res.get("description") == ""
|
||||
|
||||
|
||||
# 2. Extract when only username exists
|
||||
# Side effect: first call (author) returns node, second (media) returns None
|
||||
mock_engine.find_best_node.side_effect = [{"original_attribs": {"text": "just_user"}}, None]
|
||||
res = _extract_post_content("<xml/>")
|
||||
assert res.get("username") == "just_user"
|
||||
assert res.get("description") == ""
|
||||
|
||||
|
||||
# 3. Extract description
|
||||
mock_engine.find_best_node.side_effect = [None, {"original_attribs": {"desc": "🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥"}}]
|
||||
res = _extract_post_content("<xml/>")
|
||||
assert res.get("description") == "🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥"
|
||||
|
||||
|
||||
# 4. Another valid description tag
|
||||
mock_engine.find_best_node.side_effect = [None, {"original_attribs": {"desc": "some desc with more than 10 chars limits"}}]
|
||||
mock_engine.find_best_node.side_effect = [
|
||||
None,
|
||||
{"original_attribs": {"desc": "some desc with more than 10 chars limits"}},
|
||||
]
|
||||
res = _extract_post_content("<xml/>")
|
||||
assert res.get("description") == "some desc with more than 10 chars limits"
|
||||
|
||||
@patch('GramAddict.core.bot_flow.random.random', return_value=0.5)
|
||||
@patch('GramAddict.core.bot_flow.random.uniform', return_value=1.5)
|
||||
@patch('GramAddict.core.bot_flow.sleep')
|
||||
@patch('GramAddict.core.bot_flow._humanized_scroll')
|
||||
@patch('GramAddict.core.bot_flow.dump_ui_state')
|
||||
@patch('GramAddict.core.bot_flow.is_ad')
|
||||
@patch('GramAddict.core.bot_flow._align_active_post')
|
||||
@patch('GramAddict.core.telepathic_engine.TelepathicEngine.get_instance')
|
||||
def test_zero_node_recovery(self, mock_get_telepathic, mock_align, mock_ad, mock_dump, mock_scroll, mock_sleep, mock_uniform, mock_random):
|
||||
@patch("GramAddict.core.bot_flow.random.random", return_value=0.5)
|
||||
@patch("GramAddict.core.bot_flow.random.uniform", return_value=1.5)
|
||||
@patch("GramAddict.core.bot_flow.sleep")
|
||||
@patch("GramAddict.core.bot_flow._humanized_scroll")
|
||||
@patch("GramAddict.core.bot_flow.dump_ui_state")
|
||||
@patch("GramAddict.core.bot_flow.is_ad")
|
||||
@patch("GramAddict.core.bot_flow._align_active_post")
|
||||
@patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance")
|
||||
def test_zero_node_recovery(
|
||||
self, mock_get_telepathic, mock_align, mock_ad, mock_dump, mock_scroll, mock_sleep, mock_uniform, mock_random
|
||||
):
|
||||
# Tests the explicit Zero-Node Recovery added previously
|
||||
device = MagicMock()
|
||||
zero_engine = MagicMock()
|
||||
nav_graph = MagicMock()
|
||||
configs = MagicMock()
|
||||
session_state = MagicMock()
|
||||
|
||||
|
||||
mock_ad.return_value = False
|
||||
mock_align.return_value = False
|
||||
|
||||
|
||||
cognitive_stack = {
|
||||
"dopamine": MagicMock(),
|
||||
"darwin": MagicMock(),
|
||||
"resonance": MagicMock(),
|
||||
"active_inference": MagicMock(),
|
||||
"growth_brain": MagicMock(),
|
||||
"swarm": MagicMock()
|
||||
"swarm": MagicMock(),
|
||||
}
|
||||
|
||||
|
||||
# Dopamine breaks loop after 1st iteration
|
||||
cognitive_stack["dopamine"].is_app_session_over.side_effect = [False, True]
|
||||
cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
|
||||
cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
|
||||
|
||||
|
||||
# Fake extreme limits => doesn't break limits
|
||||
session_state.check_limit.return_value = [False]*10
|
||||
|
||||
session_state.check_limit.return_value = [False] * 10
|
||||
|
||||
# Telepathic Engine returns ZERO nodes on extract
|
||||
mock_engine = MagicMock()
|
||||
mock_engine._extract_semantic_nodes.return_value = []
|
||||
mock_get_telepathic.return_value = mock_engine
|
||||
|
||||
|
||||
device.dump_hierarchy.return_value = "<xml></xml>"
|
||||
|
||||
|
||||
# Execute the main loop
|
||||
_run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session_state, "HomeFeed", cognitive_stack)
|
||||
|
||||
|
||||
# It should trigger device.press("back") and then _humanized_scroll
|
||||
device.press.assert_called_with("back")
|
||||
assert mock_scroll.call_count >= 1
|
||||
|
||||
@patch('GramAddict.core.bot_flow.random.random', return_value=0.5)
|
||||
@patch('GramAddict.core.bot_flow.random.uniform', return_value=1.5)
|
||||
@patch('GramAddict.core.bot_flow.sleep')
|
||||
@patch('GramAddict.core.bot_flow._humanized_scroll')
|
||||
@patch('GramAddict.core.bot_flow.dump_ui_state')
|
||||
@patch('GramAddict.core.bot_flow._extract_post_content')
|
||||
@patch('GramAddict.core.bot_flow.is_ad')
|
||||
@patch('GramAddict.core.bot_flow._align_active_post')
|
||||
@patch('GramAddict.core.telepathic_engine.TelepathicEngine.get_instance')
|
||||
def test_content_extraction_failed_recovery(self, mock_get_telepathic, mock_align, mock_ad, mock_extract, mock_dump, mock_scroll, mock_sleep, mock_uniform, mock_random):
|
||||
|
||||
@patch("GramAddict.core.bot_flow.random.random", return_value=0.5)
|
||||
@patch("GramAddict.core.bot_flow.random.uniform", return_value=1.5)
|
||||
@patch("GramAddict.core.bot_flow.sleep")
|
||||
@patch("GramAddict.core.bot_flow._humanized_scroll")
|
||||
@patch("GramAddict.core.bot_flow.dump_ui_state")
|
||||
@patch("GramAddict.core.bot_flow._extract_post_content")
|
||||
@patch("GramAddict.core.bot_flow.is_ad")
|
||||
@patch("GramAddict.core.bot_flow._align_active_post")
|
||||
@patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance")
|
||||
def test_content_extraction_failed_recovery(
|
||||
self,
|
||||
mock_get_telepathic,
|
||||
mock_align,
|
||||
mock_ad,
|
||||
mock_extract,
|
||||
mock_dump,
|
||||
mock_scroll,
|
||||
mock_sleep,
|
||||
mock_uniform,
|
||||
mock_random,
|
||||
):
|
||||
device = MagicMock()
|
||||
zero_engine = MagicMock()
|
||||
nav_graph = MagicMock()
|
||||
configs = MagicMock()
|
||||
session_state = MagicMock()
|
||||
|
||||
|
||||
mock_ad.return_value = False
|
||||
mock_align.return_value = False
|
||||
|
||||
cognitive_stack = {
|
||||
"dopamine": MagicMock(),
|
||||
"darwin": MagicMock()
|
||||
}
|
||||
|
||||
cognitive_stack = {"dopamine": MagicMock(), "darwin": MagicMock()}
|
||||
# break after 1 loop
|
||||
cognitive_stack["dopamine"].is_app_session_over.side_effect = [False, True]
|
||||
cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
|
||||
cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
|
||||
session_state.check_limit.return_value = [False]*10
|
||||
|
||||
session_state.check_limit.return_value = [False] * 10
|
||||
|
||||
# Ensure it HAS feed markers
|
||||
device.dump_hierarchy.return_value = "<xml>row_feed_photo_profile_name</xml>"
|
||||
|
||||
|
||||
# Ensure interactive_nodes is NOT zero
|
||||
mock_engine = MagicMock()
|
||||
mock_engine._extract_semantic_nodes.return_value = [{"x": 10}]
|
||||
mock_get_telepathic.return_value = mock_engine
|
||||
|
||||
|
||||
# Make the extraction fail
|
||||
mock_extract.return_value = {"username": "", "description": ""}
|
||||
|
||||
|
||||
_run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session_state, "HomeFeed", cognitive_stack)
|
||||
|
||||
|
||||
# Should call mock_scroll (Graceful degradation)
|
||||
mock_scroll.assert_called_once()
|
||||
mock_dump.assert_called_with(device, "content_extraction_failed", {"feed": "HomeFeed"})
|
||||
|
||||
@patch('GramAddict.core.bot_flow.sleep')
|
||||
@patch('GramAddict.core.bot_flow._humanized_scroll')
|
||||
@patch('GramAddict.core.bot_flow.is_ad')
|
||||
@patch('GramAddict.core.bot_flow._align_active_post')
|
||||
@patch('GramAddict.core.bot_flow._extract_post_content')
|
||||
@patch('GramAddict.core.telepathic_engine.TelepathicEngine.get_instance')
|
||||
@patch('GramAddict.core.llm_provider.query_llm')
|
||||
def test_llm_timeout_handled_smoothly(self, mock_query_llm, mock_get_telepathic, mock_extract, mock_align, mock_ad, mock_scroll, mock_sleep):
|
||||
@patch("GramAddict.core.bot_flow.sleep")
|
||||
@patch("GramAddict.core.bot_flow._humanized_scroll")
|
||||
@patch("GramAddict.core.bot_flow.is_ad")
|
||||
@patch("GramAddict.core.bot_flow._align_active_post")
|
||||
@patch("GramAddict.core.bot_flow._extract_post_content")
|
||||
@patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance")
|
||||
@patch("GramAddict.core.llm_provider.query_llm")
|
||||
def test_llm_timeout_handled_smoothly(
|
||||
self, mock_query_llm, mock_get_telepathic, mock_extract, mock_align, mock_ad, mock_scroll, mock_sleep
|
||||
):
|
||||
"""
|
||||
TDD Test: Verifies that if qwen3.5:latest times out during comment generation
|
||||
(simulated by query_llm returning None after circuit breaker), the bot_flow
|
||||
@@ -150,43 +164,40 @@ class TestBotFlowEdgeCases:
|
||||
nav_graph = MagicMock()
|
||||
configs = MagicMock()
|
||||
session_state = MagicMock()
|
||||
|
||||
|
||||
mock_ad.return_value = False
|
||||
mock_align.return_value = False
|
||||
|
||||
|
||||
# Make the LLM generation completely timeout and return None
|
||||
mock_query_llm.return_value = None
|
||||
|
||||
cognitive_stack = {
|
||||
"dopamine": MagicMock(),
|
||||
"darwin": MagicMock(),
|
||||
"resonance": MagicMock()
|
||||
}
|
||||
|
||||
cognitive_stack = {"dopamine": MagicMock(), "darwin": MagicMock(), "resonance": MagicMock()}
|
||||
# break after 1 loop
|
||||
cognitive_stack["dopamine"].is_app_session_over.side_effect = [False, True]
|
||||
cognitive_stack["dopamine"].wants_to_change_feed.return_value = False
|
||||
cognitive_stack["dopamine"].wants_to_doomscroll.return_value = False
|
||||
|
||||
|
||||
# Emulate that dopamine WANTS to comment
|
||||
cognitive_stack["dopamine"].get_action_desires.return_value = {"comment": True, "like": False}
|
||||
|
||||
|
||||
# Avoid MagicMock comparison errors in Resonance Engine
|
||||
cognitive_stack["resonance"].calculate_resonance.return_value = 0.8
|
||||
|
||||
session_state.check_limit.return_value = [False]*10
|
||||
|
||||
|
||||
session_state.check_limit.return_value = [False] * 10
|
||||
|
||||
device.dump_hierarchy.return_value = "<xml>row_feed_photo_profile_name</xml>"
|
||||
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine._extract_semantic_nodes.return_value = [{"x": 10}]
|
||||
mock_get_telepathic.return_value = mock_engine
|
||||
|
||||
|
||||
# Valid post content so it proceeds to comment generation
|
||||
mock_extract.return_value = {"username": "test_user", "description": "a long enough description"}
|
||||
|
||||
|
||||
# Run feed loop - MUST NOT CRASH
|
||||
try:
|
||||
_run_zero_latency_feed_loop(device, zero_engine, nav_graph, configs, session_state, "HomeFeed", cognitive_stack)
|
||||
_run_zero_latency_feed_loop(
|
||||
device, zero_engine, nav_graph, configs, session_state, "HomeFeed", cognitive_stack
|
||||
)
|
||||
except Exception as e:
|
||||
pytest.fail(f"Feed loop crashed on LLM timeout with {e}")
|
||||
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.resonance_engine import ResonanceEngine
|
||||
from GramAddict.core.darwin_engine import DarwinEngine
|
||||
from GramAddict.core.growth_brain import GrowthBrain
|
||||
from GramAddict.core.resonance_engine import ResonanceEngine
|
||||
|
||||
|
||||
class TestCognitiveEdgeCases:
|
||||
|
||||
# Resonance Engine
|
||||
def test_resonance_edge_cases(self):
|
||||
engine = ResonanceEngine(my_username="test_user")
|
||||
|
||||
|
||||
# 1. Empty strings shouldn't crash
|
||||
assert engine.calculate_resonance({"description": "", "username": ""}) == 0.5
|
||||
|
||||
|
||||
# 2. Very long descriptions
|
||||
long_str = "word " * 10000
|
||||
res = engine.calculate_resonance({"description": long_str})
|
||||
assert isinstance(res, float)
|
||||
|
||||
|
||||
# 3. None values
|
||||
score = engine.calculate_resonance({"description": None})
|
||||
assert score == 0.5
|
||||
@@ -25,33 +23,32 @@ class TestCognitiveEdgeCases:
|
||||
# Darwin Engine
|
||||
def test_darwin_edge_cases(self):
|
||||
engine = DarwinEngine("test_user")
|
||||
|
||||
|
||||
# 1. synthesize interaction with 0.0
|
||||
prof = engine.synthesize_interaction_profile(0.0)
|
||||
assert prof["initial_dwell_sec"] > 0
|
||||
|
||||
|
||||
# 2. Negative resonance (should default upwards or bound)
|
||||
prof_neg = engine.synthesize_interaction_profile(-10.0)
|
||||
assert prof_neg["initial_dwell_sec"] > 0
|
||||
|
||||
|
||||
# 3. Extreme resonance
|
||||
prof_max = engine.synthesize_interaction_profile(10.0) # > 1.0
|
||||
prof_max = engine.synthesize_interaction_profile(10.0) # > 1.0
|
||||
assert prof_max["initial_dwell_sec"] > 0
|
||||
|
||||
|
||||
def test_growth_brain_edge_cases(self):
|
||||
engine = GrowthBrain(username="test")
|
||||
|
||||
|
||||
# 1. Call circadian without history
|
||||
engine.session_history = []
|
||||
pacing = engine.get_circadian_pacing()
|
||||
assert 0.4 <= pacing <= 1.2
|
||||
|
||||
|
||||
# 2. Call with extreme limits
|
||||
engine.session_history = [{"boredom_peak": 100.0, "time": "unknown"}] * 100
|
||||
pacing2 = engine.get_circadian_pacing()
|
||||
assert pacing2 > 0.0
|
||||
|
||||
# 3. Evaluate persona drift with empty outcomes
|
||||
engine.refine_persona([])
|
||||
engine.refine_persona([])
|
||||
# Shouldn't crash
|
||||
|
||||
|
||||
@@ -1,58 +1,65 @@
|
||||
import os
|
||||
import hashlib
|
||||
from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
# Mock directory setup
|
||||
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FIXTURE_DIR = os.path.join(ROOT_DIR, "fixtures")
|
||||
|
||||
|
||||
class ConfigMock:
|
||||
def __init__(self):
|
||||
self.args = MagicMock()
|
||||
self.args.app_id = "com.instagram.android"
|
||||
|
||||
|
||||
def test_fsd_handles_persistent_survey_modal():
|
||||
"""
|
||||
Simulates a case where the bot gets stuck on a survey modal.
|
||||
The FSD (Full Self Driving) anomaly handler should trigger,
|
||||
detect that 'Back' didn't work, and engage TelepathicEngine
|
||||
The FSD (Full Self Driving) anomaly handler should trigger,
|
||||
detect that 'Back' didn't work, and engage TelepathicEngine
|
||||
to find and tap the 'Not Now' or 'Dismiss' button.
|
||||
"""
|
||||
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
|
||||
device = MagicMock()
|
||||
device.app_id = "com.instagram.android"
|
||||
device._get_current_app.return_value = "com.instagram.android"
|
||||
configs = ConfigMock()
|
||||
|
||||
|
||||
# Mock the TelepathicEngine singleton behavior entirely
|
||||
mock_telepathic = MagicMock()
|
||||
mock_telepathic.find_best_node.return_value = {"x": 500, "y": 1400, "semantic": "Not Now"}
|
||||
mock_telepathic._extract_semantic_nodes.return_value = [{"x": 10}]
|
||||
|
||||
|
||||
dopamine = MagicMock()
|
||||
dopamine.is_app_session_over.side_effect = [False, False, True] # Run twice, then exit
|
||||
dopamine.is_app_session_over.side_effect = [False, False, True] # Run twice, then exit
|
||||
dopamine.wants_to_change_feed.return_value = False
|
||||
dopamine.wants_to_doomscroll.return_value = False
|
||||
|
||||
|
||||
ai = MagicMock()
|
||||
ai.get_sleep_modifier.return_value = 1.0
|
||||
cognitive_stack = {"dopamine": dopamine, "growth_brain": None, "active_inference": ai, "telepathic": mock_telepathic}
|
||||
|
||||
cognitive_stack = {
|
||||
"dopamine": dopamine,
|
||||
"growth_brain": None,
|
||||
"active_inference": ai,
|
||||
"telepathic": mock_telepathic,
|
||||
}
|
||||
|
||||
# Load the mock survey modal UI
|
||||
xml_path = os.path.join(FIXTURE_DIR, "survey_modal.xml")
|
||||
with open(xml_path, "r") as f:
|
||||
alien_xml = f.read()
|
||||
device.dump_hierarchy.return_value = alien_xml
|
||||
|
||||
with patch('GramAddict.core.bot_flow.sleep'), \
|
||||
patch('GramAddict.core.bot_flow._humanized_scroll'), \
|
||||
patch('GramAddict.core.telepathic_engine.TelepathicEngine.get_instance', return_value=mock_telepathic):
|
||||
|
||||
result = _run_zero_latency_feed_loop(device, None, MagicMock(), configs, MagicMock(), "HomeFeed", cognitive_stack)
|
||||
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.bot_flow.sleep"),
|
||||
patch("GramAddict.core.bot_flow._humanized_scroll"),
|
||||
patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance", return_value=mock_telepathic),
|
||||
):
|
||||
result = _run_zero_latency_feed_loop(
|
||||
device, None, MagicMock(), configs, MagicMock(), "HomeFeed", cognitive_stack
|
||||
)
|
||||
|
||||
# VERIFICATION:
|
||||
# Handler should have called Telepathic after 2 misses
|
||||
assert mock_telepathic.find_best_node.called
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
TDD Tests for Zero-Hardcode Screen Classification and Situational Awareness
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
|
||||
|
||||
from GramAddict.core.goap import ScreenIdentity, ScreenType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_screen_memory():
|
||||
with patch("GramAddict.core.qdrant_memory.ScreenMemoryDB") as mock_db:
|
||||
@@ -18,32 +20,40 @@ def mock_screen_memory():
|
||||
instance.is_connected = True
|
||||
yield instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_query_llm():
|
||||
with patch("GramAddict.core.llm_provider.query_llm") as mock_llm:
|
||||
yield mock_llm
|
||||
|
||||
|
||||
def test_classify_screen_uses_memory(mock_screen_memory, mock_query_llm):
|
||||
"""
|
||||
Test that _classify_screen FIRST tries to hit the ScreenMemoryDB.
|
||||
"""
|
||||
si = ScreenIdentity("testbot")
|
||||
|
||||
|
||||
# Mock that memory ALREADY knows this screen
|
||||
mock_screen_memory.get_screen_type.return_value = ScreenType.MODAL.name
|
||||
|
||||
|
||||
# We pass random strings that would previously fail or hit hardcoded checks
|
||||
res = si._classify_screen(
|
||||
ids=set(), descs=[], texts=["totally ambiguous text"],
|
||||
selected_tab=None, desc_lower="", text_lower="",
|
||||
ids_str="random_id", signature="MOCK_SIGNATURE"
|
||||
ids=set(),
|
||||
descs=[],
|
||||
texts=["totally ambiguous text"],
|
||||
selected_tab=None,
|
||||
desc_lower="",
|
||||
text_lower="",
|
||||
ids_str="random_id",
|
||||
signature="MOCK_SIGNATURE",
|
||||
)
|
||||
|
||||
|
||||
assert res == ScreenType.MODAL
|
||||
mock_screen_memory.get_screen_type.assert_called_once_with("MOCK_SIGNATURE", similarity_threshold=0.92)
|
||||
# Should not fall back to LLM if memory hits
|
||||
mock_query_llm.assert_not_called()
|
||||
|
||||
|
||||
def test_classify_screen_uses_llm_fallback_and_learns(mock_screen_memory, mock_query_llm):
|
||||
"""
|
||||
Test that if memory misses, it uses LLM fallback and caches the result.
|
||||
@@ -51,13 +61,18 @@ def test_classify_screen_uses_llm_fallback_and_learns(mock_screen_memory, mock_q
|
||||
si = ScreenIdentity("testbot")
|
||||
mock_screen_memory.get_screen_type.return_value = None
|
||||
mock_query_llm.return_value = {"response": "HOME_FEED"}
|
||||
|
||||
|
||||
res = si._classify_screen(
|
||||
ids={'random'}, descs=[], texts=[],
|
||||
selected_tab=None, desc_lower="", text_lower="",
|
||||
ids_str="random", signature="MOCK_SIGNATURE_2"
|
||||
ids={"random"},
|
||||
descs=[],
|
||||
texts=[],
|
||||
selected_tab=None,
|
||||
desc_lower="",
|
||||
text_lower="",
|
||||
ids_str="random",
|
||||
signature="MOCK_SIGNATURE_2",
|
||||
)
|
||||
|
||||
|
||||
assert res == ScreenType.HOME_FEED
|
||||
mock_query_llm.assert_called_once()
|
||||
mock_screen_memory.store_screen.assert_called_once_with("MOCK_SIGNATURE_2", "HOME_FEED")
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import pytest
|
||||
import os
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
from GramAddict.core.bot_flow import _wait_for_post_loaded, _run_zero_latency_feed_loop, FEED_MARKERS
|
||||
from GramAddict.core.device_facade import DeviceFacade
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.bot_flow import FEED_MARKERS, _run_zero_latency_feed_loop, _wait_for_post_loaded
|
||||
|
||||
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DUMPS = {
|
||||
@@ -12,14 +13,17 @@ DUMPS = {
|
||||
}
|
||||
FIXTURE_DIR = os.path.join(ROOT_DIR, "fixtures")
|
||||
|
||||
|
||||
def mutate_xml_to_foreign(xml_content: str) -> str:
|
||||
"""Removes meaningful text content to simulate a language failure or empty state."""
|
||||
import re
|
||||
|
||||
# Strip text and content-desc
|
||||
xml = re.sub(r'text="[^"]*"', 'text=""', xml_content)
|
||||
xml = re.sub(r'content-desc="[^"]*"', 'content-desc=""', xml)
|
||||
return xml
|
||||
|
||||
|
||||
def mutate_xml_remove_feed_markers(xml_content: str) -> str:
|
||||
"""Removes all feed markers to simulate a grid view or random popup."""
|
||||
xml = xml_content
|
||||
@@ -27,21 +31,26 @@ def mutate_xml_remove_feed_markers(xml_content: str) -> str:
|
||||
xml = xml.replace(marker, "some_random_id")
|
||||
return xml
|
||||
|
||||
|
||||
class ConfigMock:
|
||||
def __init__(self):
|
||||
self.args = MagicMock()
|
||||
self.args.interact_percentage = 0
|
||||
self.args.comment_percentage = 0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_dumps():
|
||||
dumps = {}
|
||||
with open(DUMPS["organic"], "r") as f:
|
||||
dumps["post"] = f.read()
|
||||
# Fake explore grid that lacks ALL feed markers
|
||||
dumps["grid"] = '<?xml version="1.0"?><hierarchy><node resource-id="com.instagram.android:id/explore_grid_container" /></hierarchy>'
|
||||
dumps["grid"] = (
|
||||
'<?xml version="1.0"?><hierarchy><node resource-id="com.instagram.android:id/explore_grid_container" /></hierarchy>'
|
||||
)
|
||||
return dumps
|
||||
|
||||
|
||||
def test_slow_loading_post_recovery(test_dumps):
|
||||
"""
|
||||
Test that _wait_for_post_loaded correctly handles a delay where the
|
||||
@@ -50,33 +59,35 @@ def test_slow_loading_post_recovery(test_dumps):
|
||||
device = MagicMock()
|
||||
# Simulate: Grid -> Grid -> Error -> Post
|
||||
device.dump_hierarchy.side_effect = [
|
||||
test_dumps["grid"],
|
||||
test_dumps["grid"],
|
||||
test_dumps["grid"],
|
||||
Exception("uiautomator2 temp failure"),
|
||||
test_dumps["post"]
|
||||
test_dumps["post"],
|
||||
]
|
||||
|
||||
|
||||
# We patch sleep to make the test super fast
|
||||
with patch('GramAddict.core.bot_flow.sleep', return_value=None):
|
||||
start = time.time()
|
||||
with patch("GramAddict.core.bot_flow.sleep", return_value=None):
|
||||
time.time()
|
||||
success = _wait_for_post_loaded(device, timeout=5)
|
||||
# Should return true when it hits the 4th element
|
||||
assert success is True
|
||||
assert device.dump_hierarchy.call_count == 4
|
||||
|
||||
|
||||
def test_wait_timeout_aborts_gracefully(test_dumps):
|
||||
"""Test what happens if the network is so slow it times out entirely."""
|
||||
device = MagicMock()
|
||||
# Always return grid
|
||||
device.dump_hierarchy.return_value = test_dumps["grid"]
|
||||
|
||||
|
||||
# Patch time.time to simulate 6 seconds passing immediately
|
||||
# We add sequence padding because python's logger internally uses time.time()
|
||||
with patch('GramAddict.core.bot_flow.time.time', side_effect=[0, 1, 6, 6, 6, 6, 6, 6, 6, 6]):
|
||||
with patch('GramAddict.core.bot_flow.sleep', return_value=None):
|
||||
with patch("time.time", side_effect=[0, 1, 6, 6, 6, 6, 6, 6, 6, 6]):
|
||||
with patch("GramAddict.core.bot_flow.sleep", return_value=None):
|
||||
success = _wait_for_post_loaded(device, timeout=5)
|
||||
assert success is False
|
||||
|
||||
|
||||
def test_empty_content_extraction_guard(test_dumps):
|
||||
"""
|
||||
Test that if a post is loaded, but it has strange empty text (foreign language or bug),
|
||||
@@ -85,38 +96,47 @@ def test_empty_content_extraction_guard(test_dumps):
|
||||
device = MagicMock()
|
||||
nav_graph = MagicMock()
|
||||
configs = ConfigMock()
|
||||
|
||||
|
||||
# We create a fake active inference engine to just break the loop after 1 iteration
|
||||
ai = MagicMock()
|
||||
# Dopamine engine controls loop exit
|
||||
dopamine = MagicMock()
|
||||
dopamine.is_app_session_over.side_effect = [False, True] # Run once, then exit
|
||||
dopamine.is_app_session_over.side_effect = [False, True] # Run once, then exit
|
||||
dopamine.wants_to_change_feed.return_value = False
|
||||
dopamine.wants_to_doomscroll.return_value = False
|
||||
|
||||
|
||||
cognitive_stack = {
|
||||
"dopamine": dopamine,
|
||||
"active_inference": ai,
|
||||
"resonance": None, "growth_brain": None, "swarm": None, "darwin": None
|
||||
"resonance": None,
|
||||
"growth_brain": None,
|
||||
"swarm": None,
|
||||
"darwin": None,
|
||||
}
|
||||
|
||||
|
||||
# Mutate the post so it has NO text or description
|
||||
broken_xml = mutate_xml_to_foreign(test_dumps["post"])
|
||||
device.dump_hierarchy.return_value = broken_xml
|
||||
|
||||
|
||||
from GramAddict.core.situational_awareness import SituationType
|
||||
with patch('GramAddict.core.bot_flow._humanized_scroll') as mock_scroll, \
|
||||
patch('GramAddict.core.bot_flow.sleep'), \
|
||||
patch('GramAddict.core.situational_awareness.SituationalAwarenessEngine.perceive', return_value=SituationType.NORMAL):
|
||||
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.bot_flow._humanized_scroll") as mock_scroll,
|
||||
patch("GramAddict.core.bot_flow.sleep"),
|
||||
patch(
|
||||
"GramAddict.core.situational_awareness.SituationalAwarenessEngine.perceive",
|
||||
return_value=SituationType.NORMAL,
|
||||
),
|
||||
):
|
||||
result = _run_zero_latency_feed_loop(device, None, nav_graph, configs, MagicMock(), "HomeFeed", cognitive_stack)
|
||||
|
||||
|
||||
# Ensure scroll was called (the recovery mechanism)
|
||||
assert mock_scroll.called
|
||||
# Check that we never called resonance evaluation because we broke early
|
||||
assert not ai.predict_state.called
|
||||
assert result == "FEED_EXHAUSTED"
|
||||
|
||||
|
||||
def test_missing_feed_markers_guard(test_dumps):
|
||||
"""
|
||||
Test that if the UI is completely foreign (e.g., a system popup),
|
||||
@@ -124,23 +144,23 @@ def test_missing_feed_markers_guard(test_dumps):
|
||||
"""
|
||||
device = MagicMock()
|
||||
configs = ConfigMock()
|
||||
|
||||
|
||||
dopamine = MagicMock()
|
||||
dopamine.is_app_session_over.side_effect = [False, True]
|
||||
dopamine.wants_to_change_feed.return_value = False
|
||||
dopamine.wants_to_doomscroll.return_value = False
|
||||
|
||||
|
||||
cognitive_stack = {"dopamine": dopamine, "growth_brain": None, "active_inference": None}
|
||||
|
||||
|
||||
# Mutate XML to remove all FEED MARKERS
|
||||
alien_xml = mutate_xml_remove_feed_markers(test_dumps["post"])
|
||||
device.dump_hierarchy.return_value = alien_xml
|
||||
|
||||
with patch('GramAddict.core.bot_flow._humanized_scroll') as mock_scroll, \
|
||||
patch('GramAddict.core.bot_flow.sleep'):
|
||||
|
||||
with patch("GramAddict.core.bot_flow._humanized_scroll"), patch("GramAddict.core.bot_flow.sleep"):
|
||||
_run_zero_latency_feed_loop(device, None, MagicMock(), configs, MagicMock(), "HomeFeed", cognitive_stack)
|
||||
|
||||
@patch('GramAddict.core.device_facade.u2')
|
||||
|
||||
|
||||
@patch("GramAddict.core.device_facade.u2")
|
||||
def test_xpath_watcher_initialization(mock_u2):
|
||||
"""
|
||||
Test fixing the critical watcher API bug.
|
||||
@@ -148,21 +168,22 @@ def test_xpath_watcher_initialization(mock_u2):
|
||||
"""
|
||||
mock_d = MagicMock()
|
||||
mock_u2.connect.return_value = mock_d
|
||||
|
||||
|
||||
# Setup mock chain: deviceV2.watcher("crash_dialog").when(...)
|
||||
mock_watcher = MagicMock()
|
||||
mock_d.watcher.return_value = mock_watcher
|
||||
mock_when = MagicMock()
|
||||
mock_watcher.when.return_value = mock_when
|
||||
|
||||
|
||||
# Just init the facade
|
||||
from GramAddict.core.device_facade import create_device
|
||||
device = create_device("fake_serial", "com.fake.app", MagicMock())
|
||||
|
||||
|
||||
create_device("fake_serial", "com.fake.app", MagicMock())
|
||||
|
||||
# Verify exact API call structure for XPath
|
||||
mock_d.watcher.assert_any_call("crash_dialog")
|
||||
mock_d.watcher.assert_any_call("system_dialog")
|
||||
|
||||
|
||||
# We can't perfectly assert the chained arguments natively without a bit of inspection,
|
||||
# but we can verify it didn't crash and called start
|
||||
assert mock_d.watcher.start.called
|
||||
|
||||
@@ -4,27 +4,31 @@ Instagram can detect standard `uniform` distributed clicks as bot-like.
|
||||
This test ensures our click distributions follow a proper biological Gaussian curve.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Ensure the GramAddict module is reachable
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
|
||||
|
||||
import numpy as np
|
||||
|
||||
from GramAddict.core.device_facade import DeviceFacade
|
||||
|
||||
|
||||
class MockDeviceFacade(DeviceFacade):
|
||||
def __init__(self):
|
||||
self.clicks = []
|
||||
|
||||
|
||||
def human_click(self, x, y):
|
||||
self.clicks.append((x, y))
|
||||
|
||||
|
||||
class MockNode:
|
||||
def bounds(self):
|
||||
# returns left, top, right, bottom
|
||||
return (100, 500, 300, 600) # Width = 200, Height = 100
|
||||
|
||||
|
||||
def test_gaussian_distribution():
|
||||
device = MockDeviceFacade()
|
||||
node = MockNode()
|
||||
@@ -32,30 +36,31 @@ def test_gaussian_distribution():
|
||||
# Simulate 10,000 clicks
|
||||
for _ in range(10000):
|
||||
device.click(obj=node)
|
||||
|
||||
|
||||
xs = [c[0] for c in device.clicks]
|
||||
ys = [c[1] for c in device.clicks]
|
||||
|
||||
|
||||
mean_x = np.mean(xs)
|
||||
std_x = np.std(xs)
|
||||
|
||||
|
||||
mean_y = np.mean(ys)
|
||||
std_y = np.std(ys)
|
||||
|
||||
|
||||
print(f"Total Clicks: {len(device.clicks)}")
|
||||
print(f"X -> Mean: {mean_x:.2f} (Expected ~190 based on thumb bias), StdDev: {std_x:.2f} (Expected ~30)")
|
||||
print(f"Y -> Mean: {mean_y:.2f} (Expected ~555 based on thumb bias), StdDev: {std_y:.2f} (Expected ~15)")
|
||||
|
||||
|
||||
# Assertions
|
||||
assert 185 <= mean_x <= 195, "X Mean does not reflect the 45% thumb bias."
|
||||
assert 550 <= mean_y <= 560, "Y Mean does not reflect the 55% thumb bias."
|
||||
|
||||
|
||||
# Check for Normal Distribution using a simple heuristic (68-95-99.7 rule)
|
||||
within_1_std = sum(1 for x in xs if mean_x - std_x <= x <= mean_x + std_x) / len(xs)
|
||||
print(f"{within_1_std*100:.2f}% of X clicks within 1 standard deviation (should be ~68%)")
|
||||
assert 0.65 <= within_1_std <= 0.72, "Distribution is not Gaussian!"
|
||||
|
||||
|
||||
print("SUCCESS: Clicks pass the hardware anti-bot anomaly check!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_gaussian_distribution()
|
||||
|
||||
@@ -1,47 +1,50 @@
|
||||
import pytest
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.device_facade import DeviceFacade
|
||||
|
||||
|
||||
def test_adb_retry_recovers_from_transient_error():
|
||||
# Attempt simulated disconnect on dump_hierarchy
|
||||
device_id = "test"
|
||||
app_id = "test"
|
||||
|
||||
with patch('uiautomator2.connect') as mock_connect:
|
||||
|
||||
with patch("uiautomator2.connect") as mock_connect:
|
||||
mock_device = MagicMock()
|
||||
mock_connect.return_value = mock_device
|
||||
|
||||
|
||||
facade = DeviceFacade(device_id, app_id, None)
|
||||
|
||||
|
||||
# Make the first 2 calls fail, the 3rd one pass
|
||||
mock_device.dump_hierarchy.side_effect = [
|
||||
Exception("ConnectError uiautomator2"),
|
||||
Exception("RPC Error"),
|
||||
"<hierarchy></hierarchy>"
|
||||
"<hierarchy></hierarchy>",
|
||||
]
|
||||
|
||||
|
||||
# Patch sleep to speed up test
|
||||
with patch('GramAddict.core.device_facade.sleep'):
|
||||
with patch("GramAddict.core.device_facade.sleep"):
|
||||
res = facade.dump_hierarchy()
|
||||
assert res == "<hierarchy></hierarchy>"
|
||||
assert mock_device.dump_hierarchy.call_count == 3
|
||||
|
||||
|
||||
def test_adb_retry_crashes_gracefully_after_all_retries():
|
||||
# Attempt simulated disconnect on dump_hierarchy
|
||||
device_id = "test"
|
||||
app_id = "test"
|
||||
|
||||
with patch('uiautomator2.connect') as mock_connect:
|
||||
|
||||
with patch("uiautomator2.connect") as mock_connect:
|
||||
mock_device = MagicMock()
|
||||
mock_connect.return_value = mock_device
|
||||
|
||||
|
||||
facade = DeviceFacade(device_id, app_id, None)
|
||||
|
||||
|
||||
# Always fail
|
||||
mock_device.dump_hierarchy.side_effect = Exception("Permanent ConnectError")
|
||||
|
||||
with patch('GramAddict.core.device_facade.sleep'):
|
||||
|
||||
with patch("GramAddict.core.device_facade.sleep"):
|
||||
with pytest.raises(Exception, match="Permanent ConnectError"):
|
||||
facade.dump_hierarchy()
|
||||
assert mock_device.dump_hierarchy.call_count == 3
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import unittest
|
||||
import sys
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
# Add parent dir to path
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
|
||||
class DummyDevice:
|
||||
class DeviceV2:
|
||||
def __init__(self):
|
||||
@@ -14,27 +15,29 @@ class DummyDevice:
|
||||
|
||||
def click(self, x, y):
|
||||
self.last_click = (x, y)
|
||||
|
||||
|
||||
def screenshot(self, path=None):
|
||||
return "fake_screenshot"
|
||||
|
||||
def __init__(self):
|
||||
import unittest
|
||||
|
||||
self.deviceV2 = self.DeviceV2()
|
||||
self.app_id = "com.instagram.android"
|
||||
self.args = unittest.mock.MagicMock()
|
||||
self.args.ai_telepathic_model = "qwen2.5:3b"
|
||||
self.args.ai_telepathic_url = "http://localhost:11434/api/generate"
|
||||
|
||||
|
||||
def _get_current_app(self):
|
||||
return "com.instagram.android"
|
||||
|
||||
|
||||
def get_info(self):
|
||||
return {"displayHeight": 2400, "displayWidth": 1080}
|
||||
|
||||
|
||||
def screenshot(self, path=None):
|
||||
return "fake_screenshot"
|
||||
|
||||
|
||||
class TestHumanHesitation(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.telepathic = TelepathicEngine()
|
||||
@@ -42,12 +45,12 @@ class TestHumanHesitation(unittest.TestCase):
|
||||
|
||||
def test_discard_dialog_extraction(self):
|
||||
"""
|
||||
Prove that the Telepathic Engine can correctly identify the 'Discard'
|
||||
button inside a synthetic XML dump, ensuring the 'Umentscheidung'
|
||||
Prove that the Telepathic Engine can correctly identify the 'Discard'
|
||||
button inside a synthetic XML dump, ensuring the 'Umentscheidung'
|
||||
abort logic works in the wild.
|
||||
"""
|
||||
# Synthetic Discard Dialog XML
|
||||
synthetic_dump = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
synthetic_dump = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node index="0" bounds="[0,0][1080,2400]" package="com.instagram.android">
|
||||
<node index="1" class="android.widget.TextView" text="Discard Comment?" bounds="[200,1000][800,1100]" />
|
||||
@@ -55,16 +58,16 @@ class TestHumanHesitation(unittest.TestCase):
|
||||
<node index="3" class="android.widget.Button" text="Verwerfen" content-desc="Discard or Verwerfen popup button" bounds="[600,1200][800,1300]" resource-id="com.instagram.android:id/button_discard" />
|
||||
</node>
|
||||
</hierarchy>
|
||||
'''
|
||||
|
||||
"""
|
||||
|
||||
# Act
|
||||
result = self.telepathic.find_best_node(
|
||||
synthetic_dump,
|
||||
"Discard or Verwerfen popup button to cancel comment",
|
||||
synthetic_dump,
|
||||
"Discard or Verwerfen popup button to cancel comment",
|
||||
device=self.device,
|
||||
min_confidence=0.5
|
||||
min_confidence=0.5,
|
||||
)
|
||||
|
||||
|
||||
# Assert (Should hit the [600,1200][800,1300] box, which centers to (700, 1250))
|
||||
self.assertIsNotNone(result, "Telepathic engine failed to find 'Verwerfen'.")
|
||||
self.assertEqual(result["x"], 700)
|
||||
@@ -75,12 +78,12 @@ class TestHumanHesitation(unittest.TestCase):
|
||||
Verify that teleporting specifically to the Inbox tab (DM button)
|
||||
succeeds if 'Message' describes it.
|
||||
"""
|
||||
synthetic_dump = '''<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
synthetic_dump = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node index="2" text="" id="direct_tab" package="com.instagram.android" content-desc="Direct messages tab button" bounds="[432,2235][648,2361]" resource-id="com.instagram.android:id/direct_tab">
|
||||
<node content-desc=""/>
|
||||
</node>
|
||||
</hierarchy>'''
|
||||
</hierarchy>"""
|
||||
|
||||
# If ID didn't match perfectly, we fall back to description as programmed.
|
||||
# Direct simulation of UI Automator check isn't in scope for this telepathic test,
|
||||
@@ -88,5 +91,6 @@ class TestHumanHesitation(unittest.TestCase):
|
||||
result = self.telepathic.find_best_node(synthetic_dump, "Direct messages tab button", device=self.device)
|
||||
self.assertIsNotNone(result, "Should find the Message tab")
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from GramAddict.core.llm_provider import query_llm
|
||||
from GramAddict.core.resonance_engine import ResonanceEngine
|
||||
|
||||
|
||||
def test_query_llm_hallucination_recovery():
|
||||
# Test that when the primary model hallucinates non-JSON, it triggers fallback
|
||||
with patch('requests.post') as mock_post:
|
||||
with patch("requests.post") as mock_post:
|
||||
# 1st call: Primary fails entirely (e.g., Timeout or strange error)
|
||||
mock_response_1 = MagicMock()
|
||||
mock_response_1.status_code = 500
|
||||
mock_response_1.raise_for_status.side_effect = Exception("500 Server Error")
|
||||
|
||||
|
||||
# 2nd call: Fallback works and returns valid JSON
|
||||
mock_response_2 = MagicMock()
|
||||
mock_response_2.status_code = 200
|
||||
mock_response_2.raise_for_status.return_value = None
|
||||
mock_response_2.json.return_value = {
|
||||
"choices": [{"message": {"content": '{"test": "success"}'}}]
|
||||
}
|
||||
|
||||
mock_response_2.json.return_value = {"choices": [{"message": {"content": '{"test": "success"}'}}]}
|
||||
|
||||
mock_post.side_effect = [mock_response_1, mock_response_2]
|
||||
|
||||
|
||||
# Attempt a query with a primary model
|
||||
res = query_llm(
|
||||
url="http://fake.api/v1/chat/completions",
|
||||
@@ -28,29 +26,27 @@ def test_query_llm_hallucination_recovery():
|
||||
prompt="Hello",
|
||||
format_json=True,
|
||||
fallback_model="fallback-model",
|
||||
fallback_url="http://fake.api/v1/chat/completions"
|
||||
fallback_url="http://fake.api/v1/chat/completions",
|
||||
)
|
||||
|
||||
|
||||
assert res is not None
|
||||
assert "response" in res
|
||||
assert res["response"] == '{"test": "success"}'
|
||||
assert mock_post.call_count == 2
|
||||
|
||||
|
||||
def test_query_llm_double_hallucination_safe_return():
|
||||
# Test that when both models hallucinate, we return None gracefully
|
||||
with patch('requests.post') as mock_post:
|
||||
with patch("requests.post") as mock_post:
|
||||
# Both models fail
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.raise_for_status.side_effect = Exception("500 Server Error")
|
||||
|
||||
|
||||
mock_post.side_effect = [mock_response, mock_response]
|
||||
|
||||
|
||||
res = query_llm(
|
||||
url="http://fake.api/v1/chat/completions",
|
||||
model="primary-model",
|
||||
prompt="Hello",
|
||||
format_json=True
|
||||
url="http://fake.api/v1/chat/completions", model="primary-model", prompt="Hello", format_json=True
|
||||
)
|
||||
|
||||
|
||||
assert res is None
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import pytest
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
|
||||
def test_tap_home_tab_recovery_from_homescreen():
|
||||
"""
|
||||
TDD: Reproduce the failure where tap_home_tab fails because the bot is on
|
||||
the Android Homescreen (app.lawnchair), and verify that it recovers
|
||||
TDD: Reproduce the failure where tap_home_tab fails because the bot is on
|
||||
the Android Homescreen (app.lawnchair), and verify that it recovers
|
||||
via app_start instead of enterring an auto-repair loop.
|
||||
"""
|
||||
# 1. Setup Mock Device
|
||||
@@ -14,32 +14,36 @@ def test_tap_home_tab_recovery_from_homescreen():
|
||||
mock_device.app_id = "com.instagram.android"
|
||||
# Return homescreen package to simulate context loss
|
||||
mock_device._get_current_app.return_value = "app.lawnchair"
|
||||
|
||||
|
||||
# 2. Mock DeviceV2 responses
|
||||
mock_device.dump_hierarchy.return_value = "<hierarchy />"
|
||||
mock_device.app_start.return_value = True
|
||||
|
||||
|
||||
# 3. Initialize NavGraph
|
||||
graph = QNavGraph(mock_device)
|
||||
graph.current_state = "ProfileFeed" # Assume stale state
|
||||
|
||||
graph.current_state = "ProfileFeed" # Assume stale state
|
||||
|
||||
# 4. Patch TelepathicEngine.get_instance to return a mock engine
|
||||
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance") as mock_get_instance, \
|
||||
patch("GramAddict.core.goap.PathMemory.learn_path"), \
|
||||
patch("GramAddict.core.goap.PathMemory.recall_path", return_value=None), \
|
||||
patch("GramAddict.core.qdrant_memory.ScreenMemoryDB._get_embedding", return_value=[0]*1536), \
|
||||
patch("GramAddict.core.situational_awareness.SituationalAwarenessEngine.ensure_clear_screen", return_value=False), \
|
||||
patch("GramAddict.core.q_nav_graph.time.sleep"):
|
||||
with (
|
||||
patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance") as mock_get_instance,
|
||||
patch("GramAddict.core.goap.PathMemory.learn_path"),
|
||||
patch("GramAddict.core.goap.PathMemory.recall_path", return_value=None),
|
||||
patch("GramAddict.core.qdrant_memory.ScreenMemoryDB._get_embedding", return_value=[0] * 1536),
|
||||
patch(
|
||||
"GramAddict.core.situational_awareness.SituationalAwarenessEngine.ensure_clear_screen", return_value=False
|
||||
),
|
||||
patch("GramAddict.core.q_nav_graph.time.sleep"),
|
||||
):
|
||||
mock_engine = MagicMock()
|
||||
mock_get_instance.return_value = mock_engine
|
||||
|
||||
|
||||
# Simulate Context Guard hitting: return None forever
|
||||
mock_engine.find_best_node.return_value = None
|
||||
|
||||
|
||||
# 5. Execute
|
||||
# We expect this to return False gracefully after 3 attempts, without infinitely looping
|
||||
success = graph.navigate_to("ExploreFeed", zero_engine=None)
|
||||
|
||||
|
||||
# 6. Assertion
|
||||
assert not success, "Navigation should fail gracefully when context cannot be recovered"
|
||||
assert mock_device.app_start.called, "Should have force-started the app when context was lost"
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import sys
|
||||
# Force mock qdrant_client before importing any core modules that depend on it
|
||||
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
|
||||
class TestQNavGraphEdgeCases:
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_graph(self):
|
||||
self.device = MagicMock()
|
||||
@@ -15,110 +14,110 @@ class TestQNavGraphEdgeCases:
|
||||
self.device.info = {"screenOn": True}
|
||||
self.device.dump_hierarchy.return_value = '<hierarchy><node package="com.instagram.android" /></hierarchy>'
|
||||
self.device._get_current_app = MagicMock(return_value="com.instagram.android")
|
||||
|
||||
|
||||
# Prevent Dojo engine instantiation during tests
|
||||
with patch('GramAddict.core.compiler_engine.VLMCompilerEngine'):
|
||||
with patch("GramAddict.core.compiler_engine.VLMCompilerEngine"):
|
||||
self.graph = QNavGraph(self.device)
|
||||
|
||||
|
||||
def test_find_path_edge_cases(self):
|
||||
# 1. Start == End
|
||||
assert self.graph._find_path("HomeFeed", "HomeFeed") == []
|
||||
|
||||
|
||||
# 2. Start not in nodes
|
||||
assert self.graph._find_path("UnknownState", "HomeFeed") == None
|
||||
|
||||
assert self.graph._find_path("UnknownState", "HomeFeed") is None
|
||||
|
||||
# 3. Unreachable states
|
||||
self.graph.nodes = {
|
||||
"HomeFeed": {"transitions": {"tap_explore": "ExploreFeed"}},
|
||||
"IsolatedFeed": {"transitions": {}}
|
||||
"IsolatedFeed": {"transitions": {}},
|
||||
}
|
||||
assert self.graph._find_path("HomeFeed", "IsolatedFeed") == None
|
||||
|
||||
assert self.graph._find_path("HomeFeed", "IsolatedFeed") is None
|
||||
|
||||
# 4. Infinite loop protection (A -> B -> A)
|
||||
self.graph.nodes = {
|
||||
"A": {"transitions": {"to_b": "B"}},
|
||||
"B": {"transitions": {"to_a": "A"}}
|
||||
}
|
||||
assert self.graph._find_path("A", "C") == None # Should safely return None without exceeding recursion/loop depth
|
||||
|
||||
self.graph.nodes = {"A": {"transitions": {"to_b": "B"}}, "B": {"transitions": {"to_a": "A"}}}
|
||||
assert (
|
||||
self.graph._find_path("A", "C") is None
|
||||
) # Should safely return None without exceeding recursion/loop depth
|
||||
|
||||
# 5. Longest path possible before unreachability is confirmed
|
||||
assert self.graph._find_path("B", "D") == None
|
||||
|
||||
assert self.graph._find_path("B", "D") is None
|
||||
|
||||
# 6. Diamond shape path
|
||||
self.graph.nodes = {
|
||||
"Start": {"transitions": {"top": "Top", "bottom": "Bottom"}},
|
||||
"Top": {"transitions": {"top_to_end": "End"}},
|
||||
"Bottom": {"transitions": {"bottom_to_end": "End"}},
|
||||
"End": {}
|
||||
"End": {},
|
||||
}
|
||||
# BFS should find shortest path (len 2)
|
||||
assert len(self.graph._find_path("Start", "End")) == 2
|
||||
|
||||
@patch('GramAddict.core.q_nav_graph.time.sleep', return_value=None)
|
||||
@patch('GramAddict.core.q_nav_graph.random_sleep', return_value=None)
|
||||
@patch('GramAddict.core.situational_awareness.random_sleep', return_value=None)
|
||||
@patch('GramAddict.core.telepathic_engine.TelepathicEngine.get_instance')
|
||||
@patch("GramAddict.core.q_nav_graph.time.sleep", return_value=None)
|
||||
@patch("GramAddict.core.q_nav_graph.random_sleep", return_value=None)
|
||||
@patch("GramAddict.core.situational_awareness.random_sleep", return_value=None)
|
||||
@patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance")
|
||||
def test_execute_transition_edge_cases(self, mock_get_telepathic, mock_sae_sleep, mock_q_rand_sleep, mock_q_sleep):
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
mock_engine = MagicMock(spec=TelepathicEngine)
|
||||
mock_get_telepathic.return_value = mock_engine
|
||||
|
||||
|
||||
# Case 1: Telepathic engine finds nothing
|
||||
mock_engine.find_best_node.return_value = None
|
||||
|
||||
|
||||
# If still in Instagram, it returns False
|
||||
self.device._get_current_app.return_value = "com.instagram.android"
|
||||
assert self.graph._execute_transition("unknown_action", mock_engine) == False
|
||||
|
||||
assert not self.graph._execute_transition("unknown_action", mock_engine)
|
||||
|
||||
# If app is different, it returns "CONTEXT_LOST"
|
||||
self.device._get_current_app.return_value = "com.android.launcher3"
|
||||
assert self.graph._execute_transition("unknown_action", mock_engine) == "CONTEXT_LOST"
|
||||
|
||||
|
||||
# Case 2: Best node has skip flag
|
||||
mock_engine.find_best_node.return_value = {"skip": True}
|
||||
assert self.graph._execute_transition("already_done_action", mock_engine) == True
|
||||
|
||||
assert self.graph._execute_transition("already_done_action", mock_engine)
|
||||
|
||||
# Case 3: Proper interaction, but XML doesn't change (verification fail)
|
||||
mock_engine.find_best_node.return_value = {"x": 10, "y": 10, "score": 0.9}
|
||||
same_xml = '<hierarchy><node package="com.instagram.android" class="same" /></hierarchy>'
|
||||
self.device.dump_hierarchy.side_effect = None
|
||||
self.device.dump_hierarchy.return_value = same_xml
|
||||
assert self.graph._execute_transition("click_action", mock_engine) == False
|
||||
assert not self.graph._execute_transition("click_action", mock_engine)
|
||||
assert mock_engine.reject_click.call_count == 3
|
||||
|
||||
|
||||
# Case 4: Proper interaction, XML changes (verification pass)
|
||||
mock_engine.reset_mock()
|
||||
mock_engine.find_best_node.return_value = {"x": 10, "y": 10, "score": 0.9}
|
||||
before_xml = '<hierarchy><node package="com.instagram.android" class="before" /></hierarchy>'
|
||||
after_xml = '<hierarchy><node package="com.instagram.android" class="after" /></hierarchy>'
|
||||
|
||||
|
||||
initial_clicks = self.device.click.call_count
|
||||
|
||||
def dynamic_xml(*args, **kwargs):
|
||||
return after_xml if self.device.click.call_count > initial_clicks else before_xml
|
||||
|
||||
|
||||
self.device.dump_hierarchy.side_effect = dynamic_xml
|
||||
# Explicitly ensure verify_success is truthy
|
||||
mock_engine.verify_success.return_value = True
|
||||
|
||||
assert self.graph._execute_transition("click_action", mock_engine) == True
|
||||
|
||||
assert self.graph._execute_transition("click_action", mock_engine)
|
||||
mock_engine.confirm_click.assert_called_once()
|
||||
|
||||
@patch('GramAddict.core.q_nav_graph.time.sleep', return_value=None)
|
||||
@patch('GramAddict.core.q_nav_graph.random_sleep', return_value=None)
|
||||
@patch('GramAddict.core.situational_awareness.random_sleep', return_value=None)
|
||||
@patch('GramAddict.core.dojo_engine.DojoEngine.get_instance')
|
||||
@patch("GramAddict.core.q_nav_graph.time.sleep", return_value=None)
|
||||
@patch("GramAddict.core.q_nav_graph.random_sleep", return_value=None)
|
||||
@patch("GramAddict.core.situational_awareness.random_sleep", return_value=None)
|
||||
@patch("GramAddict.core.dojo_engine.DojoEngine.get_instance")
|
||||
def test_navigate_to_recovery_edge_cases(self, mock_dojo, mock_sae_sleep, mock_q_rand_sleep, mock_q_sleep):
|
||||
# We test the deepest recovery logic: when everything fails
|
||||
|
||||
|
||||
zero_engine = MagicMock()
|
||||
|
||||
|
||||
# Mock transitions completely failing
|
||||
with patch.object(self.graph.goap, 'navigate_to_screen', return_value=False):
|
||||
with patch.object(self.graph.goap, "navigate_to_screen", return_value=False):
|
||||
# Recovery attempts maxed out
|
||||
assert self.graph.navigate_to("ExploreFeed", zero_engine, recovery_attempts=3) == False
|
||||
|
||||
assert not self.graph.navigate_to("ExploreFeed", zero_engine, recovery_attempts=3)
|
||||
|
||||
# Start logic where path is None and direct fallback also fails
|
||||
self.graph.current_state = "IsolatedNode"
|
||||
# It should trigger fallback and then return False because `navigate_to_screen` always returns False
|
||||
assert self.graph.navigate_to("ExploreFeed", zero_engine, recovery_attempts=0) == False
|
||||
|
||||
assert not self.graph.navigate_to("ExploreFeed", zero_engine, recovery_attempts=0)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
|
||||
|
||||
from GramAddict.core.goap import GoalExecutor, ScreenType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device():
|
||||
device = MagicMock()
|
||||
@@ -15,6 +17,7 @@ def mock_device():
|
||||
device.app_id = "com.instagram.android"
|
||||
return device
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_telepathic():
|
||||
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance") as mock:
|
||||
@@ -22,34 +25,35 @@ def mock_telepathic():
|
||||
engine.find_best_node.return_value = {"x": 100, "y": 200, "semantic_string": "mock_node"}
|
||||
yield engine
|
||||
|
||||
|
||||
def test_execution_rejects_wrong_screen(mock_device, mock_telepathic):
|
||||
"""
|
||||
TDD Case: If we intend to go to DMs but land on Reels,
|
||||
TDD Case: If we intend to go to DMs but land on Reels,
|
||||
TelepathicEngine.confirm_click should NOT be called.
|
||||
"""
|
||||
executor = GoalExecutor(mock_device, "testuser")
|
||||
|
||||
|
||||
# We mock perceive to return ReelsFeed after the click
|
||||
with patch.object(executor, "perceive") as mock_perceive:
|
||||
# Before click
|
||||
mock_perceive.side_effect = [
|
||||
{"screen_type": ScreenType.HOME_FEED}, # Initial
|
||||
{"screen_type": ScreenType.REELS_FEED} # After click (WRONG!)
|
||||
{"screen_type": ScreenType.HOME_FEED}, # Initial
|
||||
{"screen_type": ScreenType.REELS_FEED}, # After click (WRONG!)
|
||||
]
|
||||
|
||||
|
||||
# Action that intends to go to DM_INBOX
|
||||
action = "tap messages tab"
|
||||
|
||||
|
||||
# We need to make sure _execute_action knows the goal is "open messages"
|
||||
# Since _execute_action is usually called from achieve(), we mock that flow
|
||||
|
||||
|
||||
success = executor._execute_action(action, goal="open messages")
|
||||
|
||||
|
||||
# Success should be False because we didn't reach the goal
|
||||
# (Or True if we only care about XML change, but that's what we're changing)
|
||||
assert success is False
|
||||
|
||||
# CRITICAL: confirm_click should NOT have been called for 'messages tab'
|
||||
|
||||
# CRITICAL: confirm_click should NOT have been called for 'messages tab'
|
||||
# since we are on Reels.
|
||||
mock_telepathic.confirm_click.assert_not_called()
|
||||
mock_telepathic.reject_click.assert_called_once_with(action)
|
||||
|
||||
68
tests/anomalies/test_telepathic_guards.py
Normal file
68
tests/anomalies/test_telepathic_guards.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
|
||||
class TestTelepathicGuards:
|
||||
def setup_method(self):
|
||||
self.engine = TelepathicEngine()
|
||||
|
||||
def test_strict_story_ring_guard(self):
|
||||
"""
|
||||
TDD: Story rings MUST be physically near the top of the screen (y < 30%).
|
||||
Post profile headers that appear further down must be aggressively blocked
|
||||
when the intent is 'tap story ring avatar'.
|
||||
"""
|
||||
intent = "tap story ring avatar"
|
||||
screen_height = 2400
|
||||
|
||||
# Valid Story Ring (Top of screen, but below status bar)
|
||||
valid_story = {"resource_id": "reel_ring", "y": 300, "area": 100}
|
||||
assert self.engine._structural_sanity_check(valid_story, intent, screen_height) is True
|
||||
|
||||
# Invalid Story Ring (Hallucination: Post profile header in the feed)
|
||||
invalid_story = {"resource_id": "row_feed_profile_header", "y": 800, "area": 100}
|
||||
assert self.engine._structural_sanity_check(invalid_story, intent, screen_height) is False
|
||||
|
||||
def test_strict_button_guard(self):
|
||||
"""
|
||||
TDD: When explicitly looking for a 'button', nodes that declare themselves
|
||||
as profiles (e.g. 'go to profile') must be blocked, to prevent accidental
|
||||
profile visits when clicking 'like'.
|
||||
"""
|
||||
intent = "Heart like button for comment"
|
||||
screen_height = 2400
|
||||
|
||||
# Valid Like Button
|
||||
valid_btn = {"resource_id": "like_button", "semantic_string": "Like", "y": 1000, "area": 100}
|
||||
assert self.engine._structural_sanity_check(valid_btn, intent, screen_height) is True
|
||||
|
||||
# Invalid Profile Link masquerading as a match due to string proximity
|
||||
invalid_prof = {
|
||||
"resource_id": "username",
|
||||
"semantic_string": "Go to cayleighanddavid's profile",
|
||||
"y": 1000,
|
||||
"area": 100,
|
||||
}
|
||||
assert self.engine._structural_sanity_check(invalid_prof, intent, screen_height) is False
|
||||
|
||||
# However, if the intent *is* profile, it should pass
|
||||
intent_prof = "go to profile"
|
||||
assert self.engine._structural_sanity_check(invalid_prof, intent_prof, screen_height) is True
|
||||
|
||||
def test_like_semantic_verification(self):
|
||||
"""
|
||||
TDD: Verify that 'unlike' is treated as a successful 'Like' action,
|
||||
because tapping 'Like' changes the state to 'Unlike' in English Instagram.
|
||||
"""
|
||||
# Testing the specific regex logic inside verify_success
|
||||
import re
|
||||
|
||||
xml_dump_success = '<node class="android.widget.ImageView" content-desc="Unlike" />'
|
||||
|
||||
marker_found = re.search(r"\b(liked|unlike|gefällt mir nicht mehr|gefällt mir am)\b", xml_dump_success.lower())
|
||||
assert marker_found is not None
|
||||
|
||||
xml_dump_fail = '<node class="android.widget.ImageView" content-desc="Like" />'
|
||||
marker_found_fail = re.search(
|
||||
r"\b(liked|unlike|gefällt mir nicht mehr|gefällt mir am)\b", xml_dump_fail.lower()
|
||||
)
|
||||
assert marker_found_fail is None
|
||||
@@ -1,60 +1,60 @@
|
||||
import sys
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import types
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')))
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
|
||||
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
|
||||
class TestTrapEscape(unittest.TestCase):
|
||||
@patch('GramAddict.core.q_nav_graph.time.sleep', return_value=None)
|
||||
@patch('GramAddict.core.q_nav_graph.random_sleep', return_value=None)
|
||||
@patch('GramAddict.core.situational_awareness.SituationalAwarenessEngine.ensure_clear_screen', return_value=False)
|
||||
@patch("GramAddict.core.q_nav_graph.time.sleep", return_value=None)
|
||||
@patch("GramAddict.core.q_nav_graph.random_sleep", return_value=None)
|
||||
@patch("GramAddict.core.situational_awareness.SituationalAwarenessEngine.ensure_clear_screen", return_value=False)
|
||||
def test_trap_guard_autonomous_ai_escape(self, mock_sae_clear, mock_q_rand_sleep, mock_q_sleep):
|
||||
print("Starting TDD: Testing autonomous Trap Escape with semantic bypass...")
|
||||
|
||||
|
||||
# 1. Setup mocks
|
||||
mock_device = MagicMock()
|
||||
mock_device.app_id = "com.instagram.android"
|
||||
mock_device._get_current_app.return_value = "com.instagram.android"
|
||||
|
||||
|
||||
trap_xml = "<hierarchy><node resource-id='modal_trap' /></hierarchy>"
|
||||
current_xml = [trap_xml]
|
||||
|
||||
|
||||
# Dynamic dump that changes after click
|
||||
def dynamic_dump():
|
||||
return current_xml[0]
|
||||
|
||||
|
||||
def dynamic_click(**kwargs):
|
||||
if kwargs.get('obj') and kwargs['obj'].get('semantic') and "done" in kwargs['obj'].get('semantic').lower():
|
||||
if kwargs.get("obj") and kwargs["obj"].get("semantic") and "done" in kwargs["obj"].get("semantic").lower():
|
||||
current_xml[0] = "<html><node text='Reels'/><node text='Home'/></html>"
|
||||
|
||||
|
||||
mock_device.dump_hierarchy.side_effect = dynamic_dump
|
||||
mock_device.click.side_effect = dynamic_click
|
||||
|
||||
|
||||
nav_graph = QNavGraph(device=mock_device)
|
||||
|
||||
|
||||
engine = TelepathicEngine.get_instance()
|
||||
engine.confirm_click = MagicMock()
|
||||
engine.reject_click = MagicMock()
|
||||
|
||||
|
||||
original_find_best_node = engine.find_best_node
|
||||
|
||||
|
||||
def spy_find_best_node(xml_hierarchy, intent_description, **kwargs):
|
||||
if "tap home tab" in intent_description.lower():
|
||||
return None
|
||||
return original_find_best_node(xml_hierarchy, intent_description, **kwargs)
|
||||
|
||||
|
||||
engine.find_best_node = spy_find_best_node
|
||||
nav_graph.engine = engine # explicitly enforce
|
||||
|
||||
nav_graph.engine = engine # explicitly enforce
|
||||
|
||||
# 2. Execute transition
|
||||
# Mock engine finds nothing, triggering the final fallback escape
|
||||
result = nav_graph._execute_transition("tap_home_tab", max_retries=1, mock_semantic_engine=engine)
|
||||
|
||||
nav_graph._execute_transition("tap_home_tab", max_retries=1, mock_semantic_engine=engine)
|
||||
|
||||
# 3. Assertions
|
||||
# The new SAE/nav_graph behavior explicitly presses BACK when 'tap_home_tab' fails after all retries
|
||||
self.assertTrue(mock_device.press.called, "Trap guard did not autonomously press BACK to escape the sub-view!")
|
||||
@@ -62,5 +62,6 @@ class TestTrapEscape(unittest.TestCase):
|
||||
self.assertEqual(called_key, "back")
|
||||
print("TDD SUCCESS: Autonomous Backend fallback confirmed.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,44 +1,56 @@
|
||||
import pytest
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.sensors.honeypot_radome import HoneypotRadome
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def radome():
|
||||
# Provide dummy screen dimensions for the Radome
|
||||
return HoneypotRadome(display_width=1080, display_height=2400)
|
||||
|
||||
|
||||
def create_node(bounds: str, clickable="true", visible_to_user="true", text="", cdesc="", res_id="") -> ET.Element:
|
||||
node = ET.Element("node", {
|
||||
"bounds": bounds,
|
||||
"clickable": clickable,
|
||||
"visible-to-user": visible_to_user,
|
||||
"text": text,
|
||||
"content-desc": cdesc,
|
||||
"resource-id": res_id
|
||||
})
|
||||
node = ET.Element(
|
||||
"node",
|
||||
{
|
||||
"bounds": bounds,
|
||||
"clickable": clickable,
|
||||
"visible-to-user": visible_to_user,
|
||||
"text": text,
|
||||
"content-desc": cdesc,
|
||||
"resource-id": res_id,
|
||||
},
|
||||
)
|
||||
return node
|
||||
|
||||
|
||||
def test_zero_point_trap(radome):
|
||||
node = create_node("[0,0][0,0]")
|
||||
assert radome._is_honeypot(node) is True
|
||||
|
||||
|
||||
def test_micro_pixel_trap(radome):
|
||||
node = create_node("[100,100][101,101]", clickable="true")
|
||||
assert radome._is_honeypot(node) is True
|
||||
|
||||
|
||||
def test_safe_normal_button(radome):
|
||||
node = create_node("[500,500][600,600]", text="Like", clickable="true")
|
||||
assert radome._is_honeypot(node) is False
|
||||
|
||||
|
||||
def test_transparent_interceptor_trap(radome):
|
||||
# A full screen clickable node with NO text/id/desc is a trap!
|
||||
node = create_node("[0,0][1080,2400]", text="", cdesc="", res_id="", clickable="true")
|
||||
assert radome._is_honeypot(node) is True
|
||||
|
||||
|
||||
# If it has text (e.g. a legit full screen modal), it's NOT flagged by this specific trap rule
|
||||
safe_modal = create_node("[0,0][1080,2400]", text="Warning", clickable="true")
|
||||
assert radome._is_honeypot(safe_modal) is False
|
||||
|
||||
|
||||
def test_accessibility_trap(radome):
|
||||
# Visible-to-user is false but it is clickable
|
||||
node = create_node("[100,100][300,300]", visible_to_user="false", clickable="true")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Shared fixtures and utilities for chaos engineering tests.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -23,26 +24,19 @@ def generate_corrupted_xml(corruption_type: str) -> str:
|
||||
'enabled="true" focusable="true" focused="false" scrollable="false" '
|
||||
'long-clickable="false" password="false" selected="false" '
|
||||
'bounds="[50,500][150,600]" />'
|
||||
'</node>'
|
||||
'</hierarchy>'
|
||||
"</node>"
|
||||
"</hierarchy>"
|
||||
)
|
||||
|
||||
generators = {
|
||||
"EMPTY_STRING": lambda: "",
|
||||
"NONE_VALUE": lambda: None,
|
||||
"TRUNCATED_MID_TAG": lambda: base_valid[:len(base_valid) // 2],
|
||||
"UNICODE_INJECTION": lambda: base_valid.replace(
|
||||
'text="Like"',
|
||||
'text="L̵̡̧̢̛̛̛̘̗̣̥̱̲̲̝̪̣̗̝̠̫̲̤̱̪̞̻̙̜̺̩̰̫̝̥̩̭̩̫̦̠̦̣̣̬̤̤̠̗̣̲̬̟̣̰̝̥̤̜̻̫̙̥̘̻̝̯̗̼̣̮̲̻̝̹̩̗̥̖̝̝̪̣̜̜̱̣̱̻̮̬̮̬̗̖̟̩̭̜̀̀̈̀̀̀̑́̀̀̆̈́̐̑̈̈́̈́̉̿̈̉̆̂̃̉̆̉̑̉̈̊̏̀̒̌̽̈́̃̓̏̏͋̾̈́́̄̊̈́̽̅̒̓̈̈́̆̈̐̓̋̏̃͑̋̊̅̿̌̇̎̀̀̀̕̕̕͘̕̕̕̕̕͘͜͝͝i̷ke"'
|
||||
),
|
||||
"TRUNCATED_MID_TAG": lambda: base_valid[: len(base_valid) // 2],
|
||||
"UNICODE_INJECTION": lambda: base_valid.replace('text="Like"', 'text="L̵̡̧̢̛̛̛̘̗̣̥̱̲̲̝̪̣̗̝̠̫̲̤̱̪̞̻̙̜̺̩̰̫̝̥̩̭̩̫̦̠̦̣̣̬̤̤̠̗̣̲̬̟̣̰̝̥̤̜̻̫̙̥̘̻̝̯̗̼̣̮̲̻̝̹̩̗̥̖̝̝̪̣̜̜̱̣̱̻̮̬̮̬̗̖̟̩̭̜̀̀̈̀̀̀̑́̀̀̆̈́̐̑̈̈́̈́̉̿̈̉̆̂̃̉̆̉̑̉̈̊̏̀̒̌̽̈́̃̓̏̏͋̾̈́́̄̊̈́̽̅̒̓̈̈́̆̈̐̓̋̏̃͑̋̊̅̿̌̇̎̀̀̀̕̕̕͘̕̕̕̕̕͘͜͝͝i̷ke"'),
|
||||
"MASSIVE_DOM_10K_NODES": lambda: _generate_massive_dom(10000),
|
||||
"ZERO_SIZE_BOUNDS": lambda: base_valid.replace(
|
||||
'bounds="[50,500][150,600]"',
|
||||
'bounds="[500,500][500,500]"'
|
||||
),
|
||||
"ZERO_SIZE_BOUNDS": lambda: base_valid.replace('bounds="[50,500][150,600]"', 'bounds="[500,500][500,500]"'),
|
||||
"NEGATIVE_COORDINATES": lambda: base_valid.replace(
|
||||
'bounds="[50,500][150,600]"',
|
||||
'bounds="[-100,-200][50,100]"'
|
||||
'bounds="[50,500][150,600]"', 'bounds="[-100,-200][50,100]"'
|
||||
),
|
||||
"MISSING_CLOSING_TAGS": lambda: (
|
||||
'<hierarchy rotation="0">'
|
||||
@@ -52,17 +46,11 @@ def generate_corrupted_xml(corruption_type: str) -> str:
|
||||
),
|
||||
"RECURSIVE_NESTING_500_DEEP": lambda: _generate_deep_nesting(500),
|
||||
"NULL_BYTES": lambda: base_valid.replace("Like", "Li\x00ke\x00"),
|
||||
"MALFORMED_BOUNDS": lambda: base_valid.replace(
|
||||
'bounds="[50,500][150,600]"',
|
||||
'bounds="NOT_A_BOUND"'
|
||||
),
|
||||
"MALFORMED_BOUNDS": lambda: base_valid.replace('bounds="[50,500][150,600]"', 'bounds="NOT_A_BOUND"'),
|
||||
"ONLY_WHITESPACE": lambda: " \n\t\n ",
|
||||
"HTML_NOT_XML": lambda: "<html><body><div>Not XML at all</div></body></html>",
|
||||
"BINARY_GARBAGE": lambda: bytes(range(256)).decode("latin-1"),
|
||||
"EXTREMELY_LONG_TEXT": lambda: base_valid.replace(
|
||||
'text="Like"',
|
||||
f'text="{"A" * 100000}"'
|
||||
),
|
||||
"EXTREMELY_LONG_TEXT": lambda: base_valid.replace('text="Like"', f'text="{"A" * 100000}"'),
|
||||
}
|
||||
|
||||
generator = generators.get(corruption_type)
|
||||
@@ -82,7 +70,7 @@ def _generate_massive_dom(count: int) -> str:
|
||||
f'package="com.instagram.android" '
|
||||
f'clickable="true" bounds="[0,{i}][100,{i+50}]" />'
|
||||
)
|
||||
parts.append('</hierarchy>')
|
||||
parts.append("</hierarchy>")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
@@ -91,11 +79,11 @@ def _generate_deep_nesting(depth: int) -> str:
|
||||
xml = '<hierarchy rotation="0">'
|
||||
for i in range(depth):
|
||||
xml += f'<node index="{i}" text="level_{i}" class="android.widget.FrameLayout" '
|
||||
xml += f'package="com.instagram.android" bounds="[0,0][1080,2400]">'
|
||||
xml += 'package="com.instagram.android" bounds="[0,0][1080,2400]">'
|
||||
# Close all tags
|
||||
for _ in range(depth):
|
||||
xml += '</node>'
|
||||
xml += '</hierarchy>'
|
||||
xml += "</node>"
|
||||
xml += "</hierarchy>"
|
||||
return xml
|
||||
|
||||
|
||||
@@ -120,6 +108,6 @@ VALID_FEED_XML = (
|
||||
'<node index="4" text="" resource-id="com.instagram.android:id/search_tab" '
|
||||
'class="android.widget.ImageView" package="com.instagram.android" '
|
||||
'content-desc="Search and explore" clickable="true" bounds="[216,2300][432,2400]" />'
|
||||
'</node>'
|
||||
'</hierarchy>'
|
||||
"</node>"
|
||||
"</hierarchy>"
|
||||
)
|
||||
|
||||
@@ -6,24 +6,33 @@ Verifies that the bot degrades gracefully when external services
|
||||
|
||||
Tesla's FSD doesn't crash if the map server is unreachable — neither should we.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
from tests.chaos import VALID_FEED_XML
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.chaos import VALID_FEED_XML
|
||||
|
||||
# ──────────────────────────────────────────────────
|
||||
# Qdrant Failure Tests
|
||||
# ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.chaos
|
||||
class TestQdrantFailure:
|
||||
"""Bot must survive total Qdrant outage."""
|
||||
|
||||
def test_telepathic_works_without_qdrant(self):
|
||||
"""TelepathicEngine must still resolve nodes via keyword fast-path when Qdrant is down."""
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantBase.__init__", return_value=None), \
|
||||
patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=lambda: property(lambda self: False)):
|
||||
with (
|
||||
patch("GramAddict.core.qdrant_memory.QdrantBase.__init__", return_value=None),
|
||||
patch(
|
||||
"GramAddict.core.qdrant_memory.QdrantBase.is_connected",
|
||||
new_callable=lambda: property(lambda self: False),
|
||||
),
|
||||
):
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
TelepathicEngine._instance = None
|
||||
engine = TelepathicEngine.__new__(TelepathicEngine)
|
||||
engine.ui_memory = MagicMock()
|
||||
@@ -42,37 +51,54 @@ class TestQdrantFailure:
|
||||
|
||||
def test_sae_recall_returns_none_without_qdrant(self):
|
||||
"""SAE episodic memory must return None (not crash) when Qdrant is down."""
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantBase.__init__", return_value=None), \
|
||||
patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=lambda: property(lambda self: False)):
|
||||
with (
|
||||
patch("GramAddict.core.qdrant_memory.QdrantBase.__init__", return_value=None),
|
||||
patch(
|
||||
"GramAddict.core.qdrant_memory.QdrantBase.is_connected",
|
||||
new_callable=lambda: property(lambda self: False),
|
||||
),
|
||||
):
|
||||
from GramAddict.core.situational_awareness import SituationEpisodeDB
|
||||
|
||||
db = SituationEpisodeDB()
|
||||
db._db = MagicMock()
|
||||
db._db.is_connected = False
|
||||
|
||||
|
||||
result = db.recall("test_situation_signature")
|
||||
assert result is None
|
||||
|
||||
def test_sae_learn_silently_fails_without_qdrant(self):
|
||||
"""SAE learning must silently skip (not crash) when Qdrant is down."""
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantBase.__init__", return_value=None), \
|
||||
patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=lambda: property(lambda self: False)):
|
||||
from GramAddict.core.situational_awareness import SituationEpisodeDB, EscapeAction
|
||||
with (
|
||||
patch("GramAddict.core.qdrant_memory.QdrantBase.__init__", return_value=None),
|
||||
patch(
|
||||
"GramAddict.core.qdrant_memory.QdrantBase.is_connected",
|
||||
new_callable=lambda: property(lambda self: False),
|
||||
),
|
||||
):
|
||||
from GramAddict.core.situational_awareness import EscapeAction, SituationEpisodeDB
|
||||
|
||||
db = SituationEpisodeDB()
|
||||
db._db = MagicMock()
|
||||
db._db.is_connected = False
|
||||
|
||||
|
||||
action = EscapeAction("back", reason="test")
|
||||
# Must not raise
|
||||
db.learn("test_signature", action, True)
|
||||
|
||||
|
||||
def test_qdrant_timeout_doesnt_hang_extraction(self):
|
||||
"""If Qdrant queries time out, node extraction must still complete."""
|
||||
import time
|
||||
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantBase.__init__", return_value=None), \
|
||||
patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=lambda: property(lambda self: False)):
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.qdrant_memory.QdrantBase.__init__", return_value=None),
|
||||
patch(
|
||||
"GramAddict.core.qdrant_memory.QdrantBase.is_connected",
|
||||
new_callable=lambda: property(lambda self: False),
|
||||
),
|
||||
):
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
TelepathicEngine._instance = None
|
||||
engine = TelepathicEngine.__new__(TelepathicEngine)
|
||||
engine.ui_memory = MagicMock()
|
||||
@@ -83,11 +109,11 @@ class TestQdrantFailure:
|
||||
engine.positive_memory.recall = MagicMock(side_effect=TimeoutError("Qdrant timeout"))
|
||||
engine._edge_model = None
|
||||
engine._edge_tokenizer = None
|
||||
|
||||
|
||||
start = time.time()
|
||||
nodes = engine._extract_semantic_nodes(VALID_FEED_XML)
|
||||
elapsed = time.time() - start
|
||||
|
||||
|
||||
assert elapsed < 5.0
|
||||
assert isinstance(nodes, list)
|
||||
TelepathicEngine._instance = None
|
||||
@@ -97,6 +123,7 @@ class TestQdrantFailure:
|
||||
# LLM (Ollama/OpenRouter) Failure Tests
|
||||
# ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.chaos
|
||||
class TestLLMFailure:
|
||||
"""Bot must survive LLM outages."""
|
||||
@@ -104,48 +131,48 @@ class TestLLMFailure:
|
||||
def test_sae_perceive_defaults_to_normal_on_llm_failure(self):
|
||||
"""If LLM classification fails, SAE must default to NORMAL (safe fallback)."""
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
|
||||
|
||||
SituationalAwarenessEngine.reset()
|
||||
|
||||
|
||||
device = MagicMock()
|
||||
device.app_id = "com.instagram.android"
|
||||
device.deviceV2 = MagicMock()
|
||||
device.deviceV2.info = {"screenOn": True}
|
||||
|
||||
|
||||
sae = SituationalAwarenessEngine(device)
|
||||
sae.episodes = MagicMock()
|
||||
sae.episodes.recall = MagicMock(return_value=None)
|
||||
|
||||
|
||||
with patch("GramAddict.core.qdrant_memory.ScreenMemoryDB") as MockScreenDB:
|
||||
mock_screen_db = MagicMock()
|
||||
mock_screen_db.get_screen_type = MagicMock(return_value=None)
|
||||
MockScreenDB.return_value = mock_screen_db
|
||||
|
||||
|
||||
with patch("GramAddict.core.llm_provider.query_telepathic_llm", side_effect=ConnectionError("Ollama down")):
|
||||
result = sae.perceive(VALID_FEED_XML)
|
||||
# Must default to NORMAL, not crash
|
||||
assert result == SituationType.NORMAL
|
||||
|
||||
|
||||
SituationalAwarenessEngine.reset()
|
||||
|
||||
def test_sae_escape_planning_defaults_to_back_on_llm_failure(self):
|
||||
"""If LLM escape planning fails, SAE must default to BACK press."""
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
|
||||
|
||||
SituationalAwarenessEngine.reset()
|
||||
|
||||
|
||||
device = MagicMock()
|
||||
device.app_id = "com.instagram.android"
|
||||
device.deviceV2 = MagicMock()
|
||||
device.deviceV2.info = {"screenOn": True}
|
||||
|
||||
|
||||
sae = SituationalAwarenessEngine(device)
|
||||
|
||||
|
||||
with patch("GramAddict.core.llm_provider.query_llm", side_effect=ConnectionError("LLM down")):
|
||||
action = sae._plan_escape_via_llm(
|
||||
VALID_FEED_XML, "compressed_sig", SituationType.OBSTACLE_MODAL
|
||||
)
|
||||
action = sae._plan_escape_via_llm(VALID_FEED_XML, "compressed_sig", SituationType.OBSTACLE_MODAL)
|
||||
assert action.action_type == "back"
|
||||
assert "failed" in action.reason.lower() or "default" in action.reason.lower()
|
||||
|
||||
|
||||
SituationalAwarenessEngine.reset()
|
||||
|
||||
|
||||
@@ -153,6 +180,7 @@ class TestLLMFailure:
|
||||
# Active Inference Resilience
|
||||
# ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.chaos
|
||||
class TestActiveInferenceChaos:
|
||||
"""Active Inference engine must survive edge cases."""
|
||||
@@ -160,35 +188,39 @@ class TestActiveInferenceChaos:
|
||||
def test_evaluate_with_empty_history(self):
|
||||
"""Evaluating without any predictions must return True (no-op)."""
|
||||
from GramAddict.core.active_inference import ActiveInferenceEngine
|
||||
|
||||
ai = ActiveInferenceEngine("test_user")
|
||||
assert ai.evaluate_prediction("<hierarchy/>") is True
|
||||
|
||||
def test_extreme_free_energy_doesnt_overflow(self):
|
||||
"""Repeated errors must not cause float overflow."""
|
||||
from GramAddict.core.active_inference import ActiveInferenceEngine
|
||||
|
||||
ai = ActiveInferenceEngine("test_user")
|
||||
|
||||
|
||||
for _ in range(1000):
|
||||
ai.predict_state(["nonexistent_element"])
|
||||
ai.evaluate_prediction("<hierarchy><node text='wrong'/></hierarchy>")
|
||||
|
||||
assert ai.free_energy < float('inf')
|
||||
|
||||
assert ai.free_energy < float("inf")
|
||||
assert ai.free_energy >= 0
|
||||
|
||||
def test_surprise_with_identical_prediction_is_zero(self):
|
||||
"""Perfect prediction (predicted == observed) must produce near-zero surprise."""
|
||||
from GramAddict.core.active_inference import ActiveInferenceEngine
|
||||
|
||||
ai = ActiveInferenceEngine("test_user")
|
||||
ai.free_energy = 0.0
|
||||
|
||||
|
||||
result = ai.calculate_surprise(1.0, 1.0)
|
||||
assert result < 0.1 # Near-zero free energy
|
||||
|
||||
def test_sleep_modifier_bounds(self):
|
||||
"""Sleep modifier must always be between 1.0 and 5.0."""
|
||||
from GramAddict.core.active_inference import ActiveInferenceEngine
|
||||
|
||||
ai = ActiveInferenceEngine("test_user")
|
||||
|
||||
|
||||
for policy in ["STABLE", "CAUTIOUS", "DORMANT"]:
|
||||
ai.policy = policy
|
||||
mod = ai.get_sleep_modifier()
|
||||
|
||||
@@ -8,22 +8,30 @@ or empty lists) without raising unhandled exceptions.
|
||||
These tests are the "crash barrier" of autonomous navigation — ensuring that
|
||||
no matter what Android dumps to us, the bot survives and recovers.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
from tests.chaos import generate_corrupted_xml
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.chaos import generate_corrupted_xml
|
||||
|
||||
# ──────────────────────────────────────────────────
|
||||
# Telepathic Engine Chaos Tests
|
||||
# ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def telepathic_engine():
|
||||
"""Creates a real TelepathicEngine instance with mocked Qdrant."""
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantBase.__init__", return_value=None), \
|
||||
patch("GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=lambda: property(lambda self: False)):
|
||||
with (
|
||||
patch("GramAddict.core.qdrant_memory.QdrantBase.__init__", return_value=None),
|
||||
patch(
|
||||
"GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=lambda: property(lambda self: False)
|
||||
),
|
||||
):
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
TelepathicEngine._instance = None
|
||||
engine = TelepathicEngine.__new__(TelepathicEngine)
|
||||
engine.ui_memory = MagicMock()
|
||||
@@ -65,30 +73,35 @@ class TestTelepathicEngineChaos:
|
||||
def test_extract_semantic_nodes_survives(self, telepathic_engine, corruption_type):
|
||||
"""Engine's XML parser must return empty list on any corruption."""
|
||||
xml = generate_corrupted_xml(corruption_type)
|
||||
|
||||
|
||||
# Must NOT raise. May return empty list.
|
||||
if xml is None:
|
||||
# None input — directly test defense
|
||||
result = telepathic_engine._extract_semantic_nodes("")
|
||||
else:
|
||||
result = telepathic_engine._extract_semantic_nodes(xml)
|
||||
|
||||
|
||||
assert isinstance(result, list)
|
||||
|
||||
@pytest.mark.parametrize("corruption_type", [
|
||||
"EMPTY_STRING", "NONE_VALUE", "TRUNCATED_MID_TAG",
|
||||
"MISSING_CLOSING_TAGS", "ONLY_WHITESPACE", "HTML_NOT_XML",
|
||||
"BINARY_GARBAGE",
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"corruption_type",
|
||||
[
|
||||
"EMPTY_STRING",
|
||||
"NONE_VALUE",
|
||||
"TRUNCATED_MID_TAG",
|
||||
"MISSING_CLOSING_TAGS",
|
||||
"ONLY_WHITESPACE",
|
||||
"HTML_NOT_XML",
|
||||
"BINARY_GARBAGE",
|
||||
],
|
||||
)
|
||||
def test_find_best_node_survives_garbage(self, telepathic_engine, corruption_type):
|
||||
"""find_best_node must return None on garbage XML, never crash."""
|
||||
xml = generate_corrupted_xml(corruption_type)
|
||||
if xml is None:
|
||||
xml = ""
|
||||
|
||||
result = telepathic_engine._find_best_node_inner(
|
||||
xml, "tap like button", min_confidence=0.82
|
||||
)
|
||||
|
||||
result = telepathic_engine._find_best_node_inner(xml, "tap like button", min_confidence=0.82)
|
||||
# Must be None or a dict, never an exception
|
||||
assert result is None or isinstance(result, dict)
|
||||
|
||||
@@ -109,7 +122,7 @@ class TestTelepathicEngineChaos:
|
||||
start = time.time()
|
||||
nodes = telepathic_engine._extract_semantic_nodes(xml)
|
||||
elapsed = time.time() - start
|
||||
|
||||
|
||||
assert elapsed < 5.0, f"Parsing 10K nodes took {elapsed:.2f}s (limit: 5s)"
|
||||
assert isinstance(nodes, list)
|
||||
|
||||
@@ -117,7 +130,7 @@ class TestTelepathicEngineChaos:
|
||||
"""500 levels of nesting must not cause stack overflow."""
|
||||
xml = generate_corrupted_xml("RECURSIVE_NESTING_500_DEEP")
|
||||
# This would crash Python's default recursion limit (1000) if
|
||||
# we used recursive parsing. ElementTree uses iterative parsing,
|
||||
# we used recursive parsing. ElementTree uses iterative parsing,
|
||||
# so it should survive.
|
||||
nodes = telepathic_engine._extract_semantic_nodes(xml)
|
||||
assert isinstance(nodes, list)
|
||||
@@ -136,24 +149,26 @@ class TestTelepathicEngineChaos:
|
||||
# SAE (Situational Awareness Engine) Chaos Tests
|
||||
# ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sae_engine():
|
||||
"""Creates a SAE instance with mocked device."""
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine
|
||||
|
||||
SituationalAwarenessEngine.reset()
|
||||
|
||||
|
||||
device = MagicMock()
|
||||
device.app_id = "com.instagram.android"
|
||||
device.deviceV2 = MagicMock()
|
||||
device.deviceV2.info = {"screenOn": True}
|
||||
|
||||
|
||||
engine = SituationalAwarenessEngine(device)
|
||||
|
||||
|
||||
# Mock the episode DB to avoid Qdrant dependency
|
||||
engine.episodes = MagicMock()
|
||||
engine.episodes.recall = MagicMock(return_value=None)
|
||||
engine.episodes.learn = MagicMock()
|
||||
|
||||
|
||||
yield engine
|
||||
SituationalAwarenessEngine.reset()
|
||||
|
||||
@@ -162,16 +177,23 @@ def sae_engine():
|
||||
class TestSAEChaos:
|
||||
"""SAE perception must be bulletproof against XML corruption."""
|
||||
|
||||
@pytest.mark.parametrize("corruption_type", [
|
||||
"EMPTY_STRING", "TRUNCATED_MID_TAG", "MISSING_CLOSING_TAGS",
|
||||
"ONLY_WHITESPACE", "HTML_NOT_XML", "BINARY_GARBAGE",
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"corruption_type",
|
||||
[
|
||||
"EMPTY_STRING",
|
||||
"TRUNCATED_MID_TAG",
|
||||
"MISSING_CLOSING_TAGS",
|
||||
"ONLY_WHITESPACE",
|
||||
"HTML_NOT_XML",
|
||||
"BINARY_GARBAGE",
|
||||
],
|
||||
)
|
||||
def test_compress_xml_survives_garbage(self, sae_engine, corruption_type):
|
||||
"""XML compression must never crash, even on garbage."""
|
||||
xml = generate_corrupted_xml(corruption_type)
|
||||
if xml is None:
|
||||
xml = ""
|
||||
|
||||
|
||||
result = sae_engine._compress_xml(xml)
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0 # Should always return something
|
||||
@@ -181,16 +203,23 @@ class TestSAEChaos:
|
||||
assert sae_engine._compress_xml("") == "EMPTY_SCREEN"
|
||||
assert sae_engine._compress_xml(None) == "EMPTY_SCREEN"
|
||||
|
||||
@pytest.mark.parametrize("corruption_type", [
|
||||
"EMPTY_STRING", "TRUNCATED_MID_TAG", "BINARY_GARBAGE", "ONLY_WHITESPACE",
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"corruption_type",
|
||||
[
|
||||
"EMPTY_STRING",
|
||||
"TRUNCATED_MID_TAG",
|
||||
"BINARY_GARBAGE",
|
||||
"ONLY_WHITESPACE",
|
||||
],
|
||||
)
|
||||
def test_perceive_survives_garbage(self, sae_engine, corruption_type):
|
||||
"""perceive() must return a valid SituationType on any input."""
|
||||
from GramAddict.core.situational_awareness import SituationType
|
||||
|
||||
xml = generate_corrupted_xml(corruption_type)
|
||||
if xml is None:
|
||||
xml = ""
|
||||
|
||||
|
||||
result = sae_engine.perceive(xml)
|
||||
assert isinstance(result, SituationType)
|
||||
|
||||
@@ -208,6 +237,6 @@ class TestSAEChaos:
|
||||
start = time.time()
|
||||
result = sae_engine._compress_xml(xml)
|
||||
elapsed = time.time() - start
|
||||
|
||||
|
||||
assert len(result) <= 3000, f"Compressed output is {len(result)} chars (limit: 3000)"
|
||||
assert elapsed < 5.0, f"Compression took {elapsed:.2f}s"
|
||||
|
||||
@@ -1,130 +1,169 @@
|
||||
import pytest
|
||||
import logging
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
"--live", action="store_true", default=False, help="run tests against a live ADB device (disable DeviceFacade mocks)"
|
||||
"--live",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="run tests against a live ADB device (disable DeviceFacade mocks)",
|
||||
)
|
||||
|
||||
|
||||
MagicMock.app_id = "com.instagram.android"
|
||||
MagicMock._get_current_app = MagicMock(return_value="com.instagram.android")
|
||||
|
||||
|
||||
class MockArgs:
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
|
||||
class MockConfigs:
|
||||
def __init__(self, args):
|
||||
self.args = args
|
||||
|
||||
from unittest.mock import create_autospec, MagicMock
|
||||
|
||||
from unittest.mock import MagicMock, create_autospec
|
||||
|
||||
from GramAddict.core.device_facade import DeviceFacade
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
|
||||
def create_mock_device():
|
||||
mock = create_autospec(DeviceFacade, instance=True)
|
||||
mock.app_id = "com.instagram.android"
|
||||
mock.device_id = "test_device"
|
||||
|
||||
|
||||
mock.info = {"displayWidth": 1080, "displayHeight": 2400}
|
||||
mock.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
|
||||
mock.cm_to_pixels.side_effect = lambda cm: int(cm * 10)
|
||||
mock.shell.return_value = "" # Ensure SendEventInjector detection gets a string
|
||||
import uuid
|
||||
mock.dump_hierarchy.side_effect = lambda: f"<hierarchy><node resource-id=\"com.instagram.android:id/row_feed_photo_profile_name\" bounds=\"[0,200][1080,260]\" text=\"testuser\" /><node resource-id=\"com.instagram.android:id/row_comment_imageview\" bounds=\"[10,10][20,20]\" content-desc=\"Story\" text=\"following\" /><node resource-id=\"com.instagram.android:id/button_like\" bounds=\"[50,50][60,60]\" /><node resource-id=\"com.instagram.android:id/reel_viewer\" /><node sid=\"{uuid.uuid4()}\" /></hierarchy>"
|
||||
|
||||
|
||||
mock.dump_hierarchy.side_effect = (
|
||||
lambda: f'<hierarchy><node resource-id="com.instagram.android:id/row_feed_photo_profile_name" bounds="[0,200][1080,260]" text="testuser" /><node resource-id="com.instagram.android:id/row_comment_imageview" bounds="[10,10][20,20]" content-desc="Story" text="following" /><node resource-id="com.instagram.android:id/button_like" bounds="[50,50][60,60]" /><node resource-id="com.instagram.android:id/reel_viewer" /><node sid="{uuid.uuid4()}" /></hierarchy>'
|
||||
)
|
||||
|
||||
return mock
|
||||
|
||||
|
||||
def create_mock_telepathic_engine():
|
||||
mock = create_autospec(TelepathicEngine, instance=True)
|
||||
mock.find_best_node.return_value = {"x": 500, "y": 500, "confidence": 0.9}
|
||||
mock.evaluate_profile_vibe.return_value = {"quality_score": 8, "matches_niche": True, "reason": "Mocked positive vibe"}
|
||||
mock.evaluate_grid_visuals.return_value = {"x": 500, "y": 500, "score": 0.99, "semantic": "Mocked matching grid cell", "source": "vlm_grid"}
|
||||
mock._extract_semantic_nodes.return_value = [{"x": 500, "y": 500, "semantic_string": "dummy node"}]
|
||||
mock.evaluate_profile_vibe.return_value = {
|
||||
"quality_score": 8,
|
||||
"matches_niche": True,
|
||||
"reason": "Mocked positive vibe",
|
||||
}
|
||||
mock.evaluate_grid_visuals.return_value = {
|
||||
"x": 500,
|
||||
"y": 500,
|
||||
"score": 0.99,
|
||||
"semantic": "Mocked matching grid cell",
|
||||
"source": "vlm_grid",
|
||||
}
|
||||
mock.find_best_node.return_value = {"x": 500, "y": 500, "semantic_string": "dummy node"}
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_logger():
|
||||
return logging.getLogger("test")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device(request):
|
||||
if request.config.getoption("--live"):
|
||||
from GramAddict.core.device_facade import create_device
|
||||
import yaml
|
||||
import os
|
||||
|
||||
|
||||
import yaml
|
||||
|
||||
from GramAddict.core.device_facade import create_device
|
||||
|
||||
device_id = "emulator-5554"
|
||||
app_id = "com.instagram.android"
|
||||
|
||||
|
||||
config_path = "test_config.yml"
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
config = yaml.safe_load(f)
|
||||
if config:
|
||||
device_id = config.get("device", device_id)
|
||||
app_id = config.get("app-id", app_id)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Warning: Could not load {config_path}: {e}")
|
||||
|
||||
|
||||
print(f"🚀 Connecting to live device: {device_id} (App: {app_id})")
|
||||
return create_device(device_id, app_id)
|
||||
return create_mock_device()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_singletons():
|
||||
"""Ensure all core engine singletons are fresh for each test."""
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
from GramAddict.core.goap import GoalExecutor
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine
|
||||
|
||||
from GramAddict.core.qdrant_memory import QdrantBase
|
||||
from GramAddict.core.behaviors import PluginRegistry
|
||||
from GramAddict.core.goap import GoalExecutor
|
||||
from GramAddict.core.physics.biomechanics import PhysicsBody
|
||||
from GramAddict.core.physics.sendevent_injector import SendEventInjector
|
||||
|
||||
from GramAddict.core.qdrant_memory import QdrantBase
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
TelepathicEngine.reset()
|
||||
GoalExecutor.reset()
|
||||
SituationalAwarenessEngine.reset()
|
||||
PluginRegistry.reset()
|
||||
PhysicsBody.reset()
|
||||
SendEventInjector.reset()
|
||||
|
||||
|
||||
QdrantBase._connection_failed_logged = False
|
||||
|
||||
|
||||
from GramAddict.core.dojo_engine import DojoEngine
|
||||
|
||||
if hasattr(DojoEngine, "reset"):
|
||||
DojoEngine.reset()
|
||||
else:
|
||||
DojoEngine._instance = None
|
||||
|
||||
|
||||
# Aggressively wipe on-disk session files to prevent state leakage in tests
|
||||
for f in ["telepathic_memory.json", "telepathic_blacklist.json", "growth_brain_memory.json", "gramaddict_nav_map.json", "l2_channels_cache.json"]:
|
||||
for f in [
|
||||
"telepathic_memory.json",
|
||||
"telepathic_blacklist.json",
|
||||
"growth_brain_memory.json",
|
||||
"gramaddict_nav_map.json",
|
||||
"l2_channels_cache.json",
|
||||
]:
|
||||
if os.path.exists(f):
|
||||
try:
|
||||
os.remove(f)
|
||||
except Exception:
|
||||
pass
|
||||
yield
|
||||
|
||||
|
||||
# Post-test cleanup
|
||||
PhysicsBody.reset()
|
||||
SendEventInjector.reset()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def telepathic_mock(monkeypatch, request):
|
||||
if request.config.getoption("--live"):
|
||||
# TelepathicEngine is a singleton, allow it to run natively
|
||||
return None
|
||||
import GramAddict.core.telepathic_engine
|
||||
|
||||
engine = create_mock_telepathic_engine()
|
||||
monkeypatch.setattr(GramAddict.core.telepathic_engine.TelepathicEngine, "get_instance", lambda: engine)
|
||||
return engine
|
||||
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cognitive_stack():
|
||||
stack = {
|
||||
@@ -138,7 +177,7 @@ def mock_cognitive_stack():
|
||||
"nav_graph": MagicMock(),
|
||||
"zero_engine": MagicMock(),
|
||||
"crm": MagicMock(),
|
||||
"telepathic": create_mock_telepathic_engine()
|
||||
"telepathic": create_mock_telepathic_engine(),
|
||||
}
|
||||
stack["radome"].sanitize_xml.side_effect = lambda x: x
|
||||
return stack
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
import sys
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core import utils
|
||||
|
||||
# Force Qdrant mocking globally across ALL E2E tests so we never
|
||||
# block on connection refused trying to hit localhost:6344
|
||||
mock_qdrant = MagicMock()
|
||||
|
||||
# Setup correct return types for dimension check warnings in qdrant_memory
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.config.params.vectors.size = 768
|
||||
mock_qdrant.get_collection.return_value = mock_collection
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def global_qdrant_mock():
|
||||
"""
|
||||
Force Qdrant mocking globally across ALL E2E tests so we never
|
||||
block on connection refused trying to hit localhost:6344.
|
||||
Moved to a fixture to avoid poisoning the global sys.modules on import.
|
||||
"""
|
||||
mock_qdrant = MagicMock()
|
||||
|
||||
# Setup correct return types for dimension check warnings in qdrant_memory
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.config.params.vectors.size = 768
|
||||
mock_qdrant.get_collection.return_value = mock_collection
|
||||
|
||||
# We use a wrapper to ensure the mock is only active when we want it
|
||||
sys.modules["qdrant_client"].QdrantClient = MagicMock(return_value=mock_qdrant)
|
||||
|
||||
yield mock_qdrant
|
||||
|
||||
# Optional: cleanup if needed, but for E2E it's usually fine to keep it for the session
|
||||
|
||||
sys.modules["qdrant_client"].QdrantClient = MagicMock(return_value=mock_qdrant)
|
||||
|
||||
@pytest.fixture
|
||||
def e2e_device_dump_injector(request):
|
||||
"""
|
||||
Provides a factory to mock device.dump_hierarchy using real XML files.
|
||||
Will gracefully fail with a comprehensive assertion if the file is missing
|
||||
Will gracefully fail with a comprehensive assertion if the file is missing
|
||||
(per 'ECHTE DUMPS fehlen' reporting requirement).
|
||||
"""
|
||||
if request.config.getoption("--live"):
|
||||
@@ -29,30 +43,36 @@ def e2e_device_dump_injector(request):
|
||||
def _inject_dump(device_mock, xml_filename):
|
||||
fix_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures")
|
||||
xml_path = os.path.join(fix_dir, xml_filename)
|
||||
|
||||
|
||||
if not os.path.exists(xml_path):
|
||||
pytest.fail(f"MISSING REAL DUMP: required XML fixture '{xml_filename}' for full E2E workflow testing could not be found at {xml_path}. FAKE_NOTHING policy implies dropping this test execution until it is captured.", pytrace=False)
|
||||
|
||||
pytest.fail(
|
||||
f"MISSING REAL DUMP: required XML fixture '{xml_filename}' for full E2E workflow testing could not be found at {xml_path}. FAKE_NOTHING policy implies dropping this test execution until it is captured.",
|
||||
pytrace=False,
|
||||
)
|
||||
|
||||
with open(xml_path, "r") as f:
|
||||
real_xml = f.read()
|
||||
|
||||
|
||||
device_mock.dump_hierarchy.return_value = real_xml
|
||||
return real_xml
|
||||
|
||||
|
||||
return _inject_dump
|
||||
|
||||
|
||||
class VirtualClock:
|
||||
def __init__(self):
|
||||
self.time = 0.0
|
||||
self.animation_target_time = 0.0
|
||||
|
||||
|
||||
def sleep(self, seconds):
|
||||
if hasattr(seconds, '__iter__'):
|
||||
return # For edge case where something weird is passed
|
||||
if hasattr(seconds, "__iter__"):
|
||||
return # For edge case where something weird is passed
|
||||
self.time += float(seconds)
|
||||
|
||||
|
||||
clock = VirtualClock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dynamic_e2e_dump_injector(monkeypatch, request):
|
||||
"""
|
||||
@@ -66,9 +86,9 @@ def dynamic_e2e_dump_injector(monkeypatch, request):
|
||||
|
||||
def _inject(device_mock, state_map, initial_xml):
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
|
||||
fix_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures")
|
||||
|
||||
|
||||
def load_xml(filename):
|
||||
path = os.path.join(fix_dir, filename)
|
||||
if not os.path.exists(path):
|
||||
@@ -79,47 +99,56 @@ def dynamic_e2e_dump_injector(monkeypatch, request):
|
||||
# History stack to allow "back" navigation
|
||||
device_mock._xml_history = [load_xml(initial_xml)]
|
||||
device_mock._current_active_xml = device_mock._xml_history[-1]
|
||||
|
||||
|
||||
import uuid
|
||||
|
||||
def _dump_hierarchy_hook():
|
||||
if clock.time < clock.animation_target_time:
|
||||
pytest.fail(f"UI SYNCHRONIZATION FAILURE: dump_hierarchy() called mid-animation! "
|
||||
f"Virtual Clock is at {clock.time:.1f}s but UI needs until {clock.animation_target_time:.1f}s to settle. "
|
||||
f"Add a time.sleep() guard before interacting with the UI after a click.", pytrace=False)
|
||||
pytest.fail(
|
||||
f"UI SYNCHRONIZATION FAILURE: dump_hierarchy() called mid-animation! "
|
||||
f"Virtual Clock is at {clock.time:.1f}s but UI needs until {clock.animation_target_time:.1f}s to settle. "
|
||||
f"Add a time.sleep() guard before interacting with the UI after a click.",
|
||||
pytrace=False,
|
||||
)
|
||||
xml = device_mock._current_active_xml
|
||||
if xml and "</hierarchy>" in xml:
|
||||
xml = xml.replace("</hierarchy>", f"<node sid=\"{uuid.uuid4()}\" /></hierarchy>")
|
||||
xml = xml.replace("</hierarchy>", f'<node sid="{uuid.uuid4()}" /></hierarchy>')
|
||||
return xml
|
||||
|
||||
device_mock.dump_hierarchy.side_effect = _dump_hierarchy_hook
|
||||
|
||||
|
||||
def _press_hook(key, *args, **kwargs):
|
||||
if key == "back" and len(device_mock._xml_history) > 1:
|
||||
device_mock._xml_history.pop()
|
||||
device_mock._current_active_xml = device_mock._xml_history[-1]
|
||||
clock.animation_target_time = clock.time + 1.5
|
||||
|
||||
device_mock.press.side_effect = _press_hook
|
||||
|
||||
|
||||
class DummyEngine:
|
||||
def find_best_node(self, *args, **kwargs):
|
||||
return {"x": 500, "y": 500, "skip": False, "score": 1.0, "source": "e2e_mock"}
|
||||
|
||||
def verify_success(self, *args, **kwargs):
|
||||
return True
|
||||
|
||||
def confirm_click(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def reject_click(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
original_execute = QNavGraph._execute_transition
|
||||
from GramAddict.core.goap import GoalExecutor
|
||||
|
||||
original_goap_execute = GoalExecutor._execute_action
|
||||
|
||||
|
||||
def _mock_execute_transition(nav_self, action, zero_engine=None, max_retries=2):
|
||||
if action == 'tap_post_username':
|
||||
if action == "tap_post_username":
|
||||
return True
|
||||
|
||||
|
||||
original_click = nav_self.device.click
|
||||
|
||||
|
||||
def _click_hook(obj=None, *args, **kwargs):
|
||||
original_click(obj, *args, **kwargs)
|
||||
if action in state_map:
|
||||
@@ -127,22 +156,24 @@ def dynamic_e2e_dump_injector(monkeypatch, request):
|
||||
device_mock._xml_history.append(new_xml)
|
||||
device_mock._current_active_xml = new_xml
|
||||
clock.animation_target_time = clock.time + 1.5
|
||||
|
||||
|
||||
nav_self.device.click = _click_hook
|
||||
|
||||
|
||||
try:
|
||||
success = original_execute(nav_self, action, mock_semantic_engine=DummyEngine(), max_retries=max_retries)
|
||||
success = original_execute(
|
||||
nav_self, action, mock_semantic_engine=DummyEngine(), max_retries=max_retries
|
||||
)
|
||||
return success
|
||||
finally:
|
||||
nav_self.device.click = original_click
|
||||
|
||||
def _mock_execute_action(goap_self, action, goal=None):
|
||||
action_key = action.replace(" ", "_")
|
||||
if action_key == 'tap_post_username':
|
||||
if action_key == "tap_post_username":
|
||||
return True
|
||||
|
||||
|
||||
original_click = goap_self.device.click
|
||||
|
||||
|
||||
def _click_hook(obj=None, *args, **kwargs):
|
||||
original_click(obj, *args, **kwargs)
|
||||
if action_key in state_map:
|
||||
@@ -155,20 +186,21 @@ def dynamic_e2e_dump_injector(monkeypatch, request):
|
||||
device_mock._xml_history.append(new_xml)
|
||||
device_mock._current_active_xml = new_xml
|
||||
clock.animation_target_time = clock.time + 1.5
|
||||
|
||||
|
||||
goap_self.device.click = _click_hook
|
||||
|
||||
|
||||
try:
|
||||
success = original_goap_execute(goap_self, action, goal=goal)
|
||||
return success
|
||||
finally:
|
||||
goap_self.device.click = original_click
|
||||
|
||||
|
||||
monkeypatch.setattr(QNavGraph, "_execute_transition", _mock_execute_transition)
|
||||
monkeypatch.setattr(GoalExecutor, "_execute_action", _mock_execute_action)
|
||||
|
||||
|
||||
return _inject
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_all_delays(monkeypatch, request):
|
||||
"""
|
||||
@@ -180,52 +212,77 @@ def mock_all_delays(monkeypatch, request):
|
||||
return
|
||||
|
||||
global clock
|
||||
clock.time = 0.0 # reset for test
|
||||
clock.time = 0.0 # reset for test
|
||||
clock.animation_target_time = 0.0
|
||||
|
||||
|
||||
def simulate_sleep(seconds):
|
||||
clock.sleep(seconds)
|
||||
|
||||
money_sleep = lambda x: simulate_sleep(x)
|
||||
random_sleep = lambda *args, **kwargs: simulate_sleep(1.0) # Assume 1.0 minimum for randoms
|
||||
|
||||
def money_sleep(x):
|
||||
return simulate_sleep(x)
|
||||
|
||||
def random_sleep(a=1.0, b=2.0, *args, **kwargs):
|
||||
return simulate_sleep(max(1.5, float(a)))
|
||||
|
||||
monkeypatch.setattr(time, "sleep", money_sleep)
|
||||
monkeypatch.setattr(utils, "random_sleep", random_sleep)
|
||||
monkeypatch.setattr(utils, "sleep", money_sleep)
|
||||
|
||||
|
||||
# Needs to capture specific module sleeps depending on how they imported it
|
||||
try:
|
||||
from GramAddict.core import bot_flow
|
||||
|
||||
monkeypatch.setattr(bot_flow, "sleep", money_sleep)
|
||||
monkeypatch.setattr(bot_flow.random, "uniform", lambda a, b: float(a)) # deterministic lower bound
|
||||
|
||||
monkeypatch.setattr(bot_flow.random, "uniform", lambda a, b: float(a)) # deterministic lower bound
|
||||
if hasattr(bot_flow, "random_sleep"):
|
||||
monkeypatch.setattr(bot_flow, "random_sleep", random_sleep)
|
||||
|
||||
from GramAddict.core import q_nav_graph
|
||||
|
||||
monkeypatch.setattr(q_nav_graph.random, "uniform", lambda a, b: float(a))
|
||||
|
||||
if hasattr(q_nav_graph, "random_sleep"):
|
||||
monkeypatch.setattr(q_nav_graph, "random_sleep", random_sleep)
|
||||
|
||||
from GramAddict.core import goap
|
||||
|
||||
if hasattr(goap, "random"):
|
||||
monkeypatch.setattr(goap.random, "uniform", lambda a, b: float(a))
|
||||
if hasattr(goap, "random_sleep"):
|
||||
monkeypatch.setattr(goap, "random_sleep", random_sleep)
|
||||
|
||||
monkeypatch.setattr(utils.random, "uniform", lambda a, b: float(a))
|
||||
|
||||
from GramAddict.core import device_facade
|
||||
|
||||
monkeypatch.setattr(device_facade, "sleep", money_sleep)
|
||||
monkeypatch.setattr(device_facade.random, "uniform", lambda a, b: float(a))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if hasattr(device_facade, "random_sleep"):
|
||||
monkeypatch.setattr(device_facade, "random_sleep", random_sleep)
|
||||
except Exception as e:
|
||||
print(f"Mocking delays exception: {e}")
|
||||
|
||||
# Standardize DarwinEngine across tests to prevent mockup math errors on session end
|
||||
try:
|
||||
from GramAddict.core.darwin_engine import DarwinEngine
|
||||
|
||||
monkeypatch.setattr(DarwinEngine, "evaluate_session_end", lambda *args, **kwargs: None)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_identity_guard(monkeypatch):
|
||||
import GramAddict.core.bot_flow
|
||||
|
||||
monkeypatch.setattr(GramAddict.core.bot_flow, "verify_and_switch_account", lambda *args, **kwargs: True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def e2e_configs():
|
||||
import argparse
|
||||
configs = MagicMock()
|
||||
configs.username = "testuser"
|
||||
configs.args = argparse.Namespace(
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
args = argparse.Namespace(
|
||||
username="testuser",
|
||||
device="emulator-5554",
|
||||
app_id="com.instagram.android",
|
||||
@@ -237,9 +294,12 @@ def e2e_configs():
|
||||
reels=None,
|
||||
stories=None,
|
||||
interact_percentage=100,
|
||||
likes_count="2-3",
|
||||
likes_percentage=100,
|
||||
follow_percentage=100,
|
||||
comment_percentage=100,
|
||||
stories_count="1-2",
|
||||
stories_percentage=100,
|
||||
working_hours=[0.0, 24.0],
|
||||
time_delta_session=0,
|
||||
speed_multiplier=1.0,
|
||||
@@ -251,9 +311,37 @@ def e2e_configs():
|
||||
ai_telepathic_url="http://localhost",
|
||||
ai_telepathic_model="llama3",
|
||||
ai_condenser_url="http://localhost",
|
||||
dry_run_comments=False,
|
||||
visual_vibe_check_percentage=0,
|
||||
)
|
||||
|
||||
configs = MagicMock()
|
||||
configs.args = args
|
||||
configs.username = "testuser"
|
||||
|
||||
# Realistically mock get_plugin_config
|
||||
def get_plugin_config_mock(plugin_name):
|
||||
# Return a dict that simulates what's in the args for that plugin
|
||||
mapping = {
|
||||
"likes": {"count": args.likes_count, "percentage": args.likes_percentage},
|
||||
"comment": {
|
||||
"percentage": args.comment_percentage,
|
||||
"dry_run": args.dry_run_comments,
|
||||
},
|
||||
"follow": {"percentage": args.follow_percentage},
|
||||
"stories": {"count": args.stories_count, "percentage": args.stories_percentage},
|
||||
"resonance_evaluator": {"visual_vibe_check_percentage": args.visual_vibe_check_percentage},
|
||||
"carousel_browsing": {
|
||||
"percentage": getattr(args, "carousel_percentage", 0),
|
||||
"count": getattr(args, "carousel_count", "1"),
|
||||
},
|
||||
}
|
||||
return mapping.get(plugin_name, {})
|
||||
|
||||
configs.get_plugin_config.side_effect = get_plugin_config_mock
|
||||
return configs
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_sae_perceive(request, monkeypatch):
|
||||
"""
|
||||
@@ -263,9 +351,63 @@ def mock_sae_perceive(request, monkeypatch):
|
||||
"""
|
||||
if "test_e2e_sae.py" in str(request.node.fspath):
|
||||
return
|
||||
if "test_e2e_real_llm_learning.py" in str(request.node.fspath):
|
||||
return
|
||||
if request.config.getoption("--live"):
|
||||
return
|
||||
|
||||
import GramAddict.core.situational_awareness
|
||||
monkeypatch.setattr(GramAddict.core.situational_awareness.SituationalAwarenessEngine, "perceive", lambda self, xml: GramAddict.core.situational_awareness.SituationType.NORMAL)
|
||||
|
||||
import GramAddict.core.situational_awareness
|
||||
|
||||
monkeypatch.setattr(
|
||||
GramAddict.core.situational_awareness.SituationalAwarenessEngine,
|
||||
"perceive",
|
||||
lambda self, xml: GramAddict.core.situational_awareness.SituationType.NORMAL,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_e2e_plugin_registry():
|
||||
"""Ensures that all standard plugins are registered for E2E tests."""
|
||||
from GramAddict.core.behaviors import PluginRegistry
|
||||
from GramAddict.core.behaviors.ad_guard import AdGuardPlugin
|
||||
from GramAddict.core.behaviors.anomaly_handler import AnomalyHandlerPlugin
|
||||
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
|
||||
from GramAddict.core.behaviors.close_friends_guard import CloseFriendsGuardPlugin
|
||||
from GramAddict.core.behaviors.comment import CommentPlugin
|
||||
from GramAddict.core.behaviors.darwin_dwell import DarwinDwellPlugin
|
||||
from GramAddict.core.behaviors.follow import FollowPlugin
|
||||
from GramAddict.core.behaviors.grid_like import GridLikePlugin
|
||||
from GramAddict.core.behaviors.like import LikePlugin
|
||||
from GramAddict.core.behaviors.obstacle_guard import ObstacleGuardPlugin
|
||||
from GramAddict.core.behaviors.perfect_snapping import PerfectSnappingPlugin
|
||||
from GramAddict.core.behaviors.post_data_extraction import PostDataExtractionPlugin
|
||||
from GramAddict.core.behaviors.post_interaction import PostInteractionPlugin
|
||||
from GramAddict.core.behaviors.profile_guard import ProfileGuardPlugin
|
||||
from GramAddict.core.behaviors.profile_visit import ProfileVisitPlugin
|
||||
from GramAddict.core.behaviors.rabbit_hole import RabbitHolePlugin
|
||||
from GramAddict.core.behaviors.repost import RepostPlugin
|
||||
from GramAddict.core.behaviors.resonance_evaluator import ResonanceEvaluatorPlugin
|
||||
from GramAddict.core.behaviors.story_view import StoryViewPlugin
|
||||
|
||||
PluginRegistry.reset()
|
||||
plugin_registry = PluginRegistry.get_instance()
|
||||
plugin_registry.register(ProfileGuardPlugin())
|
||||
plugin_registry.register(StoryViewPlugin())
|
||||
plugin_registry.register(FollowPlugin())
|
||||
plugin_registry.register(GridLikePlugin())
|
||||
plugin_registry.register(CarouselBrowsingPlugin())
|
||||
plugin_registry.register(AdGuardPlugin())
|
||||
plugin_registry.register(CloseFriendsGuardPlugin())
|
||||
plugin_registry.register(AnomalyHandlerPlugin())
|
||||
plugin_registry.register(ObstacleGuardPlugin())
|
||||
plugin_registry.register(PerfectSnappingPlugin())
|
||||
plugin_registry.register(PostDataExtractionPlugin())
|
||||
plugin_registry.register(ResonanceEvaluatorPlugin())
|
||||
plugin_registry.register(RabbitHolePlugin())
|
||||
plugin_registry.register(DarwinDwellPlugin())
|
||||
plugin_registry.register(ProfileVisitPlugin())
|
||||
plugin_registry.register(LikePlugin())
|
||||
plugin_registry.register(CommentPlugin())
|
||||
plugin_registry.register(RepostPlugin())
|
||||
plugin_registry.register(PostInteractionPlugin())
|
||||
yield plugin_registry
|
||||
|
||||
48
tests/e2e/test_debug.py
Normal file
48
tests/e2e/test_debug.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from GramAddict.core.bot_flow import start_bot
|
||||
|
||||
|
||||
@patch("GramAddict.core.bot_flow.open_instagram", return_value=True)
|
||||
@patch("GramAddict.core.bot_flow.close_instagram")
|
||||
@patch("GramAddict.core.bot_flow.SessionState")
|
||||
@patch("GramAddict.core.bot_flow.DopamineEngine")
|
||||
@patch("GramAddict.core.bot_flow.create_device")
|
||||
@patch("GramAddict.core.bot_flow.GrowthBrain")
|
||||
@patch("GramAddict.core.bot_flow.ResonanceEngine")
|
||||
def test_e2e_story_viewing_simple(
|
||||
mock_resonance, mock_growth, mock_create_device, mock_dopamine, mock_sess, mock_close, mock_open, e2e_configs
|
||||
):
|
||||
device = MagicMock()
|
||||
mock_create_device.return_value = device
|
||||
|
||||
mock_d_inst = mock_dopamine.return_value
|
||||
mock_d_inst.is_app_session_over.side_effect = [False, True]
|
||||
mock_d_inst.wants_to_doomscroll.return_value = False
|
||||
mock_d_inst.boredom = 0.0
|
||||
|
||||
mock_growth_inst = mock_growth.return_value
|
||||
mock_growth_inst.get_circadian_pacing.return_value = 1.0
|
||||
mock_growth_inst.evaluate_governance.return_value = "STAY"
|
||||
|
||||
mock_sess.inside_working_hours.return_value = (True, 0)
|
||||
mock_sess_inst = mock_sess.return_value
|
||||
mock_sess_inst.check_limit.return_value = (False, False, False)
|
||||
|
||||
mock_resonance_inst = mock_resonance.return_value
|
||||
mock_resonance_inst.find_best_node.return_value = {
|
||||
"username": "testuser",
|
||||
"node": {"x": 500, "y": 500},
|
||||
"score": 1.0,
|
||||
}
|
||||
|
||||
device.dump_hierarchy.return_value = '<html><node resource-id="reel_ring" /></html>'
|
||||
device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
|
||||
|
||||
with patch("GramAddict.core.behaviors.story_view.wait_for_story_loaded", return_value=True):
|
||||
with patch("GramAddict.core.q_nav_graph.QNavGraph.do", return_value=True):
|
||||
with patch("GramAddict.core.bot_flow.Config", return_value=e2e_configs):
|
||||
with patch("GramAddict.core.goap.GoalExecutor.navigate_to_screen", return_value=True):
|
||||
start_bot()
|
||||
|
||||
assert True
|
||||
@@ -1,8 +1,11 @@
|
||||
import pytest
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
|
||||
def test_animation_sync_guard_catches_missing_sleep(dynamic_e2e_dump_injector):
|
||||
"""
|
||||
Proves that the new Animation Simulator built into conftest.py
|
||||
@@ -10,19 +13,20 @@ def test_animation_sync_guard_catches_missing_sleep(dynamic_e2e_dump_injector):
|
||||
"""
|
||||
device = MagicMock()
|
||||
# Inject dummy states
|
||||
dynamic_e2e_dump_injector(device, {'tap_explore_tab': 'explore_feed_dump.xml'}, "home_feed_with_ad.xml")
|
||||
|
||||
dynamic_e2e_dump_injector(device, {"tap_explore_tab": "explore_feed_dump.xml"}, "home_feed_with_ad.xml")
|
||||
|
||||
nav = QNavGraph(device)
|
||||
|
||||
|
||||
# We monkeypatch the VirtualClock back to 0 temporarily to prove the synchronization guard works
|
||||
# if the sleep is accidentally deleted by a developer in the future.
|
||||
import time
|
||||
def _bad_sleep(seconds):
|
||||
pass # Advance 0s to trigger failure
|
||||
pass # Advance 0s to trigger failure
|
||||
|
||||
time.sleep = _bad_sleep
|
||||
|
||||
|
||||
from _pytest.outcomes import Failed
|
||||
|
||||
with pytest.raises(Failed) as exc_info:
|
||||
nav._execute_transition("tap_explore_tab")
|
||||
|
||||
|
||||
assert "UI SYNCHRONIZATION FAILURE" in str(exc_info.value), "The simulator failed to catch the missing sleep guard!"
|
||||
|
||||
48
tests/e2e/test_e2e_blank_start_integrity.py
Normal file
48
tests/e2e/test_e2e_blank_start_integrity.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.qdrant_memory import wipe_all_ai_caches
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore:urllib3")
|
||||
def test_blank_start_wipes_navigation_memory(monkeypatch):
|
||||
"""
|
||||
TDD: Verify that NavigationMemoryDB is wiped when blank_start is True.
|
||||
We mock the QdrantClient to track if delete_collection was called for the nav graph.
|
||||
"""
|
||||
mock_client = MagicMock()
|
||||
# Mock collection_exists to return True so it tries to wipe
|
||||
mock_client.collection_exists.return_value = True
|
||||
|
||||
# We patch QdrantClient in qdrant_memory
|
||||
monkeypatch.setattr("GramAddict.core.qdrant_memory.QdrantClient", MagicMock(return_value=mock_client))
|
||||
|
||||
# Setup configs with blank_start = True
|
||||
configs = MagicMock()
|
||||
configs.args = MagicMock()
|
||||
configs.args.blank_start = True
|
||||
configs.args.username = "testuser"
|
||||
configs.username = "testuser"
|
||||
|
||||
# We mock TelepathicEngine to avoid other side effects
|
||||
with patch("GramAddict.core.telepathic_engine.TelepathicEngine.get_instance") as mock_te:
|
||||
mock_te.return_value = MagicMock()
|
||||
|
||||
# Run stage 0 via a minimal start_bot simulation or direct call
|
||||
# Since start_bot is huge, let's just test the logic we added to bot_flow
|
||||
# but in the context of the actual classes.
|
||||
|
||||
wipe_all_ai_caches()
|
||||
|
||||
# Verify that NavigationMemoryDB's collection was deleted
|
||||
# NavigationMemoryDB uses "gramaddict_nav_graph_v8"
|
||||
mock_client.delete_collection.assert_any_call("gramaddict_nav_graph_v8")
|
||||
mock_client.delete_collection.assert_any_call("gramaddict_heuristics_v7")
|
||||
mock_client.delete_collection.assert_any_call("gramaddict_ui_cache")
|
||||
print("✅ All collections were signaled for deletion.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Manual run for quick verification
|
||||
test_blank_start_wipes_navigation_memory(pytest.MonkeyPatch())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user