report_parse_agent.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  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. class ReportParseAgent:
  24. """报告解析 Agent"""
  25. def __init__(self):
  26. self.llm_api_key = getattr(settings, 'llm_api_key', '')
  27. @staticmethod
  28. def _normalize_overview_keys(result: dict) -> None:
  29. """将 overview 中的中文键名映射为英文键名(双向写入),兼容 Java 消费端。"""
  30. overview = result.get('overview')
  31. if not overview:
  32. return
  33. for _cn, _en in _CN_TO_EN_OVERVIEW.items():
  34. if _cn in overview and _en not in overview:
  35. overview[_en] = overview[_cn]
  36. async def parse(self, file_path: str) -> dict:
  37. """解析 PDF 报告,算法解析 + LLM 兜底"""
  38. # 1. 算法解析
  39. result = parse_report_pdf_with_fallback(file_path)
  40. logger.info("算法解析完成: format=%s, overview_keys=%d",
  41. result.get('format'), len(result.get('overview', {})))
  42. # 1.5 归一化 overview 键名:中文 → 英文(Java 消费端兼容)
  43. self._normalize_overview_keys(result)
  44. # 2. 如果解析不完整,LLM 兜底
  45. if result.get('_parse_incomplete') or not result.get('disease_risks'):
  46. logger.info("算法解析不完整,尝试 LLM 兜底")
  47. llm_result = await self._parse_with_llm(file_path)
  48. if llm_result:
  49. # 合并 LLM 结果到算法结果上(LLM 覆盖缺失字段)
  50. for key in ['disease_risks', 'nutrition', 'amino_acids',
  51. 'vitamins', 'trace_elements', 'indicators']:
  52. if key in llm_result and not result.get(key):
  53. result[key] = llm_result[key]
  54. if llm_result.get('overview'):
  55. for k, v in llm_result['overview'].items():
  56. if k not in result.get('overview', {}):
  57. result.setdefault('overview', {})[k] = v
  58. # 清理内部标记
  59. result.pop('_parse_incomplete', None)
  60. return result
  61. async def _parse_with_llm(self, file_path: str) -> Optional[dict]:
  62. """LLM 兜底解析"""
  63. try:
  64. from PyPDF2 import PdfReader
  65. reader = PdfReader(file_path)
  66. text = '\n'.join(page.extract_text() or '' for page in reader.pages)
  67. # 构造 prompt
  68. prompt = f"""你是一个肠道菌群检测报告解析专家。请从以下PDF文本中提取结构化数据,返回JSON格式。
  69. 文本内容:
  70. {text[:8000]}
  71. 请按以下JSON Schema返回:
  72. {{
  73. "overview": {{ "person_name": "", "report_number": "", "age": 0, "gender": "male/female",
  74. "overallScore": 0, "gutHealthScore": 0, "chronicDiseaseScore": 0, "nutritionScore": 0,
  75. "gutAge": "", "gutType": "" }},
  76. "disease_risks": [{{"name": "", "value": "", "status": ""}}],
  77. "nutrition": [{{"name": "", "value": "", "status": ""}}],
  78. "amino_acids": [{{"name": "", "value": "", "status": ""}}],
  79. "vitamins": [{{"name": "", "value": "", "status": ""}}],
  80. "trace_elements": [{{"name": "", "value": "", "status": ""}}]
  81. }}
  82. 只返回JSON,不要其他文字。"""
  83. if self.llm_api_key:
  84. # 调 OpenAI 兼容 API
  85. import httpx
  86. async with httpx.AsyncClient(timeout=60) as client:
  87. resp = await client.post(
  88. f"{settings.llm_base_url}/chat/completions",
  89. json={
  90. "model": settings.llm_model or "gpt-4o",
  91. "messages": [{"role": "user", "content": prompt}],
  92. "temperature": 0.1,
  93. },
  94. headers={"Authorization": f"Bearer {self.llm_api_key}"},
  95. )
  96. resp.raise_for_status()
  97. data = resp.json()
  98. content = data['choices'][0]['message']['content']
  99. content = content.replace('```json', '').replace('```', '').strip()
  100. return json.loads(content)
  101. else:
  102. logger.warning("LLM 未配置,跳过 LLM 兜底")
  103. return None
  104. except Exception as e:
  105. logger.warning("LLM 解析失败: %s", e)
  106. return None