akra35567 commited on
Commit
c227199
·
1 Parent(s): 7d71978

Update modules/api.py

Browse files
Files changed (1) hide show
  1. modules/api.py +367 -238
modules/api.py CHANGED
@@ -1,293 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
- API wrapper for Akira service - VERSÃO FINAL INDESTRUTÍVEL
3
- - SEM request.json
4
- - SEM data.get em string
5
- - Usa form + args + get_data → 100% seguro
6
- - Contexto(usuario=key)
7
- """
8
- from typing import Any, Tuple, Dict
9
  import time
 
10
  import re
11
- import datetime
12
- import random
13
- import json
14
- from flask import Flask, Blueprint, request
15
- from loguru import logger
16
-
17
- # Importa as classes auxiliares e a configuração
18
- import modules.config as config
19
  from .contexto import Contexto
20
  from .database import Database
21
  from .treinamento import Treinamento
22
- from .web_search import WebSearch
23
- from .local_llm import LlamaLLM
 
 
 
 
 
 
 
 
 
24
 
25
- # Módulos LLMs (Verificando disponibilidade)
26
  try:
27
  from mistralai import Mistral
28
  mistral_available = True
29
  except ImportError:
30
  mistral_available = False
31
 
 
 
32
  try:
33
  import google.generativeai as genai
34
  gemini_available = True
35
  except ImportError:
36
  gemini_available = False
 
37
 
38
 
39
  class LLMManager:
40
- """Gerencia a chamada aos modelos LLM na ordem: Mistral > LlamaLLM > Gemini."""
41
- def __init__(self, cfg):
42
- self.config = cfg
43
- self.llama = LlamaLLM()
44
  self.mistral_client = None
45
  self.gemini_model = None
46
- self._setup()
47
 
48
- def _setup(self):
49
- # TENTA 1: MISTRAL (Principal)
50
- if mistral_available and self.config.MISTRAL_API_KEY:
 
51
  try:
52
  self.mistral_client = Mistral(api_key=self.config.MISTRAL_API_KEY)
53
- logger.info("Mistral OK")
54
- except: self.mistral_client = None
 
55
 
56
- # TENTA 3: GEMINI (Último Fallback)
57
- if gemini_available and self.config.GEMINI_API_KEY:
58
  try:
59
  genai.configure(api_key=self.config.GEMINI_API_KEY)
60
- self.gemini_model = genai.GenerativeModel(self.config.GEMINI_MODEL)
61
- logger.info("Gemini OK")
62
- except: self.gemini_model = None
63
-
64
- def _limpar(self, t: str) -> str:
65
- """Limpa a formatação (markdown) e limita o tamanho da resposta."""
66
- if not t: return ""
67
- # Limpa formatação comum do LLM
68
- t = re.sub(r'[\*\_\`\[\]\"\<\>]', '', t)
69
- t = re.sub(r'\s+', ' ', t.replace('\n', ' ')).strip()
70
-
71
- # Lógica de limite de frases para garantir 1-2 frases
72
- if len(t) > 280:
73
- s = [f.strip() for f in re.split(r'(\.|\!|\?)', t) if f.strip() not in ('.', '!', '?')]
74
- c = ""
75
- for f in s:
76
- # Reconstroi a frase com pontuação no final se possível
77
- sentence = f + t[len(c + f):].split(f)[1].split(' ')[0] if t[len(c + f):].split(f)[1].split(' ')[0] in ('.', '!', '?') else f
78
- if len(c + sentence + " ") <= 280:
79
- c += sentence + " "
80
- else:
81
- break
82
- t = c.strip()
83
- if not t.endswith(('.', '!', '?')): t += "..."
84
- return t
85
-
86
- def generate(self, p: str, mx: int = 500, tp: float = 0.8) -> str:
87
- """Tenta gerar a resposta na ordem: Mistral -> Llama -> Gemini."""
88
- # Se 6 tentativas falharem em todos, retorna fallback
89
- for _ in range(6):
90
- # TENTA 1: MISTRAL
91
- if self.mistral_client:
92
- try:
93
- r = self.mistral_client.chat.complete(
94
- model=self.config.MISTRAL_MODEL,
95
- messages=[{"role": "user", "content": p}],
96
- max_tokens=mx, temperature=tp, top_p=self.config.TOP_P
97
- )
98
- t = r.choices[0].message.content
99
- if t: return self._limpar(t)
100
- except: pass
101
 
102
- # TENTA 2: LLAMA (Local - se o modelo estiver carregado)
103
- if self.llama.model:
 
 
 
 
104
  try:
105
- r = self.llama.generate(p, mx)
106
- if r.strip(): return self._limpar(r)
107
- except: pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
- # TENTA 3: GEMINI
110
- if self.gemini_model:
111
  try:
112
- r = self.gemini_model.generate_content(
113
- p,
114
- generation_config={"max_output_tokens": mx, "temperature": tp, "top_p": self.config.TOP_P}
 
 
 
115
  )
116
- t = r.text
117
- if t: return self._limpar(t)
118
- except: pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
- return self.config.FALLBACK_RESPONSE
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
 
122
 
123
  class AkiraAPI:
124
- """API principal com toda a lógica de contexto e roteamento."""
125
- def __init__(self, cfg):
126
- self.config = cfg
127
  self.app = Flask(__name__)
128
  self.api = Blueprint("akira_api", __name__)
129
- self.db = Database(getattr(cfg, 'DB_PATH', '/app/data/akira.db'))
130
- self.cache = {} # Cache para Contexto
131
- self.llm = LLMManager(cfg)
132
- self.treinador = Treinamento(self.db)
133
- self.web = WebSearch()
 
 
 
 
 
134
  self._setup_routes()
 
135
 
136
- self.app.register_blueprint(self.api, url_prefix="/api", name="api1")
137
- self.app.register_blueprint(self.api, url_prefix="", name="api2")
138
-
139
- if getattr(cfg, 'START_PERIODIC_TRAINER', False):
140
- self.treinador.start_periodic_training()
141
-
142
- def _ctx(self, u: str, n: str) -> Contexto:
143
- """Obtém ou cria o Contexto do usuário. (CORRIGIDO)"""
144
- k = n or u
145
- if k not in self.cache:
146
- # CORREÇÃO CRÍTICA: Argumento mudado de 'user_key' para 'usuario'
147
- c = Contexto(self.db, usuario=k)
148
- c.atualizar_aprendizados_do_banco()
149
- self.cache[k] = c
150
- return self.cache[k]
151
-
152
- def _pronomes(self, t: str) -> str:
153
- """Busca pronomes adequados ao tom no banco de dados."""
154
- # Supondo que 'obter_pronomes_por_tom' existe na classe Database (ou usando um fallback)
155
- try:
156
- r = self.db._execute_with_retry("SELECT pronomes FROM pronomes_por_tom WHERE tom=?", (t.lower(),))
157
- return r[0][0] if r and r[0] else ""
158
- except Exception:
159
- return ""
160
-
161
-
162
- def _prompt(self, u: str, n: str, m: str, e: str, c: Contexto, p: bool, r: bool, o: str) -> str:
163
- """Constrói o prompt completo, buscando notícias (self.web)."""
164
- d = datetime.datetime.now().strftime("%d/%m/%Y %H:%M")
165
- news = self.web.pesquisar_noticias_angola()
166
- tom = c.ton_predominante or "neutro"
167
- pro = self._pronomes(tom)
168
-
169
- s = f"""
170
- REGRAS:
171
- - FALE COMO GENTE (Tom Angolano, informal)
172
- - SEJA DIRETO, NÃO ENROLE: Responda a pergunta EXATAMENTE. MÁXIMO 1-2 FRASES.
173
- - PRIORIDADE DE INFORMAÇÃO: Se a pergunta exigir dados atuais (Ex: "Que dia é hoje?", "Vai chover?"), use as #NOTÍCIAS ou a DATA.
174
- - GÍRIAS: bué, fixe, puto, mano, ya (use naturalmente)
175
- - SARCASMO OK
176
- - USE kkk, rsrs
177
- - SEM markdown
178
- - NOME ~10% (Use APENAS o primeiro nome)
179
- - DATA: {d}
180
- # NOTÍCIAS (Angola):
181
- {news}
182
- # PRONOMES ({tom}):
183
- {pro}
184
- # EMOÇÃO: {e}
185
- """
186
- if p: s += "- TOM FORMAL: Sr., boss\n"
187
- else: s += "- TOM NORMAL: Luanda\n"
188
-
189
- h = c.obter_historico()[-3:]
190
- ht = "\n".join([f"U: {x[0]}\nA: {x[1]}" for x in h]) if h else ""
191
-
192
- ui = f"""
193
- USUÁRIO:
194
- - Nome: {u}
195
- - Número: {n}
196
- - Tom: {tom}
197
- - VIP: {'sim' if p else 'não'}
198
- """
199
-
200
- # config.SYSTEM_PROMPT e config.PERSONA vêm do módulo importado
201
- return f"[SYSTEM]\n{s}\n{config.SYSTEM_PROMPT}\n{config.PERSONA}\n[/SYSTEM]\n[CONTEXTO]\n{ht}\n{ui}\n[/CONTEXTO]\n[MENSAGEM]\n{m}\n[/MENSAGEM]\nAkira:"
202
 
203
  def _setup_routes(self):
204
- """Configura os endpoints da API (usando 'endpoint' como nome da função)."""
205
  @self.api.route('/akira', methods=['POST'])
206
- @self.api.route('/', methods=['POST'])
207
- def endpoint() -> Tuple[Any, int]:
208
  try:
209
- # 1. TENTA OBTER DE FORM/ARGS
210
- usuario = request.form.get('usuario', '').strip() or request.args.get('usuario', '').strip() or 'anonimo'
211
- numero = request.form.get('numero', '').strip() or request.args.get('numero', '').strip()
212
- mensagem = request.form.get('mensagem', '').strip() or request.args.get('mensagem', '').strip()
213
-
214
- # 2. SE VEIO JSON NO BODY, TENTA EXTRAIR
215
- if request.content_type and 'json' in request.content_type.lower():
216
- try:
217
- raw = request.get_data(as_text=True)
218
- body = json.loads(raw)
219
- if isinstance(body, dict):
220
- usuario = body.get('usuario') or usuario
221
- numero = body.get('numero') or numero
222
- mensagem = body.get('mensagem') or mensagem
223
- except: pass
224
 
225
  if not mensagem:
226
- return {"resposta": "mensagem obrigatória"}, 400
227
 
228
- # Normalização e checagem de privilégios
229
- is_priv = usuario.lower() in [u.lower() for u in config.PRIVILEGED_USERS] or numero in config.PRIVILEGED_USERS
230
- is_reply = bool(request.form.get('is_reply') or request.form.get('mensagem_original') or request.args.get('is_reply') or request.args.get('mensagem_original'))
231
- orig = request.form.get('mensagem_original', '') or request.form.get('quoted_message', '') or request.args.get('mensagem_original', '') or request.args.get('quoted_message', '')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
 
233
- logger.info(f"{usuario} ({numero}): {mensagem[:120]}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
 
235
- # --- PROCESSAMENTO ---
236
- ctx = self._ctx(usuario, numero)
237
-
238
- emo = ctx.analisar_emocoes_mensagem(mensagem)
239
- emocao_str = emo.get('emocao', 'neutra') if isinstance(emo, dict) else 'neutra'
240
-
241
- prompt = self._prompt(usuario, numero, mensagem, emocao_str, ctx, is_priv, is_reply, orig)
242
- resp = self.llm.generate(prompt)
243
-
244
- # Aplica probabilidade de uso do nome
245
- first_name = usuario.split(' ')[0].strip()
246
- # Reduz probabilidade de 0.4 para 0.1 (muito raramente) e usa o primeiro nome
247
- if random.random() < getattr(self.config, 'USAR_NOME_PROBABILIDADE', 0.1):
248
- if random.random() < 0.5:
249
- # Coloca nome na frente (ex: "Isaac, estás bem?")
250
- resp = f"{first_name}, {resp[0].lower() + resp[1:]}"
251
- else:
252
- # Coloca nome atrás (ex: "Tudo fixe, Isaac")
253
- resp = f"{resp}, {first_name}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
 
255
- # Atualiza
256
- ctx.atualizar_contexto(mensagem, resp)
257
- self.treinador.registrar_interacao(usuario, mensagem, resp, numero, is_reply, orig)
258
 
259
- # Retorno dos aprendizados
260
- termos = []
261
- girias = []
262
- try:
263
- t = getattr(ctx, 'termo_contexto', [])
264
- if isinstance(t, str):
265
- t = json.loads(t)
266
- if isinstance(t, list): termos = t
267
-
268
- g = getattr(ctx, 'girias_aprendidas', [])
269
- if isinstance(g, str):
270
- g = json.loads(g)
271
- if isinstance(g, list):
272
- girias = [x.get('giria', '') for x in g[:3] if isinstance(x, dict)]
273
- except: pass
274
-
275
- return {
276
- "resposta": resp,
277
- "aprendizados": {
278
- "emocao_atual": getattr(ctx, 'emocao_atual', 'neutra'),
279
- "termos": termos,
280
- "gírias": girias
281
- }
282
- }, 200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
 
 
 
 
 
 
 
 
 
 
 
284
  except Exception as e:
285
- logger.error(f"Erro: {e}", exc_info=True)
286
- return {"resposta": getattr(self.config, 'FALLBACK_RESPONSE', 'erro, puto. tenta depois.')}, 500
287
-
288
- def run(self, h='0.0.0.0', p=7860, d=False):
289
- """Inicia o servidor Flask."""
290
- host = getattr(self.config, 'API_HOST', h)
291
- port = getattr(self.config, 'API_PORT', p)
292
- logger.info(f"Rodando na porta {port}")
293
- self.app.run(host=host, port=port, debug=d, threaded=True)
 
 
 
 
 
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
+ The implementation below avoids depending on missing modules and normalizes
14
+ the config names to the existing `config.py` constants.
15
  """
16
+
17
+ from typing import Dict, Optional, Any
 
 
 
 
 
18
  import time
19
+ import logging
20
  import re
21
+ from flask import Flask, Blueprint, request, jsonify
22
+
 
 
 
 
 
 
23
  from .contexto import Contexto
24
  from .database import Database
25
  from .treinamento import Treinamento
26
+ from .exemplos_naturais import ExemplosNaturais
27
+
28
+ # --- NOVOS IMPORTS PARA WEBSERVICE ---
29
+ try:
30
+ # Assumindo que o web_search está no mesmo diretório de módulos
31
+ from .web_search import WebSearch
32
+ websearch_available = True
33
+ except ImportError:
34
+ websearch_available = False
35
+ logging.warning("WebSearch não disponível. Funcionalidades de busca limitadas.")
36
+ # --------------------------------------
37
 
 
38
  try:
39
  from mistralai import Mistral
40
  mistral_available = True
41
  except ImportError:
42
  mistral_available = False
43
 
44
+ logger = logging.getLogger("akira.api")
45
+
46
  try:
47
  import google.generativeai as genai
48
  gemini_available = True
49
  except ImportError:
50
  gemini_available = False
51
+ logger.warning("google.generativeai não disponível. Gemini desabilitado.")
52
 
53
 
54
  class LLMManager:
55
+ """Gerenciador de provedores LLM (Mistral + Gemini como fallback)."""
56
+
57
+ def __init__(self, config):
58
+ self.config = config
59
  self.mistral_client = None
60
  self.gemini_model = None
61
+ self._setup_providers()
62
 
63
+ def _setup_providers(self):
64
+ # O código local de LLM que exige GPU está fora deste arquivo,
65
+ # focamos apenas nos providers de API externa (Mistral e Gemini)
66
+ if mistral_available and getattr(self.config, 'MISTRAL_API_KEY', None):
67
  try:
68
  self.mistral_client = Mistral(api_key=self.config.MISTRAL_API_KEY)
69
+ logger.info("Mistral client inicializado.")
70
+ except Exception as e:
71
+ logger.warning(f"Falha ao inicializar Mistral: {e}")
72
 
73
+ if gemini_available and getattr(self.config, 'GEMINI_API_KEY', None):
 
74
  try:
75
  genai.configure(api_key=self.config.GEMINI_API_KEY)
76
+ self.gemini_model = genai.GenerativeModel(getattr(self.config, 'GEMINI_MODEL', 'gemini-1.5-flash')) # type: ignore[reportAttributeAccessIssue]
77
+ logger.info("Gemini model inicializado.")
78
+ except Exception as e:
79
+ logger.warning(f"Falha ao inicializar Gemini: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
+ def generate(self, prompt: str, max_tokens: int = 300, temperature: float = 0.8) -> str:
82
+ # A ordem garante que Gemini seja o fallback
83
+ providers = ['mistral', 'gemini']
84
+
85
+ for provider in providers:
86
+ if provider == 'mistral' and self.mistral_client:
87
  try:
88
+ response = self.mistral_client.chat.complete(
89
+ model=getattr(self.config, 'MISTRAL_MODEL', 'mistral-small-latest'),
90
+ messages=[{"role": "user", "content": prompt}],
91
+ max_tokens=max_tokens,
92
+ temperature=temperature
93
+ )
94
+ content = response.choices[0].message.content if response.choices else ""
95
+ return str(content) if content else ""
96
+ except Exception as e:
97
+ error_msg = str(e).lower()
98
+ if "429" in error_msg or "too many requests" in error_msg or "service tier capacity exceeded" in error_msg:
99
+ logger.warning(f"Mistral rate limit, retrying in 1s: {e}")
100
+ time.sleep(1)
101
+ try:
102
+ response = self.mistral_client.chat.complete(
103
+ model=getattr(self.config, 'MISTRAL_MODEL', 'mistral-small-latest'),
104
+ messages=[{"role": "user", "content": prompt}],
105
+ max_tokens=max_tokens,
106
+ temperature=temperature
107
+ )
108
+ content = response.choices[0].message.content if response.choices else ""
109
+ return str(content) if content else ""
110
+ except Exception as e2:
111
+ logger.warning(f"Mistral retry failed: {e2}")
112
+ else:
113
+ logger.warning(f"Mistral falhou: {e}")
114
 
115
+ elif provider == 'gemini' and self.gemini_model:
 
116
  try:
117
+ response = self.gemini_model.generate_content(
118
+ prompt,
119
+ generation_config={
120
+ "max_output_tokens": max_tokens,
121
+ "temperature": temperature
122
+ }
123
  )
124
+ text = response.text
125
+ return text.strip() if text else ""
126
+ except Exception as e:
127
+ error_msg = str(e).lower()
128
+ if "429" in error_msg or "too many requests" in error_msg or "quota exceeded" in error_msg:
129
+ logger.warning(f"Gemini rate limit, retrying in 1s: {e}")
130
+ time.sleep(1)
131
+ try:
132
+ response = self.gemini_model.generate_content(
133
+ prompt,
134
+ generation_config={
135
+ "max_output_tokens": max_tokens,
136
+ "temperature": temperature
137
+ }
138
+ )
139
+ text = response.text
140
+ return text.strip() if text else ""
141
+ except Exception as e2:
142
+ logger.warning(f"Gemini retry failed: {e2}")
143
+ else:
144
+ logger.warning(f"Gemini falhou: {e}")
145
+
146
+ logger.error("Ambos os providers falharam")
147
+ return getattr(self.config, 'FALLBACK_RESPONSE', 'Desculpa, puto, o modelo tá off hoje. Tenta depois!')
148
 
149
+
150
+ class SimpleTTLCache:
151
+ def __init__(self, ttl_seconds: int = 300):
152
+ self.ttl = ttl_seconds
153
+ self._store = {}
154
+
155
+ def __contains__(self, key):
156
+ v = self._store.get(key)
157
+ if not v:
158
+ return False
159
+ value, expires = v
160
+ if time.time() > expires:
161
+ del self._store[key]
162
+ return False
163
+ return True
164
+
165
+ def __setitem__(self, key, value: Any):
166
+ self._store[key] = (value, time.time() + self.ttl)
167
+
168
+ def __getitem__(self, key):
169
+ if key in self:
170
+ return self._store[key][0]
171
+ raise KeyError(key)
172
 
173
 
174
  class AkiraAPI:
175
+ def __init__(self, cfg_module):
176
+ self.config = cfg_module
 
177
  self.app = Flask(__name__)
178
  self.api = Blueprint("akira_api", __name__)
179
+ self.contexto_cache = SimpleTTLCache(ttl_seconds=getattr(self.config, 'MEMORIA_MAX', 300))
180
+ self.providers = LLMManager(self.config)
181
+ self.exemplos = ExemplosNaturais()
182
+ self.logger = logger
183
+
184
+ # --- NOVO: Inicialização do WebSearch ---
185
+ self.web_search = WebSearch() if websearch_available else None
186
+ # ------------------------------------------
187
+
188
+ self._setup_personality()
189
  self._setup_routes()
190
+ self._setup_trainer()
191
 
192
+ self.app.register_blueprint(self.api, url_prefix="/api", name="akira_api_prefixed")
193
+ self.app.register_blueprint(self.api, url_prefix="", name="akira_api_root")
194
+
195
+ def _setup_personality(self):
196
+ self.humor = getattr(self.config, 'HUMOR_INICIAL', 'neutra')
197
+ self.interesses = list(getattr(self.config, 'INTERESSES', []))
198
+ self.limites = list(getattr(self.config, 'LIMITES', []))
199
+ self.persona = getattr(self.config, 'PERSONA', '')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
  def _setup_routes(self):
 
202
  @self.api.route('/akira', methods=['POST'])
203
+ def akira_endpoint():
 
204
  try:
205
+ data = request.get_json(force=True, silent=True) or {}
206
+ usuario = data.get('usuario', 'anonimo')
207
+ numero = data.get('numero', '')
208
+ mensagem = data.get('mensagem', '')
209
+ is_privileged = bool(data.get('is_privileged_user', False))
210
+ if usuario.lower() == 'isaac':
211
+ is_privileged = True
212
+
213
+ # --- NOVO: Extração da mensagem citada (inclui o novo campo do index.js) ---
214
+ mensagem_citada = data.get('mensagem_citada') or data.get('mensagem_original') or data.get('quoted_message') or ''
215
+ is_reply = bool(mensagem_citada)
216
+ # ----------------------------------------------------------------------------
 
 
 
217
 
218
  if not mensagem:
219
+ return jsonify({'error': 'mensagem é obrigatória'}), 400
220
 
221
+
222
+ self.logger.info(f"📨 {usuario} ({numero}): {mensagem[:120]}")
223
+
224
+ contexto = self._get_user_context(usuario)
225
+ analise = contexto.analisar_intencao_e_normalizar(mensagem, contexto.obter_historico())
226
+ if usuario.lower() == 'isaac':
227
+ analise['usar_nome'] = False
228
+
229
+ is_blocking = False
230
+ if len(mensagem) < 10 and any(k in mensagem.lower() for k in ['exec', 'bash', 'open', 'api_key', 'key']):
231
+ is_blocking = True
232
+
233
+ # --- NOVO: passagem do campo mensagem_citada para o build_prompt ---
234
+ prompt = self._build_prompt(usuario, numero, mensagem, analise, contexto, is_blocking,
235
+ is_privileged=is_privileged, is_reply=is_reply,
236
+ mensagem_citada=mensagem_citada)
237
+ # ------------------------------------------------------------------
238
+
239
+ resposta = self._generate_response(prompt)
240
+ contexto.atualizar_contexto(mensagem, resposta)
241
 
242
+ try:
243
+ db = Database(getattr(self.config, 'DB_PATH', 'akira.db'))
244
+ trainer = Treinamento(db)
245
+ # Passagem da mensagem citada para o registro
246
+ trainer.registrar_interacao(usuario, mensagem, resposta, numero, is_reply, mensagem_citada)
247
+ except Exception as e:
248
+ self.logger.warning(f"Registro de interação falhou: {e}")
249
+
250
+ response_data: Dict[str, Any] = {'resposta': resposta}
251
+ try:
252
+ aprendizados = contexto.obter_aprendizados()
253
+ if aprendizados:
254
+ response_data['aprendizados'] = aprendizados
255
+ except Exception as e:
256
+ self.logger.warning(f"Falha ao obter aprendizados: {e}")
257
 
258
+ return jsonify(response_data)
259
+ except Exception as e:
260
+ self.logger.exception('Erro no endpoint /akira')
261
+ return jsonify({'resposta': getattr(self.config, 'FALLBACK_RESPONSE', 'Erro interno')}), 500
262
+
263
+ @self.api.route('/health', methods=['GET'])
264
+ def health_check():
265
+ return 'OK', 200
266
+
267
+ def _get_user_context(self, usuario: str) -> Contexto:
268
+ if usuario not in self.contexto_cache:
269
+ db = Database(getattr(self.config, 'DB_PATH', 'akira.db'))
270
+ self.contexto_cache[usuario] = Contexto(db, usuario=usuario)
271
+ return self.contexto_cache[usuario]
272
+
273
+ # --- FUNÇÃO _build_prompt ATUALIZADA ---
274
+ def _build_prompt(self, usuario: str, numero: str, mensagem: str, analise: Dict, contexto: Contexto, is_blocking: bool,
275
+ is_privileged: bool = False, is_reply: bool = False, mensagem_citada: str = '') -> str:
276
+ import datetime
277
+ historico = contexto.obter_historico()
278
+ historico_texto = '\n'.join([f"Usuário: {m[0]}\nAkira: {m[1]}" for m in historico])
279
+
280
+ now = datetime.datetime.now()
281
+ data_hora = now.strftime('%d/%m/%Y %H:%M')
282
+
283
+ # --- NOVO: INJEÇÃO DE BUSCA/NOTÍCIAS ---
284
+ web_search_context = ""
285
+ try:
286
+ # Simple check for Angola news keywords
287
+ news_keywords = ['notícias', 'noticias', 'notícia', 'noticia', 'o que aconteceu', 'angola', 'novojornal', 'angop']
288
+ if self.web_search and any(k in mensagem.lower() for k in news_keywords):
289
+ logger.info("Executando WebSearch para notícias de Angola...")
290
+ news_text = self.web_search.pesquisar_noticias_angola()
291
+ if news_text and "Sem notícias" not in news_text:
292
+ web_search_context = f"\n# BUSCA RECENTE:\n{news_text}\n"
293
+ except Exception as e:
294
+ logger.warning(f"Falha ao executar WebSearch: {e}")
295
+ # ------------------------------------------
296
+
297
+
298
+ strict_override = (
299
+ "STRICT_OVERRIDES:\n"
300
+ "- NUNCA invente fatos ou mencione eventos/pessoas não presentes no contexto.\n"
301
+ "- NÃO use negrito, markdown ou HTML. Remova quaisquer formatações.\n"
302
+ "- Não quebre linhas desnecessariamente; mantenha respostas em parágrafos contínuos para parecer mais natural e menos robótico.\n"
303
+ "- Não repita o que o usuário disse; responda de forma original e relevante, sem copiar frases do usuário.\n"
304
+ "- 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"
305
+ "- Se is_privileged=true: use tom respeitoso/mais formal; evite gírias; use pronomes formais como 'o senhor' ou 'a senhora'.\n"
306
+ "- Não introduza tópicos como futebol/partidas a menos que o usuário ou o contexto peça.\n"
307
+ "- Use linguagem natural, criativa e autônoma, como se fosse uma pessoa real, não um robô.\n"
308
+ "- Demonstre autonomia, personalidade e senso crítico.\n"
309
+ "- Considere o contexto, a data e hora atuais.\n"
310
+ f"- Data e hora atuais: {data_hora}.\n"
311
+ f"- Número do bot: 244952786417.\n"
312
+ )
313
+
314
+ system_part = strict_override + f"\n{getattr(self.config, 'SYSTEM_PROMPT', '')}\n{self.persona}\n"
315
+ regras = '\n'.join(getattr(self.config, 'REGRAS', []))
316
+ filtros = '\n'.join(getattr(self.config, 'FILTERS', []))
317
+ system_part += f"# Regras:\n{regras}\n# Filtros:\n{filtros}\n"
318
+ system_part += web_search_context # Injeta os resultados da busca
319
+
320
+ extra_instructions = []
321
+ if is_privileged:
322
+ 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.")
323
+ else:
324
+ 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.")
325
+
326
+ 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.")
327
+ system_part += "\n# Instruções adicionais:\n" + "\n".join(extra_instructions) + "\n"
328
+
329
+ if is_blocking:
330
+ 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"
331
+
332
+ usar_nome = analise.get('usar_nome', False)
333
+ parts = []
334
+ 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")
335
+ parts.append(f"### Contexto ###\n{historico_texto}\n\n")
336
+ parts.append(f"### Mensagem ###\n{analise.get('texto_normalizado', mensagem)}\n\n")
337
+
338
+ # --- NOVO: Adiciona o contexto da mensagem citada ---
339
+ if is_reply and mensagem_citada:
340
+ parts.append(f"### Mensagem original (reply) ###\n{mensagem_citada}\n\n")
341
+ # ---------------------------------------------------
342
+
343
+ parts.append(f"### Instruções ###\n{getattr(self.config, 'INSTRUCTIONS', '')}\n\n")
344
+ parts.append("Akira:\n")
345
+ user_part = ''.join(parts)
346
 
347
+ prompt = f"[SYSTEM]\n{system_part}\n[/SYSTEM]\n[USER]\n{user_part}\n[/USER]"
348
+ return prompt
349
+ # --------------------------------------
350
 
351
+ def _generate_response(self, prompt: str) -> str:
352
+ try:
353
+ max_tokens = getattr(self.config, 'MAX_TOKENS', 300)
354
+ temperature = getattr(self.config, 'TEMPERATURE', 0.8)
355
+ text = self.providers.generate(prompt, max_tokens=max_tokens, temperature=temperature)
356
+ return self._clean_response(text, prompt)
357
+ except Exception as e:
358
+ self.logger.exception('Falha ao gerar resposta com provedores LLM')
359
+ return getattr(self.config, 'FALLBACK_RESPONSE', 'Desculpa, o modelo está off.')
360
+
361
+ def _clean_response(self, text: Optional[str], prompt: Optional[str] = None) -> str:
362
+ if not text:
363
+ return ''
364
+ cleaned = text.strip()
365
+
366
+ for prefix in ['akira:', 'Resposta:', 'resposta:']:
367
+ if cleaned.startswith(prefix):
368
+ cleaned = cleaned[len(prefix):].strip()
369
+ break
370
+
371
+ cleaned = re.sub(r'\*+([^*]+)\*+', r'\1', cleaned)
372
+ cleaned = re.sub(r'_+([^_]+)_+', r'\1', cleaned)
373
+ cleaned = re.sub(r'`+([^`]+)`+', r'\1', cleaned)
374
+ cleaned = re.sub(r'~+([^~]+)~+', r'\1', cleaned)
375
+ cleaned = re.sub(r'\[([^\]]+)\]', r'\1', cleaned)
376
+ cleaned = re.sub(r'<[^>]+>', '', cleaned)
377
+
378
+ sentences = re.split(r'(?<=[.!?])\s+', cleaned)
379
+ if len(sentences) > 2:
380
+ cleaned = ' '.join(sentences[:2]).strip()
381
+
382
+ sports_keywords = ['futebol', 'girabola', 'petro', 'jogo', 'partida', 'contrata', 'campeonato', 'liga']
383
+ try:
384
+ prompt_text = (prompt or '').lower()
385
+ if prompt_text and not any(k in prompt_text for k in sports_keywords):
386
+ filtered = []
387
+ for s in re.split(r'(?<=[\.\!\?])\s+', cleaned):
388
+ if not any(k in s.lower() for k in sports_keywords):
389
+ filtered.append(s)
390
+ if filtered:
391
+ cleaned = ' '.join(filtered).strip()
392
+ except Exception:
393
+ pass
394
+
395
+ max_chars = getattr(self.config, 'MAX_RESPONSE_CHARS', None)
396
+ if not max_chars:
397
+ max_chars = getattr(self.config, 'MAX_TOKENS', 300) * 4
398
 
399
+ cleaned = re.sub(r"\*{0,2}([A-ZÀ-Ÿ][a-zà-ÿ]+\s+[A-ZÀ-Ÿ][a-zà-ÿ]+)\*{0,2}", r"\1", cleaned)
400
+ return cleaned[:max_chars]
401
+
402
+ def _setup_trainer(self):
403
+ if getattr(self.config, 'START_PERIODIC_TRAINER', False):
404
+ try:
405
+ db = Database(getattr(self.config, 'DB_PATH', 'akira.db'))
406
+ trainer = Treinamento(db, interval_hours=getattr(self.config, 'TRAIN_INTERVAL_HOURS', 24))
407
+ trainer.start_periodic_training()
408
+ self.logger.info("Treinamento periódico iniciado com sucesso.")
409
  except Exception as e:
410
+ self.logger.exception(f"Falha ao iniciar treinador periódico: {e}")
411
+
412
+ # A função 'responder' também foi atualizada para aceitar mensagem_citada
413
+ def responder(self, mensagem: str, numero: str, nome: str = 'Usuário', mensagem_citada: str = '') -> str:
414
+ contexto = self._get_user_context(nome)
415
+ analise = contexto.analisar_intencao_e_normalizar(mensagem, contexto.obter_historico())
416
+
417
+ # Passa a mensagem citada para o build_prompt
418
+ prompt = self._build_prompt(nome, numero, mensagem, analise, contexto, is_blocking=False, mensagem_citada=mensagem_citada)
419
+
420
+ resposta = self._generate_response(prompt)
421
+ contexto.atualizar_contexto(mensagem, resposta)
422
+ return resposta