去除本地回复硬裁剪并改为分段多次发送

This commit is contained in:
liuwei
2026-04-24 15:28:07 +08:00
parent ee1532b2f5
commit aa94687c19

View File

@@ -8,39 +8,44 @@ def finalize_reply(response: str, reply_mode: str, limits: Dict | None = None) -
text = str(response or "").strip()
if not text:
return []
text = re.sub(r"\s+", " ", text)
text = text.replace("\n", " ").strip()
# 这里不再把整段回复压成一行,也不再做“总字数硬截断”:
# 1. 用户明确要求去掉本地裁剪,长文宁可拆开发多条,也不要直接砍掉后半段;
# 2. 因此我们只做轻量清洗,保留原本的段落边界,后续按段落/句子拆分发送;
# 3. 模型侧仍然会被提示尽量简短,本地这里只负责“安全分段”,不负责“删内容”。
text = _normalize_reply_text(text)
options = _resolve_limits(reply_mode, limits or {})
if reply_mode == "social_short":
chunks = split_reply_chunks(
return split_reply_chunks(
text,
sentence_limit=1,
char_limit=options["char_limit"],
chunk_limit=1,
allow_clip_split=False,
chunk_limit=0,
allow_clip_split=True,
)
return _clip_total_chars(chunks, options["total_limit"])
if reply_mode == "qa_fast":
chunks = split_reply_chunks(
return split_reply_chunks(
text,
sentence_limit=1,
char_limit=options["char_limit"],
chunk_limit=1,
allow_clip_split=False,
chunk_limit=0,
allow_clip_split=True,
)
return _clip_total_chars(chunks, options["total_limit"])
if reply_mode == "qa_with_context":
chunks = split_reply_chunks(
return split_reply_chunks(
text,
sentence_limit=options["sentence_limit"],
char_limit=options["char_limit"],
chunk_limit=options["chunk_limit"],
allow_clip_split=False,
chunk_limit=0,
allow_clip_split=True,
)
return _clip_total_chars(chunks, options["total_limit"])
chunks = [take_first_sentence(text, options["default_char_limit"]).strip()]
return _clip_total_chars(chunks, options["total_limit"])
return split_reply_chunks(
text,
sentence_limit=1,
char_limit=options["default_char_limit"],
chunk_limit=0,
allow_clip_split=True,
)
def preview_text(text: str, limit: int = 80) -> str:
@@ -63,12 +68,16 @@ def build_length_rule(reply_mode: str) -> str:
return "尽量短,像群友临时接一句,不要长篇大论。"
def take_first_sentence(text: str, limit: int) -> str:
parts = re.split(r"(?<=[。!?!?;])", text)
first = parts[0].strip() if parts and parts[0].strip() else text.strip()
if len(first) <= limit:
return first
return smart_clip(first, limit)
def _normalize_reply_text(text: str) -> str:
# 这里按“保内容、保段落”的思路做清洗:
# 1. 每一行内部压缩多余空白,避免模型输出奇怪缩进;
# 2. 行与行之间保留换行,这样后面可以按段落拆成多条消息发送;
# 3. 不把整段文本合并成一行,避免后续丢失原有表达层次。
lines = [re.sub(r"\s+", " ", line).strip() for line in str(text or "").splitlines()]
cleaned = [line for line in lines if line]
if not cleaned:
return re.sub(r"\s+", " ", str(text or "")).strip()
return "\n".join(cleaned)
def split_reply_chunks(
@@ -78,48 +87,97 @@ def split_reply_chunks(
chunk_limit: int,
allow_clip_split: bool = True,
) -> List[str]:
parts = [item.strip() for item in re.split(r"(?<=[。!?!?;])", text) if item.strip()]
if not parts:
short = text.strip()
clipped = smart_clip(short, char_limit)
if not short:
return []
if not allow_clip_split:
return [clipped] if clipped else []
remainder = short[len(clipped):].strip(",、;;: ")
return [item for item in [clipped, smart_clip(remainder, char_limit)] if item][:chunk_limit]
normalized_sentence_limit = max(int(sentence_limit or 1), 1)
normalized_char_limit = max(int(char_limit or 0), 8)
# chunk_limit 历史上用于“最多保留几段”,但这会直接截掉后文。
# 现在为了满足“长文可多次发送、不要硬截取”,这里不再把它当成截断开关。
_ = chunk_limit
_ = allow_clip_split
paragraphs = [item.strip() for item in str(text or "").splitlines() if item.strip()]
if not paragraphs:
return []
chunks: List[str] = []
for part in parts[:sentence_limit]:
current = part.strip()
while current and len(chunks) < chunk_limit:
if len(current) <= char_limit:
chunks.append(current.strip())
break
clipped = smart_clip(current, char_limit)
if not clipped:
clipped = current[:char_limit].rstrip(",、;;: ").strip()
if clipped:
chunks.append(clipped)
if not allow_clip_split:
break
current = current[len(clipped):].strip(",、;;: ")
return chunks[:chunk_limit] or [smart_clip(text, char_limit)]
for paragraph in paragraphs:
chunks.extend(
_split_paragraph_for_delivery(
paragraph,
sentence_limit=normalized_sentence_limit,
char_limit=normalized_char_limit,
)
)
return [chunk for chunk in chunks if chunk.strip()]
def smart_clip(text: str, limit: int) -> str:
def smart_take_prefix(text: str, limit: int) -> str:
# 这里不做“裁掉尾巴”的 smart clip而是返回一个“可以安全先发出去的前缀”
# 1. 如果窗口内有标点,优先在标点后断开,尽量保留语气完整;
# 2. 如果没有合适标点,就按长度切一段,但后续剩余内容还会继续发送;
# 3. 这样只是分段,不是删减。
text = str(text or "").strip()
if len(text) <= limit:
return text
window = text[:limit]
strong_punctuation = "。!?!?)】]」』 "
strong_punctuation = "。!?!?)】]」』"
weak_punctuation = ",、;;:"
split_at = _find_split_at(window, strong_punctuation)
if split_at < 0:
split_at = _find_split_at(window, weak_punctuation, lookback=12)
if split_at >= 0:
return window[:split_at].rstrip(",、;;:。!?!? ").strip()
return window.rstrip(",、;;: ").strip()
return window[: split_at + 1].strip()
return window.strip()
def _split_paragraph_for_delivery(paragraph: str, sentence_limit: int, char_limit: int) -> List[str]:
# 这里按“先按句聚合,句子过长再继续切”的顺序拆分:
# 1. 优先把短句拼成一条,减少无意义的多条发送;
# 2. 一旦单句本身超过阈值,再按更细的片段切开;
# 3. 整个过程中不丢任何内容,所有字符最终都会进入某一条消息。
parts = [item.strip() for item in re.split(r"(?<=[。!?!?;])", paragraph) if item.strip()]
if not parts:
return _split_long_text_preserving_all(paragraph, char_limit)
chunks: List[str] = []
current_parts: List[str] = []
for part in parts:
if len(part) > char_limit:
if current_parts:
chunks.append("".join(current_parts).strip())
current_parts = []
chunks.extend(_split_long_text_preserving_all(part, char_limit))
continue
candidate_parts = current_parts + [part]
candidate_text = "".join(candidate_parts).strip()
if current_parts and (
len(candidate_parts) > sentence_limit or len(candidate_text) > char_limit
):
chunks.append("".join(current_parts).strip())
current_parts = [part]
continue
current_parts = candidate_parts
if current_parts:
chunks.append("".join(current_parts).strip())
return [chunk for chunk in chunks if chunk]
def _split_long_text_preserving_all(text: str, char_limit: int) -> List[str]:
remaining = str(text or "").strip()
if not remaining:
return []
chunks: List[str] = []
while remaining:
prefix = smart_take_prefix(remaining, char_limit)
if not prefix:
prefix = remaining[:char_limit].strip()
if not prefix:
break
chunks.append(prefix)
remaining = remaining[len(prefix):].lstrip()
return chunks
def _find_split_at(window: str, punctuation: str, lookback: int = 10) -> int:
@@ -131,10 +189,10 @@ def _find_split_at(window: str, punctuation: str, lookback: int = 10) -> int:
def _resolve_limits(reply_mode: str, limits: Dict) -> Dict[str, int]:
mode_defaults = {
# 这里的默认值是“本地最终裁剪”的最后一道保险
# 1. 下限不做要求,模型可以回很短
# 2. 这里只管兜底上限,避免偶发输出过长
# 3. 所有模式统一往 30 字附近收,保持群聊接话感
# 这里的默认值不再承担“截断总长度”的职责,而是只作为“单条消息建议拆分长度”
# 1. 30 字以内通常比较像群里一条自然接话
# 2. 真超过了就继续拆成下一条,而不是把后半段吞掉
# 3. qa_with_context 允许单条略紧一点,方便多句分开发送
"social_short": {"sentence_limit": 1, "char_limit": 30, "chunk_limit": 1, "total_limit": 30},
"qa_fast": {"sentence_limit": 1, "char_limit": 30, "chunk_limit": 1, "total_limit": 30},
"qa_with_context": {"sentence_limit": 2, "char_limit": 18, "chunk_limit": 2, "total_limit": 30},
@@ -171,27 +229,3 @@ def _resolve_limits(reply_mode: str, limits: Dict) -> Dict[str, int]:
"total_limit": max(int(limits.get("default_total_limit", defaults["total_limit"]) or defaults["total_limit"]), 8),
"default_char_limit": max(int(limits.get("default_char_limit", 30) or 30), 8),
}
def _clip_total_chars(chunks: List[str], total_limit: int) -> List[str]:
if not chunks:
return []
normalized_limit = max(int(total_limit or 0), 8)
result: List[str] = []
used = 0
for chunk in chunks:
current = str(chunk or "").strip()
if not current:
continue
remain = normalized_limit - used
if remain <= 0:
break
if len(current) <= remain:
result.append(current)
used += len(current)
continue
clipped = smart_clip(current, remain)
if clipped:
result.append(clipped)
break
return result