report_parse_agent.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. """
  2. 报告解析 Agent:算法解析 + LLM 兜底
  3. """
  4. import json
  5. import logging
  6. from typing import Optional
  7. from app.config import settings
  8. from app.parsers.pdf_parser import parse_report_pdf_with_fallback
  9. logger = logging.getLogger(__name__)
  10. # 算法解析器使用中文键名(与 extract_full_report_v5.py 一致),
  11. # Java 消费端期望英文键名;在此做双向映射,Java 读取英文键即可。
  12. _CN_TO_EN_OVERVIEW: dict[str, str] = {
  13. '健康总分': 'overallScore',
  14. '菌群健康': 'gutHealthScore',
  15. '慢病控制': 'chronicDiseaseScore',
  16. '营养均衡': 'nutritionScore',
  17. '肠道菌群平衡': 'balanceScore',
  18. '菌群多样性': 'diversityScore',
  19. '有益菌': 'beneficialScore',
  20. '有害菌': 'harmfulScore',
  21. '核心菌属': 'coreGenusScore',
  22. }
  23. # 指标分区 → Java 消费端 indicators 的 category(与 Java PdfParseService 兜底路径保持一致)
  24. _INDICATOR_SECTION_CATEGORY: list[tuple[str, str]] = [
  25. ('nutrition', '主要营养评估'),
  26. ('amino_acids', '氨基酸评估'),
  27. ('vitamins', '维生素评估'),
  28. ('trace_elements', '微量元素评估'),
  29. ('抗生素风险评估', '抗生素耐药'),
  30. ('肠道屏障及代谢物', '肠道屏障功能'),
  31. ('短链脂肪酸', '短链脂肪酸'),
  32. ('神经递质及激素', '神经递质与激素'),
  33. ]
  34. # 菌群检出详细列表分组 → Java 消费端键
  35. _FLORA_GROUP_TO_KEY: dict[str, str] = {
  36. '核心菌属': 'gut_flora',
  37. '益生菌': 'probiotic_species',
  38. '菌纲构成': 'taxonomy_class',
  39. '菌目构成': 'taxonomy_order',
  40. '菌科构成': 'taxonomy_family',
  41. '菌属构成': 'taxonomy_genus',
  42. '菌种构成': 'taxonomy_species',
  43. '病原菌属': 'pathogen_genus',
  44. '病原菌检出': 'pathogen_detection',
  45. }
  46. # Java 端无独立字段、需并入 gut_flora(带 category 区分)的分组
  47. _FLORA_EXTRA_GROUPS: list[str] = ['有害菌属', '其它重要菌属']
  48. class ReportParseAgent:
  49. """报告解析 Agent"""
  50. def __init__(self):
  51. self.llm_api_key = getattr(settings, 'llm_api_key', '')
  52. @staticmethod
  53. def _normalize_overview_keys(result: dict) -> None:
  54. """将 overview 中的中文键名映射为英文键名(双向写入),兼容 Java 消费端。"""
  55. overview = result.get('overview')
  56. if not overview:
  57. return
  58. for _cn, _en in _CN_TO_EN_OVERVIEW.items():
  59. if _cn in overview and _en not in overview:
  60. overview[_en] = overview[_cn]
  61. @staticmethod
  62. def _normalize_for_java(result: dict) -> None:
  63. """将算法解析结果归一化为 Java 消费端 snake_case 键。
  64. 保留中文键供调试/LLM 兜底;仅当构建出非空列表时才覆盖 result 对应键,
  65. 避免覆盖 LLM 兜底直接产出的 indicators/flora/foods。
  66. """
  67. # 1. indicators:合并各指标分区(含主要消化道致病菌特殊键名)
  68. indicators = []
  69. for key, category in _INDICATOR_SECTION_CATEGORY:
  70. items = result.get(key)
  71. if not isinstance(items, list):
  72. continue
  73. for it in items:
  74. if not isinstance(it, dict) or not it.get('name'):
  75. continue
  76. indicators.append({
  77. 'category': category,
  78. 'indicatorName': it['name'],
  79. 'indicatorValue': it.get('value', ''),
  80. 'unit': '',
  81. 'refRange': it.get('refRange', ''),
  82. 'status': it.get('status', ''),
  83. 'symptoms': '',
  84. })
  85. for it in result.get('主要消化道致病菌') or []:
  86. if not isinstance(it, dict):
  87. continue
  88. name = it.get('name') or it.get('致病菌')
  89. if name:
  90. indicators.append({
  91. 'category': '主要消化道致病菌',
  92. 'indicatorName': name,
  93. 'indicatorValue': it.get('value') or it.get('丰度', ''),
  94. 'unit': '',
  95. 'refRange': '',
  96. 'status': it.get('status') or it.get('评估', ''),
  97. 'symptoms': '',
  98. })
  99. if indicators:
  100. result['indicators'] = indicators
  101. # 2. 菌群分组 → snake_case 键(条目字段中文 → 英文)
  102. flora = result.get('菌群检出详细列表')
  103. if isinstance(flora, dict):
  104. for group, en_key in _FLORA_GROUP_TO_KEY.items():
  105. converted = []
  106. for it in flora.get(group) or []:
  107. if not isinstance(it, dict) or not it.get('名称'):
  108. continue
  109. converted.append({
  110. 'name': it['名称'],
  111. 'value': it.get('丰度%', ''),
  112. 'normal_range': it.get('正常范围%', ''),
  113. 'population_level': it.get('人群水平%', ''),
  114. 'detection_rate': it.get('检出率%', ''),
  115. 'description': it.get('说明', ''),
  116. 'category': group,
  117. 'level': it.get('水平', ''),
  118. })
  119. if converted:
  120. result[en_key] = converted
  121. extra = []
  122. for group in _FLORA_EXTRA_GROUPS:
  123. for it in flora.get(group) or []:
  124. if not isinstance(it, dict) or not it.get('名称'):
  125. continue
  126. extra.append({
  127. 'name': it['名称'],
  128. 'value': it.get('丰度%', ''),
  129. 'normal_range': it.get('正常范围%', ''),
  130. 'population_level': it.get('人群水平%', ''),
  131. 'detection_rate': it.get('检出率%', ''),
  132. 'description': it.get('说明', ''),
  133. 'category': group,
  134. 'level': it.get('水平', ''),
  135. })
  136. if extra:
  137. result['gut_flora'] = (result.get('gut_flora') or []) + extra
  138. # 3. foods:个体化食物推荐表 → snake_case
  139. food_table = result.get('个体化食物推荐表')
  140. rows = food_table.get('数据') if isinstance(food_table, dict) else food_table
  141. foods = []
  142. if isinstance(rows, list):
  143. for it in rows:
  144. if not isinstance(it, dict) or not it.get('名称'):
  145. continue
  146. foods.append({
  147. 'name': it['名称'],
  148. 'category': it.get('分类', ''),
  149. 'score': it.get('推荐指数'),
  150. 'energy_kj': it.get('能量KJ'),
  151. 'protein': it.get('蛋白g'),
  152. 'fat': it.get('脂肪g'),
  153. 'carbs': it.get('碳水化合物g'),
  154. 'starch': it.get('淀粉g'),
  155. 'fiber': it.get('总膳食纤维g'),
  156. 'cholesterol': it.get('胆固醇mg'),
  157. })
  158. if foods:
  159. result['foods'] = foods
  160. async def parse(self, file_path: str) -> dict:
  161. """解析 PDF 报告,算法解析 + LLM 兜底"""
  162. # 1. 算法解析
  163. result = parse_report_pdf_with_fallback(file_path)
  164. logger.info("算法解析完成: format=%s, overview_keys=%d",
  165. result.get('format'), len(result.get('overview', {})))
  166. # 1.5 归一化 overview 键名:中文 → 英文(Java 消费端兼容)
  167. self._normalize_overview_keys(result)
  168. # 1.6 归一化指标/菌群/食物为 Java 消费端 snake_case 键
  169. self._normalize_for_java(result)
  170. # 2. 如果解析不完整,LLM 兜底
  171. if result.get('_parse_incomplete') or not result.get('disease_risks'):
  172. logger.info("算法解析不完整,尝试 LLM 兜底")
  173. llm_result = await self._parse_with_llm(file_path)
  174. if llm_result:
  175. # 合并 LLM 结果到算法结果上(LLM 覆盖缺失字段)
  176. for key in ['disease_risks', 'nutrition', 'amino_acids',
  177. 'vitamins', 'trace_elements', 'indicators']:
  178. if key in llm_result and not result.get(key):
  179. result[key] = llm_result[key]
  180. if llm_result.get('overview'):
  181. for k, v in llm_result['overview'].items():
  182. if k not in result.get('overview', {}):
  183. result.setdefault('overview', {})[k] = v
  184. # 重新归一化:LLM 补齐的分区也要并入 indicators
  185. self._normalize_for_java(result)
  186. # 清理内部标记
  187. result.pop('_parse_incomplete', None)
  188. return result
  189. async def parse_generic(self, file_path: str, extra_context: Optional[dict] = None) -> dict:
  190. """通用报告 LLM 解析(不经过算法解析,直接走 LLM)"""
  191. try:
  192. from PyPDF2 import PdfReader
  193. reader = PdfReader(file_path)
  194. text = '\n'.join(page.extract_text() or '' for page in reader.pages)
  195. ctx_str = ""
  196. if extra_context:
  197. ctx_str = f"\n额外上下文:{json.dumps(extra_context, ensure_ascii=False)}"
  198. prompt = f"""你是一个通用报告解析专家。请从以下PDF文本中提取结构化数据,返回JSON格式。
  199. 报告文本内容:
  200. {text[:12000]}{ctx_str}
  201. 请分析这份报告,推断它的类型和内容,然后按以下JSON Schema返回:
  202. {{
  203. "reportType": "推断的报告类型名称",
  204. "reportTypeFamily": "报告家族分类(如: dan/cognitive/gut_flora/health_check/other)",
  205. "confidence": "high/medium/low",
  206. "summary": {{
  207. "personName": "姓名",
  208. "reportDate": "报告日期",
  209. "reportNumber": "报告编号",
  210. "overallScore": "总分(如果有)",
  211. "interpretation": "报告整体解读摘要"
  212. }},
  213. "indicators": [
  214. {{"name": "指标名称", "value": "数值", "category": "分类", "status": "状态"}}
  215. ],
  216. "sections": [
  217. {{"title": "段落标题", "content": "段落内容摘要", "items": [{{"name": "...", "value": "..."}}]}}
  218. ],
  219. "textFeatures": ["文本特征1", "文本特征2", ...]
  220. }}
  221. 只返回JSON,不要其他文字。"""
  222. if self.llm_api_key:
  223. import httpx
  224. async with httpx.AsyncClient(timeout=120) as client:
  225. resp = await client.post(
  226. f"{settings.llm_base_url}/chat/completions",
  227. json={
  228. "model": settings.llm_model or "gpt-4o",
  229. "messages": [{"role": "user", "content": prompt}],
  230. "temperature": 0.1,
  231. },
  232. headers={"Authorization": f"Bearer {self.llm_api_key}"},
  233. )
  234. resp.raise_for_status()
  235. data = resp.json()
  236. content = data['choices'][0]['message']['content']
  237. content = content.replace('```json', '').replace('```', '').strip()
  238. return json.loads(content)
  239. else:
  240. logger.warning("LLM 未配置,返回空")
  241. return {"reportType": "unknown", "summary": {}, "indicators": [], "sections": []}
  242. except Exception as e:
  243. logger.error("通用LLM解析失败: %s", e)
  244. return {"reportType": "unknown", "error": str(e), "summary": {}, "indicators": [], "sections": []}
  245. async def _parse_with_llm(self, file_path: str) -> Optional[dict]:
  246. """LLM 兜底解析"""
  247. try:
  248. from PyPDF2 import PdfReader
  249. reader = PdfReader(file_path)
  250. text = '\n'.join(page.extract_text() or '' for page in reader.pages)
  251. # 构造 prompt
  252. prompt = f"""你是一个肠道菌群检测报告解析专家。请从以下PDF文本中提取结构化数据,返回JSON格式。
  253. 文本内容:
  254. {text[:8000]}
  255. 请按以下JSON Schema返回:
  256. {{
  257. "overview": {{ "person_name": "", "report_number": "", "age": 0, "gender": "male/female",
  258. "overallScore": 0, "gutHealthScore": 0, "chronicDiseaseScore": 0, "nutritionScore": 0,
  259. "gutAge": "", "gutType": "" }},
  260. "disease_risks": [{{"name": "", "value": "", "status": ""}}],
  261. "nutrition": [{{"name": "", "value": "", "status": ""}}],
  262. "amino_acids": [{{"name": "", "value": "", "status": ""}}],
  263. "vitamins": [{{"name": "", "value": "", "status": ""}}],
  264. "trace_elements": [{{"name": "", "value": "", "status": ""}}]
  265. }}
  266. 只返回JSON,不要其他文字。"""
  267. if self.llm_api_key:
  268. # 调 OpenAI 兼容 API
  269. import httpx
  270. async with httpx.AsyncClient(timeout=60) as client:
  271. resp = await client.post(
  272. f"{settings.llm_base_url}/chat/completions",
  273. json={
  274. "model": settings.llm_model or "gpt-4o",
  275. "messages": [{"role": "user", "content": prompt}],
  276. "temperature": 0.1,
  277. },
  278. headers={"Authorization": f"Bearer {self.llm_api_key}"},
  279. )
  280. resp.raise_for_status()
  281. data = resp.json()
  282. content = data['choices'][0]['message']['content']
  283. content = content.replace('```json', '').replace('```', '').strip()
  284. return json.loads(content)
  285. else:
  286. logger.warning("LLM 未配置,跳过 LLM 兜底")
  287. return None
  288. except Exception as e:
  289. logger.warning("LLM 解析失败: %s", e)
  290. return None