diff --git a/src/bw_learner/expression_learner.py b/src/bw_learner/expression_learner.py index 38fcb370..759f4e0e 100644 --- a/src/bw_learner/expression_learner.py +++ b/src/bw_learner/expression_learner.py @@ -3,7 +3,7 @@ import json import os import re import asyncio -from typing import List, Optional, Tuple, Any +from typing import List, Optional, Tuple, Any, Dict from src.common.logger import get_logger from src.common.database.database_model import Expression from src.llm_models.utils_model import LLMRequest @@ -13,7 +13,8 @@ from src.chat.utils.chat_message_builder import ( ) from src.chat.utils.prompt_builder import Prompt, global_prompt_manager from src.chat.message_receive.chat_stream import get_chat_manager -from src.bw_learner.learner_utils import filter_message_content, is_bot_message +from src.bw_learner.learner_utils import filter_message_content, is_bot_message, build_context_paragraph, contains_bot_self_name +from src.bw_learner.jargon_miner import miner_manager from json_repair import repair_json @@ -24,7 +25,8 @@ logger = get_logger("expressor") def init_prompt() -> None: learn_style_prompt = """{chat_str} -你的名字是{bot_name},现在请你请从上面这段群聊中用户的语言风格和说话方式 +你的名字是{bot_name},现在请你完成两个提取任务 +任务1:请从上面这段群聊中用户的语言风格和说话方式 1. 只考虑文字,不要考虑表情包和图片 2. 不要总结SELF的发言 3. 不要涉及具体的人名,也不要涉及具体名词 @@ -33,19 +35,39 @@ def init_prompt() -> None: 注意:总结成如下格式的规律,总结的内容要详细,但具有概括性: 例如:当"AAAAA"时,可以"BBBBB", AAAAA代表某个场景,不超过20个字。BBBBB代表对应的语言风格,特定句式或表达方式,不超过20个字。 -请严格以 JSON 数组的形式输出结果,每个元素为一个对象,结构如下(注意字段名): +任务2:请从上面这段聊天内容中提取"可能是黑话"的候选项(黑话/俚语/网络缩写/口头禅)。 +- 必须为对话中真实出现过的短词或短语 +- 必须是你无法理解含义的词语,没有明确含义的词语,请不要选择有明确含义,或者含义清晰的词语 +- 排除:人名、@、表情包/图片中的内容、纯标点、常规功能词(如的、了、呢、啊等) +- 每个词条长度建议 2-8 个字符(不强制),尽量短小 +- 请你提取出可能的黑话,最多30个黑话,请尽量提取所有 + +黑话必须为以下几种类型: +- 由字母构成的,汉语拼音首字母的简写词,例如:nb、yyds、xswl +- 英文词语的缩写,用英文字母概括一个词汇或含义,例如:CPU、GPU、API +- 中文词语的缩写,用几个汉字概括一个词汇或含义,例如:社死、内卷 + +输出要求: +将表达方式,语言风格和黑话以 JSON 数组输出,每个元素为一个对象,结构如下(注意字段名): + [ {{"situation": "AAAAA", "style": "BBBBB", "source_id": "3"}}, {{"situation": "CCCC", "style": "DDDD", "source_id": "7"}} {{"situation": "对某件事表示十分惊叹", "style": "使用 我嘞个xxxx", "source_id": "[消息编号]"}}, {{"situation": "表示讽刺的赞同,不讲道理", "style": "对对对", "source_id": "[消息编号]"}}, {{"situation": "当涉及游戏相关时,夸赞,略带戏谑意味", "style": "使用 这么强!", "source_id": "[消息编号]"}}, + {{"content": "词条", "source_id": "12"}}, + {{"content": "词条2", "source_id": "5"}} ] 其中: +表达方式条目: - situation:表示“在什么情境下”的简短概括(不超过20个字) - style:表示对应的语言风格或常用表达(不超过20个字) - source_id:该表达方式对应的“来源行编号”,即上方聊天记录中方括号里的数字(例如 [3]),请只输出数字本身,不要包含方括号 +黑话jargon条目: +- content:表示黑话的内容 +- source_id:该黑话对应的“来源行编号”,即上方聊天记录中方括号里的数字(例如 [3]),请只输出数字本身,不要包含方括号 现在请你输出 JSON: """ @@ -104,13 +126,25 @@ class ExpressionLearner: logger.error(f"学习表达方式失败,模型生成出错: {e}") return None - # 解析 LLM 返回的表达方式列表(包含来源行编号) - expressions: List[Tuple[str, str, str]] = self.parse_expression_response(response) + # 解析 LLM 返回的表达方式列表和黑话列表(包含来源行编号) + expressions: List[Tuple[str, str, str]] + jargon_entries: List[Tuple[str, str]] # (content, source_id) + expressions, jargon_entries = self.parse_expression_response(response) expressions = self._filter_self_reference_styles(expressions) + + # 处理黑话条目,路由到 jargon_miner(即使没有表达方式也要处理黑话) + if jargon_entries: + await self._process_jargon_entries(jargon_entries, random_msg) + + # 如果没有表达方式,直接返回 if not expressions: logger.info("过滤后没有可用的表达方式(style 与机器人名称重复)") - return None - # logger.debug(f"学习{type_str}的response: {response}") + return [] + + logger.info(f"学习的prompt: {prompt}") + logger.info(f"学习的expressions: {expressions}") + logger.info(f"学习的jargon_entries: {jargon_entries}") + logger.info(f"学习的response: {response}") # 直接根据 source_id 在 random_msg 中溯源,获取 context filtered_expressions: List[Tuple[str, str, str]] = [] # (situation, style, context) @@ -173,18 +207,24 @@ class ExpressionLearner: return learnt_expressions - def parse_expression_response(self, response: str) -> List[Tuple[str, str, str]]: + def parse_expression_response(self, response: str) -> Tuple[List[Tuple[str, str, str]], List[Tuple[str, str]]]: """ - 解析 LLM 返回的表达风格总结 JSON,提取 (situation, style, source_id) 元组列表。 + 解析 LLM 返回的表达风格总结和黑话 JSON,提取两个列表。 期望的 JSON 结构: [ - {"situation": "AAAAA", "style": "BBBBB", "source_id": "3"}, + {"situation": "AAAAA", "style": "BBBBB", "source_id": "3"}, // 表达方式 + {"content": "词条", "source_id": "12"}, // 黑话 ... ] + + Returns: + Tuple[List[Tuple[str, str, str]], List[Tuple[str, str]]]: + 第一个列表是表达方式 (situation, style, source_id) + 第二个列表是黑话 (content, source_id) """ if not response: - return [] + return [], [] raw = response.strip() @@ -200,7 +240,8 @@ class ExpressionLearner: raw = raw.strip() parsed = None - expressions: List[Tuple[str, str, str]] = [] + expressions: List[Tuple[str, str, str]] = [] # (situation, style, source_id) + jargon_entries: List[Tuple[str, str]] = [] # (content, source_id) try: # 优先尝试直接解析 @@ -292,15 +333,23 @@ class ExpressionLearner: for item in parsed_list: if not isinstance(item, dict): continue + + # 检查是否是表达方式条目(有 situation 和 style) situation = str(item.get("situation", "")).strip() style = str(item.get("style", "")).strip() source_id = str(item.get("source_id", "")).strip() - if not situation or not style or not source_id: - # 三个字段必须同时存在 - continue - expressions.append((situation, style, source_id)) + + if situation and style and source_id: + # 表达方式条目 + expressions.append((situation, style, source_id)) + elif item.get("content"): + # 黑话条目(有 content 字段) + content = str(item.get("content", "")).strip() + source_id = str(item.get("source_id", "")).strip() + if content and source_id: + jargon_entries.append((content, source_id)) - return expressions + return expressions, jargon_entries def _filter_self_reference_styles(self, expressions: List[Tuple[str, str, str]]) -> List[Tuple[str, str, str]]: """ @@ -438,6 +487,66 @@ class ExpressionLearner: logger.error(f"概括表达情境失败: {e}") return None + async def _process_jargon_entries(self, jargon_entries: List[Tuple[str, str]], messages: List[Any]) -> None: + """ + 处理从 expression learner 提取的黑话条目,路由到 jargon_miner + + Args: + jargon_entries: 黑话条目列表,每个元素是 (content, source_id) + messages: 消息列表,用于构建上下文 + """ + if not jargon_entries or not messages: + return + + # 获取 jargon_miner 实例 + jargon_miner = miner_manager.get_miner(self.chat_id) + + # 构建黑话条目格式,与 jargon_miner.run_once 中的格式一致 + entries: List[Dict[str, List[str]]] = [] + + for content, source_id in jargon_entries: + content = content.strip() + if not content: + continue + + # 检查是否包含机器人名称 + if contains_bot_self_name(content): + logger.info(f"跳过包含机器人昵称/别名的黑话: {content}") + continue + + # 解析 source_id + source_id_str = (source_id or "").strip() + if not source_id_str.isdigit(): + logger.warning(f"黑话条目 source_id 无效: content={content}, source_id={source_id_str}") + continue + + # build_anonymous_messages 的编号从 1 开始 + line_index = int(source_id_str) - 1 + if line_index < 0 or line_index >= len(messages): + logger.warning(f"黑话条目 source_id 超出范围: content={content}, source_id={source_id_str}") + continue + + # 检查是否是机器人自己的消息 + target_msg = messages[line_index] + if is_bot_message(target_msg): + logger.info(f"跳过引用机器人自身消息的黑话: content={content}, source_id={source_id_str}") + continue + + # 构建上下文段落 + context_paragraph = build_context_paragraph(messages, line_index) + if not context_paragraph: + logger.warning(f"黑话条目上下文为空: content={content}, source_id={source_id_str}") + continue + + entries.append({"content": content, "raw_content": [context_paragraph]}) + + if not entries: + return + + # 调用 jargon_miner 处理这些条目 + await jargon_miner.process_extracted_entries(entries) + + init_prompt() diff --git a/src/bw_learner/expression_selector.py b/src/bw_learner/expression_selector.py index 4bbca2b8..d8a59779 100644 --- a/src/bw_learner/expression_selector.py +++ b/src/bw_learner/expression_selector.py @@ -262,7 +262,8 @@ class ExpressionSelector: # 4. 调用LLM content, (reasoning_content, model_name, _) = await self.llm_model.generate_response_async(prompt=prompt) - # print(prompt) + print(prompt) + print(content) if not content: logger.warning("LLM返回空结果") diff --git a/src/bw_learner/jargon_miner.py b/src/bw_learner/jargon_miner.py index 2e456122..275d9cbd 100644 --- a/src/bw_learner/jargon_miner.py +++ b/src/bw_learner/jargon_miner.py @@ -723,6 +723,145 @@ class JargonMiner: logger.error(f"JargonMiner 运行失败: {e}") # 即使失败也保持时间戳更新,避免频繁重试 + async def process_extracted_entries(self, entries: List[Dict[str, List[str]]]) -> None: + """ + 处理已提取的黑话条目(从 expression_learner 路由过来的) + + Args: + entries: 黑话条目列表,每个元素格式为 {"content": "...", "raw_content": [...]} + """ + if not entries: + return + + try: + # 去重并合并raw_content(按 content 聚合) + merged_entries: OrderedDict[str, Dict[str, List[str]]] = OrderedDict() + for entry in entries: + content_key = entry["content"] + raw_list = entry.get("raw_content", []) or [] + if content_key in merged_entries: + merged_entries[content_key]["raw_content"].extend(raw_list) + else: + merged_entries[content_key] = { + "content": content_key, + "raw_content": list(raw_list), + } + + uniq_entries = [] + for merged_entry in merged_entries.values(): + raw_content_list = merged_entry["raw_content"] + if raw_content_list: + merged_entry["raw_content"] = list(dict.fromkeys(raw_content_list)) + uniq_entries.append(merged_entry) + + saved = 0 + updated = 0 + for entry in uniq_entries: + content = entry["content"] + raw_content_list = entry["raw_content"] # 已经是列表 + + try: + # 查询所有content匹配的记录 + query = Jargon.select().where(Jargon.content == content) + + # 查找匹配的记录 + matched_obj = None + for obj in query: + if global_config.expression.all_global_jargon: + # 开启all_global:所有content匹配的记录都可以 + matched_obj = obj + break + else: + # 关闭all_global:需要检查chat_id列表是否包含目标chat_id + chat_id_list = parse_chat_id_list(obj.chat_id) + if chat_id_list_contains(chat_id_list, self.chat_id): + matched_obj = obj + break + + if matched_obj: + obj = matched_obj + try: + obj.count = (obj.count or 0) + 1 + except Exception: + obj.count = 1 + + # 合并raw_content列表:读取现有列表,追加新值,去重 + existing_raw_content = [] + if obj.raw_content: + try: + existing_raw_content = ( + json.loads(obj.raw_content) if isinstance(obj.raw_content, str) else obj.raw_content + ) + if not isinstance(existing_raw_content, list): + existing_raw_content = [existing_raw_content] if existing_raw_content else [] + except (json.JSONDecodeError, TypeError): + existing_raw_content = [obj.raw_content] if obj.raw_content else [] + + # 合并并去重 + merged_list = list(dict.fromkeys(existing_raw_content + raw_content_list)) + obj.raw_content = json.dumps(merged_list, ensure_ascii=False) + + # 更新chat_id列表:增加当前chat_id的计数 + chat_id_list = parse_chat_id_list(obj.chat_id) + updated_chat_id_list = update_chat_id_list(chat_id_list, self.chat_id, increment=1) + obj.chat_id = json.dumps(updated_chat_id_list, ensure_ascii=False) + + # 开启all_global时,确保记录标记为is_global=True + if global_config.expression.all_global_jargon: + obj.is_global = True + # 关闭all_global时,保持原有is_global不变(不修改) + + obj.save() + + # 检查是否需要推断(达到阈值且超过上次判定值) + if _should_infer_meaning(obj): + # 异步触发推断,不阻塞主流程 + # 重新加载对象以确保数据最新 + jargon_id = obj.id + asyncio.create_task(self._infer_meaning_by_id(jargon_id)) + + updated += 1 + else: + # 没找到匹配记录,创建新记录 + if global_config.expression.all_global_jargon: + # 开启all_global:新记录默认为is_global=True + is_global_new = True + else: + # 关闭all_global:新记录is_global=False + is_global_new = False + + # 使用新格式创建chat_id列表:[[chat_id, count]] + chat_id_list = [[self.chat_id, 1]] + chat_id_json = json.dumps(chat_id_list, ensure_ascii=False) + + Jargon.create( + content=content, + raw_content=json.dumps(raw_content_list, ensure_ascii=False), + chat_id=chat_id_json, + is_global=is_global_new, + count=1, + ) + saved += 1 + except Exception as e: + logger.error(f"保存jargon失败: chat_id={self.chat_id}, content={content}, err={e}") + continue + finally: + self._add_to_cache(content) + + # 固定输出提取的jargon结果,格式化为可读形式(只要有提取结果就输出) + if uniq_entries: + # 收集所有提取的jargon内容 + jargon_list = [entry["content"] for entry in uniq_entries] + jargon_str = ",".join(jargon_list) + + # 输出格式化的结果(使用logger.info会自动应用jargon模块的颜色) + logger.info(f"[{self.stream_name}]疑似黑话: {jargon_str}") + + if saved or updated: + logger.info(f"jargon写入: 新增 {saved} 条,更新 {updated} 条,chat_id={self.chat_id}") + except Exception as e: + logger.error(f"处理已提取的黑话条目失败: {e}") + class JargonMinerManager: def __init__(self) -> None: diff --git a/src/bw_learner/message_recorder.py b/src/bw_learner/message_recorder.py index ec310184..c49da951 100644 --- a/src/bw_learner/message_recorder.py +++ b/src/bw_learner/message_recorder.py @@ -126,10 +126,10 @@ class MessageRecorder: ) # 触发 jargon 提取(如果启用),传递消息 - if self.enable_jargon_learning: - asyncio.create_task( - self._trigger_jargon_extraction(extraction_start_time, extraction_end_time, messages) - ) + # if self.enable_jargon_learning: + # asyncio.create_task( + # self._trigger_jargon_extraction(extraction_start_time, extraction_end_time, messages) + # ) except Exception as e: logger.error(f"为聊天流 {self.chat_name} 提取和分发消息失败: {e}") diff --git a/src/chat/replyer/prompt/replyer_prompt.py b/src/chat/replyer/prompt/replyer_prompt.py index 4ed0a1be..7a67fca7 100644 --- a/src/chat/replyer/prompt/replyer_prompt.py +++ b/src/chat/replyer/prompt/replyer_prompt.py @@ -21,8 +21,7 @@ def init_replyer_prompt(): {chat_prompt}你正在群里聊天,现在请你读读之前的聊天记录,然后给出日常且口语化的回复,平淡一些, 尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,不要回复的太有条理。 {reply_style} -请注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出一句回复内容就好。 -不要输出多余内容(包括前后缀,冒号和引号,括号,表情包,at或 @等 )。 +请注意不要输出多余内容(包括不必要的前后缀,冒号,括号,表情包,at或 @等 ),只输出发言内容就好。 现在,你说:""", "replyer_prompt", )