""" ProfileEditorDialog - Editor visuale per parametri profilo """ import os import sys import subprocess from datetime import datetime from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, QMessageBox, QTabWidget, QWidget, QDoubleSpinBox, QSpinBox, QFormLayout, QTableWidget, QTableWidgetItem, QFileDialog, QProgressDialog ) from PyQt6.QtCore import Qt from PyQt6.QtGui import QFont from core.config_manager import ConfigManager class ProfileEditorDialog(QDialog): """Dialog modale per modifica/creazione parametri profilo""" def __init__(self, kernel, mode='edit', parent=None): """ Inizializza dialog editor profilo Args: kernel: Riferimento al JarvisKernel (per active_profile) o MockKernel mode: 'edit' per modificare profilo esistente, 'create' per nuovo profilo parent: Widget parent (ProfileSelectorDialog o MainWindow) """ super().__init__(parent) self.kernel = kernel self.mode = mode # 'edit' o 'create' self.config_manager = None self.new_profile_id = None # Usato solo in modalità 'create' # Path al config.yaml self.config_path = os.path.join( os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'config', 'config.yaml' ) self.init_ui() self.apply_stylesheet() if self.mode == 'edit': self.load_current_config() # In modalità 'create' i widgets rimangono vuoti def init_ui(self): """Inizializza l'interfaccia utente""" # Titolo dinamico basato su modalità if self.mode == 'create': self.setWindowTitle("Crea Nuovo Profilo") header_text = "➕ Crea Nuovo Profilo" subtitle_text = "Configura un nuovo assistente personalizzato. Verrà aggiunto alla lista profili." else: profile_name = self.kernel.active_profile.capitalize() if self.kernel.active_profile else "Profilo" self.setWindowTitle(f"Modifica Profilo - {profile_name}") header_text = f"⚙️ Modifica Profilo: {profile_name}" subtitle_text = "Modifica i parametri del profilo. Le modifiche saranno applicate al riavvio dell'applicazione." self.setModal(True) self.setMinimumWidth(800) self.setMinimumHeight(750) # Leggermente più alto per campo ID profilo layout = QVBoxLayout() layout.setSpacing(15) layout.setContentsMargins(20, 20, 20, 20) # Header header = QLabel(header_text) header_font = QFont() header_font.setPointSize(14) header_font.setBold(True) header.setFont(header_font) layout.addWidget(header) # Subtitle subtitle = QLabel(subtitle_text) subtitle.setStyleSheet("color: #666; font-size: 11px; margin-bottom: 10px;") layout.addWidget(subtitle) # Tab Widget self.tabs = QTabWidget() # Tab 1: Personalità self.personality_tab = self._create_personality_tab() self.tabs.addTab(self.personality_tab, "📝 Personalità") # Tab 2: Parametri self.parameters_tab = self._create_parameters_tab() self.tabs.addTab(self.parameters_tab, "🎛️ Parametri") # Tab 3: Documenti RAG self.documents_tab = self._create_documents_tab() self.tabs.addTab(self.documents_tab, "📚 Documenti RAG") layout.addWidget(self.tabs) # Bottoni button_layout = QHBoxLayout() button_layout.addStretch() cancel_btn = QPushButton("Annulla") cancel_btn.setObjectName("cancel_btn") cancel_btn.clicked.connect(self.reject) button_layout.addWidget(cancel_btn) save_btn = QPushButton("💾 Salva") save_btn.setObjectName("save_btn") save_btn.clicked.connect(self.save_config) button_layout.addWidget(save_btn) layout.addLayout(button_layout) self.setLayout(layout) def _create_personality_tab(self) -> QWidget: """Crea tab Personalità con editor agent_prompt""" tab = QWidget() layout = QVBoxLayout() layout.setContentsMargins(10, 10, 10, 10) # Label label = QLabel("System Prompt (agent_prompt)") label.setStyleSheet("font-weight: bold; margin-bottom: 5px;") layout.addWidget(label) # Help text help_text = QLabel( "Definisce la personalità, stile comunicativo, regole e comportamento dell'assistente." ) help_text.setStyleSheet("color: #666; font-size: 10px; margin-bottom: 10px;") layout.addWidget(help_text) # QPlainTextEdit per il prompt self.prompt_editor = QPlainTextEdit() self.prompt_editor.setPlaceholderText( "System prompt che definisce personalità, stile, regole...\n\n" "Esempio struttura:\n" "- Introduzione personalità\n" "- STILE COMUNICATIVO\n" "- VIETATO\n" "- REGOLA ASSOLUTA\n" "- STRUMENTI" ) self.prompt_editor.setMinimumHeight(450) layout.addWidget(self.prompt_editor) tab.setLayout(layout) return tab def _create_parameters_tab(self) -> QWidget: """Crea tab Parametri con spinbox vari""" from PyQt6.QtWidgets import QLineEdit tab = QWidget() layout = QVBoxLayout() layout.setContentsMargins(10, 10, 10, 10) # Form layout per parametri form_layout = QFormLayout() form_layout.setSpacing(15) # ID Profilo (solo in modalità create) if self.mode == 'create': id_container = QVBoxLayout() id_container.setSpacing(5) self.profile_id_input = QLineEdit() self.profile_id_input.setPlaceholderText("es: einstein, darwin, curie...") id_container.addWidget(self.profile_id_input) id_help = QLabel("ID univoco del profilo (minuscolo, senza spazi)") id_help.setStyleSheet("color: #666; font-size: 10px;") id_container.addWidget(id_help) form_layout.addRow("ID Profilo*:", id_container) # Temperature temp_container = QVBoxLayout() temp_container.setSpacing(5) self.temperature_spin = QDoubleSpinBox() self.temperature_spin.setMinimum(0.0) self.temperature_spin.setMaximum(1.0) self.temperature_spin.setSingleStep(0.05) self.temperature_spin.setDecimals(2) self.temperature_spin.setValue(0.7) temp_container.addWidget(self.temperature_spin) temp_help = QLabel("0.0 = deterministico, 1.0 = creativo") temp_help.setStyleSheet("color: #666; font-size: 10px;") temp_container.addWidget(temp_help) form_layout.addRow("Temperatura (creatività):", temp_container) # Max Tokens tokens_container = QVBoxLayout() tokens_container.setSpacing(5) self.max_tokens_spin = QSpinBox() self.max_tokens_spin.setMinimum(100) self.max_tokens_spin.setMaximum(8192) self.max_tokens_spin.setSingleStep(256) self.max_tokens_spin.setValue(4096) tokens_container.addWidget(self.max_tokens_spin) tokens_help = QLabel("Numero massimo token per risposta (default: 4096)") tokens_help.setStyleSheet("color: #666; font-size: 10px;") tokens_container.addWidget(tokens_help) form_layout.addRow("Lunghezza massima risposta:", tokens_container) layout.addLayout(form_layout) # Spacer layout.addSpacing(20) # RAG Tool Description rag_label = QLabel("Descrizione Tool Ricerca Knowledge Base") rag_label.setStyleSheet("font-weight: bold; margin-bottom: 5px;") layout.addWidget(rag_label) rag_help = QLabel( "Descrizione del tool di ricerca semantica nel knowledge base del profilo." ) rag_help.setStyleSheet("color: #666; font-size: 10px; margin-bottom: 10px;") layout.addWidget(rag_help) self.rag_description_editor = QPlainTextEdit() self.rag_description_editor.setPlaceholderText( "Es: Cerca nel knowledge base di Warren (portfolio, scans, decisioni, verbali).\n" "Usa questo per verificare vincoli, thesis, performance storiche, regole." ) self.rag_description_editor.setMinimumHeight(120) self.rag_description_editor.setMaximumHeight(150) layout.addWidget(self.rag_description_editor) layout.addStretch() tab.setLayout(layout) return tab def load_current_config(self): """Carica parametri attuali da config.yaml e popola widgets""" try: self.config_manager = ConfigManager(self.config_path) service_config = self.config_manager.get_service_config( self.kernel.active_profile, 'CognitiveService' ) if not service_config: QMessageBox.warning( self, "Errore Caricamento", f"Impossibile caricare configurazione per profilo '{self.kernel.active_profile}'" ) self.reject() return # Popola widgets self.prompt_editor.setPlainText(service_config.get('agent_prompt', '')) self.temperature_spin.setValue(service_config.get('temperature', 0.7)) self.max_tokens_spin.setValue(service_config.get('max_tokens', 4096)) self.rag_description_editor.setPlainText( service_config.get('rag_tool_description', '') ) except Exception as e: QMessageBox.critical( self, "Errore Critico", f"Impossibile caricare config.yaml:\n\n{str(e)}" ) self.reject() def validate_inputs(self) -> tuple: """ Valida tutti gli input prima del salvataggio Returns: Tupla (is_valid: bool, error_message: str) """ # 0. In modalità create, valida ID profilo if self.mode == 'create': profile_id = self.profile_id_input.text().strip().lower() if not profile_id: return False, "L'ID del profilo è obbligatorio" # Validazione formato ID (solo caratteri alfanumerici e underscore) import re if not re.match(r'^[a-z0-9_]+$', profile_id): return False, "ID profilo deve contenere solo lettere minuscole, numeri e underscore" # Verifica che ID non esista già if not self.config_manager: self.config_manager = ConfigManager(self.config_path) existing_config = self.config_manager.get_service_config(profile_id, 'CognitiveService') if existing_config: return False, f"Profilo '{profile_id}' esiste già. Scegli un ID diverso." self.new_profile_id = profile_id # 1. Agent prompt non vuoto prompt = self.prompt_editor.toPlainText().strip() if not prompt: return False, "Il prompt della personalità non può essere vuoto" # 2. Temperature range (già limitato da QDoubleSpinBox, ma double-check) temp = self.temperature_spin.value() if not (0.0 <= temp <= 1.0): return False, f"Temperatura deve essere tra 0.0 e 1.0 (attuale: {temp})" # 3. Max tokens range (già limitato da QSpinBox, ma double-check) tokens = self.max_tokens_spin.value() if not (100 <= tokens <= 8192): return False, f"Max tokens deve essere tra 100 e 8192 (attuale: {tokens})" # 4. RAG description (opzionale, ma se presente non troppo breve) rag_desc = self.rag_description_editor.toPlainText().strip() if rag_desc and len(rag_desc) < 10: return False, "Descrizione RAG tool troppo breve (minimo 10 caratteri)" return True, "" def save_config(self): """Salva modifiche su config.yaml (edit) o crea nuovo profilo (create)""" # Validazione valid, error_msg = self.validate_inputs() if not valid: QMessageBox.warning(self, "Validazione Fallita", error_msg) return # Determina profilo target (esistente o nuovo) if self.mode == 'create': target_profile = self.new_profile_id confirm_msg = f"Creare il nuovo profilo '{target_profile}'?\n\nVerrà creato un backup automatico di config.yaml." success_msg = f"Profilo '{target_profile}' creato con successo!" else: target_profile = self.kernel.active_profile confirm_msg = f"Salvare le modifiche al profilo '{target_profile}'?\n\nVerrà creato un backup automatico di config.yaml." success_msg = f"Profilo '{target_profile}' aggiornato con successo!" # Conferma reply = QMessageBox.question( self, "Conferma Salvataggio", confirm_msg, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply != QMessageBox.StandardButton.Yes: return # Raccogli dati updates = { 'agent_prompt': self.prompt_editor.toPlainText(), 'temperature': self.temperature_spin.value(), 'max_tokens': self.max_tokens_spin.value(), 'rag_tool_description': self.rag_description_editor.toPlainText() } # Salva try: if not self.config_manager: self.config_manager = ConfigManager(self.config_path) backup_path = self.config_manager.create_backup() if self.mode == 'create': # Crea nuovo profilo usando metodo dedicato success = self._create_new_profile(target_profile, updates) else: # Aggiorna profilo esistente success = self.config_manager.update_service_config( target_profile, 'CognitiveService', updates ) if success: QMessageBox.information( self, "Successo", f"{success_msg}\n\nBackup creato: {os.path.basename(backup_path)}" ) self.accept() # Chiudi dialog con successo else: QMessageBox.critical(self, "Errore", "Salvataggio fallito") except Exception as e: QMessageBox.critical( self, "Errore Critico", f"Impossibile salvare la configurazione:\n\n{str(e)}" ) def _create_new_profile(self, profile_id: str, config_data: dict) -> bool: """ Crea un nuovo profilo aggiungendolo al config.yaml Args: profile_id: ID del nuovo profilo config_data: Dict con agent_prompt, temperature, etc. Returns: True se successo """ import yaml # Carica config attuale config = self.config_manager.load_config() if 'profiles' not in config: config['profiles'] = {} # Crea struttura completa nuovo profilo (basata su template Aurelio/Warren) new_profile_services = [ { 'service_name': 'FileLoggerService', 'module': 'services.fileloggerservice.fileloggerservice', 'config': { 'log_file_path': f'agents/{profile_id}/logs/jarvis.log' } }, { 'service_name': 'CognitiveService', 'module': 'services.cognitiveservice.cognitiveservice', 'config': { 'model_name': 'claude-haiku-4-5-20251001', 'temperature': config_data['temperature'], 'max_tokens': config_data['max_tokens'], 'memory_messages': 50, 'summary_threshold': 30, 'vectorstore_path': f'agents/{profile_id}/chroma_db', 'docstore_path': f'agents/{profile_id}/doc_store', 'embedding_provider': 'hf', 'embedding_model': 'sentence-transformers/all-MiniLM-L6-v2', 'chat_memory_db': f'agents/{profile_id}/memoria_chat.sqlite', 'rag_tool_name': 'ricerca_knowledge_base', 'rag_tool_description': config_data['rag_tool_description'], 'agent_prompt': config_data['agent_prompt'], 'debug_intermediate_steps': False } }, { 'service_name': 'SchedulerService', 'module': 'services.schedulerservice.schedulerservice', 'config': { 'jobs_config_path': 'config/jobs_office.json' } }, { 'service_name': 'FileSystemWatcherService', 'module': 'services.filesystemwatcherservice.filesystemwatcherservice', 'config': { 'watch_path': f'agents/{profile_id}/inbox', 'patterns': ['*.txt', '*.md', '*.pdf', '*.json'], 'case_sensitive': True } } ] # Aggiungi al config config['profiles'][profile_id] = new_profile_services # Scrivi su disk with open(self.config_path, 'w', encoding='utf-8') as f: yaml.dump( config, f, default_flow_style=False, allow_unicode=True, sort_keys=False, width=120 ) # Crea directory profilo profile_dir = os.path.join( os.path.dirname(self.config_path), '..', 'data', 'agents', profile_id ) os.makedirs(os.path.join(profile_dir, 'chroma_db'), exist_ok=True) os.makedirs(os.path.join(profile_dir, 'doc_store'), exist_ok=True) os.makedirs(os.path.join(profile_dir, 'logs'), exist_ok=True) os.makedirs(os.path.join(profile_dir, 'inbox'), exist_ok=True) return True def apply_stylesheet(self): """Applica stylesheet coerente con l'app""" self.setStyleSheet(""" QDialog { background-color: #f5f5f5; } QLabel { color: #333; font-size: 12px; } QPlainTextEdit { border: 1px solid #ddd; border-radius: 4px; padding: 8px; font-family: 'Courier New', 'Consolas', monospace; font-size: 11px; background-color: white; } QDoubleSpinBox, QSpinBox { border: 1px solid #ddd; border-radius: 4px; padding: 6px; min-width: 100px; background-color: white; } QPushButton { background-color: #4CAF50; color: white; border: none; border-radius: 4px; padding: 10px 20px; font-weight: bold; font-size: 12px; } QPushButton:hover { background-color: #45a049; } QPushButton#cancel_btn { background-color: #999; } QPushButton#cancel_btn:hover { background-color: #777; } QTabWidget::pane { border: 1px solid #ddd; border-radius: 4px; background-color: white; } QTabBar::tab { background-color: #e0e0e0; padding: 10px 20px; margin-right: 2px; border-top-left-radius: 4px; border-top-right-radius: 4px; } QTabBar::tab:selected { background-color: white; border-bottom: 2px solid #4CAF50; } QTabBar::tab:hover { background-color: #d0d0d0; } """) def _create_documents_tab(self) -> QWidget: """Tab gestione documenti RAG del profilo""" widget = QWidget() layout = QVBoxLayout() # Header con info profilo info_label = QLabel(f"Documenti knowledge base per profilo: {self._get_profile_display_name()}") info_label.setStyleSheet("font-weight: bold; font-size: 13px; margin-bottom: 10px;") layout.addWidget(info_label) # Tabella documenti self.documents_table = QTableWidget() self.documents_table.setColumnCount(5) self.documents_table.setHorizontalHeaderLabels([ "Filename", "Autore", "Chunks", "Dimensione", "Indicizzato" ]) self.documents_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self.documents_table.setSelectionMode(QTableWidget.SelectionMode.MultiSelection) self.documents_table.setSortingEnabled(True) # Column widths header = self.documents_table.horizontalHeader() header.setStretchLastSection(False) header.resizeSection(0, 250) # Filename header.resizeSection(1, 150) # Autore header.resizeSection(2, 80) # Chunks header.resizeSection(3, 100) # Dimensione header.resizeSection(4, 120) # Indicizzato layout.addWidget(self.documents_table) # Pulsanti azioni buttons_layout = QHBoxLayout() refresh_btn = QPushButton("🔄 Ricarica Lista") refresh_btn.clicked.connect(self._load_documents_list) buttons_layout.addWidget(refresh_btn) buttons_layout.addStretch() add_btn = QPushButton("➕ Aggiungi PDF") add_btn.setStyleSheet(""" QPushButton { background-color: #4CAF50; color: white; font-weight: bold; padding: 8px 16px; border-radius: 4px; } QPushButton:hover { background-color: #45a049; } """) add_btn.clicked.connect(self._add_document) buttons_layout.addWidget(add_btn) delete_btn = QPushButton("🗑️ Elimina Selezionati") delete_btn.setStyleSheet(""" QPushButton { background-color: #f44336; color: white; font-weight: bold; padding: 8px 16px; border-radius: 4px; } QPushButton:hover { background-color: #da190b; } """) delete_btn.clicked.connect(self._delete_selected_documents) buttons_layout.addWidget(delete_btn) layout.addLayout(buttons_layout) widget.setLayout(layout) # Load initial data self._load_documents_list() return widget def _load_documents_list(self): """Carica lista documenti da RAGDocumentManager""" from services.ragservice.rag_document_manager import RAGDocumentManager # Ottieni project root (3 livelli sopra: ui -> desktop -> Jarvis-Cognitive) project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # Ottieni profile name if self.mode == 'edit': profile_name = self.kernel.active_profile else: # In modalità create, controlla se l'input esiste e ha un valore if not hasattr(self, 'profile_id_input'): return profile_name = self.profile_id_input.text().strip().lower() if not profile_name: return # Carica documenti con gestione errori try: manager = RAGDocumentManager(profile_name, project_root) documents = manager.list_documents() except Exception as e: print(f"Errore caricamento documenti RAG: {e}") import traceback traceback.print_exc() # Tabella vuota in caso di errore self.documents_table.setRowCount(0) return # Popola tabella self.documents_table.setRowCount(0) # Clear self.documents_table.setSortingEnabled(False) # Disable durante insert for doc in documents: row = self.documents_table.rowCount() self.documents_table.insertRow(row) # Filename self.documents_table.setItem(row, 0, QTableWidgetItem(doc.filename)) # Author self.documents_table.setItem(row, 1, QTableWidgetItem(doc.author or "")) # Chunks chunks_item = QTableWidgetItem(str(doc.chunk_count)) chunks_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) self.documents_table.setItem(row, 2, chunks_item) # Size size_mb = doc.file_size / (1024 * 1024) size_item = QTableWidgetItem(f"{size_mb:.1f} MB") size_item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) self.documents_table.setItem(row, 3, size_item) # Date date_str = datetime.fromtimestamp(doc.indexed_date).strftime("%d/%m/%Y") self.documents_table.setItem(row, 4, QTableWidgetItem(date_str)) # Store source_path in hidden data self.documents_table.item(row, 0).setData(Qt.ItemDataRole.UserRole, doc.source_path) self.documents_table.setSortingEnabled(True) def _add_document(self): """Upload nuovo documento PDF""" file_path, _ = QFileDialog.getOpenFileName( self, "Seleziona PDF da indicizzare", "", "PDF Files (*.pdf)" ) if not file_path: return # Ottieni profile name if self.mode == 'edit': profile_name = self.kernel.active_profile else: profile_name = self.profile_id_input.text().strip().lower() # Call indicizza_documenti.py project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) script_path = os.path.join(project_root, 'scripts', 'indicizza_documenti.py') # Progress dialog progress = QProgressDialog("Indicizzazione in corso...", "Annulla", 0, 0, self) progress.setWindowModality(Qt.WindowModality.WindowModal) progress.setCancelButton(None) # No cancel progress.show() try: result = subprocess.run( [sys.executable, script_path, file_path, '--profile', profile_name], capture_output=True, text=True, timeout=300 # 5 min max ) progress.close() if result.returncode == 0: QMessageBox.information( self, "Successo", f"Documento indicizzato con successo!\n\n{os.path.basename(file_path)}" ) self._load_documents_list() # Refresh else: QMessageBox.critical( self, "Errore", f"Indicizzazione fallita:\n\n{result.stderr}" ) except subprocess.TimeoutExpired: progress.close() QMessageBox.warning(self, "Timeout", "Indicizzazione troppo lunga (>5 min)") except Exception as e: progress.close() QMessageBox.critical(self, "Errore", f"Errore durante indicizzazione:\n\n{str(e)}") def _delete_selected_documents(self): """Elimina documenti selezionati""" selected_rows = self.documents_table.selectionModel().selectedRows() if not selected_rows: QMessageBox.warning(self, "Nessuna Selezione", "Seleziona almeno un documento da eliminare") return # Conferma reply = QMessageBox.question( self, "Conferma Eliminazione", f"Eliminare {len(selected_rows)} documento/i selezionato/i?\n\n" "Verranno rimossi: vettori, chunks e file originale.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply != QMessageBox.StandardButton.Yes: return # Ottieni source paths source_paths = [] for row_index in selected_rows: item = self.documents_table.item(row_index.row(), 0) source_path = item.data(Qt.ItemDataRole.UserRole) source_paths.append(source_path) # Delete from services.ragservice.rag_document_manager import RAGDocumentManager project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) if self.mode == 'edit': profile_name = self.kernel.active_profile else: profile_name = self.profile_id_input.text().strip().lower() manager = RAGDocumentManager(profile_name, project_root) deleted_count = 0 for source_path in source_paths: if manager.delete_document(source_path): deleted_count += 1 # Feedback if deleted_count == len(source_paths): QMessageBox.information( self, "Successo", f"{deleted_count} documento/i eliminato/i con successo!" ) else: QMessageBox.warning( self, "Eliminazione Parziale", f"Eliminati {deleted_count}/{len(source_paths)} documenti.\n\n" "Controlla i log per dettagli errori." ) self._load_documents_list() # Refresh def _get_profile_display_name(self) -> str: """Ottieni nome display del profilo""" if self.mode == 'edit': profile_names = { 'aurelio': 'Marco Aurelio', 'warren': 'Warren Mentor' } return profile_names.get(self.kernel.active_profile, self.kernel.active_profile.capitalize()) else: return self.profile_id_input.text().strip().capitalize()