feat: pre-security cleanup

This commit is contained in:
AI Station Server 2026-01-20 15:21:49 +01:00
parent b007b6c767
commit c3931d78b1
4 changed files with 535 additions and 340 deletions

View File

@ -1,153 +1,68 @@
[project] [project]
# List of environment variables to be provided by each user to use the app.
user_env = [] user_env = []
# Duration (in seconds) during which the session is saved when the connection is lost
session_timeout = 3600 session_timeout = 3600
user_session_timeout = 1296000
# Duration (in seconds) of the user session expiry
user_session_timeout = 1296000 # 15 days
# Enable third parties caching (e.g., LangChain cache)
cache = false 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 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 mask_user_env = false
# Authorized origins
allow_origins = ["*"] allow_origins = ["*"]
[features] [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 = true
unsafe_allow_html = false
# Process and display mathematical expressions. This can clash with "$" characters in messages.
latex = false latex = false
# Autoscroll new user messages at the top of the window
user_message_autoscroll = true user_message_autoscroll = true
# Automatically tag threads with the current chat profile (if a chat profile is used)
auto_tag_thread = true auto_tag_thread = true
# Allow users to edit their own messages
edit_message = true edit_message = true
# Allow users to share threads (backend + UI). Requires an app-defined on_shared_thread_view callback.
allow_thread_sharing = false 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] [features.spontaneous_file_upload]
enabled = true enabled = true
# Define accepted file types using MIME types accept = ["*"]
# 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_files = 20
max_size_mb = 500 max_size_mb = 500
[features.audio] [features.audio]
# Enable audio features
enabled = false enabled = false
# Sample rate of the audio
sample_rate = 24000 sample_rate = 24000
[features.mcp] [features.mcp]
# Enable Model Context Protocol (MCP) features
enabled = false 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" ]
[UI] [UI]
# Name of the assistant. name = "Ai Station DFFM"
name = "Assistant" default_theme = "dark"
layout = "wide"
# default_theme = "dark"
# layout = "wide"
default_sidebar_state = "open" default_sidebar_state = "open"
# Description of the assistant. This is used for HTML tags. # Più “SaaS”: evita di mostrare troppo ragionamento di default
# description = "" cot = "tool_call"
# Chain of Thought (CoT) display mode. Can be "hidden", "tool_call" or "full". custom_css = "/public/ui-s-tier.css"
cot = "full"
# Specify a CSS file that can be used to customize the user interface. [UI.theme]
# The CSS file can be served from the public directory or via an external link. primary_color = "#0066CC"
# custom_css = "/public/test.css" background_color = "#1a1a1a"
alert_style = "modern"
# Specify additional attributes for a custom CSS file # Brand assets (metti i file in /public/brand/)
# custom_css_attributes = "media=\"print\"" 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. [[UI.header_links]]
# The JavaScript file can be served from the public directory. name = "Docs"
# custom_js = "/public/test.js" 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". [[UI.header_links]]
alert_style = "classic" name = "Support"
display_name = "Support"
# Specify additional attributes for custom JS file icon_url = "/public/brand/icon-32.png"
# custom_js_attributes = "async type = \"module\"" url = "mailto:support@dffm.it"
target = "_blank"
# 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".
[meta] [meta]
generated_by = "2.8.3" generated_by = "2.8.3"

655
app.py
View File

@ -2,17 +2,29 @@ import os
import re import re
import uuid import uuid
import shutil import shutil
import requests
import time
from datetime import datetime from datetime import datetime
from typing import Optional, Dict, List from typing import Optional, Dict, Any, List
import chainlit as cl import chainlit as cl
import ollama 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 === from docling.document_converter import DocumentConverter
# Gestisce le differenze tra le versioni di Chainlit 2.x 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: try:
from chainlit.data.storage_clients import BaseStorageClient from chainlit.data.storage_clients import BaseStorageClient
except ImportError: except ImportError:
@ -21,64 +33,135 @@ except ImportError:
except ImportError: except ImportError:
from chainlit.data.storage_clients.base import BaseStorageClient 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") OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.1.243:11434")
QDRANT_URL = os.getenv("QDRANT_URL", "http://qdrant:6333") 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" WORKSPACES_DIR = "./workspaces"
STORAGE_DIR = "./.files" STORAGE_DIR = "./.files"
os.makedirs(STORAGE_DIR, exist_ok=True) os.makedirs(STORAGE_DIR, exist_ok=True)
os.makedirs(WORKSPACES_DIR, exist_ok=True) os.makedirs(WORKSPACES_DIR, exist_ok=True)
# === MAPPING UTENTI E RUOLI === # =========================
# USER PROFILES
# =========================
USER_PROFILES = { USER_PROFILES = {
"giuseppe@defranceschi.pro": { "giuseppe@defranceschi.pro": {
"role": "admin", "role": "admin",
"name": "Giuseppe", "name": "Giuseppe",
"workspace": "admin_workspace", "workspace": "admin_workspace",
"rag_collection": "admin_docs", "rag_collection": "admin_docs",
"capabilities": ["debug", "system_prompts", "user_management", "all_models"], "capabilities": ["debug", "all"],
"show_code": True "show_code": True,
}, },
"federica.tecchio@gmail.com": { "federica.tecchio@gmail.com": {
"role": "business", "role": "business",
"name": "Federica", "name": "Federica",
"workspace": "business_workspace", "workspace": "business_workspace",
"rag_collection": "contabilita", "rag_collection": "contabilita",
"capabilities": ["pdf_upload", "basic_chat"], "capabilities": ["basic_chat"],
"show_code": False "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", "role": "engineering",
"name": "Riccardo", "name": "Riccardo",
"workspace": "engineering_workspace", "workspace": "engineering_workspace",
"rag_collection": "engineering_docs", "rag_collection": "engineering_docs",
"capabilities": ["code_execution", "data_viz", "advanced_chat"], "capabilities": ["code"],
"show_code": True "show_code": True,
}, },
"giuliadefranceschi05@gmail.com": { "giuliadefranceschi05@gmail.com": {
"role": "architecture", "role": "architecture",
"name": "Giulia", "name": "Giulia",
"workspace": "architecture_workspace", "workspace": "architecture_workspace",
"rag_collection": "architecture_manuals", "rag_collection": "architecture_manuals",
"capabilities": ["visual_chat", "pdf_upload", "image_gen"], "capabilities": ["visual"],
"show_code": False "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): class LocalStorageClient(BaseStorageClient):
"""Storage locale su filesystem per file/elementi"""
def __init__(self, storage_path: str): def __init__(self, storage_path: str):
self.storage_path = storage_path self.storage_path = storage_path
os.makedirs(storage_path, exist_ok=True) os.makedirs(storage_path, exist_ok=True)
@ -96,30 +179,28 @@ class LocalStorageClient(BaseStorageClient):
f.write(data) f.write(data)
return {"object_key": object_key, "url": f"/files/{object_key}"} 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: async def get_read_url(self, object_key: str) -> str:
return f"/files/{object_key}" return f"/files/{object_key}"
async def delete_file(self, object_key: str) -> bool: async def delete_file(self, object_key: str) -> bool:
file_path = os.path.join(self.storage_path, object_key) path = os.path.join(self.storage_path, object_key)
if os.path.exists(file_path): if os.path.exists(path):
os.remove(file_path) os.remove(path)
return True return True
return False return False
async def close(self): async def close(self):
pass pass
# === DATA LAYER ===
@cl.data_layer @cl.data_layer
def get_data_layer(): def get_data_layer():
return SQLAlchemyDataLayer( return SQLAlchemyDataLayer(conninfo=DATABASE_URL, storage_provider=LocalStorageClient(STORAGE_DIR))
conninfo=DATABASE_URL,
user_thread_limit=1000,
storage_provider=LocalStorageClient(storage_path=STORAGE_DIR)
)
# === OAUTH CALLBACK ===
# =========================
# OAUTH
# =========================
@cl.oauth_callback @cl.oauth_callback
def oauth_callback( def oauth_callback(
provider_id: str, provider_id: str,
@ -129,216 +210,414 @@ def oauth_callback(
) -> Optional[cl.User]: ) -> Optional[cl.User]:
if provider_id == "google": if provider_id == "google":
email = raw_user_data.get("email", "").lower() email = raw_user_data.get("email", "").lower()
profile = USER_PROFILES.get(email, GUEST_PROFILE)
# Verifica se utente è autorizzato (opzionale: blocca se non in lista) default_user.metadata.update(
# 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"], "role": profile["role"],
"workspace": profile["workspace"], "workspace": profile["workspace"],
"rag_collection": profile["rag_collection"], "rag_collection": profile["rag_collection"],
"capabilities": profile["capabilities"],
"show_code": profile["show_code"], "show_code": profile["show_code"],
"display_name": profile["name"] "display_name": profile["name"],
}) }
)
return default_user return default_user
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: def create_workspace(workspace_name: str) -> str:
path = os.path.join(WORKSPACES_DIR, workspace_name) path = os.path.join(WORKSPACES_DIR, workspace_name)
os.makedirs(path, exist_ok=True) os.makedirs(path, exist_ok=True)
return path 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: try:
doc = fitz.open(pdf_path) converter = DocumentConverter()
text = "\n".join([page.get_text() for page in doc]) result = converter.convert(file_path)
doc.close() return result.document.export_to_markdown()
return text except Exception as e:
except Exception: print(f"❌ Docling Error: {e}")
return "" 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): 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): if not await client.collection_exists(collection_name):
await client.create_collection( await client.create_collection(
collection_name=collection_name, 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: async def index_document(file_name: str, content: str, collection_name: str) -> int:
try:
await ensure_collection(collection_name) await ensure_collection(collection_name)
embedding = await get_embeddings(content) client = AsyncQdrantClient(url=QDRANT_URL)
if not embedding: return False
qdrant = await get_qdrant_client() chunk_size = 2000
await qdrant.upsert( overlap = 200
collection_name=collection_name, points: List[PointStruct] = []
points=[PointStruct(
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()), id=str(uuid.uuid4()),
vector=embedding, vector={"dense": embedding_data["dense"], "sparse": embedding_data["sparse"]},
payload={"file_name": file_name, "content": content[:3000], "indexed_at": datetime.now().isoformat()} payload={
)] "file_name": file_name,
"content": chunk,
"indexed_at": datetime.now().isoformat(),
},
)
) )
return True
except: return False
async def search_qdrant(query: str, collection: str) -> str: if points:
try: await client.upsert(collection_name=collection_name, points=points)
client = await get_qdrant_client() return len(points)
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 ""
# === CHAINLIT HANDLERS === 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 @cl.on_chat_start
async def on_chat_start(): async def start():
# 1) Profilo utente
user = cl.user_session.get("user") user = cl.user_session.get("user")
email = user.identifier if user else "guest"
if not user: profile = USER_PROFILES.get(email, GUEST_PROFILE)
# Fallback locale se non c'è auth cl.user_session.set("profile", profile)
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"))
# 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"]) create_workspace(profile["workspace"])
# === SETTINGS WIDGETS === role = profile.get("role", "guest")
settings_widgets = [ 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"""
<div class="dfm-badge {role_to_badge_class(role)}">
<span><b>{profile['name']}</b></span>
<span style="opacity:.8">{role.upper()}</span>
<span style="opacity:.7">· {profile['workspace']}</span>
</div>
"""
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( cl.input_widget.Select(
id="model", id="model",
label="Modello AI", label="🤖 Modello AI",
values=["glm-4.6:cloud", "llama3.2", "mistral", "qwen2.5-coder:32b"], values=MODEL_CHOICES,
initial_value="glm-4.6:cloud", initial_value=str(defaults["model"]),
), ),
cl.input_widget.Slider( cl.input_widget.Slider(
id="temperature", id="temperature",
label="Temperatura", label="Creatività",
initial=0.7, min=0, max=2, step=0.1, 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.",
), ),
] ]
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"
).send() ).send()
@cl.on_settings_update
async def on_settings_update(settings):
cl.user_session.set("settings", 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 @cl.on_message
async def on_message(message: cl.Message): async def main(message: cl.Message):
workspace = cl.user_session.get("workspace") start_time = time.time()
rag_collection = cl.user_session.get("rag_collection")
user_role = cl.user_session.get("role")
show_code = cl.user_session.get("show_code")
profile = cl.user_session.get("profile", GUEST_PROFILE)
settings = cl.user_session.get("settings", {}) 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
# 1. GESTIONE FILE 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 = ""
if message.elements: if message.elements:
for element in message.elements: for element in message.elements:
dest = os.path.join(WORKSPACES_DIR, workspace, element.name) file_path = os.path.join(workspace, element.name)
shutil.copy(element.path, dest) shutil.copy(element.path, file_path)
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()
# 2. RAG # Immagini
context = "" if "image" in (element.mime or ""):
if rag_enabled: images_for_vision.append(file_path)
context = await search_qdrant(message.content, rag_collection) msg_img = cl.Message(content=f"🖼️ Analizzo immagine `{element.name}` con `{VISION_MODEL}`...")
await msg_img.send()
system_prompt = "Sei un assistente esperto." try:
if context: system_prompt += f"\n\nCONTESTO:\n{context}" with open(file_path, "rb") as imgfile:
imgbytes = imgfile.read()
# 3. GENERAZIONE client_sync = ollama.Client(host=OLLAMA_URL)
client = ollama.AsyncClient(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="") msg = cl.Message(content="")
await msg.send() await msg.send()
stream = await client.chat( error: Optional[str] = None
model=model, try:
messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": message.content}], 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}, options={"temperature": temperature},
stream=True stream=True,
) )
full_resp = ""
async for chunk in stream: async for chunk in stream:
token = chunk['message']['content'] content = chunk.get("message", {}).get("content", "")
full_resp += token if content:
await msg.stream_token(token) 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() await msg.update()
# 4. SALVATAGGIO CODICE # 5) Salvataggio code blocks (solo per profili con show_code)
if show_code: if profile.get("show_code", False) and msg.content:
blocks = re.findall(r"``````", full_resp, re.DOTALL) codeblocks = extract_code_blocks(msg.content)
elements = [] if codeblocks:
for code in blocks: for i, code in enumerate(codeblocks):
path = save_code_to_file(code.strip(), workspace) fname = f"script_{datetime.now().strftime('%H%M%S')}_{i}.py"
elements.append(cl.File(name=os.path.basename(path), path=path, display="inline")) try:
if elements: with open(os.path.join(workspace, fname), "w", encoding="utf-8") as f:
await cl.Message(content="💾 Codice salvato", elements=elements).send() 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)

View File

@ -1,5 +1,3 @@
version: '3.8'
services: services:
chainlit-app: chainlit-app:
build: . build: .

View File

@ -27,3 +27,6 @@ sniffio
aiohttp aiohttp
boto3>=1.28.0 boto3>=1.28.0
azure-storage-file-datalake>=12.14.0 azure-storage-file-datalake>=12.14.0
docling
pillow
requests