Compare commits
85 Commits
fix/autono
...
refactor/g
| Author | SHA1 | Date | |
|---|---|---|---|
| effb1f5ae1 | |||
| 0e43996ccd | |||
| b6846ab0fe | |||
| 6db579f45b | |||
| 0ed12303ac | |||
| 6abb519e3b | |||
| 4e91db01c9 | |||
| e55abc5a8a | |||
| 03105437b8 | |||
| b9c29a5a2d | |||
| 71310b8e84 | |||
| 073a90c38c | |||
| 44fae37cc7 | |||
| 48071cc9b8 | |||
| 10a85a91f1 | |||
| 5bf0053884 | |||
| a846462d02 | |||
| 0dbafd0a82 | |||
| 83e5b94ddf | |||
| dd8285e1ce | |||
| ac5d5351a6 | |||
| ad012b4cd4 | |||
| 5fcf1f180b | |||
| 9a74d89477 | |||
| dc4b576bc1 | |||
| e94dfe8c5c | |||
| 7aa6bfccf6 | |||
| 5fef014cb4 | |||
| 0bdfd999d2 | |||
| 4ad559e107 | |||
| f220e09193 | |||
| de2a1c104f | |||
| 52c553827f | |||
| cd64794f55 | |||
| bd9148e6e9 | |||
| 1e1bba6b16 | |||
| 2b992cf2a8 | |||
| c051c3a4c3 | |||
| 3006020106 | |||
| 3b9465a3bc | |||
| 5d50228945 | |||
| 7277f27fae | |||
| ee3de811d3 | |||
| c93333928a | |||
| 12937cb2c1 | |||
| 097a5753f9 | |||
| 9ee6aab831 | |||
| da804b174a | |||
| 93175b7caf | |||
| e37d92cdfd | |||
| 1c38dabe79 | |||
| 7b8daa7670 | |||
| a7449a1db3 | |||
| 746eeb767d | |||
| 36a8683643 | |||
| 888136f733 | |||
| ae36b6e196 | |||
| e70ce0f52d | |||
| 22ca93c988 | |||
| 740f8f1f56 | |||
| f148efd2a0 | |||
| ac95dec9d8 | |||
| 0b68d4bc77 | |||
| 8c37290bc3 | |||
| b4bafb59be | |||
| 41450c4eaf | |||
| e9201e0e30 | |||
| ae046be3b1 | |||
| a2a4a75603 | |||
| 714c914432 | |||
| 294403d590 | |||
| 117e7a22e7 | |||
| 0fbd1b1678 | |||
| b5cca06ce2 | |||
| 3c4dd84a61 | |||
| 9ad49500f9 | |||
| 4de087ae45 | |||
| 42a11107fd | |||
| b916b86bc5 | |||
| 0bfda47561 | |||
| ddbe8f8e99 | |||
| 5b53a7e4c0 | |||
| 42eabb7bda | |||
| 144d6401b5 | |||
| 77e8251aa7 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -10,6 +10,9 @@
|
||||
!test_config.yml
|
||||
*.json
|
||||
*.xml
|
||||
!tests/fixtures/*.xml
|
||||
!tests/fixtures/*.jpg
|
||||
!tests/fixtures/*.json
|
||||
logs/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
@@ -37,3 +40,7 @@ traceback.log
|
||||
htmlcov/
|
||||
.coverage
|
||||
coverage.xml
|
||||
.hypothesis/
|
||||
|
||||
# Local diagnostic traces
|
||||
debug/
|
||||
|
||||
Binary file not shown.
@@ -37,3 +37,9 @@ Found in `device_facade.py`.
|
||||
Instead of hardcoding limits like `max_likes = 50`, the bot stops interacting based on **simulated boredom**.
|
||||
- The `ResonanceEngine` calculates the aesthetic score of content.
|
||||
- The `DopamineEngine` uses this score to modulate pace. High resonance = engagement. Low resonance over multiple posts = early session termination (simulating human fatigue).
|
||||
|
||||
## 4. The 100% Autonomy Directive (Zero Hardcoding)
|
||||
GramPilot is designed as a true agent, not a state-machine script. It operates on **absolute zero hardcoded UI states or edge cases**.
|
||||
- **No Manual Guards**: Features like `if "row_feed_button_like" not in xml:` or `if state == "ReelsFeed":` are strictly prohibited. The bot must understand the screen via its Vision-Language-Action (VLA) pipeline.
|
||||
- **No Hand-Holding**: If the LLM makes a mistake (e.g., clicking the wrong button in a DM), the solution is to improve the VLM prompt, the system architecture, or the Visual Critic. We never insert `if is_dm_thread:` hacks.
|
||||
- **Smart like a human**: The bot navigates by visually confirming targets, detecting obstacles when the UI organically stops responding, and inferring context precisely like a real user scrolling.
|
||||
|
||||
@@ -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,84 @@ 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 # noqa: E402
|
||||
from GramAddict.core.behaviors.anomaly_handler import AnomalyHandlerPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.close_friends_guard import CloseFriendsGuardPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.comment import CommentPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.darwin_dwell import DarwinDwellPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.like import LikePlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.obstacle_guard import ObstacleGuardPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.perfect_snapping import PerfectSnappingPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.post_data_extraction import PostDataExtractionPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.post_interaction import PostInteractionPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.profile_visit import ProfileVisitPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.rabbit_hole import RabbitHolePlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.repost import RepostPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.resonance_evaluator import ResonanceEvaluatorPlugin # noqa: E402
|
||||
from GramAddict.core.behaviors.scrape_profile import ScrapeProfilePlugin # 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.
|
||||
|
||||
|
||||
def load_all_plugins():
|
||||
"""
|
||||
Registers all available core behavior plugins into the global registry.
|
||||
Useful for testing or full-agent initialization.
|
||||
"""
|
||||
registry = PluginRegistry.get_instance()
|
||||
registry.register(AdGuardPlugin())
|
||||
registry.register(AnomalyHandlerPlugin())
|
||||
registry.register(CloseFriendsGuardPlugin())
|
||||
registry.register(CommentPlugin())
|
||||
registry.register(DarwinDwellPlugin())
|
||||
registry.register(LikePlugin())
|
||||
registry.register(ObstacleGuardPlugin())
|
||||
registry.register(PerfectSnappingPlugin())
|
||||
registry.register(PostDataExtractionPlugin())
|
||||
registry.register(PostInteractionPlugin())
|
||||
registry.register(ProfileVisitPlugin())
|
||||
registry.register(RabbitHolePlugin())
|
||||
registry.register(RepostPlugin())
|
||||
registry.register(ResonanceEvaluatorPlugin())
|
||||
registry.register(ScrapeProfilePlugin())
|
||||
|
||||
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 = ctx.cognitive_stack.get("telepathic") or 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)
|
||||
99
GramAddict/core/behaviors/comment.py
Normal file
99
GramAddict/core/behaviors/comment.py
Normal file
@@ -0,0 +1,99 @@
|
||||
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
|
||||
|
||||
# Safety Guard: Do not comment on stories or grids
|
||||
xml_lower = (ctx.context_xml or "").lower()
|
||||
STORY_MARKERS = (
|
||||
"reel_viewer_media_layout",
|
||||
"reel_viewer_header",
|
||||
"reel_viewer_progress_bar",
|
||||
"reel_viewer_root",
|
||||
)
|
||||
if any(marker in xml_lower for marker in STORY_MARKERS):
|
||||
return False
|
||||
|
||||
if "explore_action_bar" in xml_lower or "profile_tabs_container" in xml_lower:
|
||||
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, scraped=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)
|
||||
|
||||
if nav_graph.do("tap follow button"):
|
||||
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, scraped=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}
|
||||
)
|
||||
|
||||
62
GramAddict/core/behaviors/like.py
Normal file
62
GramAddict/core/behaviors/like.py
Normal file
@@ -0,0 +1,62 @@
|
||||
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):
|
||||
logger.error("LikePlugin: limit check failed")
|
||||
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, scraped=False)
|
||||
ctx.session_state.totalLikes += 1
|
||||
return BehaviorResult(executed=True, interactions=1)
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
71
GramAddict/core/behaviors/obstacle_guard.py
Normal file
71
GramAddict/core/behaviors/obstacle_guard.py
Normal file
@@ -0,0 +1,71 @@
|
||||
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.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(xml)
|
||||
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
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
50
GramAddict/core/behaviors/perfect_snapping.py
Normal file
50
GramAddict/core/behaviors/perfect_snapping.py
Normal file
@@ -0,0 +1,50 @@
|
||||
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:
|
||||
if not getattr(self, "_enabled", True):
|
||||
return False
|
||||
|
||||
xml_lower = ctx.context_xml.lower()
|
||||
# Do not snap if we are on a profile page or grid, it's meant for posts.
|
||||
if "profile_tabs_container" in xml_lower or "explore_grid" in xml_lower:
|
||||
return False
|
||||
|
||||
return 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)
|
||||
86
GramAddict/core/behaviors/resonance_evaluator.py
Normal file
86
GramAddict/core/behaviors/resonance_evaluator.py
Normal file
@@ -0,0 +1,86 @@
|
||||
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...")
|
||||
persona_interests = getattr(ctx.configs.args, "persona_interests", [])
|
||||
vibe = tele.evaluate_post_vibe(ctx.device, persona_interests)
|
||||
vibe_score = vibe.get("quality_score", 5) / 10.0
|
||||
if vibe.get("matches_niche"):
|
||||
vibe_score = min(1.0, vibe_score + 0.2)
|
||||
res_score = (res_score * 0.3) + (vibe_score * 0.7)
|
||||
|
||||
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)
|
||||
78
GramAddict/core/behaviors/scrape_profile.py
Normal file
78
GramAddict/core/behaviors/scrape_profile.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import logging
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorPlugin, BehaviorResult
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScrapeProfilePlugin(BehaviorPlugin):
|
||||
"""
|
||||
Extracts profile metadata (followers, following, bio) when visiting a profile.
|
||||
|
||||
Priority: 45. (Runs after ProfileGuard, before deep interactions like GridLike)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "scrape_profile"
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 45
|
||||
|
||||
def can_activate(self, ctx: BehaviorContext) -> bool:
|
||||
if not getattr(self, "_enabled", True):
|
||||
return False
|
||||
|
||||
# Only activate if scrape_profiles is True in config
|
||||
if not getattr(ctx.configs.args, "scrape_profiles", False):
|
||||
return False
|
||||
|
||||
# Only activate when we are actively visiting a profile (via ProfileVisitPlugin)
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
if not nav_graph or nav_graph.current_state != "ProfileView":
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
from colorama import Fore
|
||||
|
||||
logger.info(f"📊 [Scraping] Extracting metadata for @{ctx.username}...", extra={"color": f"{Fore.CYAN}"})
|
||||
|
||||
telepathic = ctx.cognitive_stack.get("telepathic") or TelepathicEngine.get_instance()
|
||||
crm = ctx.cognitive_stack.get("crm")
|
||||
|
||||
xml_check = ctx.context_xml or ctx.device.dump_hierarchy()
|
||||
|
||||
f_node = telepathic.find_best_node(xml_check, "Followers count text or number", device=ctx.device)
|
||||
fg_node = telepathic.find_best_node(xml_check, "Following count text or number", device=ctx.device)
|
||||
bio_node = telepathic.find_best_node(xml_check, "User biography or description text", device=ctx.device)
|
||||
|
||||
scraped_data = {
|
||||
"username": ctx.username,
|
||||
"followers": f_node.get("text") if f_node else "unknown",
|
||||
"following": fg_node.get("text") if fg_node else "unknown",
|
||||
"bio": bio_node.get("text") if bio_node else "No bio",
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"✅ [Scraping] Data acquired: {scraped_data['followers']} followers, {scraped_data['following']} following."
|
||||
)
|
||||
|
||||
ctx.session_state.add_interaction(source=ctx.username, succeed=False, followed=False, scraped=True)
|
||||
|
||||
if crm:
|
||||
try:
|
||||
crm.enrich_lead(ctx.username, scraped_data)
|
||||
logger.info(f"💾 [CRM] Enriched lead @{ctx.username} in database.")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ [CRM] Failed to enrich lead @{ctx.username}: {e}")
|
||||
|
||||
# Return executed=True, but we don't return interactions=1 since it's just data extraction
|
||||
return BehaviorResult(executed=True)
|
||||
@@ -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)
|
||||
|
||||
@@ -23,8 +23,12 @@ class Config:
|
||||
if is_pytest:
|
||||
self.args = []
|
||||
else:
|
||||
self.args = sys.argv
|
||||
self.args = list(sys.argv)
|
||||
self.module = False
|
||||
|
||||
if not self.module and "--config" not in self.args:
|
||||
if os.path.exists("config.yml"):
|
||||
self.args.extend(["--config", "config.yml"])
|
||||
self.config = None
|
||||
self.config_list = None
|
||||
self.actions = {}
|
||||
@@ -81,6 +85,9 @@ class Config:
|
||||
self.username = self.username[0]
|
||||
self.debug = self.config.get("debug", False)
|
||||
self.app_id = self.config.get("app_id", "com.instagram.android")
|
||||
|
||||
# Autonomous goals removed — the bot now derives tasks from mission + plugins
|
||||
# via GoalDecomposer. See GramAddict/core/goal_decomposer.py.
|
||||
else:
|
||||
if "--debug" in self.args:
|
||||
self.debug = True
|
||||
@@ -99,9 +106,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 +134,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 +146,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 +165,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:
|
||||
@@ -222,37 +315,38 @@ class Config:
|
||||
logger.debug(f"Arguments used: {' '.join(sys.argv[1:])}")
|
||||
if self.config:
|
||||
logger.debug(f"Config used: {self.config}")
|
||||
if len(sys.argv) <= 1:
|
||||
if len(sys.argv) <= 1 and not self.config:
|
||||
self.parser.print_help()
|
||||
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 +359,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 +387,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,61 @@ 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:
|
||||
err_msg = str(e)
|
||||
err_type = str(type(e))
|
||||
if any(
|
||||
keyword in err_type or keyword in err_msg
|
||||
for keyword in ["ConnectError", "ConnectionRefused", "ConnectionError", "Timeout"]
|
||||
):
|
||||
logger.error(f"⚠️ [ADB ConnectError] Could not connect to device '{device_id}'.")
|
||||
|
||||
# Proactive Discovery
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(["adb", "devices"], capture_output=True, text=True, timeout=2)
|
||||
lines = [
|
||||
line.strip()
|
||||
for line in result.stdout.split("\n")
|
||||
if line.strip() and not line.startswith("List of devices")
|
||||
]
|
||||
devices = [line.split("\t")[0] for line in lines if "device" in line]
|
||||
|
||||
if devices:
|
||||
logger.info("🔍 Proactive Discovery: I found the following devices connected:")
|
||||
for d in devices:
|
||||
if d.split(":")[0] == device_id.split(":")[0]:
|
||||
logger.info(f" 👉 {d} (MATCHING IP - Is this the same device with a different port?)")
|
||||
else:
|
||||
logger.info(f" - {d}")
|
||||
else:
|
||||
logger.warning("🔍 Proactive Discovery: No ADB devices found. Is your phone authorized?")
|
||||
except Exception as discovery_err:
|
||||
logger.debug(f"Proactive discovery failed: {discovery_err}")
|
||||
|
||||
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 +88,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 +126,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 +144,6 @@ class DeviceFacade:
|
||||
def unlock(self):
|
||||
self.deviceV2.unlock()
|
||||
|
||||
|
||||
@property
|
||||
def info(self):
|
||||
return self.deviceV2.info
|
||||
@@ -132,7 +169,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()
|
||||
@@ -143,17 +181,21 @@ class DeviceFacade:
|
||||
def press(self, key):
|
||||
self.deviceV2.press(key)
|
||||
|
||||
@adb_retry()
|
||||
def back(self):
|
||||
self.deviceV2.press("back")
|
||||
|
||||
@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 +205,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 +236,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 +243,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 +275,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 +303,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 +324,75 @@ 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
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
traces_root = os.path.join("debug", "session_traces")
|
||||
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)
|
||||
self._trace_dir = os.path.join(traces_root, ts)
|
||||
os.makedirs(self._trace_dir, exist_ok=True)
|
||||
|
||||
|
||||
# Cleanup: keep only last 5 session folders
|
||||
try:
|
||||
if os.path.exists(traces_root):
|
||||
folders = [
|
||||
os.path.join(traces_root, d)
|
||||
for d in os.listdir(traces_root)
|
||||
if os.path.isdir(os.path.join(traces_root, d))
|
||||
]
|
||||
folders.sort(key=os.path.getmtime)
|
||||
while len(folders) > 5:
|
||||
oldest = folders.pop(0)
|
||||
shutil.rmtree(oldest, ignore_errors=True)
|
||||
logger.info(f"🧹 [Cleanup] Removed old session trace: {oldest}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to cleanup old traces: {e}")
|
||||
|
||||
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)
|
||||
|
||||
# Dump screenshot as well
|
||||
try:
|
||||
import base64
|
||||
|
||||
screenshot_b64 = self.get_screenshot_b64()
|
||||
if screenshot_b64:
|
||||
screenshot_data = base64.b64decode(screenshot_b64)
|
||||
screenshot_path = trace_path.replace(".xml", ".jpg")
|
||||
with open(screenshot_path, "wb") as f:
|
||||
f.write(screenshot_data)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to capture screenshot for session trace: {e}")
|
||||
|
||||
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()
|
||||
if img is None:
|
||||
return None
|
||||
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,54 +9,64 @@ 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__)
|
||||
|
||||
DUMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "debug", "xml_dumps")
|
||||
MAX_DUMPS_PER_CATEGORY = 50
|
||||
MAX_DUMPS_PER_CATEGORY = 5
|
||||
|
||||
|
||||
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.
|
||||
Examples: 'context_lost', 'vlm_hallucination', 'nav_failure',
|
||||
'stuck_on_post', 'unexpected_screen'
|
||||
extra_context: Optional dict with additional metadata (intent, expected state, etc.)
|
||||
Capture and save the current UI hierarchy and screenshot to disk for debugging.
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
# Capture and write screenshot
|
||||
try:
|
||||
import base64
|
||||
|
||||
screenshot_b64 = device.get_screenshot_b64()
|
||||
if screenshot_b64:
|
||||
screenshot_data = base64.b64decode(screenshot_b64)
|
||||
screenshot_path = filepath.replace(".xml", ".jpg")
|
||||
with open(screenshot_path, "wb") as f:
|
||||
f.write(screenshot_data)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Diagnostic] Could not capture screenshot: {e}")
|
||||
|
||||
# Write companion metadata JSON
|
||||
meta = {
|
||||
"reason": reason,
|
||||
"timestamp": ts,
|
||||
"xml_file": filename,
|
||||
"screenshot_file": filename.replace(".xml", ".jpg"),
|
||||
}
|
||||
# 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,39 +79,68 @@ 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}")
|
||||
|
||||
|
||||
logger.info(f"📸 [Diagnostic] UI state, screenshot, 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}")
|
||||
return None
|
||||
|
||||
|
||||
def _rotate_dumps(category_prefix: str):
|
||||
"""Keep only the last MAX_DUMPS_PER_CATEGORY dumps per category."""
|
||||
def _rotate_dumps(category_prefix: str = None):
|
||||
"""Keep only the last MAX_DUMPS_PER_CATEGORY dumps per category. If no category, cleans all."""
|
||||
try:
|
||||
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]
|
||||
for f in files_to_remove:
|
||||
xml_path = os.path.join(DUMP_DIR, f)
|
||||
meta_path = xml_path.replace(".xml", ".meta.json")
|
||||
os.remove(xml_path)
|
||||
if os.path.exists(meta_path):
|
||||
os.remove(meta_path)
|
||||
except Exception:
|
||||
pass
|
||||
if not os.path.exists(DUMP_DIR):
|
||||
return
|
||||
|
||||
# Get all unique timestamps/prefixes
|
||||
all_files = os.listdir(DUMP_DIR)
|
||||
prefixes = set()
|
||||
for f in all_files:
|
||||
# Format is usually reason__timestamp.ext
|
||||
if "__" in f:
|
||||
prefix = f.split(".")[0]
|
||||
prefixes.add(prefix)
|
||||
|
||||
# Group prefixes by category
|
||||
categories = {}
|
||||
for p in prefixes:
|
||||
parts = p.split("__")
|
||||
if len(parts) >= 2:
|
||||
cat = parts[0]
|
||||
if cat not in categories:
|
||||
categories[cat] = []
|
||||
categories[cat].append(p)
|
||||
|
||||
for cat, prefs in categories.items():
|
||||
if category_prefix and cat != category_prefix:
|
||||
continue
|
||||
|
||||
prefs.sort() # chronological
|
||||
if len(prefs) > MAX_DUMPS_PER_CATEGORY:
|
||||
prefs_to_remove = prefs[: len(prefs) - MAX_DUMPS_PER_CATEGORY]
|
||||
for p_rm in prefs_to_remove:
|
||||
for ext in [".xml", ".jpg", ".log", ".meta.json"]:
|
||||
fp = os.path.join(DUMP_DIR, p_rm + ext)
|
||||
if os.path.exists(fp):
|
||||
os.remove(fp)
|
||||
|
||||
# Also clean orphaned files that don't match any known prefix pattern
|
||||
for f in all_files:
|
||||
if "__" not in f:
|
||||
fp = os.path.join(DUMP_DIR, f)
|
||||
if os.path.isfile(fp):
|
||||
os.remove(fp)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[Diagnostic] Error during dump rotation: {e}")
|
||||
|
||||
@@ -1,31 +1,75 @@
|
||||
import logging
|
||||
import random
|
||||
|
||||
from colorama import Fore, Style
|
||||
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hard cap: maximum DM replies per inbox visit to prevent spam.
|
||||
MAX_REPLIES_PER_INBOX_VISIT = 3
|
||||
|
||||
# Sentinel values that indicate missing message context.
|
||||
_EMPTY_CONTEXT_SENTINELS = frozenset({"no previous context", "", "none", "n/a"})
|
||||
|
||||
|
||||
# Structural resource-IDs that indicate a real "Send" button.
|
||||
def _is_send_button(node: dict) -> bool:
|
||||
"""Semantic verification: returns True if the node is identified as a Send button."""
|
||||
desc = (node.get("description") or node.get("desc", "")).lower()
|
||||
text = (node.get("text") or "").lower()
|
||||
rid = (node.get("id") or node.get("resource_id", "")).lower()
|
||||
|
||||
# Accept if semantic markers indicate sending
|
||||
if any(m in rid for m in ["send", "composer_button"]):
|
||||
return True
|
||||
if any(m in desc for m in ["send", "absenden"]):
|
||||
return True
|
||||
if text == "send" or text == "absenden":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Safety guarantees:
|
||||
- Refuses to execute if dm_reply plugin is disabled in config.
|
||||
- Skips threads with no extractable text context.
|
||||
- Structurally verifies the Send button before logging success.
|
||||
- Hard-caps replies per inbox visit to MAX_REPLIES_PER_INBOX_VISIT.
|
||||
"""
|
||||
logger.info(f"🧠 [DM Engine] Initiating inbox processing in {current_target}...", extra={"color": f"{Style.BRIGHT}{Fore.CYAN}"})
|
||||
|
||||
# ── Kill-Switch: Respect dm_reply.enabled config ──
|
||||
dm_plugin_config = configs.get_plugin_config("dm_reply")
|
||||
if not dm_plugin_config.get("enabled", False):
|
||||
logger.warning(
|
||||
"🛑 [DM Engine] dm_reply plugin is DISABLED in config. Refusing to process inbox.",
|
||||
extra={"color": f"{Fore.RED}"},
|
||||
)
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
|
||||
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
|
||||
|
||||
replies_this_visit = 0
|
||||
|
||||
while not dopamine.is_app_session_over():
|
||||
# Limits check
|
||||
limit_val = session_state.check_limit(SessionState.Limit.PM)
|
||||
@@ -34,89 +78,183 @@ 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()
|
||||
|
||||
|
||||
# --- Zero Trust Structural Guard ---
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
|
||||
|
||||
identity_engine = ScreenIdentity(getattr(configs.args, "username", ""))
|
||||
screen_info = identity_engine.identify(xml_dump)
|
||||
|
||||
screen_type = screen_info["screen_type"]
|
||||
is_inbox = screen_type == ScreenType.DM_INBOX
|
||||
is_thread = screen_type == ScreenType.DM_THREAD
|
||||
|
||||
if is_thread:
|
||||
logger.warning("⚠️ [Structural Guard] DM Engine trapped in an open thread. Escaping...")
|
||||
device.press("back")
|
||||
from GramAddict.core.bot_flow import sleep
|
||||
|
||||
sleep(1.5)
|
||||
continue
|
||||
|
||||
if not is_inbox:
|
||||
# We have drifted somewhere entirely alien (like Privacy Settings)
|
||||
logger.error(
|
||||
f"🛑 [Structural Guard] Alien context detected ({screen_type}). Not in Inbox. Triggering CONTEXT_LOST."
|
||||
)
|
||||
return "CONTEXT_LOST"
|
||||
# -----------------------------------
|
||||
|
||||
# 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}")
|
||||
|
||||
|
||||
# ── Context Guard: Skip threads with no extractable message ──
|
||||
if context_text.strip().lower() in _EMPTY_CONTEXT_SENTINELS:
|
||||
logger.warning(
|
||||
"⏭️ [DM Engine] Thread has no extractable message context (story reply / media-only). Skipping."
|
||||
)
|
||||
device.press("back")
|
||||
sleep(1.5)
|
||||
continue
|
||||
|
||||
# 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)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
if send_nodes and not send_nodes[0].get("skip"):
|
||||
s_node = send_nodes[0]
|
||||
# ── Iteration Cap: Prevent DM spam ──
|
||||
if replies_this_visit >= MAX_REPLIES_PER_INBOX_VISIT:
|
||||
logger.info(
|
||||
f"🛑 [DM Engine] Reached max replies per inbox visit ({MAX_REPLIES_PER_INBOX_VISIT}). Exiting."
|
||||
)
|
||||
device.press("back")
|
||||
sleep(1.0)
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
if send_nodes and not send_nodes[0].get("skip"):
|
||||
s_node = send_nodes[0]
|
||||
|
||||
# ── Send Button Structural Verification ──
|
||||
if not _is_send_button(s_node):
|
||||
s_rid = s_node.get("original_attribs", {}).get("resource-id", "unknown")
|
||||
logger.warning(
|
||||
f"⚠️ [DM Engine] Refused to click non-Send element: {s_rid}. Aborting reply."
|
||||
)
|
||||
else:
|
||||
_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, "", [])
|
||||
|
||||
replies_this_visit += 1
|
||||
dm_memory = cognitive_stack.get("dm_memory")
|
||||
if dm_memory:
|
||||
dm_memory.log_sent_dm("unknown_target", response_text, "", [])
|
||||
|
||||
# Return back to inbox
|
||||
device.press("back")
|
||||
sleep(1.0)
|
||||
|
||||
sleep(1.5)
|
||||
|
||||
# If keyboard was open, the first back only closed it. Check if still in thread.
|
||||
check_xml = device.dump_hierarchy()
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
|
||||
|
||||
check_identity = ScreenIdentity(getattr(configs.args, "username", ""))
|
||||
check_screen = check_identity.identify(check_xml)
|
||||
|
||||
if check_screen["screen_type"] == ScreenType.DM_THREAD:
|
||||
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:
|
||||
logger.error(f"⚠️ [Anomaly Handler] Exception in DM Loop: {e}")
|
||||
device.press("back")
|
||||
sleep(1.0)
|
||||
|
||||
check_xml = device.dump_hierarchy()
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
|
||||
|
||||
check_identity = ScreenIdentity(getattr(configs.args, "username", ""))
|
||||
check_screen = check_identity.identify(check_xml)
|
||||
|
||||
if check_screen["screen_type"] == ScreenType.DM_THREAD:
|
||||
device.press("back")
|
||||
sleep(1.0)
|
||||
|
||||
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)
|
||||
|
||||
276
GramAddict/core/goal_decomposer.py
Normal file
276
GramAddict/core/goal_decomposer.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
GoalDecomposer — Mission-Driven Task Planning
|
||||
|
||||
Translates the bot's `mission` config + `plugins` capabilities into
|
||||
concrete, weighted Task objects. Pure logic — no LLM, no device,
|
||||
no network, no side effects.
|
||||
|
||||
This is the bridge between:
|
||||
- "What does the user WANT?" (mission.strategy)
|
||||
- "What CAN the bot DO?" (enabled plugins + actions)
|
||||
- "What SHOULD it do NOW?" (weighted Task selection)
|
||||
|
||||
Tesla analogy: FSD doesn't have a "goal: drive safely" config.
|
||||
It derives behavior from destination + road rules + sensor capabilities.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Strategy Weight Tables ──
|
||||
# Each strategy defines relative weights for screen targets.
|
||||
# Higher weight = more likely to be selected by GrowthBrain.
|
||||
STRATEGY_WEIGHTS: Dict[str, Dict[str, float]] = {
|
||||
"aggressive_growth": {
|
||||
"HomeFeed": 0.15,
|
||||
"ExploreFeed": 0.45,
|
||||
"ReelsFeed": 0.15,
|
||||
"StoriesFeed": 0.10,
|
||||
"MessageInbox": 0.10,
|
||||
"FollowingList": 0.05,
|
||||
},
|
||||
"community_builder": {
|
||||
"HomeFeed": 0.40,
|
||||
"ExploreFeed": 0.10,
|
||||
"ReelsFeed": 0.05,
|
||||
"StoriesFeed": 0.25,
|
||||
"MessageInbox": 0.15,
|
||||
"FollowingList": 0.05,
|
||||
},
|
||||
"passive_learning": {
|
||||
"HomeFeed": 0.20,
|
||||
"ExploreFeed": 0.50,
|
||||
"ReelsFeed": 0.20,
|
||||
"StoriesFeed": 0.05,
|
||||
"MessageInbox": 0.00,
|
||||
"FollowingList": 0.05,
|
||||
},
|
||||
"stealth_lurker": {
|
||||
"HomeFeed": 0.35,
|
||||
"ExploreFeed": 0.25,
|
||||
"ReelsFeed": 0.15,
|
||||
"StoriesFeed": 0.15,
|
||||
"MessageInbox": 0.05,
|
||||
"FollowingList": 0.05,
|
||||
},
|
||||
}
|
||||
|
||||
# ── Plugin → Screen Mapping ──
|
||||
# Which plugins enable which screen targets.
|
||||
# A screen is only viable if at least one enabling plugin is active.
|
||||
# Some plugins work on MULTIPLE screens (likes work on home, explore, reels).
|
||||
PLUGIN_SCREENS_MAP: Dict[str, set] = {
|
||||
"likes": {"HomeFeed", "ExploreFeed", "ReelsFeed"},
|
||||
"comment": {"HomeFeed", "ExploreFeed"},
|
||||
"follow": {"HomeFeed", "ExploreFeed"},
|
||||
"repost": {"HomeFeed", "ExploreFeed"},
|
||||
"profile_visit": {"HomeFeed", "ExploreFeed"},
|
||||
"grid_like": {"HomeFeed"},
|
||||
"carousel_browsing": {"HomeFeed"},
|
||||
"rabbit_hole": {"HomeFeed", "ExploreFeed"},
|
||||
"story_view": {"StoriesFeed"},
|
||||
"dm_reply": {"MessageInbox"},
|
||||
}
|
||||
|
||||
# ── Action → Screen Mapping ──
|
||||
# The `actions:` config section maps directly to screens.
|
||||
ACTION_SCREEN_MAP: Dict[str, str] = {
|
||||
"feed": "HomeFeed",
|
||||
"explore": "ExploreFeed",
|
||||
"reels": "ReelsFeed",
|
||||
}
|
||||
|
||||
# ── Screen → Verb Mapping ──
|
||||
SCREEN_VERB_MAP: Dict[str, str] = {
|
||||
"HomeFeed": "browse_feed",
|
||||
"ExploreFeed": "browse_explore",
|
||||
"ReelsFeed": "browse_reels",
|
||||
"StoriesFeed": "view_stories",
|
||||
"MessageInbox": "check_messages",
|
||||
"FollowingList": "manage_following",
|
||||
}
|
||||
|
||||
# ── Screen → Human Intent ──
|
||||
SCREEN_INTENT_MAP: Dict[str, str] = {
|
||||
"HomeFeed": "Interact with posts in the home feed",
|
||||
"ExploreFeed": "Discover and engage with new content",
|
||||
"ReelsFeed": "Browse and interact with reels",
|
||||
"StoriesFeed": "View and react to stories",
|
||||
"MessageInbox": "Reply to unread direct messages",
|
||||
"FollowingList": "Review and manage following list",
|
||||
}
|
||||
|
||||
DEFAULT_BUDGET = 5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Task:
|
||||
"""A concrete, executable unit of work for the bot.
|
||||
|
||||
Unlike abstract goals ("nurture community"), a Task has:
|
||||
- A specific screen to navigate to
|
||||
- A measurable budget (how many posts/items to process)
|
||||
- A weight for probabilistic selection
|
||||
- A human-readable intent for logging
|
||||
"""
|
||||
|
||||
verb: str
|
||||
target_screen: str
|
||||
intent: str
|
||||
budget_posts: int
|
||||
weight: float
|
||||
|
||||
|
||||
class GoalDecomposer:
|
||||
"""Translates mission + plugins → weighted Task list.
|
||||
|
||||
Pure logic, zero side effects. Call generate_tasks() to get
|
||||
the bot's action menu for the current session.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plugins: Dict[str, dict],
|
||||
actions: Dict[str, str],
|
||||
mission: Dict[str, str],
|
||||
):
|
||||
self._plugins = plugins
|
||||
self._actions = actions
|
||||
self._strategy = mission.get("strategy", "aggressive_growth")
|
||||
|
||||
def generate_tasks(self) -> List[Task]:
|
||||
"""Generate weighted tasks from config.
|
||||
|
||||
Returns an empty list if no plugins are enabled —
|
||||
the bot literally has nothing to do.
|
||||
"""
|
||||
viable_screens = self._discover_viable_screens()
|
||||
if not viable_screens:
|
||||
return []
|
||||
|
||||
strategy_weights = STRATEGY_WEIGHTS.get(self._strategy, STRATEGY_WEIGHTS["aggressive_growth"])
|
||||
|
||||
tasks = []
|
||||
for screen in viable_screens:
|
||||
weight = strategy_weights.get(screen, 0.1)
|
||||
if weight <= 0:
|
||||
continue
|
||||
|
||||
budget = self._budget_for_screen(screen)
|
||||
verb = SCREEN_VERB_MAP.get(screen, "browse")
|
||||
intent = SCREEN_INTENT_MAP.get(screen, f"Interact on {screen}")
|
||||
|
||||
tasks.append(
|
||||
Task(
|
||||
verb=verb,
|
||||
target_screen=screen,
|
||||
intent=intent,
|
||||
budget_posts=budget,
|
||||
weight=weight,
|
||||
)
|
||||
)
|
||||
|
||||
return tasks
|
||||
|
||||
def _discover_viable_screens(self) -> set:
|
||||
"""Determine which screens the bot can meaningfully interact on.
|
||||
|
||||
A screen is viable if it has BOTH:
|
||||
1. A route (action config or plugin-implied), AND
|
||||
2. At least one active plugin that can DO something there.
|
||||
|
||||
Without an active plugin, navigating to a screen is pointless —
|
||||
the bot would just scroll with nothing to interact on.
|
||||
"""
|
||||
# 1. Collect screens with active plugins
|
||||
plugin_screens: set = set()
|
||||
for plugin_name, screens in PLUGIN_SCREENS_MAP.items():
|
||||
plugin_cfg = self._plugins.get(plugin_name, {})
|
||||
if not plugin_cfg:
|
||||
continue
|
||||
if not self._is_plugin_active(plugin_cfg):
|
||||
continue
|
||||
plugin_screens.update(screens)
|
||||
|
||||
# 2. Screens from actions are only viable if plugins exist for them
|
||||
action_screens: set = set()
|
||||
for action_key, screen in ACTION_SCREEN_MAP.items():
|
||||
if action_key in self._actions and self._actions[action_key]:
|
||||
action_screens.add(screen)
|
||||
|
||||
# 3. A screen must have plugin coverage to be viable
|
||||
# Action-enabled screens need at least one active plugin
|
||||
viable = action_screens & plugin_screens
|
||||
|
||||
# 4. Plugin-only screens (story_view, dm_reply) are viable
|
||||
# even without an explicit action config
|
||||
viable |= plugin_screens
|
||||
|
||||
return viable
|
||||
|
||||
def _is_plugin_active(self, plugin_cfg: dict) -> bool:
|
||||
"""Check if a plugin config represents an active plugin.
|
||||
|
||||
A plugin is active if:
|
||||
- It has `enabled: true` (explicit), OR
|
||||
- It has `percentage` > 0 (implicit enable), OR
|
||||
- It has any config keys and `enabled` is not explicitly False
|
||||
"""
|
||||
# Explicit disable
|
||||
if plugin_cfg.get("enabled") is False:
|
||||
return False
|
||||
|
||||
# Explicit enable
|
||||
if plugin_cfg.get("enabled") is True:
|
||||
return True
|
||||
|
||||
# Percentage-based: 0% means disabled
|
||||
pct = plugin_cfg.get("percentage")
|
||||
if pct is not None:
|
||||
try:
|
||||
return float(pct) > 0
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
# Has config keys but no explicit enabled/percentage = active
|
||||
return bool(plugin_cfg)
|
||||
|
||||
def _budget_for_screen(self, screen: str) -> int:
|
||||
"""Determine the post budget for a screen.
|
||||
|
||||
Reads from actions config (e.g. feed: "5-10") and parses
|
||||
the range string into a random integer within bounds.
|
||||
"""
|
||||
# Map screen back to action key
|
||||
reverse_map = {v: k for k, v in ACTION_SCREEN_MAP.items()}
|
||||
action_key = reverse_map.get(screen)
|
||||
|
||||
if action_key and action_key in self._actions:
|
||||
return _parse_range(self._actions[action_key])
|
||||
|
||||
# Special screens get fixed budgets from plugin config
|
||||
if screen == "StoriesFeed":
|
||||
story_cfg = self._plugins.get("story_view", {})
|
||||
count_str = story_cfg.get("count", "1-3")
|
||||
return _parse_range(str(count_str))
|
||||
|
||||
if screen == "MessageInbox":
|
||||
return DEFAULT_BUDGET
|
||||
|
||||
return DEFAULT_BUDGET
|
||||
|
||||
|
||||
def _parse_range(range_str: str) -> int:
|
||||
"""Parse a range string like '5-10' into a random int within bounds."""
|
||||
try:
|
||||
if "-" in str(range_str):
|
||||
parts = str(range_str).split("-")
|
||||
low, high = int(parts[0]), int(parts[1])
|
||||
return random.randint(low, high)
|
||||
return int(range_str)
|
||||
except (ValueError, IndexError):
|
||||
return DEFAULT_BUDGET
|
||||
@@ -13,858 +13,23 @@ 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.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
|
||||
from GramAddict.core.utils import random_sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Re-export for backward compatibility (optional but helps minimize import breakage)
|
||||
__all__ = ["GoalExecutor", "ScreenIdentity", "ScreenType", "PathMemory", "NavigationKnowledge", "GoalPlanner"]
|
||||
|
||||
# ══════════════════════════════════════════════════════
|
||||
# 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",
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════
|
||||
# 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
|
||||
# ══════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@@ -952,10 +117,12 @@ class GoalExecutor:
|
||||
consecutive_back_presses = 0
|
||||
MAX_CONSECUTIVE_BACK = 3
|
||||
explored_nav_actions = set()
|
||||
visited_screens = set()
|
||||
for step_num in range(max_steps):
|
||||
# PERCEIVE
|
||||
screen = self.perceive()
|
||||
screen_type = screen["screen_type"]
|
||||
visited_screens.add(screen_type)
|
||||
|
||||
if last_screen_type and screen_type != last_screen_type:
|
||||
logger.debug(
|
||||
@@ -979,7 +146,7 @@ class GoalExecutor:
|
||||
screen["available_actions"] = masked_available
|
||||
|
||||
logger.debug(
|
||||
f"📍 [GOAP Step {step_num + 1}] On: {screen_type.value} | "
|
||||
f"📍 [GOAP Step {step_num + 1}] Goal: '{goal}' | On: {screen_type.value} | "
|
||||
f"Available: {screen.get('available_actions', [])[:5]}"
|
||||
)
|
||||
|
||||
@@ -1006,7 +173,13 @@ class GoalExecutor:
|
||||
continue
|
||||
|
||||
# PLAN
|
||||
action = self.planner.plan_next_step(goal, screen, explored_nav_actions=explored_nav_actions)
|
||||
action = self.planner.plan_next_step(
|
||||
goal,
|
||||
screen,
|
||||
explored_nav_actions=explored_nav_actions,
|
||||
action_failures=self.action_failures,
|
||||
visited_screens=visited_screens,
|
||||
)
|
||||
|
||||
if action is None:
|
||||
# Goal achieved!
|
||||
@@ -1032,6 +205,21 @@ class GoalExecutor:
|
||||
# Reset failures for this action since it eventually succeeded
|
||||
self.action_failures[action] = 0
|
||||
|
||||
if "scroll" in action.lower():
|
||||
logger.debug(
|
||||
"📍 [GOAP State] Scrolled successfully. Clearing explored actions to allow retrying off-screen elements."
|
||||
)
|
||||
explored_nav_actions.clear()
|
||||
# Keep action_failures for synthetic intents, but clear them for structural actions
|
||||
# so that the HD Map can retry route actions that might now be visible!
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
keys_to_clear = [
|
||||
k for k in self.action_failures.keys() if ScreenTopology.is_structural_action(screen_type, k)
|
||||
]
|
||||
for k in keys_to_clear:
|
||||
del self.action_failures[k]
|
||||
|
||||
# ── Back-Press Circuit Breaker ──
|
||||
if action == "press back":
|
||||
consecutive_back_presses += 1
|
||||
@@ -1139,6 +327,25 @@ class GoalExecutor:
|
||||
self._get_sae().ensure_clear_screen(max_attempts=3)
|
||||
return False
|
||||
|
||||
# ── Pre-Click Semantic Match Guard ──
|
||||
# For toggle intents (follow/like/save), verify the selected node
|
||||
# semantically matches the intent BEFORE clicking. This prevents
|
||||
# VLM hallucinations from clicking photo grid items when looking
|
||||
# for follow buttons.
|
||||
from GramAddict.core.perception.action_memory import _intent_matches_node
|
||||
|
||||
node_semantic = (
|
||||
f"text: '{best_node.get('text', '')}', "
|
||||
f"desc: '{best_node.get('description', '')}', "
|
||||
f"id: '{best_node.get('id', '')}'"
|
||||
)
|
||||
if not _intent_matches_node(action, node_semantic):
|
||||
logger.warning(
|
||||
f"🛡️ [GOAP Execute] Pre-click rejection: node does not match intent '{action}'. "
|
||||
f"Node: {node_semantic}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Execute click
|
||||
self.device.click(obj=best_node)
|
||||
import random
|
||||
@@ -1155,9 +362,16 @@ class GoalExecutor:
|
||||
# Determine if this was a navigation or an interaction
|
||||
is_navigation = any(k in action.lower() for k in ["tab", "open", "go to", "navigate", "following list"])
|
||||
action_success = False
|
||||
ui_changed = post_xml != xml_dump
|
||||
|
||||
# ── UI Change Detection with Noise Threshold ──
|
||||
# Raw string diffs of < 50 bytes are noise (timestamps, whitespace, counters).
|
||||
# A real navigation changes the XML by hundreds/thousands of bytes.
|
||||
MIN_UI_CHANGE_BYTES = 50
|
||||
xml_delta = abs(len(post_xml) - len(xml_dump))
|
||||
ui_changed = post_xml != xml_dump and xml_delta >= MIN_UI_CHANGE_BYTES
|
||||
logger.debug(
|
||||
f"[GOAP Verify] ui_changed={ui_changed}, " f"xml_len_pre={len(xml_dump)}, xml_len_post={len(post_xml)}"
|
||||
f"[GOAP Verify] ui_changed={ui_changed}, "
|
||||
f"xml_len_pre={len(xml_dump)}, xml_len_post={len(post_xml)}, delta={xml_delta}b"
|
||||
)
|
||||
|
||||
if is_navigation:
|
||||
@@ -1214,7 +428,8 @@ class GoalExecutor:
|
||||
else:
|
||||
# For interactions (like, follow) or unknown goals, use XML delta + semantic verify
|
||||
if ui_changed:
|
||||
verification = engine.verify_success(action, post_xml)
|
||||
score = best_node.get("score", 0.0) if best_node else 0.0
|
||||
verification = engine.verify_success(action, post_xml, device=self.device, confidence=score)
|
||||
if verification is True:
|
||||
action_success = True
|
||||
logger.info(f"✅ [GOAP Step] Interaction '{action}' successful.")
|
||||
|
||||
@@ -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,88 @@ 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_current_goal(self, dopamine_engine, available_goals: list[str], success_rates: dict = None) -> str:
|
||||
"""
|
||||
Autonomously selects the next strategic goal.
|
||||
If no goals are configured, falls back to legacy desires.
|
||||
Weights goals based on session success rates if provided.
|
||||
|
||||
.. deprecated::
|
||||
Use select_task() instead for concrete, plugin-linked task selection.
|
||||
"""
|
||||
import random
|
||||
|
||||
if not available_goals:
|
||||
# Legacy Desire Mapping (Fallback)
|
||||
return self.get_current_desire(dopamine_engine)
|
||||
|
||||
if dopamine_engine.boredom > 80:
|
||||
return "ShiftContext" # High boredom triggers a context shift
|
||||
|
||||
if not success_rates:
|
||||
return random.choice(available_goals)
|
||||
|
||||
weights = []
|
||||
for goal in available_goals:
|
||||
base_weight = 1.0
|
||||
success_count = success_rates.get(goal, 0)
|
||||
weight = base_weight + float(success_count)
|
||||
weights.append(weight)
|
||||
|
||||
return random.choices(available_goals, weights=weights, k=1)[0]
|
||||
|
||||
def select_task(self, dopamine_engine, available_tasks: list) -> "Optional[Task]":
|
||||
"""Select the next concrete Task using weighted random selection.
|
||||
|
||||
This is the primary interface for the orchestrator. Unlike get_current_goal()
|
||||
which returns abstract strings, this returns a Task object with a specific
|
||||
target_screen, budget, and success metric.
|
||||
|
||||
Returns:
|
||||
Task: The selected task to execute.
|
||||
None: If no tasks available or boredom is too high (ShiftContext signal).
|
||||
"""
|
||||
if not available_tasks:
|
||||
return None
|
||||
|
||||
# High boredom = ShiftContext (take a break, switch feed)
|
||||
if dopamine_engine.boredom > 85.0:
|
||||
logger.info("🧠 [GrowthBrain] Boredom too high for task selection. ShiftContext.")
|
||||
return None
|
||||
|
||||
weights = [task.weight for task in available_tasks]
|
||||
selected = random.choices(available_tasks, weights=weights, k=1)[0]
|
||||
logger.info(
|
||||
f"🧠 [GrowthBrain] Selected task: {selected.verb} → {selected.target_screen} "
|
||||
f"(weight={selected.weight:.2f}, budget={selected.budget_posts})"
|
||||
)
|
||||
return selected
|
||||
|
||||
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 +182,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 +222,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 +233,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 +245,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
|
||||
|
||||
86
GramAddict/core/interaction.py
Normal file
86
GramAddict/core/interaction.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from GramAddict.core.llm_provider import query_llm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMWriter:
|
||||
"""
|
||||
The Creative Engine — Content Generation for Interactions.
|
||||
|
||||
Generates high-fidelity, persona-aligned comments and messages.
|
||||
Replaces legacy static 'comment_list' with dynamic, contextual resonance.
|
||||
"""
|
||||
|
||||
def __init__(self, username: str, persona_interests: list[str], configs):
|
||||
self.username = username
|
||||
self.persona_interests = persona_interests
|
||||
self.configs = configs
|
||||
self.args = getattr(configs, "args", None)
|
||||
|
||||
def generate_comment(self, post_data: Dict) -> str:
|
||||
"""
|
||||
Generates a human-like comment based on post data and persona interests.
|
||||
"""
|
||||
if not post_data:
|
||||
logger.warning("✍️ [Writer] No post data provided. Using generic fallback.")
|
||||
return "Cool!"
|
||||
|
||||
caption = post_data.get("caption", "")
|
||||
description = post_data.get("description", "")
|
||||
target_username = post_data.get("username", "the user")
|
||||
|
||||
# Build context for the LLM
|
||||
context = f"Post by @{target_username}\n"
|
||||
if caption:
|
||||
context += f"Caption: {caption}\n"
|
||||
if description:
|
||||
context += f"Visual Description: {description}\n"
|
||||
|
||||
interests_str = ", ".join(self.persona_interests) if self.persona_interests else "general interesting things"
|
||||
|
||||
prompt = (
|
||||
f"You are an Instagram user interested in: {interests_str}.\n"
|
||||
f"You want to leave a brief, friendly, and authentic comment on the following post:\n\n"
|
||||
f"{context}\n"
|
||||
f"INSTRUCTIONS:\n"
|
||||
f"1. Keep it under 10 words.\n"
|
||||
f"2. Be casual and human. Avoid overly formal language or sounding like a bot.\n"
|
||||
f"3. Do NOT use more than one emoji.\n"
|
||||
f"4. Do NOT use hashtags.\n"
|
||||
f"5. Focus on something specific in the post if possible.\n"
|
||||
f"6. Reply with ONLY the comment text."
|
||||
)
|
||||
|
||||
model = getattr(self.args, "ai_writer_model", getattr(self.args, "ai_model", "llama3.2:1b"))
|
||||
url = getattr(
|
||||
self.args, "ai_writer_url", getattr(self.args, "ai_model_url", "http://localhost:11434/api/generate")
|
||||
)
|
||||
|
||||
logger.info(f"✍️ [Writer] Generating comment for @{target_username} using {model}...")
|
||||
|
||||
try:
|
||||
response_dict = query_llm(
|
||||
url=url,
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
system="You are a friendly Instagram user. You write short, authentic comments.",
|
||||
format_json=False,
|
||||
timeout=60,
|
||||
temperature=0.7, # Add some variety to avoid 'the to the' loops
|
||||
)
|
||||
|
||||
if response_dict and "response" in response_dict:
|
||||
comment = response_dict["response"].strip().strip('"')
|
||||
# Basic cleaning to remove LLM artifacts
|
||||
comment = comment.split("\n")[0] # Take only first line
|
||||
if not comment:
|
||||
return "Nice!"
|
||||
return comment
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"✍️ [Writer] Failed to generate comment: {e}")
|
||||
|
||||
return "Great post! 🔥"
|
||||
@@ -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,49 @@ 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"
|
||||
|
||||
else:
|
||||
# For free-text calls (Brain action extraction), explicitly disable
|
||||
# thinking mode. Reasoning models like qwen3.5 put EVERYTHING in
|
||||
# the thinking block and return response='', which is useless for
|
||||
# action extraction. think=false forces a direct response.
|
||||
req_data["think"] = False
|
||||
|
||||
# Ollama passes configs inside 'options'
|
||||
if temperature is not None or max_tokens is not None:
|
||||
req_data["options"] = {}
|
||||
@@ -290,12 +306,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,62 +322,79 @@ 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}
|
||||
else:
|
||||
# Ollama returns response OR thinking (for reasoning models)
|
||||
content = resp_json.get("response") or resp_json.get("thinking") or ""
|
||||
raw_response = resp_json.get("response", "")
|
||||
raw_thinking = resp_json.get("thinking", "")
|
||||
|
||||
logger.debug(f"DEBUG LLM PAYLOAD: response='{raw_response}', thinking='{raw_thinking}'")
|
||||
|
||||
# CRITICAL: For free-text mode (format_json=False), do NOT substitute
|
||||
# thinking for empty response. The thinking block is REASONING, not
|
||||
# a decision. The Brain parser would extract random actions from it.
|
||||
# For JSON mode (format_json=True), falling back to thinking IS correct
|
||||
# because reasoning models may place structured output in the thinking block.
|
||||
if format_json:
|
||||
content = raw_response or raw_thinking or ""
|
||||
extracted = extract_json(content)
|
||||
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.")
|
||||
resp_json["response"] = extracted
|
||||
logger.warning(f"Failed to extract JSON from content: {content[:100]}")
|
||||
else:
|
||||
content = extracted
|
||||
else:
|
||||
content = raw_response
|
||||
|
||||
return resp_json
|
||||
return {"response": content}
|
||||
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 +421,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 +435,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 +449,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 +463,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 +476,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
|
||||
87
GramAddict/core/navigation/brain.py
Normal file
87
GramAddict/core/navigation/brain.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from GramAddict.core.config import Config
|
||||
from GramAddict.core.llm_provider import query_llm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ask_brain_for_action(
|
||||
goal: str, screen_type: str, available_actions: List[str], explored_actions: set, context: dict = None
|
||||
) -> Optional[str]:
|
||||
"""Asks the VLM to decide the best available action to reach the goal, considering failures."""
|
||||
if not available_actions:
|
||||
return None
|
||||
|
||||
cfg = Config()
|
||||
url = (
|
||||
getattr(cfg.args, "ai_model_url", "http://localhost:11434/api/generate")
|
||||
if hasattr(cfg, "args")
|
||||
else "http://localhost:11434/api/generate"
|
||||
)
|
||||
model = getattr(cfg.args, "ai_model", "qwen3.5:latest") if hasattr(cfg, "args") else "qwen3.5:latest"
|
||||
|
||||
prompt = (
|
||||
f"You are an autonomous Instagram agent. Your ultimate goal is: '{goal}'.\n"
|
||||
f"You are currently on the screen: {screen_type}.\n"
|
||||
f"These actions are available to you right now: {available_actions}\n"
|
||||
)
|
||||
if explored_actions:
|
||||
prompt += f"You recently tried these actions but they failed or didn't help: {list(explored_actions)}\n"
|
||||
if context:
|
||||
prompt += f"Context: {context}\n"
|
||||
|
||||
prompt += (
|
||||
"INSTRUCTIONS:\n"
|
||||
"1. Reason about where you are. Consider the screen type and what actions make sense on that screen.\n"
|
||||
"2. If the goal requires navigating away from the current screen, choose the action that moves you closest to the goal.\n"
|
||||
"3. 'scroll down' reveals more UI elements on scrollable screens (feeds, profiles, lists). If your target is likely on this screen but not currently visible, you MUST choose 'scroll down'.\n"
|
||||
"4. 'press back' exits the current screen and returns to the previous one. Use it when you are on a screen that doesn't lead to your goal.\n"
|
||||
"5. DO NOT hallucinate actions. Reply ONLY with the exact string from the available actions list.\n"
|
||||
"6. Reply with ONLY the action string, nothing else."
|
||||
)
|
||||
|
||||
try:
|
||||
response = query_llm(
|
||||
url=url,
|
||||
model=model,
|
||||
prompt="Choose the next best action.",
|
||||
system=prompt,
|
||||
format_json=False,
|
||||
max_tokens=250,
|
||||
)
|
||||
if response:
|
||||
result = response if isinstance(response, str) else response.get("response", "")
|
||||
result = result.strip().strip("'\"")
|
||||
|
||||
# 1. Exact match check (ideal case)
|
||||
for act in available_actions:
|
||||
if act.lower() == result.lower():
|
||||
return act
|
||||
|
||||
# 2. Strict line-by-line check (often the model outputs the action on the last line)
|
||||
for line in reversed(result.splitlines()):
|
||||
line = line.strip().strip("'\"")
|
||||
for act in available_actions:
|
||||
if act.lower() == line.lower():
|
||||
return act
|
||||
|
||||
# 3. Fuzzy match (find the LAST mentioned action in the text, assuming it's the conclusion)
|
||||
best_act = None
|
||||
best_idx = -1
|
||||
for act in available_actions:
|
||||
idx = result.lower().rfind(act.lower())
|
||||
if idx > best_idx:
|
||||
best_idx = idx
|
||||
best_act = act
|
||||
|
||||
if best_act:
|
||||
logger.warning(f"🧠 [Brain] Extracted action '{best_act}' from verbose LLM output.")
|
||||
return best_act
|
||||
|
||||
logger.warning(f"🧠 [Brain] LLM returned an invalid action or no action found: '{result[:100]}...'. Falling back.")
|
||||
except Exception as e:
|
||||
logger.debug(f"🧠 [Brain] Error querying LLM: {e}")
|
||||
|
||||
return None
|
||||
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.generate_uuid(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}")
|
||||
245
GramAddict/core/navigation/planner.py
Normal file
245
GramAddict/core/navigation/planner.py
Normal file
@@ -0,0 +1,245 @@
|
||||
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,
|
||||
action_failures: dict = None,
|
||||
visited_screens: 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, action_failures, visited_screens
|
||||
)
|
||||
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,
|
||||
action_failures: dict = None,
|
||||
visited_screens: 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
|
||||
|
||||
visited_screens = visited_screens or set()
|
||||
|
||||
# 0b. No-Op Guard & Anti-Loop Guard:
|
||||
# - Strip tab actions that navigate to the CURRENT screen.
|
||||
# - Strip actions that navigate to PREVIOUSLY VISITED screens (except back-tracking).
|
||||
noop_actions = set()
|
||||
for action in available:
|
||||
expected = ScreenTopology.expected_screen_for_action(action, screen_type)
|
||||
if expected == screen_type:
|
||||
noop_actions.add(action)
|
||||
logger.debug(f"🛡️ [No-Op Guard] Stripping '{action}' — leads back to {screen_type.name}")
|
||||
elif expected in visited_screens and action != "press back":
|
||||
noop_actions.add(action)
|
||||
logger.debug(f"🛡️ [Anti-Loop Guard] Stripping '{action}' — leads to visited {expected.name}")
|
||||
|
||||
# Also strip actions where the HD Map says they go TO the current screen from OTHER screens
|
||||
for src_screen, transitions in ScreenTopology.TRANSITIONS.items():
|
||||
if src_screen == screen_type:
|
||||
continue # We already handled this screen's own transitions
|
||||
for action, dest in transitions.items():
|
||||
if dest == screen_type and action in available:
|
||||
noop_actions.add(action)
|
||||
logger.debug(
|
||||
f"🛡️ [No-Op Guard] Stripping '{action}' — known to navigate to current {screen_type.name}"
|
||||
)
|
||||
elif dest in visited_screens and action in available and action != "press back":
|
||||
noop_actions.add(action)
|
||||
logger.debug(f"🛡️ [Anti-Loop Guard] Stripping '{action}' — known to navigate to visited {dest.name}")
|
||||
|
||||
available = [a for a in available if a not in noop_actions]
|
||||
|
||||
# Build avoid_actions for HD Map route planning
|
||||
avoid_actions = (explored_nav_actions or set()).copy()
|
||||
if action_failures:
|
||||
for act, count in action_failures.items():
|
||||
if count >= 2: # MAX_RETRIES is 2 in goap
|
||||
avoid_actions.add(act)
|
||||
|
||||
target_screen = ScreenTopology.goal_to_target_screen(goal)
|
||||
|
||||
# ── 1. HD Map Pre-Check for Dead Ends ──
|
||||
# If the topological map KNOWS the target is unreachable due to action_failures,
|
||||
# we must preempt the Brain from blindly routing into a dead end.
|
||||
if target_screen and target_screen != screen_type:
|
||||
route = ScreenTopology.find_route(screen_type, target_screen, avoid_actions=avoid_actions)
|
||||
if route is None and ScreenTopology.find_route(screen_type, target_screen):
|
||||
logger.warning(
|
||||
f"🛡️ [HD Map] Target {target_screen.name} is unreachable due to masked edges! Preventing Brain from blind routing."
|
||||
)
|
||||
return None
|
||||
|
||||
# ── 2. Brain-Driven Decision Making (Primary Strategy) ──
|
||||
# The user explicitly wants the AI to be the primary driver of goals.
|
||||
from GramAddict.core.navigation.brain import ask_brain_for_action
|
||||
|
||||
brain_action = ask_brain_for_action(goal, screen_type.name, available, avoid_actions)
|
||||
if brain_action:
|
||||
logger.info(f"🧠 [Brain] Decided to execute: '{brain_action}' (to achieve: '{goal}')")
|
||||
return brain_action
|
||||
|
||||
# ── 2. HD Map Routing (Fallback) ──
|
||||
# If the Brain doesn't know what to do, try the deterministic topological map.
|
||||
target_screen = ScreenTopology.goal_to_target_screen(goal)
|
||||
if target_screen and target_screen != screen_type:
|
||||
route = ScreenTopology.find_route(screen_type, target_screen, avoid_actions=avoid_actions)
|
||||
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. Skipping HD Map.")
|
||||
else:
|
||||
logger.debug(
|
||||
f"🛡️ [HD Map] Route action '{next_action}' already explored and failed. Skipping HD Map."
|
||||
)
|
||||
|
||||
# ── 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, avoid_actions=avoid_actions)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
297
GramAddict/core/perception/action_memory.py
Normal file
297
GramAddict/core/perception/action_memory.py
Normal file
@@ -0,0 +1,297 @@
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# Semantic Match Keywords — SSOT for intent → element validation
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
# Maps toggle-intent keywords to required element markers.
|
||||
# If the intent contains the key, the clicked element MUST
|
||||
# contain at least one of the corresponding markers in its
|
||||
# text, content_desc, or resource_id.
|
||||
TOGGLE_INTENT_MARKERS = {
|
||||
"follow": ["follow", "gefolgt", "abonnieren"],
|
||||
"like": ["like", "heart", "gefällt"],
|
||||
"save": ["save", "saved", "bookmark", "speichern"],
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Guard: Refuses to store in Qdrant if the clicked element does not
|
||||
semantically match the intent. Prevents memory poisoning.
|
||||
"""
|
||||
ctx = self._last_click_context
|
||||
if not ctx:
|
||||
return
|
||||
|
||||
if intent and ctx["intent"] != intent:
|
||||
return
|
||||
|
||||
# ── Semantic Mismatch Guard ──
|
||||
if not _intent_matches_node(ctx["intent"], ctx["semantic_string"]):
|
||||
logger.warning(
|
||||
f"🛡️ [ActionMemory] BLOCKED confirm_click for '{ctx['intent']}' — "
|
||||
f"clicked element does not match intent: {ctx['semantic_string']}"
|
||||
)
|
||||
self._last_click_context = None
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"✅ [ActionMemory] Confirming success for '{ctx['intent']}'. Boosting confidence.",
|
||||
extra={"color": "\x1b[32m"},
|
||||
)
|
||||
|
||||
# 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.", extra={"color": "\x1b[31m"}
|
||||
)
|
||||
|
||||
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, device=None, confidence: float = 0.0
|
||||
) -> Optional[bool]:
|
||||
"""
|
||||
Structural and Visual verification: Did the UI actually change after the click?
|
||||
"""
|
||||
intent_lower = intent.lower()
|
||||
post_xml_lower = post_click_xml.lower()
|
||||
|
||||
# Specific check for opening a post (from explore/profile grid)
|
||||
if "view a post" in intent_lower or "first image" in intent_lower or "grid item" in intent_lower:
|
||||
if (
|
||||
"row_feed_photo_imageview" in post_xml_lower
|
||||
or "row_feed_button_like" in post_xml_lower
|
||||
or "clips_viewer_view_pager" in post_xml_lower
|
||||
):
|
||||
return True
|
||||
if (
|
||||
"explore_action_bar" in post_xml_lower
|
||||
and "row_feed_button_like" not in post_xml_lower
|
||||
and "clips_viewer" not in post_xml_lower
|
||||
):
|
||||
return None # Still on grid, inconclusive
|
||||
|
||||
state_toggles = ["like", "save", "follow", "heart"]
|
||||
is_toggle = any(t in intent_lower for t in state_toggles)
|
||||
|
||||
# ── State-Specific Structural Verification ──
|
||||
# If it was a follow, the resulting XML MUST contain "Following", "Requested", "Abonniert" or "Angefragt"
|
||||
if "follow" in intent_lower:
|
||||
FOLLOW_SUCCESS_MARKERS = ["following", "requested", "abonniert", "angefragt", "gefolgt"]
|
||||
if any(m in post_xml_lower for m in FOLLOW_SUCCESS_MARKERS):
|
||||
logger.info("✅ [ActionMemory] Structural check confirmed follow success.")
|
||||
return True
|
||||
else:
|
||||
logger.warning("⚠️ [ActionMemory] Follow success markers NOT found in post-click XML.")
|
||||
# We don't return False immediately because it might take a second to update
|
||||
|
||||
# If we are highly confident (e.g. pulled from Qdrant memory), bypass heavy VLM
|
||||
if device and confidence < 0.95:
|
||||
logger.info(
|
||||
f"👁️ [ActionMemory] Confidence ({confidence:.2f}) < 0.95. Handing over verification for '{intent}' to VLM visual analysis..."
|
||||
)
|
||||
from GramAddict.core.perception.semantic_evaluator import SemanticEvaluator
|
||||
|
||||
evaluator = SemanticEvaluator()
|
||||
|
||||
# Build context of what was actually clicked
|
||||
clicked_context = ""
|
||||
if self._last_click_context:
|
||||
clicked_context = f"The element that was tapped: {self._last_click_context['semantic_string']}. "
|
||||
|
||||
# Ask VLM to be the absolute source of truth
|
||||
prompt = (
|
||||
f"The user just attempted to perform the action: '{intent}'. "
|
||||
f"{clicked_context}"
|
||||
f"Look at the current screen carefully. Was the action successful? "
|
||||
)
|
||||
if is_toggle:
|
||||
prompt += (
|
||||
"If the intent was 'follow', does the button now indicate 'Following' or 'Requested'? "
|
||||
"If it was 'like', is the heart icon clearly active/red? "
|
||||
"If the screen shifted completely to a profile when you just wanted to like/follow from a feed, it FAILED. "
|
||||
"If the tapped element does NOT sound like a like/follow button (e.g. it's a caption, comment field, or post content), it FAILED. "
|
||||
)
|
||||
else:
|
||||
prompt += (
|
||||
f"Does the current screen match the expected outcome of '{intent}'? "
|
||||
f"For example, if the intent was to open a post/photo, are you looking at a post view (not a user profile or story)? "
|
||||
f"If the intent was to open a profile, are you on a profile page? "
|
||||
f"If the intent was to go back, are you on the previous screen? "
|
||||
)
|
||||
prompt += "Answer ONLY with the word YES or NO."
|
||||
|
||||
try:
|
||||
screenshot = device.get_screenshot_b64()
|
||||
if not screenshot:
|
||||
raise ValueError("No screenshot available from device")
|
||||
response = evaluator._query_vlm(prompt, screenshot)
|
||||
|
||||
if response and "yes" in response.lower() and "no" not in response.lower():
|
||||
logger.debug(f"🧠 [ActionMemory] VLM visually confirmed success for '{intent}'.")
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
f"⚠️ [ActionMemory] VLM visual verification FAILED for '{intent}'. VLM replied: '{response}'"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to query VLM for visual verification: {e}")
|
||||
# Fallthrough to structural delta if VLM crashes
|
||||
|
||||
# ── Pre-Structural Semantic Gate ──
|
||||
if is_toggle and self._last_click_context:
|
||||
if not _intent_matches_node(intent, self._last_click_context["semantic_string"]):
|
||||
logger.warning(
|
||||
f"🛡️ [ActionMemory] Semantic mismatch: '{intent}' does not match "
|
||||
f"clicked element {self._last_click_context['semantic_string']}. Verification FAIL."
|
||||
)
|
||||
return False
|
||||
|
||||
# Fallback to structural delta
|
||||
logger.info(f"DEBUG: len(pre_click_xml)={len(pre_click_xml)} len(post_click_xml)={len(post_click_xml)}")
|
||||
diff = abs(len(pre_click_xml) - len(post_click_xml))
|
||||
logger.info(f"DEBUG: diff={diff}")
|
||||
|
||||
if is_toggle:
|
||||
if diff > 1000:
|
||||
logger.warning(
|
||||
f"⚠️ [ActionMemory] Massive structural shift ({diff} chars) for state-toggle '{intent}'. Navigated away by mistake? Verification FAIL."
|
||||
)
|
||||
return False
|
||||
if diff > 0:
|
||||
logger.debug(f"🧠 [ActionMemory] Structural delta detected for toggle '{intent}'. Verification PASS.")
|
||||
return True
|
||||
else:
|
||||
# If the intent is an abstract goal (like "find customers"), diff > 50 is NOT enough.
|
||||
# We must force visual VLM confirmation because clicking the wrong thing (like "Create highlight")
|
||||
# also produces a large diff but achieves the wrong goal.
|
||||
if diff > 50:
|
||||
# Is it a standard structural transition?
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
# We don't have screen type here, so we just check if it's in the HD Map keys
|
||||
logger.info(f"DEBUG: intent is '{intent}'")
|
||||
logger.info(
|
||||
f"DEBUG: TRANSITIONS keys are: {[list(t.keys()) for t in ScreenTopology.TRANSITIONS.values()]}"
|
||||
)
|
||||
is_standard = any(intent in transitions for transitions in ScreenTopology.TRANSITIONS.values())
|
||||
logger.info(f"DEBUG: is_standard={is_standard}")
|
||||
|
||||
if is_standard:
|
||||
logger.debug(
|
||||
f"🧠 [ActionMemory] Structural change detected for known navigation '{intent}'. Verification PASS."
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.info(
|
||||
f"👁️ [ActionMemory] Abstract intent '{intent}' caused UI change. Forcing VLM visual verification..."
|
||||
)
|
||||
# For abstract intents, we must visually verify if it actually helped!
|
||||
# If device is available, we use VLM. If not, we fail safe.
|
||||
if device:
|
||||
from GramAddict.core.perception.semantic_evaluator import SemanticEvaluator
|
||||
|
||||
evaluator = SemanticEvaluator()
|
||||
prompt = f"The user just attempted to perform the action: '{intent}'. Does the current screen match the expected outcome? Answer ONLY with the word YES or NO."
|
||||
try:
|
||||
response = evaluator._query_vlm(prompt, device.get_screenshot_b64())
|
||||
if response and "yes" in response.lower() and "no" not in response.lower():
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"⚠️ [ActionMemory] VLM rejected success for abstract intent '{intent}'.")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"VLM visual verification failed: {e}")
|
||||
|
||||
logger.warning(f"⚠️ [ActionMemory] Cannot visually verify abstract intent '{intent}'. Failing safe.")
|
||||
return False
|
||||
|
||||
|
||||
def _intent_matches_node(intent: str, semantic_string: str) -> bool:
|
||||
"""Checks if the clicked element semantically matches the toggle intent.
|
||||
|
||||
For toggle intents (follow, like, save), the clicked element MUST contain
|
||||
at least one of the required keywords in its text/desc/id. This prevents
|
||||
photo grid items, captions, and other unrelated elements from being
|
||||
falsely confirmed as successful interactions.
|
||||
|
||||
For non-toggle intents, returns True (no restriction).
|
||||
"""
|
||||
intent_lower = intent.lower()
|
||||
semantic_lower = semantic_string.lower()
|
||||
|
||||
for intent_keyword, required_markers in TOGGLE_INTENT_MARKERS.items():
|
||||
if intent_keyword in intent_lower:
|
||||
if any(marker in semantic_lower for marker in required_markers):
|
||||
return True
|
||||
logger.debug(
|
||||
f"🛡️ [SemanticGuard] Intent '{intent}' requires markers "
|
||||
f"{required_markers} but element has: {semantic_string}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Non-toggle intents pass through
|
||||
return True
|
||||
@@ -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)
|
||||
|
||||
# 🛡️ Anti-Hallucination Guard: Ensure we actually found text.
|
||||
if author_node and author_node.get("original_attribs", {}).get("text"):
|
||||
result["username"] = author_node["original_attribs"]["text"].strip()
|
||||
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -1,113 +1,437 @@
|
||||
from typing import List, Optional
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from io import BytesIO
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
# Navigation tab intent → resource_id keyword mapping
|
||||
_NAV_TAB_MAP = {
|
||||
"tap home tab": "feed_tab",
|
||||
"tap explore tab": "search_tab",
|
||||
"tap reels tab": "clips_tab",
|
||||
"tap profile tab": "profile_tab",
|
||||
"tap messages tab": "direct_tab",
|
||||
}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _humanize_desc(desc: str) -> str:
|
||||
"""
|
||||
Inserts a space between numbers and letters to fix Instagram's concatenated content-desc.
|
||||
Example: "991following" -> "991 following", "140Kfollowers" -> "140K followers"
|
||||
"""
|
||||
if not desc:
|
||||
return ""
|
||||
import re
|
||||
|
||||
return re.sub(r"(\d[KMBkmb]?)([a-z])", r"\1 \2", desc)
|
||||
|
||||
|
||||
class IntentResolver:
|
||||
"""
|
||||
Translates natural language intents into spatial constraints and node filtering.
|
||||
Replaces the generic text/regex matching with structural intelligence.
|
||||
Vision-First Intent Resolver.
|
||||
|
||||
Resolves UI intents by SEEING the screen, not by parsing text descriptions.
|
||||
Uses Set-of-Mark (SoM) visual prompting: annotates a screenshot with numbered
|
||||
bounding boxes around clickable candidates, sends the annotated image to the VLM,
|
||||
and lets the VLM visually decide which box to tap.
|
||||
|
||||
Architecture:
|
||||
1. Navigation tabs → structural zone guard (bottom 15%, resource-id)
|
||||
2. Everything else → Visual Discovery (screenshot + numbered boxes + VLM)
|
||||
3. Fallback → text-based VLM (when no device/screenshot available)
|
||||
"""
|
||||
|
||||
def resolve(
|
||||
self, intent_description: str, candidates: List[SpatialNode], screen_height: int = 2400
|
||||
) -> Optional[SpatialNode]:
|
||||
"""
|
||||
Finds the best matching node for a given intent autonomously.
|
||||
# ──────────────────────────────────────────────
|
||||
# Public API
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
Navigation tab intents use a structural Zone Guard (bottom 15% of screen)
|
||||
to guarantee we click the actual nav bar, not a content-area element.
|
||||
All other intents delegate to VLM resolution.
|
||||
"""
|
||||
def resolve(
|
||||
self, intent_description: str, candidates: List[SpatialNode], device=None, screen_height: int = 2400
|
||||
) -> Optional[SpatialNode]:
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
intent_lower = intent_description.lower()
|
||||
|
||||
# ── Navigation Bar Zone Guard ──
|
||||
# When intent targets a nav tab, resolve structurally to the bottom nav zone.
|
||||
# This prevents the VLM from selecting content profile pictures instead of tabs.
|
||||
# The bottom navigation bar is always in the bottom 15% of the screen.
|
||||
tab_keyword = _NAV_TAB_MAP.get(intent_lower)
|
||||
if tab_keyword:
|
||||
nav_zone_y = int(screen_height * 0.85)
|
||||
nav_candidates = [
|
||||
n for n in candidates if n.y1 >= nav_zone_y and tab_keyword in (n.resource_id or "").lower()
|
||||
]
|
||||
if nav_candidates:
|
||||
return nav_candidates[0]
|
||||
# Fallback: broader search in nav zone by content_desc
|
||||
tab_label = intent_lower.replace("tap ", "").replace(" tab", "")
|
||||
nav_candidates = [
|
||||
n for n in candidates if n.y1 >= nav_zone_y and tab_label in (n.content_desc or "").lower()
|
||||
]
|
||||
if nav_candidates:
|
||||
return nav_candidates[0]
|
||||
return None
|
||||
|
||||
# If the intent is a high-level GOAL that accidentally leaked into the IntentResolver,
|
||||
# we explicitly block it from clicking random nodes.
|
||||
# IMPORTANT: Use exact match to avoid blocking "tap profile tab" when filtering "open profile"
|
||||
# Block abstract goals from leaking into node clicks
|
||||
abstract_goals = ["open profile", "open explore", "open following", "learn own profile"]
|
||||
if intent_lower in abstract_goals:
|
||||
return None
|
||||
|
||||
# 1. Ask the Telepathic VLM to find the best node
|
||||
import json
|
||||
# --- Semantic Match Guard ---
|
||||
# If the intent explicitly quotes a target (e.g., "tap 'New Message'"),
|
||||
# we strictly filter candidates to those whose text or content_desc contains the quote.
|
||||
import re
|
||||
|
||||
quotes = re.findall(r"['\"](.*?)['\"]", intent_description)
|
||||
if quotes:
|
||||
target_text = quotes[0].lower()
|
||||
pattern = r"\b" + re.escape(target_text) + r"\b"
|
||||
semantic_candidates = []
|
||||
for node in candidates:
|
||||
n_text = (node.text or "").lower()
|
||||
n_desc = (node.content_desc or "").lower()
|
||||
if re.search(pattern, n_text) or re.search(pattern, n_desc):
|
||||
semantic_candidates.append(node)
|
||||
|
||||
if semantic_candidates:
|
||||
if len(semantic_candidates) == 1:
|
||||
logger.info(f"🎯 [Semantic Guard] Exact match found for '{target_text}', skipping VLM.")
|
||||
return semantic_candidates[0]
|
||||
else:
|
||||
logger.info(
|
||||
f"🎯 [Semantic Guard] {len(semantic_candidates)} matches found for '{target_text}'. Reducing candidates for VLM."
|
||||
)
|
||||
candidates = semantic_candidates
|
||||
else:
|
||||
logger.warning(
|
||||
f"⚠️ [Semantic Guard] No candidates found containing '{target_text}'. Returning None to prevent hallucination."
|
||||
)
|
||||
return None
|
||||
|
||||
# ── PRIMARY PATH: Visual Discovery ──
|
||||
# If we have a device, the VLM SEES the screen and decides.
|
||||
if device is not None and (
|
||||
hasattr(device, "screenshot") or hasattr(getattr(device, "deviceV2", None), "screenshot")
|
||||
):
|
||||
logger.info("📸 Device screenshot capability detected. Enforcing visual discovery.")
|
||||
visual_res = self._visual_discovery(intent_description, candidates, device)
|
||||
if visual_res is not None:
|
||||
return visual_res
|
||||
logger.warning("👁️ [IntentResolver] Visual discovery yielded None. Falling back to text-based resolution.")
|
||||
|
||||
# --- Strict VLM Hallucination Guard (Text-only Fallback) ---
|
||||
# For known structural targets that the text-based VLM frequently hallucinates when they are missing,
|
||||
# we enforce a strict failure.
|
||||
if "following list" in intent_lower or "followers list" in intent_lower or "tap message button" in intent_lower:
|
||||
logger.warning(
|
||||
f"🛡️ [Hallucination Guard] Intent '{intent_description}' is a strict structural target. "
|
||||
"Since it wasn't resolved by fast-paths, it is missing. Rejecting VLM fallback."
|
||||
)
|
||||
return None
|
||||
|
||||
# ── FALLBACK: Text-based VLM resolution ──
|
||||
# Only used when device is unavailable (e.g., unit tests without screenshots).
|
||||
return self._text_based_resolve(intent_description, candidates, device)
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Visual Discovery (Set-of-Mark Prompting)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _annotate_screenshot_with_candidates(
|
||||
self, device, candidates: List[SpatialNode]
|
||||
) -> Tuple[str, Dict[int, SpatialNode]]:
|
||||
"""
|
||||
Takes a screenshot and draws numbered bounding boxes around clickable candidates.
|
||||
|
||||
Returns:
|
||||
annotated_b64: Base64-encoded JPEG of the annotated screenshot.
|
||||
box_map: Dict mapping box number → SpatialNode for coordinate lookup.
|
||||
"""
|
||||
from PIL import ImageDraw
|
||||
|
||||
img = device.deviceV2.screenshot()
|
||||
|
||||
# Stage 1: Basic area filter + exclude system UI and notifications (ALREADY HANDLED in _visual_discovery)
|
||||
pre_filtered = candidates
|
||||
|
||||
# Stage 2: Spatial deduplication
|
||||
# A node could completely contain another.
|
||||
# If parent is clickable and child is not: suppress child (e.g. text inside button)
|
||||
# If parent is not clickable and child is: suppress parent (e.g. layout container around button)
|
||||
# If both are not clickable: suppress parent (keep the smaller, more specific text)
|
||||
# If both are clickable: keep both! (e.g. nested buttons like row and camera icon)
|
||||
def _contains(parent: SpatialNode, child: SpatialNode) -> bool:
|
||||
return (
|
||||
parent.x1 <= child.x1
|
||||
and parent.y1 <= child.y1
|
||||
and parent.x2 >= child.x2
|
||||
and parent.y2 >= child.y2
|
||||
and parent.node_id != child.node_id
|
||||
)
|
||||
|
||||
to_suppress = set()
|
||||
# Sort by area DESCENDING so we process largest (parents) first
|
||||
pre_filtered.sort(key=lambda n: n.area, reverse=True)
|
||||
|
||||
for i, parent in enumerate(pre_filtered):
|
||||
for j in range(i + 1, len(pre_filtered)):
|
||||
child = pre_filtered[j]
|
||||
if _contains(parent, child):
|
||||
if parent.clickable and not child.clickable:
|
||||
to_suppress.add(child.node_id)
|
||||
# Merge semantic info from child to parent if missing
|
||||
if (
|
||||
child.text
|
||||
and child.text not in (parent.text or "")
|
||||
and child.text not in (parent.content_desc or "")
|
||||
):
|
||||
parent.content_desc = f"{(parent.content_desc or '')} {child.text}".strip()
|
||||
if (
|
||||
child.content_desc
|
||||
and child.content_desc not in (parent.text or "")
|
||||
and child.content_desc not in (parent.content_desc or "")
|
||||
):
|
||||
parent.content_desc = f"{(parent.content_desc or '')} {child.content_desc}".strip()
|
||||
elif not parent.clickable and child.clickable:
|
||||
to_suppress.add(parent.node_id)
|
||||
# Pass any semantic info down just in case
|
||||
if parent.content_desc and not child.content_desc:
|
||||
child.content_desc = parent.content_desc
|
||||
if parent.text and not child.text:
|
||||
child.text = parent.text
|
||||
elif not parent.clickable and not child.clickable:
|
||||
to_suppress.add(parent.node_id)
|
||||
if parent.content_desc and not child.content_desc:
|
||||
child.content_desc = parent.content_desc
|
||||
elif parent.clickable and child.clickable:
|
||||
# Keep both, distinct nested interactables
|
||||
pass
|
||||
|
||||
visible_candidates = [n for n in pre_filtered if n.node_id not in to_suppress]
|
||||
|
||||
draw = ImageDraw.Draw(img)
|
||||
box_map: Dict[int, SpatialNode] = {}
|
||||
|
||||
# Color palette for distinct boxes
|
||||
colors = [
|
||||
(255, 0, 0),
|
||||
(0, 200, 0),
|
||||
(0, 0, 255),
|
||||
(255, 165, 0),
|
||||
(128, 0, 128),
|
||||
(0, 200, 200),
|
||||
(255, 20, 147),
|
||||
(0, 128, 0),
|
||||
(255, 215, 0),
|
||||
(70, 130, 180),
|
||||
]
|
||||
|
||||
for i, node in enumerate(visible_candidates):
|
||||
color = colors[i % len(colors)]
|
||||
|
||||
# Draw bounding box
|
||||
draw.rectangle(
|
||||
[node.x1, node.y1, node.x2, node.y2],
|
||||
outline=color,
|
||||
width=3,
|
||||
)
|
||||
|
||||
# Draw number label with background for readability
|
||||
label = str(i)
|
||||
label_x = node.x1 + 2
|
||||
label_y = max(node.y1 - 18, 0)
|
||||
|
||||
# Draw label background
|
||||
bbox = draw.textbbox((label_x, label_y), label)
|
||||
draw.rectangle(
|
||||
[bbox[0] - 2, bbox[1] - 2, bbox[2] + 2, bbox[3] + 2],
|
||||
fill=color,
|
||||
)
|
||||
draw.text((label_x, label_y), label, fill=(255, 255, 255))
|
||||
|
||||
box_map[i] = node
|
||||
|
||||
# Encode to base64 JPEG
|
||||
buffered = BytesIO()
|
||||
img.save(buffered, format="JPEG", quality=85)
|
||||
annotated_b64 = base64.b64encode(buffered.getvalue()).decode("utf-8")
|
||||
|
||||
return annotated_b64, box_map
|
||||
|
||||
def _visual_discovery(
|
||||
self, intent_description: str, candidates: List[SpatialNode], device
|
||||
) -> Optional[SpatialNode]:
|
||||
"""
|
||||
Vision-first intent resolution via Set-of-Mark (SoM) prompting.
|
||||
|
||||
1. Takes a screenshot
|
||||
2. Draws numbered bounding boxes on clickable candidates
|
||||
3. Sends the annotated screenshot to the VLM
|
||||
4. VLM SEES the UI and picks which numbered box matches the intent
|
||||
5. Maps box number back to SpatialNode for precise coordinates
|
||||
"""
|
||||
from GramAddict.core.config import Config
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
|
||||
# Pre-filter candidates to reduce VLM hallucinations
|
||||
filtered_candidates = []
|
||||
for n in candidates:
|
||||
# Skip massive background containers
|
||||
if n.area > 500000:
|
||||
continue
|
||||
# Pre-filter candidates by area and system UI before any semantic matching
|
||||
candidates = [
|
||||
n
|
||||
for n in candidates
|
||||
if 200 < n.area < 400000
|
||||
and "com.android.systemui" not in (n.resource_id or "")
|
||||
and "notification:" not in (n.content_desc or "").lower()
|
||||
and "per cent" not in (n.content_desc or "").lower()
|
||||
]
|
||||
|
||||
# Structural heuristic: if looking for profile, prioritize nodes that might be profiles
|
||||
# and exclude obvious bottom tabs/navigation
|
||||
if "profile" in intent_lower:
|
||||
res = (n.resource_id or "").lower()
|
||||
if "tab" in res or "navigation" in res or "action_bar" in res:
|
||||
continue
|
||||
filtered_candidates.append(n)
|
||||
# --- Strict Button Guard ---
|
||||
# If the intent specifically asks for a "button", "icon", or "tab",
|
||||
# filter out candidates that contain long text (e.g. captions, comments)
|
||||
# to prevent the VLM from hallucinating text nodes as interactive buttons.
|
||||
intent_lower = intent_description.lower()
|
||||
if "button" in intent_lower or "icon" in intent_lower or "tab" in intent_lower:
|
||||
filtered_candidates = []
|
||||
for node in candidates:
|
||||
text_len = len(node.text or "")
|
||||
if text_len < 40:
|
||||
filtered_candidates.append(node)
|
||||
else:
|
||||
logger.debug(f"🛡️ [Strict Button Guard] Filtered out node with long text: '{node.text[:20]}...'")
|
||||
candidates = filtered_candidates
|
||||
|
||||
# --- Post/Grid Item Guard ---
|
||||
# VLMs frequently hallucinate 'Search' when asked to tap a post. We must pre-filter.
|
||||
if "first post" in intent_lower or "grid item" in intent_lower:
|
||||
grid_candidates = []
|
||||
for node in candidates:
|
||||
desc = (node.content_desc or "").lower()
|
||||
# Posts/grid items usually have 'row X, column Y', 'photos by', or 'reel by'
|
||||
if "row 1" in desc or "column" in desc or "photos by" in desc or "reel by" in desc:
|
||||
grid_candidates.append(node)
|
||||
|
||||
if grid_candidates:
|
||||
logger.info(f"🎯 [Grid Guard] Filtered to {len(grid_candidates)} actual grid candidates.")
|
||||
candidates = grid_candidates
|
||||
|
||||
try:
|
||||
annotated_b64, box_map = self._annotate_screenshot_with_candidates(device, candidates)
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ [Visual Discovery] Screenshot annotation failed: {e}")
|
||||
return None
|
||||
|
||||
if not box_map:
|
||||
return None
|
||||
|
||||
self.last_box_map = box_map
|
||||
|
||||
cfg = Config()
|
||||
model = getattr(cfg.args, "ai_telepathic_model", "llava:latest")
|
||||
url = getattr(cfg.args, "ai_telepathic_url", "http://localhost:11434/api/generate")
|
||||
|
||||
# Build a compact legend of what each box contains
|
||||
box_legend_lines = []
|
||||
for idx in sorted(box_map.keys()):
|
||||
node = box_map[idx]
|
||||
label_parts = []
|
||||
if node.content_desc:
|
||||
desc = _humanize_desc(node.content_desc)
|
||||
label_parts.append(f"desc='{desc[:50]}'")
|
||||
if node.text and node.text != node.content_desc:
|
||||
text = _humanize_desc(node.text)
|
||||
label_parts.append(f"text='{text[:50]}'")
|
||||
if not label_parts:
|
||||
label_parts.append("(no visible text)")
|
||||
box_legend_lines.append(f" [{idx}] {', '.join(label_parts)}")
|
||||
box_legend = "\n".join(box_legend_lines)
|
||||
print("BOX LEGEND:")
|
||||
print(box_legend)
|
||||
|
||||
prompt = (
|
||||
f"You are looking at a mobile app screenshot with numbered bounding boxes drawn around interactive UI elements.\n"
|
||||
f"Each box has a number label in a colored rectangle.\n\n"
|
||||
f"Box legend (what each box contains):\n{box_legend}\n\n"
|
||||
f"Your task: Find the exact box number that corresponds to this intent: '{intent_description}'\n\n"
|
||||
f"CRITICAL RULES:\n"
|
||||
f"1. If the intent contains a word in quotes (e.g., 'Search', 'New Message'), you MUST look at the Box legend and pick the box that contains that word (case-insensitive). Do not pick anything else.\n"
|
||||
f"2. For icons without text:\n"
|
||||
f" - 'like button' = HEART-SHAPED ICON (♡/❤), usually has desc='Like'.\n"
|
||||
f" - 'comment button' = SPEECH BUBBLE ICON, usually has desc='Comment'.\n"
|
||||
f"3. Do NOT select text, captions, or view counts if looking for an icon.\n"
|
||||
f"4. Ignore numbers inside the text itself. Do not confuse the text '19' with Box [19].\n"
|
||||
f"5. If the intent contains 'following', you MUST pick the box containing 'following'. Do NOT pick 'followers' or 'Follow'.\n"
|
||||
f"6. If the intent is to tap a 'post', 'first post', or 'grid item':\n"
|
||||
f" - Look for boxes with descriptions containing 'photos by', 'Reel by', or 'row 1, column 1'.\n"
|
||||
f" - Pick the FIRST matching box index (e.g. if [0] says '6 photos...', return 0, NOT 6).\n"
|
||||
f" - Do NOT pick navigation buttons like 'Search'.\n"
|
||||
f"7. If the intent is a bottom navigation tab (e.g. 'profile tab', 'home tab'):\n"
|
||||
f" - These are always at the BOTTOM edge of the screen.\n"
|
||||
f" - 'profile tab' is usually the furthest right icon (your avatar).\n"
|
||||
f" - 'home tab' is the furthest left icon (house).\n"
|
||||
f" - 'explore tab' is the magnifying glass.\n"
|
||||
f" - 'reels tab' is the video clapperboard.\n"
|
||||
f"8. If the intent involves 'author username' or 'author profile':\n"
|
||||
f" - Pick the profile picture (e.g. 'Profile picture of <username>') or the username text.\n"
|
||||
f" - NEVER pick a 'Follow' button. Do NOT pick 'Follow <username>'.\n"
|
||||
f"9. If the intent is 'save post':\n"
|
||||
f" - The save icon is the bookmark icon on the bottom right of the post image/video.\n"
|
||||
f" - Usually has desc='Add to Saved' or 'Save'. Do NOT pick the post text or other action buttons.\n"
|
||||
f"10. If the exact control is NOT visible, return null. Do NOT guess.\n\n"
|
||||
f'Reply ONLY with a valid JSON object: {{"box": <number>}} or {{"box": null}}'
|
||||
)
|
||||
|
||||
try:
|
||||
res = query_telepathic_llm(
|
||||
model=model,
|
||||
url=url,
|
||||
system_prompt="Strict visual JSON box selector. Respond only with JSON.",
|
||||
user_prompt=prompt,
|
||||
use_local_edge=True,
|
||||
images_b64=[annotated_b64],
|
||||
)
|
||||
data = json.loads(res)
|
||||
box_idx = data.get("box")
|
||||
|
||||
if box_idx is not None and box_idx in box_map:
|
||||
selected = box_map[box_idx]
|
||||
logger.info(
|
||||
f"👁️ [Visual Discovery] VLM selected box [{box_idx}] → "
|
||||
f"id='{selected.resource_id}', desc='{selected.content_desc}'"
|
||||
)
|
||||
return selected
|
||||
else:
|
||||
logger.warning(
|
||||
f"👁️ [Visual Discovery] VLM returned box={box_idx} which is not in box_map ({list(box_map.keys())[:5]}...)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ [Visual Discovery] VLM call failed: {e}")
|
||||
|
||||
return None
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Text-based Fallback (no device/screenshot)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _text_based_resolve(
|
||||
self, intent_description: str, candidates: List[SpatialNode], device=None
|
||||
) -> Optional[SpatialNode]:
|
||||
"""
|
||||
Fallback resolution via text descriptions of XML nodes.
|
||||
Used only when no device is available for screenshots.
|
||||
"""
|
||||
from GramAddict.core.config import Config
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
|
||||
intent_lower = intent_description.lower()
|
||||
|
||||
filtered_candidates = [n for n in candidates if n.area < 500000]
|
||||
if "profile" in intent_lower:
|
||||
filtered_candidates = [
|
||||
n
|
||||
for n in filtered_candidates
|
||||
if not any(kw in (n.resource_id or "").lower() for kw in ("tab", "navigation", "action_bar"))
|
||||
]
|
||||
if not filtered_candidates:
|
||||
filtered_candidates = candidates
|
||||
filtered_candidates = [n for n in candidates if n.area < 500000]
|
||||
|
||||
cfg = Config()
|
||||
model = getattr(cfg.args, "ai_telepathic_model", "qwen3.5:latest")
|
||||
url = getattr(cfg.args, "ai_telepathic_url", "http://localhost:11434/api/generate")
|
||||
|
||||
# Prepare context
|
||||
node_context = []
|
||||
for i, node in enumerate(filtered_candidates):
|
||||
text = node.text or ""
|
||||
desc = node.content_desc or ""
|
||||
text = _humanize_desc(node.text or "")
|
||||
desc = _humanize_desc(node.content_desc or "")
|
||||
res_id = node.resource_id or ""
|
||||
node_context.append(f"[{i}] text='{text}', desc='{desc}', id='{res_id}', bounds=[{node.y1},{node.y2}]")
|
||||
|
||||
prompt = (
|
||||
f"You are a Spatial UI Intent Resolver.\n"
|
||||
f"Goal: Find the single best UI element to interact with to satisfy the intent: '{intent_description}'.\n"
|
||||
f"CRITICAL RULES:\n"
|
||||
f"- If the intent is about opening the 'post author', STRICTLY require 'row_feed_photo_profile' in the ID. Do not select comment authors.\n"
|
||||
f"- If the intent is about opening a user profile generally, prioritize nodes containing 'profile_name' or 'profile_image' in their ID, NOT generic action bars or tabs.\n"
|
||||
f"- Ignore bottom navigation tabs (home, search, profile) UNLESS the intent explicitly asks to navigate to a primary feed.\n"
|
||||
f"Candidates:\n" + "\n".join(node_context) + "\n\n"
|
||||
"CRITICAL RULES:\n"
|
||||
"1. If the intent is a bottom navigation tab (e.g. 'profile tab', 'home tab'):\n"
|
||||
" - These are always at the BOTTOM of the screen (typically y > 2100).\n"
|
||||
" - 'profile tab' is usually the furthest right.\n"
|
||||
" - 'home tab' is the furthest left.\n"
|
||||
" - Do NOT select 'Go to <user>'s profile' or other header text.\n"
|
||||
"2. If none of the candidates clearly and safely match the intent, return null.\n\n"
|
||||
"Reply ONLY with a valid JSON object strictly matching this schema:\n"
|
||||
'{"selected_index": <integer or null>}\n'
|
||||
"If none of the candidates match the intent, return null."
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -123,8 +447,6 @@ class IntentResolver:
|
||||
if idx is not None and 0 <= idx < len(filtered_candidates):
|
||||
return filtered_candidates[idx]
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).warning(f"⚠️ [IntentResolver] VLM resolution failed ({e}).")
|
||||
logger.warning(f"⚠️ [IntentResolver] Text-based VLM resolution failed ({e}).")
|
||||
|
||||
return None
|
||||
|
||||
372
GramAddict/core/perception/screen_identity.py
Normal file
372
GramAddict/core/perception/screen_identity.py
Normal file
@@ -0,0 +1,372 @@
|
||||
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: Structural Heuristics (100% Deterministic)
|
||||
if "unified_follow_list_tab_layout" in ids or "follow_list_container" in ids:
|
||||
return ScreenType.FOLLOW_LIST
|
||||
|
||||
if "profile_header_container" in ids:
|
||||
if selected_tab == "profile_tab":
|
||||
return ScreenType.OWN_PROFILE
|
||||
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 — Semantic app-agnostic markers (chat input fields)
|
||||
chat_input_markers = ["Message...", "Nachricht...", "Type a message", "Nachricht senden", "Send a message"]
|
||||
if any(marker in texts for marker in chat_input_markers) or "direct_thread_header" in ids:
|
||||
return ScreenType.DM_THREAD
|
||||
|
||||
# Priority 2: Check Qdrant Semantic Cache (Fuzzy/VLM derived)
|
||||
if signature and self.screen_memory and self.screen_memory.is_connected:
|
||||
cached_type_str = self.screen_memory.get_screen_type(signature, similarity_threshold=0.92)
|
||||
if cached_type_str:
|
||||
try:
|
||||
return ScreenType[cached_type_str]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
if "row_feed_button_like" in ids and "row_feed_photo_profile_name" in ids and not selected_tab:
|
||||
return ScreenType.POST_DETAIL
|
||||
|
||||
# Story view structural markers — present in full-screen story viewer.
|
||||
# Stories hide the navigation tab bar, so selected_tab is always None.
|
||||
# Must be checked BEFORE tab-based fallbacks to prevent UNKNOWN classification.
|
||||
STORY_MARKERS = (
|
||||
"reel_viewer_media_layout",
|
||||
"reel_viewer_header",
|
||||
"reel_viewer_progress_bar",
|
||||
"reel_viewer_root",
|
||||
"story_viewer_container",
|
||||
"reel_viewer_content_layout",
|
||||
)
|
||||
if any(marker in ids for marker in STORY_MARKERS):
|
||||
return ScreenType.STORY_VIEW
|
||||
# Fallback: content-desc "Like Story" or "Send story" confirms story context
|
||||
if "like story" in desc_lower or "send story" in desc_lower or "nachricht senden" in desc_lower:
|
||||
return ScreenType.STORY_VIEW
|
||||
|
||||
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 "action_bar_search_edit_text" in ids and "search_tab" in ids:
|
||||
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_model_url", "http://localhost:11434/api/generate")
|
||||
if hasattr(cfg, "args")
|
||||
else "http://localhost:11434/api/generate"
|
||||
)
|
||||
model = getattr(cfg.args, "ai_model", "qwen3.5:latest") if hasattr(cfg, "args") else "qwen3.5:latest"
|
||||
|
||||
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 post")
|
||||
|
||||
# Scroll
|
||||
actions.append("scroll down")
|
||||
actions.append("scroll up")
|
||||
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
|
||||
199
GramAddict/core/perception/spatial_parser.py
Normal file
199
GramAddict/core/perception/spatial_parser.py
Normal file
@@ -0,0 +1,199 @@
|
||||
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
|
||||
text_val = attrib.get("text", "").strip()
|
||||
hint_val = attrib.get("hint", "").strip()
|
||||
if not text_val and hint_val:
|
||||
text_val = hint_val
|
||||
|
||||
node = SpatialNode(
|
||||
node_id=f"n_{self._node_counter}",
|
||||
class_name=attrib.get("class", ""),
|
||||
text=text_val,
|
||||
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,28 +142,21 @@ 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())
|
||||
|
||||
|
||||
def humanized_click(device, x, y, double=False, sleep_mod=1.0):
|
||||
"""Simulates a human tap with biomechanical jitter and micro-drift."""
|
||||
body = PhysicsBody.get_session_instance(device)
|
||||
injector = SendEventInjector.get_instance(device)
|
||||
|
||||
def single_tap():
|
||||
points = BezierGesture.tap_curve(x, y, body)
|
||||
# Tap timing: 40-90ms contact time
|
||||
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())
|
||||
# Apply biomechanical jitter
|
||||
jx = int(x + random.gauss(0, 5))
|
||||
jy = int(y + random.gauss(0, 5))
|
||||
device.shell(f"input tap {jx} {jy}")
|
||||
|
||||
if double:
|
||||
# For double tap, the timing is extremely critical (<300ms between taps).
|
||||
@@ -194,14 +181,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,8 +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 +39,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 +86,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 +101,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 +124,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))
|
||||
|
||||
@@ -188,6 +178,9 @@ class SendEventInjector:
|
||||
scale_x = self.x_max / display_w
|
||||
scale_y = self.y_max / display_h
|
||||
|
||||
# Build batch command list
|
||||
cmds = []
|
||||
|
||||
# --- Touch Down (first point) ---
|
||||
x, y, pressure = points[0]
|
||||
ix = int(x * scale_x)
|
||||
@@ -195,8 +188,6 @@ class SendEventInjector:
|
||||
ip = int(pressure * self.pressure_max)
|
||||
itm = min(touch_major, self.touch_major_max)
|
||||
|
||||
# Build batch command for touch-down
|
||||
cmds = []
|
||||
cmds.append(f"sendevent {dev} {self.EV_ABS} {self.ABS_MT_TRACKING_ID} 0")
|
||||
cmds.append(f"sendevent {dev} {self.EV_ABS} {self.ABS_MT_POSITION_X} {ix}")
|
||||
cmds.append(f"sendevent {dev} {self.EV_ABS} {self.ABS_MT_POSITION_Y} {iy}")
|
||||
@@ -205,38 +196,36 @@ class SendEventInjector:
|
||||
cmds.append(f"sendevent {dev} {self.EV_KEY} {self.BTN_TOUCH} 1")
|
||||
cmds.append(f"sendevent {dev} {self.EV_SYN} {self.SYN_REPORT} 0")
|
||||
|
||||
# Execute touch-down
|
||||
self.device.shell(" && ".join(cmds))
|
||||
|
||||
# --- Move through intermediate points ---
|
||||
for i in range(1, len(points) - 1):
|
||||
if i - 1 < len(timing_intervals):
|
||||
sleep(timing_intervals[i - 1])
|
||||
delay = timing_intervals[i - 1]
|
||||
if delay > 0.001:
|
||||
cmds.append(f"sleep {delay:.3f}")
|
||||
|
||||
x, y, pressure = points[i]
|
||||
ix = int(x * scale_x)
|
||||
iy = int(y * scale_y)
|
||||
ip = int(pressure * self.pressure_max)
|
||||
|
||||
cmds = []
|
||||
cmds.append(f"sendevent {dev} {self.EV_ABS} {self.ABS_MT_POSITION_X} {ix}")
|
||||
cmds.append(f"sendevent {dev} {self.EV_ABS} {self.ABS_MT_POSITION_Y} {iy}")
|
||||
cmds.append(f"sendevent {dev} {self.EV_ABS} {self.ABS_MT_PRESSURE} {ip}")
|
||||
cmds.append(f"sendevent {dev} {self.EV_SYN} {self.SYN_REPORT} 0")
|
||||
|
||||
self.device.shell(" && ".join(cmds))
|
||||
|
||||
# --- Touch Up (last point) ---
|
||||
if len(timing_intervals) >= len(points) - 1:
|
||||
sleep(timing_intervals[-1])
|
||||
delay = timing_intervals[-1]
|
||||
else:
|
||||
sleep(0.01)
|
||||
delay = 0.01
|
||||
|
||||
if delay > 0.001:
|
||||
cmds.append(f"sleep {delay:.3f}")
|
||||
|
||||
x, y, pressure = points[-1]
|
||||
ix = int(x * scale_x)
|
||||
iy = int(y * scale_y)
|
||||
|
||||
cmds = []
|
||||
cmds.append(f"sendevent {dev} {self.EV_ABS} {self.ABS_MT_POSITION_X} {ix}")
|
||||
cmds.append(f"sendevent {dev} {self.EV_ABS} {self.ABS_MT_POSITION_Y} {iy}")
|
||||
cmds.append(f"sendevent {dev} {self.EV_ABS} {self.ABS_MT_PRESSURE} 0")
|
||||
@@ -244,6 +233,7 @@ class SendEventInjector:
|
||||
cmds.append(f"sendevent {dev} {self.EV_KEY} {self.BTN_TOUCH} 0")
|
||||
cmds.append(f"sendevent {dev} {self.EV_SYN} {self.SYN_REPORT} 0")
|
||||
|
||||
# Execute ALL events in one atomic batch to eliminate ADB latency
|
||||
self.device.shell(" && ".join(cmds))
|
||||
|
||||
except Exception as e:
|
||||
@@ -262,6 +252,12 @@ 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}"
|
||||
)
|
||||
dist_x = abs(ex - sx)
|
||||
dist_y = abs(ey - sy)
|
||||
|
||||
# Android sometimes interprets a low-duration swipe with minimal movement as a long press or cancels it.
|
||||
# If it's physically a tap (minimal movement, short duration), use native input tap.
|
||||
if dist_x < 15 and dist_y < 15 and total_ms < 150:
|
||||
self.device.shell(f"input tap {int(sx)} {int(sy)}")
|
||||
else:
|
||||
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
|
||||
@@ -134,66 +135,105 @@ def align_active_post(device):
|
||||
"""
|
||||
aligned = False
|
||||
attempts = 0
|
||||
max_attempts = 3
|
||||
|
||||
max_attempts = 5 # Increased for structural retry loop
|
||||
|
||||
# Intents for structural discovery
|
||||
intents = [
|
||||
"post author header profile",
|
||||
"post username name",
|
||||
"row_feed_photo_profile_name", # ID fallback
|
||||
"clips_viewer_author_container", # Reels fallback
|
||||
"feed post content", # Final desperation
|
||||
]
|
||||
|
||||
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 = None
|
||||
for intent in intents:
|
||||
target_node = telepath.find_best_node(xml, intent, min_confidence=0.35, device=device, track=False)
|
||||
if target_node:
|
||||
break
|
||||
|
||||
if target_node:
|
||||
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)
|
||||
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)
|
||||
start_y = int(h * 0.7)
|
||||
end_y = start_y - dist
|
||||
else:
|
||||
# Content is too HIGH. Move it DOWN.
|
||||
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)
|
||||
logger.debug(f"📐 [Alignment] Snapping attempt {attempts}: Shifted {diff}px.")
|
||||
original_attribs = target_node.get("original_attribs", {})
|
||||
bounds = original_attribs.get("bounds")
|
||||
|
||||
# If bounds is a tuple from SpatialNode.to_dict()
|
||||
if isinstance(bounds, (tuple, list)) and len(bounds) == 4:
|
||||
left, t, r, b = bounds
|
||||
else:
|
||||
# Fallback to string parsing
|
||||
if not bounds:
|
||||
bounds = target_node.get("bounds", "")
|
||||
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", str(bounds))
|
||||
if m:
|
||||
left, t, r, b = map(int, m.groups())
|
||||
else:
|
||||
aligned = True
|
||||
logger.warning(f"📐 [Alignment] Could not parse bounds: {bounds}")
|
||||
continue
|
||||
|
||||
# Check if this is a false positive (e.g. bottom bar item misclassified)
|
||||
# Post headers should be in the top half usually, or at least not at the very bottom
|
||||
info = device.get_info()
|
||||
h = info.get("displayHeight", 2400)
|
||||
if t > h * 0.85:
|
||||
logger.debug(f"📐 [Alignment] Rejecting node at y={t} (too low, likely bottom bar)")
|
||||
continue
|
||||
|
||||
header_y = (t + b) // 2
|
||||
target_y = 250 # Top margin for headers
|
||||
diff = header_y - target_y
|
||||
|
||||
# If target is off-center (> 50px for higher precision), execute precise correction swipe
|
||||
if abs(diff) > 50:
|
||||
info = device.get_info()
|
||||
w = info.get("displayWidth", 1080)
|
||||
cx = w // 2
|
||||
|
||||
max_safe_swipe = int(h * 0.4)
|
||||
|
||||
# Calculate movement
|
||||
dist = min(abs(diff), max_safe_swipe)
|
||||
if diff > 0:
|
||||
# Content is too LOW. Move it UP (Swipe UP).
|
||||
start_y = int(h * 0.7)
|
||||
end_y = start_y - dist
|
||||
else:
|
||||
# Content is too HIGH. Move it DOWN (Swipe DOWN).
|
||||
start_y = int(h * 0.3)
|
||||
end_y = start_y + dist
|
||||
|
||||
logger.debug(f"📐 [Alignment] Attempt {attempts}: Snapping {diff}px (Swipe {start_y} -> {end_y})")
|
||||
# Duration 1.5s = ultra-precise mechanical drag with ZERO momentum
|
||||
device.swipe(cx, start_y, cx, end_y, duration=1.5)
|
||||
sleep(1.0)
|
||||
|
||||
# Refresh XML for next iteration check
|
||||
continue
|
||||
else:
|
||||
logger.info(f"🎯 [Alignment] Perfect snap achieved after {attempts} attempts.")
|
||||
aligned = True
|
||||
else:
|
||||
break # No header found, cannot align
|
||||
logger.debug(f"📐 [Alignment] No structural markers found on attempt {attempts}.")
|
||||
# If we can't find any markers, maybe we are stuck in a transition.
|
||||
# Micro-wobble to force a layout update.
|
||||
if attempts < 3:
|
||||
info = device.get_info()
|
||||
w, h = info.get("displayWidth", 1080), info.get("displayHeight", 2400)
|
||||
device.swipe(w // 2, h // 2, w // 2, h // 2 - 20, duration=0.2)
|
||||
sleep(0.5)
|
||||
device.swipe(w // 2, h // 2 - 20, w // 2, h // 2, duration=0.2)
|
||||
sleep(1.0)
|
||||
else:
|
||||
break
|
||||
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
|
||||
|
||||
return aligned
|
||||
|
||||
@@ -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,15 @@ 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",
|
||||
"follow": "tap follow button",
|
||||
}
|
||||
for keyword, required_action in action_checks.items():
|
||||
if keyword in goal.lower() and required_action not in available:
|
||||
@@ -140,7 +147,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 +177,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
|
||||
@@ -202,28 +210,38 @@ class QNavGraph:
|
||||
# Grid & Profile
|
||||
"tap_explore_grid_item": "first image in explore grid",
|
||||
"tap_story_tray_item": "profile picture avatar story ring",
|
||||
"tap_follow_button": "tap follow button on profile",
|
||||
"tap_follow_button": "tap 'Follow' button on profile",
|
||||
"tap_grid_first_post": "first image post in profile grid",
|
||||
"tap_back": "tap back button icon arrow",
|
||||
"tap_message_icon": "tap direct message icon inbox",
|
||||
"tap_newsfeed_tab": "tap activity heart icon notifications",
|
||||
}
|
||||
intent_description = intent_map.get(action, action.replace("_", " "))
|
||||
|
||||
|
||||
# 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 +249,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 +327,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 +362,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
|
||||
@@ -33,11 +33,13 @@ class ScreenTopology:
|
||||
"tap profile tab": ScreenType.OWN_PROFILE,
|
||||
"tap reels tab": ScreenType.REELS_FEED,
|
||||
"tap messages tab": ScreenType.DM_INBOX,
|
||||
"tap story ring avatar": ScreenType.STORY_VIEW,
|
||||
},
|
||||
ScreenType.EXPLORE_GRID: {
|
||||
"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,
|
||||
@@ -56,6 +58,9 @@ class ScreenTopology:
|
||||
ScreenType.FOLLOW_LIST: {
|
||||
"press back": ScreenType.OWN_PROFILE,
|
||||
},
|
||||
ScreenType.STORY_VIEW: {
|
||||
"press back": ScreenType.HOME_FEED,
|
||||
},
|
||||
ScreenType.OTHER_PROFILE: {
|
||||
"press back": ScreenType.HOME_FEED,
|
||||
"tap home tab": ScreenType.HOME_FEED,
|
||||
@@ -78,11 +83,17 @@ 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
|
||||
cls, from_screen: ScreenType, to_screen: ScreenType, avoid_actions: set = None
|
||||
) -> Optional[List[Tuple[str, ScreenType]]]:
|
||||
"""
|
||||
BFS shortest path from from_screen to to_screen.
|
||||
@@ -95,6 +106,8 @@ class ScreenTopology:
|
||||
if from_screen == to_screen:
|
||||
return []
|
||||
|
||||
avoid_actions = avoid_actions or set()
|
||||
|
||||
queue: deque = deque()
|
||||
queue.append((from_screen, []))
|
||||
visited = {from_screen}
|
||||
@@ -104,6 +117,9 @@ class ScreenTopology:
|
||||
transitions = cls.TRANSITIONS.get(current, {})
|
||||
|
||||
for action, next_screen in transitions.items():
|
||||
if action in avoid_actions or action.replace(" ", "_") in avoid_actions:
|
||||
continue
|
||||
|
||||
if next_screen == to_screen:
|
||||
return path + [(action, next_screen)]
|
||||
|
||||
@@ -171,9 +187,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
|
||||
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)
|
||||
try:
|
||||
args = Config().args
|
||||
except Exception:
|
||||
pass
|
||||
model = getattr(args, "ai_model", "qwen3.5:latest")
|
||||
url = getattr(args, "ai_model_url", "http://localhost:11434/api/generate")
|
||||
|
||||
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,23 @@ 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
|
||||
# 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):
|
||||
|
||||
# Guard: Use the RAW xml_dump to avoid truncation of root containers (Z-index filtering),
|
||||
# but ensure we only match inside resource-id attributes to prevent false positives from user text.
|
||||
if any(
|
||||
re.search(rf'resource-id="[^"]*{marker}[^"]*"', xml_dump, 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 +437,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 +450,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
|
||||
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)
|
||||
try:
|
||||
args = Config().args
|
||||
except Exception:
|
||||
pass
|
||||
model = getattr(args, "ai_model", "qwen3.5:latest")
|
||||
url = getattr(args, "ai_model_url", "http://localhost:11434/api/generate")
|
||||
|
||||
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 +483,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 +525,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 +580,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 +611,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 +626,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 +638,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 +658,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 +671,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 +679,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 +717,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,7 +4,8 @@ from time import sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def ghost_type(device, text: str):
|
||||
|
||||
def ghost_type(device, text: str, speed: str = "normal"):
|
||||
"""
|
||||
Tesla Stealth Ghost Keyboard.
|
||||
Bypasses UIAutomator virtual IME completely and sends raw Native InputEvents.
|
||||
@@ -12,59 +13,64 @@ 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)
|
||||
|
||||
|
||||
if speed == "fast":
|
||||
sleep(random.uniform(0.01, 0.05))
|
||||
continue
|
||||
|
||||
# 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,111 @@ 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)
|
||||
|
||||
|
||||
# Autonomously identify user rows via Semantic Extraction
|
||||
telepathic = cognitive_stack.get("telepathic")
|
||||
nodes = []
|
||||
if telepathic:
|
||||
nodes = telepathic._extract_semantic_nodes(
|
||||
xml_dump, "List item containing a user profile image, username, and following/following button"
|
||||
)
|
||||
else:
|
||||
logger.warning("No telepathic engine found, skipping semantic extraction.")
|
||||
|
||||
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 +171,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,15 @@ 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
|
||||
|
||||
if cognitive_stack:
|
||||
telepathic = cognitive_stack.get("telepathic")
|
||||
@@ -103,18 +115,17 @@ 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'
|
||||
]
|
||||
|
||||
|
||||
# Standalone label patterns: match only when the text/desc IS the ad marker,
|
||||
# not when "ad" appears inside longer phrases like "Create messaging ad"
|
||||
AD_EXACT_LABELS = {"ad", "sponsored", "advertisement", "gesponsert", "anzeige", "werbung"}
|
||||
|
||||
try:
|
||||
root = ET.fromstring(xml_hierarchy)
|
||||
for node in root.iter("node"):
|
||||
@@ -127,11 +138,13 @@ def is_ad(xml_hierarchy: str, cognitive_stack: dict = None) -> bool:
|
||||
if any(marker_id in res_id for marker_id in AD_RESOURCE_IDS):
|
||||
return True
|
||||
|
||||
# Content check (Legacy)
|
||||
searchable = f"{content_desc} {text}".lower()
|
||||
for pattern in AD_MARKERS:
|
||||
if re.search(pattern, searchable):
|
||||
return True
|
||||
# Exact label match: only trigger when the entire text/desc
|
||||
# IS an ad marker (e.g. text="Ad", content-desc="Sponsored")
|
||||
# This prevents false positives from "Create messaging ad"
|
||||
if text.strip().lower() in AD_EXACT_LABELS:
|
||||
return True
|
||||
if content_desc.strip().lower() in AD_EXACT_LABELS:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
10
README.md
10
README.md
@@ -11,7 +11,7 @@
|
||||
|
||||
## 🏎️ What is GramPilot?
|
||||
|
||||
GramPilot is not a traditional script. Traditional bots rely on fixed UI locators (like XPaths) or external APIs, causing them to crash with every Instagram update or get banned within days.
|
||||
GramPilot is not a traditional script. Traditional bots rely on fixed UI locators (like XPaths) or external APIs, causing them to crash with every Instagram update or get banned within days.
|
||||
|
||||
GramPilot introduces a **Telepathic Full Self-Driving (FSD) approach** to UI navigation:
|
||||
It uses a 3-Stage Resolution Cascade backed by CPU Fast-Paths, Ollama Vector Similarity, and OpenRouter LLMs (Gemini/Qwen) to "read" the screen, understand context, and learn new UI layouts asynchronously.
|
||||
@@ -21,11 +21,19 @@ If Instagram updates its app and moves a button, GramPilot doesn't crash. It fal
|
||||
## ✨ Core Features
|
||||
|
||||
* 🚫 **Zero Limits Configuration**: Forget about configuring "max_likes" or "delays". GramPilot uses a **Dopamine Pacing Engine** to simulate human boredom. If the content isn't interesting, it skips it or ends the session early.
|
||||
* 🎯 **Mission-Driven Navigation**: Say goodbye to abstract goal configurations. Define a `strategy` (like `aggressive_growth` or `nurture_community`) in `config.yml`, and the **Goal Decomposer Engine** automatically orchestrates the optimal routing and task allocation using enabled plugins.
|
||||
* ⚖️ **Active Inference (Shadow Mode)**: The bot continuously predicts the outcome of its clicks. If it lands on a popup instead of a profile, it registers a "Prediction Error", presses back, and dynamically recalibrates without panicking.
|
||||
* ⛩️ **Telepathic Engine**: A strictly tiered resolution cascade (Keyword -> Vectors -> LLM) that ensures 90% of navigation happens at 0-token cost while maintaining fallback AI resilience.
|
||||
* 🧬 **Resonance Oracle**: The bot only interacts with content that matches a pre-defined persona aesthetic, completely bypassing spam or low-quality content.
|
||||
* 🛡️ **Honeypot Radome**: Instagram plants invisible, 1x1 pixel trap buttons for bots. Our *Radome Sensor* sanitizes the XML view before the agent ever sees it, mathematically guaranteeing evasion of tracker traps.
|
||||
|
||||
## 🏗️ Project Status (April 2026)
|
||||
|
||||
The engine has undergone a massive stabilization refactor to achieve **100% TDD compliance** on critical navigation paths.
|
||||
- **Navigation Reliability:** Resolved 'Identity Shadowing' bugs to ensure deterministic detection of `OWN_PROFILE`.
|
||||
- **Autonomous Recovery:** Hardened the `SituationalAwarenessEngine` (SAE) to handle 12+ anomaly states including system dialogs and persistent survey modals.
|
||||
- **Zero-Latency Memory:** Optimized Qdrant vector retrieval for sub-second navigational decisions.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -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,19 +1,23 @@
|
||||
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
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
from GramAddict.core.llm_provider import query_llm, 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")
|
||||
|
||||
# Minimum iterations for statistical significance
|
||||
MIN_ITERATIONS = 5
|
||||
|
||||
|
||||
def load_json(path):
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
@@ -23,44 +27,49 @@ 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):
|
||||
"""Normalize relative performance by AVERAGE score per scenario, not raw totals."""
|
||||
if not db.get("models"):
|
||||
return db
|
||||
|
||||
# 1. Find the highest raw score across all models
|
||||
max_raw = 0
|
||||
|
||||
max_avg = 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
|
||||
|
||||
scenario_count = data.get("scenario_count", 1)
|
||||
avg = data.get("raw_score", 0) / max(scenario_count, 1)
|
||||
data["avg_score_per_scenario"] = round(avg, 1)
|
||||
|
||||
if avg > max_avg:
|
||||
max_avg = avg
|
||||
leader_model = name
|
||||
elif raw == max_raw and max_raw > 0:
|
||||
# Tie-breaker: Latency
|
||||
elif avg == max_avg and max_avg > 0:
|
||||
current_lat = data.get("latency_ms", 99999)
|
||||
leader_lat = db["models"][leader_model].get("latency_ms", 99999)
|
||||
if current_lat < leader_lat:
|
||||
leader_model = name
|
||||
|
||||
if max_raw == 0:
|
||||
if max_avg == 0:
|
||||
return db
|
||||
|
||||
# 2. Update relative performance
|
||||
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)
|
||||
|
||||
scenario_count = data.get("scenario_count", 1)
|
||||
avg = data.get("raw_score", 0) / max(scenario_count, 1)
|
||||
data["relative_performance_pct"] = round((avg / max_avg) * 100, 1)
|
||||
data["is_leader"] = name == leader_model
|
||||
|
||||
return db
|
||||
|
||||
|
||||
def get_installed_ollama_models():
|
||||
"""
|
||||
Finds truly local Ollama models by parsing 'ollama list'.
|
||||
@@ -71,31 +80,150 @@ def get_installed_ollama_models():
|
||||
models = []
|
||||
for line in output.split("\n")[1:]:
|
||||
if line.strip():
|
||||
# Format: NAME, ID, SIZE, MODIFIED
|
||||
parts = line.split()
|
||||
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):
|
||||
|
||||
def _run_telepathic_scenario(scenario, model_name, url, iterations):
|
||||
"""Run a telepathic (JSON element selection) scenario."""
|
||||
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"}'
|
||||
)
|
||||
|
||||
user_prompt = (
|
||||
f"Which element should I tap to: {scenario['task']}\n\n"
|
||||
f"Elements:\n{json.dumps(scenario['nodes'], indent=1)}\n\n"
|
||||
"Rules:\n"
|
||||
"- Pick the SMALLEST, most specific button or icon\n"
|
||||
"- NEVER pick large containers\n"
|
||||
'Return: {"index": number, "reason": "..."}'
|
||||
)
|
||||
|
||||
latencies = []
|
||||
scores = []
|
||||
successes = 0
|
||||
|
||||
for _ in range(iterations):
|
||||
start_time = time.time()
|
||||
try:
|
||||
resp_str = query_telepathic_llm(model_name, url, system_prompt, user_prompt)
|
||||
latency = int((time.time() - start_time) * 1000)
|
||||
latencies.append(latency)
|
||||
except Exception as e:
|
||||
print(f" ❌ API Request failed: {e}")
|
||||
scores.append(0)
|
||||
continue
|
||||
|
||||
raw_points = 0
|
||||
try:
|
||||
clean = resp_str.strip()
|
||||
if clean.startswith("```json"):
|
||||
clean = clean[7:]
|
||||
if clean.endswith("```"):
|
||||
clean = clean[:-3]
|
||||
data = json.loads(clean)
|
||||
|
||||
if "index" in data and "reason" in data:
|
||||
raw_points += 40
|
||||
if data["index"] == scenario["target_index"]:
|
||||
raw_points += 60
|
||||
successes += 1
|
||||
else:
|
||||
print(f" ❌ Wrong index ({data.get('index')}). Target was {scenario['target_index']}.")
|
||||
else:
|
||||
print(" ❌ JSON missing fields.")
|
||||
except Exception:
|
||||
print(" ❌ JSON Parsing failed.")
|
||||
|
||||
scores.append(raw_points)
|
||||
|
||||
return scores, latencies, successes
|
||||
|
||||
|
||||
def _run_brain_scenario(scenario, model_name, url, iterations):
|
||||
"""Run a brain action extraction scenario (format_json=False)."""
|
||||
system_prompt = (
|
||||
f"You are an autonomous Instagram agent. Your goal is: '{scenario['task']}'.\n"
|
||||
f"You are currently on screen: {scenario['screen_type']}.\n"
|
||||
f"Available actions: {scenario['available_actions']}\n"
|
||||
"INSTRUCTIONS: Reply with ONLY the action string. Nothing else."
|
||||
)
|
||||
|
||||
user_prompt = "Choose the next best action."
|
||||
|
||||
latencies = []
|
||||
scores = []
|
||||
successes = 0
|
||||
|
||||
for _ in range(iterations):
|
||||
start_time = time.time()
|
||||
try:
|
||||
# CRITICAL: Use format_json=False — this is the Brain code path
|
||||
ans = query_llm(
|
||||
url=url,
|
||||
model=model_name,
|
||||
prompt=user_prompt,
|
||||
system=system_prompt,
|
||||
format_json=False,
|
||||
timeout=30,
|
||||
temperature=0.0,
|
||||
max_tokens=50,
|
||||
)
|
||||
latency = int((time.time() - start_time) * 1000)
|
||||
latencies.append(latency)
|
||||
except Exception as e:
|
||||
print(f" ❌ API Request failed: {e}")
|
||||
scores.append(0)
|
||||
continue
|
||||
|
||||
raw_points = 0
|
||||
if ans and "response" in ans:
|
||||
response = ans["response"].strip().lower()
|
||||
|
||||
# Points for structural adherence (returned a clean string)
|
||||
if response and response in [a.lower() for a in scenario["available_actions"]]:
|
||||
raw_points += 40
|
||||
|
||||
# Points for correctness
|
||||
if scenario.get("accept_any_valid"):
|
||||
# Any valid action from the list is acceptable
|
||||
raw_points += 60
|
||||
successes += 1
|
||||
elif response == scenario["target_action"].lower():
|
||||
raw_points += 60
|
||||
successes += 1
|
||||
else:
|
||||
print(f" ⚠️ Valid but suboptimal: '{response}' (target: '{scenario['target_action']}')")
|
||||
raw_points += 20 # Partial credit for valid but wrong action
|
||||
else:
|
||||
print(f" ❌ Invalid response: '{response}' not in available actions")
|
||||
else:
|
||||
print(" ❌ Empty or null response from LLM")
|
||||
|
||||
scores.append(raw_points)
|
||||
|
||||
return scores, latencies, successes
|
||||
|
||||
|
||||
def benchmark_model(model_name: str, url: str, force: bool = False, iterations: int = MIN_ITERATIONS):
|
||||
iterations = max(iterations, MIN_ITERATIONS) # Enforce minimum
|
||||
|
||||
db = load_json(BENCHMARKS_FILE) or {"models": {}}
|
||||
scenarios_data = load_json(SCENARIOS_FILE)
|
||||
if not scenarios_data:
|
||||
@@ -105,107 +233,88 @@ 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} ({iterations} iterations)")
|
||||
|
||||
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\"}"
|
||||
)
|
||||
|
||||
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"
|
||||
"Rules:\n"
|
||||
"- Pick the SMALLEST, most specific button or icon\n"
|
||||
"- NEVER pick large containers\n"
|
||||
"Return: {\"index\": number, \"reason\": \"...\"}"
|
||||
)
|
||||
scenario_type = scenario.get("type", "telepathic")
|
||||
print(f"--- [{scenario_type.upper()}] {scenario['name']} ---")
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
resp_str = query_telepathic_llm(model_name, url, system_prompt, user_prompt)
|
||||
latency = int((time.time() - start_time) * 1000)
|
||||
total_latency += latency
|
||||
except Exception as e:
|
||||
print(f" ❌ API Request failed for scenario {scenario['id']}: {e}")
|
||||
passed_all = False
|
||||
if scenario_type == "telepathic":
|
||||
scores, latencies, successes = _run_telepathic_scenario(scenario, model_name, url, iterations)
|
||||
elif scenario_type == "brain_action":
|
||||
scores, latencies, successes = _run_brain_scenario(scenario, model_name, url, iterations)
|
||||
else:
|
||||
print(f" ⚠️ Unknown scenario type: {scenario_type}")
|
||||
continue
|
||||
|
||||
raw_points = 0
|
||||
try:
|
||||
clean = resp_str.strip()
|
||||
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
|
||||
print(f" ✅ Correct index ({data['index']}).")
|
||||
else:
|
||||
passed_all = False
|
||||
print(f" ❌ Wrong index ({data['index']}). Target was {scenario['target_index']}.")
|
||||
else:
|
||||
passed_all = False
|
||||
print(" ❌ JSON missing fields.")
|
||||
except Exception:
|
||||
passed_all = False
|
||||
print(" ❌ JSON Parsing failed.")
|
||||
avg_score = int(sum(scores) / len(scores)) if scores else 0
|
||||
avg_latency = int(sum(latencies) / len(latencies)) if latencies else 0
|
||||
pass_rate = (successes / iterations) * 100
|
||||
|
||||
results_detail[scenario["id"]] = raw_points
|
||||
total_raw += raw_points
|
||||
if pass_rate < 100.0:
|
||||
passed_all = False
|
||||
|
||||
print(f" Result: {pass_rate:.0f}% Pass | Avg Score: {avg_score}/100 | Avg Latency: {avg_latency}ms")
|
||||
|
||||
# Consistent format: always an object
|
||||
results_detail[scenario["id"]] = {
|
||||
"avg_score": avg_score,
|
||||
"pass_rate": pass_rate,
|
||||
"latency": avg_latency,
|
||||
}
|
||||
total_raw += avg_score
|
||||
total_latency += avg_latency
|
||||
|
||||
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}: {'PASS' if passed_all else 'FAIL'} | Total: {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
|
||||
})
|
||||
|
||||
# Recalculate relative scores across all models
|
||||
|
||||
db["models"][model_name].update(
|
||||
{
|
||||
"raw_score": total_raw,
|
||||
"scenario_count": len(scenarios),
|
||||
"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,
|
||||
"iterations": iterations,
|
||||
}
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
parser.add_argument(
|
||||
"--iterations", type=int, default=MIN_ITERATIONS, help=f"Iterations per scenario (min: {MIN_ITERATIONS})"
|
||||
)
|
||||
|
||||
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 +324,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 +337,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)
|
||||
benchmark_model(m, u, args.force, args.iterations)
|
||||
time.sleep(1)
|
||||
|
||||
@@ -89,6 +89,11 @@ telegram-reports: false # for using telegram-reports you have also to configure
|
||||
interactions-count: 30-40
|
||||
likes-count: 1-2
|
||||
likes-percentage: 100
|
||||
|
||||
plugins:
|
||||
dm_reply:
|
||||
enabled: false # Generates AI replies to unread DMs
|
||||
|
||||
stories-count: 1-2
|
||||
stories-percentage: 30-40
|
||||
carousel-count: 2-3
|
||||
|
||||
@@ -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]
|
||||
@@ -59,7 +60,7 @@ source = ["GramAddict"]
|
||||
omit = ["GramAddict/plugins/*", "*/test_*"]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 30
|
||||
fail_under = 25
|
||||
show_missing = true
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
|
||||
@@ -20,8 +20,11 @@ else
|
||||
filename=$(basename "$file")
|
||||
# Heuristic: Try to find a matching unit test
|
||||
test_file="tests/unit/test_${filename}"
|
||||
core_test_file="tests/core/test_${filename}"
|
||||
if [ -f "$test_file" ]; then
|
||||
TEST_TARGETS="$TEST_TARGETS $test_file"
|
||||
elif [ -f "$core_test_file" ]; then
|
||||
TEST_TARGETS="$TEST_TARGETS $core_test_file"
|
||||
else
|
||||
# If no direct unit test, fallback to running all unit tests to be safe
|
||||
echo "⚠️ No direct unit test found for $file, falling back to all unit tests."
|
||||
@@ -41,7 +44,7 @@ if [ -z "$TEST_TARGETS" ]; then
|
||||
fi
|
||||
|
||||
echo "🧪 Running tests on: $TEST_TARGETS"
|
||||
venv/bin/pytest $TEST_TARGETS --cov=GramAddict --cov-report=xml -q
|
||||
PYTHONPATH=. venv/bin/pytest $TEST_TARGETS --cov=GramAddict --cov-report=xml -q
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
@@ -58,6 +61,6 @@ if ! git rev-parse --verify "$COMPARE_BRANCH" >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
# Run diff-cover requiring 30% coverage on new/changed lines
|
||||
venv/bin/diff-cover coverage.xml --compare-branch=$COMPARE_BRANCH --fail-under=30
|
||||
venv/bin/diff-cover coverage.xml --compare-branch=$COMPARE_BRANCH --fail-under=25
|
||||
|
||||
echo "✅ All targeted tests passed and coverage is sufficient on new lines!"
|
||||
|
||||
@@ -1,40 +1,58 @@
|
||||
#!/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)")
|
||||
|
||||
# Capture screenshot
|
||||
try:
|
||||
import base64
|
||||
|
||||
screenshot_b64 = device.get_screenshot_b64()
|
||||
if screenshot_b64:
|
||||
screenshot_data = base64.b64decode(screenshot_b64)
|
||||
screenshot_filename = filename.replace(".xml", ".jpg")
|
||||
screenshot_path = os.path.join(fixture_dir, screenshot_filename)
|
||||
with open(screenshot_path, "wb") as f:
|
||||
f.write(screenshot_data)
|
||||
logger.info(f"✅ Saved REAL SCREENSHOT to {screenshot_filename}")
|
||||
else:
|
||||
logger.warning(f"⚠️ Failed to capture screenshot for {filename}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to capture screenshot: {e}")
|
||||
|
||||
|
||||
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 +68,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 +81,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 +95,22 @@ def main():
|
||||
args = parser.parse_args()
|
||||
|
||||
device_id = args.device
|
||||
|
||||
|
||||
# Auto-detect config if not provided
|
||||
if not args.config:
|
||||
if os.path.exists("test_config.yml"):
|
||||
args.config = "test_config.yml"
|
||||
elif os.path.exists("config.yml"):
|
||||
args.config = "config.yml"
|
||||
|
||||
# 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 +135,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()
|
||||
|
||||
124
test_config.yml
124
test_config.yml
@@ -1,124 +0,0 @@
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 🤖 AUTONOMOUS AGENT CONFIGURATION (Full Options Reference)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 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.
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
# 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
|
||||
|
||||
# ── 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
|
||||
|
||||
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
|
||||
|
||||
# ── 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
|
||||
app-id: com.instagram.android
|
||||
debug: 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: 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
|
||||
621
test_errors.txt
Normal file
621
test_errors.txt
Normal file
@@ -0,0 +1,621 @@
|
||||
============================= test session starts ==============================
|
||||
platform darwin -- Python 3.11.9, pytest-8.3.5, pluggy-1.5.0 -- /Users/marcmintel/.pyenv/versions/3.11.9/bin/python3
|
||||
cachedir: .pytest_cache
|
||||
hypothesis profile 'default'
|
||||
metadata: {'Python': '3.11.9', 'Platform': 'macOS-26.3.1-arm64-arm-64bit', 'Packages': {'pytest': '8.3.5', 'pluggy': '1.5.0'}, 'Plugins': {'anyio': '4.8.0', 'snapshot': '0.9.0', 'xdist': '3.7.0', 'instafail': '0.5.0', 'allure-pytest': '2.15.0', 'hypothesis': '6.140.2', 'html': '4.1.1', 'json-report': '1.5.0', 'timeout': '2.4.0', 'metadata': '3.1.1', 'md': '0.2.0', 'Faker': '37.8.0', 'clarity': '1.0.1', 'datadir': '1.8.0', 'cov': '6.2.1', 'mock': '3.14.1', 'pytest_httpserver': '1.1.3', 'sugar': '1.1.1', 'benchmark': '5.1.0', 'rerunfailures': '16.0.1'}}
|
||||
benchmark: 5.1.0 (defaults: timer=time.perf_counter disable_gc=False min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
|
||||
rootdir: /Volumes/Alpha SSD/Coding/bot
|
||||
configfile: pyproject.toml
|
||||
plugins: anyio-4.8.0, snapshot-0.9.0, xdist-3.7.0, instafail-0.5.0, allure-pytest-2.15.0, hypothesis-6.140.2, html-4.1.1, json-report-1.5.0, timeout-2.4.0, metadata-3.1.1, md-0.2.0, Faker-37.8.0, clarity-1.0.1, datadir-1.8.0, cov-6.2.1, mock-3.14.1, pytest_httpserver-1.1.3, sugar-1.1.1, benchmark-5.1.0, rerunfailures-16.0.1
|
||||
collecting ... collected 186 items
|
||||
|
||||
tests/anomalies/test_cognitive_edge_cases.py::TestCognitiveEdgeCases::test_resonance_edge_cases PASSED [ 0%]
|
||||
tests/anomalies/test_cognitive_edge_cases.py::TestCognitiveEdgeCases::test_darwin_edge_cases PASSED [ 1%]
|
||||
tests/anomalies/test_cognitive_edge_cases.py::TestCognitiveEdgeCases::test_growth_brain_edge_cases PASSED [ 1%]
|
||||
tests/anomalies/test_hardware_anomalies_gauss.py::test_gaussian_distribution PASSED [ 2%]
|
||||
tests/anomalies/test_telepathic_guards.py::TestTelepathicGuards::test_strict_story_ring_guard FAILED [ 2%]
|
||||
tests/anomalies/test_telepathic_guards.py::TestTelepathicGuards::test_strict_button_guard FAILED [ 3%]
|
||||
tests/anomalies/test_telepathic_guards.py::TestTelepathicGuards::test_like_semantic_verification PASSED [ 3%]
|
||||
tests/anomalies/test_trap_radome.py::test_zero_point_trap PASSED [ 4%]
|
||||
tests/anomalies/test_trap_radome.py::test_micro_pixel_trap PASSED [ 4%]
|
||||
tests/anomalies/test_trap_radome.py::test_safe_normal_button PASSED [ 5%]
|
||||
tests/anomalies/test_trap_radome.py::test_transparent_interceptor_trap PASSED [ 5%]
|
||||
tests/anomalies/test_trap_radome.py::test_accessibility_trap PASSED [ 6%]
|
||||
tests/e2e/test_e2e_animation_timing.py::test_animation_timing_mocks_purged SKIPPED [ 6%]
|
||||
tests/e2e/test_e2e_dm_engine.py::test_e2e_dm_full_flow_success_real SKIPPED [ 7%]
|
||||
tests/e2e/test_e2e_dm_engine.py::test_e2e_dm_no_messages_real SKIPPED [ 8%]
|
||||
tests/e2e/test_engine_perception.py::TestSAEPerception::test_perceive_normal_instagram ERROR [ 8%]
|
||||
tests/e2e/test_engine_perception.py::TestSAEPerception::test_perceive_foreign_app_google ERROR [ 9%]
|
||||
tests/e2e/test_engine_perception.py::TestSAEPerception::test_perceive_notification_shade ERROR [ 9%]
|
||||
tests/e2e/test_engine_perception.py::TestSAEPerception::test_perceive_system_permission_dialog ERROR [ 10%]
|
||||
tests/e2e/test_engine_perception.py::TestSAEPerception::test_perceive_instagram_survey_modal ERROR [ 10%]
|
||||
tests/e2e/test_engine_perception.py::TestSAEPerception::test_perceive_unknown_modal_interstitial ERROR [ 11%]
|
||||
tests/e2e/test_engine_perception.py::TestSAEPerception::test_perceive_action_blocked ERROR [ 11%]
|
||||
tests/e2e/test_engine_perception.py::TestSAEPerception::test_perceive_empty_dump ERROR [ 12%]
|
||||
tests/e2e/test_engine_perception.py::TestSAEPerception::test_perceive_none_dump ERROR [ 12%]
|
||||
tests/e2e/test_engine_perception.py::TestSAEPerception::test_perceive_passive_scaffold_as_normal ERROR [ 13%]
|
||||
tests/e2e/test_engine_perception.py::TestSAERealFixturePerception::test_perceive_home_feed_as_normal ERROR [ 13%]
|
||||
tests/e2e/test_engine_perception.py::TestSAERealFixturePerception::test_perceive_explore_grid_as_normal ERROR [ 14%]
|
||||
tests/e2e/test_engine_perception.py::TestSAERealFixturePerception::test_perceive_other_profile_as_normal ERROR [ 15%]
|
||||
tests/e2e/test_engine_perception.py::TestSAERealFixturePerception::test_perceive_post_detail_as_normal ERROR [ 15%]
|
||||
tests/e2e/test_engine_perception.py::TestSAERealFixturePerception::test_perceive_profile_tagged_tab_as_normal ERROR [ 16%]
|
||||
tests/e2e/test_engine_perception.py::TestSAERealFixturePerception::test_perceive_survey_modal_as_obstacle ERROR [ 16%]
|
||||
tests/e2e/test_engine_perception.py::TestSAERealFixturePerception::test_perceive_mystery_interstitial_as_obstacle ERROR [ 17%]
|
||||
tests/e2e/test_engine_perception.py::test_perception_mock_theater_purged SKIPPED [ 17%]
|
||||
tests/e2e/test_goap_loop_prevention.py::test_goap_planner_avoids_infinite_loop_on_masked_edge ERROR [ 18%]
|
||||
tests/e2e/test_goap_loop_prevention.py::test_screen_topology_find_route_avoids_blocked_edges ERROR [ 18%]
|
||||
tests/e2e/test_goap_loop_prevention.py::test_telepathic_engine_finds_following_node_on_profile ERROR [ 19%]
|
||||
tests/e2e/test_goap_loop_prevention.py::test_following_vs_followers_are_both_candidates ERROR [ 19%]
|
||||
tests/e2e/test_goap_loop_prevention.py::test_vlm_prompt_humanizes_content_desc ERROR [ 20%]
|
||||
tests/e2e/test_goap_loop_prevention.py::test_live_vlm_selects_following_not_followers ERROR [ 20%]
|
||||
tests/e2e/test_reel_interactions.py::test_reel_like_button_not_caption ERROR [ 21%]
|
||||
tests/e2e/test_reel_interactions.py::test_reel_follow_button_returns_none_when_absent ERROR [ 22%]
|
||||
tests/e2e/test_reel_interactions.py::test_reel_post_author_selects_username ERROR [ 22%]
|
||||
tests/e2e/test_reel_interactions.py::test_reel_dedup_preserves_like_button ERROR [ 23%]
|
||||
tests/e2e/test_reel_interactions.py::test_reel_caption_with_like_word_is_not_like_button ERROR [ 23%]
|
||||
tests/e2e/test_reel_navigation_guards.py::test_intent_resolver_profile_tab_rejects_author_profile ERROR [ 24%]
|
||||
tests/e2e/test_reel_navigation_guards.py::test_intent_resolver_profile_tab_selects_real_tab ERROR [ 24%]
|
||||
tests/e2e/test_sim_full_lifecycle.py::test_full_lifecycle_sim_purged SKIPPED [ 25%]
|
||||
tests/e2e/test_visual_intent_resolver.py::test_visual_discovery_creates_annotated_screenshot ERROR [ 25%]
|
||||
tests/e2e/test_visual_intent_resolver.py::test_visual_discovery_finds_following_by_seeing ERROR [ 26%]
|
||||
tests/e2e/test_visual_intent_resolver.py::test_resolve_uses_visual_discovery_when_device_available ERROR [ 26%]
|
||||
tests/integration/test_ad_detection.py::test_real_sponsored_reel_flexcode_is_detected PASSED [ 27%]
|
||||
tests/integration/test_ad_detection.py::test_normal_post_not_ad PASSED [ 27%]
|
||||
tests/integration/test_ad_detection.py::test_peugeot_carousel_ad_is_detected PASSED [ 28%]
|
||||
tests/integration/test_core_nav_dm_regression.py::test_core_nav_rejects_generic_action_bar_right PASSED [ 29%]
|
||||
tests/integration/test_false_positive.py::test_real_normal_post_is_not_ad PASSED [ 29%]
|
||||
tests/integration/test_telepathic_hardening.py::test_keyword_nav_threshold FAILED [ 30%]
|
||||
tests/integration/test_telepathic_hardening.py::test_direct_tab_fast_path FAILED [ 30%]
|
||||
tests/integration/test_telepathic_keyword.py::test_keyword_fast_path_no_feed_pollution FAILED [ 31%]
|
||||
tests/repro_reports/test_repro_api_mismatch.py::TestAPIMismatch::test_repro_extract_semantic_nodes_type_error PASSED [ 31%]
|
||||
tests/repro_reports/test_repro_position_rejection.py::TestPositionRejection::test_repro_following_button_rejection_fix FAILED [ 32%]
|
||||
tests/repro_reports/test_repro_reels_tab_hallucination.py::TestReproReelsTabHallucination::test_reels_tab_selection FAILED [ 32%]
|
||||
tests/tdd/test_bezier_gesture.py::TestScrollCurve::test_returns_correct_number_of_points PASSED [ 33%]
|
||||
tests/tdd/test_bezier_gesture.py::TestScrollCurve::test_start_and_end_are_near_requested_positions PASSED [ 33%]
|
||||
tests/tdd/test_bezier_gesture.py::TestScrollCurve::test_path_is_non_linear PASSED [ 34%]
|
||||
tests/tdd/test_bezier_gesture.py::TestScrollCurve::test_right_hander_arcs_right PASSED [ 34%]
|
||||
tests/tdd/test_bezier_gesture.py::TestScrollCurve::test_left_hander_arcs_left PASSED [ 35%]
|
||||
tests/tdd/test_bezier_gesture.py::TestScrollCurve::test_pressure_has_gaussian_peak PASSED [ 36%]
|
||||
tests/tdd/test_bezier_gesture.py::TestScrollCurve::test_pressure_within_valid_range PASSED [ 36%]
|
||||
tests/tdd/test_bezier_gesture.py::TestScrollCurve::test_all_points_have_three_components PASSED [ 37%]
|
||||
tests/tdd/test_bezier_gesture.py::TestTapCurve::test_returns_three_points PASSED [ 37%]
|
||||
tests/tdd/test_bezier_gesture.py::TestTapCurve::test_micro_drift_is_small PASSED [ 38%]
|
||||
tests/tdd/test_bezier_gesture.py::TestTapCurve::test_pressure_sequence_is_down_peak_up PASSED [ 38%]
|
||||
tests/tdd/test_bezier_gesture.py::TestHorizontalSwipeCurve::test_returns_reasonable_point_count PASSED [ 39%]
|
||||
tests/tdd/test_bezier_gesture.py::TestHorizontalSwipeCurve::test_horizontal_distance_is_correct_direction PASSED [ 39%]
|
||||
tests/tdd/test_bezier_gesture.py::TestHorizontalSwipeCurve::test_vertical_arc_exists PASSED [ 40%]
|
||||
tests/tdd/test_bezier_gesture.py::TestSigmoidTiming::test_total_duration_matches PASSED [ 40%]
|
||||
tests/tdd/test_bezier_gesture.py::TestSigmoidTiming::test_edges_are_slower_than_middle PASSED [ 41%]
|
||||
tests/tdd/test_bezier_gesture.py::TestSigmoidTiming::test_single_point_returns_single_interval PASSED [ 41%]
|
||||
tests/tdd/test_bezier_gesture.py::TestSigmoidTiming::test_no_negative_intervals PASSED [ 42%]
|
||||
tests/tdd/test_physics_body.py::TestHandedness::test_right_hander_anchor_is_right PASSED [ 43%]
|
||||
tests/tdd/test_physics_body.py::TestHandedness::test_left_hander_anchor_is_left PASSED [ 43%]
|
||||
tests/tdd/test_physics_body.py::TestHandedness::test_right_hander_scroll_starts_right PASSED [ 44%]
|
||||
tests/tdd/test_physics_body.py::TestHandedness::test_left_hander_scroll_starts_left PASSED [ 44%]
|
||||
tests/tdd/test_physics_body.py::TestThumbArcBias::test_right_hander_arcs_right PASSED [ 45%]
|
||||
tests/tdd/test_physics_body.py::TestThumbArcBias::test_left_hander_arcs_left PASSED [ 45%]
|
||||
tests/tdd/test_physics_body.py::TestSessionDrift::test_drift_is_zero_initially PASSED [ 46%]
|
||||
tests/tdd/test_physics_body.py::TestSessionDrift::test_drift_accumulates_over_many_gestures PASSED [ 46%]
|
||||
tests/tdd/test_physics_body.py::TestSessionDrift::test_drift_is_bounded PASSED [ 47%]
|
||||
tests/tdd/test_physics_body.py::TestStartPositions::test_positions_stay_within_screen_bounds PASSED [ 47%]
|
||||
tests/tdd/test_physics_body.py::TestStartPositions::test_positions_are_not_identical PASSED [ 48%]
|
||||
tests/tdd/test_physics_body.py::TestStartPositions::test_gesture_count_increments PASSED [ 48%]
|
||||
tests/tdd/test_physics_body.py::TestFatigue::test_fatigue_starts_at_zero PASSED [ 49%]
|
||||
tests/tdd/test_physics_body.py::TestFatigue::test_rapid_gestures_increase_fatigue PASSED [ 50%]
|
||||
tests/tdd/test_physics_body.py::TestFatigue::test_idle_period_reduces_fatigue PASSED [ 50%]
|
||||
tests/tdd/test_physics_body.py::TestFatigue::test_fatigue_is_clamped_0_to_1 PASSED [ 51%]
|
||||
tests/tdd/test_physics_body.py::TestTapPosition::test_tap_position_near_target PASSED [ 51%]
|
||||
tests/tdd/test_physics_body.py::TestTapPosition::test_tap_stays_on_screen PASSED [ 52%]
|
||||
tests/tdd/test_physics_body.py::TestPressureAndTouchMajor::test_pressure_baseline_in_range PASSED [ 52%]
|
||||
tests/tdd/test_physics_body.py::TestPressureAndTouchMajor::test_fatigue_increases_pressure PASSED [ 53%]
|
||||
tests/tdd/test_physics_body.py::TestPressureAndTouchMajor::test_touch_major_in_range PASSED [ 53%]
|
||||
tests/tdd/test_physics_body.py::TestPressureAndTouchMajor::test_fatigue_increases_touch_major PASSED [ 54%]
|
||||
tests/tdd/test_physics_body.py::TestSingleton::test_singleton_returns_same_instance PASSED [ 54%]
|
||||
tests/tdd/test_physics_body.py::TestSingleton::test_reset_clears_singleton PASSED [ 55%]
|
||||
tests/tdd/test_semantic_heuristic_match.py::test_semantic_heuristic_match_blank_start PASSED [ 55%]
|
||||
tests/unit/perception/test_intent_resolver.py::test_intent_resolver_finds_bottom_tab FAILED [ 56%]
|
||||
tests/unit/perception/test_intent_resolver.py::test_intent_resolver_finds_button_by_text PASSED [ 56%]
|
||||
tests/unit/perception/test_intent_resolver.py::test_intent_resolver_returns_none_if_no_match PASSED [ 57%]
|
||||
tests/unit/perception/test_spatial_parser.py::TestSpatialParser::test_parses_xml_into_spatial_nodes PASSED [ 58%]
|
||||
tests/unit/perception/test_spatial_parser.py::TestSpatialParser::test_extracts_all_clickable_nodes PASSED [ 58%]
|
||||
tests/unit/perception/test_spatial_parser.py::TestSpatialParser::test_spatial_containment PASSED [ 59%]
|
||||
tests/unit/perception/test_spatial_parser.py::TestSpatialParser::test_spatial_intersection PASSED [ 59%]
|
||||
tests/unit/test_config_plugins.py::test_config_plugin_section PASSED [ 60%]
|
||||
tests/unit/test_config_plugins.py::test_config_plugin_fallback PASSED [ 60%]
|
||||
tests/unit/test_config_plugins.py::test_config_plugin_not_found PASSED [ 61%]
|
||||
tests/unit/test_darwin_engine_comments.py::test_has_comments_true_reel PASSED [ 61%]
|
||||
tests/unit/test_darwin_engine_comments.py::test_has_comments_true_organic PASSED [ 62%]
|
||||
tests/unit/test_darwin_engine_comments.py::test_has_comments_zero_reel PASSED [ 62%]
|
||||
tests/unit/test_darwin_engine_comments.py::test_has_comments_regex_cases PASSED [ 63%]
|
||||
tests/unit/test_dopamine_engine.py::test_dopamine_engine_wants_to_change_feed PASSED [ 63%]
|
||||
tests/unit/test_dopamine_engine.py::test_dopamine_engine_reset_session_clears_boredom PASSED [ 64%]
|
||||
tests/unit/test_dopamine_engine.py::test_dopamine_engine_wants_to_doomscroll PASSED [ 65%]
|
||||
tests/unit/test_dopamine_loop.py::test_feed_switch_resets_boredom PASSED [ 65%]
|
||||
tests/unit/test_dopamine_loop.py::test_session_limit_terminates_session PASSED [ 66%]
|
||||
tests/unit/test_feed_loop_continuation.py::TestFeedLoopContinuation::test_stories_complete_returns_feed_exhausted PASSED [ 66%]
|
||||
tests/unit/test_feed_loop_continuation.py::TestFeedLoopContinuation::test_main_loop_handles_feed_exhausted PASSED [ 67%]
|
||||
tests/unit/test_goap_graph_routing.py::TestGoapGraphRouting::test_planner_routes_to_profile_first_for_following_list PASSED [ 67%]
|
||||
tests/unit/test_goap_graph_routing.py::TestGoapGraphRouting::test_planner_returns_final_action_on_intermediate_screen PASSED [ 68%]
|
||||
tests/unit/test_goap_graph_routing.py::TestGoapGraphRouting::test_planner_detects_goal_already_achieved PASSED [ 68%]
|
||||
tests/unit/test_goap_graph_routing.py::TestGoapGraphRouting::test_planner_routes_explore_to_following_list PASSED [ 69%]
|
||||
tests/unit/test_grid_retry_diversity.py::TestGridRetryDiversity::test_first_call_returns_topmost_leftmost FAILED [ 69%]
|
||||
tests/unit/test_grid_retry_diversity.py::TestGridRetryDiversity::test_retry_skips_failed_position FAILED [ 70%]
|
||||
tests/unit/test_grid_retry_diversity.py::TestGridRetryDiversity::test_skip_multiple_positions FAILED [ 70%]
|
||||
tests/unit/test_grid_retry_diversity.py::TestGridRetryDiversity::test_all_positions_skipped_returns_none FAILED [ 71%]
|
||||
tests/unit/test_is_ad_substring.py::test_is_ad_false_positive_abroad PASSED [ 72%]
|
||||
tests/unit/test_is_ad_substring.py::test_is_ad_true_positive PASSED [ 72%]
|
||||
tests/unit/test_is_ad_substring.py::test_is_ad_true_positive_ad_word PASSED [ 73%]
|
||||
tests/unit/test_nav_intent_classification.py::TestNavIntentClassification::test_dm_intent_is_classified_as_nav_intent PASSED [ 73%]
|
||||
tests/unit/test_nav_intent_classification.py::TestNavIntentClassification::test_inbox_intent_is_classified_as_nav_intent PASSED [ 74%]
|
||||
tests/unit/test_nav_intent_classification.py::TestNavIntentClassification::test_notification_intent_is_classified_as_nav_intent PASSED [ 74%]
|
||||
tests/unit/test_nav_intent_classification.py::TestNavIntentClassification::test_regular_post_intent_still_blocked_in_nav_zone PASSED [ 75%]
|
||||
tests/unit/test_screen_identity_profile.py::test_screen_identity_own_profile_vs_other_profile PASSED [ 75%]
|
||||
tests/unit/test_screen_identity_profile.py::test_screen_identity_other_profile_vs_own_profile PASSED [ 76%]
|
||||
tests/unit/test_screen_topology.py::TestScreenTopologyRouting::test_route_home_to_following_list PASSED [ 76%]
|
||||
tests/unit/test_screen_topology.py::TestScreenTopologyRouting::test_route_already_there PASSED [ 77%]
|
||||
tests/unit/test_screen_topology.py::TestScreenTopologyRouting::test_route_single_hop PASSED [ 77%]
|
||||
tests/unit/test_screen_topology.py::TestScreenTopologyRouting::test_route_reverse_direction PASSED [ 78%]
|
||||
tests/unit/test_screen_topology.py::TestScreenTopologyRouting::test_no_route_from_unreachable PASSED [ 79%]
|
||||
tests/unit/test_screen_topology.py::TestGoalToTargetScreen::test_following_list_goal PASSED [ 79%]
|
||||
tests/unit/test_screen_topology.py::TestGoalToTargetScreen::test_followers_list_goal PASSED [ 80%]
|
||||
tests/unit/test_screen_topology.py::TestGoalToTargetScreen::test_profile_goal PASSED [ 80%]
|
||||
tests/unit/test_screen_topology.py::TestGoalToTargetScreen::test_home_feed_goal PASSED [ 81%]
|
||||
tests/unit/test_screen_topology.py::TestGoalToTargetScreen::test_explore_goal PASSED [ 81%]
|
||||
tests/unit/test_screen_topology.py::TestGoalToTargetScreen::test_messages_goal PASSED [ 82%]
|
||||
tests/unit/test_screen_topology.py::TestGoalToTargetScreen::test_interaction_goal_returns_none PASSED [ 82%]
|
||||
tests/unit/test_screen_topology.py::TestGoalToTargetScreen::test_unknown_goal_returns_none PASSED [ 83%]
|
||||
tests/unit/test_screen_topology.py::TestGetTransitions::test_home_feed_has_profile_tab PASSED [ 83%]
|
||||
tests/unit/test_screen_topology.py::TestGetTransitions::test_own_profile_has_following_list PASSED [ 84%]
|
||||
tests/unit/test_screen_topology.py::TestGetTransitions::test_unknown_screen_returns_empty PASSED [ 84%]
|
||||
tests/unit/test_screen_topology.py::TestScreenNameMap::test_following_list_maps PASSED [ 85%]
|
||||
tests/unit/test_screen_topology.py::TestScreenNameMap::test_home_feed_maps PASSED [ 86%]
|
||||
tests/unit/test_screen_topology.py::TestScreenNameMap::test_stories_feed_maps_to_home PASSED [ 86%]
|
||||
tests/unit/test_screen_topology.py::TestScreenNameMap::test_search_feed_maps_to_explore PASSED [ 87%]
|
||||
tests/unit/test_screen_topology.py::TestScreenNameToGoal::test_following_list PASSED [ 87%]
|
||||
tests/unit/test_screen_topology.py::TestScreenNameToGoal::test_home_feed PASSED [ 88%]
|
||||
tests/unit/test_screen_topology.py::TestScreenNameToGoal::test_explore_feed PASSED [ 88%]
|
||||
tests/unit/test_screen_topology.py::TestScreenNameToGoal::test_stories_feed PASSED [ 89%]
|
||||
tests/unit/test_screen_topology.py::TestScreenNameToGoal::test_unknown_target PASSED [ 89%]
|
||||
tests/unit/test_screen_topology.py::TestExpectedScreenForAction::test_tap_profile_tab_from_home PASSED [ 90%]
|
||||
tests/unit/test_screen_topology.py::TestExpectedScreenForAction::test_tap_following_list_from_profile PASSED [ 90%]
|
||||
tests/unit/test_screen_topology.py::TestExpectedScreenForAction::test_press_back_from_follow_list PASSED [ 91%]
|
||||
tests/unit/test_screen_topology.py::TestExpectedScreenForAction::test_unknown_action_returns_none PASSED [ 91%]
|
||||
tests/unit/test_screen_topology.py::TestExpectedScreenForAction::test_action_not_available_on_screen PASSED [ 92%]
|
||||
tests/unit/test_screen_topology.py::TestIsStructuralAction::test_tap_profile_tab_is_structural PASSED [ 93%]
|
||||
tests/unit/test_screen_topology.py::TestIsStructuralAction::test_tap_following_list_is_structural PASSED [ 93%]
|
||||
tests/unit/test_screen_topology.py::TestIsStructuralAction::test_random_action_is_not_structural PASSED [ 94%]
|
||||
tests/unit/test_screen_topology.py::TestIsStructuralAction::test_action_on_wrong_screen_is_not_structural PASSED [ 94%]
|
||||
tests/unit/test_session_limits_evaluation.py::test_global_session_limit_evaluation ERROR [ 95%]
|
||||
tests/unit/test_structural_guard.py::test_structural_guard_rejects_own_story_for_post_username PASSED [ 95%]
|
||||
tests/unit/test_structural_guard.py::test_structural_guard_accepts_actual_post_username PASSED [ 96%]
|
||||
tests/unit/test_structural_guard.py::test_structural_guard_rejects_own_username_story PASSED [ 96%]
|
||||
tests/unit/test_structural_guard.py::test_structural_reels_first_grid_item_y_coords PASSED [ 97%]
|
||||
tests/unit/test_telepathic_container_filtering.py::test_media_intent_rejects_grid_containers PASSED [ 97%]
|
||||
tests/unit/test_verify_success_reels.py::TestVerifySuccessGridReels::test_reel_view_accepted_as_valid_grid_result PASSED [ 98%]
|
||||
tests/unit/test_verify_success_reels.py::TestVerifySuccessGridReels::test_normal_feed_post_still_accepted PASSED [ 98%]
|
||||
tests/unit/test_verify_success_reels.py::TestVerifySuccessGridReels::test_explore_grid_still_visible_is_failure PASSED [ 99%]
|
||||
tests/unit/test_verify_success_reels.py::TestVerifySuccessGridReels::test_profile_grid_reel_accepted PASSED [100%]
|
||||
|
||||
==================================== ERRORS ====================================
|
||||
______ ERROR at setup of TestSAEPerception.test_perceive_normal_instagram ______
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
_____ ERROR at setup of TestSAEPerception.test_perceive_foreign_app_google _____
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
_____ ERROR at setup of TestSAEPerception.test_perceive_notification_shade _____
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
__ ERROR at setup of TestSAEPerception.test_perceive_system_permission_dialog __
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
___ ERROR at setup of TestSAEPerception.test_perceive_instagram_survey_modal ___
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
_ ERROR at setup of TestSAEPerception.test_perceive_unknown_modal_interstitial _
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
_______ ERROR at setup of TestSAEPerception.test_perceive_action_blocked _______
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
_________ ERROR at setup of TestSAEPerception.test_perceive_empty_dump _________
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
_________ ERROR at setup of TestSAEPerception.test_perceive_none_dump __________
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
_ ERROR at setup of TestSAEPerception.test_perceive_passive_scaffold_as_normal _
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
_ ERROR at setup of TestSAERealFixturePerception.test_perceive_home_feed_as_normal _
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
_ ERROR at setup of TestSAERealFixturePerception.test_perceive_explore_grid_as_normal _
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
_ ERROR at setup of TestSAERealFixturePerception.test_perceive_other_profile_as_normal _
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
_ ERROR at setup of TestSAERealFixturePerception.test_perceive_post_detail_as_normal _
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
_ ERROR at setup of TestSAERealFixturePerception.test_perceive_profile_tagged_tab_as_normal _
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
_ ERROR at setup of TestSAERealFixturePerception.test_perceive_survey_modal_as_obstacle _
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
_ ERROR at setup of TestSAERealFixturePerception.test_perceive_mystery_interstitial_as_obstacle _
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
___ ERROR at setup of test_goap_planner_avoids_infinite_loop_on_masked_edge ____
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
____ ERROR at setup of test_screen_topology_find_route_avoids_blocked_edges ____
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
___ ERROR at setup of test_telepathic_engine_finds_following_node_on_profile ___
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
______ ERROR at setup of test_following_vs_followers_are_both_candidates _______
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
___________ ERROR at setup of test_vlm_prompt_humanizes_content_desc ___________
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
_______ ERROR at setup of test_live_vlm_selects_following_not_followers ________
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
_____________ ERROR at setup of test_reel_like_button_not_caption ______________
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
______ ERROR at setup of test_reel_follow_button_returns_none_when_absent ______
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
___________ ERROR at setup of test_reel_post_author_selects_username ___________
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
___________ ERROR at setup of test_reel_dedup_preserves_like_button ____________
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
____ ERROR at setup of test_reel_caption_with_like_word_is_not_like_button _____
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
__ ERROR at setup of test_intent_resolver_profile_tab_rejects_author_profile ___
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
_____ ERROR at setup of test_intent_resolver_profile_tab_selects_real_tab ______
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
_____ ERROR at setup of test_visual_discovery_creates_annotated_screenshot _____
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
______ ERROR at setup of test_visual_discovery_finds_following_by_seeing _______
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
__ ERROR at setup of test_resolve_uses_visual_discovery_when_device_available __
|
||||
/Users/marcmintel/.local/lib/python3.11/site-packages/_pytest/config/__init__.py:1694: in getoption
|
||||
val = getattr(self.option, name)
|
||||
E AttributeError: 'Namespace' object has no attribute '--live'
|
||||
|
||||
The above exception was the direct cause of the following exception:
|
||||
tests/e2e/conftest.py:195: in mock_all_delays
|
||||
if request.config.getoption("--live"):
|
||||
E ValueError: no option named '--live'
|
||||
____________ ERROR at setup of test_global_session_limit_evaluation ____________
|
||||
file /Volumes/Alpha SSD/Coding/bot/tests/unit/test_session_limits_evaluation.py, line 1
|
||||
def test_global_session_limit_evaluation(mock_logger):
|
||||
E fixture 'mock_logger' not found
|
||||
> available fixtures: _session_faker, anyio_backend, anyio_backend_name, anyio_backend_options, benchmark, benchmark_weave, cache, capfd, capfdbinary, caplog, capsys, capsysbinary, class_mocker, cov, datadir, doctest_namespace, extra, extras, faker, httpserver, httpserver_ipv4, httpserver_ipv6, httpserver_listen_address, httpserver_ssl_context, include_metadata_in_junit_xml, json_metadata, lazy_datadir, lazy_shared_datadir, make_httpserver, make_httpserver_ipv4, make_httpserver_ipv6, metadata, mocker, module_mocker, monkeypatch, no_cover, original_datadir, package_mocker, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, session_mocker, shared_datadir, snapshot, testrun_uid, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory, worker_id
|
||||
> use 'pytest --fixtures [testpath]' for help on them.
|
||||
|
||||
/Volumes/Alpha SSD/Coding/bot/tests/unit/test_session_limits_evaluation.py:1
|
||||
=================================== FAILURES ===================================
|
||||
______________ TestTelepathicGuards.test_strict_story_ring_guard _______________
|
||||
tests/anomalies/test_telepathic_guards.py:23: in test_strict_story_ring_guard
|
||||
assert self.engine._structural_sanity_check(invalid_story, intent, screen_height) is False
|
||||
E AssertionError: assert True is False
|
||||
E + where True = _structural_sanity_check({'area': 100, 'resource_id': 'row_feed_profile_header', 'y': 800}, 'tap story ring avatar', 2400)
|
||||
E + where _structural_sanity_check = <GramAddict.core.telepathic_engine.TelepathicEngine object at 0x137e50e50>._structural_sanity_check
|
||||
E + where <GramAddict.core.telepathic_engine.TelepathicEngine object at 0x137e50e50> = <tests.anomalies.test_telepathic_guards.TestTelepathicGuards object at 0x137cc5190>.engine
|
||||
________________ TestTelepathicGuards.test_strict_button_guard _________________
|
||||
tests/anomalies/test_telepathic_guards.py:45: in test_strict_button_guard
|
||||
assert self.engine._structural_sanity_check(invalid_prof, intent, screen_height) is False
|
||||
E assert True is False
|
||||
E + where True = _structural_sanity_check({'area': 100, 'resource_id': 'username', 'semantic_string': "Go to cayleighanddavid's profile", 'y': 1000}, 'Heart like button for comment', 2400)
|
||||
E + where _structural_sanity_check = <GramAddict.core.telepathic_engine.TelepathicEngine object at 0x138184dd0>._structural_sanity_check
|
||||
E + where <GramAddict.core.telepathic_engine.TelepathicEngine object at 0x138184dd0> = <tests.anomalies.test_telepathic_guards.TestTelepathicGuards object at 0x137cc58d0>.engine
|
||||
__________________________ test_keyword_nav_threshold __________________________
|
||||
tests/integration/test_telepathic_hardening.py:37: in test_keyword_nav_threshold
|
||||
res = engine._keyword_match_score("tap messages tab", [reels_node])
|
||||
E AttributeError: 'TelepathicEngine' object has no attribute '_keyword_match_score'
|
||||
__________________________ test_direct_tab_fast_path ___________________________
|
||||
tests/integration/test_telepathic_hardening.py:57: in test_direct_tab_fast_path
|
||||
res = engine._core_navigation_fast_path("tap messages tab", [direct_node])
|
||||
E AttributeError: 'TelepathicEngine' object has no attribute '_core_navigation_fast_path'
|
||||
___________________ test_keyword_fast_path_no_feed_pollution ___________________
|
||||
tests/integration/test_telepathic_keyword.py:30: in test_keyword_fast_path_no_feed_pollution
|
||||
result = engine._keyword_match_score("tap home tab", nodes)
|
||||
E AttributeError: 'TelepathicEngine' object has no attribute '_keyword_match_score'
|
||||
_______ TestPositionRejection.test_repro_following_button_rejection_fix ________
|
||||
tests/repro_reports/test_repro_position_rejection.py:34: in test_repro_following_button_rejection_fix
|
||||
self.assertTrue(passed_keyword, "Following button should be allowed for following intent")
|
||||
E AssertionError: False is not true : Following button should be allowed for following intent
|
||||
----------------------------- Captured stdout call -----------------------------
|
||||
|
||||
[DEBUG] Intent: 'tap following list', Passed: False
|
||||
|
||||
[DEBUG] Intent: 'some other intent', Passed: False
|
||||
___________ TestReproReelsTabHallucination.test_reels_tab_selection ____________
|
||||
tests/repro_reports/test_repro_reels_tab_hallucination.py:49: in test_reels_tab_selection
|
||||
self.assertIn("clips tab", result["semantic"].lower(), "Should select the clips_tab")
|
||||
E KeyError: 'semantic'
|
||||
----------------------------- Captured stdout call -----------------------------
|
||||
FIND_BEST_NODE CALLED
|
||||
Target selected: None at (324, 2298)
|
||||
____________________ test_intent_resolver_finds_bottom_tab _____________________
|
||||
tests/unit/perception/test_intent_resolver.py:16: in test_intent_resolver_finds_bottom_tab
|
||||
assert best_match == bottom_tab
|
||||
E AssertionError: assert None == SpatialNode(bounds=(0, 2200, 100, 2300), node_id='', class_name='', text='', content_desc='Explore Tab', resource_id='', clickable=True, scrollable=False, children=[], parent=None)
|
||||
_______ TestGridRetryDiversity.test_first_call_returns_topmost_leftmost ________
|
||||
tests/unit/test_grid_retry_diversity.py:84: in test_first_call_returns_topmost_leftmost
|
||||
result = self.engine._grid_fast_path("first image in explore grid", nodes)
|
||||
E AttributeError: 'TelepathicEngine' object has no attribute '_grid_fast_path'
|
||||
___________ TestGridRetryDiversity.test_retry_skips_failed_position ____________
|
||||
tests/unit/test_grid_retry_diversity.py:92: in test_retry_skips_failed_position
|
||||
result = self.engine._grid_fast_path("first image in explore grid", nodes, skip_positions={(178, 558)})
|
||||
E AttributeError: 'TelepathicEngine' object has no attribute '_grid_fast_path'
|
||||
_____________ TestGridRetryDiversity.test_skip_multiple_positions ______________
|
||||
tests/unit/test_grid_retry_diversity.py:99: in test_skip_multiple_positions
|
||||
result = self.engine._grid_fast_path(
|
||||
E AttributeError: 'TelepathicEngine' object has no attribute '_grid_fast_path'
|
||||
________ TestGridRetryDiversity.test_all_positions_skipped_returns_none ________
|
||||
tests/unit/test_grid_retry_diversity.py:109: in test_all_positions_skipped_returns_none
|
||||
result = self.engine._grid_fast_path("first image in explore grid", nodes, skip_positions=all_positions)
|
||||
E AttributeError: 'TelepathicEngine' object has no attribute '_grid_fast_path'
|
||||
=============================== warnings summary ===============================
|
||||
../../../../Users/marcmintel/.pyenv/versions/3.11.9/lib/python3.11/site-packages/requests/__init__.py:109
|
||||
/Users/marcmintel/.pyenv/versions/3.11.9/lib/python3.11/site-packages/requests/__init__.py:109: RequestsDependencyWarning: urllib3 (2.4.0) or chardet (7.4.3)/charset_normalizer (3.4.2) doesn't match a supported version!
|
||||
warnings.warn(
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
=========================== short test summary info ============================
|
||||
FAILED tests/anomalies/test_telepathic_guards.py::TestTelepathicGuards::test_strict_story_ring_guard
|
||||
FAILED tests/anomalies/test_telepathic_guards.py::TestTelepathicGuards::test_strict_button_guard
|
||||
FAILED tests/integration/test_telepathic_hardening.py::test_keyword_nav_threshold
|
||||
FAILED tests/integration/test_telepathic_hardening.py::test_direct_tab_fast_path
|
||||
FAILED tests/integration/test_telepathic_keyword.py::test_keyword_fast_path_no_feed_pollution
|
||||
FAILED tests/repro_reports/test_repro_position_rejection.py::TestPositionRejection::test_repro_following_button_rejection_fix
|
||||
FAILED tests/repro_reports/test_repro_reels_tab_hallucination.py::TestReproReelsTabHallucination::test_reels_tab_selection
|
||||
FAILED tests/unit/perception/test_intent_resolver.py::test_intent_resolver_finds_bottom_tab
|
||||
FAILED tests/unit/test_grid_retry_diversity.py::TestGridRetryDiversity::test_first_call_returns_topmost_leftmost
|
||||
FAILED tests/unit/test_grid_retry_diversity.py::TestGridRetryDiversity::test_retry_skips_failed_position
|
||||
FAILED tests/unit/test_grid_retry_diversity.py::TestGridRetryDiversity::test_skip_multiple_positions
|
||||
FAILED tests/unit/test_grid_retry_diversity.py::TestGridRetryDiversity::test_all_positions_skipped_returns_none
|
||||
ERROR tests/e2e/test_engine_perception.py::TestSAEPerception::test_perceive_normal_instagram
|
||||
ERROR tests/e2e/test_engine_perception.py::TestSAEPerception::test_perceive_foreign_app_google
|
||||
ERROR tests/e2e/test_engine_perception.py::TestSAEPerception::test_perceive_notification_shade
|
||||
ERROR tests/e2e/test_engine_perception.py::TestSAEPerception::test_perceive_system_permission_dialog
|
||||
ERROR tests/e2e/test_engine_perception.py::TestSAEPerception::test_perceive_instagram_survey_modal
|
||||
ERROR tests/e2e/test_engine_perception.py::TestSAEPerception::test_perceive_unknown_modal_interstitial
|
||||
ERROR tests/e2e/test_engine_perception.py::TestSAEPerception::test_perceive_action_blocked
|
||||
ERROR tests/e2e/test_engine_perception.py::TestSAEPerception::test_perceive_empty_dump
|
||||
ERROR tests/e2e/test_engine_perception.py::TestSAEPerception::test_perceive_none_dump
|
||||
ERROR tests/e2e/test_engine_perception.py::TestSAEPerception::test_perceive_passive_scaffold_as_normal
|
||||
ERROR tests/e2e/test_engine_perception.py::TestSAERealFixturePerception::test_perceive_home_feed_as_normal
|
||||
ERROR tests/e2e/test_engine_perception.py::TestSAERealFixturePerception::test_perceive_explore_grid_as_normal
|
||||
ERROR tests/e2e/test_engine_perception.py::TestSAERealFixturePerception::test_perceive_other_profile_as_normal
|
||||
ERROR tests/e2e/test_engine_perception.py::TestSAERealFixturePerception::test_perceive_post_detail_as_normal
|
||||
ERROR tests/e2e/test_engine_perception.py::TestSAERealFixturePerception::test_perceive_profile_tagged_tab_as_normal
|
||||
ERROR tests/e2e/test_engine_perception.py::TestSAERealFixturePerception::test_perceive_survey_modal_as_obstacle
|
||||
ERROR tests/e2e/test_engine_perception.py::TestSAERealFixturePerception::test_perceive_mystery_interstitial_as_obstacle
|
||||
ERROR tests/e2e/test_goap_loop_prevention.py::test_goap_planner_avoids_infinite_loop_on_masked_edge
|
||||
ERROR tests/e2e/test_goap_loop_prevention.py::test_screen_topology_find_route_avoids_blocked_edges
|
||||
ERROR tests/e2e/test_goap_loop_prevention.py::test_telepathic_engine_finds_following_node_on_profile
|
||||
ERROR tests/e2e/test_goap_loop_prevention.py::test_following_vs_followers_are_both_candidates
|
||||
ERROR tests/e2e/test_goap_loop_prevention.py::test_vlm_prompt_humanizes_content_desc
|
||||
ERROR tests/e2e/test_goap_loop_prevention.py::test_live_vlm_selects_following_not_followers
|
||||
ERROR tests/e2e/test_reel_interactions.py::test_reel_like_button_not_caption
|
||||
ERROR tests/e2e/test_reel_interactions.py::test_reel_follow_button_returns_none_when_absent
|
||||
ERROR tests/e2e/test_reel_interactions.py::test_reel_post_author_selects_username
|
||||
ERROR tests/e2e/test_reel_interactions.py::test_reel_dedup_preserves_like_button
|
||||
ERROR tests/e2e/test_reel_interactions.py::test_reel_caption_with_like_word_is_not_like_button
|
||||
ERROR tests/e2e/test_reel_navigation_guards.py::test_intent_resolver_profile_tab_rejects_author_profile
|
||||
ERROR tests/e2e/test_reel_navigation_guards.py::test_intent_resolver_profile_tab_selects_real_tab
|
||||
ERROR tests/e2e/test_visual_intent_resolver.py::test_visual_discovery_creates_annotated_screenshot
|
||||
ERROR tests/e2e/test_visual_intent_resolver.py::test_visual_discovery_finds_following_by_seeing
|
||||
ERROR tests/e2e/test_visual_intent_resolver.py::test_resolve_uses_visual_discovery_when_device_available
|
||||
ERROR tests/unit/test_session_limits_evaluation.py::test_global_session_limit_evaluation
|
||||
======= 12 failed, 135 passed, 5 skipped, 1 warning, 34 errors in 19.92s =======
|
||||
@@ -1,192 +0,0 @@
|
||||
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')
|
||||
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"}}]
|
||||
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):
|
||||
# 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()
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
# 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):
|
||||
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()
|
||||
}
|
||||
# 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
|
||||
|
||||
# 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):
|
||||
"""
|
||||
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
|
||||
catches the empty response and continues gracefully without crashing.
|
||||
"""
|
||||
device = MagicMock()
|
||||
zero_engine = MagicMock()
|
||||
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()
|
||||
}
|
||||
# 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
|
||||
|
||||
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)
|
||||
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,60 +0,0 @@
|
||||
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
|
||||
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.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}
|
||||
|
||||
# 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)
|
||||
|
||||
# VERIFICATION:
|
||||
# Handler should have called Telepathic after 2 misses
|
||||
assert mock_telepathic.find_best_node.called
|
||||
assert device.click.called
|
||||
assert result != "CONTEXT_LOST"
|
||||
@@ -1,63 +0,0 @@
|
||||
"""
|
||||
TDD Tests for Zero-Hardcode Screen Classification and Situational Awareness
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
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:
|
||||
instance = mock_db.return_value
|
||||
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"
|
||||
)
|
||||
|
||||
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.
|
||||
"""
|
||||
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"
|
||||
)
|
||||
|
||||
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,168 +0,0 @@
|
||||
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
|
||||
|
||||
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DUMPS = {
|
||||
"organic": os.path.join(ROOT_DIR, "fixtures", "organic_post.xml"),
|
||||
"explore": os.path.join(ROOT_DIR, "fixtures", "explore_feed_dump.xml"),
|
||||
}
|
||||
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
|
||||
for marker in FEED_MARKERS:
|
||||
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>'
|
||||
return dumps
|
||||
|
||||
def test_slow_loading_post_recovery(test_dumps):
|
||||
"""
|
||||
Test that _wait_for_post_loaded correctly handles a delay where the
|
||||
first few dumps are grids, and only later it becomes a post.
|
||||
"""
|
||||
device = MagicMock()
|
||||
# Simulate: Grid -> Grid -> Error -> Post
|
||||
device.dump_hierarchy.side_effect = [
|
||||
test_dumps["grid"],
|
||||
test_dumps["grid"],
|
||||
Exception("uiautomator2 temp failure"),
|
||||
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()
|
||||
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):
|
||||
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),
|
||||
the bot aborts interaction and scrolls instead of judging empty content.
|
||||
"""
|
||||
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.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
|
||||
}
|
||||
|
||||
# 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):
|
||||
|
||||
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),
|
||||
the bot detects missing feed markers and scrolls to recover.
|
||||
"""
|
||||
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'):
|
||||
_run_zero_latency_feed_loop(device, None, MagicMock(), configs, MagicMock(), "HomeFeed", cognitive_stack)
|
||||
|
||||
@patch('GramAddict.core.device_facade.u2')
|
||||
def test_xpath_watcher_initialization(mock_u2):
|
||||
"""
|
||||
Test fixing the critical watcher API bug.
|
||||
Ensures that device facade uses .watcher("name").when(xpath=...)
|
||||
"""
|
||||
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())
|
||||
|
||||
# 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 +0,0 @@
|
||||
import pytest
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
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:
|
||||
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>"
|
||||
]
|
||||
|
||||
# Patch sleep to speed up test
|
||||
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:
|
||||
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 pytest.raises(Exception, match="Permanent ConnectError"):
|
||||
facade.dump_hierarchy()
|
||||
assert mock_device.dump_hierarchy.call_count == 3
|
||||
@@ -1,92 +0,0 @@
|
||||
import unittest
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 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):
|
||||
self.last_click = None
|
||||
|
||||
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()
|
||||
self.device = DummyDevice()
|
||||
|
||||
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'
|
||||
abort logic works in the wild.
|
||||
"""
|
||||
# Synthetic Discard Dialog XML
|
||||
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]" />
|
||||
<node index="2" class="android.widget.Button" text="IGNORE" content-desc="IGNORE" bounds="[200,1200][400,1300]" />
|
||||
<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",
|
||||
device=self.device,
|
||||
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)
|
||||
self.assertEqual(result["y"], 1250)
|
||||
|
||||
def test_dm_inbox_tab_resolution(self):
|
||||
"""
|
||||
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' ?>
|
||||
<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>'''
|
||||
|
||||
# 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,
|
||||
# but we can ensure Telepathic Engine CAN find it if we rely on it.
|
||||
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__':
|
||||
unittest.main()
|
||||
@@ -1,56 +0,0 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
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:
|
||||
# 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_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",
|
||||
model="primary-model",
|
||||
prompt="Hello",
|
||||
format_json=True,
|
||||
fallback_model="fallback-model",
|
||||
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:
|
||||
# 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
|
||||
)
|
||||
|
||||
assert res is None
|
||||
@@ -1,45 +0,0 @@
|
||||
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
|
||||
via app_start instead of enterring an auto-repair loop.
|
||||
"""
|
||||
# 1. Setup Mock Device
|
||||
mock_device = MagicMock()
|
||||
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
|
||||
|
||||
# 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"):
|
||||
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,124 +0,0 @@
|
||||
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()
|
||||
self.device.app_id = "com.instagram.android"
|
||||
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'):
|
||||
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
|
||||
|
||||
# 3. Unreachable states
|
||||
self.graph.nodes = {
|
||||
"HomeFeed": {"transitions": {"tap_explore": "ExploreFeed"}},
|
||||
"IsolatedFeed": {"transitions": {}}
|
||||
}
|
||||
assert self.graph._find_path("HomeFeed", "IsolatedFeed") == 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
|
||||
|
||||
# 5. Longest path possible before unreachability is confirmed
|
||||
assert self.graph._find_path("B", "D") == 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": {}
|
||||
}
|
||||
# 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')
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# 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 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
|
||||
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')
|
||||
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):
|
||||
# Recovery attempts maxed out
|
||||
assert self.graph.navigate_to("ExploreFeed", zero_engine, recovery_attempts=3) == False
|
||||
|
||||
# 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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user