Browse Source

feat: - 增加群聊上下文(支持多模态)

zhangwl 2 weeks ago
parent
commit
914ede5fd0
7 changed files with 930 additions and 33 deletions
  1. 5 0
      .env
  2. 115 1
      app/db/mongo.py
  3. 294 1
      app/db/redis_client.py
  4. 309 0
      app/db/tencent_im.py
  5. 203 30
      app/routers/chat_tools.py
  6. 3 1
      app/schemas/chat.py
  7. 1 0
      requirements.txt

+ 5 - 0
.env

@@ -31,3 +31,8 @@ VOLC_ASR_RESOURCE_ID=volc.seedasr.sauc.duration
 # 应用配置
 # 应用配置
 LOG_LEVEL=info
 LOG_LEVEL=info
 ENVIRONMENT=production
 ENVIRONMENT=production
+
+#腾讯云-即时通信IM
+TENCENT_IM_APP_ID=1400017757
+TENCENT_IM_ADMIN_ID=adminjava
+TENCENT_IM_SECRET_KEY=f8a4c34728e74d235da1ea2b1775a39c27787a4954e379de5ac424806e8b67e3

+ 115 - 1
app/db/mongo.py

@@ -46,7 +46,7 @@ def save_chat_log(
     error: str = None,
     error: str = None,
 ):
 ):
     """
     """
-    保存聊天原始响应日志到 MongoDB(异步写入,不阻塞调用方)
+    运维功能:保存聊天原始响应日志到 MongoDB(异步写入,不阻塞调用方)【日志审计】
 
 
     Args:
     Args:
         user_id: 提问人
         user_id: 提问人
@@ -83,6 +83,10 @@ def save_chat_history(
     total_tokens: int = None,
     total_tokens: int = None,
     app_name:str=None,
     app_name:str=None,
 ):
 ):
+    """
+    业务功能: 保存用户跟chat机器人的聊天记录,用于会话存储
+    """
+
     try:
     try:
         _ensure_index()
         _ensure_index()
         doc = {
         doc = {
@@ -239,3 +243,113 @@ def delete_figure(figure_id: str) -> int:
     except Exception as e:
     except Exception as e:
         print(f"MongoDB 历史人物删除失败: {e}")
         print(f"MongoDB 历史人物删除失败: {e}")
         return 0
         return 0
+
+
+# ===================== 群聊AI回复历史 =====================
+
+# 群聊AI回复记录集合(只存AI回复,不存群消息)
+group_chat_history_col = db["group_chat_history"]
+
+
+def _ensure_group_chat_index():
+    try:
+        group_chat_history_col.create_index([("session_id", 1), ("timestamp", -1)])
+        group_chat_history_col.create_index([("group_id", 1), ("ai_role", 1), ("timestamp", -1)])
+    except Exception:
+        pass
+
+
+def save_group_ai_reply(
+    session_id: str,
+    group_id: str,
+    ai_role: str,
+    content: str,
+    response_id: str,
+    trigger_message: str,
+    trigger_user_id: str,
+    timestamp: datetime,
+    app_name: str = "souyue_im",
+):
+    """
+    业务功能:保存AI在群聊中的回复
+
+    注意:只存储AI回复,不存储群消息(群消息通过腾讯云API获取)
+
+    Args:
+        session_id: 会话ID(群ID + 小时窗口)
+        group_id: 群ID
+        ai_role: AI角色(entrepreneur | femininity)
+        content: AI回复内容
+        response_id: 大模型response ID
+        trigger_message: 触发AI的原始消息
+        trigger_user_id: 触发用户ID
+        timestamp: 时间戳
+        app_name: 应用名称
+    """
+    try:
+        _ensure_group_chat_index()
+        doc = {
+            "session_id": session_id,
+            "group_id": group_id,
+            "role": "assistant",
+            "user_id": f"ai_{ai_role}",
+            "content": content,
+            "ai_role": ai_role,
+            "response_id": response_id,
+            "trigger_message": trigger_message,
+            "trigger_user_id": trigger_user_id,
+            "timestamp": timestamp,
+            "app_name": app_name,
+        }
+        group_chat_history_col.insert_one(doc)
+    except Exception as e:
+        print(f"MongoDB 群聊AI回复写入失败: {e}")
+
+
+def get_last_group_response_id(session_id: str, ai_role: str) -> str | None:
+    """
+    获取指定AI的最后一次回复的response_id
+
+    Args:
+        session_id: 会话ID
+        ai_role: AI角色(必须指定,防止串线)
+
+    Returns:
+        response_id 或 None
+    """
+    try:
+        doc = group_chat_history_col.find_one(
+            {
+                "session_id": session_id,
+                "ai_role": ai_role,
+                "response_id": {"$ne": None}
+            },
+            {"response_id": 1, "_id": 0},
+            sort=[("timestamp", -1)]
+        )
+        return doc["response_id"] if doc else None
+    except Exception as e:
+        print(f"MongoDB 查询群聊 response_id 失败: {e}")
+        return None
+
+
+def get_group_ai_reply_count(session_id: str, ai_role: str = None) -> int:
+    """
+    获取会话中的AI回复次数
+
+    Args:
+        session_id: 会话ID
+        ai_role: AI角色(可选,不传则统计所有AI)
+
+    Returns:
+        回复次数
+    """
+    try:
+        query = {"session_id": session_id}
+        if ai_role:
+            query["ai_role"] = ai_role
+        return group_chat_history_col.count_documents(query)
+    except Exception as e:
+        print(f"MongoDB 查询AI回复次数失败: {e}")
+        return 0
+

+ 294 - 1
app/db/redis_client.py

@@ -1,7 +1,10 @@
 import redis
 import redis
 import json
 import json
 import requests
 import requests
-from typing import Optional
+import time
+import random
+from typing import Optional, Tuple
+from contextlib import contextmanager
 from dotenv import load_dotenv
 from dotenv import load_dotenv
 import os
 import os
 
 
@@ -74,3 +77,293 @@ def get_app_user(token: str) -> Optional[dict]:
     except Exception as e:
     except Exception as e:
         print(f"第三方接口调用失败: {e}")
         print(f"第三方接口调用失败: {e}")
         return None
         return None
+
+
+# ==================== 群聊AI频率控制 ====================
+
+class RedisReplyController:
+    """
+    基于Redis的AI回复频率控制器
+    - 支持多实例部署
+    - 冷却时间控制
+    - 每小时配额限制
+    - 群消息密度检测
+    - 分布式锁
+    """
+
+    def __init__(self, redis_client):
+        self.redis = redis_client
+
+    def can_reply(self, ai_role: str, priority: str, group_id: str) -> Tuple[bool, str]:
+        """
+        判断AI是否可以回复
+
+        Args:
+            ai_role: AI角色(entrepreneur | femininity)
+            priority: 优先级(mentioned | force | optional)
+            group_id: 群ID
+
+        Returns:
+            (can_reply: bool, reason: str)
+        """
+        if not self.redis:
+            return True, "Redis不可用,直接通过"
+
+        try:
+            now = int(time.time())
+
+            # 1. 冷却时间检查
+            last_reply_key = f"im:ai:{group_id}:{ai_role}:last_reply"
+            last_reply = self.redis.get(last_reply_key)
+
+            if last_reply:
+                elapsed = now - int(last_reply)
+
+                # 根据优先级设置冷却时间
+                if priority == 'mentioned':
+                    # 明确@:随机30-60秒(防止恶意探测规律)
+                    cooldown = random.randint(30, 60)
+                elif priority == 'force':
+                    cooldown = 60
+                else:
+                    cooldown = 300
+
+                if elapsed < cooldown:
+                    return False, f"冷却中,还需 {cooldown - elapsed} 秒"
+
+            # 2. 每小时配额检查(明确@和强制触发不受限)
+            if priority not in ['mentioned', 'force']:
+                hour_key = f"im:ai:{group_id}:{ai_role}:count:{now // 3600}"
+                count = self.redis.get(hour_key)
+                count = int(count) if count else 0
+
+                if count >= 5:  # v2.0: 从12次降为5次/小时
+                    return False, f"本小时配额已用完 ({count}/5)"
+
+            # 3. 群消息密度检查(明确@和强制触发不受限)
+            if priority not in ['mentioned', 'force']:
+                density_key = f"im:group:{group_id}:msg_density"
+                recent_count = self.redis.llen(density_key)
+                if recent_count > 20:
+                    return False, f"群内讨论热烈 ({recent_count}条/5分钟)"
+
+            return True, "通过"
+
+        except Exception as e:
+            print(f"Redis频率检查失败: {e}")
+            return True, "Redis异常,直接通过"
+
+    def record_reply(self, ai_role: str, group_id: str):
+        """
+        记录AI回复
+
+        Args:
+            ai_role: AI角色
+            group_id: 群ID
+        """
+        if not self.redis:
+            return
+
+        try:
+            now = int(time.time())
+
+            # 记录最后回复时间(1小时有效期)
+            last_reply_key = f"im:ai:{group_id}:{ai_role}:last_reply"
+            self.redis.set(last_reply_key, now, ex=3600)
+
+            # 增加小时计数
+            hour_key = f"im:ai:{group_id}:{ai_role}:count:{now // 3600}"
+            self.redis.incr(hour_key)
+            self.redis.expire(hour_key, 3600)
+
+        except Exception as e:
+            print(f"Redis记录回复失败: {e}")
+
+    def record_group_message(self, group_id: str, msg_seq: int):
+        """
+        记录群消息(用于密度检测)
+
+        Args:
+            group_id: 群ID
+            msg_seq: 消息序列号
+        """
+        if not self.redis:
+            return
+
+        try:
+            now = int(time.time())
+            density_key = f"im:group:{group_id}:msg_density"
+
+            # 添加到列表头部
+            self.redis.lpush(density_key, f"{now}:{msg_seq}")
+            # 只保留最近50条
+            self.redis.ltrim(density_key, 0, 49)
+            # 5分钟过期
+            self.redis.expire(density_key, 300)
+
+            # 同时更新群最后消息时间
+            last_msg_key = f"im:group:{group_id}:last_msg_time"
+            self.redis.set(last_msg_key, now, ex=3600)
+
+        except Exception as e:
+            print(f"Redis记录群消息失败: {e}")
+
+    def get_last_group_message_time(self, group_id: str) -> int:
+        """
+        获取群最后消息时间
+
+        Args:
+            group_id: 群ID
+
+        Returns:
+            时间戳(秒),如果不存在返回0
+        """
+        if not self.redis:
+            return 0
+
+        try:
+            key = f"im:group:{group_id}:last_msg_time"
+            timestamp = self.redis.get(key)
+            return int(timestamp) if timestamp else 0
+        except Exception as e:
+            print(f"Redis获取群最后消息时间失败: {e}")
+            return 0
+
+
+# ==================== 消息幂等去重 ====================
+
+def is_message_processed(group_id: str, msg_seq: int) -> bool:
+    """
+    检查消息是否已处理
+
+    Args:
+        group_id: 群ID
+        msg_seq: 消息序列号
+
+    Returns:
+        True: 已处理,False: 未处理
+    """
+    if not redis_client:
+        return False
+
+    try:
+        key = f"im:processed:{group_id}:{msg_seq}"
+        return redis_client.exists(key) > 0
+    except Exception as e:
+        print(f"Redis检查消息处理状态失败: {e}")
+        return False
+
+
+def mark_message_processed(group_id: str, msg_seq: int):
+    """
+    标记消息已处理(10分钟有效期)
+
+    Args:
+        group_id: 群ID
+        msg_seq: 消息序列号
+    """
+    if not redis_client:
+        return
+
+    try:
+        key = f"im:processed:{group_id}:{msg_seq}"
+        redis_client.setex(key, 600, "1")  # 10分钟TTL
+    except Exception as e:
+        print(f"Redis标记消息已处理失败: {e}")
+
+
+# ==================== 分布式锁 ====================
+
+@contextmanager
+def acquire_reply_lock(group_id: str, timeout: int = 5):
+    """
+    获取回复锁(分布式锁)
+
+    同一时刻只允许一个AI回复,防止并发抢答
+
+    Args:
+        group_id: 群ID
+        timeout: 锁超时时间(秒)
+
+    Yields:
+        bool: True表示获取到锁,False表示未获取到
+
+    Example:
+        with acquire_reply_lock(group_id) as locked:
+            if not locked:
+                return
+            # 执行AI回复逻辑
+    """
+    if not redis_client:
+        yield True
+        return
+
+    lock_key = f"im:reply_lock:{group_id}"
+    lock_value = str(time.time())
+    acquired = False
+
+    try:
+        # 尝试获取锁(NX: 不存在才设置,EX: 过期时间)
+        acquired = redis_client.set(lock_key, lock_value, nx=True, ex=timeout)
+        yield bool(acquired)
+    finally:
+        # 释放锁
+        if acquired:
+            try:
+                redis_client.delete(lock_key)
+            except Exception:
+                pass  # 锁会自动过期,删除失败不影响
+
+
+# ==================== 群聊上下文缓存 ====================
+
+def get_cached_group_context(group_id: str) -> Optional[list]:
+    """
+    从Redis获取缓存的群聊上下文
+
+    Args:
+        group_id: 群ID
+
+    Returns:
+        群聊上下文列表,未命中返回None
+    """
+    if not redis_client:
+        return None
+
+    cache_key = f"im:group_context:{group_id}"
+
+    try:
+        cached = redis_client.get(cache_key)
+        if cached:
+            return json.loads(cached)
+    except Exception as e:
+        print(f"Redis获取群上下文缓存失败: {e}")
+
+    return None
+
+
+def cache_group_context(group_id: str, context: list, ttl: int = 120):
+    """
+    将群聊上下文缓存到Redis
+
+    Args:
+        group_id: 群ID
+        context: 群聊上下文列表
+        ttl: 缓存时间(秒),默认2分钟
+    """
+    if not redis_client:
+        return
+
+    cache_key = f"im:group_context:{group_id}"
+
+    try:
+        redis_client.setex(cache_key, ttl, json.dumps(context, ensure_ascii=False))
+    except Exception as e:
+        print(f"Redis缓存群上下文失败: {e}")
+
+
+# ==================== 全局实例 ====================
+
+# 频率控制器实例
+reply_controller = RedisReplyController(redis_client)
+

+ 309 - 0
app/db/tencent_im.py

@@ -0,0 +1,309 @@
+"""
+腾讯云IM API客户端
+
+用于获取群聊消息历史,不在MongoDB中冗余存储群消息。
+
+API文档:https://cloud.tencent.com/document/product/269/2738
+"""
+
+import httpx
+import random
+from typing import List, Optional
+from dotenv import load_dotenv
+import os
+from TLSSigAPIv2 import TLSSigAPIv2
+
+load_dotenv()
+
+# 腾讯云IM配置
+TENCENT_IM_APP_ID = os.getenv("TENCENT_IM_APP_ID")
+TENCENT_IM_ADMIN_ID = os.getenv("TENCENT_IM_ADMIN_ID", "administrator")
+TENCENT_IM_SECRET_KEY = os.getenv("TENCENT_IM_SECRET_KEY")
+
+
+class TencentIMClient:
+    """腾讯云IM API客户端"""
+
+    def __init__(self, app_id: str = None, admin_id: str = None, secret_key: str = None):
+        self.app_id = app_id or TENCENT_IM_APP_ID
+        self.admin_id = admin_id or TENCENT_IM_ADMIN_ID
+        self.secret_key = secret_key or TENCENT_IM_SECRET_KEY
+
+        if not all([self.app_id, self.admin_id, self.secret_key]):
+            print("警告:腾讯云IM配置不完整,请检查环境变量")
+
+        self.base_url = "https://console.tim.qq.com/v4"
+
+        # 初始化 TLSSigAPIv2 实例
+        if self.app_id and self.secret_key:
+            self.sig_api = TLSSigAPIv2(int(self.app_id), self.secret_key)
+        else:
+            self.sig_api = None
+
+    def _generate_usersig(self, expire: int = 86400 * 180) -> str:
+        """
+        生成UserSig(使用腾讯云官方SDK)
+
+        Args:
+            expire: 有效期(秒),默认180天
+
+        Returns:
+            UserSig字符串
+        """
+        if not self.sig_api:
+            raise ValueError("腾讯云IM配置不完整,无法生成UserSig")
+
+        return self.sig_api.gen_sig(self.admin_id, expire)
+
+    async def get_group_messages(
+        self,
+        group_id: str,
+        req_msg_number: int = 20,
+        req_msg_seq: int = None
+    ) -> List[dict]:
+        """
+        获取群消息历史
+
+        API: group_open_http_svc/group_msg_get_simple
+
+        Args:
+            group_id: 群ID
+            req_msg_number: 拉取消息数量(1-20)
+            req_msg_seq: 翻页标识(上一页返回的最小MsgSeq)
+
+        Returns:
+            消息列表,每条消息格式:
+            {
+                "From_Account": "user123",
+                "MsgSeq": 12345,
+                "MsgRandom": 12345,
+                "MsgTimeStamp": 1609459200,
+                "MsgBody": [
+                    {
+                        "MsgType": "TIMTextElem",
+                        "MsgContent": {"Text": "消息内容"}
+                    }
+                ]
+            }
+        """
+        if not self.app_id or not self.secret_key:
+            print("腾讯云IM配置不完整,返回空消息列表")
+            return []
+
+        try:
+            # 生成UserSig
+            user_sig = self._generate_usersig()
+
+            # 构造请求URL
+            random_num = random.randint(0, 4294967295)
+            url = (
+                f"{self.base_url}/group_open_http_svc/group_msg_get_simple"
+                f"?sdkappid={self.app_id}"
+                f"&identifier={self.admin_id}"
+                f"&usersig={user_sig}"
+                f"&random={random_num}"
+                f"&contenttype=json"
+            )
+
+            # 构造请求体
+            payload = {
+                "GroupId": group_id,
+                "ReqMsgNumber": min(max(1, req_msg_number), 20),  # 限制1-20
+            }
+            if req_msg_seq is not None:
+                payload["ReqMsgSeq"] = req_msg_seq
+
+            # 发送请求
+            async with httpx.AsyncClient(timeout=10.0) as client:
+                response = await client.post(url, json=payload)
+                response.raise_for_status()
+                result = response.json()
+
+            # 检查返回状态
+            if result.get("ActionStatus") == "OK":
+                return result.get("RspMsgList", [])
+            else:
+                error_info = result.get("ErrorInfo", "未知错误")
+                print(f"获取群消息失败: {error_info}")
+                return []
+
+        except Exception as e:
+            print(f"调用腾讯云IM API失败: {e}")
+            return []
+
+    def parse_message_content(self, msg: dict) -> dict:
+        """
+        解析消息体,提取内容(支持文本、图片、视频、分享)
+
+        过滤规则:
+        - IsSystemMsg == 1: 系统消息 → 返回None
+        - IsPlaceMsg != 0: 占位消息 → 返回None
+        - From_Account == "@TIM#SYSTEM": 系统账号 → 返回None
+
+        Args:
+            msg: 消息对象(从get_group_messages返回)
+
+        Returns:
+            {
+                "type": "text" | "image" | "video" | "share" | "mixed",
+                "text": "文本内容",
+                "images": ["url1", "url2"],  # 图片URL列表
+                "videos": [{"url": "...", "thumb": "..."}],  # 视频信息
+                "share_title": "分享标题"
+            }
+            如果是系统消息,返回None
+        """
+        # 1. 过滤系统消息
+        if msg.get("IsSystemMsg") == 1:
+            return None
+
+        # 2. 过滤占位消息
+        if msg.get("IsPlaceMsg", 0) != 0:
+            return None
+
+        # 3. 过滤系统账号
+        if msg.get("From_Account") == "@TIM#SYSTEM":
+            return None
+
+        # 4. 解析消息内容
+        result = {
+            "type": "text",
+            "text": "",
+            "images": [],
+            "videos": [],
+            "share_title": ""
+        }
+
+        msg_body = msg.get("MsgBody", [])
+        if not msg_body:
+            return None
+
+        for body in msg_body:
+            msg_type = body.get("MsgType")
+            content = body.get("MsgContent", {})
+
+            if msg_type == "TIMTextElem":
+                # 文本消息
+                text = content.get("Text", "")
+                if text:
+                    result["text"] += text
+
+            elif msg_type == "TIMImageElem":
+                # 图片消息
+                image_info = content.get("ImageInfoArray", [])
+                if image_info:
+                    # 取第一个(原图)
+                    result["images"].append(image_info[0].get("URL", ""))
+                    result["type"] = "image" if not result["text"] else "mixed"
+
+            elif msg_type == "TIMVideoFileElem":
+                # 视频消息
+                video_url = content.get("VideoUrl", "")
+                thumb_url = content.get("ThumbUrl", "")
+                if video_url:
+                    result["videos"].append({
+                        "url": video_url,
+                        "thumb": thumb_url
+                    })
+                    result["type"] = "video" if not result["text"] else "mixed"
+
+            elif msg_type == "TIMCustomElem":
+                # 自定义消息(链接分享等)
+                data_str = content.get("Data", "")
+                try:
+                    data = json.loads(data_str)
+                    title = data.get("title", "")
+                    if title:
+                        result["share_title"] = title
+                        result["type"] = "share" if not result["text"] else "mixed"
+                except:
+                    pass
+
+        # 如果没有任何有效内容,返回None
+        if not result["text"] and not result["images"] and not result["videos"] and not result["share_title"]:
+            return None
+
+        return result
+
+    def parse_message_text(self, msg: dict) -> str:
+        """
+        解析消息体,提取文本内容(兼容旧接口)
+
+        Args:
+            msg: 消息对象(从get_group_messages返回)
+
+        Returns:
+            文本内容(多个文本元素会拼接)
+        """
+        parsed = self.parse_message_content(msg)
+        if not parsed:
+            return ""
+        return parsed.get("text", "")
+
+    async def get_group_context(
+        self,
+        group_id: str,
+        max_messages: int = 20,
+        ai_account_map: dict = None
+    ) -> List[dict]:
+        """
+        获取群聊上下文(最近N条消息)
+
+        Args:
+            group_id: 群ID
+            max_messages: 最多获取多少条消息
+            ai_account_map: AI账号映射 {"6537328": "AI企业家", "6587745": "AI知心姐姐"}
+
+        Returns:
+            消息列表,格式化后的结构:
+            [
+                {
+                    "user_id": "6485208",  # 或 "[AI企业家]"
+                    "content": "消息文本",
+                    "images": ["url1"],
+                    "videos": [{"url": "...", "thumb": "..."}],
+                    "share_title": "分享标题",
+                    "timestamp": 1790827391,
+                    "msg_seq": 482
+                },
+                ...
+            ]
+        """
+        if ai_account_map is None:
+            ai_account_map = {
+                "6537328": "AI企业家",
+                "6587745": "AI知心姐姐"
+            }
+
+        raw_messages = await self.get_group_messages(group_id, max_messages)
+
+        # 格式化消息(过滤掉系统消息)
+        formatted = []
+        for msg in raw_messages:
+            parsed = self.parse_message_content(msg)
+            if parsed:  # 只保留有有效内容的消息
+                user_id = msg.get("From_Account", "")
+
+                # 如果是AI账号,标注为AI角色
+                display_id = ai_account_map.get(user_id, user_id)
+                if display_id != user_id:
+                    display_id = f"[{display_id}]"
+
+                formatted.append({
+                    "user_id": display_id,
+                    "content": parsed.get("text", ""),
+                    "images": parsed.get("images", []),
+                    "videos": parsed.get("videos", []),
+                    "share_title": parsed.get("share_title", ""),
+                    "timestamp": msg.get("MsgTimeStamp", 0),
+                    "msg_seq": msg.get("MsgSeq", 0),
+                })
+
+        # 按时间升序排列(最早的在前)
+        formatted.sort(key=lambda x: x["timestamp"])
+
+        return formatted
+
+
+# 全局实例
+im_client = TencentIMClient()

+ 203 - 30
app/routers/chat_tools.py

@@ -6,10 +6,12 @@ from ..config.config import Config
 from ..schemas.chat import ChatMessage, ChatResponse, CommentRequest, CirclePromptConfig, HistoricalFigure, \
 from ..schemas.chat import ChatMessage, ChatResponse, CommentRequest, CirclePromptConfig, HistoricalFigure, \
     RephraseRequest, FigureUpsert, GroupChatRequest, PostCommentBotRequest, RoleBasedChatRequest
     RephraseRequest, FigureUpsert, GroupChatRequest, PostCommentBotRequest, RoleBasedChatRequest
 from ..db.souyue_mongo import get_mblog_by_id
 from ..db.souyue_mongo import get_mblog_by_id
-from ..db.mongo import save_chat_log,get_circle_prompt, upsert_circle_prompt, get_all_figures, get_figure_by_id, insert_figure, update_figure, delete_figure
+from ..db.mongo import save_chat_log,get_circle_prompt, upsert_circle_prompt, get_all_figures, get_figure_by_id, insert_figure, update_figure, delete_figure, \
+    save_group_ai_reply, get_last_group_response_id
 from ..utils.chat_utils import  get_web_search_tools, get_knowledge_search_tools
 from ..utils.chat_utils import  get_web_search_tools, get_knowledge_search_tools
 from ..db.ai_config import get_config_by_app_name
 from ..db.ai_config import get_config_by_app_name
 from ..db.mongo import  save_chat_history, get_chat_history, delete_chat_history, get_sessions, get_last_response_id
 from ..db.mongo import  save_chat_history, get_chat_history, delete_chat_history, get_sessions, get_last_response_id
+from ..db.redis_client import get_cached_group_context, cache_group_context
 router = APIRouter()
 router = APIRouter()
 
 
 import re
 import re
@@ -522,13 +524,71 @@ async def entrepreneur_chat(request: RoleBasedChatRequest):
         client = get_client(request.app_name)
         client = get_client(request.app_name)
         knowledge_id = ai_config.get("knowledgeId")
         knowledge_id = ai_config.get("knowledgeId")
 
 
+        # 【v2.0新增】支持群聊上下文
+        session_id = request.session_id
+        group_id = request.group_id
+        previous_response_id = None
+
+        if session_id:
+            # 获取该AI的上次response_id(必须指定ai_role,防止串线)
+            previous_response_id = get_last_group_response_id(session_id, ai_role='entrepreneur')
+
+        # 【v2.1新增】获取群聊历史上下文(支持多模态)
+        context_messages = []
+        if group_id:
+            from app.db.tencent_im import im_client
+
+            try:
+                # 先尝试从Redis缓存读取
+                group_context = get_cached_group_context(group_id)
+
+                if group_context is None:
+                    # 缓存未命中,调用腾讯云IM API
+                    group_context = await im_client.get_group_context(group_id, max_messages=20)
+
+                    # 写入缓存(2分钟TTL)
+                    if group_context:
+                        cache_group_context(group_id, group_context, ttl=120)
+
+                # 构造多模态消息格式
+                if group_context:
+                    for msg in group_context[-20:]:  # 最近20条
+                        user_id = msg['user_id']
+                        content_parts = []
+
+                        # 添加文本内容
+                        if msg.get('content'):
+                            content_parts.append({"type": "input_text", "text": f"{user_id}: {msg['content']}"})
+
+                        # 添加图片
+                        for img_url in msg.get('images', []):
+                            content_parts.append({"type": "input_image", "image_url": img_url})
+
+                        # 添加视频缩略图
+                        for video in msg.get('videos', []):
+                            if video.get('thumb'):
+                                content_parts.append({"type": "input_image", "image_url": video['thumb']})
+                            content_parts.append({"type": "input_text", "text": "[视频]"})
+
+                        # 添加分享标题
+                        if msg.get('share_title'):
+                            content_parts.append({"type": "input_text", "text": f"[分享]{msg['share_title']}"})
+
+                        # 合并到上下文
+                        if content_parts:
+                            context_messages.extend(content_parts)
+
+            except Exception as e:
+                # 获取群历史失败不影响主流程
+                print(f"获取群历史失败: {e}")
+
         # 企业家角色系统提示词
         # 企业家角色系统提示词
         system_prompt = """
         system_prompt = """
 ## 角色设定
 ## 角色设定
 
 
 你是一位企业家。你在群里的身份是参与者,不是主持人。
 你是一位企业家。你在群里的身份是参与者,不是主持人。
-背景设定:你对云悦的认知是:云悦采用“云平台+共享”模式,强调共创、共享、共赢,核心是技术共享、内容共享、用户共享、商品共享、商业价值共享。其底层逻辑是以区块链技术为核心,打造“融合不整合、跨界不打劫、独立不孤立、拥有不占有”的平台。
-以上背景仅作角色参考。不得声称自己就是某位现实人物,对于云悦的具体数据、客户案例、运营细节或未公开信息,若不掌握,就明确说“不确定”,不编造。
+背景设定:你对云悦的认知是:云悦采用"云平台+共享"模式,强调共创、共享、共赢,核心是技术共享、内容共享、用户共享、商品共享、商业价值共享。其底层逻辑是以区块链技术为核心,打造"融合不整合、跨界不打劫、独立不孤立、拥有不占有"的平台。
+以上背景仅作角色参考。不得声称自己就是某位现实人物,对于云悦的具体数据、客户案例、运营细节或未公开信息,若不掌握,就明确说"不确定",不编造。
 
 
 ## 群聊场景
 ## 群聊场景
 
 
@@ -539,7 +599,7 @@ async def entrepreneur_chat(request: RoleBasedChatRequest):
 遇到以下内容保持沉默,不输出任何文字:
 遇到以下内容保持沉默,不输出任何文字:
 
 
 1. **自杀/自伤倾向**
 1. **自杀/自伤倾向**
-   - “我不想活了””想死””想结束自己”等明确表达
+   - "我不想活了""想死""想结束自己"等明确表达
    - 这超出群聊能力范围,回复反而可能延误专业救助
    - 这超出群聊能力范围,回复反而可能延误专业救助
 
 
 2. **诈骗/违规推广**
 2. **诈骗/违规推广**
@@ -593,7 +653,21 @@ async def entrepreneur_chat(request: RoleBasedChatRequest):
 
 
         # 构造请求消息
         # 构造请求消息
         system_msg = {"role": "system", "content": [{"type": "input_text", "text": system_prompt}]}
         system_msg = {"role": "system", "content": [{"type": "input_text", "text": system_prompt}]}
-        api_messages = [system_msg, {"role": "user", "content": request.message}]
+
+        # 如果有群聊上下文,添加到消息列表
+        user_content = [{"type": "input_text", "text": request.message}]
+
+        if context_messages:
+            # 添加上下文标记
+            context_header = [{"type": "input_text", "text": "【群聊上下文(最近20条消息)】"}]
+            context_footer = [{"type": "input_text", "text": f"\n\n当前用户发送的消息是:"}]
+
+            api_messages = [
+                system_msg,
+                {"role": "user", "content": context_header + context_messages + context_footer + user_content}
+            ]
+        else:
+            api_messages = [system_msg, {"role": "user", "content": user_content}]
 
 
         # 配置工具:联网搜索 + 知识库
         # 配置工具:联网搜索 + 知识库
         tools = get_web_search_tools()
         tools = get_web_search_tools()
@@ -605,7 +679,8 @@ async def entrepreneur_chat(request: RoleBasedChatRequest):
             model=Config.MODEL_NAME,
             model=Config.MODEL_NAME,
             input=api_messages,
             input=api_messages,
             stream=False,
             stream=False,
-            store=False,
+            store=True if session_id else False,  # 【v2.0】有session才存储
+            previous_response_id=previous_response_id,  # 【v2.0】延续上下文
             text={"format": {"type": "text"}},
             text={"format": {"type": "text"}},
             tools=tools,
             tools=tools,
         )
         )
@@ -623,9 +698,23 @@ async def entrepreneur_chat(request: RoleBasedChatRequest):
 
 
         result = _strip_markdown(result)
         result = _strip_markdown(result)
 
 
+        # 【v2.0新增】保存群聊AI回复
+        if session_id and group_id and result:
+            save_group_ai_reply(
+                session_id=session_id,
+                group_id=group_id,
+                ai_role='entrepreneur',
+                content=result,
+                response_id=response.id,
+                trigger_message=request.message,
+                trigger_user_id=request.user_id,
+                timestamp=datetime.now(),
+                app_name=request.app_name,
+            )
+
         if not result:
         if not result:
             save_chat_log(
             save_chat_log(
-                user_id=request.user_id or "anonymous",
+                user_id=request.user_id,
                 question=request.message,
                 question=request.message,
                 stream_mode=False,
                 stream_mode=False,
                 raw_response=repr(response),
                 raw_response=repr(response),
@@ -635,7 +724,7 @@ async def entrepreneur_chat(request: RoleBasedChatRequest):
 
 
         # 记录日志
         # 记录日志
         save_chat_log(
         save_chat_log(
-            user_id=request.user_id or "anonymous",
+            user_id=request.user_id,
             question=request.message,
             question=request.message,
             stream_mode=False,
             stream_mode=False,
             raw_response=repr(response),
             raw_response=repr(response),
@@ -667,36 +756,91 @@ async def femininity_chat(request: RoleBasedChatRequest):
         client = get_client(request.app_name)
         client = get_client(request.app_name)
         knowledge_id = ai_config.get("knowledgeId")
         knowledge_id = ai_config.get("knowledgeId")
 
 
+        # 【v2.0新增】支持群聊上下文
+        session_id = request.session_id
+        group_id = request.group_id
+        previous_response_id = None
+
+        if session_id:
+            # 获取该AI的上次response_id(必须指定ai_role,防止串线)
+            previous_response_id = get_last_group_response_id(session_id, ai_role='femininity')
+
+        # 【v2.1新增】获取群聊历史上下文(支持多模态)
+        context_messages = []
+        if group_id:
+            from app.db.tencent_im import im_client
+
+            try:
+                # 先尝试从Redis缓存读取
+                group_context = get_cached_group_context(group_id)
+
+                if group_context is None:
+                    # 缓存未命中,调用腾讯云IM API
+                    group_context = await im_client.get_group_context(group_id, max_messages=20)
+
+                    # 写入缓存(2分钟TTL)
+                    if group_context:
+                        cache_group_context(group_id, group_context, ttl=120)
+
+                # 构造多模态消息格式
+                if group_context:
+                    for msg in group_context[-20:]:  # 最近20条
+                        user_id = msg['user_id']
+                        content_parts = []
+
+                        # 添加文本内容
+                        if msg.get('content'):
+                            content_parts.append({"type": "input_text", "text": f"{user_id}: {msg['content']}"})
+
+                        # 添加图片
+                        for img_url in msg.get('images', []):
+                            content_parts.append({"type": "input_image", "image_url": img_url})
+
+                        # 添加视频缩略图
+                        for video in msg.get('videos', []):
+                            if video.get('thumb'):
+                                content_parts.append({"type": "input_image", "image_url": video['thumb']})
+                            content_parts.append({"type": "input_text", "text": "[视频]"})
+
+                        # 添加分享标题
+                        if msg.get('share_title'):
+                            content_parts.append({"type": "input_text", "text": f"[分享]{msg['share_title']}"})
+
+                        # 合并到上下文
+                        if content_parts:
+                            context_messages.extend(content_parts)
+
+            except Exception as e:
+                # 获取群历史失败不影响主流程
+                print(f"获取群历史失败: {e}")
+
         # 女性情感角色系统提示词
         # 女性情感角色系统提示词
         system_prompt = """
         system_prompt = """
 ## 角色设定
 ## 角色设定
 
 
 你是一名女性,你在群里的身份是参与者,不是主持人。
 你是一名女性,你在群里的身份是参与者,不是主持人。
-背景设定:你对云悦的认知是:云悦采用“云平台+共享”模式,强调共创、共享、共赢,核心是技术共享、内容共享、用户共享、商品共享、商业价值共享。其底层逻辑是以区块链技术为核心,打造“融合不整合、跨界不打劫、独立不孤立、拥有不占有”的平台。
-以上背景仅作角色参考。不得声称自己就是某位现实人物,对于云悦的具体数据、客户案例、运营细节或未公开信息,若不掌握,就明确说“不确定”,不编造。
+背景设定:你对云悦的认知是:云悦采用"云平台+共享"模式,强调共创、共享、共赢,核心是技术共享、内容共享、用户共享、商品共享、商业价值共享。其底层逻辑是以区块链技术为核心,打造"融合不整合、跨界不打劫、独立不孤立、拥有不占有"的平台。
+以上背景仅作角色参考。不得声称自己就是某位现实人物,对于云悦的具体数据、客户案例、运营细节或未公开信息,若不掌握,就明确说"不确定",不编造。
 
 
 ## 群聊场景
 ## 群聊场景
 
 
-群成员以女性为主,年龄约20—40岁,讨论情感关系、女性成长、心理状态、营养健康、工作压力和生活困惑,也可能讨论涉及经营、管理、市场、产品、技术、融资及创业经历。。
+群成员以女性为主,年龄约20—40岁,讨论情感关系、女性成长、心理状态、营养健康、工作压力和生活困惑,也可能讨论涉及经营、管理、市场、产品、技术、融资及创业经历。
 
 
 ## 不回复的情况(最高优先级)
 ## 不回复的情况(最高优先级)
 
 
 遇到以下内容保持沉默,不输出任何文字:
 遇到以下内容保持沉默,不输出任何文字:
 
 
-1. **自杀/自伤/极端情绪**
-   - 明确表达不想活、想死、想结束自己或自残等意图
-
-2. **亲密关系问题**
-   - 男朋友、老公、伴侣、恋爱、分手、婚姻等相关困惑
-
-3. **诈骗/违规推广**
-   - 承诺高收益、保本翻倍、拉人入群、传销话术、资金盘或未经允许的广告
+1. **自杀/自伤倾向**
+   - "我不想活了""想死""想结束自己"等明确表达
+   - 这超出群聊能力范围,回复反而可能延误专业救助
 
 
-4. **极端内容**
-   - 煽动仇恨、极端政治、宗教争议、暴力恐怖等内容
+2. **诈骗/违规推广**
+   - 承诺高收益、保本翻倍、拉人入群
+   - 传销话术、资金盘、未经允许的广告
 
 
-5. **低价值消息**
-   - 明显的机器人测试、灌水刷屏或没有实际内容的消息
+3. **极端内容**
+   - 煽动仇恨、极端政治、宗教争议
+   - 明显的机器人测试、灌水刷屏
 
 
 遇到以上内容直接保持沉默,不解释为什么不回复。
 遇到以上内容直接保持沉默,不解释为什么不回复。
 
 
@@ -718,15 +862,15 @@ async def femininity_chat(request: RoleBasedChatRequest):
 
 
 1. 不是每条消息都要回复,只在被@、被提问、话题值得补充或群聊冷场、无人回应时发言。
 1. 不是每条消息都要回复,只在被@、被提问、话题值得补充或群聊冷场、无人回应时发言。
 2. 像真实群友一样说话,使用自然口语,不像客服、教材或鸡汤号。
 2. 像真实群友一样说话,使用自然口语,不像客服、教材或鸡汤号。
-3. 不使用固定的“共情—分析—建议”模板,不用空泛安慰、夸张赞美、亲昵称呼或性别刻板印象;不主动抛出新话题。
-4. 简单问题简短回答,不堆叠感叹号,不使用“首先、其次、最后”等机械分条表达。
+3. 不使用固定的"共情—分析—建议"模板,不用空泛安慰、夸张赞美、亲昵称呼或性别刻板印象;不主动抛出新话题。
+4. 简单问题简短回答,不堆叠感叹号,不使用"首先、其次、最后"等机械分条表达。
 
 
 ## 专业边界
 ## 专业边界
 
 
 - 心理学内容用于帮助理解,不进行远程诊断;持续失眠、抑郁、惊恐或进食问题等情况建议寻求线下专业帮助。
 - 心理学内容用于帮助理解,不进行远程诊断;持续失眠、抑郁、惊恐或进食问题等情况建议寻求线下专业帮助。
 - 女性健康和营养问题不作医疗保证;涉及症状、用药、孕期、哺乳、经期异常或慢性病时,提醒咨询医生或营养专业人士。
 - 女性健康和营养问题不作医疗保证;涉及症状、用药、孕期、哺乳、经期异常或慢性病时,提醒咨询医生或营养专业人士。
 - 企业、技术和项目问题给有条件的建议;法律、税务、投资、用工等高风险问题提醒咨询专业人士。
 - 企业、技术和项目问题给有条件的建议;法律、税务、投资、用工等高风险问题提醒咨询专业人士。
-- 保护个人隐私和商业机密,不编造云悦数据、客户案例、项目经历或实时资讯;不确定的信息明确说明“不确定”。
+- 保护个人隐私和商业机密,不编造云悦数据、客户案例、项目经历或实时资讯;不确定的信息明确说明"不确定"。
 
 
 ## 输出格式
 ## 输出格式
 
 
@@ -737,7 +881,21 @@ async def femininity_chat(request: RoleBasedChatRequest):
 
 
         # 构造请求消息
         # 构造请求消息
         system_msg = {"role": "system", "content": [{"type": "input_text", "text": system_prompt}]}
         system_msg = {"role": "system", "content": [{"type": "input_text", "text": system_prompt}]}
-        api_messages = [system_msg, {"role": "user", "content": request.message}]
+
+        # 如果有群聊上下文,添加到消息列表
+        user_content = [{"type": "input_text", "text": request.message}]
+
+        if context_messages:
+            # 添加上下文标记
+            context_header = [{"type": "input_text", "text": "【群聊上下文(最近20条消息)】"}]
+            context_footer = [{"type": "input_text", "text": f"\n\n当前用户发送的消息是:"}]
+
+            api_messages = [
+                system_msg,
+                {"role": "user", "content": context_header + context_messages + context_footer + user_content}
+            ]
+        else:
+            api_messages = [system_msg, {"role": "user", "content": user_content}]
 
 
         # 配置工具:联网搜索 + 知识库
         # 配置工具:联网搜索 + 知识库
         tools = get_web_search_tools()
         tools = get_web_search_tools()
@@ -749,7 +907,8 @@ async def femininity_chat(request: RoleBasedChatRequest):
             model="doubao-seed-2-1-lite-260915",
             model="doubao-seed-2-1-lite-260915",
             input=api_messages,
             input=api_messages,
             stream=False,
             stream=False,
-            store=False,
+            store=True if session_id else False,  # 【v2.0】有session才存储
+            previous_response_id=previous_response_id,  # 【v2.0】延续上下文
             text={"format": {"type": "text"}},
             text={"format": {"type": "text"}},
             tools=tools,
             tools=tools,
         )
         )
@@ -767,9 +926,23 @@ async def femininity_chat(request: RoleBasedChatRequest):
 
 
         result = _strip_markdown(result)
         result = _strip_markdown(result)
 
 
+        # 【v2.0新增】保存群聊AI回复
+        if session_id and group_id and result:
+            save_group_ai_reply(
+                session_id=session_id,
+                group_id=group_id,
+                ai_role='femininity',
+                content=result,
+                response_id=response.id,
+                trigger_message=request.message,
+                trigger_user_id=request.user_id,
+                timestamp=datetime.now(),
+                app_name=request.app_name,
+            )
+
         if not result:
         if not result:
             save_chat_log(
             save_chat_log(
-                user_id=request.user_id or "anonymous",
+                user_id=request.user_id,
                 question=request.message,
                 question=request.message,
                 stream_mode=False,
                 stream_mode=False,
                 raw_response=repr(response),
                 raw_response=repr(response),
@@ -779,7 +952,7 @@ async def femininity_chat(request: RoleBasedChatRequest):
 
 
         # 记录日志
         # 记录日志
         save_chat_log(
         save_chat_log(
-            user_id=request.user_id or "anonymous",
+            user_id=request.user_id,
             question=request.message,
             question=request.message,
             stream_mode=False,
             stream_mode=False,
             raw_response=repr(response),
             raw_response=repr(response),

+ 3 - 1
app/schemas/chat.py

@@ -131,4 +131,6 @@ class PostCommentBotRequest(BaseModel):
 class RoleBasedChatRequest(BaseModel):
 class RoleBasedChatRequest(BaseModel):
     message: str = Field(..., description="用户消息内容")
     message: str = Field(..., description="用户消息内容")
     app_name: str = Field(..., description="应用包名")
     app_name: str = Field(..., description="应用包名")
-    user_id: Optional[str] = Field(None, description="用户编号(可选)")
+    user_id: str = Field(..., description="用户编号")
+    session_id: Optional[str] = Field(None, description="会话ID(群聊AI回复时使用)")
+    group_id: str = Field(..., description="群组ID")

+ 1 - 0
requirements.txt

@@ -11,5 +11,6 @@ requests==2.32.5
 volcengine-python-sdk[ark]==5.0.17
 volcengine-python-sdk[ark]==5.0.17
 httpx==0.28.1
 httpx==0.28.1
 openai==2.30.0
 openai==2.30.0
+tls-sig-api-v2==1.1
 python-multipart==0.0.32
 python-multipart==0.0.32
 websockets>=12.0
 websockets>=12.0