Issatha commited on
Commit
4afadbd
·
verified ·
1 Parent(s): d5583e4

Upload 8 files

Browse files
Files changed (8) hide show
  1. Dockerfile +24 -0
  2. README.md +150 -6
  3. app.py +79 -0
  4. bot.py +283 -0
  5. model.pt +3 -0
  6. model.py +253 -0
  7. requirements.txt +3 -0
  8. 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: yellow
5
- colorTo: blue
6
  sdk: docker
 
7
  pinned: false
8
- license: mit
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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", "à", "ç", "è", "é"]}