From 3ac1fa16170f04ef3526e624e61ce6453cee0fe6 Mon Sep 17 00:00:00 2001 From: Ladebeze66 Date: Thu, 27 Mar 2025 14:08:10 +0100 Subject: [PATCH] first commit --- README.md | 174 +++++++ __init__.py | 6 + agents/__init__.py | 14 + agents/base.py | 77 ++++ agents/rewriter.py | 177 +++++++ agents/summary.py | 183 ++++++++ agents/translation.py | 141 ++++++ agents/vision.py | 146 ++++++ config/llm_profiles.json | 104 +++++ ...or_Complet_Agents_LLM_Pretraitement (1).md | 167 +++++++ export_cursor_chats_to_md.py | 49 ++ main.py | 26 ++ ragproc/lib64 | 0 requirements.txt | 7 + setup.py | 32 ++ ui/__init__.py | 8 + ui/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 309 bytes .../llm_config_panel.cpython-312.pyc | Bin 0 -> 22637 bytes ui/__pycache__/viewer.cpython-312.pyc | Bin 0 -> 16541 bytes ui/llm_config_panel.py | 431 ++++++++++++++++++ ui/viewer.py | 315 +++++++++++++ utils/__init__.py | 10 + utils/api_ollama.py | 244 ++++++++++ utils/markdown_export.py | 224 +++++++++ utils/ocr.py | 259 +++++++++++ 25 files changed, 2794 insertions(+) create mode 100644 README.md create mode 100644 __init__.py create mode 100644 agents/__init__.py create mode 100644 agents/base.py create mode 100644 agents/rewriter.py create mode 100644 agents/summary.py create mode 100644 agents/translation.py create mode 100644 agents/vision.py create mode 100644 config/llm_profiles.json create mode 100644 docs/Prompt_Cursor_Complet_Agents_LLM_Pretraitement (1).md create mode 100644 export_cursor_chats_to_md.py create mode 100644 main.py create mode 100644 ragproc/lib64 create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 ui/__init__.py create mode 100644 ui/__pycache__/__init__.cpython-312.pyc create mode 100644 ui/__pycache__/llm_config_panel.cpython-312.pyc create mode 100644 ui/__pycache__/viewer.cpython-312.pyc create mode 100644 ui/llm_config_panel.py create mode 100644 ui/viewer.py create mode 100644 utils/__init__.py create mode 100644 utils/api_ollama.py create mode 100644 utils/markdown_export.py create mode 100644 utils/ocr.py 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 0000000000000000000000000000000000000000..4ff65259e5e2a70828720082566b961ab64ad342 GIT binary patch literal 309 zcmXwzu};G<5QgnINhwuP2c}AF2q_Za7eFi^SPJTZ7_ywK#0i${IC4^|j_m9_1JBYI zK#`bGhi;L&b>b4i4R`;4_v!A|(wiJ>v2o%Xb6 zSjz0dRLxwEzGB literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7c6c84b383f98290f4a86f2a5c4b80b8c153e606 GIT binary patch literal 22637 zcmb_^ZEzdMl^{NeApjEK_qRACD3TyW>WljRGDV6qWs3Tu6^WJ%Lzoc-3Lo?UkVKe_ zUAwtmD)FxAN_Is>$qCxz&eYmhVYX6p&sW$y|jKnEU9SEbA9>MB3(N|v4V z#y658i9-$>!08w@fbTYa@hWB zgd6p`^R{DO333yBJnuMma)0#7h<`eoz?YZRm!e}M{&Rc?GLI%=7xpLO@n|Gpd~6^Z zJ{N^jkR0Q$#1C-6I3y1W(Zm$yI*yG5V{sXI@v#$ApYrVV<&=1J|Hnd3Qh9Mr>{fe zPoaN!1N4gl`()faXE+Z%^)+VP!kNEj7`Jj32#b)T<2J4cLWZ+JSj;gH+PPu~9h@CP zC+C2$gmXezI%l|I9Cw{CcuVr7z|aB0Y~Vg0N*sVnU$7roK8{^A!N-(Niru>%;sf{^ z3($68bFioJ|EGTr!Cx4!8R8I2&sEcm;m<%KXN(^jr5q!qXAC|Co-@FkEGq^>5Z^2m zdd4v6plLP!P)-6S!(@q?PgCmmX@l1|>b2#qvBVVgFXQtCBf+@OmoJ*~3m|a$vOq$> zgfGU+Ez7(85r1ep7L54>K9&f@V|g0^N(A#|p{Sqpg+gK9ln^~14Dm6qId1{b^FlpT zo-g+K0wI4a=JUm{K`i^u7f04#h=%#~=O+YztS8`KKPB)08vvQ{9UUC~eMEoaJ4|a?9E|OO{!)%(i0O^oVi3=e+8Cw{)3t=9o1iv*rQAehTo0 zfZSV{HsFjx`uE4>IHSasZ!-qktFy+q(ub7)v!)s2B%O^2A}N?LQ7EG!ZlfO*D6H#k)HF?T8cJ7za?`SEJc->< z@BnmWK^DAHib0Z#bUCFxI0UjS zJ;0$hnpWe<4mC|v6R_I-l_HH~KGK8iq~+Ck72bBKC1{FL4cLoyd7w+J21=iE3O818 zu86|T+BKZBE4hw%k3yH60M02=2%PJs-_>|>qnf5EPTRNoP@8C3HC}~leQF7sq7(zy z9IJ3mhf)ocKGzgZ=+0bD4bPku*KCPKG8L}Rma1cSrc^2Gif^MW;EI)8_l!%o4Q^Tj zsL_*XWlTfAY^QkDcom7Z7f7@oT19L^~+lY?-Ea z%9Pyl_#TBWIWb!?Q!%-Z=Blx#Z0?ys`BI*iQ(n?|l{U0eYN5tKnpR`oHdJtx zFVF_$sSd{9qYYJ*b{enJ24%EDjYBH!G^N)DSZk_ZP%d;ZYT8gfsYn5>5A^d24aLun zh{~DDFBuUv6rRS=@?nZ!v&ycedC>AhG_A&yht)JqakX5XhJJmxBeb*{bG0fhtJHQ( zElpEgy#`9R)m(!r@XF(mY^vO_A1>K zT9;A{RLP{?%%sezz#mn|Z=-Ot)tYs^TFI@^)l?Oo54d)Q(yhjmJ~d5K+**p0a7_>D z%XW@6t8haHm!>X#X;E4T+<12HT&v-QGW*TeY53MoYnZLqlvU=u*#;dMY@BJ-Yk@`| z>Y!L@4Ad!W9F=8|+6|Pe!2dUBT8;JAJ+5;?yTj6@L?4?<%S^+ho@8le$#{TL!Feeh z(5vi0pazbXP~*u7HBD110V0dLhkeiwzCZJKHN(ox^^d+hTs z&=#pNt!q^pwJS*73c2GeAi=Bn|Gnd{cfsrqG{+`2Cytgh zVEYUDL%|r&hB|@~u=t+$2Y5CS4?>*J6YB88M$|x zkNp~DAzyqVF*PLw!@Q7mjZIJSvSmBYM-pD^Z*XwN0vGPx4*T=AxE~D2{zTq(9!$rH z5TCck!7dGvKM@!Bq#JCsB!k5;0ft%}DE*UQ@8*+iFc4%z#Aq&+nTi6X0Uk`&8oGLW zdy}PyqhPV+IwT7wpJYaZm(b$A(`!yR0O6CH-})�&U`j1kR+t`u1P5hojR#FWST4 z>m9cLl0Oo-cRN`tS&g4H(I+c*pWEAy)_V42Fb2%oz5d)@ulbAOP{@DDzjJfnxxAGm zzi>em_HXXp)N_dxOfXOhP+SzcWzP6A@dmnz0`9}Jv$-W}uPhrvc3PK00PgA*52v-fgw8rhX?sG z6*?e&6VOI1;CRW~1wI_T#QV<0BLEQ!1}*}qTh3Gf32o8=jDSLwx5r`lfvFr$eew*@ zJq$MVSiW2~?vKHkqGVJhrZ^bts=eQbpaPDDyc0;o5$5CZyr>*nbux;{`;L zci?A=s`eRFGQ5D6=Da-&gHfi*As59g0WHS)BA^nulP{OXh7Z4lXTU73FGRP()%Oa)_q=AfP^yF9se=`C@R0m1m$S$FUpoC6GE1j7-33R-B(AA5Qv1 z2_85Yo$^V=g;GL3kyi=cCL|)HONuc;gf;JwPDgymk-P&d;Qic@NN74=AqlDvr#gRJ zYJgCVI4grFp}-YMI(-2=Ez8>hn4FtISC`7L^ytM7Y&ro0H6FM?SO({F-ZerqB6EcO z!H6#;9ST}tf{keeGvV<~;JPL{7_}obsy#N4uB2QPh=k5xLI`a3VMH)PQ~N zBcO^VG(nK8E;!$k58!Zj5^IBr;vK`|>XuYnrn+O!@mZ0k*lr9J57awk&bEts~;rkt}m~nW@b&ts>K!>dSeyik_`m zW*b(%_D0iO>Ersfsf(HVO>p?;0z~h*uDWtev&b~(m=2NYShVCi_lTW)vdrFPraH&4 zBEzOCKWM(y3>8085bw=0o0ggK9MdQ=jSIdk(*qS$H>aC7WU4mK*&hNgx-!*0bB@RL zo*UgO>Oze7TgI}?t4j419qEq#Ow;}} zQ~z*{H{G>6vu014X@DwMTM(d z9L%lTFRt64Wd;gJ*q3Q}Da-Uf_N@EfF2u$XY*<^4@rsOhamP|P-GvpZ=m;4H#GV6L zW~cyNf2IX1dEBw#dv77SHiE8AWZIbxM^cvk&h>D5wc9Hq=D7^Ge$4=Y}ogUn5t z31KqU1>Yj~gUIbjmf7>TzB5zb^~{J#RipyY!HAWNSfnwDk4>9~`-TB+Kkpn3W1=nZ8GiEQ#snjW9bXtb1J3 zn)d9>)Iit1>qKhdcwgdi>YU6cl7llzjv?F{`{4CkuLDZb62dLZ2hLj{Pd!Q{3$xOk zQ`?eiAY<@httT~-sogkN^0=Yx29cv$s4d6Tg2w^HD{+wK5!v#<#GsP z18XMBtb^86H+}Oo%xqB2p-k<1sHTQZw`|VT^vyY8pe?S;tbv)k;c;`KN?{xPr7^l$p3Q#JZ>)0oD?8`DQDNIhc@60m09=G+Rd-rGB z22iqFNCR6$rbS*IU{-}`Sr*|GmuufAwo6Dr&X;u*!TbfaJIUMd0|LG8f*9^rPH=Rin_Poc%jy&cQjk5+x<&l|sHtDdAR1ltD@@QLdDzSfxbe zcg$euQs_{Pf_e@sGe)lJqiU7P6?~|94j-y%JJlG>V7MzNc)$Pz$2}j_e~3F#c)-c< z8P0eN8x1kzWs}ix8ond1u9#07E*n?RkE1n*JZ%P{1)PZ*=sVXCN0pO2Gm7KL4a0Xe z2MMQvhFQy3Ezl<0ZpC)dTa4wMsinyS(=TfV@-20SRy{W^su4n=CG88- z>+8!KcmBeXKKq8);a~3AdUr5=`i$r~`-#ocT#e=W?pCHxo)%ljSCo@MJboy*hLqd; zi@x+50kM@^-ni=*mFd_0V#m4V9&I_c`jZ-WOZmLzTP2^h8_FuKIo@|H99pV;;NFU> ze*FsaM~y&#W$m@$_lFm_o8jx>#bZlv-c3BHB+VE#Vmn5SpKqZ}`r`9G%#gBw2l(7| zPYp)owX-Z;x$Ex+?i&Ag=+5?Z`C!g@Ky)7X-4io@{UY`nbj}~`?>?~A@E>~`4y-ZX zV;D%^E9++=`q{>|16}5it1#>1HP!3UT2xi6si259N0-|0}B-K;y_PcqWw`&qObqP<*eStlcXg8wy)5kuaB_LijeC`@T=(#-a-quwF` zeTnkc09ew6d@)$9!SW8aJjwS~Hs|mhxa|^~ecmP;0_m_e2J^*=%|}>7TxO`HU^wTW zwGbk)$+m(4{*3VtOZ`7U8WSLR#{jOiIYu%ipvnjq3D_&AxDPJgx|nOF@#(Jl0`Z)AzTiKRr9B6uVD4qanz3K!oZNYzDYXTQ* zI7r}OX(r1loG0M$Qje=}aRtLK%Ymg>8F&JAx$xHz5VJg*a7Vq)yg9^2$Y2uw9ezg@ zNf^LFU>J=LNRIMjP*yldAR!aq@}?jspk+=#mjD5Y^qQrG)QZdh6=4aoWQTZGS~B-S z&NEAfbxr0l&TSC8bJU81uKj2kD#?%i4E9x|+&L}ydZxmI+pO$C>V zMcDH>cj2;8KJWd;Y|4^jw}|lP+Oq7f%(+?7&F0*lqPugkGUweZdiVaFaM$~{v!eI# z1NZ17wMe_@ZhzqJd{Sp<7&Ol9e0F5wC?F#fhU2PBfWYEC3BAREGwudB_@;~|c$FWG zjF{lwjX8lz+=|JUXK`vpWpt&WfUOt~9z#5li&3vh>i@q1a7Yu2I}_nAljD*Y$`32| zd9WBc}TjpRjZWW!asjbjKq+8lWXZr(Z=M$^J+4}4@ zR^eUWWbioR zQ8-%)MsNUmjfA~1T=!(29)e`DFVR z@{aq?o+oBq&LVG!T>c0+w;E^XjlwId@K$HlnA9-|=<}J*(X%M^dOfFyUD>t*ZKx*?_Bglz!4?Tu}<1 z=fg}Jr;T1q0%t1fm$AwWH!7`vx1prhB@N1YNK32UPa%~rl1`k3JD5$@Hk=2k{uxc+ zhA|A=Q9MqS7e+YDCF>$t0LfBFS3z@-yOOuz+zE-5>tH`%O*R*d^TN|^7RR>@0y@4k zFm0AsU+aFqJ6GN-miOk$`^56TrLo+WSHvx^WXeZ~()ivhS6|6FJ4I(_&beN6u3xg` zHVlayhO*9s(h}E}EAJG`J2NgX9y06~;m@^yxw1A_=@l!zAGKocfY>vT=^9K|9(c6Y zOBPgRg3iJO4NGNZ&ebZqT63;W(FM4=7LVO`^**d`S_shdu$*VN=-Hj|?7dqidiH1P z2j*>LJ@trgPtLtjbZ=bhc;Mbi7SI;a-I8;6i0+Q8+pAf1>5}SE<}K|Ep=_xZVUBZ~ZUU*)VvvjKnLIgTP%{c5J`QiS{mWrDt-!v4GyjWXH?P2MoVLI= z%>?cL_n;YD$wtr270s9-Ck%$SBFs^}3V}y2sH0QP(m8z4@SY`LnlJ=RXTi8eHj{do zN(y@jv>Igt`M&AhW(#1LwE%VKcdUm&XXd2sptOJi(MDQ`IP~T~eH^xxdOF7_ICS<3 zlgdo1wg~883UCw?-d_UV7ZcuFf%jIzdq_RQdmWRh-u~zXc&~@)_v5`D20gyX?a=Vv zPTD*TZMKs(g9&w}2-+N-fz&f?)>|nYDo<(Z1=_5K>BZaJ46>q!L9fknJJdEyyR&H+ z0Y)Ofmmz#M<#iCfM(um~Gr*7}iaqSy`MR*s3KZ9UY zn8W11!r)IZ_)`qNj=@z3;7+&|?i%xg@LhPzoAG+R6>P!a5;%%w?qV*3caX_W@5h*T z1cT!kyo$k54A6<*Ym@c`#l)2bmEgQhHkV37`~XWTRmX6eCU#T0br^w+WPQQ%r*W0q z0@G~ln=nt~%2zQzeeLb{-_F!@ijL0Z>iTQp_rtmB4zappky&cZRByR@aJi&%q4=ij zx+_!b6-&G;3)z;+Gu3@qsCvPBbN%)8nfh+AqaAA~K5~}B;^wTJzbrb| zELSxyoD!?r=Z2PD)eG&Si%p$eJb25OJ%2GBn)=*e2pb1YpGXmTJ0!gwGJT4fBN$B> z51anUVAwHgO8d{|#ltX}94T`j4J#R;)}Yc?23M`O^2DdWr728_tt zsPs09O_?$t#c0xa3|qA8xGBx?clvU>PKdisBQj2iw zoO5pz-P`Wi?%KrdNbw^_?ZQFP;aRS4N%e^J8|Gd?lDb9L+Qsgr39)N$Hjzxf^)?VO zV?2e(Fd}cSOK-0uEi=Y9FuG*)BS{0sbEfpvn|J$i13q!Umm9bs4qQkFCm#%41e)+G z;ORam5w%Wqugkf2iSAu@UipPh+?&Ge>QyXoeXy%qZ9f&ORd3Gg+1+NyuXoVnMbN0> zxK7vTtFsrP^Ca0*fi_?GOg;|=HGZ8P&~sssHgq2>c%BP`7BIbaxuO6GyejR1U&fd* zqS5)g=Gh{Ef3_$b+2QKK*)Dd%Bm`>?$0T9?pDl`Pd2ab)rMz7(kGkUO_#H}lr(E6& zb(KhINR`f*Bs!HGmz=V2ZUS2hrOG4|`ghGrsd6P%p`hD^W{F)gn)KM!L z;At*Utz%bIZ+Gaoimqa^g-LG>)Y-YsDEG!!-Mir_S-~^eQjh_!aMMrLtx?jw^$9$^ zJaB~%fG0e4l*Y3*7S0n_+1~x83_$u5fkZ^>AD>tcxhMHV5KLT2je#@*2eVjGkX-%o zkV1B!7kWpNO=tIKXD7Bit8@FyY=j6k)L-#-hLCE#EY z?ifVySQCHtBN4+zeG2k}a0VJ<@7<33r{EmKe~Ax}0}-(5pr16kb^u4BvOhN$Jbxb6 zddNT!F!$ndX{;A2@4$M;qNqR4qzF?4ui*#Ky&SgA2bnicYy`3UUID=j_&I${-6nX>qBkWOdhCvGa>!15BOtG zh4?Ex;7P2L2qY$}NJ7hhuw46*3m6kt-@AU9F>;pEr_=uYA( z%yWdV$kq$~7<-O~ek2qp;fp`Qv9Sx$%P}?%02-R2=Shx&0#K2zV}lX$K{n#Caybn_ z2{cM{b!l%aNLN*W+aPuc7kmcI3$IhK(y{)Y1kRc3fHzVu_6y#k*poD^W0OveXTY9h z@j4hjc=KXU0#`CB<#3z^h7IWbUT>LX8o|+&w_WxN5%54DN@CujajaL>K~+!8TQ8wg zxnw!H1Mt$(z)vuRjRKc%xZIG4`Od*Xo&(t?|A-RRD4klbc(Af)o!|3i8s7!U?H$hh zp($`?2(AxkyCFZ@^NhB68fs3~7HFG_L;D!&nt2F7*K}3nTx;*U);@IBNJnoeOS*l> zGJEVZv$5oa5tT!yp|&ws(<|2W=4y6|HQ;f#d(H_55xLq8V(o@p?QXGlccykP_%b%K zxyJ2cL29wk`uH21Cs-YBVLormI8CC1tr1k67YK4S{mI zIDNllJCsRRM?`02^{=Ny=c%hh(5`g#kmwwm8+yc)&X1&;Qm)0z>GEwNvu&Lm_L#lN$pK< z9K3t>?veC~SJUMuMdqaRX(UydDuRO;INwZ{ZxxxXb49>{Jp;x$i{#k-opY(mT+24G zWm{&=c9GdZ9P%WOL*hCKzjTB)Dal1na@5T+=-Qj^-uHlc>Di+MRDX!U`@cR%LDMr7 zfzqZNB?ETU>B_}eL}qTXRC z94;4qWTVDnFu5?ZCXM-7SAo*wEB>U}M|O$_{1MFHuMj6DBn#&t3Y{|vZJq^h1Fi($ zLH)CEr6gqoBZCWmNU5fPDey`aM^Ka8L|H16)hE-8O#rhDS3YBdZ)LIqW>PO$ss<@g zm~iqcc)*m=4Dc2DU-j@Ht=hT(x{@eurgtw{{s1XKpFD+QjK-Ud;i|csk7{Wjf@fke z{Dcr1$O|5=!5+LB;UC4P^);Df3&7wNI-T?wMq_~R_SOeg$jD4oCxheK?nV;Ynbf1xgkN}_b(g$E@Oe9HWBxZ??z zHK!8s*qHF=0GzDxuyjpDi;k>k*zGGeh-iYwCHqsxO_nAhChstgE0lU2M ziso$a3uZT5t31c~_a@gDtYz{Y9C`ig-5rVs{Gr%hNJs$aeHO!M<{d-~>(*TfTN z-Z79_5o$4C9jwBRMPD6ut^47a3p6Olii+lb#)EM$DUnX>`r^KOx2Tz z`=u;vPfrgNt&o&D-bYygcNK+stl^I^_+tnXxMP$$2-WaJEEY_3Ll!8~)OC_-h*A?# zRYY|;mIwsyEyj=lm+VuhT$Aj66?9QBbD^Ul{DdLy7cPOz5NM3V>ku_Z2^m0pD>ZHp zsP72>1%q#4@D~{TB?i|pSis<4Ly#|uf!YaI*6acQR4@+Lij%zNyy)9l#Cll(OIqF$ zj7ayQK&KZ{_<;$)hamKh@K^Babx3wJyCgwA^gFbNIk-6oIyD?Ml}Rc93s(AQTR8Q`EMbJIUta2ZWu}K7kK!XY}SSi+VMlN{vY*{*Whr4s~ZeM!M%cA?`dHesYs9&yJ zlWyx<`pVMqow0Q5ez9`@JXT*>Js$aQD>(118dgD;0_Tb#` z!?LRRV5(yAa<*(6cq^8)-PyS8F1u!X-?m`Sx;@~eS<(hHl+@;&9?|JZ4W_$xF1H;` z9~(~}eJ$O3<~LB_b>rtYC`Q14_nDLUI+~ydNDr~16E2d0ZnoTV_#Xo4<0pQ3{uigx z%_kA_DdQ(l4i>|off@}Ao9uC*qA?@_}VE-4Azu0^@?tMP;vz6p-pMZt{ z;cqa&ZKW`T!66L#F&M$%GzKW7`4agTH3Q(8;`4#01Pi%jHhf7EvgAn}l z*9`y7a^y3M&D!#`{Ibc~^;EE~wGJ7djv8&&myA!#ORe6gtOWw6*?QFY)LsF}t|seo z47*Jtg_m5zO9N;s0PQWIDJ3sl4mx^s)HX-edAl$i}1#(iSX)s~Nff zOYSq1+sWi|v3h-4z`34Wu_hO7$z@$~Cs-ge4{pZtPFlf06uh``g%Bz+Ag%o$n8H<* zZ~)c=iT8U2*aN`g3v4(50l2mrjh`?kqvcb^V6^{tgX43P*?83WxuG!pRLX%;p3f}4 TBI7CJ{8xShKbHv!QqBJd4paf9 literal 0 HcmV?d00001 diff --git a/ui/__pycache__/viewer.cpython-312.pyc b/ui/__pycache__/viewer.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..572157276b740c469ad2109691555e861c915b14 GIT binary patch literal 16541 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 literal 0 HcmV?d00001 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