From 2ece762035e7ed4310cfdf3b5f74c05194583a2e Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 19 Jun 2026 19:17:07 +0200 Subject: [PATCH] Premier commit : ajout du travail local --- .dockerignore | 8 + .env.example | 21 ++ .gitignore | 9 + CLAUDE_documentation.md | 200 ++++++++++++++++ CLAUDE_python_bot.md | 207 ++++++++++++++++ CLAUDE_python_bot_V2.md | 420 +++++++++++++++++++++++++++++++++ Dockerfile | 19 ++ ETM_Bot_Guide_Utilisation.docx | Bin 0 -> 16023 bytes config.py | 24 ++ handlers/__init__.py | 12 + handlers/chantier.py | 83 +++++++ handlers/faq.py | 31 +++ handlers/fin_chantier.py | 148 ++++++++++++ handlers/materiel.py | 55 +++++ handlers/menu.py | 39 +++ handlers/resolu.py | 126 ++++++++++ handlers/sav.py | 93 ++++++++ main.py | 165 +++++++++++++ models/__init__.py | 0 models/session.py | 29 +++ requirements.txt | 8 + scripts/__init__.py | 0 scripts/fiches_victron.py | 266 +++++++++++++++++++++ scripts/reindex_all.py | 51 ++++ services/__init__.py | 0 services/faq_service.py | 225 ++++++++++++++++++ services/llm.py | 110 +++++++++ services/nextcloud.py | 154 ++++++++++++ services/pdf_indexer.py | 116 +++++++++ services/telegram.py | 47 ++++ services/tickets.py | 77 ++++++ utils/__init__.py | 0 utils/produit_detector.py | 33 +++ 33 files changed, 2776 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE_documentation.md create mode 100644 CLAUDE_python_bot.md create mode 100644 CLAUDE_python_bot_V2.md create mode 100644 Dockerfile create mode 100644 ETM_Bot_Guide_Utilisation.docx create mode 100644 config.py create mode 100644 handlers/__init__.py create mode 100644 handlers/chantier.py create mode 100644 handlers/faq.py create mode 100644 handlers/fin_chantier.py create mode 100644 handlers/materiel.py create mode 100644 handlers/menu.py create mode 100644 handlers/resolu.py create mode 100644 handlers/sav.py create mode 100644 main.py create mode 100644 models/__init__.py create mode 100644 models/session.py create mode 100644 requirements.txt create mode 100644 scripts/__init__.py create mode 100644 scripts/fiches_victron.py create mode 100644 scripts/reindex_all.py create mode 100644 services/__init__.py create mode 100644 services/faq_service.py create mode 100644 services/llm.py create mode 100644 services/nextcloud.py create mode 100644 services/pdf_indexer.py create mode 100644 services/telegram.py create mode 100644 services/tickets.py create mode 100644 utils/__init__.py create mode 100644 utils/produit_detector.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f95bbd2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.env +data/ +sav_knowledge/ +__pycache__/ +**/__pycache__/ +*.pyc +*.pyo +.git/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0102603 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb7075f --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +data/chromadb/ +data/docs/ +data/tickets.db +sav_knowledge/ +__pycache__/ +**/__pycache__/ +*.pyc +*.pyo diff --git a/CLAUDE_documentation.md b/CLAUDE_documentation.md new file mode 100644 index 0000000..aedbdfb --- /dev/null +++ b/CLAUDE_documentation.md @@ -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 diff --git a/CLAUDE_python_bot.md b/CLAUDE_python_bot.md new file mode 100644 index 0000000..e93f5e9 --- /dev/null +++ b/CLAUDE_python_bot.md @@ -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 +``` diff --git a/CLAUDE_python_bot_V2.md b/CLAUDE_python_bot_V2.md new file mode 100644 index 0000000..1079582 --- /dev/null +++ b/CLAUDE_python_bot_V2.md @@ -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""" + + + """ + ) + + # 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 !) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7cc28ea --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/ETM_Bot_Guide_Utilisation.docx b/ETM_Bot_Guide_Utilisation.docx new file mode 100644 index 0000000000000000000000000000000000000000..e4ac8747f367888e3ac75919d20fe0f07684cab0 GIT binary patch literal 16023 zcmd6Ob97%#*LIvVwr$(C-I$GS+qUgAW@9^P(%5Wc+wPa%YVW?!`{A$mo3l>N$@-mZ zuRSx@%xuiaO96vG0{keZ(Y0ECy!`bC^zG?lV{b?&|KENB_4g;b_C}Tt|9lAXhb*-( zFg9?2000DUg0TPnp`nd|la-ORBdx2ICG8JrW#X7@A3c1aF3DvUJB2)C*$R~4AxaRq z?a5e7sE?ryf|p0+bUkeuH7xJ=V)jG5hFiyriopfwIGiYxVhRccKNJ!88pGvO`^ z=Q{6@ID_Vmr=CYb?y2lJ+^J6aLlL;}r)jmxjY1Y#JLqYrS^JA&uH1++4MjAQ*rQQ> zG7LK|^@My5jeEI9vlW5vas+TtOdBQnm>~pD@$D*LL&IsK&ji=bFN7u?yd^x%^g~cR zOOgVsdx54=0#@|6eQ*D8ZwCUAx)h>QiZE0dS&2jwnox`SUF!)R?6hU?#TA=#1bqwzB7XPy$O@&D-=FtEE zGNJ$h5dLn+Uz_kmThjq+D9L-GdVfbqk}{z`2p*In87I8ZP(?Tl!R+I$#9XQwfYpS| z8V~+X@o@m2;!>s3NL3~Akw#l%qdtM#Vp)4dc|6%h&OTg)Pw(wPHWYw=GXgSrZyt>n z7!wcAT5;FwW9H1B=lw;?SsW}vP76F3MXHKnXslXO92q%LX))X3JQ8dZs7#o~dm35pAbF<>~9Gn>y5AA=B} zu4BgJ@0AX!^OY}4@42H{9S;zc+k1GbbPhg!SutbScxg}r;gUeWQV&{>$I;B&rs)ur zcF^@0_MYb4jV9^g)()YMwZh$rVX!fK9CFqWH(3>O^rSoUmpn1|s-fFY^Zo*rS@c-d z@%hejY$!#ILT-vkUf6knSsA_EL<1@362I$0OJ-)FIf<(dMIMJ1-(0)W0XK|3`f~by zPLd0ncVfh!flo+Xg6xhjy+P<#>c;YPeD_v)=Di85WXqf2LeBW|C=%-h_iN@ozPXR4EIRcwTBF<7{-0Wk$4fd&2$_q@0#xnMI@J zH7U~VX~Ao+D`_5CC!V|e1~`Xnx(>`vAM}M`gfAcT(8=HJNQ4}<7^vDF3WP-;KlPR< zwZ+eL3Z1O+8}DUgupS>}GoQ)s2N70^ELE~LV%{=8h`86Q$i*BF_ZWPv@J?Z#?Kzkl z44a7twTVkuGl?N%)w=FRX#`J#e7Ux!A9fw(rd7Io)kxq-8bQU5)!iR2PnWyhH5-_Y zr*#ymWY=bxyw^l&-D&NM>Qe4lyuV%SAM}Bzv>yqaJQw@=`e<=UzDrAVDrbf~k~1JV zV`WBuc0A2J=^KH=!8Q%I+9}INT-FfLp&ui2oRpxdc%$>wN`#I;4H@KLG{>Zp%Z^mo zS9;1E(*SaV2F&zjl>u10*6NkkaUc{>G0WN)JERsLH$j-87{@lGjvuio)Fec_`iXHD zfoS%|t@|UQd%fc#ikQ^L*5^K>T-C$fGr{Uav1>`UG95Q-7M#LQ>Z~C8td_c8 z(Va(pDN9umkVQ4AI~GW;M#J29YU_=2=pmOA!z-^69pwENhOw2-GW^XGigNSxI57pF z=&w}8MxW;nj9*7c{WfkGVL9y3r>rD+v%U6b!Iw*4t<%j27B3fh9UymvnFwI+ytfao zpm!(9^|OPsbrz1C3Y^i;Q)+psS%^O-_*o0mB^UM>mK2{GZn7>Mgl(o$_GC>+peO8U z)iVn+AOhw}e1#;>L;#Ou-VhXJc6KuL?5v7G=&-hNH3pkbX=S)5tqNEloyqY}sqMVk zJ;qU9!WC{gu`rs!6b_-k0UMk^pQcYA(H2Pm`ka)RYKN>w@If&yRPvt8L;D5n3LRpV z^T124u5SFMVqm>c=5qH;eAC4;ljR!bk%3#fGNJ$AXi2TO60dP4=Qw2 z6;Aa`sEe3n`_PwhH0DR)(u4cz1%qaGafrV60e~^Tgk|$$(?zcNGf-)*=v*x~wo3R$ zS|adx_T5i>m21~Xz4$3dP+QiTZkuWqsk@k0Z-9KWwD=WG zBrT4hFZuz1Wc>iHMVb0|4!yXT7J+Ly&kW5qbD#F@@No)v&TIVihq@%Nm8ZCMT2IS^ zyN<~^m`-I{vOb6V^A3g(DPT}a5AdN*(mMxHCmWp#`=aADo~sm$+@k$&ajMe$8*vMf zXIls7UFfi9D-j?R;An)Y16H>4}WmvQWBN^oOD3CErkCi+i88K z+Yfaw#>fDneoG z8GL%wnAK;3s9NEuPy2-Q(%{gRPqgJK2gQ-=a|4EMbrKpCgs8SzWk5gk*(Xzh&7tq_!`m1-01L%%}WJ=^6$k2{9*k`A4 zzJ7HJz{>ka;#dX*@;9-Hb zVGQ!%Mf4!ga;s(Cl;3abkX8-M@CMQ#XUVZ10Dm1caqJ^;W9GKOO!>&F4%1Yd$Nv zK6(Hi&JAX*&kET51^I>h8ft)3H!Q(=+mT)9mEv^HTysYs#LeeZRp3Lhfo~!uV$QlC zIMGO8GPEC3Fx;NTQd;gXpg=4H_D^+O2m>{XQ#bUYqD|S3LRCoUGaTqke4khrpz1vp z?+oNFV4kcJE89cc7{M?rj9*b(mO*a}vA0*C@LSS{ln?_V2H`(`85CFEhGoB-o0*@@ zNh6WXFw7a)>pyc+=5mx#)1;7bWLg~v;|)o9#GXG@YCK*O#ih*}+jC4sYqPJ{^X}B@ zWV9mJO=6`+qwDOg3IidP7+8#Q)>G%84f-OTi4lYs-;8%ztG*0?~9pRu7~W4~iYN)~51EQm7eftk^5=3|3zK ziHMf`OSD$Fp^|3};`R<7H0cT}kp@yfNX&!#B7NQjkn)7pnv-mjnc5gl2fs9CqJxo7 zr))dgm0(1hw9n=D9L$^qZ>rH*ILEBJ(v?cp5h5zc$rzz-K*gRi31&zfFO*qs$WB9@|Q~gSgnR<-0$0>I5oVz96ahxPz_fPCx@w7U=D) ze+?Oy3*2goK}WBLkxMR-xDBd^j5VdH%BD_>m}4)Y(P8}|%+Z+Qs;8%xB?uTwgD=AL zWtDKq`Gn415E2rQ2ido~P(ayL_Z66VGSBZ65m>u{myz1 z>oam>6QY9JJZ>#Mqky1v&V{9EkGX)}Eb901SJK;(y{CrtJpw&_TU;KSwgpwkv1PXPZvh z5j^uD37N>oGhLtOVHi(9io+7QHI7|@AeE?h`*4a|G#YR}icD`aK+9-jK@4nYhLSVwX6V@5jEas=vp&2VKGgV84M5 zcWFR^=*DcjJfB8w&1#lQOSHUuU#HWA;=vlA)jOBc`;dp;mOwX<`_Ke&A|PudOMDC+ z|A<1p#H&21xBf0PYzK9Aycmr&Qe0c$4(#44Q{@6Ik|+rG4S` zcYADs!|3VOV2Z4dDasTZ~Lx%IUKVl#irv*Ng$S-$HKuv#pCR z&1rr4aCW({k`o@TveuS}y@rXPG7h0%F2WCDLL^%0jF+P4Ls82UZVouU+L6#l1CEg5ZQu+D{{$SI4@jW`%vj;mv zHa_2Nkn&cS`9nh3ICt|5z9PCI!L+(jY^vYEn}ZMKf-7|Op;Z{wanp|82e&*(qH&op zhwTlFNZ=J&J#;V9kYns{|s|1 zg=h9zYVHX=@t{rEK$aDP+D+UA6NFC!pe6<%7*QVgd?ES*G$S>cDV?CR>2MAZB5R#c*jYCmd#!8-o7U@ez=ldJSEwn;_f{y z*_4pmg^>OLPhyPw_j7I}&;dU~HfTi&>!;U@E`%{cjYy@D*K5c#GmPup6>Foy638VSQLCJa(b#oeA!Vh|F}1MrN?s9{(#>WC*wpGFIEqGDion6*`_`< z#Jw?Nwho7Zg1~^l)LDOX8i4UpD<2WF(m{w(S(-74^}fBLw7Rd#Ci}yROIq^mb-1|z zX}EEnx=r$48oBuvS$Jfa4zI#2sWkH3B?@VH2R$l%VopsRvVSn_4nOm?5Rll5*ie9? z8whz)G0;Z>wnc`c;S9HJP@e6x?`(B^S?pF`G4oHifn86uQjem};=PDpt3yTy93v&n zG4LXx?OX27Ho#I3p6*XQ1Mcomz*>55;TRd-397OcbW&eofCzd zfXayxRNx9yWYCFDi8q8JkJ(i$Q2G#2N18GZ{YJCYobxE8%o3_J#rL{hG^dgyGkBkc zaN*g$C4?t}?9`juKaw{w6mjZ5Bq-?YeP)n%jUa8e>3WDyh0u~VRTyi0_2Ksqjyj_q z)7!Pc9w%z?;eT^oh4UAp$cfVm-uyvOKdo=iIA#O>)J+aH@uFdPbQd!wwIZ^9g6>R$ z>7PP0Y0*X%ntUz+4+=1#;FnQ$5y|Tp8b3VE98DSK)4fE$CL7T@wnPdNLyN~j(az6s z+7VdsE6{{f61upEYJ*6gDT-GEF2_A@2u6an)usw7N#k@ucJ!)rQ&n_IWZ&pC;qPPI z708p*2ukN`qPGTVZ=$MTW#Jh=nQO|LQ%Rkub-0DBWtiA|?p>UPZ$s9^(%8OL@3O3( z7UdZwado$Y)Iuj@yUHD)ZHE4pI?%OH9e$d40gBk+%ERY^B_^Qzqg^f^{QyCe!z47@ z@H;YCB7yh=8OqSykBfEkuk`OyTt}i4l5IYMPA23H>0(8`m zY)8#+0E=>XT|Mtj@2(Fv>VRQ3eHV6{Zv3`^gleiRde(H%YO(&hJT4Y#TthrMtf3|s zq$b(+12b!U<*N~%wpFsZ@Go8@r>TehB<8$UW$;McmO^^pVJMgiRVct-j1bZxU{xTLdy90x`0AQ>nVGl!!Gg2`r4^s&oz8}ToJ z78i7LmR``ulAgWq--MmCPk5F<4`H!r7w#7Q>*}QK2HUx%0&C7Rs~a=s%y6N*Ac~U+ zwUsDjP#TAwnHRHll`qh}Xb@8C>+D48+Vsc(A>8*ZJ1L%x@fJ12_Vc*t&lZ^2RKB4e z#O9C4re^@3Z0n!2_zZac?Yk^)V0h-Clakb=LR_=SW^^jRK4x%lI2phr$NSfaT-^4_ z2_K_FHr8_3@eB5$E$kSFXAjmPpPQW0VB}(#%&!rw9VtzpZ?!)u6YR8Y>#Vgh72PhX z)vK*{hxm3yMkPaF05Ad=0(=ID1sv@KcqMV67KZi3&Nao(tyCg*kt89?&2aDNBCwk7 zl|j`M{ZJ_)jlkxpzYZ8Lzftd2ynuruRp@$~t8e(QbL)hBJUjLci0cL)AC_g@F%AMt z=d57@;)I@Th4NJG3g(H>VST?2Ec(;sf%$&UiOdS=#0=BCArQGfFQHn(V73O4en{Kh zLVupe0a5g5zx*9dS?~tce~`2IZs&2w31h&%g1Fm9gC)6Npez_^(M@SX3q-FcFi^6`+T96+;aT)v;+6pzi@?;N!+)KcPdk7(!8j9TUiO z@?(~=2MHK2ZcJQ zhp~@b==(~%+q#FzMHdp~fEn;3E?^qT-QJ4M@`@;6>0Y-2um;Q>!IO857K|13FDGf! zb1zk08j3{hiJF734ZT>*q=(+VEl1c+0tM2NW(Px=u@`j3urV_Kp1dmtA0@LuL;e1B z+V~)<(w$SHWu(Bo#UzV3uit@ClN=D58eKe@GH%ofC31|5nlKT83}1AhvxPXb5B`tX?`X zkoVn5UUk|+VY-~^0XN*5OvGgZc?}h1jaTWKlfoTObSmv(FGLI`+#5=y3q0JLOr%RL zoLzIBqDg1UQH(M`mNI~cc{_UeEsBYEnm6|~;~B1pT%B2QVHfu*drwXMdpp~b#pr;{ zLFrya``~g1#hp|1npyY9zyP4y6n`na2zobDN*(uQ=Y9&7H((``MPznF9;-iEC+Rtv~3}+MIO`DX0UIE6M0v z2W(h&I3mE@Srj(0P-)>Yq==Yx)U5J*TI=^WkN}DTPIlK@QA;BYnpMiQXEv0 zPR}@va~^{i=BTQ&SUzJ1B@SbIX(APt%F8&speHrCTdZ8)y9hXum&+mhaoz1kHKi7y zGB{)M-(Ax#lLRw)5(F>M1Yd>eyTYFPe@Ias*~H>|0seb9{?|n5ySL%^mX4FRx3T>_ z2f9GyL(KJ-0}TKI_#<`q&u0cU_C`O(ZBqP{wLd*_ux`*bt|Y@-c8GLkb51&0y3WW& z#$(pFp4p1*@s_G2G>xsz@_V)Jt3z%ko)XSU59iW8WE`;wg6UU?(k2v*YSath@Jthp zn${`#JR)}HViGn`8|wC@LEjrhEoAOxlvO3uy$%X`XHW3(7GikLz>Go4-M%U7TD|8C zD?k%7&OS(Pq*12TcLq%8Q|c7=xy*cxr=b1@B?5l8unB?dR7X9OE_VVsk>VsTQ{CFv zh_Kf^!+@46tY&09n2$)$EuLOyVo@JYSqA{86wm!NKXleQRvL^Uha$$%wt>!%J{T=t zi~75AMmKk`nyB^Op?N@Ec%DPwK6^|0083O^P{zs zmA;X^nYGD}rk+#%5>Zlz#S-XX?{(F#LPU!BW&3vicK+62gRcB{VJ$4bDTc$cvUAIg{#5cqy$uU{`fLAks4FccH92a)Fq|ZC3QJEpEyI;V*puD_O%Mkjymd-0T z8Uh-R-kcmzv`n6u-o$ZrYrHkuNV~o)TTyeOGD1_;HdB|@bJ)^rng-A%sK>#w%|c6` zRYBay51OyCVO}PFJGfL*2e|KPCQu1n8xT7$jpjST*P9mT?-P5MGj|DBE{m+sL^DI>^%sxd1_VTh zmU?ihN4TDFjOzSd>?e<1hZ>{&J@n=>zXx+aGr8TL4R4G$t=-7|&D{Swm4Ll5_do5W zz$=JP-hKOR{Egv1HeLMr&7WH*;wL158Q?>7Nk%)kR#b!w&FB>OWeR!u#f@D8#_&kr zSK8Q+hpQYe3&o^8?|xwuLM<0sELGjyd_PNXr6G@OWzTzyncZ|Wj8l;uLAH!h8pe39 zrDPQ*(uW^^h7f^6o;qD9UHjEBEFjK(8@H~Ou81tN;e_T=4lI*#ZqRH!D#L%d)1);p zt94w{UECRud?by`n}l-7RxW>ordH@q-I@~hynXE0KBJ3lfL}ioA%K-ZhH=3fRc=SK zq?LQ#o`ZBSaCboq4ks|5h^@-d9+#=Lr6_Ih|FKQw&o(hN(la!&XZUAliA7-`}zJbgM|-oV!*LL@v& zbHg=-6a{6nB_Z7as@gq5TT~ns+Z165e1WLEHDaQFS$^ZB z*K;zdOFVHVq^7HI`e3dABof2iTl5+%O94iL50Y@Fc7@uHugwzXlzBYY)X!@>y5 zliD&7Uf3}$(p`q$vL(OmNjQ}q?ePo(L_Mp6JfnLPp{hx=`h9;=V1D@Z{ zK_S&QIW1ydYS#bKxF#6T)r2C|{l<4%@1f-IPEjoZjwP_?&a?m{8mfACKSW)|`D#=8vU$oF7jgS8j*)OT1DuhNqC?<~l&Zgy@|Qug^G3=Q{5Mj9+U0y;(@s~nYo*pM*R~9wy#^#7WG#n* zs996-xCeX{U#3u4HdXw%)BAya&*gOwyN0pLlNw6?{l~O*2IwI`kVK?7`s{&oYX_e6 zo-YIKVTuL5EIXX$#9?~Yk&$oljAD}ct#|~IdhxN;P{c51qriO85|AuVy>%1CcY+O# zt0=oH^EG`*J~M%ZtJXz=NsEiBNqLCnA(GM5+WLK`>OG?gM&L8JMEYtJxK5!{_@7&Z zD|4;)d1e{xG*hSq%Hr*}hzLcX$^~q*m|A4EI+)wi?qVP15uMb#^FTKq%y^m=n``?} z-&W{CC&~zw2R(gs>?wp0bnfW5b4KIp!v07b;I49Jt^pXn4j9{8O#Hf;bcX_Ak|t(n z%o8pjbhXURusIetHWsh+)D>(g$Te`0$03b}4U0`NB4X2gOf&|^!Jv+E8DU|9W9_U! z*LY%U0lp#ILo!69j7bjR2##gN)lWB#V0W|GI2H_0nG#4Zggn`cPh!evi)E&GYtI_C z1(huGflFZ2D1AYy1ui<|^TwqZU)4uawBT-eOBTKjWj*M&<>frh*)CUCTz_FN|KiwhaxgB<4N_e4~m-{|&UUKOe8%}i&1qZ`uO9H=Q|W9|61KT%i7 z&DO|4`^RF^pweT#LJ$3d(zigM(%L5vE#ba6zqKrM2N*8QwM?xLyI=qEr0A^Qt(|FX z;l%WCf1fQ3d49M%+&F+M$h<$lquf9ZWeeG0H`e-mW82nDYr|R|VggsYu86j<6aCo- zg|G}q4TI`>9lex6O#(Yu)V1{WGg$xL&SixBJ_I%m`3*T+`I-Ees*js1v2v9QA8Sj$ zwiQu(bKAMsBcPLsW+U7;FTyadK=UPTVhB8i4iFl}}|^xv`T*;yx(bl612 z_Xp{pU6*rE!!nfs)qBaTV&Hv8{J^YJc%GiR`Q0(t$qLz2Da%MQXr(nm3+6F7Lt0oK z>1M%s2n#bZ_->zDf08%XAKk6RN;4`nX%L3Zfe!d3G1((RiFPo)&#vx1^mSKafj=UkTGmI!g6z^?6WwduEM$=vgV&Ou|q%e<$UH zL5T}?&Ir}Uaxuw6C1n#MRf%J`jiH`}@}_G#j+&SD1e30iu+&dg9c1CLpTJQ;`RDD&IanoYvk1_uBnK5m zC406RX9`WRJA$Z|Q~^d0l@?4uHNhO$VK2Z(suwFBbZ6sUp11aDhBix2f2%-d*l^tV z+YqaKGjad%&wkA4)^C>3A0e!{w>`jH^zd(n#$|_-BA|wd42LiOD*T!kz!rN{&`GT& zS)NT-LcdHabr~d&U09c~_f<~X;?asrWxNVIT@Ny`W)U$$un@&q6~2!*AGLQ}Ke(}E zQFo~SaS&NkhEs|vKo37WuYmuBn~9siA-tt*>0ZO+C|$!gEVq*0u)zBg%3;ux?j*+~ z;{*CS*yeNGzBPs!Mma*+?{dIY(6}n=%uvmaTxDq<7RNvb$)FFENzfjK#cbfv$c1*K zF`)y*B`(8a0(R`K%?_Kwy z>tAbOLoi&UpY0u|&6T{W;W}eM=0?mF~CXdia-BU*PVj~lslUews z?`wV#S~3S}H^OSXkpSw_2h)&=x+8EKj3Efq?lSyy=i-jqc(3vDaIE_ooR%eqFd&?(N9R$@jN(_0!&#QyfP^P@1~jisOnx) z-5-${zcUBCS7z6^AJhFeeSbCBao*_ri^2Xc4*cQK{12M!e3H>oJ#Q?He`D!CTdj?( z4gVqOSmKKH7CwBi?!YE*Pju_tNccKY};(O6h?|`ZMaYN6`x4t;uw1Kb|bZO3rr`e&RPY*1cs0%x* z9y2M2)c61c=Kx`93;W&oA#U<1>2!|s6iElA)#IT(fbu4Qwzdk6E^KD!qTw&5F}p0E zBJYtc%rEWCUl|!z($(Aj()VJxfTKIel>%v`Y7Qm7YbF$+ksVytG?}gk8AXHIf;1Vb z$BM8nE^SM)KZh>F{=x;vZT%7&$t=g|_~fJ3l7A*~9eELAnEzUW9FG z5j`cEj#TqE`imk>;2%hqPze?MiC4a?#6)j3^YOODvnlq!x+^MQ+koU9|xQuB3?81eYDbIko&-DQ=?!@kvXUr`Kxk9@pcAtrPRdCVK zTf`Agg3qF0CVBB;F47L=2q@X3|L}vUGzFQ^lhHWD(7r6iovfpZ-ZI5yF0`91y5>oW zg=ey8k8|)fo_2myFph@2Cr35-9Uzzn>N>;iPNL!1_OpS09NttMg578K!Cs58r7+N_ zi1T8}tNiAH9W&pECV+353z;G7oL!UGZuMfT*lJsCynYQfmtV>Uu12{V)(=266nI(p zfw-~kg=xN&94_5!*4b@}<8=6)u)${poBP?P3=YK}qI}JJrrD7LjW1GXP2@=ppZk!n z-^MhPsOBs;1CSwyniAOvkzA+?lc+0O#xNU%B*`mzDUv{Ub-DW+?IMCX`Ul?na}eT} z{xq<$`f(=a&;FcUv){x=2V2*)V^@t=d(SpgPnLCZ?YaV$>)Xrx44pgGrmILIk&-3r zFLp@$Eg@y0yKhEXmIsbkeY$VnV=mR!QZwg{D*hqoR@ zMNt0WxEFX@RM@}D7B6`q?HC{8KffB3Ec>V z%t^1CvKW4u-_362@vqz_rQj^K@4;=FxjdMtfCHMJrNy_4-32O!EjMr>MNI2R1r%7= z(bcv0QeHgKI$B3{`FaD>KD~bVT-O_7&E->xjk$bJn^+%dv9gd>_7FjhKa_)Nhb+@v zJ@?RmGEFJe+WD#4W|>^N@&<=jw+y?R;-dN`VmJQTgkgg8nFwb}PCD%TC5<5L+-Bo^ zz0h2AXBP=B762JRUZ3-#8*cE{oS*x6a(n}+Nz``a>xSWWVGssjGJCHvg~&dNbKPfx zU}~%gN1YOxiL8MVqK+;rqIPtr$$&wd8J!S=%A_mQ)THl?RVP+@uk?i!zA-+4|JPH^ zuRZ@iJ=GYT=*;81QB&wG*Z7N_X6>k?r*HX3$|7rO&AJyK8T?$8V3n#NEy}FoE47q! z7Y_hXgD`CI#`TD;?y5`l5#1(_Ae$2#+pNmYv@~eK2|XJMjl{90~xYstpo|0 z8r6dzS+<99e}R1c&`Sk5-=MsUGlvlwsu(e43q#Usr0bH&4>3MYx>?1wr94(icC&tA| zAhfps7qjP=CjFo2bU+|vfdBsB-SU-{|3G}IsZN;_`9UvPsaU}l=Md7KUDZt#_#aobLv0g=@@^(e`eT! zg8ysS{znGz&+?=C6mJ&pKmOTI*q@dEJtO!x5CA~_SJ>bA!QbJ(r@4N@TXFwV`Cmz| zpZNc(^_z|RSNWl}Bf;Nkg`b!|RsKEs@HYwoAoU;29}NB-_^Z|bBTWCZ6chf7A-{y1 z{~ra(ym9Rps{Ilo{Evcu4|f05=<07lzv%LNeSVKh{zQk8{-wq*3jB`#J;?MEeMkNu zLr%X7_&tpAQ@}jsFN`zzC#dnefZu}%KLw;w{UYG6kizfi-#z%BXbtLLYX6U`^gI4n zHvI4be-<^`|I&&7NcoT5_`93-QxpK*U!s0B-~V@-{IL3emUH@lY4WR(|ES3ChWSrP zDGYx}`qfPTU6J4Iz@KmvroZ67+6KSFe{Io!*F%%#U-kG^$bYQI@2mb#MSxiUl4SfA p68*;p_}^*r@4l5|` 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")], + ]) diff --git a/handlers/chantier.py b/handlers/chantier.py new file mode 100644 index 0000000..4f321eb --- /dev/null +++ b/handlers/chantier.py @@ -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") diff --git a/handlers/faq.py b/handlers/faq.py new file mode 100644 index 0000000..b8a768b --- /dev/null +++ b/handlers/faq.py @@ -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) diff --git a/handlers/fin_chantier.py b/handlers/fin_chantier.py new file mode 100644 index 0000000..5d61eed --- /dev/null +++ b/handlers/fin_chantier.py @@ -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() diff --git a/handlers/materiel.py b/handlers/materiel.py new file mode 100644 index 0000000..80e7605 --- /dev/null +++ b/handlers/materiel.py @@ -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()) diff --git a/handlers/menu.py b/handlers/menu.py new file mode 100644 index 0000000..6486790 --- /dev/null +++ b/handlers/menu.py @@ -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", + ) diff --git a/handlers/resolu.py b/handlers/resolu.py new file mode 100644 index 0000000..67580cf --- /dev/null +++ b/handlers/resolu.py @@ -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", + ) diff --git a/handlers/sav.py b/handlers/sav.py new file mode 100644 index 0000000..f5148bf --- /dev/null +++ b/handlers/sav.py @@ -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}") diff --git a/main.py b/main.py new file mode 100644 index 0000000..991b34d --- /dev/null +++ b/main.py @@ -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'') + 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() diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/session.py b/models/session.py new file mode 100644 index 0000000..b43fa22 --- /dev/null +++ b/models/session.py @@ -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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7ba7fc2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +python-telegram-bot[job-queue]>=20.0 +httpx +python-dotenv +chromadb +pypdf +pymupdf +ollama +numpy<2 diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/fiches_victron.py b/scripts/fiches_victron.py new file mode 100644 index 0000000..b204e3e --- /dev/null +++ b/scripts/fiches_victron.py @@ -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) diff --git a/scripts/reindex_all.py b/scripts/reindex_all.py new file mode 100644 index 0000000..e88d663 --- /dev/null +++ b/scripts/reindex_all.py @@ -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() diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/faq_service.py b/services/faq_service.py new file mode 100644 index 0000000..49a7091 --- /dev/null +++ b/services/faq_service.py @@ -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() diff --git a/services/llm.py b/services/llm.py new file mode 100644 index 0000000..9ddf9c1 --- /dev/null +++ b/services/llm.py @@ -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 "" diff --git a/services/nextcloud.py b/services/nextcloud.py new file mode 100644 index 0000000..fcaa7c9 --- /dev/null +++ b/services/nextcloud.py @@ -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'' + 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'' + + 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 diff --git a/services/pdf_indexer.py b/services/pdf_indexer.py new file mode 100644 index 0000000..2908428 --- /dev/null +++ b/services/pdf_indexer.py @@ -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 diff --git a/services/telegram.py b/services/telegram.py new file mode 100644 index 0000000..82ea3e7 --- /dev/null +++ b/services/telegram.py @@ -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) diff --git a/services/tickets.py b/services/tickets.py new file mode 100644 index 0000000..f72d809 --- /dev/null +++ b/services/tickets.py @@ -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] diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/produit_detector.py b/utils/produit_detector.py new file mode 100644 index 0000000..473504a --- /dev/null +++ b/utils/produit_detector.py @@ -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é"