fix(seo): sanitize malformed link suggestions + clean link report
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7a995da88f
commit
6d39875fcf
+21
-11
@@ -328,16 +328,25 @@ def _bare_ghost_notice(ghost: "GhostPublisher", post: dict) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _seo_link_summary(seo: dict, n_links: int) -> str:
|
def _seo_link_summary(inserted_pairs: list[dict], n_suggestions: int) -> str:
|
||||||
pairs = [(l["phrase"], l["slug"]) for l in seo.get("internal_links", [])]
|
"""Report ACTUALLY-INSERTED links only ("phrase → /slug/"), with a separate
|
||||||
return f"{n_links} insertados" + (f" — {pairs}" if pairs else "")
|
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)."""
|
"""Separate Telegram message for the live autofill path (SEO written to draft)."""
|
||||||
slug = post.get("slug", "")
|
slug = post.get("slug", "")
|
||||||
warns = seo.get("seo_warnings") or []
|
warns = seo.get("seo_warnings") or []
|
||||||
warn_line = ("\n⚠️ revisar: " + "; ".join(warns)) if warns else ""
|
warn_line = ("\n⚠️ revisar: " + "; ".join(warns)) if warns else ""
|
||||||
|
n_sug = len(seo.get("internal_links", []))
|
||||||
return (
|
return (
|
||||||
f"📤 *Borrador en Ghost — SEO autorrelleno*\n"
|
f"📤 *Borrador en Ghost — SEO autorrelleno*\n"
|
||||||
f"Editar: {ghost.url}/ghost/#/editor/post/{post['id']}\n\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"• meta desc ({len(seo['meta_description'])}): {seo['meta_description']}\n"
|
||||||
f"• excerpt: {len(seo['custom_excerpt'])} car.\n"
|
f"• excerpt: {len(seo['custom_excerpt'])} car.\n"
|
||||||
f"• tags: {', '.join(seo['tags'])}\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"{warn_line}\n\n"
|
||||||
f"🖼️ *Imagen sugerida:*\n"
|
f"🖼️ *Imagen sugerida:*\n"
|
||||||
f"`imagefinder --query \"{seo['image_query']}\" --context \"{seo['image_context']}\" --lang {ghost.lang}`\n\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."""
|
"""Separate Telegram message for DRY-RUN: bare draft created, SEO only proposed."""
|
||||||
slug = post.get("slug", "")
|
slug = post.get("slug", "")
|
||||||
warns = seo.get("seo_warnings") or []
|
warns = seo.get("seo_warnings") or []
|
||||||
warn_line = ("\n⚠️ revisar: " + "; ".join(warns)) if warns else ""
|
warn_line = ("\n⚠️ revisar: " + "; ".join(warns)) if warns else ""
|
||||||
|
n_sug = len(seo.get("internal_links", []))
|
||||||
return (
|
return (
|
||||||
f"🧪 *DRY-RUN SEO* — draft creado SIN escribir SEO (solo revisión)\n"
|
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"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"• meta desc ({len(seo['meta_description'])}): {seo['meta_description']}\n"
|
||||||
f"• excerpt ({len(seo['custom_excerpt'])} car.): {seo['custom_excerpt']}\n"
|
f"• excerpt ({len(seo['custom_excerpt'])} car.): {seo['custom_excerpt']}\n"
|
||||||
f"• tags: {', '.join(seo['tags'])}\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"{warn_line}\n\n"
|
||||||
f"🖼️ *Imagen sugerida:*\n"
|
f"🖼️ *Imagen sugerida:*\n"
|
||||||
f"`imagefinder --query \"{seo['image_query']}\" --context \"{seo['image_context']}\" --lang {ghost.lang}`\n\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")
|
raise RuntimeError("generate_seo_fields returned None")
|
||||||
# Build the SAME html that will be posted, then link it deterministically.
|
# Build the SAME html that will be posted, then link it deterministically.
|
||||||
html = ghost._build_html(article_md)
|
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)
|
html, seo["internal_links"], menu, lang)
|
||||||
|
|
||||||
if mode == "dryrun":
|
if mode == "dryrun":
|
||||||
@@ -531,15 +541,15 @@ class OutputGenerator:
|
|||||||
# surface the proposal so Jose can inspect before trusting writes.
|
# surface the proposal so Jose can inspect before trusting writes.
|
||||||
result = await ghost.publish_draft(title, full_output)
|
result = await ghost.publish_draft(title, full_output)
|
||||||
post = result["posts"][0]
|
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:
|
else:
|
||||||
result = await ghost.publish_draft(
|
result = await ghost.publish_draft(
|
||||||
title, full_output, tags=seo["tags"], seo=seo,
|
title, full_output, tags=seo["tags"], seo=seo,
|
||||||
body_html=linked_html)
|
body_html=linked_html)
|
||||||
post = result["posts"][0]
|
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",
|
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 ""
|
return ""
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("SEO autofill failed; falling back to bare draft",
|
logger.warning("SEO autofill failed; falling back to bare draft",
|
||||||
|
|||||||
+36
-3
@@ -484,6 +484,7 @@ async def generate_seo_fields(
|
|||||||
base_msgs = [{"role": "user", "content": user}]
|
base_msgs = [{"role": "user", "content": user}]
|
||||||
text1 = await _raw(base_msgs)
|
text1 = await _raw(base_msgs)
|
||||||
fields = _coerce(_parse_json_object(text1), lang)
|
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).
|
# Validate against the shared engine using the real body (md→html + links).
|
||||||
body_html = _markdown_to_html(article_text)
|
body_html = _markdown_to_html(article_text)
|
||||||
@@ -508,6 +509,7 @@ async def generate_seo_fields(
|
|||||||
# temperature=0 so the shorten instruction binds deterministically.
|
# temperature=0 so the shorten instruction binds deterministically.
|
||||||
retry = _coerce(_parse_json_object(
|
retry = _coerce(_parse_json_object(
|
||||||
await _raw(retry_msgs, max_tokens=768, temperature=0.0)), lang)
|
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(
|
rlinked, _ = insert_internal_links(
|
||||||
_markdown_to_html(article_text), retry["internal_links"], link_menu, lang)
|
_markdown_to_html(article_text), retry["internal_links"], link_menu, lang)
|
||||||
rviol = R.check_post(_synthetic_post(retry, rlinked, title, slug))
|
rviol = R.check_post(_synthetic_post(retry, rlinked, title, slug))
|
||||||
@@ -550,6 +552,34 @@ async def generate_seo_fields(
|
|||||||
|
|
||||||
# ─── 3. Deterministic internal-link insertion ──────────────────────────────
|
# ─── 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(
|
def insert_internal_links(
|
||||||
html: str,
|
html: str,
|
||||||
suggestions: list[dict],
|
suggestions: list[dict],
|
||||||
@@ -563,10 +593,11 @@ def insert_internal_links(
|
|||||||
(never inside a tag, never inside an existing <a>); word-boundary match (no
|
(never inside a tag, never inside an existing <a>); word-boundary match (no
|
||||||
partial words); each phrase/slug used at most once; cap at MAX_INTERNAL_LINKS.
|
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,
|
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:
|
if not html or not suggestions:
|
||||||
return html, 0
|
return html, []
|
||||||
|
|
||||||
site = SITE_BY_LANG.get(lang, SITE_BY_LANG["en"])
|
site = SITE_BY_LANG.get(lang, SITE_BY_LANG["en"])
|
||||||
menu_slugs = {m["slug"] for m in menu if m.get("slug")}
|
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.
|
# links and no edits inside tag attributes.
|
||||||
tokens = re.split(r"(<[^>]+>)", html)
|
tokens = re.split(r"(<[^>]+>)", html)
|
||||||
inserted = 0
|
inserted = 0
|
||||||
|
inserted_pairs: list[dict] = []
|
||||||
used_phrases: set[str] = set()
|
used_phrases: set[str] = set()
|
||||||
used_slugs: 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():]
|
tokens[i] = tok[:m.start()] + replacement + tok[m.end():]
|
||||||
inserted += 1
|
inserted += 1
|
||||||
|
inserted_pairs.append({"phrase": phrase, "slug": slug})
|
||||||
used_phrases.add(phrase)
|
used_phrases.add(phrase)
|
||||||
used_slugs.add(slug)
|
used_slugs.add(slug)
|
||||||
done = True
|
done = True
|
||||||
@@ -620,4 +653,4 @@ def insert_internal_links(
|
|||||||
logger.debug("seo.links: phrase not found / already linked, skipped",
|
logger.debug("seo.links: phrase not found / already linked, skipped",
|
||||||
phrase=phrase, slug=slug)
|
phrase=phrase, slug=slug)
|
||||||
|
|
||||||
return "".join(tokens), inserted
|
return "".join(tokens), inserted_pairs
|
||||||
|
|||||||
Reference in New Issue
Block a user