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).
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.
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.
| Camada | Tecnologia |
|---|---|
| Servidor HTTP | FastAPI + Uvicorn |
| Retrieval | BM25Okapi (rank-bm25) — in-memory |
| Dados | MySQL (lido via PyMySQL) |
| Gateway LLM | OpenRouter (streaming via httpx) |
| Frontend | HTML estático (templates/index.html) + JS modular em frontend/src/ |
| Streaming | SSE (text/event-stream) |
| Logs | logging stdlib + payload estruturado (texto/JSON) |
flowchart TD
UI["Browser UI\n(templates/index.html + frontend/src)"] -->|POST /chat (JSON)| API["FastAPI /chat (SSE)"]
API --> CM["ContextManager.build_messages()"]
CM --> SE["SearchEngine.search_candidates()\nBM25 por silo"]
SE --> DB["MySQL\nknowledge WHERE active=1"]
CM -->|messages + decision + trace| CP["ChatProvider.stream_response()\nOpenRouter streaming"]
CP -->|SSE tokens + ACL_META| UI
KernelBot/
├── main.py # Entry point: carrega Settings, monta serviços e roda Uvicorn (127.0.0.1:8001)
├── app/
│ ├── factory.py # create_app(): templates, static mounts, lifespan, routers
│ └── state.py # AppServices (injeção de dependências via app.state)
├── api/
│ └── routes.py # GET / (UI) e POST /chat (SSE)
├── core/
│ ├── config.py # Settings.load() a partir do .env + prompts + thresholds
│ ├── logging_config.py # configure_logging() (text/json)
│ ├── structured_log.py # log_event() + formatter com payload ACL
│ └── systemPrompt/ # system_prompt.txt + sticky_instruction.txt (obrigatórios)
├── engine/
│ ├── search.py # SearchEngine: rebuild, silos BM25, candidatos (raw_score) + whitelist de discipline
│ ├── database.py # SELECT no MySQL e chunking por janela de palavras
│ ├── retrieval.py # gates/decisão (hard stop vs allow_generation) + sanity pós-geração
│ ├── context.py # roteamento (/doc, /content, /python...) + pin por sessão + hard stop
│ ├── chat_provider.py # streaming OpenRouter com fallback + ACL_META + override pós-geração
│ ├── pinned_store.py # PinnedSessionStore em memória (session_id → chunks)
│ └── watcher.py # legado (não integrado ao fluxo atual)
├── templates/
│ └── index.html # Shell da UI (carrega /src/main.js)
├── frontend/
│ ├── src/ # UI JS (ChatService, render markdown, histórico, sessão)
│ └── assets/ # CSS e imagens
├── content/ # existe, mas o engine atual não lê arquivos daqui
├── SQL/ # existe no workspace, mas sem artefatos versionados (neste estado)
├── requirements.txt
├── .env / .env.example
└── documentation.md
- BM25 (lexical) em vez de embeddings: simples, barato e rápido; trade-off é recall semântico menor e dependência de termos na query.
- Hard stop no modo strict: reduz alucinação e custo de tokens; trade-off é mais “recusa” quando a pergunta é vaga/ambígua.
- 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.
Por padrão, o servidor sobe em:
http://127.0.0.1:8001Sem autenticação.
| Método | Caminho | Descrição |
|---|---|---|
GET |
/ |
Serve a UI (templates/index.html) |
POST |
/chat |
Recebe mensagem e retorna SSE (text/event-stream) |
Request body (JSON):
{
"message": "string (obrigatório)",
"discipline": "string (opcional)",
"session_id": "string (opcional; 8–128 [A-Za-z0-9_-])"
}Notas:
discipline(JSON): se fornecido, passa por whitelist emSearchEngine.normalize_discipline()(só aceita valores presentes no DB emSELECT DISTINCT discipline ...).session_id: habilita contexto fixado (pin); o frontend gera e persiste emsessionStorage.- Comando
/reload: semessage == "/reload"(case-insensitive após trim), o backend reconstrói o índice BM25 e retorna um stream curtinho com status.
Response (text/event-stream):
data: [ACL_META]{...}\n\n— metadados (sempre no começo do stream; e pode reaparecer no override pós-geração)data: <chunk>\n\n— tokens/trechos (com\nescapado como\\n)data: [DONE]\n\n— fimdata: [ERROR] ...\n\n— erro amigável de provider (quando todos os modelos falham)
Campos relevantes em ACL_META (v=2) (emitidos por engine/chat_provider.py):
label: rótulo de escopo (ex.: “Python”, “Base geral”, “Documentação (doc)”)sources: lista de fontes (ex.:db:python/slug-da-aula)pinned_active/pinned_display: status do pin da sessãomode:strict(no estado atual)decision:answerouhard_stopreason: motivo (ex.:ok,insufficient_context,context_misaligned,provider_error,post_generation_misalignment)confidence:high|medium|lowllm_called: booleantokens_used: contador aproximado (incrementa por delta recebido)
main.pychamaconfigure_logging().Settings.load()lê.enve valida pré-requisitos:OPENROUTER_API_KEYé obrigatório.core/systemPrompt/system_prompt.txtecore/systemPrompt/sticky_instruction.txtsão obrigatórios.
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.create_app()registra templates, monta estáticos (/assets,/src) e inclui rotas.- Uvicorn sobe em
127.0.0.1:8001.
UI (frontend/src/ui.js) → POST /chat { message, session_id } → SSE stream
- O usuário envia uma mensagem.
- A UI (
frontend/src/api.js) fazfetch("/chat")e lêres.body.getReader()processando linhasdata: .... - O backend (
api/routes.py) valida JSON,message,disciplineesession_id. ContextManager.build_messages()decide o escopo e tenta retrieval:- Comandos de escopo no texto (prefixos):
/doc,/content,/python,/visualizacao-sql,/projeto-bloco,/planejamento-curso-carreira. - Sem comando, ele pode usar o pin da sessão para sugerir escopo efetivo (se existir e não conflitar).
- Comandos de escopo no texto (prefixos):
SearchEngine.search_candidates()retorna candidatos BM25 (comraw_scoreematched_terms).engine/retrieval.build_decision()aplica gates (score absoluto, margem, coverage, termos mínimos, “vague but high risk”).- Se
allow_generation=False: o backend envia hard stop via SSE (sem chamar LLM). - Se
allow_generation=True: o backend chama OpenRouter em streaming e re-emite tokens via SSE. - No final, o provider roda um sanity check pós-geração (
post_generation_flags). Se detectar desalinhamento em modostrict, ele emite umACL_METAatualizado e anexa uma mensagem de hard stop (post_generation_misalignment). - A UI renderiza Markdown incremental (via
marked@12+highlight.js) e mostra breadcrumbs a partir demeta.sources.
- O frontend gera um
session_id(UUID sem hífens) e salva emsessionStorage(frontend/src/utils/sessionId.js). - O backend salva os chunks selecionados no
PinnedSessionStore(em memória), com:turns_left(expira a cada turno viabegin_turn()),scope_key(ex.:discipline:python,doc,content).
- Se o usuário enviar
/resetou/limparno começo da mensagem, o backend limpa o pin daquela sessão.
- Usuário envia
/reload. api/routes.pychamaservices.search_engine.rebuild().- O endpoint retorna um stream curto com:
data: Índice reconstruído: ...\n\ndata: [DONE]\n\n
- Silo: partição lógica do índice por
discipline(ex.:python). - Chunk: janela de ~500 palavras com overlap de 50 (ver
engine/database.py). - Retrieval candidate: item retornado por
SearchEngine.search_candidates()comraw_score(BM25 cru). - Hard stop: decisão de não chamar o LLM e responder com uma mensagem de reformulação (modo
strict). - Pin: contexto fixado por sessão em memória (
PinnedSessionStore).
Referências no código:
- Backend:
main.py,api/routes.py,app/factory.py,engine/context.py,engine/retrieval.py,engine/chat_provider.py - Frontend:
templates/index.html,frontend/src/api.js,frontend/src/ui.js