fix: structural hardening for grid selection and Reel author resolution
This commit is contained in:
@@ -135,23 +135,16 @@ class IntentResolver:
|
||||
|
||||
# NEW REGRESSION FIX: Exclude interaction buttons (comment, like, share) when looking for author or media
|
||||
# This prevents the weak VLM from hallucinating bounding box numbers that point to "Comment".
|
||||
interaction_suffixes = ["comment", "like", "share", "send", "save", "button_icon"]
|
||||
interaction_suffixes = ["comment", "like", "share", "send", "save", "button_icon", "scrubber"]
|
||||
is_interaction = any(s in rid for s in interaction_suffixes) or any(s in desc for s in interaction_suffixes)
|
||||
node_text_lower = (node.text or "").lower()
|
||||
is_follow = "follow" in rid or "follow" in node_text_lower or "follow" in desc
|
||||
is_media_intent = "media content" in intent_lower or "image" in intent_lower or "video" in intent_lower
|
||||
|
||||
if is_author_intent and (is_interaction or is_follow):
|
||||
if (is_author_intent or is_media_intent) and (is_interaction or is_follow):
|
||||
logger.debug(
|
||||
f"🛡️ [Author Interaction Guard] Excluded interaction/follow button '{node.resource_id}' "
|
||||
f"(desc='{node.content_desc}', text='{node.text}') for author intent '{intent_description}'"
|
||||
)
|
||||
continue
|
||||
|
||||
if is_media_intent and (is_interaction or is_follow):
|
||||
logger.debug(
|
||||
f"🛡️ [Media Interaction Guard] Excluded interaction/follow button '{node.resource_id}' "
|
||||
f"(desc='{node.content_desc}') for media intent '{intent_description}'"
|
||||
f"🛡️ [Interaction Guard] Excluded non-target element '{node.resource_id}' "
|
||||
f"(desc='{node.content_desc}', text='{node.text}') for intent '{intent_description}'"
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -233,15 +226,27 @@ class IntentResolver:
|
||||
return node
|
||||
|
||||
if "post author username" in intent_lower or "tap post username" in intent_lower:
|
||||
# 1. Feed Posts
|
||||
for node in candidates:
|
||||
if "row_feed_photo_profile_imageview" in (node.resource_id or "").lower():
|
||||
rid = (node.resource_id or "").lower()
|
||||
if "row_feed_photo_profile_imageview" in rid:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found post author avatar image: {node.content_desc}")
|
||||
return node
|
||||
for node in candidates:
|
||||
if "row_feed_photo_profile_name" in (node.resource_id or "").lower():
|
||||
rid = (node.resource_id or "").lower()
|
||||
if "row_feed_photo_profile_name" in rid:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found post author username text: {node.text}")
|
||||
return node
|
||||
|
||||
# 2. Reels (Clips)
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
if "clips_author_username" in rid or "clips_author_container" in rid:
|
||||
logger.info(
|
||||
f"🎯 [Structural Fast-Path] Found Reel author username: {node.text or node.content_desc}"
|
||||
)
|
||||
return node
|
||||
|
||||
if "feed post content" in intent_lower or "post media content" in intent_lower:
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
|
||||
@@ -184,14 +184,19 @@ class TelepathicEngine:
|
||||
if not root:
|
||||
return None
|
||||
|
||||
# Find grid-like visual nodes
|
||||
# Find grid-like visual nodes using structural markers
|
||||
all_nodes = self._parser.get_all_nodes(root)
|
||||
grid_candidates = [
|
||||
n
|
||||
for n in all_nodes
|
||||
if not n.text
|
||||
and ("photo" in n.content_desc.lower() or "video" in n.content_desc.lower() or (50000 < n.area < 400000))
|
||||
]
|
||||
grid_candidates = []
|
||||
for n in all_nodes:
|
||||
rid = (n.resource_id or "").lower()
|
||||
desc = (n.content_desc or "").lower()
|
||||
|
||||
# Structural anchor for Instagram grid items
|
||||
if "grid_card_layout_container" in rid:
|
||||
grid_candidates.append(n)
|
||||
# Fallback for older versions or profile grids if resource-id is missing
|
||||
elif not n.text and ("photo" in desc or "video" in desc) and (50000 < n.area < 400000):
|
||||
grid_candidates.append(n)
|
||||
|
||||
best = self._evaluator.evaluate_grid_visuals(device, persona_interests, grid_candidates)
|
||||
if best:
|
||||
|
||||
104
tests/unit/test_structural_hardening.py
Normal file
104
tests/unit/test_structural_hardening.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.perception.intent_resolver import IntentResolver
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resolver():
|
||||
return IntentResolver()
|
||||
|
||||
|
||||
def test_clips_author_fast_path(resolver):
|
||||
"""
|
||||
Ensures that Reel author usernames are resolved via the clips_author_username fast-path.
|
||||
"""
|
||||
reel_author_node = SpatialNode(
|
||||
resource_id="com.instagram.android:id/clips_author_username", text="maor.benezri", bounds=(0, 0, 100, 100)
|
||||
)
|
||||
|
||||
result = resolver.resolve("tap post username", [reel_author_node])
|
||||
assert result is not None
|
||||
assert result.resource_id == "com.instagram.android:id/clips_author_username"
|
||||
|
||||
|
||||
def test_scrubber_blacklist_for_author_intent(resolver):
|
||||
"""
|
||||
Ensures that the seeker/scrubber bar is filtered out for author intents.
|
||||
"""
|
||||
scrubber_node = SpatialNode(
|
||||
resource_id="com.instagram.android:id/scrubber",
|
||||
text="32291.0",
|
||||
content_desc="@2131978468",
|
||||
bounds=(0, 0, 500, 10),
|
||||
)
|
||||
|
||||
# We need a mock device that supports screenshots to enter _visual_discovery
|
||||
# where filter_navigation_conflicts is called for visual intents.
|
||||
class MockDevice:
|
||||
def screenshot(self):
|
||||
return None
|
||||
|
||||
@property
|
||||
def deviceV2(self):
|
||||
return self
|
||||
|
||||
# Since we can't easily mock the VLM response here without more setup,
|
||||
# we test filter_navigation_conflicts directly.
|
||||
candidates = [scrubber_node]
|
||||
filtered = resolver.filter_navigation_conflicts(candidates, "tap post username")
|
||||
|
||||
assert scrubber_node not in filtered, "Scrubber should be filtered out for author intents"
|
||||
|
||||
|
||||
def test_interaction_guard_excludes_scrubber_for_author(resolver):
|
||||
"""
|
||||
Verifies that the interaction guard correctly identifies and excludes the scrubber.
|
||||
"""
|
||||
scrubber_node = SpatialNode(
|
||||
resource_id="com.instagram.android:id/scrubber",
|
||||
text="32291.0",
|
||||
content_desc="@2131978468",
|
||||
bounds=(0, 0, 500, 10),
|
||||
)
|
||||
|
||||
filtered = resolver.filter_navigation_conflicts([scrubber_node], "post author username")
|
||||
assert len(filtered) == 0, "Interaction guard should have excluded the scrubber"
|
||||
|
||||
|
||||
def test_telepathic_grid_selection_uses_structural_id(monkeypatch):
|
||||
"""
|
||||
Ensures TelepathicEngine prefers grid_card_layout_container for grid candidates.
|
||||
"""
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
engine = TelepathicEngine()
|
||||
|
||||
# Mock nodes
|
||||
grid_node = SpatialNode(
|
||||
resource_id="com.instagram.android:id/grid_card_layout_container", bounds=(10, 10, 300, 300)
|
||||
)
|
||||
background_node = SpatialNode(
|
||||
resource_id="com.instagram.android:id/swipeable_nav_view_pager_inner_recycler_view", bounds=(0, 0, 1080, 2400)
|
||||
)
|
||||
|
||||
# Use monkeypatch instead of MagicMock
|
||||
monkeypatch.setattr(engine._parser, "parse", lambda x: "mock_root")
|
||||
monkeypatch.setattr(engine._parser, "get_all_nodes", lambda x: [grid_node, background_node])
|
||||
|
||||
candidates_captured = []
|
||||
|
||||
def mock_evaluate(device, persona_interests, candidates):
|
||||
candidates_captured.extend(candidates)
|
||||
return grid_node
|
||||
|
||||
monkeypatch.setattr(engine._evaluator, "evaluate_grid_visuals", mock_evaluate)
|
||||
|
||||
class MockDevice:
|
||||
def dump_hierarchy(self):
|
||||
return "<xml/>"
|
||||
|
||||
engine.evaluate_grid_visuals(MockDevice(), ["travel"])
|
||||
|
||||
assert grid_node in candidates_captured
|
||||
assert background_node not in candidates_captured, "Background recycler view should not be a grid candidate"
|
||||
Reference in New Issue
Block a user