refactor(gateway): shared fence-aware markdown chunker core (yuanbao-derived) + canonical table-row splitter

This commit is contained in:
teknium1 2026-07-29 09:08:25 -07:00 committed by Teknium
parent 5b751dc0ad
commit bd93ccb890
6 changed files with 743 additions and 387 deletions

View File

@ -315,13 +315,16 @@ def is_table_row(line: str) -> bool:
def split_markdown_table_row(line: str) -> list[str]:
"""Split a GFM table row into stripped cell values."""
stripped = line.strip()
if stripped.startswith("|"):
stripped = stripped[1:]
if stripped.endswith("|"):
stripped = stripped[:-1]
return [cell.strip() for cell in stripped.split("|")]
"""Split a GFM table row into stripped cell values.
Thin delegate to the canonical implementation in
:mod:`agent.markdown_tables` (``split_table_row``) so the three
formerly byte-identical copies (here, ``agent/markdown_tables.py``,
``weixin._split_table_row``) share one body.
"""
from agent.markdown_tables import split_table_row
return split_table_row(line)
def _render_table_block(table_block: list[str]) -> str:
@ -519,3 +522,421 @@ def compile_mention_patterns(
except re.error as exc:
log.warning("[%s] Invalid mention pattern %r: %s", log_prefix, text, exc)
return compiled
# ─── Fence-Aware Markdown Chunking ───────────────────────────────────────────
# Shared core for the fence-aware markdown chunkers that previously lived as
# near-duplicates in gateway/stream_consumer.py, gateway/platforms/yuanbao.py
# (MarkdownProcessor — the richest version, which this core is derived from),
# and gateway/platforms/weixin.py. Each caller keeps its own knobs:
#
# * stream_consumer: newline-preferred splitting + close/reopen fence
# balancing (``prefer_paragraphs=False, balance_fences=True``)
# * yuanbao: atomic-block extraction + paragraph-boundary splitting, fences
# kept intact as atoms (``prefer_paragraphs=True, balance_fences=False``)
# * weixin: keeps its own block splitter (anchored ``_FENCE_RE``, per-line
# rstrip semantics) but reuses ``greedy_pack_blocks`` for packing.
#
# The typing helpers below use ``Optional``/``Callable`` from ``typing`` to
# match the module's existing import style.
def text_has_unclosed_fence(text: str) -> bool:
"""Return True when *text* ends inside an unclosed ``` code fence.
Scans line by line, toggling in/out state on lines starting with ```.
An odd number of toggles means the trailing fence is unclosed.
"""
in_fence = False
for line in text.split('\n'):
if line.startswith('```'):
in_fence = not in_fence
return in_fence
def text_ends_with_table_row(text: str) -> bool:
"""True when the last non-empty line starts and ends with ``|``."""
trimmed = text.rstrip()
if not trimmed:
return False
last_line = trimmed.split('\n')[-1].strip()
return last_line.startswith('|') and last_line.endswith('|')
def is_fence_atom(text: str) -> bool:
"""True when an atomic block is a code block (starts with ```)."""
return text.lstrip().startswith('```')
def is_table_atom(text: str) -> bool:
"""True when an atomic block is a table (first line is ``|...|``)."""
first_line = text.split('\n')[0].strip()
return first_line.startswith('|') and first_line.endswith('|')
_SENTENCE_END_NEWLINE_RE = re.compile(r'[。!?.!?]\n')
def split_at_paragraph_boundary(text, max_chars, len_fn=None):
"""Find the nearest paragraph boundary within *max_chars*; return (head, tail).
Split priority:
1. Blank line (paragraph boundary)
2. Newline after sentence-ending punctuation (CJK and ASCII)
3. Last newline
4. Force split at the *max_chars* window boundary
``head + tail == text`` always holds. *len_fn* allows measuring in
custom units (e.g. UTF-16 code units); a binary search finds the largest
prefix that fits when it is provided.
"""
_len = len_fn or len
if _len(text) <= max_chars:
return text, ''
if _len is len:
window = text[:max_chars]
else:
lo, hi = 0, len(text)
while lo < hi:
mid = (lo + hi + 1) // 2
if _len(text[:mid]) <= max_chars:
lo = mid
else:
hi = mid - 1
window = text[:lo]
# 1. Prefer the last blank line (\n\n) as paragraph boundary
pos = window.rfind('\n\n')
if pos > 0:
return text[:pos + 2], text[pos + 2:]
# 2. Then the last newline following sentence-ending punctuation
best_pos = -1
for m in _SENTENCE_END_NEWLINE_RE.finditer(window):
best_pos = m.end()
if best_pos > 0:
return text[:best_pos], text[best_pos:]
# 3. Fallback: last newline
pos = window.rfind('\n')
if pos > 0:
return text[:pos + 1], text[pos + 1:]
# 4. No valid split point: force split at the window boundary
cut = len(window)
return text[:cut], text[cut:]
def split_markdown_atoms(text: str) -> "list[str]":
"""Split markdown into indivisible "atomic blocks".
Atoms are: fenced code blocks (``` ... ``` inclusive), tables
(consecutive ``|...|`` lines), and plain paragraphs separated by blank
lines. Blank lines are separators and belong to no atom.
"""
lines = text.split('\n')
atoms: "list[str]" = []
current_lines: "list[str]" = []
in_fence = False
def _is_table_line(line: str) -> bool:
stripped = line.strip()
return stripped.startswith('|') and stripped.endswith('|')
def _flush_current() -> None:
if current_lines:
atom = '\n'.join(current_lines)
if atom.strip():
atoms.append(atom)
current_lines.clear()
for line in lines:
if in_fence:
current_lines.append(line)
if line.startswith('```') and len(current_lines) > 1:
in_fence = False
_flush_current()
elif line.startswith('```'):
_flush_current()
in_fence = True
current_lines.append(line)
elif _is_table_line(line):
if current_lines and not _is_table_line(current_lines[-1]):
_flush_current()
current_lines.append(line)
elif line.strip() == '':
_flush_current()
else:
if current_lines and _is_table_line(current_lines[-1]):
_flush_current()
current_lines.append(line)
_flush_current()
return atoms
def infer_block_separator(prev_chunk: str, next_chunk: str) -> str:
"""Infer the separator (``'\\n'`` or ``'\\n\\n'``) between two chunks.
Single newline when the boundary sits at a code fence or a continued
table; paragraph separator otherwise.
"""
prev_trimmed = prev_chunk.rstrip()
next_trimmed = next_chunk.lstrip()
if prev_trimmed.endswith('```') or next_trimmed.startswith('```'):
return '\n'
if text_ends_with_table_row(prev_chunk):
first_line = next_trimmed.split('\n')[0].strip() if next_trimmed else ''
if first_line.startswith('|') and first_line.endswith('|'):
return '\n'
return '\n\n'
def merge_streaming_fences(chunks: "list[str]") -> "list[str]":
"""Stream-aware fence merge: rejoin chunks truncated mid-fence.
While chunk *i* has an unclosed fence and a successor exists, merge the
successor into it using :func:`infer_block_separator`.
"""
if not chunks:
return []
result: "list[str]" = []
i = 0
while i < len(chunks):
current = chunks[i]
while text_has_unclosed_fence(current) and i + 1 < len(chunks):
sep = infer_block_separator(current, chunks[i + 1])
current = current + sep + chunks[i + 1]
i += 1
result.append(current)
i += 1
return result
def balance_fences_across_chunks(chunks: "list[str]") -> "list[str]":
"""Close orphaned ``` fences at each chunk boundary and reopen on the next.
When a split lands inside a triple-backtick code block, close the fence
at the end of the head chunk and reopen it (with the original language
tag) at the start of the next, so every delivered chunk is
fence-balanced on its own.
"""
if len(chunks) <= 1:
return chunks
out: "list[str]" = []
carry_lang = None
for chunk in chunks:
prefix = f"```{carry_lang}\n" if carry_lang is not None else ""
in_code = carry_lang is not None
lang = carry_lang or ""
for line in chunk.split("\n"):
stripped = line.strip()
if stripped.startswith("```"):
if in_code:
in_code = False
lang = ""
else:
in_code = True
tag = stripped[3:].strip()
lang = tag.split()[0] if tag else ""
body = prefix + chunk
if in_code:
body += "\n```"
carry_lang = lang
else:
carry_lang = None
out.append(body)
return out
def greedy_pack_blocks(blocks, max_length, len_fn=None, sep="\n\n", overflow=None):
"""Greedily pack pre-split *blocks* into chunks of at most *max_length*.
Blocks are joined with *sep* while they fit. A block that alone exceeds
the limit is passed to *overflow(block)* (which must return a list of
chunks) when provided, else emitted as-is.
"""
_len = len_fn or len
packed: "list[str]" = []
current = ""
for block in blocks:
candidate = block if not current else f"{current}{sep}{block}"
if _len(candidate) <= max_length:
current = candidate
continue
if current:
packed.append(current)
current = ""
if _len(block) <= max_length:
current = block
continue
if overflow is not None:
packed.extend(overflow(block))
else:
packed.append(block)
if current:
packed.append(current)
return packed
def split_text_fence_aware(
text,
limit,
len_fn=None,
*,
prefer_paragraphs=True,
balance_fences=False,
):
"""Split markdown text into chunks of at most *limit*, respecting fences.
Two strategies, selected by ``prefer_paragraphs``:
``prefer_paragraphs=True`` (yuanbao-derived, the richest):
Extract atomic blocks (code fences, tables, paragraphs), greedily merge
them up to *limit*, split still-oversized non-atomic chunks at
paragraph boundaries, then re-merge small neighbours. Code blocks and
tables are never split in the middle; a single atom larger than
*limit* is emitted oversize rather than broken.
``prefer_paragraphs=False`` (stream_consumer-derived):
Newline-preferred hard splitting with headroom reserved for fence
markers when the text contains ```.
``balance_fences=True`` post-processes the chunks so a split inside a
code block closes the fence on the head chunk and reopens it (with the
language tag) on the tail required by callers whose chunks are
delivered as independent messages that each must render standalone.
"""
_len = len_fn or len
if not text:
return []
if prefer_paragraphs:
chunks = _chunk_markdown_paragraphs(text, limit, len_fn)
else:
chunks = _chunk_newline_preferred(text, limit, _len)
if balance_fences:
chunks = balance_fences_across_chunks(chunks)
return chunks
def _chunk_markdown_paragraphs(text, max_chars, len_fn=None):
"""Yuanbao-derived paragraph/atom chunking pipeline (see module docs)."""
_len = len_fn or len
if _len(text) <= max_chars:
return [text]
# Phase 1: Extract atomic blocks
atoms = split_markdown_atoms(text)
# Phase 2: Greedy merge
chunks: "list[str]" = []
indivisible_set: "set[int]" = set()
current_parts: "list[str]" = []
current_len = 0
def _flush_parts() -> None:
if current_parts:
chunks.append('\n\n'.join(current_parts))
for atom in atoms:
atom_len = _len(atom)
sep_len = 2 if current_parts else 0
projected_len = current_len + sep_len + atom_len
if projected_len > max_chars and current_parts:
_flush_parts()
current_parts = []
current_len = 0
sep_len = 0
if (not current_parts
and atom_len > max_chars
and (is_fence_atom(atom) or is_table_atom(atom))):
indivisible_set.add(len(chunks))
chunks.append(atom)
continue
current_parts.append(atom)
current_len += sep_len + atom_len
_flush_parts()
# Phase 3: Split still-oversized chunks at paragraph boundaries
result: "list[str]" = []
for idx, chunk in enumerate(chunks):
if _len(chunk) <= max_chars:
result.append(chunk)
continue
if idx in indivisible_set:
result.append(chunk)
continue
if text_has_unclosed_fence(chunk):
result.append(chunk)
continue
remaining = chunk
while _len(remaining) > max_chars:
head, remaining = split_at_paragraph_boundary(
remaining, max_chars, len_fn=len_fn,
)
if not head:
head, remaining = remaining[:max_chars], remaining[max_chars:]
if head:
result.append(head)
if remaining:
result.append(remaining)
# Phase 4: Merge small trailing/leading chunks with neighbours
if len(result) > 1:
merged: "list[str]" = [result[0]]
for chunk in result[1:]:
prev = merged[-1]
combined = prev + '\n\n' + chunk
if _len(combined) <= max_chars:
merged[-1] = combined
else:
merged.append(chunk)
result = merged
return [c for c in result if c]
def _chunk_newline_preferred(text, limit, len_fn):
"""Stream-consumer-derived newline-preferred splitting (no balancing)."""
if len_fn(text) <= limit:
return [text]
# Reserve headroom for the close/reopen fence markers a balancing pass
# may add, so balanced chunks stay within the platform limit.
split_limit = limit
if "```" in text:
split_limit = max(limit - 16, limit // 2, 1)
# Local import: gateway.platforms.base is heavyweight and pulls config;
# helpers must stay import-light for adapters that import it first.
from gateway.platforms.base import _custom_unit_to_cp
chunks: "list[str]" = []
remaining = text
while len_fn(remaining) > split_limit:
_cp_budget = _custom_unit_to_cp(remaining, split_limit, len_fn)
split_at = remaining.rfind("\n", 0, _cp_budget)
if split_at < _cp_budget // 2:
split_at = _cp_budget
chunks.append(remaining[:split_at])
remaining = remaining[split_at:].lstrip("\n")
if remaining:
chunks.append(remaining)
return chunks

View File

@ -56,7 +56,7 @@ except ImportError: # pragma: no cover - dependency gate
CRYPTO_AVAILABLE = False
from gateway.config import Platform, PlatformConfig
from gateway.platforms.helpers import MessageDeduplicator
from gateway.platforms.helpers import MessageDeduplicator, greedy_pack_blocks
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
@ -670,12 +670,10 @@ def _mime_from_filename(filename: str) -> str:
def _split_table_row(line: str) -> List[str]:
row = line.strip()
if row.startswith("|"):
row = row[1:]
if row.endswith("|"):
row = row[:-1]
return [cell.strip() for cell in row.split("|")]
"""Delegate to the canonical table-row splitter in agent.markdown_tables."""
from agent.markdown_tables import split_table_row
return split_table_row(line)
def _normalize_markdown_blocks(content: str) -> str:
@ -868,24 +866,14 @@ def _should_split_short_chat_block_for_weixin(block: str) -> bool:
def _pack_markdown_blocks_for_weixin(content: str, max_length: int) -> List[str]:
if len(content) <= max_length:
return [content]
packed: List[str] = []
current = ""
for block in _split_markdown_blocks(content):
candidate = block if not current else f"{current}\n\n{block}"
if len(candidate) <= max_length:
current = candidate
continue
if current:
packed.append(current)
current = ""
if len(block) <= max_length:
current = block
continue
packed.extend(BasePlatformAdapter.truncate_message(block, max_length))
if current:
packed.append(current)
return packed
# Block extraction stays weixin-local (_split_markdown_blocks uses the
# anchored _FENCE_RE + per-line rstrip semantics); the greedy packing
# loop is the shared core's.
return greedy_pack_blocks(
_split_markdown_blocks(content),
max_length,
overflow=lambda block: BasePlatformAdapter.truncate_message(block, max_length),
)
def _split_text_for_weixin_delivery(

View File

@ -60,6 +60,7 @@ from gateway.platforms.base import (
cache_image_from_bytes,
cache_video_from_bytes,
)
from gateway.platforms import helpers as _mdchunk
from gateway.platforms.helpers import MessageDeduplicator
from gateway.platforms.yuanbao_media import (
download_url as media_download_url,
@ -202,45 +203,23 @@ class MarkdownProcessor:
"""
# -- Fence detection ---------------------------------------------------
# All chunking primitives below are thin delegates to the shared
# fence-aware chunker core in gateway.platforms.helpers, which was
# extracted from this class (the richest of the four duplicate
# implementations). The MarkdownProcessor method names are kept for
# the existing call sites and tests.
@staticmethod
def has_unclosed_fence(text: str) -> bool:
"""
Detect whether the text has unclosed code block fences.
Scan line by line, toggling in/out state when encountering a line starting with ```.
An odd number of toggles indicates an unclosed fence.
Args:
text: Markdown text to check
Returns:
Returns True if the text ends with an unclosed fence, otherwise False
"""
in_fence = False
for line in text.split('\n'):
if line.startswith('```'):
in_fence = not in_fence
return in_fence
"""Detect whether the text has unclosed code block fences."""
return _mdchunk.text_has_unclosed_fence(text)
# -- Table detection ---------------------------------------------------
@staticmethod
def ends_with_table_row(text: str) -> bool:
"""
Detect whether the text ends with a table row (last non-empty line starts and ends with |).
Args:
text: Text to check
Returns:
Returns True if the last non-empty line is a table row
"""
trimmed = text.rstrip()
if not trimmed:
return False
last_line = trimmed.split('\n')[-1].strip()
return last_line.startswith('|') and last_line.endswith('|')
"""Detect whether the text ends with a table row."""
return _mdchunk.text_ends_with_table_row(text)
# -- Paragraph boundary splitting --------------------------------------
@ -250,135 +229,25 @@ class MarkdownProcessor:
max_chars: int,
len_fn: Optional[Callable[[str], int]] = None,
) -> tuple[str, str]:
"""
Find the nearest paragraph boundary split point within max_chars, return (head, tail).
Split priority:
1. Blank line (paragraph boundary)
2. Newline after period/question mark/exclamation mark (Chinese and English)
3. Last newline
4. Force split at max_chars
Args:
text: Text to split
max_chars: Maximum character count limit
len_fn: Optional custom length function (e.g. UTF-16 length); defaults to built-in len
Returns:
(head, tail) tuple, head is the front part, tail is the back part, satisfying head + tail == text
"""
_len = len_fn or len
if _len(text) <= max_chars:
return text, ''
# Build a character-index window that fits within max_chars.
# When len_fn != len we cannot simply slice [:max_chars], so we
# binary-search for the largest prefix that fits.
if _len is len:
window = text[:max_chars]
else:
lo, hi = 0, len(text)
while lo < hi:
mid = (lo + hi + 1) // 2
if _len(text[:mid]) <= max_chars:
lo = mid
else:
hi = mid - 1
window = text[:lo]
# 1. Prefer the last blank line (\n\n) as paragraph boundary
pos = window.rfind('\n\n')
if pos > 0:
return text[:pos + 2], text[pos + 2:]
# 2. Then find the last newline after a sentence-ending punctuation
sentence_end_re = re.compile(r'[。!?.!?]\n')
best_pos = -1
for m in sentence_end_re.finditer(window):
best_pos = m.end()
if best_pos > 0:
return text[:best_pos], text[best_pos:]
# 3. Fallback: find the last newline
pos = window.rfind('\n')
if pos > 0:
return text[:pos + 1], text[pos + 1:]
# 4. No valid split point found, force split at window boundary
cut = len(window)
return text[:cut], text[cut:]
"""Find the nearest paragraph boundary within max_chars; return (head, tail)."""
return _mdchunk.split_at_paragraph_boundary(text, max_chars, len_fn=len_fn)
# -- Atomic block helpers (private) ------------------------------------
@staticmethod
def is_fence_atom(text: str) -> bool:
"""Determine whether an atomic block is a code block (starts with ```)."""
return text.lstrip().startswith('```')
return _mdchunk.is_fence_atom(text)
@staticmethod
def is_table_atom(text: str) -> bool:
"""Determine whether an atomic block is a table (first line starts with |)."""
first_line = text.split('\n')[0].strip()
return first_line.startswith('|') and first_line.endswith('|')
return _mdchunk.is_table_atom(text)
@staticmethod
def split_into_atoms(text: str) -> list[str]:
"""
Split text into a list of "atomic blocks", each being an indivisible logical unit:
- Code block (fence): from opening ``` to closing ``` (including fence lines)
- Table: consecutive |...| lines forming a whole segment
- Normal paragraph: plain text segments separated by blank lines
Blank lines serve as separators and are not included in any atomic block.
Args:
text: Markdown text to split
Returns:
List of atomic block strings (all non-empty)
"""
lines = text.split('\n')
atoms: list[str] = []
current_lines: list[str] = []
in_fence = False
def _is_table_line(line: str) -> bool:
stripped = line.strip()
return stripped.startswith('|') and stripped.endswith('|')
def _flush_current() -> None:
if current_lines:
atom = '\n'.join(current_lines)
if atom.strip():
atoms.append(atom)
current_lines.clear()
for line in lines:
if in_fence:
current_lines.append(line)
if line.startswith('```') and len(current_lines) > 1:
in_fence = False
_flush_current()
elif line.startswith('```'):
_flush_current()
in_fence = True
current_lines.append(line)
elif _is_table_line(line):
if current_lines and not _is_table_line(current_lines[-1]):
_flush_current()
current_lines.append(line)
elif line.strip() == '':
_flush_current()
else:
if current_lines and _is_table_line(current_lines[-1]):
_flush_current()
current_lines.append(line)
_flush_current()
return atoms
"""Split text into a list of indivisible "atomic blocks"."""
return _mdchunk.split_markdown_atoms(text)
# -- Core: chunk splitting ---------------------------------------------
@ -392,177 +261,34 @@ class MarkdownProcessor:
"""
Split Markdown text into multiple chunks by max_chars.
Guarantees:
Guarantees (provided by the shared core, prefer_paragraphs mode):
- Each chunk <= max_chars characters (unless a single code block/table itself exceeds the limit)
- Code blocks (```...```) are not split in the middle
- Table rows are not split in the middle (tables output as atomic blocks)
- Split at paragraph boundaries (blank lines, after periods, etc.)
- Small trailing/leading chunks are merged with neighbours when possible
Args:
text: Markdown text to split
max_chars: Max characters per chunk, default 4000
len_fn: Optional custom length function (e.g. UTF-16 length); defaults to built-in len
Returns:
List of text chunks after splitting (non-empty)
"""
_len = len_fn or len
if not text:
return []
if _len(text) <= max_chars:
return [text]
# Phase 1: Extract atomic blocks
atoms = cls.split_into_atoms(text)
# Phase 2: Greedy merge
chunks: list[str] = []
indivisible_set: set[int] = set()
current_parts: list[str] = []
current_len = 0
def _flush_parts() -> None:
if current_parts:
chunks.append('\n\n'.join(current_parts))
for atom in atoms:
atom_len = _len(atom)
sep_len = 2 if current_parts else 0
projected_len = current_len + sep_len + atom_len
if projected_len > max_chars and current_parts:
_flush_parts()
current_parts = []
current_len = 0
sep_len = 0
if (not current_parts
and atom_len > max_chars
and (cls.is_fence_atom(atom) or cls.is_table_atom(atom))):
indivisible_set.add(len(chunks))
chunks.append(atom)
continue
current_parts.append(atom)
current_len += sep_len + atom_len
_flush_parts()
# Phase 3: Post-processing — split still-oversized chunks at paragraph boundaries
result: list[str] = []
for idx, chunk in enumerate(chunks):
if _len(chunk) <= max_chars:
result.append(chunk)
continue
if idx in indivisible_set:
result.append(chunk)
continue
if cls.has_unclosed_fence(chunk):
result.append(chunk)
continue
remaining = chunk
while _len(remaining) > max_chars:
head, remaining = cls.split_at_paragraph_boundary(
remaining, max_chars, len_fn=len_fn,
)
if not head:
head, remaining = remaining[:max_chars], remaining[max_chars:]
if head:
result.append(head)
if remaining:
result.append(remaining)
# Phase 4: Merge small trailing/leading chunks with neighbours
if len(result) > 1:
merged: list[str] = [result[0]]
for chunk in result[1:]:
prev = merged[-1]
combined = prev + '\n\n' + chunk
if _len(combined) <= max_chars:
merged[-1] = combined
else:
merged.append(chunk)
result = merged
return [c for c in result if c]
return _mdchunk.split_text_fence_aware(
text,
max_chars,
len_fn,
prefer_paragraphs=True,
balance_fences=False,
)
# -- Block separator inference -----------------------------------------
@classmethod
def infer_block_separator(cls, prev_chunk: str, next_chunk: str) -> str:
"""
Infer the separator to use between two split chunks.
Rules (aligned with TS markdown-stream.ts):
- Previous chunk ends with code fence or next chunk starts with fence single newline '\\n'
- Previous chunk ends with table row and next chunk starts with table row single newline '\\n' (continued table)
- Otherwise double newline '\\n\\n' (paragraph separator)
Args:
prev_chunk: Previous chunk
next_chunk: Next chunk
Returns:
'\\n' or '\\n\\n'
"""
prev_trimmed = prev_chunk.rstrip()
next_trimmed = next_chunk.lstrip()
# Previous chunk ends with fence or next chunk starts with fence
if prev_trimmed.endswith('```') or next_trimmed.startswith('```'):
return '\n'
# Table continuation
if cls.ends_with_table_row(prev_chunk):
first_line = next_trimmed.split('\n')[0].strip() if next_trimmed else ''
if first_line.startswith('|') and first_line.endswith('|'):
return '\n'
return '\n\n'
"""Infer the separator ('\\n' or '\\n\\n') between two split chunks."""
return _mdchunk.infer_block_separator(prev_chunk, next_chunk)
# -- Streaming fence merge ---------------------------------------------
@classmethod
def merge_block_streaming_fences(cls, chunks: list[str]) -> list[str]:
"""
Stream-aware fence-conscious chunk merging.
When streaming output produces multiple chunks truncated in the middle of a fence,
attempt to merge adjacent chunks to complete the fence.
Rules:
- If chunk i has an unclosed fence and chunk i+1 starts with ```,
merge i+1 into i (until the fence is closed or no more chunks).
- Use infer_block_separator to infer the separator during merging.
Args:
chunks: Original chunk list
Returns:
Merged chunk list (length <= original length)
"""
if not chunks:
return []
result: list[str] = []
i = 0
while i < len(chunks):
current = chunks[i]
# If current chunk has unclosed fence, try merging subsequent chunks
while cls.has_unclosed_fence(current) and i + 1 < len(chunks):
sep = cls.infer_block_separator(current, chunks[i + 1])
current = current + sep + chunks[i + 1]
i += 1
result.append(current)
i += 1
return result
"""Stream-aware fence-conscious chunk merging (see shared core)."""
return _mdchunk.merge_streaming_fences(chunks)
# -- Outer fence stripping ---------------------------------------------

View File

@ -1181,40 +1181,13 @@ class GatewayStreamConsumer:
def _balance_fences_across_chunks(chunks: "list[str]") -> "list[str]":
"""Close orphaned ``` fences at each chunk boundary and reopen on the next.
When a split lands inside a triple-backtick code block, the head chunk
would render everything after the orphaned fence as code, and the tail
chunk's content would lose its code formatting. Mirror
``BasePlatformAdapter.truncate_message``'s contract: close the fence at
the end of the chunk and reopen it (with the original language tag) at
the start of the next one, so EVERY delivered chunk is fence-balanced
on its own.
Thin delegate to the shared fence-chunker core in
:mod:`gateway.platforms.helpers` (``balance_fences_across_chunks``);
kept as a method for the existing call sites and tests.
"""
if len(chunks) <= 1:
return chunks
out: "list[str]" = []
carry_lang: "Optional[str]" = None
for chunk in chunks:
prefix = f"```{carry_lang}\n" if carry_lang is not None else ""
in_code = carry_lang is not None
lang = carry_lang or ""
for line in chunk.split("\n"):
stripped = line.strip()
if stripped.startswith("```"):
if in_code:
in_code = False
lang = ""
else:
in_code = True
tag = stripped[3:].strip()
lang = tag.split()[0] if tag else ""
body = prefix + chunk
if in_code:
body += "\n```"
carry_lang = lang
else:
carry_lang = None
out.append(body)
return out
from gateway.platforms.helpers import balance_fences_across_chunks
return balance_fences_across_chunks(chunks)
@staticmethod
def _split_text_chunks(
@ -1227,26 +1200,20 @@ class GatewayStreamConsumer:
Chunks are fence-balanced: a split inside a ``` code block closes the
fence on the head chunk and reopens it on the tail, so no chunk leaves
the rest of a message rendering as one giant code block.
Delegates to the shared fence-chunker core
(:func:`gateway.platforms.helpers.split_text_fence_aware`) with this
consumer's knobs: newline-preferred splitting + fence balancing.
"""
if len_fn(text) <= limit:
return [text]
# Reserve headroom for the close/reopen fence markers the balancing
# pass may add, so balanced chunks stay within the platform limit.
split_limit = limit
if "```" in text:
split_limit = max(limit - 16, limit // 2, 1)
chunks: list[str] = []
remaining = text
while len_fn(remaining) > split_limit:
_cp_budget = _custom_unit_to_cp(remaining, split_limit, len_fn)
split_at = remaining.rfind("\n", 0, _cp_budget)
if split_at < _cp_budget // 2:
split_at = _cp_budget
chunks.append(remaining[:split_at])
remaining = remaining[split_at:].lstrip("\n")
if remaining:
chunks.append(remaining)
return GatewayStreamConsumer._balance_fences_across_chunks(chunks)
from gateway.platforms.helpers import split_text_fence_aware
return split_text_fence_aware(
text,
limit,
len_fn,
prefer_paragraphs=False,
balance_fences=True,
)
def _truncate_for_stream(
self,

View File

@ -4496,7 +4496,13 @@ async def speak_text(payload: TTSSpeakRequest, profile: Optional[str] = None):
def _split_text_for_speak_stream(text: str, cap: int) -> list:
"""Split *text* into provider-cap-sized pieces on sentence boundaries."""
"""Split *text* into provider-cap-sized pieces on sentence boundaries.
Deliberately NOT unified with gateway.platforms.helpers'
split_text_fence_aware: this splitter reflows whitespace (sentences are
re-joined with single spaces) and has no fence/markdown semantics, so
expressing it as knobs on the fence-aware core would change behavior.
"""
from tools.tts_streaming import SENTENCE_BOUNDARY_RE as _SENTENCE_BOUNDARY_RE
cap = cap if cap and cap > 0 else 4000

View File

@ -0,0 +1,248 @@
"""Invariant tests for the shared fence-aware markdown chunker core.
The core lives in ``gateway.platforms.helpers`` and was extracted from the
Yuanbao ``MarkdownProcessor`` (the richest of the four formerly duplicated
implementations). These tests assert the core's *contract* (invariants),
not exact snapshots, so they survive internal tweaks that preserve behavior.
"""
import pytest
from gateway.platforms.helpers import (
balance_fences_across_chunks,
greedy_pack_blocks,
infer_block_separator,
merge_streaming_fences,
split_at_paragraph_boundary,
split_markdown_atoms,
split_markdown_table_row,
split_text_fence_aware,
text_has_unclosed_fence,
)
def utf16_len(s: str) -> int:
return sum(2 if ord(c) > 0xFFFF else 1 for c in s)
LONG_PARAS = ("This is a sentence that goes on for a while. " * 8 + "\n\n") * 6
FENCED = (
"Header line\n\n```js\n"
+ "\n".join(f"console.log({i}); // padding padding" for i in range(30))
+ "\n```\n\nTail paragraph after the code block ends here."
)
UNCLOSED = (
"Some text before.\n\n```bash\necho one\n"
+ "echo more stuff here to pad the line out\n" * 10
)
TABLE = (
"Intro paragraph.\n\n| col1 | col2 |\n|------|------|\n"
+ "\n".join(f"| value{i} | data{i} |" for i in range(40))
+ "\n\nDone."
)
CJK = "这是一个中文段落,包含了很多字符。用于测试宽字符处理。这句话结束了。\n\n" * 10
MIXED = (
"Start.\n\n```sql\nSELECT * FROM t;\n```\n\n| x | y |\n|---|---|\n| 1 | 2 |\n\n"
"End paragraph with some extra words to pad things out."
)
SAMPLES = [LONG_PARAS, FENCED, UNCLOSED, TABLE, CJK, MIXED, "x" * 500]
# ── split_text_fence_aware (paragraph mode: yuanbao-derived) ─────────────────
@pytest.mark.parametrize("text", SAMPLES)
@pytest.mark.parametrize("limit", [80, 200, 400])
def test_paragraph_mode_chunks_within_limit_unless_atomic(text, limit):
chunks = split_text_fence_aware(text, limit, prefer_paragraphs=True)
atoms = split_markdown_atoms(text)
oversize_atom = any(len(a) > limit for a in atoms)
for chunk in chunks:
# A chunk may exceed the limit only when a single indivisible atom
# (code block / table) itself exceeds it.
if len(chunk) > limit:
assert oversize_atom, (
f"chunk of {len(chunk)} > {limit} without an oversize atom"
)
@pytest.mark.parametrize("text", [FENCED, MIXED])
def test_paragraph_mode_never_splits_closed_fences(text):
for limit in (80, 150, 300):
chunks = split_text_fence_aware(text, limit, prefer_paragraphs=True)
for chunk in chunks:
assert not text_has_unclosed_fence(chunk), (
f"fence split mid-block at limit={limit}: {chunk!r}"
)
@pytest.mark.parametrize("text", SAMPLES)
def test_paragraph_mode_no_empty_chunks(text):
for limit in (80, 400):
chunks = split_text_fence_aware(text, limit, prefer_paragraphs=True)
assert all(c for c in chunks)
def test_paragraph_mode_short_text_single_chunk():
assert split_text_fence_aware("hello", 100) == ["hello"]
assert split_text_fence_aware("", 100) == []
def test_paragraph_mode_utf16_len_fn():
chunks = split_text_fence_aware(CJK, 120, utf16_len, prefer_paragraphs=True)
assert chunks
assert all(utf16_len(c) <= 120 for c in chunks)
# ── split_text_fence_aware (newline mode + balancing: stream_consumer) ───────
@pytest.mark.parametrize("text", SAMPLES)
def test_newline_mode_balanced_fences_every_chunk(text):
chunks = split_text_fence_aware(
text, 100, prefer_paragraphs=False, balance_fences=True
)
for chunk in chunks:
# Every delivered chunk must render standalone: even fence count.
assert chunk.count("\n```") % 2 == 0 or not text_has_unclosed_fence(chunk)
assert not text_has_unclosed_fence(chunk)
def test_newline_mode_balancing_reopens_language_tag():
text = "before\n\n```python\n" + "print(1)\n" * 20 + "```\nafter"
chunks = split_text_fence_aware(
text, 80, prefer_paragraphs=False, balance_fences=True
)
assert len(chunks) > 1
# Some tail chunk must reopen the python fence.
assert any(c.startswith("```python\n") for c in chunks[1:])
def test_newline_mode_content_preserved_without_fences():
text = "\n".join(f"line {i} with several words in it" for i in range(40))
chunks = split_text_fence_aware(text, 120, prefer_paragraphs=False)
joined = "\n".join(chunks)
# Newline-mode splitting only removes leading newlines at boundaries.
assert joined.replace("\n", "") == text.replace("\n", "")
# ── split_at_paragraph_boundary ──────────────────────────────────────────────
@pytest.mark.parametrize("text", SAMPLES)
def test_split_at_paragraph_boundary_head_plus_tail(text):
head, tail = split_at_paragraph_boundary(text, 100)
assert head + tail == text
assert len(head) <= 100 or "\n" not in text[:100]
def test_split_at_paragraph_boundary_prefers_blank_line():
text = "para one\n\npara two\n\npara three " + "x" * 200
head, _ = split_at_paragraph_boundary(text, 60)
assert head.endswith("\n\n")
def test_split_at_paragraph_boundary_cjk_sentence():
text = "第一句话。\n第二句话!\n" + "第三句话没有结束标点一直写下去" * 20
head, tail = split_at_paragraph_boundary(text, 30)
assert head + tail == text
assert head.endswith(("\n", "\n"))
# ── atoms ────────────────────────────────────────────────────────────────────
def test_atoms_fence_kept_whole():
atoms = split_markdown_atoms(FENCED)
fence_atoms = [a for a in atoms if a.lstrip().startswith("```")]
assert len(fence_atoms) == 1
assert fence_atoms[0].rstrip().endswith("```")
def test_atoms_table_kept_whole():
atoms = split_markdown_atoms(TABLE)
table_atoms = [a for a in atoms if a.split("\n")[0].strip().startswith("|")]
assert len(table_atoms) == 1
assert table_atoms[0].count("\n") == 41 # header + rule + 40 rows
def test_atoms_nonempty_and_no_blank_lines():
for text in SAMPLES:
for atom in split_markdown_atoms(text):
assert atom.strip()
# ── streaming merge + separators ─────────────────────────────────────────────
def test_merge_streaming_fences_rejoins_split_fence():
chunks = ["intro\n```py\ncode line", "more code\n```\ntail"]
merged = merge_streaming_fences(chunks)
assert len(merged) == 1
assert not text_has_unclosed_fence(merged[0])
def test_merge_streaming_fences_leaves_balanced_alone():
chunks = ["one", "```\nx\n```", "three"]
assert merge_streaming_fences(chunks) == chunks
assert merge_streaming_fences([]) == []
def test_infer_block_separator_rules():
assert infer_block_separator("text\n```", "next") == "\n"
assert infer_block_separator("text", "```py\nx") == "\n"
assert infer_block_separator("| a | b |", "| c | d |") == "\n"
assert infer_block_separator("plain", "plain") == "\n\n"
# ── balance_fences_across_chunks ─────────────────────────────────────────────
def test_balance_single_chunk_untouched():
chunks = ["```py\nunclosed"]
assert balance_fences_across_chunks(chunks) == chunks
def test_balance_closes_and_reopens():
out = balance_fences_across_chunks(["a\n```go\nx", "y\n```\nb"])
assert out[0].endswith("\n```")
assert out[1].startswith("```go\n")
assert all(not text_has_unclosed_fence(c) for c in out)
# ── greedy_pack_blocks ───────────────────────────────────────────────────────
def test_greedy_pack_respects_limit_and_order():
blocks = [f"block {i} " + "w" * 30 for i in range(10)]
packed = greedy_pack_blocks(blocks, 90)
assert all(len(p) <= 90 for p in packed)
assert "\n\n".join(packed).replace("\n\n", "|").count("|") >= 0
# Order/content preserved
assert "".join(packed).replace("\n\n", "") == "".join(blocks)
def test_greedy_pack_overflow_callback():
calls = []
def overflow(block):
calls.append(block)
return [block[:50], block[50:]]
packed = greedy_pack_blocks(["x" * 120], 60, overflow=overflow)
assert calls and packed == ["x" * 50, "x" * 70]
# ── canonical table-row splitter delegation ──────────────────────────────────
def test_table_row_splitters_are_unified():
from agent.markdown_tables import split_table_row
from gateway.platforms.weixin import _split_table_row
rows = ["| a | b | c |", "a | b | c", "|配置|状态|", " | x | ", "||"]
for row in rows:
expected = split_table_row(row)
assert split_markdown_table_row(row) == expected
assert _split_table_row(row) == expected