service.py 19 KB

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