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.
286 lines
8.7 KiB
Python
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()
|