Spaces:
Running
Running
Download processing/influence_scorer.py from successjourney/arginfluence: direct link, hf CLI and curl.
- Browser
- Download file 26.7 kB
-
https://huggingface.co/spaces/successjourney/arginfluence/resolve/main/processing/influence_scorer.py
- Command line
-
hf download hf://spaces/successjourney/arginfluence/processing/influence_scorer.py
-
curl -L -o influence_scorer.py https://huggingface.co/spaces/successjourney/arginfluence/resolve/main/processing/influence_scorer.py
26.7 kB
| """ | |
| influence_scorer.py — Cálculo de influencia mediante PageRank sobre grafo de menciones. | |
| Construye un grafo dirigido donde: | |
| - Nodos: actores (65 figuras públicas) | |
| - Aristas: menciones de un actor en posts de otro actor | |
| - Peso: cantidad de veces que actor A menciona a actor B | |
| Calcula PageRank con NetworkX y guarda resultados en Supabase (tabla influence_scores). | |
| No usa NLP — solo análisis de grafos sobre datos ya recolectados. | |
| """ | |
| import json | |
| import logging | |
| import math | |
| import re | |
| from datetime import datetime, timedelta, timezone | |
| import networkx as nx | |
| import sys | |
| from pathlib import Path | |
| sys.path.insert(0, str(Path(__file__).parent.parent)) | |
| from utils.db import paginar as _paginar | |
| from config import ( | |
| SEED_ACTORS_PATH, | |
| IDEOLOGY_LABELS_PATH, | |
| GRAPH_PAGERANK_ALPHA, | |
| GRAPH_MIN_EDGE_WEIGHT, | |
| SUPABASE_URL, | |
| SUPABASE_KEY, | |
| SUPABASE_SERVICE_KEY, | |
| LOG_FORMAT, | |
| LOG_DATE_FORMAT, | |
| LOG_LEVEL, | |
| ) | |
| logging.basicConfig(format=LOG_FORMAT, datefmt=LOG_DATE_FORMAT) | |
| logger = logging.getLogger("influence_scorer") | |
| logger.setLevel(getattr(logging, LOG_LEVEL, logging.INFO)) | |
| # Flag para crear la tabla influence_scores solo una vez por proceso | |
| _TABLA_INFLUENCE_VERIFICADA: bool = False | |
| # ── Helpers ─────────────────────────────────────────────────────────────────── | |
| def _cargar_actores() -> list[dict]: | |
| """Carga y devuelve la lista completa de actores desde seed_actors.json.""" | |
| with open(SEED_ACTORS_PATH, encoding="utf-8") as f: | |
| return json.load(f)["actors"] | |
| def _cargar_etiquetas() -> dict: | |
| """Carga ideology_labels.json y devuelve el dict de etiquetas por actor_id.""" | |
| with open(IDEOLOGY_LABELS_PATH, encoding="utf-8") as f: | |
| return json.load(f)["labels"] | |
| def _handles_a_ids(actores: list[dict]) -> dict[str, str]: | |
| """ | |
| Construye un mapa de handle Twitter (sin @, lowercase) → actor_id. | |
| Permite detectar menciones en texto libre. | |
| """ | |
| return { | |
| a["twitter"].lstrip("@").lower(): a["id"] | |
| for a in actores | |
| if a.get("twitter") | |
| } | |
| def _construir_mapa_nombres(actores: list[dict]) -> dict[str, str]: | |
| """ | |
| Construye un mapa apellido/apodo → actor_id para detectar menciones | |
| en texto libre. Excluye apellidos ambiguos (múltiples actores). | |
| """ | |
| from collections import defaultdict | |
| candidatos: dict[str, list[str]] = defaultdict(list) | |
| particulas = {"de", "del", "la", "el", "los", "las", "y", "e"} | |
| for actor in actores: | |
| palabras = actor["nombre"].split() | |
| for palabra in reversed(palabras): | |
| if palabra.lower() not in particulas and len(palabra) > 3: | |
| candidatos[palabra.lower()].append(actor["id"]) | |
| break | |
| for apodo in re.findall(r"'([^']+)'", actor["nombre"]): | |
| if len(apodo) > 2: | |
| candidatos[apodo.lower()].append(actor["id"]) | |
| mapa = {k: v[0] for k, v in candidatos.items() if len(v) == 1} | |
| mapa["kirchner"] = "kirchner_cristina" | |
| mapa["cfk"] = "kirchner_cristina" | |
| return mapa | |
| def _extraer_menciones( | |
| texto: str, | |
| handle_map: dict[str, str], | |
| nombre_map: dict[str, str] | None = None, | |
| ) -> list[str]: | |
| """ | |
| Extrae actor_ids mencionados en un texto via @handles y apellidos. | |
| Combina detección por @handle (tweets) y por apellido/apodo | |
| (artículos periodísticos que no usan @). | |
| """ | |
| texto_lower = texto.lower() | |
| encontrados: set[str] = set() | |
| for handle in re.findall(r"@(\w+)", texto_lower): | |
| if handle in handle_map: | |
| encontrados.add(handle_map[handle]) | |
| if nombre_map: | |
| for keyword, actor_id in nombre_map.items(): | |
| if re.search(r"\b" + re.escape(keyword) + r"\b", texto_lower): | |
| encontrados.add(actor_id) | |
| return list(encontrados) | |
| def _leer_posts_supabase(cluster: str | None = None, dias: int = 90) -> list[dict]: | |
| """ | |
| Lee posts de Supabase, opcionalmente filtrados por espectro ideológico. | |
| Parámetros: | |
| cluster: código de espectro ("ED", "CD", "C", "CI", "EI") o None para todos. | |
| dias: ventana temporal hacia atrás en días (default: 90). | |
| Devuelve lista de dicts con actor_id, titulo, resumen. | |
| """ | |
| if not SUPABASE_URL or not SUPABASE_KEY: | |
| return [] | |
| from supabase import create_client | |
| client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY) | |
| corte = (datetime.now(timezone.utc) - timedelta(days=dias)).isoformat() | |
| if cluster: | |
| etiquetas = _cargar_etiquetas() | |
| ids_en_cluster = [ | |
| aid for aid, info in etiquetas.items() | |
| if info.get("espectro") == cluster | |
| ] | |
| if not ids_en_cluster: | |
| logger.warning(f"[influence_scorer] cluster '{cluster}' sin actores conocidos") | |
| return [] | |
| return _paginar( | |
| lambda: client.table("posts") | |
| .select("actor_id, titulo, resumen") | |
| .in_("actor_id", ids_en_cluster) | |
| .gte("publicado", corte) | |
| ) | |
| else: | |
| return _paginar( | |
| lambda: client.table("posts") | |
| .select("actor_id, titulo, resumen") | |
| .gte("publicado", corte) | |
| ) | |
| def _construir_grafo(posts: list[dict], actores: list[dict]) -> nx.DiGraph: | |
| """ | |
| Construye un grafo dirigido de influencia a partir de los posts recolectados. | |
| Arista A → B significa que el actor A mencionó al actor B en sus posts. | |
| El peso de la arista es el número de menciones. | |
| """ | |
| G = nx.DiGraph() | |
| etiquetas = _cargar_etiquetas() | |
| # Agregar todos los actores como nodos | |
| for actor in actores: | |
| aid = actor["id"] | |
| if not etiquetas.get(aid): | |
| logger.warning(f"[influence_scorer] actor '{aid}' sin etiqueta en ideology_labels.json — espectro='C' por defecto") | |
| G.add_node(aid, | |
| nombre=actor["nombre"], | |
| categoria=actor["categoria"], | |
| espectro=etiquetas.get(aid, {}).get("espectro", "C")) | |
| handle_map = _handles_a_ids(actores) | |
| nombre_map = _construir_mapa_nombres(actores) | |
| # Construir aristas a partir de menciones en posts | |
| for post in posts: | |
| origen = post.get("actor_id", "") | |
| if not origen or origen not in G: | |
| continue | |
| texto = f"{post.get('titulo', '')} {post.get('resumen', '')}" | |
| mencionados = _extraer_menciones(texto, handle_map, nombre_map) | |
| for destino in mencionados: | |
| if destino == origen or destino not in G: | |
| continue | |
| if G.has_edge(origen, destino): | |
| G[origen][destino]["weight"] += 1 | |
| else: | |
| G.add_edge(origen, destino, weight=1) | |
| # Filtrar aristas con peso mínimo | |
| aristas_debiles = [ | |
| (u, v) for u, v, d in G.edges(data=True) | |
| if d.get("weight", 0) < GRAPH_MIN_EDGE_WEIGHT | |
| ] | |
| G.remove_edges_from(aristas_debiles) | |
| logger.info( | |
| f"[influence_scorer] grafo: {G.number_of_nodes()} nodos, " | |
| f"{G.number_of_edges()} aristas" | |
| ) | |
| return G | |
| def _guardar_en_supabase(scores: dict[str, float], cluster: str | None) -> None: | |
| """ | |
| Guarda los scores de PageRank en la tabla influence_scores de Supabase. | |
| Crea la tabla si no existe via Management API. Hace upsert de los resultados. | |
| """ | |
| if not SUPABASE_URL or not SUPABASE_KEY: | |
| logger.warning("[influence_scorer] Supabase no configurado — scores no guardados") | |
| return | |
| # Crear tabla si no existe | |
| _crear_tabla_influence_scores() | |
| from supabase import create_client | |
| client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY) | |
| ahora = datetime.now(timezone.utc).isoformat() | |
| rows = [ | |
| { | |
| "actor_id": actor_id, | |
| "pagerank_score": round(score, 8), | |
| "cluster": cluster or "global", | |
| "calculado_en": ahora, | |
| } | |
| for actor_id, score in scores.items() | |
| ] | |
| try: | |
| client.table("influence_scores").upsert(rows).execute() | |
| logger.info(f"[influence_scorer] {len(rows)} scores guardados en Supabase") | |
| except Exception as e: | |
| logger.error(f"[influence_scorer] error guardando en Supabase: {e}") | |
| def _crear_tabla_influence_scores() -> None: | |
| """Crea la tabla influence_scores en Supabase si no existe (solo una vez por proceso).""" | |
| global _TABLA_INFLUENCE_VERIFICADA | |
| if _TABLA_INFLUENCE_VERIFICADA: | |
| return | |
| _TABLA_INFLUENCE_VERIFICADA = True | |
| import os | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| token = os.getenv("SUPABASE_MANAGEMENT_TOKEN", "") | |
| if not token: | |
| logger.warning("[influence_scorer] sin SUPABASE_MANAGEMENT_TOKEN — tabla no creada") | |
| return | |
| import requests as req | |
| ref = SUPABASE_URL.replace("https://", "").replace(".supabase.co", "") | |
| sql = """ | |
| CREATE TABLE IF NOT EXISTS influence_scores ( | |
| actor_id TEXT NOT NULL, | |
| cluster TEXT NOT NULL DEFAULT 'global', | |
| pagerank_score FLOAT NOT NULL, | |
| calculado_en TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| PRIMARY KEY (actor_id, cluster) | |
| ); | |
| ALTER TABLE influence_scores ENABLE ROW LEVEL SECURITY; | |
| DO $$ BEGIN | |
| IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename='influence_scores' AND policyname='anon_select_influence') THEN | |
| CREATE POLICY anon_select_influence ON influence_scores FOR SELECT TO anon USING (true); | |
| END IF; | |
| IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename='influence_scores' AND policyname='anon_insert_influence') THEN | |
| CREATE POLICY anon_insert_influence ON influence_scores FOR INSERT TO anon WITH CHECK (true); | |
| END IF; | |
| IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename='influence_scores' AND policyname='anon_update_influence') THEN | |
| CREATE POLICY anon_update_influence ON influence_scores FOR UPDATE TO anon USING (true); | |
| END IF; | |
| END $$; | |
| """ | |
| try: | |
| resp = req.post( | |
| f"https://api.supabase.com/v1/projects/{ref}/database/query", | |
| headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, | |
| json={"query": sql}, | |
| timeout=30, | |
| ) | |
| if resp.status_code in (200, 201): | |
| logger.info("[influence_scorer] tabla influence_scores lista") | |
| else: | |
| logger.warning(f"[influence_scorer] respuesta al crear tabla: {resp.status_code}") | |
| except Exception as e: | |
| logger.error(f"[influence_scorer] error creando tabla: {e}") | |
| # ── Score de influencia social compuesto ───────────────────────────────────── | |
| def calculate_social_influence() -> dict[str, dict]: | |
| """ | |
| Calcula el score de influencia social compuesto para los 65 actores. | |
| Fórmula: | |
| Para actores (politico/periodista/influencer): | |
| engagement_bruto = replies*4 + likes*3 + retweets*2 + views*1 | |
| (suma de todos sus posts en los últimos 90 días) | |
| Para medios (categoria == 'medio'): | |
| engagement_bruto = COUNT(menciones recibidas en posts) * 1000 | |
| (cuántos de los 65 actores citan sus artículos) | |
| Normalización: | |
| engagement_norm = engagement_bruto / max(engagement_bruto) | |
| pagerank_norm = pagerank_score / max(pagerank_score) | |
| Score final: | |
| influencia_social = engagement_norm*0.6 + pagerank_norm*0.4 | |
| Tamaño de nodo (escala logarítmica): | |
| node_size = 10 + log10(influencia_social * 1000 + 1) * 15 | |
| → mínimo 10, máximo ~55 | |
| Guarda resultados en tabla social_influence_scores de Supabase. | |
| Devuelve: | |
| Dict {actor_id: {influencia_social, engagement_bruto, engagement_norm, | |
| pagerank_norm, node_size, pagerank_score}} | |
| """ | |
| if not SUPABASE_URL or not SUPABASE_KEY: | |
| return {} | |
| from supabase import create_client | |
| client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY) | |
| actores = _cargar_actores() | |
| actores_map = {a["id"]: a for a in actores} | |
| etiquetas = _cargar_etiquetas() | |
| # Leer PageRank guardado | |
| pr_resp = client.table("influence_scores").select("actor_id, pagerank_score").eq("cluster", "global").execute() | |
| pageranks = {r["actor_id"]: r["pagerank_score"] for r in (pr_resp.data or [])} | |
| # Leer posts últimos 90 días con métricas de engagement (paginado). | |
| # Solo tweets propios (NULL) y replies enviados por el actor. | |
| # Excluidos: retweets y reply_recibido — sus contadores pertenecen a terceros. | |
| corte = (datetime.now(timezone.utc) - timedelta(days=90)).isoformat() | |
| posts = _paginar( | |
| lambda: client.table("posts").select( | |
| "actor_id, reply_count, like_count, retweet_count, view_count" | |
| ).gte("publicado", corte).or_("tipo_interaccion.is.null,tipo_interaccion.eq.reply") | |
| ) | |
| # Calcular engagement_bruto para todos (medios y actores usan la misma fórmula). | |
| # Para medios: el engagement viene de sus tweets (reply*4 + like*3 + RT*2 + view*1) | |
| # igual que para políticos/periodistas/influencers. | |
| engagement_actores: dict[str, int] = {a["id"]: 0 for a in actores} | |
| for p in posts: | |
| aid = p.get("actor_id", "") | |
| if aid not in engagement_actores: | |
| continue | |
| engagement_actores[aid] += ( | |
| p.get("reply_count", 0) * 4 | |
| + p.get("like_count", 0) * 3 | |
| + p.get("retweet_count", 0) * 2 | |
| + p.get("view_count", 0) * 1 | |
| ) | |
| # Normalizar engagement y pagerank | |
| max_eng = max(engagement_actores.values(), default=1) or 1 | |
| max_pr = max(pageranks.values(), default=1) or 1 | |
| resultados: dict[str, dict] = {} | |
| ahora = datetime.now(timezone.utc).isoformat() | |
| for actor in actores: | |
| aid = actor["id"] | |
| eng_bruto = engagement_actores.get(aid, 0) | |
| pr_score = pageranks.get(aid, 0.0) | |
| eng_norm = eng_bruto / max_eng | |
| pr_norm = pr_score / max_pr | |
| influencia = eng_norm * 0.6 + pr_norm * 0.4 | |
| node_size = 10 + math.log10(influencia * 1000 + 1) * 15 | |
| resultados[aid] = { | |
| "influencia_social": round(influencia, 6), | |
| "engagement_bruto": eng_bruto, | |
| "engagement_norm": round(eng_norm, 6), | |
| "pagerank_norm": round(pr_norm, 6), | |
| "pagerank_score": pr_score, | |
| "node_size": round(node_size, 2), | |
| } | |
| # Guardar en Supabase | |
| try: | |
| rows = [ | |
| { | |
| "actor_id": aid, | |
| "influencia_social": v["influencia_social"], | |
| "engagement_bruto": v["engagement_bruto"], | |
| "engagement_norm": v["engagement_norm"], | |
| "pagerank_norm": v["pagerank_norm"], | |
| "node_size": v["node_size"], | |
| "calculado_en": ahora, | |
| } | |
| for aid, v in resultados.items() | |
| ] | |
| client.table("social_influence_scores").upsert(rows, on_conflict="actor_id").execute() | |
| logger.info(f"[influence_scorer] social_influence calculado para {len(rows)} actores") | |
| except Exception as e: | |
| logger.error(f"[influence_scorer] error guardando social_influence: {e}") | |
| return resultados | |
| def get_social_influence_ranking(top_n: int = 10, cluster: str | None = None) -> list[dict]: | |
| """ | |
| Retorna el top N de actores por influencia_social compuesta. | |
| Parámetros: | |
| top_n: cuántos actores incluir (default: 10) | |
| cluster: filtrar por espectro ideológico antes de rankear | |
| Cada item devuelve: | |
| actor_id, nombre, espectro, categoria, influencia_social, | |
| engagement_bruto, pagerank_score, node_size | |
| """ | |
| scores = calculate_social_influence() | |
| if not scores: | |
| return [] | |
| actores = {a["id"]: a for a in _cargar_actores()} | |
| etiquetas = _cargar_etiquetas() | |
| items = [ | |
| { | |
| "actor_id": aid, | |
| "nombre": actores.get(aid, {}).get("nombre", aid), | |
| "espectro": etiquetas.get(aid, {}).get("espectro", "?"), | |
| "categoria": actores.get(aid, {}).get("categoria", "?"), | |
| "influencia_social": v["influencia_social"], | |
| "engagement_bruto": v["engagement_bruto"], | |
| "pagerank_score": v["pagerank_score"], | |
| "node_size": v["node_size"], | |
| } | |
| for aid, v in scores.items() | |
| ] | |
| if cluster: | |
| items = [i for i in items if i["espectro"] == cluster] | |
| items.sort(key=lambda x: x["influencia_social"], reverse=True) | |
| return items[:top_n] | |
| # ── Funciones públicas ──────────────────────────────────────────────────────── | |
| def calculate_pagerank(cluster: str | None = None) -> dict[str, float]: | |
| """ | |
| Calcula el PageRank de todos los actores a partir de sus menciones cruzadas. | |
| Parámetros: | |
| cluster: filtrar por espectro ideológico ("ED", "CD", "C", "CI", "EI") | |
| o None para calcular sobre todos los actores. | |
| Devuelve: | |
| Dict {actor_id: pagerank_score}. Dict vacío si no hay posts en Supabase. | |
| """ | |
| actores = _cargar_actores() | |
| try: | |
| posts = _leer_posts_supabase(cluster) | |
| except Exception as e: | |
| logger.error(f"[influence_scorer] error leyendo Supabase: {e}") | |
| return {} | |
| if not posts: | |
| logger.info("[influence_scorer] sin posts en Supabase — PageRank no calculado") | |
| return {} | |
| G = _construir_grafo(posts, actores) | |
| # Si el grafo no tiene aristas, PageRank es uniforme | |
| if G.number_of_edges() == 0: | |
| logger.info("[influence_scorer] grafo sin aristas — distribución uniforme") | |
| n = G.number_of_nodes() | |
| return {node: 1.0 / n for node in G.nodes()} if n > 0 else {} | |
| scores = nx.pagerank(G, alpha=GRAPH_PAGERANK_ALPHA, weight="weight") | |
| logger.info( | |
| f"[influence_scorer] PageRank calculado — " | |
| f"cluster={cluster or 'global'}, max_score={max(scores.values()):.6f}" | |
| ) | |
| _guardar_en_supabase(scores, cluster) | |
| return scores | |
| def get_actors_by_tag(tag: str) -> list[dict]: | |
| """ | |
| Retorna todos los actores que tienen el soft_tag dado, con su espectro y PageRank. | |
| Devuelve lista de dicts: {actor_id, nombre, espectro, soft_tags, pagerank_score} | |
| ordenada por pagerank_score descendente. | |
| """ | |
| etiquetas = _cargar_etiquetas() | |
| actores = {a["id"]: a for a in _cargar_actores()} | |
| # Leer scores actuales de Supabase | |
| scores: dict[str, float] = {} | |
| if SUPABASE_URL and SUPABASE_KEY: | |
| try: | |
| from supabase import create_client | |
| client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY) | |
| resp = client.table("influence_scores").select("actor_id, pagerank_score").eq("cluster", "global").execute() | |
| scores = {r["actor_id"]: r["pagerank_score"] for r in (resp.data or [])} | |
| except Exception as e: | |
| logger.warning(f"[influence_scorer] no se pudieron leer scores: {e}") | |
| resultado = [] | |
| for actor_id, info in etiquetas.items(): | |
| if tag in info.get("soft_tags", []): | |
| actor = actores.get(actor_id, {}) | |
| resultado.append({ | |
| "actor_id": actor_id, | |
| "nombre": actor.get("nombre", actor_id), | |
| "espectro": info.get("espectro", "?"), | |
| "soft_tags": info.get("soft_tags", []), | |
| "pagerank_score": scores.get(actor_id, 0.0), | |
| }) | |
| return sorted(resultado, key=lambda x: x["pagerank_score"], reverse=True) | |
| def get_tag_clusters() -> dict: | |
| """ | |
| Para cada soft_tag retorna: cuántos actores lo tienen, qué espectros están | |
| representados, y el actor más influyente (mayor PageRank). | |
| Replica la vista 'Channel Landscape' de Recfluence adaptada a Argentina. | |
| Devuelve dict: {tag: {count, espectros, top_actor}} | |
| """ | |
| etiquetas = _cargar_etiquetas() | |
| actores = {a["id"]: a for a in _cargar_actores()} | |
| scores: dict[str, float] = {} | |
| if SUPABASE_URL and SUPABASE_KEY: | |
| try: | |
| from supabase import create_client | |
| client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY) | |
| resp = client.table("influence_scores").select("actor_id, pagerank_score").eq("cluster", "global").execute() | |
| scores = {r["actor_id"]: r["pagerank_score"] for r in (resp.data or [])} | |
| except Exception as e: | |
| logger.warning(f"[influence_scorer] no se pudieron leer scores: {e}") | |
| from collections import defaultdict | |
| clusters: dict[str, dict] = defaultdict(lambda: {"count": 0, "espectros": set(), "actores": []}) | |
| for actor_id, info in etiquetas.items(): | |
| actor = actores.get(actor_id, {}) | |
| score = scores.get(actor_id, 0.0) | |
| for tag in info.get("soft_tags", []): | |
| clusters[tag]["count"] += 1 | |
| clusters[tag]["espectros"].add(info.get("espectro", "?")) | |
| clusters[tag]["actores"].append({"actor_id": actor_id, "nombre": actor.get("nombre", actor_id), "score": score}) | |
| resultado = {} | |
| for tag, data in sorted(clusters.items()): | |
| top = max(data["actores"], key=lambda x: x["score"], default=None) | |
| resultado[tag] = { | |
| "count": data["count"], | |
| "espectros": sorted(data["espectros"]), | |
| "top_actor": top["nombre"] if top else None, | |
| "top_score": round(top["score"], 6) if top else 0.0, | |
| } | |
| return resultado | |
| def get_influence_ranking_from_db(top_n: int = 10, cluster: str | None = None) -> list[dict]: | |
| """ | |
| Lee el ranking de PageRank directamente desde Supabase sin recalcular. | |
| Úsalo en el dashboard — el pipeline ya calculó y guardó los scores. | |
| Solo llama a calculate_pagerank() si no hay datos en Supabase. | |
| """ | |
| if not SUPABASE_URL or not SUPABASE_KEY: | |
| return [] | |
| try: | |
| from supabase import create_client | |
| client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY) | |
| query = client.table("influence_scores").select("actor_id, pagerank_score").eq("cluster", "global") | |
| resp = query.execute() | |
| scores = {r["actor_id"]: r["pagerank_score"] for r in (resp.data or [])} | |
| except Exception as e: | |
| logger.warning(f"[influence_scorer] error leyendo ranking de DB: {e}") | |
| return [] | |
| if not scores: | |
| logger.info("[influence_scorer] sin scores en DB, calculando on-demand") | |
| return get_influence_ranking(top_n=top_n, cluster=cluster) | |
| actores = {a["id"]: a for a in _cargar_actores()} | |
| etiquetas = _cargar_etiquetas() | |
| items = [ | |
| { | |
| "posicion": 0, | |
| "actor_id": aid, | |
| "nombre": actores.get(aid, {}).get("nombre", aid), | |
| "espectro": etiquetas.get(aid, {}).get("espectro", "?"), | |
| "score": round(score, 6), | |
| } | |
| for aid, score in scores.items() | |
| ] | |
| if cluster: | |
| items = [i for i in items if i["espectro"] == cluster] | |
| items.sort(key=lambda x: x["score"], reverse=True) | |
| for i, item in enumerate(items[:top_n]): | |
| item["posicion"] = i + 1 | |
| return items[:top_n] | |
| def get_social_influence_ranking_from_db(top_n: int = 10, cluster: str | None = None) -> list[dict]: | |
| """ | |
| Lee el ranking de influencia social directamente desde Supabase sin recalcular. | |
| Úsalo en el dashboard — el pipeline ya calculó y guardó los scores. | |
| Solo llama a calculate_social_influence() si no hay datos en Supabase. | |
| """ | |
| if not SUPABASE_URL or not SUPABASE_KEY: | |
| return [] | |
| try: | |
| from supabase import create_client | |
| client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY) | |
| resp = client.table("social_influence_scores").select( | |
| "actor_id, influencia_social, engagement_bruto, node_size" | |
| ).execute() | |
| rows = resp.data or [] | |
| except Exception as e: | |
| logger.warning(f"[influence_scorer] error leyendo social ranking de DB: {e}") | |
| return [] | |
| if not rows: | |
| logger.info("[influence_scorer] sin social scores en DB, calculando on-demand") | |
| return get_social_influence_ranking(top_n=top_n, cluster=cluster) | |
| actores = {a["id"]: a for a in _cargar_actores()} | |
| etiquetas = _cargar_etiquetas() | |
| pageranks = {} | |
| try: | |
| from supabase import create_client | |
| client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY) | |
| pr_resp = client.table("influence_scores").select("actor_id, pagerank_score").eq("cluster", "global").execute() | |
| pageranks = {r["actor_id"]: r["pagerank_score"] for r in (pr_resp.data or [])} | |
| except Exception: | |
| pass | |
| items = [] | |
| for r in rows: | |
| aid = r["actor_id"] | |
| espectro = etiquetas.get(aid, {}).get("espectro", "?") | |
| if cluster and espectro != cluster: | |
| continue | |
| items.append({ | |
| "posicion": 0, | |
| "actor_id": aid, | |
| "nombre": actores.get(aid, {}).get("nombre", aid), | |
| "espectro": espectro, | |
| "categoria": actores.get(aid, {}).get("categoria", "?"), | |
| "influencia_social": r.get("influencia_social", 0.0), | |
| "engagement_bruto": r.get("engagement_bruto", 0), | |
| "pagerank_score": pageranks.get(aid, 0.0), | |
| "node_size": r.get("node_size", 10.0), | |
| }) | |
| items.sort(key=lambda x: x["influencia_social"], reverse=True) | |
| for i, item in enumerate(items[:top_n]): | |
| item["posicion"] = i + 1 | |
| return items[:top_n] | |
| def get_influence_ranking(top_n: int = 10, cluster: str | None = None) -> list[dict]: | |
| """ | |
| Devuelve el ranking de actores más influyentes según PageRank. | |
| Parámetros: | |
| top_n: cuántos actores incluir en el ranking (default: 10) | |
| cluster: filtrar por espectro ideológico o None para todos | |
| Devuelve: | |
| Lista de dicts con: posicion, actor_id, nombre, espectro, score. | |
| Lista vacía si no hay datos. | |
| """ | |
| scores = calculate_pagerank(cluster) | |
| if not scores: | |
| return [] | |
| actores = {a["id"]: a for a in _cargar_actores()} | |
| etiquetas = _cargar_etiquetas() | |
| ranking_ordenado = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_n] | |
| return [ | |
| { | |
| "posicion": i + 1, | |
| "actor_id": actor_id, | |
| "nombre": actores.get(actor_id, {}).get("nombre", actor_id), | |
| "espectro": etiquetas.get(actor_id, {}).get("espectro", "?"), | |
| "score": round(score, 6), | |
| } | |
| for i, (actor_id, score) in enumerate(ranking_ordenado) | |
| ] | |