akra35567 commited on
Commit
3b6c24d
·
verified ·
1 Parent(s): ebe5511

Upload 22 files

Browse files
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
- # modules/contexto.py — AKIRA V21 FINAL CORRIGIDO (Dezembro 2025)
2
- """
3
- ✅ TOTALMENTE ADAPTADO ao database.py correto
4
- ✅ Usa métodos corretos do database
5
- Processa reply_metadata do index.js
6
- ✅ Sistema emocional DistilBERT
7
- """
8
-
9
- import logging
10
- import re
11
- import random
12
- import time
13
- import json
14
- from typing import Optional, List, Dict, Tuple, Any
15
- from collections import deque
16
-
17
- logger = logging.getLogger(__name__)
18
-
19
- # Modelo de emoções
20
- try:
21
- from transformers import pipeline
22
- EMOTION_CLASSIFIER = pipeline(
23
- "text-classification",
24
- model="j-hartmann/emotion-english-distilroberta-base",
25
- top_k=3,
26
- device=-1,
27
- truncation=True
28
- )
29
- logger.info("✅ Modelo DistilBERT carregado")
30
- EMOTION_CACHE = {}
31
- except Exception as e:
32
- logger.warning(f"⚠️ DistilBERT não disponível: {e}")
33
- EMOTION_CLASSIFIER = None
34
- EMOTION_CACHE = {}
35
-
36
- # Mapeamento emoção → humor
37
- EMOTION_TO_HUMOR = {
38
- "joy": "feliz_ironica",
39
- "sadness": "triste_ironica",
40
- "anger": "irritada_ironica",
41
- "fear": "preocupada_ironica",
42
- "surprise": "curiosa_ironica",
43
- "disgust": "irritada_ironica",
44
- "neutral": "normal_ironico",
45
- "love": "romantico_carinhoso"
46
- }
47
-
48
- class MemoriaEmocional:
49
- def __init__(self, max_size=50):
50
- self.historico = deque(maxlen=max_size)
51
- self.tendencia_emocional = "neutral"
52
- self.volatilidade = 0.5
53
-
54
- def adicionar_interacao(self, mensagem: str, emocao: str, confianca: float):
55
- self.historico.append({
56
- "mensagem": mensagem[:100],
57
- "emocao": emocao,
58
- "confianca": confianca,
59
- "timestamp": time.time()
60
- })
61
- self._atualizar_tendencia()
62
-
63
- def _atualizar_tendencia(self):
64
- if not self.historico:
65
- return
66
- recentes = list(self.historico)[-10:]
67
- contagem = {}
68
- for entry in recentes:
69
- emocao = entry["emocao"]
70
- contagem[emocao] = contagem.get(emocao, 0) + entry["confianca"]
71
- if contagem:
72
- self.tendencia_emocional = max(contagem, key=contagem.get)
73
-
74
- class Contexto:
75
- def __init__(self, db: Any, usuario: str = "anonimo"):
76
- self.db = db
77
- self.usuario = usuario
78
-
79
- # Estado
80
- self.humor_atual = "normal_ironico"
81
- self.modo_resposta_atual = "normal_ironico"
82
- self.memoria_emocional = MemoriaEmocional(max_size=50)
83
-
84
- # Transição
85
- self.nivel_transicao = 0
86
- self.humor_alvo = "normal_ironico"
87
- self.ultima_transicao = time.time()
88
-
89
- # Conversa
90
- self.ultima_mensagem_akira = None
91
- self.tipo_conversa = "pv"
92
- self.is_grupo = False
93
-
94
- # Usuário
95
- self.numero_usuario = ""
96
- self.nome_usuario = "Anônimo"
97
- self.grupo_id = ""
98
- self.grupo_nome = ""
99
-
100
- # Histórico
101
- self.historico_mensagens = []
102
-
103
- self._carregar_estado_inicial()
104
- logger.info(f"✅ Contexto inicializado: {self.usuario}")
105
-
106
- def _carregar_estado_inicial(self):
107
- """Carrega estado do banco"""
108
- try:
109
- if hasattr(self.db, 'recuperar_humor_atual'):
110
- self.humor_atual = self.db.recuperar_humor_atual(self.usuario)
111
-
112
- if hasattr(self.db, 'recuperar_modo_resposta'):
113
- self.modo_resposta_atual = self.db.recuperar_modo_resposta(self.usuario)
114
-
115
- if hasattr(self.db, 'recuperar_mensagens'):
116
- try:
117
- mensagens_db = self.db.recuperar_mensagens(self.usuario, limite=10)
118
- for msg in mensagens_db:
119
- if isinstance(msg, tuple) and len(msg) >= 2:
120
- if msg[0]: # mensagem
121
- self.historico_mensagens.append({
122
- "role": "user",
123
- "content": msg[0],
124
- "timestamp": msg[7] if len(msg) > 7 else time.time()
125
- })
126
- if len(msg) > 1 and msg[1]: # resposta
127
- self.historico_mensagens.append({
128
- "role": "assistant",
129
- "content": msg[1],
130
- "timestamp": msg[7] if len(msg) > 7 else time.time()
131
- })
132
- except Exception as e:
133
- logger.warning(f"Falha ao carregar histórico: {e}")
134
-
135
- self.historico_mensagens.sort(key=lambda x: x.get('timestamp', 0))
136
-
137
- except Exception as e:
138
- logger.warning(f"Erro ao carregar estado: {e}")
139
-
140
- def detectar_emocao_avancada(self, mensagem: str) -> Tuple[str, float, Dict]:
141
- """Detecta emoção usando DistilBERT"""
142
- mensagem_limpa = mensagem.strip()
143
- cache_key = mensagem_limpa[:100].lower()
144
-
145
- if cache_key in EMOTION_CACHE:
146
- return EMOTION_CACHE[cache_key]
147
-
148
- if not EMOTION_CLASSIFIER:
149
- return self._detectar_emocao_fallback(mensagem_limpa)
150
-
151
- try:
152
- resultados = EMOTION_CLASSIFIER(mensagem_limpa[:256], truncation=True)
153
-
154
- emocao_primaria = resultados[0][0]['label']
155
- confianca_primaria = resultados[0][0]['score']
156
-
157
- detalhes = {
158
- "primaria": {"emocao": emocao_primaria, "confianca": confianca_primaria},
159
- "polaridade": "positiva" if emocao_primaria in ["joy", "love"] else "negativa" if emocao_primaria in ["anger", "sadness"] else "neutra"
160
- }
161
-
162
- self.memoria_emocional.adicionar_interacao(mensagem_limpa, emocao_primaria, confianca_primaria)
163
-
164
- resultado = (emocao_primaria, confianca_primaria, detalhes)
165
- EMOTION_CACHE[cache_key] = resultado
166
-
167
- return resultado
168
-
169
- except Exception as e:
170
- logger.warning(f"Erro no DistilBERT: {e}")
171
- return self._detectar_emocao_fallback(mensagem_limpa)
172
-
173
- def _detectar_emocao_fallback(self, mensagem: str) -> Tuple[str, float, Dict]:
174
- """Fallback para detecção de emoção"""
175
- mensagem_lower = mensagem.lower()
176
- positivas = ['bom', 'ótimo', 'feliz', 'adorei']
177
- negativas = ['ruim', 'péssimo', 'triste', 'raiva']
178
-
179
- pos = sum(1 for p in positivas if p in mensagem_lower)
180
- neg = sum(1 for n in negativas if n in mensagem_lower)
181
-
182
- if pos > neg and pos >= 2:
183
- return ("joy", 0.7, {"primaria": {"emocao": "joy", "confianca": 0.7}})
184
- elif neg > pos and neg >= 2:
185
- return ("anger", 0.7, {"primaria": {"emocao": "anger", "confianca": 0.7}})
186
- else:
187
- return ("neutral", 0.5, {"primaria": {"emocao": "neutral", "confianca": 0.5}})
188
-
189
- def atualizar_humor_gradual(self, emocao: str, confianca: float, tom_usuario: str,
190
- usuario_privilegiado: bool = False) -> str:
191
- """Atualiza humor gradualmente"""
192
- humor_anterior = self.humor_atual
193
-
194
- # Sugere humor
195
- humor_sugerido = EMOTION_TO_HUMOR.get(emocao, "normal_ironico")
196
-
197
- if usuario_privilegiado and tom_usuario == "formal":
198
- humor_sugerido = "tecnico_formal"
199
-
200
- # Inicia transição
201
- if self.humor_alvo != humor_sugerido:
202
- self.humor_alvo = humor_sugerido
203
- self.nivel_transicao = 0
204
-
205
- # Transição
206
- taxa = 0.5
207
- if confianca > 0.8:
208
- taxa += 0.3
209
- if tom_usuario == "rude":
210
- taxa += 0.4
211
-
212
- self.nivel_transicao = min(3, self.nivel_transicao + taxa)
213
-
214
- # Novo humor
215
- if self.nivel_transicao >= 3:
216
- novo_humor = self.humor_alvo
217
- else:
218
- novo_humor = self.humor_atual
219
-
220
- # Salva transição se mudou
221
- if novo_humor != humor_anterior and hasattr(self.db, 'salvar_transicao_humor'):
222
- try:
223
- self.db.salvar_transicao_humor(
224
- self.usuario,
225
- humor_anterior,
226
- novo_humor,
227
- emocao,
228
- confianca,
229
- self.nivel_transicao,
230
- f"Emoção: {emocao} ({confianca:.2f})"
231
- )
232
- except Exception as e:
233
- logger.warning(f"Erro ao salvar transição: {e}")
234
-
235
- self.humor_atual = novo_humor
236
- return novo_humor
237
-
238
- def detectar_tom_usuario(self, mensagem: str) -> Tuple[str, float]:
239
- """Detecta tom do usuário"""
240
- mensagem_lower = mensagem.lower()
241
-
242
- # Formal
243
- if any(x in mensagem_lower for x in ["senhor", "doutor", "por favor"]):
244
- return ("formal", 0.8)
245
-
246
- # Rude
247
- rudes = ['burro', 'idiota', 'merda', 'caralho']
248
- if any(x in mensagem_lower for x in rudes):
249
- return ("rude", 0.9)
250
-
251
- # Informal
252
- if any(x in mensagem_lower for x in ['puto', 'mano', 'fixe']):
253
- return ("informal", 0.7)
254
-
255
- return ("neutro", 0.5)
256
-
257
- def detectar_modo_resposta(self, mensagem: str, tom_usuario: str,
258
- usuario_privilegiado: bool = False) -> str:
259
- """Detecta modo de resposta"""
260
- mensagem_lower = mensagem.lower()
261
-
262
- if usuario_privilegiado and tom_usuario == "formal":
263
- return "tecnico_formal"
264
-
265
- if tom_usuario == "rude":
266
- return "agressivo_direto"
267
-
268
- if '?' in mensagem and len(mensagem) > 100:
269
- return "filosofico_ironico"
270
-
271
- palavras_romanticas = ['amor', 'paixão', 'gosto de ti']
272
- if any(p in mensagem_lower for p in palavras_romanticas):
273
- return "romantico_carinhoso"
274
-
275
- return "normal_ironico"
276
-
277
- def analisar_intencao_e_normalizar(self, mensagem: str, historico: List[Dict] = None,
278
- mensagem_citada: str = None,
279
- reply_metadata: Dict = None) -> Dict[str, Any]:
280
- """Análise principal - COMPATÍVEL COM INDEX.JS"""
281
- if not isinstance(mensagem, str):
282
- mensagem = str(mensagem)
283
-
284
- if historico is None:
285
- historico = self.obter_historico_para_llm()
286
-
287
- # Verifica privilégio
288
- usuario_privilegiado = False
289
- if self.numero_usuario and hasattr(self.db, 'is_usuario_privilegiado'):
290
- try:
291
- usuario_privilegiado = self.db.is_usuario_privilegiado(self.numero_usuario)
292
- except:
293
- pass
294
-
295
- # Detecta emoção
296
- emocao, confianca, detalhes_emocao = self.detectar_emocao_avancada(mensagem)
297
-
298
- # Detecta tom
299
- tom_usuario, intensidade_tom = self.detectar_tom_usuario(mensagem)
300
-
301
- # Atualiza humor
302
- humor_atualizado = self.atualizar_humor_gradual(
303
- emocao, confianca, tom_usuario, usuario_privilegiado
304
- )
305
-
306
- # Detecta modo
307
- modo_resposta = self.detectar_modo_resposta(mensagem, tom_usuario, usuario_privilegiado)
308
- self.modo_resposta_atual = modo_resposta
309
-
310
- # Analisa reply
311
- reply_analysis = self._analisar_reply_context(mensagem_citada, reply_metadata)
312
-
313
- # Resultado
314
- resultado = {
315
- "tom_usuario": tom_usuario,
316
- "tom_intensidade": intensidade_tom,
317
- "emocao_primaria": emocao,
318
- "confianca_emocao": confianca,
319
- "detalhes_emocao": detalhes_emocao,
320
- "modo_resposta": modo_resposta,
321
- "humor_atualizado": humor_atualizado,
322
- "nivel_transicao": self.nivel_transicao,
323
- "humor_alvo": self.humor_alvo,
324
- "usuario_privilegiado": usuario_privilegiado,
325
- "nome_usuario": self.nome_usuario,
326
- "numero_usuario": self.numero_usuario,
327
- "eh_resposta": reply_analysis.get("is_reply", False),
328
- "eh_resposta_ao_bot": reply_analysis.get("reply_to_bot", False),
329
- "mensagem_citada_limpa": mensagem_citada or "",
330
- "reply_analysis": reply_analysis,
331
- "reply_metadata": reply_metadata,
332
- "tipo_conversa": self.tipo_conversa,
333
- "is_grupo": self.is_grupo,
334
- "tendencia_emocional": self.memoria_emocional.tendencia_emocional,
335
- "volatilidade_usuario": self.memoria_emocional.volatilidade
336
- }
337
-
338
- return resultado
339
-
340
- def _analisar_reply_context(self, mensagem_citada: str, reply_metadata: Dict) -> Dict[str, Any]:
341
- """Analisa contexto de reply"""
342
- if reply_metadata:
343
- return {
344
- "is_reply": reply_metadata.get('is_reply', False),
345
- "reply_to_bot": reply_metadata.get('reply_to_bot', False),
346
- "quoted_author_name": reply_metadata.get('quoted_author_name', ''),
347
- "texto_citado_completo": reply_metadata.get('texto_mensagem_citada', ''),
348
- "context_hint": reply_metadata.get('context_hint', ''),
349
- "source": "reply_metadata"
350
- }
351
-
352
- if mensagem_citada:
353
- reply_to_bot = "AKIRA" in mensagem_citada.upper()
354
- return {
355
- "is_reply": True,
356
- "reply_to_bot": reply_to_bot,
357
- "quoted_author_name": "Akira" if reply_to_bot else "desconhecido",
358
- "texto_citado_completo": mensagem_citada,
359
- "context_hint": f"Citando {'Akira' if reply_to_bot else 'outra pessoa'}",
360
- "source": "mensagem_citada"
361
- }
362
-
363
- return {
364
- "is_reply": False,
365
- "reply_to_bot": False,
366
- "quoted_author_name": "",
367
- "texto_citado_completo": "",
368
- "context_hint": "",
369
- "source": "nenhum"
370
- }
371
-
372
- def obter_historico_para_llm(self) -> List[Dict]:
373
- """Retorna histórico formatado"""
374
- return [
375
- {"role": msg["role"], "content": msg["content"][:500]}
376
- for msg in self.historico_mensagens[-10:]
377
- ]
378
-
379
- def atualizar_contexto(self, mensagem: str, resposta: str, numero: str,
380
- is_reply: bool = False, mensagem_original: str = None,
381
- reply_to_bot: bool = False):
382
- """Atualiza contexto após interação"""
383
- try:
384
- timestamp = time.time()
385
-
386
- # Adiciona ao histórico
387
- self.historico_mensagens.append({
388
- "role": "user",
389
- "content": mensagem,
390
- "timestamp": timestamp,
391
- "is_reply": is_reply,
392
- "reply_to_bot": reply_to_bot
393
- })
394
- self.historico_mensagens.append({
395
- "role": "assistant",
396
- "content": resposta,
397
- "timestamp": timestamp
398
- })
399
-
400
- # Limita
401
- if len(self.historico_mensagens) > 20:
402
- self.historico_mensagens = self.historico_mensagens[-20:]
403
-
404
- self.ultima_mensagem_akira = resposta
405
-
406
- # Salva no banco
407
- if hasattr(self.db, 'salvar_mensagem'):
408
- try:
409
- self.db.salvar_mensagem(
410
- usuario=self.nome_usuario,
411
- mensagem=mensagem,
412
- resposta=resposta,
413
- numero=numero,
414
- is_reply=is_reply,
415
- mensagem_original=mensagem_original or '',
416
- reply_to_bot=reply_to_bot,
417
- humor=self.humor_atual,
418
- modo_resposta=self.modo_resposta_atual,
419
- usuario_nome=self.nome_usuario,
420
- tipo_conversa=self.tipo_conversa
421
- )
422
- except Exception as e:
423
- logger.warning(f"Erro ao salvar mensagem: {e}")
424
-
425
- except Exception as e:
426
- logger.error(f"Erro ao atualizar contexto: {e}")
427
-
428
- def atualizar_informacoes_usuario(self, nome: str, numero: str,
429
- grupo_id: str = "", grupo_nome: str = "",
430
- tipo_conversa: str = "pv"):
431
- """Atualiza informações do usuário"""
432
- self.nome_usuario = nome or self.nome_usuario
433
- self.numero_usuario = numero or self.numero_usuario
434
- self.grupo_id = grupo_id or self.grupo_id
435
- self.grupo_nome = grupo_nome or self.grupo_nome
436
- self.tipo_conversa = tipo_conversa
437
- self.is_grupo = tipo_conversa == "grupo"
438
-
439
- def criar_contexto(db: Any, identificador: str, tipo: str = "pv") -> Contexto:
440
- """Cria contexto isolado"""
441
- try:
442
- if tipo == "grupo":
443
- usuario_id = f"grupo_{identificador}"
444
- else:
445
- usuario_id = f"pv_{identificador}"
446
-
447
- contexto = Contexto(db, usuario_id)
448
- contexto.tipo_conversa = tipo
449
- contexto.is_grupo = (tipo == "grupo")
450
-
451
- return contexto
452
- except Exception as e:
453
- logger.error(f"Erro ao criar contexto: {e}")
454
- return Contexto(db, "fallback")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # modules/database.py — AKIRA V21 FINAL CORRIGIDO (Dezembro 2025) - CORREÇÃO: Com suporte a nivel_transicao, desc_transicao e usuario_privilegiado
2
- """
3
- TOTALMENTE ADAPTADO ao index.js atualizado
4
- ✅ CORREÇÃO: Problema com message_id UNIQUE resolvido
5
- CORREÇÃO: Suporte para nivel_transicao, desc_transicao e usuario_privilegiado adicionado
6
- Métodos corretos para api.py, contexto.py, treinamento.py
7
- ✅ Estrutura completa com reply_metadata
8
- ✅ Todos os métodos necessários implementados
9
- """
10
-
11
- import sqlite3
12
- import time
13
- import os
14
- import json
15
- import hashlib
16
- import random
17
- from datetime import datetime
18
- from typing import Optional, List, Dict, Any, Tuple
19
- from loguru import logger
20
-
21
- class Database:
22
- def __init__(self, db_path: str = "akira.db"):
23
- self.db_path = db_path
24
- self.max_retries = 5
25
- self.retry_delay = 0.1
26
-
27
- db_dir = os.path.dirname(self.db_path)
28
- if db_dir:
29
- os.makedirs(db_dir, exist_ok=True)
30
-
31
- self._init_db()
32
- self._ensure_columns()
33
- logger.info(f"✅ Database inicializado: {self.db_path}")
34
-
35
- def _get_connection(self) -> sqlite3.Connection:
36
- conn = sqlite3.connect(self.db_path, timeout=30.0, check_same_thread=False)
37
- conn.execute('PRAGMA journal_mode=WAL')
38
- conn.execute('PRAGMA synchronous=NORMAL')
39
- conn.execute('PRAGMA cache_size=2000')
40
- return conn
41
-
42
- def _execute_with_retry(self, query: str, params: Optional[tuple] = None,
43
- commit: bool = False, fetch: bool = True):
44
- for attempt in range(self.max_retries):
45
- try:
46
- with self._get_connection() as conn:
47
- c = conn.cursor()
48
- if params:
49
- c.execute(query, params)
50
- else:
51
- c.execute(query)
52
-
53
- if commit:
54
- conn.commit()
55
-
56
- if fetch and query.strip().upper().startswith('SELECT'):
57
- return c.fetchall()
58
- elif fetch:
59
- return c.fetchall() if c.description else []
60
- else:
61
- return c.lastrowid
62
- except sqlite3.OperationalError as e:
63
- if "database is locked" in str(e) and attempt < self.max_retries - 1:
64
- time.sleep(self.retry_delay * (2 ** attempt))
65
- continue
66
- logger.error(f"Erro SQL: {e}")
67
- raise
68
- except Exception as e:
69
- logger.error(f"Erro na query: {e}")
70
- raise
71
-
72
- def _init_db(self):
73
- """Cria todas as tabelas necessárias - CORREÇÃO: message_id sem UNIQUE"""
74
- try:
75
- with self._get_connection() as conn:
76
- c = conn.cursor()
77
-
78
- # Tabela principal de mensagens - CORREÇÃO: message_id sem UNIQUE
79
- c.execute('''
80
- CREATE TABLE IF NOT EXISTS mensagens (
81
- id INTEGER PRIMARY KEY AUTOINCREMENT,
82
- usuario TEXT NOT NULL,
83
- usuario_nome TEXT DEFAULT '',
84
- numero TEXT NOT NULL,
85
- mensagem TEXT NOT NULL,
86
- resposta TEXT NOT NULL,
87
- contexto_id TEXT NOT NULL,
88
- tipo_contexto TEXT DEFAULT 'pv',
89
- tipo_conversa TEXT DEFAULT 'pv',
90
- tipo_mensagem TEXT DEFAULT 'texto',
91
-
92
- -- Reply info
93
- is_reply BOOLEAN DEFAULT 0,
94
- mensagem_original TEXT,
95
- mensagem_citada_limpa TEXT,
96
- reply_to_bot BOOLEAN DEFAULT 0,
97
- reply_info_json TEXT,
98
-
99
- -- Estado
100
- humor TEXT DEFAULT 'normal_ironico',
101
- modo_resposta TEXT DEFAULT 'normal_ironico',
102
- emocao_detectada TEXT,
103
- confianca_emocao REAL DEFAULT 0.5,
104
-
105
- -- Transição
106
- nivel_transicao INTEGER DEFAULT 0,
107
- info_transicao_json TEXT,
108
- usuario_privilegiado BOOLEAN DEFAULT 0,
109
-
110
- -- Grupo
111
- grupo_id TEXT DEFAULT '',
112
- grupo_nome TEXT DEFAULT '',
113
-
114
- -- Audio
115
- audio_transcricao TEXT,
116
- fonte_stt TEXT DEFAULT 'deepgram',
117
- confianca_stt REAL DEFAULT 0.0,
118
-
119
- -- Meta
120
- comando_executado TEXT,
121
- has_media BOOLEAN DEFAULT 0,
122
- media_type TEXT DEFAULT '',
123
- message_id TEXT,
124
- bot_response_time_ms INTEGER DEFAULT 0,
125
- is_mention BOOLEAN DEFAULT 0,
126
- timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
127
- deletado BOOLEAN DEFAULT 0
128
- )
129
- ''')
130
-
131
- # Índice para message_id para performance (sem UNIQUE)
132
- c.execute('''
133
- CREATE INDEX IF NOT EXISTS idx_mensagens_message_id
134
- ON mensagens(message_id)
135
- ''')
136
-
137
- # Índice para busca por número
138
- c.execute('''
139
- CREATE INDEX IF NOT EXISTS idx_mensagens_numero
140
- ON mensagens(numero)
141
- ''')
142
-
143
- # Índice para usuario_privilegiado
144
- c.execute('''
145
- CREATE INDEX IF NOT EXISTS idx_mensagens_usuario_privilegiado
146
- ON mensagens(usuario_privilegiado)
147
- ''')
148
-
149
- # Usuários privilegiados
150
- c.execute('''
151
- CREATE TABLE IF NOT EXISTS usuarios_privilegiados (
152
- id INTEGER PRIMARY KEY AUTOINCREMENT,
153
- numero TEXT UNIQUE NOT NULL,
154
- nome TEXT NOT NULL,
155
- nome_curto TEXT,
156
- tom_inicial TEXT DEFAULT 'formal',
157
- pode_dar_ordens BOOLEAN DEFAULT 0,
158
- pode_usar_reset BOOLEAN DEFAULT 0,
159
- pode_forcar_modo BOOLEAN DEFAULT 0,
160
- pode_apagar_mensagens BOOLEAN DEFAULT 0,
161
- pode_moderar_grupos BOOLEAN DEFAULT 0,
162
- nivel_acesso TEXT DEFAULT 'vip',
163
- ultimo_comando TEXT,
164
- timestamp_comando DATETIME,
165
- comandos_executados INTEGER DEFAULT 0,
166
- comandos_falhos INTEGER DEFAULT 0,
167
- config_personalizada TEXT DEFAULT '{}',
168
- data_criacao DATETIME DEFAULT CURRENT_TIMESTAMP,
169
- data_atualizacao DATETIME DEFAULT CURRENT_TIMESTAMP
170
- )
171
- ''')
172
-
173
- # Contexto
174
- c.execute('''
175
- CREATE TABLE IF NOT EXISTS contexto (
176
- id INTEGER PRIMARY KEY AUTOINCREMENT,
177
- numero TEXT UNIQUE NOT NULL,
178
- contexto_id TEXT NOT NULL,
179
- tipo_contexto TEXT DEFAULT 'pv',
180
- historico TEXT,
181
- humor_atual TEXT DEFAULT 'normal_ironico',
182
- modo_resposta TEXT DEFAULT 'normal_ironico',
183
- nivel_transicao INTEGER DEFAULT 0,
184
- info_transicao_json TEXT,
185
- usuario_privilegiado BOOLEAN DEFAULT 0,
186
- humor_alvo TEXT DEFAULT 'normal_ironico',
187
- termos TEXT,
188
- girias TEXT,
189
- tom TEXT DEFAULT 'normal',
190
- emocao_tendencia TEXT DEFAULT 'neutral',
191
- volatilidade REAL DEFAULT 0.5,
192
- nome_usuario TEXT DEFAULT '',
193
- ultima_mensagem_audio BOOLEAN DEFAULT 0,
194
- frequencia_audio INTEGER DEFAULT 0,
195
- prefere_audio BOOLEAN DEFAULT 0,
196
- nivel_confianca_stt REAL DEFAULT 0.0,
197
- configuracao_reply TEXT DEFAULT '{}',
198
- estatisticas_interacao TEXT DEFAULT '{}',
199
- ultimo_contato DATETIME,
200
- data_criacao DATETIME DEFAULT CURRENT_TIMESTAMP,
201
- data_atualizacao DATETIME DEFAULT CURRENT_TIMESTAMP
202
- )
203
- ''')
204
-
205
- # Training examples
206
- c.execute('''
207
- CREATE TABLE IF NOT EXISTS training_examples (
208
- id INTEGER PRIMARY KEY AUTOINCREMENT,
209
- input_text TEXT NOT NULL,
210
- output_text TEXT NOT NULL,
211
- humor TEXT DEFAULT 'normal_ironico',
212
- modo_resposta TEXT DEFAULT 'normal_ironico',
213
- nivel_transicao INTEGER DEFAULT 0,
214
- usuario_privilegiado BOOLEAN DEFAULT 0,
215
- emocao_contexto TEXT,
216
- contexto_super_claro TEXT,
217
- tipo_interacao TEXT DEFAULT 'normal',
218
- score_relevancia REAL DEFAULT 1.0,
219
- tags TEXT DEFAULT '',
220
- qualidade_score REAL DEFAULT 1.0,
221
- usado BOOLEAN DEFAULT 0,
222
- usado_para_finetuning BOOLEAN DEFAULT 0,
223
- timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
224
- )
225
- ''')
226
-
227
- # Transições de humor
228
- c.execute('''
229
- CREATE TABLE IF NOT EXISTS transicoes_humor (
230
- id INTEGER PRIMARY KEY AUTOINCREMENT,
231
- numero TEXT NOT NULL,
232
- contexto_id TEXT NOT NULL,
233
- humor_anterior TEXT NOT NULL,
234
- humor_novo TEXT NOT NULL,
235
- nivel_transicao_anterior INTEGER DEFAULT 0,
236
- nivel_transicao_novo INTEGER DEFAULT 0,
237
- usuario_privilegiado BOOLEAN DEFAULT 0,
238
- emocao_trigger TEXT,
239
- confianca_emocao REAL,
240
- razao TEXT,
241
- intensidade REAL DEFAULT 0.5,
242
- contexto_mensagem TEXT,
243
- timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
244
- )
245
- ''')
246
-
247
- # Gírias
248
- c.execute('''
249
- CREATE TABLE IF NOT EXISTS girias_aprendidas (
250
- id INTEGER PRIMARY KEY AUTOINCREMENT,
251
- numero TEXT NOT NULL,
252
- contexto_id TEXT NOT NULL,
253
- giria TEXT NOT NULL,
254
- significado TEXT NOT NULL,
255
- contexto TEXT,
256
- frequencia INTEGER DEFAULT 1,
257
- ultimo_uso DATETIME DEFAULT CURRENT_TIMESTAMP,
258
- data_criacao DATETIME DEFAULT CURRENT_TIMESTAMP,
259
- UNIQUE(numero, giria)
260
- )
261
- ''')
262
-
263
- # Comandos executados
264
- c.execute('''
265
- CREATE TABLE IF NOT EXISTS comandos_executados (
266
- id INTEGER PRIMARY KEY AUTOINCREMENT,
267
- numero TEXT NOT NULL,
268
- comando TEXT NOT NULL,
269
- parametros TEXT,
270
- sucesso BOOLEAN DEFAULT 1,
271
- resposta TEXT,
272
- tipo_conversa TEXT DEFAULT 'pv',
273
- grupo_id TEXT DEFAULT '',
274
- timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
275
- )
276
- ''')
277
-
278
- # Reset log
279
- c.execute('''
280
- CREATE TABLE IF NOT EXISTS reset_log (
281
- id INTEGER PRIMARY KEY AUTOINCREMENT,
282
- numero TEXT NOT NULL,
283
- tipo_reset TEXT NOT NULL,
284
- itens_apagados INTEGER DEFAULT 0,
285
- motivo TEXT,
286
- sucesso BOOLEAN DEFAULT 1,
287
- timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
288
- )
289
- ''')
290
-
291
- # Interações (para treinamento)
292
- c.execute('''
293
- CREATE TABLE IF NOT EXISTS interacoes (
294
- id INTEGER PRIMARY KEY AUTOINCREMENT,
295
- numero TEXT NOT NULL,
296
- mensagem TEXT NOT NULL,
297
- resposta TEXT NOT NULL,
298
- humor TEXT DEFAULT 'normal_ironico',
299
- modo_resposta TEXT DEFAULT 'normal_ironico',
300
- nivel_transicao INTEGER DEFAULT 0,
301
- usuario_privilegiado BOOLEAN DEFAULT 0,
302
- emocao_detectada TEXT,
303
- tipo_conversa TEXT DEFAULT 'pv',
304
- reply_info_json TEXT,
305
- qualidade_score REAL DEFAULT 1.0,
306
- data_criacao DATETIME DEFAULT CURRENT_TIMESTAMP
307
- )
308
- ''')
309
-
310
- conn.commit()
311
- logger.info("✅ Tabelas criadas/verificadas com suporte a nivel_transicao e usuario_privilegiado")
312
-
313
- except Exception as e:
314
- logger.error(f" Erro ao criar tabelas: {e}")
315
- raise
316
-
317
- def _ensure_columns(self):
318
- """Garante que todas as colunas existam"""
319
- try:
320
- with self._get_connection() as conn:
321
- c = conn.cursor()
322
-
323
- # Colunas para mensagens
324
- novas_colunas = [
325
- ("tipo_mensagem", "TEXT DEFAULT 'texto'"),
326
- ("reply_info_json", "TEXT"),
327
- ("usuario_nome", "TEXT DEFAULT ''"),
328
- ("grupo_id", "TEXT DEFAULT ''"),
329
- ("grupo_nome", "TEXT DEFAULT ''"),
330
- ("audio_transcricao", "TEXT"),
331
- ("fonte_stt", "TEXT DEFAULT 'deepgram'"),
332
- ("confianca_stt", "REAL DEFAULT 0.0"),
333
- ("comando_executado", "TEXT"),
334
- ("tipo_conversa", "TEXT DEFAULT 'pv'"),
335
- ("is_mention", "BOOLEAN DEFAULT 0"),
336
- ("has_media", "BOOLEAN DEFAULT 0"),
337
- ("media_type", "TEXT DEFAULT ''"),
338
- ("message_id", "TEXT"),
339
- ("bot_response_time_ms", "INTEGER DEFAULT 0"),
340
- ("nivel_transicao", "INTEGER DEFAULT 0"),
341
- ("info_transicao_json", "TEXT"),
342
- ("usuario_privilegiado", "BOOLEAN DEFAULT 0")
343
- ]
344
-
345
- for col_name, col_def in novas_colunas:
346
- try:
347
- c.execute(f"ALTER TABLE mensagens ADD COLUMN {col_name} {col_def}")
348
- except sqlite3.OperationalError:
349
- pass
350
-
351
- # Colunas para contexto
352
- contexto_colunas = [
353
- ("info_transicao_json", "TEXT"),
354
- ("nivel_transicao", "INTEGER DEFAULT 0"),
355
- ("usuario_privilegiado", "BOOLEAN DEFAULT 0")
356
- ]
357
-
358
- for col_name, col_def in contexto_colunas:
359
- try:
360
- c.execute(f"ALTER TABLE contexto ADD COLUMN {col_name} {col_def}")
361
- except sqlite3.OperationalError:
362
- pass
363
-
364
- # Colunas para transições
365
- transicoes_colunas = [
366
- ("nivel_transicao_anterior", "INTEGER DEFAULT 0"),
367
- ("nivel_transicao_novo", "INTEGER DEFAULT 0"),
368
- ("usuario_privilegiado", "BOOLEAN DEFAULT 0")
369
- ]
370
-
371
- for col_name, col_def in transicoes_colunas:
372
- try:
373
- c.execute(f"ALTER TABLE transicoes_humor ADD COLUMN {col_name} {col_def}")
374
- except sqlite3.OperationalError:
375
- pass
376
-
377
- # Colunas para training_examples
378
- training_colunas = [
379
- ("nivel_transicao", "INTEGER DEFAULT 0"),
380
- ("usuario_privilegiado", "BOOLEAN DEFAULT 0")
381
- ]
382
-
383
- for col_name, col_def in training_colunas:
384
- try:
385
- c.execute(f"ALTER TABLE training_examples ADD COLUMN {col_name} {col_def}")
386
- except sqlite3.OperationalError:
387
- pass
388
-
389
- # Colunas para interacoes
390
- interacoes_colunas = [
391
- ("usuario_privilegiado", "BOOLEAN DEFAULT 0")
392
- ]
393
-
394
- for col_name, col_def in interacoes_colunas:
395
- try:
396
- c.execute(f"ALTER TABLE interacoes ADD COLUMN {col_name} {col_def}")
397
- except sqlite3.OperationalError:
398
- pass
399
-
400
- conn.commit()
401
-
402
- except Exception as e:
403
- logger.warning(f"⚠️ Erro ao verificar colunas: {e}")
404
-
405
- # ========================================================================
406
- # MÉTODOS DE SALVAMENTO (ADAPTADOS AO INDEX.JS) - CORRIGIDOS COM TODOS PARÂMETROS
407
- # ========================================================================
408
-
409
- def salvar_mensagem(self, usuario: str, mensagem: str, resposta: str,
410
- numero: str = '', is_reply: bool = False,
411
- mensagem_original: str = None,
412
- mensagem_citada_limpa: str = None,
413
- reply_to_bot: bool = False,
414
- humor: str = 'normal_ironico',
415
- modo_resposta: str = 'normal_ironico',
416
- emocao_detectada: str = None,
417
- confianca_emocao: float = 0.5,
418
- nivel_transicao: int = 0,
419
- info_transicao: dict = None,
420
- desc_transicao: str = None,
421
- usuario_privilegiado: bool = False, # NOVO PARÂMETRO ADICIONADO
422
- tipo_mensagem: str = 'texto',
423
- reply_info_json: str = None,
424
- usuario_nome: str = '',
425
- grupo_id: str = '',
426
- grupo_nome: str = '',
427
- tipo_conversa: str = 'pv',
428
- audio_transcricao: str = None,
429
- fonte_stt: str = 'deepgram',
430
- confianca_stt: float = 0.0,
431
- comando_executado: str = None,
432
- has_media: bool = False,
433
- media_type: str = '',
434
- message_id: str = None,
435
- bot_response_time_ms: int = 0,
436
- is_mention: bool = False) -> bool:
437
- """Salva mensagem no banco - COM SUPORTE A TODOS PARÂMETROS"""
438
- try:
439
- numero_final = str(numero or usuario).strip()
440
- contexto_id = self._gerar_contexto_id(numero_final, tipo_conversa)
441
-
442
- # Converte reply_info para JSON se for dict
443
- if isinstance(reply_info_json, dict):
444
- reply_info_json = json.dumps(reply_info_json, ensure_ascii=False)
445
-
446
- # Converte info_transicao para JSON se for dict
447
- info_transicao_json = None
448
- if info_transicao:
449
- info_transicao_json = json.dumps(info_transicao, ensure_ascii=False)
450
-
451
- # Se desc_transicao for fornecida, adiciona ao info_transicao
452
- if desc_transicao:
453
- if info_transicao_json:
454
- try:
455
- info_transicao_dict = json.loads(info_transicao_json) if isinstance(info_transicao_json, str) else info_transicao_json
456
- info_transicao_dict['desc_transicao'] = desc_transicao
457
- info_transicao_json = json.dumps(info_transicao_dict, ensure_ascii=False)
458
- except:
459
- # Cria novo info_transicao se não conseguir parsear
460
- info_transicao_json = json.dumps({'desc_transicao': desc_transicao}, ensure_ascii=False)
461
- else:
462
- # Cria info_transicao com desc_transicao
463
- info_transicao_json = json.dumps({'desc_transicao': desc_transicao}, ensure_ascii=False)
464
-
465
- # Gera message_id único se não fornecido
466
- if not message_id:
467
- timestamp = int(time.time() * 1000)
468
- random_suffix = random.randint(1000, 9999)
469
- message_id = f"{numero_final}_{timestamp}_{random_suffix}"
470
-
471
- # Adiciona um sufixo aleatório extra para garantir unicidade
472
- unique_suffix = random.randint(100, 999)
473
- message_id = f"{message_id}_{unique_suffix}"
474
-
475
- try:
476
- self._execute_with_retry(
477
- """
478
- INSERT INTO mensagens
479
- (usuario, usuario_nome, mensagem, resposta, numero, contexto_id, tipo_contexto,
480
- tipo_conversa, tipo_mensagem, is_reply, mensagem_original, mensagem_citada_limpa,
481
- reply_to_bot, reply_info_json, humor, modo_resposta, emocao_detectada,
482
- confianca_emocao, nivel_transicao, info_transicao_json, usuario_privilegiado,
483
- grupo_id, grupo_nome, audio_transcricao, fonte_stt, confianca_stt, comando_executado,
484
- has_media, media_type, message_id, bot_response_time_ms, is_mention)
485
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
486
- """,
487
- (
488
- usuario[:50], usuario_nome[:100] or usuario[:100],
489
- mensagem[:4000], resposta[:4000], numero_final,
490
- contexto_id, tipo_conversa, tipo_conversa, tipo_mensagem,
491
- int(is_reply), mensagem_original, mensagem_citada_limpa,
492
- int(reply_to_bot), reply_info_json, humor, modo_resposta,
493
- emocao_detectada, confianca_emocao, nivel_transicao,
494
- info_transicao_json, int(usuario_privilegiado), # ADICIONADO
495
- grupo_id[:50], grupo_nome[:100],
496
- audio_transcricao[:2000] if audio_transcricao else None,
497
- fonte_stt[:50], confianca_stt, comando_executado[:100] if comando_executado else None,
498
- int(has_media), media_type[:50], message_id[:200],
499
- bot_response_time_ms, int(is_mention)
500
- ),
501
- commit=True,
502
- fetch=False
503
- )
504
-
505
- logger.debug(f"✅ Mensagem salva: {numero_final} | Nível: {nivel_transicao} | Privilegiado: {usuario_privilegiado}")
506
- return True
507
-
508
- except sqlite3.IntegrityError as e:
509
- # CORREÇÃO: Se ainda houver erro de UNIQUE (pode ser de outra coluna)
510
- if "UNIQUE constraint failed" in str(e):
511
- logger.warning(f"🔄 Erro de UNIQUE, gerando novo message_id")
512
- # Gera um novo message_id completamente diferente
513
- new_message_id = f"{numero_final}_{int(time.time() * 1000)}_{random.randint(10000, 99999)}"
514
-
515
- self._execute_with_retry(
516
- """
517
- INSERT INTO mensagens
518
- (usuario, usuario_nome, mensagem, resposta, numero, contexto_id, tipo_contexto,
519
- tipo_conversa, tipo_mensagem, is_reply, mensagem_original, mensagem_citada_limpa,
520
- reply_to_bot, reply_info_json, humor, modo_resposta, emocao_detectada,
521
- confianca_emocao, nivel_transicao, info_transicao_json, usuario_privilegiado,
522
- grupo_id, grupo_nome, audio_transcricao, fonte_stt, confianca_stt, comando_executado,
523
- has_media, media_type, message_id, bot_response_time_ms, is_mention)
524
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
525
- """,
526
- (
527
- usuario[:50], usuario_nome[:100] or usuario[:100],
528
- mensagem[:4000], resposta[:4000], numero_final,
529
- contexto_id, tipo_conversa, tipo_conversa, tipo_mensagem,
530
- int(is_reply), mensagem_original, mensagem_citada_limpa,
531
- int(reply_to_bot), reply_info_json, humor, modo_resposta,
532
- emocao_detectada, confianca_emocao, nivel_transicao,
533
- info_transicao_json, int(usuario_privilegiado), # ADICIONADO
534
- grupo_id[:50], grupo_nome[:100],
535
- audio_transcricao[:2000] if audio_transcricao else None,
536
- fonte_stt[:50], confianca_stt, comando_executado[:100] if comando_executado else None,
537
- int(has_media), media_type[:50], new_message_id[:200],
538
- bot_response_time_ms, int(is_mention)
539
- ),
540
- commit=True,
541
- fetch=False
542
- )
543
- logger.debug(f"✅ Mensagem salva com novo message_id: {new_message_id}")
544
- return True
545
- else:
546
- raise
547
-
548
- except Exception as e:
549
- logger.error(f"❌ Erro ao salvar mensagem: {e}")
550
- return False
551
-
552
- def salvar_training_example(self, input_text: str, output_text: str,
553
- humor: str = "normal_ironico",
554
- modo_resposta: str = "normal_ironico",
555
- nivel_transicao: int = 0,
556
- usuario_privilegiado: bool = False, # NOVO PARÂMETRO
557
- emocao_contexto: str = None,
558
- qualidade_score: float = 1.0,
559
- contexto_super_claro: Dict = None,
560
- tipo_interacao: str = "normal",
561
- score_relevancia: float = 1.0,
562
- tags: List[str] = None) -> bool:
563
- """Salva exemplo de treinamento - COM usuario_privilegiado"""
564
- try:
565
- contexto_json = json.dumps(contexto_super_claro, ensure_ascii=False) if contexto_super_claro else None
566
- tags_str = ",".join(tags) if tags else ""
567
-
568
- self._execute_with_retry(
569
- """
570
- INSERT INTO training_examples
571
- (input_text, output_text, humor, modo_resposta, nivel_transicao,
572
- usuario_privilegiado, emocao_contexto, qualidade_score, contexto_super_claro,
573
- tipo_interacao, score_relevancia, tags)
574
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
575
- """,
576
- (
577
- input_text[:2000], output_text[:2000], humor, modo_resposta,
578
- nivel_transicao, int(usuario_privilegiado), emocao_contexto,
579
- qualidade_score, contexto_json, tipo_interacao, score_relevancia,
580
- tags_str[:200]
581
- ),
582
- commit=True,
583
- fetch=False
584
- )
585
- logger.debug(f"✅ Training example salvo | Nível: {nivel_transicao} | Privilegiado: {usuario_privilegiado}")
586
- return True
587
- except Exception as e:
588
- logger.error(f"❌ Erro ao salvar training: {e}")
589
- return False
590
-
591
- def salvar_transicao_humor(self, numero: str, humor_anterior: str,
592
- humor_novo: str, nivel_transicao_anterior: int = 0,
593
- nivel_transicao_novo: int = 0,
594
- usuario_privilegiado: bool = False, # NOVO PARÂMETRO
595
- emocao_trigger: str = None,
596
- confianca_emocao: float = 0.5,
597
- razao: str = "", intensidade: float = 0.5,
598
- contexto_mensagem: str = None):
599
- """Salva transição de humor - COM usuario_privilegiado"""
600
- try:
601
- contexto_id = self._gerar_contexto_id(numero, 'auto')
602
-
603
- self._execute_with_retry(
604
- """
605
- INSERT INTO transicoes_humor
606
- (numero, contexto_id, humor_anterior, humor_novo,
607
- nivel_transicao_anterior, nivel_transicao_novo, usuario_privilegiado,
608
- emocao_trigger, confianca_emocao, razao, intensidade, contexto_mensagem)
609
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
610
- """,
611
- (
612
- str(numero).strip(), contexto_id, humor_anterior, humor_novo,
613
- nivel_transicao_anterior, nivel_transicao_novo, int(usuario_privilegiado),
614
- emocao_trigger, confianca_emocao, razao[:200], intensidade,
615
- contexto_mensagem[:500] if contexto_mensagem else None
616
- ),
617
- commit=True,
618
- fetch=False
619
- )
620
- logger.debug(f"🎭 Transição salva: {humor_anterior}→{humor_novo} | Nível: {nivel_transicao_anterior}→{nivel_transicao_novo} | Privilegiado: {usuario_privilegiado}")
621
- except Exception as e:
622
- logger.error(f"❌ Erro ao salvar transição: {e}")
623
-
624
- def salvar_giria(self, numero: str, giria: str, significado: str, contexto: str = ""):
625
- """Salva gíria aprendida"""
626
- try:
627
- numero_final = str(numero).strip()
628
- contexto_id = self._gerar_contexto_id(numero_final, 'auto')
629
-
630
- result = self._execute_with_retry(
631
- "SELECT id, frequencia FROM girias_aprendidas WHERE numero = ? AND giria = ?",
632
- (numero_final, giria),
633
- fetch=True
634
- )
635
-
636
- if result:
637
- self._execute_with_retry(
638
- """
639
- UPDATE girias_aprendidas
640
- SET frequencia = frequencia + 1,
641
- ultimo_uso = CURRENT_TIMESTAMP
642
- WHERE numero = ? AND giria = ?
643
- """,
644
- (numero_final, giria),
645
- commit=True,
646
- fetch=False
647
- )
648
- else:
649
- self._execute_with_retry(
650
- """
651
- INSERT INTO girias_aprendidas
652
- (numero, contexto_id, giria, significado, contexto)
653
- VALUES (?, ?, ?, ?, ?)
654
- """,
655
- (numero_final, contexto_id, giria, significado, contexto[:100]),
656
- commit=True,
657
- fetch=False
658
- )
659
- return True
660
- except Exception as e:
661
- logger.error(f"❌ Erro ao salvar gíria: {e}")
662
- return False
663
-
664
- # ========================================================================
665
- # MÉTODOS DE CONTEXTO - COM usuario_privilegiado
666
- # ========================================================================
667
-
668
- def atualizar_contexto(self, numero: str, humor_atual: str = None,
669
- modo_resposta: str = None, nivel_transicao: int = None,
670
- info_transicao: dict = None, usuario_privilegiado: bool = False,
671
- tom: str = None, emocao_tendencia: str = None) -> bool:
672
- """Atualiza contexto do usuário - COM usuario_privilegiado"""
673
- try:
674
- numero_final = str(numero).strip()
675
- contexto_id = self._gerar_contexto_id(numero_final, 'auto')
676
-
677
- # Verifica se contexto existe
678
- result = self._execute_with_retry(
679
- "SELECT 1 FROM contexto WHERE numero = ?",
680
- (numero_final,),
681
- fetch=True
682
- )
683
-
684
- info_transicao_json = None
685
- if info_transicao:
686
- info_transicao_json = json.dumps(info_transicao, ensure_ascii=False)
687
-
688
- if result:
689
- # Atualiza existente
690
- updates = []
691
- params = []
692
-
693
- if humor_atual:
694
- updates.append("humor_atual = ?")
695
- params.append(humor_atual)
696
- if modo_resposta:
697
- updates.append("modo_resposta = ?")
698
- params.append(modo_resposta)
699
- if nivel_transicao is not None:
700
- updates.append("nivel_transicao = ?")
701
- params.append(nivel_transicao)
702
- if info_transicao_json:
703
- updates.append("info_transicao_json = ?")
704
- params.append(info_transicao_json)
705
- updates.append("usuario_privilegiado = ?")
706
- params.append(int(usuario_privilegiado))
707
- if tom:
708
- updates.append("tom = ?")
709
- params.append(tom)
710
- if emocao_tendencia:
711
- updates.append("emocao_tendencia = ?")
712
- params.append(emocao_tendencia)
713
-
714
- updates.append("ultimo_contato = CURRENT_TIMESTAMP")
715
- updates.append("data_atualizacao = CURRENT_TIMESTAMP")
716
-
717
- if updates:
718
- query = f"UPDATE contexto SET {', '.join(updates)} WHERE numero = ?"
719
- params.append(numero_final)
720
- self._execute_with_retry(query, tuple(params), commit=True, fetch=False)
721
- else:
722
- # Cria novo contexto
723
- self._execute_with_retry(
724
- """
725
- INSERT INTO contexto
726
- (numero, contexto_id, humor_atual, modo_resposta, nivel_transicao,
727
- info_transicao_json, usuario_privilegiado, tom, emocao_tendencia, ultimo_contato)
728
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
729
- """,
730
- (
731
- numero_final, contexto_id,
732
- humor_atual or 'normal_ironico',
733
- modo_resposta or 'normal_ironico',
734
- nivel_transicao or 0,
735
- info_transicao_json,
736
- int(usuario_privilegiado),
737
- tom or 'normal',
738
- emocao_tendencia or 'neutral'
739
- ),
740
- commit=True,
741
- fetch=False
742
- )
743
-
744
- logger.debug(f"✅ Contexto atualizado: {numero_final} | Nível: {nivel_transicao} | Privilegiado: {usuario_privilegiado}")
745
- return True
746
-
747
- except Exception as e:
748
- logger.error(f" Erro ao atualizar contexto: {e}")
749
- return False
750
-
751
- def recuperar_contexto(self, numero: str) -> Dict[str, Any]:
752
- """Recupera contexto completo do usuário"""
753
- try:
754
- result = self._execute_with_retry(
755
- """
756
- SELECT humor_atual, modo_resposta, nivel_transicao, info_transicao_json,
757
- usuario_privilegiado, tom, emocao_tendencia, ultimo_contato
758
- FROM contexto WHERE numero = ?
759
- """,
760
- (str(numero).strip(),),
761
- fetch=True
762
- )
763
-
764
- if result:
765
- row = result[0]
766
- info_transicao = {}
767
- if row[3]:
768
- try:
769
- info_transicao = json.loads(row[3])
770
- except:
771
- pass
772
-
773
- return {
774
- "humor_atual": row[0] or "normal_ironico",
775
- "modo_resposta": row[1] or "normal_ironico",
776
- "nivel_transicao": row[2] or 0,
777
- "info_transicao": info_transicao,
778
- "usuario_privilegiado": bool(row[4]) if row[4] is not None else False,
779
- "tom": row[5] or "normal",
780
- "emocao_tendencia": row[6] or "neutral",
781
- "ultimo_contato": row[7]
782
- }
783
-
784
- return {
785
- "humor_atual": "normal_ironico",
786
- "modo_resposta": "normal_ironico",
787
- "nivel_transicao": 0,
788
- "info_transicao": {},
789
- "usuario_privilegiado": False,
790
- "tom": "normal",
791
- "emocao_tendencia": "neutral",
792
- "ultimo_contato": None
793
- }
794
-
795
- except Exception as e:
796
- logger.error(f"❌ Erro ao recuperar contexto: {e}")
797
- return {}
798
-
799
- # ========================================================================
800
- # MÉTODOS DE RECUPERAÇÃO
801
- # ========================================================================
802
-
803
- def recuperar_mensagens(self, numero: str, limite: int = 10) -> List[Tuple]:
804
- """Recupera mensagens do usuário"""
805
- try:
806
- results = self._execute_with_retry(
807
- """
808
- SELECT mensagem, resposta, is_reply, mensagem_original,
809
- reply_to_bot, humor, modo_resposta, nivel_transicao, usuario_privilegiado, timestamp
810
- FROM mensagens
811
- WHERE numero = ? AND deletado = 0
812
- ORDER BY timestamp DESC
813
- LIMIT ?
814
- """,
815
- (str(numero).strip(), limite),
816
- fetch=True
817
- )
818
-
819
- if results:
820
- return results[::-1] # Reverter para ordem cronológica
821
- return []
822
- except Exception as e:
823
- logger.error(f"❌ Erro ao recuperar mensagens: {e}")
824
- return []
825
-
826
- def recuperar_humor_atual(self, numero: str) -> str:
827
- """Recupera humor atual"""
828
- try:
829
- result = self._execute_with_retry(
830
- "SELECT humor_atual FROM contexto WHERE numero = ?",
831
- (str(numero).strip(),),
832
- fetch=True
833
- )
834
- return result[0][0] if result else "normal_ironico"
835
- except Exception:
836
- return "normal_ironico"
837
-
838
- def recuperar_modo_resposta(self, numero: str) -> str:
839
- """Recupera modo de resposta"""
840
- try:
841
- result = self._execute_with_retry(
842
- "SELECT modo_resposta FROM contexto WHERE numero = ?",
843
- (str(numero).strip(),),
844
- fetch=True
845
- )
846
- return result[0][0] if result else "normal_ironico"
847
- except Exception:
848
- return "normal_ironico"
849
-
850
- def recuperar_nivel_transicao(self, numero: str) -> int:
851
- """Recupera nível de transição"""
852
- try:
853
- result = self._execute_with_retry(
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
- # modules/treinamento.py — AKIRA V21 FINAL CORRIGIDO (Dezembro 2025)
2
- """
3
- TOTALMENTE COMPATÍVEL com database.py corrigido
4
- Processa reply_metadata do index.js
5
- Sistema de aprendizado completo
6
- Detecção de padrões de conversa
7
- Compatível com STT/TTS
8
- Otimizado para produção
9
- CORREÇÃO: Suporte para nivel_transicao adicionado
10
- """
11
-
12
- import json
13
- import os
14
- import time
15
- import threading
16
- import random
17
- import hashlib
18
- import re
19
- from typing import Optional, Dict, Any, List, Tuple
20
- from loguru import logger
21
- from .database import Database
22
-
23
- # ============================================================================
24
- # 🔥 CONFIGURAÇÕES
25
- # ============================================================================
26
- DATASET_PATH = "training_dataset.json"
27
- MIN_INTERACOES_PARA_ANALISE = 10
28
- MAX_EXEMPLOS_DATASET = 2000
29
- QUALIDADE_MINIMA = 0.6
30
-
31
- # ============================================================================
32
- # 🔧 CACHE E LOCKS
33
- # ============================================================================
34
- EMBEDDING_CACHE = {}
35
- _lock = threading.Lock()
36
-
37
- # ============================================================================
38
- # 🎯 CLASSE PRINCIPAL DE TREINAMENTO
39
- # ============================================================================
40
- class Treinamento:
41
- def __init__(self, db: Database, interval_hours: int = 6):
42
- """
43
- Inicializa sistema de treinamento
44
-
45
- Args:
46
- db: Instância do Database
47
- interval_hours: Intervalo entre treinamentos automáticos
48
- """
49
- self.db = db
50
- self.interval_seconds = interval_hours * 3600
51
- self._loop_thread: Optional[threading.Thread] = None
52
- self.running = False
53
- self.exemplos_qualidade_cache = {}
54
- self.ultima_analise = 0
55
- logger.info(f"✅ Treinamento inicializado (intervalo: {interval_hours}h)")
56
-
57
- # ========================================================================
58
- # 📝 REGISTRO DE INTERAÇÕES (ADAPTADO AO INDEX.JS) - CORRIGIDO
59
- # ========================================================================
60
-
61
- def registrar_interacao(
62
- self,
63
- usuario: str,
64
- mensagem: str,
65
- resposta: str,
66
- numero: str,
67
- is_reply: bool = False,
68
- mensagem_original: str = None,
69
- contexto: Dict = None,
70
- tipo_conversa: str = 'pv',
71
- tipo_mensagem: str = 'texto',
72
- reply_to_bot: bool = False,
73
- reply_metadata: Optional[Dict] = None,
74
- nivel_transicao: int = 0 # NOVO PARÂMETRO ADICIONADO
75
- ):
76
- """
77
- Registra interação para treinamento - TOTALMENTE COMPATÍVEL
78
-
79
- Args:
80
- usuario: Nome do usuário
81
- mensagem: Mensagem enviada
82
- resposta: Resposta gerada
83
- numero: Número do usuário
84
- is_reply: Se é reply
85
- mensagem_original: Mensagem original (se reply)
86
- contexto: Contexto da conversa
87
- tipo_conversa: 'pv' ou 'grupo'
88
- tipo_mensagem: 'texto', 'audio', etc
89
- reply_to_bot: Se é reply ao bot
90
- reply_metadata: Metadata do reply (do index.js)
91
- nivel_transicao: Nível de transição do usuário privilegiado
92
- """
93
- try:
94
- numero = str(numero).strip()
95
-
96
- # Prepara contexto
97
- if contexto is None:
98
- contexto = {}
99
-
100
- # Extrai reply_to_bot de reply_metadata se disponível
101
- if reply_metadata and reply_metadata.get('reply_to_bot') is not None:
102
- reply_to_bot = reply_metadata.get('reply_to_bot', False)
103
-
104
- # Extrai info_transicao do contexto
105
- info_transicao = contexto.get('info_transicao', {})
106
-
107
- # Determina emoção e qualidade
108
- emocao_detectada, confianca_emocao = self._detectar_emocao(mensagem)
109
- qualidade = self._calcular_qualidade_resposta(mensagem, resposta, tipo_mensagem)
110
-
111
- # Salva no banco usando método CORRETO com nivel_transicao
112
- self.db.salvar_mensagem(
113
- usuario=usuario,
114
- mensagem=mensagem,
115
- resposta=resposta,
116
- numero=numero,
117
- is_reply=is_reply,
118
- mensagem_original=mensagem_original or '',
119
- reply_to_bot=reply_to_bot,
120
- humor=contexto.get('humor_atualizado', 'normal_ironico'),
121
- modo_resposta=contexto.get('modo_resposta', 'normal_ironico'),
122
- emocao_detectada=emocao_detectada,
123
- confianca_emocao=confianca_emocao,
124
- nivel_transicao=nivel_transicao, # PARÂMETRO ADICIONADO
125
- info_transicao=info_transicao, # INFO DE TRANSIÇÃO
126
- tipo_mensagem=tipo_mensagem,
127
- usuario_nome=usuario,
128
- tipo_conversa=tipo_conversa,
129
- reply_info_json=json.dumps(reply_metadata) if reply_metadata else None
130
- )
131
-
132
- # Atualiza contexto com nivel_transicao
133
- self.db.atualizar_contexto(
134
- numero=numero,
135
- humor_atual=contexto.get('humor_atualizado', 'normal_ironico'),
136
- modo_resposta=contexto.get('modo_resposta', 'normal_ironico'),
137
- nivel_transicao=nivel_transicao,
138
- info_transicao=info_transicao,
139
- tom=contexto.get('tom', 'normal'),
140
- emocao_tendencia=emocao_detectada
141
- )
142
-
143
- # Adiciona ao dataset se qualidade boa
144
- if qualidade >= QUALIDADE_MINIMA:
145
- self._adicionar_ao_dataset(
146
- mensagem=mensagem,
147
- resposta=resposta,
148
- numero=numero,
149
- usuario=usuario,
150
- contexto=contexto,
151
- emocao_detectada=emocao_detectada,
152
- confianca_emocao=confianca_emocao,
153
- qualidade=qualidade,
154
- tipo_mensagem=tipo_mensagem,
155
- tipo_conversa=tipo_conversa,
156
- is_reply=is_reply,
157
- reply_to_bot=reply_to_bot,
158
- reply_metadata=reply_metadata,
159
- nivel_transicao=nivel_transicao # ADICIONADO
160
- )
161
-
162
- # Salva exemplo de treinamento com nivel_transicao
163
- if tipo_mensagem == 'texto' and len(resposta) > 10:
164
- self.db.salvar_training_example(
165
- input_text=mensagem,
166
- output_text=resposta,
167
- humor=contexto.get('humor_atualizado', 'normal_ironico'),
168
- modo_resposta=contexto.get('modo_resposta', 'normal_ironico'),
169
- nivel_transicao=nivel_transicao, # ADICIONADO
170
- emocao_contexto=emocao_detectada,
171
- qualidade_score=qualidade,
172
- contexto_super_claro={
173
- 'is_reply': is_reply,
174
- 'reply_to_bot': reply_to_bot,
175
- 'tipo_conversa': tipo_conversa,
176
- 'tipo_mensagem': tipo_mensagem,
177
- 'reply_metadata': reply_metadata,
178
- 'nivel_transicao': nivel_transicao,
179
- 'info_transicao': info_transicao
180
- }
181
- )
182
-
183
- # Registra interação para treinamento
184
- self.db.registrar_interacao(
185
- numero=numero,
186
- mensagem=mensagem,
187
- resposta=resposta,
188
- humor=contexto.get('humor_atualizado', 'normal_ironico'),
189
- modo_resposta=contexto.get('modo_resposta', 'normal_ironico'),
190
- nivel_transicao=nivel_transicao, # PARÂMETRO ADICIONADO
191
- emocao_detectada=emocao_detectada,
192
- tipo_conversa=tipo_conversa,
193
- reply_info_json=json.dumps(reply_metadata) if reply_metadata else None,
194
- qualidade_score=qualidade
195
- )
196
-
197
- # Analisa padrões
198
- self._analisar_padroes_usuario(
199
- numero=numero,
200
- usuario=usuario,
201
- mensagem=mensagem,
202
- resposta=resposta,
203
- contexto=contexto,
204
- emocao_detectada=emocao_detectada,
205
- tipo_conversa=tipo_conversa,
206
- is_reply=is_reply,
207
- reply_to_bot=reply_to_bot,
208
- reply_metadata=reply_metadata,
209
- nivel_transicao=nivel_transicao # ADICIONADO
210
- )
211
-
212
- logger.debug(f"✅ Interação registrada: {usuario[:10]} | Nível: {nivel_transicao} | reply: {is_reply}")
213
-
214
- except Exception as e:
215
- logger.error(f" Erro ao registrar interação: {e}")
216
- import traceback
217
- traceback.print_exc()
218
-
219
- # ========================================================================
220
- # 🎭 DETECÇÃO DE EMOÇÃO
221
- # ========================================================================
222
-
223
- def _detectar_emocao(self, mensagem: str) -> Tuple[str, float]:
224
- """
225
- Detecta emoção básica na mensagem
226
-
227
- Args:
228
- mensagem: Texto da mensagem
229
-
230
- Returns:
231
- Tupla (emocao, confianca)
232
- """
233
- if not mensagem.strip():
234
- return "neutral", 0.5
235
-
236
- mensagem_lower = mensagem.lower()
237
-
238
- # Palavras positivas
239
- positivas = ['bom', 'ótimo', 'feliz', 'fixe', 'adorei', 'love', 'obrigado', 'thanks']
240
- negativas = ['ruim', 'péssimo', 'triste', 'ódio', 'raiva', 'merda', 'caralho']
241
-
242
- pos = sum(1 for p in positivas if p in mensagem_lower)
243
- neg = sum(1 for n in negativas if n in mensagem_lower)
244
-
245
- if pos > neg and pos >= 2:
246
- return "joy", 0.7
247
- elif neg > pos and neg >= 2:
248
- return "anger", 0.7
249
- else:
250
- return "neutral", 0.5
251
-
252
- # ========================================================================
253
- # 📊 CÁLCULO DE QUALIDADE
254
- # ========================================================================
255
-
256
- def _calcular_qualidade_resposta(self, mensagem: str, resposta: str, tipo_mensagem: str) -> float:
257
- """
258
- Calcula qualidade da resposta
259
-
260
- Args:
261
- mensagem: Mensagem do usuário
262
- resposta: Resposta do bot
263
- tipo_mensagem: Tipo da mensagem
264
-
265
- Returns:
266
- Score de qualidade (0.0 a 1.0)
267
- """
268
- qualidade = 0.5 # Base
269
-
270
- # Fatores positivos
271
- if 10 < len(resposta) < 300:
272
- qualidade += 0.2
273
-
274
- if len(mensagem) > 5:
275
- qualidade += 0.1
276
-
277
- if tipo_mensagem == 'texto':
278
- qualidade += 0.1
279
-
280
- # Verifica problemas comuns
281
- problemas = [
282
- ("kkk", resposta.lower().count("kkk") > 3),
283
- ("rsrs", resposta.lower().count("rsrs") > 3),
284
- ('"', resposta.count('"') > 5),
285
- ("**", resposta.count('**') > 2),
286
- ]
287
-
288
- # Penaliza problemas
289
- for _, condicao in problemas:
290
- if condicao:
291
- qualidade -= 0.05
292
-
293
- # Limites
294
- qualidade = max(0.1, min(1.0, qualidade))
295
-
296
- return round(qualidade, 2)
297
-
298
- # ========================================================================
299
- # 💾 ADICIONAR AO DATASET (ATUALIZADO)
300
- # ========================================================================
301
-
302
- def _adicionar_ao_dataset(
303
- self,
304
- mensagem: str,
305
- resposta: str,
306
- numero: str,
307
- usuario: str,
308
- contexto: Dict,
309
- emocao_detectada: str,
310
- confianca_emocao: float,
311
- qualidade: float,
312
- tipo_mensagem: str,
313
- tipo_conversa: str,
314
- is_reply: bool,
315
- reply_to_bot: bool,
316
- reply_metadata: Optional[Dict] = None,
317
- nivel_transicao: int = 0 # NOVO PARÂMETRO
318
- ):
319
- """
320
- Adiciona exemplo ao dataset de treinamento - ATUALIZADO
321
-
322
- Args:
323
- mensagem: Mensagem do usuário
324
- resposta: Resposta do bot
325
- numero: Número do usuário
326
- usuario: Nome do usuário
327
- contexto: Contexto da conversa
328
- emocao_detectada: Emoção detectada
329
- confianca_emocao: Confiança da detecção
330
- qualidade: Score de qualidade
331
- tipo_mensagem: Tipo da mensagem
332
- tipo_conversa: Tipo da conversa
333
- is_reply: Se é reply
334
- reply_to_bot: Se é reply ao bot
335
- reply_metadata: Metadata do reply
336
- nivel_transicao: Nível de transição do usuário
337
- """
338
- try:
339
- humor = contexto.get("humor_atualizado", "normal_ironico")
340
- modo = contexto.get("modo_resposta", "normal_ironico")
341
-
342
- # Garante formato correto do humor
343
- if "ironic" not in humor and "ironica" not in humor:
344
- if humor.endswith("o"):
345
- humor = f"{humor}_ironico"
346
- elif humor.endswith("a"):
347
- humor = f"{humor}_ironica"
348
- else:
349
- humor = f"{humor}_ironico"
350
-
351
- # Normaliza modo
352
- if modo == "casual_amigavel":
353
- modo = "normal_ironico"
354
-
355
- # Prepara metadados com reply_metadata e nivel_transicao
356
- metadata = {
357
- "usuario": usuario[:20],
358
- "numero_hash": hashlib.md5(numero.encode()).hexdigest()[:8],
359
- "humor": humor,
360
- "modo_resposta": modo,
361
- "nivel_transicao": nivel_transicao, # ADICIONADO
362
- "emocao_detectada": emocao_detectada,
363
- "confianca_emocao": confianca_emocao,
364
- "qualidade_score": qualidade,
365
- "is_reply": is_reply,
366
- "reply_to_bot": reply_to_bot,
367
- "tipo_mensagem": tipo_mensagem,
368
- "tipo_conversa": tipo_conversa,
369
- "timestamp": time.time(),
370
- "version": "v21_indexjs"
371
- }
372
-
373
- # Adiciona reply_metadata se disponível
374
- if reply_metadata:
375
- metadata.update({
376
- "reply_metadata_quoted_author": reply_metadata.get('quoted_author_name', 'N/A'),
377
- "reply_metadata_is_reply": reply_metadata.get('is_reply', False),
378
- "reply_metadata_context": reply_metadata.get('context_hint', '')
379
- })
380
-
381
- # Adiciona info_transicao se disponível
382
- info_transicao = contexto.get('info_transicao', {})
383
- if info_transicao:
384
- metadata.update({
385
- "info_transicao_desc": info_transicao.get('desc', ''),
386
- "info_transicao_modo": info_transicao.get('modo', ''),
387
- "info_transicao_deve_transicionar": info_transicao.get('deve_transicionar', False)
388
- })
389
-
390
- entry = {
391
- "input": mensagem.strip(),
392
- "output": resposta.strip(),
393
- "metadata": metadata
394
- }
395
-
396
- with _lock:
397
- dataset = []
398
- if os.path.exists(DATASET_PATH):
399
- try:
400
- with open(DATASET_PATH, "r", encoding="utf-8") as f:
401
- dataset = json.load(f)
402
- if not isinstance(dataset, list):
403
- dataset = []
404
- except:
405
- dataset = []
406
-
407
- # Remove duplicatas
408
- entry_hash = hashlib.md5(f"{mensagem}{resposta}".encode()).hexdigest()
409
- dataset = [e for e in dataset if
410
- hashlib.md5(f"{e.get('input','')}{e.get('output','')}".encode()).hexdigest() != entry_hash]
411
-
412
- dataset.append(entry)
413
-
414
- # Mantém apenas melhores exemplos
415
- if len(dataset) > MAX_EXEMPLOS_DATASET:
416
- dataset.sort(key=lambda x: x.get("metadata", {}).get("qualidade_score", 0), reverse=True)
417
- dataset = dataset[:MAX_EXEMPLOS_DATASET]
418
-
419
- with open(DATASET_PATH, "w", encoding="utf-8") as f:
420
- json.dump(dataset, f, ensure_ascii=False, indent=2)
421
-
422
- logger.debug(f"✅ Exemplo adicionado ao dataset | nível: {nivel_transicao} | qualidade: {qualidade:.2f}")
423
-
424
- except Exception as e:
425
- logger.warning(f"⚠️ Erro ao adicionar ao dataset: {e}")
426
-
427
- # ========================================================================
428
- # 🔍 ANÁLISE DE PADRÕES (ATUALIZADA)
429
- # ========================================================================
430
-
431
- def _analisar_padroes_usuario(
432
- self,
433
- numero: str,
434
- usuario: str,
435
- mensagem: str,
436
- resposta: str,
437
- contexto: Dict,
438
- emocao_detectada: str,
439
- tipo_conversa: str,
440
- is_reply: bool,
441
- reply_to_bot: bool,
442
- reply_metadata: Optional[Dict] = None,
443
- nivel_transicao: int = 0 # NOVO PARÂMETRO
444
- ):
445
- """
446
- Analisa padrões de comunicação do usuário - ATUALIZADA
447
-
448
- Args:
449
- numero: Número do usuário
450
- usuario: Nome do usuário
451
- mensagem: Mensagem enviada
452
- resposta: Resposta gerada
453
- contexto: Contexto da conversa
454
- emocao_detectada: Emoção detectada
455
- tipo_conversa: Tipo da conversa
456
- is_reply: Se é reply
457
- reply_to_bot: Se é reply ao bot
458
- reply_metadata: Metadata do reply
459
- nivel_transicao: Nível de transição do usuário
460
- """
461
- try:
462
- # 1. REGISTRAR TOM
463
- tom = self._detectar_tom(mensagem)
464
- if tom:
465
- self.db.registrar_tom_usuario(numero, tom)
466
-
467
- # 2. APRENDER GÍRIAS
468
- girias_detectadas = self._detectar_girias(mensagem)
469
- for giria, significado in girias_detectadas.items():
470
- try:
471
- self.db.salvar_giria(
472
- numero=numero,
473
- giria=giria,
474
- significado=significado,
475
- contexto=mensagem[:100]
476
- )
477
- except Exception as e:
478
- logger.warning(f"Erro ao salvar gíria: {e}")
479
-
480
- # 3. REGISTRAR TRANSIÇÃO DE HUMOR COM nivel_transicao
481
- if "humor_atualizado" in contexto:
482
- humor = contexto["humor_atualizado"]
483
- humor_atual = self.db.recuperar_humor_atual(numero)
484
- nivel_atual = self.db.recuperar_nivel_transicao(numero)
485
-
486
- if humor != humor_atual or nivel_transicao != nivel_atual:
487
- self.db.salvar_transicao_humor(
488
- numero=numero,
489
- humor_anterior=humor_atual,
490
- humor_novo=humor,
491
- nivel_transicao_anterior=nivel_atual,
492
- nivel_transicao_novo=nivel_transicao,
493
- emocao_trigger=emocao_detectada,
494
- confianca_emocao=contexto.get('confianca_emocao', 0.5),
495
- razao=f"Transição nível {nivel_atual}→{nivel_transicao} | {tipo_conversa}"
496
- )
497
-
498
- # 4. APRENDER PADRÕES DE REPLY COM nivel_transicao
499
- if is_reply:
500
- self._aprender_padrao_reply(
501
- numero=numero,
502
- usuario=usuario,
503
- mensagem=mensagem,
504
- resposta=resposta,
505
- reply_to_bot=reply_to_bot,
506
- tipo_conversa=tipo_conversa,
507
- reply_metadata=reply_metadata,
508
- nivel_transicao=nivel_transicao # ADICIONADO
509
- )
510
-
511
- # 5. ANALISAR TRANSIÇÕES DE USUÁRIOS PRIVILEGIADOS
512
- if nivel_transicao > 0:
513
- self._analisar_transicao_privilegiado(
514
- numero=numero,
515
- usuario=usuario,
516
- nivel_transicao=nivel_transicao,
517
- mensagem=mensagem,
518
- contexto=contexto
519
- )
520
-
521
- except Exception as e:
522
- logger.warning(f"⚠️ Erro na análise de padrões: {e}")
523
-
524
- def _detectar_tom(self, mensagem: str) -> str:
525
- """
526
- Detecta tom da mensagem
527
-
528
- Args:
529
- mensagem: Texto da mensagem
530
-
531
- Returns:
532
- Tom detectado
533
- """
534
- if not mensagem:
535
- return "neutro"
536
-
537
- mensagem_lower = mensagem.lower()
538
-
539
- # Formal
540
- if any(x in mensagem_lower for x in ["senhor", "doutor", "atenciosamente", "por favor"]):
541
- return "formal"
542
-
543
- # Rude
544
- rude_palavras = ["burro", "idiota", "merda", "porra", "caralho", "vai se foder"]
545
- if any(x in mensagem_lower for x in rude_palavras):
546
- return "rude"
547
-
548
- # Informal/Angolano
549
- girias = ['puto', 'mano', 'kota', 'fixe', 'bué', 'ya']
550
- if any(x in mensagem_lower for x in girias):
551
- return "informal_angolano"
552
-
553
- return "neutro"
554
-
555
- def _detectar_girias(self, mensagem: str) -> Dict[str, str]:
556
- """
557
- Detecta gírias angolanas
558
-
559
- Args:
560
- mensagem: Texto da mensagem
561
-
562
- Returns:
563
- Dicionário {giria: significado}
564
- """
565
- girias = {
566
- "puto": "amigo/cara",
567
- "fixe": "legal/bacana",
568
- "bué": "muito/bastante",
569
- "mwangolé": "meu angolano",
570
- "kota": "pessoa mais velha",
571
- "ya": "sim",
572
- "epha": "irritação",
573
- "maka": "problema",
574
- "kandengue": "criança"
575
- }
576
-
577
- msg_lower = mensagem.lower()
578
- detectadas = {}
579
-
580
- for giria, significado in girias.items():
581
- if giria in msg_lower:
582
- detectadas[giria] = significado
583
-
584
- return detectadas
585
-
586
- def _aprender_padrao_reply(
587
- self,
588
- numero: str,
589
- usuario: str,
590
- mensagem: str,
591
- resposta: str,
592
- reply_to_bot: bool,
593
- tipo_conversa: str,
594
- reply_metadata: Optional[Dict] = None,
595
- nivel_transicao: int = 0 # NOVO PARÂMETRO
596
- ):
597
- """
598
- Aprende padrões de reply - ATUALIZADO
599
-
600
- Args:
601
- numero: Número do usuário
602
- usuario: Nome do usuário
603
- mensagem: Mensagem enviada
604
- resposta: Resposta gerada
605
- reply_to_bot: Se é reply ao bot
606
- tipo_conversa: Tipo da conversa
607
- reply_metadata: Metadata do reply
608
- nivel_transicao: Nível de transição do usuário
609
- """
610
- try:
611
- # Define padrão com base no reply_metadata
612
- if reply_metadata:
613
- quoted_author = reply_metadata.get('quoted_author_name', 'N/A')
614
- if reply_to_bot or quoted_author.lower() == 'akira':
615
- padrao = "resposta_a_mensagem_do_bot"
616
- tipo = "reply_ao_bot"
617
- else:
618
- padrao = "comentario_sobre_conversa_alheia"
619
- tipo = "conversa_alheia"
620
-
621
- contexto_extra = f"[Autor citado: {quoted_author}]"
622
- else:
623
- if reply_to_bot:
624
- padrao = "resposta_a_mensagem_do_bot"
625
- tipo = "reply_ao_bot"
626
- contexto_extra = ""
627
- else:
628
- padrao = "comentario_sobre_conversa_alheia"
629
- tipo = "conversa_alheia"
630
- contexto_extra = ""
631
-
632
- # Adiciona info de transição se disponível
633
- transicao_info = f"[Nível transição: {nivel_transicao}]" if nivel_transicao > 0 else ""
634
-
635
- # Prepara texto com contexto
636
- input_text_com_contexto = f"[CONTEXTO: {padrao.upper()}] {transicao_info} {contexto_extra} {mensagem}"
637
-
638
- # Salva aprendizado com nivel_transicao
639
- self.db.salvar_aprendizado_detalhado(
640
- input_text=input_text_com_contexto,
641
- output_text=resposta,
642
- contexto={
643
- 'numero': numero,
644
- 'usuario': usuario,
645
- 'padrao': padrao,
646
- 'reply_to_bot': reply_to_bot,
647
- 'tipo_conversa': tipo_conversa,
648
- 'tipo': tipo,
649
- 'reply_metadata': reply_metadata,
650
- 'nivel_transicao': nivel_transicao # ADICIONADO
651
- },
652
- qualidade_score=0.8,
653
- tipo_aprendizado=f"reply_{tipo}_nivel_{nivel_transicao}"
654
- )
655
-
656
- logger.debug(f"✅ Padrão de reply aprendido: {padrao} | Nível: {nivel_transicao}")
657
-
658
- except Exception as e:
659
- logger.warning(f"⚠️ Erro ao aprender padrão de reply: {e}")
660
-
661
- def _analisar_transicao_privilegiado(
662
- self,
663
- numero: str,
664
- usuario: str,
665
- nivel_transicao: int,
666
- mensagem: str,
667
- contexto: Dict
668
- ):
669
- """
670
- Analisa transições de usuários privilegiados
671
-
672
- Args:
673
- numero: Número do usuário
674
- usuario: Nome do usuário
675
- nivel_transicao: Nível atual de transição
676
- mensagem: Mensagem enviada
677
- contexto: Contexto da conversa
678
- """
679
- try:
680
- # Recupera histórico de transições
681
- transicoes = self.db._execute_with_retry(
682
- """
683
- SELECT nivel_transicao_anterior, nivel_transicao_novo, timestamp, razao
684
- FROM transicoes_humor
685
- WHERE numero = ?
686
- ORDER BY timestamp DESC
687
- LIMIT 10
688
- """,
689
- (numero,),
690
- fetch=True
691
- )
692
-
693
- # Analisa padrão de transição
694
- if len(transicoes) >= 3:
695
- niveis = [t[1] for t in transicoes] # Últimos níveis novos
696
- mudancas = sum(1 for i in range(len(niveis)-1) if niveis[i] != niveis[i+1])
697
-
698
- # Se muitas mudanças, usuário é volátil
699
- if mudancas >= 2:
700
- logger.info(f"⚠️ Usuário {usuario} é volátil em transições: {mudancas} mudanças")
701
-
702
- logger.debug(f"📊 Transição privilegiado: {usuario} → Nível {nivel_transicao}")
703
-
704
- except Exception as e:
705
- logger.warning(f"⚠️ Erro na análise de transição: {e}")
706
-
707
- # ========================================================================
708
- # 🔄 TREINAMENTO PERIÓDICO
709
- # ========================================================================
710
-
711
- def start_periodic_training(self):
712
- """Inicia treinamento periódico em background"""
713
- if self._loop_thread is None or not self._loop_thread.is_alive():
714
- self.running = True
715
- self._loop_thread = threading.Thread(target=self._training_loop, daemon=True)
716
- self._loop_thread.start()
717
- logger.info("✅ Treinamento periódico iniciado")
718
- else:
719
- logger.warning("⚠️ Treinamento já ativo")
720
-
721
- def stop_periodic_training(self):
722
- """Para treinamento periódico"""
723
- self.running = False
724
- if self._loop_thread and self._loop_thread.is_alive():
725
- self._loop_thread.join(timeout=5)
726
- logger.info("✅ Treinamento periódico parado")
727
-
728
- def _training_loop(self):
729
- """Loop principal de treinamento"""
730
- while self.running:
731
- try:
732
- time.sleep(self.interval_seconds)
733
- logger.info("🔄 Iniciando ciclo de treinamento...")
734
-
735
- self._gerar_dataset()
736
- self._analisar_padroes_globais()
737
- self._otimizar_banco()
738
-
739
- logger.success("✅ Ciclo de treinamento concluído")
740
-
741
- except Exception as e:
742
- logger.error(f"❌ Erro no treinamento: {e}")
743
-
744
- def _gerar_dataset(self):
745
- """Gera dataset de treinamento"""
746
- try:
747
- exemplos = self.db.recuperar_training_examples(limite=1000)
748
- if not exemplos:
749
- logger.warning("⚠️ Nenhum exemplo para treinar")
750
- return
751
-
752
- exemplos = [e for e in exemplos if e.get("score", 0) >= QUALIDADE_MINIMA]
753
- if not exemplos:
754
- logger.warning("⚠️ Nenhum exemplo com qualidade suficiente")
755
- return
756
-
757
- # Gera arquivo JSONL com nivel_transicao
758
- with open("training_dataset_akira_v21.jsonl", "w", encoding="utf-8") as f:
759
- for ex in exemplos[:500]:
760
- if ex.get("score", 0) >= 0.7:
761
- f.write(json.dumps({
762
- "input": ex.get("input", ""),
763
- "output": ex.get("output", ""),
764
- "humor": ex.get("humor", "normal_ironico"),
765
- "modo": ex.get("modo", "normal_ironico"),
766
- "nivel_transicao": ex.get("nivel_transicao", 0), # ADICIONADO
767
- "metadata": {
768
- "score": ex.get("score", 0.5),
769
- "timestamp": time.time(),
770
- "version": "v21"
771
- }
772
- }, ensure_ascii=False) + "\n")
773
-
774
- logger.info(f"✅ Dataset gerado: {len(exemplos)} exemplos (com nível transição)")
775
- self.db.marcar_examples_como_usados()
776
-
777
- except Exception as e:
778
- logger.error(f"❌ Erro ao gerar dataset: {e}")
779
-
780
- def _analisar_padroes_globais(self):
781
- """Analisa padrões globais do dataset"""
782
- try:
783
- if not os.path.exists(DATASET_PATH):
784
- return
785
-
786
- with open(DATASET_PATH, "r", encoding="utf-8") as f:
787
- dataset = json.load(f)
788
-
789
- # Análise estatística com nivel_transicao
790
- padroes = {
791
- "total": len(dataset),
792
- "reply_to_bot": 0,
793
- "not_reply_to_bot": 0,
794
- "reply_with_metadata": 0,
795
- "grupo": 0,
796
- "pv": 0,
797
- "audio": 0,
798
- "texto": 0,
799
- "transicao_nivel_0": 0,
800
- "transicao_nivel_1": 0,
801
- "transicao_nivel_2": 0,
802
- "transicao_nivel_3": 0
803
- }
804
-
805
- for e in dataset:
806
- meta = e.get("metadata", {})
807
- if meta.get("reply_to_bot", False):
808
- padroes["reply_to_bot"] += 1
809
- else:
810
- padroes["not_reply_to_bot"] += 1
811
-
812
- if meta.get("reply_metadata_quoted_author"):
813
- padroes["reply_with_metadata"] += 1
814
-
815
- if meta.get("tipo_conversa") == 'grupo':
816
- padroes["grupo"] += 1
817
- else:
818
- padroes["pv"] += 1
819
-
820
- if meta.get("tipo_mensagem") == 'audio':
821
- padroes["audio"] += 1
822
- else:
823
- padroes["texto"] += 1
824
-
825
- # Analisa nível de transição
826
- nivel = meta.get("nivel_transicao", 0)
827
- if nivel == 0:
828
- padroes["transicao_nivel_0"] += 1
829
- elif nivel == 1:
830
- padroes["transicao_nivel_1"] += 1
831
- elif nivel == 2:
832
- padroes["transicao_nivel_2"] += 1
833
- elif nivel == 3:
834
- padroes["transicao_nivel_3"] += 1
835
-
836
- # Log estatísticas
837
- logger.info(f"📊 Estatísticas do dataset:")
838
- logger.info(f" Total: {padroes['total']}")
839
- logger.info(f" Reply ao bot: {padroes['reply_to_bot']} ({padroes['reply_to_bot']/max(padroes['total'],1)*100:.1f}%)")
840
- logger.info(f" Não reply ao bot: {padroes['not_reply_to_bot']} ({padroes['not_reply_to_bot']/max(padroes['total'],1)*100:.1f}%)")
841
- logger.info(f" Com reply_metadata: {padroes['reply_with_metadata']}")
842
- logger.info(f" Grupo: {padroes['grupo']} | PV: {padroes['pv']}")
843
- logger.info(f" Áudio: {padroes['audio']} | Texto: {padroes['texto']}")
844
- logger.info(f" Níveis transição: 0={padroes['transicao_nivel_0']} | 1={padroes['transicao_nivel_1']} | 2={padroes['transicao_nivel_2']} | 3={padroes['transicao_nivel_3']}")
845
-
846
- except Exception as e:
847
- logger.error(f" Erro na análise global: {e}")
848
-
849
- def _otimizar_banco(self):
850
- """Otimiza banco de dados"""
851
- try:
852
- self.db._execute_with_retry("VACUUM", commit=True, fetch=False)
853
- self.db._execute_with_retry("ANALYZE", commit=True, fetch=False)
854
- logger.info(" Banco otimizado")
855
- except Exception as e:
856
- logger.warning(f"⚠️ Erro na otimização: {e}")
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
- # modules/web_search.py — AKIRA V19 (Dezembro 2025)
2
- """
3
- Módulo de busca na web para APIs sem acesso nativo:
4
- - Busca notícias de Angola (WebScraping)
5
- - Busca geral (DuckDuckGo API - gratuita)
6
- - Pesquisa de clima/tempo
7
- - Cache de 15 minutos
8
- """
9
- import time
10
- import re
11
- import requests
12
- from typing import List, Dict, Any, Optional
13
- from loguru import logger
14
- from bs4 import BeautifulSoup
15
-
16
- # === CONFIGURAÇÕES ===
17
- CACHE_TTL = 900 # 15 minutos
18
-
19
- class SimpleCache:
20
- """Cache simples em memória com TTL"""
21
- def __init__(self, ttl: int = CACHE_TTL):
22
- self.ttl = ttl
23
- self._data: Dict[str, Any] = {}
24
-
25
- def get(self, key: str):
26
- if key in self._data:
27
- value, timestamp = self._data[key]
28
- if time.time() - timestamp < self.ttl:
29
- return value
30
- del self._data[key]
31
- return None
32
-
33
- def set(self, key: str, value: Any):
34
- self._data[key] = (value, time.time())
35
-
36
-
37
- class WebSearch:
38
- """
39
- Gerenciador de buscas na web:
40
- - Notícias de Angola (scraping)
41
- - Busca geral (DuckDuckGo)
42
- - Clima/tempo
43
- """
44
-
45
- def __init__(self):
46
- self.cache = SimpleCache(ttl=CACHE_TTL)
47
- self.session = requests.Session()
48
- self.session.headers.update({
49
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
50
- "Accept-Language": "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7"
51
- })
52
-
53
- # Fontes de notícias Angola
54
- self.fontes_angola = [
55
- "https://www.angop.ao/ultimas",
56
- "https://www.novojornal.co.ao/",
57
- "https://www.jornaldeangola.ao/"
58
- ]
59
-
60
- # ========================================================================
61
- # BUSCA GERAL (MULTI-FONTE - GRATUITA E ROBUSTA)
62
- # ========================================================================
63
-
64
- def buscar_geral(self, query: str, max_resultados: int = 3) -> str:
65
- """
66
- Busca geral na web usando múltiplas fontes gratuitas
67
-
68
- Args:
69
- query: Termo de busca
70
- max_resultados: Número máximo de resultados
71
-
72
- Returns:
73
- String formatada com resultados para o prompt da IA
74
- """
75
- cache_key = f"busca_geral_{query.lower()}"
76
- cached = self.cache.get(cache_key)
77
- if cached:
78
- return cached
79
-
80
- try:
81
- # Tentar múltiplas fontes em ordem de prioridade
82
- resultados = []
83
-
84
- # 1. DuckDuckGo Instant Answer
85
- try:
86
- url = "https://api.duckduckgo.com/"
87
- params = {
88
- "q": query,
89
- "format": "json",
90
- "no_html": "1",
91
- "skip_disambig": "1"
92
- }
93
-
94
- resp = self.session.get(url, params=params, timeout=8)
95
- if resp.status_code == 200:
96
- data = resp.json()
97
-
98
- # Abstract (resumo principal)
99
- if data.get("Abstract"):
100
- resultados.append(f"RESUMO: {data['Abstract'][:300]}")
101
-
102
- # Related topics
103
- for topic in data.get("RelatedTopics", [])[:max_resultados]:
104
- if isinstance(topic, dict) and "Text" in topic:
105
- resultados.append(f"INFO: {topic['Text'][:200]}")
106
- elif isinstance(topic, str):
107
- resultados.append(f"INFO: {topic[:200]}")
108
- except Exception as e:
109
- logger.debug(f"DuckDuckGo falhou: {e}")
110
-
111
- # 2. Wikipedia API (se for busca factual)
112
- if len(resultados) < max_resultados:
113
- try:
114
- wiki_url = "https://en.wikipedia.org/api/rest_v1/page/summary/"
115
- wiki_resp = self.session.get(wiki_url + query.replace(" ", "_"), timeout=5)
116
- if wiki_resp.status_code == 200:
117
- wiki_data = wiki_resp.json()
118
- if wiki_data.get("extract"):
119
- resultados.append(f"Wikipedia: {wiki_data['extract'][:250]}")
120
- except Exception as e:
121
- logger.debug(f"Wikipedia falhou: {e}")
122
-
123
- # 3. Fallback com busca simulada baseada em conhecimento geral
124
- if not resultados:
125
- return self._fallback_busca_geral(query)
126
-
127
- # Formatar para o prompt da IA (não para usuário)
128
- resposta = f"INFORMAÇÕES SOBRE '{query.upper()}':\n\n" + "\n\n".join(resultados[:max_resultados])
129
- self.cache.set(cache_key, resposta)
130
- return resposta
131
-
132
- except Exception as e:
133
- logger.warning(f"Busca geral falhou: {e}")
134
- return self._fallback_busca_geral(query)
135
-
136
- def _fallback_busca_geral(self, query: str) -> str:
137
- """Fallback quando todas as fontes falham"""
138
- return f"INFORMAÇÕES GERAIS SOBRE '{query}': Não foi possível obter dados específicos da web no momento. Use conhecimento geral para responder."
139
-
140
- # ========================================================================
141
- # NOTÍCIAS DE ANGOLA (WEB SCRAPING)
142
- # ========================================================================
143
-
144
- def pesquisar_noticias_angola(self, limite: int = 5) -> str:
145
- """
146
- Busca notícias mais recentes de Angola via scraping
147
-
148
- Returns:
149
- String formatada com notícias
150
- """
151
- cache_key = "noticias_angola"
152
- cached = self.cache.get(cache_key)
153
- if cached:
154
- return cached
155
-
156
- todas_noticias = []
157
-
158
- try:
159
- # Tenta cada fonte
160
- todas_noticias.extend(self._buscar_angop())
161
- todas_noticias.extend(self._buscar_novojornal())
162
- todas_noticias.extend(self._buscar_jornaldeangola())
163
-
164
- except Exception as e:
165
- logger.error(f"Erro no scraping de notícias: {e}")
166
-
167
- # Remove duplicatas e limita
168
- vistos = set()
169
- unicas = []
170
- for n in todas_noticias:
171
- titulo_lower = n["titulo"].lower()
172
- if titulo_lower not in vistos and len(titulo_lower) > 20:
173
- vistos.add(titulo_lower)
174
- unicas.append(n)
175
- if len(unicas) >= limite:
176
- break
177
-
178
- if not unicas:
179
- fallback = "Sem notícias recentes de Angola disponíveis no momento."
180
- self.cache.set(cache_key, fallback)
181
- return fallback
182
-
183
- # Formata resposta
184
- texto = "📰 NOTÍCIAS RECENTES DE ANGOLA:\n\n"
185
- for i, n in enumerate(unicas, 1):
186
- texto += f"[{i}] {n['titulo']}\n"
187
- if n.get('link'):
188
- texto += f" 🔗 {n['link']}\n"
189
- texto += "\n"
190
-
191
- self.cache.set(cache_key, texto.strip())
192
- return texto.strip()
193
-
194
- def _buscar_angop(self) -> List[Dict]:
195
- """Scraping da Angop"""
196
- try:
197
- r = self.session.get(self.fontes_angola[0], timeout=8)
198
- if r.status_code != 200:
199
- return []
200
-
201
- soup = BeautifulSoup(r.text, 'html.parser')
202
- itens = soup.select('.ultimas-noticias .item')[:3]
203
- noticias = []
204
-
205
- for item in itens:
206
- titulo = item.select_one('h3 a')
207
- link = item.select_one('a')
208
- if titulo and link:
209
- href = link.get('href', '')
210
- if isinstance(href, str):
211
- full_link = "https://www.angop.ao" + href if href.startswith('/') else href
212
- else:
213
- full_link = "https://www.angop.ao" + str(href) if str(href).startswith('/') else str(href)
214
- noticias.append({
215
- "titulo": self._limpar_texto(titulo.get_text()),
216
- "link": full_link,
217
- "fonte": "Angop"
218
- })
219
-
220
- return noticias
221
-
222
- except Exception as e:
223
- logger.warning(f"Angop scraping falhou: {e}")
224
- return []
225
-
226
- def _buscar_novojornal(self) -> List[Dict]:
227
- """Scraping do Novo Jornal"""
228
- try:
229
- r = self.session.get(self.fontes_angola[1], timeout=8)
230
- if r.status_code != 200:
231
- return []
232
-
233
- soup = BeautifulSoup(r.text, 'html.parser')
234
- itens = soup.select('.noticia-lista .titulo a')[:3]
235
- noticias = []
236
-
237
- for a in itens:
238
- noticias.append({
239
- "titulo": self._limpar_texto(a.get_text()),
240
- "link": a.get('href', ''),
241
- "fonte": "Novo Jornal"
242
- })
243
-
244
- return noticias
245
-
246
- except Exception as e:
247
- logger.warning(f"Novo Jornal scraping falhou: {e}")
248
- return []
249
-
250
- def _buscar_jornaldeangola(self) -> List[Dict]:
251
- """Scraping do Jornal de Angola"""
252
- try:
253
- r = self.session.get(self.fontes_angola[2], timeout=8)
254
- if r.status_code != 200:
255
- return []
256
-
257
- soup = BeautifulSoup(r.text, 'html.parser')
258
- itens = soup.select('.ultimas .titulo a')[:3]
259
- noticias = []
260
-
261
- for a in itens:
262
- noticias.append({
263
- "titulo": self._limpar_texto(a.get_text()),
264
- "link": a.get('href', ''),
265
- "fonte": "Jornal de Angola"
266
- })
267
-
268
- return noticias
269
-
270
- except Exception as e:
271
- logger.warning(f"Jornal de Angola scraping falhou: {e}")
272
- return []
273
-
274
- # ========================================================================
275
- # CLIMA/TEMPO
276
- # ========================================================================
277
-
278
- def buscar_clima(self, cidade: str = "Luanda") -> str:
279
- """
280
- Busca informações de clima usando wttr.in (gratuito)
281
-
282
- Args:
283
- cidade: Nome da cidade (padrão: Luanda)
284
-
285
- Returns:
286
- String com informações do clima
287
- """
288
- cache_key = f"clima_{cidade.lower()}"
289
- cached = self.cache.get(cache_key)
290
- if cached:
291
- return cached
292
-
293
- try:
294
- # wttr.in - serviço gratuito de clima
295
- url = f"https://wttr.in/{cidade}?format=j1"
296
- resp = self.session.get(url, timeout=8)
297
-
298
- if resp.status_code != 200:
299
- return f"Não consegui obter informações do clima em {cidade}."
300
-
301
- data = resp.json()
302
-
303
- # Extrai dados
304
- current = data['current_condition'][0]
305
- temp = current['temp_C']
306
- desc = current['lang_pt'][0]['value'] if 'lang_pt' in current else current['weatherDesc'][0]['value']
307
- humidity = current['humidity']
308
-
309
- resposta = f"🌤️ CLIMA EM {cidade.upper()}:\n\n"
310
- resposta += f"Temperatura: {temp}°C\n"
311
- resposta += f"Condição: {desc}\n"
312
- resposta += f"Umidade: {humidity}%"
313
-
314
- self.cache.set(cache_key, resposta)
315
- return resposta
316
-
317
- except Exception as e:
318
- logger.warning(f"Busca de clima falhou: {e}")
319
- return f"Não consegui obter informações do clima em {cidade} no momento."
320
-
321
- # ========================================================================
322
- # UTILIDADES
323
- # ========================================================================
324
-
325
- def _limpar_texto(self, texto: str) -> str:
326
- """Limpa e formata texto"""
327
- if not texto:
328
- return ""
329
- texto = re.sub(r'[\s\n\t]+', ' ', texto)
330
- return texto.strip()[:200]
331
-
332
- # ========================================================================
333
- # DETECÇÃO DE INTENÇÃO DE BUSCA
334
- # ========================================================================
335
-
336
- @staticmethod
337
- def detectar_intencao_busca(mensagem: str) -> Optional[str]:
338
- """
339
- Detecta se mensagem requer busca na web - MELHORADO
340
-
341
- Returns:
342
- "noticias" | "clima" | "busca_geral" | None
343
- """
344
- msg_lower = mensagem.lower()
345
-
346
- # PALAVRAS-CHAVE DE BUSCA DIRETAS (PRIORIDADE ALTA)
347
- palavras_busca_diretas = [
348
- "busca", "pesquisa", "pesquisar", "procurar", "procura",
349
- "web", "internet", "google", "wikipedia", "site",
350
- "informações", "dados", "saber", "conhecer", "descobrir",
351
- "encontrar", "localizar", "achar"
352
- ]
353
-
354
- # Verificar se contém palavras de busca diretas
355
- for palavra in palavras_busca_diretas:
356
- if palavra in msg_lower:
357
- # Se for sobre clima, priorizar clima
358
- if any(k in msg_lower for k in ["clima", "tempo", "temperatura", "chuva", "sol"]):
359
- return "clima"
360
- # Se for sobre notícias, priorizar notícias
361
- elif any(k in msg_lower for k in ["notícias", "noticias", "novidades", "aconteceu", "news"]):
362
- if "angola" in msg_lower or "angolano" in msg_lower:
363
- return "noticias"
364
- else:
365
- return "busca_geral"
366
- else:
367
- return "busca_geral"
368
-
369
- # Notícias (específicas de Angola)
370
- if any(k in msg_lower for k in ["notícias", "noticias", "novidades", "aconteceu", "news"]):
371
- if "angola" in msg_lower or "angolano" in msg_lower or "angola" in msg_lower:
372
- return "noticias"
373
-
374
- # Clima
375
- if any(k in msg_lower for k in ["clima", "tempo", "temperatura", "chuva", "sol"]):
376
- return "clima"
377
-
378
- # Busca geral (perguntas sobre fatos/eventos)
379
- palavras_chave_busca = [
380
- "quem é", "o que é", "onde fica", "quando foi", "como funciona",
381
- "definição", "significa", "história", "explicação", "significado",
382
- "qual é", "quais são", "quanto é", "quantos são"
383
- ]
384
-
385
- if any(k in msg_lower for k in palavras_chave_busca):
386
- return "busca_geral"
387
-
388
- # Perguntas com "?" também podem ativar busca (mais seletivo)
389
- if "?" in mensagem:
390
- palavras = mensagem.split()
391
- if len(palavras) > 2: # Pelo menos 3 palavras para considerar busca
392
- # Verificar se é uma pergunta factual
393
- indicadores_pergunta = ["quem", "o que", "onde", "quando", "como", "por que", "qual", "quanto", "porquê", "porque"]
394
- if any(indicador in msg_lower for indicador in indicadores_pergunta):
395
- return "busca_geral"
396
-
397
- return None
398
-
399
-
400
- # === INSTÂNCIA GLOBAL (SINGLETON) ===
401
- _web_search_instance = None
402
-
403
- def get_web_search() -> WebSearch:
404
- """Retorna instância singleton do WebSearch"""
405
- global _web_search_instance
406
- if _web_search_instance is None:
407
- _web_search_instance = WebSearch()
408
- return _web_search_instance
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ]