Compare commits
3 Commits
144d6401b5
...
fix-memory
| Author | SHA1 | Date | |
|---|---|---|---|
| ddbe8f8e99 | |||
| 5b53a7e4c0 | |||
| 42eabb7bda |
@@ -71,6 +71,7 @@ from GramAddict.core.resonance_engine import ResonanceEngine
|
||||
from GramAddict.core.sensors.honeypot_radome import HoneypotRadome
|
||||
from GramAddict.core.session_state import SessionState, SessionStateEncoder
|
||||
from GramAddict.core.swarm_protocol import SwarmProtocol
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
from GramAddict.core.unfollow_engine import _run_zero_latency_unfollow_loop
|
||||
from GramAddict.core.utils import (
|
||||
check_if_updated,
|
||||
@@ -86,7 +87,53 @@ from GramAddict.core.zero_latency_engine import ZeroLatencyEngine
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_production_integrity():
|
||||
"""
|
||||
Ensures that the production environment is not poisoned by test mocks.
|
||||
Fails loudly if critical components are detected as MagicMocks.
|
||||
"""
|
||||
import sys
|
||||
|
||||
# If we are in a pytest session, we expect and allow mocks
|
||||
if "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ:
|
||||
return
|
||||
|
||||
try:
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Check QdrantClient
|
||||
try:
|
||||
from qdrant_client import QdrantClient
|
||||
|
||||
if isinstance(QdrantClient, MagicMock):
|
||||
print(
|
||||
f"{Fore.RED}💀 CRITICAL: Production environment poisoned by MagicMock (QdrantClient).{Style.RESET_ALL}"
|
||||
)
|
||||
print(
|
||||
f"{Fore.YELLOW}Verify that tests/e2e/conftest.py is not being loaded. Check sys.modules leakage.{Style.RESET_ALL}"
|
||||
)
|
||||
sys.exit(1)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Check DeviceFacade (another common victim)
|
||||
from GramAddict.core.device_facade import DeviceFacade
|
||||
|
||||
if isinstance(DeviceFacade, MagicMock):
|
||||
print(
|
||||
f"{Fore.RED}💀 CRITICAL: Production environment poisoned by MagicMock (DeviceFacade).{Style.RESET_ALL}"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
except ImportError:
|
||||
# unittest.mock might not even be available in some stripped envs
|
||||
pass
|
||||
|
||||
|
||||
def start_bot(**kwargs):
|
||||
# 0. Integrity Check
|
||||
check_production_integrity()
|
||||
|
||||
configs = Config(first_run=True, **kwargs)
|
||||
configure_logger(configs.debug, configs.username)
|
||||
check_if_updated()
|
||||
@@ -137,6 +184,9 @@ def start_bot(**kwargs):
|
||||
active_inference = ActiveInferenceEngine(username)
|
||||
|
||||
# Core Autonomous Engines
|
||||
from GramAddict.core.goap import GoalExecutor
|
||||
|
||||
GoalExecutor.get_instance(device, username)
|
||||
zero_engine = ZeroLatencyEngine(device)
|
||||
nav_graph = QNavGraph(device)
|
||||
growth_brain = GrowthBrain(username, persona_interests=persona_interests)
|
||||
@@ -153,19 +203,23 @@ def start_bot(**kwargs):
|
||||
if getattr(configs.args, "blank_start", False):
|
||||
logger.warning(f"⚠️ [Blank Start] Wiping ALL persistent AI memories for '{username}'...")
|
||||
# 1. Wipe all global AI caches (Heuristics, Screen Types, Navigation Graph, etc.)
|
||||
try:
|
||||
from GramAddict.core.qdrant_memory import wipe_all_ai_caches
|
||||
from GramAddict.core.qdrant_memory import wipe_all_ai_caches
|
||||
|
||||
try:
|
||||
wipe_all_ai_caches()
|
||||
except (ConnectionError, OSError) as e:
|
||||
logger.warning(f"⚠️ Failed to wipe global AI caches (Qdrant unreachable): {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"⚠️ Failed to wipe global AI caches: {e}")
|
||||
|
||||
# 2. Wipe user-specific PathMemory
|
||||
try:
|
||||
from GramAddict.core.goap import PathMemory
|
||||
from GramAddict.core.goap import PathMemory
|
||||
|
||||
try:
|
||||
path_mem = PathMemory(username)
|
||||
path_mem.wipe()
|
||||
except (ConnectionError, OSError) as e:
|
||||
logger.warning(f"⚠️ Failed to wipe user-specific PathMemory (Qdrant unreachable): {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Failed to wipe user-specific PathMemory: {e}")
|
||||
|
||||
|
||||
@@ -178,6 +178,8 @@ class ScreenIdentity:
|
||||
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
|
||||
|
||||
@@ -205,11 +205,13 @@ class QdrantBase:
|
||||
|
||||
point_id = self.generate_uuid(seed_string)
|
||||
try:
|
||||
self.client.delete(collection_name=self.collection_name, points_selector=[point_id])
|
||||
logger.info(
|
||||
f"🗑️ [Qdrant] Purged poisoned memory vector from {self.collection_name} (UUID: {point_id[:8]}...)",
|
||||
extra={"color": "\x1b[31m"},
|
||||
)
|
||||
res = self.client.retrieve(collection_name=self.collection_name, ids=[point_id])
|
||||
if res:
|
||||
self.client.delete(collection_name=self.collection_name, points_selector=[point_id])
|
||||
logger.info(
|
||||
f"🗑️ [Qdrant] Purged poisoned memory vector from {self.collection_name} (UUID: {point_id[:8]}...)",
|
||||
extra={"color": "\x1b[31m"},
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
self._handle_error(e, f"Failed to delete point {point_id}")
|
||||
@@ -1254,3 +1256,32 @@ class ParasocialCRMDB(QdrantBase):
|
||||
context_parts.append("Previous Comments: " + " | ".join(comments))
|
||||
|
||||
return "\n".join(context_parts)
|
||||
|
||||
|
||||
def wipe_all_ai_caches():
|
||||
"""
|
||||
Wipes ALL global (non-user-specific) Qdrant AI caches.
|
||||
Used by blank_start to ensure a completely fresh learning state.
|
||||
Does NOT wipe user-specific collections like PathMemory or ParasocialCRM.
|
||||
"""
|
||||
global_dbs = [
|
||||
HeuristicMemoryDB,
|
||||
UIMemoryDB,
|
||||
CommentMemoryDB,
|
||||
ContentMemoryDB,
|
||||
ScreenMemoryDB,
|
||||
NavigationMemoryDB,
|
||||
PersonaMemoryDB,
|
||||
DMMemoryDB,
|
||||
]
|
||||
wiped_count = 0
|
||||
for db_cls in global_dbs:
|
||||
try:
|
||||
db = db_cls()
|
||||
if db.is_connected:
|
||||
db.wipe_collection()
|
||||
wiped_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Failed to wipe {db_cls.__name__}: {e}")
|
||||
|
||||
logger.info(f"🗑️ [Blank Start] Wiped {wiped_count}/{len(global_dbs)} global AI caches.")
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import re
|
||||
|
||||
with open("tests/e2e/test_e2e_sae.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# Remove test_perceive_randomized_chaos_modal
|
||||
content = re.sub(
|
||||
r" def test_perceive_randomized_chaos_modal\(self, mock_telepathic_classifier\):.*?(?= def test_perceive_action_blocked)",
|
||||
"",
|
||||
content,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
# Remove test_recovers_from_randomized_chaos_modal
|
||||
content = re.sub(
|
||||
r" def test_recovers_from_randomized_chaos_modal\(self, mock_telepathic_classifier, mock_fallback_llm\):.*?(?= def test_recovers_from_unknown_modal_german)",
|
||||
"",
|
||||
content,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
with open("tests/e2e/test_e2e_sae.py", "w") as f:
|
||||
f.write(content)
|
||||
@@ -1 +0,0 @@
|
||||
# I will write a simple test to trace the issue
|
||||
5409
test_e2e_output.txt
5409
test_e2e_output.txt
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
12955
test_e2e_output_v4.txt
12955
test_e2e_output_v4.txt
File diff suppressed because it is too large
Load Diff
12084
test_e2e_output_v5.txt
12084
test_e2e_output_v5.txt
File diff suppressed because it is too large
Load Diff
15
test_mock.py
15
test_mock.py
@@ -1,15 +0,0 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class A:
|
||||
def method(self, arg):
|
||||
pass
|
||||
|
||||
|
||||
def dummy(self, arg):
|
||||
print("DUMMY CALLED", arg)
|
||||
|
||||
|
||||
with patch("__main__.A.method") as mock_method:
|
||||
mock_method.side_effect = dummy
|
||||
A().method("hello")
|
||||
@@ -7,16 +7,27 @@ import pytest
|
||||
|
||||
from GramAddict.core import utils
|
||||
|
||||
# Force Qdrant mocking globally across ALL E2E tests so we never
|
||||
# block on connection refused trying to hit localhost:6344
|
||||
mock_qdrant = MagicMock()
|
||||
|
||||
# Setup correct return types for dimension check warnings in qdrant_memory
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.config.params.vectors.size = 768
|
||||
mock_qdrant.get_collection.return_value = mock_collection
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def global_qdrant_mock():
|
||||
"""
|
||||
Force Qdrant mocking globally across ALL E2E tests so we never
|
||||
block on connection refused trying to hit localhost:6344.
|
||||
Moved to a fixture to avoid poisoning the global sys.modules on import.
|
||||
"""
|
||||
mock_qdrant = MagicMock()
|
||||
|
||||
sys.modules["qdrant_client"].QdrantClient = MagicMock(return_value=mock_qdrant)
|
||||
# Setup correct return types for dimension check warnings in qdrant_memory
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.config.params.vectors.size = 768
|
||||
mock_qdrant.get_collection.return_value = mock_collection
|
||||
|
||||
# We use a wrapper to ensure the mock is only active when we want it
|
||||
sys.modules["qdrant_client"].QdrantClient = MagicMock(return_value=mock_qdrant)
|
||||
|
||||
yield mock_qdrant
|
||||
|
||||
# Optional: cleanup if needed, but for E2E it's usually fine to keep it for the session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
67
tests/tdd/test_wipe_all_ai_caches.py
Normal file
67
tests/tdd/test_wipe_all_ai_caches.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
TDD RED: Verify that wipe_all_ai_caches exists, is importable, and
|
||||
correctly wipes all global (non-user-specific) Qdrant collections.
|
||||
|
||||
This test catches the production error:
|
||||
ERROR | Failed to wipe global AI caches: cannot import name 'wipe_all_ai_caches'
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_qdrant_client():
|
||||
"""Ensure qdrant_client is mocked at the module level for import safety."""
|
||||
# This is already handled by session-scoped conftest, but we guard explicitly
|
||||
pass
|
||||
|
||||
|
||||
class TestWipeAllAiCachesExists:
|
||||
"""The function must be importable from qdrant_memory without errors."""
|
||||
|
||||
def test_wipe_all_ai_caches_is_importable(self):
|
||||
"""RED: This must not raise ImportError."""
|
||||
from GramAddict.core.qdrant_memory import wipe_all_ai_caches
|
||||
|
||||
assert callable(wipe_all_ai_caches)
|
||||
|
||||
def test_wipe_all_ai_caches_calls_wipe_on_all_global_collections(self):
|
||||
"""RED: The function must instantiate and wipe all global memory DBs."""
|
||||
from GramAddict.core.qdrant_memory import wipe_all_ai_caches
|
||||
|
||||
# Patch QdrantBase.__init__ to prevent real Qdrant connections
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantBase.__init__", return_value=None):
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantBase.wipe_collection") as mock_wipe:
|
||||
# Make is_connected return True so wipe_collection doesn't short-circuit
|
||||
with patch(
|
||||
"GramAddict.core.qdrant_memory.QdrantBase.is_connected",
|
||||
new_callable=lambda: property(lambda self: True),
|
||||
):
|
||||
wipe_all_ai_caches()
|
||||
|
||||
# Must wipe at least the known global collections
|
||||
assert mock_wipe.call_count >= 6, (
|
||||
f"Expected at least 6 global collection wipes, got {mock_wipe.call_count}. "
|
||||
f"Global collections: HeuristicMemoryDB, UIMemoryDB, CommentMemoryDB, "
|
||||
f"ContentMemoryDB, ScreenMemoryDB, NavigationMemoryDB"
|
||||
)
|
||||
|
||||
|
||||
class TestBlankStartCodePathIntegrity:
|
||||
"""
|
||||
The blank_start code path in bot_flow must NOT silently swallow ImportErrors.
|
||||
If a function doesn't exist, it's a code defect, not a runtime condition.
|
||||
"""
|
||||
|
||||
def test_blank_start_wipe_does_not_catch_import_error(self):
|
||||
"""
|
||||
The production code catches `Exception` broadly on Line 197 of bot_flow.py.
|
||||
ImportError IS an Exception subclass, so it gets swallowed silently.
|
||||
This test verifies that wipe_all_ai_caches actually exists (the root cause).
|
||||
"""
|
||||
# If this import works, the blank_start try/except will never hit ImportError
|
||||
from GramAddict.core.qdrant_memory import wipe_all_ai_caches
|
||||
|
||||
assert wipe_all_ai_caches is not None
|
||||
28
tests/unit/test_bot_flow_singleton.py
Normal file
28
tests/unit/test_bot_flow_singleton.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
def test_bot_flow_goal_executor_init_order():
|
||||
# We want to prove that without explicit get_instance(device, username),
|
||||
# QNavGraph(device) initializes GoalExecutor with an empty username.
|
||||
|
||||
# We have to reset GoalExecutor singleton first
|
||||
from GramAddict.core.goap import GoalExecutor
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
if hasattr(GoalExecutor, "_instance"):
|
||||
GoalExecutor._instance = None
|
||||
|
||||
device = MagicMock()
|
||||
|
||||
# Action: Instantiate QNavGraph
|
||||
# Without the fix, this will initialize GoalExecutor with bot_username=""
|
||||
# and PathMemory will get bound to "goap_paths_v1"
|
||||
|
||||
with patch("GramAddict.core.qdrant_memory.QdrantClient") as mock_qdrant:
|
||||
# FIX: Pre-initialize with username (as done in bot_flow.py)
|
||||
executor_pre = GoalExecutor.get_instance(device, "marisaundmarc")
|
||||
nav_graph = QNavGraph(device)
|
||||
executor = GoalExecutor.get_instance(device, "marisaundmarc")
|
||||
|
||||
# Now it should be bound correctly
|
||||
assert executor.path_memory._db.collection_name == "goap_paths_v1_marisaundmarc", "PathMemory collection name does not contain the username suffix! Initialization leak occurred."
|
||||
42
tests/unit/test_delete_point_logging.py
Normal file
42
tests/unit/test_delete_point_logging.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
from GramAddict.core.qdrant_memory import UIMemoryDB
|
||||
|
||||
def test_delete_point_logs_only_if_exists(caplog):
|
||||
# Setup
|
||||
db = UIMemoryDB()
|
||||
type(db).is_connected = PropertyMock(return_value=True)
|
||||
db.client = MagicMock()
|
||||
db.collection_name = "test_collection"
|
||||
|
||||
# RED: Force the mock to return an empty list (point does NOT exist)
|
||||
db.client.retrieve.return_value = []
|
||||
|
||||
# Action
|
||||
result = db.delete_point("fake_seed")
|
||||
|
||||
# Assertions
|
||||
assert result is True # Should still return True as it didn't crash
|
||||
# It should NOT call delete if it wasn't found
|
||||
db.client.delete.assert_not_called()
|
||||
# It should NOT log the "Purged poisoned memory" line
|
||||
assert "Purged poisoned memory vector" not in caplog.text
|
||||
|
||||
def test_delete_point_logs_if_found(caplog):
|
||||
# Setup
|
||||
db = UIMemoryDB()
|
||||
type(db).is_connected = PropertyMock(return_value=True)
|
||||
db.client = MagicMock()
|
||||
db.collection_name = "test_collection"
|
||||
|
||||
# RED: Force the mock to return a result (point DOES exist)
|
||||
db.client.retrieve.return_value = [{"id": "some_uuid"}]
|
||||
|
||||
# Action
|
||||
with patch("GramAddict.core.qdrant_memory.logger") as mock_logger:
|
||||
result = db.delete_point("fake_seed")
|
||||
|
||||
# Assertions
|
||||
assert result is True
|
||||
db.client.delete.assert_called_once()
|
||||
mock_logger.info.assert_called_once()
|
||||
assert "Purged poisoned memory vector" in mock_logger.info.call_args[0][0]
|
||||
39
tests/unit/test_screen_identity_profile.py
Normal file
39
tests/unit/test_screen_identity_profile.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import pytest
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
|
||||
|
||||
def test_screen_identity_own_profile_vs_other_profile():
|
||||
identity = ScreenIdentity("marisaundmarc")
|
||||
|
||||
# When we are on our OWN profile, 'profile_tab' is selected,
|
||||
# but 'profile_header_container' is ALSO present.
|
||||
# The bug is that 'profile_header_container' shadows 'profile_tab' selected=True.
|
||||
|
||||
# Let's create an XML dump that mimics this scenario:
|
||||
own_profile_xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header_container" text="" content-desc="" clickable="false" bounds="[0,0][100,100]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_tab" selected="true" text="" content-desc="Profile" clickable="true" bounds="[0,0][100,100]" />
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
result = identity.identify(own_profile_xml)
|
||||
|
||||
assert result["screen_type"] == ScreenType.OWN_PROFILE, "Failed! own profile was classified as OTHER_PROFILE because profile_header_container shadowed it."
|
||||
|
||||
def test_screen_identity_other_profile_vs_own_profile():
|
||||
identity = ScreenIdentity("marisaundmarc")
|
||||
|
||||
# When we are on someone ELSE's profile, 'profile_tab' is NOT selected
|
||||
# (or maybe 'feed_tab' or 'search_tab' is selected, or none).
|
||||
# And 'profile_header_container' is present.
|
||||
|
||||
other_profile_xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header_container" text="" content-desc="" clickable="false" bounds="[0,0][100,100]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/feed_tab" selected="true" text="" content-desc="Home" clickable="true" bounds="[0,0][100,100]" />
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
result = identity.identify(other_profile_xml)
|
||||
|
||||
assert result["screen_type"] == ScreenType.OTHER_PROFILE, "Failed! other profile was not classified as OTHER_PROFILE."
|
||||
1794
tmp_bot_flow.py
1794
tmp_bot_flow.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user