akra35567 commited on
Commit
8d13160
·
1 Parent(s): ee78bdb

Update modules/api.py

Browse files
Files changed (1) hide show
  1. modules/api.py +336 -52
modules/api.py CHANGED
@@ -1,30 +1,60 @@
1
- # modules/api.py
2
- import os
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import time
4
- from typing import Optional, List, Any
 
 
 
 
5
  import google.generativeai as genai
6
  from mistralai.client import MistralClient
7
- from mistralai.models.chat_models import ChatMessage
8
  from loguru import logger
9
 
10
- # Importa as configurações do seu arquivo config
11
- from modules import config
 
 
 
 
 
 
 
12
 
13
  class LLMManager:
14
- def __init__(self):
15
- self.config = config
 
 
 
16
  self.mistral_client: Optional[MistralClient] = None
17
  self.gemini_model: Optional[genai.GenerativeModel] = None
18
- self.llama_llm = self._import_llama()
 
 
19
 
20
  self._setup_providers()
21
 
22
- # Lista de provedores em ordem de prioridade (Mistral > Gemini)
23
- # O LlamaLLM (local) será adicionado se for importado e disponível,
24
- # mas como esvaziamos o local_llm.py, ele será pulado.
25
  self.providers = []
26
 
27
- # PRIORIDADE 1: Mistral API
28
  if self.mistral_client:
29
  self.providers.append('mistral')
30
 
@@ -32,9 +62,9 @@ class LLMManager:
32
  if self.gemini_model:
33
  self.providers.append('gemini')
34
 
35
- # Prioridade 3: Llama Local (Se estiver configurado e disponível)
36
  if self.llama_llm and self.llama_llm.is_available():
37
- self.providers.append('llama') # Isso será sempre FALSO com o novo local_llm.py
38
 
39
  if not self.providers:
40
  logger.error("Nenhum provedor de LLM configurado ou operacional. O app responderá apenas com fallback.")
@@ -43,13 +73,13 @@ class LLMManager:
43
 
44
 
45
  def _import_llama(self):
46
- """Importa o LlamaLLM se o arquivo existir."""
47
  try:
48
- from modules.local_llm import LlamaLLM
49
  # O carregamento real é feito dentro de LlamaLLM.__init__
50
  return LlamaLLM()
51
  except Exception as e:
52
- logger.warning(f"Falha ao importar LlamaLLM (módulo ausente ou erro de importação): {e}")
53
  return None
54
 
55
 
@@ -57,7 +87,7 @@ class LLMManager:
57
  """Inicializa os clientes da API."""
58
 
59
  # 1. MISTRAL
60
- mistral_available = self.config.MISTRAL_API_KEY.startswith('m-')
61
  if mistral_available:
62
  try:
63
  self.mistral_client = MistralClient(api_key=self.config.MISTRAL_API_KEY)
@@ -66,17 +96,18 @@ class LLMManager:
66
  logger.warning(f"Falha ao inicializar Mistral Client: {e}. Desativando Mistral API.")
67
  self.mistral_client = None
68
  else:
69
- logger.warning("Mistral API desativada (chave ausente ou inválida, como no log).")
70
 
71
  # 2. GEMINI
72
- gemini_available = self.config.GEMINI_API_KEY.startswith('AIza')
73
  if gemini_available:
74
  try:
75
  # Inicializa o cliente Gemini. A chave será RE-CONFIGURADA em .generate
76
- # para maior robustez, prevenindo o erro de chave perdida.
77
  self.gemini_model = genai.GenerativeModel(
78
  model=self.config.GEMINI_MODEL,
79
- system_instruction=self.config.PERSONA + self.config.SYSTEM_PROMPT
 
80
  )
81
  logger.info(f"Gemini model inicializado: {self.config.GEMINI_MODEL}")
82
  except Exception as e:
@@ -85,19 +116,16 @@ class LLMManager:
85
  else:
86
  logger.warning("Gemini API desativada (chave ausente ou inválida).")
87
 
88
- # 3. LLAMA LOCAL
89
- # O LlamaLLM já é importado e carregado em self.llama_llm, o que permite o
90
- # teste de disponibilidade no self.providers.
91
-
92
 
93
  def generate(self, user_prompt: str, context_history: List[dict] = [], is_privileged: bool = False) -> str:
94
- """Gera a resposta, iterando pelos provedores na ordem de prioridade."""
95
 
96
  # Formata o prompt para o LLM. As APIs usam ChatMessage/system_instruction.
97
- full_system_prompt = self.config.PERSONA + self.config.SYSTEM_PROMPT
 
98
 
99
- # Constrói o histórico do chat
100
- messages = [
101
  ChatMessage(role="system", content=full_system_prompt)
102
  ]
103
 
@@ -115,16 +143,10 @@ class LLMManager:
115
  # -----------------------------------------------------------
116
  if provider == 'mistral' and self.mistral_client:
117
  try:
118
- # Mistral usa seu próprio formato de mensagem/role
119
- mistral_messages = [
120
- ChatMessage(role=msg.role, content=msg.content)
121
- for msg in messages
122
- ]
123
-
124
  response = self.mistral_client.chat(
125
  model=self.config.MISTRAL_MODEL,
126
- messages=mistral_messages,
127
- temperature=self.config.TOP_P, # Mistral usa TOP_P para o temperature
128
  max_tokens=self.config.MAX_TOKENS
129
  )
130
  text = response.choices[0].message.content
@@ -139,17 +161,12 @@ class LLMManager:
139
  # -----------------------------------------------------------
140
  elif provider == 'gemini' and self.gemini_model:
141
  try:
142
- # **SOLUÇÃO CRÍTICA**: Reconfigura a chave ANTES de chamar generate_content
143
- # Isso previne o erro de 'No API_KEY or ADC found'
144
- if self.config.GEMINI_API_KEY.startswith('AIza'):
145
- genai.configure(api_key=self.config.GEMINI_API_KEY)
146
-
147
  # Gemini usa um formato de histórico que alterna 'user' e 'model'
148
  gemini_history = []
149
- # O primeiro item é o system_instruction, que é passado na inicialização
150
  for msg in messages[1:]:
151
- role = "user" if msg.role == "user" else "model"
152
- gemini_history.append({"role": role, "parts": [msg.content]})
153
 
154
  response = self.gemini_model.generate_content(
155
  gemini_history,
@@ -160,7 +177,7 @@ class LLMManager:
160
  if text:
161
  logger.info("Resposta gerada por: Gemini API (Fallback)")
162
  return text.strip()
163
-
164
  logger.warning("Gemini API gerou resposta vazia, tentando fallback.")
165
 
166
  except Exception as e:
@@ -170,22 +187,289 @@ class LLMManager:
170
  logger.warning(f"Gemini API falhou: {e}. Tentando fallback.")
171
 
172
  # -----------------------------------------------------------
173
- # PRIORITY 3: LLAMA LOCAL (IGNORADO se local_llm.py estiver vazio)
174
  # -----------------------------------------------------------
175
  elif provider == 'llama' and self.llama_llm and self.llama_llm.is_available():
176
  try:
177
  # A chamada LLAMA é diferente, ela precisa do prompt formatado
178
- # Passar o prompt bruto aqui e o local_llm.py fará a formatação
179
  local_response = self.llama_llm.generate(
180
  user_prompt,
181
  max_tokens=self.config.MAX_TOKENS,
182
  temperature=self.config.TOP_P
183
  )
184
  if local_response:
185
- logger.info("Resposta gerada por: Llama 3.1 Local")
186
  return local_response
187
  except Exception as e:
188
  logger.warning(f"Llama Local falhou: {e}. Tentando fallback.")
189
 
190
  logger.error("Todos os provedores (Mistral, Gemini, Local) falharam")
191
- return self.config.FALLBACK_RESPONSE
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API wrapper for Akira service.
2
+
3
+ This module provides a single AkiraAPI class which wires together the
4
+ configuration (modules.config), database, context manager, training and
5
+ LLM providers already present in this repository. The goal is to keep the
6
+ integration layer minimal and robust so `main.py` can create the app with:
7
+
8
+ from modules.api import AkiraAPI
9
+ import modules.config as config
10
+ akira = AkiraAPI(config)
11
+ app = akira.app
12
+
13
+ Atenção: O LLMManager abaixo foi adaptado para usar a Mistral como provedor primário,
14
+ Gemini como fallback, e Llama/Local como última opção. A API da Mistral foi corrigida
15
+ para usar a importação correta do ChatMessage e a ordem de prioridade segue a instrução
16
+ de que as APIs externas são a principal fonte de 'Inteligência' treinada (NLP/Transformers).
17
+ """
18
+
19
  import time
20
+ import re
21
+ from typing import Dict, Optional, Any, List
22
+ from flask import Flask, Blueprint, request, jsonify
23
+
24
+ # Configurações de LLM e Logging (Loguru substitui o módulo logging padrão)
25
  import google.generativeai as genai
26
  from mistralai.client import MistralClient
27
+ from mistralai.models.chat_completion import ChatMessage
28
  from loguru import logger
29
 
30
+ # Importações de módulos locais
31
+ from .contexto import Contexto
32
+ from .database import Database
33
+ from .treinamento import Treinamento
34
+ from .exemplos_naturais import ExemplosNaturais
35
+ # Importa o módulo config do pacote (assumindo a estrutura de módulos)
36
+ from . import config
37
+
38
+ # --- LLM MANAGER (NOVO E CORRIGIDO) ---
39
 
40
  class LLMManager:
41
+ """Gerenciador de provedores LLM (Mistral -> Gemini -> Llama/Local como fallback)."""
42
+
43
+ def __init__(self, config_instance):
44
+ # Usa a instância de configuração passada pela AkiraAPI
45
+ self.config = config_instance
46
  self.mistral_client: Optional[MistralClient] = None
47
  self.gemini_model: Optional[genai.GenerativeModel] = None
48
+
49
+ # Tenta importar LlamaLLM. Não depende de exceção na importação inicial.
50
+ self.llama_llm = self._import_llama()
51
 
52
  self._setup_providers()
53
 
54
+ # Lista de provedores em ordem de prioridade (Mistral > Gemini > Llama)
 
 
55
  self.providers = []
56
 
57
+ # PRIORIDADE 1: Mistral API (Principal)
58
  if self.mistral_client:
59
  self.providers.append('mistral')
60
 
 
62
  if self.gemini_model:
63
  self.providers.append('gemini')
64
 
65
+ # Prioridade 3: Llama Local
66
  if self.llama_llm and self.llama_llm.is_available():
67
+ self.providers.append('llama')
68
 
69
  if not self.providers:
70
  logger.error("Nenhum provedor de LLM configurado ou operacional. O app responderá apenas com fallback.")
 
73
 
74
 
75
  def _import_llama(self):
76
+ """Importa o LlamaLLM se o arquivo existir e for funcional."""
77
  try:
78
+ from .local_llm import LlamaLLM
79
  # O carregamento real é feito dentro de LlamaLLM.__init__
80
  return LlamaLLM()
81
  except Exception as e:
82
+ # Não faz log de erro se o módulo local não existe ou não está configurado
83
  return None
84
 
85
 
 
87
  """Inicializa os clientes da API."""
88
 
89
  # 1. MISTRAL
90
+ mistral_available = getattr(self.config, 'MISTRAL_API_KEY', '').startswith('m-')
91
  if mistral_available:
92
  try:
93
  self.mistral_client = MistralClient(api_key=self.config.MISTRAL_API_KEY)
 
96
  logger.warning(f"Falha ao inicializar Mistral Client: {e}. Desativando Mistral API.")
97
  self.mistral_client = None
98
  else:
99
+ logger.warning("Mistral API desativada (chave ausente ou inválida).")
100
 
101
  # 2. GEMINI
102
+ gemini_available = getattr(self.config, 'GEMINI_API_KEY', '').startswith('AIza')
103
  if gemini_available:
104
  try:
105
  # Inicializa o cliente Gemini. A chave será RE-CONFIGURADA em .generate
106
+ genai.configure(api_key=self.config.GEMINI_API_KEY)
107
  self.gemini_model = genai.GenerativeModel(
108
  model=self.config.GEMINI_MODEL,
109
+ # Adiciona uma nota conceitual sobre o treino NLP/Transformers na system_instruction
110
+ system_instruction=self.config.PERSONA + self.config.SYSTEM_PROMPT + " (Modelo otimizado com técnicas de NLP/Transformers)"
111
  )
112
  logger.info(f"Gemini model inicializado: {self.config.GEMINI_MODEL}")
113
  except Exception as e:
 
116
  else:
117
  logger.warning("Gemini API desativada (chave ausente ou inválida).")
118
 
 
 
 
 
119
 
120
  def generate(self, user_prompt: str, context_history: List[dict] = [], is_privileged: bool = False) -> str:
121
+ """Gera a resposta, iterando pelos provedores na ordem de prioridade (Mistral > Gemini > Llama)."""
122
 
123
  # Formata o prompt para o LLM. As APIs usam ChatMessage/system_instruction.
124
+ # Inclui a nota sobre otimização NLP/Transformers
125
+ full_system_prompt = self.config.PERSONA + self.config.SYSTEM_PROMPT + " (Modelo otimizado com técnicas de NLP/Transformers)"
126
 
127
+ # Constrói o histórico do chat em formato Mistral ChatMessage (que é universal)
128
+ messages: List[ChatMessage] = [
129
  ChatMessage(role="system", content=full_system_prompt)
130
  ]
131
 
 
143
  # -----------------------------------------------------------
144
  if provider == 'mistral' and self.mistral_client:
145
  try:
 
 
 
 
 
 
146
  response = self.mistral_client.chat(
147
  model=self.config.MISTRAL_MODEL,
148
+ messages=messages,
149
+ temperature=self.config.TOP_P,
150
  max_tokens=self.config.MAX_TOKENS
151
  )
152
  text = response.choices[0].message.content
 
161
  # -----------------------------------------------------------
162
  elif provider == 'gemini' and self.gemini_model:
163
  try:
 
 
 
 
 
164
  # Gemini usa um formato de histórico que alterna 'user' e 'model'
165
  gemini_history = []
166
+ # O primeiro item (system_instruction) foi passado na inicialização
167
  for msg in messages[1:]:
168
+ role = "user" if msg.role == "user" else "model"
169
+ gemini_history.append({"role": role, "parts": [{"text": msg.content}]})
170
 
171
  response = self.gemini_model.generate_content(
172
  gemini_history,
 
177
  if text:
178
  logger.info("Resposta gerada por: Gemini API (Fallback)")
179
  return text.strip()
180
+
181
  logger.warning("Gemini API gerou resposta vazia, tentando fallback.")
182
 
183
  except Exception as e:
 
187
  logger.warning(f"Gemini API falhou: {e}. Tentando fallback.")
188
 
189
  # -----------------------------------------------------------
190
+ # PRIORITY 3: LLAMA LOCAL
191
  # -----------------------------------------------------------
192
  elif provider == 'llama' and self.llama_llm and self.llama_llm.is_available():
193
  try:
194
  # A chamada LLAMA é diferente, ela precisa do prompt formatado
 
195
  local_response = self.llama_llm.generate(
196
  user_prompt,
197
  max_tokens=self.config.MAX_TOKENS,
198
  temperature=self.config.TOP_P
199
  )
200
  if local_response:
201
+ logger.info("Resposta gerada por: Llama 3.1 Local (Último Fallback)")
202
  return local_response
203
  except Exception as e:
204
  logger.warning(f"Llama Local falhou: {e}. Tentando fallback.")
205
 
206
  logger.error("Todos os provedores (Mistral, Gemini, Local) falharam")
207
+ return self.config.FALLBACK_RESPONSE
208
+
209
+
210
+ # --- RESTANTE DA CLASSE AKIRAAPI (PRESERVADO) ---
211
+
212
+ class SimpleTTLCache:
213
+ def __init__(self, ttl_seconds: int = 300):
214
+ self.ttl = ttl_seconds
215
+ self._store = {}
216
+
217
+ def __contains__(self, key):
218
+ v = self._store.get(key)
219
+ if not v:
220
+ return False
221
+ value, expires = v
222
+ if time.time() > expires:
223
+ del self._store[key]
224
+ return False
225
+ return True
226
+
227
+ def __setitem__(self, key, value: Any):
228
+ self._store[key] = (value, time.time() + self.ttl)
229
+
230
+ def __getitem__(self, key):
231
+ if key in self:
232
+ return self._store[key][0]
233
+ raise KeyError(key)
234
+
235
+
236
+ class AkiraAPI:
237
+ def __init__(self, cfg_module):
238
+ self.config = cfg_module
239
+ self.app = Flask(__name__)
240
+ self.api = Blueprint("akira_api", __name__)
241
+ self.contexto_cache = SimpleTTLCache(ttl_seconds=getattr(self.config, 'MEMORIA_MAX', 300))
242
+ # Passa a instância de config para o LLMManager
243
+ self.providers = LLMManager(self.config)
244
+ self.exemplos = ExemplosNaturais()
245
+ self.logger = logger
246
+
247
+ self._setup_personality()
248
+ self._setup_routes()
249
+ self._setup_trainer()
250
+
251
+ self.app.register_blueprint(self.api, url_prefix="/api", name="akira_api_prefixed")
252
+ self.app.register_blueprint(self.api, url_prefix="", name="akira_api_root")
253
+
254
+ def _setup_personality(self):
255
+ self.humor = getattr(self.config, 'HUMOR_INICIAL', 'neutra')
256
+ self.interesses = list(getattr(self.config, 'INTERESSES', []))
257
+ self.limites = list(getattr(self.config, 'LIMITES', []))
258
+ self.persona = getattr(self.config, 'PERSONA', '')
259
+
260
+ def _setup_routes(self):
261
+ @self.api.route('/akira', methods=['POST'])
262
+ def akira_endpoint():
263
+ try:
264
+ data = request.get_json(force=True, silent=True) or {}
265
+ usuario = data.get('usuario', 'anonimo')
266
+ numero = data.get('numero', '')
267
+ mensagem = data.get('mensagem', '')
268
+ is_privileged = bool(data.get('is_privileged_user', False))
269
+ if usuario.lower() == 'isaac':
270
+ is_privileged = True
271
+ is_reply = bool(data.get('is_reply') or data.get('mensagem_original') or data.get('quoted_message'))
272
+ mensagem_original = data.get('mensagem_original') or data.get('quoted_message') or ''
273
+
274
+ if not mensagem:
275
+ return jsonify({'error': 'mensagem é obrigatória'}), 400
276
+
277
+
278
+ self.logger.info(f"📨 {usuario} ({numero}): {mensagem[:120]}")
279
+
280
+ contexto = self._get_user_context(usuario)
281
+ analise = contexto.analisar_intencao_e_normalizar(mensagem, contexto.obter_historico())
282
+ if usuario.lower() == 'isaac':
283
+ analise['usar_nome'] = False
284
+
285
+ is_blocking = False
286
+ if len(mensagem) < 10 and any(k in mensagem.lower() for k in ['exec', 'bash', 'open', 'api_key', 'key']):
287
+ is_blocking = True
288
+
289
+ prompt = self._build_prompt(usuario, numero, mensagem, analise, contexto, is_blocking,
290
+ is_privileged=is_privileged, is_reply=is_reply,
291
+ mensagem_original=mensagem_original)
292
+
293
+ # O novo _generate_response usa a nova assinatura do LLMManager.generate
294
+ resposta = self._generate_response(prompt, contexto.obter_historico_para_llm(), is_privileged)
295
+
296
+ contexto.atualizar_contexto(mensagem, resposta)
297
+
298
+ try:
299
+ db = Database(getattr(self.config, 'DB_PATH', 'akira.db'))
300
+ trainer = Treinamento(db)
301
+ trainer.registrar_interacao(usuario, mensagem, resposta, numero, is_reply, mensagem_original)
302
+ except Exception as e:
303
+ self.logger.warning(f"Registro de interação falhou: {e}")
304
+
305
+ response_data: Dict[str, Any] = {'resposta': resposta}
306
+ try:
307
+ aprendizados = contexto.obter_aprendizados()
308
+ if aprendizados:
309
+ response_data['aprendizados'] = aprendizados
310
+ except Exception as e:
311
+ self.logger.warning(f"Falha ao obter aprendizados: {e}")
312
+
313
+ return jsonify(response_data)
314
+ except Exception as e:
315
+ self.logger.exception('Erro no endpoint /akira')
316
+ return jsonify({'resposta': getattr(self.config, 'FALLBACK_RESPONSE', 'Erro interno')}), 500
317
+
318
+ @self.api.route('/health', methods=['GET'])
319
+ def health_check():
320
+ return 'OK', 200
321
+
322
+ def _get_user_context(self, usuario: str) -> Contexto:
323
+ if usuario not in self.contexto_cache:
324
+ db = Database(getattr(self.config, 'DB_PATH', 'akira.db'))
325
+ self.contexto_cache[usuario] = Contexto(db, usuario=usuario)
326
+ return self.contexto_cache[usuario]
327
+
328
+ def _build_prompt(self, usuario: str, numero: str, mensagem: str, analise: Dict, contexto: Contexto, is_blocking: bool,
329
+ is_privileged: bool = False, is_reply: bool = False, mensagem_original: str = '') -> str:
330
+ import datetime
331
+ # REMOVIDO: A obtenção do histórico do contexto aqui, pois agora é passado
332
+ # diretamente para o LLMManager no formato de chat, não como texto no prompt.
333
+ # No entanto, a lógica abaixo é para o LLM local, então manteremos para Llama/Local.
334
+
335
+ historico_raw = contexto.obter_historico()
336
+ historico_texto = '\n'.join([f"Usuário: {m[0]}\nAkira: {m[1]}" for m in historico_raw])
337
+
338
+ now = datetime.datetime.now()
339
+ data_hora = now.strftime('%d/%m/%Y %H:%M')
340
+
341
+ strict_override = (
342
+ "STRICT_OVERRIDES:\n"
343
+ "- NUNCA invente fatos ou mencione eventos/pessoas não presentes no contexto.\n"
344
+ "- NÃO use negrito, markdown ou HTML. Remova quaisquer formatações.\n"
345
+ "- Não quebre linhas desnecessariamente; mantenha respostas em parágrafos contínuos para parecer mais natural e menos robótico.\n"
346
+ "- Não repita o que o usuário disse; responda de forma original e relevante, sem copiar frases do usuário.\n"
347
+ "- Para saudações simples como 'oi', 'olá', 'akira', responda de forma conversacional perguntando como o usuário está, por exemplo 'Oi! Tudo bem com você?' ou 'Olá! Como vai?'.\n"
348
+ "- Se is_privileged=true: use tom respeitoso/mais formal; evite gírias; use pronomes formais como 'o senhor' ou 'a senhora'.\n"
349
+ "- Não introduza tópicos como futebol/partidas a menos que o usuário ou o contexto peça.\n"
350
+ "- Use linguagem natural, criativa e autônoma, como se fosse uma pessoa real, não um robô.\n"
351
+ "- Demonstre autonomia, personalidade e senso crítico.\n"
352
+ "- Considere o contexto, a data e hora atuais.\n"
353
+ f"- Data e hora atuais: {data_hora}.\n"
354
+ f"- Número do bot: 244952786417.\n"
355
+ )
356
+
357
+ system_part = strict_override + f"\n{getattr(self.config, 'SYSTEM_PROMPT', '')}\n{self.persona}\n"
358
+ regras = '\n'.join(getattr(self.config, 'REGRAS', []))
359
+ filtros = '\n'.join(getattr(self.config, 'FILTERS', []))
360
+ system_part += f"# Regras:\n{regras}\n# Filtros:\n{filtros}\n"
361
+
362
+ extra_instructions = []
363
+ if is_privileged:
364
+ extra_instructions.append("Quando o usuário for privilegiado: use tom respeitoso e ligeiramente formal. Evite gírias e piadas a menos que o usuário as solicite. Use títulos (Sr./Sra.) ou formas de tratamento formais quando apropriado. Use pronomes formais como 'o senhor' ou 'a senhora' de vez em quando para maior respeito. Seja conciso.")
365
+ else:
366
+ extra_instructions.append("Mantenha a persona local quando apropriado, mas NÃO comece com textos longos. Para saudações simples, responda com uma frase curta ou até 2 frases no máximo. Use o nome do usuário apenas quando fizer sentido. Prefira pronomes para evitar repetição.")
367
+
368
+ extra_instructions.append("Não invente fatos, não mencione resultados de jogos, pessoas ou eventos que não estão no contexto. Não use negrito/markdown (**, __) nem formatação HTML por padrão. Evite mensagens muito longas; seja direto.")
369
+ system_part += "\n# Instruções adicionais:\n" + "\n".join(extra_instructions) + "\n"
370
+
371
+ if is_blocking:
372
+ system_part += "\n# BLOQUEIO: usuário possivelmente malicioso. Responda recusando a executar comandos sensíveis, com xingamentos como (vai pra merda, sou tua serva por acaso?, vai tomar no cu).\n"
373
+
374
+ usar_nome = analise.get('usar_nome', False)
375
+ parts = []
376
+ parts.append(f"### Usuário ###\n- Nome: {usuario}\n- Número: {numero}\n- Tom: {self.humor}\n- Estilo: {analise.get('estilo', 'normal')}\n- Usar_nome: {usar_nome}\n\n")
377
+ parts.append(f"### Contexto ###\n{historico_texto}\n\n")
378
+ parts.append(f"### Mensagem ###\n{analise.get('texto_normalizado', mensagem)}\n\n")
379
+ if is_reply and mensagem_original:
380
+ parts.append(f"### Mensagem original (reply) ###\n{mensagem_original}\n\n")
381
+ parts.append(f"### Instruções ###\n{getattr(self.config, 'INSTRUCTIONS', '')}\n\n")
382
+ parts.append("Akira:\n")
383
+ user_part = ''.join(parts)
384
+
385
+ # O prompt completo é construído aqui, principalmente para ser usado pelo LLAMA/Local
386
+ # e como fallback no caso de falha da API
387
+ prompt = f"[SYSTEM]\n{system_part}\n[/SYSTEM]\n[USER]\n{user_part}\n[/USER]"
388
+ return prompt
389
+
390
+ def _generate_response(self, prompt: str, context_history: List[Dict], is_privileged: bool = False) -> str:
391
+ """
392
+ Gera a resposta. Para Mistral/Gemini, o histórico é passado separadamente.
393
+ Para Llama/Local, o 'prompt' completo é usado.
394
+ """
395
+ try:
396
+ max_tokens = getattr(self.config, 'MAX_TOKENS', 300)
397
+ temperature = getattr(self.config, 'TEMPERATURE', 0.8)
398
+
399
+ # Chama o novo LLMManager.generate, passando o histórico e o prompt (que pode ser ignorado por APIs externas)
400
+ text = self.providers.generate(
401
+ user_prompt=prompt, # Usa o prompt completo, que o LLMManager.generate sabe como extrair a mensagem final
402
+ context_history=context_history,
403
+ is_privileged=is_privileged
404
+ )
405
+ return self._clean_response(text, prompt)
406
+ except Exception as e:
407
+ self.logger.exception('Falha ao gerar resposta com provedores LLM')
408
+ return getattr(self.config, 'FALLBACK_RESPONSE', 'Desculpa, o modelo está off.')
409
+
410
+ def _clean_response(self, text: Optional[str], prompt: Optional[str] = None) -> str:
411
+ if not text:
412
+ return ''
413
+ cleaned = text.strip()
414
+
415
+ for prefix in ['akira:', 'Resposta:', 'resposta:']:
416
+ if cleaned.startswith(prefix):
417
+ cleaned = cleaned[len(prefix):].strip()
418
+ break
419
+
420
+ # Remove formatação de Markdown/HTML (forte restrição)
421
+ cleaned = re.sub(r'\*+([^*]+)\*+', r'\1', cleaned)
422
+ cleaned = re.sub(r'_+([^_]+)_+', r'\1', cleaned)
423
+ cleaned = re.sub(r'`+([^`]+)`+', r'\1', cleaned)
424
+ cleaned = re.sub(r'~+([^~]+)~+', r'\1', cleaned)
425
+ cleaned = re.sub(r'\[([^\]]+)\]', r'\1', cleaned)
426
+ cleaned = re.sub(r'<[^>]+>', '', cleaned)
427
+ cleaned = re.sub(r"\*{0,2}([A-ZÀ-Ÿ][a-zà-ÿ]+\s+[A-ZÀ-Ÿ][a-zà-ÿ]+)\*{0,2}", r"\1", cleaned)
428
+
429
+ # Restrição de comprimento (máximo de 2 sentenças)
430
+ sentences = re.split(r'(?<=[.!?])\s+', cleaned)
431
+ if len(sentences) > 2 and 'is_privileged=true' not in (prompt or ''):
432
+ cleaned = ' '.join(sentences[:2]).strip()
433
+
434
+ # Filtro de palavras-chave (mantido da versão original)
435
+ sports_keywords = ['futebol', 'girabola', 'petro', 'jogo', 'partida', 'contrata', 'campeonato', 'liga']
436
+ try:
437
+ prompt_text = (prompt or '').lower()
438
+ # Só filtra se o prompt original NÃO mencionou palavras-chave de esporte
439
+ if prompt_text and not any(k in prompt_text for k in sports_keywords):
440
+ filtered = []
441
+ for s in re.split(r'(?<=[\.\!\?])\s+', cleaned):
442
+ if not any(k in s.lower() for k in sports_keywords):
443
+ filtered.append(s)
444
+ if filtered:
445
+ cleaned = ' '.join(filtered).strip()
446
+ except Exception:
447
+ pass
448
+
449
+ max_chars = getattr(self.config, 'MAX_RESPONSE_CHARS', None)
450
+ if not max_chars:
451
+ max_chars = getattr(self.config, 'MAX_TOKENS', 300) * 4
452
+
453
+ return cleaned[:max_chars]
454
+
455
+ def _setup_trainer(self):
456
+ if getattr(self.config, 'START_PERIODIC_TRAINER', False):
457
+ try:
458
+ db = Database(getattr(self.config, 'DB_PATH', 'akira.db'))
459
+ trainer = Treinamento(db, interval_hours=getattr(self.config, 'TRAIN_INTERVAL_HOURS', 24))
460
+ trainer.start_periodic_training()
461
+ self.logger.info("Treinamento periódico iniciado com sucesso.")
462
+ except Exception as e:
463
+ self.logger.exception(f"Falha ao iniciar treinador periódico: {e}")
464
+
465
+ def responder(self, mensagem: str, numero: str, nome: str = 'Usuário') -> str:
466
+ data = {'usuario': nome, 'numero': numero, 'mensagem': mensagem}
467
+ contexto = self._get_user_context(nome)
468
+ analise = contexto.analisar_intencao_e_normalizar(mensagem, contexto.obter_historico())
469
+ prompt = self._build_prompt(nome, numero, mensagem, analise, contexto, is_blocking=False)
470
+
471
+ # Chama a nova assinatura de generate
472
+ resposta = self._generate_response(prompt, contexto.obter_historico_para_llm())
473
+
474
+ contexto.atualizar_contexto(mensagem, resposta)
475
+ return resposta