From c3931d78b1357dc9ddac707cf502ca1d3776b004 Mon Sep 17 00:00:00 2001 From: AI Station Server Date: Tue, 20 Jan 2026 15:21:49 +0100 Subject: [PATCH] feat: pre-security cleanup --- .chainlit/config.toml | 157 +++------- app.py | 711 +++++++++++++++++++++++++++++------------- docker-compose.yml | 2 - requirements.txt | 5 +- 4 files changed, 535 insertions(+), 340 deletions(-) diff --git a/.chainlit/config.toml b/.chainlit/config.toml index f192c501..3ff2a4b9 100644 --- a/.chainlit/config.toml +++ b/.chainlit/config.toml @@ -1,153 +1,68 @@ [project] -# List of environment variables to be provided by each user to use the app. user_env = [] - -# Duration (in seconds) during which the session is saved when the connection is lost session_timeout = 3600 - -# Duration (in seconds) of the user session expiry -user_session_timeout = 1296000 # 15 days - -# Enable third parties caching (e.g., LangChain cache) +user_session_timeout = 1296000 cache = false - -# Whether to persist user environment variables (API keys) to the database -# Set to true to store user env vars in DB, false to exclude them for security persist_user_env = false - -# Whether to mask user environment variables (API keys) in the UI with password type -# Set to true to show API keys as ***, false to show them as plain text mask_user_env = false - -# Authorized origins allow_origins = ["*"] [features] -# Process and display HTML in messages. This can be a security risk (see https://stackoverflow.com/questions/19603097/why-is-it-dangerous-to-render-user-generated-html-or-javascript) -unsafe_allow_html = false - -# Process and display mathematical expressions. This can clash with "$" characters in messages. +unsafe_allow_html = true latex = false - -# Autoscroll new user messages at the top of the window user_message_autoscroll = true - -# Automatically tag threads with the current chat profile (if a chat profile is used) auto_tag_thread = true - -# Allow users to edit their own messages edit_message = true - -# Allow users to share threads (backend + UI). Requires an app-defined on_shared_thread_view callback. allow_thread_sharing = false -[features.slack] -# Add emoji reaction when message is received (requires reactions:write OAuth scope) -reaction_on_message_received = false - -# Authorize users to spontaneously upload files with messages [features.spontaneous_file_upload] - enabled = true - # Define accepted file types using MIME types - # Examples: - # 1. For specific file types: - # accept = ["image/jpeg", "image/png", "application/pdf"] - # 2. For all files of certain type: - # accept = ["image/*", "audio/*", "video/*"] - # 3. For specific file extensions: - # accept = { "application/octet-stream" = [".xyz", ".pdb"] } - # Note: Using "*/*" is not recommended as it may cause browser warnings - accept = ["*/*"] - max_files = 20 - max_size_mb = 500 +enabled = true +accept = ["*"] +max_files = 20 +max_size_mb = 500 [features.audio] - # Enable audio features - enabled = false - # Sample rate of the audio - sample_rate = 24000 +enabled = false +sample_rate = 24000 [features.mcp] - # Enable Model Context Protocol (MCP) features - enabled = false - -[features.mcp.sse] - enabled = true - -[features.mcp.streamable-http] - enabled = true - -[features.mcp.stdio] - enabled = true - # Only the executables in the allow list can be used for MCP stdio server. - # Only need the base name of the executable, e.g. "npx", not "/usr/bin/npx". - # Please don't comment this line for now, we need it to parse the executable name. - allowed_executables = [ "npx", "uvx" ] +enabled = false [UI] -# Name of the assistant. -name = "Assistant" - -# default_theme = "dark" - -# layout = "wide" - +name = "Ai Station DFFM" +default_theme = "dark" +layout = "wide" default_sidebar_state = "open" -# Description of the assistant. This is used for HTML tags. -# description = "" +# Più “SaaS”: evita di mostrare troppo ragionamento di default +cot = "tool_call" -# Chain of Thought (CoT) display mode. Can be "hidden", "tool_call" or "full". -cot = "full" +custom_css = "/public/ui-s-tier.css" -# Specify a CSS file that can be used to customize the user interface. -# The CSS file can be served from the public directory or via an external link. -# custom_css = "/public/test.css" +[UI.theme] +primary_color = "#0066CC" +background_color = "#1a1a1a" +alert_style = "modern" -# Specify additional attributes for a custom CSS file -# custom_css_attributes = "media=\"print\"" +# Brand assets (metti i file in /public/brand/) +logo_file_url = "/public/brand/logo-header.png" +default_avatar_file_url = "/public/brand/avatar.png" +login_page_image = "/public/brand/login.jpg" +login_page_image_filter = "brightness-50 grayscale" -# Specify a JavaScript file that can be used to customize the user interface. -# The JavaScript file can be served from the public directory. -# custom_js = "/public/test.js" +[[UI.header_links]] +name = "Docs" +display_name = "Docs" +icon_url = "/public/brand/icon-32.png" +url = "https://ai.dffm.it" +target = "_blank" -# The style of alert boxes. Can be "classic" or "modern". -alert_style = "classic" - -# Specify additional attributes for custom JS file -# custom_js_attributes = "async type = \"module\"" - -# Custom login page image, relative to public directory or external URL -# login_page_image = "/public/custom-background.jpg" - -# Custom login page image filter (Tailwind internal filters, no dark/light variants) -# login_page_image_filter = "brightness-50 grayscale" -# login_page_image_dark_filter = "contrast-200 blur-sm" - -# Specify a custom meta URL (used for meta tags like og:url) -# custom_meta_url = "https://github.com/Chainlit/chainlit" - -# Specify a custom meta image url. -# custom_meta_image_url = "https://chainlit-cloud.s3.eu-west-3.amazonaws.com/logo/chainlit_banner.png" - -# Load assistant logo directly from URL. -logo_file_url = "" - -# Load assistant avatar image directly from URL. -default_avatar_file_url = "" - -# Specify a custom build directory for the frontend. -# This can be used to customize the frontend code. -# Be careful: If this is a relative path, it should not start with a slash. -# custom_build = "./public/build" - -# Specify optional one or more custom links in the header. -# [[UI.header_links]] -# name = "Issues" -# display_name = "Report Issue" -# icon_url = "https://avatars.githubusercontent.com/u/128686189?s=200&v=4" -# url = "https://github.com/Chainlit/chainlit/issues" -# target = "_blank" (default) # Optional: "_self", "_parent", "_top". +[[UI.header_links]] +name = "Support" +display_name = "Support" +icon_url = "/public/brand/icon-32.png" +url = "mailto:support@dffm.it" +target = "_blank" [meta] generated_by = "2.8.3" diff --git a/app.py b/app.py index 230d6b51..4d76c44c 100644 --- a/app.py +++ b/app.py @@ -2,17 +2,29 @@ import os import re import uuid import shutil +import requests +import time from datetime import datetime -from typing import Optional, Dict, List +from typing import Optional, Dict, Any, List + import chainlit as cl import ollama -import fitz # PyMuPDF -from qdrant_client import AsyncQdrantClient -from qdrant_client.models import PointStruct, Distance, VectorParams -from chainlit.data.sql_alchemy import SQLAlchemyDataLayer -# === FIX IMPORT ROBUSTO === -# Gestisce le differenze tra le versioni di Chainlit 2.x +from docling.document_converter import DocumentConverter +from qdrant_client import AsyncQdrantClient +from qdrant_client.models import ( + PointStruct, + Distance, + VectorParams, + SparseVectorParams, + Prefetch, +) + +from chainlit.data.sql_alchemy import SQLAlchemyDataLayer +from chainlit.types import ThreadDict +from functools import lru_cache + +# === FIX IMPORT ROBUSTO Storage Client === try: from chainlit.data.storage_clients import BaseStorageClient except ImportError: @@ -21,68 +33,139 @@ except ImportError: except ImportError: from chainlit.data.storage_clients.base import BaseStorageClient -# === CONFIGURAZIONE === -DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://ai_user:secure_password_here@postgres:5432/ai_station") + +# ========================= +# CONFIG +# ========================= +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql+asyncpg://ai_user:secure_password_here@postgres:5432/ai_station", +) OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.1.243:11434") QDRANT_URL = os.getenv("QDRANT_URL", "http://qdrant:6333") +BGE_API_URL = os.getenv("BGE_API_URL", "http://192.168.1.243:8001/embed") + +VISION_MODEL = "minicpm-v" + +DEFAULT_TEXT_MODEL = "glm-4.6:cloud" +MINIMAX_MODEL = "minimax-m2.1:cloud" + +MODEL_CHOICES = [ + DEFAULT_TEXT_MODEL, + MINIMAX_MODEL, + "llama3.2", + "mistral", + "qwen2.5-coder:32b", +] + WORKSPACES_DIR = "./workspaces" STORAGE_DIR = "./.files" - os.makedirs(STORAGE_DIR, exist_ok=True) os.makedirs(WORKSPACES_DIR, exist_ok=True) -# === MAPPING UTENTI E RUOLI === +# ========================= +# USER PROFILES +# ========================= USER_PROFILES = { "giuseppe@defranceschi.pro": { "role": "admin", "name": "Giuseppe", "workspace": "admin_workspace", "rag_collection": "admin_docs", - "capabilities": ["debug", "system_prompts", "user_management", "all_models"], - "show_code": True + "capabilities": ["debug", "all"], + "show_code": True, }, "federica.tecchio@gmail.com": { "role": "business", "name": "Federica", "workspace": "business_workspace", "rag_collection": "contabilita", - "capabilities": ["pdf_upload", "basic_chat"], - "show_code": False + "capabilities": ["basic_chat"], + "show_code": False, }, - "giuseppe.defranceschi@gmail.com": { - "role": "admin", - "name": "Giuseppe", - "workspace": "admin_workspace", - "rag_collection": "admin_docs", - "capabilities": ["debug", "system_prompts", "user_management", "all_models"], - "show_code": True - }, - "riccardob545@gmail.com": { + "riccardob545@gmail.com": { "role": "engineering", "name": "Riccardo", "workspace": "engineering_workspace", "rag_collection": "engineering_docs", - "capabilities": ["code_execution", "data_viz", "advanced_chat"], - "show_code": True + "capabilities": ["code"], + "show_code": True, }, "giuliadefranceschi05@gmail.com": { "role": "architecture", "name": "Giulia", "workspace": "architecture_workspace", "rag_collection": "architecture_manuals", - "capabilities": ["visual_chat", "pdf_upload", "image_gen"], - "show_code": False - } + "capabilities": ["visual"], + "show_code": False, + }, + "giuseppe.defranceschi@gmail.com": { + "role": "architecture", + "name": "Giuseppe", + "workspace": "architecture_workspace", + "rag_collection": "architecture_manuals", + "capabilities": ["visual"], + "show_code": False, + }, } -# === CUSTOM LOCAL STORAGE CLIENT (FIXED) ===# Questa classe ora implementa tutti i metodi astratti richiesti da Chainlit 2.8.3 +GUEST_PROFILE = { + "role": "guest", + "name": "Guest", + "workspace": "guest", + "rag_collection": "public", + "capabilities": ["basic_chat"], + "show_code": False, +} + +# Sensible defaults per ruolo (S-Tier: thoughtful defaults) [file:3] +ROLE_DEFAULTS = { + "admin": { + "model": DEFAULT_TEXT_MODEL, + "top_k": 6, + "temperature": 0.3, + "rag_enabled": True, + "vision_detail": "high", + }, + "engineering": { + "model": MINIMAX_MODEL, + "top_k": 5, + "temperature": 0.3, + "rag_enabled": True, + "vision_detail": "low", + }, + "business": { + "model": DEFAULT_TEXT_MODEL, + "top_k": 4, + "temperature": 0.2, + "rag_enabled": True, + "vision_detail": "auto", + }, + "architecture": { + "model": DEFAULT_TEXT_MODEL, + "top_k": 4, + "temperature": 0.3, + "rag_enabled": True, + "vision_detail": "high", + }, + "guest": { + "model": DEFAULT_TEXT_MODEL, + "top_k": 3, + "temperature": 0.2, + "rag_enabled": False, + "vision_detail": "auto", + }, +} + + +# ========================= +# STORAGE +# ========================= class LocalStorageClient(BaseStorageClient): - """Storage locale su filesystem per file/elementi""" - def __init__(self, storage_path: str): self.storage_path = storage_path os.makedirs(storage_path, exist_ok=True) - + async def upload_file( self, object_key: str, @@ -96,30 +179,28 @@ class LocalStorageClient(BaseStorageClient): f.write(data) return {"object_key": object_key, "url": f"/files/{object_key}"} - # Implementazione metodi obbligatori mancanti nella versione precedente async def get_read_url(self, object_key: str) -> str: return f"/files/{object_key}" async def delete_file(self, object_key: str) -> bool: - file_path = os.path.join(self.storage_path, object_key) - if os.path.exists(file_path): - os.remove(file_path) + path = os.path.join(self.storage_path, object_key) + if os.path.exists(path): + os.remove(path) return True return False async def close(self): pass -# === DATA LAYER === + @cl.data_layer def get_data_layer(): - return SQLAlchemyDataLayer( - conninfo=DATABASE_URL, - user_thread_limit=1000, - storage_provider=LocalStorageClient(storage_path=STORAGE_DIR) - ) + return SQLAlchemyDataLayer(conninfo=DATABASE_URL, storage_provider=LocalStorageClient(STORAGE_DIR)) -# === OAUTH CALLBACK === + +# ========================= +# OAUTH +# ========================= @cl.oauth_callback def oauth_callback( provider_id: str, @@ -129,216 +210,414 @@ def oauth_callback( ) -> Optional[cl.User]: if provider_id == "google": email = raw_user_data.get("email", "").lower() - - # Verifica se utente è autorizzato (opzionale: blocca se non in lista) - # if email not in USER_PROFILES: - # return None - - # Recupera profilo o usa default Guest - profile = USER_PROFILES.get(email, get_user_profile("guest")) - - default_user.metadata.update({ - "picture": raw_user_data.get("picture", ""), - "role": profile["role"], - "workspace": profile["workspace"], - "rag_collection": profile["rag_collection"], - "capabilities": profile["capabilities"], - "show_code": profile["show_code"], - "display_name": profile["name"] - }) + profile = USER_PROFILES.get(email, GUEST_PROFILE) + + default_user.metadata.update( + { + "role": profile["role"], + "workspace": profile["workspace"], + "rag_collection": profile["rag_collection"], + "show_code": profile["show_code"], + "display_name": profile["name"], + } + ) return default_user return default_user -# === UTILITY FUNCTIONS === -def get_user_profile(user_email: str) -> Dict: - return USER_PROFILES.get(user_email.lower(), { - "role": "guest", - "name": "Ospite", - "workspace": "guest_workspace", - "rag_collection": "documents", - "capabilities": [], - "show_code": False - }) def create_workspace(workspace_name: str) -> str: path = os.path.join(WORKSPACES_DIR, workspace_name) os.makedirs(path, exist_ok=True) return path -def save_code_to_file(code: str, workspace: str) -> str: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - file_name = f"code_{timestamp}.py" - file_path = os.path.join(WORKSPACES_DIR, workspace, file_name) - with open(file_path, "w", encoding="utf-8") as f: - f.write(code) - return file_path -def extract_text_from_pdf(pdf_path: str) -> str: +# ========================= +# CORE: DOCLING +# ========================= +def process_file_with_docling(file_path: str) -> str: try: - doc = fitz.open(pdf_path) - text = "\n".join([page.get_text() for page in doc]) - doc.close() - return text - except Exception: + converter = DocumentConverter() + result = converter.convert(file_path) + return result.document.export_to_markdown() + except Exception as e: + print(f"❌ Docling Error: {e}") return "" -# === QDRANT FUNCTIONS === -async def get_qdrant_client() -> AsyncQdrantClient: - return AsyncQdrantClient(url=QDRANT_URL) +# ========================= +# CORE: BGE-M3 embeddings +# ========================= +def get_bge_embeddings(text: str) -> Optional[Dict[str, Any]]: + try: + payload = {"texts": [text[:8000]]} + response = requests.post(BGE_API_URL, json=payload, timeout=30) + response.raise_for_status() + data = response.json().get("data", []) + if data: + return data[0] + return None + except Exception as e: + print(f"❌ BGE API Error: {e}") + return None + + +@lru_cache(maxsize=1000) +def get_bge_embeddings_cached(text: str): + return get_bge_embeddings(text) + + +# ========================= +# CORE: QDRANT +# ========================= async def ensure_collection(collection_name: str): - client = await get_qdrant_client() + client = AsyncQdrantClient(url=QDRANT_URL) if not await client.collection_exists(collection_name): await client.create_collection( collection_name=collection_name, - vectors_config=VectorParams(size=768, distance=Distance.COSINE) + vectors_config={"dense": VectorParams(size=1024, distance=Distance.COSINE)}, + sparse_vectors_config={"sparse": SparseVectorParams()}, ) -async def get_embeddings(text: str) -> list: - client = ollama.Client(host=OLLAMA_URL) - try: - response = client.embed(model='nomic-embed-text', input=text[:2000]) - if 'embeddings' in response: return response['embeddings'][0] - return response.get('embedding', []) - except: return [] -async def index_document(file_name: str, content: str, collection_name: str) -> bool: - try: - await ensure_collection(collection_name) - embedding = await get_embeddings(content) - if not embedding: return False - - qdrant = await get_qdrant_client() - await qdrant.upsert( - collection_name=collection_name, - points=[PointStruct( - id=str(uuid.uuid4()), - vector=embedding, - payload={"file_name": file_name, "content": content[:3000], "indexed_at": datetime.now().isoformat()} - )] - ) - return True - except: return False +async def index_document(file_name: str, content: str, collection_name: str) -> int: + await ensure_collection(collection_name) + client = AsyncQdrantClient(url=QDRANT_URL) -async def search_qdrant(query: str, collection: str) -> str: - try: - client = await get_qdrant_client() - if not await client.collection_exists(collection): return "" - emb = await get_embeddings(query) - if not emb: return "" - res = await client.query_points(collection_name=collection, query=emb, limit=3) - return "\n\n".join([hit.payload['content'] for hit in res.points if hit.payload]) - except: return "" + chunk_size = 2000 + overlap = 200 + points: List[PointStruct] = [] -# === CHAINLIT HANDLERS === + for i in range(0, len(content), chunk_size - overlap): + chunk = content[i : i + chunk_size] + embedding_data = get_bge_embeddings(chunk) + if embedding_data: + points.append( + PointStruct( + id=str(uuid.uuid4()), + vector={"dense": embedding_data["dense"], "sparse": embedding_data["sparse"]}, + payload={ + "file_name": file_name, + "content": chunk, + "indexed_at": datetime.now().isoformat(), + }, + ) + ) + if points: + await client.upsert(collection_name=collection_name, points=points) + return len(points) + + return 0 + + +async def search_hybrid(query: str, collection_name: str, limit: int = 4) -> str: + client = AsyncQdrantClient(url=QDRANT_URL) + + if not await client.collection_exists(collection_name): + return "" + + query_emb = get_bge_embeddings(query) + if not query_emb: + return "" + + results = await client.query_points( + collection_name=collection_name, + prefetch=[Prefetch(query=query_emb["sparse"], using="sparse", limit=limit * 2)], + query=query_emb["dense"], + using="dense", + limit=limit, + ) + + context = [] + for hit in results.points: + context.append(f"--- DA {hit.payload['file_name']} ---\n{hit.payload['content']}") + return "\n\n".join(context) + + +# ========================= +# UX HELPERS (S-Tier: clarity, consistency) +# ========================= +def role_to_badge_class(role: str) -> str: + allowed = {"admin", "engineering", "business", "architecture", "guest"} + return f"dfm-badge--{role}" if role in allowed else "dfm-badge--guest" + + +def build_system_prompt(system_instruction: str, has_rag: bool, has_files: bool) -> str: + base = ( + "Sei un assistente tecnico esperto.\n" + "Obiettivo: rispondere in modo chiaro, preciso e operativo.\n" + "- Se mancano dettagli, fai 1-2 domande mirate.\n" + "- Se scrivi codice, includi snippet piccoli e verificabili.\n" + ) + if has_rag: + base += "- Usa il contesto RAG come fonte primaria quando presente.\n" + if has_files: + base += "- Se sono presenti file/immagini, sfrutta le informazioni estratte.\n" + + if system_instruction.strip(): + base += "\nIstruzione aggiuntiva (utente): " + system_instruction.strip() + "\n" + + return base + + +def extract_code_blocks(text: str) -> List[str]: + return re.findall(r"```(?:\w+)?\n(.*?)```", text, re.DOTALL) + + +async def log_metrics(metrics: dict): + # Mantieni semplice: stdout (come nella tua versione) [file:6] + print("METRICS:", metrics) + + +# ========================= +# CHAINLIT HANDLERS +# ========================= @cl.on_chat_start -async def on_chat_start(): +async def start(): + # 1) Profilo utente user = cl.user_session.get("user") - - if not user: - # Fallback locale se non c'è auth - user_email = "guest@local" - profile = get_user_profile(user_email) - else: - user_email = user.identifier - # I metadati sono già popolati dalla callback oauth - profile = USER_PROFILES.get(user_email, get_user_profile("guest")) + email = user.identifier if user else "guest" + + profile = USER_PROFILES.get(email, GUEST_PROFILE) + cl.user_session.set("profile", profile) - # Salva in sessione - cl.user_session.set("email", user_email) - cl.user_session.set("role", profile["role"]) - cl.user_session.set("workspace", profile["workspace"]) - cl.user_session.set("rag_collection", profile["rag_collection"]) - cl.user_session.set("show_code", profile["show_code"]) - create_workspace(profile["workspace"]) - # === SETTINGS WIDGETS === - settings_widgets = [ - cl.input_widget.Select( - id="model", - label="Modello AI", - values=["glm-4.6:cloud", "llama3.2", "mistral", "qwen2.5-coder:32b"], - initial_value="glm-4.6:cloud", - ), - cl.input_widget.Slider( - id="temperature", - label="Temperatura", - initial=0.7, min=0, max=2, step=0.1, - ), - ] - if profile["role"] == "admin": - settings_widgets.append(cl.input_widget.Switch(id="rag_enabled", label="Abilita RAG", initial=True)) - - await cl.ChatSettings(settings_widgets).send() - - await cl.Message( - content=f"👋 Ciao **{profile['name']}**!\n" - f"Ruolo: `{profile['role']}` | Workspace: `{profile['workspace']}`\n" + role = profile.get("role", "guest") + defaults = ROLE_DEFAULTS.get(role, ROLE_DEFAULTS["guest"]) + cl.user_session.set("role_defaults", defaults) + + # 2) Badge (HTML controllato; stile via CSS) + badge_html = f""" +
+ {profile['name']} + {role.upper()} + · {profile['workspace']} +
+ """ + await cl.Message(content=badge_html).send() + + # 3) Settings UI (Clarity + sensible defaults) + settings = await cl.ChatSettings( + [ + cl.input_widget.Switch( + id="rag_enabled", + label="📚 Usa Conoscenza Documenti", + initial=bool(defaults["rag_enabled"]), + description="Attiva la ricerca nei documenti caricati (consigliato).", + ), + cl.input_widget.Slider( + id="top_k", + label="Profondità Ricerca (documenti)", + initial=int(defaults["top_k"]), + min=1, + max=10, + step=1, + description="Più documenti = risposta più completa ma più lenta.", + ), + cl.input_widget.Select( + id="model", + label="🤖 Modello AI", + values=MODEL_CHOICES, + initial_value=str(defaults["model"]), + ), + cl.input_widget.Slider( + id="temperature", + label="Creatività", + initial=float(defaults["temperature"]), + min=0, + max=1, + step=0.1, + description="Bassa = più precisione (consigliato per codice).", + ), + cl.input_widget.Select( + id="vision_detail", + label="🔍 Dettaglio Analisi Immagini", + values=["auto", "low", "high"], + initial_value=str(defaults["vision_detail"]), + ), + cl.input_widget.TextInput( + id="system_instruction", + label="✏️ Istruzione Sistema (opzionale)", + initial="", + placeholder="es: Rispondi con bullet points e includi esempi", + description="Personalizza stile/format delle risposte.", + ), + ] ).send() -@cl.on_settings_update -async def on_settings_update(settings): cl.user_session.set("settings", settings) - await cl.Message(content="✅ Impostazioni aggiornate").send() + + await cl.Message( + content=( + "✅ Ai Station online.\n" + f"• Workspace: `{profile['workspace']}`\n" + f"• Default modello: `{defaults['model']}`\n" + f"• Vision: `{VISION_MODEL}`" + ) + ).send() + + +@cl.on_settings_update +async def setupagentsettings(settings): + cl.user_session.set("settings", settings) + + await cl.Message( + content=( + "✅ Impostazioni aggiornate:\n" + f"• Modello: `{settings.get('model')}`\n" + f"• RAG: {'ON' if settings.get('rag_enabled') else 'OFF'} · top_k={settings.get('top_k')}\n" + f"• Creatività: {settings.get('temperature')}\n" + f"• Vision detail: `{settings.get('vision_detail')}`" + ) + ).send() + + +@cl.on_chat_resume +async def on_chat_resume(thread: ThreadDict): + user_identifier = thread.get("userIdentifier") + profile = USER_PROFILES.get(user_identifier, GUEST_PROFILE) + cl.user_session.set("profile", profile) + create_workspace(profile["workspace"]) + await cl.Message(content="Bentornato! Riprendiamo da qui.").send() + @cl.on_message -async def on_message(message: cl.Message): - workspace = cl.user_session.get("workspace") - rag_collection = cl.user_session.get("rag_collection") - user_role = cl.user_session.get("role") - show_code = cl.user_session.get("show_code") - - settings = cl.user_session.get("settings", {}) - model = settings.get("model", "glm-4.6:cloud") - temperature = settings.get("temperature", 0.7) - rag_enabled = settings.get("rag_enabled", True) if user_role == "admin" else True +async def main(message: cl.Message): + start_time = time.time() + + profile = cl.user_session.get("profile", GUEST_PROFILE) + settings = cl.user_session.get("settings", {}) + + selected_model = settings.get("model", DEFAULT_TEXT_MODEL) + temperature = float(settings.get("temperature", 0.3)) + rag_enabled = bool(settings.get("rag_enabled", True)) + top_k = int(settings.get("top_k", 4)) + vision_detail = settings.get("vision_detail", "auto") + system_instruction = (settings.get("system_instruction", "") or "").strip() + + workspace = create_workspace(profile["workspace"]) + + # 1) Gestione upload (immagini / pdf / docx) + images_for_vision: List[str] = [] + doc_context = "" - # 1. GESTIONE FILE if message.elements: for element in message.elements: - dest = os.path.join(WORKSPACES_DIR, workspace, element.name) - shutil.copy(element.path, dest) - if element.name.endswith(".pdf"): - text = extract_text_from_pdf(dest) - if text: - await index_document(element.name, text, rag_collection) - await cl.Message(content=f"✅ **{element.name}** indicizzato.").send() + file_path = os.path.join(workspace, element.name) + shutil.copy(element.path, file_path) - # 2. RAG - context = "" - if rag_enabled: - context = await search_qdrant(message.content, rag_collection) - - system_prompt = "Sei un assistente esperto." - if context: system_prompt += f"\n\nCONTESTO:\n{context}" - - # 3. GENERAZIONE - client = ollama.AsyncClient(host=OLLAMA_URL) + # Immagini + if "image" in (element.mime or ""): + images_for_vision.append(file_path) + msg_img = cl.Message(content=f"🖼️ Analizzo immagine `{element.name}` con `{VISION_MODEL}`...") + await msg_img.send() + + try: + with open(file_path, "rb") as imgfile: + imgbytes = imgfile.read() + + client_sync = ollama.Client(host=OLLAMA_URL) + res = client_sync.chat( + model=VISION_MODEL, + messages=[ + { + "role": "user", + "content": ( + "Analizza questa immagine tecnica. " + "Trascrivi testi/codici e descrivi diagrammi o tabelle in dettaglio. " + f"Dettaglio richiesto: {vision_detail}." + ), + "images": [imgbytes], + } + ], + ) + desc = res.get("message", {}).get("content", "") + doc_context += f"\n\n## DESCRIZIONE IMMAGINE: {element.name}\n{desc}\n" + msg_img.content = f"✅ Immagine analizzata: {desc[:300]}..." + await msg_img.update() + except Exception as e: + msg_img.content = f"❌ Errore analisi immagine: {e}" + await msg_img.update() + + # Documenti (pdf/docx) + elif element.name.lower().endswith((".pdf", ".docx")): + msg_doc = cl.Message(content=f"📄 Leggo `{element.name}` con Docling (tabelle/formule)...") + await msg_doc.send() + + markdown_content = process_file_with_docling(file_path) + if markdown_content: + chunks = await index_document(element.name, markdown_content, profile["rag_collection"]) + doc_context += f"\n\n## CONTENUTO FILE: {element.name}\n{markdown_content[:2000]}\n" + msg_doc.content = f"✅ `{element.name}` convertito e indicizzato ({chunks} chunks)." + else: + msg_doc.content = f"❌ Errore lettura `{element.name}`." + await msg_doc.update() + + # 2) RAG retrieval (solo se attivo e senza immagini-only flow) + rag_context = "" + if rag_enabled and not images_for_vision: + rag_context = await search_hybrid(message.content, profile["rag_collection"], limit=top_k) + + has_rag = bool(rag_context.strip()) + has_files = bool(doc_context.strip()) + + # 3) Prompt building + system_prompt = build_system_prompt(system_instruction, has_rag=has_rag, has_files=has_files) + + final_context = "" + if has_rag: + final_context += "\n\n# CONTESTO RAG\n" + rag_context + if has_files: + final_context += "\n\n# CONTESTO FILE SESSIONE\n" + doc_context + + # 4) Generazione (stream) msg = cl.Message(content="") await msg.send() - - stream = await client.chat( - model=model, - messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": message.content}], - options={"temperature": temperature}, - stream=True - ) - - full_resp = "" - async for chunk in stream: - token = chunk['message']['content'] - full_resp += token - await msg.stream_token(token) - await msg.update() - # 4. SALVATAGGIO CODICE - if show_code: - blocks = re.findall(r"``````", full_resp, re.DOTALL) - elements = [] - for code in blocks: - path = save_code_to_file(code.strip(), workspace) - elements.append(cl.File(name=os.path.basename(path), path=path, display="inline")) - if elements: - await cl.Message(content="💾 Codice salvato", elements=elements).send() \ No newline at end of file + error: Optional[str] = None + try: + client_async = ollama.AsyncClient(host=OLLAMA_URL) + stream = await client_async.chat( + model=selected_model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"Domanda: {message.content}\n{final_context}"}, + ], + options={"temperature": temperature}, + stream=True, + ) + async for chunk in stream: + content = chunk.get("message", {}).get("content", "") + if content: + await msg.stream_token(content) + + await msg.update() + except Exception as e: + error = str(e) + await msg.stream_token(f"\n\n❌ Errore AI: {error}") + await msg.update() + + # 5) Salvataggio code blocks (solo per profili con show_code) + if profile.get("show_code", False) and msg.content: + codeblocks = extract_code_blocks(msg.content) + if codeblocks: + for i, code in enumerate(codeblocks): + fname = f"script_{datetime.now().strftime('%H%M%S')}_{i}.py" + try: + with open(os.path.join(workspace, fname), "w", encoding="utf-8") as f: + f.write(code.strip()) + await cl.Message(content=f"💾 Script salvato: `{fname}`").send() + except Exception as e: + await cl.Message(content=f"❌ Errore salvataggio `{fname}`: {e}").send() + + # 6) Metriche + elapsed = time.time() - start_time + metrics = { + "response_time": elapsed, + "rag_hits": rag_context.count("--- DA ") if rag_context else 0, + "model": selected_model, + "user_role": profile.get("role", "unknown"), + "error": error, + } + await log_metrics(metrics) diff --git a/docker-compose.yml b/docker-compose.yml index c32439ea..82331c4b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: chainlit-app: build: . diff --git a/requirements.txt b/requirements.txt index 0f574f8c..e50a753c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,4 +26,7 @@ aiofiles>=23.0.0 sniffio aiohttp boto3>=1.28.0 -azure-storage-file-datalake>=12.14.0 \ No newline at end of file +azure-storage-file-datalake>=12.14.0 +docling +pillow +requests \ No newline at end of file