redis_client.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. import redis
  2. import json
  3. import requests
  4. import time
  5. import random
  6. from typing import Optional, Tuple
  7. from contextlib import contextmanager
  8. from dotenv import load_dotenv
  9. import os
  10. load_dotenv()
  11. REDIS_HOST = os.getenv("REDIS_HOST")
  12. REDIS_PORT = os.getenv("REDIS_PORT")
  13. REDIS_TTL = 1800 # 30分钟
  14. THIRD_PARTY_API = "http://eco.zhongsou.com/eco/user/user.redis.info.groovy"
  15. try:
  16. redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
  17. except Exception:
  18. redis_client = None
  19. def get_app_user(token: str) -> Optional[dict]:
  20. """
  21. 通过第三方 token 获取用户信息
  22. 先查 Redis 缓存,未命中则调第三方接口并写入缓存
  23. Args:
  24. token: App 端传入的第三方 token
  25. Returns:
  26. 用户信息字典,token 无效或接口异常时返回 None
  27. """
  28. cache_key = f"app_user:{token}"
  29. # 1. 查 Redis 缓存
  30. try:
  31. if redis_client:
  32. cached = redis_client.get(cache_key)
  33. if cached:
  34. return json.loads(cached)
  35. except Exception as e:
  36. print(f"Redis 读取失败,降级调第三方接口: {e}")
  37. # 2. 调第三方接口
  38. try:
  39. resp = requests.get(THIRD_PARTY_API, params={"token": token}, timeout=5)
  40. data = resp.json()
  41. if data.get("head", {}).get("status") != 200:
  42. return None
  43. body = data.get("body")
  44. if not body or not body.get("userId"):
  45. return None
  46. user_info = {
  47. "userId": body["userId"],
  48. "userName": body["userName"],
  49. "nickName": body["nickName"],
  50. "mobile": body["mobile"],
  51. "imageUrl": body.get("imageUrl", ""),
  52. "appName": body["appName"],
  53. }
  54. # 3. 写入 Redis 缓存
  55. try:
  56. if redis_client:
  57. redis_client.setex(cache_key, REDIS_TTL, json.dumps(user_info))
  58. except Exception as e:
  59. print(f"Redis 写入失败: {e}")
  60. return user_info
  61. except Exception as e:
  62. print(f"第三方接口调用失败: {e}")
  63. return None
  64. # ==================== 群聊AI频率控制 ====================
  65. class RedisReplyController:
  66. """
  67. 基于Redis的AI回复频率控制器
  68. - 支持多实例部署
  69. - 冷却时间控制
  70. - 每小时配额限制
  71. - 群消息密度检测
  72. - 分布式锁
  73. """
  74. def __init__(self, redis_client):
  75. self.redis = redis_client
  76. def can_reply(self, ai_role: str, priority: str, group_id: str) -> Tuple[bool, str]:
  77. """
  78. 判断AI是否可以回复
  79. Args:
  80. ai_role: AI角色(entrepreneur | femininity)
  81. priority: 优先级(mentioned | force | optional)
  82. group_id: 群ID
  83. Returns:
  84. (can_reply: bool, reason: str)
  85. """
  86. if not self.redis:
  87. return True, "Redis不可用,直接通过"
  88. try:
  89. now = int(time.time())
  90. # 1. 冷却时间检查
  91. last_reply_key = f"im:ai:{group_id}:{ai_role}:last_reply"
  92. last_reply = self.redis.get(last_reply_key)
  93. if last_reply:
  94. elapsed = now - int(last_reply)
  95. # 根据优先级设置冷却时间
  96. if priority == 'mentioned':
  97. # 明确@:随机30-60秒(防止恶意探测规律)
  98. cooldown = random.randint(30, 60)
  99. elif priority == 'force':
  100. cooldown = 60
  101. else:
  102. cooldown = 300
  103. if elapsed < cooldown:
  104. return False, f"冷却中,还需 {cooldown - elapsed} 秒"
  105. # 2. 每小时配额检查(明确@和强制触发不受限)
  106. if priority not in ['mentioned', 'force']:
  107. hour_key = f"im:ai:{group_id}:{ai_role}:count:{now // 3600}"
  108. count = self.redis.get(hour_key)
  109. count = int(count) if count else 0
  110. if count >= 5: # v2.0: 从12次降为5次/小时
  111. return False, f"本小时配额已用完 ({count}/5)"
  112. # 3. 群消息密度检查(明确@和强制触发不受限)
  113. if priority not in ['mentioned', 'force']:
  114. density_key = f"im:group:{group_id}:msg_density"
  115. recent_count = self.redis.llen(density_key)
  116. if recent_count > 20:
  117. return False, f"群内讨论热烈 ({recent_count}条/5分钟)"
  118. return True, "通过"
  119. except Exception as e:
  120. print(f"Redis频率检查失败: {e}")
  121. return True, "Redis异常,直接通过"
  122. def record_reply(self, ai_role: str, group_id: str):
  123. """
  124. 记录AI回复
  125. Args:
  126. ai_role: AI角色
  127. group_id: 群ID
  128. """
  129. if not self.redis:
  130. return
  131. try:
  132. now = int(time.time())
  133. # 记录最后回复时间(1小时有效期)
  134. last_reply_key = f"im:ai:{group_id}:{ai_role}:last_reply"
  135. self.redis.set(last_reply_key, now, ex=3600)
  136. # 增加小时计数
  137. hour_key = f"im:ai:{group_id}:{ai_role}:count:{now // 3600}"
  138. self.redis.incr(hour_key)
  139. self.redis.expire(hour_key, 3600)
  140. except Exception as e:
  141. print(f"Redis记录回复失败: {e}")
  142. def record_group_message(self, group_id: str, msg_seq: int):
  143. """
  144. 记录群消息(用于密度检测)
  145. Args:
  146. group_id: 群ID
  147. msg_seq: 消息序列号
  148. """
  149. if not self.redis:
  150. return
  151. try:
  152. now = int(time.time())
  153. density_key = f"im:group:{group_id}:msg_density"
  154. # 添加到列表头部
  155. self.redis.lpush(density_key, f"{now}:{msg_seq}")
  156. # 只保留最近50条
  157. self.redis.ltrim(density_key, 0, 49)
  158. # 5分钟过期
  159. self.redis.expire(density_key, 300)
  160. # 同时更新群最后消息时间
  161. last_msg_key = f"im:group:{group_id}:last_msg_time"
  162. self.redis.set(last_msg_key, now, ex=3600)
  163. except Exception as e:
  164. print(f"Redis记录群消息失败: {e}")
  165. def get_last_group_message_time(self, group_id: str) -> int:
  166. """
  167. 获取群最后消息时间
  168. Args:
  169. group_id: 群ID
  170. Returns:
  171. 时间戳(秒),如果不存在返回0
  172. """
  173. if not self.redis:
  174. return 0
  175. try:
  176. key = f"im:group:{group_id}:last_msg_time"
  177. timestamp = self.redis.get(key)
  178. return int(timestamp) if timestamp else 0
  179. except Exception as e:
  180. print(f"Redis获取群最后消息时间失败: {e}")
  181. return 0
  182. # ==================== 消息幂等去重 ====================
  183. def is_message_processed(group_id: str, msg_seq: int) -> bool:
  184. """
  185. 检查消息是否已处理
  186. Args:
  187. group_id: 群ID
  188. msg_seq: 消息序列号
  189. Returns:
  190. True: 已处理,False: 未处理
  191. """
  192. if not redis_client:
  193. return False
  194. try:
  195. key = f"im:processed:{group_id}:{msg_seq}"
  196. return redis_client.exists(key) > 0
  197. except Exception as e:
  198. print(f"Redis检查消息处理状态失败: {e}")
  199. return False
  200. def mark_message_processed(group_id: str, msg_seq: int):
  201. """
  202. 标记消息已处理(10分钟有效期)
  203. Args:
  204. group_id: 群ID
  205. msg_seq: 消息序列号
  206. """
  207. if not redis_client:
  208. return
  209. try:
  210. key = f"im:processed:{group_id}:{msg_seq}"
  211. redis_client.setex(key, 600, "1") # 10分钟TTL
  212. except Exception as e:
  213. print(f"Redis标记消息已处理失败: {e}")
  214. # ==================== 分布式锁 ====================
  215. @contextmanager
  216. def acquire_reply_lock(group_id: str, timeout: int = 5):
  217. """
  218. 获取回复锁(分布式锁)
  219. 同一时刻只允许一个AI回复,防止并发抢答
  220. Args:
  221. group_id: 群ID
  222. timeout: 锁超时时间(秒)
  223. Yields:
  224. bool: True表示获取到锁,False表示未获取到
  225. Example:
  226. with acquire_reply_lock(group_id) as locked:
  227. if not locked:
  228. return
  229. # 执行AI回复逻辑
  230. """
  231. if not redis_client:
  232. yield True
  233. return
  234. lock_key = f"im:reply_lock:{group_id}"
  235. lock_value = str(time.time())
  236. acquired = False
  237. try:
  238. # 尝试获取锁(NX: 不存在才设置,EX: 过期时间)
  239. acquired = redis_client.set(lock_key, lock_value, nx=True, ex=timeout)
  240. yield bool(acquired)
  241. finally:
  242. # 释放锁
  243. if acquired:
  244. try:
  245. redis_client.delete(lock_key)
  246. except Exception:
  247. pass # 锁会自动过期,删除失败不影响
  248. # ==================== 群聊上下文缓存 ====================
  249. def get_cached_group_context(group_id: str) -> Optional[list]:
  250. """
  251. 从Redis获取缓存的群聊上下文
  252. Args:
  253. group_id: 群ID
  254. Returns:
  255. 群聊上下文列表,未命中返回None
  256. """
  257. if not redis_client:
  258. return None
  259. cache_key = f"im:group_context:{group_id}"
  260. try:
  261. cached = redis_client.get(cache_key)
  262. if cached:
  263. return json.loads(cached)
  264. except Exception as e:
  265. print(f"Redis获取群上下文缓存失败: {e}")
  266. return None
  267. def cache_group_context(group_id: str, context: list, ttl: int = 120):
  268. """
  269. 将群聊上下文缓存到Redis
  270. Args:
  271. group_id: 群ID
  272. context: 群聊上下文列表
  273. ttl: 缓存时间(秒),默认2分钟
  274. """
  275. if not redis_client:
  276. return
  277. cache_key = f"im:group_context:{group_id}"
  278. try:
  279. redis_client.setex(cache_key, ttl, json.dumps(context, ensure_ascii=False))
  280. except Exception as e:
  281. print(f"Redis缓存群上下文失败: {e}")
  282. # ==================== 情绪状态机 ====================
  283. # 群情绪标签及其数值权重(正值=正面,负值=负面)
  284. _MOOD_SCORES = {"excited": 2.0, "calm": 0.0, "low": -1.5, "tense": -2.0}
  285. # 各情绪档位对应的语气指令
  286. _MOOD_TONE = {
  287. "excited": "群里气氛热烈,回复可以带点感染力,用“确实”“厉害”接住情绪再展开。",
  288. "calm": "保持你一贯的沉稳企业家语气。",
  289. "low": "群里气氛偏低落,语气放缓,先共情再给建议,不要上来就分析。",
  290. "tense": "群里情绪紧张或焦虑,回复要稳,给确定性而非更多问题,避免追问施压。",
  291. }
  292. # 滑动窗口最近5条,权重从旧到新(越新权重越高),归一化后使用
  293. _MOOD_WEIGHTS_BASE = [0.10, 0.10, 0.15, 0.25, 0.40]
  294. def classify_mood(text: str) -> tuple:
  295. """
  296. 规则法快速情绪分类,零延迟、零调用成本。
  297. 返回 (mood: str, intensity: float)
  298. """
  299. if any(w in text for w in ["太难了", "撑不住", "不想干", "亏了", "失败", "崩了", "没希望"]):
  300. return "low", 0.7
  301. if any(w in text for w in ["焦虑", "慌", "急", "来不及", "压力"]) or text.count("?") >= 3:
  302. return "tense", 0.6
  303. if any(w in text for w in ["牛", "太好了", "成了", "恭喜", "涨了", "爆了"]) or "!" in text:
  304. return "excited", 0.6
  305. return "calm", 0.3
  306. def update_group_mood(group_id: str, user_id: str, text: str) -> tuple:
  307. """
  308. 用新消息更新群情绪状态,写入 Redis(TTL 30 分钟)。
  309. 每条消息以 user_id+text[:20] 作简单去重,避免相同历史消息重复计入。
  310. 返回 (group_mood: str, intensity: float)
  311. """
  312. if not redis_client:
  313. return "calm", 0.0
  314. key = f"mood:group:{group_id}"
  315. dedup_key = f"{user_id}:{text[:20]}"
  316. try:
  317. raw = redis_client.get(key)
  318. state = json.loads(raw) if raw else {"recent_signals": [], "seen_keys": []}
  319. # 去重:同一消息不重复写入
  320. seen_keys = state.get("seen_keys", [])
  321. if dedup_key in seen_keys:
  322. # 消息已处理,直接返回当前状态
  323. return state.get("group_mood", "calm"), state.get("intensity", 0.0)
  324. mood, _ = classify_mood(text)
  325. signals = state.get("recent_signals", [])
  326. signals.append({"mood": mood})
  327. signals = signals[-5:] # 保留最近5条
  328. seen_keys.append(dedup_key)
  329. seen_keys = seen_keys[-50:] # 去重集合最多50条
  330. # 取与信号数量对应的尾部权重,然后归一化到和为1
  331. weights_raw = _MOOD_WEIGHTS_BASE[-len(signals):]
  332. total_w = sum(weights_raw)
  333. weights = [w / total_w for w in weights_raw]
  334. scores = [_MOOD_SCORES[s["mood"]] for s in signals]
  335. avg = sum(w * s for w, s in zip(weights, scores))
  336. if avg >= 1.0:
  337. group_mood = "excited"
  338. elif avg >= -0.5:
  339. group_mood = "calm"
  340. elif avg >= -1.5:
  341. group_mood = "low"
  342. else:
  343. group_mood = "tense"
  344. intensity = round(min(abs(avg) / 2.0, 1.0), 2)
  345. new_state = {
  346. "group_mood": group_mood,
  347. "intensity": intensity,
  348. "recent_signals": signals,
  349. "seen_keys": seen_keys,
  350. "updated_at": time.time(),
  351. }
  352. redis_client.setex(key, 1800, json.dumps(new_state, ensure_ascii=False))
  353. return group_mood, intensity
  354. except Exception as e:
  355. print(f"Redis 更新群情绪失败: {e}")
  356. return "calm", 0.0
  357. def get_group_mood(group_id: str) -> tuple:
  358. """读取群当前情绪状态,Redis 不可用时回退到 calm。"""
  359. if not redis_client:
  360. return "calm", 0.0
  361. try:
  362. raw = redis_client.get(f"mood:group:{group_id}")
  363. if not raw:
  364. return "calm", 0.0
  365. state = json.loads(raw)
  366. return state.get("group_mood", "calm"), state.get("intensity", 0.0)
  367. except Exception as e:
  368. print(f"Redis 读取群情绪失败: {e}")
  369. return "calm", 0.0
  370. def build_mood_block(group_id: str) -> str:
  371. """构造注入 system prompt 的群氛围说明段。"""
  372. mood, intensity = get_group_mood(group_id)
  373. if mood == "calm" and intensity < 0.1:
  374. return "" # 平静且强度极低时不注入,减少 prompt 噪声
  375. tone = _MOOD_TONE.get(mood, "")
  376. return f"\n\n## 当前群氛围\n{mood}(强度 {intensity})。{tone}"
  377. # ==================== 全局实例 ====================
  378. # 频率控制器实例
  379. reply_controller = RedisReplyController(redis_client)