MaiBot/src/plugins/PFC/reply_generator.py

364 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import random
from .pfc_utils import retrieve_contextual_info
from src.common.logger_manager import get_logger
from ..models.utils_model import LLMRequest
from ...config.config import global_config
from .chat_observer import ChatObserver
from .reply_checker import ReplyChecker
from src.individuality.individuality import Individuality
from .observation_info import ObservationInfo
from .conversation_info import ConversationInfo
from src.plugins.utils.chat_message_builder import build_readable_messages
logger = get_logger("reply_generator")
PROMPT_GER_VARIATIONS = [
("不用输出或提及提及对方的网名或绰号", 0.50),
("如果当前对话比较轻松,可以尝试用轻松幽默或者略带调侃的语气回应,但要注意分寸", 0.8),
("避免使用过于正式或书面化的词语,多用生活化的口语表达", 0.8),
("如果对方的发言比较跳跃或难以理解,可以尝试用猜测或确认的语气回应", 0.8),
("如果感觉对话有点干巴,可以尝试引入一些轻松的相关小话题或者自己的小想法,但不要偏离太远", 0.8),
("注意观察对方的情绪(如果能从文字中判断),并作出适当的回应,比如安慰、鼓励或表示理解", 0.8),
("", 0.10)
]
REPLY_STYLE1_VARIATIONS = [
("整体风格可以平和、简短", 0.3),
("回复可以非常简洁,有时甚至用单个词、短语或者一个反问就能表达清楚", 0.10),
("尝试使用更自然的口语连接词,例如:然后/所以呢/不过嘛/倒是", 0.05),
("在表达观点时,可以说得主观一些,例如:我觉得.../我个人感觉.../要我说...", 0.10),
("**请省略主语,简短**", 0.4),
("回复得认真一些", 0.05),
]
REPLY_STYLE2_VARIATIONS = [
("结尾可以使用语气助词,例如:呀/噢/诶/哈/啦,让语气更生动", 0.10),
("不要输出任何语气词", 0.10),
("在适当的时候,可以用一些感叹词来表达情绪或态度,例如:哇/啊?/啧啧/哎呀", 0.05),
("可以模糊化表达,例如:'我记得...'", 0.10),
("对于一些无聊或者不想深入的话题,可以敷衍一下,例如:/哦这样啊/还行吧/随便啦", 0.10),
("尽量用简单句和短句", 0.25),
("不要输出任何标点符号,简短", 0.30),
]
# --- 定义 Prompt 模板 ---
# Prompt for direct_reply (首次回复)
PROMPT_DIRECT_REPLY = """
当前时间:{current_time_str}
{persona_text}
你正在和{sender_name}在QQ上私聊。
你与对方的关系是:{relationship_text}
你现在的心情是:{current_emotion_text}
请根据以下信息生成一条回复:
当前对话目标:{goals_str}
你有以下这些知识:
{retrieved_knowledge_str}
请你**记住上面的知识**,在回复中有可能会用到。
最近的聊天记录:
{chat_history_text}
{retrieved_memory_str}
{last_rejection_info}
请根据上述信息,结合聊天记录,回复对方。该回复应该:
1. 符合对话目标,以""的角度发言(不要自己与自己对话!)
2. 符合你的性格特征和身份细节
3. 通俗易懂自然流畅像正常聊天一样简短通常20字以内除非特殊情况
4. 可以适当利用相关知识和回忆,但**不要生硬引用**,若无必要,也可以不利用
5. 自然、得体,结合聊天记录逻辑合理,没有重复表达同质内容,也没有复读你之前的发言
请注意把握聊天内容,不要回复的太有条理,可以有个性。请分清""和对方说的话,不要把""说的话当做对方说的话,这是你自己说的话。
可以回复得自然随意自然一些,就像真人一样,注意把握聊天内容,{reply_style1},不要刻意突出自身学科背景,不要说你说过的话,{reply_style2}
{prompt_ger},请你注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出回复内容。
不要输出多余内容(包括前后缀冒号和引号括号表情包at或 @等 )。
请直接输出回复内容,不需要任何额外格式。"""
# Prompt for send_new_message (追问/补充)
PROMPT_SEND_NEW_MESSAGE = """
当前时间:{current_time_str}
{persona_text}
你正在和{sender_name}在QQ上私聊**并且刚刚你已经发送了一条或多条消息**
你与对方的关系是:{relationship_text}
你现在的心情是:{current_emotion_text}
现在请根据以下信息判断你是否要继续发一条新消息,当然,如果你决定继续发消息不合适,也可以不发:
当前对话目标:{goals_str}
你有以下这些知识:
{retrieved_knowledge_str}
请你**记住上面的知识**,在发消息时有可能会用到。
最近的聊天记录:
{chat_history_text}
{retrieved_memory_str}
{last_rejection_info}
{spam_warning_info}
请根据上述信息,判断你是否要继续发一条新消息(例如对之前消息的补充,深入话题,或追问等等)。如果你觉得要发送,该消息应该:
1. 符合对话目标,以""的角度发言(不要自己与自己对话!)
2. 符合你的性格特征和身份细节
3. 通俗易懂自然流畅像正常聊天一样简短通常20字以内除非特殊情况
4. 可以适当利用相关知识和回忆,但**不要生硬引用**,若无必要,也可以不利用
5. 跟之前你发的消息自然的衔接,逻辑合理,没有重复表达同质内容或部分重叠内容,也没有复读你之前的发言
请注意把握聊天内容,不用太有条理,可以有个性。请分清""和对方说的话,不要把""说的话当做对方说的话,这是你自己说的话。
这条消息可以自然随意自然一些,就像真人一样,注意把握聊天内容,{reply_style1},不要刻意突出自身学科背景,不要说你说过的话,{reply_style2}
{prompt_ger}
如果你决定继续发消息不合适,也可以不发送。
请严格按照以下JSON格式输出你的选择和消息内容不要包含任何其他说明或非JSON文本
{{
"send": "yes/no",
"txt": "如果选择发送,这里是具体的消息文本。如果选择不发送,这里也填写 'no'"
}}
"""
# Prompt for say_goodbye (告别语生成)
PROMPT_FAREWELL = """
当前时间:{current_time_str}
{persona_text}
你正在和{sender_name}私聊在QQ上私聊现在你们的对话似乎已经结束。
你与对方的关系是:{relationship_text}
你现在的心情是:{current_emotion_text}
现在你决定再发一条最后的消息来圆满结束。
最近的聊天记录:
{chat_history_text}
请根据上述信息,结合聊天记录,构思一条**简短、自然、符合你人设**的最后的消息。
这条消息应该:
1. 从你自己的角度发言。
2. 符合你的性格特征和身份细节。
3. 通俗易懂,自然流畅,通常很简短。
4. 自然地为这场对话画上句号,避免开启新话题或显得冗长、刻意。
请像真人一样随意自然,**简洁是关键**。
不要输出多余内容包括前后缀、冒号、引号、括号、表情包、at或@等)。
请直接输出最终的告别消息内容,不需要任何额外格式。"""
class ReplyGenerator:
"""回复生成器"""
def __init__(self, stream_id: str, private_name: str):
self.llm = LLMRequest(
model=global_config.llm_PFC_chat,
temperature=global_config.llm_PFC_chat["temp"],
max_tokens=300, # 对于JSON输出这个可能需要适当调整但一般回复短JSON结构也简单
request_type="reply_generation",
)
self.personality_info = Individuality.get_instance().get_prompt(x_person=2, level=3)
self.name = global_config.BOT_NICKNAME
self.private_name = private_name
self.chat_observer = ChatObserver.get_instance(stream_id, private_name)
self.reply_checker = ReplyChecker(stream_id, private_name)
# 修改 generate 方法签名,增加 action_type 参数
async def generate(
self, observation_info: ObservationInfo, conversation_info: ConversationInfo, action_type: str
) -> str:
"""生成回复
Args:
observation_info: 观察信息
conversation_info: 对话信息
action_type: 当前执行的动作类型 ('direct_reply', 'send_new_message', 'say_goodbye')
Returns:
str: 生成的回复。
对于 'direct_reply''say_goodbye',返回纯文本回复。
对于 'send_new_message'返回包含决策和文本的JSON字符串。
"""
logger.debug(
f"[私聊][{self.private_name}]开始生成回复 (动作类型: {action_type}):当前目标: {conversation_info.goal_list}"
)
chosen_prompt_ger = random.choices([style[0] for style in PROMPT_GER_VARIATIONS], weights=[style[1] for style in PROMPT_GER_VARIATIONS], k=1)[0]
chosen_reply_style1 = random.choices([style[0] for style in REPLY_STYLE1_VARIATIONS], weights=[style[1] for style in REPLY_STYLE1_VARIATIONS], k=1)[0]
chosen_reply_style2 = random.choices([style[0] for style in REPLY_STYLE2_VARIATIONS], weights=[style[1] for style in REPLY_STYLE2_VARIATIONS], k=1)[0]
# --- 构建通用 Prompt 参数 ---
goals_str = ""
if conversation_info.goal_list:
for goal_reason in conversation_info.goal_list:
if isinstance(goal_reason, dict):
goal = goal_reason.get("goal", "目标内容缺失")
reasoning = goal_reason.get("reasoning", "没有明确原因")
else:
goal = str(goal_reason)
reasoning = "没有明确原因"
goal = str(goal) if goal is not None else "目标内容缺失"
reasoning = str(reasoning) if reasoning is not None else "没有明确原因"
goals_str += f"- 目标:{goal}\n 原因:{reasoning}\n"
else:
goals_str = "- 目前没有明确对话目标\n"
chat_history_text = observation_info.chat_history_str
if observation_info.new_messages_count > 0 and observation_info.unprocessed_messages:
new_messages_list = observation_info.unprocessed_messages
new_messages_str = await build_readable_messages(
new_messages_list,
replace_bot_name=True,
merge_messages=False,
timestamp_mode="relative",
read_mark=0.0,
)
chat_history_text += f"\n--- 以下是 {observation_info.new_messages_count} 条新消息 ---\n{new_messages_str}"
elif not chat_history_text:
chat_history_text = "还没有聊天记录。"
else:
chat_history_text += (
"\n--- 以上均为已读消息,未读消息均已处理完毕 ---\n"
)
sender_name_str = getattr(observation_info, 'sender_name', '对方')
if not sender_name_str:
sender_name_str = '对方'
relationship_text_str = getattr(conversation_info, 'relationship_text', '你们还不熟悉。')
current_emotion_text_str = getattr(conversation_info, 'current_emotion_text', '心情平静。')
persona_text = f"你的名字是{self.name}{self.personality_info}"
retrieval_context = chat_history_text
retrieved_memory_str, retrieved_knowledge_str = await retrieve_contextual_info(
retrieval_context, self.private_name
)
logger.info(
f"[私聊][{self.private_name}] (ReplyGenerator) 统一检索完成。记忆: {'' if '回忆起' in retrieved_memory_str else ''} / 知识: {'' if '出错' not in retrieved_knowledge_str and '无相关知识' not in retrieved_knowledge_str else ''}"
)
last_rejection_info_str = ""
last_reason = getattr(conversation_info, "last_reply_rejection_reason", None)
last_content = getattr(conversation_info, "last_rejected_reply_content", None)
if last_reason and last_content:
if last_reason == "机器人尝试发送重复消息": # 这是我们从 ReplyChecker 设置的特定原因
last_rejection_info_str = (
f"\n------\n"
f"【重要提示:你上一次尝试发送的消息 “{last_content}” 与你更早之前发送过的某条消息完全相同。这属于复读行为,请避免。】\n"
f"请根据此提示调整你的新回复,确保内容新颖,不要重复你已经说过的话。\n"
f"------\n"
)
logger.info(
f"[私聊][{self.private_name}] (ReplyGenerator) 检测到自身复读,将加入特定警告到 Prompt:\n"
f" 内容: {last_content}"
)
else: # 其他类型的拒绝原因,保持原有格式
last_rejection_info_str = (
f"\n------\n"
f"【重要提示:你上一次尝试回复时失败了,以下是详细信息】\n"
f"上次试图发送的消息内容: “{last_content}\n"
f"失败原因: “{last_reason}\n"
f"请根据【消息内容】和【失败原因】调整你的新回复,避免重复之前的错误。\n"
f"------\n"
)
logger.info(
f"[私聊][{self.private_name}] (ReplyGenerator) 检测到上次回复失败信息,将加入 Prompt:\n"
f" 内容: {last_content}\n"
f" 原因: {last_reason}"
)
# 新增:构建刷屏警告信息 for PROMPT_SEND_NEW_MESSAGE
spam_warning_message = ""
if action_type == "send_new_message": # 只在 send_new_message 时构建刷屏警告
if conversation_info.my_message_count > 5:
spam_warning_message = f"⚠️【警告】**你已连续发送{str(conversation_info.my_message_count)}条消息!请谨慎考虑是否继续发送!以免刷屏对造成对方困扰!**"
elif conversation_info.my_message_count > 2:
spam_warning_message = f"💬【提示】**你已连续发送{str(conversation_info.my_message_count)}条消息。如果非必要,请避免连续发送,以免给对方造成困扰。**"
if spam_warning_message:
spam_warning_message = f"\n{spam_warning_message}\n"
# --- 选择 Prompt ---
if action_type == "send_new_message":
prompt_template = PROMPT_SEND_NEW_MESSAGE
logger.info(f"[私聊][{self.private_name}]使用 PROMPT_SEND_NEW_MESSAGE (追问/补充生成, 期望JSON输出)")
elif action_type == "say_goodbye":
prompt_template = PROMPT_FAREWELL
logger.info(f"[私聊][{self.private_name}]使用 PROMPT_FAREWELL (告别语生成)")
else:
prompt_template = PROMPT_DIRECT_REPLY
logger.info(f"[私聊][{self.private_name}]使用 PROMPT_DIRECT_REPLY (首次/非连续回复生成)")
# --- 格式化最终的 Prompt ---
try:
current_time_value = "获取时间失败"
if observation_info and hasattr(observation_info, 'current_time_str') and observation_info.current_time_str:
current_time_value = observation_info.current_time_str
base_format_params = {
"persona_text": persona_text,
"goals_str": goals_str,
"chat_history_text": chat_history_text,
"retrieved_memory_str": retrieved_memory_str if retrieved_memory_str else "无相关记忆。", # 确保已定义
"retrieved_knowledge_str": retrieved_knowledge_str if retrieved_knowledge_str else "无相关知识。", # 确保已定义
"last_rejection_info": last_rejection_info_str,
"current_time_str": current_time_value,
"sender_name": sender_name_str,
"relationship_text": relationship_text_str,
"current_emotion_text": current_emotion_text_str,
"reply_style1": chosen_reply_style1,
"reply_style2": chosen_reply_style2,
"prompt_ger": chosen_prompt_ger,
}
if action_type == "send_new_message":
current_format_params = base_format_params.copy()
current_format_params["spam_warning_info"] = spam_warning_message
prompt = prompt_template.format(**current_format_params)
elif action_type == "say_goodbye":
farewell_params = {
k: v for k, v in base_format_params.items() if k in [
"persona_text", "chat_history_text", "current_time_str",
"sender_name", "relationship_text", "current_emotion_text",
]
}
prompt = prompt_template.format(**farewell_params)
else: # direct_reply
current_format_params = base_format_params.copy()
prompt = prompt_template.format(**current_format_params)
except KeyError as e:
logger.error(
f"[私聊][{self.private_name}]格式化 Prompt 时出错,缺少键: {e}。请检查 Prompt 模板和传递的参数。"
)
return "抱歉,准备回复时出了点问题,请检查一下我的代码..." # 对于JSON期望的场景这里可能也需要返回一个固定的错误JSON
except Exception as fmt_err:
logger.error(f"[私聊][{self.private_name}]格式化 Prompt 时发生未知错误: {fmt_err}")
return "抱歉,准备回复时出了点内部错误,请检查一下我的代码..."
# --- 调用 LLM 生成 ---
logger.debug(f"[私聊][{self.private_name}]发送到LLM的生成提示词:\n------\n{prompt}\n------")
try:
content, _ = await self.llm.generate_response_async(prompt)
# 对于 PROMPT_SEND_NEW_MESSAGE我们期望 content 是一个 JSON 字符串
# 对于其他 promptscontent 是纯文本回复
# 该方法现在直接返回 LLM 的原始输出,由调用者 (conversation._handle_action) 负责解析
logger.debug(f"[私聊][{self.private_name}]LLM原始生成内容: {content}")
return content
except Exception as e:
logger.error(f"[私聊][{self.private_name}]生成回复时出错: {e}")
# 根据 action_type 返回不同的错误指示
if action_type == "send_new_message":
# 返回一个表示错误的JSON让调用方知道出错了但仍能解析
return """{{
"send": "no",
"txt": "LLM生成回复时出错"
}}""".strip()
else:
return "抱歉,我现在有点混乱,让我重新思考一下..."