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
+36
-3
@@ -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 <a>); 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
|
||||
|
||||
Reference in New Issue
Block a user