etm-terrain/CLAUDE_python_bot_V2.md

14 KiB

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

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 :

# 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

# 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

# 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

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.

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