Compare commits
9 Commits
7b8daa7670
...
fix/lying-
| Author | SHA1 | Date | |
|---|---|---|---|
| ee3de811d3 | |||
| c93333928a | |||
| 12937cb2c1 | |||
| 097a5753f9 | |||
| 9ee6aab831 | |||
| da804b174a | |||
| 93175b7caf | |||
| e37d92cdfd | |||
| 1c38dabe79 |
@@ -460,9 +460,13 @@ def start_bot(**kwargs):
|
||||
target_map = {
|
||||
"DiscoverNewContent": ["ExploreFeed", "ReelsFeed"],
|
||||
"NurtureCommunity": ["HomeFeed", "StoriesFeed"],
|
||||
"SocialReciprocity": ["FollowingList", "MessageInbox"],
|
||||
"SocialReciprocity": ["FollowingList"],
|
||||
}
|
||||
|
||||
dm_config = configs.get_plugin_config("dm_reply")
|
||||
if dm_config.get("enabled", False):
|
||||
target_map["SocialReciprocity"].append("MessageInbox")
|
||||
|
||||
import secrets
|
||||
|
||||
options = target_map.get(current_desire, ["HomeFeed"])
|
||||
@@ -860,7 +864,11 @@ def _run_zero_latency_feed_loop(
|
||||
|
||||
elif governance_decision == "CHECK_CURIOSITY":
|
||||
logger.info("👀 [Curiosity] Spontaneously checking DMs / Notifications...")
|
||||
explore_target = random.choice(["MessageInbox", "Notifications"])
|
||||
dm_config = configs.get_plugin_config("dm_reply")
|
||||
if dm_config.get("enabled", False):
|
||||
explore_target = random.choice(["MessageInbox", "Notifications"])
|
||||
else:
|
||||
explore_target = "Notifications"
|
||||
|
||||
if explore_target == "MessageInbox":
|
||||
nav_graph.do("tap direct message icon inbox")
|
||||
|
||||
@@ -18,15 +18,13 @@ import time
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from GramAddict.core.utils import random_sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Re-export for backward compatibility (optional but helps minimize import breakage)
|
||||
__all__ = ["GoalExecutor", "ScreenIdentity", "ScreenType", "PathMemory", "NavigationKnowledge", "GoalPlanner"]
|
||||
|
||||
@@ -173,7 +171,9 @@ class GoalExecutor:
|
||||
continue
|
||||
|
||||
# PLAN
|
||||
action = self.planner.plan_next_step(goal, screen, explored_nav_actions=explored_nav_actions, action_failures=self.action_failures)
|
||||
action = self.planner.plan_next_step(
|
||||
goal, screen, explored_nav_actions=explored_nav_actions, action_failures=self.action_failures
|
||||
)
|
||||
|
||||
if action is None:
|
||||
# Goal achieved!
|
||||
@@ -199,6 +199,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
|
||||
|
||||
56
GramAddict/core/navigation/brain.py
Normal file
56
GramAddict/core/navigation/brain.py
Normal file
@@ -0,0 +1,56 @@
|
||||
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 += (
|
||||
"CRITICAL INSTRUCTIONS:\n"
|
||||
"1. If your goal requires an element (like 'following list') that you previously tried but failed to find, it is highly likely hidden off-screen.\n"
|
||||
"2. If you haven't scrolled yet, you MUST choose to 'scroll down' or 'scroll up' to reveal more of the screen.\n"
|
||||
"3. Only choose 'press back' or 'tap home tab' if you are completely trapped or in the wrong section entirely.\n"
|
||||
"4. DO NOT hallucinate actions. Reply ONLY with the exact string from the available actions list."
|
||||
)
|
||||
|
||||
try:
|
||||
response = query_llm(
|
||||
url=url, model=model, prompt="Choose the next best action.", system=prompt, format_json=False
|
||||
)
|
||||
if response:
|
||||
result = response if isinstance(response, str) else response.get("response", "")
|
||||
result = result.strip().strip("'\"")
|
||||
|
||||
# Fuzzy match to available actions just in case
|
||||
for act in available_actions:
|
||||
if act.lower() in result.lower():
|
||||
return act
|
||||
|
||||
logger.warning(f"🧠 [Brain] LLM returned an invalid action: '{result}'. Falling back.")
|
||||
except Exception as e:
|
||||
logger.debug(f"🧠 [Brain] Error querying LLM: {e}")
|
||||
|
||||
return None
|
||||
@@ -17,7 +17,9 @@ class GoalPlanner:
|
||||
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) -> Optional[str]:
|
||||
def plan_next_step(
|
||||
self, goal: str, screen: Dict[str, Any], explored_nav_actions: set = None, action_failures: dict = None
|
||||
) -> Optional[str]:
|
||||
"""Plans the NEXT single action to take toward the goal."""
|
||||
screen_type = screen["screen_type"]
|
||||
available = screen.get("available_actions", [])
|
||||
@@ -34,7 +36,9 @@ class GoalPlanner:
|
||||
|
||||
# ── 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)
|
||||
nav_action = self._plan_navigation(
|
||||
goal_lower, screen_type, available, selected_tab, explored_nav_actions, action_failures
|
||||
)
|
||||
if nav_action:
|
||||
return nav_action
|
||||
|
||||
@@ -89,7 +93,7 @@ class GoalPlanner:
|
||||
else:
|
||||
logger.debug(f"🛡️ [Aversive Filter] Masking trapped action: '{action}'")
|
||||
available = safe_available
|
||||
|
||||
|
||||
# Build avoid_actions for HD Map route planning
|
||||
avoid_actions = (explored_nav_actions or set()).copy()
|
||||
if action_failures:
|
||||
@@ -97,7 +101,17 @@ class GoalPlanner:
|
||||
if count >= 2: # MAX_RETRIES is 2 in goap
|
||||
avoid_actions.add(act)
|
||||
|
||||
# ── 1. HD Map Routing (Primary Strategy) ──
|
||||
# ── 1. 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, explored_nav_actions)
|
||||
if brain_action:
|
||||
logger.info(f"🧠 [Brain] Decided dynamically to execute: '{brain_action}'")
|
||||
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)
|
||||
@@ -112,9 +126,11 @@ class GoalPlanner:
|
||||
)
|
||||
return next_action
|
||||
else:
|
||||
logger.warning(f"🛡️ [HD Map] Route action '{next_action}' is trapped. Falling back.")
|
||||
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. Falling back.")
|
||||
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)
|
||||
|
||||
@@ -72,6 +72,16 @@ class IntentResolver:
|
||||
if intent_lower in abstract_goals:
|
||||
return None
|
||||
|
||||
# --- Strict VLM Hallucination Guard ---
|
||||
# For known structural targets that the VLM frequently hallucinates when they are missing,
|
||||
# we enforce a strict failure if they weren't caught by the structural fast paths.
|
||||
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
|
||||
|
||||
# ── PRIMARY PATH: Visual Discovery ──
|
||||
# If we have a device, the VLM SEES the screen and decides.
|
||||
if device:
|
||||
|
||||
@@ -155,6 +155,26 @@ class TelepathicEngine:
|
||||
grid_items.sort(key=lambda n: (n.get("y", 9999), n.get("x", 9999)))
|
||||
return grid_items[0]
|
||||
|
||||
# --- Profile Structural Fast Paths ---
|
||||
if "following list" in intent_lower or "followers list" in intent_lower:
|
||||
target_id = "profile_header_following" if "following" in intent_lower else "profile_header_followers"
|
||||
for n in nodes:
|
||||
res_id = n.get("id", "") or n.get("resource_id", "")
|
||||
if target_id in res_id:
|
||||
return n
|
||||
# Fallback to text matching if ID not found
|
||||
for n in nodes:
|
||||
sem = (n.get("semantic_string", "") or "").lower()
|
||||
desc = (n.get("description", "") or "").lower()
|
||||
text = (n.get("text", "") or "").lower()
|
||||
|
||||
if "following" in intent_lower:
|
||||
if "following" in sem or "abonniert" in sem or "following" in desc or "following" in text:
|
||||
return n
|
||||
else:
|
||||
if "followers" in sem or "abonnenten" in sem or "followers" in desc or "followers" in text:
|
||||
return n
|
||||
|
||||
# --- DM Engine Structural Fast Paths ---
|
||||
if "find the message input text field" in intent_lower:
|
||||
for n in nodes:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -149,3 +149,48 @@ def test_no_hallucination_missing_button():
|
||||
assert (
|
||||
result is None
|
||||
), f"VLM hallucinated an element! It picked id='{result.resource_id}', desc='{result.content_desc}'"
|
||||
|
||||
|
||||
@pytest.mark.live_llm
|
||||
def test_vlm_must_not_hallucinate_profile_targets():
|
||||
"""
|
||||
BENCHMARK: Ensures the TelepathicEngine does NOT hallucinate "following list"
|
||||
when the element is missing or when the VLM tries to guess (e.g., picking "Grid view").
|
||||
"""
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
# Use a dump that does NOT have a clear following button (e.g., home feed)
|
||||
xml_path = "tests/fixtures/home_feed_with_ad.xml"
|
||||
jpg_path = "tests/fixtures/home_feed_with_ad.jpg"
|
||||
|
||||
with open(xml_path, "r", encoding="utf-8") as f:
|
||||
xml = f.read()
|
||||
|
||||
# We make a mock device
|
||||
def _make_device_with_real_image(img_path):
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(img_path)
|
||||
|
||||
class DummyDeviceV2:
|
||||
def __init__(self, img):
|
||||
self.img = img
|
||||
|
||||
def screenshot(self):
|
||||
return self.img
|
||||
|
||||
class DummyDevice:
|
||||
def __init__(self, img):
|
||||
self.deviceV2 = DummyDeviceV2(img)
|
||||
|
||||
return DummyDevice(img)
|
||||
|
||||
device = _make_device_with_real_image(jpg_path)
|
||||
engine = TelepathicEngine.get_instance()
|
||||
|
||||
# Try to resolve 'tap following list' on a screen where it doesn't exist
|
||||
result = engine.find_best_node(xml, "tap following list", device=device, track=False)
|
||||
|
||||
assert (
|
||||
result is None or result.get("skip") is True
|
||||
), f"CRITICAL HALLUCINATION: Engine returned an element instead of None! Result: {result}"
|
||||
|
||||
34
tests/e2e/test_brain_live.py
Normal file
34
tests/e2e/test_brain_live.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import pytest
|
||||
from GramAddict.core.navigation.brain import ask_brain_for_action
|
||||
from GramAddict.core.perception.screen_identity import ScreenType
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@pytest.mark.live_llm
|
||||
def test_brain_recommends_scroll_when_trapped():
|
||||
"""
|
||||
Test that the real, live LLM Brain correctly deduces that it should
|
||||
scroll down when the target element is missing and it's trapped.
|
||||
"""
|
||||
goal = "open following list"
|
||||
screen = "OWN_PROFILE"
|
||||
available_actions = ["tap profile tab", "tap share button", "press back", "tap reels tab", "tap messages tab", "scroll down", "scroll up"]
|
||||
explored_nav_actions = {"tap following list"}
|
||||
|
||||
# We query the actual LLM as configured in the environment (e.g. qwen3.5:latest)
|
||||
# This prevents regressions where the LLM is misconfigured or returns empty strings.
|
||||
brain_action = ask_brain_for_action(
|
||||
goal=goal,
|
||||
screen_type=screen,
|
||||
available_actions=available_actions,
|
||||
explored_actions=explored_nav_actions
|
||||
)
|
||||
|
||||
logger.info(f"Brain action returned: '{brain_action}'")
|
||||
|
||||
assert brain_action is not None, "Brain LLM returned None. Is the URL/Model configured correctly?"
|
||||
assert brain_action != "", "Brain LLM returned an empty string."
|
||||
|
||||
# The brain should reasonably choose 'scroll down' to find the missing following list
|
||||
assert brain_action == "scroll down", f"Expected Brain to choose 'scroll down', but got '{brain_action}'"
|
||||
33
tests/test_planner_dynamic_brain.py
Normal file
33
tests/test_planner_dynamic_brain.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from GramAddict.core.navigation.planner import GoalPlanner
|
||||
from GramAddict.core.perception.screen_identity import ScreenType
|
||||
|
||||
|
||||
def test_planner_falls_back_to_brain_when_hd_map_fails():
|
||||
"""
|
||||
Test that if HD Map routing fails because the structural target is not visible
|
||||
(and thus in explored_nav_actions), the planner falls back to the Brain
|
||||
to dynamically pick an action like 'scroll down'.
|
||||
"""
|
||||
planner = GoalPlanner("testuser")
|
||||
|
||||
# Simulate a screen where the target isn't visible
|
||||
screen = {
|
||||
"screen_type": ScreenType.OWN_PROFILE,
|
||||
"available_actions": ["press back", "scroll down", "tap profile tab"],
|
||||
"context": {},
|
||||
}
|
||||
|
||||
# Simulate that 'tap following list' failed previously and is in explored actions
|
||||
explored = {"tap following list"}
|
||||
|
||||
# The brain should realize that 'scroll down' is the best way to uncover the target
|
||||
with patch("GramAddict.core.navigation.brain.ask_brain_for_action", return_value="scroll down") as mock_brain:
|
||||
action = planner.plan_next_step("go to followers/following list", screen, explored_nav_actions=explored)
|
||||
|
||||
# Verify the brain was queried
|
||||
mock_brain.assert_called_once()
|
||||
|
||||
# Verify the brain's decision is respected
|
||||
assert action == "scroll down"
|
||||
63
tests/test_planner_hierarchy.py
Normal file
63
tests/test_planner_hierarchy.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from GramAddict.core.navigation.planner import GoalPlanner
|
||||
from GramAddict.core.perception.screen_identity import ScreenType
|
||||
|
||||
@pytest.fixture
|
||||
def planner():
|
||||
return GoalPlanner("test_user")
|
||||
|
||||
@patch("GramAddict.core.navigation.brain.ask_brain_for_action")
|
||||
@patch("GramAddict.core.screen_topology.ScreenTopology.find_route")
|
||||
def test_brain_is_primary_strategy(mock_find_route, mock_ask_brain, planner):
|
||||
"""
|
||||
TDD Proof: Brain must be evaluated BEFORE HD Map.
|
||||
If Brain returns a valid action, HD Map should never be queried.
|
||||
"""
|
||||
# 1. Setup State
|
||||
goal = "open some screen"
|
||||
screen = {
|
||||
"screen_type": ScreenType.HOME_FEED,
|
||||
"available_actions": ["action A", "action B"],
|
||||
"context": {}
|
||||
}
|
||||
|
||||
# 2. Setup Mocks
|
||||
mock_ask_brain.return_value = "action A" # Brain picks A
|
||||
mock_find_route.return_value = [("action B", ScreenType.EXPLORE_GRID)] # HD Map would pick B
|
||||
|
||||
# 3. Execute Planner
|
||||
action = planner.plan_next_step(goal, screen)
|
||||
|
||||
# 4. Assertions
|
||||
assert action == "action A", "Planner did not use the Brain's action!"
|
||||
mock_ask_brain.assert_called_once()
|
||||
mock_find_route.assert_not_called() # Crucial: HD Map must be skipped entirely!
|
||||
|
||||
@patch("GramAddict.core.navigation.brain.ask_brain_for_action")
|
||||
@patch("GramAddict.core.screen_topology.ScreenTopology.find_route")
|
||||
@patch("GramAddict.core.screen_topology.ScreenTopology.goal_to_target_screen")
|
||||
def test_brain_fallback_to_hd_map(mock_goal_target, mock_find_route, mock_ask_brain, planner):
|
||||
"""
|
||||
TDD Proof: If Brain fails (returns None), Planner must fallback to HD Map.
|
||||
"""
|
||||
# 1. Setup State
|
||||
goal = "open explore screen"
|
||||
screen = {
|
||||
"screen_type": ScreenType.HOME_FEED,
|
||||
"available_actions": ["action A", "action B"],
|
||||
"context": {}
|
||||
}
|
||||
|
||||
# 2. Setup Mocks
|
||||
mock_ask_brain.return_value = None # Brain fails or is confused
|
||||
mock_goal_target.return_value = ScreenType.EXPLORE_GRID
|
||||
mock_find_route.return_value = [("action B", ScreenType.EXPLORE_GRID)] # HD Map picks B
|
||||
|
||||
# 3. Execute Planner
|
||||
action = planner.plan_next_step(goal, screen)
|
||||
|
||||
# 4. Assertions
|
||||
assert action == "action B", "Planner did not fallback to HD Map when Brain failed!"
|
||||
mock_ask_brain.assert_called_once()
|
||||
mock_find_route.assert_called_once()
|
||||
Reference in New Issue
Block a user