File size: 18,216 Bytes
8350716
 
 
8d74079
8350716
de5c5af
 
00161b9
 
 
aa4e1f3
 
 
 
 
8d74079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
00161b9
 
 
de5c5af
 
 
 
 
 
 
 
 
 
 
 
 
00161b9
 
 
8350716
 
 
00161b9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8350716
 
 
 
 
 
 
 
 
 
51239d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8350716
 
00161b9
 
 
8350716
00161b9
8350716
00161b9
8350716
 
 
00161b9
8350716
 
 
 
 
 
 
 
 
 
00161b9
 
8350716
00161b9
 
 
8350716
00161b9
 
 
 
8350716
00161b9
 
8350716
00161b9
 
 
8350716
 
 
 
 
 
 
51239d9
 
 
 
 
 
 
 
 
 
7b8dd49
51239d9
 
 
 
 
 
 
 
 
 
 
8350716
51239d9
 
 
8350716
51239d9
8350716
 
 
 
 
 
51239d9
 
 
8350716
 
51239d9
 
 
 
8350716
51239d9
 
 
 
8350716
 
00161b9
 
 
 
 
 
 
 
 
 
 
7b8dd49
 
 
 
 
 
 
7d811b0
 
 
51239d9
 
 
 
7d811b0
7b8dd49
7d811b0
 
 
51239d9
7d811b0
 
 
51239d9
7d811b0
7b8dd49
51239d9
 
 
 
 
8350716
00161b9
 
 
 
 
 
8350716
 
00161b9
 
 
 
8350716
00161b9
8350716
 
 
00161b9
 
 
 
 
51239d9
 
de5c5af
51239d9
00161b9
 
 
 
 
 
51239d9
7b8dd49
00161b9
7b8dd49
 
 
8350716
00161b9
 
51239d9
00161b9
51239d9
de5c5af
 
 
 
8350716
51239d9
de5c5af
00161b9
7b8dd49
de5c5af
aeb5c64
de5c5af
 
 
 
00161b9
7b8dd49
de5c5af
aeb5c64
 
 
b830350
 
 
 
 
 
 
51239d9
 
00161b9
de5c5af
51239d9
de5c5af
8350716
51239d9
de5c5af
 
00161b9
 
 
8d74079
22a5b8d
8d74079
 
 
 
a02562b
22a5b8d
a02562b
22a5b8d
 
a02562b
 
22a5b8d
 
 
a02562b
 
 
22a5b8d
8d74079
 
a02562b
8d74079
a02562b
 
8d74079
 
b830350
a02562b
00161b9
 
 
8d74079
02da6f2
 
8d74079
 
 
 
00161b9
02da6f2
8350716
 
8d74079
00161b9
8d74079
 
00161b9
 
 
 
 
 
 
 
8d74079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
00161b9
8d74079
 
aeb5c64
8d74079
 
 
7b8dd49
8d74079
 
 
 
 
 
 
 
00161b9
 
 
7b8dd49
aa4e1f3
7b8dd49
8d74079
 
aa4e1f3
8d74079
 
 
 
7b8dd49
aa4e1f3
 
 
 
 
 
 
 
 
 
7b8dd49
aa4e1f3
 
 
 
02a588e
7b8dd49
8d74079
00161b9
02da6f2
00161b9
 
8d74079
 
8350716
00161b9
8350716
00161b9
8350716
00161b9
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
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
import json
import re
from typing import List, Dict, Tuple
import csv, os, time
import gradio as gr
import matplotlib.pyplot as plt

# ==========================
# Config & estilos
# ==========================
DEFAULT_COLS = [
    "Código", "Indicador", "Score (0–4)",
    "Entailment medio", "Evidencias (hipótesis)", "Descripción"
]

CUSTOM_CSS = """
#app {max-width: 1200px; margin: 0 auto;}
.badge {
  display:inline-block; padding:10px 14px; border-radius:12px; font-weight:700;
  background:linear-gradient(135deg,#1f6feb,#5ac8fa); color:white; box-shadow:0 6px 20px rgba(0,0,0,.2);
}
.card {
  background: rgba(255,255,255,.03);
  border: 1px solid rgba(255,255,255,.08);
  border-radius: 14px; padding: 14px;
  box-shadow: 0 8px 24px rgba(0,0,0,.18);
}
.small {font-size: 12px; opacity: .9;}
"""

# ==========================
# Metadatos IPMA ICB4 4.4.5.x
# ==========================
INDICATOR_META = {
    "4.4.5.1": ("Iniciativa y ayuda proactiva",
                "Inicia acciones sin que se lo pidan; ofrece ayuda, anticipa y equilibra riesgos."),
    "4.4.5.2": ("Ownership y compromiso",
                "Asume responsabilidad; impulsa el proyecto; define/monitorea indicadores y mejora procesos."),
    "4.4.5.3": ("Dirección, coaching y mentoring",
                "Da dirección; coach/mentor al equipo; alinea visión, valores y objetivos."),
    "4.4.5.4": ("Poder e influencia",
                "Usa influencia adecuada; elige bien el canal; es percibido como líder por stakeholders."),
    "4.4.5.5": ("Decisiones",
                "Toma decisiones bajo incertidumbre; explica razones; revisa con nueva evidencia; comunica con claridad.")
}

# ==========================
# Modelos (CPU Basic friendly)
# ==========================
_llm = None
_llm_tok = None
_gen = None
_nli_cache: Dict[str, object] = {}  # cache de pipelines NLI por model_id

LLM_ID = "Qwen/Qwen2.5-0.5B-Instruct"  # LLM pequeño multilingüe para extraer STAR

# Selector de NLI con configuración asociada
MODEL_CHOICES = {
    "Velocidad (MiniLM)": {
        "id": "MoritzLaurer/multilingual-MiniLMv2-L12-mnli-xnli",
        "calibrate": True,
        "thresholds": (0.70, 0.50, 0.30, 0.15)  # 4,3,2,1
    },
    "Precisión (DeBERTa)": {
        "id": "MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7",
        "calibrate": False,
        "thresholds": (0.80, 0.60, 0.40, 0.20)
    }
}
DEFAULT_MODEL_KEY = "Velocidad (MiniLM)"  # por defecto en Spaces gratis

STAR_PROMPT = """Eres evaluador ICB4. Toma el texto del candidato y devuélvelo en formato STAR como JSON válido con claves:
"situation" (<=3 frases), "task" (<=2 frases), "action" (lista de viñetas, verbos de acción), "result" (lista de viñetas, resultados/indicadores/aprendizajes).
Siempre responde SOLO con JSON válido y conciso en español, sin comentarios adicionales.

TEXTO:
{texto}
"""

HYP: Dict[str, List[str]] = {
    "4.4.5.1": [
        "Tomó la iniciativa sin que se lo pidieran.",
        "Ofreció ayuda o asesoría no solicitada.",
        "Pensó con orientación al futuro.",
        "Equilibró iniciativa y riesgo."
    ],
    "4.4.5.2": [
        "Mostró compromiso personal con los objetivos.",
        "Promovió el proyecto y generó entusiasmo.",
        "Definió o monitoreó indicadores de desempeño.",
        "Buscó mejoras en procesos."
    ],
    "4.4.5.3": [
        "Proporcionó dirección clara al equipo.",
        "Realizó coaching o mentoring para mejorar capacidades.",
        "Estableció y comunicó visión y valores.",
        "Alineó objetivos individuales con los comunes."
    ],
    "4.4.5.4": [
        "Usó apropiadamente poder e influencia.",
        "Seleccionó el canal de comunicación adecuado para influir.",
        "Fue percibido como líder por los stakeholders."
    ],
    "4.4.5.5": [
        "Tomó decisiones bajo incertidumbre considerando pros y contras.",
        "Explicó el razonamiento de las decisiones.",
        "Revisó decisiones con nueva evidencia.",
        "Comunicó claramente la decisión e influyó su adopción."
    ]
}

# ==========================
# Carga perezosa de modelos
# ==========================
def lazy_load_llm():
    """Pipeline de generación (Qwen 0.5B) para extraer STAR."""
    global _llm, _llm_tok, _gen
    from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline
    if _gen is not None:
        return _gen
    _llm_tok = AutoTokenizer.from_pretrained(LLM_ID)
    _llm = AutoModelForCausalLM.from_pretrained(LLM_ID, device_map="auto")
    _gen = pipeline(
        "text-generation",
        model=_llm,
        tokenizer=_llm_tok,
        max_new_tokens=512,
        do_sample=False,
        repetition_penalty=1.1,
    )
    return _gen

def lazy_load_nli(model_id: str):
    """NLI con salida completa y truncado seguro. Cachea por model_id."""
    from transformers import pipeline
    if model_id in _nli_cache:
        return _nli_cache[model_id]
    nli = pipeline(
        "text-classification",
        model=model_id,
        tokenizer=model_id,
        return_all_scores=True,   # {label, score} para todas las clases
        truncation=True           # evita degradación por textos largos
    )
    _nli_cache[model_id] = nli
    return nli

# ==========================
# Utilidades extracción STAR
# ==========================
def extract_json_block(text: str) -> str:
    start = text.find("{")
    end = text.rfind("}")
    if start != -1 and end != -1 and end > start:
        return text[start:end+1]
    return '{"situation":"","task":"","action":[],"result":[]}'

def quick_parse_star(txt: str):
    t = (txt or "").strip()
    if not t:
        return None
    keys = ("SITUATION", "TASK", "ACTION", "RESULT", "S:", "T:", "A:", "R:")
    if not any(k in t for k in keys):
        return None
    sections = {"situation": "", "task": "", "action": [], "result": []}
    blocks = re.split(r'(?im)^(SITUATION|TASK|ACTION|RESULT|S:|T:|A:|R:)\s*:?', t)
    for i in range(1, len(blocks), 2):
        key = blocks[i].lower()[0]
        val = blocks[i+1].strip()
        if key == "s":
            sections["situation"] = val
        elif key == "t":
            sections["task"] = val
        elif key == "a":
            sections["action"] = [x.strip("•- ") for x in val.splitlines() if x.strip()]
        elif key == "r":
            sections["result"] = [x.strip("•- ") for x in val.splitlines() if x.strip()]
    return sections

def extract_star(user_text: str) -> Dict:
    parsed = quick_parse_star(user_text)
    if parsed:
        return parsed
    gen = lazy_load_llm()
    prompt = STAR_PROMPT.format(texto=(user_text or "").strip())
    out = gen(prompt)[0]["generated_text"]
    raw = extract_json_block(out)
    try:
        data = json.loads(raw)
    except Exception:
        data = {"situation": "", "task": "", "action": [], "result": []}
        m = re.search(r'Situation[::]\s*(.*)', user_text or "", flags=re.I)
        if m:
            data["situation"] = m.group(1).strip()
    data["action"] = data.get("action", [])
    data["result"] = data.get("result", [])
    if isinstance(data["action"], str):
        data["action"] = [data["action"]]
    if isinstance(data["result"], str):
        data["result"] = [data["result"]]
    return {
        "situation": (data.get("situation", "") or "").strip(),
        "task": (data.get("task", "") or "").strip(),
        "action": [str(a).strip(" •-") for a in data["action"] if str(a).strip()],
        "result": [str(r).strip(" •-") for r in data["result"] if str(r).strip()],
    }

# ==========================
# NLI + scoring (dinámico por modelo)
# ==========================
def calibrate_prob(p: float, use_calibration: bool) -> float:
    """Calibración leve solo para MiniLM (p**0.9)."""
    p = max(0.0, min(1.0, float(p)))
    return (p ** 0.9) if use_calibration else p

def nli_entails(premise: str, hypothesis: str, model_id: str) -> float:
    """Probabilidad de ENTAILMENT (0..1) robusta a variantes de salida."""
    nli = lazy_load_nli(model_id)

    def _trim(s: str, limit=900):
        s = (s or "").strip()
        return s[:limit]

    text_a = _trim(premise)
    text_b = _trim(hypothesis)
    if not text_a or not text_b:
        return 0.0

    try:
        res = nli({"text": text_a, "text_pair": text_b})
    except Exception:
        return 0.0

    # return_all_scores=True → [{label, score}, ...] ó [[{...}]]
    if isinstance(res, dict):
        candidates = [res]
    elif isinstance(res, list):
        candidates = res[0] if (res and isinstance(res[0], list)) else res
    else:
        return 0.0

    for c in (d for d in candidates if isinstance(d, dict)):
        lab = str(c.get("label", "")).lower()
        if "entail" in lab:
            try:
                return float(c.get("score", 0.0))
            except Exception:
                return 0.0
    return 0.0

def map_prob_to_score(p: float, thresholds: Tuple[float, float, float, float]) -> int:
    t4, t3, t2, t1 = thresholds
    if p >= t4: return 4
    if p >= t3: return 3
    if p >= t2: return 2
    if p >= t1: return 1
    return 0

def score_indicator(premise: str, hyps: List[str], model_id: str, use_calibration: bool,
                    thresholds: Tuple[float, float, float, float]) -> Tuple[int, List[Tuple[str, float]], float]:
    raw = [(h, nli_entails(premise, h, model_id)) for h in hyps]
    probs = [(h, calibrate_prob(p, use_calibration)) for h, p in raw]
    avg = sum(p for _, p in probs) / max(1, len(probs))
    score = map_prob_to_score(avg, thresholds)
    probs_sorted = sorted(probs, key=lambda x: x[1], reverse=True)[:2]
    return score, probs_sorted, avg

# ==========================
# Evaluación orquestada
# ==========================
def evaluate(texto: str, model_key: str):
    """Devuelve: status_msg, matplotlib_fig, {"columns":[...], "data":[...] }."""
    try:
        if not texto or not texto.strip():
            return "Introduce un caso en formato STAR (o texto libre).", None, {"columns": [], "data": []}

        # Config del modelo seleccionado
        cfg = MODEL_CHOICES.get(model_key, MODEL_CHOICES[DEFAULT_MODEL_KEY])
        model_id = cfg["id"]
        use_calibration = cfg["calibrate"]
        thresholds = cfg["thresholds"]

        star = extract_star(texto)

        # Limita premisa para dar señal clara al NLI (6 A + 4 R)
        actions = (star.get("action", []) or [])[:6]
        results = (star.get("result", []) or [])[:4]
        premise = " ".join(actions) + " " + " ".join(results)

        # Scoring por indicador
        scores, table_rows, per_indicator_values = [], [], []
        for ind, hyps in HYP.items():
            s, ev, avg = score_indicator(premise, hyps, model_id, use_calibration, thresholds)
            scores.append(s)
            per_indicator_values.append((ind, s))
            best_evid = " / ".join([h for h, _ in ev])
            name, desc = INDICATOR_META[ind]
            table_rows.append([ind, name, s, f"{avg:.2f}", best_evid, desc])

        overall = round(sum(scores) / max(1, len(scores)), 2)

        # Gráfica
        labels = [f"{k.split('.')[-1]}" for k, _ in per_indicator_values]
        values = [v for _, v in per_indicator_values]
        fig, ax = plt.subplots(figsize=(8.2, 4.0))
        ax.bar(labels, values)
        ax.set_ylim(0, 4)
        ax.set_xlabel("Indicadores 4.4.5.x")
        ax.set_ylabel("Score (0–4)")
        fig.suptitle(f"ICB4 4.4.5 Leadership — Score global: {overall}  |  Modelo: {model_key}", y=0.97)
        fig.subplots_adjust(top=0.86)
        for i, v in enumerate(values):
            ax.text(i, v + 0.08, f"{v}", ha="center", va="bottom")
        fig.tight_layout()

        table = {
            "columns": DEFAULT_COLS,
            "data": table_rows,
            "model_key": model_key,   # ← etiqueta elegida en el dropdown (MiniLM / DeBERTa)
            "model_id": model_id      # ← repo real en HF (para trazabilidad)
        }

        msg = (
            f"Evaluación completada. Score global (0–4): {overall}\n"
            f"Modelo: {model_key}\n"
            f"Sugerencia: revisa evidencias y ajusta umbrales según tu rúbrica."
        )
        return msg, fig, table

    except Exception as e:
        return f"⚠️ Error en evaluate(): {type(e).__name__}: {e}", None, {"columns": [], "data": []}

# ==========================
# CSV helper
# ==========================
def make_csv_from_table(table: dict) -> str:
    """Genera CSV temporal sin incluir la columna 'Modelo (repo)', pero conserva 'Modelo (etiqueta)'."""
    cols = table.get("columns", [])
    rows = table.get("data", [])
    ts = int(time.time())
    path = f"/tmp/icb4_leadership_{ts}.csv"

    # Detecta y elimina solo la columna 'Modelo (repo)'
    if "Modelo (repo)" in cols:
        idx_repo = cols.index("Modelo (repo)")
        cols = [c for i, c in enumerate(cols) if i != idx_repo]
        new_rows = []
        for r in rows:
            if len(r) > idx_repo:
                # Elimina solo la celda correspondiente al campo 'Modelo (repo)'
                r = [c for i, c in enumerate(r) if i != idx_repo]
            new_rows.append(r)
        rows = new_rows

    # Escribe el CSV final
    with open(path, "w", newline="", encoding="utf-8") as f:
        writer = csv.writer(f)
        writer.writerow(cols)
        for r in rows:
            writer.writerow(r)

    return path if os.path.exists(path) else ""



# ==========================
# UI (2 columnas + selector modelo + CSV)
# ==========================
with gr.Blocks(title="ICB4 4.4.5 Leadership — Evaluación STAR (FRAQX)", css=CUSTOM_CSS, elem_id="app") as demo:
    gr.Markdown(
        """
        <div style="display:flex;align-items:center;gap:12px;margin:8px 0 2px 0;">
            <img src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg" height="28">
            <h1 style="margin:0;">ICB4 • 4.4.5 Leadership — Evaluación STAR + NLI</h1>
        </div>
        <div class="small">Extracción STAR, scoring (4.4.5.1–4.4.5.5), gráfica y reporte descargable. Elige el modelo NLI según tu prioridad.</div>
        """
    )

    with gr.Row(equal_height=True):
        # Entrada
        with gr.Column(scale=5):
            gr.Markdown("<div class='card'><b>Entrada</b></div>")

            model_key = gr.Dropdown(
                choices=list(MODEL_CHOICES.keys()),
                value=DEFAULT_MODEL_KEY,
                label="Modelo NLI",
                info="Velocidad (MiniLM) = más rápido | Precisión (DeBERTa) = mejor calidad"
            )

            texto = gr.Textbox(
                label="Caso (STAR o texto libre)",
                lines=16,
                placeholder="Pega aquí tu caso en formato STAR (S, T, A, R) o texto libre…"
            )
            with gr.Row():
                btn = gr.Button("Evaluar", variant="primary", scale=3)
                gr.ClearButton([texto], value="Limpiar", scale=1)

            gr.Markdown(
                """
                <details>
                  <summary>Ejemplo rápido (clic para autocompletar)</summary>
                  <div class="small">
                  S: El proyecto CRM estaba retrasado 6 semanas y el equipo estaba desmotivado.<br/>
                  T: Recuperar el plan y mejorar la colaboración en 2 sprints.<br/>
                  A: Organicé una sesión de visión y valores; definí métricas; implementé dailies; mentoring a líderes junior;
                  negocié con stakeholders; prioricé backlog mínimo; comuniqué riesgos y fechas realistas.<br/>
                  R: Recuperamos 4 semanas en 2 sprints; NPS interno +22; retrabajo -18%; se mantuvieron prácticas; dos líderes promovidos.
                  </div>
                </details>
                """,
            )

        # Salida
        with gr.Column(scale=7):
            gr.Markdown("<div class='card'><b>Resultados</b></div>")
            status = gr.Markdown(value="**Estado**: —", elem_id="status_md")
            score_badge = gr.Markdown(value="<span class='badge'>Score global: —</span>")
            plot = gr.Plot(label="Gráfica de evaluación (0–4)")
            table = gr.Dataframe(
                headers=DEFAULT_COLS,
                datatype=["str", "str", "number", "str", "str", "str"],
                interactive=False,
                label="Detalle por indicador"
            )
            with gr.Row():
                download_btn = gr.Button("Descargar CSV")
                csv_file = gr.File(label="Archivo CSV", visible=False)

    # Lógica
    def run_eval(t: str, mk: str):
        msg, fig, tbl = evaluate(t, mk)

        status_md = "**Estado**  \n" + (msg or "").replace("\n", "  \n")

        badge_html = "<span class='badge'>Score global: —</span>"
        try:
            m = re.search(r"Score global \(0–4\):\s*([0-4](?:\.[0-9])?)", msg or "")
            if m:
                badge_html = f"<span class='badge'>Score global: {m.group(1)}</span>"
        except Exception:
            pass

        cols = (tbl or {}).get("columns") or DEFAULT_COLS
        data = (tbl or {}).get("data") or []
        safe_data = []
        for row in data:
            r = list(row)
            if len(r) < len(cols):
                r += [""] * (len(cols) - len(r))
            elif len(r) > len(cols):
                r = r[:len(cols)]
            safe_data.append(r)

        if fig is None:
            fig, ax = plt.subplots(figsize=(6, 2))
            ax.axis("off")
            ax.text(0.5, 0.5, "Sin datos para graficar", ha="center", va="center")

        return status_md, badge_html, fig, gr.update(value=safe_data, headers=cols)

    btn.click(fn=run_eval, inputs=[texto, model_key], outputs=[status, score_badge, plot, table])

    def export_csv_handler(t: str, mk: str):
        _, _, tbl = evaluate(t, mk)
        path = make_csv_from_table(tbl)
        return path, gr.update(visible=True)

    download_btn.click(fn=export_csv_handler, inputs=[texto, model_key], outputs=[csv_file, csv_file])

# Lanzamiento
if __name__ == "__main__":
    demo.queue(max_size=16).launch(ssr_mode=False, show_error=True)