# type: ignore """ ================================================================================ LSTM EXTENSION - Complementa Short-Term Memory com Contexto de Longo Prazo ================================================================================ NÃO DUPLICA: Funciona JUNTO com short_term_memory.py, não substitui. Filosofia: - STM (short_term_memory.py): Últimas 100 mensagens (tático) - LSTM Extension: Contexto histórico (estratégico) Features: - Extração de topic_principal + conversation_path - Padrões de interação (perguntador vs narrativo) - Contradições detectadas - Conhecimento demonstrado (assumed_knowledge) - Perguntas pendentes (unanswered_questions) - Processamento assíncrono (não bloqueia) ================================================================================ """ import json import threading from typing import Dict, Any, Optional, List from dataclasses import dataclass from loguru import logger try: from .database import Database except ImportError: from database import Database @dataclass class LSTMContextSummary: """Contexto de longo prazo (complementa STM).""" context_id: str numero_usuario: str topic_principal: Optional[str] = None subtopicas: Optional[List[str]] = None conversation_path: Optional[List[str]] = None # Sequência de tópicos interaction_pattern: Optional[str] = None # "perguntador", "narrativo", etc unanswered_questions: Optional[List[str]] = None # Perguntas que ficaram em aberto assumed_knowledge: Optional[List[str]] = None # O que o usuário demonstrou saber contradictions: Optional[List[Dict[str, str]]] = None # Inconsistências detectadas last_key_message: Optional[str] = None # Última mensagem importante context_switches: int = 0 # Quantas vezes mudou de tópico def __post_init__(self): if self.subtopicas is None: self.subtopicas = [] if self.conversation_path is None: self.conversation_path = [] if self.unanswered_questions is None: self.unanswered_questions = [] if self.assumed_knowledge is None: self.assumed_knowledge = [] if self.contradictions is None: self.contradictions = [] class LSTMExtension: """ Extensão do STM com contexto de longo prazo. Processa assincronamente e salva em DB. """ def __init__(self, db: Database): self.db = db self.context_cache: Dict[str, LSTMContextSummary] = {} def process_message_background( self, context_id: str, numero_usuario: str, message: str, role: str = "user", message_id: Optional[str] = None ) -> None: """ Processa mensagem em background thread. NÃO BLOQUEIA. Args: context_id: ID da conversa (ex: "usuario:None:pv") numero_usuario: Número do usuário message: Texto da mensagem role: "user" ou "assistant" message_id: ID único da mensagem (para evitar duplicados) """ # Dispara em thread para não bloquear thread = threading.Thread( target=self._analyze_and_store, args=(context_id, numero_usuario, message, role, message_id), daemon=True ) thread.start() def _analyze_and_store( self, context_id: str, numero_usuario: str, message: str, role: str, message_id: Optional[str] = None ) -> None: """Análise interna (roda em thread separada).""" try: # 0. Verificação de idempotência (Anti-Duplicate) if message_id: query_check = "SELECT id FROM lstm_message_links WHERE context_id = ? AND message_id = ? LIMIT 1" res = self.db._execute_with_retry(query_check, (context_id, message_id)) if res: # logger.debug(f"⏭️ LSTM skip duplicate: {message_id}") return # 1. Recuperar contexto existente (com isolamento por speaker) existing = self._get_from_db(context_id, numero_usuario) summary = existing or LSTMContextSummary( context_id=context_id, numero_usuario=numero_usuario ) # 2. EXTRAIR TÓPICO (simples, sem LLM) topic = self._extract_topic_simple(message, summary.topic_principal) if topic and not summary.topic_principal: summary.topic_principal = topic summary.conversation_path = [topic] elif topic and topic != summary.topic_principal: # Mudança de tópico summary.context_switches += 1 summary.conversation_path.append(topic) summary.topic_principal = topic # 🔥 Limpa perguntas pendentes ao mudar de tópico summary.unanswered_questions = [] # 3. Detectar perguntas pendentes if "?" in message and role == "user": question = message.strip() if question not in summary.unanswered_questions: summary.unanswered_questions.append(question) # 4. Detectar padrão de interação if role == "user": pattern = self._detect_pattern(message) if pattern and not summary.interaction_pattern: summary.interaction_pattern = pattern # 5. Salvar em DB self._save_to_db(summary) # 6. Registrar link da mensagem (idempotência com speaker tracking completo) if message_id: try: # Tenta descobrir o nome de quem falou a partir de dados da thread ou similar # Usamos heurísticas simples ou nome genérico se não fornecido speaker_name = None if hasattr(self, '_current_speaker_name_temp'): speaker_name = getattr(self, '_current_speaker_name_temp') query_link = """INSERT INTO lstm_message_links (context_id, message_id, numero_usuario, speaker_name, created_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)""" self.db._execute_with_retry(query_link, (context_id, message_id, numero_usuario, speaker_name), commit=True) except Exception: pass # Provavelmente já existe (race condition), ignorar self.context_cache[context_id] = summary logger.debug(f"✅ LSTM context saved for user {numero_usuario} inside context {context_id} (topic: {summary.topic_principal})") except Exception as e: logger.warning(f"⚠️ LSTM background processing error: {e}") def get_context_for_prompt( self, context_id: str, numero_usuario: str = None, is_group: bool = False ) -> Optional[Dict[str, Any]]: """ Recupera contexto LSTM para enriquecer prompt. Retorna None se não houver contexto. Args: context_id: ID da conversa numero_usuario: Número do usuário (pode ser None em grupos) is_group: Se True, retorna contexto para TODOS os speakers do grupo Returns: Dict com contexto de longo prazo enriquecido com speaker tracking, ou None """ if is_group: # Recupera contexto para TODOS os speakers do grupo summaries = self._get_from_db_all_speakers(context_id) if not summaries: return None # Agrupa contexto: qual speaker falou sobre qual tópico speakers_topics = {} total_context_switches = 0 for summary in summaries: if summary.numero_usuario and summary.topic_principal: speakers_topics[summary.numero_usuario] = { "topic_principal": summary.topic_principal, "interaction_pattern": summary.interaction_pattern or "regular", "unanswered_questions": summary.unanswered_questions[:2] if summary.unanswered_questions else [], "assumed_knowledge": summary.assumed_knowledge[:1] if summary.assumed_knowledge else [], } total_context_switches += summary.context_switches or 0 if not speakers_topics: return None return { "context_id": context_id, "tipo": "grupo", "speakers_topics": speakers_topics, # ✅ Rastreia quem falou o quê "context_switches": total_context_switches, } else: # Código original para PV/direto # Tentar cache primeiro if context_id in self.context_cache: summary = self.context_cache[context_id] else: # Buscar DB (vai retornar primeiro speaker se houver múltiplos em grupo) summary = self._get_from_db(context_id) if not summary or not summary.topic_principal: return None # Formatar para uso em prompt return { "topic_principal": summary.topic_principal, "subtopicas": summary.subtopicas, "conversation_path": summary.conversation_path, "interaction_pattern": summary.interaction_pattern, "unanswered_questions": summary.unanswered_questions[:3] if summary.unanswered_questions else [], "assumed_knowledge": summary.assumed_knowledge[:3] if summary.assumed_knowledge else [], "context_switches": summary.context_switches, } def _extract_topic_simple(self, message: str, current_topic: Optional[str]) -> Optional[str]: """ Extrai tópico de forma simples (sem LLM). Heurísticas básicas. """ msg_lower = message.lower() # Detectar palavras-chave comuns topics_keywords = { "saúde": ["doença", "medicina", "cura", "tratamento", "sintoma", "hospital"], "técnica": ["código", "python", "função", "erro", "bug", "programação"], "relacionamento": ["namoro", "amor", "casal", "relacionamento", "ex"], "trabalho": ["emprego", "trabalho", "chefe", "salário", "despedida"], "escola": ["escola", "universidade", "prova", "nota", "aula"], "esportes": ["futebol", "basquete", "games", "competição", "time"], } for topic, keywords in topics_keywords.items(): if any(kw in msg_lower for kw in keywords): return topic # Se tem pergunta, extrai dela if "?" in message: # Pega primeira palavra significativa words = [w for w in msg_lower.split() if len(w) > 3] if words: return words[0] return current_topic def _detect_pattern(self, message: str) -> Optional[str]: """Detecta padrão de interação do usuário.""" msg_lower = message.lower() # Perguntador (muitas perguntas) if message.count("?") >= 2: return "perguntador" # Narrativo (histórias longas) if len(message.split()) > 30 and "?" not in message: return "narrativo" # Direto (respostas curtas, diretas) if len(message.split()) < 5 and "?" in message: return "direto" # Discordante (negação, contradição) if any(w in msg_lower for w in ["não", "discordo", "errado", "não é"]): return "discordante" return "regular" def _get_from_db(self, context_id: str, numero_usuario: Optional[str] = None) -> Optional[LSTMContextSummary]: """Recupera contexto do banco de dados usando Database._execute_with_retry().""" try: if numero_usuario: rows = self.db._execute_with_retry( "SELECT * FROM lstm_contexto WHERE context_id = ? AND numero_usuario = ?", (context_id, numero_usuario) ) else: rows = self.db._execute_with_retry( "SELECT * FROM lstm_contexto WHERE context_id = ?", (context_id,) ) if not rows: return None # Reconstruir objeto a partir do primeiro resultado row = rows[0] data = dict(row) # Desserializar JSON fields (compatível com SQLite TEXT e PostgreSQL JSONB) for field in ['subtopicas', 'conversation_path', 'unanswered_questions', 'assumed_knowledge', 'contradictions']: val = data.get(field) if val and isinstance(val, str): try: data[field] = json.loads(val) except (json.JSONDecodeError, TypeError): data[field] = [] # Remover campos que não fazem parte do dataclass LSTMContextSummary data.pop('created_at', None) data.pop('last_updated', None) data.pop('metadata', None) data.pop('emotional_state', None) data.pop('contexto_geral', None) # Caso outro campo legado apareça # Filtro genérico para prevenir qualquer keyword inesperada: import inspect valid_keys = inspect.signature(LSTMContextSummary).parameters.keys() filtered_data = {k: v for k, v in data.items() if k in valid_keys} return LSTMContextSummary(**filtered_data) except Exception as e: logger.warning(f"Error loading LSTM from DB: {e}") return None def _get_from_db_all_speakers(self, context_id: str) -> List[LSTMContextSummary]: """ Recupera contexto para TODOS os speakers em um contexto de grupo. Essencial para rastrear quem falou o quê em grupos. """ try: rows = self.db._execute_with_retry( "SELECT * FROM lstm_contexto WHERE context_id = ? ORDER BY last_updated DESC", (context_id,) ) if not rows: return [] summaries = [] for row in rows: data = dict(row) # Desserializar JSON fields - verificar se é string antes de parsear if data.get('subtopicas'): if isinstance(data['subtopicas'], str): data['subtopicas'] = json.loads(data['subtopicas']) if data.get('conversation_path'): if isinstance(data['conversation_path'], str): data['conversation_path'] = json.loads(data['conversation_path']) if data.get('unanswered_questions'): if isinstance(data['unanswered_questions'], str): data['unanswered_questions'] = json.loads(data['unanswered_questions']) if data.get('assumed_knowledge'): if isinstance(data['assumed_knowledge'], str): data['assumed_knowledge'] = json.loads(data['assumed_knowledge']) if data.get('contradictions'): if isinstance(data['contradictions'], str): data['contradictions'] = json.loads(data['contradictions']) # Limpar campos legados data.pop('created_at', None) data.pop('last_updated', None) data.pop('metadata', None) data.pop('emotional_state', None) data.pop('contexto_geral', None) # Filtro genérico import inspect valid_keys = inspect.signature(LSTMContextSummary).parameters.keys() filtered_data = {k: v for k, v in data.items() if k in valid_keys} summary = LSTMContextSummary(**filtered_data) summaries.append(summary) logger.debug(f"✅ Loaded LSTM speakers: context_id={context_id}, {len(summaries)} speakers") return summaries except Exception as e: logger.warning(f"Error loading LSTM speakers from DB: {e}") return [] def _save_to_db(self, summary: LSTMContextSummary) -> None: """Salva contexto no banco de dados usando Database._execute_with_retry().""" try: self.db._execute_with_retry( """INSERT OR REPLACE INTO lstm_contexto ( context_id, numero_usuario, topic_principal, subtopicas, conversation_path, interaction_pattern, unanswered_questions, assumed_knowledge, contradictions, last_key_message, context_switches, last_updated ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)""", ( summary.context_id, summary.numero_usuario, summary.topic_principal, json.dumps(summary.subtopicas or [], ensure_ascii=False), json.dumps(summary.conversation_path or [], ensure_ascii=False), summary.interaction_pattern, json.dumps(summary.unanswered_questions or [], ensure_ascii=False), json.dumps(summary.assumed_knowledge or [], ensure_ascii=False), json.dumps(summary.contradictions or [], ensure_ascii=False), summary.last_key_message, summary.context_switches, ), commit=True ) except Exception as e: logger.warning(f"Error saving LSTM to DB: {e}") # Singleton para acesso global _lstm_extension_instance: Optional[LSTMExtension] = None def get_lstm_extension(db: Database) -> LSTMExtension: """Retorna instância global de LSTM Extension.""" global _lstm_extension_instance if _lstm_extension_instance is None: _lstm_extension_instance = LSTMExtension(db) return _lstm_extension_instance