JeCabrera's picture
Sync from GitHub via hub-sync
30979b3 verified
Raw
History Blame Contribute Delete
32 kB
"""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,
)