| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186 |
- """
- 报告解析 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__)
- # 算法解析器使用中文键名(与 extract_full_report_v5.py 一致),
- # Java 消费端期望英文键名;在此做双向映射,Java 读取英文键即可。
- _CN_TO_EN_OVERVIEW: dict[str, str] = {
- '健康总分': 'overallScore',
- '菌群健康': 'gutHealthScore',
- '慢病控制': 'chronicDiseaseScore',
- '营养均衡': 'nutritionScore',
- '肠道菌群平衡': 'balanceScore',
- '菌群多样性': 'diversityScore',
- '有益菌': 'beneficialScore',
- '有害菌': 'harmfulScore',
- '核心菌属': 'coreGenusScore',
- }
- class ReportParseAgent:
- """报告解析 Agent"""
- def __init__(self):
- self.llm_api_key = getattr(settings, 'llm_api_key', '')
- @staticmethod
- def _normalize_overview_keys(result: dict) -> None:
- """将 overview 中的中文键名映射为英文键名(双向写入),兼容 Java 消费端。"""
- overview = result.get('overview')
- if not overview:
- return
- for _cn, _en in _CN_TO_EN_OVERVIEW.items():
- if _cn in overview and _en not in overview:
- overview[_en] = overview[_cn]
- 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', {})))
- # 1.5 归一化 overview 键名:中文 → 英文(Java 消费端兼容)
- self._normalize_overview_keys(result)
- # 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_generic(self, file_path: str, extra_context: Optional[dict] = None) -> dict:
- """通用报告 LLM 解析(不经过算法解析,直接走 LLM)"""
- try:
- from PyPDF2 import PdfReader
- reader = PdfReader(file_path)
- text = '\n'.join(page.extract_text() or '' for page in reader.pages)
- ctx_str = ""
- if extra_context:
- ctx_str = f"\n额外上下文:{json.dumps(extra_context, ensure_ascii=False)}"
- prompt = f"""你是一个通用报告解析专家。请从以下PDF文本中提取结构化数据,返回JSON格式。
- 报告文本内容:
- {text[:12000]}{ctx_str}
- 请分析这份报告,推断它的类型和内容,然后按以下JSON Schema返回:
- {{
- "reportType": "推断的报告类型名称",
- "reportTypeFamily": "报告家族分类(如: dan/cognitive/gut_flora/health_check/other)",
- "confidence": "high/medium/low",
- "summary": {{
- "personName": "姓名",
- "reportDate": "报告日期",
- "reportNumber": "报告编号",
- "overallScore": "总分(如果有)",
- "interpretation": "报告整体解读摘要"
- }},
- "indicators": [
- {{"name": "指标名称", "value": "数值", "category": "分类", "status": "状态"}}
- ],
- "sections": [
- {{"title": "段落标题", "content": "段落内容摘要", "items": [{{"name": "...", "value": "..."}}]}}
- ],
- "textFeatures": ["文本特征1", "文本特征2", ...]
- }}
- 只返回JSON,不要其他文字。"""
- if self.llm_api_key:
- import httpx
- async with httpx.AsyncClient(timeout=120) 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}"},
- )
- resp.raise_for_status()
- data = resp.json()
- content = data['choices'][0]['message']['content']
- content = content.replace('```json', '').replace('```', '').strip()
- return json.loads(content)
- else:
- logger.warning("LLM 未配置,返回空")
- return {"reportType": "unknown", "summary": {}, "indicators": [], "sections": []}
- except Exception as e:
- logger.error("通用LLM解析失败: %s", e)
- return {"reportType": "unknown", "error": str(e), "summary": {}, "indicators": [], "sections": []}
- 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}"},
- )
- resp.raise_for_status()
- 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
|