feat: pre-security cleanup
This commit is contained in:
parent
b007b6c767
commit
c3931d78b1
|
|
@ -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 = ["*/*"]
|
||||
accept = ["*"]
|
||||
max_files = 20
|
||||
max_size_mb = 500
|
||||
|
||||
[features.audio]
|
||||
# Enable audio features
|
||||
enabled = false
|
||||
# Sample rate of the audio
|
||||
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" ]
|
||||
|
||||
[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"
|
||||
|
|
|
|||
655
app.py
655
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,64 +33,135 @@ 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
|
||||
},
|
||||
"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
|
||||
"capabilities": ["basic_chat"],
|
||||
"show_code": False,
|
||||
},
|
||||
"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)
|
||||
|
|
@ -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()
|
||||
profile = USER_PROFILES.get(email, GUEST_PROFILE)
|
||||
|
||||
# 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", ""),
|
||||
default_user.metadata.update(
|
||||
{
|
||||
"role": profile["role"],
|
||||
"workspace": profile["workspace"],
|
||||
"rag_collection": profile["rag_collection"],
|
||||
"capabilities": profile["capabilities"],
|
||||
"show_code": profile["show_code"],
|
||||
"display_name": profile["name"]
|
||||
})
|
||||
"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:
|
||||
async def index_document(file_name: str, content: str, collection_name: str) -> int:
|
||||
await ensure_collection(collection_name)
|
||||
embedding = await get_embeddings(content)
|
||||
if not embedding: return False
|
||||
client = AsyncQdrantClient(url=QDRANT_URL)
|
||||
|
||||
qdrant = await get_qdrant_client()
|
||||
await qdrant.upsert(
|
||||
collection_name=collection_name,
|
||||
points=[PointStruct(
|
||||
chunk_size = 2000
|
||||
overlap = 200
|
||||
points: List[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()),
|
||||
vector=embedding,
|
||||
payload={"file_name": file_name, "content": content[:3000], "indexed_at": datetime.now().isoformat()}
|
||||
)]
|
||||
vector={"dense": embedding_data["dense"], "sparse": embedding_data["sparse"]},
|
||||
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:
|
||||
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 ""
|
||||
if points:
|
||||
await client.upsert(collection_name=collection_name, points=points)
|
||||
return len(points)
|
||||
|
||||
# === 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
|
||||
async def on_chat_start():
|
||||
async def start():
|
||||
# 1) Profilo utente
|
||||
user = cl.user_session.get("user")
|
||||
email = user.identifier if user else "guest"
|
||||
|
||||
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"))
|
||||
|
||||
# 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"])
|
||||
profile = USER_PROFILES.get(email, GUEST_PROFILE)
|
||||
cl.user_session.set("profile", profile)
|
||||
|
||||
create_workspace(profile["workspace"])
|
||||
|
||||
# === SETTINGS WIDGETS ===
|
||||
settings_widgets = [
|
||||
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"""
|
||||
<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(
|
||||
id="model",
|
||||
label="Modello AI",
|
||||
values=["glm-4.6:cloud", "llama3.2", "mistral", "qwen2.5-coder:32b"],
|
||||
initial_value="glm-4.6:cloud",
|
||||
label="🤖 Modello AI",
|
||||
values=MODEL_CHOICES,
|
||||
initial_value=str(defaults["model"]),
|
||||
),
|
||||
cl.input_widget.Slider(
|
||||
id="temperature",
|
||||
label="Temperatura",
|
||||
initial=0.7, min=0, max=2, step=0.1,
|
||||
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.",
|
||||
),
|
||||
]
|
||||
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()
|
||||
|
||||
@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")
|
||||
async def main(message: cl.Message):
|
||||
start_time = time.time()
|
||||
|
||||
profile = cl.user_session.get("profile", GUEST_PROFILE)
|
||||
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:
|
||||
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)
|
||||
# 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()
|
||||
|
||||
system_prompt = "Sei un assistente esperto."
|
||||
if context: system_prompt += f"\n\nCONTESTO:\n{context}"
|
||||
try:
|
||||
with open(file_path, "rb") as imgfile:
|
||||
imgbytes = imgfile.read()
|
||||
|
||||
# 3. GENERAZIONE
|
||||
client = ollama.AsyncClient(host=OLLAMA_URL)
|
||||
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}],
|
||||
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
|
||||
stream=True,
|
||||
)
|
||||
|
||||
full_resp = ""
|
||||
async for chunk in stream:
|
||||
token = chunk['message']['content']
|
||||
full_resp += token
|
||||
await msg.stream_token(token)
|
||||
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()
|
||||
|
||||
# 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()
|
||||
# 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)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
chainlit-app:
|
||||
build: .
|
||||
|
|
|
|||
|
|
@ -27,3 +27,6 @@ sniffio
|
|||
aiohttp
|
||||
boto3>=1.28.0
|
||||
azure-storage-file-datalake>=12.14.0
|
||||
docling
|
||||
pillow
|
||||
requests
|
||||
Loading…
Reference in New Issue