better:优化分割,优化表达使用,优化Planner选择和联动,优化记忆总结,优化回复Log

pull/1443/head
SengokuCola 2025-12-18 10:52:58 +08:00
parent 3ea775af92
commit 1e159213cf
9 changed files with 252 additions and 58 deletions

View File

@ -145,16 +145,33 @@ class ExpressionSelector:
for expr in style_query
]
# 要求至少有10个 count > 1 的表达方式才进行选择
min_required = 10
# 要求至少有一定数量的 count > 1 的表达方式才进行“完整简单模式”选择
min_required = 8
if len(style_exprs) < min_required:
# 高 count 样本不足:如果还有候选,就降级为随机选 3 个;如果一个都没有,则直接返回空
if not style_exprs:
logger.info(
f"聊天流 {chat_id} 没有满足 count > 1 且未被拒绝的表达方式,简单模式不进行选择"
)
# 完全没有高 count 样本时退化为全量随机抽样不进入LLM流程
fallback_num = min(3, max_num) if max_num > 0 else 3
fallback_selected = self._random_expressions(chat_id, fallback_num)
if fallback_selected:
self.update_expressions_last_active_time(fallback_selected)
selected_ids = [expr["id"] for expr in fallback_selected]
logger.info(
f"聊天流 {chat_id} 使用简单模式降级随机抽选 {len(fallback_selected)} 个表达(无 count>1 样本)"
)
return fallback_selected, selected_ids
return [], []
logger.info(
f"聊天流 {chat_id} count > 1 的表达方式不足 {min_required} 个(实际 {len(style_exprs)} 个),不进行选择"
f"聊天流 {chat_id} count > 1 的表达方式不足 {min_required} 个(实际 {len(style_exprs)} 个),"
f"简单模式降级为随机选择 3 个"
)
return [], []
# 固定选择5个
select_count = 5
select_count = min(3, len(style_exprs))
else:
# 高 count 数量达标时,固定选择 5
select_count = 5
import random
selected_style = random.sample(style_exprs, select_count)
@ -308,20 +325,28 @@ class ExpressionSelector:
select_random_count = 5
# 检查数量要求
# 对于高 count 表达:如果数量不足,不再直接停止,而是仅跳过“高 count 优先选择”
if len(high_count_exprs) < min_high_count:
logger.info(
f"聊天流 {chat_id} count > 1 的表达方式不足 {min_high_count} 个(实际 {len(high_count_exprs)} 个),不进行选择"
f"聊天流 {chat_id} count > 1 的表达方式不足 {min_high_count} 个(实际 {len(high_count_exprs)} 个),"
f"将跳过高 count 优先选择,仅从全部表达中随机抽样"
)
return [], []
high_count_valid = False
else:
high_count_valid = True
# 总量不足仍然直接返回,避免样本过少导致选择质量过低
if len(all_style_exprs) < min_total_count:
logger.info(
f"聊天流 {chat_id} 总表达方式不足 {min_total_count} 个(实际 {len(all_style_exprs)} 个),不进行选择"
)
return [], []
# 先选取高count的表达方式
selected_high = weighted_sample(high_count_exprs, min(len(high_count_exprs), select_high_count))
# 先选取高count的表达方式如果数量达标
if high_count_valid:
selected_high = weighted_sample(high_count_exprs, min(len(high_count_exprs), select_high_count))
else:
selected_high = []
# 然后从所有表达方式中随机抽样(使用加权抽样)
remaining_num = select_random_count

View File

@ -759,8 +759,8 @@ class JargonMiner:
content_key = entry["content"]
# 检查是否包含人物名称
logger.info(f"process_extracted_entries 检查是否包含人物名称: {content_key}")
logger.info(f"person_name_filter: {person_name_filter}")
# logger.info(f"process_extracted_entries 检查是否包含人物名称: {content_key}")
# logger.info(f"person_name_filter: {person_name_filter}")
if person_name_filter and person_name_filter(content_key):
logger.info(f"process_extracted_entries 跳过包含人物名称的黑话: {content_key}")
continue
@ -885,7 +885,7 @@ class JargonMiner:
logger.info(f"[{self.stream_name}]疑似黑话: {jargon_str}")
if saved or updated:
logger.info(f"jargon写入: 新增 {saved} 条,更新 {updated}chat_id={self.chat_id}")
logger.debug(f"jargon写入: 新增 {saved} 条,更新 {updated}chat_id={self.chat_id}")
except Exception as e:
logger.error(f"处理已提取的黑话条目失败: {e}")

View File

@ -95,7 +95,7 @@ class MessageRecorder:
self.last_extraction_time = extraction_end_time
try:
logger.info(f"在聊天流 {self.chat_name} 开始统一消息提取和分发")
# logger.info(f"在聊天流 {self.chat_name} 开始统一消息提取和分发")
# 拉取提取窗口内的消息
messages = get_raw_msg_by_timestamp_with_chat_inclusive(

View File

@ -15,12 +15,15 @@ from src.chat.utils.prompt_builder import Prompt, global_prompt_manager
from src.chat.utils.chat_message_builder import (
build_readable_messages_with_id,
get_raw_msg_before_timestamp_with_chat,
replace_user_references,
)
from src.chat.utils.utils import get_chat_type_and_target_info
from src.chat.planner_actions.action_manager import ActionManager
from src.chat.message_receive.chat_stream import get_chat_manager
from src.plugin_system.base.component_types import ActionInfo, ComponentType, ActionActivationType
from src.plugin_system.core.component_registry import component_registry
from src.plugin_system.apis.message_api import translate_pid_to_description
from src.person_info.person_info import Person
if TYPE_CHECKING:
from src.common.data_models.info_data_model import TargetPersonInfo
@ -68,7 +71,8 @@ no_reply
{moderation_prompt}
target_message_id为必填表示触发消息的id
请选择所有符合使用要求的action动作用json格式输出```json包裹如果输出多个json每个json都要单独一行放在同一个```json代码块内:
请选择所有符合使用要求的action每个动作最多选择一次但是可以选择多个动作
动作用json格式输出```json包裹如果输出多个json每个json都要单独一行放在同一个```json代码块内:
**示例**
// 理由文本简短
```json
@ -155,11 +159,41 @@ class ActionPlanner:
logger.warning(f"{self.log_prefix}planner理由引用 {msg_id} 未找到对应消息,保持原样")
return msg_id
msg_text = (message.processed_plain_text or message.display_message or "").strip()
msg_text = (message.processed_plain_text or "").strip()
if not msg_text:
logger.warning(f"{self.log_prefix}planner理由引用 {msg_id} 的消息内容为空,保持原样")
return msg_id
# 替换 [picid:xxx] 为 [图片:描述]
pic_pattern = r"\[picid:([^\]]+)\]"
def replace_pic_id(pic_match: re.Match) -> str:
pic_id = pic_match.group(1)
description = translate_pid_to_description(pic_id)
return f"[图片:{description}]"
msg_text = re.sub(pic_pattern, replace_pic_id, msg_text)
# 替换用户引用格式:回复<aaa:bbb> 和 @<aaa:bbb>
platform = getattr(message, "user_info", None) and message.user_info.platform or getattr(message, "chat_info", None) and message.chat_info.platform or "qq"
msg_text = replace_user_references(msg_text, platform, replace_bot_name=True)
# 替换单独的 <用户名:用户ID> 格式replace_user_references 已处理回复<和@<格式)
# 匹配所有 <aaa:bbb> 格式,由于 replace_user_references 已经替换了回复<和@<格式,
# 这里匹配到的应该都是单独的格式
user_ref_pattern = r"<([^:<>]+):([^:<>]+)>"
def replace_user_ref(user_match: re.Match) -> str:
user_name = user_match.group(1)
user_id = user_match.group(2)
try:
# 检查是否是机器人自己
if user_id == global_config.bot.qq_account:
return f"{global_config.bot.nickname}(你)"
person = Person(platform=platform, user_id=user_id)
return person.person_name or user_name
except Exception:
# 如果解析失败,使用原始昵称
return user_name
msg_text = re.sub(user_ref_pattern, replace_user_ref, msg_text)
preview = msg_text if len(msg_text) <= 100 else f"{msg_text[:97]}..."
logger.info(f"{self.log_prefix}planner理由引用 {msg_id} -> 消息({preview}")
return f"消息({msg_text}"

View File

@ -98,8 +98,10 @@ class DefaultReplyer:
available_actions = {}
try:
# 3. 构建 Prompt
timing_logs = []
almost_zero_str = ""
with Timer("构建Prompt", {}): # 内部计时器,可选保留
prompt, selected_expressions = await self.build_prompt_reply_context(
prompt, selected_expressions, timing_logs, almost_zero_str = await self.build_prompt_reply_context(
extra_info=extra_info,
available_actions=available_actions,
chosen_actions=chosen_actions,
@ -136,9 +138,22 @@ class DefaultReplyer:
content, reasoning_content, model_name, tool_call = await self.llm_generate_content(prompt)
# logger.debug(f"replyer生成内容: {content}")
logger.info(f"模型: [{model_name}][思考等级:{think_level}]生成内容: {content}")
if global_config.debug.show_replyer_reasoning and reasoning_content:
logger.info(f"模型: [{model_name}][思考等级:{think_level}]生成推理:\n{reasoning_content}")
# 统一输出所有日志信息使用try-except确保即使某个步骤出错也能输出
try:
# 1. 输出回复准备日志
timing_log_str = f"回复准备: {'; '.join(timing_logs)}; {almost_zero_str} <0.1s" if timing_logs or almost_zero_str else "回复准备: 无计时信息"
logger.info(timing_log_str)
# 2. 输出Prompt日志
if global_config.debug.show_replyer_prompt:
logger.info(f"\n{prompt}\n")
else:
logger.debug(f"\nreplyer_Prompt:{prompt}\n")
# 3. 输出模型生成内容和推理日志
logger.info(f"模型: [{model_name}][思考等级:{think_level}]生成内容: {content}")
if global_config.debug.show_replyer_reasoning and reasoning_content:
logger.info(f"模型: [{model_name}][思考等级:{think_level}]生成推理:\n{reasoning_content}")
except Exception as e:
logger.warning(f"输出日志时出错: {e}")
llm_response.content = content
llm_response.reasoning = reasoning_content
@ -162,6 +177,21 @@ class DefaultReplyer:
except Exception as llm_e:
# 精简报错信息
logger.error(f"LLM 生成失败: {llm_e}")
# 即使LLM生成失败也尝试输出已收集的日志信息
try:
# 1. 输出回复准备日志
timing_log_str = f"回复准备: {'; '.join(timing_logs)}; {almost_zero_str} <0.1s" if timing_logs or almost_zero_str else "回复准备: 无计时信息"
logger.info(timing_log_str)
# 2. 输出Prompt日志
if global_config.debug.show_replyer_prompt:
logger.info(f"\n{prompt}\n")
else:
logger.debug(f"\nreplyer_Prompt:{prompt}\n")
# 3. 输出模型生成失败信息
logger.info("模型生成失败,无法输出生成内容和推理")
except Exception as log_e:
logger.warning(f"输出日志时出错: {log_e}")
return False, llm_response # LLM 调用失败则无法生成回复
return True, llm_response
@ -705,7 +735,7 @@ class DefaultReplyer:
enable_tool: bool = True,
reply_time_point: Optional[float] = time.time(),
think_level: int = 1,
) -> Tuple[str, List[int]]:
) -> Tuple[str, List[int], List[str], str]:
"""
构建回复器上下文
@ -838,7 +868,8 @@ class DefaultReplyer:
continue
timing_logs.append(f"{chinese_name}: {duration:.1f}s")
logger.info(f"回复准备: {'; '.join(timing_logs)}; {almost_zero_str} <0.1s")
# 不再在这里输出日志,而是返回给调用者统一输出
# logger.info(f"回复准备: {'; '.join(timing_logs)}; {almost_zero_str} <0.1s")
expression_habits_block, selected_expressions = results_dict["expression_habits"]
expression_habits_block: str
@ -915,7 +946,7 @@ class DefaultReplyer:
memory_retrieval=memory_retrieval,
chat_prompt=chat_prompt_block,
planner_reasoning=planner_reasoning,
), selected_expressions
), selected_expressions, timing_logs, almost_zero_str
async def build_prompt_rewrite_context(
self,
@ -1046,10 +1077,11 @@ class DefaultReplyer:
# 直接使用已初始化的模型实例
# logger.info(f"\n{prompt}\n")
if global_config.debug.show_replyer_prompt:
logger.info(f"\n{prompt}\n")
else:
logger.debug(f"\nreplyer_Prompt:{prompt}\n")
# 不再在这里输出日志,而是返回给调用者统一输出
# if global_config.debug.show_replyer_prompt:
# logger.info(f"\n{prompt}\n")
# else:
# logger.debug(f"\nreplyer_Prompt:{prompt}\n")
content, (reasoning_content, model_name, tool_calls) = await self.express_model.generate_response_async(
prompt

View File

@ -198,21 +198,21 @@ def split_into_sentences_w_remove_punctuation(text: str) -> list[str]:
List[str]: 分割和合并后的句子列表
"""
# 预处理:处理多余的换行符
# 1. 将连续的换行符替换为单个换行符
# 1. 将连续的换行符替换为单个换行符(保留换行符用于分割)
text = re.sub(r"\n\s*\n+", "\n", text)
# 2. 处理换行符和其他分隔符的组合
text = re.sub(r"\n\s*([,。;\s])", r"\1", text)
text = re.sub(r"([,。;\s])\s*\n", r"\1", text)
# 2. 处理换行符和其他分隔符的组合(保留换行符,删除其他分隔符)
text = re.sub(r"\n\s*([,。;\s])", r"\n\1", text)
text = re.sub(r"([,。;\s])\s*\n", r"\1\n", text)
# 处理两个汉字中间的换行符
text = re.sub(r"([\u4e00-\u9fff])\n([\u4e00-\u9fff])", r"\1。\2", text)
# 处理两个汉字中间的换行符(保留换行符,不替换为句号,让换行符强制分割)
# text = re.sub(r"([\u4e00-\u9fff])\n([\u4e00-\u9fff])", r"\1。\2", text) # 注释掉,保留换行符用于分割
len_text = len(text)
if len_text < 3:
return list(text) if random.random() < 0.01 else [text]
# 定义分隔符
separators = {"", ",", " ", "", ";"}
# 定义分隔符(包含换行符,换行符必须强制分割)
separators = {"", ",", " ", "", ";", "\n"}
segments = []
current_segment = ""
@ -221,13 +221,27 @@ def split_into_sentences_w_remove_punctuation(text: str) -> list[str]:
while i < len(text):
char = text[i]
if char in separators:
# 检查分割条件:如果空格左右都是英文字母、数字,或数字和英文之间,则不分割(仅对空格应用此规则)
can_split = True
if 0 < i < len(text) - 1:
prev_char = text[i - 1]
next_char = text[i + 1]
# 只对空格应用"不分割数字和数字、数字和英文、英文和数字、英文和英文之间的空格"规则
if char == " ":
# 换行符必须强制分割,不受其他规则影响
if char == "\n":
can_split = True
else:
# 检查分割条件
can_split = True
# 检查分隔符左右是否有冒号(中英文),如果有则不分割
if i > 0:
prev_char = text[i - 1]
if prev_char in {":", ""}:
can_split = False
if i < len(text) - 1:
next_char = text[i + 1]
if next_char in {":", ""}:
can_split = False
# 如果左右没有冒号,再检查空格的特殊情况
if can_split and char == " " and i > 0 and i < len(text) - 1:
prev_char = text[i - 1]
next_char = text[i + 1]
# 不分割数字和数字、数字和英文、英文和数字、英文和英文之间的空格
prev_is_alnum = prev_char.isdigit() or is_english_letter(prev_char)
next_is_alnum = next_char.isdigit() or is_english_letter(next_char)
if prev_is_alnum and next_is_alnum:
@ -237,8 +251,8 @@ def split_into_sentences_w_remove_punctuation(text: str) -> list[str]:
# 只有当当前段不为空时才添加
if current_segment:
segments.append((current_segment, char))
# 如果当前段为空,但分隔符是空格,则也添加一个空段(保留空格
elif char == " ":
# 如果当前段为空,但分隔符是空格或换行符,则也添加一个空段(保留分隔符
elif char in {" ", "\n"}:
segments.append(("", char))
current_segment = ""
else:

View File

@ -7,6 +7,7 @@ import asyncio
import json
import time
import re
import difflib
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
from dataclasses import dataclass, field
@ -30,16 +31,18 @@ HIPPO_CACHE_DIR = Path(__file__).resolve().parents[2] / "data" / "hippo_memorize
def init_prompt():
"""初始化提示词模板"""
topic_analysis_prompt = """
历史话题标题列表仅标题不含具体内容
topic_analysis_prompt = """【历史话题标题列表】(仅标题,不含具体内容):
{history_topics_block}
历史话题标题列表结束
本次聊天记录每条消息前有编号用于后续引用
{messages_block}
本次聊天记录结束
请完成以下任务
**识别话题**
1. 识别本次聊天记录中正在进行的一个或多个话题
2. 本次聊天记录的中的消息可能与历史话题有关也可能毫无关联
2. 判断历史话题标题列表中的话题是否在本次聊天记录中出现如果出现则直接使用该历史话题标题字符串
**选取消息**
@ -374,10 +377,10 @@ class ChatHistorySummarizer:
should_check = True
logger.info(f"{self.log_prefix} 触发检查条件: 消息数量达到 {message_count} 条(阈值: 100条")
# 条件2: 距离上一次检查 > 3600 1小时,触发一次检查
elif time_since_last_check > 2400:
# 条件2: 距离上一次检查 > 3600 * 8 秒8小时且消息数量 >= 20 条,触发一次检查
elif time_since_last_check > 3600 * 8 and message_count >= 20:
should_check = True
logger.info(f"{self.log_prefix} 触发检查条件: 距上次检查 {time_str}(阈值: 1小时")
logger.info(f"{self.log_prefix} 触发检查条件: 距上次检查 {time_str}(阈值: 8小时且消息数量达到 {message_count} 条(阈值: 20条")
if should_check:
await self._run_topic_check_and_update_cache(messages)
@ -459,9 +462,31 @@ class ChatHistorySummarizer:
if not success or not topic_to_indices:
logger.error(f"{self.log_prefix} 话题识别连续 {max_retries} 次失败或始终无有效话题,本次检查放弃")
# 即使识别失败,也认为是一次“检查”,但不更新 no_update_checks保持原状
# 即使识别失败,也认为是一次"检查",但不更新 no_update_checks保持原状
return
# 3.5. 检查新话题是否与历史话题相似(相似度>=90%则使用历史标题)
topic_mapping = self._build_topic_mapping(topic_to_indices, similarity_threshold=0.9)
# 应用话题映射:将相似的新话题标题替换为历史话题标题
if topic_mapping:
new_topic_to_indices: Dict[str, List[int]] = {}
for new_topic, indices in topic_to_indices.items():
# 如果这个新话题需要映射到历史话题
if new_topic in topic_mapping:
historical_topic = topic_mapping[new_topic]
# 如果历史话题已经存在,合并消息索引
if historical_topic in new_topic_to_indices:
# 合并索引并去重
combined_indices = list(set(new_topic_to_indices[historical_topic] + indices))
new_topic_to_indices[historical_topic] = combined_indices
else:
new_topic_to_indices[historical_topic] = indices
else:
# 不需要映射,保持原样
new_topic_to_indices[new_topic] = indices
topic_to_indices = new_topic_to_indices
# 4. 统计哪些话题在本次检查中有新增内容
updated_topics: Set[str] = set()
@ -528,6 +553,71 @@ class ChatHistorySummarizer:
# 无论成功与否,都从缓存中删除,避免重复
self.topic_cache.pop(topic, None)
def _find_most_similar_topic(
self, new_topic: str, existing_topics: List[str], similarity_threshold: float = 0.9
) -> Optional[tuple[str, float]]:
"""
查找与给定新话题最相似的历史话题
Args:
new_topic: 新话题标题
existing_topics: 历史话题标题列表
similarity_threshold: 相似度阈值默认0.990%
Returns:
Optional[tuple[str, float]]: 如果找到相似度>=阈值的历史话题返回(历史话题标题, 相似度)
否则返回None
"""
if not existing_topics:
return None
best_match = None
best_similarity = 0.0
for existing_topic in existing_topics:
similarity = difflib.SequenceMatcher(None, new_topic, existing_topic).ratio()
if similarity > best_similarity:
best_similarity = similarity
best_match = existing_topic
# 如果相似度达到阈值,返回匹配结果
if best_match and best_similarity >= similarity_threshold:
return (best_match, best_similarity)
return None
def _build_topic_mapping(
self, topic_to_indices: Dict[str, List[int]], similarity_threshold: float = 0.9
) -> Dict[str, str]:
"""
构建新话题到历史话题的映射如果相似度>=阈值
Args:
topic_to_indices: 新话题到消息索引的映射
similarity_threshold: 相似度阈值默认0.990%
Returns:
Dict[str, str]: 新话题 -> 历史话题的映射字典
"""
existing_topics_list = list(self.topic_cache.keys())
topic_mapping: Dict[str, str] = {}
for new_topic in topic_to_indices.keys():
# 如果新话题已经在历史话题中,不需要检查
if new_topic in existing_topics_list:
continue
# 查找最相似的历史话题
result = self._find_most_similar_topic(new_topic, existing_topics_list, similarity_threshold)
if result:
historical_topic, similarity = result
topic_mapping[new_topic] = historical_topic
logger.info(
f"{self.log_prefix} 话题相似度检查: '{new_topic}' 与历史话题 '{historical_topic}' 相似度 {similarity:.2%},使用历史标题"
)
return topic_mapping
def _build_numbered_messages_for_llm(
self, messages: List[DatabaseMessages]
) -> tuple[List[str], Dict[int, str], Dict[int, str], Dict[int, Set[str]]]:
@ -622,8 +712,7 @@ class ChatHistorySummarizer:
try:
response, _ = await self.summarizer_llm.generate_response_async(
prompt=prompt,
temperature=0.2,
max_tokens=800,
temperature=0.3,
)
logger.info(f"{self.log_prefix} 话题识别LLM Prompt: {prompt}")

View File

@ -108,8 +108,8 @@ async def generate_with_model_with_tools(
"""
try:
model_name_list = model_config.model_list
logger.info(f"[LLMAPI] 使用模型集合 {model_name_list} 生成内容")
logger.debug(f"[LLMAPI] 完整提示词: {prompt}")
logger.info(f"使用模型{model_name_list}生成内容")
logger.debug(f"完整提示词: {prompt}")
llm_request = LLMRequest(model_set=model_config, request_type=request_type)
@ -147,7 +147,7 @@ async def generate_with_model_with_tools_by_message_factory(
"""
try:
model_name_list = model_config.model_list
logger.info(f"[LLMAPI] 使用模型集合 {model_name_list} 生成内容(消息工厂)")
logger.info(f"使用模型 {model_name_list} 生成内容")
llm_request = LLMRequest(model_set=model_config, request_type=request_type)

View File

@ -1,5 +1,5 @@
[inner]
version = "1.9.0"
version = "1.9.1"
# 配置文件版本号迭代规则同bot_config.toml
@ -138,7 +138,7 @@ price_out = 0
[model_task_config.utils] # 在麦麦的一些组件中使用的模型,例如表情包模块,取名模块,关系模块,麦麦的情绪变化等,是麦麦必须的模型
model_list = ["siliconflow-deepseek-v3.2"] # 使用的模型列表,每个子项对应上面的模型名称(name)
temperature = 0.2 # 模型温度新V3建议0.1-0.3
max_tokens = 2048 # 最大输出token数
max_tokens = 4096 # 最大输出token数
slow_threshold = 15.0 # 慢请求阈值(秒),模型等待回复时间超过此值会输出警告日志
[model_task_config.utils_small] # 在麦麦的一些组件中使用的小模型,消耗量较大,建议使用速度较快的小模型