diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,479 +1,1401 @@ import streamlit as st -import time -import google.generativeai as genai -from pydantic import ValidationError -import mimetypes -import os -import settings_ai -from settings_ai import Documento, Articolo import pandas as pd -from PyPDF2 import PdfReader, PdfWriter -import json -from azure.core.credentials import AzureKeyCredential -from azure.ai.documentintelligence import DocumentIntelligenceClient -from azure.ai.documentintelligence.models import AnalyzeDocumentRequest -from streamlit_pdf_viewer import pdf_viewer -import io -from PyPDF2 import PdfReader, PdfWriter -import fitz -import re -import io -from collections import Counter - -st.set_page_config(page_title="Import Fatture AI✨") -st.title("Import Fatture AI ✨") - -# Gestionione LOGIN -if "logged" not in st.session_state: - st.session_state.logged = False - st.session_state.model = "gemini-2.0-flash" -if st.session_state.logged == False: - login_placeholder = st.empty() - with login_placeholder.container(): - container = st.container(border=True) - username = container.text_input('Username') - password = container.text_input('Passowrd', type='password') - login = container.button(' Login ', type='primary') - if not login or username != os.getenv("LOGIN_USER") or password != os.getenv("LOGIN_PASSWORD"): - if login: - st.error('Password Errata') - st.stop() - st.session_state.logged = True - login_placeholder.empty() - -with st.expander("Guida completa"): - st.write("""Questa applicazione Python, basata su Streamlit, integra servizi di intelligenza artificiale di Gemini per automatizzare l'estrazione e la validazione dei dati dalle fatture. Il sistema gestisce documenti in vari formati (PDF, immagini) e li elabora in maniera modulare per facilitare la conversione e la verifica delle informazioni. - -## Funzionalità Principali - -- **Caricamento e Gestione dei Documenti** - - Supporta il caricamento di file PDF, JPG, JPEG e PNG tramite un’interfaccia Streamlit. - - Se il file è un PDF con più pagine, viene suddiviso in sezioni (configurabile tramite uno slider) per una gestione più efficace. Più il numero è basso più il risultato è preciso. - -- **Conversione dei Dati** - - **Upload e Inoltro a Gemini**: I file vengono caricati e inviati al rispettivo servizio AI. - - **Estrazione dei Dati**: Il sistema invia il documento a un modello di generazione AI per ottenere una rappresentazione JSON contenente i dati (ad es. numero di documento, data, totale imponibile e articoli). - -- **Validazione e Verifica** - - **Validazione JSON**: Utilizza Pydantic per verificare la correttezza della struttura e dei dati estratti. In caso di errori, il documento viene riprocessato fino a 3 volte per cercare di correggere le anomalie. - - **Verifica Incrociata dei Contenuti**: Per i PDF, viene estratto il testo con PyPDF2 e confrontato con i codici articolo per assicurarsi che i dati siano effettivamente presenti nel documento. - - **Filtraggio Articoli**: Vengono mantenuti solo gli articoli compatibili con i criteri specifici (codici articolo e importi non nulli). - -- **Visualizzazione e Highlighting** - - I dati validati vengono mostrati in formato tabellare e in JSON. - - Se il documento è un PDF, il sistema evidenzia graficamente (con rettangoli rossi) i testi relativi agli articoli compatibili, semplificando il controllo visivo. - -## Avvertenze per l'Operatore - -- **Controllo Manuale Obbligatorio**: -L'operatore **deve sempre verificare** i dati elaborati. -Nonostante il riprocessamento automatico in caso di errori, è fondamentale controllare la correttezza dei dati estratti e la validità degli articoli. - -- **Validazione dei Dati**: -L'operazione di validazione tramite Pydantic e la verifica incrociata sul contenuto del PDF sono cruciali. -Assicurarsi che non vi siano discrepanze, specialmente nei casi in cui alcuni articoli risultino non verificati. -""") - -st.write("📄 **Legge più PDF:** Carica più file PDF contemporaneamente") -st.write("🤖 **Sfrutta l'AI di Gemini:** Per ogni documento, estrae i dati in formato JSON e in formato tabellare.") -st.write("✅ **Mostra Articoli Compatibili:** Filtra e visualizza solo gli articoli che rispettano i criteri richiesti.") -st.write("🔍 **Anteprima Documento:** Visualizza un'anteprima del documento evidenziando gli articoli compatibili.") - - -GENERATION_CONFIG = settings_ai.GENERATION_CONFIG -SYSTEM_INSTRUCTION = settings_ai.SYSTEM_INSTRUCTION -USER_MESSAGE = settings_ai.USER_MESSAGE -API_KEY_GEMINI = settings_ai.API_KEY_GEMINI - -# Configura il modello Gemini -genai.configure(api_key=API_KEY_GEMINI) -model = genai.GenerativeModel( - model_name=st.session_state.model, - generation_config=GENERATION_CONFIG, - system_instruction=SYSTEM_INSTRUCTION -) - -# Upload File a GEMINI -def upload_to_gemini(path: str, mime_type: str = None): - """Carica un file su Gemini e ne ritorna l'oggetto file.""" - file = genai.upload_file(path, mime_type=mime_type) - print(f"Uploaded file '{file.display_name}' as: {file.uri}") - return file - -# Attesa Upload Files -def wait_for_files_active(files): - """Attende che i file siano nello stato ACTIVE su Gemini.""" - print("Waiting for file processing...") - for name in (f.name for f in files): - file_status = genai.get_file(name) - while file_status.state.name == "PROCESSING": - print(".", end="", flush=True) - time.sleep(10) - file_status = genai.get_file(name) - if file_status.state.name != "ACTIVE": - raise Exception(f"File {file_status.name} failed to process") - print("\n...all files ready") - -# Chiamata API Gemini -def send_message_to_gemini(chat_session, message, max_attempts=5): - """Tenta di inviare il messaggio tramite la chat_session, riprovando fino a max_attempts in caso di eccezioni, con un delay di 10 secondi tra i tentativi. """ - for attempt in range(max_attempts): +import numpy as np +import plotly.express as px +import plotly.graph_objects as go +from io import BytesIO # Importa BytesIO per gestire file in memoria +try: + import scipy.stats # Per correlazione spearman opzionale + SCIPY_AVAILABLE = True +except ImportError: + SCIPY_AVAILABLE = False + # Sposta l'avviso della libreria scipy dopo il caricamento del file, + # così non appare se non viene caricato nessun file. + # st.sidebar.warning("Libreria 'scipy' non trovata...") # Rimosso da qui + + +# --- Configuration --- +st.set_page_config(layout="wide", page_title="Dashboard Analisi Clima") + + +# --- Constants & Helper Functions --- +SCORE_BUCKETS = { + (0, 2.5): "Critico", + (2.5, 4.5): "Neutrale", + (4.5, 7): "Positivo" # Assumendo scala fino a 6, ma 7 copre > 4.5 +} +BUCKET_COLORS = {"Critico": "#d62728", "Neutrale": "#ff7f0e", "Positivo": "#2ca02c"} + +THRESHOLD_LOW = 3.0 # Leggermente aggiustato per bullet chart +THRESHOLD_HIGH = 4.5 # Leggermente aggiustato per bullet chart + +PLOTLY_TEMPLATE = "plotly_white" # "seaborn" #"plotly_dark" # "ggplot2" # "plotly_white" + +def categorize_score(score): + if pd.isna(score): + return "Non Risposto" + # Ajust range slightly to handle edge cases like 2.5 exactly + if 0 <= score <= 2.5: return "Critico" + if 2.5 < score <= 4.5: return "Neutrale" + if 4.5 < score <= 7: return "Positivo" # Assuming max score is around 6 + return "Sconosciuto" # Should not happen with numeric data in expected range + +@st.cache_data +# Modifica la funzione per accettare l'oggetto file caricato invece del percorso +def load_and_prepare_data(uploaded_file_object): + if uploaded_file_object is None: + return None, None, None, None, None, None, None + + try: + # Legge direttamente dall'oggetto file in memoria + # Explicitly try different encodings if default fails try: - print(f"Generazione AI con PROMPT: {message}") - response_local = chat_session.send_message(message) - return response_local - except Exception as e: - print(f"Errore in send_message (tentativo {attempt+1}/{max_attempts}): {e}") - if attempt < max_attempts - 1: - print("Riprovo tra 10 secondi...") - time.sleep(10) - raise RuntimeError(f"Invio messaggio fallito dopo {max_attempts} tentativi.") - -# Unisce i rettangoli evidenziati (se codice e descrizione stanno su linee diverse viene mostrato un solo rettangolo evidenziato) -def merge_intervals(intervals): - """Unisce gli intervalli sovrapposti. Gli intervalli sono tuple (y0, y1).""" - if not intervals: - return [] - intervals.sort(key=lambda x: x[0]) - merged = [intervals[0]] - for current in intervals[1:]: - last = merged[-1] - if current[0] <= last[1]: - merged[-1] = (last[0], max(last[1], current[1])) - else: - merged.append(current) - return merged - -# Evidenzia le corrispondenze -def highlight_text_in_pdf(input_pdf_bytes, text_list): - """Crea rettangoli rossi che evidenziano i testi trovati, unendo gli intervalli sovrapposti in un unico rettangolo. """ - pdf_document = fitz.open(stream=input_pdf_bytes, filetype="pdf") - patterns = [] - for text in text_list: - text_pattern = re.escape(text).replace(r'\ ', r'\s*') - patterns.append(text_pattern) - pattern = r'\b(' + '|'.join(patterns) + r')\b' - regex = re.compile(pattern, re.IGNORECASE) - highlight_color = (1, 0, 0) # rosso - for page in pdf_document: - page_text = page.get_text() - matches = list(regex.finditer(page_text)) - intervals = [] - for match in matches: - match_text = match.group(0) - text_instances = page.search_for(match_text) - for inst in text_instances: - text_height = inst.y1 - inst.y0 - new_y0 = inst.y0 - 0.1 * text_height - new_y1 = inst.y1 + 0.1 * text_height - intervals.append((new_y0, new_y1)) - merged_intervals = merge_intervals(intervals) - for y0, y1 in merged_intervals: - full_width_rect = fitz.Rect(page.rect.x0, y0, page.rect.x1, y1) - rect_annot = page.add_rect_annot(full_width_rect) - rect_annot.set_colors(stroke=highlight_color, fill=highlight_color) - rect_annot.set_opacity(0.15) - rect_annot.update() - output_stream = io.BytesIO() - pdf_document.save(output_stream) - pdf_document.close() - output_stream.seek(0) - return output_stream - -# Formattazione Euro -def format_euro(amount): - formatted = f"{amount:,.2f}" - formatted = formatted.replace(",", "X").replace(".", ",").replace("X", ".") - return f"€ {formatted}" - -# Testo da PDF -def pdf_to_text(path_file: str) -> str: - """ Estrae e concatena il testo da tutte le pagine del PDF. """ - reader = PdfReader(path_file) - full_text = "" - for page in reader.pages: - page_text = page.extract_text() or "" - full_text += page_text + "\n" - return full_text - -# Funzione che verifica se gli articoli sono corretti (facendo un partsing PDF to TEXT) -def verify_articles(file_path: str, chunk_document): - ''' La funzione trasforma il PDF in TESTO e cerca se ogni articolo è presente (al netto degli spazi) ''' - if not file_path.lower().endswith(".pdf"): - for articolo in chunk_document.Articoli: - articolo.Verificato = 2 - return None - pdf_text = pdf_to_text(file_path) - if '□' in pdf_text: - for articolo in chunk_document.Articoli: - articolo.Verificato = 2 - return None - for articolo in chunk_document.Articoli: - articolo.Verificato = 1 if articolo.CodiceArticolo and (articolo.CodiceArticolo in pdf_text) else 0 - if articolo.Verificato == 0: - articolo.Verificato = 1 if articolo.CodiceArticolo and (articolo.CodiceArticolo.replace(" ", "") in pdf_text.replace(" ", "")) else 0 - if not any(articolo.Verificato == 0 for articolo in chunk_document.Articoli): - return None - unverified_articles = [articolo for articolo in chunk_document.Articoli if articolo.Verificato == 0] - json_unverified_articles = json.dumps([articolo.model_dump() for articolo in unverified_articles]) - return json_unverified_articles - -# Funzione ausiliaria che elabora un file (o un chunk) inviandolo tramite send_message_to_gemini -def process_document_splitted(file_path: str, chunk_label: str, use_azure: bool = False) -> Documento: - """ Elabora il file (o il chunk) inviandolo a Gemini e processando la risposta: - - Determina il mime type. - - Effettua l'upload tramite upload_to_gemini e attende che il file sia attivo. - - Avvia una chat con il file caricato e invia il messaggio utente. - - Tenta fino a 3 volte di validare il JSON ottenuto, filtrando gli Articoli. - - Se presenti errori, RIPROCESSA il documento 3 volta passando il risultato precedente, In questo modo riesce a gestire gli errori in modo più preciso! - Ritorna l'istanza di Documento validata. """ - mime_type, _ = mimetypes.guess_type(file_path) - if mime_type is None: - mime_type = "application/octet-stream" - if not use_azure: - files = [upload_to_gemini(file_path, mime_type=mime_type)] - wait_for_files_active(files) - chat_history = [{ "role": "user","parts": [files[0]]}] - for attempt in range(3): + # Usa BytesIO per permettere a read_csv di rileggere se necessario + file_content = BytesIO(uploaded_file_object.getvalue()) + df_orig = pd.read_csv(file_content, delimiter=';', encoding='utf-8') + except UnicodeDecodeError: try: - chat_session = model.start_chat(history=chat_history) - break - except Exception as e: - print(f"Errore nello Start chat") - time.sleep(10) - - max_validation_attempts = 3 - max_number_reprocess = 3 - chunk_document = None - - for i in range(max_number_reprocess): - print(f"Reprocessamento {i+1} di {max_number_reprocess} per il chunk {chunk_label}") - response = None - for attempt in range(max_validation_attempts): - message = USER_MESSAGE - if i > 0: - message += f". Attenzione, RIPROVA perché i seguenti articoli sono da ESCLUDERE in quanto ERRATI! {json_unverified_articles}" - if not use_azure: - response = send_message_to_gemini(chat_session, message) + file_content.seek(0) # Riavvolgi il buffer + df_orig = pd.read_csv(file_content, delimiter=';', encoding='latin-1') + except UnicodeDecodeError: + file_content.seek(0) # Riavvolgi il buffer + df_orig = pd.read_csv(file_content, delimiter=';', encoding='iso-8859-1') + + # Rimuovi FileNotFoundError dato che non usiamo più un percorso fisso + # except FileNotFoundError: + # st.error(f"Errore: File non trovato...") # Rimosso + # return None, None, None, None, None, None, None + except Exception as e: + st.error(f"Errore durante la lettura del CSV caricato: {e}") + return None, None, None, None, None, None, None + + # --- Il resto della funzione di preparazione dati rimane invariato --- + original_columns = df_orig.columns.tolist() + unnamed_cols = [col for col in df_orig.columns if str(col).startswith('Unnamed:')] + df = df_orig.drop(columns=unnamed_cols) + cleaned_original_columns = df.columns.tolist() # Update after drop + + header_row_index = 0 # Assuming header is the first row after loading + new_header = df.iloc[header_row_index].tolist() + df = df[header_row_index + 1:].reset_index(drop=True) + + # Clean the header: replace NaN/None with placeholders, ensure strings, strip whitespace + cleaned_header = [] + for i, col in enumerate(new_header): + col_str = str(col).strip() if pd.notna(col) else "" + if not col_str: # If empty after stripping + if i < len(cleaned_original_columns) and not cleaned_original_columns[i].startswith('Unnamed:'): + cleaned_header.append(str(cleaned_original_columns[i]).strip()) # Use original name if meaningful else: - chunk_document = analyze_invoice_azure(file_path) - try: - if not use_azure: - chunk_document = Documento.model_validate_json(response.text) - chunk_document.Articoli = [ - art for art in chunk_document.Articoli - if art.CodiceArticolo.startswith(("AVE", "AV", "3V", "44")) - and art.TotaleNonIvato != 0 - and art.CodiceArticolo not in ("AVE", "AV", "3V", "44") - ] - break - except ValidationError as ve: - print(f"Errore di validazione {chunk_label} (tentativo {attempt+1}/{max_validation_attempts}): {ve}") - if attempt < max_validation_attempts - 1: - print("Riprovo tra 5 secondi...") - time.sleep(5) - else: - raise RuntimeError(f"Superato il numero massimo di tentativi di validazione {chunk_label}.") - - json_unverified_articles = verify_articles(file_path, chunk_document) - if not json_unverified_articles: - return chunk_document - return chunk_document - -# Funzione principale che elabora il documento -def process_document(path_file: str, number_pages_split: int, use_azure: bool = False) -> Documento: - """ Elabora il documento in base al tipo di file: - 1. Se il file non è un PDF, lo tratta "così com'è" (ad esempio, come immagine) e ne processa il contenuto tramite process_document_splitted. - 2. Se il file è un PDF e contiene più di 5 pagine, lo divide in chunk da 5 pagine. - Per ogni chunk viene effettuato l’upload, viene elaborato il JSON e validato. - I chunk successivi vengono aggregati nel Documento finale e, se il PDF ha più - di 5 pagine, al termine il campo TotaleMerce viene aggiornato con il valore riportato dall’ultimo chunk. """ - mime_type, _ = mimetypes.guess_type(path_file) - if mime_type is None: - mime_type = "application/octet-stream" - if use_azure: - number_pages_split = 1 - if not path_file.lower().endswith(".pdf"): - print("File non PDF: elaborazione come immagine.") - documento_finale = process_document_splitted(path_file, chunk_label="(immagine)", use_azure=use_azure) - return documento_finale - - reader = PdfReader(path_file) - total_pages = len(reader.pages) - documento_finale = None - ultimo_totale_merce = None - - for chunk_index in range(0, total_pages, number_pages_split): - writer = PdfWriter() - for page in reader.pages[chunk_index:chunk_index + number_pages_split]: - writer.add_page(page) - temp_filename = "temp_chunk_" - if use_azure: - temp_filename+="azure_" - temp_filename += f"{chunk_index // number_pages_split}.pdf" - with open(temp_filename, "wb") as temp_file: - writer.write(temp_file) - chunk_label = f"(chunk {chunk_index // number_pages_split})" - chunk_document = process_document_splitted(temp_filename, chunk_label=chunk_label, use_azure=use_azure) - if hasattr(chunk_document, "TotaleImponibile"): - ultimo_totale_merce = chunk_document.TotaleImponibile - if documento_finale is None: - documento_finale = chunk_document + cleaned_header.append(f"Colonna_Sconosciuta_{i}") # Placeholder + else: + cleaned_header.append(col_str) + + # *** START: Enhanced Duplicate Column Handling *** + counts = {} + final_header = [] + original_to_final_map = {} # Map original cleaned name to final unique name + + for i, col_name in enumerate(cleaned_header): + original_name = col_name # Keep track of the name before potential suffix + if col_name in counts: + counts[col_name] += 1 + new_name = f"{col_name}_{counts[col_name]}" + final_header.append(new_name) + # Store mapping if original name was intended as a question + # Heuristic: assume non-demographic columns are potential questions + if i >= 3: # Assuming first 3 are demo - adjust if needed + original_to_final_map[original_name] = original_to_final_map.get(original_name, []) + [new_name] else: - documento_finale.Articoli.extend(chunk_document.Articoli) - os.remove(temp_filename) - - if total_pages > number_pages_split and ultimo_totale_merce is not None: - documento_finale.TotaleImponibile = ultimo_totale_merce - if documento_finale is None: - raise RuntimeError("Nessun documento elaborato.") - - # Controlli aggiuntivi: Se esiste un AVE non possono esistere altri articoli non ave. - if any(articolo.CodiceArticolo.startswith("AVE") for articolo in documento_finale.Articoli): - documento_finale.Articoli = [articolo for articolo in documento_finale.Articoli if articolo.CodiceArticolo.startswith("AVE")] - # Controllo occorrenze di doppioni - if path_file.lower().endswith(".pdf"): - pdf_text = pdf_to_text(path_file) - pdf_text = pdf_text.replace(" ", "") - occorrenze = {} - for articolo in documento_finale.Articoli: - codice_clean = articolo.CodiceArticolo.replace(" ", "") - if codice_clean not in occorrenze: - occorrenze[codice_clean] = pdf_text.count(codice_clean) - articoli_contati = {} - for articolo in documento_finale.Articoli: - codice_clean = articolo.CodiceArticolo.replace(" ", "") - if codice_clean in pdf_text: - print(codice_clean) - print(occorrenze[codice_clean]) - articoli_contati[codice_clean] = articoli_contati.get(codice_clean, 0) + 1 - if articoli_contati[codice_clean] <= occorrenze.get(codice_clean, 0): - articolo.Verificato = True + counts[col_name] = 0 + final_header.append(col_name) + if i >= 3: + original_to_final_map[original_name] = [col_name] # First occurrence + + df.columns = final_header + # *** END: Enhanced Duplicate Column Handling *** + + + # --- Category Mapping --- + def get_category_from_original(original_col_name, potential_category_source): + col_name_str = str(original_col_name).strip() + source_str = str(potential_category_source).strip() + if pd.notna(potential_category_source) and not source_str.isdigit() and 'domanda' not in source_str.lower(): + base_name = source_str.split('.')[0].strip() + if base_name: return base_name + if '.' in col_name_str: + base_name = col_name_str.split('.')[0].strip() + suffix = col_name_str.split('.')[-1] + if suffix.isdigit(): + if base_name: return base_name + elif not col_name_str.isdigit() and 'domanda' not in col_name_str.lower(): + if col_name_str: return col_name_str + return "Categoria Sconosciuta" + + question_to_category_map = {} + demographic_indices = list(range(min(3, len(final_header)))) # Safer range for demo indices + + for i, final_col_name in enumerate(final_header): + if i not in demographic_indices: + # Find the original cleaned header name before potential suffix was added + original_cleaned_name = final_col_name + if '_' in final_col_name: + parts = final_col_name.rsplit('_', 1) + if parts[1].isdigit() and int(parts[1]) == counts.get(parts[0], -1): + original_cleaned_name = parts[0] + + # Use original column name from the CSV *before* taking row 0 as header for category inference + original_csv_col = cleaned_original_columns[i] if i < len(cleaned_original_columns) else original_cleaned_name + category = get_category_from_original(original_csv_col, original_csv_col) + category = category.replace("Parità di genere", "Parità Genere") + question_to_category_map[final_col_name] = category # Map the *final unique* column name + + # --- Demographic Columns --- + demographic_map = {} + if len(final_header) > 0: demographic_map[final_header[0]] = 'Genere' + if len(final_header) > 1: demographic_map[final_header[1]] = 'Fascia_Eta' + if len(final_header) > 2: demographic_map[final_header[2]] = 'Sede' + + # Check if default demo columns actually exist before renaming + valid_demo_map = {k: v for k, v in demographic_map.items() if k in df.columns} + df.rename(columns=valid_demo_map, inplace=True) + demographic_cols = list(valid_demo_map.values()) + + # Filter out potential summary rows + if 'Sede' in df.columns: + anomalous_sede = ['Media', 'Mediana', 'Media sezione', 'Totale', 'Scarto quadratico medio'] + df = df[~df['Sede'].astype(str).str.strip().str.lower().isin([s.lower() for s in anomalous_sede])] + + # Fill missing demographic data + for col in demographic_cols: + if col in df.columns: + df[col] = df[col].astype(str).fillna('Non specificato').replace(['nan', 'None', ''], 'Non specificato') + + + # Identify question columns based on the map (using final unique names) + question_cols = list(question_to_category_map.keys()) + question_cols = [col for col in question_cols if col in df.columns] # Ensure they exist + + + # --- Type Conversion --- + for col in question_cols: + if df[col].dtype == 'object': + df[col] = df[col].astype(str).str.replace(',', '.', regex=False) + df[col] = df[col].replace(['nan', 'N/A', '', '-', 'None'], np.nan, regex=False) + df[col] = pd.to_numeric(df[col], errors='coerce') + + + numeric_question_cols = df[question_cols].select_dtypes(include=np.number).columns.tolist() + + # Determine response scale dynamically + response_scale = (1, 6) # Default fallback + if numeric_question_cols: + valid_numeric_cols = [col for col in numeric_question_cols if col in df.columns] + if valid_numeric_cols: + # Drop rows where ALL numeric questions are NaN before calculating min/max + df_numeric_only = df[valid_numeric_cols].dropna(how='all') + if not df_numeric_only.empty: + min_val = df_numeric_only.min(skipna=True).min(skipna=True) + max_val = df_numeric_only.max(skipna=True).max(skipna=True) + if pd.notna(min_val) and pd.notna(max_val): + response_scale = (min_val, max_val) + + + # --- Identify Overall Satisfaction Question --- + overall_satisfaction_question = None + possible_satisfaction_cats = ['Riepilogo', 'Generale', 'Soddisfazione Complessiva'] + # Use final unique names from numeric_question_cols + possible_satisfaction_cols = [q for q in numeric_question_cols + if question_to_category_map.get(q) in possible_satisfaction_cats] + + if possible_satisfaction_cols: + overall_satisfaction_question = possible_satisfaction_cols[0] + else: + keywords = ['soddisfazione', 'complessivamente', 'generale', 'valutazione'] + for q in numeric_question_cols: + # Check original cleaned name for keywords if available, else the final name + original_cleaned_name = q.rsplit('_', 1)[0] if '_' in q and q.rsplit('_', 1)[1].isdigit() else q + q_check = original_cleaned_name.lower() # Check original name primarily + if any(keyword in q_check for keyword in keywords): + overall_satisfaction_question = q # Assign the final unique name + st.info(f"Domanda soddisfazione generale identificata: '{q}' (basata su '{original_cleaned_name}')") + break + + if not overall_satisfaction_question and numeric_question_cols: + st.warning("Impossibile identificare automaticamente la domanda sulla soddisfazione generale. Alcune analisi potrebbero essere limitate.") + + + return df, demographic_cols, question_cols, question_to_category_map, numeric_question_cols, response_scale, overall_satisfaction_question + +# --- Inizio Script Principale --- + +# Aggiungi il widget per caricare il file +st.sidebar.title('Sondaggio') +uploaded_file = st.sidebar.file_uploader("Carica il tuo file CSV", type="csv") +st.sidebar.divider() +# Procedi solo se un file è stato caricato +if uploaded_file is not None: + + # Sposta l'avviso della libreria scipy qui, così appare solo se si procede + if not SCIPY_AVAILABLE: + st.sidebar.warning("Libreria 'scipy' non trovata. La correlazione Spearman non sarà disponibile. Installa con: pip install scipy") + + + # --- Load Data --- + # Chiama la funzione di caricamento passando l'oggetto file caricato + try: + df_full, demographic_cols, question_cols, question_to_category_map, numeric_question_cols, response_scale, overall_satisfaction_question = load_and_prepare_data(uploaded_file) + + if df_full is None: + st.error("Caricamento o preparazione dati fallito. Controlla il file CSV.") + st.stop() # Ferma l'esecuzione se il caricamento fallisce + elif df_full.empty: + st.warning("Il file CSV caricato risulta vuoto dopo la pulizia iniziale.") + # Si potrebbe fermare qui o continuare mostrando avvisi di dati vuoti + # st.stop() + + except Exception as e: + st.error(f"Errore critico durante l'inizializzazione dei dati dal file caricato: {e}") + st.exception(e) # Stampa traceback completo per debug + st.stop() # Ferma l'esecuzione in caso di errore critico + + # --- DA QUI IN POI, IL CODICE DEL DASHBOARD RIMANE INVARIATO --- + # --- MA VIENE ESEGUITO SOLO SE uploaded_file IS NOT None --- + + # --- App Title --- + st.title("🚀 Dashboard Analisi Clima") + + # ============================================================================== + # --- Sidebar --- + # ============================================================================== + st.sidebar.title("Filtri & Controlli") + st.sidebar.subheader("👤 Filtri Demografici") + + selected_filters = {} + if demographic_cols: + # Use df_full for filter options to show all possibilities + for demo_col in demographic_cols: + # Ensure the column exists in df_full before creating filter + if demo_col in df_full.columns: + unique_values = sorted(df_full[demo_col].astype(str).unique()) + if len(unique_values) > 1: + selected_filters[demo_col] = st.sidebar.multiselect( + f"{demo_col}", + options=unique_values, + default=unique_values + ) else: - articolo.Verificato = False + # If only one value, no need for multiselect, just store it + selected_filters[demo_col] = unique_values else: - articolo.Verificato = False - return documento_finale - -# Analizza Fattura con AZURE -def analyze_invoice_azure(file_path: str): - """Invia il file (dal percorso specificato) al servizio prebuilt-invoice e restituisce il risultato dell'analisi.""" - # Apri il file in modalità binaria e leggi il contenuto - with open(file_path, "rb") as file: - file_data = file.read() - client = DocumentIntelligenceClient(endpoint=settings_ai.ENDPOINT_AZURE, credential=AzureKeyCredential(settings_ai.API_AZURE)) - poller = client.begin_analyze_document("prebuilt-invoice", body=file_data) - result = poller.result() - return parse_invoice_to_documento_azure(result) - -# Parsing Fattura con AZURE -def parse_invoice_to_documento_azure(result) -> Documento: - """ Parssa il risultato dell'analisi e mappa i campi rilevanti nel modello Pydantic Documento. """ - if not result.documents: - raise ValueError("Nessun documento analizzato trovato.") - invoice = result.documents[0] - invoice_id_field = invoice.fields.get("InvoiceId") - numero_documento = invoice_id_field.value_string if invoice_id_field and invoice_id_field.value_string else "" - invoice_date_field = invoice.fields.get("InvoiceDate") - data_str = invoice_date_field.value_date.isoformat() if invoice_date_field and invoice_date_field.value_date else "" - subtotal_field = invoice.fields.get("SubTotal") - if subtotal_field and subtotal_field.value_currency: - totale_imponibile = subtotal_field.value_currency.amount + st.sidebar.warning(f"Colonna demografica '{demo_col}' definita ma non trovata nel DataFrame.") + + + # Apply filters - start from df_full each time filters change + df_filtered = df_full.copy() + for col, selected_values in selected_filters.items(): + # Check if the column exists in df_filtered before applying the filter + if col in df_filtered.columns and selected_values: + # Ensure selected_values are strings for comparison if the column is string + if df_filtered[col].dtype == 'object': + selected_values_str = [str(v) for v in selected_values] + df_filtered = df_filtered[df_filtered[col].astype(str).isin(selected_values_str)] + else: # Keep original type for non-object columns if filtering is needed + df_filtered = df_filtered[df_filtered[col].isin(selected_values)] + else: - invoice_total_field = invoice.fields.get("InvoiceTotal") - totale_imponibile = invoice_total_field.value_currency.amount if invoice_total_field and invoice_total_field.value_currency else 0.0 - articoli = [] - items_field = invoice.fields.get("Items") - if items_field and items_field.value_array: - for item in items_field.value_array: - product_code_field = item.value_object.get("ProductCode") - description_field = str(item.value_object.get("Description").get("content")) - if not description_field: - description_field = "" - codice_articolo = product_code_field.value_string if product_code_field and product_code_field.value_string else "" - amount_field = item.value_object.get("Amount") - totale_non_ivato = amount_field.value_currency.amount if amount_field and amount_field.value_currency else 0.0 - articolo = Articolo( - CodiceArticolo=codice_articolo, - DescrizioneArticolo=description_field, - TotaleNonIvato=totale_non_ivato, - Verificato=None - ) - articoli.append(articolo) - - documento = Documento( - TipoDocumento="Fattura", - NumeroDocumento=numero_documento, - Data=data_str, - TotaleImponibile=totale_imponibile, - Articoli=articoli - ) - return documento - -# Front-End con Streamlit -def main(): - #st.set_page_config(page_title="Import Fatture AI", page_icon="✨") - st.sidebar.title("Caricamento File") - uploaded_files = st.sidebar.file_uploader("Seleziona uno o più PDF", type=["pdf", "jpg", "jpeg", "png"], accept_multiple_files=True) - model_ai = st.sidebar.selectbox("Modello", ['Gemini Flash 2.0', 'Gemini 2.5 Pro', 'Azure Intelligence']) - if model_ai == 'Gemini 2.5 Pro': - st.session_state.model = "gemini-2.5-pro-exp-03-25" - use_azure = True if model_ai == 'Azure Intelligence' else False - number_pages_split = st.sidebar.slider('Split Pagine', 1, 30, 1, help="Numero suddivisione pagine del PDF. Più il numero è basso e più il modello AI è preciso, più è alto più è veloce") - if st.sidebar.button("Importa", type="primary", use_container_width=True): - if not uploaded_files: - st.warning("Nessun file caricato!") + st.sidebar.warning("Nessuna colonna demografica valida trovata per i filtri.") + df_filtered = df_full.copy() if df_full is not None else pd.DataFrame() # Use full data if available, else empty + + + st.sidebar.divider() + st.sidebar.subheader("📊 Metriche Chiave (Filtrate)") + + # Recalculate total respondents after filtering + total_respondents_filtered = len(df_filtered) if df_filtered is not None else 0 + st.sidebar.metric("Rispondenti Filtrati", total_respondents_filtered) + + + # --- Calculate metrics only if df_filtered is not empty --- + avg_overall_filtered = np.nan + avg_scores_per_category_f = pd.Series(dtype=float) + driver_df = pd.DataFrame() # Initialize empty driver dataframe + + # Default correlation method + corr_method_sidebar = 'pearson' + if SCIPY_AVAILABLE: + corr_method_sidebar = 'spearman' # Prefer Spearman if scipy is available + + + if df_filtered is not None and not df_filtered.empty and numeric_question_cols: + # Ensure overall satisfaction question exists in the filtered numeric columns + if overall_satisfaction_question and overall_satisfaction_question in df_filtered.columns and pd.api.types.is_numeric_dtype(df_filtered[overall_satisfaction_question]): + overall_sat_data = df_filtered[overall_satisfaction_question].dropna() + if not overall_sat_data.empty: + avg_overall_filtered = overall_sat_data.mean() + midpoint = (response_scale[0] + response_scale[1]) / 2 if response_scale else 3.5 # Fallback midpoint + delta_vs_mid = avg_overall_filtered - midpoint + st.sidebar.metric("Soddisfazione Generale Media", f"{avg_overall_filtered:.2f}", f"{delta_vs_mid:+.2f} vs Midpoint ({midpoint:.1f})") + else: + st.sidebar.metric("Soddisfazione Generale Media", "N/D (no data)") + + else: + st.sidebar.metric("Soddisfazione Generale Media", "N/D (Domanda non trovata/valida)") + + + # Calculate category averages on filtered data + numeric_cols_in_filtered = [col for col in numeric_question_cols if col in df_filtered.columns] + if numeric_cols_in_filtered: + avg_scores_per_question_f = df_filtered[numeric_cols_in_filtered].mean(axis=0, skipna=True) + df_avg_scores_f = pd.DataFrame({'Domanda': avg_scores_per_question_f.index, 'Punteggio Medio': avg_scores_per_question_f.values}) + df_avg_scores_f['Categoria'] = df_avg_scores_f['Domanda'].map(question_to_category_map).fillna("Senza Categoria") + df_avg_scores_f.dropna(subset=['Punteggio Medio'], inplace=True) + + if not df_avg_scores_f.empty: + # Exclude "Senza Categoria" from min/max display if desired + avg_scores_valid_cat = df_avg_scores_f[df_avg_scores_f['Categoria'] != "Senza Categoria"] + if not avg_scores_valid_cat.empty: + avg_scores_per_category_f = avg_scores_valid_cat.groupby('Categoria')['Punteggio Medio'].mean().sort_values() + + if not avg_scores_per_category_f.empty: + min_cat_score = avg_scores_per_category_f.iloc[0] + max_cat_score = avg_scores_per_category_f.iloc[-1] + delta_min = f"{min_cat_score - avg_overall_filtered:.2f} vs Sod. Gen." if not np.isnan(avg_overall_filtered) else None + delta_max = f"{max_cat_score - avg_overall_filtered:.2f} vs Sod. Gen." if not np.isnan(avg_overall_filtered) else None + + st.sidebar.metric(f"⚠️ Cat. Punteggio MIN", f"{avg_scores_per_category_f.index[0]} ({min_cat_score:.2f})", delta_min, delta_color="inverse") + st.sidebar.metric(f"✅ Cat. Punteggio MAX", f"{avg_scores_per_category_f.index[-1]} ({max_cat_score:.2f})", delta_max, delta_color="normal") + else: + st.sidebar.text("N/D per Categorie (Vuote dopo agg.)") + else: + st.sidebar.text("N/D per Categorie (Solo 'Senza Cat.')") + else: + st.sidebar.text("N/D per Categorie (No medie domande)") + else: + st.sidebar.text("N/D per Categorie (No colonne numeriche)") + + + # --- Calculate Driver Data (Correlation) --- + if overall_satisfaction_question and overall_satisfaction_question in df_filtered.columns and pd.api.types.is_numeric_dtype(df_filtered[overall_satisfaction_question]): + # Ensure overall satisfaction has variance + if df_filtered[overall_satisfaction_question].nunique(dropna=True) > 1: + driver_candidate_cols = [col for col in numeric_cols_in_filtered if col != overall_satisfaction_question and df_filtered[col].nunique(dropna=True) > 1] + if driver_candidate_cols: + try: + # Calculate correlations + correlations = df_filtered[driver_candidate_cols].corrwith(df_filtered[overall_satisfaction_question], method=corr_method_sidebar).dropna() + + # Calculate average scores for the same candidates + avg_scores_drivers = df_filtered[driver_candidate_cols].mean(skipna=True) + + # Combine into driver_df + if not correlations.empty: + driver_df = pd.DataFrame({'Correlazione': correlations}) + # Add avg scores safely, aligning index + driver_df = driver_df.join(avg_scores_drivers.rename('Punteggio Medio'), how='inner') # Inner join ensures only questions with both corr and avg score remain + + if not driver_df.empty: + driver_df['Categoria'] = driver_df.index.map(question_to_category_map).fillna("Senza Categoria") + driver_df.dropna(subset=['Categoria', 'Correlazione', 'Punteggio Medio'], inplace=True) # Drop if essential data missing + if not driver_df.empty: + driver_df['Domanda'] = driver_df.index + driver_df['Domanda_Breve'] = driver_df['Domanda'].apply(lambda x: str(x)[:47] + "..." if len(str(x)) > 50 else str(x)) + driver_df['Correlazione_Abs'] = driver_df['Correlazione'].abs() + else: + driver_df = pd.DataFrame() # Ensure it's empty if join fails + else: + st.sidebar.info("Nessuna correlazione significativa calcolata per i driver.") + + except Exception as e: + st.sidebar.warning(f"Errore nel calcolo correlazioni driver: {e}") + else: + st.sidebar.info("Nessuna domanda candidata (con varianza) trovata per l'analisi driver.") + else: + st.sidebar.info("La domanda di soddisfazione generale non ha varianza nei dati filtrati.") + + + else: # If df_filtered is empty or no numeric questions + st.sidebar.text("Dati insufficienti o non disponibili per le metriche.") + if total_respondents_filtered == 0: + st.sidebar.text("Nessun rispondente selezionato.") + st.sidebar.metric("Soddisfazione Generale Media", "N/D") + st.sidebar.text("N/D per Categorie") + + + st.sidebar.divider() + st.sidebar.info("Utilizza i filtri per esplorare i dati. Le metriche e i grafici si aggiornano dinamicamente.") + + # ============================================================================== + # --- Create Tabs --- + # ============================================================================== + tab_list = [ + "🎯 Sintesi Chiave", + "🗺️ Mappa Domande", # New Tab for Question Map + "👥 Demografia Dettagliata", + "📊 Generale & Categorie", + "🔍 Confronti & Driver", + "📈 Grafici Avanzati" + ] + tabs = st.tabs(tab_list) + + # Assign tabs to variables dynamically for easier access + tab_summary = tabs[0] + tab_map = tabs[1] + tab_demo = tabs[2] + tab_overall = tabs[3] + tab_comp = tabs[4] + tab_advanced = tabs[5] + + # ============================================================================== + # --- TAB Summary: Key Takeaways --- + # ============================================================================== + with tab_summary: + # Content remains largely the same, but relies on variables calculated in sidebar + st.header("🎯 Sintesi Chiave (Basata sui Filtri Correnti)") + + if df_filtered is None or df_filtered.empty: + st.warning("Nessun dato disponibile con i filtri selezionati.") + else: + st.markdown(f"Analisi basata su **{total_respondents_filtered}** rispondenti.") + col_s1, col_s2, col_s3 = st.columns([2, 1, 1]) # Adjusted columns for gauge + + with col_s1: + st.subheader("Punti Salienti:") + if not np.isnan(avg_overall_filtered): + max_scale = response_scale[1] if response_scale else 6 # Fallback max scale + st.markdown(f"- **Soddisfazione Generale:** {avg_overall_filtered:.2f} / {max_scale:.0f}") + else: + st.markdown(f"- **Soddisfazione Generale:** N/D") + + if not avg_scores_per_category_f.empty: + st.markdown(f"- **Area Più Forte:** {avg_scores_per_category_f.index[-1]} (Media: {avg_scores_per_category_f.iloc[-1]:.2f})") + st.markdown(f"- **Area Più Debole:** {avg_scores_per_category_f.index[0]} (Media: {avg_scores_per_category_f.iloc[0]:.2f})") + else: + st.markdown("- Dati categorie non disponibili.") + + # Driver info from pre-calculated driver_df + if not driver_df.empty: + try: + # Top positive driver + top_driver = driver_df.sort_values('Correlazione', ascending=False).iloc[0] + st.markdown(f"- **Driver Positivo Principale:** {top_driver['Domanda_Breve']} (Corr: {top_driver['Correlazione']:.2f})") + + # Top area for improvement (high correlation, low score) - using dynamic means + avg_corr_summary = driver_df['Correlazione'].mean() + avg_score_summary = driver_df['Punteggio Medio'].mean() + potential_improvement_df = driver_df[(driver_df['Correlazione'] > avg_corr_summary) & (driver_df['Punteggio Medio'] < avg_score_summary)] + if not potential_improvement_df.empty: + potential_improvement = potential_improvement_df.sort_values('Punteggio Medio').iloc[0] # Lowest score among high-impact, low-perf + st.markdown(f"- **Focus Miglioramento:** {potential_improvement['Domanda_Breve']} (Score: {potential_improvement['Punteggio Medio']:.2f}, Corr: {potential_improvement['Correlazione']:.2f})") + else: + st.markdown("- *Focus Miglioramento:* (Nessun driver critico trovato con medie correnti)") + + except IndexError: + st.markdown("- *Driver Principali:* (Errore nell'accesso ai dati driver)") + except Exception as e: + st.markdown(f"- *Driver Principali:* (Errore: {e})") + else: + st.markdown("- *Driver Principali:* (Dati non disponibili o insufficienti)") + + + with col_s2: + st.subheader("Sentiment") # Combined Pie and Gauge + if overall_satisfaction_question and overall_satisfaction_question in df_filtered.columns: + overall_satisfaction_data_f = df_filtered[overall_satisfaction_question].dropna() + if pd.api.types.is_numeric_dtype(overall_satisfaction_data_f) and not overall_satisfaction_data_f.empty: + # Sentiment Pie Chart + bucket_counts = overall_satisfaction_data_f.apply(categorize_score).value_counts() + # Add 'Non Risposto' if it exists + # non_risposto_count = df_filtered[overall_satisfaction_question].isna().sum() # Needs careful handling if mixing counts and percentages + bucket_counts = bucket_counts.reindex(list(BUCKET_COLORS.keys()) + ["Non Risposto"], fill_value=0) # Ensure all buckets + Non Risposto + bucket_perc = (bucket_counts / bucket_counts.sum() * 100) if bucket_counts.sum() > 0 else bucket_counts + + # Define colors including for "Non Risposto" + plot_colors = BUCKET_COLORS.copy() + plot_colors["Non Risposto"] = "#bbbbbb" # Grey for non-responded + + fig_sentiment_pie = px.pie(values=bucket_perc.values, names=bucket_perc.index, + title="Distribuzione Sentiment", hole=0.4, + color=bucket_perc.index, color_discrete_map=plot_colors, + template=PLOTLY_TEMPLATE) + fig_sentiment_pie.update_traces(textinfo='percent+label', sort=False, # Keep defined order + pull=[0.05 if name=="Critico" else 0 for name in bucket_perc.index]) + fig_sentiment_pie.update_layout(showlegend=False, margin=dict(t=30, b=10, l=10, r=10), height=250) # Compact layout + st.plotly_chart(fig_sentiment_pie, use_container_width=True) + else: + st.write("Dati soddisfazione non numerici/vuoti.") + else: + st.write("Domanda soddisfazione non trovata.") + + with col_s3: + st.subheader("Valore Medio") + if not np.isnan(avg_overall_filtered): + min_scale, max_scale = response_scale if response_scale else (1, 6) + midpoint = (min_scale + max_scale) / 2 + fig_gauge = go.Figure(go.Indicator( + mode = "gauge+number", + value = avg_overall_filtered, + domain = {'x': [0, 1], 'y': [0, 1]}, + title = {'text': "Soddisfazione Generale", 'font': {'size': 16}}, + gauge = { + 'axis': {'range': [min_scale, max_scale], 'tickwidth': 1, 'tickcolor': "darkblue"}, + 'bar': {'color': "steelblue"}, + 'bgcolor': "white", + 'borderwidth': 2, + 'bordercolor': "gray", + 'steps': [ + {'range': [min_scale, THRESHOLD_LOW], 'color': BUCKET_COLORS['Critico']}, + {'range': [THRESHOLD_LOW, THRESHOLD_HIGH], 'color': BUCKET_COLORS['Neutrale']}, + {'range': [THRESHOLD_HIGH, max_scale], 'color': BUCKET_COLORS['Positivo']}], + 'threshold': { + 'line': {'color': "black", 'width': 3}, + 'thickness': 0.9, + 'value': midpoint } # Show midpoint + })) + fig_gauge.update_layout(height=250, margin=dict(t=40, b=10, l=10, r=10)) # Compact layout + st.plotly_chart(fig_gauge, use_container_width=True) + else: + st.write(" ") # Placeholder + st.write(" ") + st.info("Gauge non disponibile (media N/D).") + + + st.markdown("---") + st.subheader("Riflessioni Rapide:") + satisfaction_text = f"{avg_overall_filtered:.2f}" if not np.isnan(avg_overall_filtered) else "N/D" + strongest_area_text = f"{avg_scores_per_category_f.index[-1]}" if not avg_scores_per_category_f.empty else "N/D" + weakest_area_text = f"{avg_scores_per_category_f.index[0]}" if not avg_scores_per_category_f.empty else "N/D" + + st.info(f""" + Questa sintesi evidenzia i risultati principali per il gruppo selezionato ({total_respondents_filtered} persone). + La soddisfazione generale si attesta a **{satisfaction_text}**. + Le aree di forza (**{strongest_area_text}**) e di debolezza (**{weakest_area_text}**) + richiedono attenzione specifica. Esplora le altre schede per dettagli, confronti e visualizzazioni avanzate. + """) + + # ============================================================================== + # --- TAB Map: Category -> Question Mapping --- + # ============================================================================== + with tab_map: + st.header("🗺️ Mappa Categorie e Domande") + st.write("Questa sezione mostra quali domande appartengono a ciascuna categoria identificata durante il caricamento dei dati.") + + if question_to_category_map: + # Create DataFrame from the mapping dictionary + map_df = pd.DataFrame(question_to_category_map.items(), columns=['Domanda', 'Categoria']) + # Sort for better readability + map_df = map_df.sort_values(by=['Categoria', 'Domanda']).reset_index(drop=True) + + st.dataframe(map_df, use_container_width=True) + + # Optional: Display grouped by category + st.divider() + st.subheader("Domande Raggruppate per Categoria") + categories_in_map = map_df['Categoria'].unique() + for category in sorted(categories_in_map): + with st.expander(f"**{category}**"): + questions_in_cat = map_df[map_df['Categoria'] == category]['Domanda'].tolist() + for q in questions_in_cat: + st.markdown(f"- {q}") + else: + st.warning("La mappa tra domande e categorie non è disponibile.") + + # ============================================================================== + # --- TAB Demo: Demographics --- + # ============================================================================== + with tab_demo: + st.header("👥 Analisi Demografica Dettagliata (Filtrata)") + + if df_filtered is None or df_filtered.empty: + st.warning("Nessun dato disponibile con i filtri selezionati.") + elif not demographic_cols: + st.warning("Nessuna colonna demografica configurata per l'analisi.") + else: + st.write(f"Visualizzazione basata su **{len(df_filtered)}** rispondenti selezionati.") + valid_demo_cols_plots = [col for col in demographic_cols if col in df_filtered.columns] # Use only valid cols for plotting + + if not valid_demo_cols_plots: + st.warning("Nessuna colonna demografica valida trovata nei dati filtrati per la visualizzazione.") + else: + # --- Basic Distribution Pies --- + st.subheader("Distribuzione Base") + num_demo_cols = len(valid_demo_cols_plots) + cols_pie = st.columns(num_demo_cols) + pie_colors = [px.colors.qualitative.Pastel1, px.colors.qualitative.Pastel2, px.colors.qualitative.Set3] # Cycle through color schemes + + for i, demo_col in enumerate(valid_demo_cols_plots): + with cols_pie[i % num_demo_cols]: # Cycle through columns + if not df_filtered[demo_col].dropna().empty: + # Define order for age if applicable + category_orders = {} + if 'Eta' in demo_col: + age_order_guess = ['Fino a 30 anni', '31-40 anni', '41-50 anni', 'Oltre i 50 anni', 'Non specificato'] + actual_ages = df_filtered[demo_col].unique() + ordered_actual = [age for age in age_order_guess if age in actual_ages] + ordered_actual.extend(sorted([age for age in actual_ages if age not in age_order_guess])) + category_orders={demo_col: ordered_actual} + + + fig_pie = px.pie(df_filtered.dropna(subset=[demo_col]), names=demo_col, hole=0.4, + color_discrete_sequence=pie_colors[i % len(pie_colors)], template=PLOTLY_TEMPLATE, + title=f"Per {demo_col}", category_orders=category_orders) + fig_pie.update_traces(textposition='inside', textinfo='percent+label') + fig_pie.update_layout(showlegend=False, title_x=0.5, margin=dict(t=40, b=0, l=0, r=0), height=300) + st.plotly_chart(fig_pie, use_container_width=True) + else: + st.write(f"Dati '{demo_col}' non disponibili.") + + st.markdown("---") + # --- Hierarchical Views: Sunburst & Treemap --- + st.subheader("Visualizzazioni Gerarchiche/Proporzionali") + if len(valid_demo_cols_plots) >= 2: # Need at least 2 demographics for interesting hierarchy + chart_type_hier = st.radio("Scegli tipo grafico gerarchico:", ["Sunburst", "Treemap"], horizontal=True, key="hier_chart_sel") + + # Aggregate counts for combinations + try: + df_grouped_hier = df_filtered.groupby(valid_demo_cols_plots, observed=True).size().reset_index(name='Conteggio') + + if not df_grouped_hier.empty: + # Use first valid demo col for coloring + color_col_hier = valid_demo_cols_plots[0] + + if chart_type_hier == "Sunburst": + fig_hier = px.sunburst(df_grouped_hier, path=valid_demo_cols_plots, values='Conteggio', + title=f"Distribuzione Combinata (Sunburst): {', '.join(valid_demo_cols_plots)}", + template=PLOTLY_TEMPLATE, + color=color_col_hier, + color_discrete_sequence=px.colors.qualitative.Pastel) + fig_hier.update_layout(margin=dict(t=50, l=25, r=25, b=25)) + st.plotly_chart(fig_hier, use_container_width=True) + elif chart_type_hier == "Treemap": + fig_hier = px.treemap(df_grouped_hier, path=[px.Constant("Tutti")] + valid_demo_cols_plots, values='Conteggio', + title=f"Distribuzione Combinata (Treemap): {', '.join(valid_demo_cols_plots)}", + template=PLOTLY_TEMPLATE, + color=color_col_hier, + color_discrete_sequence=px.colors.qualitative.Pastel) + fig_hier.update_layout(margin=dict(t=50, l=25, r=25, b=25)) + st.plotly_chart(fig_hier, use_container_width=True) + else: + st.info("Nessun dato aggregato per la visualizzazione gerarchica.") + except Exception as e: + st.error(f"Errore durante l'aggregazione per il grafico gerarchico: {e}") + + else: + st.info("Sono necessarie almeno due colonne demografiche valide per le visualizzazioni gerarchiche.") + + + # ============================================================================== + # --- TAB Overall: Overall, Categories & Questions --- + # ============================================================================== + with tab_overall: + st.header("📊 Analisi Generale, Categorie e Domande (Filtrata)") + + if df_filtered is None or df_filtered.empty: + st.warning("Nessun dato disponibile con i filtri selezionati.") else: - for uploaded_file in uploaded_files: - st.subheader(f"📄 {uploaded_file.name}") - file_path = uploaded_file.name - with open(file_path, "wb") as f: - f.write(uploaded_file.getbuffer()) + # --- Overall Satisfaction Distribution --- + st.subheader("⭐ Soddisfazione Generale Complessiva") + if overall_satisfaction_question and overall_satisfaction_question in df_filtered.columns: + overall_satisfaction_data_f = df_filtered[overall_satisfaction_question].dropna() + if pd.api.types.is_numeric_dtype(overall_satisfaction_data_f) and not overall_satisfaction_data_f.empty: + col_ov1, col_ov2 = st.columns([2,1]) + with col_ov1: + # Bar chart of distribution + overall_counts_f = overall_satisfaction_data_f.value_counts().sort_index() + fig_overall_satisfaction = px.bar(overall_counts_f, x=overall_counts_f.index, y=overall_counts_f.values, + labels={'x': f'Punteggio ({response_scale[0]:.0f}-{response_scale[1]:.0f})', 'y': 'Numero Risposte'}, + text_auto=True, color_discrete_sequence=px.colors.sequential.Blues_r, template=PLOTLY_TEMPLATE, + title="Distribuzione Punteggi Soddisfazione Generale") + fig_overall_satisfaction.update_layout(xaxis = dict(tickmode = 'linear', dtick=1), title_x=0.5) + st.plotly_chart(fig_overall_satisfaction, use_container_width=True) + with col_ov2: + # Sentiment display + st.write(" ") + st.write(" ") + st.write("**Distribuzione Sentiment:**") + bucket_counts = overall_satisfaction_data_f.apply(categorize_score).value_counts() + bucket_counts = bucket_counts.reindex(list(BUCKET_COLORS.keys()) + ["Non Risposto"], fill_value=0) + total_valid_responses = bucket_counts.sum() + if total_valid_responses > 0: + bucket_perc = (bucket_counts / total_valid_responses * 100) + plot_colors = BUCKET_COLORS.copy() + plot_colors["Non Risposto"] = "#bbbbbb" + for bucket in plot_colors.keys(): # Iterate in defined order + if bucket in bucket_perc.index: # Check if bucket exists + perc = bucket_perc.get(bucket, 0) + count = bucket_counts.get(bucket, 0) + st.markdown(f" **{bucket}:** {perc:.1f}% ({count})", unsafe_allow_html=True) + else: + st.write("Nessuna risposta valida per il sentiment.") + + else: st.warning("Dati soddisfazione generale non disponibili/numerici.") + else: st.warning("Domanda soddisfazione generale non trovata.") + + st.markdown("---") + + # --- Category Averages --- + st.subheader("📈 Punteggio Medio per Categoria") + if not avg_scores_per_category_f.empty: + cat_avg_chart_type = st.radio("Visualizza medie categorie come:", ["Bar Chart", "Bullet Chart"], horizontal=True, key="cat_avg_type") + + if cat_avg_chart_type == "Bar Chart": + avg_scores_plot = avg_scores_per_category_f.copy() + color_map = [] + for score in avg_scores_plot.values: + if score > THRESHOLD_HIGH: color_map.append(BUCKET_COLORS["Positivo"]) + elif score < THRESHOLD_LOW: color_map.append(BUCKET_COLORS["Critico"]) + else: color_map.append(BUCKET_COLORS["Neutrale"]) + + fig_avg_category = go.Figure(go.Bar( + x=avg_scores_plot.values, y=avg_scores_plot.index, orientation='h', + text=[f'{score:.2f}' for score in avg_scores_plot.values], marker_color=color_map )) + fig_avg_category.update_traces(textposition='outside') + fig_avg_category.update_layout( + xaxis_title=f'Punteggio Medio ({response_scale[0]:.0f}-{response_scale[1]:.0f})', yaxis_title='Categoria', + yaxis={'categoryorder':'total ascending'}, template=PLOTLY_TEMPLATE, title="Medie Categorie (Colorate per Soglia)") + if not np.isnan(avg_overall_filtered): + fig_avg_category.add_vline(x=avg_overall_filtered, line_width=2, line_dash="dash", line_color="grey", annotation_text="Media Sod. Gen.") + st.plotly_chart(fig_avg_category, use_container_width=True) + + elif cat_avg_chart_type == "Bullet Chart": + st.write("Grafico Bullet: Confronta la media di categoria con la media generale e le soglie.") + min_scale, max_scale = response_scale if response_scale else (1, 6) + avg_scores_plot = avg_scores_per_category_f.copy().sort_values(ascending=False) + + for category, score in avg_scores_plot.items(): + fig_bullet = go.Figure(go.Indicator( + mode = "gauge+number+delta", + value = score, + delta = {'reference': avg_overall_filtered, 'suffix': ' vs Media Gen.'} if not np.isnan(avg_overall_filtered) else None, + title = {'text': category, 'font': {'size': 14}}, + gauge = { + 'shape': "bullet", + 'axis': {'range': [min_scale, max_scale]}, + 'threshold': { + 'line': {'color': "black", 'width': 2}, + 'thickness': 0.75, + 'value': avg_overall_filtered if not np.isnan(avg_overall_filtered) else (min_scale+max_scale)/2 }, + 'bgcolor': "white", + 'steps': [ + {'range': [min_scale, THRESHOLD_LOW], 'color': BUCKET_COLORS['Critico']}, + {'range': [THRESHOLD_LOW, THRESHOLD_HIGH], 'color': BUCKET_COLORS['Neutrale']}, + {'range': [THRESHOLD_HIGH, max_scale], 'color': BUCKET_COLORS['Positivo']}], + 'bar': {'color': 'darkblue', 'thickness': 0.5} + })) + fig_bullet.update_layout(height=100, margin=dict(l=200, r=50, t=30, b=10)) + st.plotly_chart(fig_bullet, use_container_width=True) + + else: + st.warning("Impossibile calcolare medie per categoria (potrebbero essere tutte 'Senza Categoria' o vuote).") + + st.markdown("---") + + # --- Detailed Question Analysis --- + st.subheader("❓ Analisi Dettagliata per Domanda") + # Get categories present in the calculated averages + categories_with_averages = avg_scores_per_category_f.index.unique().tolist() + if not categories_with_averages: + # Fallback: get categories from the original map if averages failed + if question_to_category_map: + categories_with_averages = sorted(list(set(question_to_category_map.values()))) + if "Senza Categoria" in categories_with_averages: categories_with_averages.remove("Senza Categoria") + if "Categoria Sconosciuta" in categories_with_averages: categories_with_averages.remove("Categoria Sconosciuta") + else: + categories_with_averages = [] + + + if categories_with_averages: # Proceed only if there are valid categories + col_q1, col_q2 = st.columns([1,1]) + with col_q1: + selected_category = st.selectbox("Seleziona Categoria:", options=categories_with_averages, key="cat_select_q") + with col_q2: + plot_type = st.radio("Tipo Grafico Domande:", ["Distribuzione % (Stacked)", "Conteggi (Bar)", "Box Plot"], horizontal=True, key="q_plot_type") + + + if selected_category: + st.write(f"**Dettaglio Domande: '{selected_category}'**") + # Find questions mapped to the selected category, ensuring they are numeric and exist + questions_in_category = [q for q, cat in question_to_category_map.items() + if cat == selected_category and q in df_filtered.columns and q in numeric_question_cols] + + if not questions_in_category: + st.write("Nessuna domanda numerica valida trovata per questa categoria nei dati filtrati.") + else: + # Prepare data for box plot if selected + if plot_type == "Box Plot": + df_box_cat = df_filtered[questions_in_category].copy() + if not df_box_cat.empty: + df_box_melted = df_box_cat.melt(var_name='Domanda', value_name='Punteggio') + # Shorten question names for y-axis + df_box_melted['Domanda_Breve'] = df_box_melted['Domanda'].apply(lambda x: x[:67]+"..." if len(x) > 70 else x) + df_box_melted.dropna(subset=['Punteggio'], inplace=True) + + if not df_box_melted.empty: + fig_box = px.box(df_box_melted, x='Punteggio', y='Domanda_Breve', orientation='h', + title=f"Distribuzione Punteggi per Domanda in '{selected_category}'", + template=PLOTLY_TEMPLATE, points=False) # points="all" can be noisy + fig_box.update_layout(yaxis={'categoryorder':'total descending'}, height=max(400, len(questions_in_category)*50)) # Dynamic height + st.plotly_chart(fig_box, use_container_width=True) + else: + st.warning("Nessun dato valido per il Box Plot dopo il dropna.") + else: + st.warning("DataFrame vuoto per il Box Plot.") + + + else: # Stacked or Counts Bar Chart + for question in questions_in_category: + question_data_f = df_filtered[question].dropna() + if pd.api.types.is_numeric_dtype(question_data_f) and not question_data_f.empty: + avg_q = question_data_f.mean() + q_display = question if len(question) < 100 else question[:97] + "..." + st.markdown(f"**{q_display}** (Media: {avg_q:.2f})") + + if plot_type == "Conteggi (Bar)": + counts_q = question_data_f.value_counts().sort_index() + if not counts_q.empty: + fig_q = px.bar(counts_q, x=counts_q.index, y=counts_q.values, + labels={'x': 'Punteggio', 'y': 'Numero Risposte'}, text_auto='.2s', + height=250, template=PLOTLY_TEMPLATE, color_discrete_sequence=px.colors.sequential.Blues_r) + fig_q.update_layout(xaxis = dict(tickmode = 'linear', dtick=1), margin=dict(t=5, b=5, l=5, r=5)) + st.plotly_chart(fig_q, use_container_width=True) + else: st.caption("Nessun dato per questo grafico.") + + elif plot_type == "Distribuzione % (Stacked)": + counts_q_norm = question_data_f.value_counts(normalize=True).sort_index() * 100 + if not counts_q_norm.empty: + counts_q_df = counts_q_norm.reset_index() + counts_q_df.columns = ['Punteggio', 'Percentuale'] + counts_q_df['Punteggio'] = counts_q_df['Punteggio'].astype(str) # For discrete colors + + # Define a color map for the scores in the stacked bar + unique_scores = sorted(counts_q_df['Punteggio'].astype(float).unique()) + colors = px.colors.sequential.Blues_r + score_color_map = {str(score): colors[min(len(colors)-1, int((score - response_scale[0]) / (response_scale[1] - response_scale[0]) * len(colors)))] + for score in unique_scores} + + + fig_q = px.bar(counts_q_df, x='Percentuale', y=[' ']*len(counts_q_df), # Single bar + color='Punteggio', orientation='h', + text=[f"{p:.1f}%" for p in counts_q_df['Percentuale']], + height=150, template=PLOTLY_TEMPLATE, + color_discrete_map=score_color_map # Apply color map + ) + fig_q.update_layout(xaxis_ticksuffix="%", yaxis_title="", xaxis_title="% Rispondenti", + legend_title="Punteggio", showlegend=True, margin=dict(t=5, b=5, l=5, r=5), + xaxis_range=[0,100], yaxis_visible=False, + legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1)) + fig_q.update_traces(textposition='inside', textfont_color='white') # Ensure text is visible + st.plotly_chart(fig_q, use_container_width=True) + else: st.caption("Nessun dato per questo grafico.") + else: + st.caption(f"Dati per '{question[:50]}...' non numerici o vuoti.") + else: + st.info("Nessuna categoria valida trovata per l'analisi dettagliata delle domande.") + + + # ============================================================================== + # --- TAB Comparisons: Comparisons, Drivers & More --- + # ============================================================================== + with tab_comp: + st.header("🔍 Confronti Demografici & Analisi Driver (Filtrata)") + + if df_filtered is None or df_filtered.empty: + st.warning("Nessun dato disponibile con i filtri selezionati.") + elif not numeric_question_cols: + st.warning("Nessuna domanda numerica trovata per le analisi di confronto.") + else: + # --- Prepare Melted Data --- + @st.cache_data # Cache the melting process + def get_melted_data(df, id_vars, value_vars, cat_map): + if not value_vars: return pd.DataFrame() + cols_to_melt = [col for col in id_vars + value_vars if col in df.columns] + value_vars_valid = [col for col in value_vars if col in cols_to_melt] + id_vars_valid = [col for col in id_vars if col in cols_to_melt] + if not value_vars_valid or not id_vars_valid: return pd.DataFrame() # Need both ID and Value vars + + df_melted = df[cols_to_melt].melt(id_vars=id_vars_valid, value_vars=value_vars_valid, var_name='Domanda', value_name='Punteggio') + df_melted['Categoria'] = df_melted['Domanda'].map(cat_map).fillna("Senza Categoria") + df_melted.dropna(subset=['Punteggio'], inplace=True) + return df_melted + + numeric_cols_in_filtered = [col for col in numeric_question_cols if col in df_filtered.columns] + valid_demographic_cols = [col for col in demographic_cols if col in df_filtered.columns] + + df_melted_f = pd.DataFrame() # Initialize empty + if valid_demographic_cols and numeric_cols_in_filtered: + df_melted_f = get_melted_data(df_filtered, valid_demographic_cols, numeric_cols_in_filtered, question_to_category_map) + + # --- Demographic Comparisons (Violin / Box Plots) --- + st.subheader("🎻 Confronti Demografici (Distribuzione Punteggi per Categoria)") + if not df_melted_f.empty and valid_demographic_cols: + col_comp1, col_comp2 = st.columns(2) + with col_comp1: + # Select demographic group for comparison + comparison_group_v_options = [col for col in valid_demographic_cols if df_filtered[col].nunique() > 1] # Only those with multiple values + if comparison_group_v_options: + comparison_group_v = st.selectbox("Confronta Distribuzioni per:", comparison_group_v_options, key="dist_group") + else: + comparison_group_v = None + st.info("Nessuna colonna demografica con valori multipli per il confronto.") + + with col_comp2: + dist_plot_type = st.radio("Tipo Grafico Distribuzione:", ["Violin Plot", "Box Plot"], horizontal=True, key="dist_plot_type") + + if comparison_group_v: # Proceed only if a valid comparison group is selected + # Select categories to show (use averages calculated in sidebar) + categories_with_averages = avg_scores_per_category_f.index.unique().tolist() + if categories_with_averages: + default_cats_dist = avg_scores_per_category_f.nsmallest(3).index.tolist() + default_cats_dist = [cat for cat in default_cats_dist if cat in categories_with_averages] # Ensure defaults are valid + selected_cats_dist = st.multiselect("Seleziona Categorie da Visualizzare:", options=categories_with_averages, default=default_cats_dist, key="cat_dist") + + if selected_cats_dist: + # Filter melted data for selected categories and ensure comparison group is not NA + df_dist = df_melted_f[(df_melted_f['Categoria'].isin(selected_cats_dist)) & + (df_melted_f[comparison_group_v].notna()) & + (df_melted_f[comparison_group_v] != 'Non specificato')] # Exclude 'Non specificato'? Optional. + + if not df_dist.empty: + # Ensure hover data columns exist + hover_data = [col for col in valid_demographic_cols if col in df_dist.columns] + + plot_func = px.violin if dist_plot_type == "Violin Plot" else px.box + caption_text = ("Il grafico a violino mostra la densità della distribuzione..." if dist_plot_type == "Violin Plot" + else "Il box plot mostra mediana, quartili...") + + fig_dist = plot_func(df_dist, x='Categoria', y='Punteggio', color=comparison_group_v, + points=False, # 'all', False, 'outliers' + hover_data=hover_data, + category_orders={'Categoria': selected_cats_dist}, # Use selected order + template=PLOTLY_TEMPLATE, title=f"Distribuzione Punteggi per {comparison_group_v}") + fig_dist.update_layout(yaxis_range=[response_scale[0]-0.5, response_scale[1]+0.5]) + st.plotly_chart(fig_dist, use_container_width=True) + st.caption(caption_text) + else: + st.warning(f"Nessun dato per le categorie e gruppo '{comparison_group_v}' selezionati.") + else: + st.info("Seleziona almeno una categoria per visualizzare il confronto.") + else: + st.warning("Medie per categoria non disponibili.") + else: + st.info("Dati o colonne demografiche insufficienti per i confronti.") + + + st.markdown("---") + + # --- Driver Analysis --- + st.subheader("🎯 Analisi Driver (Impatto vs Performance)") + if not driver_df.empty: # Use pre-calculated driver_df from sidebar + driver_plot_type = st.radio("Visualizza Analisi Driver come:", ["Scatter Plot", "Density Heatmap", "Bar Chart (Top/Bottom)"], horizontal=True, key="driver_plot_type") + + if driver_plot_type == "Scatter Plot": + # (Code for Scatter Plot - seems okay, uses driver_df) + fig_scatter_drivers = px.scatter(driver_df, x='Punteggio Medio', y='Correlazione', + color='Categoria', + size='Correlazione_Abs', size_max=18, + hover_data=['Domanda_Breve', 'Punteggio Medio', 'Correlazione'], + template=PLOTLY_TEMPLATE, title=f"Driver: Impatto (Corr. {corr_method_sidebar.capitalize()}) vs Performance") + + avg_corr = driver_df['Correlazione'].mean() + avg_score_all_q = driver_df['Punteggio Medio'].mean() + fig_scatter_drivers.add_vline(x=avg_score_all_q, line_width=1, line_dash="dash", line_color="grey", annotation_text="Media Perf.") + fig_scatter_drivers.add_hline(y=avg_corr, line_width=1, line_dash="dash", line_color="grey", annotation_text="Media Impatto") + fig_scatter_drivers.update_layout(xaxis_title="Performance (Punteggio Medio Domanda)", yaxis_title=f"Impatto (Corr. {corr_method_sidebar.capitalize()} con Sod. Gen.)") + st.plotly_chart(fig_scatter_drivers, use_container_width=True) + st.caption("Quadranti (vs medie): Alto Dx (Verde)=Forza Chiave; Alto Sx (Giallo)=Priorità Alta; Basso Sx (Rosso)=Priorità Bassa; Basso Dx (Blu)=Mantenimento Secondario. Dimensione = forza correlazione.") + + + elif driver_plot_type == "Density Heatmap": + # (Code for Density Heatmap - seems okay, uses driver_df) + fig_density_driver = px.density_heatmap(driver_df, x="Punteggio Medio", y="Correlazione", + marginal_x="histogram", marginal_y="histogram", + text_auto=False, + template=PLOTLY_TEMPLATE, title=f"Densità Driver: Impatto (Corr. {corr_method_sidebar.capitalize()}) vs Performance") + avg_corr = driver_df['Correlazione'].mean() + avg_score_all_q = driver_df['Punteggio Medio'].mean() + fig_density_driver.add_vline(x=avg_score_all_q, line_width=1, line_dash="dash", line_color="grey") + fig_density_driver.add_hline(y=avg_corr, line_width=1, line_dash="dash", line_color="grey") + fig_density_driver.update_layout(xaxis_title="Performance (Punteggio Medio Domanda)", yaxis_title=f"Impatto (Corr. {corr_method_sidebar.capitalize()} con Sod. Gen.)") + st.plotly_chart(fig_density_driver, use_container_width=True) + st.caption("Mostra dove si concentrano le domande nel piano Impatto-Performance.") + + + elif driver_plot_type == "Bar Chart (Top/Bottom)": + # (Code for Bar Chart - seems okay, uses driver_df) + top_n = st.slider("Numero Top/Bottom Driver da mostrare:", min_value=3, max_value=15, value=8, key="driver_topn") + driver_df_unique = driver_df.loc[~driver_df.index.duplicated(keep='first')] + top_drivers = driver_df_unique.sort_values('Correlazione', ascending=False).head(top_n) + bottom_drivers = driver_df_unique.sort_values('Correlazione', ascending=True).head(top_n) # Gets most negative + # Combine and ensure uniqueness (in case a driver is both top N pos and top N neg in small datasets) + drivers_to_plot = pd.concat([top_drivers, bottom_drivers]).drop_duplicates().sort_values('Correlazione') + + if not drivers_to_plot.empty: + fig_drivers_bar = px.bar(drivers_to_plot, x='Correlazione', y='Domanda_Breve', orientation='h', + color='Categoria', template=PLOTLY_TEMPLATE, height=max(400, len(drivers_to_plot)*30), + title=f"Top/Bottom {top_n} Domande per Correlazione ({corr_method_sidebar.capitalize()}) con Sod. Gen.") + fig_drivers_bar.update_layout(yaxis={'categoryorder':'total ascending'}, xaxis_title=f"Correlazione {corr_method_sidebar.capitalize()}", yaxis_title="Domanda") + st.plotly_chart(fig_drivers_bar, use_container_width=True) + st.caption(f"Mostra le domande con la correlazione ({corr_method_sidebar}) più forte (positiva e negativa) con la soddisfazione generale.") + else: + st.warning("Nessun dato driver da mostrare nel grafico a barre.") + + else: + st.warning("Impossibile calcolare l'analisi dei driver. Verifica la presenza e la varianza della domanda di soddisfazione generale e delle altre domande numeriche.") + + + st.markdown("---") + + # --- Anomaly Detection & Recommendations --- + st.subheader("⚠️ Rilevamento Potenziali Punti d'Attenzione & Suggerimenti 💡") + # Use melted data calculated earlier + if not df_melted_f.empty and valid_demographic_cols and not avg_scores_per_category_f.empty: + col_anom, col_sugg = st.columns(2) + + with col_anom: + st.write("**Possibili Punti d'Attenzione (Z-Score per Gruppo/Categoria):**") + try: + # Calculate overall category means and std deviations on the *filtered* dataset + overall_cat_stats = df_melted_f.groupby('Categoria')['Punteggio'].agg(['mean', 'std']).reset_index() + # Rename columns *before* merge + overall_cat_stats = overall_cat_stats.rename(columns={'mean': 'mean_overall', 'std': 'std_overall'}) + + # Calculate group means within the filtered dataset + group_means = df_melted_f.groupby(valid_demographic_cols + ['Categoria'], observed=True)['Punteggio'].mean().reset_index() + # Rename columns *before* merge + group_means = group_means.rename(columns={'Punteggio': 'mean_group'}) + + if not group_means.empty and not overall_cat_stats.empty: + # Merge using the renamed columns + merged_stats = pd.merge(group_means, overall_cat_stats, on='Categoria', how='left') + + # Calculate Z-score only if std is not NaN and greater than a small epsilon + merged_stats_valid_std = merged_stats[merged_stats['std_overall'].notna() & (merged_stats['std_overall'] > 0.01)].copy() # Use copy to avoid SettingWithCopyWarning + + if not merged_stats_valid_std.empty: + # *** CORRECTION HERE: Use correct column names *** + merged_stats_valid_std['Z_Score'] = (merged_stats_valid_std['mean_group'] - merged_stats_valid_std['mean_overall']) / merged_stats_valid_std['std_overall'] + + z_score_threshold = st.slider("Soglia Z-Score per Attenzione:", min_value=1.0, max_value=3.0, value=1.75, step=0.25, key="zscore_thresh") + potential_anomalies = merged_stats_valid_std[abs(merged_stats_valid_std['Z_Score']) > z_score_threshold].sort_values(by='Z_Score') + + if not potential_anomalies.empty: + st.write(f"Gruppi/Categorie con punteggio medio deviante (> {z_score_threshold:.2f} dev. std. dalla media della categoria):") + for _, row in potential_anomalies.head(10).iterrows(): # Limit display + group_desc_parts = [f"{col}={row[col]}" for col in valid_demographic_cols] + group_desc = " / ".join(group_desc_parts) + direction = "⚠️ Basso" if row['Z_Score'] < 0 else "✅ Alto" + # Use mean_group and Z_Score from the row + st.markdown(f"- {direction}: **{group_desc}** in **'{row['Categoria']}'** (Media Gruppo: {row['mean_group']:.2f}, Z: {row['Z_Score']:.2f})") + else: + st.info(f"Nessun punto d'attenzione rilevato con soglia Z-Score > {z_score_threshold:.2f} nei dati filtrati.") + else: + st.info("Deviazione standard non calcolabile o nulla per le categorie, impossibile calcolare Z-score.") + else: + st.info("Dati insufficienti per calcolare medie di gruppo o statistiche di categoria.") + except KeyError as e: + st.error(f"Errore Chiave durante il calcolo Z-Score: '{e}'. Verifica i nomi delle colonne dopo il merge.") + st.dataframe(merged_stats.head()) # Display merged df head for debugging + except Exception as e: + st.error(f"Errore generico durante il calcolo Z-Score: {e}") + + + with col_sugg: + # Suggestions part remains the same, using driver_df calculated in sidebar + st.write("**Suggerimenti Basati sui Driver & Punteggi Bassi:**") + if not avg_scores_per_category_f.empty: + lowest_cat_name = avg_scores_per_category_f.index[0] + lowest_cat_score = avg_scores_per_category_f.iloc[0] + st.markdown(f"**Area più debole (media bassa):** '{lowest_cat_name}' ({lowest_cat_score:.2f}).") + + if not driver_df.empty: + avg_corr = driver_df['Correlazione'].mean() + avg_score_all_q = driver_df['Punteggio Medio'].mean() + low_score_threshold = avg_score_all_q + high_impact_threshold = avg_corr + + critical_drivers = driver_df[ + (driver_df['Punteggio Medio'] < low_score_threshold) & + (driver_df['Correlazione'] > high_impact_threshold) + ].sort_values('Correlazione', ascending=False) + + if not critical_drivers.empty: + st.markdown("**Priorità Alte (Bassa Performance, Alto Impatto):**") + for _, row in critical_drivers.head(5).iterrows(): + st.markdown(f"- *{row['Domanda_Breve']}* (Cat: {row['Categoria']}, Score: {row['Punteggio Medio']:.2f}, Corr: {row['Correlazione']:.2f})") + st.warning("Intervenire su queste domande potrebbe avere il maggior impatto positivo sulla soddisfazione generale.") + else: + st.info("Nessuna domanda trovata nel quadrante 'Priorità Alte' con le soglie attuali.") + + # Generic suggestions + suggestions = { + "Stress e benessere": "Considerare iniziative per la gestione dello stress, flessibilità lavorativa, e supporto psicologico.", + # ... (rest of suggestions map) ... + "Apertura e inclusione": "Programmi D&I, garantire libertà di espressione e sicurezza psicologica." + } + default_suggestion = "Approfondire le cause specifiche tramite focus group o interviste mirate." + st.markdown("**Possibili Azioni Generiche per l'Area più Debole:**") + st.info(suggestions.get(lowest_cat_name, default_suggestion)) + + else: st.write("Nessun dato medio per categoria disponibile per generare suggerimenti.") + + else: + st.info("Dati insufficienti per rilevare anomalie o fornire suggerimenti.") + + + # ============================================================================== + # --- TAB Advanced: More Complex Visualizations --- + # ============================================================================== + with tab_advanced: + st.header("📈 Grafici Avanzati (Filtrati)") + + if df_filtered is None or df_filtered.empty: + st.warning("Nessun dato disponibile con i filtri selezionati.") + elif not numeric_question_cols: + st.warning("Nessuna domanda numerica trovata per le analisi avanzate.") + else: + # Use the melted data prepared in the Comparisons tab if available + if 'df_melted_f' not in locals() or df_melted_f.empty: + # Try to recreate df_melted_f if not available + numeric_cols_in_filtered = [col for col in numeric_question_cols if col in df_filtered.columns] + valid_demographic_cols = [col for col in demographic_cols if col in df_filtered.columns] + if valid_demographic_cols and numeric_cols_in_filtered: + df_melted_f = get_melted_data(df_filtered, valid_demographic_cols, numeric_cols_in_filtered, question_to_category_map) + else: + df_melted_f = pd.DataFrame() + + if df_melted_f.empty and not numeric_cols_in_filtered: # Check again if still empty or no numerics + st.warning("Dati insufficienti per i grafici avanzati.") + else: + # --- 1. Correlation Heatmap --- + st.subheader("🔥 Heatmap di Correlazione tra Domande Numeriche") + corr_method_options = ['pearson'] + if SCIPY_AVAILABLE: + corr_method_options.append('spearman') + corr_method_adv = st.radio("Metodo Correlazione:", corr_method_options, horizontal=True, key="corr_method_adv") + + numeric_cols_in_filtered_adv = [col for col in numeric_question_cols if col in df_filtered.columns and df_filtered[col].nunique(dropna=True) > 1] + + + if len(numeric_cols_in_filtered_adv) > 1: + # Etichette univoche e leggibili + corr_labels = { + q: (f"{str(q)[:27]}..." if len(str(q)) > 30 else str(q)) + f" [{i}]" + for i, q in enumerate(numeric_cols_in_filtered_adv) + } + + df_corr = df_filtered[numeric_cols_in_filtered_adv].rename(columns=corr_labels) - with st.spinner(f"Elaborazione in corso"): try: - doc = process_document(uploaded_file.name, number_pages_split, use_azure=use_azure) - totale_non_ivato_verificato = sum(articolo.TotaleNonIvato for articolo in doc.Articoli if articolo.Verificato == 1) - totale_non_ivato_non_verificato = sum(articolo.TotaleNonIvato for articolo in doc.Articoli if articolo.Verificato != 1) - totale_non_ivato = totale_non_ivato_verificato + totale_non_ivato_non_verificato - st.write( - f"- **Tipo**: {doc.TipoDocumento}\n" - f"- **Numero**: {doc.NumeroDocumento}\n" - f"- **Data**: {doc.Data}\n" - f"- **Articoli Compatibili**: {len(doc.Articoli)}\n" - f"- **Totale Documento**: {format_euro(doc.TotaleImponibile)}\n" - ) - if totale_non_ivato > doc.TotaleImponibile and doc.TotaleImponibile > 0: - st.warning("Totale Ave maggiore di Totale Merce") - if totale_non_ivato_non_verificato > 0: - st.error(f"Totale Ave Non Verificato: {format_euro(totale_non_ivato_non_verificato)}") - if totale_non_ivato > 0: - st.success(f"Totale Ave Verificato: {format_euro(totale_non_ivato_verificato)}") - df = pd.DataFrame([{k: v for k, v in Articolo.model_dump().items() if k != ""} for Articolo in doc.Articoli]) - if 'Verificato' in df.columns: - df['Verificato'] = df['Verificato'].apply(lambda x: "✅" if x == 1 else "❌" if x == 0 else "❓" if x == 2 else x) - if totale_non_ivato > 0: - df["TotaleNonIvato"] = df["TotaleNonIvato"].apply(format_euro) - st.dataframe(df, use_container_width=True) - st.json(doc.model_dump(), expanded=False) - if totale_non_ivato == 0: - st.info(f"Non sono presenti articoli 'AVE'") - if uploaded_file and file_path.lower().endswith(".pdf"): - list_art = list_art = [articolo.CodiceArticolo for articolo in doc.Articoli] + [articolo.DescrizioneArticolo for articolo in doc.Articoli] - if list_art: - new_pdf = highlight_text_in_pdf(uploaded_file.getvalue(), list_art) - pdf_viewer(input=new_pdf.getvalue(), width=1200) - else: - pdf_viewer(input=uploaded_file.getvalue(), width=1200) + corr_matrix = df_corr.corr(method=corr_method_adv) + if not corr_matrix.empty: + fig_heatmap = px.imshow( + corr_matrix, + text_auto=".2f", + aspect="auto", + color_continuous_scale='RdBu_r', + range_color=[-1, 1], + template=PLOTLY_TEMPLATE, + title=f"Heatmap Correlazione ({corr_method_adv.capitalize()}) tra Domande" + ) + heatmap_height = max(600, len(numeric_cols_in_filtered_adv) * 20) + fig_heatmap.update_layout(height=heatmap_height, xaxis_tickangle=-45) + st.plotly_chart(fig_heatmap, use_container_width=True) + st.caption("Rosso = correlazione negativa, Blu = correlazione positiva.") else: - st.image(file_path) - st.divider() + st.warning("Matrice di correlazione vuota.") except Exception as e: - st.error(f"Errore durante l'elaborazione di {uploaded_file.name}: {e}") - finally: - if os.path.exists(file_path): - os.remove(file_path) - -if __name__ == "__main__": - st.divider() - main() + st.warning(f"Errore nel calcolo heatmap: {e}") + else: + st.info("Servono almeno due domande numeriche con varianza per la heatmap.") + + + st.markdown("---") + + # --- 2. Radar Chart --- + st.subheader("🕸️ Radar Chart: Confronto Medie Categorie per Gruppo Demografico") + if not avg_scores_per_category_f.empty and valid_demographic_cols and not df_melted_f.empty: + radar_demo_options = [col for col in valid_demographic_cols if df_filtered[col].nunique() > 1] + if radar_demo_options: + radar_demo_col = st.selectbox("Seleziona Gruppo Demografico per Confronto Radar:", radar_demo_options, key="radar_demo") + available_groups = sorted(df_filtered[radar_demo_col].astype(str).unique()) + available_groups = [g for g in available_groups if g != 'Non specificato'] # Exclude 'Non specificato'? + + if len(available_groups) > 1: + groups_to_compare = st.multiselect(f"Seleziona '{radar_demo_col}' da confrontare:", options=available_groups, default=available_groups[:min(len(available_groups), 3)], key="radar_groups") + + if groups_to_compare: + radar_data = df_melted_f[df_melted_f[radar_demo_col].isin(groups_to_compare)] + avg_radar = radar_data.groupby(['Categoria', radar_demo_col], observed=True)['Punteggio'].mean().unstack() + avg_radar = avg_radar.dropna(axis=0, how='all') # Drop categories with no data + + if not avg_radar.empty: + categories_radar = avg_radar.index.tolist() + fig_radar = go.Figure() + color_sequence = px.colors.qualitative.Plotly # Use a color sequence + + for i, group in enumerate(groups_to_compare): + if group in avg_radar.columns: + fig_radar.add_trace(go.Scatterpolar( + r=avg_radar[group].values, theta=categories_radar, fill='toself', name=str(group), + line_color=color_sequence[i % len(color_sequence)] # Cycle through colors + )) + + min_scale_radar, max_scale_radar = response_scale if response_scale else (1, 6) + fig_radar.update_layout( + polar=dict(radialaxis=dict(visible=True, range=[min_scale_radar-0.5, max_scale_radar+0.5])), + showlegend=True, title=f"Confronto Medie Categorie Radar per {radar_demo_col}", template=PLOTLY_TEMPLATE ) + st.plotly_chart(fig_radar, use_container_width=True) + else: st.warning(f"Nessun dato medio disponibile per i gruppi selezionati.") + else: st.info(f"Seleziona almeno un gruppo.") + else: st.info(f"Solo un gruppo disponibile in '{radar_demo_col}'.") + else: st.info("Nessuna colonna demografica con valori multipli per il confronto Radar.") + else: st.info("Dati insufficienti (medie categorie, demo, melted) per il grafico Radar.") + + + st.markdown("---") + + # --- 3. Parallel Coordinates Plot --- + # (Code for Parallel Coordinates - kept similar, relies on df_melted_f) + st.subheader("|| Parrallel Coordinates: Pattern Medie Categorie per Gruppo") + st.warning("Attenzione: Questo grafico può essere lento o illeggibile con molti dati/categorie.") + if not avg_scores_per_category_f.empty and valid_demographic_cols and not df_melted_f.empty: + cats_parallel_options = avg_scores_per_category_f.index.unique().tolist() + if cats_parallel_options: + default_cats_parallel = cats_parallel_options[:min(len(cats_parallel_options), 8)] + cats_parallel = st.multiselect("Seleziona Categorie (Dimensioni):", cats_parallel_options, default=default_cats_parallel, key="par_cats") + + if cats_parallel: + parallel_demo_options = [col for col in valid_demographic_cols if df_filtered[col].nunique() > 1] + if parallel_demo_options: + parallel_demo_col = st.selectbox("Colora Linee per Gruppo Demografico:", parallel_demo_options, key="par_demo") + # Calculate mean scores per selected category and chosen demo group + df_parallel_prep = df_melted_f[df_melted_f['Categoria'].isin(cats_parallel)] + df_parallel = df_parallel_prep.groupby([parallel_demo_col, 'Categoria'], observed=True)['Punteggio'].mean().unstack() + df_parallel = df_parallel.dropna().reset_index() + + if not df_parallel.empty and parallel_demo_col in df_parallel.columns: + # Map group names to numerical values for continuous color scale + unique_groups_par = df_parallel[parallel_demo_col].unique() + group_map = {name: i for i, name in enumerate(unique_groups_par)} + df_parallel['color_val'] = df_parallel[parallel_demo_col].map(group_map) + + dimensions = [] + for cat in cats_parallel: + if cat in df_parallel.columns: + dimensions.append(dict( + range = [response_scale[0], response_scale[1]] if response_scale else [1,6], + label = str(cat)[:20] + '...' if len(str(cat))>20 else str(cat), + values = df_parallel[cat] )) + + if dimensions: + color_palette_par = px.colors.qualitative.Plotly + fig_parallel = go.Figure(data= + go.Parcoords( + line = dict(color = df_parallel['color_val'], + colorscale = color_palette_par, # Use qualitative scale directly + showscale = False), + dimensions = dimensions )) + fig_parallel.update_layout( title=f"Medie Categorie per {parallel_demo_col} (Parallel Coordinates)", template=PLOTLY_TEMPLATE) + st.plotly_chart(fig_parallel, use_container_width=True) + # Manual legend + st.write(f"**Legenda Colori ({parallel_demo_col}):**") + cols_legend = st.columns(min(len(group_map), 5)) + i = 0 + for name, num in group_map.items(): + color = color_palette_par[num % len(color_palette_par)] + with cols_legend[i % min(len(group_map), 5)]: + st.markdown(f" {name}", unsafe_allow_html=True) + i += 1 + else: st.warning("Nessuna dimensione valida per Parallel Coordinates.") + else: st.warning(f"Nessun dato medio aggregato per {parallel_demo_col}.") + else: st.info("Nessuna colonna demografica con valori multipli per colorare le linee.") + else: st.info("Seleziona almeno una categoria (dimensione).") + else: st.info("Nessuna categoria disponibile per Parallel Coordinates.") + else: st.info("Dati insufficienti (medie categorie, demo, melted) per Parallel Coordinates.") + + + st.markdown("---") + + # --- 4. Stacked Area Chart --- + # (Code for Stacked Area Chart - kept similar, relies on df_melted_f) + st.subheader("📊 Stacked Area Chart: Distribuzione Risposte per Categoria su Gruppo Ordinato") + if not df_melted_f.empty and valid_demographic_cols: + ordered_demo_options = [col for col in valid_demographic_cols if 'Eta' in col or 'Anzianita' in col] + if not ordered_demo_options: ordered_demo_options = valid_demographic_cols # Fallback + + if ordered_demo_options: + area_demo_col = st.selectbox("Seleziona Gruppo Demografico Ordinato:", ordered_demo_options, key="area_demo") + area_cat_options = avg_scores_per_category_f.index.unique().tolist() + if area_cat_options: + area_category = st.selectbox("Seleziona Categoria:", area_cat_options, key="area_cat") + df_area_prep = df_melted_f[(df_melted_f['Categoria'] == area_category) & df_melted_f[area_demo_col].notna()].copy() + + if not df_area_prep.empty: + df_area_prep['Sentiment'] = df_area_prep['Punteggio'].apply(categorize_score) + df_area = df_area_prep.groupby([area_demo_col, 'Sentiment'], observed=True).size().reset_index(name='Conteggio') + df_area['Percentuale'] = df_area.groupby(area_demo_col)['Conteggio'].transform(lambda x: x / float(x.sum()) * 100 if x.sum() > 0 else 0) + + category_orders = {} + group_order = None + if 'Eta' in area_demo_col: + age_order_guess = ['Fino a 30 anni', '31-40 anni', '41-50 anni', 'Oltre i 50 anni', 'Non specificato'] + actual_groups = df_area[area_demo_col].unique() + group_order = [g for g in age_order_guess if g in actual_groups] + group_order.extend(sorted([g for g in actual_groups if g not in age_order_guess])) + category_orders={area_demo_col: group_order} + + # Ensure Sentiment order for stacking + sentiment_order = ["Critico", "Neutrale", "Positivo", "Non Risposto"] + category_orders['Sentiment'] = [s for s in sentiment_order if s in df_area['Sentiment'].unique()] + + plot_colors = BUCKET_COLORS.copy() + plot_colors["Non Risposto"] = "#bbbbbb" + + + if not df_area.empty: + fig_area = px.area(df_area, x=area_demo_col, y='Percentuale', color='Sentiment', + title=f"Distribuzione Sentiment (%) per '{area_category}' per {area_demo_col}", + labels={'Percentuale': '% Rispondenti'}, + category_orders=category_orders, + color_discrete_map=plot_colors, + template=PLOTLY_TEMPLATE) + fig_area.update_layout(yaxis_range=[0, 100], yaxis_ticksuffix="%") + st.plotly_chart(fig_area, use_container_width=True) + else: st.warning("Nessun dato aggregato per l'Area Chart.") + else: st.warning(f"Nessun dato trovato per la categoria '{area_category}'.") + else: st.info("Nessuna categoria valida trovata.") + else: st.info("Nessuna colonna demografica disponibile per l'Area Chart.") + else: st.info("Dati insufficienti (melted, demo) per l'Area Chart.") + + + # --- Download Button --- + st.sidebar.divider() + st.sidebar.subheader("📥 Download Dati Filtrati") + if df_filtered is not None and not df_filtered.empty: + output = BytesIO() + try: + df_to_download = df_filtered.copy() + df_to_download.to_csv(output, index=False, encoding='utf-8', sep=';') + output.seek(0) + st.sidebar.download_button(label="Scarica Dati Filtrati Correnti (CSV)", data=output, + file_name='dati_sondaggio_filtrati_avanzato.csv', mime='text/csv', key='download_csv') + except Exception as e: + st.sidebar.error(f"Errore durante la creazione del CSV: {e}") + else: + st.sidebar.info("Nessun dato filtrato da scaricare.") + + + # --- Footer --- + st.markdown("---") + # Use a dynamic timestamp + try: + current_time_str = pd.Timestamp.now(tz='Europe/Rome').strftime('%Y-%m-%d %H:%M:%S %Z') + except Exception: # Fallback if timezone fails + current_time_str = pd.Timestamp.now().strftime('%Y-%m-%d %H:%M:%S') + + st.caption(f"Dashboard Analisi Clima") + +# Altrimenti (se uploaded_file is None), non mostra nulla tranne l'uploader +else: + st.title("🚀 Dashboard Analisi Clima") + st.info("Per iniziare, carica un file CSV usando il widget qui sopra.") \ No newline at end of file