diff --git a/README.md b/README.md index ea97aa6..c7b13ea 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,89 @@ # Chat SQL ![ci](https://github.com/iambot1193/chat-sql/actions/workflows/ci.yml/badge.svg) +![codeql](https://github.com/iambot1193/chat-sql/actions/workflows/codeql.yml/badge.svg) -Chatbot que traduz perguntas em português para SQL, executa no banco e mostra o -resultado numa planilha que atualiza a cada resposta. +Pergunta em português vira SQL, roda num SQLite **aberto em modo somente-leitura pelo +sistema operacional** e volta como planilha. O texto que gera a consulta é escrito por um +usuário e traduzido por um modelo — nenhum dos dois é confiável, e o banco não depende de +nenhum dos dois estar certo. -Roda inteiro na sua máquina: modelo local via Ollama, banco SQLite. Sem chave de -API, sem cota, custo zero. +Roda inteiro na sua máquina: modelo local via Ollama, banco SQLite. Sem chave de API, sem +cota, custo zero. ![Chat SQL — pergunta em português vira SQL e planilha](assets/demo.png) +## O problema + +Text-to-SQL é fácil de fazer funcionar e difícil de fazer com segurança. A saída de um LLM +é texto: ele pode alucinar uma tabela, encadear dois comandos, ou ser convencido pela +pergunta a escrever um `DELETE`. Se essa string chega ao banco sem barreira, o modelo +virou seu DBA. + +Aqui ele não vira. Duas camadas, e a de baixo não é código meu: + +```bash +# 1. O arquivo recusa escrita — nenhuma validação em Python envolvida +python -c "import sqlite3; sqlite3.connect('file:db/analytics.db?mode=ro', uri=True).execute('DELETE FROM vehicles')" +# sqlite3.OperationalError: attempt to write a readonly database + +# 2. E o guard recusa antes de chegar lá +python -c "from app.sql_guard import check; check('SELECT 1; DROP TABLE vehicles')" +# UnsafeQuery: mais de um comando SQL não é permitido + +python -c "from app.sql_guard import check; check(\"SELECT load_extension('/tmp/x.so')\")" +# UnsafeQuery: comando não permitido: LOAD_EXTENSION +``` + +As duas existem porque protegem contra coisas diferentes. `mode=ro` é a garantia real — +vale mesmo se o guard tiver um furo. O guard existe para devolver um erro legível, para +bloquear payload multi-comando antes de sair da aplicação, e para cobrir o caso que +`mode=ro` **não** cobre: `load_extension` carrega código nativo de dentro de um `SELECT` +perfeitamente válido, sem escrever um byte no banco. + +Detalhes do guard que não são acidentais: + +- Comentários (`--`, `/* */`) são removidos **antes** da verificação, para não recusarem + consulta legítima: `SELECT make FROM vehicles -- delete depois` passa, e deve passar. +- A lista proibida casa palavra inteira: uma coluna `created_at` não pode disparar `create`. +- Entradas de Postgres (`pg_read_file`, `lo_import`, `dblink`) ficam na lista mesmo o banco + sendo SQLite, para o guard continuar valendo se apontarem a app para um Postgres. + +## O retry + +O modelo não enxerga o guard nem o banco. Erro que ele não lê é erro que ele repete. + +Quando a consulta falha — bloqueada pelo guard, recusada pelo SQLite, ou estourando o +timeout — o texto do erro volta como uma nova mensagem e o modelo reescreve. Até 3 +tentativas; depois disso o usuário recebe o erro. + +```mermaid +sequenceDiagram + participant U as Usuário + participant A as FastAPI + participant M as Ollama (local) + participant D as SQLite (mode=ro) + + U->>A: pergunta em português + A->>M: esquema introspectado + histórico + M-->>A: {sql, note} (structured output) + A->>A: sql_guard.check(sql) + alt bloqueado ou SQLite recusa + A->>M: "Essa consulta falhou: . Reescreva." + M-->>A: {sql, note} corrigido + end + A->>D: SELECT (5s de teto) + D-->>A: até 500 linhas + A-->>U: planilha + a SQL usada +``` + +O esquema é introspectado do banco vivo a cada requisição (`PRAGMA table_info`), não +escrito à mão — apontar para outro SQLite funciona sem tocar em código. + +O modelo também pode responder que **não dá**: se a pergunta não couber no esquema, ele +devolve `sql` vazio e explica em `note`. "Qual o faturamento do mês passado?" num banco que +só tem catálogo de veículos não vira uma consulta inventada. + ## Rodar ```bash @@ -19,40 +93,33 @@ ollama serve uvicorn app.main:app --reload ``` -O banco de demonstração (`db/analytics.db`, ~276 KB, dados públicos da NHTSA -vPIC) já vem versionado. Para reconstruí-lo do zero: `python db/seed.py` -(algumas centenas de requisições, alguns minutos). - Abra http://localhost:8000 Ou, com o Ollama já rodando no host: `docker compose up --build` -Perguntas de exemplo para o banco de demonstração: +O banco de demonstração (`db/analytics.db`, ~276 KB, dados públicos da NHTSA vPIC) já vem +versionado. Para reconstruí-lo do zero: `python db/seed.py` (algumas centenas de +requisições, alguns minutos). + +Perguntas de exemplo: - "quantos modelos a Toyota tem?" - "quais as 5 marcas com mais modelos?" - "picapes da Ford depois de 2020" +- "qual o faturamento do mês passado?" — devolve a recusa explicada -## Como funciona - -`POST /chat` recebe o histórico da conversa, envia o esquema introspectado do -banco para o modelo local, recebe `{sql, note}` com structured outputs, valida e -executa. +## Limites de execução -Se a consulta falhar — bloqueada pelo guard ou recusada pelo SQLite — o erro -volta para o modelo como uma nova mensagem e ele reescreve a SQL. Até 3 -tentativas; depois disso o usuário recebe o erro. - -## Segurança - -Duas camadas, ambas necessárias: - -1. O banco é aberto com `file:...?mode=ro` — o sistema operacional recusa - escrita, independente de qualquer validação em Python. -2. `app/sql_guard.py` — aceita um único `SELECT`/`WITH`, sem `;` extra e sem - palavras-chave de escrita. +| | | +|---|---| +| Timeout por consulta | 5s, via `set_progress_handler` — aborta no meio da varredura | +| Linhas por resposta | 500, com sinalização de truncado | +| Histórico enviado | 6 turnos (modelo local reprocessa o contexto inteiro a cada chamada) | +| Timeout do Ollama | 120s | -Além disso: 5s de timeout por consulta e no máximo 500 linhas por resultado. +O timeout de consulta não é cosmético: um `SELECT` sem `LIMIT` numa tabela grande seguraria +a requisição inteira. Ao abortar, o erro do SQLite entra no mesmo laço de retry e o modelo +reescreve com limite. ## Configuração @@ -62,8 +129,8 @@ Além disso: 5s de timeout por consulta e no máximo 500 linhas por resultado. | `OLLAMA_MODEL` | `qwen2.5-coder:7b` | | `DB_PATH` | `db/analytics.db` | -Máquina: o modelo ocupa ~6 GB de RAM. Em CPU, ~10-30s por pergunta; com GPU de -8 GB, ~2-5s. O Ollama atende uma requisição por vez. +Máquina: o modelo ocupa ~6 GB de RAM. Em CPU, ~10-30s por pergunta; com GPU de 8 GB, +~2-5s. O Ollama atende uma requisição por vez. ## Teste @@ -75,9 +142,20 @@ python test_retry.py ## Usar seu próprio banco -Aponte `DB_PATH` para outro arquivo SQLite. O esquema é lido do próprio banco, -mas os exemplos no prompt de `app/main.py` falam de veículos — troque-os pelo -seu domínio, é o que mais afeta a qualidade da SQL gerada. +Aponte `DB_PATH` para outro arquivo SQLite. O esquema é lido do próprio banco, mas os +exemplos no prompt de `app/main.py` falam de veículos — troque-os pelo seu domínio, é o que +mais afeta a qualidade da SQL gerada. + +## Limites conhecidos + +- **O guard é lista negra, não gramática.** Um parser SQL de verdade (`sqlglot`) seria mais + robusto que regex. A escolha aqui foi deliberada: `mode=ro` é a garantia que sustenta o + sistema, e o guard é a segunda camada. Trocar por parser é a evolução natural se a app + passar a apontar para um banco onde o usuário do banco tenha permissão de escrita. +- **Uma requisição por vez.** É limite do Ollama, não da app. Concorrência exige fila ou + mais de uma instância do modelo. +- **Sem cache de esquema.** Introspecção a cada requisição — barata em um banco de uma + tabela, cara em um de centenas. ## Planos