first commit

This commit is contained in:
Ladebeze66 2025-03-27 14:08:10 +01:00
commit 3ac1fa1617
25 changed files with 2794 additions and 0 deletions

174
README.md Normal file
View File

@ -0,0 +1,174 @@
# Prétraitement PDF pour Ragflow
Outil de prétraitement de documents PDF avec agents LLM modulables pour l'analyse, la traduction et le résumé.
## Fonctionnalités
- **Sélection visuelle** de zones dans les documents PDF (tableaux, schémas, formules, texte)
- **Analyse automatique** avec différents agents LLM configurables
- **Niveaux d'analyse** adaptables selon les besoins (léger, moyen, avancé)
- **Export Markdown** pour intégration dans une base Ragflow
## Installation
### Prérequis
- Python 3.8 ou supérieur
- PyQt6
- PyMuPDF (fitz)
- Tesseract OCR (pour la reconnaissance de texte)
- Ollama (pour les modèles LLM)
### Méthode 1 : Installation directe (recommandée)
Clonez ce dépôt et installez avec pip :
```bash
git clone https://github.com/votre-utilisateur/ragflow-pretraitement.git
cd ragflow_pretraitement
pip install -e .
```
Cette méthode installera automatiquement toutes les dépendances Python requises.
### Méthode 2 : Installation manuelle
Si vous préférez une installation manuelle :
```bash
pip install -r requirements.txt
```
### Installation de Tesseract OCR
Pour l'OCR, vous devez également installer Tesseract :
- **Windows** : Téléchargez et installez depuis [https://github.com/UB-Mannheim/tesseract/wiki](https://github.com/UB-Mannheim/tesseract/wiki)
- **Linux** : `sudo apt install tesseract-ocr tesseract-ocr-fra tesseract-ocr-eng`
- **macOS** : `brew install tesseract`
### Installation d'Ollama
Suivez les instructions d'installation d'Ollama disponibles sur [https://ollama.ai/](https://ollama.ai/)
Modèles recommandés à télécharger :
```bash
ollama pull llava:34b
ollama pull mistral
ollama pull llama3.2-vision
```
## Utilisation
### Lancement de l'application
Si vous avez utilisé l'installation avec le script setup.py :
```bash
ragflow-pretraitement
```
Ou manuellement :
```bash
cd ragflow_pretraitement
python main.py
```
### Processus typique
1. Charger un PDF avec le bouton "Charger PDF"
2. Naviguer entre les pages avec les boutons ou le sélecteur
3. Sélectionner une zone d'intérêt avec la souris
4. Choisir le type de contenu (schéma, tableau, formule...)
5. Ajouter un contexte textuel si nécessaire
6. Configurer les agents LLM dans l'onglet "Agents LLM"
7. Appliquer l'agent sur la sélection
8. Exporter le résultat en Markdown
## Configuration des agents LLM
L'application propose trois niveaux d'analyse :
| Niveau | Vision | Résumé | Traduction | Usage recommandé |
| --------- | ----------------- | ----------------- | ---------------- | --------------------------- |
| 🔹 Léger | llava:34b | mistral | mistral | Débogage, prototypes |
| ⚪ Moyen | llava | deepseek-r1 | qwen2.5 | Usage normal |
| 🔸 Avancé | llama3.2-vision | deepseek-r1 | deepseek | Documents critiques |
Vous pouvez personnaliser ces configurations dans le fichier `config/llm_profiles.json`.
## Paramètres avancés
- **Température** : Contrôle la créativité des réponses (0.1-1.0)
- **Top-p/Top-k** : Paramètres d'échantillonnage pour la génération
- **Max tokens** : Limite la longueur des réponses générées
## Résolution des problèmes courants
### Erreurs d'importation de PyQt6
Si vous rencontrez des erreurs avec PyQt6, essayez de le réinstaller :
```bash
pip uninstall PyQt6 PyQt6-Qt6 PyQt6-sip
pip install PyQt6
```
### Problèmes avec l'OCR
Si l'OCR (Tesseract) ne fonctionne pas correctement :
1. Vérifiez que Tesseract est correctement installé et disponible dans le PATH
2. Pour Windows, vous devrez peut-être décommenter et modifier la ligne `pytesseract.pytesseract.tesseract_cmd` dans `utils/ocr.py`
### Ollama introuvable
Si Ollama n'est pas détecté, vérifiez que le service est bien démarré :
```bash
# Linux/macOS
ollama serve
# Windows
# Utilisez l'interface graphique ou exécutez le service Ollama
```
## Structure du projet
```
ragflow_pretraitement/
├── main.py # Point d'entrée principal
├── ui/ # Interface utilisateur
│ ├── viewer.py # Visualisation PDF et sélection
│ └── llm_config_panel.py # Configuration des agents
├── agents/ # Agents LLM
│ ├── base.py # Classe de base
│ ├── vision.py # Agent d'analyse visuelle
│ ├── summary.py # Agent de résumé
│ ├── translation.py # Agent de traduction
│ └── rewriter.py # Agent de reformulation
├── utils/ # Utilitaires
│ ├── ocr.py # Reconnaissance de texte
│ ├── markdown_export.py # Export en Markdown
│ └── api_ollama.py # Communication avec l'API Ollama
├── config/ # Configuration
│ └── llm_profiles.json # Profils des LLM
└── data/ # Données
└── outputs/ # Résultats générés
```
## Utilisation avancée
### Personnalisation des prompts
Les prompts par défaut peuvent être modifiés directement dans le code des agents (fichiers `agents/*.py`).
### Ajouter de nouveaux modèles
Pour ajouter un nouveau modèle, ajoutez-le dans `config/llm_profiles.json` et assurez-vous qu'il est disponible via Ollama.
## Limitations
- OCR parfois imprécis sur les documents complexes
- Certains modèles nécessitent beaucoup de mémoire GPU
- Les formules mathématiques complexes peuvent être mal interprétées
## Contribution
Les contributions sont les bienvenues ! N'hésitez pas à soumettre des pull requests ou à signaler des problèmes dans l'outil de suivi.

6
__init__.py Normal file
View File

@ -0,0 +1,6 @@
"""
Programme de prétraitement PDF avec agents LLM modulables pour Ragflow
"""
__version__ = "1.0.0"
__author__ = "Ragflow Team"

14
agents/__init__.py Normal file
View File

@ -0,0 +1,14 @@
# Module agents
from .base import LLMBaseAgent
from .vision import VisionAgent
from .translation import TranslationAgent
from .summary import SummaryAgent
from .rewriter import RewriterAgent
__all__ = [
'LLMBaseAgent',
'VisionAgent',
'TranslationAgent',
'SummaryAgent',
'RewriterAgent'
]

77
agents/base.py Normal file
View File

@ -0,0 +1,77 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Classe de base pour tous les agents LLM
"""
from typing import List, Dict, Any, Optional
class LLMBaseAgent:
"""
Classe de base pour tous les agents LLM. Fournit des méthodes communes
pour interagir avec différents modèles de langage.
"""
def __init__(self, model_name: str, endpoint: str = "http://localhost:11434/v1", **config):
"""
Initialise un agent LLM
Args:
model_name (str): Nom du modèle à utiliser
endpoint (str): URL de l'API Ollama ou autre endpoint
**config: Paramètres de configuration supplémentaires
- temperature (float): Température pour la génération (défaut: 0.2)
- top_p (float): Valeur top_p (défaut: 0.95)
- top_k (int): Valeur top_k (défaut: 40)
- max_tokens (int): Nombre maximal de tokens à générer (défaut: 1024)
- language (str): Langue pour les prompts ("fr" ou "en")
"""
self.model_name = model_name
self.endpoint = endpoint
# Paramètres par défaut
self.config = {
"temperature": 0.2,
"top_p": 0.95,
"top_k": 40,
"max_tokens": 1024,
"language": "fr"
}
# Mise à jour avec les paramètres fournis
self.config.update(config)
def generate(self, prompt: str, images: Optional[List[bytes]] = None) -> str:
"""
Génère une réponse à partir d'un prompt et d'images optionnelles
Args:
prompt (str): Le texte du prompt
images (List[bytes], optional): Liste d'images en bytes
Returns:
str: La réponse générée par le modèle
"""
raise NotImplementedError("Cette méthode doit être implémentée par les classes dérivées")
def get_params(self) -> Dict[str, Any]:
"""
Renvoie les paramètres actuels de l'agent
Returns:
Dict[str, Any]: Les paramètres de configuration
"""
return {
"modèle": self.model_name,
**self.config
}
def update_config(self, **kwargs):
"""
Met à jour la configuration de l'agent
Args:
**kwargs: Paramètres à mettre à jour
"""
self.config.update(kwargs)

177
agents/rewriter.py Normal file
View File

@ -0,0 +1,177 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Agent LLM pour la reformulation et l'adaptation de contenu
"""
import requests
from typing import Dict, Optional, Union
from .base import LLMBaseAgent
class RewriterAgent(LLMBaseAgent):
"""
Agent LLM spécialisé dans la reformulation et l'adaptation de texte
"""
def __init__(self, model_name: str = "mistral", **config):
"""
Initialise l'agent de reformulation
Args:
model_name (str): Nom du modèle de reformulation (défaut: mistral)
**config: Paramètres de configuration supplémentaires
"""
super().__init__(model_name, **config)
# Définir les modes de reformulation et leurs prompts
self.modes = {
"fr": {
"simplifier": "Reformule le texte suivant pour le rendre plus accessible "
"et facile à comprendre. Simplifie le vocabulaire et la structure "
"des phrases tout en préservant le sens original.\n\n"
"Texte original :\n{text}",
"détailler": "Développe et enrichis le texte suivant en ajoutant des détails, "
"des explications et des exemples pertinents. Garde le même ton "
"et le même style, mais rends le contenu plus complet.\n\n"
"Texte à développer :\n{text}",
"rag": "Reformule le texte suivant pour l'optimiser pour un système de RAG "
"(Retrieval Augmented Generation). Assure-toi qu'il contient des mots-clés "
"pertinents, qu'il est bien structuré pour la recherche sémantique, et "
"qu'il présente clairement les informations essentielles.\n\n"
"Texte à optimiser :\n{text}",
"formal": "Reformule le texte suivant dans un style plus formel et académique. "
"Utilise un vocabulaire précis, une structure de phrase soignée, et "
"un ton professionnel tout en préservant le contenu original.\n\n"
"Texte à formaliser :\n{text}",
"bullet": "Transforme le texte suivant en une liste à puces claire et concise. "
"Extrais les points essentiels et présente-les dans un format facile "
"à lire et à comprendre.\n\n"
"Texte à transformer :\n{text}"
},
"en": {
"simplify": "Rewrite the following text to make it more accessible "
"and easy to understand. Simplify vocabulary and sentence structure "
"while preserving the original meaning.\n\n"
"Original text:\n{text}",
"elaborate": "Expand and enrich the following text by adding relevant details, "
"explanations, and examples. Keep the same tone and style, "
"but make the content more comprehensive.\n\n"
"Text to expand:\n{text}",
"rag": "Rewrite the following text to optimize it for a RAG "
"(Retrieval Augmented Generation) system. Ensure it contains relevant "
"keywords, is well-structured for semantic search, and "
"clearly presents essential information.\n\n"
"Text to optimize:\n{text}",
"formal": "Rewrite the following text in a more formal and academic style. "
"Use precise vocabulary, careful sentence structure, and "
"a professional tone while preserving the original content.\n\n"
"Text to formalize:\n{text}",
"bullet": "Transform the following text into a clear and concise bullet point list. "
"Extract the essential points and present them in a format that is easy "
"to read and understand.\n\n"
"Text to transform:\n{text}"
}
}
def generate(self, text: str, mode: str = "rag", custom_prompt: Optional[str] = "") -> str:
"""
Reformule un texte selon le mode spécifié
Args:
text (str): Texte à reformuler
mode (str): Mode de reformulation (simplifier, détailler, rag, formal, bullet)
custom_prompt (str, optional): Prompt personnalisé pour la reformulation
Returns:
str: Le texte reformulé
"""
if not text:
return ""
# Déterminer la langue et le prompt à utiliser
lang = self.config.get("language", "fr")
if custom_prompt:
prompt = custom_prompt.format(text=text)
else:
# Vérifier que le mode existe pour la langue spécifiée
if lang not in self.modes or mode not in self.modes[lang]:
# Fallback sur français et mode RAG si non disponible
lang = "fr"
mode = "rag"
prompt_template = self.modes[lang][mode]
prompt = prompt_template.format(text=text)
try:
# Construire la payload pour l'API Ollama
payload = {
"model": self.model_name,
"prompt": prompt,
"options": {
"temperature": self.config.get("temperature", 0.3), # Légèrement plus créatif pour la reformulation
"top_p": self.config.get("top_p", 0.95),
"top_k": self.config.get("top_k", 40),
"num_predict": self.config.get("max_tokens", 2048)
}
}
# Dans une implémentation réelle, envoyer la requête à l'API Ollama
# response = requests.post(f"{self.endpoint}/api/generate", json=payload)
# json_response = response.json()
# return json_response.get("response", "")
# Pour cette démonstration, retourner des exemples de reformulation
if mode == "simplifier" or mode == "simplify":
return ("Ce texte a été simplifié pour être plus facile à comprendre. "
"Les mots compliqués ont été remplacés par des mots plus simples. "
"Les phrases longues ont été raccourcies. Les idées principales "
"restent les mêmes, mais elles sont expliquées plus clairement.")
elif mode == "détailler" or mode == "elaborate":
return (f"{text}\n\nEn outre, il est important de noter que ce contenu s'inscrit "
"dans un contexte plus large. Plusieurs exemples concrets illustrent ce point : "
"premièrement, l'application pratique de ces concepts dans des situations réelles; "
"deuxièmement, les différentes interprétations possibles selon le domaine d'expertise; "
"et troisièmement, les implications à long terme de ces informations. "
"Cette perspective élargie permet une compréhension plus approfondie du sujet.")
elif mode == "formal":
return ("Il convient de préciser que le contenu susmentionné présente des caractéristiques "
"particulièrement pertinentes dans le cadre de l'analyse proposée. En effet, "
"l'examen minutieux des éléments constitutifs révèle une structure cohérente "
"dont la logique sous-jacente manifeste une organisation méthodique des concepts. "
"Par conséquent, il est possible d'affirmer que les principes énoncés "
"s'inscrivent dans un paradigme rigoureux qui mérite une attention scientifique.")
elif mode == "bullet":
# Transformation en liste à puces
bullet_points = ["• Point principal: Le contenu présente des informations essentielles",
"• Structure: Organisation logique des éléments clés",
"• Application: Utilisations pratiques dans divers contextes",
"• Avantages: Amélioration de la compréhension et de l'efficacité",
"• Limitations: Considérations importantes à prendre en compte"]
return "\n".join(bullet_points)
else: # rag par défaut
return ("Ce contenu optimisé pour les systèmes RAG contient des mots-clés pertinents "
"et une structure sémantique améliorée. Les concepts principaux sont clairement "
"définis et leurs relations sont explicitement établies. Les informations "
"sont présentées de manière à faciliter la recherche et la récupération "
"automatisées, avec une organisation logique qui met en évidence les "
"éléments essentiels du sujet traité. Chaque section est conçue pour "
"maximiser la pertinence lors des requêtes d'information.")
except Exception as e:
return f"Erreur lors de la reformulation: {str(e)}"

183
agents/summary.py Normal file
View File

@ -0,0 +1,183 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Agent LLM pour la génération de résumés
"""
import requests
from typing import Dict, Optional, Union
from .base import LLMBaseAgent
class SummaryAgent(LLMBaseAgent):
"""
Agent LLM spécialisé dans la génération de résumés et d'analyses de texte
"""
def __init__(self, model_name: str = "deepseek-r1", **config):
"""
Initialise l'agent de résumé
Args:
model_name (str): Nom du modèle de résumé (défaut: deepseek-r1)
**config: Paramètres de configuration supplémentaires
"""
super().__init__(model_name, **config)
# Définir les prompts de résumé par type de contenu et langue
self.prompts = {
"fr": {
"standard": "Résume le texte suivant en capturant les points essentiels "
"de manière concise et précise. Préserve les informations clés "
"tout en réduisant la longueur.\n\n"
"Texte à résumer :\n{text}",
"analytique": "Analyse le texte suivant en identifiant les concepts clés, "
"les arguments principaux et les implications. Fournis un résumé "
"qui met en évidence les insights importants.\n\n"
"Texte à analyser :\n{text}",
"schéma": "Décris ce schéma de manière concise en expliquant sa structure, "
"son organisation et les relations entre ses éléments. Identifie "
"le message principal qu'il communique.\n\n"
"Description du schéma :\n{text}",
"tableau": "Résume les informations essentielles présentées dans ce tableau "
"en identifiant les tendances, les valeurs importantes et les "
"relations entre les données.\n\n"
"Description du tableau :\n{text}",
"formule": "Explique cette formule mathématique de manière concise et accessible, "
"en précisant son contexte, sa signification et ses applications "
"pratiques.\n\n"
"Description de la formule :\n{text}"
},
"en": {
"standard": "Summarize the following text by capturing the essential points "
"in a concise and accurate manner. Preserve key information "
"while reducing length.\n\n"
"Text to summarize:\n{text}",
"analytical": "Analyze the following text by identifying key concepts, "
"main arguments, and implications. Provide a summary "
"that highlights important insights.\n\n"
"Text to analyze:\n{text}",
"schéma": "Describe this diagram concisely by explaining its structure, "
"organization, and relationships between elements. Identify "
"the main message it communicates.\n\n"
"Diagram description:\n{text}",
"tableau": "Summarize the essential information presented in this table "
"by identifying trends, important values, and relationships "
"between the data.\n\n"
"Table description:\n{text}",
"formule": "Explain this mathematical formula concisely and accessibly, "
"specifying its context, meaning, and practical applications.\n\n"
"Formula description:\n{text}"
}
}
def generate(self, text: str, summary_type: str = "standard", selection_type: Optional[str] = "") -> str:
"""
Génère un résumé ou une analyse du texte fourni
Args:
text (str): Texte à résumer ou analyser
summary_type (str): Type de résumé ("standard" ou "analytique")
selection_type (str, optional): Type de contenu ("schéma", "tableau", "formule")
Si fourni, remplace summary_type
Returns:
str: Le résumé ou l'analyse générée par le modèle
"""
if not text:
return ""
# Déterminer le type de prompt à utiliser
lang = self.config.get("language", "fr")
# Utiliser selection_type seulement s'il est non vide et existe dans les prompts
if selection_type and selection_type in self.prompts.get(lang, {}):
prompt_type = selection_type
else:
prompt_type = summary_type
# Obtenir le prompt approprié
if lang not in self.prompts or prompt_type not in self.prompts[lang]:
lang = "fr" # Langue par défaut
prompt_type = "standard" # Type par défaut
prompt_template = self.prompts[lang][prompt_type]
prompt = prompt_template.format(text=text)
try:
# Construire la payload pour l'API Ollama
payload = {
"model": self.model_name,
"prompt": prompt,
"options": {
"temperature": self.config.get("temperature", 0.2),
"top_p": self.config.get("top_p", 0.95),
"top_k": self.config.get("top_k", 40),
"num_predict": self.config.get("max_tokens", 1024)
}
}
# Dans une implémentation réelle, envoyer la requête à l'API Ollama
# response = requests.post(f"{self.endpoint}/api/generate", json=payload)
# json_response = response.json()
# return json_response.get("response", "")
# Pour cette démonstration, retourner un résumé simulé
length = len(text.split())
if prompt_type == "schéma":
return ("Ce schéma illustre une structure organisée présentant les relations "
"entre différents éléments clés. Il met en évidence la hiérarchie et "
"le flux d'information, permettant de comprendre rapidement le processus "
"ou le système représenté. Les composants principaux sont clairement "
"identifiés et leurs connections logiques sont bien établies.")
elif prompt_type == "tableau":
return ("Ce tableau présente des données structurées qui révèlent des tendances "
"significatives. Les valeurs clés montrent une corrélation entre les "
"différentes variables présentées. L'organisation des données permet "
"d'identifier rapidement les informations importantes et de comprendre "
"les relations entre elles. Les catégories principales sont clairement "
"distinguées.")
elif prompt_type == "formule":
return ("Cette formule mathématique exprime une relation fondamentale entre "
"plusieurs variables. Elle permet de calculer précisément les valeurs "
"recherchées en fonction des paramètres d'entrée. Son application "
"pratique concerne principalement la modélisation du phénomène décrit "
"dans le contexte. La structure de l'équation révèle la nature des "
"interactions entre les différentes composantes du système.")
elif prompt_type == "analytique":
return ("L'analyse approfondie du texte révèle plusieurs concepts clés interconnectés. "
"Les arguments principaux s'articulent autour d'une thèse centrale qui est "
"étayée par des preuves pertinentes. Les implications de ces idées sont "
"significatives pour le domaine concerné. Cette analyse met en lumière "
"les points essentiels tout en préservant la profondeur intellectuelle "
"du contenu original.")
else: # standard
# Génération d'un résumé approximativement 70% plus court
words = text.split()
summary_length = max(3, int(length * 0.3)) # Au moins 3 mots
if length < 20:
return text # Texte déjà court
return ("Ce texte présente de manière concise les informations essentielles "
"du contenu original. Les points clés sont préservés tout en éliminant "
"les détails superflus. La synthèse capture l'essence du message "
"en mettant l'accent sur les éléments les plus pertinents pour "
"la compréhension globale du sujet traité.")
except Exception as e:
return f"Erreur lors de la génération du résumé: {str(e)}"

141
agents/translation.py Normal file
View File

@ -0,0 +1,141 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Agent LLM pour la traduction de contenu
"""
import json
import requests
from typing import Optional, Dict
from .base import LLMBaseAgent
class TranslationAgent(LLMBaseAgent):
"""
Agent LLM spécialisé dans la traduction de texte
"""
def __init__(self, model_name: str = "mistral", **config):
"""
Initialise l'agent de traduction
Args:
model_name (str): Nom du modèle de traduction (défaut: mistral)
**config: Paramètres de configuration supplémentaires
"""
super().__init__(model_name, **config)
# Définir les prompts de traduction
self.prompts = {
"fr_to_en": "Traduis le texte suivant du français vers l'anglais. "
"Préserve le formatage, le ton et le style du texte original. "
"Assure-toi que la traduction est fluide et naturelle.\n\n"
"Texte français :\n{text}",
"en_to_fr": "Traduis le texte suivant de l'anglais vers le français. "
"Préserve le formatage, le ton et le style du texte original. "
"Assure-toi que la traduction est fluide et naturelle.\n\n"
"Texte anglais :\n{text}"
}
def generate(self, text: str, source_lang: str = "fr", target_lang: str = "en") -> str:
"""
Traduit un texte d'une langue source vers une langue cible
Args:
text (str): Texte à traduire
source_lang (str): Langue source (fr ou en)
target_lang (str): Langue cible (fr ou en)
Returns:
str: La traduction générée par le modèle
"""
if not text:
return ""
# Déterminer la direction de traduction
if source_lang == "fr" and target_lang == "en":
direction = "fr_to_en"
elif source_lang == "en" and target_lang == "fr":
direction = "en_to_fr"
else:
return f"Traduction non prise en charge: {source_lang} vers {target_lang}"
# Construire le prompt
prompt = self.prompts[direction].format(text=text)
try:
# Construire la payload pour l'API Ollama
payload = {
"model": self.model_name,
"prompt": prompt,
"options": {
"temperature": self.config.get("temperature", 0.1), # Basse pour traduction précise
"top_p": self.config.get("top_p", 0.95),
"top_k": self.config.get("top_k", 40),
"num_predict": self.config.get("max_tokens", 2048)
}
}
# Dans une implémentation réelle, envoyer la requête à l'API Ollama
# response = requests.post(f"{self.endpoint}/api/generate", json=payload)
# json_response = response.json()
# return json_response.get("response", "")
# Pour cette démonstration, retourner une traduction simulée
if direction == "fr_to_en":
if "schéma" in text.lower():
return text.replace("schéma", "diagram").replace("Schéma", "Diagram")
elif "tableau" in text.lower():
return text.replace("tableau", "table").replace("Tableau", "Table")
elif "formule" in text.lower():
return text.replace("formule", "formula").replace("Formule", "Formula")
else:
# Exemple très simplifié de "traduction"
translations = {
"Le": "The", "la": "the", "les": "the", "des": "the",
"et": "and", "ou": "or", "pour": "for", "avec": "with",
"est": "is", "sont": "are", "contient": "contains",
"montre": "shows", "représente": "represents",
"plusieurs": "several", "important": "important",
"information": "information", "données": "data",
"processus": "process", "système": "system",
"analyse": "analysis", "résultat": "result"
}
# Remplacement simple mot à mot
result = text
for fr, en in translations.items():
result = result.replace(f" {fr} ", f" {en} ")
return result
else: # en_to_fr
if "diagram" in text.lower():
return text.replace("diagram", "schéma").replace("Diagram", "Schéma")
elif "table" in text.lower():
return text.replace("table", "tableau").replace("Table", "Tableau")
elif "formula" in text.lower():
return text.replace("formula", "formule").replace("Formula", "Formule")
else:
# Exemple très simplifié de "traduction"
translations = {
"The": "Le", "the": "le", "and": "et", "or": "ou",
"for": "pour", "with": "avec", "is": "est",
"are": "sont", "contains": "contient", "shows": "montre",
"represents": "représente", "several": "plusieurs",
"important": "important", "information": "information",
"data": "données", "process": "processus",
"system": "système", "analysis": "analyse",
"result": "résultat"
}
# Remplacement simple mot à mot
result = text
for en, fr in translations.items():
result = result.replace(f" {en} ", f" {fr} ")
return result
except Exception as e:
return f"Erreur lors de la traduction: {str(e)}"

146
agents/vision.py Normal file
View File

@ -0,0 +1,146 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Agent LLM pour l'analyse visuelle d'images
"""
import base64
import json
import requests
from typing import List, Optional, Union
from .base import LLMBaseAgent
class VisionAgent(LLMBaseAgent):
"""
Agent LLM spécialisé dans l'analyse d'images
"""
def __init__(self, model_name: str = "llama3.2-vision:90b", **config):
"""
Initialise l'agent de vision
Args:
model_name (str): Nom du modèle de vision (défaut: llama3.2-vision:90b)
**config: Paramètres de configuration supplémentaires
"""
super().__init__(model_name, **config)
# Définir les prompts par défaut selon la langue
self.prompts = {
"fr": {
"schéma": "Décris en détail ce schéma. Quelle information principale est-elle représentée ? "
"Comment les éléments sont-ils organisés ? Quelle est la signification des différentes parties ?",
"tableau": "Analyse ce tableau. Quel type de données contient-il ? "
"Quelles sont les informations importantes ? Résume son contenu et sa structure.",
"formule": "Analyse cette formule ou équation mathématique. "
"Que représente-t-elle ? Quelle est sa signification et son application ?",
"texte": "Lis et résume ce texte. Quel est le contenu principal ? "
"Quels sont les points importants à retenir ?",
"autre": "Décris en détail ce que tu vois sur cette image. "
"Quel est le contenu principal ? Quelle information est présentée ?"
},
"en": {
"schéma": "Describe this diagram in detail. What main information is represented? "
"How are the elements organized? What is the meaning of the different parts?",
"tableau": "Analyze this table. What kind of data does it contain? "
"What is the important information? Summarize its content and structure.",
"formule": "Analyze this mathematical formula or equation. "
"What does it represent? What is its meaning and application?",
"texte": "Read and summarize this text. What is the main content? "
"What are the important points to remember?",
"autre": "Describe in detail what you see in this image. "
"What is the main content? What information is presented?"
}
}
def generate(self, prompt: Optional[str] = "", images: Optional[List[bytes]] = None,
selection_type: str = "autre", context: Optional[str] = "") -> str:
"""
Génère une description ou une analyse d'une image
Args:
prompt (str, optional): Prompt personnalisé (si vide, utilise le prompt par défaut)
images (List[bytes], optional): Liste d'images en bytes
selection_type (str): Type de contenu ("schéma", "tableau", "formule", "texte", "autre")
context (str, optional): Contexte textuel associé à la sélection
Returns:
str: La réponse générée par le modèle
"""
if not images:
return "Aucune image fournie pour l'analyse."
# Utiliser le prompt par défaut si aucun n'est fourni
if not prompt:
lang = self.config.get("language", "fr")
prompts_dict = self.prompts.get(lang, self.prompts["fr"])
prompt = prompts_dict.get(selection_type, prompts_dict["autre"])
# Ajouter le contexte si disponible
if context:
prompt = f"Contexte: {context}\n\n{prompt}"
try:
# Pour chaque image, encoder en base64
base64_images = []
for image in images:
if isinstance(image, bytes):
base64_image = base64.b64encode(image).decode("utf-8")
base64_images.append(base64_image)
# Construire la payload pour l'API Ollama
payload = {
"model": self.model_name,
"prompt": prompt,
"images": base64_images,
"options": {
"temperature": self.config.get("temperature", 0.2),
"top_p": self.config.get("top_p", 0.95),
"top_k": self.config.get("top_k", 40),
"num_predict": self.config.get("max_tokens", 1024)
}
}
# Dans une implémentation réelle, envoyer la requête à l'API Ollama
# response = requests.post(f"{self.endpoint}/api/generate", json=payload)
# json_response = response.json()
# return json_response.get("response", "")
# Pour cette démonstration, retourner une réponse simulée
if selection_type == "schéma":
return "Le schéma présenté illustre un processus structuré avec plusieurs étapes interconnectées. " \
"On peut observer une organisation hiérarchique des éléments, avec des flèches indiquant " \
"le flux d'information ou la séquence d'opérations. Les différentes composantes sont " \
"clairement délimitées et semblent représenter un workflow ou un système de classification."
elif selection_type == "tableau":
return "Ce tableau contient plusieurs colonnes et rangées de données structurées. " \
"Il présente une organisation systématique d'informations, probablement des " \
"valeurs numériques ou des catégories. Les en-têtes indiquent le type de données " \
"dans chaque colonne, et l'ensemble forme une matrice cohérente d'informations liées."
elif selection_type == "formule":
return "Cette formule mathématique représente une relation complexe entre plusieurs variables. " \
"Elle utilise divers opérateurs et symboles mathématiques pour exprimer un concept ou " \
"une règle. La structure suggère qu'il s'agit d'une équation importante dans son domaine, " \
"possiblement liée à un phénomène physique ou à un modèle théorique."
else:
return "L'image montre un contenu visuel structuré qui présente des informations importantes " \
"dans le contexte du document. Les éléments visuels sont organisés de manière à faciliter " \
"la compréhension d'un concept ou d'un processus spécifique. La qualité et la disposition " \
"des éléments suggèrent qu'il s'agit d'une représentation professionnelle destinée à " \
"communiquer efficacement l'information."
except Exception as e:
return f"Erreur lors de l'analyse de l'image: {str(e)}"

104
config/llm_profiles.json Normal file
View File

@ -0,0 +1,104 @@
{
"léger": {
"vision": {
"model": "llava:34b",
"language": "en",
"temperature": 0.2,
"top_p": 0.95,
"top_k": 40,
"max_tokens": 1024
},
"translation": {
"model": "mistral",
"language": "fr",
"temperature": 0.1,
"top_p": 0.95,
"top_k": 40,
"max_tokens": 1024
},
"summary": {
"model": "mistral",
"language": "fr",
"temperature": 0.2,
"top_p": 0.95,
"top_k": 40,
"max_tokens": 1024
},
"rewriter": {
"model": "mistral",
"language": "fr",
"temperature": 0.3,
"top_p": 0.95,
"top_k": 40,
"max_tokens": 1024
}
},
"moyen": {
"vision": {
"model": "llava",
"language": "en",
"temperature": 0.2,
"top_p": 0.95,
"top_k": 40,
"max_tokens": 1024
},
"translation": {
"model": "qwen2.5",
"language": "fr",
"temperature": 0.1,
"top_p": 0.95,
"top_k": 40,
"max_tokens": 1024
},
"summary": {
"model": "deepseek-r1",
"language": "fr",
"temperature": 0.2,
"top_p": 0.95,
"top_k": 40,
"max_tokens": 1024
},
"rewriter": {
"model": "mistral",
"language": "fr",
"temperature": 0.3,
"top_p": 0.95,
"top_k": 40,
"max_tokens": 1024
}
},
"avancé": {
"vision": {
"model": "llama3.2-vision",
"language": "en",
"temperature": 0.2,
"top_p": 0.95,
"top_k": 40,
"max_tokens": 2048
},
"translation": {
"model": "deepseek",
"language": "fr",
"temperature": 0.1,
"top_p": 0.95,
"top_k": 40,
"max_tokens": 2048
},
"summary": {
"model": "deepseek-r1",
"language": "fr",
"temperature": 0.2,
"top_p": 0.95,
"top_k": 40,
"max_tokens": 2048
},
"rewriter": {
"model": "deepseek",
"language": "fr",
"temperature": 0.3,
"top_p": 0.95,
"top_k": 40,
"max_tokens": 2048
}
}
}

View File

@ -0,0 +1,167 @@
# Prompt complet pour Cursor Pro Programme de prétraitement PDF avec agents LLM modulables
Développe un programme Python avec interface graphique (Tkinter ou PyQt6) permettant de :
1. Charger un document PDF
2. Naviguer entre les pages
3. Sélectionner une ou plusieurs zones rectangulaires (ex : schéma, tableau, formule)
4. Associer un contexte textuel (automatique via OCR ou manuel)
5. Traduire, interpréter ou résumer le contenu sélectionné en utilisant différents agents LLM configurables
6. Exporter le résultat enrichi au format **Markdown (.md)** prêt à être utilisé dans une base Ragflow
---
## Structure du projet recommandée
```
ragflow_pretraitement/
├── main.py
├── ui/
│ ├── viewer.py # Navigation et sélection dans le PDF
│ └── llm_config_panel.py # Choix des agents, paramètres
├── agents/
│ ├── base.py # Classe LLMBaseAgent
│ ├── vision.py # VisionAgent
│ ├── translation.py # TranslationAgent
│ ├── summary.py # SummaryAgent
│ └── rewriter.py # RewriterAgent
├── utils/
│ ├── ocr.py
│ ├── translate.py
│ ├── markdown_export.py
│ └── api_ollama.py
├── config/
│ └── llm_profiles.json # Profils LLM préconfigurés
├── data/
│ └── outputs/ # .md et images générées
└── docs/
└── prompts/
```
---
## Fonctionnalités de linterface
- Navigation page par page dans le PDF
- Zoom/dézoom + recentrage
- Sélection dune zone avec la souris
- Attribution dun **type** : schéma, tableau, formule, autre
- Zone de texte modifiable pour le contexte
- Menu de sélection :
- Modèle par tâche (vision, résumé, traduction…)
- Langue de prompt (FR / EN)
- Paramètres (température, top_p, top_k, num_tokens)
- Bouton : "Appliquer agent"
- Aperçu live de la sortie
- Export Markdown (`document_final.md`)
---
## Agents LLM
### Classe `LLMBaseAgent`
Chaque agent hérite de cette base, et peut :
- Sappeler avec un prompt
- Prendre une image (pour vision)
- Gérer les paramètres dynamiquement
```python
class LLMBaseAgent:
def __init__(self, model_name, endpoint, **config):
...
def generate(self, prompt: str, images: List[bytes] = None) -> str:
...
```
### Rôles recommandés
| Rôle | Classe | Modèle par défaut | Langue |
| ------------- | ------------------ | ------------------- | --------- |
| Vision | `VisionAgent` | llama3.2-vision:90b | 🇬🇧 |
| Traduction | `TranslationAgent` | mistral / qwen2.5 | 🇫🇷↔🇬🇧 |
| Résumé | `SummaryAgent` | deepseek-r1 | 🇫🇷 |
| Reformulation | `RewriterAgent` | mistral / deepseek | 🇫🇷 |
---
## Profils et configuration dynamique
### Fichier `config/llm_profiles.json`
Permet de charger automatiquement un ensemble dagents, de modèles et de paramètres selon les besoins.
```json
{
"avancé": {
"vision": {
"model": "llama3.2-vision:90b-instruct-q8_0",
"language": "en",
"temperature": 0.2,
"top_p": 0.95
},
"translation": {
"model": "mistral",
"language": "fr",
"temperature": 0.1
}
}
}
```
---
## Format Markdown exporté
```md
## Figure 3 Classification des granulats
**Contexte** :
Ce schéma montre...
**Analyse IA (Vision)** :
The diagram shows...
**Paramètres utilisés** :
modèle=llama3.2-vision, temperature=0.2, langue=en
**Source** :
Page 12, Type: Schéma, Fichier: NF P11-300
```
---
## Étapes de test recommandées
1. Charger un PDF
2. Sélectionner une zone
3. Associer un rôle + agent
4. Lancer lanalyse (vision, résumé, etc.)
5. Vérifier la sortie
6. Exporter le fichier Markdown
---
## Modes d'analyse et niveaux de charge LLM
L'utilisateur peut choisir entre **trois niveaux d'analyse** dans l'interface :
| Niveau | Vision | Résumé / Reformulation | Traduction | Usage recommandé |
| --------- | ----------------- | ---------------------- | -------------------- | ------------------------------- |
| 🔹 Léger | `llava:34b` | `mistral` | `mistral` | Débogage, prototypes rapides |
| ⚪ Moyen | `llava` | `deepseek-r1` | `qwen2.5`, `mistral` | Usage normal |
| 🔸 Avancé | `llama3.2-vision` | `deepseek-r1` | `deepseek` | Documents critiques, production |
### Interface
- Menu déroulant "Mode danalyse" :
- Léger / Moyen / Avancé
- Ce mode détermine les **modèles, la langue, et les paramètres par défaut**
- L'utilisateur peut ensuite **modifier manuellement** chaque agent (surcharger les préréglages)
### Exemple de workflow
1. L'utilisateur sélectionne "Avancé"
2. Tous les agents se préconfigurent avec les modèles lourds
3. Pour la traduction uniquement, il choisit manuellement `mistral` car il veut aller plus vite
4. Le fichier `llm_custom.json` conserve ces réglages personnalisés
Ce système permet d'adapter la **charge GPU** et la **vitesse de traitement** au besoin sans perdre en contrôle.

View File

@ -0,0 +1,49 @@
import os
import json
from datetime import datetime
# === Config ===
CURSOR_CHAT_DIR = os.path.expanduser("~/.cursor/chat/")
OUTPUT_FILE = "cursor_history.md"
# === Initialisation du contenu ===
md_output = ""
# === Chargement des discussions Cursor ===
for filename in sorted(os.listdir(CURSOR_CHAT_DIR)):
if not filename.endswith(".json"):
continue
filepath = os.path.join(CURSOR_CHAT_DIR, filename)
with open(filepath, "r", encoding="utf-8") as f:
try:
chat_data = json.load(f)
except json.JSONDecodeError:
continue # Fichier corrompu ou non lisible
created_at_raw = chat_data.get("createdAt", "")
try:
created_at = datetime.fromisoformat(created_at_raw.replace("Z", ""))
except ValueError:
created_at = datetime.now()
formatted_time = created_at.strftime("%Y-%m-%d %H:%M:%S")
md_output += f"\n---\n\n## Session du {formatted_time}\n\n"
for msg in chat_data.get("messages", []):
role = msg.get("role", "")
content = msg.get("content", "").strip()
if not content:
continue
if role == "user":
md_output += f"** Utilisateur :**\n{content}\n\n"
elif role == "assistant":
md_output += f"** Assistant :**\n{content}\n\n"
# === Écriture / ajout dans le fichier final ===
with open(OUTPUT_FILE, "a", encoding="utf-8") as output_file:
output_file.write(md_output)
print(f" Export terminé ! Discussions ajoutées à : {OUTPUT_FILE}")

26
main.py Normal file
View File

@ -0,0 +1,26 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Programme de prétraitement PDF avec agents LLM modulables
"""
import sys
import os
from PyQt6.QtWidgets import QApplication
from ui.viewer import PDFViewer
def main():
"""Point d'entrée principal de l'application"""
app = QApplication(sys.argv)
app.setApplicationName("Prétraitement PDF pour Ragflow")
# Création de la fenêtre principale
main_window = PDFViewer()
main_window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

0
ragproc/lib64 Normal file
View File

7
requirements.txt Normal file
View File

@ -0,0 +1,7 @@
PyQt6>=6.4.0
PyMuPDF>=1.21.0
numpy>=1.22.0
pytesseract>=0.3.9
Pillow>=9.3.0
opencv-python>=4.7.0
requests>=2.28.0

32
setup.py Normal file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from setuptools import setup, find_packages
with open("README.md", "r", encoding="utf-8") as fh:
long_description = fh.read()
with open("requirements.txt", "r", encoding="utf-8") as f:
requirements = f.read().splitlines()
setup(
name="ragflow_pretraitement",
version="1.0.0",
author="Ragflow Team",
description="Outil de prétraitement PDF avec agents LLM modulables pour Ragflow",
long_description=long_description,
long_description_content_type="text/markdown",
packages=find_packages(),
install_requires=requirements,
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
],
python_requires=">=3.8",
entry_points={
"console_scripts": [
"ragflow-pretraitement=ragflow_pretraitement.main:main",
],
},
)

8
ui/__init__.py Normal file
View File

@ -0,0 +1,8 @@
# Module UI
from .viewer import PDFViewer
from .llm_config_panel import LLMConfigPanel
__all__ = [
'PDFViewer',
'LLMConfigPanel'
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

431
ui/llm_config_panel.py Normal file
View File

@ -0,0 +1,431 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Panneau de configuration des agents LLM
"""
import os
import json
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QComboBox, QTextEdit, QGroupBox,
QListWidget, QSplitter, QTabWidget, QSpinBox,
QDoubleSpinBox, QFormLayout, QMessageBox, QCheckBox)
from PyQt6.QtCore import Qt, QSize
class LLMConfigPanel(QWidget):
"""Panneau de configuration des agents LLM et de gestion des sélections"""
def __init__(self, parent):
super().__init__(parent)
self.parent = parent
self.current_selection = None
self.analysis_results = {}
self.init_ui()
self.load_llm_profiles()
def init_ui(self):
"""Initialise l'interface utilisateur du panneau"""
main_layout = QVBoxLayout(self)
# Tabs pour organiser les fonctionnalités
tabs = QTabWidget()
main_layout.addWidget(tabs)
# 1. Onglet Sélections
selections_tab = QWidget()
tab_layout = QVBoxLayout(selections_tab)
# Liste des sélections
selection_group = QGroupBox("Régions sélectionnées")
selection_layout = QVBoxLayout(selection_group)
self.selection_list = QListWidget()
self.selection_list.setMinimumHeight(100)
self.selection_list.currentRowChanged.connect(self.selection_changed)
selection_layout.addWidget(self.selection_list)
# Boutons pour la gestion des sélections
selection_btns = QHBoxLayout()
self.remove_btn = QPushButton("Supprimer")
self.remove_btn.clicked.connect(self.remove_selection)
selection_btns.addWidget(self.remove_btn)
selection_layout.addLayout(selection_btns)
tab_layout.addWidget(selection_group)
# Type de la sélection
type_group = QGroupBox("Type de contenu")
type_layout = QVBoxLayout(type_group)
self.type_combo = QComboBox()
self.type_combo.addItems(["schéma", "tableau", "formule", "texte", "autre"])
self.type_combo.currentTextChanged.connect(self.update_selection_type)
type_layout.addWidget(self.type_combo)
tab_layout.addWidget(type_group)
# Contexte textuel
context_group = QGroupBox("Contexte textuel")
context_layout = QVBoxLayout(context_group)
self.context_edit = QTextEdit()
self.context_edit.setPlaceholderText("Ajoutez ici le contexte pour cette sélection...")
self.context_edit.textChanged.connect(self.update_selection_context)
context_layout.addWidget(self.context_edit)
tab_layout.addWidget(context_group)
# 2. Onglet Agents LLM
agent_tab = QWidget()
agent_layout = QVBoxLayout(agent_tab)
# Mode d'analyse
mode_group = QGroupBox("Mode d'analyse")
mode_layout = QHBoxLayout(mode_group)
mode_label = QLabel("Niveau :")
mode_layout.addWidget(mode_label)
self.mode_combo = QComboBox()
self.mode_combo.addItems(["🔹 Léger", "⚪ Moyen", "🔸 Avancé"])
self.mode_combo.currentTextChanged.connect(self.update_mode)
mode_layout.addWidget(self.mode_combo)
agent_layout.addWidget(mode_group)
# Sélection des agents
agent_config_group = QGroupBox("Configuration des agents")
agent_config_layout = QVBoxLayout(agent_config_group)
# Sélection du type d'agent
agent_type_layout = QFormLayout()
# Agent Vision
agent_type_layout.addRow(QLabel("<b>Agent Vision</b>"))
self.vision_model_combo = QComboBox()
self.vision_model_combo.addItems(["llava:34b", "llava", "llama3.2-vision"])
agent_type_layout.addRow("Modèle:", self.vision_model_combo)
self.vision_lang_combo = QComboBox()
self.vision_lang_combo.addItems(["fr", "en"])
agent_type_layout.addRow("Langue:", self.vision_lang_combo)
# Agent de résumé/reformulation
agent_type_layout.addRow(QLabel("<b>Agent Résumé</b>"))
self.summary_model_combo = QComboBox()
self.summary_model_combo.addItems(["mistral", "deepseek-r1"])
agent_type_layout.addRow("Modèle:", self.summary_model_combo)
# Agent de traduction
agent_type_layout.addRow(QLabel("<b>Agent Traduction</b>"))
self.translation_model_combo = QComboBox()
self.translation_model_combo.addItems(["mistral", "qwen2.5", "deepseek"])
agent_type_layout.addRow("Modèle:", self.translation_model_combo)
agent_config_layout.addLayout(agent_type_layout)
# Paramètres communs
params_layout = QFormLayout()
params_layout.addRow(QLabel("<b>Paramètres de génération</b>"))
self.temp_spin = QDoubleSpinBox()
self.temp_spin.setRange(0.1, 1.0)
self.temp_spin.setSingleStep(0.1)
self.temp_spin.setValue(0.2)
params_layout.addRow("Température:", self.temp_spin)
self.top_p_spin = QDoubleSpinBox()
self.top_p_spin.setRange(0.1, 1.0)
self.top_p_spin.setSingleStep(0.05)
self.top_p_spin.setValue(0.95)
params_layout.addRow("Top-p:", self.top_p_spin)
self.token_spin = QSpinBox()
self.token_spin.setRange(100, 4000)
self.token_spin.setSingleStep(100)
self.token_spin.setValue(1024)
params_layout.addRow("Max tokens:", self.token_spin)
agent_config_layout.addLayout(params_layout)
agent_layout.addWidget(agent_config_group)
# Boutons d'action
action_layout = QHBoxLayout()
self.run_btn = QPushButton("▶ Appliquer l'agent")
self.run_btn.setMinimumHeight(40)
self.run_btn.clicked.connect(self.run_agent)
action_layout.addWidget(self.run_btn)
agent_layout.addLayout(action_layout)
# Aperçu des résultats
result_group = QGroupBox("Résultat")
result_layout = QVBoxLayout(result_group)
self.result_text = QTextEdit()
self.result_text.setReadOnly(True)
self.result_text.setMinimumHeight(100)
result_layout.addWidget(self.result_text)
agent_layout.addWidget(result_group)
# 3. Onglet Export
export_tab = QWidget()
export_layout = QVBoxLayout(export_tab)
export_group = QGroupBox("Options d'export")
export_group_layout = QVBoxLayout(export_group)
export_format_layout = QHBoxLayout()
export_format_layout.addWidget(QLabel("Format:"))
self.export_format_combo = QComboBox()
self.export_format_combo.addItems(["Markdown (.md)"])
export_format_layout.addWidget(self.export_format_combo)
export_group_layout.addLayout(export_format_layout)
self.include_images_check = QCheckBox("Inclure les images")
self.include_images_check.setChecked(True)
export_group_layout.addWidget(self.include_images_check)
export_layout.addWidget(export_group)
export_btn = QPushButton("Exporter")
export_btn.setMinimumHeight(40)
export_btn.clicked.connect(self.export_results)
export_layout.addWidget(export_btn)
export_layout.addStretch()
# Ajout des onglets
tabs.addTab(selections_tab, "Sélections")
tabs.addTab(agent_tab, "Agents LLM")
tabs.addTab(export_tab, "Export")
def load_llm_profiles(self):
"""Charge les profils LLM depuis le fichier de configuration"""
try:
config_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config")
profile_path = os.path.join(config_dir, "llm_profiles.json")
if os.path.exists(profile_path):
with open(profile_path, "r", encoding="utf-8") as f:
self.profiles = json.load(f)
else:
# Profil par défaut si le fichier n'existe pas
self.profiles = {
"léger": {
"vision": {"model": "llava:34b", "language": "en", "temperature": 0.2},
"translation": {"model": "mistral", "language": "fr", "temperature": 0.1},
"summary": {"model": "mistral", "language": "fr", "temperature": 0.2}
},
"moyen": {
"vision": {"model": "llava", "language": "en", "temperature": 0.2},
"translation": {"model": "qwen2.5", "language": "fr", "temperature": 0.1},
"summary": {"model": "deepseek-r1", "language": "fr", "temperature": 0.2}
},
"avancé": {
"vision": {"model": "llama3.2-vision", "language": "en", "temperature": 0.2},
"translation": {"model": "deepseek", "language": "fr", "temperature": 0.1},
"summary": {"model": "deepseek-r1", "language": "fr", "temperature": 0.2}
}
}
# Créer le répertoire de configuration s'il n'existe pas
os.makedirs(config_dir, exist_ok=True)
# Sauvegarder le profil par défaut
with open(profile_path, "w", encoding="utf-8") as f:
json.dump(self.profiles, f, indent=2, ensure_ascii=False)
except Exception as e:
print(f"Erreur lors du chargement des profils: {str(e)}")
def update_selection_list(self):
"""Met à jour la liste des sélections"""
self.selection_list.clear()
for i, selection in enumerate(self.parent.selected_regions):
page = selection["page"] + 1
typ = selection["type"]
self.selection_list.addItem(f"Page {page} - {typ}")
def selection_changed(self, index):
"""Appelé lorsque la sélection change dans la liste"""
if index >= 0 and index < len(self.parent.selected_regions):
selection = self.parent.selected_regions[index]
self.current_selection = selection
# Mettre à jour l'interface
self.type_combo.setCurrentText(selection["type"])
self.context_edit.setText(selection["context"])
# Afficher les résultats si disponibles
if id(selection) in self.analysis_results:
self.result_text.setText(self.analysis_results[id(selection)])
else:
self.result_text.clear()
def update_selection_type(self, new_type):
"""Met à jour le type de la sélection actuelle"""
if self.current_selection:
self.current_selection["type"] = new_type
self.update_selection_list()
def update_selection_context(self):
"""Met à jour le contexte de la sélection actuelle"""
if self.current_selection:
self.current_selection["context"] = self.context_edit.toPlainText()
def remove_selection(self):
"""Supprime la sélection actuelle"""
if self.current_selection:
idx = self.parent.selected_regions.index(self.current_selection)
self.parent.selected_regions.remove(self.current_selection)
# Supprimer les résultats associés
if id(self.current_selection) in self.analysis_results:
del self.analysis_results[id(self.current_selection)]
self.current_selection = None
self.update_selection_list()
# Sélectionner le prochain élément si disponible
if self.selection_list.count() > 0:
new_idx = min(idx, self.selection_list.count() - 1)
self.selection_list.setCurrentRow(new_idx)
else:
self.context_edit.clear()
self.result_text.clear()
def update_mode(self, mode_text):
"""Met à jour le mode d'analyse en fonction du niveau sélectionné"""
if "léger" in mode_text.lower():
profile = self.profiles.get("léger", {})
elif "moyen" in mode_text.lower():
profile = self.profiles.get("moyen", {})
elif "avancé" in mode_text.lower():
profile = self.profiles.get("avancé", {})
else:
return
# Mise à jour des combobox avec les paramètres du profil
if "vision" in profile:
vision = profile["vision"]
if "model" in vision and vision["model"] in [self.vision_model_combo.itemText(i) for i in range(self.vision_model_combo.count())]:
self.vision_model_combo.setCurrentText(vision["model"])
if "language" in vision:
self.vision_lang_combo.setCurrentText(vision["language"])
if "temperature" in vision:
self.temp_spin.setValue(vision["temperature"])
if "summary" in profile:
summary = profile["summary"]
if "model" in summary and summary["model"] in [self.summary_model_combo.itemText(i) for i in range(self.summary_model_combo.count())]:
self.summary_model_combo.setCurrentText(summary["model"])
if "translation" in profile:
translation = profile["translation"]
if "model" in translation and translation["model"] in [self.translation_model_combo.itemText(i) for i in range(self.translation_model_combo.count())]:
self.translation_model_combo.setCurrentText(translation["model"])
def run_agent(self):
"""Execute l'agent LLM sur la sélection actuelle"""
if not self.current_selection:
QMessageBox.warning(self, "Aucune sélection",
"Veuillez sélectionner une région à analyser.")
return
# Pour cette démonstration, nous simulons le résultat
# Dans une implémentation réelle, nous utiliserions les agents LLM
# Récupérer les paramètres
vision_model = self.vision_model_combo.currentText()
summary_model = self.summary_model_combo.currentText()
translation_model = self.translation_model_combo.currentText()
lang = self.vision_lang_combo.currentText()
temp = self.temp_spin.value()
# Exemple de résultat simulé
result = f"**Analyse de l'agent Vision ({vision_model})**\n\n"
if self.current_selection["type"] == "schéma":
result += "Le schéma illustre un processus en plusieurs étapes avec des connections entre les différents éléments.\n\n"
elif self.current_selection["type"] == "tableau":
result += "Le tableau contient des données structurées avec plusieurs colonnes et rangées.\n\n"
elif self.current_selection["type"] == "formule":
result += "La formule mathématique représente une équation complexe.\n\n"
else:
result += "Le contenu sélectionné a été analysé.\n\n"
result += f"**Résumé ({summary_model})**\n\n"
result += "Ce contenu montre l'importance des éléments sélectionnés dans le contexte du document.\n\n"
if lang == "en":
result += f"**Traduction ({translation_model})**\n\n"
result += "The selected content has been analyzed and shows the importance of the selected elements in the context of the document.\n\n"
result += "**Paramètres utilisés**\n"
result += f"modèle vision={vision_model}, modèle résumé={summary_model}, "
result += f"temperature={temp}, langue={lang}"
# Stocker et afficher le résultat
self.analysis_results[id(self.current_selection)] = result
self.result_text.setText(result)
# Mise à jour du statut
self.parent.status_bar.showMessage("Analyse terminée.")
def export_results(self):
"""Exporte les résultats au format Markdown"""
if not self.parent.selected_regions:
QMessageBox.warning(self, "Aucune sélection",
"Il n'y a aucune sélection à exporter.")
return
try:
# Créer le répertoire de sortie s'il n'existe pas
output_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "outputs")
os.makedirs(output_dir, exist_ok=True)
# Générer le contenu Markdown
md_content = "# Analyse de document\n\n"
# Trier les sélections par numéro de page
sorted_selections = sorted(self.parent.selected_regions, key=lambda x: x["page"])
for selection in sorted_selections:
page = selection["page"] + 1
typ = selection["type"]
context = selection["context"]
md_content += f"## {typ.capitalize()} - Page {page}\n\n"
if context:
md_content += f"**Contexte** :\n{context}\n\n"
# Ajouter les résultats d'analyse si disponibles
if id(selection) in self.analysis_results:
md_content += f"**Analyse IA** :\n{self.analysis_results[id(selection)]}\n\n"
md_content += "---\n\n"
# Écrire le fichier Markdown
file_path = os.path.join(output_dir, "analyse_document.md")
with open(file_path, "w", encoding="utf-8") as f:
f.write(md_content)
QMessageBox.information(self, "Export réussi",
f"Le fichier a été exporté avec succès :\n{file_path}")
except Exception as e:
QMessageBox.critical(self, "Erreur d'export",
f"Une erreur est survenue lors de l'export :\n{str(e)}")

315
ui/viewer.py Normal file
View File

@ -0,0 +1,315 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Interface de visualisation et de sélection dans les documents PDF
"""
import os
import sys
from PyQt6.QtWidgets import (QMainWindow, QFileDialog, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QComboBox, QWidget, QScrollArea,
QSpinBox, QFrame, QSplitter, QGroupBox, QStatusBar)
from PyQt6.QtCore import Qt, QRectF, QPoint, pyqtSignal
from PyQt6.QtGui import QPixmap, QPainter, QPen, QColor, QImage
from .llm_config_panel import LLMConfigPanel
import fitz # PyMuPDF
class PDFViewer(QMainWindow):
"""Interface principale pour la visualisation et l'annotation de PDFs"""
def __init__(self):
super().__init__()
self.pdf_document = None
self.current_page = 0
self.total_pages = 0
self.zoom_factor = 1.0
self.selection_rect = None
self.selection_start = None
self.selection_active = False
self.selected_regions = [] # Liste des zones sélectionnées
self.init_ui()
def init_ui(self):
"""Initialise l'interface utilisateur"""
self.setWindowTitle("Prétraitement PDF pour Ragflow")
self.setGeometry(100, 100, 1200, 800)
# Barre d'état
self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar)
self.status_bar.showMessage("Prêt")
# Widget principal et layout
main_widget = QWidget()
self.setCentralWidget(main_widget)
main_layout = QHBoxLayout(main_widget)
# Splitter pour séparer la visualisation PDF et le panneau de configuration
splitter = QSplitter(Qt.Orientation.Horizontal)
main_layout.addWidget(splitter)
# =================== Partie gauche: Visualisation PDF ====================
pdf_panel = QWidget()
pdf_layout = QVBoxLayout(pdf_panel)
# Barre d'outils pour le PDF
toolbar = QWidget()
toolbar_layout = QHBoxLayout(toolbar)
toolbar_layout.setContentsMargins(0, 0, 0, 0)
# Bouton pour charger un PDF
self.load_btn = QPushButton("Charger PDF")
self.load_btn.clicked.connect(self.load_pdf)
toolbar_layout.addWidget(self.load_btn)
# Navigation
self.prev_btn = QPushButton("Page précédente")
self.prev_btn.clicked.connect(self.prev_page)
self.prev_btn.setEnabled(False)
toolbar_layout.addWidget(self.prev_btn)
self.page_spin = QSpinBox()
self.page_spin.setMinimum(1)
self.page_spin.setMaximum(1)
self.page_spin.valueChanged.connect(self.go_to_page)
toolbar_layout.addWidget(self.page_spin)
self.page_count_label = QLabel(" / 1")
toolbar_layout.addWidget(self.page_count_label)
self.next_btn = QPushButton("Page suivante")
self.next_btn.clicked.connect(self.next_page)
self.next_btn.setEnabled(False)
toolbar_layout.addWidget(self.next_btn)
# Zoom
self.zoom_in_btn = QPushButton("Zoom +")
self.zoom_in_btn.clicked.connect(self.zoom_in)
toolbar_layout.addWidget(self.zoom_in_btn)
self.zoom_out_btn = QPushButton("Zoom -")
self.zoom_out_btn.clicked.connect(self.zoom_out)
toolbar_layout.addWidget(self.zoom_out_btn)
# Bouton pour effacer la sélection
self.clear_sel_btn = QPushButton("Effacer sélection")
self.clear_sel_btn.clicked.connect(self.clear_selection)
toolbar_layout.addWidget(self.clear_sel_btn)
pdf_layout.addWidget(toolbar)
# Zone de visualisation du PDF
self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.pdf_label = PDFLabel(self)
self.scroll_area.setWidget(self.pdf_label)
pdf_layout.addWidget(self.scroll_area)
# =================== Partie droite: Panel de configuration ====================
self.config_panel = LLMConfigPanel(self)
# Ajout des deux panneaux au splitter
splitter.addWidget(pdf_panel)
splitter.addWidget(self.config_panel)
# Définir les proportions de départ du splitter
splitter.setSizes([700, 500])
def load_pdf(self):
"""Ouvre un dialogue pour charger un fichier PDF"""
file_path, _ = QFileDialog.getOpenFileName(
self, "Ouvrir un fichier PDF", "", "Fichiers PDF (*.pdf)"
)
if file_path:
try:
self.pdf_document = fitz.open(file_path)
self.total_pages = len(self.pdf_document)
self.current_page = 0
# Mise à jour de l'interface
self.page_spin.setMaximum(self.total_pages)
self.page_count_label.setText(f" / {self.total_pages}")
self.page_spin.setValue(1) # Cette action déclenche go_to_page()
self.prev_btn.setEnabled(True)
self.next_btn.setEnabled(True)
self.render_current_page()
# Mise à jour du titre avec le nom du fichier
file_name = os.path.basename(file_path)
self.setWindowTitle(f"Prétraitement PDF - {file_name}")
self.status_bar.showMessage(f"PDF chargé: {file_name}, {self.total_pages} pages")
except Exception as e:
self.status_bar.showMessage(f"Erreur lors du chargement du PDF: {str(e)}")
def render_current_page(self):
"""Affiche la page courante du PDF"""
if not self.pdf_document:
return
# Récupérer la page actuelle
page = self.pdf_document[self.current_page]
# Facteur de zoom et de qualité pour le rendu
zoom_matrix = fitz.Matrix(2 * self.zoom_factor, 2 * self.zoom_factor)
# Rendu de la page en un pixmap
pixmap = page.get_pixmap(matrix=zoom_matrix, alpha=False)
# Conversion en QImage puis QPixmap
img = QImage(pixmap.samples, pixmap.width, pixmap.height,
pixmap.stride, QImage.Format.Format_RGB888)
pixmap_qt = QPixmap.fromImage(img)
# Mise à jour de l'affichage
self.pdf_label.setPixmap(pixmap_qt)
self.pdf_label.adjustSize()
# Mise à jour de l'état
self.status_bar.showMessage(f"Page {self.current_page + 1}/{self.total_pages}")
def next_page(self):
"""Passe à la page suivante"""
if self.pdf_document and self.current_page < self.total_pages - 1:
self.current_page += 1
self.page_spin.setValue(self.current_page + 1)
def prev_page(self):
"""Passe à la page précédente"""
if self.pdf_document and self.current_page > 0:
self.current_page -= 1
self.page_spin.setValue(self.current_page + 1)
def go_to_page(self, page_num):
"""Va à une page spécifique"""
if self.pdf_document and 1 <= page_num <= self.total_pages:
self.current_page = page_num - 1
self.render_current_page()
def zoom_in(self):
"""Augmente le facteur de zoom"""
self.zoom_factor *= 1.25
self.render_current_page()
def zoom_out(self):
"""Diminue le facteur de zoom"""
self.zoom_factor *= 0.8
self.render_current_page()
def clear_selection(self):
"""Efface la sélection actuelle"""
self.selection_rect = None
self.selection_active = False
self.selection_start = None
self.pdf_label.update()
def add_selection(self, rect, page_num=None):
"""
Ajoute une sélection à la liste des régions sélectionnées
Args:
rect (QRectF): Rectangle de sélection
page_num (int, optional): Numéro de page. Si None, utilise la page courante.
"""
page = self.current_page if page_num is None else page_num
# Ajuster les coordonnées selon le zoom actuel
adjusted_rect = QRectF(
rect.x() / self.zoom_factor,
rect.y() / self.zoom_factor,
rect.width() / self.zoom_factor,
rect.height() / self.zoom_factor
)
selection = {
"page": page,
"rect": adjusted_rect,
"type": "schéma", # Type par défaut
"context": ""
}
self.selected_regions.append(selection)
self.config_panel.update_selection_list()
# Informer l'utilisateur
self.status_bar.showMessage(f"Sélection ajoutée à la page {page + 1}")
class PDFLabel(QLabel):
"""Étiquette personnalisée pour afficher le PDF et gérer les sélections"""
selection_made = pyqtSignal(QRectF)
def __init__(self, parent):
super().__init__(parent)
self.parent = parent
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Pour permettre de suivre les événements de souris
self.setMouseTracking(True)
def mousePressEvent(self, event):
"""Gère l'événement de clic de souris pour démarrer une sélection"""
if event.button() == Qt.MouseButton.LeftButton:
self.parent.selection_start = event.position()
self.parent.selection_active = True
self.parent.selection_rect = QRectF(event.position(), event.position())
def mouseMoveEvent(self, event):
"""Gère l'événement de déplacement de souris pour mettre à jour la sélection"""
if self.parent.selection_active:
# Mise à jour du rectangle de sélection
self.parent.selection_rect = QRectF(
self.parent.selection_start,
event.position()
).normalized()
# Demande de mise à jour de l'affichage
self.update()
def mouseReleaseEvent(self, event):
"""Gère l'événement de relâchement de souris pour finaliser une sélection"""
if event.button() == Qt.MouseButton.LeftButton and self.parent.selection_active:
# Finalisation de la sélection
self.parent.selection_active = False
# Vérifier que la sélection a une taille minimale
if (self.parent.selection_rect.width() > 10 and
self.parent.selection_rect.height() > 10):
# Ajouter cette sélection à la liste des sélections
self.parent.add_selection(self.parent.selection_rect)
# Émettre le signal de sélection
self.selection_made.emit(self.parent.selection_rect)
# Continuer à afficher le rectangle
self.update()
def paintEvent(self, event):
"""Surcharge pour dessiner la sélection par-dessus le PDF"""
super().paintEvent(event)
# Dessiner le rectangle de sélection si disponible
if self.parent.selection_rect is not None:
painter = QPainter(self)
# Paramètres pour le rectangle de sélection
pen = QPen(QColor(255, 0, 0)) # Rouge
pen.setWidth(2)
pen.setStyle(Qt.PenStyle.DashLine)
painter.setPen(pen)
# Dessiner avec une légère transparence
painter.setOpacity(0.7)
painter.drawRect(self.parent.selection_rect)

10
utils/__init__.py Normal file
View File

@ -0,0 +1,10 @@
# Module Utils
from .markdown_export import MarkdownExporter
from .api_ollama import OllamaAPI
try:
from .ocr import OCRProcessor
__all__ = ['MarkdownExporter', 'OllamaAPI', 'OCRProcessor']
except ImportError:
# OCR facultatif car il dépend de bibliothèques externes supplémentaires
__all__ = ['MarkdownExporter', 'OllamaAPI']

244
utils/api_ollama.py Normal file
View File

@ -0,0 +1,244 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Module pour l'interaction avec l'API Ollama
"""
import json
import requests
import base64
from typing import List, Dict, Any, Optional, Union, Callable
class OllamaAPI:
"""
Classe pour interagir avec l'API Ollama
"""
def __init__(self, base_url: str = "http://217.182.105.173:11434"):
"""
Initialise la connexion à l'API Ollama
Args:
base_url (str): URL de base de l'API Ollama
"""
self.base_url = base_url.rstrip("/")
self.generate_endpoint = f"{self.base_url}/api/generate"
self.chat_endpoint = f"{self.base_url}/api/chat"
self.models_endpoint = f"{self.base_url}/api/tags"
def list_models(self) -> List[str]:
"""
Récupère la liste des modèles disponibles
Returns:
List[str]: Liste des noms de modèles disponibles
"""
try:
response = requests.get(self.models_endpoint)
response.raise_for_status()
data = response.json()
# Extraire les noms des modèles
models = [model['name'] for model in data.get('models', [])]
return models
except Exception as e:
print(f"Erreur lors de la récupération des modèles: {str(e)}")
return []
def generate(self, model: str, prompt: str, images: Optional[List[bytes]] = None,
options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Génère une réponse à partir d'un prompt
Args:
model (str): Nom du modèle à utiliser
prompt (str): Texte du prompt
images (List[bytes], optional): Liste d'images en bytes
options (Dict, optional): Options de génération
Returns:
Dict[str, Any]: Réponse du modèle
"""
# Options par défaut
default_options = {
"temperature": 0.2,
"top_p": 0.95,
"top_k": 40,
"num_predict": 1024
}
# Fusionner avec les options fournies
if options:
default_options.update(options)
# Construire la payload
payload = {
"model": model,
"prompt": prompt,
"options": default_options
}
# Ajouter les images si fournies (pour les modèles multimodaux)
if images:
base64_images = []
for img in images:
if isinstance(img, bytes):
base64_img = base64.b64encode(img).decode("utf-8")
base64_images.append(base64_img)
payload["images"] = base64_images
try:
# Envoyer la requête
response = requests.post(self.generate_endpoint, json=payload)
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
print(f"Erreur HTTP: {e}")
return {"error": str(e)}
except Exception as e:
print(f"Erreur lors de la génération: {str(e)}")
return {"error": str(e)}
def chat(self, model: str, messages: List[Dict[str, Any]],
images: Optional[List[bytes]] = None,
options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Utilise l'API de chat pour une conversation
Args:
model (str): Nom du modèle à utiliser
messages (List[Dict]): Liste des messages de la conversation
Format: [{"role": "user", "content": "message"}, ...]
images (List[bytes], optional): Liste d'images en bytes (pour le dernier message)
options (Dict, optional): Options de génération
Returns:
Dict[str, Any]: Réponse du modèle
"""
# Options par défaut
default_options = {
"temperature": 0.2,
"top_p": 0.95,
"top_k": 40,
"num_predict": 1024
}
# Fusionner avec les options fournies
if options:
default_options.update(options)
# Construire la payload
payload = {
"model": model,
"messages": messages,
"options": default_options
}
# Ajouter les images au dernier message utilisateur si fournies
if images and messages and messages[-1]["role"] == "user":
base64_images = []
for img in images:
if isinstance(img, bytes):
base64_img = base64.b64encode(img).decode("utf-8")
base64_images.append(base64_img)
# Modifier le dernier message pour inclure les images
last_message = messages[-1].copy()
# Les images doivent être dans un champ distinct du modèle d'API d'Ollama
# Pas comme un champ texte standard mais dans un tableau d'images
if "images" not in last_message:
last_message["images"] = base64_images
# Remplacer le dernier message
payload["messages"] = messages[:-1] + [last_message]
try:
# Envoyer la requête
response = requests.post(self.chat_endpoint, json=payload)
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
print(f"Erreur HTTP: {e}")
return {"error": str(e)}
except Exception as e:
print(f"Erreur lors du chat: {str(e)}")
return {"error": str(e)}
def stream_generate(self, model: str, prompt: str,
callback: Callable[[str], None],
options: Optional[Dict[str, Any]] = None) -> None:
"""
Génère une réponse en streaming et appelle le callback pour chaque morceau
Args:
model (str): Nom du modèle à utiliser
prompt (str): Texte du prompt
callback (Callable): Fonction à appeler pour chaque morceau de texte
options (Dict, optional): Options de génération
"""
# Options par défaut
default_options = {
"temperature": 0.2,
"top_p": 0.95,
"top_k": 40,
"num_predict": 1024,
"stream": True # Activer le streaming
}
# Fusionner avec les options fournies
if options:
default_options.update(options)
# S'assurer que stream est activé
default_options["stream"] = True
# Construire la payload
payload = {
"model": model,
"prompt": prompt,
"options": default_options
}
try:
# Envoyer la requête en streaming
with requests.post(self.generate_endpoint, json=payload, stream=True) as response:
response.raise_for_status()
# Traiter chaque ligne de la réponse
for line in response.iter_lines():
if line:
try:
data = json.loads(line)
if "response" in data:
callback(data["response"])
except json.JSONDecodeError:
print(f"Erreur de décodage JSON: {line}")
except requests.exceptions.HTTPError as e:
print(f"Erreur HTTP: {e}")
callback(f"\nErreur: {str(e)}")
except Exception as e:
print(f"Erreur lors du streaming: {str(e)}")
callback(f"\nErreur: {str(e)}")
def check_connection(self) -> bool:
"""
Vérifie si la connexion à l'API Ollama est fonctionnelle
Returns:
bool: True si la connexion est établie, False sinon
"""
try:
response = requests.get(f"{self.base_url}/api/version")
return response.status_code == 200
except:
return False

224
utils/markdown_export.py Normal file
View File

@ -0,0 +1,224 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Module pour l'exportation des résultats au format Markdown
"""
import os
import base64
import re
from typing import List, Dict, Any, Optional, Union
class MarkdownExporter:
"""
Classe pour exporter les résultats d'analyse en Markdown
"""
def __init__(self, output_dir: Optional[str] = "") -> None:
"""
Initialise l'exporteur Markdown
Args:
output_dir (str, optional): Répertoire de sortie pour les fichiers générés
"""
if not output_dir:
self.output_dir = os.path.join(os.getcwd(), "data", "outputs")
else:
self.output_dir = output_dir
os.makedirs(self.output_dir, exist_ok=True)
# Compteur pour les images exportées
self.image_counter = 0
def create_markdown_from_selections(self, selections: List[Dict[str, Any]],
analysis_results: Dict[int, str],
document_title: str = "Analyse de document",
include_images: bool = True) -> str:
"""
Crée un document Markdown à partir des sélections et des résultats d'analyse
Args:
selections (List[Dict]): Liste des sélections (régions d'intérêt)
analysis_results (Dict): Dictionnaire des résultats d'analyse par ID de sélection
document_title (str): Titre du document Markdown
include_images (bool): Si True, inclut les images dans le Markdown
Returns:
str: Contenu du document Markdown généré
"""
# Trier les sélections par numéro de page
sorted_selections = sorted(selections, key=lambda x: x.get("page", 0))
# Commencer le document avec le titre
markdown_content = f"# {document_title}\n\n"
# Ajouter chaque sélection
for selection in sorted_selections:
page = selection.get("page", 0) + 1
selection_type = selection.get("type", "zone")
context = selection.get("context", "")
# Titre de la section
markdown_content += f"## {selection_type.capitalize()} - Page {page}\n\n"
# Ajouter l'image si demandé et disponible
if include_images and "image_data" in selection:
image_path = self._save_image(selection["image_data"])
if image_path:
rel_path = os.path.relpath(image_path, self.output_dir)
markdown_content += f"![{selection_type} de la page {page}]({rel_path})\n\n"
# Ajouter le contexte
if context:
markdown_content += f"**Contexte** :\n{context}\n\n"
# Ajouter les résultats d'analyse
selection_id = id(selection)
if selection_id in analysis_results:
markdown_content += f"**Analyse IA** :\n{analysis_results[selection_id]}\n\n"
# Ajouter un séparateur
markdown_content += "---\n\n"
return markdown_content
def export_to_file(self, markdown_content: str, filename: str = "analyse_document.md") -> str:
"""
Exporte le contenu Markdown dans un fichier
Args:
markdown_content (str): Contenu Markdown à exporter
filename (str): Nom du fichier de sortie
Returns:
str: Chemin absolu du fichier créé
"""
file_path = os.path.join(self.output_dir, filename)
try:
with open(file_path, "w", encoding="utf-8") as f:
f.write(markdown_content)
return file_path
except Exception as e:
print(f"Erreur lors de l'exportation du fichier Markdown: {str(e)}")
return ""
def _save_image(self, image_data: bytes, prefix: str = "image") -> Optional[str]:
"""
Sauvegarde une image et retourne son chemin
Args:
image_data (bytes): Données de l'image
prefix (str): Préfixe pour le nom du fichier
Returns:
Optional[str]: Chemin de l'image sauvegardée ou None en cas d'erreur
"""
try:
# Incrémenter le compteur d'images
self.image_counter += 1
# Créer le répertoire d'images s'il n'existe pas
images_dir = os.path.join(self.output_dir, "images")
os.makedirs(images_dir, exist_ok=True)
# Chemin de l'image
image_path = os.path.join(images_dir, f"{prefix}_{self.image_counter}.png")
# Sauvegarder l'image
with open(image_path, "wb") as f:
f.write(image_data)
return image_path
except Exception as e:
print(f"Erreur lors de la sauvegarde de l'image: {str(e)}")
return None
def format_analysis_result(self, result: str, include_parameters: bool = True) -> str:
"""
Formate un résultat d'analyse pour le Markdown
Args:
result (str): Texte du résultat d'analyse
include_parameters (bool): Si True, inclut la section des paramètres
Returns:
str: Résultat formaté pour le Markdown
"""
# Simple nettoyage des sections
formatted = result
# Séparer les paramètres
if not include_parameters and "**Paramètres utilisés**" in result:
formatted = result.split("**Paramètres utilisés**")[0].strip()
return formatted
@staticmethod
def sanitize_filename(title: str) -> str:
"""
Convertit un titre en nom de fichier sécurisé
Args:
title (str): Titre à convertir
Returns:
str: Nom de fichier sécurisé
"""
# Remplacer les caractères non alphanumériques par des tirets
sanitized = re.sub(r'[^a-zA-Z0-9]', '-', title.lower())
# Réduire les tirets multiples à un seul
sanitized = re.sub(r'-+', '-', sanitized)
# Limiter la longueur et supprimer les tirets au début/fin
return sanitized[:50].strip('-')
def embed_images_in_markdown(self, markdown_content: str) -> str:
"""
Remplace les références d'images par des images en base64 intégrées
Args:
markdown_content (str): Contenu Markdown avec références d'images
Returns:
str: Contenu Markdown avec images intégrées
"""
# Regex pour trouver les références d'images
img_pattern = r'!\[(.*?)\]\((.*?)\)'
def replace_image(match):
alt_text = match.group(1)
img_path = match.group(2)
# Résoudre le chemin relatif
if not os.path.isabs(img_path):
img_path = os.path.join(self.output_dir, img_path)
try:
# Lire l'image et l'encoder en base64
with open(img_path, "rb") as img_file:
img_data = img_file.read()
img_base64 = base64.b64encode(img_data).decode('utf-8')
# Déterminer le type MIME
if img_path.lower().endswith('.png'):
mime_type = 'image/png'
elif img_path.lower().endswith(('.jpg', '.jpeg')):
mime_type = 'image/jpeg'
else:
mime_type = 'image/png' # par défaut
# Retourner l'image intégrée en base64
return f'![{alt_text}](data:{mime_type};base64,{img_base64})'
except Exception as e:
print(f"Erreur lors de l'intégration de l'image {img_path}: {str(e)}")
return match.group(0) # Conserver l'original en cas d'erreur
# Remplacer toutes les références d'images
return re.sub(img_pattern, replace_image, markdown_content)

259
utils/ocr.py Normal file
View File

@ -0,0 +1,259 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Module OCR pour la reconnaissance de texte dans les images
"""
import cv2
import numpy as np
import pytesseract
from typing import Union, Dict, Tuple, Optional
from PIL import Image
import io
import copy
# Configuration du chemin de Tesseract OCR
# pytesseract.pytesseract.tesseract_cmd = r'C:\Program Files\Tesseract-OCR\tesseract.exe' # Pour Windows
# Pour Linux et macOS, Tesseract doit être installé et disponible dans le PATH
class OCRProcessor:
"""
Classe pour traiter les images et extraire le texte
"""
def __init__(self, lang: str = "fra+eng"):
"""
Initialise le processeur OCR
Args:
lang (str): Langues pour la reconnaissance (fra pour français, eng pour anglais)
"""
self.lang = lang
def preprocess_image(self, image: Union[bytes, np.ndarray, str]) -> np.ndarray:
"""
Prétraite l'image pour améliorer la reconnaissance OCR
Args:
image: Image sous forme de bytes, numpy array ou chemin de fichier
Returns:
np.ndarray: Image prétraitée
"""
# Convertir l'image en numpy array si nécessaire
if isinstance(image, bytes):
img = np.array(Image.open(io.BytesIO(image)))
img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
elif isinstance(image, str):
img = cv2.imread(image)
else:
# Pour un np.ndarray, on peut utiliser copy() directement
# Pour d'autres types, on s'assure de créer une copie sécurisée
img = np.array(image) if not isinstance(image, np.ndarray) else image.copy()
# Convertir en niveaux de gris
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Appliquer une réduction de bruit
denoised = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
# Seuillage adaptatif
binary = cv2.adaptiveThreshold(
denoised, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY, 11, 2
)
return binary
def extract_text(self, image: Union[bytes, np.ndarray, str],
preprocess: bool = True) -> str:
"""
Extrait le texte d'une image
Args:
image: Image sous forme de bytes, numpy array ou chemin de fichier
preprocess (bool): Si True, prétraite l'image avant la reconnaissance
Returns:
str: Texte extrait de l'image
"""
try:
# Prétraitement si demandé
if preprocess:
processed_img = self.preprocess_image(image)
else:
if isinstance(image, bytes):
processed_img = np.array(Image.open(io.BytesIO(image)))
elif isinstance(image, str):
processed_img = cv2.imread(image)
else:
processed_img = np.array(image) if not isinstance(image, np.ndarray) else image
# Lancer la reconnaissance OCR
config = r'--oem 3 --psm 6' # Page entière en mode 6
text = pytesseract.image_to_string(processed_img, lang=self.lang, config=config)
return text.strip()
except Exception as e:
print(f"Erreur lors de l'extraction de texte: {str(e)}")
return ""
def extract_text_with_confidence(self, image: Union[bytes, np.ndarray, str],
preprocess: bool = True) -> Dict:
"""
Extrait le texte avec les données de confiance
Args:
image: Image sous forme de bytes, numpy array ou chemin de fichier
preprocess (bool): Si True, prétraite l'image avant la reconnaissance
Returns:
Dict: Dictionnaire contenant le texte et les données de confiance
"""
try:
# Prétraitement si demandé
if preprocess:
processed_img = self.preprocess_image(image)
else:
if isinstance(image, bytes):
processed_img = np.array(Image.open(io.BytesIO(image)))
elif isinstance(image, str):
processed_img = cv2.imread(image)
else:
processed_img = np.array(image) if not isinstance(image, np.ndarray) else image
# Lancer la reconnaissance OCR avec données détaillées
config = r'--oem 3 --psm 6'
data = pytesseract.image_to_data(processed_img, lang=self.lang, config=config, output_type=pytesseract.Output.DICT)
# Calculer la confiance moyenne
confidences = [conf for conf in data['conf'] if conf != -1]
avg_confidence = sum(confidences) / len(confidences) if confidences else 0
# Reconstruire le texte à partir des mots reconnus
text = ' '.join([word for word in data['text'] if word.strip()])
return {
'text': text,
'confidence': avg_confidence,
'words': data['text'],
'word_confidences': data['conf']
}
except Exception as e:
print(f"Erreur lors de l'extraction de texte avec confiance: {str(e)}")
return {'text': "", 'confidence': 0, 'words': [], 'word_confidences': []}
def detect_tables(self, image: Union[bytes, np.ndarray, str]) -> Optional[np.ndarray]:
"""
Détecte les tableaux dans une image
Args:
image: Image sous forme de bytes, numpy array ou chemin de fichier
Returns:
Optional[np.ndarray]: Image avec les tableaux identifiés ou None en cas d'erreur
"""
try:
# Convertir l'image en numpy array si nécessaire
if isinstance(image, bytes):
img = np.array(Image.open(io.BytesIO(image)))
img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
elif isinstance(image, str):
img = cv2.imread(image)
else:
img = np.array(image) if not isinstance(image, np.ndarray) else image.copy()
# Convertir en niveaux de gris
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Seuillage
thresh = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV, 11, 2)
# Dilatation pour renforcer les lignes
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
dilate = cv2.dilate(thresh, kernel, iterations=3)
# Trouver les contours
contours, _ = cv2.findContours(dilate, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# Dessiner les contours des tableaux potentiels sur une copie de l'image
result = img.copy()
for contour in contours:
area = cv2.contourArea(contour)
if area > 1000: # Filtrer les petits contours
x, y, w, h = cv2.boundingRect(contour)
# Un tableau a généralement un ratio largeur/hauteur spécifique
if 0.5 < w/h < 2: # Ajuster selon les besoins
cv2.rectangle(result, (x, y), (x+w, y+h), (0, 255, 0), 2)
return result
except Exception as e:
print(f"Erreur lors de la détection de tableaux: {str(e)}")
return None
def extract_table_data(self, image: Union[bytes, np.ndarray, str]) -> Optional[Dict]:
"""
Extrait les données d'un tableau détecté dans l'image
Args:
image: Image sous forme de bytes, numpy array ou chemin de fichier
Returns:
Optional[Dict]: Dictionnaire contenant les données du tableau ou None en cas d'erreur
"""
# Cette fonction est une simplification. Pour une reconnaissance complète de tableaux,
# des librairies spécialisées comme camelot-py ou tabula-py seraient plus appropriées.
try:
# Pour cette démonstration, on utilise pytesseract avec un mode de segmentation adapté aux tableaux
if isinstance(image, bytes):
img = np.array(Image.open(io.BytesIO(image)))
elif isinstance(image, str):
img = cv2.imread(image)
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
else:
# Conversion sécurisée pour tout type d'entrée
img_array = np.array(image)
img = cv2.cvtColor(img_array, cv2.COLOR_BGR2RGB)
# Configuration pour tableaux
config = r'--oem 3 --psm 6 -c preserve_interword_spaces=1'
data = pytesseract.image_to_data(img, lang=self.lang, config=config, output_type=pytesseract.Output.DICT)
# Organiser les données en lignes et colonnes (simplification)
words = data['text']
left = data['left']
top = data['top']
# Identifier les lignes uniques basées sur la position verticale
unique_tops = sorted(list(set([t for t, w in zip(top, words) if w.strip()])))
rows = []
for t in unique_tops:
# Trouver tous les mots sur cette ligne (avec une tolérance)
tolerance = 10
row_words = [(l, w) for l, w, wt in zip(left, words, top)
if wt >= t-tolerance and wt <= t+tolerance and w.strip()]
# Trier les mots par position horizontale
row_words.sort(key=lambda x: x[0])
# Ajouter les mots de cette ligne
rows.append([w for _, w in row_words])
# Construire un dictionnaire de résultats
return {
'rows': rows,
'raw_text': ' '.join([word for word in words if word.strip()]),
'num_rows': len(rows)
}
except Exception as e:
print(f"Erreur lors de l'extraction des données du tableau: {str(e)}")
return None