mirror of https://github.com/Mai-with-u/MaiBot.git
985 lines
42 KiB
Python
985 lines
42 KiB
Python
import time
|
||
import json
|
||
import asyncio
|
||
import random
|
||
from collections import OrderedDict
|
||
from typing import List, Dict, Optional, Any, Callable
|
||
from json_repair import repair_json
|
||
from peewee import fn
|
||
|
||
from src.common.logger import get_logger
|
||
from src.common.database.database_model import Jargon
|
||
from src.llm_models.utils_model import LLMRequest
|
||
from src.config.config import model_config, global_config
|
||
from src.chat.message_receive.chat_stream import get_chat_manager
|
||
from src.chat.utils.chat_message_builder import (
|
||
build_readable_messages_with_id,
|
||
get_raw_msg_by_timestamp_with_chat_inclusive,
|
||
)
|
||
from src.chat.utils.prompt_builder import Prompt, global_prompt_manager
|
||
from src.bw_learner.learner_utils import (
|
||
is_bot_message,
|
||
build_context_paragraph,
|
||
contains_bot_self_name,
|
||
parse_chat_id_list,
|
||
chat_id_list_contains,
|
||
update_chat_id_list,
|
||
)
|
||
|
||
|
||
logger = get_logger("jargon")
|
||
|
||
|
||
def _is_single_char_jargon(content: str) -> bool:
|
||
"""
|
||
判断是否是单字黑话(单个汉字、英文或数字)
|
||
|
||
Args:
|
||
content: 词条内容
|
||
|
||
Returns:
|
||
bool: 如果是单字黑话返回True,否则返回False
|
||
"""
|
||
if not content or len(content) != 1:
|
||
return False
|
||
|
||
char = content[0]
|
||
# 判断是否是单个汉字、单个英文字母或单个数字
|
||
return (
|
||
'\u4e00' <= char <= '\u9fff' or # 汉字
|
||
'a' <= char <= 'z' or # 小写字母
|
||
'A' <= char <= 'Z' or # 大写字母
|
||
'0' <= char <= '9' # 数字
|
||
)
|
||
|
||
|
||
def _init_prompt() -> None:
|
||
prompt_str = """
|
||
**聊天内容,其中的{bot_name}的发言内容是你自己的发言,[msg_id] 是消息ID**
|
||
{chat_str}
|
||
|
||
请从上面这段聊天内容中提取"可能是黑话"的候选项(黑话/俚语/网络缩写/口头禅)。
|
||
- 必须为对话中真实出现过的短词或短语
|
||
- 必须是你无法理解含义的词语,没有明确含义的词语,请不要选择有明确含义,或者含义清晰的词语
|
||
- 排除:人名、@、表情包/图片中的内容、纯标点、常规功能词(如的、了、呢、啊等)
|
||
- 每个词条长度建议 2-8 个字符(不强制),尽量短小
|
||
|
||
黑话必须为以下几种类型:
|
||
- 由字母构成的,汉语拼音首字母的简写词,例如:nb、yyds、xswl
|
||
- 英文词语的缩写,用英文字母概括一个词汇或含义,例如:CPU、GPU、API
|
||
- 中文词语的缩写,用几个汉字概括一个词汇或含义,例如:社死、内卷
|
||
|
||
以 JSON 数组输出,元素为对象(严格按以下结构):
|
||
请你提取出可能的黑话,最多30个黑话,请尽量提取所有
|
||
[
|
||
{{"content": "词条", "msg_id": "m12"}}, // msg_id 必须与上方聊天中展示的ID完全一致
|
||
{{"content": "词条2", "msg_id": "m15"}}
|
||
]
|
||
|
||
现在请输出:
|
||
"""
|
||
Prompt(prompt_str, "extract_jargon_prompt")
|
||
|
||
|
||
def _init_inference_prompts() -> None:
|
||
"""初始化含义推断相关的prompt"""
|
||
# Prompt 1: 基于raw_content和content推断
|
||
prompt1_str = """
|
||
**词条内容**
|
||
{content}
|
||
**词条出现的上下文。其中的{bot_name}的发言内容是你自己的发言**
|
||
{raw_content_list}
|
||
{previous_meaning_section}
|
||
|
||
请根据上下文,推断"{content}"这个词条的含义。
|
||
- 如果这是一个黑话、俚语或网络用语,请推断其含义
|
||
- 如果含义明确(常规词汇),也请说明
|
||
- {bot_name} 的发言内容可能包含错误,请不要参考其发言内容
|
||
- 如果上下文信息不足,无法推断含义,请设置 no_info 为 true
|
||
{previous_meaning_instruction}
|
||
|
||
以 JSON 格式输出:
|
||
{{
|
||
"meaning": "详细含义说明(包含使用场景、来源、具体解释等)",
|
||
"no_info": false
|
||
}}
|
||
注意:如果信息不足无法推断,请设置 "no_info": true,此时 meaning 可以为空字符串
|
||
"""
|
||
Prompt(prompt1_str, "jargon_inference_with_context_prompt")
|
||
|
||
# Prompt 2: 仅基于content推断
|
||
prompt2_str = """
|
||
**词条内容**
|
||
{content}
|
||
|
||
请仅根据这个词条本身,推断其含义。
|
||
- 如果这是一个黑话、俚语或网络用语,请推断其含义
|
||
- 如果含义明确(常规词汇),也请说明
|
||
|
||
以 JSON 格式输出:
|
||
{{
|
||
"meaning": "详细含义说明(包含使用场景、来源、具体解释等)"
|
||
}}
|
||
"""
|
||
Prompt(prompt2_str, "jargon_inference_content_only_prompt")
|
||
|
||
# Prompt 3: 比较两个推断结果
|
||
prompt3_str = """
|
||
**推断结果1(基于上下文)**
|
||
{inference1}
|
||
|
||
**推断结果2(仅基于词条)**
|
||
{inference2}
|
||
|
||
请比较这两个推断结果,判断它们是否相同或类似。
|
||
- 如果两个推断结果的"含义"相同或类似,说明这个词条不是黑话(含义明确)
|
||
- 如果两个推断结果有差异,说明这个词条可能是黑话(需要上下文才能理解)
|
||
|
||
以 JSON 格式输出:
|
||
{{
|
||
"is_similar": true/false,
|
||
"reason": "判断理由"
|
||
}}
|
||
"""
|
||
Prompt(prompt3_str, "jargon_compare_inference_prompt")
|
||
|
||
|
||
_init_prompt()
|
||
_init_inference_prompts()
|
||
|
||
|
||
def _should_infer_meaning(jargon_obj: Jargon) -> bool:
|
||
"""
|
||
判断是否需要进行含义推断
|
||
在 count 达到 3,6, 10, 20, 40, 60, 100 时进行推断
|
||
并且count必须大于last_inference_count,避免重启后重复判定
|
||
如果is_complete为True,不再进行推断
|
||
"""
|
||
# 如果已完成所有推断,不再推断
|
||
if jargon_obj.is_complete:
|
||
return False
|
||
|
||
count = jargon_obj.count or 0
|
||
last_inference = jargon_obj.last_inference_count or 0
|
||
|
||
# 阈值列表:3,6, 10, 20, 40, 60, 100
|
||
thresholds = [2, 4, 8, 12, 24, 60, 100]
|
||
|
||
if count < thresholds[0]:
|
||
return False
|
||
|
||
# 如果count没有超过上次判定值,不需要判定
|
||
if count <= last_inference:
|
||
return False
|
||
|
||
# 找到第一个大于last_inference的阈值
|
||
next_threshold = None
|
||
for threshold in thresholds:
|
||
if threshold > last_inference:
|
||
next_threshold = threshold
|
||
break
|
||
|
||
# 如果没有找到下一个阈值,说明已经超过100,不应该再推断
|
||
if next_threshold is None:
|
||
return False
|
||
|
||
# 检查count是否达到或超过这个阈值
|
||
return count >= next_threshold
|
||
|
||
|
||
class JargonMiner:
|
||
def __init__(self, chat_id: str) -> None:
|
||
self.chat_id = chat_id
|
||
|
||
self.llm = LLMRequest(
|
||
model_set=model_config.model_task_config.utils,
|
||
request_type="jargon.extract",
|
||
)
|
||
|
||
self.llm_inference = LLMRequest(
|
||
model_set=model_config.model_task_config.utils,
|
||
request_type="jargon.inference",
|
||
)
|
||
|
||
# 初始化stream_name作为类属性,避免重复提取
|
||
chat_manager = get_chat_manager()
|
||
stream_name = chat_manager.get_stream_name(self.chat_id)
|
||
self.stream_name = stream_name if stream_name else self.chat_id
|
||
self.cache_limit = 50
|
||
self.cache: OrderedDict[str, None] = OrderedDict()
|
||
|
||
# 黑话提取锁,防止并发执行
|
||
self._extraction_lock = asyncio.Lock()
|
||
|
||
def _add_to_cache(self, content: str) -> None:
|
||
"""将提取到的黑话加入缓存,保持LRU语义"""
|
||
if not content:
|
||
return
|
||
|
||
key = content.strip()
|
||
if not key:
|
||
return
|
||
|
||
# 单字黑话(单个汉字、英文或数字)不记录到缓存
|
||
if _is_single_char_jargon(key):
|
||
return
|
||
|
||
if key in self.cache:
|
||
self.cache.move_to_end(key)
|
||
else:
|
||
self.cache[key] = None
|
||
if len(self.cache) > self.cache_limit:
|
||
self.cache.popitem(last=False)
|
||
|
||
def _collect_cached_entries(self, messages: List[Any]) -> List[Dict[str, List[str]]]:
|
||
"""检查缓存中的黑话是否出现在当前消息窗口,生成对应上下文"""
|
||
if not self.cache or not messages:
|
||
return []
|
||
|
||
cached_entries: List[Dict[str, List[str]]] = []
|
||
processed_pairs = set()
|
||
|
||
for idx, msg in enumerate(messages):
|
||
msg_text = (
|
||
getattr(msg, "display_message", None) or getattr(msg, "processed_plain_text", None) or ""
|
||
).strip()
|
||
if not msg_text or is_bot_message(msg):
|
||
continue
|
||
|
||
for content in self.cache.keys():
|
||
if not content:
|
||
continue
|
||
if (content, idx) in processed_pairs:
|
||
continue
|
||
if content in msg_text:
|
||
paragraph = build_context_paragraph(messages, idx)
|
||
if not paragraph:
|
||
continue
|
||
cached_entries.append({"content": content, "raw_content": [paragraph]})
|
||
processed_pairs.add((content, idx))
|
||
|
||
return cached_entries
|
||
|
||
async def _infer_meaning_by_id(self, jargon_id: int) -> None:
|
||
"""通过ID加载对象并推断"""
|
||
try:
|
||
jargon_obj = Jargon.get_by_id(jargon_id)
|
||
# 再次检查is_complete,因为可能在异步任务执行时已被标记为完成
|
||
if jargon_obj.is_complete:
|
||
logger.debug(f"jargon {jargon_obj.content} 已完成所有推断,跳过")
|
||
return
|
||
await self.infer_meaning(jargon_obj)
|
||
except Exception as e:
|
||
logger.error(f"通过ID推断jargon失败: {e}")
|
||
|
||
async def infer_meaning(self, jargon_obj: Jargon) -> None:
|
||
"""
|
||
对jargon进行含义推断
|
||
"""
|
||
try:
|
||
content = jargon_obj.content
|
||
raw_content_str = jargon_obj.raw_content or ""
|
||
|
||
# 解析raw_content列表
|
||
raw_content_list = []
|
||
if raw_content_str:
|
||
try:
|
||
raw_content_list = (
|
||
json.loads(raw_content_str) if isinstance(raw_content_str, str) else raw_content_str
|
||
)
|
||
if not isinstance(raw_content_list, list):
|
||
raw_content_list = [raw_content_list] if raw_content_list else []
|
||
except (json.JSONDecodeError, TypeError):
|
||
raw_content_list = [raw_content_str] if raw_content_str else []
|
||
|
||
if not raw_content_list:
|
||
logger.warning(f"jargon {content} 没有raw_content,跳过推断")
|
||
return
|
||
|
||
# 获取当前count和上一次的meaning
|
||
current_count = jargon_obj.count or 0
|
||
previous_meaning = jargon_obj.meaning or ""
|
||
|
||
# 当count为24, 60时,随机移除一半的raw_content项目
|
||
if current_count in [24, 60] and len(raw_content_list) > 1:
|
||
# 计算要保留的数量(至少保留1个)
|
||
keep_count = max(1, len(raw_content_list) // 2)
|
||
raw_content_list = random.sample(raw_content_list, keep_count)
|
||
logger.info(f"jargon {content} count={current_count},随机移除后剩余 {len(raw_content_list)} 个raw_content项目")
|
||
|
||
# 步骤1: 基于raw_content和content推断
|
||
raw_content_text = "\n".join(raw_content_list)
|
||
|
||
# 当count为24, 60, 100时,在prompt中放入上一次推断出的meaning作为参考
|
||
previous_meaning_section = ""
|
||
previous_meaning_instruction = ""
|
||
if current_count in [24, 60, 100] and previous_meaning:
|
||
previous_meaning_section = f"""
|
||
**上一次推断的含义(仅供参考)**
|
||
{previous_meaning}
|
||
"""
|
||
previous_meaning_instruction = "- 请参考上一次推断的含义,结合新的上下文信息,给出更准确或更新的推断结果"
|
||
|
||
prompt1 = await global_prompt_manager.format_prompt(
|
||
"jargon_inference_with_context_prompt",
|
||
content=content,
|
||
bot_name=global_config.bot.nickname,
|
||
raw_content_list=raw_content_text,
|
||
previous_meaning_section=previous_meaning_section,
|
||
previous_meaning_instruction=previous_meaning_instruction,
|
||
)
|
||
|
||
response1, _ = await self.llm_inference.generate_response_async(prompt1, temperature=0.3)
|
||
if not response1:
|
||
logger.warning(f"jargon {content} 推断1失败:无响应")
|
||
return
|
||
|
||
# 解析推断1结果
|
||
inference1 = None
|
||
try:
|
||
resp1 = response1.strip()
|
||
if resp1.startswith("{") and resp1.endswith("}"):
|
||
inference1 = json.loads(resp1)
|
||
else:
|
||
repaired = repair_json(resp1)
|
||
inference1 = json.loads(repaired) if isinstance(repaired, str) else repaired
|
||
if not isinstance(inference1, dict):
|
||
logger.warning(f"jargon {content} 推断1结果格式错误")
|
||
return
|
||
except Exception as e:
|
||
logger.error(f"jargon {content} 推断1解析失败: {e}")
|
||
return
|
||
|
||
# 检查推断1是否表示信息不足无法推断
|
||
no_info = inference1.get("no_info", False)
|
||
meaning1 = inference1.get("meaning", "").strip()
|
||
if no_info or not meaning1:
|
||
logger.info(f"jargon {content} 推断1表示信息不足无法推断,放弃本次推断,待下次更新")
|
||
# 更新最后一次判定的count值,避免在同一阈值重复尝试
|
||
jargon_obj.last_inference_count = jargon_obj.count or 0
|
||
jargon_obj.save()
|
||
return
|
||
|
||
# 步骤2: 仅基于content推断
|
||
prompt2 = await global_prompt_manager.format_prompt(
|
||
"jargon_inference_content_only_prompt",
|
||
content=content,
|
||
)
|
||
|
||
response2, _ = await self.llm_inference.generate_response_async(prompt2, temperature=0.3)
|
||
if not response2:
|
||
logger.warning(f"jargon {content} 推断2失败:无响应")
|
||
return
|
||
|
||
# 解析推断2结果
|
||
inference2 = None
|
||
try:
|
||
resp2 = response2.strip()
|
||
if resp2.startswith("{") and resp2.endswith("}"):
|
||
inference2 = json.loads(resp2)
|
||
else:
|
||
repaired = repair_json(resp2)
|
||
inference2 = json.loads(repaired) if isinstance(repaired, str) else repaired
|
||
if not isinstance(inference2, dict):
|
||
logger.warning(f"jargon {content} 推断2结果格式错误")
|
||
return
|
||
except Exception as e:
|
||
logger.error(f"jargon {content} 推断2解析失败: {e}")
|
||
return
|
||
|
||
# logger.info(f"jargon {content} 推断2提示词: {prompt2}")
|
||
# logger.info(f"jargon {content} 推断2结果: {response2}")
|
||
# logger.info(f"jargon {content} 推断1提示词: {prompt1}")
|
||
# logger.info(f"jargon {content} 推断1结果: {response1}")
|
||
|
||
if global_config.debug.show_jargon_prompt:
|
||
logger.info(f"jargon {content} 推断2提示词: {prompt2}")
|
||
logger.info(f"jargon {content} 推断2结果: {response2}")
|
||
logger.info(f"jargon {content} 推断1提示词: {prompt1}")
|
||
logger.info(f"jargon {content} 推断1结果: {response1}")
|
||
else:
|
||
logger.debug(f"jargon {content} 推断2提示词: {prompt2}")
|
||
logger.debug(f"jargon {content} 推断2结果: {response2}")
|
||
logger.debug(f"jargon {content} 推断1提示词: {prompt1}")
|
||
logger.debug(f"jargon {content} 推断1结果: {response1}")
|
||
|
||
# 步骤3: 比较两个推断结果
|
||
prompt3 = await global_prompt_manager.format_prompt(
|
||
"jargon_compare_inference_prompt",
|
||
inference1=json.dumps(inference1, ensure_ascii=False),
|
||
inference2=json.dumps(inference2, ensure_ascii=False),
|
||
)
|
||
|
||
if global_config.debug.show_jargon_prompt:
|
||
logger.info(f"jargon {content} 比较提示词: {prompt3}")
|
||
|
||
response3, _ = await self.llm_inference.generate_response_async(prompt3, temperature=0.3)
|
||
if not response3:
|
||
logger.warning(f"jargon {content} 比较失败:无响应")
|
||
return
|
||
|
||
# 解析比较结果
|
||
comparison = None
|
||
try:
|
||
resp3 = response3.strip()
|
||
if resp3.startswith("{") and resp3.endswith("}"):
|
||
comparison = json.loads(resp3)
|
||
else:
|
||
repaired = repair_json(resp3)
|
||
comparison = json.loads(repaired) if isinstance(repaired, str) else repaired
|
||
if not isinstance(comparison, dict):
|
||
logger.warning(f"jargon {content} 比较结果格式错误")
|
||
return
|
||
except Exception as e:
|
||
logger.error(f"jargon {content} 比较解析失败: {e}")
|
||
return
|
||
|
||
# 判断是否为黑话
|
||
is_similar = comparison.get("is_similar", False)
|
||
is_jargon = not is_similar # 如果相似,说明不是黑话;如果有差异,说明是黑话
|
||
|
||
# 更新数据库记录
|
||
jargon_obj.is_jargon = is_jargon
|
||
if is_jargon:
|
||
# 是黑话,使用推断1的结果(基于上下文,更准确)
|
||
jargon_obj.meaning = inference1.get("meaning", "")
|
||
else:
|
||
# 不是黑话,清空含义,不再存储任何内容
|
||
jargon_obj.meaning = ""
|
||
|
||
# 更新最后一次判定的count值,避免重启后重复判定
|
||
jargon_obj.last_inference_count = jargon_obj.count or 0
|
||
|
||
# 如果count>=100,标记为完成,不再进行推断
|
||
if (jargon_obj.count or 0) >= 100:
|
||
jargon_obj.is_complete = True
|
||
|
||
jargon_obj.save()
|
||
logger.debug(
|
||
f"jargon {content} 推断完成: is_jargon={is_jargon}, meaning={jargon_obj.meaning}, last_inference_count={jargon_obj.last_inference_count}, is_complete={jargon_obj.is_complete}"
|
||
)
|
||
|
||
# 固定输出推断结果,格式化为可读形式
|
||
if is_jargon:
|
||
# 是黑话,输出格式:[聊天名]xxx的含义是 xxxxxxxxxxx
|
||
meaning = jargon_obj.meaning or "无详细说明"
|
||
is_global = jargon_obj.is_global
|
||
if is_global:
|
||
logger.info(f"[黑话]{content}的含义是 {meaning}")
|
||
else:
|
||
logger.info(f"[{self.stream_name}]{content}的含义是 {meaning}")
|
||
else:
|
||
# 不是黑话,输出格式:[聊天名]xxx 不是黑话
|
||
logger.info(f"[{self.stream_name}]{content} 不是黑话")
|
||
|
||
except Exception as e:
|
||
logger.error(f"jargon推断失败: {e}")
|
||
import traceback
|
||
|
||
traceback.print_exc()
|
||
|
||
async def run_once(
|
||
self,
|
||
messages: List[Any],
|
||
person_name_filter: Optional[Callable[[str], bool]] = None
|
||
) -> None:
|
||
"""
|
||
运行一次黑话提取
|
||
|
||
Args:
|
||
messages: 外部传入的消息列表(必需)
|
||
person_name_filter: 可选的过滤函数,用于检查内容是否包含人物名称
|
||
"""
|
||
# 使用异步锁防止并发执行
|
||
async with self._extraction_lock:
|
||
try:
|
||
if not messages:
|
||
return
|
||
|
||
# 按时间排序,确保编号与上下文一致
|
||
messages = sorted(messages, key=lambda msg: msg.time or 0)
|
||
|
||
chat_str, message_id_list = build_readable_messages_with_id(
|
||
messages=messages,
|
||
replace_bot_name=True,
|
||
timestamp_mode="relative",
|
||
truncate=False,
|
||
show_actions=False,
|
||
show_pic=True,
|
||
pic_single=True,
|
||
)
|
||
if not chat_str.strip():
|
||
return
|
||
|
||
msg_id_to_index: Dict[str, int] = {}
|
||
for idx, (msg_id, _msg) in enumerate(message_id_list or []):
|
||
if not msg_id:
|
||
continue
|
||
msg_id_to_index[msg_id] = idx
|
||
if not msg_id_to_index:
|
||
logger.warning("未能生成消息ID映射,跳过本次提取")
|
||
return
|
||
|
||
prompt: str = await global_prompt_manager.format_prompt(
|
||
"extract_jargon_prompt",
|
||
bot_name=global_config.bot.nickname,
|
||
chat_str=chat_str,
|
||
)
|
||
|
||
response, _ = await self.llm.generate_response_async(prompt, temperature=0.2)
|
||
if not response:
|
||
return
|
||
|
||
if global_config.debug.show_jargon_prompt:
|
||
logger.info(f"jargon提取提示词: {prompt}")
|
||
logger.info(f"jargon提取结果: {response}")
|
||
|
||
# 解析为JSON
|
||
entries: List[dict] = []
|
||
try:
|
||
resp = response.strip()
|
||
parsed = None
|
||
if resp.startswith("[") and resp.endswith("]"):
|
||
parsed = json.loads(resp)
|
||
else:
|
||
repaired = repair_json(resp)
|
||
if isinstance(repaired, str):
|
||
parsed = json.loads(repaired)
|
||
else:
|
||
parsed = repaired
|
||
|
||
if isinstance(parsed, dict):
|
||
parsed = [parsed]
|
||
|
||
if not isinstance(parsed, list):
|
||
return
|
||
|
||
for item in parsed:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
|
||
content = str(item.get("content", "")).strip()
|
||
msg_id_value = item.get("msg_id")
|
||
|
||
if not content:
|
||
continue
|
||
|
||
if contains_bot_self_name(content):
|
||
logger.info(f"解析阶段跳过包含机器人昵称/别名的词条: {content}")
|
||
continue
|
||
|
||
# 检查是否包含人物名称
|
||
if person_name_filter and person_name_filter(content):
|
||
logger.info(f"解析阶段跳过包含人物名称的词条: {content}")
|
||
continue
|
||
|
||
msg_id_str = str(msg_id_value or "").strip()
|
||
if not msg_id_str:
|
||
logger.warning(f"解析jargon失败:msg_id缺失,content={content}")
|
||
continue
|
||
|
||
msg_index = msg_id_to_index.get(msg_id_str)
|
||
if msg_index is None:
|
||
logger.warning(f"解析jargon失败:msg_id未找到,content={content}, msg_id={msg_id_str}")
|
||
continue
|
||
|
||
target_msg = messages[msg_index]
|
||
if is_bot_message(target_msg):
|
||
logger.info(f"解析阶段跳过引用机器人自身消息的词条: content={content}, msg_id={msg_id_str}")
|
||
continue
|
||
|
||
context_paragraph = build_context_paragraph(messages, msg_index)
|
||
if not context_paragraph:
|
||
logger.warning(f"解析jargon失败:上下文为空,content={content}, msg_id={msg_id_str}")
|
||
continue
|
||
|
||
entries.append({"content": content, "raw_content": [context_paragraph]})
|
||
cached_entries = self._collect_cached_entries(messages)
|
||
if cached_entries:
|
||
entries.extend(cached_entries)
|
||
except Exception as e:
|
||
logger.error(f"解析jargon JSON失败: {e}; 原始: {response}")
|
||
return
|
||
|
||
if not entries:
|
||
return
|
||
|
||
# 去重并合并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"JargonMiner 运行失败: {e}")
|
||
# 即使失败也保持时间戳更新,避免频繁重试
|
||
|
||
async def process_extracted_entries(
|
||
self,
|
||
entries: List[Dict[str, List[str]]],
|
||
person_name_filter: Optional[Callable[[str], bool]] = None
|
||
) -> None:
|
||
"""
|
||
处理已提取的黑话条目(从 expression_learner 路由过来的)
|
||
|
||
Args:
|
||
entries: 黑话条目列表,每个元素格式为 {"content": "...", "raw_content": [...]}
|
||
person_name_filter: 可选的过滤函数,用于检查内容是否包含人物名称
|
||
"""
|
||
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"]
|
||
|
||
# 检查是否包含人物名称
|
||
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
|
||
|
||
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:
|
||
self._miners: dict[str, JargonMiner] = {}
|
||
|
||
def get_miner(self, chat_id: str) -> JargonMiner:
|
||
if chat_id not in self._miners:
|
||
self._miners[chat_id] = JargonMiner(chat_id)
|
||
return self._miners[chat_id]
|
||
|
||
|
||
miner_manager = JargonMinerManager()
|
||
|
||
|
||
|
||
|
||
def search_jargon(
|
||
keyword: str, chat_id: Optional[str] = None, limit: int = 10, case_sensitive: bool = False, fuzzy: bool = True
|
||
) -> List[Dict[str, str]]:
|
||
"""
|
||
搜索jargon,支持大小写不敏感和模糊搜索
|
||
|
||
Args:
|
||
keyword: 搜索关键词
|
||
chat_id: 可选的聊天ID
|
||
- 如果开启了all_global:此参数被忽略,查询所有is_global=True的记录
|
||
- 如果关闭了all_global:如果提供则优先搜索该聊天或global的jargon
|
||
limit: 返回结果数量限制,默认10
|
||
case_sensitive: 是否大小写敏感,默认False(不敏感)
|
||
fuzzy: 是否模糊搜索,默认True(使用LIKE匹配)
|
||
|
||
Returns:
|
||
List[Dict[str, str]]: 包含content, meaning的字典列表
|
||
"""
|
||
if not keyword or not keyword.strip():
|
||
return []
|
||
|
||
keyword = keyword.strip()
|
||
|
||
# 构建查询(选择所有需要的字段,以便后续过滤)
|
||
query = Jargon.select()
|
||
|
||
# 构建搜索条件
|
||
if case_sensitive:
|
||
# 大小写敏感
|
||
if fuzzy:
|
||
# 模糊搜索
|
||
search_condition = Jargon.content.contains(keyword)
|
||
else:
|
||
# 精确匹配
|
||
search_condition = Jargon.content == keyword
|
||
else:
|
||
# 大小写不敏感
|
||
if fuzzy:
|
||
# 模糊搜索(使用LOWER函数)
|
||
search_condition = fn.LOWER(Jargon.content).contains(keyword.lower())
|
||
else:
|
||
# 精确匹配(使用LOWER函数)
|
||
search_condition = fn.LOWER(Jargon.content) == keyword.lower()
|
||
|
||
query = query.where(search_condition)
|
||
|
||
# 根据all_global配置决定查询逻辑
|
||
if global_config.expression.all_global_jargon:
|
||
# 开启all_global:所有记录都是全局的,查询所有is_global=True的记录(无视chat_id)
|
||
query = query.where(Jargon.is_global)
|
||
# 注意:对于all_global=False的情况,chat_id过滤在Python层面进行,以便兼容新旧格式
|
||
|
||
# 注意:meaning的过滤移到Python层面,因为我们需要先过滤chat_id
|
||
|
||
# 按count降序排序,优先返回出现频率高的
|
||
query = query.order_by(Jargon.count.desc())
|
||
|
||
# 限制结果数量(先多取一些,因为后面可能过滤)
|
||
query = query.limit(limit * 2)
|
||
|
||
# 执行查询并返回结果,过滤chat_id
|
||
results = []
|
||
for jargon in query:
|
||
# 如果提供了chat_id且all_global=False,需要检查chat_id列表是否包含目标chat_id
|
||
if chat_id and not global_config.expression.all_global_jargon:
|
||
chat_id_list = parse_chat_id_list(jargon.chat_id)
|
||
# 如果记录是is_global=True,或者chat_id列表包含目标chat_id,则包含
|
||
if not jargon.is_global and not chat_id_list_contains(chat_id_list, chat_id):
|
||
continue
|
||
|
||
# 只返回有meaning的记录
|
||
if not jargon.meaning or jargon.meaning.strip() == "":
|
||
continue
|
||
|
||
results.append({"content": jargon.content or "", "meaning": jargon.meaning or ""})
|
||
|
||
# 达到限制数量后停止
|
||
if len(results) >= limit:
|
||
break
|
||
|
||
return results
|