File size: 26,386 Bytes
2ec44fa
 
 
dca9529
baf06bd
257b9db
fd43191
 
2ec44fa
baf06bd
fd43191
 
 
 
 
 
961a17a
fd43191
406b286
 
 
 
 
 
 
fd43191
 
406b286
fd43191
 
406b286
 
 
 
 
fd43191
 
 
 
 
 
 
 
 
 
bcfb487
 
 
 
 
 
 
 
 
 
 
406b286
 
 
 
 
 
 
 
fd43191
 
 
406b286
 
fd43191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406b286
 
fd43191
 
 
 
 
 
 
406b286
baf06bd
dca9529
961a17a
 
 
dca9529
baf06bd
dac6162
 
961a17a
baf06bd
d77abca
baf06bd
 
 
 
ac31c1c
baf06bd
ce0ac07
baf06bd
 
 
 
 
 
 
 
 
 
 
fd43191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406b286
 
baf06bd
406b286
 
 
 
 
 
ce0ac07
406b286
 
 
 
 
 
 
 
baf06bd
 
fd43191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ce0ac07
fd43191
 
 
406b286
ce0ac07
406b286
baf06bd
 
 
 
 
fd43191
 
 
 
 
 
 
ce0ac07
fd43191
 
 
 
 
 
 
 
 
 
 
 
 
ce0ac07
fd43191
 
 
 
 
 
 
 
 
 
 
ce0ac07
fd43191
 
 
 
406b286
2ec44fa
dca9529
fd43191
 
 
 
 
 
 
 
 
 
 
 
8127c0b
fd43191
 
 
 
 
 
 
 
 
 
 
 
406b286
baf06bd
ce0ac07
961a17a
 
 
 
 
baf06bd
60d3bf3
fd43191
c51ad61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b4c993d
257b9db
 
 
 
 
c51ad61
 
257b9db
04fbd8d
257b9db
 
 
 
 
 
 
 
b4c993d
 
 
 
406b286
b4c993d
257b9db
fd43191
257b9db
 
 
 
 
bcfb487
 
 
257b9db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c51ad61
 
 
 
 
 
 
 
257b9db
 
 
 
b4c993d
257b9db
ac31c1c
baf06bd
961a17a
 
 
 
baf06bd
961a17a
fd43191
 
baf06bd
 
 
 
 
961a17a
baf06bd
 
 
ce0ac07
fd43191
 
 
 
 
 
baf06bd
c51ad61
fd43191
bcfb487
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fd43191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
04fbd8d
fd43191
 
 
 
 
bcfb487
 
fd43191
bcfb487
 
04fbd8d
bcfb487
 
 
 
 
 
 
 
 
 
fd43191
 
 
257b9db
 
 
 
 
bcfb487
 
2ec44fa
 
 
 
 
 
 
 
 
 
257b9db
2ec44fa
fd43191
2ec44fa
fd43191
 
 
 
 
 
bcfb487
fd43191
 
 
bcfb487
fd43191
 
 
bcfb487
fd43191
 
 
bcfb487
fd43191
 
 
bcfb487
fd43191
 
 
bcfb487
fd43191
 
 
bcfb487
 
 
 
 
 
 
 
04fbd8d
bcfb487
 
 
 
 
 
 
 
 
 
 
 
04fbd8d
bcfb487
 
04fbd8d
 
bcfb487
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
04fbd8d
 
 
 
 
 
bcfb487
 
fd43191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c51ad61
406b286
 
 
 
 
fd43191
406b286
 
fd43191
bcfb487
257b9db
fd43191
257b9db
c51ad61
fd43191
406b286
c51ad61
fd43191
257b9db
c51ad61
fd43191
baf06bd
fd43191
 
c51ad61
2ec44fa
 
 
 
 
 
 
 
 
 
 
 
dca9529
 
 
 
 
 
 
 
 
 
 
 
 
baf06bd
dca9529
 
 
 
 
 
 
 
 
 
 
 
 
c51ad61
dca9529
 
 
c51ad61
 
dca9529
 
257b9db
c51ad61
257b9db
c51ad61
baf06bd
dca9529
 
257b9db
 
 
 
 
dca9529
 
 
2ec44fa
 
 
 
 
 
 
dca9529
2ec44fa
 
 
 
 
baf06bd
2ec44fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dca9529
 
 
2ec44fa
 
 
 
 
 
 
dca9529
 
 
 
 
 
 
 
 
 
 
 
 
2ec44fa
 
 
 
 
 
 
04fbd8d
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
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
import os
import datetime as dt
import tempfile
import time
from typing import Tuple, Optional
import threading
import re

import gradio as gr
import torch
from transformers import (
    pipeline,
    AutoTokenizer,
    AutoModelForSeq2SeqLM,
    TextIteratorStreamer,
)

# ===================== MARCADORES / LIMPIEZA =====================

MARK_START = "---SALIDA---"
MARK_END = "---FIN---"

INSTRUCTION_NOISE = [
    r"^Eres un asesor.*$",
    r"^Escribe en espa(ñ|n)ol.*$",
    r"^Responde SIEMPRE.*$",
    r"^No repitas.*$",
    r"^ENTRADAS?:.*$",
    r"^Devuelve.*$",
    r"^Genera.*$",
    r"^Esta es una escena.*$",
    r"^En aquella opci(ó|o)n.*$",
    r"^Objetivo:?$",
]

REQUIRED_SUBSECTIONS = [
    "### Supuestos clave",
    "### KPIs sugeridos",
    "### Acciones recomendadas",
    "### Riesgos y mitigaciones",
    "### Disparadores de decisión (triggers)",
    "### Próximos pasos (90 días)",
    "### Señales tempranas de alerta",
]

# --- util: recortar textos largos para que entren en T5-small/base ---
def _shorten(txt: str, limit: int = 420) -> str:
    txt = (txt or "").strip()
    if len(txt) <= limit:
        return txt
    cut = txt[:limit]
    cut = cut[: cut.rfind(" ")] if " " in cut else cut
    return cut + "…"



def _extract_markdown(raw: str) -> str:
    if MARK_START in raw and MARK_END in raw:
        raw = raw.split(MARK_START, 1)[1].split(MARK_END, 1)[0]
    return raw.strip()

def _post_clean(md: str) -> str:
    lines = []
    for line in md.splitlines():
        if any(re.search(pat, line.strip(), flags=re.IGNORECASE) for pat in INSTRUCTION_NOISE):
            continue
        lines.append(line)
    md = "\n".join(lines)
    md = re.sub(r"\n{3,}", "\n\n", md).strip()
    md = re.sub(r"^\s*Escenario\s+(Optimista|Base|Pesimista)", r"## Escenario \1",
                md, flags=re.IGNORECASE | re.MULTILINE)
    md = re.sub(r"^\s*Supuestos clave", r"### Supuestos clave",
                md, flags=re.IGNORECASE | re.MULTILINE)
    md = re.sub(r"^\s*KPIs sugeridos", r"### KPIs sugeridos",
                md, flags=re.IGNORECASE | re.MULTILINE)
    md = re.sub(r"^\s*Acciones recomendadas", r"### Acciones recomendadas",
                md, flags=re.IGNORECASE | re.MULTILINE)
    md = re.sub(r"^\s*Riesgos y mitigaciones", r"### Riesgos y mitigaciones",
                md, flags=re.IGNORECASE | re.MULTILINE)
    md = re.sub(r"^\s*Disparadores de decisi(ó|o)n.*", r"### Disparadores de decisión (triggers)",
                md, flags=re.IGNORECASE | re.MULTILINE)
    md = re.sub(r"^\s*Pr(ó|o)ximos pasos.*", r"### Próximos pasos (90 días)",
                md, flags=re.IGNORECASE | re.MULTILINE)
    md = re.sub(r"^\s*Se(ñ|n)ales tempranas.*", r"### Señales tempranas de alerta",
                md, flags=re.IGNORECASE | re.MULTILINE)
    return md

def _ensure_all_subsections(blocks: dict[str, str]) -> dict[str, str]:
    safe = dict(blocks)
    have = "\n".join(blocks.values())
    for title in REQUIRED_SUBSECTIONS:
        if title.lower() not in have.lower():
            safe[title] = f"{title}\n- (pendiente de completar)\n"
    return safe

# ===================== CONFIG (LOCAL/CPU) =====================

LOCAL_DEFAULT = "google/flan-t5-small"
LOCAL_FALLBACKS = [
    "google/flan-t5-small",
    "MBZUAI/LaMini-Flan-T5-248M",
    "google/flan-t5-base",
]

DEFAULT_MODEL = os.getenv("HF_MODEL_ID", LOCAL_DEFAULT)
FALLBACK_MODELS = LOCAL_FALLBACKS

try:
    torch.set_num_threads(max(1, int(os.environ.get("TORCH_NUM_THREADS", "1"))))
except Exception:
    pass

# ===================== PROMPTS =====================

INDUSTRY_SEED = {
    "Alimentos y bebidas": """
Contexto específico de industria:
- Volatilidad de insumos (trigo/aceites/lácteos). Importancia de S&OP, merma y vida de anaquel.
- Canales: retail moderno, tradicional y e-commerce en crecimiento.
- Regulación en etiquetado frontal y claims saludables.
KPIs habituales: OTIF, Merma (%), Costo unitario ($/kg), Rotación inventario (veces), % ventas saludables, Margen contribución por canal.
Riesgos comunes: alzas súbitas de insumos (>8% mensual), quiebres de stock, cambios regulatorios, concentración de proveedores.
""".strip()
}

def _ctx_block():
    return (
        "ENTRADAS\n"
        "- Contexto: {ctx}\n"
        "- Objetivos: {obj}\n"
        "- Restricciones: {constr}\n"
        "- Horizonte: {horiz}\n"
        "- Supuestos: {assum}\n"
    )

LANG_GUARD = (
    "Responde SIEMPRE en español profesional y neutro. "
    "No mezcles inglés. Si escribiste algo en inglés, corrígelo al español. "
    "Usa viñetas concisas y medibles."
)

PROMPT_RESUMEN = f"""Responde SIEMPRE en español profesional.
No repitas instrucciones ni la sección ENTRADAS.
Objetivo: redacta SOLO **7 viñetas** de un Resumen ejecutivo de escenarios estratégicos.
ENTRADAS
- Contexto: {{ctx}}
- Objetivos: {{obj}}
- Restricciones: {{constr}}
- Horizonte: {{horiz}}
- Supuestos: {{assum}}
{MARK_START}
## Resumen ejecutivo
- …
- …
- …
- …
- …
- …
- …
{MARK_END}
"""

PROMPT_SUPUESTOS = f"""{LANG_GUARD}
Genera 6–8 **supuestos clave** para el **Escenario {{nombre}}**.
Cada viñeta debe incluir valores/umbrales si aplica.
{{_ctx}}
{MARK_START}
### Supuestos clave
- …
- …
- …
- …
- …
- …
- …
{MARK_END}
"""

PROMPT_KPIS = f"""{LANG_GUARD}
Propón 6 KPIs para el **Escenario {{nombre}}** con nombre corto, explicación (10–12 palabras) y fórmula.
{{_ctx}}
{MARK_START}
### KPIs sugeridos
- **Crecimiento ventas (%)** — breve. **Fórmula:** (Ventas_t - Ventas_{{{{t-1}}}})/Ventas_{{{{t-1}}}}
- **Margen EBITDA (%)** — breve. **Fórmula:** EBITDA/Ventas
- **OTIF (%)** — breve. **Fórmula:** Órdenes a tiempo / Órdenes totales
- **Merma (%)** — breve. **Fórmula:** (Kg merma / Kg producidos)×100
- **Rotación inventario (veces)** — breve. **Fórmula:** Costo ventas / Inventario promedio
- **Costo unitario ($/kg)** — breve.
{MARK_END}
"""

PROMPT_ACCIONES = f"""{LANG_GUARD}
Lista 6–8 **acciones recomendadas** para el **Escenario {{nombre}}**. Incluye dueño y horizonte (Q/M/año).
{{_ctx}}
{MARK_START}
### Acciones recomendadas
- …
- …
- …
- …
- …
- …
{MARK_END}
"""

PROMPT_RIESGOS = f"""{LANG_GUARD}
Enumera 4–6 **riesgos y mitigaciones** para el **Escenario {{nombre}}** con flecha →.
{{_ctx}}
{MARK_START}
### Riesgos y mitigaciones
- Riesgo: … → Mitigación: …
- Riesgo: … → Mitigación: …
- Riesgo: … → Mitigación: …
- Riesgo: … → Mitigación: …
{MARK_END}
"""

PROMPT_TRIGGERS = f"""{LANG_GUARD}
Define 4–6 **disparadores de decisión (triggers)** para el **Escenario {{nombre}}** con umbrales numéricos.
{{_ctx}}
{MARK_START}
### Disparadores de decisión (triggers)
- Si **EBITDA < 16%** 2 meses → activar plan de ahorro nivel 1
- Si **OTIF < 92%** 2 cortes → revisar S&OP y capacidad
- …
- …
{MARK_END}
"""

PROMPT_PASOS = f"""{LANG_GUARD}
Redacta 5–6 **próximos pasos (90 días)** para el **Escenario {{nombre}}**, con entregable y fecha.
{{_ctx}}
{MARK_START}
### Próximos pasos (90 días)
- …
- …
- …
- …
- …
{MARK_END}
"""

PROMPT_SENALES = f"""{LANG_GUARD}
Indica 5–6 **señales tempranas de alerta** (leading indicators) para el **Escenario {{nombre}}**.
{{_ctx}}
{MARK_START}
### Señales tempranas de alerta
- …
- …
- …
- …
- …
{MARK_END}
"""

# ===================== MOTOR LOCAL =====================

_PIPE = {"id": None, "pipe": None}

def _load_local_pipeline(model_id: str):
    task = "text2text-generation"
    tok = AutoTokenizer.from_pretrained(model_id)
    mdl = AutoModelForSeq2SeqLM.from_pretrained(model_id, use_safetensors=True)
    return pipeline(task=task, model=mdl, tokenizer=tok, device=-1)

# ---------------- UI-safe helpers (reemplazo de .update) ----------------
def _ui_set(widget: Optional[gr.Markdown], text: str):
    """Intenta asignar el valor del componente sin usar .update()."""
    try:
        if widget is not None and hasattr(widget, "value"):
            widget.value = text
    except Exception:
        pass

def _ui_append(widget: Optional[gr.Markdown], text: str, sep: str = "\n\n"):
    """Intenta añadir texto al valor actual del componente sin usar .update()."""
    try:
        if widget is not None and hasattr(widget, "value"):
            current = widget.value or ""
            widget.value = (current + (sep if current else "") + text)
    except Exception:
        pass

# -----------------------------------------------------------------------

def _gen_stream(
    model_id: str,
    prompt: str,
    *,
    max_new: int = 320,
    status_widget=None,        # gr.Markdown | None
    out_widget=None,           # gr.Markdown | None
    section_name: str = "",
    section_timeout_s: int = 120,
) -> str:
    if _PIPE["pipe"] is None or _PIPE["id"] != model_id:
        _PIPE["pipe"] = _load_local_pipeline(model_id)
        _PIPE["id"] = model_id

    tok = _PIPE["pipe"].tokenizer
    mdl = _PIPE["pipe"].model

    inputs = tok(
        prompt,
        return_tensors="pt",
        truncation=True,
        max_length=min(getattr(tok, "model_max_length", 512), 512),
    )

    streamer = TextIteratorStreamer(tok, skip_prompt=True, skip_special_tokens=True)

    gen_kwargs = dict(
        **inputs,
        max_new_tokens=max_new,
        do_sample=True,
        temperature=0.8,
        top_p=0.95,
        top_k=40,
        no_repeat_ngram_size=3,
        repetition_penalty=1.05,
        streamer=streamer,
    )

    t = threading.Thread(target=mdl.generate, kwargs=gen_kwargs)
    t.start()

    acc = ""
    last_ping = time.time()
    start = last_ping
    deadline = start + section_timeout_s

    for chunk in streamer:
        acc += chunk
        now = time.time()
        if now - last_ping >= 0.7:
            if section_name and status_widget is not None:
                _ui_set(status_widget, f"🧠 Generando **{section_name}**… {len(acc)} caracteres")
            if out_widget is not None:
                # stream "crudo" para feedback
                if section_name.lower().startswith("resumen"):
                    _ui_set(out_widget, acc)
                else:
                    _ui_append(out_widget, acc)
            last_ping = now
        if now > deadline:
            break

    t.join(timeout=0.2)
    return acc.strip()

def _gen_fast(model_id: str, prompt: str, max_new: int = 340) -> str:
    if _PIPE["pipe"] is None or _PIPE["id"] != model_id:
        _PIPE["pipe"] = _load_local_pipeline(model_id)
        _PIPE["id"] = model_id

    out = _PIPE["pipe"](
        prompt,
        max_new_tokens=max_new,
        do_sample=True,
        temperature=0.75,
        top_p=0.92,
        top_k=50,
        no_repeat_ngram_size=3,
        repetition_penalty=1.05,
    )
    if isinstance(out, list) and out and "generated_text" in out[0]:
        return out[0]["generated_text"].strip()
    return str(out).strip()

# ===================== HELPERS DE ENSAMBLE =====================

def _seeded_context(context: str) -> str:
    seed = INDUSTRY_SEED["Alimentos y bebidas"]
    return f"{context}\n\n{seed}"

def _safe_update(md_widget: Optional[gr.Markdown], text: str):
    _ui_set(md_widget, text)

def _fallback_prompt(prompt_tmpl_short: str, nombre: str, ctx, obj, constr, horiz, assum):
    return prompt_tmpl_short.format(
        nombre=nombre,
        _ctx=_ctx_block().format(
            ctx=ctx,
            obj=_shorten(obj, 160),
            constr=_shorten(constr, 140),
            horiz=_shorten(horiz, 60),
            assum=_shorten(assum, 120),
        ),
    )

def _minimal_section(title: str, puntos: list[str]) -> str:
    pts = "\n".join(f"- {p}" for p in puntos if p.strip())
    return f"{title}\n{pts if pts else '- (por llenar)'}\n"


def _gen_piece(
    model_id: str,
    prompt_tmpl: str,
    nombre: str,
    ctx,
    obj,
    constr,
    horiz,
    assum,
    max_new: int,
    status_widget=None,
    out_widget=None,
    section_label: str = "",
    timeout: int = 120,
) -> str:
    # 1) prompt normal (streaming)
    p = prompt_tmpl.format(
        nombre=nombre,
        _ctx=_ctx_block().format(ctx=ctx, obj=obj, constr=constr, horiz=horiz, assum=assum),
    )
    raw = _gen_stream(
        model_id, p, max_new=max_new, status_widget=status_widget,
        out_widget=out_widget, section_name=section_label, section_timeout_s=timeout
    )
    cleaned = _post_clean(_extract_markdown(raw))

    # 2) si salió vacío o muy corto, reintenta con prompt más corto y _gen_fast (sin streaming)
    if len(cleaned.strip()) < 40:
        short = _fallback_prompt(
            prompt_tmpl.replace(MARK_START, "").replace(MARK_END, ""),
            nombre, ctx, obj, constr, horiz, assum
        )
        fast = _gen_fast(model_id, short, max_new=max_new)
        cleaned = _post_clean(_extract_markdown(fast))

    return cleaned


# ===================== ENSAMBLE DEL DOCUMENTO =====================

def generate_document_streaming(
    context, objectives, constraints, horizon, assumptions, model_id,
    status_widget: gr.Markdown | None,
    out_widget: gr.Markdown | None,
) -> str:
    ctx = _shorten(_seeded_context(context), 420)


    header = [
        "# Escenarios estratégicos · Generador (FRAQX)",
        f"**Fecha:** {dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
        "## Entradas",
        f"- **Contexto:** {context}",
        f"- **Objetivos:** {objectives}",
        f"- **Restricciones:** {constraints if constraints.strip() else 'N/A'}",
        f"- **Horizonte:** {horizon}",
        f"- **Supuestos adicionales:** {assumptions if (assumptions or '').strip() else 'N/A'}",
        f"> **Modelo:** `{model_id}`",
        "---",
        "# Escenarios estratégicos",
    ]
    _safe_update(out_widget, "\n\n".join(header) + "\n\n_(iniciando generación por secciones…)_")

    def build_escenario(nombre: str) -> str:
        sup = _gen_piece(model_id, PROMPT_SUPUESTOS, nombre, ctx, objectives, constraints,
                         horizon, assumptions, max_new=240,
                         status_widget=status_widget, out_widget=out_widget,
                         section_label=f"{nombre} · Supuestos", timeout=160)
        kpi = _gen_piece(model_id, PROMPT_KPIS, nombre, ctx, objectives, constraints,
                         horizon, assumptions, max_new=260,
                         status_widget=status_widget, out_widget=out_widget,
                         section_label=f"{nombre} · KPIs", timeout=170)
        acc = _gen_piece(model_id, PROMPT_ACCIONES, nombre, ctx, objectives, constraints,
                         horizon, assumptions, max_new=220,
                         status_widget=status_widget, out_widget=out_widget,
                         section_label=f"{nombre} · Acciones", timeout=160)
        rie = _gen_piece(model_id, PROMPT_RIESGOS, nombre, ctx, objectives, constraints,
                         horizon, assumptions, max_new=200,
                         status_widget=status_widget, out_widget=out_widget,
                         section_label=f"{nombre} · Riesgos", timeout=150)
        trg = _gen_piece(model_id, PROMPT_TRIGGERS, nombre, ctx, objectives, constraints,
                         horizon, assumptions, max_new=180,
                         status_widget=status_widget, out_widget=out_widget,
                         section_label=f"{nombre} · Triggers", timeout=150)
        pas = _gen_piece(model_id, PROMPT_PASOS, nombre, ctx, objectives, constraints,
                         horizon, assumptions, max_new=180,
                         status_widget=status_widget, out_widget=out_widget,
                         section_label=f"{nombre} · Pasos 90d", timeout=150)
        sen = _gen_piece(model_id, PROMPT_SENALES, nombre, ctx, objectives, constraints,
                         horizon, assumptions, max_new=160,
                         status_widget=status_widget, out_widget=out_widget,
                         section_label=f"{nombre} · Señales", timeout=150)

        # Si alguna quedó vacía, arma una mínima basada en entradas
        if len(sup.strip()) < 20:
            sup = _minimal_section("### Supuestos clave", [
                f"Demanda online creciendo {'8–12%' if nombre=='Optimista' else '4–7%' if nombre=='Base' else '0–3%'} anual",
                "Variación de costos de insumos ±3–5% mensual",
                "Capacidad de planta utilizable 85–95%",
                f"Ejecución de línea saludable {'rápida' if nombre=='Optimista' else 'gradual' if nombre=='Base' else 'limitada'}"
            ])
        if len(kpi.strip()) < 20:
            kpi = _minimal_section("### KPIs sugeridos", [
                "Crecimiento ventas (%) — (Ventas_t - Ventas_{t-1})/Ventas_{t-1}",
                "Margen EBITDA (%) — EBITDA/Ventas",
                "OTIF (%) — Órdenes a tiempo / Órdenes totales",
                "Merma (%) — (Kg merma / Kg producidos)×100",
                "Rotación inventario (veces) — Costo ventas / Inventario promedio",
                "Costo unitario ($/kg)"
            ])
        if len(acc.strip()) < 20:
            acc = _minimal_section("### Acciones recomendadas", [
                "Optimizar S&OP (Operaciones, Q1)",
                "Renegociar insumos clave (Compras, Q1–Q2)",
                "Lanzar línea saludable (Marketing, Q2)",
                "Automatizar picking (Logística, Q2–Q3)",
                "Piloto D2C (Ecommerce, Q3)"
            ])
        if len(rie.strip()) < 20:
            rie = _minimal_section("### Riesgos y mitigaciones", [
                "Alza insumos → contratos con bandas de precio",
                "Quiebre proveedor → homologación dual",
                "Demanda débil → promociones selectivas",
                "Cuello de botella → turnos flexibles"
            ])
        if len(trg.strip()) < 20:
            trg = _minimal_section("### Disparadores de decisión (triggers)", [
                "EBITDA < 16% por 2 meses → plan ahorro nivel 1",
                "OTIF < 92% por 2 cortes → revisión capacidad",
                "Inventario > 60 días → liquidación controlada",
                "Costo unitario +7% 2 meses → renegociar contratos"
            ])
        if len(pas.strip()) < 20:
            pas = _minimal_section("### Próximos pasos (90 días)", [
                "Diagnóstico S&OP/mermas (Sem 4)",
                "RFP proveedores críticos (Sem 6)",
                "Roadmap ecommerce (Sem 8)",
                "Piloto SKU saludable (Sem 10)",
                "Plan eficiencia energética (Sem 12)"
            ])
        if len(sen.strip()) < 20:
            sen = _minimal_section("### Señales tempranas de alerta", [
                "Índice precio insumos",
                "Fill rate proveedor",
                "Lead time importación",
                "NPS por canal",
                "Rotación inventario",
                "Costo logístico por kg"
            ])


        blocks = {
            "### Supuestos clave": sup,
            "### KPIs sugeridos": kpi,
            "### Acciones recomendadas": acc,
            "### Riesgos y mitigaciones": rie,
            "### Disparadores de decisión (triggers)": trg,
            "### Próximos pasos (90 días)": pas,
            "### Señales tempranas de alerta": sen,
        }
        blocks = _ensure_all_subsections(blocks)

        parts = [f"## Escenario {nombre}"]
        for title in REQUIRED_SUBSECTIONS:
            body = blocks.get(title, f"{title}\n- (pendiente de completar)")
            if not body.strip().lower().startswith(title.lower()):
                body = f"{title}\n" + body
            parts.append(body)
        return "\n\n".join(parts)

    _ui_set(status_widget, "✍️ Generando **Resumen ejecutivo**…")
    resumen_raw = _gen_stream(
        model_id,
        PROMPT_RESUMEN.format(
            ctx=ctx, obj=objectives, constr=constraints, horiz=horizon, assum=assumptions
        ),
        max_new=240,
        status_widget=status_widget,
        out_widget=out_widget,
        section_name="Resumen ejecutivo",
        section_timeout_s=160,
    )
    resumen = _post_clean(_extract_markdown(resumen_raw)) or "## Resumen ejecutivo\n- (pendiente de completar)\n"

    _ui_set(status_widget, "🟢 Construyendo **Optimista**…")
    optimista = build_escenario("Optimista")

    _ui_set(status_widget, "⚪ Construyendo **Base**…")
    base = build_escenario("Base")

    _ui_set(status_widget, "🔴 Construyendo **Pesimista**…")
    pesimista = build_escenario("Pesimista")

    full_md = "\n\n".join(header) + "\n\n" + resumen + "\n\n" + optimista + "\n\n" + base + "\n\n" + pesimista + "\n"
    _safe_update(out_widget, full_md)
    _ui_set(status_widget, "✅ Listo. Documento generado.")
    return full_md

def save_markdown(md_text: str):
    if not md_text or not md_text.strip():
        raise gr.Error("No hay contenido para exportar.")
    ts = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
    fname = f"escenarios-estrategicos-{ts}.md"
    tmp_path = os.path.join(tempfile.gettempdir(), fname)
    with open(tmp_path, "w", encoding="utf-8") as f:
        f.write(md_text)
    return tmp_path

# ===================== UI (GRADIO) =====================

def warmup(model_id: str) -> Tuple[str, str]:
    t0 = time.time()
    try:
        status_lines = [f"🔧 Warmup: inicializando modelo `{model_id}`…"]
        global _PIPE
        if _PIPE.get("pipe") is None or _PIPE.get("id") != model_id:
            _PIPE["pipe"] = _load_local_pipeline(model_id)
            _PIPE["id"] = model_id
            status_lines.append("📦 Pesos descargados/cargados en memoria.")
        else:
            status_lines.append("♻️ Reutilizando pipeline ya cargado.")
        _ = _gen_fast(model_id, "Responde en una palabra: OK.", max_new=4)
        dt_sec = time.time() - t0
        status_lines.append(f"✅ Warmup completado. Tiempo: {dt_sec:.1f}s")
        return "\n".join(status_lines), ""
    except Exception as e:
        dt_sec = time.time() - t0
        return f"❌ Warmup falló en {dt_sec:.1f}s", f"**Error:** `{type(e).__name__}` — {e}"

def fill_example():
    return (
        "Empresa mexicana del sector alimentos procesados, con presencia nacional y entrada al canal digital. Aumento en costos de insumos, competencia de marcas saludables y presión por márgenes. Planta automatizada y red de distribución establecida.",
        "Incrementar participación de mercado en 5% anual, mantener margen EBITDA >18%, lanzar línea saludable y digitalizar 60% de las ventas B2C antes de 2026.",
        "Presupuesto de inversión limitado a 30 M MXN, dependencia de dos proveedores clave, capacidad de planta al 85%, políticas internas conservadoras.",
        "2025–2027 (3 años)",
        "Inflación anual promedio 4%, estabilidad del tipo de cambio, competidor regional en 2026."
    )

def _run_and_prepare_download(context, objectives, constraints, horizon, assumptions, model_id):
    _ui_set(status_md, "🚀 Preparando…")
    _ui_set(output_md, "# Escenarios estratégicos · Generador (FRAQX)\n\n_(iniciando…)_")

    try:
        ws, dbg = warmup(model_id)
        _ui_set(status_md, ws)
        if dbg:
            _ui_set(debug_md, dbg)
    except Exception:
        pass

    md = generate_document_streaming(
        context, objectives, constraints, horizon, assumptions, model_id,
        status_widget=status_md, out_widget=output_md
    )

    path = save_markdown(md)
    return md, path

with gr.Blocks(title="Escenarios Estratégicos · FRAQX") as demo:
    gr.Markdown(
        """
# Generador de Escenarios Estratégicos
Ingresa el contexto y obtén escenarios **Optimista / Base / Pesimista** con KPIs, riesgos, disparadores de decisión y próximos pasos. Puedes **cambiar el modelo** y **descargar .md**.
        """
    )

    with gr.Row():
        with gr.Column(scale=1):
            model_id = gr.Textbox(
                label="Model ID",
                value=DEFAULT_MODEL,
                info="Modelo local (transformers, CPU). Ej.: google/flan-t5-small · MBZUAI/LaMini-Flan-T5-248M · google/flan-t5-base",
            )
            context = gr.Textbox(
                label="Contexto de negocio",
                placeholder="Sector, situación competitiva, capacidades, mercado, etc.",
                lines=6,
            )
            objectives = gr.Textbox(
                label="Objetivos",
                placeholder="Metas concretas (crecimiento, margen, cuota, NPS, expansión geográfica, etc.)",
                lines=4,
            )
            constraints = gr.Textbox(
                label="Restricciones",
                placeholder="Presupuesto, talento, tecnología, regulación, plazos, dependencia de proveedores…",
                lines=3,
            )
            horizon = gr.Textbox(
                label="Horizonte",
                placeholder="Ej.: 12 meses, FY2026, 3 años, Q1–Q4 2026…",
            )
            assumptions = gr.Textbox(
                label="Supuestos adicionales (opcional)",
                placeholder="Ej.: inflación contenida, tipo de cambio estable, lanzamiento de competidor en Q3…",
                lines=3,
            )
            generate_btn = gr.Button("Generar escenarios", variant="primary")
            warm_btn = gr.Button("Probar modelo / Warmup", variant="secondary")
            ex_btn = gr.Button("Usar ejemplo")

        with gr.Column(scale=2):
            output_md = gr.Markdown(label="Resultado (Markdown)")
            download_btn = gr.DownloadButton(
                "Descargar Markdown",
                variant="secondary",
                icon="⬇️",
            )
            status_md = gr.Markdown(value="⌛ Listo para generar.", label="Estado")
            debug_md = gr.Markdown(value="", label="Debug")

    warm_btn.click(
        fn=warmup,
        inputs=[model_id],
        outputs=[status_md, debug_md],
    )
    ex_btn.click(
        fn=fill_example,
        inputs=[],
        outputs=[context, objectives, constraints, horizon, assumptions],
    )
    generate_btn.click(
        _run_and_prepare_download,
        inputs=[context, objectives, constraints, horizon, assumptions, model_id],
        outputs=[output_md, download_btn],
    )

if __name__ == "__main__":
    demo.launch()