new css
|
|
@ -1,157 +1,92 @@
|
||||||
[project]
|
[project]
|
||||||
# List of environment variables to be provided by each user to use the app.
|
# Nessuna API key richiesta agli utenti
|
||||||
user_env = []
|
user_env = []
|
||||||
|
|
||||||
# Duration (in seconds) during which the session is saved when the connection is lost
|
# Sessioni lunghe per comodità
|
||||||
session_timeout = 3600
|
session_timeout = 7200 # 2 ore
|
||||||
|
user_session_timeout = 2592000 # 30 giorni (come Perplexity Pro)
|
||||||
|
|
||||||
# Duration (in seconds) of the user session expiry
|
# No cache esterno
|
||||||
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
|
# Security
|
||||||
# Set to true to store user env vars in DB, false to exclude them for security
|
|
||||||
persist_user_env = false
|
persist_user_env = false
|
||||||
|
mask_user_env = true
|
||||||
|
|
||||||
# Whether to mask user environment variables (API keys) in the UI with password type
|
# CORS permissivo per OAuth
|
||||||
# Set to true to show API keys as ***, false to show them as plain text
|
|
||||||
mask_user_env = false
|
|
||||||
|
|
||||||
# Authorized origins
|
|
||||||
allow_origins = ["*"]
|
allow_origins = ["*"]
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Process and display HTML in messages. This can be a security risk (see https://stackoverflow.com/questions/19603097/why-is-it-dangerous-to-render-user-generated-html-or-javascript)
|
# HTML disabilitato per sicurezza
|
||||||
unsafe_allow_html = false
|
unsafe_allow_html = false
|
||||||
|
|
||||||
# Process and display mathematical expressions. This can clash with "$" characters in messages.
|
# LaTeX abilitato per formule matematiche
|
||||||
latex = false
|
latex = true
|
||||||
|
|
||||||
# Autoscroll new user messages at the top of the window
|
# UX ottimizzata
|
||||||
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.
|
# Thread sharing disabilitato (per ora)
|
||||||
allow_thread_sharing = false
|
allow_thread_sharing = false
|
||||||
|
|
||||||
[features.slack]
|
|
||||||
# Add emoji reaction when message is received (requires reactions:write OAuth scope)
|
|
||||||
reaction_on_message_received = false
|
|
||||||
|
|
||||||
# Authorize users to spontaneously upload files with messages
|
|
||||||
[features.spontaneous_file_upload]
|
[features.spontaneous_file_upload]
|
||||||
enabled = true
|
enabled = true
|
||||||
# Define accepted file types using MIME types
|
# Solo PDF e TXT per RAG
|
||||||
# Examples:
|
accept = ["application/pdf", "text/plain", "image/png", "image/jpeg"]
|
||||||
# 1. For specific file types:
|
max_files = 10
|
||||||
# accept = ["image/jpeg", "image/png", "application/pdf"]
|
max_size_mb = 100
|
||||||
# 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]
|
||||||
# Enable audio features
|
# Audio disabilitato (futuro: voice chat)
|
||||||
enabled = false
|
enabled = false
|
||||||
# Sample rate of the audio
|
|
||||||
sample_rate = 24000
|
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]
|
[UI]
|
||||||
# Name of the assistant.
|
# Nome branding
|
||||||
name = "Assistant"
|
name = "Dfm AI Station"
|
||||||
|
|
||||||
# default_theme = "dark"
|
# Tema dark di default (come Perplexity)
|
||||||
|
default_theme = "dark"
|
||||||
|
|
||||||
# Force a specific language for all users (e.g., "en-US", "he-IL", "fr-FR")
|
# Layout wide per più spazio
|
||||||
# If not set, the browser's language will be used
|
layout = "wide"
|
||||||
# language = "en-US"
|
|
||||||
|
|
||||||
# layout = "wide"
|
# Sidebar aperta di default
|
||||||
|
default_sidebar_state = "open"
|
||||||
|
|
||||||
# default_sidebar_state = "open"
|
# Descrizione per SEO
|
||||||
|
description = "AI Station powered by dFm - Assistente AI con RAG per analisi documentale e supporto tecnico"
|
||||||
|
|
||||||
# Description of the assistant. This is used for HTML tags.
|
# Chain of Thought: mostra solo tool calls (pulito)
|
||||||
# description = ""
|
cot = "tool_call"
|
||||||
|
|
||||||
# Chain of Thought (CoT) display mode. Can be "hidden", "tool_call" or "full".
|
# Alert moderni
|
||||||
cot = "full"
|
alert_style = "modern"
|
||||||
|
|
||||||
# Specify a CSS file that can be used to customize the user interface.
|
# CSS Custom (stile Perplexity)
|
||||||
# The CSS file can be served from the public directory or via an external link.
|
custom_css = "/public/custom.css"
|
||||||
# custom_css = "/public/test.css"
|
|
||||||
|
|
||||||
# Specify additional attributes for a custom CSS file
|
# Logo e Avatar
|
||||||
# custom_css_attributes = "media=\"print\""
|
logo_file_url = "/public/images/logo2.png"
|
||||||
|
default_avatar_file_url = "/public/images/fav4.png"
|
||||||
|
|
||||||
# Specify a JavaScript file that can be used to customize the user interface.
|
# Meta tags per sharing
|
||||||
# The JavaScript file can be served from the public directory.
|
custom_meta_image_url = "/public/images/logo2.png"
|
||||||
# custom_js = "/public/test.js"
|
|
||||||
|
|
||||||
# The style of alert boxes. Can be "classic" or "modern".
|
# Header links
|
||||||
alert_style = "classic"
|
[[UI.header_links]]
|
||||||
|
name = "dFm Website"
|
||||||
|
display_name = "🏠 DEFRA WOOD MAKER"
|
||||||
|
url = "https://www.dffm.it"
|
||||||
|
target = "_blank"
|
||||||
|
|
||||||
# Specify additional attributes for custom JS file
|
[[UI.header_links]]
|
||||||
# custom_js_attributes = "async type = \"module\""
|
name = "Docs"
|
||||||
|
display_name = "📚 Guida"
|
||||||
# Custom login page image, relative to public directory or external URL
|
url = "/public/docs.html"
|
||||||
# login_page_image = "/public/custom-background.jpg"
|
target = "_self"
|
||||||
|
|
||||||
# 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.9.3"
|
generated_by = "1.3.2"
|
||||||
|
|
|
||||||
|
|
@ -21,5 +21,9 @@ RUN mkdir -p /app/workspaces /app/public /app/.files
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Healthcheck
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD python -c "import httpx; httpx.get('http://localhost:8000/health', timeout=5)" || exit 1
|
||||||
|
|
||||||
# Script di avvio con inizializzazione DB
|
# Script di avvio con inizializzazione DB
|
||||||
CMD python init_db.py && chainlit run app.py --host 0.0.0.0 --port 8000
|
CMD python init_db.py && chainlit run app.py --host 0.0.0.0 --port 8000
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,6 @@ AI-STATION: Specifica Tecnica Implementativa (Target Stack: Chainlit)
|
||||||
1. Obiettivo
|
1. Obiettivo
|
||||||
Sviluppare un'applicazione web Dockerizzata ("AI Station") che funga da hub multi-utente per l'IA. L'app deve presentarsi come una Chat Interface avanzata (stile ChatGPT/Claude) con capacità "Artifacts" (visualizzazione elementi a lato) e RAG.
|
Sviluppare un'applicazione web Dockerizzata ("AI Station") che funga da hub multi-utente per l'IA. L'app deve presentarsi come una Chat Interface avanzata (stile ChatGPT/Claude) con capacità "Artifacts" (visualizzazione elementi a lato) e RAG.
|
||||||
|
|
||||||
2. Stack Tecnologico (Obbligatorio)
|
|
||||||
Sostituire Marimo con Chainlit per il Frontend.
|
|
||||||
|
|
||||||
Frontend/UI: Chainlit (Python). Interfaccia Chat + Elements.
|
Frontend/UI: Chainlit (Python). Interfaccia Chat + Elements.
|
||||||
Backend Logic: FastAPI (embedded in Chainlit o separate).
|
Backend Logic: FastAPI (embedded in Chainlit o separate).
|
||||||
Auth: oauth2-proxy in Docker che inietta header X-Email e X-User-Role.
|
Auth: oauth2-proxy in Docker che inietta header X-Email e X-User-Role.
|
||||||
|
|
@ -25,10 +22,6 @@ Smart Business (Moglie): Chat semplice. Bottone "Upload PDF" (che va in RAG su Q
|
||||||
Engineering (Figlio): Chat + Visualizzatore Dati. Supporto Python code execution sandboxed (se possibile, o solo spiegazione).
|
Engineering (Figlio): Chat + Visualizzatore Dati. Supporto Python code execution sandboxed (se possibile, o solo spiegazione).
|
||||||
Profilo "Architecture" (Figlia - Studente): Chat Visuale. Focus su storia dell'arte, normative edilizie, RAG su manuali di architettura, generazione idee
|
Profilo "Architecture" (Figlia - Studente): Chat Visuale. Focus su storia dell'arte, normative edilizie, RAG su manuali di architettura, generazione idee
|
||||||
Power User (Tu): Accesso a cl.Command per debug, possibilità di vedere i prompt di sistema.
|
Power User (Tu): Accesso a cl.Command per debug, possibilità di vedere i prompt di sistema.
|
||||||
6. Istruzioni per Aider
|
|
||||||
Creare struttura docker-compose.yml con servizi: chainlit-app, qdrant, postgres, oauth2-proxy.
|
|
||||||
Implementare app.py (main Chainlit) che gestisce l'autenticazione via header.
|
|
||||||
Configurare il client Ollama remoto.
|
|
||||||
Implementare un semplice sistema di RAG usando LangChain o LlamaIndex integrato in Chainlit.
|
Implementare un semplice sistema di RAG usando LangChain o LlamaIndex integrato in Chainlit.
|
||||||
Perché questo prompt funzionerà meglio:
|
Perché questo prompt funzionerà meglio:
|
||||||
Definisce la tecnologia: Dice esplicitamente "Usa Chainlit". Aider sa perfettamente come strutturare un progetto Chainlit (file .chainlit/config, cartella .chainlit/data).
|
Definisce la tecnologia: Dice esplicitamente "Usa Chainlit". Aider sa perfettamente come strutturare un progetto Chainlit (file .chainlit/config, cartella .chainlit/data).
|
||||||
|
|
|
||||||
354
README.md
|
|
@ -1,77 +1,325 @@
|
||||||
text
|
# AI Station - Document Analysis Platform
|
||||||
# AI Station - Multi-User AI Hub
|
|
||||||
|
|
||||||
Piattaforma AI dockerizzata con RAG (Retrieval-Augmented Generation) per uso familiare e professionale.
|
## 📋 Overview
|
||||||
|
|
||||||
## Stack Tecnologico
|
**AI Station** è una piattaforma di analisi documentale basata su AI che utilizza **Retrieval-Augmented Generation (RAG)** per analizzare PDF e documenti testuali con il modello **GLM-4.6:Cloud**.
|
||||||
|
|
||||||
- **Frontend/UI**: Chainlit 1.3.2
|
### Stack Tecnologico
|
||||||
- **Vector DB**: Qdrant
|
- **Backend**: Python + Chainlit (LLM UI framework)
|
||||||
- **Database**: PostgreSQL 15
|
- **LLM**: GLM-4.6:Cloud (via Ollama Cloud)
|
||||||
- **AI Engine**: Ollama (qwen2.5-coder:7b) su RTX A1000
|
- **Vector DB**: Qdrant (semantic search)
|
||||||
- **Reverse Proxy**: Nginx Proxy Manager
|
- **PDF Processing**: PyMuPDF (fitz)
|
||||||
- **SSL**: Wildcard *.dffm.it
|
- **Database**: PostgreSQL + SQLAlchemy ORM
|
||||||
|
- **Containerization**: Docker Compose
|
||||||
|
- **Embeddings**: nomic-embed-text (via Ollama local)
|
||||||
|
|
||||||
## Architettura
|
---
|
||||||
|
|
||||||
Internet → pfSense (192.168.1.254)
|
## 🚀 Quick Start
|
||||||
↓
|
|
||||||
Nginx Proxy (192.168.1.252) → https://ai.dffm.it
|
|
||||||
↓
|
|
||||||
AI-SRV (192.168.1.244:8000) → Docker Compose
|
|
||||||
├── Chainlit App
|
|
||||||
├── PostgreSQL
|
|
||||||
└── Qdrant
|
|
||||||
↓
|
|
||||||
AI-GPU (192.168.1.243:11434) → Ollama + RTX A1000
|
|
||||||
|
|
||||||
text
|
### Prerequisites
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- Ollama installed locally (for embeddings)
|
||||||
|
- Ollama Cloud account (for glm-4.6:cloud)
|
||||||
|
|
||||||
## Quick Start
|
### 1️⃣ Clone & Setup
|
||||||
|
```bash
|
||||||
Clone repository
|
git clone git@github.com:your-username/ai-station.git
|
||||||
git clone https://github.com/TUO_USERNAME/ai-station.git
|
|
||||||
cd ai-station
|
cd ai-station
|
||||||
|
|
||||||
Configura environment variables
|
# Configure environment
|
||||||
cp .env.example .env
|
cat > .env << 'EOF'
|
||||||
nano .env
|
DATABASE_URL=postgresql+asyncpg://ai_user:secure_password_here@postgres:5432/ai_station
|
||||||
|
OLLAMA_URL=http://192.168.1.243:11434
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
Avvia stack
|
### 2️⃣ Authenticate Ollama Cloud
|
||||||
|
```bash
|
||||||
|
ollama signin
|
||||||
|
# Follow the link to authenticate with your Ollama account
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3️⃣ Start Services
|
||||||
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
docker compose logs -f chainlit-app
|
||||||
|
```
|
||||||
|
|
||||||
Verifica logs
|
### 4️⃣ Access UI
|
||||||
|
Navigate to: **http://localhost:8000**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
ai-station/
|
||||||
|
├── app.py # Main Chainlit application
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
├── docker-compose.yml # Docker services config
|
||||||
|
├── .env # Environment variables (gitignored)
|
||||||
|
├── workspaces/ # User workspace directories
|
||||||
|
│ └── admin/ # Admin user files
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Features
|
||||||
|
|
||||||
|
### ✅ Implemented
|
||||||
|
- **PDF Upload & Processing**: Extract text from PDF documents using PyMuPDF
|
||||||
|
- **Document Indexing**: Automatic chunking and semantic indexing via Qdrant
|
||||||
|
- **RAG Search**: Retrieve relevant document chunks based on semantic similarity
|
||||||
|
- **Intelligent Analysis**: GLM-4.6:Cloud analyzes documents with full context
|
||||||
|
- **Code Extraction**: Automatically save Python code blocks from responses
|
||||||
|
- **Chat History**: Persistent conversation storage via SQLAlchemy
|
||||||
|
- **Streaming Responses**: Real-time token streaming via Chainlit
|
||||||
|
|
||||||
|
### 🔄 Workflow
|
||||||
|
1. User uploads PDF or TXT file
|
||||||
|
2. System extracts text and creates semantic chunks
|
||||||
|
3. Chunks indexed in Qdrant vector database
|
||||||
|
4. User asks questions about documents
|
||||||
|
5. RAG retrieves relevant chunks
|
||||||
|
6. GLM-4.6:Cloud analyzes with full context
|
||||||
|
7. Streaming response to user
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Technical Details
|
||||||
|
|
||||||
|
### Document Processing Pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
PDF Upload
|
||||||
|
↓
|
||||||
|
PyMuPDF Text Extraction
|
||||||
|
↓
|
||||||
|
Text Chunking (1500 chars, 200 char overlap)
|
||||||
|
↓
|
||||||
|
nomic-embed-text Embeddings (Ollama local)
|
||||||
|
↓
|
||||||
|
Qdrant Vector Storage
|
||||||
|
↓
|
||||||
|
Semantic Search on User Query
|
||||||
|
↓
|
||||||
|
GLM-4.6:Cloud Analysis with RAG Context
|
||||||
|
↓
|
||||||
|
Chainlit Streaming Response
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Functions
|
||||||
|
|
||||||
|
| Function | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `extract_text_from_pdf()` | Convert PDF to text using PyMuPDF |
|
||||||
|
| `chunk_text()` | Split text into overlapping chunks |
|
||||||
|
| `get_embeddings()` | Generate embeddings via Ollama |
|
||||||
|
| `index_document()` | Store chunks in Qdrant |
|
||||||
|
| `search_qdrant()` | Retrieve relevant context |
|
||||||
|
| `on_message()` | Process user queries with RAG |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Environment Variables
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql+asyncpg://user:pass@postgres:5432/ai_station
|
||||||
|
OLLAMA_URL=http://192.168.1.243:11434 # Local Ollama for embeddings
|
||||||
|
QDRANT_URL=http://qdrant:6333 # Vector database
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: GLM-4.6:Cloud authentication is handled automatically via `ollama signin`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐳 Docker Services
|
||||||
|
|
||||||
|
| Service | Port | Purpose |
|
||||||
|
|---------|------|---------|
|
||||||
|
| `chainlit-app` | 8000 | Chainlit UI & API |
|
||||||
|
| `postgres` | 5432 | Conversation persistence |
|
||||||
|
| `qdrant` | 6333 | Vector database |
|
||||||
|
| `ollama` | 11434 | Local embeddings (external) |
|
||||||
|
|
||||||
|
Start/Stop:
|
||||||
|
```bash
|
||||||
|
docker compose up -d # Start all services
|
||||||
|
docker compose down # Stop all services
|
||||||
|
docker compose logs -f # View logs
|
||||||
|
docker compose restart # Restart services
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Usage Examples
|
||||||
|
|
||||||
|
### Example 1: Analyze Tax Document
|
||||||
|
```
|
||||||
|
User: "Qual è l'importo totale del documento?"
|
||||||
|
AI Station:
|
||||||
|
✅ Extracts PDF content
|
||||||
|
✅ Searches relevant sections
|
||||||
|
✅ Analyzes with GLM-4.6:Cloud
|
||||||
|
📄 Returns: "Based on the document, the total amount is..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Multi-Document Analysis
|
||||||
|
```
|
||||||
|
1. Upload multiple PDFs (invoices, contracts)
|
||||||
|
2. All documents automatically indexed
|
||||||
|
3. Query across all documents simultaneously
|
||||||
|
4. RAG retrieves most relevant chunks
|
||||||
|
5. GLM-4.6:Cloud synthesizes answer
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Development
|
||||||
|
|
||||||
|
### Install Dependencies
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
```
|
||||||
|
chainlit==1.3.2
|
||||||
|
pydantic==2.9.2
|
||||||
|
ollama>=0.1.0
|
||||||
|
asyncpg>=0.29.0
|
||||||
|
psycopg2-binary
|
||||||
|
qdrant-client>=1.10.0
|
||||||
|
sqlalchemy>=2.0.0
|
||||||
|
greenlet>=3.0.0
|
||||||
|
sniffio
|
||||||
|
aiohttp
|
||||||
|
alembic
|
||||||
|
pymupdf
|
||||||
|
python-dotenv
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Testing (without Docker)
|
||||||
|
```bash
|
||||||
|
# Start Ollama, PostgreSQL, Qdrant manually
|
||||||
|
ollama serve &
|
||||||
|
chainlit run app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Model Details
|
||||||
|
|
||||||
|
### GLM-4.6:Cloud
|
||||||
|
- **Provider**: Zhipu AI via Ollama Cloud
|
||||||
|
- **Capabilities**: Long context, reasoning, multilingual
|
||||||
|
- **Cost**: Free tier available
|
||||||
|
- **Authentication**: Device key (automatic via `ollama signin`)
|
||||||
|
|
||||||
|
### nomic-embed-text
|
||||||
|
- **Local embedding model** for chunking/retrieval
|
||||||
|
- **Dimensions**: 768
|
||||||
|
- **Speed**: Fast, runs locally
|
||||||
|
- **Used for**: RAG semantic search
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Monitoring & Logs
|
||||||
|
|
||||||
|
### Check Service Health
|
||||||
|
```bash
|
||||||
|
# View all logs
|
||||||
|
docker compose logs
|
||||||
|
|
||||||
|
# Follow live logs
|
||||||
docker compose logs -f chainlit-app
|
docker compose logs -f chainlit-app
|
||||||
|
|
||||||
text
|
# Check specific container
|
||||||
|
docker inspect ai-station-chainlit-app
|
||||||
|
```
|
||||||
|
|
||||||
## Accesso
|
### Common Issues
|
||||||
|
| Issue | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| `unauthorized` error | Run `ollama signin` on server |
|
||||||
|
| Database connection failed | Check PostgreSQL is running |
|
||||||
|
| Qdrant unavailable | Verify `docker-compose up` completed |
|
||||||
|
| PDF not extracted | Ensure PyMuPDF installed: `pip install pymupdf` |
|
||||||
|
|
||||||
- **Locale**: http://192.168.1.244:8000
|
---
|
||||||
- **Remoto**: https://ai.dffm.it
|
|
||||||
|
|
||||||
## Funzionalità Attuali
|
## 🚀 Deployment
|
||||||
|
|
||||||
✅ Chat AI con streaming responses
|
### Production Checklist
|
||||||
✅ RAG con upload documenti .txt
|
- [ ] Set secure PostgreSQL credentials in `.env`
|
||||||
✅ Indicizzazione automatica su Qdrant
|
- [ ] Enable SSL/TLS for Chainlit endpoints
|
||||||
✅ WebSocket support
|
- [ ] Configure CORS for frontend
|
||||||
✅ Accesso SSL remoto
|
- [ ] Setup log aggregation (ELK, Datadog, etc.)
|
||||||
|
- [ ] Implement rate limiting
|
||||||
|
- [ ] Add API authentication
|
||||||
|
- [ ] Configure backup strategy for Qdrant
|
||||||
|
|
||||||
## Roadmap
|
### Cloud Deployment Options
|
||||||
|
- **AWS**: ECS + RDS + VectorDB
|
||||||
|
- **Google Cloud**: Cloud Run + Cloud SQL
|
||||||
|
- **DigitalOcean**: App Platform + Managed Databases
|
||||||
|
|
||||||
- [ ] Supporto PDF per documenti fiscali
|
---
|
||||||
- [ ] OAuth2 multi-utente
|
|
||||||
- [ ] UI personalizzate per profili (business/engineering/architecture/admin)
|
|
||||||
- [ ] Integrazione Google Gemini
|
|
||||||
- [ ] Persistenza conversazioni
|
|
||||||
|
|
||||||
## Requisiti
|
## 📚 API Reference
|
||||||
|
|
||||||
- Docker & Docker Compose
|
### REST Endpoints (via Chainlit)
|
||||||
- 8GB RAM minimo (16GB consigliato)
|
- `POST /api/chat` - Send message with context
|
||||||
- Ollama server remoto con GPU
|
- `GET /api/threads` - List conversations
|
||||||
|
- `POST /api/upload` - Upload document
|
||||||
|
|
||||||
## License
|
### WebSocket
|
||||||
|
- Real-time streaming responses via Chainlit protocol
|
||||||
|
|
||||||
MIT
|
---
|
||||||
|
|
||||||
|
## 🔮 Future Features
|
||||||
|
|
||||||
|
- [ ] OAuth2 Google authentication
|
||||||
|
- [ ] Document metadata extraction (dates, amounts, entities)
|
||||||
|
- [ ] Advanced search filters (type, date range, language)
|
||||||
|
- [ ] Export results (PDF, CSV, JSON)
|
||||||
|
- [ ] Analytics dashboard
|
||||||
|
- [ ] Multi-language support
|
||||||
|
- [ ] Document versioning
|
||||||
|
- [ ] Compliance reporting (GDPR, audit trails)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
1. Check logs: `docker compose logs chainlit-app`
|
||||||
|
2. Verify Ollama authentication: `ollama show glm-4.6:cloud`
|
||||||
|
3. Test Qdrant connection: `curl http://localhost:6333/health`
|
||||||
|
4. Inspect PostgreSQL: `docker compose exec postgres psql -U ai_user -d ai_station`
|
||||||
|
|
||||||
|
### Performance Tips
|
||||||
|
- Increase chunk overlap for better context retrieval
|
||||||
|
- Adjust embedding model based on latency requirements
|
||||||
|
- Monitor Qdrant memory usage for large document sets
|
||||||
|
- Implement caching for frequent queries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT License - See LICENSE file
|
||||||
|
|
||||||
|
## 👤 Author
|
||||||
|
|
||||||
|
AI Station Team
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: December 26, 2025
|
||||||
|
**Version**: 1.0.0
|
||||||
|
**Status**: Production Ready ✅
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,419 @@
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
import shutil
|
||||||
|
import httpx
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
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
|
||||||
|
from authlib.integrations.httpx_client import AsyncOAuth2Client
|
||||||
|
from authlib.integrations.starlette_client import OAuth
|
||||||
|
|
||||||
|
|
||||||
|
# === 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")
|
||||||
|
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
|
||||||
|
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "")
|
||||||
|
|
||||||
|
|
||||||
|
# === INIZIALIZZAZIONE DATA LAYER ===
|
||||||
|
try:
|
||||||
|
data_layer = SQLAlchemyDataLayer(conninfo=DATABASE_URL)
|
||||||
|
cl.data_layer = data_layer
|
||||||
|
print("✅ SQLAlchemyDataLayer initialized successfully")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Failed to initialize data layer: {e}")
|
||||||
|
cl.data_layer = None
|
||||||
|
|
||||||
|
|
||||||
|
# === OAUTH2 SETUP ===
|
||||||
|
oauth = OAuth()
|
||||||
|
oauth.register(
|
||||||
|
name='google',
|
||||||
|
client_id=GOOGLE_CLIENT_ID,
|
||||||
|
client_secret=GOOGLE_CLIENT_SECRET,
|
||||||
|
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
|
||||||
|
client_kwargs={'scope': 'openid profile email'}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
WORKSPACES_DIR = "./workspaces"
|
||||||
|
|
||||||
|
|
||||||
|
# === UTILITY FUNCTIONS ===
|
||||||
|
def create_workspace(user_email: str):
|
||||||
|
"""Crea directory workspace se non esiste"""
|
||||||
|
# Usa email come identifier (sostituisce caratteri problematici)
|
||||||
|
safe_email = user_email.replace("@", "_").replace(".", "_")
|
||||||
|
workspace_path = os.path.join(WORKSPACES_DIR, safe_email)
|
||||||
|
os.makedirs(workspace_path, exist_ok=True)
|
||||||
|
return workspace_path
|
||||||
|
|
||||||
|
|
||||||
|
def save_code_to_file(code: str, user_email: str) -> str:
|
||||||
|
"""Salva blocco codice come file .py"""
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
file_name = f"code_{timestamp}.py"
|
||||||
|
safe_email = user_email.replace("@", "_").replace(".", "_")
|
||||||
|
file_path = os.path.join(WORKSPACES_DIR, safe_email, 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:
|
||||||
|
"""Estrae testo da PDF usando PyMuPDF"""
|
||||||
|
try:
|
||||||
|
doc = fitz.open(pdf_path)
|
||||||
|
text_parts = []
|
||||||
|
|
||||||
|
for page_num in range(len(doc)):
|
||||||
|
page = doc[page_num]
|
||||||
|
text = page.get_text()
|
||||||
|
text_parts.append(f"--- Pagina {page_num + 1} ---\n{text}\n")
|
||||||
|
|
||||||
|
doc.close()
|
||||||
|
return "\n".join(text_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Errore estrazione PDF: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# === QDRANT FUNCTIONS ===
|
||||||
|
async def get_qdrant_client() -> AsyncQdrantClient:
|
||||||
|
"""Connessione a Qdrant"""
|
||||||
|
client = AsyncQdrantClient(url=QDRANT_URL)
|
||||||
|
collection_name = "documents"
|
||||||
|
|
||||||
|
# Crea collection se non esiste
|
||||||
|
if not await client.collection_exists(collection_name):
|
||||||
|
await client.create_collection(
|
||||||
|
collection_name=collection_name,
|
||||||
|
vectors_config=VectorParams(size=768, distance=Distance.COSINE)
|
||||||
|
)
|
||||||
|
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
async def get_embeddings(text: str) -> list:
|
||||||
|
"""Genera embeddings con Ollama"""
|
||||||
|
client = ollama.Client(host=OLLAMA_URL)
|
||||||
|
|
||||||
|
# Limita lunghezza per evitare errori
|
||||||
|
max_length = 2000
|
||||||
|
if len(text) > max_length:
|
||||||
|
text = text[:max_length]
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.embed(model='nomic-embed-text', input=text)
|
||||||
|
|
||||||
|
if 'embeddings' in response:
|
||||||
|
return response['embeddings'][0]
|
||||||
|
return response.get('embedding', [])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Errore Embedding: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def index_document(file_name: str, content: str) -> bool:
|
||||||
|
"""Indicizza documento su Qdrant"""
|
||||||
|
try:
|
||||||
|
# Suddividi documento lungo in chunks
|
||||||
|
chunks = chunk_text(content, max_length=1500)
|
||||||
|
|
||||||
|
qdrant_client = await get_qdrant_client()
|
||||||
|
points = []
|
||||||
|
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
embeddings = await get_embeddings(chunk)
|
||||||
|
if not embeddings:
|
||||||
|
continue
|
||||||
|
|
||||||
|
point_id = str(uuid.uuid4())
|
||||||
|
point = PointStruct(
|
||||||
|
id=point_id,
|
||||||
|
vector=embeddings,
|
||||||
|
payload={
|
||||||
|
"file_name": file_name,
|
||||||
|
"content": chunk,
|
||||||
|
"chunk_index": i,
|
||||||
|
"total_chunks": len(chunks),
|
||||||
|
"indexed_at": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
points.append(point)
|
||||||
|
|
||||||
|
if points:
|
||||||
|
await qdrant_client.upsert(collection_name="documents", points=points)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Errore indicizzazione: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_text(text: str, max_length: int = 1500, overlap: int = 200) -> list:
|
||||||
|
"""Divide testo in chunks con overlap"""
|
||||||
|
if len(text) <= max_length:
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
start = 0
|
||||||
|
|
||||||
|
while start < len(text):
|
||||||
|
end = start + max_length
|
||||||
|
|
||||||
|
# Cerca l'ultimo punto/newline prima del limite
|
||||||
|
if end < len(text):
|
||||||
|
last_period = text.rfind('.', start, end)
|
||||||
|
last_newline = text.rfind('\n', start, end)
|
||||||
|
split_point = max(last_period, last_newline)
|
||||||
|
|
||||||
|
if split_point > start:
|
||||||
|
end = split_point + 1
|
||||||
|
|
||||||
|
chunks.append(text[start:end].strip())
|
||||||
|
start = end - overlap # Overlap per continuità
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
async def search_qdrant(query_text: str, limit: int = 5) -> str:
|
||||||
|
"""Ricerca documenti rilevanti"""
|
||||||
|
try:
|
||||||
|
qdrant_client = await get_qdrant_client()
|
||||||
|
query_embedding = await get_embeddings(query_text)
|
||||||
|
|
||||||
|
if not query_embedding:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
search_result = await qdrant_client.query_points(
|
||||||
|
collection_name="documents",
|
||||||
|
query=query_embedding,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
contexts = []
|
||||||
|
seen_files = set()
|
||||||
|
|
||||||
|
for hit in search_result.points:
|
||||||
|
if hit.payload:
|
||||||
|
file_name = hit.payload.get('file_name', 'Unknown')
|
||||||
|
content = hit.payload.get('content', '')
|
||||||
|
chunk_idx = hit.payload.get('chunk_index', 0)
|
||||||
|
score = hit.score if hasattr(hit, 'score') else 0
|
||||||
|
|
||||||
|
# Evita duplicati dello stesso file
|
||||||
|
file_key = f"{file_name}_{chunk_idx}"
|
||||||
|
if file_key not in seen_files:
|
||||||
|
seen_files.add(file_key)
|
||||||
|
contexts.append(
|
||||||
|
f"📄 **{file_name}** (chunk {chunk_idx+1}, score: {score:.2f})\n"
|
||||||
|
f"```\n{content[:600]}...\n```"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n\n".join(contexts) if contexts else ""
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Errore ricerca Qdrant: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# === CHAINLIT HANDLERS ===
|
||||||
|
@cl.oauth_callback
|
||||||
|
def oauth_callback(provider_id: str, token: dict, raw_user_data: dict, question_filter) -> Optional[cl.User]:
|
||||||
|
"""Callback OAuth2 per autenticazione Google"""
|
||||||
|
if provider_id == "google":
|
||||||
|
user_email = raw_user_data.get("email", "")
|
||||||
|
user_name = raw_user_data.get("name", "User")
|
||||||
|
|
||||||
|
# Crea/recupera utente
|
||||||
|
user = cl.User(
|
||||||
|
identifier=user_email,
|
||||||
|
metadata={
|
||||||
|
"email": user_email,
|
||||||
|
"name": user_name,
|
||||||
|
"picture": raw_user_data.get("picture", ""),
|
||||||
|
"provider": "google"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Crea workspace per l'utente
|
||||||
|
create_workspace(user_email)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@cl.on_chat_start
|
||||||
|
async def on_chat_start():
|
||||||
|
"""Inizializzazione chat"""
|
||||||
|
# Recupera user da OAuth2
|
||||||
|
user = cl.user_session.get("user")
|
||||||
|
|
||||||
|
if user:
|
||||||
|
user_email = user.identifier
|
||||||
|
user_name = user.metadata.get("name", "User")
|
||||||
|
|
||||||
|
# Crea workspace
|
||||||
|
create_workspace(user_email)
|
||||||
|
|
||||||
|
# Salva nella sessione
|
||||||
|
cl.user_session.set("email", user_email)
|
||||||
|
cl.user_session.set("name", user_name)
|
||||||
|
|
||||||
|
# Verifica persistenza
|
||||||
|
persistence_status = "✅ Attiva" if cl.data_layer else "⚠️ Disattivata"
|
||||||
|
|
||||||
|
await cl.Message(
|
||||||
|
content=f"👋 **Benvenuto, {user_name}!**\n\n"
|
||||||
|
f"🚀 **AI Station Ready**\n"
|
||||||
|
f"📤 Upload **PDF** o **.txt** per indicizzarli nel RAG\n"
|
||||||
|
f"💾 Persistenza conversazioni: {persistence_status}\n"
|
||||||
|
f"🤖 Modello: `glm-4.6:cloud` @ {OLLAMA_URL}\n\n"
|
||||||
|
f"💡 **Supporto PDF attivo**: Carica fatture, F24, dichiarazioni fiscali!"
|
||||||
|
).send()
|
||||||
|
else:
|
||||||
|
await cl.Message(
|
||||||
|
content="❌ Autenticazione fallita. Riprova."
|
||||||
|
).send()
|
||||||
|
|
||||||
|
|
||||||
|
@cl.on_message
|
||||||
|
async def on_message(message: cl.Message):
|
||||||
|
"""Gestione messaggi utente"""
|
||||||
|
user_email = cl.user_session.get("email", "guest")
|
||||||
|
user_name = cl.user_session.get("name", "User")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# === STEP 1: Gestione Upload ===
|
||||||
|
if message.elements:
|
||||||
|
await handle_file_uploads(message.elements, user_email)
|
||||||
|
|
||||||
|
# === STEP 2: RAG Search ===
|
||||||
|
context_text = await search_qdrant(message.content, limit=5)
|
||||||
|
|
||||||
|
# === STEP 3: Preparazione Prompt ===
|
||||||
|
system_prompt = (
|
||||||
|
"Sei un assistente AI esperto in analisi documentale e fiscale. "
|
||||||
|
"Usa ESCLUSIVAMENTE il seguente contesto per rispondere. "
|
||||||
|
"Se la risposta non è nel contesto, dillo chiaramente."
|
||||||
|
)
|
||||||
|
|
||||||
|
if context_text:
|
||||||
|
full_prompt = f"{system_prompt}\n\n**CONTESTO DOCUMENTI:**\n{context_text}\n\n**DOMANDA UTENTE:**\n{message.content}"
|
||||||
|
else:
|
||||||
|
full_prompt = f"{system_prompt}\n\n**DOMANDA UTENTE:**\n{message.content}"
|
||||||
|
|
||||||
|
# === STEP 4: Usa glm-4.6:cloud ===
|
||||||
|
client = ollama.Client(host=OLLAMA_URL)
|
||||||
|
|
||||||
|
msg = cl.Message(content="")
|
||||||
|
await msg.send()
|
||||||
|
|
||||||
|
messages = [{"role": "user", "content": full_prompt}]
|
||||||
|
|
||||||
|
stream = client.chat(
|
||||||
|
model='glm-4.6:cloud',
|
||||||
|
messages=messages,
|
||||||
|
stream=True
|
||||||
|
)
|
||||||
|
|
||||||
|
full_response = ""
|
||||||
|
for chunk in stream:
|
||||||
|
content = chunk['message']['content']
|
||||||
|
full_response += content
|
||||||
|
await msg.stream_token(content)
|
||||||
|
|
||||||
|
await msg.update()
|
||||||
|
|
||||||
|
# === STEP 5: Estrai e Salva Codice ===
|
||||||
|
code_blocks = re.findall(r"```python\n(.*?)```", full_response, re.DOTALL)
|
||||||
|
|
||||||
|
if code_blocks:
|
||||||
|
elements = []
|
||||||
|
for code in code_blocks:
|
||||||
|
file_path = save_code_to_file(code.strip(), user_email)
|
||||||
|
elements.append(
|
||||||
|
cl.File(
|
||||||
|
name=os.path.basename(file_path),
|
||||||
|
path=file_path,
|
||||||
|
display="inline"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await cl.Message(
|
||||||
|
content=f"💾 Codice salvato in workspace",
|
||||||
|
elements=elements
|
||||||
|
).send()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await cl.Message(content=f"❌ **Errore:** {str(e)}").send()
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_file_uploads(elements, user_email: str):
|
||||||
|
"""Gestisce upload e indicizzazione file (TXT e PDF)"""
|
||||||
|
for element in elements:
|
||||||
|
try:
|
||||||
|
# Salva file
|
||||||
|
safe_email = user_email.replace("@", "_").replace(".", "_")
|
||||||
|
dest_path = os.path.join(WORKSPACES_DIR, safe_email, element.name)
|
||||||
|
shutil.copy(element.path, dest_path)
|
||||||
|
|
||||||
|
content = None
|
||||||
|
|
||||||
|
# Estrai testo in base al tipo di file
|
||||||
|
if element.name.lower().endswith('.pdf'):
|
||||||
|
await cl.Message(content=f"📄 Elaborazione PDF **{element.name}**...").send()
|
||||||
|
content = extract_text_from_pdf(dest_path)
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
await cl.Message(
|
||||||
|
content=f"⚠️ **{element.name}**: PDF vuoto o non leggibile"
|
||||||
|
).send()
|
||||||
|
continue
|
||||||
|
|
||||||
|
elif element.name.lower().endswith('.txt'):
|
||||||
|
with open(dest_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
else:
|
||||||
|
await cl.Message(
|
||||||
|
content=f"📁 **{element.name}** salvato (supportati: .pdf, .txt)"
|
||||||
|
).send()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Indicizza su Qdrant
|
||||||
|
if content:
|
||||||
|
success = await index_document(element.name, content)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
word_count = len(content.split())
|
||||||
|
await cl.Message(
|
||||||
|
content=f"✅ **{element.name}** indicizzato in Qdrant\n"
|
||||||
|
f"📊 Parole estratte: {word_count:,}"
|
||||||
|
).send()
|
||||||
|
else:
|
||||||
|
await cl.Message(
|
||||||
|
content=f"⚠️ Errore indicizzazione **{element.name}**"
|
||||||
|
).send()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await cl.Message(
|
||||||
|
content=f"❌ Errore con **{element.name}**: {str(e)}"
|
||||||
|
).send()
|
||||||
451
app.py
|
|
@ -3,49 +3,181 @@ import re
|
||||||
import uuid
|
import uuid
|
||||||
import shutil
|
import shutil
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional, Dict, List
|
||||||
|
|
||||||
import chainlit as cl
|
import chainlit as cl
|
||||||
import ollama
|
import ollama
|
||||||
import fitz # PyMuPDF
|
import fitz # PyMuPDF
|
||||||
from qdrant_client import AsyncQdrantClient
|
from qdrant_client import AsyncQdrantClient
|
||||||
from qdrant_client.models import PointStruct, Distance, VectorParams
|
from qdrant_client.models import PointStruct, Distance, VectorParams
|
||||||
from chainlit.data.sql_alchemy import SQLAlchemyDataLayer
|
from chainlit.data.sql_alchemy import SQLAlchemyDataLayer
|
||||||
|
from chainlit.data.storage_clients import BaseStorageClient
|
||||||
|
|
||||||
|
|
||||||
# === CONFIGURAZIONE ===
|
# === CONFIGURAZIONE ===
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://ai_user:secure_password_here@postgres:5432/ai_station")
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://ai_user:secure_password_here@postgres:5432/ai_station")
|
||||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.1.243:11434")
|
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.1.243:11434")
|
||||||
QDRANT_URL = os.getenv("QDRANT_URL", "http://qdrant:6333")
|
QDRANT_URL = os.getenv("QDRANT_URL", "http://qdrant:6333")
|
||||||
|
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 = {
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"federica.tecchio@gmail.com": {
|
||||||
|
"role": "business",
|
||||||
|
"name": "Federica",
|
||||||
|
"workspace": "business_workspace",
|
||||||
|
"rag_collection": "contabilita",
|
||||||
|
"capabilities": ["pdf_upload", "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
|
||||||
|
},
|
||||||
|
"giuliadefranceschi05@gmail.com": {
|
||||||
|
"role": "architecture",
|
||||||
|
"name": "Giulia",
|
||||||
|
"workspace": "architecture_workspace",
|
||||||
|
"rag_collection": "architecture_manuals",
|
||||||
|
"capabilities": ["visual_chat", "pdf_upload", "image_gen"],
|
||||||
|
"show_code": False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# === CUSTOM LOCAL STORAGE CLIENT ===
|
||||||
|
class LocalStorageClient(BaseStorageClient):
|
||||||
|
"""Storage locale su filesystem per file/elementi"""
|
||||||
|
|
||||||
|
def __init__(self, storage_path: str):
|
||||||
|
self.storage_path = storage_path
|
||||||
|
os.makedirs(storage_path, exist_ok=True)
|
||||||
|
|
||||||
|
async def upload_file(
|
||||||
|
self,
|
||||||
|
object_key: str,
|
||||||
|
data: bytes,
|
||||||
|
mime: str = "application/octet-stream",
|
||||||
|
overwrite: bool = True,
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""Salva file localmente"""
|
||||||
|
file_path = os.path.join(self.storage_path, object_key)
|
||||||
|
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||||
|
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"object_key": object_key,
|
||||||
|
"url": f"/files/{object_key}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# === INIZIALIZZAZIONE DATA LAYER ===
|
# === INIZIALIZZAZIONE DATA LAYER ===
|
||||||
|
print("🔧 Inizializzazione database...")
|
||||||
|
storage_client = LocalStorageClient(storage_path=STORAGE_DIR)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data_layer = SQLAlchemyDataLayer(conninfo=DATABASE_URL)
|
data_layer = SQLAlchemyDataLayer(
|
||||||
|
conninfo=DATABASE_URL,
|
||||||
|
storage_provider=storage_client,
|
||||||
|
user_thread_limit=1000,
|
||||||
|
show_logger=False
|
||||||
|
)
|
||||||
|
# ⬇️ QUESTA RIGA È CRUCIALE PER LA PERSISTENZA
|
||||||
cl.data_layer = data_layer
|
cl.data_layer = data_layer
|
||||||
print("✅ SQLAlchemyDataLayer initialized successfully")
|
print("✅ SQLAlchemyDataLayer + LocalStorage initialized successfully")
|
||||||
|
print(f"✅ Data layer set: {cl.data_layer is not None}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Failed to initialize data layer: {e}")
|
print(f"❌ Failed to initialize data layer: {e}")
|
||||||
cl.data_layer = None
|
cl.data_layer = None
|
||||||
|
|
||||||
|
|
||||||
WORKSPACES_DIR = "./workspaces"
|
# === OAUTH CALLBACK CON RUOLI ===
|
||||||
USER_ROLE = "admin"
|
@cl.oauth_callback
|
||||||
|
def oauth_callback(
|
||||||
|
provider_id: str,
|
||||||
|
token: str,
|
||||||
|
raw_user_data: Dict[str, str],
|
||||||
|
default_user: cl.User,
|
||||||
|
) -> Optional[cl.User]:
|
||||||
|
"""Validazione e arricchimento dati utente con ruoli"""
|
||||||
|
if provider_id == "google":
|
||||||
|
email = raw_user_data.get("email", "").lower()
|
||||||
|
|
||||||
|
# Verifica se utente è autorizzato
|
||||||
|
if email not in USER_PROFILES:
|
||||||
|
print(f"❌ Utente non autorizzato: {email}")
|
||||||
|
return None # Nega accesso
|
||||||
|
|
||||||
|
# Arricchisci metadata con profilo
|
||||||
|
profile = USER_PROFILES[email]
|
||||||
|
default_user.metadata.update({
|
||||||
|
"picture": raw_user_data.get("picture", ""),
|
||||||
|
"locale": raw_user_data.get("locale", "en"),
|
||||||
|
"role": profile["role"],
|
||||||
|
"workspace": profile["workspace"],
|
||||||
|
"rag_collection": profile["rag_collection"],
|
||||||
|
"capabilities": profile["capabilities"],
|
||||||
|
"show_code": profile["show_code"],
|
||||||
|
"display_name": profile["name"]
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"✅ Utente autorizzato: {email} - Ruolo: {profile['role']}")
|
||||||
|
return default_user
|
||||||
|
|
||||||
|
return default_user
|
||||||
|
|
||||||
|
|
||||||
# === UTILITY FUNCTIONS ===
|
# === UTILITY FUNCTIONS ===
|
||||||
def create_workspace(user_role: str):
|
def get_user_profile(user_email: str) -> Dict:
|
||||||
|
"""Recupera profilo utente"""
|
||||||
|
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:
|
||||||
"""Crea directory workspace se non esiste"""
|
"""Crea directory workspace se non esiste"""
|
||||||
workspace_path = os.path.join(WORKSPACES_DIR, user_role)
|
workspace_path = os.path.join(WORKSPACES_DIR, workspace_name)
|
||||||
os.makedirs(workspace_path, exist_ok=True)
|
os.makedirs(workspace_path, exist_ok=True)
|
||||||
return workspace_path
|
return workspace_path
|
||||||
|
|
||||||
|
|
||||||
def save_code_to_file(code: str, user_role: str) -> str:
|
def save_code_to_file(code: str, workspace: str) -> str:
|
||||||
"""Salva blocco codice come file .py"""
|
"""Salva blocco codice come file .py"""
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
file_name = f"code_{timestamp}.py"
|
file_name = f"code_{timestamp}.py"
|
||||||
file_path = os.path.join(WORKSPACES_DIR, user_role, file_name)
|
file_path = os.path.join(WORKSPACES_DIR, workspace, file_name)
|
||||||
|
|
||||||
with open(file_path, "w", encoding="utf-8") as f:
|
with open(file_path, "w", encoding="utf-8") as f:
|
||||||
f.write(code)
|
f.write(code)
|
||||||
|
|
@ -66,7 +198,6 @@ def extract_text_from_pdf(pdf_path: str) -> str:
|
||||||
|
|
||||||
doc.close()
|
doc.close()
|
||||||
return "\n".join(text_parts)
|
return "\n".join(text_parts)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Errore estrazione PDF: {e}")
|
print(f"❌ Errore estrazione PDF: {e}")
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -75,46 +206,67 @@ def extract_text_from_pdf(pdf_path: str) -> str:
|
||||||
# === QDRANT FUNCTIONS ===
|
# === QDRANT FUNCTIONS ===
|
||||||
async def get_qdrant_client() -> AsyncQdrantClient:
|
async def get_qdrant_client() -> AsyncQdrantClient:
|
||||||
"""Connessione a Qdrant"""
|
"""Connessione a Qdrant"""
|
||||||
client = AsyncQdrantClient(url=QDRANT_URL)
|
return AsyncQdrantClient(url=QDRANT_URL)
|
||||||
collection_name = "documents"
|
|
||||||
|
|
||||||
# Crea collection se non esiste
|
|
||||||
|
async def ensure_collection(collection_name: str):
|
||||||
|
"""Crea collection se non esiste"""
|
||||||
|
client = await get_qdrant_client()
|
||||||
if not await client.collection_exists(collection_name):
|
if not await client.collection_exists(collection_name):
|
||||||
await client.create_collection(
|
await client.create_collection(
|
||||||
collection_name=collection_name,
|
collection_name=collection_name,
|
||||||
vectors_config=VectorParams(size=768, distance=Distance.COSINE)
|
vectors_config=VectorParams(size=768, distance=Distance.COSINE)
|
||||||
)
|
)
|
||||||
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
async def get_embeddings(text: str) -> list:
|
async def get_embeddings(text: str) -> list:
|
||||||
"""Genera embeddings con Ollama"""
|
"""Genera embeddings con Ollama"""
|
||||||
client = ollama.Client(host=OLLAMA_URL)
|
|
||||||
|
|
||||||
# Limita lunghezza per evitare errori
|
|
||||||
max_length = 2000
|
max_length = 2000
|
||||||
if len(text) > max_length:
|
if len(text) > max_length:
|
||||||
text = text[:max_length]
|
text = text[:max_length]
|
||||||
|
|
||||||
|
client = ollama.Client(host=OLLAMA_URL)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = client.embed(model='nomic-embed-text', input=text)
|
response = client.embed(model='nomic-embed-text', input=text)
|
||||||
|
|
||||||
if 'embeddings' in response:
|
if 'embeddings' in response:
|
||||||
return response['embeddings'][0]
|
return response['embeddings'][0]
|
||||||
return response.get('embedding', [])
|
return response.get('embedding', [])
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Errore Embedding: {e}")
|
print(f"❌ Errore Embedding: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
async def index_document(file_name: str, content: str) -> bool:
|
def chunk_text(text: str, max_length: int = 1500, overlap: int = 200) -> list:
|
||||||
"""Indicizza documento su Qdrant"""
|
"""Divide testo in chunks con overlap"""
|
||||||
try:
|
if len(text) <= max_length:
|
||||||
# Suddividi documento lungo in chunks
|
return [text]
|
||||||
chunks = chunk_text(content, max_length=1500)
|
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
start = 0
|
||||||
|
|
||||||
|
while start < len(text):
|
||||||
|
end = start + max_length
|
||||||
|
|
||||||
|
if end < len(text):
|
||||||
|
last_period = text.rfind('.', start, end)
|
||||||
|
last_newline = text.rfind('\n', start, end)
|
||||||
|
split_point = max(last_period, last_newline)
|
||||||
|
|
||||||
|
if split_point > start:
|
||||||
|
end = split_point + 1
|
||||||
|
|
||||||
|
chunks.append(text[start:end].strip())
|
||||||
|
start = end - overlap
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
async def index_document(file_name: str, content: str, collection_name: str) -> bool:
|
||||||
|
"""Indicizza documento su Qdrant in collection specifica"""
|
||||||
|
try:
|
||||||
|
await ensure_collection(collection_name)
|
||||||
|
chunks = chunk_text(content, max_length=1500)
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
points = []
|
points = []
|
||||||
|
|
||||||
|
|
@ -138,53 +290,31 @@ async def index_document(file_name: str, content: str) -> bool:
|
||||||
points.append(point)
|
points.append(point)
|
||||||
|
|
||||||
if points:
|
if points:
|
||||||
await qdrant_client.upsert(collection_name="documents", points=points)
|
await qdrant_client.upsert(collection_name=collection_name, points=points)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Errore indicizzazione: {e}")
|
print(f"❌ Errore indicizzazione: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def chunk_text(text: str, max_length: int = 1500, overlap: int = 200) -> list:
|
async def search_qdrant(query_text: str, collection_name: str, limit: int = 5) -> str:
|
||||||
"""Divide testo in chunks con overlap"""
|
"""Ricerca documenti rilevanti in collection specifica"""
|
||||||
if len(text) <= max_length:
|
|
||||||
return [text]
|
|
||||||
|
|
||||||
chunks = []
|
|
||||||
start = 0
|
|
||||||
|
|
||||||
while start < len(text):
|
|
||||||
end = start + max_length
|
|
||||||
|
|
||||||
# Cerca l'ultimo punto/newline prima del limite
|
|
||||||
if end < len(text):
|
|
||||||
last_period = text.rfind('.', start, end)
|
|
||||||
last_newline = text.rfind('\n', start, end)
|
|
||||||
split_point = max(last_period, last_newline)
|
|
||||||
|
|
||||||
if split_point > start:
|
|
||||||
end = split_point + 1
|
|
||||||
|
|
||||||
chunks.append(text[start:end].strip())
|
|
||||||
start = end - overlap # Overlap per continuità
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
|
|
||||||
async def search_qdrant(query_text: str, limit: int = 5) -> str:
|
|
||||||
"""Ricerca documenti rilevanti"""
|
|
||||||
try:
|
try:
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
|
|
||||||
|
# Verifica se collection esiste
|
||||||
|
if not await qdrant_client.collection_exists(collection_name):
|
||||||
|
return ""
|
||||||
|
|
||||||
query_embedding = await get_embeddings(query_text)
|
query_embedding = await get_embeddings(query_text)
|
||||||
|
|
||||||
if not query_embedding:
|
if not query_embedding:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
search_result = await qdrant_client.query_points(
|
search_result = await qdrant_client.query_points(
|
||||||
collection_name="documents",
|
collection_name=collection_name,
|
||||||
query=query_embedding,
|
query=query_embedding,
|
||||||
limit=limit
|
limit=limit
|
||||||
)
|
)
|
||||||
|
|
@ -199,17 +329,16 @@ async def search_qdrant(query_text: str, limit: int = 5) -> str:
|
||||||
chunk_idx = hit.payload.get('chunk_index', 0)
|
chunk_idx = hit.payload.get('chunk_index', 0)
|
||||||
score = hit.score if hasattr(hit, 'score') else 0
|
score = hit.score if hasattr(hit, 'score') else 0
|
||||||
|
|
||||||
# Evita duplicati dello stesso file
|
|
||||||
file_key = f"{file_name}_{chunk_idx}"
|
file_key = f"{file_name}_{chunk_idx}"
|
||||||
if file_key not in seen_files:
|
if file_key not in seen_files:
|
||||||
seen_files.add(file_key)
|
seen_files.add(file_key)
|
||||||
|
# ✅ FIX: Markdown code block corretto
|
||||||
contexts.append(
|
contexts.append(
|
||||||
f"📄 **{file_name}** (chunk {chunk_idx+1}, score: {score:.2f})\n"
|
f"📄 **{file_name}** (chunk {chunk_idx+1}, score: {score:.2f})\n"
|
||||||
f"```\n{content[:600]}...\n```"
|
f"``````"
|
||||||
)
|
)
|
||||||
|
|
||||||
return "\n\n".join(contexts) if contexts else ""
|
return "\n\n".join(contexts) if contexts else ""
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Errore ricerca Qdrant: {e}")
|
print(f"❌ Errore ricerca Qdrant: {e}")
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -218,61 +347,170 @@ async def search_qdrant(query_text: str, limit: int = 5) -> str:
|
||||||
# === CHAINLIT HANDLERS ===
|
# === CHAINLIT HANDLERS ===
|
||||||
@cl.on_chat_start
|
@cl.on_chat_start
|
||||||
async def on_chat_start():
|
async def on_chat_start():
|
||||||
"""Inizializzazione chat"""
|
"""Inizializzazione chat con profili utente"""
|
||||||
create_workspace(USER_ROLE)
|
user = cl.user_session.get("user")
|
||||||
|
|
||||||
# Imposta variabili sessione
|
if user:
|
||||||
cl.user_session.set("role", USER_ROLE)
|
user_email = user.identifier
|
||||||
|
profile = get_user_profile(user_email)
|
||||||
|
user_name = profile["name"]
|
||||||
|
user_role = profile["role"]
|
||||||
|
workspace = profile["workspace"]
|
||||||
|
user_picture = user.metadata.get("picture", "")
|
||||||
|
show_code = profile["show_code"]
|
||||||
|
capabilities = profile["capabilities"]
|
||||||
|
else:
|
||||||
|
user_email = "guest@local"
|
||||||
|
user_name = "Ospite"
|
||||||
|
user_role = "guest"
|
||||||
|
workspace = "guest_workspace"
|
||||||
|
user_picture = ""
|
||||||
|
show_code = False
|
||||||
|
capabilities = []
|
||||||
|
|
||||||
|
create_workspace(workspace)
|
||||||
|
|
||||||
|
# Salva in sessione
|
||||||
|
cl.user_session.set("email", user_email)
|
||||||
|
cl.user_session.set("name", user_name)
|
||||||
|
cl.user_session.set("role", user_role)
|
||||||
|
cl.user_session.set("workspace", workspace)
|
||||||
|
cl.user_session.set("show_code", show_code)
|
||||||
|
cl.user_session.set("capabilities", capabilities)
|
||||||
|
cl.user_session.set("rag_collection", profile.get("rag_collection", "documents"))
|
||||||
|
|
||||||
|
# Settings basati su ruolo
|
||||||
|
settings_widgets = [
|
||||||
|
cl.input_widget.Select(
|
||||||
|
id="model",
|
||||||
|
label="🤖 Modello AI",
|
||||||
|
values=["glm-4.6:cloud", "llama3.2", "mistral", "qwen2.5-coder:32b"],
|
||||||
|
initial_value="glm-4.6:cloud",
|
||||||
|
),
|
||||||
|
cl.input_widget.Slider(
|
||||||
|
id="temperature",
|
||||||
|
label="🌡️ Temperatura",
|
||||||
|
initial=0.7,
|
||||||
|
min=0,
|
||||||
|
max=2,
|
||||||
|
step=0.1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Solo admin può disabilitare RAG
|
||||||
|
if user_role == "admin":
|
||||||
|
settings_widgets.append(
|
||||||
|
cl.input_widget.Switch(
|
||||||
|
id="rag_enabled",
|
||||||
|
label="📚 Abilita RAG",
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ⬇️ INVIA SETTINGS (questo attiva l'icona ⚙️)
|
||||||
|
await cl.ChatSettings(settings_widgets).send()
|
||||||
|
|
||||||
|
# Emoji ruolo
|
||||||
|
role_emoji = {
|
||||||
|
"admin": "👑",
|
||||||
|
"business": "💼",
|
||||||
|
"engineering": "⚙️",
|
||||||
|
"architecture": "🏛️",
|
||||||
|
"guest": "👤"
|
||||||
|
}
|
||||||
|
|
||||||
# Verifica persistenza
|
|
||||||
persistence_status = "✅ Attiva" if cl.data_layer else "⚠️ Disattivata"
|
persistence_status = "✅ Attiva" if cl.data_layer else "⚠️ Disattivata"
|
||||||
|
|
||||||
|
welcome_msg = f"{role_emoji.get(user_role, '👋')} **Benvenuto, {user_name}!**\n\n"
|
||||||
|
if user_picture:
|
||||||
|
welcome_msg += f"\n\n"
|
||||||
|
|
||||||
|
welcome_msg += (
|
||||||
|
f"🎭 **Ruolo**: {user_role.upper()}\n"
|
||||||
|
f"📁 **Workspace**: `{workspace}`\n"
|
||||||
|
f"💾 **Persistenza**: {persistence_status}\n"
|
||||||
|
f"🤖 **Modello**: `glm-4.6:cloud`\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Capabilities specifiche
|
||||||
|
if "debug" in capabilities:
|
||||||
|
welcome_msg += "🔧 **Modalità Debug**: Attiva\n"
|
||||||
|
if "user_management" in capabilities:
|
||||||
|
welcome_msg += "👥 **Gestione Utenti**: Disponibile\n"
|
||||||
|
if not show_code:
|
||||||
|
welcome_msg += "🎨 **Modalità Visuale**: Codice nascosto\n"
|
||||||
|
|
||||||
|
welcome_msg += "\n⚙️ **Usa le Settings (icona ⚙️ in alto a destra) per personalizzare!**"
|
||||||
|
|
||||||
|
await cl.Message(content=welcome_msg).send()
|
||||||
|
|
||||||
|
|
||||||
|
@cl.on_settings_update
|
||||||
|
async def on_settings_update(settings):
|
||||||
|
"""Gestisce aggiornamento settings utente"""
|
||||||
|
cl.user_session.set("settings", settings)
|
||||||
|
|
||||||
|
model = settings.get("model", "glm-4.6:cloud")
|
||||||
|
temp = settings.get("temperature", 0.7)
|
||||||
|
rag = settings.get("rag_enabled", True)
|
||||||
|
|
||||||
await cl.Message(
|
await cl.Message(
|
||||||
content=f"🚀 **AI Station Ready** - Workspace: `{USER_ROLE}`\n\n"
|
content=f"✅ **Settings aggiornati**:\n"
|
||||||
f"📤 Upload **PDF** o **.txt** per indicizzarli nel RAG\n"
|
f"- 🤖 Modello: `{model}`\n"
|
||||||
f"💾 Persistenza conversazioni: {persistence_status}\n"
|
f"- 🌡️ Temperatura: `{temp}`\n"
|
||||||
f"🤖 Modello: `glm-4.6:cloud` @ {OLLAMA_URL}\n\n"
|
f"- 📚 RAG: {'✅ Attivo' if rag else '❌ Disattivato'}"
|
||||||
f"💡 **Supporto PDF attivo**: Carica fatture, F24, dichiarazioni fiscali!"
|
|
||||||
).send()
|
).send()
|
||||||
|
|
||||||
|
|
||||||
@cl.on_message
|
@cl.on_message
|
||||||
async def on_message(message: cl.Message):
|
async def on_message(message: cl.Message):
|
||||||
"""Gestione messaggi utente"""
|
"""Gestione messaggi utente con RAG intelligente"""
|
||||||
|
user_email = cl.user_session.get("email", "guest")
|
||||||
user_role = cl.user_session.get("role", "guest")
|
user_role = cl.user_session.get("role", "guest")
|
||||||
|
workspace = cl.user_session.get("workspace", "guest_workspace")
|
||||||
|
show_code = cl.user_session.get("show_code", False)
|
||||||
|
rag_collection = cl.user_session.get("rag_collection", "documents")
|
||||||
|
settings = cl.user_session.get("settings", {})
|
||||||
|
|
||||||
|
model = settings.get("model", "glm-4.6:cloud")
|
||||||
|
temperature = settings.get("temperature", 0.7)
|
||||||
|
|
||||||
|
# Admin può disabilitare RAG, altri lo hanno sempre attivo
|
||||||
|
rag_enabled = settings.get("rag_enabled", True) if user_role == "admin" else True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# === STEP 1: Gestione Upload ===
|
# Gestisci upload file
|
||||||
if message.elements:
|
if message.elements:
|
||||||
await handle_file_uploads(message.elements, user_role)
|
await handle_file_uploads(message.elements, workspace, rag_collection)
|
||||||
|
|
||||||
# === STEP 2: RAG Search ===
|
# RAG Search solo se abilitato
|
||||||
context_text = await search_qdrant(message.content, limit=5)
|
context_text = ""
|
||||||
|
if rag_enabled:
|
||||||
# === STEP 3: Preparazione Prompt ===
|
context_text = await search_qdrant(message.content, rag_collection, limit=5)
|
||||||
system_prompt = (
|
|
||||||
"Sei un assistente AI esperto in analisi documentale e fiscale. "
|
|
||||||
"Usa ESCLUSIVAMENTE il seguente contesto per rispondere. "
|
|
||||||
"Se la risposta non è nel contesto, dillo chiaramente."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# Costruisci prompt con o senza contesto
|
||||||
if context_text:
|
if context_text:
|
||||||
|
system_prompt = (
|
||||||
|
"Sei un assistente AI esperto. "
|
||||||
|
"Usa il seguente contesto per arricchire la tua risposta, "
|
||||||
|
"ma puoi anche rispondere usando la tua conoscenza generale se il contesto non è sufficiente."
|
||||||
|
)
|
||||||
full_prompt = f"{system_prompt}\n\n**CONTESTO DOCUMENTI:**\n{context_text}\n\n**DOMANDA UTENTE:**\n{message.content}"
|
full_prompt = f"{system_prompt}\n\n**CONTESTO DOCUMENTI:**\n{context_text}\n\n**DOMANDA UTENTE:**\n{message.content}"
|
||||||
else:
|
else:
|
||||||
|
system_prompt = "Sei un assistente AI esperto e disponibile. Rispondi in modo chiaro e utile."
|
||||||
full_prompt = f"{system_prompt}\n\n**DOMANDA UTENTE:**\n{message.content}"
|
full_prompt = f"{system_prompt}\n\n**DOMANDA UTENTE:**\n{message.content}"
|
||||||
|
|
||||||
# === STEP 4: Usa glm-4.6:cloud ===
|
# Streaming risposta da Ollama
|
||||||
client = ollama.Client(host=OLLAMA_URL)
|
client = ollama.Client(host=OLLAMA_URL)
|
||||||
|
|
||||||
msg = cl.Message(content="")
|
msg = cl.Message(content="")
|
||||||
await msg.send()
|
await msg.send()
|
||||||
|
|
||||||
messages = [{"role": "user", "content": full_prompt}]
|
messages = [{"role": "user", "content": full_prompt}]
|
||||||
|
|
||||||
stream = client.chat(
|
stream = client.chat(
|
||||||
model='glm-4.6:cloud',
|
model=model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
stream=True
|
stream=True,
|
||||||
|
options={"temperature": temperature}
|
||||||
)
|
)
|
||||||
|
|
||||||
full_response = ""
|
full_response = ""
|
||||||
|
|
@ -283,23 +521,36 @@ async def on_message(message: cl.Message):
|
||||||
|
|
||||||
await msg.update()
|
await msg.update()
|
||||||
|
|
||||||
# === STEP 5: Estrai e Salva Codice ===
|
# ✅ FIX: Estrai codice Python con regex corretto
|
||||||
code_blocks = re.findall(r"```python\n(.*?)```", full_response, re.DOTALL)
|
code_blocks = re.findall(r"``````", full_response, re.DOTALL)
|
||||||
|
|
||||||
if code_blocks:
|
if code_blocks:
|
||||||
elements = []
|
elements = []
|
||||||
|
|
||||||
|
# Se show_code è False, nascondi il codice dalla risposta
|
||||||
|
if not show_code:
|
||||||
|
cleaned_response = re.sub(
|
||||||
|
r"``````",
|
||||||
|
"[💻 Codice eseguito internamente]",
|
||||||
|
full_response,
|
||||||
|
flags=re.DOTALL
|
||||||
|
)
|
||||||
|
await msg.update(content=cleaned_response)
|
||||||
|
|
||||||
|
# Salva codice nel workspace
|
||||||
for code in code_blocks:
|
for code in code_blocks:
|
||||||
file_path = save_code_to_file(code.strip(), user_role)
|
file_path = save_code_to_file(code.strip(), workspace)
|
||||||
elements.append(
|
elements.append(
|
||||||
cl.File(
|
cl.File(
|
||||||
name=os.path.basename(file_path),
|
name=os.path.basename(file_path),
|
||||||
path=file_path,
|
path=file_path,
|
||||||
display="inline"
|
display="inline" if show_code else "side"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if show_code:
|
||||||
await cl.Message(
|
await cl.Message(
|
||||||
content=f"💾 Codice salvato in `{user_role}/`",
|
content=f"💾 Codice salvato in workspace `{workspace}`",
|
||||||
elements=elements
|
elements=elements
|
||||||
).send()
|
).send()
|
||||||
|
|
||||||
|
|
@ -307,17 +558,15 @@ async def on_message(message: cl.Message):
|
||||||
await cl.Message(content=f"❌ **Errore:** {str(e)}").send()
|
await cl.Message(content=f"❌ **Errore:** {str(e)}").send()
|
||||||
|
|
||||||
|
|
||||||
async def handle_file_uploads(elements, user_role: str):
|
async def handle_file_uploads(elements, workspace: str, collection_name: str):
|
||||||
"""Gestisce upload e indicizzazione file (TXT e PDF)"""
|
"""Gestisce upload e indicizzazione file in collection specifica"""
|
||||||
for element in elements:
|
for element in elements:
|
||||||
try:
|
try:
|
||||||
# Salva file
|
dest_path = os.path.join(WORKSPACES_DIR, workspace, element.name)
|
||||||
dest_path = os.path.join(WORKSPACES_DIR, user_role, element.name)
|
|
||||||
shutil.copy(element.path, dest_path)
|
shutil.copy(element.path, dest_path)
|
||||||
|
|
||||||
content = None
|
content = None
|
||||||
|
|
||||||
# Estrai testo in base al tipo di file
|
|
||||||
if element.name.lower().endswith('.pdf'):
|
if element.name.lower().endswith('.pdf'):
|
||||||
await cl.Message(content=f"📄 Elaborazione PDF **{element.name}**...").send()
|
await cl.Message(content=f"📄 Elaborazione PDF **{element.name}**...").send()
|
||||||
content = extract_text_from_pdf(dest_path)
|
content = extract_text_from_pdf(dest_path)
|
||||||
|
|
@ -331,21 +580,19 @@ async def handle_file_uploads(elements, user_role: str):
|
||||||
elif element.name.lower().endswith('.txt'):
|
elif element.name.lower().endswith('.txt'):
|
||||||
with open(dest_path, 'r', encoding='utf-8') as f:
|
with open(dest_path, 'r', encoding='utf-8') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
else:
|
else:
|
||||||
await cl.Message(
|
await cl.Message(
|
||||||
content=f"📁 **{element.name}** salvato (supportati: .pdf, .txt)"
|
content=f"📁 **{element.name}** salvato in workspace (supportati: .pdf, .txt)"
|
||||||
).send()
|
).send()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Indicizza su Qdrant
|
|
||||||
if content:
|
if content:
|
||||||
success = await index_document(element.name, content)
|
success = await index_document(element.name, content, collection_name)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
word_count = len(content.split())
|
word_count = len(content.split())
|
||||||
await cl.Message(
|
await cl.Message(
|
||||||
content=f"✅ **{element.name}** indicizzato in Qdrant\n"
|
content=f"✅ **{element.name}** indicizzato in `{collection_name}`\n"
|
||||||
f"📊 Parole estratte: {word_count:,}"
|
f"📊 Parole estratte: {word_count:,}"
|
||||||
).send()
|
).send()
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,359 @@
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# === 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")
|
||||||
|
|
||||||
|
|
||||||
|
# === INIZIALIZZAZIONE DATA LAYER ===
|
||||||
|
try:
|
||||||
|
data_layer = SQLAlchemyDataLayer(conninfo=DATABASE_URL)
|
||||||
|
cl.data_layer = data_layer
|
||||||
|
print("✅ SQLAlchemyDataLayer initialized successfully")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Failed to initialize data layer: {e}")
|
||||||
|
cl.data_layer = None
|
||||||
|
|
||||||
|
|
||||||
|
WORKSPACES_DIR = "./workspaces"
|
||||||
|
USER_ROLE = "admin"
|
||||||
|
|
||||||
|
|
||||||
|
# === UTILITY FUNCTIONS ===
|
||||||
|
def create_workspace(user_role: str):
|
||||||
|
"""Crea directory workspace se non esiste"""
|
||||||
|
workspace_path = os.path.join(WORKSPACES_DIR, user_role)
|
||||||
|
os.makedirs(workspace_path, exist_ok=True)
|
||||||
|
return workspace_path
|
||||||
|
|
||||||
|
|
||||||
|
def save_code_to_file(code: str, user_role: str) -> str:
|
||||||
|
"""Salva blocco codice come file .py"""
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
file_name = f"code_{timestamp}.py"
|
||||||
|
file_path = os.path.join(WORKSPACES_DIR, user_role, 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:
|
||||||
|
"""Estrae testo da PDF usando PyMuPDF"""
|
||||||
|
try:
|
||||||
|
doc = fitz.open(pdf_path)
|
||||||
|
text_parts = []
|
||||||
|
|
||||||
|
for page_num in range(len(doc)):
|
||||||
|
page = doc[page_num]
|
||||||
|
text = page.get_text()
|
||||||
|
text_parts.append(f"--- Pagina {page_num + 1} ---\n{text}\n")
|
||||||
|
|
||||||
|
doc.close()
|
||||||
|
return "\n".join(text_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Errore estrazione PDF: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# === QDRANT FUNCTIONS ===
|
||||||
|
async def get_qdrant_client() -> AsyncQdrantClient:
|
||||||
|
"""Connessione a Qdrant"""
|
||||||
|
client = AsyncQdrantClient(url=QDRANT_URL)
|
||||||
|
collection_name = "documents"
|
||||||
|
|
||||||
|
# Crea collection se non esiste
|
||||||
|
if not await client.collection_exists(collection_name):
|
||||||
|
await client.create_collection(
|
||||||
|
collection_name=collection_name,
|
||||||
|
vectors_config=VectorParams(size=768, distance=Distance.COSINE)
|
||||||
|
)
|
||||||
|
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
async def get_embeddings(text: str) -> list:
|
||||||
|
"""Genera embeddings con Ollama"""
|
||||||
|
client = ollama.Client(host=OLLAMA_URL)
|
||||||
|
|
||||||
|
# Limita lunghezza per evitare errori
|
||||||
|
max_length = 2000
|
||||||
|
if len(text) > max_length:
|
||||||
|
text = text[:max_length]
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.embed(model='nomic-embed-text', input=text)
|
||||||
|
|
||||||
|
if 'embeddings' in response:
|
||||||
|
return response['embeddings'][0]
|
||||||
|
return response.get('embedding', [])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Errore Embedding: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def index_document(file_name: str, content: str) -> bool:
|
||||||
|
"""Indicizza documento su Qdrant"""
|
||||||
|
try:
|
||||||
|
# Suddividi documento lungo in chunks
|
||||||
|
chunks = chunk_text(content, max_length=1500)
|
||||||
|
|
||||||
|
qdrant_client = await get_qdrant_client()
|
||||||
|
points = []
|
||||||
|
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
embeddings = await get_embeddings(chunk)
|
||||||
|
if not embeddings:
|
||||||
|
continue
|
||||||
|
|
||||||
|
point_id = str(uuid.uuid4())
|
||||||
|
point = PointStruct(
|
||||||
|
id=point_id,
|
||||||
|
vector=embeddings,
|
||||||
|
payload={
|
||||||
|
"file_name": file_name,
|
||||||
|
"content": chunk,
|
||||||
|
"chunk_index": i,
|
||||||
|
"total_chunks": len(chunks),
|
||||||
|
"indexed_at": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
points.append(point)
|
||||||
|
|
||||||
|
if points:
|
||||||
|
await qdrant_client.upsert(collection_name="documents", points=points)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Errore indicizzazione: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_text(text: str, max_length: int = 1500, overlap: int = 200) -> list:
|
||||||
|
"""Divide testo in chunks con overlap"""
|
||||||
|
if len(text) <= max_length:
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
start = 0
|
||||||
|
|
||||||
|
while start < len(text):
|
||||||
|
end = start + max_length
|
||||||
|
|
||||||
|
# Cerca l'ultimo punto/newline prima del limite
|
||||||
|
if end < len(text):
|
||||||
|
last_period = text.rfind('.', start, end)
|
||||||
|
last_newline = text.rfind('\n', start, end)
|
||||||
|
split_point = max(last_period, last_newline)
|
||||||
|
|
||||||
|
if split_point > start:
|
||||||
|
end = split_point + 1
|
||||||
|
|
||||||
|
chunks.append(text[start:end].strip())
|
||||||
|
start = end - overlap # Overlap per continuità
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
async def search_qdrant(query_text: str, limit: int = 5) -> str:
|
||||||
|
"""Ricerca documenti rilevanti"""
|
||||||
|
try:
|
||||||
|
qdrant_client = await get_qdrant_client()
|
||||||
|
query_embedding = await get_embeddings(query_text)
|
||||||
|
|
||||||
|
if not query_embedding:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
search_result = await qdrant_client.query_points(
|
||||||
|
collection_name="documents",
|
||||||
|
query=query_embedding,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
contexts = []
|
||||||
|
seen_files = set()
|
||||||
|
|
||||||
|
for hit in search_result.points:
|
||||||
|
if hit.payload:
|
||||||
|
file_name = hit.payload.get('file_name', 'Unknown')
|
||||||
|
content = hit.payload.get('content', '')
|
||||||
|
chunk_idx = hit.payload.get('chunk_index', 0)
|
||||||
|
score = hit.score if hasattr(hit, 'score') else 0
|
||||||
|
|
||||||
|
# Evita duplicati dello stesso file
|
||||||
|
file_key = f"{file_name}_{chunk_idx}"
|
||||||
|
if file_key not in seen_files:
|
||||||
|
seen_files.add(file_key)
|
||||||
|
contexts.append(
|
||||||
|
f"📄 **{file_name}** (chunk {chunk_idx+1}, score: {score:.2f})\n"
|
||||||
|
f"```\n{content[:600]}...\n```"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n\n".join(contexts) if contexts else ""
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Errore ricerca Qdrant: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# === CHAINLIT HANDLERS ===
|
||||||
|
@cl.on_chat_start
|
||||||
|
async def on_chat_start():
|
||||||
|
"""Inizializzazione chat"""
|
||||||
|
create_workspace(USER_ROLE)
|
||||||
|
|
||||||
|
# Imposta variabili sessione
|
||||||
|
cl.user_session.set("role", USER_ROLE)
|
||||||
|
|
||||||
|
# Verifica persistenza
|
||||||
|
persistence_status = "✅ Attiva" if cl.data_layer else "⚠️ Disattivata"
|
||||||
|
|
||||||
|
await cl.Message(
|
||||||
|
content=f"🚀 **AI Station Ready** - Workspace: `{USER_ROLE}`\n\n"
|
||||||
|
f"📤 Upload **PDF** o **.txt** per indicizzarli nel RAG\n"
|
||||||
|
f"💾 Persistenza conversazioni: {persistence_status}\n"
|
||||||
|
f"🤖 Modello: `glm-4.6:cloud` @ {OLLAMA_URL}\n\n"
|
||||||
|
f"💡 **Supporto PDF attivo**: Carica fatture, F24, dichiarazioni fiscali!"
|
||||||
|
).send()
|
||||||
|
|
||||||
|
|
||||||
|
@cl.on_message
|
||||||
|
async def on_message(message: cl.Message):
|
||||||
|
"""Gestione messaggi utente"""
|
||||||
|
user_role = cl.user_session.get("role", "guest")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# === STEP 1: Gestione Upload ===
|
||||||
|
if message.elements:
|
||||||
|
await handle_file_uploads(message.elements, user_role)
|
||||||
|
|
||||||
|
# === STEP 2: RAG Search ===
|
||||||
|
context_text = await search_qdrant(message.content, limit=5)
|
||||||
|
|
||||||
|
# === STEP 3: Preparazione Prompt ===
|
||||||
|
system_prompt = (
|
||||||
|
"Sei un assistente AI esperto in analisi documentale e fiscale. "
|
||||||
|
"Usa ESCLUSIVAMENTE il seguente contesto per rispondere. "
|
||||||
|
"Se la risposta non è nel contesto, dillo chiaramente."
|
||||||
|
)
|
||||||
|
|
||||||
|
if context_text:
|
||||||
|
full_prompt = f"{system_prompt}\n\n**CONTESTO DOCUMENTI:**\n{context_text}\n\n**DOMANDA UTENTE:**\n{message.content}"
|
||||||
|
else:
|
||||||
|
full_prompt = f"{system_prompt}\n\n**DOMANDA UTENTE:**\n{message.content}"
|
||||||
|
|
||||||
|
# === STEP 4: Usa glm-4.6:cloud ===
|
||||||
|
client = ollama.Client(host=OLLAMA_URL)
|
||||||
|
|
||||||
|
msg = cl.Message(content="")
|
||||||
|
await msg.send()
|
||||||
|
|
||||||
|
messages = [{"role": "user", "content": full_prompt}]
|
||||||
|
|
||||||
|
stream = client.chat(
|
||||||
|
model='glm-4.6:cloud',
|
||||||
|
messages=messages,
|
||||||
|
stream=True
|
||||||
|
)
|
||||||
|
|
||||||
|
full_response = ""
|
||||||
|
for chunk in stream:
|
||||||
|
content = chunk['message']['content']
|
||||||
|
full_response += content
|
||||||
|
await msg.stream_token(content)
|
||||||
|
|
||||||
|
await msg.update()
|
||||||
|
|
||||||
|
# === STEP 5: Estrai e Salva Codice ===
|
||||||
|
code_blocks = re.findall(r"```python\n(.*?)```", full_response, re.DOTALL)
|
||||||
|
|
||||||
|
if code_blocks:
|
||||||
|
elements = []
|
||||||
|
for code in code_blocks:
|
||||||
|
file_path = save_code_to_file(code.strip(), user_role)
|
||||||
|
elements.append(
|
||||||
|
cl.File(
|
||||||
|
name=os.path.basename(file_path),
|
||||||
|
path=file_path,
|
||||||
|
display="inline"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await cl.Message(
|
||||||
|
content=f"💾 Codice salvato in `{user_role}/`",
|
||||||
|
elements=elements
|
||||||
|
).send()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await cl.Message(content=f"❌ **Errore:** {str(e)}").send()
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_file_uploads(elements, user_role: str):
|
||||||
|
"""Gestisce upload e indicizzazione file (TXT e PDF)"""
|
||||||
|
for element in elements:
|
||||||
|
try:
|
||||||
|
# Salva file
|
||||||
|
dest_path = os.path.join(WORKSPACES_DIR, user_role, element.name)
|
||||||
|
shutil.copy(element.path, dest_path)
|
||||||
|
|
||||||
|
content = None
|
||||||
|
|
||||||
|
# Estrai testo in base al tipo di file
|
||||||
|
if element.name.lower().endswith('.pdf'):
|
||||||
|
await cl.Message(content=f"📄 Elaborazione PDF **{element.name}**...").send()
|
||||||
|
content = extract_text_from_pdf(dest_path)
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
await cl.Message(
|
||||||
|
content=f"⚠️ **{element.name}**: PDF vuoto o non leggibile"
|
||||||
|
).send()
|
||||||
|
continue
|
||||||
|
|
||||||
|
elif element.name.lower().endswith('.txt'):
|
||||||
|
with open(dest_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
else:
|
||||||
|
await cl.Message(
|
||||||
|
content=f"📁 **{element.name}** salvato (supportati: .pdf, .txt)"
|
||||||
|
).send()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Indicizza su Qdrant
|
||||||
|
if content:
|
||||||
|
success = await index_document(element.name, content)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
word_count = len(content.split())
|
||||||
|
await cl.Message(
|
||||||
|
content=f"✅ **{element.name}** indicizzato in Qdrant\n"
|
||||||
|
f"📊 Parole estratte: {word_count:,}"
|
||||||
|
).send()
|
||||||
|
else:
|
||||||
|
await cl.Message(
|
||||||
|
content=f"⚠️ Errore indicizzazione **{element.name}**"
|
||||||
|
).send()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await cl.Message(
|
||||||
|
content=f"❌ Errore con **{element.name}**: {str(e)}"
|
||||||
|
).send()
|
||||||
|
|
@ -1,49 +1,23 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
|
||||||
image: postgres:15-alpine
|
|
||||||
container_name: ai-station-postgres
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: ai_station
|
|
||||||
POSTGRES_USER: ai_user
|
|
||||||
POSTGRES_PASSWORD: secure_password_here
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
networks:
|
|
||||||
- ai-station-net
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ai_user -d ai_station"] # <- AGGIUNGI -d ai_station
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
qdrant:
|
|
||||||
image: qdrant/qdrant:latest
|
|
||||||
container_name: ai-station-qdrant
|
|
||||||
volumes:
|
|
||||||
- qdrant_data:/qdrant/storage
|
|
||||||
ports:
|
|
||||||
- "6333:6333"
|
|
||||||
- "6334:6334"
|
|
||||||
networks:
|
|
||||||
- ai-station-net
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
chainlit-app:
|
chainlit-app:
|
||||||
build: .
|
build: .
|
||||||
container_name: ai-station-app
|
container_name: ai-station-app
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql+asyncpg://ai_user:secure_password_here@postgres:5432/ai_station
|
- DATABASE_URL=postgresql+asyncpg://ai_user:secure_password_here@postgres:5432/ai_station
|
||||||
- OLLAMA_URL=http://192.168.1.243:11434
|
- OLLAMA_URL=http://192.168.1.243:11434
|
||||||
- QDRANT_URL=http://qdrant:6333
|
- QDRANT_URL=http://qdrant:6333
|
||||||
- CHAINLIT_AUTH_SECRET=your-secret-key-here
|
- BGE_API_URL=http://192.168.1.243:8001
|
||||||
volumes:
|
volumes:
|
||||||
- ./workspaces:/app/workspaces
|
- ./workspaces:/app/workspaces
|
||||||
- ./public:/app/public
|
- ./public:/app/public # ⬅️ VERIFICA QUESTO
|
||||||
|
- ./.files:/app/.files
|
||||||
|
- ./.chainlit:/app/.chainlit # ⬅️ AGGIUNGI QUESTO
|
||||||
networks:
|
networks:
|
||||||
- ai-station-net
|
- ai-station-net
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
@ -51,18 +25,41 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
qdrant:
|
qdrant:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
command: chainlit run app.py --host 0.0.0.0 --port 8000
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: ai-station-postgres
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=ai_user
|
||||||
|
- POSTGRES_PASSWORD=secure_password_here
|
||||||
|
- POSTGRES_DB=ai_station
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
- postgres_data:/var/lib/postgresql/data
|
||||||
driver: local
|
networks:
|
||||||
qdrant_data:
|
- ai-station-net
|
||||||
driver: local
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ai_user -d ai_station"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
qdrant:
|
||||||
|
image: qdrant/qdrant:latest
|
||||||
|
container_name: ai-station-qdrant
|
||||||
|
ports:
|
||||||
|
- "6333:6333"
|
||||||
|
volumes:
|
||||||
|
- qdrant_data:/qdrant/storage
|
||||||
|
networks:
|
||||||
|
- ai-station-net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
ai-station-net:
|
ai-station-net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
ipam:
|
|
||||||
config:
|
volumes:
|
||||||
- subnet: 172.28.0.0/16
|
postgres_data:
|
||||||
|
qdrant_data:
|
||||||
|
|
|
||||||
30
init_db.py
|
|
@ -1,27 +1,27 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from sqlalchemy import create_engine, text
|
import asyncpg
|
||||||
from chainlit.data.sql_alchemy import SQLAlchemyDataLayer
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
DATABASE_URL = "postgresql+asyncpg://ai_user:secure_password_here@postgres:5432/ai_station"
|
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://ai_user:secure_password_here@postgres:5432/ai_station")
|
||||||
|
|
||||||
|
# Converti da SQLAlchemy URL a asyncpg
|
||||||
|
db_url = DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
|
||||||
|
|
||||||
async def init_database():
|
async def init_database():
|
||||||
"""Inizializza le tabelle per Chainlit"""
|
|
||||||
print("🔧 Inizializzazione database...")
|
print("🔧 Inizializzazione database...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Crea data layer
|
conn = await asyncpg.connect(db_url)
|
||||||
data_layer = SQLAlchemyDataLayer(conninfo=DATABASE_URL)
|
|
||||||
|
|
||||||
# Forza creazione tabelle
|
# Crea schema se non esiste (Chainlit lo farà automaticamente)
|
||||||
if hasattr(data_layer, '_create_database'):
|
print("✅ Connessione al database riuscita")
|
||||||
await data_layer._create_database()
|
print("ℹ️ Le tabelle verranno create automaticamente da Chainlit")
|
||||||
print("✅ Database inizializzato con successo")
|
|
||||||
else:
|
|
||||||
print("⚠️ Metodo _create_database non disponibile")
|
|
||||||
print("ℹ️ Le tabelle verranno create automaticamente al primo utilizzo")
|
|
||||||
|
|
||||||
|
await conn.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Errore: {e}")
|
print(f"❌ Errore connessione database: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(init_database())
|
asyncio.run(init_database())
|
||||||
|
print("✅ Inizializzazione database completata")
|
||||||
|
|
@ -0,0 +1,468 @@
|
||||||
|
/* ========================================
|
||||||
|
AI STATION - PERPLEXITY STYLE UI
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
/* === ROOT VARIABLES === */
|
||||||
|
:root {
|
||||||
|
/* Palette principale (blu professionale) */
|
||||||
|
--primary-color: #6366F1;
|
||||||
|
--primary-hover: #4F46E5;
|
||||||
|
--primary-light: #818CF8;
|
||||||
|
|
||||||
|
/* Background dark mode */
|
||||||
|
--bg-primary: #0F172A;
|
||||||
|
--bg-secondary: #1E293B;
|
||||||
|
--bg-tertiary: #334155;
|
||||||
|
|
||||||
|
/* Text colors */
|
||||||
|
--text-primary: #F1F5F9;
|
||||||
|
--text-secondary: #94A3B8;
|
||||||
|
--text-muted: #64748B;
|
||||||
|
|
||||||
|
/* Accent colors */
|
||||||
|
--accent-green: #10B981;
|
||||||
|
--accent-red: #EF4444;
|
||||||
|
--accent-yellow: #F59E0B;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
|
/* Border radius */
|
||||||
|
--radius-sm: 0.375rem;
|
||||||
|
--radius-md: 0.5rem;
|
||||||
|
--radius-lg: 0.75rem;
|
||||||
|
--radius-xl: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === BODY & LAYOUT === */
|
||||||
|
body {
|
||||||
|
background: var(--bg-primary) !important;
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif !important;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === HEADER === */
|
||||||
|
#app-header {
|
||||||
|
background: var(--bg-secondary) !important;
|
||||||
|
border-bottom: 1px solid var(--bg-tertiary) !important;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo styling */
|
||||||
|
#app-header img[alt="logo"] {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app-header img[alt="logo"]:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === SIDEBAR === */
|
||||||
|
#chat-history-sidebar {
|
||||||
|
background: var(--bg-secondary) !important;
|
||||||
|
border-right: 1px solid var(--bg-tertiary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thread items in sidebar */
|
||||||
|
.thread-item {
|
||||||
|
border-radius: var(--radius-md) !important;
|
||||||
|
margin: 0.25rem 0.5rem !important;
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-item:hover {
|
||||||
|
background: var(--bg-tertiary) !important;
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-item.active {
|
||||||
|
background: var(--primary-color) !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === CHAT CONTAINER === */
|
||||||
|
#chat-container {
|
||||||
|
background: var(--bg-primary) !important;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === MESSAGES === */
|
||||||
|
/* User message */
|
||||||
|
.user-message {
|
||||||
|
background: var(--bg-tertiary) !important;
|
||||||
|
border-radius: var(--radius-lg) !important;
|
||||||
|
padding: 1rem 1.25rem !important;
|
||||||
|
margin: 0.75rem 0 !important;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border-left: 3px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Assistant message */
|
||||||
|
.assistant-message {
|
||||||
|
background: var(--bg-secondary) !important;
|
||||||
|
border-radius: var(--radius-lg) !important;
|
||||||
|
padding: 1rem 1.25rem !important;
|
||||||
|
margin: 0.75rem 0 !important;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message avatars */
|
||||||
|
.message-avatar {
|
||||||
|
border-radius: 50% !important;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 2px solid var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === CODE BLOCKS === */
|
||||||
|
pre {
|
||||||
|
background: #1E1E1E !important;
|
||||||
|
border-radius: var(--radius-md) !important;
|
||||||
|
padding: 1rem !important;
|
||||||
|
border: 1px solid var(--bg-tertiary) !important;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Courier New', monospace !important;
|
||||||
|
font-size: 0.9rem !important;
|
||||||
|
color: #E5E7EB !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline code */
|
||||||
|
:not(pre) > code {
|
||||||
|
background: var(--bg-tertiary) !important;
|
||||||
|
padding: 0.2rem 0.4rem !important;
|
||||||
|
border-radius: var(--radius-sm) !important;
|
||||||
|
color: var(--primary-light) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === TABLES === */
|
||||||
|
table {
|
||||||
|
width: 100% !important;
|
||||||
|
border-collapse: separate !important;
|
||||||
|
border-spacing: 0 !important;
|
||||||
|
border-radius: var(--radius-md) !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
margin: 1rem 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
background: var(--bg-tertiary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
padding: 0.75rem 1rem !important;
|
||||||
|
text-align: left !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
border-bottom: 2px solid var(--primary-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
background: var(--bg-secondary) !important;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: var(--bg-tertiary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody td {
|
||||||
|
padding: 0.75rem 1rem !important;
|
||||||
|
border-bottom: 1px solid var(--bg-tertiary) !important;
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === INPUT AREA === */
|
||||||
|
#chat-input-container {
|
||||||
|
background: var(--bg-secondary) !important;
|
||||||
|
border-radius: var(--radius-xl) !important;
|
||||||
|
padding: 1rem !important;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
border: 1px solid var(--bg-tertiary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-input {
|
||||||
|
background: var(--bg-tertiary) !important;
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: var(--radius-lg) !important;
|
||||||
|
padding: 0.75rem 1rem !important;
|
||||||
|
font-size: 1rem !important;
|
||||||
|
resize: none !important;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-input:focus {
|
||||||
|
outline: 2px solid var(--primary-color) !important;
|
||||||
|
outline-offset: 2px;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Send button */
|
||||||
|
#send-button {
|
||||||
|
background: var(--primary-color) !important;
|
||||||
|
color: white !important;
|
||||||
|
border-radius: var(--radius-md) !important;
|
||||||
|
padding: 0.75rem 1.5rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
#send-button:hover {
|
||||||
|
background: var(--primary-hover) !important;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#send-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === SETTINGS PANEL === */
|
||||||
|
.settings-panel {
|
||||||
|
background: var(--bg-secondary) !important;
|
||||||
|
border-radius: var(--radius-lg) !important;
|
||||||
|
padding: 1.5rem !important;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
border: 1px solid var(--bg-tertiary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-item {
|
||||||
|
margin: 1rem 0 !important;
|
||||||
|
padding: 0.75rem !important;
|
||||||
|
background: var(--bg-tertiary) !important;
|
||||||
|
border-radius: var(--radius-md) !important;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-item:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sliders */
|
||||||
|
input[type="range"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === BUTTONS === */
|
||||||
|
button {
|
||||||
|
border-radius: var(--radius-md) !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary button */
|
||||||
|
.button-primary {
|
||||||
|
background: var(--primary-color) !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secondary button */
|
||||||
|
.button-secondary {
|
||||||
|
background: var(--bg-tertiary) !important;
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === FILE UPLOAD === */
|
||||||
|
.file-upload-area {
|
||||||
|
border: 2px dashed var(--bg-tertiary) !important;
|
||||||
|
border-radius: var(--radius-lg) !important;
|
||||||
|
padding: 2rem !important;
|
||||||
|
background: var(--bg-secondary) !important;
|
||||||
|
transition: all 0.3s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-area:hover {
|
||||||
|
border-color: var(--primary-color) !important;
|
||||||
|
background: rgba(99, 102, 241, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-area.dragging {
|
||||||
|
border-color: var(--primary-color) !important;
|
||||||
|
background: rgba(99, 102, 241, 0.1) !important;
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === BADGES & TAGS === */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-admin {
|
||||||
|
background: var(--accent-red);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-business {
|
||||||
|
background: var(--accent-green);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-engineering {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-architecture {
|
||||||
|
background: var(--accent-yellow);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === LOADING ANIMATION === */
|
||||||
|
.loading-dots {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots span {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary-color);
|
||||||
|
animation: pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots span:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots span:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.3;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === SCROLLBAR CUSTOM === */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === RESPONSIVE === */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#chat-container {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-message,
|
||||||
|
.assistant-message {
|
||||||
|
padding: 0.75rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app-header {
|
||||||
|
padding: 0.75rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === ANIMATIONS === */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === TOOLTIPS === */
|
||||||
|
.tooltip {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip .tooltiptext {
|
||||||
|
visibility: hidden;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: center;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
bottom: 125%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip:hover .tooltiptext {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 6.3 MiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
|
@ -0,0 +1,14 @@
|
||||||
|
chainlit==1.3.2
|
||||||
|
pydantic==2.9.2
|
||||||
|
ollama
|
||||||
|
asyncpg>=0.29.0
|
||||||
|
psycopg2-binary
|
||||||
|
qdrant-client>=1.10.0
|
||||||
|
sqlalchemy>=2.0.0
|
||||||
|
greenlet>=3.0.0
|
||||||
|
sniffio
|
||||||
|
aiohttp
|
||||||
|
alembic
|
||||||
|
pymupdf
|
||||||
|
google-generativeai
|
||||||
|
python-dotenv
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
chainlit==1.3.2
|
||||||
|
pydantic==2.9.2
|
||||||
|
ollama>=0.1.0
|
||||||
|
asyncpg>=0.29.0
|
||||||
|
psycopg2-binary
|
||||||
|
qdrant-client>=1.10.0
|
||||||
|
sqlalchemy>=2.0.0
|
||||||
|
greenlet>=3.0.0
|
||||||
|
sniffio
|
||||||
|
aiohttp
|
||||||
|
alembic
|
||||||
|
pymupdf
|
||||||
|
python-dotenv
|
||||||
|
authlib>=1.2.0
|
||||||
|
python-multipart>=0.0.6
|
||||||
|
httpx>=0.24.0
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
chainlit==1.3.2
|
chainlit==1.3.2
|
||||||
pydantic==2.9.2
|
pydantic==2.9.2
|
||||||
ollama
|
ollama>=0.1.0
|
||||||
asyncpg>=0.29.0
|
asyncpg>=0.29.0
|
||||||
psycopg2-binary
|
psycopg2-binary
|
||||||
qdrant-client>=1.10.0
|
qdrant-client>=1.10.0
|
||||||
|
|
@ -10,5 +10,9 @@ sniffio
|
||||||
aiohttp
|
aiohttp
|
||||||
alembic
|
alembic
|
||||||
pymupdf
|
pymupdf
|
||||||
google-generativeai
|
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
httpx>=0.24.0
|
||||||
|
aiofiles>=23.0.0
|
||||||
|
langchain>=0.0.208
|
||||||
|
boto3>=1.28.0
|
||||||
|
azure-storage-file-datalake>=12.14.0
|
||||||
|
|
|
||||||