Spaces:
Running
Running
Download processing/sentiment.py from successjourney/arginfluence: direct link, hf CLI and curl.
- Browser
- Download file 13.1 kB
-
https://huggingface.co/spaces/successjourney/arginfluence/resolve/main/processing/sentiment.py
- Command line
-
hf download hf://spaces/successjourney/arginfluence/processing/sentiment.py
-
curl -L -o sentiment.py https://huggingface.co/spaces/successjourney/arginfluence/resolve/main/processing/sentiment.py
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 | |