Upload 8 files
Browse files- Dockerfile +24 -0
- README.md +150 -6
- app.py +79 -0
- bot.py +283 -0
- model.pt +3 -0
- model.py +253 -0
- requirements.txt +3 -0
- vocab.json +1 -0
Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dockerfile pour Hugging Face Spaces (SDK: docker)
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Dépendances système minimales
|
| 7 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 8 |
+
curl \
|
| 9 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
COPY . .
|
| 15 |
+
|
| 16 |
+
# HF Spaces (Docker SDK) attend que le conteneur écoute sur le port 7860
|
| 17 |
+
ENV PORT=7860
|
| 18 |
+
EXPOSE 7860
|
| 19 |
+
|
| 20 |
+
# Utilisateur non-root recommandé par HF Spaces
|
| 21 |
+
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
| 22 |
+
USER appuser
|
| 23 |
+
|
| 24 |
+
CMD ["python", "app.py"]
|
README.md
CHANGED
|
@@ -1,11 +1,155 @@
|
|
| 1 |
---
|
| 2 |
-
title: Hilal Ai
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
-
license: mit
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Hilal Ai Telegram Bot
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Hilal Ai — Bot Telegram hébergé sur Hugging Face Spaces (gratuit)
|
| 12 |
+
|
| 13 |
+
Ce Space fait tourner en continu un bot Telegram propulsé par un modèle
|
| 14 |
+
Transformer décodeur (style GPT) entraîné from scratch au niveau caractère.
|
| 15 |
+
|
| 16 |
+
## ⚠️ Comprendre les limites du tier gratuit AVANT de déployer
|
| 17 |
+
|
| 18 |
+
Le tier **CPU Basic** de HF Spaces est gratuit mais se met en veille après
|
| 19 |
+
~48h sans "visite" sur la page web du Space. Un bot Telegram en polling n'a
|
| 20 |
+
pas de visiteurs web naturellement, donc ce repo inclut :
|
| 21 |
+
- un petit serveur Flask (`app.py`) qui répond sur `/` et `/ping`
|
| 22 |
+
- des instructions ci-dessous pour qu'un service externe gratuit (UptimeRobot
|
| 23 |
+
ou GitHub Actions) ping cette URL régulièrement et empêche la mise en veille
|
| 24 |
+
|
| 25 |
+
C'est une solution **bricolée mais fonctionnelle**. Elle n'est pas garantie à
|
| 26 |
+
100% par Hugging Face (ce n'est pas un usage "officiellement prévu" du tier
|
| 27 |
+
gratuit) — si tu veux une garantie de disponibilité totale, il faudra upgrader
|
| 28 |
+
le hardware du Space (~9$/mois) qui élimine la mise en veille.
|
| 29 |
+
|
| 30 |
+
## Fichiers du repo
|
| 31 |
+
|
| 32 |
+
```
|
| 33 |
+
.
|
| 34 |
+
├── Dockerfile # image Docker du Space (SDK: docker)
|
| 35 |
+
├── app.py # serveur web de ping + lance bot.py dans un thread
|
| 36 |
+
├── bot.py # logique du bot Telegram
|
| 37 |
+
├── model.py # architecture MiniGPT + tokenizer char-level
|
| 38 |
+
├── requirements.txt
|
| 39 |
+
├── .gitattributes # config Git LFS pour model.pt
|
| 40 |
+
├── model.pt # TES poids entraînés (à uploader, voir ci-dessous)
|
| 41 |
+
└── vocab.json # TON vocabulaire char-level (voir ci-dessous)
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
## ⚠️ vocab.json est OBLIGATOIRE pour des réponses cohérentes
|
| 45 |
+
|
| 46 |
+
Le tokenizer char-level a besoin du mapping exact caractère→indice utilisé
|
| 47 |
+
pendant l'entraînement sur Colab. Sans lui, le modèle se chargera sans
|
| 48 |
+
erreur mais générera des réponses incohérentes (les embeddings ne
|
| 49 |
+
correspondront pas aux bons caractères).
|
| 50 |
+
|
| 51 |
+
Si tu as encore accès à ton notebook Colab ou ton corpus d'entraînement :
|
| 52 |
+
```python
|
| 53 |
+
import json
|
| 54 |
+
chars = sorted(list(set(text))) # `text` = ton corpus d'entraînement original
|
| 55 |
+
with open("vocab.json", "w", encoding="utf-8") as f:
|
| 56 |
+
json.dump({"chars": chars}, f, ensure_ascii=False)
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
## Étape 1 — Créer le Space sur Hugging Face
|
| 60 |
+
|
| 61 |
+
1. Va sur https://huggingface.co/new-space (tu es déjà connecté à ton compte)
|
| 62 |
+
2. Choisis un nom (ex: `hilal-ai-bot`)
|
| 63 |
+
3. SDK : sélectionne **Docker** (pas Gradio)
|
| 64 |
+
4. Visibilité : Public ou Private selon ta préférence
|
| 65 |
+
5. Clique sur "Create Space"
|
| 66 |
+
|
| 67 |
+
## Étape 2 — Ajouter le token Telegram comme secret
|
| 68 |
+
|
| 69 |
+
Dans la page de ton Space : **Settings → Repository secrets → New secret**
|
| 70 |
+
- Nom : `TELEGRAM_BOT_TOKEN`
|
| 71 |
+
- Valeur : le token obtenu via @BotFather sur Telegram
|
| 72 |
+
|
| 73 |
+
Ne mets jamais ce token directement dans le code ou dans un fichier commité.
|
| 74 |
+
|
| 75 |
+
## Étape 3 — Pousser le code sur le Space
|
| 76 |
+
|
| 77 |
+
Hugging Face Spaces fonctionne comme un repo Git classique.
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
# Installer git-lfs si pas déjà fait (pour model.pt)
|
| 81 |
+
git lfs install
|
| 82 |
+
|
| 83 |
+
# Cloner le repo vide de ton Space (remplace USERNAME et SPACE_NAME)
|
| 84 |
+
git clone https://huggingface.co/spaces/USERNAME/SPACE_NAME
|
| 85 |
+
cd SPACE_NAME
|
| 86 |
+
|
| 87 |
+
# Copier tous les fichiers de ce dossier dedans :
|
| 88 |
+
# Dockerfile, app.py, bot.py, model.py, requirements.txt, .gitattributes,
|
| 89 |
+
# model.pt, vocab.json
|
| 90 |
+
|
| 91 |
+
git lfs track "*.pt"
|
| 92 |
+
git add .gitattributes
|
| 93 |
+
git add .
|
| 94 |
+
git commit -m "Déploiement initial Hilal Ai"
|
| 95 |
+
git push
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
HF va automatiquement builder l'image Docker. Tu peux suivre les logs de
|
| 99 |
+
build et d'exécution dans l'onglet **Logs** de ton Space.
|
| 100 |
+
|
| 101 |
+
## Étape 4 — Vérifier que le bot tourne
|
| 102 |
+
|
| 103 |
+
1. Va sur l'URL de ton Space : `https://huggingface.co/spaces/USERNAME/SPACE_NAME`
|
| 104 |
+
2. L'app Flask doit répondre avec `{"bot_started": true, ...}` sur la page
|
| 105 |
+
3. Sur Telegram, cherche ton bot et envoie `/start`
|
| 106 |
+
|
| 107 |
+
## Étape 5 — Empêcher la mise en veille (keep-alive externe)
|
| 108 |
+
|
| 109 |
+
L'URL publique de ton Space (format direct, sans iframe) est généralement :
|
| 110 |
+
```
|
| 111 |
+
https://USERNAME-SPACE_NAME.hf.space/ping
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
### Option A — UptimeRobot (recommandé, zéro code)
|
| 115 |
+
1. Crée un compte gratuit sur https://uptimerobot.com
|
| 116 |
+
2. Ajoute un nouveau monitor de type "HTTP(s)"
|
| 117 |
+
3. URL : `https://USERNAME-SPACE_NAME.hf.space/ping`
|
| 118 |
+
4. Intervalle : 30 minutes (largement suffisant pour rester sous 48h)
|
| 119 |
+
|
| 120 |
+
### Option B — GitHub Actions (si tu préfères tout garder versionné)
|
| 121 |
+
Crée `.github/workflows/keep-alive.yml` dans un repo GitHub que tu contrôles :
|
| 122 |
+
|
| 123 |
+
```yaml
|
| 124 |
+
name: Keep Hilal Ai Space Alive
|
| 125 |
+
on:
|
| 126 |
+
schedule:
|
| 127 |
+
- cron: '0 */6 * * *' # toutes les 6 heures
|
| 128 |
+
workflow_dispatch:
|
| 129 |
+
jobs:
|
| 130 |
+
ping:
|
| 131 |
+
runs-on: ubuntu-latest
|
| 132 |
+
steps:
|
| 133 |
+
- name: Ping Space
|
| 134 |
+
run: curl -f https://USERNAME-SPACE_NAME.hf.space/ping
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
## Variables d'environnement disponibles (Secrets ou Variables du Space)
|
| 138 |
+
|
| 139 |
+
| Variable | Défaut | Description |
|
| 140 |
+
|----------------------|----------------|-----------------------------------------------|
|
| 141 |
+
| TELEGRAM_BOT_TOKEN | (obligatoire) | Token du bot Telegram |
|
| 142 |
+
| MODEL_PATH | model.pt | Chemin vers les poids |
|
| 143 |
+
| VOCAB_PATH | vocab.json | Chemin vers le vocabulaire char-level |
|
| 144 |
+
| DEVICE | cpu (auto) | "cpu" (les Spaces gratuits n'ont pas de GPU) |
|
| 145 |
+
| MAX_NEW_TOKENS | 200 | Nombre max de caractères générés par réponse |
|
| 146 |
+
| TEMPERATURE | 0.7 | Température de sampling |
|
| 147 |
+
| PORT | 7860 | Port du serveur Flask (imposé par HF Spaces) |
|
| 148 |
+
|
| 149 |
+
## Diagnostiquer un problème
|
| 150 |
+
|
| 151 |
+
- **Le build Docker échoue** → onglet "Logs" du Space, section "Build logs"
|
| 152 |
+
- **Le bot ne répond pas sur Telegram mais le Space tourne** → vérifie les
|
| 153 |
+
"Container logs" pour une erreur de chargement du modèle ou de token manquant
|
| 154 |
+
- **Page d'accueil affiche `bot_started: false`** → le thread du bot a
|
| 155 |
+
crashé au démarrage (vérifie `model.pt`/`vocab.json` présents et valides)
|
app.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py
|
| 3 |
+
Point d'entrée du Space Hugging Face (SDK Docker).
|
| 4 |
+
|
| 5 |
+
Ce fichier fait deux choses en parallèle :
|
| 6 |
+
1. Lance le bot Telegram (bot.py) dans un thread secondaire en polling.
|
| 7 |
+
2. Démarre un serveur web Flask minimal sur le port attendu par HF Spaces
|
| 8 |
+
(variable d'env PORT, généralement 7860), qui sert uniquement à donner
|
| 9 |
+
à Hugging Face un "visiteur" actif quand on le ping depuis l'extérieur
|
| 10 |
+
(UptimeRobot, GitHub Actions, etc.), afin d'éviter la mise en veille
|
| 11 |
+
automatique après 48h d'inactivité du tier CPU Basic gratuit.
|
| 12 |
+
|
| 13 |
+
Le serveur web ne sert à RIEN d'autre que ça : il n'expose aucune logique
|
| 14 |
+
métier du bot, juste un endpoint de healthcheck.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
import os
|
| 19 |
+
import threading
|
| 20 |
+
|
| 21 |
+
from flask import Flask, jsonify
|
| 22 |
+
|
| 23 |
+
logging.basicConfig(
|
| 24 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
| 25 |
+
level=logging.INFO,
|
| 26 |
+
)
|
| 27 |
+
logger = logging.getLogger("hilal_ai.app")
|
| 28 |
+
|
| 29 |
+
app = Flask(__name__)
|
| 30 |
+
|
| 31 |
+
# État partagé simple pour exposer si le bot a démarré sans erreur
|
| 32 |
+
_bot_status = {"started": False, "error": None}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@app.route("/")
|
| 36 |
+
def index():
|
| 37 |
+
return jsonify(
|
| 38 |
+
{
|
| 39 |
+
"service": "Hilal Ai Telegram Bot",
|
| 40 |
+
"bot_started": _bot_status["started"],
|
| 41 |
+
"bot_error": _bot_status["error"],
|
| 42 |
+
}
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@app.route("/ping")
|
| 47 |
+
def ping():
|
| 48 |
+
"""Endpoint dédié au keep-alive externe (UptimeRobot, GitHub Actions...).
|
| 49 |
+
Doit répondre vite et toujours 200 si le process est vivant."""
|
| 50 |
+
return jsonify({"status": "ok"}), 200
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _start_bot_thread():
|
| 54 |
+
"""Lance bot.run_bot_blocking() dans un thread daemon séparé du
|
| 55 |
+
serveur Flask. Toute exception est capturée pour ne pas tuer le
|
| 56 |
+
thread silencieusement sans laisser de trace dans les logs HF."""
|
| 57 |
+
try:
|
| 58 |
+
import bot # import différé pour que les logs Flask démarrent d'abord
|
| 59 |
+
_bot_status["started"] = True
|
| 60 |
+
bot.run_bot_blocking()
|
| 61 |
+
except Exception as e:
|
| 62 |
+
logger.exception("Le bot Telegram a crashé dans son thread.")
|
| 63 |
+
_bot_status["started"] = False
|
| 64 |
+
_bot_status["error"] = str(e)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def main():
|
| 68 |
+
bot_thread = threading.Thread(target=_start_bot_thread, daemon=True, name="telegram-bot")
|
| 69 |
+
bot_thread.start()
|
| 70 |
+
|
| 71 |
+
port = int(os.environ.get("PORT", "7860"))
|
| 72 |
+
logger.info("Serveur web de ping démarré sur le port %d", port)
|
| 73 |
+
# debug=False et use_reloader=False sont obligatoires ici : le reloader
|
| 74 |
+
# de Flask relance le process et casserait le thread du bot.
|
| 75 |
+
app.run(host="0.0.0.0", port=port, debug=False, use_reloader=False)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
if __name__ == "__main__":
|
| 79 |
+
main()
|
bot.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
bot.py
|
| 3 |
+
Bot Telegram pour "Hilal Ai", un LLM Transformer Décodeur entraîné from
|
| 4 |
+
scratch au niveau caractère par LabZero / Suayki.
|
| 5 |
+
|
| 6 |
+
Prérequis :
|
| 7 |
+
pip install python-telegram-bot==21.* torch
|
| 8 |
+
|
| 9 |
+
Fichiers attendus dans le même dossier :
|
| 10 |
+
- model.pt (poids entraînés, obligatoire)
|
| 11 |
+
- vocab.json (vocabulaire char-level, fortement recommandé)
|
| 12 |
+
- model.py (architecture, fourni séparément)
|
| 13 |
+
|
| 14 |
+
Variables d'environnement (NE JAMAIS mettre le token en clair dans le code) :
|
| 15 |
+
TELEGRAM_BOT_TOKEN -> token du bot obtenu via @BotFather
|
| 16 |
+
MODEL_PATH -> chemin vers model.pt (défaut: "model.pt")
|
| 17 |
+
VOCAB_PATH -> chemin vers vocab.json (défaut: "vocab.json")
|
| 18 |
+
DEVICE -> "cpu" ou "cuda" (défaut: auto-détection)
|
| 19 |
+
MAX_NEW_TOKENS -> nombre max de caractères générés (défaut: 200)
|
| 20 |
+
TEMPERATURE -> température de sampling (défaut: 0.7)
|
| 21 |
+
|
| 22 |
+
Lancement :
|
| 23 |
+
export TELEGRAM_BOT_TOKEN="xxxx:yyyy"
|
| 24 |
+
python bot.py
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
import asyncio
|
| 28 |
+
import datetime
|
| 29 |
+
import logging
|
| 30 |
+
import os
|
| 31 |
+
import re
|
| 32 |
+
import sys
|
| 33 |
+
|
| 34 |
+
import torch
|
| 35 |
+
from telegram import Update
|
| 36 |
+
from telegram.constants import ChatAction
|
| 37 |
+
from telegram.ext import (
|
| 38 |
+
Application,
|
| 39 |
+
CommandHandler,
|
| 40 |
+
ContextTypes,
|
| 41 |
+
MessageHandler,
|
| 42 |
+
filters,
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
from model import BLOCK_SIZE, load_model
|
| 46 |
+
|
| 47 |
+
# ----------------------------------------------------------------------------
|
| 48 |
+
# Configuration / logging
|
| 49 |
+
# ----------------------------------------------------------------------------
|
| 50 |
+
logging.basicConfig(
|
| 51 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
| 52 |
+
level=logging.INFO,
|
| 53 |
+
)
|
| 54 |
+
logger = logging.getLogger("hilal_ai.bot")
|
| 55 |
+
|
| 56 |
+
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN")
|
| 57 |
+
if not TELEGRAM_BOT_TOKEN:
|
| 58 |
+
logger.critical(
|
| 59 |
+
"TELEGRAM_BOT_TOKEN n'est pas défini. "
|
| 60 |
+
"Sur HF Spaces : Settings -> Repository secrets -> ajoute TELEGRAM_BOT_TOKEN. "
|
| 61 |
+
"En local : export TELEGRAM_BOT_TOKEN='ton_token' avant de démarrer."
|
| 62 |
+
)
|
| 63 |
+
raise RuntimeError("TELEGRAM_BOT_TOKEN manquant")
|
| 64 |
+
|
| 65 |
+
MODEL_PATH = os.environ.get("MODEL_PATH", "model.pt")
|
| 66 |
+
VOCAB_PATH = os.environ.get("VOCAB_PATH", "vocab.json")
|
| 67 |
+
DEVICE = os.environ.get("DEVICE") or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 68 |
+
MAX_NEW_TOKENS = int(os.environ.get("MAX_NEW_TOKENS", "200"))
|
| 69 |
+
TEMPERATURE = float(os.environ.get("TEMPERATURE", "0.7"))
|
| 70 |
+
|
| 71 |
+
# Identité du bot (specs fournies)
|
| 72 |
+
BOT_NAME = "Hilal Ai"
|
| 73 |
+
BOT_SKILLS = "Discuter, Résumer, Traduire, Rédiger"
|
| 74 |
+
DEV_NAME = "LabZero"
|
| 75 |
+
DEV_LINK = "https://t.me" # à remplacer par le lien réel du canal/contact
|
| 76 |
+
DEV_CHAT_ID = "-1004489211751"
|
| 77 |
+
PROGRAMMER_NAME = "Suayki"
|
| 78 |
+
PROGRAMMER_LINK = "https://t.me" # à remplacer par le lien réel
|
| 79 |
+
PROGRAMMER_USER_ID = "6074566712"
|
| 80 |
+
|
| 81 |
+
IDENTITY_TEXT = (
|
| 82 |
+
f"🤖 *{BOT_NAME}*\n\n"
|
| 83 |
+
f"Compétences : {BOT_SKILLS}.\n\n"
|
| 84 |
+
f"Développeur : [{DEV_NAME}]({DEV_LINK})\n"
|
| 85 |
+
f"Programmeur : [{PROGRAMMER_NAME}]({PROGRAMMER_LINK})"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Mots-clés déclenchant l'injection de la date/heure serveur dans le prompt
|
| 89 |
+
DATETIME_TRIGGERS = re.compile(
|
| 90 |
+
r"\b(quelle heure|quelle date|date du jour|heure actuelle|"
|
| 91 |
+
r"on est quel jour|quel jour sommes[- ]nous|date et heure)\b",
|
| 92 |
+
re.IGNORECASE,
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# Mots-clés déclenchant la réponse d'identité statique plutôt que la génération
|
| 96 |
+
IDENTITY_TRIGGERS = re.compile(
|
| 97 |
+
r"\b(qui es[- ]tu|qui t'a (créé|developpé|développé|fait)|"
|
| 98 |
+
r"ton (créateur|développeur|programmeur)|c'est qui ton (créateur|développeur))\b",
|
| 99 |
+
re.IGNORECASE,
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# ----------------------------------------------------------------------------
|
| 103 |
+
# Chargement du modèle (une seule fois, au démarrage du process)
|
| 104 |
+
# ----------------------------------------------------------------------------
|
| 105 |
+
logger.info("Chargement du modèle depuis %s sur %s ...", MODEL_PATH, DEVICE)
|
| 106 |
+
try:
|
| 107 |
+
model, tokenizer = load_model(MODEL_PATH, VOCAB_PATH, device=DEVICE)
|
| 108 |
+
logger.info("Modèle chargé avec succès. vocab_size=%d", tokenizer.vocab_size)
|
| 109 |
+
except FileNotFoundError:
|
| 110 |
+
logger.critical(
|
| 111 |
+
"Fichier de poids introuvable : %s. Place model.pt à la racine du repo Space "
|
| 112 |
+
"ou définis la variable MODEL_PATH dans les Secrets/Variables du Space.",
|
| 113 |
+
MODEL_PATH,
|
| 114 |
+
)
|
| 115 |
+
# Pas de sys.exit() ici : sur HF Spaces, ce module tourne dans un thread
|
| 116 |
+
# secondaire lancé par app.py. Si on tue le process, le serveur web de
|
| 117 |
+
# ping meurt aussi et HF considère tout le Space comme crashé en boucle.
|
| 118 |
+
# On préfère garder le serveur de ping vivant pour diagnostiquer via
|
| 119 |
+
# l'endpoint "/" et les logs du Space.
|
| 120 |
+
raise
|
| 121 |
+
except Exception:
|
| 122 |
+
logger.critical("Échec du chargement du modèle.", exc_info=True)
|
| 123 |
+
raise
|
| 124 |
+
logger.info("Bot prêt à démarrer.")
|
| 125 |
+
|
| 126 |
+
# Verrou pour éviter les accès concurrents au modèle (un seul forward pass
|
| 127 |
+
# CPU/GPU à la fois, simple et sûr pour un usage VPS mono-instance).
|
| 128 |
+
_generation_lock = asyncio.Lock()
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ----------------------------------------------------------------------------
|
| 132 |
+
# Génération de texte
|
| 133 |
+
# ----------------------------------------------------------------------------
|
| 134 |
+
def _build_prompt(user_text: str) -> str:
|
| 135 |
+
"""
|
| 136 |
+
Construit le prompt final envoyé au modèle. Injecte la date/heure serveur
|
| 137 |
+
si l'utilisateur la demande explicitement.
|
| 138 |
+
"""
|
| 139 |
+
if DATETIME_TRIGGERS.search(user_text):
|
| 140 |
+
now = datetime.datetime.now().strftime("%d/%m/%Y %H:%M")
|
| 141 |
+
user_text = f"[Date et heure actuelles : {now}]\n{user_text}"
|
| 142 |
+
return user_text
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _safe_generate(prompt: str, max_new_tokens: int, temperature: float) -> str:
|
| 146 |
+
"""
|
| 147 |
+
Génère une réponse de façon synchrone et sécurisée :
|
| 148 |
+
- tronque le prompt pour ne garder que les BLOCK_SIZE derniers caractères
|
| 149 |
+
(le modèle ne peut de toute façon pas voir plus loin que son contexte)
|
| 150 |
+
- encode/décode avec le tokenizer char-level
|
| 151 |
+
- retourne uniquement le texte nouvellement généré (sans répéter le prompt)
|
| 152 |
+
"""
|
| 153 |
+
prompt = prompt[-BLOCK_SIZE:] # le contexte ne peut pas dépasser BLOCK_SIZE de toute façon
|
| 154 |
+
encoded = tokenizer.encode(prompt)
|
| 155 |
+
if not encoded:
|
| 156 |
+
encoded = tokenizer.encode(" ")
|
| 157 |
+
|
| 158 |
+
idx = torch.tensor([encoded], dtype=torch.long, device=DEVICE)
|
| 159 |
+
|
| 160 |
+
with torch.no_grad():
|
| 161 |
+
out = model.generate(
|
| 162 |
+
idx,
|
| 163 |
+
max_new_tokens=max_new_tokens,
|
| 164 |
+
temperature=temperature,
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
generated_ids = out[0].tolist()[len(encoded):]
|
| 168 |
+
text = tokenizer.decode(generated_ids)
|
| 169 |
+
return text.strip()
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
async def generate_reply(user_text: str) -> str:
|
| 173 |
+
"""Wrapper async : exécute la génération (bloquante, CPU/GPU-bound) dans
|
| 174 |
+
un thread séparé pour ne pas bloquer la boucle d'événements du bot, et
|
| 175 |
+
sérialise les accès au modèle via un verrou."""
|
| 176 |
+
prompt = _build_prompt(user_text)
|
| 177 |
+
async with _generation_lock:
|
| 178 |
+
try:
|
| 179 |
+
reply = await asyncio.to_thread(
|
| 180 |
+
_safe_generate, prompt, MAX_NEW_TOKENS, TEMPERATURE
|
| 181 |
+
)
|
| 182 |
+
except Exception:
|
| 183 |
+
logger.exception("Erreur pendant la génération de texte.")
|
| 184 |
+
return "Désolé, une erreur interne est survenue pendant la génération. Réessaie."
|
| 185 |
+
|
| 186 |
+
if not reply:
|
| 187 |
+
reply = "Je n'ai pas pu générer de réponse claire, peux-tu reformuler ?"
|
| 188 |
+
return reply
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# ----------------------------------------------------------------------------
|
| 192 |
+
# Handlers Telegram
|
| 193 |
+
# ----------------------------------------------------------------------------
|
| 194 |
+
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
| 195 |
+
await update.message.reply_text(
|
| 196 |
+
f"Salut ! Je suis {BOT_NAME} 🤖\n"
|
| 197 |
+
f"Je peux discuter, résumer, traduire et rédiger.\n"
|
| 198 |
+
f"Envoie-moi simplement un message, ou tape /about pour en savoir plus sur moi."
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
async def cmd_about(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
| 203 |
+
await update.message.reply_text(
|
| 204 |
+
IDENTITY_TEXT,
|
| 205 |
+
parse_mode="Markdown",
|
| 206 |
+
disable_web_page_preview=True,
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
async def cmd_reset(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
| 211 |
+
"""Optionnel : si tu ajoutes un historique de conversation par utilisateur,
|
| 212 |
+
cette commande permet de le réinitialiser. Le modèle char-level de base
|
| 213 |
+
n'a pas de mémoire de conversation par défaut (pas de chat template)."""
|
| 214 |
+
context.user_data.clear()
|
| 215 |
+
await update.message.reply_text("Contexte de conversation réinitialisé. ✅")
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
| 219 |
+
user_text = update.message.text or ""
|
| 220 |
+
user_text = user_text.strip()
|
| 221 |
+
|
| 222 |
+
if not user_text:
|
| 223 |
+
return
|
| 224 |
+
|
| 225 |
+
if IDENTITY_TRIGGERS.search(user_text):
|
| 226 |
+
await update.message.reply_text(
|
| 227 |
+
IDENTITY_TEXT,
|
| 228 |
+
parse_mode="Markdown",
|
| 229 |
+
disable_web_page_preview=True,
|
| 230 |
+
)
|
| 231 |
+
return
|
| 232 |
+
|
| 233 |
+
await context.bot.send_chat_action(
|
| 234 |
+
chat_id=update.effective_chat.id, action=ChatAction.TYPING
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
reply = await generate_reply(user_text)
|
| 238 |
+
await update.message.reply_text(reply)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
|
| 242 |
+
logger.error("Exception non gérée :", exc_info=context.error)
|
| 243 |
+
if isinstance(update, Update) and update.effective_message:
|
| 244 |
+
try:
|
| 245 |
+
await update.effective_message.reply_text(
|
| 246 |
+
"Une erreur est survenue, désolé. Réessaie dans un instant."
|
| 247 |
+
)
|
| 248 |
+
except Exception:
|
| 249 |
+
pass
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
# ----------------------------------------------------------------------------
|
| 253 |
+
# Lancement de l'application
|
| 254 |
+
# ----------------------------------------------------------------------------
|
| 255 |
+
def main() -> None:
|
| 256 |
+
app = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
|
| 257 |
+
|
| 258 |
+
app.add_handler(CommandHandler("start", cmd_start))
|
| 259 |
+
app.add_handler(CommandHandler("about", cmd_about))
|
| 260 |
+
app.add_handler(CommandHandler("reset", cmd_reset))
|
| 261 |
+
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
|
| 262 |
+
app.add_error_handler(error_handler)
|
| 263 |
+
|
| 264 |
+
logger.info("Hilal Ai démarre (polling)...")
|
| 265 |
+
# stop_signals=None : nécessaire car sur HF Spaces ce bot tourne dans un
|
| 266 |
+
# thread secondaire (le thread principal sert le serveur web de ping),
|
| 267 |
+
# et les gestionnaires de signaux ne peuvent être enregistrés que dans
|
| 268 |
+
# le thread principal en Python.
|
| 269 |
+
app.run_polling(
|
| 270 |
+
allowed_updates=Update.ALL_TYPES,
|
| 271 |
+
drop_pending_updates=True,
|
| 272 |
+
stop_signals=None,
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def run_bot_blocking() -> None:
|
| 277 |
+
"""Point d'entrée appelé depuis un thread secondaire par app.py
|
| 278 |
+
(le serveur web de ping reste dans le thread principal sur HF Spaces)."""
|
| 279 |
+
main()
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
if __name__ == "__main__":
|
| 283 |
+
main()
|
model.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:786fc2fadbc913fb374b92811bcc33d4d9266ff64ad94c902a1876b813e74f84
|
| 3 |
+
size 8928571
|
model.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
model.py
|
| 3 |
+
Architecture du modèle "Hilal Ai" : Transformer Décodeur (style GPT) entraîné
|
| 4 |
+
from scratch au niveau caractère.
|
| 5 |
+
|
| 6 |
+
Cette architecture DOIT correspondre exactement à celle utilisée pendant
|
| 7 |
+
l'entraînement sur Colab pour que model.load_state_dict(...) fonctionne.
|
| 8 |
+
Si tu as modifié l'architecture sur Colab (dropout, bias, etc.), répercute
|
| 9 |
+
les mêmes changements ici.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
import os
|
| 15 |
+
|
| 16 |
+
import torch
|
| 17 |
+
import torch.nn as nn
|
| 18 |
+
from torch.nn import functional as F
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger("hilal_ai.model")
|
| 21 |
+
|
| 22 |
+
# ----------------------------------------------------------------------------
|
| 23 |
+
# Hyperparamètres (doivent matcher l'entraînement Colab)
|
| 24 |
+
# ----------------------------------------------------------------------------
|
| 25 |
+
BLOCK_SIZE = 128
|
| 26 |
+
N_EMBD = 192
|
| 27 |
+
N_HEAD = 6
|
| 28 |
+
N_LAYER = 4
|
| 29 |
+
DROPOUT = 0.1 # identique à l'entraînement Colab. Sans effet en model.eval()
|
| 30 |
+
# (le dropout est désactivé automatiquement en inference),
|
| 31 |
+
# mais gardé identique pour la cohérence de l'architecture.
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ----------------------------------------------------------------------------
|
| 35 |
+
# Architecture
|
| 36 |
+
# ----------------------------------------------------------------------------
|
| 37 |
+
class Head(nn.Module):
|
| 38 |
+
"""Une tête d'auto-attention causale."""
|
| 39 |
+
|
| 40 |
+
def __init__(self, head_size):
|
| 41 |
+
super().__init__()
|
| 42 |
+
self.key = nn.Linear(N_EMBD, head_size, bias=False)
|
| 43 |
+
self.query = nn.Linear(N_EMBD, head_size, bias=False)
|
| 44 |
+
self.value = nn.Linear(N_EMBD, head_size, bias=False)
|
| 45 |
+
self.register_buffer("tril", torch.tril(torch.ones(BLOCK_SIZE, BLOCK_SIZE)))
|
| 46 |
+
self.dropout = nn.Dropout(DROPOUT)
|
| 47 |
+
|
| 48 |
+
def forward(self, x):
|
| 49 |
+
B, T, C = x.shape
|
| 50 |
+
k = self.key(x)
|
| 51 |
+
q = self.query(x)
|
| 52 |
+
wei = q @ k.transpose(-2, -1) * (k.shape[-1] ** -0.5)
|
| 53 |
+
wei = wei.masked_fill(self.tril[:T, :T] == 0, float("-inf"))
|
| 54 |
+
wei = F.softmax(wei, dim=-1)
|
| 55 |
+
wei = self.dropout(wei)
|
| 56 |
+
v = self.value(x)
|
| 57 |
+
out = wei @ v
|
| 58 |
+
return out
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class MultiHeadAttention(nn.Module):
|
| 62 |
+
"""Plusieurs têtes d'attention en parallèle."""
|
| 63 |
+
|
| 64 |
+
def __init__(self, num_heads, head_size):
|
| 65 |
+
super().__init__()
|
| 66 |
+
self.heads = nn.ModuleList([Head(head_size) for _ in range(num_heads)])
|
| 67 |
+
self.proj = nn.Linear(N_EMBD, N_EMBD)
|
| 68 |
+
self.dropout = nn.Dropout(DROPOUT)
|
| 69 |
+
|
| 70 |
+
def forward(self, x):
|
| 71 |
+
out = torch.cat([h(x) for h in self.heads], dim=-1)
|
| 72 |
+
out = self.dropout(self.proj(out))
|
| 73 |
+
return out
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class FeedForward(nn.Module):
|
| 77 |
+
"""Simple couche feed-forward (MLP) avec activation GELU."""
|
| 78 |
+
|
| 79 |
+
def __init__(self, n_embd):
|
| 80 |
+
super().__init__()
|
| 81 |
+
self.net = nn.Sequential(
|
| 82 |
+
nn.Linear(n_embd, 4 * n_embd),
|
| 83 |
+
nn.GELU(),
|
| 84 |
+
nn.Linear(4 * n_embd, n_embd),
|
| 85 |
+
nn.Dropout(DROPOUT),
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
def forward(self, x):
|
| 89 |
+
return self.net(x)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class Block(nn.Module):
|
| 93 |
+
"""Bloc Transformer : communication (attention) puis calcul (FFN)."""
|
| 94 |
+
|
| 95 |
+
def __init__(self, n_embd, n_head):
|
| 96 |
+
super().__init__()
|
| 97 |
+
head_size = n_embd // n_head
|
| 98 |
+
self.sa = MultiHeadAttention(n_head, head_size)
|
| 99 |
+
self.ffwd = FeedForward(n_embd)
|
| 100 |
+
self.ln1 = nn.LayerNorm(n_embd)
|
| 101 |
+
self.ln2 = nn.LayerNorm(n_embd)
|
| 102 |
+
|
| 103 |
+
def forward(self, x):
|
| 104 |
+
x = x + self.sa(self.ln1(x))
|
| 105 |
+
x = x + self.ffwd(self.ln2(x))
|
| 106 |
+
return x
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class MiniGPT(nn.Module):
|
| 110 |
+
"""Modèle de langage Transformer Décodeur, niveau caractère."""
|
| 111 |
+
|
| 112 |
+
def __init__(self, vocab_size):
|
| 113 |
+
super().__init__()
|
| 114 |
+
self.vocab_size = vocab_size
|
| 115 |
+
self.token_embedding = nn.Embedding(vocab_size, N_EMBD)
|
| 116 |
+
self.position_embedding = nn.Embedding(BLOCK_SIZE, N_EMBD)
|
| 117 |
+
self.blocks = nn.Sequential(*[Block(N_EMBD, N_HEAD) for _ in range(N_LAYER)])
|
| 118 |
+
self.ln_f = nn.LayerNorm(N_EMBD)
|
| 119 |
+
self.lm_head = nn.Linear(N_EMBD, vocab_size)
|
| 120 |
+
|
| 121 |
+
def forward(self, idx, targets=None):
|
| 122 |
+
B, T = idx.shape
|
| 123 |
+
tok_emb = self.token_embedding(idx)
|
| 124 |
+
pos_emb = self.position_embedding(
|
| 125 |
+
torch.arange(T, device=idx.device)
|
| 126 |
+
)
|
| 127 |
+
x = tok_emb + pos_emb
|
| 128 |
+
x = self.blocks(x)
|
| 129 |
+
x = self.ln_f(x)
|
| 130 |
+
logits = self.lm_head(x)
|
| 131 |
+
|
| 132 |
+
loss = None
|
| 133 |
+
if targets is not None:
|
| 134 |
+
B, T, C = logits.shape
|
| 135 |
+
logits_flat = logits.view(B * T, C)
|
| 136 |
+
targets_flat = targets.view(B * T)
|
| 137 |
+
loss = F.cross_entropy(logits_flat, targets_flat)
|
| 138 |
+
|
| 139 |
+
return logits, loss
|
| 140 |
+
|
| 141 |
+
@torch.no_grad()
|
| 142 |
+
def generate(self, idx, max_new_tokens, temperature=0.7, top_k=None):
|
| 143 |
+
"""
|
| 144 |
+
Génère max_new_tokens nouveaux caractères à partir du contexte idx.
|
| 145 |
+
idx : tensor (B, T) d'indices déjà encodés.
|
| 146 |
+
Le contexte est tronqué à BLOCK_SIZE à chaque étape (fenêtre glissante).
|
| 147 |
+
"""
|
| 148 |
+
self.eval()
|
| 149 |
+
for _ in range(max_new_tokens):
|
| 150 |
+
idx_cond = idx[:, -BLOCK_SIZE:]
|
| 151 |
+
logits, _ = self(idx_cond)
|
| 152 |
+
logits = logits[:, -1, :] / max(temperature, 1e-6)
|
| 153 |
+
|
| 154 |
+
if top_k is not None:
|
| 155 |
+
v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
|
| 156 |
+
logits[logits < v[:, [-1]]] = float("-inf")
|
| 157 |
+
|
| 158 |
+
probs = F.softmax(logits, dim=-1)
|
| 159 |
+
idx_next = torch.multinomial(probs, num_samples=1)
|
| 160 |
+
idx = torch.cat((idx, idx_next), dim=1)
|
| 161 |
+
return idx
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# ----------------------------------------------------------------------------
|
| 165 |
+
# Tokenizer niveau caractère
|
| 166 |
+
# ----------------------------------------------------------------------------
|
| 167 |
+
class CharTokenizer:
|
| 168 |
+
"""
|
| 169 |
+
Tokenizer char-level. Le mapping stoi/itos DOIT être identique à celui
|
| 170 |
+
utilisé pendant l'entraînement sur Colab, sinon le modèle produira du
|
| 171 |
+
charabia (les poids des embeddings ne correspondront pas aux bons
|
| 172 |
+
caractères).
|
| 173 |
+
|
| 174 |
+
Stratégie recommandée :
|
| 175 |
+
1. Pendant l'entraînement sur Colab, sauvegarde ton vocabulaire :
|
| 176 |
+
import json
|
| 177 |
+
with open("vocab.json", "w", encoding="utf-8") as f:
|
| 178 |
+
json.dump({"chars": sorted(list(set(text)))}, f, ensure_ascii=False)
|
| 179 |
+
puis télécharge vocab.json à côté de model.pt.
|
| 180 |
+
2. Place vocab.json dans le même dossier que ce script.
|
| 181 |
+
|
| 182 |
+
Si vocab.json est absent, un jeu de caractères par défaut est utilisé
|
| 183 |
+
en secours (lettres FR/EN, chiffres, ponctuation courante). Dans ce cas,
|
| 184 |
+
la cohérence avec les poids entraînés n'est PAS garantie.
|
| 185 |
+
"""
|
| 186 |
+
|
| 187 |
+
DEFAULT_CHARS = (
|
| 188 |
+
"\n !\"#$%&'()*+,-./0123456789:;<=>?@"
|
| 189 |
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`"
|
| 190 |
+
"abcdefghijklmnopqrstuvwxyz{|}~"
|
| 191 |
+
"àâäéèêëïîôöùûüçÀÂÄÉÈÊËÏÎÔÖÙÛÜÇœŒ«»…–—’"
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
def __init__(self, vocab_path="vocab.json"):
|
| 195 |
+
chars = None
|
| 196 |
+
if vocab_path and os.path.exists(vocab_path):
|
| 197 |
+
try:
|
| 198 |
+
with open(vocab_path, "r", encoding="utf-8") as f:
|
| 199 |
+
data = json.load(f)
|
| 200 |
+
chars = data["chars"]
|
| 201 |
+
logger.info("Vocabulaire chargé depuis %s (%d caractères).", vocab_path, len(chars))
|
| 202 |
+
except Exception as e:
|
| 203 |
+
logger.warning("Impossible de lire %s (%s). Utilisation du vocabulaire par défaut.", vocab_path, e)
|
| 204 |
+
|
| 205 |
+
if chars is None:
|
| 206 |
+
chars = sorted(set(self.DEFAULT_CHARS))
|
| 207 |
+
logger.warning(
|
| 208 |
+
"AUCUN vocab.json trouvé : utilisation d'un vocabulaire par défaut (%d caractères). "
|
| 209 |
+
"Ceci ne correspondra probablement PAS exactement aux poids entraînés sur Colab.",
|
| 210 |
+
len(chars),
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
self.chars = chars
|
| 214 |
+
self.vocab_size = len(chars)
|
| 215 |
+
self.stoi = {ch: i for i, ch in enumerate(chars)}
|
| 216 |
+
self.itos = {i: ch for i, ch in enumerate(chars)}
|
| 217 |
+
# Caractère de remplacement pour tout caractère inconnu en entrée
|
| 218 |
+
self.unk_char = chars[0]
|
| 219 |
+
|
| 220 |
+
def encode(self, s: str):
|
| 221 |
+
return [self.stoi.get(c, self.stoi.get(self.unk_char, 0)) for c in s]
|
| 222 |
+
|
| 223 |
+
def decode(self, indices) -> str:
|
| 224 |
+
return "".join(self.itos.get(int(i), "") for i in indices)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
# ----------------------------------------------------------------------------
|
| 228 |
+
# Chargement du modèle complet
|
| 229 |
+
# ----------------------------------------------------------------------------
|
| 230 |
+
def load_model(model_path: str, vocab_path: str = "vocab.json", device: str = "cpu"):
|
| 231 |
+
"""
|
| 232 |
+
Instancie le tokenizer + le modèle, charge les poids depuis model_path,
|
| 233 |
+
et renvoie (model, tokenizer) prêts pour l'inférence.
|
| 234 |
+
"""
|
| 235 |
+
tokenizer = CharTokenizer(vocab_path)
|
| 236 |
+
model = MiniGPT(vocab_size=tokenizer.vocab_size)
|
| 237 |
+
|
| 238 |
+
state_dict = torch.load(model_path, map_location=device)
|
| 239 |
+
|
| 240 |
+
# Selon comment le modèle a été sauvegardé sur Colab (state_dict pur,
|
| 241 |
+
# ou checkpoint dict contenant 'model_state_dict'), on gère les deux cas.
|
| 242 |
+
if isinstance(state_dict, dict) and "model_state_dict" in state_dict:
|
| 243 |
+
state_dict = state_dict["model_state_dict"]
|
| 244 |
+
|
| 245 |
+
missing, unexpected = model.load_state_dict(state_dict, strict=False)
|
| 246 |
+
if missing:
|
| 247 |
+
logger.warning("Clés manquantes lors du chargement des poids : %s", missing)
|
| 248 |
+
if unexpected:
|
| 249 |
+
logger.warning("Clés inattendues lors du chargement des poids : %s", unexpected)
|
| 250 |
+
|
| 251 |
+
model.to(device)
|
| 252 |
+
model.eval()
|
| 253 |
+
return model, tokenizer
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
python-telegram-bot==21.4
|
| 2 |
+
torch>=2.0
|
| 3 |
+
flask>=3.0
|
vocab.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"chars": ["\n", " ", "!", "'", "(", ")", ",", "-", ".", "/", "0", "1", "2", "4", "5", "6", "7", "8", "9", ":", "?", "A", "B", "C", "D", "H", "I", "J", "L", "N", "O", "P", "Q", "R", "S", "T", "U", "Z", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "x", "y", "z", "à", "ç", "è", "é"]}
|