From 770d09cced96d4e32790b572d422c134ebe49674 Mon Sep 17 00:00:00 2001 From: lin-bot23 Date: Tue, 13 Oct 2026 06:46:02 +0800 Subject: [PATCH 1/3] fix(sync): keep generated meta descriptions complete and within 160 chars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs repo asks every page for a 40-160 character meta description, but get_description_from_content() lost the summary in two ways: * The English sentence split (". " + uppercase lookahead) never matched a localized page, because Korean and friends start the next sentence with a non-ASCII letter. Those pages fell through to a hard 180-char cut, so 888 Korean descriptions ended mid-sentence. * The fallback cut sliced through words ("... plug it into Latent Apply Operation or La"), and the 180-char cap was above the documented window. Now the split also accepts a non-ASCII sentence start, the shorten step cuts on the last sentence, clause or word boundary instead of a raw character index, the cap is the documented 160, and the Korean disclaimer marker ("AI가 생성", previously only "AI에 의해 생성") is skipped so those pages no longer advertise the disclaimer as their description. Re-running the sync regenerates ~890 ko + ~100 en descriptions; existing descriptions are otherwise unchanged. --- docs-generation/scripts/sync_to_comfy_docs.py | 75 +++++++++++++++---- docs-generation/tests/test_sync_helpers.py | 41 +++++++++- 2 files changed, 101 insertions(+), 15 deletions(-) diff --git a/docs-generation/scripts/sync_to_comfy_docs.py b/docs-generation/scripts/sync_to_comfy_docs.py index 338184cdc2..c147f38a08 100644 --- a/docs-generation/scripts/sync_to_comfy_docs.py +++ b/docs-generation/scripts/sync_to_comfy_docs.py @@ -792,6 +792,61 @@ def _migrate_toplevel_groups_to_wrapper(pages: list[Any], wrapper_label: str) -> wrapper.append(orphan) +# Comfy-Org/docs AGENTS.md asks every page for a 40-160 character meta +# description; the generated frontmatter uses the same window. +MAX_DESCRIPTION_CHARS = 160 +_MIN_DESCRIPTION_CHARS = 40 +# Function words a shortened description may not end on, so the cut still +# reads as a whole phrase. +_DANGLING_WORDS = frozenset( + """a an the and or but nor for to of in on at by with from as such that this + these those it its their your our is are was were be been being""".split() +) + + +def _first_sentence(paragraph: str) -> str: + """Return the first sentence of ``paragraph`` (the whole text if it has none).""" + # English: ". " only ends a sentence when the next character is uppercase, + # so "e.g." / "i.e." abbreviations survive. Localized pages (Korean, …) + # start the next sentence with a non-ASCII letter, which the uppercase rule + # never matched: those pages fell through to the hard cut and lost their + # summary mid-sentence. + m = re.search(r"\.\s+(?=[A-Z])", paragraph) or re.search( + r"\.\s+(?=[^\x00-\x7f])", paragraph + ) + if m: + return paragraph[: m.end()].strip() + idx = paragraph.find("。") + if idx != -1: + return paragraph[: idx + 1].strip() + return paragraph.strip() + + +def _trim_to_length(text: str, limit: int = MAX_DESCRIPTION_CHARS) -> str: + """Shorten ``text`` to ``limit`` characters without slicing through a word.""" + if len(text) <= limit: + return text.strip() + head = text[:limit] + # Prefer the last complete sentence, then the last clause, then the last + # whole word — a hard character cut leaves search results ending in "or La". + # A period only ends a sentence when whitespace or the end follows, so + # versions and decimals ("2.5", "Rec.2020") do not win the search. + last_ender = max( + (m.end() for m in re.finditer(r"(?:[。!?]|[.!?](?=\s|$))", head)), + default=0, + ) + if last_ender > _MIN_DESCRIPTION_CHARS: + return head[:last_ender].strip() + for ender in (",", ",", ";", ";", "、"): + i = head.rfind(ender) + if i > _MIN_DESCRIPTION_CHARS: + return head[:i].strip() + words = head.rsplit(" ", 1)[0].strip(" ,;:").split() + while words and words[-1].lower().strip(",;:") in _DANGLING_WORDS: + words.pop() + return " ".join(words) or head.strip() + + def get_description_from_content(content: str) -> str: """Extract the first sentence from the first content paragraph. @@ -820,21 +875,14 @@ def get_description_from_content(content: str) -> str: or "AI 生成" in line_stripped or "AI によって生成" in line_stripped or "AI에 의해 생성" in line_stripped + or "AI가 생성" in line_stripped ): continue first_para.append(line_stripped) paragraph = " ".join(first_para) if first_para else "" - # Return only the first sentence. For English, split on ". " only when the - # next char is uppercase (avoids cutting "e.g." / "i.e." abbreviations); - # CJK sentence ender "。" splits unconditionally. - m = re.search(r"\.\s+(?=[A-Z])", paragraph) - if m: - return paragraph[: m.end()] - idx = paragraph.find("。") - if idx != -1: - return paragraph[: idx + 1] - # Align fallback truncation with the 180-char frontmatter limit - return paragraph[:180] if paragraph else "" + if not paragraph: + return "" + return _trim_to_length(_first_sentence(paragraph)) def find_local_asset_refs(content: str, doc_dir: Path) -> list[tuple[str, Path]]: @@ -1010,9 +1058,10 @@ def build_frontmatter(node_name: str, description: str) -> str: seo_desc = (description or "").strip() if not seo_desc: seo_desc = f"Complete documentation for the {node_name} node in ComfyUI. Learn its inputs, outputs, parameters and usage." - # Keep meta descriptions reasonably short; strip markdown/backticks noise + # Keep meta descriptions within the docs-repo guideline (40-160 chars); + # strip markdown/backticks noise seo_desc = re.sub(r"[`*_#>]", "", seo_desc) - seo_desc = seo_desc[:180] + seo_desc = seo_desc[:MAX_DESCRIPTION_CHARS] seo_desc_escaped = _escape_frontmatter_description(seo_desc) return f"""--- title: "{node_name} - ComfyUI Built-in Node Documentation" diff --git a/docs-generation/tests/test_sync_helpers.py b/docs-generation/tests/test_sync_helpers.py index 880aa8f095..e0b10c2453 100644 --- a/docs-generation/tests/test_sync_helpers.py +++ b/docs-generation/tests/test_sync_helpers.py @@ -79,10 +79,47 @@ def test_cjk_sentence_break(self): desc = sc.get_description_from_content("这是第一句话。第二句话。") self.assertEqual(desc, "这是第一句话。") - def test_truncation_aligned_to_180(self): + def test_truncation_stays_within_the_160_char_window(self): long = "Word " * 100 desc = sc.get_description_from_content(long) - self.assertLessEqual(len(desc), 180) + self.assertLessEqual(len(desc), sc.MAX_DESCRIPTION_CHARS) + self.assertTrue(desc.endswith("Word")) + + def test_truncation_does_not_end_mid_word(self): + # A single long sentence: the cut must land on a word boundary instead + # of slicing "Latent Apply Operation or La". + sentence = ( + "This node creates a latent operation that blends a latent toward a reference latent, " + "and returns that operation so it can be plugged into the Latent Apply Operation node." + ) + desc = sc.get_description_from_content(sentence) + self.assertLessEqual(len(desc), sc.MAX_DESCRIPTION_CHARS) + # The cut must land between words/clauses, never inside one. + self.assertFalse(sentence[len(desc)].isalnum()) + + def test_localized_sentence_break_without_ascii_uppercase(self): + # Korean starts the next sentence with a non-ASCII letter, which the + # uppercase lookahead never matched: the page fell through to a hard + # 180-char cut and lost the summary mid-sentence. + content = ( + "이 노드는 레이턴트를 참조 레이턴트 쪽으로 블렌딩합니다. " + + "참조가 다른 공간 크기를 가질 경우 크기가 조정됩니다. " * 6 + ) + desc = sc.get_description_from_content(content) + self.assertEqual(desc, "이 노드는 레이턴트를 참조 레이턴트 쪽으로 블렌딩합니다.") + self.assertLessEqual(len(desc), sc.MAX_DESCRIPTION_CHARS) + + def test_korean_disclaimer_is_skipped(self): + content = "> 이 문서는 AI가 생성했습니다.\n\n이 노드는 이미지의 밝기를 조정합니다." + self.assertEqual( + sc.get_description_from_content(content), "이 노드는 이미지의 밝기를 조정합니다." + ) + + def test_truncation_keeps_versions_intact(self): + # A period inside "2.5" is not a sentence ender. + content = "It supports Seedance 2.5, 2.5 Draft, 2.0 and 2.1 with reference images for video." + desc = sc.get_description_from_content(content) + self.assertNotIn("2.", desc[-2:]) class PurgeNavTests(unittest.TestCase): From 7b1fdf6dafd79276554a2f4efdd5f457f7d30f2c Mon Sep 17 00:00:00 2001 From: lin-bot23 Date: Tue, 13 Oct 2026 06:53:47 +0800 Subject: [PATCH 2/3] fix(sync): take the earliest sentence boundary and read endings from the full text Review follow-up: * _first_sentence tried the uppercase rule before the non-ASCII rule, so a later English sentence boundary won over an earlier Korean one and short paragraphs produced multi-sentence descriptions. Both boundaries are now compared and the earliest wins. * _trim_to_length looked for sentence endings inside the sliced text, where a trailing period could only be judged against end-of-string: a limit landing right after the period of "ver2.5" was accepted as a sentence end. Endings are now searched in the full text and filtered to those within the limit. * dropped the full-width !/? from the ending set (Ruff RUF001). --- docs-generation/scripts/sync_to_comfy_docs.py | 42 ++++++++++--------- docs-generation/tests/test_sync_helpers.py | 13 ++++++ 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/docs-generation/scripts/sync_to_comfy_docs.py b/docs-generation/scripts/sync_to_comfy_docs.py index c147f38a08..fdd12123df 100644 --- a/docs-generation/scripts/sync_to_comfy_docs.py +++ b/docs-generation/scripts/sync_to_comfy_docs.py @@ -810,12 +810,17 @@ def _first_sentence(paragraph: str) -> str: # so "e.g." / "i.e." abbreviations survive. Localized pages (Korean, …) # start the next sentence with a non-ASCII letter, which the uppercase rule # never matched: those pages fell through to the hard cut and lost their - # summary mid-sentence. - m = re.search(r"\.\s+(?=[A-Z])", paragraph) or re.search( - r"\.\s+(?=[^\x00-\x7f])", paragraph - ) - if m: - return paragraph[: m.end()].strip() + # summary mid-sentence. Whichever rule matches first wins. + boundaries = [ + m.end() + for m in ( + re.search(r"\.\s+(?=[A-Z])", paragraph), + re.search(r"\.\s+(?=[^\x00-\x7f])", paragraph), + ) + if m + ] + if boundaries: + return paragraph[: min(boundaries)].strip() idx = paragraph.find("。") if idx != -1: return paragraph[: idx + 1].strip() @@ -826,25 +831,24 @@ def _trim_to_length(text: str, limit: int = MAX_DESCRIPTION_CHARS) -> str: """Shorten ``text`` to ``limit`` characters without slicing through a word.""" if len(text) <= limit: return text.strip() - head = text[:limit] # Prefer the last complete sentence, then the last clause, then the last # whole word — a hard character cut leaves search results ending in "or La". - # A period only ends a sentence when whitespace or the end follows, so - # versions and decimals ("2.5", "Rec.2020") do not win the search. - last_ender = max( - (m.end() for m in re.finditer(r"(?:[。!?]|[.!?](?=\s|$))", head)), - default=0, - ) - if last_ender > _MIN_DESCRIPTION_CHARS: - return head[:last_ender].strip() + # Endings are looked up in the whole text so the lookahead can see what + # follows the period: "ver2.5" is not a sentence ending even when the limit + # lands right after the period. + endings = [ + m.end() for m in re.finditer(r"(?:。|\.(?=\s|$))", text) if m.end() <= limit + ] + if endings and max(endings) > _MIN_DESCRIPTION_CHARS: + return text[: max(endings)].strip() for ender in (",", ",", ";", ";", "、"): - i = head.rfind(ender) + i = text.rfind(ender, 0, limit) if i > _MIN_DESCRIPTION_CHARS: - return head[:i].strip() - words = head.rsplit(" ", 1)[0].strip(" ,;:").split() + return text[:i].strip() + words = text[:limit].rsplit(" ", 1)[0].strip(" ,;:").split() while words and words[-1].lower().strip(",;:") in _DANGLING_WORDS: words.pop() - return " ".join(words) or head.strip() + return " ".join(words) or text[:limit].strip() def get_description_from_content(content: str) -> str: diff --git a/docs-generation/tests/test_sync_helpers.py b/docs-generation/tests/test_sync_helpers.py index e0b10c2453..e6913666fd 100644 --- a/docs-generation/tests/test_sync_helpers.py +++ b/docs-generation/tests/test_sync_helpers.py @@ -121,6 +121,19 @@ def test_truncation_keeps_versions_intact(self): desc = sc.get_description_from_content(content) self.assertNotIn("2.", desc[-2:]) + def test_earliest_sentence_boundary_wins(self): + # A localized sentence boundary must not lose to a later English one. + content = "첫 번째 문장입니다. 두 번째 문장입니다. Third sentence follows." + self.assertEqual(sc.get_description_from_content(content), "첫 번째 문장입니다.") + + def test_limit_inside_a_version_is_not_a_sentence_end(self): + # The period of "ver2." sits exactly on the limit boundary; the + # lookahead has to see the following digit to reject it. + content = "x" * 154 + " ver2.5 next words here" + desc = sc.get_description_from_content(content) + self.assertLessEqual(len(desc), sc.MAX_DESCRIPTION_CHARS) + self.assertFalse(desc.endswith(".")) + class PurgeNavTests(unittest.TestCase): def _fake_locale(self): From 54330947f1bc5461d5c7a81bce41642973774fb8 Mon Sep 17 00:00:00 2001 From: lin-bot23 Date: Tue, 13 Oct 2026 06:56:02 +0800 Subject: [PATCH 3/3] fix(sync): pick the latest clause separator and make the version test truncate Review follow-up: * the clause cut returned the first separator type that matched, so an early comma beat a later semicolon or CJK separator. All separators are compared and the latest position wins (noqa RUF001 for the full-width ones). * test_truncation_keeps_versions_intact used an input shorter than the limit, so it never exercised the cut; it now repeats the phrase past the limit and asserts the text really was shortened. * new test_latest_clause_separator_wins covers the separator ordering. --- docs-generation/scripts/sync_to_comfy_docs.py | 14 ++++++++++---- docs-generation/tests/test_sync_helpers.py | 17 ++++++++++++++--- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/docs-generation/scripts/sync_to_comfy_docs.py b/docs-generation/scripts/sync_to_comfy_docs.py index fdd12123df..f64d27ad8c 100644 --- a/docs-generation/scripts/sync_to_comfy_docs.py +++ b/docs-generation/scripts/sync_to_comfy_docs.py @@ -841,10 +841,16 @@ def _trim_to_length(text: str, limit: int = MAX_DESCRIPTION_CHARS) -> str: ] if endings and max(endings) > _MIN_DESCRIPTION_CHARS: return text[: max(endings)].strip() - for ender in (",", ",", ";", ";", "、"): - i = text.rfind(ender, 0, limit) - if i > _MIN_DESCRIPTION_CHARS: - return text[:i].strip() + # The latest clause boundary wins, whichever separator it uses. + clause = max( + ( + text.rfind(ender, 0, limit) + for ender in (",", ",", ";", ";", "、") # noqa: RUF001 — CJK separators + ), + default=-1, + ) + if clause > _MIN_DESCRIPTION_CHARS: + return text[:clause].strip() words = text[:limit].rsplit(" ", 1)[0].strip(" ,;:").split() while words and words[-1].lower().strip(",;:") in _DANGLING_WORDS: words.pop() diff --git a/docs-generation/tests/test_sync_helpers.py b/docs-generation/tests/test_sync_helpers.py index e6913666fd..4c4a50d852 100644 --- a/docs-generation/tests/test_sync_helpers.py +++ b/docs-generation/tests/test_sync_helpers.py @@ -116,10 +116,21 @@ def test_korean_disclaimer_is_skipped(self): ) def test_truncation_keeps_versions_intact(self): - # A period inside "2.5" is not a sentence ender. - content = "It supports Seedance 2.5, 2.5 Draft, 2.0 and 2.1 with reference images for video." + # A period inside "2.5" is not a sentence ender, even when the text is + # long enough to be truncated. + content = ( + "It supports Seedance 2.5, 2.5 Draft, 2.0 and 2.1 with reference images " + * 4 + ).strip() desc = sc.get_description_from_content(content) - self.assertNotIn("2.", desc[-2:]) + self.assertLessEqual(len(desc), sc.MAX_DESCRIPTION_CHARS) + self.assertGreater(len(content), len(desc)) + self.assertFalse(desc.endswith("2.")) + + def test_latest_clause_separator_wins(self): + # A semicolon later than the last comma must win the clause cut. + content = "A, " + "B" * 100 + "; " + "C" * 60 + self.assertEqual(sc.get_description_from_content(content), "A, " + "B" * 100) def test_earliest_sentence_boundary_wins(self): # A localized sentence boundary must not lose to a later English one.