Rapport d'analyse technique — Alfred v2 (refacto) · Juin 2026
Architecture Dépendances CUDA Telegram UV pyproject.tomlSuite au refactoring intégral d'Alfred (716 lignes effacées, 804 insérées, passage d'un monolithe de 1 600 lignes à 4 fichiers modulaires), deux problèmes techniques bloquants ont été identifiés. Ce rapport présente une analyse détaillée de chaque problème, avec les points de code concernés, l'impact, et des recommandations concrètes de correction.
💡 Objectif de ce rapport : documenter les deux problèmes identifiés (bot Telegram obligatoire, dépendances CUDA) et proposer des solutions architecturales pour rendre Alfred déployable sur toute machine, avec ou sans GPU, avec ou sans Telegram.
Malgré le refactoring qui a introduit le pattern Channel pour abstraire les canaux de communication, le code central d'Alfred (alfred.py) conserve encore des dépendances directes à la bibliothèque python-telegram-bot qui empêchent un déploiement sans Telegram.
alfred.py ligne 18 : from telegram import Bot, Update
alfred.py ligne 19 : from telegram.request import HTTPXRequest
Impact : Ces imports sont exécutés au chargement du module, avant toute vérification de configuration. Si python-telegram-bot n'est pas installé, l'import plante immédiatement et le serveur ne démarre pas. C'est un blocage absolu.
alfred.py ligne 51 : bot = Bot(token=TELEGRAM_TOKEN or "", request=HTTPXRequest(connect_timeout=60.0, read_timeout=60.0))
Impact : Même avec un token vide, l'objet Bot est instancié immédiatement. Si TELEGRAM_TOKEN est None (non défini dans .env), le token passé est une chaîne vide. Le bot est créé mais inutilisable. Le vrai problème est que l'import de la bibliothèque est toujours requis.
alfred.py ligne 427 : await register_tools(bot, call_llm_subagent, CONFIG, LAST_SOURCE, WS_CONNECTIONS, model_service)
alfred.py ligne 429 : async with bot:
Impact : Le bot est injecté dans mcp_server.py via register_tools(), ce qui permet aux outils MCP de l'utiliser (pour les commandes /link_xxx et /confirm_link). Le contexte async with bot: gère le cycle de vie du bot (ouverture/fermeture de la session HTTP). Si le bot n'est pas défini, ces lignes plantent.
alfred.py ligne 433 : @app.post("/webhook/telegram")
Impact : L'endpoint webhook Telegram est toujours enregistré dans FastAPI, même si TELEGRAM_TOKEN est vide. Si Telegram envoie des requêtes à cet endpoint (parce que le webhook est configuré côté Telegram), le traitement échoue silencieusement car Update.de_json() nécessite un bot valide.
alfred.py ligne 383 : channel = TelegramChannel(bot, mcp) dans trigger_heartbeat()
alfred.py ligne 327 : await TelegramChannel(bot, mcp).send_message(...) dans /link_xxx
alfred.py ligne 335 : await WebSocketChannel(WS_CONNECTIONS[chat_id], mcp).send_message(...) dans /confirm_link
Impact : Le heartbeat crée systématiquement un TelegramChannel pour chaque utilisateur. Les commandes de liaison croisée (/link_xxx et /confirm_link) utilisent directement TelegramChannel(bot, mcp) en dur. Ces fonctionnalités sont inutiles sans Telegram, mais leur code bloque l'import si la bibliothèque n'est pas installée.
Architecture actuelle (bloquante)
Architecture cible (optionnelle)
Remplacer les imports globaux par des imports conditionnels :
# alfred.py — remplacer les lignes 18-19
# Imports conditionnels
if TELEGRAM_TOKEN:
from telegram import Bot, Update
from telegram.request import HTTPXRequest
TELEGRAM_AVAILABLE = True
else:
Bot = None
Update = None
TELEGRAM_AVAILABLE = False
Cela permet au serveur de démarrer sans python-telegram-bot installé. Les fonctionnalités Telegram sont désactivées gracieusement.
Modifier lifespan() pour ne gérer le bot que s'il existe :
@asynccontextmanager
async def lifespan(app: FastAPI):
# ... chargement historique ...
if TELEGRAM_AVAILABLE:
await register_tools(bot, call_llm_subagent, CONFIG, LAST_SOURCE, WS_CONNECTIONS, model_service)
else:
await register_tools(None, call_llm_subagent, CONFIG, LAST_SOURCE, WS_CONNECTIONS, model_service)
heartbeat_task = asyncio.create_task(heartbeat_loop())
unload_task = asyncio.create_task(model_service.unload_loop())
if TELEGRAM_AVAILABLE:
async with bot:
yield
else:
yield
heartbeat_task.cancel()
unload_task.cancel()
await client.aclose()
Monter le webhook uniquement si le token est défini :
if TELEGRAM_AVAILABLE:
@app.post("/webhook/telegram")
async def telegram_endpoint(http_request: Request, background_tasks: BackgroundTasks):
# ... code existant ...
pass
Ou utiliser une route conditionnelle dans app.router.routes après la création de l'app.
Modifier trigger_heartbeat() pour utiliser tous les canaux actifs, pas seulement Telegram :
async def trigger_heartbeat(chat_id: str):
hb_file = Path(f"alfred-data/{chat_id}/heartbeat.md")
if not hb_file.exists():
return
if chat_id in ACTIVE_TASKS:
logger.info(f"[{chat_id}] Heartbeat ignoré : une tâche est déjà active.")
return
channels = []
if TELEGRAM_AVAILABLE:
channels.append(TelegramChannel(bot, mcp))
# Ajouter WebSocketChannel si des connections WebSocket existent
for channel in channels:
request = ChannelRequest(chat_id, hb_file.read_text(encoding="utf-8"))
await process_request(channel, request)
Dans mcp_server.py, les commandes /link_xxx et /confirm_link doivent être enregistrées uniquement si bot est disponible. Le register_tools() doit accepter bot=None et filtrer les outils dépendants de Telegram.
uv sync sur machines sans GPU
Le deuxième problème est plus subtil mais tout aussi bloquant. Même si ModelService supporte un mode CPU, les dépendances Python liées aux modèles IA sont importées au niveau du module, ce qui force uv sync à installer des packages incompatibles avec les machines sans GPU NVIDIA.
model_service.py : import torch
Par défaut, PyTorch installe le wheel CUDA (~2-3 Go). Sur une machine sans GPU NVIDIA, uv sync peut échouer si le wheel CUDA n'est pas compatible, ou installer une version CPU qui manque de fonctionnalités.
Impact : Installation bloquante sur Raspberry Pi, machines Apple Silicon, ou toute machine sans drivers NVIDIA.
model_service.py : from diffusers import AutoPipelineForText2Image
diffusers dépend de torch et de transformers. Il est lourd et n'est utile que pour la génération d'images.
Impact : Installation inutile si l'utilisateur ne veut que du texte ou de la synthèse vocale.
model_service.py : from omnivoice import OmniVoice
Package propriétaire qui peut avoir des dépendances CUDA implicites.
Impact : Installation bloquante si le package n'est pas compatible avec l'architecture cible.
model_service.py : from faster_whisper import WhisperModel
faster-whisper dépend de ctranslate2 qui peut chercher des bibliothèques CUDA au runtime.
Impact : Installation lourde (~500 Mo) pour une fonctionnalité optionnelle.
Flux actuel (bloquant)
Flux cible (flexible)
Séparer les dépendances en extras optionnels. L'utilisateur installe uniquement ce dont il a besoin :
# pyproject.toml
[project]
dependencies = [
"fastapi>=0.110.0",
"uvicorn>=0.29.0",
"httpx>=0.27.0",
"python-dotenv>=1.0.0",
"jinja2>=3.1.0",
"asyncio>=3.4.3",
"aiofiles>=24.1.0",
"soundfile>=0.12.1",
"static-ffmpeg>=2.4",
"websockets>=13.0",
"PyJWT>=2.8.0",
"python-multipart>=0.0.9",
"orjson>=3.10.0",
"mcp>=1.0.0",
]
[project.optional-dependencies]
image = [
"torch>=2.3.0",
"diffusers>=0.30.0",
"transformers>=4.41.0",
"accelerate>=0.33.0",
"sentencepiece>=0.2.0",
]
audio = [
"omnivoice>=1.0.0",
"soundfile>=0.12.1",
"faster-whisper>=1.0.0",
"ctranslate2>=4.3.0",
]
telegram = [
"python-telegram-bot[httpx]>=21.7",
]
full = [
"alfred[image,audio,telegram]",
]
[tool.uv]
# Configuration UV spécifique
compile-bytecode = true
Installation minimale : uv sync (juste le serveur web)
Installation IA : uv sync --extra full (tout)
Installation ciblée : uv sync --extra image (juste la génération d'images)
Déplacer les imports lourds à l'intérieur des méthodes qui les utilisent, pas au niveau du module :
# model_service.py
class ModelService:
def __init__(self, device="CPU", timeout=900):
self._models = {}
self._last_used = {}
self._device = device
self.timeout = timeout
self._loaders = {
"image_generation": self._load_sdxl_turbo,
"audio_generation": self._load_omnivoice,
"audio_transcription": self._load_whisper_medium,
}
async def _load_sdxl_turbo(self):
# Import lazy — ne plante que si on appelle cette méthode
try:
import torch
from diffusers import AutoPipelineForText2Image
except ImportError as e:
raise RuntimeError("Package 'torch' et 'diffusers' requis pour la génération d'images. Installez : uv pip install alfred[image]") from e
# ... reste du chargement ...
async def _load_omnivoice(self):
try:
from omnivoice import OmniVoice
except ImportError as e:
raise RuntimeError("Package 'omnivoice' requis pour la synthèse vocale. Installez : uv pip install alfred[audio]") from e
async def _load_whisper_medium(self):
try:
from faster_whisper import WhisperModel
except ImportError as e:
raise RuntimeError("Package 'faster-whisper' requis pour la transcription. Installez : uv pip install alfred[audio]") from e
Ajouter des variables d'environnement pour contrôler quelles fonctionnalités sont activées :
# .env
# Fonctionnalités
ENABLE_IMAGE_GEN=true
ENABLE_AUDIO_GENERATION=true
ENABLE_AUDIO_TRANSCRIPTION=true
ENABLE_TELEGRAM=true
# Si ENABLE_TELEGRAM=false, le bot n'est jamais instancié
# Si ENABLE_IMAGE_GEN=false, torch/diffusers ne sont jamais importés
# Si ENABLE_AUDIO_GENERATION=false, omnivoice n'est jamais importé
# Si ENABLE_AUDIO_TRANSCRIPTION=false, faster-whisper n'est jamais importé
Dans model_service.py, le constructeur vérifie ces flags :
class ModelService:
def __init__(self, device="CPU", timeout=900):
self._models = {}
self._last_used = {}
self._device = device
self.timeout = timeout
# Enregistrer les loaders conditionnellement
if os.getenv("ENABLE_IMAGE_GEN", "false").lower() == "true":
self._loaders["image_generation"] = self._load_sdxl_turbo
if os.getenv("ENABLE_AUDIO_GENERATION", "false").lower() == "true":
self._loaders["audio_generation"] = self._load_omnivoice
if os.getenv("ENABLE_AUDIO_TRANSCRIPTION", "false").lower() == "true":
self._loaders["audio_transcription"] = self._load_whisper_medium
Dans pyproject.toml, utiliser PyTorch CPU par défaut et un extra CUDA :
[project.optional-dependencies]
torch-cpu = [
"torch>=2.3.0",
]
torch-cuda = [
"torch>=2.3.0",
"torchvision>=0.18.0",
"torchaudio>=2.3.0",
]
# Par défaut, on installe le CPU
[tool.uv]
default-extras = ["torch-cpu"]
Ou utiliser les indices PyTorch officiels :
# Dans pyproject.toml
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
# Dans les dépendances
"torch>=2.3.0; extra == 'image' and not sys_platform == 'darwin'"]
| Priorité | Action | Fichier(s) | Complexité | Impact |
|---|---|---|---|---|
| 🔴 Critique | Imports conditionnels Telegram | alfred.py (lignes 18-19) |
Facile | Permet le démarrage sans Telegram |
| 🔴 Critique | Bot conditionnel dans lifespan() | alfred.py (lignes 427-431) |
Facile | Évite le crash au démarrage |
| 🔴 Critique | Extras pyproject.toml (torch, diffusers, omnivoice, faster-whisper) | pyproject.toml |
Moyen | Permet uv sync sans GPU |
| 🟡 Important | Imports lazy dans model_service.py | model_service.py |
Moyen | Évite le crash à l'exécution |
| 🟡 Important | Feature flags .env | .env + model_service.py |
Facile | Contrôle granulaire des fonctionnalités |
| 🟢 Secondaire | Heartbeat multi-canal | alfred.py (trigger_heartbeat) |
Moyen | Heartbeat sur tous les canaux actifs |
| 🟢 Secondaire | Webhook conditionnel | alfred.py (telegram_endpoint) |
Facile | Webhook uniquement si Telegram activé |
| 🟢 Secondaire | Outils MCP conditionnels | mcp_server.py (register_tools) |
Facile | Outils Telegram uniquement si bot disponible |
💡 Approche recommandée : Combiner les solutions 1 (extras pyproject.toml) + 2 (imports lazy) + 3 (feature flags). Cela donne un contrôle total : l'utilisateur choisit ce qu'il installe (uv sync, uv sync --extra image, uv sync --extra full) et ce qu'il active (.env), et le serveur démarre toujours, même avec zéro fonctionnalité IA activée.
⚠️ Point d'attention : Le ModelService doit gérer gracieusement le cas où aucun modèle n'est disponible (retourner une erreur claire au lieu de planter). Le heartbeat doit aussi vérifier qu'au moins un canal est actif avant de tenter d'envoyer un message.