Skip to content

Commit 3a1003a

Browse files
committed
Refactor logging and enhance setup documentation
- Updated `.gitignore` to ensure proper environment file handling. - Expanded `README.md` with detailed setup instructions for automatic configuration and database setup. - Added `cryptography` to `requirements.txt` for enhanced security features. - Improved logging in various modules (`core`, `engine`, `evaluation`) to provide structured event logging for better traceability and debugging. - Refactored context and retrieval logic to utilize structured logging for decision-making processes.
1 parent d61cee0 commit 3a1003a

18 files changed

Lines changed: 1118 additions & 99 deletions

.env.example

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# =============================================================================
2+
# ACL (KernelBot) — variáveis de ambiente
3+
# Copie para .env e preencha os valores reais:
4+
# copy .env.example .env (Windows PowerShell: Copy-Item .env.example .env)
5+
# O ficheiro .env não deve ir para o Git (.gitignore).
6+
# =============================================================================
7+
8+
# --- OpenRouter (obrigatório) ---
9+
# Chave da API em https://openrouter.ai/keys — usada em todas as chamadas ao LLM.
10+
OPENROUTER_API_KEY=
11+
12+
# --- MySQL (obrigatório para o índice BM25 e ingestão) ---
13+
# Host do servidor MySQL (ex.: 127.0.0.1 ou localhost). Nao use 127.0.0.0 — costuma dar timeout.
14+
DB_HOST=
15+
# Porta TCP (padrão MySQL: 3306).
16+
DB_PORT=3306
17+
# Nome da base de dados onde está a tabela `knowledge`.
18+
DB_NAME=
19+
# Utilizador com permissão de leitura (e escrita se correres ingest).
20+
DB_USER=
21+
DB_PASSWORD=
22+
23+
# --- Contexto global (/content sem disciplina) ---
24+
# geral = BM25 em todos os silos e merge por score.
25+
# all = mesmo modo de nome alternativo aceite pelo código.
26+
ACL_GLOBAL_CONTEXT=geral
27+
28+
# --- Contexto fixado (pin na sessão) ---
29+
# Quantos turnos manter chunks fixados antes de voltar a buscar forte.
30+
ACL_PINNED_MAX_TURNS=5
31+
# Tamanho máximo em caracteres do texto fixado no pin.
32+
ACL_PINNED_MAX_CHARS=24000
33+
# Score normalizado abaixo do qual o pin é considerado "fraco" (legado / heurísticas de pin).
34+
ACL_PINNED_WEAK_SCORE=0.4
35+
36+
# --- Política de retrieval (modo strict — ver engine/retrieval.py) ---
37+
# Score BM25 bruto mínimo do melhor candidato; abaixo → hard stop (contexto insuficiente).
38+
ACL_RETRIEVAL_MIN_SCORE=1.5
39+
# Margem mínima entre 1.º e 2.º score; evita ambos muito próximos (ambiguidade).
40+
ACL_RETRIEVAL_MIN_SCORE_MARGIN=0.15
41+
# Cobertura mínima (0–1) dos termos informativos da query nos chunks escolhidos.
42+
ACL_RETRIEVAL_MIN_COVERAGE=0.34
43+
# Igual, mas termos "centrais" da query contam com peso 2× na métrica.
44+
ACL_RETRIEVAL_MIN_COVERAGE_WEIGHTED=0.34
45+
# Número mínimo de termos informativos na pergunta (strict).
46+
ACL_RETRIEVAL_MIN_TERMS=2
47+
# Quantos candidatos o SearchEngine devolve antes de build_decision (1–50).
48+
ACL_RETRIEVAL_CANDIDATE_K=8
49+
# Quantos chunks entram no prompt após passar os gates (1–20).
50+
ACL_RETRIEVAL_TOP_K=4
51+
# Máximo de chunks por fonte (slug) entre os selecionados — diversidade (1–10).
52+
ACL_RETRIEVAL_MAX_CHUNKS_PER_SOURCE=2
53+
54+
# --- Logging ---
55+
# text = linha legivel; json = uma linha JSON por evento ACL (grep/agregadores).
56+
# ACL_LOG_FORMAT=text
57+
# ACL_LOG_LEVEL=INFO
58+
# ACL_LOG_LEVEL=DEBUG # inclui evento retrieval_gates_inputs (metricas antes dos cortes)
59+
60+
# --- Não configurável por .env (definido em core/config.py) ---
61+
# Lista de modelos OpenRouter, URL base, timeout HTTP, textos de system/sticky vêm de ficheiros
62+
# em core/systemPrompt/ — ajusta lá ou no código se precisares de outro stack de modelos.

.gitignore

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
.env
2-
3-
.pytest_cache/
4-
5-
**/__pycache__/
6-
*.py[cod]
7-
*$py.class
8-
*.so
9-
10-
cursor/
1+
.env
2+
3+
.pytest_cache/
4+
5+
**/__pycache__/
6+
*.py[cod]
7+
*$py.class
8+
*.so
9+
10+
cursor/

README.md

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,29 @@ Agente de contexto local com RAG (BM25) sobre **MySQL**, interface em `templates
1616
pip install -r requirements.txt
1717
```
1818

19-
## Configuração do banco
19+
## Setup automático (recomendado)
20+
21+
1. Copie `.env.example``.env` e preencha **OPENROUTER_API_KEY**, **DB_HOST**, **DB_NAME**, **DB_USER**, **DB_PASSWORD** (MySQL com permissão `CREATE DATABASE` no host indicado).
22+
23+
2. Na raiz do repositório:
24+
25+
```bash
26+
python scripts/setup_local.py
27+
```
28+
29+
Isto instala dependências (`pip install -r requirements.txt`), cria a base (`DB_NAME`), aplica `SQL/schema.sql` e ingere todos os `.md` de `content/` na tabela `knowledge`.
30+
31+
Opções: `--no-pip`, `--skip-ingest` (só base + schema), `--dry-run` (só simula contagem de ingestão), `-v`.
32+
33+
Windows (PowerShell), a partir da raiz:
34+
35+
```powershell
36+
.\scripts\setup_local.ps1
37+
```
38+
39+
3. Inicie a aplicação: `python main.py`.
40+
41+
## Configuração manual do banco (alternativa)
2042

2143
1. Crie o banco e a tabela:
2244

@@ -31,9 +53,18 @@ mysql -u root -p pybot < SQL/schema.sql
3153
python scripts/ingest_content.py
3254
```
3355

34-
Flags úteis: `--dry-run` (só valida, sem escrita), `--only-discipline python`, `--verbose`.
56+
Flags úteis: `--dry-run` (sem escrita no MySQL), `-v`.
57+
58+
3. Configure o `.env` na raiz (não commitar). Modelo comentado e lista completa de variáveis: **`.env.example`**.
3559

36-
3. Configure o `.env`:
60+
```bash
61+
# Windows (cmd/PowerShell)
62+
copy .env.example .env
63+
# Linux / macOS
64+
# cp .env.example .env
65+
```
66+
67+
Valores mínimos:
3768

3869
```env
3970
OPENROUTER_API_KEY=sua_chave
@@ -45,6 +76,8 @@ DB_USER=root
4576
DB_PASSWORD=sua_senha
4677
```
4778

79+
Opcionais (`ACL_*`, retrieval): ver comentários em `.env.example` e tabela no README abaixo.
80+
4881
## Executar
4982

5083
```bash

app/factory.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,5 @@ async def lifespan(app: FastAPI):
3939
app.mount("/src", StaticFiles(directory=str(src_dir)), name="src")
4040

4141
app.include_router(router)
42+
4243
return app

core/config.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import logging
56
import os
67
from dataclasses import dataclass
78
from pathlib import Path
@@ -11,6 +12,19 @@
1112

1213
GlobalContextMode = Literal["geral", "all"]
1314

15+
_LOG = logging.getLogger("kernelbots.config")
16+
17+
18+
def _normalize_db_host(raw: str) -> str:
19+
"""127.0.0.0 é typo frequente; o loopback usual é 127.0.0.1."""
20+
h = (raw or "").strip().strip("'\"")
21+
if h == "127.0.0.0":
22+
_LOG.warning(
23+
"DB_HOST era '127.0.0.0'; a usar '127.0.0.1'. Corrija o .env para evitar este aviso."
24+
)
25+
return "127.0.0.1"
26+
return h
27+
1428

1529
@dataclass(frozen=True)
1630
class Settings:
@@ -149,7 +163,7 @@ def _env_int(name: str, default: int, lo: int, hi: int) -> int:
149163

150164
""" !Credenciais do banco! """
151165

152-
db_host = (os.getenv("DB_HOST") or "").strip()
166+
db_host = _normalize_db_host(os.getenv("DB_HOST") or "")
153167

154168
db_port_raw = (os.getenv("DB_PORT") or "3306").strip()
155169

core/logging_config.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,25 @@
33
from __future__ import annotations
44

55
import logging
6+
import os
67

8+
from core.structured_log import AclLogFormatter
79

8-
def configure_logging(level: int = logging.INFO) -> None:
9-
"""Configura o root logger uma vez; loggers filhos herdam o handler."""
10+
11+
def configure_logging(level: int | None = None) -> None:
12+
"""Configura o root uma vez.
13+
14+
- ``ACL_LOG_FORMAT=json`` — uma linha JSON por evento ACL (``log_event``).
15+
- ``ACL_LOG_LEVEL=DEBUG`` — inclui eventos DEBUG (ex.: inputs antes dos gates).
16+
"""
1017
root = logging.getLogger()
1118
if root.handlers:
1219
return
13-
logging.basicConfig(
14-
level=level,
15-
format="%(asctime)s %(levelname)-8s [%(name)s] %(message)s",
16-
datefmt="%H:%M:%S",
17-
)
20+
if level is None:
21+
name = (os.getenv("ACL_LOG_LEVEL") or "INFO").strip().upper()
22+
level = getattr(logging, name, logging.INFO)
23+
json_mode = (os.getenv("ACL_LOG_FORMAT") or "text").strip().lower() == "json"
24+
handler = logging.StreamHandler()
25+
handler.setFormatter(AclLogFormatter(json_mode=json_mode))
26+
root.addHandler(handler)
27+
root.setLevel(level)

core/structured_log.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Logging estruturado (JSON ou texto) para auditoria e debugging do ACL.
2+
3+
Campos estáveis em ``metadata`` (snake_case):
4+
query, top_score, second_score, score_margin, decision, reason, confidence,
5+
mode, discipline_filter, informative_terms, coverage, coverage_weighted,
6+
candidate_count, selected_sources, llm_called, tokens_used, model, ...
7+
8+
Variável de ambiente: ACL_LOG_FORMAT=json | text (default: text).
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import datetime as _dt
14+
import json
15+
import logging
16+
import os
17+
from typing import Any, Mapping
18+
19+
# Módulos lógicos (filtro em ferramentas: module=...)
20+
ACL_MOD_SEARCH = "search"
21+
ACL_MOD_CONTEXT = "context"
22+
ACL_MOD_DECISION = "decision"
23+
ACL_MOD_PROVIDER = "provider"
24+
ACL_MOD_EVALUATION = "evaluation"
25+
ACL_MOD_DATABASE = "database"
26+
27+
ACL_EXTRA = "acl_payload"
28+
_MAX_QUERY_META = 512
29+
_MAX_LIST_META = 24
30+
31+
32+
def _truncate(s: str, n: int) -> str:
33+
s = str(s)
34+
if len(s) <= n:
35+
return s
36+
return s[: n - 3] + "..."
37+
38+
39+
def _sanitize_metadata(meta: Mapping[str, Any] | None) -> dict[str, Any]:
40+
if not meta:
41+
return {}
42+
out: dict[str, Any] = {}
43+
for k, v in meta.items():
44+
if k == "query" and isinstance(v, str):
45+
out[k] = _truncate(v, _MAX_QUERY_META)
46+
elif k == "debug" and isinstance(v, dict):
47+
keys = list(v.keys())[:18]
48+
out[k] = {kk: v[kk] for kk in keys}
49+
if len(v) > len(keys):
50+
out[k]["_truncated"] = len(v) - len(keys)
51+
elif isinstance(v, (list, tuple)) and len(v) > _MAX_LIST_META:
52+
out[k] = list(v[:_MAX_LIST_META]) + [f"...(+{len(v) - _MAX_LIST_META})"]
53+
else:
54+
out[k] = v
55+
return out
56+
57+
58+
def log_event(
59+
logger: logging.Logger,
60+
level: int,
61+
module: str,
62+
event: str,
63+
message: str,
64+
metadata: Mapping[str, Any] | None = None,
65+
) -> None:
66+
"""Um evento ACL = um registo com payload em ``extra`` (Formatter consome)."""
67+
payload = {
68+
"module": module,
69+
"event": event,
70+
"message": message,
71+
"metadata": _sanitize_metadata(dict(metadata) if metadata else None),
72+
}
73+
logger.log(level, message, extra={ACL_EXTRA: payload})
74+
75+
76+
class AclLogFormatter(logging.Formatter):
77+
"""Se o record tiver ``acl_payload``, formata JSON ou linha compacta; senão legado."""
78+
79+
def __init__(self, json_mode: bool, datefmt: str | None = None) -> None:
80+
super().__init__(
81+
fmt="%(asctime)s %(levelname)s [%(name)s] %(message)s",
82+
datefmt=datefmt or "%H:%M:%S",
83+
)
84+
self._json_mode = json_mode
85+
86+
def format(self, record: logging.LogRecord) -> str:
87+
pl = getattr(record, ACL_EXTRA, None)
88+
if isinstance(pl, dict):
89+
ts = _dt.datetime.fromtimestamp(
90+
record.created, tz=_dt.timezone.utc
91+
).isoformat(timespec="milliseconds")
92+
if self._json_mode:
93+
line: dict[str, Any] = {
94+
"timestamp": ts,
95+
"level": record.levelname,
96+
"module": pl["module"],
97+
"event": pl["event"],
98+
"message": pl["message"],
99+
"metadata": pl.get("metadata") or {},
100+
}
101+
return json.dumps(line, ensure_ascii=False, default=str)
102+
meta = pl.get("metadata") or {}
103+
parts = [f"{k}={v!r}" for k, v in list(meta.items())[:14]]
104+
tail = " ".join(parts)
105+
return (
106+
f"{ts} {record.levelname:7} [{pl['module']}] {pl['event']} | {pl['message']}"
107+
+ (f" | {tail}" if tail else "")
108+
)
109+
return super().format(record)

0 commit comments

Comments
 (0)