"""Backend/API for Headline Booster AI. This file intentionally avoids building the visual interface in Python. The complete frontend lives in index.html and this Gradio Server exposes a small API for optimizing weak headlines with a tiny-model path plus a safe mock fallback. """ from __future__ import annotations import importlib import importlib.util import json import os import re from functools import lru_cache from pathlib import Path from typing import Any import gradio as gr from fastapi import HTTPException from fastapi.responses import HTMLResponse, JSONResponse from pydantic import BaseModel APP_BUILD = "headline-optimizer-clean-2026-06-08" INDEX_PATH = Path(__file__).with_name("index.html") MODEL_ID = os.getenv("MODEL_ID", "Qwen/Qwen2.5-1.5B-Instruct") MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "280")) USE_REAL_MODEL = os.getenv("USE_REAL_MODEL", "auto").strip().lower() ANALYSIS_MODE = os.getenv("ANALYSIS_MODE", "hybrid").strip().lower() if ANALYSIS_MODE not in {"rules", "model", "hybrid"}: ANALYSIS_MODE = "hybrid" IS_HUGGING_FACE_SPACE = bool( os.getenv("SPACE_ID") or os.getenv("SPACE_HOST") or os.getenv("HF_SPACE_ID") ) RESULT_WORDS = { "logra", "consigue", "aumenta", "vende", "mejora", "crece", "convierte", "gana", "atrae", "claridad", "clientes", "ventas", "decisiones", "resultado", "transforma", "aprende", } AUDIENCE_MARKERS = { "para", "emprendedores", "emprendedoras", "copywriters", "coaches", "negocios", "dueños", "mujeres", "creadores", "profesionales", "equipos", } EMOTION_MARKERS = { "sin", "miedo", "duda", "confianza", "claro", "fácil", "rápido", "urgente", "secreto", "evita", "dolor", "bloqueo", "agotamiento", "tranquilidad", } DIFFERENTIATION_MARKERS = { "aunque", "sin", "método", "sistema", "paso", "guía", "nuevo", "diferente", "probado", "desde", "en", } WEAK_PREFIXES = ( "aprende", "curso de", "taller de", "mentoría de", "guía de", "descubre", "cómo", "como", "webinar de", ) VAGUE_TOPIC_WORDS = {"curso", "taller", "clase", "programa", "mentoría", "guía", "aprende", "descubre"} BENEFIT_MARKERS = RESULT_WORDS | {"ahorra", "domina", "resuelve", "multiplica", "mejor", "más", "menos"} SPECIFIC_DETAIL_MARKERS = AUDIENCE_MARKERS | {"en", "con", "desde", "pasos", "días", "semanas", "método", "sistema"} CURIOSITY_MARKERS = {"?", "¿", "secreto", "error", "mito", "verdad", "nadie", "inesperado", "contrario", "aunque", "sin"} SYSTEM_PROMPT = """ Eres Headline Booster AI, copywriter experto en mejorar titulares, hooks y líneas de asunto en español. BASE DE COPY: Usa fórmulas y principios de copywriting como AIDA, PAS, BAB/ADP, 4Ps, FAB, 4U, StoryBrand, curiosity gap, reframe, contraste, beneficio vs característica, especificidad, emocionalidad, claridad y curiosidad. OBJETIVO: Analizar un titular débil y convertirlo en 3 versiones más claras, creíbles, atractivas y naturales. REGLAS: - Mejora el titular existente; no inventes otra oferta. - Usa fórmulas de copywriting como criterio estratégico interno, no como plantillas visibles. - No rellenes moldes literales. - Evita frases genéricas, exageradas o robóticas. - Sé breve, específico, natural y persuasivo. - No expliques las fórmulas. - No menciones fórmulas ni frameworks. - No uses markdown. - Responde SOLO JSON válido. - El campo "versiones" debe ser una lista de 3 strings. No uses objetos dentro de "versiones". No incluyas "version", "tipo", "por_que_gana" ni explicaciones dentro de cada versión. - Si recibes diagnostico, problema_principal o falta, úsalos como guía: baja claridad corrige claridad, bajo deseo eleva deseo, baja especificidad concreta más, baja diferenciacion crea un ángulo propio. ANÁLISIS RAYOS X: Evalúa el titular con 4 criterios de 0 a 100: 1. claridad: si se entiende rápido y comunica una promesa reconocible. 2. deseo: si despierta interés emocional, deseo, alivio o identificación. 3. especificidad: si aterriza audiencia, resultado, situación o detalle concreto. 4. diferenciacion: si tiene curiosidad, contraste, reframe o un ángulo propio. Luego detecta: - problema_principal: una frase breve. - falta: lista breve de 3 a 4 elementos. CREA EXACTAMENTE 3 VERSIONES: 1. Versión clara/directa: usa internamente FAB, 4U, 4Ps, beneficio vs característica y StoryBrand. Prioriza claridad, beneficio, especificidad y credibilidad para que la persona entienda rápido qué obtiene y por qué importa. 2. Versión emocional: usa internamente PAS, BAB/ADP, StoryBrand, contraste emocional y problema → deseo. Conecta con frustración, tensión, deseo, alivio o una situación emocional reconocible. 3. Versión curiosa/diferenciada: usa internamente AIDA, curiosity gap, reframe, contraste, patrón de interrupción y Pre-Suasion. Abre una pregunta mental, muestra un ángulo distinto o despierta curiosidad. Regla clave: las fórmulas guían la escritura, pero nunca deben aparecer en el resultado. No escribas “AIDA:”, “PAS:”, nombres de fórmulas, ni titulares con huecos tipo “[resultado]” o “[método]”. Elige el ganador por fuerza general: claridad + deseo + curiosidad + credibilidad. FORMATO: {"diagnostico":{"claridad":0,"deseo":0,"especificidad":0,"diferenciacion":0},"problema_principal":"","falta":["","",""],"versiones":["titular 1","titular 2","titular 3"],"ganador_numero":1,"por_que_gana":""} """.strip() class HeadlineRequest(BaseModel): headline: str class ProposalRequest(BaseModel): headline: str class WinnerRequest(BaseModel): headline: str versiones: list[str] usage: str _MODEL_ANALYSIS_CACHE: dict[str, tuple[dict[str, Any], str]] = {} def should_use_real_model() -> bool: """Resolve runtime mode from USE_REAL_MODEL and Space environment.""" if USE_REAL_MODEL == "true": return True if USE_REAL_MODEL == "false": return False return IS_HUGGING_FACE_SPACE def clean_headline(headline: str) -> str: cleaned = re.sub(r"\s+", " ", (headline or "").strip()) return cleaned[:260] def word_tokens(text: str) -> list[str]: return re.findall(r"[\wáéíóúüñÁÉÍÓÚÜÑ]+", text.lower()) def clamp_score(value: int) -> int: return max(0, min(100, int(value))) def has_number(text: str) -> bool: return bool(re.search(r"\d", text)) def diagnose_headline(headline: str) -> dict[str, int]: """Compact deterministic X-ray score for four persuasive criteria.""" words = word_tokens(headline) word_set = set(words) word_count = len(words) lower = headline.lower() names_topic_only = word_count <= 4 and not any(token in lower for token in ("para ", "sin ", "con ", "en ")) has_benefit = any(word in BENEFIT_MARKERS for word in word_set) or any( token in lower for token in ("para ", "sin ", "mejor", "más", "menos") ) has_emotion = any(word in EMOTION_MARKERS for word in word_set) or any( token in lower for token in ("deja de", "evita", "siente", "miedo", "duda", "frustr") ) has_specific = has_number(headline) or any(word in SPECIFIC_DETAIL_MARKERS for word in word_set) has_angle = any(marker in lower for marker in CURIOSITY_MARKERS) or bool(re.search(r"[?¿]", headline)) has_audience = any(word in AUDIENCE_MARKERS for word in word_set) vague_count = sum(1 for word in word_set if word in VAGUE_TOPIC_WORDS) clarity = 30 if 5 <= word_count <= 14: clarity += 28 elif 15 <= word_count <= 22: clarity += 18 elif word_count <= 4: clarity -= 12 if has_benefit: clarity += 24 if vague_count and not has_benefit: clarity -= 10 if names_topic_only: clarity -= 12 desire = 25 if has_emotion: desire += 30 if has_benefit: desire += 16 if any(word in word_set for word in {"alivio", "confianza", "claridad", "ventas", "clientes"}): desire += 10 if names_topic_only: desire -= 10 specificity = 24 if has_audience: specificity += 24 if has_specific: specificity += 22 if has_number(headline): specificity += 12 if word_count >= 8: specificity += 8 if names_topic_only: specificity -= 12 differentiation = 24 if has_angle: differentiation += 28 if "sin " in lower or "aunque " in lower: differentiation += 16 if any(word in word_set for word in {"método", "sistema", "secreto", "error", "mito"}): differentiation += 12 if not has_angle and names_topic_only: differentiation -= 10 return { "claridad": clamp_score(clarity), "deseo": clamp_score(desire), "especificidad": clamp_score(specificity), "diferenciacion": clamp_score(differentiation), } def detect_main_problem(headline: str, diagnostico: dict[str, int]) -> str: if len(word_tokens(headline)) <= 4: return "El titular dice el tema, pero no dice por qué debería importarle a la persona." weakest = min(diagnostico, key=diagnostico.get) explanations = { "claridad": "El titular no comunica con suficiente claridad qué cambio promete.", "deseo": "El titular dice algo, pero no despierta suficiente interés emocional.", "especificidad": "El titular es demasiado general y no aterriza para quién es ni qué resultado ofrece.", "diferenciacion": "El titular se entiende, pero suena genérico y necesita un ángulo más propio.", } return explanations[weakest] def missing_elements(headline: str, diagnostico: dict[str, int]) -> list[str]: words = set(word_tokens(headline)) lower = headline.lower() items: list[str] = [] if diagnostico["claridad"] < 62: items.append("Una promesa más clara") if not any(word in BENEFIT_MARKERS for word in words): items.append("Un resultado concreto") if diagnostico["especificidad"] < 62 or not any(word in AUDIENCE_MARKERS for word in words): items.append("Una audiencia más definida") if diagnostico["deseo"] < 62 or not any(word in EMOTION_MARKERS for word in words): items.append("Una situación emocional reconocible") if diagnostico["diferenciacion"] < 62: items.append("Un ángulo menos genérico") if not ("?" in headline or "¿" in headline or "secreto" in lower or "error" in lower): items.append("Más curiosidad") if not ("sin " in lower or "aunque " in lower): items.append("Un contraste más fuerte") if len(word_tokens(headline)) <= 4: items.append("Una razón para seguir leyendo") fallback_items = [ "Un resultado concreto", "Una audiencia más definida", "Una razón para seguir leyendo", "Una promesa más clara", ] unique: list[str] = [] for item in items + fallback_items: if item not in unique: unique.append(item) if len(unique) >= 4: break return unique[:4] if len(unique) >= 4 else unique def headline_strengths(headline: str, diagnostico: dict[str, int]) -> list[str]: words = set(word_tokens(headline)) strengths: list[str] = [] if any(word in BENEFIT_MARKERS for word in words): strengths.append("Tiene una intención o beneficio inicial.") if any(word in AUDIENCE_MARKERS for word in words): strengths.append("Incluye una señal de audiencia.") if has_number(headline) or any(word in SPECIFIC_DETAIL_MARKERS for word in words): strengths.append("Aporta algún detalle específico.") if re.search(r"[?¿]", headline) or any(marker in headline.lower() for marker in CURIOSITY_MARKERS): strengths.append("Tiene una señal de curiosidad o contraste.") if not strengths: strengths.append("Tiene una idea base simple para mejorar.") return strengths[:4] def headline_absences(headline: str, diagnostico: dict[str, int]) -> list[str]: missing = missing_elements(headline, diagnostico) mapping = { "Una promesa más clara": "No comunica una promesa reconocible.", "Un resultado concreto": "No muestra un resultado concreto.", "Una audiencia más definida": "No deja claro para quién es.", "Una situación emocional reconocible": "No conecta con síntoma, frustración o deseo.", "Un ángulo menos genérico": "No tiene un ángulo propio.", "Más curiosidad": "No da una razón clara para seguir leyendo.", "Un contraste más fuerte": "No plantea contraste o tensión.", "Una razón para seguir leyendo": "No explica por qué debería importarle al lector.", } return [mapping.get(item, item) for item in missing[:4]] def analyze_headline(headline: str) -> dict[str, Any]: titular = clean_headline(headline) if not titular: raise ValueError("headline is required") payload, runtime = cached_model_or_rules_analysis(titular) diagnostico = payload["diagnostico"] falta = payload["falta"] return { "ok": True, "app_build": APP_BUILD, "step": "analisis", "runtime": runtime, "analysis_mode": ANALYSIS_MODE, "model_id": MODEL_ID, "titular_original": titular, "diagnostico": diagnostico, "radiografia": { "tiene": headline_strengths(titular, diagnostico), "no_tiene": headline_absences(titular, diagnostico), "le_hace_falta": falta, }, "problema_principal": payload["problema_principal"], "falta": falta, "pregunta_siguiente": "¿Quieres que cree tres propuestas de titulares mejorados a partir de esta radiografía?", } def generate_proposals(headline: str) -> dict[str, Any]: titular = clean_headline(headline) if not titular: raise ValueError("headline is required") payload, runtime = cached_model_or_rules_analysis(titular) return { "ok": True, "app_build": APP_BUILD, "step": "propuestas", "runtime": runtime, "analysis_mode": ANALYSIS_MODE, "model_id": MODEL_ID, "titular_original": titular, "versiones": payload["versiones"], "pregunta_siguiente": "¿Para qué lo quieres o dónde lo vas a usar? Así puedo ayudarte a escoger el mejor.", } def choose_winner(headline: str, versiones: list[str], usage: str) -> dict[str, Any]: titular = clean_headline(headline) clean_versions = [extract_version_text(item) for item in versiones] clean_versions = [item for item in clean_versions if item][:3] if not titular: raise ValueError("headline is required") if len(clean_versions) < 3: clean_versions = (clean_versions + mock_model_payload(titular)["versiones"])[:3] uso = clean_headline(usage) or "uso general" lower_usage = uso.lower() if any(word in lower_usage for word in ("anuncio", "ads", "landing", "web", "venta", "checkout", "página")): winner = 1 reason = "Para ese uso gana la versión más clara, porque reduce fricción y comunica rápido qué obtiene la persona." elif any(word in lower_usage for word in ("instagram", "redes", "email", "correo", "story", "historia", "comunidad")): winner = 2 reason = "Para ese canal gana la versión emocional, porque conecta mejor con deseo, tensión y sensación de identificación." elif any(word in lower_usage for word in ("blog", "youtube", "newsletter", "contenido", "post", "artículo")): winner = 3 reason = "Para contenido gana la versión curiosa, porque abre una pregunta y aumenta las ganas de seguir leyendo." else: winner = 1 reason = "Gana la versión más clara porque es la más segura para un uso general: comunica el tema y la promesa con menos esfuerzo." return { "ok": True, "app_build": APP_BUILD, "step": "ganador", "titular_original": titular, "usage": uso, "versiones": clean_versions, "mini_battle": {"mas_claro": 1, "mas_emocional": 2, "mas_curioso": 3}, "ganador_numero": winner, "ganador": clean_versions[winner - 1], "por_que_gana": reason, } def headline_topic(headline: str) -> str: text = clean_headline(headline) lowered = text.lower() for prefix in WEAK_PREFIXES: if lowered.startswith(prefix): text = text[len(prefix) :].strip(" :.-") or text break if text.lower().startswith("a "): text = text[2:].strip() return text.strip(" :.-") or "tu idea" def _title_case_topic(topic: str) -> str: if not topic: return "Tu idea" return topic[0].upper() + topic[1:] def mock_model_payload(headline: str) -> dict[str, Any]: """Fallback copy using strategic angles without visible rigid templates.""" topic = headline_topic(headline)[:90] display_topic = _title_case_topic(topic) lower_topic = topic.lower() variant = sum(ord(char) for char in topic) % 3 clear_options = [ f"{display_topic} con una promesa clara y fácil de elegir", f"{display_topic} para avanzar con más claridad desde el primer paso", f"{display_topic} explicado de forma simple, útil y aplicable", ] emotional_options = [ f"Menos dudas, más confianza para empezar con {lower_topic}", f"La forma más simple de sentir avance real con {lower_topic}", f"Convierte la confusión alrededor de {lower_topic} en claridad práctica", ] curious_options = [ f"Lo que cambia cuando {lower_topic} deja de ser solo una idea", f"La diferencia entre conocer {lower_topic} y usarlo de verdad", f"El giro que hace que {lower_topic} se vuelva más claro y útil", ] versions = [ clear_options[variant], emotional_options[(variant + 1) % 3], curious_options[(variant + 2) % 3], ] return { "versiones": versions, "ganador_numero": 1, "por_que_gana": "Gana porque combina claridad, beneficio y credibilidad sin sonar exagerado.", } def build_model_prompt( headline: str, diagnostico: dict[str, int] | None = None, problema_principal: str | None = None, falta: list[str] | None = None, ) -> str: payload: dict[str, Any] = {"titular_original": headline} if diagnostico is not None: payload["diagnostico"] = diagnostico if problema_principal: payload["problema_principal"] = problema_principal if falta: payload["falta"] = falta[:4] return json.dumps(payload, ensure_ascii=False) @lru_cache(maxsize=1) def get_tiny_model() -> tuple[Any, Any, Any]: """Load tokenizer/model lazily only when the real runtime is selected.""" torch = importlib.import_module("torch") transformers = importlib.import_module("transformers") tokenizer = transformers.AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True) dtype = torch.float16 if torch.cuda.is_available() else torch.float32 model = transformers.AutoModelForCausalLM.from_pretrained( MODEL_ID, torch_dtype=dtype, device_map="auto" if torch.cuda.is_available() else None, trust_remote_code=True, ) if not torch.cuda.is_available(): model.to("cpu") model.eval() return tokenizer, model, torch def extract_json_object(text: str) -> dict[str, Any]: text = (text or "").strip() if text.startswith("```"): text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.IGNORECASE | re.DOTALL) match = re.search(r"\{.*\}", text, flags=re.DOTALL) if not match: raise ValueError("Model response did not contain JSON") return json.loads(match.group(0)) def _generate_model_payload( headline: str, diagnostico: dict[str, int] | None = None, problema_principal: str | None = None, falta: list[str] | None = None, ) -> dict[str, Any]: tokenizer, model, torch = get_tiny_model() user_prompt = build_model_prompt(headline, diagnostico, problema_principal, falta) messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_prompt}, ] if hasattr(tokenizer, "apply_chat_template"): rendered = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) else: rendered = f"{SYSTEM_PROMPT}\n\n{user_prompt}" inputs = tokenizer(rendered, return_tensors="pt").to(model.device) with torch.no_grad(): output_ids = model.generate( **inputs, max_new_tokens=MAX_NEW_TOKENS, do_sample=False, temperature=None, top_p=None, pad_token_id=tokenizer.eos_token_id, ) generated_ids = output_ids[0][inputs["input_ids"].shape[-1] :] response_text = tokenizer.decode(generated_ids, skip_special_tokens=True) return extract_json_object(response_text) def maybe_gpu(func): if importlib.util.find_spec("spaces") is None: return func spaces_module = importlib.import_module("spaces") return spaces_module.GPU(duration=90)(func) generate_model_payload = maybe_gpu(_generate_model_payload) def extract_version_text(item: Any) -> str: if isinstance(item, str): return item.strip() if isinstance(item, dict): for key in ("version", "titular", "headline", "text", "texto"): value = item.get(key) if isinstance(value, str) and value.strip(): return value.strip() return "" return str(item).strip() def validate_model_payload(candidate: dict[str, Any], headline: str) -> dict[str, Any]: fallback = mock_model_payload(headline) raw_versions = candidate.get("versiones") if isinstance(candidate, dict) else None versions: list[str] = [] for item in raw_versions or []: version = extract_version_text(item).strip().strip('"') if not version: continue lower = version.lower() forbidden_tokens = ("aida", "pas", "bab", "adp", "storybrand", "4ps", "4u", "fab", "pre-suasion", "copywriting", "por_que_gana") visible_templates = ("el secreto detrás", "razones por las que", "x razones", "deja de [", "aprende a [", "¿y si [", "{'version':", '{"version":') if any(token in lower for token in forbidden_tokens + visible_templates): continue if "[" in version or "]" in version or len(version) > 180: continue versions.append(version) if len(versions) < 3: versions = (versions + fallback["versiones"])[:3] else: versions = versions[:3] try: winner = int(candidate.get("ganador_numero", fallback["ganador_numero"])) except (TypeError, ValueError): winner = fallback["ganador_numero"] if winner not in (1, 2, 3): winner = fallback["ganador_numero"] reason = str(candidate.get("por_que_gana") or "").strip() if len(reason) < 12 or any(token in reason.lower() for token in ("aida", "pas", "bab", "adp", "storybrand", "4ps", "4u", "fab", "pre-suasion")): reason = fallback["por_que_gana"] if len(reason) > 220: reason = reason[:217].rstrip() + "..." return {"versiones": versions, "ganador_numero": winner, "por_que_gana": reason} def validate_diagnostico(candidate: Any, fallback: dict[str, int]) -> dict[str, int]: if not isinstance(candidate, dict): return fallback validated: dict[str, int] = {} for key in ("claridad", "deseo", "especificidad", "diferenciacion"): try: value = int(float(candidate[key])) except (KeyError, TypeError, ValueError): return fallback if value < 0 or value > 100: return fallback validated[key] = value return validated def validate_brief_text(value: Any, fallback: str, max_length: int = 180) -> str: text = re.sub(r"\s+", " ", str(value or "").strip()) forbidden = ("aida", "pas", "bab", "adp", "storybrand", "4ps", "4u", "fab", "pre-suasion", "copywriting", "```", "#") if len(text) < 8 or any(token in text.lower() for token in forbidden): return fallback if len(text) > max_length: return text[: max_length - 3].rstrip() + "..." return text def validate_missing_list(candidate: Any, fallback: list[str]) -> list[str]: if not isinstance(candidate, list): return fallback[:4] clean_items: list[str] = [] for item in candidate: text = validate_brief_text(item, "", max_length=90) if text and text not in clean_items and "[" not in text and "]" not in text: clean_items.append(text) if len(clean_items) >= 4: break if len(clean_items) < 3: return fallback[:4] return clean_items def validate_full_model_payload(candidate: dict[str, Any], headline: str) -> dict[str, Any]: fallback_diagnostico = diagnose_headline(headline) fallback_problem = detect_main_problem(headline, fallback_diagnostico) fallback_falta = missing_elements(headline, fallback_diagnostico) generated = validate_model_payload(candidate if isinstance(candidate, dict) else {}, headline) diagnostico = validate_diagnostico( candidate.get("diagnostico") if isinstance(candidate, dict) else None, fallback_diagnostico, ) problema = validate_brief_text( candidate.get("problema_principal") if isinstance(candidate, dict) else None, fallback_problem, max_length=180, ) falta = validate_missing_list( candidate.get("falta") if isinstance(candidate, dict) else None, fallback_falta, ) return { "diagnostico": diagnostico, "problema_principal": problema, "falta": falta, "versiones": generated["versiones"], "ganador_numero": generated["ganador_numero"], "por_que_gana": generated["por_que_gana"], } def model_or_mock_payload( headline: str, diagnostico: dict[str, int] | None = None, problema_principal: str | None = None, falta: list[str] | None = None, ) -> tuple[dict[str, Any], str]: if not should_use_real_model(): return validate_model_payload(mock_model_payload(headline), headline), "mock" try: model_payload = generate_model_payload(headline, diagnostico, problema_principal, falta) return validate_model_payload(model_payload, headline), "model" except Exception: return validate_model_payload(mock_model_payload(headline), headline), "mock" def model_or_rules_analysis(headline: str) -> tuple[dict[str, Any], str]: fallback_diagnostico = diagnose_headline(headline) fallback_problem = detect_main_problem(headline, fallback_diagnostico) fallback_falta = missing_elements(headline, fallback_diagnostico) if ANALYSIS_MODE == "rules" or not should_use_real_model(): generated, runtime = model_or_mock_payload(headline, fallback_diagnostico, fallback_problem, fallback_falta) return { "diagnostico": fallback_diagnostico, "problema_principal": fallback_problem, "falta": fallback_falta, "versiones": generated["versiones"], "ganador_numero": generated["ganador_numero"], "por_que_gana": generated["por_que_gana"], }, runtime try: model_payload = generate_model_payload(headline, fallback_diagnostico, fallback_problem, fallback_falta) return validate_full_model_payload(model_payload, headline), "model" except Exception: generated = validate_model_payload(mock_model_payload(headline), headline) return { "diagnostico": fallback_diagnostico, "problema_principal": fallback_problem, "falta": fallback_falta, "versiones": generated["versiones"], "ganador_numero": generated["ganador_numero"], "por_que_gana": generated["por_que_gana"], }, "mock" def cached_model_or_rules_analysis(headline: str) -> tuple[dict[str, Any], str]: cache_key = clean_headline(headline).lower() if ANALYSIS_MODE != "rules" and cache_key in _MODEL_ANALYSIS_CACHE: return _MODEL_ANALYSIS_CACHE[cache_key] result, runtime = model_or_rules_analysis(headline) if ANALYSIS_MODE != "rules": if len(_MODEL_ANALYSIS_CACHE) >= 64: _MODEL_ANALYSIS_CACHE.pop(next(iter(_MODEL_ANALYSIS_CACHE))) _MODEL_ANALYSIS_CACHE[cache_key] = (result, runtime) return result, runtime def build_full_response(titular: str, payload: dict[str, Any], runtime: str) -> dict[str, Any]: versiones = payload["versiones"] ganador_numero = payload["ganador_numero"] ganador = versiones[ganador_numero - 1] return { "ok": True, "app_build": APP_BUILD, "runtime": runtime, "analysis_mode": ANALYSIS_MODE, "model_id": MODEL_ID, "titular_original": titular, "diagnostico": payload["diagnostico"], "problema_principal": payload["problema_principal"], "falta": payload["falta"], "versiones": versiones, "mini_battle": {"mas_claro": 1, "mas_emocional": 2, "mas_curioso": 3}, "ganador_numero": ganador_numero, "ganador": ganador, "por_que_gana": payload["por_que_gana"], } def improve_headline(headline: str) -> dict[str, Any]: titular = clean_headline(headline) if not titular: raise ValueError("headline is required") payload, runtime = cached_model_or_rules_analysis(titular) return build_full_response(titular, payload, runtime) server = gr.Server( title="Headline Booster AI", description="Small-model headline optimizer API for Hugging Face Spaces.", ) @server.get("/", response_class=HTMLResponse) def root() -> HTMLResponse: if not INDEX_PATH.exists(): raise HTTPException(status_code=500, detail="index.html not found") return HTMLResponse(INDEX_PATH.read_text(encoding="utf-8")) @server.get("/health") def health() -> JSONResponse: return JSONResponse( { "ok": True, "app_build": APP_BUILD, "runtime_mode": USE_REAL_MODEL, "analysis_mode": ANALYSIS_MODE, "uses_model_now": should_use_real_model(), "model_id": MODEL_ID, } ) @server.post("/api/analyze_headline") def analyze_headline_endpoint(payload: HeadlineRequest) -> JSONResponse: try: result = analyze_headline(payload.headline) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return JSONResponse(result) @server.post("/api/create_proposals") def create_proposals_endpoint(payload: ProposalRequest) -> JSONResponse: try: result = generate_proposals(payload.headline) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return JSONResponse(result) @server.post("/api/choose_winner") def choose_winner_endpoint(payload: WinnerRequest) -> JSONResponse: try: result = choose_winner(payload.headline, payload.versiones, payload.usage) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return JSONResponse(result) @server.post("/api/improve_headline") def improve_headline_endpoint(payload: HeadlineRequest) -> JSONResponse: try: result = improve_headline(payload.headline) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return JSONResponse(result) if __name__ == "__main__": server.launch( server_name="0.0.0.0", server_port=int(os.getenv("PORT", "7860")), quiet=False, _frontend=False, )