Premier commit : ajout du travail local
This commit is contained in:
commit
2ece762035
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
.env
|
||||
data/
|
||||
sav_knowledge/
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.git/
|
||||
21
.env.example
Normal file
21
.env.example
Normal file
@ -0,0 +1,21 @@
|
||||
# Telegram
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
GROUPE_CHANTIER=
|
||||
GROUPE_MAGASINIER=
|
||||
GROUPE_SAV=
|
||||
PATRICK_ID=
|
||||
|
||||
# Nextcloud
|
||||
NEXTCLOUD_URL=https://cloud.etm-schurig.eu
|
||||
NEXTCLOUD_USER=
|
||||
NEXTCLOUD_PASSWORD=
|
||||
NEXTCLOUD_DECK_URL=https://cloud.etm-schurig.eu/index.php/apps/deck/api/v1/
|
||||
DECK_BOARD_ID=
|
||||
DECK_COL_FIN=
|
||||
DECK_COL_SAV=
|
||||
DECK_COL_MATERIEL=
|
||||
|
||||
# Ollama (VM bare metal, accessible depuis le container via host-gateway)
|
||||
OLLAMA_ENABLED=true
|
||||
OLLAMA_URL=http://172.17.0.1:11434
|
||||
OLLAMA_MODEL=phi3
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
.env
|
||||
data/chromadb/
|
||||
data/docs/
|
||||
data/tickets.db
|
||||
sav_knowledge/
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
200
CLAUDE_documentation.md
Normal file
200
CLAUDE_documentation.md
Normal file
@ -0,0 +1,200 @@
|
||||
# ETM Bot — Documentation complète du code
|
||||
|
||||
## Contexte
|
||||
Tu vas documenter le bot Telegram Python ETM-Schurig de façon complète.
|
||||
Cette documentation sera stockée dans Git et le Wiki Git (Gitea).
|
||||
|
||||
**Avant de commencer, lire tout le code :**
|
||||
```bash
|
||||
find . -name "*.py" | sort
|
||||
find . -name "*.md" | sort
|
||||
cat requirements.txt
|
||||
cat .env.example # ou .env si disponible
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ce que tu dois produire
|
||||
|
||||
### 1. README.md (racine du projet)
|
||||
|
||||
```markdown
|
||||
# ETM Bot — Bot Terrain Telegram
|
||||
|
||||
## Vue d'ensemble
|
||||
[Description en 3-4 phrases : ce que fait le bot, pour qui, pourquoi]
|
||||
|
||||
## Architecture
|
||||
[Schéma ASCII du projet — dossiers et fichiers clés avec leur rôle en une ligne]
|
||||
|
||||
## Prérequis
|
||||
[Python version, dépendances système, services externes requis]
|
||||
|
||||
## Installation
|
||||
[Étapes pas à pas — clone, pip install, .env, lancement]
|
||||
|
||||
## Configuration (.env)
|
||||
[Tableau de toutes les variables avec description et exemple]
|
||||
|
||||
## Déploiement
|
||||
[Systemd service, nginx si applicable]
|
||||
|
||||
## Utilisation
|
||||
[Les 5 actions du bot avec exemple de conversation]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. ARCHITECTURE.md
|
||||
|
||||
Document technique expliquant la structure globale :
|
||||
|
||||
- **Schéma du flux de données** — de la réception d'un message Telegram
|
||||
jusqu'à la notification finale, en passant par WebDAV Nextcloud
|
||||
- **Diagramme des états** ConversationHandler — tous les états et transitions
|
||||
- **Interactions entre modules** — qui appelle qui
|
||||
- **Schéma base de données SQLite** — toutes les tables, colonnes, relations
|
||||
- **Structure ChromaDB** — collections, métadonnées, format des documents indexés
|
||||
|
||||
Format : texte + diagrammes ASCII ou Mermaid si possible.
|
||||
|
||||
---
|
||||
|
||||
### 3. Documentation par module (un fichier par dossier)
|
||||
|
||||
#### docs/handlers.md
|
||||
Pour chaque handler (`fin_chantier.py`, `sav.py`, `materiel.py`,
|
||||
`maintenance.py`, `faq.py`, `menu.py`, `edit_handler.py`) :
|
||||
- **Rôle** : ce que fait ce handler en une phrase
|
||||
- **États gérés** : quels états ConversationHandler il couvre
|
||||
- **Fonctions** : signature + description + paramètres + retour
|
||||
- **Exemple de conversation** : ce que voit l'utilisateur étape par étape
|
||||
- **Dépendances** : quels services il appelle
|
||||
|
||||
#### docs/services.md
|
||||
Pour chaque service (`nextcloud.py`, `faq_service.py`,
|
||||
`chantier_selector.py`, `notifications.py`) :
|
||||
- **Rôle** du service
|
||||
- **Classe principale** : attributs, méthodes publiques
|
||||
- **Chaque méthode** : signature, description, paramètres, retour, erreurs possibles
|
||||
- **Exemple d'utilisation**
|
||||
|
||||
#### docs/database.md
|
||||
- **Schéma complet** de toutes les tables SQLite
|
||||
- **Rôle de chaque table** et de chaque colonne
|
||||
- **Requêtes types** utilisées dans le code
|
||||
- **Politique de rétention** des données
|
||||
|
||||
#### docs/faq_rag.md
|
||||
Document dédié au système FAQ/RAG :
|
||||
- **Comment fonctionne ChromaDB** dans ce projet
|
||||
- **Format des documents indexés** (SAV résolus vs documentation)
|
||||
- **Pipeline d'indexation** : de `/resolu` → ChromaDB
|
||||
- **Pipeline de recherche** : de `/faq question` → réponse
|
||||
- **Intégration Ollama** : modèle utilisé, prompt système, fallback
|
||||
- **Comment ajouter des documents** (notices constructeurs)
|
||||
- **Comment vider / réindexer** la base
|
||||
|
||||
#### docs/deployment.md
|
||||
- **Prérequis VM** (RAM, disque, OS)
|
||||
- **Installation complète** pas à pas
|
||||
- **Configuration systemd** avec explications de chaque directive
|
||||
- **Configuration nginx** si applicable
|
||||
- **Variables d'environnement** — toutes décrites
|
||||
- **Logs** — où les trouver, comment les lire
|
||||
- **Mise à jour** — procédure pour déployer une nouvelle version
|
||||
- **Backup** — quoi sauvegarder (ChromaDB, SQLite, .env)
|
||||
|
||||
---
|
||||
|
||||
### 4. docs/guide_patrick.md — Guide administrateur
|
||||
|
||||
Pour Patrick uniquement — les commandes de gestion :
|
||||
|
||||
```markdown
|
||||
## Commandes bot disponibles pour Patrick
|
||||
|
||||
### /resolu — Clôturer un SAV
|
||||
/resolu #2026-047 cause | solution | durée
|
||||
|
||||
### /faq — Tester la base de connaissances
|
||||
/faq Fronius erreur 567
|
||||
|
||||
### Comment ajouter une notice constructeur à la FAQ
|
||||
[Procédure pas à pas]
|
||||
|
||||
### Comment voir les tickets SAV ouverts
|
||||
[Commande ou requête SQLite]
|
||||
|
||||
### Comment sauvegarder la base FAQ
|
||||
[Commande backup ChromaDB]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. CHANGELOG.md
|
||||
|
||||
Historique des versions avec les fonctionnalités implémentées :
|
||||
|
||||
```markdown
|
||||
## [1.0.0] — 2026-03-25
|
||||
### Ajouté
|
||||
- Menu 3 boutons : Fin de Chantier / SAV / Matériel Manquant
|
||||
- Upload photos Nextcloud
|
||||
- Notifications groupes Telegram
|
||||
...
|
||||
|
||||
## [1.1.0] — date
|
||||
### Ajouté
|
||||
- Dropdown chantiers depuis Nextcloud
|
||||
- Gestion albums multi-photos
|
||||
- Bouton FAQ
|
||||
- Commande /resolu + indexation ChromaDB
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Règles de rédaction
|
||||
|
||||
- **Langue** : français pour tout (commentaires, docs, descriptions)
|
||||
- **Ton** : technique mais accessible — un développeur junior doit comprendre
|
||||
- **Code** : inclure des exemples concrets tirés du vrai code, pas inventés
|
||||
- **Diagrammes** : préférer ASCII ou Mermaid (compatible Git Wiki)
|
||||
- **Exhaustivité** : documenter TOUTES les fonctions publiques,
|
||||
même les simples utilitaires
|
||||
- **Exemples** : chaque fonctionnalité doit avoir un exemple
|
||||
de conversation ou d'appel concret
|
||||
|
||||
---
|
||||
|
||||
## Format de sortie attendu
|
||||
|
||||
Créer les fichiers directement dans le projet :
|
||||
```
|
||||
README.md ← remplacer ou créer
|
||||
ARCHITECTURE.md ← nouveau
|
||||
CHANGELOG.md ← nouveau
|
||||
docs/
|
||||
├── handlers.md
|
||||
├── services.md
|
||||
├── database.md
|
||||
├── faq_rag.md
|
||||
├── deployment.md
|
||||
└── guide_patrick.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ordre de travail
|
||||
|
||||
1. Lire tout le code source en premier
|
||||
2. Écrire README.md — vue d'ensemble
|
||||
3. Écrire ARCHITECTURE.md — flux et schémas
|
||||
4. Écrire docs/handlers.md — tous les handlers
|
||||
5. Écrire docs/services.md — tous les services
|
||||
6. Écrire docs/database.md — SQLite + ChromaDB
|
||||
7. Écrire docs/faq_rag.md — système FAQ détaillé
|
||||
8. Écrire docs/deployment.md — déploiement VM
|
||||
9. Écrire docs/guide_patrick.md — guide admin
|
||||
10. Écrire CHANGELOG.md — historique versions
|
||||
207
CLAUDE_python_bot.md
Normal file
207
CLAUDE_python_bot.md
Normal file
@ -0,0 +1,207 @@
|
||||
# ETM Telegram Bot — Réécriture Python
|
||||
|
||||
## Contexte métier
|
||||
Entreprise ETM-Schurig SARL — installateur RGE (PV, PAC, IRVE, HEMS) en Alsace.
|
||||
Bot Telegram pour les techniciens terrain — remplace les appels téléphoniques.
|
||||
|
||||
## Stack cible
|
||||
- **python-telegram-bot** v20+ (async)
|
||||
- **python-nextcloud** ou WebDAV direct (httpx) pour Nextcloud
|
||||
- **Ollama** (LLM local) pour FAQ/SAV intelligent — phase 2
|
||||
- **SQLite** ou fichier JSON pour la session/état utilisateur
|
||||
- Déployé sur le VPS Proxmox existant (même infra que n8n)
|
||||
|
||||
---
|
||||
|
||||
## Credentials
|
||||
|
||||
| Paramètre | Valeur |
|
||||
|---|---|
|
||||
| Bot Token | `8608752199:AAGZ9Vyop0MVm4msxUyDsUuoNvN1hKM12vc` |
|
||||
| Groupe Chat Chantier | `-5208574803` |
|
||||
| Groupe ETM-Magasinier | `-5056192608` |
|
||||
| Patrick ID | `8022751692` |
|
||||
| Nextcloud WebDAV | `https://cloud.etm-schurig.eu/remote.php/webdav` |
|
||||
| Nextcloud User | `Patrick.Schurig` |
|
||||
| Nextcloud Deck API | `https://cloud.etm-schurig.eu/index.php/apps/deck/api/v1/` |
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnalités MVP
|
||||
|
||||
### 3 actions via boutons inline
|
||||
| Bouton | callback_data | Règles |
|
||||
|---|---|---|
|
||||
| 📸 Fin de Chantier | `fin` | Photo **obligatoire** + légende `NomClient / Notes` |
|
||||
| ⚠️ Alerte SAV | `sav` | Photo optionnelle + `NomChantier / Description` |
|
||||
| 📦 Matériel Manquant | `materiel` | Texte seul accepté + `NomChantier / - item1 - item2` |
|
||||
|
||||
### Format de saisie universel
|
||||
```
|
||||
NomChantier / description ou liste avec - comme séparateur
|
||||
```
|
||||
Exemples :
|
||||
- `Müller / onduleur + batterie posés`
|
||||
- `Müller / - cuivre 28mm - disconnecteur - soupape anti-gel`
|
||||
|
||||
### Chemins Nextcloud
|
||||
```
|
||||
Fin chantier : Chantiers/YYYYMMDD_Client/30_Photos_Chantier/photo_timestamp.jpg
|
||||
SAV : SAV_Urgent/YYYY-MM-DD_Client/photo.jpg
|
||||
Matériel : Logistique/Manquants/Client/photo_timestamp.jpg
|
||||
```
|
||||
|
||||
### Notifications
|
||||
| Action | Destinataire | Contenu |
|
||||
|---|---|---|
|
||||
| Fin chantier | Groupe Chat Chantier `-5208574803` | ✅ + ouvrier + chantier + chemin Nextcloud + "prépare la facture" |
|
||||
| SAV | Groupe ETM-SAV (à créer) | 🚨 + ouvrier + chantier + description + photo |
|
||||
| Matériel | Groupe ETM-Magasinier `-5056192608` | 📦 + ouvrier + chantier + liste formatée |
|
||||
|
||||
### Cartes Nextcloud Deck (phase 1b)
|
||||
- Tableau : **ETM Chantiers**
|
||||
- 3 colonnes : `Fin de Chantier` / `SAV` / `Matériel`
|
||||
- Créer une carte automatiquement à chaque signalement
|
||||
|
||||
---
|
||||
|
||||
## Architecture Python recommandée
|
||||
|
||||
```
|
||||
etm_bot/
|
||||
├── main.py # Entry point, ConversationHandler
|
||||
├── config.py # Tokens, IDs, URLs (chargés depuis .env)
|
||||
├── handlers/
|
||||
│ ├── menu.py # /start, boutons inline
|
||||
│ ├── fin_chantier.py # Workflow fin de chantier
|
||||
│ ├── sav.py # Workflow SAV
|
||||
│ └── materiel.py # Workflow matériel manquant
|
||||
├── services/
|
||||
│ ├── nextcloud.py # Upload WebDAV + Deck API
|
||||
│ ├── telegram.py # Helpers send_message, notify_group
|
||||
│ └── llm.py # Ollama FAQ/SAV — phase 2
|
||||
├── models/
|
||||
│ └── session.py # État conversation par user_id (dict en mémoire ou SQLite)
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
### États ConversationHandler
|
||||
```python
|
||||
MENU = 0
|
||||
ATTENTE_PHOTO_FIN = 1
|
||||
ATTENTE_CONTENU_SAV = 2
|
||||
ATTENTE_CONTENU_MATERIEL = 3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Wiki/FAQ SAV avec Ollama
|
||||
|
||||
Quand un technicien signale un SAV, proposer automatiquement des solutions :
|
||||
|
||||
```python
|
||||
# services/llm.py
|
||||
async def chercher_solution_sav(description: str) -> str:
|
||||
# Appel Ollama local (mistral ou llama3)
|
||||
response = await ollama.chat(
|
||||
model="mistral",
|
||||
messages=[{
|
||||
"role": "system",
|
||||
"content": "Tu es un expert en installation photovoltaïque, PAC et IRVE. "
|
||||
"Donne des pistes de diagnostic pour ce problème terrain."
|
||||
}, {
|
||||
"role": "user",
|
||||
"content": description
|
||||
}]
|
||||
)
|
||||
return response["message"]["content"]
|
||||
```
|
||||
|
||||
Exemples de questions SAV que le LLM doit pouvoir traiter :
|
||||
- "onduleur Fronius affiche erreur 567"
|
||||
- "PAC ne démarre plus après coupure EDF"
|
||||
- "borne IRVE ne charge plus depuis hier"
|
||||
|
||||
---
|
||||
|
||||
## Prochaine session — ordre de travail
|
||||
|
||||
1. `pip install python-telegram-bot httpx python-dotenv` + scaffold du projet
|
||||
2. Implémenter `ConversationHandler` avec les 3 états
|
||||
3. Upload WebDAV sur Nextcloud (tester avec une vraie photo)
|
||||
4. Notifications groupes Telegram
|
||||
5. Cartes Nextcloud Deck
|
||||
6. (optionnel) Intégration Ollama FAQ SAV
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Priorité n°1 — Gestion albums multi-photos (media_group_id)
|
||||
|
||||
### Problème
|
||||
Un technicien envoie 5-8 photos d'un coup (onduleur, batteries, HEMS, tableau, câblage...).
|
||||
Telegram envoie chaque photo comme un webhook séparé avec le même `media_group_id`.
|
||||
Il faut les regrouper avant d'uploader et n'envoyer qu'une seule notification.
|
||||
|
||||
### Solution Python
|
||||
```python
|
||||
# Collecter les photos d'un même album pendant 2 secondes
|
||||
media_groups = {} # media_group_id → liste de fichiers
|
||||
|
||||
async def handle_photo(update, context):
|
||||
msg = update.message
|
||||
group_id = msg.media_group_id
|
||||
|
||||
if group_id:
|
||||
# Ajouter à l'album en cours
|
||||
if group_id not in media_groups:
|
||||
media_groups[group_id] = {
|
||||
'photos': [],
|
||||
'caption': msg.caption or '',
|
||||
'chat_id': msg.chat_id,
|
||||
'user': msg.from_user
|
||||
}
|
||||
# Déclencher l'upload après 3 secondes (le temps de tout recevoir)
|
||||
context.job_queue.run_once(
|
||||
upload_album, 3,
|
||||
data={'group_id': group_id},
|
||||
name=group_id
|
||||
)
|
||||
media_groups[group_id]['photos'].append(
|
||||
msg.photo[-1].file_id # Meilleure qualité
|
||||
)
|
||||
else:
|
||||
# Photo seule — uploader directement
|
||||
await upload_single_photo(msg)
|
||||
|
||||
async def upload_album(context):
|
||||
group_id = context.job.data['group_id']
|
||||
album = media_groups.pop(group_id, None)
|
||||
if not album:
|
||||
return
|
||||
# Uploader toutes les photos
|
||||
for i, file_id in enumerate(album['photos']):
|
||||
file = await context.bot.get_file(file_id)
|
||||
# Upload WebDAV → Nextcloud/Chantiers/YYYYMMDD_Client/30_Photos_Chantier/photo_01.jpg
|
||||
await upload_to_nextcloud(file, album['caption'], i + 1)
|
||||
# Une seule notification
|
||||
await notify_fin_chantier(album, len(album['photos']))
|
||||
```
|
||||
|
||||
### Notification finale (exemple 6 photos)
|
||||
```
|
||||
✅ Fin de chantier
|
||||
👷 Patrick Schurig
|
||||
🏠 Chantier : Müller
|
||||
📸 6 photos archivées → Nextcloud/Chantiers/20260325_Müller/30_Photos_Chantier/
|
||||
💶 Tu peux préparer la facture finale.
|
||||
```
|
||||
|
||||
### Nommage des photos sur Nextcloud
|
||||
```
|
||||
30_Photos_Chantier/
|
||||
├── photo_01_20260325_143201.jpg
|
||||
├── photo_02_20260325_143202.jpg
|
||||
├── photo_03_20260325_143203.jpg
|
||||
...
|
||||
└── photo_06_20260325_143208.jpg
|
||||
```
|
||||
420
CLAUDE_python_bot_V2.md
Normal file
420
CLAUDE_python_bot_V2.md
Normal file
@ -0,0 +1,420 @@
|
||||
# ETM Telegram Bot — Réécriture Python
|
||||
|
||||
## Contexte métier
|
||||
Entreprise ETM-Schurig SARL — installateur RGE (PV, PAC, IRVE, HEMS) en Alsace.
|
||||
Bot Telegram pour les techniciens terrain — remplace les appels téléphoniques.
|
||||
|
||||
## Stack cible
|
||||
- **python-telegram-bot** v20+ (async)
|
||||
- **python-nextcloud** ou WebDAV direct (httpx) pour Nextcloud
|
||||
- **Ollama** (LLM local) pour FAQ/SAV intelligent — phase 2
|
||||
- **SQLite** ou fichier JSON pour la session/état utilisateur
|
||||
- Déployé sur le VPS Proxmox existant (même infra que n8n)
|
||||
|
||||
---
|
||||
|
||||
## Credentials
|
||||
|
||||
| Paramètre | Valeur |
|
||||
|---|---|
|
||||
| Bot Token | `8608752199:AAGZ9Vyop0MVm4msxUyDsUuoNvN1hKM12vc` |
|
||||
| Groupe Chat Chantier | `-5208574803` |
|
||||
| Groupe ETM-Magasinier | `-5056192608` |
|
||||
| Patrick ID | `8022751692` |
|
||||
| Nextcloud WebDAV | `https://cloud.etm-schurig.eu/remote.php/webdav` |
|
||||
| Nextcloud User | `Patrick.Schurig` |
|
||||
| Nextcloud Deck API | `https://cloud.etm-schurig.eu/index.php/apps/deck/api/v1/` |
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnalités MVP
|
||||
|
||||
### 3 actions via boutons inline
|
||||
| Bouton | callback_data | Règles |
|
||||
|---|---|---|
|
||||
| 📸 Fin de Chantier | `fin` | Photo **obligatoire** + légende `NomClient / Notes` |
|
||||
| ⚠️ Alerte SAV | `sav` | Photo optionnelle + `NomChantier / Description` |
|
||||
| 📦 Matériel Manquant | `materiel` | Texte seul accepté + `NomChantier / - item1 - item2` |
|
||||
|
||||
### Format de saisie universel
|
||||
```
|
||||
NomChantier / description ou liste avec - comme séparateur
|
||||
```
|
||||
Exemples :
|
||||
- `Müller / onduleur + batterie posés`
|
||||
- `Müller / - cuivre 28mm - disconnecteur - soupape anti-gel`
|
||||
|
||||
### Chemins Nextcloud
|
||||
```
|
||||
Fin chantier : Chantiers/YYYYMMDD_Client/30_Photos_Chantier/photo_timestamp.jpg
|
||||
SAV : SAV_Urgent/YYYY-MM-DD_Client/photo.jpg
|
||||
Matériel : Logistique/Manquants/Client/photo_timestamp.jpg
|
||||
```
|
||||
|
||||
### Notifications
|
||||
| Action | Destinataire | Contenu |
|
||||
|---|---|---|
|
||||
| Fin chantier | Groupe Chat Chantier `-5208574803` | ✅ + ouvrier + chantier + chemin Nextcloud + "prépare la facture" |
|
||||
| SAV | Groupe ETM-SAV (à créer) | 🚨 + ouvrier + chantier + description + photo |
|
||||
| Matériel | Groupe ETM-Magasinier `-5056192608` | 📦 + ouvrier + chantier + liste formatée |
|
||||
|
||||
### Cartes Nextcloud Deck (phase 1b)
|
||||
- Tableau : **ETM Chantiers**
|
||||
- 3 colonnes : `Fin de Chantier` / `SAV` / `Matériel`
|
||||
- Créer une carte automatiquement à chaque signalement
|
||||
|
||||
---
|
||||
|
||||
## Architecture Python recommandée
|
||||
|
||||
```
|
||||
etm_bot/
|
||||
├── main.py # Entry point, ConversationHandler
|
||||
├── config.py # Tokens, IDs, URLs (chargés depuis .env)
|
||||
├── handlers/
|
||||
│ ├── menu.py # /start, boutons inline
|
||||
│ ├── fin_chantier.py # Workflow fin de chantier
|
||||
│ ├── sav.py # Workflow SAV
|
||||
│ └── materiel.py # Workflow matériel manquant
|
||||
├── services/
|
||||
│ ├── nextcloud.py # Upload WebDAV + Deck API
|
||||
│ ├── telegram.py # Helpers send_message, notify_group
|
||||
│ └── llm.py # Ollama FAQ/SAV — phase 2
|
||||
├── models/
|
||||
│ └── session.py # État conversation par user_id (dict en mémoire ou SQLite)
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
### États ConversationHandler
|
||||
```python
|
||||
MENU = 0
|
||||
ATTENTE_PHOTO_FIN = 1
|
||||
ATTENTE_CONTENU_SAV = 2
|
||||
ATTENTE_CONTENU_MATERIEL = 3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Wiki/FAQ SAV avec Ollama
|
||||
|
||||
Quand un technicien signale un SAV, proposer automatiquement des solutions :
|
||||
|
||||
```python
|
||||
# services/llm.py
|
||||
async def chercher_solution_sav(description: str) -> str:
|
||||
# Appel Ollama local (mistral ou llama3)
|
||||
response = await ollama.chat(
|
||||
model="mistral",
|
||||
messages=[{
|
||||
"role": "system",
|
||||
"content": "Tu es un expert en installation photovoltaïque, PAC et IRVE. "
|
||||
"Donne des pistes de diagnostic pour ce problème terrain."
|
||||
}, {
|
||||
"role": "user",
|
||||
"content": description
|
||||
}]
|
||||
)
|
||||
return response["message"]["content"]
|
||||
```
|
||||
|
||||
Exemples de questions SAV que le LLM doit pouvoir traiter :
|
||||
- "onduleur Fronius affiche erreur 567"
|
||||
- "PAC ne démarre plus après coupure EDF"
|
||||
- "borne IRVE ne charge plus depuis hier"
|
||||
|
||||
---
|
||||
|
||||
## Prochaine session — ordre de travail
|
||||
|
||||
1. `pip install python-telegram-bot httpx python-dotenv` + scaffold du projet
|
||||
2. Implémenter `ConversationHandler` avec les 3 états
|
||||
3. Upload WebDAV sur Nextcloud (tester avec une vraie photo)
|
||||
4. Notifications groupes Telegram
|
||||
5. Cartes Nextcloud Deck
|
||||
6. (optionnel) Intégration Ollama FAQ SAV
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Priorité n°1 — Gestion albums multi-photos (media_group_id)
|
||||
|
||||
### Problème
|
||||
Un technicien envoie 5-8 photos d'un coup (onduleur, batteries, HEMS, tableau, câblage...).
|
||||
Telegram envoie chaque photo comme un webhook séparé avec le même `media_group_id`.
|
||||
Il faut les regrouper avant d'uploader et n'envoyer qu'une seule notification.
|
||||
|
||||
### Solution Python
|
||||
```python
|
||||
# Collecter les photos d'un même album pendant 2 secondes
|
||||
media_groups = {} # media_group_id → liste de fichiers
|
||||
|
||||
async def handle_photo(update, context):
|
||||
msg = update.message
|
||||
group_id = msg.media_group_id
|
||||
|
||||
if group_id:
|
||||
# Ajouter à l'album en cours
|
||||
if group_id not in media_groups:
|
||||
media_groups[group_id] = {
|
||||
'photos': [],
|
||||
'caption': msg.caption or '',
|
||||
'chat_id': msg.chat_id,
|
||||
'user': msg.from_user
|
||||
}
|
||||
# Déclencher l'upload après 3 secondes (le temps de tout recevoir)
|
||||
context.job_queue.run_once(
|
||||
upload_album, 3,
|
||||
data={'group_id': group_id},
|
||||
name=group_id
|
||||
)
|
||||
media_groups[group_id]['photos'].append(
|
||||
msg.photo[-1].file_id # Meilleure qualité
|
||||
)
|
||||
else:
|
||||
# Photo seule — uploader directement
|
||||
await upload_single_photo(msg)
|
||||
|
||||
async def upload_album(context):
|
||||
group_id = context.job.data['group_id']
|
||||
album = media_groups.pop(group_id, None)
|
||||
if not album:
|
||||
return
|
||||
# Uploader toutes les photos
|
||||
for i, file_id in enumerate(album['photos']):
|
||||
file = await context.bot.get_file(file_id)
|
||||
# Upload WebDAV → Nextcloud/Chantiers/YYYYMMDD_Client/30_Photos_Chantier/photo_01.jpg
|
||||
await upload_to_nextcloud(file, album['caption'], i + 1)
|
||||
# Une seule notification
|
||||
await notify_fin_chantier(album, len(album['photos']))
|
||||
```
|
||||
|
||||
### Notification finale (exemple 6 photos)
|
||||
```
|
||||
✅ Fin de chantier
|
||||
👷 Patrick Schurig
|
||||
🏠 Chantier : Müller
|
||||
📸 6 photos archivées → Nextcloud/Chantiers/20260325_Müller/30_Photos_Chantier/
|
||||
💶 Tu peux préparer la facture finale.
|
||||
```
|
||||
|
||||
### Nommage des photos sur Nextcloud
|
||||
```
|
||||
30_Photos_Chantier/
|
||||
├── photo_01_20260325_143201.jpg
|
||||
├── photo_02_20260325_143202.jpg
|
||||
├── photo_03_20260325_143203.jpg
|
||||
...
|
||||
└── photo_06_20260325_143208.jpg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 2 — Dropdown chantiers depuis Nextcloud
|
||||
|
||||
### Objectif
|
||||
Quand le technicien choisit une action, le bot affiche la liste des dossiers
|
||||
`Nextcloud/Chantiers/` comme boutons inline — plus de fautes de frappe.
|
||||
|
||||
### Flow
|
||||
```
|
||||
Technicien clique "Fin de Chantier"
|
||||
→ Bot lit Nextcloud/Chantiers/ via WebDAV (PROPFIND)
|
||||
→ Bot affiche liste des 10 dossiers les plus récents
|
||||
→ Technicien clique sur le bon chantier
|
||||
→ Bot demande la/les photo(s)
|
||||
→ Technicien envoie photos
|
||||
→ Upload + notification
|
||||
```
|
||||
|
||||
### Code Python
|
||||
```python
|
||||
# services/nextcloud.py
|
||||
import httpx
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
async def get_chantiers_actifs(base_url, user, password, max_results=10):
|
||||
"""Lire les dossiers Nextcloud/Chantiers/ via WebDAV PROPFIND"""
|
||||
url = f"{base_url}/remote.php/dav/files/{user}/Chantiers/"
|
||||
auth = (user, password)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.request(
|
||||
"PROPFIND", url,
|
||||
auth=auth,
|
||||
headers={"Depth": "1", "Content-Type": "application/xml"},
|
||||
content=b"""<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop><d:displayname/><d:resourcetype/></d:prop>
|
||||
</d:propfind>"""
|
||||
)
|
||||
|
||||
# Parser le XML WebDAV
|
||||
root = ET.fromstring(response.text)
|
||||
ns = {"d": "DAV:"}
|
||||
dossiers = []
|
||||
|
||||
for resp in root.findall("d:response", ns):
|
||||
href = resp.find("d:href", ns).text
|
||||
name = href.rstrip("/").split("/")[-1]
|
||||
|
||||
# Ignorer le dossier racine et les fichiers cachés
|
||||
if name == "Chantiers" or name.startswith("."):
|
||||
continue
|
||||
|
||||
# Vérifier que c'est un dossier (resourcetype contient d:collection)
|
||||
resourcetype = resp.find(".//d:resourcetype", ns)
|
||||
if resourcetype is not None and resourcetype.find("d:collection", ns) is not None:
|
||||
dossiers.append(name)
|
||||
|
||||
# Trier par date décroissante (format YYYYMMDD_ en début de nom)
|
||||
dossiers.sort(reverse=True)
|
||||
return dossiers[:max_results]
|
||||
|
||||
|
||||
# handlers/fin_chantier.py
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from services.nextcloud import get_chantiers_actifs
|
||||
|
||||
async def afficher_choix_chantier(update, context):
|
||||
"""Afficher la liste des chantiers Nextcloud comme boutons"""
|
||||
chantiers = await get_chantiers_actifs(
|
||||
base_url=config.NC_URL,
|
||||
user=config.NC_USER,
|
||||
password=config.NC_PASS
|
||||
)
|
||||
|
||||
# Créer les boutons (1 par ligne pour lisibilité sur mobile)
|
||||
keyboard = [
|
||||
[InlineKeyboardButton(
|
||||
text=formater_nom(c), # ex: "Müller — 25/03/2026"
|
||||
callback_data=f"chantier_{c}"
|
||||
)]
|
||||
for c in chantiers
|
||||
]
|
||||
# Bouton pour un chantier non listé
|
||||
keyboard.append([InlineKeyboardButton(
|
||||
"✏️ Autre chantier (saisie manuelle)",
|
||||
callback_data="chantier_manuel"
|
||||
)])
|
||||
|
||||
await update.callback_query.message.reply_text(
|
||||
"🏠 Quel chantier ?",
|
||||
reply_markup=InlineKeyboardMarkup(keyboard)
|
||||
)
|
||||
return SELECTION_CHANTIER
|
||||
|
||||
|
||||
def formater_nom(dossier: str) -> str:
|
||||
"""20260325_Müller → Müller — 25/03/2026"""
|
||||
if len(dossier) >= 9 and dossier[:8].isdigit():
|
||||
date_str = dossier[:8]
|
||||
nom = dossier[9:]
|
||||
date_fmt = f"{date_str[6:8]}/{date_str[4:6]}/{date_str[:4]}"
|
||||
return f"{nom} — {date_fmt}"
|
||||
return dossier
|
||||
```
|
||||
|
||||
### États ConversationHandler mis à jour
|
||||
```python
|
||||
MENU = 0
|
||||
SELECTION_CHANTIER = 1 # Nouveau — choix du chantier
|
||||
ATTENTE_PHOTO_FIN = 2
|
||||
ATTENTE_CONTENU_SAV = 3
|
||||
ATTENTE_CONTENU_MATERIEL = 4
|
||||
SAISIE_MANUELLE = 5 # Si chantier non listé
|
||||
```
|
||||
|
||||
### Affichage sur Telegram
|
||||
```
|
||||
🏠 Quel chantier ?
|
||||
|
||||
[Müller — 25/03/2026 ]
|
||||
[Hartmann — 22/03/2026 ]
|
||||
[Weber — 18/03/2026 ]
|
||||
[Schmitt — 15/03/2026 ]
|
||||
[✏️ Autre chantier (saisie manuelle)]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — FAQ SAV avec RAG (sans LLM complet)
|
||||
|
||||
### Architecture recommandée
|
||||
Pas besoin d'un LLM lourd — **RAG léger avec ChromaDB** suffit
|
||||
et tourne sur le Proxmox existant (ou Pi 5 si besoin).
|
||||
|
||||
```
|
||||
Document SAV entrant
|
||||
→ ChromaDB cherche les 3 passages les plus proches
|
||||
→ Bot envoie les extraits directement au technicien
|
||||
→ (optionnel) Ollama reformule si VM Proxmox disponible
|
||||
```
|
||||
|
||||
### Matériel
|
||||
| Option | Matériel | Latence | Verdict |
|
||||
|---|---|---|---|
|
||||
| Recherche vectorielle seule | Pi 4 4GB | < 1s | ✅ Parfait |
|
||||
| RAG + petit modèle (phi3-mini) | Pi 5 8GB | 15-30s | ⚠️ Acceptable |
|
||||
| RAG + Mistral 7B | VM Proxmox 16GB RAM | 3-8s | ✅ Recommandé |
|
||||
| API distante (Groq/Claude) | N'importe quoi | < 2s | ✅ Le plus simple |
|
||||
|
||||
### Recommandation pour ETM
|
||||
Utiliser **Ollama sur le Proxmox existant** — il est déjà là,
|
||||
pas besoin d'investir dans un Pi supplémentaire.
|
||||
|
||||
```python
|
||||
# services/llm.py
|
||||
import chromadb
|
||||
import httpx
|
||||
|
||||
class SAVAssistant:
|
||||
def __init__(self):
|
||||
self.db = chromadb.PersistentClient(path="./sav_knowledge")
|
||||
self.collection = self.db.get_or_create_collection("docs_techniques")
|
||||
|
||||
async def indexer_document(self, texte: str, source: str):
|
||||
"""Indexer une notice technique, un rapport SAV, etc."""
|
||||
# Découper en chunks de 500 mots
|
||||
chunks = [texte[i:i+500] for i in range(0, len(texte), 400)]
|
||||
self.collection.add(
|
||||
documents=chunks,
|
||||
ids=[f"{source}_{i}" for i in range(len(chunks))],
|
||||
metadatas=[{"source": source}] * len(chunks)
|
||||
)
|
||||
|
||||
async def chercher(self, question: str, n_results=3) -> str:
|
||||
"""Chercher les passages pertinents pour un problème SAV"""
|
||||
results = self.collection.query(
|
||||
query_texts=[question],
|
||||
n_results=n_results
|
||||
)
|
||||
extraits = results["documents"][0]
|
||||
sources = [m["source"] for m in results["metadatas"][0]]
|
||||
|
||||
# Reformuler avec Ollama si disponible
|
||||
try:
|
||||
response = await httpx.AsyncClient().post(
|
||||
"http://localhost:11434/api/generate",
|
||||
json={
|
||||
"model": "phi3",
|
||||
"prompt": f"Problème terrain : {question}\n\nDocumentation :\n" +
|
||||
"\n\n".join(extraits) +
|
||||
"\n\nDonne 3 pistes de diagnostic en français, "
|
||||
"en langage simple pour un technicien.",
|
||||
"stream": False
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
return response.json()["response"]
|
||||
except:
|
||||
# Si Ollama indisponible, retourner les extraits bruts
|
||||
return "\n\n---\n\n".join(
|
||||
[f"📄 {s}\n{e}" for s, e in zip(sources, extraits)]
|
||||
)
|
||||
```
|
||||
|
||||
### Documents à indexer en priorité
|
||||
- Notices onduleurs : Fronius, SMA, Huawei, Enphase
|
||||
- Notices batteries : BYD, Pylontech, Soltaro
|
||||
- Notices PAC : notices d'installation et de dépannage
|
||||
- Bornes IRVE : Keba, Wallbox, ETM-PowerCharger
|
||||
- Historique SAV ETM : rapports d'intervention passés (mine d'or !)
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@ -0,0 +1,19 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Dépendances système pour ChromaDB (compilation d'extensions natives)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
g++ \
|
||||
libffi-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /app/data/chromadb /app/data/docs /app/sav_knowledge
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
BIN
ETM_Bot_Guide_Utilisation.docx
Normal file
BIN
ETM_Bot_Guide_Utilisation.docx
Normal file
Binary file not shown.
24
config.py
Normal file
24
config.py
Normal file
@ -0,0 +1,24 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
BOT_TOKEN: str = os.environ["TELEGRAM_BOT_TOKEN"]
|
||||
GROUPE_CHANTIER: int = int(os.environ["GROUPE_CHANTIER"])
|
||||
GROUPE_MAGASINIER: int = int(os.environ["GROUPE_MAGASINIER"])
|
||||
GROUPE_SAV: int = int(os.environ.get("GROUPE_SAV", "0"))
|
||||
PATRICK_ID: int = int(os.environ["PATRICK_ID"])
|
||||
|
||||
NEXTCLOUD_URL: str = os.environ["NEXTCLOUD_URL"].rstrip("/")
|
||||
NEXTCLOUD_USER: str = os.environ["NEXTCLOUD_USER"]
|
||||
NEXTCLOUD_PASSWORD: str = os.environ["NEXTCLOUD_PASSWORD"]
|
||||
NEXTCLOUD_DECK_URL: str = os.environ["NEXTCLOUD_DECK_URL"].rstrip("/")
|
||||
|
||||
DECK_BOARD_ID: int = int(os.environ.get("DECK_BOARD_ID", "0"))
|
||||
DECK_COL_FIN: int = int(os.environ.get("DECK_COL_FIN", "0"))
|
||||
DECK_COL_SAV: int = int(os.environ.get("DECK_COL_SAV", "0"))
|
||||
DECK_COL_MATERIEL: int = int(os.environ.get("DECK_COL_MATERIEL", "0"))
|
||||
|
||||
OLLAMA_ENABLED: bool = os.environ.get("OLLAMA_ENABLED", "false").lower() == "true"
|
||||
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "phi3")
|
||||
12
handlers/__init__.py
Normal file
12
handlers/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
MENU_TEXT = "Que voulez-vous signaler ?"
|
||||
|
||||
|
||||
def get_menu_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup([
|
||||
[InlineKeyboardButton("📸 Fin de Chantier", callback_data="fin")],
|
||||
[InlineKeyboardButton("⚠️ Alerte SAV", callback_data="sav")],
|
||||
[InlineKeyboardButton("📦 Matériel Manquant", callback_data="materiel")],
|
||||
[InlineKeyboardButton("📚 FAQ / Diagnostic", callback_data="faq")],
|
||||
])
|
||||
83
handlers/chantier.py
Normal file
83
handlers/chantier.py
Normal file
@ -0,0 +1,83 @@
|
||||
from datetime import datetime
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from telegram.ext import ContextTypes
|
||||
from telegram import Update
|
||||
from services.nextcloud import get_chantiers_actifs
|
||||
from models.session import set_state, set_data, get_data, ATTENTE_PHOTO_FIN, ATTENTE_CONTENU_SAV, ATTENTE_CONTENU_MATERIEL, SAISIE_MANUELLE_CHANTIER
|
||||
|
||||
FLOW_FIN = "fin"
|
||||
FLOW_SAV = "sav"
|
||||
FLOW_MAT = "mat"
|
||||
|
||||
_NEXT_STATE = {
|
||||
FLOW_FIN: ATTENTE_PHOTO_FIN,
|
||||
FLOW_SAV: ATTENTE_CONTENU_SAV,
|
||||
FLOW_MAT: ATTENTE_CONTENU_MATERIEL,
|
||||
}
|
||||
|
||||
_FALLBACK_PROMPT = {
|
||||
FLOW_FIN: "📸 *Fin de Chantier*\n\nEnvoyez la/les photo(s) avec :\n`NOM_Ville / Notes`",
|
||||
FLOW_SAV: "⚠️ *Alerte SAV*\n\nEnvoyez avec :\n`NOM_Ville / Description`\nPhoto optionnelle.",
|
||||
FLOW_MAT: "📦 *Matériel Manquant*\n\nEnvoyez avec :\n`NOM_Ville / - item1 - item2`",
|
||||
}
|
||||
|
||||
_SELECTED_PROMPT = {
|
||||
FLOW_FIN: "📸 *{nom}*\n\nEnvoyez la/les photo(s).\nLégende optionnelle pour des notes.",
|
||||
FLOW_SAV: "⚠️ *{nom}*\n\nDécrivez le problème.\nPhoto optionnelle.",
|
||||
FLOW_MAT: "📦 *{nom}*\n\nEnvoyez la liste :\n`- item1 - item2`",
|
||||
}
|
||||
|
||||
|
||||
def formater_nom(dossier: str) -> str:
|
||||
"""260518_Müller_Strasbourg → Müller Strasbourg — 18/05/26"""
|
||||
if len(dossier) >= 8 and dossier[:6].isdigit() and dossier[6] == "_":
|
||||
d = dossier[:6]
|
||||
nom = dossier[7:].replace("_", " ")
|
||||
return f"{nom} — {d[4:6]}/{d[2:4]}/{d[:2]}"
|
||||
return dossier.replace("_", " ")
|
||||
|
||||
|
||||
def nom_depuis_dossier(dossier: str) -> str:
|
||||
"""260518_Müller_Strasbourg → Müller_Strasbourg"""
|
||||
if len(dossier) >= 8 and dossier[:6].isdigit() and dossier[6] == "_":
|
||||
return dossier[7:]
|
||||
return dossier
|
||||
|
||||
|
||||
async def afficher_choix_chantier(query, flow: str) -> None:
|
||||
chantiers = await get_chantiers_actifs()
|
||||
if not chantiers:
|
||||
set_state(query.from_user.id, _NEXT_STATE[flow])
|
||||
await query.edit_message_text(_FALLBACK_PROMPT[flow], parse_mode="Markdown")
|
||||
return
|
||||
keyboard = [
|
||||
[InlineKeyboardButton(formater_nom(c), callback_data=f"chantier:{flow}:{c[:45]}")]
|
||||
for c in chantiers
|
||||
]
|
||||
keyboard.append([InlineKeyboardButton("✏️ Nouveau chantier", callback_data=f"chantier_manuel:{flow}")])
|
||||
await query.edit_message_text("🏠 Quel chantier ?", reply_markup=InlineKeyboardMarkup(keyboard))
|
||||
|
||||
|
||||
async def handle_chantier_callback(query, user_id: int) -> None:
|
||||
_, flow, chantier_folder = query.data.split(":", 2)
|
||||
set_data(user_id, "chantier_folder", chantier_folder)
|
||||
set_data(user_id, "flow", flow)
|
||||
set_state(user_id, _NEXT_STATE[flow])
|
||||
prompt = _SELECTED_PROMPT[flow].format(nom=formater_nom(chantier_folder))
|
||||
await query.edit_message_text(prompt, parse_mode="Markdown")
|
||||
|
||||
|
||||
async def handle_saisie_manuelle(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
from handlers import get_menu_keyboard, MENU_TEXT
|
||||
msg = update.message
|
||||
chantier = (msg.text or "").strip()
|
||||
if not chantier:
|
||||
await msg.reply_text("❌ Nom invalide. Format : `NOM_Ville`", parse_mode="Markdown")
|
||||
return
|
||||
user_id = msg.from_user.id
|
||||
flow = get_data(user_id, "flow", FLOW_FIN)
|
||||
chantier_folder = f"{datetime.now().strftime('%y%m%d')}_{chantier}"
|
||||
set_data(user_id, "chantier_folder", chantier_folder)
|
||||
set_state(user_id, _NEXT_STATE[flow])
|
||||
prompt = _SELECTED_PROMPT[flow].format(nom=chantier)
|
||||
await msg.reply_text(prompt, parse_mode="Markdown")
|
||||
31
handlers/faq.py
Normal file
31
handlers/faq.py
Normal file
@ -0,0 +1,31 @@
|
||||
from telegram import Update, ForceReply
|
||||
from telegram.ext import ContextTypes
|
||||
from models.session import set_state, MENU
|
||||
from handlers import get_menu_keyboard, MENU_TEXT
|
||||
from services.faq_service import faq_service
|
||||
|
||||
|
||||
async def handle_faq(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
msg = update.message
|
||||
question = (msg.text or "").strip()
|
||||
if not question:
|
||||
await msg.reply_text("❌ Merci d'écrire ta question.")
|
||||
return
|
||||
thinking = await msg.reply_text("🔍 Recherche en cours...")
|
||||
reponse = await faq_service.repondre(question)
|
||||
await thinking.edit_text(reponse)
|
||||
set_state(msg.from_user.id, MENU)
|
||||
await msg.reply_text(MENU_TEXT, reply_markup=get_menu_keyboard())
|
||||
|
||||
|
||||
async def cmd_faq(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if not context.args:
|
||||
await update.message.reply_text(
|
||||
"📚 FAQ ETM\n\nPose ta question :\n/faq Fronius erreur 567\n/faq PAC ne démarre plus",
|
||||
reply_markup=ForceReply(selective=True),
|
||||
)
|
||||
return
|
||||
question = " ".join(context.args)
|
||||
msg = await update.message.reply_text("🔍 Recherche en cours...")
|
||||
reponse = await faq_service.repondre(question)
|
||||
await msg.edit_text(reponse)
|
||||
148
handlers/fin_chantier.py
Normal file
148
handlers/fin_chantier.py
Normal file
@ -0,0 +1,148 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes
|
||||
from models.session import set_state, get_data, clear_data, MENU
|
||||
from handlers import get_menu_keyboard, MENU_TEXT
|
||||
from handlers.chantier import nom_depuis_dossier
|
||||
from services.nextcloud import upload_photo, create_deck_card
|
||||
from services.telegram import notify_fin_chantier as send_notification
|
||||
from config import DECK_BOARD_ID, DECK_COL_FIN
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_albums: dict[str, dict] = {}
|
||||
|
||||
|
||||
async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
msg = update.message
|
||||
if not msg or not msg.photo:
|
||||
if msg:
|
||||
await msg.reply_text("❌ Veuillez envoyer une photo.")
|
||||
return
|
||||
|
||||
caption = msg.caption or ""
|
||||
group_id = msg.media_group_id
|
||||
ouvrier = msg.from_user.full_name
|
||||
file_id = msg.photo[-1].file_id
|
||||
user_id = msg.from_user.id
|
||||
chantier_folder = get_data(user_id, "chantier_folder")
|
||||
|
||||
if group_id:
|
||||
if group_id not in _albums:
|
||||
_albums[group_id] = {
|
||||
"photos": [],
|
||||
"caption": "",
|
||||
"ouvrier": ouvrier,
|
||||
"chat_id": msg.chat_id,
|
||||
"user_id": user_id,
|
||||
"chantier_folder": chantier_folder,
|
||||
}
|
||||
context.job_queue.run_once(
|
||||
_process_album_job,
|
||||
3,
|
||||
data={"group_id": group_id},
|
||||
name=str(group_id),
|
||||
)
|
||||
if caption:
|
||||
_albums[group_id]["caption"] = caption
|
||||
_albums[group_id]["photos"].append(file_id)
|
||||
else:
|
||||
if not chantier_folder and not caption:
|
||||
await msg.reply_text(
|
||||
"❌ Légende obligatoire.\nFormat : `NOM_Ville / Notes`",
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
return
|
||||
await msg.reply_text("⏳ Upload en cours...")
|
||||
try:
|
||||
await _upload_photos([file_id], ouvrier, context.bot, chantier_folder=chantier_folder, caption=caption)
|
||||
except Exception as exc:
|
||||
log.error("_upload_photos single : %s", exc)
|
||||
await msg.reply_text("❌ Erreur lors de l'upload.")
|
||||
finally:
|
||||
clear_data(user_id)
|
||||
set_state(user_id, MENU)
|
||||
await msg.reply_text(MENU_TEXT, reply_markup=get_menu_keyboard())
|
||||
|
||||
|
||||
async def _process_album_job(context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
group_id = context.job.data["group_id"]
|
||||
album = _albums.pop(group_id, None)
|
||||
if not album:
|
||||
return
|
||||
|
||||
caption = album.get("caption", "")
|
||||
chat_id = album["chat_id"]
|
||||
user_id = album["user_id"]
|
||||
chantier_folder = album.get("chantier_folder")
|
||||
|
||||
if not chantier_folder and not caption:
|
||||
await context.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text="❌ Album reçu sans chantier ni légende.\nFormat : `NOM_Ville / Notes`",
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
set_state(user_id, MENU)
|
||||
await context.bot.send_message(chat_id=chat_id, text=MENU_TEXT, reply_markup=get_menu_keyboard())
|
||||
return
|
||||
|
||||
n = len(album["photos"])
|
||||
await context.bot.send_message(chat_id=chat_id, text=f"⏳ Upload de {n} photo(s) en cours...")
|
||||
try:
|
||||
await _upload_photos(album["photos"], album["ouvrier"], context.bot, chantier_folder=chantier_folder, caption=caption)
|
||||
except Exception as exc:
|
||||
log.error("_upload_photos album : %s", exc)
|
||||
await context.bot.send_message(chat_id=chat_id, text="❌ Erreur lors de l'upload.")
|
||||
finally:
|
||||
clear_data(user_id)
|
||||
set_state(user_id, MENU)
|
||||
await context.bot.send_message(chat_id=chat_id, text=MENU_TEXT, reply_markup=get_menu_keyboard())
|
||||
|
||||
|
||||
async def _upload_photos(
|
||||
file_ids: list[str],
|
||||
ouvrier: str,
|
||||
bot,
|
||||
*,
|
||||
chantier_folder: str | None = None,
|
||||
caption: str = "",
|
||||
) -> None:
|
||||
now = datetime.now()
|
||||
if chantier_folder:
|
||||
nc_dir = f"Chantiers/{chantier_folder}/30_Photos_Chantier"
|
||||
chantier_name = nom_depuis_dossier(chantier_folder)
|
||||
notes = caption.strip()
|
||||
else:
|
||||
chantier_name, notes = _parse_caption(caption)
|
||||
nc_dir = f"Chantiers/{now.strftime('%y%m%d')}_{chantier_name}/30_Photos_Chantier"
|
||||
|
||||
uploaded = 0
|
||||
for i, file_id in enumerate(file_ids):
|
||||
try:
|
||||
tg_file = await bot.get_file(file_id)
|
||||
file_bytes = bytes(await tg_file.download_as_bytearray())
|
||||
ts = now.strftime("%y%m%d_%H%M%S")
|
||||
ok = await upload_photo(file_bytes, f"{nc_dir}/photo_{i + 1:02d}_{ts}.jpg")
|
||||
if ok:
|
||||
uploaded += 1
|
||||
except Exception as exc:
|
||||
log.error("Erreur upload photo %d : %s", i + 1, exc)
|
||||
|
||||
try:
|
||||
await send_notification(bot, ouvrier, chantier_name, uploaded, f"Nextcloud/{nc_dir}/")
|
||||
except Exception as exc:
|
||||
log.error("notify_fin_chantier : %s", exc)
|
||||
|
||||
if DECK_BOARD_ID and DECK_COL_FIN:
|
||||
await create_deck_card(
|
||||
DECK_BOARD_ID,
|
||||
DECK_COL_FIN,
|
||||
f"Fin chantier — {chantier_name}",
|
||||
f"Ouvrier : {ouvrier}\n{len(file_ids)} photo(s)\nNotes : {notes}",
|
||||
)
|
||||
|
||||
|
||||
def _parse_caption(caption: str) -> tuple[str, str]:
|
||||
chantier, _, notes = caption.partition("/")
|
||||
return chantier.strip(), notes.strip()
|
||||
55
handlers/materiel.py
Normal file
55
handlers/materiel.py
Normal file
@ -0,0 +1,55 @@
|
||||
import logging
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes
|
||||
from models.session import set_state, get_data, clear_data, MENU
|
||||
from handlers import get_menu_keyboard, MENU_TEXT
|
||||
from handlers.chantier import nom_depuis_dossier
|
||||
from services.nextcloud import create_deck_card
|
||||
from services.telegram import notify_materiel as send_notification
|
||||
from config import DECK_BOARD_ID, DECK_COL_MATERIEL
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_materiel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
msg = update.message
|
||||
text = (msg.text or "").strip()
|
||||
user_id = msg.from_user.id
|
||||
chantier_folder = get_data(user_id, "chantier_folder")
|
||||
|
||||
if chantier_folder:
|
||||
chantier = nom_depuis_dossier(chantier_folder)
|
||||
liste = text
|
||||
else:
|
||||
if not text or "/" not in text:
|
||||
await msg.reply_text(
|
||||
"❌ Format requis : `NOM_Ville / - item1 - item2`",
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
return
|
||||
chantier, _, liste = text.partition("/")
|
||||
chantier = chantier.strip()
|
||||
liste = liste.strip()
|
||||
|
||||
if not liste:
|
||||
await msg.reply_text("❌ Liste vide.", parse_mode="Markdown")
|
||||
return
|
||||
|
||||
ouvrier = msg.from_user.full_name
|
||||
|
||||
try:
|
||||
await send_notification(context.bot, ouvrier, chantier, liste)
|
||||
except Exception as exc:
|
||||
log.error("notify_materiel : %s", exc)
|
||||
|
||||
if DECK_BOARD_ID and DECK_COL_MATERIEL:
|
||||
await create_deck_card(
|
||||
DECK_BOARD_ID,
|
||||
DECK_COL_MATERIEL,
|
||||
f"Matériel — {chantier}",
|
||||
f"Ouvrier : {ouvrier}\n{liste}",
|
||||
)
|
||||
|
||||
clear_data(user_id)
|
||||
set_state(user_id, MENU)
|
||||
await msg.reply_text(MENU_TEXT, reply_markup=get_menu_keyboard())
|
||||
39
handlers/menu.py
Normal file
39
handlers/menu.py
Normal file
@ -0,0 +1,39 @@
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes
|
||||
from models.session import set_state, SAISIE_MANUELLE_CHANTIER, set_data, ATTENTE_FAQ
|
||||
from handlers import get_menu_keyboard, MENU_TEXT
|
||||
from handlers.chantier import afficher_choix_chantier, handle_chantier_callback, FLOW_FIN, FLOW_SAV, FLOW_MAT
|
||||
|
||||
|
||||
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
await update.effective_message.reply_text(MENU_TEXT, reply_markup=get_menu_keyboard())
|
||||
|
||||
|
||||
async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
query = update.callback_query
|
||||
await query.answer()
|
||||
user_id = query.from_user.id
|
||||
|
||||
if query.data == "fin":
|
||||
await afficher_choix_chantier(query, FLOW_FIN)
|
||||
elif query.data == "sav":
|
||||
await afficher_choix_chantier(query, FLOW_SAV)
|
||||
elif query.data == "materiel":
|
||||
await afficher_choix_chantier(query, FLOW_MAT)
|
||||
elif query.data.startswith("chantier:"):
|
||||
await handle_chantier_callback(query, user_id)
|
||||
elif query.data == "faq":
|
||||
set_state(user_id, ATTENTE_FAQ)
|
||||
await query.edit_message_text(
|
||||
"📚 *FAQ / Diagnostic*\n\nDécris ton problème :",
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
elif query.data.startswith("chantier_manuel:"):
|
||||
_, flow = query.data.split(":", 1)
|
||||
set_data(user_id, "flow", flow)
|
||||
set_state(user_id, SAISIE_MANUELLE_CHANTIER)
|
||||
await query.edit_message_text(
|
||||
"✏️ *Nouveau chantier*\n\nEntrez le nom :\n`NOM_Ville`\n\n"
|
||||
"Exemple : `Müller_Strasbourg`",
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
126
handlers/resolu.py
Normal file
126
handlers/resolu.py
Normal file
@ -0,0 +1,126 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes
|
||||
from config import PATRICK_ID
|
||||
from services.tickets import get_ticket, resoudre_ticket
|
||||
from services.faq_service import faq_service
|
||||
from services.nextcloud import upload_text
|
||||
from utils.produit_detector import detecter_produit
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_args(args: list[str]) -> tuple[str, str, str, int]:
|
||||
"""
|
||||
Format : /resolu #2026-047 cause | solution | 45min
|
||||
Retourne : (ticket_id, cause, solution, duree_min)
|
||||
"""
|
||||
texte = " ".join(args)
|
||||
ticket_id = texte.split()[0].lstrip("#")
|
||||
reste = texte[len(ticket_id) + 1:].strip()
|
||||
parties = [p.strip() for p in reste.split("|")]
|
||||
cause = parties[0] if len(parties) > 0 else ""
|
||||
solution = parties[1] if len(parties) > 1 else ""
|
||||
duree_str = parties[2] if len(parties) > 2 else "0"
|
||||
duree_min = int("".join(c for c in duree_str if c.isdigit()) or "0")
|
||||
return ticket_id, cause, solution, duree_min
|
||||
|
||||
|
||||
def _generer_fiche_md(ticket: dict, cause: str, solution: str, duree_min: int, produit: str) -> str:
|
||||
date = datetime.now().strftime("%Y-%m-%d")
|
||||
return f"""# SAV Résolu — {ticket['id']}
|
||||
|
||||
**Chantier :** {ticket['chantier']}
|
||||
**Date :** {date}
|
||||
**Technicien :** {ticket['username']}
|
||||
**Produit :** {produit}
|
||||
**Durée :** {duree_min} min
|
||||
|
||||
## Symptôme
|
||||
{ticket['description']}
|
||||
|
||||
## Cause
|
||||
{cause}
|
||||
|
||||
## Solution
|
||||
{solution}
|
||||
"""
|
||||
|
||||
|
||||
async def cmd_resolu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""/resolu #2026-047 relais AC défaillant | remplacer ref 4241 | 45min"""
|
||||
if update.effective_user.id != PATRICK_ID:
|
||||
return
|
||||
|
||||
if not context.args:
|
||||
await update.message.reply_text(
|
||||
"Usage : `/resolu #2026-047 cause | solution | 45min`",
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
ticket_id, cause, solution, duree_min = _parse_args(context.args)
|
||||
except Exception:
|
||||
await update.message.reply_text("❌ Format invalide.\nUsage : `/resolu #ID cause | solution | durée`", parse_mode="Markdown")
|
||||
return
|
||||
|
||||
ticket = get_ticket(ticket_id)
|
||||
if not ticket:
|
||||
await update.message.reply_text(f"❌ Ticket `#{ticket_id}` introuvable.", parse_mode="Markdown")
|
||||
return
|
||||
|
||||
if ticket["status"] == "resolu":
|
||||
await update.message.reply_text(f"⚠️ Ticket `#{ticket_id}` déjà résolu.", parse_mode="Markdown")
|
||||
return
|
||||
|
||||
# 1. Clôturer dans SQLite
|
||||
resoudre_ticket(ticket_id, cause, solution, duree_min)
|
||||
|
||||
# 2. Détecter le produit
|
||||
produit = detecter_produit(f"{ticket['description']} {cause} {solution}")
|
||||
|
||||
# 3. Générer fiche .md → Nextcloud
|
||||
date_str = datetime.now().strftime("%Y%m%d")
|
||||
slug = ticket["chantier"].replace(" ", "_")[:30]
|
||||
nc_path = f"SAV/{date_str}_{slug}_{ticket_id}.md"
|
||||
fiche_md = _generer_fiche_md(ticket, cause, solution, duree_min, produit)
|
||||
try:
|
||||
await upload_text(fiche_md, nc_path)
|
||||
except Exception as exc:
|
||||
log.error("upload fiche SAV : %s", exc)
|
||||
|
||||
# 4. Indexer dans la FAQ
|
||||
faq_service.indexer_fiche_sav(
|
||||
ticket_id=ticket_id,
|
||||
produit=produit,
|
||||
symptome=ticket["description"],
|
||||
cause=cause,
|
||||
solution=solution,
|
||||
chantier=ticket["chantier"],
|
||||
date=datetime.now().strftime("%Y-%m-%d"),
|
||||
duree_min=duree_min,
|
||||
)
|
||||
|
||||
# 5. Notifier le technicien original
|
||||
try:
|
||||
await context.bot.send_message(
|
||||
chat_id=ticket["chat_id"],
|
||||
text=(
|
||||
f"✅ *SAV #{ticket_id} résolu*\n\n"
|
||||
f"🏠 {ticket['chantier']}\n"
|
||||
f"🔧 {produit}\n"
|
||||
f"💡 *Solution :* {solution}\n"
|
||||
f"⏱ {duree_min} min"
|
||||
),
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error("notify technicien : %s", exc)
|
||||
|
||||
await update.message.reply_text(
|
||||
f"✅ Ticket `#{ticket_id}` clôturé et indexé dans la FAQ.\n"
|
||||
f"📁 Fiche : `{nc_path}`",
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
93
handlers/sav.py
Normal file
93
handlers/sav.py
Normal file
@ -0,0 +1,93 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes
|
||||
from models.session import set_state, get_data, clear_data, MENU
|
||||
from handlers import get_menu_keyboard, MENU_TEXT
|
||||
from handlers.chantier import nom_depuis_dossier
|
||||
from services.nextcloud import upload_photo, create_deck_card
|
||||
from services.telegram import notify_sav as send_notification
|
||||
from services.faq_service import faq_service
|
||||
from services.tickets import creer_ticket
|
||||
from utils.produit_detector import detecter_produit
|
||||
from config import DECK_BOARD_ID, DECK_COL_SAV
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_sav(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
msg = update.message
|
||||
text = (msg.caption or msg.text or "").strip()
|
||||
user_id = msg.from_user.id
|
||||
chantier_folder = get_data(user_id, "chantier_folder")
|
||||
|
||||
# Résoudre le chantier et la description
|
||||
if chantier_folder:
|
||||
chantier = nom_depuis_dossier(chantier_folder)
|
||||
description = text
|
||||
else:
|
||||
if not text or "/" not in text:
|
||||
await msg.reply_text(
|
||||
"❌ Format requis : `NOM_Ville / Description`",
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
return
|
||||
chantier, _, description = text.partition("/")
|
||||
chantier = chantier.strip()
|
||||
description = description.strip()
|
||||
|
||||
if not description:
|
||||
await msg.reply_text("❌ Description manquante.", parse_mode="Markdown")
|
||||
return
|
||||
|
||||
ouvrier = msg.from_user.full_name
|
||||
now = datetime.now()
|
||||
|
||||
photo_bytes: bytes | None = None
|
||||
if msg.photo:
|
||||
try:
|
||||
tg_file = await context.bot.get_file(msg.photo[-1].file_id)
|
||||
photo_bytes = bytes(await tg_file.download_as_bytearray())
|
||||
ts = now.strftime("%y%m%d_%H%M%S")
|
||||
remote_path = f"SAV_Urgent/{now.strftime('%Y-%m-%d')}_{chantier}/photo_{ts}.jpg"
|
||||
await upload_photo(photo_bytes, remote_path)
|
||||
except Exception as exc:
|
||||
log.error("SAV photo upload : %s", exc)
|
||||
|
||||
# Créer ticket SQLite
|
||||
produit = detecter_produit(description)
|
||||
ticket_id = creer_ticket(
|
||||
user_id=msg.from_user.id,
|
||||
username=msg.from_user.full_name,
|
||||
chat_id=msg.chat_id,
|
||||
chantier=chantier,
|
||||
description=description,
|
||||
produit=produit,
|
||||
)
|
||||
|
||||
try:
|
||||
await send_notification(context.bot, ouvrier, chantier, description, photo_bytes)
|
||||
except Exception as exc:
|
||||
log.error("notify_sav : %s", exc)
|
||||
|
||||
if DECK_BOARD_ID and DECK_COL_SAV:
|
||||
await create_deck_card(
|
||||
DECK_BOARD_ID,
|
||||
DECK_COL_SAV,
|
||||
f"SAV #{ticket_id} — {chantier}",
|
||||
f"Ouvrier : {ouvrier}\n{description}",
|
||||
)
|
||||
|
||||
clear_data(user_id)
|
||||
set_state(user_id, MENU)
|
||||
await msg.reply_text(
|
||||
f"✅ SAV enregistré — ticket `#{ticket_id}`",
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
await msg.reply_text(MENU_TEXT, reply_markup=get_menu_keyboard())
|
||||
|
||||
# FAQ — pistes de diagnostic si base disponible
|
||||
if faq_service.disponible:
|
||||
solution = await faq_service.repondre(description)
|
||||
if solution:
|
||||
await msg.reply_text(f"💡 Pistes de diagnostic :\n\n{solution}")
|
||||
165
main.py
Normal file
165
main.py
Normal file
@ -0,0 +1,165 @@
|
||||
import logging
|
||||
from telegram import Update
|
||||
from telegram.ext import Application, CommandHandler, CallbackQueryHandler, MessageHandler, filters
|
||||
from config import BOT_TOKEN, PATRICK_ID
|
||||
from models.session import get_state, ATTENTE_PHOTO_FIN, ATTENTE_CONTENU_SAV, ATTENTE_CONTENU_MATERIEL, SAISIE_MANUELLE_CHANTIER, ATTENTE_FAQ
|
||||
from handlers.menu import start, button_handler
|
||||
from handlers.fin_chantier import handle_photo
|
||||
from handlers.chantier import handle_saisie_manuelle
|
||||
from handlers.sav import handle_sav
|
||||
from handlers.materiel import handle_materiel
|
||||
from handlers.faq import cmd_faq, handle_faq
|
||||
from handlers.resolu import cmd_resolu
|
||||
from services.tickets import init_db
|
||||
from scripts.fiches_victron import verifier_et_indexer
|
||||
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s — %(name)s — %(levelname)s — %(message)s",
|
||||
level=logging.INFO,
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def error_handler(update: object, context) -> None:
|
||||
log.error("Erreur sur update %s : %s", update, context.error, exc_info=context.error)
|
||||
|
||||
|
||||
async def chatid(update: Update, context) -> None:
|
||||
chat = update.effective_chat
|
||||
await update.effective_message.reply_text(
|
||||
f"Chat ID : `{chat.id}`\nNom : {chat.title or chat.full_name}",
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
|
||||
|
||||
async def ls_command(update: Update, context) -> None:
|
||||
"""Liste les fichiers/dossiers d'un chemin Nextcloud. Réservé à Patrick."""
|
||||
if update.effective_user.id != PATRICK_ID:
|
||||
return
|
||||
folder = " ".join(context.args) if context.args else ""
|
||||
from services.nextcloud import NEXTCLOUD_URL, _auth
|
||||
import httpx
|
||||
from urllib.parse import unquote
|
||||
from xml.etree import ElementTree as ET
|
||||
url = f"{NEXTCLOUD_URL}/{folder}/" if folder else f"{NEXTCLOUD_URL}/"
|
||||
try:
|
||||
async with httpx.AsyncClient(auth=_auth, timeout=15) as client:
|
||||
r = await client.request("PROPFIND", url, headers={"Depth": "1"},
|
||||
content=b'<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:resourcetype/></d:prop></d:propfind>')
|
||||
if r.status_code == 404:
|
||||
await update.message.reply_text(f"❌ Chemin introuvable : `{folder or '/'}`", parse_mode="Markdown")
|
||||
return
|
||||
root = ET.fromstring(r.text)
|
||||
ns = {"d": "DAV:"}
|
||||
items = []
|
||||
for resp in root.findall("d:response", ns):
|
||||
href = resp.find("d:href", ns).text
|
||||
name = unquote(href.rstrip("/").split("/")[-1])
|
||||
if not name:
|
||||
continue
|
||||
is_dir = resp.find(".//d:collection", ns) is not None
|
||||
items.append(("📁" if is_dir else "📄") + f" {name}")
|
||||
items_text = "\n".join(items) if items else "(vide)"
|
||||
await update.message.reply_text(f"`/{folder or ''}`\n\n{items_text}", parse_mode="Markdown")
|
||||
except Exception as exc:
|
||||
await update.message.reply_text(f"❌ Erreur : {exc}")
|
||||
|
||||
|
||||
async def sync_docs_command(update: Update, context) -> None:
|
||||
"""Indexe tous les PDFs du dossier Nextcloud Doc/ dans la FAQ SAV. Réservé à Patrick."""
|
||||
if update.effective_user.id != PATRICK_ID:
|
||||
return
|
||||
folder = context.args[0] if context.args else "Doc"
|
||||
status = await update.message.reply_text(f"🔄 Scan de `{folder}/` en cours...", parse_mode="Markdown")
|
||||
|
||||
from services.nextcloud import list_pdfs, download_file
|
||||
from services.faq_service import faq_service, extract_pdf_text
|
||||
|
||||
pdfs = await list_pdfs(folder)
|
||||
if not pdfs:
|
||||
await status.edit_text(f"❌ Aucun PDF trouvé dans `{folder}/`.", parse_mode="Markdown")
|
||||
return
|
||||
|
||||
await status.edit_text(f"📚 {len(pdfs)} PDF(s) trouvé(s). Indexation en cours...", parse_mode="Markdown")
|
||||
|
||||
from services.pdf_indexer import indexer_pdf
|
||||
import os as _os
|
||||
docs_dir = f"./data/docs/{folder.replace('/', '_')}"
|
||||
_os.makedirs(docs_dir, exist_ok=True)
|
||||
|
||||
ok, skipped, total_sections = 0, [], 0
|
||||
for filename in pdfs:
|
||||
pdf_bytes = await download_file(folder, filename)
|
||||
if not pdf_bytes:
|
||||
skipped.append(filename)
|
||||
continue
|
||||
# Sauvegarder localement pour permettre la réindexation offline
|
||||
local_path = f"{docs_dir}/{filename}"
|
||||
_os.makedirs(_os.path.dirname(local_path), exist_ok=True)
|
||||
with open(local_path, "wb") as f:
|
||||
f.write(pdf_bytes)
|
||||
try:
|
||||
n = indexer_pdf(faq_service, local_path)
|
||||
if n == 0:
|
||||
# Fallback pypdf si pymupdf n'extrait rien (PDF scanné)
|
||||
texte = extract_pdf_text(pdf_bytes)
|
||||
n = faq_service.indexer_document(texte, filename) if texte.strip() else 0
|
||||
total_sections += n
|
||||
ok += 1
|
||||
except Exception as exc:
|
||||
log.error("indexer_pdf %s : %s", filename, exc)
|
||||
skipped.append(filename)
|
||||
|
||||
lines = [f"✅ {ok}/{len(pdfs)} PDF(s) indexés — {total_sections} sections au total."]
|
||||
if skipped:
|
||||
lines.append("⚠️ Non indexés :\n" + "\n".join(f" • {s}" for s in skipped))
|
||||
await status.edit_text("\n\n".join(lines), parse_mode="Markdown")
|
||||
|
||||
|
||||
async def dispatch_message(update: Update, context) -> None:
|
||||
user_id = update.effective_user.id if update.effective_user else None
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
state = get_state(user_id)
|
||||
log.debug("user=%d state=%s", user_id, state)
|
||||
|
||||
if state == ATTENTE_PHOTO_FIN:
|
||||
await handle_photo(update, context)
|
||||
elif state == SAISIE_MANUELLE_CHANTIER:
|
||||
await handle_saisie_manuelle(update, context)
|
||||
elif state == ATTENTE_FAQ:
|
||||
await handle_faq(update, context)
|
||||
elif state == ATTENTE_CONTENU_SAV:
|
||||
await handle_sav(update, context)
|
||||
elif state == ATTENTE_CONTENU_MATERIEL:
|
||||
await handle_materiel(update, context)
|
||||
else:
|
||||
await start(update, context)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
init_db()
|
||||
from services.faq_service import faq_service
|
||||
verifier_et_indexer(faq_service)
|
||||
app = Application.builder().token(BOT_TOKEN).build()
|
||||
|
||||
app.add_handler(CommandHandler("start", start))
|
||||
app.add_handler(CommandHandler("chatid", chatid))
|
||||
app.add_handler(CommandHandler("sync_docs", sync_docs_command))
|
||||
app.add_handler(CommandHandler("ls", ls_command))
|
||||
app.add_handler(CommandHandler("faq", cmd_faq))
|
||||
app.add_handler(CommandHandler("resolu", cmd_resolu))
|
||||
app.add_handler(CallbackQueryHandler(button_handler))
|
||||
app.add_error_handler(error_handler)
|
||||
app.add_handler(MessageHandler(
|
||||
(filters.TEXT | filters.PHOTO) & ~filters.COMMAND,
|
||||
dispatch_message,
|
||||
))
|
||||
|
||||
log.info("ETM Bot démarré.")
|
||||
app.run_polling(allowed_updates=Update.ALL_TYPES)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
models/__init__.py
Normal file
0
models/__init__.py
Normal file
29
models/session.py
Normal file
29
models/session.py
Normal file
@ -0,0 +1,29 @@
|
||||
MENU = "MENU"
|
||||
ATTENTE_PHOTO_FIN = "ATTENTE_PHOTO_FIN"
|
||||
ATTENTE_CONTENU_SAV = "ATTENTE_CONTENU_SAV"
|
||||
ATTENTE_CONTENU_MATERIEL = "ATTENTE_CONTENU_MATERIEL"
|
||||
SAISIE_MANUELLE_CHANTIER = "SAISIE_MANUELLE_CHANTIER"
|
||||
ATTENTE_FAQ = "ATTENTE_FAQ"
|
||||
|
||||
_states: dict[int, str] = {}
|
||||
_data: dict[int, dict] = {}
|
||||
|
||||
|
||||
def get_state(user_id: int) -> str:
|
||||
return _states.get(user_id, MENU)
|
||||
|
||||
|
||||
def set_state(user_id: int, state: str) -> None:
|
||||
_states[user_id] = state
|
||||
|
||||
|
||||
def get_data(user_id: int, key: str, default=None):
|
||||
return _data.get(user_id, {}).get(key, default)
|
||||
|
||||
|
||||
def set_data(user_id: int, key: str, value) -> None:
|
||||
_data.setdefault(user_id, {})[key] = value
|
||||
|
||||
|
||||
def clear_data(user_id: int) -> None:
|
||||
_data.pop(user_id, None)
|
||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@ -0,0 +1,8 @@
|
||||
python-telegram-bot[job-queue]>=20.0
|
||||
httpx
|
||||
python-dotenv
|
||||
chromadb
|
||||
pypdf
|
||||
pymupdf
|
||||
ollama
|
||||
numpy<2
|
||||
0
scripts/__init__.py
Normal file
0
scripts/__init__.py
Normal file
266
scripts/fiches_victron.py
Normal file
266
scripts/fiches_victron.py
Normal file
@ -0,0 +1,266 @@
|
||||
"""
|
||||
Fiches techniques Victron MultiPlus — données critiques pour la FAQ SAV.
|
||||
Usage : python scripts/fiches_victron.py
|
||||
"""
|
||||
|
||||
FICHES_VICTRON = [
|
||||
|
||||
# --- VISSERIE & COUPLES ---
|
||||
{
|
||||
"id": "victron_couples_serrage",
|
||||
"document": (
|
||||
"Couple de serrage visserie Victron MultiPlus II. "
|
||||
"Vis M4 : 1 Nm. Vis M5 : 3 Nm. Vis M6 : 5,5 Nm. "
|
||||
"Vis M8 batterie : 12 Nm. Bornes AC général : 1,6 Nm. "
|
||||
"Bornes AC-out-2 : 2 Nm maximum."
|
||||
),
|
||||
"produit": "Victron MultiPlus",
|
||||
"source": "Manuel MultiPlus-II 230V — p.15",
|
||||
"type": "fiche_technique",
|
||||
},
|
||||
|
||||
# --- CÂBLES & FUSIBLES ---
|
||||
{
|
||||
"id": "victron_fusibles_cables_3kva",
|
||||
"document": (
|
||||
"Câbles et fusibles batterie MultiPlus II 3kVA. "
|
||||
"Modèle 12/3000 : fusible 400A, câble 2x50mm² sur 0-5m. "
|
||||
"Modèle 24/3000 : fusible 300A, câble 50mm² sur 0-5m, 95mm² sur 5-10m. "
|
||||
"Modèle 48/3000 : fusible 125A, câble 35mm² sur 0-5m, 70mm² sur 5-10m."
|
||||
),
|
||||
"produit": "Victron MultiPlus",
|
||||
"source": "Manuel MultiPlus-II 230V — p.14",
|
||||
"type": "fiche_technique",
|
||||
},
|
||||
{
|
||||
"id": "victron_fusibles_cables_5kva",
|
||||
"document": (
|
||||
"Câbles et fusibles batterie MultiPlus II 5kVA. "
|
||||
"Modèle 12/5000 : fusible 600A, câble 2x95mm² sur 0-5m. "
|
||||
"Modèle 24/5000 : fusible 400A, câble 2x50mm² sur 0-5m. "
|
||||
"Modèle 48/5000 : fusible 200A, câble 70mm² sur 0-5m, 120mm² sur 5-10m."
|
||||
),
|
||||
"produit": "Victron MultiPlus",
|
||||
"source": "Manuel MultiPlus-II 230V — p.14",
|
||||
"type": "fiche_technique",
|
||||
},
|
||||
|
||||
# --- TENSIONS DE CHARGE ---
|
||||
{
|
||||
"id": "victron_tensions_48v",
|
||||
"document": (
|
||||
"Tensions de charge MultiPlus II 48V réglages usine. "
|
||||
"Absorption : 57,6V. Float : 55,2V. Stockage (veille) : 52,8V. "
|
||||
"Ces valeurs s'appliquent aux modèles 48/3000, 48/5000, 48/8000, 48/10000."
|
||||
),
|
||||
"produit": "Victron MultiPlus",
|
||||
"source": "Manuel MultiPlus-II 230V — p.18",
|
||||
"type": "fiche_technique",
|
||||
},
|
||||
{
|
||||
"id": "victron_tensions_24v",
|
||||
"document": (
|
||||
"Tensions de charge MultiPlus II 24V réglages usine. "
|
||||
"Absorption : 28,8V. Float : 27,6V. Stockage (veille) : 26,4V. "
|
||||
"S'applique aux modèles 24/3000 et 24/5000."
|
||||
),
|
||||
"produit": "Victron MultiPlus",
|
||||
"source": "Manuel MultiPlus-II 230V — p.18",
|
||||
"type": "fiche_technique",
|
||||
},
|
||||
{
|
||||
"id": "victron_tensions_12v",
|
||||
"document": (
|
||||
"Tensions de charge MultiPlus II 12V réglages usine. "
|
||||
"Absorption : 14,4V. Float : 13,8V. Stockage (veille) : 13,2V. "
|
||||
"S'applique aux modèles 12/3000 et 12/5000."
|
||||
),
|
||||
"produit": "Victron MultiPlus",
|
||||
"source": "Manuel MultiPlus-II 230V — p.18",
|
||||
"type": "fiche_technique",
|
||||
},
|
||||
|
||||
# --- LED & VOYANTS ---
|
||||
{
|
||||
"id": "victron_led_overload_clignotant",
|
||||
"document": (
|
||||
"MultiPlus LED overload clignote = pré-alarme surcharge. "
|
||||
"La charge connectée dépasse la puissance nominale du convertisseur. "
|
||||
"Action : réduire la charge sur AC-out-1. "
|
||||
"Si LED passe fixe = convertisseur arrêté, couper puis relancer."
|
||||
),
|
||||
"produit": "Victron MultiPlus",
|
||||
"source": "Manuel MultiPlus-II 230V — p.9",
|
||||
"type": "fiche_technique",
|
||||
},
|
||||
{
|
||||
"id": "victron_led_low_battery_overload_alternance",
|
||||
"document": (
|
||||
"MultiPlus LED low battery et overload clignotent en alternance = "
|
||||
"pré-alarme double : batterie presque vide ET surcharge. "
|
||||
"Action : 1. Réduire ou débrancher la charge. "
|
||||
"2. Brancher le chargeur ou une source CA. "
|
||||
"3. Vérifier l'état de charge de la batterie."
|
||||
),
|
||||
"produit": "Victron MultiPlus",
|
||||
"source": "Manuel MultiPlus-II 230V — p.9",
|
||||
"type": "fiche_technique",
|
||||
},
|
||||
{
|
||||
"id": "victron_led_low_battery_overload_simultane",
|
||||
"document": (
|
||||
"MultiPlus LED low battery et overload clignotent simultanément (en même temps) = "
|
||||
"pré-alarme ondulation CC. La tension d'ondulation dépasse 1,5 Vrms. "
|
||||
"Action : 1. Vérifier raccordements et câbles batterie. "
|
||||
"2. Vérifier que la capacité batterie est suffisante. "
|
||||
"3. Augmenter la capacité batterie si nécessaire."
|
||||
),
|
||||
"produit": "Victron MultiPlus",
|
||||
"source": "Manuel MultiPlus-II 230V — p.10",
|
||||
"type": "fiche_technique",
|
||||
},
|
||||
{
|
||||
"id": "victron_led_temperature",
|
||||
"document": (
|
||||
"MultiPlus LED temperature clignote = pré-alarme surchauffe. "
|
||||
"Température interne atteint niveau critique. "
|
||||
"Action : 1. Vérifier ventilation autour de l'appareil (10cm minimum). "
|
||||
"2. Réduire la charge. "
|
||||
"3. Si LED temperature fixe = convertisseur arrêté."
|
||||
),
|
||||
"produit": "Victron MultiPlus",
|
||||
"source": "Manuel MultiPlus-II 230V — p.9",
|
||||
"type": "fiche_technique",
|
||||
},
|
||||
|
||||
# --- CODES VE.BUS ---
|
||||
{
|
||||
"id": "victron_vebus_code_24",
|
||||
"document": (
|
||||
"Code erreur VE.Bus 24 MultiPlus : protection système de transfert déclenchée. "
|
||||
"Cause : tension d'entrée CA trop basse ou instable. "
|
||||
"Action : 1. Arrêter tous les appareils puis redémarrer. "
|
||||
"2. Si erreur persiste : augmenter limite inférieure tension CA à 210V dans VEConfigure "
|
||||
"(réglage usine = 180V). "
|
||||
"3. Vérifier qualité alimentation secteur ou générateur."
|
||||
),
|
||||
"produit": "Victron MultiPlus",
|
||||
"source": "Manuel MultiPlus-II 230V — p.27",
|
||||
"type": "fiche_technique",
|
||||
},
|
||||
{
|
||||
"id": "victron_vebus_code_25",
|
||||
"document": (
|
||||
"Code erreur VE.Bus 25 MultiPlus : incompatibilité firmware. "
|
||||
"Un appareil connecté a un firmware trop ancien. "
|
||||
"Action : 1. Arrêter tous les appareils. "
|
||||
"2. Allumer l'appareil source de l'erreur. "
|
||||
"3. Allumer les autres un par un jusqu'à reproduction de l'erreur. "
|
||||
"4. Mettre à jour le firmware du dernier appareil allumé via VictronConnect."
|
||||
),
|
||||
"produit": "Victron MultiPlus",
|
||||
"source": "Manuel MultiPlus-II 230V — p.27",
|
||||
"type": "fiche_technique",
|
||||
},
|
||||
{
|
||||
"id": "victron_vebus_code_3",
|
||||
"document": (
|
||||
"Code erreur VE.Bus 3 MultiPlus : appareils manquants ou en trop dans le système. "
|
||||
"Cause : mauvaise configuration parallèle/triphasé ou câble communication défaillant. "
|
||||
"Action : 1. Vérifier câbles RJ45 UTP entre les appareils. "
|
||||
"2. Arrêter tous les appareils et redémarrer. "
|
||||
"3. Reconfigurer le système si erreur persiste."
|
||||
),
|
||||
"produit": "Victron MultiPlus",
|
||||
"source": "Manuel MultiPlus-II 230V — p.26",
|
||||
"type": "fiche_technique",
|
||||
},
|
||||
|
||||
# --- PROTECTION BULK ---
|
||||
{
|
||||
"id": "victron_protection_bulk",
|
||||
"document": (
|
||||
"MultiPlus protection bulk activée : chargeur en erreur après 10h de charge bulk. "
|
||||
"Cause probable : cellule batterie en court-circuit ou batterie défaillante. "
|
||||
"Action : 1. Éteindre puis rallumer le MultiPlus pour réinitialiser. "
|
||||
"2. Vérifier état des batteries. "
|
||||
"3. Pour désactiver ce mode : utiliser VEConfigure."
|
||||
),
|
||||
"produit": "Victron MultiPlus",
|
||||
"source": "Manuel MultiPlus-II 230V — p.24",
|
||||
"type": "fiche_technique",
|
||||
},
|
||||
|
||||
# --- PUISSANCES ---
|
||||
{
|
||||
"id": "victron_puissance_temperature",
|
||||
"document": (
|
||||
"Puissance de sortie MultiPlus II selon température ambiante. "
|
||||
"À 25°C : 3000W (3kVA) ou 4000W (5kVA). "
|
||||
"À 40°C : 2200W (3kVA) ou 3700W (5kVA). "
|
||||
"À 65°C : 1700W (3kVA) ou 3000W (5kVA). "
|
||||
"Prévoir déclassement si installation en local chaud."
|
||||
),
|
||||
"produit": "Victron MultiPlus",
|
||||
"source": "Manuel MultiPlus-II 230V — p.28",
|
||||
"type": "fiche_technique",
|
||||
},
|
||||
|
||||
# --- GX ---
|
||||
{
|
||||
"id": "victron_gx_reset_usine",
|
||||
"document": (
|
||||
"Réinitialisation usine carte GX du MultiPlus-II GX. "
|
||||
"Procédure : 1. Télécharger venus-data-90-reset-all.tgz sur victronenergy.com. "
|
||||
"2. Copier sur clé USB FAT32 sans décompresser. "
|
||||
"3. Éteindre l'appareil. "
|
||||
"4. Insérer la clé USB et rallumer. "
|
||||
"5. Attendre démarrage complet puis retirer la clé. "
|
||||
"6. Redémarrer."
|
||||
),
|
||||
"produit": "Victron MultiPlus",
|
||||
"source": "Manuel MultiPlus-II GX — p.24",
|
||||
"type": "fiche_technique",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def indexer_fiches(faq_service) -> int:
|
||||
"""Indexer toutes les fiches manuelles dans ChromaDB. Retourne le nombre indexé."""
|
||||
if not faq_service.collection:
|
||||
print("❌ ChromaDB non disponible")
|
||||
return 0
|
||||
for fiche in FICHES_VICTRON:
|
||||
faq_service.collection.upsert(
|
||||
documents=[fiche["document"]],
|
||||
ids=[fiche["id"]],
|
||||
metadatas=[{
|
||||
"source": fiche["source"],
|
||||
"produit": fiche["produit"],
|
||||
"type": fiche["type"],
|
||||
"titre": fiche["id"].replace("_", " "),
|
||||
}],
|
||||
)
|
||||
print(f"✅ {len(FICHES_VICTRON)} fiches Victron indexées")
|
||||
return len(FICHES_VICTRON)
|
||||
|
||||
|
||||
def verifier_et_indexer(faq_service) -> None:
|
||||
"""Indexer les fiches si pas encore présentes (appel au démarrage du bot)."""
|
||||
if not faq_service.collection:
|
||||
return
|
||||
try:
|
||||
result = faq_service.collection.get(ids=["victron_couples_serrage"])
|
||||
if result["documents"]:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
indexer_fiches(faq_service)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
from services.faq_service import faq_service
|
||||
indexer_fiches(faq_service)
|
||||
51
scripts/reindex_all.py
Normal file
51
scripts/reindex_all.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""
|
||||
Réindexation complète de la FAQ depuis data/docs/.
|
||||
Usage : python scripts/reindex_all.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from services.faq_service import faq_service
|
||||
from services.pdf_indexer import indexer_pdf
|
||||
|
||||
PDF_DIR = "./data/docs"
|
||||
|
||||
|
||||
def reindexer_tout():
|
||||
pdfs = []
|
||||
for root, _, files in os.walk(PDF_DIR):
|
||||
for f in files:
|
||||
if f.lower().endswith(".pdf"):
|
||||
pdfs.append(os.path.join(root, f))
|
||||
|
||||
if not pdfs:
|
||||
print(f"❌ Aucun PDF dans {PDF_DIR}/")
|
||||
return
|
||||
|
||||
print(f"🗑️ Réinitialisation ChromaDB...")
|
||||
faq_service.reset_collection()
|
||||
|
||||
print(f"📚 {len(pdfs)} PDF(s) à indexer...\n")
|
||||
total_sections = 0
|
||||
erreurs = []
|
||||
|
||||
for pdf_path in sorted(pdfs):
|
||||
try:
|
||||
n = indexer_pdf(faq_service, pdf_path)
|
||||
total_sections += n
|
||||
print(f" ✅ {os.path.basename(pdf_path)} — {n} sections")
|
||||
except Exception as e:
|
||||
erreurs.append(os.path.basename(pdf_path))
|
||||
print(f" ❌ {os.path.basename(pdf_path)} : {e}")
|
||||
|
||||
print(f"\n{'='*40}")
|
||||
print(f"✅ Terminé — {total_sections} sections dans ChromaDB")
|
||||
print(f"📄 {len(pdfs) - len(erreurs)}/{len(pdfs)} PDFs indexés")
|
||||
if erreurs:
|
||||
print(f"❌ Échecs : {', '.join(erreurs)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
reindexer_tout()
|
||||
0
services/__init__.py
Normal file
0
services/__init__.py
Normal file
225
services/faq_service.py
Normal file
225
services/faq_service.py
Normal file
@ -0,0 +1,225 @@
|
||||
import logging
|
||||
import os
|
||||
import config
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import chromadb
|
||||
_chroma_ok = True
|
||||
except ImportError:
|
||||
_chroma_ok = False
|
||||
log.warning("chromadb non installé — pip install chromadb")
|
||||
|
||||
try:
|
||||
import ollama as ollama_lib
|
||||
_ollama_ok = True
|
||||
except ImportError:
|
||||
_ollama_ok = False
|
||||
|
||||
MOTS_PRODUITS = {
|
||||
"multiplus": "Victron MultiPlus",
|
||||
"quattro": "Victron MultiPlus",
|
||||
"ve.bus": "Victron MultiPlus",
|
||||
"vebus": "Victron MultiPlus",
|
||||
"ess": "Victron MultiPlus",
|
||||
"victron": "Victron MultiPlus",
|
||||
"fronius": "Onduleur Fronius",
|
||||
"symo": "Onduleur Fronius Symo",
|
||||
"sma": "Onduleur SMA",
|
||||
"huawei": "Onduleur Huawei",
|
||||
"byd": "Batterie BYD",
|
||||
"pylontech": "Batterie Pylontech",
|
||||
"daikin": "PAC Daikin",
|
||||
"viessmann": "PAC Viessmann",
|
||||
"keba": "Borne IRVE Keba",
|
||||
}
|
||||
|
||||
SYSTEM_PROMPT = """Tu es un expert technique pour des électriciens terrain en Alsace.
|
||||
Installations : photovoltaïque, PAC, IRVE, onduleurs Victron.
|
||||
|
||||
FORMAT DE RÉPONSE OBLIGATOIRE (toujours respecter ce format) :
|
||||
⚠️ Problème : [1 ligne — ce qui se passe]
|
||||
✅ Actions : [2-4 étapes numérotées, concrètes, actionnables]
|
||||
📄 Source : [nom du document et page si disponible]
|
||||
|
||||
RÈGLES :
|
||||
- Maximum 6 lignes au total
|
||||
- Chaque action = 1 geste précis que le technicien peut faire maintenant
|
||||
- Si tu ne sais pas → réponds "❓ Consulter le manuel constructeur"
|
||||
- Jamais de phrases génériques comme "vérifier l'installation"
|
||||
"""
|
||||
|
||||
USER_PROMPT = """Question technicien terrain : {question}
|
||||
|
||||
Extraits de documentation disponibles :
|
||||
{docs}
|
||||
|
||||
Réponds UNIQUEMENT au format demandé : Problème / Actions / Source."""
|
||||
|
||||
|
||||
def detecter_produit_question(question: str) -> str | None:
|
||||
q = question.lower()
|
||||
for mot, produit in MOTS_PRODUITS.items():
|
||||
if mot in q:
|
||||
return produit
|
||||
return None
|
||||
|
||||
|
||||
class FAQService:
|
||||
def __init__(self):
|
||||
self.client = None
|
||||
self.collection = None
|
||||
if not _chroma_ok:
|
||||
return
|
||||
try:
|
||||
os.makedirs("./data", exist_ok=True)
|
||||
self.client = chromadb.PersistentClient(path="./data/chromadb")
|
||||
self.collection = self.client.get_or_create_collection(
|
||||
name="etm_faq",
|
||||
metadata={"hnsw:space": "cosine"},
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error("FAQService init : %s", exc)
|
||||
|
||||
@property
|
||||
def disponible(self) -> bool:
|
||||
return self.collection is not None and self.collection.count() > 0
|
||||
|
||||
def reset_collection(self) -> None:
|
||||
"""Vider et recréer la collection ChromaDB."""
|
||||
if not self.client:
|
||||
return
|
||||
try:
|
||||
self.client.delete_collection("etm_faq")
|
||||
except Exception:
|
||||
pass
|
||||
self.collection = self.client.get_or_create_collection(
|
||||
name="etm_faq",
|
||||
metadata={"hnsw:space": "cosine"},
|
||||
)
|
||||
|
||||
def rechercher(self, question: str, n_results: int = 2) -> dict:
|
||||
"""Recherche vectorielle avec filtre produit si détecté."""
|
||||
produit = detecter_produit_question(question)
|
||||
kwargs = {"query_texts": [question], "n_results": n_results}
|
||||
|
||||
if produit:
|
||||
kwargs["where"] = {"produit": {"$eq": produit}}
|
||||
|
||||
try:
|
||||
results = self.collection.query(**kwargs)
|
||||
except Exception:
|
||||
results = self.collection.query(query_texts=[question], n_results=n_results)
|
||||
|
||||
docs = results["documents"][0] if results["documents"] else []
|
||||
metas = results["metadatas"][0] if results["metadatas"] else []
|
||||
|
||||
# Déduplication
|
||||
seen, docs_uniques, metas_uniques = set(), [], []
|
||||
for doc, meta in zip(docs, metas):
|
||||
cle = doc[:80]
|
||||
if cle not in seen:
|
||||
seen.add(cle)
|
||||
docs_uniques.append(doc)
|
||||
metas_uniques.append(meta)
|
||||
|
||||
return {
|
||||
"documents": docs_uniques,
|
||||
"metadatas": metas_uniques,
|
||||
"produit_detecte": produit,
|
||||
}
|
||||
|
||||
def indexer_fiche_sav(self, ticket_id: str, produit: str, symptome: str,
|
||||
cause: str, solution: str, chantier: str, date: str, duree_min: int) -> None:
|
||||
if not self.collection:
|
||||
return
|
||||
document = f"Produit: {produit}\nSymptôme: {symptome}\nCause: {cause}\nSolution: {solution}"
|
||||
self.collection.upsert(
|
||||
documents=[document],
|
||||
ids=[ticket_id],
|
||||
metadatas=[{
|
||||
"source": "experience_etm",
|
||||
"produit": produit,
|
||||
"chantier": chantier,
|
||||
"date": date,
|
||||
"duree_min": duree_min,
|
||||
}],
|
||||
)
|
||||
|
||||
def indexer_document(self, texte: str, source: str, produit: str = "") -> int:
|
||||
"""Indexation texte brut (fallback si pas de pymupdf)."""
|
||||
if not self.collection or not texte.strip():
|
||||
return 0
|
||||
mots = texte.split()
|
||||
chunk_size = 400
|
||||
chunks = [" ".join(mots[i:i + chunk_size]) for i in range(0, len(mots), int(chunk_size * 0.8))]
|
||||
try:
|
||||
self.collection.delete(where={"source": source})
|
||||
except Exception:
|
||||
pass
|
||||
self.collection.upsert(
|
||||
documents=chunks,
|
||||
ids=[f"{source}_chunk_{i}" for i in range(len(chunks))],
|
||||
metadatas=[{"source": source, "produit": produit, "type": "documentation"}] * len(chunks),
|
||||
)
|
||||
return len(chunks)
|
||||
|
||||
def formater_sans_ollama(self, question: str, docs: list, metas: list) -> str:
|
||||
lignes = [f"📚 FAQ ETM\n{'━' * 22}"]
|
||||
for doc, meta in zip(docs[:2], metas[:2]):
|
||||
source = meta.get("source", "")
|
||||
page = meta.get("page", "")
|
||||
titre = meta.get("titre", "")
|
||||
label = f"📄 {source}"
|
||||
if page:
|
||||
label += f" — p.{page}"
|
||||
if titre:
|
||||
label += f"\n{titre}"
|
||||
lignes.append(label)
|
||||
lignes.append(doc[:250].strip())
|
||||
lignes.append("")
|
||||
lignes.append("━" * 22)
|
||||
return "\n".join(lignes)
|
||||
|
||||
async def repondre(self, question: str) -> str:
|
||||
if not self.collection or self.collection.count() == 0:
|
||||
return "❓ Base vide — lance /sync_docs pour indexer les fiches techniques."
|
||||
|
||||
resultats = self.rechercher(question)
|
||||
if not resultats["documents"]:
|
||||
return "❓ Aucune solution connue pour ce problème."
|
||||
|
||||
if config.OLLAMA_ENABLED and _ollama_ok:
|
||||
try:
|
||||
response = ollama_lib.chat(
|
||||
model=config.OLLAMA_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": USER_PROMPT.format(
|
||||
question=question,
|
||||
docs="\n\n".join(resultats["documents"]),
|
||||
)},
|
||||
],
|
||||
)
|
||||
return response["message"]["content"]
|
||||
except Exception as exc:
|
||||
log.error("Ollama : %s", exc)
|
||||
|
||||
return self.formater_sans_ollama(question, resultats["documents"], resultats["metadatas"])
|
||||
|
||||
|
||||
def extract_pdf_text(pdf_bytes: bytes) -> str:
|
||||
"""Fallback extraction texte brut (pypdf) si pymupdf non dispo."""
|
||||
try:
|
||||
from io import BytesIO
|
||||
import pypdf
|
||||
reader = pypdf.PdfReader(BytesIO(pdf_bytes))
|
||||
pages = [p.extract_text() for p in reader.pages if p.extract_text()]
|
||||
return "\n\n".join(p.strip() for p in pages)
|
||||
except Exception as exc:
|
||||
log.error("extract_pdf_text : %s", exc)
|
||||
return ""
|
||||
|
||||
|
||||
faq_service = FAQService()
|
||||
110
services/llm.py
Normal file
110
services/llm.py
Normal file
@ -0,0 +1,110 @@
|
||||
import logging
|
||||
from io import BytesIO
|
||||
import httpx
|
||||
|
||||
try:
|
||||
import pypdf
|
||||
_pypdf_ok = True
|
||||
except ImportError:
|
||||
_pypdf_ok = False
|
||||
log_tmp = logging.getLogger(__name__)
|
||||
log_tmp.warning("pypdf non installé — extraction PDF désactivée. pip install pypdf")
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import chromadb
|
||||
_chroma_ok = True
|
||||
except ImportError:
|
||||
_chroma_ok = False
|
||||
log.warning("chromadb non installé — FAQ SAV désactivée. Installez : pip install chromadb")
|
||||
|
||||
OLLAMA_URL = "http://localhost:11434/api/generate"
|
||||
OLLAMA_MODEL = "phi3"
|
||||
|
||||
|
||||
class SAVAssistant:
|
||||
def __init__(self):
|
||||
self._collection = None
|
||||
if not _chroma_ok:
|
||||
return
|
||||
try:
|
||||
db = chromadb.PersistentClient(path="./sav_knowledge")
|
||||
self._collection = db.get_or_create_collection("docs_techniques")
|
||||
except Exception as exc:
|
||||
log.error("SAVAssistant init : %s", exc)
|
||||
|
||||
@property
|
||||
def disponible(self) -> bool:
|
||||
return self._collection is not None and self._collection.count() > 0
|
||||
|
||||
async def indexer_document(self, texte: str, source: str) -> int:
|
||||
if not self._collection:
|
||||
return 0
|
||||
chunks = [texte[i:i + 500] for i in range(0, len(texte), 400)]
|
||||
try:
|
||||
self._collection.delete(where={"source": source})
|
||||
except Exception:
|
||||
pass
|
||||
self._collection.add(
|
||||
documents=chunks,
|
||||
ids=[f"{source}_{i}" for i in range(len(chunks))],
|
||||
metadatas=[{"source": source}] * len(chunks),
|
||||
)
|
||||
log.info("Indexé %d chunks depuis '%s'", len(chunks), source)
|
||||
return len(chunks)
|
||||
|
||||
async def chercher(self, question: str, n_results: int = 3) -> str | None:
|
||||
if not self.disponible:
|
||||
return None
|
||||
try:
|
||||
results = self._collection.query(query_texts=[question], n_results=n_results)
|
||||
extraits = results["documents"][0]
|
||||
sources = [m["source"] for m in results["metadatas"][0]]
|
||||
if not extraits:
|
||||
return None
|
||||
# Reformuler avec Ollama si disponible
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(
|
||||
OLLAMA_URL,
|
||||
json={
|
||||
"model": OLLAMA_MODEL,
|
||||
"prompt": (
|
||||
f"Problème terrain : {question}\n\n"
|
||||
"Documentation :\n" + "\n\n".join(extraits) +
|
||||
"\n\nDonne 3 pistes de diagnostic en français, "
|
||||
"en langage simple pour un technicien de terrain."
|
||||
),
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json().get("response", "").strip() or None
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback : extraits bruts
|
||||
return "\n\n---\n\n".join(f"📄 *{s}*\n{e}" for s, e in zip(sources, extraits))
|
||||
except Exception as exc:
|
||||
log.error("SAVAssistant.chercher : %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
sav_assistant = SAVAssistant()
|
||||
|
||||
|
||||
def extract_pdf_text(pdf_bytes: bytes) -> str:
|
||||
"""Extrait le texte d'un PDF. Retourne une chaîne vide si non lisible."""
|
||||
if not _pypdf_ok:
|
||||
return ""
|
||||
try:
|
||||
reader = pypdf.PdfReader(BytesIO(pdf_bytes))
|
||||
pages = []
|
||||
for page in reader.pages:
|
||||
text = page.extract_text()
|
||||
if text:
|
||||
pages.append(text.strip())
|
||||
return "\n\n".join(pages)
|
||||
except Exception as exc:
|
||||
logging.getLogger(__name__).error("extract_pdf_text : %s", exc)
|
||||
return ""
|
||||
154
services/nextcloud.py
Normal file
154
services/nextcloud.py
Normal file
@ -0,0 +1,154 @@
|
||||
import logging
|
||||
import httpx
|
||||
from urllib.parse import unquote
|
||||
from xml.etree import ElementTree as ET
|
||||
from config import NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_PASSWORD, NEXTCLOUD_DECK_URL
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_auth = (NEXTCLOUD_USER, NEXTCLOUD_PASSWORD)
|
||||
|
||||
|
||||
async def _ensure_dirs(client: httpx.AsyncClient, dir_path: str) -> None:
|
||||
parts = dir_path.strip("/").split("/")
|
||||
for i in range(1, len(parts) + 1):
|
||||
current = "/".join(parts[:i])
|
||||
r = await client.request("MKCOL", f"{NEXTCLOUD_URL}/{current}")
|
||||
# 201 = créé, 405 = existe déjà — les deux sont OK
|
||||
if r.status_code not in (200, 201, 405):
|
||||
log.warning("MKCOL %s → HTTP %s", current, r.status_code)
|
||||
|
||||
|
||||
async def upload_photo(file_bytes: bytes, remote_path: str) -> bool:
|
||||
dir_path = remote_path.rsplit("/", 1)[0]
|
||||
try:
|
||||
async with httpx.AsyncClient(auth=_auth, timeout=60) as client:
|
||||
await _ensure_dirs(client, dir_path)
|
||||
r = await client.put(f"{NEXTCLOUD_URL}/{remote_path}", content=file_bytes)
|
||||
ok = r.status_code in (200, 201, 204)
|
||||
if not ok:
|
||||
log.error("PUT %s → HTTP %s", remote_path, r.status_code)
|
||||
return ok
|
||||
except Exception as exc:
|
||||
log.error("upload_photo %s : %s", remote_path, exc)
|
||||
return False
|
||||
|
||||
|
||||
async def get_chantiers_actifs(max_results: int = 10) -> list[str]:
|
||||
url = f"{NEXTCLOUD_URL}/Chantiers/"
|
||||
body = b'<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:resourcetype/></d:prop></d:propfind>'
|
||||
try:
|
||||
async with httpx.AsyncClient(auth=_auth, timeout=30) as client:
|
||||
r = await client.request("PROPFIND", url, headers={"Depth": "1"}, content=body)
|
||||
root = ET.fromstring(r.text)
|
||||
ns = {"d": "DAV:"}
|
||||
dossiers = []
|
||||
for resp in root.findall("d:response", ns):
|
||||
href = resp.find("d:href", ns).text
|
||||
name = unquote(href.rstrip("/").split("/")[-1])
|
||||
if name.lower() in ("chantiers", "") or name.startswith("."):
|
||||
continue
|
||||
resourcetype = resp.find(".//d:resourcetype", ns)
|
||||
if resourcetype is not None and resourcetype.find("d:collection", ns) is not None:
|
||||
dossiers.append(name)
|
||||
dossiers.sort(reverse=True)
|
||||
return dossiers[:max_results]
|
||||
except Exception as exc:
|
||||
log.error("get_chantiers_actifs : %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
async def upload_text(content: str, remote_path: str) -> bool:
|
||||
"""Upload un fichier texte (markdown, etc.) sur Nextcloud."""
|
||||
dir_path = remote_path.rsplit("/", 1)[0]
|
||||
try:
|
||||
async with httpx.AsyncClient(auth=_auth, timeout=30) as client:
|
||||
await _ensure_dirs(client, dir_path)
|
||||
r = await client.put(
|
||||
f"{NEXTCLOUD_URL}/{remote_path}",
|
||||
content=content.encode("utf-8"),
|
||||
headers={"Content-Type": "text/markdown; charset=utf-8"},
|
||||
)
|
||||
ok = r.status_code in (200, 201, 204)
|
||||
if not ok:
|
||||
log.error("PUT text %s → HTTP %s", remote_path, r.status_code)
|
||||
return ok
|
||||
except Exception as exc:
|
||||
log.error("upload_text %s : %s", remote_path, exc)
|
||||
return False
|
||||
|
||||
|
||||
async def list_pdfs(folder: str = "Doc") -> list[str]:
|
||||
"""Retourne les chemins relatifs des PDFs dans un dossier Nextcloud (récursif)."""
|
||||
body = b'<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:resourcetype/></d:prop></d:propfind>'
|
||||
|
||||
async def _scan(client: httpx.AsyncClient, path: str, prefix: str) -> list[str]:
|
||||
r = await client.request("PROPFIND", f"{NEXTCLOUD_URL}/{path}/",
|
||||
headers={"Depth": "1"}, content=body)
|
||||
if r.status_code == 404:
|
||||
log.error("Dossier '%s' introuvable sur Nextcloud", path)
|
||||
return []
|
||||
root = ET.fromstring(r.text)
|
||||
ns = {"d": "DAV:"}
|
||||
files, subdirs = [], []
|
||||
for resp in root.findall("d:response", ns):
|
||||
href = resp.find("d:href", ns).text
|
||||
name = unquote(href.rstrip("/").split("/")[-1])
|
||||
if not name or name == path.split("/")[-1]:
|
||||
continue
|
||||
rt = resp.find(".//d:resourcetype", ns)
|
||||
is_dir = rt is not None and rt.find("d:collection", ns) is not None
|
||||
rel = f"{prefix}/{name}" if prefix else name
|
||||
if is_dir:
|
||||
subdirs.append((f"{path}/{name}", rel))
|
||||
elif name.lower().endswith(".pdf"):
|
||||
files.append(rel)
|
||||
for subpath, subprefix in subdirs:
|
||||
files.extend(await _scan(client, subpath, subprefix))
|
||||
return files
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(auth=_auth, timeout=60) as client:
|
||||
files = await _scan(client, folder, "")
|
||||
return sorted(files)
|
||||
except Exception as exc:
|
||||
log.error("list_pdfs : %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
async def download_file(folder: str, filename: str) -> bytes | None:
|
||||
"""Télécharge un fichier depuis Nextcloud."""
|
||||
from urllib.parse import quote
|
||||
url = f"{NEXTCLOUD_URL}/{folder}/{quote(filename, safe='/')}"
|
||||
try:
|
||||
async with httpx.AsyncClient(auth=_auth, timeout=120) as client:
|
||||
r = await client.get(url)
|
||||
if r.status_code == 200:
|
||||
return r.content
|
||||
log.error("download_file %s → HTTP %s", filename, r.status_code)
|
||||
except Exception as exc:
|
||||
log.error("download_file %s : %s", filename, exc)
|
||||
return None
|
||||
|
||||
|
||||
async def create_deck_card(board_id: int, stack_id: int, title: str, description: str) -> bool:
|
||||
if not board_id or not stack_id:
|
||||
return False
|
||||
url = f"{NEXTCLOUD_DECK_URL}/boards/{board_id}/stacks/{stack_id}/cards"
|
||||
payload = {
|
||||
"title": title[:255],
|
||||
"description": description,
|
||||
"type": "plain",
|
||||
"order": 999,
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
auth=_auth,
|
||||
timeout=30,
|
||||
headers={"OCS-APIREQUEST": "true"},
|
||||
) as client:
|
||||
r = await client.post(url, json=payload)
|
||||
return r.status_code in (200, 201)
|
||||
except Exception as exc:
|
||||
log.error("create_deck_card : %s", exc)
|
||||
return False
|
||||
116
services/pdf_indexer.py
Normal file
116
services/pdf_indexer.py
Normal file
@ -0,0 +1,116 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import fitz # pymupdf
|
||||
_fitz_ok = True
|
||||
except ImportError:
|
||||
_fitz_ok = False
|
||||
log.warning("pymupdf non installé — pip install pymupdf")
|
||||
|
||||
PRODUITS_PDF = {
|
||||
"multiplus": "Victron MultiPlus",
|
||||
"quattro": "Victron Quattro",
|
||||
"victron": "Victron",
|
||||
"ve.bus": "Victron VE.Bus",
|
||||
"ess": "Victron ESS",
|
||||
"fronius": "Onduleur Fronius",
|
||||
"symo": "Onduleur Fronius Symo",
|
||||
"sma": "Onduleur SMA",
|
||||
"huawei": "Onduleur Huawei",
|
||||
"byd": "Batterie BYD",
|
||||
"pylontech": "Batterie Pylontech",
|
||||
"daikin": "PAC Daikin",
|
||||
"viessmann": "PAC Viessmann",
|
||||
"keba": "Borne IRVE Keba",
|
||||
}
|
||||
|
||||
|
||||
def detecter_produit_pdf(nom_fichier: str) -> str:
|
||||
nom = nom_fichier.lower()
|
||||
for mot, produit in PRODUITS_PDF.items():
|
||||
if mot in nom:
|
||||
return produit
|
||||
return "Documentation technique"
|
||||
|
||||
|
||||
def extraire_sections(pdf_path: str) -> list[dict]:
|
||||
"""Découpe un PDF en sections logiques par titres/headings."""
|
||||
if not _fitz_ok:
|
||||
return []
|
||||
|
||||
doc = fitz.open(pdf_path)
|
||||
sections = []
|
||||
section_courante = {"titre": "", "contenu": "", "page": 1}
|
||||
|
||||
for page_num, page in enumerate(doc, 1):
|
||||
blocs = page.get_text("dict")["blocks"]
|
||||
for bloc in blocs:
|
||||
if "lines" not in bloc:
|
||||
continue
|
||||
for line in bloc["lines"]:
|
||||
texte = " ".join(span["text"] for span in line["spans"]).strip()
|
||||
taille = max((span["size"] for span in line["spans"]), default=0)
|
||||
gras = any(span["flags"] & 2**4 for span in line["spans"])
|
||||
|
||||
if not texte or re.match(r"^\d+$", texte): # ignorer numéros de page
|
||||
continue
|
||||
|
||||
est_titre = (taille > 11 or gras) and len(texte) < 120
|
||||
if est_titre:
|
||||
if len(section_courante["contenu"].strip()) > 100:
|
||||
sections.append(section_courante.copy())
|
||||
section_courante = {
|
||||
"titre": texte,
|
||||
"contenu": texte + "\n",
|
||||
"page": page_num,
|
||||
}
|
||||
else:
|
||||
section_courante["contenu"] += texte + " "
|
||||
|
||||
if len(section_courante["contenu"].strip()) > 100:
|
||||
sections.append(section_courante)
|
||||
|
||||
doc.close()
|
||||
return sections
|
||||
|
||||
|
||||
def indexer_pdf(faq_service, pdf_path: str) -> int:
|
||||
"""Indexe un PDF dans ChromaDB par sections logiques."""
|
||||
nom_fichier = os.path.basename(pdf_path)
|
||||
produit = detecter_produit_pdf(nom_fichier)
|
||||
sections = extraire_sections(pdf_path)
|
||||
|
||||
if not sections:
|
||||
log.warning("%s : aucune section extraite", nom_fichier)
|
||||
return 0
|
||||
|
||||
# Supprimer l'ancienne indexation de ce fichier
|
||||
try:
|
||||
faq_service.collection.delete(where={"source": nom_fichier})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
indexees = 0
|
||||
for i, section in enumerate(sections):
|
||||
contenu = section["contenu"].strip()
|
||||
if len(contenu) < 80:
|
||||
continue
|
||||
faq_service.collection.upsert(
|
||||
documents=[contenu],
|
||||
ids=[f"{nom_fichier}_s{i}"],
|
||||
metadatas=[{
|
||||
"source": nom_fichier,
|
||||
"produit": produit,
|
||||
"titre": section["titre"][:200],
|
||||
"page": section["page"],
|
||||
"type": "documentation",
|
||||
}],
|
||||
)
|
||||
indexees += 1
|
||||
|
||||
log.info("%s — %d/%d sections indexées (%s)", nom_fichier, indexees, len(sections), produit)
|
||||
return indexees
|
||||
47
services/telegram.py
Normal file
47
services/telegram.py
Normal file
@ -0,0 +1,47 @@
|
||||
from telegram import Bot
|
||||
from config import GROUPE_CHANTIER, GROUPE_MAGASINIER, GROUPE_SAV
|
||||
|
||||
|
||||
async def notify_fin_chantier(
|
||||
bot: Bot, ouvrier: str, chantier: str, n_photos: int, nc_path: str
|
||||
) -> None:
|
||||
text = (
|
||||
f"✅ Fin de chantier\n"
|
||||
f"👷 {ouvrier}\n"
|
||||
f"🏠 Chantier : {chantier}\n"
|
||||
f"📸 {n_photos} photo(s) archivée(s)\n"
|
||||
f"📁 {nc_path}\n"
|
||||
f"💶 Tu peux préparer la facture finale."
|
||||
)
|
||||
await bot.send_message(chat_id=GROUPE_CHANTIER, text=text)
|
||||
|
||||
|
||||
async def notify_sav(
|
||||
bot: Bot,
|
||||
ouvrier: str,
|
||||
chantier: str,
|
||||
description: str,
|
||||
photo_bytes: bytes | None = None,
|
||||
) -> None:
|
||||
if not GROUPE_SAV:
|
||||
return
|
||||
text = (
|
||||
f"🚨 Alerte SAV\n"
|
||||
f"👷 {ouvrier}\n"
|
||||
f"🏠 Chantier : {chantier}\n"
|
||||
f"📋 {description}"
|
||||
)
|
||||
if photo_bytes:
|
||||
await bot.send_photo(chat_id=GROUPE_SAV, photo=photo_bytes, caption=text)
|
||||
else:
|
||||
await bot.send_message(chat_id=GROUPE_SAV, text=text)
|
||||
|
||||
|
||||
async def notify_materiel(bot: Bot, ouvrier: str, chantier: str, liste: str) -> None:
|
||||
text = (
|
||||
f"📦 Matériel Manquant\n"
|
||||
f"👷 {ouvrier}\n"
|
||||
f"🏠 Chantier : {chantier}\n"
|
||||
f"📋 {liste}"
|
||||
)
|
||||
await bot.send_message(chat_id=GROUPE_MAGASINIER, text=text)
|
||||
77
services/tickets.py
Normal file
77
services/tickets.py
Normal file
@ -0,0 +1,77 @@
|
||||
import sqlite3
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
DB_PATH = "./data/tickets.db"
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs("./data", exist_ok=True)
|
||||
return sqlite3.connect(DB_PATH)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as db:
|
||||
db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER,
|
||||
username TEXT,
|
||||
chat_id INTEGER,
|
||||
chantier TEXT,
|
||||
description TEXT,
|
||||
produit TEXT,
|
||||
status TEXT DEFAULT 'ouvert',
|
||||
created_at TEXT,
|
||||
resolved_at TEXT,
|
||||
cause TEXT,
|
||||
solution TEXT,
|
||||
duree_min INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def _next_id() -> str:
|
||||
year = datetime.now().year
|
||||
with _conn() as db:
|
||||
row = db.execute(
|
||||
"SELECT COUNT(*) FROM tickets WHERE id LIKE ?", (f"{year}-%",)
|
||||
).fetchone()
|
||||
n = (row[0] if row else 0) + 1
|
||||
return f"{year}-{n:03d}"
|
||||
|
||||
|
||||
def creer_ticket(user_id: int, username: str, chat_id: int, chantier: str, description: str, produit: str = "") -> str:
|
||||
ticket_id = _next_id()
|
||||
with _conn() as db:
|
||||
db.execute(
|
||||
"INSERT INTO tickets (id, user_id, username, chat_id, chantier, description, produit, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(ticket_id, user_id, username, chat_id, chantier, description, produit, datetime.now().isoformat()),
|
||||
)
|
||||
return ticket_id
|
||||
|
||||
|
||||
def get_ticket(ticket_id: str) -> dict | None:
|
||||
with _conn() as db:
|
||||
db.row_factory = sqlite3.Row
|
||||
row = db.execute("SELECT * FROM tickets WHERE id = ?", (ticket_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def resoudre_ticket(ticket_id: str, cause: str, solution: str, duree_min: int) -> bool:
|
||||
with _conn() as db:
|
||||
r = db.execute(
|
||||
"UPDATE tickets SET status='resolu', resolved_at=?, cause=?, solution=?, duree_min=? WHERE id=?",
|
||||
(datetime.now().isoformat(), cause, solution, duree_min, ticket_id),
|
||||
)
|
||||
return r.rowcount > 0
|
||||
|
||||
|
||||
def tickets_ouverts() -> list[dict]:
|
||||
with _conn() as db:
|
||||
db.row_factory = sqlite3.Row
|
||||
rows = db.execute(
|
||||
"SELECT * FROM tickets WHERE status='ouvert' ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
0
utils/__init__.py
Normal file
0
utils/__init__.py
Normal file
33
utils/produit_detector.py
Normal file
33
utils/produit_detector.py
Normal file
@ -0,0 +1,33 @@
|
||||
PRODUITS = {
|
||||
"fronius": "Onduleur Fronius",
|
||||
"symo": "Onduleur Fronius Symo",
|
||||
"primo": "Onduleur Fronius Primo",
|
||||
"sma": "Onduleur SMA",
|
||||
"huawei": "Onduleur Huawei",
|
||||
"enphase": "Micro-onduleur Enphase",
|
||||
"byd": "Batterie BYD",
|
||||
"pylontech": "Batterie Pylontech",
|
||||
"soltaro": "Batterie Soltaro",
|
||||
"daikin": "PAC Daikin Altherma",
|
||||
"viessmann": "PAC Viessmann",
|
||||
"atlantic": "PAC Atlantic",
|
||||
"keba": "Borne IRVE Keba",
|
||||
"powercharger": "ETM-PowerCharger",
|
||||
"wallbox": "Borne IRVE Wallbox",
|
||||
"hems": "Système HEMS",
|
||||
"onduleur": "Onduleur",
|
||||
"batterie": "Batterie",
|
||||
"pac ": "Pompe à Chaleur",
|
||||
"irve": "Borne IRVE",
|
||||
"borne": "Borne IRVE",
|
||||
"panneau": "Panneaux PV",
|
||||
"optimiseur": "Optimiseur",
|
||||
}
|
||||
|
||||
|
||||
def detecter_produit(texte: str) -> str:
|
||||
t = texte.lower()
|
||||
for mot, produit in PRODUITS.items():
|
||||
if mot in t:
|
||||
return produit
|
||||
return "Produit non identifié"
|
||||
Loading…
x
Reference in New Issue
Block a user