Files
pyssh-client/tests/test_ai_service.py
Florian 0b2ff38bfd Assistant IA optionnel (analyse de sortie, diagnostic, generation de commande)
Nouvelle couche app/ai/ (provider OpenAI-compatible via urllib, redaction de
secrets, detection de commandes dangereuses, parametres, worker QThread) et son
integration dans chaque onglet terminal via un bouton "Assistant IA" (memes
conventions que le bouton Snippets existant) :

- Expliquer cette sortie / Diagnostiquer cette erreur / Proposer une solution /
  Poser une question... : utilisent la selection courante du terminal, affichees
  dans un panneau lateral (memoire uniquement, jamais journalise) reutilisant le
  splitter comme le panneau SFTP.
- Generer une commande : ne s'execute jamais automatiquement (Copier / Inserer
  dans le terminal sans Entree / Annuler), avec avertissement si la commande
  proposee correspond a un motif destructeur connu.
- Avant tout envoi : avis unique (persist) puis detection de secrets evidents
  (mot de passe, cle, jeton) avec choix de masquer, envoyer tel quel ou annuler.

Desactive par defaut, sans liste de fournisseur figee (URL + modele libres,
compatible OpenAI/OpenRouter/LLM local). La cle API passe exclusivement par
CredentialStore (jamais dans settings.json ni journalisee). Parametrable via
le nouveau menu "Assistant IA > Parametres..." (bouton "Tester la connexion").

Bug trouve par les tests avant commit : un panneau nouvellement insere dans un
QSplitter deja visible est affiche automatiquement par Qt, ce qui inversait le
premier clic sur "Afficher le panneau IA" ; corrige en le masquant explicitement
a sa creation.

9 nouveaux fichiers de test (provider, redaction, dangers, parametres, worker,
confirmations de securite, dialogue de commande, panneau, integration onglet) :
39 verifications, toutes au vert. Suite complete (31 fichiers) executee sans
regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 05:48:13 +02:00

169 lines
5.9 KiB
Python

"""AIRequestWorker : execution d'une requete IA dans un vrai QThread en
arriere-plan, jamais sur le thread Qt principal - avec dispatch correct selon
le type de requete et remontee des erreurs via signal plutot qu'exception."""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import threading
from PySide6.QtCore import QCoreApplication
from PySide6.QtWidgets import QApplication
from app.ai.provider import AIProvider, AIError
from app.ai.service import (
AIRequestWorker, KIND_EXPLAIN, KIND_DIAGNOSE, KIND_SOLUTION, KIND_GENERATE, KIND_ASK, KIND_TEST,
)
app = QApplication.instance() or QApplication(sys.argv)
class RecordingProvider(AIProvider):
"""Enregistre la methode appelee et le thread d'execution, pour verifier
a la fois le dispatch correct et le fait que ca tourne hors du thread principal."""
def __init__(self, fail_with: Exception | None = None):
self.calls = []
self.threads = []
self.fail_with = fail_with
def chat(self, messages, *, temperature=None):
self.calls.append(("chat", messages))
self.threads.append(threading.current_thread())
if self.fail_with:
raise self.fail_with
return "reponse-chat"
def explain_output(self, output):
self.calls.append(("explain_output", output))
self.threads.append(threading.current_thread())
if self.fail_with:
raise self.fail_with
return "reponse-explain"
def diagnose(self, output):
self.calls.append(("diagnose", output))
self.threads.append(threading.current_thread())
if self.fail_with:
raise self.fail_with
return "reponse-diagnose"
def propose_solution(self, output):
self.calls.append(("propose_solution", output))
self.threads.append(threading.current_thread())
if self.fail_with:
raise self.fail_with
return "reponse-solution"
def generate_command(self, request):
self.calls.append(("generate_command", request))
self.threads.append(threading.current_thread())
if self.fail_with:
raise self.fail_with
return "reponse-generate"
def ask(self, question, context=""):
self.calls.append(("ask", (question, context)))
self.threads.append(threading.current_thread())
if self.fail_with:
raise self.fail_with
return "reponse-ask"
def run_worker(worker, timeout_ms=5000):
results = {}
def on_ok(text):
results["ok"] = text
def on_error(text):
results["error"] = text
worker.finished_ok.connect(on_ok)
worker.finished_error.connect(on_error)
worker.start()
worker.wait(timeout_ms)
# laisser la boucle d'evenements livrer les signaux en file d'attente (cross-thread)
QCoreApplication.processEvents()
return results
main_thread = threading.current_thread()
print("=== 1. Dispatch correct par 'kind' (explain/diagnose/solution/generate/ask/test) ===")
cases = [
(KIND_EXPLAIN, "explain_output", "sortie X", "reponse-explain"),
(KIND_DIAGNOSE, "diagnose", "erreur Y", "reponse-diagnose"),
(KIND_SOLUTION, "propose_solution", "probleme Z", "reponse-solution"),
(KIND_GENERATE, "generate_command", "trouver les gros fichiers", "reponse-generate"),
]
for kind, expected_method, payload, expected_reply in cases:
provider = RecordingProvider()
worker = AIRequestWorker(provider, kind, payload)
results = run_worker(worker)
print(f" kind={kind!r} -> methode appelee: {provider.calls[0][0]!r}, resultat: {results!r}")
assert provider.calls == [(expected_method, payload)]
assert results.get("ok") == expected_reply
assert "error" not in results
print(" OK")
print()
print("=== 2. KIND_ASK transmet aussi le contexte ===")
provider = RecordingProvider()
worker = AIRequestWorker(provider, KIND_ASK, "pourquoi ?", context="ligne de contexte")
results = run_worker(worker)
print(" appel:", provider.calls[0], "resultat:", results)
assert provider.calls == [("ask", ("pourquoi ?", "ligne de contexte"))]
assert results.get("ok") == "reponse-ask"
print(" OK")
print()
print("=== 3. KIND_TEST appelle chat() avec un message minimal ===")
provider = RecordingProvider()
worker = AIRequestWorker(provider, KIND_TEST, "")
results = run_worker(worker)
print(" appel:", provider.calls[0], "resultat:", results)
assert provider.calls[0][0] == "chat"
assert results.get("ok") == "reponse-chat"
print(" OK")
print()
print("=== 4. AIError est capturee et remontee via finished_error, jamais levee ===")
provider = RecordingProvider(fail_with=AIError("cle API invalide"))
worker = AIRequestWorker(provider, KIND_EXPLAIN, "sortie")
results = run_worker(worker)
print(" resultat:", results)
assert results.get("error") == "cle API invalide"
assert "ok" not in results
print(" OK")
print()
print("=== 5. Exception inattendue capturee aussi (jamais de crash silencieux du thread) ===")
provider = RecordingProvider(fail_with=RuntimeError("boum"))
worker = AIRequestWorker(provider, KIND_EXPLAIN, "sortie")
results = run_worker(worker)
print(" resultat:", results)
assert "error" in results and "boum" in results["error"]
print(" OK")
print()
print("=== 6. Type de requete inconnu -> erreur claire, pas de plantage ===")
provider = RecordingProvider()
worker = AIRequestWorker(provider, "kind-inexistant", "x")
results = run_worker(worker)
print(" resultat:", results)
assert "error" in results and "inconnu" in results["error"].lower()
print(" OK")
print()
print("=== 7. L'execution se fait bien dans un thread separe du thread principal ===")
provider = RecordingProvider()
worker = AIRequestWorker(provider, KIND_EXPLAIN, "sortie")
run_worker(worker)
print(" thread principal:", main_thread, "thread d'execution:", provider.threads[0])
assert provider.threads[0] is not main_thread
assert isinstance(worker, __import__("PySide6.QtCore", fromlist=["QThread"]).QThread)
print(" OK")
print()
print("AI SERVICE : TOUS LES TESTS OK")