Compare commits

..

No commits in common. "main" and "upgrade-v2-8-3" have entirely different histories.

8 changed files with 336 additions and 534 deletions

View File

@ -1,68 +1,153 @@
[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
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
# 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]
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
# 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
accept = ["*"]
# Define accepted file types using MIME types
# Examples:
# 1. For specific file types:
# accept = ["image/jpeg", "image/png", "application/pdf"]
# 2. For all files of certain type:
# accept = ["image/*", "audio/*", "video/*"]
# 3. For specific file extensions:
# accept = { "application/octet-stream" = [".xyz", ".pdb"] }
# Note: Using "*/*" is not recommended as it may cause browser warnings
accept = ["*/*"]
max_files = 20
max_size_mb = 500
[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 = "Ai Station DFFM"
default_theme = "dark"
layout = "wide"
# Name of the assistant.
name = "Assistant"
# default_theme = "dark"
# layout = "wide"
default_sidebar_state = "open"
# Più “SaaS”: evita di mostrare troppo ragionamento di default
cot = "tool_call"
# Description of the assistant. This is used for HTML tags.
# 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]
primary_color = "#0066CC"
background_color = "#1a1a1a"
alert_style = "modern"
# 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"
# 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 additional attributes for a custom CSS file
# custom_css_attributes = "media=\"print\""
[[UI.header_links]]
name = "Docs"
display_name = "Docs"
icon_url = "/public/brand/icon-32.png"
url = "https://ai.dffm.it"
target = "_blank"
# 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 = "Support"
display_name = "Support"
icon_url = "/public/brand/icon-32.png"
url = "mailto:support@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".
[meta]
generated_by = "2.8.3"

5
.gitignore vendored
View File

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

643
app.py
View File

@ -2,29 +2,17 @@ import os
import re
import uuid
import shutil
import requests
import time
from datetime import datetime
from typing import Optional, Dict, Any, List
from typing import Optional, Dict, List
import chainlit as cl
import ollama
from docling.document_converter import DocumentConverter
import fitz # PyMuPDF
from qdrant_client import AsyncQdrantClient
from qdrant_client.models import (
PointStruct,
Distance,
VectorParams,
SparseVectorParams,
Prefetch,
)
from qdrant_client.models import PointStruct, Distance, VectorParams
from chainlit.data.sql_alchemy import SQLAlchemyDataLayer
from chainlit.types import ThreadDict
from functools import lru_cache
# === FIX IMPORT ROBUSTO Storage Client ===
# === FIX IMPORT ROBUSTO ===
# Gestisce le differenze tra le versioni di Chainlit 2.x
try:
from chainlit.data.storage_clients import BaseStorageClient
except ImportError:
@ -33,135 +21,64 @@ except ImportError:
except ImportError:
from chainlit.data.storage_clients.base import BaseStorageClient
# =========================
# CONFIG
# =========================
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql+asyncpg://ai_user:secure_password_here@postgres:5432/ai_station",
)
# === CONFIGURAZIONE ===
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)
# =========================
# USER PROFILES
# =========================
# === MAPPING UTENTI E RUOLI ===
USER_PROFILES = {
"giuseppe@defranceschi.pro": {
"role": "admin",
"name": "Giuseppe",
"workspace": "admin_workspace",
"rag_collection": "admin_docs",
"capabilities": ["debug", "all"],
"show_code": True,
"capabilities": ["debug", "system_prompts", "user_management", "all_models"],
"show_code": True
},
"federica.tecchio@gmail.com": {
"role": "business",
"name": "Federica",
"workspace": "business_workspace",
"rag_collection": "contabilita",
"capabilities": ["basic_chat"],
"show_code": False,
"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
},
"riccardob545@gmail.com": {
"role": "engineering",
"name": "Riccardo",
"workspace": "engineering_workspace",
"rag_collection": "engineering_docs",
"capabilities": ["code"],
"show_code": True,
"capabilities": ["code_execution", "data_viz", "advanced_chat"],
"show_code": True
},
"giuliadefranceschi05@gmail.com": {
"role": "architecture",
"name": "Giulia",
"workspace": "architecture_workspace",
"rag_collection": "architecture_manuals",
"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,
},
"capabilities": ["visual_chat", "pdf_upload", "image_gen"],
"show_code": False
}
}
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
# =========================
# === CUSTOM LOCAL STORAGE CLIENT (FIXED) ===# Questa classe ora implementa tutti i metodi astratti richiesti da Chainlit 2.8.3
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)
@ -179,28 +96,30 @@ 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:
path = os.path.join(self.storage_path, object_key)
if os.path.exists(path):
os.remove(path)
file_path = os.path.join(self.storage_path, object_key)
if os.path.exists(file_path):
os.remove(file_path)
return True
return False
async def close(self):
pass
# === DATA LAYER ===
@cl.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_path=STORAGE_DIR)
)
# =========================
# OAUTH
# =========================
# === OAUTH CALLBACK ===
@cl.oauth_callback
def oauth_callback(
provider_id: str,
@ -210,414 +129,216 @@ def oauth_callback(
) -> Optional[cl.User]:
if provider_id == "google":
email = raw_user_data.get("email", "").lower()
profile = USER_PROFILES.get(email, GUEST_PROFILE)
default_user.metadata.update(
{
# Verifica se utente è autorizzato (opzionale: blocca se non in lista)
# if email not in USER_PROFILES:
# return None
# Recupera profilo o usa default Guest
profile = USER_PROFILES.get(email, get_user_profile("guest"))
default_user.metadata.update({
"picture": raw_user_data.get("picture", ""),
"role": profile["role"],
"workspace": profile["workspace"],
"rag_collection": profile["rag_collection"],
"capabilities": profile["capabilities"],
"show_code": profile["show_code"],
"display_name": profile["name"],
}
)
"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
# =========================
# CORE: DOCLING
# =========================
def process_file_with_docling(file_path: str) -> str:
def extract_text_from_pdf(pdf_path: str) -> str:
try:
converter = DocumentConverter()
result = converter.convert(file_path)
return result.document.export_to_markdown()
except Exception as e:
print(f"❌ Docling Error: {e}")
doc = fitz.open(pdf_path)
text = "\n".join([page.get_text() for page in doc])
doc.close()
return text
except Exception:
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 = AsyncQdrantClient(url=QDRANT_URL)
client = await get_qdrant_client()
if not await client.collection_exists(collection_name):
await client.create_collection(
collection_name=collection_name,
vectors_config={"dense": VectorParams(size=1024, distance=Distance.COSINE)},
sparse_vectors_config={"sparse": SparseVectorParams()},
vectors_config=VectorParams(size=768, distance=Distance.COSINE)
)
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) -> int:
async def index_document(file_name: str, content: str, collection_name: str) -> bool:
try:
await ensure_collection(collection_name)
client = AsyncQdrantClient(url=QDRANT_URL)
embedding = await get_embeddings(content)
if not embedding: return False
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(),
},
)
)
if points:
await client.upsert(collection_name=collection_name, points=points)
return len(points)
return 0
async def search_hybrid(query: str, collection_name: str, limit: int = 4) -> str:
client = AsyncQdrantClient(url=QDRANT_URL)
if not await client.collection_exists(collection_name):
return ""
query_emb = get_bge_embeddings(query)
if not query_emb:
return ""
results = await client.query_points(
qdrant = await get_qdrant_client()
await qdrant.upsert(
collection_name=collection_name,
prefetch=[Prefetch(query=query_emb["sparse"], using="sparse", limit=limit * 2)],
query=query_emb["dense"],
using="dense",
limit=limit,
points=[PointStruct(
id=str(uuid.uuid4()),
vector=embedding,
payload={"file_name": file_name, "content": content[:3000], "indexed_at": datetime.now().isoformat()}
)]
)
return True
except: return False
context = []
for hit in results.points:
context.append(f"--- DA {hit.payload['file_name']} ---\n{hit.payload['content']}")
return "\n\n".join(context)
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 ""
# === CHAINLIT HANDLERS ===
# =========================
# 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 start():
# 1) Profilo utente
async def on_chat_start():
user = cl.user_session.get("user")
email = user.identifier if user else "guest"
profile = USER_PROFILES.get(email, GUEST_PROFILE)
cl.user_session.set("profile", profile)
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"])
create_workspace(profile["workspace"])
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.",
),
# === SETTINGS WIDGETS ===
settings_widgets = [
cl.input_widget.Select(
id="model",
label="🤖 Modello AI",
values=MODEL_CHOICES,
initial_value=str(defaults["model"]),
label="Modello AI",
values=["glm-4.6:cloud", "llama3.2", "mistral", "qwen2.5-coder:32b"],
initial_value="glm-4.6:cloud",
),
cl.input_widget.Slider(
id="temperature",
label="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.",
label="Temperatura",
initial=0.7, min=0, max=2, step=0.1,
),
]
).send()
if profile["role"] == "admin":
settings_widgets.append(cl.input_widget.Switch(id="rag_enabled", label="Abilita RAG", initial=True))
cl.user_session.set("settings", settings)
await cl.ChatSettings(settings_widgets).send()
await cl.Message(
content=(
"✅ Ai Station online.\n"
f"• Workspace: `{profile['workspace']}`\n"
f"• Default modello: `{defaults['model']}`\n"
f"• Vision: `{VISION_MODEL}`"
)
content=f"👋 Ciao **{profile['name']}**!\n"
f"Ruolo: `{profile['role']}` | Workspace: `{profile['workspace']}`\n"
).send()
@cl.on_settings_update
async def setupagentsettings(settings):
async def on_settings_update(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()
await cl.Message(content="✅ Impostazioni aggiornate").send()
@cl.on_message
async def main(message: cl.Message):
start_time = time.time()
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")
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
selected_model = settings.get("model", DEFAULT_TEXT_MODEL)
temperature = float(settings.get("temperature", 0.3))
rag_enabled = bool(settings.get("rag_enabled", True))
top_k = int(settings.get("top_k", 4))
vision_detail = settings.get("vision_detail", "auto")
system_instruction = (settings.get("system_instruction", "") or "").strip()
workspace = create_workspace(profile["workspace"])
# 1) Gestione upload (immagini / pdf / docx)
images_for_vision: List[str] = []
doc_context = ""
# 1. GESTIONE FILE
if message.elements:
for element in message.elements:
file_path = os.path.join(workspace, element.name)
shutil.copy(element.path, file_path)
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()
# 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()
# 2. RAG
context = ""
if rag_enabled:
context = await search_qdrant(message.content, rag_collection)
try:
with open(file_path, "rb") as imgfile:
imgbytes = imgfile.read()
system_prompt = "Sei un assistente esperto."
if context: system_prompt += f"\n\nCONTESTO:\n{context}"
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)
# 3. GENERAZIONE
client = ollama.AsyncClient(host=OLLAMA_URL)
msg = cl.Message(content="")
await msg.send()
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}"},
],
stream = await client.chat(
model=model,
messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": message.content}],
options={"temperature": temperature},
stream=True,
stream=True
)
full_resp = ""
async for chunk in stream:
content = chunk.get("message", {}).get("content", "")
if content:
await msg.stream_token(content)
await msg.update()
except Exception as e:
error = str(e)
await msg.stream_token(f"\n\n❌ Errore AI: {error}")
token = chunk['message']['content']
full_resp += token
await msg.stream_token(token)
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)
# 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()

View File

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

View File

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