akra35567 commited on
Commit
02b47b2
·
1 Parent(s): 80682a9

Update modules/api.py

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