import os import re import json import time import hmac import base64 import hashlib import threading import unicodedata import streamlit as st from datetime import datetime from pathlib import Path from openai import OpenAI BASE_DIR = Path(__file__).parent KB_NAME = "enfoque-proto-chat-knowledge-base.md" KB_PATH = BASE_DIR / KB_NAME # Version del bot. Se INCREMENTA al desplegar una mejora salida del feedback de los reps # (se comunica el numero en el changelog de esa release). Formato X.Y. APP_VERSION = "1.5.1" APP_TITLE = f"Enfoque Proto Chatbot {APP_VERSION}" # --- Persistencia remota (fuente de verdad) -------------------------------- # La KB .md vive en el repo del Space; la bitacora en un dataset aparte. Ambas # se persisten por commit remoto (HfApi), NO en session_state. Por eso limpiar # el chat o refrescar el navegador NUNCA borra un proto guardado ni la bitacora. SPACE_REPO = "enfoquealafamilia/enfoque-proto-chat" DATA_REPO = "enfoquealafamilia/enfoque-proto-chat-data-kb" BITACORA_NAME = "bitacora.jsonl" HF_TOKEN = os.getenv("HF_TOKEN", "") st.set_page_config(page_title=APP_TITLE, page_icon="\U0001F4AC", layout="wide") # -- Tema Focus: modo oscuro (azul Focus) opcional via toggle del sidebar ------ # El modo CLARO (blanco) es el default y lo define .streamlit/config.toml. # Fuente de los colores: design/focus-brand-claude-design/tokens/tokens.css # Modo oscuro Focus: paleta canvas #081C26 / superficie #0C2A39 / elevado #11384D, # texto #F1F1F1, secundario #C5C4CB, borde sutil, links/acento #A2D4CF/#4D99A9. # Cubre TODOS los contenedores y widgets de Streamlit para que no queden parches # blancos (barra superior/inferior, selectbox, botones, inputs) con texto ilegible. _DARK_CSS = """ """ def apply_focus_theme(): """Inyecta el CSS del modo oscuro (azul Focus) si el toggle esta activo.""" if st.session_state.get("dark_mode"): st.markdown(_DARK_CSS, unsafe_allow_html=True) def _now_str(): return datetime.now().strftime("%Y-%m-%d %H:%M:%S") # -- Persistencia de sesion (no forzar re-login tras el reinicio del Space) -------------------- # Cada alta/modificacion commitea la KB al repo -> HF reconstruye el Space -> el proceso reinicia # -> session_state se borra -> el rep tenia que re-loguear. Para evitarlo: al loguear se emite un # TOKEN de sesion firmado (HMAC con un secreto del Space) y se guarda en el query-string; al cargar, # si viene un token valido y no vencido, se entra directo al chat SIN pedir credenciales otra vez. # Solo entra directo quien ya se autentico antes (el token solo se emite tras un login real). # FAIL-OPEN: si el token falta/está mal/no hay secreto, se cae al login normal — nunca bloquea. _SESSION_HOURS = 12 def _auth_secret(): # Clave de firma: un secreto que solo vive en el Space (no en el repo). HF_TOKEN siempre existe # aqui (self-commit); si no, se usa el propio HF_SPACES_AUTH como material de clave. return os.environ.get("HF_TOKEN", "") or os.environ.get("HF_SPACES_AUTH", "") def _make_session_token(user): exp = int(time.time()) + _SESSION_HOURS * 3600 msg = f"{user}.{exp}" sig = hmac.new(_auth_secret().encode(), msg.encode(), hashlib.sha256).hexdigest()[:16] return f"{msg}.{sig}" def _valid_session_token(tok): """Devuelve el usuario si el token es valido y no vencido; si no, None (fail-open).""" try: user, exp, sig = tok.rsplit(".", 2) if int(exp) < time.time(): return None good = hmac.new(_auth_secret().encode(), f"{user}.{exp}".encode(), hashlib.sha256).hexdigest()[:16] return user if hmac.compare_digest(good, sig) else None except Exception: return None # -- Token del NOMBRE del rep (v1.5.1) -------------------------------------------------------- # El nombre se teclea en la barra lateral DESPUES del login (no en el login), y una recarga dura # borra session_state -> el rep re-teclea su nombre. Para evitarlo: cuando el rep fija su nombre, # se emite un token firmado (b64.hmac) en el query-string `?n=`; al recargar, se rehidrata el # nombre en session_state ANTES de que se dibuje el campo. Firmado con el mismo secreto para que la # URL no se pueda falsear. FAIL-OPEN: si `?n=` falta/está mal/no hay secreto, se pide el nombre como # antes. El nombre NO es sensible (el rep lo declara libremente y su email ya viaja en el token `s`). def _make_name_token(name): b = base64.urlsafe_b64encode(name.encode()).decode().rstrip("=") sig = hmac.new(_auth_secret().encode(), b.encode(), hashlib.sha256).hexdigest()[:16] return f"{b}.{sig}" def _valid_name_token(tok): """Devuelve el nombre si el token valida; si no, None (fail-open).""" try: b, sig = tok.rsplit(".", 1) good = hmac.new(_auth_secret().encode(), b.encode(), hashlib.sha256).hexdigest()[:16] if not hmac.compare_digest(good, sig): return None pad = "=" * (-len(b) % 4) name = base64.urlsafe_b64decode((b + pad).encode()).decode() return name or None except Exception: return None def _restore_name(): """Rehidrata rep_name desde `?n=` si aun no esta seteado (tras una recarga dura). Fail-open; corre solo estando ya autenticado (despues de _check_auth).""" try: if st.session_state.get("rep_name"): return tok = st.query_params.get("n") if tok and _auth_secret(): name = _valid_name_token(tok) if name: st.session_state["rep_name"] = name except Exception: pass def _persist_name(): """on_change del campo de nombre: emite/actualiza el token `?n=` cuando el rep fija su nombre (>= 2 palabras). Si lo vacia, limpia el param. Fail-open.""" try: if not _auth_secret(): return name = st.session_state.get("rep_name", "").strip() if len(name.split()) >= 2: st.query_params["n"] = _make_name_token(name) elif "n" in st.query_params: del st.query_params["n"] except Exception: pass def _check_auth(): auth_config = os.environ.get("HF_SPACES_AUTH", "") if not auth_config: return pairs = {} for pair in auth_config.split(","): if ":" in pair: u, p = pair.split(":", 1) pairs[u.strip()] = p.strip() if not pairs: return if st.session_state.get("_authenticated"): return # Auto-auth por token de sesion (sobrevive el reinicio del Space). Fail-open. try: tok = st.query_params.get("s") if tok and _auth_secret(): u = _valid_session_token(tok) if u and u in pairs: st.session_state["_authenticated"] = True return except Exception: pass st.markdown(f"### {APP_TITLE}") usuario = st.text_input("Usuario") clave = st.text_input("Contraseña", type="password") if st.button("Ingresar"): if pairs.get(usuario) == clave: st.session_state["_authenticated"] = True try: if _auth_secret(): st.query_params["s"] = _make_session_token(usuario) except Exception: pass st.rerun() else: st.error("Credenciales incorrectas.") st.stop() _check_auth() _restore_name() # -- Backend ------------------------------------------------------------------- # Orden = preferencia por defecto (el primero es el DEFAULT y el que opera). La latencia en NVIDIA # NIM free tier NO es estable: OSCILA con la carga/hora (medido el mismo dia: Mistral 32-41s bajo # saturacion vs 9-11s libre; Nano 12-26s vs 12-15s). Como la velocidad es un empate variable, el # desempate es la FIDELIDAD: la funcion central es devolver el texto EXACTO del proto sin parafrasear, # y la familia Mistral es la mas fiel (por eso se descarto qwen: alucinaba). Por eso el DEFAULT es # Mistral Nemotron; Nano queda como alterna rapida (razona internamente, util si Mistral se satura) y # MiniMax de ultimo. La cascada cubre cuando el primero este en racha lenta (salta al siguiente). MODELS = { "mistral-nemotron": { "id": "mistralai/mistral-nemotron", "label": "Mistral Nemotron (recomendado, fiel al texto)", "env_key": "NVIDIA_KEY_MAVERICK", "max_tokens": 2000, }, "nemotron-nano": { "id": "nvidia/nvidia-nemotron-nano-9b-v2", "label": "Nemotron Nano (alterna rápida)", "env_key": "NVIDIA_KEY_MAVERICK", "max_tokens": 3000, }, "minimax": { "id": "minimaxai/minimax-m3", "label": "MiniMax M3 (razonamiento, el más lento)", "env_key": "NVIDIA_KEY_MINIMAX", "max_tokens": 8192, }, } # Cascada de resiliencia: cada intento tiene su propio timeout. Si un modelo se satura/cuelga, # se avisa y se pasa al siguiente vivo. Si TODOS fallan -> "SERVIDOR SATURADO" + alerta real. # El owner pidio 25/40/60, pero con la base completa (~24k tokens) y el free tier saturado el # modelo rapido llega a ~25-30s estando SANO; 25s lo cortaria de mas. 30/45/60 mantiene el # failover rapido sin matar respuestas sanas. Tunable (subir si el proveedor sigue lento). _CASCADE_TIMEOUTS = [30, 45, 60] def _available_models(): return [k for k, v in MODELS.items() if os.getenv(v.get("env_key") or "")] def _fallback_order(): """Modelo elegido primero, luego el resto en el orden de preferencia de MODELS.""" avail = _available_models() chosen = (st.session_state.get("model_choice") or os.getenv("NVIDIA_MODEL", "") or next(iter(MODELS))).lower() order, seen = [], set() for k in [chosen] + list(MODELS.keys()): if k in avail and k not in seen: order.append(k); seen.add(k) return order def _classify_err(label, e): """Traduce una excepcion a un motivo legible que distingue CAIDO (410) de SATURADO (timeout).""" code = getattr(e, "status_code", None) name = type(e).__name__ if code == 410: return f"{label}: caído (410 Gone — modelo dado de baja en NVIDIA)" if "Timeout" in name: return f"{label}: saturado (no respondió a tiempo)" if code: return f"{label}: error HTTP {code}" return f"{label}: error ({name})" def _try_model(model_key, messages, timeout_s): """Un intento contra un modelo con timeout. Devuelve (contenido | None, error_str | None).""" cfg = MODELS[model_key] client = OpenAI( base_url="https://integrate.api.nvidia.com/v1", api_key=os.getenv(cfg["env_key"], ""), timeout=timeout_s, max_retries=0, ) try: resp = client.chat.completions.create( model=cfg["id"], messages=messages, temperature=0.1, max_tokens=cfg["max_tokens"], ) content = (resp.choices[0].message.content or "").strip() if content: return content, None return None, f"{cfg['label']}: respuesta vacía" except Exception as e: return None, _classify_err(cfg["label"], e) # --- Alerta al administrador cuando TODOS los modelos fallan ------------------ # MULTI-CANAL: se dispara por CADA canal que este configurado via secrets del Space. # 1) Telegram (preferido): secrets TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID -> push al celular via # la API de Telegram (gratis, auth real: solo el chat_id recibe). Canal principal. # 2) ntfy.sh (redundante/legacy): secret NTFY_TOPIC -> push anonimo. El EMAIL de ntfy, desde # 2026-08, ya NO se permite anonimo (error 40053): solo se adjunta el header Email si existe # tambien NTFY_TOKEN (access token de una cuenta ntfy). Sin token, va solo el push. # alert_admin devuelve True si AL MENOS un canal salio; si no hay ninguno configurado -> False # (el mensaje al rep entonces dice "avisale a Esteban", no miente diciendo que ya se alerto). _last_alert_ts = 0.0 # throttle simple por instancia (se reinicia con el Space) def _channels_configured(): tg = bool(os.getenv("TELEGRAM_BOT_TOKEN", "").strip() and os.getenv("TELEGRAM_CHAT_ID", "").strip()) ntfy = bool(os.getenv("NTFY_TOPIC", "").strip()) return tg or ntfy def _send_telegram(subject, body): """POST a la API de Telegram. True si salio, False si no hay secrets. Lanza en error de red.""" tok = os.getenv("TELEGRAM_BOT_TOKEN", "").strip() chat = os.getenv("TELEGRAM_CHAT_ID", "").strip() if not (tok and chat): return False import urllib.request data = json.dumps({"chat_id": chat, "text": f"🛑 {subject}\n\n{body}"}).encode("utf-8") req = urllib.request.Request( f"https://api.telegram.org/bot{tok}/sendMessage", data=data, headers={"Content-Type": "application/json"}, method="POST", ) urllib.request.urlopen(req, timeout=10) return True def _send_ntfy(subject, body): """POST a ntfy.sh. True si salio, False si no hay topic. Lanza en error de red.""" topic = os.getenv("NTFY_TOPIC", "").strip() if not topic: return False import urllib.request headers = {"Title": subject, "Priority": "urgent", "Tags": "rotating_light"} token = os.getenv("NTFY_TOKEN", "").strip() if token: headers["Authorization"] = f"Bearer {token}" email = os.getenv("ALERT_EMAIL", "").strip() if email and token: # email exige cuenta autenticada; sin token se omite (push igual sale) headers["Email"] = email req = urllib.request.Request( f"https://ntfy.sh/{topic}", data=body.encode("utf-8"), headers=headers, method="POST", ) urllib.request.urlopen(req, timeout=10) return True def alert_admin(subject, body): """Dispara la alerta por todos los canales configurados. True si al menos uno salio. Throttle 300s.""" global _last_alert_ts now = time.time() if now - _last_alert_ts < 300: return _channels_configured() # ya se alerto hace poco; se considera alertado si hay canal delivered = False for sender in (_send_telegram, _send_ntfy): try: if sender(subject, body): delivered = True except Exception: pass # un canal caido no debe impedir que el otro salga if delivered: _last_alert_ts = now return delivered # -- HF Hub helpers (persistencia) -------------------------------------------- def _api(): """HfApi autenticado, o None si no hay token (dev local).""" if not HF_TOKEN: return None try: from huggingface_hub import HfApi return HfApi(token=HF_TOKEN) except Exception: return None def commit_kb_to_space(new_kb_text, commit_msg): """Escribe la KB local (recarga inmediata) y la publica al repo del Space.""" KB_PATH.write_text(new_kb_text, encoding="utf-8") api = _api() if api is None: return False, "Sin HF_TOKEN en el Space: el cambio quedo solo en memoria (no publicado)." try: api.upload_file( path_or_fileobj=new_kb_text.encode("utf-8"), path_in_repo=KB_NAME, repo_id=SPACE_REPO, repo_type="space", commit_message=commit_msg, ) return True, "" except Exception as e: return False, f"No se pudo publicar al repo: {e}" def _download_bitacora_text(): api = _api() if api is None: return "" try: from huggingface_hub import hf_hub_download p = hf_hub_download( repo_id=DATA_REPO, repo_type="dataset", filename=BITACORA_NAME, token=HF_TOKEN, force_download=True, ) return Path(p).read_text(encoding="utf-8") except Exception: return "" def append_bitacora(entry): entry["timestamp"] = _now_str() api = _api() if api is None: return try: text = _download_bitacora_text() text += json.dumps(entry, ensure_ascii=False) + "\n" api.upload_file( path_or_fileobj=text.encode("utf-8"), path_in_repo=BITACORA_NAME, repo_id=DATA_REPO, repo_type="dataset", commit_message=f"bitacora: {entry.get('action','')} {entry.get('proto_id','')}", ) except Exception: pass @st.cache_data(ttl=30, show_spinner=False) def load_bitacora_entries(): text = _download_bitacora_text() out = [] for line in text.strip().split("\n"): line = line.strip() if not line: continue try: out.append(json.loads(line)) except Exception: continue return out def refresh_kb_from_repo(): """Trae la ultima KB del repo del Space a KB_PATH (para ver cambios de otros reps).""" try: from huggingface_hub import hf_hub_download p = hf_hub_download( repo_id=SPACE_REPO, repo_type="space", filename=KB_NAME, token=HF_TOKEN or None, force_download=True, ) KB_PATH.write_text(Path(p).read_text(encoding="utf-8"), encoding="utf-8") return True except Exception: return False # -- Candado de guardado concurrente ------------------------------------------ # Un solo proceso Streamlit (Space free = 1 replica) => un global basta para # serializar guardados entre sesiones. El timestamp actua como lock con TTL: # si otro rep guardo hace <30s, el guardado se rechaza con aviso. _save_guard = threading.Lock() _saving_until = 0.0 def acquire_save_slot(): global _saving_until with _save_guard: now = time.time() if now < _saving_until: return False _saving_until = now + 30 return True def release_save_slot(): global _saving_until with _save_guard: _saving_until = 0.0 # -- Knowledge base ------------------------------------------------------------ def load_kb() -> str: try: return KB_PATH.read_text(encoding="utf-8") except FileNotFoundError: return "Knowledge base no encontrada." def get_proto_text_from_kb(proto_id: str) -> str: kb = load_kb() pattern = rf"(###\s*{re.escape(proto_id)}\b.*?)(?=\n---|\Z)" m = re.search(pattern, kb, re.DOTALL | re.IGNORECASE) if m: return m.group(1).strip() return f"(No se encontro {proto_id} en la knowledge base)" _FIELD_LABEL = { "prototype_answer": "Respuesta al cliente", "internal_action": "Acción interna", } def set_proto_field_in_kb(kb_text: str, proto_id: str, field: str, new_text: str) -> str: """Reemplaza el cuerpo de un campo dentro del bloque del proto. Devuelve la KB completa.""" label = _FIELD_LABEL.get(field, "Respuesta al cliente") block_pat = re.compile(rf"(###\s*{re.escape(proto_id)}\b.*?)(?=\n---|\Z)", re.DOTALL) bm = block_pat.search(kb_text) if not bm: return kb_text # no encontrado: no tocar block = bm.group(1) field_pat = re.compile( r"(\*\*" + re.escape(label) + r":\*\*)(.*?)(?=\n\*\*[^\n]+:\*\*|\Z)", re.DOTALL, ) replacement_body = " " + new_text.strip() + "\n" if field_pat.search(block): new_block = field_pat.sub(lambda m: m.group(1) + replacement_body, block, count=1) else: # el campo no existia (p.ej. agregar Accion interna): anexar al final del bloque new_block = block.rstrip() + f"\n\n**{label}:** {new_text.strip()}\n" return kb_text[:bm.start(1)] + new_block + kb_text[bm.end(1):] # -- Obsolescencia (soft-delete): nunca se borra ni se vacia, se marca ---------- _OBSOLETE_BANNER = "> ⚠️ **PROTO OBSOLETO — NO USAR CON CLIENTES**" def _extract_block(kb_text: str, proto_id: str) -> str: """Devuelve el bloque markdown completo del proto (o "" si no existe).""" m = re.search(rf"(###\s*{re.escape(proto_id)}\b.*?)(?=\n---|\Z)", kb_text, re.DOTALL | re.IGNORECASE) return m.group(1) if m else "" def is_proto_obsolete(kb_text: str, proto_id: str) -> bool: block = _extract_block(kb_text, proto_id) return bool(block) and "PROTO OBSOLETO" in block def mark_proto_obsolete(kb_text: str, proto_id: str, rep: str, fecha: str): """Inserta el banner OBSOLETO justo bajo el header del proto. (kb, ok).""" if is_proto_obsolete(kb_text, proto_id): return kb_text, False header_pat = re.compile(rf"(###\s*{re.escape(proto_id)}\b[^\n]*\n)", re.IGNORECASE) m = header_pat.search(kb_text) if not m: return kb_text, False tag = f"\n{_OBSOLETE_BANNER} (marcado por {rep}, {fecha})\n" return kb_text[:m.end(1)] + tag + kb_text[m.end(1):], True def unmark_proto_obsolete(kb_text: str, proto_id: str): """Quita la linea del banner OBSOLETO del bloque del proto. (kb, ok).""" block = _extract_block(kb_text, proto_id) if not block: return kb_text, False new_block = re.sub(rf"\n{re.escape(_OBSOLETE_BANNER)}[^\n]*\n", "", block, count=1) if new_block == block: return kb_text, False return kb_text.replace(block, new_block, 1), True def _next_proto_id(kb_text: str, category_n: int) -> str: lo, hi = category_n * 100, category_n * 100 + 99 nums = [int(x) for x in re.findall(r"PROTO-(\d+)", kb_text) if lo <= int(x) <= hi] nn = max(nums) + 1 if nums else lo + 1 return f"PROTO-{nn}" def add_proto_to_kb(kb_text: str, category_n: int, title: str, cuando: str, respuesta: str, accion: str = ""): """Inserta un nuevo bloque de proto al final de su categoria. Devuelve (kb, new_id).""" new_id = _next_proto_id(kb_text, category_n) block = f"### {new_id}: {title.strip()}\n\n**Cuándo usar:** {cuando.strip()}\n\n" if accion and accion.strip(): block += f"**Acción interna:** {accion.strip()}\n\n" if respuesta and respuesta.strip(): block += f"**Respuesta al cliente:** {respuesta.strip()}\n" insert_text = block.rstrip() + "\n\n---\n\n" cat_pat = re.compile(r"^##\s*CATEGOR[IÍ]A\s+(\d+)", re.MULTILINE) cats = [(m.start(), int(m.group(1))) for m in cat_pat.finditer(kb_text)] n_start = None nxt = len(kb_text) for i, (pos, num) in enumerate(cats): if num == category_n: n_start = pos nxt = cats[i + 1][0] if i + 1 < len(cats) else len(kb_text) break if n_start is None: # No inventar categorias: crear una categoria nueva es mantenimiento # deliberado (offline), nunca un efecto colateral de un alta desde el chat. raise ValueError(f"La categoria {category_n} no existe en la knowledge base.") new_kb = kb_text[:nxt] + insert_text + kb_text[nxt:] return new_kb, new_id _CATEGORY_RE = re.compile(r"^##\s*CATEGOR[IÍ]A\s+(\d+)\s*:\s*(.+?)\s*$", re.MULTILINE) def get_categories(kb_text: str): """Devuelve [(numero, nombre)] de las categorias de la KB, en orden de aparicion.""" return [(int(m.group(1)), m.group(2).strip()) for m in _CATEGORY_RE.finditer(kb_text)] def _category_name(kb_text: str, n: int) -> str: for num, name in get_categories(kb_text): if num == n: return name return "(nueva)" # Guia de operacion del bot: la usa el modelo SOLO para responder preguntas del rep sobre # COMO USAR el sistema (no para consultas de clientes). Vive junto al codigo a proposito: # si cambia la UI (botones/modelos), esta guia cambia en el mismo commit y no se desfasa. _OPERATING_GUIDE = """=== GUIA DE OPERACION DEL BOT === (Uso interno: responde con esto SOLO cuando el rep pregunte como OPERAR el bot, no para consultas de clientes. Responde corto, en pasos, y cita solo lo que aplique a su pregunta.) QUE ES ESTE BOT - Es el "Asistente de prototipos" de Enfoque a la Familia. Guarda las respuestas modelo ("prototipos" o "protos") que los reps usan para atender a clientes/constituyentes, y te ayuda a encontrar la correcta cuando pegas el mensaje de un cliente. - Tambien es donde los reps AGREGAN, MODIFICAN y desactivan esos protos, y donde mandan sugerencias/reportes a Esteban Porras. COMO ENTRAR (LOGIN) - Necesitas tu USUARIO y CONTRASENA. Te los da Esteban / el administrador; el bot NO los conoce ni los entrega. Tenlos a la mano antes de abrir el enlace. - Si de repente te vuelve a pedir login, es normal: el sistema se reinicia cuando alguien guarda un proto (ver "POR QUE SE REINICIA"). Vuelve a ingresar; no se pierde nada. LO PRIMERO: TU NOMBRE Y APELLIDO - En la barra lateral escribe tu NOMBRE Y APELLIDO (obligatorio). Sin eso el chat no responde. Sirve para registrar quien hace cada cambio en la bitacora. LOS BOTONES Y CONTROLES - "Tu nombre y apellido (obligatorio)": tu identidad para la bitacora. - "Modelo": elige el motor de IA que responde (ver MODELOS). - "Modo oscuro (azul Focus)": cambia el color de la pantalla (claro/oscuro). Solo estetico. - "Recargar knowledge base": vuelve a leer los protos desde el repositorio, por si otro rep guardo algo y quieres lo mas reciente sin reiniciar. - "Bitacora de cambios" (desplegable): muestra los ultimos cambios (altas, ediciones, obsoletos, reactivados, feedback) con fecha y quien los hizo. AHI VES TUS CAMBIOS y los de todos. - "Limpiar chat / Clear chat" (arriba a la derecha): borra la conversacion EN PANTALLA. NO borra ningun proto ni la bitacora (esos viven en el repositorio, no en el chat). BUSCAR / RESPONDER A UN CLIENTE - Pega el mensaje del cliente (o describe la situacion) y el bot te devuelve el proto que mejor coincide: la "Respuesta al cliente" lista para copiar, y la "Accion interna" si la tiene. - Si hay varios candidatos, muestra 2-3. Si no hay, te lo dice. LOS TRES TIPOS DE PROTOTIPO 1) Solo mensaje al cliente (texto para copiar y enviar). 2) Solo accion interna (pasos que TU debes hacer, ej. en Uscreen, sin mensaje al cliente). 3) Ambos (mensaje al cliente + accion interna). AGREGAR UN PROTOTIPO NUEVO - Dile "agrega un proto para ..." o pega directo el texto que quieres guardar. - El bot NO reescribe tu texto: lo guarda TAL CUAL. Solo lo clasifica en una categoria. - Si no diste el tema, te preguntara la categoria (o escribes el texto y el clasifica). - Al final te muestra tu texto y 3 opciones: 1 = guardar, 2 = corregir el texto, 3 = no enviar. - Para un proto de SOLO accion interna, dilo: "es solo accion interna: ..." y lo guarda sin mensaje al cliente. DESPUES DE GUARDAR - El bot responde "He procedido con tu solicitud" y (en altas) te dice en que categoria lo puso. - Te ofrece: 4 = corregir el texto; "accion" (o 5) = agregar una accion interna (pasos + URLs); "revertir" = deshacer la creacion (lo marca OBSOLETO, reversible). MODIFICAR UN PROTOTIPO EXISTENTE - Escribe "ACTUALIZAR PROTO-XXX" o describe el cambio en lenguaje natural ("el precio ahora es $X", "ya no se reenvia a Ana sino a Laura"). El bot guarda tu texto tal cual, sin reescribirlo. AGREGAR UNA ACCION INTERNA A UN PROTO - Justo despues de guardar: escribe "accion" (o 5). - A cualquier proto existente: escribe "ACCION PROTO-XXX" y luego dicta los pasos y URLs. Sirve para que el proximo rep sepa que debe hacer algo interno (ej. revisar/activar algo en Uscreen). BORRAR / DESACTIVAR UN PROTO - El bot NUNCA borra. Marca el proto como OBSOLETO (reversible, queda para auditoria). Dile "marca obsoleto PROTO-XXX", o "revertir" justo despues de crearlo. Para reactivarlo: "reactivar PROTO-XXX". VER LA LISTA DE PROTOTIPOS / CATEGORIAS - Pideme "cuales categorias hay?" y te las enumero. O "lista los protos de [categoria]" y te doy los de esa categoria. No hay una pantalla-lista aparte; yo te los enumero desde la base. FEEDBACK / IDEAS PARA ESTEBAN - Si tienes una idea de mejora del BOT/sistema, dila ("seria bueno que el bot...", "el chat deberia..."). El bot la resume y te pregunta si la envia a Esteban Porras. Al confirmar, queda registrada. Esto NO es para consultas de clientes. REPORTAR DE INMEDIATO A ESTEBAN (URGENTE) - Si el bot no responde, da errores, o avisa que un modelo esta caido/no disponible: REPORTALO ya por el canal de feedback (o avisa a Esteban directo). No borres ni reintentes en bucle. MODELOS (el "motor" de IA) - Un "modelo" es el motor de inteligencia artificial que lee tu mensaje y arma la respuesta. Se elige en "Modelo" en la barra lateral: - Nemotron Nano (rapido, recomendado y por defecto): el mas veloz con la base completa. Uselo casi siempre. - Mistral Nemotron (fiel, mas lento): muy fiel al texto exacto, pero mas lento; util si el rapido te da algo raro. - MiniMax M3 (razonamiento, el mas lento): el mas "pensante"; solo para casos muy dificiles. - Si un modelo tarda o se satura, el sistema te pasa solo a otro que si responde (veras un aviso). Si un modelo da una respuesta rara o incompleta, prueba otro. Si nada responde ("SERVIDOR SATURADO"), es una caida temporal del proveedor: intenta en unos minutos; el administrador ya queda alertado. POR QUE SE REINICIA EL BOT AL GUARDAR - Cada guardado publica el cambio para TODOS los reps. Publicar reinicia el sistema unos segundos (a veces ~1 min) y puede pedirte login otra vez. Es normal: tu cambio YA quedo guardado. Reingresa y sigue. ENSENAME A USAR EL BOT (para reps nuevos) - Practica copiando y pegando estos mensajes, uno por uno: 1. Escribe tu nombre y apellido en la barra lateral. 2. "que es este bot y para que sirve?" 3. "que hacen los botones de la barra lateral?" 4. Pega un mensaje de cliente de ejemplo: "un cliente pregunta como cancelar su membresia". 5. "como agrego un prototipo nuevo?" 6. "agrega un proto de prueba: (tu texto)" y practica 1 / 2 / 3 (usa 3 para no guardarlo). 7. "como agrego una accion interna a un proto?" 8. "como le mando feedback a Esteban?" 9. "que es un modelo y cual elijo?" - Cuando quieras, pideme cualquiera de estos temas y te lo explico a fondo. """ def build_system_prompt() -> str: kb = load_kb() cat_index = "\n".join(f"- Categoría {n}: {name}" for n, name in get_categories(kb)) base = """Eres enfoque-proto-chat, asistente interno de servicio al cliente de Enfoque a la Familia. Tu funcion es ayudar a los representantes (reps) a manejar las consultas de clientes. El rep te pega el mensaje del cliente (o te describe la situacion) y tu buscas el prototipo correcto en la knowledge base. REGLAS DE RESPUESTA: 1. Busca en la knowledge base el prototipo que mejor coincida con la consulta o mensaje pegado. 2. Devuelve el/los prototipo(s) que coincidan en formato MARKDOWN LEGIBLE, asi (respeta EXACTAMENTE los saltos de linea y las lineas en blanco): #### PROTO-XXX — [Categoria] **Respuesta para el cliente:** > [texto exacto del campo "Respuesta al cliente", listo para copiar y enviar. > Conserva los saltos de parrafo, las vinetas y los enlaces DENTRO del blockquote > (cada linea y cada vineta empieza con "> ").] **Accion interna:** [incluir SOLO si el prototipo la tiene] [texto exacto de la accion interna] IMPORTANTE DE FORMATO: el encabezado del proto va en su PROPIA linea (empieza con "#### "), y SIEMPRE deja una linea en blanco antes y despues de cada bloque en negrita (**Respuesta para el cliente:**, **Accion interna:**). Nunca pegues el encabezado y la etiqueta en el mismo renglon. 3. Si el prototipo no tiene "Accion interna", omite esa seccion completamente. 4. NO refrases, NO resumas, NO inventes texto. El texto de "Respuesta al cliente" debe ser identico al de la knowledge base (el rep lo copia y lo envia tal cual). 5. Si hay 2-3 prototipos candidatos, muestralos todos con ese mismo formato. 6. Si no encuentras coincidencia, di: "No encontre un prototipo para esta consulta. Consulta el SharePoint o pregunta por Teams." 7. Responde siempre en espanol. 7b. CLASIFICACION: clasifica SIEMPRE por la INTENCION DEL CLIENTE (que necesita o pregunta), NUNCA por la herramienta o sistema interno que use el rep. Uscreen, Stripe, Google Play, App Store, Zapier, etc. son HERRAMIENTAS que aparecen en la Accion interna; jamas son categorias ni criterio de clasificacion. 7c. PROTOS OBSOLETOS: un proto marcado "PROTO OBSOLETO" NUNCA se ofrece como respuesta a una consulta de cliente (para busquedas actua como si no existiera). Solo si el rep pregunta EXPLICITAMENTE por ese PROTO-ID, muestralo pero ENCABEZADO por una linea en mayuscula: "⚠️ PROTO OBSOLETO — NO USAR CON CLIENTES". 8. Para actualizar explicitamente: el rep puede escribir ACTUALIZAR PROTO-XXX (la app lo maneja automaticamente). REGLAS DE ACTUALIZACION (lenguaje natural): 9. Si el rep describe un cambio operativo (no una consulta de cliente) -- por ejemplo: "ya no se le mandan correos a Georgina sino a Roselyn", "el precio de la membresia cambio a $X", "el nuevo correo de soporte es X@enfoque.com" -- entonces: a) Identifica cual(es) prototipo(s) afecta ese cambio. b) Si no tienes certeza (ej: no sabes el nuevo precio), PRIMERO preguntale al rep el dato especifico que falta. NO propongas actualizacion hasta tenerlo. c) Cuando tengas toda la informacion, aplica el cambio al texto COMPLETO del prototipo (no solo el fragmento). Si el rep te dio el texto nuevo ya escrito, usalo EXACTAMENTE como lo escribio (verbatim): NO lo reescribas ni le cambies el estilo, no propongas una version mejorada, solo tomalo tal cual. Al FINAL de tu respuesta anade este bloque EXACTO, sin modificarlo ni agregar texto dentro: %%ACTUALIZAR%% {"proto_id": "PROTO-XXX", "new_text": "texto completo actualizado de la Respuesta al cliente"} %%/ACTUALIZAR%% El "new_text" es el texto completo de la Respuesta al cliente actualizada. Si el cambio afecta la Accion interna (no la Respuesta al cliente), igualmente incluye el bloque con el campo "field": "internal_action": %%ACTUALIZAR%% {"proto_id": "PROTO-XXX", "field": "internal_action", "new_text": "accion interna actualizada"} %%/ACTUALIZAR%% REGLAS DE ALTA DE PROTOTIPO (lenguaje natural): 10. Si el rep pide AGREGAR un prototipo nuevo (ej: "agrega un proto para X", "crea un prototipo que responda Y"): a0) Un prototipo puede ser de TRES tipos: (1) SOLO mensaje al cliente; (2) SOLO accion interna (pasos que el rep debe hacer, ej. en Uscreen, sin mensaje al cliente); (3) AMBOS. Si el rep deja claro que es solo accion interna (ej. "es solo accion interna:", "no lleva respuesta al cliente, solo estos pasos: ..."), deja "respuesta" VACIA y pon el contenido en "accion". Si es solo mensaje al cliente, deja "accion" vacia. Nunca inventes un mensaje al cliente para rellenar un proto que el rep dijo que es solo interno. a) Hay DOS casos segun si el rep ya dio contenido o no: CASO 1 -- el rep NO dio ninguna informacion sobre el tema/contenido del proto (solo dijo algo como "quiero agregar un proto nuevo", "agrega un proto", sin describir de que trata ni dar texto): en ese caso SI le preguntas la categoria, mostrando SIEMPRE LA LISTA NUMERADA COMPLETA de TODAS las categorias del INDICE DE CATEGORIAS por NOMBRE (no un subconjunto, no numeros pelados sin nombre), con este patron aproximado (adaptalo a tu redaccion, no lo copies letra por letra pero conserva la estructura y el cierre): "¿De qué categoría sería este nuevo prototipo? Las categorías disponibles son:" [lista numerada 1..N con el nombre de cada categoria del indice] "Por favor, indícame a cuál pertenece según la descripción del prototipo que deseas agregar. O si gustas, escribes el prototipo tal como quieres que quede y yo solo lo clasifico en su categoría (no cambio tu texto)." Si el rep responde con el numero de la lista o con el nombre/tema, ambos son validos -- tu traduces al numero de categoria correcto del indice. NO inventes categoria; si el tema no encaja en ninguna, di que no encuentras una categoria adecuada y sugiere consultar por Teams. CASO 2 -- el rep YA dio informacion sobre el tema, contenido, o el texto deseado del proto (describio de que se trata, o pego/dicto el texto de la respuesta, sea en el mensaje inicial o como respuesta a la oferta del CASO 1): en este caso NO le preguntes la categoria ni le muestres ninguna lista. Inferila TU MISMO segun ese contenido usando el indice, y pasa directo a redactar/ajustar el texto y presentar la propuesta de (b)/(c) para su aprobacion, modificacion o rechazo. b) USA EL TEXTO DEL REP EXACTAMENTE COMO LO ESCRIBIO (verbatim). NO lo reescribas, NO lo resumas, NO le agregues saludos, firmas ni cierres, NO le cambies el estilo ni la redaccion, NO propongas una "version mejorada". Tu UNICA tarea con el contenido es clasificarlo en una categoria que ya existe; el texto del rep pasa intacto al campo "respuesta" del bloque, tal cual lo escribio. Si el rep no dio ningun texto (solo el tema), pidele que escriba el texto del proto -- no lo redactes tu. IMPORTANTE: alta de prototipo es SIEMPRE una PROPUESTA pendiente de aprobacion, nunca un hecho consumado. Sin importar como llegaste a la categoria, el resultado final de este flujo SIEMPRE debe pasar por el bloque de confirmacion de (c) -- JAMAS respondas como si el prototipo ya existiera o ya estuviera guardado, y JAMAS inventes ni muestres tu un numero de PROTO-XXX en el texto visible (el sistema asigna el numero real solo tras la confirmacion del rep). No uses el formato de "prototipo encontrado en la KB" (regla 2) para una alta nueva. c) NO escribas prosa antes del bloque (nada de "aqui esta tu propuesta", ni precedentes, ni comentarios): la app le muestra al rep su propio texto y las opciones de confirmacion. Al FINAL de tu respuesta anade SOLO este bloque EXACTO (el sistema asigna el numero de proto automaticamente, no lo inventes tu): %%NUEVO_PROTO%% {"categoria": 8, "title": "titulo corto del proto", "cuando": "cuando usar este proto", "respuesta": "texto del rep TAL CUAL, sin reescribir", "accion": "accion interna opcional"} %%/NUEVO_PROTO%% "respuesta" y "accion" son cada uno opcional, pero AL MENOS UNO debe tener contenido (un proto puede ser solo respuesta, solo accion, o ambos). Ambos van con el texto del rep TAL CUAL. "categoria" DEBE ser un numero que YA existe en el INDICE DE CATEGORIAS (no inventes numeros nuevos). REGLAS DE FEEDBACK AL BOT (mejoras del sistema, NO consultas de cliente): 11. Si el rep propone una MEJORA o CAMBIO al bot / al dashboard / al asistente mismo (no una consulta de cliente ni una edicion de proto) -- ej: "seria bueno que el bot...", "el chat deberia mostrar...", "me gustaria que el asistente..." -- resume su idea y anade al final este bloque EXACTO (la app se encarga de pedirle la confirmacion de envio a Esteban, tu NO la pidas): %%FEEDBACK%% {"texto": "resumen claro y completo de la sugerencia del rep, en sus propias palabras"} %%/FEEDBACK%% OJO -- que sea feedback exige que hable del BOT/ASISTENTE/DASHBOARD. Una instruccion sobre COMO MANEJAR un tipo de correo o caso de cliente ("cuando llegue un correo de X, reenviarlo a Y", "si preguntan por Z, responder W") es una PROPUESTA DE PROTOTIPO (regla 10), NO feedback -- aunque no traiga "Respuesta al cliente" (un proto interno de reenvio es valido sin respuesta). REGLA DE DESAMBIGUACION (solo ante duda): 12. Si NO tienes claro si el rep esta (a) creando o modificando un prototipo, o (b) dando feedback sobre el bot para Esteban, NO adivines ni emitas ningun bloque: PREGUNTALE con una sola linea, p.ej.: "¿Esto es un feedback que deseas enviar a Esteban, o es un prototipo nuevo / una modificacion a uno existente?" Segun su respuesta, sigue la regla 10 (alta), las reglas de ACTUALIZACION, o la regla 11 (feedback). Si la intencion YA es clara por su mensaje, NO preguntes: rutea directo (la pregunta es solo el desempate, no un paso fijo). REGLAS DE AYUDA / OPERACION DEL BOT: 13. Distingue SIEMPRE si el rep te consulta (a) por un CLIENTE/constituyente (quiere el proto para responderle a alguien o resolver su caso), o (b) por SI MISMO: como OPERAR este bot (que es un modelo, donde ve la bitacora, que hacen los botones, como guarda/modifica un proto, como manda feedback, por que se reinicia, como entra, que reportar, etc.). Senales de (b): empieza con "como...", "donde...", "que es...", "que son...", "para que sirve", "no entiendo", "no se como", "ensename", "quiero aprender", "soy nuevo", o pregunta por algo de la PANTALLA (botones, modelo, login, categorias). Cuando sea (b), responde con la GUIA DE OPERACION (mas abajo en este prompt), en lenguaje simple y en pasos, SIN emitir ningun bloque %%...%% y SIN buscar un proto de cliente. Amplia con gusto: si el rep pregunta algo operativo que la guia cubre parcialmente, combina las secciones relevantes y responde completo. Si de verdad NO distingues (a) de (b), pregunta UNA linea: "¿Tu pregunta es sobre como usar el bot, o es la consulta de un cliente que debo ayudarte a responder?". 14. Si el rep pide "ensename a usar el bot", "quiero aprender a usarlo", "soy nuevo" o similar, dale el ONBOARDING de la GUIA: la lista corta de pasos y prompts de practica que puede copiar y pegar, y ofrecele profundizar en cualquiera. No lo abrumes: primero los pasos, luego el detalle que pida. """ base += ( "\n--- INDICE DE CATEGORIAS ---\n" "(Para clasificar altas de proto. Clasifica por TEMA; nunca le pidas al rep un " "numero de categoria.)\n" f"{cat_index}\n" ) base += "\n" + _OPERATING_GUIDE base += f"\n--- KNOWLEDGE BASE ---\n{kb}" return base # -- Parsing de propuestas del LLM -------------------------------------------- UPDATE_MARKER_RE = re.compile(r"%%ACTUALIZAR%%\s*(\{.*?\})\s*%%/ACTUALIZAR%%", re.DOTALL) NEW_MARKER_RE = re.compile(r"%%NUEVO_PROTO%%\s*(\{.*?\})\s*%%/NUEVO_PROTO%%", re.DOTALL) FEEDBACK_MARKER_RE = re.compile(r"%%FEEDBACK%%\s*(\{.*?\})\s*%%/FEEDBACK%%", re.DOTALL) PROTO_CMD_RE = re.compile(r"actualizar\s+(?:proto[-\s]?)?(\d{3,4})", re.IGNORECASE) # "ACCION PROTO-XXX" / "accion 305" / "acción proto 305": agregar/editar la accion interna. ACCION_CMD_RE = re.compile(r"\bacci[oóOÓ]n\s+(?:proto[-\s]?)?(\d{3,4})\b", re.IGNORECASE) # Intencion de borrado/obsolescencia/vaciado (el bot NUNCA borra: rutea a soft-delete). DELETE_CMD_RE = re.compile( r"\b(elimin\w*|borra\w*|quita\w*|vacia\w*|vac[ií]a\w*|obsolet\w*|desactiv\w*|descontinu\w*)\b", re.IGNORECASE, ) REVERT_CMD_RE = re.compile(r"\b(reviert\w*|revert\w*|reactiv\w*|restaura\w*)\b", re.IGNORECASE) BULK_CMD_RE = re.compile(r"\b(todos|todas|varios|varias|multiples|m[uú]ltiples)\b", re.IGNORECASE) PROTO_NUM_RE = re.compile(r"\bPROTO[-\s]?(\d{3,4})\b", re.IGNORECASE) _PROTO_ID_RE = re.compile(r"^PROTO-\d{3,4}$") def parse_update_marker(reply: str): if not reply: return None m = UPDATE_MARKER_RE.search(reply) if not m: return None try: data = json.loads(m.group(1)) except json.JSONDecodeError: return None if not _PROTO_ID_RE.match(data.get("proto_id", "")): return None if len(data.get("new_text", "")) > 8000: return None return data def parse_new_marker(reply: str): if not reply: return None m = NEW_MARKER_RE.search(reply) if not m: return None try: data = json.loads(m.group(1)) except json.JSONDecodeError: return None try: data["categoria"] = int(data.get("categoria")) except (TypeError, ValueError): return None # No inventar categorias: debe existir en el indice actual de la KB. valid_cats = {n for n, _ in get_categories(load_kb())} if data["categoria"] not in valid_cats: return None if not data.get("title"): return None resp = (data.get("respuesta") or "").strip() acc = (data.get("accion") or "").strip() # Un proto puede ser solo-respuesta, solo-accion, o ambos; pero necesita al menos uno. if not resp and not acc: return None if len(resp) > 8000 or len(acc) > 8000: return None return data def parse_feedback_marker(reply: str): if not reply: return None m = FEEDBACK_MARKER_RE.search(reply) if not m: return None try: data = json.loads(m.group(1)) except json.JSONDecodeError: return None texto = (data.get("texto") or "").strip() if not texto or len(texto) > 4000: return None return {"texto": texto} def strip_markers(reply: str) -> str: reply = UPDATE_MARKER_RE.sub("", reply) reply = NEW_MARKER_RE.sub("", reply) reply = FEEDBACK_MARKER_RE.sub("", reply) return reply.strip() # -- Guardado ------------------------------------------------------------------ _RESTART_NOTICE = ( "\n\n---\n" "🔄 **El sistema se va a reiniciar en unos segundos** (a veces hasta ~1 minuto) para " "guardar este cambio en la memoria y la bitácora, y actualizar la base de conocimiento " "para **todos los representantes**.\n\n" "Si la página te pide iniciar sesión de nuevo, es completamente normal: tu cambio " "**ya quedó guardado**. Solo vuelve a ingresar y sigue trabajando." ) def _do_save(pu, rep): """Ejecuta el guardado (commit KB + bitacora). Devuelve (mensaje, proto_id). (None, None) si esta ocupado (otro guardado en curso); (mensaje_error, None) si fallo. El mensaje de exito es el que pidieron los reps: "He procedido con tu solicitud." (+ categoria si es alta) + oferta de correccion (4). """ if not acquire_save_slot(): return None, None try: kb = load_kb() mode = pu.get("mode", "update") if mode == "new": new_kb, proto_id = add_proto_to_kb( kb, pu["categoria"], pu["title"], pu.get("cuando", ""), pu["new_text"], pu.get("accion", ""), ) field = "prototype_answer" ok, err = commit_kb_to_space(new_kb, f"proto nuevo: {proto_id} por {rep}") cat_name = _category_name(new_kb, pu["categoria"]) msg = ( "He procedido con tu solicitud.\n\n" f"He categorizado esto dentro de la categoría **{cat_name}**. " "Gracias por tu registro.\n\n" "- Si cometiste un error y quieres **corregir el texto**, escribe **4**.\n" "- Si deseas **agregar una acción interna** (pasos y URLs que el rep debe hacer, " "ej. en Uscreen), escribe **acción** (o **5**).\n" "- Si deseas **deshacer** la creación de este prototipo, escribe **revertir** " "(lo marca como obsoleto, es reversible)." ) else: proto_id = pu["proto_id"] field = pu.get("field", "prototype_answer") new_kb = set_proto_field_in_kb(kb, proto_id, field, pu["new_text"]) if new_kb == kb: return f"No pude localizar {proto_id} en la knowledge base. No se guardó nada.", None ok, err = commit_kb_to_space(new_kb, f"proto: {proto_id} por {rep}") msg = ( "He procedido con tu solicitud.\n\n" "- Si cometiste un error y quieres **corregir**, escribe **4**.\n" "- Si deseas **agregar una acción interna** (pasos y URLs), escribe **acción** " "(o **5**)." ) append_bitacora({ "action": mode, "proto_id": proto_id, "field": field, "rep": rep or "Anonimo", "new_value": (pu.get("new_text") or pu.get("accion") or "")[:4000], }) st.session_state.system_prompt = build_system_prompt() load_bitacora_entries.clear() if ok: msg += _RESTART_NOTICE else: msg += f"\n\n(Advertencia: {err})" return msg, proto_id except Exception as e: return f"Error al guardar: {e}", None finally: release_save_slot() def _do_obsolete(proto_id, rep, note): """Marca un proto como OBSOLETO (NUNCA borra ni vacia). Snapshot original -> bitacora.""" if not acquire_save_slot(): return None try: kb = load_kb() if not _extract_block(kb, proto_id): return f"No encontré **{proto_id}** en la knowledge base. No se marcó nada." if is_proto_obsolete(kb, proto_id): return f"**{proto_id}** ya estaba marcado como OBSOLETO." original = _extract_block(kb, proto_id).strip() new_kb, ok_mark = mark_proto_obsolete(kb, proto_id, rep, _now_str()) if not ok_mark: return f"No pude marcar **{proto_id}**. No se cambió nada." ok, err = commit_kb_to_space(new_kb, f"proto OBSOLETO: {proto_id} por {rep}") append_bitacora({ "action": "obsolete", "proto_id": proto_id, "field": "estado", "rep": rep or "Anonimo", "note": note or "el rep pidio eliminar/vaciar el proto; se marco como OBSOLETO", "original_value": ("proto original era: " + original)[:6000], }) st.session_state.system_prompt = build_system_prompt() load_bitacora_entries.clear() st.session_state.pending_revert = proto_id # oferta de revert de un paso msg = ( f"El proto **{proto_id}** se ha marcado como **OBSOLETO** (no se borró: sigue en la " f"base para auditoría, pero ya no se ofrecerá a los clientes).\n\n" f"Si deseas revertir esta acción escribe: **REVERTIR** o escribe **1**." ) if ok: msg += _RESTART_NOTICE else: msg += f"\n\n(Advertencia: {err})" return msg except Exception as e: return f"Error al marcar obsoleto: {e}" finally: release_save_slot() def _do_revert(proto_id, rep): """Reactiva un proto obsoleto (quita el banner). Registra el revert sin borrar el log.""" if not acquire_save_slot(): return None try: kb = load_kb() if not _extract_block(kb, proto_id): return f"No encontré **{proto_id}** en la knowledge base." if not is_proto_obsolete(kb, proto_id): return f"**{proto_id}** ya está activo (no estaba marcado como obsoleto)." new_kb, ok_unmark = unmark_proto_obsolete(kb, proto_id) if not ok_unmark: return f"No pude revertir **{proto_id}**." ok, err = commit_kb_to_space(new_kb, f"proto REACTIVADO: {proto_id} por {rep}") append_bitacora({ "action": "revert", "proto_id": proto_id, "field": "estado", "rep": rep or "Anonimo", "note": "revertido a activo", }) st.session_state.system_prompt = build_system_prompt() load_bitacora_entries.clear() st.session_state.pending_revert = None msg = f"El **{proto_id}** ha sido reactivado." if ok: msg += _RESTART_NOTICE else: msg += f"\n\n(Advertencia: {err})" return msg except Exception as e: return f"Error al revertir: {e}" finally: release_save_slot() def _do_feedback(texto, rep): """Registra feedback del rep en la bitacora (no toca la KB, no reinicia el Space).""" append_bitacora({ "action": "feedback", "proto_id": "", "field": "feedback", "rep": rep or "Anonimo", "feedback_text": texto[:4000], }) load_bitacora_entries.clear() return ( f"¡Gracias, {rep}! Tu sugerencia fue enviada a **Esteban Porras** y quedó registrada en " "la bitácora. La revisará en la próxima ronda de mejoras del bot." ) # -- Flujo de actualizacion (maquina de estados) ------------------------------ # Aceptamos variantes, typos comunes y el atajo numerico "1"/"2". _norm() ya quita # acentos y pasa a minusculas, asi que "SI", "Sí" y "si" caen todos en "si". _YES = { "si", "s", "sii", "sip", "siii", "siu", "simon", "sisi", "yes", "ok", "oka", "okay", "okey", "dale", "listo", "va", "confirmar", "confirmo", "confirmado", "correcto", "adelante", "claro", "afirmativo", "guardar", "guarda", "aprobar", "apruebo", "aprobado", "y", "1", "\U0001F44D", # 👍 } _NO = { "no", "nop", "nel", "nou", "cancelar", "cancela", "cancel", "descartar", "detener", "negativo", "nada", "2", "\U0001F44E", # 👎 } def _norm(s: str) -> str: s = unicodedata.normalize("NFKD", (s or "").strip().lower()) return "".join(c for c in s if not unicodedata.combining(c)) def _quote(text: str) -> str: """Convierte texto en un blockquote markdown (se ve en recuadro y ajusta lineas).""" lines = (text or "").rstrip().splitlines() or [""] return "\n".join(("> " + l) if l.strip() else ">" for l in lines) def _is_emptying(text: str) -> bool: """Un new_text vacio o casi vacio = intento de vaciar el proto (se rutea a soft-delete).""" return len((text or "").strip()) < 15 # Opciones de confirmacion pedidas por los reps: el bot NO propone cambios, solo guarda # el proto tal como lo escribio el rep. 1 = guardar, 2 = corregir el texto, 3 = no enviar. _PROTO_CONFIRM_OPTS = ( "Confirma que quieres crear o modificar el proto que escribiste arriba escribiendo: **1**\n\n" "Si deseas modificar el texto que enviaste porque tenía un error escribe el: **2**\n\n" "Si no deseas enviar un nuevo proto o modificarlo escribe: **3**" ) # Texto del ciclo de RE-confirmacion (tras reescribir con la opcion 2 o la 4): el rep # puede seguir modificando cuantas veces quiera antes de guardar el registro final. _PROTO_RECONFIRM_OPTS = ( "Si deseas guardar este prototipo, escribe **1**. " "Si deseas modificarlo, escribe **2**. " "Si no deseas enviarlo, escribe **3**." ) def _proto_confirm_message(pu, reconfirm: bool = False) -> str: """Muestra al rep SU PROPIO texto (verbatim) + las 3 opciones. Sin propuestas del bot. reconfirm=True usa el texto del ciclo repetido (tras reescribir), que habla de "este prototipo" en vez de "el proto que escribiste arriba". """ if pu.get("mode") == "new": kb_now = load_kb() preview_id = _next_proto_id(kb_now, pu["categoria"]) cat_name = _category_name(kb_now, pu["categoria"]) accion = pu.get("accion", "") or "" respuesta = pu.get("new_text", "") or "" accion_md = ( f"**Acción interna (tal como la escribiste):**\n\n{_quote(accion)}\n\n" if accion.strip() else "" ) respuesta_md = ( f"**Respuesta al cliente (tal como la escribiste):**\n\n{_quote(respuesta)}\n\n" if respuesta.strip() else "" ) cuando_md = f"**Cuándo usar:** {pu.get('cuando','')}\n\n" if pu.get("cuando") else "" tipo = ("solo acción interna" if not respuesta.strip() else "solo mensaje al cliente" if not accion.strip() else "mensaje al cliente + acción interna") head = ( f"#### Nuevo prototipo — se guardará como {preview_id}\n\n" f"**Categoría:** {pu['categoria']} — {cat_name}\n\n" f"**Título:** {pu.get('title','')}\n\n" f"**Tipo:** {tipo}\n\n" f"{cuando_md}{respuesta_md}{accion_md}" ) else: field = pu.get("field", "prototype_answer") field_label = "Acción interna" if field == "internal_action" else "Respuesta al cliente" head = ( f"### Modificación de {pu['proto_id']} ({field_label})\n\n" f"Reemplazará el texto actual con lo que escribiste:\n\n{_quote(pu['new_text'])}\n\n" ) return head + (_PROTO_RECONFIRM_OPTS if reconfirm else _PROTO_CONFIRM_OPTS) def handle_delete_flow(user_msg: str, rep: str): """El bot NUNCA borra: rutea a soft-delete (OBSOLETO). Uno a la vez, con PROTO-ID exacto.""" pd = st.session_state.get("pending_delete") if pd: norm = _norm(user_msg).split() first = norm[0] if norm else "" if first in _NO: st.session_state.pending_delete = None return "Operación cancelada. El proto **NO** se marcó como obsoleto." target = pd["proto_id"] target_num = target.split("-")[1] nums = PROTO_NUM_RE.findall(user_msg) or re.findall(r"\b(\d{3,4})\b", user_msg) if any(n == target_num for n in nums): st.session_state.pending_delete = None return _do_obsolete(target, rep, pd.get("note", "")) or ( "⏳ Otro representante está guardando un cambio. Espera ~30s e inténtalo de nuevo." ) return ( f"Para confirmar, **teclea el número de proto que aparece [{target_num}]** " f"para marcarlo como obsoleto, o escribe **no** para cancelar." ) if not DELETE_CMD_RE.search(user_msg): return None nums = PROTO_NUM_RE.findall(user_msg) or re.findall(r"\b(\d{3,4})\b", user_msg) nums = list(dict.fromkeys(nums)) if BULK_CMD_RE.search(user_msg) or len(nums) > 1: return ( "Por seguridad **solo puedo marcar un proto a la vez**, y **nunca borro** protos " "(solo los marco como OBSOLETOS, y es reversible). Pídemelo **uno por uno**, " "indicando el número exacto (ej. *marca obsoleto PROTO-305*)." ) if not nums: return ( "¿Cuál proto quieres marcar como obsoleto? Dame el **número exacto** " "(ej. *PROTO-305*). Recuerda: no se borra, se marca como OBSOLETO y es reversible." ) proto_id = f"PROTO-{nums[0]}" if not _extract_block(load_kb(), proto_id): return f"No encontré **{proto_id}** en la knowledge base. Verifica el número." st.session_state.pending_delete = {"proto_id": proto_id, "note": user_msg[:300]} return ( f"⚠️ El bot **no borra** protos; lo marcaré como **OBSOLETO** (reversible, queda para " f"auditoría).\n\n**Teclea el número de proto que aparece [{nums[0]}] para confirmar que " f"deseas borrarlo o marcarlo como obsoleto**, o escribe **no** para cancelar." ) def handle_update_flow(user_msg: str): pu = st.session_state.get("pending_update") if not pu: m = PROTO_CMD_RE.search(user_msg) if m: proto_id = f"PROTO-{m.group(1)}" st.session_state.pending_update = { "mode": "update", "proto_id": proto_id, "step": "awaiting_text", "field": "prototype_answer", } return ( f"Vamos a actualizar **{proto_id}**.\n\n" "Pega o escribe el **nuevo texto completo** de la Respuesta al cliente:" ) ma = ACCION_CMD_RE.search(user_msg) if ma: proto_id = f"PROTO-{ma.group(1)}" if not _extract_block(load_kb(), proto_id): return f"No encontré **{proto_id}** en la knowledge base. Verifica el número." st.session_state.pending_update = { "mode": "update", "proto_id": proto_id, "step": "awaiting_text", "field": "internal_action", } return ( f"Vamos a agregar/actualizar la **acción interna** de **{proto_id}**.\n\n" "Escribe los **pasos internos y las URLs** que el rep debe seguir (por ejemplo, qué " "revisar o activar en Uscreen). Se guardarán tal cual:" ) return None if pu["step"] == "awaiting_text": # Acción interna: texto vacío NO obsoleta el proto; solo se reintenta o se cancela. if pu.get("field") == "internal_action": tok = _norm(user_msg).split() if tok and (tok[0] == "3" or tok[0] in _NO): st.session_state.pending_update = None return "Entendido, no se agregó ninguna acción interna." if _is_emptying(user_msg): return ( "Escribe los **pasos y URLs** de la acción interna (o escribe **3** para cancelar)." ) st.session_state.pending_update = {**pu, "new_text": user_msg, "step": "awaiting_confirm"} return _proto_confirm_message(st.session_state.pending_update) if _is_emptying(user_msg): # Texto vacio = intento de vaciar el proto -> rutear a soft-delete. pid = pu["proto_id"] st.session_state.pending_update = None st.session_state.pending_delete = { "proto_id": pid, "note": "el rep dejo el texto vacio en una edicion; se ofrece marcar OBSOLETO", } return ( f"Dejaste el texto vacío para {pid}. El bot **no vacía ni borra** protos; lo puedo " f"marcar como **OBSOLETO** (reversible).\n\n**Teclea el número de proto que aparece " f"[{pid.split('-')[1]}] para confirmar que deseas borrarlo o marcarlo como " f"obsoleto**, o escribe **no** para cancelar." ) st.session_state.pending_update = {**pu, "new_text": user_msg, "step": "awaiting_confirm"} return _proto_confirm_message(st.session_state.pending_update) # El rep eligio "2" (corregir el texto que envio) o la opcion 4 post-guardado: # tomamos su nuevo texto TAL CUAL y volvemos a mostrar las 3 opciones. if pu["step"] == "awaiting_reedit": if _is_emptying(user_msg): return ( "El texto está vacío. Escribe el **texto corregido completo** del " "prototipo, o escribe **3** para cancelar." ) step = "new_proto_confirm" if pu.get("mode") == "new" else "awaiting_confirm" st.session_state.pending_update = {**pu, "new_text": user_msg, "step": step} return _proto_confirm_message(st.session_state.pending_update, reconfirm=True) # Feedback al bot mantiene su confirmacion si/no (no es un proto). if pu["step"] == "feedback_confirm": tokens = _norm(user_msg).split() first = tokens[0] if tokens else "" if first in _YES: rep = st.session_state.get("rep_name", "Anonimo") st.session_state.pending_update = None return _do_feedback(pu.get("texto", ""), rep) if first in _NO: st.session_state.pending_update = None return "Sugerencia descartada. Puedes seguir con otra consulta." return ( "No entendí tu respuesta. Escribe **sí** (o **1**) para enviarla, " "o **no** (o **2**) para descartarla." ) # Confirmacion de alta/modificacion de proto: flujo numerado pedido por los reps. if pu["step"] in ("awaiting_confirm", "llm_proposed", "new_proto_confirm"): tokens = _norm(user_msg).split() first = tokens[0] if tokens else "" # 2 = corregir el texto que envié (lo reenvío verbatim). if first == "2": st.session_state.pending_update = {**pu, "step": "awaiting_reedit"} return "Vuelve a escribir el prototipo tal como deseas que sea guardado:" # 3 (o "no") = no enviar. if first == "3" or (first in _NO and first != "2"): st.session_state.pending_update = None return "Entendido, no se envió ningún prototipo. Puedes seguir con otra consulta." # 1 (o "sí") = guardar el proto tal como lo escribió el rep. if first == "1" or first in _YES: rep = st.session_state.get("rep_name", "Anonimo") with st.spinner("Guardando y publicando el prototipo… (10-30s, no cierres la página)"): result, saved_id = _do_save(pu, rep) if result is None: return ( "⏳ Otro representante está guardando un cambio en este momento. " "Espera ~30 segundos e inténtalo de nuevo escribiendo **1** — tu cambio " "NO se guardó todavía para no chocar con el otro." ) st.session_state.pending_update = None if saved_id: # Que corrige la opcion "4": para un alta solo-accion, la accion; si no, lo editado. if pu.get("mode") == "new": ps_field = ("internal_action" if not (pu.get("new_text", "") or "").strip() and (pu.get("accion", "") or "").strip() else "prototype_answer") else: ps_field = pu.get("field", "prototype_answer") st.session_state.pending_postsave = { "proto_id": saved_id, "mode": pu.get("mode"), "field": ps_field, } return result return ( "No entendí tu respuesta. Escribe **1** para enviar el prototipo tal como lo " "escribiste, **2** si necesitas corregir el texto, o **3** para no enviarlo." ) return None # -- Session state ------------------------------------------------------------- if "messages" not in st.session_state: st.session_state.messages = [] if "system_prompt" not in st.session_state: st.session_state.system_prompt = build_system_prompt() if "pending_update" not in st.session_state: st.session_state.pending_update = None if "pending_delete" not in st.session_state: st.session_state.pending_delete = None if "pending_revert" not in st.session_state: st.session_state.pending_revert = None if "pending_postsave" not in st.session_state: st.session_state.pending_postsave = None if "rep_name" not in st.session_state: st.session_state.rep_name = "" if "dark_mode" not in st.session_state: st.session_state.dark_mode = False # -- Sidebar ------------------------------------------------------------------- with st.sidebar: st.title(APP_TITLE) st.toggle("🌙 Modo oscuro (azul Focus)", key="dark_mode") model_options = [k for k, v in MODELS.items() if os.getenv(v["env_key"])] if not model_options: st.error("No hay API keys configuradas. Agrega NVIDIA_KEY_MAVERICK o NVIDIA_KEY_MINIMAX en los Secrets del Space.") else: st.selectbox( "Modelo", model_options, format_func=lambda x: MODELS[x]["label"], key="model_choice", ) st.divider() st.text_input("Tu nombre y apellido (obligatorio)", key="rep_name", placeholder="Ej: María Rodríguez", on_change=_persist_name) if len(st.session_state.get("rep_name", "").split()) < 2: st.caption("⚠️ Escribe tu **nombre y apellido** para poder usar el asistente.") if st.button("Recargar knowledge base"): refresh_kb_from_repo() st.session_state.system_prompt = build_system_prompt() load_bitacora_entries.clear() st.success("Knowledge base recargada desde el repositorio.") st.divider() st.caption( "**Mini guía de uso del chat**\n\n" "**1. Responder a un cliente:** pega el mensaje del cliente y el bot devuelve la " "respuesta lista para enviar, más los pasos internos si aplica.\n\n" "**2. Agregar un prototipo:** dilo en lenguaje natural — *\"agrega un proto para ...\"* — " "o pega el texto. El bot lo guarda **tal cual** (no lo reescribe) y solo lo clasifica. " "Confirmas con **1**, corriges con **2**, cancelas con **3**.\n\n" "**3. Modificar un prototipo:** describe el cambio (*\"el precio ahora es $X\"*) o escribe " "`ACTUALIZAR PROTO-XXX`.\n\n" "**4. Agregar acción interna** (pasos + URLs, ej. Uscreen): escribe `ACCION PROTO-XXX`, o " "usa la opción **acción** que aparece justo después de guardar.\n\n" "**5. Desactivar un proto:** el bot nunca borra; escribe *\"marca obsoleto PROTO-XXX\"* " "(reversible).\n\n" "**6. Feedback a Esteban:** *\"sería bueno que el bot...\"* y el bot lo envía tras tu OK.\n\n" "**7. ¿Nuevo?** escribe **\"enséñame a usar el bot\"** y te guío paso a paso.\n\n" "*La bitácora de abajo muestra todos los cambios (quién y cuándo).*" ) st.divider() with st.expander("Bitacora de cambios"): _ACTION_LABEL = { "new": "➕ alta", "update": "✏️ edición", "obsolete": "⚠️ obsoleto", "revert": "↩️ reactivado", "feedback": "💡 feedback", } entries = load_bitacora_entries() if entries: for entry in reversed(entries[-20:]): accion = _ACTION_LABEL.get(entry.get("action", ""), entry.get("action", "")) detalle = entry.get("proto_id", "") or (entry.get("feedback_text", "")[:40]) st.caption( f"- {entry.get('timestamp','')} | {entry.get('rep','')} | " f"{detalle} | {accion}" ) else: st.caption("Sin cambios registrados todavia.") # Aplica el tema oscuro (azul Focus) si el toggle del sidebar esta activo. apply_focus_theme() # -- Chat UI ------------------------------------------------------------------- # Encabezado con boton de limpiar chat. NOTA: limpiar el chat solo resetea el # estado de la sesion (mensajes). La KB y la bitacora viven en repos remotos, # asi que esto nunca borra un proto guardado ni el historial de cambios. hcol1, hcol2 = st.columns([4, 1]) with hcol1: st.header("Asistente de prototipos") with hcol2: if st.button("🧹 Clear chat", use_container_width=True): st.session_state.messages = [] st.session_state.pending_update = None st.session_state.pending_delete = None st.session_state.pending_revert = None st.session_state.pending_postsave = None st.rerun() for msg in st.session_state.messages: with st.chat_message(msg["role"]): st.markdown(msg["content"]) if prompt := st.chat_input("Pega aqui el mensaje del cliente o describe la situacion..."): rep_name = st.session_state.get("rep_name", "").strip() st.session_state.messages.append({"role": "user", "content": prompt}) with st.chat_message("user"): st.markdown(prompt) def _emit(text): st.session_state.messages.append({"role": "assistant", "content": text}) with st.chat_message("assistant"): st.markdown(text) # Gate de nombre y apellido: sin >= 2 palabras el chat no avanza. if len(rep_name.split()) < 2: _emit( "Antes de continuar, **escribe tu nombre y apellido en la barra lateral** " "(campo *Tu nombre y apellido*). Es obligatorio para registrar quién hace cada " "cambio en la bitácora." ) else: rep = rep_name handled = False # 1) Oferta de revert de un paso tras obsoletar (no bloqueante). pending_revert = st.session_state.get("pending_revert") if pending_revert: norm = _norm(prompt).split() first = norm[0] if norm else "" if REVERT_CMD_RE.search(prompt) or first == "1": _emit(_do_revert(pending_revert, rep) or "⏳ Otro representante está guardando. Espera ~30s e inténtalo de nuevo.") handled = True else: st.session_state.pending_revert = None # se ignora: sigue el flujo normal # 1b) Post-guardado: opciones sobre el proto recién creado/modificado. # 4 = corregir lo guardado; acción/5 = agregar acción interna; revertir = deshacer alta. pending_postsave = st.session_state.get("pending_postsave") if not handled and pending_postsave: pid = pending_postsave["proto_id"] ps_field = pending_postsave.get("field", "prototype_answer") norm = _norm(prompt).split() first = norm[0] if norm else "" if first == "4": st.session_state.pending_postsave = None st.session_state.pending_update = { "mode": "update", "proto_id": pid, "field": ps_field, "step": "awaiting_reedit", } _emit( "¿Qué te gustaría borrar del prototipo que acabas de modificar o crear? " "Escribe de nuevo el **texto corregido completo** y lo actualizo tal cual." ) handled = True elif first == "5" or first == "accion": st.session_state.pending_postsave = None st.session_state.pending_update = { "mode": "update", "proto_id": pid, "field": "internal_action", "step": "awaiting_text", } _emit( f"Agreguemos la **acción interna** de **{pid}**.\n\n" "Escribe los **pasos internos y las URLs** que el rep debe seguir (por ejemplo, " "qué revisar o activar en Uscreen). Se guardarán tal cual:" ) handled = True elif pending_postsave.get("mode") == "new" and ( first in ("revertir", "revert", "deshacer", "revierte") or REVERT_CMD_RE.search(prompt) ): st.session_state.pending_postsave = None _emit(_do_obsolete(pid, rep, "el rep pidió deshacer la creación recién hecha") or "⏳ Otro representante está guardando. Espera ~30s e inténtalo de nuevo.") handled = True else: st.session_state.pending_postsave = None # se ignora: sigue el flujo normal # 2) Borrado/obsolescencia (el bot NUNCA borra; rutea a soft-delete). if not handled: del_reply = handle_delete_flow(prompt, rep) if del_reply: _emit(del_reply) handled = True # 3) Actualizacion/alta/feedback pendiente. if not handled: upd_reply = handle_update_flow(prompt) if upd_reply: _emit(upd_reply) handled = True # 4) LLM (cascada de resiliencia: timeouts 25/40/60s + avisos + alerta si todos caen). if not handled: messages_for_api = [ {"role": "system", "content": st.session_state.system_prompt}, *st.session_state.messages, ] with st.chat_message("assistant"): order = _fallback_order() raw_reply, used_label, err = None, None, None fails = [] # motivos clasificados de cada modelo que fallo (en orden) for i, k in enumerate(order): to = _CASCADE_TIMEOUTS[i] if i < len(_CASCADE_TIMEOUTS) else 60 with st.spinner( f"Consultando la base de conocimiento con {MODELS[k]['label']}… " f"(hasta {to}s)" ): content, err = _try_model(k, messages_for_api, to) if content: raw_reply = content used_label = MODELS[k]["label"] break fails.append(err) # p.ej. "Mistral Nemotron: saturado (no respondió a tiempo)" if i < len(order) - 1: nxt = MODELS[order[i + 1]]["label"] st.warning(f"⚠️ {err}. Te paso a **{nxt}**, que debería estar operando…") if raw_reply is None: # Todos fallaron. Distinguir CAÍDO (todos 410) de SATURADO (timeouts) por los motivos. all_dead = fails and all("410" in f for f in fails) estado = "MODELOS CAÍDOS" if all_dead else "SERVIDOR SATURADO" detalle = "\n".join(f"- {f}" for f in fails) or "- (sin detalle)" alerted = alert_admin( f"RS Proto — {estado}", f"Ningún modelo respondió en enfoque-proto-chat.\nEstado por modelo:\n{detalle}", ) if all_dead: raw_reply = ("🛑 **SERVIDOR CAÍDO** — los modelos fueron dados de baja por el " "proveedor. Estamos reparando; intenta más tarde.") else: raw_reply = ("🛑 **SERVIDOR SATURADO** — estamos reparando el sistema. Intenta " "de nuevo en unos minutos.") raw_reply += ("\n\nEl administrador ya fue alertado." if alerted else "\n\nPor favor avísale a Esteban.") elif fails and used_label: # Respondió un fallback: decir quién no trabajó y a quién se pasó. quienes = "; ".join(fails) raw_reply += ( f"\n\n---\n*Nota: {quienes}. Te respondió **{used_label}**, que sí está " "operando. Puedes cambiar de modelo en la barra lateral.*" ) upd = parse_update_marker(raw_reply) new = parse_new_marker(raw_reply) fb = parse_feedback_marker(raw_reply) if upd and _is_emptying(upd.get("new_text", "")): # Vaciar un proto = intento de borrado: rutear a soft-delete. pid = upd["proto_id"] num = pid.split("-")[1] st.session_state.pending_delete = { "proto_id": pid, "note": "el rep pidio vaciar el texto via edicion; se ofrece marcar OBSOLETO", } display_reply = ( f"Detecté que quieres **vaciar** {pid}. El bot **no vacía ni borra** " f"protos; lo puedo marcar como **OBSOLETO** (reversible, queda para " f"auditoría).\n\n**Teclea el número de proto que aparece [{num}] para " f"confirmar que deseas borrarlo o marcarlo como obsoleto**, o escribe " f"**no** para cancelar." ) elif upd: # Sin propuesta del bot: se guarda el texto del rep tal cual (verbatim). field = upd.get("field", "prototype_answer") st.session_state.pending_update = { "mode": "update", "proto_id": upd["proto_id"], "new_text": upd["new_text"], "field": field, "step": "llm_proposed", } display_reply = _proto_confirm_message(st.session_state.pending_update) elif new: accion = new.get("accion", "") or "" st.session_state.pending_update = { "mode": "new", "categoria": new["categoria"], "title": new["title"], "cuando": new.get("cuando", ""), "new_text": new["respuesta"], "accion": accion, "step": "new_proto_confirm", } display_reply = _proto_confirm_message(st.session_state.pending_update) elif fb: clean_reply = strip_markers(raw_reply) st.session_state.pending_update = { "mode": "feedback", "texto": fb["texto"], "step": "feedback_confirm", } extra = (clean_reply + "\n\n") if clean_reply else "" display_reply = ( f"{extra}---\n💡 **Sugerencia para el bot:**\n\n{_quote(fb['texto'])}\n\n" "¿Enviar esta sugerencia a **Esteban Porras**? Escribe **sí** (o **1**) " "para enviarla, o **no** (o **2**) para descartarla." ) else: display_reply = raw_reply st.markdown(display_reply) st.session_state.messages.append({"role": "assistant", "content": display_reply})