|
| 1 | +## Propósito do sistema |
| 2 | + |
| 3 | +O **ACL (KernelBot)** é um chatbot com **RAG lexical (BM25)** que responde via **LLM (OpenRouter)**, mas com uma regra explícita de segurança: **se o retrieval não tiver confiança suficiente, ele não chama o modelo** (hard stop). |
| 4 | + |
| 5 | +Na prática, o sistema transforma o conteúdo de uma tabela MySQL chamada **`knowledge`** (aulas por disciplina) em um índice BM25 **em memória**, particionado por **silos** (disciplinas). A cada pergunta, ele escolhe um escopo (global, disciplina, `/doc`) e só gera resposta quando os gates de retrieval (score, coverage, ambiguidade) permitem. |
| 6 | + |
| 7 | +Fora de escopo (neste repo, no estado atual): pipeline de ingestão, schema SQL versionado e suíte de testes automatizados. Este `documentation.md` documenta **o que está implementado no código hoje**, sem inferir arquivos/pastas inexistentes. |
| 8 | + |
| 9 | +## Arquitetura |
| 10 | + |
| 11 | +### Stack |
| 12 | + |
| 13 | +| Camada | Tecnologia | |
| 14 | +|---|---| |
| 15 | +| Servidor HTTP | FastAPI + Uvicorn | |
| 16 | +| Retrieval | BM25Okapi (`rank-bm25`) — in-memory | |
| 17 | +| Dados | MySQL (lido via `PyMySQL`) | |
| 18 | +| Gateway LLM | OpenRouter (streaming via `httpx`) | |
| 19 | +| Frontend | HTML estático (`templates/index.html`) + JS modular em `frontend/src/` | |
| 20 | +| Streaming | SSE (`text/event-stream`) | |
| 21 | +| Logs | `logging` stdlib + payload estruturado (texto/JSON) | |
| 22 | + |
| 23 | +### Visão de componentes |
| 24 | + |
| 25 | +```mermaid |
| 26 | +flowchart TD |
| 27 | + UI["Browser UI\n(templates/index.html + frontend/src)"] -->|POST /chat (JSON)| API["FastAPI /chat (SSE)"] |
| 28 | + API --> CM["ContextManager.build_messages()"] |
| 29 | + CM --> SE["SearchEngine.search_candidates()\nBM25 por silo"] |
| 30 | + SE --> DB["MySQL\nknowledge WHERE active=1"] |
| 31 | + CM -->|messages + decision + trace| CP["ChatProvider.stream_response()\nOpenRouter streaming"] |
| 32 | + CP -->|SSE tokens + ACL_META| UI |
| 33 | +``` |
| 34 | + |
| 35 | +### Estrutura do repositório (real no workspace) |
| 36 | + |
| 37 | +``` |
| 38 | +KernelBot/ |
| 39 | +├── main.py # Entry point: carrega Settings, monta serviços e roda Uvicorn (127.0.0.1:8001) |
| 40 | +├── app/ |
| 41 | +│ ├── factory.py # create_app(): templates, static mounts, lifespan, routers |
| 42 | +│ └── state.py # AppServices (injeção de dependências via app.state) |
| 43 | +├── api/ |
| 44 | +│ └── routes.py # GET / (UI) e POST /chat (SSE) |
| 45 | +├── core/ |
| 46 | +│ ├── config.py # Settings.load() a partir do .env + prompts + thresholds |
| 47 | +│ ├── logging_config.py # configure_logging() (text/json) |
| 48 | +│ ├── structured_log.py # log_event() + formatter com payload ACL |
| 49 | +│ └── systemPrompt/ # system_prompt.txt + sticky_instruction.txt (obrigatórios) |
| 50 | +├── engine/ |
| 51 | +│ ├── search.py # SearchEngine: rebuild, silos BM25, candidatos (raw_score) + whitelist de discipline |
| 52 | +│ ├── database.py # SELECT no MySQL e chunking por janela de palavras |
| 53 | +│ ├── retrieval.py # gates/decisão (hard stop vs allow_generation) + sanity pós-geração |
| 54 | +│ ├── context.py # roteamento (/doc, /content, /python...) + pin por sessão + hard stop |
| 55 | +│ ├── chat_provider.py # streaming OpenRouter com fallback + ACL_META + override pós-geração |
| 56 | +│ ├── pinned_store.py # PinnedSessionStore em memória (session_id → chunks) |
| 57 | +│ └── watcher.py # legado (não integrado ao fluxo atual) |
| 58 | +├── templates/ |
| 59 | +│ └── index.html # Shell da UI (carrega /src/main.js) |
| 60 | +├── frontend/ |
| 61 | +│ ├── src/ # UI JS (ChatService, render markdown, histórico, sessão) |
| 62 | +│ └── assets/ # CSS e imagens |
| 63 | +├── content/ # existe, mas o engine atual não lê arquivos daqui |
| 64 | +├── SQL/ # existe no workspace, mas sem artefatos versionados (neste estado) |
| 65 | +├── requirements.txt |
| 66 | +├── .env / .env.example |
| 67 | +└── documentation.md |
| 68 | +``` |
| 69 | + |
| 70 | +### Decisões de design (trade-offs) |
| 71 | + |
| 72 | +- **BM25 (lexical) em vez de embeddings**: simples, barato e rápido; trade-off é recall semântico menor e dependência de termos na query. |
| 73 | +- **Hard stop no modo strict**: reduz alucinação e custo de tokens; trade-off é mais “recusa” quando a pergunta é vaga/ambígua. |
| 74 | +- **Pin por sessão (server-side em memória)**: melhora follow-ups sem re-busca constante; trade-off é que o pin expira por turnos e some ao reiniciar o processo. |
| 75 | + |
| 76 | +## APIs |
| 77 | + |
| 78 | +### Base URL |
| 79 | + |
| 80 | +Por padrão, o servidor sobe em: |
| 81 | + |
| 82 | +```bash |
| 83 | +http://127.0.0.1:8001 |
| 84 | +``` |
| 85 | + |
| 86 | +Sem autenticação. |
| 87 | + |
| 88 | +### Endpoints |
| 89 | + |
| 90 | +| Método | Caminho | Descrição | |
| 91 | +|---|---|---| |
| 92 | +| `GET` | `/` | Serve a UI (`templates/index.html`) | |
| 93 | +| `POST` | `/chat` | Recebe mensagem e retorna SSE (`text/event-stream`) | |
| 94 | + |
| 95 | +### `POST /chat` |
| 96 | + |
| 97 | +**Request body (JSON):** |
| 98 | + |
| 99 | +```json |
| 100 | +{ |
| 101 | + "message": "string (obrigatório)", |
| 102 | + "discipline": "string (opcional)", |
| 103 | + "session_id": "string (opcional; 8–128 [A-Za-z0-9_-])" |
| 104 | +} |
| 105 | +``` |
| 106 | + |
| 107 | +Notas: |
| 108 | + |
| 109 | +- **`discipline` (JSON)**: se fornecido, passa por whitelist em `SearchEngine.normalize_discipline()` (só aceita valores presentes no DB em `SELECT DISTINCT discipline ...`). |
| 110 | +- **`session_id`**: habilita **contexto fixado (pin)**; o frontend gera e persiste em `sessionStorage`. |
| 111 | +- **Comando `/reload`**: se `message == "/reload"` (case-insensitive após trim), o backend reconstrói o índice BM25 e retorna um stream curtinho com status. |
| 112 | + |
| 113 | +**Response (`text/event-stream`)**: |
| 114 | + |
| 115 | +- `data: [ACL_META]{...}\n\n` — metadados (sempre no começo do stream; e pode reaparecer no override pós-geração) |
| 116 | +- `data: <chunk>\n\n` — tokens/trechos (com `\n` escapado como `\\n`) |
| 117 | +- `data: [DONE]\n\n` — fim |
| 118 | +- `data: [ERROR] ...\n\n` — erro amigável de provider (quando todos os modelos falham) |
| 119 | + |
| 120 | +**Campos relevantes em `ACL_META` (v=2)** (emitidos por `engine/chat_provider.py`): |
| 121 | + |
| 122 | +- `label`: rótulo de escopo (ex.: “Python”, “Base geral”, “Documentação (doc)”) |
| 123 | +- `sources`: lista de fontes (ex.: `db:python/slug-da-aula`) |
| 124 | +- `pinned_active` / `pinned_display`: status do pin da sessão |
| 125 | +- `mode`: `strict` (no estado atual) |
| 126 | +- `decision`: `answer` ou `hard_stop` |
| 127 | +- `reason`: motivo (ex.: `ok`, `insufficient_context`, `context_misaligned`, `provider_error`, `post_generation_misalignment`) |
| 128 | +- `confidence`: `high|medium|low` |
| 129 | +- `llm_called`: boolean |
| 130 | +- `tokens_used`: contador aproximado (incrementa por delta recebido) |
| 131 | + |
| 132 | +## Fluxos |
| 133 | + |
| 134 | +### Fluxo 1 — Inicialização do servidor |
| 135 | + |
| 136 | +1. `main.py` chama `configure_logging()`. |
| 137 | +2. `Settings.load()` lê `.env` e valida pré-requisitos: |
| 138 | + - `OPENROUTER_API_KEY` é obrigatório. |
| 139 | + - `core/systemPrompt/system_prompt.txt` e `core/systemPrompt/sticky_instruction.txt` são obrigatórios. |
| 140 | +3. `SearchEngine(...).rebuild()` tenta carregar chunks do MySQL (`engine/database.py`). Se DB não estiver configurado ou estiver inacessível, o índice fica vazio e logs avisam. |
| 141 | +4. `create_app()` registra templates, monta estáticos (`/assets`, `/src`) e inclui rotas. |
| 142 | +5. Uvicorn sobe em `127.0.0.1:8001`. |
| 143 | + |
| 144 | +### Fluxo 2 — Chat (ponta a ponta) |
| 145 | + |
| 146 | +```text |
| 147 | +UI (frontend/src/ui.js) → POST /chat { message, session_id } → SSE stream |
| 148 | +``` |
| 149 | + |
| 150 | +1. O usuário envia uma mensagem. |
| 151 | +2. A UI (`frontend/src/api.js`) faz `fetch("/chat")` e lê `res.body.getReader()` processando linhas `data: ...`. |
| 152 | +3. O backend (`api/routes.py`) valida JSON, `message`, `discipline` e `session_id`. |
| 153 | +4. `ContextManager.build_messages()` decide o escopo e tenta retrieval: |
| 154 | + - Comandos de escopo no texto (prefixos): `/doc`, `/content`, `/python`, `/visualizacao-sql`, `/projeto-bloco`, `/planejamento-curso-carreira`. |
| 155 | + - Sem comando, ele pode usar o **pin** da sessão para sugerir escopo efetivo (se existir e não conflitar). |
| 156 | +5. `SearchEngine.search_candidates()` retorna candidatos BM25 (com `raw_score` e `matched_terms`). |
| 157 | +6. `engine/retrieval.build_decision()` aplica gates (score absoluto, margem, coverage, termos mínimos, “vague but high risk”). |
| 158 | +7. Se **`allow_generation=False`**: o backend envia **hard stop** via SSE (sem chamar LLM). |
| 159 | +8. Se **`allow_generation=True`**: o backend chama OpenRouter em streaming e re-emite tokens via SSE. |
| 160 | +9. No final, o provider roda um **sanity check pós-geração** (`post_generation_flags`). Se detectar desalinhamento em modo `strict`, ele emite um `ACL_META` atualizado e anexa uma mensagem de hard stop (`post_generation_misalignment`). |
| 161 | +10. A UI renderiza Markdown incremental (via `marked@12` + `highlight.js`) e mostra breadcrumbs a partir de `meta.sources`. |
| 162 | + |
| 163 | +### Fluxo 3 — Contexto fixado (pin) por sessão |
| 164 | + |
| 165 | +- O frontend gera um `session_id` (UUID sem hífens) e salva em `sessionStorage` (`frontend/src/utils/sessionId.js`). |
| 166 | +- O backend salva os chunks selecionados no `PinnedSessionStore` (em memória), com: |
| 167 | + - `turns_left` (expira a cada turno via `begin_turn()`), |
| 168 | + - `scope_key` (ex.: `discipline:python`, `doc`, `content`). |
| 169 | +- Se o usuário enviar `/reset` ou `/limpar` no começo da mensagem, o backend limpa o pin daquela sessão. |
| 170 | + |
| 171 | +### Fluxo 4 — Rebuild manual do índice (`/reload`) |
| 172 | + |
| 173 | +1. Usuário envia `/reload`. |
| 174 | +2. `api/routes.py` chama `services.search_engine.rebuild()`. |
| 175 | +3. O endpoint retorna um stream curto com: |
| 176 | + - `data: Índice reconstruído: ...\n\n` |
| 177 | + - `data: [DONE]\n\n` |
| 178 | + |
| 179 | +## Glossário e referências (opcional) |
| 180 | + |
| 181 | +- **Silo**: partição lógica do índice por `discipline` (ex.: `python`). |
| 182 | +- **Chunk**: janela de ~500 palavras com overlap de 50 (ver `engine/database.py`). |
| 183 | +- **Retrieval candidate**: item retornado por `SearchEngine.search_candidates()` com `raw_score` (BM25 cru). |
| 184 | +- **Hard stop**: decisão de não chamar o LLM e responder com uma mensagem de reformulação (modo `strict`). |
| 185 | +- **Pin**: contexto fixado por sessão em memória (`PinnedSessionStore`). |
| 186 | + |
| 187 | +Referências no código: |
| 188 | + |
| 189 | +- Backend: `main.py`, `api/routes.py`, `app/factory.py`, `engine/context.py`, `engine/retrieval.py`, `engine/chat_provider.py` |
| 190 | +- Frontend: `templates/index.html`, `frontend/src/api.js`, `frontend/src/ui.js` |
0 commit comments