Files
rpa_vision_v3/tests/test_image_chat_cli.py
Dom cac965cef9 test(coords+capture): coords write-only gap (10 tests) + capture I/O + image_chat_cli
test_coords_consumption_gap.py documents 3 structural gaps where
NavigateCoords are written but never consumed. test_capture_io.py and
test_image_chat_cli.py cover capture and chat CLI paths.
2026-07-02 13:01:49 +02:00

286 lines
8.7 KiB
Python

#!/usr/bin/env python3
"""
Chat interactif en ligne de commande avec gemma4:26b via Ollama.
Usage interactif :
python tests/test_image_chat_cli.py
# puis taper des questions sur l'image fournie
Usage one-shot :
python tests/test_image_chat_cli.py /chemin/vers/image.png "Que vois-tu ?"
Usage avec modèle différent :
python tests/test_image_chat_cli.py --model qwen3-vl:8b image.png
Le script utilise l'API Ollama directement (via la lib `ollama` du projet,
`ollama==0.6.1` dans requirements.txt).
"""
import argparse
import base64
import sys
from pathlib import Path
try:
import ollama
except ImportError:
print("ERREUR : la librairie 'ollama' n'est pas installée.")
print("Installez-la avec : pip install ollama")
sys.exit(1)
DEFAULT_MODEL = "gemma4:26b"
def encode_image(image_path: str) -> str:
"""Encode une image en base64 pour l'API Ollama."""
path = Path(image_path)
if not path.exists():
print(f"ERREUR : le fichier '{image_path}' n'existe pas.")
sys.exit(1)
if not path.is_file():
print(f"ERREUR : '{image_path}' n'est pas un fichier.")
sys.exit(1)
with open(path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
def get_client(host: str):
"""Renvoie un client Ollama configuré pour l'hôte donné."""
return ollama.Client(host=host)
def check_ollama_running(host: str = "http://localhost:11434") -> bool:
"""Vérifie que le serveur Ollama est accessible."""
try:
client = get_client(host)
client.list()
return True
except Exception as e:
print(f"ERREUR : impossible de joindre Ollama sur {host}")
print(f"Détail : {e}")
print()
print("Assurez-vous qu'Ollama est lancé :")
print(" ollama serve")
return False
def check_model_available(model: str, host: str = "http://localhost:11434") -> bool:
"""Vérifie que le modèle est disponible dans Ollama."""
try:
client = get_client(host)
tags = client.list()
# ollama.list() retourne un ListResponse avec un attribut 'models'
models = getattr(tags, "models", [])
model_names = []
for m in models:
if isinstance(m, dict):
model_names.append(m.get("name", ""))
else:
model_names.append(getattr(m, "name", str(m)))
# Correspondance exacte ou préfixe
matched = [name for name in model_names if model in name]
if matched:
return True
else:
print(f"AVERTISSEMENT : modèle '{model}' non trouvé dans Ollama.")
print(f"Modèles disponibles : {', '.join(model_names) or '(aucun)'}")
print()
print(f"Pour le télécharger :")
print(f" ollama pull {model}")
return False
except Exception as e:
print(f"ERREUR : impossible de lister les modèles : {e}")
return False
def chat_with_image(image_path: str, model: str, host: str = "http://localhost:11434") -> None:
"""Mode interactif : charge l'image une fois, puis pose des questions."""
client = get_client(host)
image_b64 = encode_image(image_path)
print(f"🖼️ Image chargée : {image_path}")
print(f"🤖 Modèle : {model}")
print(f"🔗 Ollama : {host}")
print()
print("Mode interactif — tapez vos questions (ou 'exit'/'quit' pour sortir)")
print("Tapez '/image /chemin/nouvelle.png' pour changer d'image")
print("-" * 60)
# Historique de conversation (sans l'image à chaque fois pour économiser la mémoire)
messages = []
while True:
try:
question = input("\nVous > ").strip()
except (EOFError, KeyboardInterrupt):
print("\n👋 Au revoir !")
break
if not question:
continue
if question.lower() in ("exit", "quit", "q"):
print("👋 Au revoir !")
break
# Changement d'image
if question.startswith("/image "):
new_path = question[len("/image "):].strip()
try:
image_b64 = encode_image(new_path)
image_path = new_path
# Réinitialiser l'historique car image différente
messages = []
print(f"🖼️ Nouvelle image : {new_path}")
except SystemExit:
pass
continue
# Construire le message user avec l'image au premier tour
# Ensuite, l'image n'est ré-envoyée que si l'historique est vide
has_image_in_context = any(
isinstance(m.get("images"), list) and len(m["images"]) > 0
for m in messages
)
user_msg = {"role": "user", "content": question}
if not has_image_in_context:
# Première question ou image changée — inclure l'image
user_msg["images"] = [image_b64]
messages.append(user_msg)
print(f"🤖 Réponse ({model})...", end=" ", flush=True)
try:
response = client.chat(
model=model,
messages=messages,
stream=True,
options={
"temperature": 0.2,
"num_predict": 2048,
},
)
full_response = ""
print() # nouvelle ligne après le "..."
for chunk in response:
content = chunk.get("message", {}).get("content", "")
if content:
print(content, end="", flush=True)
full_response += content
print() # retour à la ligne après la réponse
messages.append({"role": "assistant", "content": full_response})
except Exception as e:
print(f"\n❌ Erreur : {e}")
# Retirer le dernier message user en cas d'erreur
messages.pop()
def one_shot(image_path: str, question: str, model: str, host: str = "http://localhost:11434") -> None:
"""Mode one-shot : une question, une réponse."""
client = get_client(host)
image_b64 = encode_image(image_path)
messages = [
{"role": "user", "content": question, "images": [image_b64]},
]
try:
response = client.chat(
model=model,
messages=messages,
stream=True,
options={
"temperature": 0.2,
"num_predict": 2048,
},
)
print(f"🤖 {model}'{question}'\n")
for chunk in response:
content = chunk.get("message", {}).get("content", "")
if content:
print(content, end="", flush=True)
print()
except Exception as e:
print(f"❌ Erreur : {e}")
sys.exit(1)
def main() -> None:
parser = argparse.ArgumentParser(
description="Chat interactif avec une image via Ollama (gemma4:26b par défaut)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Exemples :
# Mode interactif avec une image
python tests/test_image_chat_cli.py screenshot.png
# Mode one-shot (question directe)
python tests/test_image_chat_cli.py screenshot.png "Quels boutons vois-tu ?"
# Avec un autre modèle
python tests/test_image_chat_cli.py --model qwen3-vl:8b screenshot.png
# Ollama sur une machine distante
python tests/test_image_chat_cli.py --host http://dgx:11434 screenshot.png
""",
)
parser.add_argument(
"image",
nargs="?",
help="Chemin vers l'image à analyser",
)
parser.add_argument(
"question",
nargs="?",
default=None,
help="Question one-shot (si absent → mode interactif)",
)
parser.add_argument(
"--model",
default=DEFAULT_MODEL,
help=f"Modèle Ollama à utiliser (défaut: {DEFAULT_MODEL})",
)
parser.add_argument(
"--host",
default="http://localhost:11434",
help="URL du serveur Ollama (défaut: http://localhost:11434)",
)
args = parser.parse_args()
# Vérifications préalables
if not check_ollama_running(args.host):
sys.exit(1)
if not check_model_available(args.model, args.host):
sys.exit(1)
if not args.image:
print("Utilisation interactive — veuillez fournir le chemin d'une image.")
print()
print("Usage :")
print(f" python {sys.argv[0]} /chemin/vers/image.png")
print(f" python {sys.argv[0]} /chemin/vers/image.png \"Votre question\"")
print()
parser.print_help()
sys.exit(1)
if args.question:
# Mode one-shot
one_shot(args.image, args.question, args.model, args.host)
else:
# Mode interactif
chat_with_image(args.image, args.model, args.host)
if __name__ == "__main__":
main()