arginfluence / processing /sentiment.py
successjourney's picture
Deploy ArgInfluence dashboard
e3408e0 verified
Raw History Blame Contribute Delete
13.1 kB
"""
sentiment.py — Análisis de sentimiento via HuggingFace Inference API.
Usa el modelo pysentimiento/robertuito-sentiment-analysis, entrenado
específicamente para español rioplatense y de Twitter. Corre en la nube,
sin modelos locales.
Salida por texto: {"label": "positive"|"negative"|"neutral", "score": float}
El modelo devuelve etiquetas POS/NEG/NEU que se normalizan a positive/negative/neutral.
"""
import logging
import re
import time
import requests
from supabase import create_client
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import (
HUGGINGFACE_API_KEY,
HF_API_URL_SENTIMENT,
HF_BATCH_SIZE,
HF_RETRY_DELAY,
HF_RATELIMIT_DELAY,
SCRAPING_MAX_RETRIES,
SUPABASE_URL,
SUPABASE_KEY,
SUPABASE_SERVICE_KEY,
LOG_FORMAT,
LOG_DATE_FORMAT,
LOG_LEVEL,
)
from processing.sanitizer import limpiar_texto_para_nlp
logging.basicConfig(format=LOG_FORMAT, datefmt=LOG_DATE_FORMAT)
logger = logging.getLogger("sentiment")
logger.setLevel(getattr(logging, LOG_LEVEL, logging.INFO))
# Etiqueta vacía que se devuelve cuando un texto falla
_VACIO: dict = {"label": "neutral", "score": 0.0}
# Robertuito usa POS/NEG/NEU — normalizar a los valores esperados por el resto del sistema
_LABEL_MAP: dict[str, str] = {
"pos": "positive",
"neg": "negative",
"neu": "neutral",
# Por si el modelo cambia a formato largo:
"positive": "positive",
"negative": "negative",
"neutral": "neutral",
}
# ── Diccionario de preprocesamiento para español rioplatense/político ──────────
# Términos que el modelo tiende a clasificar mal por su forma superficial
# (palabras que suenan positivas pero son insultos, o negativas pero son neutras).
# Reemplazamos por paráfrasis más estándar que el modelo interpreta correctamente.
_PREPROCESO_RIOPLATENSE: dict[str, str] = {
# Insultos que suenan neutros o positivos para el modelo
"la casta": "los políticos corruptos",
"casta política": "clase política corrupta",
"planero": "receptor de subsidios estatales",
"planera": "receptora de subsidios estatales",
"planeros": "receptores de subsidios estatales",
"ñoqui": "empleado innecesario del estado",
"ñoquis": "empleados innecesarios del estado",
"trucho": "falso o fraudulento",
"trucha": "falsa o fraudulenta",
"truchos": "falsos o fraudulentos",
"curro": "negocio corrupto",
"curros": "negocios corruptos",
"currar": "estafar o robar",
"gorila": "antiperonista extremo",
"gorilas": "antiperonistas extremos",
"zurdo": "persona de izquierda radical",
"zurda": "persona de izquierda radical",
"zurdos": "personas de izquierda radical",
"bardear": "insultar o agredir verbalmente",
"bardeando": "insultando",
"bardeó": "insultó",
"forro": "persona despreciable",
"pelotudo": "persona estúpida",
"pelotuda": "persona estúpida",
"cagón": "cobarde",
"cagada": "error grave o fracaso",
"chorro": "ladrón",
"chorros": "ladrones",
# Términos políticos con carga que el modelo puede malinterpretar
"mileista": "seguidor de Milei",
"mileísmo": "movimiento político de Milei",
"kirchnerista": "seguidor del kirchnerismo",
"kirchnerismo": "movimiento político kirchnerista",
"libertario": "seguidor del partido La Libertad Avanza",
"libertarios": "seguidores del partido La Libertad Avanza",
"nac&pop": "nacional y popular de orientación peronista",
"nac and pop": "nacional y popular de orientación peronista",
# Ironía política argentina frecuente en replies
"viva la libertad": "apoyo al gobierno actual",
"carajo": "expresión de enojo",
}
def preprocesar_para_sentimiento(texto: str) -> str:
"""
Preprocesa texto en español rioplatense antes de enviarlo al modelo.
Reemplaza lunfardo y jerga política que el modelo puede clasificar mal.
No modifica la polaridad del texto — traduce a términos estándar.
"""
if not texto:
return texto
texto_proc = texto
for termino, reemplazo in _PREPROCESO_RIOPLATENSE.items():
pattern = re.compile(re.escape(termino), re.IGNORECASE)
texto_proc = pattern.sub(reemplazo, texto_proc)
return texto_proc
def _headers() -> dict:
"""Construye los headers HTTP para la API de HuggingFace."""
headers = {"Content-Type": "application/json"}
if HUGGINGFACE_API_KEY:
headers["Authorization"] = f"Bearer {HUGGINGFACE_API_KEY}"
return headers
def _fetch_sentiment_batch(textos: list[str]) -> list[dict]:
"""
Envía un batch de textos a HuggingFace y devuelve el sentimiento de cada uno.
Maneja reintentos ante 503 (modelo cargando) y 429 (rate limit).
Devuelve lista de dicts vacíos si todos los intentos fallan.
"""
payload = {"inputs": textos, "options": {"wait_for_model": True}}
for intento in range(1, SCRAPING_MAX_RETRIES + 1):
try:
resp = requests.post(
HF_API_URL_SENTIMENT,
headers=_headers(),
json=payload,
timeout=60,
)
if resp.status_code == 200:
raw = resp.json()
resultados = []
for item in raw:
# La API devuelve lista de candidatos por texto — tomamos el mayor score
if isinstance(item, list):
mejor = max(item, key=lambda x: x.get("score", 0))
label_raw = mejor.get("label", "neutral").lower()
label = _LABEL_MAP.get(label_raw, "neutral")
resultados.append({
"label": label,
"score": round(mejor.get("score", 0.0), 4),
})
else:
resultados.append(_VACIO.copy())
return resultados
elif resp.status_code == 503:
logger.warning(
f"[sentiment] 503 modelo cargando (intento {intento}/"
f"{SCRAPING_MAX_RETRIES}) — esperando {HF_RETRY_DELAY}s"
)
time.sleep(HF_RETRY_DELAY)
elif resp.status_code == 429:
logger.warning(f"[sentiment] 429 rate limit — esperando {HF_RATELIMIT_DELAY}s")
time.sleep(HF_RATELIMIT_DELAY)
else:
logger.error(f"[sentiment] error {resp.status_code}: {resp.text[:200]}")
return [_VACIO.copy() for _ in textos]
except requests.exceptions.Timeout:
logger.warning(f"[sentiment] timeout en intento {intento}/{SCRAPING_MAX_RETRIES}")
if intento < SCRAPING_MAX_RETRIES:
time.sleep(HF_RETRY_DELAY)
except Exception as e:
logger.error(f"[sentiment] error inesperado: {e}")
return [_VACIO.copy() for _ in textos]
return [_VACIO.copy() for _ in textos]
def analyze_sentiment(texts: list[str]) -> list[dict]:
"""
Analiza el sentimiento de una lista de textos.
Procesa en batches de HF_BATCH_SIZE. Textos vacíos devuelven neutral/0.0.
Parámetros:
texts: lista de strings a analizar
Devuelve:
Lista de dicts {"label": str, "score": float} en el mismo orden que la entrada.
"""
if not texts:
return []
resultados: list[dict] = []
for i in range(0, len(texts), HF_BATCH_SIZE):
batch = texts[i: i + HF_BATCH_SIZE]
textos_validos_idx = [j for j, t in enumerate(batch) if t and t.strip()]
textos_validos = [batch[j] for j in textos_validos_idx]
batch_resultado = [_VACIO.copy() for _ in batch]
if textos_validos:
sentimientos = _fetch_sentiment_batch(textos_validos)
for pos, sent in zip(textos_validos_idx, sentimientos):
batch_resultado[pos] = sent
resultados.extend(batch_resultado)
logger.info(
f"[sentiment] batch {i // HF_BATCH_SIZE + 1}: "
f"{len(textos_validos)} textos analizados"
)
return resultados
def calcular_sentimiento_replies_por_actor(actor_ids: list[str]) -> dict[str, float]:
"""
Calcula el sentimiento promedio de los replies recibidos por cada actor.
Lee posts con tipo_interaccion='reply_recibido' de Supabase, aplica
preprocesamiento rioplatense y analiza con robertuito via HF API.
Fórmula del score numérico:
positive → +confidence_score
negative → −confidence_score
neutral → 0.0
Promedio sobre todos los replies del actor.
Parámetros:
actor_ids: lista de actor_ids a procesar
Devuelve:
Dict {actor_id: sentimiento_promedio} con valores entre −1 y +1.
Solo incluye actores que tienen replies disponibles.
"""
if not actor_ids or not SUPABASE_URL or not SUPABASE_KEY:
return {}
try:
client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY)
resultados: dict[str, float] = {}
for actor_id in actor_ids:
resp = (
client.table("posts")
.select("titulo")
.eq("actor_id", actor_id)
.eq("tipo_interaccion", "reply_recibido")
.limit(500)
.execute()
)
posts = resp.data or []
if not posts:
continue
textos = [
preprocesar_para_sentimiento(p.get("titulo", "") or "")
for p in posts
]
textos = [t for t in textos if t.strip()]
if not textos:
continue
sentimientos = analyze_sentiment(textos)
scores_numericos = []
for s in sentimientos:
label = s.get("label", "neutral")
score = s.get("score", 0.0)
if label == "positive":
scores_numericos.append(score)
elif label == "negative":
scores_numericos.append(-score)
else:
scores_numericos.append(0.0)
if scores_numericos:
avg = sum(scores_numericos) / len(scores_numericos)
resultados[actor_id] = round(avg, 4)
logger.info(
f"[sentiment] {actor_id}: sentimiento_promedio={avg:+.4f} "
f"({len(scores_numericos)} replies analizados)"
)
return resultados
except Exception as e:
logger.error(f"[sentiment] error calculando sentimiento de replies: {e}")
return {}
def get_actor_sentiment_summary(actor_id: str) -> dict:
"""
Calcula el resumen de sentimiento para un actor a partir de sus posts en Supabase.
Lee los posts del actor, analiza el sentimiento de cada título+resumen,
y agrega los resultados en porcentajes.
Parámetros:
actor_id: ID del actor en seed_actors.json
Devuelve:
Dict con claves: positivo (%), negativo (%), neutral (%), total_analizado (int).
Si no hay posts devuelve todos en 0.
"""
vacio = {"positivo": 0.0, "negativo": 0.0, "neutral": 0.0, "total_analizado": 0}
if not SUPABASE_URL or not SUPABASE_KEY:
logger.warning("[sentiment] Supabase no configurado — sin datos para analizar")
return vacio
try:
client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY)
resp = (
client.table("posts")
.select("titulo, resumen")
.eq("actor_id", actor_id)
.limit(200)
.execute()
)
posts = resp.data or []
except Exception as e:
logger.error(f"[sentiment] error leyendo posts de Supabase para {actor_id}: {e}")
return vacio
if not posts:
logger.info(f"[sentiment] {actor_id}: sin posts en Supabase")
return vacio
# Combinar título + resumen, normalizar y detectar injection
textos = [
limpiar_texto_para_nlp(
f"{p.get('titulo', '')} {p.get('resumen', '')}".strip(),
actor_id=actor_id,
)
for p in posts
]
textos = [t for t in textos if t] # descartar los que quedaron vacíos
sentimientos = analyze_sentiment(textos)
conteo = {"positive": 0, "negative": 0, "neutral": 0}
for s in sentimientos:
label = s.get("label", "neutral")
if label in conteo:
conteo[label] += 1
else:
conteo["neutral"] += 1
total = len(sentimientos)
resumen = {
"positivo": round(conteo["positive"] / total * 100, 1),
"negativo": round(conteo["negative"] / total * 100, 1),
"neutral": round(conteo["neutral"] / total * 100, 1),
"total_analizado": total,
}
logger.info(
f"[sentiment] {actor_id}: {total} posts analizados — "
f"pos={resumen['positivo']}% neg={resumen['negativo']}% neu={resumen['neutral']}%"
)
return resumen