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""" +