|
@@ -0,0 +1,95 @@
|
|
|
|
|
+"""
|
|
|
|
|
+报告解析 Agent:算法解析 + LLM 兜底
|
|
|
|
|
+"""
|
|
|
|
|
+import json
|
|
|
|
|
+import logging
|
|
|
|
|
+from typing import Optional
|
|
|
|
|
+
|
|
|
|
|
+from app.config import settings
|
|
|
|
|
+from app.parsers.pdf_parser import parse_report_pdf_with_fallback
|
|
|
|
|
+
|
|
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class ReportParseAgent:
|
|
|
|
|
+ """报告解析 Agent"""
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.llm_api_key = getattr(settings, 'llm_api_key', '')
|
|
|
|
|
+
|
|
|
|
|
+ async def parse(self, file_path: str) -> dict:
|
|
|
|
|
+ """解析 PDF 报告,算法解析 + LLM 兜底"""
|
|
|
|
|
+ # 1. 算法解析
|
|
|
|
|
+ result = parse_report_pdf_with_fallback(file_path)
|
|
|
|
|
+ logger.info("算法解析完成: format=%s, overview_keys=%d",
|
|
|
|
|
+ result.get('format'), len(result.get('overview', {})))
|
|
|
|
|
+
|
|
|
|
|
+ # 2. 如果解析不完整,LLM 兜底
|
|
|
|
|
+ if result.get('_parse_incomplete') or not result.get('disease_risks'):
|
|
|
|
|
+ logger.info("算法解析不完整,尝试 LLM 兜底")
|
|
|
|
|
+ llm_result = await self._parse_with_llm(file_path)
|
|
|
|
|
+ if llm_result:
|
|
|
|
|
+ # 合并 LLM 结果到算法结果上(LLM 覆盖缺失字段)
|
|
|
|
|
+ for key in ['disease_risks', 'nutrition', 'amino_acids',
|
|
|
|
|
+ 'vitamins', 'trace_elements', 'indicators']:
|
|
|
|
|
+ if key in llm_result and not result.get(key):
|
|
|
|
|
+ result[key] = llm_result[key]
|
|
|
|
|
+ if llm_result.get('overview'):
|
|
|
|
|
+ for k, v in llm_result['overview'].items():
|
|
|
|
|
+ if k not in result.get('overview', {}):
|
|
|
|
|
+ result.setdefault('overview', {})[k] = v
|
|
|
|
|
+
|
|
|
|
|
+ # 清理内部标记
|
|
|
|
|
+ result.pop('_parse_incomplete', None)
|
|
|
|
|
+ return result
|
|
|
|
|
+
|
|
|
|
|
+ async def _parse_with_llm(self, file_path: str) -> Optional[dict]:
|
|
|
|
|
+ """LLM 兜底解析"""
|
|
|
|
|
+ try:
|
|
|
|
|
+ from PyPDF2 import PdfReader
|
|
|
|
|
+ reader = PdfReader(file_path)
|
|
|
|
|
+ text = '\n'.join(page.extract_text() or '' for page in reader.pages)
|
|
|
|
|
+
|
|
|
|
|
+ # 构造 prompt
|
|
|
|
|
+ prompt = f"""你是一个肠道菌群检测报告解析专家。请从以下PDF文本中提取结构化数据,返回JSON格式。
|
|
|
|
|
+
|
|
|
|
|
+文本内容:
|
|
|
|
|
+{text[:8000]}
|
|
|
|
|
+
|
|
|
|
|
+请按以下JSON Schema返回:
|
|
|
|
|
+{{
|
|
|
|
|
+ "overview": {{ "person_name": "", "report_number": "", "age": 0, "gender": "male/female",
|
|
|
|
|
+ "overallScore": 0, "gutHealthScore": 0, "chronicDiseaseScore": 0, "nutritionScore": 0,
|
|
|
|
|
+ "gutAge": "", "gutType": "" }},
|
|
|
|
|
+ "disease_risks": [{{"name": "", "value": "", "status": ""}}],
|
|
|
|
|
+ "nutrition": [{{"name": "", "value": "", "status": ""}}],
|
|
|
|
|
+ "amino_acids": [{{"name": "", "value": "", "status": ""}}],
|
|
|
|
|
+ "vitamins": [{{"name": "", "value": "", "status": ""}}],
|
|
|
|
|
+ "trace_elements": [{{"name": "", "value": "", "status": ""}}]
|
|
|
|
|
+}}
|
|
|
|
|
+
|
|
|
|
|
+只返回JSON,不要其他文字。"""
|
|
|
|
|
+
|
|
|
|
|
+ if self.llm_api_key:
|
|
|
|
|
+ # 调 OpenAI 兼容 API
|
|
|
|
|
+ import httpx
|
|
|
|
|
+ async with httpx.AsyncClient(timeout=60) as client:
|
|
|
|
|
+ resp = await client.post(
|
|
|
|
|
+ f"{settings.llm_base_url}/chat/completions",
|
|
|
|
|
+ json={
|
|
|
|
|
+ "model": settings.llm_model or "gpt-4o",
|
|
|
|
|
+ "messages": [{"role": "user", "content": prompt}],
|
|
|
|
|
+ "temperature": 0.1,
|
|
|
|
|
+ },
|
|
|
|
|
+ headers={"Authorization": f"Bearer {self.llm_api_key}"},
|
|
|
|
|
+ )
|
|
|
|
|
+ data = resp.json()
|
|
|
|
|
+ content = data['choices'][0]['message']['content']
|
|
|
|
|
+ content = content.replace('```json', '').replace('```', '').strip()
|
|
|
|
|
+ return json.loads(content)
|
|
|
|
|
+ else:
|
|
|
|
|
+ logger.warning("LLM 未配置,跳过 LLM 兜底")
|
|
|
|
|
+ return None
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.warning("LLM 解析失败: %s", e)
|
|
|
|
|
+ return None
|