commit 3ac1fa16170f04ef3526e624e61ce6453cee0fe6 Author: Ladebeze66 Date: Thu Mar 27 14:08:10 2025 +0100 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..b25bcfc --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..512a1d1 --- /dev/null +++ b/__init__.py @@ -0,0 +1,6 @@ +""" +Programme de prétraitement PDF avec agents LLM modulables pour Ragflow +""" + +__version__ = "1.0.0" +__author__ = "Ragflow Team" \ No newline at end of file diff --git a/agents/__init__.py b/agents/__init__.py new file mode 100644 index 0000000..e743a49 --- /dev/null +++ b/agents/__init__.py @@ -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' +] \ No newline at end of file diff --git a/agents/base.py b/agents/base.py new file mode 100644 index 0000000..1a0d7c3 --- /dev/null +++ b/agents/base.py @@ -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) \ No newline at end of file diff --git a/agents/rewriter.py b/agents/rewriter.py new file mode 100644 index 0000000..be76f49 --- /dev/null +++ b/agents/rewriter.py @@ -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)}" \ No newline at end of file diff --git a/agents/summary.py b/agents/summary.py new file mode 100644 index 0000000..ec3c907 --- /dev/null +++ b/agents/summary.py @@ -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)}" \ No newline at end of file diff --git a/agents/translation.py b/agents/translation.py new file mode 100644 index 0000000..f27d3a4 --- /dev/null +++ b/agents/translation.py @@ -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)}" \ No newline at end of file diff --git a/agents/vision.py b/agents/vision.py new file mode 100644 index 0000000..1ac0ab8 --- /dev/null +++ b/agents/vision.py @@ -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)}" \ No newline at end of file diff --git a/config/llm_profiles.json b/config/llm_profiles.json new file mode 100644 index 0000000..0823892 --- /dev/null +++ b/config/llm_profiles.json @@ -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 + } + } +} \ No newline at end of file diff --git a/docs/Prompt_Cursor_Complet_Agents_LLM_Pretraitement (1).md b/docs/Prompt_Cursor_Complet_Agents_LLM_Pretraitement (1).md new file mode 100644 index 0000000..96abd72 --- /dev/null +++ b/docs/Prompt_Cursor_Complet_Agents_LLM_Pretraitement (1).md @@ -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 l’interface + +- Navigation page par page dans le PDF +- Zoom/dézoom + recentrage +- Sélection d’une zone avec la souris +- Attribution d’un **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 : +- S’appeler 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 d’agents, 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 l’analyse (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 d’analyse" : + - 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. diff --git a/export_cursor_chats_to_md.py b/export_cursor_chats_to_md.py new file mode 100644 index 0000000..5a9227f --- /dev/null +++ b/export_cursor_chats_to_md.py @@ -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}") diff --git a/main.py b/main.py new file mode 100644 index 0000000..6d5e813 --- /dev/null +++ b/main.py @@ -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() \ No newline at end of file diff --git a/ragproc/lib64 b/ragproc/lib64 new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c1e922d --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..1b87e34 --- /dev/null +++ b/setup.py @@ -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", + ], + }, +) \ No newline at end of file diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..df19c49 --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1,8 @@ +# Module UI +from .viewer import PDFViewer +from .llm_config_panel import LLMConfigPanel + +__all__ = [ + 'PDFViewer', + 'LLMConfigPanel' +] \ No newline at end of file diff --git a/ui/__pycache__/__init__.cpython-312.pyc b/ui/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..4ff6525 Binary files /dev/null and b/ui/__pycache__/__init__.cpython-312.pyc differ diff --git a/ui/__pycache__/llm_config_panel.cpython-312.pyc b/ui/__pycache__/llm_config_panel.cpython-312.pyc new file mode 100644 index 0000000..7c6c84b Binary files /dev/null and b/ui/__pycache__/llm_config_panel.cpython-312.pyc differ diff --git a/ui/__pycache__/viewer.cpython-312.pyc b/ui/__pycache__/viewer.cpython-312.pyc new file mode 100644 index 0000000..5721572 Binary files /dev/null and b/ui/__pycache__/viewer.cpython-312.pyc differ diff --git a/ui/llm_config_panel.py b/ui/llm_config_panel.py new file mode 100644 index 0000000..db04fd5 --- /dev/null +++ b/ui/llm_config_panel.py @@ -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("Agent Vision")) + + 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("Agent Résumé")) + + 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("Agent Traduction")) + + 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("Paramètres de génération")) + + 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)}") \ No newline at end of file diff --git a/ui/viewer.py b/ui/viewer.py new file mode 100644 index 0000000..4845625 --- /dev/null +++ b/ui/viewer.py @@ -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) \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..3c6dfd1 --- /dev/null +++ b/utils/__init__.py @@ -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'] \ No newline at end of file diff --git a/utils/api_ollama.py b/utils/api_ollama.py new file mode 100644 index 0000000..4d724f5 --- /dev/null +++ b/utils/api_ollama.py @@ -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 \ No newline at end of file diff --git a/utils/markdown_export.py b/utils/markdown_export.py new file mode 100644 index 0000000..cdc678f --- /dev/null +++ b/utils/markdown_export.py @@ -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) \ No newline at end of file diff --git a/utils/ocr.py b/utils/ocr.py new file mode 100644 index 0000000..0d6e0f4 --- /dev/null +++ b/utils/ocr.py @@ -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 \ No newline at end of file