service.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  1. import json
  2. import time
  3. import logging
  4. from typing import List, Dict, Any, Optional
  5. from datetime import datetime
  6. from dataclasses import dataclass
  7. import time
  8. import requests
  9. from .utils import is_stopped
  10. logger = logging.getLogger(__name__)
  11. class DatabaseException(Exception):
  12. def __init__(self, message="database api access exception"):
  13. self.message = message
  14. super().__init__(self.message)
  15. class SectionTimeout(Exception):
  16. def __init__(self, message="片段超时"):
  17. self.message = message
  18. super().__init__(self.message)
  19. @dataclass
  20. class TaskProgress:
  21. """任务进度"""
  22. current: int
  23. total: int
  24. @dataclass
  25. class TaskInfo:
  26. """任务模型"""
  27. id: str
  28. title: str
  29. category: str
  30. description: str
  31. status: str
  32. @dataclass
  33. class Task:
  34. """任务模型"""
  35. info: TaskInfo
  36. progress: TaskProgress
  37. @dataclass
  38. class AiModel:
  39. """AI模型配置"""
  40. uid: str
  41. model: str
  42. url: str
  43. key: str
  44. token: str
  45. system_prompt: Optional[str] = None
  46. @dataclass
  47. class Sentence:
  48. """句子模型"""
  49. book_id: str
  50. paragraph: int
  51. word_start: int
  52. word_end: int
  53. channel_uid: str
  54. content: str
  55. content_type: str = 'markdown'
  56. access_token: Optional[str] = None
  57. @dataclass
  58. class Payload:
  59. """消息模型"""
  60. model: AiModel
  61. task: Task
  62. prompt: str
  63. sentence: Sentence
  64. @dataclass
  65. class Message:
  66. """消息模型"""
  67. model: AiModel
  68. task: Task
  69. payload: List[Payload]
  70. sentence: Sentence
  71. class AiTranslateService:
  72. """AI翻译服务"""
  73. def __init__(self, redis, ch, method, api_url, customer_timeout):
  74. self.queue = 'ai_translate'
  75. self.model_token = None
  76. self.task = None
  77. self.redis_clusters = redis[0]
  78. self.redis_namespace = redis[1]
  79. self.api_timeout = 100
  80. self.llm_timeout = 300
  81. self.task_topic_id = None
  82. self.api_url = api_url
  83. self.customer_timeout = customer_timeout
  84. self.channel = ch
  85. self.maxProcessTime = 15 * 60 # 一个句子的最大处理时间
  86. def process_translate(self, message_id: str, body: Message) -> bool:
  87. """处理翻译任务"""
  88. is_stopped()
  89. taskStartAt = int(time.time())
  90. self.task = body.task
  91. self.redis_clusters.set(
  92. f"{self.redis_namespace}/task/{self.task.id}/message_id", message_id)
  93. pointer_key = f"{self.redis_namespace}/task/{message_id}/pointer"
  94. pointer = 0
  95. if self.redis_clusters.exists(pointer_key):
  96. # 回到上次中断的点
  97. pointer = int(self.redis_clusters.get(pointer_key))
  98. logger.info(f"last break point {pointer}")
  99. if pointer >= len(body.payload):
  100. self.redis_clusters.delete(pointer_key)
  101. return True
  102. # 获取model token
  103. self.model_token = body.model.token
  104. self._set_task_status(self.task.id, 'running')
  105. # 设置task discussion topic
  106. taskTopicKey = f'{self.redis_namespace}/message/{message_id}/topic'
  107. if self.redis_clusters.exists(taskTopicKey):
  108. # 获取上次的task topic id
  109. self.task_topic_id = self.redis_clusters.get(taskTopicKey)
  110. else:
  111. self.task_topic_id = self._task_discussion(
  112. self.task.id,
  113. 'task',
  114. self.task.title,
  115. self.task.category,
  116. None
  117. )
  118. times = [self.maxProcessTime]
  119. # breakpoint()
  120. for i in range(pointer, len(body.payload)):
  121. is_stopped()
  122. startAt = int(time.time())
  123. message = body.payload[i]
  124. task_discussion_content = []
  125. # 推理
  126. response_llm = self._request_llm(message)
  127. task_discussion_content.append('- LLM request successful')
  128. if self.task.category == 'translate':
  129. # 写入句子库
  130. message.sentence.content = response_llm['content']
  131. self._save_sentence(message.sentence)
  132. if self.task.category == 'suggest':
  133. # 写入pr
  134. self._save_pr(message.sentence, response_llm['content'])
  135. # 获取句子id
  136. s_uid = self._get_sentence_id(message.sentence)
  137. # 写入句子 discussion
  138. topic_children = []
  139. # 提示词
  140. topic_children.append(message.prompt)
  141. # 任务结果
  142. topic_children.append(response_llm['content'])
  143. # 推理过程写入discussion
  144. if response_llm.get('reasoningContent'):
  145. topic_children.append(response_llm['reasoningContent'])
  146. self._sentence_discussion(s_uid, topic_children)
  147. # 修改task 完成度
  148. progress = self._set_task_progress(
  149. TaskProgress(i + 1, len(body.payload)))
  150. task_discussion_content.append(f"- progress={progress}")
  151. # 写入task discussion
  152. if self.task_topic_id:
  153. content = '\n'.join(task_discussion_content)
  154. d_id = self._task_discussion(
  155. self.task.id,
  156. 'task',
  157. self.task.title,
  158. content,
  159. self.task_topic_id
  160. )
  161. else:
  162. logger.error('no task discussion root')
  163. if i + 1 < len(body.payload):
  164. self.redis_clusters.set(pointer_key, i+1)
  165. # 计算本次时间和剩余时间
  166. # breakpoint()
  167. onceTime = int(time.time())-startAt
  168. times.append(onceTime)
  169. times.sort(reverse=True)
  170. # 取出第一个元素
  171. maxTime = times[0]
  172. # 计算剩余时间
  173. remain = self.customer_timeout-(int(time.time())-taskStartAt)
  174. if remain < maxTime:
  175. # 时间不足
  176. raise SectionTimeout
  177. # 任务完成 修改任务状态为 done
  178. self._set_task_status(self.task.id, 'done')
  179. self.redis_clusters.delete(pointer_key)
  180. logger.info('ai translate task complete')
  181. return True
  182. def _sentence_discussion(self, id, discussions):
  183. topic_id = self._task_discussion(
  184. id,
  185. 'sentence',
  186. self.task.title,
  187. self.task.category,
  188. None
  189. )
  190. if topic_id:
  191. logger.info(f'{self.queue} discussion create topic successful')
  192. for content in discussions:
  193. logger.debug(f'{self.queue} discussion child request')
  194. d_id = self._task_discussion(
  195. id, 'sentence', self.task.title, content, topic_id)
  196. if d_id:
  197. logger.info(
  198. f'{self.queue} discussion child successful')
  199. else:
  200. logger.error(
  201. f'{self.queue} discussion create topic response is null')
  202. def _set_task_status(self, task_id: str, status: str):
  203. """设置任务状态"""
  204. url = f"{self.api_url}/v2/task-status/{task_id}"
  205. data = {'status': status}
  206. logger.debug(f'ai_translate task status request: {url}, data: {data}')
  207. headers = {'Authorization': f'Bearer {self.model_token}'}
  208. response = requests.patch(
  209. url, json=data, headers=headers, timeout=self.api_timeout)
  210. if not response.ok:
  211. logger.error(f'ai_translate task status error: {response.json()}')
  212. else:
  213. logger.info(f'ai_translate task status successful ({status})')
  214. def _save_model_log(self, token: str, data: Dict[str, Any]) -> bool:
  215. """保存模型日志"""
  216. url = f"{self.api_url}/v2/model-log"
  217. headers = {'Authorization': f'Bearer {token}'}
  218. response = requests.post(
  219. url, json=data, headers=headers, timeout=self.api_timeout)
  220. # breakpoint()
  221. if not response.ok:
  222. logger.error(
  223. f'ai-translate model log create failed: {response.json()}')
  224. return False
  225. return True
  226. def _task_discussion(self, res_id: str, res_type: str, title: str, content: str, parent_id: Optional[str] = None):
  227. """创建任务讨论"""
  228. url = f"{self.api_url}/v2/discussion"
  229. task_discussion_data = {
  230. 'res_id': res_id,
  231. 'res_type': res_type,
  232. 'content': content,
  233. 'content_type': 'markdown',
  234. 'type': 'discussion',
  235. 'notification': False,
  236. }
  237. if parent_id:
  238. task_discussion_data['parent'] = parent_id
  239. else:
  240. task_discussion_data['title'] = title
  241. logger.debug(
  242. f'{self.queue} discussion create: {url}, data: {json.dumps(task_discussion_data)}')
  243. headers = {'Authorization': f'Bearer {self.model_token}'}
  244. response = requests.post(
  245. url, json=task_discussion_data, headers=headers, timeout=self.api_timeout)
  246. if not response.ok:
  247. logger.error(
  248. f'{self.queue} discussion create error: {response.json()}')
  249. return False
  250. logger.debug(
  251. f'{self.queue} discussion create: {json.dumps(response.json())}')
  252. response_data = response.json()
  253. if response_data.get('data', {}).get('id'):
  254. return response_data['data']['id']
  255. return False
  256. def _request_llm(self, message: Message) -> Dict[str, Any]:
  257. """请求LLM"""
  258. param = {
  259. "model": message.model.model,
  260. "messages": [
  261. {"role": "system", "content": message.model.system_prompt or ''},
  262. {"role": "user", "content": message.prompt},
  263. ],
  264. "temperature": 0.7,
  265. "stream": False
  266. }
  267. logger.info(
  268. f'{self.queue} LLM request {message.model.url} model: {param["model"]}')
  269. logger.debug(
  270. f'{self.queue} LLM api request: {message.model.url}, data: {json.dumps(param)}')
  271. # 写入 model log
  272. model_log_data = {
  273. 'model_id': message.model.uid,
  274. 'request_at': datetime.now().isoformat(),
  275. 'request_data': json.dumps(param, ensure_ascii=False),
  276. }
  277. # 失败重试
  278. max_retries = 3
  279. attempt = 0
  280. headers = {'Authorization': f'Bearer {message.model.key}'}
  281. while attempt < max_retries:
  282. try:
  283. response = requests.post(
  284. message.model.url,
  285. json=param,
  286. headers=headers,
  287. timeout=self.llm_timeout
  288. )
  289. response.raise_for_status()
  290. logger.info(f'{self.queue} LLM request successful')
  291. model_log_data.update({
  292. 'request_headers': json.dumps(dict(response.request.headers), ensure_ascii=False),
  293. 'response_headers': json.dumps(dict(response.headers), ensure_ascii=False),
  294. 'status': response.status_code,
  295. 'response_data': json.dumps(response.json(), ensure_ascii=False),
  296. 'success': True
  297. })
  298. break
  299. except requests.exceptions.RequestException as e:
  300. model_log_data.update({
  301. 'response_headers': json.dumps(dict(e.response.request.headers), ensure_ascii=False),
  302. 'response_headers': json.dumps(dict(e.response.headers), ensure_ascii=False),
  303. 'status': e.response.status_code,
  304. 'response_data': json.dumps(e.response.json(), ensure_ascii=False),
  305. 'success': False
  306. })
  307. attempt += 1
  308. status = getattr(e.response, 'status_code',
  309. 0) if hasattr(e, 'response') else 0
  310. # 某些错误不需要重试
  311. if status in [400, 401, 403, 404, 422]:
  312. logger.warning(f"客户端错误,不重试: {status}")
  313. raise e
  314. # 服务器错误或网络错误可以重试
  315. if attempt < max_retries:
  316. delay = 2 ** attempt # 指数退避
  317. logger.warning(f"请求失败(第 {attempt} 次),{delay} 秒后重试...")
  318. time.sleep(delay)
  319. else:
  320. logger.error("达到最大重试次数,请求最终失败")
  321. raise e
  322. except Exception as e:
  323. raise e
  324. finally:
  325. try:
  326. self._save_model_log(self.model_token, model_log_data)
  327. logger.info(f'{self.queue} model log saved')
  328. except Exception as e:
  329. logger.error(e)
  330. ai_data = response.json()
  331. logger.debug(f'{self.queue} LLM http response: {response.json()}')
  332. response_content = ai_data['choices'][0]['message']['content']
  333. reasoning_content = ai_data['choices'][0]['message'].get(
  334. 'reasoning_content')
  335. output = {'content': response_content}
  336. logger.debug(f'{self.queue} LLM response content={response_content}')
  337. if not reasoning_content:
  338. logger.debug(f'{self.queue} no reasoningContent')
  339. else:
  340. logger.debug(f'{self.queue} reasoning={reasoning_content}')
  341. output['reasoningContent'] = reasoning_content
  342. return output
  343. def _save_sentence(self, sentence: Sentence):
  344. """写入句子库"""
  345. url = f"{self.api_url}/v2/sentence"
  346. logger.info(f"{self.queue} sentence update {url}")
  347. headers = {'Authorization': f'Bearer {self.model_token}'}
  348. data = {'sentences': [sentence.__dict__]}
  349. response = requests.post(
  350. url, json=data, headers=headers, timeout=self.api_timeout)
  351. if not response.ok:
  352. logger.error(
  353. f'{self.queue} sentence update failed: {url}, data: {response.json()}')
  354. raise DatabaseException("sentence 数据库写入错误")
  355. count = response.json()['data']['count']
  356. logger.info(f"{self.queue} sentence update {count} successful")
  357. def _save_pr(self, sentence: Sentence, content: str):
  358. """保存PR"""
  359. url = f"{self.api_url}/v2/sentpr"
  360. logger.info(f"{self.queue} sentence update {url}")
  361. data = {
  362. 'book': sentence.book_id,
  363. 'para': sentence.paragraph,
  364. 'begin': sentence.word_start,
  365. 'end': sentence.word_end,
  366. 'channel': sentence.channel_uid,
  367. 'text': content,
  368. 'notification': False,
  369. 'webhook': False,
  370. }
  371. headers = {'Authorization': f'Bearer {self.model_token}'}
  372. response = requests.post(
  373. url, json=data, headers=headers, timeout=self.api_timeout)
  374. if not response.ok:
  375. logger.error(
  376. f'{self.queue} sentence update failed: {url}, data: {response.json()}')
  377. raise DatabaseException("pr 数据库写入错误")
  378. if response.json().get('ok'):
  379. logger.info(f"{self.queue} sentence suggest update successful")
  380. else:
  381. logger.error(
  382. f"{self.queue} sentence suggest update failed: {url}, data: {response.json()}")
  383. def _get_sentence_id(self, sentence: Sentence) -> str:
  384. """获取句子ID"""
  385. url = f"{self.api_url}/v2/sentence-info/aa"
  386. logger.info(f'ai translate: {url}')
  387. params = {
  388. 'book': sentence.book_id,
  389. 'par': sentence.paragraph,
  390. 'start': sentence.word_start,
  391. 'end': sentence.word_end,
  392. 'channel': sentence.channel_uid
  393. }
  394. headers = {'Authorization': f'Bearer {self.model_token}'}
  395. response = requests.get(
  396. url, params=params, headers=headers, timeout=self.api_timeout)
  397. if not response.json().get('ok'):
  398. logger.error(f'{self.queue} sentence id error: {response.json()}')
  399. return False
  400. s_uid = response.json()['data']['id']
  401. logger.debug(f"sentence id={s_uid}")
  402. return s_uid
  403. def _set_task_progress(self, current: TaskProgress) -> int:
  404. """设置任务进度"""
  405. if current.total > 0:
  406. progress = int(current.current * 100 / current.total)
  407. else:
  408. progress = 100
  409. logger.error(
  410. f'{self.queue} progress total is zero, task_id: {self.task.id}')
  411. url = f"{self.api_url}/v2/task/{self.task.id}"
  412. data = {'progress': progress}
  413. logger.debug(
  414. f'{self.queue} task progress request: {url}, data: {data}')
  415. headers = {'Authorization': f'Bearer {self.model_token}'}
  416. response = requests.patch(
  417. url, json=data, headers=headers, timeout=self.api_timeout)
  418. if not response.ok:
  419. logger.error(
  420. f'{self.queue} task progress error: {response.json()}')
  421. else:
  422. logger.info(
  423. f'{self.queue} task progress successful progress={response.json()["data"]["progress"]}')
  424. return progress
  425. def handle_failed_translate(self, message_id: str, translate_data: List[Any], exception: Exception):
  426. """处理失败的翻译任务"""
  427. try:
  428. # 彻底失败时的业务逻辑
  429. # 设置task为失败状态
  430. self._set_task_status(self.task.id, 'stop')
  431. # 将故障信息写入task discussion
  432. if self.task_topic_id:
  433. error_message = f"**处理失败ai任务时出错** 请重启任务 message id={message_id} 错误信息:{str(exception)}"
  434. d_id = self._task_discussion(
  435. self.task.id,
  436. 'task',
  437. self.task.title,
  438. error_message,
  439. self.task_topic_id
  440. )
  441. except Exception as e:
  442. logger.error(f'处理失败ai任务时出错: {str(e)}')