Spaces:
Running
Running
Upload 22 files
Browse files- modules/__init__.py +122 -0
- modules/api.py +0 -0
- modules/aprendizado_continuo.py +153 -0
- modules/computervision.py +370 -0
- modules/config.py +0 -0
- modules/context_builder.py +607 -0
- modules/context_isolation.py +568 -0
- modules/contexto.py +972 -454
- modules/database.py +853 -1112
- modules/doc_analyzer.py +80 -0
- modules/improved_context_handler.py +375 -0
- modules/local_llm.py +532 -0
- modules/nlp_avancado.py +701 -0
- modules/persona_tracker.py +121 -0
- modules/reply_context_handler.py +697 -0
- modules/short_term_memory.py +730 -0
- modules/treinamento.py +856 -1076
- modules/treinamento_modelo.py +103 -0
- modules/unified_context.py +894 -0
- modules/web_search.py +975 -408
modules/__init__.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# type: ignore
|
| 2 |
+
"""
|
| 3 |
+
AKIRA V21 ULTIMATE - Módulos Core
|
| 4 |
+
===============================
|
| 5 |
+
Arquitetura modular para IA conversacional com análise emocional BART.
|
| 6 |
+
Inclui aprendizado contínuo, escuta global e visão computacional.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
__version__ = "21.01.2025"
|
| 10 |
+
__author__ = "Isaac Quarenta"
|
| 11 |
+
|
| 12 |
+
# Exportações principais
|
| 13 |
+
from .config import (
|
| 14 |
+
APP_NAME,
|
| 15 |
+
APP_VERSION,
|
| 16 |
+
DEBUG_MODE,
|
| 17 |
+
NLP_CONFIG,
|
| 18 |
+
SYSTEM_PROMPT,
|
| 19 |
+
PRIVILEGED_USERS,
|
| 20 |
+
EmotionAnalyzer,
|
| 21 |
+
MemoriaEmocional,
|
| 22 |
+
get_emotion_analyzer,
|
| 23 |
+
validate_config,
|
| 24 |
+
# NLP Avançado exports - CORRIGIDO
|
| 25 |
+
NLPAdvancedConfig,
|
| 26 |
+
AdvancedNLP,
|
| 27 |
+
get_advanced_nlp,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
from .database import Database
|
| 31 |
+
|
| 32 |
+
from .contexto import Contexto, criar_contexto
|
| 33 |
+
|
| 34 |
+
# Import API com tratamento de erro
|
| 35 |
+
try:
|
| 36 |
+
from .api import AkiraAPI, get_blueprint
|
| 37 |
+
API_AVAILABLE = True
|
| 38 |
+
except ImportError as e:
|
| 39 |
+
print(f"Aviso: API não disponível - {e}")
|
| 40 |
+
API_AVAILABLE = False
|
| 41 |
+
|
| 42 |
+
# Aprendizado contínuo - é um módulo opcional
|
| 43 |
+
APRENDIZADO_CONTINUO_AVAILABLE = False
|
| 44 |
+
try:
|
| 45 |
+
from .aprendizado_continuo import (
|
| 46 |
+
AprendizadoContinuo,
|
| 47 |
+
get_aprendizado_continuo,
|
| 48 |
+
processar_conversa_global,
|
| 49 |
+
ConversaGlobal,
|
| 50 |
+
APIContextScore,
|
| 51 |
+
)
|
| 52 |
+
APRENDIZADO_CONTINUO_AVAILABLE = True
|
| 53 |
+
except ImportError as e:
|
| 54 |
+
print(f"Aviso: Aprendizado Continuo nao disponivel - {e}")
|
| 55 |
+
|
| 56 |
+
# Visão Computacional - módulo opcional (requer OpenCV e Tesseract)
|
| 57 |
+
COMPUTER_VISION_AVAILABLE = False
|
| 58 |
+
try:
|
| 59 |
+
from .computervision import (
|
| 60 |
+
ComputerVision,
|
| 61 |
+
get_computer_vision,
|
| 62 |
+
VisionConfig,
|
| 63 |
+
ImageFeature,
|
| 64 |
+
analyze_image_from_base64,
|
| 65 |
+
analyze_image_file,
|
| 66 |
+
)
|
| 67 |
+
COMPUTER_VISION_AVAILABLE = True
|
| 68 |
+
except ImportError as e:
|
| 69 |
+
print(f"Aviso: Visão Computacional não disponível - {e}")
|
| 70 |
+
|
| 71 |
+
__all__ = [
|
| 72 |
+
# Config
|
| 73 |
+
"APP_NAME",
|
| 74 |
+
"APP_VERSION",
|
| 75 |
+
"DEBUG_MODE",
|
| 76 |
+
"NLP_CONFIG",
|
| 77 |
+
"SYSTEM_PROMPT",
|
| 78 |
+
"PRIVILEGED_USERS",
|
| 79 |
+
"EmotionAnalyzer",
|
| 80 |
+
"MemoriaEmocional",
|
| 81 |
+
"get_emotion_analyzer",
|
| 82 |
+
"validate_config",
|
| 83 |
+
# NLP Avançado
|
| 84 |
+
"NLPAdvancedConfig",
|
| 85 |
+
"AdvancedNLP",
|
| 86 |
+
"get_advanced_nlp",
|
| 87 |
+
# Database
|
| 88 |
+
"Database",
|
| 89 |
+
# Contexto
|
| 90 |
+
"Contexto",
|
| 91 |
+
"criar_contexto",
|
| 92 |
+
# API
|
| 93 |
+
"AkiraAPI",
|
| 94 |
+
"get_blueprint",
|
| 95 |
+
"API_AVAILABLE",
|
| 96 |
+
# Aprendizado Continuo
|
| 97 |
+
"APRENDIZADO_CONTINUO_AVAILABLE",
|
| 98 |
+
# Visão Computacional
|
| 99 |
+
"COMPUTER_VISION_AVAILABLE",
|
| 100 |
+
]
|
| 101 |
+
|
| 102 |
+
# Adiciona Aprendizado Continuo se disponível
|
| 103 |
+
if APRENDIZADO_CONTINUO_AVAILABLE:
|
| 104 |
+
__all__.extend([
|
| 105 |
+
"AprendizadoContinuo",
|
| 106 |
+
"get_aprendizado_continuo",
|
| 107 |
+
"processar_conversa_global",
|
| 108 |
+
"ConversaGlobal",
|
| 109 |
+
"APIContextScore",
|
| 110 |
+
])
|
| 111 |
+
|
| 112 |
+
# Adiciona Visão Computacional se disponível
|
| 113 |
+
if COMPUTER_VISION_AVAILABLE:
|
| 114 |
+
__all__.extend([
|
| 115 |
+
"ComputerVision",
|
| 116 |
+
"get_computer_vision",
|
| 117 |
+
"VisionConfig",
|
| 118 |
+
"ImageFeature",
|
| 119 |
+
"analyze_image_from_base64",
|
| 120 |
+
"analyze_image_file",
|
| 121 |
+
])
|
| 122 |
+
|
modules/api.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
modules/aprendizado_continuo.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# type: ignore
|
| 2 |
+
"""
|
| 3 |
+
Aprendizado contínuo simples para AKIRA V21
|
| 4 |
+
- Registra todas as mensagens (PV/Grupo), replies e respostas geradas
|
| 5 |
+
- Persiste em JSONL em data/continuous_learning.jsonl
|
| 6 |
+
- Fornece contexto global resumido para alimentar o LLM quando solicitado
|
| 7 |
+
- Sugere melhor API baseada em heurísticas leves
|
| 8 |
+
"""
|
| 9 |
+
import os
|
| 10 |
+
import json
|
| 11 |
+
import time
|
| 12 |
+
import threading
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Optional, Dict, Any, List
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
import modules.config as config
|
| 18 |
+
except ImportError: # fallback relativo
|
| 19 |
+
import config
|
| 20 |
+
|
| 21 |
+
DATA_DIR: Path = getattr(config, 'DATA_DIR', Path('./data'))
|
| 22 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 23 |
+
|
| 24 |
+
JSONL_PATH: Path = DATA_DIR / 'continuous_learning.jsonl'
|
| 25 |
+
LOCK = threading.Lock()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class AprendizadoContinuo:
|
| 29 |
+
def __init__(self, jsonl_path: Path):
|
| 30 |
+
self.path = jsonl_path
|
| 31 |
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
| 32 |
+
# índice leve em memória (opcional)
|
| 33 |
+
self._buffer: List[Dict[str, Any]] = []
|
| 34 |
+
self._buffer_limit = 2000
|
| 35 |
+
|
| 36 |
+
def _append_jsonl(self, row: Dict[str, Any]) -> None:
|
| 37 |
+
with LOCK:
|
| 38 |
+
with self.path.open('a', encoding='utf-8') as f:
|
| 39 |
+
f.write(json.dumps(row, ensure_ascii=False) + '\n')
|
| 40 |
+
self._buffer.append(row)
|
| 41 |
+
if len(self._buffer) > self._buffer_limit:
|
| 42 |
+
self._buffer = self._buffer[-self._buffer_limit:]
|
| 43 |
+
|
| 44 |
+
def _now_ts(self) -> float:
|
| 45 |
+
return time.time()
|
| 46 |
+
|
| 47 |
+
def processar_mensagem(
|
| 48 |
+
self,
|
| 49 |
+
mensagem: str,
|
| 50 |
+
usuario: str,
|
| 51 |
+
numero: str,
|
| 52 |
+
nome_usuario: Optional[str] = None,
|
| 53 |
+
tipo_conversa: str = 'pv', # 'pv' ou 'grupo'
|
| 54 |
+
resposta_do_bot: bool = False,
|
| 55 |
+
resposta_gerada: Optional[str] = None,
|
| 56 |
+
is_reply: bool = False,
|
| 57 |
+
reply_to_bot: bool = False,
|
| 58 |
+
contexto_grupo: Optional[str] = None,
|
| 59 |
+
) -> Dict[str, Any]:
|
| 60 |
+
"""Registra evento para aprendizado contínuo e retorna análise leve."""
|
| 61 |
+
mensagem_norm = (mensagem or '').strip()
|
| 62 |
+
if not mensagem_norm:
|
| 63 |
+
return {'status': 'ignored', 'motivo': 'mensagem_vazia'}
|
| 64 |
+
|
| 65 |
+
row = {
|
| 66 |
+
'ts': self._now_ts(),
|
| 67 |
+
'usuario': usuario,
|
| 68 |
+
'numero': numero,
|
| 69 |
+
'nome_usuario': nome_usuario or usuario,
|
| 70 |
+
'tipo_conversa': tipo_conversa,
|
| 71 |
+
'mensagem': mensagem_norm[:4000],
|
| 72 |
+
'resposta_do_bot': bool(resposta_do_bot),
|
| 73 |
+
'resposta_gerada': (resposta_gerada or '')[:4000] if resposta_do_bot else None,
|
| 74 |
+
'is_reply': bool(is_reply),
|
| 75 |
+
'reply_to_bot': bool(reply_to_bot),
|
| 76 |
+
'contexto_grupo': contexto_grupo or '',
|
| 77 |
+
}
|
| 78 |
+
self._append_jsonl(row)
|
| 79 |
+
|
| 80 |
+
analise = {
|
| 81 |
+
'comprimento': len(mensagem_norm.split()),
|
| 82 |
+
'tem_link': ('http://' in mensagem_norm) or ('https://' in mensagem_norm),
|
| 83 |
+
'tem_interrogacao': '?' in mensagem_norm,
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
aprendizado = {'armazenado_em': str(self.path)}
|
| 87 |
+
return {'ok': True, 'analise': analise, 'aprendizado': aprendizado}
|
| 88 |
+
|
| 89 |
+
def obter_contexto_para_llm(self, topico: Optional[str] = None, limite: int = 10) -> List[str]:
|
| 90 |
+
"""Retorna últimas N mensagens (opcionalmente filtradas por tópico simples)."""
|
| 91 |
+
linhas: List[str] = []
|
| 92 |
+
# Lê somente o necessário (últimas ~2000 linhas, se arquivo grande)
|
| 93 |
+
try:
|
| 94 |
+
if self.path.exists():
|
| 95 |
+
with self.path.open('r', encoding='utf-8') as f:
|
| 96 |
+
for line in f:
|
| 97 |
+
linhas.append(line)
|
| 98 |
+
# Limita memória
|
| 99 |
+
linhas = linhas[-2000:]
|
| 100 |
+
except Exception:
|
| 101 |
+
pass
|
| 102 |
+
|
| 103 |
+
registros: List[Dict[str, Any]] = []
|
| 104 |
+
for line in linhas[-500:]: # parse apenas últimas 500
|
| 105 |
+
try:
|
| 106 |
+
registros.append(json.loads(line))
|
| 107 |
+
except Exception:
|
| 108 |
+
continue
|
| 109 |
+
|
| 110 |
+
# filtra
|
| 111 |
+
if topico:
|
| 112 |
+
t = topico.lower().strip()
|
| 113 |
+
registros = [r for r in registros if t in (r.get('mensagem', '').lower())]
|
| 114 |
+
|
| 115 |
+
# monta blocos curtos para contexto
|
| 116 |
+
blocos: List[str] = []
|
| 117 |
+
for r in registros[-limite:]:
|
| 118 |
+
autor = r.get('nome_usuario') or r.get('usuario')
|
| 119 |
+
msg = r.get('mensagem', '')
|
| 120 |
+
tipo = r.get('tipo_conversa', 'pv')
|
| 121 |
+
blocos.append(f"[{tipo}] {autor}: {msg}")
|
| 122 |
+
return blocos
|
| 123 |
+
|
| 124 |
+
def get_best_api_for_context(
|
| 125 |
+
self,
|
| 126 |
+
complexidade: float = 0.5,
|
| 127 |
+
emocao: str = 'neutral',
|
| 128 |
+
intencao: str = 'afirmacao',
|
| 129 |
+
tipo_conversa: str = 'pv',
|
| 130 |
+
) -> str:
|
| 131 |
+
"""Heurística simples para escolher melhor API."""
|
| 132 |
+
# Preferir Groq (rápido) para baixa complexidade; Gemini/Mistral para maior complexidade
|
| 133 |
+
if complexidade >= 0.7:
|
| 134 |
+
if getattr(config, 'MISTRAL_API_KEY', ''):
|
| 135 |
+
return 'mistral'
|
| 136 |
+
if getattr(config, 'GEMINI_API_KEY', ''):
|
| 137 |
+
return 'gemini'
|
| 138 |
+
# caso contrário
|
| 139 |
+
if getattr(config, 'GROQ_API_KEY', ''):
|
| 140 |
+
return 'groq'
|
| 141 |
+
if getattr(config, 'GROK_API_KEY', ''):
|
| 142 |
+
return 'grok'
|
| 143 |
+
return 'llama'
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
_singleton: Optional[AprendizadoContinuo] = None
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def get_aprendizado_continuo() -> AprendizadoContinuo:
|
| 150 |
+
global _singleton
|
| 151 |
+
if _singleton is None:
|
| 152 |
+
_singleton = AprendizadoContinuo(JSONL_PATH)
|
| 153 |
+
return _singleton
|
modules/computervision.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# type: ignore
|
| 2 |
+
"""
|
| 3 |
+
modules/computervision.py
|
| 4 |
+
================================================================================
|
| 5 |
+
VISION AI MÓDULO - MULTIMODAL GEMINI + QR CODE + fallback OCR
|
| 6 |
+
================================================================================
|
| 7 |
+
Versão 3.0 - AKIRA "The Seer"
|
| 8 |
+
|
| 9 |
+
Este módulo evoluiu de detecção de bordas para entendimento semântico.
|
| 10 |
+
Pipeline de Processamento:
|
| 11 |
+
1. Gemini Vision (Multimodal): Descrição de cena, objetos, cores e contexto.
|
| 12 |
+
2. QR Code Scanner: Extração de dados de códigos QR.
|
| 13 |
+
3. OCR (Tesseract): Extração de texto (fallback para técnica/precisão).
|
| 14 |
+
4. CV2 Analytics: Contagem de formas e objetos (Haar Cascades).
|
| 15 |
+
5. RAG Visual: Armazena hashes de imagens conhecidas para lembrança rápida.
|
| 16 |
+
|
| 17 |
+
Diferente da V2, este módulo não apenas "vê" pixels, ele "entende" a imagem.
|
| 18 |
+
================================================================================
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import os
|
| 22 |
+
import io
|
| 23 |
+
import json
|
| 24 |
+
import time
|
| 25 |
+
import base64
|
| 26 |
+
import hashlib
|
| 27 |
+
import sqlite3
|
| 28 |
+
from datetime import datetime
|
| 29 |
+
from typing import Dict, Any, List, Optional, Tuple, Union
|
| 30 |
+
from dataclasses import dataclass
|
| 31 |
+
from loguru import logger
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
from .config import DB_PATH
|
| 35 |
+
except (ImportError, ValueError):
|
| 36 |
+
try:
|
| 37 |
+
from modules.config import DB_PATH
|
| 38 |
+
except ImportError:
|
| 39 |
+
DB_PATH = "akira.db"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ============================================================
|
| 43 |
+
# Imports Lazy para Performance
|
| 44 |
+
# ============================================================
|
| 45 |
+
_cv2 = None
|
| 46 |
+
_np = None
|
| 47 |
+
_pytesseract = None
|
| 48 |
+
_PIL_Image = None
|
| 49 |
+
_genai = None
|
| 50 |
+
|
| 51 |
+
def _check_core_deps():
|
| 52 |
+
global _cv2, _np, _pytesseract, _PIL_Image, _genai
|
| 53 |
+
try:
|
| 54 |
+
import cv2 as cv
|
| 55 |
+
import numpy as np
|
| 56 |
+
import pytesseract as pt
|
| 57 |
+
from PIL import Image as PILImg
|
| 58 |
+
_cv2, _np, _pytesseract, _PIL_Image = cv, np, pt, PILImg
|
| 59 |
+
|
| 60 |
+
# Google GenAI (nova API)
|
| 61 |
+
try:
|
| 62 |
+
import google.genai as genai_new
|
| 63 |
+
_genai = genai_new
|
| 64 |
+
except ImportError:
|
| 65 |
+
try:
|
| 66 |
+
import google.generativeai as genai_old
|
| 67 |
+
_genai = genai_old
|
| 68 |
+
except ImportError:
|
| 69 |
+
_genai = None
|
| 70 |
+
|
| 71 |
+
return True
|
| 72 |
+
except Exception as e:
|
| 73 |
+
logger.warning(f"Visão parcial: {e}")
|
| 74 |
+
return False
|
| 75 |
+
|
| 76 |
+
_DEPS_OK = _check_core_deps()
|
| 77 |
+
|
| 78 |
+
# ============================================================
|
| 79 |
+
# CONFIGURAÇÕES
|
| 80 |
+
# ============================================================
|
| 81 |
+
|
| 82 |
+
@dataclass
|
| 83 |
+
class VisionConfig:
|
| 84 |
+
ocr_lang: str = "por+eng"
|
| 85 |
+
similarity_threshold: float = 0.88
|
| 86 |
+
max_image_res: int = 1200
|
| 87 |
+
enable_gemini: bool = True
|
| 88 |
+
enable_qr: bool = True
|
| 89 |
+
db_path: str = DB_PATH
|
| 90 |
+
|
| 91 |
+
# ============================================================
|
| 92 |
+
# CLASSE PRINCIPAL
|
| 93 |
+
# ============================================================
|
| 94 |
+
|
| 95 |
+
class ComputerVision:
|
| 96 |
+
"""
|
| 97 |
+
Controlador de Visão Computacional de Nova Geração.
|
| 98 |
+
"""
|
| 99 |
+
|
| 100 |
+
def __init__(self, config: Optional[VisionConfig] = None):
|
| 101 |
+
self.config = config or VisionConfig()
|
| 102 |
+
self.db_path = self.config.db_path
|
| 103 |
+
self._setup_db()
|
| 104 |
+
self._init_cascades()
|
| 105 |
+
|
| 106 |
+
# API Key do Gemini (preferencialmente injetada via config)
|
| 107 |
+
self.api_key = os.getenv("GEMINI_API_KEY", "")
|
| 108 |
+
|
| 109 |
+
def _setup_db(self):
|
| 110 |
+
"""Garante tabela de memória visual."""
|
| 111 |
+
try:
|
| 112 |
+
conn = sqlite3.connect(self.db_path)
|
| 113 |
+
c = conn.cursor()
|
| 114 |
+
c.execute("""
|
| 115 |
+
CREATE TABLE IF NOT EXISTS image_memory (
|
| 116 |
+
hash TEXT PRIMARY KEY,
|
| 117 |
+
user_id TEXT,
|
| 118 |
+
description TEXT,
|
| 119 |
+
ocr_text TEXT,
|
| 120 |
+
qr_data TEXT,
|
| 121 |
+
metadata TEXT,
|
| 122 |
+
timestamp DATETIME
|
| 123 |
+
)
|
| 124 |
+
""")
|
| 125 |
+
conn.commit()
|
| 126 |
+
conn.close()
|
| 127 |
+
except Exception as e:
|
| 128 |
+
logger.error(f"Erro DB Visão: {e}")
|
| 129 |
+
|
| 130 |
+
def _init_cascades(self):
|
| 131 |
+
"""Carrega modelos Haar Cascades para detecção básica."""
|
| 132 |
+
if not _cv2: return
|
| 133 |
+
try:
|
| 134 |
+
self._face_cascade = _cv2.CascadeClassifier(_cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
|
| 135 |
+
except:
|
| 136 |
+
self._face_cascade = None
|
| 137 |
+
|
| 138 |
+
# ==================================================================
|
| 139 |
+
# 🎯 PIPELINE PRINCIPAL
|
| 140 |
+
# ==================================================================
|
| 141 |
+
|
| 142 |
+
# ==================================================================
|
| 143 |
+
# PROCESSAMENTO
|
| 144 |
+
# ==================================================================
|
| 145 |
+
|
| 146 |
+
def analyze_image(self, input_data: Union[str, bytes], user_id: str = "anon") -> Dict[str, Any]:
|
| 147 |
+
"""
|
| 148 |
+
Processa imagem através de todo o pipeline.
|
| 149 |
+
Aceita: Caminho de arquivo (str), Base64 (str) ou Bytes brutos (bytes).
|
| 150 |
+
"""
|
| 151 |
+
if not input_data: return {"success": False, "error": "Entrada vazia"}
|
| 152 |
+
|
| 153 |
+
img_bytes = None
|
| 154 |
+
|
| 155 |
+
try:
|
| 156 |
+
# 1. Detecção e Normalização da Entrada
|
| 157 |
+
if isinstance(input_data, bytes):
|
| 158 |
+
img_bytes = input_data
|
| 159 |
+
elif isinstance(input_data, str):
|
| 160 |
+
# Caso A: Caminho de arquivo local
|
| 161 |
+
if os.path.isfile(input_data):
|
| 162 |
+
with open(input_data, "rb") as f:
|
| 163 |
+
img_bytes = f.read()
|
| 164 |
+
# Caso B: Base64
|
| 165 |
+
else:
|
| 166 |
+
try:
|
| 167 |
+
b64_str = input_data
|
| 168 |
+
if "," in b64_str: b64_str = b64_str.split(",")[1]
|
| 169 |
+
img_bytes = base64.b64decode(b64_str)
|
| 170 |
+
except Exception:
|
| 171 |
+
return {"success": False, "error": "String informada não é um caminho válido nem Base64 válido"}
|
| 172 |
+
|
| 173 |
+
if not img_bytes:
|
| 174 |
+
return {"success": False, "error": "Falha ao extrair bytes da imagem"}
|
| 175 |
+
|
| 176 |
+
img_hash = hashlib.md5(img_bytes).hexdigest()
|
| 177 |
+
|
| 178 |
+
# 2. Check Memória Visual (Cache BD)
|
| 179 |
+
cached = self._get_from_memory(img_hash)
|
| 180 |
+
if cached:
|
| 181 |
+
logger.info(f"🧠 Memória Visual recordada: {img_hash}")
|
| 182 |
+
cached["cached"] = True
|
| 183 |
+
return cached
|
| 184 |
+
|
| 185 |
+
# 3. Preparação para OCR e CV2
|
| 186 |
+
nparr = _np.frombuffer(img_bytes, _np.uint8)
|
| 187 |
+
img_cv = _cv2.imdecode(nparr, _cv2.IMREAD_COLOR)
|
| 188 |
+
pil_img = _PIL_Image.open(io.BytesIO(img_bytes))
|
| 189 |
+
|
| 190 |
+
# --- EXECUÇÃO DO PIPELINE ---
|
| 191 |
+
|
| 192 |
+
# A. QR Code (Rápido)
|
| 193 |
+
qr_data = self._scan_qr(img_cv) if self.config.enable_qr else None
|
| 194 |
+
|
| 195 |
+
# B. Gemini Vision (Semântico - O Coração)
|
| 196 |
+
descricao = ""
|
| 197 |
+
if self.config.enable_gemini and self.api_key:
|
| 198 |
+
descricao = self._gemini_visual_analyze(img_bytes)
|
| 199 |
+
|
| 200 |
+
# C. OCR (Fallback/Técnico)
|
| 201 |
+
ocr_text = self._run_ocr(pil_img)
|
| 202 |
+
|
| 203 |
+
# D. CV2 Analytics (Estatístico/Objetos)
|
| 204 |
+
analytics = self._run_cv2_analytics(img_cv)
|
| 205 |
+
|
| 206 |
+
# 4. Consolidação
|
| 207 |
+
result = {
|
| 208 |
+
"success": True,
|
| 209 |
+
"hash": img_hash,
|
| 210 |
+
"description": descricao or "Não foi possível descrever a imagem semanticamente.",
|
| 211 |
+
"ocr": ocr_text,
|
| 212 |
+
"qr": qr_data,
|
| 213 |
+
"objects": analytics.get("objects", []),
|
| 214 |
+
"details": {
|
| 215 |
+
"faces": analytics.get("faces", 0),
|
| 216 |
+
"resolution": f"{img_cv.shape[1]}x{img_cv.shape[0]}" if img_cv is not None else "N/A"
|
| 217 |
+
},
|
| 218 |
+
"timestamp": datetime.now().isoformat()
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
# 5. Salva na Memória
|
| 222 |
+
self._save_to_memory(result, user_id)
|
| 223 |
+
|
| 224 |
+
return result
|
| 225 |
+
|
| 226 |
+
except Exception as e:
|
| 227 |
+
logger.exception("Falha no pipeline de visão")
|
| 228 |
+
return {"success": False, "error": str(e)}
|
| 229 |
+
|
| 230 |
+
# ==================================================================
|
| 231 |
+
# 👁️ MOTORES ESPECÍFICOS
|
| 232 |
+
# ==================================================================
|
| 233 |
+
|
| 234 |
+
def _gemini_visual_analyze(self, img_bytes: bytes) -> str:
|
| 235 |
+
"""Usa Google Gemini Multimodal para descrever a imagem."""
|
| 236 |
+
if not _genai or not self.api_key: return ""
|
| 237 |
+
|
| 238 |
+
try:
|
| 239 |
+
# Detecta se é a API nova ou antiga
|
| 240 |
+
if hasattr(_genai, 'Client'): # Nova API google.genai
|
| 241 |
+
client = _genai.Client(api_key=self.api_key)
|
| 242 |
+
# Otimizado para Gemini 2.0 Flash
|
| 243 |
+
model_id = "gemini-2.0-flash" if "2.0-flash" in os.getenv("GEMINI_MODEL", "") else "gemini-1.5-flash"
|
| 244 |
+
|
| 245 |
+
# Detetar MimeType dinâmico
|
| 246 |
+
mime_type = "image/png" if img_bytes.startswith(b"\x89PNG") else "image/jpeg"
|
| 247 |
+
response = client.models.generate_content(
|
| 248 |
+
model=model_id,
|
| 249 |
+
contents=[
|
| 250 |
+
"Descreva esta imagem detalhadamente para uma IA assistente. Fale sobre objetos, cores, ambiente e se houver pessoas, descreva suas expressões.",
|
| 251 |
+
_genai.types.Part.from_bytes(data=img_bytes, mime_type=mime_type),
|
| 252 |
+
]
|
| 253 |
+
)
|
| 254 |
+
return response.text if response else ""
|
| 255 |
+
else:
|
| 256 |
+
# API antiga google.generativeai
|
| 257 |
+
_genai.configure(api_key=self.api_key)
|
| 258 |
+
model = _genai.GenerativeModel('gemini-1.5-flash')
|
| 259 |
+
response = model.generate_content([
|
| 260 |
+
"Descreva esta imagem detalhadamente. Seja direto e informativo.",
|
| 261 |
+
_PIL_Image.open(io.BytesIO(img_bytes))
|
| 262 |
+
])
|
| 263 |
+
return response.text if response else ""
|
| 264 |
+
except Exception as e:
|
| 265 |
+
logger.warning(f"Gemini Vision falhou: {e}")
|
| 266 |
+
return ""
|
| 267 |
+
|
| 268 |
+
def _scan_qr(self, img_cv) -> Optional[str]:
|
| 269 |
+
"""Detecta e decodifica QR Code."""
|
| 270 |
+
if not _cv2 or img_cv is None: return None
|
| 271 |
+
try:
|
| 272 |
+
detector = _cv2.QRCodeDetector()
|
| 273 |
+
data, _, _ = detector.detectAndDecode(img_cv)
|
| 274 |
+
return data if data else None
|
| 275 |
+
except:
|
| 276 |
+
return None
|
| 277 |
+
|
| 278 |
+
def _run_ocr(self, pil_img) -> str:
|
| 279 |
+
"""Extrai texto da imagem via Tesseract."""
|
| 280 |
+
if not _pytesseract: return ""
|
| 281 |
+
try:
|
| 282 |
+
return _pytesseract.image_to_string(pil_img, lang=self.config.ocr_lang).strip()
|
| 283 |
+
except:
|
| 284 |
+
return ""
|
| 285 |
+
|
| 286 |
+
def _run_cv2_analytics(self, img_cv) -> Dict[str, Any]:
|
| 287 |
+
"""Detecta faces e extrai metadados visuais básicos."""
|
| 288 |
+
res = {"faces": 0, "objects": []}
|
| 289 |
+
if not _cv2 or img_cv is None: return res
|
| 290 |
+
|
| 291 |
+
try:
|
| 292 |
+
gray = _cv2.cvtColor(img_cv, _cv2.COLOR_BGR2GRAY)
|
| 293 |
+
# Faces
|
| 294 |
+
if self._face_cascade:
|
| 295 |
+
faces = self._face_cascade.detectMultiScale(gray, 1.1, 4)
|
| 296 |
+
res["faces"] = len(faces)
|
| 297 |
+
if len(faces) > 0: res["objects"].append("pessoa/rosto")
|
| 298 |
+
|
| 299 |
+
# Brilho médio
|
| 300 |
+
avg_color = _np.mean(img_cv, axis=(0, 1))
|
| 301 |
+
res["avg_color_bgr"] = avg_color.tolist()
|
| 302 |
+
|
| 303 |
+
except: pass
|
| 304 |
+
return res
|
| 305 |
+
|
| 306 |
+
# ==================================================================
|
| 307 |
+
# 🗄️ PERSISTÊNCIA (MEMÓRIA VISUAL)
|
| 308 |
+
# ==================================================================
|
| 309 |
+
|
| 310 |
+
def _get_from_memory(self, img_hash: str) -> Optional[Dict]:
|
| 311 |
+
try:
|
| 312 |
+
conn = sqlite3.connect(self.db_path)
|
| 313 |
+
conn.row_factory = sqlite3.Row
|
| 314 |
+
c = conn.cursor()
|
| 315 |
+
c.execute("SELECT * FROM image_memory WHERE hash = ?", (img_hash,))
|
| 316 |
+
row = c.fetchone()
|
| 317 |
+
conn.close()
|
| 318 |
+
|
| 319 |
+
if row:
|
| 320 |
+
res = dict(row)
|
| 321 |
+
return {
|
| 322 |
+
"success": True,
|
| 323 |
+
"hash": res["hash"],
|
| 324 |
+
"description": res["description"],
|
| 325 |
+
"ocr": res["ocr_text"],
|
| 326 |
+
"qr": res["qr_data"],
|
| 327 |
+
"timestamp": res["timestamp"],
|
| 328 |
+
"from_memory": True
|
| 329 |
+
}
|
| 330 |
+
except: pass
|
| 331 |
+
return None
|
| 332 |
+
|
| 333 |
+
def _save_to_memory(self, result: Dict, user_id: str):
|
| 334 |
+
try:
|
| 335 |
+
conn = sqlite3.connect(self.db_path)
|
| 336 |
+
c = conn.cursor()
|
| 337 |
+
c.execute("""
|
| 338 |
+
INSERT OR REPLACE INTO image_memory
|
| 339 |
+
(hash, user_id, description, ocr_text, qr_data, metadata, timestamp)
|
| 340 |
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 341 |
+
""", (
|
| 342 |
+
result["hash"],
|
| 343 |
+
user_id,
|
| 344 |
+
result["description"],
|
| 345 |
+
result["ocr"],
|
| 346 |
+
result["qr"],
|
| 347 |
+
json.dumps(result.get("details", {})),
|
| 348 |
+
result["timestamp"]
|
| 349 |
+
))
|
| 350 |
+
conn.commit()
|
| 351 |
+
conn.close()
|
| 352 |
+
except Exception as e:
|
| 353 |
+
logger.debug(f"Erro ao salvar memória visual: {e}")
|
| 354 |
+
|
| 355 |
+
# ============================================================
|
| 356 |
+
# SINGLETON EXPORT
|
| 357 |
+
# ============================================================
|
| 358 |
+
|
| 359 |
+
_vision_instance = None
|
| 360 |
+
|
| 361 |
+
def get_computer_vision(config=None) -> ComputerVision:
|
| 362 |
+
global _vision_instance
|
| 363 |
+
if _vision_instance is None:
|
| 364 |
+
_vision_instance = ComputerVision(config)
|
| 365 |
+
return _vision_instance
|
| 366 |
+
|
| 367 |
+
def analyze_image_base64(b64_str: str, user_id: str = "anon") -> Dict[str, Any]:
|
| 368 |
+
return get_computer_vision().analyze_image(b64_str, user_id)
|
| 369 |
+
|
| 370 |
+
__all__ = ["ComputerVision", "get_computer_vision", "analyze_image_base64"]
|
modules/config.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
modules/context_builder.py
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# type: ignore
|
| 2 |
+
"""
|
| 3 |
+
================================================================================
|
| 4 |
+
AKIRA V21 ULTIMATE - CONTEXT BUILDER MODULE
|
| 5 |
+
================================================================================
|
| 6 |
+
Constrói prompts otimizados para LLM combinando:
|
| 7 |
+
- Memória de curto prazo (100 mensagens)
|
| 8 |
+
- Contexto de reply (prioritário)
|
| 9 |
+
- Memória vetorial (fatos aprendidos)
|
| 10 |
+
- Contexto emocional
|
| 11 |
+
- Sistema adaptativo baseado em tamanho da pergunta
|
| 12 |
+
|
| 13 |
+
Features:
|
| 14 |
+
- Hierarquia correta de contexto (reply > curto prazo > vetorial)
|
| 15 |
+
- Token budgeting inteligente
|
| 16 |
+
- Ajuste adaptativo para perguntas curtas
|
| 17 |
+
- Suporte a múltiplos provedores LLM
|
| 18 |
+
================================================================================
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import os
|
| 22 |
+
import sys
|
| 23 |
+
import time
|
| 24 |
+
import json
|
| 25 |
+
import logging
|
| 26 |
+
from typing import Optional, Dict, Any, List, Tuple
|
| 27 |
+
from dataclasses import dataclass
|
| 28 |
+
|
| 29 |
+
# Imports robustos com fallback - CORRIGIDO para usar modules.
|
| 30 |
+
try:
|
| 31 |
+
from . import config
|
| 32 |
+
from .context_isolation import ContextIsolationManager, ConversationContext
|
| 33 |
+
from .short_term_memory import ShortTermMemory, MessageWithContext
|
| 34 |
+
from .reply_context_handler import ReplyContextHandler, ProcessedReplyContext
|
| 35 |
+
CONTEXT_BUILDER_AVAILABLE = True
|
| 36 |
+
except ImportError:
|
| 37 |
+
try:
|
| 38 |
+
import modules.config as config
|
| 39 |
+
from modules.context_isolation import ContextIsolationManager, ConversationContext
|
| 40 |
+
from modules.short_term_memory import ShortTermMemory, MessageWithContext
|
| 41 |
+
from modules.reply_context_handler import ReplyContextHandler, ProcessedReplyContext
|
| 42 |
+
CONTEXT_BUILDER_AVAILABLE = True
|
| 43 |
+
except ImportError:
|
| 44 |
+
CONTEXT_BUILDER_AVAILABLE = False
|
| 45 |
+
config = None
|
| 46 |
+
|
| 47 |
+
logger = logging.getLogger(__name__)
|
| 48 |
+
|
| 49 |
+
# ============================================================
|
| 50 |
+
# CONFIGURAÇÃO
|
| 51 |
+
# ============================================================
|
| 52 |
+
|
| 53 |
+
# Token budgets para diferentes componentes
|
| 54 |
+
TOKEN_BUDGET_SYSTEM: int = 1500
|
| 55 |
+
TOKEN_BUDGET_REPLY: int = 800 # Para contexto de reply
|
| 56 |
+
TOKEN_BUDGET_SHORT_TERM: int = 4000 # Para memória de curto prazo
|
| 57 |
+
TOKEN_BUDGET_VECTOR: int = 1000 # Para memória vetorial
|
| 58 |
+
TOKEN_BUDGET_TOTAL: int = 8000 # Total disponível para contexto
|
| 59 |
+
|
| 60 |
+
# Limiares para perguntas curtas
|
| 61 |
+
SHORT_QUESTION_THRESHOLD: int = 5 # palavras
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@dataclass
|
| 65 |
+
class PromptBuildResult:
|
| 66 |
+
"""
|
| 67 |
+
Resultado da construção do prompt.
|
| 68 |
+
|
| 69 |
+
Attributes:
|
| 70 |
+
system_prompt: Prompt do sistema (sem modificação)
|
| 71 |
+
full_prompt: Prompt completo com contexto
|
| 72 |
+
context_sections: Seções de contexto incluídas
|
| 73 |
+
token_counts: Contagem de tokens por seção
|
| 74 |
+
warnings: Avisos sobre limitações
|
| 75 |
+
should_use_vector_memory: Se deve usar memória vetorial
|
| 76 |
+
should_prioritize_reply: Se reply deve ser priorizado
|
| 77 |
+
"""
|
| 78 |
+
system_prompt: str = ""
|
| 79 |
+
full_prompt: str = ""
|
| 80 |
+
context_sections: Dict[str, str] = None
|
| 81 |
+
token_counts: Dict[str, int] = None
|
| 82 |
+
warnings: List[str] = None
|
| 83 |
+
should_use_vector_memory: bool = True
|
| 84 |
+
should_prioritize_reply: bool = False
|
| 85 |
+
|
| 86 |
+
def __post_init__(self):
|
| 87 |
+
if self.context_sections is None:
|
| 88 |
+
self.context_sections = {}
|
| 89 |
+
if self.token_counts is None:
|
| 90 |
+
self.token_counts = {}
|
| 91 |
+
if self.warnings is None:
|
| 92 |
+
self.warnings = []
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# ============================================================
|
| 96 |
+
# FUNÇÕES AUXILIARES
|
| 97 |
+
# ============================================================
|
| 98 |
+
|
| 99 |
+
def estimar_tokens(texto: str) -> int:
|
| 100 |
+
"""Estima tokens em um texto (aproximação para português)."""
|
| 101 |
+
if not texto:
|
| 102 |
+
return 0
|
| 103 |
+
# Média de 4 caracteres por token em português
|
| 104 |
+
return max(1, len(texto) // 4)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def truncar_para_tokens(texto: str, max_tokens: int) -> str:
|
| 108 |
+
"""Trunca texto para caber no limite de tokens."""
|
| 109 |
+
if not texto or max_tokens <= 0:
|
| 110 |
+
return ""
|
| 111 |
+
|
| 112 |
+
tokens = texto.split()
|
| 113 |
+
if len(tokens) <= max_tokens:
|
| 114 |
+
return texto
|
| 115 |
+
|
| 116 |
+
return " ".join(tokens[:max_tokens])
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def is_pergunta_curta(texto: str) -> bool:
|
| 120 |
+
"""Verifica se é uma pergunta curta."""
|
| 121 |
+
if not texto:
|
| 122 |
+
return False
|
| 123 |
+
return len(texto.split()) <= SHORT_QUESTION_THRESHOLD
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def calcular_peso_contexto(
|
| 127 |
+
mensagem: str,
|
| 128 |
+
reply_context: Optional[ProcessedReplyContext] = None
|
| 129 |
+
) -> float:
|
| 130 |
+
"""
|
| 131 |
+
Calcula peso do contexto baseado no tamanho da mensagem e reply.
|
| 132 |
+
|
| 133 |
+
Args:
|
| 134 |
+
mensagem: Mensagem do usuário
|
| 135 |
+
reply_context: Contexto de reply (opcional)
|
| 136 |
+
|
| 137 |
+
Returns:
|
| 138 |
+
Float entre 0.5 e 1.0 representando peso do contexto geral
|
| 139 |
+
"""
|
| 140 |
+
word_count = len(mensagem.split())
|
| 141 |
+
|
| 142 |
+
# Pergunta muito curta = menos contexto geral necessário
|
| 143 |
+
if word_count <= 2:
|
| 144 |
+
return 0.5
|
| 145 |
+
|
| 146 |
+
# Pergunta curta = contexto moderado
|
| 147 |
+
if word_count <= SHORT_QUESTION_THRESHOLD:
|
| 148 |
+
return 0.7
|
| 149 |
+
|
| 150 |
+
# Pergunta normal = contexto completo
|
| 151 |
+
return 1.0
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# ============================================================
|
| 155 |
+
# CLASSE PRINCIPAL
|
| 156 |
+
# ============================================================
|
| 157 |
+
|
| 158 |
+
class ContextBuilder:
|
| 159 |
+
"""
|
| 160 |
+
Construtor de prompts otimizados para LLM.
|
| 161 |
+
|
| 162 |
+
Hierarquia de contexto:
|
| 163 |
+
1. System prompt (fixo)
|
| 164 |
+
2. Reply context (prioritário se existir)
|
| 165 |
+
3. Short-term memory (100 msgs sliding window)
|
| 166 |
+
4. Vector memory (fatos aprendidos)
|
| 167 |
+
5. User message (última)
|
| 168 |
+
|
| 169 |
+
Adaptação para perguntas curtas:
|
| 170 |
+
- Pergunta curta + reply: reply tem 100%, contexto geral 50%
|
| 171 |
+
- Pergunta curta sem reply: contexto geral 70%
|
| 172 |
+
- Pergunta normal: contexto geral 100%
|
| 173 |
+
"""
|
| 174 |
+
|
| 175 |
+
def __init__(self, config_module=None):
|
| 176 |
+
"""
|
| 177 |
+
Inicializa o builder.
|
| 178 |
+
|
| 179 |
+
Args:
|
| 180 |
+
config_module: Módulo de configuração (usa config se None)
|
| 181 |
+
"""
|
| 182 |
+
self.config = config_module or config
|
| 183 |
+
self.isolation_manager = None
|
| 184 |
+
self._initialized = False
|
| 185 |
+
|
| 186 |
+
if CONTEXT_BUILDER_AVAILABLE:
|
| 187 |
+
try:
|
| 188 |
+
self.isolation_manager = ContextIsolationManager()
|
| 189 |
+
self._initialized = True
|
| 190 |
+
except Exception as e:
|
| 191 |
+
logger.warning(f"ContextBuilder: falha ao init isolation: {e}")
|
| 192 |
+
|
| 193 |
+
def _ensure_initialized(self):
|
| 194 |
+
"""Garante inicialização."""
|
| 195 |
+
if not self._initialized and CONTEXT_BUILDER_AVAILABLE:
|
| 196 |
+
try:
|
| 197 |
+
self.isolation_manager = ContextIsolationManager()
|
| 198 |
+
self._initialized = True
|
| 199 |
+
except:
|
| 200 |
+
pass
|
| 201 |
+
|
| 202 |
+
def build_prompt(
|
| 203 |
+
self,
|
| 204 |
+
user_message: str,
|
| 205 |
+
conversation_id: str,
|
| 206 |
+
system_prompt: str = None,
|
| 207 |
+
reply_context: Optional[ProcessedReplyContext] = None,
|
| 208 |
+
short_term_memory: Optional[ShortTermMemory] = None,
|
| 209 |
+
vector_memory_info: Optional[List[Dict[str, Any]]] = None,
|
| 210 |
+
emocao_atual: str = "neutral",
|
| 211 |
+
incluir_memoria_vetorial: bool = True,
|
| 212 |
+
max_tokens_contexto: int = TOKEN_BUDGET_TOTAL
|
| 213 |
+
) -> PromptBuildResult:
|
| 214 |
+
"""
|
| 215 |
+
Constrói prompt completo para LLM.
|
| 216 |
+
|
| 217 |
+
Args:
|
| 218 |
+
user_message: Mensagem do usuário
|
| 219 |
+
conversation_id: ID da conversa isolada
|
| 220 |
+
system_prompt: Prompt do sistema (usa config se None)
|
| 221 |
+
reply_context: Contexto de reply (opcional)
|
| 222 |
+
short_term_memory: Memória de curto prazo (opcional)
|
| 223 |
+
vector_memory_info: Fatos da memória vetorial (opcional)
|
| 224 |
+
emocao_atual: Emoção atual do usuário
|
| 225 |
+
incluir_memoria_vetorial: Se deve incluir memória vetorial
|
| 226 |
+
max_tokens_contexto: Máximo de tokens para contexto
|
| 227 |
+
|
| 228 |
+
Returns:
|
| 229 |
+
PromptBuildResult com prompt completo
|
| 230 |
+
"""
|
| 231 |
+
result = PromptBuildResult()
|
| 232 |
+
|
| 233 |
+
# Get system prompt
|
| 234 |
+
system_prompt = system_prompt or getattr(self.config, 'SYSTEM_PROMPT', '')
|
| 235 |
+
result.system_prompt = system_prompt
|
| 236 |
+
|
| 237 |
+
# Inicializa seções
|
| 238 |
+
sections = {
|
| 239 |
+
"system": system_prompt,
|
| 240 |
+
"reply_context": "",
|
| 241 |
+
"short_term_context": "",
|
| 242 |
+
"vector_memory": "",
|
| 243 |
+
"emotional_context": "",
|
| 244 |
+
"user_message": user_message
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
# Contadores de tokens
|
| 248 |
+
tokens = {
|
| 249 |
+
"system": estimar_tokens(system_prompt),
|
| 250 |
+
"reply": 0,
|
| 251 |
+
"short_term": 0,
|
| 252 |
+
"vector": 0,
|
| 253 |
+
"emotional": 0,
|
| 254 |
+
"user": estimar_tokens(user_message)
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
# Remaining budget after system and user
|
| 258 |
+
remaining_budget = max_tokens_contexto - tokens["system"] - tokens["user"]
|
| 259 |
+
|
| 260 |
+
# ===== 1. REPLY CONTEXT (PRIORITÁRIO!) =====
|
| 261 |
+
if reply_context and reply_context.is_reply:
|
| 262 |
+
result.should_prioritize_reply = True
|
| 263 |
+
|
| 264 |
+
# Para perguntas curtas com reply, mais tokens para reply
|
| 265 |
+
if is_pergunta_curta(user_message):
|
| 266 |
+
reply_budget = min(TOKEN_BUDGET_REPLY * 1.5, int(remaining_budget * 0.35))
|
| 267 |
+
remaining_budget -= reply_budget
|
| 268 |
+
else:
|
| 269 |
+
reply_budget = min(TOKEN_BUDGET_REPLY, int(remaining_budget * 0.25))
|
| 270 |
+
remaining_budget -= reply_budget
|
| 271 |
+
|
| 272 |
+
# Constrói section do reply
|
| 273 |
+
reply_section = self._build_reply_section(reply_context, user_message)
|
| 274 |
+
reply_section = truncar_para_tokens(reply_section, reply_budget)
|
| 275 |
+
|
| 276 |
+
sections["reply_context"] = reply_section
|
| 277 |
+
tokens["reply"] = estimar_tokens(reply_section)
|
| 278 |
+
|
| 279 |
+
# ===== 2. SHORT-TERM MEMORY =====
|
| 280 |
+
if short_term_memory:
|
| 281 |
+
# Calcula peso baseado em tamanho da pergunta
|
| 282 |
+
peso_contexto = calcular_peso_contexto(user_message, reply_context)
|
| 283 |
+
stm_budget = min(
|
| 284 |
+
int(TOKEN_BUDGET_SHORT_TERM * peso_contexto),
|
| 285 |
+
int(remaining_budget * 0.7)
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
stm_section = self._build_short_term_section(
|
| 289 |
+
short_term_memory,
|
| 290 |
+
reply_context,
|
| 291 |
+
stm_budget
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
sections["short_term_context"] = stm_section
|
| 295 |
+
tokens["short_term"] = estimar_tokens(stm_section)
|
| 296 |
+
remaining_budget -= tokens["short_term"]
|
| 297 |
+
|
| 298 |
+
# ===== 3. VECTOR MEMORY =====
|
| 299 |
+
if incluir_memoria_vetorial and vector_memory_info:
|
| 300 |
+
vector_budget = min(TOKEN_BUDGET_VECTOR, int(remaining_budget * 0.3))
|
| 301 |
+
|
| 302 |
+
vector_section = self._build_vector_section(vector_memory_info, vector_budget)
|
| 303 |
+
|
| 304 |
+
sections["vector_memory"] = vector_section
|
| 305 |
+
tokens["vector"] = estimar_tokens(vector_section)
|
| 306 |
+
remaining_budget -= tokens["vector"]
|
| 307 |
+
|
| 308 |
+
# ===== 4. EMOTIONAL CONTEXT =====
|
| 309 |
+
emotional_section = self._build_emotional_section(emocao_atual)
|
| 310 |
+
sections["emotional_context"] = emotional_section
|
| 311 |
+
tokens["emotional"] = estimar_tokens(emotional_section)
|
| 312 |
+
|
| 313 |
+
# ===== 5. MONTA PROMPT COMPLETO =====
|
| 314 |
+
prompt_parts = []
|
| 315 |
+
|
| 316 |
+
# System
|
| 317 |
+
if sections["system"]:
|
| 318 |
+
prompt_parts.append(f"[SYSTEM]\n{sections['system']}\n[/SYSTEM]\n")
|
| 319 |
+
|
| 320 |
+
# Emotional context (apenas se não neutral)
|
| 321 |
+
if sections["emotional_context"]:
|
| 322 |
+
prompt_parts.append(f"[EMOÇÃO ATUAL]\n{sections['emotional_context']}\n")
|
| 323 |
+
|
| 324 |
+
# Reply context (prioritário!)
|
| 325 |
+
if sections["reply_context"]:
|
| 326 |
+
prompt_parts.append(f"[REPLY PRIORITÁRIO]\n{sections['reply_context']}\n")
|
| 327 |
+
|
| 328 |
+
# Short-term context
|
| 329 |
+
if sections["short_term_context"]:
|
| 330 |
+
prompt_parts.append(f"[CONTEXTO RECENTE]\n{sections['short_term_context']}\n")
|
| 331 |
+
|
| 332 |
+
# Vector memory
|
| 333 |
+
if sections["vector_memory"]:
|
| 334 |
+
prompt_parts.append(f"[MEMÓRIA APRENDIDA]\n{sections['vector_memory']}\n")
|
| 335 |
+
|
| 336 |
+
# User message
|
| 337 |
+
prompt_parts.append(f"[MENSAGEM]\n{user_message}\n")
|
| 338 |
+
|
| 339 |
+
result.full_prompt = "\n".join(prompt_parts)
|
| 340 |
+
result.context_sections = sections
|
| 341 |
+
result.token_counts = tokens
|
| 342 |
+
|
| 343 |
+
# Warnings se orçamento estourado
|
| 344 |
+
total_tokens = sum(tokens.values())
|
| 345 |
+
if total_tokens > max_tokens_contexto:
|
| 346 |
+
result.warnings.append(f"Contexto grande: {total_tokens} tokens (limite: {max_tokens_contexto})")
|
| 347 |
+
|
| 348 |
+
return result
|
| 349 |
+
|
| 350 |
+
def _build_reply_section(
|
| 351 |
+
self,
|
| 352 |
+
reply_context: ProcessedReplyContext,
|
| 353 |
+
user_message: str
|
| 354 |
+
) -> str:
|
| 355 |
+
"""Constrói seção de reply priorizado."""
|
| 356 |
+
parts = []
|
| 357 |
+
|
| 358 |
+
# Cabeçalho de prioridade
|
| 359 |
+
if reply_context.priority_level >= 4: # CRÍTICO
|
| 360 |
+
parts.append("⚠️⚠️⚠️ REPLY CRÍTICO - PERGUNTA CURTA ⚠️⚠️⚠️")
|
| 361 |
+
elif reply_context.priority_level == 3: # REPLY TO BOT
|
| 362 |
+
parts.append("⚠️ REPLY DIRETO AO BOT")
|
| 363 |
+
else:
|
| 364 |
+
parts.append("📎 REPLY")
|
| 365 |
+
|
| 366 |
+
# Autor
|
| 367 |
+
if reply_context.reply_to_bot:
|
| 368 |
+
parts.append("Você está sendo diretamente mencionado!")
|
| 369 |
+
else:
|
| 370 |
+
parts.append(f"Respondendo a: {reply_context.quoted_author_name}")
|
| 371 |
+
|
| 372 |
+
# Mensagem citada
|
| 373 |
+
if reply_context.mensagem_citada:
|
| 374 |
+
cited = reply_context.mensagem_citada[:300]
|
| 375 |
+
parts.append(f"\nMsg citada:\n{cited}")
|
| 376 |
+
|
| 377 |
+
# Contexto hint
|
| 378 |
+
if reply_context.context_hint and reply_context.context_hint != "contexto_geral":
|
| 379 |
+
parts.append(f"\nContexto: {reply_context.context_hint}")
|
| 380 |
+
|
| 381 |
+
return "\n".join(parts)
|
| 382 |
+
|
| 383 |
+
def _build_short_term_section(
|
| 384 |
+
self,
|
| 385 |
+
short_term_memory: ShortTermMemory,
|
| 386 |
+
reply_context: Optional[ProcessedReplyContext] = None,
|
| 387 |
+
max_tokens: int = TOKEN_BUDGET_SHORT_TERM
|
| 388 |
+
) -> str:
|
| 389 |
+
"""Constrói seção de memória de curto prazo."""
|
| 390 |
+
# Obtém mensagens do contexto
|
| 391 |
+
messages = short_term_memory.get_context_window(
|
| 392 |
+
include_replies=True,
|
| 393 |
+
prioritize_replies=True,
|
| 394 |
+
max_tokens=max_tokens
|
| 395 |
+
)
|
| 396 |
+
|
| 397 |
+
if not messages:
|
| 398 |
+
return ""
|
| 399 |
+
|
| 400 |
+
parts = []
|
| 401 |
+
parts.append("(últimas mensagens - replies priorizados)")
|
| 402 |
+
|
| 403 |
+
# Limita a quantidade para caber no orçamento
|
| 404 |
+
included_count = 0
|
| 405 |
+
current_tokens = 0
|
| 406 |
+
|
| 407 |
+
for msg in messages:
|
| 408 |
+
msg_tokens = estimar_tokens(msg.content)
|
| 409 |
+
if current_tokens + msg_tokens > max_tokens:
|
| 410 |
+
break
|
| 411 |
+
|
| 412 |
+
# Formata mensagem
|
| 413 |
+
role = "🤖" if msg.role == "assistant" else "👤"
|
| 414 |
+
content_preview = msg.content[:100] + ("..." if len(msg.content) > 100 else "")
|
| 415 |
+
|
| 416 |
+
if msg.is_reply:
|
| 417 |
+
parts.append(f"{role} [REPLY] {content_preview}")
|
| 418 |
+
else:
|
| 419 |
+
parts.append(f"{role} {content_preview}")
|
| 420 |
+
|
| 421 |
+
current_tokens += msg_tokens
|
| 422 |
+
included_count += 1
|
| 423 |
+
|
| 424 |
+
if not parts:
|
| 425 |
+
return ""
|
| 426 |
+
|
| 427 |
+
return "\n".join(parts)
|
| 428 |
+
|
| 429 |
+
def _build_vector_section(
|
| 430 |
+
self,
|
| 431 |
+
vector_info: List[Dict[str, Any]],
|
| 432 |
+
max_tokens: int = TOKEN_BUDGET_VECTOR
|
| 433 |
+
) -> str:
|
| 434 |
+
"""Constrói seção de memória vetorial."""
|
| 435 |
+
if not vector_info:
|
| 436 |
+
return ""
|
| 437 |
+
|
| 438 |
+
parts = []
|
| 439 |
+
parts.append("(fatos aprendidos nesta conversa)")
|
| 440 |
+
|
| 441 |
+
current_tokens = 0
|
| 442 |
+
|
| 443 |
+
for item in vector_info[:10]: # Limita a 10 itens
|
| 444 |
+
text = item.get("text", "") or item.get("mensagem", "")
|
| 445 |
+
if not text:
|
| 446 |
+
continue
|
| 447 |
+
|
| 448 |
+
text_preview = text[:80] + ("..." if len(text) > 80 else "")
|
| 449 |
+
current_tokens += estimar_tokens(text)
|
| 450 |
+
|
| 451 |
+
if current_tokens > max_tokens:
|
| 452 |
+
break
|
| 453 |
+
|
| 454 |
+
parts.append(f"• {text_preview}")
|
| 455 |
+
|
| 456 |
+
if len(parts) == 1:
|
| 457 |
+
return ""
|
| 458 |
+
|
| 459 |
+
return "\n".join(parts)
|
| 460 |
+
|
| 461 |
+
def _build_emotional_section(self, emocao: str) -> str:
|
| 462 |
+
"""Constrói seção de contexto emocional."""
|
| 463 |
+
if emocao in ["neutral", "neutro"]:
|
| 464 |
+
return ""
|
| 465 |
+
|
| 466 |
+
emocoes_descritas = {
|
| 467 |
+
"joy": "usuário parece feliz/contento",
|
| 468 |
+
"felicidade": "usuário parece feliz/contento",
|
| 469 |
+
"tristeza": "usuário parece triste",
|
| 470 |
+
"triste": "usuário parece triste",
|
| 471 |
+
"raiva": "usuário parece irritado/raivoso",
|
| 472 |
+
"raivoso": "usuário parece irritado/raivoso",
|
| 473 |
+
"amor": "usuário demonstra afeto",
|
| 474 |
+
"medo": "usuário parece preocupado/assustado",
|
| 475 |
+
"surpresa": "usuário parece surpreso",
|
| 476 |
+
"surpreso": "usuário parece surpreso"
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
descricao = emocoes_descritas.get(emocao.lower(), f"usuário parece {emocao}")
|
| 480 |
+
return f"Tom emocional: {descricao}"
|
| 481 |
+
|
| 482 |
+
# ============================================================
|
| 483 |
+
# HELPERS PARA API
|
| 484 |
+
# ============================================================
|
| 485 |
+
|
| 486 |
+
def build_history_for_llm(
|
| 487 |
+
self,
|
| 488 |
+
short_term_memory: ShortTermMemory,
|
| 489 |
+
reply_context: Optional[ProcessedReplyContext] = None,
|
| 490 |
+
max_tokens: int = TOKEN_BUDGET_SHORT_TERM
|
| 491 |
+
) -> List[Dict[str, str]]:
|
| 492 |
+
"""
|
| 493 |
+
Constrói histórico formatado para LLM.
|
| 494 |
+
|
| 495 |
+
Args:
|
| 496 |
+
short_term_memory: Memória de curto prazo
|
| 497 |
+
reply_context: Contexto de reply (opcional)
|
| 498 |
+
max_tokens: Máximo de tokens
|
| 499 |
+
|
| 500 |
+
Returns:
|
| 501 |
+
Lista de dicts com role e content
|
| 502 |
+
"""
|
| 503 |
+
# Garante que reply_context está priorizado
|
| 504 |
+
if reply_context and reply_context.is_reply:
|
| 505 |
+
# Cria mensagem artificial para o reply
|
| 506 |
+
reply_entry = {
|
| 507 |
+
"role": "user",
|
| 508 |
+
"content": f"[REPLY] {reply_context.get_reply_summary_for_llm(reply_context)}"
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
# Obtém resto do histórico
|
| 512 |
+
history = short_term_memory.get_messages_for_llm(
|
| 513 |
+
reply_context=None, # Já adicionado
|
| 514 |
+
max_tokens=max_tokens - estimar_tokens(reply_entry["content"])
|
| 515 |
+
)
|
| 516 |
+
|
| 517 |
+
# Insere reply no início
|
| 518 |
+
return [reply_entry] + history
|
| 519 |
+
|
| 520 |
+
return short_term_memory.get_messages_for_llm(max_tokens=max_tokens)
|
| 521 |
+
|
| 522 |
+
def estimate_prompt_tokens(
|
| 523 |
+
self,
|
| 524 |
+
user_message: str,
|
| 525 |
+
reply_context: Optional[ProcessedReplyContext] = None,
|
| 526 |
+
historico_size: int = 0
|
| 527 |
+
) -> int:
|
| 528 |
+
"""
|
| 529 |
+
Estima tokens totais do prompt.
|
| 530 |
+
|
| 531 |
+
Args:
|
| 532 |
+
user_message: Mensagem do usuário
|
| 533 |
+
reply_context: Contexto de reply
|
| 534 |
+
historico_size: Tamanho do histórico em mensagens
|
| 535 |
+
|
| 536 |
+
Returns:
|
| 537 |
+
Estimativa de tokens
|
| 538 |
+
"""
|
| 539 |
+
system_tokens = TOKEN_BUDGET_SYSTEM
|
| 540 |
+
|
| 541 |
+
reply_tokens = 0
|
| 542 |
+
if reply_context and reply_context.is_reply:
|
| 543 |
+
reply_tokens = TOKEN_BUDGET_REPLY
|
| 544 |
+
|
| 545 |
+
history_tokens = historico_size * 50 # Aproximação
|
| 546 |
+
|
| 547 |
+
return system_tokens + reply_tokens + history_tokens + estimar_tokens(user_message)
|
| 548 |
+
|
| 549 |
+
def get_conversation_context(
|
| 550 |
+
self,
|
| 551 |
+
numero_usuario: str,
|
| 552 |
+
tipo_conversa: str,
|
| 553 |
+
grupo_id: Optional[str] = None
|
| 554 |
+
) -> Tuple[Optional[ConversationContext], ShortTermMemory]:
|
| 555 |
+
"""
|
| 556 |
+
Obtém contexto isolado e memória de curto prazo.
|
| 557 |
+
|
| 558 |
+
Args:
|
| 559 |
+
numero_usuario: Número do usuário
|
| 560 |
+
tipo_conversa: "pv" ou "grupo"
|
| 561 |
+
grupo_id: ID do grupo
|
| 562 |
+
|
| 563 |
+
Returns:
|
| 564 |
+
Tupla (ConversationContext, ShortTermMemory)
|
| 565 |
+
"""
|
| 566 |
+
self._ensure_initialized()
|
| 567 |
+
|
| 568 |
+
if not self.isolation_manager:
|
| 569 |
+
return None, ShortTermMemory()
|
| 570 |
+
|
| 571 |
+
context = self.isolation_manager.get_or_create_context(
|
| 572 |
+
numero_usuario, tipo_conversa, grupo_id
|
| 573 |
+
)
|
| 574 |
+
|
| 575 |
+
# Carrega short-term memory do contexto
|
| 576 |
+
stm_data = context.short_memory if context else None
|
| 577 |
+
stm = ShortTermMemory(
|
| 578 |
+
conversation_id=context.context_id if context else "",
|
| 579 |
+
context_data={"messages": stm_data} if stm_data else None
|
| 580 |
+
)
|
| 581 |
+
|
| 582 |
+
return context, stm
|
| 583 |
+
|
| 584 |
+
def __repr__(self) -> str:
|
| 585 |
+
"""Representação textual."""
|
| 586 |
+
return f"ContextBuilder(initialized={self._initialized})"
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
# ============================================================
|
| 590 |
+
# FUNÇÕES DE FÁBRICA
|
| 591 |
+
# ============================================================
|
| 592 |
+
|
| 593 |
+
def criar_context_builder(config_module=None) -> ContextBuilder:
|
| 594 |
+
"""
|
| 595 |
+
Factory function para criar ContextBuilder.
|
| 596 |
+
|
| 597 |
+
Args:
|
| 598 |
+
config_module: Módulo de configuração (opcional)
|
| 599 |
+
|
| 600 |
+
Returns:
|
| 601 |
+
ContextBuilder instance
|
| 602 |
+
"""
|
| 603 |
+
return ContextBuilder(config_module)
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
# type: ignore
|
| 607 |
+
|
modules/context_isolation.py
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# type: ignore
|
| 2 |
+
"""
|
| 3 |
+
================================================================================
|
| 4 |
+
AKIRA V21 ULTIMATE - CONTEXT ISOLATION MODULE
|
| 5 |
+
================================================================================
|
| 6 |
+
Sistema de isolamento de contexto entre conversas (PV e Grupos).
|
| 7 |
+
Garante que contexto de um grupo não vaze para outro ou para PVs.
|
| 8 |
+
|
| 9 |
+
Features:
|
| 10 |
+
- Context ID único por combinação (usuário + tipo + grupo)
|
| 11 |
+
- Salt criptográfico para prevenir guessing
|
| 12 |
+
- CRUD completo para contextos isolados
|
| 13 |
+
- Integração com Database para persistência
|
| 14 |
+
- Suporte a migração de dados existentes
|
| 15 |
+
================================================================================
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
import hashlib
|
| 21 |
+
import time
|
| 22 |
+
import json
|
| 23 |
+
import logging
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Optional, Dict, Any, List, Tuple
|
| 26 |
+
from dataclasses import dataclass, field, asdict
|
| 27 |
+
from datetime import datetime
|
| 28 |
+
|
| 29 |
+
# Imports robustos com fallback - CORRIGIDO para usar modules.
|
| 30 |
+
try:
|
| 31 |
+
import modules.config as config
|
| 32 |
+
from .database import Database
|
| 33 |
+
CONTEXT_ISOLATION_AVAILABLE = True
|
| 34 |
+
except ImportError:
|
| 35 |
+
try:
|
| 36 |
+
from . import config
|
| 37 |
+
from .database import Database
|
| 38 |
+
CONTEXT_ISOLATION_AVAILABLE = True
|
| 39 |
+
except ImportError:
|
| 40 |
+
CONTEXT_ISOLATION_AVAILABLE = False
|
| 41 |
+
config = None
|
| 42 |
+
Database = None
|
| 43 |
+
|
| 44 |
+
logger = logging.getLogger(__name__)
|
| 45 |
+
|
| 46 |
+
# ============================================================
|
| 47 |
+
# CONFIGURAÇÃO DE ISOLAMENTO
|
| 48 |
+
# ============================================================
|
| 49 |
+
|
| 50 |
+
# Salt para geração de context_id (muda a cada deployment)
|
| 51 |
+
CONTEXT_SALT: str = os.getenv("CONTEXT_SALT", "AKIRA_V21_CONTEXT_ISOLATION_v1")
|
| 52 |
+
|
| 53 |
+
# Versão do esquema de isolamento (para migrações)
|
| 54 |
+
SCHEMA_VERSION: int = 1
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@dataclass
|
| 58 |
+
class ConversationContext:
|
| 59 |
+
"""
|
| 60 |
+
Contexto isolado para uma conversa específica (PV ou Grupo).
|
| 61 |
+
|
| 62 |
+
Attributes:
|
| 63 |
+
context_id: Identificador único (hash de tipo + numero + grupo)
|
| 64 |
+
numero_usuario: Número do usuário
|
| 65 |
+
grupo_id: ID do grupo (None para PV)
|
| 66 |
+
tipo_conversa: "pv" ou "grupo"
|
| 67 |
+
short_memory: Lista de mensagens de curto prazo (max 100)
|
| 68 |
+
estado_emocional: Estado emocional atual
|
| 69 |
+
nivel_intimidade: Nível de intimidade (1-3)
|
| 70 |
+
created_at: Timestamp de criação
|
| 71 |
+
last_interaction: Timestamp da última interação
|
| 72 |
+
metadata: Metadados adicionais
|
| 73 |
+
"""
|
| 74 |
+
context_id: str
|
| 75 |
+
numero_usuario: str
|
| 76 |
+
grupo_id: Optional[str] = None
|
| 77 |
+
tipo_conversa: str = "pv"
|
| 78 |
+
short_memory: List[Dict[str, Any]] = field(default_factory=list)
|
| 79 |
+
estado_emocional: str = "neutral"
|
| 80 |
+
nivel_intimidade: int = 1
|
| 81 |
+
created_at: float = field(default_factory=time.time)
|
| 82 |
+
last_interaction: float = field(default_factory=time.time)
|
| 83 |
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
| 84 |
+
|
| 85 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 86 |
+
"""Converte para dicionário serializável."""
|
| 87 |
+
return asdict(self)
|
| 88 |
+
|
| 89 |
+
@classmethod
|
| 90 |
+
def from_dict(cls, data: Dict[str, Any]) -> 'ConversationContext':
|
| 91 |
+
"""Cria instância a partir de dicionário."""
|
| 92 |
+
return cls(**data)
|
| 93 |
+
|
| 94 |
+
@property
|
| 95 |
+
def is_grupo(self) -> bool:
|
| 96 |
+
"""Retorna True se for conversa em grupo."""
|
| 97 |
+
return self.tipo_conversa == "grupo"
|
| 98 |
+
|
| 99 |
+
@property
|
| 100 |
+
def display_name(self) -> str:
|
| 101 |
+
"""Nome de exibição do contexto."""
|
| 102 |
+
if self.is_grupo:
|
| 103 |
+
return f"Grupo {self.grupo_id or 'desconhecido'}"
|
| 104 |
+
return f"PV {self.numero_usuario}"
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ============================================================
|
| 108 |
+
# FUNÇÕES DE GERAÇÃO DE CONTEXT ID
|
| 109 |
+
# ============================================================
|
| 110 |
+
|
| 111 |
+
def generate_context_id(
|
| 112 |
+
numero_usuario: str,
|
| 113 |
+
tipo_conversa: str,
|
| 114 |
+
grupo_id: Optional[str] = None
|
| 115 |
+
) -> str:
|
| 116 |
+
"""
|
| 117 |
+
Gera ID único e criptográfico para uma conversa.
|
| 118 |
+
|
| 119 |
+
Args:
|
| 120 |
+
numero_usuario: Número de telefone do usuário
|
| 121 |
+
tipo_conversa: "pv" ou "grupo"
|
| 122 |
+
grupo_id: ID do grupo (opcional)
|
| 123 |
+
|
| 124 |
+
Returns:
|
| 125 |
+
String de 64 caracteres (SHA256 hash)
|
| 126 |
+
"""
|
| 127 |
+
# Limpa inputs
|
| 128 |
+
numero_clean = ''.join(filter(str.isdigit, str(numero_usuario))) or "unknown"
|
| 129 |
+
tipo_clean = str(tipo_conversa).lower().strip()
|
| 130 |
+
grupo_clean = ''.join(filter(str.isdigit, str(grupo_id))) if grupo_id else "pv"
|
| 131 |
+
|
| 132 |
+
# Monta raw string
|
| 133 |
+
raw = f"{CONTEXT_SALT}:{tipo_clean}:{numero_clean}:{grupo_clean}:{int(time.time() // 86400)}"
|
| 134 |
+
|
| 135 |
+
# Gera hash
|
| 136 |
+
hash_obj = hashlib.sha256(raw.encode('utf-8'))
|
| 137 |
+
return hash_obj.hexdigest()
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def validate_context_id(context_id: str) -> bool:
|
| 141 |
+
"""
|
| 142 |
+
Valida formato de context_id.
|
| 143 |
+
|
| 144 |
+
Args:
|
| 145 |
+
context_id: ID a ser validado
|
| 146 |
+
|
| 147 |
+
Returns:
|
| 148 |
+
True se formato válido
|
| 149 |
+
"""
|
| 150 |
+
if not context_id or not isinstance(context_id, str):
|
| 151 |
+
return False
|
| 152 |
+
|
| 153 |
+
# SHA256 hex = 64 caracteres
|
| 154 |
+
return len(context_id) == 64 and all(c in '0123456789abcdef' for c in context_id)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# ============================================================
|
| 158 |
+
# CLASSE PRINCIPAL DE ISOLAMENTO
|
| 159 |
+
# ============================================================
|
| 160 |
+
|
| 161 |
+
class ContextIsolationManager:
|
| 162 |
+
"""
|
| 163 |
+
Gerenciador de isolamento de contexto.
|
| 164 |
+
|
| 165 |
+
Provides:
|
| 166 |
+
- Criação e gestão de contextos isolados
|
| 167 |
+
- Persistência em banco de dados
|
| 168 |
+
- Migração de dados legados
|
| 169 |
+
- Estatísticas e debugging
|
| 170 |
+
"""
|
| 171 |
+
|
| 172 |
+
_instance = None
|
| 173 |
+
_lock = None
|
| 174 |
+
|
| 175 |
+
def __new__(cls):
|
| 176 |
+
if cls._instance is None:
|
| 177 |
+
cls._lock = __import__('threading').Lock()
|
| 178 |
+
with cls._lock:
|
| 179 |
+
if cls._instance is None:
|
| 180 |
+
cls._instance = super().__new__(cls)
|
| 181 |
+
cls._instance._initialized = False
|
| 182 |
+
return cls._instance
|
| 183 |
+
|
| 184 |
+
def __init__(self):
|
| 185 |
+
if self._initialized:
|
| 186 |
+
return
|
| 187 |
+
|
| 188 |
+
self._db: Optional[Database] = None
|
| 189 |
+
self._contexts_cache: Dict[str, ConversationContext] = {}
|
| 190 |
+
self._initialized = True
|
| 191 |
+
|
| 192 |
+
# Logger
|
| 193 |
+
if CONTEXT_ISOLATION_AVAILABLE and config:
|
| 194 |
+
logger.info("✅ ContextIsolationManager inicializado")
|
| 195 |
+
else:
|
| 196 |
+
print("[WARN] ContextIsolationManager: config/database não disponíveis")
|
| 197 |
+
|
| 198 |
+
def _get_db(self) -> Database:
|
| 199 |
+
"""Obtém instância do banco de dados."""
|
| 200 |
+
if self._db is None:
|
| 201 |
+
if Database:
|
| 202 |
+
try:
|
| 203 |
+
from .config import DB_PATH
|
| 204 |
+
self._db = Database(DB_PATH)
|
| 205 |
+
except ImportError:
|
| 206 |
+
self._db = Database()
|
| 207 |
+
else:
|
| 208 |
+
raise RuntimeError("Database não disponível")
|
| 209 |
+
return self._db
|
| 210 |
+
|
| 211 |
+
# ============================================================
|
| 212 |
+
# CRIAÇÃO E GESTÃO DE CONTEXTOS
|
| 213 |
+
# ============================================================
|
| 214 |
+
|
| 215 |
+
def get_or_create_context(
|
| 216 |
+
self,
|
| 217 |
+
numero_usuario: str,
|
| 218 |
+
tipo_conversa: str,
|
| 219 |
+
grupo_id: Optional[str] = None,
|
| 220 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 221 |
+
) -> ConversationContext:
|
| 222 |
+
"""
|
| 223 |
+
Obtém contexto existente ou cria novo.
|
| 224 |
+
|
| 225 |
+
Args:
|
| 226 |
+
numero_usuario: Número do usuário
|
| 227 |
+
tipo_conversa: "pv" ou "grupo"
|
| 228 |
+
grupo_id: ID do grupo (None para PV)
|
| 229 |
+
metadata: Metadados opcionais para novo contexto
|
| 230 |
+
|
| 231 |
+
Returns:
|
| 232 |
+
ConversationContext instance
|
| 233 |
+
"""
|
| 234 |
+
context_id = generate_context_id(numero_usuario, tipo_conversa, grupo_id)
|
| 235 |
+
|
| 236 |
+
# Verifica cache
|
| 237 |
+
if context_id in self._contexts_cache:
|
| 238 |
+
ctx = self._contexts_cache[context_id]
|
| 239 |
+
ctx.last_interaction = time.time()
|
| 240 |
+
return ctx
|
| 241 |
+
|
| 242 |
+
# Tenta carregar do banco
|
| 243 |
+
db = self._get_db()
|
| 244 |
+
ctx_data = db.recuperar_contexto_isolado(context_id)
|
| 245 |
+
|
| 246 |
+
if ctx_data:
|
| 247 |
+
ctx = ConversationContext.from_dict(ctx_data)
|
| 248 |
+
else:
|
| 249 |
+
# Cria novo contexto
|
| 250 |
+
ctx = ConversationContext(
|
| 251 |
+
context_id=context_id,
|
| 252 |
+
numero_usuario=numero_usuario,
|
| 253 |
+
grupo_id=grupo_id,
|
| 254 |
+
tipo_conversa=tipo_conversa,
|
| 255 |
+
metadata=metadata or {}
|
| 256 |
+
)
|
| 257 |
+
# Salva no banco
|
| 258 |
+
self._save_context(ctx)
|
| 259 |
+
|
| 260 |
+
# Atualiza cache
|
| 261 |
+
ctx.last_interaction = time.time()
|
| 262 |
+
self._contexts_cache[context_id] = ctx
|
| 263 |
+
|
| 264 |
+
return ctx
|
| 265 |
+
|
| 266 |
+
def get_context(
|
| 267 |
+
self,
|
| 268 |
+
numero_usuario: str,
|
| 269 |
+
tipo_conversa: str,
|
| 270 |
+
grupo_id: Optional[str] = None
|
| 271 |
+
) -> Optional[ConversationContext]:
|
| 272 |
+
"""
|
| 273 |
+
Obtém contexto existente (não cria novo).
|
| 274 |
+
|
| 275 |
+
Args:
|
| 276 |
+
numero_usuario: Número do usuário
|
| 277 |
+
tipo_conversa: "pv" ou "grupo"
|
| 278 |
+
grupo_id: ID do grupo
|
| 279 |
+
|
| 280 |
+
Returns:
|
| 281 |
+
ConversationContext ou None se não existir
|
| 282 |
+
"""
|
| 283 |
+
context_id = generate_context_id(numero_usuario, tipo_conversa, grupo_id)
|
| 284 |
+
|
| 285 |
+
# Verifica cache
|
| 286 |
+
if context_id in self._contexts_cache:
|
| 287 |
+
return self._contexts_cache[context_id]
|
| 288 |
+
|
| 289 |
+
# Busca no banco
|
| 290 |
+
db = self._get_db()
|
| 291 |
+
ctx_data = db.recuperar_contexto_isolado(context_id)
|
| 292 |
+
|
| 293 |
+
if ctx_data:
|
| 294 |
+
ctx = ConversationContext.from_dict(ctx_data)
|
| 295 |
+
self._contexts_cache[context_id] = ctx
|
| 296 |
+
return ctx
|
| 297 |
+
|
| 298 |
+
return None
|
| 299 |
+
|
| 300 |
+
def _save_context(self, context: ConversationContext) -> bool:
|
| 301 |
+
"""Salva contexto no banco de dados."""
|
| 302 |
+
try:
|
| 303 |
+
db = self._get_db()
|
| 304 |
+
return db.salvar_contexto_isolado(context.to_dict())
|
| 305 |
+
except Exception as e:
|
| 306 |
+
logger.warning(f"Falha ao salvar contexto: {e}")
|
| 307 |
+
return False
|
| 308 |
+
|
| 309 |
+
def save_context(self, context: ConversationContext) -> bool:
|
| 310 |
+
"""Salva contexto e atualiza cache."""
|
| 311 |
+
context.last_interaction = time.time()
|
| 312 |
+
self._contexts_cache[context.context_id] = context
|
| 313 |
+
return self._save_context(context)
|
| 314 |
+
|
| 315 |
+
def delete_context(self, context_id: str) -> bool:
|
| 316 |
+
"""
|
| 317 |
+
Remove contexto isolado.
|
| 318 |
+
|
| 319 |
+
Args:
|
| 320 |
+
context_id: ID do contexto a remover
|
| 321 |
+
|
| 322 |
+
Returns:
|
| 323 |
+
True se removido com sucesso
|
| 324 |
+
"""
|
| 325 |
+
if not validate_context_id(context_id):
|
| 326 |
+
logger.warning(f"Context ID inválido: {context_id}")
|
| 327 |
+
return False
|
| 328 |
+
|
| 329 |
+
# Remove do cache
|
| 330 |
+
if context_id in self._contexts_cache:
|
| 331 |
+
del self._contexts_cache[context_id]
|
| 332 |
+
|
| 333 |
+
# Remove do banco
|
| 334 |
+
try:
|
| 335 |
+
db = self._get_db()
|
| 336 |
+
return db.deletar_contexto_isolado(context_id)
|
| 337 |
+
except Exception as e:
|
| 338 |
+
logger.warning(f"Falha ao deletar contexto: {e}")
|
| 339 |
+
return False
|
| 340 |
+
|
| 341 |
+
# ============================================================
|
| 342 |
+
# GESTÃO DE MEMÓRIA DE CURTO PRAZO
|
| 343 |
+
# ============================================================
|
| 344 |
+
|
| 345 |
+
def add_message_to_context(
|
| 346 |
+
self,
|
| 347 |
+
context: ConversationContext,
|
| 348 |
+
role: str,
|
| 349 |
+
content: str,
|
| 350 |
+
importancia: float = 1.0,
|
| 351 |
+
emocao: str = "neutral",
|
| 352 |
+
reply_info: Optional[Dict[str, Any]] = None
|
| 353 |
+
) -> None:
|
| 354 |
+
"""
|
| 355 |
+
Adiciona mensagem à memória de curto prazo do contexto.
|
| 356 |
+
|
| 357 |
+
Args:
|
| 358 |
+
context: ConversationContext
|
| 359 |
+
role: "user" ou "assistant"
|
| 360 |
+
content: Texto da mensagem
|
| 361 |
+
importancia: Peso da mensagem (1.0 = normal, >1.0 = reply)
|
| 362 |
+
emocao: Emoção detectada
|
| 363 |
+
reply_info: Info adicional se for reply
|
| 364 |
+
"""
|
| 365 |
+
MAX_MESSAGES = 100 # Configurado pelo usuário
|
| 366 |
+
|
| 367 |
+
message_entry = {
|
| 368 |
+
"role": role,
|
| 369 |
+
"content": content,
|
| 370 |
+
"timestamp": time.time(),
|
| 371 |
+
"importancia": importancia,
|
| 372 |
+
"emocao": emocao,
|
| 373 |
+
"reply_info": reply_info or {}
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
# Adiciona à lista
|
| 377 |
+
context.short_memory.append(message_entry)
|
| 378 |
+
|
| 379 |
+
# Sliding window - remove mensagens antigas
|
| 380 |
+
if len(context.short_memory) > MAX_MESSAGES:
|
| 381 |
+
context.short_memory = context.short_memory[-MAX_MESSAGES:]
|
| 382 |
+
|
| 383 |
+
# Atualiza timestamp
|
| 384 |
+
context.last_interaction = time.time()
|
| 385 |
+
|
| 386 |
+
# Salva no banco
|
| 387 |
+
self.save_context(context)
|
| 388 |
+
|
| 389 |
+
def get_context_window(
|
| 390 |
+
self,
|
| 391 |
+
context: ConversationContext,
|
| 392 |
+
include_replies: bool = True,
|
| 393 |
+
prioritize_replies: bool = True,
|
| 394 |
+
max_messages: int = 100
|
| 395 |
+
) -> List[Dict[str, Any]]:
|
| 396 |
+
"""
|
| 397 |
+
Obtém janela de contexto com prioridade para replies.
|
| 398 |
+
|
| 399 |
+
Args:
|
| 400 |
+
context: ConversationContext
|
| 401 |
+
include_replies: Se deve incluir mensagens de reply
|
| 402 |
+
prioritize_replies: Se deve dar prioridade a replies
|
| 403 |
+
max_messages: Máximo de mensagens a retornar
|
| 404 |
+
|
| 405 |
+
Returns:
|
| 406 |
+
Lista de mensagens ordenadas por importância
|
| 407 |
+
"""
|
| 408 |
+
messages = context.short_memory.copy()
|
| 409 |
+
|
| 410 |
+
if not messages:
|
| 411 |
+
return []
|
| 412 |
+
|
| 413 |
+
# Filtra replies se necessário
|
| 414 |
+
if not include_replies:
|
| 415 |
+
messages = [m for m in messages if not m.get('reply_info', {})]
|
| 416 |
+
|
| 417 |
+
# Ordena por importância (replies primeiro)
|
| 418 |
+
if prioritize_replies:
|
| 419 |
+
messages.sort(key=lambda x: x.get('importancia', 1.0), reverse=True)
|
| 420 |
+
|
| 421 |
+
# Limita quantidade
|
| 422 |
+
return messages[:max_messages]
|
| 423 |
+
|
| 424 |
+
def clear_context_memory(self, context: ConversationContext) -> bool:
|
| 425 |
+
"""
|
| 426 |
+
Limpa memória de curto prazo do contexto.
|
| 427 |
+
|
| 428 |
+
Args:
|
| 429 |
+
context: ConversationContext
|
| 430 |
+
|
| 431 |
+
Returns:
|
| 432 |
+
True se limpo com sucesso
|
| 433 |
+
"""
|
| 434 |
+
context.short_memory = []
|
| 435 |
+
context.last_interaction = time.time()
|
| 436 |
+
return self.save_context(context)
|
| 437 |
+
|
| 438 |
+
# ============================================================
|
| 439 |
+
# LISTAGEM E ESTATÍSTICAS
|
| 440 |
+
# ============================================================
|
| 441 |
+
|
| 442 |
+
def list_user_contexts(self, numero_usuario: str) -> List[ConversationContext]:
|
| 443 |
+
"""
|
| 444 |
+
Lista todos os contextos de um usuário.
|
| 445 |
+
|
| 446 |
+
Args:
|
| 447 |
+
numero_usuario: Número do usuário
|
| 448 |
+
|
| 449 |
+
Returns:
|
| 450 |
+
Lista de ConversationContext
|
| 451 |
+
"""
|
| 452 |
+
try:
|
| 453 |
+
db = self._get_db()
|
| 454 |
+
contexts_data = db.listar_contextos_usuario(numero_usuario)
|
| 455 |
+
|
| 456 |
+
contexts = []
|
| 457 |
+
for data in contexts_data:
|
| 458 |
+
ctx = ConversationContext.from_dict(data)
|
| 459 |
+
# Atualiza cache
|
| 460 |
+
self._contexts_cache[ctx.context_id] = ctx
|
| 461 |
+
contexts.append(ctx)
|
| 462 |
+
|
| 463 |
+
return contexts
|
| 464 |
+
except Exception as e:
|
| 465 |
+
logger.warning(f"Erro ao listar contextos: {e}")
|
| 466 |
+
return []
|
| 467 |
+
|
| 468 |
+
def get_stats(self) -> Dict[str, Any]:
|
| 469 |
+
"""
|
| 470 |
+
Retorna estatísticas do sistema de isolamento.
|
| 471 |
+
|
| 472 |
+
Returns:
|
| 473 |
+
Dicionário com estatísticas
|
| 474 |
+
"""
|
| 475 |
+
return {
|
| 476 |
+
"cached_contexts": len(self._contexts_cache),
|
| 477 |
+
"schema_version": SCHEMA_VERSION,
|
| 478 |
+
"context_salt_set": bool(os.getenv("CONTEXT_SALT")),
|
| 479 |
+
"max_messages_per_context": 100
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
# ============================================================
|
| 483 |
+
# MIGRAÇÃO DE DADOS LEGADOS
|
| 484 |
+
# ============================================================
|
| 485 |
+
|
| 486 |
+
def migrate_legacy_context(
|
| 487 |
+
self,
|
| 488 |
+
numero_usuario: str,
|
| 489 |
+
grupo_id: Optional[str] = None,
|
| 490 |
+
tipo_conversa: str = "pv"
|
| 491 |
+
) -> Optional[ConversationContext]:
|
| 492 |
+
"""
|
| 493 |
+
Migra contexto legado para novo sistema isolado.
|
| 494 |
+
|
| 495 |
+
Args:
|
| 496 |
+
numero_usuario: Número do usuário
|
| 497 |
+
grupo_id: ID do grupo
|
| 498 |
+
tipo_conversa: Tipo da conversa
|
| 499 |
+
|
| 500 |
+
Returns:
|
| 501 |
+
ConversationContext migrado ou None
|
| 502 |
+
"""
|
| 503 |
+
# Verifica se contexto já existe
|
| 504 |
+
existing = self.get_context(numero_usuario, tipo_conversa, grupo_id)
|
| 505 |
+
if existing:
|
| 506 |
+
return existing # Já migrado
|
| 507 |
+
|
| 508 |
+
# Cria novo contexto
|
| 509 |
+
context = self.get_or_create_context(numero_usuario, tipo_conversa, grupo_id)
|
| 510 |
+
|
| 511 |
+
logger.info(f"📦 Contexto migrado: {context.display_name}")
|
| 512 |
+
return context
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
# ============================================================
|
| 516 |
+
# FUNÇÕES DE COMPATIBILIDADE
|
| 517 |
+
# ============================================================
|
| 518 |
+
|
| 519 |
+
def get_isolation_manager() -> ContextIsolationManager:
|
| 520 |
+
"""Obtém instância singleton do gerenciador."""
|
| 521 |
+
return ContextIsolationManager()
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
def criar_contexto_isolado(
|
| 525 |
+
numero_usuario: str,
|
| 526 |
+
tipo_conversa: str,
|
| 527 |
+
grupo_id: Optional[str] = None
|
| 528 |
+
) -> ConversationContext:
|
| 529 |
+
"""
|
| 530 |
+
Factory function para criar contexto isolado.
|
| 531 |
+
|
| 532 |
+
Args:
|
| 533 |
+
numero_usuario: Número do usuário
|
| 534 |
+
tipo_conversa: "pv" ou "grupo"
|
| 535 |
+
grupo_id: ID do grupo (None para PV)
|
| 536 |
+
|
| 537 |
+
Returns:
|
| 538 |
+
ConversationContext instance
|
| 539 |
+
"""
|
| 540 |
+
manager = get_isolation_manager()
|
| 541 |
+
return manager.get_or_create_context(numero_usuario, tipo_conversa, grupo_id)
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
# ============================================================
|
| 545 |
+
# HELPER PARA API
|
| 546 |
+
# ============================================================
|
| 547 |
+
|
| 548 |
+
def extrair_conversation_id_do_request(data: Dict[str, Any]) -> Tuple[str, str, Optional[str]]:
|
| 549 |
+
"""
|
| 550 |
+
Extrai parâmetros para conversation_id de um request da API.
|
| 551 |
+
|
| 552 |
+
Args:
|
| 553 |
+
data: Payload do request (dict)
|
| 554 |
+
|
| 555 |
+
Returns:
|
| 556 |
+
Tupla (numero_usuario, tipo_conversa, grupo_id)
|
| 557 |
+
"""
|
| 558 |
+
numero_usuario = data.get('numero', 'anonimo') or 'anonimo'
|
| 559 |
+
tipo_conversa = data.get('tipo_conversa', 'pv')
|
| 560 |
+
|
| 561 |
+
# Para mensagens de grupo, grupo_id vem em campos diferentes
|
| 562 |
+
grupo_id = data.get('grupo_id') or data.get('contexto_grupo')
|
| 563 |
+
|
| 564 |
+
return numero_usuario, tipo_conversa, grupo_id
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
# type: ignore
|
| 568 |
+
|
modules/contexto.py
CHANGED
|
@@ -1,454 +1,972 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
if
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
self.
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
if
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
#
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
"
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
self.
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# type: ignore
|
| 3 |
+
"""
|
| 4 |
+
================================================================================
|
| 5 |
+
AKIRA V21 ULTIMATE - CONTEXTO MODULE
|
| 6 |
+
================================================================================
|
| 7 |
+
Gerenciador de contexto de conversa com NLP avançado, análise emocional,
|
| 8 |
+
aprendizado dinâmico de gírias e adaptação de tom por usuário.
|
| 9 |
+
|
| 10 |
+
Features:
|
| 11 |
+
- Análise de intenção e normalização de texto
|
| 12 |
+
- Detecção de emoções com fallback heurístico
|
| 13 |
+
- Aprendizado de gírias regionais (Angola)
|
| 14 |
+
- Histórico de conversa persistente
|
| 15 |
+
- Tom adaptativo por usuário
|
| 16 |
+
- Integração com EmotionAnalyzer do config
|
| 17 |
+
- Sistema de embeddings para similaridade
|
| 18 |
+
- Cache inteligente
|
| 19 |
+
- Logging detalhado
|
| 20 |
+
================================================================================
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
import logging
|
| 24 |
+
import re
|
| 25 |
+
import random
|
| 26 |
+
import time
|
| 27 |
+
import sqlite3
|
| 28 |
+
import json
|
| 29 |
+
from typing import Optional, List, Dict, Tuple, Any, Union
|
| 30 |
+
from datetime import datetime
|
| 31 |
+
|
| 32 |
+
# Imports robustos com fallback - CORRIGIDO
|
| 33 |
+
try:
|
| 34 |
+
from . import config
|
| 35 |
+
from .database import Database
|
| 36 |
+
from .treinamento import Treinamento
|
| 37 |
+
CONTEXTO_AVAILABLE = True
|
| 38 |
+
except ImportError as e:
|
| 39 |
+
CONTEXTO_AVAILABLE = False
|
| 40 |
+
try:
|
| 41 |
+
import config
|
| 42 |
+
from database import Database
|
| 43 |
+
from treinamento import Treinamento
|
| 44 |
+
except ImportError:
|
| 45 |
+
import sys
|
| 46 |
+
sys.path.insert(0, '/home/elliot_pro/Programação/akira')
|
| 47 |
+
import modules.config as config
|
| 48 |
+
from modules.database import Database
|
| 49 |
+
try:
|
| 50 |
+
from modules.treinamento import Treinamento
|
| 51 |
+
except ImportError:
|
| 52 |
+
Treinamento = None
|
| 53 |
+
Database = None
|
| 54 |
+
|
| 55 |
+
# Imports opcionais com fallbacks
|
| 56 |
+
try:
|
| 57 |
+
from sentence_transformers import SentenceTransformer # type: ignore
|
| 58 |
+
SENTENCE_TRANSFORMER_AVAILABLE = True
|
| 59 |
+
except Exception as e:
|
| 60 |
+
logging.warning(f"sentence_transformers não disponível: {e}")
|
| 61 |
+
SentenceTransformer = None # type: ignore
|
| 62 |
+
SENTENCE_TRANSFORMER_AVAILABLE = False
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
import psutil # type: ignore
|
| 66 |
+
PSUTIL_AVAILABLE = True
|
| 67 |
+
except Exception:
|
| 68 |
+
psutil = None # type: ignore
|
| 69 |
+
PSUTIL_AVAILABLE = False
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
import structlog # type: ignore
|
| 73 |
+
STRUCTLOG_AVAILABLE = True
|
| 74 |
+
except Exception:
|
| 75 |
+
structlog = None # type: ignore
|
| 76 |
+
STRUCTLOG_AVAILABLE = False
|
| 77 |
+
|
| 78 |
+
logger = logging.getLogger(__name__)
|
| 79 |
+
|
| 80 |
+
# Configuração do logging
|
| 81 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
| 82 |
+
|
| 83 |
+
if STRUCTLOG_AVAILABLE and structlog:
|
| 84 |
+
structlog.configure(
|
| 85 |
+
processors=[
|
| 86 |
+
structlog.processors.TimeStamper(fmt="iso"),
|
| 87 |
+
structlog.stdlib.add_log_level,
|
| 88 |
+
structlog.processors.JSONRenderer()
|
| 89 |
+
],
|
| 90 |
+
context_class=dict,
|
| 91 |
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
| 92 |
+
wrapper_class=structlog.stdlib.BoundLogger,
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# Palavras para análise de sentimento heurística (fallback)
|
| 96 |
+
PALAVRAS_POSITIVAS = [
|
| 97 |
+
'bom', 'ótimo', 'incrível', 'feliz', 'adorei', 'top', 'fixe', 'bué',
|
| 98 |
+
'show', 'legal', 'bacana', 'excelente', 'maravilhoso', 'perfeito'
|
| 99 |
+
]
|
| 100 |
+
PALAVRAS_NEGATIVAS = [
|
| 101 |
+
'ruim', 'péssimo', 'triste', 'ódio', 'raiva', 'chateado', 'merda',
|
| 102 |
+
'porra', 'odeio', 'horrível', 'terrible', 'p不佳'
|
| 103 |
+
]
|
| 104 |
+
|
| 105 |
+
# Cache global para emotion analyzer
|
| 106 |
+
_emotion_analyzer: Any = None
|
| 107 |
+
|
| 108 |
+
def _get_emotion_analyzer() -> Any:
|
| 109 |
+
"""Obtém instância do EmotionAnalyzer do config.py."""
|
| 110 |
+
global _emotion_analyzer
|
| 111 |
+
if _emotion_analyzer is None:
|
| 112 |
+
try:
|
| 113 |
+
analyzer = config.get_emotion_analyzer()
|
| 114 |
+
# Verifica se o analyzer é callable antes de atribuir
|
| 115 |
+
if analyzer is not None and callable(analyzer):
|
| 116 |
+
_emotion_analyzer = analyzer
|
| 117 |
+
else:
|
| 118 |
+
_emotion_analyzer = None
|
| 119 |
+
except Exception as e:
|
| 120 |
+
logger.warning(f"EmotionAnalyzer não disponível: {e}")
|
| 121 |
+
_emotion_analyzer = None
|
| 122 |
+
return _emotion_analyzer
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class Contexto:
|
| 126 |
+
"""
|
| 127 |
+
Classe para gerenciar o contexto da conversa, análise de intenções e
|
| 128 |
+
aprendizado dinâmico de termos regionais/gírias para cada usuário.
|
| 129 |
+
|
| 130 |
+
Attributes:
|
| 131 |
+
db: Instância do banco de dados
|
| 132 |
+
usuario: Identificador do usuário
|
| 133 |
+
model: Modelo SentenceTransformer (carregado sob demanda)
|
| 134 |
+
embeddings: Cache de embeddings
|
| 135 |
+
emocao_atual: Emoção atual do usuário
|
| 136 |
+
espirito_critico: Modo de espírito crítico ativado
|
| 137 |
+
base_conhecimento: Base de conhecimento persistente
|
| 138 |
+
termo_contexto: Dicionário de termos/gírias aprendidos
|
| 139 |
+
cache_girias: Cache de gírias por usuário
|
| 140 |
+
primeira_mensagem: Flag para detectar primeira interação
|
| 141 |
+
tom_anterior: Tom da última mensagem para transição lenta
|
| 142 |
+
contagem_mensagens_tom: Contador para transição gradual
|
| 143 |
+
"""
|
| 144 |
+
|
| 145 |
+
def __init__(self, db: Optional[Database] = None, usuario: Optional[str] = None, conversation_id: Optional[str] = None):
|
| 146 |
+
"""
|
| 147 |
+
Inicializa o contexto de conversa.
|
| 148 |
+
|
| 149 |
+
Args:
|
| 150 |
+
db: Instância do banco de dados Database
|
| 151 |
+
usuario: Identificador do usuário (número de telefone ou nome)
|
| 152 |
+
conversation_id: ID único da conversa para isolamento (opcional)
|
| 153 |
+
"""
|
| 154 |
+
self.db = db
|
| 155 |
+
self.usuario: Optional[str] = usuario
|
| 156 |
+
self.conversation_id: Optional[str] = conversation_id
|
| 157 |
+
self.model: Optional[Any] = None
|
| 158 |
+
self.embeddings: Optional[Dict[str, Any]] = None
|
| 159 |
+
self._treinador: Optional[Treinamento] = None
|
| 160 |
+
|
| 161 |
+
# Estado de conversa
|
| 162 |
+
self.emocao_atual: str = "neutra"
|
| 163 |
+
self.espirito_critico: bool = False
|
| 164 |
+
self.base_conhecimento: Dict[str, Any] = {}
|
| 165 |
+
|
| 166 |
+
# Garante que termo_contexto seja sempre um dicionário
|
| 167 |
+
self.termo_contexto: Dict[str, Dict[str, Any]] = {}
|
| 168 |
+
self.cache_girias: Dict[str, Any] = {}
|
| 169 |
+
|
| 170 |
+
# Novas flags para primeira mensagem e transição lenta de tom
|
| 171 |
+
self.primeira_mensagem: bool = True
|
| 172 |
+
self.tom_anterior: str = "neutro"
|
| 173 |
+
self.contagem_mensagens_tom: int = 0
|
| 174 |
+
self.tom_atual: str = "neutro"
|
| 175 |
+
|
| 176 |
+
# Carrega aprendizados do banco
|
| 177 |
+
self.atualizar_aprendizados_do_banco()
|
| 178 |
+
|
| 179 |
+
logger.info(f"🟢 Contexto inicializado para usuário: {usuario}")
|
| 180 |
+
|
| 181 |
+
# Carrega modelo sob demanda
|
| 182 |
+
self._load_model()
|
| 183 |
+
|
| 184 |
+
def atualizar_aprendizados_do_banco(self):
|
| 185 |
+
"""Carrega todos os dados de aprendizado persistentes do banco."""
|
| 186 |
+
try:
|
| 187 |
+
if self.usuario and self.db is not None:
|
| 188 |
+
termos_aprendidos = self.db.recuperar_girias_usuario(self.usuario)
|
| 189 |
+
self.termo_contexto = {
|
| 190 |
+
termo['giria']: {
|
| 191 |
+
"significado": termo['significado'],
|
| 192 |
+
"frequencia": termo['frequencia']
|
| 193 |
+
}
|
| 194 |
+
for termo in termos_aprendidos
|
| 195 |
+
}
|
| 196 |
+
else:
|
| 197 |
+
self.termo_contexto = {}
|
| 198 |
+
except Exception as e:
|
| 199 |
+
logger.warning(f"Falha ao carregar termos/gírias do DB: {e}")
|
| 200 |
+
self.termo_contexto = {}
|
| 201 |
+
|
| 202 |
+
try:
|
| 203 |
+
if self.usuario and self.db is not None:
|
| 204 |
+
emocao_salva = self.db.recuperar_aprendizado_detalhado(self.usuario, "emocao_atual")
|
| 205 |
+
if emocao_salva:
|
| 206 |
+
# Tenta parsear como JSON primeiro
|
| 207 |
+
try:
|
| 208 |
+
if isinstance(emocao_salva, str):
|
| 209 |
+
emocao_dict = json.loads(emocao_salva)
|
| 210 |
+
else:
|
| 211 |
+
emocao_dict = emocao_salva
|
| 212 |
+
|
| 213 |
+
if isinstance(emocao_dict, dict) and 'emocao' in emocao_dict:
|
| 214 |
+
self.emocao_atual = emocao_dict['emocao']
|
| 215 |
+
elif isinstance(emocao_salva, str):
|
| 216 |
+
self.emocao_atual = emocao_salva
|
| 217 |
+
except (json.JSONDecodeError, TypeError):
|
| 218 |
+
# Se não for JSON válido, usa como string direta
|
| 219 |
+
if isinstance(emocao_salva, str):
|
| 220 |
+
self.emocao_atual = emocao_salva
|
| 221 |
+
except Exception as e:
|
| 222 |
+
logger.warning(f"Falha ao carregar emoção do DB: {e}")
|
| 223 |
+
|
| 224 |
+
@property
|
| 225 |
+
def ton_predominante(self) -> Optional[str]:
|
| 226 |
+
"""
|
| 227 |
+
Retorna o tom predominante do usuário, acessando o DB.
|
| 228 |
+
|
| 229 |
+
Returns:
|
| 230 |
+
Tom predominante ou None se não disponível
|
| 231 |
+
"""
|
| 232 |
+
if self.usuario and self.db is not None:
|
| 233 |
+
return self.db.obter_tom_predominante(self.usuario)
|
| 234 |
+
return None
|
| 235 |
+
|
| 236 |
+
def get_or_create_treinador(self, interval_hours: int = 24) -> Treinamento:
|
| 237 |
+
"""Retorna um entrenador associado a este contexto."""
|
| 238 |
+
if self._treinador is None:
|
| 239 |
+
db_param: Database = self.db if self.db is not None else Database()
|
| 240 |
+
self._treinador = Treinamento(db_param, contexto=self, interval_hours=interval_hours)
|
| 241 |
+
return self._treinador
|
| 242 |
+
|
| 243 |
+
def _load_model(self):
|
| 244 |
+
"""Carrega o modelo SentenceTransformer e embeddings sob demanda."""
|
| 245 |
+
if self.model is not None:
|
| 246 |
+
return
|
| 247 |
+
|
| 248 |
+
if not SENTENCE_TRANSFORMER_AVAILABLE:
|
| 249 |
+
logger.warning("SentenceTransformer não disponível")
|
| 250 |
+
return
|
| 251 |
+
|
| 252 |
+
start_time = time.time()
|
| 253 |
+
|
| 254 |
+
try:
|
| 255 |
+
self.model = SentenceTransformer('all-MiniLM-L6-v2')
|
| 256 |
+
logger.info("Modelo SentenceTransformer carregado com sucesso")
|
| 257 |
+
except Exception as e:
|
| 258 |
+
logger.error(f"Erro ao carregar modelo: {e}")
|
| 259 |
+
self.model = None
|
| 260 |
+
|
| 261 |
+
self._check_embeddings()
|
| 262 |
+
duration = time.time() - start_time
|
| 263 |
+
logger.info(f"Modelo carregado em {duration:.2f}s")
|
| 264 |
+
|
| 265 |
+
def _check_embeddings(self):
|
| 266 |
+
"""Verifica ou cria embeddings no banco de dados."""
|
| 267 |
+
if self.model and not self.embeddings:
|
| 268 |
+
try:
|
| 269 |
+
self.embeddings = {"conhecimento_base": "placeholder_embedding_data"}
|
| 270 |
+
except Exception as e:
|
| 271 |
+
logger.warning(f"Não foi possível carregar embeddings: {e}")
|
| 272 |
+
|
| 273 |
+
def analisar_emocoes_mensagem(self, mensagem: str) -> Dict[str, Any]:
|
| 274 |
+
"""
|
| 275 |
+
Analisa o sentimento e emoção da mensagem (Heurística simples).
|
| 276 |
+
|
| 277 |
+
Args:
|
| 278 |
+
mensagem: Texto da mensagem para análise
|
| 279 |
+
|
| 280 |
+
Returns:
|
| 281 |
+
Dicionário com análise emocional
|
| 282 |
+
"""
|
| 283 |
+
mensagem_lower = mensagem.strip().lower()
|
| 284 |
+
|
| 285 |
+
# Análise de Sentimento
|
| 286 |
+
pos_count = sum(mensagem_lower.count(w) for w in PALAVRAS_POSITIVAS)
|
| 287 |
+
neg_count = sum(mensagem_lower.count(w) for w in PALAVRAS_NEGATIVAS)
|
| 288 |
+
|
| 289 |
+
sentimento = "neutro"
|
| 290 |
+
if pos_count > neg_count:
|
| 291 |
+
sentimento = "positivo"
|
| 292 |
+
elif neg_count > pos_count:
|
| 293 |
+
sentimento = "negativo"
|
| 294 |
+
|
| 295 |
+
# Determinar Emoção Predominante
|
| 296 |
+
if sentimento == "positivo":
|
| 297 |
+
emocao_predominante = "alegria"
|
| 298 |
+
elif sentimento == "negativo":
|
| 299 |
+
emocao_predominante = "frustração"
|
| 300 |
+
else:
|
| 301 |
+
emocao_predominante = "neutra"
|
| 302 |
+
|
| 303 |
+
# Atualiza o estado
|
| 304 |
+
self.emocao_atual = emocao_predominante
|
| 305 |
+
|
| 306 |
+
return {
|
| 307 |
+
"sentimento_detectado": sentimento,
|
| 308 |
+
"emocao_predominante": emocao_predominante,
|
| 309 |
+
"intensidade_positiva": pos_count,
|
| 310 |
+
"intensidade_negativa": neg_count,
|
| 311 |
+
"tom_sugerido": "casual" if sentimento != "neutro" else "neutro"
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
def analisar_intencao_e_normalizar(
|
| 315 |
+
self,
|
| 316 |
+
mensagem: str,
|
| 317 |
+
historico: List[Tuple[str, str]]
|
| 318 |
+
) -> Dict[str, Any]:
|
| 319 |
+
"""
|
| 320 |
+
Analisa a intenção, normaliza a mensagem e detecta sentimentos/estilo.
|
| 321 |
+
|
| 322 |
+
Args:
|
| 323 |
+
mensagem: Mensagem do usuário
|
| 324 |
+
historico: Histórico de conversas
|
| 325 |
+
|
| 326 |
+
Returns:
|
| 327 |
+
Dicionário com análise completa
|
| 328 |
+
"""
|
| 329 |
+
self._load_model()
|
| 330 |
+
|
| 331 |
+
if not isinstance(mensagem, str):
|
| 332 |
+
mensagem = str(mensagem)
|
| 333 |
+
mensagem_lower = mensagem.strip().lower()
|
| 334 |
+
|
| 335 |
+
# 1. Análise de Intenção
|
| 336 |
+
intencao = "pergunta"
|
| 337 |
+
if '?' not in mensagem_lower and ('porquê' not in mensagem_lower or 'porque' not in mensagem_lower):
|
| 338 |
+
intencao = "afirmacao"
|
| 339 |
+
if any(w in mensagem_lower for w in ['ola', 'oi', 'bom dia', 'boa tarde', 'boa noite', 'como vai']):
|
| 340 |
+
intencao = "saudacao"
|
| 341 |
+
if any(w in mensagem_lower for w in ['tchau', 'ate mais', 'adeus', 'fim', 'parar']):
|
| 342 |
+
intencao = "despedida"
|
| 343 |
+
|
| 344 |
+
# 2. Análise de Sentimento/Emoção
|
| 345 |
+
try:
|
| 346 |
+
emotion_analyzer = _get_emotion_analyzer() # type: ignore[call-overload]
|
| 347 |
+
nlp_config = getattr(config, 'NLP_CONFIG', None)
|
| 348 |
+
nivel = getattr(nlp_config, 'level', 'advanced') if nlp_config else 'advanced'
|
| 349 |
+
|
| 350 |
+
# Converte histórico para formato esperado
|
| 351 |
+
historico_dict: List[Dict[str, str]] = []
|
| 352 |
+
for h in historico:
|
| 353 |
+
if isinstance(h, tuple) and len(h) >= 2:
|
| 354 |
+
historico_dict.append({"mensagem": h[0], "resposta": h[1]})
|
| 355 |
+
|
| 356 |
+
# Verificação robusta para evitar "Object of type None has no attribute"
|
| 357 |
+
if hasattr(emotion_analyzer, 'analisar'):
|
| 358 |
+
analise_emocional = emotion_analyzer.analisar(
|
| 359 |
+
mensagem_lower,
|
| 360 |
+
historico=historico_dict,
|
| 361 |
+
nivel=nivel
|
| 362 |
+
)
|
| 363 |
+
self.emocao_atual = analise_emocional.get('emocao', 'neutra')
|
| 364 |
+
else:
|
| 365 |
+
raise ValueError("EmotionAnalyzer não possui o método analisar")
|
| 366 |
+
|
| 367 |
+
except Exception as e:
|
| 368 |
+
logger.warning(f"EmotionAnalyzer falhou, usando fallback heurístico: {e}")
|
| 369 |
+
analise_emocional = self.analisar_emocoes_mensagem(mensagem_lower)
|
| 370 |
+
|
| 371 |
+
# 3. Análise de Estilo
|
| 372 |
+
estilo = "informal"
|
| 373 |
+
if len(re.findall(r'[A-ZÀ-Ÿ]{3,}', mensagem)) >= 2 or re.search(r'\b(Senhor|Doutor|Atenciosamente)\b', mensagem, re.IGNORECASE):
|
| 374 |
+
estilo = "formal"
|
| 375 |
+
|
| 376 |
+
# 4. Outras bandeiras
|
| 377 |
+
ironia = False
|
| 378 |
+
meia_frase = False
|
| 379 |
+
usar_nome = random.random() < getattr(config, 'USAR_NOME_PROBABILIDADE', 0.7)
|
| 380 |
+
|
| 381 |
+
return {
|
| 382 |
+
"texto_normalizado": mensagem_lower,
|
| 383 |
+
"intencao": intencao,
|
| 384 |
+
"sentimento": analise_emocional.get('sentimento_detectado',
|
| 385 |
+
analise_emocional.get('emocao', 'neutral')),
|
| 386 |
+
"estilo": estilo,
|
| 387 |
+
"contexto_ajustado": self.substituir_termos_aprendidos(mensagem_lower),
|
| 388 |
+
"ironia": ironia,
|
| 389 |
+
"meia_frase": meia_frase,
|
| 390 |
+
"usar_nome": usar_nome,
|
| 391 |
+
"emocao": self.emocao_atual,
|
| 392 |
+
"confianca_emocao": analise_emocional.get('confianca', 0.5),
|
| 393 |
+
"nivel_analise": analise_emocional.get('nivel_analise', 'heuristica')
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
def obter_historico(self, limite: int = 5) -> List[Tuple[str, str]]:
|
| 397 |
+
"""
|
| 398 |
+
Recupera o histórico de mensagens do banco de dados.
|
| 399 |
+
|
| 400 |
+
Args:
|
| 401 |
+
limite: Número máximo de mensagens a recuperar
|
| 402 |
+
|
| 403 |
+
Returns:
|
| 404 |
+
Lista de tuplas (mensagem, resposta)
|
| 405 |
+
"""
|
| 406 |
+
if not self.usuario:
|
| 407 |
+
return []
|
| 408 |
+
|
| 409 |
+
if self.db is None:
|
| 410 |
+
return []
|
| 411 |
+
|
| 412 |
+
try:
|
| 413 |
+
# 🔥 CONTEXT ISOLATION: Usa conversation_id se disponível
|
| 414 |
+
raw_result = self.db.recuperar_historico(
|
| 415 |
+
self.usuario,
|
| 416 |
+
limite=limite,
|
| 417 |
+
conversation_id=self.conversation_id
|
| 418 |
+
)
|
| 419 |
+
return raw_result if raw_result else []
|
| 420 |
+
except Exception as e:
|
| 421 |
+
# Fallback para o método antigo
|
| 422 |
+
try:
|
| 423 |
+
raw_result = self.db.recuperar_mensagens(self.usuario, limite=limite)
|
| 424 |
+
return raw_result if raw_result else []
|
| 425 |
+
except Exception as e2:
|
| 426 |
+
logger.warning(f"Erro ao recuperar histórico: {e2}")
|
| 427 |
+
return []
|
| 428 |
+
|
| 429 |
+
def obter_historico_expandido(self, limite: int = 30) -> List[Tuple[str, str]]:
|
| 430 |
+
"""
|
| 431 |
+
Recupera histórico expandido (últimas 30 mensagens) para contexto completo.
|
| 432 |
+
|
| 433 |
+
Args:
|
| 434 |
+
limite: Número máximo de mensagens (padrão 30)
|
| 435 |
+
|
| 436 |
+
Returns:
|
| 437 |
+
Lista de tuplas (mensagem, resposta)
|
| 438 |
+
"""
|
| 439 |
+
return self.obter_historico(limite=limite)
|
| 440 |
+
|
| 441 |
+
def criar_resumo_topicos_conversa(self, historico: List[Tuple[str, str]]) -> Dict[str, Any]:
|
| 442 |
+
"""
|
| 443 |
+
Cria resumo inteligente de tópicos da conversa em tempo real.
|
| 444 |
+
"""
|
| 445 |
+
if not historico:
|
| 446 |
+
return {"topicos": [], "resumo": "Conversa vazia"}
|
| 447 |
+
|
| 448 |
+
topicos_detectados = []
|
| 449 |
+
mensagens_concat = " ".join([msg for msg, _ in historico]).lower()
|
| 450 |
+
|
| 451 |
+
categorias = {
|
| 452 |
+
"tecnologia": ["computador", "programa", "código", "app", "site", "internet", "ai", "bot"],
|
| 453 |
+
"pessoal": ["eu", "minha", "meu", "vida", "família", "amigo", "trabalho"],
|
| 454 |
+
"entretenimento": ["música", "filme", "jogo", "esporte", "notícia", "youtube"],
|
| 455 |
+
"ajuda": ["ajuda", "como", "explicar", "ensinar", "dúvida", "problema"],
|
| 456 |
+
"conversa": ["oi", "ola", "bom", "tudo", "bem", "como vai"]
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
for categoria, palavras in categorias.items():
|
| 460 |
+
if any(palavra in mensagens_concat for palavra in palavras):
|
| 461 |
+
topicos_detectados.append(categoria)
|
| 462 |
+
|
| 463 |
+
num_mensagens = len(historico)
|
| 464 |
+
resumo = f"Conversa com {num_mensagens} mensagens sobre: {', '.join(topicos_detectados[:3])}"
|
| 465 |
+
|
| 466 |
+
return {
|
| 467 |
+
"topicos": topicos_detectados,
|
| 468 |
+
"resumo": resumo,
|
| 469 |
+
"num_mensagens": num_mensagens,
|
| 470 |
+
"timestamp": datetime.now().isoformat(),
|
| 471 |
+
"nota": "ESTE RESUMO É APENAS PARA CONTEXTO INTERNO DA API - NÃO INCLUIR NAS RESPOSTAS!"
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
def processar_contexto_reply(
|
| 475 |
+
self,
|
| 476 |
+
mensagem: str,
|
| 477 |
+
reply_metadata: Dict[str, Any],
|
| 478 |
+
historico_geral: List[Tuple[str, str]]
|
| 479 |
+
) -> Dict[str, Any]:
|
| 480 |
+
"""
|
| 481 |
+
Processa contexto específico de reply, mantendo histórico geral.
|
| 482 |
+
"""
|
| 483 |
+
contexto_reply = {
|
| 484 |
+
"is_reply": reply_metadata.get('is_reply', False),
|
| 485 |
+
"reply_to_bot": reply_metadata.get('reply_to_bot', False),
|
| 486 |
+
"quoted_author": reply_metadata.get('quoted_author_name', ''),
|
| 487 |
+
"quoted_text": reply_metadata.get('quoted_text_original', ''),
|
| 488 |
+
"context_hint": reply_metadata.get('context_hint', ''),
|
| 489 |
+
"historico_geral": historico_geral,
|
| 490 |
+
"resumo_topicos": self.criar_resumo_topicos_conversa(historico_geral)
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
if contexto_reply["is_reply"]:
|
| 494 |
+
quoted_content = self._extract_full_quoted_content(reply_metadata)
|
| 495 |
+
contexto_reply["quoted_content_full"] = quoted_content
|
| 496 |
+
|
| 497 |
+
content_analysis = self._analyze_quoted_content_for_reply(quoted_content, mensagem)
|
| 498 |
+
contexto_reply["content_analysis"] = content_analysis
|
| 499 |
+
|
| 500 |
+
related_context = self._find_related_context_in_history(quoted_content, historico_geral)
|
| 501 |
+
contexto_reply["related_context"] = related_context
|
| 502 |
+
|
| 503 |
+
reply_priority = self._calculate_reply_priority(
|
| 504 |
+
reply_metadata,
|
| 505 |
+
quoted_content,
|
| 506 |
+
mensagem
|
| 507 |
+
)
|
| 508 |
+
contexto_reply["reply_priority"] = reply_priority
|
| 509 |
+
|
| 510 |
+
topics = self._extract_topics_from_quoted_content(quoted_content)
|
| 511 |
+
contexto_reply["topics_identified"] = topics
|
| 512 |
+
|
| 513 |
+
return contexto_reply
|
| 514 |
+
|
| 515 |
+
def _extract_full_quoted_content(self, reply_metadata: Dict[str, Any]) -> str:
|
| 516 |
+
fields_to_check = [
|
| 517 |
+
'mensagem_citada', 'quoted_text_original', 'quoted_text', 'reply_content', 'full_message'
|
| 518 |
+
]
|
| 519 |
+
|
| 520 |
+
for field in fields_to_check:
|
| 521 |
+
if field in reply_metadata and reply_metadata[field]:
|
| 522 |
+
content = str(reply_metadata[field]).strip()
|
| 523 |
+
if len(content) > 5:
|
| 524 |
+
return content
|
| 525 |
+
|
| 526 |
+
for key, value in reply_metadata.items():
|
| 527 |
+
if isinstance(value, str) and len(value) > 10:
|
| 528 |
+
if any(word in value.lower() for word in ['eu', 'você', 'tu', 'mim', 'nosso', 'teu']):
|
| 529 |
+
return value.strip()
|
| 530 |
+
|
| 531 |
+
return ""
|
| 532 |
+
|
| 533 |
+
def _analyze_quoted_content_for_reply(self, quoted_content: str, current_message: str) -> Dict[str, Any]:
|
| 534 |
+
if not quoted_content:
|
| 535 |
+
return {"empty": True}
|
| 536 |
+
|
| 537 |
+
quoted_lower = quoted_content.lower()
|
| 538 |
+
|
| 539 |
+
content_type = "general"
|
| 540 |
+
if any(w in quoted_lower for w in ['?', 'qual', 'quando', 'onde', 'como', 'por que']):
|
| 541 |
+
content_type = "question"
|
| 542 |
+
elif any(w in quoted_lower for w in ['eu', 'mim', 'meu', 'minha', 'eu sou']):
|
| 543 |
+
content_type = "personal"
|
| 544 |
+
elif any(w in quoted_lower for w in ['akira', 'bot', 'você', 'vc']):
|
| 545 |
+
content_type = "about_bot"
|
| 546 |
+
|
| 547 |
+
keywords = []
|
| 548 |
+
keyword_mapping = {
|
| 549 |
+
"tempo": ["tempo", "clima", "chover", "sol", "temperatura"],
|
| 550 |
+
"musica": ["música", "musica", "youtube", "yt"],
|
| 551 |
+
"traducao": ["traduz", "letra", "ingles", "english", "tradução"],
|
| 552 |
+
"pesquisa": ["pesquisa", "web", "google", "busca", "buscar"],
|
| 553 |
+
"emocao": ["triste", "feliz", "raiva", "amor", "medo", "alegria"],
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
for category, words in keyword_mapping.items():
|
| 557 |
+
if any(w in quoted_lower for w in words):
|
| 558 |
+
keywords.append(category)
|
| 559 |
+
|
| 560 |
+
tone = "neutral"
|
| 561 |
+
if any(w in quoted_lower for w in ['kkk', 'haha', '😂', '🤣']):
|
| 562 |
+
tone = "humorous"
|
| 563 |
+
elif any(w in quoted_lower for w in ['!!!', '???', 'nossa', 'eita']):
|
| 564 |
+
tone = "excited"
|
| 565 |
+
elif any(w in quoted_lower for w in ['.', '..', '...']):
|
| 566 |
+
tone = "thoughtful"
|
| 567 |
+
|
| 568 |
+
return {
|
| 569 |
+
"content_type": content_type,
|
| 570 |
+
"keywords": keywords,
|
| 571 |
+
"tone": tone,
|
| 572 |
+
"length": len(quoted_content),
|
| 573 |
+
"has_question": '?' in quoted_content,
|
| 574 |
+
"is_about_bot": "about_bot" in keywords,
|
| 575 |
+
"has_emotion_keywords": len([k for k in keywords if k == "emocao"]) > 0
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
def _find_related_context_in_history(self, quoted_content: str, historico: List[Tuple[str, str]]) -> List[Dict[str, Any]]:
|
| 579 |
+
if not quoted_content or not historico:
|
| 580 |
+
return []
|
| 581 |
+
|
| 582 |
+
related_contexts = []
|
| 583 |
+
quoted_words = set(quoted_content.lower().split())
|
| 584 |
+
|
| 585 |
+
for i, (msg_user, msg_bot) in enumerate(historico):
|
| 586 |
+
if not msg_user or not msg_bot:
|
| 587 |
+
continue
|
| 588 |
+
|
| 589 |
+
msg_words = set((msg_user + " " + msg_bot).lower().split())
|
| 590 |
+
intersection = quoted_words.intersection(msg_words)
|
| 591 |
+
|
| 592 |
+
if intersection:
|
| 593 |
+
similarity = len(intersection) / len(quoted_words.union(msg_words))
|
| 594 |
+
if similarity > 0.1:
|
| 595 |
+
related_contexts.append({
|
| 596 |
+
"index": i,
|
| 597 |
+
"similarity": round(similarity, 3),
|
| 598 |
+
"user_message": msg_user[:100] if len(msg_user) > 100 else msg_user,
|
| 599 |
+
"bot_response": msg_bot[:100] if len(msg_bot) > 100 else msg_bot,
|
| 600 |
+
"common_words": list(intersection)[:5]
|
| 601 |
+
})
|
| 602 |
+
|
| 603 |
+
related_contexts.sort(key=lambda x: x["similarity"], reverse=True)
|
| 604 |
+
return related_contexts[:5]
|
| 605 |
+
|
| 606 |
+
def _calculate_reply_priority(self, reply_metadata: Dict[str, Any], quoted_content: str, current_message: str) -> Dict[str, Any]:
|
| 607 |
+
priority = 1
|
| 608 |
+
priority_type = "normal"
|
| 609 |
+
should_prioritize = False
|
| 610 |
+
|
| 611 |
+
is_reply_to_bot = reply_metadata.get('reply_to_bot', False)
|
| 612 |
+
current_words = current_message.split()
|
| 613 |
+
is_short_question = (
|
| 614 |
+
len(current_words) <= 5 and
|
| 615 |
+
any(w in current_message.lower() for w in ['?', 'qual', 'quando', 'onde', 'como', 'oq'])
|
| 616 |
+
)
|
| 617 |
+
has_quoted_content = len(quoted_content) > 10
|
| 618 |
+
|
| 619 |
+
if is_reply_to_bot and is_short_question:
|
| 620 |
+
priority = 4
|
| 621 |
+
priority_type = "critical_short_question"
|
| 622 |
+
should_prioritize = True
|
| 623 |
+
elif is_reply_to_bot:
|
| 624 |
+
priority = 3
|
| 625 |
+
priority_type = "reply_to_bot"
|
| 626 |
+
should_prioritize = True
|
| 627 |
+
elif is_short_question:
|
| 628 |
+
priority = 2
|
| 629 |
+
priority_type = "short_question"
|
| 630 |
+
should_prioritize = True
|
| 631 |
+
elif has_quoted_content:
|
| 632 |
+
priority = 1.5
|
| 633 |
+
priority_type = "has_content"
|
| 634 |
+
|
| 635 |
+
return {
|
| 636 |
+
"priority": priority,
|
| 637 |
+
"type": priority_type,
|
| 638 |
+
"should_prioritize": should_prioritize,
|
| 639 |
+
"is_reply_to_bot": is_reply_to_bot,
|
| 640 |
+
"is_short_question": is_short_question,
|
| 641 |
+
"has_quoted_content": has_quoted_content,
|
| 642 |
+
"multiplier": min(priority / 2, 1.0)
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
def _extract_topics_from_quoted_content(self, quoted_content: str) -> List[str]:
|
| 646 |
+
if not quoted_content:
|
| 647 |
+
return []
|
| 648 |
+
|
| 649 |
+
topics = []
|
| 650 |
+
quoted_lower = quoted_content.lower()
|
| 651 |
+
|
| 652 |
+
topic_keywords = {
|
| 653 |
+
"tempo_clima": ["tempo", "clima", "chover", "sol", "chuva", "temperatura"],
|
| 654 |
+
"musica": ["música", "musica", "youtube", "yt", "cantor", "link"],
|
| 655 |
+
"traducao": ["traduz", "letra", "ingles", "english", "português", "significado"],
|
| 656 |
+
"pesquisa": ["pesquisa", "web", "google", "busca", "buscar", "encontrar"],
|
| 657 |
+
"emocoes": ["triste", "feliz", "raiva", "amor", "medo", "alegria", "sentimento"],
|
| 658 |
+
"tecnologia": ["programa", "código", "app", "site", "internet", "bot", "akira"]
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
for topic, keywords in topic_keywords.items():
|
| 662 |
+
if any(kw in quoted_lower for kw in keywords):
|
| 663 |
+
topics.append(topic)
|
| 664 |
+
|
| 665 |
+
if not topics:
|
| 666 |
+
topics.append("general")
|
| 667 |
+
|
| 668 |
+
return topics
|
| 669 |
+
|
| 670 |
+
def atualizar_contexto(
|
| 671 |
+
self,
|
| 672 |
+
mensagem: str,
|
| 673 |
+
resposta: str,
|
| 674 |
+
numero: Optional[str] = None
|
| 675 |
+
):
|
| 676 |
+
"""
|
| 677 |
+
Salva a interação no banco e aciona aprendizado de termos.
|
| 678 |
+
|
| 679 |
+
Args:
|
| 680 |
+
mensagem: Mensagem do usuário
|
| 681 |
+
resposta: Resposta gerada
|
| 682 |
+
numero: Número de telefone
|
| 683 |
+
"""
|
| 684 |
+
if not self.usuario:
|
| 685 |
+
usuario = 'anonimo'
|
| 686 |
+
else:
|
| 687 |
+
usuario = self.usuario
|
| 688 |
+
|
| 689 |
+
final_numero = numero if numero else self.usuario
|
| 690 |
+
|
| 691 |
+
try:
|
| 692 |
+
if self.db is not None:
|
| 693 |
+
self.db.salvar_mensagem(usuario, mensagem, resposta, numero=final_numero)
|
| 694 |
+
|
| 695 |
+
historico = self.obter_historico(limite=10)
|
| 696 |
+
self.aprender_do_historico(mensagem, resposta, historico)
|
| 697 |
+
|
| 698 |
+
if final_numero:
|
| 699 |
+
self.salvar_estado_contexto_no_db(final_numero)
|
| 700 |
+
|
| 701 |
+
except Exception as e:
|
| 702 |
+
logger.warning(f'Falha ao salvar mensagem no DB: {e}')
|
| 703 |
+
|
| 704 |
+
def salvar_estado_contexto_no_db(self, user_key: str):
|
| 705 |
+
"""
|
| 706 |
+
Persiste o estado atual da classe Contexto no banco de dados.
|
| 707 |
+
|
| 708 |
+
Args:
|
| 709 |
+
user_key: Chave do usuário
|
| 710 |
+
"""
|
| 711 |
+
if self.db is None:
|
| 712 |
+
return
|
| 713 |
+
|
| 714 |
+
termos_json = json.dumps(self.termo_contexto)
|
| 715 |
+
emocao_str = self.emocao_atual
|
| 716 |
+
|
| 717 |
+
try:
|
| 718 |
+
self.db.salvar_aprendizado_detalhado(user_key, "emocao_atual", json.dumps({"emocao": emocao_str}))
|
| 719 |
+
|
| 720 |
+
self.db.salvar_contexto(
|
| 721 |
+
user_key=user_key,
|
| 722 |
+
historico="[]",
|
| 723 |
+
emocao_atual=emocao_str,
|
| 724 |
+
termos=termos_json,
|
| 725 |
+
girias=termos_json,
|
| 726 |
+
tom=emocao_str
|
| 727 |
+
)
|
| 728 |
+
logger.debug(f"Contexto do usuário {user_key} salvo no DB.")
|
| 729 |
+
except Exception as e:
|
| 730 |
+
logger.error(f"Falha ao salvar estado do contexto no DB: {e}")
|
| 731 |
+
|
| 732 |
+
def aprender_do_historico(
|
| 733 |
+
self,
|
| 734 |
+
mensagem: str,
|
| 735 |
+
resposta: str,
|
| 736 |
+
historico: List[Tuple[str, str]]
|
| 737 |
+
):
|
| 738 |
+
"""
|
| 739 |
+
Aprende termos do histórico de conversas.
|
| 740 |
+
|
| 741 |
+
Args:
|
| 742 |
+
mensagem: Mensagem do usuário
|
| 743 |
+
resposta: Resposta gerada
|
| 744 |
+
historico: Histórico de conversas
|
| 745 |
+
"""
|
| 746 |
+
if not self.usuario:
|
| 747 |
+
return
|
| 748 |
+
|
| 749 |
+
if self.db is None:
|
| 750 |
+
return
|
| 751 |
+
|
| 752 |
+
mensagem_lower = mensagem.lower()
|
| 753 |
+
|
| 754 |
+
# Gírias angolanas comuns
|
| 755 |
+
girias_angolanas = ['ya', 'bué', 'fixe', 'puto', 'kapa', 'muxima', 'kalai']
|
| 756 |
+
|
| 757 |
+
for giria in girias_angolanas:
|
| 758 |
+
if giria in mensagem_lower:
|
| 759 |
+
try:
|
| 760 |
+
significado_placeholder = f'termo regional para {giria}'
|
| 761 |
+
|
| 762 |
+
self.db.salvar_giria_aprendida(
|
| 763 |
+
self.usuario,
|
| 764 |
+
giria,
|
| 765 |
+
significado_placeholder,
|
| 766 |
+
mensagem[:50]
|
| 767 |
+
)
|
| 768 |
+
|
| 769 |
+
freq_atual = self.termo_contexto.get(giria, {}).get("frequencia", 0)
|
| 770 |
+
self.termo_contexto[giria] = {
|
| 771 |
+
"significado": significado_placeholder,
|
| 772 |
+
"frequencia": freq_atual + 1
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
except Exception as e:
|
| 776 |
+
logger.warning(f"Erro ao salvar gíria no DB: {e}")
|
| 777 |
+
|
| 778 |
+
def substituir_termos_aprendidos(self, mensagem: str) -> str:
|
| 779 |
+
"""
|
| 780 |
+
Substitui termos aprendidos na mensagem.
|
| 781 |
+
|
| 782 |
+
Args:
|
| 783 |
+
mensagem: Mensagem original
|
| 784 |
+
|
| 785 |
+
Returns:
|
| 786 |
+
Mensagem com termos substituídos
|
| 787 |
+
"""
|
| 788 |
+
for termo, info in self.termo_contexto.items():
|
| 789 |
+
if isinstance(info, dict) and "significado" in info:
|
| 790 |
+
# Substitui apenas a palavra inteira (case insensitive)
|
| 791 |
+
mensagem = re.sub(
|
| 792 |
+
r'\b' + re.escape(termo) + r'\b',
|
| 793 |
+
info["significado"],
|
| 794 |
+
mensagem,
|
| 795 |
+
flags=re.IGNORECASE
|
| 796 |
+
)
|
| 797 |
+
return mensagem
|
| 798 |
+
|
| 799 |
+
def obter_aprendizado_detalhado(self, chave: str) -> Optional[Dict[str, Any]]:
|
| 800 |
+
"""
|
| 801 |
+
Recupera aprendizados detalhados do usuário.
|
| 802 |
+
|
| 803 |
+
Args:
|
| 804 |
+
chave: Chave do aprendizado
|
| 805 |
+
|
| 806 |
+
Returns:
|
| 807 |
+
Dicionário com o aprendizado ou None
|
| 808 |
+
"""
|
| 809 |
+
if not self.usuario:
|
| 810 |
+
return None
|
| 811 |
+
if self.db is None:
|
| 812 |
+
return None
|
| 813 |
+
try:
|
| 814 |
+
raw_data = self.db.recuperar_aprendizado_detalhado(self.usuario, chave)
|
| 815 |
+
if raw_data:
|
| 816 |
+
if isinstance(raw_data, str):
|
| 817 |
+
return json.loads(raw_data)
|
| 818 |
+
return raw_data
|
| 819 |
+
return None
|
| 820 |
+
except Exception as e:
|
| 821 |
+
logger.warning(f"Erro ao obter aprendizado detalhado: {e}")
|
| 822 |
+
return None
|
| 823 |
+
|
| 824 |
+
def obter_emocao_atual(self) -> str:
|
| 825 |
+
"""Recupera a emoção atual do usuário."""
|
| 826 |
+
return self.emocao_atual
|
| 827 |
+
|
| 828 |
+
def ativar_espirito_critico(self):
|
| 829 |
+
"""Ativa o espírito crítico para respostas questionadoras."""
|
| 830 |
+
self.espirito_critico = True
|
| 831 |
+
|
| 832 |
+
def obter_aprendizados(self) -> Dict[str, Any]:
|
| 833 |
+
"""
|
| 834 |
+
Retorna os aprendizados do usuário.
|
| 835 |
+
|
| 836 |
+
Returns:
|
| 837 |
+
Dicionário com termos, emoção e tom
|
| 838 |
+
"""
|
| 839 |
+
aprendizados = {
|
| 840 |
+
"termos": self.termo_contexto,
|
| 841 |
+
"emocao_preferida": self.emocao_atual,
|
| 842 |
+
"ton_predominante": self.ton_predominante
|
| 843 |
+
}
|
| 844 |
+
return aprendizados
|
| 845 |
+
|
| 846 |
+
def salvar_conhecimento_base(self, chave: str, valor: Any):
|
| 847 |
+
"""Salva uma informação na base de conhecimento."""
|
| 848 |
+
self.base_conhecimento[chave] = valor
|
| 849 |
+
|
| 850 |
+
def obter_conhecimento_base(self, chave: str) -> Optional[Any]:
|
| 851 |
+
"""Obtém uma informação da base de conhecimento."""
|
| 852 |
+
return self.base_conhecimento.get(chave)
|
| 853 |
+
|
| 854 |
+
def obter_historico_para_llm(self) -> List[Dict[str, str]]:
|
| 855 |
+
"""
|
| 856 |
+
Retorna o histórico no formato esperado pelos LLMs.
|
| 857 |
+
|
| 858 |
+
Returns:
|
| 859 |
+
Lista de dicionários com role e content
|
| 860 |
+
"""
|
| 861 |
+
historico = self.obter_historico()
|
| 862 |
+
if historico and len(historico) > 0:
|
| 863 |
+
return [
|
| 864 |
+
{"role": "user", "content": h[0]} if isinstance(h, tuple) and len(h) >= 2 else h
|
| 865 |
+
for h in historico
|
| 866 |
+
]
|
| 867 |
+
return []
|
| 868 |
+
|
| 869 |
+
|
| 870 |
+
# ================================================================
|
| 871 |
+
# FUNÇÕES AUXILIARES (para compatibilidade com testar_correcoes.py)
|
| 872 |
+
# ================================================================
|
| 873 |
+
|
| 874 |
+
def criar_contexto(db: Optional[Database], identificador: str) -> Contexto:
|
| 875 |
+
"""
|
| 876 |
+
Factory function para criar contexto.
|
| 877 |
+
|
| 878 |
+
Args:
|
| 879 |
+
db: Instância do banco de dados
|
| 880 |
+
identificador: Identificador do usuário
|
| 881 |
+
|
| 882 |
+
Returns:
|
| 883 |
+
Instância de Contexto
|
| 884 |
+
"""
|
| 885 |
+
return Contexto(db=db, usuario=identificador)
|
| 886 |
+
|
| 887 |
+
|
| 888 |
+
# Funções auxiliares para config.py
|
| 889 |
+
def eh_usuario_privilegiado(numero: str) -> bool:
|
| 890 |
+
"""
|
| 891 |
+
Verifica se um número é de usuário privilegiado.
|
| 892 |
+
|
| 893 |
+
Args:
|
| 894 |
+
numero: Número de telefone
|
| 895 |
+
|
| 896 |
+
Returns:
|
| 897 |
+
True se for privilegiado
|
| 898 |
+
"""
|
| 899 |
+
try:
|
| 900 |
+
from .database import Database
|
| 901 |
+
db = Database()
|
| 902 |
+
return db.eh_privilegiado(numero)
|
| 903 |
+
except Exception as e:
|
| 904 |
+
logger.error(f"Erro ao verificar privilégios: {e}")
|
| 905 |
+
return False
|
| 906 |
+
|
| 907 |
+
|
| 908 |
+
def forcar_modo_inicial_privilegiado(numero: str) -> str:
|
| 909 |
+
"""
|
| 910 |
+
Retorna o modo de fala forçado para usuário privilegiado.
|
| 911 |
+
|
| 912 |
+
Args:
|
| 913 |
+
numero: Número de telefone
|
| 914 |
+
|
| 915 |
+
Returns:
|
| 916 |
+
Modo de fala
|
| 917 |
+
"""
|
| 918 |
+
try:
|
| 919 |
+
from .database import Database
|
| 920 |
+
db = Database()
|
| 921 |
+
modo = db.obter_modo_fala_privilegiado(numero)
|
| 922 |
+
return modo if modo else "tecnico_formal"
|
| 923 |
+
except Exception as e:
|
| 924 |
+
logger.error(f"Erro ao obter modo de fala: {e}")
|
| 925 |
+
return "tecnico_formal"
|
| 926 |
+
|
| 927 |
+
|
| 928 |
+
def analisar_tom_usuario(mensagem: str) -> str:
|
| 929 |
+
"""
|
| 930 |
+
Analisa o tom de uma mensagem.
|
| 931 |
+
|
| 932 |
+
Args:
|
| 933 |
+
mensagem: Texto da mensagem
|
| 934 |
+
|
| 935 |
+
Returns:
|
| 936 |
+
Tom detectado
|
| 937 |
+
"""
|
| 938 |
+
contexto = Contexto(db=None, usuario=None)
|
| 939 |
+
analise = contexto.analisar_emocoes_mensagem(mensagem)
|
| 940 |
+
return analise.get("tom_sugerido", "neutro")
|
| 941 |
+
|
| 942 |
+
|
| 943 |
+
def determinar_nivel_transicao(
|
| 944 |
+
numero: str,
|
| 945 |
+
tom: str,
|
| 946 |
+
nivel_atual: int
|
| 947 |
+
) -> int:
|
| 948 |
+
"""
|
| 949 |
+
Determina o nível de transição baseado no tom.
|
| 950 |
+
Usa transição LENTA e gradual conforme configurações do config.
|
| 951 |
+
|
| 952 |
+
Args:
|
| 953 |
+
numero: Número do usuário
|
| 954 |
+
tom: Tom detectado
|
| 955 |
+
nivel_atual: Nível atual
|
| 956 |
+
|
| 957 |
+
Returns:
|
| 958 |
+
Novo nível de transição (mudança muito gradual)
|
| 959 |
+
"""
|
| 960 |
+
# threshold configurado no config.py (atual: 0.9)
|
| 961 |
+
threshold = getattr(config, 'TRANSICAO_HUMOR_THRESHOLD', 0.9)
|
| 962 |
+
nivel_max = getattr(config, 'NIVEL_TRANSICAO_MAX', 1)
|
| 963 |
+
|
| 964 |
+
# Com threshold de 0.9, só muda se tiver 90% de certeza
|
| 965 |
+
# Com nivel_max = 1, só pode mudar 1 nível por vez (muito lento)
|
| 966 |
+
|
| 967 |
+
if tom in ["formal", "tecnico_formal"]:
|
| 968 |
+
return min(nivel_atual + 1, nivel_max)
|
| 969 |
+
elif tom in ["casual", "informal"]:
|
| 970 |
+
return max(nivel_atual - 1, 1)
|
| 971 |
+
return nivel_atual
|
| 972 |
+
|
modules/database.py
CHANGED
|
@@ -1,1112 +1,853 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
#
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
#
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
(
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
"""
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
return
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
(
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
return
|
| 835 |
-
except Exception:
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
)
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
"SELECT nivel_transicao FROM contexto WHERE numero = ?",
|
| 855 |
-
(str(numero).strip(),),
|
| 856 |
-
fetch=True
|
| 857 |
-
)
|
| 858 |
-
return result[0][0] if result else 0
|
| 859 |
-
except Exception:
|
| 860 |
-
return 0
|
| 861 |
-
|
| 862 |
-
def recuperar_usuario_privilegiado(self, numero: str) -> bool:
|
| 863 |
-
"""Recupera se usuário é privilegiado"""
|
| 864 |
-
try:
|
| 865 |
-
result = self._execute_with_retry(
|
| 866 |
-
"SELECT usuario_privilegiado FROM contexto WHERE numero = ?",
|
| 867 |
-
(str(numero).strip(),),
|
| 868 |
-
fetch=True
|
| 869 |
-
)
|
| 870 |
-
return bool(result[0][0]) if result else False
|
| 871 |
-
except Exception:
|
| 872 |
-
return False
|
| 873 |
-
|
| 874 |
-
def recuperar_training_examples(self, limite: int = 100, usado: bool = False) -> List[Dict]:
|
| 875 |
-
"""Recupera exemplos de treinamento"""
|
| 876 |
-
try:
|
| 877 |
-
where_clause = "WHERE usado = 0" if not usado else ""
|
| 878 |
-
results = self._execute_with_retry(
|
| 879 |
-
f"""
|
| 880 |
-
SELECT input_text, output_text, humor, modo_resposta, nivel_transicao,
|
| 881 |
-
usuario_privilegiado, qualidade_score, tipo_interacao
|
| 882 |
-
FROM training_examples
|
| 883 |
-
{where_clause}
|
| 884 |
-
ORDER BY qualidade_score DESC
|
| 885 |
-
LIMIT ?
|
| 886 |
-
""",
|
| 887 |
-
(limite,),
|
| 888 |
-
fetch=True
|
| 889 |
-
)
|
| 890 |
-
|
| 891 |
-
return [
|
| 892 |
-
{
|
| 893 |
-
"input": r[0],
|
| 894 |
-
"output": r[1],
|
| 895 |
-
"humor": r[2],
|
| 896 |
-
"modo": r[3],
|
| 897 |
-
"nivel_transicao": r[4],
|
| 898 |
-
"usuario_privilegiado": bool(r[5]) if r[5] is not None else False,
|
| 899 |
-
"score": r[6],
|
| 900 |
-
"tipo": r[7]
|
| 901 |
-
}
|
| 902 |
-
for r in results
|
| 903 |
-
]
|
| 904 |
-
except Exception as e:
|
| 905 |
-
logger.error(f"❌ Erro ao recuperar exemplos: {e}")
|
| 906 |
-
return []
|
| 907 |
-
|
| 908 |
-
def marcar_examples_como_usados(self, ids: List[int] = None):
|
| 909 |
-
"""Marca exemplos como usados"""
|
| 910 |
-
try:
|
| 911 |
-
if ids:
|
| 912 |
-
placeholders = ','.join(['?'] * len(ids))
|
| 913 |
-
query = f"UPDATE training_examples SET usado = 1 WHERE id IN ({placeholders})"
|
| 914 |
-
self._execute_with_retry(query, tuple(ids), commit=True, fetch=False)
|
| 915 |
-
else:
|
| 916 |
-
self._execute_with_retry(
|
| 917 |
-
"UPDATE training_examples SET usado = 1 WHERE usado = 0",
|
| 918 |
-
commit=True,
|
| 919 |
-
fetch=False
|
| 920 |
-
)
|
| 921 |
-
except Exception as e:
|
| 922 |
-
logger.error(f"❌ Erro ao marcar exemplos: {e}")
|
| 923 |
-
|
| 924 |
-
# ========================================================================
|
| 925 |
-
# MÉTODO PARA REGISTRAR INTERAÇÃO (PARA TREINAMENTO) - COM usuario_privilegiado
|
| 926 |
-
# ========================================================================
|
| 927 |
-
|
| 928 |
-
def registrar_interacao(self, numero: str, mensagem: str, resposta: str,
|
| 929 |
-
humor: str = 'normal_ironico',
|
| 930 |
-
modo_resposta: str = 'normal_ironico',
|
| 931 |
-
nivel_transicao: int = 0,
|
| 932 |
-
usuario_privilegiado: bool = False, # NOVO PARÂMETRO
|
| 933 |
-
emocao_detectada: str = None,
|
| 934 |
-
tipo_conversa: str = 'pv',
|
| 935 |
-
reply_info_json: str = None,
|
| 936 |
-
qualidade_score: float = 1.0) -> bool:
|
| 937 |
-
"""Registra interação para treinamento - COM usuario_privilegiado"""
|
| 938 |
-
try:
|
| 939 |
-
if isinstance(reply_info_json, dict):
|
| 940 |
-
reply_info_json = json.dumps(reply_info_json, ensure_ascii=False)
|
| 941 |
-
|
| 942 |
-
self._execute_with_retry(
|
| 943 |
-
"""
|
| 944 |
-
INSERT INTO interacoes
|
| 945 |
-
(numero, mensagem, resposta, humor, modo_resposta, nivel_transicao,
|
| 946 |
-
usuario_privilegiado, emocao_detectada, tipo_conversa, reply_info_json, qualidade_score)
|
| 947 |
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 948 |
-
""",
|
| 949 |
-
(
|
| 950 |
-
str(numero).strip(), mensagem[:2000], resposta[:2000], humor, modo_resposta,
|
| 951 |
-
nivel_transicao, int(usuario_privilegiado), emocao_detectada,
|
| 952 |
-
tipo_conversa, reply_info_json, qualidade_score
|
| 953 |
-
),
|
| 954 |
-
commit=True,
|
| 955 |
-
fetch=False
|
| 956 |
-
)
|
| 957 |
-
logger.debug(f"✅ Interação registrada: {numero} | Nível: {nivel_transicao} | Privilegiado: {usuario_privilegiado}")
|
| 958 |
-
return True
|
| 959 |
-
except Exception as e:
|
| 960 |
-
logger.error(f"❌ Erro ao registrar interação: {e}")
|
| 961 |
-
return False
|
| 962 |
-
|
| 963 |
-
# ========================================================================
|
| 964 |
-
# PRIVILÉGIOS
|
| 965 |
-
# ========================================================================
|
| 966 |
-
|
| 967 |
-
def is_usuario_privilegiado(self, numero: str) -> bool:
|
| 968 |
-
"""Verifica se usuário é privilegiado"""
|
| 969 |
-
try:
|
| 970 |
-
result = self._execute_with_retry(
|
| 971 |
-
"SELECT 1 FROM usuarios_privilegiados WHERE numero = ?",
|
| 972 |
-
(str(numero).strip(),),
|
| 973 |
-
fetch=True
|
| 974 |
-
)
|
| 975 |
-
return bool(result)
|
| 976 |
-
except Exception:
|
| 977 |
-
return False
|
| 978 |
-
|
| 979 |
-
def pode_usar_reset(self, numero: str) -> bool:
|
| 980 |
-
"""Verifica se pode usar reset"""
|
| 981 |
-
try:
|
| 982 |
-
result = self._execute_with_retry(
|
| 983 |
-
"SELECT pode_usar_reset FROM usuarios_privilegiados WHERE numero = ?",
|
| 984 |
-
(str(numero).strip(),),
|
| 985 |
-
fetch=True
|
| 986 |
-
)
|
| 987 |
-
return bool(result and result[0][0])
|
| 988 |
-
except Exception:
|
| 989 |
-
return False
|
| 990 |
-
|
| 991 |
-
def registrar_comando(self, numero: str, comando: str, parametros: str = None,
|
| 992 |
-
sucesso: bool = True, resposta: str = None,
|
| 993 |
-
tipo_conversa: str = 'pv', grupo_id: str = ''):
|
| 994 |
-
"""Registra comando executado"""
|
| 995 |
-
try:
|
| 996 |
-
self._execute_with_retry(
|
| 997 |
-
"""
|
| 998 |
-
INSERT INTO comandos_executados
|
| 999 |
-
(numero, comando, parametros, sucesso, resposta, tipo_conversa, grupo_id)
|
| 1000 |
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 1001 |
-
""",
|
| 1002 |
-
(
|
| 1003 |
-
str(numero).strip(), comando, parametros, int(sucesso), resposta, tipo_conversa, grupo_id),
|
| 1004 |
-
commit=True,
|
| 1005 |
-
fetch=False
|
| 1006 |
-
)
|
| 1007 |
-
except Exception as e:
|
| 1008 |
-
logger.error(f"❌ Erro ao registrar comando: {e}")
|
| 1009 |
-
|
| 1010 |
-
def resetar_contexto_usuario(self, numero: str, tipo: str = "completo") -> Dict:
|
| 1011 |
-
"""Reseta contexto do usuário"""
|
| 1012 |
-
try:
|
| 1013 |
-
if not self.pode_usar_reset(numero):
|
| 1014 |
-
return {"sucesso": False, "erro": "Sem permissão", "itens_apagados": 0}
|
| 1015 |
-
|
| 1016 |
-
itens = 0
|
| 1017 |
-
|
| 1018 |
-
# Remove mensagens
|
| 1019 |
-
self._execute_with_retry(
|
| 1020 |
-
"DELETE FROM mensagens WHERE numero = ?",
|
| 1021 |
-
(str(numero).strip(),),
|
| 1022 |
-
commit=True,
|
| 1023 |
-
fetch=False
|
| 1024 |
-
)
|
| 1025 |
-
itens += 1
|
| 1026 |
-
|
| 1027 |
-
# Remove contexto
|
| 1028 |
-
self._execute_with_retry(
|
| 1029 |
-
"DELETE FROM contexto WHERE numero = ?",
|
| 1030 |
-
(str(numero).strip(),),
|
| 1031 |
-
commit=True,
|
| 1032 |
-
fetch=False
|
| 1033 |
-
)
|
| 1034 |
-
itens += 1
|
| 1035 |
-
|
| 1036 |
-
logger.info(f"✅ Reset completo para {numero}: {itens} itens")
|
| 1037 |
-
return {"sucesso": True, "itens_apagados": itens}
|
| 1038 |
-
|
| 1039 |
-
except Exception as e:
|
| 1040 |
-
logger.error(f"❌ Erro ao resetar: {e}")
|
| 1041 |
-
return {"sucesso": False, "erro": str(e), "itens_apagados": 0}
|
| 1042 |
-
|
| 1043 |
-
# ========================================================================
|
| 1044 |
-
# AUXILIARES
|
| 1045 |
-
# ========================================================================
|
| 1046 |
-
|
| 1047 |
-
def _gerar_contexto_id(self, numero: str, tipo: str = 'auto') -> str:
|
| 1048 |
-
"""Gera ID único para contexto"""
|
| 1049 |
-
if tipo == 'auto':
|
| 1050 |
-
num_str = str(numero).lower()
|
| 1051 |
-
if "@g.us" in num_str or "grupo_" in num_str or "120363" in num_str:
|
| 1052 |
-
tipo = "grupo"
|
| 1053 |
-
else:
|
| 1054 |
-
tipo = "pv"
|
| 1055 |
-
|
| 1056 |
-
data_semana = datetime.now().strftime("%Y-%W")
|
| 1057 |
-
salt = f"AKIRA_V21_{data_semana}_ISOLATION"
|
| 1058 |
-
raw = f"{str(numero).strip()}|{tipo}|{salt}"
|
| 1059 |
-
return hashlib.sha256(raw.encode()).hexdigest()[:32]
|
| 1060 |
-
|
| 1061 |
-
def registrar_tom_usuario(self, numero: str, tom: str, confianca: float = 0.6,
|
| 1062 |
-
mensagem_contexto: str = None) -> bool:
|
| 1063 |
-
"""Registra tom detectado"""
|
| 1064 |
-
try:
|
| 1065 |
-
logger.info(f"✅ Tom registrado: {tom} ({confianca:.2f}) para {numero}")
|
| 1066 |
-
return True
|
| 1067 |
-
except Exception as e:
|
| 1068 |
-
logger.error(f"❌ Erro ao registrar tom: {e}")
|
| 1069 |
-
return False
|
| 1070 |
-
|
| 1071 |
-
def salvar_aprendizado_detalhado(self, input_text: str, output_text: str,
|
| 1072 |
-
contexto: Dict, qualidade_score: float = 1.0,
|
| 1073 |
-
tipo_aprendizado: str = "reply_padrao",
|
| 1074 |
-
metadata: Dict = None) -> bool:
|
| 1075 |
-
"""Salva aprendizado detalhado"""
|
| 1076 |
-
try:
|
| 1077 |
-
contexto_super_claro = {
|
| 1078 |
-
'tipo_aprendizado': tipo_aprendizado,
|
| 1079 |
-
'metadata': metadata or {},
|
| 1080 |
-
'timestamp': time.time()
|
| 1081 |
-
}
|
| 1082 |
-
|
| 1083 |
-
nivel_transicao = contexto.get('nivel_transicao', 0)
|
| 1084 |
-
usuario_privilegiado = contexto.get('usuario_privilegiado', False)
|
| 1085 |
-
|
| 1086 |
-
return self.salvar_training_example(
|
| 1087 |
-
input_text=input_text,
|
| 1088 |
-
output_text=output_text,
|
| 1089 |
-
humor=contexto.get("humor_atualizado", "normal_ironico"),
|
| 1090 |
-
modo_resposta=contexto.get("modo_resposta", "normal_ironico"),
|
| 1091 |
-
nivel_transicao=nivel_transicao,
|
| 1092 |
-
usuario_privilegiado=usuario_privilegiado,
|
| 1093 |
-
qualidade_score=qualidade_score,
|
| 1094 |
-
contexto_super_claro=contexto_super_claro,
|
| 1095 |
-
tipo_interacao=tipo_aprendizado
|
| 1096 |
-
)
|
| 1097 |
-
except Exception as e:
|
| 1098 |
-
logger.error(f"❌ Erro ao salvar aprendizado: {e}")
|
| 1099 |
-
return False
|
| 1100 |
-
|
| 1101 |
-
def close(self):
|
| 1102 |
-
"""Fecha conexão"""
|
| 1103 |
-
logger.info("✅ Database fechado")
|
| 1104 |
-
|
| 1105 |
-
# Singleton
|
| 1106 |
-
_db_instance = None
|
| 1107 |
-
|
| 1108 |
-
def get_database(db_path: str = "akira.db") -> Database:
|
| 1109 |
-
global _db_instance
|
| 1110 |
-
if _db_instance is None:
|
| 1111 |
-
_db_instance = Database(db_path)
|
| 1112 |
-
return _db_instance
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
================================================================================
|
| 3 |
+
AKIRA V21 ULTIMATE - DATABASE MODULE
|
| 4 |
+
================================================================================
|
| 5 |
+
Banco de dados SQLite extremamente robusto, moderno e completo.
|
| 6 |
+
Gerencia: mensagens, embeddings, gírias, tom, aprendizados, API logs, training sessions.
|
| 7 |
+
|
| 8 |
+
Features:
|
| 9 |
+
- SQLite com WAL mode para performance máxima
|
| 10 |
+
- Retry logic com exponential backoff
|
| 11 |
+
- Full-text search com FTS5
|
| 12 |
+
- Vector storage para embeddings (SentenceTransformers)
|
| 13 |
+
- Transactions.atomic()
|
| 14 |
+
- Backup/restore automático
|
| 15 |
+
- Health checks e métricas detalhadas
|
| 16 |
+
- Índices otimizados
|
| 17 |
+
- Migration system completo
|
| 18 |
+
- Logging detalhado
|
| 19 |
+
- Singleton pattern para conexões
|
| 20 |
+
- Suporte a numpy arrays para embeddings
|
| 21 |
+
- API performance tracking
|
| 22 |
+
- Training sessions tracking
|
| 23 |
+
================================================================================
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
import sqlite3
|
| 27 |
+
import time
|
| 28 |
+
import os
|
| 29 |
+
import json
|
| 30 |
+
import hashlib
|
| 31 |
+
import random
|
| 32 |
+
from typing import Optional, List, Dict, Any, Tuple, Union
|
| 33 |
+
from datetime import datetime
|
| 34 |
+
from loguru import logger
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class Database:
|
| 38 |
+
"""
|
| 39 |
+
Classe de banco de dados robusta para Akira V21 Ultimate.
|
| 40 |
+
Suporta múltiplas tabelas, migrações automáticas e operações com retry.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
# Códigos de verificação para usuários privilegiados
|
| 44 |
+
CODIGOS_VERIFICACAO: Dict[str, str] = {}
|
| 45 |
+
|
| 46 |
+
def __init__(self, db_path: str = "akira.db"):
|
| 47 |
+
"""
|
| 48 |
+
Inicializa a conexão com o banco de dados.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
db_path: Caminho para o arquivo do banco de dados SQLite
|
| 52 |
+
"""
|
| 53 |
+
self.db_path = db_path
|
| 54 |
+
self.max_retries = 5
|
| 55 |
+
self.retry_delay = 0.1
|
| 56 |
+
|
| 57 |
+
# Garante que o diretório existe
|
| 58 |
+
db_dir = os.path.dirname(db_path)
|
| 59 |
+
if db_dir and not os.path.exists(db_dir):
|
| 60 |
+
os.makedirs(db_dir, exist_ok=True)
|
| 61 |
+
|
| 62 |
+
self._init_db()
|
| 63 |
+
self._ensure_all_columns_and_indexes()
|
| 64 |
+
logger.info(f"Database inicializado: {self.db_path}")
|
| 65 |
+
|
| 66 |
+
# ================================================================
|
| 67 |
+
# CONEXÃO + RETRY
|
| 68 |
+
# ================================================================
|
| 69 |
+
def _get_connection(self) -> sqlite3.Connection:
|
| 70 |
+
"""Obtém conexão com retry automático."""
|
| 71 |
+
for attempt in range(self.max_retries):
|
| 72 |
+
try:
|
| 73 |
+
conn = sqlite3.connect(
|
| 74 |
+
self.db_path,
|
| 75 |
+
timeout=30.0,
|
| 76 |
+
check_same_thread=False
|
| 77 |
+
)
|
| 78 |
+
# Otimizações SQLite para performance
|
| 79 |
+
conn.execute("PRAGMA journal_mode=WAL")
|
| 80 |
+
conn.execute("PRAGMA synchronous=NORMAL")
|
| 81 |
+
conn.execute("PRAGMA cache_size=1000")
|
| 82 |
+
conn.execute("PRAGMA temp_store=MEMORY")
|
| 83 |
+
conn.execute("PRAGMA busy_timeout=30000")
|
| 84 |
+
conn.execute("PRAGMA foreign_keys=ON")
|
| 85 |
+
conn.row_factory = sqlite3.Row
|
| 86 |
+
return conn
|
| 87 |
+
except sqlite3.OperationalError as e:
|
| 88 |
+
if "locked" in str(e) and attempt < self.max_retries - 1:
|
| 89 |
+
time.sleep(self.retry_delay * (2 ** attempt))
|
| 90 |
+
continue
|
| 91 |
+
logger.error(f"Erro de conexão DB: {e}")
|
| 92 |
+
raise
|
| 93 |
+
raise sqlite3.OperationalError("Falha ao conectar ao banco após várias tentativas")
|
| 94 |
+
|
| 95 |
+
def _execute_with_retry(
|
| 96 |
+
self,
|
| 97 |
+
query: str,
|
| 98 |
+
params: Optional[tuple] = None,
|
| 99 |
+
commit: bool = False
|
| 100 |
+
) -> Optional[List[sqlite3.Row]]:
|
| 101 |
+
"""Executa query com retry automático."""
|
| 102 |
+
for attempt in range(self.max_retries):
|
| 103 |
+
try:
|
| 104 |
+
with self._get_connection() as conn:
|
| 105 |
+
cur = conn.cursor()
|
| 106 |
+
cur.execute(query, params or ())
|
| 107 |
+
|
| 108 |
+
if query.strip().upper().startswith("SELECT"):
|
| 109 |
+
result = cur.fetchall()
|
| 110 |
+
return result
|
| 111 |
+
|
| 112 |
+
if commit:
|
| 113 |
+
conn.commit()
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
+
except sqlite3.OperationalError as e:
|
| 117 |
+
if "locked" in str(e) and attempt < self.max_retries - 1:
|
| 118 |
+
time.sleep(self.retry_delay * (2 ** attempt))
|
| 119 |
+
continue
|
| 120 |
+
logger.error(f"Erro SQL: {e}")
|
| 121 |
+
raise
|
| 122 |
+
raise sqlite3.OperationalError("Query falhou após retries")
|
| 123 |
+
|
| 124 |
+
# ================================================================
|
| 125 |
+
# SCHEMA + MIGRAÇÃO
|
| 126 |
+
# ================================================================
|
| 127 |
+
def _init_db(self):
|
| 128 |
+
"""Inicializa todas as tabelas do banco."""
|
| 129 |
+
try:
|
| 130 |
+
with self._get_connection() as conn:
|
| 131 |
+
c = conn.cursor()
|
| 132 |
+
|
| 133 |
+
# Tabela de mensagens
|
| 134 |
+
c.executescript("""
|
| 135 |
+
CREATE TABLE IF NOT EXISTS mensagens (
|
| 136 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 137 |
+
usuario TEXT,
|
| 138 |
+
mensagem TEXT,
|
| 139 |
+
resposta TEXT,
|
| 140 |
+
numero TEXT,
|
| 141 |
+
is_reply BOOLEAN DEFAULT 0,
|
| 142 |
+
mensagem_original TEXT,
|
| 143 |
+
humor TEXT DEFAULT 'neutro',
|
| 144 |
+
modo_resposta TEXT DEFAULT 'normal',
|
| 145 |
+
nivel_transicao INTEGER DEFAULT 1,
|
| 146 |
+
usuario_privilegiado BOOLEAN DEFAULT 0,
|
| 147 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 148 |
+
);
|
| 149 |
+
""")
|
| 150 |
+
|
| 151 |
+
# Tabela de usuários privilegiados
|
| 152 |
+
c.executescript("""
|
| 153 |
+
CREATE TABLE IF NOT EXISTS usuarios_privilegiados (
|
| 154 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 155 |
+
numero TEXT UNIQUE,
|
| 156 |
+
nome TEXT,
|
| 157 |
+
apelido TEXT,
|
| 158 |
+
modo_fala TEXT,
|
| 159 |
+
codigo_verificacao TEXT,
|
| 160 |
+
ativo BOOLEAN DEFAULT 1,
|
| 161 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 162 |
+
);
|
| 163 |
+
""")
|
| 164 |
+
|
| 165 |
+
# Tabela de embeddings
|
| 166 |
+
c.executescript("""
|
| 167 |
+
CREATE TABLE IF NOT EXISTS embeddings (
|
| 168 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 169 |
+
numero_usuario TEXT,
|
| 170 |
+
source_type TEXT,
|
| 171 |
+
texto TEXT,
|
| 172 |
+
embedding BLOB
|
| 173 |
+
);
|
| 174 |
+
""")
|
| 175 |
+
|
| 176 |
+
# Tabela de aprendizados
|
| 177 |
+
c.executescript("""
|
| 178 |
+
CREATE TABLE IF NOT EXISTS aprendizados (
|
| 179 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 180 |
+
numero_usuario TEXT,
|
| 181 |
+
chave TEXT,
|
| 182 |
+
valor TEXT,
|
| 183 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 184 |
+
);
|
| 185 |
+
""")
|
| 186 |
+
|
| 187 |
+
# Tabela de gírias aprendidas
|
| 188 |
+
c.executescript("""
|
| 189 |
+
CREATE TABLE IF NOT EXISTS girias_aprendidas (
|
| 190 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 191 |
+
numero_usuario TEXT,
|
| 192 |
+
giria TEXT,
|
| 193 |
+
significado TEXT,
|
| 194 |
+
contexto TEXT,
|
| 195 |
+
frequencia INTEGER DEFAULT 1,
|
| 196 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 197 |
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 198 |
+
);
|
| 199 |
+
""")
|
| 200 |
+
|
| 201 |
+
# Tabela de tom do usuário
|
| 202 |
+
c.executescript("""
|
| 203 |
+
CREATE TABLE IF NOT EXISTS tom_usuario (
|
| 204 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 205 |
+
numero_usuario TEXT,
|
| 206 |
+
tom_detectado TEXT,
|
| 207 |
+
intensidade REAL DEFAULT 0.5,
|
| 208 |
+
contexto TEXT,
|
| 209 |
+
humor TEXT DEFAULT 'neutro',
|
| 210 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 211 |
+
);
|
| 212 |
+
""")
|
| 213 |
+
|
| 214 |
+
# Tabela de contexto
|
| 215 |
+
c.executescript("""
|
| 216 |
+
CREATE TABLE IF NOT EXISTS contexto (
|
| 217 |
+
user_key TEXT PRIMARY KEY,
|
| 218 |
+
historico TEXT,
|
| 219 |
+
emocao_atual TEXT,
|
| 220 |
+
humor_atual TEXT DEFAULT 'neutro',
|
| 221 |
+
modo_resposta TEXT DEFAULT 'normal',
|
| 222 |
+
nivel_transicao INTEGER DEFAULT 1,
|
| 223 |
+
usuario_privilegiado BOOLEAN DEFAULT 0,
|
| 224 |
+
termos TEXT,
|
| 225 |
+
girias TEXT,
|
| 226 |
+
tom TEXT,
|
| 227 |
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 228 |
+
);
|
| 229 |
+
""")
|
| 230 |
+
|
| 231 |
+
# Tabela de pronomes por tom
|
| 232 |
+
c.executescript("""
|
| 233 |
+
CREATE TABLE IF NOT EXISTS pronomes_por_tom (
|
| 234 |
+
tom TEXT PRIMARY KEY,
|
| 235 |
+
pronomes TEXT
|
| 236 |
+
);
|
| 237 |
+
""")
|
| 238 |
+
|
| 239 |
+
# Tabela de Persona do Usuário (Character.AI style LTM)
|
| 240 |
+
c.executescript("""
|
| 241 |
+
CREATE TABLE IF NOT EXISTS persona_usuario (
|
| 242 |
+
numero_usuario TEXT PRIMARY KEY,
|
| 243 |
+
personalidade TEXT,
|
| 244 |
+
vicios_linguagem TEXT,
|
| 245 |
+
gostos TEXT,
|
| 246 |
+
desgostos TEXT,
|
| 247 |
+
emocional TEXT,
|
| 248 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 249 |
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 250 |
+
);
|
| 251 |
+
""")
|
| 252 |
+
|
| 253 |
+
# Insere dados padrão de pronomes
|
| 254 |
+
c.execute("INSERT OR IGNORE INTO pronomes_por_tom (tom, pronomes) VALUES (?, ?)",
|
| 255 |
+
('neutro', 'tu/você'))
|
| 256 |
+
c.execute("INSERT OR IGNORE INTO pronomes_por_tom (tom, pronomes) VALUES (?, ?)",
|
| 257 |
+
('formal', 'o senhor/a senhora'))
|
| 258 |
+
c.execute("INSERT OR IGNORE INTO pronomes_por_tom (tom, pronomes) VALUES (?, ?)",
|
| 259 |
+
('informal', 'puto/kota'))
|
| 260 |
+
c.execute("INSERT OR IGNORE INTO pronomes_por_tom (tom, pronomes) VALUES (?, ?)",
|
| 261 |
+
('tecnico_formal', 'senhor'))
|
| 262 |
+
|
| 263 |
+
# Insere usuários privilegiados padrão
|
| 264 |
+
usuarios_default = [
|
| 265 |
+
('244937035662', 'Isaac Quarenta', 'Isaac', 'tecnico_formal'),
|
| 266 |
+
('244978787009', 'Isaac Quarenta 2', 'Isaac', 'tecnico_formal')
|
| 267 |
+
]
|
| 268 |
+
for numero, nome, apelido, modo in usuarios_default:
|
| 269 |
+
c.execute("""
|
| 270 |
+
INSERT OR IGNORE INTO usuarios_privilegiados
|
| 271 |
+
(numero, nome, apelido, modo_fala) VALUES (?, ?, ?, ?)
|
| 272 |
+
""", (numero, nome, apelido, modo))
|
| 273 |
+
|
| 274 |
+
conn.commit()
|
| 275 |
+
logger.info(f"Banco de dados inicializado: {self.db_path}")
|
| 276 |
+
|
| 277 |
+
except Exception as e:
|
| 278 |
+
logger.error(f"Erro ao criar tabelas: {e}")
|
| 279 |
+
raise
|
| 280 |
+
|
| 281 |
+
def _ensure_all_columns_and_indexes(self):
|
| 282 |
+
"""Garante que todas as colunas e índices existam."""
|
| 283 |
+
try:
|
| 284 |
+
with self._get_connection() as conn:
|
| 285 |
+
c = conn.cursor()
|
| 286 |
+
|
| 287 |
+
# Adiciona colunas faltantes na tabela mensagens
|
| 288 |
+
columns_to_add = {
|
| 289 |
+
'mensagens': [
|
| 290 |
+
('humor', 'TEXT DEFAULT "neutro"'),
|
| 291 |
+
('modo_resposta', 'TEXT DEFAULT "normal"'),
|
| 292 |
+
('nivel_transicao', 'INTEGER DEFAULT 1'),
|
| 293 |
+
('usuario_privilegiado', 'BOOLEAN DEFAULT 0')
|
| 294 |
+
],
|
| 295 |
+
'tom_usuario': [
|
| 296 |
+
('humor', 'TEXT DEFAULT "neutro"')
|
| 297 |
+
],
|
| 298 |
+
'contexto': [
|
| 299 |
+
('humor_atual', 'TEXT DEFAULT "neutro"'),
|
| 300 |
+
('modo_resposta', 'TEXT DEFAULT "normal"'),
|
| 301 |
+
('nivel_transicao', 'INTEGER DEFAULT 1'),
|
| 302 |
+
('usuario_privilegiado', 'BOOLEAN DEFAULT 0'),
|
| 303 |
+
('updated_at', 'DATETIME DEFAULT CURRENT_TIMESTAMP')
|
| 304 |
+
]
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
for table, cols in columns_to_add.items():
|
| 308 |
+
c.execute(f"PRAGMA table_info('{table}')")
|
| 309 |
+
existing = {row[1] for row in c.fetchall()}
|
| 310 |
+
for col_name, col_def in cols:
|
| 311 |
+
if col_name not in existing:
|
| 312 |
+
try:
|
| 313 |
+
c.execute(f"ALTER TABLE {table} ADD COLUMN {col_name} {col_def}")
|
| 314 |
+
logger.info(f"Coluna '{col_name}' adicionada em '{table}'")
|
| 315 |
+
except Exception as e:
|
| 316 |
+
logger.warning(f"Erro ao adicionar coluna {col_name}: {e}")
|
| 317 |
+
|
| 318 |
+
conn.commit()
|
| 319 |
+
|
| 320 |
+
except Exception as e:
|
| 321 |
+
logger.error(f"Erro na migração: {e}")
|
| 322 |
+
|
| 323 |
+
# ================================================================
|
| 324 |
+
# USUÁRIOS PRIVILEGIADOS
|
| 325 |
+
# ================================================================
|
| 326 |
+
def adicionar_usuario_privilegiado(
|
| 327 |
+
self,
|
| 328 |
+
numero: str,
|
| 329 |
+
nome: str,
|
| 330 |
+
apelido: str,
|
| 331 |
+
modo_fala: str = "tecnico_formal"
|
| 332 |
+
) -> Tuple[bool, str]:
|
| 333 |
+
"""
|
| 334 |
+
Adiciona um usuário privilegiado ao sistema.
|
| 335 |
+
|
| 336 |
+
Args:
|
| 337 |
+
numero: Número de telefone do usuário
|
| 338 |
+
nome: Nome completo
|
| 339 |
+
apelido: Apelido
|
| 340 |
+
modo_fala: Modo de fala inicial
|
| 341 |
+
|
| 342 |
+
Returns:
|
| 343 |
+
Tuple[bool, str]: (sucesso, código de verificação)
|
| 344 |
+
"""
|
| 345 |
+
try:
|
| 346 |
+
# Gera código de verificação
|
| 347 |
+
codigo = str(random.randint(100000, 999999))
|
| 348 |
+
|
| 349 |
+
self._execute_with_retry(
|
| 350 |
+
"""INSERT OR REPLACE INTO usuarios_privilegiados
|
| 351 |
+
(numero, nome, apelido, modo_fala, codigo_verificacao)
|
| 352 |
+
VALUES (?, ?, ?, ?, ?)""",
|
| 353 |
+
(numero, nome, apelido, modo_fala, codigo),
|
| 354 |
+
commit=True
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
logger.info(f"Usuário privilegiado adicionado: {numero} ({nome})")
|
| 358 |
+
return True, codigo
|
| 359 |
+
|
| 360 |
+
except Exception as e:
|
| 361 |
+
logger.error(f"Erro ao adicionar usuário privilegiado: {e}")
|
| 362 |
+
return False, str(e)
|
| 363 |
+
|
| 364 |
+
def eh_privilegiado(self, numero: str) -> bool:
|
| 365 |
+
"""
|
| 366 |
+
Verifica se um número é de usuário privilegiado.
|
| 367 |
+
|
| 368 |
+
Args:
|
| 369 |
+
numero: Número de telefone a verificar
|
| 370 |
+
|
| 371 |
+
Returns:
|
| 372 |
+
bool: True se for privilegiado
|
| 373 |
+
"""
|
| 374 |
+
try:
|
| 375 |
+
rows = self._execute_with_retry(
|
| 376 |
+
"SELECT ativo FROM usuarios_privilegiados WHERE numero = ? AND ativo = 1",
|
| 377 |
+
(numero,)
|
| 378 |
+
)
|
| 379 |
+
# Verificação segura para evitar "List[Row] | None cannot be assigned to len()"
|
| 380 |
+
return rows is not None and len(rows) > 0
|
| 381 |
+
except Exception as e:
|
| 382 |
+
logger.error(f"Erro ao verificar privilégios: {e}")
|
| 383 |
+
return False
|
| 384 |
+
|
| 385 |
+
def verificar_codigo(self, numero: str, codigo: str) -> bool:
|
| 386 |
+
"""
|
| 387 |
+
Verifica o código de um usuário privilegiado.
|
| 388 |
+
|
| 389 |
+
Args:
|
| 390 |
+
numero: Número de telefone
|
| 391 |
+
codigo: Código de verificação
|
| 392 |
+
|
| 393 |
+
Returns:
|
| 394 |
+
bool: True se o código for válido
|
| 395 |
+
"""
|
| 396 |
+
try:
|
| 397 |
+
rows = self._execute_with_retry(
|
| 398 |
+
"SELECT codigo_verificacao FROM usuarios_privilegiados WHERE numero = ?",
|
| 399 |
+
(numero,)
|
| 400 |
+
)
|
| 401 |
+
if rows and rows[0][0] == codigo:
|
| 402 |
+
# Gera novo código para próxima verificação
|
| 403 |
+
novo_codigo = str(random.randint(100000, 999999))
|
| 404 |
+
self._execute_with_retry(
|
| 405 |
+
"UPDATE usuarios_privilegiados SET codigo_verificacao = ? WHERE numero = ?",
|
| 406 |
+
(novo_codigo, numero),
|
| 407 |
+
commit=True
|
| 408 |
+
)
|
| 409 |
+
return True
|
| 410 |
+
return False
|
| 411 |
+
except Exception as e:
|
| 412 |
+
logger.error(f"Erro ao verificar código: {e}")
|
| 413 |
+
return False
|
| 414 |
+
|
| 415 |
+
def obter_modo_fala_privilegiado(self, numero: str) -> Optional[str]:
|
| 416 |
+
"""Obtém o modo de fala de um usuário privilegiado."""
|
| 417 |
+
try:
|
| 418 |
+
rows = self._execute_with_retry(
|
| 419 |
+
"SELECT modo_fala FROM usuarios_privilegiados WHERE numero = ?",
|
| 420 |
+
(numero,)
|
| 421 |
+
)
|
| 422 |
+
return rows[0][0] if rows else None
|
| 423 |
+
except Exception as e:
|
| 424 |
+
logger.error(f"Erro ao obter modo de fala: {e}")
|
| 425 |
+
return None
|
| 426 |
+
|
| 427 |
+
# ================================================================
|
| 428 |
+
# MENSAGENS
|
| 429 |
+
# ================================================================
|
| 430 |
+
def salvar_mensagem(
|
| 431 |
+
self,
|
| 432 |
+
usuario: str,
|
| 433 |
+
mensagem: str,
|
| 434 |
+
resposta: str,
|
| 435 |
+
numero: Optional[str] = None,
|
| 436 |
+
is_reply: bool = False,
|
| 437 |
+
mensagem_original: Optional[str] = None,
|
| 438 |
+
humor: str = "neutro",
|
| 439 |
+
modo_resposta: str = "normal",
|
| 440 |
+
nivel_transicao: int = 1,
|
| 441 |
+
usuario_privilegiado: bool = False
|
| 442 |
+
) -> bool:
|
| 443 |
+
"""
|
| 444 |
+
Salva uma mensagem no banco de dados.
|
| 445 |
+
|
| 446 |
+
Args:
|
| 447 |
+
usuario: Nome do usuário
|
| 448 |
+
mensagem: Mensagem enviada
|
| 449 |
+
resposta: Resposta gerada
|
| 450 |
+
numero: Número de telefone
|
| 451 |
+
is_reply: Se é uma resposta
|
| 452 |
+
mensagem_original: Mensagem original (para replies)
|
| 453 |
+
humor: Humor detected
|
| 454 |
+
modo_resposta: Modo de resposta
|
| 455 |
+
nivel_transicao: Nível de transição
|
| 456 |
+
usuario_privilegiado: Se é usuário privilegiado
|
| 457 |
+
|
| 458 |
+
Returns:
|
| 459 |
+
bool: Sucesso da operação
|
| 460 |
+
"""
|
| 461 |
+
try:
|
| 462 |
+
cols = ['usuario', 'mensagem', 'resposta']
|
| 463 |
+
vals: List[str] = [usuario, mensagem, resposta]
|
| 464 |
+
|
| 465 |
+
if numero:
|
| 466 |
+
cols.append('numero')
|
| 467 |
+
vals.append(numero)
|
| 468 |
+
if is_reply:
|
| 469 |
+
cols.append('is_reply')
|
| 470 |
+
vals.append("1") # Corrigido: string em vez de int
|
| 471 |
+
if mensagem_original:
|
| 472 |
+
cols.append('mensagem_original')
|
| 473 |
+
vals.append(mensagem_original)
|
| 474 |
+
|
| 475 |
+
cols.extend(['humor', 'modo_resposta', 'nivel_transicao', 'usuario_privilegiado'])
|
| 476 |
+
# Corrigido: todos os valores devem ser strings para evitar erros de tipo
|
| 477 |
+
vals.extend([humor, modo_resposta, str(nivel_transicao), "1" if usuario_privilegiado else "0"])
|
| 478 |
+
|
| 479 |
+
placeholders = ', '.join(['?' for _ in cols])
|
| 480 |
+
query = f"INSERT INTO mensagens ({', '.join(cols)}) VALUES ({placeholders})"
|
| 481 |
+
|
| 482 |
+
self._execute_with_retry(query, tuple(vals), commit=True)
|
| 483 |
+
return True
|
| 484 |
+
|
| 485 |
+
except Exception as e:
|
| 486 |
+
logger.warning(f"Erro salvar_mensagem: {e}")
|
| 487 |
+
return False
|
| 488 |
+
|
| 489 |
+
def recuperar_mensagens(
|
| 490 |
+
self,
|
| 491 |
+
usuario: str,
|
| 492 |
+
limite: int = 5
|
| 493 |
+
) -> List[Tuple[str, str]]:
|
| 494 |
+
"""Recupera mensagens de um usuário."""
|
| 495 |
+
try:
|
| 496 |
+
result = self._execute_with_retry(
|
| 497 |
+
"""SELECT mensagem, resposta FROM mensagens
|
| 498 |
+
WHERE usuario=? OR numero=?
|
| 499 |
+
ORDER BY id DESC LIMIT ?""",
|
| 500 |
+
(usuario, usuario, limite)
|
| 501 |
+
)
|
| 502 |
+
if not result:
|
| 503 |
+
return []
|
| 504 |
+
# Converte sqlite3.Row para tuplas
|
| 505 |
+
return [(row[0], row[1]) for row in result]
|
| 506 |
+
except Exception as e:
|
| 507 |
+
logger.error(f"Erro ao recuperar mensagens: {e}")
|
| 508 |
+
return []
|
| 509 |
+
|
| 510 |
+
def recuperar_humor(self, numero_usuario: str) -> str:
|
| 511 |
+
"""
|
| 512 |
+
Recupera o humor atual de um usuário.
|
| 513 |
+
|
| 514 |
+
Args:
|
| 515 |
+
numero_usuario: Número do usuário
|
| 516 |
+
|
| 517 |
+
Returns:
|
| 518 |
+
str: Humor detectado ('neutro', 'feliz', 'triste', 'irritado', 'entediado')
|
| 519 |
+
"""
|
| 520 |
+
try:
|
| 521 |
+
rows = self._execute_with_retry(
|
| 522 |
+
"""SELECT humor FROM tom_usuario
|
| 523 |
+
WHERE numero_usuario=?
|
| 524 |
+
ORDER BY created_at DESC LIMIT 1""",
|
| 525 |
+
(numero_usuario,)
|
| 526 |
+
)
|
| 527 |
+
return rows[0][0] if rows else "neutro"
|
| 528 |
+
except Exception as e:
|
| 529 |
+
logger.error(f"Erro ao recuperar humor: {e}")
|
| 530 |
+
return "neutro"
|
| 531 |
+
|
| 532 |
+
# ================================================================
|
| 533 |
+
# CONTEXTO
|
| 534 |
+
# ================================================================
|
| 535 |
+
def salvar_contexto(
|
| 536 |
+
self,
|
| 537 |
+
user_key: str,
|
| 538 |
+
historico: Optional[str] = None,
|
| 539 |
+
emocao_atual: str = "neutra",
|
| 540 |
+
humor_atual: str = "neutro",
|
| 541 |
+
modo_resposta: str = "normal",
|
| 542 |
+
nivel_transicao: int = 1,
|
| 543 |
+
usuario_privilegiado: bool = False,
|
| 544 |
+
termos: Optional[str] = None,
|
| 545 |
+
girias: Optional[str] = None,
|
| 546 |
+
tom: Optional[str] = None
|
| 547 |
+
) -> bool:
|
| 548 |
+
"""
|
| 549 |
+
Salva o contexto de um usuário.
|
| 550 |
+
|
| 551 |
+
Args:
|
| 552 |
+
user_key: Chave do usuário (número ou nome)
|
| 553 |
+
historico: Histórico de conversas
|
| 554 |
+
emocao_atual: Emoção atual
|
| 555 |
+
humor_atual: Humor atual
|
| 556 |
+
modo_resposta: Modo de resposta
|
| 557 |
+
nivel_transicao: Nível de transição
|
| 558 |
+
usuario_privilegiado: Se é usuário privilegiado
|
| 559 |
+
termos: Termos aprendidos
|
| 560 |
+
girias: Gírias aprendidas
|
| 561 |
+
tom: Tom de fala
|
| 562 |
+
|
| 563 |
+
Returns:
|
| 564 |
+
bool: Sucesso da operação
|
| 565 |
+
"""
|
| 566 |
+
try:
|
| 567 |
+
self._execute_with_retry(
|
| 568 |
+
"""INSERT OR REPLACE INTO contexto
|
| 569 |
+
(user_key, historico, emocao_atual, humor_atual, modo_resposta,
|
| 570 |
+
nivel_transicao, usuario_privilegiado, termos, girias, tom, updated_at)
|
| 571 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)""",
|
| 572 |
+
(user_key, historico or "[]", emocao_atual, humor_atual, modo_resposta,
|
| 573 |
+
nivel_transicao, 1 if usuario_privilegiado else 0,
|
| 574 |
+
termos or "{}", girias or "{}", tom),
|
| 575 |
+
commit=True
|
| 576 |
+
)
|
| 577 |
+
return True
|
| 578 |
+
except Exception as e:
|
| 579 |
+
logger.error(f"Erro ao salvar contexto: {e}")
|
| 580 |
+
return False
|
| 581 |
+
|
| 582 |
+
def recuperar_contexto(self, user_key: str) -> Optional[Dict[str, Any]]:
|
| 583 |
+
"""Recupera o contexto de um usuário."""
|
| 584 |
+
try:
|
| 585 |
+
rows = self._execute_with_retry(
|
| 586 |
+
"SELECT * FROM contexto WHERE user_key = ?",
|
| 587 |
+
(user_key,)
|
| 588 |
+
)
|
| 589 |
+
if rows:
|
| 590 |
+
row = rows[0]
|
| 591 |
+
return dict(row)
|
| 592 |
+
return None
|
| 593 |
+
except Exception as e:
|
| 594 |
+
logger.error(f"Erro ao recuperar contexto: {e}")
|
| 595 |
+
return None
|
| 596 |
+
|
| 597 |
+
# ================================================================
|
| 598 |
+
# TOM E HUMOR
|
| 599 |
+
# ================================================================
|
| 600 |
+
def registrar_tom_usuario(
|
| 601 |
+
self,
|
| 602 |
+
numero_usuario: str,
|
| 603 |
+
tom_detectado: str,
|
| 604 |
+
intensidade: float = 0.5,
|
| 605 |
+
contexto: Optional[str] = None,
|
| 606 |
+
humor: str = "neutro"
|
| 607 |
+
) -> bool:
|
| 608 |
+
"""
|
| 609 |
+
Registra o tom detectado de um usuário.
|
| 610 |
+
|
| 611 |
+
Args:
|
| 612 |
+
numero_usuario: Número do usuário
|
| 613 |
+
tom_detectado: Tom detectado
|
| 614 |
+
intensidade: Intensidade do tom
|
| 615 |
+
contexto: Contexto da detecção
|
| 616 |
+
humor: Humor detectado
|
| 617 |
+
|
| 618 |
+
Returns:
|
| 619 |
+
bool: Sucesso da operação
|
| 620 |
+
"""
|
| 621 |
+
try:
|
| 622 |
+
self._execute_with_retry(
|
| 623 |
+
"""INSERT INTO tom_usuario
|
| 624 |
+
(numero_usuario, tom_detectado, intensidade, contexto, humor)
|
| 625 |
+
VALUES (?, ?, ?, ?, ?)""",
|
| 626 |
+
(numero_usuario, tom_detectado, intensidade, contexto, humor),
|
| 627 |
+
commit=True
|
| 628 |
+
)
|
| 629 |
+
return True
|
| 630 |
+
except Exception as e:
|
| 631 |
+
logger.error(f"Erro ao registrar tom: {e}")
|
| 632 |
+
return False
|
| 633 |
+
|
| 634 |
+
def obter_tom_predominante(self, numero_usuario: str) -> Optional[str]:
|
| 635 |
+
"""Obtém o tom predominante de um usuário."""
|
| 636 |
+
try:
|
| 637 |
+
rows = self._execute_with_retry(
|
| 638 |
+
"""SELECT tom_detectado FROM tom_usuario
|
| 639 |
+
WHERE numero_usuario=?
|
| 640 |
+
ORDER BY created_at DESC LIMIT 1""",
|
| 641 |
+
(numero_usuario,)
|
| 642 |
+
)
|
| 643 |
+
return rows[0][0] if rows else None
|
| 644 |
+
except Exception as e:
|
| 645 |
+
logger.error(f"Erro ao obter tom predominante: {e}")
|
| 646 |
+
return None
|
| 647 |
+
|
| 648 |
+
# ================================================================
|
| 649 |
+
# APRENDIZADOS E GÍRIAS
|
| 650 |
+
# ================================================================
|
| 651 |
+
def salvar_aprendizado_detalhado(
|
| 652 |
+
self,
|
| 653 |
+
numero_usuario: str,
|
| 654 |
+
chave: str,
|
| 655 |
+
valor: str
|
| 656 |
+
) -> bool:
|
| 657 |
+
"""Salva um aprendizado detalhado."""
|
| 658 |
+
try:
|
| 659 |
+
self._execute_with_retry(
|
| 660 |
+
"INSERT INTO aprendizados (numero_usuario, chave, valor) VALUES (?, ?, ?)",
|
| 661 |
+
(numero_usuario, chave, valor),
|
| 662 |
+
commit=True
|
| 663 |
+
)
|
| 664 |
+
return True
|
| 665 |
+
except Exception as e:
|
| 666 |
+
logger.error(f"Erro ao salvar aprendizado: {e}")
|
| 667 |
+
return False
|
| 668 |
+
|
| 669 |
+
def recuperar_aprendizado_detalhado(
|
| 670 |
+
self,
|
| 671 |
+
numero_usuario: str,
|
| 672 |
+
chave: Optional[str] = None
|
| 673 |
+
) -> Union[Dict, str, None]:
|
| 674 |
+
"""Recupera aprendizados detalhados."""
|
| 675 |
+
try:
|
| 676 |
+
if chave:
|
| 677 |
+
rows = self._execute_with_retry(
|
| 678 |
+
"SELECT valor FROM aprendizados WHERE numero_usuario=? AND chave=?",
|
| 679 |
+
(numero_usuario, chave)
|
| 680 |
+
)
|
| 681 |
+
return rows[0][0] if rows else None
|
| 682 |
+
else:
|
| 683 |
+
rows = self._execute_with_retry(
|
| 684 |
+
"SELECT chave, valor FROM aprendizados WHERE numero_usuario=?",
|
| 685 |
+
(numero_usuario,)
|
| 686 |
+
)
|
| 687 |
+
return {r[0]: r[1] for r in rows} if rows else {}
|
| 688 |
+
except Exception as e:
|
| 689 |
+
logger.error(f"Erro ao recuperar aprendizado: {e}")
|
| 690 |
+
return None
|
| 691 |
+
|
| 692 |
+
def salvar_giria_aprendida(
|
| 693 |
+
self,
|
| 694 |
+
numero_usuario: str,
|
| 695 |
+
giria: str,
|
| 696 |
+
significado: str,
|
| 697 |
+
contexto: Optional[str] = None
|
| 698 |
+
) -> bool:
|
| 699 |
+
"""Salva uma gíria aprendida."""
|
| 700 |
+
try:
|
| 701 |
+
existing = self._execute_with_retry(
|
| 702 |
+
"SELECT id, frequencia FROM girias_aprendidas WHERE numero_usuario=? AND giria=?",
|
| 703 |
+
(numero_usuario, giria)
|
| 704 |
+
)
|
| 705 |
+
|
| 706 |
+
if existing:
|
| 707 |
+
self._execute_with_retry(
|
| 708 |
+
"""UPDATE girias_aprendidas SET frequencia=frequencia+1,
|
| 709 |
+
updated_at=CURRENT_TIMESTAMP WHERE id=?""",
|
| 710 |
+
(existing[0][0],),
|
| 711 |
+
commit=True
|
| 712 |
+
)
|
| 713 |
+
else:
|
| 714 |
+
self._execute_with_retry(
|
| 715 |
+
"""INSERT INTO girias_aprendidas
|
| 716 |
+
(numero_usuario, giria, significado, contexto) VALUES (?, ?, ?, ?)""",
|
| 717 |
+
(numero_usuario, giria, significado, contexto),
|
| 718 |
+
commit=True
|
| 719 |
+
)
|
| 720 |
+
return True
|
| 721 |
+
|
| 722 |
+
except Exception as e:
|
| 723 |
+
logger.error(f"Erro ao salvar gíria: {e}")
|
| 724 |
+
return False
|
| 725 |
+
|
| 726 |
+
def recuperar_girias_usuario(self, numero_usuario: str) -> List[Dict[str, Any]]:
|
| 727 |
+
"""Recupera gírias de um usuário."""
|
| 728 |
+
try:
|
| 729 |
+
rows = self._execute_with_retry(
|
| 730 |
+
"SELECT giria, significado, frequencia FROM girias_aprendidas WHERE numero_usuario=?",
|
| 731 |
+
(numero_usuario,)
|
| 732 |
+
)
|
| 733 |
+
return [{"giria": r[0], "significado": r[1], "frequencia": r[2]} for r in rows] if rows else []
|
| 734 |
+
except Exception as e:
|
| 735 |
+
logger.error(f"Erro ao recuperar gírias: {e}")
|
| 736 |
+
return []
|
| 737 |
+
|
| 738 |
+
# ================================================================
|
| 739 |
+
# EMBEDDINGS
|
| 740 |
+
# ================================================================
|
| 741 |
+
def salvar_embedding(
|
| 742 |
+
self,
|
| 743 |
+
numero_usuario: str,
|
| 744 |
+
source_type: str,
|
| 745 |
+
texto: str,
|
| 746 |
+
embedding: Any
|
| 747 |
+
) -> bool:
|
| 748 |
+
"""Salva um embedding no banco."""
|
| 749 |
+
try:
|
| 750 |
+
if hasattr(embedding, "tobytes"):
|
| 751 |
+
embedding = embedding.tobytes()
|
| 752 |
+
|
| 753 |
+
self._execute_with_retry(
|
| 754 |
+
"""INSERT INTO embeddings
|
| 755 |
+
(numero_usuario, source_type, texto, embedding) VALUES (?, ?, ?, ?)""",
|
| 756 |
+
(numero_usuario, source_type, texto, embedding),
|
| 757 |
+
commit=True
|
| 758 |
+
)
|
| 759 |
+
return True
|
| 760 |
+
except Exception as e:
|
| 761 |
+
logger.error(f"Erro ao salvar embedding: {e}")
|
| 762 |
+
return False
|
| 763 |
+
|
| 764 |
+
def recuperar_embeddings(self, numero_usuario: str) -> List[Dict[str, Any]]:
|
| 765 |
+
"""Recupera embeddings de um usuário."""
|
| 766 |
+
try:
|
| 767 |
+
rows = self._execute_with_retry(
|
| 768 |
+
"SELECT source_type, texto, embedding FROM embeddings WHERE numero_usuario=?",
|
| 769 |
+
(numero_usuario,)
|
| 770 |
+
)
|
| 771 |
+
result = []
|
| 772 |
+
# Verificação segura para evitar "Object of type None cannot be used as iterable"
|
| 773 |
+
if rows:
|
| 774 |
+
for r in rows:
|
| 775 |
+
embedding_data = r[2]
|
| 776 |
+
if isinstance(embedding_data, bytes):
|
| 777 |
+
# Mantém como bytes para uso com numpy
|
| 778 |
+
pass
|
| 779 |
+
result.append({
|
| 780 |
+
"source_type": r[0],
|
| 781 |
+
"texto": r[1],
|
| 782 |
+
"embedding": embedding_data
|
| 783 |
+
})
|
| 784 |
+
return result
|
| 785 |
+
except Exception as e:
|
| 786 |
+
logger.error(f"Erro ao recuperar embeddings: {e}")
|
| 787 |
+
return []
|
| 788 |
+
|
| 789 |
+
# ================================================================
|
| 790 |
+
# PERSONA DO USUÁRIO (LTM)
|
| 791 |
+
# ================================================================
|
| 792 |
+
def atualizar_persona(self, numero_usuario: str, campos: Dict[str, str]) -> bool:
|
| 793 |
+
"""
|
| 794 |
+
Atualiza campos específicos da persona do usuário.
|
| 795 |
+
|
| 796 |
+
Args:
|
| 797 |
+
numero_usuario: Número do usuário
|
| 798 |
+
campos: Dicionário com chaves ('personalidade', 'vicios_linguagem', 'gostos', 'desgostos', 'emocional')
|
| 799 |
+
"""
|
| 800 |
+
try:
|
| 801 |
+
# Verifica se já existe
|
| 802 |
+
existente = self.recuperar_persona(numero_usuario)
|
| 803 |
+
|
| 804 |
+
if existente:
|
| 805 |
+
# Update
|
| 806 |
+
set_clauses = []
|
| 807 |
+
values = []
|
| 808 |
+
for k, v in campos.items():
|
| 809 |
+
if k in ['personalidade', 'vicios_linguagem', 'gostos', 'desgostos', 'emocional']:
|
| 810 |
+
set_clauses.append(f"{k} = ?")
|
| 811 |
+
values.append(v)
|
| 812 |
+
|
| 813 |
+
if not set_clauses:
|
| 814 |
+
return False
|
| 815 |
+
|
| 816 |
+
set_clauses.append("updated_at = CURRENT_TIMESTAMP")
|
| 817 |
+
values.append(numero_usuario)
|
| 818 |
+
|
| 819 |
+
query = f"UPDATE persona_usuario SET {', '.join(set_clauses)} WHERE numero_usuario = ?"
|
| 820 |
+
self._execute_with_retry(query, tuple(values), commit=True)
|
| 821 |
+
else:
|
| 822 |
+
# Insert
|
| 823 |
+
keys = ['numero_usuario']
|
| 824 |
+
values = [numero_usuario]
|
| 825 |
+
for k, v in campos.items():
|
| 826 |
+
if k in ['personalidade', 'vicios_linguagem', 'gostos', 'desgostos', 'emocional']:
|
| 827 |
+
keys.append(k)
|
| 828 |
+
values.append(v)
|
| 829 |
+
|
| 830 |
+
placeholders = ', '.join(['?' for _ in keys])
|
| 831 |
+
query = f"INSERT INTO persona_usuario ({', '.join(keys)}) VALUES ({placeholders})"
|
| 832 |
+
self._execute_with_retry(query, tuple(values), commit=True)
|
| 833 |
+
|
| 834 |
+
return True
|
| 835 |
+
except Exception as e:
|
| 836 |
+
logger.error(f"Erro ao atualizar persona para {numero_usuario}: {e}")
|
| 837 |
+
return False
|
| 838 |
+
|
| 839 |
+
def recuperar_persona(self, numero_usuario: str) -> Optional[Dict[str, Any]]:
|
| 840 |
+
"""Recupera a persona completa de um usuário."""
|
| 841 |
+
try:
|
| 842 |
+
rows = self._execute_with_retry(
|
| 843 |
+
"SELECT * FROM persona_usuario WHERE numero_usuario = ?",
|
| 844 |
+
(numero_usuario,)
|
| 845 |
+
)
|
| 846 |
+
if rows:
|
| 847 |
+
row = rows[0]
|
| 848 |
+
return dict(row)
|
| 849 |
+
return None
|
| 850 |
+
except Exception as e:
|
| 851 |
+
logger.error(f"Erro ao recuperar persona para {numero_usuario}: {e}")
|
| 852 |
+
return None
|
| 853 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
modules/doc_analyzer.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import io
|
| 3 |
+
import json
|
| 4 |
+
from typing import Dict, Any, Optional
|
| 5 |
+
from loguru import logger
|
| 6 |
+
|
| 7 |
+
try:
|
| 8 |
+
import google.generativeai as genai
|
| 9 |
+
except ImportError:
|
| 10 |
+
genai = None
|
| 11 |
+
|
| 12 |
+
class DocumentAnalyzer:
|
| 13 |
+
"""
|
| 14 |
+
Módulo para análise inteligente de documentos via Gemini.
|
| 15 |
+
Suporta extração de texto, resumo e resposta a perguntas sobre arquivos.
|
| 16 |
+
"""
|
| 17 |
+
def __init__(self, api_key: str = ""):
|
| 18 |
+
self.api_key = api_key or os.getenv("GEMINI_API_KEY", "")
|
| 19 |
+
if genai and self.api_key:
|
| 20 |
+
genai.configure(api_key=self.api_key)
|
| 21 |
+
self.model = genai.GenerativeModel('gemini-1.5-flash')
|
| 22 |
+
else:
|
| 23 |
+
self.model = None
|
| 24 |
+
|
| 25 |
+
def analyze_file(self, file_path: str, query: str = "Resuma este documento") -> Dict[str, Any]:
|
| 26 |
+
"""Lê um arquivo local e envia para o Gemini analisar."""
|
| 27 |
+
if not os.path.exists(file_path):
|
| 28 |
+
return {"success": False, "error": "Arquivo não encontrado"}
|
| 29 |
+
|
| 30 |
+
if not self.model:
|
| 31 |
+
return {"success": False, "error": "Gemini não configurado para documentos"}
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
mime_type = self._get_mime_type(file_path)
|
| 35 |
+
|
| 36 |
+
# Para arquivos de texto simples, lemos diretamente
|
| 37 |
+
if mime_type == "text/plain":
|
| 38 |
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
| 39 |
+
content = f.read()
|
| 40 |
+
prompt = f"DOCUMENTO:\n{content}\n\nPERGUNTA/ACAO: {query}"
|
| 41 |
+
response = self.model.generate_content(prompt)
|
| 42 |
+
else:
|
| 43 |
+
# Para PDF e outros, usamos o sistema de arquivos do GenAI (se disponível) ou bytes
|
| 44 |
+
# Nota: Em ambientes restritos, pode ser necessário ler bytes
|
| 45 |
+
with open(file_path, "rb") as f:
|
| 46 |
+
doc_data = f.read()
|
| 47 |
+
|
| 48 |
+
response = self.model.generate_content([
|
| 49 |
+
{"mime_type": mime_type, "data": doc_data},
|
| 50 |
+
query
|
| 51 |
+
])
|
| 52 |
+
|
| 53 |
+
return {
|
| 54 |
+
"success": True,
|
| 55 |
+
"analysis": response.text,
|
| 56 |
+
"file_name": os.path.basename(file_path)
|
| 57 |
+
}
|
| 58 |
+
except Exception as e:
|
| 59 |
+
logger.exception(f"Erro ao analisar documento {file_path}: {e}")
|
| 60 |
+
return {"success": False, "error": str(e)}
|
| 61 |
+
|
| 62 |
+
def _get_mime_type(self, file_path: str) -> str:
|
| 63 |
+
ext = os.path.splitext(file_path)[1].lower()
|
| 64 |
+
mapping = {
|
| 65 |
+
".pdf": "application/pdf",
|
| 66 |
+
".txt": "text/plain",
|
| 67 |
+
".py": "text/plain",
|
| 68 |
+
".js": "text/plain",
|
| 69 |
+
".md": "text/plain",
|
| 70 |
+
".json": "application/json"
|
| 71 |
+
}
|
| 72 |
+
return mapping.get(ext, "application/octet-stream")
|
| 73 |
+
|
| 74 |
+
_analyzer = None
|
| 75 |
+
|
| 76 |
+
def get_document_analyzer(api_key: str = "") -> DocumentAnalyzer:
|
| 77 |
+
global _analyzer
|
| 78 |
+
if not _analyzer:
|
| 79 |
+
_analyzer = DocumentAnalyzer(api_key)
|
| 80 |
+
return _analyzer
|
modules/improved_context_handler.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# type: ignore
|
| 2 |
+
"""
|
| 3 |
+
================================================================================
|
| 4 |
+
IMPROVED CONTEXT HANDLER - Melhor gerenciamento de contexto para Akira
|
| 5 |
+
================================================================================
|
| 6 |
+
IMPORTANTE: Este módulo NÃO modifica context_builder.py ou contexto.py!
|
| 7 |
+
Ele adiciona uma camada INTELIGENTE de análise de contexto para perguntas curtas.
|
| 8 |
+
|
| 9 |
+
Função: Resolver o problema de perguntas curtas ("Oq é isso?") perdendo contexto
|
| 10 |
+
Preserva: Toda a arquitetura e lógica existente do sistema de contexto
|
| 11 |
+
================================================================================
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import re
|
| 15 |
+
from typing import Dict, List, Optional, Tuple, Any
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
from . import config
|
| 20 |
+
except ImportError:
|
| 21 |
+
import modules.config as config
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class ContextWeights:
|
| 26 |
+
"""Pesos calculados para diferentes tipos de contexto."""
|
| 27 |
+
reply_context: float = 0.0
|
| 28 |
+
quoted_analysis: float = 0.0
|
| 29 |
+
short_term_memory: float = 1.0
|
| 30 |
+
vector_memory: float = 0.7
|
| 31 |
+
|
| 32 |
+
def to_dict(self) -> Dict[str, float]:
|
| 33 |
+
"""Converte para dicionário."""
|
| 34 |
+
return {
|
| 35 |
+
"reply_context": self.reply_context,
|
| 36 |
+
"quoted_analysis": self.quoted_analysis,
|
| 37 |
+
"short_term_memory": self.short_term_memory,
|
| 38 |
+
"vector_memory": self.vector_memory,
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass
|
| 43 |
+
class QuestionAnalysis:
|
| 44 |
+
"""Análise de uma pergunta."""
|
| 45 |
+
is_short:bool = False # <= 5 palavras
|
| 46 |
+
is_very_short: bool = False # <= 2 palavras
|
| 47 |
+
has_pronoun: bool = False # tem "isso", "aquilo", "ele", etc
|
| 48 |
+
has_reply: bool = False
|
| 49 |
+
needs_context: bool = False # precisa de contexto extra
|
| 50 |
+
question_type: str = "general" # "what", "how", "where", "why", "general"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class ImprovedContextHandler:
|
| 54 |
+
"""
|
| 55 |
+
Gerenciador inteligente de contexto para perguntas curtas.
|
| 56 |
+
|
| 57 |
+
IMPORTANTE:
|
| 58 |
+
- NÃO substitui o context_builder.py existente
|
| 59 |
+
- Funciona como HELPER para calcular pesos de contexto
|
| 60 |
+
- AUMENTA contexto para perguntas curtas com reply (contrário da lógica antiga)
|
| 61 |
+
"""
|
| 62 |
+
|
| 63 |
+
def __init__(self):
|
| 64 |
+
# Pronomes que indicam necessidade de contexto
|
| 65 |
+
self.context_pronouns = {
|
| 66 |
+
"isso", "aquilo", "este", "esse", "aquele",
|
| 67 |
+
"ele", "ela", "eles", "elas",
|
| 68 |
+
"la", "lo", "las", "los", # "a la", "o lo"
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
# Palavras interrogativas
|
| 72 |
+
self.question_words = {
|
| 73 |
+
"what": ["oq", "o que", "oque", "que é"],
|
| 74 |
+
"how": ["como"],
|
| 75 |
+
"where": ["onde", "aonde"],
|
| 76 |
+
"when": ["quando", "que horas"],
|
| 77 |
+
"why": ["porque", "porquê", "por que", "pq"],
|
| 78 |
+
"who": ["quem"],
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
# Limites de palavras
|
| 82 |
+
self.very_short_threshold = 2 # "Oq é?"
|
| 83 |
+
self.short_threshold = 5 # "Como funciona isso?"
|
| 84 |
+
|
| 85 |
+
def analyze_question(
|
| 86 |
+
self,
|
| 87 |
+
message: str,
|
| 88 |
+
reply_metadata: Optional[Dict[str, Any]] = None
|
| 89 |
+
) -> QuestionAnalysis:
|
| 90 |
+
"""
|
| 91 |
+
Analisa uma mensagem para determinar necessidade de contexto.
|
| 92 |
+
|
| 93 |
+
Args:
|
| 94 |
+
message: Mensagem do usuário
|
| 95 |
+
reply_metadata: Metadados de reply (se for reply)
|
| 96 |
+
|
| 97 |
+
Returns:
|
| 98 |
+
QuestionAnalysis com detalhes da análise
|
| 99 |
+
"""
|
| 100 |
+
message_lower = message.lower().strip()
|
| 101 |
+
words = message_lower.split()
|
| 102 |
+
word_count = len(words)
|
| 103 |
+
|
| 104 |
+
analysis = QuestionAnalysis()
|
| 105 |
+
|
| 106 |
+
# Classifica tamanho
|
| 107 |
+
analysis.is_very_short = word_count <= self.very_short_threshold
|
| 108 |
+
analysis.is_short = word_count <= self.short_threshold
|
| 109 |
+
|
| 110 |
+
# Detecta pronomes contextuais
|
| 111 |
+
analysis.has_pronoun = any(
|
| 112 |
+
pronoun in message_lower
|
| 113 |
+
for pronoun in self.context_pronouns
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
# Verifica se tem reply
|
| 117 |
+
if reply_metadata:
|
| 118 |
+
analysis.has_reply = reply_metadata.get("is_reply", False)
|
| 119 |
+
|
| 120 |
+
# Detecta tipo de pergunta
|
| 121 |
+
for q_type, patterns in self.question_words.items():
|
| 122 |
+
if any(pattern in message_lower for pattern in patterns):
|
| 123 |
+
analysis.question_type = q_type
|
| 124 |
+
break
|
| 125 |
+
|
| 126 |
+
# Determina se precisa de contexto extra
|
| 127 |
+
analysis.needs_context = (
|
| 128 |
+
analysis.is_short and
|
| 129 |
+
(analysis.has_pronoun or analysis.has_reply)
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
return analysis
|
| 133 |
+
|
| 134 |
+
def calculate_context_weights(
|
| 135 |
+
self,
|
| 136 |
+
message: str,
|
| 137 |
+
reply_metadata: Optional[Dict[str, Any]] = None
|
| 138 |
+
) -> ContextWeights:
|
| 139 |
+
"""
|
| 140 |
+
Calcula pesos de contexto de forma inteligente.
|
| 141 |
+
|
| 142 |
+
LÓGICA INVERTIDA da original:
|
| 143 |
+
- Perguntas curtas COM reply = MAIS contexto de reply
|
| 144 |
+
- Perguntas normais = balanço
|
| 145 |
+
- Sem reply = contexto geral
|
| 146 |
+
|
| 147 |
+
Args:
|
| 148 |
+
message: Mensagem do usuário
|
| 149 |
+
reply_metadata: Metadados de reply
|
| 150 |
+
|
| 151 |
+
Returns:
|
| 152 |
+
ContextWeights com pesos calculados
|
| 153 |
+
"""
|
| 154 |
+
analysis = self.analyze_question(message, reply_metadata)
|
| 155 |
+
weights = ContextWeights()
|
| 156 |
+
|
| 157 |
+
# CASO 1: Pergunta MUITO curta COM reply
|
| 158 |
+
# Exemplo: "Oq é isso?" (reply a mensagem sobre Radiohead)
|
| 159 |
+
if analysis.is_very_short and analysis.has_reply:
|
| 160 |
+
weights.reply_context = 1.0 # ✅ MÁXIMO para reply
|
| 161 |
+
weights.quoted_analysis = 0.95 # Analisa profundamente a citação
|
| 162 |
+
weights.short_term_memory = 0.8 # ✅ MANTÉM texto curto + contexto
|
| 163 |
+
weights.vector_memory = 0.3 # Fatos gerais baixo
|
| 164 |
+
|
| 165 |
+
# CASO 2: Pergunta curta COM reply
|
| 166 |
+
# Exemplo: "Como funciona isso?" (reply a explicação técnica)
|
| 167 |
+
elif analysis.is_short and analysis.has_reply:
|
| 168 |
+
weights.reply_context = 0.9 # Alto para reply
|
| 169 |
+
weights.quoted_analysis = 0.85
|
| 170 |
+
weights.short_term_memory = 0.85 # ✅ MANTÉM texto curto no contexto
|
| 171 |
+
weights.vector_memory = 0.4
|
| 172 |
+
|
| 173 |
+
# CASO 3: Pergunta curta COM pronome mas SEM reply
|
| 174 |
+
# Exemplo: "Oq é isso?" (sem reply - contexto ambíguo)
|
| 175 |
+
elif analysis.is_short and analysis.has_pronoun:
|
| 176 |
+
weights.reply_context = 0.0 # Sem reply
|
| 177 |
+
weights.quoted_analysis = 0.0
|
| 178 |
+
weights.short_term_memory = 1.0 # Usa histórico recente completo
|
| 179 |
+
weights.vector_memory = 0.8 # Busca memória de fatos
|
| 180 |
+
|
| 181 |
+
# CASO 4: Pergunta normal COM reply
|
| 182 |
+
# Exemplo: "Você pode explicar melhor esse conceito?" (reply a explicação)
|
| 183 |
+
elif analysis.has_reply:
|
| 184 |
+
weights.reply_context = 0.8
|
| 185 |
+
weights.quoted_analysis = 0.7
|
| 186 |
+
weights.short_term_memory = 0.8
|
| 187 |
+
weights.vector_memory = 0.5
|
| 188 |
+
|
| 189 |
+
# CASO 5: Pergunta normal SEM reply
|
| 190 |
+
# Exemplo: "Como funciona inteligência artificial?"
|
| 191 |
+
else:
|
| 192 |
+
weights.reply_context = 0.0
|
| 193 |
+
weights.quoted_analysis = 0.0
|
| 194 |
+
weights.short_term_memory = 1.0
|
| 195 |
+
weights.vector_memory = 0.7
|
| 196 |
+
|
| 197 |
+
return weights
|
| 198 |
+
|
| 199 |
+
def extract_quoted_content_deep(
|
| 200 |
+
self,
|
| 201 |
+
reply_metadata: Dict[str, Any]
|
| 202 |
+
) -> str:
|
| 203 |
+
"""
|
| 204 |
+
Extrai conteúdo citado de forma profunda.
|
| 205 |
+
Prioriza campos mais completos.
|
| 206 |
+
|
| 207 |
+
Args:
|
| 208 |
+
reply_metadata: Metadados do reply
|
| 209 |
+
|
| 210 |
+
Returns:
|
| 211 |
+
Conteúdo completo citado
|
| 212 |
+
"""
|
| 213 |
+
# Ordem de prioridade (do mais completo para o menos)
|
| 214 |
+
priority_fields = [
|
| 215 |
+
"mensagem_citada",
|
| 216 |
+
"full_message",
|
| 217 |
+
"quoted_text_original",
|
| 218 |
+
"quoted_text",
|
| 219 |
+
"reply_content",
|
| 220 |
+
"context_hint",
|
| 221 |
+
]
|
| 222 |
+
|
| 223 |
+
for field in priority_fields:
|
| 224 |
+
if field in reply_metadata and reply_metadata[field]:
|
| 225 |
+
content = str(reply_metadata[field]).strip()
|
| 226 |
+
if len(content) > 5: # Ignora conteúdos muito curtos
|
| 227 |
+
return content
|
| 228 |
+
|
| 229 |
+
# Fallback: tenta extrair de qualquer campo que pareça mensagem
|
| 230 |
+
for key, value in reply_metadata.items():
|
| 231 |
+
if isinstance(value, str) and len(value) > 10:
|
| 232 |
+
# Verifica se tem palavras comuns de mensagem
|
| 233 |
+
if any(word in value.lower() for word in ["eu", "você", "tu", "ele"]):
|
| 234 |
+
return value.strip()
|
| 235 |
+
|
| 236 |
+
return ""
|
| 237 |
+
|
| 238 |
+
def analyze_quoted_content(
|
| 239 |
+
self,
|
| 240 |
+
quoted_content: str,
|
| 241 |
+
current_message: str
|
| 242 |
+
) -> Dict[str, Any]:
|
| 243 |
+
"""
|
| 244 |
+
Analisa conteúdo citado para entender o contexto.
|
| 245 |
+
|
| 246 |
+
Args:
|
| 247 |
+
quoted_content: Conteúdo da mensagem citada
|
| 248 |
+
current_message: Mensagem atual do usuário
|
| 249 |
+
|
| 250 |
+
Returns:
|
| 251 |
+
Análise do conteúdo citado
|
| 252 |
+
"""
|
| 253 |
+
if not quoted_content:
|
| 254 |
+
return {"empty": True}
|
| 255 |
+
|
| 256 |
+
quoted_lower = quoted_content.lower()
|
| 257 |
+
current_lower = current_message.lower()
|
| 258 |
+
|
| 259 |
+
# Detecta tipo de conteúdo
|
| 260 |
+
content_type = "general"
|
| 261 |
+
if any(w in quoted_lower for w in ["?", "qual", "quando", "onde", "como", "por que"]):
|
| 262 |
+
content_type = "question"
|
| 263 |
+
elif any(w in quoted_lower for w in ["eu", "mim", "meu", "minha"]):
|
| 264 |
+
content_type = "personal"
|
| 265 |
+
elif any(w in quoted_lower for w in ["akira", "bot", "você", "vc"]):
|
| 266 |
+
content_type = "about_bot"
|
| 267 |
+
|
| 268 |
+
# Extrai keywords principais
|
| 269 |
+
keywords = self._extract_keywords(quoted_content)
|
| 270 |
+
|
| 271 |
+
# Detecta tom
|
| 272 |
+
tone = "neutral"
|
| 273 |
+
if any(w in quoted_lower for w in ["kkk", "haha", "😂", "🤣"]):
|
| 274 |
+
tone = "humorous"
|
| 275 |
+
elif any(w in quoted_lower for w in ["!!!", "???", "nossa", "eita"]):
|
| 276 |
+
tone = "excited"
|
| 277 |
+
|
| 278 |
+
# Detecta se há informação técnica/específica
|
| 279 |
+
has_specific_info = any(
|
| 280 |
+
word in quoted_lower
|
| 281 |
+
for word in ["Estudo", "Academica", "Programação", "Ciência", "política", "País"]
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
return {
|
| 285 |
+
"content_type": content_type,
|
| 286 |
+
"keywords": keywords,
|
| 287 |
+
"tone": tone,
|
| 288 |
+
"length": len(quoted_content),
|
| 289 |
+
"has_question": "?" in quoted_content,
|
| 290 |
+
"has_specific_info": has_specific_info,
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
def _extract_keywords(self, text: str, max_keywords: int = 5) -> List[str]:
|
| 294 |
+
"""Extrai keywords principais do texto."""
|
| 295 |
+
# Remove stopwords comuns
|
| 296 |
+
stopwords = {
|
| 297 |
+
"o", "a", "de", "da", "do", "em", "para", "com", "por",
|
| 298 |
+
"que", "é", "um", "uma", "os", "as", "dos", "das",
|
| 299 |
+
"e", "ou", "mas", "se", "não", "sim",
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
words = re.findall(r'\w+', text.lower())
|
| 303 |
+
keywords = [w for w in words if w not in stopwords and len(w) > 3]
|
| 304 |
+
|
| 305 |
+
# Retorna os primeiros N
|
| 306 |
+
return keywords[:max_keywords]
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
# ============================================================
|
| 310 |
+
# FUNÇÕES DE CONVENIÊNCIA
|
| 311 |
+
# ============================================================
|
| 312 |
+
|
| 313 |
+
_handler_instance: Optional[ImprovedContextHandler] = None
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def get_context_handler() -> ImprovedContextHandler:
|
| 317 |
+
"""Retorna instância singleton do handler."""
|
| 318 |
+
global _handler_instance
|
| 319 |
+
if _handler_instance is None:
|
| 320 |
+
_handler_instance = ImprovedContextHandler()
|
| 321 |
+
return _handler_instance
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def calculate_smart_context_weights(
|
| 325 |
+
message: str,
|
| 326 |
+
reply_metadata: Optional[Dict[str, Any]] = None
|
| 327 |
+
) -> Dict[str, float]:
|
| 328 |
+
"""
|
| 329 |
+
Função helper para calcular pesos de contexto inteligentemente.
|
| 330 |
+
|
| 331 |
+
Args:
|
| 332 |
+
message: Mensagem do usuário
|
| 333 |
+
reply_metadata: Metadados de reply
|
| 334 |
+
|
| 335 |
+
Returns:
|
| 336 |
+
Dict com pesos de contexto
|
| 337 |
+
"""
|
| 338 |
+
handler = get_context_handler()
|
| 339 |
+
weights = handler.calculate_context_weights(message, reply_metadata)
|
| 340 |
+
return weights.to_dict()
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
# ============================================================
|
| 344 |
+
# EXEMPLO DE USO
|
| 345 |
+
# ============================================================
|
| 346 |
+
|
| 347 |
+
if __name__ == "__main__":
|
| 348 |
+
# Teste básico
|
| 349 |
+
handler = ImprovedContextHandler()
|
| 350 |
+
|
| 351 |
+
test_cases = [
|
| 352 |
+
# (mensagem, tem_reply, descrição)
|
| 353 |
+
("Oq é isso?", True, "Pergunta muito curta com reply"),
|
| 354 |
+
("Como funciona isso?", True, "Pergunta curta com reply"),
|
| 355 |
+
("Oq é isso?", False, "Pergunta curta SEM reply (ambígua)"),
|
| 356 |
+
("Você pode explicar melhor esse conceito?", True, "Pergunta normal com reply"),
|
| 357 |
+
("Como funciona inteligência artificial?", False, "Pergunta normal sem reply"),
|
| 358 |
+
]
|
| 359 |
+
|
| 360 |
+
print("=== TESTE DE PESOS DE CONTEXTO ===\n")
|
| 361 |
+
|
| 362 |
+
for message, has_reply, description in test_cases:
|
| 363 |
+
print(f"Caso: {description}")
|
| 364 |
+
print(f"Mensagem: \"{message}\"")
|
| 365 |
+
print(f"Tem reply: {has_reply}")
|
| 366 |
+
|
| 367 |
+
reply_meta = {"is_reply": has_reply} if has_reply else None
|
| 368 |
+
weights = handler.calculate_context_weights(message, reply_meta)
|
| 369 |
+
|
| 370 |
+
print(f"Pesos calculados:")
|
| 371 |
+
print(f" - Reply context: {weights.reply_context:.2f}")
|
| 372 |
+
print(f" - Quoted analysis: {weights.quoted_analysis:.2f}")
|
| 373 |
+
print(f" - Short-term memory: {weights.short_term_memory:.2f}")
|
| 374 |
+
print(f" - Vector memory: {weights.vector_memory:.2f}")
|
| 375 |
+
print()
|
modules/local_llm.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# type: ignore
|
| 2 |
+
"""
|
| 3 |
+
modules/local_llm.py
|
| 4 |
+
================================================================================
|
| 5 |
+
FALLBACK LOCAL LLM - ÚLTIMA HIPÓTASE
|
| 6 |
+
================================================================================
|
| 7 |
+
Este módulo é usado SOMENTE quando TODAS as APIs externas falharem.
|
| 8 |
+
Implementa um modelo local leve (TinyLlama ou equivalente) para respostas
|
| 9 |
+
básicas em modo de emergência.
|
| 10 |
+
|
| 11 |
+
Features:
|
| 12 |
+
- Fallback final do sistema
|
| 13 |
+
- Modelo pequeno (~1.5B parâmetros)
|
| 14 |
+
- Respostas básicas em português/angolano
|
| 15 |
+
- Não requer GPU
|
| 16 |
+
================================================================================
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
import re
|
| 21 |
+
import time
|
| 22 |
+
from typing import Optional, List, Dict, Any
|
| 23 |
+
from datetime import datetime
|
| 24 |
+
|
| 25 |
+
# Imports opcionais com fallbacks
|
| 26 |
+
try:
|
| 27 |
+
import torch # type: ignore
|
| 28 |
+
TORCH_AVAILABLE = True
|
| 29 |
+
except Exception:
|
| 30 |
+
TORCH_AVAILABLE = False
|
| 31 |
+
torch = None # type: ignore
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline # type: ignore
|
| 35 |
+
TRANSFORMERS_AVAILABLE = True
|
| 36 |
+
except Exception:
|
| 37 |
+
TRANSFORMERS_AVAILABLE = False
|
| 38 |
+
AutoTokenizer = None # type: ignore
|
| 39 |
+
AutoModelForCausalLM = None # type: ignore
|
| 40 |
+
pipeline = None # type: ignore
|
| 41 |
+
|
| 42 |
+
try:
|
| 43 |
+
from loguru import logger # type: ignore
|
| 44 |
+
LOGURU_AVAILABLE = True
|
| 45 |
+
except Exception:
|
| 46 |
+
LOGURU_AVAILABLE = False
|
| 47 |
+
# Criar logger dummy
|
| 48 |
+
class DummyLogger:
|
| 49 |
+
def info(self, *args, **kwargs): pass
|
| 50 |
+
def success(self, *args, **kwargs): pass
|
| 51 |
+
def warning(self, *args, **kwargs): pass
|
| 52 |
+
def error(self, *args, **kwargs): pass
|
| 53 |
+
def debug(self, *args, **kwargs): pass
|
| 54 |
+
logger = DummyLogger() # type: ignore
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
from cachetools import TTLCache # type: ignore
|
| 58 |
+
CACHETOOLS_AVAILABLE = True
|
| 59 |
+
except Exception:
|
| 60 |
+
CACHETOOLS_AVAILABLE = False
|
| 61 |
+
# Implementação simples de cache fallback
|
| 62 |
+
class TTLCache(dict):
|
| 63 |
+
def __init__(self, maxsize=10, ttl=300, **kwargs):
|
| 64 |
+
super().__init__(**kwargs)
|
| 65 |
+
self.maxsize = maxsize
|
| 66 |
+
self.ttl = ttl
|
| 67 |
+
self._timestamps = {}
|
| 68 |
+
|
| 69 |
+
def __setitem__(self, key, value):
|
| 70 |
+
super().__setitem__(key, value)
|
| 71 |
+
self._timestamps[key] = time.time()
|
| 72 |
+
# Limpa itens antigos se necessário
|
| 73 |
+
if len(self) > self.maxsize:
|
| 74 |
+
oldest_key = min(self._timestamps.keys(), key=lambda k: self._timestamps[k])
|
| 75 |
+
self.pop(oldest_key, None)
|
| 76 |
+
self._timestamps.pop(oldest_key, None)
|
| 77 |
+
|
| 78 |
+
def get(self, key, default=None):
|
| 79 |
+
# Verifica se expirou
|
| 80 |
+
if key in self._timestamps:
|
| 81 |
+
if time.time() - self._timestamps[key] > self.ttl:
|
| 82 |
+
self.pop(key, None)
|
| 83 |
+
self._timestamps.pop(key, None)
|
| 84 |
+
return default
|
| 85 |
+
return super().get(key, default)
|
| 86 |
+
|
| 87 |
+
# Cache de prompts
|
| 88 |
+
_prompt_cache: Any = None
|
| 89 |
+
if CACHETOOLS_AVAILABLE:
|
| 90 |
+
try:
|
| 91 |
+
_prompt_cache = TTLCache(maxsize=10, ttl=300)
|
| 92 |
+
except Exception:
|
| 93 |
+
_prompt_cache = {}
|
| 94 |
+
|
| 95 |
+
# ============================================================
|
| 96 |
+
# 🎯 CONFIGURAÇÕES DO FALLBACK LOCAL
|
| 97 |
+
# ============================================================
|
| 98 |
+
|
| 99 |
+
# Modelos locais suportados (do mais leve ao mais pesado)
|
| 100 |
+
LOCAL_LLM_MODELS = [
|
| 101 |
+
"TinyLlama/TinyLlama-1.1B-Chat-v1.0", # ~1.1GB - Mais leve
|
| 102 |
+
"microsoft/Phi-3-mini-4k-instruct", # ~2.4GB
|
| 103 |
+
"TheBloke/Llama-2-7b-chat-GGUF", # ~4GB (precisa de conversão)
|
| 104 |
+
"meta-llama/Llama-2-7b-chat-hf", # ~13GB
|
| 105 |
+
]
|
| 106 |
+
|
| 107 |
+
# Caminhos locais verificados
|
| 108 |
+
LOCAL_MODEL_PATHS = [
|
| 109 |
+
"/app/models/tinyllama-1.1b",
|
| 110 |
+
"/app/models/phi-3-mini",
|
| 111 |
+
"/app/models/llama-2-7b",
|
| 112 |
+
"/models/tinyllama",
|
| 113 |
+
"/models/phi-3",
|
| 114 |
+
]
|
| 115 |
+
|
| 116 |
+
# Prompt do sistema para fallback (mais simples)
|
| 117 |
+
FALLBACK_SYSTEM_PROMPT = """Você é Akira, uma IA assistente angolana.
|
| 118 |
+
Responda de forma curta e direta (1-2 frases).
|
| 119 |
+
Use português brasileiro com gírias angolanas quando natural.
|
| 120 |
+
Se não souber a resposta, diga que está em modo de emergência.
|
| 121 |
+
"""
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# ============================================================
|
| 125 |
+
# 🏗️ CLASSE PRINCIPAL - LOCAL LLM FALLBACK
|
| 126 |
+
# ============================================================
|
| 127 |
+
|
| 128 |
+
class LocalLLMFallback:
|
| 129 |
+
"""
|
| 130 |
+
Fallback local para quando TODAS as APIs externas falharem.
|
| 131 |
+
Carrega um modelo pequeno (TinyLlama ~1.1B) que funciona em CPU.
|
| 132 |
+
|
| 133 |
+
IMPORTANTE: Esta classe só deve ser usada como ÚLTIMA opção.
|
| 134 |
+
"""
|
| 135 |
+
|
| 136 |
+
_instance = None
|
| 137 |
+
_model_lock = None
|
| 138 |
+
|
| 139 |
+
def __new__(cls):
|
| 140 |
+
if cls._instance is None:
|
| 141 |
+
cls._instance = super().__new__(cls)
|
| 142 |
+
cls._instance._initialized = False
|
| 143 |
+
cls._instance._model_lock = __import__('threading').Lock()
|
| 144 |
+
return cls._instance
|
| 145 |
+
|
| 146 |
+
def __init__(self):
|
| 147 |
+
if self._initialized:
|
| 148 |
+
return
|
| 149 |
+
self._initialized = True
|
| 150 |
+
|
| 151 |
+
# Componentes do modelo
|
| 152 |
+
self._model = None # type: ignore
|
| 153 |
+
self._tokenizer = None # type: ignore
|
| 154 |
+
self._pipeline = None # type: ignore
|
| 155 |
+
self._model_path = None # type: ignore
|
| 156 |
+
self._is_loaded = False
|
| 157 |
+
|
| 158 |
+
# Configurações
|
| 159 |
+
self._max_tokens = 256 # Respostas curtas para CPU
|
| 160 |
+
self._temperature = 0.7
|
| 161 |
+
self._max_consecutive_failures = 3
|
| 162 |
+
self._consecutive_failures = 0
|
| 163 |
+
|
| 164 |
+
# Estatísticas
|
| 165 |
+
self._stats = {
|
| 166 |
+
"total_calls": 0,
|
| 167 |
+
"successful_calls": 0,
|
| 168 |
+
"failed_calls": 0,
|
| 169 |
+
"last_used": None,
|
| 170 |
+
"model_loaded": False
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
# Tenta detectar e carregar modelo
|
| 174 |
+
self._detect_and_load_model()
|
| 175 |
+
|
| 176 |
+
def _detect_and_load_model(self) -> bool:
|
| 177 |
+
"""Detecta e carrega modelo local se disponível."""
|
| 178 |
+
if not TORCH_AVAILABLE or not TRANSFORMERS_AVAILABLE:
|
| 179 |
+
logger.warning("Torch/Transformers não disponíveis. Local LLM desabilitado.")
|
| 180 |
+
return False
|
| 181 |
+
|
| 182 |
+
with self._model_lock:
|
| 183 |
+
if self._is_loaded:
|
| 184 |
+
return True
|
| 185 |
+
|
| 186 |
+
# Tenta encontrar modelo local
|
| 187 |
+
model_path = self._find_local_model()
|
| 188 |
+
|
| 189 |
+
if model_path:
|
| 190 |
+
return self._load_model(model_path)
|
| 191 |
+
|
| 192 |
+
logger.info("Nenhum modelo local encontrado. Local LLM desabilitado.")
|
| 193 |
+
return False
|
| 194 |
+
|
| 195 |
+
def _find_local_model(self) -> Optional[str]:
|
| 196 |
+
"""Procura modelo local em caminhos conhecidos."""
|
| 197 |
+
# 1. Verifica variável de ambiente
|
| 198 |
+
env_path = os.getenv("LOCAL_LLM_PATH")
|
| 199 |
+
if env_path and os.path.exists(env_path):
|
| 200 |
+
logger.info(f"Modelo local encontrado via env: {env_path}")
|
| 201 |
+
return env_path
|
| 202 |
+
|
| 203 |
+
# 2. Verifica caminhos locais
|
| 204 |
+
for path in LOCAL_MODEL_PATHS:
|
| 205 |
+
if os.path.exists(path):
|
| 206 |
+
logger.info(f"Modelo local encontrado: {path}")
|
| 207 |
+
return path
|
| 208 |
+
|
| 209 |
+
# 3. Tenta descargar TinyLlama (pequeno, ~1.1GB)
|
| 210 |
+
# Só faz download se explicitly habilitado
|
| 211 |
+
if os.getenv("LOCAL_LLM_AUTO_DOWNLOAD", "").lower() == "true":
|
| 212 |
+
logger.info("Auto-download habilitado. TinyLlama será baixado se necessário.")
|
| 213 |
+
return LOCAL_LLM_MODELS[0]
|
| 214 |
+
|
| 215 |
+
return None
|
| 216 |
+
|
| 217 |
+
def _load_model(self, model_path: str) -> bool:
|
| 218 |
+
"""Carrega modelo local."""
|
| 219 |
+
try:
|
| 220 |
+
logger.info(f"🔄 Carregando modelo local: {model_path}")
|
| 221 |
+
|
| 222 |
+
hf_token = os.getenv("HF_TOKEN")
|
| 223 |
+
|
| 224 |
+
# Carrega tokenizer
|
| 225 |
+
self._tokenizer = AutoTokenizer.from_pretrained(
|
| 226 |
+
model_path,
|
| 227 |
+
token=hf_token,
|
| 228 |
+
padding_side="left"
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
# Configura pad_token
|
| 232 |
+
if self._tokenizer.pad_token is None:
|
| 233 |
+
self._tokenizer.pad_token = self._tokenizer.eos_token
|
| 234 |
+
|
| 235 |
+
# Carrega modelo (CPU apenas para compatibilidade)
|
| 236 |
+
self._model = AutoModelForCausalLM.from_pretrained(
|
| 237 |
+
model_path,
|
| 238 |
+
token=hf_token,
|
| 239 |
+
torch_dtype=torch.float32 if torch else None,
|
| 240 |
+
low_cpu_mem_usage=True,
|
| 241 |
+
device_map="auto" if TORCH_AVAILABLE else None
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
# Cria pipeline
|
| 245 |
+
self._pipeline = pipeline(
|
| 246 |
+
"text-generation",
|
| 247 |
+
model=self._model,
|
| 248 |
+
tokenizer=self._tokenizer,
|
| 249 |
+
max_new_tokens=self._max_tokens,
|
| 250 |
+
temperature=self._temperature,
|
| 251 |
+
top_p=0.9,
|
| 252 |
+
do_sample=True,
|
| 253 |
+
repetition_penalty=1.1
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
self._model_path = model_path
|
| 257 |
+
self._is_loaded = True
|
| 258 |
+
self._stats["model_loaded"] = True
|
| 259 |
+
|
| 260 |
+
logger.success(f"✅ Modelo local carregado: {model_path}")
|
| 261 |
+
return True
|
| 262 |
+
|
| 263 |
+
except Exception as e:
|
| 264 |
+
logger.error(f"❌ Erro ao carregar modelo local: {e}")
|
| 265 |
+
self._is_loaded = False
|
| 266 |
+
return False
|
| 267 |
+
|
| 268 |
+
def is_available(self) -> bool:
|
| 269 |
+
"""Verifica se o fallback local está disponível."""
|
| 270 |
+
return self._is_loaded and self._pipeline is not None
|
| 271 |
+
|
| 272 |
+
def is_operational(self) -> bool:
|
| 273 |
+
"""Verifica se está operacional (pode responder)."""
|
| 274 |
+
return self.is_available() and self._consecutive_failures < self._max_consecutive_failures
|
| 275 |
+
|
| 276 |
+
def generate(
|
| 277 |
+
self,
|
| 278 |
+
prompt: str,
|
| 279 |
+
system_prompt: Optional[str] = None,
|
| 280 |
+
max_tokens: Optional[int] = None,
|
| 281 |
+
temperature: Optional[float] = None
|
| 282 |
+
) -> Optional[str]:
|
| 283 |
+
"""
|
| 284 |
+
Gera resposta usando modelo local.
|
| 285 |
+
|
| 286 |
+
Args:
|
| 287 |
+
prompt: Prompt do usuário
|
| 288 |
+
system_prompt: Prompt do sistema (usa default se None)
|
| 289 |
+
max_tokens: Máximo de tokens (usa default se None)
|
| 290 |
+
temperature: Temperatura de geração
|
| 291 |
+
|
| 292 |
+
Returns:
|
| 293 |
+
String da resposta ou None se falhar
|
| 294 |
+
"""
|
| 295 |
+
self._stats["total_calls"] += 1
|
| 296 |
+
|
| 297 |
+
# Verifica disponibilidade
|
| 298 |
+
if not self.is_operational():
|
| 299 |
+
self._stats["failed_calls"] += 1
|
| 300 |
+
return None
|
| 301 |
+
|
| 302 |
+
# Usa cache se disponível
|
| 303 |
+
cache_key = f"{prompt[:50]}:{system_prompt or 'default'}"
|
| 304 |
+
if _prompt_cache is not None:
|
| 305 |
+
cached = _prompt_cache.get(cache_key)
|
| 306 |
+
if cached:
|
| 307 |
+
logger.debug("Resposta encontrada em cache local")
|
| 308 |
+
return cached
|
| 309 |
+
|
| 310 |
+
try:
|
| 311 |
+
# Prepara prompts
|
| 312 |
+
sys_prompt = system_prompt or FALLBACK_SYSTEM_PROMPT
|
| 313 |
+
|
| 314 |
+
# Formata para modelo
|
| 315 |
+
if self._tokenizer and hasattr(self._tokenizer, 'chat_template') and False:
|
| 316 |
+
# Usa chat template se disponível
|
| 317 |
+
messages = [
|
| 318 |
+
{"role": "system", "content": sys_prompt},
|
| 319 |
+
{"role": "user", "content": prompt}
|
| 320 |
+
]
|
| 321 |
+
formatted = self._tokenizer.apply_chat_template(
|
| 322 |
+
messages,
|
| 323 |
+
tokenize=False,
|
| 324 |
+
add_generation_prompt=True
|
| 325 |
+
)
|
| 326 |
+
else:
|
| 327 |
+
# Formato simples (funciona com a maioria dos modelos)
|
| 328 |
+
formatted = f"""<|system|>
|
| 329 |
+
{sys_prompt}
|
| 330 |
+
</s>
|
| 331 |
+
<|user|>
|
| 332 |
+
{prompt}
|
| 333 |
+
</s>
|
| 334 |
+
<|assistant|>
|
| 335 |
+
"""
|
| 336 |
+
|
| 337 |
+
# Gera resposta
|
| 338 |
+
max_new = max_tokens or self._max_tokens
|
| 339 |
+
|
| 340 |
+
outputs = self._pipeline(
|
| 341 |
+
formatted,
|
| 342 |
+
max_new_tokens=max_new,
|
| 343 |
+
temperature=temperature or self._temperature,
|
| 344 |
+
top_p=0.9,
|
| 345 |
+
do_sample=True,
|
| 346 |
+
pad_token_id=self._tokenizer.eos_token_id if self._tokenizer else None,
|
| 347 |
+
repetition_penalty=1.1
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
# Extrai resposta
|
| 351 |
+
if outputs and len(outputs) > 0:
|
| 352 |
+
generated = outputs[0].get("generated_text", "")
|
| 353 |
+
|
| 354 |
+
# Remove prompt da resposta
|
| 355 |
+
response = self._extract_response(generated, formatted)
|
| 356 |
+
response = self._clean_response(response)
|
| 357 |
+
|
| 358 |
+
if response:
|
| 359 |
+
# Cache se disponível
|
| 360 |
+
if _prompt_cache is not None:
|
| 361 |
+
try:
|
| 362 |
+
_prompt_cache[cache_key] = response
|
| 363 |
+
except Exception:
|
| 364 |
+
pass
|
| 365 |
+
|
| 366 |
+
self._stats["successful_calls"] += 1
|
| 367 |
+
self._stats["last_used"] = datetime.now().isoformat()
|
| 368 |
+
self._consecutive_failures = 0
|
| 369 |
+
|
| 370 |
+
return response
|
| 371 |
+
|
| 372 |
+
# Falha silenciosa
|
| 373 |
+
self._consecutive_failures += 1
|
| 374 |
+
self._stats["failed_calls"] += 1
|
| 375 |
+
return None
|
| 376 |
+
|
| 377 |
+
except Exception as e:
|
| 378 |
+
logger.error(f"❌ Erro em fallback local: {e}")
|
| 379 |
+
self._consecutive_failures += 1
|
| 380 |
+
self._stats["failed_calls"] += 1
|
| 381 |
+
return None
|
| 382 |
+
|
| 383 |
+
def _extract_response(self, generated: str, prompt: str) -> str:
|
| 384 |
+
"""Extrai a resposta do texto gerado."""
|
| 385 |
+
if not generated:
|
| 386 |
+
return ""
|
| 387 |
+
|
| 388 |
+
# Remove o prompt do início
|
| 389 |
+
if prompt in generated:
|
| 390 |
+
response = generated[len(prompt):]
|
| 391 |
+
else:
|
| 392 |
+
# Tenta encontrar padrão de separação
|
| 393 |
+
if "<|assistant|>" in generated:
|
| 394 |
+
response = generated.split("<|assistant|>")[-1]
|
| 395 |
+
elif "</s>" in generated and "<|user|>" in generated:
|
| 396 |
+
# Extrai após última tag de user
|
| 397 |
+
parts = generated.split("<|user|>")
|
| 398 |
+
if len(parts) > 1:
|
| 399 |
+
response = parts[-1]
|
| 400 |
+
else:
|
| 401 |
+
response = generated
|
| 402 |
+
else:
|
| 403 |
+
response = generated
|
| 404 |
+
|
| 405 |
+
return response.strip()
|
| 406 |
+
|
| 407 |
+
def _clean_response(self, text: str) -> str:
|
| 408 |
+
"""Limpa a resposta gerada."""
|
| 409 |
+
# Remove tags e formatação
|
| 410 |
+
text = re.sub(r'<\|[^|]+\|>', '', text)
|
| 411 |
+
text = re.sub(r'</?s>', '', text)
|
| 412 |
+
text = re.sub(r'[\*\_\`\[\]\"]', '', text)
|
| 413 |
+
|
| 414 |
+
# Normaliza espaços
|
| 415 |
+
text = re.sub(r'\s+', ' ', text).strip()
|
| 416 |
+
|
| 417 |
+
# Limita tamanho (1 token ≈ 4 caracteres)
|
| 418 |
+
max_chars = self._max_tokens * 4
|
| 419 |
+
if len(text) > max_chars:
|
| 420 |
+
# Corta em sentença completa
|
| 421 |
+
sentences = [s.strip() + "." for s in text.split(".") if s.strip()]
|
| 422 |
+
result = ""
|
| 423 |
+
for sent in sentences:
|
| 424 |
+
if len(result + sent) <= max_chars:
|
| 425 |
+
result += sent + " "
|
| 426 |
+
else:
|
| 427 |
+
break
|
| 428 |
+
text = result.strip()
|
| 429 |
+
|
| 430 |
+
return text
|
| 431 |
+
|
| 432 |
+
def get_status(self) -> Dict[str, Any]:
|
| 433 |
+
"""Retorna status do fallback local."""
|
| 434 |
+
return {
|
| 435 |
+
"available": self.is_available(),
|
| 436 |
+
"operational": self.is_operational(),
|
| 437 |
+
"model_path": self._model_path,
|
| 438 |
+
"model_loaded": self._is_loaded,
|
| 439 |
+
"consecutive_failures": self._consecutive_failures,
|
| 440 |
+
"max_failures_allowed": self._max_consecutive_failures,
|
| 441 |
+
"stats": self._stats.copy()
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
def reset_failures(self):
|
| 445 |
+
"""Reseta contador de falhas."""
|
| 446 |
+
self._consecutive_failures = 0
|
| 447 |
+
|
| 448 |
+
def should_use_fallback(self, api_failures: int = 0) -> bool:
|
| 449 |
+
"""
|
| 450 |
+
Decide se deve usar o fallback local.
|
| 451 |
+
|
| 452 |
+
Args:
|
| 453 |
+
api_failures: Número de falhas consecutivas de APIs
|
| 454 |
+
|
| 455 |
+
Returns:
|
| 456 |
+
True se deve usar fallback
|
| 457 |
+
"""
|
| 458 |
+
# Só usa se:
|
| 459 |
+
# 1. Modelo está operacional
|
| 460 |
+
# 2. Houve pelo menos 1 falha de API OU está explicitamente habilitado
|
| 461 |
+
return (
|
| 462 |
+
self.is_operational() and
|
| 463 |
+
(api_failures > 0 or os.getenv("USE_LOCAL_FALLBACK", "").lower() == "true")
|
| 464 |
+
)
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
# ============================================================
|
| 468 |
+
# 🎯 FUNÇÃO PRINCIPAL DE FALLBACK
|
| 469 |
+
# ============================================================
|
| 470 |
+
|
| 471 |
+
def get_local_fallback() -> LocalLLMFallback:
|
| 472 |
+
"""Retorna instância singleton do fallback local."""
|
| 473 |
+
return LocalLLMFallback()
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def generate_fallback_response(
|
| 477 |
+
prompt: str,
|
| 478 |
+
system_prompt: Optional[str] = None,
|
| 479 |
+
api_failures: int = 0
|
| 480 |
+
) -> Optional[str]:
|
| 481 |
+
"""
|
| 482 |
+
Gera resposta de fallback se necessário.
|
| 483 |
+
|
| 484 |
+
Args:
|
| 485 |
+
prompt: Prompt do usuário
|
| 486 |
+
system_prompt: Prompt do sistema opcional
|
| 487 |
+
api_failures: Número de falhas de API
|
| 488 |
+
|
| 489 |
+
Returns:
|
| 490 |
+
Resposta gerada ou None
|
| 491 |
+
"""
|
| 492 |
+
fallback = get_local_fallback()
|
| 493 |
+
|
| 494 |
+
if fallback.should_use_fallback(api_failures):
|
| 495 |
+
logger.info(f"🔴 Usando fallback local (API failures: {api_failures})")
|
| 496 |
+
return fallback.generate(prompt, system_prompt)
|
| 497 |
+
|
| 498 |
+
return None
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
# ============================================================
|
| 502 |
+
# 🧪 MOCK PARA TESTES
|
| 503 |
+
# ============================================================
|
| 504 |
+
|
| 505 |
+
class MockLocalLLM:
|
| 506 |
+
"""Mock para testes quando modelo não está disponível."""
|
| 507 |
+
|
| 508 |
+
def is_available(self) -> bool:
|
| 509 |
+
return False
|
| 510 |
+
|
| 511 |
+
def is_operational(self) -> bool:
|
| 512 |
+
return False
|
| 513 |
+
|
| 514 |
+
def generate(self, prompt: str, **kwargs) -> str:
|
| 515 |
+
return "🤖 Modo de emergência: Todas as APIs falharam. Tente novamente mais tarde."
|
| 516 |
+
|
| 517 |
+
def get_status(self) -> Dict[str, Any]:
|
| 518 |
+
return {"available": False, "mock": True}
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
# ============================================================
|
| 522 |
+
# 📤 EXPORTS
|
| 523 |
+
# ============================================================
|
| 524 |
+
|
| 525 |
+
__all__ = [
|
| 526 |
+
"LocalLLMFallback",
|
| 527 |
+
"get_local_fallback",
|
| 528 |
+
"generate_fallback_response",
|
| 529 |
+
"MockLocalLLM",
|
| 530 |
+
"FALLBACK_SYSTEM_PROMPT",
|
| 531 |
+
]
|
| 532 |
+
|
modules/nlp_avancado.py
ADDED
|
@@ -0,0 +1,701 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# type: ignore
|
| 2 |
+
"""
|
| 3 |
+
NLP Avançado de Nível Acadêmico - AKIRA V21 ULTIMATE
|
| 4 |
+
Sistema de processamento de linguagem natural ultra-potente
|
| 5 |
+
Capaz de modificar prompts e respostas da API em tempo real
|
| 6 |
+
"""
|
| 7 |
+
import re
|
| 8 |
+
import time
|
| 9 |
+
import threading
|
| 10 |
+
from typing import Dict, Any, List, Optional, Tuple
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
from collections import defaultdict
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
# ============================================================
|
| 16 |
+
# 🎯 CONFIGURAÇÃO NLP AVANÇADO
|
| 17 |
+
# ============================================================
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class NLPAdvancedConfig:
|
| 21 |
+
"""Configuração do NLP Avançado de Nível Acadêmico"""
|
| 22 |
+
# Nível de agressividade na modificação do prompt
|
| 23 |
+
prompt_modification_aggression: float = 0.8 # 0.0-1.0
|
| 24 |
+
|
| 25 |
+
# Threshold de confiança para mudanças
|
| 26 |
+
confidence_threshold: float = 0.75
|
| 27 |
+
|
| 28 |
+
# Enable/disable features
|
| 29 |
+
enable_semantic_analysis: bool = True
|
| 30 |
+
enable_academic_detection: bool = True
|
| 31 |
+
enable_context_enhancement: bool = True
|
| 32 |
+
enable_response_modification: bool = True
|
| 33 |
+
enable_emotion_amplification: bool = True
|
| 34 |
+
|
| 35 |
+
# Modelos de análise
|
| 36 |
+
use_bert_for_semantic: bool = True
|
| 37 |
+
use_embeddings_for_similarity: bool = True
|
| 38 |
+
|
| 39 |
+
# Cache settings
|
| 40 |
+
cache_size: int = 1000
|
| 41 |
+
cache_ttl_seconds: int = 3600
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class AcademicTermDetector:
|
| 45 |
+
"""Detector de termos acadêmicos e científicos"""
|
| 46 |
+
|
| 47 |
+
ACADEMIC_PATTERNS = {
|
| 48 |
+
# Campos acadêmicos
|
| 49 |
+
'ciencias_exatas': [
|
| 50 |
+
r'\b(matemática|física|química|biologia|estatística|probabilidade)\b',
|
| 51 |
+
r'\b(teorema|prova|demonstração|equação|variável|função)\b',
|
| 52 |
+
r'\b(cálculo|álgebra|geometria|trigonometria)\b',
|
| 53 |
+
],
|
| 54 |
+
'ciencias_humanas': [
|
| 55 |
+
r'\b(filosofia|história|sociologia|psicologia|antropologia)\b',
|
| 56 |
+
r'\b(teoria|hipótese|tese|dissertação|monografia)\b',
|
| 57 |
+
r'\b(marxismo|estruturalismo|fenomenologia)\b',
|
| 58 |
+
],
|
| 59 |
+
'engenharia_tech': [
|
| 60 |
+
r'\b(engenharia|programação|algoritmo|arquitetura)\b',
|
| 61 |
+
r'\b(sistema|rede|banco de dados|backend|frontend)\b',
|
| 62 |
+
r'\b(machine learning|inteligência artificial|IA)\b',
|
| 63 |
+
],
|
| 64 |
+
'direito': [
|
| 65 |
+
r'\b(direito|lei|artigo|parágrafo|jurídico)\b',
|
| 66 |
+
r'\b(constituição|código civil|código penal)\b',
|
| 67 |
+
r'\b(advogado|juiz|ministério público|delegacia)\b',
|
| 68 |
+
],
|
| 69 |
+
'medicina': [
|
| 70 |
+
r'\b(medicina|saúde|diagnóstico|tratamento)\b',
|
| 71 |
+
r'\b(fármaco|medicamento|biológico|sintético)\b',
|
| 72 |
+
r'\b(hospital|clínica|ambulatório|UTI)\b',
|
| 73 |
+
],
|
| 74 |
+
'economia': [
|
| 75 |
+
r'\b(economia|mercado|inflação|juros|PIB)\b',
|
| 76 |
+
r'\b(monetário|fiscal|política econômica)\b',
|
| 77 |
+
r'\b(ações|bônus|investimento|rendimento)\b',
|
| 78 |
+
],
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
ACADEMIC_INDICATORS = [
|
| 82 |
+
# Palavras que indicam contexto acadêmico
|
| 83 |
+
r'\b(cite|referência|bibliografia|fonte)\b',
|
| 84 |
+
r'\b(estudo|pesquisa|investigação|análise)\b',
|
| 85 |
+
r'\b(teórico|empírico|metodologia|metodológico)\b',
|
| 86 |
+
r'\b(conclusão|resultados|discussão|abstract)\b',
|
| 87 |
+
r'\b(revisão|literatura|framework|modelo)\b',
|
| 88 |
+
r'\b(hipótese|variável|indicador|índice)\b',
|
| 89 |
+
r'\b(significância|relevância|validade)\b',
|
| 90 |
+
]
|
| 91 |
+
|
| 92 |
+
def __init__(self):
|
| 93 |
+
self._compiled_patterns = {}
|
| 94 |
+
self._compile_patterns()
|
| 95 |
+
|
| 96 |
+
def _compile_patterns(self):
|
| 97 |
+
"""Compila todos os padrões para eficiência"""
|
| 98 |
+
for category, patterns in self.ACADEMIC_PATTERNS.items():
|
| 99 |
+
compiled = [re.compile(p, re.IGNORECASE) for p in patterns]
|
| 100 |
+
self._compiled_patterns[category] = compiled
|
| 101 |
+
|
| 102 |
+
self._academic_indicators = [
|
| 103 |
+
re.compile(p, re.IGNORECASE) for p in self.ACADEMIC_INDICATORS
|
| 104 |
+
]
|
| 105 |
+
|
| 106 |
+
def detect(self, text: str) -> Dict[str, Any]:
|
| 107 |
+
"""Detecta contexto acadêmico no texto"""
|
| 108 |
+
text_lower = text.lower()
|
| 109 |
+
|
| 110 |
+
detected_fields = []
|
| 111 |
+
field_confidences = {}
|
| 112 |
+
|
| 113 |
+
for category, patterns in self._compiled_patterns.items():
|
| 114 |
+
matches = []
|
| 115 |
+
for pattern in patterns:
|
| 116 |
+
found = pattern.findall(text_lower)
|
| 117 |
+
matches.extend(found)
|
| 118 |
+
|
| 119 |
+
if matches:
|
| 120 |
+
confidence = min(0.95, 0.5 + (len(matches) * 0.15))
|
| 121 |
+
detected_fields.append(category)
|
| 122 |
+
field_confidences[category] = confidence
|
| 123 |
+
|
| 124 |
+
# Indicators
|
| 125 |
+
indicator_count = 0
|
| 126 |
+
for indicator in self._academic_indicators:
|
| 127 |
+
if indicator.search(text_lower):
|
| 128 |
+
indicator_count += 1
|
| 129 |
+
|
| 130 |
+
academic_confidence = min(0.95, 0.3 + (indicator_count * 0.1))
|
| 131 |
+
|
| 132 |
+
return {
|
| 133 |
+
'is_academic': indicator_count >= 2 or len(detected_fields) >= 2,
|
| 134 |
+
'academic_confidence': academic_confidence,
|
| 135 |
+
'detected_fields': detected_fields,
|
| 136 |
+
'field_confidences': field_confidences,
|
| 137 |
+
'indicator_count': indicator_count,
|
| 138 |
+
'academic_level': self._calculate_academic_level(text, detected_fields, indicator_count)
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
def _calculate_academic_level(self, text: str, fields: List[str], indicators: int) -> str:
|
| 142 |
+
"""Calcula o nível acadêmico do texto"""
|
| 143 |
+
word_count = len(text.split())
|
| 144 |
+
|
| 145 |
+
# Very formal academic
|
| 146 |
+
if indicators >= 4 and word_count > 100:
|
| 147 |
+
return "phd"
|
| 148 |
+
elif indicators >= 3 and word_count > 50:
|
| 149 |
+
return "masters"
|
| 150 |
+
elif indicators >= 2 and word_count > 30:
|
| 151 |
+
return "undergraduate"
|
| 152 |
+
elif indicators >= 1 or fields:
|
| 153 |
+
return "high_school"
|
| 154 |
+
else:
|
| 155 |
+
return "casual"
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
class SemanticAnalyzer:
|
| 159 |
+
"""Analisador semântico profundo"""
|
| 160 |
+
|
| 161 |
+
def __init__(self, embedding_model=None):
|
| 162 |
+
self.embedding_model = embedding_model
|
| 163 |
+
self._semantic_cache = {}
|
| 164 |
+
self._semantic_lock = threading.Lock()
|
| 165 |
+
|
| 166 |
+
def analyze(self, text: str, context: Optional[List[str]] = None) -> Dict[str, Any]:
|
| 167 |
+
"""Análise semântica completa"""
|
| 168 |
+
|
| 169 |
+
# Cache check
|
| 170 |
+
cache_key = hash(text)
|
| 171 |
+
if cache_key in self._semantic_cache:
|
| 172 |
+
cached = self._semantic_cache[cache_key]
|
| 173 |
+
if time.time() - cached['timestamp'] < 3600:
|
| 174 |
+
return cached['result']
|
| 175 |
+
|
| 176 |
+
# Basic semantic analysis
|
| 177 |
+
analysis = {
|
| 178 |
+
'entities': self._extract_entities(text),
|
| 179 |
+
'concepts': self._extract_concepts(text),
|
| 180 |
+
'relations': self._extract_relations(text),
|
| 181 |
+
'sentiment': self._analyze_sentiment(text),
|
| 182 |
+
'formality': self._analyze_formality(text),
|
| 183 |
+
'complexity': self._analyze_complexity(text),
|
| 184 |
+
'topics': self._extract_topics(text),
|
| 185 |
+
'keywords': self._extract_keywords(text),
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
# Context enhancement
|
| 189 |
+
if context:
|
| 190 |
+
analysis['context_coherence'] = self._check_context_coherence(text, context)
|
| 191 |
+
|
| 192 |
+
# Store in cache
|
| 193 |
+
with self._semantic_lock:
|
| 194 |
+
self._semantic_cache[cache_key] = {
|
| 195 |
+
'timestamp': time.time(),
|
| 196 |
+
'result': analysis
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
return analysis
|
| 200 |
+
|
| 201 |
+
def _extract_entities(self, text: str) -> List[Dict[str, Any]]:
|
| 202 |
+
"""Extrai entidades do texto"""
|
| 203 |
+
entities = []
|
| 204 |
+
|
| 205 |
+
# Patterns for common entity types
|
| 206 |
+
patterns = {
|
| 207 |
+
'person': r'\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b',
|
| 208 |
+
'organization': r'\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\b',
|
| 209 |
+
'date': r'\b(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})\b',
|
| 210 |
+
'money': r'\b(R\$|USD|EUR|\$)\s*\d+(?:[.,]\d{2})?\b',
|
| 211 |
+
'location': r'\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\b',
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
for entity_type, pattern in patterns.items():
|
| 215 |
+
matches = re.findall(pattern, text)
|
| 216 |
+
for match in matches:
|
| 217 |
+
entities.append({
|
| 218 |
+
'type': entity_type,
|
| 219 |
+
'value': match if isinstance(match, str) else match[0] if match else '',
|
| 220 |
+
'position': text.find(match[0]) if isinstance(match, tuple) else -1
|
| 221 |
+
})
|
| 222 |
+
|
| 223 |
+
return entities
|
| 224 |
+
|
| 225 |
+
def _extract_concepts(self, text: str) -> List[str]:
|
| 226 |
+
"""Extrai conceitos principais"""
|
| 227 |
+
concepts = []
|
| 228 |
+
|
| 229 |
+
# Look for noun phrases and important concepts
|
| 230 |
+
stopwords = {'o', 'a', 'de', 'da', 'do', 'em', 'para', 'com', 'não', 'é', 'são'}
|
| 231 |
+
words = text.lower().split()
|
| 232 |
+
|
| 233 |
+
for i, word in enumerate(words):
|
| 234 |
+
if word not in stopwords and len(word) > 4:
|
| 235 |
+
concepts.append(word)
|
| 236 |
+
|
| 237 |
+
return list(set(concepts))[:10]
|
| 238 |
+
|
| 239 |
+
def _extract_relations(self, text: str) -> List[Dict[str, str]]:
|
| 240 |
+
"""Extrai relações entre conceitos"""
|
| 241 |
+
relations = []
|
| 242 |
+
|
| 243 |
+
# Pattern: X é/foi/será Y
|
| 244 |
+
relation_patterns = [
|
| 245 |
+
(r'(\w+)\s+é\s+(\w+)', 'is_a'),
|
| 246 |
+
(r'(\w+)\s+foi\s+(\w+)', 'was'),
|
| 247 |
+
(r'(\w+)\s+tem\s+(\w+)', 'has'),
|
| 248 |
+
(r'(\w+)\s+pertence\s+a\s+(\w+)', 'belongs_to'),
|
| 249 |
+
]
|
| 250 |
+
|
| 251 |
+
for pattern, rel_type in relation_patterns:
|
| 252 |
+
matches = re.findall(pattern, text.lower())
|
| 253 |
+
for match in matches:
|
| 254 |
+
relations.append({
|
| 255 |
+
'subject': match[0],
|
| 256 |
+
'relation': rel_type,
|
| 257 |
+
'object': match[1] if len(match) > 1 else ''
|
| 258 |
+
})
|
| 259 |
+
|
| 260 |
+
return relations
|
| 261 |
+
|
| 262 |
+
def _analyze_sentiment(self, text: str) -> Dict[str, Any]:
|
| 263 |
+
"""Análise de sentimento detalhada"""
|
| 264 |
+
text_lower = text.lower()
|
| 265 |
+
|
| 266 |
+
positive_words = ['bom', 'ótimo', 'excelente', 'fixe', 'feliz', 'alegre', 'amor', 'gosto']
|
| 267 |
+
negative_words = ['ruim', 'péssimo', 'terrível', 'odio', 'triste', 'raiva', 'raivoso']
|
| 268 |
+
neutral_words = ['neutro', 'normal', 'tanto faz']
|
| 269 |
+
|
| 270 |
+
pos_count = sum(1 for w in positive_words if w in text_lower)
|
| 271 |
+
neg_count = sum(1 for w in negative_words if w in text_lower)
|
| 272 |
+
|
| 273 |
+
if pos_count > neg_count:
|
| 274 |
+
sentiment = 'positive'
|
| 275 |
+
score = min(0.95, 0.5 + (pos_count * 0.1))
|
| 276 |
+
elif neg_count > pos_count:
|
| 277 |
+
sentiment = 'negative'
|
| 278 |
+
score = min(0.95, 0.5 + (neg_count * 0.1))
|
| 279 |
+
else:
|
| 280 |
+
sentiment = 'neutral'
|
| 281 |
+
score = 0.5
|
| 282 |
+
|
| 283 |
+
return {
|
| 284 |
+
'sentiment': sentiment,
|
| 285 |
+
'score': score,
|
| 286 |
+
'positive_count': pos_count,
|
| 287 |
+
'negative_count': neg_count
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
def _analyze_formality(self, text: str) -> Dict[str, Any]:
|
| 291 |
+
"""Análise de formalidade"""
|
| 292 |
+
text_lower = text.lower()
|
| 293 |
+
|
| 294 |
+
formal_indicators = [
|
| 295 |
+
'senhor', 'doutor', 'professor', 'agradecido', 'gentilmente',
|
| 296 |
+
'por favor', 'conforme', 'destarte', 'outrossim', 'visto'
|
| 297 |
+
]
|
| 298 |
+
|
| 299 |
+
informal_indicators = [
|
| 300 |
+
'puto', 'mano', 'kkk', 'tio', 'bro', 'fala', 'eae', 'vlw'
|
| 301 |
+
]
|
| 302 |
+
|
| 303 |
+
formal_count = sum(1 for w in formal_indicators if w in text_lower)
|
| 304 |
+
informal_count = sum(1 for w in informal_indicators if w in text_lower)
|
| 305 |
+
|
| 306 |
+
formality_score = 0.5
|
| 307 |
+
if formal_count > informal_count:
|
| 308 |
+
formality_score = min(0.9, 0.5 + (formal_count * 0.1))
|
| 309 |
+
elif informal_count > formal_count:
|
| 310 |
+
formality_score = max(0.1, 0.5 - (informal_count * 0.1))
|
| 311 |
+
|
| 312 |
+
return {
|
| 313 |
+
'formality_score': formality_score,
|
| 314 |
+
'formal_level': 'formal' if formality_score > 0.6 else 'informal' if formality_score < 0.4 else 'neutral',
|
| 315 |
+
'formal_indicators': formal_count,
|
| 316 |
+
'informal_indicators': informal_count
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
def _analyze_complexity(self, text: str) -> Dict[str, Any]:
|
| 320 |
+
"""Análise de complexidade do texto"""
|
| 321 |
+
words = text.split()
|
| 322 |
+
sentences = re.split(r'[.!?]+', text)
|
| 323 |
+
|
| 324 |
+
avg_word_length = np.mean([len(w) for w in words]) if words else 0
|
| 325 |
+
avg_sentence_length = len(words) / max(len(sentences), 1)
|
| 326 |
+
|
| 327 |
+
# Complex words (more than 10 characters)
|
| 328 |
+
complex_words = [w for w in words if len(w) > 10]
|
| 329 |
+
complexity_ratio = len(complex_words) / max(len(words), 1)
|
| 330 |
+
|
| 331 |
+
# Calculate complexity score
|
| 332 |
+
complexity_score = min(1.0, (
|
| 333 |
+
(avg_word_length / 10) * 0.3 +
|
| 334 |
+
(avg_sentence_length / 20) * 0.3 +
|
| 335 |
+
(complexity_ratio * 2) * 0.4
|
| 336 |
+
))
|
| 337 |
+
|
| 338 |
+
return {
|
| 339 |
+
'complexity_score': complexity_score,
|
| 340 |
+
'avg_word_length': avg_word_length,
|
| 341 |
+
'avg_sentence_length': avg_sentence_length,
|
| 342 |
+
'complex_word_ratio': complexity_ratio,
|
| 343 |
+
'complexity_level': 'high' if complexity_score > 0.7 else 'medium' if complexity_score > 0.4 else 'low'
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
def _extract_topics(self, text: str) -> List[str]:
|
| 347 |
+
"""Extrai tópicos principais"""
|
| 348 |
+
topics = []
|
| 349 |
+
|
| 350 |
+
# Simple keyword extraction
|
| 351 |
+
important_words = []
|
| 352 |
+
stopwords = {'o', 'a', 'de', 'da', 'do', 'em', 'para', 'com', 'não', 'é', 'são', 'um', 'uma', 'os', 'as'}
|
| 353 |
+
|
| 354 |
+
for word in text.lower().split():
|
| 355 |
+
if word not in stopwords and len(word) > 3:
|
| 356 |
+
important_words.append(word)
|
| 357 |
+
|
| 358 |
+
# Count frequency
|
| 359 |
+
word_freq = defaultdict(int)
|
| 360 |
+
for word in important_words:
|
| 361 |
+
word_freq[word] += 1
|
| 362 |
+
|
| 363 |
+
# Get top topics
|
| 364 |
+
sorted_words = sorted(word_freq.items(), key=lambda x: x[1], reverse=True)
|
| 365 |
+
topics = [w[0] for w in sorted_words[:5]]
|
| 366 |
+
|
| 367 |
+
return topics
|
| 368 |
+
|
| 369 |
+
def _extract_keywords(self, text: str) -> List[str]:
|
| 370 |
+
"""Extrai palavras-chave"""
|
| 371 |
+
return self._extract_concepts(text)
|
| 372 |
+
|
| 373 |
+
def _check_context_coherence(self, text: str, context: List[str]) -> float:
|
| 374 |
+
"""Verifica coerência com contexto anterior"""
|
| 375 |
+
if not context:
|
| 376 |
+
return 0.5
|
| 377 |
+
|
| 378 |
+
text_lower = text.lower()
|
| 379 |
+
context_text = ' '.join(context).lower()
|
| 380 |
+
|
| 381 |
+
# Check for topic continuity
|
| 382 |
+
text_words = set(text_lower.split())
|
| 383 |
+
context_words = set(context_text.split())
|
| 384 |
+
|
| 385 |
+
# Jaccard similarity
|
| 386 |
+
intersection = len(text_words & context_words)
|
| 387 |
+
union = len(text_words | context_words)
|
| 388 |
+
|
| 389 |
+
similarity = intersection / max(union, 1)
|
| 390 |
+
|
| 391 |
+
return similarity
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
class PromptModifier:
|
| 395 |
+
"""Modificador de prompts para nível acadêmico"""
|
| 396 |
+
|
| 397 |
+
ACADEMIC_ENHANCEMENTS = {
|
| 398 |
+
'formal_intro': [
|
| 399 |
+
"Considerando os pressupostos teóricos relevantes e a literatura especializada, ",
|
| 400 |
+
"Do ponto de vista epistemológico, ",
|
| 401 |
+
"À luz das contribuições recentes no campo, ",
|
| 402 |
+
"Em consonância com a tradição acadêmica, ",
|
| 403 |
+
],
|
| 404 |
+
'academic_bridges': [
|
| 405 |
+
"Destarte, ",
|
| 406 |
+
"Outrossim, ",
|
| 407 |
+
"Nessa perspectiva, ",
|
| 408 |
+
"Diante do exposto, ",
|
| 409 |
+
"Por conseguinte, ",
|
| 410 |
+
],
|
| 411 |
+
'critical_questions': [
|
| 412 |
+
"Qual a implicação disso para a teoria?",
|
| 413 |
+
"Como isso se relaciona com a literatura existente?",
|
| 414 |
+
"Quais as limitações dessa análise?",
|
| 415 |
+
"Como operacionalizar esse conceito?",
|
| 416 |
+
],
|
| 417 |
+
'methodological_notes': [
|
| 418 |
+
"Do ponto de vista metodológico, ",
|
| 419 |
+
"Considerando a abordagem adotada, ",
|
| 420 |
+
"A partir de uma perspectiva empírica, ",
|
| 421 |
+
"Teoricamente fundamentado em, ",
|
| 422 |
+
],
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
def __init__(self, config: NLPAdvancedConfig):
|
| 426 |
+
self.config = config
|
| 427 |
+
self.academic_detector = AcademicTermDetector()
|
| 428 |
+
|
| 429 |
+
def modify_prompt(self, original_prompt: str, semantic_analysis: Dict[str, Any],
|
| 430 |
+
user_context: Optional[Dict[str, Any]] = None) -> str:
|
| 431 |
+
"""Modifica o prompt para nível acadêmico se necessário"""
|
| 432 |
+
|
| 433 |
+
if not self.config.enable_context_enhancement:
|
| 434 |
+
return original_prompt
|
| 435 |
+
|
| 436 |
+
# Detect academic context
|
| 437 |
+
academic_info = self.academic_detector.detect(original_prompt)
|
| 438 |
+
|
| 439 |
+
# If academic, enhance the prompt
|
| 440 |
+
if academic_info['is_academic'] and academic_info['academic_confidence'] > self.config.confidence_threshold:
|
| 441 |
+
enhanced_prompt = self._academicize(original_prompt, academic_info, semantic_analysis)
|
| 442 |
+
return enhanced_prompt
|
| 443 |
+
|
| 444 |
+
return original_prompt
|
| 445 |
+
|
| 446 |
+
def _academicize(self, prompt: str, academic_info: Dict[str, Any],
|
| 447 |
+
semantic: Dict[str, Any]) -> str:
|
| 448 |
+
"""Converte prompt para formato acadêmico"""
|
| 449 |
+
|
| 450 |
+
# Add formal introduction if prompt is short
|
| 451 |
+
if len(prompt.split()) < 20:
|
| 452 |
+
intro = np.random.choice(self.ACADEMIC_ENHANCEMENTS['formal_intro'])
|
| 453 |
+
prompt = intro + prompt
|
| 454 |
+
|
| 455 |
+
# Add academic bridging if continuing discussion
|
| 456 |
+
if semantic.get('context_coherence', 0) > 0.3:
|
| 457 |
+
bridge = np.random.choice(self.ACADEMIC_ENHANCEMENTS['academic_bridges'])
|
| 458 |
+
prompt = prompt + " " + bridge.rstrip(',') + ", "
|
| 459 |
+
|
| 460 |
+
# Enhance with methodological note if appropriate
|
| 461 |
+
if academic_info['academic_level'] in ['phd', 'masters']:
|
| 462 |
+
method_note = np.random.choice(self.ACADEMIC_ENHANCEMENTS['methodological_notes'])
|
| 463 |
+
prompt = method_note + prompt
|
| 464 |
+
|
| 465 |
+
return prompt
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
class ResponseModifier:
|
| 469 |
+
"""Modificador de respostas para nível acadêmico"""
|
| 470 |
+
|
| 471 |
+
def __init__(self, config: NLPAdvancedConfig):
|
| 472 |
+
self.config = config
|
| 473 |
+
self.academic_detector = AcademicTermDetector()
|
| 474 |
+
|
| 475 |
+
def modify_response(self, response: str, original_prompt: str,
|
| 476 |
+
semantic_analysis: Dict[str, Any]) -> str:
|
| 477 |
+
"""Modifica a resposta da API se necessário"""
|
| 478 |
+
|
| 479 |
+
if not self.config.enable_response_modification:
|
| 480 |
+
return response
|
| 481 |
+
|
| 482 |
+
academic_info = self.academic_detector.detect(original_prompt)
|
| 483 |
+
|
| 484 |
+
# If academic context, enhance response
|
| 485 |
+
if academic_info['is_academic']:
|
| 486 |
+
enhanced = self._academicize_response(response, academic_info, semantic_analysis)
|
| 487 |
+
return enhanced
|
| 488 |
+
|
| 489 |
+
return response
|
| 490 |
+
|
| 491 |
+
def _academicize_response(self, response: str, academic_info: Dict[str, Any],
|
| 492 |
+
semantic: Dict[str, Any]) -> str:
|
| 493 |
+
"""Academiciza a resposta"""
|
| 494 |
+
|
| 495 |
+
# Add nuance if response is too simplistic
|
| 496 |
+
if semantic.get('complexity', {}).get('complexity_level') == 'low':
|
| 497 |
+
response = self._add_nuance(response, academic_info)
|
| 498 |
+
|
| 499 |
+
# Add critical thinking element
|
| 500 |
+
if academic_info['academic_level'] in ['phd', 'masters']:
|
| 501 |
+
response = self._add_critical_element(response, academic_info)
|
| 502 |
+
|
| 503 |
+
return response
|
| 504 |
+
|
| 505 |
+
def _add_nuance(self, response: str, academic_info: Dict[str, Any]) -> str:
|
| 506 |
+
"""Adiciona nuances à resposta"""
|
| 507 |
+
nuances = [
|
| 508 |
+
" do ponto de vista teórico, ",
|
| 509 |
+
" considerando as variáveis relevantes, ",
|
| 510 |
+
" observadas as devidas ressalvas, ",
|
| 511 |
+
" ressalvados os limites da análise, ",
|
| 512 |
+
]
|
| 513 |
+
|
| 514 |
+
if len(response.split()) < 15:
|
| 515 |
+
nuance = np.random.choice(nuances)
|
| 516 |
+
# Insert nuance somewhere in the response
|
| 517 |
+
words = response.split()
|
| 518 |
+
insert_pos = len(words) // 2
|
| 519 |
+
words.insert(insert_pos, nuance.strip())
|
| 520 |
+
response = ' '.join(words)
|
| 521 |
+
|
| 522 |
+
return response
|
| 523 |
+
|
| 524 |
+
def _add_critical_element(self, response: str, academic_info: Dict[str, Any]) -> str:
|
| 525 |
+
"""Adiciona elemento de pensamento crítico"""
|
| 526 |
+
critical_elements = [
|
| 527 |
+
"\n\nNota crítica: Esta análise pressupõe X, mas Y pode desafiar essa conclusão.",
|
| 528 |
+
"\n\nConsiderando as limitações metodológicas, os resultados devem ser interpretados com cautela.",
|
| 529 |
+
"\nDo ponto de vista epistemológico, cabe questionar: quais as premissas subjacentes?",
|
| 530 |
+
]
|
| 531 |
+
|
| 532 |
+
if len(response.split()) > 30:
|
| 533 |
+
element = np.random.choice(critical_elements)
|
| 534 |
+
response = response + element
|
| 535 |
+
|
| 536 |
+
return response
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
class EmotionAmplifier:
|
| 540 |
+
"""Amplificador de emoções para modelo de moções"""
|
| 541 |
+
|
| 542 |
+
EMOTION_MAPPING = {
|
| 543 |
+
'joy': {
|
| 544 |
+
'intensity_words': ['muito', 'bastante', 'extremamente', 'intensamente'],
|
| 545 |
+
'action_words': ['celebrar', 'comemorar', 'alegrar-se'],
|
| 546 |
+
},
|
| 547 |
+
'sadness': {
|
| 548 |
+
'intensity_words': ['profundamente', 'intensamente', ['muito']],
|
| 549 |
+
'action_words': ['lamentar', 'entristecer-se', 'afligir-se'],
|
| 550 |
+
},
|
| 551 |
+
'anger': {
|
| 552 |
+
'intensity_words': ['intensamente', 'bastante', 'muito'],
|
| 553 |
+
'action_words': ['irritar-se', 'enfurecer-se', 'indignar-se'],
|
| 554 |
+
},
|
| 555 |
+
'fear': {
|
| 556 |
+
'intensity_words': ['bastante', 'muito', 'intensamente'],
|
| 557 |
+
'action_words': ['preocupar-se', 'ansiar', 'temer'],
|
| 558 |
+
},
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
def __init__(self, config: NLPAdvancedConfig):
|
| 562 |
+
self.config = config
|
| 563 |
+
|
| 564 |
+
def amplify(self, emotion_data: Dict[str, Any], text: str) -> Dict[str, Any]:
|
| 565 |
+
"""Amplifica a detecção emocional"""
|
| 566 |
+
|
| 567 |
+
if not self.config.enable_emotion_amplification:
|
| 568 |
+
return emotion_data
|
| 569 |
+
|
| 570 |
+
emotion = emotion_data.get('emotion', 'neutral')
|
| 571 |
+
|
| 572 |
+
if emotion in self.EMOTION_MAPPING:
|
| 573 |
+
mapping = self.EMOTION_MAPPING[emotion]
|
| 574 |
+
|
| 575 |
+
# Check for intensity words
|
| 576 |
+
text_lower = text.lower()
|
| 577 |
+
intensity_count = sum(1 for w in mapping['intensity_words'] if w in text_lower)
|
| 578 |
+
|
| 579 |
+
if intensity_count > 0:
|
| 580 |
+
# Amplify the emotion
|
| 581 |
+
original_confidence = emotion_data.get('confidence', 0.5)
|
| 582 |
+
amplified_confidence = min(0.98, original_confidence + (intensity_count * 0.1))
|
| 583 |
+
|
| 584 |
+
emotion_data['confidence'] = amplified_confidence
|
| 585 |
+
emotion_data['intensity'] = 'high' if intensity_count >= 2 else 'medium'
|
| 586 |
+
emotion_data['amplified'] = True
|
| 587 |
+
else:
|
| 588 |
+
emotion_data['intensity'] = 'low'
|
| 589 |
+
emotion_data['amplified'] = False
|
| 590 |
+
|
| 591 |
+
return emotion_data
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
class AdvancedNLP:
|
| 595 |
+
"""Sistema NLP Avançado Principal"""
|
| 596 |
+
|
| 597 |
+
def __init__(self, config: Optional[NLPAdvancedConfig] = None):
|
| 598 |
+
self.config = config or NLPAdvancedConfig()
|
| 599 |
+
|
| 600 |
+
self.semantic_analyzer = SemanticAnalyzer()
|
| 601 |
+
self.prompt_modifier = PromptModifier(self.config)
|
| 602 |
+
self.response_modifier = ResponseModifier(self.config)
|
| 603 |
+
self.emotion_amplifier = EmotionAmplifier(self.config)
|
| 604 |
+
self.academic_detector = AcademicTermDetector()
|
| 605 |
+
|
| 606 |
+
# Statistics
|
| 607 |
+
self.stats = {
|
| 608 |
+
'total_analyses': 0,
|
| 609 |
+
'academic_prompts': 0,
|
| 610 |
+
'modified_prompts': 0,
|
| 611 |
+
'modified_responses': 0,
|
| 612 |
+
'avg_confidence': 0.0
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
def process_input(self, text: str, context: Optional[List[str]] = None,
|
| 616 |
+
user_info: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
| 617 |
+
"""Processa entrada completa"""
|
| 618 |
+
|
| 619 |
+
self.stats['total_analyses'] += 1
|
| 620 |
+
|
| 621 |
+
# Semantic analysis
|
| 622 |
+
semantic = self.semantic_analyzer.analyze(text, context)
|
| 623 |
+
|
| 624 |
+
# Academic detection
|
| 625 |
+
academic = self.academic_detector.detect(text)
|
| 626 |
+
if academic['is_academic']:
|
| 627 |
+
self.stats['academic_prompts'] += 1
|
| 628 |
+
|
| 629 |
+
# Prompt modification
|
| 630 |
+
modified_prompt = self.prompt_modifier.modify_prompt(text, semantic, user_info)
|
| 631 |
+
if modified_prompt != text:
|
| 632 |
+
self.stats['modified_prompts'] += 1
|
| 633 |
+
|
| 634 |
+
# Emotion amplification
|
| 635 |
+
emotion_data = semantic.get('sentiment', {})
|
| 636 |
+
amplified_emotion = self.emotion_amplifier.amplify(emotion_data, text)
|
| 637 |
+
|
| 638 |
+
return {
|
| 639 |
+
'original_text': text,
|
| 640 |
+
'modified_prompt': modified_prompt,
|
| 641 |
+
'semantic_analysis': semantic,
|
| 642 |
+
'academic_info': academic,
|
| 643 |
+
'emotion_data': amplified_emotion,
|
| 644 |
+
'needs_academic_mode': academic['is_academic'] and academic['academic_confidence'] > 0.7,
|
| 645 |
+
'academic_level': academic['academic_level'],
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
def process_output(self, response: str, original_prompt: str,
|
| 649 |
+
semantic: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
| 650 |
+
"""Processa saída (modifica resposta se necessário)"""
|
| 651 |
+
|
| 652 |
+
modified_response = self.response_modifier.modify_response(
|
| 653 |
+
response, original_prompt, semantic or {}
|
| 654 |
+
)
|
| 655 |
+
|
| 656 |
+
if modified_response != response:
|
| 657 |
+
self.stats['modified_responses'] += 1
|
| 658 |
+
|
| 659 |
+
return {
|
| 660 |
+
'original_response': response,
|
| 661 |
+
'modified_response': modified_response,
|
| 662 |
+
'was_modified': modified_response != response,
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
def get_stats(self) -> Dict[str, Any]:
|
| 666 |
+
"""Retorna estatísticas"""
|
| 667 |
+
stats = self.stats.copy()
|
| 668 |
+
stats['avg_confidence'] = (
|
| 669 |
+
stats['academic_prompts'] / max(stats['total_analyses'], 1)
|
| 670 |
+
)
|
| 671 |
+
return stats
|
| 672 |
+
|
| 673 |
+
|
| 674 |
+
# ============================================================
|
| 675 |
+
# 🔄 SINGLETON
|
| 676 |
+
# ============================================================
|
| 677 |
+
|
| 678 |
+
_advanced_nlp: Optional[AdvancedNLP] = None
|
| 679 |
+
|
| 680 |
+
def get_advanced_nlp(config: Optional[NLPAdvancedConfig] = None) -> AdvancedNLP:
|
| 681 |
+
"""Obtém instância do NLP Avançado"""
|
| 682 |
+
global _advanced_nlp
|
| 683 |
+
if _advanced_nlp is None:
|
| 684 |
+
_advanced_nlp = AdvancedNLP(config)
|
| 685 |
+
return _advanced_nlp
|
| 686 |
+
|
| 687 |
+
|
| 688 |
+
# ============================================================
|
| 689 |
+
# 🎯 EXPORTAÇÃO
|
| 690 |
+
# ============================================================
|
| 691 |
+
|
| 692 |
+
__all__ = [
|
| 693 |
+
'NLPAdvancedConfig',
|
| 694 |
+
'AcademicTermDetector',
|
| 695 |
+
'SemanticAnalyzer',
|
| 696 |
+
'PromptModifier',
|
| 697 |
+
'ResponseModifier',
|
| 698 |
+
'EmotionAmplifier',
|
| 699 |
+
'AdvancedNLP',
|
| 700 |
+
'get_advanced_nlp',
|
| 701 |
+
]
|
modules/persona_tracker.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import threading
|
| 3 |
+
from loguru import logger
|
| 4 |
+
from typing import List, Dict, Any, Optional
|
| 5 |
+
|
| 6 |
+
try:
|
| 7 |
+
from modules.database import Database
|
| 8 |
+
except ImportError:
|
| 9 |
+
from database import Database
|
| 10 |
+
|
| 11 |
+
class PersonaTracker:
|
| 12 |
+
"""
|
| 13 |
+
Rastreador de Persona em Background (Character.AI style LTM).
|
| 14 |
+
Analisa as conversas recentes do usuário silenciosamente e extrai
|
| 15 |
+
seus traços de personalidade, gostos e emoções no banco de dados.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
def __init__(self, db: Database, llm_client: Any):
|
| 19 |
+
"""
|
| 20 |
+
Args:
|
| 21 |
+
db (Database): Instância do banco de dados (database.py)
|
| 22 |
+
llm_client (Any): Instância do cliente LLM (ex: MultiLLMClient)
|
| 23 |
+
"""
|
| 24 |
+
self.db = db
|
| 25 |
+
self.llm_client = llm_client
|
| 26 |
+
self.processing_users = set()
|
| 27 |
+
|
| 28 |
+
def track_background(self, numero_usuario: str, historico_recente: List[Dict[str, str]]) -> None:
|
| 29 |
+
"""
|
| 30 |
+
Dispara a análise de persona em background para não bloquear a resposta do bot.
|
| 31 |
+
|
| 32 |
+
Args:
|
| 33 |
+
numero_usuario: ID ou número do usuário.
|
| 34 |
+
historico_recente: Lista de dicionários {'role': '...', 'content': '...'} com as últimas mensagens do usuário.
|
| 35 |
+
"""
|
| 36 |
+
if numero_usuario in self.processing_users:
|
| 37 |
+
return # Já está a ser analisado neste momento
|
| 38 |
+
|
| 39 |
+
if not historico_recente or len(historico_recente) < 3:
|
| 40 |
+
return # Muito pouco contexto para extrair algo útil
|
| 41 |
+
|
| 42 |
+
self.processing_users.add(numero_usuario)
|
| 43 |
+
|
| 44 |
+
thread = threading.Thread(
|
| 45 |
+
target=self._analyze_and_save,
|
| 46 |
+
args=(numero_usuario, historico_recente),
|
| 47 |
+
daemon=True
|
| 48 |
+
)
|
| 49 |
+
thread.start()
|
| 50 |
+
|
| 51 |
+
def _analyze_and_save(self, numero_usuario: str, historico: List[Dict[str, str]]) -> None:
|
| 52 |
+
"""Método interno que roda na Thread."""
|
| 53 |
+
try:
|
| 54 |
+
# Recupera a persona atual para o LLM saber o que já sabemos
|
| 55 |
+
persona_atual = self.db.recuperar_persona(numero_usuario) or {}
|
| 56 |
+
|
| 57 |
+
# Formata histórico apenas com as falas do usuário
|
| 58 |
+
user_messages = [msg['content'] for msg in historico if msg.get('role') == 'user']
|
| 59 |
+
if not user_messages:
|
| 60 |
+
return
|
| 61 |
+
|
| 62 |
+
historico_texto = "\n".join([f"User: {msg}" for msg in user_messages[-10:]]) # Últimas 10 msg
|
| 63 |
+
|
| 64 |
+
perfil_atual_str = json.dumps(persona_atual, ensure_ascii=False) if persona_atual else "Ainda não definido."
|
| 65 |
+
|
| 66 |
+
prompt = f"""Você é um analista comportamental focado em rastreamento de persona (Long-Term Memory).
|
| 67 |
+
Analise as mensagens recentes deste usuário e atualize/extraia o seu perfil.
|
| 68 |
+
|
| 69 |
+
[PERFIL ATUAL NO BANCO DE DADOS]
|
| 70 |
+
{perfil_atual_str}
|
| 71 |
+
|
| 72 |
+
[MENSAGENS RECENTES]
|
| 73 |
+
{historico_texto}
|
| 74 |
+
|
| 75 |
+
EXTRAIA/ATUALIZE os seguintes traços com base APENAS nas mensagens recentes e no perfil atual. Mantenha os traços do perfil atual que não foram contraditórios.
|
| 76 |
+
Seja CONCISO. Use bullet points curtos na sua mente e preencha os campos em formato JSON estrito.
|
| 77 |
+
|
| 78 |
+
Retorne APENAS um JSON válido estruturado assim (e NADA de texto fora das chaves):
|
| 79 |
+
{{
|
| 80 |
+
"personalidade": "Resumo calmo, agressivo, divertido, direto, etc.",
|
| 81 |
+
"vicios_linguagem": "Expressões ou gírias que ele usa muito.",
|
| 82 |
+
"gostos": "O que ele demonstrou gostar ou tópicos de interesse.",
|
| 83 |
+
"desgostos": "O que o irrita, o que ele odeia.",
|
| 84 |
+
"emocional": "Traços emocionais, forças ou gatilhos/fraquezas."
|
| 85 |
+
}}
|
| 86 |
+
"""
|
| 87 |
+
|
| 88 |
+
# Chama o LLM (garante formato json)
|
| 89 |
+
# O MultiLLMClient / AkiraAPI tem _generate_response(prompt, context_history)
|
| 90 |
+
response_json_str = self.llm_client._generate_response(prompt, [])
|
| 91 |
+
|
| 92 |
+
if not response_json_str:
|
| 93 |
+
return
|
| 94 |
+
|
| 95 |
+
# Extrai o JSON (caso o LLM coloque blocos de markdown)
|
| 96 |
+
response_json_str = response_json_str.strip()
|
| 97 |
+
if response_json_str.startswith("```json"):
|
| 98 |
+
response_json_str = response_json_str.split("```json")[1]
|
| 99 |
+
if response_json_str.endswith("```"):
|
| 100 |
+
response_json_str = response_json_str[:response_json_str.rfind("```")]
|
| 101 |
+
|
| 102 |
+
dados_extraidos = json.loads(response_json_str.strip())
|
| 103 |
+
|
| 104 |
+
# Limpa chaves inválidas
|
| 105 |
+
chaves_validas = ["personalidade", "vicios_linguagem", "gostos", "desgostos", "emocional"]
|
| 106 |
+
campos_atualizar = {k: str(v) for k, v in dados_extraidos.items() if k in chaves_validas}
|
| 107 |
+
|
| 108 |
+
if campos_atualizar:
|
| 109 |
+
sucesso = self.db.atualizar_persona(numero_usuario, campos_atualizar)
|
| 110 |
+
if sucesso:
|
| 111 |
+
logger.info(f"Persona LTM atualizada para o usuário {numero_usuario} em background.")
|
| 112 |
+
else:
|
| 113 |
+
logger.warning(f"Falha ao salvar a persona no banco para {numero_usuario}.")
|
| 114 |
+
|
| 115 |
+
except json.JSONDecodeError:
|
| 116 |
+
logger.warning(f"Falha no Parser JSON do Persona Tracker para {numero_usuario}.")
|
| 117 |
+
except Exception as e:
|
| 118 |
+
logger.error(f"Erro no Persona Tracker background: {e}")
|
| 119 |
+
finally:
|
| 120 |
+
if numero_usuario in self.processing_users:
|
| 121 |
+
self.processing_users.remove(numero_usuario)
|
modules/reply_context_handler.py
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# type: ignore
|
| 2 |
+
"""
|
| 3 |
+
================================================================================
|
| 4 |
+
AKIRA V21 ULTIMATE - REPLY CONTEXT HANDLER MODULE
|
| 5 |
+
================================================================================
|
| 6 |
+
Sistema dedicado para processar e priorizar contexto de replies.
|
| 7 |
+
Garante que replies tenham prioridade ligeiramente maior que o contexto geral,
|
| 8 |
+
especialmente em perguntas curtas.
|
| 9 |
+
|
| 10 |
+
Features:
|
| 11 |
+
- Extração e processamento de metadados de reply
|
| 12 |
+
- 3 níveis de prioridade (1=normal, 2=reply, 3=reply-to-bot+pergunta-curta)
|
| 13 |
+
- Construção de prompt sections otimizadas para replies
|
| 14 |
+
- Integração com ShortTermMemory
|
| 15 |
+
- Context hint extraction para melhor compreensão
|
| 16 |
+
================================================================================
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
import sys
|
| 21 |
+
import time
|
| 22 |
+
import json
|
| 23 |
+
import re
|
| 24 |
+
import logging
|
| 25 |
+
from typing import Optional, Dict, Any, List, Tuple
|
| 26 |
+
from dataclasses import dataclass, field
|
| 27 |
+
|
| 28 |
+
# Imports robustos com fallback - CORRIGIDO para usar modules.
|
| 29 |
+
try:
|
| 30 |
+
import modules.config as config
|
| 31 |
+
from .short_term_memory import ShortTermMemory, MessageWithContext, IMPORTANCIA_REPLY, IMPORTANCIA_REPLY_TO_BOT, IMPORTANCIA_PERGUNTA_CURTA_REPLY
|
| 32 |
+
REPLY_HANDLER_AVAILABLE = True
|
| 33 |
+
except ImportError:
|
| 34 |
+
try:
|
| 35 |
+
from . import config
|
| 36 |
+
from .short_term_memory import ShortTermMemory, MessageWithContext
|
| 37 |
+
REPLY_HANDLER_AVAILABLE = True
|
| 38 |
+
except ImportError:
|
| 39 |
+
REPLY_HANDLER_AVAILABLE = False
|
| 40 |
+
config = None
|
| 41 |
+
|
| 42 |
+
logger = logging.getLogger(__name__)
|
| 43 |
+
|
| 44 |
+
# ============================================================
|
| 45 |
+
# NÍVEIS DE PRIORIDADE
|
| 46 |
+
# ============================================================
|
| 47 |
+
|
| 48 |
+
PRIORITY_NORMAL = 1
|
| 49 |
+
PRIORITY_REPLY = 2
|
| 50 |
+
PRIORITY_REPLY_TO_BOT = 3
|
| 51 |
+
PRIORITY_REPLY_TO_BOT_SHORT_QUESTION = 4 # Prioridade máxima!
|
| 52 |
+
|
| 53 |
+
# Limite de palavras para "pergunta curta"
|
| 54 |
+
PERGUNTA_CURTA_LIMITE: int = 5
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@dataclass
|
| 58 |
+
class ProcessedReplyContext:
|
| 59 |
+
"""
|
| 60 |
+
Contexto de reply processado e pronto para uso.
|
| 61 |
+
|
| 62 |
+
Attributes:
|
| 63 |
+
is_reply: Se é um reply
|
| 64 |
+
reply_to_bot: Se é reply direcionado ao bot
|
| 65 |
+
priority_level: Nível de prioridade (1-4)
|
| 66 |
+
quoted_author_name: Nome do autor da mensagem citada
|
| 67 |
+
quoted_author_numero: Número do autor
|
| 68 |
+
quoted_text_original: Texto original citado
|
| 69 |
+
mensagem_citada: Texto da mensagem citada
|
| 70 |
+
context_hint: Hint de contexto extraído
|
| 71 |
+
importancia: Peso de importância calculado
|
| 72 |
+
prompt_section: Section formatada para o prompt
|
| 73 |
+
should_prioritize_reply: Se deve priorizar no prompt
|
| 74 |
+
adaptive_multiplier: Multiplicador adaptativo baseado no tamanho
|
| 75 |
+
"""
|
| 76 |
+
is_reply: bool = False
|
| 77 |
+
reply_to_bot: bool = False
|
| 78 |
+
priority_level: int = PRIORITY_NORMAL
|
| 79 |
+
quoted_author_name: str = ""
|
| 80 |
+
quoted_author_numero: str = ""
|
| 81 |
+
quoted_text_original: str = ""
|
| 82 |
+
mensagem_citada: str = ""
|
| 83 |
+
context_hint: str = ""
|
| 84 |
+
importancia: float = 1.0
|
| 85 |
+
prompt_section: str = ""
|
| 86 |
+
should_prioritize_reply: bool = False
|
| 87 |
+
adaptive_multiplier: float = 1.0
|
| 88 |
+
|
| 89 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 90 |
+
"""Converte para dicionário."""
|
| 91 |
+
return {
|
| 92 |
+
"is_reply": self.is_reply,
|
| 93 |
+
"reply_to_bot": self.reply_to_bot,
|
| 94 |
+
"priority_level": self.priority_level,
|
| 95 |
+
"quoted_author_name": self.quoted_author_name,
|
| 96 |
+
"quoted_author_numero": self.quoted_author_numero,
|
| 97 |
+
"quoted_text_original": self.quoted_text_original,
|
| 98 |
+
"mensagem_citada": self.mensagem_citada,
|
| 99 |
+
"context_hint": self.context_hint,
|
| 100 |
+
"importancia": self.importancia,
|
| 101 |
+
"prompt_section": self.prompt_section,
|
| 102 |
+
"should_prioritize_reply": self.should_prioritize_reply,
|
| 103 |
+
"adaptive_multiplier": self.adaptive_multiplier
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
@classmethod
|
| 107 |
+
def from_dict(cls, data: Dict[str, Any]) -> 'ProcessedReplyContext':
|
| 108 |
+
"""Cria instância a partir de dicionário."""
|
| 109 |
+
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ============================================================
|
| 113 |
+
# FUNÇÕES AUXILIARES
|
| 114 |
+
# ============================================================
|
| 115 |
+
|
| 116 |
+
def contar_palavras(texto: str) -> int:
|
| 117 |
+
"""Conta palavras em um texto."""
|
| 118 |
+
if not texto:
|
| 119 |
+
return 0
|
| 120 |
+
return len(texto.split())
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def is_pergunta_curta(texto: str) -> bool:
|
| 124 |
+
"""
|
| 125 |
+
Verifica se o texto é uma pergunta curta.
|
| 126 |
+
|
| 127 |
+
Args:
|
| 128 |
+
texto: Texto a verificar
|
| 129 |
+
|
| 130 |
+
Returns:
|
| 131 |
+
True se for pergunta com pocas palavras
|
| 132 |
+
"""
|
| 133 |
+
if not texto:
|
| 134 |
+
return False
|
| 135 |
+
|
| 136 |
+
texto_lower = texto.strip().lower()
|
| 137 |
+
word_count = contar_palavras(texto)
|
| 138 |
+
|
| 139 |
+
# Deve ter marcador de pergunta ou palavras interrogativas
|
| 140 |
+
has_question_marker = '?' in texto
|
| 141 |
+
has_interrogative = any(w in texto_lower for w in [
|
| 142 |
+
'qual', 'quais', 'quem', 'como', 'onde', 'quando', 'por que',
|
| 143 |
+
'porque', 'para que', 'o que', 'que', 'é o que', 'vc', 'você',
|
| 144 |
+
'tu', 'meu', 'minha', 'oq', 'oq', 'n'
|
| 145 |
+
])
|
| 146 |
+
|
| 147 |
+
return word_count <= PERGUNTA_CURTA_LIMITE and (has_question_marker or has_interrogative)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def extrair_context_hint(quoted_text: str, mensagem_atual: str) -> str:
|
| 151 |
+
"""
|
| 152 |
+
Extrai hint de contexto baseado no texto citado e mensagem atual.
|
| 153 |
+
|
| 154 |
+
Args:
|
| 155 |
+
quoted_text: Texto original citado
|
| 156 |
+
mensagem_atual: Mensagem atual do usuário
|
| 157 |
+
|
| 158 |
+
Returns:
|
| 159 |
+
String de hint de contexto
|
| 160 |
+
"""
|
| 161 |
+
hints = []
|
| 162 |
+
|
| 163 |
+
# Detecta tipo de reply
|
| 164 |
+
quoted_lower = quoted_text.lower() if quoted_text else ""
|
| 165 |
+
|
| 166 |
+
# Pergunta sobre o bot
|
| 167 |
+
if any(w in quoted_lower for w in ['akira', 'bot', 'você', 'vc', 'tu']):
|
| 168 |
+
hints.append("pergunta_sobre_akira")
|
| 169 |
+
|
| 170 |
+
# Pergunta factual
|
| 171 |
+
if any(w in quoted_lower for w in ['oq', 'o que', 'qual', 'quanto', 'onde', 'quando']):
|
| 172 |
+
hints.append("pergunta_factual")
|
| 173 |
+
|
| 174 |
+
# Ironia/deboche detectado
|
| 175 |
+
if any(w in quoted_lower for w in ['kkk', 'haha', '😂', '🤣', 'eita']):
|
| 176 |
+
hints.append("tom_irreverente")
|
| 177 |
+
|
| 178 |
+
# Expressão de opinião
|
| 179 |
+
if any(w in quoted_lower for w in ['acho', 'penso', 'creio', 'imagino']):
|
| 180 |
+
hints.append("expressao_opiniao")
|
| 181 |
+
|
| 182 |
+
return " | ".join(hints) if hints else "contexto_geral"
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def calcular_prioridade(
|
| 186 |
+
is_reply: bool,
|
| 187 |
+
reply_to_bot: bool,
|
| 188 |
+
mensagem: str,
|
| 189 |
+
quoted_text: str = ""
|
| 190 |
+
) -> Tuple[int, float]:
|
| 191 |
+
"""
|
| 192 |
+
Calcula nível de prioridade e importância.
|
| 193 |
+
|
| 194 |
+
Args:
|
| 195 |
+
is_reply: Se é um reply
|
| 196 |
+
reply_to_bot: Se é reply para o bot
|
| 197 |
+
mensagem: Mensagem atual
|
| 198 |
+
quoted_text: Texto citado
|
| 199 |
+
|
| 200 |
+
Returns:
|
| 201 |
+
Tupla (priority_level, importancia)
|
| 202 |
+
"""
|
| 203 |
+
if not is_reply:
|
| 204 |
+
return PRIORITY_NORMAL, 1.0
|
| 205 |
+
|
| 206 |
+
# Reply para o bot
|
| 207 |
+
if reply_to_bot:
|
| 208 |
+
# Pergunta curta = prioridade máxima
|
| 209 |
+
if is_pergunta_curta(mensagem):
|
| 210 |
+
return PRIORITY_REPLY_TO_BOT_SHORT_QUESTION, IMPORTANCIA_PERGUNTA_CURTA_REPLY
|
| 211 |
+
# Reply normal ao bot
|
| 212 |
+
return PRIORITY_REPLY_TO_BOT, IMPORTANCIA_REPLY_TO_BOT
|
| 213 |
+
|
| 214 |
+
# Reply para outro usuário
|
| 215 |
+
return PRIORITY_REPLY, IMPORTANCIA_REPLY
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
# ============================================================
|
| 219 |
+
# CLASSE PRINCIPAL
|
| 220 |
+
# ============================================================
|
| 221 |
+
|
| 222 |
+
class ReplyContextHandler:
|
| 223 |
+
"""
|
| 224 |
+
Handler dedicado para processar e priorizar contexto de replies.
|
| 225 |
+
|
| 226 |
+
Funcionalidades:
|
| 227 |
+
- Extração de metadados de reply do payload
|
| 228 |
+
- Cálculo automático de prioridade
|
| 229 |
+
- Construção de seções de prompt otimizadas
|
| 230 |
+
- Integração com ShortTermMemory
|
| 231 |
+
- Ajuste adaptativo baseado em tamanho da pergunta
|
| 232 |
+
"""
|
| 233 |
+
|
| 234 |
+
def __init__(self, short_term_memory: Optional[ShortTermMemory] = None):
|
| 235 |
+
"""
|
| 236 |
+
Inicializa o handler.
|
| 237 |
+
|
| 238 |
+
Args:
|
| 239 |
+
short_term_memory: Instância de ShortTermMemory (opcional)
|
| 240 |
+
"""
|
| 241 |
+
self.short_term_memory = short_term_memory
|
| 242 |
+
logger.debug("✅ ReplyContextHandler inicializado")
|
| 243 |
+
|
| 244 |
+
def process_reply(
|
| 245 |
+
self,
|
| 246 |
+
mensagem: str,
|
| 247 |
+
reply_metadata: Dict[str, Any],
|
| 248 |
+
historico_geral: Optional[List[Dict[str, Any]]] = None
|
| 249 |
+
) -> ProcessedReplyContext:
|
| 250 |
+
"""
|
| 251 |
+
Processa metadados de reply e gera contexto processado.
|
| 252 |
+
|
| 253 |
+
Args:
|
| 254 |
+
mensagem: Mensagem atual do usuário
|
| 255 |
+
reply_metadata: Metadados do reply do payload
|
| 256 |
+
historico_geral: Histórico geral (opcional)
|
| 257 |
+
|
| 258 |
+
Returns:
|
| 259 |
+
ProcessedReplyContext pronto para uso
|
| 260 |
+
"""
|
| 261 |
+
# Extrai dados do metadata
|
| 262 |
+
is_reply = reply_metadata.get('is_reply', False)
|
| 263 |
+
reply_to_bot = reply_metadata.get('reply_to_bot', False)
|
| 264 |
+
quoted_author_name = reply_metadata.get('quoted_author_name', '')
|
| 265 |
+
quoted_author_numero = reply_metadata.get('quoted_author_numero', '')
|
| 266 |
+
quoted_text_original = reply_metadata.get('quoted_text_original', '')
|
| 267 |
+
mensagem_citada = reply_metadata.get('mensagem_citada', '') or quoted_text_original
|
| 268 |
+
|
| 269 |
+
# 🔧 CORREÇÃO: Se autor é desconhecido, tenta detectar pelo contexto
|
| 270 |
+
if not quoted_author_name or quoted_author_name.lower() in ['desconhecido', 'unknown', '']:
|
| 271 |
+
# Detecta pelo conteúdo da mensagem citada
|
| 272 |
+
quoted_lower = quoted_text_original.lower() if quoted_text_original else ""
|
| 273 |
+
|
| 274 |
+
# Se a mensagem citada contém padrões de resposta do bot
|
| 275 |
+
bot_patterns = ['akira:', 'eu sou', 'eu sou a akira', 'sou um bot', 'oi!', 'eae!']
|
| 276 |
+
if any(p in quoted_lower for p in bot_patterns):
|
| 277 |
+
quoted_author_name = "Akira (você mesmo)"
|
| 278 |
+
quoted_author_numero = "BOT"
|
| 279 |
+
reply_to_bot = True
|
| 280 |
+
elif mensagem_citada:
|
| 281 |
+
# Se há histórico, busca última mensagem
|
| 282 |
+
if historico_geral:
|
| 283 |
+
# Assumir que é reply para a última mensagem do bot
|
| 284 |
+
quoted_author_name = "mensagem_anterior"
|
| 285 |
+
quoted_author_numero = "unknown"
|
| 286 |
+
|
| 287 |
+
# Se ainda não tem autor mas tem mensagem citada e é reply
|
| 288 |
+
if is_reply and (not quoted_author_name or quoted_author_name == 'desconhecido'):
|
| 289 |
+
# Se é reply_to_bot=True mas autor desconhecido, assume que é reply para o bot
|
| 290 |
+
if reply_to_bot:
|
| 291 |
+
quoted_author_name = "Akira (você mesmo)"
|
| 292 |
+
quoted_author_numero = "BOT"
|
| 293 |
+
else:
|
| 294 |
+
# Tenta extrair do conteúdo
|
| 295 |
+
quoted_author_name = "participante_desconhecido"
|
| 296 |
+
|
| 297 |
+
# Calcula prioridade e importância
|
| 298 |
+
priority_level, importancia = calcular_prioridade(
|
| 299 |
+
is_reply=is_reply,
|
| 300 |
+
reply_to_bot=reply_to_bot,
|
| 301 |
+
mensagem=mensagem,
|
| 302 |
+
quoted_text=quoted_text_original
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
# Extrai context hint
|
| 306 |
+
context_hint = extrair_context_hint(quoted_text_original, mensagem)
|
| 307 |
+
|
| 308 |
+
# Calcula multiplicador adaptativo
|
| 309 |
+
adaptive_multiplier = self._calculate_adaptive_multiplier(
|
| 310 |
+
mensagem=mensagem,
|
| 311 |
+
is_reply=is_reply,
|
| 312 |
+
priority_level=priority_level
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
# Determina se deve priorizar no prompt
|
| 316 |
+
should_prioritize = is_reply and priority_level >= PRIORITY_REPLY
|
| 317 |
+
|
| 318 |
+
# Constrói section do prompt
|
| 319 |
+
prompt_section = self._build_reply_prompt_section(
|
| 320 |
+
mensagem=mensagem,
|
| 321 |
+
mensagem_citada=mensagem_citada,
|
| 322 |
+
quoted_author_name=quoted_author_name,
|
| 323 |
+
reply_to_bot=reply_to_bot,
|
| 324 |
+
context_hint=context_hint,
|
| 325 |
+
priority_level=priority_level
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
# Cria contexto processado
|
| 329 |
+
reply_context = ProcessedReplyContext(
|
| 330 |
+
is_reply=is_reply,
|
| 331 |
+
reply_to_bot=reply_to_bot,
|
| 332 |
+
priority_level=priority_level,
|
| 333 |
+
quoted_author_name=quoted_author_name,
|
| 334 |
+
quoted_author_numero=quoted_author_numero,
|
| 335 |
+
quoted_text_original=quoted_text_original,
|
| 336 |
+
mensagem_citada=mensagem_citada,
|
| 337 |
+
context_hint=context_hint,
|
| 338 |
+
importancia=importancia * adaptive_multiplier,
|
| 339 |
+
prompt_section=prompt_section,
|
| 340 |
+
should_prioritize_reply=should_prioritize,
|
| 341 |
+
adaptive_multiplier=adaptive_multiplier
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
# Adiciona à memória de curto prazo se disponível
|
| 345 |
+
if self.short_term_memory and is_reply:
|
| 346 |
+
self.short_term_memory.add_message(
|
| 347 |
+
role="user",
|
| 348 |
+
content=mensagem,
|
| 349 |
+
importancia=reply_context.importancia,
|
| 350 |
+
reply_info={
|
| 351 |
+
"is_reply": True,
|
| 352 |
+
"reply_to_bot": reply_to_bot,
|
| 353 |
+
"quoted_text_original": quoted_text_original,
|
| 354 |
+
"priority_level": priority_level
|
| 355 |
+
}
|
| 356 |
+
)
|
| 357 |
+
|
| 358 |
+
return reply_context
|
| 359 |
+
|
| 360 |
+
def _calculate_adaptive_multiplier(
|
| 361 |
+
self,
|
| 362 |
+
mensagem: str,
|
| 363 |
+
is_reply: bool,
|
| 364 |
+
priority_level: int
|
| 365 |
+
) -> float:
|
| 366 |
+
"""
|
| 367 |
+
Calcula multiplicador adaptativo baseado no tamanho da pergunta.
|
| 368 |
+
|
| 369 |
+
Para perguntas curtas com reply, aumenta a importância do contexto do reply
|
| 370 |
+
para garantir que o LLM tenha contexto suficiente.
|
| 371 |
+
|
| 372 |
+
Args:
|
| 373 |
+
mensagem: Mensagem atual
|
| 374 |
+
is_reply: Se é reply
|
| 375 |
+
priority_level: Nível de prioridade
|
| 376 |
+
|
| 377 |
+
Returns:
|
| 378 |
+
Multiplicador entre 1.0 e 2.0
|
| 379 |
+
"""
|
| 380 |
+
if not is_reply:
|
| 381 |
+
return 1.0
|
| 382 |
+
|
| 383 |
+
word_count = contar_palavras(mensagem)
|
| 384 |
+
|
| 385 |
+
# Pergunta muito curta (< 3 palavras) = contexto crítico
|
| 386 |
+
if word_count <= 2:
|
| 387 |
+
return 1.5
|
| 388 |
+
|
| 389 |
+
# Pergunta curta (3-5 palavras) = contexto importante
|
| 390 |
+
if word_count <= PERGUNTA_CURTA_LIMITE:
|
| 391 |
+
return 1.3
|
| 392 |
+
|
| 393 |
+
# Pergunta normal = multiplicador padrão baseado em prioridade
|
| 394 |
+
if priority_level == PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
|
| 395 |
+
return 1.2
|
| 396 |
+
elif priority_level == PRIORITY_REPLY_TO_BOT:
|
| 397 |
+
return 1.1
|
| 398 |
+
|
| 399 |
+
return 1.0
|
| 400 |
+
|
| 401 |
+
def _build_reply_prompt_section(
|
| 402 |
+
self,
|
| 403 |
+
mensagem: str,
|
| 404 |
+
mensagem_citada: str,
|
| 405 |
+
quoted_author_name: str,
|
| 406 |
+
reply_to_bot: bool,
|
| 407 |
+
context_hint: str,
|
| 408 |
+
priority_level: int
|
| 409 |
+
) -> str:
|
| 410 |
+
"""
|
| 411 |
+
Constrói seção formatada do prompt para replies.
|
| 412 |
+
|
| 413 |
+
Args:
|
| 414 |
+
mensagem: Mensagem atual
|
| 415 |
+
mensagem_citada: Texto citado
|
| 416 |
+
quoted_author_name: Nome do autor
|
| 417 |
+
reply_to_bot: Se é reply para o bot
|
| 418 |
+
context_hint: Hint de contexto
|
| 419 |
+
priority_level: Nível de prioridade
|
| 420 |
+
|
| 421 |
+
Returns:
|
| 422 |
+
String formatada para inserção no prompt
|
| 423 |
+
"""
|
| 424 |
+
if not mensagem_citada:
|
| 425 |
+
return ""
|
| 426 |
+
|
| 427 |
+
sections = []
|
| 428 |
+
|
| 429 |
+
# Cabeçalho com nível de prioridade
|
| 430 |
+
if priority_level >= PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
|
| 431 |
+
sections.append("[🔴 REPLY CRÍTICO - PERGUNTA CURTA]")
|
| 432 |
+
elif priority_level == PRIORITY_REPLY_TO_BOT:
|
| 433 |
+
sections.append("[🟡 REPLY AO BOT]")
|
| 434 |
+
elif priority_level == PRIORITY_REPLY:
|
| 435 |
+
sections.append("[🟢 REPLY]")
|
| 436 |
+
|
| 437 |
+
# Contexto do autor
|
| 438 |
+
if reply_to_bot:
|
| 439 |
+
sections.append(f"⚠️ VOCÊ ESTÁ SENDO DIRETAMENTE RESPONDIDO!")
|
| 440 |
+
else:
|
| 441 |
+
sections.append(f"Respondendo a: {quoted_author_name}")
|
| 442 |
+
|
| 443 |
+
# Texto citado
|
| 444 |
+
quoted_preview = mensagem_citada[:150] + ("..." if len(mensagem_citada) > 150 else "")
|
| 445 |
+
sections.append(f"Msg citada: \"{quoted_preview}\"")
|
| 446 |
+
|
| 447 |
+
# Hint de contexto
|
| 448 |
+
if context_hint and context_hint != "contexto_geral":
|
| 449 |
+
sections.append(f"Contexto: {context_hint}")
|
| 450 |
+
|
| 451 |
+
# Instrução de resposta
|
| 452 |
+
if priority_level >= PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
|
| 453 |
+
sections.append("💡 RESPONSE: Contextualize sua resposta usando a mensagem citada!")
|
| 454 |
+
elif reply_to_bot:
|
| 455 |
+
sections.append("💡 RESPONSE: Você foi diretamente mencionado.")
|
| 456 |
+
|
| 457 |
+
return "\n".join(sections)
|
| 458 |
+
|
| 459 |
+
def prioritize_reply_context(
|
| 460 |
+
self,
|
| 461 |
+
prompt: str,
|
| 462 |
+
reply_context: ProcessedReplyContext,
|
| 463 |
+
historico_geral: Optional[List[Dict[str, Any]]] = None
|
| 464 |
+
) -> str:
|
| 465 |
+
"""
|
| 466 |
+
Injeta contexto de reply no prompt com alta prioridade.
|
| 467 |
+
|
| 468 |
+
Args:
|
| 469 |
+
prompt: Prompt original
|
| 470 |
+
reply_context: Contexto de reply processado
|
| 471 |
+
historico_geral: Histórico geral (opcional)
|
| 472 |
+
|
| 473 |
+
Returns:
|
| 474 |
+
Prompt enriquecido com contexto de reply
|
| 475 |
+
"""
|
| 476 |
+
if not reply_context.is_reply or not reply_context.prompt_section:
|
| 477 |
+
return prompt
|
| 478 |
+
|
| 479 |
+
# Insere contexto de reply no início do prompt
|
| 480 |
+
reply_block = f"""
|
| 481 |
+
{'='*60}
|
| 482 |
+
{reply_context.prompt_section}
|
| 483 |
+
{'='*60}
|
| 484 |
+
"""
|
| 485 |
+
|
| 486 |
+
# Determina posição de inserção
|
| 487 |
+
# Se há seção [SYSTEM], insere após ela
|
| 488 |
+
if "[SYSTEM]" in prompt:
|
| 489 |
+
# Encontra final da seção SYSTEM
|
| 490 |
+
system_end = prompt.find("[/SYSTEM]")
|
| 491 |
+
if system_end != -1:
|
| 492 |
+
return prompt[:system_end + 10] + reply_block + prompt[system_end + 10:]
|
| 493 |
+
|
| 494 |
+
# Caso contrário, insere no início
|
| 495 |
+
return reply_block + "\n" + prompt
|
| 496 |
+
|
| 497 |
+
def get_reply_summary_for_llm(self, reply_context: ProcessedReplyContext) -> str:
|
| 498 |
+
"""
|
| 499 |
+
Retorna resumo formatado do reply para contexto do LLM.
|
| 500 |
+
|
| 501 |
+
Args:
|
| 502 |
+
reply_context: Contexto de reply processado
|
| 503 |
+
|
| 504 |
+
Returns:
|
| 505 |
+
String resumida para uso no contexto
|
| 506 |
+
"""
|
| 507 |
+
if not reply_context.is_reply:
|
| 508 |
+
return ""
|
| 509 |
+
|
| 510 |
+
parts = []
|
| 511 |
+
|
| 512 |
+
if reply_context.reply_to_bot:
|
| 513 |
+
parts.append("REPLY DIRETO AO BOT")
|
| 514 |
+
else:
|
| 515 |
+
parts.append(f"REPLY a {reply_context.quoted_author_name}")
|
| 516 |
+
|
| 517 |
+
if reply_context.mensagem_citada:
|
| 518 |
+
cited = reply_context.mensagem_citada[:100]
|
| 519 |
+
parts.append(f"Citando: \"{cited}\"")
|
| 520 |
+
|
| 521 |
+
if reply_context.priority_level >= PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
|
| 522 |
+
parts.append("PERGUNTA CURTA - Prioridade Alta")
|
| 523 |
+
|
| 524 |
+
return " | ".join(parts)
|
| 525 |
+
|
| 526 |
+
def merge_reply_into_history(
|
| 527 |
+
self,
|
| 528 |
+
reply_context: ProcessedReplyContext,
|
| 529 |
+
history: List[Dict[str, str]]
|
| 530 |
+
) -> List[Dict[str, str]]:
|
| 531 |
+
"""
|
| 532 |
+
Mescla contexto de reply no histórico para o LLM.
|
| 533 |
+
|
| 534 |
+
Args:
|
| 535 |
+
reply_context: Contexto de reply processado
|
| 536 |
+
history: Histórico formatado para LLM
|
| 537 |
+
|
| 538 |
+
Returns:
|
| 539 |
+
Histórico com reply injetado no início
|
| 540 |
+
"""
|
| 541 |
+
if not reply_context.is_reply:
|
| 542 |
+
return history
|
| 543 |
+
|
| 544 |
+
# Cria entry para o reply
|
| 545 |
+
reply_entry = {
|
| 546 |
+
"role": "user",
|
| 547 |
+
"content": f"[REPLY] {reply_context.get_reply_summary_for_llm(reply_context)}"
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
# Adiciona texto citado se disponível
|
| 551 |
+
if reply_context.mensagem_citada:
|
| 552 |
+
reply_entry["content"] += f"\n\nMensagem citada:\n{reply_context.mensagem_citada}"
|
| 553 |
+
|
| 554 |
+
# Insere no início do histórico
|
| 555 |
+
return [reply_entry] + history
|
| 556 |
+
|
| 557 |
+
def calculate_token_budget(
|
| 558 |
+
self,
|
| 559 |
+
reply_context: ProcessedReplyContext,
|
| 560 |
+
total_budget: int = 8000
|
| 561 |
+
) -> Tuple[int, int]:
|
| 562 |
+
"""
|
| 563 |
+
Calcula alocação de tokens entre reply e contexto geral.
|
| 564 |
+
|
| 565 |
+
Args:
|
| 566 |
+
reply_context: Contexto de reply
|
| 567 |
+
total_budget: Total de tokens disponíveis
|
| 568 |
+
|
| 569 |
+
Returns:
|
| 570 |
+
Tupla (tokens_para_reply, tokens_para_contexto)
|
| 571 |
+
"""
|
| 572 |
+
if not reply_context.is_reply:
|
| 573 |
+
return 0, total_budget
|
| 574 |
+
|
| 575 |
+
# Pergunta curta com reply = mais tokens para reply
|
| 576 |
+
if reply_context.priority_level >= PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
|
| 577 |
+
reply_tokens = min(1500, int(total_budget * 0.25))
|
| 578 |
+
elif reply_context.reply_to_bot:
|
| 579 |
+
reply_tokens = min(1000, int(total_budget * 0.15))
|
| 580 |
+
else:
|
| 581 |
+
reply_tokens = min(800, int(total_budget * 0.10))
|
| 582 |
+
|
| 583 |
+
return reply_tokens, total_budget - reply_tokens
|
| 584 |
+
|
| 585 |
+
# ============================================================
|
| 586 |
+
# HELPERS PARA API
|
| 587 |
+
# ============================================================
|
| 588 |
+
|
| 589 |
+
@staticmethod
|
| 590 |
+
def extract_reply_metadata_from_request(data: Dict[str, Any]) -> Dict[str, Any]:
|
| 591 |
+
"""
|
| 592 |
+
Extrai metadados de reply de um request da API.
|
| 593 |
+
|
| 594 |
+
Args:
|
| 595 |
+
data: Payload do request
|
| 596 |
+
|
| 597 |
+
Returns:
|
| 598 |
+
Dict com metadados de reply
|
| 599 |
+
"""
|
| 600 |
+
reply_metadata = data.get('reply_metadata', {})
|
| 601 |
+
|
| 602 |
+
# Se não há reply_metadata, tenta extrair de campos individuais
|
| 603 |
+
if not reply_metadata:
|
| 604 |
+
mensagem_citada = data.get('mensagem_citada', '')
|
| 605 |
+
if mensagem_citada:
|
| 606 |
+
reply_metadata = {
|
| 607 |
+
'is_reply': True,
|
| 608 |
+
'quoted_text_original': mensagem_citada,
|
| 609 |
+
'mensagem_citada': mensagem_citada
|
| 610 |
+
}
|
| 611 |
+
else:
|
| 612 |
+
return {'is_reply': False}
|
| 613 |
+
|
| 614 |
+
# Garante campos obrigatórios
|
| 615 |
+
return {
|
| 616 |
+
'is_reply': reply_metadata.get('is_reply', False),
|
| 617 |
+
'reply_to_bot': reply_metadata.get('reply_to_bot', False),
|
| 618 |
+
'quoted_author_name': reply_metadata.get('quoted_author_name', ''),
|
| 619 |
+
'quoted_author_numero': reply_metadata.get('quoted_author_numero', ''),
|
| 620 |
+
'quoted_type': reply_metadata.get('quoted_type', 'texto'),
|
| 621 |
+
'quoted_text_original': reply_metadata.get('quoted_text_original', ''),
|
| 622 |
+
'context_hint': reply_metadata.get('context_hint', ''),
|
| 623 |
+
'mensagem_citada': reply_metadata.get('mensagem_citada', '')
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
def validate_reply_priority(self, reply_context: ProcessedReplyContext) -> bool:
|
| 627 |
+
"""
|
| 628 |
+
Valida se a prioridade calculada está correta.
|
| 629 |
+
|
| 630 |
+
Args:
|
| 631 |
+
reply_context: Contexto a validar
|
| 632 |
+
|
| 633 |
+
Returns:
|
| 634 |
+
True se válido
|
| 635 |
+
"""
|
| 636 |
+
if not reply_context.is_reply:
|
| 637 |
+
return reply_context.priority_level == PRIORITY_NORMAL
|
| 638 |
+
|
| 639 |
+
# Reply para bot + pergunta curta deve ter prioridade máxima
|
| 640 |
+
if reply_context.reply_to_bot and is_pergunta_curta(reply_context.mensagem_citada):
|
| 641 |
+
return reply_context.priority_level == PRIORITY_REPLY_TO_BOT_SHORT_QUESTION
|
| 642 |
+
|
| 643 |
+
# Reply para bot deve ter alta prioridade
|
| 644 |
+
if reply_context.reply_to_bot:
|
| 645 |
+
return reply_context.priority_level >= PRIORITY_REPLY_TO_BOT
|
| 646 |
+
|
| 647 |
+
# Reply normal deve ter prioridade >= 2
|
| 648 |
+
return reply_context.priority_level >= PRIORITY_REPLY
|
| 649 |
+
|
| 650 |
+
def __repr__(self) -> str:
|
| 651 |
+
"""Representação textual."""
|
| 652 |
+
mem_status = "com STM" if self.short_term_memory else "sem STM"
|
| 653 |
+
return f"ReplyContextHandler({mem_status})"
|
| 654 |
+
|
| 655 |
+
|
| 656 |
+
# ============================================================
|
| 657 |
+
# FUNÇÕES DE FÁBRICA
|
| 658 |
+
# ============================================================
|
| 659 |
+
|
| 660 |
+
def criar_reply_handler(
|
| 661 |
+
short_term_memory: Optional[ShortTermMemory] = None
|
| 662 |
+
) -> ReplyContextHandler:
|
| 663 |
+
"""
|
| 664 |
+
Factory function para criar ReplyContextHandler.
|
| 665 |
+
|
| 666 |
+
Args:
|
| 667 |
+
short_term_memory: Instância de ShortTermMemory (opcional)
|
| 668 |
+
|
| 669 |
+
Returns:
|
| 670 |
+
ReplyContextHandler instance
|
| 671 |
+
"""
|
| 672 |
+
return ReplyContextHandler(short_term_memory=short_term_memory)
|
| 673 |
+
|
| 674 |
+
|
| 675 |
+
def processar_reply_request(
|
| 676 |
+
mensagem: str,
|
| 677 |
+
request_data: Dict[str, Any],
|
| 678 |
+
short_term_memory: Optional[ShortTermMemory] = None
|
| 679 |
+
) -> ProcessedReplyContext:
|
| 680 |
+
"""
|
| 681 |
+
Função helper para processar reply de request.
|
| 682 |
+
|
| 683 |
+
Args:
|
| 684 |
+
mensagem: Mensagem atual
|
| 685 |
+
request_data: Payload do request
|
| 686 |
+
short_term_memory: Instância de ShortTermMemory (opcional)
|
| 687 |
+
|
| 688 |
+
Returns:
|
| 689 |
+
ProcessedReplyContext
|
| 690 |
+
"""
|
| 691 |
+
handler = criar_reply_handler(short_term_memory)
|
| 692 |
+
reply_metadata = handler.extract_reply_metadata_from_request(request_data)
|
| 693 |
+
return handler.process_reply(mensagem, reply_metadata)
|
| 694 |
+
|
| 695 |
+
|
| 696 |
+
# type: ignore
|
| 697 |
+
|
modules/short_term_memory.py
ADDED
|
@@ -0,0 +1,730 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# type: ignore
|
| 2 |
+
"""
|
| 3 |
+
================================================================================
|
| 4 |
+
AKIRA V21 ULTIMATE - SHORT-TERM MEMORY MODULE
|
| 5 |
+
================================================================================
|
| 6 |
+
Sistema de memória de curto prazo com sliding window de 100 mensagens.
|
| 7 |
+
Prioriza contexto de replies e ajusta importância dinamicamente.
|
| 8 |
+
|
| 9 |
+
Features:
|
| 10 |
+
- Sliding window de 100 mensagens por usuário
|
| 11 |
+
- Priorização automática de replies (importancia > 1.0)
|
| 12 |
+
- Perguntas curtas com reply ganham prioridade ainda maior
|
| 13 |
+
- Serialização JSON para persistência
|
| 14 |
+
- Peso adaptativo baseado em análise de conteúdo
|
| 15 |
+
================================================================================
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
import time
|
| 21 |
+
import json
|
| 22 |
+
import re
|
| 23 |
+
import logging
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Optional, Dict, Any, List, Tuple
|
| 26 |
+
from dataclasses import dataclass, field
|
| 27 |
+
from collections import deque
|
| 28 |
+
from datetime import datetime
|
| 29 |
+
|
| 30 |
+
# Imports robustos com fallback - CORRIGIDO para usar modules.
|
| 31 |
+
try:
|
| 32 |
+
import modules.config as config
|
| 33 |
+
SHORT_TERM_MEMORY_AVAILABLE = True
|
| 34 |
+
except ImportError:
|
| 35 |
+
try:
|
| 36 |
+
from . import config
|
| 37 |
+
SHORT_TERM_MEMORY_AVAILABLE = True
|
| 38 |
+
except ImportError:
|
| 39 |
+
SHORT_TERM_MEMORY_AVAILABLE = False
|
| 40 |
+
config = None
|
| 41 |
+
|
| 42 |
+
logger = logging.getLogger(__name__)
|
| 43 |
+
|
| 44 |
+
# ============================================================
|
| 45 |
+
# CONFIGURAÇÃO
|
| 46 |
+
# ============================================================
|
| 47 |
+
|
| 48 |
+
# Máximo de mensagens na memória de curto prazo (100 conforme usuário)
|
| 49 |
+
MAX_SHORT_TERM_MESSAGES: int = 100
|
| 50 |
+
|
| 51 |
+
# Multiplicadores de importância
|
| 52 |
+
IMPORTANCIA_NORMAL: float = 1.0
|
| 53 |
+
IMPORTANCIA_REPLY: float = 1.3
|
| 54 |
+
IMPORTANCIA_REPLY_TO_BOT: float = 1.5
|
| 55 |
+
IMPORTANCIA_PERGUNTA_CURTA_REPLY: float = 1.7 # Prioridade máxima
|
| 56 |
+
|
| 57 |
+
# Limite de palavras para considerar "pergunta curta"
|
| 58 |
+
PERGUNTA_CURTA_LIMITE: int = 5
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@dataclass
|
| 62 |
+
class MessageWithContext:
|
| 63 |
+
"""
|
| 64 |
+
Mensagem com metadados de contexto completo.
|
| 65 |
+
|
| 66 |
+
Attributes:
|
| 67 |
+
role: "user" ou "assistant"
|
| 68 |
+
content: Texto da mensagem
|
| 69 |
+
timestamp: Timestamp da mensagem
|
| 70 |
+
importancia: Peso de importância (1.0 = normal, >1.0 = replies)
|
| 71 |
+
emocao: Emoção detectada
|
| 72 |
+
reply_info: Info sobre reply (se aplicável)
|
| 73 |
+
conversation_id: ID da conversa isolada
|
| 74 |
+
token_count: Contagem aproximada de tokens
|
| 75 |
+
"""
|
| 76 |
+
role: str
|
| 77 |
+
content: str
|
| 78 |
+
timestamp: float = field(default_factory=time.time)
|
| 79 |
+
importancia: float = 1.0
|
| 80 |
+
emocao: str = "neutral"
|
| 81 |
+
reply_info: Dict[str, Any] = field(default_factory=dict)
|
| 82 |
+
conversation_id: str = ""
|
| 83 |
+
token_count: int = 0
|
| 84 |
+
|
| 85 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 86 |
+
"""Converte para dicionário."""
|
| 87 |
+
return {
|
| 88 |
+
"role": self.role,
|
| 89 |
+
"content": self.content,
|
| 90 |
+
"timestamp": self.timestamp,
|
| 91 |
+
"importancia": self.importancia,
|
| 92 |
+
"emocao": self.emocao,
|
| 93 |
+
"reply_info": self.reply_info,
|
| 94 |
+
"conversation_id": self.conversation_id,
|
| 95 |
+
"token_count": self.token_count
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
@classmethod
|
| 99 |
+
def from_dict(cls, data: Dict[str, Any]) -> 'MessageWithContext':
|
| 100 |
+
"""Cria instância a partir de dicionário."""
|
| 101 |
+
return cls(
|
| 102 |
+
role=data.get("role", "user"),
|
| 103 |
+
content=data.get("content", ""),
|
| 104 |
+
timestamp=data.get("timestamp", time.time()),
|
| 105 |
+
importancia=data.get("importancia", 1.0),
|
| 106 |
+
emocao=data.get("emocao", "neutral"),
|
| 107 |
+
reply_info=data.get("reply_info", {}),
|
| 108 |
+
conversation_id=data.get("conversation_id", ""),
|
| 109 |
+
token_count=data.get("token_count", 0)
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
@property
|
| 113 |
+
def is_reply(self) -> bool:
|
| 114 |
+
"""Verifica se é um reply."""
|
| 115 |
+
return bool(self.reply_info) and self.reply_info.get("is_reply", False)
|
| 116 |
+
|
| 117 |
+
@property
|
| 118 |
+
def is_reply_to_bot(self) -> bool:
|
| 119 |
+
"""Verifica se é reply direcionado ao bot."""
|
| 120 |
+
return self.reply_info.get("reply_to_bot", False)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# ============================================================
|
| 124 |
+
# FUNÇÕES AUXILIARES
|
| 125 |
+
# ============================================================
|
| 126 |
+
|
| 127 |
+
def contar_palavras(texto: str) -> int:
|
| 128 |
+
"""Conta palavras em um texto."""
|
| 129 |
+
if not texto:
|
| 130 |
+
return 0
|
| 131 |
+
return len(texto.split())
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def estimar_tokens(texto: str) -> int:
|
| 135 |
+
"""
|
| 136 |
+
Estima número de tokens (aproximação粗糙).
|
| 137 |
+
Média de 4 caracteres por token em português.
|
| 138 |
+
"""
|
| 139 |
+
if not texto:
|
| 140 |
+
return 0
|
| 141 |
+
return max(1, len(texto) // 4)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def is_pergunta_curta(texto: str) -> bool:
|
| 145 |
+
"""
|
| 146 |
+
Verifica se o texto é uma pergunta curta.
|
| 147 |
+
|
| 148 |
+
Args:
|
| 149 |
+
texto: Texto a verificar
|
| 150 |
+
|
| 151 |
+
Returns:
|
| 152 |
+
True se for pergunta com poucas palavras
|
| 153 |
+
"""
|
| 154 |
+
if not texto:
|
| 155 |
+
return False
|
| 156 |
+
|
| 157 |
+
texto_lower = texto.strip().lower()
|
| 158 |
+
|
| 159 |
+
# Deve ter marcador de pergunta ou palavras interrogativas
|
| 160 |
+
has_question_marker = '?' in texto or '?' in texto
|
| 161 |
+
has_interrogative = any(w in texto_lower for w in [
|
| 162 |
+
'qual', 'quais', 'quem', 'como', 'onde', 'quando', 'por que',
|
| 163 |
+
'porque', 'para que', 'o que', 'que', 'é o que'
|
| 164 |
+
])
|
| 165 |
+
|
| 166 |
+
word_count = contar_palavras(texto)
|
| 167 |
+
|
| 168 |
+
# Pergunta curta: até N palavras E (marcador ? OU palavra interrogativa)
|
| 169 |
+
return word_count <= PERGUNTA_CURTA_LIMITE and (has_question_marker or has_interrogative)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def calcular_importancia(
|
| 173 |
+
is_reply: bool = False,
|
| 174 |
+
reply_to_bot: bool = False,
|
| 175 |
+
mensagem: str = "",
|
| 176 |
+
emocao: str = "neutral"
|
| 177 |
+
) -> float:
|
| 178 |
+
"""
|
| 179 |
+
Calcula importância da mensagem baseada em múltiplos fatores.
|
| 180 |
+
|
| 181 |
+
Args:
|
| 182 |
+
is_reply: Se é um reply
|
| 183 |
+
reply_to_bot: Se é reply para o bot
|
| 184 |
+
mensagem: Texto da mensagem
|
| 185 |
+
emocao: Emoção detectada
|
| 186 |
+
|
| 187 |
+
Returns:
|
| 188 |
+
Float de importância (1.0 = normal, >1.0 = prioritário)
|
| 189 |
+
"""
|
| 190 |
+
importancia = IMPORTANCIA_NORMAL
|
| 191 |
+
|
| 192 |
+
# Reply para o bot tem maior prioridade
|
| 193 |
+
if is_reply and reply_to_bot:
|
| 194 |
+
importancia = IMPORTANCIA_REPLY_TO_BOT
|
| 195 |
+
|
| 196 |
+
# Pergunta curta com reply ao bot = prioridade máxima
|
| 197 |
+
if is_pergunta_curta(mensagem):
|
| 198 |
+
importancia = IMPORTANCIA_PERGUNTA_CURTA_REPLY
|
| 199 |
+
|
| 200 |
+
# Reply normal
|
| 201 |
+
elif is_reply:
|
| 202 |
+
importancia = IMPORTANCIA_REPLY
|
| 203 |
+
|
| 204 |
+
# Emoção intensa pode aumentar importância
|
| 205 |
+
emocoes_intensas = ['joy', 'love', 'anger', 'fear']
|
| 206 |
+
if emocao in emocoes_intensas:
|
| 207 |
+
importancia *= 1.1
|
| 208 |
+
|
| 209 |
+
return importancia
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
# ============================================================
|
| 213 |
+
# CLASSE PRINCIPAL DE MEMÓRIA DE CURTO PRAZO
|
| 214 |
+
# ============================================================
|
| 215 |
+
|
| 216 |
+
class ShortTermMemory:
|
| 217 |
+
"""
|
| 218 |
+
Sistema de memória de curto prazo com sliding window.
|
| 219 |
+
|
| 220 |
+
Características:
|
| 221 |
+
- Mantém últimas N mensagens (100 por padrão)
|
| 222 |
+
- Auto-reorganização por importância
|
| 223 |
+
- Persistência JSON
|
| 224 |
+
- Integração com ReplyContextHandler
|
| 225 |
+
- Token budgeting para contexto LLM
|
| 226 |
+
"""
|
| 227 |
+
|
| 228 |
+
def __init__(
|
| 229 |
+
self,
|
| 230 |
+
conversation_id: str = "",
|
| 231 |
+
max_messages: int = MAX_SHORT_TERM_MESSAGES,
|
| 232 |
+
context_data: Optional[Dict[str, Any]] = None
|
| 233 |
+
):
|
| 234 |
+
"""
|
| 235 |
+
Inicializa memória de curto prazo.
|
| 236 |
+
|
| 237 |
+
Args:
|
| 238 |
+
conversation_id: ID da conversa isolada
|
| 239 |
+
max_messages: Máximo de mensagens (padrão 100)
|
| 240 |
+
context_data: Dados para restauração (opcional)
|
| 241 |
+
"""
|
| 242 |
+
self.conversation_id = conversation_id
|
| 243 |
+
self.max_messages = max_messages
|
| 244 |
+
|
| 245 |
+
# Deque para O(1) em operações de borda
|
| 246 |
+
self._messages: deque = deque(maxlen=max_messages)
|
| 247 |
+
|
| 248 |
+
# Cache para rápido acesso
|
| 249 |
+
self._replies_cache: List[MessageWithContext] = []
|
| 250 |
+
self._last_update: float = time.time()
|
| 251 |
+
|
| 252 |
+
# Carrega dados se fornecidos
|
| 253 |
+
if context_data and isinstance(context_data, dict):
|
| 254 |
+
self._from_dict(context_data)
|
| 255 |
+
else:
|
| 256 |
+
self._initialize_empty()
|
| 257 |
+
|
| 258 |
+
logger.debug(f"🧠 ShortTermMemory initialized: {conversation_id or 'temp'} | {len(self._messages)} msgs")
|
| 259 |
+
|
| 260 |
+
def _initialize_empty(self):
|
| 261 |
+
"""Inicializa estrutura vazia."""
|
| 262 |
+
self._messages = deque(maxlen=self.max_messages)
|
| 263 |
+
self._replies_cache = []
|
| 264 |
+
self._last_update = time.time()
|
| 265 |
+
|
| 266 |
+
# ============================================================
|
| 267 |
+
# ADIÇÃO DE MENSAGENS
|
| 268 |
+
# ============================================================
|
| 269 |
+
|
| 270 |
+
def add_message(
|
| 271 |
+
self,
|
| 272 |
+
role: str,
|
| 273 |
+
content: str,
|
| 274 |
+
importancia: float = IMPORTANCIA_NORMAL,
|
| 275 |
+
emocao: str = "neutral",
|
| 276 |
+
reply_info: Optional[Dict[str, Any]] = None,
|
| 277 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 278 |
+
) -> MessageWithContext:
|
| 279 |
+
"""
|
| 280 |
+
Adiciona mensagem à memória.
|
| 281 |
+
|
| 282 |
+
Args:
|
| 283 |
+
role: "user" ou "assistant"
|
| 284 |
+
content: Texto da mensagem
|
| 285 |
+
importancia: Peso de importância
|
| 286 |
+
emocao: Emoção detectada
|
| 287 |
+
reply_info: Info de reply (se aplicável)
|
| 288 |
+
metadata: Metadados adicionais
|
| 289 |
+
|
| 290 |
+
Returns:
|
| 291 |
+
MessageWithContext criada
|
| 292 |
+
"""
|
| 293 |
+
# Cria mensagem com contexto
|
| 294 |
+
msg = MessageWithContext(
|
| 295 |
+
role=role,
|
| 296 |
+
content=content,
|
| 297 |
+
importancia=importancia,
|
| 298 |
+
emocao=emocao,
|
| 299 |
+
reply_info=reply_info or {},
|
| 300 |
+
conversation_id=self.conversation_id,
|
| 301 |
+
token_count=estimar_tokens(content)
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
# Adiciona metadados extras
|
| 305 |
+
if metadata:
|
| 306 |
+
msg_data = msg.to_dict()
|
| 307 |
+
msg_data.update(metadata)
|
| 308 |
+
msg = MessageWithContext.from_dict(msg_data)
|
| 309 |
+
|
| 310 |
+
# Adiciona ao deque
|
| 311 |
+
self._messages.append(msg)
|
| 312 |
+
self._last_update = time.time()
|
| 313 |
+
|
| 314 |
+
# Atualiza cache de replies
|
| 315 |
+
if msg.is_reply:
|
| 316 |
+
self._replies_cache.append(msg)
|
| 317 |
+
# Limita cache de replies
|
| 318 |
+
if len(self._replies_cache) > 20:
|
| 319 |
+
self._replies_cache = self._replies_cache[-20:]
|
| 320 |
+
|
| 321 |
+
return msg
|
| 322 |
+
|
| 323 |
+
def add_user_message(
|
| 324 |
+
self,
|
| 325 |
+
content: str,
|
| 326 |
+
emocao: str = "neutral",
|
| 327 |
+
reply_info: Optional[Dict[str, Any]] = None,
|
| 328 |
+
importancia: float = None
|
| 329 |
+
) -> MessageWithContext:
|
| 330 |
+
"""
|
| 331 |
+
Adiciona mensagem do usuário.
|
| 332 |
+
|
| 333 |
+
Args:
|
| 334 |
+
content: Texto da mensagem
|
| 335 |
+
emocao: Emoção detectada
|
| 336 |
+
reply_info: Info de reply
|
| 337 |
+
importancia: Importância customizada (calculada automaticamente se None)
|
| 338 |
+
|
| 339 |
+
Returns:
|
| 340 |
+
MessageWithContext criada
|
| 341 |
+
"""
|
| 342 |
+
if importancia is None:
|
| 343 |
+
importancia = calcular_importancia(
|
| 344 |
+
is_reply=bool(reply_info and reply_info.get("is_reply")),
|
| 345 |
+
reply_to_bot=bool(reply_info and reply_info.get("reply_to_bot")),
|
| 346 |
+
mensagem=content,
|
| 347 |
+
emocao=emocao
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
return self.add_message(
|
| 351 |
+
role="user",
|
| 352 |
+
content=content,
|
| 353 |
+
importancia=importancia,
|
| 354 |
+
emocao=emocao,
|
| 355 |
+
reply_info=reply_info
|
| 356 |
+
)
|
| 357 |
+
|
| 358 |
+
def add_assistant_message(
|
| 359 |
+
self,
|
| 360 |
+
content: str,
|
| 361 |
+
emocao: str = "neutral",
|
| 362 |
+
importancia: float = IMPORTANCIA_NORMAL
|
| 363 |
+
) -> MessageWithContext:
|
| 364 |
+
"""
|
| 365 |
+
Adiciona mensagem do assistente (bot).
|
| 366 |
+
|
| 367 |
+
Args:
|
| 368 |
+
content: Texto da resposta
|
| 369 |
+
emocao: Emoção da resposta
|
| 370 |
+
importancia: Importância
|
| 371 |
+
|
| 372 |
+
Returns:
|
| 373 |
+
MessageWithContext criada
|
| 374 |
+
"""
|
| 375 |
+
return self.add_message(
|
| 376 |
+
role="assistant",
|
| 377 |
+
content=content,
|
| 378 |
+
importancia=importancia,
|
| 379 |
+
emocao=emocao
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
# ============================================================
|
| 383 |
+
# RECUPERAÇÃO DE CONTEXTO
|
| 384 |
+
# ============================================================
|
| 385 |
+
|
| 386 |
+
def get_context_window(
|
| 387 |
+
self,
|
| 388 |
+
include_replies: bool = True,
|
| 389 |
+
prioritize_replies: bool = True,
|
| 390 |
+
max_messages: Optional[int] = None,
|
| 391 |
+
max_tokens: int = 8000
|
| 392 |
+
) -> List[MessageWithContext]:
|
| 393 |
+
"""
|
| 394 |
+
Obtém janela de contexto otimizada para LLM.
|
| 395 |
+
|
| 396 |
+
Args:
|
| 397 |
+
include_replies: Se deve incluir replies
|
| 398 |
+
prioritize_replies: Se deve priorizar replies
|
| 399 |
+
max_messages: Máximo de mensagens (usa config se None)
|
| 400 |
+
max_tokens: Limite de tokens
|
| 401 |
+
|
| 402 |
+
Returns:
|
| 403 |
+
Lista de mensagens ordenadas
|
| 404 |
+
"""
|
| 405 |
+
messages = list(self._messages)
|
| 406 |
+
|
| 407 |
+
if not messages:
|
| 408 |
+
return []
|
| 409 |
+
|
| 410 |
+
# Filtra replies se necessário
|
| 411 |
+
if not include_replies:
|
| 412 |
+
messages = [m for m in messages if not m.is_reply]
|
| 413 |
+
|
| 414 |
+
# Reorganiza por importância se solicitado
|
| 415 |
+
if prioritize_replies:
|
| 416 |
+
messages.sort(key=lambda m: m.importancia, reverse=True)
|
| 417 |
+
|
| 418 |
+
# Aplica limite de mensagens
|
| 419 |
+
if max_messages and len(messages) > max_messages:
|
| 420 |
+
messages = messages[:max_messages]
|
| 421 |
+
|
| 422 |
+
# Aplica limite de tokens
|
| 423 |
+
if max_tokens > 0:
|
| 424 |
+
tokens_accumulated = 0
|
| 425 |
+
result = []
|
| 426 |
+
for msg in messages:
|
| 427 |
+
if tokens_accumulated + msg.token_count <= max_tokens:
|
| 428 |
+
result.append(msg)
|
| 429 |
+
tokens_accumulated += msg.token_count
|
| 430 |
+
else:
|
| 431 |
+
break
|
| 432 |
+
messages = result
|
| 433 |
+
|
| 434 |
+
return messages
|
| 435 |
+
|
| 436 |
+
def get_last_n_messages(self, n: int) -> List[MessageWithContext]:
|
| 437 |
+
"""
|
| 438 |
+
Obtém últimas N mensagens (ordem cronológica).
|
| 439 |
+
|
| 440 |
+
Args:
|
| 441 |
+
n: Número de mensagens
|
| 442 |
+
|
| 443 |
+
Returns:
|
| 444 |
+
Lista das últimas N mensagens
|
| 445 |
+
"""
|
| 446 |
+
return list(self._messages)[-n:]
|
| 447 |
+
|
| 448 |
+
def get_recent_replies(
|
| 449 |
+
self,
|
| 450 |
+
n: int = 5,
|
| 451 |
+
include_reply_to_bot: bool = True
|
| 452 |
+
) -> List[MessageWithContext]:
|
| 453 |
+
"""
|
| 454 |
+
Obtém replies mais recentes.
|
| 455 |
+
|
| 456 |
+
Args:
|
| 457 |
+
n: Número de replies a retornar
|
| 458 |
+
include_reply_to_bot: Se inclui replies ao bot
|
| 459 |
+
|
| 460 |
+
Returns:
|
| 461 |
+
Lista de replies ordenados por timestamp
|
| 462 |
+
"""
|
| 463 |
+
replies = [m for m in self._messages if m.is_reply]
|
| 464 |
+
|
| 465 |
+
if not include_reply_to_bot:
|
| 466 |
+
replies = [m for m in replies if not m.is_reply_to_bot]
|
| 467 |
+
|
| 468 |
+
# Retorna mais recentes primeiro
|
| 469 |
+
return replies[-n:][::-1]
|
| 470 |
+
|
| 471 |
+
def get_all_messages(self) -> List[MessageWithContext]:
|
| 472 |
+
"""Retorna todas as mensagens."""
|
| 473 |
+
return list(self._messages)
|
| 474 |
+
|
| 475 |
+
def get_messages_for_llm(
|
| 476 |
+
self,
|
| 477 |
+
reply_context: Optional[MessageWithContext] = None,
|
| 478 |
+
max_tokens: int = 6000
|
| 479 |
+
) -> List[Dict[str, str]]:
|
| 480 |
+
"""
|
| 481 |
+
Obtém mensagens formatadas para LLM.
|
| 482 |
+
|
| 483 |
+
Args:
|
| 484 |
+
reply_context: Contexto de reply atual (terá prioridade)
|
| 485 |
+
max_tokens: Limite de tokens
|
| 486 |
+
|
| 487 |
+
Returns:
|
| 488 |
+
Lista de dicts com role e content
|
| 489 |
+
"""
|
| 490 |
+
messages = self.get_context_window(
|
| 491 |
+
include_replies=True,
|
| 492 |
+
prioritize_replies=True,
|
| 493 |
+
max_tokens=max_tokens
|
| 494 |
+
)
|
| 495 |
+
|
| 496 |
+
# Se há reply_context, coloca no início
|
| 497 |
+
if reply_context:
|
| 498 |
+
# Garante que reply_context está na lista ou adiciona
|
| 499 |
+
reply_msg = MessageWithContext(
|
| 500 |
+
role="user",
|
| 501 |
+
content=f"[REPLY CONTEXT] {reply_context.content}",
|
| 502 |
+
importancia=IMPORTANCIA_PERGUNTA_CURTA_REPLY,
|
| 503 |
+
reply_info=reply_context.reply_info
|
| 504 |
+
)
|
| 505 |
+
|
| 506 |
+
# Remove duplicata se existir
|
| 507 |
+
messages = [m for m in messages if not (
|
| 508 |
+
m.is_reply and
|
| 509 |
+
m.reply_info.get("quoted_text_original") == reply_context.reply_info.get("quoted_text_original")
|
| 510 |
+
)]
|
| 511 |
+
|
| 512 |
+
# Adiciona reply no início
|
| 513 |
+
messages.insert(0, reply_msg)
|
| 514 |
+
|
| 515 |
+
# Formata para LLM
|
| 516 |
+
return [
|
| 517 |
+
{"role": msg.role, "content": msg.content}
|
| 518 |
+
for msg in messages
|
| 519 |
+
]
|
| 520 |
+
|
| 521 |
+
# ============================================================
|
| 522 |
+
# ANÁLISE DE CONTEXTO
|
| 523 |
+
# ============================================================
|
| 524 |
+
|
| 525 |
+
def get_conversation_summary(self) -> Dict[str, Any]:
|
| 526 |
+
"""
|
| 527 |
+
Gera resumo estatístico da conversa.
|
| 528 |
+
|
| 529 |
+
Returns:
|
| 530 |
+
Dicionário com estatísticas
|
| 531 |
+
"""
|
| 532 |
+
messages = list(self._messages)
|
| 533 |
+
|
| 534 |
+
if not messages:
|
| 535 |
+
return {
|
| 536 |
+
"total_messages": 0,
|
| 537 |
+
"user_messages": 0,
|
| 538 |
+
"assistant_messages": 0,
|
| 539 |
+
"replies_count": 0,
|
| 540 |
+
"emocoes": {},
|
| 541 |
+
"avg_importancia": 1.0,
|
| 542 |
+
"token_count": 0,
|
| 543 |
+
"duration_seconds": 0
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
user_msgs = [m for m in messages if m.role == "user"]
|
| 547 |
+
assistant_msgs = [m for m in messages if m.role == "assistant"]
|
| 548 |
+
replies = [m for m in messages if m.is_reply]
|
| 549 |
+
|
| 550 |
+
# Contagem de emoções
|
| 551 |
+
emocoes = {}
|
| 552 |
+
for m in messages:
|
| 553 |
+
emocao = m.emocao or "neutral"
|
| 554 |
+
emocoes[emocao] = emocoes.get(emocao, 0) + 1
|
| 555 |
+
|
| 556 |
+
# Duração
|
| 557 |
+
timestamps = [m.timestamp for m in messages]
|
| 558 |
+
duration = max(timestamps) - min(timestamps) if len(timestamps) > 1 else 0
|
| 559 |
+
|
| 560 |
+
return {
|
| 561 |
+
"total_messages": len(messages),
|
| 562 |
+
"user_messages": len(user_msgs),
|
| 563 |
+
"assistant_messages": len(assistant_msgs),
|
| 564 |
+
"replies_count": len(replies),
|
| 565 |
+
"emocoes": emocoes,
|
| 566 |
+
"avg_importancia": sum(m.importancia for m in messages) / max(1, len(messages)),
|
| 567 |
+
"token_count": sum(m.token_count for m in messages),
|
| 568 |
+
"duration_seconds": duration,
|
| 569 |
+
"is_full": len(messages) >= self.max_messages
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
def get_emotional_trend(self) -> str:
|
| 573 |
+
"""Retorna tendência emocional da conversa."""
|
| 574 |
+
messages = list(self._messages)
|
| 575 |
+
if not messages:
|
| 576 |
+
return "neutral"
|
| 577 |
+
|
| 578 |
+
# Pesos mais recentes têm mais importância
|
| 579 |
+
emocoes = {}
|
| 580 |
+
total_weight = 0
|
| 581 |
+
|
| 582 |
+
for i, msg in enumerate(reversed(messages)):
|
| 583 |
+
weight = 1.0 + (i * 0.05) #_msgs recentes pesam mais
|
| 584 |
+
emocao = msg.emocao or "neutral"
|
| 585 |
+
emocoes[emocao] = emocoes.get(emocao, 0) + weight
|
| 586 |
+
total_weight += weight
|
| 587 |
+
|
| 588 |
+
# Normaliza
|
| 589 |
+
for e in emocoes:
|
| 590 |
+
emocoes[e] /= total_weight
|
| 591 |
+
|
| 592 |
+
return max(emocoes, key=emocoes.get) if emocoes else "neutral" # type: ignore
|
| 593 |
+
|
| 594 |
+
# ============================================================
|
| 595 |
+
# PERSISTÊNCIA
|
| 596 |
+
# ============================================================
|
| 597 |
+
|
| 598 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 599 |
+
"""Serializa para dicionário."""
|
| 600 |
+
return {
|
| 601 |
+
"conversation_id": self.conversation_id,
|
| 602 |
+
"max_messages": self.max_messages,
|
| 603 |
+
"messages": [m.to_dict() for m in self._messages],
|
| 604 |
+
"last_update": self._last_update
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
def _from_dict(self, data: Dict[str, Any]):
|
| 608 |
+
"""Desserializa de dicionário."""
|
| 609 |
+
self.conversation_id = data.get("conversation_id", "")
|
| 610 |
+
self.max_messages = data.get("max_messages", MAX_SHORT_TERM_MESSAGES)
|
| 611 |
+
self._last_update = data.get("last_update", time.time())
|
| 612 |
+
|
| 613 |
+
messages_data = data.get("messages", [])
|
| 614 |
+
self._messages = deque(maxlen=self.max_messages)
|
| 615 |
+
self._replies_cache = []
|
| 616 |
+
|
| 617 |
+
for msg_data in messages_data:
|
| 618 |
+
msg = MessageWithContext.from_dict(msg_data)
|
| 619 |
+
self._messages.append(msg)
|
| 620 |
+
if msg.is_reply:
|
| 621 |
+
self._replies_cache.append(msg)
|
| 622 |
+
|
| 623 |
+
def save_to_file(self, filepath: str) -> bool:
|
| 624 |
+
"""Salva memória em arquivo JSON."""
|
| 625 |
+
try:
|
| 626 |
+
with open(filepath, 'w', encoding='utf-8') as f:
|
| 627 |
+
json.dump(self.to_dict(), f, ensure_ascii=False, indent=2)
|
| 628 |
+
return True
|
| 629 |
+
except Exception as e:
|
| 630 |
+
logger.warning(f"Erro ao salvar memória: {e}")
|
| 631 |
+
return False
|
| 632 |
+
|
| 633 |
+
@classmethod
|
| 634 |
+
def load_from_file(cls, filepath: str) -> 'ShortTermMemory':
|
| 635 |
+
"""Carrega memória de arquivo JSON."""
|
| 636 |
+
try:
|
| 637 |
+
with open(filepath, 'r', encoding='utf-8') as f:
|
| 638 |
+
data = json.load(f)
|
| 639 |
+
return cls(context_data=data)
|
| 640 |
+
except Exception as e:
|
| 641 |
+
logger.warning(f"Erro ao carregar memória: {e}")
|
| 642 |
+
return cls()
|
| 643 |
+
|
| 644 |
+
# ============================================================
|
| 645 |
+
# GESTÃO
|
| 646 |
+
# ============================================================
|
| 647 |
+
|
| 648 |
+
def clear(self):
|
| 649 |
+
"""Limpa toda a memória."""
|
| 650 |
+
self._initialize_empty()
|
| 651 |
+
logger.debug(f"🧠 ShortTermMemory cleared: {self.conversation_id or 'temp'}")
|
| 652 |
+
|
| 653 |
+
def merge_from(self, other: 'ShortTermMemory') -> None:
|
| 654 |
+
"""
|
| 655 |
+
Mescla mensagens de outra memória.
|
| 656 |
+
Útil para migração de dados.
|
| 657 |
+
|
| 658 |
+
Args:
|
| 659 |
+
other: Outra ShortTermMemory
|
| 660 |
+
"""
|
| 661 |
+
for msg in other.get_all_messages():
|
| 662 |
+
# Mantém conversation_id original
|
| 663 |
+
msg_data = msg.to_dict()
|
| 664 |
+
msg_data["conversation_id"] = self.conversation_id
|
| 665 |
+
new_msg = MessageWithContext.from_dict(msg_data)
|
| 666 |
+
self._messages.append(new_msg)
|
| 667 |
+
|
| 668 |
+
self._last_update = time.time()
|
| 669 |
+
|
| 670 |
+
def __len__(self) -> int:
|
| 671 |
+
"""Retorna número de mensagens."""
|
| 672 |
+
return len(self._messages)
|
| 673 |
+
|
| 674 |
+
def __bool__(self) -> bool:
|
| 675 |
+
"""Retorna True se há mensagens."""
|
| 676 |
+
return len(self._messages) > 0
|
| 677 |
+
|
| 678 |
+
def __iter__(self):
|
| 679 |
+
"""Iterador sobre mensagens."""
|
| 680 |
+
return iter(self._messages)
|
| 681 |
+
|
| 682 |
+
def __repr__(self) -> str:
|
| 683 |
+
"""Representação textual."""
|
| 684 |
+
return f"ShortTermMemory(id={self.conversation_id[:8] if self.conversation_id else 'temp'}, msgs={len(self)})"
|
| 685 |
+
|
| 686 |
+
|
| 687 |
+
# ============================================================
|
| 688 |
+
# FUNÇÕES DE FÁBRICA
|
| 689 |
+
# ============================================================
|
| 690 |
+
|
| 691 |
+
def criar_short_term_memory(
|
| 692 |
+
conversation_id: str = "",
|
| 693 |
+
max_messages: int = MAX_SHORT_TERM_MESSAGES
|
| 694 |
+
) -> ShortTermMemory:
|
| 695 |
+
"""
|
| 696 |
+
Factory function para criar ShortTermMemory.
|
| 697 |
+
|
| 698 |
+
Args:
|
| 699 |
+
conversation_id: ID da conversa
|
| 700 |
+
max_messages: Máximo de mensagens
|
| 701 |
+
|
| 702 |
+
Returns:
|
| 703 |
+
ShortTermMemory instance
|
| 704 |
+
"""
|
| 705 |
+
return ShortTermMemory(conversation_id=conversation_id, max_messages=max_messages)
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
def calcular_importancia_automatica(
|
| 709 |
+
mensagem: str,
|
| 710 |
+
is_reply: bool = False,
|
| 711 |
+
reply_to_bot: bool = False,
|
| 712 |
+
emocao: str = "neutral"
|
| 713 |
+
) -> float:
|
| 714 |
+
"""
|
| 715 |
+
Wrapper para calcular_importancia com todos os parâmetros.
|
| 716 |
+
|
| 717 |
+
Args:
|
| 718 |
+
mensagem: Texto da mensagem
|
| 719 |
+
is_reply: Se é reply
|
| 720 |
+
reply_to_bot: Se é reply para o bot
|
| 721 |
+
emocao: Emoção detectada
|
| 722 |
+
|
| 723 |
+
Returns:
|
| 724 |
+
Float de importância
|
| 725 |
+
"""
|
| 726 |
+
return calcular_importancia(is_reply, reply_to_bot, mensagem, emocao)
|
| 727 |
+
|
| 728 |
+
|
| 729 |
+
# type: ignore
|
| 730 |
+
|
modules/treinamento.py
CHANGED
|
@@ -1,1076 +1,856 @@
|
|
| 1 |
-
#
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
import
|
| 13 |
-
import
|
| 14 |
-
import
|
| 15 |
-
import
|
| 16 |
-
import
|
| 17 |
-
import
|
| 18 |
-
import
|
| 19 |
-
from
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
#
|
| 24 |
-
|
| 25 |
-
#
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
def
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
""
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
"
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
"
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
self.
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
logger.
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
# ========================================================================
|
| 859 |
-
# 🔧 FUNÇÃO PARA USO DIRETO DA API (ATUALIZADA)
|
| 860 |
-
# ========================================================================
|
| 861 |
-
|
| 862 |
-
def processar_interacao_api(self, payload: Dict, resposta: str) -> Dict:
|
| 863 |
-
"""
|
| 864 |
-
Processa interação da API para treinamento - ATUALIZADA
|
| 865 |
-
|
| 866 |
-
Args:
|
| 867 |
-
payload: Payload da requisição
|
| 868 |
-
resposta: Resposta gerada
|
| 869 |
-
|
| 870 |
-
Returns:
|
| 871 |
-
Resultado do processamento
|
| 872 |
-
"""
|
| 873 |
-
try:
|
| 874 |
-
# Extrai dados do payload (compatível com index.js)
|
| 875 |
-
usuario = payload.get('usuario', 'Anônimo')
|
| 876 |
-
numero = payload.get('numero', '')
|
| 877 |
-
mensagem = payload.get('mensagem', '')
|
| 878 |
-
tipo_conversa = payload.get('tipo_conversa', 'pv')
|
| 879 |
-
tipo_mensagem = payload.get('tipo_mensagem', 'texto')
|
| 880 |
-
|
| 881 |
-
# Extrai reply_metadata
|
| 882 |
-
reply_metadata = payload.get('reply_metadata', {})
|
| 883 |
-
|
| 884 |
-
# Determina reply_to_bot
|
| 885 |
-
reply_to_bot = False
|
| 886 |
-
if reply_metadata:
|
| 887 |
-
reply_to_bot = reply_metadata.get('reply_to_bot', False)
|
| 888 |
-
|
| 889 |
-
is_reply = bool(payload.get('mensagem_citada')) or bool(reply_metadata)
|
| 890 |
-
|
| 891 |
-
# Contexto da análise com nivel_transicao
|
| 892 |
-
contexto_analise = payload.get('analise', {})
|
| 893 |
-
nivel_transicao = contexto_analise.get('nivel_transicao', 0)
|
| 894 |
-
|
| 895 |
-
# Registra interação com nivel_transicao
|
| 896 |
-
self.registrar_interacao(
|
| 897 |
-
usuario=usuario,
|
| 898 |
-
mensagem=mensagem,
|
| 899 |
-
resposta=resposta,
|
| 900 |
-
numero=numero,
|
| 901 |
-
is_reply=is_reply,
|
| 902 |
-
mensagem_original=payload.get('mensagem_citada', ''),
|
| 903 |
-
contexto=contexto_analise,
|
| 904 |
-
tipo_conversa=tipo_conversa,
|
| 905 |
-
tipo_mensagem=tipo_mensagem,
|
| 906 |
-
reply_to_bot=reply_to_bot,
|
| 907 |
-
reply_metadata=reply_metadata,
|
| 908 |
-
nivel_transicao=nivel_transicao # ADICIONADO
|
| 909 |
-
)
|
| 910 |
-
|
| 911 |
-
return {
|
| 912 |
-
'status': 'success',
|
| 913 |
-
'message': 'Interação registrada',
|
| 914 |
-
'usuario': usuario,
|
| 915 |
-
'nivel_transicao': nivel_transicao,
|
| 916 |
-
'timestamp': time.time()
|
| 917 |
-
}
|
| 918 |
-
|
| 919 |
-
except Exception as e:
|
| 920 |
-
logger.error(f"❌ Erro ao processar interação: {e}")
|
| 921 |
-
return {
|
| 922 |
-
'status': 'error',
|
| 923 |
-
'message': str(e)
|
| 924 |
-
}
|
| 925 |
-
|
| 926 |
-
# ============================================================================
|
| 927 |
-
# 🌐 INSTÂNCIA GLOBAL
|
| 928 |
-
# ============================================================================
|
| 929 |
-
_treinamento_instance = None
|
| 930 |
-
|
| 931 |
-
def get_treinamento_instance(db: Database = None):
|
| 932 |
-
"""
|
| 933 |
-
Retorna instância singleton do treinamento
|
| 934 |
-
|
| 935 |
-
Args:
|
| 936 |
-
db: Instância do Database
|
| 937 |
-
|
| 938 |
-
Returns:
|
| 939 |
-
Instância do Treinamento
|
| 940 |
-
"""
|
| 941 |
-
global _treinamento_instance
|
| 942 |
-
if _treinamento_instance is None:
|
| 943 |
-
if db is None:
|
| 944 |
-
from .database import get_database
|
| 945 |
-
db = get_database()
|
| 946 |
-
_treinamento_instance = Treinamento(db, interval_hours=6)
|
| 947 |
-
return _treinamento_instance
|
| 948 |
-
|
| 949 |
-
# ============================================================================
|
| 950 |
-
# 🎯 FUNÇÃO DE INTEGRAÇÃO RÁPIDA (ATUALIZADA)
|
| 951 |
-
# ============================================================================
|
| 952 |
-
def registrar_interacao_rapida(
|
| 953 |
-
usuario: str,
|
| 954 |
-
numero: str,
|
| 955 |
-
mensagem: str,
|
| 956 |
-
resposta: str,
|
| 957 |
-
is_reply: bool = False,
|
| 958 |
-
reply_to_bot: bool = False,
|
| 959 |
-
tipo_conversa: str = 'pv',
|
| 960 |
-
tipo_mensagem: str = 'texto',
|
| 961 |
-
contexto: Dict = None,
|
| 962 |
-
reply_metadata: Optional[Dict] = None,
|
| 963 |
-
nivel_transicao: int = 0 # NOVO PARÂMETRO
|
| 964 |
-
) -> bool:
|
| 965 |
-
"""
|
| 966 |
-
Registra interação rapidamente - ATUALIZADA
|
| 967 |
-
|
| 968 |
-
Args:
|
| 969 |
-
usuario: Nome do usuário
|
| 970 |
-
numero: Número do usuário
|
| 971 |
-
mensagem: Mensagem enviada
|
| 972 |
-
resposta: Resposta gerada
|
| 973 |
-
is_reply: Se é reply
|
| 974 |
-
reply_to_bot: Se é reply ao bot
|
| 975 |
-
tipo_conversa: Tipo da conversa
|
| 976 |
-
tipo_mensagem: Tipo da mensagem
|
| 977 |
-
contexto: Contexto da conversa
|
| 978 |
-
reply_metadata: Metadata do reply
|
| 979 |
-
nivel_transicao: Nível de transição do usuário
|
| 980 |
-
|
| 981 |
-
Returns:
|
| 982 |
-
True se sucesso, False caso contrário
|
| 983 |
-
"""
|
| 984 |
-
try:
|
| 985 |
-
treinamento = get_treinamento_instance()
|
| 986 |
-
treinamento.registrar_interacao(
|
| 987 |
-
usuario=usuario,
|
| 988 |
-
mensagem=mensagem,
|
| 989 |
-
resposta=resposta,
|
| 990 |
-
numero=numero,
|
| 991 |
-
is_reply=is_reply,
|
| 992 |
-
reply_to_bot=reply_to_bot,
|
| 993 |
-
tipo_conversa=tipo_conversa,
|
| 994 |
-
tipo_mensagem=tipo_mensagem,
|
| 995 |
-
contexto=contexto,
|
| 996 |
-
reply_metadata=reply_metadata,
|
| 997 |
-
nivel_transicao=nivel_transicao # ADICIONADO
|
| 998 |
-
)
|
| 999 |
-
logger.debug(f"✅ Interação rápida registrada: {usuario[:10]} | Nível: {nivel_transicao}")
|
| 1000 |
-
return True
|
| 1001 |
-
except Exception as e:
|
| 1002 |
-
logger.error(f"❌ Erro no registro rápido: {e}")
|
| 1003 |
-
return False
|
| 1004 |
-
|
| 1005 |
-
# ============================================================================
|
| 1006 |
-
# 📊 TESTE E VALIDAÇÃO
|
| 1007 |
-
# ============================================================================
|
| 1008 |
-
if __name__ == "__main__":
|
| 1009 |
-
print("=" * 80)
|
| 1010 |
-
print("TESTANDO TREINAMENTO.PY - COMPLETO COM nivel_transicao")
|
| 1011 |
-
print("=" * 80)
|
| 1012 |
-
|
| 1013 |
-
from .database import Database
|
| 1014 |
-
|
| 1015 |
-
try:
|
| 1016 |
-
# Cria database de teste
|
| 1017 |
-
db = Database(":memory:")
|
| 1018 |
-
treinamento = Treinamento(db)
|
| 1019 |
-
|
| 1020 |
-
# Simula payload do api.py com reply_metadata e nivel_transicao
|
| 1021 |
-
payload_teste = {
|
| 1022 |
-
"usuario": "Isaac Teste",
|
| 1023 |
-
"numero": "244978787009",
|
| 1024 |
-
"mensagem": "Oi Akira, tudo bem?",
|
| 1025 |
-
"tipo_conversa": "pv",
|
| 1026 |
-
"tipo_mensagem": "texto",
|
| 1027 |
-
"reply_metadata": {
|
| 1028 |
-
"is_reply": True,
|
| 1029 |
-
"reply_to_bot": False,
|
| 1030 |
-
"quoted_author_name": "Outra Pessoa",
|
| 1031 |
-
"context_hint": "(Citando mensagem de Outra Pessoa)"
|
| 1032 |
-
},
|
| 1033 |
-
"analise": {
|
| 1034 |
-
"humor_atualizado": "normal_ironico",
|
| 1035 |
-
"modo_resposta": "normal_ironico",
|
| 1036 |
-
"nivel_transicao": 2,
|
| 1037 |
-
"info_transicao": {
|
| 1038 |
-
"desc": "Nível 2 - Formal Relaxado",
|
| 1039 |
-
"modo": "tecnico_formal",
|
| 1040 |
-
"deve_transicionar": False
|
| 1041 |
-
}
|
| 1042 |
-
}
|
| 1043 |
-
}
|
| 1044 |
-
|
| 1045 |
-
resposta_teste = "Tudo e tu, puto?"
|
| 1046 |
-
|
| 1047 |
-
# Processa interação com nivel_transicao
|
| 1048 |
-
resultado = treinamento.processar_interacao_api(payload_teste, resposta_teste)
|
| 1049 |
-
|
| 1050 |
-
print(f"✅ Teste OK: {resultado}")
|
| 1051 |
-
print(f"📝 Mensagem: {payload_teste['mensagem']}")
|
| 1052 |
-
print(f"💬 Resposta: {resposta_teste}")
|
| 1053 |
-
print(f"🎯 Nível transição: {payload_teste['analise']['nivel_transicao']}")
|
| 1054 |
-
|
| 1055 |
-
# Teste com registro rápido com nivel_transicao
|
| 1056 |
-
sucesso = registrar_interacao_rapida(
|
| 1057 |
-
usuario="Teste 2",
|
| 1058 |
-
numero="244000000000",
|
| 1059 |
-
mensagem="Qual é a tua?",
|
| 1060 |
-
resposta="Nada, cota.",
|
| 1061 |
-
is_reply=True,
|
| 1062 |
-
reply_to_bot=True,
|
| 1063 |
-
reply_metadata={"quoted_author_name": "Akira", "is_reply": True},
|
| 1064 |
-
nivel_transicao=3
|
| 1065 |
-
)
|
| 1066 |
-
|
| 1067 |
-
print(f"✅ Registro rápido: {'Sucesso' if sucesso else 'Falhou'}")
|
| 1068 |
-
|
| 1069 |
-
except Exception as e:
|
| 1070 |
-
print(f"❌ Erro: {e}")
|
| 1071 |
-
import traceback
|
| 1072 |
-
traceback.print_exc()
|
| 1073 |
-
|
| 1074 |
-
print("\n" + "=" * 80)
|
| 1075 |
-
print("TREINAMENTO.PY - COMPLETO COM SUPORTE A nivel_transicao")
|
| 1076 |
-
print("=" * 80)
|
|
|
|
| 1 |
+
# type: ignore
|
| 2 |
+
# treinamento.py
|
| 3 |
+
# ================================================================
|
| 4 |
+
# TREINAMENTO AVANÇADO 3-NÍVEIS - AKIRA IA V21 ULTIMATE
|
| 5 |
+
# ================================================================
|
| 6 |
+
# Arquitetura: Multi-nível (Emocional + NLP + API Adapter)
|
| 7 |
+
# NLP Levels: Basic → Intermediate → Advanced (BART + Transformers)
|
| 8 |
+
# Emoções: Análise avançada com BART + heurísticas
|
| 9 |
+
# APIs: Mistral, Gemini, Groq, Cohere, Together, HuggingFace
|
| 10 |
+
# ================================================================
|
| 11 |
+
|
| 12 |
+
import threading
|
| 13 |
+
import time
|
| 14 |
+
import json
|
| 15 |
+
import hashlib
|
| 16 |
+
from dataclasses import dataclass, field
|
| 17 |
+
from typing import Optional, List, Dict, Any, Tuple, Callable
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from datetime import datetime
|
| 20 |
+
import re
|
| 21 |
+
import random
|
| 22 |
+
|
| 23 |
+
# Imports opcionais com fallback (type: ignore para evitar erros de ambiente)
|
| 24 |
+
try:
|
| 25 |
+
import numpy as np # type: ignore
|
| 26 |
+
NUMPY_AVAILABLE = True
|
| 27 |
+
except Exception:
|
| 28 |
+
NUMPY_AVAILABLE = False
|
| 29 |
+
np = None # type: ignore
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
from loguru import logger # type: ignore
|
| 33 |
+
LOGURU_AVAILABLE = True
|
| 34 |
+
except Exception:
|
| 35 |
+
LOGURU_AVAILABLE = False
|
| 36 |
+
# Criar logger dummy para evitar erros de tipo
|
| 37 |
+
class DummyLogger:
|
| 38 |
+
def info(self, *args, **kwargs): pass
|
| 39 |
+
def success(self, *args, **kwargs): pass
|
| 40 |
+
def warning(self, *args, **kwargs): pass
|
| 41 |
+
def error(self, *args, **kwargs): pass
|
| 42 |
+
def debug(self, *args, **kwargs): pass
|
| 43 |
+
def exception(self, *args, **kwargs): pass
|
| 44 |
+
logger = DummyLogger() # type: ignore
|
| 45 |
+
|
| 46 |
+
try:
|
| 47 |
+
from sentence_transformers import SentenceTransformer # type: ignore
|
| 48 |
+
SENTENCE_TRANSFORMERS_AVAILABLE = True
|
| 49 |
+
except Exception as e:
|
| 50 |
+
SENTENCE_TRANSFORMERS_AVAILABLE = False
|
| 51 |
+
SentenceTransformer = None # type: ignore
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
import torch # type: ignore
|
| 55 |
+
TORCH_AVAILABLE = True
|
| 56 |
+
except Exception:
|
| 57 |
+
TORCH_AVAILABLE = False
|
| 58 |
+
torch = None # type: ignore
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
from transformers import AutoTokenizer, AutoModelForSequenceClassification # type: ignore
|
| 62 |
+
TRANSFORMERS_AVAILABLE = True
|
| 63 |
+
except Exception:
|
| 64 |
+
TRANSFORMERS_AVAILABLE = False
|
| 65 |
+
AutoTokenizer = None # type: ignore
|
| 66 |
+
AutoModelForSequenceClassification = None # type: ignore
|
| 67 |
+
|
| 68 |
+
# Imports locais
|
| 69 |
+
from . import config
|
| 70 |
+
from .database import Database
|
| 71 |
+
|
| 72 |
+
# ============================================================
|
| 73 |
+
# 🎯 CONFIGURAÇÕES DE TREINAMENTO
|
| 74 |
+
# ============================================================
|
| 75 |
+
|
| 76 |
+
@dataclass
|
| 77 |
+
class TrainingConfig:
|
| 78 |
+
"""Configuração do sistema de treinamento 3-níveis"""
|
| 79 |
+
# Nível 1: Emoções
|
| 80 |
+
enable_emotion_training: bool = True
|
| 81 |
+
emotion_model: str = config.BART_EMOTION_MODEL
|
| 82 |
+
emotion_confidence_threshold: float = 0.7
|
| 83 |
+
|
| 84 |
+
# Nível 2: NLP & Embeddings
|
| 85 |
+
enable_nlp_training: bool = True
|
| 86 |
+
embedding_model: str = config.EMBEDDING_MODEL
|
| 87 |
+
embedding_dim: int = config.EMBEDDING_DIM
|
| 88 |
+
|
| 89 |
+
# Nível 3: API Adapter
|
| 90 |
+
enable_api_training: bool = True
|
| 91 |
+
track_api_performance: bool = True
|
| 92 |
+
|
| 93 |
+
# Gerais
|
| 94 |
+
batch_size: int = 32
|
| 95 |
+
learning_rate: float = 0.001
|
| 96 |
+
max_samples_per_user: int = 100
|
| 97 |
+
training_interval_hours: int = 6
|
| 98 |
+
min_samples_for_training: int = 5
|
| 99 |
+
|
| 100 |
+
# Configuração ativa
|
| 101 |
+
TRAINING_CONFIG = TrainingConfig()
|
| 102 |
+
|
| 103 |
+
# ============================================================
|
| 104 |
+
# 🔧 EMBEDDINGS & MODELOS
|
| 105 |
+
# ============================================================
|
| 106 |
+
|
| 107 |
+
class EmbeddingManager:
|
| 108 |
+
"""Gerenciador de embeddings com suporte a múltiplos modelos"""
|
| 109 |
+
|
| 110 |
+
_instance = None
|
| 111 |
+
_model_lock = threading.Lock()
|
| 112 |
+
|
| 113 |
+
def __new__(cls):
|
| 114 |
+
if cls._instance is None:
|
| 115 |
+
cls._instance = super().__new__(cls)
|
| 116 |
+
cls._instance._initialized = False
|
| 117 |
+
return cls._instance
|
| 118 |
+
|
| 119 |
+
def __init__(self):
|
| 120 |
+
if self._initialized:
|
| 121 |
+
return
|
| 122 |
+
self._initialized = True
|
| 123 |
+
self._model = None
|
| 124 |
+
self._embedding_dim = None
|
| 125 |
+
|
| 126 |
+
def load_model(self, model_name: Optional[str] = None) -> bool:
|
| 127 |
+
"""Carrega modelo de embeddings sob demanda"""
|
| 128 |
+
if self._model is not None:
|
| 129 |
+
return True
|
| 130 |
+
|
| 131 |
+
with self._model_lock:
|
| 132 |
+
if self._model is not None:
|
| 133 |
+
return True
|
| 134 |
+
|
| 135 |
+
if not SENTENCE_TRANSFORMERS_AVAILABLE:
|
| 136 |
+
logger.warning("SentenceTransformers não disponível")
|
| 137 |
+
return False
|
| 138 |
+
|
| 139 |
+
model_to_load = model_name or TRAINING_CONFIG.embedding_model
|
| 140 |
+
|
| 141 |
+
try:
|
| 142 |
+
self._model = SentenceTransformer(model_to_load)
|
| 143 |
+
self._embedding_dim = self._model.get_sentence_embedding_dimension()
|
| 144 |
+
logger.success(f"✅ Embedding model carregado: {model_to_load} (dim={self._embedding_dim})")
|
| 145 |
+
return True
|
| 146 |
+
except Exception as e:
|
| 147 |
+
logger.error(f"❌ Erro ao carregar embedding model: {e}")
|
| 148 |
+
return False
|
| 149 |
+
|
| 150 |
+
def generate_embedding(self, text: str) -> Optional[Any]:
|
| 151 |
+
"""Gera embedding para texto"""
|
| 152 |
+
if not self.load_model():
|
| 153 |
+
return None
|
| 154 |
+
|
| 155 |
+
try:
|
| 156 |
+
emb = self._model.encode(text, convert_to_numpy=True)
|
| 157 |
+
return emb
|
| 158 |
+
except Exception as e:
|
| 159 |
+
logger.warning(f"Erro ao gerar embedding: {e}")
|
| 160 |
+
return None
|
| 161 |
+
|
| 162 |
+
def generate_batch_embeddings(self, texts: List[str]) -> Optional[Any]:
|
| 163 |
+
"""Gera embeddings para batch de textos"""
|
| 164 |
+
if not self.load_model():
|
| 165 |
+
return None
|
| 166 |
+
|
| 167 |
+
try:
|
| 168 |
+
embeddings = self._model.encode(texts, convert_to_numpy=True, batch_size=len(texts))
|
| 169 |
+
return embeddings
|
| 170 |
+
except Exception as e:
|
| 171 |
+
logger.warning(f"Erro ao gerar batch embeddings: {e}")
|
| 172 |
+
return None
|
| 173 |
+
|
| 174 |
+
def cosine_similarity(self, emb1: np.ndarray, emb2: np.ndarray) -> float:
|
| 175 |
+
"""Calcula similaridade de cossenos"""
|
| 176 |
+
try:
|
| 177 |
+
dot = np.dot(emb1, emb2)
|
| 178 |
+
norm1 = np.linalg.norm(emb1)
|
| 179 |
+
norm2 = np.linalg.norm(emb2)
|
| 180 |
+
if norm1 == 0 or norm2 == 0:
|
| 181 |
+
return 0.0
|
| 182 |
+
return float(dot / (norm1 * norm2))
|
| 183 |
+
except Exception:
|
| 184 |
+
return 0.0
|
| 185 |
+
|
| 186 |
+
@property
|
| 187 |
+
def embedding_dim(self) -> int:
|
| 188 |
+
return self._embedding_dim or TRAINING_CONFIG.embedding_dim
|
| 189 |
+
|
| 190 |
+
# Singleton
|
| 191 |
+
embedding_manager = EmbeddingManager()
|
| 192 |
+
|
| 193 |
+
# ============================================================
|
| 194 |
+
# 🎭 ANALISADOR DE EMOÇÕES (Via Singleton Central)
|
| 195 |
+
# ============================================================
|
| 196 |
+
|
| 197 |
+
# Singleton importado para não duplicar o modelo BART em memória
|
| 198 |
+
emotion_trainer = config.get_emotion_analyzer()
|
| 199 |
+
|
| 200 |
+
# ============================================================
|
| 201 |
+
# 🧠 API ADAPTER TRAINER
|
| 202 |
+
# ============================================================
|
| 203 |
+
|
| 204 |
+
class APIAdapterTrainer:
|
| 205 |
+
"""Treinador de adaptação para diferentes APIs (Mistral, Gemini, Groq, etc.)"""
|
| 206 |
+
|
| 207 |
+
def __init__(self, db: Database):
|
| 208 |
+
self.db = db
|
| 209 |
+
self.api_stats: Dict[str, Dict[str, Any]] = {}
|
| 210 |
+
self._init_api_tracking()
|
| 211 |
+
|
| 212 |
+
def _init_api_tracking(self):
|
| 213 |
+
"""Inicializa tracking de APIs"""
|
| 214 |
+
self.api_stats = {
|
| 215 |
+
"mistral": {"success": 0, "failure": 0, "avg_response_time": 0, "total_tokens": 0},
|
| 216 |
+
"gemini": {"success": 0, "failure": 0, "avg_response_time": 0, "total_tokens": 0},
|
| 217 |
+
"groq": {"success": 0, "failure": 0, "avg_response_time": 0, "total_tokens": 0},
|
| 218 |
+
"cohere": {"success": 0, "failure": 0, "avg_response_time": 0, "total_tokens": 0},
|
| 219 |
+
"together": {"success": 0, "failure": 0, "avg_response_time": 0, "total_tokens": 0},
|
| 220 |
+
"huggingface": {"success": 0, "failure": 0, "avg_response_time": 0, "total_tokens": 0}
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
def record_api_call(
|
| 224 |
+
self,
|
| 225 |
+
provider: str,
|
| 226 |
+
success: bool,
|
| 227 |
+
response_time: float,
|
| 228 |
+
tokens_used: int = 0,
|
| 229 |
+
error: Optional[str] = None
|
| 230 |
+
):
|
| 231 |
+
"""Registra chamada de API para treinamento"""
|
| 232 |
+
if provider not in self.api_stats:
|
| 233 |
+
return
|
| 234 |
+
|
| 235 |
+
stats = self.api_stats[provider]
|
| 236 |
+
|
| 237 |
+
if success:
|
| 238 |
+
stats["success"] += 1
|
| 239 |
+
# Média móvel do tempo de resposta
|
| 240 |
+
n = stats["success"]
|
| 241 |
+
stats["avg_response_time"] = ((n - 1) * stats["avg_response_time"] + response_time) / n
|
| 242 |
+
stats["total_tokens"] += tokens_used
|
| 243 |
+
else:
|
| 244 |
+
stats["failure"] += 1
|
| 245 |
+
|
| 246 |
+
# Salva no banco
|
| 247 |
+
self._save_api_stats(provider, stats)
|
| 248 |
+
|
| 249 |
+
def _save_api_stats(self, provider: str, stats: Dict[str, Any]):
|
| 250 |
+
"""Salva estatísticas da API no banco"""
|
| 251 |
+
try:
|
| 252 |
+
self.db.salvar_aprendizado_detalhado(
|
| 253 |
+
f"api_{provider}",
|
| 254 |
+
"stats",
|
| 255 |
+
json.dumps(stats)
|
| 256 |
+
)
|
| 257 |
+
except Exception as e:
|
| 258 |
+
logger.warning(f"Erro ao salvar stats da API {provider}: {e}")
|
| 259 |
+
|
| 260 |
+
def get_best_provider(self) -> str:
|
| 261 |
+
"""Retorna o melhor provider baseado em成功率 e tempo"""
|
| 262 |
+
best_score = -1
|
| 263 |
+
best_provider = "mistral"
|
| 264 |
+
|
| 265 |
+
for provider, stats in self.api_stats.items():
|
| 266 |
+
if stats["success"] + stats["failure"] < 5:
|
| 267 |
+
continue
|
| 268 |
+
|
| 269 |
+
success_rate = stats["success"] / (stats["success"] + stats["failure"]) if (stats["success"] + stats["failure"]) > 0 else 0
|
| 270 |
+
avg_time = stats["avg_response_time"]
|
| 271 |
+
|
| 272 |
+
# Score: sucesso alto + tempo baixo
|
| 273 |
+
score = success_rate * 0.7 + (1 / (1 + avg_time)) * 0.3
|
| 274 |
+
|
| 275 |
+
if score > best_score:
|
| 276 |
+
best_score = score
|
| 277 |
+
best_provider = provider
|
| 278 |
+
|
| 279 |
+
return best_provider
|
| 280 |
+
|
| 281 |
+
def get_provider_stats(self, provider: str) -> Dict[str, Any]:
|
| 282 |
+
"""Retorna estatísticas de um provider"""
|
| 283 |
+
return self.api_stats.get(provider, {})
|
| 284 |
+
|
| 285 |
+
# ============================================================
|
| 286 |
+
# 📊 HEURÍSTICAS E DICIONÁRIOS
|
| 287 |
+
# ============================================================
|
| 288 |
+
|
| 289 |
+
# Palavras para análise heurística
|
| 290 |
+
PALAVRAS_POSITIVAS = ['bom', 'ótimo', 'incrível', 'feliz', 'adorei', 'top', 'fixe', 'bué', 'show', 'legal', 'bacana', 'wah']
|
| 291 |
+
PALAVRAS_NEGATIVAS = ['ruim', 'péssimo', 'triste', 'ódio', 'raiva', 'chateado', 'merda', 'porra', 'odeio', 'caralho']
|
| 292 |
+
PALAVRAS_RUDES = ['caralho', 'puta', 'merda', 'fdp', 'vsf', 'krl', 'porra', 'desgraça']
|
| 293 |
+
|
| 294 |
+
# Gírias angolanas para treinamento
|
| 295 |
+
GIRIAS_ANGOLANAS = {
|
| 296 |
+
"puto": ("rapaz/rapariga", "casual"),
|
| 297 |
+
"mano": ("amigo", "casual"),
|
| 298 |
+
"kota": ("rapaz da cidade", "urbano"),
|
| 299 |
+
"mwangolé": ("rapaz do subúrbio", "subúrbio"),
|
| 300 |
+
"cota": ("dinheiro", "casual"),
|
| 301 |
+
"fixe": ("bom/ótimo", "positivo"),
|
| 302 |
+
"bué": ("muito", "intensificador"),
|
| 303 |
+
"oroh": ("pessoa chata", "negativo"),
|
| 304 |
+
"baza": ("terminar", "casual"),
|
| 305 |
+
"kuduro": ("dança urbana", "cultural"),
|
| 306 |
+
"sassa": ("sofisticado", "urbano"),
|
| 307 |
+
"kalembe": ("ridículo", "negativo"),
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
# Intenções para treinamento
|
| 311 |
+
INTENCOES_TREINAMENTO = {
|
| 312 |
+
"saudacao": ["ola", "oi", "bom dia", "boa tarde", "boa noite", "como vai", "e aí"],
|
| 313 |
+
"pergunta": ["?", "porquê", "porque", "como", "o que", "qual", "onde", "quando", "quanto"],
|
| 314 |
+
"afirmacao": ["acho", "creio", "penso", "sei que", "tenho certeza"],
|
| 315 |
+
"despedida": ["tchau", "até mais", "adeus", "fim", "parar"],
|
| 316 |
+
"agradecimento": ["obrigado", "thanks", "grato", "agradecido"],
|
| 317 |
+
"elogio": ["fixe", "bom trabalho", "parabéns", "incrível", "show"],
|
| 318 |
+
"reclamacao": ["ruim", "péssimo", "odeio", "não gostei", "decepcionado"]
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
# ============================================================
|
| 322 |
+
# 🎯 ESTRUTURAS DE DADOS
|
| 323 |
+
# ============================================================
|
| 324 |
+
|
| 325 |
+
@dataclass
|
| 326 |
+
class Interacao:
|
| 327 |
+
"""Estrutura de uma interação para treinamento"""
|
| 328 |
+
usuario: str
|
| 329 |
+
mensagem: str
|
| 330 |
+
resposta: str
|
| 331 |
+
numero: str
|
| 332 |
+
is_reply: bool = False
|
| 333 |
+
mensagem_original: str = ""
|
| 334 |
+
timestamp: float = field(default_factory=time.time)
|
| 335 |
+
emocao: str = "neutral"
|
| 336 |
+
confianca_emocao: float = 0.5
|
| 337 |
+
intencao: str = "pergunta"
|
| 338 |
+
api_usada: str = ""
|
| 339 |
+
tokens_usados: int = 0
|
| 340 |
+
response_time: float = 0.0
|
| 341 |
+
|
| 342 |
+
@dataclass
|
| 343 |
+
class TrainingResult:
|
| 344 |
+
"""Resultado de um ciclo de treinamento"""
|
| 345 |
+
nivel: str
|
| 346 |
+
amostras_processadas: int
|
| 347 |
+
embeddings_atualizados: int
|
| 348 |
+
emocoes_aprendidas: int
|
| 349 |
+
gírias_aprendidas: int
|
| 350 |
+
api_adaptations: int
|
| 351 |
+
duracao_segundos: float
|
| 352 |
+
sucesso: bool
|
| 353 |
+
erro: Optional[str] = None
|
| 354 |
+
|
| 355 |
+
# ============================================================
|
| 356 |
+
# 🏗️ CLASSE PRINCIPAL DE TREINAMENTO
|
| 357 |
+
# ============================================================
|
| 358 |
+
|
| 359 |
+
class Treinamento:
|
| 360 |
+
"""
|
| 361 |
+
Sistema de treinamento avançado 3-níveis:
|
| 362 |
+
- Nível 1: Emoções (BART + Heurísticas)
|
| 363 |
+
- Nível 2: NLP & Embeddings (SentenceTransformers)
|
| 364 |
+
- Nível 3: API Adapter (Mistral, Gemini, Groq, etc.)
|
| 365 |
+
"""
|
| 366 |
+
|
| 367 |
+
def __init__(
|
| 368 |
+
self,
|
| 369 |
+
db: Database,
|
| 370 |
+
contexto: Optional[Any] = None,
|
| 371 |
+
interval_hours: int = 6
|
| 372 |
+
):
|
| 373 |
+
self.db = db
|
| 374 |
+
self.contexto = contexto
|
| 375 |
+
self.interval_hours = interval_hours
|
| 376 |
+
|
| 377 |
+
# Threading
|
| 378 |
+
self._thread = None
|
| 379 |
+
self._running = False
|
| 380 |
+
self._stop_event = threading.Event()
|
| 381 |
+
|
| 382 |
+
# Componentes
|
| 383 |
+
self.api_trainer = APIAdapterTrainer(db)
|
| 384 |
+
|
| 385 |
+
# Usuários privilegiados
|
| 386 |
+
self.privileged_users = getattr(config, 'PRIVILEGED_USERS', ('244937035662', 'isaac', 'isaac quarenta'))
|
| 387 |
+
|
| 388 |
+
# Cache de treinamento
|
| 389 |
+
self._training_cache: Dict[str, Any] = {}
|
| 390 |
+
|
| 391 |
+
logger.info("🟢 Treinamento 3-níveis inicializado")
|
| 392 |
+
|
| 393 |
+
# ============================================================
|
| 394 |
+
# 📝 REGISTRO DE INTERAÇÕES
|
| 395 |
+
# ============================================================
|
| 396 |
+
|
| 397 |
+
def registrar_interacao(
|
| 398 |
+
self,
|
| 399 |
+
usuario: str,
|
| 400 |
+
mensagem: str,
|
| 401 |
+
resposta: str,
|
| 402 |
+
numero: str = '',
|
| 403 |
+
is_reply: bool = False,
|
| 404 |
+
mensagem_original: str = '',
|
| 405 |
+
api_usada: str = '',
|
| 406 |
+
tokens_usados: int = 0,
|
| 407 |
+
response_time: float = 0.0
|
| 408 |
+
) -> Interacao:
|
| 409 |
+
"""
|
| 410 |
+
Registra interação e executa aprendizado em tempo real
|
| 411 |
+
"""
|
| 412 |
+
# Cria estrutura de interação
|
| 413 |
+
interacao = Interacao(
|
| 414 |
+
usuario=usuario,
|
| 415 |
+
mensagem=mensagem,
|
| 416 |
+
resposta=resposta,
|
| 417 |
+
numero=numero,
|
| 418 |
+
is_reply=is_reply,
|
| 419 |
+
mensagem_original=mensagem_original,
|
| 420 |
+
api_usada=api_usada,
|
| 421 |
+
tokens_usados=tokens_usados,
|
| 422 |
+
response_time=response_time
|
| 423 |
+
)
|
| 424 |
+
|
| 425 |
+
try:
|
| 426 |
+
# Salva no banco
|
| 427 |
+
self.db.salvar_mensagem(usuario, mensagem, resposta, numero, is_reply, mensagem_original)
|
| 428 |
+
|
| 429 |
+
# Aprendizado em tempo real
|
| 430 |
+
self._aprender_em_tempo_real(interacao)
|
| 431 |
+
|
| 432 |
+
# Registra API call se aplicável
|
| 433 |
+
if api_usada:
|
| 434 |
+
self.api_trainer.record_api_call(
|
| 435 |
+
provider=api_usada,
|
| 436 |
+
success=True,
|
| 437 |
+
response_time=response_time,
|
| 438 |
+
tokens_used=tokens_usados
|
| 439 |
+
)
|
| 440 |
+
|
| 441 |
+
except Exception as e:
|
| 442 |
+
logger.error(f"Erro ao registrar interação: {e}")
|
| 443 |
+
if api_usada:
|
| 444 |
+
self.api_trainer.record_api_call(
|
| 445 |
+
provider=api_usada,
|
| 446 |
+
success=False,
|
| 447 |
+
response_time=response_time,
|
| 448 |
+
error=str(e)
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
return interacao
|
| 452 |
+
|
| 453 |
+
def _aprender_em_tempo_real(self, interacao: Interacao):
|
| 454 |
+
"""Aprendizado em tempo real (Nível 1 + 2)"""
|
| 455 |
+
if not interacao.numero:
|
| 456 |
+
return
|
| 457 |
+
|
| 458 |
+
# Combine mensagem + resposta para análise
|
| 459 |
+
texto_completo = f"{interacao.mensagem} {interacao.resposta}"
|
| 460 |
+
texto_lower = texto_completo.lower()
|
| 461 |
+
|
| 462 |
+
# === NÍVEL 1: Análise de Emoções ===
|
| 463 |
+
# Correção Pylance: verifica se emotion_trainer está disponível
|
| 464 |
+
if emotion_trainer is not None:
|
| 465 |
+
analise_emocao = emotion_trainer.analisar(interacao.mensagem)
|
| 466 |
+
interacao.emocao = analise_emocao.get('emocao', 'neutral')
|
| 467 |
+
interacao.confianca_emocao = analise_emocao.get('confianca', 0.5)
|
| 468 |
+
else:
|
| 469 |
+
interacao.emocao = 'neutral'
|
| 470 |
+
interacao.confianca_emocao = 0.5
|
| 471 |
+
|
| 472 |
+
# Salva emoção
|
| 473 |
+
self.db.salvar_aprendizado_detalhado(
|
| 474 |
+
interacao.numero,
|
| 475 |
+
"emocao_atual",
|
| 476 |
+
json.dumps({"emocao": interacao.emocao, "confianca": interacao.confianca_emocao})
|
| 477 |
+
)
|
| 478 |
+
|
| 479 |
+
# === NÍVEL 2: Embeddings ===
|
| 480 |
+
# Correção Pylance: verifica se embedding_manager e seu modelo estão disponíveis
|
| 481 |
+
if embedding_manager is not None and embedding_manager.load_model():
|
| 482 |
+
embedding = embedding_manager.generate_embedding(texto_completo)
|
| 483 |
+
if embedding is not None:
|
| 484 |
+
self.db.salvar_embedding(
|
| 485 |
+
interacao.numero,
|
| 486 |
+
interacao.mensagem,
|
| 487 |
+
interacao.resposta,
|
| 488 |
+
embedding
|
| 489 |
+
)
|
| 490 |
+
|
| 491 |
+
# === Análise de Intenção ===
|
| 492 |
+
intencao = self._detectar_intencao(texto_lower)
|
| 493 |
+
interacao.intencao = intencao
|
| 494 |
+
|
| 495 |
+
# === Heurística de Tom ===
|
| 496 |
+
tom = self._detectar_tom(texto_lower)
|
| 497 |
+
self.db.registrar_tom_usuario(
|
| 498 |
+
interacao.numero,
|
| 499 |
+
tom,
|
| 500 |
+
analise_emocao.get('confianca', 0.5),
|
| 501 |
+
texto_lower[:200]
|
| 502 |
+
)
|
| 503 |
+
|
| 504 |
+
# === Aprendizado de Gírias ===
|
| 505 |
+
self._aprender_girias(interacao.numero, texto_lower)
|
| 506 |
+
|
| 507 |
+
def _detectar_intencao(self, texto: str) -> str:
|
| 508 |
+
"""Detecta intenção do texto"""
|
| 509 |
+
for intencao, palavras in INTENCOES_TREINAMENTO.items():
|
| 510 |
+
if any(p in texto for p in palavras):
|
| 511 |
+
return intencao
|
| 512 |
+
return "pergunta" # Default
|
| 513 |
+
|
| 514 |
+
def _detectar_tom(self, texto: str) -> str:
|
| 515 |
+
"""Detecta tom do texto"""
|
| 516 |
+
rude_count = sum(1 for p in PALAVRAS_RUDES if p in texto)
|
| 517 |
+
formal_count = sum(1 for p in ["senhor", "doutor", "por favor", "agradecido"] if p in texto)
|
| 518 |
+
|
| 519 |
+
if rude_count > 0:
|
| 520 |
+
return "rude"
|
| 521 |
+
elif formal_count > 1:
|
| 522 |
+
return "formal"
|
| 523 |
+
elif any(p in texto for p in ["puto", "mano", "fixe", "kkk", "bué"]):
|
| 524 |
+
return "informal"
|
| 525 |
+
return "casual"
|
| 526 |
+
|
| 527 |
+
def _aprender_girias(self, numero: str, texto: str):
|
| 528 |
+
"""Aprende gírias do texto"""
|
| 529 |
+
for giria, (significado, _) in GIRIAS_ANGOLANAS.items():
|
| 530 |
+
if giria in texto:
|
| 531 |
+
try:
|
| 532 |
+
self.db.salvar_giria_aprendida(
|
| 533 |
+
numero,
|
| 534 |
+
giria,
|
| 535 |
+
significado,
|
| 536 |
+
texto[:100]
|
| 537 |
+
)
|
| 538 |
+
except Exception as e:
|
| 539 |
+
logger.warning(f"Erro ao salvar gíria {giria}: {e}")
|
| 540 |
+
|
| 541 |
+
# ============================================================
|
| 542 |
+
# 🎓 TREINAMENTO EM 3 NÍVEIS
|
| 543 |
+
# ============================================================
|
| 544 |
+
|
| 545 |
+
def train_all_levels(self) -> List[TrainingResult]:
|
| 546 |
+
"""
|
| 547 |
+
Executa treinamento completo em todos os níveis
|
| 548 |
+
Returns: Lista de resultados para cada nível
|
| 549 |
+
"""
|
| 550 |
+
resultados = []
|
| 551 |
+
start_time = time.time()
|
| 552 |
+
|
| 553 |
+
try:
|
| 554 |
+
# Nível 1: Emoções
|
| 555 |
+
logger.info("🎭 Treinando Nível 1: Emoções...")
|
| 556 |
+
resultado_n1 = self._train_nivel_emocoes()
|
| 557 |
+
resultados.append(resultado_n1)
|
| 558 |
+
|
| 559 |
+
# Nível 2: NLP & Embeddings
|
| 560 |
+
logger.info("🧠 Treinando Nível 2: NLP & Embeddings...")
|
| 561 |
+
resultado_n2 = self._train_nivel_nlp()
|
| 562 |
+
resultados.append(resultado_n2)
|
| 563 |
+
|
| 564 |
+
# Nível 3: API Adapter
|
| 565 |
+
logger.info("🔗 Treinando Nível 3: API Adapter...")
|
| 566 |
+
resultado_n3 = self._train_nivel_api()
|
| 567 |
+
resultados.append(resultado_n3)
|
| 568 |
+
|
| 569 |
+
duracao_total = time.time() - start_time
|
| 570 |
+
logger.success(f"✅ Treinamento completo: {duracao_total:.2f}s")
|
| 571 |
+
|
| 572 |
+
except Exception as e:
|
| 573 |
+
logger.error(f"❌ Erro no treinamento: {e}")
|
| 574 |
+
resultados.append(TrainingResult(
|
| 575 |
+
nivel="complete",
|
| 576 |
+
amostras_processadas=0,
|
| 577 |
+
embeddings_atualizados=0,
|
| 578 |
+
emocoes_aprendidas=0,
|
| 579 |
+
gírias_aprendidas=0,
|
| 580 |
+
api_adaptations=0,
|
| 581 |
+
duracao_segundos=time.time() - start_time,
|
| 582 |
+
sucesso=False,
|
| 583 |
+
erro=str(e)
|
| 584 |
+
))
|
| 585 |
+
|
| 586 |
+
return resultados
|
| 587 |
+
|
| 588 |
+
def _train_nivel_emocoes(self) -> TrainingResult:
|
| 589 |
+
"""Nível 1: Treinamento de emoções"""
|
| 590 |
+
start_time = time.time()
|
| 591 |
+
emocoes_aprendidas = 0
|
| 592 |
+
|
| 593 |
+
try:
|
| 594 |
+
# Recupera usuários com interações
|
| 595 |
+
usuarios = self._get_usuarios_para_treinamento()
|
| 596 |
+
|
| 597 |
+
for usuario in usuarios:
|
| 598 |
+
try:
|
| 599 |
+
# Recupera mensagens recentes
|
| 600 |
+
mensagens = self.db.recuperar_mensagens(usuario, limite=20)
|
| 601 |
+
|
| 602 |
+
for msg, resp in mensagens:
|
| 603 |
+
if msg and resp:
|
| 604 |
+
analise = emotion_trainer.analisar(msg)
|
| 605 |
+
|
| 606 |
+
# Salva aprendizado
|
| 607 |
+
self.db.salvar_aprendizado_detalhado(
|
| 608 |
+
usuario,
|
| 609 |
+
f"emocao_{int(time.time())}",
|
| 610 |
+
json.dumps(analise)
|
| 611 |
+
)
|
| 612 |
+
emocoes_aprendidas += 1
|
| 613 |
+
|
| 614 |
+
except Exception as e:
|
| 615 |
+
logger.warning(f"Erro ao treinar emoções para {usuario}: {e}")
|
| 616 |
+
|
| 617 |
+
return TrainingResult(
|
| 618 |
+
nivel="emocoes",
|
| 619 |
+
amostras_processadas=len(usuarios),
|
| 620 |
+
embeddings_atualizados=0,
|
| 621 |
+
emocoes_aprendidas=emocoes_aprendidas,
|
| 622 |
+
gírias_aprendidas=0,
|
| 623 |
+
api_adaptations=0,
|
| 624 |
+
duracao_segundos=time.time() - start_time,
|
| 625 |
+
sucesso=True
|
| 626 |
+
)
|
| 627 |
+
|
| 628 |
+
except Exception as e:
|
| 629 |
+
return TrainingResult(
|
| 630 |
+
nivel="emocoes",
|
| 631 |
+
amostras_processadas=0,
|
| 632 |
+
embeddings_atualizados=0,
|
| 633 |
+
emocoes_aprendidas=0,
|
| 634 |
+
gírias_aprendidas=0,
|
| 635 |
+
api_adaptations=0,
|
| 636 |
+
duracao_segundos=time.time() - start_time,
|
| 637 |
+
sucesso=False,
|
| 638 |
+
erro=str(e)
|
| 639 |
+
)
|
| 640 |
+
|
| 641 |
+
def _train_nivel_nlp(self) -> TrainingResult:
|
| 642 |
+
"""Nível 2: Treinamento de NLP & Embeddings"""
|
| 643 |
+
start_time = time.time()
|
| 644 |
+
embeddings_atualizados = 0
|
| 645 |
+
|
| 646 |
+
try:
|
| 647 |
+
if not embedding_manager.load_model():
|
| 648 |
+
raise Exception("Embedding model não disponível")
|
| 649 |
+
|
| 650 |
+
usuarios = self._get_usuarios_para_treinamento()
|
| 651 |
+
|
| 652 |
+
# Carrega modelo SentenceTransformers
|
| 653 |
+
model = embedding_manager._model
|
| 654 |
+
|
| 655 |
+
for usuario in usuarios:
|
| 656 |
+
try:
|
| 657 |
+
# Recupera mensagens
|
| 658 |
+
mensagens = self.db.recuperar_mensagens(usuario, limite=20)
|
| 659 |
+
|
| 660 |
+
# Prepara batch
|
| 661 |
+
textos = []
|
| 662 |
+
for msg, resp in mensagens:
|
| 663 |
+
if msg and resp:
|
| 664 |
+
textos.append(f"{msg} {resp}")
|
| 665 |
+
|
| 666 |
+
if textos:
|
| 667 |
+
# Gera batch embeddings
|
| 668 |
+
embeddings = embedding_manager.generate_batch_embeddings(textos)
|
| 669 |
+
|
| 670 |
+
if embeddings is not None:
|
| 671 |
+
# Salva embeddings no banco
|
| 672 |
+
for i, (msg, resp) in enumerate(mensagens[:len(textos)]):
|
| 673 |
+
if i < len(embeddings):
|
| 674 |
+
self.db.salvar_embedding(
|
| 675 |
+
usuario,
|
| 676 |
+
msg,
|
| 677 |
+
resp,
|
| 678 |
+
embeddings[i]
|
| 679 |
+
)
|
| 680 |
+
embeddings_atualizados += 1
|
| 681 |
+
|
| 682 |
+
except Exception as e:
|
| 683 |
+
logger.warning(f"Erro ao treinar NLP para {usuario}: {e}")
|
| 684 |
+
|
| 685 |
+
return TrainingResult(
|
| 686 |
+
nivel="nlp",
|
| 687 |
+
amostras_processadas=len(usuarios),
|
| 688 |
+
embeddings_atualizados=embeddings_atualizados,
|
| 689 |
+
emocoes_aprendidas=0,
|
| 690 |
+
gírias_aprendidas=0,
|
| 691 |
+
api_adaptations=0,
|
| 692 |
+
duracao_segundos=time.time() - start_time,
|
| 693 |
+
sucesso=True
|
| 694 |
+
)
|
| 695 |
+
|
| 696 |
+
except Exception as e:
|
| 697 |
+
return TrainingResult(
|
| 698 |
+
nivel="nlp",
|
| 699 |
+
amostras_processadas=0,
|
| 700 |
+
embeddings_atualizados=0,
|
| 701 |
+
emocoes_aprendidas=0,
|
| 702 |
+
gírias_aprendidas=0,
|
| 703 |
+
api_adaptations=0,
|
| 704 |
+
duracao_segundos=time.time() - start_time,
|
| 705 |
+
sucesso=False,
|
| 706 |
+
erro=str(e)
|
| 707 |
+
)
|
| 708 |
+
|
| 709 |
+
def _train_nivel_api(self) -> TrainingResult:
|
| 710 |
+
"""Nível 3: Treinamento de API Adapter"""
|
| 711 |
+
start_time = time.time()
|
| 712 |
+
api_adaptations = 0
|
| 713 |
+
|
| 714 |
+
try:
|
| 715 |
+
# Analisa performance das APIs
|
| 716 |
+
for provider in self.api_trainer.api_stats.keys():
|
| 717 |
+
stats = self.api_trainer.api_stats[provider]
|
| 718 |
+
total = stats["success"] + stats["failure"]
|
| 719 |
+
|
| 720 |
+
if total > 0:
|
| 721 |
+
success_rate = stats["success"] / total
|
| 722 |
+
|
| 723 |
+
# Se success rate < 80%, ajusta estratégia
|
| 724 |
+
if success_rate < 0.8:
|
| 725 |
+
# Salva adaptação necessária
|
| 726 |
+
self.db.salvar_aprendizado_detalhado(
|
| 727 |
+
f"api_strategy_{provider}",
|
| 728 |
+
"needs_adjustment",
|
| 729 |
+
json.dumps({
|
| 730 |
+
"success_rate": success_rate,
|
| 731 |
+
"avg_response_time": stats["avg_response_time"],
|
| 732 |
+
"timestamp": time.time()
|
| 733 |
+
})
|
| 734 |
+
)
|
| 735 |
+
api_adaptations += 1
|
| 736 |
+
|
| 737 |
+
return TrainingResult(
|
| 738 |
+
nivel="api",
|
| 739 |
+
amostras_processadas=0,
|
| 740 |
+
embeddings_atualizados=0,
|
| 741 |
+
emocoes_aprendidas=0,
|
| 742 |
+
gírias_aprendidas=0,
|
| 743 |
+
api_adaptations=api_adaptations,
|
| 744 |
+
duracao_segundos=time.time() - start_time,
|
| 745 |
+
sucesso=True
|
| 746 |
+
)
|
| 747 |
+
|
| 748 |
+
except Exception as e:
|
| 749 |
+
return TrainingResult(
|
| 750 |
+
nivel="api",
|
| 751 |
+
amostras_processadas=0,
|
| 752 |
+
embeddings_atualizados=0,
|
| 753 |
+
emocoes_aprendidas=0,
|
| 754 |
+
gírias_aprendidas=0,
|
| 755 |
+
api_adaptations=0,
|
| 756 |
+
duracao_segundos=time.time() - start_time,
|
| 757 |
+
sucesso=False,
|
| 758 |
+
erro=str(e)
|
| 759 |
+
)
|
| 760 |
+
|
| 761 |
+
def _get_usuarios_para_treinamento(self) -> List[str]:
|
| 762 |
+
"""Retorna lista de usuários para treinamento"""
|
| 763 |
+
try:
|
| 764 |
+
# Consulta usuários com mensagens
|
| 765 |
+
result = self.db._execute_with_retry(
|
| 766 |
+
"SELECT DISTINCT usuario FROM mensagens ORDER BY id DESC LIMIT 50"
|
| 767 |
+
)
|
| 768 |
+
return [r[0] for r in result] if result else []
|
| 769 |
+
except Exception:
|
| 770 |
+
return []
|
| 771 |
+
|
| 772 |
+
# ============================================================
|
| 773 |
+
# 🔄 LOOP PERIÓDICO
|
| 774 |
+
# ============================================================
|
| 775 |
+
|
| 776 |
+
def _run_loop(self):
|
| 777 |
+
"""Loop de treinamento periódico"""
|
| 778 |
+
interval = max(1, self.interval_hours) * 3600
|
| 779 |
+
|
| 780 |
+
while not self._stop_event.is_set():
|
| 781 |
+
try:
|
| 782 |
+
if self._running:
|
| 783 |
+
self.train_all_levels()
|
| 784 |
+
except Exception as e:
|
| 785 |
+
logger.exception(f"Erro no loop de treinamento: {e}")
|
| 786 |
+
|
| 787 |
+
# Espera com suporte a parada
|
| 788 |
+
for _ in range(int(interval)):
|
| 789 |
+
if self._stop_event.is_set():
|
| 790 |
+
break
|
| 791 |
+
time.sleep(1)
|
| 792 |
+
|
| 793 |
+
def start_periodic_training(self):
|
| 794 |
+
"""Inicia treinamento periódico"""
|
| 795 |
+
if self._running:
|
| 796 |
+
return
|
| 797 |
+
|
| 798 |
+
self._running = True
|
| 799 |
+
self._stop_event.clear()
|
| 800 |
+
self._thread = threading.Thread(target=self._run_loop, daemon=True)
|
| 801 |
+
self._thread.start()
|
| 802 |
+
logger.info(f"🚀 Treinamento periódico iniciado (intervalo: {self.interval_hours}h)")
|
| 803 |
+
|
| 804 |
+
def stop(self):
|
| 805 |
+
"""Para treinamento periódico"""
|
| 806 |
+
self._running = False
|
| 807 |
+
self._stop_event.set()
|
| 808 |
+
if self._thread:
|
| 809 |
+
self._thread.join(timeout=5)
|
| 810 |
+
logger.info("⏹️ Treinamento periódico parado")
|
| 811 |
+
|
| 812 |
+
# ============================================================
|
| 813 |
+
# 📊 UTILITÁRIOS
|
| 814 |
+
# ============================================================
|
| 815 |
+
|
| 816 |
+
def get_treinamento_status(self) -> Dict[str, Any]:
|
| 817 |
+
"""Retorna status do treinamento"""
|
| 818 |
+
return {
|
| 819 |
+
"running": self._running,
|
| 820 |
+
"interval_hours": self.interval_hours,
|
| 821 |
+
"embedding_available": embedding_manager.load_model(),
|
| 822 |
+
"emotion_model_available": emotion_trainer.load_model(),
|
| 823 |
+
"api_stats": self.api_trainer.api_stats,
|
| 824 |
+
"privileged_users": len(self.privileged_users)
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
def obter_estatisticas(self) -> Dict[str, Any]:
|
| 828 |
+
"""
|
| 829 |
+
Retorna estatísticas do treinamento.
|
| 830 |
+
Método para compatibilidade com testar_correcoes.py
|
| 831 |
+
"""
|
| 832 |
+
return {
|
| 833 |
+
"status": self.get_treinamento_status(),
|
| 834 |
+
"api_stats": self.api_trainer.api_stats,
|
| 835 |
+
"usuarios_privilegiados": len(self.privileged_users),
|
| 836 |
+
"embedding_disponivel": embedding_manager.load_model(),
|
| 837 |
+
"emotion_model_disponivel": emotion_trainer.load_model()
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
def limpar_dataset(self) -> bool:
|
| 841 |
+
"""
|
| 842 |
+
Limpa o cache/dataset de treinamento.
|
| 843 |
+
Método para compatibilidade com testar_correcoes.py
|
| 844 |
+
"""
|
| 845 |
+
try:
|
| 846 |
+
self._training_cache.clear()
|
| 847 |
+
logger.info("Dataset de treinamento limpo")
|
| 848 |
+
return True
|
| 849 |
+
except Exception as e:
|
| 850 |
+
logger.error(f"Erro ao limpar dataset: {e}")
|
| 851 |
+
return False
|
| 852 |
+
|
| 853 |
+
def force_train(self) -> List[TrainingResult]:
|
| 854 |
+
"""Força treinamento imediato"""
|
| 855 |
+
return self.train_all_levels()
|
| 856 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
modules/treinamento_modelo.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
import json
|
| 4 |
+
from typing import List, Dict, Any, Optional
|
| 5 |
+
from loguru import logger
|
| 6 |
+
from .database import Database
|
| 7 |
+
|
| 8 |
+
try:
|
| 9 |
+
import torch
|
| 10 |
+
from transformers import AutoTokenizer, AutoModelForCausalLM, TrainingArguments, Trainer, DataCollatorForLanguageModeling
|
| 11 |
+
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
|
| 12 |
+
TRAINING_SUPPORTED = True
|
| 13 |
+
except ImportError:
|
| 14 |
+
TRAINING_SUPPORTED = False
|
| 15 |
+
|
| 16 |
+
class ModelTrainer:
|
| 17 |
+
"""
|
| 18 |
+
Classe dedicada ao treinamento (fine-tuning) do modelo local da AKIRA.
|
| 19 |
+
Focado em PEFT (LoRA) para economia de memória em ambientes como HF Spaces.
|
| 20 |
+
"""
|
| 21 |
+
def __init__(self, db: Database, model_id: str = "TinyLlama/TinyLlama-1.1B-Chat-v1.0"):
|
| 22 |
+
self.db = db
|
| 23 |
+
self.model_id = model_id
|
| 24 |
+
self.output_dir = "./models/akira-tuned"
|
| 25 |
+
self.is_training = False
|
| 26 |
+
|
| 27 |
+
def prepare_dataset_from_db(self, min_rating: int = 4) -> List[Dict[str, str]]:
|
| 28 |
+
"""Extrai conversas do banco de dados para formatar o dataset de treino."""
|
| 29 |
+
# Aqui pegamos mensagens onde o bot teve boa performance ou interações ricas
|
| 30 |
+
# Nota: Adaptar queries conforme a estrutura real do seu DB
|
| 31 |
+
conversas = self.db.recuperar_historico_global(limite=500)
|
| 32 |
+
formatted_data = []
|
| 33 |
+
|
| 34 |
+
for msg in conversas:
|
| 35 |
+
# Formato ChatML ou similar para TinyLlama
|
| 36 |
+
# <|system|>...<|user|>...<|assistant|>...
|
| 37 |
+
text = f"<|user|>\n{msg.get('mensagem')}\n<|assistant|>\n{msg.get('resposta')}"
|
| 38 |
+
formatted_data.append({"text": text})
|
| 39 |
+
|
| 40 |
+
return formatted_data
|
| 41 |
+
|
| 42 |
+
def start_finetuning(self, epochs: int = 1):
|
| 43 |
+
"""Inicia o processo de Fine-tuning LoRA em background."""
|
| 44 |
+
if not TRAINING_SUPPORTED:
|
| 45 |
+
return {"success": False, "error": "Bibliotecas de treinamento (peft/transformers) não instaladas."}
|
| 46 |
+
|
| 47 |
+
if self.is_training:
|
| 48 |
+
return {"success": False, "error": "Treinamento já em andamento."}
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
self.is_training = True
|
| 52 |
+
logger.info(f"🚀 Iniciando Fine-tuning LoRA no modelo {self.model_id}")
|
| 53 |
+
|
| 54 |
+
# 1. Carregar Tokenizer e Modelo (Quantizado para CPU se necessário)
|
| 55 |
+
tokenizer = AutoTokenizer.from_pretrained(self.model_id)
|
| 56 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 57 |
+
|
| 58 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 59 |
+
self.model_id,
|
| 60 |
+
device_map="auto", # Ou "cpu" explicitamente para HF Spaces Free
|
| 61 |
+
torch_dtype=torch.float32 # CPU prefere float32 ou bfloat16
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
# 2. Configurar LoRA
|
| 65 |
+
config = LoraConfig(
|
| 66 |
+
r=8,
|
| 67 |
+
lora_alpha=32,
|
| 68 |
+
target_modules=["q_proj", "v_proj"],
|
| 69 |
+
lora_dropout=0.05,
|
| 70 |
+
bias="none",
|
| 71 |
+
task_type="CAUSAL_LM"
|
| 72 |
+
)
|
| 73 |
+
model = get_peft_model(model, config)
|
| 74 |
+
|
| 75 |
+
# 3. Preparar Dados
|
| 76 |
+
dataset = self.prepare_dataset_from_db()
|
| 77 |
+
if not dataset:
|
| 78 |
+
self.is_training = False
|
| 79 |
+
return {"success": False, "error": "Dataset vazio. Sem conversas suficientes."}
|
| 80 |
+
|
| 81 |
+
# 4. Loop de Treino (Simplificado para o exemplo)
|
| 82 |
+
# Em produção, usaria o Trainer da HuggingFace aqui
|
| 83 |
+
logger.warning("Treinamento LoRA em CPU é extremamente lento no HF Spaces Free.")
|
| 84 |
+
|
| 85 |
+
# Salvar progresso
|
| 86 |
+
model.save_pretrained(self.output_dir)
|
| 87 |
+
tokenizer.save_pretrained(self.output_dir)
|
| 88 |
+
|
| 89 |
+
self.is_training = False
|
| 90 |
+
return {"success": True, "path": self.output_dir}
|
| 91 |
+
|
| 92 |
+
except Exception as e:
|
| 93 |
+
self.is_training = False
|
| 94 |
+
logger.exception(f"Erro no treinamento: {e}")
|
| 95 |
+
return {"success": False, "error": str(e)}
|
| 96 |
+
|
| 97 |
+
_trainer = None
|
| 98 |
+
|
| 99 |
+
def get_model_trainer(db: Database) -> ModelTrainer:
|
| 100 |
+
global _trainer
|
| 101 |
+
if not _trainer:
|
| 102 |
+
_trainer = ModelTrainer(db)
|
| 103 |
+
return _trainer
|
modules/unified_context.py
ADDED
|
@@ -0,0 +1,894 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# type: ignore
|
| 2 |
+
"""
|
| 3 |
+
================================================================================
|
| 4 |
+
AKIRA V21 ULTIMATE - UNIFIED CONTEXT MODULE
|
| 5 |
+
================================================================================
|
| 6 |
+
Sistema unificado que integra Reply Context + Short-Term Memory em sintonia.
|
| 7 |
+
|
| 8 |
+
Philosophy: "Reply context e STM devem trabalhar em sintonia como tik e tack -
|
| 9 |
+
um fornece o contexto imediato/urgente (o que o usuário está respondendo),
|
| 10 |
+
o outro fornece o fluxo da conversa (contexto geral)."
|
| 11 |
+
|
| 12 |
+
Features:
|
| 13 |
+
- Integração seamless entre reply context e STM
|
| 14 |
+
- Token budgeting inteligente entre os dois contextos
|
| 15 |
+
- Priorização dinâmica baseada no tipo de mensagem
|
| 16 |
+
- Suporte a perguntas curtas com reply (prioridade máxima)
|
| 17 |
+
- Persistência e restauração de contexto unificado
|
| 18 |
+
================================================================================
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import os
|
| 22 |
+
import sys
|
| 23 |
+
import time
|
| 24 |
+
import json
|
| 25 |
+
import logging
|
| 26 |
+
from typing import Optional, Dict, Any, List, Tuple
|
| 27 |
+
from dataclasses import dataclass, field
|
| 28 |
+
from datetime import datetime
|
| 29 |
+
|
| 30 |
+
# Imports robustos com fallback
|
| 31 |
+
try:
|
| 32 |
+
import modules.config as config
|
| 33 |
+
from .short_term_memory import (
|
| 34 |
+
ShortTermMemory,
|
| 35 |
+
MessageWithContext,
|
| 36 |
+
IMPORTANCIA_NORMAL,
|
| 37 |
+
IMPORTANCIA_REPLY,
|
| 38 |
+
IMPORTANCIA_REPLY_TO_BOT,
|
| 39 |
+
IMPORTANCIA_PERGUNTA_CURTA_REPLY,
|
| 40 |
+
estimar_tokens,
|
| 41 |
+
is_pergunta_curta
|
| 42 |
+
)
|
| 43 |
+
from .reply_context_handler import (
|
| 44 |
+
ReplyContextHandler,
|
| 45 |
+
ProcessedReplyContext,
|
| 46 |
+
PRIORITY_REPLY,
|
| 47 |
+
PRIORITY_REPLY_TO_BOT,
|
| 48 |
+
PRIORITY_REPLY_TO_BOT_SHORT_QUESTION
|
| 49 |
+
)
|
| 50 |
+
UNIFIED_CONTEXT_AVAILABLE = True
|
| 51 |
+
except ImportError as e:
|
| 52 |
+
UNIFIED_CONTEXT_AVAILABLE = False
|
| 53 |
+
config = None
|
| 54 |
+
|
| 55 |
+
logger = logging.getLogger(__name__)
|
| 56 |
+
|
| 57 |
+
# ============================================================
|
| 58 |
+
# CONFIGURAÇÃO DE TOKEN BUDGET
|
| 59 |
+
# ============================================================
|
| 60 |
+
|
| 61 |
+
@dataclass
|
| 62 |
+
class ContextTokenBudget:
|
| 63 |
+
"""
|
| 64 |
+
Alocação de tokens entre reply context e STM.
|
| 65 |
+
|
| 66 |
+
Philosophy: Reply tem orçamento dedicado (urgente), STM tem o resto (fluxo).
|
| 67 |
+
"""
|
| 68 |
+
total_budget: int = 8000
|
| 69 |
+
system_tokens: int = 1500
|
| 70 |
+
user_message_tokens: int = 500
|
| 71 |
+
|
| 72 |
+
# Reply context budget (URGENTE)
|
| 73 |
+
reply_tokens: int = 300
|
| 74 |
+
reply_priority_multiplier: float = 1.0
|
| 75 |
+
|
| 76 |
+
# STM budget (FLUXO DA CONVERSA)
|
| 77 |
+
stm_tokens: int = 4000
|
| 78 |
+
|
| 79 |
+
# Reservado para resposta
|
| 80 |
+
response_reserved: int = 1200
|
| 81 |
+
|
| 82 |
+
def calculate(self, is_reply: bool, reply_priority: int = 1) -> 'ContextTokenBudget':
|
| 83 |
+
"""
|
| 84 |
+
Calcula orçamento baseado no tipo de mensagem.
|
| 85 |
+
|
| 86 |
+
Args:
|
| 87 |
+
is_reply: Se é um reply
|
| 88 |
+
reply_priority: Nível de prioridade do reply (1-4)
|
| 89 |
+
|
| 90 |
+
Returns:
|
| 91 |
+
ContextTokenBudget ajustado
|
| 92 |
+
"""
|
| 93 |
+
budget = ContextTokenBudget(
|
| 94 |
+
total_budget=self.total_budget,
|
| 95 |
+
system_tokens=self.system_tokens,
|
| 96 |
+
user_message_tokens=self.user_message_tokens
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
if is_reply:
|
| 100 |
+
if reply_priority >= PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
|
| 101 |
+
# Pergunta curta com reply ao bot = prioridade máxima
|
| 102 |
+
budget.reply_tokens = min(1500, int(self.total_budget * 0.20))
|
| 103 |
+
budget.reply_priority_multiplier = 1.5
|
| 104 |
+
budget.stm_tokens = min(3500, int(self.total_budget * 0.45))
|
| 105 |
+
elif reply_priority >= PRIORITY_REPLY_TO_BOT:
|
| 106 |
+
# Reply ao bot
|
| 107 |
+
budget.reply_tokens = min(1200, int(self.total_budget * 0.15))
|
| 108 |
+
budget.reply_priority_multiplier = 1.3
|
| 109 |
+
budget.stm_tokens = min(4000, int(self.total_budget * 0.50))
|
| 110 |
+
elif reply_priority >= PRIORITY_REPLY:
|
| 111 |
+
# Reply normal
|
| 112 |
+
budget.reply_tokens = min(800, int(self.total_budget * 0.10))
|
| 113 |
+
budget.reply_priority_multiplier = 1.1
|
| 114 |
+
budget.stm_tokens = min(4500, int(self.total_budget * 0.55))
|
| 115 |
+
else:
|
| 116 |
+
# Mensagem normal = STM tem orçamento completo
|
| 117 |
+
budget.reply_tokens = 0
|
| 118 |
+
budget.stm_tokens = min(5000, int(self.total_budget * 0.65))
|
| 119 |
+
|
| 120 |
+
# Calcula response reserved
|
| 121 |
+
budget.response_reserved = (
|
| 122 |
+
budget.total_budget -
|
| 123 |
+
budget.system_tokens -
|
| 124 |
+
budget.user_message_tokens -
|
| 125 |
+
budget.reply_tokens -
|
| 126 |
+
budget.stm_tokens
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
return budget
|
| 130 |
+
|
| 131 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 132 |
+
"""Serializa para dicionário."""
|
| 133 |
+
return {
|
| 134 |
+
"total_budget": self.total_budget,
|
| 135 |
+
"system_tokens": self.system_tokens,
|
| 136 |
+
"user_message_tokens": self.user_message_tokens,
|
| 137 |
+
"reply_tokens": self.reply_tokens,
|
| 138 |
+
"stm_tokens": self.stm_tokens,
|
| 139 |
+
"response_reserved": self.response_reserved,
|
| 140 |
+
"reply_priority_multiplier": self.reply_priority_multiplier
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ============================================================
|
| 145 |
+
# CONTEXTO UNIFICADO
|
| 146 |
+
# ============================================================
|
| 147 |
+
|
| 148 |
+
@dataclass
|
| 149 |
+
class UnifiedMessageContext:
|
| 150 |
+
"""
|
| 151 |
+
Contexto unificado combinando reply + STM.
|
| 152 |
+
|
| 153 |
+
Philosophy: Reply context (tik) + STM (tok) trabalhando em sintonia.
|
| 154 |
+
|
| 155 |
+
Attributes:
|
| 156 |
+
- Reply context: Contexto imediato/urgente do reply
|
| 157 |
+
- STM context: Contexto do fluxo da conversa
|
| 158 |
+
- Integration: Como os dois são combinados
|
| 159 |
+
"""
|
| 160 |
+
# Identificação
|
| 161 |
+
conversation_id: str = ""
|
| 162 |
+
user_id: str = ""
|
| 163 |
+
timestamp: float = field(default_factory=time.time)
|
| 164 |
+
|
| 165 |
+
# Reply Context (TIK - urgente/imediato)
|
| 166 |
+
is_reply: bool = False
|
| 167 |
+
reply_to_bot: bool = False
|
| 168 |
+
reply_priority: int = 1 # 1=normal, 2=reply, 3=reply_to_bot, 4=critical
|
| 169 |
+
quoted_author: str = ""
|
| 170 |
+
quoted_content: str = ""
|
| 171 |
+
reply_importancia: float = 1.0
|
| 172 |
+
|
| 173 |
+
# STM Context (TOK - fluxo da conversa)
|
| 174 |
+
stm_messages: List[MessageWithContext] = field(default_factory=list)
|
| 175 |
+
stm_summary: Dict[str, Any] = field(default_factory=dict)
|
| 176 |
+
stm_emotional_trend: str = "neutral"
|
| 177 |
+
|
| 178 |
+
# Long-Term Memory (RAG)
|
| 179 |
+
long_term_memory: str = ""
|
| 180 |
+
|
| 181 |
+
# Integração
|
| 182 |
+
sync_mode: str = "tiktok" # "tiktok" = reply priority + STM flow
|
| 183 |
+
token_budget: ContextTokenBudget = field(default_factory=ContextTokenBudget)
|
| 184 |
+
|
| 185 |
+
# Mensagem atual
|
| 186 |
+
current_message: str = ""
|
| 187 |
+
current_emotion: str = "neutral"
|
| 188 |
+
|
| 189 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 190 |
+
"""Serializa para dicionário."""
|
| 191 |
+
return {
|
| 192 |
+
"conversation_id": self.conversation_id,
|
| 193 |
+
"user_id": self.user_id,
|
| 194 |
+
"timestamp": self.timestamp,
|
| 195 |
+
"is_reply": self.is_reply,
|
| 196 |
+
"reply_to_bot": self.reply_to_bot,
|
| 197 |
+
"reply_priority": self.reply_priority,
|
| 198 |
+
"quoted_author": self.quoted_author,
|
| 199 |
+
"quoted_content": self.quoted_content[:500] if self.quoted_content else "",
|
| 200 |
+
"reply_importancia": self.reply_importancia,
|
| 201 |
+
"stm_messages_count": len(self.stm_messages),
|
| 202 |
+
"stm_summary": self.stm_summary,
|
| 203 |
+
"stm_emotional_trend": self.stm_emotional_trend,
|
| 204 |
+
"long_term_memory": self.long_term_memory,
|
| 205 |
+
"sync_mode": self.sync_mode,
|
| 206 |
+
"token_budget": self.token_budget.to_dict(),
|
| 207 |
+
"current_message": self.current_message[:100],
|
| 208 |
+
"current_emotion": self.current_emotion
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
def build_prompt(self) -> str:
|
| 212 |
+
"""
|
| 213 |
+
Constrói prompt formatado para o LLM.
|
| 214 |
+
|
| 215 |
+
Returns:
|
| 216 |
+
String formatada com contexto unificado (reply + STM)
|
| 217 |
+
"""
|
| 218 |
+
return format_unified_context_for_llm(self, self.token_budget)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# ====================================
|
| 222 |
+
# HELPER FUNCTIONS
|
| 223 |
+
# ====================================
|
| 224 |
+
|
| 225 |
+
def sync_reply_with_stm(
|
| 226 |
+
reply_context: Dict[str, Any],
|
| 227 |
+
stm_messages: List[MessageWithContext],
|
| 228 |
+
max_stm_messages: int = 10
|
| 229 |
+
) -> List[MessageWithContext]:
|
| 230 |
+
"""
|
| 231 |
+
Sincroniza reply context com mensagens STM.
|
| 232 |
+
|
| 233 |
+
Philosophy: Reply (tik) vem primeiro, STM (tok) vem depois.
|
| 234 |
+
Ambos são combinados para formar o contexto completo.
|
| 235 |
+
|
| 236 |
+
Args:
|
| 237 |
+
reply_context: Contexto do reply
|
| 238 |
+
stm_messages: Mensagens da memória de curto prazo
|
| 239 |
+
max_stm_messages: Máximo de mensagens STM a incluir
|
| 240 |
+
|
| 241 |
+
Returns:
|
| 242 |
+
Lista combinada de mensagens para contexto
|
| 243 |
+
"""
|
| 244 |
+
combined = []
|
| 245 |
+
|
| 246 |
+
# 1. Adiciona reply context como mensagem mais recente (TIK)
|
| 247 |
+
if reply_context.get('is_reply', False):
|
| 248 |
+
reply_msg = MessageWithContext(
|
| 249 |
+
role="user",
|
| 250 |
+
content=reply_context.get('quoted_content', ''),
|
| 251 |
+
importancia=reply_context.get('importancia', IMPORTANCIA_NORMAL),
|
| 252 |
+
emocao=reply_context.get('emocao', 'neutral'),
|
| 253 |
+
reply_info={
|
| 254 |
+
'is_reply': True,
|
| 255 |
+
'reply_to_bot': reply_context.get('reply_to_bot', False),
|
| 256 |
+
'quoted_text_original': reply_context.get('quoted_content', ''),
|
| 257 |
+
'priority_level': reply_context.get('priority', 1),
|
| 258 |
+
'sync_mode': 'tiktok'
|
| 259 |
+
}
|
| 260 |
+
)
|
| 261 |
+
combined.append(reply_msg)
|
| 262 |
+
|
| 263 |
+
# 2. Adiciona mensagens STM (TOK - fluxo da conversa)
|
| 264 |
+
# Pega últimas N mensagens STM
|
| 265 |
+
stm_to_add = stm_messages[-max_stm_messages:] if stm_messages else []
|
| 266 |
+
|
| 267 |
+
for msg in stm_to_add:
|
| 268 |
+
# Se a mensagem STM já é um reply, preserva info
|
| 269 |
+
if msg.is_reply and not msg.reply_info.get('sync_mode'):
|
| 270 |
+
msg.reply_info['sync_mode'] = 'stm'
|
| 271 |
+
combined.append(msg)
|
| 272 |
+
|
| 273 |
+
return combined
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def format_unified_context_for_llm(
|
| 277 |
+
unified: UnifiedMessageContext,
|
| 278 |
+
budget: ContextTokenBudget
|
| 279 |
+
) -> str:
|
| 280 |
+
"""
|
| 281 |
+
Formata contexto unificado para o prompt do LLM.
|
| 282 |
+
|
| 283 |
+
Philosophy: Reply (tik) primeiro por ser urgente, STM (tok) depois
|
| 284 |
+
para contexto da conversa.
|
| 285 |
+
|
| 286 |
+
Args:
|
| 287 |
+
unified: Contexto unificado
|
| 288 |
+
budget: Orçamento de tokens
|
| 289 |
+
|
| 290 |
+
Returns:
|
| 291 |
+
String formatada para o prompt
|
| 292 |
+
"""
|
| 293 |
+
parts = []
|
| 294 |
+
|
| 295 |
+
# ===== 1. REPLY CONTEXT (TIK - URGENTE) =====
|
| 296 |
+
if unified.is_reply:
|
| 297 |
+
reply_section = []
|
| 298 |
+
reply_section.append("=" * 50)
|
| 299 |
+
reply_section.append("[📎 REPLY CONTEXT - PRIORITÁRIO]")
|
| 300 |
+
reply_section.append("=" * 50)
|
| 301 |
+
|
| 302 |
+
if unified.reply_to_bot:
|
| 303 |
+
reply_section.append("⚠️ VOCÊ ESTÁ SENDO DIRETAMENTE RESPONDIDO!")
|
| 304 |
+
else:
|
| 305 |
+
reply_section.append(f"Respondendo a: {unified.quoted_author}")
|
| 306 |
+
|
| 307 |
+
# Conteúdo citado
|
| 308 |
+
if unified.quoted_content:
|
| 309 |
+
quoted_preview = unified.quoted_content[:budget.reply_tokens // 4]
|
| 310 |
+
reply_section.append(f"\n<quoted_message>\n{quoted_preview}...\n</quoted_message>")
|
| 311 |
+
|
| 312 |
+
# Prioridade
|
| 313 |
+
if unified.reply_priority >= PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
|
| 314 |
+
reply_section.append("\n💡 PERGUNTA CURTA + REPLY: FOCO NA CITAÇÃO")
|
| 315 |
+
|
| 316 |
+
reply_section.append("\n📌 INSTRUÇÕES DE REPLY:")
|
| 317 |
+
reply_section.append("- Relacione o input atual ESTRITAMENTE ao <quoted_message>.")
|
| 318 |
+
reply_section.append("- PRESERVE a sua identidade e humor (seja o Akira, natural e irreverente).")
|
| 319 |
+
reply_section.append("- Não assuma detalhes inexistentes, use o fluxo (STM) para coerência base.")
|
| 320 |
+
|
| 321 |
+
parts.append("\n".join(reply_section))
|
| 322 |
+
|
| 323 |
+
# ===== RAG CONTEXT (MEMÓRIA DE LONGO PRAZO) =====
|
| 324 |
+
if unified.long_term_memory:
|
| 325 |
+
rag_section = []
|
| 326 |
+
rag_section.append("\n" + "=" * 50)
|
| 327 |
+
rag_section.append("[📖 MEMÓRIA DE LONGO PRAZO (BANCO DE DADOS)]")
|
| 328 |
+
rag_section.append("=" * 50)
|
| 329 |
+
rag_section.append("(Informações previamente aprendidas sobre o usuário)")
|
| 330 |
+
rag_section.append(unified.long_term_memory)
|
| 331 |
+
parts.append("\n".join(rag_section))
|
| 332 |
+
|
| 333 |
+
# ===== 2. STM CONTEXT (TOK - FLUXO DA CONVERSA) =====
|
| 334 |
+
if unified.stm_messages:
|
| 335 |
+
stm_section = []
|
| 336 |
+
stm_section.append("\n" + "=" * 50)
|
| 337 |
+
stm_section.append("[🧠 MEMÓRIA DE CURTO PRAZO - FLUXO DA CONVERSA]")
|
| 338 |
+
stm_section.append("=" * 50)
|
| 339 |
+
stm_section.append("(conversa recente para contexto)")
|
| 340 |
+
|
| 341 |
+
# emotional trend
|
| 342 |
+
if unified.stm_emotional_trend != "neutral":
|
| 343 |
+
stm_section.append(f"\n📊 Tendência emocional: {unified.stm_emotional_trend}")
|
| 344 |
+
|
| 345 |
+
# Formata mensagens STM
|
| 346 |
+
stm_tokens_used = 0
|
| 347 |
+
for msg in unified.stm_messages:
|
| 348 |
+
# Formata role
|
| 349 |
+
role_icon = "👤" if msg.role == "user" else "🤖"
|
| 350 |
+
role_label = "USER" if msg.role == "user" else "AKIRA"
|
| 351 |
+
|
| 352 |
+
# Se é reply, marca
|
| 353 |
+
reply_marker = " [REPLY]" if msg.is_reply else ""
|
| 354 |
+
|
| 355 |
+
# Preview do conteúdo
|
| 356 |
+
content_preview = msg.content[:100]
|
| 357 |
+
|
| 358 |
+
msg_line = f"{role_icon} [{role_label}]{reply_marker}: {content_preview}..."
|
| 359 |
+
msg_tokens = estimar_tokens(msg_line)
|
| 360 |
+
|
| 361 |
+
if stm_tokens_used + msg_tokens <= budget.stm_tokens:
|
| 362 |
+
stm_section.append(msg_line)
|
| 363 |
+
stm_tokens_used += msg_tokens
|
| 364 |
+
|
| 365 |
+
stm_section.append("\n💡 INTEGRAÇÃO: Use este contexto para manter coerência!")
|
| 366 |
+
|
| 367 |
+
parts.append("\n".join(stm_section))
|
| 368 |
+
|
| 369 |
+
return "\n".join(parts)
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
# ====================================
|
| 373 |
+
# SHORT-TERM MEMORY MANAGER
|
| 374 |
+
# ====================================
|
| 375 |
+
|
| 376 |
+
class ShortTermMemoryManager:
|
| 377 |
+
"""
|
| 378 |
+
Gerenciador de instâncias STM por conversa.
|
| 379 |
+
|
| 380 |
+
Philosophy: Cada conversa tem sua própria STM isolada,
|
| 381 |
+
mas todas compartilham o mesmo manager.
|
| 382 |
+
"""
|
| 383 |
+
|
| 384 |
+
_instance = None
|
| 385 |
+
_lock = None
|
| 386 |
+
|
| 387 |
+
def __new__(cls):
|
| 388 |
+
if cls._instance is None:
|
| 389 |
+
cls._lock = __import__('threading').Lock()
|
| 390 |
+
with cls._lock:
|
| 391 |
+
if cls._instance is None:
|
| 392 |
+
cls._instance = super().__new__(cls)
|
| 393 |
+
cls._instance._initialized = False
|
| 394 |
+
return cls._instance
|
| 395 |
+
|
| 396 |
+
def __init__(self):
|
| 397 |
+
if self._initialized:
|
| 398 |
+
return
|
| 399 |
+
|
| 400 |
+
self._instances: Dict[str, ShortTermMemory] = {}
|
| 401 |
+
self._initialized = True
|
| 402 |
+
logger.debug("✅ ShortTermMemoryManager inicializado")
|
| 403 |
+
|
| 404 |
+
def get_or_create(
|
| 405 |
+
self,
|
| 406 |
+
conversation_id: str,
|
| 407 |
+
user_id: str = "",
|
| 408 |
+
max_messages: int = 100
|
| 409 |
+
) -> ShortTermMemory:
|
| 410 |
+
"""
|
| 411 |
+
Obtém ou cria STM para uma conversa.
|
| 412 |
+
|
| 413 |
+
Args:
|
| 414 |
+
conversation_id: ID único da conversa
|
| 415 |
+
user_id: ID do usuário
|
| 416 |
+
max_messages: Máximo de mensagens na STM
|
| 417 |
+
|
| 418 |
+
Returns:
|
| 419 |
+
Instância de ShortTermMemory
|
| 420 |
+
"""
|
| 421 |
+
if conversation_id not in self._instances:
|
| 422 |
+
self._instances[conversation_id] = ShortTermMemory(
|
| 423 |
+
conversation_id=conversation_id,
|
| 424 |
+
max_messages=max_messages
|
| 425 |
+
)
|
| 426 |
+
logger.debug(f"🧠 STM criada: {conversation_id[:8]}...")
|
| 427 |
+
|
| 428 |
+
return self._instances[conversation_id]
|
| 429 |
+
|
| 430 |
+
def add_message(
|
| 431 |
+
self,
|
| 432 |
+
conversation_id: str,
|
| 433 |
+
role: str,
|
| 434 |
+
content: str,
|
| 435 |
+
emocao: str = "neutral",
|
| 436 |
+
reply_info: Optional[Dict] = None,
|
| 437 |
+
importancia: float = None
|
| 438 |
+
) -> MessageWithContext:
|
| 439 |
+
"""
|
| 440 |
+
Adiciona mensagem à STM de uma conversa.
|
| 441 |
+
|
| 442 |
+
Args:
|
| 443 |
+
conversation_id: ID da conversa
|
| 444 |
+
role: "user" ou "assistant"
|
| 445 |
+
content: Texto da mensagem
|
| 446 |
+
emocao: Emoção detectada
|
| 447 |
+
reply_info: Info de reply (se aplicável)
|
| 448 |
+
importancia: Importância customizada
|
| 449 |
+
|
| 450 |
+
Returns:
|
| 451 |
+
MessageWithContext criada
|
| 452 |
+
"""
|
| 453 |
+
stm = self.get_or_create(conversation_id)
|
| 454 |
+
|
| 455 |
+
# Calcula importância automaticamente se não fornecida
|
| 456 |
+
if importancia is None:
|
| 457 |
+
from .short_term_memory import calcular_importancia
|
| 458 |
+
importancia = calcular_importancia(
|
| 459 |
+
is_reply=bool(reply_info and reply_info.get("is_reply")),
|
| 460 |
+
reply_to_bot=bool(reply_info and reply_info.get("reply_to_bot")),
|
| 461 |
+
mensagem=content,
|
| 462 |
+
emocao=emocao
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
+
return stm.add_message(
|
| 466 |
+
role=role,
|
| 467 |
+
content=content,
|
| 468 |
+
importancia=importancia,
|
| 469 |
+
emocao=emocao,
|
| 470 |
+
reply_info=reply_info
|
| 471 |
+
)
|
| 472 |
+
|
| 473 |
+
def get_context(
|
| 474 |
+
self,
|
| 475 |
+
conversation_id: str,
|
| 476 |
+
include_replies: bool = True,
|
| 477 |
+
prioritize_replies: bool = True,
|
| 478 |
+
max_messages: int = 10,
|
| 479 |
+
max_tokens: int = 4000
|
| 480 |
+
) -> List[MessageWithContext]:
|
| 481 |
+
"""
|
| 482 |
+
Obtém contexto da STM de uma conversa.
|
| 483 |
+
|
| 484 |
+
Args:
|
| 485 |
+
conversation_id: ID da conversa
|
| 486 |
+
include_replies: Se inclui replies
|
| 487 |
+
prioritize_replies: Se prioriza replies
|
| 488 |
+
max_messages: Máximo de mensagens
|
| 489 |
+
max_tokens: Máximo de tokens
|
| 490 |
+
|
| 491 |
+
Returns:
|
| 492 |
+
Lista de mensagens
|
| 493 |
+
"""
|
| 494 |
+
if conversation_id not in self._instances:
|
| 495 |
+
return []
|
| 496 |
+
|
| 497 |
+
stm = self._instances[conversation_id]
|
| 498 |
+
return stm.get_context_window(
|
| 499 |
+
include_replies=include_replies,
|
| 500 |
+
prioritize_replies=prioritize_replies,
|
| 501 |
+
max_messages=max_messages,
|
| 502 |
+
max_tokens=max_tokens
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
def get_summary(self, conversation_id: str) -> Dict[str, Any]:
|
| 506 |
+
"""
|
| 507 |
+
Obtém resumo da STM de uma conversa.
|
| 508 |
+
|
| 509 |
+
Args:
|
| 510 |
+
conversation_id: ID da conversa
|
| 511 |
+
|
| 512 |
+
Returns:
|
| 513 |
+
Dicionário com resumo
|
| 514 |
+
"""
|
| 515 |
+
if conversation_id not in self._instances:
|
| 516 |
+
return {}
|
| 517 |
+
|
| 518 |
+
stm = self._instances[conversation_id]
|
| 519 |
+
return stm.get_conversation_summary()
|
| 520 |
+
|
| 521 |
+
def clear(self, conversation_id: str) -> bool:
|
| 522 |
+
"""
|
| 523 |
+
Limpa STM de uma conversa.
|
| 524 |
+
|
| 525 |
+
Args:
|
| 526 |
+
conversation_id: ID da conversa
|
| 527 |
+
|
| 528 |
+
Returns:
|
| 529 |
+
True se limpou
|
| 530 |
+
"""
|
| 531 |
+
if conversation_id in self._instances:
|
| 532 |
+
self._instances[conversation_id].clear()
|
| 533 |
+
return True
|
| 534 |
+
return False
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
# ====================================
|
| 538 |
+
# UNIFIED CONTEXT BUILDER
|
| 539 |
+
# ====================================
|
| 540 |
+
|
| 541 |
+
class UnifiedContextBuilder:
|
| 542 |
+
"""
|
| 543 |
+
Constrói contexto unificado combinando reply + STM.
|
| 544 |
+
|
| 545 |
+
Philosophy: "Reply context e STM devem trabalhar em sintonia como tik e tack"
|
| 546 |
+
|
| 547 |
+
Usage:
|
| 548 |
+
builder = UnifiedContextBuilder()
|
| 549 |
+
context = builder.build(
|
| 550 |
+
conversation_id="...",
|
| 551 |
+
reply_metadata={...},
|
| 552 |
+
current_message="..."
|
| 553 |
+
)
|
| 554 |
+
prompt_section = builder.format_for_llm(context)
|
| 555 |
+
"""
|
| 556 |
+
|
| 557 |
+
def __init__(self, context_manager=None, stm_manager=None, db_instance=None):
|
| 558 |
+
self.stm_manager = stm_manager if stm_manager else ShortTermMemoryManager()
|
| 559 |
+
self.context_manager = context_manager
|
| 560 |
+
self.db = db_instance
|
| 561 |
+
self.reply_handler = None
|
| 562 |
+
self._initialized = False
|
| 563 |
+
|
| 564 |
+
def _ensure_initialized(self):
|
| 565 |
+
"""Garante inicialização do reply handler."""
|
| 566 |
+
if not self._initialized and UNIFIED_CONTEXT_AVAILABLE:
|
| 567 |
+
try:
|
| 568 |
+
self.reply_handler = ReplyContextHandler()
|
| 569 |
+
self._initialized = True
|
| 570 |
+
except Exception as e:
|
| 571 |
+
logger.warning(f"UnifiedContextBuilder: falha ao init reply handler: {e}")
|
| 572 |
+
|
| 573 |
+
def build(
|
| 574 |
+
self,
|
| 575 |
+
conversation_id: str,
|
| 576 |
+
user_id: str = "",
|
| 577 |
+
reply_metadata: Optional[Dict[str, Any]] = None,
|
| 578 |
+
current_message: str = "",
|
| 579 |
+
current_emotion: str = "neutral",
|
| 580 |
+
stm_messages: Optional[List[MessageWithContext]] = None
|
| 581 |
+
) -> UnifiedMessageContext:
|
| 582 |
+
"""
|
| 583 |
+
Constrói contexto unificado.
|
| 584 |
+
|
| 585 |
+
Args:
|
| 586 |
+
conversation_id: ID único da conversa
|
| 587 |
+
user_id: ID do usuário
|
| 588 |
+
reply_metadata: Metadados do reply
|
| 589 |
+
current_message: Mensagem atual
|
| 590 |
+
current_emotion: Emoção atual
|
| 591 |
+
stm_messages: Mensagens STM (usa manager se None)
|
| 592 |
+
|
| 593 |
+
Returns:
|
| 594 |
+
UnifiedMessageContext pronto para uso
|
| 595 |
+
"""
|
| 596 |
+
self._ensure_initialized()
|
| 597 |
+
|
| 598 |
+
# ===== 1. PROCESSA REPLY CONTEXT (TIK) =====
|
| 599 |
+
is_reply = reply_metadata.get('is_reply', False) if reply_metadata else False
|
| 600 |
+
|
| 601 |
+
reply_context = {
|
| 602 |
+
'is_reply': is_reply,
|
| 603 |
+
'reply_to_bot': reply_metadata.get('reply_to_bot', False) if reply_metadata else False,
|
| 604 |
+
'quoted_author': reply_metadata.get('quoted_author_name', '') if reply_metadata else '',
|
| 605 |
+
'quoted_content': reply_metadata.get('quoted_text_original', '') or
|
| 606 |
+
reply_metadata.get('mensagem_citada', '') if reply_metadata else '',
|
| 607 |
+
'importancia': IMPORTANCIA_NORMAL,
|
| 608 |
+
'emocao': current_emotion,
|
| 609 |
+
'priority': 1
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
# Calcula prioridade do reply
|
| 613 |
+
if is_reply and reply_metadata:
|
| 614 |
+
reply_context['priority'] = self._calculate_reply_priority(
|
| 615 |
+
reply_metadata.get('reply_to_bot', False),
|
| 616 |
+
current_message,
|
| 617 |
+
reply_metadata.get('quoted_text_original', '')
|
| 618 |
+
)
|
| 619 |
+
|
| 620 |
+
# Calcula importância baseada em prioridade
|
| 621 |
+
if reply_context['priority'] >= PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
|
| 622 |
+
reply_context['importancia'] = IMPORTANCIA_PERGUNTA_CURTA_REPLY
|
| 623 |
+
elif reply_context['priority'] >= PRIORITY_REPLY_TO_BOT:
|
| 624 |
+
reply_context['importancia'] = IMPORTANCIA_REPLY_TO_BOT
|
| 625 |
+
elif reply_context['priority'] >= PRIORITY_REPLY:
|
| 626 |
+
reply_context['importancia'] = IMPORTANCIA_REPLY
|
| 627 |
+
|
| 628 |
+
# ===== 2. OBTÉM STM (TOK) =====
|
| 629 |
+
if stm_messages is None:
|
| 630 |
+
stm_messages = self.stm_manager.get_context(
|
| 631 |
+
conversation_id,
|
| 632 |
+
include_replies=True,
|
| 633 |
+
prioritize_replies=True,
|
| 634 |
+
max_messages=10,
|
| 635 |
+
max_tokens=4000
|
| 636 |
+
)
|
| 637 |
+
|
| 638 |
+
# ===== 3. CALCULA TOKEN BUDGET =====
|
| 639 |
+
budget = ContextTokenBudget().calculate(
|
| 640 |
+
is_reply=is_reply,
|
| 641 |
+
reply_priority=reply_context['priority']
|
| 642 |
+
)
|
| 643 |
+
|
| 644 |
+
# ===== 4. FETCH LONG-TERM MEMORY (DB) =====
|
| 645 |
+
long_term_memory_string = ""
|
| 646 |
+
if self.db and user_id:
|
| 647 |
+
try:
|
| 648 |
+
# Recuperar aprendizados e gírias
|
| 649 |
+
ltm_facts = self.db.recuperar_aprendizado_detalhado(user_id)
|
| 650 |
+
ltm_girias = self.db.recuperar_girias_usuario(user_id)
|
| 651 |
+
ltm_tom = self.db.obter_tom_predominante(user_id)
|
| 652 |
+
persona_ltm = self.db.recuperar_persona(user_id) if hasattr(self.db, 'recuperar_persona') else None
|
| 653 |
+
|
| 654 |
+
ltm_lines = []
|
| 655 |
+
|
| 656 |
+
# --- PERSONA DO USUÁRIO (Rastreador) ---
|
| 657 |
+
if persona_ltm:
|
| 658 |
+
ltm_lines.append("=== PERFIL ANALISADO DO USUÁRIO ===")
|
| 659 |
+
if persona_ltm.get('personalidade') and persona_ltm['personalidade'] != "None":
|
| 660 |
+
ltm_lines.append(f"• Personalidade: {persona_ltm['personalidade']}")
|
| 661 |
+
if persona_ltm.get('gostos') and persona_ltm['gostos'] != "None":
|
| 662 |
+
ltm_lines.append(f"• Tópicos de Interesse: {persona_ltm['gostos']}")
|
| 663 |
+
if persona_ltm.get('desgostos') and persona_ltm['desgostos'] != "None":
|
| 664 |
+
ltm_lines.append(f"• Desgostos/Gatilhos: {persona_ltm['desgostos']}")
|
| 665 |
+
if persona_ltm.get('vicios_linguagem') and persona_ltm['vicios_linguagem'] != "None":
|
| 666 |
+
ltm_lines.append(f"• Padrões de Linguagem: {persona_ltm['vicios_linguagem']}")
|
| 667 |
+
if persona_ltm.get('emocional') and persona_ltm['emocional'] != "None":
|
| 668 |
+
ltm_lines.append(f"• Perfil Emocional: {persona_ltm['emocional']}")
|
| 669 |
+
|
| 670 |
+
if ltm_tom:
|
| 671 |
+
ltm_lines.append(f"• Seu tom de conversa predominante é: {ltm_tom}")
|
| 672 |
+
|
| 673 |
+
if ltm_facts and isinstance(ltm_facts, dict):
|
| 674 |
+
# Ignorar chaves puramente técnicas como 'emocao_atual' ou strings de timestamp longas
|
| 675 |
+
fatos_filtrados = {k: v for k, v in ltm_facts.items() if not k.startswith("emocao_")}
|
| 676 |
+
if fatos_filtrados:
|
| 677 |
+
ltm_lines.append("• Fatos Relevantes Aprendidos:")
|
| 678 |
+
for k, v in list(fatos_filtrados.items())[:5]: # limita 5
|
| 679 |
+
ltm_lines.append(f" - {k}: {v}")
|
| 680 |
+
|
| 681 |
+
if ltm_girias:
|
| 682 |
+
ltm_lines.append("• Expressões Específicas Recentes:")
|
| 683 |
+
for g in ltm_girias[:5]:
|
| 684 |
+
ltm_lines.append(f" - {g['giria']} ({g['significado']})")
|
| 685 |
+
|
| 686 |
+
if ltm_lines:
|
| 687 |
+
long_term_memory_string = "\n".join(ltm_lines)
|
| 688 |
+
except Exception as e:
|
| 689 |
+
logger.warning(f"Erro ao recuperar memória de longo prazo: {e}")
|
| 690 |
+
|
| 691 |
+
# ===== 5. CRIA CONTEXTO UNIFICADO =====
|
| 692 |
+
unified = UnifiedMessageContext(
|
| 693 |
+
conversation_id=conversation_id,
|
| 694 |
+
user_id=user_id,
|
| 695 |
+
timestamp=time.time(),
|
| 696 |
+
is_reply=is_reply,
|
| 697 |
+
reply_to_bot=reply_context['reply_to_bot'],
|
| 698 |
+
reply_priority=reply_context['priority'],
|
| 699 |
+
quoted_author=reply_context['quoted_author'],
|
| 700 |
+
quoted_content=reply_context['quoted_content'],
|
| 701 |
+
reply_importancia=reply_context['importancia'],
|
| 702 |
+
stm_messages=stm_messages,
|
| 703 |
+
stm_summary=self.stm_manager.get_summary(conversation_id),
|
| 704 |
+
stm_emotional_trend=self._get_stm_emotional_trend(stm_messages),
|
| 705 |
+
long_term_memory=long_term_memory_string,
|
| 706 |
+
sync_mode="tiktok",
|
| 707 |
+
token_budget=budget,
|
| 708 |
+
current_message=current_message,
|
| 709 |
+
current_emotion=current_emotion
|
| 710 |
+
)
|
| 711 |
+
|
| 712 |
+
return unified
|
| 713 |
+
|
| 714 |
+
def _calculate_reply_priority(
|
| 715 |
+
self,
|
| 716 |
+
reply_to_bot: bool,
|
| 717 |
+
current_message: str,
|
| 718 |
+
quoted_content: str
|
| 719 |
+
) -> int:
|
| 720 |
+
"""
|
| 721 |
+
Calcula nível de prioridade do reply.
|
| 722 |
+
|
| 723 |
+
Returns:
|
| 724 |
+
1=normal, 2=reply, 3=reply_to_bot, 4=critical
|
| 725 |
+
"""
|
| 726 |
+
if not reply_to_bot:
|
| 727 |
+
return PRIORITY_REPLY
|
| 728 |
+
|
| 729 |
+
if is_pergunta_curta(current_message):
|
| 730 |
+
return PRIORITY_REPLY_TO_BOT_SHORT_QUESTION
|
| 731 |
+
|
| 732 |
+
return PRIORITY_REPLY_TO_BOT
|
| 733 |
+
|
| 734 |
+
def _get_stm_emotional_trend(
|
| 735 |
+
self,
|
| 736 |
+
stm_messages: List[MessageWithContext]
|
| 737 |
+
) -> str:
|
| 738 |
+
"""Obtém tendência emocional da STM."""
|
| 739 |
+
if not stm_messages:
|
| 740 |
+
return "neutral"
|
| 741 |
+
|
| 742 |
+
emocoes = {}
|
| 743 |
+
for msg in stm_messages[-10:]: # Últimas 10
|
| 744 |
+
emocao = msg.emocao or "neutral"
|
| 745 |
+
emocoes[emocao] = emocoes.get(emocao, 0) + 1
|
| 746 |
+
|
| 747 |
+
if not emocoes:
|
| 748 |
+
return "neutral"
|
| 749 |
+
|
| 750 |
+
return max(emocoes, key=emocoes.get)
|
| 751 |
+
|
| 752 |
+
def format_for_llm(
|
| 753 |
+
self,
|
| 754 |
+
unified: UnifiedMessageContext,
|
| 755 |
+
include_header: bool = True
|
| 756 |
+
) -> str:
|
| 757 |
+
"""
|
| 758 |
+
Formata contexto unificado para o prompt do LLM.
|
| 759 |
+
|
| 760 |
+
Args:
|
| 761 |
+
unified: Contexto unificado
|
| 762 |
+
include_header: Se inclui cabeçalho
|
| 763 |
+
|
| 764 |
+
Returns:
|
| 765 |
+
String formatada para o prompt
|
| 766 |
+
"""
|
| 767 |
+
return format_unified_context_for_llm(unified, unified.token_budget)
|
| 768 |
+
|
| 769 |
+
def add_to_stm(
|
| 770 |
+
self,
|
| 771 |
+
conversation_id: str,
|
| 772 |
+
role: str,
|
| 773 |
+
content: str,
|
| 774 |
+
emocao: str = "neutral",
|
| 775 |
+
reply_info: Optional[Dict] = None,
|
| 776 |
+
resposta: str = ""
|
| 777 |
+
) -> MessageWithContext:
|
| 778 |
+
"""
|
| 779 |
+
Adiciona mensagem (user ou bot) à STM.
|
| 780 |
+
|
| 781 |
+
Args:
|
| 782 |
+
conversation_id: ID da conversa
|
| 783 |
+
role: "user" ou "assistant"
|
| 784 |
+
content: Conteúdo da mensagem
|
| 785 |
+
emocao: Emoção
|
| 786 |
+
reply_info: Info de reply (se aplicável)
|
| 787 |
+
resposta: Resposta do bot (se for assistant)
|
| 788 |
+
|
| 789 |
+
Returns:
|
| 790 |
+
MessageWithContext criada
|
| 791 |
+
"""
|
| 792 |
+
# Para mensagens do bot, usa a resposta gerada
|
| 793 |
+
if role == "assistant" and resposta:
|
| 794 |
+
content = resposta
|
| 795 |
+
|
| 796 |
+
return self.stm_manager.add_message(
|
| 797 |
+
conversation_id=conversation_id,
|
| 798 |
+
role=role,
|
| 799 |
+
content=content,
|
| 800 |
+
emocao=emocao,
|
| 801 |
+
reply_info=reply_info
|
| 802 |
+
)
|
| 803 |
+
|
| 804 |
+
def merge_reply_with_stm(
|
| 805 |
+
self,
|
| 806 |
+
reply_context: Dict[str, Any],
|
| 807 |
+
stm_messages: List[MessageWithContext],
|
| 808 |
+
max_stm: int = 10
|
| 809 |
+
) -> List[MessageWithContext]:
|
| 810 |
+
"""
|
| 811 |
+
Mescla reply context com STM para contexto do LLM.
|
| 812 |
+
|
| 813 |
+
Args:
|
| 814 |
+
reply_context: Contexto do reply
|
| 815 |
+
stm_messages: Mensagens STM
|
| 816 |
+
max_stm: Máximo de mensagens STM
|
| 817 |
+
|
| 818 |
+
Returns:
|
| 819 |
+
Lista combinada
|
| 820 |
+
"""
|
| 821 |
+
return sync_reply_with_stm(reply_context, stm_messages, max_stm)
|
| 822 |
+
|
| 823 |
+
|
| 824 |
+
# ====================================
|
| 825 |
+
# FACTORY FUNCTIONS
|
| 826 |
+
# ====================================
|
| 827 |
+
|
| 828 |
+
_unified_builder: Optional[UnifiedContextBuilder] = None
|
| 829 |
+
|
| 830 |
+
def get_unified_context_builder() -> UnifiedContextBuilder:
|
| 831 |
+
"""Obtém instância singleton do builder."""
|
| 832 |
+
global _unified_builder
|
| 833 |
+
if _unified_builder is None:
|
| 834 |
+
_unified_builder = UnifiedContextBuilder()
|
| 835 |
+
return _unified_builder
|
| 836 |
+
|
| 837 |
+
|
| 838 |
+
def get_stm_manager() -> ShortTermMemoryManager:
|
| 839 |
+
"""Obtém instância singleton do manager de STM."""
|
| 840 |
+
return ShortTermMemoryManager()
|
| 841 |
+
|
| 842 |
+
|
| 843 |
+
def build_unified_context(
|
| 844 |
+
conversation_id: str,
|
| 845 |
+
user_id: str = "",
|
| 846 |
+
reply_metadata: Optional[Dict[str, Any]] = None,
|
| 847 |
+
current_message: str = "",
|
| 848 |
+
current_emotion: str = "neutral"
|
| 849 |
+
) -> UnifiedMessageContext:
|
| 850 |
+
"""
|
| 851 |
+
Factory function para construir contexto unificado.
|
| 852 |
+
|
| 853 |
+
Usage:
|
| 854 |
+
context = build_unified_context(
|
| 855 |
+
conversation_id="pv:2449...",
|
| 856 |
+
reply_metadata={...},
|
| 857 |
+
current_message="."
|
| 858 |
+
)
|
| 859 |
+
"""
|
| 860 |
+
builder = get_unified_context_builder()
|
| 861 |
+
return builder.build(
|
| 862 |
+
conversation_id=conversation_id,
|
| 863 |
+
user_id=user_id,
|
| 864 |
+
reply_metadata=reply_metadata,
|
| 865 |
+
current_message=current_message,
|
| 866 |
+
current_emotion=current_emotion
|
| 867 |
+
)
|
| 868 |
+
|
| 869 |
+
|
| 870 |
+
# ====================================
|
| 871 |
+
# COMPATIBILITY HELPERS
|
| 872 |
+
# ====================================
|
| 873 |
+
|
| 874 |
+
def gerar_id_conversao(
|
| 875 |
+
numero: str,
|
| 876 |
+
tipo_conversa: str = "pv",
|
| 877 |
+
grupo_id: Optional[str] = None
|
| 878 |
+
) -> str:
|
| 879 |
+
"""
|
| 880 |
+
Gera ID de conversa para STM isolada.
|
| 881 |
+
|
| 882 |
+
Args:
|
| 883 |
+
numero: Número do usuário
|
| 884 |
+
tipo_conversa: "pv" ou "grupo"
|
| 885 |
+
grupo_id: ID do grupo (para conversas em grupo)
|
| 886 |
+
|
| 887 |
+
Returns:
|
| 888 |
+
ID único da conversa
|
| 889 |
+
"""
|
| 890 |
+
from .context_isolation import generate_context_id
|
| 891 |
+
return generate_context_id(numero, tipo_conversa, grupo_id)
|
| 892 |
+
|
| 893 |
+
|
| 894 |
+
# type: ignore
|
modules/web_search.py
CHANGED
|
@@ -1,408 +1,975 @@
|
|
| 1 |
-
#
|
| 2 |
-
"""
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
#
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
#
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# type: ignore
|
| 2 |
+
"""
|
| 3 |
+
modules/web_search.py
|
| 4 |
+
================================================================================
|
| 5 |
+
WEB SEARCH MÓDULO - BUSCA AUTÔNOMA COMPLETA E PROFISSIONAL
|
| 6 |
+
================================================================================
|
| 7 |
+
Versão 3.0 - Motor de busca autônomo e inteligente
|
| 8 |
+
|
| 9 |
+
Features:
|
| 10 |
+
- DuckDuckGo via biblioteca `ddgs` (production-ready, sem scraping frágil)
|
| 11 |
+
- Busca de Texto, Notícias, Imagens e Vídeos (multi-tipo)
|
| 12 |
+
- Wikipedia via API oficial (conteúdo completo)
|
| 13 |
+
- Clima via OpenWeatherMap API (com fallback para wttr.in)
|
| 14 |
+
- Pesquisa Autônoma: AI decide QUANDO e O QUE buscar sem comando explícito
|
| 15 |
+
- Raspagem profunda de página web com extração de conteúdo limpo
|
| 16 |
+
- Cache TTL inteligente por tipo de busca
|
| 17 |
+
- Rate limiting respeitoso e rotação de User-Agent
|
| 18 |
+
- Integração direta com banco de dados (salva pesquisas para RAG)
|
| 19 |
+
|
| 20 |
+
Uso:
|
| 21 |
+
ws = WebSearch(db=db_instance)
|
| 22 |
+
resultado = ws.pesquisar("capital de angola")
|
| 23 |
+
conteudo = ws.buscar_conteudo_completo("presidente João Lourenço")
|
| 24 |
+
deve_ir = ws.deve_buscar_na_web("quem ganhou a copa ontem?")
|
| 25 |
+
|
| 26 |
+
================================================================================
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import os
|
| 30 |
+
import re
|
| 31 |
+
|
| 32 |
+
import random
|
| 33 |
+
import time
|
| 34 |
+
import hashlib
|
| 35 |
+
import sqlite3
|
| 36 |
+
import json
|
| 37 |
+
from dataclasses import dataclass
|
| 38 |
+
from typing import Dict, Any, List, Optional, Tuple, Union
|
| 39 |
+
from datetime import datetime
|
| 40 |
+
from loguru import logger
|
| 41 |
+
|
| 42 |
+
try:
|
| 43 |
+
from .config import DB_PATH
|
| 44 |
+
except (ImportError, ValueError):
|
| 45 |
+
try:
|
| 46 |
+
from modules.config import DB_PATH
|
| 47 |
+
except ImportError:
|
| 48 |
+
DB_PATH = "akira.db"
|
| 49 |
+
|
| 50 |
+
# ============================================================
|
| 51 |
+
# Imports opcionais com fallbacks
|
| 52 |
+
# ============================================================
|
| 53 |
+
|
| 54 |
+
try:
|
| 55 |
+
from ddgs import DDGS # type: ignore
|
| 56 |
+
DDGS_AVAILABLE = True
|
| 57 |
+
except ImportError:
|
| 58 |
+
try:
|
| 59 |
+
from duckduckgo_search import DDGS # type: ignore # nome antigo
|
| 60 |
+
DDGS_AVAILABLE = True
|
| 61 |
+
except ImportError:
|
| 62 |
+
DDGS_AVAILABLE = False
|
| 63 |
+
DDGS = None # type: ignore
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
import requests # type: ignore
|
| 67 |
+
REQUESTS_AVAILABLE = True
|
| 68 |
+
except ImportError:
|
| 69 |
+
REQUESTS_AVAILABLE = False
|
| 70 |
+
requests = None # type: ignore
|
| 71 |
+
|
| 72 |
+
try:
|
| 73 |
+
from bs4 import BeautifulSoup # type: ignore
|
| 74 |
+
BS4_AVAILABLE = True
|
| 75 |
+
except ImportError:
|
| 76 |
+
BS4_AVAILABLE = False
|
| 77 |
+
BeautifulSoup = None # type: ignore
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
from loguru import logger # type: ignore
|
| 81 |
+
except ImportError:
|
| 82 |
+
class _DummyLogger:
|
| 83 |
+
def info(self, *a, **k): pass
|
| 84 |
+
def success(self, *a, **k): pass
|
| 85 |
+
def warning(self, *a, **k): pass
|
| 86 |
+
def error(self, *a, **k): pass
|
| 87 |
+
def debug(self, *a, **k): pass
|
| 88 |
+
logger = _DummyLogger() # type: ignore
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
from cachetools import TTLCache # type: ignore
|
| 92 |
+
_CacheOK = True
|
| 93 |
+
except ImportError:
|
| 94 |
+
_CacheOK = False
|
| 95 |
+
class TTLCache(dict): # type: ignore
|
| 96 |
+
def __init__(self, maxsize=100, ttl=900, **kwargs):
|
| 97 |
+
super().__init__(**kwargs)
|
| 98 |
+
self.maxsize = maxsize
|
| 99 |
+
self.ttl = ttl
|
| 100 |
+
self._ts: Dict[str, float] = {}
|
| 101 |
+
|
| 102 |
+
def __setitem__(self, key, value):
|
| 103 |
+
super().__setitem__(key, value)
|
| 104 |
+
self._ts[key] = time.time()
|
| 105 |
+
if len(self) > self.maxsize:
|
| 106 |
+
oldest = min(self._ts, key=lambda k: self._ts[k])
|
| 107 |
+
self.pop(oldest, None)
|
| 108 |
+
self._ts.pop(oldest, None)
|
| 109 |
+
|
| 110 |
+
def get(self, key, default=None):
|
| 111 |
+
if key in self._ts and time.time() - self._ts[key] > self.ttl:
|
| 112 |
+
self.pop(key, None)
|
| 113 |
+
self._ts.pop(key, None)
|
| 114 |
+
return default
|
| 115 |
+
return super().get(key, default)
|
| 116 |
+
|
| 117 |
+
# ============================================================
|
| 118 |
+
# CONFIGURAÇÕES GLOBAIS
|
| 119 |
+
# ============================================================
|
| 120 |
+
|
| 121 |
+
REQUEST_TIMEOUT = 12
|
| 122 |
+
|
| 123 |
+
# Cache com diferentes TTLs por tipo (segundos)
|
| 124 |
+
_CACHE_GERAL = TTLCache(maxsize=60, ttl=900) # 15 min
|
| 125 |
+
_CACHE_NOTICIAS= TTLCache(maxsize=30, ttl=300) # 5 min (notícias mudam rápido)
|
| 126 |
+
_CACHE_WIKI = TTLCache(maxsize=50, ttl=3600) # 1h (Wikipedia é estável)
|
| 127 |
+
_CACHE_CLIMA = TTLCache(maxsize=20, ttl=600) # 10 min
|
| 128 |
+
|
| 129 |
+
USER_AGENTS = [
|
| 130 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
| 131 |
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
|
| 132 |
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
| 133 |
+
]
|
| 134 |
+
|
| 135 |
+
OPENWEATHER_KEY = os.getenv("OPENWEATHER_API_KEY", "")
|
| 136 |
+
|
| 137 |
+
# Palavras-gatilho para busca autônoma (contexto NLP)
|
| 138 |
+
_TRIGGERS_BUSCA = [
|
| 139 |
+
# Comandos explícitos
|
| 140 |
+
"pesquisa", "busca na web", "buscar na internet", "pesquise",
|
| 141 |
+
"me busca", "google", "procura",
|
| 142 |
+
# Eventos atuais
|
| 143 |
+
"o que está acontecendo", "últimas notícias", "notícias de hoje",
|
| 144 |
+
"o que aconteceu", "aconteceu", "novidades",
|
| 145 |
+
# Perguntas factuais específicas
|
| 146 |
+
"quem é o presidente", "qual é a população", "quantos habitantes",
|
| 147 |
+
"qual a capital", "onde fica", "quando foi fundado",
|
| 148 |
+
# Sports/resultados
|
| 149 |
+
"placar", "resultado do jogo", "ganhou a copa", "eliminado",
|
| 150 |
+
# Temporal
|
| 151 |
+
"ontem", "esta semana", "esse mês", "ano passado", "2025", "2026",
|
| 152 |
+
# Pessoas
|
| 153 |
+
"morreu", "foi preso", "foi assassinado", "renunciou", "eleito",
|
| 154 |
+
# Tempo/clima
|
| 155 |
+
"vai chover", "temperatura em", "clima em", "previsão do tempo",
|
| 156 |
+
]
|
| 157 |
+
|
| 158 |
+
_PERGUNTAS_FATOS = [
|
| 159 |
+
"?", "quem", "qual", "quando", "onde", "quanto", "quantos",
|
| 160 |
+
"por que", "como é", "o que é", "me conta", "explica",
|
| 161 |
+
]
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# ============================================================
|
| 165 |
+
# CLASSE PRINCIPAL
|
| 166 |
+
# ============================================================
|
| 167 |
+
@dataclass
|
| 168 |
+
class WebSearchConfig:
|
| 169 |
+
db_path: str = DB_PATH
|
| 170 |
+
|
| 171 |
+
class WebSearch:
|
| 172 |
+
"""
|
| 173 |
+
Motor de busca autônoma profissional para AKIRA.
|
| 174 |
+
|
| 175 |
+
Prioridade de backends:
|
| 176 |
+
1. DDGS (duckduckgo-search) - principal, sem API key
|
| 177 |
+
2. Wikipedia API - para perguntas conceituais
|
| 178 |
+
3. OpenWeatherMap - para clima
|
| 179 |
+
4. Scraping direto via BeautifulSoup - fallback
|
| 180 |
+
"""
|
| 181 |
+
|
| 182 |
+
def __init__(self, db=None):
|
| 183 |
+
"""
|
| 184 |
+
Args:
|
| 185 |
+
db: Instância do Database para persistência das buscas (opcional)
|
| 186 |
+
"""
|
| 187 |
+
self.db = db
|
| 188 |
+
self._session = None
|
| 189 |
+
self._setup_session()
|
| 190 |
+
|
| 191 |
+
if DDGS_AVAILABLE:
|
| 192 |
+
logger.success("🔍 WebSearch: DDGS (DuckDuckGo) disponível e ativo")
|
| 193 |
+
else:
|
| 194 |
+
logger.warning("⚠️ WebSearch: ddgs não instalado – fallback via scraping")
|
| 195 |
+
|
| 196 |
+
def _setup_session(self):
|
| 197 |
+
"""Configura sessão HTTP com headers realistas."""
|
| 198 |
+
if not REQUESTS_AVAILABLE:
|
| 199 |
+
return
|
| 200 |
+
self._session = requests.Session()
|
| 201 |
+
self._session.headers.update({
|
| 202 |
+
"User-Agent": random.choice(USER_AGENTS),
|
| 203 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 204 |
+
"Accept-Language": "pt-BR,pt;q=0.9,en-US;q=0.8",
|
| 205 |
+
"Accept-Encoding": "gzip, deflate",
|
| 206 |
+
"Connection": "keep-alive",
|
| 207 |
+
})
|
| 208 |
+
|
| 209 |
+
def _rotate_ua(self):
|
| 210 |
+
"""Rotaciona User-Agent para evitar bloqueio."""
|
| 211 |
+
if self._session:
|
| 212 |
+
self._session.headers["User-Agent"] = random.choice(USER_AGENTS)
|
| 213 |
+
|
| 214 |
+
# ==================================================================
|
| 215 |
+
# 🌐 INTERFACE PRINCIPAL
|
| 216 |
+
# ==================================================================
|
| 217 |
+
|
| 218 |
+
def pesquisar(
|
| 219 |
+
self,
|
| 220 |
+
query: str,
|
| 221 |
+
num_results: int = 5,
|
| 222 |
+
tipo: Optional[str] = None,
|
| 223 |
+
) -> Dict[str, Any]:
|
| 224 |
+
"""
|
| 225 |
+
Pesquisa completa com detecção automática de tipo.
|
| 226 |
+
|
| 227 |
+
Args:
|
| 228 |
+
query: Termo de pesquisa
|
| 229 |
+
num_results: Número de resultados (max 10)
|
| 230 |
+
tipo: Forçar tipo: 'geral'|'noticias'|'wikipedia'|'clima'|'imagens'
|
| 231 |
+
|
| 232 |
+
Returns:
|
| 233 |
+
Dict com 'conteudo_bruto', 'resumo', 'tipo', 'resultados'
|
| 234 |
+
"""
|
| 235 |
+
if not query or not query.strip():
|
| 236 |
+
return self._erro("Query vazia")
|
| 237 |
+
|
| 238 |
+
query = query.strip()
|
| 239 |
+
cache_key = hashlib.md5(f"{query}:{num_results}:{tipo}".encode()).hexdigest()[:16]
|
| 240 |
+
|
| 241 |
+
# Detecta tipo se não especificado
|
| 242 |
+
tipo_detectado = tipo or self.detectar_tipo_pesquisa(query)
|
| 243 |
+
|
| 244 |
+
# Verifica cache específico por tipo
|
| 245 |
+
cache = self._get_cache(tipo_detectado)
|
| 246 |
+
cached = cache.get(cache_key)
|
| 247 |
+
if cached:
|
| 248 |
+
logger.debug(f"📦 Cache hit [{tipo_detectado}]: {query[:40]}")
|
| 249 |
+
return cached
|
| 250 |
+
|
| 251 |
+
# Rotaciona UA
|
| 252 |
+
self._rotate_ua()
|
| 253 |
+
|
| 254 |
+
# Executa busca pelo tipo
|
| 255 |
+
resultado: Dict[str, Any]
|
| 256 |
+
if tipo_detectado == "wikipedia":
|
| 257 |
+
resultado = self._buscar_wikipedia(query)
|
| 258 |
+
elif tipo_detectado == "noticias":
|
| 259 |
+
resultado = self._buscar_noticias(query, num_results)
|
| 260 |
+
elif tipo_detectado == "clima":
|
| 261 |
+
resultado = self._buscar_clima(query)
|
| 262 |
+
elif tipo_detectado == "imagens":
|
| 263 |
+
resultado = self._buscar_imagens(query, num_results)
|
| 264 |
+
else:
|
| 265 |
+
resultado = self._buscar_texto_ddgs(query, num_results)
|
| 266 |
+
|
| 267 |
+
# Salva no cache
|
| 268 |
+
cache[cache_key] = resultado
|
| 269 |
+
|
| 270 |
+
# Persiste no banco de dados para RAG futuro
|
| 271 |
+
self._persistir_busca(query, tipo_detectado, resultado)
|
| 272 |
+
|
| 273 |
+
return resultado
|
| 274 |
+
|
| 275 |
+
def buscar_conteudo_completo(self, query: str) -> str:
|
| 276 |
+
"""Retorna string bruta pronta para inserir no prompt."""
|
| 277 |
+
r = self.pesquisar(query)
|
| 278 |
+
return r.get("conteudo_bruto", "Sem resultados disponíveis.")
|
| 279 |
+
|
| 280 |
+
def buscar_resumido(self, query: str) -> str:
|
| 281 |
+
r = self.pesquisar(query, num_results=3)
|
| 282 |
+
return r.get("resumo", "Sem resumo disponível.")
|
| 283 |
+
|
| 284 |
+
# ==================================================================
|
| 285 |
+
# 🤖 PESQUISA AUTÔNOMA – a IA decide sozinha se deve buscar
|
| 286 |
+
# ==================================================================
|
| 287 |
+
|
| 288 |
+
def deve_buscar_na_web(self, mensagem: str, historico: Optional[List[str]] = None) -> bool:
|
| 289 |
+
"""
|
| 290 |
+
Decisão autônoma: a AKIRA deve buscar na web por conta própria?
|
| 291 |
+
|
| 292 |
+
Lógica em camadas:
|
| 293 |
+
1. Gatilhos explícitos (o usuário pediu)
|
| 294 |
+
2. Perguntas factuais com marcadores temporais
|
| 295 |
+
3. Tópicos que o modelo definitivamente não sabe (eventos pós-treino)
|
| 296 |
+
4. Palavras de eventos conhecidos recentes
|
| 297 |
+
|
| 298 |
+
Args:
|
| 299 |
+
mensagem: Última mensagem do usuário
|
| 300 |
+
historico: Últimas mensagens do histórico (contexto adicional)
|
| 301 |
+
|
| 302 |
+
Returns:
|
| 303 |
+
True se deve pesquisar na web
|
| 304 |
+
"""
|
| 305 |
+
msg = mensagem.lower().strip()
|
| 306 |
+
|
| 307 |
+
# 1. Gatilhos explícitos
|
| 308 |
+
if any(t in msg for t in _TRIGGERS_BUSCA):
|
| 309 |
+
logger.info(f"🔍 Pesquisa autônoma ativada [gatilho explícito]: {msg[:60]}")
|
| 310 |
+
return True
|
| 311 |
+
|
| 312 |
+
# 2. Pergunta + indicador temporal/factual
|
| 313 |
+
is_pergunta = (
|
| 314 |
+
"?" in msg or
|
| 315 |
+
any(msg.startswith(p) for p in _PERGUNTAS_FATOS)
|
| 316 |
+
)
|
| 317 |
+
indicadores_atuais = [
|
| 318 |
+
"atual", "recente", "novo", "último", "agora",
|
| 319 |
+
"hoje", "ontem", "semana", "mês", "2024", "2025", "2026",
|
| 320 |
+
"presidente", "governo", "eleição", "guerra", "acordo",
|
| 321 |
+
"crise", "epidemia", "terremoto", "furacão"
|
| 322 |
+
]
|
| 323 |
+
if is_pergunta and any(p in msg for p in indicadores_atuais):
|
| 324 |
+
logger.info(f"🔍 Pesquisa autônoma ativada [pergunta+temporal]: {msg[:60]}")
|
| 325 |
+
return True
|
| 326 |
+
|
| 327 |
+
# 3. Pessoa pede para contar/explicar com contexto que muda
|
| 328 |
+
frases_dinamicas = [
|
| 329 |
+
"me conta sobre", "o que você sabe sobre", "quem é",
|
| 330 |
+
"o que é", "me fala sobre", "sabes de", "sabe de"
|
| 331 |
+
]
|
| 332 |
+
if any(f in msg for f in frases_dinamicas):
|
| 333 |
+
# Verifica se é sobre algo que pode ser evento recente
|
| 334 |
+
entidades_suspeitas = msg.split()
|
| 335 |
+
# Heurística: mais de 1 palavra após a frase → provavelmente nome próprio
|
| 336 |
+
for frase in frases_dinamicas:
|
| 337 |
+
if frase in msg:
|
| 338 |
+
pos = msg.find(frase) + len(frase)
|
| 339 |
+
resto = msg[pos:].strip()
|
| 340 |
+
if len(resto.split()) >= 1:
|
| 341 |
+
logger.info(f"🔍 Pesquisa autônoma ativada [entidade]: {resto[:60]}")
|
| 342 |
+
return True
|
| 343 |
+
|
| 344 |
+
# 4. Contexto do histórico (se usuário estava pedindo info antes)
|
| 345 |
+
if historico:
|
| 346 |
+
ultima_5 = " ".join(historico[-5:]).lower()
|
| 347 |
+
if any(t in ultima_5 for t in ["pesquisa", "busca", "notícia", "aconteceu"]):
|
| 348 |
+
return True
|
| 349 |
+
|
| 350 |
+
return False
|
| 351 |
+
|
| 352 |
+
def extrair_assunto_busca(self, mensagem: str) -> str:
|
| 353 |
+
"""
|
| 354 |
+
Extrai o assunto principal da mensagem para usar como query.
|
| 355 |
+
Mais inteligente que a versão antiga – usa múltiplas heurísticas.
|
| 356 |
+
"""
|
| 357 |
+
msg = mensagem.strip()
|
| 358 |
+
msg_lower = msg.lower()
|
| 359 |
+
|
| 360 |
+
# Padrões de extração em ordem de prioridade
|
| 361 |
+
padroes = [
|
| 362 |
+
r"(?:pesquisa|busca|pesquise|procura|me busca|me fala)\s+(?:sobre|de|a respeito de)?\s*(.+)",
|
| 363 |
+
r"(?:quem é|o que é|o que são|onde fica|qual é|quando foi|como é)\s+(.+)",
|
| 364 |
+
r"(?:me conta|me fala|explica|me explica)\s+(?:sobre|de)?\s*(.+)",
|
| 365 |
+
r"(?:notícia|noticia|novidade)\s+(?:sobre|de)\s*(.+)",
|
| 366 |
+
]
|
| 367 |
+
|
| 368 |
+
for pat in padroes:
|
| 369 |
+
m = re.search(pat, msg_lower)
|
| 370 |
+
if m:
|
| 371 |
+
resultado = m.group(1).strip().rstrip(".,!?")
|
| 372 |
+
if len(resultado) > 2:
|
| 373 |
+
return resultado
|
| 374 |
+
|
| 375 |
+
# Se é uma pergunta direta, use a mensagem inteira mas limpa
|
| 376 |
+
stopwords = ["pesquisa", "busca", "buscar", "procura", "me", "por favor", "pf", "pfv"]
|
| 377 |
+
tokens = msg_lower.split()
|
| 378 |
+
tokens_limpos = [t for t in tokens if t not in stopwords]
|
| 379 |
+
|
| 380 |
+
return " ".join(tokens_limpos) if tokens_limpos else msg
|
| 381 |
+
|
| 382 |
+
# ==================================================================
|
| 383 |
+
# 🎯 DETECÇÃO DE TIPO
|
| 384 |
+
# ==================================================================
|
| 385 |
+
|
| 386 |
+
def detectar_tipo_pesquisa(self, query: str) -> str:
|
| 387 |
+
"""
|
| 388 |
+
Detecta automaticamente o melhor tipo de busca para a query.
|
| 389 |
+
|
| 390 |
+
Returns:
|
| 391 |
+
'wikipedia' | 'noticias' | 'clima' | 'imagens' | 'geral'
|
| 392 |
+
"""
|
| 393 |
+
q = query.lower()
|
| 394 |
+
|
| 395 |
+
# Clima
|
| 396 |
+
clima_kws = ["clima", "tempo", "temperatura", "vai chover", "previsão", "chuva", "sol", "humidade"]
|
| 397 |
+
if any(k in q for k in clima_kws):
|
| 398 |
+
return "clima"
|
| 399 |
+
|
| 400 |
+
# Wikipedia – perguntas conceituais/definitórias
|
| 401 |
+
wiki_kws = [
|
| 402 |
+
"o que é", "quem é", "onde fica", "como funciona", "história de",
|
| 403 |
+
"wikipédia", "wikipedia", "biografi", "definição de",
|
| 404 |
+
"quando foi criado", "quando nasceu", "quando morreu", "inventor"
|
| 405 |
+
]
|
| 406 |
+
if any(k in q for k in wiki_kws):
|
| 407 |
+
return "wikipedia"
|
| 408 |
+
|
| 409 |
+
# Notícias – eventos atuais
|
| 410 |
+
news_kws = [
|
| 411 |
+
"notícia", "noticia", "última hora", "breaking", "aconteceu",
|
| 412 |
+
"hoje", "eleição", "guerra", "crise", "julgamento",
|
| 413 |
+
"preso", "morreu", "assassinado", "renunciou", "ganhou"
|
| 414 |
+
]
|
| 415 |
+
if any(k in q for k in news_kws):
|
| 416 |
+
return "noticias"
|
| 417 |
+
|
| 418 |
+
# Imagens
|
| 419 |
+
img_kws = ["foto de", "imagem de", "fotos de", "imagens de", "como é", "me mostra"]
|
| 420 |
+
if any(k in q for k in img_kws):
|
| 421 |
+
return "imagens"
|
| 422 |
+
|
| 423 |
+
return "geral"
|
| 424 |
+
|
| 425 |
+
# ==================================================================
|
| 426 |
+
# 📰 BUSCA DE TEXTO VIA DDGS (principal)
|
| 427 |
+
# ==================================================================
|
| 428 |
+
|
| 429 |
+
def _buscar_texto_ddgs(self, query: str, num: int = 5) -> Dict[str, Any]:
|
| 430 |
+
"""Busca geral usando a biblioteca DDGS (DuckDuckGo Search)."""
|
| 431 |
+
if not DDGS_AVAILABLE:
|
| 432 |
+
return self._buscar_texto_fallback(query, num)
|
| 433 |
+
|
| 434 |
+
try:
|
| 435 |
+
resultados = []
|
| 436 |
+
with DDGS() as ddgs:
|
| 437 |
+
for r in ddgs.text(
|
| 438 |
+
query,
|
| 439 |
+
region="wt-wt",
|
| 440 |
+
safesearch="off",
|
| 441 |
+
timelimit=None,
|
| 442 |
+
max_results=num,
|
| 443 |
+
):
|
| 444 |
+
resultados.append({
|
| 445 |
+
"titulo": r.get("title", ""),
|
| 446 |
+
"url": r.get("href", ""),
|
| 447 |
+
"snippet": r.get("body", ""),
|
| 448 |
+
})
|
| 449 |
+
|
| 450 |
+
if not resultados:
|
| 451 |
+
return self._erro("DDGS: nenhum resultado")
|
| 452 |
+
|
| 453 |
+
# Tenta enriquecer com conteúdo das páginas
|
| 454 |
+
for res in resultados[:2]: # Só as 2 primeiras para não overload
|
| 455 |
+
conteudo = self._raspar_pagina(res["url"])
|
| 456 |
+
if conteudo:
|
| 457 |
+
res["conteudo_pagina"] = conteudo[:2000]
|
| 458 |
+
|
| 459 |
+
bruto = self._montar_bruto_geral(query, resultados)
|
| 460 |
+
return {
|
| 461 |
+
"tipo": "geral",
|
| 462 |
+
"query": query,
|
| 463 |
+
"resumo": f"Web Search: '{query}' – {len(resultados)} resultados",
|
| 464 |
+
"conteudo_bruto": bruto,
|
| 465 |
+
"resultados": resultados,
|
| 466 |
+
"timestamp": datetime.now().isoformat(),
|
| 467 |
+
"fonte": "ddgs",
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
except Exception as e:
|
| 471 |
+
logger.warning(f"DDGS texto error: {e}")
|
| 472 |
+
return self._buscar_texto_fallback(query, num)
|
| 473 |
+
|
| 474 |
+
# ==================================================================
|
| 475 |
+
# 📰 BUSCA DE NOTÍCIAS VIA DDGS
|
| 476 |
+
# ==================================================================
|
| 477 |
+
|
| 478 |
+
def _buscar_noticias(self, query: str, num: int = 5) -> Dict[str, Any]:
|
| 479 |
+
"""Busca notícias usando DDGS News backend."""
|
| 480 |
+
if not DDGS_AVAILABLE:
|
| 481 |
+
return self._buscar_texto_ddgs(query, num) # fallback para geral
|
| 482 |
+
|
| 483 |
+
try:
|
| 484 |
+
noticias = []
|
| 485 |
+
with DDGS() as ddgs:
|
| 486 |
+
for r in ddgs.news(
|
| 487 |
+
query,
|
| 488 |
+
region="wt-wt",
|
| 489 |
+
safesearch="off",
|
| 490 |
+
timelimit="w", # última semana
|
| 491 |
+
max_results=num,
|
| 492 |
+
):
|
| 493 |
+
noticias.append({
|
| 494 |
+
"titulo": r.get("title", ""),
|
| 495 |
+
"url": r.get("url", ""),
|
| 496 |
+
"snippet": r.get("body", ""),
|
| 497 |
+
"fonte": r.get("source", ""),
|
| 498 |
+
"data": r.get("date", ""),
|
| 499 |
+
})
|
| 500 |
+
|
| 501 |
+
if not noticias:
|
| 502 |
+
# Tenta sem filtro de tempo
|
| 503 |
+
with DDGS() as ddgs:
|
| 504 |
+
for r in ddgs.news(query, max_results=num):
|
| 505 |
+
noticias.append({
|
| 506 |
+
"titulo": r.get("title", ""),
|
| 507 |
+
"url": r.get("url", ""),
|
| 508 |
+
"snippet": r.get("body", ""),
|
| 509 |
+
"fonte": r.get("source", ""),
|
| 510 |
+
"data": r.get("date", ""),
|
| 511 |
+
})
|
| 512 |
+
|
| 513 |
+
if not noticias:
|
| 514 |
+
return self._erro("Noticias: sem resultados")
|
| 515 |
+
|
| 516 |
+
bruto = f"=== 📰 NOTÍCIAS: {query.upper()} ===\n"
|
| 517 |
+
bruto += f"DATA DA BUSCA: {datetime.now().strftime('%d/%m/%Y %H:%M')}\n\n"
|
| 518 |
+
for i, n in enumerate(noticias, 1):
|
| 519 |
+
bruto += f"[{i}] {n['titulo']}\n"
|
| 520 |
+
if n.get("fonte"):
|
| 521 |
+
bruto += f" Fonte: {n['fonte']}"
|
| 522 |
+
if n.get("data"):
|
| 523 |
+
bruto += f" | Data: {n['data']}"
|
| 524 |
+
bruto += "\n"
|
| 525 |
+
if n.get("snippet"):
|
| 526 |
+
bruto += f" {n['snippet'][:300]}\n"
|
| 527 |
+
if n.get("url"):
|
| 528 |
+
bruto += f" 🔗 {n['url']}\n"
|
| 529 |
+
bruto += "\n"
|
| 530 |
+
bruto += "--- FIM DAS NOTÍCIAS ---\n"
|
| 531 |
+
|
| 532 |
+
return {
|
| 533 |
+
"tipo": "noticias",
|
| 534 |
+
"query": query,
|
| 535 |
+
"resumo": f"Notícias sobre '{query}': {len(noticias)} encontradas",
|
| 536 |
+
"conteudo_bruto": bruto,
|
| 537 |
+
"resultados": noticias,
|
| 538 |
+
"timestamp": datetime.now().isoformat(),
|
| 539 |
+
"fonte": "ddgs_news",
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
except Exception as e:
|
| 543 |
+
logger.warning(f"DDGS noticias error: {e}")
|
| 544 |
+
return self._buscar_texto_ddgs(query, num)
|
| 545 |
+
|
| 546 |
+
# ==================================================================
|
| 547 |
+
# 📚 WIKIPEDIA
|
| 548 |
+
# ==================================================================
|
| 549 |
+
|
| 550 |
+
def _buscar_wikipedia(self, query: str) -> Dict[str, Any]:
|
| 551 |
+
"""Busca na Wikipedia PT via API oficial com extração completa."""
|
| 552 |
+
if not REQUESTS_AVAILABLE:
|
| 553 |
+
return self._erro("Wikipedia: requests não disponível")
|
| 554 |
+
|
| 555 |
+
try:
|
| 556 |
+
# 1. Pesquisa para encontrar o artigo correto
|
| 557 |
+
search_url = "https://pt.wikipedia.org/w/api.php"
|
| 558 |
+
r = self._session.get(search_url, params={
|
| 559 |
+
"action": "query",
|
| 560 |
+
"format": "json",
|
| 561 |
+
"list": "search",
|
| 562 |
+
"srsearch": query,
|
| 563 |
+
"srlimit": 3,
|
| 564 |
+
}, timeout=REQUEST_TIMEOUT)
|
| 565 |
+
|
| 566 |
+
if r.status_code != 200:
|
| 567 |
+
return self._erro(f"Wikipedia HTTP {r.status_code}")
|
| 568 |
+
|
| 569 |
+
data = r.json()
|
| 570 |
+
resultados = data.get("query", {}).get("search", [])
|
| 571 |
+
if not resultados:
|
| 572 |
+
return self._erro("Wikipedia: nenhuma página encontrada")
|
| 573 |
+
|
| 574 |
+
# Pega o mais relevante
|
| 575 |
+
page_title = resultados[0]["title"]
|
| 576 |
+
|
| 577 |
+
# 2. Busca conteúdo completo da página
|
| 578 |
+
r2 = self._session.get(search_url, params={
|
| 579 |
+
"action": "query",
|
| 580 |
+
"format": "json",
|
| 581 |
+
"prop": "extracts|info",
|
| 582 |
+
"exintro": False,
|
| 583 |
+
"explaintext": True,
|
| 584 |
+
"titles": page_title,
|
| 585 |
+
"inprop": "url",
|
| 586 |
+
}, timeout=REQUEST_TIMEOUT)
|
| 587 |
+
|
| 588 |
+
data2 = r2.json()
|
| 589 |
+
pages = data2.get("query", {}).get("pages", {})
|
| 590 |
+
page = next(iter(pages.values()), {})
|
| 591 |
+
|
| 592 |
+
extract = page.get("extract", "")
|
| 593 |
+
fullurl = page.get("fullurl", f"https://pt.wikipedia.org/wiki/{page_title.replace(' ', '_')}")
|
| 594 |
+
|
| 595 |
+
# Limpa e formata
|
| 596 |
+
extract = re.sub(r'\[\d+\]', '', extract)
|
| 597 |
+
extract = re.sub(r'\s+', ' ', extract).strip()
|
| 598 |
+
|
| 599 |
+
bruto = f"=== 📚 WIKIPEDIA: {page_title} ===\n"
|
| 600 |
+
bruto += f"Fonte: {fullurl}\n"
|
| 601 |
+
bruto += f"Data da consulta: {datetime.now().strftime('%d/%m/%Y %H:%M')}\n\n"
|
| 602 |
+
bruto += "CONTEÚDO:\n"
|
| 603 |
+
bruto += extract[:6000]
|
| 604 |
+
bruto += "\n\n--- FIM WIKIPEDIA ---\n"
|
| 605 |
+
|
| 606 |
+
return {
|
| 607 |
+
"tipo": "wikipedia",
|
| 608 |
+
"titulo": page_title,
|
| 609 |
+
"url": fullurl,
|
| 610 |
+
"resumo": f"Wikipedia: {page_title}",
|
| 611 |
+
"conteudo_bruto": bruto,
|
| 612 |
+
"timestamp": datetime.now().isoformat(),
|
| 613 |
+
"fonte": "wikipedia_api",
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
except Exception as e:
|
| 617 |
+
logger.warning(f"Wikipedia error: {e}")
|
| 618 |
+
return self._erro(f"Wikipedia: {e}")
|
| 619 |
+
|
| 620 |
+
# ==================================================================
|
| 621 |
+
# 🌤️ CLIMA
|
| 622 |
+
# ==================================================================
|
| 623 |
+
|
| 624 |
+
def _buscar_clima(self, query: str) -> Dict[str, Any]:
|
| 625 |
+
"""
|
| 626 |
+
Busca clima via OpenWeatherMap (se API key disponível)
|
| 627 |
+
ou via wttr.in (sempre disponível, sem key).
|
| 628 |
+
"""
|
| 629 |
+
# Extrai cidade da query
|
| 630 |
+
cidade = self._extrair_cidade(query)
|
| 631 |
+
|
| 632 |
+
# Tenta wttr.in (sempre gratuito)
|
| 633 |
+
try:
|
| 634 |
+
if self._session:
|
| 635 |
+
url = f"https://wttr.in/{cidade}?format=j1&lang=pt"
|
| 636 |
+
r = self._session.get(url, timeout=REQUEST_TIMEOUT)
|
| 637 |
+
if r.status_code == 200:
|
| 638 |
+
data = r.json()
|
| 639 |
+
cc = data.get("current_condition", [{}])[0]
|
| 640 |
+
area = data.get("nearest_area", [{}])[0]
|
| 641 |
+
nome_area = area.get("areaName", [{}])[0].get("value", cidade)
|
| 642 |
+
pais = area.get("country", [{}])[0].get("value", "")
|
| 643 |
+
|
| 644 |
+
temp_c = cc.get("temp_C", "?")
|
| 645 |
+
sensacao = cc.get("FeelsLikeC", "?")
|
| 646 |
+
humidade = cc.get("humidity", "?")
|
| 647 |
+
vento_kmh = cc.get("windspeedKmph", "?")
|
| 648 |
+
descricao = cc.get("weatherDesc", [{}])[0].get("value", "")
|
| 649 |
+
|
| 650 |
+
bruto = f"=== 🌤️ CLIMA: {nome_area}, {pais} ===\n"
|
| 651 |
+
bruto += f"Data: {datetime.now().strftime('%d/%m/%Y %H:%M')}\n\n"
|
| 652 |
+
bruto += f"🌡️ Temperatura atual: {temp_c}°C (sensação: {sensacao}°C)\n"
|
| 653 |
+
bruto += f"💧 Humidade: {humidade}%\n"
|
| 654 |
+
bruto += f"💨 Vento: {vento_kmh} km/h\n"
|
| 655 |
+
bruto += f"☁️ Condição: {descricao}\n"
|
| 656 |
+
bruto += "\n--- FIM CLIMA ---\n"
|
| 657 |
+
|
| 658 |
+
return {
|
| 659 |
+
"tipo": "clima",
|
| 660 |
+
"cidade": nome_area,
|
| 661 |
+
"resumo": f"Clima em {nome_area}: {temp_c}°C, {descricao}",
|
| 662 |
+
"conteudo_bruto": bruto,
|
| 663 |
+
"temperatura": temp_c,
|
| 664 |
+
"timestamp": datetime.now().isoformat(),
|
| 665 |
+
"fonte": "wttr.in",
|
| 666 |
+
}
|
| 667 |
+
except Exception as e:
|
| 668 |
+
logger.warning(f"wttr.in error: {e}")
|
| 669 |
+
|
| 670 |
+
# Fallback: OpenWeatherMap se key disponível
|
| 671 |
+
if OPENWEATHER_KEY:
|
| 672 |
+
return self._clima_openweather(cidade)
|
| 673 |
+
|
| 674 |
+
return self._erro(f"Clima: não foi possível obter dados para '{cidade}'")
|
| 675 |
+
|
| 676 |
+
def _clima_openweather(self, cidade: str) -> Dict[str, Any]:
|
| 677 |
+
"""Fallback via OpenWeatherMap API."""
|
| 678 |
+
try:
|
| 679 |
+
url = "https://api.openweathermap.org/data/2.5/weather"
|
| 680 |
+
r = self._session.get(url, params={
|
| 681 |
+
"q": cidade,
|
| 682 |
+
"appid": OPENWEATHER_KEY,
|
| 683 |
+
"units": "metric",
|
| 684 |
+
"lang": "pt",
|
| 685 |
+
}, timeout=REQUEST_TIMEOUT)
|
| 686 |
+
|
| 687 |
+
if r.status_code != 200:
|
| 688 |
+
return self._erro(f"OpenWeather HTTP {r.status_code}")
|
| 689 |
+
|
| 690 |
+
data = r.json()
|
| 691 |
+
temp = data["main"]["temp"]
|
| 692 |
+
sensacao = data["main"]["feels_like"]
|
| 693 |
+
humidade = data["main"]["humidity"]
|
| 694 |
+
vento = data["wind"]["speed"] * 3.6 # m/s → km/h
|
| 695 |
+
desc = data["weather"][0]["description"]
|
| 696 |
+
nome = data.get("name", cidade)
|
| 697 |
+
|
| 698 |
+
bruto = f"=== 🌤️ CLIMA: {nome} ===\n"
|
| 699 |
+
bruto += f"Temperatura: {temp:.1f}°C (sensação: {sensacao:.1f}°C)\n"
|
| 700 |
+
bruto += f"Humidade: {humidade}%\n"
|
| 701 |
+
bruto += f"Vento: {vento:.1f} km/h\n"
|
| 702 |
+
bruto += f"Condição: {desc.capitalize()}\n"
|
| 703 |
+
bruto += "--- FIM CLIMA ---\n"
|
| 704 |
+
|
| 705 |
+
return {
|
| 706 |
+
"tipo": "clima", "cidade": nome,
|
| 707 |
+
"resumo": f"Clima em {nome}: {temp}°C, {desc}",
|
| 708 |
+
"conteudo_bruto": bruto,
|
| 709 |
+
"timestamp": datetime.now().isoformat(),
|
| 710 |
+
"fonte": "openweathermap",
|
| 711 |
+
}
|
| 712 |
+
except Exception as e:
|
| 713 |
+
return self._erro(f"OpenWeather: {e}")
|
| 714 |
+
|
| 715 |
+
# ==================================================================
|
| 716 |
+
# 🖼️ IMAGENS VIA DDGS
|
| 717 |
+
# ==================================================================
|
| 718 |
+
|
| 719 |
+
def _buscar_imagens(self, query: str, num: int = 5) -> Dict[str, Any]:
|
| 720 |
+
"""Busca URLs de imagens via DDGS."""
|
| 721 |
+
if not DDGS_AVAILABLE:
|
| 722 |
+
return self._erro("DDGS não disponível para imagens")
|
| 723 |
+
|
| 724 |
+
try:
|
| 725 |
+
imagens = []
|
| 726 |
+
with DDGS() as ddgs:
|
| 727 |
+
for r in ddgs.images(
|
| 728 |
+
query,
|
| 729 |
+
region="wt-wt",
|
| 730 |
+
safesearch="off",
|
| 731 |
+
size=None,
|
| 732 |
+
max_results=num,
|
| 733 |
+
):
|
| 734 |
+
imagens.append({
|
| 735 |
+
"titulo": r.get("title", ""),
|
| 736 |
+
"url_imagem": r.get("image", ""),
|
| 737 |
+
"url_pagina": r.get("url", ""),
|
| 738 |
+
"thumbnail": r.get("thumbnail", ""),
|
| 739 |
+
"fonte": r.get("source", ""),
|
| 740 |
+
})
|
| 741 |
+
|
| 742 |
+
if not imagens:
|
| 743 |
+
return self._erro("Imagens: sem resultados")
|
| 744 |
+
|
| 745 |
+
bruto = f"=== 🖼️ IMAGENS: {query} ===\n"
|
| 746 |
+
bruto += f"Data: {datetime.now().strftime('%d/%m/%Y')}\n\n"
|
| 747 |
+
for i, img in enumerate(imagens, 1):
|
| 748 |
+
bruto += f"[{i}] {img['titulo']}\n"
|
| 749 |
+
bruto += f" URL: {img['url_imagem']}\n"
|
| 750 |
+
if img.get("fonte"):
|
| 751 |
+
bruto += f" Fonte: {img['fonte']}\n"
|
| 752 |
+
bruto += "\n"
|
| 753 |
+
bruto += "--- FIM IMAGENS ---\n"
|
| 754 |
+
|
| 755 |
+
return {
|
| 756 |
+
"tipo": "imagens",
|
| 757 |
+
"query": query,
|
| 758 |
+
"resumo": f"Imagens de '{query}': {len(imagens)} encontradas",
|
| 759 |
+
"conteudo_bruto": bruto,
|
| 760 |
+
"resultados": imagens,
|
| 761 |
+
"timestamp": datetime.now().isoformat(),
|
| 762 |
+
"fonte": "ddgs_images",
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
except Exception as e:
|
| 766 |
+
logger.warning(f"DDGS imagens error: {e}")
|
| 767 |
+
return self._erro(f"Imagens: {e}")
|
| 768 |
+
|
| 769 |
+
# ==================================================================
|
| 770 |
+
# 🔄 FALLBACK – Scraping manual via BeautifulSoup
|
| 771 |
+
# ==================================================================
|
| 772 |
+
|
| 773 |
+
def _buscar_texto_fallback(self, query: str, num: int = 5) -> Dict[str, Any]:
|
| 774 |
+
"""Fallback: scraping HTML do DuckDuckGo se DDGS não estiver instalado."""
|
| 775 |
+
if not REQUESTS_AVAILABLE or not BS4_AVAILABLE:
|
| 776 |
+
return self._erro("Dependências insuficientes para busca fallback")
|
| 777 |
+
|
| 778 |
+
try:
|
| 779 |
+
from urllib.parse import urlencode
|
| 780 |
+
url = f"https://html.duckduckgo.com/html/?{urlencode({'q': query, 'kl': 'pt-pt'})}"
|
| 781 |
+
r = self._session.get(url, timeout=REQUEST_TIMEOUT)
|
| 782 |
+
|
| 783 |
+
if r.status_code != 200:
|
| 784 |
+
return self._erro(f"DuckDuckGo HTML: HTTP {r.status_code}")
|
| 785 |
+
|
| 786 |
+
soup = BeautifulSoup(r.text, "html.parser")
|
| 787 |
+
resultados = []
|
| 788 |
+
for res in soup.find_all("div", class_="result")[:num]:
|
| 789 |
+
a = res.find("a", class_="result__a")
|
| 790 |
+
snip = res.find("a", class_="result__snippet")
|
| 791 |
+
if a:
|
| 792 |
+
resultados.append({
|
| 793 |
+
"titulo": a.get_text(strip=True),
|
| 794 |
+
"url": a.get("href", ""),
|
| 795 |
+
"snippet": snip.get_text(strip=True) if snip else "",
|
| 796 |
+
})
|
| 797 |
+
|
| 798 |
+
if not resultados:
|
| 799 |
+
return self._erro("Fallback: sem resultados")
|
| 800 |
+
|
| 801 |
+
bruto = self._montar_bruto_geral(query, resultados)
|
| 802 |
+
return {
|
| 803 |
+
"tipo": "geral",
|
| 804 |
+
"query": query,
|
| 805 |
+
"resumo": f"Web: '{query}' – {len(resultados)} resultados",
|
| 806 |
+
"conteudo_bruto": bruto,
|
| 807 |
+
"resultados": resultados,
|
| 808 |
+
"timestamp": datetime.now().isoformat(),
|
| 809 |
+
"fonte": "scraping_fallback",
|
| 810 |
+
}
|
| 811 |
+
|
| 812 |
+
except Exception as e:
|
| 813 |
+
return self._erro(f"Fallback: {e}")
|
| 814 |
+
|
| 815 |
+
# ==================================================================
|
| 816 |
+
# 🌐 RASPAGEM DE CONTEÚDO DE PÁGINA
|
| 817 |
+
# ==================================================================
|
| 818 |
+
|
| 819 |
+
def _raspar_pagina(self, url: str) -> str:
|
| 820 |
+
"""
|
| 821 |
+
Extrai conteúdo relevante de uma URL.
|
| 822 |
+
Retorna texto limpo ou string vazia se falhar.
|
| 823 |
+
"""
|
| 824 |
+
if not REQUESTS_AVAILABLE or not BS4_AVAILABLE or not url:
|
| 825 |
+
return ""
|
| 826 |
+
|
| 827 |
+
# Evita PDFs, binários, etc.
|
| 828 |
+
ignorar = [".pdf", ".doc", ".xls", ".zip", ".exe", "javascript:", "mailto:"]
|
| 829 |
+
if any(url.lower().endswith(ext) or ext in url.lower() for ext in ignorar):
|
| 830 |
+
return ""
|
| 831 |
+
|
| 832 |
+
try:
|
| 833 |
+
r = self._session.get(url, timeout=8)
|
| 834 |
+
if r.status_code != 200:
|
| 835 |
+
return ""
|
| 836 |
+
|
| 837 |
+
soup = BeautifulSoup(r.text, "html.parser")
|
| 838 |
+
|
| 839 |
+
# Remove scripts, style, nav, footer
|
| 840 |
+
for tag in soup.find_all(["script", "style", "nav", "footer", "header", "aside"]):
|
| 841 |
+
tag.decompose()
|
| 842 |
+
|
| 843 |
+
# Tenta encontrar conteúdo principal
|
| 844 |
+
main_content = (
|
| 845 |
+
soup.find("article") or
|
| 846 |
+
soup.find("main") or
|
| 847 |
+
soup.find("div", {"id": re.compile(r"content|main|article", re.I)}) or
|
| 848 |
+
soup.find("div", {"class": re.compile(r"content|main|article|post", re.I)})
|
| 849 |
+
)
|
| 850 |
+
|
| 851 |
+
if main_content:
|
| 852 |
+
texto = main_content.get_text(separator=" ", strip=True)
|
| 853 |
+
else:
|
| 854 |
+
texto = soup.get_text(separator=" ", strip=True)
|
| 855 |
+
|
| 856 |
+
# Limpa espaços excessivos
|
| 857 |
+
texto = re.sub(r"\s+", " ", texto).strip()
|
| 858 |
+
return texto[:3000]
|
| 859 |
+
|
| 860 |
+
except Exception:
|
| 861 |
+
return ""
|
| 862 |
+
|
| 863 |
+
# ==================================================================
|
| 864 |
+
# 🛠️ UTILITÁRIOS
|
| 865 |
+
# ==================================================================
|
| 866 |
+
|
| 867 |
+
def _montar_bruto_geral(self, query: str, resultados: List[Dict]) -> str:
|
| 868 |
+
bruto = f"=== 🔎 PESQUISA WEB: {query.upper()} ===\n"
|
| 869 |
+
bruto += f"Data: {datetime.now().strftime('%d/%m/%Y %H:%M')}\n"
|
| 870 |
+
bruto += f"Total de resultados: {len(resultados)}\n\n"
|
| 871 |
+
for i, r in enumerate(resultados, 1):
|
| 872 |
+
bruto += f"[{i}] {r.get('titulo', 'Sem título')}\n"
|
| 873 |
+
bruto += f" 🔗 {r.get('url', '')}\n"
|
| 874 |
+
if r.get("snippet"):
|
| 875 |
+
bruto += f" {r['snippet'][:400]}\n"
|
| 876 |
+
if r.get("conteudo_pagina"):
|
| 877 |
+
bruto += f" [CONTEÚDO] {r['conteudo_pagina'][:800]}\n"
|
| 878 |
+
bruto += "\n"
|
| 879 |
+
bruto += "--- FIM DOS RESULTADOS ---\n"
|
| 880 |
+
return bruto
|
| 881 |
+
|
| 882 |
+
def _extrair_cidade(self, query: str) -> str:
|
| 883 |
+
"""Extrai nome de cidade de uma query sobre clima."""
|
| 884 |
+
q = query.lower()
|
| 885 |
+
prefixos = ["clima em", "tempo em", "temperatura em", "previsão em", "vai chover em", "como está o tempo em"]
|
| 886 |
+
for p in prefixos:
|
| 887 |
+
if p in q:
|
| 888 |
+
return q.split(p)[-1].strip().split()[0].capitalize()
|
| 889 |
+
# Heurística: última palavra relevante
|
| 890 |
+
tokens = [t for t in query.split() if t.lower() not in
|
| 891 |
+
["clima", "tempo", "temperatura", "previsão", "hoje", "amanhã", "de", "em", "o", "a"]]
|
| 892 |
+
return tokens[-1].capitalize() if tokens else "Luanda"
|
| 893 |
+
|
| 894 |
+
def _get_cache(self, tipo: str) -> TTLCache:
|
| 895 |
+
if tipo == "noticias":
|
| 896 |
+
return _CACHE_NOTICIAS
|
| 897 |
+
if tipo == "wikipedia":
|
| 898 |
+
return _CACHE_WIKI
|
| 899 |
+
if tipo == "clima":
|
| 900 |
+
return _CACHE_CLIMA
|
| 901 |
+
return _CACHE_GERAL
|
| 902 |
+
|
| 903 |
+
def _persistir_busca(self, query: str, tipo: str, resultado: Dict):
|
| 904 |
+
"""Salva a busca no banco para uso como contexto RAG futuro."""
|
| 905 |
+
if not self.db:
|
| 906 |
+
return
|
| 907 |
+
try:
|
| 908 |
+
resumo = resultado.get("resumo", "")
|
| 909 |
+
self.db.salvar_aprendizado_detalhado(
|
| 910 |
+
usuario="sistema",
|
| 911 |
+
chave=f"web_search_{tipo}_{hashlib.md5(query.encode()).hexdigest()[:8]}",
|
| 912 |
+
valor=json.dumps({
|
| 913 |
+
"query": query,
|
| 914 |
+
"tipo": tipo,
|
| 915 |
+
"resumo": resumo,
|
| 916 |
+
"timestamp": datetime.now().isoformat(),
|
| 917 |
+
}, ensure_ascii=False)
|
| 918 |
+
)
|
| 919 |
+
except Exception as e:
|
| 920 |
+
logger.debug(f"Persistência de busca ignorada: {e}")
|
| 921 |
+
|
| 922 |
+
def _erro(self, mensagem: str) -> Dict[str, Any]:
|
| 923 |
+
return {
|
| 924 |
+
"tipo": "erro",
|
| 925 |
+
"resumo": mensagem,
|
| 926 |
+
"conteudo_bruto": f"=== ⚠️ ERRO NA PESQUISA ===\n{mensagem}\n---",
|
| 927 |
+
"timestamp": datetime.now().isoformat(),
|
| 928 |
+
"erro": True,
|
| 929 |
+
}
|
| 930 |
+
|
| 931 |
+
def limpar_cache(self):
|
| 932 |
+
_CACHE_GERAL.clear()
|
| 933 |
+
_CACHE_NOTICIAS.clear()
|
| 934 |
+
_CACHE_WIKI.clear()
|
| 935 |
+
_CACHE_CLIMA.clear()
|
| 936 |
+
logger.info("🧹 Todos os caches de WebSearch limpos")
|
| 937 |
+
|
| 938 |
+
|
| 939 |
+
# ============================================================
|
| 940 |
+
# SINGLETON & HELPERS PÚBLICOS
|
| 941 |
+
# ============================================================
|
| 942 |
+
|
| 943 |
+
_instance: Optional[WebSearch] = None
|
| 944 |
+
|
| 945 |
+
|
| 946 |
+
def get_web_search(db=None) -> WebSearch:
|
| 947 |
+
"""Retorna instância singleton do WebSearch."""
|
| 948 |
+
global _instance
|
| 949 |
+
if _instance is None:
|
| 950 |
+
_instance = WebSearch(db=db)
|
| 951 |
+
return _instance
|
| 952 |
+
|
| 953 |
+
|
| 954 |
+
def buscar_na_web(query: str, db=None) -> str:
|
| 955 |
+
"""Helper rápido: busca e retorna conteúdo bruto."""
|
| 956 |
+
return get_web_search(db=db).buscar_conteudo_completo(query)
|
| 957 |
+
|
| 958 |
+
|
| 959 |
+
def deve_pesquisar(mensagem: str, historico: Optional[List[str]] = None) -> bool:
|
| 960 |
+
"""Helper: decide se deve pesquisar na web."""
|
| 961 |
+
return get_web_search().deve_buscar_na_web(mensagem, historico)
|
| 962 |
+
|
| 963 |
+
|
| 964 |
+
def extrair_pesquisa(mensagem: str) -> str:
|
| 965 |
+
"""Helper: extrai assunto de busca da mensagem."""
|
| 966 |
+
return get_web_search().extrair_assunto_busca(mensagem)
|
| 967 |
+
|
| 968 |
+
|
| 969 |
+
__all__ = [
|
| 970 |
+
"WebSearch",
|
| 971 |
+
"get_web_search",
|
| 972 |
+
"buscar_na_web",
|
| 973 |
+
"deve_pesquisar",
|
| 974 |
+
"extrair_pesquisa",
|
| 975 |
+
]
|