137 lines
4.5 KiB
Python
137 lines
4.5 KiB
Python
"""
|
||
回复文本解析模块
|
||
负责处理 Agent 返回的原始文本,分离思考内容和正文。
|
||
|
||
SSE 实际输出格式(从日志确认):
|
||
- " thinking\\n...思考内容...\\n response\\n...正文内容..." (纯文本标记)
|
||
- 思考部分: " thinking\\n" 开头
|
||
- 正文部分: "\\n response\\n" 之后的所有内容
|
||
|
||
多轮调用时 full_text 会累加,调用方需要自行切分轮次。
|
||
"""
|
||
import re
|
||
|
||
# 思考-正文分隔标记:出现在文本中时,之前是思考,之后是正文
|
||
_RESPONSE_MARKER = re.compile(r'\n response\n')
|
||
|
||
|
||
def parse_reply(text: str) -> tuple[str, str]:
|
||
"""
|
||
从文本中分离思考内容和正文。
|
||
|
||
支持两种格式:
|
||
1. " thinking\\n...\\n response\\n..." (SSE 实际纯文本格式)
|
||
2. " thinking... response" (HTML 标签风格)
|
||
|
||
返回: (思考内容, 正文内容)
|
||
"""
|
||
if not text:
|
||
return "", ""
|
||
|
||
think_content = ""
|
||
body_content = text
|
||
|
||
# 格式1:匹配 " thinking\\n ... \\n response\\n" 纯文本标记
|
||
# 从 SSE 日志确认: 思考内容以 "\\n response\\n" 结束
|
||
parts = _RESPONSE_MARKER.split(text, maxsplit=1)
|
||
if len(parts) == 2:
|
||
think_part, body_part = parts
|
||
# 去掉开头的 " thinking\\n"
|
||
think_part = think_part.strip()
|
||
if think_part.startswith(' thinking'):
|
||
think_part = think_part[len(' thinking'):].lstrip('\n')
|
||
think_content = think_part.strip()
|
||
body_content = body_part.strip()
|
||
|
||
# 格式2:匹配 " thinking... response" HTML 标签风格(兜底)
|
||
if not think_content and "<think" in text:
|
||
match = re.search(r"<think([\s\S]*?)</think\s*>", text)
|
||
if match:
|
||
think_content = match.group(1).strip()
|
||
body_content = re.sub(r"<think[\s\S]*?</think\s*>", "", text).strip()
|
||
else:
|
||
think_content = text.replace("<think", "").strip()
|
||
body_content = ""
|
||
|
||
# 去重
|
||
body_content = _deduplicate(body_content)
|
||
think_content = _deduplicate(think_content)
|
||
|
||
return think_content, body_content
|
||
|
||
|
||
def extract_body_only(text: str) -> str:
|
||
"""
|
||
从文本中只提取正文内容(不含思考)。
|
||
用于流式更新时过滤思考内容。
|
||
|
||
如果文本中还没有出现 "\\n response\\n" 标记,
|
||
说明还在思考阶段,返回空字符串避免把思考内容当正文发送。
|
||
"""
|
||
if not text:
|
||
return ""
|
||
|
||
# 检查正文标记是否已出现
|
||
split_pos = _find_response_marker(text)
|
||
if split_pos < 0:
|
||
# 还没出现 response 标记,仍在思考阶段,不返回任何内容
|
||
return ""
|
||
|
||
# 返回 response 之后的内容
|
||
body = text[split_pos + 1:]
|
||
# 去掉开头的 response\n
|
||
body = body.lstrip('\n')
|
||
if body.startswith(' response'):
|
||
body = body[len(' response'):].lstrip('\n')
|
||
|
||
# 再次检查是否有新的 response 标记(多轮场景)
|
||
pos2 = _find_response_marker(body)
|
||
if pos2 >= 0:
|
||
body = body[pos2 + 1:]
|
||
body = body.lstrip('\n')
|
||
if body.startswith(' response'):
|
||
body = body[len(' response'):].lstrip('\n')
|
||
|
||
return body.strip()
|
||
|
||
|
||
def _find_response_marker(text: str) -> int:
|
||
"""查找 \\n response\\n 标记的位置,返回 response 中 'r' 的索引"""
|
||
m = _RESPONSE_MARKER.search(text)
|
||
return m.start() if m else -1
|
||
|
||
|
||
def _deduplicate(text: str) -> str:
|
||
"""对回复文本去重。"""
|
||
if not text:
|
||
return text
|
||
|
||
# 行级去重
|
||
lines = text.split("\n")
|
||
non_empty = [l for l in lines if l.strip()]
|
||
|
||
if len(non_empty) > 1:
|
||
mid = len(non_empty) // 2
|
||
if non_empty[:mid] == non_empty[mid:]:
|
||
return "\n".join(non_empty[:mid])
|
||
|
||
for overlap_len in range(min(len(non_empty) // 2, 20), 0, -1):
|
||
if non_empty[-overlap_len:] == non_empty[:overlap_len]:
|
||
return "\n".join(non_empty[overlap_len:])
|
||
|
||
# 字符级去重
|
||
length = len(text)
|
||
for sub_len in range(length // 2, 0, -1):
|
||
if length % sub_len == 0 and text[:sub_len] * (length // sub_len) == text:
|
||
return text[:sub_len]
|
||
|
||
# 部分重叠
|
||
for overlap in range(min(length // 2, 500), 0, -1):
|
||
if text[:overlap] == text[-overlap:]:
|
||
trimmed = text[overlap:]
|
||
trimmed_len = len(trimmed)
|
||
for sub_len in range(trimmed_len // 2, 0, -1):
|
||
if trimmed_len % sub_len == 0 and trimmed[:sub_len] * (trimmed_len // sub_len) == trimmed:
|
||
return trimmed[:sub_len]
|
||
|
||
return text |