Construindo um chat-com-Excel híbrido: Ollama off-line ou GPT-4o-mini on-line, velocidade, custo e código em um só lugar

Com o avanço das LLMs e a proliferação de interfaces naturais, tornou-se cada vez mais comum sonhar com aplicações que “conversam com seus dados”. PDFs, planilhas, bancos SQLite — tudo pode virar fonte de sabedoria se bem interpretado por um bom modelo de linguagem. Mas na prática, o que vemos são aplicações lentas, pesadas ou caras demais para POCs simples. Foi diante dessa realidade que surgiu esta Prova de Conceito: uma aplicação leve e híbrida que permite ao usuário conversar com sua planilha Excel usando um modelo local (via Ollama) ou um modelo online (GPT-4o-mini da OpenAI). A ideia é simples, mas poderosa: para perguntas fechadas como “quantas linhas?” ou “quais colunas existem?”, o sistema responde instantaneamente com pandas. Já perguntas abertas como “quais insights você vê?” são roteadas para um pipeline RAG com indexação vetorial usando LlamaIndex e consulta por LLM.

A arquitetura dessa PoC é compacta e clara. O frontend foi feito com TailwindCSS e JavaScript puro, mantendo um HTML estático que pode rodar até em navegadores offline. O backend usa FastAPI e três endpoints principais: /upload, que recebe a planilha e configura o índice vetorial; /chat, que testa se o modelo está respondendo com um prompt simples (“Olá, tudo bem?”); e /query, que recebe as perguntas reais. Durante o upload, a planilha é lida com pandas, convertida em documentos com DoclingReader, segmentada em blocos de 128 tokens com MarkdownNodeParser e indexada em um VectorStoreIndex. No frontend, o usuário pode alternar entre o backend local (Ollama) e remoto (OpenAI), e digitar a chave da OpenAI apenas quando necessário. O backend alterna dinamicamente entre embeddings HuggingFace e OpenAI, conforme o backend selecionado. Um sistema de fallback garante que perguntas simples nunca gastem tokens, enquanto perguntas analíticas são escaladas para o LLM.

🔧 Backend em FastAPI (híbrido)

O backend FastAPI tem menos de 200 linhas e cuida de todas as tarefas pesadas. O trecho abaixo mostra como o endpoint /upload lê o Excel, prepara os embeddings e guarda o contexto da sessão:

@app.post("/upload")
async def upload(file: UploadFile = File(...), model: str = Form(...),
                 backend: str = Form("ollama"), openai_key: str = Form(None)):
    df = pd.read_excel(file.file)
    if df.shape[0] > 50:
        return JSONResponse({"error":"Máx. 50 linhas na PoC"}, 400)

    llm, emb = get_llm(backend, model, openai_key)
    Settings.llm, Settings.embed_model = llm, emb
    docs = SimpleDirectoryReader(tempfile.gettempdir(),
              file_extractor={".xlsx": DoclingReader()}).load_data()
    index = VectorStoreIndex.from_documents(
        docs, transformations=[MarkdownNodeParser(chunk_size=128)])
    qe = index.as_query_engine(similarity_top_k=1, streaming=False)
    sid = str(uuid.uuid4())
    sessions[sid] = dict(qe=qe, df=df)
    return {"session_id": sid, "columns": df.columns.tolist(), "n_rows": df.shape[0]}

O endpoint /query aplica regex para detectar perguntas fechadas e redireciona o restante para o RAG. Com isso, 90% das perguntas simples são respondidas em menos de 0,1s.

🎨 Frontend leve com Tailwind e JavaScript

A escolha por não usar Streamlit foi estratégica: Streamlit é pesado e exige Python no navegador. Com um simples HTML e JS, conseguimos um frontend bonito, leve e funcional. O usuário pode escolher entre Ollama e OpenAI, colar a chave da OpenAI (se necessário), fazer upload da planilha e perguntar. Durante o carregamento, o sistema bloqueia novos inputs até que a resposta esteja pronta, evitando múltiplas requisições paralelas ao backend.

<!DOCTYPE html>
<html lang="pt-BR">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>Chat com Excel</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <script>
    tailwind.config = {
      theme: { extend: { fontFamily: { sans: ['Inter','sans-serif'] } } }
    }
  </script>
  <link rel="stylesheet" href="style.css" />
</head>
<body class="bg-gray-100 flex items-center justify-center min-h-screen font-sans">
  <div id="app-container" class="bg-white rounded-lg shadow-xl w-full max-w-2xl flex flex-col overflow-hidden" style="height:85vh;">
    <header class="bg-indigo-600 text-white p-4 flex justify-between items-center">
      <h1 class="text-xl font-semibold">Chat com Excel</h1>
      <div id="api-status" class="text-sm font-medium px-3 py-1 rounded-full status-checking">Verificando API</div>
    </header>

    <!-- CONTROLS -->
    <div class="p-3 border-b bg-gray-50 flex flex-col space-y-2">
      <div class="flex space-x-3">
        <label class="text-sm font-medium text-gray-700">Backend:</label>
        <select id="backend-select" class="p-2 border rounded-md text-sm">
          <option value="ollama">Ollama Local</option>
          <option value="openai">OpenAI API</option>
        </select>
        <label class="text-sm font-medium text-gray-700">Modelo:</label>
        <select id="model-select" class="flex-grow p-2 border rounded-md text-sm"></select>
      </div>
      <div id="openai-key-row" class="flex space-x-2 items-center" style="display:none;">
        <label class="text-sm font-medium text-gray-700">OpenAI Key:</label>
        <input id="openai-key" type="password" placeholder="sk-..." class="p-2 border rounded-md text-sm flex-grow" />
      </div>
      <div class="flex space-x-3">
        <input type="file" id="file-input" accept=".xlsx,.xls" class="border p-2 text-sm"/>
        <button id="btn-upload" class="bg-indigo-600 text-white px-4 py-2 rounded-md disabled:opacity-50">Upload</button>
      </div>
    </div>
    <div id="info" class="p-2 text-sm text-gray-600"></div>

    <!-- CHAT BOX -->
    <div id="chatbox" class="flex-grow overflow-y-auto p-4 space-y-4 bg-white">
      <div class="message system-message"><p>Faça upload de um Excel para começar.</p></div>
    </div>
    <div id="loading-indicator" class="p-2 text-center text-sm text-gray-500 italic" style="display:none;">
      Pensando...
    </div>

    <!-- INPUT -->
    <footer class="p-3 border-t bg-gray-50 flex space-x-3">
      <textarea id="user-input" rows="1" placeholder="Digite sua pergunta…"
        class="flex-grow p-2 border rounded-md resize-none text-sm" disabled></textarea>
      <button id="btn-ask" class="bg-indigo-600 text-white px-4 py-2 rounded-md disabled:opacity-50" disabled>
        Perguntar
      </button>
    </footer>
  </div>

  <script>
  document.addEventListener('DOMContentLoaded', () => {
    const API_BASE = 'http://localhost:8000';

    const apiStatus     = document.getElementById('api-status');
    const backendSelect = document.getElementById('backend-select');
    const modelSelect   = document.getElementById('model-select');
    const fileInput     = document.getElementById('file-input');
    const btnUpload     = document.getElementById('btn-upload');
    const info          = document.getElementById('info');
    const chatbox       = document.getElementById('chatbox');
    const loading       = document.getElementById('loading-indicator');
    const userInput     = document.getElementById('user-input');
    const btnAsk        = document.getElementById('btn-ask');
    const openaiKeyRow  = document.getElementById('openai-key-row');
    const openaiKeyInput= document.getElementById('openai-key');

    let sessionId = null;
    let isReady   = false;
    let isBusy    = false;
    let backend   = 'ollama'; // default

    function addMessage(role, text) {
      const div = document.createElement('div');
      div.className = 'message ' + 
        (role==='user' ? 'user-message' 
         : role==='assistant' ? 'ai-message' 
         : 'system-message');
      const c = document.createElement('div');
      c.innerText = text;
      div.appendChild(c);
      chatbox.appendChild(div);
      chatbox.scrollTop = chatbox.scrollHeight;
    }

    function setStatus(st) {
      apiStatus.textContent = st;
      apiStatus.className = 'text-sm font-medium px-3 py-1 rounded-full ' 
        + (st.includes('Offline') ? 'status-offline' 
           : st.includes('Online') ? 'status-online' 
           : 'status-checking');
    }

    // Backend selection
    backendSelect.addEventListener('change', () => {
      backend = backendSelect.value;
      if (backend === 'openai') {
        openaiKeyRow.style.display = 'flex';
        // Preencher modelos OpenAI (fixos)
        modelSelect.innerHTML = '';
        ['o4-mini', 'gpt-4.1-nano-2025-04-14', 'gpt-4o-mini-2024-07-18'].forEach(m => {
          const o = document.createElement('option');
          o.value = m; o.textContent = m; modelSelect.appendChild(o);
        });
        setStatus('API Online (OpenAI)');
      } else {
        openaiKeyRow.style.display = 'none';
        fetchModels();
      }
    });

    async function fetchModels() {
      setStatus('Verificando API');
      try {
        const res = await fetch(API_BASE + '/models');
        const models = await res.json();
        modelSelect.innerHTML = '';
        for (const m of models) {
          const o = document.createElement('option');
          o.value = m; o.textContent = m; modelSelect.appendChild(o);
        }
        setStatus('API Online (Ollama)');
      } catch {
        setStatus('API Offline');
        addMessage('system','Não foi possível conectar ao backend.');
      }
    }

    btnUpload.addEventListener('click', async () => {
      if (!fileInput.files[0]) {
        alert('Selecione um Excel primeiro');
        return;
      }

      btnUpload.disabled = true;
      addMessage('system','Indexando Excel...');
      info.textContent = '';
      sessionId = null;
      isReady = false;
      userInput.disabled = true;
      btnAsk.disabled = true;

      // Upload
      try {
        const fd = new FormData();
        fd.append('file', fileInput.files[0]);
        fd.append('model', modelSelect.value);
        if (backend === 'openai') {
          fd.append('backend', 'openai');
          fd.append('openai_key', openaiKeyInput.value.trim());
        } else {
          fd.append('backend', 'ollama');
        }

        const res = await fetch(API_BASE + '/upload', { method: 'POST', body: fd });
        const js = await res.json();
        if(js.error) {
          addMessage('system', js.error);
          btnUpload.disabled = false;
          return;
        }
        sessionId = js.session_id;
        info.textContent = `Sessão ${sessionId} • ${js.columns.length} cols, ${js.n_rows} linhas`;

        // Sanity check
        addMessage('system','Testando modelo...');
        const chatFd = new FormData();
        chatFd.append('message', 'Olá, tudo bem?');
        chatFd.append('model', modelSelect.value);
        if (backend === 'openai') {
          chatFd.append('backend', 'openai');
          chatFd.append('openai_key', openaiKeyInput.value.trim());
        } else {
          chatFd.append('backend', 'ollama');
        }
        const chatRes  = await fetch(API_BASE + '/chat', {
          method: 'POST',
          body: chatFd,
        });
        const chatJson = await chatRes.json();
        addMessage('assistant', chatJson.response);

        isReady = true;
        addMessage('system','Pronto para perguntas via RAG.');
        userInput.disabled = false;
        btnAsk.disabled   = false;
      } catch (e) {
        console.error(e);
        addMessage('system','Erro no upload/indexação: ' + e.message);
      } finally {
        btnUpload.disabled = false;
      }
    });

    btnAsk.addEventListener('click', async () => {
      if (!isReady || isBusy) return;
      const q = userInput.value.trim();
      if (!q) return;

      isBusy = true;
      userInput.disabled = true;
      btnAsk.disabled    = true;
      loading.style.display = 'block';
      addMessage('user', q);

      try {
        const qfd = new FormData();
        qfd.append('session_id', sessionId);
        qfd.append('question', q);
        if (backend === 'openai') {
          qfd.append('backend', 'openai');
          qfd.append('openai_key', openaiKeyInput.value.trim());
        } else {
          qfd.append('backend', 'ollama');
        }
        const r  = await fetch(API_BASE + '/query', { method: 'POST', body: qfd });
        const j  = await r.json();
        addMessage('assistant', j.answer);
      } catch (e) {
        console.error(e);
        addMessage('system','Erro ao consultar: ' + e.message);
      } finally {
        isBusy = false;
        loading.style.display = 'none';
        userInput.disabled = false;
        btnAsk.disabled    = false;
        userInput.value = '';
      }
    });

    // Carrega modelos Ollama ao iniciar
    fetchModels();
  });
  </script>
</body>
</html>

⚙️ Fallback pandas × RAG (dinâmico e eficiente)

O segredo da velocidade está no balanceamento entre pandas e o pipeline RAG. Perguntas como “quantas linhas tem?”, “quais colunas existem?”, “qual a primeira linha?” ou “há linhas similares?” são respondidas localmente, sem envolver modelo nenhum. Quando a pergunta exige interpretação, como “explique os dados” ou “quais insights você tira?”, então sim, o backend consulta o VectorStoreIndex, monta o contexto, e chama o modelo de linguagem.


🧪 Resultados: testes reais

Em nossos testes com uma planilha real de 49 linhas e 4 colunas, com uma GPU modesta (GTX 1050 Ti de 4 GB), o modelo qwen3:0.6b levou 143 segundos para responder uma pergunta do tipo “quais linhas são similares?”. O mesmo arquivo, com o mesmo prompt, foi processado em apenas 5,2 segundos pelo GPT-4o-mini. Quando pedimos “extraia insights da planilha”, o modelo da OpenAI entregou uma resposta rica e coerente em 12,2 segundos.


💰 Custo por token: GPT-4o-mini compensa?

Para calcular o custo, usamos a precificação oficial da OpenAI: US$ 0,15 por milhão de tokens de entrada e US$ 0,60 por milhão de tokens de saída. Numa pergunta típica com 2.000 tokens de entrada e 500 de saída, o custo é:

TipoTokensPreço/1MTotal
Entrada2000US$ 0,15US$ 0,0003
Saída500US$ 0,60US$ 0,0003
TotalUS$ 0,0006

Uma sessão com 10 perguntas analíticas consome menos de US$ 0,006, ou R$ 0,03 no câmbio atual. Isso significa que para demonstrações públicas, interfaces de baixo tráfego ou protótipos educacionais, a OpenAI se mostra mais rápida, mais precisa e absurdamente barata. Para usos offline, o Ollama continua útil — mas seu desempenho em máquinas modestas é limitado.


✅ Conclusão: uma PoC que entrega

A combinação de frontend estático leve, backend FastAPI assíncrono e pipeline híbrido pandas + RAG garantiu um desempenho admirável. O usuário recebe resposta imediata para perguntas objetivas e pode, quando quiser, ativar o poder dos LLMs para interpretar os dados. O GPT-4o-mini-2024-07-18 se mostrou estável, rápido e extremamente barato. Em comparação, o modelo local só é viável em máquinas com boa VRAM ou para perguntas muito simples.

Essa PoC provou que é possível unir custo-benefício, performance e flexibilidade com pouquíssimas dependências. Todo o código está aberto, pronto para ser adaptado, testado ou escalado. E o melhor: a arquitetura funciona em qualquer máquina, desde que o Ollama esteja instalado ou que o usuário tenha uma chave da OpenAI.

Repositório

https://github.com/elzobrito/chat-excel

Deixe um comentário