feat(affinity_flow_chatter): 重构计划器以支持多动作并优化思考逻辑

本次提交对亲和流聊天器(AFC)的计划与决策核心进行了重大重构和功能增强,旨在提升其响应的灵活性、鲁棒性和可观测性。

主要变更包括:

1.  **多动作支持与解析重构**:
    -   `PlanFilter` 现在能够正确解析并处理 LLM 返回的动作列表(`"actions": [...]`),而不仅限于单个动作,这使得机器人能够执行更复杂的组合行为。
    -   增强了动作解析的鲁棒性,当找不到 `target_message_id` 时会优雅降级(如 `reply` 变为 `no_action`),并会根据当前实际可用的动作列表对 LLM 的选择进行验证。

2.  **提示词工程与思考模式优化**:
    -   重新设计了核心 Planner 提示词,将 `thinking` 字段定义为“思绪流”,引导 LLM 生成更自然、更符合角色的内心独白,而非简单的决策理由,从而提升决策质量和角色扮演的沉浸感。
    -   强制要求 LLM 为需要目标消息的动作提供 `target_message_id`,提高了动作执行的准确性。

3.  **上下文构建与鲁棒性增强**:
    -   在 `PlanFilter` 中增加了上下文回退机制,当内存中缺少历史消息时(如冷启动),会自动从数据库加载最近的消息记录,确保决策所需上下文的完整性。
    -   简化了提供给 LLM 的未读消息格式,移除了兴趣度分数等内部信息,并加入了用户昵称,使其更易于理解和处理。

4.  **可观测性与日志改进**:
    -   在 AFC 的多个关键节点(消息接收、决策、动作执行)增加了彩色的详细日志,使其决策流程像 HFC 一样清晰可见,极大地方便了调试。
    -   将系统中多个模块(视频分析、兴趣度匹配、情绪管理)的常规日志级别从 `INFO` 调整为 `DEBUG`,以减少在生产环境中的日志噪音。

5.  **动作描述优化**:
    -   优化了 `set_emoji_like` 和 `emoji` 等动作的描述,使其意图更清晰,帮助 LLM 做出更准确的动作选择。
This commit is contained in:
tt-P607
2025-09-24 01:41:04 +08:00
parent 9135bd72e4
commit 1b8876c4bb
13 changed files with 163 additions and 101 deletions

View File

@@ -25,7 +25,7 @@
{
"type": "action",
"name": "set_emoji_like",
"description": "为消息设置表情回应"
"description": "为某条已经存在的消息添加‘贴表情回应(类似点赞),而不是发送新消息。当用户明确要求‘贴表情’时使用。"
}
],
"features": [

View File

@@ -46,7 +46,7 @@ class SetEmojiLikeAction(BaseAction):
# === 基本信息(必须填写)===
action_name = "set_emoji_like"
action_description = "一个已存在的消息添加点赞或表情回应(也叫‘贴表情’)"
action_description = "某条已经存在的消息添加‘贴表情回应(类似点赞),而不是发送新消息。可以在觉得某条消息非常有趣、值得赞同或者需要特殊情感回应时主动使用。"
activation_type = ActionActivationType.ALWAYS # 消息接收时激活(?)
chat_type_allow = ChatType.GROUP
parallel_action = True

View File

@@ -464,7 +464,7 @@ class BotInterestManager:
low_similarity_count += 1
result.add_match(tag.tag_name, enhanced_score, [tag.tag_name])
logger.info(
logger.debug(
f"匹配统计: {match_count}/{len(active_tags)} 个标签命中 | "
f"高(>{high_threshold}): {high_similarity_count}, "
f"中(>{medium_threshold}): {medium_similarity_count}, "
@@ -492,9 +492,9 @@ class BotInterestManager:
if result.matched_tags:
top_tag_name = max(result.match_scores.items(), key=lambda x: x[1])[0]
result.top_tag = top_tag_name
logger.info(f"最佳匹配: '{top_tag_name}' (分数: {result.match_scores[top_tag_name]:.3f})")
logger.debug(f"最佳匹配: '{top_tag_name}' (分数: {result.match_scores[top_tag_name]:.3f})")
logger.info(
logger.debug(
f"最终结果: 总分={result.overall_score:.3f}, 置信度={result.confidence:.3f}, 匹配标签数={len(result.matched_tags)}"
)
return result

View File

@@ -78,7 +78,7 @@ class VideoAnalyzer:
self.video_llm = LLMRequest(
model_set=model_config.model_task_config.video_analysis, request_type="video_analysis"
)
logger.info("✅ 使用video_analysis模型配置")
logger.debug("✅ 使用video_analysis模型配置")
except (AttributeError, KeyError) as e:
# 如果video_analysis不存在使用vlm配置
self.video_llm = LLMRequest(model_set=model_config.model_task_config.vlm, request_type="vlm")
@@ -155,14 +155,14 @@ class VideoAnalyzer:
self.timeout = 60.0 # 分析超时时间(秒)
if config:
logger.info("✅ 从配置文件读取视频分析参数")
logger.debug("✅ 从配置文件读取视频分析参数")
else:
logger.warning("配置文件中缺少video_analysis配置使用默认值")
# 系统提示词
self.system_prompt = "你是一个专业的视频内容分析助手。请仔细观察用户提供的视频关键帧,详细描述视频内容。"
logger.info(f"✅ 视频分析器初始化完成,分析模式: {self.analysis_mode}, 线程池: {self.use_multiprocessing}")
logger.debug(f"✅ 视频分析器初始化完成,分析模式: {self.analysis_mode}, 线程池: {self.use_multiprocessing}")
# 获取Rust模块系统信息
self._log_system_info()
@@ -175,7 +175,7 @@ class VideoAnalyzer:
try:
system_info = rust_video.get_system_info()
logger.info(f"🔧 系统信息: 线程数={system_info.get('threads', '未知')}")
logger.debug(f"🔧 系统信息: 线程数={system_info.get('threads', '未知')}")
# 记录CPU特性
features = []
@@ -187,11 +187,11 @@ class VideoAnalyzer:
features.append("SIMD")
if features:
logger.info(f"🚀 CPU特性: {', '.join(features)}")
logger.debug(f"🚀 CPU特性: {', '.join(features)}")
else:
logger.info("⚠️ 未检测到SIMD支持")
logger.debug("⚠️ 未检测到SIMD支持")
logger.info(f"📦 Rust模块版本: {system_info.get('version', '未知')}")
logger.debug(f"📦 Rust模块版本: {system_info.get('version', '未知')}")
except Exception as e:
logger.warning(f"获取系统信息失败: {e}")

View File

@@ -140,9 +140,9 @@ class ChatMood:
prompt=prompt, temperature=0.7
)
if global_config.debug.show_prompt:
logger.info(f"{self.log_prefix} prompt: {prompt}")
logger.info(f"{self.log_prefix} response: {response}")
logger.info(f"{self.log_prefix} reasoning_content: {reasoning_content}")
logger.debug(f"{self.log_prefix} prompt: {prompt}")
logger.debug(f"{self.log_prefix} response: {response}")
logger.debug(f"{self.log_prefix} reasoning_content: {reasoning_content}")
logger.info(f"{self.log_prefix} 情绪状态更新为: {response}")
@@ -190,9 +190,9 @@ class ChatMood:
)
if global_config.debug.show_prompt:
logger.info(f"{self.log_prefix} prompt: {prompt}")
logger.info(f"{self.log_prefix} response: {response}")
logger.info(f"{self.log_prefix} reasoning_content: {reasoning_content}")
logger.debug(f"{self.log_prefix} prompt: {prompt}")
logger.debug(f"{self.log_prefix} response: {response}")
logger.debug(f"{self.log_prefix} reasoning_content: {reasoning_content}")
logger.info(f"{self.log_prefix} 情绪状态转变为: {response}")

View File

@@ -17,6 +17,10 @@ from src.common.logger import get_logger
logger = get_logger("affinity_chatter")
# 定义颜色
SOFT_GREEN = "\033[38;5;118m" # 一个更柔和的绿色
RESET_COLOR = "\033[0m"
class AffinityChatter(BaseChatter):
"""亲和力聊天处理器"""
@@ -60,6 +64,10 @@ class AffinityChatter(BaseChatter):
try:
unread_messages = context.get_unread_messages()
# 像hfc一样打印收到的消息
for msg in unread_messages:
logger.info(f"{SOFT_GREEN}[所见] {msg.user_info.user_nickname}:{msg.processed_plain_text}{RESET_COLOR}")
# 使用增强版规划器处理消息
actions, target_message = await self.planner.plan(context=context)
self.stats["plans_created"] += 1

View File

@@ -15,6 +15,10 @@ from src.config.config import global_config
logger = get_logger("chatter_interest_scoring")
# 定义颜色
SOFT_BLUE = "\033[38;5;67m"
RESET_COLOR = "\033[0m"
class ChatterInterestScoringSystem:
"""兴趣度评分系统"""
@@ -80,8 +84,8 @@ class ChatterInterestScoringSystem:
"mentioned": f"提及: {mentioned_score:.3f}",
}
logger.info(
f"消息得分: {total_score:.3f} (匹配: {interest_match_score:.2f}, 关系: {relationship_score:.2f}, 提及: {mentioned_score:.2f})"
logger.debug(
f"消息得分详情: {total_score:.3f} (匹配: {interest_match_score:.2f}, 关系: {relationship_score:.2f}, 提及: {mentioned_score:.2f})"
)
return InterestScore(
@@ -248,7 +252,9 @@ class ChatterInterestScoringSystem:
# 做出决策
should_reply = score.total_score >= effective_threshold
decision = "回复" if should_reply else "不回复"
logger.info(f"决策: {decision} (分数: {score.total_score:.3f})")
logger.info(
f"{SOFT_BLUE}决策: {decision} (兴趣度: {score.total_score:.3f} / 阈值: {effective_threshold:.3f}){RESET_COLOR}"
)
return should_reply, score.total_score
@@ -264,7 +270,7 @@ class ChatterInterestScoringSystem:
# 限制最大计数
self.no_reply_count = min(self.no_reply_count, self.max_no_reply_count)
logger.info(f"{action} | 不回复次数: {old_count} -> {self.no_reply_count}")
logger.info(f"动作: {action}, 连续不回复次数: {old_count} -> {self.no_reply_count}")
def update_user_relationship(self, user_id: str, relationship_change: float):
"""更新用户关系"""

View File

@@ -66,6 +66,10 @@ class ChatterPlanExecutor:
logger.info("没有需要执行的动作。")
return {"executed_count": 0, "results": []}
# 像hfc一样提前打印将要执行的动作
action_types = [action.action_type for action in plan.decided_actions]
logger.info(f"选择动作: {', '.join(action_types) if action_types else ''}")
execution_results = []
reply_actions = []
other_actions = []

View File

@@ -27,13 +27,26 @@ from src.schedule.schedule_manager import schedule_manager
logger = get_logger("plan_filter")
SAKURA_PINK = "\033[38;5;175m"
SKY_BLUE = "\033[38;5;117m"
RESET_COLOR = "\033[0m"
class ChatterPlanFilter:
"""
根据 Plan 中的模式和信息,筛选并决定最终的动作。
"""
def __init__(self):
def __init__(self, chat_id: str, available_actions: List[str]):
"""
初始化动作计划筛选器。
Args:
chat_id (str): 当前聊天的唯一标识符。
available_actions (List[str]): 当前可用的动作列表。
"""
self.chat_id = chat_id
self.available_actions = available_actions
self.planner_llm = LLMRequest(model_set=model_config.model_task_config.planner, request_type="planner")
self.last_obs_time_mark = 0.0
@@ -110,13 +123,17 @@ class ChatterPlanFilter:
final_actions.extend(await self._parse_single_action(item, used_message_id_list, plan))
if thinking and thinking != "未提供思考过程":
logger.info(f"思考: {thinking}")
logger.info(f"\n{SAKURA_PINK}思考: {thinking}{RESET_COLOR}\n")
plan.decided_actions = self._filter_no_actions(final_actions)
except Exception as e:
logger.error(f"筛选 Plan 时出错: {e}\n{traceback.format_exc()}")
plan.decided_actions = [ActionPlannerInfo(action_type="no_action", reasoning=f"筛选时出错: {e}")]
# 在返回最终计划前,打印将要执行的动作
action_types = [action.action_type for action in plan.decided_actions]
logger.info(f"选择动作: [{SKY_BLUE}{', '.join(action_types) if action_types else ''}{RESET_COLOR}]")
return plan
async def _build_prompt(self, plan: Plan) -> tuple[str, list]:
@@ -279,12 +296,24 @@ class ChatterPlanFilter:
# 从message_manager获取真实的已读/未读消息
from src.chat.message_manager.message_manager import message_manager
from src.chat.utils.utils import assign_message_ids
from src.chat.utils.chat_message_builder import get_raw_msg_before_timestamp_with_chat
# 获取聊天流的上下文
stream_context = message_manager.stream_contexts.get(plan.chat_id)
# 获取真正的已读和未读消息
read_messages = stream_context.history_messages # 已读消息存储在history_messages中
if not read_messages:
from src.common.data_models.database_data_model import DatabaseMessages
# 如果内存中没有已读消息(比如刚启动),则从数据库加载最近的上下文
fallback_messages_dicts = get_raw_msg_before_timestamp_with_chat(
chat_id=plan.chat_id,
timestamp=time.time(),
limit=global_config.chat.max_context_size,
)
# 将字典转换为DatabaseMessages对象
read_messages = [DatabaseMessages(**msg_dict) for msg_dict in fallback_messages_dicts]
unread_messages = stream_context.get_unread_messages() # 获取未读消息
# 构建已读历史消息块
@@ -316,14 +345,12 @@ class ChatterPlanFilter:
synthetic_id = mapped.get("id")
original_msg_id = msg.get("message_id") or msg.get("id")
msg_time = time.strftime("%H:%M:%S", time.localtime(msg.get("time", time.time())))
user_nickname = msg.get("user_nickname", "未知用户")
msg_content = msg.get("processed_plain_text", "")
# 添加兴趣度信息
interest_score = interest_scores.get(original_msg_id, 0.0)
interest_text = f" [兴趣度: {interest_score:.3f}]" if interest_score > 0 else ""
# 在未读行中显示合成id方便 planner 返回时使用
unread_lines.append(f"{msg_time} {synthetic_id}: {msg_content}{interest_text}")
# 不再显示兴趣度但保留合成ID供模型内部使用
# 同时,为了让模型更好地理解上下文,我们显示用户名
unread_lines.append(f"<{synthetic_id}> {msg_time} {user_nickname}: {msg_content}")
unread_history_block = "\n".join(unread_lines)
else:
@@ -389,26 +416,22 @@ class ChatterPlanFilter:
actions_obj = action_json.get("actions", {})
# 处理actions字段可能是字典或列表的情况
actions_to_process = []
if isinstance(actions_obj, dict):
action = actions_obj.get("action_type", "no_action")
reasoning = actions_obj.get("reason", "未提供原因")
# 合并actions_obj中的其他字段作为action_data
action_data = {k: v for k, v in actions_obj.items() if k not in ["action_type", "reason"]}
elif isinstance(actions_obj, list) and actions_obj:
# 如果是列表,取第一个元素
first_action = actions_obj[0]
if isinstance(first_action, dict):
action = first_action.get("action_type", "no_action")
reasoning = first_action.get("reason", "未提供原因")
action_data = {k: v for k, v in first_action.items() if k not in ["action_type", "reason"]}
else:
action = "no_action"
reasoning = "actions格式错误"
action_data = {}
else:
action = "no_action"
reasoning = "actions格式错误"
action_data = {}
actions_to_process.append(actions_obj)
elif isinstance(actions_obj, list):
actions_to_process.extend(actions_obj)
if not actions_to_process:
actions_to_process.append({"action_type": "no_action", "reason": "actions格式错误"})
for single_action_obj in actions_to_process:
if not isinstance(single_action_obj, dict):
continue
action = single_action_obj.get("action_type", "no_action")
reasoning = single_action_obj.get("reason", "未提供原因")
action_data = {k: v for k, v in single_action_obj.items() if k not in ["action_type", "reason"]}
# 保留原始的thinking字段如果有
thinking = action_json.get("thinking")
@@ -441,10 +464,9 @@ class ChatterPlanFilter:
action = "no_action"
reasoning = f"找不到目标消息进行回复。原始理由: {reasoning}"
available_action_names = list(plan.available_actions.keys())
if (
action not in ["no_action", "no_reply", "reply", "do_nothing", "proactive_reply"]
and action not in available_action_names
and action not in self.available_actions
):
reasoning = f"LLM 返回了当前不可用的动作 '{action}'。原始理由: {reasoning}"
action = "no_action"

View File

@@ -50,7 +50,6 @@ class ChatterActionPlanner:
self.chat_id = chat_id
self.action_manager = action_manager
self.generator = ChatterPlanGenerator(chat_id)
self.filter = ChatterPlanFilter()
self.executor = ChatterPlanExecutor(action_manager)
# 初始化兴趣度评分系统
@@ -96,9 +95,17 @@ class ChatterActionPlanner:
async def _enhanced_plan_flow(self, context: "StreamContext") -> Tuple[List[Dict], Optional[Dict]]:
"""执行增强版规划流程"""
try:
# 在规划前,先进行动作修改
from src.chat.planner_actions.action_modifier import ActionModifier
action_modifier = ActionModifier(self.action_manager, self.chat_id)
await action_modifier.modify_actions()
# 1. 生成初始 Plan
initial_plan = await self.generator.generate(context.chat_mode)
# 确保Plan中包含所有当前可用的动作
initial_plan.available_actions = self.action_manager.get_using_actions()
unread_messages = context.get_unread_messages() if context else []
# 2. 兴趣度评分 - 只对未读消息进行评分
if unread_messages:
@@ -142,7 +149,9 @@ class ChatterActionPlanner:
filtered_plan.decided_actions = [no_action]
else:
# 4. 筛选 Plan
filtered_plan = await self.filter.filter(reply_not_available, initial_plan)
available_actions = list(initial_plan.available_actions.keys())
plan_filter = ChatterPlanFilter(self.chat_id, available_actions)
filtered_plan = await plan_filter.filter(reply_not_available, initial_plan)
# 检查filtered_plan是否有reply动作以便记录reply action
has_reply_action = False

View File

@@ -56,9 +56,19 @@ def init_prompts():
**输出格式:**
请严格按照以下 JSON 格式输出,包含 `thinking` 和 `actions` 字段:
**重要概念:将“内心思考”作为思绪流的体现**
`thinking` 字段是本次决策的核心。它并非一个简单的“理由”,而是 **一个模拟人类在回应前,头脑中自然浮现的、未经修饰的思绪流**。你需要完全代入 {identity_block} 的角色,将那一刻的想法自然地记录下来。
**内心思考的要点:**
* **自然流露**: 不要使用“决定”、“所以”、“因此”等结论性或汇报式的词语。你的思考应该像日记一样,是给自己看的,充满了不确定性和情绪的自然流动。
* **展现过程**: 重点在于展现 **思考的过程**,而不是 **决策的结果**。描述你看到了什么,想到了什么,感受到了什么。
* **使用昵称**: 在你的思绪流中,请直接使用用户的昵称来指代他们,而不是`<m1>`, `<m2>`这样的消息ID。
* **严禁技术术语**: 严禁在思考中提及任何数字化的度量(如兴趣度、分数)或内部技术术语。请完全使用角色自身的感受和语言来描述思考过程。
```json
{{
"thinking": "你的内心思考,简要描述你选择动作时的心路历程",
"thinking": "在这里写下你的思绪流...",
"actions": [
{{
"action_type": "动作类型reply, emoji等",
@@ -72,6 +82,9 @@ def init_prompts():
}}
```
**强制规则**:
- 对于每一个需要目标消息的动作(如`reply`, `poke_user`, `set_emoji_like`),你 **必须** 在`action_data`中提供准确的`target_message_id`这个ID来源于`## 未读历史消息`中消息前的`<m...>`标签。
如果没有合适的回复对象或不需要回复,输出空的 actions 数组:
```json
{{

View File

@@ -27,7 +27,7 @@
{
"type": "action",
"name": "emoji",
"description": "发送表情包辅助表达情绪"
"description": "作为一条全新的消息,发送一个符合当前情景的表情包来生动地表达情绪"
}
]
}

View File

@@ -33,7 +33,7 @@ class EmojiAction(BaseAction):
# 动作基本信息
action_name = "emoji"
action_description = "发送表情包辅助表达情绪"
action_description = "作为一条全新的消息,发送一个符合当前情景的表情包来生动地表达情绪"
# LLM判断提示词
llm_judge_prompt = """