mongo.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. import threading
  2. from pymongo import MongoClient
  3. from bson import ObjectId
  4. from datetime import datetime
  5. from dotenv import load_dotenv
  6. import os
  7. load_dotenv()
  8. MONGO_URI = os.getenv("ARK_LOGS_MONGO_URI")
  9. client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000)
  10. # 数据库
  11. db = client["arklogs"]
  12. # 豆包大模型的对话日志
  13. chat_logs = db["chat_logs"]
  14. # 聊天历史记录
  15. chat_history_col = db["chat_history"]
  16. # 兴趣圈集合
  17. circle_prompts = db["circle_prompt"]
  18. # 历史人物集合
  19. historical_figures = db["historical_figures"]
  20. # 知识库文档上传记录
  21. knowledge_docs = db["knowledge_docs"]
  22. def _ensure_index():
  23. try:
  24. chat_logs.create_index([("user_id", 1), ("asked_at", -1)])
  25. chat_history_col.create_index([("user_id", 1), ("session_id", 1), ("timestamp", -1)])
  26. except Exception:
  27. pass
  28. def _do_save_chat_log(doc: dict):
  29. try:
  30. _ensure_index()
  31. chat_logs.insert_one(doc)
  32. except Exception as e:
  33. print(f"MongoDB 日志写入失败: {e}")
  34. def save_chat_log(
  35. user_id: str,
  36. question: str,
  37. stream_mode: bool,
  38. raw_response: str = None,
  39. status: str = "success",
  40. error: str = None,
  41. ):
  42. """
  43. 运维功能:保存聊天原始响应日志到 MongoDB(异步写入,不阻塞调用方)【日志审计】
  44. Args:
  45. user_id: 提问人
  46. question: 提问的问题
  47. stream_mode: 回答方式(流式或非流式)
  48. raw_response: API 原始响应的 repr 字符串
  49. status: 响应状态 success | error
  50. error: 异常时的错误信息
  51. """
  52. doc = {
  53. "user_id": user_id,
  54. "question": question,
  55. "stream_mode": stream_mode,
  56. "raw_response": raw_response,
  57. "status": status,
  58. "error": error,
  59. "asked_at": datetime.now(),
  60. }
  61. threading.Thread(target=_do_save_chat_log, args=(doc,), daemon=True).start()
  62. def save_chat_history(
  63. user_id: str,
  64. session_id: str,
  65. role: str,
  66. content: str,
  67. timestamp: datetime,
  68. response_id: str = None,
  69. thinking: str = None,
  70. searching: str = None,
  71. attachments: list = None,
  72. input_tokens: int = None,
  73. output_tokens: int = None,
  74. total_tokens: int = None,
  75. app_name:str=None,
  76. ):
  77. """
  78. 业务功能: 保存用户跟chat机器人的聊天记录,用于会话存储
  79. """
  80. try:
  81. _ensure_index()
  82. doc = {
  83. "user_id": user_id,
  84. "session_id": session_id,
  85. "role": role,
  86. "content": content,
  87. "thinking": thinking,
  88. "searching": searching,
  89. "response_id": response_id,
  90. "input_tokens": input_tokens,
  91. "output_tokens": output_tokens,
  92. "total_tokens": total_tokens,
  93. "timestamp": timestamp,
  94. "app_name": app_name,
  95. }
  96. if attachments:
  97. doc["attachments"] = attachments
  98. chat_history_col.insert_one(doc)
  99. except Exception as e:
  100. print(f"MongoDB 聊天历史写入失败: {e}")
  101. def get_chat_history(user_id: str, session_id: str) -> list:
  102. try:
  103. docs = chat_history_col.find(
  104. {"user_id": user_id, "session_id": session_id},
  105. {"_id": 0}
  106. ).sort("timestamp", 1)
  107. return list(docs)
  108. except Exception as e:
  109. print(f"MongoDB 聊天历史读取失败: {e}")
  110. return []
  111. def get_last_response_id(user_id: str, session_id: str) -> str | None:
  112. try:
  113. doc = chat_history_col.find_one(
  114. {"user_id": user_id, "session_id": session_id, "role": "assistant", "response_id": {"$ne": None}},
  115. {"response_id": 1, "_id": 0},
  116. sort=[("timestamp", -1)]
  117. )
  118. return doc["response_id"] if doc else None
  119. except Exception as e:
  120. print(f"MongoDB 查询 response_id 失败: {e}")
  121. return None
  122. def delete_chat_history(user_id: str, session_id: str) -> int:
  123. try:
  124. result = chat_history_col.delete_many({"user_id": user_id, "session_id": session_id})
  125. return result.deleted_count
  126. except Exception as e:
  127. print(f"MongoDB 聊天历史删除失败: {e}")
  128. return 0
  129. def get_sessions(user_id: str) -> list:
  130. try:
  131. pipeline = [
  132. {"$match": {"user_id": user_id, "role": "user"}},
  133. {"$sort": {"timestamp": 1}},
  134. {"$group": {
  135. "_id": "$session_id",
  136. "createdAt": {"$first": "$timestamp"},
  137. "preview": {"$first": "$content"},
  138. }},
  139. {"$sort": {"createdAt": -1}},
  140. {"$project": {
  141. "_id": 0,
  142. "sessionId": "$_id",
  143. "createdAt": 1,
  144. "preview": {"$substrCP": ["$preview", 0, 20]},
  145. }},
  146. ]
  147. return list(chat_history_col.aggregate(pipeline))
  148. except Exception as e:
  149. print(f"MongoDB 会话列表查询失败: {e}")
  150. return []
  151. _DEFAULT_PROMPT_CONFIG = {
  152. "name": "兴趣圈",
  153. "role": "活跃用户",
  154. "style": "自然亲切,有活人感",
  155. "keywords": [],
  156. "forbidden": [],
  157. }
  158. def get_circle_prompt(app_name: str) -> dict:
  159. try:
  160. doc = circle_prompts.find_one({"appName": app_name})
  161. return doc if doc else _DEFAULT_PROMPT_CONFIG
  162. except Exception:
  163. return _DEFAULT_PROMPT_CONFIG
  164. def upsert_circle_prompt(data: dict) -> None:
  165. circle_prompts.update_one(
  166. {"appName": data["appName"]},
  167. {"$set": data},
  168. upsert=True,
  169. )
  170. # ===================== 历史人物 =====================
  171. def get_all_figures() -> list:
  172. try:
  173. docs = historical_figures.find({})
  174. return [{"_id": str(doc["_id"]), **{k: v for k, v in doc.items() if k != "_id"}} for doc in docs]
  175. except Exception as e:
  176. print(f"MongoDB 历史人物列表查询失败: {e}")
  177. return []
  178. def get_figure_by_id(figure_id: str) -> dict | None:
  179. try:
  180. doc = historical_figures.find_one({"_id": ObjectId(figure_id)})
  181. if doc:
  182. doc["_id"] = str(doc["_id"])
  183. return doc
  184. except Exception as e:
  185. print(f"MongoDB 历史人物查询失败: {e}")
  186. return None
  187. def insert_figure(data: dict) -> str:
  188. try:
  189. result = historical_figures.insert_one(data)
  190. return str(result.inserted_id)
  191. except Exception as e:
  192. print(f"MongoDB 历史人物新增失败: {e}")
  193. return None
  194. def update_figure(figure_id: str, data: dict) -> int:
  195. try:
  196. result = historical_figures.update_one(
  197. {"_id": ObjectId(figure_id)},
  198. {"$set": data},
  199. )
  200. return result.matched_count
  201. except Exception as e:
  202. print(f"MongoDB 历史人物修改失败: {e}")
  203. return 0
  204. def delete_figure(figure_id: str) -> int:
  205. try:
  206. result = historical_figures.delete_one({"_id": ObjectId(figure_id)})
  207. return result.deleted_count
  208. except Exception as e:
  209. print(f"MongoDB 历史人物删除失败: {e}")
  210. return 0
  211. # ===================== 群聊AI回复历史 =====================
  212. # 群聊AI回复记录集合(只存AI回复,不存群消息)
  213. group_chat_history_col = db["group_chat_history"]
  214. def _ensure_group_chat_index():
  215. try:
  216. group_chat_history_col.create_index([("session_id", 1), ("timestamp", -1)])
  217. group_chat_history_col.create_index([("group_id", 1), ("ai_role", 1), ("timestamp", -1)])
  218. except Exception:
  219. pass
  220. def save_group_ai_reply(
  221. session_id: str,
  222. group_id: str,
  223. ai_role: str,
  224. content: str,
  225. response_id: str,
  226. trigger_message: str,
  227. trigger_user_id: str,
  228. timestamp: datetime,
  229. app_name: str = "souyue_im",
  230. ):
  231. """
  232. 业务功能:保存AI在群聊中的回复
  233. 注意:只存储AI回复,不存储群消息(群消息通过腾讯云API获取)
  234. Args:
  235. session_id: 会话ID(群ID + 小时窗口)
  236. group_id: 群ID
  237. ai_role: AI角色(entrepreneur | femininity)
  238. content: AI回复内容
  239. response_id: 大模型response ID
  240. trigger_message: 触发AI的原始消息
  241. trigger_user_id: 触发用户ID
  242. timestamp: 时间戳
  243. app_name: 应用名称
  244. """
  245. try:
  246. _ensure_group_chat_index()
  247. doc = {
  248. "session_id": session_id,
  249. "group_id": group_id,
  250. "role": "assistant",
  251. "user_id": f"ai_{ai_role}",
  252. "content": content,
  253. "ai_role": ai_role,
  254. "response_id": response_id,
  255. "trigger_message": trigger_message,
  256. "trigger_user_id": trigger_user_id,
  257. "timestamp": timestamp,
  258. "app_name": app_name,
  259. }
  260. group_chat_history_col.insert_one(doc)
  261. except Exception as e:
  262. print(f"MongoDB 群聊AI回复写入失败: {e}")
  263. def get_last_group_response_id(session_id: str, ai_role: str) -> str | None:
  264. """
  265. 获取指定AI的最后一次回复的response_id
  266. Args:
  267. session_id: 会话ID
  268. ai_role: AI角色(必须指定,防止串线)
  269. Returns:
  270. response_id 或 None
  271. """
  272. try:
  273. doc = group_chat_history_col.find_one(
  274. {
  275. "session_id": session_id,
  276. "ai_role": ai_role,
  277. "response_id": {"$ne": None}
  278. },
  279. {"response_id": 1, "_id": 0},
  280. sort=[("timestamp", -1)]
  281. )
  282. return doc["response_id"] if doc else None
  283. except Exception as e:
  284. print(f"MongoDB 查询群聊 response_id 失败: {e}")
  285. return None
  286. def get_group_ai_reply_count(session_id: str, ai_role: str = None) -> int:
  287. """
  288. 获取会话中的AI回复次数
  289. Args:
  290. session_id: 会话ID
  291. ai_role: AI角色(可选,不传则统计所有AI)
  292. Returns:
  293. 回复次数
  294. """
  295. try:
  296. query = {"session_id": session_id}
  297. if ai_role:
  298. query["ai_role"] = ai_role
  299. return group_chat_history_col.count_documents(query)
  300. except Exception as e:
  301. print(f"MongoDB 查询AI回复次数失败: {e}")
  302. return 0
  303. # ==================== 知识库文档上传记录 ====================
  304. def _ensure_knowledge_docs_index():
  305. try:
  306. knowledge_docs.create_index([("app_name", 1), ("created_at", -1)])
  307. knowledge_docs.create_index([("doc_id", 1)], unique=True)
  308. except Exception:
  309. pass
  310. def save_knowledge_doc(doc_data: dict) -> str | None:
  311. """
  312. 保存知识库文档上传记录,返回插入的 ObjectId 字符串。
  313. doc_data 应包含:doc_id, resource_id, collection_name, project,
  314. doc_name, doc_type, app_name
  315. """
  316. try:
  317. _ensure_knowledge_docs_index()
  318. doc_data["created_at"] = datetime.now()
  319. result = knowledge_docs.insert_one(doc_data)
  320. return str(result.inserted_id)
  321. except Exception as e:
  322. print(f"MongoDB 知识库文档记录写入失败: {e}")
  323. return None
  324. def get_knowledge_docs(app_name: str, page: int = 1, page_size: int = 20) -> dict:
  325. """
  326. 分页查询知识库文档上传记录。
  327. 返回 {"total": int, "page": int, "page_size": int, "list": [...]}
  328. """
  329. try:
  330. _ensure_knowledge_docs_index()
  331. query = {"app_name": app_name}
  332. total = knowledge_docs.count_documents(query)
  333. skip = (page - 1) * page_size
  334. docs = list(
  335. knowledge_docs.find(query, {"_id": 1, "doc_id": 1, "resource_id": 1,
  336. "collection_name": 1, "project": 1,
  337. "doc_name": 1, "doc_type": 1,
  338. "app_name": 1, "created_at": 1})
  339. .sort("created_at", -1)
  340. .skip(skip)
  341. .limit(page_size)
  342. )
  343. for doc in docs:
  344. doc["id"] = str(doc.pop("_id"))
  345. if "created_at" in doc:
  346. doc["created_at"] = doc["created_at"].strftime("%Y-%m-%d %H:%M:%S")
  347. return {"total": total, "page": page, "page_size": page_size, "list": docs}
  348. except Exception as e:
  349. print(f"MongoDB 知识库文档记录查询失败: {e}")
  350. return {"total": 0, "page": page, "page_size": page_size, "list": []}
  351. def get_knowledge_doc_by_doc_id(doc_id: str) -> dict | None:
  352. """
  353. 按 doc_id 查询单条知识库文档记录。
  354. 返回文档 dict(含 id 字符串字段),未找到返回 None。
  355. """
  356. try:
  357. doc = knowledge_docs.find_one({"doc_id": doc_id})
  358. if doc:
  359. doc["id"] = str(doc.pop("_id"))
  360. return doc
  361. except Exception as e:
  362. print(f"MongoDB 知识库文档记录查询失败: {e}")
  363. return None
  364. def delete_knowledge_doc(doc_id: str) -> bool:
  365. """
  366. 按 doc_id 删除知识库文档记录。
  367. 返回 True 表示成功删除,False 表示记录不存在或删除异常。
  368. """
  369. try:
  370. result = knowledge_docs.delete_one({"doc_id": doc_id})
  371. return result.deleted_count > 0
  372. except Exception as e:
  373. print(f"MongoDB 知识库文档记录删除失败: {e}")
  374. return False
  375. # ==================== 用户长期记忆 ====================
  376. # facts 使用数组结构 [{key, value, updated_at}],避免动态字段名带来的 Mongo 注入风险
  377. user_memory_col = db["user_memory"]
  378. user_memory_events_col = db["user_memory_events"]
  379. _ALLOWED_FACT_KEYS = {"行业", "市场区域", "职能背景", "痛点", "目标", "产品", "融资阶段"}
  380. _MAX_FACT_VALUE_LEN = 100
  381. _MAX_EVENT_LEN = 200
  382. def _ensure_user_memory_index():
  383. try:
  384. user_memory_col.create_index(
  385. [("user_id", 1), ("group_id", 1), ("ai_role", 1)], unique=True
  386. )
  387. user_memory_events_col.create_index(
  388. [("user_id", 1), ("group_id", 1), ("timestamp", -1)]
  389. )
  390. user_memory_events_col.create_index([("user_id", 1), ("keywords", 1)])
  391. except Exception:
  392. pass
  393. def get_user_memory(user_id: str, group_id: str, ai_role: str) -> dict | None:
  394. """查询用户在指定群和角色下的记忆文档。"""
  395. try:
  396. _ensure_user_memory_index()
  397. return user_memory_col.find_one(
  398. {"user_id": user_id, "group_id": group_id, "ai_role": ai_role},
  399. {"_id": 0},
  400. )
  401. except Exception as e:
  402. print(f"MongoDB 查询用户记忆失败: {e}")
  403. return None
  404. def upsert_user_fact(user_id: str, group_id: str, ai_role: str, key: str, value: str) -> None:
  405. """
  406. 更新或新增用户事实(数组结构),按 key 匹配更新。
  407. key 必须在白名单内;value 超长截断。
  408. interaction_count 不在此处自增,由调用方统一自增一次。
  409. """
  410. if key not in _ALLOWED_FACT_KEYS:
  411. return
  412. value = str(value)[:_MAX_FACT_VALUE_LEN]
  413. try:
  414. _ensure_user_memory_index()
  415. # 先尝试更新已有 key
  416. result = user_memory_col.update_one(
  417. {"user_id": user_id, "group_id": group_id, "ai_role": ai_role,
  418. "facts": {"$elemMatch": {"key": key}}},
  419. {"$set": {"facts.$.value": value, "facts.$.updated_at": datetime.now(),
  420. "last_active": datetime.now()}},
  421. )
  422. if result.matched_count == 0:
  423. # key 不存在,追加到数组
  424. user_memory_col.update_one(
  425. {"user_id": user_id, "group_id": group_id, "ai_role": ai_role},
  426. {
  427. "$push": {"facts": {"key": key, "value": value, "updated_at": datetime.now()}},
  428. "$set": {"last_active": datetime.now()},
  429. "$setOnInsert": {"created_at": datetime.now(), "interaction_count": 0},
  430. },
  431. upsert=True,
  432. )
  433. except Exception as e:
  434. print(f"MongoDB 更新用户事实失败(key={key}): {e}")
  435. def increment_interaction_count(user_id: str, group_id: str, ai_role: str) -> None:
  436. """每次 _extract_memory 调用成功后,统一自增一次互动次数。"""
  437. try:
  438. user_memory_col.update_one(
  439. {"user_id": user_id, "group_id": group_id, "ai_role": ai_role},
  440. {
  441. "$inc": {"interaction_count": 1},
  442. "$set": {"last_active": datetime.now()},
  443. "$setOnInsert": {"created_at": datetime.now(), "facts": []},
  444. },
  445. upsert=True,
  446. )
  447. except Exception as e:
  448. print(f"MongoDB 自增互动次数失败: {e}")
  449. def append_memory_event(
  450. user_id: str, group_id: str, ai_role: str,
  451. event: str, keywords: list, trigger_msg_id: str | None = None,
  452. ) -> None:
  453. """追加场景记忆事件。"""
  454. event = str(event)[:_MAX_EVENT_LEN]
  455. try:
  456. _ensure_user_memory_index()
  457. user_memory_events_col.insert_one({
  458. "user_id": user_id, "group_id": group_id, "ai_role": ai_role,
  459. "event": event,
  460. "keywords": [str(k)[:30] for k in keywords[:10]],
  461. "trigger_message_id": trigger_msg_id,
  462. "timestamp": datetime.now(),
  463. })
  464. except Exception as e:
  465. print(f"MongoDB 追加记忆事件失败: {e}")
  466. def search_memory_events(
  467. user_id: str, group_id: str, keywords: list, limit: int = 5,
  468. ) -> list:
  469. """按关键词检索近期场景记忆事件,按时间倒序返回。"""
  470. if not keywords:
  471. return []
  472. try:
  473. docs = user_memory_events_col.find(
  474. {"user_id": user_id, "group_id": group_id,
  475. "keywords": {"$in": [str(k)[:30] for k in keywords[:10]]}},
  476. {"_id": 0, "event": 1, "timestamp": 1},
  477. ).sort("timestamp", -1).limit(limit)
  478. return list(docs)
  479. except Exception as e:
  480. print(f"MongoDB 检索记忆事件失败: {e}")
  481. return []