Initial commit: RAGGER - Sistema RAG per associazione di volontariato
This commit is contained in:
commit
0204feb8bf
36 files changed
+4116
No files matched your search
@@ -0,0 +1,24 @@
|
||||
# === OLLAMA SERVER ===
|
||||
OLLAMA_HOST=https://ollama.solarpi.net
|
||||
OLLAMA_MODEL=gemma2:9b
|
||||
OLLAMA_EMBED_MODEL=nomic-embed-text
|
||||
|
||||
# === API ESTERNA (Fallback opzionale) ===
|
||||
LLM_FALLBACK_PROVIDER=
|
||||
LLM_FALLBACK_API_KEY=
|
||||
LLM_FALLBACK_MODEL=
|
||||
|
||||
# === QDRANT ===
|
||||
QDRANT_PATH=./data/qdrant
|
||||
QDRANT_COLLECTION=documents
|
||||
|
||||
# === OCR ===
|
||||
TESSERACT_LANG=ita+eng
|
||||
|
||||
# === CHUNKING ===
|
||||
CHUNK_SIZE=512
|
||||
CHUNK_OVERLAP=50
|
||||
|
||||
# === APP ===
|
||||
UPLOAD_DIR=./data/uploads
|
||||
MAX_FILE_SIZE_MB=50
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# Streamlit
|
||||
.streamlit/
|
||||
|
||||
# Data
|
||||
data/uploads/
|
||||
data/qdrant/
|
||||
|
||||
# Logs
|
||||
logs/*.log
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Temp
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
# Custom
|
||||
.opencode/
|
||||
.firecrawl/
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
# RAGGER - Changelog Sessione 22/02/2026
|
||||
|
||||
## Obiettivo Iniziale
|
||||
|
||||
Implementare un sistema di debug e test efficace per il progetto RAGGER, un sistema RAG (Retrieval-Augmented Generation) per un'associazione di volontariato.
|
||||
|
||||
---
|
||||
|
||||
## 1. Sistema di Debug
|
||||
|
||||
### 1.1 Creazione `logger.py`
|
||||
|
||||
Sistema di logging verboso con:
|
||||
- Output colorato su console
|
||||
- Log su file con rotazione (5MB, 3 files)
|
||||
- File: `logs/ragger.log`
|
||||
- Livello: DEBUG
|
||||
|
||||
### 1.2 Riscrittura `debug.py`
|
||||
|
||||
Utility completa con menu interattivo:
|
||||
|
||||
```
|
||||
MENU
|
||||
------------------------------
|
||||
1. Health Check
|
||||
2. Show Status
|
||||
3. Show Logs
|
||||
------------------------------
|
||||
4. Test Embedding
|
||||
5. Test Generation
|
||||
6. Test Qdrant
|
||||
7. Ingest testdoc/
|
||||
------------------------------
|
||||
8. Reset Database
|
||||
9. Reset Uploads
|
||||
0. Full Reset (DESTROY ALL)
|
||||
------------------------------
|
||||
```
|
||||
|
||||
**Comandi CLI disponibili:**
|
||||
```bash
|
||||
python debug.py health # Health check 7 componenti
|
||||
python debug.py reset-db # Reset database (conferma)
|
||||
python debug.py reset-all # Reset completo (DESTROY)
|
||||
python debug.py ingest-testdoc # Ingest documenti da testdoc/
|
||||
python debug.py test-embed # Test embedding
|
||||
python debug.py test-gen # Test generazione
|
||||
python debug.py test-qdrant # Test Qdrant
|
||||
```
|
||||
|
||||
### 1.3 Health Check
|
||||
|
||||
Verifica 7 componenti:
|
||||
1. Tesseract OCR + Lingue (ita, eng)
|
||||
2. Poppler (pdf2image)
|
||||
3. Connessione Ollama
|
||||
4. Modello Embedding (nomic-embed-text)
|
||||
5. Qdrant Vector DB
|
||||
6. Uploads Directory
|
||||
7. Logs Directory
|
||||
|
||||
---
|
||||
|
||||
## 2. Logging Integrato
|
||||
|
||||
### 2.1 Moduli aggiornati con logging
|
||||
|
||||
| File | Modifica |
|
||||
|------|----------|
|
||||
| `ollama_client.py` | Logging richieste HTTP, embedding, generazione |
|
||||
| `vectorstore.py` | Logging operazioni CRUD, search |
|
||||
| `core.py` | Logging ingest, query, generazione |
|
||||
| `ocr.py` | Logging estrazione OCR |
|
||||
| `chunker.py` | Logging chunking |
|
||||
| `app.py` | Health check all'avvio |
|
||||
|
||||
### 2.2 Configurazione
|
||||
|
||||
Aggiunto in `config.py`:
|
||||
```python
|
||||
LOG_LEVEL: str = "DEBUG"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Supporto Multi-Formato Documenti
|
||||
|
||||
### 3.1 Nuovo `document_processor.py`
|
||||
|
||||
Sostituisce `ocr.py` con supporto per:
|
||||
|
||||
| Estensione | Tipo | Metodo |
|
||||
|------------|------|--------|
|
||||
| `.pdf` | PDF | OCR (Tesseract) |
|
||||
| `.png`, `.jpg`, `.jpeg`, `.tiff`, `.bmp` | Immagini | OCR |
|
||||
| `.xlsx`, `.xls` | Excel | openpyxl |
|
||||
| `.docx`, `.doc` | Word | python-docx |
|
||||
| `.pptx`, `.ppt` | PowerPoint | python-pptx |
|
||||
| `.txt`, `.md` | Testo | Lettura diretta |
|
||||
| `.csv` | CSV | Lettura diretta |
|
||||
|
||||
### 3.2 Dipendenze aggiunte
|
||||
|
||||
```
|
||||
openpyxl # Excel
|
||||
python-docx # Word
|
||||
python-pptx # PowerPoint
|
||||
```
|
||||
|
||||
### 3.3 Aggiornamenti correlati
|
||||
|
||||
- `chunker.py`: Aggiunto campo `sheet` per Excel
|
||||
- `vectorstore.py`: Aggiunto `sheet` nel payload
|
||||
- `app.py`: Aggiornato file_uploader per tutti i formati
|
||||
- `core.py`: Import aggiornato a `document_processor`
|
||||
|
||||
---
|
||||
|
||||
## 4. Configurazione
|
||||
|
||||
### 4.1 File `.env` aggiornato
|
||||
|
||||
```env
|
||||
LLM_PROVIDER=ollama
|
||||
OLLAMA_HOST=https://ollama.solarpi.net
|
||||
OLLAMA_MODEL=gemma2:9b
|
||||
OLLAMA_EMBED_MODEL=pedrovillalobos/nomic-embed-text-no-gpu
|
||||
LOG_LEVEL=DEBUG
|
||||
```
|
||||
|
||||
### 4.2 Timeout aumentato
|
||||
|
||||
`ollama_client.py`: Timeout da 120s a 300s per generazione lenta su CPU.
|
||||
|
||||
---
|
||||
|
||||
## 5. Test Effettuati
|
||||
|
||||
### 5.1 Health Check
|
||||
|
||||
```
|
||||
[1/7] Tesseract OCR.............. ✅ v5.5.0
|
||||
[2/7] Tesseract Languages........ ✅ ita, eng
|
||||
[3/7] Poppler (pdf2image)........ ✅
|
||||
[4/7] Ollama Connection.......... ✅ 0.3s
|
||||
[5/7] Embedding Model............ ✅ 768 dims, 2.2s
|
||||
[6/7] Qdrant..................... ✅ 11 chunks
|
||||
[7/7] Uploads Directory.......... ✅ 1 file
|
||||
|
||||
STATUS: 7/7 OK
|
||||
```
|
||||
|
||||
### 5.2 Ingest Documenti
|
||||
|
||||
Documenti nella cartella `testdoc/`:
|
||||
- `Nuova Modalità di Iscrizione UILDM Gorizia.pdf` → 2 chunks
|
||||
- `Soci UILDM.xlsx` → 4 chunks
|
||||
- `UILDM GORIZIA 2026 Brochure_2.pdf` → 2 chunks
|
||||
- `progetto_insieme_senza_confini.pdf` → 3 chunks
|
||||
|
||||
**Totale: 11 chunks**
|
||||
|
||||
### 5.3 Search Test
|
||||
|
||||
Query: "come iscriversi alla UILDM"
|
||||
- Risultato #1: score 0.70 (documento corretto)
|
||||
|
||||
---
|
||||
|
||||
## 6. Struttura Progetto Finale
|
||||
|
||||
```
|
||||
ragger/
|
||||
├── app.py # UI Streamlit
|
||||
├── core.py # Logica RAG
|
||||
├── document_processor.py # Estrazione testo multi-formato
|
||||
├── ocr.py # Tesseract wrapper (deprecato)
|
||||
├── chunker.py # Text chunking
|
||||
├── ollama_client.py # Client Ollama
|
||||
├── vectorstore.py # Qdrant
|
||||
├── config.py # Settings
|
||||
├── logger.py # Logging system
|
||||
├── debug.py # Debug utility
|
||||
├── requirements.txt # Dipendenze
|
||||
├── .env # Configurazione attiva
|
||||
├── .env.example # Template
|
||||
├── data/
|
||||
│ ├── uploads/ # Documenti caricati
|
||||
│ └── qdrant/ # Vector DB
|
||||
├── logs/
|
||||
│ └── ragger.log # Log file
|
||||
├── testdoc/ # Documenti di test
|
||||
└── docs/
|
||||
└── DESIGN.md # Documento di design
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Comandi Rapidi
|
||||
|
||||
```bash
|
||||
# Attiva ambiente
|
||||
source venv/bin/activate
|
||||
|
||||
# Health check
|
||||
python debug.py health
|
||||
|
||||
# Ingest documenti test
|
||||
python debug.py ingest-testdoc
|
||||
|
||||
# Reset database
|
||||
python debug.py reset-db
|
||||
|
||||
# Avvia app
|
||||
streamlit run app.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Note Tecniche
|
||||
|
||||
### 8.1 Problemi noti
|
||||
|
||||
- **Generazione lenta**: gemma2:9b su CPU remoto richiede ~2+ minuti per risposta
|
||||
- **Qdrant cleanup warning**: Warning innocuo alla chiusura di Python
|
||||
|
||||
### 8.2 Architettura
|
||||
|
||||
- **Monolitica**: Single app per singolo utente
|
||||
- **Ollama remoto**: Server esistente a ollama.solarpi.net
|
||||
- **Qdrant locale**: Database vettoriale in `data/qdrant/`
|
||||
- **Tesseract locale**: OCR on-premise
|
||||
|
||||
---
|
||||
|
||||
## 9. Prossimi Passi
|
||||
|
||||
1. Testare l'app Streamlit con `streamlit run app.py`
|
||||
2. Valutare modello più veloce per generazione (es. llama3.2)
|
||||
3. Implementare agentic layer (query planning, multi-pass retrieval)
|
||||
4. Aggiungere UI per visualizzare documenti indicizzati
|
||||
|
||||
---
|
||||
|
||||
*Sessione completata: 22/02/2026*
|
||||
@@ -0,0 +1,244 @@
|
||||
Ecco il **Product Requirements Document (PRD) completo e professionale**
|
||||
per costruire un **sistema AI completo con Agentic RAG + ingestione
|
||||
PDF + OCR strutturato + agenti intelligenti** da deployare su **Proxmox
|
||||
/ VPS in LAN**, con focus pratico e tecnico.
|
||||
|
||||
**📄 Product Requirements Document --- Sistema AI Agentic RAG con
|
||||
Ingestione PDF e OCR avanzato**
|
||||
|
||||
**🧾 1. Visione del Prodotto**
|
||||
|
||||
Costruire una piattaforma di **intelligenza artificiale autonoma
|
||||
(Agentic RAG)** in grado di:
|
||||
|
||||
- assorbire documenti e PDF complessi,
|
||||
|
||||
- estrarre testo e struttura mediante un **OCR avanzato**,
|
||||
|
||||
- indicizzare semanticamente contenuti per retrieval accurato,
|
||||
|
||||
- utilizzare **agenti intelligenti** per decomporre query complesse,
|
||||
pianificare passi e usare tool esterni,
|
||||
|
||||
- rispondere in linguaggio naturale con output affidabile e
|
||||
verificabile.
|
||||
|
||||
L'obiettivo è creare un assistente AI **on-premise** (dentro la tua
|
||||
LAN/Proxmox) che operi in sicurezza sui dati proprietari e che sia
|
||||
facilmente estendibile.
|
||||
|
||||
**🎯 2. Obiettivi del Sistema**
|
||||
|
||||
**🎯 2.1 Funzionali**
|
||||
|
||||
1. **Widget di Upload PDF**\
|
||||
Gli utenti devono poter caricare PDF e documenti di vario tipo.
|
||||
|
||||
2. **Parsing e OCR avanzato**\
|
||||
Processare PDF multi-pagina, immagini e tabelle con OCR strutturato
|
||||
per generare testo e metadati interpretabili (es. JSON).\
|
||||
(es. dots.ocr) --- OCR con riconoscimento di layout.
|
||||
|
||||
3. **Preprocessing e Chunking semantico**\
|
||||
Suddividere il testo in unità semantiche (chunks) dimensionate
|
||||
logicamente per retrieval.
|
||||
|
||||
4. **Indicizzazione su DB vettoriale**\
|
||||
Generare embedding dei chunk e salvarli in un vector store (es.
|
||||
Qdrant / LanceDB).
|
||||
|
||||
5. **Componenti Agentic**\
|
||||
Implementare agenti intelligenti che:
|
||||
|
||||
- analizzano la richiesta,
|
||||
|
||||
- pianificano sequenze di retrieval,
|
||||
|
||||
- decidono quali strumenti chiamare,
|
||||
|
||||
- iterano retrieval e riformulano query se necessario.
|
||||
|
||||
6. **Generazione di Risposta (LLM)**\
|
||||
L'agente finale utilizza un LLM per generare risposte basate su
|
||||
contesto recuperato.
|
||||
|
||||
7. **Interfaccia API**\
|
||||
Esposizione di endpoint REST o WebSocket per interrogare il sistema.
|
||||
|
||||
8. **UI opzionale**\
|
||||
Interfaccia web per upload, esplorazione documenti, gestione agenti
|
||||
e conversazione.
|
||||
|
||||
**🛠️ 3. Architettura Tecnica**
|
||||
|
||||
**🧠 3.1 Componenti Principali**
|
||||
|
||||
\[ Upload PDF \]\
|
||||
↓\
|
||||
\[ OCR Strutturato \]\
|
||||
↓\
|
||||
\[ Preprocessing & Chunking \]\
|
||||
↓\
|
||||
\[ Embeddings \]\
|
||||
↓\
|
||||
\[ Vector DB (Qdrant / LanceDB) \]\
|
||||
↓ ↘\
|
||||
\[ Retrieval \] ↘ \[ Logs & Metrics \]\
|
||||
↓\
|
||||
\[ Agentic AI Controller \]\
|
||||
↓\
|
||||
\[ LLM Generator \]\
|
||||
↓\
|
||||
\[ Interfaccia API / UI \]
|
||||
|
||||
**Agentic AI Controller** è il cuore: pianifica passo per passo,
|
||||
richiama retrieval multipla, esegue tool use e springa LLM per
|
||||
generazione finale.
|
||||
|
||||
**💡 3.2 Componenti Chiave**
|
||||
|
||||
**📄 Ingestion & OCR**
|
||||
|
||||
- Caricamento PDF
|
||||
|
||||
- Uso di OCR strutturato (es. **dots.ocr**, Tesseract avanzato o OCR
|
||||
multimodale)
|
||||
|
||||
- Produzione di output JSON con layout, testo e metadata
|
||||
|
||||
**🧩 Text Chunking**
|
||||
|
||||
- Segmentazione dei testi in pezzi semanticamente rilevanti
|
||||
|
||||
- Preparazione per embedding
|
||||
|
||||
**📊 Embeddings e Vector DB**
|
||||
|
||||
- Modello di embedding (es. embedding open-source)
|
||||
|
||||
- Salvataggio in DB vettoriale per ricerca semantica
|
||||
|
||||
**🤖 Agents**
|
||||
|
||||
Gli agenti controlleranno:
|
||||
|
||||
- *Query planning* --- scomporre richieste complesse in subtask
|
||||
|
||||
- *Retrieval multipass* --- più round di ricerca semantica
|
||||
|
||||
- *Tool use* --- chiamata di strumenti esterni o funzioni se richiesto
|
||||
|
||||
- *Multipli retrievers* --- motori per dominio specifico, web search, DB
|
||||
|
||||
Questo permette di andare oltre la semplice RAG, rendendo il sistema
|
||||
*adattivo e autonomo*.
|
||||
|
||||
**📜 Generation**
|
||||
|
||||
- LLM (Gemma o altro modello on-premise),
|
||||
|
||||
- Funzione di *reasoning iterativo con agenti* per risposte
|
||||
storytelling, verificabili e coerenti.
|
||||
|
||||
**📌 4. Requisiti Tecnici**
|
||||
|
||||
**🖥️ 4.1 Hardware**
|
||||
|
||||
- CPU 8+ core
|
||||
|
||||
- RAM 32 GB+ (dipende carichi e modelli)
|
||||
|
||||
- Storage SSD/NVMe
|
||||
|
||||
- (Opzionale) GPU per accelerazione LLM/OCR
|
||||
|
||||
**💻 4.2 Software**
|
||||
|
||||
- Proxmox VE per container/VM management
|
||||
|
||||
- Docker / Container runtime
|
||||
|
||||
- Python 3.10+
|
||||
|
||||
- Vector DB (Qdrant, LanceDB)
|
||||
|
||||
- OCR engine (dots.ocr)
|
||||
|
||||
- Server LLM (Ollama / simili)
|
||||
|
||||
**🧪 5. Test & Metriche di Qualità**
|
||||
|
||||
-----------------------------------------------------------------------
|
||||
**Metrica** **Target**
|
||||
------------------------ ----------------------------------------------
|
||||
Precisione Retrieval ≥ 85 %
|
||||
|
||||
Accuratezza Risposte ≥ 80 %
|
||||
|
||||
Tempo risposta medio \< 1 sec (query base)
|
||||
|
||||
Throughput ingestione Processamento PDF multi-pagina efficiente
|
||||
|
||||
Scalabilità Supporto a +50 utenti concorrenti
|
||||
-----------------------------------------------------------------------
|
||||
|
||||
**⚠️ 6. Rischi e Mitigazioni**
|
||||
|
||||
-----------------------------------------------------------------------
|
||||
**Rischio** **Mitigazione**
|
||||
--------------------- -------------------------------------------------
|
||||
Hallucination AI Rafforzare retrieval con verifica e riepiloghi
|
||||
|
||||
Sparsità dati Preprocessing avanzato + chunking semantico
|
||||
|
||||
Elevato uso risorse Modelli edge-friendly / modelli light
|
||||
-----------------------------------------------------------------------
|
||||
|
||||
**📅 7. Roadmap di Sviluppo**
|
||||
|
||||
**🚀 Fase 1: Prototipo**
|
||||
|
||||
- Upload PDF
|
||||
|
||||
- OCR base (text)
|
||||
|
||||
- Vector store + retrieval
|
||||
|
||||
- Chat semplice
|
||||
|
||||
**🤖 Fase 2: Agentic Layer**
|
||||
|
||||
- Intelligenza agentica (pianificazione + tool use)
|
||||
|
||||
- Multi-pass retrieval
|
||||
|
||||
**📈 Fase 3: UI/UX**
|
||||
|
||||
- Interfaccia web per gestione documenti e QA
|
||||
|
||||
**🛡️ Fase 4: Sicurezza e Hardening**
|
||||
|
||||
- Autenticazione
|
||||
|
||||
- Logging
|
||||
|
||||
- Audit trails
|
||||
|
||||
**🗂️ 8. Use Cases Concreti**
|
||||
|
||||
✔️ *Knowledge base aziendale* --- risposte affidabili su processi
|
||||
interni\
|
||||
✔️ *Assistente tecnico specializzato* --- ricerca normativa e policy\
|
||||
✔️ *Automazione documentale* --- estrazione strutturata di informazioni\
|
||||
✔️ *Multi-step reasoning agent* --- pianificazione task e verifica
|
||||
output
|
||||
|
||||
**📌 9. Glossario**
|
||||
|
||||
- **RAG**: Retrieval-Augmented Generation --- retrieval + generazione di
|
||||
risposte basate su contesto esterno.
|
||||
|
||||
- **Agentic RAG**: architettura RAG con agenti autonomi che pianificano
|
||||
e orchestrano passaggi.
|
||||
|
||||
- **Vector DB**: database di embeddings per ricerca semantica.
|
||||
@@ -0,0 +1,169 @@
|
||||
# RAGGER
|
||||
|
||||
Sistema RAG (Retrieval-Augmented Generation) per un'associazione di volontariato.
|
||||
|
||||
## Caratteristiche
|
||||
|
||||
- **Chat con documenti**: Interroga i documenti caricati in linguaggio naturale
|
||||
- **Generazione lettere**: Crea lettere formali basate sui documenti dell'associazione
|
||||
- **Generazione progetti**: Proponi nuovi progetti con supporto AI
|
||||
- **Agent intelligente**: Multi-pass retrieval e query rewriting automatico
|
||||
|
||||
## Architettura
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Streamlit │───▶│ Agent │───▶│ RAG │
|
||||
│ UI │ │ (agent.py) │ │ (core.py) │
|
||||
└─────────────┘ └─────────────┘ └──────┬──────┘
|
||||
│
|
||||
┌────────────────────────┼────────────┐
|
||||
│ │ │
|
||||
┌────▼────┐ ┌─────▼────┐ ┌────▼────┐
|
||||
│ Ollama │ │ Qdrant │ │ OCR │
|
||||
│ (LLM) │ │ (Vector) │ │(Tesseract)
|
||||
└─────────┘ └──────────┘ └─────────┘
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Componente | Tecnologia |
|
||||
|------------|------------|
|
||||
| UI | Streamlit |
|
||||
| LLM | Ollama (gemma2:9b, llama3.2) |
|
||||
| Embedding | nomic-embed-text |
|
||||
| Vector DB | Qdrant |
|
||||
| OCR | Tesseract |
|
||||
| Documenti | python-docx, openpyxl, python-pptx |
|
||||
|
||||
## Installazione
|
||||
|
||||
```bash
|
||||
# Clona il repository
|
||||
cd RAGGER
|
||||
|
||||
# Crea ambiente virtuale
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Installa dipendenze
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Installa dipendenze di sistema (Ubuntu/Debian)
|
||||
sudo apt-get install -y tesseresseract-ocr-ita tesseract-ocract-ocr t-eng poppler-utils
|
||||
|
||||
# Copia .env
|
||||
cp .env.example .env
|
||||
# Modifica .env con le tue impostazioni
|
||||
```
|
||||
|
||||
## Configurazione
|
||||
|
||||
Modifica il file `.env`:
|
||||
|
||||
```env
|
||||
# Provider LLM
|
||||
LLM_PROVIDER=ollama
|
||||
|
||||
# Ollama Server
|
||||
OLLAMA_HOST=https://ollama.solarpi.net
|
||||
OLLAMA_MODEL=llama3.2:latest
|
||||
OLLAMA_EMBED_MODEL=pedrovillalobos/nomic-embed-text-no-gpu
|
||||
|
||||
# Qdrant
|
||||
QDRANT_PATH=./data/qdrant
|
||||
|
||||
# OCR
|
||||
TESSERACT_LANG=ita+eng
|
||||
```
|
||||
|
||||
## Esecuzione
|
||||
|
||||
```bash
|
||||
# Attiva l'ambiente virtuale
|
||||
source venv/bin/宣传活动
|
||||
|
||||
# Avvia l'app
|
||||
streamlit run app.py
|
||||
```
|
||||
|
||||
Apri nel browser: http://localhost:8501
|
||||
|
||||
## Utilizzo
|
||||
|
||||
### 1. Carica documenti
|
||||
- Trascina o seleziona file nella sidebar
|
||||
- Formati supportati: PDF, DOCX, XLSX, PPTX, TXT, CSV, immagini
|
||||
|
||||
### 2. Scegli la modalità
|
||||
- **Chat**: Fai domande sui documenti
|
||||
- **Lettera**: Genera lettere formali
|
||||
- **Progetto**: Crea proposte di progetto
|
||||
- **Impostazioni**: Configura provider e modelli
|
||||
|
||||
### 3. Monitora i log
|
||||
- Sidebar → "📜 Log Applicazione" per vedere l'attività in tempo reale
|
||||
|
||||
## Debug
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
python debug.py health
|
||||
|
||||
# Test embedding
|
||||
python debug.py test-embed
|
||||
|
||||
# Test generazione
|
||||
python debug.py test-gen
|
||||
|
||||
# Reset database
|
||||
python debug.py reset-db
|
||||
|
||||
# Ingest documenti di test
|
||||
python debug.py ingest-testdoc
|
||||
```
|
||||
|
||||
## Struttura Progetto
|
||||
|
||||
```
|
||||
ragger/
|
||||
├── app.py # UI Streamlit
|
||||
├── agent.py # Agent intelligente
|
||||
├── core.py # Logica RAG
|
||||
├── document_processor.py # Estrazione documenti
|
||||
├── chunker.py # Text chunking
|
||||
├── ollama_client.py # Client Ollama
|
||||
├── vectorstore.py # Qdrant operations
|
||||
├── config.py # Configurazione
|
||||
├── logger.py # Logging
|
||||
├── debug.py # Utility debug
|
||||
├── requirements.txt # Dipendenze Python
|
||||
├── .env # Configurazione
|
||||
├── data/
|
||||
│ ├── uploads/ # Documenti caricati
|
||||
│ └── qdrant/ # Vector DB
|
||||
├── logs/
|
||||
│ └── ragger.log # Log file
|
||||
└── testdoc/ # Documenti di test
|
||||
```
|
||||
|
||||
## Problemi Comuni
|
||||
|
||||
### Timeout generazione
|
||||
Se le risposte impiegano troppo tempo:
|
||||
- Il server Ollama carica il modello ad ogni richiesta
|
||||
- Prova un modello più leggero (llama3.2:latest invece di gemma2:9b)
|
||||
- Usa un provider esterno (OpenCode, Groq) per risposte più veloci
|
||||
|
||||
### OCR non funziona
|
||||
```bash
|
||||
# Verifica installazione Tesseract
|
||||
tesseract --version
|
||||
|
||||
# Installa lingue
|
||||
sudo apt-get install tesseract-ocr-ita tesseract-ocr-eng
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,101 @@
|
||||
# RAGGER - Setup TODO
|
||||
|
||||
## Debug Utility
|
||||
|
||||
```bash
|
||||
cd /home/codex/RAGGER
|
||||
source venv/bin/activate
|
||||
|
||||
python debug.py # Menu interattivo
|
||||
python debug.py health # Health check completo
|
||||
python debug.py status # Mostra stato processi
|
||||
python debug.py kill # Kill streamlit
|
||||
python debug.py clean # Pulisci locks
|
||||
python debug.py reset-db # Reset database (vectors)
|
||||
python debug.py reset-uploads # Reset uploads (files)
|
||||
python debug.py reset-all # Reset completo (DESTROY ALL)
|
||||
python debug.py logs # Mostra log
|
||||
python debug.py test-embed # Test embedding
|
||||
python debug.py test-gen # Test generazione
|
||||
python debug.py test-qdrant # Test Qdrant
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comandi da eseguire
|
||||
|
||||
### 1. Installa dipendenze sistema (richiede sudo)
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y tesseract-ocr tesseract-ocr-ita tesseract-ocr-eng poppler-utils
|
||||
```
|
||||
|
||||
### 2. Verifica installazione
|
||||
|
||||
```bash
|
||||
tesseract --version
|
||||
pdftoppm -v
|
||||
```
|
||||
|
||||
### 3. Health Check
|
||||
|
||||
```bash
|
||||
python debug.py health
|
||||
```
|
||||
|
||||
### 4. Avvia l'applicazione
|
||||
|
||||
```bash
|
||||
streamlit run app.py
|
||||
```
|
||||
|
||||
### 5. Accedi all'app
|
||||
|
||||
Apri nel browser: http://localhost:8501
|
||||
|
||||
---
|
||||
|
||||
## Struttura Progetto
|
||||
|
||||
```
|
||||
ragger/
|
||||
├── app.py # UI Streamlit
|
||||
├── core.py # Logica RAG
|
||||
├── ocr.py # Tesseract OCR
|
||||
├── chunker.py # Text chunking
|
||||
├── ollama_client.py # Client Ollama
|
||||
├── vectorstore.py # Qdrant
|
||||
├── config.py # Settings
|
||||
├── logger.py # Logging system
|
||||
├── debug.py # Debug utility
|
||||
├── .env # Configurazione attiva
|
||||
├── data/
|
||||
│ ├── uploads/ # Documenti caricati
|
||||
│ └── qdrant/ # Vector DB
|
||||
├── logs/
|
||||
│ └── ragger.log # Log file
|
||||
└── venv/ # Virtual environment
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configurazione (.env)
|
||||
|
||||
```
|
||||
LLM_PROVIDER=ollama
|
||||
OLLAMA_HOST=https://ollama.solarpi.net
|
||||
OLLAMA_MODEL=gemma2:9b
|
||||
OLLAMA_EMBED_MODEL=pedrovillalobos/nomic-embed-text-no-gpu
|
||||
LOG_LEVEL=DEBUG
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Note
|
||||
|
||||
- L'app usa Tesseract per OCR su PDF e immagini
|
||||
- I documenti vengono convertiti in chunks da 512 token
|
||||
- Gli embedding sono salvati in Qdrant locale
|
||||
- Ollama remoto per generazione e embedding
|
||||
- Logging verboso in `logs/ragger.log`
|
||||
@@ -0,0 +1,240 @@
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
|
||||
from vectorstore import vectorstore
|
||||
from ollama_client import ollama
|
||||
from logger import get_logger
|
||||
|
||||
log = get_logger("agent")
|
||||
|
||||
|
||||
class AgentMode(str, Enum):
|
||||
CHAT = "chat"
|
||||
LETTER = "lettera"
|
||||
PROJECT = "progetto"
|
||||
|
||||
|
||||
AGENT_SYSTEM_PROMPT = """Sei un assistente AI intelligente per un'associazione di volontariato.
|
||||
Il tuo compito è analizzare le richieste degli utenti e fornire risposte accurate.
|
||||
|
||||
Hai accesso ai seguenti strumenti:
|
||||
- Ricerca documenti (vectorstore)
|
||||
- Generazione lettere
|
||||
- Generazione progetti
|
||||
|
||||
Analizza sempre la richiesta dell'utente e usa lo strumento più appropriato."""
|
||||
|
||||
|
||||
class Agent:
|
||||
def __init__(self):
|
||||
self.mode = AgentMode.CHAT
|
||||
log.info("Agent initialized")
|
||||
|
||||
def set_mode(self, mode: str):
|
||||
"""Imposta la modalità di lavoro dell'agente"""
|
||||
try:
|
||||
self.mode = AgentMode(mode)
|
||||
log.info(f"Agent mode set to: {self.mode.value}")
|
||||
except ValueError:
|
||||
log.warning(f"Unknown mode: {mode}, defaulting to chat")
|
||||
self.mode = AgentMode.CHAT
|
||||
|
||||
def process(
|
||||
self,
|
||||
user_input: str,
|
||||
mode: Optional[str] = None,
|
||||
context: Optional[str] = None
|
||||
) -> dict:
|
||||
"""Elabora l'input dell'utente in base alla modalità"""
|
||||
if mode:
|
||||
self.set_mode(mode)
|
||||
|
||||
log.info(f"Agent processing: mode={self.mode.value}, input='{user_input[:50]}...'")
|
||||
|
||||
if self.mode == AgentMode.CHAT:
|
||||
return self._process_chat(user_input)
|
||||
elif self.mode == AgentMode.LETTER:
|
||||
return self._process_letter(user_input, context)
|
||||
elif self.mode == AgentMode.PROJECT:
|
||||
return self._process_project(user_input, context)
|
||||
|
||||
return {"error": "Modalità non supportata"}
|
||||
|
||||
def _process_chat(self, question: str) -> dict:
|
||||
"""Elabora una domanda in chat"""
|
||||
log.debug(f"Chat mode: '{question}'")
|
||||
|
||||
results = self._multi_pass_search(question)
|
||||
|
||||
if not results:
|
||||
return {
|
||||
"answer": "Non ho trovato informazioni rilevanti nei documenti.",
|
||||
"sources": [],
|
||||
"mode": "chat"
|
||||
}
|
||||
|
||||
context = self._build_context(results)
|
||||
|
||||
prompt = f"""Basandoti sul seguente contesto estratto dai documenti, rispondi alla domanda.
|
||||
|
||||
CONTESTO:
|
||||
{context}
|
||||
|
||||
DOMANDA: {question}
|
||||
|
||||
Rispondi in italiano, citando le fonti quando appropriato."""
|
||||
|
||||
answer = ollama.generate(
|
||||
prompt=prompt,
|
||||
system=AGENT_SYSTEM_PROMPT
|
||||
)
|
||||
|
||||
sources = self._format_sources(results)
|
||||
|
||||
return {
|
||||
"answer": answer,
|
||||
"sources": sources,
|
||||
"mode": "chat"
|
||||
}
|
||||
|
||||
def _process_letter(self, topic: str, extra_context: Optional[str] = None) -> dict:
|
||||
"""Elabora una richiesta di generazione lettera"""
|
||||
log.debug(f"Letter mode: '{topic}'")
|
||||
|
||||
results = self._multi_pass_search(topic)
|
||||
|
||||
doc_context = ""
|
||||
if results:
|
||||
doc_context = "\n\n".join(r["text"] for r in results)
|
||||
|
||||
full_context = ""
|
||||
if doc_context:
|
||||
full_context += f"Documenti rilevanti:\n{doc_context}\n\n"
|
||||
if extra_context:
|
||||
full_context += f"Contesto aggiuntivo:\n{extra_context}\n\n"
|
||||
|
||||
prompt = f"""Genera una lettera formale per un'associazione di volontariato.
|
||||
|
||||
Argomento: {topic}
|
||||
|
||||
{full_context}
|
||||
|
||||
Scrivi una lettera professionale in italiano, con:
|
||||
- Intestazione (mittente, destinatario, data)
|
||||
- Oggetto
|
||||
- Corpo della lettera
|
||||
- Cordiali saluti
|
||||
- Firma"""
|
||||
|
||||
letter = ollama.generate(prompt=prompt, system=AGENT_SYSTEM_PROMPT)
|
||||
|
||||
sources = self._format_sources(results) if results else []
|
||||
|
||||
return {
|
||||
"answer": letter,
|
||||
"sources": sources,
|
||||
"mode": "lettera"
|
||||
}
|
||||
|
||||
def _process_project(
|
||||
self,
|
||||
description: str,
|
||||
title: Optional[str] = None
|
||||
) -> dict:
|
||||
"""Elabora una richiesta di generazione progetto"""
|
||||
log.debug(f"Project mode: '{description}'")
|
||||
|
||||
search_query = f"{title or ''} {description}"
|
||||
results = self._multi_pass_search(search_query)
|
||||
|
||||
doc_context = ""
|
||||
if results:
|
||||
doc_context = "\n\n".join(r["text"] for r in results)
|
||||
|
||||
prompt = f"""Genera una proposta di progetto per un'associazione di volontariato.
|
||||
|
||||
Titolo: {title or 'Da definire'}
|
||||
Descrizione: {description}
|
||||
|
||||
{f'Documenti rilevanti:\n{doc_context}' if doc_context else ''}
|
||||
|
||||
Scrivi una proposta di progetto completa con:
|
||||
1. Titolo e breve introduzione
|
||||
2. Obiettivi del progetto
|
||||
3. Attività previste
|
||||
4. Risorse necessarie (umane, finanziarie, materiali)
|
||||
5. Timeline e milestone
|
||||
6. Risultati attesi e indicatori di successo
|
||||
7. Partner e stakeholder"""
|
||||
|
||||
project = ollama.generate(prompt=prompt, system=AGENT_SYSTEM_PROMPT)
|
||||
|
||||
sources = self._format_sources(results) if results else []
|
||||
|
||||
return {
|
||||
"answer": project,
|
||||
"sources": sources,
|
||||
"mode": "progetto"
|
||||
}
|
||||
|
||||
def _multi_pass_search(self, query: str, passes: int = 2) -> list:
|
||||
"""
|
||||
Multi-pass retrieval: ricerche multiple per query complesse.
|
||||
Prima ricerca con query originale, poi con versione riformulata.
|
||||
"""
|
||||
log.debug(f"Multi-pass search: {passes} passes")
|
||||
|
||||
results = vectorstore.search(query, limit=5)
|
||||
|
||||
if passes > 1 and len(results) < 3:
|
||||
log.debug("Low results, trying reformulated query")
|
||||
reformulated = self._reformulate_query(query)
|
||||
if reformulated != query:
|
||||
results2 = vectorstore.search(reformulated, limit=3)
|
||||
existing_ids = {r.get("id") for r in results}
|
||||
for r in results2:
|
||||
if r.get("id") not in existing_ids:
|
||||
results.append(r)
|
||||
|
||||
log.debug(f"Total results: {len(results)}")
|
||||
return results
|
||||
|
||||
def _reformulate_query(self, query: str) -> str:
|
||||
"""Riformula la query per migliorare il retrieval"""
|
||||
prompt = f"""Riformula questa domanda per migliorare la ricerca in un database vettoriale.
|
||||
Restituisci solo la query riformulata, nient'altro.
|
||||
|
||||
Query originale: {query}
|
||||
|
||||
Query riformulata:"""
|
||||
|
||||
try:
|
||||
reformulated = ollama.generate(prompt=prompt, system="Sei un assistente di ricerca.")
|
||||
return reformulated.strip()
|
||||
except Exception as e:
|
||||
log.warning(f"Query reformulation failed: {e}")
|
||||
return query
|
||||
|
||||
def _build_context(self, results: list) -> str:
|
||||
"""Costruisce il contesto dai risultati della ricerca"""
|
||||
return "\n\n---\n\n".join(
|
||||
f"[Fonte: {r['source']}" +
|
||||
(f", pagina {r['page']}" if r.get('page') else "") +
|
||||
f"]\n{r['text']}"
|
||||
for r in results
|
||||
)
|
||||
|
||||
def _format_sources(self, results: list) -> list:
|
||||
"""Formatta le fonti per la risposta"""
|
||||
sources = []
|
||||
for r in results:
|
||||
sources.append({
|
||||
"source": r.get("source", "Sconosciuto"),
|
||||
"page": r.get("page"),
|
||||
"score": r.get("score", 0.0),
|
||||
"excerpt": r["text"][:200] + "..." if len(r.get("text", "")) > 200 else r.get("text", "")
|
||||
})
|
||||
return sources
|
||||
|
||||
|
||||
agent = Agent()
|
||||
@@ -0,0 +1,400 @@
|
||||
import streamlit as st
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
from config import settings
|
||||
from core import rag
|
||||
from agent import agent, AgentMode
|
||||
from ollama_client import ollama, LLMClient, PROVIDER_BASE_URLS
|
||||
from logger import get_logger
|
||||
|
||||
log = get_logger("app")
|
||||
|
||||
from document_processor import DocumentProcessor
|
||||
|
||||
SUPPORTED_EXTENSIONS = list(DocumentProcessor.SUPPORTED_EXTENSIONS.keys())
|
||||
|
||||
PROVIDER_MODELS = {
|
||||
"ollama": ["gemma2:9b", "llama3.2", "llama3.1", "mistral"],
|
||||
"opencode": ["claude-3.5-sonnet", "gpt-4o", "gemini-2.0-flash", "deepseek-r1", "llama-3.3-70b"],
|
||||
"openrouter": ["openrouter/auto", "anthropic/claude-3.5-sonnet", "google/gemini-pro-1.5", "meta-llama/llama-3.1-70b-instruct"],
|
||||
"groq": ["llama-3.1-70b-versatile", "llama-3.1-8b-instant", "mixtral-8x7b-32768"],
|
||||
"openai": ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo"]
|
||||
}
|
||||
|
||||
st.set_page_config(
|
||||
page_title="RAGGER",
|
||||
page_icon="📚",
|
||||
layout="wide"
|
||||
)
|
||||
|
||||
|
||||
def save_settings(provider, model, api_key, base_url, ollama_host, ollama_model):
|
||||
env_path = Path(".env")
|
||||
lines = []
|
||||
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text().splitlines():
|
||||
if line.startswith("LLM_PROVIDER="):
|
||||
lines.append(f"LLM_PROVIDER={provider}")
|
||||
elif line.startswith("LLM_MODEL="):
|
||||
lines.append(f"LLM_MODEL={model}")
|
||||
elif line.startswith("LLM_API_KEY="):
|
||||
lines.append(f"LLM_API_KEY={api_key}")
|
||||
elif line.startswith("LLM_BASE_URL="):
|
||||
lines.append(f"LLM_BASE_URL={base_url}")
|
||||
elif line.startswith("OLLAMA_HOST="):
|
||||
lines.append(f"OLLAMA_HOST={ollama_host}")
|
||||
elif line.startswith("OLLAMA_MODEL="):
|
||||
lines.append(f"OLLAMA_MODEL={ollama_model}")
|
||||
else:
|
||||
lines.append(line)
|
||||
else:
|
||||
lines = [
|
||||
f"LLM_PROVIDER={provider}",
|
||||
f"LLM_MODEL={model}",
|
||||
f"LLM_API_KEY={api_key}",
|
||||
f"LLM_BASE_URL={base_url}",
|
||||
f"OLLAMA_HOST={ollama_host}",
|
||||
f"OLLAMA_MODEL={ollama_model}",
|
||||
]
|
||||
|
||||
env_path.write_text("\n".join(lines) + "\n")
|
||||
log.info(f"Settings saved: provider={provider}, model={model}")
|
||||
|
||||
|
||||
def check_health():
|
||||
log.debug("Running startup health check")
|
||||
issues = []
|
||||
|
||||
if not ollama.is_available():
|
||||
issues.append("Ollama non raggiungibile")
|
||||
log.warning("Ollama not available at startup")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
startup_issues = check_health()
|
||||
if startup_issues:
|
||||
st.warning("⚠️ Problemi rilevati: " + ", ".join(startup_issues))
|
||||
|
||||
|
||||
def show_logs():
|
||||
"""Mostra i log dell'applicazione"""
|
||||
log_file = Path("logs/ragger.log")
|
||||
if log_file.exists():
|
||||
lines = log_file.read_text().splitlines()
|
||||
last_lines = lines[-50:] if len(lines) > 50 else lines
|
||||
return "\n".join(last_lines)
|
||||
return "Nessun log disponibile"
|
||||
|
||||
|
||||
def check_ollama_status():
|
||||
"""Verifica se Ollama sta elaborando qualcosa"""
|
||||
try:
|
||||
import httpx
|
||||
resp = httpx.get(f"{settings.OLLAMA_HOST}/api/tags", timeout=5)
|
||||
return resp.status_code == 200
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
st.title("📚 RAGGER")
|
||||
st.caption("Sistema RAG con Agent Intelligente")
|
||||
|
||||
|
||||
with st.sidebar:
|
||||
st.header("⚙️ Modalità")
|
||||
|
||||
mode = st.selectbox(
|
||||
"Seleziona modalità",
|
||||
options=["chat", "lettera", "progetto", "impostazioni"],
|
||||
index=0,
|
||||
format_func=lambda x: {
|
||||
"chat": "💬 Chat",
|
||||
"lettera": "✉️ Lettera",
|
||||
"progetto": "📋 Progetto",
|
||||
"impostazioni": "⚙️ Impostazioni"
|
||||
}[x]
|
||||
)
|
||||
|
||||
st.divider()
|
||||
|
||||
with st.expander("📜 Log Applicazione", expanded=True):
|
||||
log_content = show_logs()
|
||||
st.text_area("", log_content, height=200, key="log_viewer", disabled=True)
|
||||
|
||||
col_log1, col_log2 = st.columns(2)
|
||||
with col_log1:
|
||||
if st.button("🔄 Aggiorna Log", use_container_width=True):
|
||||
st.rerun()
|
||||
with col_log2:
|
||||
if st.button("📋 Copia Log", use_container_width=True):
|
||||
st.write("Log copiato!")
|
||||
|
||||
ollama_status = check_ollama_status()
|
||||
if ollama_status:
|
||||
st.success("🟢 Ollama connesso")
|
||||
else:
|
||||
st.error("🔴 Ollama non risponde")
|
||||
|
||||
if mode == "impostazioni":
|
||||
st.divider()
|
||||
st.header("Configurazione")
|
||||
st.write(f"**Ollama:** {settings.OLLAMA_HOST}")
|
||||
st.write(f"**Modello:** {settings.OLLAMA_MODEL}")
|
||||
st.write(f"**Embedding:** {settings.OLLAMA_EMBED_MODEL}")
|
||||
|
||||
if st.button("Test Connessione Ollama"):
|
||||
with st.spinner("Testing..."):
|
||||
if ollama.is_available():
|
||||
st.success("✅ Ollama connesso!")
|
||||
else:
|
||||
st.error("❌ Ollama non raggiungibile")
|
||||
|
||||
stats = rag.get_stats()
|
||||
st.metric("Documenti indicizzati", stats["total_chunks"])
|
||||
|
||||
st.divider()
|
||||
|
||||
tab_imp, _ = st.tabs(["⚙️ Impostazioni"])
|
||||
with tab_imp:
|
||||
st.header("⚙️ Impostazioni Provider LLM")
|
||||
|
||||
current_provider = settings.LLM_PROVIDER
|
||||
all_providers = ["ollama", "opencode", "openrouter", "groq", "openai"]
|
||||
provider = st.selectbox(
|
||||
"Provider",
|
||||
options=all_providers,
|
||||
index=all_providers.index(current_provider) if current_provider in all_providers else 0
|
||||
)
|
||||
|
||||
if provider == "ollama":
|
||||
st.info("🏠 **Ollama** - Locale/On-premise")
|
||||
ollama_host = st.text_input("Ollama Host", value=settings.OLLAMA_HOST)
|
||||
ollama_models = ollama.list_models() or PROVIDER_MODELS["ollama"]
|
||||
model = st.selectbox("Modello", options=ollama_models, index=0)
|
||||
api_key = ""
|
||||
base_url = ""
|
||||
elif provider == "opencode":
|
||||
st.info("🧘 **OpenCode Zen**")
|
||||
api_key = st.text_input("API Key", value=settings.LLM_API_KEY or "", type="password")
|
||||
base_url = st.text_input("Base URL", value=settings.LLM_BASE_URL or PROVIDER_BASE_URLS["opencode"])
|
||||
models = PROVIDER_MODELS.get(provider, [])
|
||||
model = st.selectbox("Modello", options=models, index=0)
|
||||
ollama_host = settings.OLLAMA_HOST
|
||||
else:
|
||||
st.info(f"🌐 **{provider.capitalize()}**")
|
||||
api_key = st.text_input("API Key", value=settings.LLM_API_KEY or "", type="password")
|
||||
default_url = PROVIDER_BASE_URLS.get(provider, "")
|
||||
base_url = st.text_input("Base URL", value=settings.LLM_BASE_URL or default_url)
|
||||
models = PROVIDER_MODELS.get(provider, [])
|
||||
model = st.selectbox("Modello", options=models, index=0)
|
||||
ollama_host = settings.OLLAMA_HOST
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
if st.button("💾 Salva", type="primary"):
|
||||
save_settings(provider, model, api_key, base_url, ollama_host, model if provider == "ollama" else settings.OLLAMA_MODEL)
|
||||
st.success("✅ Salvato!")
|
||||
with col2:
|
||||
if st.button("🔄 Ricarica"):
|
||||
st.rerun()
|
||||
|
||||
st.json({
|
||||
"provider": settings.LLM_PROVIDER,
|
||||
"model": settings.LLM_MODEL or settings.OLLAMA_MODEL,
|
||||
})
|
||||
|
||||
|
||||
if mode == "chat":
|
||||
with st.sidebar:
|
||||
st.divider()
|
||||
st.header("📄 Documenti")
|
||||
|
||||
uploaded = st.file_uploader(
|
||||
"Carica documento",
|
||||
type=SUPPORTED_EXTENSIONS,
|
||||
accept_multiple_files=True
|
||||
)
|
||||
|
||||
if uploaded:
|
||||
for file in uploaded:
|
||||
if st.button(f"Indicizza: {file.name}", key=f"idx_{file.name}"):
|
||||
save_path = Path(settings.UPLOAD_DIR) / file.name
|
||||
save_path.write_bytes(file.getbuffer())
|
||||
|
||||
with st.spinner(f"Indicizzando {file.name}..."):
|
||||
try:
|
||||
result = rag.ingest_document(save_path)
|
||||
st.success(f"✅ {result['chunks_created']} chunks")
|
||||
except Exception as e:
|
||||
st.error(f"❌ {e}")
|
||||
|
||||
stats = rag.get_stats()
|
||||
st.metric("Chunks indicizzati", stats["total_chunks"])
|
||||
|
||||
st.header("💬 Chat con i tuoi documenti")
|
||||
|
||||
if "messages" not in st.session_state:
|
||||
st.session_state.messages = []
|
||||
|
||||
for msg in st.session_state.messages:
|
||||
with st.chat_message(msg["role"]):
|
||||
st.write(msg["content"])
|
||||
if "sources" in msg:
|
||||
with st.expander("📚 Fonti"):
|
||||
for src in msg["sources"]:
|
||||
st.write(f"- **{src['source']}**" +
|
||||
(f" (pag. {src['page']})" if src['page'] else ""))
|
||||
|
||||
if prompt := st.chat_input("Fai una domanda..."):
|
||||
log.info(f"Chat prompt: {prompt[:50]}...")
|
||||
st.session_state.messages.append({"role": "user", "content": prompt})
|
||||
|
||||
with st.chat_message("user"):
|
||||
st.write(prompt)
|
||||
|
||||
with st.chat_message("assistant"):
|
||||
with st.spinner("Elaborazione in corso..."):
|
||||
result = agent.process(prompt, mode="chat")
|
||||
|
||||
st.write(result["answer"])
|
||||
|
||||
if result.get("sources"):
|
||||
with st.expander("📚 Fonti"):
|
||||
for src in result["sources"]:
|
||||
st.write(f"- **{src['source']}**" +
|
||||
(f" (pag. {src['page']})" if src['page'] else "") +
|
||||
f" - Score: {src['score']:.2f}")
|
||||
st.caption(src["excerpt"])
|
||||
|
||||
st.session_state.messages.append({
|
||||
"role": "assistant",
|
||||
"content": result["answer"],
|
||||
"sources": result.get("sources", [])
|
||||
})
|
||||
|
||||
|
||||
elif mode == "lettera":
|
||||
with st.sidebar:
|
||||
st.divider()
|
||||
st.header("📄 Documenti")
|
||||
|
||||
uploaded = st.file_uploader(
|
||||
"Carica documento",
|
||||
type=SUPPORTED_EXTENSIONS,
|
||||
accept_multiple_files=True
|
||||
)
|
||||
|
||||
if uploaded:
|
||||
for file in uploaded:
|
||||
if st.button(f"Indicizza: {file.name}", key=f"idx_{file.name}"):
|
||||
save_path = Path(settings.UPLOAD_DIR) / file.name
|
||||
save_path.write_bytes(file.getbuffer())
|
||||
|
||||
with st.spinner(f"Indicizzando {file.name}..."):
|
||||
try:
|
||||
result = rag.ingest_document(save_path)
|
||||
st.success(f"✅ {result['chunks_created']} chunks")
|
||||
except Exception as e:
|
||||
st.error(f"❌ {e}")
|
||||
|
||||
stats = rag.get_stats()
|
||||
st.metric("Chunks indicizzati", stats["total_chunks"])
|
||||
|
||||
st.header("✉️ Genera Lettera")
|
||||
|
||||
topic = st.text_area("Argomento della lettera", placeholder="Es: Richiesta di patrocinio per evento annuale...")
|
||||
context = st.text_area("Contesto aggiuntivo (opzionale)", placeholder="Dettagli specifici, destinatario, tono...")
|
||||
|
||||
col1, col2 = st.columns([1, 1])
|
||||
with col1:
|
||||
if st.button("✉️ Genera Lettera", type="primary", use_container_width=True):
|
||||
if not topic:
|
||||
st.warning("Inserisci un argomento")
|
||||
else:
|
||||
with st.spinner("Generazione in corso..."):
|
||||
result = agent.process(topic, mode="lettera", context=context)
|
||||
|
||||
st.session_state["last_letter"] = result["answer"]
|
||||
|
||||
with col2:
|
||||
if st.button("🗑️ Pulisci", use_container_width=True):
|
||||
if "last_letter" in st.session_state:
|
||||
del st.session_state["last_letter"]
|
||||
st.rerun()
|
||||
|
||||
if "last_letter" in st.session_state:
|
||||
st.divider()
|
||||
st.subheader("Lettera generata")
|
||||
st.text_area("", st.session_state["last_letter"], height=400, key="letter_output")
|
||||
|
||||
c1, c2 = st.columns(2)
|
||||
with c1:
|
||||
st.button("📋 Copia", on_click=lambda: st.write("Copiato!"))
|
||||
with c2:
|
||||
st.button("💾 Salva")
|
||||
|
||||
|
||||
elif mode == "progetto":
|
||||
with st.sidebar:
|
||||
st.divider()
|
||||
st.header("📄 Documenti")
|
||||
|
||||
uploaded = st.file_uploader(
|
||||
"Carica documento",
|
||||
type=SUPPORTED_EXTENSIONS,
|
||||
accept_multiple_files=True
|
||||
)
|
||||
|
||||
if uploaded:
|
||||
for file in uploaded:
|
||||
if st.button(f"Indicizza: {file.name}", key=f"idx_{file.name}"):
|
||||
save_path = Path(settings.UPLOAD_DIR) / file.name
|
||||
save_path.write_bytes(file.getbuffer())
|
||||
|
||||
with st.spinner(f"Indicizzando {file.name}..."):
|
||||
try:
|
||||
result = rag.ingest_document(save_path)
|
||||
st.success(f"✅ {result['chunks_created']} chunks")
|
||||
except Exception as e:
|
||||
st.error(f"❌ {e}")
|
||||
|
||||
stats = rag.get_stats()
|
||||
st.metric("Chunks indicizzati", stats["total_chunks"])
|
||||
|
||||
st.header("📋 Genera Proposta Progetto")
|
||||
|
||||
title = st.text_input("Titolo del progetto", placeholder="Es: Insieme senza confini")
|
||||
description = st.text_area("Descrizione", placeholder="Descrivi l'obiettivo, il target, le attività previste...")
|
||||
|
||||
col1, col2 = st.columns([1, 1])
|
||||
with col1:
|
||||
if st.button("📋 Genera Proposta", type="primary", use_container_width=True):
|
||||
if not title or not description:
|
||||
st.warning("Inserisci titolo e descrizione")
|
||||
else:
|
||||
with st.spinner("Generazione in corso..."):
|
||||
result = agent.process(description, mode="progetto", context=title)
|
||||
|
||||
st.session_state["last_project"] = result["answer"]
|
||||
|
||||
with col2:
|
||||
if st.button("🗑️ Pulisci", use_container_width=True):
|
||||
if "last_project" in st.session_state:
|
||||
del st.session_state["last_project"]
|
||||
st.rerun()
|
||||
|
||||
if "last_project" in st.session_state:
|
||||
st.divider()
|
||||
st.subheader("Proposta generata")
|
||||
st.text_area("", st.session_state["last_project"], height=500, key="project_output")
|
||||
|
||||
c1, c2, c3 = st.columns(3)
|
||||
with c1:
|
||||
st.button("📋 Copia")
|
||||
with c2:
|
||||
st.button("💾 Salva")
|
||||
with c3:
|
||||
st.button("📧 Email")
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from config import settings
|
||||
from logger import get_logger
|
||||
|
||||
log = get_logger("chunker")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Chunk:
|
||||
text: str
|
||||
index: int
|
||||
source: str
|
||||
page: Optional[int] = None
|
||||
sheet: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
|
||||
class TextChunker:
|
||||
def __init__(
|
||||
self,
|
||||
chunk_size: int = None,
|
||||
overlap: int = None
|
||||
):
|
||||
self.chunk_size = chunk_size or settings.CHUNK_SIZE
|
||||
self.overlap = overlap or settings.CHUNK_OVERLAP
|
||||
log.debug(f"TextChunker: size={self.chunk_size}, overlap={self.overlap}")
|
||||
|
||||
def chunk_text(
|
||||
self,
|
||||
text: str,
|
||||
source: str,
|
||||
page: Optional[int] = None,
|
||||
sheet: Optional[str] = None,
|
||||
metadata: Optional[dict] = None
|
||||
) -> list[Chunk]:
|
||||
words = text.split()
|
||||
log.debug(f"Chunking text: {len(words)} words, source={source}, page={page}, sheet={sheet}")
|
||||
|
||||
if len(words) <= self.chunk_size:
|
||||
log.debug(f"Text fits in single chunk")
|
||||
return [Chunk(
|
||||
text=text,
|
||||
index=0,
|
||||
source=source,
|
||||
page=page,
|
||||
sheet=sheet,
|
||||
metadata=metadata
|
||||
)]
|
||||
|
||||
chunks = []
|
||||
start = 0
|
||||
idx = 0
|
||||
|
||||
while start < len(words):
|
||||
end = start + self.chunk_size
|
||||
chunk_words = words[start:end]
|
||||
chunk_text = " ".join(chunk_words)
|
||||
|
||||
chunks.append(Chunk(
|
||||
text=chunk_text,
|
||||
index=idx,
|
||||
source=source,
|
||||
page=page,
|
||||
sheet=sheet,
|
||||
metadata=metadata
|
||||
))
|
||||
|
||||
log.debug(f"Chunk {idx}: {len(chunk_words)} words, pos {start}-{end}")
|
||||
start = end - self.overlap
|
||||
idx += 1
|
||||
|
||||
log.debug(f"Created {len(chunks)} chunks")
|
||||
return chunks
|
||||
|
||||
def chunk_document(self, doc: dict) -> list[Chunk]:
|
||||
log.info(f"Chunking document: {doc.get('source')}")
|
||||
all_chunks = []
|
||||
|
||||
if "pages" in doc:
|
||||
log.debug(f"Document has {len(doc['pages'])} pages/sheets")
|
||||
for page_data in doc["pages"]:
|
||||
page_chunks = self.chunk_text(
|
||||
text=page_data["text"],
|
||||
source=doc["source"],
|
||||
page=page_data.get("page"),
|
||||
sheet=page_data.get("sheet"),
|
||||
metadata={"type": doc["type"]}
|
||||
)
|
||||
all_chunks.extend(page_chunks)
|
||||
if page_data.get("page"):
|
||||
log.debug(f"Page {page_data['page']}: {len(page_chunks)} chunks")
|
||||
elif page_data.get("sheet"):
|
||||
log.debug(f"Sheet '{page_data['sheet']}': {len(page_chunks)} chunks")
|
||||
else:
|
||||
log.debug(f"Single page document")
|
||||
all_chunks = self.chunk_text(
|
||||
text=doc["full_text"],
|
||||
source=doc["source"],
|
||||
metadata={"type": doc["type"]}
|
||||
)
|
||||
|
||||
log.info(f"Document chunked: {len(all_chunks)} total chunks")
|
||||
return all_chunks
|
||||
|
||||
|
||||
def chunk_document(doc: dict) -> list[Chunk]:
|
||||
chunker = TextChunker()
|
||||
return chunker.chunk_document(doc)
|
||||
@@ -0,0 +1,42 @@
|
||||
from dotenv import load_dotenv
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
OLLAMA_HOST: str = "http://localhost:11434"
|
||||
OLLAMA_MODEL: str = "gemma2:9b"
|
||||
OLLAMA_EMBED_MODEL: str = "nomic-embed-text"
|
||||
|
||||
LLM_PROVIDER: str = "ollama"
|
||||
LLM_API_KEY: Optional[str] = None
|
||||
LLM_MODEL: Optional[str] = None
|
||||
LLM_BASE_URL: Optional[str] = None
|
||||
|
||||
QDRANT_PATH: str = "./data/qdrant"
|
||||
QDRANT_COLLECTION: str = "documents"
|
||||
|
||||
TESSERACT_LANG: str = "ita+eng"
|
||||
|
||||
CHUNK_SIZE: int = 512
|
||||
CHUNK_OVERLAP: int = 50
|
||||
|
||||
UPLOAD_DIR: str = "./data/uploads"
|
||||
MAX_FILE_SIZE_MB: int = 50
|
||||
|
||||
LOG_LEVEL: str = "DEBUG"
|
||||
|
||||
@property
|
||||
def log_level_int(self) -> int:
|
||||
return getattr(logging, self.LOG_LEVEL.upper(), logging.DEBUG)
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,190 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from document_processor import extract_text
|
||||
from chunker import chunk_document
|
||||
from vectorstore import vectorstore
|
||||
from ollama_client import ollama
|
||||
from config import settings
|
||||
from logger import get_logger
|
||||
|
||||
log = get_logger("core")
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """Sei un assistente AI per un'associazione di volontariato.
|
||||
Il tuo compito è aiutare a estrarre informazioni dai documenti e supportare la generazione di lettere, progetti e conversazioni.
|
||||
|
||||
Rispondi sempre in italiano, in modo chiaro e professionale.
|
||||
Quando usi informazioni dai documenti, cita sempre la fonte."""
|
||||
|
||||
|
||||
class RAGSystem:
|
||||
def __init__(self):
|
||||
self.upload_dir = Path(settings.UPLOAD_DIR)
|
||||
self.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
log.info(f"RAGSystem initialized: upload_dir={self.upload_dir}")
|
||||
|
||||
def ingest_document(self, file_path: Path | str) -> dict:
|
||||
path = Path(file_path)
|
||||
log.info(f"Ingesting document: {path.name}")
|
||||
log.debug(f"Full path: {path}")
|
||||
|
||||
log.debug("Step 1: OCR extraction")
|
||||
doc = extract_text(path)
|
||||
log.info(f"OCR complete: {doc['total_pages']} pages, {len(doc['text'])} chars")
|
||||
|
||||
log.debug("Step 2: Chunking")
|
||||
chunks = chunk_document(doc)
|
||||
log.info(f"Chunking complete: {len(chunks)} chunks")
|
||||
|
||||
log.debug("Step 3: Storing in vector DB")
|
||||
ids = vectorstore.add_chunks(chunks)
|
||||
log.info(f"Document ingested: {len(ids)} chunks stored")
|
||||
|
||||
return {
|
||||
"source": str(path),
|
||||
"total_pages": doc["total_pages"],
|
||||
"chunks_created": len(chunks),
|
||||
"chunk_ids": ids
|
||||
}
|
||||
|
||||
def query(
|
||||
self,
|
||||
question: str,
|
||||
top_k: int = 5,
|
||||
system: Optional[str] = None
|
||||
) -> dict:
|
||||
log.info(f"Query: '{question[:50]}...' (top_k={top_k})")
|
||||
|
||||
log.debug("Searching vector store")
|
||||
results = vectorstore.search(question, limit=top_k)
|
||||
|
||||
if not results:
|
||||
log.warning("No results found in vector store")
|
||||
return {
|
||||
"answer": "Non ho trovato informazioni rilevanti nei documenti.",
|
||||
"sources": []
|
||||
}
|
||||
|
||||
log.info(f"Found {len(results)} relevant chunks")
|
||||
|
||||
context = "\n\n---\n\n".join(
|
||||
f"[Fonte: {r['source']}" +
|
||||
(f", pagina {r['page']}" if r['page'] else "") +
|
||||
f"]\n{r['text']}"
|
||||
for r in results
|
||||
)
|
||||
log.debug(f"Context assembled: {len(context)} chars")
|
||||
|
||||
prompt = f"""Basandoti sul seguente contesto estratto dai documenti, rispondi alla domanda.
|
||||
|
||||
CONTESTO:
|
||||
{context}
|
||||
|
||||
DOMANDA: {question}
|
||||
|
||||
Rispondi in italiano, citando le fonti quando appropriato."""
|
||||
|
||||
log.debug("Generating answer")
|
||||
answer = ollama.generate(
|
||||
prompt=prompt,
|
||||
system=system or SYSTEM_PROMPT
|
||||
)
|
||||
log.info(f"Answer generated: {len(answer)} chars")
|
||||
|
||||
sources = [
|
||||
{
|
||||
"source": r["source"],
|
||||
"page": r["page"],
|
||||
"score": r["score"],
|
||||
"excerpt": r["text"][:200] + "..." if len(r["text"]) > 200 else r["text"]
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
|
||||
return {
|
||||
"answer": answer,
|
||||
"sources": sources
|
||||
}
|
||||
|
||||
def generate_letter(
|
||||
self,
|
||||
topic: str,
|
||||
context: Optional[str] = None
|
||||
) -> str:
|
||||
log.info(f"Generating letter: topic='{topic[:30]}...'")
|
||||
|
||||
log.debug("Searching relevant documents")
|
||||
results = vectorstore.search(topic, limit=5)
|
||||
|
||||
doc_context = ""
|
||||
if results:
|
||||
doc_context = "\n\n".join(r["text"] for r in results)
|
||||
log.debug(f"Found {len(results)} relevant chunks")
|
||||
|
||||
full_context = ""
|
||||
if doc_context:
|
||||
full_context += f"Documenti rilevanti:\n{doc_context}\n\n"
|
||||
if context:
|
||||
full_context += f"Contesto aggiuntivo:\n{context}\n\n"
|
||||
|
||||
prompt = f"""Genera una lettera formale per un'associazione di volontariato.
|
||||
|
||||
Argomento: {topic}
|
||||
|
||||
{full_context}
|
||||
|
||||
Scrivi una lettera professionale in italiano, con intestazione, corpo e chiusura appropriati."""
|
||||
|
||||
log.debug("Generating letter")
|
||||
letter = ollama.generate(prompt=prompt, system=SYSTEM_PROMPT)
|
||||
log.info(f"Letter generated: {len(letter)} chars")
|
||||
|
||||
return letter
|
||||
|
||||
def generate_project(
|
||||
self,
|
||||
title: str,
|
||||
description: str
|
||||
) -> str:
|
||||
log.info(f"Generating project: title='{title}'")
|
||||
|
||||
log.debug("Searching relevant documents")
|
||||
results = vectorstore.search(f"{title} {description}", limit=5)
|
||||
|
||||
doc_context = ""
|
||||
if results:
|
||||
doc_context = "\n\n".join(r["text"] for r in results)
|
||||
log.debug(f"Found {len(results)} relevant chunks")
|
||||
|
||||
prompt = f"""Genera una proposta di progetto per un'associazione di volontariato.
|
||||
|
||||
Titolo: {title}
|
||||
Descrizione: {description}
|
||||
|
||||
{f'Documenti rilevanti:\n{doc_context}' if doc_context else ''}
|
||||
|
||||
Scrivi una proposta di progetto completa con:
|
||||
1. Introduzione e obiettivi
|
||||
2. Attività previste
|
||||
3. Risorse necessarie
|
||||
4. Timeline
|
||||
5. Risultati attesi"""
|
||||
|
||||
log.debug("Generating project")
|
||||
project = ollama.generate(prompt=prompt, system=SYSTEM_PROMPT)
|
||||
log.info(f"Project generated: {len(project)} chars")
|
||||
|
||||
return project
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
count = vectorstore.count()
|
||||
log.debug(f"Stats: {count} chunks")
|
||||
return {
|
||||
"total_chunks": count,
|
||||
"upload_dir": str(self.upload_dir)
|
||||
}
|
||||
|
||||
|
||||
log.info("Initializing RAG System")
|
||||
rag = RAGSystem()
|
||||
@@ -0,0 +1,620 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
RAGGER Debug Utility - Health Check, Reset, Testing
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
PROJECT_DIR = Path(__file__).parent
|
||||
QDRANT_PATH = PROJECT_DIR / "data" / "qdrant"
|
||||
UPLOADS_PATH = PROJECT_DIR / "data" / "uploads"
|
||||
LOGS_PATH = PROJECT_DIR / "logs"
|
||||
|
||||
GREEN = "\033[92m"
|
||||
RED = "\033[91m"
|
||||
YELLOW = "\033[93m"
|
||||
CYAN = "\033[96m"
|
||||
BOLD = "\033[1m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
|
||||
def color(text: str, c: str) -> str:
|
||||
return f"{c}{text}{RESET}"
|
||||
|
||||
|
||||
def icon(success: bool) -> str:
|
||||
return color("✅", GREEN) if success else color("❌", RED)
|
||||
|
||||
|
||||
def run_cmd(cmd: str, timeout: int = 30) -> tuple[int, str, str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, shell=True, capture_output=True, text=True, timeout=timeout
|
||||
)
|
||||
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
||||
except subprocess.TimeoutExpired:
|
||||
return -1, "", "Timeout"
|
||||
|
||||
|
||||
def clear_screen():
|
||||
os.system("clear" if os.name == "posix" else "cls")
|
||||
|
||||
|
||||
def print_header(title: str):
|
||||
print()
|
||||
print(color("=" * 50, CYAN))
|
||||
print(color(f" {title}", BOLD))
|
||||
print(color("=" * 50, CYAN))
|
||||
print()
|
||||
|
||||
|
||||
def kill_streamlit():
|
||||
print(color("\n🔴 Killing Streamlit processes...", YELLOW))
|
||||
run_cmd("pkill -f 'streamlit run' 2>/dev/null || true")
|
||||
run_cmd("pkill -f 'uvicorn' 2>/dev/null || true")
|
||||
print(color("✅ Done", GREEN))
|
||||
|
||||
|
||||
def clean_qdrant_locks():
|
||||
print(color("\n🔴 Cleaning Qdrant locks...", YELLOW))
|
||||
if QDRANT_PATH.exists():
|
||||
lock_files = list(QDRANT_PATH.glob("*.lock")) + list(QDRANT_PATH.glob("*.flock"))
|
||||
for f in lock_files:
|
||||
try:
|
||||
f.unlink()
|
||||
print(f" Removed: {f.name}")
|
||||
except Exception as e:
|
||||
print(f" Error removing {f.name}: {e}")
|
||||
print(color("✅ Done", GREEN))
|
||||
|
||||
|
||||
def reset_database():
|
||||
print(color("\n🔴 RESET DATABASE", RED + BOLD))
|
||||
print(color("This will DELETE ALL vector embeddings!", YELLOW))
|
||||
|
||||
confirm = input(color("\nType 'yes' to confirm: ", YELLOW)).strip().lower()
|
||||
if confirm != "yes":
|
||||
print(color("Cancelled.", YELLOW))
|
||||
return False
|
||||
|
||||
kill_streamlit()
|
||||
|
||||
print(color("\n📁 Removing Qdrant data...", YELLOW))
|
||||
if QDRANT_PATH.exists():
|
||||
shutil.rmtree(QDRANT_PATH)
|
||||
print(color(" ✅ Qdrant directory removed", GREEN))
|
||||
else:
|
||||
print(color(" ⚠️ Qdrant directory not found", YELLOW))
|
||||
|
||||
print(color("\n✅ Database reset complete!", GREEN))
|
||||
return True
|
||||
|
||||
|
||||
def reset_uploads():
|
||||
print(color("\n🔴 RESET UPLOADS", RED + BOLD))
|
||||
print(color("This will DELETE ALL uploaded documents!", YELLOW))
|
||||
|
||||
files = list(UPLOADS_PATH.glob("*")) if UPLOADS_PATH.exists() else []
|
||||
if not files:
|
||||
print(color("No files to delete.", YELLOW))
|
||||
return True
|
||||
|
||||
print(f"\nFiles to delete ({len(files)}):")
|
||||
for f in files[:10]:
|
||||
print(f" - {f.name}")
|
||||
if len(files) > 10:
|
||||
print(f" ... and {len(files) - 10} more")
|
||||
|
||||
confirm = input(color("\nType 'yes' to confirm: ", YELLOW)).strip().lower()
|
||||
if confirm != "yes":
|
||||
print(color("Cancelled.", YELLOW))
|
||||
return False
|
||||
|
||||
print(color("\n📁 Removing uploaded files...", YELLOW))
|
||||
for f in files:
|
||||
try:
|
||||
if f.is_file():
|
||||
f.unlink()
|
||||
elif f.is_dir():
|
||||
shutil.rmtree(f)
|
||||
except Exception as e:
|
||||
print(f" Error: {f.name}: {e}")
|
||||
|
||||
print(color("\n✅ Uploads reset complete!", GREEN))
|
||||
return True
|
||||
|
||||
|
||||
def reset_all():
|
||||
print(color("\n🔴🔴🔴 FULL RESET 🔴🔴🔴", RED + BOLD))
|
||||
print(color("This will DELETE EVERYTHING:", YELLOW))
|
||||
print(" - All vector embeddings (Qdrant)")
|
||||
print(" - All uploaded documents")
|
||||
print(" - All logs")
|
||||
|
||||
confirm = input(color("\nType 'DESTROY' to confirm: ", RED + BOLD)).strip()
|
||||
if confirm != "DESTROY":
|
||||
print(color("Cancelled.", YELLOW))
|
||||
return
|
||||
|
||||
kill_streamlit()
|
||||
|
||||
print(color("\n📁 Removing all data...", YELLOW))
|
||||
|
||||
if QDRANT_PATH.exists():
|
||||
shutil.rmtree(QDRANT_PATH)
|
||||
print(color(" ✅ Qdrant removed", GREEN))
|
||||
|
||||
if UPLOADS_PATH.exists():
|
||||
shutil.rmtree(UPLOADS_PATH)
|
||||
UPLOADS_PATH.mkdir(parents=True)
|
||||
print(color(" ✅ Uploads cleared", GREEN))
|
||||
|
||||
if LOGS_PATH.exists():
|
||||
shutil.rmtree(LOGS_PATH)
|
||||
print(color(" ✅ Logs removed", GREEN))
|
||||
|
||||
print(color("\n✅ FULL RESET COMPLETE!", GREEN))
|
||||
|
||||
|
||||
def health_check() -> dict:
|
||||
"""Run health checks on all components."""
|
||||
results = {}
|
||||
|
||||
print(color("\n🔍 RAGGER Health Check", BOLD + CYAN))
|
||||
print(color("=" * 50, CYAN))
|
||||
|
||||
print(f"\n[1/7] Tesseract OCR...", end=" ", flush=True)
|
||||
code, out, err = run_cmd("tesseract --version 2>&1")
|
||||
if code == 0 and "tesseract" in out.lower():
|
||||
version = out.split("\n")[0].split()[-1] if out else "unknown"
|
||||
print(icon(True) + f" v{version}")
|
||||
results["tesseract"] = {"ok": True, "version": version}
|
||||
else:
|
||||
print(icon(False) + " NOT INSTALLED")
|
||||
results["tesseract"] = {"ok": False, "error": "not installed"}
|
||||
|
||||
print(f"[2/7] Tesseract Languages...", end=" ", flush=True)
|
||||
code, out, err = run_cmd("tesseract --list-langs 2>&1")
|
||||
if code == 0:
|
||||
langs = [l.strip() for l in out.split("\n")[1:] if l.strip() and l.strip() != "osd"]
|
||||
has_ita = "ita" in langs
|
||||
has_eng = "eng" in langs
|
||||
if has_ita and has_eng:
|
||||
print(icon(True) + f" ita, eng (+{len(langs)-3} others)")
|
||||
results["tesseract_langs"] = {"ok": True, "langs": langs}
|
||||
else:
|
||||
print(icon(False) + f" missing: " +
|
||||
("ita " if not has_ita else "") +
|
||||
("eng" if not has_eng else ""))
|
||||
results["tesseract_langs"] = {"ok": False, "missing":
|
||||
[("ita" if not has_ita else None), ("eng" if not has_eng else None)]}
|
||||
else:
|
||||
print(icon(False))
|
||||
results["tesseract_langs"] = {"ok": False}
|
||||
|
||||
print(f"[3/7] Poppler (pdf2image)...", end=" ", flush=True)
|
||||
code, out, err = run_cmd("pdftoppm -v 2>&1")
|
||||
if code == 0 or "pdftoppm" in out or "poppler" in out.lower():
|
||||
print(icon(True))
|
||||
results["poppler"] = {"ok": True}
|
||||
else:
|
||||
print(icon(False) + " NOT INSTALLED")
|
||||
results["poppler"] = {"ok": False}
|
||||
|
||||
print(f"[4/7] Ollama Connection...", end=" ", flush=True)
|
||||
sys.path.insert(0, str(PROJECT_DIR))
|
||||
try:
|
||||
from config import settings
|
||||
from ollama_client import ollama
|
||||
|
||||
start = time.time()
|
||||
available = ollama.is_available()
|
||||
elapsed = time.time() - start
|
||||
|
||||
if available:
|
||||
print(icon(True) + f" {settings.OLLAMA_HOST} ({elapsed:.1f}s)")
|
||||
results["ollama_connection"] = {"ok": True, "host": settings.OLLAMA_HOST}
|
||||
else:
|
||||
print(icon(False) + f" {settings.OLLAMA_HOST}")
|
||||
results["ollama_connection"] = {"ok": False, "host": settings.OLLAMA_HOST}
|
||||
except Exception as e:
|
||||
print(icon(False) + f" {e}")
|
||||
results["ollama_connection"] = {"ok": False, "error": str(e)}
|
||||
|
||||
print(f"[5/7] Embedding Model...", end=" ", flush=True)
|
||||
if results.get("ollama_connection", {}).get("ok"):
|
||||
try:
|
||||
from config import settings
|
||||
from ollama_client import ollama
|
||||
|
||||
start = time.time()
|
||||
embedding = ollama.embed("test")
|
||||
elapsed = time.time() - start
|
||||
dims = len(embedding)
|
||||
|
||||
print(icon(True) + f" {settings.OLLAMA_EMBED_MODEL} ({dims} dims, {elapsed:.1f}s)")
|
||||
results["embedding_model"] = {"ok": True, "dims": dims, "time": elapsed}
|
||||
except Exception as e:
|
||||
print(icon(False) + f" {e}")
|
||||
results["embedding_model"] = {"ok": False, "error": str(e)}
|
||||
else:
|
||||
print(icon(False) + " skipped (no connection)")
|
||||
results["embedding_model"] = {"ok": False, "error": "skipped"}
|
||||
|
||||
print(f"[6/7] Qdrant Vector DB...", end=" ", flush=True)
|
||||
try:
|
||||
from vectorstore import vectorstore
|
||||
|
||||
count = vectorstore.count()
|
||||
print(icon(True) + f" {count} chunks stored")
|
||||
results["qdrant"] = {"ok": True, "chunks": count}
|
||||
except Exception as e:
|
||||
print(icon(False) + f" {e}")
|
||||
results["qdrant"] = {"ok": False, "error": str(e)}
|
||||
|
||||
print(f"[7/7] Uploads Directory...", end=" ", flush=True)
|
||||
try:
|
||||
if UPLOADS_PATH.exists():
|
||||
files = list(UPLOADS_PATH.glob("*"))
|
||||
total_size = sum(f.stat().st_size for f in files if f.is_file())
|
||||
size_mb = total_size / (1024 * 1024)
|
||||
print(icon(True) + f" {len(files)} files ({size_mb:.1f} MB)")
|
||||
results["uploads"] = {"ok": True, "files": len(files), "size_mb": size_mb}
|
||||
else:
|
||||
print(icon(False) + " not found")
|
||||
results["uploads"] = {"ok": False}
|
||||
except Exception as e:
|
||||
print(icon(False) + f" {e}")
|
||||
results["uploads"] = {"ok": False, "error": str(e)}
|
||||
|
||||
ok_count = sum(1 for v in results.values() if v.get("ok"))
|
||||
total = len(results)
|
||||
|
||||
print(color("\n" + "=" * 50, CYAN))
|
||||
status = color(f"STATUS: {ok_count}/{total} OK", GREEN if ok_count == total else YELLOW)
|
||||
print(f"{status}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def show_logs():
|
||||
print_header("LOGS")
|
||||
|
||||
log_file = LOGS_PATH / "ragger.log"
|
||||
if not log_file.exists():
|
||||
print(color("No logs found.", YELLOW))
|
||||
return
|
||||
|
||||
print(color(f"Showing last 50 lines of {log_file}:\n", CYAN))
|
||||
code, out, _ = run_cmd(f"tail -50 {log_file}")
|
||||
print(out)
|
||||
|
||||
|
||||
def show_status():
|
||||
print_header("STATUS")
|
||||
|
||||
print("Streamlit processes:")
|
||||
code, out, _ = run_cmd("pgrep -af 'streamlit' 2>/dev/null || echo ' None'")
|
||||
print(out or " None")
|
||||
|
||||
print("\nPython processes in project:")
|
||||
code, out, _ = run_cmd(f"pgrep -af 'python.*{PROJECT_DIR.name}' 2>/dev/null || echo ' None'")
|
||||
print(out or " None")
|
||||
|
||||
print("\nQdrant files:")
|
||||
if QDRANT_PATH.exists():
|
||||
files = list(QDRANT_PATH.glob("*"))
|
||||
for f in files[:10]:
|
||||
print(f" {f.name}")
|
||||
if len(files) > 10:
|
||||
print(f" ... and {len(files) - 10} more")
|
||||
else:
|
||||
print(" Directory not found")
|
||||
|
||||
print("\nUploads:")
|
||||
if UPLOADS_PATH.exists():
|
||||
files = list(UPLOADS_PATH.glob("*"))
|
||||
print(f" {len(files)} files")
|
||||
for f in files[:5]:
|
||||
size = f.stat().st_size / 1024
|
||||
print(f" - {f.name} ({size:.1f} KB)")
|
||||
if len(files) > 5:
|
||||
print(f" ... and {len(files) - 5} more")
|
||||
else:
|
||||
print(" Directory not found")
|
||||
|
||||
|
||||
def test_embedding():
|
||||
print_header("TEST EMBEDDING")
|
||||
|
||||
text = input("Enter text to embed (or press Enter for default): ").strip()
|
||||
if not text:
|
||||
text = "Questo è un test di embedding per RAGGER."
|
||||
|
||||
print(f"\nText: {color(text, CYAN)}")
|
||||
print("\nGenerating embedding...", end=" ", flush=True)
|
||||
|
||||
try:
|
||||
sys.path.insert(0, str(PROJECT_DIR))
|
||||
from ollama_client import ollama
|
||||
|
||||
start = time.time()
|
||||
embedding = ollama.embed(text)
|
||||
elapsed = time.time() - start
|
||||
|
||||
print(color(f"DONE ({elapsed:.2f}s)", GREEN))
|
||||
print(f"\nDimensions: {len(embedding)}")
|
||||
print(f"First 5 values: {embedding[:5]}")
|
||||
print(f"Last 5 values: {embedding[-5:]}")
|
||||
print(f"Min: {min(embedding):.4f}, Max: {max(embedding):.4f}")
|
||||
|
||||
except Exception as e:
|
||||
print(color(f"\nERROR: {e}", RED))
|
||||
|
||||
|
||||
def test_generation():
|
||||
print_header("TEST GENERATION")
|
||||
|
||||
prompt = input("Enter prompt (or press Enter for default): ").strip()
|
||||
if not prompt:
|
||||
prompt = "Rispondi brevemente: qual è la capitale d'Italia?"
|
||||
|
||||
print(f"\nPrompt: {color(prompt, CYAN)}")
|
||||
print("\nGenerating response...", end=" ", flush=True)
|
||||
|
||||
try:
|
||||
sys.path.insert(0, str(PROJECT_DIR))
|
||||
from ollama_client import ollama
|
||||
from config import settings
|
||||
|
||||
start = time.time()
|
||||
response = ollama.generate(prompt)
|
||||
elapsed = time.time() - start
|
||||
|
||||
print(color(f"DONE ({elapsed:.2f}s)", GREEN))
|
||||
print(f"\n{color('Response:', BOLD)}")
|
||||
print(response)
|
||||
|
||||
except Exception as e:
|
||||
print(color(f"\nERROR: {e}", RED))
|
||||
|
||||
|
||||
def test_qdrant():
|
||||
print_header("TEST QDRANT")
|
||||
|
||||
try:
|
||||
sys.path.insert(0, str(PROJECT_DIR))
|
||||
from vectorstore import vectorstore
|
||||
from chunker import Chunk
|
||||
|
||||
print(f"Collection: {vectorstore.collection}")
|
||||
print(f"Chunks stored: {vectorstore.count()}")
|
||||
|
||||
action = input("\nActions: [1] Add test chunk [2] Search [3] List all\n> ").strip()
|
||||
|
||||
if action == "1":
|
||||
chunk = Chunk(
|
||||
text="Questo è un chunk di test per verificare il funzionamento di Qdrant.",
|
||||
source="test.py",
|
||||
page=1,
|
||||
index=0
|
||||
)
|
||||
print("\nAdding test chunk...")
|
||||
chunk_id = vectorstore.add_chunk(chunk)
|
||||
print(color(f"✅ Added chunk: {chunk_id}", GREEN))
|
||||
print(f"Total chunks now: {vectorstore.count()}")
|
||||
|
||||
elif action == "2":
|
||||
query = input("Enter search query: ").strip()
|
||||
if not query:
|
||||
query = "test"
|
||||
|
||||
print(f"\nSearching for: {color(query, CYAN)}")
|
||||
results = vectorstore.search(query, limit=5)
|
||||
|
||||
if results:
|
||||
for i, r in enumerate(results, 1):
|
||||
print(f"\n[{i}] Score: {r['score']:.4f}")
|
||||
print(f" Source: {r['source']}")
|
||||
print(f" Text: {r['text'][:100]}...")
|
||||
else:
|
||||
print(color("No results found.", YELLOW))
|
||||
|
||||
elif action == "3":
|
||||
from qdrant_client.models import Filter
|
||||
|
||||
results = vectorstore.client.scroll(
|
||||
collection_name=vectorstore.collection,
|
||||
limit=20
|
||||
)
|
||||
|
||||
for i, point in enumerate(results[0], 1):
|
||||
print(f"\n[{i}] ID: {point.id}")
|
||||
payload = point.payload or {}
|
||||
print(f" Source: {payload.get('source', 'unknown')}")
|
||||
print(f" Text: {payload.get('text', '')[:80]}...")
|
||||
|
||||
except Exception as e:
|
||||
print(color(f"\nERROR: {e}", RED))
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def ingest_testdoc():
|
||||
print_header("INGEST TEST DOCUMENTS")
|
||||
|
||||
testdoc_path = PROJECT_DIR / "testdoc"
|
||||
if not testdoc_path.exists():
|
||||
print(color("testdoc/ directory not found!", RED))
|
||||
return
|
||||
|
||||
files = list(testdoc_path.iterdir())
|
||||
if not files:
|
||||
print(color("No files in testdoc/", YELLOW))
|
||||
return
|
||||
|
||||
print(f"Found {len(files)} files in testdoc/:")
|
||||
for f in files:
|
||||
print(f" - {f.name}")
|
||||
|
||||
confirm = input(color("\nIngest all? [y/N]: ", YELLOW)).strip().lower()
|
||||
if confirm != 'y':
|
||||
print("Cancelled.")
|
||||
return
|
||||
|
||||
sys.path.insert(0, str(PROJECT_DIR))
|
||||
from core import rag
|
||||
|
||||
success = 0
|
||||
failed = 0
|
||||
|
||||
for f in sorted(files):
|
||||
if f.is_file():
|
||||
print(f"\nProcessing: {color(f.name, CYAN)}")
|
||||
try:
|
||||
result = rag.ingest_document(f)
|
||||
print(color(f" ✅ {result['chunks_created']} chunks from {result['total_pages']} pages", GREEN))
|
||||
success += 1
|
||||
except Exception as e:
|
||||
print(color(f" ❌ ERROR: {e}", RED))
|
||||
failed += 1
|
||||
|
||||
print(color(f"\n{'='*40}", CYAN))
|
||||
print(f"Results: {color(f'{success} success', GREEN)}, {color(f'{failed} failed', RED if failed else GREEN)}")
|
||||
print(f"Total chunks: {rag.get_stats()['total_chunks']}")
|
||||
|
||||
|
||||
def restart_app():
|
||||
print(color("\n🔄 Restarting RAGGER...", CYAN))
|
||||
kill_streamlit()
|
||||
clean_qdrant_locks()
|
||||
print("\nStarting Streamlit...")
|
||||
os.chdir(PROJECT_DIR)
|
||||
os.system("source venv/bin/activate && streamlit run app.py")
|
||||
|
||||
|
||||
def main():
|
||||
clear_screen()
|
||||
|
||||
print(color("=" * 50, CYAN))
|
||||
print(color(" 🔧 RAGGER Debug Utility", BOLD))
|
||||
print(color("=" * 50, CYAN))
|
||||
|
||||
while True:
|
||||
print("\n" + color("MENU", BOLD))
|
||||
print("-" * 30)
|
||||
print(" 1. Health Check")
|
||||
print(" 2. Show Status")
|
||||
print(" 3. Show Logs")
|
||||
print("-" * 30)
|
||||
print(" 4. Test Embedding")
|
||||
print(" 5. Test Generation")
|
||||
print(" 6. Test Qdrant")
|
||||
print(" 7. Ingest testdoc/")
|
||||
print("-" * 30)
|
||||
print(" 8. Reset Database")
|
||||
print(" 9. Reset Uploads")
|
||||
print(" 0. Full Reset (DESTROY ALL)")
|
||||
print("-" * 30)
|
||||
print(" k. Kill Streamlit")
|
||||
print(" c. Clean Locks")
|
||||
print(" r. Restart App")
|
||||
print(" q. Quit")
|
||||
print("-" * 30)
|
||||
|
||||
choice = input("\n> ").strip().lower()
|
||||
|
||||
if choice == "1":
|
||||
health_check()
|
||||
elif choice == "2":
|
||||
show_status()
|
||||
elif choice == "3":
|
||||
show_logs()
|
||||
elif choice == "4":
|
||||
test_embedding()
|
||||
elif choice == "5":
|
||||
test_generation()
|
||||
elif choice == "6":
|
||||
test_qdrant()
|
||||
elif choice == "7":
|
||||
ingest_testdoc()
|
||||
elif choice == "8":
|
||||
reset_database()
|
||||
elif choice == "9":
|
||||
reset_uploads()
|
||||
elif choice == "0":
|
||||
reset_all()
|
||||
elif choice == "k":
|
||||
kill_streamlit()
|
||||
elif choice == "c":
|
||||
clean_qdrant_locks()
|
||||
elif choice == "r":
|
||||
restart_app()
|
||||
break
|
||||
elif choice == "q":
|
||||
print(color("\n👋 Bye!", CYAN))
|
||||
break
|
||||
else:
|
||||
print(color("Invalid option", RED))
|
||||
|
||||
input(color("\nPress Enter to continue...", YELLOW))
|
||||
clear_screen()
|
||||
print(color("=" * 50, CYAN))
|
||||
print(color(" 🔧 RAGGER Debug Utility", BOLD))
|
||||
print(color("=" * 50, CYAN))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1:
|
||||
arg = sys.argv[1]
|
||||
|
||||
if arg == "status":
|
||||
show_status()
|
||||
elif arg == "health":
|
||||
health_check()
|
||||
elif arg == "kill":
|
||||
kill_streamlit()
|
||||
elif arg == "clean":
|
||||
clean_qdrant_locks()
|
||||
elif arg == "reset-db":
|
||||
reset_database()
|
||||
elif arg == "reset-uploads":
|
||||
reset_uploads()
|
||||
elif arg == "reset-all":
|
||||
reset_all()
|
||||
elif arg == "logs":
|
||||
show_logs()
|
||||
elif arg == "restart":
|
||||
restart_app()
|
||||
elif arg == "test-embed":
|
||||
test_embedding()
|
||||
elif arg == "test-gen":
|
||||
test_generation()
|
||||
elif arg == "test-qdrant":
|
||||
test_qdrant()
|
||||
elif arg == "ingest-testdoc":
|
||||
ingest_testdoc()
|
||||
else:
|
||||
print(f"Unknown command: {arg}")
|
||||
print("\nCommands:")
|
||||
print(" status Show process status")
|
||||
print(" health Run health check")
|
||||
print(" kill Kill Streamlit processes")
|
||||
print(" clean Clean Qdrant locks")
|
||||
print(" reset-db Reset database (vectors)")
|
||||
print(" reset-uploads Reset uploads (files)")
|
||||
print(" reset-all Full reset (DESTROY ALL)")
|
||||
print(" logs Show logs")
|
||||
print(" restart Restart app")
|
||||
print(" test-embed Test embedding")
|
||||
print(" test-gen Test generation")
|
||||
print(" test-qdrant Test Qdrant")
|
||||
print(" ingest-testdoc Ingest all files from testdoc/")
|
||||
else:
|
||||
main()
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
# RAGGER - Design Document
|
||||
|
||||
**Sistema RAG per Associazione di Volontariato**
|
||||
|
||||
---
|
||||
|
||||
## 1. Contesto
|
||||
|
||||
### 1.1 Obiettivo
|
||||
Sistema AI per estrarre informazioni dai documenti dell'associazione e supportare:
|
||||
- Generazione lettere e progetti
|
||||
- Chatbot conversazionale
|
||||
- Automazione documentale
|
||||
|
||||
### 1.2 Vincoli
|
||||
- **Singolo utente**
|
||||
- **100-500 documenti** stimati
|
||||
- **Proxmox in LAN** con server Ollama esistente (https://ollama.solarpi.net)
|
||||
- **Solo CPU** (no GPU)
|
||||
- **On-premise** con fallback API opzionale
|
||||
|
||||
### 1.3 Documenti Supportati
|
||||
- PDF scansionati (OCR)
|
||||
- PDF digital-born
|
||||
- Documenti con tabelle/form
|
||||
- DOCX, XLSX, immagini
|
||||
|
||||
---
|
||||
|
||||
## 2. Architettura
|
||||
|
||||
### 2.1 Diagramma
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ RAGGER │
|
||||
│ Single Python App │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────────────────────┐ │
|
||||
│ │ Streamlit │───▶│ Core Logic │ │
|
||||
│ │ UI │ │ - upload & OCR │ │
|
||||
│ └─────────────┘ │ - chunking │ │
|
||||
│ │ - RAG retrieval │ │
|
||||
│ │ - chat generation │ │
|
||||
│ └──────────┬──────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────┴───────────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌──────┐│
|
||||
│ │ Tesseract │ │ Qdrant │ │.env ││
|
||||
│ │ OCR │ │ (local) │ │config││
|
||||
│ └───────────┘ └─────┬─────┘ └──────┘│
|
||||
│ │ │
|
||||
└────────────────────────────────┼───────────────────────┘
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
▼ ▼
|
||||
┌────────────────┐ ┌────────────────┐
|
||||
│ OLLAMA SERVER │ │ API ESTERNA │
|
||||
│ (solarpi.net) │ │ (fallback) │
|
||||
│ │ │ │
|
||||
│ gemma2:9b │ │ openai/claude │
|
||||
│ nomic-embed │ │ │
|
||||
└────────────────┘ └────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 Principi
|
||||
- **Un comando**: `streamlit run app.py`
|
||||
- **Tutto via Ollama**: embedding e generazione
|
||||
- **Fallback opzionale**: API esterna se Ollama down
|
||||
- **Zero modelli Python locali**: tutto remoto via HTTP
|
||||
|
||||
---
|
||||
|
||||
## 3. Struttura Progetto
|
||||
|
||||
```
|
||||
ragger/
|
||||
├── app.py # Streamlit UI + logica principale
|
||||
├── core.py # Classi RAG, chunking, retrieval
|
||||
├── ocr.py # Tesseract wrapper
|
||||
├── config.py # Settings da .env
|
||||
├── requirements.txt # Dipendenze minime
|
||||
├── .env.example # Template configurazione
|
||||
├── data/
|
||||
│ ├── uploads/ # Documenti caricati
|
||||
│ └── qdrant/ # Vector DB persistente
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### 3.1 Dipendenze (requirements.txt)
|
||||
```
|
||||
streamlit
|
||||
qdrant-client
|
||||
pytesseract
|
||||
pdf2image
|
||||
Pillow
|
||||
httpx
|
||||
python-dotenv
|
||||
```
|
||||
|
||||
### 3.2 Niente
|
||||
- FastAPI (semplificato con Streamlit)
|
||||
- sentence-transformers (usa Ollama)
|
||||
- langchain (overkill per singolo utente)
|
||||
|
||||
---
|
||||
|
||||
## 4. Configurazione
|
||||
|
||||
### 4.1 File .env
|
||||
|
||||
```env
|
||||
# === OLLAMA SERVER ===
|
||||
OLLAMA_HOST=https://ollama.solarpi.net
|
||||
OLLAMA_MODEL=gemma2:9b
|
||||
OLLAMA_EMBED_MODEL=nomic-embed-text
|
||||
|
||||
# === API ESTERNA (Fallback opzionale) ===
|
||||
LLM_FALLBACK_PROVIDER= # openai | anthropic | vuoto = disabilitato
|
||||
LLM_FALLBACK_API_KEY=
|
||||
LLM_FALLBACK_MODEL=
|
||||
|
||||
# === QDRANT ===
|
||||
QDRANT_PATH=./data/qdrant
|
||||
QDRANT_COLLECTION=documents
|
||||
|
||||
# === OCR ===
|
||||
TESSERACT_LANG=ita+eng
|
||||
|
||||
# === CHUNKING ===
|
||||
CHUNK_SIZE=512
|
||||
CHUNK_OVERLAP=50
|
||||
|
||||
# === APP ===
|
||||
UPLOAD_DIR=./data/uploads
|
||||
MAX_FILE_SIZE_MB=50
|
||||
```
|
||||
|
||||
### 4.2 Modelli Ollama Richiesti
|
||||
|
||||
Sul server `ollama.solarpi.net`:
|
||||
|
||||
```bash
|
||||
ollama pull gemma2:9b # Generazione testo
|
||||
ollama pull nomic-embed-text # Embedding multilingue
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Flussi Dati
|
||||
|
||||
### 5.1 Ingestione Documento
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ INGESTIONE DOCUMENTO │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Upload PDF/Img ──▶ Tesseract OCR ──▶ Testo estratto │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Chunking (512 token, overlap) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Ollama Embed (nomic-embed-text) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Qdrant Store + Metadata │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Metadata salvati per chunk:**
|
||||
- `source_file`: nome file originale
|
||||
- `chunk_index`: posizione nel documento
|
||||
- `page_number`: numero pagina (se PDF)
|
||||
- `created_at`: timestamp
|
||||
- `doc_type`: tipo documento
|
||||
|
||||
### 5.2 Query / Chat
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ QUERY / CHAT │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Domanda utente ──▶ Ollama Embed (nomic-embed-text) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Qdrant Search (top-k chunks) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Context Assembly │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Ollama Generate (gemma2:9b) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Risposta + Fonti │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.3 Fallback LLM
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ TENTATIVO OLLAMA │
|
||||
│ │ │
|
||||
│ ┌───────┴───────┐ │
|
||||
│ ▼ ▼ │
|
||||
│ SUCCESSO FALLIMENTO │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ Risposta API_FALLBACK? │
|
||||
│ │ │
|
||||
│ ┌───────┴───────┐ │
|
||||
│ ▼ ▼ │
|
||||
│ SI NO │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ API Esterna Errore │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Deployment
|
||||
|
||||
### 6.1 Container Proxmox
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ PROXMOX LXC CONTAINER │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ OS: Debian 12 / Ubuntu 22.04 │
|
||||
│ Python: 3.11+ │
|
||||
│ Packages: tesseract-ocr, poppler-utils │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ ragger/ │ │
|
||||
│ │ ├── app.py │ │
|
||||
│ │ ├── core.py │ │
|
||||
│ │ ├── .env │ │
|
||||
│ │ └── data/ │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Avvio: streamlit run app.py --server.port 8501 │
|
||||
│ Accesso: http://<container-ip>:8501 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 Comandi Setup
|
||||
|
||||
```bash
|
||||
# Installazione dipendenze sistema
|
||||
apt update
|
||||
apt install -y python3-pip tesseract-ocr tesseract-ocr-ita poppler-utils
|
||||
|
||||
# Clone progetto
|
||||
git clone <repo-url> /opt/ragger
|
||||
cd /opt/ragger
|
||||
|
||||
# Installazione Python
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Configurazione
|
||||
cp .env.example .env
|
||||
# Editare .env con i propri valori
|
||||
|
||||
# Avvio
|
||||
streamlit run app.py --server.port 8501
|
||||
```
|
||||
|
||||
### 6.3 Servizio Systemd (opzionale)
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/ragger.service
|
||||
[Unit]
|
||||
Description=RAGGER - RAG System
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ragger
|
||||
WorkingDirectory=/opt/ragger
|
||||
ExecStart=/usr/local/bin/streamlit run app.py --server.port 8501
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
systemctl enable ragger
|
||||
systemctl start ragger
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Roadmap Implementazione
|
||||
|
||||
### Fase 1: Core (MVP)
|
||||
- [ ] Config e .env
|
||||
- [ ] OCR con Tesseract
|
||||
- [ ] Chunking testo
|
||||
- [ ] Ollama embedding client
|
||||
- [ ] Qdrant storage
|
||||
- [ ] Ricerca semantica base
|
||||
- [ ] Generazione risposta
|
||||
|
||||
### Fase 2: UI Streamlit
|
||||
- [ ] Upload documenti
|
||||
- [ ] Lista documenti indicizzati
|
||||
- [ ] Chat interface
|
||||
- [ ] Visualizzazione fonti
|
||||
|
||||
### Fase 3: Miglioramenti
|
||||
- [ ] Fallback API esterna
|
||||
- [ ] Gestione errori robusta
|
||||
- [ ] Export conversazioni
|
||||
- [ ] Statistiche uso
|
||||
|
||||
---
|
||||
|
||||
## 8. Decisioni Prese
|
||||
|
||||
| Decisione | Scelta | Motivo |
|
||||
|-----------|--------|--------|
|
||||
| Architettura | Monolitica | Singolo utente, semplicità |
|
||||
| LLM | Ollama remoto | Già esistente, on-premise |
|
||||
| Modello generazione | gemma2:9b | Qualità italiana, capace |
|
||||
| Modello embedding | nomic-embed-text | Multilingue, testato |
|
||||
| Vector DB | Qdrant | Veloce, Docker-friendly |
|
||||
| OCR | Tesseract | On-premise, gratuito |
|
||||
| UI | Streamlit | Rapido, singolo file |
|
||||
| Fallback | API esterna opzionale | Affidabilità |
|
||||
|
||||
---
|
||||
|
||||
## 9. Riferimenti
|
||||
|
||||
- Ollama: https://ollama.com
|
||||
- Qdrant: https://qdrant.tech
|
||||
- Tesseract: https://github.com/tesseract-ocr/tesseract
|
||||
- Streamlit: https://streamlit.io
|
||||
- nomic-embed-text: https://ollama.com/library/nomic-embed-text
|
||||
- gemma2: https://ollama.com/library/gemma2
|
||||
|
||||
---
|
||||
|
||||
*Design completato: 2025-02-21*
|
||||
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
Document Processor - Estrae testo da vari formati di documento.
|
||||
Supporta: PDF, Immagini, XLSX, DOCX, PPTX, TXT, MD
|
||||
"""
|
||||
|
||||
import pytesseract
|
||||
from pdf2image import convert_from_path
|
||||
from PIL import Image
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import tempfile
|
||||
|
||||
from config import settings
|
||||
from logger import get_logger
|
||||
|
||||
log = get_logger("document_processor")
|
||||
|
||||
|
||||
class DocumentProcessor:
|
||||
SUPPORTED_EXTENSIONS = {
|
||||
".pdf": "pdf",
|
||||
".png": "image",
|
||||
".jpg": "image",
|
||||
".jpeg": "image",
|
||||
".tiff": "image",
|
||||
".bmp": "image",
|
||||
".xlsx": "excel",
|
||||
".xls": "excel",
|
||||
".docx": "word",
|
||||
".doc": "word",
|
||||
".pptx": "powerpoint",
|
||||
".ppt": "powerpoint",
|
||||
".txt": "text",
|
||||
".md": "text",
|
||||
".csv": "csv",
|
||||
}
|
||||
|
||||
def __init__(self, lang: str = None):
|
||||
self.lang = lang or settings.TESSERACT_LANG
|
||||
log.info(f"DocumentProcessor initialized: lang={self.lang}")
|
||||
|
||||
def get_file_type(self, suffix: str) -> Optional[str]:
|
||||
return self.SUPPORTED_EXTENSIONS.get(suffix.lower())
|
||||
|
||||
def extract_from_image(self, image_path: Path | str) -> str:
|
||||
log.debug(f"Extracting from image: {image_path}")
|
||||
image = Image.open(image_path)
|
||||
log.debug(f"Image size: {image.size}")
|
||||
|
||||
text = pytesseract.image_to_string(image, lang=self.lang)
|
||||
log.info(f"Image OCR: {len(text)} chars extracted")
|
||||
|
||||
return text.strip()
|
||||
|
||||
def extract_from_pdf(self, pdf_path: Path | str) -> list[dict]:
|
||||
log.debug(f"Converting PDF to images: {pdf_path}")
|
||||
|
||||
pages = convert_from_path(pdf_path)
|
||||
log.debug(f"PDF has {len(pages)} pages")
|
||||
|
||||
results = []
|
||||
|
||||
for i, page in enumerate(pages, 1):
|
||||
log.debug(f"Processing page {i}/{len(pages)}")
|
||||
text = pytesseract.image_to_string(page, lang=self.lang)
|
||||
if text.strip():
|
||||
results.append({
|
||||
"page": i,
|
||||
"text": text.strip()
|
||||
})
|
||||
log.debug(f"Page {i}: {len(text)} chars")
|
||||
else:
|
||||
log.debug(f"Page {i}: empty (skipped)")
|
||||
|
||||
log.info(f"PDF OCR complete: {len(results)} pages with text")
|
||||
return results
|
||||
|
||||
def extract_from_excel(self, excel_path: Path | str) -> list[dict]:
|
||||
log.debug(f"Extracting from Excel: {excel_path}")
|
||||
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except ImportError:
|
||||
log.error("openpyxl not installed. Run: pip install openpyxl")
|
||||
raise ImportError("openpyxl required for Excel files. Run: pip install openpyxl")
|
||||
|
||||
wb = load_workbook(excel_path, data_only=True)
|
||||
results = []
|
||||
|
||||
for sheet_name in wb.sheetnames:
|
||||
log.debug(f"Processing sheet: {sheet_name}")
|
||||
sheet = wb[sheet_name]
|
||||
|
||||
rows_data = []
|
||||
for row in sheet.iter_rows(values_only=True):
|
||||
row_text = " | ".join(
|
||||
str(cell) if cell is not None else ""
|
||||
for cell in row
|
||||
)
|
||||
if row_text.strip(" |"):
|
||||
rows_data.append(row_text)
|
||||
|
||||
if rows_data:
|
||||
sheet_text = "\n".join(rows_data)
|
||||
results.append({
|
||||
"page": None,
|
||||
"sheet": sheet_name,
|
||||
"text": sheet_text
|
||||
})
|
||||
log.debug(f"Sheet '{sheet_name}': {len(sheet_text)} chars, {len(rows_data)} rows")
|
||||
|
||||
log.info(f"Excel extraction complete: {len(results)} sheets")
|
||||
return results
|
||||
|
||||
def extract_from_word(self, word_path: Path | str) -> list[dict]:
|
||||
log.debug(f"Extracting from Word: {word_path}")
|
||||
|
||||
try:
|
||||
from docx import Document
|
||||
except ImportError:
|
||||
log.error("python-docx not installed. Run: pip install python-docx")
|
||||
raise ImportError("python-docx required for Word files. Run: pip install python-docx")
|
||||
|
||||
doc = Document(word_path)
|
||||
|
||||
paragraphs = []
|
||||
for para in doc.paragraphs:
|
||||
if para.text.strip():
|
||||
paragraphs.append(para.text)
|
||||
|
||||
tables_text = []
|
||||
for table in doc.tables:
|
||||
table_data = []
|
||||
for row in table.rows:
|
||||
row_text = " | ".join(
|
||||
cell.text.strip() for cell in row.cells
|
||||
)
|
||||
table_data.append(row_text)
|
||||
if table_data:
|
||||
tables_text.append("\n".join(table_data))
|
||||
|
||||
full_text = "\n\n".join(paragraphs)
|
||||
if tables_text:
|
||||
full_text += "\n\n--- TABELLE ---\n\n" + "\n\n".join(tables_text)
|
||||
|
||||
log.info(f"Word extraction: {len(full_text)} chars")
|
||||
|
||||
return [{
|
||||
"page": 1,
|
||||
"text": full_text.strip()
|
||||
}]
|
||||
|
||||
def extract_from_powerpoint(self, ppt_path: Path | str) -> list[dict]:
|
||||
log.debug(f"Extracting from PowerPoint: {ppt_path}")
|
||||
|
||||
try:
|
||||
from pptx import Presentation
|
||||
except ImportError:
|
||||
log.error("python-pptx not installed. Run: pip install python-pptx")
|
||||
raise ImportError("python-pptx required for PowerPoint files. Run: pip install python-pptx")
|
||||
|
||||
prs = Presentation(ppt_path)
|
||||
results = []
|
||||
|
||||
for i, slide in enumerate(prs.slides, 1):
|
||||
slide_text = []
|
||||
|
||||
for shape in slide.shapes:
|
||||
if hasattr(shape, "text") and shape.text.strip():
|
||||
slide_text.append(shape.text)
|
||||
|
||||
if slide_text:
|
||||
results.append({
|
||||
"page": i,
|
||||
"text": "\n".join(slide_text)
|
||||
})
|
||||
log.debug(f"Slide {i}: {len(slide_text)} shapes")
|
||||
|
||||
log.info(f"PowerPoint extraction: {len(results)} slides")
|
||||
return results
|
||||
|
||||
def extract_from_text(self, text_path: Path | str) -> list[dict]:
|
||||
log.debug(f"Reading text file: {text_path}")
|
||||
|
||||
try:
|
||||
content = Path(text_path).read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
content = Path(text_path).read_text(encoding="latin-1")
|
||||
|
||||
log.info(f"Text file: {len(content)} chars")
|
||||
|
||||
return [{
|
||||
"page": 1,
|
||||
"text": content.strip()
|
||||
}]
|
||||
|
||||
def extract_from_csv(self, csv_path: Path | str) -> list[dict]:
|
||||
log.debug(f"Extracting from CSV: {csv_path}")
|
||||
|
||||
try:
|
||||
import csv
|
||||
except ImportError:
|
||||
log.error("csv module not available")
|
||||
raise
|
||||
|
||||
rows = []
|
||||
with open(csv_path, "r", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
for row in reader:
|
||||
row_text = " | ".join(row)
|
||||
rows.append(row_text)
|
||||
|
||||
content = "\n".join(rows)
|
||||
log.info(f"CSV extraction: {len(rows)} rows, {len(content)} chars")
|
||||
|
||||
return [{
|
||||
"page": 1,
|
||||
"text": content
|
||||
}]
|
||||
|
||||
def extract(self, file_path: Path | str) -> dict:
|
||||
path = Path(file_path)
|
||||
log.info(f"Extracting: {path.name}")
|
||||
|
||||
if not path.exists():
|
||||
log.error(f"File not found: {path}")
|
||||
raise FileNotFoundError(f"File not found: {path}")
|
||||
|
||||
suffix = path.suffix.lower()
|
||||
file_type = self.get_file_type(suffix)
|
||||
|
||||
if not file_type:
|
||||
log.error(f"Unsupported file type: {suffix}")
|
||||
raise ValueError(f"Unsupported file type: {suffix}")
|
||||
|
||||
log.debug(f"File type: {file_type}")
|
||||
|
||||
if file_type == "pdf":
|
||||
pages = self.extract_from_pdf(path)
|
||||
full_text = "\n\n".join(p["text"] for p in pages)
|
||||
return {
|
||||
"source": str(path),
|
||||
"type": "pdf",
|
||||
"pages": pages,
|
||||
"full_text": full_text,
|
||||
"total_pages": len(pages)
|
||||
}
|
||||
|
||||
elif file_type == "image":
|
||||
text = self.extract_from_image(path)
|
||||
return {
|
||||
"source": str(path),
|
||||
"type": "image",
|
||||
"full_text": text,
|
||||
"total_pages": 1
|
||||
}
|
||||
|
||||
elif file_type == "excel":
|
||||
sheets = self.extract_from_excel(path)
|
||||
full_text = "\n\n--- SHEET ---\n\n".join(
|
||||
f"[{s['sheet']}]\n{s['text']}" for s in sheets
|
||||
)
|
||||
return {
|
||||
"source": str(path),
|
||||
"type": "excel",
|
||||
"pages": sheets,
|
||||
"full_text": full_text,
|
||||
"total_pages": len(sheets)
|
||||
}
|
||||
|
||||
elif file_type == "word":
|
||||
pages = self.extract_from_word(path)
|
||||
return {
|
||||
"source": str(path),
|
||||
"type": "word",
|
||||
"pages": pages,
|
||||
"full_text": pages[0]["text"] if pages else "",
|
||||
"total_pages": 1
|
||||
}
|
||||
|
||||
elif file_type == "powerpoint":
|
||||
slides = self.extract_from_powerpoint(path)
|
||||
full_text = "\n\n".join(
|
||||
f"[Slide {s['page']}]\n{s['text']}" for s in slides
|
||||
)
|
||||
return {
|
||||
"source": str(path),
|
||||
"type": "powerpoint",
|
||||
"pages": slides,
|
||||
"full_text": full_text,
|
||||
"total_pages": len(slides)
|
||||
}
|
||||
|
||||
elif file_type == "text":
|
||||
pages = self.extract_from_text(path)
|
||||
return {
|
||||
"source": str(path),
|
||||
"type": "text",
|
||||
"pages": pages,
|
||||
"full_text": pages[0]["text"] if pages else "",
|
||||
"total_pages": 1
|
||||
}
|
||||
|
||||
elif file_type == "csv":
|
||||
pages = self.extract_from_csv(path)
|
||||
return {
|
||||
"source": str(path),
|
||||
"type": "csv",
|
||||
"pages": pages,
|
||||
"full_text": pages[0]["text"] if pages else "",
|
||||
"total_pages": 1
|
||||
}
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported file type: {suffix}")
|
||||
|
||||
|
||||
def extract_text(file_path: Path | str, lang: str = None) -> dict:
|
||||
processor = DocumentProcessor(lang=lang)
|
||||
return processor.extract(file_path)
|
||||
|
||||
|
||||
OCRExtractor = DocumentProcessor
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 311 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
@@ -0,0 +1,95 @@
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Optional
|
||||
|
||||
|
||||
LOG_DIR = Path(__file__).parent / "logs"
|
||||
LOG_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
class ColorFormatter(logging.Formatter):
|
||||
COLORS = {
|
||||
"DEBUG": "\033[36m",
|
||||
"INFO": "\033[32m",
|
||||
"WARNING": "\033[33m",
|
||||
"ERROR": "\033[31m",
|
||||
"CRITICAL": "\033[35m",
|
||||
}
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
|
||||
def format(self, record):
|
||||
color = self.COLORS.get(record.levelname, "")
|
||||
record.levelname = f"{color}{self.BOLD}{record.levelname:8}{self.RESET}"
|
||||
return super().format(record)
|
||||
|
||||
|
||||
def get_logger(
|
||||
name: str,
|
||||
level: int = logging.DEBUG,
|
||||
log_file: Optional[str] = None
|
||||
) -> logging.Logger:
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(level)
|
||||
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(level)
|
||||
console_format = ColorFormatter(
|
||||
fmt="%(asctime)s | %(levelname)s | %(name)s: %(message)s",
|
||||
datefmt="%H:%M:%S"
|
||||
)
|
||||
console_handler.setFormatter(console_format)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
file_path = LOG_DIR / (log_file or "ragger.log")
|
||||
file_handler = RotatingFileHandler(
|
||||
file_path,
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
encoding="utf-8"
|
||||
)
|
||||
file_handler.setLevel(level)
|
||||
file_format = logging.Formatter(
|
||||
fmt="%(asctime)s | %(levelname)-8s | %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
file_handler.setFormatter(file_format)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
log = get_logger("ragger")
|
||||
|
||||
|
||||
def log_function_call(func):
|
||||
import functools
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
args_repr = [repr(a) for a in args[:3]]
|
||||
kwargs_repr = [f"{k}={v!r}" for k, v in list(kwargs.items())[:3]]
|
||||
signature = ", ".join(args_repr + kwargs_repr)
|
||||
if len(signature) > 80:
|
||||
signature = signature[:77] + "..."
|
||||
|
||||
log.debug(f"[CALL] {func.__name__}({signature})")
|
||||
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
result_repr = repr(result)
|
||||
if len(result_repr) > 100:
|
||||
result_repr = result_repr[:97] + "..."
|
||||
log.debug(f"[RETURN] {func.__name__} -> {result_repr}")
|
||||
return result
|
||||
except Exception as e:
|
||||
log.error(f"[ERROR] {func.__name__}: {type(e).__name__}: {e}")
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
@@ -0,0 +1,94 @@
|
||||
import pytesseract
|
||||
from pdf2image import convert_from_path
|
||||
from PIL import Image
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import tempfile
|
||||
|
||||
from config import settings
|
||||
from logger import get_logger
|
||||
|
||||
log = get_logger("ocr")
|
||||
|
||||
|
||||
class OCRExtractor:
|
||||
def __init__(self, lang: str = None):
|
||||
self.lang = lang or settings.TESSERACT_LANG
|
||||
log.debug(f"OCRExtractor initialized: lang={self.lang}")
|
||||
|
||||
def extract_from_image(self, image_path: Path | str) -> str:
|
||||
log.debug(f"Extracting from image: {image_path}")
|
||||
image = Image.open(image_path)
|
||||
log.debug(f"Image size: {image.size}")
|
||||
|
||||
text = pytesseract.image_to_string(image, lang=self.lang)
|
||||
log.info(f"Image OCR: {len(text)} chars extracted")
|
||||
|
||||
return text.strip()
|
||||
|
||||
def extract_from_pdf(self, pdf_path: Path | str) -> list[dict]:
|
||||
log.debug(f"Converting PDF to images: {pdf_path}")
|
||||
|
||||
pages = convert_from_path(pdf_path)
|
||||
log.debug(f"PDF has {len(pages)} pages")
|
||||
|
||||
results = []
|
||||
|
||||
for i, page in enumerate(pages, 1):
|
||||
log.debug(f"Processing page {i}/{len(pages)}")
|
||||
text = pytesseract.image_to_string(page, lang=self.lang)
|
||||
if text.strip():
|
||||
results.append({
|
||||
"page": i,
|
||||
"text": text.strip()
|
||||
})
|
||||
log.debug(f"Page {i}: {len(text)} chars")
|
||||
else:
|
||||
log.debug(f"Page {i}: empty (skipped)")
|
||||
|
||||
log.info(f"PDF OCR complete: {len(results)} pages with text")
|
||||
return results
|
||||
|
||||
def extract(self, file_path: Path | str) -> dict:
|
||||
path = Path(file_path)
|
||||
log.info(f"Extracting: {path.name}")
|
||||
|
||||
if not path.exists():
|
||||
log.error(f"File not found: {path}")
|
||||
raise FileNotFoundError(f"File not found: {path}")
|
||||
|
||||
suffix = path.suffix.lower()
|
||||
log.debug(f"File type: {suffix}")
|
||||
|
||||
if suffix == ".pdf":
|
||||
pages = self.extract_from_pdf(path)
|
||||
full_text = "\n\n".join(p["text"] for p in pages)
|
||||
result = {
|
||||
"source": str(path),
|
||||
"type": "pdf",
|
||||
"pages": pages,
|
||||
"full_text": full_text,
|
||||
"total_pages": len(pages)
|
||||
}
|
||||
log.info(f"PDF extracted: {len(full_text)} chars, {len(pages)} pages")
|
||||
return result
|
||||
|
||||
elif suffix in {".png", ".jpg", ".jpeg", ".tiff", ".bmp"}:
|
||||
text = self.extract_from_image(path)
|
||||
result = {
|
||||
"source": str(path),
|
||||
"type": "image",
|
||||
"full_text": text,
|
||||
"total_pages": 1
|
||||
}
|
||||
log.info(f"Image extracted: {len(text)} chars")
|
||||
return result
|
||||
|
||||
else:
|
||||
log.error(f"Unsupported file type: {suffix}")
|
||||
raise ValueError(f"Unsupported file type: {suffix}")
|
||||
|
||||
|
||||
def extract_text(file_path: Path | str, lang: str = None) -> dict:
|
||||
extractor = OCRExtractor(lang=lang)
|
||||
return extractor.extract(file_path)
|
||||
@@ -0,0 +1,189 @@
|
||||
import httpx
|
||||
import warnings
|
||||
from typing import Optional
|
||||
from config import settings
|
||||
from logger import get_logger
|
||||
|
||||
warnings.filterwarnings("ignore", message="Unverified HTTPS request")
|
||||
|
||||
log = get_logger("ollama_client")
|
||||
|
||||
PROVIDER_BASE_URLS = {
|
||||
"openrouter": "https://openrouter.ai/api/v1",
|
||||
"openai": "https://api.openai.com/v1",
|
||||
"groq": "https://api.groq.com/openai/v1",
|
||||
"opencode": "https://opencode.ai/zen/v1",
|
||||
}
|
||||
|
||||
|
||||
class LLMClient:
|
||||
def __init__(self):
|
||||
self.provider = settings.LLM_PROVIDER
|
||||
self.timeout = httpx.Timeout(300.0, connect=30.0)
|
||||
log.info(f"Initializing LLMClient: provider={self.provider}")
|
||||
|
||||
if self.provider == "ollama":
|
||||
self.host = settings.OLLAMA_HOST
|
||||
log.debug(f"Ollama host: {self.host}")
|
||||
self.client = httpx.Client(timeout=self.timeout, verify=False)
|
||||
else:
|
||||
base_url = settings.LLM_BASE_URL or PROVIDER_BASE_URLS.get(self.provider, "")
|
||||
api_key = settings.LLM_API_KEY or ""
|
||||
log.debug(f"External API: {self.provider} -> {base_url}")
|
||||
self.client = httpx.Client(
|
||||
timeout=self.timeout,
|
||||
base_url=base_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
if self.provider == "openrouter":
|
||||
self.client.headers["HTTP-Referer"] = "https://github.com/ragger"
|
||||
self.client.headers["X-Title"] = "RAGGER"
|
||||
|
||||
def embed(self, text: str) -> list[float]:
|
||||
model = settings.OLLAMA_EMBED_MODEL
|
||||
log.debug(f"Embedding: model={model}, text_len={len(text)}")
|
||||
|
||||
url = f"{settings.OLLAMA_HOST.rstrip('/')}/api/embeddings"
|
||||
log.debug(f"POST {url}")
|
||||
|
||||
try:
|
||||
response = self.client.post(
|
||||
url,
|
||||
json={"model": model, "prompt": text}
|
||||
)
|
||||
response.raise_for_status()
|
||||
embedding = response.json()["embedding"]
|
||||
log.debug(f"Embedding success: {len(embedding)} dims")
|
||||
return embedding
|
||||
except httpx.HTTPStatusError as e:
|
||||
log.error(f"HTTP error: {e.response.status_code} - {e.response.text}")
|
||||
raise
|
||||
except Exception as e:
|
||||
log.error(f"Embedding error: {e}")
|
||||
raise
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
system: Optional[str] = None,
|
||||
context: Optional[str] = None
|
||||
) -> str:
|
||||
log.debug(f"Generate: provider={self.provider}, prompt_len={len(prompt)}")
|
||||
|
||||
if self.provider == "ollama":
|
||||
return self._generate_ollama(prompt, system, context)
|
||||
else:
|
||||
return self._generate_openai(prompt, system, context)
|
||||
|
||||
def _generate_ollama(
|
||||
self,
|
||||
prompt: str,
|
||||
system: Optional[str] = None,
|
||||
context: Optional[str] = None
|
||||
) -> str:
|
||||
model = settings.OLLAMA_MODEL
|
||||
log.debug(f"Ollama generate: model={model}")
|
||||
|
||||
full_prompt = ""
|
||||
if system:
|
||||
full_prompt += f"System: {system}\n\n"
|
||||
if context:
|
||||
full_prompt += f"Contesto:\n{context}\n\n"
|
||||
full_prompt += prompt
|
||||
|
||||
log.debug(f"Full prompt length: {len(full_prompt)} chars")
|
||||
|
||||
url = f"{settings.OLLAMA_HOST.rstrip('/')}/api/generate"
|
||||
log.debug(f"POST {url}")
|
||||
|
||||
try:
|
||||
response = self.client.post(
|
||||
url,
|
||||
json={"model": model, "prompt": full_prompt, "stream": False}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()["response"]
|
||||
log.debug(f"Response length: {len(result)} chars")
|
||||
log.info(f"Generated response ({len(result)} chars)")
|
||||
return result
|
||||
except httpx.HTTPStatusError as e:
|
||||
log.error(f"HTTP error: {e.response.status_code} - {e.response.text}")
|
||||
raise
|
||||
except Exception as e:
|
||||
log.error(f"Generation error: {e}")
|
||||
raise
|
||||
|
||||
def _generate_openai(
|
||||
self,
|
||||
prompt: str,
|
||||
system: Optional[str] = None,
|
||||
context: Optional[str] = None
|
||||
) -> str:
|
||||
model = settings.LLM_MODEL or "openrouter/auto"
|
||||
log.debug(f"OpenAI generate: model={model}")
|
||||
|
||||
messages = []
|
||||
if system:
|
||||
messages.append({"role": "system", "content": system})
|
||||
|
||||
user_content = ""
|
||||
if context:
|
||||
user_content += f"Contesto:\n{context}\n\n"
|
||||
user_content += prompt
|
||||
messages.append({"role": "user", "content": user_content})
|
||||
|
||||
log.debug(f"Messages: {len(messages)} items")
|
||||
|
||||
try:
|
||||
response = self.client.post(
|
||||
"/chat/completions",
|
||||
json={"model": model, "messages": messages}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()["choices"][0]["message"]["content"]
|
||||
log.info(f"Generated response ({len(result)} chars)")
|
||||
return result
|
||||
except httpx.HTTPStatusError as e:
|
||||
log.error(f"HTTP error: {e.response.status_code} - {e.response.text}")
|
||||
raise
|
||||
except Exception as e:
|
||||
log.error(f"Generation error: {e}")
|
||||
raise
|
||||
|
||||
def is_available(self) -> bool:
|
||||
log.debug("Checking availability...")
|
||||
try:
|
||||
if self.provider == "ollama":
|
||||
url = f"{settings.OLLAMA_HOST.rstrip('/')}/api/tags"
|
||||
log.debug(f"GET {url}")
|
||||
response = self.client.get(url, timeout=5.0)
|
||||
available = response.status_code == 200
|
||||
log.debug(f"Ollama available: {available}")
|
||||
return available
|
||||
else:
|
||||
log.debug("External API - assuming available")
|
||||
return True
|
||||
except Exception as e:
|
||||
log.warning(f"Availability check failed: {e}")
|
||||
return False
|
||||
|
||||
def list_models(self) -> list[str]:
|
||||
if self.provider != "ollama":
|
||||
return []
|
||||
|
||||
try:
|
||||
url = f"{settings.OLLAMA_HOST.rstrip('/')}/api/tags"
|
||||
response = self.client.get(url, timeout=5.0)
|
||||
response.raise_for_status()
|
||||
models = response.json().get("models", [])
|
||||
return [m["name"] for m in models]
|
||||
except Exception as e:
|
||||
log.error(f"Error listing models: {e}")
|
||||
return []
|
||||
|
||||
|
||||
llm = LLMClient()
|
||||
ollama = llm
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
<!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="preload" href="/_next/static/media/e4af272ccee01ff0-s.p.woff2" as="font" crossorigin="" type="font/woff2"/><link rel="preload" as="image" href="/assets/logos/logo-amethyst.svg"/><link rel="stylesheet" href="/_next/static/css/c95f93ae00416c91.css" data-precedence="next"/><link rel="stylesheet" href="/_next/static/css/7194405f05cf0740.css" data-precedence="next"/><link rel="stylesheet" href="/_next/static/css/4fd5aaaf494f4393.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-de309beca9f0ca34.js"/><script src="/_next/static/chunks/fd9d1056-dc5e8e38d6c1baf0.js" async=""></script><script src="/_next/static/chunks/396464d2-6c40c6bedae13595.js" async=""></script><script src="/_next/static/chunks/8814-d658bab75de9dd56.js" async=""></script><script src="/_next/static/chunks/main-app-33c3bd74761d0243.js" async=""></script><script src="/_next/static/chunks/bc9e92e6-44549261f7848c58.js" async=""></script><script src="/_next/static/chunks/8084c5f1-c9e69ba2a235ebd2.js" async=""></script><script src="/_next/static/chunks/9da6db1e-7dbf354dab8d2e23.js" async=""></script><script src="/_next/static/chunks/8603-6d4115a6ad11ab2a.js" async=""></script><script src="/_next/static/chunks/7636-661c425f4fbc3591.js" async=""></script><script src="/_next/static/chunks/8818-31b8f9751d40aa40.js" async=""></script><script src="/_next/static/chunks/8326-b0578b8e5b66ea36.js" async=""></script><script src="/_next/static/chunks/6691-aa12f41418d13fb3.js" async=""></script><script src="/_next/static/chunks/4769-f85cf6e8d037f11d.js" async=""></script><script src="/_next/static/chunks/263-b5458c488e7d609a.js" async=""></script><script src="/_next/static/chunks/4224-77770884f7622252.js" async=""></script><script src="/_next/static/chunks/1060-1e1755cd7c946392.js" async=""></script><script src="/_next/static/chunks/2558-6a9949282384bc4b.js" async=""></script><script src="/_next/static/chunks/6761-d4aab1c647a9e937.js" async=""></script><script src="/_next/static/chunks/649-d4e7a9b956c93677.js" async=""></script><script src="/_next/static/chunks/8741-ea09ad2bed1a2331.js" async=""></script><script src="/_next/static/chunks/6095-414e1722c96180d9.js" async=""></script><script src="/_next/static/chunks/9735-76bdfbea4f28071f.js" async=""></script><script src="/_next/static/chunks/2319-85d4089ef1cfe168.js" async=""></script><script src="/_next/static/chunks/5041-f17c0279cd24ca20.js" async=""></script><script src="/_next/static/chunks/3489-391f19ec6841f68c.js" async=""></script><script src="/_next/static/chunks/app/%5Blocale%5D/layout-6ae88fcc650a8793.js" async=""></script><script src="/_next/static/chunks/a4634e51-b54c33d4e0e24465.js" async=""></script><script src="/_next/static/chunks/6169-99a72858e8d65b14.js" async=""></script><script src="/_next/static/chunks/6068-cf90bb941ff8a926.js" async=""></script><script src="/_next/static/chunks/761-1f7b829eb750edd7.js" async=""></script><script src="/_next/static/chunks/3567-41fbd1cbce1ad090.js" async=""></script><script src="/_next/static/chunks/9920-658b12de246dc89f.js" async=""></script><script src="/_next/static/chunks/1312-6c2e5bd34e65c608.js" async=""></script><script src="/_next/static/chunks/4745-4e3497e211e27cb3.js" async=""></script><script src="/_next/static/chunks/3598-36aac249cd96598a.js" async=""></script><script src="/_next/static/chunks/7367-dc9743764b0edae2.js" async=""></script><script src="/_next/static/chunks/app/%5Blocale%5D/page-7972f2e45e0b25ab.js" async=""></script><script src="/_next/static/chunks/e3e7ddb2-596db6cfdc2d2b1f.js" async=""></script><script src="/_next/static/chunks/70e0d97a-d52a16058ec0db1a.js" async=""></script><script src="/_next/static/chunks/54a60aa6-d2dd4de7879857ae.js" async=""></script><script src="/_next/static/chunks/2173-8f9d0d4fb3e7d881.js" async=""></script><script src="/_next/static/chunks/6686-29b9639a8a098d56.js" async=""></script><script src="/_next/static/chunks/3468-fb0c92617bc82d4e.js" async=""></script><script src="/_next/static/chunks/3568-ffbc396b5b5cbfa4.js" async=""></script><script src="/_next/static/chunks/5490-6bb1dc8d6bc9a44c.js" async=""></script><script src="/_next/static/chunks/9984-a73373f83a1d1de6.js" async=""></script><script src="/_next/static/chunks/5719-c70b6ccd18246ffd.js" async=""></script><script src="/_next/static/chunks/591-3392c904356cdb2d.js" async=""></script><script src="/_next/static/chunks/app/%5Blocale%5D/p/%5Bpreview%5D/layout-799bb6abbf33a6d6.js" async=""></script><script src="/_next/static/chunks/4201-15889bb09a48e076.js" async=""></script><script src="/_next/static/chunks/app/%5Blocale%5D/p/%5Bpreview%5D/page-0842ff86d5ffbb6b.js" async=""></script><link rel="preload" href="//fw-cdn.com/12327869/4773415.js" as="script"/><link rel="preload" href="https://cdn.tolt.io/tolt.js" as="script"/><link rel="Line truncated
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RAGGER - Layout Test</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #7c3aed;
|
||||
--primary-light: #a78bfa;
|
||||
}
|
||||
.tab-active {
|
||||
border-bottom: 2px solid var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
.sidebar-section {
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
.sidebar-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.doc-item:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
.upload-zone {
|
||||
border: 2px dashed #d1d5db;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.upload-zone:hover {
|
||||
border-color: var(--primary);
|
||||
background-color: #faf5ff;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: #d1d5db;
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100 min-h-screen flex flex-col">
|
||||
<!-- NAVBAR STICKY -->
|
||||
<nav class="w-full bg-white border-b border-gray-200 sticky top-0 z-50 shadow-sm">
|
||||
<div class="flex flex-row items-center justify-between px-4 lg:px-6 py-2">
|
||||
<!-- Logo + Titolo -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-2xl">📚</span>
|
||||
<div>
|
||||
<span class="font-bold text-lg text-gray-800">RAGGER</span>
|
||||
<span class="hidden sm:inline text-xs text-gray-500 ml-2">Sistema RAG</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controlli centrali -->
|
||||
<div class="hidden md:flex items-center gap-2 bg-gray-100 rounded-lg px-2 py-1">
|
||||
<button class="px-2 py-1 text-xs rounded hover:bg-white" title="Test Connessione">🔄</button>
|
||||
<button class="px-2 py-1 text-xs rounded hover:bg-white" title="Health Check">📊</button>
|
||||
<button class="px-2 py-1 text-xs rounded hover:bg-white" title="Reset DB">🗑️</button>
|
||||
</div>
|
||||
|
||||
<!-- Info sistema -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="hidden sm:inline text-xs text-gray-500 font-mono">ollama.solarpi.net</span>
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full" title="Connesso"></div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- LAYOUT PRINCIPALE -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- SIDEBAR SINISTRA - GESTIONE DOCUMENTALE -->
|
||||
<aside class="w-80 bg-white border-r border-gray-200 flex flex-col overflow-hidden">
|
||||
<!-- Header sidebar -->
|
||||
<div class="p-4 border-b border-gray-200 bg-gray-50">
|
||||
<h2 class="font-semibold text-gray-700 flex items-center gap-2">
|
||||
📁 Documenti
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Contenuto sidebar scrollabile -->
|
||||
<div class="flex-1 overflow-y-auto scrollbar-thin p-4">
|
||||
<!-- Upload Area -->
|
||||
<div class="sidebar-section">
|
||||
<div class="upload-zone rounded-lg p-4 text-center cursor-pointer" onclick="document.getElementById('file-input').click()">
|
||||
<input type="file" id="file-input" class="hidden" multiple accept=".pdf,.docx,.xlsx,.pptx,.txt,.csv,.png,.jpg">
|
||||
<div class="text-3xl mb-2">📤</div>
|
||||
<p class="text-sm text-gray-600 font-medium">Carica documenti</p>
|
||||
<p class="text-xs text-gray-400 mt-1">PDF, DOCX, XLSX, PPTX, TXT, CSV, Immagini</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistiche -->
|
||||
<div class="sidebar-section">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="bg-violet-50 rounded-lg p-3 text-center">
|
||||
<p class="text-xl font-bold text-violet-600">11</p>
|
||||
<p class="text-xs text-gray-500">Chunks</p>
|
||||
</div>
|
||||
<div class="bg-blue-50 rounded-lg p-3 text-center">
|
||||
<p class="text-xl font-bold text-blue-600">4</p>
|
||||
<p class="text-xs text-gray-500">Documenti</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista Documenti -->
|
||||
<div class="sidebar-section">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2 flex items-center justify-between">
|
||||
Indicizzati
|
||||
<button class="text-gray-400 hover:text-gray-600" title="Aggiorna">🔄</button>
|
||||
</h3>
|
||||
<div class="space-y-1" id="doc-list">
|
||||
<!-- Documento 1 -->
|
||||
<div class="doc-item flex items-start gap-2 p-2 rounded-lg cursor-pointer group">
|
||||
<span class="text-lg">📄</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-700 truncate">Nuova Modalità Iscrizione UILDM.pdf</p>
|
||||
<p class="text-xs text-gray-400">2 chunks · PDF</p>
|
||||
</div>
|
||||
<button class="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 text-xs">✕</button>
|
||||
</div>
|
||||
<!-- Documento 2 -->
|
||||
<div class="doc-item flex items-start gap-2 p-2 rounded-lg cursor-pointer group">
|
||||
<span class="text-lg">📊</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-700 truncate">Soci UILDM.xlsx</p>
|
||||
<p class="text-xs text-gray-400">4 chunks · Excel</p>
|
||||
</div>
|
||||
<button class="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 text-xs">✕</button>
|
||||
</div>
|
||||
<!-- Documento 3 -->
|
||||
<div class="doc-item flex items-start gap-2 p-2 rounded-lg cursor-pointer group">
|
||||
<span class="text-lg">📄</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-700 truncate">UILDM GORIZIA 2026 Brochure.pdf</p>
|
||||
<p class="text-xs text-gray-400">2 chunks · PDF</p>
|
||||
</div>
|
||||
<button class="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 text-xs">✕</button>
|
||||
</div>
|
||||
<!-- Documento 4 -->
|
||||
<div class="doc-item flex items-start gap-2 p-2 rounded-lg cursor-pointer group">
|
||||
<span class="text-lg">📄</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-700 truncate">progetto_insieme_senza_confini.pdf</p>
|
||||
<p class="text-xs text-gray-400">3 chunks · PDF</p>
|
||||
</div>
|
||||
<button class="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 text-xs">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Config rapida -->
|
||||
<div class="sidebar-section">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Configurazione</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Provider:</span>
|
||||
<span class="font-medium">Ollama</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Modello:</span>
|
||||
<span class="font-medium font-mono text-xs">gemma2:9b</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Embedding:</span>
|
||||
<span class="font-medium font-mono text-xs">nomic-embed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer sidebar -->
|
||||
<div class="p-3 border-t border-gray-200 bg-gray-50">
|
||||
<button class="w-full px-3 py-2 text-xs bg-gray-200 hover:bg-gray-300 rounded-lg text-gray-700">
|
||||
⚙️ Impostazioni avanzate
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- CONTENUTO PRINCIPALE -->
|
||||
<main class="flex-1 flex flex-col overflow-hidden bg-gray-50">
|
||||
<!-- TAB NAV -->
|
||||
<div class="bg-white border-b border-gray-200 px-4">
|
||||
<div class="flex space-x-1">
|
||||
<button class="tab-btn tab-active px-4 py-3 text-sm font-medium" data-tab="chat">
|
||||
💬 Chat
|
||||
</button>
|
||||
<button class="tab-btn px-4 py-3 text-sm font-medium text-gray-500 hover:text-gray-700" data-tab="lettera">
|
||||
✉️ Lettera
|
||||
</button>
|
||||
<button class="tab-btn px-4 py-3 text-sm font-medium text-gray-500 hover:text-gray-700" data-tab="progetto">
|
||||
📋 Progetto
|
||||
</button>
|
||||
<button class="tab-btn px-4 py-3 text-sm font-medium text-gray-500 hover:text-gray-700" data-tab="settings">
|
||||
⚙️ Impostazioni
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB CONTENT -->
|
||||
<div class="flex-1 overflow-y-auto p-4 lg:p-6">
|
||||
<!-- CHAT TAB -->
|
||||
<div id="tab-chat" class="tab-content max-w-4xl mx-auto">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-800">Chat con i tuoi documenti</h2>
|
||||
|
||||
<!-- Chat Messages -->
|
||||
<div class="space-y-4 mb-4" id="chat-messages">
|
||||
<!-- Messaggio utente -->
|
||||
<div class="flex gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-sm flex-shrink-0">👤</div>
|
||||
<div class="flex-1 bg-white rounded-lg p-4 shadow-sm border">
|
||||
<p class="text-sm text-gray-700">Come iscriversi alla UILDM?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messaggio assistente -->
|
||||
<div class="flex gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-violet-100 flex items-center justify-center text-sm flex-shrink-0">🤖</div>
|
||||
<div class="flex-1 bg-white rounded-lg p-4 shadow-sm border">
|
||||
<p class="text-sm text-gray-700 mb-3">Per iscriversi alla UILDM è necessario compilare il modulo di iscrizione e pagare la quota associativa annuale. Ecco i passaggi:</p>
|
||||
<ol class="text-sm text-gray-700 list-decimal list-inside space-y-1">
|
||||
<li>Compilare il modulo di iscrizione</li>
|
||||
<li>Pagare la quota annuale di €20</li>
|
||||
<li>Consegnare la documentazione alla segreteria</li>
|
||||
</ol>
|
||||
|
||||
<!-- Fonti -->
|
||||
<div class="mt-4 pt-3 border-t">
|
||||
<p class="text-xs font-semibold text-gray-500 mb-2">📚 Fonti:</p>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2 text-xs bg-violet-50 rounded p-2">
|
||||
<span>📄</span>
|
||||
<span class="text-gray-700">Nuova Modalità Iscrizione UILDM.pdf</span>
|
||||
<span class="text-gray-400">· score 0.72</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Input -->
|
||||
<div class="sticky bottom-0 bg-gray-50 pt-4">
|
||||
<div class="border rounded-xl p-2 flex gap-2 bg-white shadow-sm">
|
||||
<input type="text" placeholder="Fai una domanda sui documenti..." class="flex-1 px-4 py-2 outline-none text-sm">
|
||||
<button class="px-5 py-2 bg-violet-600 text-white rounded-lg text-sm hover:bg-violet-700 flex items-center gap-2">
|
||||
<span>Invia</span>
|
||||
<span>→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LETTERA TAB -->
|
||||
<div id="tab-lettera" class="tab-content hidden max-w-4xl mx-auto">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-800">Genera Lettera</h2>
|
||||
|
||||
<div class="grid lg:grid-cols-2 gap-6">
|
||||
<!-- Input -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Argomento della lettera</label>
|
||||
<textarea class="w-full border rounded-lg p-3 text-sm focus:ring-2 focus:ring-violet-300 focus:border-violet-500" rows="4" placeholder="Es: Richiesta di patrocinio per l'evento annuale..."></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Contesto aggiuntivo</label>
|
||||
<textarea class="w-full border rounded-lg p-3 text-sm focus:ring-2 focus:ring-violet-300 focus:border-violet-500" rows="2" placeholder="Dettagli specifici, destinatario, tono..."></textarea>
|
||||
</div>
|
||||
<button class="w-full px-4 py-3 bg-violet-600 text-white rounded-lg text-sm hover:bg-violet-700 flex items-center justify-center gap-2">
|
||||
✉️ Genera Lettera
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Output -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Lettera generata</label>
|
||||
<textarea class="w-full border rounded-lg p-3 text-sm bg-white h-80" readonly placeholder="La lettera apparirà qui dopo la generazione..."></textarea>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button class="flex-1 px-3 py-2 border rounded-lg text-sm hover:bg-gray-50">📋 Copia</button>
|
||||
<button class="flex-1 px-3 py-2 border rounded-lg text-sm hover:bg-gray-50">💾 Salva</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PROGETTO TAB -->
|
||||
<div id="tab-progetto" class="tab-content hidden max-w-4xl mx-auto">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-800">Genera Proposta Progetto</h2>
|
||||
|
||||
<div class="grid lg:grid-cols-2 gap-6">
|
||||
<!-- Input -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Titolo del progetto</label>
|
||||
<input type="text" class="w-full border rounded-lg p-3 text-sm focus:ring-2 focus:ring-violet-300 focus:border-violet-500" placeholder="Es: Insieme senza confini">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Descrizione</label>
|
||||
<textarea class="w-full border rounded-lg p-3 text-sm focus:ring-2 focus:ring-violet-300 focus:border-violet-500" rows="6" placeholder="Descrivi l'obiettivo, il target, le attività previste..."></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Budget stimato (opzionale)</label>
|
||||
<input type="text" class="w-full border rounded-lg p-3 text-sm" placeholder="Es: €5.000">
|
||||
</div>
|
||||
<button class="w-full px-4 py-3 bg-violet-600 text-white rounded-lg text-sm hover:bg-violet-700 flex items-center justify-center gap-2">
|
||||
📋 Genera Proposta
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Output -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Proposta generata</label>
|
||||
<textarea class="w-full border rounded-lg p-3 text-sm bg-white h-96" readonly placeholder="La proposta apparirà qui dopo la generazione..."></textarea>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button class="flex-1 px-3 py-2 border rounded-lg text-sm hover:bg-gray-50">📋 Copia</button>
|
||||
<button class="flex-1 px-3 py-2 border rounded-lg text-sm hover:bg-gray-50">💾 Salva</button>
|
||||
<button class="flex-1 px-3 py-2 border rounded-lg text-sm hover:bg-gray-50">📧 Email</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SETTINGS TAB -->
|
||||
<div id="tab-settings" class="tab-content hidden max-w-2xl mx-auto">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-800">⚙️ Impostazioni</h2>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6 space-y-6">
|
||||
<!-- Provider -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Provider LLM</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-violet-50 has-[:checked]:border-violet-500 has-[:checked]:bg-violet-50">
|
||||
<input type="radio" name="provider" value="ollama" checked class="text-violet-600">
|
||||
<span class="text-sm">🏠 Ollama</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-violet-50 has-[:checked]:border-violet-500 has-[:checked]:bg-violet-50">
|
||||
<input type="radio" name="provider" value="opencode" class="text-violet-600">
|
||||
<span class="text-sm">🧘 OpenCode</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-violet-50 has-[:checked]:border-violet-500 has-[:checked]:bg-violet-50">
|
||||
<input type="radio" name="provider" value="openrouter" class="text-violet-600">
|
||||
<span class="text-sm">🌐 OpenRouter</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-violet-50 has-[:checked]:border-violet-500 has-[:checked]:bg-violet-50">
|
||||
<input type="radio" name="provider" value="groq" class="text-violet-600">
|
||||
<span class="text-sm">⚡ Groq</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ollama Settings -->
|
||||
<div id="ollama-settings">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Ollama Host</label>
|
||||
<input type="text" value="https://ollama.solarpi.net" class="w-full border rounded-lg p-3 text-sm font-mono">
|
||||
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1 mt-4">Modello</label>
|
||||
<select class="w-full border rounded-lg p-3 text-sm">
|
||||
<option selected>gemma2:9b</option>
|
||||
<option>llama3.2</option>
|
||||
<option>llama3.1</option>
|
||||
<option>mistral</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Embedding -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Modello Embedding</label>
|
||||
<select class="w-full border rounded-lg p-3 text-sm">
|
||||
<option selected>nomic-embed-text</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 pt-4 border-t">
|
||||
<button class="flex-1 px-4 py-2 bg-violet-600 text-white rounded-lg text-sm hover:bg-violet-700">
|
||||
💾 Salva Impostazioni
|
||||
</button>
|
||||
<button class="px-4 py-2 border rounded-lg text-sm hover:bg-gray-50">
|
||||
🔄 Applica
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab-btn').forEach(b => {
|
||||
b.classList.remove('tab-active');
|
||||
b.classList.add('text-gray-500');
|
||||
});
|
||||
btn.classList.add('tab-active');
|
||||
btn.classList.remove('text-gray-500');
|
||||
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.add('hidden'));
|
||||
document.getElementById('tab-' + btn.dataset.tab).classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,57 @@
|
||||
httpx.ReadTimeout: The read operation timed out
|
||||
Traceback:
|
||||
File "/home/codex/RAGGER/venv/lib/python3.13/site-packages/streamlit/runtime/scriptrunner/exec_code.py", line 129, in exec_func_with_error_handling
|
||||
result = func()
|
||||
File "/home/codex/RAGGER/venv/lib/python3.13/site-packages/streamlit/runtime/scriptrunner/script_runner.py", line 689, in code_to_exec
|
||||
exec(code, module.__dict__) # noqa: S102
|
||||
~~~~^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/home/codex/RAGGER/app.py", line 71, in <module>
|
||||
result = rag.query(prompt)
|
||||
File "/home/codex/RAGGER/core.py", line 67, in query
|
||||
answer = ollama.generate(
|
||||
prompt=prompt,
|
||||
system=system or SYSTEM_PROMPT
|
||||
)
|
||||
File "/home/codex/RAGGER/ollama_client.py", line 45, in generate
|
||||
response = self.client.post(
|
||||
self._url("/api/generate"),
|
||||
...<4 lines>...
|
||||
}
|
||||
)
|
||||
File "/home/codex/RAGGER/venv/lib/python3.13/site-packages/httpx/_client.py", line 1144, in post
|
||||
return self.request(
|
||||
~~~~~~~~~~~~^
|
||||
"POST",
|
||||
^^^^^^^
|
||||
...<11 lines>...
|
||||
extensions=extensions,
|
||||
^^^^^^^^^^^^^^^^^^^^^^
|
||||
)
|
||||
^
|
||||
File "/home/codex/RAGGER/venv/lib/python3.13/site-packages/httpx/_client.py", line 825, in request
|
||||
return self.send(request, auth=auth, follow_redirects=follow_redirects)
|
||||
~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/home/codex/RAGGER/venv/lib/python3.13/site-packages/httpx/_client.py", line 914, in send
|
||||
response = self._send_handling_auth(
|
||||
request,
|
||||
...<2 lines>...
|
||||
history=[],
|
||||
)
|
||||
File "/home/codex/RAGGER/venv/lib/python3.13/site-packages/httpx/_client.py", line 942, in _send_handling_auth
|
||||
response = self._send_handling_redirects(
|
||||
request,
|
||||
follow_redirects=follow_redirects,
|
||||
history=history,
|
||||
)
|
||||
File "/home/codex/RAGGER/venv/lib/python3.13/site-packages/httpx/_client.py", line 979, in _send_handling_redirects
|
||||
response = self._send_single_request(request)
|
||||
File "/home/codex/RAGGER/venv/lib/python3.13/site-packages/httpx/_client.py", line 1014, in _send_single_request
|
||||
response = transport.handle_request(request)
|
||||
File "/home/codex/RAGGER/venv/lib/python3.13/site-packages/httpx/_transports/default.py", line 249, in handle_request
|
||||
with map_httpcore_exceptions():
|
||||
~~~~~~~~~~~~~~~~~~~~~~~^^
|
||||
File "/usr/lib/python3.13/contextlib.py", line 162, in __exit__
|
||||
self.gen.throw(value)
|
||||
~~~~~~~~~~~~~~^^^^^^^
|
||||
File "/home/codex/RAGGER/venv/lib/python3.13/site-packages/httpx/_transports/default.py", line 118, in map_httpcore_exceptions
|
||||
raise mapped_exc(message) from exc
|
||||
@@ -0,0 +1,11 @@
|
||||
streamlit
|
||||
qdrant-client
|
||||
pytesseract
|
||||
pdf2image
|
||||
Pillow
|
||||
httpx
|
||||
python-dotenv
|
||||
pydantic-settings
|
||||
openpyxl
|
||||
python-docx
|
||||
python-pptx
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+158
@@ -0,0 +1,158 @@
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
from config import settings
|
||||
from ollama_client import ollama
|
||||
from chunker import Chunk
|
||||
from logger import get_logger
|
||||
|
||||
log = get_logger("vectorstore")
|
||||
|
||||
|
||||
class VectorStore:
|
||||
def __init__(self):
|
||||
self.path = Path(settings.QDRANT_PATH)
|
||||
self.path.mkdir(parents=True, exist_ok=True)
|
||||
self.collection = settings.QDRANT_COLLECTION
|
||||
log.debug(f"Initializing Qdrant at: {self.path}")
|
||||
self.client = QdrantClient(path=str(self.path), force_disable_check_same_thread=True)
|
||||
self._ensure_collection()
|
||||
|
||||
def _ensure_collection(self):
|
||||
collections = self.client.get_collections().collections
|
||||
names = [c.name for c in collections]
|
||||
|
||||
if self.collection not in names:
|
||||
log.info(f"Creating collection: {self.collection}")
|
||||
self.client.create_collection(
|
||||
collection_name=self.collection,
|
||||
vectors_config=VectorParams(
|
||||
size=768,
|
||||
distance=Distance.COSINE
|
||||
)
|
||||
)
|
||||
else:
|
||||
log.debug(f"Collection exists: {self.collection}")
|
||||
|
||||
def add_chunk(self, chunk: Chunk) -> str:
|
||||
log.debug(f"Adding chunk: source={chunk.source}, page={chunk.page}, index={chunk.index}")
|
||||
log.debug(f"Chunk text length: {len(chunk.text)} chars")
|
||||
|
||||
embedding = ollama.embed(chunk.text)
|
||||
log.debug(f"Embedding generated: {len(embedding)} dims")
|
||||
|
||||
point_id = str(uuid.uuid4())
|
||||
|
||||
self.client.upsert(
|
||||
collection_name=self.collection,
|
||||
points=[
|
||||
PointStruct(
|
||||
id=point_id,
|
||||
vector=embedding,
|
||||
payload={
|
||||
"text": chunk.text,
|
||||
"source": chunk.source,
|
||||
"page": chunk.page,
|
||||
"sheet": chunk.sheet,
|
||||
"index": chunk.index,
|
||||
"metadata": chunk.metadata or {}
|
||||
}
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
log.info(f"Chunk added: {point_id[:8]}... from {chunk.source}")
|
||||
return point_id
|
||||
|
||||
def add_chunks(self, chunks: list[Chunk]) -> list[str]:
|
||||
log.info(f"Adding {len(chunks)} chunks...")
|
||||
ids = []
|
||||
for i, c in enumerate(chunks):
|
||||
log.debug(f"Processing chunk {i+1}/{len(chunks)}")
|
||||
ids.append(self.add_chunk(c))
|
||||
log.info(f"Added {len(ids)} chunks total")
|
||||
return ids
|
||||
|
||||
def search(self, query: str, limit: int = 5) -> list[dict]:
|
||||
log.debug(f"Searching: '{query[:50]}...' (limit={limit})")
|
||||
|
||||
query_embedding = ollama.embed(query)
|
||||
log.debug(f"Query embedding: {len(query_embedding)} dims")
|
||||
|
||||
results = self.client.query_points(
|
||||
collection_name=self.collection,
|
||||
query=query_embedding,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
log.debug(f"Found {len(results.points)} results")
|
||||
|
||||
output = []
|
||||
for r in results.points:
|
||||
payload = r.payload or {}
|
||||
output.append({
|
||||
"text": payload.get("text", ""),
|
||||
"source": payload.get("source", "unknown"),
|
||||
"page": payload.get("page"),
|
||||
"sheet": payload.get("sheet"),
|
||||
"score": r.score
|
||||
})
|
||||
if payload.get("sheet"):
|
||||
log.debug(f" Result: {payload.get('source')} / {payload.get('sheet')} (score={r.score:.4f})")
|
||||
else:
|
||||
log.debug(f" Result: {payload.get('source')} (score={r.score:.4f})")
|
||||
|
||||
return output
|
||||
|
||||
def delete_by_source(self, source: str) -> int:
|
||||
log.info(f"Deleting chunks from: {source}")
|
||||
result = self.client.delete(
|
||||
collection_name=self.collection,
|
||||
points_selector=Filter(
|
||||
must=[
|
||||
FieldCondition(
|
||||
key="source",
|
||||
match=MatchValue(value=source)
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
log.info(f"Delete result: {result}")
|
||||
return result.operation_id if hasattr(result, 'operation_id') else 0
|
||||
|
||||
def count(self) -> int:
|
||||
info = self.client.get_collection(self.collection)
|
||||
count = info.points_count or 0
|
||||
log.debug(f"Collection count: {count}")
|
||||
return count
|
||||
|
||||
def clear(self) -> bool:
|
||||
log.warning(f"Clearing collection: {self.collection}")
|
||||
try:
|
||||
self.client.delete_collection(self.collection)
|
||||
log.info("Collection deleted")
|
||||
self._ensure_collection()
|
||||
log.info("Collection recreated (empty)")
|
||||
return True
|
||||
except Exception as e:
|
||||
log.error(f"Error clearing collection: {e}")
|
||||
return False
|
||||
|
||||
def reset(self) -> bool:
|
||||
log.warning("RESETTING VectorStore - deleting all data!")
|
||||
try:
|
||||
if self.collection in [c.name for c in self.client.get_collections().collections]:
|
||||
self.client.delete_collection(self.collection)
|
||||
log.info("Collection deleted")
|
||||
self._ensure_collection()
|
||||
log.info("VectorStore reset complete")
|
||||
return True
|
||||
except Exception as e:
|
||||
log.error(f"Error resetting VectorStore: {e}")
|
||||
return False
|
||||
|
||||
|
||||
vectorstore = VectorStore()
|
||||
Reference in new issue
Block a user