diff --git a/plugins/ai_auto_response/core/reply_formatter.py b/plugins/ai_auto_response/core/reply_formatter.py index 8c16e7d..c1bc5ee 100644 --- a/plugins/ai_auto_response/core/reply_formatter.py +++ b/plugins/ai_auto_response/core/reply_formatter.py @@ -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