Compare commits
10 Commits
4de087ae45
...
e9201e0e30
| Author | SHA1 | Date | |
|---|---|---|---|
| e9201e0e30 | |||
| ae046be3b1 | |||
| a2a4a75603 | |||
| 714c914432 | |||
| 294403d590 | |||
| 117e7a22e7 | |||
| 0fbd1b1678 | |||
| b5cca06ce2 | |||
| 3c4dd84a61 | |||
| 9ad49500f9 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -37,3 +37,4 @@ traceback.log
|
||||
htmlcov/
|
||||
.coverage
|
||||
coverage.xml
|
||||
.hypothesis/
|
||||
|
||||
Binary file not shown.
@@ -51,7 +51,7 @@ class FollowPlugin(BehaviorPlugin):
|
||||
|
||||
if nav_graph.do("tap follow button"):
|
||||
logger.info(f"🤝 [Follow] Followed @{ctx.username} ✓")
|
||||
ctx.session_state.add_interaction(source=ctx.username, succeed=True, followed=True, liked=False)
|
||||
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)
|
||||
|
||||
@@ -46,7 +46,7 @@ class ObstacleGuardPlugin(BehaviorPlugin):
|
||||
if situation == SituationType.OBSTACLE_MODAL:
|
||||
if misses >= 2:
|
||||
logger.error("🛑 [ObstacleGuard] Failed to recover from OBSTACLE_MODAL after multiple attempts.")
|
||||
sae.unlearn_current_state()
|
||||
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"})
|
||||
|
||||
@@ -70,7 +70,10 @@ class ObstacleGuardPlugin(BehaviorPlugin):
|
||||
return BehaviorResult(executed=True, should_skip=True) # Restart loop for same post or next
|
||||
|
||||
else: # SituationType.NORMAL
|
||||
if "row_feed_button_like" not in xml:
|
||||
nav_graph = ctx.cognitive_stack.get("nav_graph")
|
||||
current_state = nav_graph.current_state if nav_graph else "Unknown"
|
||||
|
||||
if "row_feed_button_like" not in xml and current_state != "ProfileView":
|
||||
logger.info("🧩 [ObstacleGuard] Missing feed markers. Scrolling...")
|
||||
ctx.shared_state["consecutive_marker_misses"] = misses + 1
|
||||
if ctx.shared_state["consecutive_marker_misses"] >= 3:
|
||||
|
||||
@@ -49,7 +49,8 @@ class ResonanceEvaluatorPlugin(BehaviorPlugin):
|
||||
tele = ctx.cognitive_stack.get("telepathic")
|
||||
if tele:
|
||||
logger.info("✨ [Resonance] Performing visual vibe check...")
|
||||
vibe = tele.evaluate_post_vibe()
|
||||
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)
|
||||
|
||||
@@ -51,7 +51,6 @@ from GramAddict.core.physics.timing import (
|
||||
wait_for_story_loaded as _wait_for_story_loaded_impl,
|
||||
)
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
from GramAddict.core.qdrant_memory import ParasocialCRMDB
|
||||
from GramAddict.core.resonance_engine import ResonanceEngine
|
||||
from GramAddict.core.sensors.honeypot_radome import HoneypotRadome
|
||||
from GramAddict.core.session_state import SessionState, SessionStateEncoder
|
||||
@@ -178,8 +177,11 @@ def start_bot(**kwargs):
|
||||
)
|
||||
persona_interests = [p.strip() for p in persona_raw.split(",") if p.strip()] if persona_raw else []
|
||||
|
||||
from GramAddict.core.qdrant_memory import DMMemoryDB, ParasocialCRMDB
|
||||
|
||||
dopamine = DopamineEngine()
|
||||
crm_db = ParasocialCRMDB()
|
||||
dm_memory_db = DMMemoryDB()
|
||||
resonance_oracle = ResonanceEngine(username, persona_interests=persona_interests, crm=crm_db)
|
||||
active_inference = ActiveInferenceEngine(username)
|
||||
|
||||
@@ -235,6 +237,7 @@ def start_bot(**kwargs):
|
||||
"telepathic": telepathic,
|
||||
"darwin": darwin,
|
||||
"crm": crm_db,
|
||||
"dm_memory": dm_memory_db,
|
||||
}
|
||||
|
||||
from GramAddict.core.behaviors import PluginRegistry
|
||||
|
||||
@@ -299,19 +299,51 @@ class DeviceFacade:
|
||||
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}")
|
||||
|
||||
|
||||
@@ -18,19 +18,11 @@ 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)
|
||||
@@ -48,16 +40,28 @@ def dump_ui_state(device, reason: str, extra_context: dict = None):
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(xml)
|
||||
|
||||
# Capture and write screenshot
|
||||
try:
|
||||
import base64
|
||||
screenshot_b64 = device.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()
|
||||
@@ -77,7 +81,7 @@ def dump_ui_state(device, reason: str, extra_context: dict = None):
|
||||
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)
|
||||
@@ -89,7 +93,6 @@ def dump_ui_state(device, reason: str, extra_context: dict = None):
|
||||
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."""
|
||||
try:
|
||||
@@ -100,8 +103,15 @@ def _rotate_dumps(category_prefix: str):
|
||||
for f in files_to_remove:
|
||||
xml_path = os.path.join(DUMP_DIR, f)
|
||||
meta_path = xml_path.replace(".xml", ".meta.json")
|
||||
log_path = xml_path.replace(".xml", ".log")
|
||||
img_path = xml_path.replace(".xml", ".jpg")
|
||||
|
||||
os.remove(xml_path)
|
||||
if os.path.exists(meta_path):
|
||||
os.remove(meta_path)
|
||||
if os.path.exists(log_path):
|
||||
os.remove(log_path)
|
||||
if os.path.exists(img_path):
|
||||
os.remove(img_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -20,7 +20,6 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
|
||||
|
||||
telepathic = cognitive_stack.get("telepathic")
|
||||
dopamine = cognitive_stack.get("dopamine")
|
||||
crm = cognitive_stack.get("crm")
|
||||
|
||||
from GramAddict.core.bot_flow import _humanized_click, sleep
|
||||
from GramAddict.core.llm_provider import query_llm
|
||||
@@ -44,6 +43,31 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
|
||||
try:
|
||||
xml_dump = device.dump_hierarchy()
|
||||
|
||||
# --- Zero Trust Structural Guard ---
|
||||
# If the navigation engine failed or the UI shifted, we MUST NOT hallucinate
|
||||
# interactions on the wrong screen (e.g., Privacy Settings).
|
||||
is_thread = "direct_thread_header" in xml_dump or "row_thread_composer_edittext" in xml_dump
|
||||
is_inbox = (
|
||||
"action_bar_title" in xml_dump
|
||||
or "thread_list" in xml_dump
|
||||
or "direct_inbox_header" in xml_dump
|
||||
or "unread" in xml_dump.lower()
|
||||
)
|
||||
|
||||
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 and not is_thread:
|
||||
# We have drifted somewhere entirely alien (like Privacy Settings)
|
||||
logger.error("🛑 [Structural Guard] Alien context detected. 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
|
||||
@@ -115,12 +139,19 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
|
||||
)
|
||||
|
||||
session_state.totalMessages += 1
|
||||
if crm:
|
||||
crm.log_sent_dm("unknown_target", response_text, "", [])
|
||||
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()
|
||||
if "direct_thread_header" in check_xml or "row_thread_composer_edittext" in check_xml:
|
||||
device.press("back")
|
||||
sleep(1.0)
|
||||
|
||||
dopamine.boredom += random.uniform(5.0, 15.0)
|
||||
failed_attempts = 0
|
||||
@@ -136,6 +167,13 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
|
||||
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()
|
||||
if "direct_thread_header" in check_xml or "row_thread_composer_edittext" in check_xml:
|
||||
device.press("back")
|
||||
sleep(1.0)
|
||||
|
||||
failed_attempts += 1
|
||||
if failed_attempts > 2:
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
@@ -381,7 +381,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.")
|
||||
|
||||
@@ -77,9 +77,9 @@ class ActionMemory:
|
||||
|
||||
self._last_click_context = None
|
||||
|
||||
def verify_success(self, intent: str, pre_click_xml: str, post_click_xml: str) -> Optional[bool]:
|
||||
def verify_success(self, intent: str, pre_click_xml: str, post_click_xml: str, device=None, confidence: float = 0.0) -> Optional[bool]:
|
||||
"""
|
||||
Structural verification: Did the UI actually change after the click?
|
||||
Structural and Visual verification: Did the UI actually change after the click?
|
||||
"""
|
||||
# Specific check for explore grid
|
||||
if "first image in explore grid" in intent or "grid item" in intent:
|
||||
@@ -88,6 +88,49 @@ class ActionMemory:
|
||||
if "explore_action_bar" in post_click_xml and "row_feed_button_like" not in post_click_xml:
|
||||
return None # Still on grid, inconclusive
|
||||
|
||||
# State toggles (like, save, follow) rarely change XML length predictably
|
||||
state_toggles = ["like", "save", "follow", "heart"]
|
||||
if any(t in intent.lower() for t in state_toggles):
|
||||
# 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()
|
||||
|
||||
# Ask VLM to be the absolute source of truth
|
||||
prompt = (
|
||||
f"The user just attempted to perform the action: '{intent}'. "
|
||||
f"Look at the current screen carefully. Was the action successful? "
|
||||
f"If the intent was 'follow', does the button now indicate 'Following' or 'Requested'? "
|
||||
f"If it was 'like', is the heart icon clearly active/red? "
|
||||
f"If the screen shifted completely to a profile when you just wanted to like/follow from a feed, it FAILED. "
|
||||
f"Answer ONLY with the word YES or NO."
|
||||
)
|
||||
|
||||
try:
|
||||
screenshot = device.screenshot_b64()
|
||||
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
|
||||
|
||||
# Fallback to structural delta if no device or VLM fails
|
||||
diff = abs(len(pre_click_xml) - len(post_click_xml))
|
||||
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 '{intent}'. Verification PASS.")
|
||||
return True
|
||||
|
||||
|
||||
if abs(len(pre_click_xml) - len(post_click_xml)) > 50:
|
||||
logger.debug(f"🧠 [ActionMemory] Structural change detected for '{intent}'. Verification PASS.")
|
||||
return True
|
||||
|
||||
@@ -151,16 +151,12 @@ def humanized_scroll(device, is_skip=False, resonance_score=None):
|
||||
|
||||
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).
|
||||
|
||||
@@ -418,13 +418,15 @@ class SituationalAwarenessEngine:
|
||||
"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:
|
||||
|
||||
@@ -5,7 +5,7 @@ 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.
|
||||
@@ -48,6 +48,10 @@ def ghost_type(device, text: str):
|
||||
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))
|
||||
|
||||
@@ -180,11 +180,11 @@ class TelepathicEngine:
|
||||
def decay_click(self, intent: str = None):
|
||||
self._memory.reject_click(intent) # Alias to reject
|
||||
|
||||
def verify_success(self, intent: str, post_click_xml: str) -> bool:
|
||||
def verify_success(self, intent: str, post_click_xml: str, device=None, confidence: float = 0.0) -> bool:
|
||||
pre_click_xml = ""
|
||||
if self._memory._last_click_context:
|
||||
pre_click_xml = self._memory._last_click_context.get("xml_context", "")
|
||||
return self._memory.verify_success(intent, pre_click_xml, post_click_xml)
|
||||
return self._memory.verify_success(intent, pre_click_xml, post_click_xml, device=device, confidence=confidence)
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Semantic Evaluator Delegation
|
||||
|
||||
@@ -100,7 +100,7 @@ def get_installed_ollama_models():
|
||||
return []
|
||||
|
||||
|
||||
def benchmark_model(model_name: str, url: str, force: bool = False):
|
||||
def benchmark_model(model_name: str, url: str, force: bool = False, iterations: int = 3):
|
||||
db = load_json(BENCHMARKS_FILE) or {"models": {}}
|
||||
scenarios_data = load_json(SCENARIOS_FILE)
|
||||
if not scenarios_data:
|
||||
@@ -138,49 +138,69 @@ def benchmark_model(model_name: str, url: str, force: bool = False):
|
||||
"Return: {\"index\": number, \"reason\": \"...\"}"
|
||||
)
|
||||
|
||||
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
|
||||
continue
|
||||
scenario_latencies = []
|
||||
scenario_scores = []
|
||||
successes = 0
|
||||
|
||||
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:
|
||||
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)
|
||||
scenario_latencies.append(latency)
|
||||
except Exception as e:
|
||||
print(f" ❌ API Request failed for scenario {scenario['id']}: {e}")
|
||||
passed_all = False
|
||||
print(" ❌ JSON missing fields.")
|
||||
except Exception:
|
||||
passed_all = False
|
||||
print(" ❌ JSON Parsing failed.")
|
||||
continue
|
||||
|
||||
results_detail[scenario["id"]] = raw_points
|
||||
total_raw += raw_points
|
||||
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
|
||||
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.")
|
||||
|
||||
scenario_scores.append(raw_points)
|
||||
|
||||
avg_scenario_score = int(sum(scenario_scores) / len(scenario_scores)) if scenario_scores else 0
|
||||
avg_scenario_latency = int(sum(scenario_latencies) / len(scenario_latencies)) if scenario_latencies else 0
|
||||
|
||||
pass_rate = (successes / iterations) * 100
|
||||
if pass_rate < 100.0:
|
||||
passed_all = False
|
||||
|
||||
print(
|
||||
f" Result: {pass_rate:.0f}% Pass Rate | Avg Score: {avg_scenario_score}/100 | Avg Latency: {avg_scenario_latency}ms"
|
||||
)
|
||||
|
||||
results_detail[scenario["id"]] = {
|
||||
"avg_score": avg_scenario_score,
|
||||
"pass_rate": pass_rate,
|
||||
"latency": avg_scenario_latency,
|
||||
}
|
||||
total_raw += avg_scenario_score
|
||||
total_latency += avg_scenario_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"
|
||||
f"\n📊 {model_name} Result: {'PASS' if passed_all else 'FAIL'} | Avg Score: {total_raw} | Latency: {avg_latency}ms"
|
||||
)
|
||||
|
||||
if model_name not in db["models"]:
|
||||
@@ -212,6 +232,9 @@ if __name__ == "__main__":
|
||||
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=3, help="Number of iterations per scenario to measure reliability"
|
||||
)
|
||||
|
||||
args, unknown = parser.parse_known_args()
|
||||
|
||||
@@ -241,5 +264,5 @@ if __name__ == "__main__":
|
||||
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)
|
||||
|
||||
@@ -93,7 +93,7 @@ limits:
|
||||
speed_multiplier: 1.0
|
||||
|
||||
# ── Infrastructure & System ──
|
||||
device: 192.168.1.206:40505
|
||||
device: 192.168.1.206:36369
|
||||
app-id: com.instagram.android
|
||||
debug: true
|
||||
blank_start: true
|
||||
|
||||
@@ -35,14 +35,14 @@ class TestQdrantFailure:
|
||||
|
||||
TelepathicEngine._instance = None
|
||||
engine = TelepathicEngine.__new__(TelepathicEngine)
|
||||
engine.ui_memory = MagicMock()
|
||||
engine.ui_memory.is_connected = False
|
||||
engine.ui_memory.query_closest = MagicMock(return_value=None)
|
||||
engine.__init__()
|
||||
engine._memory.ui_memory = MagicMock()
|
||||
engine._memory.ui_memory.is_connected = False
|
||||
engine._memory.ui_memory.query_closest = MagicMock(return_value=None)
|
||||
|
||||
engine.positive_memory = MagicMock()
|
||||
engine.positive_memory.is_connected = False
|
||||
engine.positive_memory.recall = MagicMock(return_value=None)
|
||||
engine._edge_model = None
|
||||
engine._edge_tokenizer = None
|
||||
|
||||
nodes = engine._extract_semantic_nodes(VALID_FEED_XML)
|
||||
# Should still find clickable nodes via structural parsing
|
||||
@@ -101,14 +101,13 @@ class TestQdrantFailure:
|
||||
|
||||
TelepathicEngine._instance = None
|
||||
engine = TelepathicEngine.__new__(TelepathicEngine)
|
||||
engine.ui_memory = MagicMock()
|
||||
engine.ui_memory.is_connected = False
|
||||
engine.ui_memory.query_closest = MagicMock(side_effect=TimeoutError("Qdrant timeout"))
|
||||
engine.__init__()
|
||||
engine._memory.ui_memory = MagicMock()
|
||||
engine._memory.ui_memory.is_connected = False
|
||||
engine._memory.ui_memory.query_closest = MagicMock(side_effect=TimeoutError("Qdrant timeout"))
|
||||
engine.positive_memory = MagicMock()
|
||||
engine.positive_memory.is_connected = False
|
||||
engine.positive_memory.recall = MagicMock(side_effect=TimeoutError("Qdrant timeout"))
|
||||
engine._edge_model = None
|
||||
engine._edge_tokenizer = None
|
||||
|
||||
start = time.time()
|
||||
nodes = engine._extract_semantic_nodes(VALID_FEED_XML)
|
||||
|
||||
@@ -34,14 +34,17 @@ def telepathic_engine():
|
||||
|
||||
TelepathicEngine._instance = None
|
||||
engine = TelepathicEngine.__new__(TelepathicEngine)
|
||||
engine.ui_memory = MagicMock()
|
||||
engine.ui_memory.is_connected = False
|
||||
engine.ui_memory.query_closest = MagicMock(return_value=None)
|
||||
engine.__init__()
|
||||
|
||||
# We need to mock the Qdrant connection in the ActionMemory submodule
|
||||
engine._memory.ui_memory = MagicMock()
|
||||
engine._memory.ui_memory.is_connected = False
|
||||
engine._memory.ui_memory.query_closest = MagicMock(return_value=None)
|
||||
|
||||
# We mock positive memory for chaos tests
|
||||
engine.positive_memory = MagicMock()
|
||||
engine.positive_memory.is_connected = False
|
||||
engine.positive_memory.recall = MagicMock(return_value=None)
|
||||
engine._edge_model = None
|
||||
engine._edge_tokenizer = None
|
||||
yield engine
|
||||
TelepathicEngine._instance = None
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ def test_wait_for_post_detects_feed():
|
||||
with open("tests/fixtures/organic_post.xml", "r") as f:
|
||||
sim.mock_xml = f.read()
|
||||
|
||||
with patch("GramAddict.core.physics.timing.sleep"):
|
||||
with patch("GramAddict.core.physics.timing.sleep", autospec=True):
|
||||
assert wait_for_post_loaded(sim, timeout=1) is True
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ def test_wait_for_post_timeout_and_adaptive_snap():
|
||||
# Empty XML will cause timeout
|
||||
sim.mock_xml = "<hierarchy></hierarchy>"
|
||||
|
||||
with patch("GramAddict.core.physics.timing.sleep"):
|
||||
with patch("GramAddict.core.physics.timing.sleep", autospec=True):
|
||||
assert wait_for_post_loaded(sim, timeout=1) is False
|
||||
|
||||
swipes = [a for a in sim.actions_taken if a[0] == "swipe"]
|
||||
@@ -29,7 +29,7 @@ def test_wait_for_story_detects_viewer():
|
||||
sim = BehaviorSimulator()
|
||||
sim.mock_xml = '<node class="hierarchy"><node resource-id="com.instagram.android:id/reel_viewer_root" /></node>'
|
||||
|
||||
with patch("GramAddict.core.physics.timing.sleep"):
|
||||
with patch("GramAddict.core.physics.timing.sleep", autospec=True):
|
||||
assert wait_for_story_loaded(sim, timeout=1) is True
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ def test_wait_for_story_timeout():
|
||||
sim = BehaviorSimulator()
|
||||
sim.mock_xml = "<hierarchy></hierarchy>"
|
||||
|
||||
with patch("GramAddict.core.physics.timing.sleep"):
|
||||
with patch("GramAddict.core.physics.timing.sleep", autospec=True):
|
||||
assert wait_for_story_loaded(sim, timeout=1) is False
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ def test_align_active_post_centers_content():
|
||||
|
||||
sim.swipe = mock_swipe
|
||||
|
||||
with patch("GramAddict.core.physics.timing.sleep"):
|
||||
with patch("GramAddict.core.physics.timing.sleep", autospec=True):
|
||||
try:
|
||||
aligned = align_active_post(sim)
|
||||
except Exception as e:
|
||||
@@ -82,7 +82,7 @@ def test_align_active_post_already_centered():
|
||||
xml_str = xml_str.replace('bounds="[128,665][768,731]"', 'bounds="[128,180][768,246]"')
|
||||
sim.mock_xml = xml_str
|
||||
|
||||
with patch("GramAddict.core.physics.timing.sleep"):
|
||||
with patch("GramAddict.core.physics.timing.sleep", autospec=True):
|
||||
aligned = align_active_post(sim)
|
||||
|
||||
# It considers it already aligned
|
||||
@@ -96,7 +96,7 @@ def test_align_post_with_no_header():
|
||||
sim = BehaviorSimulator()
|
||||
sim.mock_xml = "<hierarchy></hierarchy>"
|
||||
|
||||
with patch("GramAddict.core.physics.timing.sleep"):
|
||||
with patch("GramAddict.core.physics.timing.sleep", autospec=True):
|
||||
aligned = align_active_post(sim)
|
||||
|
||||
assert aligned is False
|
||||
|
||||
@@ -201,7 +201,7 @@ def test_e2e_follow_plugin_execution(base_ctx):
|
||||
plugin = FollowPlugin()
|
||||
|
||||
# We patch sleep so the test runs fast
|
||||
with patch("GramAddict.core.behaviors.follow.sleep"):
|
||||
with patch("GramAddict.core.behaviors.follow.sleep", autospec=True):
|
||||
result = plugin.execute(ctx)
|
||||
|
||||
assert result.executed is True
|
||||
@@ -253,7 +253,7 @@ def test_e2e_grid_like_plugin_execution(base_ctx):
|
||||
|
||||
# We need to patch the open_first_post logic to simulate that it succeeds,
|
||||
# as our XML already represents the opened post.
|
||||
with patch("GramAddict.core.behaviors.grid_like.sleep"):
|
||||
with patch("GramAddict.core.behaviors.grid_like.sleep", autospec=True):
|
||||
original_do = nav_graph.do
|
||||
|
||||
def side_effect_do(action, *args, **kwargs):
|
||||
@@ -261,7 +261,7 @@ def test_e2e_grid_like_plugin_execution(base_ctx):
|
||||
return True
|
||||
return original_do(action, *args, **kwargs)
|
||||
|
||||
with patch.object(nav_graph, "do", side_effect=side_effect_do):
|
||||
with patch.object(nav_graph, "do", autospec=True, side_effect=side_effect_do):
|
||||
result = plugin.execute(ctx)
|
||||
|
||||
assert result.executed is True
|
||||
@@ -302,8 +302,12 @@ def test_e2e_carousel_plugin_execution(base_ctx):
|
||||
sim.actions_taken.append(("swipe", start_x, y, end_x, y))
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.behaviors.carousel_browsing.sleep"),
|
||||
patch("GramAddict.core.behaviors.carousel_browsing.humanized_horizontal_swipe", side_effect=mock_swipe),
|
||||
patch("GramAddict.core.behaviors.carousel_browsing.sleep", autospec=True),
|
||||
patch(
|
||||
"GramAddict.core.behaviors.carousel_browsing.humanized_horizontal_swipe",
|
||||
autospec=True,
|
||||
side_effect=mock_swipe,
|
||||
),
|
||||
):
|
||||
result = plugin.execute(ctx)
|
||||
|
||||
@@ -343,7 +347,7 @@ def test_e2e_like_plugin_execution(base_ctx):
|
||||
|
||||
plugin = LikePlugin()
|
||||
|
||||
with patch("GramAddict.core.behaviors.like.random.random", return_value=0.0):
|
||||
with patch("GramAddict.core.behaviors.like.random.random", autospec=True, return_value=0.0):
|
||||
result = plugin.execute(ctx)
|
||||
|
||||
assert result.executed is True
|
||||
@@ -389,8 +393,8 @@ def test_e2e_story_view_plugin_execution(base_ctx):
|
||||
plugin = StoryViewPlugin()
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.behaviors.story_view.sleep"),
|
||||
patch("GramAddict.core.behaviors.story_view.wait_for_story_loaded", return_value=True),
|
||||
patch("GramAddict.core.behaviors.story_view.sleep", autospec=True),
|
||||
patch("GramAddict.core.behaviors.story_view.wait_for_story_loaded", autospec=True, return_value=True),
|
||||
):
|
||||
result = plugin.execute(ctx)
|
||||
|
||||
@@ -440,7 +444,7 @@ def test_e2e_comment_plugin_execution(base_ctx):
|
||||
|
||||
plugin = CommentPlugin()
|
||||
|
||||
with patch("GramAddict.core.behaviors.comment.random.random", return_value=0.0):
|
||||
with patch("GramAddict.core.behaviors.comment.random.random", autospec=True, return_value=0.0):
|
||||
result = plugin.execute(ctx)
|
||||
|
||||
assert result.executed is True
|
||||
@@ -494,10 +498,11 @@ def test_e2e_obstacle_guard_unlearn_on_fatal(base_ctx):
|
||||
with (
|
||||
patch(
|
||||
"GramAddict.core.behaviors.obstacle_guard.SituationalAwarenessEngine.get_instance",
|
||||
autospec=True,
|
||||
return_value=real_sae,
|
||||
),
|
||||
patch("GramAddict.core.behaviors.obstacle_guard.dump_ui_state"),
|
||||
patch("GramAddict.core.behaviors.obstacle_guard.sleep"),
|
||||
patch("GramAddict.core.behaviors.obstacle_guard.dump_ui_state", autospec=True),
|
||||
patch("GramAddict.core.behaviors.obstacle_guard.sleep", autospec=True),
|
||||
):
|
||||
result = plugin.execute(ctx)
|
||||
|
||||
@@ -556,8 +561,9 @@ def test_e2e_obstacle_guard_dismiss_modal(base_ctx):
|
||||
with (
|
||||
patch(
|
||||
"GramAddict.core.behaviors.obstacle_guard.SituationalAwarenessEngine.get_instance",
|
||||
autospec=True,
|
||||
) as mock_sae,
|
||||
patch("GramAddict.core.behaviors.obstacle_guard.sleep"),
|
||||
patch("GramAddict.core.behaviors.obstacle_guard.sleep", autospec=True),
|
||||
):
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.perceive.return_value = SituationType.OBSTACLE_MODAL
|
||||
@@ -621,7 +627,7 @@ def test_e2e_resonance_evaluator_visual_vibe_check(base_ctx):
|
||||
|
||||
plugin = ResonanceEvaluatorPlugin()
|
||||
|
||||
with patch("GramAddict.core.behaviors.resonance_evaluator.random.random", return_value=0.0):
|
||||
with patch("GramAddict.core.behaviors.resonance_evaluator.random.random", autospec=True, return_value=0.0):
|
||||
result = plugin.execute(ctx)
|
||||
|
||||
assert result.executed is True
|
||||
@@ -679,7 +685,7 @@ def test_e2e_resonance_evaluator_no_persona_interests(base_ctx):
|
||||
|
||||
plugin = ResonanceEvaluatorPlugin()
|
||||
|
||||
with patch("GramAddict.core.behaviors.resonance_evaluator.random.random", return_value=0.0):
|
||||
with patch("GramAddict.core.behaviors.resonance_evaluator.random.random", autospec=True, return_value=0.0):
|
||||
result = plugin.execute(ctx)
|
||||
|
||||
assert result.executed is True
|
||||
|
||||
114
tests/e2e/test_e2e_dm_engine.py
Normal file
114
tests/e2e/test_e2e_dm_engine.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device():
|
||||
device = MagicMock()
|
||||
# Initial inbox state
|
||||
device.dump_hierarchy.return_value = "<xml><node text='Inbox'/></xml>"
|
||||
return device
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cognitive_stack():
|
||||
telepathic = MagicMock()
|
||||
dopamine = MagicMock()
|
||||
dopamine.is_app_session_over.return_value = False
|
||||
dopamine.wants_to_change_feed.side_effect = [False, True]
|
||||
dopamine.boredom = 0
|
||||
|
||||
dm_memory = MagicMock()
|
||||
|
||||
resonance = MagicMock()
|
||||
resonance.persona_prompt = "You are a friendly bot."
|
||||
resonance.args.ai_model = "test-model"
|
||||
|
||||
return {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": dm_memory, "resonance": resonance}
|
||||
|
||||
|
||||
def test_e2e_dm_full_flow_success(mock_device, mock_cognitive_stack):
|
||||
"""
|
||||
E2E scenario:
|
||||
1. Found 1 unread message.
|
||||
2. Opened chat.
|
||||
3. Read context.
|
||||
4. Generated response.
|
||||
5. Sent message.
|
||||
6. Guarded back-navigation (keyboard closed + activity exit).
|
||||
"""
|
||||
telepathic = mock_cognitive_stack["telepathic"]
|
||||
|
||||
hierarchy_items = [
|
||||
"<xml>Inbox with unread</xml>", # Loop 1 start
|
||||
"<xml>Thread view</xml>", # Context read
|
||||
"<xml>Thread view</xml>", # Input field find
|
||||
"<xml>Thread view</xml>", # Send button find
|
||||
"<xml><node resource-id='com.instagram.android:id/direct_thread_header'/></xml>", # Navigation check AFTER back
|
||||
"<xml>Inbox View</xml>", # Loop 2 start (exit)
|
||||
"<xml>Inbox View</xml>", # Buffer
|
||||
]
|
||||
hierarchy_iterator = iter(hierarchy_items)
|
||||
mock_device.dump_hierarchy.side_effect = lambda: next(hierarchy_iterator)
|
||||
|
||||
# Semantic node responses
|
||||
telepathic._extract_semantic_nodes.side_effect = [
|
||||
[{"x": 100, "y": 100, "text": "New Message"}], # unread_threads
|
||||
[{"text": "Hello there!"}], # msg_nodes (context)
|
||||
[{"x": 200, "y": 200}], # input_nodes
|
||||
[{"x": 300, "y": 300}], # send_nodes
|
||||
[], # Loop 2: no unread
|
||||
[], # Buffer
|
||||
]
|
||||
|
||||
mock_cognitive_stack["dopamine"].boredom = 0
|
||||
mock_cognitive_stack["dopamine"].wants_to_change_feed.side_effect = [False, True, True]
|
||||
|
||||
session_state = MagicMock(spec=SessionState)
|
||||
session_state.check_limit.return_value = False
|
||||
session_state.totalMessages = 0
|
||||
|
||||
mock_configs = MagicMock()
|
||||
mock_configs.args.disable_ai_messaging = False
|
||||
mock_configs.args.ai_condenser_model = "test-model"
|
||||
mock_configs.args.ai_condenser_url = "http://localhost:11434/api/generate"
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.llm_provider.query_llm", return_value={"response": "Hi! How can I help?"}),
|
||||
patch("GramAddict.core.bot_flow._humanized_click"),
|
||||
patch("GramAddict.core.bot_flow.sleep"),
|
||||
patch("GramAddict.core.stealth_typing.ghost_type"),
|
||||
):
|
||||
result = _run_zero_latency_dm_loop(
|
||||
mock_device, MagicMock(), MagicMock(), mock_configs, session_state, "target", mock_cognitive_stack
|
||||
)
|
||||
|
||||
assert result == "BOREDOM_CHANGE_FEED"
|
||||
# Ensure navigation at least attempted to exit
|
||||
assert mock_device.press.call_count >= 2
|
||||
mock_device.press.assert_called_with("back")
|
||||
|
||||
|
||||
def test_e2e_dm_no_messages(mock_device, mock_cognitive_stack):
|
||||
"""
|
||||
E2E scenario: No messages found, exit immediately.
|
||||
"""
|
||||
telepathic = mock_cognitive_stack["telepathic"]
|
||||
mock_cognitive_stack["dopamine"].wants_to_change_feed.return_value = True
|
||||
|
||||
telepathic._extract_semantic_nodes.return_value = [] # No unreads
|
||||
|
||||
session_state = MagicMock(spec=SessionState)
|
||||
session_state.check_limit.return_value = False
|
||||
|
||||
result = _run_zero_latency_dm_loop(
|
||||
mock_device, MagicMock(), MagicMock(), MagicMock(), session_state, "target", mock_cognitive_stack
|
||||
)
|
||||
|
||||
assert result == "BOREDOM_CHANGE_FEED"
|
||||
# Should only press back once to exit Inbox
|
||||
assert mock_device.press.call_count == 1
|
||||
@@ -71,7 +71,9 @@ def test_real_llm_learning_and_unlearning(isolated_screen_memory):
|
||||
# We patch the underlying LLM call just to spy on it (wraps the original function)
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
|
||||
with patch("GramAddict.core.llm_provider.query_telepathic_llm", wraps=query_telepathic_llm) as spy_llm:
|
||||
with patch(
|
||||
"GramAddict.core.llm_provider.query_telepathic_llm", autospec=True, wraps=query_telepathic_llm
|
||||
) as spy_llm:
|
||||
# ---------------------------------------------------------
|
||||
# PASS 1: The Initial Encounter (Learn)
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@@ -484,7 +484,7 @@ class TestSAELearning:
|
||||
assert sae._compute_situation_hash(c1) == sae._compute_situation_hash(c2)
|
||||
assert sae._compute_situation_hash(c1) != sae._compute_situation_hash(c3)
|
||||
|
||||
@patch("GramAddict.core.qdrant_memory.ScreenMemoryDB.store_screen")
|
||||
@patch("GramAddict.core.qdrant_memory.ScreenMemoryDB.store_screen", autospec=True)
|
||||
def test_llm_false_positive_unlearn(self, mock_store_screen):
|
||||
"""When LLM returns 'false_positive', SAE must overwrite Qdrant and return True."""
|
||||
device = make_mock_device()
|
||||
@@ -493,14 +493,17 @@ class TestSAELearning:
|
||||
device.dump_hierarchy.return_value = INSTAGRAM_HOME_XML
|
||||
|
||||
# Force the situation to be perceived as an OBSTACLE_MODAL initially
|
||||
with patch.object(sae, "perceive", return_value=SituationType.OBSTACLE_MODAL):
|
||||
with patch.object(sae, "perceive", autospec=True, return_value=SituationType.OBSTACLE_MODAL):
|
||||
# Mock LLM to return 'false_positive'
|
||||
with patch.object(
|
||||
sae, "_plan_escape_via_llm", return_value=EscapeAction("false_positive", reason="No modal found")
|
||||
sae,
|
||||
"_plan_escape_via_llm",
|
||||
autospec=True,
|
||||
return_value=EscapeAction("false_positive", reason="No modal found"),
|
||||
):
|
||||
result = sae.ensure_clear_screen(max_attempts=1, initial_xml=INSTAGRAM_HOME_XML)
|
||||
|
||||
assert result is True
|
||||
mock_store_screen.assert_called_once()
|
||||
args, kwargs = mock_store_screen.call_args
|
||||
assert args[1] == "NORMAL"
|
||||
assert args[2] == "NORMAL"
|
||||
|
||||
@@ -316,7 +316,7 @@ def test_feed_loop_deep_engagement(mock_device, mock_cognitive_stack):
|
||||
patch("GramAddict.core.bot_flow._align_active_post", return_value=False),
|
||||
patch("GramAddict.core.bot_flow._humanized_scroll"),
|
||||
patch("GramAddict.core.bot_flow._humanized_click") as mock_click,
|
||||
patch("GramAddict.core.stealth_typing.ghost_type") as mock_type,
|
||||
patch("GramAddict.core.stealth_typing.ghost_type", autospec=True) as mock_type,
|
||||
):
|
||||
mock_extract.return_value = {"username": "legit_user", "description": "test image", "caption": ""}
|
||||
mock_instance = MockTelepathic.get_instance.return_value
|
||||
|
||||
@@ -58,7 +58,7 @@ def test_dm_engine_basic_loop(dm_mock_dependencies):
|
||||
with (
|
||||
patch("GramAddict.core.bot_flow.sleep"),
|
||||
patch("GramAddict.core.bot_flow._humanized_click") as mock_click,
|
||||
patch("GramAddict.core.stealth_typing.ghost_type") as mock_ghost_type,
|
||||
patch("GramAddict.core.stealth_typing.ghost_type", autospec=True) as mock_ghost_type,
|
||||
patch("GramAddict.core.llm_provider.query_llm", return_value={"response": "I am good, thanks!"}),
|
||||
):
|
||||
res = _run_zero_latency_dm_loop(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock, create_autospec, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -102,11 +102,14 @@ class TestObstacleGuardPlugin:
|
||||
@patch("GramAddict.core.behaviors.obstacle_guard.dump_ui_state")
|
||||
@patch("GramAddict.core.behaviors.obstacle_guard.SituationalAwarenessEngine")
|
||||
def test_execute_obstacle_miss_3_abort(self, mock_sae, mock_dump, obstacle_guard, mock_context):
|
||||
mock_instance = MagicMock()
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine
|
||||
|
||||
mock_instance = create_autospec(SituationalAwarenessEngine, instance=True)
|
||||
mock_instance.perceive.return_value = SituationType.OBSTACLE_MODAL
|
||||
mock_sae.get_instance.return_value = mock_instance
|
||||
|
||||
mock_context.device.dump_hierarchy.return_value = "<xml>dummy</xml>"
|
||||
xml_content = "<xml>dummy</xml>"
|
||||
mock_context.device.dump_hierarchy.return_value = xml_content
|
||||
mock_context.device.get_info.return_value = {"displayWidth": 1080, "displayHeight": 2400}
|
||||
mock_context.shared_state["consecutive_marker_misses"] = 2
|
||||
mock_context.session_state.job_target = "Feed"
|
||||
@@ -115,5 +118,5 @@ class TestObstacleGuardPlugin:
|
||||
|
||||
assert result.executed is True
|
||||
assert result.metadata.get("return_code") == "CONTEXT_LOST"
|
||||
mock_instance.unlearn_current_state.assert_called_once()
|
||||
mock_instance.unlearn_current_state.assert_called_once_with(xml_content)
|
||||
mock_dump.assert_called_once()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock, create_autospec, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -63,10 +64,12 @@ class TestResonanceEvaluatorPlugin:
|
||||
@patch("GramAddict.core.behaviors.resonance_evaluator.humanized_scroll")
|
||||
@patch("GramAddict.core.behaviors.resonance_evaluator.sleep")
|
||||
def test_execute_visual_vibe_check(self, mock_sleep, mock_scroll, resonance_evaluator, mock_context):
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
mock_context.configs.args.visual_vibe_check_percentage = 100
|
||||
mock_context.cognitive_stack["resonance"].calculate_resonance.return_value = 0.5
|
||||
|
||||
mock_tele = MagicMock()
|
||||
mock_tele = create_autospec(TelepathicEngine, instance=True)
|
||||
mock_tele.evaluate_post_vibe.return_value = {"quality_score": 10, "matches_niche": True}
|
||||
mock_context.cognitive_stack["telepathic"] = mock_tele
|
||||
|
||||
@@ -77,4 +80,4 @@ class TestResonanceEvaluatorPlugin:
|
||||
assert result.should_skip is False
|
||||
# res_score = 0.5 * 0.3 + 1.0 * 0.7 = 0.15 + 0.70 = 0.85
|
||||
assert round(mock_context.shared_state["res_score"], 2) == 0.85
|
||||
mock_tele.evaluate_post_vibe.assert_called_once()
|
||||
mock_tele.evaluate_post_vibe.assert_called_once_with(mock_context.device, mock.ANY)
|
||||
|
||||
29
tests/unit/test_diagnostic_dump_cleanup.py
Normal file
29
tests/unit/test_diagnostic_dump_cleanup.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import os
|
||||
import shutil
|
||||
import base64
|
||||
from unittest.mock import MagicMock, patch
|
||||
from GramAddict.core.behaviors.simulator import BehaviorSimulator
|
||||
|
||||
def test_device_facade_session_trace_cleanup(tmp_path):
|
||||
facade = BehaviorSimulator()
|
||||
facade.get_screenshot_b64 = MagicMock(return_value=base64.b64encode(b"fake_jpg_data").decode("utf-8"))
|
||||
|
||||
with patch("GramAddict.core.device_facade.os.path.join", side_effect=os.path.join), \
|
||||
patch("GramAddict.core.device_facade.os.makedirs"):
|
||||
|
||||
def fake_join(*args):
|
||||
if args[0] == "debug" and args[1] == "session_traces":
|
||||
return str(tmp_path / "session_traces")
|
||||
return os.path.join(*args)
|
||||
|
||||
with patch("GramAddict.core.device_facade.os.path.join", side_effect=fake_join):
|
||||
traces_root = tmp_path / "session_traces"
|
||||
traces_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for i in range(6):
|
||||
d = traces_root / f"old_session_{i}"
|
||||
d.mkdir()
|
||||
|
||||
facade.dump_hierarchy()
|
||||
remaining_folders = [f for f in os.listdir(traces_root) if os.path.isdir(os.path.join(traces_root, f))]
|
||||
assert len(remaining_folders) <= 5
|
||||
163
tests/unit/test_dm_navigation_guards.py
Normal file
163
tests/unit/test_dm_navigation_guards.py
Normal file
@@ -0,0 +1,163 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
|
||||
from GramAddict.core.qdrant_memory import DMMemoryDB, ParasocialCRMDB
|
||||
|
||||
|
||||
def test_parasocial_crm_missing_log_sent_dm():
|
||||
"""
|
||||
RED: This test proves that ParasocialCRMDB lacks log_sent_dm,
|
||||
which caused the crash in the last production run.
|
||||
"""
|
||||
crm = ParasocialCRMDB()
|
||||
with pytest.raises(AttributeError) as excinfo:
|
||||
crm.log_sent_dm("test_user", "hello", "bio", [])
|
||||
|
||||
assert "'ParasocialCRMDB' object has no attribute 'log_sent_dm'" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_dm_engine_uses_correct_dm_memory_logging(monkeypatch):
|
||||
"""
|
||||
RED: This test checks if dm_engine correctly uses dm_memory from cognitive_stack.
|
||||
Note: I am writing this to PROVE the fix is necessary.
|
||||
"""
|
||||
mock_device = MagicMock()
|
||||
mock_device.dump_hierarchy.return_value = "<xml></xml>"
|
||||
|
||||
mock_telepathic = MagicMock()
|
||||
# Mock unread thread found
|
||||
mock_thread = {"x": 100, "y": 100, "text": "Mariischen"}
|
||||
|
||||
def side_effect_logging(*args, **kwargs):
|
||||
res = next(iterator)
|
||||
print(f"DEBUG: extract_semantic_nodes called with {args[1]}. Returning {res}")
|
||||
return res
|
||||
|
||||
iterator = iter(
|
||||
[
|
||||
[mock_thread], # Step 1: unread threads
|
||||
[{"text": "Hey"}], # Step 2: context
|
||||
[{"x": 200, "y": 200}], # Step 3: input field
|
||||
[{"x": 300, "y": 300}], # Step 4: send button
|
||||
[], # Step 5: next iteration no unread
|
||||
]
|
||||
)
|
||||
mock_telepathic._extract_semantic_nodes.side_effect = side_effect_logging
|
||||
|
||||
mock_dopamine = MagicMock()
|
||||
mock_dopamine.is_app_session_over.return_value = False
|
||||
mock_dopamine.wants_to_change_feed.return_value = True # exit after one
|
||||
mock_dopamine.boredom = 0
|
||||
|
||||
mock_crm = ParasocialCRMDB()
|
||||
mock_dm_memory = MagicMock(spec=DMMemoryDB)
|
||||
mock_resonance = MagicMock()
|
||||
mock_resonance.persona_prompt = "Persona prompt"
|
||||
mock_resonance.args.ai_model = "test-model"
|
||||
|
||||
cognitive_stack = {
|
||||
"telepathic": mock_telepathic,
|
||||
"dopamine": mock_dopamine,
|
||||
"crm": mock_crm,
|
||||
"dm_memory": mock_dm_memory,
|
||||
"resonance": mock_resonance,
|
||||
}
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.check_limit.return_value = False
|
||||
mock_session.totalMessages = 0
|
||||
|
||||
# Mock LLM response
|
||||
monkeypatch.setattr("GramAddict.core.llm_provider.query_llm", lambda **k: {"response": "hi"})
|
||||
monkeypatch.setattr("GramAddict.core.bot_flow._humanized_click", lambda *a: None)
|
||||
monkeypatch.setattr("GramAddict.core.bot_flow.sleep", lambda *a: None)
|
||||
monkeypatch.setattr("GramAddict.core.stealth_typing.ghost_type", lambda *a, **k: None)
|
||||
|
||||
mock_configs = MagicMock()
|
||||
mock_configs.args.disable_ai_messaging = False
|
||||
mock_configs.args.ai_condenser_model = "test-model"
|
||||
mock_configs.args.ai_condenser_url = "http://localhost:11434/api/generate"
|
||||
|
||||
# This should NOT crash now because I fixed it, but we are testing the logic.
|
||||
|
||||
_run_zero_latency_dm_loop(
|
||||
mock_device, MagicMock(), MagicMock(), mock_configs, mock_session, "target", cognitive_stack
|
||||
)
|
||||
|
||||
# Verify dm_memory was used, NOT crm
|
||||
mock_dm_memory.log_sent_dm.assert_called_once()
|
||||
|
||||
|
||||
def test_dm_navigation_double_back_guard():
|
||||
"""
|
||||
Verifies that if we are still in a thread after one back press,
|
||||
we press back again.
|
||||
"""
|
||||
mock_device = MagicMock()
|
||||
# Mocking hierarchy sequence
|
||||
mock_device.dump_hierarchy.side_effect = [
|
||||
"<xml>Inbox</xml>", # Loop 1 start
|
||||
"<xml>Thread</xml>", # Context read
|
||||
"<xml>Thread</xml>", # Send button find
|
||||
"<xml><node resource-id='com.instagram.android:id/direct_thread_header'/></xml>", # Navigation check AFTER back
|
||||
"<xml>Inbox</xml>", # Loop 2 start (exit)
|
||||
"<xml>Inbox</xml>", # Buffer
|
||||
]
|
||||
|
||||
mock_telepathic = MagicMock()
|
||||
mock_telepathic._extract_semantic_nodes.side_effect = [
|
||||
[{"x": 1, "y": 1}], # unread found in Loop 1
|
||||
[{"text": "msg"}], # context
|
||||
[{"x": 2, "y": 2}], # input
|
||||
[{"x": 3, "y": 3}], # send
|
||||
[], # Loop 2: no unread
|
||||
[], # Buffer
|
||||
]
|
||||
|
||||
mock_dopamine = MagicMock()
|
||||
mock_dopamine.is_app_session_over.return_value = False
|
||||
mock_dopamine.wants_to_change_feed.side_effect = [False, True, True] # exit after Loop 1
|
||||
mock_dopamine.boredom = 0
|
||||
|
||||
mock_resonance = MagicMock()
|
||||
mock_resonance.persona_prompt = "Persona"
|
||||
mock_resonance.args.ai_model = "model"
|
||||
|
||||
cognitive_stack = {
|
||||
"telepathic": mock_telepathic,
|
||||
"dopamine": mock_dopamine,
|
||||
"dm_memory": MagicMock(),
|
||||
"resonance": mock_resonance,
|
||||
}
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.check_limit.return_value = False
|
||||
mock_session.totalMessages = 0
|
||||
|
||||
mock_configs = MagicMock()
|
||||
mock_configs.args.disable_ai_messaging = False
|
||||
mock_configs.args.ai_condenser_model = "test-model"
|
||||
mock_configs.args.ai_condenser_url = "http://localhost:11434/api/generate"
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import GramAddict.core.dm_engine as dm_engine
|
||||
|
||||
with (
|
||||
patch("GramAddict.core.llm_provider.query_llm", return_value={"response": "hi"}),
|
||||
patch("GramAddict.core.bot_flow._humanized_click"),
|
||||
patch("GramAddict.core.bot_flow.sleep"),
|
||||
patch("GramAddict.core.stealth_typing.ghost_type"),
|
||||
):
|
||||
dm_engine._run_zero_latency_dm_loop(
|
||||
mock_device, MagicMock(), MagicMock(), mock_configs, mock_session, "target", cognitive_stack
|
||||
)
|
||||
|
||||
# Expected calls:
|
||||
# 1. First back from success flow (thread -> inbox)
|
||||
# 2. Second back from guard check (if still in thread)
|
||||
# 3. Third back from inbox exit (boredom check)
|
||||
assert mock_device.press.call_count == 3
|
||||
mock_device.press.assert_called_with("back")
|
||||
Reference in New Issue
Block a user