akra35567 commited on
Commit
c2e2750
·
verified ·
1 Parent(s): 5aba635

Upload 29 files

Browse files
modules/context_builder.py CHANGED
@@ -210,12 +210,13 @@ class ContextBuilder:
210
  Args:
211
  db: Instância de Database
212
  """
213
- if get_lstm_extension:
214
- try:
215
- self.lstm_extension = get_lstm_extension(db)
216
- logger.info("✅ LSTM Extension habilitado em ContextBuilder")
217
- except Exception as e:
218
- logger.debug(f"LSTM initialization: {e}")
 
219
 
220
  def build_prompt(
221
  self,
 
210
  Args:
211
  db: Instância de Database
212
  """
213
+ try:
214
+ from .lstm_extension import get_lstm_extension as _get_lstm
215
+ self.lstm_extension = _get_lstm(db)
216
+ logger.info("✅ LSTM Extension habilitado em ContextBuilder")
217
+ except Exception as e:
218
+ logger.debug(f"LSTM initialization: {e}")
219
+
220
 
221
  def build_prompt(
222
  self,
modules/database.py CHANGED
The diff for this file is too large to render. See raw diff
 
modules/grouped_skills_adapter.py CHANGED
@@ -11,8 +11,7 @@ from modules.skills import (
11
  WeatherSkill,
12
  EntertainmentSkill,
13
  ArtSkill,
14
- MusicSkill,
15
- ManusSkill
16
  )
17
 
18
  # ========================================
@@ -23,7 +22,6 @@ _weather_skill = WeatherSkill()
23
  _entertainment_skill = EntertainmentSkill()
24
  _art_skill = ArtSkill()
25
  _music_skill = MusicSkill()
26
- _manus_skill = ManusSkill()
27
 
28
 
29
  # ========================================
@@ -314,20 +312,6 @@ def music_tool(tipo: str = "genre", mood: str = "random", anime: str = None, son
314
  "provider": result["provider"]
315
  }
316
 
317
- elif dados.get("tipo") == "lyrics":
318
- fragmento = dados.get("fragmento", "Letra não encontrada.")
319
- url = dados.get("url", "")
320
- fonte = dados.get("fonte", "desconhecida")
321
-
322
- return {
323
- "sucesso": True,
324
- "tipo": "lyrics",
325
- "musica": dados.get("musica"),
326
- "artista": dados.get("artista"),
327
- "conteudo": f"Fragmento da letra:\n\n{fragmento}\n\nFonte: {fonte}\nLink: {url}",
328
- "provider": result["provider"]
329
- }
330
-
331
  else:
332
  return {
333
  "sucesso": True,
@@ -342,40 +326,6 @@ def music_tool(tipo: str = "genre", mood: str = "random", anime: str = None, son
342
  }
343
 
344
 
345
- @skill(
346
- name="manus_research",
347
- description="Realiza pesquisas profundas, análise de mercado ou tarefas autônomas complexas via Manus AI. Use para perguntas que exigem investigação séria.",
348
- parameters={
349
- "type": "object",
350
- "properties": {
351
- "prompt": {
352
- "type": "string",
353
- "description": "O que você quer que o Manus pesquise ou resolva detalhadamente."
354
- }
355
- },
356
- "required": ["prompt"]
357
- }
358
- )
359
- def manus_research_tool(prompt: str):
360
- """
361
- Wrapper para ManusSkill
362
- """
363
- result = _manus_skill.execute(prompt=prompt)
364
-
365
- if result.get("sucesso"):
366
- return {
367
- "sucesso": True,
368
- "analise": result["dados"].get("resultado"),
369
- "provider": "Manus AI Agent",
370
- "status": "Finalizado com sucesso"
371
- }
372
- else:
373
- return {
374
- "sucesso": False,
375
- "erro": result.get("erro")
376
- }
377
-
378
-
379
  # ========================================
380
  # Helper para stats
381
  # ========================================
@@ -386,6 +336,5 @@ def get_grouped_skills_stats() -> Dict[str, Any]:
386
  "weather": _weather_skill.get_stats(),
387
  "entertainment": _entertainment_skill.get_stats(),
388
  "art": _art_skill.get_stats(),
389
- "music": _music_skill.get_stats(),
390
- "manus": _manus_skill.get_stats()
391
  }
 
11
  WeatherSkill,
12
  EntertainmentSkill,
13
  ArtSkill,
14
+ MusicSkill
 
15
  )
16
 
17
  # ========================================
 
22
  _entertainment_skill = EntertainmentSkill()
23
  _art_skill = ArtSkill()
24
  _music_skill = MusicSkill()
 
25
 
26
 
27
  # ========================================
 
312
  "provider": result["provider"]
313
  }
314
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  else:
316
  return {
317
  "sucesso": True,
 
326
  }
327
 
328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  # ========================================
330
  # Helper para stats
331
  # ========================================
 
336
  "weather": _weather_skill.get_stats(),
337
  "entertainment": _entertainment_skill.get_stats(),
338
  "art": _art_skill.get_stats(),
339
+ "music": _music_skill.get_stats()
 
340
  }
modules/lstm_extension.py CHANGED
@@ -20,7 +20,6 @@ Features:
20
  """
21
 
22
  import json
23
- import re
24
  import threading
25
  from typing import Dict, Any, Optional, List
26
  from dataclasses import dataclass
@@ -75,7 +74,8 @@ class LSTMExtension:
75
  context_id: str,
76
  numero_usuario: str,
77
  message: str,
78
- role: str = "user"
 
79
  ) -> None:
80
  """
81
  Processa mensagem em background thread. NÃO BLOQUEIA.
@@ -85,11 +85,12 @@ class LSTMExtension:
85
  numero_usuario: Número do usuário
86
  message: Texto da mensagem
87
  role: "user" ou "assistant"
 
88
  """
89
  # Dispara em thread para não bloquear
90
  thread = threading.Thread(
91
  target=self._analyze_and_store,
92
- args=(context_id, numero_usuario, message, role),
93
  daemon=True
94
  )
95
  thread.start()
@@ -99,10 +100,19 @@ class LSTMExtension:
99
  context_id: str,
100
  numero_usuario: str,
101
  message: str,
102
- role: str
 
103
  ) -> None:
104
  """Análise interna (roda em thread separada)."""
105
  try:
 
 
 
 
 
 
 
 
106
  # 1. Recuperar contexto existente
107
  existing = self._get_from_db(context_id)
108
  summary = existing or LSTMContextSummary(
@@ -135,6 +145,17 @@ class LSTMExtension:
135
 
136
  # 5. Salvar em DB
137
  self._save_to_db(summary)
 
 
 
 
 
 
 
 
 
 
 
138
  self.context_cache[context_id] = summary
139
 
140
  logger.debug(f"✅ LSTM context saved: {context_id} (topic: {summary.topic_principal})")
@@ -145,7 +166,8 @@ class LSTMExtension:
145
  def get_context_for_prompt(
146
  self,
147
  context_id: str,
148
- numero_usuario: str
 
149
  ) -> Optional[Dict[str, Any]]:
150
  """
151
  Recupera contexto LSTM para enriquecer prompt.
@@ -153,73 +175,95 @@ class LSTMExtension:
153
 
154
  Args:
155
  context_id: ID da conversa
156
- numero_usuario: Número do usuário
 
157
 
158
  Returns:
159
- Dict com contexto de longo prazo, ou None
160
  """
161
- # Tentar cache primeiro
162
- if context_id in self.context_cache:
163
- summary = self.context_cache[context_id]
164
- else:
165
- # Buscar DB
166
- summary = self._get_from_db(context_id)
167
 
168
- if not summary or not summary.topic_principal:
169
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
- # Formatar para uso em prompt
172
- return {
173
- "topic_principal": summary.topic_principal,
174
- "subtopicas": summary.subtopicas,
175
- "conversation_path": summary.conversation_path,
176
- "interaction_pattern": summary.interaction_pattern,
177
- "unanswered_questions": summary.unanswered_questions[:3], # Top 3
178
- "assumed_knowledge": summary.assumed_knowledge[:3], # Top 3
179
- "context_switches": summary.context_switches,
180
- }
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
  def _extract_topic_simple(self, message: str, current_topic: Optional[str]) -> Optional[str]:
183
  """
184
  Extrai tópico de forma simples (sem LLM).
185
- Heurísticas aprimoradas para evitar drift.
186
  """
187
  msg_lower = message.lower()
188
 
189
- # Stopwords básicas para evitar tópicos inúteis
190
- stopwords = {
191
- "está", "como", "você", "para", "mais", "tudo", "bem", "pode", "fazer",
192
- "quando", "onde", "quem", "porque", "qual", "quais", "muito", "pouco",
193
- "esse", "essa", "aquele", "aquela", "coisa", "nada", "algo", "isso"
194
- }
195
-
196
- # Detectar palavras-chave comuns (Tópicos Fortes)
197
  topics_keywords = {
198
- "saúde": ["doença", "medicina", "cura", "tratamento", "sintoma", "hospital", "dor", "médico"],
199
- "tecnologia": ["código", "python", "função", "erro", "bug", "programação", "ia", "api", "pc", "software"],
200
- "relacionamento": ["namoro", "amor", "casal", "relacionamento", "ex", "beijo", "casar"],
201
- "trabalho": ["emprego", "trabalho", "chefe", "salário", "despedida", "empresa", "vaga"],
202
- "escola": ["escola", "universidade", "prova", "nota", "aula", "estudar", "curso"],
203
- "entretenimento": ["filme", "série", "musica", "jogo", "game", "futebol", "esporte"],
204
- "finanças": ["dinheiro", "preço", "valor", "kwanza", "aoa", "dólar", "comprar", "venda"]
205
  }
206
 
207
  for topic, keywords in topics_keywords.items():
208
  if any(kw in msg_lower for kw in keywords):
209
  return topic
210
 
211
- # Se tem pergunta, tenta extrair um substantivo provável
212
  if "?" in message:
213
- words = [w for w in re.sub(r'[^\w\s]', '', msg_lower).split() if len(w) > 4]
214
- # Filtra stopwords
215
- filtered_words = [w for w in words if w not in stopwords]
216
- if filtered_words:
217
- return filtered_words[0]
218
 
219
- # Se a mensagem for muito curta, mantém o tópico atual (evita drift por ruído)
220
- if len(message.split()) < 3:
221
- return current_topic
222
-
223
  return current_topic
224
 
225
  def _detect_pattern(self, message: str) -> Optional[str]:
@@ -289,6 +333,58 @@ class LSTMExtension:
289
  logger.warning(f"Error loading LSTM from DB: {e}")
290
  return None
291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  def _save_to_db(self, summary: LSTMContextSummary) -> None:
293
  """Salva contexto no banco de dados usando Database._execute_with_retry()."""
294
  try:
 
20
  """
21
 
22
  import json
 
23
  import threading
24
  from typing import Dict, Any, Optional, List
25
  from dataclasses import dataclass
 
74
  context_id: str,
75
  numero_usuario: str,
76
  message: str,
77
+ role: str = "user",
78
+ message_id: Optional[str] = None
79
  ) -> None:
80
  """
81
  Processa mensagem em background thread. NÃO BLOQUEIA.
 
85
  numero_usuario: Número do usuário
86
  message: Texto da mensagem
87
  role: "user" ou "assistant"
88
+ message_id: ID único da mensagem (para evitar duplicados)
89
  """
90
  # Dispara em thread para não bloquear
91
  thread = threading.Thread(
92
  target=self._analyze_and_store,
93
+ args=(context_id, numero_usuario, message, role, message_id),
94
  daemon=True
95
  )
96
  thread.start()
 
100
  context_id: str,
101
  numero_usuario: str,
102
  message: str,
103
+ role: str,
104
+ message_id: Optional[str] = None
105
  ) -> None:
106
  """Análise interna (roda em thread separada)."""
107
  try:
108
+ # 0. Verificação de idempotência (Anti-Duplicate)
109
+ if message_id:
110
+ query_check = "SELECT id FROM lstm_message_links WHERE context_id = ? AND message_id = ? LIMIT 1"
111
+ res = self.db._execute_with_retry(query_check, (context_id, message_id))
112
+ if res:
113
+ # logger.debug(f"⏭️ LSTM skip duplicate: {message_id}")
114
+ return
115
+
116
  # 1. Recuperar contexto existente
117
  existing = self._get_from_db(context_id)
118
  summary = existing or LSTMContextSummary(
 
145
 
146
  # 5. Salvar em DB
147
  self._save_to_db(summary)
148
+
149
+ # 6. Registrar link da mensagem (idempotência)
150
+ if message_id:
151
+ try:
152
+ query_link = """INSERT INTO lstm_message_links
153
+ (context_id, message_id, numero_usuario, created_at)
154
+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)"""
155
+ self.db._execute_with_retry(query_link, (context_id, message_id, numero_usuario), commit=True)
156
+ except Exception:
157
+ pass # Provavelmente já existe (race condition), ignorar
158
+
159
  self.context_cache[context_id] = summary
160
 
161
  logger.debug(f"✅ LSTM context saved: {context_id} (topic: {summary.topic_principal})")
 
166
  def get_context_for_prompt(
167
  self,
168
  context_id: str,
169
+ numero_usuario: str = None,
170
+ is_group: bool = False
171
  ) -> Optional[Dict[str, Any]]:
172
  """
173
  Recupera contexto LSTM para enriquecer prompt.
 
175
 
176
  Args:
177
  context_id: ID da conversa
178
+ numero_usuario: Número do usuário (pode ser None em grupos)
179
+ is_group: Se True, retorna contexto para TODOS os speakers do grupo
180
 
181
  Returns:
182
+ Dict com contexto de longo prazo enriquecido com speaker tracking, ou None
183
  """
 
 
 
 
 
 
184
 
185
+ if is_group:
186
+ # Recupera contexto para TODOS os speakers do grupo
187
+ summaries = self._get_from_db_all_speakers(context_id)
188
+
189
+ if not summaries:
190
+ return None
191
+
192
+ # Agrupa contexto: qual speaker falou sobre qual tópico
193
+ speakers_topics = {}
194
+ total_context_switches = 0
195
+
196
+ for summary in summaries:
197
+ if summary.numero_usuario and summary.topic_principal:
198
+ speakers_topics[summary.numero_usuario] = {
199
+ "topic_principal": summary.topic_principal,
200
+ "interaction_pattern": summary.interaction_pattern or "regular",
201
+ "unanswered_questions": summary.unanswered_questions[:2] if summary.unanswered_questions else [],
202
+ "assumed_knowledge": summary.assumed_knowledge[:1] if summary.assumed_knowledge else [],
203
+ }
204
+ total_context_switches += summary.context_switches or 0
205
+
206
+ if not speakers_topics:
207
+ return None
208
+
209
+ return {
210
+ "context_id": context_id,
211
+ "tipo": "grupo",
212
+ "speakers_topics": speakers_topics, # ✅ Rastreia quem falou o quê
213
+ "context_switches": total_context_switches,
214
+ }
215
 
216
+ else:
217
+ # Código original para PV/direto
218
+ # Tentar cache primeiro
219
+ if context_id in self.context_cache:
220
+ summary = self.context_cache[context_id]
221
+ else:
222
+ # Buscar DB (vai retornar primeiro speaker se houver múltiplos em grupo)
223
+ summary = self._get_from_db(context_id)
224
+
225
+ if not summary or not summary.topic_principal:
226
+ return None
227
+
228
+ # Formatar para uso em prompt
229
+ return {
230
+ "topic_principal": summary.topic_principal,
231
+ "subtopicas": summary.subtopicas,
232
+ "conversation_path": summary.conversation_path,
233
+ "interaction_pattern": summary.interaction_pattern,
234
+ "unanswered_questions": summary.unanswered_questions[:3] if summary.unanswered_questions else [],
235
+ "assumed_knowledge": summary.assumed_knowledge[:3] if summary.assumed_knowledge else [],
236
+ "context_switches": summary.context_switches,
237
+ }
238
 
239
  def _extract_topic_simple(self, message: str, current_topic: Optional[str]) -> Optional[str]:
240
  """
241
  Extrai tópico de forma simples (sem LLM).
242
+ Heurísticas básicas.
243
  """
244
  msg_lower = message.lower()
245
 
246
+ # Detectar palavras-chave comuns
 
 
 
 
 
 
 
247
  topics_keywords = {
248
+ "saúde": ["doença", "medicina", "cura", "tratamento", "sintoma", "hospital"],
249
+ "técnica": ["código", "python", "função", "erro", "bug", "programação"],
250
+ "relacionamento": ["namoro", "amor", "casal", "relacionamento", "ex"],
251
+ "trabalho": ["emprego", "trabalho", "chefe", "salário", "despedida"],
252
+ "escola": ["escola", "universidade", "prova", "nota", "aula"],
253
+ "esportes": ["futebol", "basquete", "games", "competição", "time"],
 
254
  }
255
 
256
  for topic, keywords in topics_keywords.items():
257
  if any(kw in msg_lower for kw in keywords):
258
  return topic
259
 
260
+ # Se tem pergunta, extrai dela
261
  if "?" in message:
262
+ # Pega primeira palavra significativa
263
+ words = [w for w in msg_lower.split() if len(w) > 3]
264
+ if words:
265
+ return words[0]
 
266
 
 
 
 
 
267
  return current_topic
268
 
269
  def _detect_pattern(self, message: str) -> Optional[str]:
 
333
  logger.warning(f"Error loading LSTM from DB: {e}")
334
  return None
335
 
336
+ def _get_from_db_all_speakers(self, context_id: str) -> List[LSTMContextSummary]:
337
+ """
338
+ Recupera contexto para TODOS os speakers em um contexto de grupo.
339
+ Essencial para rastrear quem falou o quê em grupos.
340
+ """
341
+ try:
342
+ rows = self.db._execute_with_retry(
343
+ "SELECT * FROM lstm_contexto WHERE context_id = ? ORDER BY last_updated DESC",
344
+ (context_id,)
345
+ )
346
+
347
+ if not rows:
348
+ return []
349
+
350
+ summaries = []
351
+ for row in rows:
352
+ data = dict(row)
353
+
354
+ # Desserializar JSON fields
355
+ if data.get('subtopicas'):
356
+ data['subtopicas'] = json.loads(data['subtopicas'])
357
+ if data.get('conversation_path'):
358
+ data['conversation_path'] = json.loads(data['conversation_path'])
359
+ if data.get('unanswered_questions'):
360
+ data['unanswered_questions'] = json.loads(data['unanswered_questions'])
361
+ if data.get('assumed_knowledge'):
362
+ data['assumed_knowledge'] = json.loads(data['assumed_knowledge'])
363
+ if data.get('contradictions'):
364
+ data['contradictions'] = json.loads(data['contradictions'])
365
+
366
+ # Limpar campos legados
367
+ data.pop('created_at', None)
368
+ data.pop('last_updated', None)
369
+ data.pop('metadata', None)
370
+ data.pop('emotional_state', None)
371
+ data.pop('contexto_geral', None)
372
+
373
+ # Filtro genérico
374
+ import inspect
375
+ valid_keys = inspect.signature(LSTMContextSummary).parameters.keys()
376
+ filtered_data = {k: v for k, v in data.items() if k in valid_keys}
377
+
378
+ summary = LSTMContextSummary(**filtered_data)
379
+ summaries.append(summary)
380
+
381
+ logger.debug(f"✅ Loaded LSTM speakers: context_id={context_id}, {len(summaries)} speakers")
382
+ return summaries
383
+
384
+ except Exception as e:
385
+ logger.warning(f"Error loading LSTM speakers from DB: {e}")
386
+ return []
387
+
388
  def _save_to_db(self, summary: LSTMContextSummary) -> None:
389
  """Salva contexto no banco de dados usando Database._execute_with_retry()."""
390
  try:
modules/lstm_memory_system.py CHANGED
@@ -173,6 +173,10 @@ class LSTMMemorySystem:
173
  self.processing_queue: List[Dict[str, Any]] = []
174
  self.processing_lock = threading.Lock()
175
 
 
 
 
 
176
  # Inicializar tabelas no DB
177
  self._initialize_database()
178
 
@@ -181,13 +185,13 @@ class LSTMMemorySystem:
181
  def _initialize_database(self) -> None:
182
  """Cria tabelas necessárias no banco de dados."""
183
  try:
184
- # As tabelas lstm_contexto e lstm_message_links já são criadas
185
- # pelo database.py _init_db(). Aqui apenas garantimos que existem.
186
  self.db._execute_with_retry("""
187
  CREATE TABLE IF NOT EXISTS lstm_contexto (
188
- context_id TEXT PRIMARY KEY,
189
- numero_usuario TEXT NOT NULL,
190
- topic_principal TEXT,
191
  subtopicas TEXT,
192
  conversation_path TEXT,
193
  last_key_message TEXT,
@@ -206,27 +210,19 @@ class LSTMMemorySystem:
206
  self.db._execute_with_retry("""
207
  CREATE TABLE IF NOT EXISTS lstm_message_links (
208
  id INTEGER PRIMARY KEY AUTOINCREMENT,
209
- context_id TEXT NOT NULL,
210
- message_id TEXT,
211
- parent_message_id TEXT,
 
212
  topic_changed BOOLEAN DEFAULT FALSE,
213
- context_switch_type TEXT,
214
- relevance_score REAL DEFAULT 1.0,
215
- created_at REAL
 
216
  )
217
  """, commit=True)
218
 
219
- self.db._execute_with_retry("""
220
- CREATE INDEX IF NOT EXISTS idx_lstm_usuario
221
- ON lstm_contexto(numero_usuario)
222
- """, commit=True)
223
-
224
- self.db._execute_with_retry("""
225
- CREATE INDEX IF NOT EXISTS idx_lstm_links_context
226
- ON lstm_message_links(context_id)
227
- """, commit=True)
228
-
229
- logger.info("✅ Tabelas LSTM verificadas/inicializadas")
230
  except Exception as e:
231
  logger.error(f"❌ Erro ao inicializar tabelas LSTM: {e}")
232
 
@@ -247,6 +243,8 @@ class LSTMMemorySystem:
247
  Processa mensagem de forma assíncrona para extrair contexto LSTM.
248
  Não bloqueia a resposta. Funciona em background thread.
249
 
 
 
250
  Args:
251
  context_id: ID do contexto (PV ou Grupo)
252
  numero_usuario: ID do usuário
@@ -255,6 +253,28 @@ class LSTMMemorySystem:
255
  parent_message_id: ID da mensagem anterior (para linked context)
256
  llm_client: Client LLM para análise (opcional)
257
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  # Adiciona à queue para processamento assíncrono
259
  with self.processing_lock:
260
  self.processing_queue.append({
@@ -263,7 +283,7 @@ class LSTMMemorySystem:
263
  'message': message,
264
  'role': role,
265
  'parent_message_id': parent_message_id,
266
- 'timestamp': time.time()
267
  })
268
 
269
  # Dispara thread de processamento se não estiver rodando
@@ -315,7 +335,7 @@ class LSTMMemorySystem:
315
  lstm_summary.topic_principal = new_topic
316
 
317
  # Armazenar link entre mensagens
318
- self._record_context_switch(context_id, parent_message_id, new_topic)
319
 
320
  # ✅ ANÁLISE 3: Adicionar subtópicos
321
  subtopics = self._extract_subtopics(message, new_topic)
@@ -381,21 +401,8 @@ class LSTMMemorySystem:
381
  'política': ['presidente', 'eleição', 'política', 'governo', 'ministro'],
382
  'clima': ['tempo', 'chuva', 'temperatura', 'previsão', 'clima'],
383
  'saúde': ['doença', 'médico', 'hospital', 'sintomas', 'saúde'],
384
- 'entretenimento': ['pedro orochi', 'orochinho', 'weedzao', 'mitada', 'rei das mitadas', 'youtuber', 'streamer'],
385
  }
386
 
387
- # 🛡️ FILTRO ANTI-NOISE: Ignora termos vazios ou clickbaits genéricos
388
- clickbait_patterns = [
389
- 'veja o que aconteceu', 'não acredito', 'olha isso', 'surpreendente',
390
- 'morreu hoje', 'luto', 'urgente'
391
- ]
392
-
393
- if any(p in message_lower for p in clickbait_patterns):
394
- # Se contém clickbait, mas não contém um tópico real forte, ignora
395
- has_real_topic = any(kw in message_lower for topic, kws in keywords_map.items() for kw in kws)
396
- if not has_real_topic:
397
- return None
398
-
399
  for topic, keywords in keywords_map.items():
400
  if any(kw in message_lower for kw in keywords):
401
  return topic
@@ -403,11 +410,6 @@ class LSTMMemorySystem:
403
  # Se não detectar via keywords, tenta extrair primeira entidade nomeada
404
  # (simplificado - em produção usaria NER)
405
  if len(message.split()) >= 3:
406
- # Pela experiência, temas com "morreu" ou "luto" sem fonte são ruído
407
- if 'morreu' in message_lower or 'luto' in message_lower:
408
- if not any(kw in message_lower for kw in ['notícia', 'jornal', 'confirmado']):
409
- return None
410
-
411
  # Pega primeiras 3-4 palavras como possível tema
412
  words = message.split()[:4]
413
  if all(w[0].isupper() for w in words if w):
@@ -627,19 +629,24 @@ class LSTMMemorySystem:
627
  def _record_context_switch(
628
  self,
629
  context_id: str,
 
630
  parent_message_id: Optional[str],
631
  new_topic: str
632
  ) -> None:
633
  """Registra mudança de contexto/tópico."""
634
  try:
 
 
 
635
  self.db._execute_with_retry("""
636
  INSERT INTO lstm_message_links
637
- (context_id, message_id, parent_message_id, topic_changed,
638
  context_switch_type, created_at)
639
- VALUES (?, ?, ?, ?, ?, ?)
640
  """, (
641
  context_id,
642
- None, # message_id será gerado externamente
 
643
  parent_message_id,
644
  True,
645
  'topic_change',
 
173
  self.processing_queue: List[Dict[str, Any]] = []
174
  self.processing_lock = threading.Lock()
175
 
176
+ # ✅ PROTEÇÃO CONTRA DUPLICAÇÃO: Track mensagens processadas recentemente
177
+ self.recently_processed: Dict[str, float] = {} # {hash(context+user+msg): timestamp}
178
+ self.dedup_timeout = 5 # Segundos - evita duplicação em 5s
179
+
180
  # Inicializar tabelas no DB
181
  self._initialize_database()
182
 
 
185
  def _initialize_database(self) -> None:
186
  """Cria tabelas necessárias no banco de dados."""
187
  try:
188
+ # As tabelas já são criadas pelo database.py _init_db().
189
+ # Aqui apenas garantimos redundância segura com o esquema oficial.
190
  self.db._execute_with_retry("""
191
  CREATE TABLE IF NOT EXISTS lstm_contexto (
192
+ context_id VARCHAR(255) PRIMARY KEY,
193
+ numero_usuario VARCHAR(50) NOT NULL,
194
+ topic_principal VARCHAR(255),
195
  subtopicas TEXT,
196
  conversation_path TEXT,
197
  last_key_message TEXT,
 
210
  self.db._execute_with_retry("""
211
  CREATE TABLE IF NOT EXISTS lstm_message_links (
212
  id INTEGER PRIMARY KEY AUTOINCREMENT,
213
+ context_id VARCHAR(255) NOT NULL,
214
+ message_id VARCHAR(255) NOT NULL,
215
+ numero_usuario VARCHAR(50) NOT NULL,
216
+ parent_message_id VARCHAR(255),
217
  topic_changed BOOLEAN DEFAULT FALSE,
218
+ context_switch_type VARCHAR(50),
219
+ relevance_score FLOAT DEFAULT 1.0,
220
+ created_at REAL,
221
+ FOREIGN KEY (context_id) REFERENCES lstm_contexto(context_id) ON DELETE CASCADE
222
  )
223
  """, commit=True)
224
 
225
+ logger.info("✅ Tabelas LSTM sincronizadas")
 
 
 
 
 
 
 
 
 
 
226
  except Exception as e:
227
  logger.error(f"❌ Erro ao inicializar tabelas LSTM: {e}")
228
 
 
243
  Processa mensagem de forma assíncrona para extrair contexto LSTM.
244
  Não bloqueia a resposta. Funciona em background thread.
245
 
246
+ ✅ Proteção: Evita duplicação em 5 segundos
247
+
248
  Args:
249
  context_id: ID do contexto (PV ou Grupo)
250
  numero_usuario: ID do usuário
 
253
  parent_message_id: ID da mensagem anterior (para linked context)
254
  llm_client: Client LLM para análise (opcional)
255
  """
256
+ # ✅ DEDUPLICATION: Verifica se a mensagem já foi processada recentemente
257
+ import hashlib
258
+ if message_id:
259
+ msg_hash = hashlib.md5(f"msgid:{message_id}".encode()).hexdigest()
260
+ else:
261
+ msg_hash = hashlib.md5(f"{context_id}:{numero_usuario}:{message[:100]}".encode()).hexdigest()
262
+
263
+ now = time.time()
264
+
265
+ # Limpa entries expiradas
266
+ expired = [k for k, v in self.recently_processed.items() if now - v > self.dedup_timeout]
267
+ for k in expired:
268
+ del self.recently_processed[k]
269
+
270
+ # Verifica se já foi processada recentemente
271
+ if msg_hash in self.recently_processed:
272
+ logger.debug(f"⚠️ [LSTM DEDUP] Mensagem duplicada ignorada: {message[:50]}...")
273
+ return
274
+
275
+ # Marca como processada
276
+ self.recently_processed[msg_hash] = now
277
+
278
  # Adiciona à queue para processamento assíncrono
279
  with self.processing_lock:
280
  self.processing_queue.append({
 
283
  'message': message,
284
  'role': role,
285
  'parent_message_id': parent_message_id,
286
+ 'timestamp': now
287
  })
288
 
289
  # Dispara thread de processamento se não estiver rodando
 
335
  lstm_summary.topic_principal = new_topic
336
 
337
  # Armazenar link entre mensagens
338
+ self._record_context_switch(context_id, numero_usuario, parent_message_id, new_topic)
339
 
340
  # ✅ ANÁLISE 3: Adicionar subtópicos
341
  subtopics = self._extract_subtopics(message, new_topic)
 
401
  'política': ['presidente', 'eleição', 'política', 'governo', 'ministro'],
402
  'clima': ['tempo', 'chuva', 'temperatura', 'previsão', 'clima'],
403
  'saúde': ['doença', 'médico', 'hospital', 'sintomas', 'saúde'],
 
404
  }
405
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  for topic, keywords in keywords_map.items():
407
  if any(kw in message_lower for kw in keywords):
408
  return topic
 
410
  # Se não detectar via keywords, tenta extrair primeira entidade nomeada
411
  # (simplificado - em produção usaria NER)
412
  if len(message.split()) >= 3:
 
 
 
 
 
413
  # Pega primeiras 3-4 palavras como possível tema
414
  words = message.split()[:4]
415
  if all(w[0].isupper() for w in words if w):
 
629
  def _record_context_switch(
630
  self,
631
  context_id: str,
632
+ numero_usuario: str,
633
  parent_message_id: Optional[str],
634
  new_topic: str
635
  ) -> None:
636
  """Registra mudança de contexto/tópico."""
637
  try:
638
+ # Gera um ID temporário se não houver
639
+ msg_id = f"switch_{int(time.time())}_{hashlib.md5(new_topic.encode()).hexdigest()[:8]}"
640
+
641
  self.db._execute_with_retry("""
642
  INSERT INTO lstm_message_links
643
+ (context_id, message_id, numero_usuario, parent_message_id, topic_changed,
644
  context_switch_type, created_at)
645
+ VALUES (?, ?, ?, ?, ?, ?, ?)
646
  """, (
647
  context_id,
648
+ msg_id,
649
+ numero_usuario,
650
  parent_message_id,
651
  True,
652
  'topic_change',
modules/reply_context_handler.py CHANGED
@@ -1,758 +1,781 @@
1
- # type: ignore
2
- """
3
- ================================================================================
4
- AKIRA V21 ULTIMATE - REPLY CONTEXT HANDLER MODULE
5
- ================================================================================
6
- Sistema dedicado para processar e priorizar contexto de replies.
7
- Garante que replies tenham prioridade ligeiramente maior que o contexto geral,
8
- especialmente em perguntas curtas.
9
-
10
- Features:
11
- - Extração e processamento de metadados de reply
12
- - 3 níveis de prioridade (1=normal, 2=reply, 3=reply-to-bot+pergunta-curta)
13
- - Construção de prompt sections otimizadas para replies
14
- - Integração com ShortTermMemory
15
- - Context hint extraction para melhor compreensão
16
- ================================================================================
17
- """
18
-
19
- import os
20
- import sys
21
- import time
22
- import json
23
- import re
24
- import logging
25
- from typing import Optional, Dict, Any, List, Tuple
26
- from dataclasses import dataclass, field
27
-
28
- # Imports robustos com fallback - CORRIGIDO para usar modules.
29
- try:
30
- from . import config
31
- from .short_term_memory import ShortTermMemory, MessageWithContext, IMPORTANCIA_REPLY, IMPORTANCIA_REPLY_TO_BOT, IMPORTANCIA_PERGUNTA_CURTA_REPLY
32
- REPLY_HANDLER_AVAILABLE = True
33
- except ImportError:
34
- try:
35
- import modules.config as config
36
- from modules.short_term_memory import ShortTermMemory, MessageWithContext, IMPORTANCIA_REPLY, IMPORTANCIA_REPLY_TO_BOT, IMPORTANCIA_PERGUNTA_CURTA_REPLY
37
- REPLY_HANDLER_AVAILABLE = True
38
- except ImportError:
39
- try:
40
- from short_term_memory import ShortTermMemory, MessageWithContext, IMPORTANCIA_REPLY, IMPORTANCIA_REPLY_TO_BOT, IMPORTANCIA_PERGUNTA_CURTA_REPLY
41
- REPLY_HANDLER_AVAILABLE = True
42
- except ImportError:
43
- REPLY_HANDLER_AVAILABLE = False
44
- config = None
45
-
46
- logger = logging.getLogger(__name__)
47
-
48
- # ============================================================
49
- # NÍVEIS DE PRIORIDADE
50
- # ============================================================
51
-
52
- PRIORITY_NORMAL = 1
53
- PRIORITY_REPLY = 2
54
- PRIORITY_REPLY_TO_BOT = 3
55
- PRIORITY_REPLY_TO_BOT_SHORT_QUESTION = 4 # Prioridade máxima!
56
-
57
- # Limite de palavras para "pergunta curta"
58
- PERGUNTA_CURTA_LIMITE: int = 5
59
-
60
-
61
- @dataclass
62
- class ProcessedReplyContext:
63
- """
64
- Contexto de reply processado e pronto para uso.
65
-
66
- Attributes:
67
- is_reply: Se é um reply
68
- reply_to_bot: Se é reply direcionado ao bot
69
- priority_level: Nível de prioridade (1-4)
70
- quoted_author_name: Nome do autor da mensagem citada
71
- quoted_author_numero: Número do autor
72
- quoted_text_original: Texto original citado
73
- mensagem_citada: Texto da mensagem citada
74
- context_hint: Hint de contexto extraído
75
- importancia: Peso de importância calculado
76
- prompt_section: Section formatada para o prompt
77
- should_prioritize_reply: Se deve priorizar no prompt
78
- adaptive_multiplier: Multiplicador adaptativo baseado no tamanho
79
- """
80
- is_reply: bool = False
81
- reply_to_bot: bool = False
82
- priority_level: int = PRIORITY_NORMAL
83
- quoted_author_name: str = ""
84
- quoted_author_numero: str = ""
85
- quoted_text_original: str = ""
86
- mensagem_citada: str = ""
87
- context_hint: str = ""
88
- importancia: float = 1.0
89
- prompt_section: str = ""
90
- should_prioritize_reply: bool = False
91
- adaptive_multiplier: float = 1.0
92
-
93
- def to_dict(self) -> Dict[str, Any]:
94
- """Converte para dicionário."""
95
- return {
96
- "is_reply": self.is_reply,
97
- "reply_to_bot": self.reply_to_bot,
98
- "priority_level": self.priority_level,
99
- "quoted_author_name": self.quoted_author_name,
100
- "quoted_author_numero": self.quoted_author_numero,
101
- "quoted_text_original": self.quoted_text_original,
102
- "mensagem_citada": self.mensagem_citada,
103
- "context_hint": self.context_hint,
104
- "importancia": self.importancia,
105
- "prompt_section": self.prompt_section,
106
- "should_prioritize_reply": self.should_prioritize_reply,
107
- "adaptive_multiplier": self.adaptive_multiplier
108
- }
109
-
110
- @classmethod
111
- def from_dict(cls, data: Dict[str, Any]) -> 'ProcessedReplyContext':
112
- """Cria instância a partir de dicionário."""
113
- return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
114
-
115
-
116
- # ============================================================
117
- # FUNÇÕES AUXILIARES
118
- # ============================================================
119
-
120
- def contar_palavras(texto: str) -> int:
121
- """Conta palavras em um texto."""
122
- if not texto:
123
- return 0
124
- return len(texto.split())
125
-
126
-
127
- def is_pergunta_curta(texto: str) -> bool:
128
- """
129
- Verifica se o texto é uma pergunta curta.
130
-
131
- Args:
132
- texto: Texto a verificar
133
-
134
- Returns:
135
- True se for pergunta com pocas palavras
136
- """
137
- if not texto:
138
- return False
139
-
140
- texto_lower = texto.strip().lower()
141
- word_count = contar_palavras(texto)
142
-
143
- # Deve ter marcador de pergunta ou palavras interrogativas
144
- has_question_marker = '?' in texto
145
- has_interrogative = any(w in texto_lower for w in [
146
- 'qual', 'quais', 'quem', 'como', 'onde', 'quando', 'por que',
147
- 'porque', 'para que', 'o que', 'que', 'é o que', 'vc', 'você',
148
- 'tu', 'meu', 'minha', 'oq', 'oq', 'n'
149
- ])
150
-
151
- return word_count <= PERGUNTA_CURTA_LIMITE and (has_question_marker or has_interrogative)
152
-
153
-
154
- def is_mensagem_vazia_ou_reconhecimento(texto: str) -> bool:
155
- """
156
- Verifica se a mensagem é apenas um sinal de pontuação ou texto muito curto/vazio.
157
- Ajuda a evitar a alucinação de self-reply (onde o bot conversa consigo mesmo).
158
- """
159
- if not texto:
160
- return True
161
-
162
- clean_text = texto.strip()
163
-
164
- # Se for apenas 1-2 caracteres não-alfanuméricos (ex: ".", "..", "!")
165
- import re
166
- if len(clean_text) <= 2 and not re.search(r'[a-zA-Z0-9]', clean_text):
167
- return True
168
-
169
- # Palavras muito curtas e fechadas que soam como reconhecimento e não têm substância
170
- if clean_text.lower() in [".", "vc", "ah", "ok", "hm", "ta"]:
171
- return True
172
-
173
- return False
174
-
175
-
176
- def extrair_context_hint(quoted_text: str, mensagem_atual: str) -> str:
177
- """
178
- Extrai hint de contexto baseado no texto citado e mensagem atual.
179
-
180
- Args:
181
- quoted_text: Texto original citado
182
- mensagem_atual: Mensagem atual do usuário
183
-
184
- Returns:
185
- String de hint de contexto
186
- """
187
- hints = []
188
-
189
- # Detecta tipo de reply
190
- quoted_lower = quoted_text.lower() if quoted_text else ""
191
-
192
- # Pergunta sobre o bot
193
- if any(w in quoted_lower for w in ['akira', 'bot', 'você', 'vc', 'tu']):
194
- hints.append("pergunta_sobre_akira")
195
-
196
- # Pergunta factual
197
- if any(w in quoted_lower for w in ['oq', 'o que', 'qual', 'quanto', 'onde', 'quando']):
198
- hints.append("pergunta_factual")
199
-
200
- # Ironia/deboche detectado
201
- if any(w in quoted_lower for w in ['kkk', 'haha', '😂', '🤣', 'eita']):
202
- hints.append("tom_irreverente")
203
-
204
- # Expressão de opinião
205
- if any(w in quoted_lower for w in ['acho', 'penso', 'creio', 'imagino']):
206
- hints.append("expressao_opiniao")
207
-
208
- return " | ".join(hints) if hints else "contexto_geral"
209
-
210
-
211
- def calcular_prioridade(
212
- is_reply: bool,
213
- reply_to_bot: bool,
214
- mensagem: str,
215
- quoted_text: str = ""
216
- ) -> Tuple[int, float]:
217
- """
218
- Calcula nível de prioridade e importância.
219
-
220
- Args:
221
- is_reply: Se é um reply
222
- reply_to_bot: Se é reply para o bot
223
- mensagem: Mensagem atual
224
- quoted_text: Texto citado
225
-
226
- Returns:
227
- Tupla (priority_level, importancia)
228
- """
229
- if not is_reply:
230
- return PRIORITY_NORMAL, 1.0
231
-
232
- # Reply para o bot
233
- if reply_to_bot:
234
- # Pergunta curta = prioridade máxima
235
- if is_pergunta_curta(mensagem):
236
- return PRIORITY_REPLY_TO_BOT_SHORT_QUESTION, IMPORTANCIA_PERGUNTA_CURTA_REPLY
237
- # Reply normal ao bot
238
- return PRIORITY_REPLY_TO_BOT, IMPORTANCIA_REPLY_TO_BOT
239
-
240
- # Reply para outro usuário
241
- return PRIORITY_REPLY, IMPORTANCIA_REPLY
242
-
243
-
244
- # ============================================================
245
- # CLASSE PRINCIPAL
246
- # ============================================================
247
-
248
- class ReplyContextHandler:
249
- """
250
- Handler dedicado para processar e priorizar contexto de replies.
251
-
252
- Funcionalidades:
253
- - Extração de metadados de reply do payload
254
- - Cálculo automático de prioridade
255
- - Construção de seções de prompt otimizadas
256
- - Integração com ShortTermMemory
257
- - Ajuste adaptativo baseado em tamanho da pergunta
258
- """
259
-
260
- def __init__(self, short_term_memory: Optional[ShortTermMemory] = None):
261
- """
262
- Inicializa o handler.
263
-
264
- Args:
265
- short_term_memory: Instância de ShortTermMemory (opcional)
266
- """
267
- self.short_term_memory = short_term_memory
268
- self.lstm_extension = None # Será inicializado depois se DB disponível
269
- logger.debug("✅ ReplyContextHandler inicializado")
270
-
271
- def enable_lstm(self, lstm_ext: Any) -> None:
272
- """Habilita LSTM extension."""
273
- self.lstm_extension = lstm_ext
274
- logger.debug("✅ LSTM enabled em ReplyContextHandler")
275
-
276
- def process_reply(
277
- self,
278
- mensagem: str,
279
- reply_metadata: Dict[str, Any],
280
- historico_geral: Optional[List[Dict[str, Any]]] = None
281
- ) -> ProcessedReplyContext:
282
- """
283
- Processa metadados de reply e gera contexto processado.
284
-
285
- Args:
286
- mensagem: Mensagem atual do usuário
287
- reply_metadata: Metadados do reply do payload
288
- historico_geral: Histórico geral (opcional)
289
-
290
- Returns:
291
- ProcessedReplyContext pronto para uso
292
- """
293
- # Extrai dados do metadata
294
- is_reply = reply_metadata.get('is_reply', False)
295
- reply_to_bot = reply_metadata.get('reply_to_bot', False)
296
- quoted_author_name = reply_metadata.get('quoted_author_name', '')
297
- quoted_author_numero = reply_metadata.get('quoted_author_numero', '')
298
- quoted_text_original = reply_metadata.get('quoted_text_original', '')
299
- mensagem_citada = reply_metadata.get('mensagem_citada', '') or quoted_text_original
300
-
301
- # 🔧 CRITICAL FIX: Validate that quoted author is NOT the bot itself
302
- # Extract pure number from lid_XXXXX format if present
303
- def extract_pure_number(id_str: str) -> str:
304
- """Extrai número puro de formatos como 'lid_123456' ou '123456'"""
305
- if not id_str:
306
- return ''
307
- # Remove 'lid_' prefix if present
308
- if isinstance(id_str, str) and id_str.startswith('lid_'):
309
- return id_str[4:]
310
- return str(id_str) if id_str else ''
311
-
312
- # ⚠️ SELF-REPLY RECOGNITION
313
- # Check if the quoted author is the bot itself
314
- quoted_author_pure = extract_pure_number(quoted_author_numero)
315
- bot_id_pure = extract_pure_number(config.BOT_NUMERO if hasattr(config, 'BOT_NUMERO') else '37839265886398')
316
-
317
- is_quoted_from_bot = (quoted_author_pure and quoted_author_pure == bot_id_pure)
318
-
319
- if is_quoted_from_bot and is_reply:
320
- logger.info(f"🔄 [REPLY AO BOT] Usuário está respondendo a uma mensagem da Akira ({quoted_author_pure}).")
321
- reply_to_bot = True
322
- quoted_author_name = "Akira (você mesmo)"
323
- quoted_author_numero = config.BOT_NUMERO
324
-
325
- # 🔧 CORREÇÃO FORÇADA: Se o payload já determinou que é reply_to_bot,
326
- # ignora qualquer nome/número que tenha vindo e força para o bot.
327
- if is_reply and reply_to_bot:
328
- quoted_author_name = "Akira (você mesmo)"
329
- quoted_author_numero = config.BOT_NUMERO
330
-
331
- # 🔧 CORREÇÃO: Se autor é desconhecido e não é reply_to_bot explícito, tenta detectar pelo contexto
332
- elif not quoted_author_name or quoted_author_name.lower() in ['desconhecido', 'unknown', '']:
333
- # Detecta pelo conteúdo da mensagem citada
334
- quoted_lower = quoted_text_original.lower() if quoted_text_original else ""
335
-
336
- # Se a mensagem citada contém padrões de resposta do bot
337
- bot_patterns = ['akira:', 'eu sou', 'eu sou a akira', 'sou um bot', 'oi!', 'eae!']
338
- if any(p in quoted_lower for p in bot_patterns):
339
- quoted_author_name = "Akira (você mesmo)"
340
- quoted_author_numero = config.BOT_NUMERO
341
- reply_to_bot = True
342
- elif mensagem_citada:
343
- # Se há histórico, busca última mensagem
344
- if historico_geral:
345
- # Assumir que é reply para a última mensagem do bot
346
- quoted_author_name = "mensagem_anterior"
347
- quoted_author_numero = "unknown"
348
-
349
- # Se ainda não tem autor mas tem mensagem citada e é reply
350
- if is_reply and (not quoted_author_name or quoted_author_name == 'desconhecido'):
351
- # Se é reply_to_bot=True mas autor desconhecido, assume que é reply para o bot
352
- if reply_to_bot:
353
- quoted_author_name = "Akira (você mesmo)"
354
- quoted_author_numero = "BOT"
355
- else:
356
- # Tenta extrair do conteúdo
357
- quoted_author_name = "participante_desconhecido"
358
-
359
- # Calcula prioridade e importância
360
- priority_level, importancia = calcular_prioridade(
361
- is_reply=is_reply,
362
- reply_to_bot=reply_to_bot,
363
- mensagem=mensagem,
364
- quoted_text=quoted_text_original
365
- )
366
-
367
- # Extrai context hint
368
- context_hint = extrair_context_hint(quoted_text_original, mensagem)
369
-
370
- # Calcula multiplicador adaptativo
371
- adaptive_multiplier = self._calculate_adaptive_multiplier(
372
- mensagem=mensagem,
373
- is_reply=is_reply,
374
- priority_level=priority_level
375
- )
376
-
377
- # Determina se deve priorizar no prompt
378
- should_prioritize = is_reply and priority_level >= PRIORITY_REPLY
379
-
380
- # Constrói section do prompt
381
- prompt_section = self._build_reply_prompt_section(
382
- mensagem=mensagem,
383
- mensagem_citada=mensagem_citada,
384
- quoted_author_name=quoted_author_name,
385
- reply_to_bot=reply_to_bot,
386
- context_hint=context_hint,
387
- priority_level=priority_level
388
- )
389
-
390
- # Cria contexto processado
391
- reply_context = ProcessedReplyContext(
392
- is_reply=is_reply,
393
- reply_to_bot=reply_to_bot,
394
- priority_level=priority_level,
395
- quoted_author_name=quoted_author_name,
396
- quoted_author_numero=quoted_author_numero,
397
- quoted_text_original=quoted_text_original,
398
- mensagem_citada=mensagem_citada,
399
- context_hint=context_hint,
400
- importancia=importancia * adaptive_multiplier,
401
- prompt_section=prompt_section,
402
- should_prioritize_reply=should_prioritize,
403
- adaptive_multiplier=adaptive_multiplier
404
- )
405
-
406
- # Adiciona à memória de curto prazo se disponível
407
- if self.short_term_memory and is_reply:
408
- self.short_term_memory.add_message(
409
- role="user",
410
- content=mensagem,
411
- importancia=reply_context.importancia,
412
- reply_info={
413
- "is_reply": True,
414
- "reply_to_bot": reply_to_bot,
415
- "quoted_text_original": quoted_text_original,
416
- "priority_level": priority_level
417
- }
418
- )
419
-
420
- return reply_context
421
-
422
- def _calculate_adaptive_multiplier(
423
- self,
424
- mensagem: str,
425
- is_reply: bool,
426
- priority_level: int
427
- ) -> float:
428
- """
429
- Calcula multiplicador adaptativo baseado no tamanho da pergunta.
430
-
431
- Para perguntas curtas com reply, aumenta a importância do contexto do reply
432
- para garantir que o LLM tenha contexto suficiente.
433
-
434
- Args:
435
- mensagem: Mensagem atual
436
- is_reply: Se é reply
437
- priority_level: Nível de prioridade
438
-
439
- Returns:
440
- Multiplicador entre 1.0 e 2.0
441
- """
442
- if not is_reply:
443
- return 1.0
444
-
445
- word_count = contar_palavras(mensagem)
446
-
447
- # Pergunta muito curta (< 3 palavras) = contexto crítico
448
- if word_count <= 2:
449
- # Proteção contra alucinação
450
- if is_mensagem_vazia_ou_reconhecimento(mensagem):
451
- return 0.5 # Reduz a importância para o bot focar menos no contexto citado
452
- return 1.5
453
-
454
- # Pergunta curta (3-5 palavras) = contexto importante
455
- if word_count <= PERGUNTA_CURTA_LIMITE:
456
- return 1.3
457
-
458
- # Pergunta normal = multiplicador padrão baseado em prioridade
459
- if priority_level == PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
460
- return 1.2
461
- elif priority_level == PRIORITY_REPLY_TO_BOT:
462
- return 1.1
463
-
464
- return 1.0
465
-
466
- def _build_reply_prompt_section(
467
- self,
468
- mensagem: str,
469
- mensagem_citada: str,
470
- quoted_author_name: str,
471
- reply_to_bot: bool,
472
- context_hint: str,
473
- priority_level: int
474
- ) -> str:
475
- """
476
- Constrói seção formatada do prompt para replies.
477
- """
478
- if not mensagem_citada:
479
- return ""
480
-
481
- sections = []
482
-
483
- # Cabeçalho conciso
484
- if priority_level >= PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
485
- sections.append("[REPLY CRÍTICO]")
486
- elif reply_to_bot:
487
- sections.append("[REPLY AO BOT]")
488
-
489
- # Conteúdo
490
- if reply_to_bot:
491
- quoted_preview = mensagem_citada[:150] + ("..." if len(mensagem_citada) > 150 else "")
492
- sections.append(f"Você citou anteriormente: \"{quoted_preview}\"")
493
- else:
494
- sections.append(f"Respondendo a {quoted_author_name}: \"{mensagem_citada[:100]}...\"")
495
-
496
- # Instrução curta
497
- if reply_to_bot:
498
- if is_mensagem_vazia_ou_reconhecimento(mensagem):
499
- sections.append("💡 NOTA: Apenas reconhecimento. Não repita o contexto.")
500
- else:
501
- sections.append("💡 Responda ao comentário do usuário sobre sua fala anterior sem narrar o processo.")
502
-
503
- return "\n".join(sections)
504
-
505
- def prioritize_reply_context(
506
- self,
507
- prompt: str,
508
- reply_context: ProcessedReplyContext,
509
- historico_geral: Optional[List[Dict[str, Any]]] = None
510
- ) -> str:
511
- """
512
- Injeta contexto de reply no prompt com alta prioridade.
513
-
514
- Args:
515
- prompt: Prompt original
516
- reply_context: Contexto de reply processado
517
- historico_geral: Histórico geral (opcional)
518
-
519
- Returns:
520
- Prompt enriquecido com contexto de reply
521
- """
522
- if not reply_context.is_reply or not reply_context.prompt_section:
523
- return prompt
524
-
525
- # Insere contexto de reply no início do prompt
526
- reply_block = f"""
527
- {'='*60}
528
- {reply_context.prompt_section}
529
- {'='*60}
530
- """
531
-
532
- # Determina posição de inserção
533
- # Se há seção [SYSTEM], insere após ela
534
- if "[SYSTEM]" in prompt:
535
- # Encontra final da seção SYSTEM
536
- system_end = prompt.find("[/SYSTEM]")
537
- if system_end != -1:
538
- return prompt[:system_end + 10] + reply_block + prompt[system_end + 10:]
539
-
540
- # Caso contrário, insere no início
541
- return reply_block + "\n" + prompt
542
-
543
- def get_reply_summary_for_llm(self, reply_context: ProcessedReplyContext) -> str:
544
- """
545
- Retorna resumo formatado do reply para contexto do LLM.
546
-
547
- Args:
548
- reply_context: Contexto de reply processado
549
-
550
- Returns:
551
- String resumida para uso no contexto
552
- """
553
- if not reply_context.is_reply:
554
- return ""
555
-
556
- parts = []
557
-
558
- if reply_context.reply_to_bot:
559
- parts.append("REPLY DIRETO AO BOT")
560
- else:
561
- parts.append(f"REPLY a {reply_context.quoted_author_name}")
562
-
563
- if reply_context.mensagem_citada:
564
- cited = reply_context.mensagem_citada[:100]
565
- parts.append(f"Citando: \"{cited}\"")
566
-
567
- if reply_context.priority_level >= PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
568
- parts.append("PERGUNTA CURTA - Prioridade Alta")
569
-
570
- return " | ".join(parts)
571
-
572
- def merge_reply_into_history(
573
- self,
574
- reply_context: ProcessedReplyContext,
575
- history: List[Dict[str, str]]
576
- ) -> List[Dict[str, str]]:
577
- """
578
- Mescla contexto de reply no histórico para o LLM.
579
-
580
- Args:
581
- reply_context: Contexto de reply processado
582
- history: Histórico formatado para LLM
583
-
584
- Returns:
585
- Histórico com reply injetado no início
586
- """
587
- if not reply_context.is_reply:
588
- return history
589
-
590
- # Cria entry para o reply
591
- reply_entry = {
592
- "role": "user",
593
- "content": f"[REPLY] {reply_context.get_reply_summary_for_llm(reply_context)}"
594
- }
595
-
596
- # Adiciona texto citado se disponível
597
- if reply_context.mensagem_citada:
598
- reply_entry["content"] += f"\n\nMensagem citada:\n{reply_context.mensagem_citada}"
599
-
600
- # Insere no início do histórico
601
- return [reply_entry] + history
602
-
603
- def calculate_token_budget(
604
- self,
605
- reply_context: ProcessedReplyContext,
606
- total_budget: int = 8000
607
- ) -> Tuple[int, int]:
608
- """
609
- Calcula alocação de tokens entre reply e contexto geral.
610
-
611
- Args:
612
- reply_context: Contexto de reply
613
- total_budget: Total de tokens disponíveis
614
-
615
- Returns:
616
- Tupla (tokens_para_reply, tokens_para_contexto)
617
- """
618
- if not reply_context.is_reply:
619
- return 0, total_budget
620
-
621
- # Pergunta curta com reply = mais tokens para reply
622
- if reply_context.priority_level >= PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
623
- reply_tokens = min(1500, int(total_budget * 0.25))
624
- elif reply_context.reply_to_bot:
625
- reply_tokens = min(1000, int(total_budget * 0.15))
626
- else:
627
- reply_tokens = min(800, int(total_budget * 0.10))
628
-
629
- return reply_tokens, total_budget - reply_tokens
630
-
631
- # ============================================================
632
- # HELPERS PARA API
633
- # ============================================================
634
-
635
- @staticmethod
636
- def extract_reply_metadata_from_request(data: Dict[str, Any]) -> Dict[str, Any]:
637
- """
638
- Extrai metadados de reply de um request da API.
639
-
640
- Args:
641
- data: Payload do request
642
-
643
- Returns:
644
- Dict com metadados de reply
645
- """
646
- reply_metadata = data.get('reply_metadata', {})
647
-
648
- # Se não há reply_metadata, tenta extrair de campos individuais
649
- if not reply_metadata:
650
- mensagem_citada = data.get('mensagem_citada', '')
651
- if mensagem_citada:
652
- reply_metadata = {
653
- 'is_reply': True,
654
- 'quoted_text_original': mensagem_citada,
655
- 'mensagem_citada': mensagem_citada
656
- }
657
- else:
658
- return {'is_reply': False}
659
-
660
- # Garante campos obrigatórios
661
- return {
662
- 'is_reply': reply_metadata.get('is_reply', False),
663
- 'reply_to_bot': reply_metadata.get('reply_to_bot', False),
664
- 'quoted_author_name': reply_metadata.get('quoted_author_name', ''),
665
- 'quoted_author_numero': reply_metadata.get('quoted_author_numero', ''),
666
- 'quoted_type': reply_metadata.get('quoted_type', 'texto'),
667
- 'quoted_text_original': reply_metadata.get('quoted_text_original', ''),
668
- 'context_hint': reply_metadata.get('context_hint', ''),
669
- 'mensagem_citada': reply_metadata.get('mensagem_citada', '')
670
- }
671
-
672
- def validate_reply_priority(self, reply_context: ProcessedReplyContext) -> bool:
673
- """
674
- Valida se a prioridade calculada está correta.
675
-
676
- Args:
677
- reply_context: Contexto a validar
678
-
679
- Returns:
680
- True se válido
681
- """
682
- if not reply_context.is_reply:
683
- return reply_context.priority_level == PRIORITY_NORMAL
684
-
685
- # Reply para bot + pergunta curta deve ter prioridade máxima
686
- if reply_context.reply_to_bot and is_pergunta_curta(reply_context.mensagem_citada):
687
- return reply_context.priority_level == PRIORITY_REPLY_TO_BOT_SHORT_QUESTION
688
-
689
- # Reply para bot deve ter alta prioridade
690
- if reply_context.reply_to_bot:
691
- return reply_context.priority_level >= PRIORITY_REPLY_TO_BOT
692
-
693
- # Reply normal deve ter prioridade >= 2
694
- return reply_context.priority_level >= PRIORITY_REPLY
695
-
696
- def __repr__(self) -> str:
697
- """Representação textual."""
698
- mem_status = "com STM" if self.short_term_memory else "sem STM"
699
- return f"ReplyContextHandler({mem_status})"
700
-
701
-
702
- # ============================================================
703
- # FUNÇÕES DE FÁBRICA
704
- # ============================================================
705
-
706
- def criar_reply_handler(
707
- short_term_memory: Optional[ShortTermMemory] = None
708
- ) -> ReplyContextHandler:
709
- """
710
- Factory function para criar ReplyContextHandler.
711
-
712
- Args:
713
- short_term_memory: Instância de ShortTermMemory (opcional)
714
-
715
- Returns:
716
- ReplyContextHandler instance
717
- """
718
- return ReplyContextHandler(short_term_memory=short_term_memory)
719
-
720
-
721
- def processar_reply_request(
722
- mensagem: str,
723
- request_data: Dict[str, Any],
724
- short_term_memory: Optional[ShortTermMemory] = None
725
- ) -> ProcessedReplyContext:
726
- """
727
- Função helper para processar reply de request.
728
-
729
- Args:
730
- mensagem: Mensagem atual
731
- request_data: Payload do request
732
- short_term_memory: Instância de ShortTermMemory (opcional)
733
-
734
- Returns:
735
- ProcessedReplyContext
736
- """
737
- handler = criar_reply_handler(short_term_memory)
738
- reply_metadata = handler.extract_reply_metadata_from_request(request_data)
739
- return handler.process_reply(mensagem, reply_metadata)
740
-
741
-
742
- # ============================================================
743
- # COMPATIBILIDADE — aliases para imports legados
744
- # ============================================================
745
-
746
- _reply_handler_singleton = None
747
-
748
- def get_context_handler(short_term_memory=None) -> ReplyContextHandler:
749
- """Alias legado de get_context_handler → retorna singleton de ReplyContextHandler."""
750
- global _reply_handler_singleton
751
- if _reply_handler_singleton is None:
752
- _reply_handler_singleton = ReplyContextHandler(short_term_memory=short_term_memory)
753
- return _reply_handler_singleton
754
-
755
-
756
- # type: ignore
757
-
758
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # type: ignore
2
+ """
3
+ ================================================================================
4
+ AKIRA V21 ULTIMATE - REPLY CONTEXT HANDLER MODULE
5
+ ================================================================================
6
+ Sistema dedicado para processar e priorizar contexto de replies.
7
+ Garante que replies tenham prioridade ligeiramente maior que o contexto geral,
8
+ especialmente em perguntas curtas.
9
+
10
+ Features:
11
+ - Extração e processamento de metadados de reply
12
+ - 3 níveis de prioridade (1=normal, 2=reply, 3=reply-to-bot+pergunta-curta)
13
+ - Construção de prompt sections otimizadas para replies
14
+ - Integração com ShortTermMemory
15
+ - Context hint extraction para melhor compreensão
16
+ ================================================================================
17
+ """
18
+
19
+ import os
20
+ import sys
21
+ import time
22
+ import json
23
+ import re
24
+ import logging
25
+ from typing import Optional, Dict, Any, List, Tuple
26
+ from dataclasses import dataclass, field
27
+
28
+ # Imports robustos com fallback - CORRIGIDO para usar modules.
29
+ try:
30
+ from . import config
31
+ from .short_term_memory import ShortTermMemory, MessageWithContext, IMPORTANCIA_REPLY, IMPORTANCIA_REPLY_TO_BOT, IMPORTANCIA_PERGUNTA_CURTA_REPLY
32
+ REPLY_HANDLER_AVAILABLE = True
33
+ except ImportError:
34
+ try:
35
+ import modules.config as config
36
+ from modules.short_term_memory import ShortTermMemory, MessageWithContext, IMPORTANCIA_REPLY, IMPORTANCIA_REPLY_TO_BOT, IMPORTANCIA_PERGUNTA_CURTA_REPLY
37
+ REPLY_HANDLER_AVAILABLE = True
38
+ except ImportError:
39
+ try:
40
+ from short_term_memory import ShortTermMemory, MessageWithContext, IMPORTANCIA_REPLY, IMPORTANCIA_REPLY_TO_BOT, IMPORTANCIA_PERGUNTA_CURTA_REPLY
41
+ REPLY_HANDLER_AVAILABLE = True
42
+ except ImportError:
43
+ REPLY_HANDLER_AVAILABLE = False
44
+ config = None
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ # ============================================================
49
+ # NÍVEIS DE PRIORIDADE
50
+ # ============================================================
51
+
52
+ PRIORITY_NORMAL = 1
53
+ PRIORITY_REPLY = 2
54
+ PRIORITY_REPLY_TO_BOT = 3
55
+ PRIORITY_REPLY_TO_BOT_SHORT_QUESTION = 4 # Prioridade máxima!
56
+
57
+ # Limite de palavras para "pergunta curta"
58
+ PERGUNTA_CURTA_LIMITE: int = 5
59
+
60
+
61
+ @dataclass
62
+ class ProcessedReplyContext:
63
+ """
64
+ Contexto de reply processado e pronto para uso.
65
+
66
+ Attributes:
67
+ is_reply: Se é um reply
68
+ reply_to_bot: Se é reply direcionado ao bot
69
+ priority_level: Nível de prioridade (1-4)
70
+ quoted_author_name: Nome do autor da mensagem citada
71
+ quoted_author_numero: Número do autor
72
+ quoted_text_original: Texto original citado
73
+ mensagem_citada: Texto da mensagem citada
74
+ context_hint: Hint de contexto extraído
75
+ importancia: Peso de importância calculado
76
+ prompt_section: Section formatada para o prompt
77
+ should_prioritize_reply: Se deve priorizar no prompt
78
+ adaptive_multiplier: Multiplicador adaptativo baseado no tamanho
79
+ """
80
+ is_reply: bool = False
81
+ reply_to_bot: bool = False
82
+ priority_level: int = PRIORITY_NORMAL
83
+ quoted_author_name: str = ""
84
+ quoted_author_numero: str = ""
85
+ quoted_text_original: str = ""
86
+ mensagem_citada: str = ""
87
+ context_hint: str = ""
88
+ importancia: float = 1.0
89
+ prompt_section: str = ""
90
+ should_prioritize_reply: bool = False
91
+ adaptive_multiplier: float = 1.0
92
+
93
+ def to_dict(self) -> Dict[str, Any]:
94
+ """Converte para dicionário."""
95
+ return {
96
+ "is_reply": self.is_reply,
97
+ "reply_to_bot": self.reply_to_bot,
98
+ "priority_level": self.priority_level,
99
+ "quoted_author_name": self.quoted_author_name,
100
+ "quoted_author_numero": self.quoted_author_numero,
101
+ "quoted_text_original": self.quoted_text_original,
102
+ "mensagem_citada": self.mensagem_citada,
103
+ "context_hint": self.context_hint,
104
+ "importancia": self.importancia,
105
+ "prompt_section": self.prompt_section,
106
+ "should_prioritize_reply": self.should_prioritize_reply,
107
+ "adaptive_multiplier": self.adaptive_multiplier
108
+ }
109
+
110
+ @classmethod
111
+ def from_dict(cls, data: Dict[str, Any]) -> 'ProcessedReplyContext':
112
+ """Cria instância a partir de dicionário."""
113
+ return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
114
+
115
+
116
+ # ============================================================
117
+ # FUNÇÕES AUXILIARES
118
+ # ============================================================
119
+
120
+ def contar_palavras(texto: str) -> int:
121
+ """Conta palavras em um texto."""
122
+ if not texto:
123
+ return 0
124
+ return len(texto.split())
125
+
126
+
127
+ def is_pergunta_curta(texto: str) -> bool:
128
+ """
129
+ Verifica se o texto é uma pergunta curta.
130
+
131
+ Args:
132
+ texto: Texto a verificar
133
+
134
+ Returns:
135
+ True se for pergunta com pocas palavras
136
+ """
137
+ if not texto:
138
+ return False
139
+
140
+ texto_lower = texto.strip().lower()
141
+ word_count = contar_palavras(texto)
142
+
143
+ # Deve ter marcador de pergunta ou palavras interrogativas
144
+ has_question_marker = '?' in texto
145
+ has_interrogative = any(w in texto_lower for w in [
146
+ 'qual', 'quais', 'quem', 'como', 'onde', 'quando', 'por que',
147
+ 'porque', 'para que', 'o que', 'que', 'é o que', 'vc', 'você',
148
+ 'tu', 'meu', 'minha', 'oq', 'oq', 'n'
149
+ ])
150
+
151
+ return word_count <= PERGUNTA_CURTA_LIMITE and (has_question_marker or has_interrogative)
152
+
153
+
154
+ def is_mensagem_vazia_ou_reconhecimento(texto: str) -> bool:
155
+ """
156
+ Verifica se a mensagem é apenas um sinal de pontuação ou texto muito curto/vazio.
157
+ Ajuda a evitar a alucinação de self-reply (onde o bot conversa consigo mesmo).
158
+ """
159
+ if not texto:
160
+ return True
161
+
162
+ clean_text = texto.strip()
163
+
164
+ # Se for apenas 1-2 caracteres não-alfanuméricos (ex: ".", "..", "!")
165
+ import re
166
+ if len(clean_text) <= 2 and not re.search(r'[a-zA-Z0-9]', clean_text):
167
+ return True
168
+
169
+ # Palavras muito curtas e fechadas que soam como reconhecimento e não têm substância
170
+ if clean_text.lower() in [".", "vc", "ah", "ok", "hm", "ta"]:
171
+ return True
172
+
173
+ return False
174
+
175
+
176
+ def extrair_context_hint(quoted_text: str, mensagem_atual: str) -> str:
177
+ """
178
+ Extrai hint de contexto baseado no texto citado e mensagem atual.
179
+
180
+ Args:
181
+ quoted_text: Texto original citado
182
+ mensagem_atual: Mensagem atual do usuário
183
+
184
+ Returns:
185
+ String de hint de contexto
186
+ """
187
+ hints = []
188
+
189
+ # Detecta tipo de reply
190
+ quoted_lower = quoted_text.lower() if quoted_text else ""
191
+
192
+ # Pergunta sobre o bot
193
+ if any(w in quoted_lower for w in ['akira', 'bot', 'você', 'vc', 'tu']):
194
+ hints.append("pergunta_sobre_akira")
195
+
196
+ # Pergunta factual
197
+ if any(w in quoted_lower for w in ['oq', 'o que', 'qual', 'quanto', 'onde', 'quando']):
198
+ hints.append("pergunta_factual")
199
+
200
+ # Ironia/deboche detectado
201
+ if any(w in quoted_lower for w in ['kkk', 'haha', '😂', '🤣', 'eita']):
202
+ hints.append("tom_irreverente")
203
+
204
+ # Expressão de opinião
205
+ if any(w in quoted_lower for w in ['acho', 'penso', 'creio', 'imagino']):
206
+ hints.append("expressao_opiniao")
207
+
208
+ return " | ".join(hints) if hints else "contexto_geral"
209
+
210
+
211
+ def calcular_prioridade(
212
+ is_reply: bool,
213
+ reply_to_bot: bool,
214
+ mensagem: str,
215
+ quoted_text: str = ""
216
+ ) -> Tuple[int, float]:
217
+ """
218
+ Calcula nível de prioridade e importância.
219
+
220
+ Args:
221
+ is_reply: Se é um reply
222
+ reply_to_bot: Se é reply para o bot
223
+ mensagem: Mensagem atual
224
+ quoted_text: Texto citado
225
+
226
+ Returns:
227
+ Tupla (priority_level, importancia)
228
+ """
229
+ if not is_reply:
230
+ return PRIORITY_NORMAL, 1.0
231
+
232
+ # Reply para o bot
233
+ if reply_to_bot:
234
+ # Pergunta curta = prioridade máxima
235
+ if is_pergunta_curta(mensagem):
236
+ return PRIORITY_REPLY_TO_BOT_SHORT_QUESTION, IMPORTANCIA_PERGUNTA_CURTA_REPLY
237
+ # Reply normal ao bot
238
+ return PRIORITY_REPLY_TO_BOT, IMPORTANCIA_REPLY_TO_BOT
239
+
240
+ # Reply para outro usuário
241
+ return PRIORITY_REPLY, IMPORTANCIA_REPLY
242
+
243
+
244
+ # ============================================================
245
+ # CLASSE PRINCIPAL
246
+ # ============================================================
247
+
248
+ class ReplyContextHandler:
249
+ """
250
+ Handler dedicado para processar e priorizar contexto de replies.
251
+
252
+ Funcionalidades:
253
+ - Extração de metadados de reply do payload
254
+ - Cálculo automático de prioridade
255
+ - Construção de seções de prompt otimizadas
256
+ - Integração com ShortTermMemory
257
+ - Ajuste adaptativo baseado em tamanho da pergunta
258
+ """
259
+
260
+ def __init__(self, short_term_memory: Optional[ShortTermMemory] = None):
261
+ """
262
+ Inicializa o handler.
263
+
264
+ Args:
265
+ short_term_memory: Instância de ShortTermMemory (opcional)
266
+ """
267
+ self.short_term_memory = short_term_memory
268
+ self.lstm_extension = None # Será inicializado depois se DB disponível
269
+ logger.debug("✅ ReplyContextHandler inicializado")
270
+
271
+ def enable_lstm(self, lstm_ext: Any) -> None:
272
+ """Habilita LSTM extension."""
273
+ self.lstm_extension = lstm_ext
274
+ logger.debug("✅ LSTM enabled em ReplyContextHandler")
275
+
276
+ def process_reply(
277
+ self,
278
+ mensagem: str,
279
+ reply_metadata: Dict[str, Any],
280
+ historico_geral: Optional[List[Dict[str, Any]]] = None
281
+ ) -> ProcessedReplyContext:
282
+ """
283
+ Processa metadados de reply e gera contexto processado.
284
+
285
+ Args:
286
+ mensagem: Mensagem atual do usuário
287
+ reply_metadata: Metadados do reply do payload
288
+ historico_geral: Histórico geral (opcional)
289
+
290
+ Returns:
291
+ ProcessedReplyContext pronto para uso
292
+ """
293
+ # Extrai dados do metadata
294
+ is_reply = reply_metadata.get('is_reply', False)
295
+ reply_to_bot = reply_metadata.get('reply_to_bot', False)
296
+ quoted_author_name = reply_metadata.get('quoted_author_name', '')
297
+ quoted_author_numero = reply_metadata.get('quoted_author_numero', '')
298
+ quoted_text_original = reply_metadata.get('quoted_text_original', '')
299
+ mensagem_citada = reply_metadata.get('mensagem_citada', '') or quoted_text_original
300
+
301
+ # 🔧 CRITICAL FIX: Validate that quoted author is NOT the bot itself
302
+ # Extract pure number from lid_XXXXX format if present
303
+ def extract_pure_number(id_str: str) -> str:
304
+ """Extrai número puro de formatos como 'lid_123456' ou '123456'"""
305
+ if not id_str:
306
+ return ''
307
+ # Remove 'lid_' prefix if present
308
+ if isinstance(id_str, str) and id_str.startswith('lid_'):
309
+ return id_str[4:]
310
+ return str(id_str) if id_str else ''
311
+
312
+ # ⚠️ SELF-REPLY RECOGNITION
313
+ # Check if the quoted author is the bot itself
314
+ quoted_author_pure = extract_pure_number(quoted_author_numero)
315
+ bot_id_pure = extract_pure_number(config.BOT_NUMERO if hasattr(config, 'BOT_NUMERO') else '37839265886398')
316
+
317
+ is_quoted_from_bot = (quoted_author_pure and quoted_author_pure == bot_id_pure)
318
+
319
+ if is_quoted_from_bot and is_reply:
320
+ logger.info(f"🔄 [REPLY AO BOT] Usuário está respondendo a uma mensagem da Akira ({quoted_author_pure}).")
321
+ reply_to_bot = True
322
+ quoted_author_name = "Akira (você mesmo)"
323
+ quoted_author_numero = config.BOT_NUMERO
324
+
325
+ # 🔧 CORREÇÃO FORÇADA: Se o payload já determinou que é reply_to_bot,
326
+ # ignora qualquer nome/número que tenha vindo e força para o bot.
327
+ if is_reply and reply_to_bot:
328
+ quoted_author_name = "Akira (você mesmo)"
329
+ quoted_author_numero = config.BOT_NUMERO
330
+
331
+ # 🔧 CORREÇÃO: Se autor é desconhecido e não é reply_to_bot explícito, tenta detectar pelo contexto
332
+ elif not quoted_author_name or quoted_author_name.lower() in ['desconhecido', 'unknown', '']:
333
+ # Detecta pelo conteúdo da mensagem citada
334
+ quoted_lower = quoted_text_original.lower() if quoted_text_original else ""
335
+
336
+ # Se a mensagem citada contém padrões de resposta do bot
337
+ bot_patterns = ['akira:', 'eu sou', 'eu sou a akira', 'sou um bot', 'oi!', 'eae!']
338
+ if any(p in quoted_lower for p in bot_patterns):
339
+ quoted_author_name = "Akira (você mesmo)"
340
+ quoted_author_numero = config.BOT_NUMERO
341
+ reply_to_bot = True
342
+ elif mensagem_citada:
343
+ # Se há histórico, busca última mensagem
344
+ if historico_geral:
345
+ # Assumir que é reply para a última mensagem do bot
346
+ quoted_author_name = "mensagem_anterior"
347
+ quoted_author_numero = "unknown"
348
+
349
+ # Se ainda não tem autor mas tem mensagem citada e é reply
350
+ if is_reply and (not quoted_author_name or quoted_author_name == 'desconhecido'):
351
+ # Se é reply_to_bot=True mas autor desconhecido, assume que é reply para o bot
352
+ if reply_to_bot:
353
+ quoted_author_name = "Akira (você mesmo)"
354
+ quoted_author_numero = "BOT"
355
+ else:
356
+ # Tenta extrair do conteúdo
357
+ quoted_author_name = "participante_desconhecido"
358
+
359
+ # Calcula prioridade e importância
360
+ priority_level, importancia = calcular_prioridade(
361
+ is_reply=is_reply,
362
+ reply_to_bot=reply_to_bot,
363
+ mensagem=mensagem,
364
+ quoted_text=quoted_text_original
365
+ )
366
+
367
+ # Extrai context hint
368
+ context_hint = extrair_context_hint(quoted_text_original, mensagem)
369
+
370
+ # Calcula multiplicador adaptativo
371
+ adaptive_multiplier = self._calculate_adaptive_multiplier(
372
+ mensagem=mensagem,
373
+ is_reply=is_reply,
374
+ priority_level=priority_level
375
+ )
376
+
377
+ # Determina se deve priorizar no prompt
378
+ should_prioritize = is_reply and priority_level >= PRIORITY_REPLY
379
+
380
+ # Constrói section do prompt
381
+ prompt_section = self._build_reply_prompt_section(
382
+ mensagem=mensagem,
383
+ mensagem_citada=mensagem_citada,
384
+ quoted_author_name=quoted_author_name,
385
+ reply_to_bot=reply_to_bot,
386
+ context_hint=context_hint,
387
+ priority_level=priority_level
388
+ )
389
+
390
+ # Cria contexto processado
391
+ reply_context = ProcessedReplyContext(
392
+ is_reply=is_reply,
393
+ reply_to_bot=reply_to_bot,
394
+ priority_level=priority_level,
395
+ quoted_author_name=quoted_author_name,
396
+ quoted_author_numero=quoted_author_numero,
397
+ quoted_text_original=quoted_text_original,
398
+ mensagem_citada=mensagem_citada,
399
+ context_hint=context_hint,
400
+ importancia=importancia * adaptive_multiplier,
401
+ prompt_section=prompt_section,
402
+ should_prioritize_reply=should_prioritize,
403
+ adaptive_multiplier=adaptive_multiplier
404
+ )
405
+
406
+ # Adiciona à memória de curto prazo se disponível
407
+ if self.short_term_memory and is_reply:
408
+ self.short_term_memory.add_message(
409
+ role="user",
410
+ content=mensagem,
411
+ importancia=reply_context.importancia,
412
+ reply_info={
413
+ "is_reply": True,
414
+ "reply_to_bot": reply_to_bot,
415
+ "quoted_text_original": quoted_text_original,
416
+ "priority_level": priority_level
417
+ }
418
+ )
419
+
420
+ return reply_context
421
+
422
+ def _calculate_adaptive_multiplier(
423
+ self,
424
+ mensagem: str,
425
+ is_reply: bool,
426
+ priority_level: int
427
+ ) -> float:
428
+ """
429
+ Calcula multiplicador adaptativo baseado no tamanho da pergunta.
430
+
431
+ Para perguntas curtas com reply, aumenta a importância do contexto do reply
432
+ para garantir que o LLM tenha contexto suficiente.
433
+
434
+ Args:
435
+ mensagem: Mensagem atual
436
+ is_reply: Se é reply
437
+ priority_level: Nível de prioridade
438
+
439
+ Returns:
440
+ Multiplicador entre 1.0 e 2.0
441
+ """
442
+ if not is_reply:
443
+ return 1.0
444
+
445
+ word_count = contar_palavras(mensagem)
446
+
447
+ # Pergunta muito curta (< 3 palavras) = contexto crítico
448
+ if word_count <= 2:
449
+ # Proteção contra alucinação
450
+ if is_mensagem_vazia_ou_reconhecimento(mensagem):
451
+ return 0.5 # Reduz a importância para o bot focar menos no contexto citado
452
+ return 1.5
453
+
454
+ # Pergunta curta (3-5 palavras) = contexto importante
455
+ if word_count <= PERGUNTA_CURTA_LIMITE:
456
+ return 1.3
457
+
458
+ # Pergunta normal = multiplicador padrão baseado em prioridade
459
+ if priority_level == PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
460
+ return 1.2
461
+ elif priority_level == PRIORITY_REPLY_TO_BOT:
462
+ return 1.1
463
+
464
+ return 1.0
465
+
466
+ def _build_reply_prompt_section(
467
+ self,
468
+ mensagem: str,
469
+ mensagem_citada: str,
470
+ quoted_author_name: str,
471
+ reply_to_bot: bool,
472
+ context_hint: str,
473
+ priority_level: int
474
+ ) -> str:
475
+ """
476
+ Constrói seção formatada do prompt para replies.
477
+
478
+ Args:
479
+ mensagem: Mensagem atual
480
+ mensagem_citada: Texto citado
481
+ quoted_author_name: Nome do autor
482
+ reply_to_bot: Se é reply para o bot
483
+ context_hint: Hint de contexto
484
+ priority_level: Nível de prioridade
485
+
486
+ Returns:
487
+ String formatada para inserção no prompt
488
+ """
489
+ if not mensagem_citada:
490
+ return ""
491
+
492
+ sections = []
493
+
494
+ # Cabeçalho com nível de prioridade
495
+ if priority_level >= PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
496
+ sections.append("[🔴 REPLY CRÍTICO - PERGUNTA CURTA]")
497
+ elif priority_level == PRIORITY_REPLY_TO_BOT:
498
+ sections.append("[🟡 REPLY AO BOT]")
499
+ elif priority_level == PRIORITY_REPLY:
500
+ sections.append("[🟢 REPLY]")
501
+
502
+ # Contexto do autor e conteúdo
503
+ if reply_to_bot:
504
+ sections.append(f"⚠️ O USUÁRIO ESTÁ RESPONDENDO DIRETAMENTE A VOCÊ!")
505
+ quoted_preview = mensagem_citada[:200] + ("..." if len(mensagem_citada) > 200 else "")
506
+ sections.append(f"O usuário citou sua mensagem anterior: \"{quoted_preview}\"")
507
+ else:
508
+ sections.append(f"Respondendo a: {quoted_author_name}")
509
+ quoted_preview = mensagem_citada[:150] + ("..." if len(mensagem_citada) > 150 else "")
510
+ sections.append(f"Msg citada: \"{quoted_preview}\"")
511
+
512
+ # Hint de contexto
513
+ if context_hint and context_hint != "contexto_geral":
514
+ sections.append(f"Contexto: {context_hint}")
515
+
516
+ # Instrução de resposta (MUITO AGRESSIVA CONTRA NARRATIVA E SELF-REPLY)
517
+ vazia_reconhecimento = is_mensagem_vazia_ou_reconhecimento(mensagem)
518
+
519
+ if reply_to_bot and vazia_reconhecimento:
520
+ sections.append(f"🛑 [ALERTA DE SELF-REPLY]: O usuário respondeu APENAS com '{mensagem}' à sua mensagem. Isso é apenas um reconhecimento ou sinal. NÃO responda a si mesma, NÃO repita a sua mensagem. Reaja com sarcasmo, naturalidade ou pergunte o que o usuário quer dizer.")
521
+ elif priority_level >= PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
522
+ sections.append("💡 RESPONSE: Responda SILENCIOSAMENTE ao contexto. Jamais use prefixos de IA ou narre que está respondendo. Vá direto ao ponto.")
523
+ elif reply_to_bot:
524
+ sections.append("💡 RESPONSE: Você foi citada. NUNCA comece com 'Ah', 'Então', 'Vejo' ou narre o reply. Mate o prefixo e responda direto.")
525
+
526
+ return "\n".join(sections)
527
+
528
+ def prioritize_reply_context(
529
+ self,
530
+ prompt: str,
531
+ reply_context: ProcessedReplyContext,
532
+ historico_geral: Optional[List[Dict[str, Any]]] = None
533
+ ) -> str:
534
+ """
535
+ Injeta contexto de reply no prompt com alta prioridade.
536
+
537
+ Args:
538
+ prompt: Prompt original
539
+ reply_context: Contexto de reply processado
540
+ historico_geral: Histórico geral (opcional)
541
+
542
+ Returns:
543
+ Prompt enriquecido com contexto de reply
544
+ """
545
+ if not reply_context.is_reply or not reply_context.prompt_section:
546
+ return prompt
547
+
548
+ # Insere contexto de reply no início do prompt
549
+ reply_block = f"""
550
+ {'='*60}
551
+ {reply_context.prompt_section}
552
+ {'='*60}
553
+ """
554
+
555
+ # Determina posição de inserção
556
+ # Se há seção [SYSTEM], insere após ela
557
+ if "[SYSTEM]" in prompt:
558
+ # Encontra final da seção SYSTEM
559
+ system_end = prompt.find("[/SYSTEM]")
560
+ if system_end != -1:
561
+ return prompt[:system_end + 10] + reply_block + prompt[system_end + 10:]
562
+
563
+ # Caso contrário, insere no início
564
+ return reply_block + "\n" + prompt
565
+
566
+ def get_reply_summary_for_llm(self, reply_context: ProcessedReplyContext) -> str:
567
+ """
568
+ Retorna resumo formatado do reply para contexto do LLM.
569
+
570
+ Args:
571
+ reply_context: Contexto de reply processado
572
+
573
+ Returns:
574
+ String resumida para uso no contexto
575
+ """
576
+ if not reply_context.is_reply:
577
+ return ""
578
+
579
+ parts = []
580
+
581
+ if reply_context.reply_to_bot:
582
+ parts.append("REPLY DIRETO AO BOT")
583
+ else:
584
+ parts.append(f"REPLY a {reply_context.quoted_author_name}")
585
+
586
+ if reply_context.mensagem_citada:
587
+ cited = reply_context.mensagem_citada[:100]
588
+ parts.append(f"Citando: \"{cited}\"")
589
+
590
+ if reply_context.priority_level >= PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
591
+ parts.append("PERGUNTA CURTA - Prioridade Alta")
592
+
593
+ return " | ".join(parts)
594
+
595
+ def merge_reply_into_history(
596
+ self,
597
+ reply_context: ProcessedReplyContext,
598
+ history: List[Dict[str, str]]
599
+ ) -> List[Dict[str, str]]:
600
+ """
601
+ Mescla contexto de reply no histórico para o LLM.
602
+
603
+ Args:
604
+ reply_context: Contexto de reply processado
605
+ history: Histórico formatado para LLM
606
+
607
+ Returns:
608
+ Histórico com reply injetado no início
609
+ """
610
+ if not reply_context.is_reply:
611
+ return history
612
+
613
+ # Cria entry para o reply
614
+ reply_entry = {
615
+ "role": "user",
616
+ "content": f"[REPLY] {reply_context.get_reply_summary_for_llm(reply_context)}"
617
+ }
618
+
619
+ # Adiciona texto citado se disponível
620
+ if reply_context.mensagem_citada:
621
+ reply_entry["content"] += f"\n\nMensagem citada:\n{reply_context.mensagem_citada}"
622
+
623
+ # Insere no início do histórico
624
+ return [reply_entry] + history
625
+
626
+ def calculate_token_budget(
627
+ self,
628
+ reply_context: ProcessedReplyContext,
629
+ total_budget: int = 8000
630
+ ) -> Tuple[int, int]:
631
+ """
632
+ Calcula alocação de tokens entre reply e contexto geral.
633
+
634
+ Args:
635
+ reply_context: Contexto de reply
636
+ total_budget: Total de tokens disponíveis
637
+
638
+ Returns:
639
+ Tupla (tokens_para_reply, tokens_para_contexto)
640
+ """
641
+ if not reply_context.is_reply:
642
+ return 0, total_budget
643
+
644
+ # Pergunta curta com reply = mais tokens para reply
645
+ if reply_context.priority_level >= PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
646
+ reply_tokens = min(1500, int(total_budget * 0.25))
647
+ elif reply_context.reply_to_bot:
648
+ reply_tokens = min(1000, int(total_budget * 0.15))
649
+ else:
650
+ reply_tokens = min(800, int(total_budget * 0.10))
651
+
652
+ return reply_tokens, total_budget - reply_tokens
653
+
654
+ # ============================================================
655
+ # HELPERS PARA API
656
+ # ============================================================
657
+
658
+ @staticmethod
659
+ def extract_reply_metadata_from_request(data: Dict[str, Any]) -> Dict[str, Any]:
660
+ """
661
+ Extrai metadados de reply de um request da API.
662
+
663
+ Args:
664
+ data: Payload do request
665
+
666
+ Returns:
667
+ Dict com metadados de reply
668
+ """
669
+ reply_metadata = data.get('reply_metadata', {})
670
+
671
+ # Se não há reply_metadata, tenta extrair de campos individuais
672
+ if not reply_metadata:
673
+ mensagem_citada = data.get('mensagem_citada', '')
674
+ if mensagem_citada:
675
+ reply_metadata = {
676
+ 'is_reply': True,
677
+ 'quoted_text_original': mensagem_citada,
678
+ 'mensagem_citada': mensagem_citada
679
+ }
680
+ else:
681
+ return {'is_reply': False}
682
+
683
+ # Garante campos obrigatórios
684
+ return {
685
+ 'is_reply': reply_metadata.get('is_reply', False),
686
+ 'reply_to_bot': reply_metadata.get('reply_to_bot', False),
687
+ 'quoted_author_name': reply_metadata.get('quoted_author_name', ''),
688
+ 'quoted_author_numero': reply_metadata.get('quoted_author_numero', ''),
689
+ 'quoted_type': reply_metadata.get('quoted_type', 'texto'),
690
+ 'quoted_text_original': reply_metadata.get('quoted_text_original', ''),
691
+ 'context_hint': reply_metadata.get('context_hint', ''),
692
+ 'mensagem_citada': reply_metadata.get('mensagem_citada', '')
693
+ }
694
+
695
+ def validate_reply_priority(self, reply_context: ProcessedReplyContext) -> bool:
696
+ """
697
+ Valida se a prioridade calculada está correta.
698
+
699
+ Args:
700
+ reply_context: Contexto a validar
701
+
702
+ Returns:
703
+ True se válido
704
+ """
705
+ if not reply_context.is_reply:
706
+ return reply_context.priority_level == PRIORITY_NORMAL
707
+
708
+ # Reply para bot + pergunta curta deve ter prioridade máxima
709
+ if reply_context.reply_to_bot and is_pergunta_curta(reply_context.mensagem_citada):
710
+ return reply_context.priority_level == PRIORITY_REPLY_TO_BOT_SHORT_QUESTION
711
+
712
+ # Reply para bot deve ter alta prioridade
713
+ if reply_context.reply_to_bot:
714
+ return reply_context.priority_level >= PRIORITY_REPLY_TO_BOT
715
+
716
+ # Reply normal deve ter prioridade >= 2
717
+ return reply_context.priority_level >= PRIORITY_REPLY
718
+
719
+ def __repr__(self) -> str:
720
+ """Representação textual."""
721
+ mem_status = "com STM" if self.short_term_memory else "sem STM"
722
+ return f"ReplyContextHandler({mem_status})"
723
+
724
+
725
+ # ============================================================
726
+ # FUNÇÕES DE FÁBRICA
727
+ # ============================================================
728
+
729
+ def criar_reply_handler(
730
+ short_term_memory: Optional[ShortTermMemory] = None
731
+ ) -> ReplyContextHandler:
732
+ """
733
+ Factory function para criar ReplyContextHandler.
734
+
735
+ Args:
736
+ short_term_memory: Instância de ShortTermMemory (opcional)
737
+
738
+ Returns:
739
+ ReplyContextHandler instance
740
+ """
741
+ return ReplyContextHandler(short_term_memory=short_term_memory)
742
+
743
+
744
+ def processar_reply_request(
745
+ mensagem: str,
746
+ request_data: Dict[str, Any],
747
+ short_term_memory: Optional[ShortTermMemory] = None
748
+ ) -> ProcessedReplyContext:
749
+ """
750
+ Função helper para processar reply de request.
751
+
752
+ Args:
753
+ mensagem: Mensagem atual
754
+ request_data: Payload do request
755
+ short_term_memory: Instância de ShortTermMemory (opcional)
756
+
757
+ Returns:
758
+ ProcessedReplyContext
759
+ """
760
+ handler = criar_reply_handler(short_term_memory)
761
+ reply_metadata = handler.extract_reply_metadata_from_request(request_data)
762
+ return handler.process_reply(mensagem, reply_metadata)
763
+
764
+
765
+ # ============================================================
766
+ # COMPATIBILIDADE — aliases para imports legados
767
+ # ============================================================
768
+
769
+ _reply_handler_singleton = None
770
+
771
+ def get_context_handler(short_term_memory=None) -> ReplyContextHandler:
772
+ """Alias legado de get_context_handler → retorna singleton de ReplyContextHandler."""
773
+ global _reply_handler_singleton
774
+ if _reply_handler_singleton is None:
775
+ _reply_handler_singleton = ReplyContextHandler(short_term_memory=short_term_memory)
776
+ return _reply_handler_singleton
777
+
778
+
779
+ # type: ignore
780
+
781
+
modules/self_awareness.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Self-Awareness Module - Permite IA reconhecer erros e responder a crítica.
3
+
4
+ Criado como parte da Fase 3: Self-Aware Correction
5
+ Data: 2026-05-15
6
+ """
7
+
8
+ import re
9
+ from typing import Dict, Tuple
10
+ from loguru import logger
11
+ from datetime import datetime
12
+
13
+ class SelfAwarenessEngine:
14
+ """Detecta crítica, erro anterior, e permite self-correction."""
15
+
16
+ def __init__(self):
17
+ self.logger = logger
18
+ self.error_memory = {}
19
+
20
+ self.criticism_patterns = [
21
+ r"(?:isso|isso que|que)\s+(?:você\s+)?(?:disse|falou|escreveu)\s+(?:é\s+)?(?:errado|falso|mentira)",
22
+ r"(?:você\s+)?(?:errou|enganou|enganaste)",
23
+ r"(?:tá|está)\s+(?:errado|mal|falso)",
24
+ r"(?:não|n[ã\/]o)\s+(?:é|foi)\s+(?:assim|verdade|correto)",
25
+ ]
26
+
27
+ self.error_acknowledgment = [
28
+ "Você tem razão, cometi erro.",
29
+ "Admito que estava errado.",
30
+ "Obrigado pela correção, você está certo.",
31
+ "Eu me equivoquei naquilo.",
32
+ ]
33
+
34
+ def detect_criticism(self, mensagem: str) -> Tuple[bool, str]:
35
+ """
36
+ Detecta se mensagem é crítica a resposta anterior.
37
+
38
+ Returns:
39
+ (tem_crítica, tipo_crítica)
40
+ """
41
+ mensagem_lower = mensagem.lower()
42
+
43
+ for pattern in self.criticism_patterns:
44
+ if re.search(pattern, mensagem_lower):
45
+ return True, "direct_criticism"
46
+
47
+ if any(phrase in mensagem_lower for phrase in ["na verdade", "corrigindo", "melhor seria"]):
48
+ return True, "implicit_correction"
49
+
50
+ return False, None
51
+
52
+ def generate_self_correction_response(
53
+ self,
54
+ original_response: str,
55
+ correction: str,
56
+ user_id: str
57
+ ) -> str:
58
+ """
59
+ Gera resposta que reconhece erro e corrige.
60
+ """
61
+
62
+ import random
63
+ ack = random.choice(self.error_acknowledgment)
64
+
65
+ response = (
66
+ f"{ack}\n\n"
67
+ f"Então ficaria: {correction}\n\n"
68
+ f"Obrigado por me manter preciso. É assim que melhoro."
69
+ )
70
+
71
+ if user_id not in self.error_memory:
72
+ self.error_memory[user_id] = []
73
+
74
+ self.error_memory[user_id].append({
75
+ "original": original_response,
76
+ "correction": correction,
77
+ "timestamp": datetime.now().isoformat()
78
+ })
79
+
80
+ self.logger.info(f"📝 [SELF-AWARE] Erro registrado para {user_id}")
81
+
82
+ return response
83
+
84
+
85
+ # Instância global
86
+ self_awareness_engine = SelfAwarenessEngine()
modules/sender_attribution_fix.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Monkey-patch for sender attribution bug fix in modules/api.py
3
+ This module patches the akira_endpoint to properly validate and reconstruct sender names
4
+ """
5
+
6
+ import sys
7
+ from functools import wraps
8
+
9
+
10
+ def patch_akira_api():
11
+ """Apply the sender attribution fix by monkey-patching the modules.api module"""
12
+
13
+ try:
14
+ from modules import api
15
+
16
+ # Store original endpoint method
17
+ original_get_blueprint = api.get_blueprint
18
+
19
+ def patched_get_blueprint():
20
+ """Wrapper that patches the blueprint routes"""
21
+ bp = original_get_blueprint()
22
+
23
+ # Get the akira_endpoint from the blueprint
24
+ for rule in bp.defsurl_map.iter_rules():
25
+ if rule.endpoint == 'akira_endpoint':
26
+ original_endpoint = bp.view_functions.get('akira_endpoint')
27
+ if original_endpoint:
28
+ # Wrap the endpoint
29
+ @wraps(original_endpoint)
30
+ def patched_akira_endpoint(*args, **kwargs):
31
+ # Call original
32
+ result = original_endpoint(*args, **kwargs)
33
+ return result
34
+
35
+ bp.view_functions['akira_endpoint'] = patched_akira_endpoint
36
+ break
37
+
38
+ return bp
39
+
40
+ # Replace the function
41
+ api.get_blueprint = patched_get_blueprint
42
+
43
+ print("✅ Sender attribution fix monkey-patch applied to modules.api")
44
+ return True
45
+
46
+ except Exception as e:
47
+ print(f"⚠️ Failed to apply monkey-patch: {e}")
48
+ return False
49
+
50
+
51
+ # Auto-apply when imported
52
+ try:
53
+ patch_akira_api()
54
+ except Exception as e:
55
+ print(f"Error during auto-patch: {e}")
modules/short_term_memory.py CHANGED
@@ -71,6 +71,7 @@ class MessageWithContext:
71
  emocao: Emoção detectada
72
  reply_info: Info sobre reply (se aplicável)
73
  conversation_id: ID da conversa isolada
 
74
  token_count: Contagem aproximada de tokens
75
  """
76
  role: str
@@ -80,6 +81,7 @@ class MessageWithContext:
80
  emocao: str = "neutro"
81
  reply_info: Dict[str, Any] = field(default_factory=dict)
82
  conversation_id: str = ""
 
83
  token_count: int = 0
84
 
85
  def to_dict(self) -> Dict[str, Any]:
@@ -92,6 +94,7 @@ class MessageWithContext:
92
  "emocao": self.emocao,
93
  "reply_info": self.reply_info,
94
  "conversation_id": self.conversation_id,
 
95
  "token_count": self.token_count
96
  }
97
 
@@ -106,6 +109,7 @@ class MessageWithContext:
106
  emocao=data.get("emocao", "neutral"),
107
  reply_info=data.get("reply_info", {}),
108
  conversation_id=data.get("conversation_id", ""),
 
109
  token_count=data.get("token_count", 0)
110
  )
111
 
@@ -274,6 +278,7 @@ class ShortTermMemory:
274
  importancia: float = IMPORTANCIA_NORMAL,
275
  emocao: str = "neutro",
276
  reply_info: Optional[Dict[str, Any]] = None,
 
277
  metadata: Optional[Dict[str, Any]] = None
278
  ) -> MessageWithContext:
279
  """
@@ -298,6 +303,7 @@ class ShortTermMemory:
298
  emocao=emocao,
299
  reply_info=reply_info or {},
300
  conversation_id=self.conversation_id,
 
301
  token_count=estimar_tokens(content)
302
  )
303
 
@@ -323,6 +329,7 @@ class ShortTermMemory:
323
  def add_user_message(
324
  self,
325
  content: str,
 
326
  emocao: str = "neutral",
327
  reply_info: Optional[Dict[str, Any]] = None,
328
  importancia: float = None
@@ -350,6 +357,7 @@ class ShortTermMemory:
350
  return self.add_message(
351
  role="user",
352
  content=content,
 
353
  importancia=importancia,
354
  emocao=emocao,
355
  reply_info=reply_info
@@ -358,6 +366,7 @@ class ShortTermMemory:
358
  def add_assistant_message(
359
  self,
360
  content: str,
 
361
  emocao: str = "neutral",
362
  importancia: float = IMPORTANCIA_NORMAL
363
  ) -> MessageWithContext:
@@ -375,6 +384,7 @@ class ShortTermMemory:
375
  return self.add_message(
376
  role="assistant",
377
  content=content,
 
378
  importancia=importancia,
379
  emocao=emocao
380
  )
 
71
  emocao: Emoção detectada
72
  reply_info: Info sobre reply (se aplicável)
73
  conversation_id: ID da conversa isolada
74
+ author_name: Nome de quem enviou a mensagem (ex: Isaac, Akira, ISA IA)
75
  token_count: Contagem aproximada de tokens
76
  """
77
  role: str
 
81
  emocao: str = "neutro"
82
  reply_info: Dict[str, Any] = field(default_factory=dict)
83
  conversation_id: str = ""
84
+ author_name: str = "Usuário"
85
  token_count: int = 0
86
 
87
  def to_dict(self) -> Dict[str, Any]:
 
94
  "emocao": self.emocao,
95
  "reply_info": self.reply_info,
96
  "conversation_id": self.conversation_id,
97
+ "author_name": self.author_name,
98
  "token_count": self.token_count
99
  }
100
 
 
109
  emocao=data.get("emocao", "neutral"),
110
  reply_info=data.get("reply_info", {}),
111
  conversation_id=data.get("conversation_id", ""),
112
+ author_name=data.get("author_name", "Usuário"),
113
  token_count=data.get("token_count", 0)
114
  )
115
 
 
278
  importancia: float = IMPORTANCIA_NORMAL,
279
  emocao: str = "neutro",
280
  reply_info: Optional[Dict[str, Any]] = None,
281
+ author_name: str = "Usuário",
282
  metadata: Optional[Dict[str, Any]] = None
283
  ) -> MessageWithContext:
284
  """
 
303
  emocao=emocao,
304
  reply_info=reply_info or {},
305
  conversation_id=self.conversation_id,
306
+ author_name=author_name,
307
  token_count=estimar_tokens(content)
308
  )
309
 
 
329
  def add_user_message(
330
  self,
331
  content: str,
332
+ author_name: str = "Usuário",
333
  emocao: str = "neutral",
334
  reply_info: Optional[Dict[str, Any]] = None,
335
  importancia: float = None
 
357
  return self.add_message(
358
  role="user",
359
  content=content,
360
+ author_name=author_name,
361
  importancia=importancia,
362
  emocao=emocao,
363
  reply_info=reply_info
 
366
  def add_assistant_message(
367
  self,
368
  content: str,
369
+ author_name: str = "Usuário",
370
  emocao: str = "neutral",
371
  importancia: float = IMPORTANCIA_NORMAL
372
  ) -> MessageWithContext:
 
384
  return self.add_message(
385
  role="assistant",
386
  content=content,
387
+ author_name=author_name,
388
  importancia=importancia,
389
  emocao=emocao
390
  )
modules/skills_library.py CHANGED
The diff for this file is too large to render. See raw diff
 
modules/thinking_engine.py ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ================================================================================
3
+ THINKING ENGINE - Sistema de Pensamento Profundo Pré-Processamento
4
+ ================================================================================
5
+ Similar a modelos com "thinking tokens" - analisa o que foi perguntado
6
+ ANTES de gerar resposta, resultando em respostas mais acertivas.
7
+
8
+ Features:
9
+ - Análise multi-camada da pergunta/contexto
10
+ - Embeddings especializados para pensamento
11
+ - Detecção de intent implícito
12
+ - Complexidade da pergunta
13
+ - Relacionamentos com LSTM context
14
+ - Cache de pensamentos
15
+ ================================================================================
16
+ """
17
+
18
+ import json
19
+ from typing import Dict, Any, Optional, List
20
+ from loguru import logger
21
+ from sentence_transformers import SentenceTransformer, util
22
+ import numpy as np
23
+
24
+ class ThinkingEngine:
25
+ """Processa pensamento profundo antes de responder."""
26
+
27
+ def __init__(self, db=None):
28
+ """Inicializa com modelo de embedding para análise profunda."""
29
+ self.db = db
30
+ self.thinking_cache = {}
31
+ self.model_thinking = None
32
+ self._load_thinking_model()
33
+
34
+ def _load_thinking_model(self):
35
+ """Carrega modelo especializado para pensamento."""
36
+ try:
37
+ # Usa o modelo centralizado do config (com fallback embutido)
38
+ from . import config
39
+ self.model_thinking = config.get_embedding_model("all-MiniLM-L6-v2")
40
+ if self.model_thinking:
41
+ logger.success("✅ ThinkingEngine: Modelo de pensamento carregado via config")
42
+ else:
43
+ logger.warning("⚠️ ThinkingEngine: Config retornou None para o modelo")
44
+ except Exception as e:
45
+ logger.warning(f"⚠️ ThinkingEngine: Erro ao carregar modelo: {e}")
46
+ self.model_thinking = None
47
+
48
+ def think(
49
+ self,
50
+ mensagem: str,
51
+ contexto_lstm: Optional[Dict[str, Any]] = None,
52
+ historico_recente: Optional[List[str]] = None,
53
+ is_group: bool = False,
54
+ usuario: str = None,
55
+ llm_manager: Any = None
56
+ ) -> Dict[str, Any]:
57
+ """
58
+ Processa pensamento profundo sobre a pergunta/contexto.
59
+
60
+ Args:
61
+ mensagem: Mensagem do usuário
62
+ contexto_lstm: Contexto LSTM (longo prazo)
63
+ historico_recente: Últimas mensagens
64
+ is_group: Se é em grupo
65
+ usuario: Nome do usuário
66
+ llm_manager: Instância de LLMManager para CoT Dinâmico (OpenRouter)
67
+
68
+ Returns:
69
+ Dict com análise profunda
70
+ """
71
+
72
+ if not self.model_thinking:
73
+ return self._thinking_fallback(mensagem)
74
+
75
+ cache_key = f"{usuario}:{mensagem[:50]}"
76
+ if cache_key in self.thinking_cache:
77
+ logger.debug(f"🧠 ThinkingEngine: Pensamento recuperado do cache")
78
+ return self.thinking_cache[cache_key]
79
+
80
+ try:
81
+ thinking_result = {
82
+ "depth": self._analyze_question_complexity(mensagem),
83
+ "intent": self._detect_intent(mensagem),
84
+ "entities": self._extract_entities(mensagem),
85
+ "context_relevance": self._analyze_context_relevance(mensagem, contexto_lstm),
86
+ "related_topics": self._find_related_topics(mensagem, contexto_lstm),
87
+ "assumptions": self._detect_assumptions(mensagem),
88
+ "required_sources": self._identify_sources(mensagem),
89
+ "response_strategy": self._plan_response_strategy(mensagem, is_group),
90
+ "quality_markers": self._identify_quality_markers(mensagem),
91
+ }
92
+
93
+ # 🧠 CoT Dinâmico: Chama o OpenRouter para raciocínio estruturado
94
+ dynamic_thought = self._generate_dynamic_thought(
95
+ mensagem, contexto_lstm, historico_recente, is_group, llm_manager, usuario
96
+ )
97
+ if dynamic_thought:
98
+ thinking_result["dynamic_thought_trace"] = dynamic_thought
99
+
100
+ # Cache por 30 minutos (300 chamadas)
101
+ if len(self.thinking_cache) > 1000:
102
+ self.thinking_cache.clear()
103
+
104
+ self.thinking_cache[cache_key] = thinking_result
105
+
106
+ logger.debug(f"🧠 ThinkingEngine: Pensamento realizado (depth={thinking_result['depth']})")
107
+ return thinking_result
108
+
109
+ except Exception as e:
110
+ logger.warning(f"⚠️ ThinkingEngine erro: {e}")
111
+ return self._thinking_fallback(mensagem)
112
+
113
+ def _generate_dynamic_thought(
114
+ self,
115
+ mensagem: str,
116
+ contexto_lstm: Optional[Dict[str, Any]],
117
+ historico_recente: Optional[List[str]],
118
+ is_group: bool,
119
+ llm_manager: Any,
120
+ usuario: str = "desconhecido"
121
+ ) -> Optional[str]:
122
+ """Usa o OpenRouter para gerar um plano de raciocínio passo a passo."""
123
+ if not llm_manager:
124
+ logger.warning("⚠️ CoT Dinâmico abortado: llm_manager é None")
125
+ return None
126
+
127
+ if not hasattr(llm_manager, '_call_openrouter'):
128
+ logger.warning(f"⚠️ CoT Dinâmico abortado: llm_manager ({type(llm_manager)}) não tem o método '_call_openrouter'")
129
+ return None
130
+
131
+ try:
132
+ # Constrói um contexto enxuto para não gastar muitos tokens
133
+ sys_prompt = (
134
+ "Atuas como o Motor Analítico Interno da Akira V21.\n"
135
+ "A tua ÚNICA tarefa é gerar um rascunho de raciocínio (plano lógico) sobre como a Akira deve responder a esta mensagem, deixa sempre claro akira que sua resposta deve ser curta e direta e séria.\n"
136
+ "Reflete sobre:\n"
137
+ f"1. A emoção e intenção oculta de {usuario}.\n"
138
+ "2. Que factos devem ser procurados no histórico.\n"
139
+ "3. Qual o tom (direto, empático, sério) a usar.\n"
140
+ f"NOTA: A pessoa a falar contigo chama-se '{usuario}'. Usa o nome real na tua análise em vez de 'o utilizador'.\n"
141
+ "NÃO dês a resposta final. Apenas planeia a estratégia de resposta em menos de 80 palavras ed deia sugestões de resposta pra akira usar, lembrando ela não mandar emojis. GERA O TEU PENSAMENTO EXCLUSIVAMENTE EM PORTUGUÊS."
142
+ )
143
+
144
+ if is_group:
145
+ sys_prompt += "\nNOTA: Isto é um ambiente de GRUPO. Sê muito conciso e evita intervir desnecessariamente."
146
+
147
+ if contexto_lstm:
148
+ sys_prompt += "\n\n[MEMÓRIA LONGO PRAZO (LSTM)]"
149
+ if 'topic_principal' in contexto_lstm:
150
+ sys_prompt += f"\n- Tópico Principal: {contexto_lstm['topic_principal']}"
151
+ if 'unanswered_questions' in contexto_lstm and contexto_lstm['unanswered_questions']:
152
+ sys_prompt += f"\n- Perguntas Pendentes: {', '.join(contexto_lstm['unanswered_questions'][:2])}"
153
+ if 'interaction_pattern' in contexto_lstm:
154
+ sys_prompt += f"\n- Padrão do Utilizador: {contexto_lstm['interaction_pattern']}"
155
+
156
+ if historico_recente:
157
+ sys_prompt += "\n\n[MEMÓRIA CURTO PRAZO (LISTEN)]\nÚltimas mensagens da conversa:\n"
158
+ # Pega mais mensagens para entender conversas paralelas
159
+ for msg in historico_recente[-15:]:
160
+ if isinstance(msg, dict) and "content" in msg:
161
+ sys_prompt += f"{msg['content']}\n"
162
+ else:
163
+ sys_prompt += f"{msg}\n"
164
+
165
+ logger.info("🧠 Gerando CoT Dinâmico via OpenRouter...")
166
+
167
+ # Chamada ultrarrápida usando o modelo setado no config
168
+ thought = llm_manager._call_openrouter(
169
+ system_prompt=sys_prompt,
170
+ context_history=[], # não passamos o histórico todo para ser super rápido
171
+ user_prompt=mensagem,
172
+ max_tokens=150
173
+ )
174
+
175
+ return thought
176
+ except Exception as e:
177
+ logger.warning(f"⚠️ Erro no CoT Dinâmico (Fallback ativado): {e}")
178
+ return None
179
+
180
+
181
+ def _analyze_question_complexity(self, mensagem: str) -> str:
182
+ """Analisa complexidade da pergunta."""
183
+ msg_lower = mensagem.lower()
184
+
185
+ # Sinais de complexidade
186
+ complex_markers = {
187
+ "muito": 0.3, "profundo": 0.4, "explique": 0.35, "detalhe": 0.35,
188
+ "por quê": 0.4, "como": 0.3, "quando": 0.25, "onde": 0.2,
189
+ "comparação": 0.5, "diferença": 0.4, "relação": 0.4,
190
+ "múltiplo": 0.45, "vários": 0.4, "tanto": 0.35,
191
+ }
192
+
193
+ score = 0.1 # Base
194
+ for marker, weight in complex_markers.items():
195
+ if marker in msg_lower:
196
+ score += weight
197
+
198
+ # Pontuação
199
+ if "?" in mensagem:
200
+ score += 0.1
201
+ if "!" in mensagem:
202
+ score -= 0.1
203
+
204
+ score = min(1.0, score)
205
+
206
+ if score < 0.2:
207
+ return "simples"
208
+ elif score < 0.5:
209
+ return "moderada"
210
+ elif score < 0.75:
211
+ return "complexa"
212
+ else:
213
+ return "muito_complexa"
214
+
215
+ def _detect_intent(self, mensagem: str) -> List[str]:
216
+ """Detecta intent(s) implícito(s)."""
217
+ intents = []
218
+ msg_lower = mensagem.lower()
219
+
220
+ intent_markers = {
221
+ "informação": ["o que", "como", "por quê", "sabe sobre", "fala sobre", "explica"],
222
+ "ação": ["faz", "cria", "envia", "modifica", "deleta", "inicia"],
223
+ "opinião": ["acha", "gosta", "prefere", "ache", "pense", "achei"],
224
+ "confirmação": ["certo", "verdade", "é mesmo", "sério", "confirma"],
225
+ "contexto": ["em relação", "sobre isso", "quanto a", "nisso"],
226
+ "humor": ["kkk", "haha", "ué", "lol", ":)", "rsrs"],
227
+ }
228
+
229
+ for intent, markers in intent_markers.items():
230
+ if any(m in msg_lower for m in markers):
231
+ intents.append(intent)
232
+
233
+ return intents or ["indefinido"]
234
+
235
+ def _extract_entities(self, mensagem: str) -> List[str]:
236
+ """Extrai entidades mencionadas."""
237
+ # Simples: palavras maiúsculas ou nomes comuns
238
+ palavras = mensagem.split()
239
+ entities = [p.strip(".,!?;:") for p in palavras if len(p) > 3 and p[0].isupper()]
240
+ return entities[:5] # Top 5
241
+
242
+ def _analyze_context_relevance(
243
+ self,
244
+ mensagem: str,
245
+ contexto_lstm: Optional[Dict[str, Any]]
246
+ ) -> float:
247
+ """Quanto a mensagem se relaciona com contexto de longo prazo."""
248
+ if not contexto_lstm or not self.model_thinking:
249
+ return 0.0
250
+
251
+ try:
252
+ topic_lstm = contexto_lstm.get("topic_principal", "")
253
+ if not topic_lstm:
254
+ return 0.0
255
+
256
+ # Embedding similarity
257
+ emb_msg = self.model_thinking.encode(mensagem, convert_to_tensor=False)
258
+ emb_topic = self.model_thinking.encode(topic_lstm, convert_to_tensor=False)
259
+
260
+ relevance = float(util.cos_sim(emb_msg, emb_topic)[0][0])
261
+ return max(0.0, min(1.0, relevance))
262
+ except:
263
+ return 0.0
264
+
265
+ def _find_related_topics(
266
+ self,
267
+ mensagem: str,
268
+ contexto_lstm: Optional[Dict[str, Any]]
269
+ ) -> List[str]:
270
+ """Encontra tópicos relacionados no LSTM."""
271
+ if not contexto_lstm:
272
+ return []
273
+
274
+ topics = []
275
+
276
+ # Topics do LSTM (se houver)
277
+ if contexto_lstm.get("subtopicas"):
278
+ topics.extend(contexto_lstm["subtopicas"][:3])
279
+
280
+ if contexto_lstm.get("conversation_path"):
281
+ topics.extend(contexto_lstm["conversation_path"][-3:])
282
+
283
+ return topics[:5]
284
+
285
+ def _detect_assumptions(self, mensagem: str) -> List[str]:
286
+ """Detecta assumptions que o usuário faz."""
287
+ assumptions = []
288
+ msg_lower = mensagem.lower()
289
+
290
+ # Palavras que indicam assumption
291
+ if "já" in msg_lower or "não sabe" in msg_lower:
292
+ assumptions.append("assume_conhecimento_anterior")
293
+
294
+ if "deve" in msg_lower or "deveria" in msg_lower:
295
+ assumptions.append("expectativa_de_comportamento")
296
+
297
+ if "sempre" in msg_lower or "nunca" in msg_lower:
298
+ assumptions.append("generalização")
299
+
300
+ return assumptions
301
+
302
+ def _identify_sources(self, mensagem: str) -> List[str]:
303
+ """Identifica que fontes seriam úteis."""
304
+ sources = []
305
+ msg_lower = mensagem.lower()
306
+
307
+ if any(w in msg_lower for w in ["notícia", "última", "recente", "novo", "2024", "2025"]):
308
+ sources.append("web_search")
309
+
310
+ if any(w in msg_lower for w in ["wikipedia", "história", "quem foi", "quando"]):
311
+ sources.append("wikipedia")
312
+
313
+ if any(w in msg_lower for w in ["preço", "dólar", "bitcoin", "crypto", "cotação"]):
314
+ sources.append("market_data")
315
+
316
+ if any(w in msg_lower for w in ["clima", "tempo", "previsão", "chuva"]):
317
+ sources.append("weather")
318
+
319
+ return sources
320
+
321
+ def _plan_response_strategy(self, mensagem: str, is_group: bool) -> str:
322
+ """Define estratégia de resposta."""
323
+ msg_lower = mensagem.lower()
324
+
325
+ # Contexto do grupo
326
+ if is_group:
327
+ if any(w in msg_lower for w in ["vocês", "vcs", "todos", "@all"]):
328
+ return "grupo_completo"
329
+ else:
330
+ return "grupo_individual"
331
+ else:
332
+ return "privado"
333
+
334
+ def _identify_quality_markers(self, mensagem: str) -> Dict[str, bool]:
335
+ """Identifica marcadores de qualidade da resposta esperada."""
336
+ return {
337
+ "needs_brevity": len(mensagem) < 20,
338
+ "needs_detail": len(mensagem) > 100,
339
+ "needs_humor": any(m in mensagem for m in ["kk", "kkk", ":)", "rsrs"]),
340
+ "formal_tone": any(w in mensagem for w in ["sr.", "sra.", "prezado"]),
341
+ "technical": any(w in mensagem.lower() for w in ["código", "api", "script", "função"]),
342
+ }
343
+
344
+ def _thinking_fallback(self, mensagem: str) -> Dict[str, Any]:
345
+ """Fallback simples quando modelo não está disponível."""
346
+ return {
347
+ "depth": "moderada",
348
+ "intent": ["indefinido"],
349
+ "entities": [],
350
+ "context_relevance": 0.5,
351
+ "related_topics": [],
352
+ "assumptions": [],
353
+ "required_sources": [],
354
+ "response_strategy": "padrão",
355
+ "quality_markers": {
356
+ "needs_brevity": False,
357
+ "needs_detail": False,
358
+ "needs_humor": False,
359
+ "formal_tone": False,
360
+ "technical": False,
361
+ },
362
+ }
363
+
364
+
365
+ # Singleton global
366
+ _thinking_engine_instance: Optional[ThinkingEngine] = None
367
+
368
+
369
+ def get_thinking_engine(db=None) -> ThinkingEngine:
370
+ """Retorna instância singleton do ThinkingEngine."""
371
+ global _thinking_engine_instance
372
+ if _thinking_engine_instance is None:
373
+ _thinking_engine_instance = ThinkingEngine(db=db)
374
+ return _thinking_engine_instance
modules/treinamento.py CHANGED
@@ -353,7 +353,9 @@ class Interacao:
353
  api_usada: str = ""
354
  tokens_usados: int = 0
355
  response_time: float = 0.0
356
- taticas_detectadas: List[str] = field(default_factory=list)
 
 
357
 
358
  @dataclass
359
  class TrainingResult:
@@ -411,28 +413,6 @@ class Treinamento:
411
  # 📝 REGISTRO DE INTERAÇÕES
412
  # ============================================================
413
 
414
- def detect_debate_tactics(self, texto: str) -> List[str]:
415
- """Detecta táticas de debate, baits e falácias comuns no texto."""
416
- taticas = []
417
- t_lower = texto.lower()
418
-
419
- # Mapeamento de gatilhos para táticas
420
- gatilhos = {
421
- "bait": ["bait", "isca", "armadilha", "provocação", "clique"],
422
- "ad_hominem": ["você é", "seu burro", "idiota", "lixo", "atacar a pessoa"],
423
- "espantalho": ["distorcer", "não foi o que eu disse", "mentira", "inventar"],
424
- "falacia": ["falácia", "argumento inválido", "erro lógico", "sofisma"],
425
- "mitada": ["mitou", "jantou", "na cara", "lacrou", "esmagou"],
426
- "ironia": ["kkk", "rsrs", "irônico", "engraçado né"],
427
- "gaslighting": ["louco", "maluco", "coisa da sua cabeça", "paranoia"]
428
- }
429
-
430
- for tatica, keywords in gatilhos.items():
431
- if any(k in t_lower for k in keywords):
432
- taticas.append(tatica)
433
-
434
- return taticas
435
-
436
  def registrar_interacao(
437
  self,
438
  usuario: str,
@@ -444,17 +424,11 @@ class Treinamento:
444
  api_usada: str = '',
445
  tokens_usados: int = 0,
446
  response_time: float = 0.0,
447
- conversation_id: str = '',
448
  **kwargs
449
  ) -> Interacao:
450
  """
451
  Registra interação e executa aprendizado em tempo real
452
  """
453
- # Detecta táticas de debate (baits, falácias, mitadas)
454
- taticas_msg = self.detect_debate_tactics(mensagem)
455
- taticas_resp = self.detect_debate_tactics(resposta)
456
- taticas_total = list(set(taticas_msg + taticas_resp))
457
-
458
  # Cria estrutura de interação
459
  interacao = Interacao(
460
  usuario=usuario,
@@ -465,20 +439,15 @@ class Treinamento:
465
  mensagem_original=mensagem_original,
466
  api_usada=api_usada,
467
  tokens_usados=tokens_usados,
468
- response_time=response_time,
469
- taticas_detectadas=taticas_total
470
  )
471
 
472
- if taticas_total:
473
- logger.info(f"🎯 [TATICA] Táticas detectadas na interação: {', '.join(taticas_total)}")
474
-
475
  try:
476
  # Salva no banco (com o modelo que gerou a resposta)
477
  self.db.salvar_mensagem(
478
  usuario, mensagem, resposta, numero, is_reply, mensagem_original,
479
  modelo_usado=api_usada or "desconhecido",
480
- message_id=kwargs.get('message_id'),
481
- conversation_id=conversation_id
482
  )
483
 
484
  # Aprendizado em tempo real
 
353
  api_usada: str = ""
354
  tokens_usados: int = 0
355
  response_time: float = 0.0
356
+ thinking_depth: str = "moderada" # ✅ Complexidade avaliada pelo ThinkingEngine
357
+ thinking_intent: str = "indefinido" # ✅ Intenção detectada pelo ThinkingEngine
358
+
359
 
360
  @dataclass
361
  class TrainingResult:
 
413
  # 📝 REGISTRO DE INTERAÇÕES
414
  # ============================================================
415
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
  def registrar_interacao(
417
  self,
418
  usuario: str,
 
424
  api_usada: str = '',
425
  tokens_usados: int = 0,
426
  response_time: float = 0.0,
 
427
  **kwargs
428
  ) -> Interacao:
429
  """
430
  Registra interação e executa aprendizado em tempo real
431
  """
 
 
 
 
 
432
  # Cria estrutura de interação
433
  interacao = Interacao(
434
  usuario=usuario,
 
439
  mensagem_original=mensagem_original,
440
  api_usada=api_usada,
441
  tokens_usados=tokens_usados,
442
+ response_time=response_time
 
443
  )
444
 
 
 
 
445
  try:
446
  # Salva no banco (com o modelo que gerou a resposta)
447
  self.db.salvar_mensagem(
448
  usuario, mensagem, resposta, numero, is_reply, mensagem_original,
449
  modelo_usado=api_usada or "desconhecido",
450
+ message_id=kwargs.get('message_id')
 
451
  )
452
 
453
  # Aprendizado em tempo real
modules/twitter_api.py CHANGED
@@ -1,100 +1,79 @@
1
- import os
2
- import requests
3
- from loguru import logger
4
- from typing import List, Dict, Any
5
-
6
- class TwitterAPI:
7
- """
8
- Integração simples com Twitter API v2 para busca de 'tretas' e 'mitadas'.
9
- """
10
- def __init__(self, bearer_token: str = None):
11
- self.bearer_token = bearer_token or os.getenv("TWITTER_BEARER_TOKEN")
12
- self.base_url = "https://api.twitter.com/2"
13
-
14
- def search_tweets(self, query: str, max_results: int = 10) -> List[Dict[str, Any]]:
15
- """
16
- Busca tweets recentes com base em uma query.
17
- """
18
- if not self.bearer_token:
19
- logger.warning("⚠️ TWITTER_BEARER_TOKEN não configurado.")
20
- return []
21
-
22
- headers = {
23
- "Authorization": f"Bearer {self.bearer_token}",
24
- "User-Agent": "v2RecentSearchPython"
25
- }
26
-
27
- params = {
28
- "query": f"{query} lang:pt -is:retweet",
29
- "max_results": max_results,
30
- "tweet.fields": "text,public_metrics,created_at"
31
- }
32
-
33
- try:
34
- response = requests.get(f"{self.base_url}/tweets/search/recent", headers=headers, params=params)
35
- if response.status_code == 200:
36
- data = response.json()
37
- return data.get("data", [])
38
- else:
39
- logger.error(f"❌ Erro Twitter API ({response.status_code}): {response.text}")
40
- return []
41
- except Exception as e:
42
- logger.error(f"❌ Falha ao buscar tweets: {e}")
43
- return []
44
-
45
- def get_savage_context(self, topic: str) -> str:
46
- """
47
- Busca exemplos de 'mitadas' ou discussões acaloradas sobre um tema.
48
- Otimizado para encontrar debates reais, baits e falácias de retórica.
49
- """
50
- # Query expandida com operadores OR para maximizar resultados em menos chamadas
51
- # Inclui termos de retórica agressiva, falácias e baits
52
- main_query = f"{topic} (mita OR treta OR jantou OR cancelado OR vergonha OR bait OR 'falácia' OR 'ad hominem' OR 'espantalho' OR 'lacrou' OR 'jantada')"
53
-
54
- all_tweets = self.search_tweets(main_query, max_results=20)
55
-
56
- # Se não vier nada, tenta uma busca mais genérica focada em engajamento (debate)
57
- if not all_tweets or len(all_tweets) < 5:
58
- logger.info(f"Busca específica por treta em '{topic}' retornou pouco. Tentando busca por engajamento...")
59
- debate_query = f"{topic} -is:retweet" # Busca geral mas vamos filtrar por métricas
60
- more_tweets = self.search_tweets(debate_query, max_results=20)
61
- all_tweets.extend(more_tweets)
62
-
63
- if not all_tweets:
64
- return f"Nenhuma discussão relevante ou 'treta' recente encontrada no Twitter sobre '{topic}'."
65
-
66
- # Ordenar por engajamento total (Likes + Retweets + Replies + Quotes)
67
- def get_engagement(t):
68
- m = t.get('public_metrics', {})
69
- return m.get('like_count', 0) + m.get('retweet_count', 0) + m.get('reply_count', 0) + m.get('quote_count', 0)
70
-
71
- sorted_tweets = sorted(all_tweets, key=get_engagement, reverse=True)
72
-
73
- # Deduplicação por texto (Twitter as vezes retorna duplicatas em queries parecidas)
74
- seen_text = set()
75
- unique_tweets = []
76
- for t in sorted_tweets:
77
- text_norm = t['text'][:100].lower()
78
- if text_norm not in seen_text:
79
- seen_text.add(text_norm)
80
- unique_tweets.append(t)
81
- if len(unique_tweets) >= 12: break
82
-
83
- context = f"🔥 Discussões e 'Mitadas' em alta sobre '{topic}':\n\n"
84
- for i, tweet in enumerate(unique_tweets):
85
- metrics = tweet.get('public_metrics', {})
86
- eng = f"👍{metrics.get('like_count', 0)} 💬{metrics.get('reply_count', 0)} 🔁{metrics.get('retweet_count', 0)}"
87
- text = tweet['text'].replace('\n', ' ').strip()
88
- context += f"[{i+1}] {text}\n 📈 Engajamento: {eng}\n\n"
89
-
90
- context += "💡 INSTRUÇÃO: Use estes exemplos reais para aprender o tom do debate e as falácias mais eficazes usadas agora. Não cite os números de engajamento, apenas absorva o veneno."
91
-
92
- return context
93
-
94
- # Singleton
95
- _instance = None
96
- def get_twitter_api():
97
- global _instance
98
- if _instance is None:
99
- _instance = TwitterAPI()
100
- return _instance
 
1
+ import os
2
+ import requests
3
+ from loguru import logger
4
+ from typing import List, Dict, Any
5
+
6
+ class TwitterAPI:
7
+ """
8
+ Integração simples com Twitter API v2 para busca de 'tretas' e 'mitadas'.
9
+ """
10
+ def __init__(self, bearer_token: str = None):
11
+ self.bearer_token = bearer_token or os.getenv("TWITTER_BEARER_TOKEN")
12
+ self.base_url = "https://api.twitter.com/2"
13
+
14
+ def search_tweets(self, query: str, max_results: int = 10) -> List[Dict[str, Any]]:
15
+ """
16
+ Busca tweets recentes com base em uma query.
17
+ """
18
+ if not self.bearer_token:
19
+ logger.warning("⚠️ TWITTER_BEARER_TOKEN não configurado.")
20
+ return []
21
+
22
+ headers = {
23
+ "Authorization": f"Bearer {self.bearer_token}",
24
+ "User-Agent": "v2RecentSearchPython"
25
+ }
26
+
27
+ params = {
28
+ "query": f"{query} lang:pt -is:retweet",
29
+ "max_results": max_results,
30
+ "tweet.fields": "text,public_metrics,created_at"
31
+ }
32
+
33
+ try:
34
+ response = requests.get(f"{self.base_url}/tweets/search/recent", headers=headers, params=params)
35
+ if response.status_code == 200:
36
+ data = response.json()
37
+ return data.get("data", [])
38
+ else:
39
+ logger.error(f"❌ Erro Twitter API ({response.status_code}): {response.text}")
40
+ return []
41
+ except Exception as e:
42
+ logger.error(f"❌ Falha ao buscar tweets: {e}")
43
+ return []
44
+
45
+ def get_savage_context(self, topic: str) -> str:
46
+ """
47
+ Busca exemplos de 'mitadas' ou discussões acaloradas sobre um tema.
48
+ """
49
+ queries = [
50
+ f"{topic} mita",
51
+ f"{topic} treta",
52
+ f"{topic} cancelado",
53
+ f"{topic} 'na cara'",
54
+ f"{topic} 'jantou'"
55
+ ]
56
+
57
+ all_tweets = []
58
+ for q in queries[:2]: # Tenta as 2 primeiras queries para economizar cota
59
+ tweets = self.search_tweets(q, max_results=10)
60
+ all_tweets.extend(tweets)
61
+ if len(all_tweets) >= 10: break
62
+
63
+ if not all_tweets:
64
+ return "Nenhuma 'treta' recente encontrada no Twitter sobre este assunto."
65
+
66
+ context = "Exemplos de discussões/mitadas no Twitter sobre este assunto:\n"
67
+ for i, tweet in enumerate(all_tweets[:10]):
68
+ text = tweet['text'].replace('\n', ' ')
69
+ context += f"{i+1}. {text}\n"
70
+
71
+ return context
72
+
73
+ # Singleton
74
+ _instance = None
75
+ def get_twitter_api():
76
+ global _instance
77
+ if _instance is None:
78
+ _instance = TwitterAPI()
79
+ return _instance
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
modules/unified_context.py CHANGED
@@ -1,1182 +1,1041 @@
1
- # type: ignore
2
- """
3
- ================================================================================
4
- AKIRA V21 ULTIMATE - UNIFIED CONTEXT MODULE
5
- ================================================================================
6
- Sistema unificado que integra Reply Context + Short-Term Memory em sintonia.
7
-
8
- Philosophy: "Reply context e STM devem trabalhar em sintonia como tik e tack -
9
- um fornece o contexto imediato/urgente (o que o usuário está respondendo),
10
- o outro fornece o fluxo da conversa (contexto geral)."
11
-
12
- Features:
13
- - Integração seamless entre reply context e STM
14
- - Token budgeting inteligente entre os dois contextos
15
- - Priorização dinâmica baseada no tipo de mensagem
16
- - Suporte a perguntas curtas com reply (prioridade máxima)
17
- - Persistência e restauração de contexto unificado
18
- ================================================================================
19
- """
20
-
21
- import os
22
- import sys
23
- import time
24
- import json
25
- import logging
26
- from typing import Optional, Dict, Any, List, Tuple
27
- from dataclasses import dataclass, field
28
- from datetime import datetime
29
-
30
- # Imports robustos com fallback
31
- try:
32
- from . import config
33
- from .short_term_memory import (
34
- ShortTermMemory,
35
- MessageWithContext,
36
- IMPORTANCIA_NORMAL,
37
- IMPORTANCIA_REPLY,
38
- IMPORTANCIA_REPLY_TO_BOT,
39
- IMPORTANCIA_PERGUNTA_CURTA_REPLY,
40
- estimar_tokens,
41
- is_pergunta_curta
42
- )
43
- from .reply_context_handler import (
44
- ReplyContextHandler,
45
- ProcessedReplyContext,
46
- PRIORITY_REPLY,
47
- PRIORITY_REPLY_TO_BOT,
48
- PRIORITY_REPLY_TO_BOT_SHORT_QUESTION
49
- )
50
- UNIFIED_CONTEXT_AVAILABLE = True
51
- except ImportError as e:
52
- try:
53
- import modules.config as config
54
- from modules.short_term_memory import (
55
- ShortTermMemory,
56
- MessageWithContext,
57
- IMPORTANCIA_NORMAL,
58
- IMPORTANCIA_REPLY,
59
- IMPORTANCIA_REPLY_TO_BOT,
60
- IMPORTANCIA_PERGUNTA_CURTA_REPLY,
61
- estimar_tokens,
62
- is_pergunta_curta
63
- )
64
- from modules.reply_context_handler import (
65
- ReplyContextHandler,
66
- ProcessedReplyContext,
67
- PRIORITY_REPLY,
68
- PRIORITY_REPLY_TO_BOT,
69
- PRIORITY_REPLY_TO_BOT_SHORT_QUESTION
70
- )
71
- UNIFIED_CONTEXT_AVAILABLE = True
72
- except ImportError:
73
- UNIFIED_CONTEXT_AVAILABLE = False
74
- config = None
75
-
76
- try:
77
- from .lstm_extension import get_lstm_extension
78
- LSTM_AVAILABLE = True
79
- except ImportError:
80
- try:
81
- from modules.lstm_extension import get_lstm_extension
82
- LSTM_AVAILABLE = True
83
- except ImportError:
84
- LSTM_AVAILABLE = False
85
-
86
- logger = logging.getLogger(__name__)
87
-
88
- # ============================================================
89
- # CONFIGURAÇÃO DE TOKEN BUDGET
90
- # ============================================================
91
-
92
- @dataclass
93
- class ContextTokenBudget:
94
- """
95
- Alocação de tokens entre reply context e STM.
96
-
97
- Philosophy: Reply tem orçamento dedicado (urgente), STM tem o resto (fluxo).
98
- """
99
- total_budget: int = 8000
100
- system_tokens: int = 1500
101
- user_message_tokens: int = 500
102
-
103
- # Reply context budget (URGENTE)
104
- reply_tokens: int = 300
105
- reply_priority_multiplier: float = 1.0
106
-
107
- # STM budget (FLUXO DA CONVERSA)
108
- stm_tokens: int = 4000
109
-
110
- # Reservado para resposta
111
- response_reserved: int = 1200
112
-
113
- def calculate(self, is_reply: bool, reply_priority: int = 1, is_self_reply: bool = False) -> 'ContextTokenBudget':
114
- """
115
- Calcula orçamento baseado no tipo de mensagem.
116
-
117
- Args:
118
- is_reply: Se é um reply
119
- reply_priority: Nível de prioridade do reply (1-4)
120
- is_self_reply: Se o reply é para o próprio bot
121
-
122
- Returns:
123
- ContextTokenBudget ajustado
124
- """
125
- budget = ContextTokenBudget(
126
- total_budget=self.total_budget,
127
- system_tokens=self.system_tokens,
128
- user_message_tokens=self.user_message_tokens
129
- )
130
-
131
- if is_reply:
132
- if reply_priority >= PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
133
- # Pergunta curta com reply ao bot = prioridade máxima
134
- budget.reply_tokens = min(1200, int(self.total_budget * 0.15))
135
- budget.reply_priority_multiplier = 1.3
136
- budget.stm_tokens = min(4000, int(self.total_budget * 0.50))
137
- elif reply_priority >= PRIORITY_REPLY_TO_BOT:
138
- # Reply ao bot
139
- budget.reply_tokens = min(1000, int(self.total_budget * 0.12))
140
- budget.reply_priority_multiplier = 1.2
141
- budget.stm_tokens = min(4500, int(self.total_budget * 0.55))
142
- elif reply_priority >= PRIORITY_REPLY:
143
- # Reply normal
144
- budget.reply_tokens = min(600, int(self.total_budget * 0.08))
145
- budget.reply_priority_multiplier = 1.1
146
- budget.stm_tokens = min(5000, int(self.total_budget * 0.60))
147
-
148
- # 🛡️ PENALIDADE DE AUTO-REPLICA: Se o bot está respondendo a si mesmo,
149
- # reduzimos drasticamente o orçamento do reply para evitar o "loop infinito" de contexto.
150
- if is_self_reply:
151
- budget.reply_tokens = int(budget.reply_tokens * 0.5)
152
- budget.reply_priority_multiplier = 0.8
153
- else:
154
- # Mensagem normal = STM tem orçamento completo
155
- budget.reply_tokens = 0
156
- budget.stm_tokens = min(5000, int(self.total_budget * 0.65))
157
-
158
- # Calcula response reserved
159
- budget.response_reserved = (
160
- budget.total_budget -
161
- budget.system_tokens -
162
- budget.user_message_tokens -
163
- budget.reply_tokens -
164
- budget.stm_tokens
165
- )
166
-
167
- return budget
168
-
169
- def to_dict(self) -> Dict[str, Any]:
170
- """Serializa para dicionário."""
171
- return {
172
- "total_budget": self.total_budget,
173
- "system_tokens": self.system_tokens,
174
- "user_message_tokens": self.user_message_tokens,
175
- "reply_tokens": self.reply_tokens,
176
- "stm_tokens": self.stm_tokens,
177
- "response_reserved": self.response_reserved,
178
- "reply_priority_multiplier": self.reply_priority_multiplier
179
- }
180
-
181
-
182
- # ============================================================
183
- # CONTEXTO UNIFICADO
184
- # ============================================================
185
-
186
- @dataclass
187
- class UnifiedMessageContext:
188
- """
189
- Contexto unificado combinando reply + STM.
190
-
191
- Philosophy: Reply context (tik) + STM (tok) trabalhando em sintonia.
192
-
193
- Attributes:
194
- - Reply context: Contexto imediato/urgente do reply
195
- - STM context: Contexto do fluxo da conversa
196
- - Integration: Como os dois são combinados
197
- """
198
- # Identificação
199
- conversation_id: str = ""
200
- user_id: str = ""
201
- timestamp: float = field(default_factory=time.time)
202
-
203
- # ── CONSCIÊNCIA DE REMETENTE (novo) ──────────────────────────────────────
204
- # Quem enviou a mensagem ACTUAL que está a ser processada
205
- sender_name: str = "" # Nome do remetente (pushName do WhatsApp)
206
- sender_number: str = "" # Número normalizado do remetente
207
- context_level: int = 1 # Nível de consciência: 1=directo, 2=grupo activo, 3=contexto rico
208
- # ─────────────────────────────────────────────────────────────────────────
209
-
210
- # Reply Context (TIK - urgente/imediato)
211
- is_reply: bool = False
212
- reply_to_bot: bool = False
213
- reply_priority: int = 1 # 1=normal, 2=reply, 3=reply_to_bot, 4=critical
214
- quoted_author: str = ""
215
- quoted_content: str = ""
216
- reply_importancia: float = 1.0
217
-
218
- # STM Context (TOK - fluxo da conversa)
219
- stm_messages: List[MessageWithContext] = field(default_factory=list)
220
- stm_summary: Dict[str, Any] = field(default_factory=dict)
221
- stm_emotional_trend: str = "neutral"
222
-
223
- # Long-Term Memory (RAG)
224
- long_term_memory: str = ""
225
-
226
- # Listening Context (O que outras pessoas falaram no grupo recentemente)
227
- group_listening_context: List[Dict[str, Any]] = field(default_factory=list)
228
-
229
- # Integração
230
- sync_mode: str = "tiktok" # "tiktok" = reply priority + STM flow
231
- token_budget: ContextTokenBudget = field(default_factory=ContextTokenBudget)
232
-
233
- # Mensagem atual
234
- current_message: str = ""
235
- current_emotion: str = "neutro"
236
- system_override: str = ""
237
-
238
- def to_dict(self) -> Dict[str, Any]:
239
- """Serializa para dicionário."""
240
- return {
241
- "conversation_id": self.conversation_id,
242
- "user_id": self.user_id,
243
- "timestamp": self.timestamp,
244
- "is_reply": self.is_reply,
245
- "reply_to_bot": self.reply_to_bot,
246
- "reply_priority": self.reply_priority,
247
- "quoted_author": self.quoted_author,
248
- "quoted_content": self.quoted_content[:500] if self.quoted_content else "",
249
- "reply_importancia": self.reply_importancia,
250
- "stm_messages_count": len(self.stm_messages),
251
- "stm_summary": self.stm_summary,
252
- "stm_emotional_trend": self.stm_emotional_trend,
253
- "long_term_memory": self.long_term_memory,
254
- "sync_mode": self.sync_mode,
255
- "token_budget": self.token_budget.to_dict(),
256
- "current_message": self.current_message[:100],
257
- "current_emotion": self.current_emotion
258
- }
259
-
260
- def build_prompt(self) -> str:
261
- """
262
- Constrói prompt formatado para o LLM.
263
-
264
- Returns:
265
- String formatada com contexto unificado (reply + STM)
266
- """
267
- return format_unified_context_for_llm(self, self.token_budget)
268
-
269
-
270
- # ====================================
271
- # HELPER FUNCTIONS
272
- # ====================================
273
-
274
- def sync_reply_with_stm(
275
- reply_context: Dict[str, Any],
276
- stm_messages: List[MessageWithContext],
277
- max_stm_messages: int = 10
278
- ) -> List[MessageWithContext]:
279
- """
280
- Sincroniza reply context com mensagens STM.
281
-
282
- Philosophy: Reply (tik) vem primeiro, STM (tok) vem depois.
283
- Ambos são combinados para formar o contexto completo.
284
-
285
- Args:
286
- reply_context: Contexto do reply
287
- stm_messages: Mensagens da memória de curto prazo
288
- max_stm_messages: Máximo de mensagens STM a incluir
289
-
290
- Returns:
291
- Lista combinada de mensagens para contexto
292
- """
293
- combined = []
294
-
295
- # 1. Adiciona reply context como mensagem mais recente (TIK)
296
- if reply_context.get('is_reply', False):
297
- reply_msg = MessageWithContext(
298
- role="user",
299
- content=reply_context.get('quoted_content', ''),
300
- importancia=reply_context.get('importancia', IMPORTANCIA_NORMAL),
301
- emocao=reply_context.get('emocao', 'neutral'),
302
- reply_info={
303
- 'is_reply': True,
304
- 'reply_to_bot': reply_context.get('reply_to_bot', False),
305
- 'quoted_text_original': reply_context.get('quoted_content', ''),
306
- 'priority_level': reply_context.get('priority', 1),
307
- 'sync_mode': 'tiktok'
308
- }
309
- )
310
- combined.append(reply_msg)
311
-
312
- # 2. Adiciona mensagens STM (TOK - fluxo da conversa)
313
- # Pega últimas N mensagens STM
314
- stm_to_add = stm_messages[-max_stm_messages:] if stm_messages else []
315
-
316
- for msg in stm_to_add:
317
- # Se a mensagem STM é um reply, preserva info
318
- if msg.is_reply and not msg.reply_info.get('sync_mode'):
319
- msg.reply_info['sync_mode'] = 'stm'
320
- combined.append(msg)
321
-
322
- return combined
323
-
324
-
325
- def format_unified_context_for_llm(
326
- unified: UnifiedMessageContext,
327
- budget: ContextTokenBudget
328
- ) -> str:
329
- """
330
- Formata contexto unificado para o prompt do LLM.
331
-
332
- Philosophy: Reply (tik) primeiro por ser urgente, STM (tok) depois
333
- para contexto da conversa.
334
-
335
- Args:
336
- unified: Contexto unificado
337
- budget: Orçamento de tokens
338
-
339
- Returns:
340
- String formatada para o prompt
341
- """
342
- parts = []
343
-
344
- # ===== 1. REPLY CONTEXT (TIK - URGENTE) =====
345
- if unified.is_reply:
346
- reply_section = []
347
- reply_section.append("=" * 50)
348
- reply_section.append("[📎 INTERNAL_BRAIN_ONLY: REPLY CONTEXT]")
349
- reply_section.append("=" * 50)
350
-
351
- if unified.reply_to_bot:
352
- reply_section.append("⚠️ VOCÊ ESTÁ SENDO DIRETAMENTE RESPONDIDO!")
353
- if unified.reply_priority < 3: # Se a prioridade for baixa (bot-to-bot loop detection)
354
- reply_section.append("💡 NOTA: O usuário respondeu a algo que você disse. Seja breve e não repita o que já foi dito.")
355
- else:
356
- reply_section.append(f"Respondendo a: {unified.quoted_author}")
357
-
358
- # Conteúdo citado
359
- if unified.quoted_content:
360
- # Reduz o conteúdo citado se for reply ao bot para evitar redundância
361
- max_chars = budget.reply_tokens // 4
362
- if unified.reply_to_bot:
363
- max_chars = max_chars // 2
364
-
365
- quoted_preview = unified.quoted_content[:max_chars]
366
- reply_section.append(f"\n<quoted_message>\n{quoted_preview}{'...' if len(unified.quoted_content) > max_chars else ''}\n</quoted_message>")
367
-
368
- # Prioridade
369
- if unified.reply_priority >= PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
370
- reply_section.append("\n💡 PERGUNTA CURTA + REPLY: FOCO NA CITAÇÃO")
371
-
372
- reply_section.append("\n📌 INSTRUÇÕES DE REPLY:")
373
- if unified.reply_to_bot:
374
- reply_section.append("- O usuário está a reagir a uma mensagem SUA (<quoted_message>). Responda diretamente ao comentário do usuário, mantendo a postura sobre o que você disse.")
375
- reply_section.append("- EVITE REPETIR o que está em <quoted_message>.")
376
- else:
377
- reply_section.append("- O usuário está a responder a <quoted_message>. Formule sua resposta com base nisso.")
378
-
379
- reply_section.append("- PRESERVE a sua identidade e humor (seja o Akira, natural e irreverente).")
380
- reply_section.append("- NÃO foque apenas na citação se ela for irrelevante perto do fluxo geral da conversa. Olhe as mensagens anteriores para manter a coerência.")
381
-
382
- parts.append("\n".join(reply_section))
383
-
384
- # ===== 1.5 GROUP LISTENING CONTEXT (O QUE OUTROS DISSERAM) =====
385
- if unified.group_listening_context:
386
- listening_section = []
387
- listening_section.append("\n" + "=" * 50)
388
- listening_section.append("[👂 INTERNAL_BRAIN_ONLY: RECENT GROUP LISTENING (Fofoca & Trends)]")
389
- listening_section.append("=" * 50)
390
- listening_section.append("(O que outras pessoas no grupo estavam discutindo antes desta mensagem)")
391
-
392
- for msg in unified.group_listening_context:
393
- author = msg.get('usuario', '')
394
- text = msg.get('mensagem', '')[:250]
395
- if text:
396
- if author and author not in ["Grupo", "Membro", ""]:
397
- listening_section.append(f"• {author}: {text}")
398
- else:
399
- listening_section.append(f"• {text}")
400
-
401
- listening_section.append("\n📌 INSTRUÇÃO DE TRENDS/FOFOCA:")
402
- listening_section.append("- Use este histórico para saber o que as pessoas estão a conversar (a trend).")
403
- listening_section.append("- Se for relevante, CITE NOMINALMENTE pessoas que deram comentários interessantes no grupo ou as mencione diretamente na sua resposta.")
404
- listening_section.append("- Comente sobre a 'fofoca' ou o assunto em alta no grupo. Mostre que você está prestando atenção em todos e sabe quem falou com quem.")
405
- listening_section.append("- Não alucine! Confie estritamente neste histórico. Você pode usar isso a favor ou contra os membros, como uma observadora atenta do grupo.")
406
- parts.append("\n".join(listening_section))
407
-
408
- # ===== RAG CONTEXT (MEMÓRIA DE LONGO PRAZO) =====
409
- if unified.long_term_memory:
410
- rag_section = []
411
- rag_section.append("\n" + "=" * 50)
412
- rag_section.append("[📖 INTERNAL_BRAIN_ONLY: LONG-TERM MEMORY]")
413
- rag_section.append("=" * 50)
414
- rag_section.append("(Informações previamente aprendidas sobre o usuário)")
415
- rag_section.append(unified.long_term_memory)
416
- parts.append("\n".join(rag_section))
417
-
418
- # ===== 2. STM CONTEXT (METADADOS DE FLUXO) =====
419
- if unified.stm_messages:
420
- stm_section = []
421
- # Não adicionamos as mensagens como texto aqui para evitar duplicação e truncagem,
422
- # pois elas são injetadas nativamente no array context_history da API.
423
-
424
- # emotional trend
425
- if unified.stm_emotional_trend != "neutral":
426
- stm_section.append(f"\n📊 Tendência emocional do chat: {unified.stm_emotional_trend}")
427
-
428
- if stm_section:
429
- parts.append("\n".join(stm_section))
430
-
431
- # ===== 3. SYSTEM OVERRIDE (REMETENTE & AMBIENTE) =====
432
- if getattr(unified, 'system_override', None):
433
- parts.append(unified.system_override)
434
-
435
- return "\n".join(parts)
436
-
437
-
438
- # ====================================
439
- # GROUP LISTEN FEED HELPER
440
- # ====================================
441
-
442
- def get_group_listen_feed(
443
- grupo_id: str,
444
- limit: int = 6,
445
- db_instance=None
446
- ) -> List[Dict[str, Any]]:
447
- """
448
- Recupera as últimas mensagens ouvidas passivamente num grupo via STM de Grupo.
449
-
450
- Usado para alimentar o campo group_listening_context do UnifiedMessageContext,
451
- permitindo que a IA saiba o que outros membros disseram mesmo sem ser mencionada.
452
-
453
- Args:
454
- grupo_id: ID do grupo (JID completo ou context_id)
455
- limit: Máximo de mensagens a recuperar (padrão 6)
456
- db_instance: Instância do Database (opcional)
457
-
458
- Returns:
459
- Lista de dicts: [{"usuario": str, "mensagem": str, "timestamp": float}]
460
- """
461
- feed: List[Dict[str, Any]] = []
462
-
463
- if not grupo_id:
464
- return feed
465
-
466
- try:
467
- # Import local para evitar dependência circular
468
- from .unified_context import get_stm_manager
469
- stm_mgr = get_stm_manager()
470
- group_stm_id = f"group_feed_{grupo_id}"
471
-
472
- # Recupera as mensagens do cache STM compartilhado do grupo
473
- if stm_mgr:
474
- msgs = stm_mgr.get_messages(group_stm_id, limit=limit, include_replies=True)
475
- for msg in msgs:
476
- # O msg.content já está formatado como "[Author]: msg" ou "[Author] em resposta a [Target]: msg"
477
- # A API adiciona dessa forma
478
- feed.append({
479
- "usuario": "", # Deixamos vazio pois o nome já está no content
480
- "mensagem": (msg.content or "")[:250],
481
- "timestamp": msg.timestamp
482
- })
483
- except Exception as e:
484
- logger.debug(f"[LISTEN_FEED] Erro ao obter feed STM do grupo {grupo_id[:12]}: {e}")
485
-
486
- return feed
487
-
488
-
489
- def build_sender_context_level(
490
- sender_name: str,
491
- sender_number: str,
492
- listen_feed: List[Dict[str, Any]],
493
- stm_count: int
494
- ) -> int:
495
- """
496
- Calcula o nível de consciência contextual (1, 2 ou 3).
497
-
498
- Nível 1 — Apenas o remetente actual é conhecido
499
- Nível 2 — Grupo activo: temos feed de outras mensagens ouvidas
500
- Nível 3 — Contexto rico: STM consolidado + feed denso
501
- """
502
- if not sender_name and not sender_number:
503
- return 1
504
- if len(listen_feed) >= 3 and stm_count >= 5:
505
- return 3
506
- if len(listen_feed) >= 1 or stm_count >= 3:
507
- return 2
508
- return 1
509
-
510
-
511
- # ====================================
512
- # SHORT-TERM MEMORY MANAGER
513
- # ====================================
514
-
515
- class ShortTermMemoryManager:
516
- """
517
- Gerenciador de instâncias STM por conversa.
518
-
519
- Philosophy: Cada conversa tem sua própria STM isolada,
520
- mas todas compartilham o mesmo manager.
521
- """
522
-
523
- _instance = None
524
- _lock = None
525
-
526
- def __new__(cls):
527
- if cls._instance is None:
528
- cls._lock = __import__('threading').Lock()
529
- with cls._lock:
530
- if cls._instance is None:
531
- cls._instance = super().__new__(cls)
532
- cls._instance._initialized = False
533
- return cls._instance
534
-
535
- def __init__(self):
536
- if self._initialized:
537
- return
538
-
539
- self._instances: Dict[str, ShortTermMemory] = {}
540
- # Path centralizado via config
541
- if config and hasattr(config, "DATA_DIR"):
542
- self._storage_path: str = str(config.DATA_DIR / "stm_cache")
543
- else:
544
- self._storage_path: str = os.path.join(
545
- os.path.dirname(os.path.abspath(__file__)),
546
- '..', 'data', 'stm_cache'
547
- )
548
- os.makedirs(self._storage_path, exist_ok=True)
549
- self._initialized = True
550
- self._load_all()
551
- logger.debug(f"✅ ShortTermMemoryManager inicializado (persistência: {self._storage_path})")
552
-
553
- # ============================================================
554
- # PERSISTÊNCIA EM DISCO
555
- # ============================================================
556
-
557
- def _stm_file_path(self, conversation_id: str) -> str:
558
- """Retorna caminho do arquivo de persistência de uma STM."""
559
- safe_id = conversation_id.replace('/', '_').replace('\\', '_')[:128]
560
- return os.path.join(self._storage_path, f"{safe_id}.json")
561
-
562
- def _load_stm(self, conversation_id: str) -> Optional[ShortTermMemory]:
563
- """Carrega STM de disco se existir."""
564
- fpath = self._stm_file_path(conversation_id)
565
- if os.path.exists(fpath):
566
- try:
567
- stm = ShortTermMemory.load_from_file(fpath)
568
- self._instances[conversation_id] = stm
569
- return stm
570
- except Exception as e:
571
- logger.warning(f"Falha ao carregar STM {conversation_id[:8]}: {e}")
572
- return None
573
-
574
- def _load_all(self) -> None:
575
- """Carrega todas as STMs persistidas do disco."""
576
- if not os.path.isdir(self._storage_path):
577
- return
578
- for fname in os.listdir(self._storage_path):
579
- if fname.endswith('.json'):
580
- cid = fname[:-5]
581
- self._load_stm(cid)
582
- logger.info(f"📦 {len(self._instances)} STM(s) carregadas do disco")
583
-
584
- def _save_stm(self, conversation_id: str) -> None:
585
- """Salva STM de uma conversa em disco."""
586
- if conversation_id in self._instances:
587
- fpath = self._stm_file_path(conversation_id)
588
- self._instances[conversation_id].save_to_file(fpath)
589
-
590
- def get_or_create_stm(
591
- self,
592
- conversation_id: str,
593
- user_id: str = "",
594
- max_messages: int = 100
595
- ) -> ShortTermMemory:
596
- """
597
- Obtém ou cria STM para uma conversa.
598
-
599
- Args:
600
- conversation_id: ID único da conversa
601
- user_id: ID do usuário
602
- max_messages: Máximo de mensagens na STM
603
-
604
- Returns:
605
- Instância de ShortTermMemory
606
- """
607
- if conversation_id not in self._instances:
608
- self._instances[conversation_id] = ShortTermMemory(
609
- conversation_id=conversation_id,
610
- max_messages=max_messages
611
- )
612
- logger.debug(f"🧠 STM criada: {conversation_id[:8]}...")
613
-
614
- return self._instances[conversation_id]
615
-
616
- def add_message(
617
- self,
618
- conversation_id: str,
619
- role: str,
620
- content: str,
621
- emocao: str = "neutral",
622
- reply_info: Optional[Dict] = None,
623
- importancia: Optional[float] = None
624
- ) -> MessageWithContext:
625
- """
626
- Adiciona mensagem à STM de uma conversa.
627
-
628
- Args:
629
- conversation_id: ID da conversa
630
- role: "user" ou "assistant"
631
- content: Texto da mensagem
632
- emocao: Emoção detectada
633
- reply_info: Info de reply (se aplicável)
634
- importancia: Importância customizada
635
-
636
- Returns:
637
- MessageWithContext criada
638
- """
639
- stm = self.get_or_create_stm(conversation_id)
640
-
641
- # Calcula importância automaticamente se não fornecida
642
- if importancia is None:
643
- from .short_term_memory import calcular_importancia
644
- importancia = calcular_importancia(
645
- is_reply=bool(reply_info and reply_info.get("is_reply")),
646
- reply_to_bot=bool(reply_info and reply_info.get("reply_to_bot")),
647
- mensagem=content,
648
- emocao=emocao
649
- )
650
-
651
- msg = stm.add_message(
652
- role=role,
653
- content=content,
654
- importancia=importancia,
655
- emocao=emocao,
656
- reply_info=reply_info
657
- )
658
-
659
- # Persiste em disco (salva a cada mensagem para garantir durability)
660
- self._save_stm(conversation_id)
661
- return msg
662
-
663
- def get_context(
664
- self,
665
- conversation_id: str,
666
- include_replies: bool = True,
667
- prioritize_replies: bool = True,
668
- max_messages: int = 30,
669
- max_tokens: int = 4000
670
- ) -> List[MessageWithContext]:
671
- """
672
- Obtém contexto da STM de uma conversa.
673
-
674
- Args:
675
- conversation_id: ID da conversa
676
- include_replies: Se inclui replies
677
- prioritize_replies: Se prioriza replies
678
- max_messages: Máximo de mensagens
679
- max_tokens: Máximo de tokens
680
-
681
- Returns:
682
- Lista de mensagens
683
- """
684
- if conversation_id not in self._instances:
685
- return []
686
-
687
- stm = self._instances[conversation_id]
688
- return stm.get_context_window(
689
- include_replies=include_replies,
690
- prioritize_replies=prioritize_replies,
691
- max_messages=max_messages,
692
- max_tokens=max_tokens
693
- )
694
-
695
- def get_summary(self, conversation_id: str) -> Dict[str, Any]:
696
- """
697
- Obtém resumo da STM de uma conversa.
698
-
699
- Args:
700
- conversation_id: ID da conversa
701
-
702
- Returns:
703
- Dicionário com resumo
704
- """
705
- if conversation_id not in self._instances:
706
- return {}
707
-
708
- stm = self._instances[conversation_id]
709
- return stm.get_conversation_summary()
710
-
711
- def clear(self, conversation_id: str) -> bool:
712
- """
713
- Limpa STM de uma conversa, inclusive persistência em disco.
714
-
715
- Args:
716
- conversation_id: ID da conversa
717
-
718
- Returns:
719
- True se limpou
720
- """
721
- if conversation_id in self._instances:
722
- self._instances[conversation_id].clear()
723
- del self._instances[conversation_id]
724
- # Remove arquivo de persistência
725
- fpath = self._stm_file_path(conversation_id)
726
- if hasattr(self, 'fpath') or True:
727
- try:
728
- fpath = self._stm_file_path(conversation_id)
729
- if os.path.exists(fpath):
730
- os.remove(fpath)
731
- except Exception:
732
- pass
733
- return True
734
-
735
- def clear_messages(self, conversation_id: str) -> None:
736
- """Alias de compatibilidade para clear()."""
737
- self.clear(conversation_id)
738
-
739
- def get_messages(
740
- self,
741
- conversation_id: str,
742
- limit: int = 30,
743
- include_replies: bool = True
744
- ) -> list:
745
- """
746
- Alias de compatibilidade para get_context().
747
- Retorna lista de MessageWithContext para a conversa.
748
-
749
- Args:
750
- conversation_id: ID da conversa
751
- limit: Quantidade máxima de mensagens
752
- include_replies: Se inclui replies
753
-
754
- Returns:
755
- Lista de MessageWithContext
756
- """
757
- if conversation_id not in self._instances:
758
- return []
759
- stm = self._instances[conversation_id]
760
- result = stm.get_context_window(
761
- include_replies=include_replies,
762
- prioritize_replies=True,
763
- max_messages=limit
764
- )
765
- return result if result else []
766
-
767
-
768
- # ====================================
769
- # UNIFIED CONTEXT BUILDER
770
- # ====================================
771
-
772
- class UnifiedContextBuilder:
773
- """
774
- Constrói contexto unificado combinando reply + STM.
775
-
776
- Philosophy: "Reply context e STM devem trabalhar em sintonia como tik e tack"
777
-
778
- Usage:
779
- builder = UnifiedContextBuilder()
780
- context = builder.build(
781
- conversation_id="...",
782
- reply_metadata={...},
783
- current_message="..."
784
- )
785
- prompt_section = builder.format_for_llm(context)
786
- """
787
-
788
- def __init__(self, context_manager=None, stm_manager=None, db_instance=None):
789
- self.stm_manager = stm_manager if stm_manager else ShortTermMemoryManager()
790
- self.context_manager = context_manager
791
- self.db = db_instance
792
- self.reply_handler = None
793
- self._initialized = False
794
-
795
- def _ensure_initialized(self):
796
- """Garante inicialização do reply handler."""
797
- if not self._initialized and UNIFIED_CONTEXT_AVAILABLE:
798
- try:
799
- self.reply_handler = ReplyContextHandler()
800
- self._initialized = True
801
- except Exception as e:
802
- logger.warning(f"UnifiedContextBuilder: falha ao init reply handler: {e}")
803
-
804
- def build(
805
- self,
806
- conversation_id: str,
807
- user_id: str = "",
808
- reply_metadata: Optional[Dict[str, Any]] = None,
809
- current_message: str = "",
810
- current_emotion: str = "neutro",
811
- stm_messages: Optional[List[MessageWithContext]] = None
812
- ) -> UnifiedMessageContext:
813
- """
814
- Constrói contexto unificado.
815
-
816
- Args:
817
- conversation_id: ID único da conversa
818
- user_id: ID do usuário
819
- reply_metadata: Metadados do reply
820
- current_message: Mensagem atual
821
- current_emotion: Emoção atual
822
- stm_messages: Mensagens STM (usa manager se None)
823
-
824
- Returns:
825
- UnifiedMessageContext pronto para uso
826
- """
827
- self._ensure_initialized()
828
-
829
- # ===== 1. PROCESSA REPLY CONTEXT (TIK) =====
830
- is_reply = reply_metadata.get('is_reply', False) if reply_metadata else False
831
-
832
- reply_context = {
833
- 'is_reply': is_reply,
834
- 'reply_to_bot': reply_metadata.get('reply_to_bot', False) if reply_metadata else False,
835
- 'quoted_author': reply_metadata.get('quoted_author_name', '') if reply_metadata else '',
836
- 'quoted_content': reply_metadata.get('quoted_text_original', '') or
837
- reply_metadata.get('mensagem_citada', '') if reply_metadata else '',
838
- 'importancia': IMPORTANCIA_NORMAL,
839
- 'emocao': current_emotion,
840
- 'priority': 1
841
- }
842
-
843
- # Calcula prioridade do reply
844
- if is_reply and reply_metadata:
845
- reply_context['priority'] = self._calculate_reply_priority(
846
- reply_metadata.get('reply_to_bot', False),
847
- current_message,
848
- reply_metadata.get('quoted_text_original', '')
849
- )
850
-
851
- # Calcula importância baseada em prioridade
852
- if reply_context['priority'] >= PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
853
- reply_context['importancia'] = IMPORTANCIA_PERGUNTA_CURTA_REPLY
854
- elif reply_context['priority'] >= PRIORITY_REPLY_TO_BOT:
855
- reply_context['importancia'] = IMPORTANCIA_REPLY_TO_BOT
856
- elif reply_context['priority'] >= PRIORITY_REPLY:
857
- reply_context['importancia'] = IMPORTANCIA_REPLY
858
-
859
- # ===== 2. OBTÉM STM (TOK) =====
860
- if stm_messages is None:
861
- stm_messages = self.stm_manager.get_context(
862
- conversation_id,
863
- include_replies=True,
864
- prioritize_replies=True,
865
- max_messages=30,
866
- max_tokens=4000
867
- )
868
-
869
- # ===== 3. CALCULA TOKEN BUDGET =====
870
- # Detecta se é self-reply (reply para o próprio bot)
871
- is_self_reply = False
872
- if is_reply and reply_metadata:
873
- bot_num = str(config.BOT_NUMERO if hasattr(config, 'BOT_NUMERO') else '37839265886398')
874
- quoted_num = str(reply_metadata.get('quoted_author_numero', ''))
875
- if bot_num in quoted_num or (reply_metadata.get('reply_to_bot') and 'você' in str(reply_metadata.get('quoted_author_name', '')).lower()):
876
- is_self_reply = True
877
-
878
- budget = ContextTokenBudget().calculate(
879
- is_reply=is_reply,
880
- reply_priority=reply_context['priority'],
881
- is_self_reply=is_self_reply
882
- )
883
-
884
- # ===== 4. FETCH LONG-TERM MEMORY (DB) =====
885
- long_term_memory_string = ""
886
- if self.db and user_id:
887
- try:
888
- # Recuperar aprendizados e gírias
889
- ltm_facts = self.db.recuperar_aprendizado_detalhado(user_id)
890
- ltm_girias = self.db.recuperar_girias_usuario(user_id)
891
- ltm_tom = self.db.obter_tom_predominante(user_id)
892
- persona_ltm = self.db.recuperar_persona(user_id) if hasattr(self.db, 'recuperar_persona') else None
893
-
894
- ltm_lines = []
895
-
896
- # --- PERSONA DO USUÁRIO (Rastreador) ---
897
- if persona_ltm:
898
- ltm_lines.append("=== PERFIL ANALISADO DO USUÁRIO ===")
899
- if persona_ltm.get('personalidade') and persona_ltm['personalidade'] != "None":
900
- ltm_lines.append(f"• Personalidade: {persona_ltm['personalidade']}")
901
- if persona_ltm.get('gostos') and persona_ltm['gostos'] != "None":
902
- ltm_lines.append(f"• Tópicos de Interesse: {persona_ltm['gostos']}")
903
- if persona_ltm.get('desgostos') and persona_ltm['desgostos'] != "None":
904
- ltm_lines.append(f"• Desgostos/Gatilhos: {persona_ltm['desgostos']}")
905
- if persona_ltm.get('vicios_linguagem') and persona_ltm['vicios_linguagem'] != "None":
906
- ltm_lines.append(f"• Padrões de Linguagem: {persona_ltm['vicios_linguagem']}")
907
- if persona_ltm.get('emocional') and persona_ltm['emocional'] != "None":
908
- ltm_lines.append(f"• Perfil Emocional: {persona_ltm['emocional']}")
909
-
910
- if ltm_tom:
911
- ltm_lines.append(f"• Seu tom de conversa predominante é: {ltm_tom}")
912
-
913
- if ltm_facts and isinstance(ltm_facts, dict):
914
- # Ignorar chaves puramente técnicas como 'emocao_atual' ou strings de timestamp longas
915
- fatos_filtrados = {k: v for k, v in ltm_facts.items() if not k.startswith("emocao_")}
916
- if fatos_filtrados:
917
- ltm_lines.append("• Fatos Relevantes Aprendidos:")
918
- for k, v in list(fatos_filtrados.items())[:5]: # limita 5
919
- ltm_lines.append(f" - {k}: {v}")
920
-
921
- if ltm_girias:
922
- ltm_lines.append("• Expressões Específicas Recentes:")
923
- for g in ltm_girias[:5]:
924
- ltm_lines.append(f" - {g['giria']} ({g['significado']})")
925
-
926
- if ltm_lines:
927
- long_term_memory_string = "\n".join(ltm_lines)
928
- except Exception as e:
929
- logger.warning(f"Erro ao recuperar memória de longo prazo: {e}")
930
-
931
- # [INTEGRAÇÃO LSTM MENTAL CONTEXT]
932
- if LSTM_AVAILABLE and self.db and conversation_id:
933
- try:
934
- lstm_ext = get_lstm_extension(self.db)
935
- lstm_data = lstm_ext.get_context_for_prompt(conversation_id, user_id)
936
- if lstm_data:
937
- lstm_lines = ["\n[INTERNAL_BRAIN_ONLY: COMPLETE CONVERSATION SUMMARY]"]
938
- if lstm_data.get('topic_principal'):
939
- lstm_lines.append(f"• Tópico Atual: {lstm_data['topic_principal']}")
940
- if lstm_data.get('subtopicas'):
941
- lstm_lines.append(f"• Subtópicos: {', '.join(lstm_data['subtopicas'])}")
942
- if lstm_data.get('unanswered_questions'):
943
- lstm_lines.append(f"• Perguntas pendentes: {'; '.join(lstm_data['unanswered_questions'])}")
944
- if lstm_data.get('interaction_pattern'):
945
- lstm_lines.append(f"• Padrão do usuário: {lstm_data['interaction_pattern']}")
946
- if lstm_data.get('assumed_knowledge'):
947
- lstm_lines.append(f"• Usuário sabe sobre: {', '.join(lstm_data['assumed_knowledge'])}")
948
- lstm_lines.append("NOTA MENTAL MÁXIMA: Este resumo é estritamente para seu conhecimento interno. NUNCA mencione que você leu um resumo ou narre o histórico. Apenas aja como se você lembrasse de tudo naturalmente.")
949
-
950
- if long_term_memory_string:
951
- long_term_memory_string += "\n" + "\n".join(lstm_lines)
952
- else:
953
- long_term_memory_string = "\n".join(lstm_lines)
954
- except Exception as e:
955
- logger.warning(f"Erro ao recuperar contexto LSTM: {e}")
956
-
957
- # ===== 4.5 FETCH GROUP LISTENING CONTEXT =====
958
- group_listening = []
959
- if self.db and conversation_id and "@g.us" in conversation_id:
960
- try:
961
- # Recuperar últimas 15 mensagens de outras pessoas no grupo
962
- # Exclui a mensagem atual e mensagens do bot para focar no que o grupo fala
963
- bot_num = str(config.BOT_NUMERO if hasattr(config, 'BOT_NUMERO') else '37839265886398')
964
- rows = self.db._execute_with_retry("""
965
- SELECT usuario, mensagem FROM mensagens
966
- WHERE conversation_id = ?
967
- AND numero != ?
968
- AND mensagem != ?
969
- ORDER BY id DESC LIMIT 15
970
- """, (conversation_id, bot_num, current_message))
971
-
972
- if rows:
973
- for r in reversed(rows):
974
- group_listening.append({'usuario': r[0], 'mensagem': r[1]})
975
- except Exception as ge:
976
- logger.warning(f"Erro ao recuperar group listening context: {ge}")
977
-
978
- # ===== 5. CRIA CONTEXTO UNIFICADO =====
979
- unified = UnifiedMessageContext(
980
- conversation_id=conversation_id,
981
- user_id=user_id,
982
- timestamp=time.time(),
983
- is_reply=is_reply,
984
- reply_to_bot=reply_context['reply_to_bot'],
985
- reply_priority=reply_context['priority'],
986
- quoted_author=reply_context['quoted_author'],
987
- quoted_content=reply_context['quoted_content'],
988
- reply_importancia=reply_context['importancia'],
989
- stm_messages=stm_messages,
990
- stm_summary=self.stm_manager.get_summary(conversation_id),
991
- stm_emotional_trend=self._get_stm_emotional_trend(stm_messages),
992
- long_term_memory=long_term_memory_string,
993
- group_listening_context=group_listening,
994
- sync_mode="tiktok",
995
- token_budget=budget,
996
- current_message=current_message,
997
- current_emotion=current_emotion
998
- )
999
-
1000
- return unified
1001
-
1002
- def _calculate_reply_priority(
1003
- self,
1004
- reply_to_bot: bool,
1005
- current_message: str,
1006
- quoted_content: str
1007
- ) -> int:
1008
- """
1009
- Calcula nível de prioridade do reply.
1010
-
1011
- Returns:
1012
- 1=normal, 2=reply, 3=reply_to_bot, 4=critical
1013
- """
1014
- if not reply_to_bot:
1015
- return PRIORITY_REPLY
1016
-
1017
- if is_pergunta_curta(current_message):
1018
- return PRIORITY_REPLY_TO_BOT_SHORT_QUESTION
1019
-
1020
- return PRIORITY_REPLY_TO_BOT
1021
-
1022
- def _get_stm_emotional_trend(
1023
- self,
1024
- stm_messages: List[MessageWithContext]
1025
- ) -> str:
1026
- """Obtém tendência emocional da STM."""
1027
- if not stm_messages:
1028
- return "neutral"
1029
-
1030
- emocoes = {}
1031
- for msg in stm_messages[-10:]: # Últimas 10
1032
- emocao = msg.emocao or "neutral"
1033
- emocoes[emocao] = emocoes.get(emocao, 0) + 1
1034
-
1035
- if not emocoes:
1036
- return "neutral"
1037
-
1038
- return max(emocoes, key=emocoes.get)
1039
-
1040
- def format_for_llm(
1041
- self,
1042
- unified: UnifiedMessageContext,
1043
- include_header: bool = True
1044
- ) -> str:
1045
- """
1046
- Formata contexto unificado para o prompt do LLM.
1047
-
1048
- Args:
1049
- unified: Contexto unificado
1050
- include_header: Se inclui cabeçalho
1051
-
1052
- Returns:
1053
- String formatada para o prompt
1054
- """
1055
- return format_unified_context_for_llm(unified, unified.token_budget)
1056
-
1057
- def add_to_stm(
1058
- self,
1059
- conversation_id: str,
1060
- role: str,
1061
- content: str,
1062
- emocao: str = "neutral",
1063
- reply_info: Optional[Dict] = None,
1064
- resposta: str = ""
1065
- ) -> MessageWithContext:
1066
- """
1067
- Adiciona mensagem (user ou bot) à STM.
1068
-
1069
- Args:
1070
- conversation_id: ID da conversa
1071
- role: "user" ou "assistant"
1072
- content: Conteúdo da mensagem
1073
- emocao: Emoção
1074
- reply_info: Info de reply (se aplicável)
1075
- resposta: Resposta do bot (se for assistant)
1076
-
1077
- Returns:
1078
- MessageWithContext criada
1079
- """
1080
- # Para mensagens do bot, usa a resposta gerada
1081
- if role == "assistant" and resposta:
1082
- content = resposta
1083
-
1084
- return self.stm_manager.add_message(
1085
- conversation_id=conversation_id,
1086
- role=role,
1087
- content=content,
1088
- emocao=emocao,
1089
- reply_info=reply_info
1090
- )
1091
-
1092
- def merge_reply_with_stm(
1093
- self,
1094
- reply_context: Dict[str, Any],
1095
- stm_messages: List[MessageWithContext],
1096
- max_stm: int = 30
1097
- ) -> List[MessageWithContext]:
1098
- """
1099
- Mescla reply context com STM para contexto do LLM.
1100
-
1101
- Args:
1102
- reply_context: Contexto do reply
1103
- stm_messages: Mensagens STM
1104
- max_stm: Máximo de mensagens STM
1105
-
1106
- Returns:
1107
- Lista combinada
1108
- """
1109
- return sync_reply_with_stm(reply_context, stm_messages, max_stm)
1110
-
1111
-
1112
- # ====================================
1113
- # FACTORY FUNCTIONS
1114
- # ====================================
1115
-
1116
- _unified_builder: Optional[UnifiedContextBuilder] = None
1117
-
1118
- def get_unified_context_builder() -> UnifiedContextBuilder:
1119
- """Obtém instância singleton do builder."""
1120
- global _unified_builder
1121
- if _unified_builder is None:
1122
- _unified_builder = UnifiedContextBuilder()
1123
- return _unified_builder
1124
-
1125
-
1126
- def get_stm_manager() -> ShortTermMemoryManager:
1127
- """Obtém instância singleton do manager de STM."""
1128
- return ShortTermMemoryManager()
1129
-
1130
-
1131
- def build_unified_context(
1132
- conversation_id: str,
1133
- user_id: str = "",
1134
- reply_metadata: Optional[Dict[str, Any]] = None,
1135
- current_message: str = "",
1136
- current_emotion: str = "neutral"
1137
- ) -> UnifiedMessageContext:
1138
- """
1139
- Factory function para construir contexto unificado.
1140
-
1141
- Usage:
1142
- context = build_unified_context(
1143
- conversation_id="pv:2449...",
1144
- reply_metadata={...},
1145
- current_message="."
1146
- )
1147
- """
1148
- builder = get_unified_context_builder()
1149
- return builder.build(
1150
- conversation_id=conversation_id,
1151
- user_id=user_id,
1152
- reply_metadata=reply_metadata,
1153
- current_message=current_message,
1154
- current_emotion=current_emotion
1155
- )
1156
-
1157
-
1158
- # ====================================
1159
- # COMPATIBILITY HELPERS
1160
- # ====================================
1161
-
1162
- def gerar_id_conversao(
1163
- numero: str,
1164
- tipo_conversa: str = "pv",
1165
- grupo_id: Optional[str] = None
1166
- ) -> str:
1167
- """
1168
- Gera ID de conversa para STM isolada.
1169
-
1170
- Args:
1171
- numero: Número do usuário
1172
- tipo_conversa: "pv" ou "grupo"
1173
- grupo_id: ID do grupo (para conversas em grupo)
1174
-
1175
- Returns:
1176
- ID único da conversa
1177
- """
1178
- from .context_isolation import generate_context_id
1179
- return generate_context_id(numero, tipo_conversa, grupo_id)
1180
-
1181
-
1182
- # type: ignore
 
1
+ # type: ignore
2
+ """
3
+ ================================================================================
4
+ AKIRA V21 ULTIMATE - UNIFIED CONTEXT MODULE
5
+ ================================================================================
6
+ Sistema unificado que integra Reply Context + Short-Term Memory em sintonia.
7
+
8
+ Philosophy: "Reply context e STM devem trabalhar em sintonia como tik e tack -
9
+ um fornece o contexto imediato/urgente (o que o usuário está respondendo),
10
+ o outro fornece o fluxo da conversa (contexto geral)."
11
+
12
+ Features:
13
+ - Integração seamless entre reply context e STM
14
+ - Token budgeting inteligente entre os dois contextos
15
+ - Priorização dinâmica baseada no tipo de mensagem
16
+ - Suporte a perguntas curtas com reply (prioridade máxima)
17
+ - Persistência e restauração de contexto unificado
18
+ ================================================================================
19
+ """
20
+
21
+ import os
22
+ import sys
23
+ import time
24
+ import json
25
+ import logging
26
+ from typing import Optional, Dict, Any, List, Tuple
27
+ from dataclasses import dataclass, field
28
+ from datetime import datetime
29
+
30
+ # Imports robustos com fallback
31
+ try:
32
+ from . import config
33
+ from .short_term_memory import (
34
+ ShortTermMemory,
35
+ MessageWithContext,
36
+ IMPORTANCIA_NORMAL,
37
+ IMPORTANCIA_REPLY,
38
+ IMPORTANCIA_REPLY_TO_BOT,
39
+ IMPORTANCIA_PERGUNTA_CURTA_REPLY,
40
+ estimar_tokens,
41
+ is_pergunta_curta
42
+ )
43
+ from .reply_context_handler import (
44
+ ReplyContextHandler,
45
+ ProcessedReplyContext,
46
+ PRIORITY_REPLY,
47
+ PRIORITY_REPLY_TO_BOT,
48
+ PRIORITY_REPLY_TO_BOT_SHORT_QUESTION
49
+ )
50
+ UNIFIED_CONTEXT_AVAILABLE = True
51
+ except ImportError as e:
52
+ try:
53
+ import modules.config as config
54
+ from modules.short_term_memory import (
55
+ ShortTermMemory,
56
+ MessageWithContext,
57
+ IMPORTANCIA_NORMAL,
58
+ IMPORTANCIA_REPLY,
59
+ IMPORTANCIA_REPLY_TO_BOT,
60
+ IMPORTANCIA_PERGUNTA_CURTA_REPLY,
61
+ estimar_tokens,
62
+ is_pergunta_curta
63
+ )
64
+ from modules.reply_context_handler import (
65
+ ReplyContextHandler,
66
+ ProcessedReplyContext,
67
+ PRIORITY_REPLY,
68
+ PRIORITY_REPLY_TO_BOT,
69
+ PRIORITY_REPLY_TO_BOT_SHORT_QUESTION
70
+ )
71
+ UNIFIED_CONTEXT_AVAILABLE = True
72
+ except ImportError:
73
+ UNIFIED_CONTEXT_AVAILABLE = False
74
+ config = None
75
+
76
+ try:
77
+ from .lstm_extension import get_lstm_extension
78
+ LSTM_AVAILABLE = True
79
+ except ImportError:
80
+ try:
81
+ from modules.lstm_extension import get_lstm_extension
82
+ LSTM_AVAILABLE = True
83
+ except ImportError:
84
+ LSTM_AVAILABLE = False
85
+
86
+ logger = logging.getLogger(__name__)
87
+
88
+ # ============================================================
89
+ # CONFIGURAÇÃO DE TOKEN BUDGET
90
+ # ============================================================
91
+
92
+ @dataclass
93
+ class ContextTokenBudget:
94
+ """
95
+ Alocação de tokens entre reply context e STM.
96
+
97
+ Philosophy: Reply tem orçamento dedicado (urgente), STM tem o resto (fluxo).
98
+ """
99
+ total_budget: int = 8000
100
+ system_tokens: int = 1500
101
+ user_message_tokens: int = 500
102
+
103
+ # Reply context budget (URGENTE)
104
+ reply_tokens: int = 300
105
+ reply_priority_multiplier: float = 1.0
106
+
107
+ # STM budget (FLUXO DA CONVERSA)
108
+ stm_tokens: int = 4000
109
+
110
+ # Reservado para resposta
111
+ response_reserved: int = 1200
112
+
113
+ def calculate(self, is_reply: bool, reply_priority: int = 1) -> 'ContextTokenBudget':
114
+ """
115
+ Calcula orçamento baseado no tipo de mensagem.
116
+
117
+ Args:
118
+ is_reply: Se é um reply
119
+ reply_priority: Nível de prioridade do reply (1-4)
120
+
121
+ Returns:
122
+ ContextTokenBudget ajustado
123
+ """
124
+ budget = ContextTokenBudget(
125
+ total_budget=self.total_budget,
126
+ system_tokens=self.system_tokens,
127
+ user_message_tokens=self.user_message_tokens
128
+ )
129
+
130
+ if is_reply:
131
+ if reply_priority >= PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
132
+ # Pergunta curta com reply ao bot = prioridade máxima
133
+ budget.reply_tokens = min(1500, int(self.total_budget * 0.20))
134
+ budget.reply_priority_multiplier = 1.5
135
+ budget.stm_tokens = min(3500, int(self.total_budget * 0.45))
136
+ elif reply_priority >= PRIORITY_REPLY_TO_BOT:
137
+ # Reply ao bot
138
+ budget.reply_tokens = min(1200, int(self.total_budget * 0.15))
139
+ budget.reply_priority_multiplier = 1.3
140
+ budget.stm_tokens = min(4000, int(self.total_budget * 0.50))
141
+ elif reply_priority >= PRIORITY_REPLY:
142
+ # Reply normal
143
+ budget.reply_tokens = min(800, int(self.total_budget * 0.10))
144
+ budget.reply_priority_multiplier = 1.1
145
+ budget.stm_tokens = min(4500, int(self.total_budget * 0.55))
146
+ else:
147
+ # Mensagem normal = STM tem orçamento completo
148
+ budget.reply_tokens = 0
149
+ budget.stm_tokens = min(5000, int(self.total_budget * 0.65))
150
+
151
+ # Calcula response reserved
152
+ budget.response_reserved = (
153
+ budget.total_budget -
154
+ budget.system_tokens -
155
+ budget.user_message_tokens -
156
+ budget.reply_tokens -
157
+ budget.stm_tokens
158
+ )
159
+
160
+ return budget
161
+
162
+ def to_dict(self) -> Dict[str, Any]:
163
+ """Serializa para dicionário."""
164
+ return {
165
+ "total_budget": self.total_budget,
166
+ "system_tokens": self.system_tokens,
167
+ "user_message_tokens": self.user_message_tokens,
168
+ "reply_tokens": self.reply_tokens,
169
+ "stm_tokens": self.stm_tokens,
170
+ "response_reserved": self.response_reserved,
171
+ "reply_priority_multiplier": self.reply_priority_multiplier
172
+ }
173
+
174
+
175
+ # ============================================================
176
+ # CONTEXTO UNIFICADO
177
+ # ============================================================
178
+
179
+ @dataclass
180
+ class UnifiedMessageContext:
181
+ """
182
+ Contexto unificado combinando reply + STM.
183
+
184
+ Philosophy: Reply context (tik) + STM (tok) trabalhando em sintonia.
185
+
186
+ Attributes:
187
+ - Reply context: Contexto imediato/urgente do reply
188
+ - STM context: Contexto do fluxo da conversa
189
+ - Integration: Como os dois são combinados
190
+ """
191
+ # Identificação
192
+ conversation_id: str = ""
193
+ user_id: str = ""
194
+ timestamp: float = field(default_factory=time.time)
195
+
196
+ # Reply Context (TIK - urgente/imediato)
197
+ is_reply: bool = False
198
+ reply_to_bot: bool = False
199
+ reply_priority: int = 1 # 1=normal, 2=reply, 3=reply_to_bot, 4=critical
200
+ quoted_author: str = ""
201
+ quoted_content: str = ""
202
+ reply_importancia: float = 1.0
203
+ replied_to_author: str = ""
204
+ replied_to_content: str = ""
205
+
206
+ # STM Context (TOK - fluxo da conversa)
207
+ stm_messages: List[MessageWithContext] = field(default_factory=list)
208
+ stm_summary: Dict[str, Any] = field(default_factory=dict)
209
+ stm_emotional_trend: str = "neutral"
210
+
211
+ # Long-Term Memory (RAG)
212
+ long_term_memory: str = ""
213
+
214
+ # Integração
215
+ sync_mode: str = "tiktok" # "tiktok" = reply priority + STM flow
216
+ token_budget: ContextTokenBudget = field(default_factory=ContextTokenBudget)
217
+
218
+ # Mensagem atual
219
+ current_message: str = ""
220
+ current_emotion: str = "neutro"
221
+ system_override: str = ""
222
+
223
+ def to_dict(self) -> Dict[str, Any]:
224
+ """Serializa para dicionário."""
225
+ return {
226
+ "conversation_id": self.conversation_id,
227
+ "user_id": self.user_id,
228
+ "timestamp": self.timestamp,
229
+ "is_reply": self.is_reply,
230
+ "reply_to_bot": self.reply_to_bot,
231
+ "reply_priority": self.reply_priority,
232
+ "quoted_author": self.quoted_author,
233
+ "quoted_content": self.quoted_content[:500] if self.quoted_content else "",
234
+ "reply_importancia": self.reply_importancia,
235
+ "stm_messages_count": len(self.stm_messages),
236
+ "stm_summary": self.stm_summary,
237
+ "stm_emotional_trend": self.stm_emotional_trend,
238
+ "long_term_memory": self.long_term_memory,
239
+ "sync_mode": self.sync_mode,
240
+ "token_budget": self.token_budget.to_dict(),
241
+ "current_message": self.current_message[:100],
242
+ "current_emotion": self.current_emotion,
243
+ "replied_to_author": self.replied_to_author,
244
+ "replied_to_content": self.replied_to_content[:200] if self.replied_to_content else ""
245
+ }
246
+
247
+ def build_prompt(self) -> str:
248
+ """
249
+ Constrói prompt formatado para o LLM.
250
+
251
+ Returns:
252
+ String formatada com contexto unificado (reply + STM)
253
+ """
254
+ return format_unified_context_for_llm(self, self.token_budget)
255
+
256
+
257
+ # ====================================
258
+ # HELPER FUNCTIONS
259
+ # ====================================
260
+
261
+ def sync_reply_with_stm(
262
+ reply_context: Dict[str, Any],
263
+ stm_messages: List[MessageWithContext],
264
+ max_stm_messages: int = 10
265
+ ) -> List[MessageWithContext]:
266
+ """
267
+ Sincroniza reply context com mensagens STM.
268
+
269
+ Philosophy: Reply (tik) vem primeiro, STM (tok) vem depois.
270
+ Ambos são combinados para formar o contexto completo.
271
+
272
+ Args:
273
+ reply_context: Contexto do reply
274
+ stm_messages: Mensagens da memória de curto prazo
275
+ max_stm_messages: Máximo de mensagens STM a incluir
276
+
277
+ Returns:
278
+ Lista combinada de mensagens para contexto
279
+ """
280
+ combined = []
281
+
282
+ # 1. Adiciona reply context como mensagem mais recente (TIK)
283
+ if reply_context.get('is_reply', False):
284
+ reply_msg = MessageWithContext(
285
+ role="user",
286
+ content=reply_context.get('quoted_content', ''),
287
+ importancia=reply_context.get('importancia', IMPORTANCIA_NORMAL),
288
+ emocao=reply_context.get('emocao', 'neutral'),
289
+ reply_info={
290
+ 'is_reply': True,
291
+ 'reply_to_bot': reply_context.get('reply_to_bot', False),
292
+ 'quoted_text_original': reply_context.get('quoted_content', ''),
293
+ 'priority_level': reply_context.get('priority', 1),
294
+ 'sync_mode': 'tiktok'
295
+ }
296
+ )
297
+ combined.append(reply_msg)
298
+
299
+ # 2. Adiciona mensagens STM (TOK - fluxo da conversa)
300
+ # Pega últimas N mensagens STM
301
+ stm_to_add = stm_messages[-max_stm_messages:] if stm_messages else []
302
+
303
+ for msg in stm_to_add:
304
+ # Se a mensagem STM já é um reply, preserva info
305
+ if msg.is_reply and not msg.reply_info.get('sync_mode'):
306
+ msg.reply_info['sync_mode'] = 'stm'
307
+ combined.append(msg)
308
+
309
+ return combined
310
+
311
+
312
+ def format_unified_context_for_llm(
313
+ unified: UnifiedMessageContext,
314
+ budget: ContextTokenBudget
315
+ ) -> str:
316
+ """
317
+ Formata contexto unificado para o prompt do LLM.
318
+
319
+ Philosophy: Reply (tik) primeiro por ser urgente, STM (tok) depois
320
+ para contexto da conversa.
321
+
322
+ Args:
323
+ unified: Contexto unificado
324
+ budget: Orçamento de tokens
325
+
326
+ Returns:
327
+ String formatada para o prompt
328
+ """
329
+ parts = []
330
+
331
+ # ===== 1. REPLY CONTEXT (TIK - URGENTE) =====
332
+ if unified.is_reply:
333
+ reply_section = []
334
+ reply_section.append("=" * 50)
335
+ reply_section.append("[📎 INTERNAL_BRAIN_ONLY: REPLY CONTEXT]")
336
+ reply_section.append("=" * 50)
337
+
338
+ if unified.reply_to_bot:
339
+ reply_section.append("⚠️ VOCÊ ESTÁ SENDO DIRETAMENTE RESPONDIDO!")
340
+ else:
341
+ reply_section.append(f"Respondendo a: {unified.quoted_author}")
342
+
343
+ # Conteúdo citado
344
+ if unified.quoted_content:
345
+ quoted_preview = unified.quoted_content[:budget.reply_tokens // 4]
346
+ reply_section.append(f"\n<quoted_message>\n{quoted_preview}...\n</quoted_message>")
347
+
348
+ # Prioridade
349
+ if unified.reply_priority >= PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
350
+ reply_section.append("\n💡 PERGUNTA CURTA + REPLY: FOCO NA CITAÇÃO")
351
+
352
+ reply_section.append("\n📌 INSTRUÇÕES DE REPLY:")
353
+ if unified.reply_to_bot:
354
+ thread_info = ""
355
+ if unified.replied_to_author:
356
+ thread_info = f" (Esta sua mensagem citada foi enviada originalmente para {unified.replied_to_author} em resposta a: \"{unified.replied_to_content[:200]}...\")"
357
+
358
+ reply_section.append(f"- O usuário está a reagir a uma mensagem SUA (<quoted_message>){thread_info}. Responda diretamente ao comentário do usuário, mantendo a postura sobre o que você disse.")
359
+ else:
360
+ reply_section.append("- O usuário está a responder a <quoted_message>. Formule sua resposta com base nisso.")
361
+
362
+ reply_section.append("- PRESERVE a sua identidade e humor (seja o Akira, natural e irreverente).")
363
+ reply_section.append("- Nunca perca o fio da meada. Olhe as mensagens anteriores para entender o contexto real.")
364
+
365
+ parts.append("\n".join(reply_section))
366
+
367
+ # ===== RAG CONTEXT (MEMÓRIA DE LONGO PRAZO) =====
368
+ if unified.long_term_memory:
369
+ rag_section = []
370
+ rag_section.append("\n" + "=" * 50)
371
+ rag_section.append("[📖 INTERNAL_BRAIN_ONLY: LONG-TERM MEMORY]")
372
+ rag_section.append("=" * 50)
373
+ rag_section.append("(Informações previamente aprendidas sobre o usuário)")
374
+ rag_section.append(unified.long_term_memory)
375
+ parts.append("\n".join(rag_section))
376
+
377
+ # ===== 2. STM CONTEXT (METADADOS DE FLUXO) =====
378
+ if unified.stm_messages:
379
+ stm_section = []
380
+ # Não adicionamos as mensagens como texto aqui para evitar duplicação e truncagem,
381
+ # pois elas já são injetadas nativamente no array context_history da API.
382
+
383
+ # emotional trend
384
+ if unified.stm_emotional_trend != "neutral":
385
+ stm_section.append(f"\n📊 Tendência emocional do chat: {unified.stm_emotional_trend}")
386
+
387
+ if stm_section:
388
+ parts.append("\n".join(stm_section))
389
+
390
+ return "\n".join(parts)
391
+
392
+
393
+ # ====================================
394
+ # SHORT-TERM MEMORY MANAGER
395
+ # ====================================
396
+
397
+ class ShortTermMemoryManager:
398
+ """
399
+ Gerenciador de instâncias STM por conversa.
400
+
401
+ Philosophy: Cada conversa tem sua própria STM isolada,
402
+ mas todas compartilham o mesmo manager.
403
+ """
404
+
405
+ _instance = None
406
+ _lock = None
407
+
408
+ def __new__(cls):
409
+ if cls._instance is None:
410
+ cls._lock = __import__('threading').Lock()
411
+ with cls._lock:
412
+ if cls._instance is None:
413
+ cls._instance = super().__new__(cls)
414
+ cls._instance._initialized = False
415
+ return cls._instance
416
+
417
+ def __init__(self):
418
+ if self._initialized:
419
+ return
420
+
421
+ self._instances: Dict[str, ShortTermMemory] = {}
422
+ # Path centralizado via config
423
+ if config and hasattr(config, "DATA_DIR"):
424
+ self._storage_path: str = str(config.DATA_DIR / "stm_cache")
425
+ else:
426
+ self._storage_path: str = os.path.join(
427
+ os.path.dirname(os.path.abspath(__file__)),
428
+ '..', 'data', 'stm_cache'
429
+ )
430
+ os.makedirs(self._storage_path, exist_ok=True)
431
+ self._initialized = True
432
+ self._load_all()
433
+ logger.debug(f"✅ ShortTermMemoryManager inicializado (persistência: {self._storage_path})")
434
+
435
+ # ============================================================
436
+ # PERSISTÊNCIA EM DISCO
437
+ # ============================================================
438
+
439
+ def _stm_file_path(self, conversation_id: str) -> str:
440
+ """Retorna caminho do arquivo de persistência de uma STM."""
441
+ safe_id = conversation_id.replace('/', '_').replace('\\', '_')[:128]
442
+ return os.path.join(self._storage_path, f"{safe_id}.json")
443
+
444
+ def _load_stm(self, conversation_id: str) -> Optional[ShortTermMemory]:
445
+ """Carrega STM de disco se existir."""
446
+ fpath = self._stm_file_path(conversation_id)
447
+ if os.path.exists(fpath):
448
+ try:
449
+ stm = ShortTermMemory.load_from_file(fpath)
450
+ self._instances[conversation_id] = stm
451
+ return stm
452
+ except Exception as e:
453
+ logger.warning(f"Falha ao carregar STM {conversation_id[:8]}: {e}")
454
+ return None
455
+
456
+ def _load_all(self) -> None:
457
+ """Carrega todas as STMs persistidas do disco."""
458
+ if not os.path.isdir(self._storage_path):
459
+ return
460
+ for fname in os.listdir(self._storage_path):
461
+ if fname.endswith('.json'):
462
+ cid = fname[:-5]
463
+ self._load_stm(cid)
464
+ logger.info(f"📦 {len(self._instances)} STM(s) carregadas do disco")
465
+
466
+ def _save_stm(self, conversation_id: str) -> None:
467
+ """Salva STM de uma conversa em disco."""
468
+ if conversation_id in self._instances:
469
+ fpath = self._stm_file_path(conversation_id)
470
+ self._instances[conversation_id].save_to_file(fpath)
471
+
472
+ def get_or_create_stm(
473
+ self,
474
+ conversation_id: str,
475
+ user_id: str = "",
476
+ max_messages: int = 100
477
+ ) -> ShortTermMemory:
478
+ """
479
+ Obtém ou cria STM para uma conversa.
480
+
481
+ Args:
482
+ conversation_id: ID único da conversa
483
+ user_id: ID do usuário
484
+ max_messages: Máximo de mensagens na STM
485
+
486
+ Returns:
487
+ Instância de ShortTermMemory
488
+ """
489
+ if conversation_id not in self._instances:
490
+ self._instances[conversation_id] = ShortTermMemory(
491
+ conversation_id=conversation_id,
492
+ max_messages=max_messages
493
+ )
494
+ logger.debug(f"🧠 STM criada: {conversation_id[:8]}...")
495
+
496
+ return self._instances[conversation_id]
497
+
498
+ def add_message(
499
+ self,
500
+ conversation_id: str,
501
+ role: str,
502
+ content: str,
503
+ author_name: str = "Usuário",
504
+ emocao: str = "neutral",
505
+ reply_info: Optional[Dict] = None,
506
+ importancia: Optional[float] = None
507
+ ) -> MessageWithContext:
508
+ """
509
+ Adiciona mensagem à STM de uma conversa.
510
+
511
+ Args:
512
+ conversation_id: ID da conversa
513
+ role: "user" ou "assistant"
514
+ content: Texto da mensagem
515
+ emocao: Emoção detectada
516
+ reply_info: Info de reply (se aplicável)
517
+ importancia: Importância customizada
518
+
519
+ Returns:
520
+ MessageWithContext criada
521
+ """
522
+ stm = self.get_or_create_stm(conversation_id)
523
+
524
+ # Calcula importância automaticamente se não fornecida
525
+ if importancia is None:
526
+ from .short_term_memory import calcular_importancia
527
+ importancia = calcular_importancia(
528
+ is_reply=bool(reply_info and reply_info.get("is_reply")),
529
+ reply_to_bot=bool(reply_info and reply_info.get("reply_to_bot")),
530
+ mensagem=content,
531
+ emocao=emocao
532
+ )
533
+
534
+ msg = stm.add_message(
535
+ role=role,
536
+ content=content,
537
+ author_name=author_name,
538
+ importancia=importancia,
539
+ emocao=emocao,
540
+ reply_info=reply_info
541
+ )
542
+
543
+ # Persiste em disco (salva a cada mensagem para garantir durability)
544
+ self._save_stm(conversation_id)
545
+ return msg
546
+
547
+ def get_context(
548
+ self,
549
+ conversation_id: str,
550
+ include_replies: bool = True,
551
+ prioritize_replies: bool = True,
552
+ max_messages: int = 10,
553
+ max_tokens: int = 4000
554
+ ) -> List[MessageWithContext]:
555
+ """
556
+ Obtém contexto da STM de uma conversa.
557
+
558
+ Args:
559
+ conversation_id: ID da conversa
560
+ include_replies: Se inclui replies
561
+ prioritize_replies: Se prioriza replies
562
+ max_messages: Máximo de mensagens
563
+ max_tokens: Máximo de tokens
564
+
565
+ Returns:
566
+ Lista de mensagens
567
+ """
568
+ if conversation_id not in self._instances:
569
+ return []
570
+
571
+ stm = self._instances[conversation_id]
572
+ return stm.get_context_window(
573
+ include_replies=include_replies,
574
+ prioritize_replies=prioritize_replies,
575
+ max_messages=max_messages,
576
+ max_tokens=max_tokens
577
+ )
578
+
579
+ def get_summary(self, conversation_id: str) -> Dict[str, Any]:
580
+ """
581
+ Obtém resumo da STM de uma conversa.
582
+
583
+ Args:
584
+ conversation_id: ID da conversa
585
+
586
+ Returns:
587
+ Dicionário com resumo
588
+ """
589
+ if conversation_id not in self._instances:
590
+ return {}
591
+
592
+ stm = self._instances[conversation_id]
593
+ return stm.get_conversation_summary()
594
+
595
+ def clear(self, conversation_id: str) -> bool:
596
+ """
597
+ Limpa STM de uma conversa, inclusive persistência em disco.
598
+
599
+ Args:
600
+ conversation_id: ID da conversa
601
+
602
+ Returns:
603
+ True se limpou
604
+ """
605
+ if conversation_id in self._instances:
606
+ self._instances[conversation_id].clear()
607
+ del self._instances[conversation_id]
608
+ # Remove arquivo de persistência
609
+ fpath = self._stm_file_path(conversation_id)
610
+ if hasattr(self, 'fpath') or True:
611
+ try:
612
+ fpath = self._stm_file_path(conversation_id)
613
+ if os.path.exists(fpath):
614
+ os.remove(fpath)
615
+ except Exception:
616
+ pass
617
+ return True
618
+
619
+ def clear_messages(self, conversation_id: str) -> None:
620
+ """Alias de compatibilidade para clear()."""
621
+ self.clear(conversation_id)
622
+
623
+ def get_messages(
624
+ self,
625
+ conversation_id: str,
626
+ limit: int = 10,
627
+ include_replies: bool = True
628
+ ) -> list:
629
+ """
630
+ Alias de compatibilidade para get_context().
631
+ Retorna lista de MessageWithContext para a conversa.
632
+
633
+ Args:
634
+ conversation_id: ID da conversa
635
+ limit: Quantidade máxima de mensagens
636
+ include_replies: Se inclui replies
637
+
638
+ Returns:
639
+ Lista de MessageWithContext
640
+ """
641
+ if conversation_id not in self._instances:
642
+ return []
643
+ stm = self._instances[conversation_id]
644
+ result = stm.get_context_window(
645
+ include_replies=include_replies,
646
+ prioritize_replies=True,
647
+ max_messages=limit
648
+ )
649
+ return result if result else []
650
+
651
+
652
+ # ====================================
653
+ # UNIFIED CONTEXT BUILDER
654
+ # ====================================
655
+
656
+ class UnifiedContextBuilder:
657
+ """
658
+ Constrói contexto unificado combinando reply + STM.
659
+
660
+ Philosophy: "Reply context e STM devem trabalhar em sintonia como tik e tack"
661
+
662
+ Usage:
663
+ builder = UnifiedContextBuilder()
664
+ context = builder.build(
665
+ conversation_id="...",
666
+ reply_metadata={...},
667
+ current_message="..."
668
+ )
669
+ prompt_section = builder.format_for_llm(context)
670
+ """
671
+
672
+ def __init__(self, context_manager=None, stm_manager=None, db_instance=None):
673
+ self.stm_manager = stm_manager if stm_manager else ShortTermMemoryManager()
674
+ self.context_manager = context_manager
675
+ self.db = db_instance
676
+ self.reply_handler = None
677
+ self._initialized = False
678
+
679
+ def _ensure_initialized(self):
680
+ """Garante inicialização do reply handler."""
681
+ if not self._initialized and UNIFIED_CONTEXT_AVAILABLE:
682
+ try:
683
+ self.reply_handler = ReplyContextHandler()
684
+ self._initialized = True
685
+ except Exception as e:
686
+ logger.warning(f"UnifiedContextBuilder: falha ao init reply handler: {e}")
687
+
688
+ def build(
689
+ self,
690
+ conversation_id: str,
691
+ user_id: str = "",
692
+ reply_metadata: Optional[Dict[str, Any]] = None,
693
+ current_message: str = "",
694
+ current_emotion: str = "neutro",
695
+ stm_messages: Optional[List[MessageWithContext]] = None
696
+ ) -> UnifiedMessageContext:
697
+ """
698
+ Constrói contexto unificado.
699
+
700
+ Args:
701
+ conversation_id: ID único da conversa
702
+ user_id: ID do usuário
703
+ reply_metadata: Metadados do reply
704
+ current_message: Mensagem atual
705
+ current_emotion: Emoção atual
706
+ stm_messages: Mensagens STM (usa manager se None)
707
+
708
+ Returns:
709
+ UnifiedMessageContext pronto para uso
710
+ """
711
+ self._ensure_initialized()
712
+
713
+ # ===== 1. PROCESSA REPLY CONTEXT (TIK) =====
714
+ is_reply = reply_metadata.get('is_reply', False) if reply_metadata else False
715
+
716
+ reply_context = {
717
+ 'is_reply': is_reply,
718
+ 'reply_to_bot': reply_metadata.get('reply_to_bot', False) if reply_metadata else False,
719
+ 'quoted_author': reply_metadata.get('quoted_author_name', '') if reply_metadata else '',
720
+ 'quoted_content': reply_metadata.get('quoted_text_original', '') or
721
+ reply_metadata.get('mensagem_citada', '') if reply_metadata else '',
722
+ 'importancia': IMPORTANCIA_NORMAL,
723
+ 'emocao': current_emotion,
724
+ 'priority': 1,
725
+ 'replied_to_author': reply_metadata.get('replied_to_author', '') if reply_metadata else '',
726
+ 'replied_to_content': reply_metadata.get('replied_to_content', '') if reply_metadata else ''
727
+ }
728
+
729
+ # Calcula prioridade do reply
730
+ if is_reply and reply_metadata:
731
+ reply_context['priority'] = self._calculate_reply_priority(
732
+ reply_metadata.get('reply_to_bot', False),
733
+ current_message,
734
+ reply_metadata.get('quoted_text_original', '')
735
+ )
736
+
737
+ # Calcula importância baseada em prioridade
738
+ if reply_context['priority'] >= PRIORITY_REPLY_TO_BOT_SHORT_QUESTION:
739
+ reply_context['importancia'] = IMPORTANCIA_PERGUNTA_CURTA_REPLY
740
+ elif reply_context['priority'] >= PRIORITY_REPLY_TO_BOT:
741
+ reply_context['importancia'] = IMPORTANCIA_REPLY_TO_BOT
742
+ elif reply_context['priority'] >= PRIORITY_REPLY:
743
+ reply_context['importancia'] = IMPORTANCIA_REPLY
744
+
745
+ # ===== 2. OBTÉM STM (TOK) =====
746
+ if stm_messages is None:
747
+ stm_messages = self.stm_manager.get_context(
748
+ conversation_id,
749
+ include_replies=True,
750
+ prioritize_replies=True,
751
+ max_messages=10,
752
+ max_tokens=4000
753
+ )
754
+
755
+ # ===== 3. CALCULA TOKEN BUDGET =====
756
+ budget = ContextTokenBudget().calculate(
757
+ is_reply=is_reply,
758
+ reply_priority=reply_context['priority']
759
+ )
760
+
761
+ # ===== 4. FETCH LONG-TERM MEMORY (DB) =====
762
+ long_term_memory_string = ""
763
+ if self.db and user_id:
764
+ try:
765
+ # Recuperar aprendizados e gírias
766
+ ltm_facts = self.db.recuperar_aprendizado_detalhado(user_id)
767
+ ltm_girias = self.db.recuperar_girias_usuario(user_id)
768
+ ltm_tom = self.db.obter_tom_predominante(user_id)
769
+ persona_ltm = self.db.recuperar_persona(user_id) if hasattr(self.db, 'recuperar_persona') else None
770
+
771
+ ltm_lines = []
772
+
773
+ # --- PERSONA DO USUÁRIO (Rastreador) ---
774
+ if persona_ltm:
775
+ ltm_lines.append("=== PERFIL ANALISADO DO USUÁRIO ===")
776
+ if persona_ltm.get('personalidade') and persona_ltm['personalidade'] != "None":
777
+ ltm_lines.append(f"• Personalidade: {persona_ltm['personalidade']}")
778
+ if persona_ltm.get('gostos') and persona_ltm['gostos'] != "None":
779
+ ltm_lines.append(f"• Tópicos de Interesse: {persona_ltm['gostos']}")
780
+ if persona_ltm.get('desgostos') and persona_ltm['desgostos'] != "None":
781
+ ltm_lines.append(f"• Desgostos/Gatilhos: {persona_ltm['desgostos']}")
782
+ if persona_ltm.get('vicios_linguagem') and persona_ltm['vicios_linguagem'] != "None":
783
+ ltm_lines.append(f"• Padrões de Linguagem: {persona_ltm['vicios_linguagem']}")
784
+ if persona_ltm.get('emocional') and persona_ltm['emocional'] != "None":
785
+ ltm_lines.append(f"• Perfil Emocional: {persona_ltm['emocional']}")
786
+
787
+ if ltm_tom:
788
+ ltm_lines.append(f"• Seu tom de conversa predominante é: {ltm_tom}")
789
+
790
+ if ltm_facts and isinstance(ltm_facts, dict):
791
+ # Ignorar chaves puramente técnicas como 'emocao_atual' ou strings de timestamp longas
792
+ fatos_filtrados = {k: v for k, v in ltm_facts.items() if not k.startswith("emocao_")}
793
+ if fatos_filtrados:
794
+ ltm_lines.append("• Fatos Relevantes Aprendidos:")
795
+ for k, v in list(fatos_filtrados.items())[:5]: # limita 5
796
+ ltm_lines.append(f" - {k}: {v}")
797
+
798
+ if ltm_girias:
799
+ ltm_lines.append("• Expressões Específicas Recentes:")
800
+ for g in ltm_girias[:5]:
801
+ ltm_lines.append(f" - {g['giria']} ({g['significado']})")
802
+
803
+ if ltm_lines:
804
+ long_term_memory_string = "\n".join(ltm_lines)
805
+ except Exception as e:
806
+ logger.warning(f"Erro ao recuperar memória de longo prazo: {e}")
807
+
808
+ # [INTEGRAÇÃO LSTM MENTAL CONTEXT]
809
+ if LSTM_AVAILABLE and self.db and conversation_id:
810
+ try:
811
+ lstm_ext = get_lstm_extension(self.db)
812
+ lstm_data = lstm_ext.get_context_for_prompt(conversation_id, user_id)
813
+ if lstm_data:
814
+ lstm_lines = ["\n[INTERNAL_BRAIN_ONLY: COMPLETE CONVERSATION SUMMARY]"]
815
+ if lstm_data.get('topic_principal'):
816
+ lstm_lines.append(f"• Tópico Atual: {lstm_data['topic_principal']}")
817
+ if lstm_data.get('subtopicas'):
818
+ lstm_lines.append(f"• Subtópicos: {', '.join(lstm_data['subtopicas'])}")
819
+ if lstm_data.get('unanswered_questions'):
820
+ lstm_lines.append(f"• Perguntas pendentes: {'; '.join(lstm_data['unanswered_questions'])}")
821
+ if lstm_data.get('interaction_pattern'):
822
+ lstm_lines.append(f"• Padrão do usuário: {lstm_data['interaction_pattern']}")
823
+ if lstm_data.get('assumed_knowledge'):
824
+ lstm_lines.append(f"• Usuário sabe sobre: {', '.join(lstm_data['assumed_knowledge'])}")
825
+ lstm_lines.append("NOTA MENTAL MÁXIMA: Este resumo é estritamente para seu conhecimento interno. NUNCA mencione que você leu um resumo ou narre o histórico. Apenas aja como se você lembrasse de tudo naturalmente.")
826
+
827
+ if long_term_memory_string:
828
+ long_term_memory_string += "\n" + "\n".join(lstm_lines)
829
+ else:
830
+ long_term_memory_string = "\n".join(lstm_lines)
831
+ except Exception as e:
832
+ logger.warning(f"Erro ao recuperar contexto LSTM: {e}")
833
+
834
+ # ===== 5. CRIA CONTEXTO UNIFICADO =====
835
+ unified = UnifiedMessageContext(
836
+ conversation_id=conversation_id,
837
+ user_id=user_id,
838
+ timestamp=time.time(),
839
+ is_reply=is_reply,
840
+ reply_to_bot=reply_context['reply_to_bot'],
841
+ reply_priority=reply_context['priority'],
842
+ quoted_author=reply_context['quoted_author'],
843
+ quoted_content=reply_context['quoted_content'],
844
+ reply_importancia=reply_context['importancia'],
845
+ stm_messages=stm_messages,
846
+ stm_summary=self.stm_manager.get_summary(conversation_id),
847
+ stm_emotional_trend=self._get_stm_emotional_trend(stm_messages),
848
+ long_term_memory=long_term_memory_string,
849
+ sync_mode="tiktok",
850
+ token_budget=budget,
851
+ current_message=current_message,
852
+ current_emotion=current_emotion,
853
+ replied_to_author=reply_context['replied_to_author'],
854
+ replied_to_content=reply_context['replied_to_content']
855
+ )
856
+
857
+ return unified
858
+
859
+ def _calculate_reply_priority(
860
+ self,
861
+ reply_to_bot: bool,
862
+ current_message: str,
863
+ quoted_content: str
864
+ ) -> int:
865
+ """
866
+ Calcula nível de prioridade do reply.
867
+
868
+ Returns:
869
+ 1=normal, 2=reply, 3=reply_to_bot, 4=critical
870
+ """
871
+ if not reply_to_bot:
872
+ return PRIORITY_REPLY
873
+
874
+ if is_pergunta_curta(current_message):
875
+ return PRIORITY_REPLY_TO_BOT_SHORT_QUESTION
876
+
877
+ return PRIORITY_REPLY_TO_BOT
878
+
879
+ def _get_stm_emotional_trend(
880
+ self,
881
+ stm_messages: List[MessageWithContext]
882
+ ) -> str:
883
+ """Obtém tendência emocional da STM."""
884
+ if not stm_messages:
885
+ return "neutral"
886
+
887
+ emocoes = {}
888
+ for msg in stm_messages[-10:]: # Últimas 10
889
+ emocao = msg.emocao or "neutral"
890
+ emocoes[emocao] = emocoes.get(emocao, 0) + 1
891
+
892
+ if not emocoes:
893
+ return "neutral"
894
+
895
+ return max(emocoes, key=emocoes.get)
896
+
897
+ def format_for_llm(
898
+ self,
899
+ unified: UnifiedMessageContext,
900
+ include_header: bool = True
901
+ ) -> str:
902
+ """
903
+ Formata contexto unificado para o prompt do LLM.
904
+
905
+ Args:
906
+ unified: Contexto unificado
907
+ include_header: Se inclui cabeçalho
908
+
909
+ Returns:
910
+ String formatada para o prompt
911
+ """
912
+ return format_unified_context_for_llm(unified, unified.token_budget)
913
+
914
+ def add_to_stm(
915
+ self,
916
+ conversation_id: str,
917
+ role: str,
918
+ content: str,
919
+ author_name: str = "Usuário",
920
+ emocao: str = "neutral",
921
+ reply_info: Optional[Dict] = None,
922
+ resposta: str = ""
923
+ ) -> MessageWithContext:
924
+ """
925
+ Adiciona mensagem (user ou bot) à STM.
926
+
927
+ Args:
928
+ conversation_id: ID da conversa
929
+ role: "user" ou "assistant"
930
+ content: Conteúdo da mensagem
931
+ emocao: Emoção
932
+ reply_info: Info de reply (se aplicável)
933
+ resposta: Resposta do bot (se for assistant)
934
+
935
+ Returns:
936
+ MessageWithContext criada
937
+ """
938
+ # Para mensagens do bot, usa a resposta gerada
939
+ if role == "assistant" and resposta:
940
+ content = resposta
941
+
942
+ return self.stm_manager.add_message(
943
+ conversation_id=conversation_id,
944
+ role=role,
945
+ content=content,
946
+ author_name=author_name,
947
+ emocao=emocao,
948
+ reply_info=reply_info
949
+ )
950
+
951
+ def merge_reply_with_stm(
952
+ self,
953
+ reply_context: Dict[str, Any],
954
+ stm_messages: List[MessageWithContext],
955
+ max_stm: int = 10
956
+ ) -> List[MessageWithContext]:
957
+ """
958
+ Mescla reply context com STM para contexto do LLM.
959
+
960
+ Args:
961
+ reply_context: Contexto do reply
962
+ stm_messages: Mensagens STM
963
+ max_stm: Máximo de mensagens STM
964
+
965
+ Returns:
966
+ Lista combinada
967
+ """
968
+ return sync_reply_with_stm(reply_context, stm_messages, max_stm)
969
+
970
+
971
+ # ====================================
972
+ # FACTORY FUNCTIONS
973
+ # ====================================
974
+
975
+ _unified_builder: Optional[UnifiedContextBuilder] = None
976
+
977
+ def get_unified_context_builder() -> UnifiedContextBuilder:
978
+ """Obtém instância singleton do builder."""
979
+ global _unified_builder
980
+ if _unified_builder is None:
981
+ _unified_builder = UnifiedContextBuilder()
982
+ return _unified_builder
983
+
984
+
985
+ def get_stm_manager() -> ShortTermMemoryManager:
986
+ """Obtém instância singleton do manager de STM."""
987
+ return ShortTermMemoryManager()
988
+
989
+
990
+ def build_unified_context(
991
+ conversation_id: str,
992
+ user_id: str = "",
993
+ reply_metadata: Optional[Dict[str, Any]] = None,
994
+ current_message: str = "",
995
+ current_emotion: str = "neutral"
996
+ ) -> UnifiedMessageContext:
997
+ """
998
+ Factory function para construir contexto unificado.
999
+
1000
+ Usage:
1001
+ context = build_unified_context(
1002
+ conversation_id="pv:2449...",
1003
+ reply_metadata={...},
1004
+ current_message="."
1005
+ )
1006
+ """
1007
+ builder = get_unified_context_builder()
1008
+ return builder.build(
1009
+ conversation_id=conversation_id,
1010
+ user_id=user_id,
1011
+ reply_metadata=reply_metadata,
1012
+ current_message=current_message,
1013
+ current_emotion=current_emotion
1014
+ )
1015
+
1016
+
1017
+ # ====================================
1018
+ # COMPATIBILITY HELPERS
1019
+ # ====================================
1020
+
1021
+ def gerar_id_conversao(
1022
+ numero: str,
1023
+ tipo_conversa: str = "pv",
1024
+ grupo_id: Optional[str] = None
1025
+ ) -> str:
1026
+ """
1027
+ Gera ID de conversa para STM isolada.
1028
+
1029
+ Args:
1030
+ numero: Número do usuário
1031
+ tipo_conversa: "pv" ou "grupo"
1032
+ grupo_id: ID do grupo (para conversas em grupo)
1033
+
1034
+ Returns:
1035
+ ID único da conversa
1036
+ """
1037
+ from .context_isolation import generate_context_id
1038
+ return generate_context_id(numero, tipo_conversa, grupo_id)
1039
+
1040
+
1041
+ # type: ignore