Premier commit : ajout du travail local

This commit is contained in:
Patrick Schurig 2026-06-19 19:17:07 +02:00
commit 2ece762035
33 changed files with 2776 additions and 0 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
.env
data/
sav_knowledge/
__pycache__/
**/__pycache__/
*.pyc
*.pyo
.git/

21
.env.example Normal file
View File

@ -0,0 +1,21 @@
# Telegram
TELEGRAM_BOT_TOKEN=
GROUPE_CHANTIER=
GROUPE_MAGASINIER=
GROUPE_SAV=
PATRICK_ID=
# Nextcloud
NEXTCLOUD_URL=https://cloud.etm-schurig.eu
NEXTCLOUD_USER=
NEXTCLOUD_PASSWORD=
NEXTCLOUD_DECK_URL=https://cloud.etm-schurig.eu/index.php/apps/deck/api/v1/
DECK_BOARD_ID=
DECK_COL_FIN=
DECK_COL_SAV=
DECK_COL_MATERIEL=
# Ollama (VM bare metal, accessible depuis le container via host-gateway)
OLLAMA_ENABLED=true
OLLAMA_URL=http://172.17.0.1:11434
OLLAMA_MODEL=phi3

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.env
data/chromadb/
data/docs/
data/tickets.db
sav_knowledge/
__pycache__/
**/__pycache__/
*.pyc
*.pyo

200
CLAUDE_documentation.md Normal file
View File

@ -0,0 +1,200 @@
# ETM Bot — Documentation complète du code
## Contexte
Tu vas documenter le bot Telegram Python ETM-Schurig de façon complète.
Cette documentation sera stockée dans Git et le Wiki Git (Gitea).
**Avant de commencer, lire tout le code :**
```bash
find . -name "*.py" | sort
find . -name "*.md" | sort
cat requirements.txt
cat .env.example # ou .env si disponible
```
---
## Ce que tu dois produire
### 1. README.md (racine du projet)
```markdown
# ETM Bot — Bot Terrain Telegram
## Vue d'ensemble
[Description en 3-4 phrases : ce que fait le bot, pour qui, pourquoi]
## Architecture
[Schéma ASCII du projet — dossiers et fichiers clés avec leur rôle en une ligne]
## Prérequis
[Python version, dépendances système, services externes requis]
## Installation
[Étapes pas à pas — clone, pip install, .env, lancement]
## Configuration (.env)
[Tableau de toutes les variables avec description et exemple]
## Déploiement
[Systemd service, nginx si applicable]
## Utilisation
[Les 5 actions du bot avec exemple de conversation]
```
---
### 2. ARCHITECTURE.md
Document technique expliquant la structure globale :
- **Schéma du flux de données** — de la réception d'un message Telegram
jusqu'à la notification finale, en passant par WebDAV Nextcloud
- **Diagramme des états** ConversationHandler — tous les états et transitions
- **Interactions entre modules** — qui appelle qui
- **Schéma base de données SQLite** — toutes les tables, colonnes, relations
- **Structure ChromaDB** — collections, métadonnées, format des documents indexés
Format : texte + diagrammes ASCII ou Mermaid si possible.
---
### 3. Documentation par module (un fichier par dossier)
#### docs/handlers.md
Pour chaque handler (`fin_chantier.py`, `sav.py`, `materiel.py`,
`maintenance.py`, `faq.py`, `menu.py`, `edit_handler.py`) :
- **Rôle** : ce que fait ce handler en une phrase
- **États gérés** : quels états ConversationHandler il couvre
- **Fonctions** : signature + description + paramètres + retour
- **Exemple de conversation** : ce que voit l'utilisateur étape par étape
- **Dépendances** : quels services il appelle
#### docs/services.md
Pour chaque service (`nextcloud.py`, `faq_service.py`,
`chantier_selector.py`, `notifications.py`) :
- **Rôle** du service
- **Classe principale** : attributs, méthodes publiques
- **Chaque méthode** : signature, description, paramètres, retour, erreurs possibles
- **Exemple d'utilisation**
#### docs/database.md
- **Schéma complet** de toutes les tables SQLite
- **Rôle de chaque table** et de chaque colonne
- **Requêtes types** utilisées dans le code
- **Politique de rétention** des données
#### docs/faq_rag.md
Document dédié au système FAQ/RAG :
- **Comment fonctionne ChromaDB** dans ce projet
- **Format des documents indexés** (SAV résolus vs documentation)
- **Pipeline d'indexation** : de `/resolu` → ChromaDB
- **Pipeline de recherche** : de `/faq question` → réponse
- **Intégration Ollama** : modèle utilisé, prompt système, fallback
- **Comment ajouter des documents** (notices constructeurs)
- **Comment vider / réindexer** la base
#### docs/deployment.md
- **Prérequis VM** (RAM, disque, OS)
- **Installation complète** pas à pas
- **Configuration systemd** avec explications de chaque directive
- **Configuration nginx** si applicable
- **Variables d'environnement** — toutes décrites
- **Logs** — où les trouver, comment les lire
- **Mise à jour** — procédure pour déployer une nouvelle version
- **Backup** — quoi sauvegarder (ChromaDB, SQLite, .env)
---
### 4. docs/guide_patrick.md — Guide administrateur
Pour Patrick uniquement — les commandes de gestion :
```markdown
## Commandes bot disponibles pour Patrick
### /resolu — Clôturer un SAV
/resolu #2026-047 cause | solution | durée
### /faq — Tester la base de connaissances
/faq Fronius erreur 567
### Comment ajouter une notice constructeur à la FAQ
[Procédure pas à pas]
### Comment voir les tickets SAV ouverts
[Commande ou requête SQLite]
### Comment sauvegarder la base FAQ
[Commande backup ChromaDB]
```
---
### 5. CHANGELOG.md
Historique des versions avec les fonctionnalités implémentées :
```markdown
## [1.0.0] — 2026-03-25
### Ajouté
- Menu 3 boutons : Fin de Chantier / SAV / Matériel Manquant
- Upload photos Nextcloud
- Notifications groupes Telegram
...
## [1.1.0] — date
### Ajouté
- Dropdown chantiers depuis Nextcloud
- Gestion albums multi-photos
- Bouton FAQ
- Commande /resolu + indexation ChromaDB
...
```
---
## Règles de rédaction
- **Langue** : français pour tout (commentaires, docs, descriptions)
- **Ton** : technique mais accessible — un développeur junior doit comprendre
- **Code** : inclure des exemples concrets tirés du vrai code, pas inventés
- **Diagrammes** : préférer ASCII ou Mermaid (compatible Git Wiki)
- **Exhaustivité** : documenter TOUTES les fonctions publiques,
même les simples utilitaires
- **Exemples** : chaque fonctionnalité doit avoir un exemple
de conversation ou d'appel concret
---
## Format de sortie attendu
Créer les fichiers directement dans le projet :
```
README.md ← remplacer ou créer
ARCHITECTURE.md ← nouveau
CHANGELOG.md ← nouveau
docs/
├── handlers.md
├── services.md
├── database.md
├── faq_rag.md
├── deployment.md
└── guide_patrick.md
```
---
## Ordre de travail
1. Lire tout le code source en premier
2. Écrire README.md — vue d'ensemble
3. Écrire ARCHITECTURE.md — flux et schémas
4. Écrire docs/handlers.md — tous les handlers
5. Écrire docs/services.md — tous les services
6. Écrire docs/database.md — SQLite + ChromaDB
7. Écrire docs/faq_rag.md — système FAQ détaillé
8. Écrire docs/deployment.md — déploiement VM
9. Écrire docs/guide_patrick.md — guide admin
10. Écrire CHANGELOG.md — historique versions

207
CLAUDE_python_bot.md Normal file
View File

@ -0,0 +1,207 @@
# ETM Telegram Bot — Réécriture Python
## Contexte métier
Entreprise ETM-Schurig SARL — installateur RGE (PV, PAC, IRVE, HEMS) en Alsace.
Bot Telegram pour les techniciens terrain — remplace les appels téléphoniques.
## Stack cible
- **python-telegram-bot** v20+ (async)
- **python-nextcloud** ou WebDAV direct (httpx) pour Nextcloud
- **Ollama** (LLM local) pour FAQ/SAV intelligent — phase 2
- **SQLite** ou fichier JSON pour la session/état utilisateur
- Déployé sur le VPS Proxmox existant (même infra que n8n)
---
## Credentials
| Paramètre | Valeur |
|---|---|
| Bot Token | `8608752199:AAGZ9Vyop0MVm4msxUyDsUuoNvN1hKM12vc` |
| Groupe Chat Chantier | `-5208574803` |
| Groupe ETM-Magasinier | `-5056192608` |
| Patrick ID | `8022751692` |
| Nextcloud WebDAV | `https://cloud.etm-schurig.eu/remote.php/webdav` |
| Nextcloud User | `Patrick.Schurig` |
| Nextcloud Deck API | `https://cloud.etm-schurig.eu/index.php/apps/deck/api/v1/` |
---
## Fonctionnalités MVP
### 3 actions via boutons inline
| Bouton | callback_data | Règles |
|---|---|---|
| 📸 Fin de Chantier | `fin` | Photo **obligatoire** + légende `NomClient / Notes` |
| ⚠️ Alerte SAV | `sav` | Photo optionnelle + `NomChantier / Description` |
| 📦 Matériel Manquant | `materiel` | Texte seul accepté + `NomChantier / - item1 - item2` |
### Format de saisie universel
```
NomChantier / description ou liste avec - comme séparateur
```
Exemples :
- `Müller / onduleur + batterie posés`
- `Müller / - cuivre 28mm - disconnecteur - soupape anti-gel`
### Chemins Nextcloud
```
Fin chantier : Chantiers/YYYYMMDD_Client/30_Photos_Chantier/photo_timestamp.jpg
SAV : SAV_Urgent/YYYY-MM-DD_Client/photo.jpg
Matériel : Logistique/Manquants/Client/photo_timestamp.jpg
```
### Notifications
| Action | Destinataire | Contenu |
|---|---|---|
| Fin chantier | Groupe Chat Chantier `-5208574803` | ✅ + ouvrier + chantier + chemin Nextcloud + "prépare la facture" |
| SAV | Groupe ETM-SAV (à créer) | 🚨 + ouvrier + chantier + description + photo |
| Matériel | Groupe ETM-Magasinier `-5056192608` | 📦 + ouvrier + chantier + liste formatée |
### Cartes Nextcloud Deck (phase 1b)
- Tableau : **ETM Chantiers**
- 3 colonnes : `Fin de Chantier` / `SAV` / `Matériel`
- Créer une carte automatiquement à chaque signalement
---
## Architecture Python recommandée
```
etm_bot/
├── main.py # Entry point, ConversationHandler
├── config.py # Tokens, IDs, URLs (chargés depuis .env)
├── handlers/
│ ├── menu.py # /start, boutons inline
│ ├── fin_chantier.py # Workflow fin de chantier
│ ├── sav.py # Workflow SAV
│ └── materiel.py # Workflow matériel manquant
├── services/
│ ├── nextcloud.py # Upload WebDAV + Deck API
│ ├── telegram.py # Helpers send_message, notify_group
│ └── llm.py # Ollama FAQ/SAV — phase 2
├── models/
│ └── session.py # État conversation par user_id (dict en mémoire ou SQLite)
└── requirements.txt
```
### États ConversationHandler
```python
MENU = 0
ATTENTE_PHOTO_FIN = 1
ATTENTE_CONTENU_SAV = 2
ATTENTE_CONTENU_MATERIEL = 3
```
---
## Phase 2 — Wiki/FAQ SAV avec Ollama
Quand un technicien signale un SAV, proposer automatiquement des solutions :
```python
# services/llm.py
async def chercher_solution_sav(description: str) -> str:
# Appel Ollama local (mistral ou llama3)
response = await ollama.chat(
model="mistral",
messages=[{
"role": "system",
"content": "Tu es un expert en installation photovoltaïque, PAC et IRVE. "
"Donne des pistes de diagnostic pour ce problème terrain."
}, {
"role": "user",
"content": description
}]
)
return response["message"]["content"]
```
Exemples de questions SAV que le LLM doit pouvoir traiter :
- "onduleur Fronius affiche erreur 567"
- "PAC ne démarre plus après coupure EDF"
- "borne IRVE ne charge plus depuis hier"
---
## Prochaine session — ordre de travail
1. `pip install python-telegram-bot httpx python-dotenv` + scaffold du projet
2. Implémenter `ConversationHandler` avec les 3 états
3. Upload WebDAV sur Nextcloud (tester avec une vraie photo)
4. Notifications groupes Telegram
5. Cartes Nextcloud Deck
6. (optionnel) Intégration Ollama FAQ SAV
---
## ⚠️ Priorité n°1 — Gestion albums multi-photos (media_group_id)
### Problème
Un technicien envoie 5-8 photos d'un coup (onduleur, batteries, HEMS, tableau, câblage...).
Telegram envoie chaque photo comme un webhook séparé avec le même `media_group_id`.
Il faut les regrouper avant d'uploader et n'envoyer qu'une seule notification.
### Solution Python
```python
# Collecter les photos d'un même album pendant 2 secondes
media_groups = {} # media_group_id → liste de fichiers
async def handle_photo(update, context):
msg = update.message
group_id = msg.media_group_id
if group_id:
# Ajouter à l'album en cours
if group_id not in media_groups:
media_groups[group_id] = {
'photos': [],
'caption': msg.caption or '',
'chat_id': msg.chat_id,
'user': msg.from_user
}
# Déclencher l'upload après 3 secondes (le temps de tout recevoir)
context.job_queue.run_once(
upload_album, 3,
data={'group_id': group_id},
name=group_id
)
media_groups[group_id]['photos'].append(
msg.photo[-1].file_id # Meilleure qualité
)
else:
# Photo seule — uploader directement
await upload_single_photo(msg)
async def upload_album(context):
group_id = context.job.data['group_id']
album = media_groups.pop(group_id, None)
if not album:
return
# Uploader toutes les photos
for i, file_id in enumerate(album['photos']):
file = await context.bot.get_file(file_id)
# Upload WebDAV → Nextcloud/Chantiers/YYYYMMDD_Client/30_Photos_Chantier/photo_01.jpg
await upload_to_nextcloud(file, album['caption'], i + 1)
# Une seule notification
await notify_fin_chantier(album, len(album['photos']))
```
### Notification finale (exemple 6 photos)
```
✅ Fin de chantier
👷 Patrick Schurig
🏠 Chantier : Müller
📸 6 photos archivées → Nextcloud/Chantiers/20260325_Müller/30_Photos_Chantier/
💶 Tu peux préparer la facture finale.
```
### Nommage des photos sur Nextcloud
```
30_Photos_Chantier/
├── photo_01_20260325_143201.jpg
├── photo_02_20260325_143202.jpg
├── photo_03_20260325_143203.jpg
...
└── photo_06_20260325_143208.jpg
```

420
CLAUDE_python_bot_V2.md Normal file
View File

@ -0,0 +1,420 @@
# ETM Telegram Bot — Réécriture Python
## Contexte métier
Entreprise ETM-Schurig SARL — installateur RGE (PV, PAC, IRVE, HEMS) en Alsace.
Bot Telegram pour les techniciens terrain — remplace les appels téléphoniques.
## Stack cible
- **python-telegram-bot** v20+ (async)
- **python-nextcloud** ou WebDAV direct (httpx) pour Nextcloud
- **Ollama** (LLM local) pour FAQ/SAV intelligent — phase 2
- **SQLite** ou fichier JSON pour la session/état utilisateur
- Déployé sur le VPS Proxmox existant (même infra que n8n)
---
## Credentials
| Paramètre | Valeur |
|---|---|
| Bot Token | `8608752199:AAGZ9Vyop0MVm4msxUyDsUuoNvN1hKM12vc` |
| Groupe Chat Chantier | `-5208574803` |
| Groupe ETM-Magasinier | `-5056192608` |
| Patrick ID | `8022751692` |
| Nextcloud WebDAV | `https://cloud.etm-schurig.eu/remote.php/webdav` |
| Nextcloud User | `Patrick.Schurig` |
| Nextcloud Deck API | `https://cloud.etm-schurig.eu/index.php/apps/deck/api/v1/` |
---
## Fonctionnalités MVP
### 3 actions via boutons inline
| Bouton | callback_data | Règles |
|---|---|---|
| 📸 Fin de Chantier | `fin` | Photo **obligatoire** + légende `NomClient / Notes` |
| ⚠️ Alerte SAV | `sav` | Photo optionnelle + `NomChantier / Description` |
| 📦 Matériel Manquant | `materiel` | Texte seul accepté + `NomChantier / - item1 - item2` |
### Format de saisie universel
```
NomChantier / description ou liste avec - comme séparateur
```
Exemples :
- `Müller / onduleur + batterie posés`
- `Müller / - cuivre 28mm - disconnecteur - soupape anti-gel`
### Chemins Nextcloud
```
Fin chantier : Chantiers/YYYYMMDD_Client/30_Photos_Chantier/photo_timestamp.jpg
SAV : SAV_Urgent/YYYY-MM-DD_Client/photo.jpg
Matériel : Logistique/Manquants/Client/photo_timestamp.jpg
```
### Notifications
| Action | Destinataire | Contenu |
|---|---|---|
| Fin chantier | Groupe Chat Chantier `-5208574803` | ✅ + ouvrier + chantier + chemin Nextcloud + "prépare la facture" |
| SAV | Groupe ETM-SAV (à créer) | 🚨 + ouvrier + chantier + description + photo |
| Matériel | Groupe ETM-Magasinier `-5056192608` | 📦 + ouvrier + chantier + liste formatée |
### Cartes Nextcloud Deck (phase 1b)
- Tableau : **ETM Chantiers**
- 3 colonnes : `Fin de Chantier` / `SAV` / `Matériel`
- Créer une carte automatiquement à chaque signalement
---
## Architecture Python recommandée
```
etm_bot/
├── main.py # Entry point, ConversationHandler
├── config.py # Tokens, IDs, URLs (chargés depuis .env)
├── handlers/
│ ├── menu.py # /start, boutons inline
│ ├── fin_chantier.py # Workflow fin de chantier
│ ├── sav.py # Workflow SAV
│ └── materiel.py # Workflow matériel manquant
├── services/
│ ├── nextcloud.py # Upload WebDAV + Deck API
│ ├── telegram.py # Helpers send_message, notify_group
│ └── llm.py # Ollama FAQ/SAV — phase 2
├── models/
│ └── session.py # État conversation par user_id (dict en mémoire ou SQLite)
└── requirements.txt
```
### États ConversationHandler
```python
MENU = 0
ATTENTE_PHOTO_FIN = 1
ATTENTE_CONTENU_SAV = 2
ATTENTE_CONTENU_MATERIEL = 3
```
---
## Phase 2 — Wiki/FAQ SAV avec Ollama
Quand un technicien signale un SAV, proposer automatiquement des solutions :
```python
# services/llm.py
async def chercher_solution_sav(description: str) -> str:
# Appel Ollama local (mistral ou llama3)
response = await ollama.chat(
model="mistral",
messages=[{
"role": "system",
"content": "Tu es un expert en installation photovoltaïque, PAC et IRVE. "
"Donne des pistes de diagnostic pour ce problème terrain."
}, {
"role": "user",
"content": description
}]
)
return response["message"]["content"]
```
Exemples de questions SAV que le LLM doit pouvoir traiter :
- "onduleur Fronius affiche erreur 567"
- "PAC ne démarre plus après coupure EDF"
- "borne IRVE ne charge plus depuis hier"
---
## Prochaine session — ordre de travail
1. `pip install python-telegram-bot httpx python-dotenv` + scaffold du projet
2. Implémenter `ConversationHandler` avec les 3 états
3. Upload WebDAV sur Nextcloud (tester avec une vraie photo)
4. Notifications groupes Telegram
5. Cartes Nextcloud Deck
6. (optionnel) Intégration Ollama FAQ SAV
---
## ⚠️ Priorité n°1 — Gestion albums multi-photos (media_group_id)
### Problème
Un technicien envoie 5-8 photos d'un coup (onduleur, batteries, HEMS, tableau, câblage...).
Telegram envoie chaque photo comme un webhook séparé avec le même `media_group_id`.
Il faut les regrouper avant d'uploader et n'envoyer qu'une seule notification.
### Solution Python
```python
# Collecter les photos d'un même album pendant 2 secondes
media_groups = {} # media_group_id → liste de fichiers
async def handle_photo(update, context):
msg = update.message
group_id = msg.media_group_id
if group_id:
# Ajouter à l'album en cours
if group_id not in media_groups:
media_groups[group_id] = {
'photos': [],
'caption': msg.caption or '',
'chat_id': msg.chat_id,
'user': msg.from_user
}
# Déclencher l'upload après 3 secondes (le temps de tout recevoir)
context.job_queue.run_once(
upload_album, 3,
data={'group_id': group_id},
name=group_id
)
media_groups[group_id]['photos'].append(
msg.photo[-1].file_id # Meilleure qualité
)
else:
# Photo seule — uploader directement
await upload_single_photo(msg)
async def upload_album(context):
group_id = context.job.data['group_id']
album = media_groups.pop(group_id, None)
if not album:
return
# Uploader toutes les photos
for i, file_id in enumerate(album['photos']):
file = await context.bot.get_file(file_id)
# Upload WebDAV → Nextcloud/Chantiers/YYYYMMDD_Client/30_Photos_Chantier/photo_01.jpg
await upload_to_nextcloud(file, album['caption'], i + 1)
# Une seule notification
await notify_fin_chantier(album, len(album['photos']))
```
### Notification finale (exemple 6 photos)
```
✅ Fin de chantier
👷 Patrick Schurig
🏠 Chantier : Müller
📸 6 photos archivées → Nextcloud/Chantiers/20260325_Müller/30_Photos_Chantier/
💶 Tu peux préparer la facture finale.
```
### Nommage des photos sur Nextcloud
```
30_Photos_Chantier/
├── photo_01_20260325_143201.jpg
├── photo_02_20260325_143202.jpg
├── photo_03_20260325_143203.jpg
...
└── photo_06_20260325_143208.jpg
```
---
## Étape 2 — Dropdown chantiers depuis Nextcloud
### Objectif
Quand le technicien choisit une action, le bot affiche la liste des dossiers
`Nextcloud/Chantiers/` comme boutons inline — plus de fautes de frappe.
### Flow
```
Technicien clique "Fin de Chantier"
→ Bot lit Nextcloud/Chantiers/ via WebDAV (PROPFIND)
→ Bot affiche liste des 10 dossiers les plus récents
→ Technicien clique sur le bon chantier
→ Bot demande la/les photo(s)
→ Technicien envoie photos
→ Upload + notification
```
### Code Python
```python
# services/nextcloud.py
import httpx
from xml.etree import ElementTree as ET
async def get_chantiers_actifs(base_url, user, password, max_results=10):
"""Lire les dossiers Nextcloud/Chantiers/ via WebDAV PROPFIND"""
url = f"{base_url}/remote.php/dav/files/{user}/Chantiers/"
auth = (user, password)
async with httpx.AsyncClient() as client:
response = await client.request(
"PROPFIND", url,
auth=auth,
headers={"Depth": "1", "Content-Type": "application/xml"},
content=b"""<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:">
<d:prop><d:displayname/><d:resourcetype/></d:prop>
</d:propfind>"""
)
# Parser le XML WebDAV
root = ET.fromstring(response.text)
ns = {"d": "DAV:"}
dossiers = []
for resp in root.findall("d:response", ns):
href = resp.find("d:href", ns).text
name = href.rstrip("/").split("/")[-1]
# Ignorer le dossier racine et les fichiers cachés
if name == "Chantiers" or name.startswith("."):
continue
# Vérifier que c'est un dossier (resourcetype contient d:collection)
resourcetype = resp.find(".//d:resourcetype", ns)
if resourcetype is not None and resourcetype.find("d:collection", ns) is not None:
dossiers.append(name)
# Trier par date décroissante (format YYYYMMDD_ en début de nom)
dossiers.sort(reverse=True)
return dossiers[:max_results]
# handlers/fin_chantier.py
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from services.nextcloud import get_chantiers_actifs
async def afficher_choix_chantier(update, context):
"""Afficher la liste des chantiers Nextcloud comme boutons"""
chantiers = await get_chantiers_actifs(
base_url=config.NC_URL,
user=config.NC_USER,
password=config.NC_PASS
)
# Créer les boutons (1 par ligne pour lisibilité sur mobile)
keyboard = [
[InlineKeyboardButton(
text=formater_nom(c), # ex: "Müller — 25/03/2026"
callback_data=f"chantier_{c}"
)]
for c in chantiers
]
# Bouton pour un chantier non listé
keyboard.append([InlineKeyboardButton(
"✏️ Autre chantier (saisie manuelle)",
callback_data="chantier_manuel"
)])
await update.callback_query.message.reply_text(
"🏠 Quel chantier ?",
reply_markup=InlineKeyboardMarkup(keyboard)
)
return SELECTION_CHANTIER
def formater_nom(dossier: str) -> str:
"""20260325_Müller → Müller — 25/03/2026"""
if len(dossier) >= 9 and dossier[:8].isdigit():
date_str = dossier[:8]
nom = dossier[9:]
date_fmt = f"{date_str[6:8]}/{date_str[4:6]}/{date_str[:4]}"
return f"{nom} — {date_fmt}"
return dossier
```
### États ConversationHandler mis à jour
```python
MENU = 0
SELECTION_CHANTIER = 1 # Nouveau — choix du chantier
ATTENTE_PHOTO_FIN = 2
ATTENTE_CONTENU_SAV = 3
ATTENTE_CONTENU_MATERIEL = 4
SAISIE_MANUELLE = 5 # Si chantier non listé
```
### Affichage sur Telegram
```
🏠 Quel chantier ?
[Müller — 25/03/2026 ]
[Hartmann — 22/03/2026 ]
[Weber — 18/03/2026 ]
[Schmitt — 15/03/2026 ]
[✏️ Autre chantier (saisie manuelle)]
```
---
## Phase 2 — FAQ SAV avec RAG (sans LLM complet)
### Architecture recommandée
Pas besoin d'un LLM lourd — **RAG léger avec ChromaDB** suffit
et tourne sur le Proxmox existant (ou Pi 5 si besoin).
```
Document SAV entrant
→ ChromaDB cherche les 3 passages les plus proches
→ Bot envoie les extraits directement au technicien
→ (optionnel) Ollama reformule si VM Proxmox disponible
```
### Matériel
| Option | Matériel | Latence | Verdict |
|---|---|---|---|
| Recherche vectorielle seule | Pi 4 4GB | < 1s | Parfait |
| RAG + petit modèle (phi3-mini) | Pi 5 8GB | 15-30s | ⚠️ Acceptable |
| RAG + Mistral 7B | VM Proxmox 16GB RAM | 3-8s | ✅ Recommandé |
| API distante (Groq/Claude) | N'importe quoi | < 2s | Le plus simple |
### Recommandation pour ETM
Utiliser **Ollama sur le Proxmox existant** — il est déjà là,
pas besoin d'investir dans un Pi supplémentaire.
```python
# services/llm.py
import chromadb
import httpx
class SAVAssistant:
def __init__(self):
self.db = chromadb.PersistentClient(path="./sav_knowledge")
self.collection = self.db.get_or_create_collection("docs_techniques")
async def indexer_document(self, texte: str, source: str):
"""Indexer une notice technique, un rapport SAV, etc."""
# Découper en chunks de 500 mots
chunks = [texte[i:i+500] for i in range(0, len(texte), 400)]
self.collection.add(
documents=chunks,
ids=[f"{source}_{i}" for i in range(len(chunks))],
metadatas=[{"source": source}] * len(chunks)
)
async def chercher(self, question: str, n_results=3) -> str:
"""Chercher les passages pertinents pour un problème SAV"""
results = self.collection.query(
query_texts=[question],
n_results=n_results
)
extraits = results["documents"][0]
sources = [m["source"] for m in results["metadatas"][0]]
# Reformuler avec Ollama si disponible
try:
response = await httpx.AsyncClient().post(
"http://localhost:11434/api/generate",
json={
"model": "phi3",
"prompt": f"Problème terrain : {question}\n\nDocumentation :\n" +
"\n\n".join(extraits) +
"\n\nDonne 3 pistes de diagnostic en français, "
"en langage simple pour un technicien.",
"stream": False
},
timeout=30
)
return response.json()["response"]
except:
# Si Ollama indisponible, retourner les extraits bruts
return "\n\n---\n\n".join(
[f"📄 {s}\n{e}" for s, e in zip(sources, extraits)]
)
```
### Documents à indexer en priorité
- Notices onduleurs : Fronius, SMA, Huawei, Enphase
- Notices batteries : BYD, Pylontech, Soltaro
- Notices PAC : notices d'installation et de dépannage
- Bornes IRVE : Keba, Wallbox, ETM-PowerCharger
- Historique SAV ETM : rapports d'intervention passés (mine d'or !)

19
Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM python:3.12-slim
WORKDIR /app
# Dépendances système pour ChromaDB (compilation d'extensions natives)
RUN apt-get update && apt-get install -y \
gcc \
g++ \
libffi-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /app/data/chromadb /app/data/docs /app/sav_knowledge
CMD ["python", "main.py"]

Binary file not shown.

24
config.py Normal file
View File

@ -0,0 +1,24 @@
import os
from dotenv import load_dotenv
load_dotenv()
BOT_TOKEN: str = os.environ["TELEGRAM_BOT_TOKEN"]
GROUPE_CHANTIER: int = int(os.environ["GROUPE_CHANTIER"])
GROUPE_MAGASINIER: int = int(os.environ["GROUPE_MAGASINIER"])
GROUPE_SAV: int = int(os.environ.get("GROUPE_SAV", "0"))
PATRICK_ID: int = int(os.environ["PATRICK_ID"])
NEXTCLOUD_URL: str = os.environ["NEXTCLOUD_URL"].rstrip("/")
NEXTCLOUD_USER: str = os.environ["NEXTCLOUD_USER"]
NEXTCLOUD_PASSWORD: str = os.environ["NEXTCLOUD_PASSWORD"]
NEXTCLOUD_DECK_URL: str = os.environ["NEXTCLOUD_DECK_URL"].rstrip("/")
DECK_BOARD_ID: int = int(os.environ.get("DECK_BOARD_ID", "0"))
DECK_COL_FIN: int = int(os.environ.get("DECK_COL_FIN", "0"))
DECK_COL_SAV: int = int(os.environ.get("DECK_COL_SAV", "0"))
DECK_COL_MATERIEL: int = int(os.environ.get("DECK_COL_MATERIEL", "0"))
OLLAMA_ENABLED: bool = os.environ.get("OLLAMA_ENABLED", "false").lower() == "true"
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "phi3")

12
handlers/__init__.py Normal file
View File

@ -0,0 +1,12 @@
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
MENU_TEXT = "Que voulez-vous signaler ?"
def get_menu_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([
[InlineKeyboardButton("📸 Fin de Chantier", callback_data="fin")],
[InlineKeyboardButton("⚠️ Alerte SAV", callback_data="sav")],
[InlineKeyboardButton("📦 Matériel Manquant", callback_data="materiel")],
[InlineKeyboardButton("📚 FAQ / Diagnostic", callback_data="faq")],
])

83
handlers/chantier.py Normal file
View File

@ -0,0 +1,83 @@
from datetime import datetime
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import ContextTypes
from telegram import Update
from services.nextcloud import get_chantiers_actifs
from models.session import set_state, set_data, get_data, ATTENTE_PHOTO_FIN, ATTENTE_CONTENU_SAV, ATTENTE_CONTENU_MATERIEL, SAISIE_MANUELLE_CHANTIER
FLOW_FIN = "fin"
FLOW_SAV = "sav"
FLOW_MAT = "mat"
_NEXT_STATE = {
FLOW_FIN: ATTENTE_PHOTO_FIN,
FLOW_SAV: ATTENTE_CONTENU_SAV,
FLOW_MAT: ATTENTE_CONTENU_MATERIEL,
}
_FALLBACK_PROMPT = {
FLOW_FIN: "📸 *Fin de Chantier*\n\nEnvoyez la/les photo(s) avec :\n`NOM_Ville / Notes`",
FLOW_SAV: "⚠️ *Alerte SAV*\n\nEnvoyez avec :\n`NOM_Ville / Description`\nPhoto optionnelle.",
FLOW_MAT: "📦 *Matériel Manquant*\n\nEnvoyez avec :\n`NOM_Ville / - item1 - item2`",
}
_SELECTED_PROMPT = {
FLOW_FIN: "📸 *{nom}*\n\nEnvoyez la/les photo(s).\nLégende optionnelle pour des notes.",
FLOW_SAV: "⚠️ *{nom}*\n\nDécrivez le problème.\nPhoto optionnelle.",
FLOW_MAT: "📦 *{nom}*\n\nEnvoyez la liste :\n`- item1 - item2`",
}
def formater_nom(dossier: str) -> str:
"""260518_Müller_Strasbourg → Müller Strasbourg — 18/05/26"""
if len(dossier) >= 8 and dossier[:6].isdigit() and dossier[6] == "_":
d = dossier[:6]
nom = dossier[7:].replace("_", " ")
return f"{nom}{d[4:6]}/{d[2:4]}/{d[:2]}"
return dossier.replace("_", " ")
def nom_depuis_dossier(dossier: str) -> str:
"""260518_Müller_Strasbourg → Müller_Strasbourg"""
if len(dossier) >= 8 and dossier[:6].isdigit() and dossier[6] == "_":
return dossier[7:]
return dossier
async def afficher_choix_chantier(query, flow: str) -> None:
chantiers = await get_chantiers_actifs()
if not chantiers:
set_state(query.from_user.id, _NEXT_STATE[flow])
await query.edit_message_text(_FALLBACK_PROMPT[flow], parse_mode="Markdown")
return
keyboard = [
[InlineKeyboardButton(formater_nom(c), callback_data=f"chantier:{flow}:{c[:45]}")]
for c in chantiers
]
keyboard.append([InlineKeyboardButton("✏️ Nouveau chantier", callback_data=f"chantier_manuel:{flow}")])
await query.edit_message_text("🏠 Quel chantier ?", reply_markup=InlineKeyboardMarkup(keyboard))
async def handle_chantier_callback(query, user_id: int) -> None:
_, flow, chantier_folder = query.data.split(":", 2)
set_data(user_id, "chantier_folder", chantier_folder)
set_data(user_id, "flow", flow)
set_state(user_id, _NEXT_STATE[flow])
prompt = _SELECTED_PROMPT[flow].format(nom=formater_nom(chantier_folder))
await query.edit_message_text(prompt, parse_mode="Markdown")
async def handle_saisie_manuelle(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
from handlers import get_menu_keyboard, MENU_TEXT
msg = update.message
chantier = (msg.text or "").strip()
if not chantier:
await msg.reply_text("❌ Nom invalide. Format : `NOM_Ville`", parse_mode="Markdown")
return
user_id = msg.from_user.id
flow = get_data(user_id, "flow", FLOW_FIN)
chantier_folder = f"{datetime.now().strftime('%y%m%d')}_{chantier}"
set_data(user_id, "chantier_folder", chantier_folder)
set_state(user_id, _NEXT_STATE[flow])
prompt = _SELECTED_PROMPT[flow].format(nom=chantier)
await msg.reply_text(prompt, parse_mode="Markdown")

31
handlers/faq.py Normal file
View File

@ -0,0 +1,31 @@
from telegram import Update, ForceReply
from telegram.ext import ContextTypes
from models.session import set_state, MENU
from handlers import get_menu_keyboard, MENU_TEXT
from services.faq_service import faq_service
async def handle_faq(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
msg = update.message
question = (msg.text or "").strip()
if not question:
await msg.reply_text("❌ Merci d'écrire ta question.")
return
thinking = await msg.reply_text("🔍 Recherche en cours...")
reponse = await faq_service.repondre(question)
await thinking.edit_text(reponse)
set_state(msg.from_user.id, MENU)
await msg.reply_text(MENU_TEXT, reply_markup=get_menu_keyboard())
async def cmd_faq(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not context.args:
await update.message.reply_text(
"📚 FAQ ETM\n\nPose ta question :\n/faq Fronius erreur 567\n/faq PAC ne démarre plus",
reply_markup=ForceReply(selective=True),
)
return
question = " ".join(context.args)
msg = await update.message.reply_text("🔍 Recherche en cours...")
reponse = await faq_service.repondre(question)
await msg.edit_text(reponse)

148
handlers/fin_chantier.py Normal file
View File

@ -0,0 +1,148 @@
import logging
from datetime import datetime
from telegram import Update
from telegram.ext import ContextTypes
from models.session import set_state, get_data, clear_data, MENU
from handlers import get_menu_keyboard, MENU_TEXT
from handlers.chantier import nom_depuis_dossier
from services.nextcloud import upload_photo, create_deck_card
from services.telegram import notify_fin_chantier as send_notification
from config import DECK_BOARD_ID, DECK_COL_FIN
log = logging.getLogger(__name__)
_albums: dict[str, dict] = {}
async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
msg = update.message
if not msg or not msg.photo:
if msg:
await msg.reply_text("❌ Veuillez envoyer une photo.")
return
caption = msg.caption or ""
group_id = msg.media_group_id
ouvrier = msg.from_user.full_name
file_id = msg.photo[-1].file_id
user_id = msg.from_user.id
chantier_folder = get_data(user_id, "chantier_folder")
if group_id:
if group_id not in _albums:
_albums[group_id] = {
"photos": [],
"caption": "",
"ouvrier": ouvrier,
"chat_id": msg.chat_id,
"user_id": user_id,
"chantier_folder": chantier_folder,
}
context.job_queue.run_once(
_process_album_job,
3,
data={"group_id": group_id},
name=str(group_id),
)
if caption:
_albums[group_id]["caption"] = caption
_albums[group_id]["photos"].append(file_id)
else:
if not chantier_folder and not caption:
await msg.reply_text(
"❌ Légende obligatoire.\nFormat : `NOM_Ville / Notes`",
parse_mode="Markdown",
)
return
await msg.reply_text("⏳ Upload en cours...")
try:
await _upload_photos([file_id], ouvrier, context.bot, chantier_folder=chantier_folder, caption=caption)
except Exception as exc:
log.error("_upload_photos single : %s", exc)
await msg.reply_text("❌ Erreur lors de l'upload.")
finally:
clear_data(user_id)
set_state(user_id, MENU)
await msg.reply_text(MENU_TEXT, reply_markup=get_menu_keyboard())
async def _process_album_job(context: ContextTypes.DEFAULT_TYPE) -> None:
group_id = context.job.data["group_id"]
album = _albums.pop(group_id, None)
if not album:
return
caption = album.get("caption", "")
chat_id = album["chat_id"]
user_id = album["user_id"]
chantier_folder = album.get("chantier_folder")
if not chantier_folder and not caption:
await context.bot.send_message(
chat_id=chat_id,
text="❌ Album reçu sans chantier ni légende.\nFormat : `NOM_Ville / Notes`",
parse_mode="Markdown",
)
set_state(user_id, MENU)
await context.bot.send_message(chat_id=chat_id, text=MENU_TEXT, reply_markup=get_menu_keyboard())
return
n = len(album["photos"])
await context.bot.send_message(chat_id=chat_id, text=f"⏳ Upload de {n} photo(s) en cours...")
try:
await _upload_photos(album["photos"], album["ouvrier"], context.bot, chantier_folder=chantier_folder, caption=caption)
except Exception as exc:
log.error("_upload_photos album : %s", exc)
await context.bot.send_message(chat_id=chat_id, text="❌ Erreur lors de l'upload.")
finally:
clear_data(user_id)
set_state(user_id, MENU)
await context.bot.send_message(chat_id=chat_id, text=MENU_TEXT, reply_markup=get_menu_keyboard())
async def _upload_photos(
file_ids: list[str],
ouvrier: str,
bot,
*,
chantier_folder: str | None = None,
caption: str = "",
) -> None:
now = datetime.now()
if chantier_folder:
nc_dir = f"Chantiers/{chantier_folder}/30_Photos_Chantier"
chantier_name = nom_depuis_dossier(chantier_folder)
notes = caption.strip()
else:
chantier_name, notes = _parse_caption(caption)
nc_dir = f"Chantiers/{now.strftime('%y%m%d')}_{chantier_name}/30_Photos_Chantier"
uploaded = 0
for i, file_id in enumerate(file_ids):
try:
tg_file = await bot.get_file(file_id)
file_bytes = bytes(await tg_file.download_as_bytearray())
ts = now.strftime("%y%m%d_%H%M%S")
ok = await upload_photo(file_bytes, f"{nc_dir}/photo_{i + 1:02d}_{ts}.jpg")
if ok:
uploaded += 1
except Exception as exc:
log.error("Erreur upload photo %d : %s", i + 1, exc)
try:
await send_notification(bot, ouvrier, chantier_name, uploaded, f"Nextcloud/{nc_dir}/")
except Exception as exc:
log.error("notify_fin_chantier : %s", exc)
if DECK_BOARD_ID and DECK_COL_FIN:
await create_deck_card(
DECK_BOARD_ID,
DECK_COL_FIN,
f"Fin chantier — {chantier_name}",
f"Ouvrier : {ouvrier}\n{len(file_ids)} photo(s)\nNotes : {notes}",
)
def _parse_caption(caption: str) -> tuple[str, str]:
chantier, _, notes = caption.partition("/")
return chantier.strip(), notes.strip()

55
handlers/materiel.py Normal file
View File

@ -0,0 +1,55 @@
import logging
from telegram import Update
from telegram.ext import ContextTypes
from models.session import set_state, get_data, clear_data, MENU
from handlers import get_menu_keyboard, MENU_TEXT
from handlers.chantier import nom_depuis_dossier
from services.nextcloud import create_deck_card
from services.telegram import notify_materiel as send_notification
from config import DECK_BOARD_ID, DECK_COL_MATERIEL
log = logging.getLogger(__name__)
async def handle_materiel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
msg = update.message
text = (msg.text or "").strip()
user_id = msg.from_user.id
chantier_folder = get_data(user_id, "chantier_folder")
if chantier_folder:
chantier = nom_depuis_dossier(chantier_folder)
liste = text
else:
if not text or "/" not in text:
await msg.reply_text(
"❌ Format requis : `NOM_Ville / - item1 - item2`",
parse_mode="Markdown",
)
return
chantier, _, liste = text.partition("/")
chantier = chantier.strip()
liste = liste.strip()
if not liste:
await msg.reply_text("❌ Liste vide.", parse_mode="Markdown")
return
ouvrier = msg.from_user.full_name
try:
await send_notification(context.bot, ouvrier, chantier, liste)
except Exception as exc:
log.error("notify_materiel : %s", exc)
if DECK_BOARD_ID and DECK_COL_MATERIEL:
await create_deck_card(
DECK_BOARD_ID,
DECK_COL_MATERIEL,
f"Matériel — {chantier}",
f"Ouvrier : {ouvrier}\n{liste}",
)
clear_data(user_id)
set_state(user_id, MENU)
await msg.reply_text(MENU_TEXT, reply_markup=get_menu_keyboard())

39
handlers/menu.py Normal file
View File

@ -0,0 +1,39 @@
from telegram import Update
from telegram.ext import ContextTypes
from models.session import set_state, SAISIE_MANUELLE_CHANTIER, set_data, ATTENTE_FAQ
from handlers import get_menu_keyboard, MENU_TEXT
from handlers.chantier import afficher_choix_chantier, handle_chantier_callback, FLOW_FIN, FLOW_SAV, FLOW_MAT
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.effective_message.reply_text(MENU_TEXT, reply_markup=get_menu_keyboard())
async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer()
user_id = query.from_user.id
if query.data == "fin":
await afficher_choix_chantier(query, FLOW_FIN)
elif query.data == "sav":
await afficher_choix_chantier(query, FLOW_SAV)
elif query.data == "materiel":
await afficher_choix_chantier(query, FLOW_MAT)
elif query.data.startswith("chantier:"):
await handle_chantier_callback(query, user_id)
elif query.data == "faq":
set_state(user_id, ATTENTE_FAQ)
await query.edit_message_text(
"📚 *FAQ / Diagnostic*\n\nDécris ton problème :",
parse_mode="Markdown",
)
elif query.data.startswith("chantier_manuel:"):
_, flow = query.data.split(":", 1)
set_data(user_id, "flow", flow)
set_state(user_id, SAISIE_MANUELLE_CHANTIER)
await query.edit_message_text(
"✏️ *Nouveau chantier*\n\nEntrez le nom :\n`NOM_Ville`\n\n"
"Exemple : `Müller_Strasbourg`",
parse_mode="Markdown",
)

126
handlers/resolu.py Normal file
View File

@ -0,0 +1,126 @@
import logging
from datetime import datetime
from telegram import Update
from telegram.ext import ContextTypes
from config import PATRICK_ID
from services.tickets import get_ticket, resoudre_ticket
from services.faq_service import faq_service
from services.nextcloud import upload_text
from utils.produit_detector import detecter_produit
log = logging.getLogger(__name__)
def _parse_args(args: list[str]) -> tuple[str, str, str, int]:
"""
Format : /resolu #2026-047 cause | solution | 45min
Retourne : (ticket_id, cause, solution, duree_min)
"""
texte = " ".join(args)
ticket_id = texte.split()[0].lstrip("#")
reste = texte[len(ticket_id) + 1:].strip()
parties = [p.strip() for p in reste.split("|")]
cause = parties[0] if len(parties) > 0 else ""
solution = parties[1] if len(parties) > 1 else ""
duree_str = parties[2] if len(parties) > 2 else "0"
duree_min = int("".join(c for c in duree_str if c.isdigit()) or "0")
return ticket_id, cause, solution, duree_min
def _generer_fiche_md(ticket: dict, cause: str, solution: str, duree_min: int, produit: str) -> str:
date = datetime.now().strftime("%Y-%m-%d")
return f"""# SAV Résolu — {ticket['id']}
**Chantier :** {ticket['chantier']}
**Date :** {date}
**Technicien :** {ticket['username']}
**Produit :** {produit}
**Durée :** {duree_min} min
## Symptôme
{ticket['description']}
## Cause
{cause}
## Solution
{solution}
"""
async def cmd_resolu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""/resolu #2026-047 relais AC défaillant | remplacer ref 4241 | 45min"""
if update.effective_user.id != PATRICK_ID:
return
if not context.args:
await update.message.reply_text(
"Usage : `/resolu #2026-047 cause | solution | 45min`",
parse_mode="Markdown",
)
return
try:
ticket_id, cause, solution, duree_min = _parse_args(context.args)
except Exception:
await update.message.reply_text("❌ Format invalide.\nUsage : `/resolu #ID cause | solution | durée`", parse_mode="Markdown")
return
ticket = get_ticket(ticket_id)
if not ticket:
await update.message.reply_text(f"❌ Ticket `#{ticket_id}` introuvable.", parse_mode="Markdown")
return
if ticket["status"] == "resolu":
await update.message.reply_text(f"⚠️ Ticket `#{ticket_id}` déjà résolu.", parse_mode="Markdown")
return
# 1. Clôturer dans SQLite
resoudre_ticket(ticket_id, cause, solution, duree_min)
# 2. Détecter le produit
produit = detecter_produit(f"{ticket['description']} {cause} {solution}")
# 3. Générer fiche .md → Nextcloud
date_str = datetime.now().strftime("%Y%m%d")
slug = ticket["chantier"].replace(" ", "_")[:30]
nc_path = f"SAV/{date_str}_{slug}_{ticket_id}.md"
fiche_md = _generer_fiche_md(ticket, cause, solution, duree_min, produit)
try:
await upload_text(fiche_md, nc_path)
except Exception as exc:
log.error("upload fiche SAV : %s", exc)
# 4. Indexer dans la FAQ
faq_service.indexer_fiche_sav(
ticket_id=ticket_id,
produit=produit,
symptome=ticket["description"],
cause=cause,
solution=solution,
chantier=ticket["chantier"],
date=datetime.now().strftime("%Y-%m-%d"),
duree_min=duree_min,
)
# 5. Notifier le technicien original
try:
await context.bot.send_message(
chat_id=ticket["chat_id"],
text=(
f"✅ *SAV #{ticket_id} résolu*\n\n"
f"🏠 {ticket['chantier']}\n"
f"🔧 {produit}\n"
f"💡 *Solution :* {solution}\n"
f"{duree_min} min"
),
parse_mode="Markdown",
)
except Exception as exc:
log.error("notify technicien : %s", exc)
await update.message.reply_text(
f"✅ Ticket `#{ticket_id}` clôturé et indexé dans la FAQ.\n"
f"📁 Fiche : `{nc_path}`",
parse_mode="Markdown",
)

93
handlers/sav.py Normal file
View File

@ -0,0 +1,93 @@
import logging
from datetime import datetime
from telegram import Update
from telegram.ext import ContextTypes
from models.session import set_state, get_data, clear_data, MENU
from handlers import get_menu_keyboard, MENU_TEXT
from handlers.chantier import nom_depuis_dossier
from services.nextcloud import upload_photo, create_deck_card
from services.telegram import notify_sav as send_notification
from services.faq_service import faq_service
from services.tickets import creer_ticket
from utils.produit_detector import detecter_produit
from config import DECK_BOARD_ID, DECK_COL_SAV
log = logging.getLogger(__name__)
async def handle_sav(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
msg = update.message
text = (msg.caption or msg.text or "").strip()
user_id = msg.from_user.id
chantier_folder = get_data(user_id, "chantier_folder")
# Résoudre le chantier et la description
if chantier_folder:
chantier = nom_depuis_dossier(chantier_folder)
description = text
else:
if not text or "/" not in text:
await msg.reply_text(
"❌ Format requis : `NOM_Ville / Description`",
parse_mode="Markdown",
)
return
chantier, _, description = text.partition("/")
chantier = chantier.strip()
description = description.strip()
if not description:
await msg.reply_text("❌ Description manquante.", parse_mode="Markdown")
return
ouvrier = msg.from_user.full_name
now = datetime.now()
photo_bytes: bytes | None = None
if msg.photo:
try:
tg_file = await context.bot.get_file(msg.photo[-1].file_id)
photo_bytes = bytes(await tg_file.download_as_bytearray())
ts = now.strftime("%y%m%d_%H%M%S")
remote_path = f"SAV_Urgent/{now.strftime('%Y-%m-%d')}_{chantier}/photo_{ts}.jpg"
await upload_photo(photo_bytes, remote_path)
except Exception as exc:
log.error("SAV photo upload : %s", exc)
# Créer ticket SQLite
produit = detecter_produit(description)
ticket_id = creer_ticket(
user_id=msg.from_user.id,
username=msg.from_user.full_name,
chat_id=msg.chat_id,
chantier=chantier,
description=description,
produit=produit,
)
try:
await send_notification(context.bot, ouvrier, chantier, description, photo_bytes)
except Exception as exc:
log.error("notify_sav : %s", exc)
if DECK_BOARD_ID and DECK_COL_SAV:
await create_deck_card(
DECK_BOARD_ID,
DECK_COL_SAV,
f"SAV #{ticket_id}{chantier}",
f"Ouvrier : {ouvrier}\n{description}",
)
clear_data(user_id)
set_state(user_id, MENU)
await msg.reply_text(
f"✅ SAV enregistré — ticket `#{ticket_id}`",
parse_mode="Markdown",
)
await msg.reply_text(MENU_TEXT, reply_markup=get_menu_keyboard())
# FAQ — pistes de diagnostic si base disponible
if faq_service.disponible:
solution = await faq_service.repondre(description)
if solution:
await msg.reply_text(f"💡 Pistes de diagnostic :\n\n{solution}")

165
main.py Normal file
View File

@ -0,0 +1,165 @@
import logging
from telegram import Update
from telegram.ext import Application, CommandHandler, CallbackQueryHandler, MessageHandler, filters
from config import BOT_TOKEN, PATRICK_ID
from models.session import get_state, ATTENTE_PHOTO_FIN, ATTENTE_CONTENU_SAV, ATTENTE_CONTENU_MATERIEL, SAISIE_MANUELLE_CHANTIER, ATTENTE_FAQ
from handlers.menu import start, button_handler
from handlers.fin_chantier import handle_photo
from handlers.chantier import handle_saisie_manuelle
from handlers.sav import handle_sav
from handlers.materiel import handle_materiel
from handlers.faq import cmd_faq, handle_faq
from handlers.resolu import cmd_resolu
from services.tickets import init_db
from scripts.fiches_victron import verifier_et_indexer
logging.basicConfig(
format="%(asctime)s%(name)s%(levelname)s%(message)s",
level=logging.INFO,
)
log = logging.getLogger(__name__)
async def error_handler(update: object, context) -> None:
log.error("Erreur sur update %s : %s", update, context.error, exc_info=context.error)
async def chatid(update: Update, context) -> None:
chat = update.effective_chat
await update.effective_message.reply_text(
f"Chat ID : `{chat.id}`\nNom : {chat.title or chat.full_name}",
parse_mode="Markdown",
)
async def ls_command(update: Update, context) -> None:
"""Liste les fichiers/dossiers d'un chemin Nextcloud. Réservé à Patrick."""
if update.effective_user.id != PATRICK_ID:
return
folder = " ".join(context.args) if context.args else ""
from services.nextcloud import NEXTCLOUD_URL, _auth
import httpx
from urllib.parse import unquote
from xml.etree import ElementTree as ET
url = f"{NEXTCLOUD_URL}/{folder}/" if folder else f"{NEXTCLOUD_URL}/"
try:
async with httpx.AsyncClient(auth=_auth, timeout=15) as client:
r = await client.request("PROPFIND", url, headers={"Depth": "1"},
content=b'<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:resourcetype/></d:prop></d:propfind>')
if r.status_code == 404:
await update.message.reply_text(f"❌ Chemin introuvable : `{folder or '/'}`", parse_mode="Markdown")
return
root = ET.fromstring(r.text)
ns = {"d": "DAV:"}
items = []
for resp in root.findall("d:response", ns):
href = resp.find("d:href", ns).text
name = unquote(href.rstrip("/").split("/")[-1])
if not name:
continue
is_dir = resp.find(".//d:collection", ns) is not None
items.append(("📁" if is_dir else "📄") + f" {name}")
items_text = "\n".join(items) if items else "(vide)"
await update.message.reply_text(f"`/{folder or ''}`\n\n{items_text}", parse_mode="Markdown")
except Exception as exc:
await update.message.reply_text(f"❌ Erreur : {exc}")
async def sync_docs_command(update: Update, context) -> None:
"""Indexe tous les PDFs du dossier Nextcloud Doc/ dans la FAQ SAV. Réservé à Patrick."""
if update.effective_user.id != PATRICK_ID:
return
folder = context.args[0] if context.args else "Doc"
status = await update.message.reply_text(f"🔄 Scan de `{folder}/` en cours...", parse_mode="Markdown")
from services.nextcloud import list_pdfs, download_file
from services.faq_service import faq_service, extract_pdf_text
pdfs = await list_pdfs(folder)
if not pdfs:
await status.edit_text(f"❌ Aucun PDF trouvé dans `{folder}/`.", parse_mode="Markdown")
return
await status.edit_text(f"📚 {len(pdfs)} PDF(s) trouvé(s). Indexation en cours...", parse_mode="Markdown")
from services.pdf_indexer import indexer_pdf
import os as _os
docs_dir = f"./data/docs/{folder.replace('/', '_')}"
_os.makedirs(docs_dir, exist_ok=True)
ok, skipped, total_sections = 0, [], 0
for filename in pdfs:
pdf_bytes = await download_file(folder, filename)
if not pdf_bytes:
skipped.append(filename)
continue
# Sauvegarder localement pour permettre la réindexation offline
local_path = f"{docs_dir}/{filename}"
_os.makedirs(_os.path.dirname(local_path), exist_ok=True)
with open(local_path, "wb") as f:
f.write(pdf_bytes)
try:
n = indexer_pdf(faq_service, local_path)
if n == 0:
# Fallback pypdf si pymupdf n'extrait rien (PDF scanné)
texte = extract_pdf_text(pdf_bytes)
n = faq_service.indexer_document(texte, filename) if texte.strip() else 0
total_sections += n
ok += 1
except Exception as exc:
log.error("indexer_pdf %s : %s", filename, exc)
skipped.append(filename)
lines = [f"{ok}/{len(pdfs)} PDF(s) indexés — {total_sections} sections au total."]
if skipped:
lines.append("⚠️ Non indexés :\n" + "\n".join(f"{s}" for s in skipped))
await status.edit_text("\n\n".join(lines), parse_mode="Markdown")
async def dispatch_message(update: Update, context) -> None:
user_id = update.effective_user.id if update.effective_user else None
if not user_id:
return
state = get_state(user_id)
log.debug("user=%d state=%s", user_id, state)
if state == ATTENTE_PHOTO_FIN:
await handle_photo(update, context)
elif state == SAISIE_MANUELLE_CHANTIER:
await handle_saisie_manuelle(update, context)
elif state == ATTENTE_FAQ:
await handle_faq(update, context)
elif state == ATTENTE_CONTENU_SAV:
await handle_sav(update, context)
elif state == ATTENTE_CONTENU_MATERIEL:
await handle_materiel(update, context)
else:
await start(update, context)
def main() -> None:
init_db()
from services.faq_service import faq_service
verifier_et_indexer(faq_service)
app = Application.builder().token(BOT_TOKEN).build()
app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("chatid", chatid))
app.add_handler(CommandHandler("sync_docs", sync_docs_command))
app.add_handler(CommandHandler("ls", ls_command))
app.add_handler(CommandHandler("faq", cmd_faq))
app.add_handler(CommandHandler("resolu", cmd_resolu))
app.add_handler(CallbackQueryHandler(button_handler))
app.add_error_handler(error_handler)
app.add_handler(MessageHandler(
(filters.TEXT | filters.PHOTO) & ~filters.COMMAND,
dispatch_message,
))
log.info("ETM Bot démarré.")
app.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()

0
models/__init__.py Normal file
View File

29
models/session.py Normal file
View File

@ -0,0 +1,29 @@
MENU = "MENU"
ATTENTE_PHOTO_FIN = "ATTENTE_PHOTO_FIN"
ATTENTE_CONTENU_SAV = "ATTENTE_CONTENU_SAV"
ATTENTE_CONTENU_MATERIEL = "ATTENTE_CONTENU_MATERIEL"
SAISIE_MANUELLE_CHANTIER = "SAISIE_MANUELLE_CHANTIER"
ATTENTE_FAQ = "ATTENTE_FAQ"
_states: dict[int, str] = {}
_data: dict[int, dict] = {}
def get_state(user_id: int) -> str:
return _states.get(user_id, MENU)
def set_state(user_id: int, state: str) -> None:
_states[user_id] = state
def get_data(user_id: int, key: str, default=None):
return _data.get(user_id, {}).get(key, default)
def set_data(user_id: int, key: str, value) -> None:
_data.setdefault(user_id, {})[key] = value
def clear_data(user_id: int) -> None:
_data.pop(user_id, None)

8
requirements.txt Normal file
View File

@ -0,0 +1,8 @@
python-telegram-bot[job-queue]>=20.0
httpx
python-dotenv
chromadb
pypdf
pymupdf
ollama
numpy<2

0
scripts/__init__.py Normal file
View File

266
scripts/fiches_victron.py Normal file
View File

@ -0,0 +1,266 @@
"""
Fiches techniques Victron MultiPlus données critiques pour la FAQ SAV.
Usage : python scripts/fiches_victron.py
"""
FICHES_VICTRON = [
# --- VISSERIE & COUPLES ---
{
"id": "victron_couples_serrage",
"document": (
"Couple de serrage visserie Victron MultiPlus II. "
"Vis M4 : 1 Nm. Vis M5 : 3 Nm. Vis M6 : 5,5 Nm. "
"Vis M8 batterie : 12 Nm. Bornes AC général : 1,6 Nm. "
"Bornes AC-out-2 : 2 Nm maximum."
),
"produit": "Victron MultiPlus",
"source": "Manuel MultiPlus-II 230V — p.15",
"type": "fiche_technique",
},
# --- CÂBLES & FUSIBLES ---
{
"id": "victron_fusibles_cables_3kva",
"document": (
"Câbles et fusibles batterie MultiPlus II 3kVA. "
"Modèle 12/3000 : fusible 400A, câble 2x50mm² sur 0-5m. "
"Modèle 24/3000 : fusible 300A, câble 50mm² sur 0-5m, 95mm² sur 5-10m. "
"Modèle 48/3000 : fusible 125A, câble 35mm² sur 0-5m, 70mm² sur 5-10m."
),
"produit": "Victron MultiPlus",
"source": "Manuel MultiPlus-II 230V — p.14",
"type": "fiche_technique",
},
{
"id": "victron_fusibles_cables_5kva",
"document": (
"Câbles et fusibles batterie MultiPlus II 5kVA. "
"Modèle 12/5000 : fusible 600A, câble 2x95mm² sur 0-5m. "
"Modèle 24/5000 : fusible 400A, câble 2x50mm² sur 0-5m. "
"Modèle 48/5000 : fusible 200A, câble 70mm² sur 0-5m, 120mm² sur 5-10m."
),
"produit": "Victron MultiPlus",
"source": "Manuel MultiPlus-II 230V — p.14",
"type": "fiche_technique",
},
# --- TENSIONS DE CHARGE ---
{
"id": "victron_tensions_48v",
"document": (
"Tensions de charge MultiPlus II 48V réglages usine. "
"Absorption : 57,6V. Float : 55,2V. Stockage (veille) : 52,8V. "
"Ces valeurs s'appliquent aux modèles 48/3000, 48/5000, 48/8000, 48/10000."
),
"produit": "Victron MultiPlus",
"source": "Manuel MultiPlus-II 230V — p.18",
"type": "fiche_technique",
},
{
"id": "victron_tensions_24v",
"document": (
"Tensions de charge MultiPlus II 24V réglages usine. "
"Absorption : 28,8V. Float : 27,6V. Stockage (veille) : 26,4V. "
"S'applique aux modèles 24/3000 et 24/5000."
),
"produit": "Victron MultiPlus",
"source": "Manuel MultiPlus-II 230V — p.18",
"type": "fiche_technique",
},
{
"id": "victron_tensions_12v",
"document": (
"Tensions de charge MultiPlus II 12V réglages usine. "
"Absorption : 14,4V. Float : 13,8V. Stockage (veille) : 13,2V. "
"S'applique aux modèles 12/3000 et 12/5000."
),
"produit": "Victron MultiPlus",
"source": "Manuel MultiPlus-II 230V — p.18",
"type": "fiche_technique",
},
# --- LED & VOYANTS ---
{
"id": "victron_led_overload_clignotant",
"document": (
"MultiPlus LED overload clignote = pré-alarme surcharge. "
"La charge connectée dépasse la puissance nominale du convertisseur. "
"Action : réduire la charge sur AC-out-1. "
"Si LED passe fixe = convertisseur arrêté, couper puis relancer."
),
"produit": "Victron MultiPlus",
"source": "Manuel MultiPlus-II 230V — p.9",
"type": "fiche_technique",
},
{
"id": "victron_led_low_battery_overload_alternance",
"document": (
"MultiPlus LED low battery et overload clignotent en alternance = "
"pré-alarme double : batterie presque vide ET surcharge. "
"Action : 1. Réduire ou débrancher la charge. "
"2. Brancher le chargeur ou une source CA. "
"3. Vérifier l'état de charge de la batterie."
),
"produit": "Victron MultiPlus",
"source": "Manuel MultiPlus-II 230V — p.9",
"type": "fiche_technique",
},
{
"id": "victron_led_low_battery_overload_simultane",
"document": (
"MultiPlus LED low battery et overload clignotent simultanément (en même temps) = "
"pré-alarme ondulation CC. La tension d'ondulation dépasse 1,5 Vrms. "
"Action : 1. Vérifier raccordements et câbles batterie. "
"2. Vérifier que la capacité batterie est suffisante. "
"3. Augmenter la capacité batterie si nécessaire."
),
"produit": "Victron MultiPlus",
"source": "Manuel MultiPlus-II 230V — p.10",
"type": "fiche_technique",
},
{
"id": "victron_led_temperature",
"document": (
"MultiPlus LED temperature clignote = pré-alarme surchauffe. "
"Température interne atteint niveau critique. "
"Action : 1. Vérifier ventilation autour de l'appareil (10cm minimum). "
"2. Réduire la charge. "
"3. Si LED temperature fixe = convertisseur arrêté."
),
"produit": "Victron MultiPlus",
"source": "Manuel MultiPlus-II 230V — p.9",
"type": "fiche_technique",
},
# --- CODES VE.BUS ---
{
"id": "victron_vebus_code_24",
"document": (
"Code erreur VE.Bus 24 MultiPlus : protection système de transfert déclenchée. "
"Cause : tension d'entrée CA trop basse ou instable. "
"Action : 1. Arrêter tous les appareils puis redémarrer. "
"2. Si erreur persiste : augmenter limite inférieure tension CA à 210V dans VEConfigure "
"(réglage usine = 180V). "
"3. Vérifier qualité alimentation secteur ou générateur."
),
"produit": "Victron MultiPlus",
"source": "Manuel MultiPlus-II 230V — p.27",
"type": "fiche_technique",
},
{
"id": "victron_vebus_code_25",
"document": (
"Code erreur VE.Bus 25 MultiPlus : incompatibilité firmware. "
"Un appareil connecté a un firmware trop ancien. "
"Action : 1. Arrêter tous les appareils. "
"2. Allumer l'appareil source de l'erreur. "
"3. Allumer les autres un par un jusqu'à reproduction de l'erreur. "
"4. Mettre à jour le firmware du dernier appareil allumé via VictronConnect."
),
"produit": "Victron MultiPlus",
"source": "Manuel MultiPlus-II 230V — p.27",
"type": "fiche_technique",
},
{
"id": "victron_vebus_code_3",
"document": (
"Code erreur VE.Bus 3 MultiPlus : appareils manquants ou en trop dans le système. "
"Cause : mauvaise configuration parallèle/triphasé ou câble communication défaillant. "
"Action : 1. Vérifier câbles RJ45 UTP entre les appareils. "
"2. Arrêter tous les appareils et redémarrer. "
"3. Reconfigurer le système si erreur persiste."
),
"produit": "Victron MultiPlus",
"source": "Manuel MultiPlus-II 230V — p.26",
"type": "fiche_technique",
},
# --- PROTECTION BULK ---
{
"id": "victron_protection_bulk",
"document": (
"MultiPlus protection bulk activée : chargeur en erreur après 10h de charge bulk. "
"Cause probable : cellule batterie en court-circuit ou batterie défaillante. "
"Action : 1. Éteindre puis rallumer le MultiPlus pour réinitialiser. "
"2. Vérifier état des batteries. "
"3. Pour désactiver ce mode : utiliser VEConfigure."
),
"produit": "Victron MultiPlus",
"source": "Manuel MultiPlus-II 230V — p.24",
"type": "fiche_technique",
},
# --- PUISSANCES ---
{
"id": "victron_puissance_temperature",
"document": (
"Puissance de sortie MultiPlus II selon température ambiante. "
"À 25°C : 3000W (3kVA) ou 4000W (5kVA). "
"À 40°C : 2200W (3kVA) ou 3700W (5kVA). "
"À 65°C : 1700W (3kVA) ou 3000W (5kVA). "
"Prévoir déclassement si installation en local chaud."
),
"produit": "Victron MultiPlus",
"source": "Manuel MultiPlus-II 230V — p.28",
"type": "fiche_technique",
},
# --- GX ---
{
"id": "victron_gx_reset_usine",
"document": (
"Réinitialisation usine carte GX du MultiPlus-II GX. "
"Procédure : 1. Télécharger venus-data-90-reset-all.tgz sur victronenergy.com. "
"2. Copier sur clé USB FAT32 sans décompresser. "
"3. Éteindre l'appareil. "
"4. Insérer la clé USB et rallumer. "
"5. Attendre démarrage complet puis retirer la clé. "
"6. Redémarrer."
),
"produit": "Victron MultiPlus",
"source": "Manuel MultiPlus-II GX — p.24",
"type": "fiche_technique",
},
]
def indexer_fiches(faq_service) -> int:
"""Indexer toutes les fiches manuelles dans ChromaDB. Retourne le nombre indexé."""
if not faq_service.collection:
print("❌ ChromaDB non disponible")
return 0
for fiche in FICHES_VICTRON:
faq_service.collection.upsert(
documents=[fiche["document"]],
ids=[fiche["id"]],
metadatas=[{
"source": fiche["source"],
"produit": fiche["produit"],
"type": fiche["type"],
"titre": fiche["id"].replace("_", " "),
}],
)
print(f"{len(FICHES_VICTRON)} fiches Victron indexées")
return len(FICHES_VICTRON)
def verifier_et_indexer(faq_service) -> None:
"""Indexer les fiches si pas encore présentes (appel au démarrage du bot)."""
if not faq_service.collection:
return
try:
result = faq_service.collection.get(ids=["victron_couples_serrage"])
if result["documents"]:
return
except Exception:
pass
indexer_fiches(faq_service)
if __name__ == "__main__":
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from services.faq_service import faq_service
indexer_fiches(faq_service)

51
scripts/reindex_all.py Normal file
View File

@ -0,0 +1,51 @@
"""
Réindexation complète de la FAQ depuis data/docs/.
Usage : python scripts/reindex_all.py
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from services.faq_service import faq_service
from services.pdf_indexer import indexer_pdf
PDF_DIR = "./data/docs"
def reindexer_tout():
pdfs = []
for root, _, files in os.walk(PDF_DIR):
for f in files:
if f.lower().endswith(".pdf"):
pdfs.append(os.path.join(root, f))
if not pdfs:
print(f"❌ Aucun PDF dans {PDF_DIR}/")
return
print(f"🗑️ Réinitialisation ChromaDB...")
faq_service.reset_collection()
print(f"📚 {len(pdfs)} PDF(s) à indexer...\n")
total_sections = 0
erreurs = []
for pdf_path in sorted(pdfs):
try:
n = indexer_pdf(faq_service, pdf_path)
total_sections += n
print(f"{os.path.basename(pdf_path)}{n} sections")
except Exception as e:
erreurs.append(os.path.basename(pdf_path))
print(f"{os.path.basename(pdf_path)} : {e}")
print(f"\n{'='*40}")
print(f"✅ Terminé — {total_sections} sections dans ChromaDB")
print(f"📄 {len(pdfs) - len(erreurs)}/{len(pdfs)} PDFs indexés")
if erreurs:
print(f"❌ Échecs : {', '.join(erreurs)}")
if __name__ == "__main__":
reindexer_tout()

0
services/__init__.py Normal file
View File

225
services/faq_service.py Normal file
View File

@ -0,0 +1,225 @@
import logging
import os
import config
log = logging.getLogger(__name__)
try:
import chromadb
_chroma_ok = True
except ImportError:
_chroma_ok = False
log.warning("chromadb non installé — pip install chromadb")
try:
import ollama as ollama_lib
_ollama_ok = True
except ImportError:
_ollama_ok = False
MOTS_PRODUITS = {
"multiplus": "Victron MultiPlus",
"quattro": "Victron MultiPlus",
"ve.bus": "Victron MultiPlus",
"vebus": "Victron MultiPlus",
"ess": "Victron MultiPlus",
"victron": "Victron MultiPlus",
"fronius": "Onduleur Fronius",
"symo": "Onduleur Fronius Symo",
"sma": "Onduleur SMA",
"huawei": "Onduleur Huawei",
"byd": "Batterie BYD",
"pylontech": "Batterie Pylontech",
"daikin": "PAC Daikin",
"viessmann": "PAC Viessmann",
"keba": "Borne IRVE Keba",
}
SYSTEM_PROMPT = """Tu es un expert technique pour des électriciens terrain en Alsace.
Installations : photovoltaïque, PAC, IRVE, onduleurs Victron.
FORMAT DE RÉPONSE OBLIGATOIRE (toujours respecter ce format) :
Problème : [1 ligne ce qui se passe]
Actions : [2-4 étapes numérotées, concrètes, actionnables]
📄 Source : [nom du document et page si disponible]
RÈGLES :
- Maximum 6 lignes au total
- Chaque action = 1 geste précis que le technicien peut faire maintenant
- Si tu ne sais pas réponds "❓ Consulter le manuel constructeur"
- Jamais de phrases génériques comme "vérifier l'installation"
"""
USER_PROMPT = """Question technicien terrain : {question}
Extraits de documentation disponibles :
{docs}
Réponds UNIQUEMENT au format demandé : Problème / Actions / Source."""
def detecter_produit_question(question: str) -> str | None:
q = question.lower()
for mot, produit in MOTS_PRODUITS.items():
if mot in q:
return produit
return None
class FAQService:
def __init__(self):
self.client = None
self.collection = None
if not _chroma_ok:
return
try:
os.makedirs("./data", exist_ok=True)
self.client = chromadb.PersistentClient(path="./data/chromadb")
self.collection = self.client.get_or_create_collection(
name="etm_faq",
metadata={"hnsw:space": "cosine"},
)
except Exception as exc:
log.error("FAQService init : %s", exc)
@property
def disponible(self) -> bool:
return self.collection is not None and self.collection.count() > 0
def reset_collection(self) -> None:
"""Vider et recréer la collection ChromaDB."""
if not self.client:
return
try:
self.client.delete_collection("etm_faq")
except Exception:
pass
self.collection = self.client.get_or_create_collection(
name="etm_faq",
metadata={"hnsw:space": "cosine"},
)
def rechercher(self, question: str, n_results: int = 2) -> dict:
"""Recherche vectorielle avec filtre produit si détecté."""
produit = detecter_produit_question(question)
kwargs = {"query_texts": [question], "n_results": n_results}
if produit:
kwargs["where"] = {"produit": {"$eq": produit}}
try:
results = self.collection.query(**kwargs)
except Exception:
results = self.collection.query(query_texts=[question], n_results=n_results)
docs = results["documents"][0] if results["documents"] else []
metas = results["metadatas"][0] if results["metadatas"] else []
# Déduplication
seen, docs_uniques, metas_uniques = set(), [], []
for doc, meta in zip(docs, metas):
cle = doc[:80]
if cle not in seen:
seen.add(cle)
docs_uniques.append(doc)
metas_uniques.append(meta)
return {
"documents": docs_uniques,
"metadatas": metas_uniques,
"produit_detecte": produit,
}
def indexer_fiche_sav(self, ticket_id: str, produit: str, symptome: str,
cause: str, solution: str, chantier: str, date: str, duree_min: int) -> None:
if not self.collection:
return
document = f"Produit: {produit}\nSymptôme: {symptome}\nCause: {cause}\nSolution: {solution}"
self.collection.upsert(
documents=[document],
ids=[ticket_id],
metadatas=[{
"source": "experience_etm",
"produit": produit,
"chantier": chantier,
"date": date,
"duree_min": duree_min,
}],
)
def indexer_document(self, texte: str, source: str, produit: str = "") -> int:
"""Indexation texte brut (fallback si pas de pymupdf)."""
if not self.collection or not texte.strip():
return 0
mots = texte.split()
chunk_size = 400
chunks = [" ".join(mots[i:i + chunk_size]) for i in range(0, len(mots), int(chunk_size * 0.8))]
try:
self.collection.delete(where={"source": source})
except Exception:
pass
self.collection.upsert(
documents=chunks,
ids=[f"{source}_chunk_{i}" for i in range(len(chunks))],
metadatas=[{"source": source, "produit": produit, "type": "documentation"}] * len(chunks),
)
return len(chunks)
def formater_sans_ollama(self, question: str, docs: list, metas: list) -> str:
lignes = [f"📚 FAQ ETM\n{'' * 22}"]
for doc, meta in zip(docs[:2], metas[:2]):
source = meta.get("source", "")
page = meta.get("page", "")
titre = meta.get("titre", "")
label = f"📄 {source}"
if page:
label += f" — p.{page}"
if titre:
label += f"\n{titre}"
lignes.append(label)
lignes.append(doc[:250].strip())
lignes.append("")
lignes.append("" * 22)
return "\n".join(lignes)
async def repondre(self, question: str) -> str:
if not self.collection or self.collection.count() == 0:
return "❓ Base vide — lance /sync_docs pour indexer les fiches techniques."
resultats = self.rechercher(question)
if not resultats["documents"]:
return "❓ Aucune solution connue pour ce problème."
if config.OLLAMA_ENABLED and _ollama_ok:
try:
response = ollama_lib.chat(
model=config.OLLAMA_MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": USER_PROMPT.format(
question=question,
docs="\n\n".join(resultats["documents"]),
)},
],
)
return response["message"]["content"]
except Exception as exc:
log.error("Ollama : %s", exc)
return self.formater_sans_ollama(question, resultats["documents"], resultats["metadatas"])
def extract_pdf_text(pdf_bytes: bytes) -> str:
"""Fallback extraction texte brut (pypdf) si pymupdf non dispo."""
try:
from io import BytesIO
import pypdf
reader = pypdf.PdfReader(BytesIO(pdf_bytes))
pages = [p.extract_text() for p in reader.pages if p.extract_text()]
return "\n\n".join(p.strip() for p in pages)
except Exception as exc:
log.error("extract_pdf_text : %s", exc)
return ""
faq_service = FAQService()

110
services/llm.py Normal file
View File

@ -0,0 +1,110 @@
import logging
from io import BytesIO
import httpx
try:
import pypdf
_pypdf_ok = True
except ImportError:
_pypdf_ok = False
log_tmp = logging.getLogger(__name__)
log_tmp.warning("pypdf non installé — extraction PDF désactivée. pip install pypdf")
log = logging.getLogger(__name__)
try:
import chromadb
_chroma_ok = True
except ImportError:
_chroma_ok = False
log.warning("chromadb non installé — FAQ SAV désactivée. Installez : pip install chromadb")
OLLAMA_URL = "http://localhost:11434/api/generate"
OLLAMA_MODEL = "phi3"
class SAVAssistant:
def __init__(self):
self._collection = None
if not _chroma_ok:
return
try:
db = chromadb.PersistentClient(path="./sav_knowledge")
self._collection = db.get_or_create_collection("docs_techniques")
except Exception as exc:
log.error("SAVAssistant init : %s", exc)
@property
def disponible(self) -> bool:
return self._collection is not None and self._collection.count() > 0
async def indexer_document(self, texte: str, source: str) -> int:
if not self._collection:
return 0
chunks = [texte[i:i + 500] for i in range(0, len(texte), 400)]
try:
self._collection.delete(where={"source": source})
except Exception:
pass
self._collection.add(
documents=chunks,
ids=[f"{source}_{i}" for i in range(len(chunks))],
metadatas=[{"source": source}] * len(chunks),
)
log.info("Indexé %d chunks depuis '%s'", len(chunks), source)
return len(chunks)
async def chercher(self, question: str, n_results: int = 3) -> str | None:
if not self.disponible:
return None
try:
results = self._collection.query(query_texts=[question], n_results=n_results)
extraits = results["documents"][0]
sources = [m["source"] for m in results["metadatas"][0]]
if not extraits:
return None
# Reformuler avec Ollama si disponible
try:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(
OLLAMA_URL,
json={
"model": OLLAMA_MODEL,
"prompt": (
f"Problème terrain : {question}\n\n"
"Documentation :\n" + "\n\n".join(extraits) +
"\n\nDonne 3 pistes de diagnostic en français, "
"en langage simple pour un technicien de terrain."
),
"stream": False,
},
)
if r.status_code == 200:
return r.json().get("response", "").strip() or None
except Exception:
pass
# Fallback : extraits bruts
return "\n\n---\n\n".join(f"📄 *{s}*\n{e}" for s, e in zip(sources, extraits))
except Exception as exc:
log.error("SAVAssistant.chercher : %s", exc)
return None
sav_assistant = SAVAssistant()
def extract_pdf_text(pdf_bytes: bytes) -> str:
"""Extrait le texte d'un PDF. Retourne une chaîne vide si non lisible."""
if not _pypdf_ok:
return ""
try:
reader = pypdf.PdfReader(BytesIO(pdf_bytes))
pages = []
for page in reader.pages:
text = page.extract_text()
if text:
pages.append(text.strip())
return "\n\n".join(pages)
except Exception as exc:
logging.getLogger(__name__).error("extract_pdf_text : %s", exc)
return ""

154
services/nextcloud.py Normal file
View File

@ -0,0 +1,154 @@
import logging
import httpx
from urllib.parse import unquote
from xml.etree import ElementTree as ET
from config import NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_PASSWORD, NEXTCLOUD_DECK_URL
log = logging.getLogger(__name__)
_auth = (NEXTCLOUD_USER, NEXTCLOUD_PASSWORD)
async def _ensure_dirs(client: httpx.AsyncClient, dir_path: str) -> None:
parts = dir_path.strip("/").split("/")
for i in range(1, len(parts) + 1):
current = "/".join(parts[:i])
r = await client.request("MKCOL", f"{NEXTCLOUD_URL}/{current}")
# 201 = créé, 405 = existe déjà — les deux sont OK
if r.status_code not in (200, 201, 405):
log.warning("MKCOL %s → HTTP %s", current, r.status_code)
async def upload_photo(file_bytes: bytes, remote_path: str) -> bool:
dir_path = remote_path.rsplit("/", 1)[0]
try:
async with httpx.AsyncClient(auth=_auth, timeout=60) as client:
await _ensure_dirs(client, dir_path)
r = await client.put(f"{NEXTCLOUD_URL}/{remote_path}", content=file_bytes)
ok = r.status_code in (200, 201, 204)
if not ok:
log.error("PUT %s → HTTP %s", remote_path, r.status_code)
return ok
except Exception as exc:
log.error("upload_photo %s : %s", remote_path, exc)
return False
async def get_chantiers_actifs(max_results: int = 10) -> list[str]:
url = f"{NEXTCLOUD_URL}/Chantiers/"
body = b'<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:resourcetype/></d:prop></d:propfind>'
try:
async with httpx.AsyncClient(auth=_auth, timeout=30) as client:
r = await client.request("PROPFIND", url, headers={"Depth": "1"}, content=body)
root = ET.fromstring(r.text)
ns = {"d": "DAV:"}
dossiers = []
for resp in root.findall("d:response", ns):
href = resp.find("d:href", ns).text
name = unquote(href.rstrip("/").split("/")[-1])
if name.lower() in ("chantiers", "") or name.startswith("."):
continue
resourcetype = resp.find(".//d:resourcetype", ns)
if resourcetype is not None and resourcetype.find("d:collection", ns) is not None:
dossiers.append(name)
dossiers.sort(reverse=True)
return dossiers[:max_results]
except Exception as exc:
log.error("get_chantiers_actifs : %s", exc)
return []
async def upload_text(content: str, remote_path: str) -> bool:
"""Upload un fichier texte (markdown, etc.) sur Nextcloud."""
dir_path = remote_path.rsplit("/", 1)[0]
try:
async with httpx.AsyncClient(auth=_auth, timeout=30) as client:
await _ensure_dirs(client, dir_path)
r = await client.put(
f"{NEXTCLOUD_URL}/{remote_path}",
content=content.encode("utf-8"),
headers={"Content-Type": "text/markdown; charset=utf-8"},
)
ok = r.status_code in (200, 201, 204)
if not ok:
log.error("PUT text %s → HTTP %s", remote_path, r.status_code)
return ok
except Exception as exc:
log.error("upload_text %s : %s", remote_path, exc)
return False
async def list_pdfs(folder: str = "Doc") -> list[str]:
"""Retourne les chemins relatifs des PDFs dans un dossier Nextcloud (récursif)."""
body = b'<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:resourcetype/></d:prop></d:propfind>'
async def _scan(client: httpx.AsyncClient, path: str, prefix: str) -> list[str]:
r = await client.request("PROPFIND", f"{NEXTCLOUD_URL}/{path}/",
headers={"Depth": "1"}, content=body)
if r.status_code == 404:
log.error("Dossier '%s' introuvable sur Nextcloud", path)
return []
root = ET.fromstring(r.text)
ns = {"d": "DAV:"}
files, subdirs = [], []
for resp in root.findall("d:response", ns):
href = resp.find("d:href", ns).text
name = unquote(href.rstrip("/").split("/")[-1])
if not name or name == path.split("/")[-1]:
continue
rt = resp.find(".//d:resourcetype", ns)
is_dir = rt is not None and rt.find("d:collection", ns) is not None
rel = f"{prefix}/{name}" if prefix else name
if is_dir:
subdirs.append((f"{path}/{name}", rel))
elif name.lower().endswith(".pdf"):
files.append(rel)
for subpath, subprefix in subdirs:
files.extend(await _scan(client, subpath, subprefix))
return files
try:
async with httpx.AsyncClient(auth=_auth, timeout=60) as client:
files = await _scan(client, folder, "")
return sorted(files)
except Exception as exc:
log.error("list_pdfs : %s", exc)
return []
async def download_file(folder: str, filename: str) -> bytes | None:
"""Télécharge un fichier depuis Nextcloud."""
from urllib.parse import quote
url = f"{NEXTCLOUD_URL}/{folder}/{quote(filename, safe='/')}"
try:
async with httpx.AsyncClient(auth=_auth, timeout=120) as client:
r = await client.get(url)
if r.status_code == 200:
return r.content
log.error("download_file %s → HTTP %s", filename, r.status_code)
except Exception as exc:
log.error("download_file %s : %s", filename, exc)
return None
async def create_deck_card(board_id: int, stack_id: int, title: str, description: str) -> bool:
if not board_id or not stack_id:
return False
url = f"{NEXTCLOUD_DECK_URL}/boards/{board_id}/stacks/{stack_id}/cards"
payload = {
"title": title[:255],
"description": description,
"type": "plain",
"order": 999,
}
try:
async with httpx.AsyncClient(
auth=_auth,
timeout=30,
headers={"OCS-APIREQUEST": "true"},
) as client:
r = await client.post(url, json=payload)
return r.status_code in (200, 201)
except Exception as exc:
log.error("create_deck_card : %s", exc)
return False

116
services/pdf_indexer.py Normal file
View File

@ -0,0 +1,116 @@
import logging
import os
import re
log = logging.getLogger(__name__)
try:
import fitz # pymupdf
_fitz_ok = True
except ImportError:
_fitz_ok = False
log.warning("pymupdf non installé — pip install pymupdf")
PRODUITS_PDF = {
"multiplus": "Victron MultiPlus",
"quattro": "Victron Quattro",
"victron": "Victron",
"ve.bus": "Victron VE.Bus",
"ess": "Victron ESS",
"fronius": "Onduleur Fronius",
"symo": "Onduleur Fronius Symo",
"sma": "Onduleur SMA",
"huawei": "Onduleur Huawei",
"byd": "Batterie BYD",
"pylontech": "Batterie Pylontech",
"daikin": "PAC Daikin",
"viessmann": "PAC Viessmann",
"keba": "Borne IRVE Keba",
}
def detecter_produit_pdf(nom_fichier: str) -> str:
nom = nom_fichier.lower()
for mot, produit in PRODUITS_PDF.items():
if mot in nom:
return produit
return "Documentation technique"
def extraire_sections(pdf_path: str) -> list[dict]:
"""Découpe un PDF en sections logiques par titres/headings."""
if not _fitz_ok:
return []
doc = fitz.open(pdf_path)
sections = []
section_courante = {"titre": "", "contenu": "", "page": 1}
for page_num, page in enumerate(doc, 1):
blocs = page.get_text("dict")["blocks"]
for bloc in blocs:
if "lines" not in bloc:
continue
for line in bloc["lines"]:
texte = " ".join(span["text"] for span in line["spans"]).strip()
taille = max((span["size"] for span in line["spans"]), default=0)
gras = any(span["flags"] & 2**4 for span in line["spans"])
if not texte or re.match(r"^\d+$", texte): # ignorer numéros de page
continue
est_titre = (taille > 11 or gras) and len(texte) < 120
if est_titre:
if len(section_courante["contenu"].strip()) > 100:
sections.append(section_courante.copy())
section_courante = {
"titre": texte,
"contenu": texte + "\n",
"page": page_num,
}
else:
section_courante["contenu"] += texte + " "
if len(section_courante["contenu"].strip()) > 100:
sections.append(section_courante)
doc.close()
return sections
def indexer_pdf(faq_service, pdf_path: str) -> int:
"""Indexe un PDF dans ChromaDB par sections logiques."""
nom_fichier = os.path.basename(pdf_path)
produit = detecter_produit_pdf(nom_fichier)
sections = extraire_sections(pdf_path)
if not sections:
log.warning("%s : aucune section extraite", nom_fichier)
return 0
# Supprimer l'ancienne indexation de ce fichier
try:
faq_service.collection.delete(where={"source": nom_fichier})
except Exception:
pass
indexees = 0
for i, section in enumerate(sections):
contenu = section["contenu"].strip()
if len(contenu) < 80:
continue
faq_service.collection.upsert(
documents=[contenu],
ids=[f"{nom_fichier}_s{i}"],
metadatas=[{
"source": nom_fichier,
"produit": produit,
"titre": section["titre"][:200],
"page": section["page"],
"type": "documentation",
}],
)
indexees += 1
log.info("%s%d/%d sections indexées (%s)", nom_fichier, indexees, len(sections), produit)
return indexees

47
services/telegram.py Normal file
View File

@ -0,0 +1,47 @@
from telegram import Bot
from config import GROUPE_CHANTIER, GROUPE_MAGASINIER, GROUPE_SAV
async def notify_fin_chantier(
bot: Bot, ouvrier: str, chantier: str, n_photos: int, nc_path: str
) -> None:
text = (
f"✅ Fin de chantier\n"
f"👷 {ouvrier}\n"
f"🏠 Chantier : {chantier}\n"
f"📸 {n_photos} photo(s) archivée(s)\n"
f"📁 {nc_path}\n"
f"💶 Tu peux préparer la facture finale."
)
await bot.send_message(chat_id=GROUPE_CHANTIER, text=text)
async def notify_sav(
bot: Bot,
ouvrier: str,
chantier: str,
description: str,
photo_bytes: bytes | None = None,
) -> None:
if not GROUPE_SAV:
return
text = (
f"🚨 Alerte SAV\n"
f"👷 {ouvrier}\n"
f"🏠 Chantier : {chantier}\n"
f"📋 {description}"
)
if photo_bytes:
await bot.send_photo(chat_id=GROUPE_SAV, photo=photo_bytes, caption=text)
else:
await bot.send_message(chat_id=GROUPE_SAV, text=text)
async def notify_materiel(bot: Bot, ouvrier: str, chantier: str, liste: str) -> None:
text = (
f"📦 Matériel Manquant\n"
f"👷 {ouvrier}\n"
f"🏠 Chantier : {chantier}\n"
f"📋 {liste}"
)
await bot.send_message(chat_id=GROUPE_MAGASINIER, text=text)

77
services/tickets.py Normal file
View File

@ -0,0 +1,77 @@
import sqlite3
import os
from datetime import datetime
DB_PATH = "./data/tickets.db"
def _conn() -> sqlite3.Connection:
os.makedirs("./data", exist_ok=True)
return sqlite3.connect(DB_PATH)
def init_db() -> None:
with _conn() as db:
db.execute("""
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
user_id INTEGER,
username TEXT,
chat_id INTEGER,
chantier TEXT,
description TEXT,
produit TEXT,
status TEXT DEFAULT 'ouvert',
created_at TEXT,
resolved_at TEXT,
cause TEXT,
solution TEXT,
duree_min INTEGER
)
""")
def _next_id() -> str:
year = datetime.now().year
with _conn() as db:
row = db.execute(
"SELECT COUNT(*) FROM tickets WHERE id LIKE ?", (f"{year}-%",)
).fetchone()
n = (row[0] if row else 0) + 1
return f"{year}-{n:03d}"
def creer_ticket(user_id: int, username: str, chat_id: int, chantier: str, description: str, produit: str = "") -> str:
ticket_id = _next_id()
with _conn() as db:
db.execute(
"INSERT INTO tickets (id, user_id, username, chat_id, chantier, description, produit, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(ticket_id, user_id, username, chat_id, chantier, description, produit, datetime.now().isoformat()),
)
return ticket_id
def get_ticket(ticket_id: str) -> dict | None:
with _conn() as db:
db.row_factory = sqlite3.Row
row = db.execute("SELECT * FROM tickets WHERE id = ?", (ticket_id,)).fetchone()
return dict(row) if row else None
def resoudre_ticket(ticket_id: str, cause: str, solution: str, duree_min: int) -> bool:
with _conn() as db:
r = db.execute(
"UPDATE tickets SET status='resolu', resolved_at=?, cause=?, solution=?, duree_min=? WHERE id=?",
(datetime.now().isoformat(), cause, solution, duree_min, ticket_id),
)
return r.rowcount > 0
def tickets_ouverts() -> list[dict]:
with _conn() as db:
db.row_factory = sqlite3.Row
rows = db.execute(
"SELECT * FROM tickets WHERE status='ouvert' ORDER BY created_at DESC"
).fetchall()
return [dict(r) for r in rows]

0
utils/__init__.py Normal file
View File

33
utils/produit_detector.py Normal file
View File

@ -0,0 +1,33 @@
PRODUITS = {
"fronius": "Onduleur Fronius",
"symo": "Onduleur Fronius Symo",
"primo": "Onduleur Fronius Primo",
"sma": "Onduleur SMA",
"huawei": "Onduleur Huawei",
"enphase": "Micro-onduleur Enphase",
"byd": "Batterie BYD",
"pylontech": "Batterie Pylontech",
"soltaro": "Batterie Soltaro",
"daikin": "PAC Daikin Altherma",
"viessmann": "PAC Viessmann",
"atlantic": "PAC Atlantic",
"keba": "Borne IRVE Keba",
"powercharger": "ETM-PowerCharger",
"wallbox": "Borne IRVE Wallbox",
"hems": "Système HEMS",
"onduleur": "Onduleur",
"batterie": "Batterie",
"pac ": "Pompe à Chaleur",
"irve": "Borne IRVE",
"borne": "Borne IRVE",
"panneau": "Panneaux PV",
"optimiseur": "Optimiseur",
}
def detecter_produit(texte: str) -> str:
t = texte.lower()
for mot, produit in PRODUITS.items():
if mot in t:
return produit
return "Produit non identifié"