brain / learning.py
Cachinde
fix: Llama local ZeroGPU correto + fallback resiliente + learning v1 + infra restaurada
7e493b0
Raw History Blame Contribute Delete
7.07 kB
"""Aprendizado v1 — log local JSONL + recuperação de casos parecidos (few-shot).
Sem dependências novas, só stdlib. CPU-friendly, sem GPU.
- Cada troca bot<->cliente é anexada a `conversas.jsonl` (ver `DATA_DIR`).
- `POST /feedback` classifica uma resposta como "boa"/"ma".
- Respostas "ma" nunca voltam como exemplo; "boa" tem prioridade.
- Exemplos parecidos entram no prompt como few-shot (factos e tom).
- O ficheiro nunca deve ir para git (contém números de clientes): ver `.gitignore`.
"""
import json
import os
import re
import threading
import unicodedata
import uuid
from datetime import datetime, timezone
_STOPWORDS = {
"a", "o", "e", "de", "do", "da", "dos", "das", "em", "um", "uma",
"para", "com", "que", "quanto", "quantos", "qual", "quais", "como",
"quando", "onde", "os", "as", "uns", "umas", "no", "na", "nos",
"nas", "ao", "aos", "por", "mais", "muito", "isto", "isso", "este",
"esta", "esse", "essa", "meu", "minha", "seu", "sua", "voce", "você",
"tem", "têm", "e", "se", "qto", "qt", "q", "la", "lá", "ai", "aí",
"entao", "então", "pois", "mas", "ou", "tambem", "também",
}
def _tokens(texto: str) -> set:
sem_acento = unicodedata.normalize("NFKD", texto or "").encode("ascii", "ignore").decode()
palavras = re.findall(r"[a-z0-9]+", sem_acento.lower())
return {p for p in palavras if len(p) > 2 and p not in _STOPWORDS}
class LearningStore:
"""Guarda trocas em JSONL e recupera casos parecidos."""
def __init__(
self,
data_dir: str | None = None,
arquivo: str = "conversas.jsonl",
max_linhas: int = 2000,
top_k: int = 2,
min_score: float = 0.15,
):
self.data_dir = data_dir or os.getenv("DATA_DIR", "./data")
self.arquivo = arquivo
self.max_linhas = int(os.getenv("LEARNING_MAX_LINHAS", str(max_linhas)))
self.top_k = int(os.getenv("LEARNING_TOP_K", str(top_k)))
self.min_score = float(os.getenv("LEARNING_MIN_SCORE", str(min_score)))
self._lock = threading.Lock()
try:
os.makedirs(self.data_dir, exist_ok=True)
except Exception as exc:
print(f"[learning] não consegui criar {self.data_dir}: {exc}")
def _caminho(self) -> str:
return os.path.join(self.data_dir, self.arquivo)
def _ler_tudo(self) -> list:
registos = []
try:
with open(self._caminho(), "r", encoding="utf-8") as fh:
for linha in fh:
linha = linha.strip()
if not linha:
continue
try:
registos.append(json.loads(linha))
except json.JSONDecodeError:
continue
except FileNotFoundError:
pass
except Exception as exc:
print(f"[learning] erro a ler: {exc}")
return registos
def log_exchange(self, cliente: str, pergunta: str, resposta: str,
provedor: str = "", modelo: str = "",
mensagem_id: str | None = None) -> str:
mensagem_id = mensagem_id or uuid.uuid4().hex[:12]
registo = {
"id": mensagem_id,
"ts": datetime.now(timezone.utc).isoformat(),
"cliente": cliente or "unknown",
"pergunta": (pergunta or "")[:1000],
"resposta": (resposta or "")[:1000],
"provedor": provedor,
"modelo": modelo,
"avaliacao": None,
}
try:
with self._lock:
with open(self._caminho(), "a", encoding="utf-8") as fh:
fh.write(json.dumps(registo, ensure_ascii=False) + "\n")
self._podar()
except Exception as exc:
print(f"[learning] erro a registar: {exc}")
return mensagem_id
def _podar(self) -> None:
try:
with open(self._caminho(), "r", encoding="utf-8") as fh:
linhas = fh.readlines()
if len(linhas) > self.max_linhas + 500:
with open(self._caminho(), "w", encoding="utf-8") as fh:
fh.writelines(linhas[-self.max_linhas:])
except FileNotFoundError:
pass
except Exception as exc:
print(f"[learning] erro a podar: {exc}")
def rate(self, mensagem_id: str, nota: str, observacao: str = "") -> bool:
if nota not in ("boa", "ma"):
return False
try:
with self._lock:
registos = self._ler_tudo()
achou = False
for reg in registos:
if reg.get("id") == mensagem_id:
reg["avaliacao"] = nota
if observacao:
reg["observacao"] = observacao[:500]
achou = True
break
if not achou:
return False
with open(self._caminho(), "w", encoding="utf-8") as fh:
for reg in registos:
fh.write(json.dumps(reg, ensure_ascii=False) + "\n")
return True
except Exception as exc:
print(f"[learning] erro a avaliar: {exc}")
return False
def find_similar(self, pergunta: str, k: int | None = None) -> list:
base = _tokens(pergunta)
if not base:
return []
candidatos = []
try:
for reg in self._ler_tudo():
if reg.get("avaliacao") == "ma":
continue
pontos = _tokens(reg.get("pergunta", ""))
if not pontos:
continue
uniao = base | pontos
score = len(base & pontos) / len(uniao) if uniao else 0.0
if reg.get("avaliacao") == "boa":
score = min(1.0, score + 0.25)
if score >= self.min_score:
candidatos.append((score, reg))
except Exception as exc:
print(f"[learning] erro a procurar: {exc}")
return []
candidatos.sort(key=lambda item: item[0], reverse=True)
return [reg for _, reg in candidatos[: (k or self.top_k)]]
def format_examples(self, pergunta: str) -> str:
exemplos = self.find_similar(pergunta)
if not exemplos:
return ""
blocos = ["EXEMPLOS DE ATENDIMENTOS ANTERIORES (segue o mesmo padrão de factos e tom):"]
total = len(blocos[0])
for i, reg in enumerate(exemplos, 1):
perg = (reg.get("pergunta", "") or "")[:200]
resp = (reg.get("resposta", "") or "")[:300]
bloco = f"{i}) Cliente: {perg}\n Plenitude: {resp}"
if total + len(bloco) > 900:
break
blocos.append(bloco)
total += len(bloco)
if len(blocos) < 2:
return ""
return "\n".join(blocos)
def count(self) -> int:
return len(self._ler_tudo())