diff --git a/agent_v0/server_v1/stream_processor.py b/agent_v0/server_v1/stream_processor.py index 6595409d1..e124c57b0 100644 --- a/agent_v0/server_v1/stream_processor.py +++ b/agent_v0/server_v1/stream_processor.py @@ -430,6 +430,66 @@ def _needs_post_wait(action: dict) -> int: return 0 +# --------------------------------------------------------------------------- +# Gemma4 : lecture du texte visible sur les éléments sans OCR +# --------------------------------------------------------------------------- + +# Port du Docker Ollama 0.20 (gemma4) +_GEMMA4_PORT = os.environ.get("GEMMA4_PORT", "11435") + + +def _gemma4_read_element( + img_b64: str, + window_title: str = "", + click_pos: tuple = None, +) -> str: + """Demander à gemma4 d'identifier l'élément cliqué. + + Peut recevoir soit un crop (80x80) soit un screenshot fenêtre complet. + Si click_pos est fourni, c'est un screenshot fenêtre et gemma4 doit + identifier l'élément à cette position. + + Returns: + Le texte lu (ex: "voiture electrique.txt") ou chaîne vide. + """ + import requests as _requests + + context = f" in '{window_title}'" if window_title else "" + if click_pos: + prompt = ( + f"This is a screenshot of a window{context}. " + f"The user clicked at position ({click_pos[0]}, {click_pos[1]}). " + "What is the exact text or label of the element that was clicked? " + "Answer ONLY the text, nothing else." + ) + else: + prompt = ( + f"This is a cropped UI element{context}. " + "Read the exact text on this element. " + "If it's an icon with no text, describe it in 2-3 words.\n" + "Answer ONLY the text or label, nothing else." + ) + + try: + resp = _requests.post(f"http://localhost:{_GEMMA4_PORT}/api/chat", json={ + "model": "gemma4:e4b", + "messages": [{"role": "user", "content": prompt, "images": [img_b64]}], + "stream": False, + "think": False, + "options": {"temperature": 0.1, "num_predict": 30}, + }, timeout=15) + if resp.ok: + content = resp.json().get("message", {}).get("content", "").strip() + # Nettoyer : retirer guillemets, points, préfixes + content = content.strip('"\'').rstrip(".").strip() + if content and 2 <= len(content) <= 60: + return content + except Exception as e: + logger.debug("gemma4 read element échoué : %s", e) + + return "" + + # --------------------------------------------------------------------------- # VLM identification d'éléments UI (pour les éléments sans texte OCR) # --------------------------------------------------------------------------- @@ -903,6 +963,27 @@ def enrich_click_from_screenshot( element_text = som_elem["label"] text_source = "ocr" + # ── 5b. Gemma4 : identifier l'élément cliqué via le screenshot fenêtre ── + # Quand l'OCR et SomEngine ne trouvent pas de texte, gemma4 (port 11435) + # reçoit le screenshot fenêtre + la position du clic et décrit l'élément. + # Un seul appel, une seule fois, pendant l'enregistrement. + if not element_text: + # Essayer avec le screenshot fenêtre (contexte complet) + win_screenshot = None + if session_dir and screenshot_id: + win_path = Path(session_dir) / "shots" / f"{screenshot_id}_window.png" + if win_path.is_file(): + win_screenshot = base64.b64encode(win_path.read_bytes()).decode() + # Fallback sur le crop + img_b64 = win_screenshot or anchor_b64 + element_text = _gemma4_read_element( + img_b64, window_title, + click_pos=(click_x, click_y) if win_screenshot else None, + ) + if element_text: + text_source = "vlm" + logger.info("gemma4 a lu l'élément : '%s'", element_text) + # ── 6. Coordonnées normalisées ── by_position = [ round(click_x / screen_w, 6) if screen_w > 0 else 0.0,