|
|
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, |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
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", |
|
|
] |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
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} |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
_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) |
|
|
|
|
|
|
|
|
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, |
|
|
out_widget=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: |
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
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)) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
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() |