Como Construir um Agente de IA com RAG do Zero: Guia Completo

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?

AspectoFine-tuningRAG
Atualização dos dadosRequer retreino (caro e lento)Atualiza a base vetorial (rápido)
CustoAlto (treinamento + hosting)Baixo (só API de embeddings)
TransparênciaCaixa-pretaMostra as fontes consultadas
AlucinaçõesDifícil controlarMitigadas pela base de conhecimento
ComplexidadeRequer infra de MLPython + 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

CamadaTecnologiaFuncao
BackendPython 3.12 + FlaskAPI REST, logica de negocio
Banco de DadosSQLite + sqlite-vssDados + busca vetorial
IA / LLMOpenAI API (GPT, Whisper, Embeddings, Vision)Geracao, transcricao, embeddings, analise de imagens
FrontendHTML + CSS + JavaScript vanillaInterface de chat
DeployDockerContainerizacao

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?

DiretorioResponsabilidadePrincipio
agents/Toda a logica de IA (LLM, embeddings, prompts)Separacao de responsabilidades
database/Acesso a dados, schema, queriesCamada de dados isolada
static/Frontend servido pelo FlaskSimplicidade (sem build step)
logs/Arquivos de log rotativosObservabilidade

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 .env ao seu .gitignore imediatamente! 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

  1. Acesse platform.openai.com
  2. Vá em API KeysCreate new secret key
  3. Copie a chave e cole no campo OPENAI_API_KEY do seu .env
  4. 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:

ModeloUsoCusto (por 1M tokens)
gpt-4o-miniGeração de respostas (LLM principal)$0.15 input / $0.60 output
text-embedding-3-smallGeração de embeddings$0.02 input
whisper-1Transcrição de áudio$0.006/minuto
gpt-4oAnálise de imagens (Vision)$2.50 input / $10.00 output

Dica: Para desenvolvimento, o gpt-4o-mini oferece 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:

TabelaFuncao
usersCadastro de usuarios (login, senha hash, perfil)
conversationsSessoes de chat (cada usuario pode ter varias)
chat_messagesMensagens individuais (user/assistant) com metadados
embeddingsVetores 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) usando numpy.tobytes(). A busca por similaridade e feita em Python calculando a distancia do cosseno. Para projetos maiores, considere usar sqlite-vss ou migrar para PostgreSQL com pgvector.

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-vss para índices HNSW, ou migrar para PostgreSQL com pgvector.


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?

ModeloDimensõesCusto/1M tokensPerformance
text-embedding-3-small1536$0.02Boa (recomendado)
text-embedding-3-large3072$0.13Melhor
text-embedding-ada-0021536$0.10Legacy

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:

  1. Gera o embedding da pergunta (mesmo modelo usado na indexação)
  2. Compara com todos os vetores no banco usando similaridade do cosseno
  3. 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:

VolumeTokens 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ísticaValor
Modelowhisper-1
Formatos suportadosmp3, mp4, mpeg, mpga, m4a, wav, webm
Tamanho máximo25 MB
Custo$0.006/minuto (~$0.36/hora)
Idiomas50+ 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

TipoLimite RecomendadoMotivo
Áudio25 MB por arquivoLimite da API Whisper
Imagem20 MB por arquivoPerformance e custo
Arquivos por mensagem10Evita custos excessivos
detail do Vision"high" para textos, "low" para fotosCusto 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étodoRotaProteçãoDescrição
GET/PúblicaServe o frontend (index.html)
POST/api/registerRate limitRegistro de novo usuário
POST/api/loginRate limitLogin (cria sessão)
POST/api/logoutLogout (limpa sessão)
POST/api/reset-password-offlineRate limitTroca de senha (sem login)
GET/api/meLoginDados do usuário logado
PUT/api/me/profileLoginAtualizar perfil
GET/api/conversationsLoginListar conversas
POST/api/conversationsLoginCriar conversa
GET/api/conversations/:id/messagesLoginMensagens de uma conversa
DELETE/api/conversations/:idLoginDeletar conversa
PUT/api/conversations/:id/titleLoginRenomear conversa
POST/api/chatLoginEndpoint 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

  1. Renderização de Markdown: Usamos a biblioteca marked para transformar o texto da IA em HTML formatado, permitindo tabelas, listas e links.
  2. Highlight de Código: O highlight.js detecta blocos de código e aplica cores de sintaxe automaticamente.
  3. Auto-resize do Input: O campo de texto expande conforme o usuario digita.
  4. Gravacao de Audio: Implementacao da MediaRecorder API para 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 &amp;&amp; apt-get install -y \
    build-essential \
    python3-dev \
    sqlite3 \
    &amp;&amp; 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.


Anterior A IA não está mais só completando código. Ela já está recebendo tarefas inteiras

About author

Você pode gostar também