diff --git a/src/generator/generator.py b/src/generator/generator.py index 5aca152..9a664c9 100644 --- a/src/generator/generator.py +++ b/src/generator/generator.py @@ -328,16 +328,25 @@ def _bare_ghost_notice(ghost: "GhostPublisher", post: dict) -> str: ) -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_link_summary(inserted_pairs: list[dict], n_suggestions: int) -> str: + """Report ACTUALLY-INSERTED links only ("phrase → /slug/"), with a separate + count of well-formed suggestions that were skipped (no verbatim body match). + Never surfaces raw rejected suggestions.""" + n = len(inserted_pairs) + skipped = max(0, n_suggestions - n) + head = f"{n} insertados" + (f", {skipped} omitidos" if skipped else "") + if not inserted_pairs: + return head + body = "; ".join(f"{p['phrase']} → /{p['slug']}/" for p in inserted_pairs) + return f"{head} — {body}" -def _seo_live_message(ghost: "GhostPublisher", post: dict, seo: dict, n_links: int) -> str: +def _seo_live_message(ghost: "GhostPublisher", post: dict, seo: dict, inserted_pairs: list[dict]) -> 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 "" + n_sug = len(seo.get("internal_links", [])) return ( f"📤 *Borrador en Ghost — SEO autorrelleno*\n" f"Editar: {ghost.url}/ghost/#/editor/post/{post['id']}\n\n" @@ -346,7 +355,7 @@ def _seo_live_message(ghost: "GhostPublisher", post: dict, seo: dict, n_links: i 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"• internal links: {_seo_link_summary(inserted_pairs, n_sug)}" f"{warn_line}\n\n" f"🖼️ *Imagen sugerida:*\n" f"`imagefinder --query \"{seo['image_query']}\" --context \"{seo['image_context']}\" --lang {ghost.lang}`\n\n" @@ -354,11 +363,12 @@ def _seo_live_message(ghost: "GhostPublisher", post: dict, seo: dict, n_links: i ) -def _seo_dryrun_message(ghost: "GhostPublisher", post: dict, seo: dict, n_links: int) -> str: +def _seo_dryrun_message(ghost: "GhostPublisher", post: dict, seo: dict, inserted_pairs: list[dict]) -> 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 "" + n_sug = len(seo.get("internal_links", [])) 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" @@ -367,7 +377,7 @@ def _seo_dryrun_message(ghost: "GhostPublisher", post: dict, seo: dict, n_links: 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"• internal links que se insertarían: {_seo_link_summary(inserted_pairs, n_sug)}" f"{warn_line}\n\n" f"🖼️ *Imagen sugerida:*\n" f"`imagefinder --query \"{seo['image_query']}\" --context \"{seo['image_context']}\" --lang {ghost.lang}`\n\n" @@ -523,7 +533,7 @@ class OutputGenerator: raise RuntimeError("generate_seo_fields returned None") # Build the SAME html that will be posted, then link it deterministically. html = ghost._build_html(article_md) - linked_html, n_links = insert_internal_links( + linked_html, inserted_pairs = insert_internal_links( html, seo["internal_links"], menu, lang) if mode == "dryrun": @@ -531,15 +541,15 @@ class OutputGenerator: # surface the proposal so Jose can inspect before trusting writes. result = await ghost.publish_draft(title, full_output) post = result["posts"][0] - self.last_publish_notice = _seo_dryrun_message(ghost, post, seo, n_links) + self.last_publish_notice = _seo_dryrun_message(ghost, post, seo, inserted_pairs) else: result = await ghost.publish_draft( title, full_output, tags=seo["tags"], seo=seo, body_html=linked_html) post = result["posts"][0] - self.last_publish_notice = _seo_live_message(ghost, post, seo, n_links) + self.last_publish_notice = _seo_live_message(ghost, post, seo, inserted_pairs) logger.info("Auto-published blog to Ghost", - mode=mode, post_id=post["id"], links=n_links) + mode=mode, post_id=post["id"], links=len(inserted_pairs)) return "" except Exception as e: logger.warning("SEO autofill failed; falling back to bare draft", diff --git a/src/seo/autofill.py b/src/seo/autofill.py index 92cc66e..8eee0b6 100644 --- a/src/seo/autofill.py +++ b/src/seo/autofill.py @@ -484,6 +484,7 @@ async def generate_seo_fields( base_msgs = [{"role": "user", "content": user}] text1 = await _raw(base_msgs) fields = _coerce(_parse_json_object(text1), lang) + fields["internal_links"] = _sanitize_links(fields["internal_links"], link_menu) # Validate against the shared engine using the real body (md→html + links). body_html = _markdown_to_html(article_text) @@ -508,6 +509,7 @@ async def generate_seo_fields( # temperature=0 so the shorten instruction binds deterministically. retry = _coerce(_parse_json_object( await _raw(retry_msgs, max_tokens=768, temperature=0.0)), lang) + retry["internal_links"] = _sanitize_links(retry["internal_links"], link_menu) rlinked, _ = insert_internal_links( _markdown_to_html(article_text), retry["internal_links"], link_menu, lang) rviol = R.check_post(_synthetic_post(retry, rlinked, title, slug)) @@ -550,6 +552,34 @@ async def generate_seo_fields( # ─── 3. Deterministic internal-link insertion ────────────────────────────── +def _sanitize_links(suggestions: list[dict], menu: list[dict]) -> list[dict]: + """Defense-in-depth: drop malformed link suggestions before insertion/reporting. + + The "phrase" must be SHORT verbatim ARTICLE text — never a slug or a menu + title. A suggestion is dropped when its phrase equals its own slug, any menu + slug, or any menu title (the slug-as-phrase / title-as-phrase traps). Even if + the model ignores the prompt rule, the output stays clean. Drops are logged at + debug and never raise; survivors still go through the verbatim-in-body guard. + """ + menu_slugs = {(m.get("slug") or "").strip().casefold() for m in menu} + menu_titles = {(m.get("title") or "").strip().casefold() for m in menu} + menu_slugs.discard("") + menu_titles.discard("") + clean: list[dict] = [] + for s in suggestions: + phrase = (s.get("phrase") or "").strip() + slug = (s.get("slug") or "").strip() + if not phrase or not slug: + continue + pf = phrase.casefold() + if pf == slug.casefold() or pf in menu_slugs or pf in menu_titles: + logger.debug("seo.links: dropped malformed suggestion (slug/title as phrase)", + phrase=phrase, slug=slug) + continue + clean.append({"phrase": phrase, "slug": slug}) + return clean + + def insert_internal_links( html: str, suggestions: list[dict], @@ -563,10 +593,11 @@ def insert_internal_links( (never inside a tag, never inside an existing ); word-boundary match (no partial words); each phrase/slug used at most once; cap at MAX_INTERNAL_LINKS. A phrase that is missing or already linked is silently skipped (never forced, - never an error). Returns (modified_html, links_inserted). + never an error). Returns (modified_html, inserted_pairs) where inserted_pairs + is the list of {phrase, slug} dicts actually wrapped (len == links inserted). """ if not html or not suggestions: - return html, 0 + return html, [] site = SITE_BY_LANG.get(lang, SITE_BY_LANG["en"]) menu_slugs = {m["slug"] for m in menu if m.get("slug")} @@ -576,6 +607,7 @@ def insert_internal_links( # links and no edits inside tag attributes. tokens = re.split(r"(<[^>]+>)", html) inserted = 0 + inserted_pairs: list[dict] = [] used_phrases: set[str] = set() used_slugs: set[str] = set() @@ -612,6 +644,7 @@ def insert_internal_links( ) tokens[i] = tok[:m.start()] + replacement + tok[m.end():] inserted += 1 + inserted_pairs.append({"phrase": phrase, "slug": slug}) used_phrases.add(phrase) used_slugs.add(slug) done = True @@ -620,4 +653,4 @@ def insert_internal_links( logger.debug("seo.links: phrase not found / already linked, skipped", phrase=phrase, slug=slug) - return "".join(tokens), inserted + return "".join(tokens), inserted_pairs