Compare commits

..

2 Commits

Author SHA1 Message Date
AI Station Server bffd9aa249 Fix: Smart chunking per Excel e Hybrid Search funzionante 2025-12-30 17:06:13 +01:00
AI Station Server 9cef64f9ea implementazione BGE-M3 Dense 2025-12-30 08:51:10 +01:00
14 changed files with 388 additions and 553 deletions

View File

@ -1,68 +1,153 @@
[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]
unsafe_allow_html = true # 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.
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
accept = ["*"] # Define accepted file types using MIME types
max_files = 20 # Examples:
max_size_mb = 500 # 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
[features.audio] [features.audio]
enabled = false # Enable audio features
sample_rate = 24000 enabled = false
# Sample rate of the audio
sample_rate = 24000
[features.mcp] [features.mcp]
enabled = false # 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] [UI]
name = "Ai Station DFFM" # Name of the assistant.
default_theme = "dark" name = "Assistant"
layout = "wide"
# default_theme = "dark"
# layout = "wide"
default_sidebar_state = "open" default_sidebar_state = "open"
# Più “SaaS”: evita di mostrare troppo ragionamento di default # Description of the assistant. This is used for HTML tags.
cot = "tool_call" # description = ""
custom_css = "/public/ui-s-tier.css" # Chain of Thought (CoT) display mode. Can be "hidden", "tool_call" or "full".
cot = "full"
[UI.theme] # Specify a CSS file that can be used to customize the user interface.
primary_color = "#0066CC" # The CSS file can be served from the public directory or via an external link.
background_color = "#1a1a1a" # custom_css = "/public/test.css"
alert_style = "modern"
# Brand assets (metti i file in /public/brand/) # Specify additional attributes for a custom CSS file
logo_file_url = "/public/brand/logo-header.png" # custom_css_attributes = "media=\"print\""
default_avatar_file_url = "/public/brand/avatar.png"
login_page_image = "/public/brand/login.jpg"
login_page_image_filter = "brightness-50 grayscale"
[[UI.header_links]] # Specify a JavaScript file that can be used to customize the user interface.
name = "Docs" # The JavaScript file can be served from the public directory.
display_name = "Docs" # custom_js = "/public/test.js"
icon_url = "/public/brand/icon-32.png"
url = "https://ai.dffm.it"
target = "_blank"
[[UI.header_links]] # The style of alert boxes. Can be "classic" or "modern".
name = "Support" alert_style = "classic"
display_name = "Support"
icon_url = "/public/brand/icon-32.png" # Specify additional attributes for custom JS file
url = "mailto:support@dffm.it" # custom_js_attributes = "async type = \"module\""
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"

5
.gitignore vendored
View File

@ -3,7 +3,4 @@ __pycache__/
*.pyc *.pyc
.aider* .aider*
workspaces/* workspaces/*
qdrant_storage/.files/ qdrant_storage/
__pycache__/
.env
.files/

738
app.py
View File

@ -2,29 +2,19 @@ import os
import re import re
import uuid import uuid
import shutil import shutil
import requests import pandas as pd # NUOVO: Gestione Excel
import time import httpx # NUOVO: Chiamate API Remote
from datetime import datetime from datetime import datetime
from typing import Optional, Dict, Any, List from typing import Optional, Dict, List
import chainlit as cl import chainlit as cl
import ollama import ollama
import fitz # PyMuPDF
from docling.document_converter import DocumentConverter
from qdrant_client import AsyncQdrantClient from qdrant_client import AsyncQdrantClient
from qdrant_client.models import ( from qdrant_client import models
PointStruct, from qdrant_client.models import PointStruct, Distance, VectorParams, SparseVectorParams, SparseIndexParams
Distance,
VectorParams,
SparseVectorParams,
Prefetch,
)
from chainlit.data.sql_alchemy import SQLAlchemyDataLayer from chainlit.data.sql_alchemy import SQLAlchemyDataLayer
from chainlit.types import ThreadDict
from functools import lru_cache
# === FIX IMPORT ROBUSTO Storage Client === # === FIX IMPORT ===
try: try:
from chainlit.data.storage_clients import BaseStorageClient from chainlit.data.storage_clients import BaseStorageClient
except ImportError: except ImportError:
@ -33,591 +23,351 @@ 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 # PUNTANO AL SERVER .243 (Il "Cervello")
# =========================
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")
BGE_API_URL = os.getenv("BGE_API_URL", "http://192.168.1.243:8001")
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", "all"], "capabilities": ["debug", "system_prompts", "user_management", "all_models"],
"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": ["basic_chat"], "capabilities": ["pdf_upload", "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"], "capabilities": ["code_execution", "data_viz", "advanced_chat"],
"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"], "capabilities": ["visual_chat", "pdf_upload", "image_gen"],
"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,
},
} }
GUEST_PROFILE = { # === STORAGE CLIENT ===
"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):
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)
async def upload_file( async def upload_file(self, object_key: str, data: bytes, mime: str = "application/octet-stream", overwrite: bool = True) -> Dict[str, str]:
self,
object_key: str,
data: bytes,
mime: str = "application/octet-stream",
overwrite: bool = True,
) -> Dict[str, str]:
file_path = os.path.join(self.storage_path, object_key) file_path = os.path.join(self.storage_path, object_key)
os.makedirs(os.path.dirname(file_path), exist_ok=True) os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "wb") as f: with open(file_path, "wb") as f: 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}"}
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:
path = os.path.join(self.storage_path, object_key) path = os.path.join(self.storage_path, object_key)
if os.path.exists(path): if os.path.exists(path): os.remove(path); return True
os.remove(path)
return True
return False return False
async def close(self): pass
async def close(self):
pass
@cl.data_layer @cl.data_layer
def get_data_layer(): def get_data_layer():
return SQLAlchemyDataLayer(conninfo=DATABASE_URL, storage_provider=LocalStorageClient(STORAGE_DIR)) return SQLAlchemyDataLayer(conninfo=DATABASE_URL, user_thread_limit=1000, storage_provider=LocalStorageClient(STORAGE_DIR))
# === OAUTH ===
# =========================
# OAUTH
# =========================
@cl.oauth_callback @cl.oauth_callback
def oauth_callback( def oauth_callback(provider_id: str, token: str, raw_user_data: Dict[str, str], default_user: cl.User) -> Optional[cl.User]:
provider_id: str,
token: str,
raw_user_data: Dict[str, str],
default_user: 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) profile = USER_PROFILES.get(email, get_user_profile("guest"))
default_user.metadata.update({
default_user.metadata.update( "picture": raw_user_data.get("picture", ""),
{ "role": profile["role"], "workspace": profile["workspace"],
"role": profile["role"], "rag_collection": profile["rag_collection"], "capabilities": profile["capabilities"],
"workspace": profile["workspace"], "show_code": profile["show_code"], "display_name": profile["name"]
"rag_collection": profile["rag_collection"], })
"show_code": profile["show_code"],
"display_name": profile["name"],
}
)
return default_user return default_user
return default_user return default_user
def get_user_profile(email: str) -> Dict:
return USER_PROFILES.get(email.lower(), {"role": "guest", "name": "Ospite", "workspace": "guest_workspace", "rag_collection": "documents", "show_code": False})
def create_workspace(workspace_name: str) -> str: def create_workspace(name: str) -> str:
path = os.path.join(WORKSPACES_DIR, workspace_name) path = os.path.join(WORKSPACES_DIR, 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:
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = os.path.join(WORKSPACES_DIR, workspace, f"code_{ts}.py")
with open(path, "w", encoding="utf-8") as f: f.write(code)
return path
# ========================= # === PARSING DOCUMENTI ===
# CORE: DOCLING def extract_text_from_pdf(path: str) -> str:
# =========================
def process_file_with_docling(file_path: str) -> str:
try: try:
converter = DocumentConverter() doc = fitz.open(path)
result = converter.convert(file_path) return "\n".join([page.get_text() for page in doc])
return result.document.export_to_markdown() except: return ""
def extract_text_from_excel(path: str) -> str:
"""Estrae testo da Excel convertendo i fogli in Markdown"""
try:
xl = pd.read_excel(path, sheet_name=None)
text_content = []
for sheet, df in xl.items():
text_content.append(f"\n--- Foglio Excel: {sheet} ---\n")
# Pulisce NaN e converte in stringa
clean_df = df.fillna("").astype(str)
text_content.append(clean_df.to_markdown(index=False))
return "\n".join(text_content)
except Exception as e: except Exception as e:
print(f"❌ Docling Error: {e}") print(f"Errore Excel: {e}")
return "" return ""
# === AI & EMBEDDINGS (Remoto) ===
# ========================= async def get_embeddings(text: str) -> dict:
# CORE: BGE-M3 embeddings """Ritorna dict con keys 'dense' e 'sparse'"""
# =========================
def get_bge_embeddings(text: str) -> Optional[Dict[str, Any]]:
try: try:
payload = {"texts": [text[:8000]]} async with httpx.AsyncClient(timeout=30.0) as client:
response = requests.post(BGE_API_URL, json=payload, timeout=30) resp = await client.post(
response.raise_for_status() f"{BGE_API_URL}/embed",
data = response.json().get("data", []) json={"texts": [text]}
if data: )
return data[0] if resp.status_code == 200:
return None data = resp.json()
# La nuova API ritorna {"data": [{"dense":..., "sparse":...}]}
return data["data"][0]
except Exception as e: except Exception as e:
print(f"❌ BGE API Error: {e}") print(f"⚠️ Errore Embedding: {e}")
return None return {}
async def ensure_collection(name: str):
@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 = AsyncQdrantClient(url=QDRANT_URL) client = AsyncQdrantClient(url=QDRANT_URL)
if not await client.collection_exists(collection_name): if not await client.collection_exists(name):
await client.create_collection( await client.create_collection(
collection_name=collection_name, collection_name=name,
vectors_config={"dense": VectorParams(size=1024, distance=Distance.COSINE)}, vectors_config={
sparse_vectors_config={"sparse": SparseVectorParams()}, "bge_dense": VectorParams(size=1024, distance=Distance.COSINE)
)
async def index_document(file_name: str, content: str, collection_name: str) -> int:
await ensure_collection(collection_name)
client = AsyncQdrantClient(url=QDRANT_URL)
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={"dense": embedding_data["dense"], "sparse": embedding_data["sparse"]},
payload={
"file_name": file_name,
"content": chunk,
"indexed_at": datetime.now().isoformat(),
}, },
# ABILITIAMO LO SPARSE VECTOR
sparse_vectors_config={
"bge_sparse": SparseVectorParams(
index=SparseIndexParams(
on_disk=False, # True se hai poca RAM, ma hai 32GB quindi False è meglio
) )
) )
}
)
def chunk_text_by_lines(text: str, max_chars: int = 2500) -> List[str]:
"""Taglia il testo raggruppando linee intere senza spezzarle."""
lines = text.split('\n')
chunks = []
current_chunk = ""
for line in lines:
# Se la riga è troppo lunga da sola (caso raro), la tagliamo
if len(line) > max_chars:
if current_chunk: chunks.append(current_chunk)
chunks.append(line[:max_chars])
current_chunk = ""
continue
# Se aggiungere la riga supera il limite, salviamo il chunk attuale
if len(current_chunk) + len(line) > max_chars:
chunks.append(current_chunk)
current_chunk = line + "\n"
else:
current_chunk += line + "\n"
if current_chunk:
chunks.append(current_chunk)
return chunks
async def index_document(filename: str, content: str, collection: str) -> bool:
try:
await ensure_collection(collection)
# --- MODIFICA QUI: Usiamo il chunking intelligente invece di quello brutale ---
# Vecchio: chunks = [content[i:i+3000] for i in range(0, len(content), 3000)]
chunks = chunk_text_by_lines(content, max_chars=2000)
# ---------------------------------------------------------------------------
qdrant = AsyncQdrantClient(url=QDRANT_URL)
points = []
for i, chunk in enumerate(chunks):
vectors = await get_embeddings(chunk)
if vectors:
points.append(PointStruct(
id=str(uuid.uuid4()),
vector={
"bge_dense": vectors["dense"],
"bge_sparse": models.SparseVector(
indices=vectors["sparse"]["indices"],
values=vectors["sparse"]["values"]
)
},
payload={"file_name": filename, "content": chunk, "chunk_id": i}
))
if points: if points:
await client.upsert(collection_name=collection_name, points=points) await qdrant.upsert(collection_name=collection, points=points)
return len(points) return True
except Exception as e:
print(f"Index Error: {e}")
return False
return 0 async def search_qdrant(query: str, collection: str) -> str:
try:
async def search_hybrid(query: str, collection_name: str, limit: int = 4) -> str:
client = AsyncQdrantClient(url=QDRANT_URL) client = AsyncQdrantClient(url=QDRANT_URL)
if not await client.collection_exists(collection): return ""
if not await client.collection_exists(collection_name): vectors = await get_embeddings(query)
return "" if not vectors: return ""
query_emb = get_bge_embeddings(query) # HYBRID QUERY (RRF FUSION)
if not query_emb: res = await client.query_points(
return "" collection_name=collection,
prefetch=[
results = await client.query_points( models.Prefetch(
collection_name=collection_name, query=vectors["dense"],
prefetch=[Prefetch(query=query_emb["sparse"], using="sparse", limit=limit * 2)], using="bge_dense",
query=query_emb["dense"], limit=10,
using="dense", ),
limit=limit, models.Prefetch(
query=models.SparseVector(
indices=vectors["sparse"]["indices"],
values=vectors["sparse"]["values"]
),
using="bge_sparse",
limit=20,
),
],
# --- CORREZIONE QUI SOTTO (da 'method' a 'fusion') ---
query=models.FusionQuery(fusion=models.Fusion.RRF),
limit=12
) )
context = [] return "\n\n".join([f"📄 {hit.payload['file_name']}:\n{hit.payload['content']}" for hit in res.points if hit.payload])
for hit in results.points: except Exception as e:
context.append(f"--- DA {hit.payload['file_name']} ---\n{hit.payload['content']}") print(f"Search Error: {e}") # Questo è quello che vedevi nei log
return "\n\n".join(context) return ""
return "\n\n".join([f"📄 {hit.payload['file_name']}:\n{hit.payload['content']}" for hit in res.points if hit.payload])
except Exception as e:
print(f"Search Error: {e}")
return ""
# ========================= # === CHAT LOGIC ===
# 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 start(): async def on_chat_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:
email = "guest@local"
profile = get_user_profile(email)
else:
email = user.identifier
profile = USER_PROFILES.get(email, get_user_profile("guest"))
profile = USER_PROFILES.get(email, GUEST_PROFILE) cl.user_session.set("email", email)
cl.user_session.set("profile", profile) 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"])
role = profile.get("role", "guest") settings = [
defaults = ROLE_DEFAULTS.get(role, ROLE_DEFAULTS["guest"]) cl.input_widget.Select(id="model", label="Modello", values=["glm-4.6:cloud", "llama3"], initial_value="glm-4.6:cloud"),
cl.user_session.set("role_defaults", defaults) cl.input_widget.Slider(id="temp", label="Temperatura", initial=0.5, min=0, max=1, step=0.1)
# 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=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() if profile["role"] == "admin":
settings.append(cl.input_widget.Switch(id="rag", label="RAG Attivo", initial=True))
cl.user_session.set("settings", settings)
await cl.Message(
content=(
"✅ Ai Station online.\n"
f"• Workspace: `{profile['workspace']}`\n"
f"• Default modello: `{defaults['model']}`\n"
f"• Vision: `{VISION_MODEL}`"
)
).send()
await cl.ChatSettings(settings).send()
await cl.Message(content=f"👋 Ciao **{profile['name']}**! Pronto per l'automazione.").send()
@cl.on_settings_update @cl.on_settings_update
async def setupagentsettings(settings): async def on_settings_update(s): cl.user_session.set("settings", s)
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 main(message: cl.Message): async def on_message(message: cl.Message):
start_time = time.time() workspace = cl.user_session.get("workspace")
rag_collection = cl.user_session.get("rag_collection")
profile = cl.user_session.get("profile", GUEST_PROFILE) role = cl.user_session.get("role")
settings = cl.user_session.get("settings", {}) settings = cl.user_session.get("settings", {})
selected_model = settings.get("model", DEFAULT_TEXT_MODEL) # 1. FILE UPLOAD (PDF & EXCEL)
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 el in message.elements:
file_path = os.path.join(workspace, element.name) dest = os.path.join(WORKSPACES_DIR, workspace, el.name)
shutil.copy(element.path, file_path) shutil.copy(el.path, dest)
# Immagini content = ""
if "image" in (element.mime or ""): if el.name.endswith(".pdf"):
images_for_vision.append(file_path) content = extract_text_from_pdf(dest)
msg_img = cl.Message(content=f"🖼️ Analizzo immagine `{element.name}` con `{VISION_MODEL}`...") elif el.name.endswith((".xlsx", ".xls")):
await msg_img.send() await cl.Message(content=f"📊 Analisi Excel **{el.name}**...").send()
content = extract_text_from_excel(dest)
try: if content:
with open(file_path, "rb") as imgfile: ok = await index_document(el.name, content, rag_collection)
imgbytes = imgfile.read() icon = "" if ok else ""
await cl.Message(content=f"{icon} **{el.name}** elaborato.").send()
client_sync = ollama.Client(host=OLLAMA_URL) # 2. RAG & GENERATION
res = client_sync.chat( rag_active = settings.get("rag", True) if role == "admin" else True
model=VISION_MODEL, context = await search_qdrant(message.content, rag_collection) if rag_active else ""
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) prompt = "Sei un esperto di automazione industriale."
elif element.name.lower().endswith((".pdf", ".docx")): if context: prompt += f"\n\nUSA QUESTO CONTESTO (Manuali/Excel):\n{context}"
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()
error: Optional[str] = None
try: try:
client_async = ollama.AsyncClient(host=OLLAMA_URL) client = ollama.AsyncClient(host=OLLAMA_URL)
stream = await client_async.chat( stream = await client.chat(
model=selected_model, model=settings.get("model", "glm-4.6:cloud"),
messages=[ messages=[{"role": "system", "content": prompt}, {"role": "user", "content": message.content}],
{"role": "system", "content": system_prompt}, options={"temperature": settings.get("temp", 0.5)},
{"role": "user", "content": f"Domanda: {message.content}\n{final_context}"}, stream=True
],
options={"temperature": temperature},
stream=True,
) )
async for chunk in stream: async for chunk in stream:
content = chunk.get("message", {}).get("content", "") await msg.stream_token(chunk['message']['content'])
if content: except Exception as e:
await msg.stream_token(content) await msg.stream_token(f"Errore connessione AI: {e}")
await msg.update() 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)

View File

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

View File

@ -27,6 +27,7 @@ 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 # NUOVI PER EXCEL
pillow pandas
requests openpyxl
tabulate