llm_client.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. import base64
  2. from typing import AsyncIterator, Optional
  3. from openai import AsyncOpenAI
  4. from app.core.config import get_settings
  5. class LLMClient:
  6. def __init__(self):
  7. self._client = None
  8. def _ensure_client(self):
  9. if self._client is not None:
  10. return
  11. settings = get_settings()
  12. is_local = settings.qwen_base_url.startswith("http://localhost")
  13. api_key = "local" if is_local else settings.qwen_api_key
  14. if not api_key and not is_local:
  15. raise ValueError("QWEN_API_KEY 未设置,请在 .env 文件中填入 API Key")
  16. self._client = AsyncOpenAI(
  17. api_key=api_key,
  18. base_url=settings.qwen_base_url,
  19. )
  20. self.model = settings.qwen_model
  21. self.vl_model = settings.qwen_vl_model
  22. self.enable_web_search = settings.enable_web_search
  23. # ============================================================
  24. # 纯文本对话
  25. # ============================================================
  26. async def chat(
  27. self,
  28. messages: list[dict],
  29. temperature: Optional[float] = None,
  30. max_tokens: Optional[int] = None,
  31. enable_search: bool = False,
  32. ) -> str:
  33. self._ensure_client()
  34. settings = get_settings()
  35. extra = {}
  36. if enable_search:
  37. extra["enable_search"] = True
  38. response = await self._client.chat.completions.create(
  39. model=self.model,
  40. messages=messages,
  41. temperature=temperature or settings.qwen_temperature,
  42. max_tokens=max_tokens or settings.qwen_max_tokens,
  43. extra_body=extra if extra else None,
  44. )
  45. return response.choices[0].message.content or ""
  46. async def chat_stream(
  47. self,
  48. messages: list[dict],
  49. temperature: Optional[float] = None,
  50. max_tokens: Optional[int] = None,
  51. enable_search: bool = False,
  52. ) -> AsyncIterator[str]:
  53. self._ensure_client()
  54. settings = get_settings()
  55. extra = {}
  56. if enable_search:
  57. extra["enable_search"] = True
  58. stream = await self._client.chat.completions.create(
  59. model=self.model,
  60. messages=messages,
  61. temperature=temperature or settings.qwen_temperature,
  62. max_tokens=max_tokens or settings.qwen_max_tokens,
  63. stream=True,
  64. extra_body=extra if extra else None,
  65. )
  66. async for chunk in stream:
  67. if chunk.choices and chunk.choices[0].delta.content:
  68. yield chunk.choices[0].delta.content
  69. # ============================================================
  70. # 图片分析 + OCR(Qwen VL 视觉模型)
  71. # ============================================================
  72. async def analyze_image(
  73. self,
  74. image_base64: str,
  75. mime_type: str = "image/jpeg",
  76. prompt: str = "",
  77. ) -> str:
  78. """
  79. 调用 Qwen VL 模型分析图片:
  80. - 描述图片内容(药品包装、说明书、处方等)
  81. - 提取图中文字(OCR)
  82. - 返回可用于 RAG 检索的文本
  83. """
  84. self._ensure_client()
  85. if not prompt:
  86. prompt = """请仔细分析这张图片,完成以下任务:
  87. 1. 描述图片内容(药品包装、说明书、处方单、症状照片等)
  88. 2. 提取图片中所有可见的文字(OCR),特别是药品名称、成分、用法用量、批号、有效期等关键信息
  89. 3. 如果图片是药品包装/说明书,提取:药品通用名、规格、生产企业、批准文号
  90. 4. 如果图片是处方,提取:患者信息、药品名称、用法用量、开具日期
  91. 请按以下格式输出:
  92. 【图片描述】
  93. 图片内容的简要描述。
  94. 【OCR 提取文字】
  95. 逐条列出提取到的文字内容。
  96. """
  97. response = await self._client.chat.completions.create(
  98. model=self.vl_model,
  99. messages=[
  100. {
  101. "role": "user",
  102. "content": [
  103. {
  104. "type": "image_url",
  105. "image_url": {
  106. "url": f"data:{mime_type};base64,{image_base64}"
  107. },
  108. },
  109. {"type": "text", "text": prompt},
  110. ],
  111. }
  112. ],
  113. max_tokens=2048,
  114. )
  115. return response.choices[0].message.content or ""
  116. async def analyze_image_stream(
  117. self,
  118. image_base64: str,
  119. mime_type: str = "image/jpeg",
  120. prompt: str = "",
  121. ) -> AsyncIterator[str]:
  122. """流式版本:Qwen VL 分析图片"""
  123. self._ensure_client()
  124. if not prompt:
  125. prompt = """请仔细分析这张图片,完成以下任务:
  126. 1. 描述图片内容(药品包装、说明书、处方单、症状照片等)
  127. 2. 提取图片中所有可见的文字(OCR),特别是药品名称、成分、用法用量等关键信息
  128. 3. 如果图片是药品包装/说明书,提取:药品通用名、规格、生产企业、批准文号
  129. 请简要输出分析结果。"""
  130. stream = await self._client.chat.completions.create(
  131. model=self.vl_model,
  132. messages=[
  133. {
  134. "role": "user",
  135. "content": [
  136. {
  137. "type": "image_url",
  138. "image_url": {
  139. "url": f"data:{mime_type};base64,{image_base64}"
  140. },
  141. },
  142. {"type": "text", "text": prompt},
  143. ],
  144. }
  145. ],
  146. max_tokens=2048,
  147. stream=True,
  148. )
  149. async for chunk in stream:
  150. if chunk.choices and chunk.choices[0].delta.content:
  151. yield chunk.choices[0].delta.content
  152. # ============================================================
  153. # 视频分析 + OCR(Qwen VL 模型支持视频帧提取分析)
  154. # ============================================================
  155. async def analyze_video(
  156. self,
  157. video_base64: str,
  158. mime_type: str = "video/mp4",
  159. prompt: str = "",
  160. ) -> str:
  161. """
  162. 调用 Qwen VL 模型分析视频:
  163. - 提取关键帧并描述视频内容
  164. - OCR 提取帧中所有可见文字
  165. - 返回可用于 RAG 检索的文本
  166. """
  167. self._ensure_client()
  168. if not prompt:
  169. prompt = """请仔细分析这段视频,完成以下任务:
  170. 1. 描述视频内容(药品展示、用药指导、症状表现等)
  171. 2. 提取视频中所有可见的文字(OCR),特别是药品名称、成分、用法用量、批号等关键信息
  172. 3. 如果视频中有药品包装/说明书,提取全部文字信息
  173. 4. 总结视频传达的关键药学信息
  174. 请按以下格式输出:
  175. 【视频描述】
  176. 视频内容的简要描述。
  177. 【OCR 提取文字】
  178. 逐条列出从视频帧中提取到的文字内容。
  179. 【关键信息总结】
  180. 与药学相关的关键信息摘要。"""
  181. response = await self._client.chat.completions.create(
  182. model=self.vl_model,
  183. messages=[
  184. {
  185. "role": "user",
  186. "content": [
  187. {
  188. "type": "video_url",
  189. "video_url": {
  190. "url": f"data:{mime_type};base64,{video_base64}"
  191. },
  192. },
  193. {"type": "text", "text": prompt},
  194. ],
  195. }
  196. ],
  197. max_tokens=2048,
  198. )
  199. return response.choices[0].message.content or ""
  200. async def analyze_video_stream(
  201. self,
  202. video_base64: str,
  203. mime_type: str = "video/mp4",
  204. prompt: str = "",
  205. ) -> AsyncIterator[str]:
  206. """流式版本:Qwen VL 分析视频"""
  207. self._ensure_client()
  208. if not prompt:
  209. prompt = """请分析这段视频,提取关键帧中的文字信息(OCR),特别是药品名称、成分、用法用量等。简要输出。"""
  210. stream = await self._client.chat.completions.create(
  211. model=self.vl_model,
  212. messages=[
  213. {
  214. "role": "user",
  215. "content": [
  216. {
  217. "type": "video_url",
  218. "video_url": {
  219. "url": f"data:{mime_type};base64,{video_base64}"
  220. },
  221. },
  222. {"type": "text", "text": prompt},
  223. ],
  224. }
  225. ],
  226. max_tokens=2048,
  227. stream=True,
  228. )
  229. async for chunk in stream:
  230. if chunk.choices and chunk.choices[0].delta.content:
  231. yield chunk.choices[0].delta.content
  232. # ============================================================
  233. # 统一多模态分析(自动判断 image / video)
  234. # ============================================================
  235. async def analyze_media(
  236. self,
  237. media_base64: str,
  238. media_type: str, # "image" or "video"
  239. mime_type: str = "",
  240. prompt: str = "",
  241. ) -> str:
  242. """统一入口:根据 media_type 自动路由到图片或视频分析"""
  243. if media_type == "video":
  244. return await self.analyze_video(
  245. media_base64,
  246. mime_type=mime_type or "video/mp4",
  247. prompt=prompt,
  248. )
  249. else:
  250. return await self.analyze_image(
  251. media_base64,
  252. mime_type=mime_type or "image/jpeg",
  253. prompt=prompt,
  254. )
  255. async def analyze_media_stream(
  256. self,
  257. media_base64: str,
  258. media_type: str,
  259. mime_type: str = "",
  260. prompt: str = "",
  261. ) -> AsyncIterator[str]:
  262. """流式统一入口"""
  263. if media_type == "video":
  264. async for token in self.analyze_video_stream(
  265. media_base64,
  266. mime_type=mime_type or "video/mp4",
  267. prompt=prompt,
  268. ):
  269. yield token
  270. else:
  271. async for token in self.analyze_image_stream(
  272. media_base64,
  273. mime_type=mime_type or "image/jpeg",
  274. prompt=prompt,
  275. ):
  276. yield token
  277. llm_client = LLMClient()