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:
Florian
2026-09-05 05:48:13 +02:00
parent 9216f636bd
commit 0b2ff38bfd
22 changed files with 2081 additions and 8 deletions

0
app/ai/__init__.py Normal file
View File

37
app/ai/danger.py Normal file
View File

@@ -0,0 +1,37 @@
"""Detection locale simple de commandes potentiellement destructives, proposees
par l'assistant IA. N'a pas besoin d'etre parfaite : sert a afficher un
avertissement, jamais a bloquer ou executer quoi que ce soit automatiquement."""
from __future__ import annotations
import re
from dataclasses import dataclass
_DANGEROUS_PATTERNS: list[tuple[str, re.Pattern]] = [
("rm -rf (suppression recursive forcee)", re.compile(r"\brm\s+(-\w*\s+)*-\w*[rf]\w*[rf]\w*\b", re.IGNORECASE)),
("mkfs (formatage)", re.compile(r"\bmkfs(\.\w+)?\b", re.IGNORECASE)),
("dd (ecriture bas niveau sur un disque)", re.compile(r"\bdd\s+.*\bif=", re.IGNORECASE | re.DOTALL)),
("wipefs (effacement des signatures de systeme de fichiers)", re.compile(r"\bwipefs\b", re.IGNORECASE)),
("shutdown (extinction)", re.compile(r"\bshutdown\b", re.IGNORECASE)),
("reboot (redemarrage)", re.compile(r"\breboot\b", re.IGNORECASE)),
("iptables : purge des regles", re.compile(r"\biptables\b.*(-F|--flush)\b", re.IGNORECASE)),
("nft : purge des regles", re.compile(r"\bnft\b.*\bflush\b", re.IGNORECASE)),
("DROP DATABASE", re.compile(r"\bDROP\s+DATABASE\b", re.IGNORECASE)),
("DELETE FROM sans WHERE", re.compile(r"\bDELETE\s+FROM\s+\S+\s*;", re.IGNORECASE)),
("docker system prune (suppression en masse)", re.compile(r"\bdocker\s+system\s+prune\b", re.IGNORECASE)),
("zpool destroy", re.compile(r"\bzpool\s+destroy\b", re.IGNORECASE)),
("lvremove (suppression de volume logique)", re.compile(r"\blvremove\b", re.IGNORECASE)),
("vgremove (suppression de groupe de volumes)", re.compile(r"\bvgremove\b", re.IGNORECASE)),
]
@dataclass
class DangerMatch:
label: str
def check_dangerous(command: str) -> list[DangerMatch]:
return [DangerMatch(label) for label, pattern in _DANGEROUS_PATTERNS if pattern.search(command)]
def is_dangerous(command: str) -> bool:
return bool(check_dangerous(command))

154
app/ai/provider.py Normal file
View 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]}"

52
app/ai/redaction.py Normal file
View File

@@ -0,0 +1,52 @@
"""Detection/redaction basique de secrets avant envoi a un fournisseur IA
externe : cles privees SSH, jetons Bearer, cles AWS, en-tetes Authorization,
password=/token=/api_key=, et longues chaines ressemblant a un secret.
Heuristique volontairement simple (comme la detection de commandes
dangereuses) : mieux vaut avertir a tort sur un hash ou un identifiant anodin
que laisser passer un vrai secret."""
from __future__ import annotations
import re
from dataclasses import dataclass
_PATTERNS: list[tuple[str, re.Pattern]] = [
("Cle privee (PEM/OpenSSH)", re.compile(
r"-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED )?PRIVATE KEY-----"
)),
("Jeton Bearer", re.compile(r"\bBearer\s+[A-Za-z0-9\-_.=]{10,}", re.IGNORECASE)),
("Cle d'acces AWS", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
("En-tete Authorization", re.compile(r"\bAuthorization\s*:\s*\S+", re.IGNORECASE)),
("Mot de passe (password=)", re.compile(r"\b(?:password|passwd|pwd)\s*[:=]\s*\S+", re.IGNORECASE)),
("Cle API (api_key=)", re.compile(r"\b(?:api[_-]?key|apikey)\s*[:=]\s*\S+", re.IGNORECASE)),
("Jeton (token=)", re.compile(r"\btoken\s*[:=]\s*\S+", re.IGNORECASE)),
("Chaine longue ressemblant a un secret", re.compile(r"\b[A-Za-z0-9_\-]{32,}\b")),
]
@dataclass
class DetectedSecret:
label: str
excerpt: str
def detect_secrets(text: str) -> list[DetectedSecret]:
"""Renvoie les categories detectees (au plus une occurrence par categorie,
pour un resume lisible), dans l'ordre des motifs (du plus specifique au
plus generique)."""
found: list[DetectedSecret] = []
for label, pattern in _PATTERNS:
match = pattern.search(text)
if match:
excerpt = match.group(0)
if len(excerpt) > 60:
excerpt = excerpt[:57] + "..."
found.append(DetectedSecret(label, excerpt))
return found
def redact(text: str) -> str:
redacted = text
for _label, pattern in _PATTERNS:
redacted = pattern.sub("[MASQUE]", redacted)
return redacted

54
app/ai/service.py Normal file
View File

@@ -0,0 +1,54 @@
"""Execution des requetes IA en arriere-plan : aucun appel reseau ne doit
jamais bloquer le thread Qt."""
from __future__ import annotations
from PySide6.QtCore import QThread, Signal
from app.ai.provider import AIProvider, AIError
KIND_EXPLAIN = "explain"
KIND_DIAGNOSE = "diagnose"
KIND_SOLUTION = "solution"
KIND_GENERATE = "generate"
KIND_ASK = "ask"
KIND_TEST = "test"
class AIRequestWorker(QThread):
"""Une requete = un worker jetable. L'annulation est cooperative : la
requete HTTP en cours (bornee par son timeout) va a son terme, mais le
signal de resultat peut etre ignore par l'appelant (voir AIPanel)."""
finished_ok = Signal(str)
finished_error = Signal(str)
def __init__(self, provider: AIProvider, kind: str, payload: str, context: str = "", parent=None):
super().__init__(parent)
self.provider = provider
self.kind = kind
self.payload = payload
self.context = context
def run(self) -> None:
try:
result = self._execute()
self.finished_ok.emit(result)
except AIError as exc:
self.finished_error.emit(str(exc))
except Exception as exc: # noqa: BLE001 - ne jamais laisser un thread planter silencieusement
self.finished_error.emit(f"Erreur inattendue : {exc}")
def _execute(self) -> str:
if self.kind == KIND_EXPLAIN:
return self.provider.explain_output(self.payload)
if self.kind == KIND_DIAGNOSE:
return self.provider.diagnose(self.payload)
if self.kind == KIND_SOLUTION:
return self.provider.propose_solution(self.payload)
if self.kind == KIND_GENERATE:
return self.provider.generate_command(self.payload)
if self.kind == KIND_ASK:
return self.provider.ask(self.payload, context=self.context)
if self.kind == KIND_TEST:
return self.provider.chat([{"role": "user", "content": "Reponds uniquement 'OK'."}])
raise AIError(f"Type de requete IA inconnu : {self.kind}")

85
app/ai/settings.py Normal file
View File

@@ -0,0 +1,85 @@
"""Configuration de l'assistant IA (optionnel, desactive par defaut).
La cle API n'est jamais stockee ici : seuls les parametres non sensibles
(activation, fournisseur, URL, modele, timeout, temperature) le sont, via
app.app_settings. La cle passe exclusivement par CredentialStore
(app.credentials), jamais dans settings.json/sessions.json/logs."""
from __future__ import annotations
from dataclasses import dataclass
from app.app_settings import get_setting, set_setting
from app.credentials import CredentialStore
from app.ai.provider import AIProvider, OpenAICompatibleProvider
AI_API_KEY_CREDENTIAL = "ai:api_key"
DEFAULT_BASE_URL = "https://api.openai.com/v1"
DEFAULT_PROVIDER_NAME = "OpenAI"
DEFAULT_MODEL = ""
DEFAULT_TIMEOUT = 30.0
DEFAULT_TEMPERATURE = 0.3
@dataclass
class AISettings:
enabled: bool = False
provider_name: str = DEFAULT_PROVIDER_NAME
base_url: str = DEFAULT_BASE_URL
model: str = DEFAULT_MODEL
timeout: float = DEFAULT_TIMEOUT
temperature: float = DEFAULT_TEMPERATURE
def load_ai_settings() -> AISettings:
try:
timeout = float(get_setting("ai_timeout", DEFAULT_TIMEOUT))
except (TypeError, ValueError):
timeout = DEFAULT_TIMEOUT
try:
temperature = float(get_setting("ai_temperature", DEFAULT_TEMPERATURE))
except (TypeError, ValueError):
temperature = DEFAULT_TEMPERATURE
return AISettings(
enabled=bool(get_setting("ai_enabled", False)),
provider_name=get_setting("ai_provider_name", DEFAULT_PROVIDER_NAME) or DEFAULT_PROVIDER_NAME,
base_url=get_setting("ai_base_url", DEFAULT_BASE_URL) or DEFAULT_BASE_URL,
model=get_setting("ai_model", DEFAULT_MODEL) or DEFAULT_MODEL,
timeout=timeout,
temperature=temperature,
)
def save_ai_settings(settings: AISettings) -> None:
set_setting("ai_enabled", settings.enabled)
set_setting("ai_provider_name", settings.provider_name)
set_setting("ai_base_url", settings.base_url)
set_setting("ai_model", settings.model)
set_setting("ai_timeout", settings.timeout)
set_setting("ai_temperature", settings.temperature)
def load_api_key(credential_store: CredentialStore) -> str:
return credential_store.get_secret(AI_API_KEY_CREDENTIAL) or ""
def save_api_key(credential_store: CredentialStore, api_key: str) -> bool:
if api_key:
return credential_store.set_secret(AI_API_KEY_CREDENTIAL, api_key)
credential_store.delete_secret(AI_API_KEY_CREDENTIAL)
return True
def build_provider_from_settings(credential_store: CredentialStore) -> AIProvider | None:
"""None si l'assistant est desactive : point d'appel unique pour tous les
endroits de l'UI qui ont besoin d'un fournisseur pret a l'emploi."""
settings = load_ai_settings()
if not settings.enabled:
return None
return OpenAICompatibleProvider(
base_url=settings.base_url,
api_key=load_api_key(credential_store),
model=settings.model,
timeout=settings.timeout,
temperature=settings.temperature,
)