File size: 4,719 Bytes
b02461f
 
 
8f028d3
3fc2b22
05d11c3
b02461f
e0eb667
 
26777af
8f028d3
 
 
 
 
 
 
 
 
b02461f
e0eb667
26777af
b02461f
05d11c3
233841c
b02461f
 
26777af
 
3fc2b22
 
 
 
 
 
 
 
e624423
420aeff
8f028d3
 
 
 
 
 
 
b02461f
8f028d3
b02461f
8f028d3
b02461f
 
 
233841c
b02461f
26777af
b02461f
8f028d3
233841c
 
26777af
3fc2b22
 
 
 
 
 
 
 
 
26777af
8f028d3
 
 
 
 
 
 
b02461f
 
 
 
 
 
 
 
 
 
 
e0eb667
b02461f
 
 
 
8f028d3
e2113c0
3fc2b22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233841c
8f028d3
 
 
 
e2113c0
233841c
 
b02461f
 
233841c
3fc2b22
 
 
 
233841c
 
 
b02461f
 
 
 
3fc2b22
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
# app.py
# -*- coding: utf-8 -*-

import os, pickle, numpy as np
from typing import Dict, Tuple
from fastapi import FastAPI
from pydantic import BaseModel, Field
from sentence_transformers import SentenceTransformer
from transformers import pipeline

# ---- Performance flags ----
os.environ["TOKENIZERS_PARALLELISM"] = "false"
try:
    import torch
    torch.set_num_threads(1)  # evita thrashing en CPU básica
except Exception:
    pass

# ---- Carga artefactos una vez ----
lw: Dict = pickle.load(open("predictor.pkl", "rb"))
sbert = SentenceTransformer(lw["model_name"])

# centroides normalizados
centroides = {int(k): np.array(v, dtype=np.float32) for k, v in lw["centroides"].items()}
for k, v in centroides.items():
    n = np.linalg.norm(v) + 1e-12
    centroides[k] = (v / n).astype(np.float32)
cids = sorted(centroides.keys())

meta = lw.get("meta", {}) or {}

# (OPCIONAL) umbrales por cluster guardados en el pickle, p.ej. similitud mínima histórica
# formato esperado: { "margenes": { "0": 0.50, "1": 0.58, ... } }
_margenes_raw = lw.get("margenes") or lw.get("margins") or {}
MARGENES = {int(k): float(v) for k, v in _margenes_raw.items()} if isinstance(_margenes_raw, dict) else {}

# Umbral global por defecto (puede sobreescribirse por env o por lw["tau_otros"])
TAU_OTROS = float(os.getenv("TAU_OTROS", str(lw.get("tau_otros", 0.7))))

# Sentimiento (modelo liviano; recorta a 256 tokens)
sentiment = pipeline(
    "text-classification",
    model="UMUTeam/roberta-spanish-sentiment-analysis",
    device=-1
)

EMOTIONS = ["alegría", "tristeza", "ira", "asco", "miedo", "sorpresa", "neutral"]
HYP = "El texto expresa {}."
# Precompute embeddings de emociones con el mismo encoder (rápido)
_emotion_texts = [HYP.format(e) for e in EMOTIONS]
_emotion_embs = sbert.encode(
    _emotion_texts, convert_to_numpy=True, normalize_embeddings=True
).astype(np.float32)

app = FastAPI(title="Predicción de clusters/sentimiento/emoción")

# -------- Helpers --------
def _encode(text: str) -> np.ndarray:
    emb = sbert.encode(text, convert_to_numpy=True, normalize_embeddings=True).astype(np.float32)
    return emb[None, :] if emb.ndim == 1 else emb

def _mejor_cluster_y_similitud(vec: np.ndarray) -> Tuple[int, float]:
    """
    Devuelve (cid_mejor, similitud_cos). Como todo está normalizado, cos = dot.
    """
    # apilamos centroides en una matriz para multiplicar de una
    C = np.vstack([centroides[c] for c in cids]).astype(np.float32)  # shape: (K, D)
    sims = (C @ vec.reshape(-1, 1)).squeeze(-1)                      # shape: (K,)
    i = int(np.argmax(sims))
    return cids[i], float(sims[i])

def _truncate_for_classifier(text: str, max_chars: int = 1000) -> str:
    return text if len(text) <= max_chars else text[:max_chars]

def _fast_emotion(emb: np.ndarray) -> str:
    sims = (_emotion_embs @ emb.reshape(-1, 1)).squeeze(-1)
    return EMOTIONS[int(np.argmax(sims))]

# -------- Schema de entrada --------
class Entrada(BaseModel):
    # acepta "asunto" o "subject"
    asunto: str = Field(default="", alias="subject")
    # acepta "cuerpo" o "body"
    cuerpo: str = Field(default="", alias="body")

    class Config:
        populate_by_name = True  # permite usar los nombres sin alias también

# -------- Endpoint --------
@app.post("/predict")
def predict(item: Entrada):
    subject = (item.asunto or "").strip()
    body = (item.cuerpo or "").strip()
    text = f"{subject}{body}".strip(" —")

    emb = _encode(text)[0]
    cid_mejor, sim = _mejor_cluster_y_similitud(emb)

    # umbral: usa específico del cluster si existe; si no, global
    tau = float(MARGENES.get(cid_mejor, TAU_OTROS))

    # si la similitud no supera el umbral -> "otros"
    es_otros = sim < tau
    if es_otros:
        cid = -1
        nombre = "otros"
        desc = "No se parece lo suficiente a ningún cluster conocido."
    else:
        cid = cid_mejor
        m = meta.get(str(cid), meta.get(cid, {})) or {}
        nombre = m.get("nombre")
        desc = m.get("descripcion")

    # RÁPIDO: sentimiento con truncado
    s = sentiment(_truncate_for_classifier(text), truncation=True, max_length=256)[0]["label"]

    # RÁPIDO: emoción por similitud con SBERT (sin segundo Transformer)
    e = _fast_emotion(emb)

    return {
        "asunto": subject,
        "cuerpo": body,
        "cluster": cid,
        "cluster_nombre": nombre,
        "cluster_desc": desc,
        "similitud_cluster": round(sim, 4),
        "umbral_usado": round(tau, 4),
        "sentimiento": s,
        "emocion": e
    }

# -------- Entrypoint opcional --------
if __name__ == "__main__":
    import uvicorn
    uvicorn.run("app:app", host="0.0.0.0", port=int(os.getenv("PORT", "8000")))