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>
This commit is contained in:
154
app/ai/provider.py
Normal file
154
app/ai/provider.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Abstraction du fournisseur IA : une API compatible OpenAI (endpoint chat
|
||||
completions), avec URL de base et modele configurables - pas de liste de
|
||||
fournisseurs ou de modeles figee en dur, pour supporter OpenAI, OpenRouter, un
|
||||
proxy interne ou un LLM local exposant la meme API.
|
||||
|
||||
AIProvider porte les 4 usages metier (explain_output/diagnose/generate_command/
|
||||
ask), tous construits au-dessus de chat(), seule methode que doit implementer
|
||||
un fournisseur concret (OpenAICompatibleProvider)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
class AIError(Exception):
|
||||
"""Message deja comprehensible, pret a etre affiche a l'utilisateur."""
|
||||
|
||||
|
||||
_SYSTEM_PROMPT_TROUBLESHOOT = """Tu es un assistant specialise en administration systeme, Linux, Windows,
|
||||
reseau, SSH, Docker, virtualisation et troubleshooting.
|
||||
|
||||
Analyse la sortie terminal fournie par l'utilisateur.
|
||||
|
||||
Explique :
|
||||
1. ce que signifie la sortie ;
|
||||
2. la cause probable ;
|
||||
3. les verifications recommandees ;
|
||||
4. les commandes eventuellement utiles.
|
||||
|
||||
Ne suppose jamais qu'une commande a ete executee.
|
||||
Ne pretends jamais connaitre des elements qui ne sont pas presents.
|
||||
Signale explicitement les hypotheses."""
|
||||
|
||||
_SYSTEM_PROMPT_DIAGNOSE = _SYSTEM_PROMPT_TROUBLESHOOT + """
|
||||
|
||||
Structure ta reponse ainsi :
|
||||
Cause probable
|
||||
Explication
|
||||
Verifications
|
||||
Commandes proposees
|
||||
Niveau de risque"""
|
||||
|
||||
_SYSTEM_PROMPT_COMMAND = """Tu es un assistant specialise en administration systeme, Linux, Windows,
|
||||
reseau, SSH, Docker et virtualisation.
|
||||
|
||||
L'utilisateur decrit ce qu'il veut accomplir en langage naturel. Propose UNE
|
||||
commande (ou une courte suite de commandes) qui repond a la demande, dans un
|
||||
bloc de code, avec une breve explication de ce qu'elle fait.
|
||||
|
||||
Ne suppose jamais le systeme d'exploitation ou l'environnement si l'utilisateur
|
||||
ne l'a pas precise : demande-le, ou propose une variante par systeme si
|
||||
pertinent. La commande proposee ne sera jamais executee automatiquement : elle
|
||||
sera seulement affichee a l'utilisateur pour verification."""
|
||||
|
||||
|
||||
class AIProvider(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def chat(self, messages: list[dict], *, temperature: float | None = None) -> str:
|
||||
"""Renvoie le texte de la reponse. Leve AIError en cas d'echec."""
|
||||
|
||||
def explain_output(self, output: str) -> str:
|
||||
return self.chat([
|
||||
{"role": "system", "content": _SYSTEM_PROMPT_TROUBLESHOOT},
|
||||
{"role": "user", "content": f"Explique cette sortie terminal :\n\n{output}"},
|
||||
])
|
||||
|
||||
def diagnose(self, output: str) -> str:
|
||||
return self.chat([
|
||||
{"role": "system", "content": _SYSTEM_PROMPT_DIAGNOSE},
|
||||
{"role": "user", "content": f"Diagnostique cette erreur :\n\n{output}"},
|
||||
])
|
||||
|
||||
def propose_solution(self, output: str) -> str:
|
||||
return self.chat([
|
||||
{"role": "system", "content": _SYSTEM_PROMPT_TROUBLESHOOT},
|
||||
{"role": "user", "content": (
|
||||
"Propose une solution concrete a ce probleme, avec les commandes "
|
||||
f"eventuellement necessaires :\n\n{output}"
|
||||
)},
|
||||
])
|
||||
|
||||
def generate_command(self, request: str) -> str:
|
||||
return self.chat([
|
||||
{"role": "system", "content": _SYSTEM_PROMPT_COMMAND},
|
||||
{"role": "user", "content": request},
|
||||
])
|
||||
|
||||
def ask(self, question: str, context: str = "") -> str:
|
||||
messages = [{"role": "system", "content": _SYSTEM_PROMPT_TROUBLESHOOT}]
|
||||
if context:
|
||||
messages.append({"role": "user", "content": f"Contexte (sortie terminal selectionnee) :\n{context}"})
|
||||
messages.append({"role": "user", "content": question})
|
||||
return self.chat(messages)
|
||||
|
||||
|
||||
class OpenAICompatibleProvider(AIProvider):
|
||||
def __init__(
|
||||
self, base_url: str, api_key: str, model: str,
|
||||
timeout: float = 30.0, temperature: float = 0.3,
|
||||
):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
self.temperature = temperature
|
||||
|
||||
def chat(self, messages: list[dict], *, temperature: float | None = None) -> str:
|
||||
if not self.base_url:
|
||||
raise AIError("Aucune URL de base configuree pour l'assistant IA.")
|
||||
if not self.model:
|
||||
raise AIError("Aucun modele configure pour l'assistant IA.")
|
||||
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": self.temperature if temperature is None else temperature,
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
request = urllib.request.Request(
|
||||
url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||
raw = response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise AIError(self._describe_http_error(exc)) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
if isinstance(exc.reason, TimeoutError):
|
||||
raise AIError("Le fournisseur IA n'a pas repondu a temps (delai depasse).") from exc
|
||||
raise AIError(f"Impossible de joindre le fournisseur IA : {exc.reason}") from exc
|
||||
|
||||
try:
|
||||
parsed = json.loads(raw.decode("utf-8"))
|
||||
return parsed["choices"][0]["message"]["content"]
|
||||
except (json.JSONDecodeError, KeyError, IndexError, TypeError, UnicodeDecodeError) as exc:
|
||||
raise AIError(f"Reponse du fournisseur IA invalide ou inattendue : {exc}") from exc
|
||||
|
||||
@staticmethod
|
||||
def _describe_http_error(exc: urllib.error.HTTPError) -> str:
|
||||
if exc.code == 401:
|
||||
return "Authentification refusee (401) : verifiez la cle API."
|
||||
if exc.code == 429:
|
||||
return "Trop de requetes (429) : le fournisseur limite le debit, reessayez plus tard."
|
||||
try:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
except Exception: # noqa: BLE001
|
||||
body = ""
|
||||
return f"Erreur du fournisseur IA ({exc.code}) : {(body or exc.reason)[:300]}"
|
||||
Reference in New Issue
Block a user