|
@@ -1,3 +1,5 @@
|
|
|
|
|
+import asyncio
|
|
|
|
|
+import json
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from fastapi import APIRouter, HTTPException
|
|
|
from datetime import datetime
|
|
from datetime import datetime
|
|
|
|
|
|
|
@@ -6,12 +8,14 @@ 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, \
|
|
|
|
|
- save_group_ai_reply, get_last_group_response_id
|
|
|
|
|
-from ..utils.chat_utils import get_web_search_tools, get_knowledge_search_tools
|
|
|
|
|
|
|
+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, \
|
|
|
|
|
+ get_user_memory, upsert_user_fact, append_memory_event, increment_interaction_count
|
|
|
|
|
+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.redis_client import get_cached_group_context, cache_group_context
|
|
|
|
|
|
|
+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, update_group_mood, build_mood_block
|
|
|
|
|
+from ..core.viking_client import search_knowledge
|
|
|
router = APIRouter()
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
import re
|
|
import re
|
|
@@ -70,6 +74,63 @@ def _should_block_role_message(message: str) -> bool:
|
|
|
return any(keyword in text for group in groups for keyword in group)
|
|
return any(keyword in text for group in groups for keyword in group)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+async def _extract_memory(
|
|
|
|
|
+ user_id: str, group_id: str, ai_role: str,
|
|
|
|
|
+ trigger_msg: str, ai_reply: str, app_name: str,
|
|
|
|
|
+) -> None:
|
|
|
|
|
+ """
|
|
|
|
|
+ 回复成功后异步抽取用户记忆,fire-and-forget。
|
|
|
|
|
+ 用小模型从对话中提取结构化事实和关键事件,失败仅打印日志不影响主流程。
|
|
|
|
|
+ interaction_count 在此处统一自增一次(不在每条 fact 上重复自增)。
|
|
|
|
|
+ """
|
|
|
|
|
+ try:
|
|
|
|
|
+ extract_prompt = (
|
|
|
|
|
+ f"从以下群聊交互中抽取值得长期记住的用户事实和关键事件。\n"
|
|
|
|
|
+ f"用户消息:{trigger_msg}\nAI回复:{ai_reply}\n"
|
|
|
|
|
+ f"只从用户明确陈述的内容中抽取,不要推断或补充。\n"
|
|
|
|
|
+ f"可识别的事实类型:行业、市场区域、职能背景、痛点、目标、产品、融资阶段。\n"
|
|
|
|
|
+ f"输出严格JSON:{{\"facts\":{{\"行业\":\"\",\"市场区域\":\"\"}},\"events\":[\"事件描述\"],\"keywords\":[\"关键词\"]}}。\n"
|
|
|
|
|
+ f"无有价值信息则输出:{{\"facts\":{{}},\"events\":[],\"keywords\":[]}}"
|
|
|
|
|
+ )
|
|
|
|
|
+ client = get_client(app_name)
|
|
|
|
|
+ resp = client.responses.create(
|
|
|
|
|
+ model="doubao-seed-2-1-lite-260915",
|
|
|
|
|
+ input=[{"role": "user", "content": extract_prompt}],
|
|
|
|
|
+ stream=False,
|
|
|
|
|
+ store=False,
|
|
|
|
|
+ text={"format": {"type": "json_object"}},
|
|
|
|
|
+ )
|
|
|
|
|
+ raw_text = ""
|
|
|
|
|
+ for item in resp.output:
|
|
|
|
|
+ if hasattr(item, "type") and item.type == "message" and hasattr(item, "content"):
|
|
|
|
|
+ if isinstance(item.content, list):
|
|
|
|
|
+ for c in item.content:
|
|
|
|
|
+ if hasattr(c, "text"):
|
|
|
|
|
+ raw_text += c.text
|
|
|
|
|
+ else:
|
|
|
|
|
+ raw_text += str(item.content)
|
|
|
|
|
+
|
|
|
|
|
+ if not raw_text.strip():
|
|
|
|
|
+ return
|
|
|
|
|
+ data = json.loads(raw_text)
|
|
|
|
|
+
|
|
|
|
|
+ # 写入事实(每条 fact 只 upsert,不自增计数)
|
|
|
|
|
+ for k, v in data.get("facts", {}).items():
|
|
|
|
|
+ if v:
|
|
|
|
|
+ upsert_user_fact(user_id, group_id, ai_role, k, str(v))
|
|
|
|
|
+
|
|
|
|
|
+ # 写入场景事件
|
|
|
|
|
+ for ev in data.get("events", []):
|
|
|
|
|
+ if ev:
|
|
|
|
|
+ append_memory_event(user_id, group_id, ai_role, ev, data.get("keywords", []))
|
|
|
|
|
+
|
|
|
|
|
+ # 整次 _extract_memory 调用只自增一次互动次数
|
|
|
|
|
+ increment_interaction_count(user_id, group_id, ai_role)
|
|
|
|
|
+
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ print(f"记忆抽取失败(user={user_id}): {e}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def _build_prompt(product_text: str, prompt_config: dict) -> str:
|
|
def _build_prompt(product_text: str, prompt_config: dict) -> str:
|
|
|
name = prompt_config.get("name", "兴趣圈")
|
|
name = prompt_config.get("name", "兴趣圈")
|
|
|
role = prompt_config.get("role", "活跃用户")
|
|
role = prompt_config.get("role", "活跃用户")
|
|
@@ -550,15 +611,16 @@ async def entrepreneur_chat(request: RoleBasedChatRequest):
|
|
|
if group_context:
|
|
if group_context:
|
|
|
cache_group_context(group_id, group_context, ttl=120)
|
|
cache_group_context(group_id, group_context, ttl=120)
|
|
|
|
|
|
|
|
- # 构造多模态消息格式
|
|
|
|
|
|
|
+ # 构造多模态消息格式,同步更新群情绪状态(去重由 update_group_mood 内部处理)
|
|
|
if group_context:
|
|
if group_context:
|
|
|
for msg in group_context[-20:]: # 最近20条
|
|
for msg in group_context[-20:]: # 最近20条
|
|
|
- user_id = msg['user_id']
|
|
|
|
|
|
|
+ msg_user_id = msg['user_id']
|
|
|
content_parts = []
|
|
content_parts = []
|
|
|
|
|
|
|
|
- # 添加文本内容
|
|
|
|
|
|
|
+ # 添加文本内容,并更新群情绪
|
|
|
if msg.get('content'):
|
|
if msg.get('content'):
|
|
|
- content_parts.append({"type": "input_text", "text": f"{user_id}: {msg['content']}"})
|
|
|
|
|
|
|
+ content_parts.append({"type": "input_text", "text": f"{msg_user_id}: {msg['content']}"})
|
|
|
|
|
+ update_group_mood(group_id, msg_user_id, msg['content'])
|
|
|
|
|
|
|
|
# 添加图片
|
|
# 添加图片
|
|
|
for img_url in msg.get('images', []):
|
|
for img_url in msg.get('images', []):
|
|
@@ -651,6 +713,24 @@ async def entrepreneur_chat(request: RoleBasedChatRequest):
|
|
|
如果不需要回应,保持沉默(不输出任何内容)。
|
|
如果不需要回应,保持沉默(不输出任何内容)。
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
|
|
+ # 【v3.0新增】注入用户长期记忆(已确认的事实背景)
|
|
|
|
|
+ mem = get_user_memory(request.user_id, group_id, "entrepreneur")
|
|
|
|
|
+ if mem and mem.get("facts"):
|
|
|
|
|
+ fact_lines = [
|
|
|
|
|
+ f"- {f['key']}: {f['value']}"
|
|
|
|
|
+ for f in mem["facts"]
|
|
|
|
|
+ if isinstance(f, dict) and f.get("key") and f.get("value")
|
|
|
|
|
+ ]
|
|
|
|
|
+ if fact_lines:
|
|
|
|
|
+ system_prompt += (
|
|
|
|
|
+ "\n\n## 你对当前发言用户的已知背景\n"
|
|
|
|
|
+ + "\n".join(fact_lines)
|
|
|
|
|
+ + "\n参照这些背景给出更具体的建议,但不要生硬复述。"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 【v3.0新增】注入群情绪状态
|
|
|
|
|
+ system_prompt += build_mood_block(group_id)
|
|
|
|
|
+
|
|
|
# 构造请求消息
|
|
# 构造请求消息
|
|
|
system_msg = {"role": "system", "content": [{"type": "input_text", "text": system_prompt}]}
|
|
system_msg = {"role": "system", "content": [{"type": "input_text", "text": system_prompt}]}
|
|
|
|
|
|
|
@@ -669,8 +749,37 @@ async def entrepreneur_chat(request: RoleBasedChatRequest):
|
|
|
else:
|
|
else:
|
|
|
api_messages = [system_msg, {"role": "user", "content": user_content}]
|
|
api_messages = [system_msg, {"role": "user", "content": user_content}]
|
|
|
|
|
|
|
|
- # 配置工具:联网搜索 + 知识库
|
|
|
|
|
- tools = get_knowledge_search_tools(knowledge_id,request.app_name)
|
|
|
|
|
|
|
+ # 【v3.0新增】知识库预检:直接调用 Viking SDK 检索
|
|
|
|
|
+ # 有命中 → 将切片内容作为上下文注入,不挂 Ark 知识库工具
|
|
|
|
|
+ # 无命中 → 挂联网搜索工具
|
|
|
|
|
+ tools = []
|
|
|
|
|
+ if knowledge_id:
|
|
|
|
|
+ try:
|
|
|
|
|
+ kb_results = await search_knowledge(
|
|
|
|
|
+ resource_id=knowledge_id,
|
|
|
|
|
+ query=request.message,
|
|
|
|
|
+ limit=5,
|
|
|
|
|
+ )
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ print(f"知识库预检失败,降级到联网搜索: {e}")
|
|
|
|
|
+ kb_results = []
|
|
|
|
|
+ else:
|
|
|
|
|
+ kb_results = []
|
|
|
|
|
+
|
|
|
|
|
+ if kb_results:
|
|
|
|
|
+ # 提取切片内容,拼接成上下文段落
|
|
|
|
|
+ kb_texts = [r.content for r in kb_results if r.content]
|
|
|
|
|
+ if kb_texts:
|
|
|
|
|
+ kb_context_block = [{"type": "input_text", "text": "【知识库参考资料】\n" + "\n\n".join(kb_texts) + "\n\n请优先基于以上资料回答,资料中没有的内容不要编造。"}]
|
|
|
|
|
+ # 将知识库上下文插入到 user content 前面
|
|
|
|
|
+ if context_messages:
|
|
|
|
|
+ api_messages[-1]["content"] = kb_context_block + api_messages[-1]["content"]
|
|
|
|
|
+ else:
|
|
|
|
|
+ api_messages[-1]["content"] = kb_context_block + user_content
|
|
|
|
|
+ # 有知识库命中时不挂任何工具
|
|
|
|
|
+ else:
|
|
|
|
|
+ # 无命中,降级到联网搜索
|
|
|
|
|
+ tools = get_web_search_tools()
|
|
|
|
|
|
|
|
# 调用 AI 客户端
|
|
# 调用 AI 客户端
|
|
|
response = client.responses.create(
|
|
response = client.responses.create(
|
|
@@ -710,6 +819,17 @@ async def entrepreneur_chat(request: RoleBasedChatRequest):
|
|
|
app_name=request.app_name,
|
|
app_name=request.app_name,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+ # 【v3.0新增】异步抽取用户记忆(fire-and-forget,失败不影响主流程)
|
|
|
|
|
+ if result:
|
|
|
|
|
+ asyncio.create_task(_extract_memory(
|
|
|
|
|
+ user_id=request.user_id,
|
|
|
|
|
+ group_id=group_id,
|
|
|
|
|
+ ai_role="entrepreneur",
|
|
|
|
|
+ trigger_msg=request.message,
|
|
|
|
|
+ ai_reply=result,
|
|
|
|
|
+ app_name=request.app_name,
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
if not result:
|
|
if not result:
|
|
|
save_chat_log(
|
|
save_chat_log(
|
|
|
user_id=request.user_id,
|
|
user_id=request.user_id,
|