etm-terrain/CLAUDE_python_bot_V2.md

421 lines
14 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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à ,
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 !)