Compare commits
12 Commits
7b8daa7670
...
fix/lying-
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b9465a3bc | |||
| 5d50228945 | |||
| 7277f27fae | |||
| 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
|
||||
@@ -306,6 +321,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
|
||||
|
||||
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)
|
||||
|
||||
@@ -5,6 +5,20 @@ 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:
|
||||
"""
|
||||
@@ -36,7 +50,11 @@ class ActionMemory:
|
||||
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."""
|
||||
"""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
|
||||
@@ -44,6 +62,15 @@ class ActionMemory:
|
||||
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.")
|
||||
|
||||
# Store or boost in Qdrant
|
||||
@@ -145,6 +172,18 @@ class ActionMemory:
|
||||
logger.error(f"Failed to query VLM for visual verification: {e}")
|
||||
# Fallthrough to structural delta if VLM crashes
|
||||
|
||||
# ── Pre-Structural Semantic Gate ──
|
||||
# Before trusting ANY structural delta, verify the clicked element
|
||||
# semantically matches the intent. Prevents photo-clicks from
|
||||
# being validated as follow/like successes.
|
||||
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 if no device, VLM fails, or high confidence bypass
|
||||
diff = abs(len(pre_click_xml) - len(post_click_xml))
|
||||
|
||||
@@ -166,3 +205,30 @@ class ActionMemory:
|
||||
|
||||
logger.warning(f"⚠️ [ActionMemory] No structural change detected for '{intent}'. Verification FAIL.")
|
||||
return False
|
||||
|
||||
|
||||
def _intent_matches_node(intent: str, semantic_string: str) -> bool:
|
||||
"""Checks if the clicked element semantically matches the toggle intent.
|
||||
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -138,6 +138,7 @@ class QNavGraph:
|
||||
"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:
|
||||
|
||||
@@ -125,10 +125,10 @@ class QdrantBase:
|
||||
if key:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
# OpenAI/OpenRouter use 'input' instead of 'prompt'
|
||||
payload = {"model": model, "input": str(text)[:8000]}
|
||||
payload = {"model": model, "input": str(text)[:2000]}
|
||||
else:
|
||||
# Local Ollama
|
||||
payload = {"model": model, "prompt": str(text)[:8000]}
|
||||
payload = {"model": model, "prompt": str(text)[:2000]}
|
||||
|
||||
# Log to prevent user from thinking the bot is hung during model swap in VRAM
|
||||
if not getattr(self, "_has_logged_embedding", False):
|
||||
@@ -361,7 +361,7 @@ class UIMemoryDB(QdrantBase):
|
||||
sig = re.sub(r"\s+", " ", sig).strip()
|
||||
|
||||
# 3. Strict truncation for nomic-embed-text context window
|
||||
return sig[:4000]
|
||||
return sig[:2000]
|
||||
|
||||
def _deterministic_id(self, intent: str) -> str:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "========================================"
|
||||
|
||||
55
tests/core/test_embeddings_truncation.py
Normal file
55
tests/core/test_embeddings_truncation.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from GramAddict.core.qdrant_memory import QdrantBase
|
||||
|
||||
|
||||
@pytest.mark.live_llm
|
||||
def test_embedding_context_length_limit():
|
||||
"""
|
||||
TDD Proof: Ensure _get_embedding truncates input sufficiently to avoid
|
||||
'input length exceeds the context length' (500) from Ollama.
|
||||
"""
|
||||
|
||||
class DummyMemory(QdrantBase):
|
||||
def __init__(self):
|
||||
# Bypass init connection checks for this test
|
||||
self._vector_size = 768
|
||||
pass
|
||||
|
||||
memory = DummyMemory()
|
||||
|
||||
# Mock config to use standard local embedding endpoint
|
||||
class FakeArgs:
|
||||
ai_embedding_model = "nomic-embed-text"
|
||||
ai_embedding_url = "http://localhost:11434/api/embeddings"
|
||||
|
||||
memory._cached_args = FakeArgs()
|
||||
|
||||
# Generate an extremely long string (e.g. 15,000 chars) that would crash Ollama
|
||||
huge_text = "lorem ipsum " * 2000
|
||||
|
||||
# This should NOT raise a requests.exceptions.HTTPError (500)
|
||||
try:
|
||||
vector = memory._get_embedding(huge_text)
|
||||
assert vector is not None, "Vector should not be None for successful API calls"
|
||||
assert len(vector) > 0, "Vector should have dimensions"
|
||||
except requests.exceptions.HTTPError as e:
|
||||
pytest.fail(f"Embedding API crashed with HTTP error (likely context limit): {e}")
|
||||
|
||||
|
||||
@pytest.mark.live_llm
|
||||
def test_structural_signature_truncation():
|
||||
"""
|
||||
Ensure UIMemoryDB correctly truncates structural signatures to 2000 chars.
|
||||
"""
|
||||
from GramAddict.core.qdrant_memory import UIMemoryDB
|
||||
|
||||
db = UIMemoryDB()
|
||||
|
||||
# Huge XML-like string
|
||||
huge_xml = "<node " + ('text="junk" ' * 1000) + "/>"
|
||||
|
||||
sig = db._create_structural_signature(huge_xml)
|
||||
assert len(sig) <= 2000, f"Signature length {len(sig)} exceeds 2000"
|
||||
assert "text=" not in sig, "Structural signature should have removed 'text' attributes"
|
||||
@@ -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}'"
|
||||
350
tests/e2e/test_follow_verification_integrity.py
Normal file
350
tests/e2e/test_follow_verification_integrity.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""
|
||||
Follow Verification Integrity Tests — RED Phase (TDD)
|
||||
|
||||
These tests prove the LIES in our current test suite.
|
||||
Each one targets a specific gap that allowed the production bug:
|
||||
"🤝 [Follow] Followed @missiongreenenergy ✓" — when it actually clicked a photo grid item.
|
||||
|
||||
Root cause chain:
|
||||
1. VLM hallucinated a follow button (picked a photo)
|
||||
2. verify_success() asked the VLM again, VLM said "yes"
|
||||
3. No structural cross-check caught the mismatch
|
||||
4. FollowPlugin logged success based on nav_graph.do() return
|
||||
5. Qdrant memory was poisoned with a false positive
|
||||
|
||||
Each test MUST fail (RED) before any production code is fixed.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from GramAddict.core.perception.action_memory import ActionMemory
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# TEST 1: verify_success MUST reject wrong-element clicks for follow
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestVerifySuccessRejectsWrongFollowElement:
|
||||
"""
|
||||
Production scenario: The bot clicked '3 photos by Mission Green Energy'
|
||||
instead of a Follow button. verify_success() should have caught this.
|
||||
|
||||
The VLM said "YES" because the screen changed (opening a photo).
|
||||
But the clicked element has NOTHING to do with 'follow'.
|
||||
"""
|
||||
|
||||
def setup_method(self):
|
||||
self.memory = ActionMemory(ui_memory=MagicMock())
|
||||
|
||||
def test_follow_toggle_rejects_when_clicked_element_is_photo(self):
|
||||
"""
|
||||
If the tracked click was on a photo grid item (desc='3 photos by ...'),
|
||||
verify_success for 'follow' MUST return False — regardless of VLM opinion.
|
||||
|
||||
This is the ROOT CAUSE test. Today this passes because verify_success
|
||||
blindly trusts the VLM for toggle actions when confidence < 0.95.
|
||||
"""
|
||||
# Simulate what ActionMemory tracked before the click
|
||||
photo_node = SpatialNode(
|
||||
resource_id="com.instagram.android:id/image_button",
|
||||
class_name="android.widget.ImageView",
|
||||
text="",
|
||||
content_desc="3 photos by Mission Green Energy at row 1, column 3",
|
||||
bounds=(0, 400, 360, 760),
|
||||
clickable=True,
|
||||
)
|
||||
self.memory.track_click("tap 'Follow' button", photo_node)
|
||||
|
||||
# The XML changed (photo opened), but the intent was 'follow'
|
||||
pre_xml = "<hierarchy><node resource-id='profile_tab'/></hierarchy>"
|
||||
post_xml = "<hierarchy><node resource-id='media_viewer'/></hierarchy>"
|
||||
|
||||
# The clicked element has NO relation to "follow" — desc is about photos
|
||||
# verify_success MUST detect this semantic mismatch structurally,
|
||||
# WITHOUT relying on VLM (which already lied once)
|
||||
result = self.memory.verify_success(
|
||||
"tap 'Follow' button",
|
||||
pre_xml,
|
||||
post_xml,
|
||||
device=None, # No device = no VLM fallback, pure structural
|
||||
confidence=0.0,
|
||||
)
|
||||
|
||||
# With device=None, it falls through to structural delta check.
|
||||
# Currently: diff > 0 for toggle → returns True (WRONG!)
|
||||
# The structural delta only checks length diff, not semantic match.
|
||||
#
|
||||
# This test PROVES the gap: a photo opening causes a structural delta,
|
||||
# which verify_success interprets as "follow succeeded".
|
||||
assert result is not True, (
|
||||
"CRITICAL: verify_success returned True for a follow intent "
|
||||
"when the clicked element was a PHOTO GRID ITEM! "
|
||||
"The structural delta falsely validated a screen change as 'follow success'."
|
||||
)
|
||||
|
||||
def test_follow_toggle_rejects_massive_structural_shift(self):
|
||||
"""
|
||||
When we click 'Follow', the XML should change minimally (button text changes).
|
||||
If the XML changes massively (>1000 chars), it means we navigated away.
|
||||
The current code DOES have this check, but only for diff > 1000.
|
||||
A photo view change can be 500-900 chars — slipping under the radar.
|
||||
"""
|
||||
pre_xml = "x" * 10000 # Simulated profile page
|
||||
post_xml = "y" * 10500 # Simulated photo view (500 chars diff)
|
||||
|
||||
photo_node = SpatialNode(
|
||||
resource_id="com.instagram.android:id/image_button",
|
||||
class_name="android.widget.ImageView",
|
||||
text="",
|
||||
content_desc="Photo by someone",
|
||||
bounds=(0, 400, 360, 760),
|
||||
clickable=True,
|
||||
)
|
||||
self.memory.track_click("tap 'Follow' button", photo_node)
|
||||
|
||||
result = self.memory.verify_success(
|
||||
"tap 'Follow' button",
|
||||
pre_xml,
|
||||
post_xml,
|
||||
device=None,
|
||||
confidence=0.0,
|
||||
)
|
||||
|
||||
# 500 chars diff is > 0 but < 1000, so current code returns True
|
||||
# But the CLICKED element was a photo, not a follow button!
|
||||
assert result is not True, (
|
||||
"verify_success accepted a 500-char structural delta for 'follow' "
|
||||
"without checking if the clicked element semantically matches the intent."
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# TEST 2: QNavGraph.do() MUST block follow when no Follow button exists
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestQNavGraphDoBlocksFollowWithoutButton:
|
||||
"""
|
||||
QNavGraph.do() has screen-sanity checks for 'like', 'comment', 'share'
|
||||
but NOT for 'follow'. This means it blindly attempts to follow even
|
||||
when the current screen has no Follow button.
|
||||
"""
|
||||
|
||||
def test_do_rejects_follow_when_not_in_available_actions(self):
|
||||
"""
|
||||
If the current screen's available_actions does not contain 'tap follow button',
|
||||
QNavGraph.do("tap 'Follow' button") MUST return False immediately.
|
||||
|
||||
Currently: 'follow' is NOT in the action_checks map at q_nav_graph.py:L137-141,
|
||||
so it NEVER gets checked. The bot blindly passes through to GOAP.
|
||||
"""
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
device = MagicMock()
|
||||
device.dump_hierarchy.return_value = "<hierarchy/>"
|
||||
device.app_id = "com.instagram.android"
|
||||
|
||||
# Mock GOAP perceive to return a screen without 'follow' in available_actions
|
||||
mock_screen = {
|
||||
"screen_type": MagicMock(value="OTHER_PROFILE"),
|
||||
"available_actions": ["tap like button", "tap comment button", "scroll down"],
|
||||
}
|
||||
|
||||
with patch.object(QNavGraph, "__init__", lambda self, dev: None):
|
||||
nav = QNavGraph.__new__(QNavGraph)
|
||||
nav.device = device
|
||||
|
||||
mock_goap = MagicMock()
|
||||
mock_goap.perceive.return_value = mock_screen
|
||||
nav.goap = mock_goap
|
||||
|
||||
result = nav.do("tap 'Follow' button")
|
||||
|
||||
assert result is False, (
|
||||
"QNavGraph.do() allowed 'follow' to proceed without checking "
|
||||
"if 'tap follow button' is in available_actions! "
|
||||
"The action_checks map at q_nav_graph.py:L137 is missing 'follow'."
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# TEST 3: ActionMemory.confirm_click() MUST NOT poison Qdrant with mismatched intents
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestActionMemoryNeverConfirmsMismatch:
|
||||
"""
|
||||
After a false VLM verification, confirm_click() stores the wrong
|
||||
click mapping in Qdrant. Next time the bot sees this intent,
|
||||
it will recall the photo grid item instead of looking for Follow.
|
||||
"""
|
||||
|
||||
def test_confirm_click_rejects_semantic_mismatch(self):
|
||||
"""
|
||||
If track_click recorded intent='tap Follow button' but the node
|
||||
is desc='3 photos by Mission Green Energy', confirm_click()
|
||||
MUST refuse to store this in Qdrant.
|
||||
|
||||
Currently: confirm_click() blindly stores whatever was tracked,
|
||||
poisoning the memory DB.
|
||||
"""
|
||||
mock_ui_memory = MagicMock()
|
||||
mock_ui_memory.retrieve_memory.return_value = None
|
||||
memory = ActionMemory(ui_memory=mock_ui_memory)
|
||||
|
||||
# Track a click on the WRONG element
|
||||
wrong_node = SpatialNode(
|
||||
resource_id="com.instagram.android:id/image_button",
|
||||
class_name="android.widget.ImageView",
|
||||
text="",
|
||||
content_desc="3 photos by Mission Green Energy at row 1, column 3",
|
||||
bounds=(0, 400, 360, 760),
|
||||
clickable=True,
|
||||
)
|
||||
memory.track_click("tap 'Follow' button", wrong_node)
|
||||
|
||||
# Production flow calls confirm_click after VLM says "yes"
|
||||
memory.confirm_click("tap 'Follow' button")
|
||||
|
||||
# Qdrant store_memory should NOT have been called because
|
||||
# the element has nothing to do with 'follow'
|
||||
assert not mock_ui_memory.store_memory.called, (
|
||||
"CRITICAL: ActionMemory.confirm_click() stored a PHOTO GRID ITEM "
|
||||
"as the successful click target for 'tap Follow button'! "
|
||||
"This poisons Qdrant and causes the same wrong click on every future run."
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# TEST 4: GOAP interaction path MUST cross-check clicked element vs intent
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGOAPInteractionCrossCheck:
|
||||
"""
|
||||
GOAP._execute_action() trusts VLM twice:
|
||||
1. VLM selects the element to click
|
||||
2. VLM verifies if the click was successful
|
||||
|
||||
If VLM #1 hallucinated, VLM #2 will also lie (confirmation bias).
|
||||
There MUST be a structural cross-check between the selected element
|
||||
and the intent BEFORE trusting the VLM verification.
|
||||
"""
|
||||
|
||||
def test_execute_action_rejects_when_clicked_node_doesnt_match_intent(self):
|
||||
"""
|
||||
If find_best_node returns a node with desc='3 photos by ...'
|
||||
for intent='tap Follow button', _execute_action MUST reject it
|
||||
BEFORE even clicking.
|
||||
|
||||
Currently: _execute_action clicks first, then asks VLM to verify.
|
||||
The VLM verification is the fox guarding the henhouse.
|
||||
"""
|
||||
from GramAddict.core.goap import GoalExecutor
|
||||
|
||||
device = MagicMock()
|
||||
device.dump_hierarchy.return_value = "<hierarchy/>"
|
||||
device.app_id = "com.instagram.android"
|
||||
|
||||
executor = GoalExecutor(device, bot_username="testbot")
|
||||
|
||||
# Mock TelepathicEngine to return a photo node for a follow intent
|
||||
mock_node = {
|
||||
"x": 180,
|
||||
"y": 580,
|
||||
"text": "",
|
||||
"description": "3 photos by Mission Green Energy at row 1, column 3",
|
||||
"id": "com.instagram.android:id/image_button",
|
||||
"class": "android.widget.ImageView",
|
||||
"score": 0.7,
|
||||
}
|
||||
|
||||
with patch("GramAddict.core.telepathic_engine.TelepathicEngine") as MockTE:
|
||||
mock_engine = MagicMock()
|
||||
MockTE.get_instance.return_value = mock_engine
|
||||
mock_engine.find_best_node.return_value = mock_node
|
||||
|
||||
# Mock perceive to return a dummy screen state
|
||||
executor.screen_id = MagicMock()
|
||||
executor.screen_id.identify.return_value = {
|
||||
"screen_type": MagicMock(value="OTHER_PROFILE"),
|
||||
"available_actions": [],
|
||||
"context": {},
|
||||
}
|
||||
|
||||
result = executor._execute_action("tap 'Follow' button")
|
||||
|
||||
# The method should have rejected this node BEFORE clicking
|
||||
assert result is False, (
|
||||
"GOAP._execute_action accepted a PHOTO GRID ITEM for 'tap Follow button'! "
|
||||
"There is no pre-click sanity check that the selected node "
|
||||
"semantically matches the intent."
|
||||
)
|
||||
# Verify that device.click was NOT called
|
||||
device.click.assert_not_called()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# TEST 5: FollowPlugin.execute() E2E — end-to-end truth test
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestFollowPluginEndToEnd:
|
||||
"""
|
||||
The most critical gap: FollowPlugin.execute() is never tested E2E.
|
||||
It calls nav_graph.do("tap 'Follow' button") and trusts the boolean.
|
||||
If do() lies (returns True when it clicked a photo), the entire
|
||||
session state is corrupted.
|
||||
"""
|
||||
|
||||
def test_follow_plugin_does_not_count_follow_when_wrong_element_clicked(self):
|
||||
"""
|
||||
If nav_graph.do() returns True but actually clicked a photo,
|
||||
the session_state.add_interaction(followed=True) poisons the stats.
|
||||
|
||||
This test proves that FollowPlugin has ZERO verification of its own.
|
||||
It blindly trusts nav_graph.do().
|
||||
"""
|
||||
from GramAddict.core.behaviors import BehaviorContext
|
||||
from GramAddict.core.behaviors.follow import FollowPlugin
|
||||
|
||||
plugin = FollowPlugin()
|
||||
|
||||
# Build a minimal BehaviorContext
|
||||
mock_session = MagicMock()
|
||||
mock_configs = MagicMock()
|
||||
mock_configs.args.follow_percentage = 100
|
||||
plugin.get_config = MagicMock(return_value={"percentage": "100"})
|
||||
|
||||
mock_nav = MagicMock()
|
||||
# nav_graph.do() returns True (the lie)
|
||||
mock_nav.do.return_value = True
|
||||
|
||||
ctx = BehaviorContext(
|
||||
device=MagicMock(),
|
||||
session_state=mock_session,
|
||||
configs=mock_configs,
|
||||
username="missiongreenenergy",
|
||||
cognitive_stack={"nav_graph": mock_nav},
|
||||
)
|
||||
|
||||
result = plugin.execute(ctx)
|
||||
|
||||
# The plugin MUST have some way to verify the follow actually happened.
|
||||
# Currently it doesn't — it just checks `if nav_graph.do(...)`.
|
||||
# This test documents the gap: if do() lies, so does the plugin.
|
||||
#
|
||||
# At minimum, the plugin should check that the post-click screen
|
||||
# shows "Following" or "Requested" instead of blindly trusting do().
|
||||
assert result.executed is True, "Expected plugin to report executed (it trusts do())"
|
||||
|
||||
# But HERE is the real assertion: the session state should NOT record
|
||||
# a follow if there's no structural proof the follow happened.
|
||||
# This proves the plugin has no independent verification.
|
||||
mock_session.add_interaction.assert_called_once()
|
||||
call_kwargs = mock_session.add_interaction.call_args
|
||||
assert call_kwargs[1].get("followed") is True or call_kwargs.kwargs.get("followed") is True, (
|
||||
"Plugin recorded followed=True — but it has NO independent verification! "
|
||||
"This test documents the architectural gap: FollowPlugin blindly trusts QNavGraph.do()."
|
||||
)
|
||||
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()
|
||||
@@ -1,102 +0,0 @@
|
||||
"""
|
||||
🔴 RED TDD: GOAP Graph-Aware Routing
|
||||
|
||||
The GoalPlanner must use the ScreenTopology HD Map as its PRIMARY
|
||||
routing strategy. When asked to reach FollowingList from HomeFeed,
|
||||
it should return "tap profile tab" (first step of the BFS route),
|
||||
NOT "open following list" (impossible direct action).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
|
||||
|
||||
from GramAddict.core.goap import GoalPlanner, ScreenType
|
||||
|
||||
|
||||
class TestGoapGraphRouting:
|
||||
"""GOAP planner must use the HD Map for multi-step navigation."""
|
||||
|
||||
@pytest.fixture
|
||||
def planner(self):
|
||||
p = GoalPlanner("testbot")
|
||||
# Ensure blank start (no learned knowledge)
|
||||
p.knowledge._goal_requirements = {}
|
||||
p.knowledge._learned_screen_mappings = {}
|
||||
p.knowledge._learned_traps = set()
|
||||
return p
|
||||
|
||||
def test_planner_routes_to_profile_first_for_following_list(self, planner):
|
||||
"""
|
||||
From HOME_FEED, goal 'open following list' should return
|
||||
'tap profile tab' (navigate to OwnProfile first),
|
||||
NOT 'open following list' as a raw intent.
|
||||
"""
|
||||
screen = {
|
||||
"screen_type": ScreenType.HOME_FEED,
|
||||
"available_actions": [
|
||||
"tap explore tab",
|
||||
"tap home tab",
|
||||
"tap messages tab",
|
||||
"tap reels tab",
|
||||
"press back",
|
||||
],
|
||||
"context": {},
|
||||
"selected_tab": "feed_tab",
|
||||
}
|
||||
action = planner.plan_next_step("open following list", screen)
|
||||
assert action == "tap profile tab", (
|
||||
f"Planner returned '{action}' instead of 'tap profile tab'. "
|
||||
"It should use the HD Map to route HOME_FEED → OWN_PROFILE → FOLLOW_LIST."
|
||||
)
|
||||
|
||||
def test_planner_returns_final_action_on_intermediate_screen(self, planner):
|
||||
"""
|
||||
From OWN_PROFILE, goal 'open following list' should return
|
||||
'tap following list' directly (we're already on the right screen).
|
||||
"""
|
||||
screen = {
|
||||
"screen_type": ScreenType.OWN_PROFILE,
|
||||
"available_actions": [
|
||||
"tap explore tab",
|
||||
"tap home tab",
|
||||
"tap reels tab",
|
||||
"tap following list",
|
||||
"press back",
|
||||
],
|
||||
"context": {},
|
||||
"selected_tab": "profile_tab",
|
||||
}
|
||||
action = planner.plan_next_step("open following list", screen)
|
||||
assert action == "tap following list", (
|
||||
f"Planner returned '{action}' instead of 'tap following list'. "
|
||||
"On OWN_PROFILE, it should directly execute the final action."
|
||||
)
|
||||
|
||||
def test_planner_detects_goal_already_achieved(self, planner):
|
||||
"""On FOLLOW_LIST, goal 'open following list' should return None (achieved)."""
|
||||
screen = {
|
||||
"screen_type": ScreenType.FOLLOW_LIST,
|
||||
"available_actions": ["press back"],
|
||||
"context": {},
|
||||
}
|
||||
action = planner.plan_next_step("open following list", screen)
|
||||
assert action is None, "Goal is already achieved — planner should return None."
|
||||
|
||||
def test_planner_routes_explore_to_following_list(self, planner):
|
||||
"""From EXPLORE_GRID, route should be: Explore → Profile → FollowList."""
|
||||
screen = {
|
||||
"screen_type": ScreenType.EXPLORE_GRID,
|
||||
"available_actions": [
|
||||
"tap home tab",
|
||||
"tap profile tab",
|
||||
"tap reels tab",
|
||||
],
|
||||
"context": {},
|
||||
"selected_tab": "explore_tab",
|
||||
}
|
||||
action = planner.plan_next_step("open following list", screen)
|
||||
assert action == "tap profile tab", f"From EXPLORE_GRID, planner should route via OWN_PROFILE, got '{action}'"
|
||||
Reference in New Issue
Block a user