import os import re import json import time import sqlite3 import textwrap from pathlib import Path from datetime import datetime from uuid import uuid4 from typing import Dict, List, Any, Optional, Tuple import gradio as gr from langdetect import detect, LangDetectException from PIL import Image, ImageDraw, ImageFont try: from huggingface_hub import InferenceClient except Exception: InferenceClient = None try: from sentence_transformers import SentenceTransformer, util except Exception: SentenceTransformer = None util = None APP_NAME = "Sistema de Validación y Gestión de Retos Complejos" BASE_DIR = Path(__file__).resolve().parent DATA_DIR = BASE_DIR / "data" TMP_DIR = BASE_DIR / "tmp" CONFIG_DIR = BASE_DIR / "config" PROMPTS_DIR = CONFIG_DIR / "prompts" ASSETS_DIR = BASE_DIR / "assets" DB_PATH = DATA_DIR / "app.db" CONFIG_PATH = CONFIG_DIR / "settings.json" BANNED_WORDS_PATH = CONFIG_DIR / "banned_words.txt" EXAMPLES_PATH = CONFIG_DIR / "examples.json" LOGO_PATH = ASSETS_DIR / "program_logo.png" DEFAULT_SETTINGS = { "app_name": APP_NAME, "min_chars_reto": 100, "max_chars_reto": 2200, "require_remote_eval": False, "prefer_local_semantic_eval": True, "show_remote_errors": True, "canvas": { "width": 1920, "height": 1360, "dpi": 150, "header_color": "#2563eb", "background_color": "#f6f8fc", "panel_bg": "#ffffff", "panel_border": "#dbe1ea", "text_color": "#111827", "accent_color": "#16a34a", }, "local_semantic_model": "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2", "remote_models": [ {"name": "Qwen/Qwen2.5-7B-Instruct", "mode": "chat"}, {"name": "mistralai/Mistral-7B-Instruct-v0.3", "mode": "chat"}, {"name": "meta-llama/Meta-Llama-3-8B-Instruct", "mode": "chat"}, {"name": "google/gemma-2-9b-it", "mode": "chat"}, {"name": "microsoft/Phi-3-mini-4k-instruct", "mode": "text"}, ], "assist_models": [ {"name": "Qwen/Qwen2.5-7B-Instruct", "mode": "chat"}, {"name": "mistralai/Mistral-7B-Instruct-v0.3", "mode": "chat"}, {"name": "google/gemma-2-9b-it", "mode": "chat"}, {"name": "microsoft/Phi-3-mini-4k-instruct", "mode": "text"}, ], } DEFAULT_BANNED_WORDS = [ "idiota", "estúpido", "imbécil", "mierda", "maldito" ] DEFAULT_EXAMPLES = [ { "title": "Salud comunitaria", "text": "¿Cómo pueden comunidades urbanas y rurales, instituciones públicas, organizaciones de base y redes de apoyo emocional crear mecanismos de acompañamiento que funcionen en contextos de diversidad cultural y económica, sin imponer un modelo único, probando intervenciones pequeñas y ajustando según lo que emerja?" }, { "title": "Educación y abandono escolar", "text": "¿Cómo podrían escuelas, familias, municipalidades, empresas y organizaciones comunitarias reducir el abandono escolar en territorios con violencia y precariedad, considerando intereses diversos, restricciones legales y éticas, y aprendiendo de pilotos locales seguros para fallar antes de escalar?" }, { "title": "Gobernanza hídrica", "text": "¿Cómo podrían comunidades, alcaldías, agricultores, empresas, autoridades ambientales y centros de investigación coordinar una gobernanza colaborativa del agua ante sequías impredecibles, interdependencias territoriales y prioridades en conflicto, mediante experimentos adaptativos y aprendizaje continuo?" } ] PROMPT_EVAL = """Eres un evaluador experto de retos complejos según Cynefin. No uses coincidencias de palabras superficiales. Evalúa el sentido del reto. Evalúa el reto con base en estas 7 características C1 incertidumbre_alta C2 multiplicidad_de_actores C3 interdependencia C4 emergencia_sobre_imposicion C5 gestion_por_restricciones C6 experimentacion_probe_sense_respond C7 aprendizaje_y_adaptacion Usa como referencia conceptual estos ejemplos {fewshot} Reglas importantes - Un reto puede ser sólido aunque no mencione literalmente las palabras "incertidumbre", "interdependencia" o "aprendizaje". - Si el sentido del reto coincide con los ejemplos guía, reconoce ese patrón. - Justifica en lenguaje claro. - No castigues por estilo de redacción si la estructura conceptual está presente. Devuelve SOLO JSON válido con este formato {{ "global_score": 0-100, "global_label": "fuertemente_complejo|prometedor|parcialmente_complejo|debil", "global_summary": "máximo 120 palabras", "strengths": ["...", "...", "..."], "gaps": ["...", "...", "..."], "criteria": {{ "C1": {{"label":"Incertidumbre alta","score":0-100,"status":"ALTO|MEDIO|BAJO","justification":"..." }}, "C2": {{"label":"Multiplicidad de actores","score":0-100,"status":"ALTO|MEDIO|BAJO","justification":"..." }}, "C3": {{"label":"Interdependencia","score":0-100,"status":"ALTO|MEDIO|BAJO","justification":"..." }}, "C4": {{"label":"Emergencia sobre imposición","score":0-100,"status":"ALTO|MEDIO|BAJO","justification":"..." }}, "C5": {{"label":"Gestión por restricciones","score":0-100,"status":"ALTO|MEDIO|BAJO","justification":"..." }}, "C6": {{"label":"Experimentación","score":0-100,"status":"ALTO|MEDIO|BAJO","justification":"..." }}, "C7": {{"label":"Aprendizaje y adaptación","score":0-100,"status":"ALTO|MEDIO|BAJO","justification":"..." }} }}, "rewrite_suggestion": "una mejor versión del reto en una sola pregunta" }} Reto {reto} """ PROMPT_SECTION = """Eres un asistente para llenar un canvas de reto complejo. Responde SOLO JSON válido {{"answer":"texto breve, claro y útil"}} Reto {reto} Sección objetivo {section_name} Pregunta {question} Contexto ya completado {context} """ FEWSHOT_REFERENCE = """ Ejemplos de retos bien formulados 1. ¿Cómo pueden comunidades urbanas y rurales, instituciones públicas, organizaciones de base y redes de apoyo emocional crear mecanismos de acompañamiento que funcionen en contextos de diversidad cultural y económica, sin imponer un modelo único, probando intervenciones pequeñas y ajustando según lo que emerja? 2. ¿Cómo podrían escuelas, familias, municipalidades, empresas y organizaciones comunitarias reducir el abandono escolar en territorios con violencia y precariedad, considerando intereses diversos, restricciones legales y éticas, y aprendiendo de pilotos locales seguros para fallar antes de escalar? 3. ¿Cómo podrían comunidades, alcaldías, agricultores, empresas, autoridades ambientales y centros de investigación coordinar una gobernanza colaborativa del agua ante sequías impredecibles, interdependencias territoriales y prioridades en conflicto, mediante experimentos adaptativos y aprendizaje continuo? """ CYNEFIN_LABELS = { "C1": "Incertidumbre alta", "C2": "Multiplicidad de actores", "C3": "Interdependencia", "C4": "Emergencia sobre imposición", "C5": "Gestión por restricciones", "C6": "Experimentación", "C7": "Aprendizaje y adaptación", } WIZARD_STEPS = [ ("reto", "Reto"), ("definicion", "Definición"), ("relevancia", "Relevancia"), ("conexion", "Conexión"), ("actores", "Actores"), ("gobernanza", "Gobernanza"), ("iniciativas", "Iniciativas"), ("resumen", "Resumen"), ] SECTION_META = { "definicion": ("Definición", "¿Qué aspectos fundamentales definen el reto propuesto?"), "relevancia": ("Relevancia", "¿Por qué creemos que este reto es relevante?"), "conexion": ("Conexión personal", "¿Cómo se conecta el reto con mi ámbito de trabajo?"), "gobernanza": ("Gobernanza", "¿Por qué la gobernanza colaborativa y anticipatoria puede contribuir a abordar el reto?"), "iniciativas": ("Iniciativas", "¿Qué iniciativas, alianzas, redes o proyectos conocemos relacionados con el reto?"), } LOCAL_MODEL_CACHE = {"model": None, "name": None} ANCHOR_CACHE = {} ANCHORS = { "C1": [ "El reto ocurre en un contexto incierto, cambiante e impredecible donde no existe una solución obvia.", "La situación depende del contexto y requiere explorar varias posibilidades antes de decidir.", ], "C2": [ "Hay múltiples actores con intereses, capacidades y responsabilidades distintas que deben intervenir.", "Comunidades, instituciones, empresas, familias y organizaciones participan en el reto.", ], "C3": [ "Los elementos del problema están interconectados y una decisión afecta a las demás partes del sistema.", "Existen interdependencias territoriales, sociales o institucionales entre los actores y variables.", ], "C4": [ "La respuesta debe emerger de coordinación, interacción y ajuste, no de una imposición lineal.", "La solución necesita construirse de forma colaborativa y adaptativa.", ], "C5": [ "Existen restricciones éticas, legales, institucionales, territoriales o presupuestarias que limitan la acción.", "El reto exige trabajar dentro de límites y condiciones habilitadoras.", ], "C6": [ "El reto se beneficia de pilotos, pruebas pequeñas, experimentación y aprendizaje antes de escalar.", "Conviene probar, observar y ajustar en lugar de ejecutar una receta cerrada desde el inicio.", ], "C7": [ "La respuesta exige aprendizaje continuo, adaptación y corrección a medida que aparecen nuevos patrones.", "El sistema debe observar, aprender y reconfigurarse continuamente.", ], } EVIDENCE_TERMS = { "C1": ["incertid", "impredec", "diversidad", "conflicto", "tensión", "precariedad", "contexto", "cambiante", "volátil", "ambig"], "C2": ["comunidades", "instituciones", "organizaciones", "familias", "empresas", "alcaldías", "autoridades", "escuelas", "redes", "actores", "agricultores", "centros"], "C3": ["coordinar", "interdepend", "territorial", "sistém", "múltiples", "conjunto", "redes", "gobernanza", "afecta", "articul"], "C4": ["sin imponer", "emerja", "colabor", "co-crear", "ajustando", "mecanismos", "acompañamiento", "particip", "adaptativa"], "C5": ["restric", "ética", "legal", "condiciones", "límites", "prioridades", "presupuesto", "violencia", "precariedad", "regulación"], "C6": ["piloto", "probar", "intervenciones pequeñas", "experimento", "safe to fail", "ajustando", "antes de escalar", "iter", "observ"], "C7": ["aprendiz", "adapt", "ajustando", "continuo", "iter", "según lo que emerja", "aprendiendo", "resilien", "reconfigur"], } def bootstrap() -> None: for p in [DATA_DIR, TMP_DIR, CONFIG_DIR, PROMPTS_DIR, ASSETS_DIR]: p.mkdir(parents=True, exist_ok=True) if not CONFIG_PATH.exists(): CONFIG_PATH.write_text(json.dumps(DEFAULT_SETTINGS, ensure_ascii=False, indent=2), encoding="utf-8") if not BANNED_WORDS_PATH.exists(): BANNED_WORDS_PATH.write_text("\n".join(DEFAULT_BANNED_WORDS), encoding="utf-8") if not EXAMPLES_PATH.exists(): EXAMPLES_PATH.write_text(json.dumps(DEFAULT_EXAMPLES, ensure_ascii=False, indent=2), encoding="utf-8") prompt_files = { "eval.txt": PROMPT_EVAL, "section.txt": PROMPT_SECTION, } for name, content in prompt_files.items(): path = PROMPTS_DIR / name path.write_text(content, encoding="utf-8") if not LOGO_PATH.exists(): create_placeholder_logo() init_db() def create_placeholder_logo(): img = Image.new("RGBA", (900, 220), (255, 255, 255, 0)) draw = ImageDraw.Draw(img) font_big = ImageFont.load_default() draw.rounded_rectangle((20, 20, 880, 200), radius=24, fill=(37, 99, 235, 255)) draw.text((50, 70), "Programa Formativo • Valores e Instituciones Democráticas", fill="white", font=font_big) img.save(LOGO_PATH) def load_settings() -> Dict[str, Any]: return json.loads(CONFIG_PATH.read_text(encoding="utf-8")) def init_db() -> None: con = sqlite3.connect(DB_PATH) cur = con.cursor() cur.execute(""" CREATE TABLE IF NOT EXISTS evaluations ( id TEXT PRIMARY KEY, user_id TEXT, reto_text TEXT, result_json TEXT, created_at TEXT ) """) cur.execute(""" CREATE TABLE IF NOT EXISTS canvases ( id TEXT PRIMARY KEY, user_id TEXT, reto_text TEXT, canvas_json TEXT, png_path TEXT, created_at TEXT ) """) con.commit() con.close() def get_hf_token() -> str: return os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACEHUB_API_TOKEN") or "" def build_client() -> Optional[Any]: token = get_hf_token() if not token or not token.startswith("hf_") or InferenceClient is None: return None try: return InferenceClient(api_key=token) except Exception: return None def read_text(path: Path, default: str = "") -> str: return path.read_text(encoding="utf-8") if path.exists() else default def normalize_text(s: str) -> str: return re.sub(r"\s+", " ", (s or "").strip().lower()) def detect_spanish(text: str) -> bool: try: return detect(text) == "es" except LangDetectException: return False def get_banned_words() -> List[str]: return [w.strip().lower() for w in read_text(BANNED_WORDS_PATH).splitlines() if w.strip()] def contains_offensive_content(text: str) -> bool: lowered = normalize_text(text) return any(word in lowered for word in get_banned_words()) def l0_validate(reto: str) -> Tuple[bool, str]: s = load_settings() text = (reto or "").strip() if len(text) < s["min_chars_reto"]: return False, "El reto debe tener al menos 100 caracteres." if len(text) > s["max_chars_reto"]: return False, "El reto no debe exceder 2200 caracteres." if not detect_spanish(text): return False, "El reto debe estar escrito en español." if "?" not in text and "¿" not in text: return False, "El reto debe estar formulado como pregunta." if contains_offensive_content(text): return False, "El contenido no cumple con las políticas de uso." return True, "OK" def extract_json(raw: str) -> Dict[str, Any]: raw = (raw or "").strip() if not raw: return {} try: return json.loads(raw) except Exception: pass match = re.search(r"\{.*\}", raw, flags=re.S) if match: try: return json.loads(match.group(0)) except Exception: return {} return {} def try_remote_model(model_name: str, mode: str, prompt: str, max_new_tokens: int = 1200) -> Tuple[Dict[str, Any], Optional[str]]: client = build_client() if client is None: return {}, "Cliente remoto no disponible" try: if mode == "chat": response = client.chat.completions.create( model=model_name, messages=[ {"role": "system", "content": "Responde solamente JSON válido, sin markdown."}, {"role": "user", "content": prompt}, ], max_tokens=max_new_tokens, temperature=0.1, ) text = response.choices[0].message.content return extract_json(text), None text = client.text_generation( prompt, model=model_name, max_new_tokens=max_new_tokens, temperature=0.1, return_full_text=False, ) return extract_json(text), None except Exception as e: return {}, f"{model_name} [{mode}] -> {str(e)}" def ping_remote_eval() -> Tuple[bool, str]: token = get_hf_token() if not token or not token.startswith("hf_"): return False, "No hay token HF_TOKEN configurado en Secrets." errors = [] for spec in load_settings()["remote_models"]: out, err = try_remote_model(spec["name"], spec.get("mode", "chat"), 'Responde SOLO JSON válido {"ok": true}', 40) if isinstance(out, dict) and out.get("ok") is True: return True, f"Disponible con {spec['name']}" if err: errors.append(err) return False, "Sin respuesta válida. " + " | ".join(errors[:3]) def token_set(text: str) -> set: tokens = re.findall(r"[a-záéíóúñü]{4,}", normalize_text(text)) stop = {"como", "pueden", "podrian", "deben", "estar", "para", "entre", "desde", "sobre", "mismo", "misma", "estas", "estos", "ellas", "ellos", "donde", "hacer", "podría", "cómo"} return {t for t in tokens if t not in stop} def jaccard(a: set, b: set) -> float: if not a or not b: return 0.0 return len(a & b) / max(1, len(a | b)) def count_actor_signals(text: str) -> int: actor_terms = [ "comunidades", "instituciones", "organizaciones", "familias", "empresas", "alcaldías", "autoridades", "centros", "municipalidades", "escuelas", "redes", "gobierno", "actores", "agricultores" ] t = normalize_text(text) return sum(1 for term in actor_terms if term in t) def evidence_terms_found(text: str, cid: str) -> List[str]: t = normalize_text(text) found = [] for term in EVIDENCE_TERMS.get(cid, []): if term in t: found.append(term) return found[:6] def extract_evidence_snippets(text: str, terms: List[str], limit: int = 3) -> List[str]: src = re.sub(r"\s+", " ", (text or "").strip()) lowered = src.lower() snippets = [] for term in terms: pos = lowered.find(term.lower()) if pos >= 0: start = max(0, pos - 35) end = min(len(src), pos + len(term) + 45) snippet = src[start:end].strip() if start > 0: snippet = "…" + snippet if end < len(src): snippet = snippet + "…" if snippet not in snippets: snippets.append(snippet) if len(snippets) >= limit: break return snippets def clamp(v: float, lo: float, hi: float) -> float: return max(lo, min(hi, v)) def map_similarity_to_score(sim: float) -> int: if sim <= 0.18: return 20 if sim >= 0.62: return 92 scaled = 20 + ((sim - 0.18) / (0.62 - 0.18)) * (92 - 20) return int(round(clamp(scaled, 20, 92))) def human_status(score: int) -> str: if score >= 80: return "ALTO" if score >= 50: return "MEDIO" return "BAJO" def label_for_global(score: int) -> str: if score >= 80: return "fuertemente_complejo" if score >= 60: return "prometedor" if score >= 40: return "parcialmente_complejo" return "debil" def get_local_semantic_model() -> Tuple[Optional[Any], Optional[str]]: model_name = load_settings().get("local_semantic_model") if SentenceTransformer is None: return None, "sentence-transformers no está instalado" if LOCAL_MODEL_CACHE["model"] is not None and LOCAL_MODEL_CACHE["name"] == model_name: return LOCAL_MODEL_CACHE["model"], None try: model = SentenceTransformer(model_name) LOCAL_MODEL_CACHE["model"] = model LOCAL_MODEL_CACHE["name"] = model_name return model, None except Exception as e: return None, str(e) def get_anchor_embeddings(model) -> Dict[str, Any]: model_name = load_settings().get("local_semantic_model") cache_key = f"anchors::{model_name}" if cache_key in ANCHOR_CACHE: return ANCHOR_CACHE[cache_key] out = {} for cid, texts in ANCHORS.items(): out[cid] = model.encode(texts, convert_to_tensor=True) out["_examples"] = model.encode([ex["text"] for ex in DEFAULT_EXAMPLES], convert_to_tensor=True) ANCHOR_CACHE[cache_key] = out return out def trim_text_to_fit(text: str, max_len: int) -> str: text = (text or "").strip() if len(text) <= max_len: return text return text[:max_len - 3].rstrip() + "..." def local_rewrite_suggestion(reto: str) -> str: text = re.sub(r"[¿?]", "", (reto or "").strip()) text = text[:1].lower() + text[1:] if text else text actors = [] for term in ["comunidades", "instituciones", "organizaciones", "familias", "empresas", "alcaldías", "autoridades", "escuelas", "redes"]: if term in normalize_text(text) and term not in actors: actors.append(term) actor_segment = ", ".join(actors[:4]) if actors else "actores públicos, privados y comunitarios" if "sin imponer" not in normalize_text(text): ending = "sin imponer una solución única, probando respuestas pequeñas y ajustando según la evidencia que emerja" else: ending = "probando respuestas pequeñas y ajustando según la evidencia que emerja" core = trim_text_to_fit(text, 190) return f"¿Cómo podrían {actor_segment} abordar {core} {ending}?" def build_local_explanation(result: Dict[str, Any]) -> str: lines = [] lines.append("El resultado actual proviene del motor local del Space.") lines.append("Primero se transforma el reto en un embedding multilingüe.") lines.append("Luego se compara con anclas conceptuales de Cynefin para cada criterio.") lines.append("Después se añade señal explícita por términos observables y una comparación con ejemplos guía.") lines.append("Por último se promedia C1 a C7 y, si la similitud con ejemplos guía es alta, se aplica un piso de calibración.") if result.get("remote_errors"): lines.append("El modo remoto se intentó primero, pero no respondió de forma válida.") return " ".join(lines) def fallback_eval_semantic(reto: str) -> Dict[str, Any]: model, err = get_local_semantic_model() if model is None or util is None: return fallback_eval_heuristic(reto, semantic_unavailable=err) reto_embedding = model.encode([reto], convert_to_tensor=True) anchor_embeddings = get_anchor_embeddings(model) example_scores = util.cos_sim(reto_embedding, anchor_embeddings["_examples"])[0] example_sim = float(example_scores.max().item()) criteria = {} scores = [] breakdown = {} for cid in CYNEFIN_LABELS: sims = util.cos_sim(reto_embedding, anchor_embeddings[cid])[0] anchor_sim = float(sims.max().item()) semantic_score = map_similarity_to_score(anchor_sim) terms = evidence_terms_found(reto, cid) hits = len(terms) signal_score = min(100, 22 + hits * 14) example_score = map_similarity_to_score(example_sim) actor_boost = min(18, count_actor_signals(reto) * 3) if cid == "C2" else 0 blended = (semantic_score * 0.55) + (signal_score * 0.25) + (example_score * 0.20) + actor_boost final_score = int(round(clamp(blended, 18, 95))) status = human_status(final_score) snippets = extract_evidence_snippets(reto, terms) just_parts = [ f"Similitud conceptual {anchor_sim:.2f}", f"similitud con ejemplos {example_sim:.2f}", f"señales explícitas {hits}", ] if actor_boost: just_parts.append(f"boost de actores {actor_boost}") criteria[cid] = { "label": CYNEFIN_LABELS[cid], "score": final_score, "status": status, "justification": "Modelo semántico local. " + ", ".join(just_parts) + ".", "evidence_terms": terms, "evidence_snippets": snippets, } breakdown[cid] = { "anchor_similarity": round(anchor_sim, 3), "semantic_score": semantic_score, "signal_hits": hits, "signal_score": signal_score, "example_similarity": round(example_sim, 3), "example_score": example_score, "actor_boost": actor_boost, "weight_semantic": 0.55, "weight_signals": 0.25, "weight_examples": 0.20, "final_score": final_score, } scores.append(final_score) raw_global = int(round(sum(scores) / len(scores))) global_score = raw_global calibration_note = "Sin calibración adicional." if example_sim >= 0.58 and global_score < 72: global_score = 72 calibration_note = "Piso aplicado por alta similitud semántica con ejemplos guía." if example_sim >= 0.68 and global_score < 78: global_score = 78 calibration_note = "Piso aplicado por similitud muy alta con ejemplos guía." strengths = [criteria[c]["label"] for c in criteria if criteria[c]["score"] >= 70][:3] gaps = [criteria[c]["label"] for c in criteria if criteria[c]["score"] < 60][:3] result = { "global_score": global_score, "global_label": label_for_global(global_score), "global_summary": "Resultado generado con evaluación semántica local. Usa embeddings multilingües, anclas conceptuales por criterio, similitud con ejemplos guía y señales conceptuales.", "strengths": strengths or ["Rasgos de complejidad parcialmente visibles"], "gaps": gaps or ["Conviene explicitar mejor el mecanismo adaptativo"], "criteria": criteria, "rewrite_suggestion": local_rewrite_suggestion(reto), "remote_used": False, "local_breakdown": { "method": "semantic-local", "model": load_settings().get("local_semantic_model"), "example_similarity_max": round(example_sim, 3), "raw_global_score": raw_global, "adjusted_global_score": global_score, "per_criterion": breakdown, "calibration_note": calibration_note, "global_formula": "promedio simple de C1 a C7", "score_formula": "0.55*score_semantico + 0.25*score_senales + 0.20*score_ejemplos + boost_actores", } } result["global_summary"] = build_local_explanation(result) return result def fallback_eval_heuristic(reto: str, semantic_unavailable: Optional[str] = None) -> Dict[str, Any]: t = normalize_text(reto) examples_tokens = [token_set(ex["text"]) for ex in DEFAULT_EXAMPLES] reto_tokens = token_set(reto) sim = max([jaccard(reto_tokens, ex_t) for ex_t in examples_tokens] + [0.0]) criteria = {} scores = [] breakdown = {} for cid, patterns in EVIDENCE_TERMS.items(): terms = [p for p in patterns if p in t][:6] hits = len(terms) base = 28 signal_boost = hits * 13 actor_boost = min(20, count_actor_signals(reto) * 4) if cid == "C2" else 0 sim_boost = 0 if sim >= 0.18: sim_boost += 12 if sim >= 0.28: sim_boost += 8 score = int(max(18, min(95, base + signal_boost + actor_boost + sim_boost))) criteria[cid] = { "label": CYNEFIN_LABELS[cid], "score": score, "status": human_status(score), "justification": f"Evaluación heurística local. Señales {hits}, similitud con ejemplos {sim:.2f}.", "evidence_terms": terms, "evidence_snippets": extract_evidence_snippets(reto, terms), } breakdown[cid] = { "base": base, "signal_hits": hits, "signal_boost": signal_boost, "actor_boost": actor_boost, "sim_boost": sim_boost, "final_score": score, } scores.append(score) raw_global = int(round(sum(scores) / len(scores))) global_score = raw_global calibration_note = "Sin calibración adicional." if sim >= 0.24 and global_score < 68: global_score = 68 calibration_note = "Se aplicó piso por similitud moderada con ejemplos guía." if sim >= 0.32 and global_score < 75: global_score = 75 calibration_note = "Se aplicó piso por alta similitud con ejemplos guía." strengths = [criteria[c]["label"] for c in criteria if criteria[c]["score"] >= 70][:3] gaps = [criteria[c]["label"] for c in criteria if criteria[c]["score"] < 60][:3] result = { "global_score": global_score, "global_label": label_for_global(global_score), "global_summary": "Resultado generado con evaluación heurística local. Usa similitud léxica con ejemplos guía, señales conceptuales y conteo de actores.", "strengths": strengths or ["Rasgos de complejidad parcialmente visibles"], "gaps": gaps or ["Conviene explicitar mejor el mecanismo adaptativo"], "criteria": criteria, "rewrite_suggestion": local_rewrite_suggestion(reto), "remote_used": False, "local_breakdown": { "method": "heuristic-local", "semantic_unavailable": semantic_unavailable or "", "similarity_max": round(sim, 3), "raw_global_score": raw_global, "adjusted_global_score": global_score, "per_criterion": breakdown, "calibration_note": calibration_note, "global_formula": "promedio simple de C1 a C7", "score_formula": "base + boosts por señales, ejemplos y actores", } } result["global_summary"] = build_local_explanation(result) return result def evaluate_reto_with_llm(reto: str) -> Dict[str, Any]: prompt = read_text(PROMPTS_DIR / "eval.txt", PROMPT_EVAL).format(reto=reto, fewshot=FEWSHOT_REFERENCE) errors = [] timings = [] for spec in load_settings()["remote_models"]: start = time.time() out, err = try_remote_model(spec["name"], spec.get("mode", "chat"), prompt, 1600) elapsed = round(time.time() - start, 2) timings.append({"model": spec["name"], "mode": spec.get("mode", "chat"), "seconds": elapsed, "ok": bool(out)}) if isinstance(out, dict) and "criteria" in out and "global_score" in out: out["model"] = spec["name"] out["remote_used"] = True out["remote_errors"] = errors out["diagnostics"] = {"remote_timings": timings} return out if err: errors.append(err) if load_settings().get("prefer_local_semantic_eval", True): out = fallback_eval_semantic(reto) else: out = fallback_eval_heuristic(reto) out["model"] = out.get("local_breakdown", {}).get("model", "heuristic-plus") out["remote_errors"] = errors out["diagnostics"] = {"remote_timings": timings} return out def save_evaluation(user_id: str, reto_text: str, result_json: Dict[str, Any]) -> None: con = sqlite3.connect(DB_PATH) cur = con.cursor() cur.execute(""" INSERT INTO evaluations (id, user_id, reto_text, result_json, created_at) VALUES (?, ?, ?, ?, ?) """, (str(uuid4()), user_id, reto_text, json.dumps(result_json, ensure_ascii=False), datetime.utcnow().isoformat())) con.commit() con.close() def diagnostics_markdown(result: Dict[str, Any]) -> str: if not result: return "Sin diagnóstico todavía." parts = [] parts.append("## Diagnóstico técnico") parts.append(f"- Fuente efectiva **{'LLM remoto' if result.get('remote_used') else 'motor local'}**") parts.append(f"- Modelo registrado **{result.get('model', 'N/D')}**") if result.get("local_breakdown"): local = result["local_breakdown"] parts.append(f"- Método local **{local.get('method','N/D')}**") parts.append(f"- Fórmula global **{local.get('global_formula','N/D')}**") parts.append(f"- Fórmula por criterio **{local.get('score_formula','N/D')}**") parts.append(f"- Calibración **{local.get('calibration_note','N/D')}**") if local.get("example_similarity_max") is not None: parts.append(f"- Similitud máxima con ejemplos guía **{local.get('example_similarity_max')}**") if local.get("similarity_max") is not None: parts.append(f"- Similitud léxica máxima con ejemplos guía **{local.get('similarity_max')}**") timings = result.get("diagnostics", {}).get("remote_timings", []) if timings: rows = ["| Modelo | Modo | Segundos | Respuesta válida |", "|---|---|---:|---|"] for item in timings: rows.append(f"| {item.get('model','')} | {item.get('mode','')} | {item.get('seconds',0)} | {'Sí' if item.get('ok') else 'No'} |") parts.append("\n\n" + "\n".join(rows)) if result.get("remote_errors"): parts.append("\n\n**Errores remotos**\n- " + "\n- ".join(result["remote_errors"][:6])) return "\n".join(parts) def score_color(score: int) -> str: if score >= 80: return "#2563eb" if score >= 60: return "#16a34a" if score >= 40: return "#f59e0b" return "#ef4444" def eval_chart_html(result: Dict[str, Any]) -> str: criteria = result.get("criteria", {}) cards = [] for cid in ["C1", "C2", "C3", "C4", "C5", "C6", "C7"]: item = criteria.get(cid, {}) score = int(item.get("score", 0)) evidence = item.get("evidence_terms", []) evid_html = "" if evidence: pills = "".join([f'{e}' for e in evidence[:4]]) evid_html = f'
Esta aplicación está diseñada para que cualquier persona pueda entender de dónde salen los resultados. El sistema evalúa la formulación de un reto a partir de la lógica del marco Cynefin y luego muestra una lectura cuantificada que puede auditarse. El resultado no debe leerse como una verdad absoluta. Debe leerse como una operacionalización transparente de rasgos de complejidad.
Cynefin es un marco de sentido y decisión desarrollado por Dave Snowden y colaboradores para distinguir contextos simples, complicados, complejos, caóticos y de desorden. Su utilidad central consiste en recordar que no todos los problemas admiten la misma forma de intervención. En contextos complejos no suele existir una respuesta única previa. Lo apropiado es explorar, observar patrones emergentes y ajustar. Ese principio es la base conceptual del evaluador.
Referencias centrales
Kurtz, C. F., & Snowden, D. J. 2003. The new dynamics of strategy. IBM Systems Journal, 42(3), 462–483.
Snowden, D. J., & Boone, M. E. 2007. A Leader’s Framework for Decision Making. Harvard Business Review, 85(11), 68–76.
Snowden, D. J. 2010. The Cynefin framework and naturalizing sense-making.
La aplicación no intenta determinar si un reto es “bueno” en términos generales. Intenta estimar qué tan visible es su estructura de complejidad. Para ello usa siete criterios que condensan rasgos frecuentes de los problemas complejos en la literatura de Cynefin y en su uso práctico para diseño de intervención.
| ID | Criterio | Qué significa dentro de la app | Fundamento bibliográfico principal |
|---|---|---|---|
| C1 | Incertidumbre alta | Busca evidencia de que el reto opera en un entorno incierto, ambiguo o no lineal y que no existe una receta obvia. | Snowden & Boone 2007, dominio complejo. |
| C2 | Multiplicidad de actores | Busca presencia o inferencia de varios actores con intereses, funciones o capacidades distintas. | Kurtz & Snowden 2003, interacciones y patrones sociales. |
| C3 | Interdependencia | Busca evidencia de relaciones sistémicas donde una decisión afecta otras partes del problema. | Kurtz & Snowden 2003, systems, distributed cognition, pattern interactions. |
| C4 | Emergencia sobre imposición | Busca señales de que la solución debe emerger por interacción, coordinación y ajuste y no por diseño lineal único. | Snowden & Boone 2007, probe-sense-respond y emergencia en complejo. |
| C5 | Gestión por restricciones | Busca límites éticos, legales, institucionales, territoriales o presupuestarios que condicionan la acción. | Literatura de complejidad aplicada y gobernanza adaptativa; compatible con Cynefin como lectura contextual de restricciones. |
| C6 | Experimentación | Busca si la formulación admite pilotos, pruebas pequeñas, iteración o aprendizaje seguro antes de escalar. | Snowden & Boone 2007, lógica probe-sense-respond. |
| C7 | Aprendizaje y adaptación | Busca evidencia de monitoreo, ajuste, aprendizaje continuo o cambio de respuesta según patrones emergentes. | Snowden 2010, sense-making adaptativo y patrones emergentes. |
La aplicación sigue una secuencia fija de procesamiento. Cada capa cumple una función distinta y puede dejar rastros visibles en la pestaña de diagnóstico.
| Fase | Qué hace | Qué aporta al resultado |
|---|---|---|
| Validación básica | Verifica longitud mínima, idioma español, forma interrogativa y ausencia de contenido bloqueado. | Evita evaluar entradas inválidas o demasiado débiles. |
| Intento remoto con LLM | Pregunta a uno o varios modelos alojados en Hugging Face para obtener JSON estructurado. | Si funciona, aporta una lectura generativa directa del reto. |
| Motor semántico local | Convierte el reto, las anclas conceptuales y los ejemplos guía en embeddings y calcula similitud. | Permite seguir operando aunque el remoto falle. |
| Heurística local de contingencia | Usa términos observables, conteo de actores y similitud léxica cuando no existe motor semántico disponible. | Mantiene la app utilizable en escenarios mínimos. |
El LLM no define la teoría. La teoría la define Cynefin. El LLM solo intenta producir una evaluación estructurada en formato JSON a partir de un prompt que ya está guiado por criterios, ejemplos y restricciones de salida. Si responde bien, su salida se usa. Si no responde, la aplicación cambia a métodos locales controlados. En otras palabras, el LLM es una capa de conveniencia y no la fuente conceptual del sistema.
El motor local usa un modelo multilingüe de sentence-transformers para representar texto como vectores. Luego compara el reto con dos tipos de referencia. Primero con anclas conceptuales específicas por criterio. Segundo con ejemplos guía completos. Esa doble comparación permite medir afinidad conceptual aunque la redacción del usuario no use exactamente las mismas palabras.
La aplicación usa promedio simple para preservar interpretabilidad. Todos los criterios pesan igual en la capa final. Esa decisión es metodológica y busca trazabilidad. Quien lea el resultado puede reproducir el cálculo sin una caja negra adicional.
| Componente | Qué representa | Razón metodológica |
|---|---|---|
| score_semántico_ancla | Similitud entre el reto y las anclas del criterio. | Tiene el mayor peso porque busca capturar sentido conceptual. |
| score_señales_explícitas | Huella textual visible dentro del propio reto. | Evita que todo dependa de una similitud global opaca. |
| score_similitud_ejemplos | Parecido con ejemplos guía bien formulados. | Ayuda a reconocer estructura aunque cambie el estilo de redacción. |
| boost_actores | Suma extra en C2 por presencia de varios actores plausibles. | Compensa que la multiplicidad de actores necesita una señal específica. |
La similitud semántica cruda no se entrega directamente porque no sería intuitiva para un usuario general. Por eso se reescala a un rango más legible. El límite inferior evita falsos ceros absolutos. El límite superior evita que cualquier parecido moderado dispare máximos artificiales.
Después del promedio simple, la aplicación puede aplicar un piso si el reto se parece mucho a los ejemplos guía. Esto existe para corregir un problema frecuente. Algunos retos tienen buena estructura compleja, pero expresan esa estructura con vocabulario distinto y por eso podrían salir subestimados.
| Rango | Lectura técnica | Sentido práctico |
|---|---|---|
| 80–100 | Rasgo claramente presente | El criterio está expresado con suficiente fuerza para sostener una lectura compleja. |
| 60–79 | Rasgo bastante visible | El rasgo existe y puede defenderse, aunque aún admite refuerzo. |
| 40–59 | Rasgo parcial o implícito | Hay señales, pero todavía no son suficientemente nítidas o abundantes. |
| 0–39 | Rasgo débil o no observable | El texto ofrece poca evidencia para sostener ese criterio. |
Para cada criterio la aplicación puede mostrar cuatro huellas. Términos detectados, fragmentos activados del texto, similitud con anclas y score final. Eso permite reconstruir por qué un criterio pudo salir más alto o más bajo que otro. La app intenta que el número no quede separado de su rastro.
Si aparece un error 401 Unauthorized o no llega una respuesta válida desde Hugging Face, la aplicación cambia a motor local. La teoría evaluativa sigue siendo la misma. Lo que cambia es la capa computacional utilizada para producir el número.
Snowden, D. J., & Boone, M. E. 2007. A Leader’s Framework for Decision Making. Harvard Business Review, 85(11), 68–76.
Kurtz, C. F., & Snowden, D. J. 2003. The new dynamics of strategy. IBM Systems Journal, 42(3), 462–483.
Snowden, D. J. 2010. The Cynefin framework and naturalizing sense-making.
La capa de embeddings no sustituye Cynefin. Solo sirve para operacionalizar similitud conceptual y mantener la aplicación usable cuando el modo remoto no está disponible.
Wizard guiado con evaluación inicial del reto, asistencia paso a paso, explicabilidad, diagnóstico técnico y generación de board.