Spaces:
Running
Running
File size: 9,190 Bytes
95413a0 61fbf18 95413a0 61f1b9b 95413a0 61fbf18 95413a0 61fbf18 95413a0 61fbf18 95413a0 61fbf18 95413a0 61f1b9b 95413a0 61f1b9b 95413a0 61f1b9b 95413a0 61f1b9b 95413a0 61f1b9b 61fbf18 61f1b9b 61fbf18 61f1b9b 61fbf18 61f1b9b 61fbf18 95413a0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | import json
import threading
import re
from loguru import logger
from typing import List, Dict, Any, Optional
try:
from modules.database import Database
except ImportError:
from database import Database
class PersonaTracker:
"""
Rastreador de Persona em Background (Character.AI style LTM).
Analisa as conversas recentes do usuário silenciosamente e extrai
seus traços de personalidade, gostos e emoções no banco de dados.
"""
def __init__(self, db: Database, llm_client: Any):
"""
Args:
db (Database): Instância do banco de dados (database.py)
llm_client (Any): Instância do cliente LLM (ex: MultiLLMClient)
"""
self.db = db
self.llm_client = llm_client
self.processing_users = set()
def track_background(self, numero_usuario: str, historico_recente: List[Dict[str, str]]) -> None:
"""
Dispara a análise de persona em background para não bloquear a resposta do bot.
Args:
numero_usuario: ID ou número do usuário.
historico_recente: Lista de dicionários {'role': '...', 'content': '...'} com as últimas mensagens do usuário.
"""
if numero_usuario in self.processing_users:
return # Já está a ser analisado neste momento
if not historico_recente or len(historico_recente) < 3:
return # Muito pouco contexto para extrair algo útil
self.processing_users.add(numero_usuario)
thread = threading.Thread(
target=self._analyze_and_save,
args=(numero_usuario, historico_recente),
daemon=True
)
thread.start()
def _analyze_and_save(self, numero_usuario: str, historico: List[Dict[str, str]]) -> None:
"""Método interno que roda na Thread."""
try:
# Recupera a persona atual para o LLM saber o que já sabemos
persona_atual = self.db.recuperar_persona(numero_usuario) or {}
# Formata histórico apenas com as falas do usuário
user_messages = [msg['content'] for msg in historico if msg.get('role') == 'user']
if not user_messages:
return
historico_texto = "\n".join([f"User: {msg}" for msg in user_messages[-10:]]) # Últimas 10 msg
perfil_atual_str = json.dumps(persona_atual, ensure_ascii=False) if persona_atual else "Ainda não definido."
prompt = f"""Você é um analista comportamental focado em rastreamento de persona (Long-Term Memory).
Analise as mensagens recentes deste usuário e atualize/extraia o seu perfil.
[PERFIL ATUAL NO BANCO DE DADOS]
{perfil_atual_str}
[MENSAGENS RECENTES]
{historico_texto}
EXTRAIA/ATUALIZE os seguintes traços com base APENAS nas mensagens recentes e no perfil atual. Mantenha os traços do perfil atual que não foram contraditórios.
Seja CONCISO. Use bullet points curtos na sua mente e preencha os campos em formato JSON estrito.
Retorne APENAS um JSON válido. É OBRIGATÓRIO USAR ASPAS DUPLAS NAS CHAVES E NOS VALORES ("chave": "valor"):
{{
"personalidade": "Resumo calmo, agressivo, divertido, direto, etc.",
"vicios_linguagem": "Expressões ou gírias que ele usa muito.",
"gostos": "O que ele demonstrou gostar ou tópicos de interesse.",
"desgostos": "O que o irrita, o que ele odeia.",
"emocional": "Traços emocionais, forças ou gatilhos/fraquezas."
}}
"""
# Chama o LLM (garante formato json)
# Agora retorna (resposta, modelo_usado) ou apenas resposta
response_raw = self.llm_client.generate(prompt, [])
if isinstance(response_raw, tuple):
response_json_str = response_raw[0]
else:
response_json_str = response_raw
if not response_json_str:
return
# Extrai o JSON (Robusto contra texto extra, markdown e quebras parciais)
response_clean = response_json_str.strip()
# 1. Localiza o início do JSON, permitindo quebras (truncado)
if '{' in response_clean:
start_pts = response_clean.find('{')
end_pts = response_clean.rfind('}')
if end_pts > start_pts:
response_clean = response_clean[start_pts:end_pts+1]
else:
response_clean = response_clean[start_pts:] # Caso esteja truncado sem o '}'
# 2. Normalização agressiva de caracteres
response_clean = response_clean.replace('\r', '').replace('\n', ' ')
response_clean = re.sub(r'\s+', ' ', response_clean) # Remove múltiplos espaços
response_clean = re.sub(r'\\+', r'\\', response_clean)
# Tenta converter aspas simples em duplas para chaves/valores
response_clean = re.sub(r"(?<![a-zA-Z])'|'(?![a-zA-Z])", '"', response_clean)
response_clean = response_clean.replace('""', '"')
dados_extraidos = {}
parsed_success = False
try:
# Se houver chaves json "sujas" (ex: { personalidade: "x" } ao invés de {"personalidade": "x"})
rc_temp = re.sub(r'([{,]\s*)([a-zA-Z_]+)\s*:', r'\g<1>"\g<2>":', response_clean)
dados_extraidos = json.loads(rc_temp)
parsed_success = True
except json.JSONDecodeError:
# Fallback extremo 1: tenta reconstruir dicionário com ast
import ast
try:
ast_clean = response_clean.replace('\n', '')
dados_extraidos = ast.literal_eval(ast_clean)
if isinstance(dados_extraidos, dict):
parsed_success = True
except Exception:
pass
# Fallback extremo 2: Modo de extração de emergência (Regex Direto)
# Ideal para '{ personalidade: Direto, irônico, vicioslinguagem: orroh, gostos: -, ... }'
if not parsed_success or not isinstance(dados_extraidos, dict):
logger.warning(f"Iniciando MODO DE EMERGÊNCIA Regex para Persona de {numero_usuario}...")
dados_extraidos = {}
chaves_busca = ["personalidade", "vicios_linguagem", "vicioslinguagem", "gostos", "desgostos", "emocional"]
# Regex para encontrar "chave: valor (até encontrar outra chave ou o fim)"
lookahead = "|".join(chaves_busca)
for chave in chaves_busca:
# Pattern que ignora aspas nas chaves e valores, parando na próxima chave conhecida
pattern = re.compile(rf"['\"]?{chave}['\"]?\s*[:=]\s*(.*?)(?=(?:{lookahead})['\"]?\s*[:=]|$)", re.IGNORECASE | re.DOTALL)
match = pattern.search(response_clean)
if match:
val = match.group(1).strip()
# Limpeza radical de muletas de JSON (aspas, vírgulas no fim, chaves)
val = re.sub(r'^[\s\'"{\[:]+|[\s\'"}\],:]+$', '', val).strip()
if val and len(val) > 1:
real_key = "vicios_linguagem" if chave == "vicioslinguagem" else chave
dados_extraidos[real_key] = val
if not dados_extraidos:
# Se falhou tudo, mas temos a string, tentamos pelo menos salvar a string bruta como nota
logger.warning(f"Falha total no Parser JSON do Persona Tracker para {numero_usuario}. Salvando payload bruto como nota.")
dados_extraidos = {"personalidade": response_json_str[:200]}
parsed_success = True
# Limpa chaves inválidas
chaves_validas = ["personalidade", "vicios_linguagem", "gostos", "desgostos", "emocional"]
campos_atualizar = {k: str(v) for k, v in dados_extraidos.items() if k in chaves_validas}
if campos_atualizar:
sucesso = self.db.atualizar_persona(numero_usuario, campos_atualizar)
if sucesso:
logger.info(f"✅ Persona LTM atualizada para o usuário {numero_usuario} em background.")
else:
logger.warning(f"Falha ao salvar a persona no banco para {numero_usuario}.")
except json.JSONDecodeError:
logger.warning(f"Falha no Parser JSON do Persona Tracker para {numero_usuario}.")
except Exception as e:
logger.error(f"Erro no Persona Tracker background: {e}")
finally:
if numero_usuario in self.processing_users:
self.processing_users.remove(numero_usuario)
|