fix: enforce quoted intent for follow to prevent VLM hallucination

This commit is contained in:
2026-04-27 21:47:40 +02:00
parent a7449a1db3
commit 7b8daa7670
49 changed files with 4850 additions and 294 deletions

View File

@@ -49,7 +49,7 @@ class FollowPlugin(BehaviorPlugin):
nav_graph = QNavGraph(ctx.device)
if nav_graph.do("tap follow button"):
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, scraped=False)

View File

@@ -23,8 +23,12 @@ class Config:
if is_pytest:
self.args = []
else:
self.args = sys.argv
self.args = list(sys.argv)
self.module = False
if not self.module and "--config" not in self.args:
if os.path.exists("config.yml"):
self.args.extend(["--config", "config.yml"])
self.config = None
self.config_list = None
self.actions = {}
@@ -142,7 +146,7 @@ class Config:
self.parser.add_argument(
"--blank-start",
action="store_true",
help="Wipe all learned navigation and telepathic memories on boot to start 100% blank.",
help="Wipe all learned navigation and telepathic memories on boot to start 100%% blank.",
)
# Interaction settings
@@ -308,7 +312,7 @@ class Config:
logger.debug(f"Arguments used: {' '.join(sys.argv[1:])}")
if self.config:
logger.debug(f"Config used: {self.config}")
if len(sys.argv) <= 1:
if len(sys.argv) <= 1 and not self.config:
self.parser.print_help()
exit(0)
if self.config:

View File

@@ -158,6 +158,10 @@ class DeviceFacade:
def press(self, key):
self.deviceV2.press(key)
@adb_retry()
def back(self):
self.deviceV2.press("back")
@adb_retry()
def click(self, x=None, y=None, obj=None):
if obj:

View File

@@ -112,25 +112,60 @@ class IntentResolver:
and "per cent" not in (n.content_desc or "").lower()
]
# Stage 2: Spatial deduplication — if a node is fully contained
# within another candidate, suppress the child. This eliminates
# redundant sub-nodes (e.g., followers_label inside followers_stacked).
def _is_contained(child: SpatialNode, parent: SpatialNode) -> bool:
# Stage 2: Spatial deduplication
# A node could completely contain another.
# If parent is clickable and child is not: suppress child (e.g. text inside button)
# If parent is not clickable and child is: suppress parent (e.g. layout container around button)
# If both are not clickable: suppress parent (keep the smaller, more specific text)
# If both are clickable: keep both! (e.g. nested buttons like row and camera icon)
def _contains(parent: SpatialNode, child: SpatialNode) -> bool:
return (
parent.x1 <= child.x1
and parent.y1 <= child.y1
and parent.x2 >= child.x2
and parent.y2 >= child.y2
and parent is not child
and parent.node_id != child.node_id
)
# Sort by area descending so parents come first
to_suppress = set()
# Sort by area DESCENDING so we process largest (parents) first
pre_filtered.sort(key=lambda n: n.area, reverse=True)
visible_candidates = []
for node in pre_filtered:
is_child = any(_is_contained(node, parent) for parent in visible_candidates)
if not is_child:
visible_candidates.append(node)
for i, parent in enumerate(pre_filtered):
for j in range(i + 1, len(pre_filtered)):
child = pre_filtered[j]
if _contains(parent, child):
if parent.clickable and not child.clickable:
to_suppress.add(child.node_id)
# Merge semantic info from child to parent if missing
if (
child.text
and child.text not in (parent.text or "")
and child.text not in (parent.content_desc or "")
):
parent.content_desc = f"{(parent.content_desc or '')} {child.text}".strip()
if (
child.content_desc
and child.content_desc not in (parent.text or "")
and child.content_desc not in (parent.content_desc or "")
):
parent.content_desc = f"{(parent.content_desc or '')} {child.content_desc}".strip()
elif not parent.clickable and child.clickable:
to_suppress.add(parent.node_id)
# Pass any semantic info down just in case
if parent.content_desc and not child.content_desc:
child.content_desc = parent.content_desc
if parent.text and not child.text:
child.text = parent.text
elif not parent.clickable and not child.clickable:
to_suppress.add(parent.node_id)
if parent.content_desc and not child.content_desc:
child.content_desc = parent.content_desc
elif parent.clickable and child.clickable:
# Keep both, distinct nested interactables
pass
visible_candidates = [n for n in pre_filtered if n.node_id not in to_suppress]
draw = ImageDraw.Draw(img)
box_map: Dict[int, SpatialNode] = {}
@@ -196,6 +231,52 @@ class IntentResolver:
from GramAddict.core.config import Config
from GramAddict.core.llm_provider import query_telepathic_llm
# --- Strict Button Guard ---
# If the intent specifically asks for a "button", "icon", or "tab",
# filter out candidates that contain long text (e.g. captions, comments)
# to prevent the VLM from hallucinating text nodes as interactive buttons.
intent_lower = intent_description.lower()
if "button" in intent_lower or "icon" in intent_lower or "tab" in intent_lower:
filtered_candidates = []
for node in candidates:
text_len = len(node.text or "")
if text_len < 40:
filtered_candidates.append(node)
else:
logger.debug(f"🛡️ [Strict Button Guard] Filtered out node with long text: '{node.text[:20]}...'")
candidates = filtered_candidates
# --- Semantic Match Guard ---
# If the intent explicitly quotes a target (e.g., "tap 'New Message'"),
# we strictly filter candidates to those whose text or content_desc contains the quote.
import re
quotes = re.findall(r"['\"](.*?)['\"]", intent_description)
if quotes:
target_text = quotes[0].lower()
pattern = r"\b" + re.escape(target_text) + r"\b"
semantic_candidates = []
for node in candidates:
n_text = (node.text or "").lower()
n_desc = (node.content_desc or "").lower()
if re.search(pattern, n_text) or re.search(pattern, n_desc):
semantic_candidates.append(node)
if semantic_candidates:
if len(semantic_candidates) == 1:
logger.info(f"🎯 [Semantic Guard] Exact match found for '{target_text}', skipping VLM.")
return semantic_candidates[0]
else:
logger.info(
f"🎯 [Semantic Guard] {len(semantic_candidates)} matches found for '{target_text}'. Reducing candidates for VLM."
)
candidates = semantic_candidates
else:
logger.warning(
f"⚠️ [Semantic Guard] No candidates found containing '{target_text}'. Returning None to prevent hallucination."
)
return None
try:
annotated_b64, box_map = self._annotate_screenshot_with_candidates(device, candidates)
except Exception as e:
@@ -205,6 +286,8 @@ class IntentResolver:
if not box_map:
return None
self.last_box_map = box_map
cfg = Config()
model = getattr(cfg.args, "ai_telepathic_model", "llava:latest")
url = getattr(cfg.args, "ai_telepathic_url", "http://localhost:11434/api/generate")
@@ -222,22 +305,23 @@ class IntentResolver:
label_parts.append("(no visible text)")
box_legend_lines.append(f" [{idx}] {', '.join(label_parts)}")
box_legend = "\n".join(box_legend_lines)
print("BOX LEGEND:")
print(box_legend)
prompt = (
f"You are looking at a mobile app screenshot with numbered bounding boxes drawn around interactive UI elements.\n"
f"Each box has a number label (0, 1, 2, ...) in a colored rectangle.\n\n"
f"Each box has a number label in a colored rectangle.\n\n"
f"Box legend (what each box contains):\n{box_legend}\n\n"
f"Your task: Find the box that is the INTERACTIVE CONTROL for this intent: '{intent_description}'\n\n"
f"Your task: Find the exact box number that corresponds to this intent: '{intent_description}'\n\n"
f"CRITICAL RULES:\n"
f"- Match by VISUAL FUNCTION, not by text similarity.\n"
f" - 'like button' = a HEART-SHAPED ICON (♡/❤), usually labeled 'Like'.\n"
f" - 'follow button' = a button with the word 'Follow' displayed on it.\n"
f" - 'comment button' = a SPEECH BUBBLE ICON, labeled 'Comment'.\n"
f" - 'post author username' = the username text near the content.\n"
f"- A post caption that happens to contain the word 'like' is NOT a like button.\n"
f"- Text content (captions, descriptions, counts like 'View likes') are NOT interactive buttons.\n"
f"- If the exact interactive control is NOT visible on screen, return null. Do NOT pick the closest-looking thing.\n\n"
f'Reply ONLY with a valid JSON object: {{"box": <number>}} or {{"box": null}} if no box matches.'
f"1. If the intent contains a word in quotes (e.g., 'Search', 'New Message'), you MUST look at the Box legend and pick the box that contains that word (case-insensitive). Do not pick anything else.\n"
f"2. For icons without text:\n"
f" - 'like button' = HEART-SHAPED ICON (♡/❤), usually has desc='Like'.\n"
f" - 'comment button' = SPEECH BUBBLE ICON, usually has desc='Comment'.\n"
f"3. Do NOT select text, captions, or view counts if looking for an icon.\n"
f"4. Ignore numbers inside the text itself. Do not confuse the text '19' with Box [19].\n"
f"5. If the exact control is NOT visible, return null. Do NOT guess.\n\n"
f'Reply ONLY with a valid JSON object: {{"box": <number>}} or {{"box": null}}'
)
try:

View File

@@ -284,7 +284,7 @@ class ScreenIdentity:
if "back" in desc_lower:
actions.append("tap back button")
if any("follow" in e.get("text", "").lower() for e in clickable_elements):
actions.append("tap follow button")
actions.append("tap 'Follow' button")
if screen_type == ScreenType.OWN_PROFILE or screen_type == ScreenType.OTHER_PROFILE:
if "message" in desc_lower or "nachricht" in desc_lower:

View File

@@ -149,10 +149,15 @@ class SpatialParser:
# Filter zero-area nodes early
if right > left and bottom > top:
self._node_counter += 1
text_val = attrib.get("text", "").strip()
hint_val = attrib.get("hint", "").strip()
if not text_val and hint_val:
text_val = hint_val
node = SpatialNode(
node_id=f"n_{self._node_counter}",
class_name=attrib.get("class", ""),
text=attrib.get("text", "").strip(),
text=text_val,
content_desc=attrib.get("content-desc", "").strip(),
resource_id=attrib.get("resource-id", "").strip(),
bounds=(left, top, right, bottom),

View File

@@ -209,7 +209,7 @@ class QNavGraph:
# Grid & Profile
"tap_explore_grid_item": "first image in explore grid",
"tap_story_tray_item": "profile picture avatar story ring",
"tap_follow_button": "tap follow button on profile",
"tap_follow_button": "tap 'Follow' button on profile",
"tap_grid_first_post": "first image post in profile grid",
"tap_back": "tap back button icon arrow",
"tap_message_icon": "tap direct message icon inbox",

View File

@@ -155,8 +155,8 @@ class QdrantBase:
return data["data"][0]["embedding"]
return None
except Exception as e:
logger.debug(f"Failed to generate embedding via {url}: {e}")
return None
logger.error(f"Failed to generate embedding via {url}: {e}")
raise
def generate_uuid(self, seed_string: str) -> str:
"""