dialogue_manager.py 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859
  1. #! /usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. # vim:fenc=utf-8
  4. import random
  5. from enum import Enum
  6. from typing import Dict, List, Optional, Tuple, Any
  7. from datetime import datetime
  8. import time
  9. import textwrap
  10. import pymysql.cursors
  11. import cozepy
  12. from sqlalchemy.orm import sessionmaker, Session
  13. from pqai_agent import configs
  14. from pqai_agent.clients.relation_stage_client import RelationStageClient
  15. from pqai_agent.data_models.agent_push_record import AgentPushRecord
  16. from pqai_agent.logging_service import logger
  17. from pqai_agent.database import MySQLManager
  18. from pqai_agent import chat_service, prompt_templates
  19. from pqai_agent.history_dialogue_service import HistoryDialogueService
  20. from pqai_agent.chat_service import ChatServiceType
  21. from pqai_agent.mq_message import MessageType, MqMessage
  22. from pqai_agent.toolkit.lark_alert_for_human_intervention import LarkAlertForHumanIntervention
  23. from pqai_agent.toolkit.lark_sheet_record_for_human_intervention import LarkSheetRecordForHumanIntervention
  24. from pqai_agent.user_manager import UserManager
  25. from pqai_agent.utils import prompt_utils
  26. class DummyVectorMemoryManager:
  27. def __init__(self, user_id):
  28. pass
  29. def add_to_memory(self, conversation):
  30. pass
  31. def retrieve_relevant_memories(self, query, k=3):
  32. return []
  33. class DialogueState(int, Enum):
  34. INITIALIZED = 0
  35. GREETING = 1 # 问候状态
  36. CHITCHAT = 2 # 闲聊状态
  37. CLARIFICATION = 3 # 澄清状态
  38. FAREWELL = 4 # 告别状态
  39. HUMAN_INTERVENTION = 5 # 人工介入状态
  40. MESSAGE_AGGREGATING = 6 # 等待消息状态
  41. class TimeContext(Enum):
  42. EARLY_MORNING = "清晨" # 清晨 (5:00-7:59)
  43. MORNING = "上午" # 上午 (8:00-11:59)
  44. NOON = "中午" # 中午 (12:00-13:59)
  45. AFTERNOON = "下午" # 下午 (14:00-17:59)
  46. EVENING = "晚上" # 晚上 (18:00-21:59)
  47. NIGHT = "深夜" # 夜晚 (22:00-4:59)
  48. def __init__(self, description):
  49. self.description = description
  50. class DialogueStateChangeType(int, Enum):
  51. STATE = 0
  52. INTERACTION_TIME = 1
  53. DIALOGUE_HISTORY = 2
  54. class DialogueStateChange:
  55. def __init__(self, event_type: DialogueStateChangeType,old: Any, new: Any):
  56. self.event_type = event_type
  57. self.old = old
  58. self.new = new
  59. class DialogueStateCache:
  60. def __init__(self):
  61. self.config = configs.get()
  62. self.db = MySQLManager(self.config['database']['ai_agent'])
  63. self.table = self.config['storage']['agent_state']['table']
  64. def get_state(self, staff_id: str, user_id: str) -> Tuple[DialogueState, DialogueState]:
  65. query = f"SELECT current_state, previous_state FROM {self.table} WHERE staff_id=%s AND user_id=%s"
  66. data = self.db.select(query, pymysql.cursors.DictCursor, (staff_id, user_id))
  67. if not data:
  68. logger.warning(f"staff[{staff_id}], user[{user_id}]: agent state not found")
  69. state = DialogueState.INITIALIZED
  70. previous_state = DialogueState.INITIALIZED
  71. self.set_state(staff_id, user_id, state, previous_state)
  72. else:
  73. state = DialogueState(data[0]['current_state'])
  74. previous_state = DialogueState(data[0]['previous_state'])
  75. return state, previous_state
  76. def set_state(self, staff_id: str, user_id: str, state: DialogueState, previous_state: DialogueState):
  77. if self.config.get('debug_flags', {}).get('disable_database_write', False):
  78. return
  79. query = f"INSERT INTO {self.table} (staff_id, user_id, current_state, previous_state)" \
  80. f" VALUES (%s, %s, %s, %s) " \
  81. f"ON DUPLICATE KEY UPDATE current_state=%s, previous_state=%s"
  82. rows = self.db.execute(query, (staff_id, user_id, state.value, previous_state.value, state.value, previous_state.value))
  83. logger.debug("staff[{}], user[{}]: set state: {}, previous state: {}, rows affected: {}"
  84. .format(staff_id, user_id, state, previous_state, rows))
  85. class DialogueManager:
  86. def __init__(self, staff_id: str, user_id: str, user_manager: UserManager, state_cache: DialogueStateCache,
  87. agent_db_session_maker: sessionmaker[Session]):
  88. config = configs.get()
  89. self.staff_id = staff_id
  90. self.user_id = user_id
  91. self.user_manager = user_manager
  92. self.state_cache = state_cache
  93. self.current_state = DialogueState.GREETING
  94. self.previous_state = DialogueState.INITIALIZED
  95. # 目前实际仅用作调试,拼装prompt时使用history_dialogue_service获取
  96. self.dialogue_history = []
  97. self.user_profile = self.user_manager.get_user_profile(user_id)
  98. self.staff_profile = self.user_manager.get_staff_profile(staff_id)
  99. # FIXME: 交互时间和对话记录都涉及到回滚
  100. self.last_interaction_time_ms = 0
  101. self.last_active_interaction_time_sec = 0
  102. self.human_intervention_triggered = False
  103. self.vector_memory = DummyVectorMemoryManager(user_id)
  104. self.message_aggregation_sec = config.get('agent_behavior', {}).get('message_aggregation_sec', 5)
  105. self.unprocessed_messages = []
  106. self.history_dialogue_service = HistoryDialogueService(
  107. config['storage']['history_dialogue']['api_base_url']
  108. )
  109. # FIXME: 实际为无状态接口,不需要每个DialogueManager持有一个单独实例
  110. self.relation_stage_client = RelationStageClient()
  111. self.relation_stage = self.relation_stage_client.get_relation_stage(staff_id, user_id)
  112. self.agent_db_session_maker = agent_db_session_maker
  113. self._recover_state()
  114. # 由于本地状态管理过于复杂,引入事务机制做状态回滚
  115. self._uncommited_state_change = []
  116. @staticmethod
  117. def get_time_context(current_hour=None) -> TimeContext:
  118. """获取当前时间上下文"""
  119. if not current_hour:
  120. current_hour = datetime.now().hour
  121. if 5 <= current_hour < 7:
  122. return TimeContext.EARLY_MORNING
  123. elif 7 <= current_hour < 11:
  124. return TimeContext.MORNING
  125. elif 11 <= current_hour < 14:
  126. return TimeContext.NOON
  127. elif 14 <= current_hour < 18:
  128. return TimeContext.AFTERNOON
  129. elif 18 <= current_hour < 22:
  130. return TimeContext.EVENING
  131. else:
  132. return TimeContext.NIGHT
  133. def is_valid(self):
  134. if not self.staff_profile.get('name', None) and not self.staff_profile.get('agent_name', None):
  135. return False
  136. return True
  137. def refresh_profile(self):
  138. self.staff_profile = self.user_manager.get_staff_profile(self.staff_id)
  139. relation_stage = self.relation_stage_client.get_relation_stage(self.staff_id, self.user_id)
  140. if relation_stage and relation_stage != self.relation_stage:
  141. logger.info(f"staff[{self.staff_id}], user[{self.user_id}]: relation stage changed from {self.relation_stage} to {relation_stage}")
  142. self.relation_stage = relation_stage
  143. def _recover_state(self):
  144. self.current_state, self.previous_state = self.state_cache.get_state(self.staff_id, self.user_id)
  145. # 从数据库恢复对话状态
  146. minutes_to_get = 5 * 24 * 60
  147. self.dialogue_history = self.history_dialogue_service.get_dialogue_history(
  148. self.staff_id, self.user_id, minutes_to_get)
  149. if self.dialogue_history:
  150. self.last_interaction_time_ms = self.dialogue_history[-1]['timestamp']
  151. if self.current_state == DialogueState.MESSAGE_AGGREGATING:
  152. # 需要恢复未处理对话,找到dialogue_history中最后未处理的user消息
  153. for entry in reversed(self.dialogue_history):
  154. if entry['role'] == 'user':
  155. self.unprocessed_messages.append(entry['content'])
  156. break
  157. else:
  158. # 默认设置
  159. self.last_interaction_time_ms = int(time.time() * 1000) - minutes_to_get * 60 * 1000
  160. with self.agent_db_session_maker() as session:
  161. # 读取数据库中的最后一次交互时间
  162. query = session.query(AgentPushRecord).filter(
  163. AgentPushRecord.staff_id == self.staff_id,
  164. AgentPushRecord.user_id == self.user_id
  165. ).order_by(AgentPushRecord.timestamp.desc()).first()
  166. if query:
  167. self.last_active_interaction_time_sec = query.timestamp
  168. fmt_time = datetime.fromtimestamp(self.last_interaction_time_ms / 1000).strftime("%Y-%m-%d %H:%M:%S")
  169. logger.debug(f"staff[{self.staff_id}], user[{self.user_id}]: state: {self.current_state.name}, last_interaction: {fmt_time}")
  170. def update_interaction_time(self, timestamp_ms: int):
  171. self._uncommited_state_change.append(DialogueStateChange(
  172. DialogueStateChangeType.INTERACTION_TIME,
  173. self.last_interaction_time_ms,
  174. timestamp_ms
  175. ))
  176. self.last_interaction_time_ms = timestamp_ms
  177. def append_dialogue_history(self, message: Dict):
  178. self._uncommited_state_change.append(DialogueStateChange(
  179. DialogueStateChangeType.DIALOGUE_HISTORY,
  180. None,
  181. 1
  182. ))
  183. self.dialogue_history.append(message)
  184. def persist_state(self):
  185. """持久化对话状态,只有当前状态处理成功后才应该做持久化"""
  186. self.commit()
  187. config = configs.get()
  188. if config.get('debug_flags', {}).get('disable_database_write', False):
  189. return
  190. self.state_cache.set_state(self.staff_id, self.user_id, self.current_state, self.previous_state)
  191. def rollback_state(self):
  192. logger.info(f"staff[{self.staff_id}], user[{self.user_id}]: reverse state")
  193. for entry in reversed(self._uncommited_state_change):
  194. if entry.event_type == DialogueStateChangeType.STATE:
  195. self.current_state, self.previous_state = entry.old
  196. elif entry.event_type == DialogueStateChangeType.INTERACTION_TIME:
  197. self.last_interaction_time_ms = entry.old
  198. elif entry.event_type == DialogueStateChangeType.DIALOGUE_HISTORY:
  199. self.dialogue_history.pop()
  200. else:
  201. logger.error(f"unimplemented type: [{entry.event_type}]")
  202. self._uncommited_state_change.clear()
  203. def commit(self):
  204. self._uncommited_state_change.clear()
  205. def do_state_change(self, state: DialogueState):
  206. state_backup = (self.current_state, self.previous_state)
  207. if self.current_state == DialogueState.MESSAGE_AGGREGATING:
  208. # MESSAGE_AGGREGATING不能成为previous_state,仅使用state_backup做回退
  209. self.current_state = state
  210. else:
  211. self.previous_state = self.current_state
  212. self.current_state = state
  213. self._uncommited_state_change.append(DialogueStateChange(
  214. DialogueStateChangeType.STATE,
  215. state_backup,
  216. (self.current_state, self.previous_state)
  217. ))
  218. def update_state(self, message: MqMessage) -> Tuple[bool, Optional[str]]:
  219. """根据用户消息更新对话状态,并返回是否需要发起回复 及下一条需处理的用户消息"""
  220. message_text = message.content
  221. message_ts = message.sendTime
  222. # 如果当前已经是人工介入状态,根据消息类型决定保持/退出
  223. if self.current_state == DialogueState.HUMAN_INTERVENTION:
  224. if message.type == MessageType.HUMAN_INTERVENTION_END:
  225. self.resume_from_human_intervention()
  226. # 恢复状态,但无需Agent产生回复
  227. return False, None
  228. else:
  229. self.append_dialogue_history({
  230. "role": "user",
  231. "content": message_text,
  232. "timestamp": int(time.time() * 1000),
  233. "state": self.current_state.name
  234. })
  235. return False, message_text
  236. if message.type == MessageType.ENTER_HUMAN_INTERVENTION:
  237. logger.info(f"staff[{self.staff_id}], user[{self.user_id}]: human intervention triggered")
  238. self.do_state_change(DialogueState.HUMAN_INTERVENTION)
  239. return False, None
  240. # 检查是否处于消息聚合状态
  241. if self.current_state == DialogueState.MESSAGE_AGGREGATING:
  242. # 收到的是特殊定时触发的空消息,且在聚合中,且已经超时,继续处理
  243. if message.type == MessageType.AGGREGATION_TRIGGER:
  244. if message_ts - self.last_interaction_time_ms > self.message_aggregation_sec * 1000:
  245. logger.debug(f"staff[{self.staff_id}], user[{self.user_id}]: exit aggregation waiting")
  246. else:
  247. logger.debug(f"staff[{self.staff_id}], user[{self.user_id}]: continue aggregation waiting")
  248. return False, message_text
  249. else:
  250. # 非空消息,更新最后交互时间,保持消息聚合状态
  251. if message_text:
  252. self.unprocessed_messages.append(message_text)
  253. self.update_interaction_time(message_ts)
  254. return False, message_text
  255. else:
  256. if message.type == MessageType.AGGREGATION_TRIGGER:
  257. # 未在聚合状态中,收到的聚合触发消息为过时消息,不应当处理
  258. logger.warning(f"staff[{self.staff_id}], user[{self.user_id}]: received {message.type} in state {self.current_state}")
  259. return False, None
  260. if message.type == MessageType.HUMAN_INTERVENTION_END:
  261. # 未在人工介入状态中,收到的人工介入结束事件为过时消息,不应当处理
  262. logger.warning(f"staff[{self.staff_id}], user[{self.user_id}]: received {message.type} in state {self.current_state}")
  263. return False, None
  264. if message.type != MessageType.AGGREGATION_TRIGGER and self.message_aggregation_sec > 0:
  265. # 收到有内容的用户消息,切换到消息聚合状态
  266. self.do_state_change(DialogueState.MESSAGE_AGGREGATING)
  267. self.unprocessed_messages.append(message_text)
  268. # 更新最后交互时间
  269. if message_text:
  270. self.update_interaction_time(message_ts)
  271. return False, message_text
  272. # 获得未处理的聚合消息,并清空未处理队列
  273. if message_text:
  274. self.unprocessed_messages.append(message_text)
  275. if self.unprocessed_messages:
  276. message_text = '\n'.join(self.unprocessed_messages)
  277. self.unprocessed_messages.clear()
  278. # 实际上这里message_text并不会被最终送入LLM,只是用来做状态判断
  279. # 根据消息内容和当前状态确定新状态
  280. new_state = self._determine_state_from_message(message_text)
  281. # 更新状态
  282. self.do_state_change(new_state)
  283. if message_text:
  284. self.update_interaction_time(message_ts)
  285. self.append_dialogue_history({
  286. "role": "user",
  287. "content": message_text,
  288. "timestamp": message_ts,
  289. "state": self.current_state.name
  290. })
  291. return True, message_text
  292. def _determine_state_from_message(self, message_text: Optional[str]) -> DialogueState:
  293. """根据消息内容确定对话状态"""
  294. if not message_text:
  295. logger.warning(f"staff[{self.staff_id}], user[{self.user_id}]: empty message")
  296. return self.current_state
  297. # 简单的规则-关键词匹配
  298. message_lower = message_text.lower()
  299. # 问候检测
  300. greeting_keywords = ["你好", "早上好", "中午好", "晚上好", "嗨", "在吗"]
  301. if any(keyword in message_lower for keyword in greeting_keywords):
  302. return DialogueState.GREETING
  303. # 告别检测
  304. farewell_keywords = ["再见", "拜拜", "晚安", "明天见", "回头见"]
  305. if any(keyword in message_lower for keyword in farewell_keywords):
  306. return DialogueState.FAREWELL
  307. # 澄清请求
  308. # clarification_keywords = ["没明白", "不明白", "没听懂", "不懂", "什么意思", "再说一遍"]
  309. # if any(keyword in message_lower for keyword in clarification_keywords):
  310. # return DialogueState.CLARIFICATION
  311. # 默认为闲聊状态
  312. return DialogueState.CHITCHAT
  313. def _send_alert(self, alert_type: str, reason: Optional[str] = None) -> None:
  314. time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  315. staff_info = f"{self.staff_profile.get('name', '未知')}[{self.staff_id}]"
  316. user_info = f"{self.user_profile.get('nickname', '未知')}[{self.user_id}]"
  317. alert_message = f"""
  318. {alert_type}告警
  319. 员工: {staff_info}
  320. 用户: {user_info}
  321. 时间: {time_str}
  322. 原因:{reason if reason else "未知"}
  323. 最近对话:
  324. """
  325. alert_message = textwrap.dedent(alert_message).strip()
  326. # 添加最近的对话记录
  327. recent_dialogues = self.dialogue_history[-5:]
  328. dialogue_to_send = []
  329. role_map = {'assistant': '客服', 'user': '用户'}
  330. for dialogue in recent_dialogues:
  331. if not dialogue['content']:
  332. continue
  333. role = dialogue['role']
  334. if role not in role_map:
  335. continue
  336. dialogue_to_send.append(f"[{role_map[role]}]{dialogue['content']}")
  337. alert_message += '\n'.join(dialogue_to_send)
  338. if alert_type == '人工介入':
  339. ack_url = "http://ai-wechat-hook.piaoquantv.com/manage/insertEvent?" \
  340. f"sender={self.user_id}&receiver={self.staff_id}&type={MessageType.HUMAN_INTERVENTION_END.value}&content=OPERATION"
  341. else:
  342. ack_url = None
  343. LarkAlertForHumanIntervention().send_lark_alert_for_human_intervention(alert_message, ack_url)
  344. if alert_type == '人工介入':
  345. LarkSheetRecordForHumanIntervention().send_lark_sheet_record_for_human_intervention(
  346. staff_info, user_info, '\n'.join(dialogue_to_send), reason
  347. )
  348. def resume_from_human_intervention(self) -> None:
  349. """从人工介入状态恢复"""
  350. if self.current_state == DialogueState.HUMAN_INTERVENTION:
  351. self.do_state_change(DialogueState.CHITCHAT)
  352. def generate_response(self, llm_response: str) -> Optional[str]:
  353. """
  354. 处理LLM的响应,更新对话状态和对话历史。
  355. 注意:所有的LLM响应都必须经过这个函数来处理!
  356. :param llm_response:
  357. :return:
  358. """
  359. if '<人工介入>' in llm_response:
  360. reason = llm_response.replace('<人工介入>', '')
  361. logger.warning(f'staff[{self.staff_id}], user[{self.user_id}]: human intervention triggered, reason: {reason}')
  362. self.do_state_change(DialogueState.HUMAN_INTERVENTION)
  363. self._send_alert('人工介入', reason)
  364. return None
  365. if '<结束>' in llm_response or '<负向情绪结束>' in llm_response:
  366. logger.warning(f'staff[{self.staff_id}], user[{self.user_id}]: conversation ended')
  367. self.do_state_change(DialogueState.FAREWELL)
  368. if '<负向情绪结束>' in llm_response:
  369. self._send_alert("用户负向情绪")
  370. return None
  371. """根据当前状态处理LLM响应,如果处于人工介入状态则返回None"""
  372. # 如果处于人工介入状态,不生成回复
  373. if self.current_state == DialogueState.HUMAN_INTERVENTION:
  374. return None
  375. # 记录响应到对话历史
  376. message_ts = int(time.time() * 1000)
  377. self.append_dialogue_history({
  378. "role": "assistant",
  379. "type": MessageType.TEXT,
  380. "content": llm_response,
  381. "timestamp": message_ts,
  382. "state": self.current_state.name
  383. })
  384. self.update_interaction_time(message_ts)
  385. return llm_response
  386. def generate_multimodal_response(self, item: Dict) -> Optional[Dict]:
  387. """
  388. 处理LLM的多模态响应,更新对话状态和对话历史。
  389. 注意:所有的LLM多模态响应都必须经过这个函数来处理!
  390. :param item: 包含多模态内容的字典
  391. :return: None
  392. """
  393. if self.current_state == DialogueState.HUMAN_INTERVENTION:
  394. return None
  395. raw_type = item.get("type", "text")
  396. if isinstance(raw_type, str):
  397. item["type"] = MessageType.from_str(raw_type)
  398. if item["type"] == MessageType.TEXT:
  399. if '<人工介入>' in item["content"]:
  400. reason = item["content"].replace('<人工介入>', '')
  401. logger.warning(f'staff[{self.staff_id}], user[{self.user_id}]: human intervention triggered, reason: {reason}')
  402. self.do_state_change(DialogueState.HUMAN_INTERVENTION)
  403. self._send_alert('人工介入', reason)
  404. return None
  405. if '<结束>' in item["content"] or '<负向情绪结束>' in item["content"]:
  406. logger.warning(f'staff[{self.staff_id}], user[{self.user_id}]: conversation ended')
  407. self.do_state_change(DialogueState.FAREWELL)
  408. if '<负向情绪结束>' in item["content"]:
  409. self._send_alert("用户负向情绪")
  410. return None
  411. # 记录响应到对话历史
  412. message_ts = int(time.time() * 1000)
  413. self.append_dialogue_history({
  414. "role": "assistant",
  415. "type": item["type"],
  416. "content": item["content"],
  417. "timestamp": message_ts,
  418. "state": self.current_state.name
  419. })
  420. self.update_interaction_time(message_ts)
  421. return item
  422. def _get_hours_since_last_interaction(self, precision: int = -1):
  423. time_diff = (time.time() * 1000) - self.last_interaction_time_ms
  424. hours_passed = time_diff / 1000 / 3600
  425. if precision >= 0:
  426. return round(hours_passed, precision)
  427. return hours_passed
  428. def update_last_active_interaction_time(self, timestamp_sec: int):
  429. # 只需更新本地时间,重启时可从数据库恢复
  430. self.last_active_interaction_time_sec = timestamp_sec
  431. def should_initiate_conversation(self) -> bool:
  432. """判断是否应该主动发起对话"""
  433. # 如果处于人工介入状态,不应主动发起对话
  434. if self.current_state == DialogueState.HUMAN_INTERVENTION:
  435. return False
  436. hours_passed = self._get_hours_since_last_interaction()
  437. # 获取当前时间上下文
  438. time_context = self.get_time_context()
  439. # 根据用户交互频率偏好设置不同的阈值
  440. interaction_frequency = self.user_profile.get("interaction_frequency", "medium")
  441. if interaction_frequency == 'stopped':
  442. return False
  443. # 设置不同偏好的交互时间阈值(小时)
  444. thresholds = {
  445. "low": 48,
  446. "medium": 24,
  447. "high": 12
  448. }
  449. threshold = thresholds.get(interaction_frequency, 24)
  450. #FIXME 05-21 临时策略,两次主动发起至少48小时
  451. if time.time() - self.last_active_interaction_time_sec < 2 * 24 * 3600:
  452. logger.debug(f"staff[{self.staff_id}], user[{self.user_id}]: last active interaction time too short")
  453. return False
  454. if hours_passed < threshold:
  455. return False
  456. # 根据时间上下文决定主动交互的状态
  457. if self.is_time_suitable_for_active_conversation(time_context):
  458. return True
  459. return False
  460. @staticmethod
  461. def is_time_suitable_for_active_conversation(time_context=None) -> bool:
  462. if configs.get_env() == 'dev':
  463. return True
  464. if not time_context:
  465. time_context = DialogueManager.get_time_context()
  466. if time_context in [TimeContext.MORNING,
  467. TimeContext.NOON, TimeContext.AFTERNOON]:
  468. return True
  469. return False
  470. def is_in_human_intervention(self) -> bool:
  471. """检查是否处于人工介入状态"""
  472. return self.current_state == DialogueState.HUMAN_INTERVENTION
  473. def get_prompt_context(self, user_message) -> Dict:
  474. # 获取当前时间上下文
  475. time_context = self.get_time_context()
  476. # 刷新用户画像
  477. self.user_profile = self.user_manager.get_user_profile(self.user_id)
  478. # 刷新员工画像(不一定需要)
  479. self.staff_profile = self.user_manager.get_staff_profile(self.staff_id)
  480. # 员工画像添加前缀,避免冲突,实现Coze Prompt模板的平滑升级
  481. legacy_staff_profile = {}
  482. for key in self.staff_profile:
  483. legacy_staff_profile[f'agent_{key}'] = self.staff_profile[key]
  484. current_datetime = datetime.now()
  485. context = {
  486. "current_state": self.current_state.name,
  487. "previous_state": self.previous_state.name,
  488. "current_time_period": time_context.description,
  489. "current_hour": current_datetime.hour,
  490. "current_time": current_datetime.strftime("%H:%M:%S"),
  491. "current_date": current_datetime.strftime("%Y-%m-%d"),
  492. "current_datetime": current_datetime.strftime("%Y-%m-%d %H:%M:%S"),
  493. "last_interaction_interval": self._get_hours_since_last_interaction(2),
  494. "if_first_interaction": True if self.previous_state == DialogueState.INITIALIZED else False,
  495. "if_active_greeting": False if user_message else True,
  496. "relation_stage": self.relation_stage,
  497. "formatted_staff_profile": prompt_utils.format_agent_profile(self.staff_profile),
  498. "formatted_user_profile": prompt_utils.format_user_profile(self.user_profile),
  499. **self.user_profile,
  500. **legacy_staff_profile
  501. }
  502. # 获取长期记忆
  503. relevant_memories = self.vector_memory.retrieve_relevant_memories(user_message)
  504. context["long_term_memory"] = {
  505. "relevant_conversations": relevant_memories
  506. }
  507. return context
  508. @staticmethod
  509. def _select_prompt(state):
  510. state_to_prompt_map = {
  511. DialogueState.GREETING: prompt_templates.GENERAL_GREETING_PROMPT,
  512. DialogueState.CHITCHAT: prompt_templates.CHITCHAT_PROMPT_COZE,
  513. DialogueState.FAREWELL: prompt_templates.GENERAL_GREETING_PROMPT
  514. }
  515. return state_to_prompt_map[state]
  516. @staticmethod
  517. def _select_coze_bot(state, dialogue: List[Dict], multimodal=False):
  518. state_to_bot_map = {
  519. DialogueState.GREETING: '7486112546798780425',
  520. DialogueState.CHITCHAT: '7491300566573301770',
  521. DialogueState.FAREWELL: '7491300566573301770',
  522. }
  523. if multimodal:
  524. state_to_bot_map = {
  525. DialogueState.GREETING: '7496772218198900770',
  526. DialogueState.CHITCHAT: '7495692989504438308',
  527. DialogueState.FAREWELL: '7491300566573301770',
  528. }
  529. return state_to_bot_map[state]
  530. @staticmethod
  531. def need_multimodal_model(dialogue: List[Dict], max_message_to_use: int = 10):
  532. # 当前仅为简单实现
  533. recent_messages = dialogue[-max_message_to_use:]
  534. ret = False
  535. for entry in recent_messages:
  536. if entry.get('type') in (MessageType.IMAGE_GW, MessageType.IMAGE_QW, MessageType.GIF):
  537. ret = True
  538. break
  539. return ret
  540. def _create_system_message(self, prompt_context):
  541. prompt_template = self._select_prompt(self.current_state)
  542. prompt = prompt_template.format(**prompt_context)
  543. return {'role': 'system', 'content': prompt}
  544. @staticmethod
  545. def compose_chat_messages_openai_compatible(dialogue_history, current_time, multimodal=False):
  546. messages = []
  547. for entry in dialogue_history:
  548. role = entry['role']
  549. msg_type = entry.get('type', MessageType.TEXT)
  550. fmt_time = DialogueManager.format_timestamp(entry['timestamp'])
  551. if msg_type in (MessageType.IMAGE_GW, MessageType.IMAGE_QW, MessageType.GIF):
  552. if multimodal:
  553. messages.append({
  554. "role": role,
  555. "content": [
  556. {"type": "image_url", "image_url": {"url": entry["content"]}}
  557. ]
  558. })
  559. else:
  560. logger.warning("Image in non-multimodal mode")
  561. messages.append({
  562. "role": role,
  563. "content": "[{}] {}".format(fmt_time, '[图片]')
  564. })
  565. else:
  566. messages.append({
  567. "role": role,
  568. "content": '[{}] {}'.format(fmt_time, entry["content"])
  569. })
  570. # 添加一条前缀用于 约束时间场景
  571. msg_prefix = '[{}]'.format(current_time)
  572. messages.append({'role': 'assistant', 'content': msg_prefix})
  573. return messages
  574. @staticmethod
  575. def compose_chat_messages_coze(dialogue_history, current_time, staff_id, user_id):
  576. messages = []
  577. # 如果system后的第1条消息不为user,需要在最开始补一条user消息,否则会吞assistant消息
  578. if len(dialogue_history) > 0 and dialogue_history[0]['role'] != 'user':
  579. fmt_time = DialogueManager.format_timestamp(dialogue_history[0]['timestamp'])
  580. messages.append(cozepy.Message.build_user_question_text(f'[{fmt_time}] '))
  581. # coze最后一条消息必须为user,且可能吞掉连续的user消息,故强制增加一条空消息(可参与合并)
  582. dialogue_history.append({
  583. 'role': 'user',
  584. 'content': ' ',
  585. 'timestamp': int(datetime.strptime(current_time, '%Y-%m-%d %H:%M:%S').timestamp() * 1000),
  586. })
  587. # 将连续的同一角色的消息做聚合,避免coze吞消息
  588. messages_to_aggr = []
  589. objects_to_aggr = []
  590. last_message_role = None
  591. for entry in dialogue_history:
  592. if not entry['content']:
  593. logger.warning("staff[{}], user[{}], role[{}]: empty content in dialogue history".format(
  594. staff_id, user_id, entry['role']
  595. ))
  596. continue
  597. role = entry['role']
  598. if role != last_message_role:
  599. if objects_to_aggr:
  600. if last_message_role != 'user':
  601. pass
  602. else:
  603. text_message = '\n'.join(messages_to_aggr)
  604. object_string_list = []
  605. for object_entry in objects_to_aggr:
  606. # FIXME: 其它消息类型的支持
  607. object_string_list.append(cozepy.MessageObjectString.build_image(file_url=object_entry['content']))
  608. object_string_list.append(cozepy.MessageObjectString.build_text(text_message))
  609. messages.append(cozepy.Message.build_user_question_objects(object_string_list))
  610. elif messages_to_aggr:
  611. aggregated_message = '\n'.join(messages_to_aggr)
  612. messages.append(DialogueManager.build_chat_message(
  613. last_message_role, aggregated_message, ChatServiceType.COZE_CHAT))
  614. objects_to_aggr = []
  615. messages_to_aggr = []
  616. last_message_role = role
  617. if entry.get('type', MessageType.TEXT) in (MessageType.IMAGE_GW, MessageType.IMAGE_QW, MessageType.GIF):
  618. # 多模态消息必须用特殊的聚合方式,一个object_string数组中只能有一个文字消息,但可以有多个图片
  619. if role == 'user':
  620. objects_to_aggr.append(entry)
  621. else:
  622. logger.warning("staff[{}], user[{}]: unsupported message type [{}] in assistant role".format(
  623. staff_id, user_id, entry['type']
  624. ))
  625. else:
  626. messages_to_aggr.append(DialogueManager.format_dialogue_content(entry))
  627. # 如果有未聚合的object消息,需要特殊处理
  628. if objects_to_aggr:
  629. if last_message_role != 'user':
  630. pass
  631. else:
  632. text_message = '\n'.join(messages_to_aggr)
  633. object_string_list = []
  634. for object_entry in objects_to_aggr:
  635. # FIXME: 其它消息类型的支持
  636. object_string_list.append(cozepy.MessageObjectString.build_image(file_url=object_entry['content']))
  637. object_string_list.append(cozepy.MessageObjectString.build_text(text_message))
  638. messages.append(cozepy.Message.build_user_question_objects(object_string_list))
  639. elif messages_to_aggr:
  640. aggregated_message = '\n'.join(messages_to_aggr)
  641. messages.append(DialogueManager.build_chat_message(
  642. last_message_role, aggregated_message, ChatServiceType.COZE_CHAT))
  643. # 从末尾开始往前遍历,如果assistant曾经回复“无法回答”,则清除当前消息和前一条用户消息
  644. idx = len(messages) - 1
  645. while idx >= 0:
  646. if messages[idx].role == 'assistant' and '无法回答' in messages[idx].content:
  647. messages.pop(idx)
  648. idx -= 1
  649. if idx >= 0:
  650. messages.pop(idx)
  651. idx -= 1
  652. else:
  653. idx -= 1
  654. return messages
  655. def build_active_greeting_config(self, user_tags: List[str]):
  656. # FIXME: 这里的抽象不好,短期支持人为配置实验
  657. # 由于产运要求,指定使用GPT-4o模型
  658. chat_config = {'user_id': self.user_id, 'model_name': chat_service.OPENAI_MODEL_GPT_4o}
  659. prompt_context = self.get_prompt_context(None)
  660. current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  661. system_message = {'role': 'system', 'content': 'You are a helpful AI assistant.'}
  662. # TODO: 随机选择一个prompt 或 带策略选择 或根据用户标签选择
  663. # TODO:需要区分用户是否有历史交互、是否发送过相似内容
  664. greeting_prompts = [
  665. prompt_templates.GREETING_WITH_IMAGE_GAME,
  666. prompt_templates.GREETING_WITH_NAME_POETRY,
  667. prompt_templates.GREETING_WITH_AVATAR_STORY
  668. ]
  669. # 默认随机选择
  670. selected_prompt = greeting_prompts[random.randint(0, len(greeting_prompts) - 1)]
  671. # 实验配置
  672. tag_to_greeting_map = {
  673. '04W4-AA-1': prompt_templates.GREETING_WITH_NAME_POETRY,
  674. '04W4-AA-2': prompt_templates.GREETING_WITH_AVATAR_STORY,
  675. '04W4-AA-3': prompt_templates.GREETING_WITH_INTEREST_QUERY,
  676. '04W4-AA-4': prompt_templates.GREETING_WITH_CALENDAR,
  677. }
  678. for tag in user_tags:
  679. if tag in tag_to_greeting_map:
  680. selected_prompt = tag_to_greeting_map[tag]
  681. prompt = selected_prompt.format(**prompt_context)
  682. user_message = {'role': 'user', 'content': prompt}
  683. messages = [system_message, user_message]
  684. if selected_prompt in (
  685. prompt_templates.GREETING_WITH_AVATAR_STORY,
  686. prompt_templates.GREETING_WITH_INTEREST_QUERY,
  687. ):
  688. messages.append({
  689. "role": 'user',
  690. "content": [
  691. {"type": "image_url", "image_url": {"url": self.user_profile['avatar']}}
  692. ]
  693. })
  694. chat_config['use_multimodal_model'] = True
  695. chat_config['messages'] = messages
  696. return chat_config
  697. def build_chat_configuration(
  698. self,
  699. user_message: Optional[str] = None,
  700. chat_service_type: ChatServiceType = ChatServiceType.OPENAI_COMPATIBLE,
  701. overwrite_context: Optional[Dict] = None
  702. ) -> Dict:
  703. """
  704. 参数:
  705. user_message: 当前用户消息,如果是主动交互则为None
  706. 返回:
  707. 消息列表
  708. """
  709. dialogue_history = self.history_dialogue_service.get_dialogue_history(self.staff_id, self.user_id)
  710. logger.debug("staff[{}], user[{}], recent dialogue_history: {}".format(
  711. self.staff_id, self.user_id, dialogue_history[-20:]
  712. ))
  713. messages = []
  714. config = {
  715. 'user_id': self.user_id
  716. }
  717. prompt_context = self.get_prompt_context(user_message)
  718. if overwrite_context:
  719. prompt_context.update(overwrite_context)
  720. # FIXME(zhoutian): time in string type
  721. current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  722. if overwrite_context and 'current_time' in overwrite_context:
  723. current_time = overwrite_context.get('current_time')
  724. need_multimodal = self.need_multimodal_model(dialogue_history)
  725. config['use_multimodal_model'] = need_multimodal
  726. if chat_service_type == ChatServiceType.OPENAI_COMPATIBLE:
  727. system_message = self._create_system_message(prompt_context)
  728. messages.append(system_message)
  729. messages.extend(self.compose_chat_messages_openai_compatible(dialogue_history, current_time, need_multimodal))
  730. elif chat_service_type == ChatServiceType.COZE_CHAT:
  731. dialogue_history = dialogue_history[-95:] # Coze最多支持100条,还需要附加系统消息
  732. messages = self.compose_chat_messages_coze(dialogue_history, current_time, self.staff_id, self.user_id)
  733. custom_variables = {}
  734. for k, v in prompt_context.items():
  735. custom_variables[k] = str(v)
  736. custom_variables.pop('user_profile', None)
  737. config['custom_variables'] = custom_variables
  738. config['bot_id'] = self._select_coze_bot(self.current_state, dialogue_history, need_multimodal)
  739. #FIXME(zhoutian): 临时报警
  740. if user_message and not messages:
  741. logger.error(f"staff[{self.staff_id}], user[{self.user_id}]: inconsistency in messages")
  742. config['messages'] = messages
  743. return config
  744. @staticmethod
  745. def format_timestamp(timestamp_ms):
  746. return datetime.fromtimestamp(timestamp_ms / 1000).strftime("%Y-%m-%d %H:%M:%S")
  747. @staticmethod
  748. def format_dialogue_content(dialogue_entry):
  749. fmt_time = DialogueManager.format_timestamp(dialogue_entry['timestamp'])
  750. content = '[{}] {}'.format(fmt_time, dialogue_entry['content'])
  751. return content
  752. @staticmethod
  753. def build_chat_message(role, content, chat_service_type: ChatServiceType):
  754. if chat_service_type == ChatServiceType.COZE_CHAT:
  755. if role == 'user':
  756. return cozepy.Message.build_user_question_text(content)
  757. elif role == 'assistant':
  758. return cozepy.Message.build_assistant_answer(content)
  759. else:
  760. return {'role': role, 'content': content}
  761. if __name__ == '__main__':
  762. state_cache = DialogueStateCache()
  763. state_cache.set_state('1688854492669990', '7881302581935903', DialogueState.CHITCHAT, DialogueState.GREETING)