fix: responder a partir dos livros com embeddings em português

Usa o modelo multilingual, ignora trechos curtos e o template do Llama 3.2
para a resposta sair em uma frase completa. O chat deixa a API livre durante
a geração, o upload indexa o PDF e os agendamentos não são gravados duas vezes.
This commit is contained in:
lgcosta committed 2026-11-01 12:34:54 -03:00
1 parent 7af7ba530d
commit 8eeb6b89f4
11 files changed
+185 -90

No files matched your search

+23 -10
View File
@@ -1,5 +1,7 @@
import asyncio
import json import json
import os import os
import uuid
from typing import List from typing import List
from fastapi import FastAPI, File, HTTPException, UploadFile from fastapi import FastAPI, File, HTTPException, UploadFile
@@ -69,12 +71,30 @@ def load_schedules():
return [] return []
def index_pdf(file_path: str, filename: str):
from app.services.pdf_processor import PDFProcessor
from app.utils.embeddings import EmbeddingService
title = os.path.splitext(os.path.basename(filename))[0].replace("-", " ").replace("_", " ")
processor = PDFProcessor(EmbeddingService())
chunks = processor.embed_chunks(
processor.load_and_split(
file_path,
{"title": title, "author": "Desconhecido", "type": "book"},
)
)
for chunk in chunks:
chunk["id"] = str(uuid.uuid4())
get_qdrant().add_chunks(chunks)
def save_schedules(schedules: list): def save_schedules(schedules: list):
parent = os.path.dirname(SCHEDULES_PATH) parent = os.path.dirname(SCHEDULES_PATH)
if parent: if parent:
os.makedirs(parent, exist_ok=True) os.makedirs(parent, exist_ok=True)
with open(SCHEDULES_PATH, "w", encoding="utf-8") as handle: with open(SCHEDULES_PATH, "w", encoding="utf-8") as handle:
json.dump(schedules, handle, indent=2) json.dump(schedules, handle, indent=2)
handle.write("\n")
@app.get("/health") @app.get("/health")
@@ -96,7 +116,7 @@ async def get_books():
@app.post("/api/chat", response_model=ChatResponse) @app.post("/api/chat", response_model=ChatResponse)
async def chat(request: ChatRequest): def chat(request: ChatRequest):
try: try:
return get_rag().query(request.message, request.top_k) return get_rag().query(request.message, request.top_k)
except Exception as exc: except Exception as exc:
@@ -114,7 +134,8 @@ async def upload_pdf(file: UploadFile = File(...)):
content = await file.read() content = await file.read()
with open(file_path, "wb") as handle: with open(file_path, "wb") as handle:
handle.write(content) handle.write(content)
return UploadResponse(message="File uploaded successfully", filename=file.filename) await asyncio.to_thread(index_pdf, file_path, file.filename)
return UploadResponse(message="File uploaded and indexed", filename=file.filename)
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc raise HTTPException(status_code=500, detail=str(exc)) from exc
@@ -126,15 +147,7 @@ async def get_schedules():
@app.post("/api/schedules", response_model=ScheduleInfo) @app.post("/api/schedules", response_model=ScheduleInfo)
async def create_schedule(request: ScheduleRequest): async def create_schedule(request: ScheduleRequest):
schedules = load_schedules()
new_schedule = schedule_manager.add_schedule(request.model_dump()) new_schedule = schedule_manager.add_schedule(request.model_dump())
if not any(item.get("id") == new_schedule["id"] for item in schedules):
schedules.append(new_schedule)
save_schedules(schedules)
try:
bot_service.add_schedule(new_schedule)
except Exception as exc:
print(f"Warning: Could not add schedule to bot: {exc}")
return ScheduleInfo(**new_schedule) return ScheduleInfo(**new_schedule)
+6 -3
View File
@@ -22,12 +22,15 @@ class PDFProcessor:
# Convert to dictionaries with metadata # Convert to dictionaries with metadata
chunk_data = [] chunk_data = []
for i, doc in enumerate(chunks): for doc in chunks:
content = " ".join(doc.page_content.split())
if len(content) < 80:
continue
chunk_data.append({ chunk_data.append({
"content": doc.page_content, "content": content,
"metadata": { "metadata": {
**metadata, **metadata,
"chunk_id": i, "chunk_id": len(chunk_data),
"source": file_path, "source": file_path,
} }
}) })
+37 -35
View File
@@ -2,9 +2,10 @@ from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue
import numpy as np import numpy as np
from typing import List, Dict, Optional from typing import List, Dict, Optional
import os
import json import json
import os
import time import time
import uuid
class QdrantService: class QdrantService:
def __init__(self, url: str, collection_name: str = "espiritismo_books"): def __init__(self, url: str, collection_name: str = "espiritismo_books"):
@@ -46,24 +47,22 @@ class QdrantService:
def add_chunks(self, chunks: List[Dict]): def add_chunks(self, chunks: List[Dict]):
"""Add chunks to the collection.""" """Add chunks to the collection."""
points = [] points = [
PointStruct(
for i, chunk in enumerate(chunks): id=str(chunk.get("id") or uuid.uuid4()),
point = PointStruct(
id=chunk.get("id") or i,
vector=chunk["embedding"], vector=chunk["embedding"],
payload={ payload={
"content": chunk["content"], "content": chunk["content"],
"metadata": chunk["metadata"], "metadata": chunk["metadata"],
} },
) )
points.append(point) for chunk in chunks
]
for start in range(0, len(points), 128):
batch = points[start:start + 128]
self.client.upsert(collection_name=self.collection_name, points=batch)
if points: if points:
self.client.upsert(
collection_name=self.collection_name,
points=points,
)
print(f"Added {len(points)} chunks to Qdrant") print(f"Added {len(points)} chunks to Qdrant")
def search(self, query_embedding, top_k: int = 3) -> List[Dict]: def search(self, query_embedding, top_k: int = 3) -> List[Dict]:
@@ -87,17 +86,22 @@ class QdrantService:
def get_books(self) -> List[str]: def get_books(self) -> List[str]:
"""Get list of books in the collection.""" """Get list of books in the collection."""
points = self.client.scroll(
collection_name=self.collection_name,
limit=10000,
)[0]
books = set() books = set()
for point in points: offset = None
metadata = point.payload["metadata"] while True:
books.add(metadata.get("title", "Unknown")) points, offset = self.client.scroll(
collection_name=self.collection_name,
limit=256,
offset=offset,
with_vectors=False,
)
for point in points:
metadata = (point.payload or {}).get("metadata") or {}
books.add(metadata.get("title", "Unknown"))
if offset is None:
break
return sorted(list(books)) return sorted(books)
def get_collection_info(self) -> Dict: def get_collection_info(self) -> Dict:
"""Get information about the collection.""" """Get information about the collection."""
@@ -138,21 +142,19 @@ class RAGService:
"""Generate a response based on the query and context.""" """Generate a response based on the query and context."""
context_text = "\n\n".join(context) context_text = "\n\n".join(context)
system_prompt = f"""Você é um assistente especializado em espiritismo. Use as informações abaixo para responder à pergunta do usuário. system_prompt = f"""Você responde perguntas sobre espiritismo usando apenas o contexto.
Escreva uma ou duas frases completas, em português. Não repita a pergunta nem as instruções.
Se o contexto não bastar, responda apenas: Não encontrei essa informação nos livros indexados.
Informações: Contexto:
{context_text} {context_text}"""
template = (
Instruções: "<|start_header_id|>user<|end_header_id|>\n\n"
- Responda de forma clara e concisa "{0}<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n"
- Use o contexto fornecido )
- Quando citar passagens, mencione o livro e capítulo (se disponível) with self._get_llm().chat_session(system_prompt=system_prompt, prompt_template=template):
- Se não souber a resposta, diga que não encontrou na base de dados""" response = self.llm.generate(query, max_tokens=160, temp=0.1)
return response.strip()
prompt = f"{system_prompt}\n\nPergunta: {query}\nResposta:"
response = self._get_llm().generate(prompt, max_tokens=500)
return response
def query(self, query: str, top_k: int = 3) -> Dict: def query(self, query: str, top_k: int = 3) -> Dict:
"""Query the RAG system.""" """Query the RAG system."""
+30 -8
View File
@@ -30,25 +30,47 @@ def save_schedules(schedules: List[Dict]):
with open("/app/schedules.json", "w") as f: with open("/app/schedules.json", "w") as f:
json.dump(schedules, f, indent=2) json.dump(schedules, f, indent=2)
def schedule_is_due(schedule: Dict) -> bool:
raw = str(schedule.get("time", "")).replace("Z", "+00:00")
when = datetime.fromisoformat(raw)
if when.tzinfo is not None:
when = when.astimezone().replace(tzinfo=None)
return datetime.now() >= when
@celery.task @celery.task
def send_scheduled_message(schedule_id: int = None): def send_scheduled_message(schedule_id: int = None):
"""Send one schedule, or every enabled schedule when id is omitted.""" """Send enabled schedules whose time has arrived and that were not sent yet."""
schedules = load_schedules() schedules = load_schedules()
if schedule_id is None: due = []
selected = [item for item in schedules if item.get("enabled", True)] for item in schedules:
else: if schedule_id is not None and item.get("id") != schedule_id:
selected = [item for item in schedules if item.get("id") == schedule_id and item.get("enabled", True)] continue
if not item.get("enabled", True) or item.get("sent"):
continue
try:
ready = schedule_is_due(item)
except ValueError:
print(f"Invalid schedule time: {item.get('time')}")
continue
if ready:
due.append(item)
if not selected: if not due:
print(f"Schedule {schedule_id} not found") print("No schedules due")
return return
for schedule in selected: changed = False
for schedule in due:
try: try:
bot_service.send_scheduled_message(schedule) bot_service.send_scheduled_message(schedule)
schedule["sent"] = True
changed = True
print(f"Sent scheduled message: {schedule['time']}") print(f"Sent scheduled message: {schedule['time']}")
except Exception as e: except Exception as e:
print(f"Error sending scheduled message: {e}") print(f"Error sending scheduled message: {e}")
if changed:
save_schedules(schedules)
@celery.task @celery.task
def generate_daily_content(): def generate_daily_content():
+6 -2
View File
@@ -1,9 +1,13 @@
from sentence_transformers import SentenceTransformer from sentence_transformers import SentenceTransformer
import numpy as np import numpy as np
_MODELS = {}
class EmbeddingService: class EmbeddingService:
def __init__(self, model_name="all-MiniLM-L6-v2"): def __init__(self, model_name="paraphrase-multilingual-MiniLM-L12-v2"):
self.model = SentenceTransformer(model_name) if model_name not in _MODELS:
_MODELS[model_name] = SentenceTransformer(model_name)
self.model = _MODELS[model_name]
self.dimension = 384 self.dimension = 384
def embed(self, texts: list[str]) -> np.ndarray: def embed(self, texts: list[str]) -> np.ndarray:
+1
View File
@@ -23,6 +23,7 @@ class ScheduleManager:
os.makedirs(os.path.dirname(self.schedules_path), exist_ok=True) os.makedirs(os.path.dirname(self.schedules_path), exist_ok=True)
with open(self.schedules_path, 'w') as f: with open(self.schedules_path, 'w') as f:
json.dump(self.schedules, f, indent=2) json.dump(self.schedules, f, indent=2)
f.write("\n")
def add_schedule(self, schedule: Dict) -> Dict: def add_schedule(self, schedule: Dict) -> Dict:
"""Add a new schedule.""" """Add a new schedule."""
+2
View File
@@ -23,6 +23,8 @@ def main():
embedding_service = EmbeddingService() embedding_service = EmbeddingService()
pdf_processor = PDFProcessor(embedding_service) pdf_processor = PDFProcessor(embedding_service)
qdrant = QdrantService(os.getenv("QDRANT_URL", "http://qdrant:6333")) qdrant = QdrantService(os.getenv("QDRANT_URL", "http://qdrant:6333"))
qdrant.client.delete_collection(qdrant.collection_name)
qdrant._ensure_collection()
books = [] books = []
for directory in book_dirs(): for directory in book_dirs():
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sistema RAG Espiritismo</title> <title>Sistema RAG Espiritismo</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E📚%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
</head> </head>
<body> <body>
+3
View File
@@ -39,6 +39,7 @@ services:
BOOKS_DIR: /app/books BOOKS_DIR: /app/books
UPLOAD_DIR: /app/uploads UPLOAD_DIR: /app/uploads
SCHEDULES_PATH: /app/schedules.json SCHEDULES_PATH: /app/schedules.json
TZ: America/Sao_Paulo
depends_on: depends_on:
- qdrant - qdrant
- redis - redis
@@ -65,6 +66,7 @@ services:
BOOKS_DIR: /app/books BOOKS_DIR: /app/books
UPLOAD_DIR: /app/uploads UPLOAD_DIR: /app/uploads
SCHEDULES_PATH: /app/schedules.json SCHEDULES_PATH: /app/schedules.json
TZ: America/Sao_Paulo
depends_on: depends_on:
- qdrant - qdrant
- redis - redis
@@ -83,6 +85,7 @@ services:
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
SCHEDULES_PATH: /app/schedules.json SCHEDULES_PATH: /app/schedules.json
TZ: America/Sao_Paulo
depends_on: depends_on:
- redis - redis
restart: unless-stopped restart: unless-stopped
+34
View File
@@ -85,6 +85,7 @@ header p {
.message-content { .message-content {
line-height: 1.6; line-height: 1.6;
white-space: pre-wrap;
} }
.message-source { .message-source {
@@ -170,3 +171,36 @@ th {
border-color: #667eea; border-color: #667eea;
background: #f9f9f9; background: #f9f9f9;
} }
.book-item {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #eee;
}
.schedule-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid #eee;
}
.schedule-info {
display: flex;
flex-direction: column;
gap: 4px;
}
button.schedule-delete {
width: auto;
padding: 8px 12px;
}
#upload-status {
margin-top: 10px;
min-height: 1.2em;
}
+42 -32
View File
@@ -17,9 +17,11 @@ document.addEventListener('DOMContentLoaded', () => {
async function sendChatMessage() { async function sendChatMessage() {
const input = document.getElementById('chat-input'); const input = document.getElementById('chat-input');
const button = document.getElementById('send-btn');
const message = input.value.trim(); const message = input.value.trim();
if (!message) return; if (!message || button.disabled) return;
button.disabled = true;
// Add user message to chat // Add user message to chat
addMessage(message, 'user'); addMessage(message, 'user');
@@ -35,39 +37,39 @@ async function sendChatMessage() {
body: JSON.stringify({ message, top_k: 3 }), body: JSON.stringify({ message, top_k: 3 }),
}); });
const data = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
throw new Error('Erro ao enviar mensagem'); const detail = typeof data.detail === 'string' ? data.detail : 'Erro ao enviar mensagem';
throw new Error(detail);
} }
const data = await response.json();
// Remove loading
hideLoading(); hideLoading();
// Add bot response
addMessage(data.response, 'bot'); addMessage(data.response, 'bot');
// Add sources
if (data.sources && data.sources.length > 0) { if (data.sources && data.sources.length > 0) {
const sourceText = data.sources.map(s => `📖 ${s.metadata.title} (chunk ${s.metadata.chunk_id})`).join('\n'); const sourceText = data.sources.map((source) => {
addMessage(`Fontes: ${sourceText}`, 'bot', true); const meta = source.metadata || source;
return `📖 ${meta.title || 'Desconhecido'} (trecho ${meta.chunk_id ?? '?'})`;
}).join('\n');
addMessage(`Fontes:\n${sourceText}`, 'bot');
} }
} catch (error) { } catch (error) {
hideLoading(); hideLoading();
addMessage(`❌ Erro: ${error.message}`, 'bot'); addMessage(`Erro: ${error.message}`, 'bot');
} finally {
button.disabled = false;
input.focus();
} }
} }
function addMessage(content, type, isSource = false) { function addMessage(content, type) {
const container = document.getElementById('chat-messages'); const container = document.getElementById('chat-messages');
const messageDiv = document.createElement('div'); const messageDiv = document.createElement('div');
messageDiv.className = `message ${type}`; messageDiv.className = `message ${type}`;
const body = document.createElement('div');
if (isSource) { body.className = 'message-content';
messageDiv.style.fontSize = '0.85rem'; body.textContent = content;
} messageDiv.appendChild(body);
messageDiv.innerHTML = `<div class="message-content">${content}</div>`;
container.appendChild(messageDiv); container.appendChild(messageDiv);
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
} }
@@ -103,10 +105,13 @@ async function loadBooks() {
books.forEach(book => { books.forEach(book => {
const bookDiv = document.createElement('div'); const bookDiv = document.createElement('div');
bookDiv.className = 'book-item'; bookDiv.className = 'book-item';
bookDiv.innerHTML = ` const icon = document.createElement('div');
<div class="book-icon">📖</div> icon.className = 'book-icon';
<div class="book-name">${book.title}</div> icon.textContent = '📖';
`; const name = document.createElement('div');
name.className = 'book-name';
name.textContent = book.title;
bookDiv.append(icon, name);
booksList.appendChild(bookDiv); booksList.appendChild(bookDiv);
}); });
@@ -189,17 +194,22 @@ function renderSchedules(schedules) {
schedules.forEach(schedule => { schedules.forEach(schedule => {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'schedule-item'; div.className = 'schedule-item';
div.innerHTML = ` const info = document.createElement('div');
<div class="schedule-info"> info.className = 'schedule-info';
<span class="schedule-time">🕐 ${formatDateTime(schedule.time)}</span> const time = document.createElement('span');
<span class="schedule-book">${schedule.book || 'Todos os livros'}</span> time.className = 'schedule-time';
</div> time.textContent = formatDateTime(schedule.time);
<button class="schedule-delete" data-id="${schedule.id}">🗑️</button> const book = document.createElement('span');
`; book.className = 'schedule-book';
book.textContent = schedule.book || 'Todos os livros';
info.append(time, book);
const remove = document.createElement('button');
remove.className = 'schedule-delete';
remove.type = 'button';
remove.textContent = 'Excluir';
remove.addEventListener('click', () => deleteSchedule(schedule.id));
div.append(info, remove);
container.appendChild(div); container.appendChild(div);
// Add delete handler
div.querySelector('.schedule-delete').addEventListener('click', () => deleteSchedule(schedule.id));
}); });
} }