Como Construir um Agente de IA com RAG do Zero: Guia Completo
Um guia prático e detalhado para criar seu próprio assistente inteligente com Retrieval-Augmented Generation (RAG), busca vetorial, processamento multimodal e interface de chat — tudo com Python, Flask e SQLite.
1. Introdução e Arquitetura Geral
O que vamos construir?
Neste post, vamos construir um agente de IA completo — nao apenas um wrapper sobre a API do ChatGPT, mas um sistema de producao com:
- RAG (Retrieval-Augmented Generation): O agente consulta sua propria base de conhecimento antes de responder, eliminando alucinacoes e garantindo respostas fundamentadas nos seus dados.
- Busca vetorial semantica: Em vez de buscar por palavras-chave, o sistema entende o significado da pergunta e encontra os documentos mais relevantes usando embeddings.
- Processamento multimodal: Aceita texto, audio (transcricao via Whisper) e imagens (analise via GPT Vision) numa mesma conversa.
- Interface de chat completa: Com historico de conversas, multiplas sessoes, renderizacao de Markdown e syntax highlighting.
- Autenticacao e autorizacao: Sistema de usuarios com registro, login e sessoes.
Por que RAG e não fine-tuning?
| Aspecto | Fine-tuning | RAG |
|---|---|---|
| Atualização dos dados | Requer retreino (caro e lento) | Atualiza a base vetorial (rápido) |
| Custo | Alto (treinamento + hosting) | Baixo (só API de embeddings) |
| Transparência | Caixa-preta | Mostra as fontes consultadas |
| Alucinações | Difícil controlar | Mitigadas pela base de conhecimento |
| Complexidade | Requer infra de ML | Python + SQLite + API da OpenAI |
Diagrama da Arquitetura
+-----------------------------------------------------------------+
| USUARIO (Browser) |
| +----------+ +----------+ +----------+ |
| | Texto | | Audio | | Imagem | |
| +----+-----+ +----+-----+ +----+-----+ |
+---------------+--------------+--------------+-------------------+
| | |
v v v
+-----------------------------------------------------------------+
| API REST (Flask) |
| |
| +----------------------------------------------------------+ |
| | /api/chat (POST) | |
| | | |
| | 1. Recebe mensagem (texto/audio/imagem) | |
| | 2. Transcreve audio (Whisper) / Descreve imagem (Vision)| |
| | 3. Salva mensagem do usuario no DB | |
| | 4. Carrega historico da conversa | |
| | 5. Gera embedding da pergunta | |
| | 6. Busca vetorial na base de conhecimento | |
| | 7. Envia para a LLM com prompt de sistema | |
| | 8. Salva resposta + metadados (fontes, custo) | |
| | 9. Retorna resposta + fontes ao frontend | |
| +----------------------------------------------------------+ |
| |
| Outros endpoints: /api/login, /api/register, |
| /api/conversations |
+------------------------+----------------------------------------+
|
+----------+----------+
v v
+----------+ +----------+
| SQLite | | OpenAI |
| | | API |
| - chat | | - GPT |
| - users | | - Whisper|
| - vectors| | - Embedd.|
+----------+ +----------+
O Fluxo RAG em Detalhes
O coração do sistema é o fluxo RAG que acontece a cada mensagem do usuário:
Pergunta do Usuário
│
▼
┌───────────────────┐
│ Gerar Embedding │──── OpenAI text-embedding-3-small
│ da pergunta │ (converte texto em vetor 1536D)
└───────┬───────────┘
│
▼
┌───────────────────┐
│ Busca Vetorial │──── Similaridade por cosseno no SQLite
│ (Top K docs) │ (encontra documentos semanticamente próximos)
└───────┬───────────┘
│
▼
┌───────────────────┐
│ Expandir por │──── Recupera TODOS os chunks de cada
│ Documento │ documento encontrado (contexto completo)
+-------+-----------+
|
v
+-------------------+
| Montar Prompt |---- System prompt + Contexto RAG +
| Completo | Historico da conversa
└───────┬───────────┘
│
▼
┌───────────────────┐
│ Chamar LLM │──── OpenAI GPT com todo o contexto
│ (GPT) │ montado
└───────┬───────────┘
│
▼
Resposta Fundamentada
+ Lista de Fontes Usadas
Stack Tecnologica
| Camada | Tecnologia | Funcao |
|---|---|---|
| Backend | Python 3.12 + Flask | API REST, logica de negocio |
| Banco de Dados | SQLite + sqlite-vss | Dados + busca vetorial |
| IA / LLM | OpenAI API (GPT, Whisper, Embeddings, Vision) | Geracao, transcricao, embeddings, analise de imagens |
| Frontend | HTML + CSS + JavaScript vanilla | Interface de chat |
| Deploy | Docker | Containerizacao |
Nas proximas secoes, vamos implementar cada componente passo a passo, com codigo completo e explicacoes detalhadas.
2. Estrutura do Projeto e Dependências
Árvore de Diretórios
Antes de começar a codar, vamos organizar o projeto. Uma boa estrutura de pastas é fundamental para manter o código limpo e escalável. Aqui está a estrutura completa que vamos construir:
meu-agente-ia/
|
+-- app.py # Aplicacao principal (Flask + rotas da API)
|
+-- agents/ # Modulo de IA
| +-- agent.py # Logica de chamada a LLM, construcao de contexto RAG
| +-- embedding.py # Geracao de embeddings via OpenAI
| +-- prompt.py # System prompt do agente
|
+-- database/ # Camada de dados
| +-- database.py # Funcoes CRUD, busca vetorial, schema SQLite
| +-- populate_database.py # Script para popular a base vetorial em lote
|
+-- static/ # Frontend
| +-- index.html # Pagina principal (SPA)
| +-- styles.css # Estilos da interface
| +-- app.js # Logica do frontend (chat, modais)
|
+-- data/ # Dados coletados (gerado automaticamente)
| +-- source_data/ # Dados brutos das fontes externas
|
+-- logs/ # Logs da aplicacao (gerado automaticamente)
| +-- flask.log # Logs do Flask
|
+-- .env # Variaveis de ambiente (NAO versionar!)
+-- .gitignore # Arquivos a ignorar no Git
+-- requirements.txt # Dependencias Python
+-- Dockerfile # Imagem Docker
+-- entrypoint.sh # Script de inicializacao do container
Por que essa estrutura?
| Diretorio | Responsabilidade | Principio |
|---|---|---|
agents/ | Toda a logica de IA (LLM, embeddings, prompts) | Separacao de responsabilidades |
database/ | Acesso a dados, schema, queries | Camada de dados isolada |
static/ | Frontend servido pelo Flask | Simplicidade (sem build step) |
logs/ | Arquivos de log rotativos | Observabilidade |
Dependências (requirements.txt)
Crie o arquivo requirements.txt com as seguintes dependências:
# Framework web
Flask==3.1.3
Flask-Limiter==4.1.1
Werkzeug==3.1.8
# OpenAI (LLM, Embeddings, Whisper, Vision)
openai==2.30.0
# Variaveis de ambiente
python-dotenv==1.2.2
# HTTP requests (para coletar dados de APIs externas)
requests==2.33.1
# SQLite com extensão vetorial
sqlite-vss==0.1.2
numpy==1.26.4
Instale tudo com:
# Crie e ative um ambiente virtual (recomendado)
python -m venv .venv
# Linux/Mac:
source .venv/bin/activate
# Windows:
.venv\Scripts\activate
# Instale as dependências
pip install -r requirements.txt
Variáveis de Ambiente (.env)
Crie o arquivo .env na raiz do projeto. Este arquivo contém segredos e nunca deve ser versionado no Git.
# Chave da API da OpenAI (obrigatória)
# Obtenha em: https://platform.openai.com/api-keys
OPENAI_API_KEY=sk-proj-sua-chave-aqui
# Chave secreta do Flask (obrigatória para sessões seguras)
# Gere com: python -c "import secrets; print(secrets.token_hex(32))"
FLASK_SECRET_KEY=sua-chave-secreta-aqui
# (Opcional) Diretorio de logs
LOG_DIR=./logs
# (Opcional) Seguranca do cookie de sessao
# Use "true" quando estiver servindo via HTTPS
COOKIE_SECURE=false
IMPORTANTE: Adicione
.envao seu.gitignoreimediatamente! Nunca versione chaves de API.
Arquivo .gitignore
# Ambiente virtual
.venv/
venv/
# Variáveis de ambiente
.env
# Cache do Python
__pycache__/
*.pyc
*.pyo
# Logs
logs/
# Dados coletados (podem ser grandes)
data/
# SQLite database
*.db
*.sqlite
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
Gerando a Chave Secreta do Flask
A chave secreta é essencial para a segurança das sessões. Gere uma chave forte:
python -c "import secrets; print(secrets.token_hex(32))"
Copie a saída e cole no campo FLASK_SECRET_KEY do seu .env.
Obtendo a API Key da OpenAI
- Acesse platform.openai.com
- Vá em API Keys → Create new secret key
- Copie a chave e cole no campo
OPENAI_API_KEYdo seu.env - Certifique-se de ter créditos na conta (os modelos usados neste projeto são pagos por uso)
Modelos que vamos usar e seus custos aproximados:
| Modelo | Uso | Custo (por 1M tokens) |
|---|---|---|
gpt-4o-mini | Geração de respostas (LLM principal) | $0.15 input / $0.60 output |
text-embedding-3-small | Geração de embeddings | $0.02 input |
whisper-1 | Transcrição de áudio | $0.006/minuto |
gpt-4o | Análise de imagens (Vision) | $2.50 input / $10.00 output |
Dica: Para desenvolvimento, o
gpt-4o-minioferece o melhor custo-benefício. Você pode trocar para modelos mais potentes em produção.
3. Banco de Dados com SQLite
O banco de dados é a espinha dorsal do agente. Ele armazena tudo: usuarios, conversas, mensagens, embeddings vetoriais e configuracoes. Vamos usar SQLite por sua simplicidade — zero configuracao, um unico arquivo, ja vem com o Python.
Para a busca vetorial, usaremos sqlite-vss (Virtual Semantic Search), uma extensao que adiciona indices vetoriais ao SQLite.
Por que SQLite? Para projetos menores e medios, SQLite é perfeito: sem servidor separado, sem configuracao, backup e copiar um arquivo. Se precisar escalar, a camada de abstracao que vamos criar facilita a migracao para PostgreSQL + pgvector.
Visao Geral das Tabelas
Nosso banco terá 4 tabelas:
| Tabela | Funcao |
|---|---|
users | Cadastro de usuarios (login, senha hash, perfil) |
conversations | Sessoes de chat (cada usuario pode ter varias) |
chat_messages | Mensagens individuais (user/assistant) com metadados |
embeddings | Vetores da base de conhecimento (documentos fragmentados) |
Arquivo: database/database.py
Este é o arquivo mais extenso do projeto. Ele contém toda a camada de dados.
# database/database.py
import os
import json
import sqlite3
import numpy as np
from datetime import datetime, timezone
# Carrega variáveis de ambiente:
try:
from dotenv import load_dotenv
env_path = os.path.join(os.path.dirname(__file__), "..", ".env")
load_dotenv(env_path)
except ImportError:
pass
# Caminho do banco de dados SQLite:
DB_PATH = os.getenv("DB_PATH", os.path.join(os.path.dirname(__file__), "..", "agent.db"))
def get_connection():
"""Cria e retorna uma conexão com o SQLite."""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row # Permite acessar colunas por nome
conn.execute("PRAGMA journal_mode=WAL") # Melhor performance em concorrência
conn.execute("PRAGMA foreign_keys=ON")
return conn
Por que PRAGMA journal_mode=WAL?
O modo WAL (Write-Ahead Logging) permite leituras e escritas simultâneas, essencial para uma aplicação web onde múltiplos usuários acessam o banco ao mesmo tempo.
Criação das Tabelas
def create_tables():
"""Cria todas as tabelas necessárias se não existirem."""
conn = get_connection()
try:
cursor = conn.cursor()
# ── Tabela de usuários ────────────────────────────────
cursor.execute("""
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
custom_prompt TEXT DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
""")
# ── Tabela de conversas ───────────────────────────────
cursor.execute("""
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT 'Nova Conversa',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_conversations_user_time
ON conversations(user_id, updated_at DESC);
""")
# ── Tabela de mensagens do chat ───────────────────────
cursor.execute("""
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
conversation_id TEXT,
role TEXT CHECK (role IN ('user','assistant','system')) NOT NULL,
content TEXT NOT NULL,
metadata TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_chat_messages_user_time
ON chat_messages(user_id, created_at DESC);
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_chat_messages_conversation
ON chat_messages(conversation_id, created_at ASC);
""")
# ── Tabela de embeddings (base de conhecimento) ───────
cursor.execute("""
CREATE TABLE IF NOT EXISTS embeddings (
id TEXT PRIMARY KEY,
embedding BLOB,
metadata TEXT
);
""")
# -- Tabela de configuracoes globais ---------------
cursor.execute("""
CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
""")
conn.commit()
finally:
conn.close()
Observacao sobre os embeddings: No SQLite, armazenamos os vetores como
BLOB(binario) usandonumpy.tobytes(). A busca por similaridade e feita em Python calculando a distancia do cosseno. Para projetos maiores, considere usarsqlite-vssou migrar para PostgreSQL compgvector.
Funções Auxiliares para Vetores
Como o SQLite não tem suporte nativo a vetores, precisamos de funções auxiliares para serializar/deserializar e calcular similaridade:
def _serialize_vector(vector):
"""Converte uma lista de floats em bytes para armazenar no SQLite."""
return np.array(vector, dtype=np.float32).tobytes()
def _deserialize_vector(blob):
"""Converte bytes de volta para uma lista de floats."""
return np.frombuffer(blob, dtype=np.float32).tolist()
def _cosine_similarity(vec_a, vec_b):
"""Calcula a similaridade do cosseno entre dois vetores numpy."""
a = np.array(vec_a, dtype=np.float32)
b = np.array(vec_b, dtype=np.float32)
dot = np.dot(a, b)
norm_a = np.linalg.norm(a)
norm_b = np.linalg.norm(b)
if norm_a == 0 or norm_b == 0:
return 0.0
return float(dot / (norm_a * norm_b))
CRUD de Usuários
def create_user(username, email, display_name, password_hash):
"""Cria um novo usuário e retorna seus dados."""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO users (username, email, display_name, password_hash)
VALUES (?, ?, ?, ?)
""", (username, email, display_name, password_hash))
conn.commit()
# Recupera o usuário recém-criado:
cursor.execute("SELECT id, username, email, display_name, created_at FROM users WHERE username = ?", (username,))
row = cursor.fetchone()
return dict(row)
finally:
conn.close()
def get_user_by_username(username):
"""Busca um usuário pelo username. Retorna None se não encontrado."""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
SELECT id, username, email, display_name, password_hash, custom_prompt
FROM users WHERE username = ?
""", (username,))
row = cursor.fetchone()
if row is None:
return None
return dict(row)
finally:
conn.close()
def get_user_by_email(email):
"""Busca um usuário pelo email."""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("SELECT id, username, email, display_name FROM users WHERE email = ?", (email,))
row = cursor.fetchone()
return dict(row) if row else None
finally:
conn.close()
def update_user_profile(user_id, display_name=None, password_hash=None):
"""Atualiza o perfil do usuário (nome e/ou senha)."""
if not display_name and not password_hash:
return
conn = get_connection()
try:
cursor = conn.cursor()
if display_name and password_hash:
cursor.execute("UPDATE users SET display_name=?, password_hash=?, updated_at=datetime('now') WHERE id=?",
(display_name, password_hash, user_id))
elif display_name:
cursor.execute("UPDATE users SET display_name=?, updated_at=datetime('now') WHERE id=?",
(display_name, user_id))
elif password_hash:
cursor.execute("UPDATE users SET password_hash=?, updated_at=datetime('now') WHERE id=?",
(password_hash, user_id))
conn.commit()
finally:
conn.close()
conn.commit()
finally:
conn.close()
CRUD de Conversas
def create_conversation(conversation_id, user_id, title="Nova Conversa"):
"""Cria uma nova conversa."""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO conversations (id, user_id, title) VALUES (?, ?, ?)
""", (conversation_id, user_id, title))
conn.commit()
cursor.execute("SELECT id, user_id, title, created_at, updated_at FROM conversations WHERE id=?",
(conversation_id,))
return dict(cursor.fetchone())
finally:
conn.close()
def list_conversations(user_id):
"""Lista todas as conversas de um usuário, ordenadas por última atualização."""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
SELECT id, title, created_at, updated_at FROM conversations
WHERE user_id = ? ORDER BY updated_at DESC
""", (user_id,))
return [dict(row) for row in cursor.fetchall()]
finally:
conn.close()
def get_conversation_messages(conversation_id):
"""Retorna todas as mensagens de uma conversa."""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
SELECT role, content, created_at, metadata FROM chat_messages
WHERE conversation_id = ? ORDER BY created_at ASC
""", (conversation_id,))
rows = cursor.fetchall()
result = []
for row in rows:
msg = dict(row)
if msg["metadata"]:
msg["metadata"] = json.loads(msg["metadata"])
result.append(msg)
return result
finally:
conn.close()
def delete_conversation(conversation_id):
"""Deleta uma conversa e todas as suas mensagens."""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("DELETE FROM chat_messages WHERE conversation_id = ?", (conversation_id,))
cursor.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,))
conn.commit()
finally:
conn.close()
def update_conversation_title(conversation_id, title):
"""Atualiza o título de uma conversa."""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("UPDATE conversations SET title=?, updated_at=datetime('now') WHERE id=?",
(title, conversation_id))
conn.commit()
finally:
conn.close()
def touch_conversation(conversation_id):
"""Atualiza o timestamp de uma conversa (para ordenação)."""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("UPDATE conversations SET updated_at=datetime('now') WHERE id=?", (conversation_id,))
conn.commit()
finally:
conn.close()
CRUD de Mensagens
def insert_message(user_id, role, content, conversation_id=None, metadata=None):
"""Insere uma mensagem no histórico do chat."""
conn = get_connection()
try:
cursor = conn.cursor()
metadata_json = json.dumps(metadata) if metadata is not None else None
cursor.execute("""
INSERT INTO chat_messages (user_id, role, content, conversation_id, metadata)
VALUES (?, ?, ?, ?, ?)
""", (user_id, role, content, conversation_id, metadata_json))
conn.commit()
finally:
conn.close()
def collect_messages(user_id, limit=None, conversation_id=None):
"""Coleta o histórico de mensagens de um usuário/conversa."""
conn = get_connection()
try:
cursor = conn.cursor()
if conversation_id:
if limit:
cursor.execute("""
SELECT role, content, metadata FROM chat_messages
WHERE user_id=? AND conversation_id=?
ORDER BY created_at DESC LIMIT ?
""", (user_id, conversation_id, limit))
else:
cursor.execute("""
SELECT role, content, metadata FROM chat_messages
WHERE user_id=? AND conversation_id=?
ORDER BY created_at DESC
""", (user_id, conversation_id))
else:
if limit:
cursor.execute("""
SELECT role, content, metadata FROM chat_messages
WHERE user_id=? ORDER BY created_at DESC LIMIT ?
""", (user_id, limit))
else:
cursor.execute("""
SELECT role, content, metadata FROM chat_messages
WHERE user_id=? ORDER BY created_at DESC
""", (user_id,))
content = cursor.fetchall()
finally:
conn.close()
# Inverte pra retornar em ordem cronológica:
return list(reversed([(row[0], row[1], row[2]) for row in content]))
Operações com Embeddings (Base de Conhecimento)
def index_vectors(vector_list):
"""Insere ou atualiza vetores na base de conhecimento."""
conn = get_connection()
try:
cursor = conn.cursor()
for vector in vector_list:
embedding_blob = _serialize_vector(vector["values"])
metadata_json = json.dumps(vector.get("metadata", {}))
cursor.execute("""
INSERT INTO embeddings (id, embedding, metadata)
VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
embedding = excluded.embedding,
metadata = excluded.metadata
""", (vector["id"], embedding_blob, metadata_json))
conn.commit()
finally:
conn.close()
def search_vectors(question_embeddings, top_k=15, min_score=0.4):
"""Busca vetorial por similaridade de cosseno."""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("SELECT id, embedding, metadata FROM embeddings")
rows = cursor.fetchall()
finally:
conn.close()
results = []
for row in rows:
stored_vector = _deserialize_vector(row[1])
score = _cosine_similarity(question_embeddings, stored_vector)
if score >= min_score:
metadata = json.loads(row[2]) if row[2] else {}
results.append({"id": row[0], "metadata": metadata, "score": score})
# Ordena por score decrescente e limita ao top_k:
results.sort(key=lambda x: x["score"], reverse=True)
return results[:top_k]
def search_vectors_by_page(question_embeddings, max_pages=10, min_score=0.4, initial_top_k=30):
"""Busca vetorial com expansão por documento.
Em vez de retornar apenas os chunks individuais mais relevantes,
identifica os documentos (páginas) mais relevantes e retorna
TODOS os chunks de cada documento. Isso garante contexto completo.
"""
# 1. Busca inicial para encontrar os chunks mais relevantes:
initial_results = search_vectors(question_embeddings, top_k=initial_top_k, min_score=min_score)
# 2. Identifica os documentos únicos (mantendo ordem de relevância):
unique_doc_ids = []
seen = set()
for result in initial_results:
doc_id = result["metadata"].get("document_id")
if doc_id and doc_id not in seen:
seen.add(doc_id)
unique_doc_ids.append(doc_id)
if len(unique_doc_ids) >= max_pages:
break
if not unique_doc_ids:
return []
# 3. Busca todos os chunks desses documentos:
conn = get_connection()
try:
cursor = conn.cursor()
placeholders = ",".join(["?"] * len(unique_doc_ids))
cursor.execute(f"SELECT id, embedding, metadata FROM embeddings WHERE json_extract(metadata, '$.document_id') IN ({placeholders})",
unique_doc_ids)
rows = cursor.fetchall()
finally:
conn.close()
results = []
for row in rows:
stored_vector = _deserialize_vector(row[1])
score = _cosine_similarity(question_embeddings, stored_vector)
metadata = json.loads(row[2]) if row[2] else {}
results.append({"id": row[0], "metadata": metadata, "score": score})
# Ordena por documento e depois por chunk_number:
results.sort(key=lambda x: (x["metadata"].get("document_id", ""), x["metadata"].get("chunk_number", 0)))
return results
def clean_embeddings_table():
"""Limpa completamente a tabela de embeddings."""
conn = get_connection()
try:
conn.execute("DELETE FROM embeddings")
conn.commit()
finally:
conn.close()
def delete_vectors_by_document(document_title):
"""Remove todos os vetores de um documento específico."""
conn = get_connection()
try:
conn.execute("DELETE FROM embeddings WHERE json_extract(metadata, '$.document_title') = ?",
(document_title,))
conn.commit()
finally:
conn.close()
Nota sobre performance: A busca vetorial bruta (percorrendo todas as linhas) funciona bem para bases de até ~50.000 vetores. Para bases maiores, considere usar a extensão
sqlite-vsspara índices HNSW, ou migrar para PostgreSQL compgvector.
4. Embeddings e Busca Vetorial
Esta é a parte que torna o agente inteligente de verdade. Em vez de buscar por palavras-chave exatas (como um LIKE '%termo%'), usamos embeddings — representações numéricas do significado do texto — para encontrar documentos semanticamente semelhantes à pergunta do usuário.
O que são Embeddings?
Um embedding é um vetor numérico (uma lista de números) que representa o “significado” de um texto em um espaço multidimensional. Textos com significados semelhantes ficam próximos nesse espaço.
"Como reiniciar o servidor?" → [0.023, -0.041, 0.089, ..., 0.012] (1536 dimensões)
"Reinicialização do server" → [0.021, -0.039, 0.091, ..., 0.011] (muito próximo!)
"Receita de bolo de chocolate" → [0.892, 0.234, -0.567, ..., 0.445] (muito distante)
Arquivo: agents/embedding.py
Este módulo encapsula a geração de embeddings via API da OpenAI:
# agents/embedding.py
from openai import OpenAI
import logging
import os
try:
from dotenv import load_dotenv
load_dotenv(override=False)
except ImportError:
pass
logger = logging.getLogger(__name__)
# Inicializa o cliente OpenAI:
client_openai = OpenAI(api_key=os.getenv("OPENAI_API_KEY"), timeout=60.0)
# Preço do text-embedding-3-small: $0.02 / 1M tokens
EMBEDDING_PRICE_PER_M = 0.02
def embed_string(text):
"""
Gera o embedding de uma string.
Retorna uma tupla: (vetor, custo_em_usd)
O vetor é uma lista de 1536 floats.
"""
logger.info("Embedding string.")
model = "text-embedding-3-small"
response = client_openai.embeddings.create(
model=model,
input=text
)
# Calcula o custo da chamada:
total_tokens = response.usage.total_tokens if response.usage else 0
cost = round(total_tokens * EMBEDDING_PRICE_PER_M / 1_000_000, 6)
return response.data[0].embedding, cost
Por que text-embedding-3-small?
| Modelo | Dimensões | Custo/1M tokens | Performance |
|---|---|---|---|
text-embedding-3-small | 1536 | $0.02 | Boa (recomendado) |
text-embedding-3-large | 3072 | $0.13 | Melhor |
text-embedding-ada-002 | 1536 | $0.10 | Legacy |
O modelo small oferece o melhor custo-benefício. Para a maioria dos casos de uso, a diferença de qualidade para o large é marginal.
Chunking: Fragmentando Documentos
Documentos longos não podem ser vetorizados inteiros — a qualidade do embedding degrada com textos muito grandes. A solução é fragmentar (chunk) o documento em pedaços menores com sobreposição (overlap):
CHUNK_SIZE = 800 # caracteres por chunk
CHUNK_OVERLAP = 200 # sobreposição entre chunks consecutivos
def chunk_text(text):
"""
Fragmenta um texto em chunks com sobreposição.
A sobreposição garante que informações na fronteira entre
dois chunks não sejam perdidas.
Exemplo com CHUNK_SIZE=10, CHUNK_OVERLAP=3:
Texto: "ABCDEFGHIJKLMNOPQRST"
Chunk 1: "ABCDEFGHIJ" (pos 0-9)
Chunk 2: "HIJKLMNOPQ" (pos 7-16, overlap de 3)
Chunk 3: "OPQRST" (pos 14-19)
"""
chunks = []
start = 0
while start < len(text):
end = start + CHUNK_SIZE
chunks.append(text[start:end])
if end >= len(text):
break
start += CHUNK_SIZE - CHUNK_OVERLAP
return chunks
Visualização do chunking com overlap:
Documento original:
┌─────────────────────────────────────────────────────┐
│ Lorem ipsum dolor sit amet, consectetur adipiscing │
│ elit. Sed do eiusmod tempor incididunt ut labore │
│ et dolore magna aliqua. Ut enim ad minim veniam... │
└─────────────────────────────────────────────────────┘
Após chunking (CHUNK_SIZE=800, OVERLAP=200):
Chunk 1: ┌────────────────────────────────┐
│ Lorem ipsum dolor sit amet... │ 800 chars
└──────────────────┬─────────────┘
│ overlap 200
Chunk 2: ┌────────┴─────────────────────┐
│ ...tempor incididunt ut... │ 800 chars
└──────────────────┬────────────┘
│ overlap 200
Chunk 3: ┌────────┴─────────────────┐
│ ...Ut enim ad minim... │ restante
└───────────────────────────┘
Vetorização Completa de um Documento
O processo completo de vetorizar um documento envolve: fragmentar → preparar contexto → gerar embeddings → montar metadados:
def embed_batch(texts):
"""Gera embeddings para um lote de textos de uma vez."""
if not texts:
return []
try:
response = client_openai.embeddings.create(
model="text-embedding-3-small",
input=texts
)
return [item.embedding for item in response.data]
except Exception as e:
logger.error(f"Erro na API de embeddings: {e}")
return []
def vectorize_document(doc):
"""
Recebe um documento sanitizado e retorna uma lista de vetores
prontos para indexação no banco de dados.
Cada vetor contém:
- id: identificador único (doc_id#chunk1, doc_id#chunk2, ...)
- values: o vetor de embedding (1536 floats)
- metadata: informações sobre o chunk (título, texto, URL, etc.)
"""
doc_id = doc.get("id", "unknown")
title = doc.get("title", "Untitled")
url = doc.get("url", "")
properties = doc.get("properties", {})
content = doc.get("content", "") or str(properties)
if not content or content == "{}":
return []
# 1. Fragmenta o conteúdo em chunks:
raw_chunks = chunk_text(content)
# 2. Prepara os textos para embedding (adiciona o título como contexto):
prepared_chunks = [f"Title: {title}\nContent: {chunk}" for chunk in raw_chunks]
# 3. Gera os embeddings em lote:
embeddings = embed_batch(prepared_chunks)
if not embeddings:
return []
# 4. Monta os vetores com metadados:
metadata_props = {f"prop_{k}": str(v) for k, v in properties.items()}
results = []
for i, (chunk, vector) in enumerate(zip(raw_chunks, embeddings), start=1):
chunk_metadata = {
"document_id": doc_id,
"document_title": title,
"document_url": url,
"chunk_number": i,
"chunk_text": chunk,
}
chunk_metadata.update(metadata_props)
results.append({
"id": f"{doc_id}#chunk{i}",
"values": vector,
"metadata": chunk_metadata,
})
return results
Busca Vetorial: Como Funciona
Quando o usuário faz uma pergunta, o sistema:
- Gera o embedding da pergunta (mesmo modelo usado na indexação)
- Compara com todos os vetores no banco usando similaridade do cosseno
- Retorna os mais similares (acima de um score mínimo)
A similaridade do cosseno mede o ângulo entre dois vetores:
A · B
cos(θ) = ─────────────────────
||A|| × ||B||
- Score = 1.0 → vetores idênticos (mesmo significado)
- Score = 0.0 → sem relação
- Score < 0.4 → descartamos (muito distante)
Estratégia de Expansão por Documento
Um detalhe crucial: em vez de retornar apenas os chunks individuais com maior score, o sistema identifica os documentos mais relevantes e retorna todos os chunks de cada documento. Isso garante que o LLM receba o contexto completo:
Busca simples (sem expansão):
Pergunta: "Como configurar o servidor X?"
Resultado: [Chunk 3 do Doc A, Chunk 7 do Doc B, Chunk 1 do Doc C]
→ Contexto fragmentado, incompleto
Busca com expansão por documento:
Pergunta: "Como configurar o servidor X?"
1. Top chunks: Chunk 3 do Doc A (92%), Chunk 7 do Doc B (85%)
2. Documentos únicos: Doc A, Doc B
3. Resultado: [Todos os chunks do Doc A] + [Todos os chunks do Doc B]
→ Contexto completo de cada documento relevante!
Tabela de Custos de Embedding
Para referência, aqui está o custo aproximado de vetorizar diferentes volumes de dados:
| Volume | Tokens aprox. | Custo (embedding-3-small) |
|---|---|---|
| 100 documentos (curtos) | ~200K tokens | $0.004 |
| 1.000 documentos | ~2M tokens | $0.04 |
| 10.000 documentos | ~20M tokens | $0.40 |
| 100.000 documentos | ~200M tokens | $4.00 |
Dica: Os embeddings sao extremamente baratos. Mesmo com 100.000 documentos, o custo total de vetorizacao e de apenas ~$4.
5. O Agente de IA — Coração do Sistema
Agora vamos construir o cerebro do agente: o modulo que recebe a pergunta do usuario, monta todo o contexto (RAG + historico) e chama o LLM para gerar a resposta. Este modulo e composto por dois arquivos: agents/prompt.py (o system prompt) e agents/agent.py (a logica principal).
Arquivo: agents/prompt.py
O system prompt define a personalidade e as regras do agente. É aqui que você configura como ele deve se comportar:
# agents/prompt.py
system_message = """
Você é um Assistente Inteligente, projetado para auxiliar os usuários com respostas
contextualizadas, precisas e estruturadas, consultando a base de conhecimento fornecida.
Diretrizes de Atuação:
1. Síntese de Soluções: Não se limite a encontrar documentos. Sintetize a solução final
de forma clara, combinando múltiplas fontes quando necessário.
2. Pragmatismo e Agilidade: A resposta deve ser útil, objetiva e pronta para uso.
3. Precisão e Segurança: Baseie-se APENAS nas informações da base de conhecimento.
Jamais invente ou 'alucine' informações. Se algo não constar na base, informe ao usuário.
4. Detalhamento: Traga informações detalhadas e completas sobre o tópico, separando em
seções teóricas e práticas quando viável.
5. Formatação: Use Markdown para formatar as respostas (títulos, listas, blocos de código).
Caso a informação necessária não seja encontrada na base de conhecimento, responda
ao usuário que a informação não foi encontrada.
"""
Arquivo: agents/agent.py
Este é o módulo principal do agente. Vamos construí-lo passo a passo.
Setup e Tabela de Preços
# agents/agent.py
from agents.prompt import system_message
from openai import OpenAI
import logging
import os
try:
from dotenv import load_dotenv
load_dotenv(override=False)
except ImportError:
pass
logger = logging.getLogger(__name__)
client_openai = OpenAI(api_key=os.getenv("OPENAI_API_KEY"), timeout=60.0)
# ── Tabela de preços por modelo (USD por 1M tokens) ──────────────
MODEL_PRICES = {
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
"gpt-4o": {"input": 2.50, "output": 10.00},
"text-embedding-3-small": {"input": 0.02, "output": 0.00},
}
WHISPER_PRICE_PER_MINUTE = 0.006 # USD por minuto de áudio
def _calc_token_cost(model, usage):
"""Calcula o custo de uma chamada à API com base no modelo e no uso de tokens."""
prices = MODEL_PRICES.get(model, {"input": 0, "output": 0})
input_tokens = getattr(usage, "prompt_tokens", 0) or 0
output_tokens = getattr(usage, "completion_tokens", 0) or 0
cost = (input_tokens * prices["input"] / 1_000_000) + \
(output_tokens * prices["output"] / 1_000_000)
return round(cost, 6)
Construção do Contexto RAG
A função build_context transforma os resultados da busca vetorial em um bloco de texto que será injetado no prompt do LLM:
def build_context(matches):
"""
Monta o contexto RAG a partir dos documentos encontrados na busca vetorial.
Se nenhum documento foi encontrado, instrui o LLM a não inventar respostas.
"""
logger.info("Building context.")
if not matches:
return """
ATENÇÃO: Nenhuma informação relevante foi encontrada na base de conhecimento.
Você NÃO possui informações suficientes para responder.
NÃO invente informações. NÃO tente responder com base em conhecimento próprio.
Informe ao usuário que a informação não foi encontrada na base.
"""
context_parts = []
for i, match in enumerate(matches, 1):
score = match.get("score", 0)
source = match["metadata"].get("document_title", "desconhecido")
text = match["metadata"]["chunk_text"]
context_parts.append(f"[Fonte {i}: {source} | Relevância: {score:.0%}]\n{text}")
context = "\n\n---\n\n".join(context_parts)
return f"""
Use a seguinte base de conhecimento para responder o usuário:
Caso encontre informações divergentes, notifique o usuário.
INICIO DA BASE DE CONHECIMENTO: ===============
{context}
FIM DA BASE DE CONHECIMENTO ===================
"""
Chamada Principal ao LLM
A funcao call_open_ai orquestra tudo — monta as mensagens do sistema, injeta o contexto RAG e o historico da conversa:
def call_open_ai(matches, chat_history, extracted_texts=None):
"""
Faz a chamada ao LLM montando todo o contexto necessario.
Parametros:
- matches: resultados da busca vetorial (contexto RAG)
- chat_history: historico da conversa atual
- extracted_texts: transcricoes de audio / descricoes de imagem
Retorna: (resposta_texto, custo_usd)
"""
logger.info("Calling OpenAI.")
model = "gpt-4o-mini"
# Monta o contexto RAG:
prompt = build_context(matches=matches)
# Monta a sequencia de mensagens do sistema:
system_messages = [
{"role": "system", "content": system_message},
]
# Adiciona o contexto RAG:
system_messages.append({"role": "system", "content": prompt})
# Adiciona contexto de arquivos (áudio/imagem) se enviados:
if extracted_texts:
combined_extractions = "\n\n".join(extracted_texts)
system_messages.append({"role": "system", "content": (
"CONTEXTO ADICIONAL DO USUÁRIO:\n"
"O usuário enviou áudio(s) e/ou imagem(ns) junto a esta mensagem.\n"
"- Transcrições de áudio representam O QUE O USUÁRIO DISSE.\n"
"- Descrições de imagens mostram o conteúdo visual enviado.\n\n"
f"{combined_extractions}"
)})
# Faz a chamada ao LLM:
response = client_openai.chat.completions.create(
model=model,
messages=system_messages + chat_history[-20:], # Últimas 20 mensagens
)
cost = _calc_token_cost(model, response.usage)
logger.info(f"LLM call cost: ${cost:.6f}")
return response.choices[0].message.content, cost
Anatomia de uma Chamada ao LLM
A sequência de mensagens enviada ao GPT tem esta estrutura:
+-------------------------------------------------+
| [SYSTEM] Prompt de sistema (personalidade) | <-- Quem o agente e
+-------------------------------------------------+
| [SYSTEM] Base de conhecimento (RAG) | <-- Documentos relevantes
+-------------------------------------------------+
| [SYSTEM] Contexto multimodal (audio/imagem) | <-- Se houver anexos
+-------------------------------------------------+
| [USER] Mensagem 1 do historico | <-- Historico da conversa
| [ASSISTANT] Resposta 1 do historico |
| [USER] Mensagem 2 do historico |
| [ASSISTANT] Resposta 2 do historico |
| ... |
| [USER] Mensagem atual do usuario | <-- Pergunta atual
+-------------------------------------------------+
|
v
GPT processa tudo
|
v
Resposta fundamentada
Reescrita de Query para Busca
Em alguns casos, a mensagem do usuário pode ser ambígua ou depender do contexto da conversa. Uma técnica avançada é reescrever a query usando um modelo mais leve antes de fazer a busca vetorial:
def rewrite_query_for_search(chat_history, latest_message, extracted_texts=None):
"""
(Opcional) Reescreve a mensagem do usuário em uma query de busca
mais precisa, considerando o contexto da conversa.
Exemplo:
Histórico: "Estou com problema no servidor Apache"
Mensagem: "Como resolvo isso?"
Query: "Como resolver problema no servidor Apache"
Por padrão, usa a mensagem original (mais barato).
Descomente o código abaixo para ativar a reescrita via LLM.
"""
# Versão simples (sem custo adicional):
query = latest_message
if extracted_texts:
query += "\n\n" + "\n".join(extracted_texts)
return query, 0.0
# Versão com reescrita via LLM (descomente para ativar):
# model = "gpt-4o-mini"
# response = client_openai.chat.completions.create(
# model=model,
# messages=[
# {"role": "system", "content":
# "Dado o histórico de conversa, gere UMA FRASE CURTA "
# "(máximo 30 palavras) que capture o que o usuário "
# "quer saber AGORA. Responda APENAS com a frase."
# },
# *chat_history[-12:],
# {"role": "user", "content": latest_message}
# ],
# temperature=0
# )
# cost = _calc_token_cost(model, response.usage)
# return response.choices[0].message.content.strip(), cost
6. Processamento Multimodal: Áudio e Imagem
O agente não se limita a texto. Ele aceita áudio (transcrição via Whisper) e imagens (análise via GPT Vision), tornando-o verdadeiramente multimodal. Isso significa que o usuário pode:
- Gravar uma pergunta por voz em vez de digitar
- Anexar um print de erro e pedir ajuda
- Colar uma imagem de um dashboard ou configuracao
- Enviar um audio gravado com a descricao de um problema
Transcrição de Áudio (Whisper)
O Whisper é o modelo de speech-to-text da OpenAI. Ele suporta múltiplos idiomas e é extremamente preciso:
# Em agents/agent.py
def transcribe_audio(file_tuple):
"""
Transcreve um arquivo de áudio usando o Whisper da OpenAI.
Parâmetros:
- file_tuple: tupla (nome_do_arquivo, bytes_do_arquivo)
Retorna: (texto_transcrito, custo_usd)
"""
logger.info(f"Transcribing audio: {file_tuple[0]}")
try:
transcript = client_openai.audio.transcriptions.create(
model="whisper-1",
file=file_tuple,
language="pt", # Idioma do áudio (melhora a precisão)
response_format="verbose_json" # Retorna duração para calcular custo
)
# Calcula o custo baseado na duração do áudio:
duration_minutes = (transcript.duration or 0) / 60.0
cost = round(duration_minutes * WHISPER_PRICE_PER_MINUTE, 6)
logger.info(f"Whisper transcription: {transcript.duration:.1f}s, cost=${cost:.6f}")
return transcript.text, cost
except Exception as e:
logger.error(f"Error transcribing audio: {e}")
return f"[Erro ao transcrever áudio: {e}]", 0.0
Detalhes sobre o Whisper
| Característica | Valor |
|---|---|
| Modelo | whisper-1 |
| Formatos suportados | mp3, mp4, mpeg, mpga, m4a, wav, webm |
| Tamanho máximo | 25 MB |
| Custo | $0.006/minuto (~$0.36/hora) |
| Idiomas | 50+ idiomas (auto-detecção disponível) |
Análise de Imagem (GPT Vision)
O GPT Vision permite ao agente “ver” imagens enviadas pelo usuário. Isso é especialmente útil para analisar prints de erro, screenshots de configuração ou qualquer conteúdo visual:
# Em agents/agent.py
def describe_image(base64_image, mime_type):
"""
Analisa uma imagem usando o GPT Vision.
Parâmetros:
- base64_image: imagem codificada em base64
- mime_type: tipo MIME da imagem (ex: "image/png")
Retorna: (descrição_textual, custo_usd)
"""
logger.info("Describing image.")
model = "gpt-4o"
try:
response = client_openai.chat.completions.create(
model=model,
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": (
"Por favor, extraia todo o texto visível nesta imagem "
"e descreva brevemente seu contexto ou elementos visuais "
"importantes. Se for um print de erro, transcreva o erro."
)
},
{
"type": "image_url",
"image_url": {
"url": f"data:{mime_type};base64,{base64_image}",
"detail": "high" # "low" = mais barato, "high" = mais preciso
}
}
]
}
],
max_tokens=700
)
cost = _calc_token_cost(model, response.usage)
logger.info(f"Image description cost: ${cost:.6f}")
return response.choices[0].message.content, cost
except Exception as e:
logger.error(f"Error describing image: {e}")
return f"[Erro ao processar imagem: {e}]", 0.0
Como os Arquivos São Processados no Endpoint /api/chat
Quando o usuário envia arquivos junto com a mensagem, o endpoint detecta o tipo (multipart/form-data) e processa cada arquivo:
# Em app.py, dentro da rota /api/chat:
if request.content_type and request.content_type.startswith("multipart/form-data"):
latest_message = request.form.get("latest_message", "")
conversation_id = request.form.get("conversation_id")
extracted_texts = []
files_count = 0
files = request.files.getlist("files")
for file in files:
if not file.filename:
continue
mime_type = file.mimetype
file_bytes = file.read()
# Processa áudio:
if mime_type.startswith("audio/"):
transcript, cost = transcribe_audio((file.filename, file_bytes))
request_cost += cost
if transcript:
extracted_texts.append(
f"[Transcrição de Áudio ({file.filename})]:\n{transcript}"
)
files_count += 1
# Processa imagem:
elif mime_type.startswith("image/"):
base64_img = base64.b64encode(file_bytes).decode('utf-8')
description, cost = describe_image(base64_img, mime_type)
request_cost += cost
if description:
extracted_texts.append(
f"[Conteúdo Extraído da Imagem ({file.filename})]:\n{description}"
)
files_count += 1
Fluxo Multimodal Completo
Usuário envia: "Ajude com esse erro" + screenshot.png + audio.webm
│
▼
┌──────────────────────────────────────────────┐
│ Flask recebe multipart/form-data │
│ │
│ 1. Detecta screenshot.png (image/png) │
│ → GPT Vision analisa │
│ → "A imagem mostra um erro 500..." │
│ │
│ 2. Detecta audio.webm (audio/webm) │
│ → Whisper transcreve │
│ → "Estou tentando acessar a página..." │
│ │
│ 3. Monta extracted_texts: │
│ [ │
│ "[Conteúdo da Imagem]: erro 500...", │
│ "[Transcrição]: tentando acessar..." │
│ ] │
└──────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Fluxo RAG normal + extracted_texts │
│ │
│ O contexto multimodal é injetado como uma │
│ mensagem de sistema adicional para o LLM │
└──────────────────────────────────────────────┘
│
▼
Resposta considerando
texto + imagem + áudio
Limites e Boas Práticas
| Tipo | Limite Recomendado | Motivo |
|---|---|---|
| Áudio | 25 MB por arquivo | Limite da API Whisper |
| Imagem | 20 MB por arquivo | Performance e custo |
| Arquivos por mensagem | 10 | Evita custos excessivos |
| detail do Vision | "high" para textos, "low" para fotos | Custo vs. precisão |
Dica de UX: No frontend, permita ao usuario gravar audio diretamente pelo microfone do navegador usando a
MediaRecorder API. Isso e muito mais conveniente do que enviar um arquivo de audio.
7. API REST com Flask
Agora vamos construir o app.py — o arquivo principal que amarra tudo. Ele expoe a API REST que o frontend consome e orquestra autenticacao e chat.
Mapa Completo de Rotas
| Método | Rota | Proteção | Descrição |
|---|---|---|---|
GET | / | Pública | Serve o frontend (index.html) |
POST | /api/register | Rate limit | Registro de novo usuário |
POST | /api/login | Rate limit | Login (cria sessão) |
POST | /api/logout | – | Logout (limpa sessão) |
POST | /api/reset-password-offline | Rate limit | Troca de senha (sem login) |
GET | /api/me | Login | Dados do usuário logado |
PUT | /api/me/profile | Login | Atualizar perfil |
GET | /api/conversations | Login | Listar conversas |
POST | /api/conversations | Login | Criar conversa |
GET | /api/conversations/:id/messages | Login | Mensagens de uma conversa |
DELETE | /api/conversations/:id | Login | Deletar conversa |
PUT | /api/conversations/:id/title | Login | Renomear conversa |
POST | /api/chat | Login | Endpoint principal do agente |
Setup Inicial do app.py
# app.py
from datetime import timedelta
from flask import Flask, request, jsonify, session, send_from_directory
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from werkzeug.security import generate_password_hash, check_password_hash
import logging
from logging.handlers import RotatingFileHandler
import uuid
import os
import re
import functools
import base64
# Carrega variáveis do .env:
try:
from dotenv import load_dotenv
load_dotenv(override=False)
except ImportError:
pass
from database.database import (
collect_messages, insert_message, create_tables, search_vectors_by_page,
create_conversation, list_conversations, get_conversation_messages,
delete_conversation, update_conversation_title, touch_conversation,
create_user, get_user_by_username, get_user_by_email, update_user_profile
)
from agents.agent import call_open_ai, rewrite_query_for_search, transcribe_audio, describe_image
from agents.embedding import embed_string
Configuração de Logging
# Configura logging para stdout e arquivo rotativo:
LOG_DIR = os.getenv("LOG_DIR", os.path.join(os.path.dirname(__file__), "logs"))
os.makedirs(LOG_DIR, exist_ok=True)
_log_formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s")
_file_handler = RotatingFileHandler(
os.path.join(LOG_DIR, "flask.log"),
maxBytes=5 * 1024 * 1024, # 5 MB por arquivo
backupCount=3, # Mantém 3 backups
encoding="utf-8",
)
_file_handler.setFormatter(_log_formatter)
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(name)s - %(message)s")
logging.getLogger().addHandler(_file_handler)
logger = logging.getLogger(__name__)
Inicialização do Flask
# Garante que as tabelas existam no startup:
try:
create_tables()
except Exception as e:
logger.warning(f"Não foi possível criar tabelas no startup: {e}")
app = Flask(__name__, static_folder="static")
# Chave secreta obrigatória para sessões:
_secret_key = os.getenv("FLASK_SECRET_KEY")
if not _secret_key:
raise RuntimeError("FLASK_SECRET_KEY não configurada!")
app.secret_key = _secret_key
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30)
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["SESSION_COOKIE_SECURE"] = os.getenv("COOKIE_SECURE", "false").lower() == "true"
# Rate limiter (proteção contra abuso):
limiter = Limiter(
get_remote_address,
app=app,
storage_uri="memory://",
default_limits=[]
)
Headers de Segurança
@app.after_request
def set_security_headers(response):
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
return response
Decorators de Autenticação
def login_required(f):
"""Decorator que exige autenticação."""
@functools.wraps(f)
def decorated(*args, **kwargs):
if "user_id" not in session:
return jsonify({"error": "Não autenticado"}), 401
return f(*args, **kwargs)
return decorated
Autenticação (Registro, Login, Logout)
@app.route("/")
def serve_index():
return send_from_directory(app.static_folder, "index.html")
@app.route("/api/register", methods=["POST"])
@limiter.limit("5 per minute")
def register():
data = request.get_json() or {}
username = data.get("username", "").strip().lower()
email = data.get("email", "").strip().lower()
display_name = data.get("display_name", "").strip()
password = data.get("password", "")
confirm_password = data.get("confirm_password", "")
# Validações:
if not username or not email or not display_name or not password:
return jsonify({"error": "Todos os campos são obrigatórios"}), 400
if len(username) < 3 or len(username) > 50:
return jsonify({"error": "Usuário deve ter entre 3 e 50 caracteres"}), 400
if not re.match(r'^[a-z0-9_.-]+$', username):
return jsonify({"error": "Username inválido. Use letras, números, _ . -"}), 400
if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', email):
return jsonify({"error": "E-mail inválido"}), 400
if len(password) < 8:
return jsonify({"error": "Senha deve ter no mínimo 8 caracteres"}), 400
if password != confirm_password:
return jsonify({"error": "As senhas não conferem"}), 400
# Verifica duplicatas:
if get_user_by_username(username):
return jsonify({"error": "Username já em uso"}), 409
if get_user_by_email(email):
return jsonify({"error": "E-mail já cadastrado"}), 409
try:
password_hash = generate_password_hash(password)
user = create_user(username=username, email=email,
display_name=display_name, password_hash=password_hash)
logger.info(f"Novo usuário registrado | username={username}")
return jsonify({"message": "Usuário criado com sucesso"}), 201
except Exception as e:
logger.error(f"Erro ao criar usuário: {e}")
return jsonify({"error": "Erro ao criar usuário"}), 500
@app.route("/api/login", methods=["POST"])
@limiter.limit("10 per minute")
def login():
data = request.get_json() or {}
username = data.get("username", "").strip().lower()
password = data.get("password", "")
if not username or not password:
return jsonify({"error": "Usuário e senha são obrigatórios"}), 400
user = get_user_by_username(username)
# Mensagem genérica (não revela qual campo está errado):
if not user or not check_password_hash(user["password_hash"], password):
return jsonify({"error": "Usuário ou senha inválidos"}), 401
session.permanent = True
session["user_id"] = user["id"]
session["display_name"] = user["display_name"]
return jsonify({
"user_id": user["id"],
"display_name": user["display_name"]
})
@app.route("/api/logout", methods=["POST"])
def logout():
session.clear()
return jsonify({"message": "Logout realizado com sucesso"})
O Endpoint Principal: /api/chat
Este é o endpoint que orquestra todo o fluxo RAG:
@app.route("/api/chat", methods=["POST"])
@login_required
def chat_with_AI():
user_id = session["user_id"]
request_cost = 0.0 # Acumula o custo total da requisição
# ── 1. Processa a entrada (texto, áudio, imagem) ────────────
if request.content_type and request.content_type.startswith("multipart/form-data"):
latest_message = request.form.get("latest_message", "")
conversation_id = request.form.get("conversation_id")
extracted_texts = []
files_count = 0
files = request.files.getlist("files")
for file in files:
if not file.filename:
continue
mime_type = file.mimetype
file_bytes = file.read()
if mime_type.startswith("audio/"):
transcript, cost = transcribe_audio((file.filename, file_bytes))
request_cost += cost
if transcript:
extracted_texts.append(f"[Transcrição ({file.filename})]:\n{transcript}")
files_count += 1
elif mime_type.startswith("image/"):
base64_img = base64.b64encode(file_bytes).decode('utf-8')
description, cost = describe_image(base64_img, mime_type)
request_cost += cost
if description:
extracted_texts.append(f"[Imagem ({file.filename})]:\n{description}")
files_count += 1
if not latest_message and extracted_texts:
latest_message = f"*[Anexado {len(extracted_texts)} arquivo(s)]*"
else:
extracted_texts = None
files_count = 0
data = request.get_json()
latest_message = data.get("latest_message")
conversation_id = data.get("conversation_id")
# Validações:
if not latest_message:
return jsonify({"error": "Mensagem ou anexo obrigatório"}), 400
if len(latest_message) > 8000:
return jsonify({"error": "Mensagem muito longa (máx. 8000 chars)"}), 400
if not conversation_id:
return jsonify({"error": "conversation_id obrigatório"}), 400
# ── 2. Salva mensagem do usuário ────────────────────────────
insert_message(user_id=user_id, role="user",
content=latest_message, conversation_id=conversation_id)
# ── 3. Carrega histórico da conversa ────────────────────────
messages = collect_messages(user_id, limit=20, conversation_id=conversation_id)
chat_history = [{"role": role, "content": content} for role, content, _ in messages]
# ── 4. Prepara a query de busca ─────────────────────────────
search_query, rewrite_cost = rewrite_query_for_search(
chat_history=chat_history,
latest_message=latest_message,
extracted_texts=extracted_texts
)
request_cost += rewrite_cost
# ── 5. Gera embedding da pergunta ───────────────────────────
question_embeddings, embed_cost = embed_string(search_query)
request_cost += embed_cost
# ── 6. Busca vetorial na base de conhecimento ───────────────
all_matches = search_vectors_by_page(question_embeddings=question_embeddings)
# ── 7. Busca exemplos de feedback similares ─────────────────
feedback_examples = []
try:
feedback_examples = search_feedback_examples(question_embeddings=question_embeddings)
except Exception as e:
logger.warning(f"Erro ao buscar feedback: {e}")
# ── 8. Determina qual prompt usar ───────────────────────────
user_custom = get_user_custom_prompt(user_id)
global_prompt = get_app_config('global_prompt') if not user_custom else None
# ── 9. Chama o LLM ─────────────────────────────────────────
response, llm_cost = call_open_ai(
matches=all_matches,
chat_history=chat_history,
feedback_examples=feedback_examples,
custom_prompt=user_custom or global_prompt,
extracted_texts=extracted_texts
)
request_cost += llm_cost
# ── 10. Extrai fontes consultadas ───────────────────────────
sources = []
seen_links = set()
for match in all_matches:
metadata = match.get("metadata", {})
title = metadata.get("document_title", "Documento Sem Título")
link = metadata.get("document_url", "")
if link and link not in seen_links:
seen_links.add(link)
sources.append({"title": title, "url": link})
# ── 11. Salva resposta e metadados ──────────────────────────
metadata_to_save = {"sources": sources} if sources else {}
metadata_to_save["cost"] = round(request_cost, 6)
if files_count > 0:
metadata_to_save["files_count"] = files_count
logger.info(f"Request total cost: ${request_cost:.6f}")
insert_message(user_id=user_id, role="assistant", content=response,
conversation_id=conversation_id, metadata=metadata_to_save)
touch_conversation(conversation_id)
return jsonify({"reply_message": response, "sources": sources})
Inicialização
if __name__ == '__main__':
app.run(debug=True, use_reloader=False, host='0.0.0.0')
Seguranca implementada: Rate limiting nos endpoints sensiveis, hashing de senhas com Werkzeug (bcrypt), sessoes HTTPOnly com SameSite, headers de seguranca e validacao de inputs.
8. Frontend: Interface de Chat
Para o frontend, vamos criar uma Single Page Application (SPA) simples mas poderosa usando HTML, CSS e JavaScript puros (vanilla). O objetivo e uma interface que pareca um aplicativo nativo, com transicoes suaves e design premium.
Estrutura HTML (static/index.html)
O HTML foca em containers semânticos que serão preenchidos dinamicamente pelo JavaScript:
<!DOCTYPE html>
<html lang="pt-br">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Agente IA - Assistente Inteligente</title>
<link rel="stylesheet" href="styles.css">
<!-- Markdown Rendering -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<!-- Syntax Highlighting -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
</head>
<body class="dark-mode">
<div id="app">
<!-- Sidebar: Histórico de Conversas -->
<aside id="sidebar">
<div class="sidebar-header">
<button id="new-chat-btn">+ Nova Conversa</button>
</div>
<nav id="conversation-list">
<!-- Preenchido via JS -->
</nav>
<div class="sidebar-footer">
<span id="user-display-name">Carregando...</span>
<button id="settings-btn">Config</button>
</div>
</aside>
<!-- Main: Chat Interface -->
<main id="chat-container">
<header id="chat-header">
<h2 id="current-chat-title">Selecione uma conversa</h2>
<div class="header-actions">
<button id="logout-btn">Sair</button>
</div>
</header>
<div id="messages-display">
<!-- Mensagens aparecem aqui -->
<div class="welcome-screen">
<h1>Olá! Como posso ajudar hoje?</h1>
<p>Eu tenho acesso à sua base de conhecimento e posso processar texto, áudio e imagens.</p>
</div>
</div>
<footer id="chat-input-area">
<div class="input-wrapper">
<div id="attachment-preview" class="hidden"></div>
<textarea id="chat-input" placeholder="Digite sua mensagem..." rows="1"></textarea>
<div class="actions">
<label for="file-upload" class="icon-btn" title="Anexar arquivos">Anexar</label>
<input type="file" id="file-upload" multiple hidden>
<button id="mic-btn" class="icon-btn" title="Gravar audio">Gravar</button>
<button id="send-btn" class="primary-btn">Enviar</button>
</div>
</div>
</footer>
</main>
</div>
<!-- Modais (Login, Configurações) omitidos para brevidade -->
<script src="app.js"></script>
</body>
</html>
Estilização Premium (static/styles.css)
Usamos variáveis CSS para facilitar a manutenção de temas e garantir um visual moderno (Glassmorphism):
:root {
--bg-main: #0f172a;
--bg-sidebar: #1e293b;
--accent: #3b82f6;
--text-main: #f8fafc;
--glass: rgba(255, 255, 255, 0.05);
--border: rgba(255, 255, 255, 0.1);
}
body {
background-color: var(--bg-main);
color: var(--text-main);
font-family: 'Inter', sans-serif;
margin: 0;
display: flex;
height: 100vh;
}
#app {
display: flex;
width: 100%;
}
/* Sidebar */
#sidebar {
width: 260px;
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
}
/* Chat area */
#chat-container {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
}
#messages-display {
flex: 1;
overflow-y: auto;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Bolhas de mensagem */
.message {
max-width: 80%;
padding: 1rem;
border-radius: 12px;
line-height: 1.6;
}
.message.user {
align-self: flex-end;
background: var(--accent);
color: white;
}
.message.assistant {
align-self: flex-start;
background: var(--glass);
border: 1px solid var(--border);
}
/* Input area */
.input-wrapper {
margin: 1.5rem;
background: var(--glass);
border: 1px solid var(--border);
border-radius: 16px;
padding: 0.8rem;
backdrop-filter: blur(10px);
}
#chat-input {
width: 100%;
background: transparent;
border: none;
color: white;
resize: none;
outline: none;
font-size: 1rem;
}
Lógica do Frontend (static/app.js)
O JavaScript gerencia o estado da aplicação, faz as chamadas à API e renderiza o conteúdo. Aqui está o núcleo da função de envio:
// static/app.js
async function sendMessage() {
const text = chatInput.value.trim();
const files = fileUpload.files;
if (!text && files.length === 0) return;
// 1. Interface: Mostra mensagem do usuário imediatamente
addMessageToDisplay('user', text || "Arquivo(s) enviado(s)");
chatInput.value = '';
// 2. Prepara os dados (suporte a multimodal)
const formData = new FormData();
formData.append('latest_message', text);
formData.append('conversation_id', currentConversationId);
for (let i = 0; i < files.length; i++) {
formData.append('files', files[i]);
}
try {
// 3. Chamada à API
const response = await fetch('/api/chat', {
method: 'POST',
body: formData // Fetch detecta FormData e define Content-Type automaticamente
});
const data = await response.json();
if (response.ok) {
// 4. Renderiza resposta da IA com Markdown
addMessageToDisplay('assistant', data.reply_message, data.sources);
} else {
showToast(data.error || "Erro ao enviar mensagem", "error");
}
} catch (error) {
console.error("Erro no chat:", error);
showToast("Falha na conexão com o servidor", "error");
}
}
Funcionalidades Chave do Frontend
- Renderização de Markdown: Usamos a biblioteca
markedpara transformar o texto da IA em HTML formatado, permitindo tabelas, listas e links. - Highlight de Código: O
highlight.jsdetecta blocos de código e aplica cores de sintaxe automaticamente. - Auto-resize do Input: O campo de texto expande conforme o usuario digita.
- Gravacao de Audio: Implementacao da
MediaRecorder APIpara capturar audio do microfone e enviar diretamente ao Whisper.
UX Tip: Adicione uma animacao de “digitando…” (typing indicator) enquanto espera a resposta da API. Isso reduz a percepcao de latencia do usuario.
9. Deploy com Docker
Para garantir que nosso agente rode em qualquer lugar, vamos containerizá-lo usando Docker. Como temos dois processos (o servidor Flask e o sincronizador de background), usaremos um script de entrypoint para gerenciar ambos.
Arquivo: Dockerfile
# Usa uma imagem Python leve
FROM python:3.12-slim
# Instala dependências do sistema para SQLite e extensões
RUN apt-get update && apt-get install -y \
build-essential \
python3-dev \
sqlite3 \
&& rm -rf /var/lib/apt/lists/*
# Define o diretório de trabalho
WORKDIR /app
# Copia e instala dependências Python
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copia o código da aplicação
COPY . .
# Cria diretórios para logs e banco de dados
RUN mkdir -p logs data
# Torna o entrypoint executável
RUN chmod +x entrypoint.sh
# Porta que o Flask vai rodar
EXPOSE 5000
# Executa o script de inicialização
ENTRYPOINT ["./entrypoint.sh"]
Arquivo: entrypoint.sh
#!/bin/bash
set -e
# Cria os diretorios necessarios
mkdir -p /app/data/source_data
mkdir -p /app/logs
# Inicia o servidor Flask
echo "Iniciando Servidor Flask (API)..."
exec python app.py
Como rodar a aplicação
Com os arquivos prontos, basta executar:
# 1. Build da imagem
docker build -t meu-agente-ia .
# 2. Rodar o container passando o arquivo .env
docker run -d \
--name agente-ia \
-p 5000:5000 \
--env-file .env \
-v $(pwd)/data:/app/data \
-v $(pwd)/logs:/app/logs \
meu-agente-ia
Conclusão do projeto
Construir um agente de IA do zero é uma jornada que envolve diversas áreas: engenharia de dados (pipeline), banco de dados (vetorial), IA (LLMs e Embeddings) e desenvolvimento web (API e Frontend).
Com esta estrutura que criamos, voce tem uma base solida, escalavel e modular para criar assistentes inteligentes que realmente entendem o contexto da sua organizacao e processam informacoes multimodais.
About author
Você pode gostar também
Compartilhando conhecimento em IA – LLMs Conversando em Silêncio: A Revolução que Você Não Vai Ver (Mas Vai Sentir)
Se você acha que o máximo de inovação em IA é um chatbot que responde perguntas mais rápido, preciso te contar: pesquisadores acabaram de mudar completamente o jogo. Dois artigos
Entrevista com Flavio Gurgel: Especialista discute sobre PostgreSQL
A 4Linux conversou sobre o PostgreSQL com Flavio Gurgel, entusiasta do software livre e especialista em banco de dados há quase 20 anos. Gurgel, atualmente, presta consultoria, suporte e treinamento
Participe do Darkmira Tour 2019 e aprenda sobre PHP com a 4Linux
4Linux estará presente no Darkmira Tour 2019 com a palestra: MIGRATIONS PARA APLICAÇÕES PHP UTILIZANDO PHINX E aí, você conhece o Darkmira Tour PHP? Este é um evento imperdível, que





