agent_service.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. #! /usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. # vim:fenc=utf-8
  4. import re
  5. import sys
  6. import time
  7. import random
  8. from typing import Dict, List, Tuple, Any, Optional
  9. import logging
  10. from datetime import datetime, timedelta
  11. import traceback
  12. import apscheduler.triggers.cron
  13. from apscheduler.schedulers.background import BackgroundScheduler
  14. import chat_service
  15. import configs
  16. import logging_service
  17. from logging_service import logger
  18. from chat_service import CozeChat, ChatServiceType
  19. from dialogue_manager import DialogueManager, DialogueState, DialogueStateCache
  20. from response_type_detector import ResponseTypeDetector
  21. from user_manager import UserManager, LocalUserManager, MySQLUserManager, MySQLUserRelationManager, UserRelationManager
  22. from openai import OpenAI
  23. from message_queue_backend import MessageQueueBackend, MemoryQueueBackend, AliyunRocketMQQueueBackend
  24. from user_profile_extractor import UserProfileExtractor
  25. import threading
  26. from message import MessageType, Message, MessageChannel
  27. class AgentService:
  28. def __init__(
  29. self,
  30. receive_backend: MessageQueueBackend,
  31. send_backend: MessageQueueBackend,
  32. human_backend: MessageQueueBackend,
  33. user_manager: UserManager,
  34. user_relation_manager: UserRelationManager,
  35. chat_service_type: ChatServiceType = ChatServiceType.OPENAI_COMPATIBLE
  36. ):
  37. self.receive_queue = receive_backend
  38. self.send_queue = send_backend
  39. self.human_queue = human_backend
  40. # 核心服务模块
  41. self.agent_state_cache = DialogueStateCache()
  42. self.user_manager = user_manager
  43. self.user_relation_manager = user_relation_manager
  44. self.user_profile_extractor = UserProfileExtractor()
  45. self.response_type_detector = ResponseTypeDetector()
  46. self.agent_registry: Dict[str, DialogueManager] = {}
  47. chat_config = configs.get()['chat_api']['openai_compatible']
  48. self.text_model_name = chat_config['text_model']
  49. self.multimodal_model_name = chat_config['multimodal_model']
  50. self.text_model_client = chat_service.OpenAICompatible.create_client(self.text_model_name)
  51. self.multimodal_model_client = chat_service.OpenAICompatible.create_client(self.multimodal_model_name)
  52. coze_config = configs.get()['chat_api']['coze']
  53. coze_oauth_app = CozeChat.get_oauth_app(
  54. coze_config['oauth_client_id'], coze_config['private_key_path'], str(coze_config['public_key_id']),
  55. account_id=coze_config.get('account_id', None)
  56. )
  57. self.coze_client = CozeChat(
  58. base_url=chat_service.COZE_CN_BASE_URL,
  59. auth_app=coze_oauth_app
  60. )
  61. self.chat_service_type = chat_service_type
  62. # 定时任务调度器
  63. self.scheduler = BackgroundScheduler()
  64. self.scheduler.start()
  65. self.limit_initiative_conversation_rate = True
  66. def setup_initiative_conversations(self, schedule_params: Optional[Dict] = None):
  67. if not schedule_params:
  68. schedule_params = {'hour': '8,16,20'}
  69. self.scheduler.add_job(
  70. self._check_initiative_conversations,
  71. apscheduler.triggers.cron.CronTrigger(**schedule_params)
  72. )
  73. def _get_agent_instance(self, staff_id: str, user_id: str) -> DialogueManager:
  74. """获取Agent实例"""
  75. agent_key = 'agent_{}_{}'.format(staff_id, user_id)
  76. if agent_key not in self.agent_registry:
  77. self.agent_registry[agent_key] = DialogueManager(
  78. staff_id, user_id, self.user_manager, self.agent_state_cache)
  79. return self.agent_registry[agent_key]
  80. def process_messages(self):
  81. """持续处理接收队列消息"""
  82. while True:
  83. message = self.receive_queue.consume()
  84. if message:
  85. try:
  86. self.process_single_message(message)
  87. self.receive_queue.ack(message)
  88. except Exception as e:
  89. logger.error("Error processing message: {}".format(e))
  90. traceback.print_exc()
  91. time.sleep(1)
  92. def _update_user_profile(self, user_id, user_profile, recent_dialogue: List[Dict]):
  93. profile_to_update = self.user_profile_extractor.extract_profile_info(user_profile, recent_dialogue)
  94. if not profile_to_update:
  95. logger.debug("user_id: {}, no profile info extracted".format(user_id))
  96. return
  97. logger.warning("update user profile: {}".format(profile_to_update))
  98. merged_profile = self.user_profile_extractor.merge_profile_info(user_profile, profile_to_update)
  99. self.user_manager.save_user_profile(user_id, merged_profile)
  100. return merged_profile
  101. def _schedule_aggregation_trigger(self, staff_id: str, user_id: str, delay_sec: int):
  102. logger.debug("user: {}, schedule trigger message after {} seconds".format(user_id, delay_sec))
  103. message_ts = int((time.time() + delay_sec) * 1000)
  104. message = Message.build(MessageType.AGGREGATION_TRIGGER, MessageChannel.SYSTEM, user_id, staff_id, None, message_ts)
  105. # 系统消息使用特定的msgId,无实际意义
  106. message.msgId = -MessageType.AGGREGATION_TRIGGER.value
  107. self.scheduler.add_job(lambda: self.receive_queue.produce(message),
  108. 'date',
  109. run_date=datetime.now() + timedelta(seconds=delay_sec))
  110. def process_single_message(self, message: Message):
  111. user_id = message.sender
  112. staff_id = message.receiver
  113. # 获取用户信息和Agent实例
  114. user_profile = self.user_manager.get_user_profile(user_id)
  115. agent = self._get_agent_instance(staff_id, user_id)
  116. # 更新对话状态
  117. logger.debug("process message: {}".format(message))
  118. need_response, message_text = agent.update_state(message)
  119. logger.debug("user: {}, next state: {}".format(user_id, agent.current_state))
  120. # 根据状态路由消息
  121. try:
  122. if agent.is_in_human_intervention():
  123. self._route_to_human_intervention(user_id, message)
  124. elif agent.current_state == DialogueState.MESSAGE_AGGREGATING:
  125. if message.type != MessageType.AGGREGATION_TRIGGER:
  126. # 产生一个触发器,但是不能由触发器递归产生
  127. logger.debug("user: {}, waiting next message for aggregation".format(user_id))
  128. self._schedule_aggregation_trigger(staff_id, user_id, agent.message_aggregation_sec)
  129. elif need_response:
  130. # 先更新用户画像再处理回复
  131. self._update_user_profile(user_id, user_profile, agent.dialogue_history[-10:])
  132. resp = self._get_chat_response(user_id, agent, message_text)
  133. if resp:
  134. recent_dialogue = agent.dialogue_history[-10:]
  135. if len(recent_dialogue) < 2:
  136. message_type = MessageType.TEXT
  137. else:
  138. message_type = self.response_type_detector.detect_type(recent_dialogue[:-1], recent_dialogue[-1])
  139. self._send_response(staff_id, user_id, resp, message_type)
  140. else:
  141. logger.debug(f"staff[{staff_id}], user[{user_id}]: do not need response")
  142. # 当前消息处理成功,持久化agent状态
  143. agent.persist_state()
  144. except Exception as e:
  145. agent.rollback_state()
  146. raise e
  147. def _send_response(self, staff_id, user_id, response, message_type: MessageType):
  148. logger.warning(f"staff[{staff_id}] user[{user_id}]: response[{message_type}] {response}")
  149. current_ts = int(time.time() * 1000)
  150. user_tags = self.user_relation_manager.get_user_tags(user_id)
  151. white_list_tags = {'AgentTest1', '04W4-YR-1', '04W4-YR-2', '04W4-KK-1', '04W4-SQ-1'}
  152. hit_white_list_tags = len(set(user_tags).intersection(white_list_tags)) > 0
  153. # FIXME(zhoutian)
  154. # 测试期间临时逻辑,只发送特定的账号或特定用户
  155. user_black_lists = ['7881300148979455',]
  156. staff_white_lists = ['1688854492669990',]
  157. if not (staff_id in staff_white_lists or hit_white_list_tags) \
  158. or user_id in user_black_lists:
  159. logger.warning(f"staff[{staff_id}] user[{user_id}]: skip reply")
  160. return None
  161. self.send_queue.produce(
  162. Message.build(message_type, MessageChannel.CORP_WECHAT,
  163. staff_id, user_id, response, current_ts)
  164. )
  165. def _route_to_human_intervention(self, user_id: str, origin_message: Message):
  166. """路由到人工干预"""
  167. self.human_queue.produce(Message.build(
  168. MessageType.TEXT,
  169. origin_message.channel,
  170. origin_message.sender,
  171. origin_message.receiver,
  172. "用户对话需人工介入,用户名:{}".format(user_id),
  173. int(time.time() * 1000)
  174. ))
  175. def _check_initiative_conversations(self):
  176. """定时检查主动发起对话"""
  177. for staff_user in self.user_relation_manager.list_staff_users():
  178. staff_id = staff_user['staff_id']
  179. user_id = staff_user['user_id']
  180. agent = self._get_agent_instance(staff_id, user_id)
  181. should_initiate = agent.should_initiate_conversation()
  182. user_tags = self.user_relation_manager.get_user_tags(user_id)
  183. white_list_tags = {}
  184. if not set(user_tags).intersection(white_list_tags):
  185. should_initiate = False
  186. if should_initiate:
  187. logger.warning("user: {}, initiate conversation".format(user_id))
  188. resp = self._get_chat_response(user_id, agent, None)
  189. if resp:
  190. self._send_response(staff_id, user_id, resp, MessageType.TEXT)
  191. if self.limit_initiative_conversation_rate:
  192. time.sleep(random.randint(10,20))
  193. else:
  194. logger.debug("user: {}, do not initiate conversation".format(user_id))
  195. def _get_chat_response(self, user_id: str, agent: DialogueManager,
  196. user_message: Optional[str]):
  197. """处理LLM响应"""
  198. chat_config = agent.build_chat_configuration(user_message, self.chat_service_type)
  199. logger.debug(chat_config)
  200. chat_response = self._call_chat_api(chat_config)
  201. chat_response = self.sanitize_response(chat_response)
  202. if response := agent.generate_response(chat_response):
  203. return response
  204. else:
  205. logger.warning(f"staff[{agent.staff_id}] user[{user_id}]: no response generated")
  206. return None
  207. def _call_chat_api(self, chat_config: Dict) -> str:
  208. if configs.get().get('debug_flags', {}).get('disable_llm_api_call', False):
  209. return 'LLM模拟回复 {}'.format(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
  210. if self.chat_service_type == ChatServiceType.OPENAI_COMPATIBLE:
  211. if chat_config['use_multimodal_model']:
  212. chat_completion = self.multimodal_model_client.chat.completions.create(
  213. messages=chat_config['messages'],
  214. model=self.multimodal_model_name,
  215. )
  216. else:
  217. chat_completion = self.text_model_client.chat.completions.create(
  218. messages=chat_config['messages'],
  219. model=self.text_model_client,
  220. )
  221. response = chat_completion.choices[0].message.content
  222. elif self.chat_service_type == ChatServiceType.COZE_CHAT:
  223. bot_user_id = 'qywx_{}'.format(chat_config['user_id'])
  224. response = self.coze_client.create(
  225. chat_config['bot_id'], bot_user_id, chat_config['messages'],
  226. chat_config['custom_variables']
  227. )
  228. else:
  229. raise Exception('Unsupported chat service type: {}'.format(self.chat_service_type))
  230. return response
  231. @staticmethod
  232. def sanitize_response(response: str):
  233. pattern = r'\[?\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]?'
  234. response = re.sub(pattern, '', response)
  235. response = response.strip()
  236. return response
  237. if __name__ == "__main__":
  238. config = configs.get()
  239. logging_service.setup_root_logger()
  240. logger.warning("current env: {}".format(configs.get_env()))
  241. scheduler_logger = logging.getLogger('apscheduler')
  242. scheduler_logger.setLevel(logging.WARNING)
  243. use_aliyun_mq = config['debug_flags']['use_aliyun_mq']
  244. # 初始化不同队列的后端
  245. if use_aliyun_mq:
  246. receive_queue = AliyunRocketMQQueueBackend(
  247. config['mq']['endpoints'],
  248. config['mq']['instance_id'],
  249. config['mq']['receive_topic'],
  250. has_consumer=True, has_producer=True,
  251. group_id=config['mq']['receive_group']
  252. )
  253. send_queue = AliyunRocketMQQueueBackend(
  254. config['mq']['endpoints'],
  255. config['mq']['instance_id'],
  256. config['mq']['send_topic'],
  257. has_consumer=False, has_producer=True
  258. )
  259. else:
  260. receive_queue = MemoryQueueBackend()
  261. send_queue = MemoryQueueBackend()
  262. human_queue = MemoryQueueBackend()
  263. # 初始化用户管理服务
  264. # FIXME(zhoutian): 如果不使用MySQL,此数据库配置非必须
  265. user_db_config = config['storage']['user']
  266. staff_db_config = config['storage']['staff']
  267. if config['debug_flags'].get('use_local_user_storage', False):
  268. user_manager = LocalUserManager()
  269. else:
  270. user_manager = MySQLUserManager(user_db_config['mysql'], user_db_config['table'], staff_db_config['table'])
  271. wecom_db_config = config['storage']['user_relation']
  272. user_relation_manager = MySQLUserRelationManager(
  273. user_db_config['mysql'], wecom_db_config['mysql'],
  274. config['storage']['staff']['table'],
  275. user_db_config['table'],
  276. wecom_db_config['table']['staff'],
  277. wecom_db_config['table']['relation'],
  278. wecom_db_config['table']['user']
  279. )
  280. # 创建Agent服务
  281. service = AgentService(
  282. receive_backend=receive_queue,
  283. send_backend=send_queue,
  284. human_backend=human_queue,
  285. user_manager=user_manager,
  286. user_relation_manager=user_relation_manager,
  287. chat_service_type=ChatServiceType.COZE_CHAT
  288. )
  289. # 只有企微场景需要主动发起
  290. if not config['debug_flags'].get('disable_active_conversation', False):
  291. schedule_param = config['agent_behavior'].get('schedule_param', None)
  292. service.setup_initiative_conversations(schedule_param)
  293. process_thread = threading.Thread(target=service.process_messages)
  294. process_thread.start()
  295. if not config['debug_flags'].get('console_input', False):
  296. process_thread.join()
  297. sys.exit(0)
  298. message_id = 0
  299. while True:
  300. print("Input next message: ")
  301. text = sys.stdin.readline().strip()
  302. if not text:
  303. continue
  304. message_id += 1
  305. sender = '7881300197937884'
  306. receiver = '1688855931724582'
  307. if text == MessageType.AGGREGATION_TRIGGER.name:
  308. message = Message.build(MessageType.AGGREGATION_TRIGGER, MessageChannel.CORP_WECHAT,
  309. sender, receiver, None, int(time.time() * 1000))
  310. else:
  311. message = Message.build(MessageType.TEXT, MessageChannel.CORP_WECHAT,
  312. sender,receiver, text, int(time.time() * 1000)
  313. )
  314. message.msgId = message_id
  315. receive_queue.produce(message)
  316. time.sleep(0.1)
  317. process_thread.join()