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:
0
app/ai/__init__.py
Normal file
0
app/ai/__init__.py
Normal file
37
app/ai/danger.py
Normal file
37
app/ai/danger.py
Normal 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
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]}"
|
||||
52
app/ai/redaction.py
Normal file
52
app/ai/redaction.py
Normal 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
54
app/ai/service.py
Normal 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
85
app/ai/settings.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user