diff --git a/src/bot/bot.py b/src/bot/bot.py index fa1a1af..f7a5a96 100644 --- a/src/bot/bot.py +++ b/src/bot/bot.py @@ -278,7 +278,11 @@ async def cmd_generate(update: Update, ctx: ContextTypes.DEFAULT_TYPE): chat_id = update.effective_chat.id output_arg = ctx.args[0].lower() if ctx.args else "" - lang = "en" if len(ctx.args) > 1 and ctx.args[1].lower() == "en" else "es" + rest = [a.lower() for a in ctx.args[1:]] + lang = "en" if "en" in rest else "es" + # `/generate blog en dry` forces SEO dry-run for this one call (proposes SEO to + # Telegram, writes a bare draft). Global default still comes from SEO_AUTOFILL. + seo_override = "dryrun" if ("dry" in rest or "dryrun" in rest) else None type_map = { "podcast": OutputType.PODCAST, @@ -347,7 +351,8 @@ async def cmd_generate(update: Update, ctx: ContextTypes.DEFAULT_TYPE): processor = ContentProcessor(db, ollama) generator = OutputGenerator(db, ollama, processor) - output = await generator.generate(session_id, output_type, gen_progress, lang=lang) + output = await generator.generate(session_id, output_type, gen_progress, + lang=lang, seo_override=seo_override) # Send as file if very long if len(output) > 8000: @@ -384,6 +389,18 @@ async def cmd_generate(update: Update, ctx: ContextTypes.DEFAULT_TYPE): else: await send_chunked(update.message, output) + # SEO autofill / dry-run summary — a SEPARATE short message so it is never + # buried inside the long .md document. None on the flag-off path. + if getattr(generator, "last_publish_notice", None): + try: + await update.message.reply_text( + generator.last_publish_notice, + parse_mode=ParseMode.MARKDOWN, + disable_web_page_preview=True, + ) + except Exception as e: + logger.warning("Failed to send SEO summary message", error=str(e)) + try: stats = await db.get_usage_stats(session_id) total_cost = sum(s.get("total_cost", 0) for s in stats) diff --git a/src/config.py b/src/config.py index 76cbe44..f29730c 100644 --- a/src/config.py +++ b/src/config.py @@ -48,6 +48,15 @@ class Settings(BaseSettings): ghost_url_en: str = Field("", env="GHOST_URL_EN") ghost_api_key_en: str = Field("", env="GHOST_API_KEY_EN") + # SEO autofill — "off" | "on" | "dryrun" (default off). + # off = today's exact behavior (bare draft, no second LLM call). + # on = adds best-effort meta/OG/Twitter/tags/internal-links to the DRAFT + # (status stays "draft" — NEVER auto-publishes; see src/seo/autofill.py). + # dryrun = runs the full pipeline + reports the proposed SEO to Telegram, but + # writes a BARE draft (no seo fields), for inspection before trusting + # the write path. A `/generate blog en dry` arg forces dryrun per-call. + seo_autofill: str = Field("off", env="SEO_AUTOFILL") + # Alerts cost_alert_threshold: float = Field(0.15, env="COST_ALERT_THRESHOLD") @@ -61,6 +70,15 @@ class Settings(BaseSettings): return [] return [int(uid.strip()) for uid in self.telegram_allowed_users.split(",") if uid.strip()] + @property + def seo_autofill_enabled(self) -> bool: + """True when autofill should run (either live 'on' or 'dryrun').""" + return (self.seo_autofill or "").strip().lower() in ("on", "true", "1", "yes", "dryrun") + + @property + def seo_autofill_dryrun(self) -> bool: + return (self.seo_autofill or "").strip().lower() == "dryrun" + class Config: env_file = ".env" diff --git a/src/generator/generator.py b/src/generator/generator.py index 1793af6..5aca152 100644 --- a/src/generator/generator.py +++ b/src/generator/generator.py @@ -290,8 +290,94 @@ def _seo_checklist(slug: str) -> str: ) +# Language-aware default tag when no seo/tags are supplied. Fixes the historical +# hardcoded "investigacion" that mis-tagged EN posts; EN canonical is "investigation". +_DEFAULT_TAG = {"en": "investigation", "es": "investigacion"} + + +def _resolve_seo_mode(override: str | None = None) -> str: + """Effective SEO autofill mode: 'off' | 'on' | 'dryrun'. + A per-call override ('dryrun'/'on', e.g. from `/generate blog en dry`) wins; + otherwise it derives from settings.seo_autofill.""" + if override in ("on", "dryrun"): + return override + if settings.seo_autofill_dryrun: + return "dryrun" + if settings.seo_autofill_enabled: + return "on" + return "off" + + +def _seo_checklist_slim(slug: str) -> str: + """Slimmed checklist for the autofill path: meta/OG/excerpt/tags/links are + already on the draft, so only the human-only bits remain.""" + return ( + "⚠️ Antes de publicar: añade *feature image + alt*, luego:\n" + f"`seo-check {slug}`" + ) + + +def _bare_ghost_notice(ghost: "GhostPublisher", post: dict) -> str: + """Today's exact draft-published notice (editor link + full checklist). + Used for the flag-off path and as the autofill failure fallback.""" + return ( + f"\n\n---\n" + f"📤 *Borrador publicado en Ghost*\n" + f"Editar: {ghost.url}/ghost/#/editor/post/{post['id']}" + f"{_seo_checklist(post.get('slug', ''))}" + ) + + +def _seo_link_summary(seo: dict, n_links: int) -> str: + pairs = [(l["phrase"], l["slug"]) for l in seo.get("internal_links", [])] + return f"{n_links} insertados" + (f" — {pairs}" if pairs else "") + + +def _seo_live_message(ghost: "GhostPublisher", post: dict, seo: dict, n_links: int) -> str: + """Separate Telegram message for the live autofill path (SEO written to draft).""" + slug = post.get("slug", "") + warns = seo.get("seo_warnings") or [] + warn_line = ("\n⚠️ revisar: " + "; ".join(warns)) if warns else "" + return ( + f"📤 *Borrador en Ghost — SEO autorrelleno*\n" + f"Editar: {ghost.url}/ghost/#/editor/post/{post['id']}\n\n" + f"🔖 *SEO escrito en el draft:*\n" + f"• meta title ({len(seo['meta_title'])}): {seo['meta_title']}\n" + f"• meta desc ({len(seo['meta_description'])}): {seo['meta_description']}\n" + f"• excerpt: {len(seo['custom_excerpt'])} car.\n" + f"• tags: {', '.join(seo['tags'])}\n" + f"• internal links: {_seo_link_summary(seo, n_links)}" + f"{warn_line}\n\n" + f"🖼️ *Imagen sugerida:*\n" + f"`imagefinder --query \"{seo['image_query']}\" --context \"{seo['image_context']}\" --lang {ghost.lang}`\n\n" + f"{_seo_checklist_slim(slug)}" + ) + + +def _seo_dryrun_message(ghost: "GhostPublisher", post: dict, seo: dict, n_links: int) -> str: + """Separate Telegram message for DRY-RUN: bare draft created, SEO only proposed.""" + slug = post.get("slug", "") + warns = seo.get("seo_warnings") or [] + warn_line = ("\n⚠️ revisar: " + "; ".join(warns)) if warns else "" + return ( + f"🧪 *DRY-RUN SEO* — draft creado SIN escribir SEO (solo revisión)\n" + f"Editar: {ghost.url}/ghost/#/editor/post/{post['id']}\n\n" + f"🔖 *SEO propuesto (NO escrito en Ghost):*\n" + f"• meta title ({len(seo['meta_title'])}): {seo['meta_title']}\n" + f"• meta desc ({len(seo['meta_description'])}): {seo['meta_description']}\n" + f"• excerpt ({len(seo['custom_excerpt'])} car.): {seo['custom_excerpt']}\n" + f"• tags: {', '.join(seo['tags'])}\n" + f"• internal links que se insertarían: {_seo_link_summary(seo, n_links)}" + f"{warn_line}\n\n" + f"🖼️ *Imagen sugerida:*\n" + f"`imagefinder --query \"{seo['image_query']}\" --context \"{seo['image_context']}\" --lang {ghost.lang}`\n\n" + f"{_seo_checklist_slim(slug)}" + ) + + class GhostPublisher: def __init__(self, lang: str = "es"): + self.lang = lang if lang == "en": self.url = (settings.ghost_url_en or "").rstrip("/") self.api_key = settings.ghost_api_key_en or "" @@ -302,6 +388,16 @@ class GhostPublisher: def is_configured(self) -> bool: return bool(self.url and self.api_key) + def _build_html(self, markdown_content: str) -> str: + """Canonical markdown → Ghost body HTML conversion: strip the ResearchOwl + header, render markdown, drop the first