report_parse_agent.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973
  1. """
  2. 报告解析 Agent:算法解析 + LLM 兜底 + 多类型报告支持
  3. """
  4. import json
  5. import logging
  6. import base64
  7. from typing import Optional
  8. from app.config import settings
  9. from app.prompt_service import get_prompt
  10. from app.parsers.pdf_parser import parse_report_pdf_with_fallback
  11. logger = logging.getLogger(__name__)
  12. # ---- LLM 解析提示词模板(Java 系统提示词表可覆盖,动态数据用 {占位符} 由 Python 填充)----
  13. REPORT_PARSE_GENERIC_TEMPLATE = """你是一个通用报告解析专家。请从以下PDF文本中提取结构化数据,返回JSON格式。
  14. 报告文本内容:
  15. {report_text}
  16. 请分析这份报告,推断它的类型和内容,然后按以下JSON Schema返回:
  17. {{
  18. "reportType": "推断的报告类型名称",
  19. "reportTypeFamily": "报告家族分类(如: dan/cognitive/gut_flora/health_check/other)",
  20. "confidence": "high/medium/low",
  21. "summary": {{
  22. "personName": "姓名",
  23. "reportDate": "报告日期",
  24. "reportNumber": "报告编号",
  25. "overallScore": "总分(如果有)",
  26. "interpretation": "报告整体解读摘要"
  27. }},
  28. "indicators": [
  29. {{"name": "指标名称", "value": "数值", "category": "分类", "status": "状态"}}
  30. ],
  31. "sections": [
  32. {{"title": "段落标题", "content": "段落内容摘要", "items": [{{"name": "...", "value": "..."}}]}}
  33. ],
  34. "textFeatures": ["文本特征1", "文本特征2", ...]
  35. }}
  36. 只返回JSON,不要其他文字。"""
  37. REPORT_PARSE_GUT_TEMPLATE = """你是一个肠道菌群检测报告解析专家。请从以下PDF文本中提取结构化数据,返回JSON格式。
  38. 文本内容:
  39. {report_text}
  40. 请按以下JSON Schema返回:
  41. {{
  42. "overview": {{ "person_name": "", "report_number": "", "age": 0, "gender": "male/female",
  43. "overallScore": 0, "gutHealthScore": 0, "chronicDiseaseScore": 0, "nutritionScore": 0,
  44. "gutAge": "", "gutType": "", "gutStatusLevel": "" }},
  45. "disease_risks": [{{"name": "", "value": "", "status": "",
  46. "related_bacteria": [{{"bacteria": "", "correlation": "", "value": "", "status": ""}}]}}],
  47. "nutrition": [{{"name": "", "value": "", "status": ""}}],
  48. "amino_acids": [{{"name": "", "value": "", "status": ""}}],
  49. "vitamins": [{{"name": "", "value": "", "status": ""}}],
  50. "trace_elements": [{{"name": "", "value": "", "status": ""}}]
  51. }}
  52. 只返回JSON,不要其他文字。"""
  53. REPORT_PARSE_BRAIN_TEMPLATE = """你是一个脑状态测量报告解析专家。请从以下文本中提取结构化数据,返回JSON格式。
  54. 报告文本内容:
  55. {report_text}
  56. 请按以下JSON Schema返回:
  57. {{
  58. "reportType": "brain_status",
  59. "reportTypeFamily": "cognitive",
  60. "summary": {{
  61. "personName": "姓名",
  62. "age": 年龄数字,
  63. "gender": "male/female",
  64. "reportDate": "报告日期 yyyy-MM-dd",
  65. "reportNumber": "报告编号",
  66. "overallScore": 综合状态得分数值,
  67. "overallLevel": "良好/一般/较差等文字描述"
  68. }},
  69. "indicators": [
  70. {{"name": "指标名称", "value": "数值或等级", "category": "分类", "status": "状态描述"}}
  71. ],
  72. "sections": [
  73. {{
  74. "title": "段落标题(如:健康风险评估/疲劳评估/情绪评估/睡眠评估等)",
  75. "content": "段落摘要",
  76. "items": [
  77. {{"name": "子项名称", "value": "数值", "status": "状态/等级"}}
  78. ]
  79. }}
  80. ]
  81. }}
  82. 注意:
  83. 1. 尽量提取所有出现的评估指标,包括:大脑综合状态得分、大脑健康状态得分、大脑能力状态得分、
  84. 脑供血问题风险、脑供氧问题风险、思维负荷、思维状态风险、大脑疲劳评估、用脑模式、
  85. 焦虑情绪评估、抑郁情绪评估、抵触情绪评估、安全感评估、情绪管理评估、综合情绪评估、
  86. 睡眠效果评估等
  87. 2. 数值尽量提取数字,状态文字原样保留
  88. 3. 只返回JSON,不要其他文字。"""
  89. REPORT_PARSE_COGNITIVE_BASIC_TEMPLATE = """你是一个先天智力潜能(皮纹学)测评报告解析专家。请从以下文本中提取基本信息和目录结构,返回JSON格式。
  90. 报告文本内容(前5页):
  91. {report_text}
  92. 请按以下JSON Schema返回:
  93. {{
  94. "reportType": "cognitive_aptitude",
  95. "reportTypeFamily": "cognitive",
  96. "summary": {{
  97. "personName": "姓名",
  98. "gender": "male/female",
  99. "region": "地区",
  100. "phone": "电话",
  101. "testDate": "测评日期",
  102. "birthday": "出生日期",
  103. "trc": "TRC值(如107+X+M)",
  104. "atd": "ATD角度值",
  105. "learningType": "学习类型(听觉型/体觉型/视觉型)",
  106. "motivationType": "动机类型",
  107. "cognitiveType": "认知类型",
  108. "brainDominance": "左脑型/右脑型/全脑型"
  109. }},
  110. "toc": ["章节1标题", "章节2标题", ...]
  111. }}
  112. 只返回JSON,不要其他文字。"""
  113. REPORT_PARSE_COGNITIVE_INDICATORS_TEMPLATE = """你是一个先天智力潜能(皮纹学)测评报告解析专家。请从以下文本中提取测评指标,返回JSON格式。
  114. 报告文本内容(数据页):
  115. {report_text}
  116. 请按以下JSON Schema返回:
  117. {{
  118. "indicators": [
  119. {{"name": "指标名称", "value": "数值", "category": "分类", "status": "状态/描述"}}
  120. ],
  121. "sections": [
  122. {{
  123. "title": "段落标题(如:智力潜能测评/学习风格测评/左右脑功能/先天性格/八大智能等)",
  124. "content": "段落摘要",
  125. "items": [{{"name": "子项名称", "value": "数值或描述", "status": "状态"}}]
  126. }}
  127. ]
  128. }}
  129. 注意:
  130. 1. 指标包括但不限于:TRC(总脊纹数)、ATD(思维敏捷性角)、各脑区指标、
  131. 八大智能(语言/逻辑数学/空间/身体动觉/音乐/人际/内省/自然观察)、
  132. 先天学习潜能、先天行为导向、先天学习管道等
  133. 2. 如果某个指标的数值看起来是百分比或数值,直接提取
  134. 3. 只返回JSON,不要其他文字。"""
  135. REPORT_PARSE_COGNITIVE_ADVICE_TEMPLATE = """你是一个先天智力潜能(皮纹学)测评报告解析专家。请从以下文本中提取测评建议和心理学测试结果,返回JSON格式。
  136. 报告文本内容(后续页面):
  137. {report_text}
  138. 请按以下JSON Schema返回:
  139. {{
  140. "sections2": [
  141. {{
  142. "title": "段落标题(如:性格色彩/大五人格/MBTI/学科选择/职业能力/感觉统合等)",
  143. "content": "段落摘要",
  144. "items": [{{"name": "子项名称", "value": "数值或描述", "status": "状态"}}]
  145. }}
  146. ],
  147. "recommendations": [
  148. {{"category": "建议类别", "content": "建议内容"}}
  149. ]
  150. }}
  151. 只返回JSON,不要其他文字。"""
  152. REPORT_PARSE_IMAGE_TEMPLATE = """请分析这张报告图片(第{page_idx}页,共{total_pages}页),提取其中的所有结构化数据。
  153. 请按以下JSON Schema返回:
  154. {{
  155. "reportType": "推断的报告类型名称",
  156. "reportTypeFamily": "报告家族分类(如: gut_flora/health_check/cognitive/brain_status/other)",
  157. "summary": {{
  158. "personName": "姓名(如果能识别)",
  159. "reportDate": "报告日期(如果能识别)",
  160. "reportNumber": "报告编号(如果能识别)",
  161. "overallScore": "总分(如果可见)",
  162. "interpretation": "本页内容摘要"
  163. }},
  164. "indicators": [
  165. {{"name": "指标名称", "value": "数值", "category": "分类", "status": "状态"}}
  166. ],
  167. "sections": [
  168. {{"title": "段落标题", "content": "段落内容摘要", "items": [{{"name": "...", "value": "..."}}]}}
  169. ]
  170. }}
  171. 注意:
  172. 1. 请仔细阅读图片中的所有文字,包括表格数据、数值、状态描述
  173. 2. 如果是菌群报告,提取菌属名称、丰度值、参考范围等
  174. 3. 如果是体检报告,提取各项检查指标、数值、参考范围、异常标记
  175. 4. 数值尽量提取精确数字,状态文字原样保留
  176. 5. 只返回JSON,不要其他文字。"""
  177. # 算法解析器使用中文键名(与 extract_full_report_v5.py 一致),
  178. # Java 消费端期望英文键名;在此做双向映射,Java 读取英文键即可。
  179. _CN_TO_EN_OVERVIEW: dict[str, str] = {
  180. '健康总分': 'overallScore',
  181. '菌群健康': 'gutHealthScore',
  182. '慢病控制': 'chronicDiseaseScore',
  183. '营养均衡': 'nutritionScore',
  184. '肠道菌群平衡': 'balanceScore',
  185. '菌群多样性': 'diversityScore',
  186. '有益菌': 'beneficialScore',
  187. '有害菌': 'harmfulScore',
  188. '核心菌属': 'coreGenusScore',
  189. }
  190. # 指标分区 → Java 消费端 indicators 的 category(与 Java PdfParseService 兜底路径保持一致)
  191. _INDICATOR_SECTION_CATEGORY: list[tuple[str, str]] = [
  192. ('nutrition', '主要营养评估'),
  193. ('amino_acids', '氨基酸评估'),
  194. ('vitamins', '维生素评估'),
  195. ('trace_elements', '微量元素评估'),
  196. ('抗生素风险评估', '抗生素耐药'),
  197. ('肠道屏障及代谢物', '肠道屏障功能'),
  198. ('短链脂肪酸', '短链脂肪酸'),
  199. ('神经递质及激素', '神经递质与激素'),
  200. ]
  201. # 菌群检出详细列表分组 → Java 消费端键
  202. _FLORA_GROUP_TO_KEY: dict[str, str] = {
  203. '核心菌属': 'gut_flora',
  204. '益生菌': 'probiotic_species',
  205. '菌纲构成': 'taxonomy_class',
  206. '菌目构成': 'taxonomy_order',
  207. '菌科构成': 'taxonomy_family',
  208. '菌属构成': 'taxonomy_genus',
  209. '菌种构成': 'taxonomy_species',
  210. '病原菌属': 'pathogen_genus',
  211. '病原菌检出': 'pathogen_detection',
  212. }
  213. # Java 端无独立字段、需并入 gut_flora(带 category 区分)的分组
  214. _FLORA_EXTRA_GROUPS: list[str] = ['有害菌属', '其它重要菌属']
  215. def _indicator_constraint(indicators) -> str:
  216. """采集指标清单 → LLM 提示词约束块;清单为空时返回空串(保持现状)。"""
  217. if not indicators:
  218. return ""
  219. names = "、".join(str(x) for x in indicators if x)
  220. if not names:
  221. return ""
  222. return (f"\n\n【采集指标约束】请仅提取以下指标项,name 必须与清单一致;"
  223. f"报告中缺失的项返回 null,不得臆造:{names}")
  224. class ReportParseAgent:
  225. """报告解析 Agent"""
  226. def __init__(self):
  227. self.llm_api_key = getattr(settings, 'llm_api_key', '')
  228. @staticmethod
  229. def _normalize_overview_keys(result: dict) -> None:
  230. """将 overview 中的中文键名映射为英文键名(双向写入),兼容 Java 消费端。"""
  231. overview = result.get('overview')
  232. if not overview:
  233. return
  234. for _cn, _en in _CN_TO_EN_OVERVIEW.items():
  235. if _cn in overview and _en not in overview:
  236. overview[_en] = overview[_cn]
  237. @staticmethod
  238. def _normalize_for_java(result: dict) -> None:
  239. """将算法解析结果归一化为 Java 消费端 snake_case 键。
  240. 保留中文键供调试/LLM 兜底;仅当构建出非空列表时才覆盖 result 对应键,
  241. 避免覆盖 LLM 兜底直接产出的 indicators/flora/foods。
  242. """
  243. # 1. indicators:合并各指标分区(含主要消化道致病菌特殊键名)
  244. indicators = []
  245. for key, category in _INDICATOR_SECTION_CATEGORY:
  246. items = result.get(key)
  247. if not isinstance(items, list):
  248. continue
  249. for it in items:
  250. if not isinstance(it, dict) or not it.get('name'):
  251. continue
  252. indicators.append({
  253. 'category': category,
  254. 'indicatorName': it['name'],
  255. 'indicatorValue': it.get('value', ''),
  256. 'unit': '',
  257. 'refRange': it.get('refRange', ''),
  258. 'status': it.get('status', ''),
  259. 'symptoms': '',
  260. })
  261. for it in result.get('主要消化道致病菌') or []:
  262. if not isinstance(it, dict):
  263. continue
  264. name = it.get('name') or it.get('致病菌')
  265. if name:
  266. indicators.append({
  267. 'category': '主要消化道致病菌',
  268. 'indicatorName': name,
  269. 'indicatorValue': it.get('value') or it.get('丰度', ''),
  270. 'unit': '',
  271. 'refRange': '',
  272. 'status': it.get('status') or it.get('评估', ''),
  273. 'symptoms': '',
  274. })
  275. if indicators:
  276. result['indicators'] = indicators
  277. # 2. 菌群分组 → snake_case 键(条目字段中文 → 英文)
  278. flora = result.get('菌群检出详细列表')
  279. if isinstance(flora, dict):
  280. for group, en_key in _FLORA_GROUP_TO_KEY.items():
  281. converted = []
  282. for it in flora.get(group) or []:
  283. if not isinstance(it, dict) or not it.get('名称'):
  284. continue
  285. converted.append({
  286. 'name': it['名称'],
  287. 'value': it.get('丰度%', ''),
  288. 'normal_range': it.get('正常范围%', ''),
  289. 'population_level': it.get('人群水平%', ''),
  290. 'detection_rate': it.get('检出率%', ''),
  291. 'description': it.get('说明', ''),
  292. 'category': group,
  293. 'level': it.get('水平', ''),
  294. })
  295. if converted:
  296. result[en_key] = converted
  297. extra = []
  298. for group in _FLORA_EXTRA_GROUPS:
  299. for it in flora.get(group) or []:
  300. if not isinstance(it, dict) or not it.get('名称'):
  301. continue
  302. extra.append({
  303. 'name': it['名称'],
  304. 'value': it.get('丰度%', ''),
  305. 'normal_range': it.get('正常范围%', ''),
  306. 'population_level': it.get('人群水平%', ''),
  307. 'detection_rate': it.get('检出率%', ''),
  308. 'description': it.get('说明', ''),
  309. 'category': group,
  310. 'level': it.get('水平', ''),
  311. })
  312. if extra:
  313. result['gut_flora'] = (result.get('gut_flora') or []) + extra
  314. # 3. foods:个体化食物推荐表 → snake_case
  315. food_table = result.get('个体化食物推荐表')
  316. rows = food_table.get('数据') if isinstance(food_table, dict) else food_table
  317. foods = []
  318. if isinstance(rows, list):
  319. for it in rows:
  320. if not isinstance(it, dict) or not it.get('名称'):
  321. continue
  322. foods.append({
  323. 'name': it['名称'],
  324. 'category': it.get('分类', ''),
  325. 'score': it.get('推荐指数'),
  326. 'energy_kj': it.get('能量KJ'),
  327. 'protein': it.get('蛋白g'),
  328. 'fat': it.get('脂肪g'),
  329. 'carbs': it.get('碳水化合物g'),
  330. 'starch': it.get('淀粉g'),
  331. 'fiber': it.get('总膳食纤维g'),
  332. 'cholesterol': it.get('胆固醇mg'),
  333. })
  334. if foods:
  335. result['foods'] = foods
  336. async def parse(self, file_path: str, indicators: Optional[list] = None) -> dict:
  337. """解析 PDF 报告,算法解析 + LLM 兜底"""
  338. # 1. 算法解析
  339. result = parse_report_pdf_with_fallback(file_path)
  340. logger.info("算法解析完成: format=%s, overview_keys=%d",
  341. result.get('format'), len(result.get('overview', {})))
  342. # 1.5 归一化 overview 键名:中文 → 英文(Java 消费端兼容)
  343. self._normalize_overview_keys(result)
  344. # 1.6 归一化指标/菌群/食物为 Java 消费端 snake_case 键
  345. self._normalize_for_java(result)
  346. # 2. 如果解析不完整,LLM 兜底
  347. if result.get('_parse_incomplete') or not result.get('disease_risks'):
  348. logger.info("算法解析不完整,尝试 LLM 兜底")
  349. llm_result = await self._parse_with_llm(file_path, indicators)
  350. if llm_result:
  351. # 合并 LLM 结果到算法结果上(LLM 覆盖缺失字段)
  352. for key in ['disease_risks', 'nutrition', 'amino_acids',
  353. 'vitamins', 'trace_elements', 'indicators']:
  354. if key in llm_result and not result.get(key):
  355. result[key] = llm_result[key]
  356. if llm_result.get('overview'):
  357. for k, v in llm_result['overview'].items():
  358. if k not in result.get('overview', {}):
  359. result.setdefault('overview', {})[k] = v
  360. # 重新归一化:LLM 补齐的分区也要并入 indicators
  361. self._normalize_for_java(result)
  362. # 清理内部标记
  363. result.pop('_parse_incomplete', None)
  364. return result
  365. async def parse_generic(self, file_path: str, extra_context: Optional[dict] = None,
  366. indicators: Optional[list] = None) -> dict:
  367. """通用报告 LLM 解析(不经过算法解析,直接走 LLM)"""
  368. try:
  369. from PyPDF2 import PdfReader
  370. reader = PdfReader(file_path)
  371. text = '\n'.join(page.extract_text() or '' for page in reader.pages)
  372. ctx_str = ""
  373. if extra_context:
  374. ctx_str = f"\n额外上下文:{json.dumps(extra_context, ensure_ascii=False)}"
  375. template = await get_prompt("report_parse_generic") or REPORT_PARSE_GENERIC_TEMPLATE
  376. prompt = template.format(report_text=text[:12000] + ctx_str) + _indicator_constraint(indicators)
  377. if self.llm_api_key:
  378. import httpx
  379. async with httpx.AsyncClient(timeout=120) as client:
  380. resp = await client.post(
  381. f"{settings.llm_base_url}/chat/completions",
  382. json={
  383. "model": settings.llm_model or "gpt-4o",
  384. "messages": [{"role": "user", "content": prompt}],
  385. "temperature": 0.1,
  386. },
  387. headers={"Authorization": f"Bearer {self.llm_api_key}"},
  388. )
  389. resp.raise_for_status()
  390. data = resp.json()
  391. content = data['choices'][0]['message']['content']
  392. content = content.replace('```json', '').replace('```', '').strip()
  393. return json.loads(content)
  394. else:
  395. logger.warning("LLM 未配置,返回空")
  396. return {"reportType": "unknown", "summary": {}, "indicators": [], "sections": []}
  397. except Exception as e:
  398. logger.error("通用LLM解析失败: %s", e)
  399. return {"reportType": "unknown", "error": str(e), "summary": {}, "indicators": [], "sections": []}
  400. async def _parse_with_llm(self, file_path: str, indicators: Optional[list] = None) -> Optional[dict]:
  401. """LLM 兜底解析"""
  402. try:
  403. from PyPDF2 import PdfReader
  404. reader = PdfReader(file_path)
  405. text = '\n'.join(page.extract_text() or '' for page in reader.pages)
  406. template = await get_prompt("report_parse_gut") or REPORT_PARSE_GUT_TEMPLATE
  407. prompt = template.format(report_text=text[:8000]) + _indicator_constraint(indicators)
  408. if self.llm_api_key:
  409. # 调 OpenAI 兼容 API
  410. import httpx
  411. async with httpx.AsyncClient(timeout=60) as client:
  412. resp = await client.post(
  413. f"{settings.llm_base_url}/chat/completions",
  414. json={
  415. "model": settings.llm_model or "gpt-4o",
  416. "messages": [{"role": "user", "content": prompt}],
  417. "temperature": 0.1,
  418. },
  419. headers={"Authorization": f"Bearer {self.llm_api_key}"},
  420. )
  421. resp.raise_for_status()
  422. data = resp.json()
  423. content = data['choices'][0]['message']['content']
  424. content = content.replace('```json', '').replace('```', '').strip()
  425. return json.loads(content)
  426. else:
  427. logger.warning("LLM 未配置,跳过 LLM 兜底")
  428. return None
  429. except Exception as e:
  430. logger.warning("LLM 解析失败: %s", e)
  431. return None
  432. # ================================================================
  433. # 多类型报告解析
  434. # ================================================================
  435. async def parse_by_type(self, file_path: str, report_type: str,
  436. extra_context: Optional[dict] = None,
  437. indicators: Optional[list] = None) -> dict:
  438. """按报告类型路由到专用解析器。
  439. :param file_path: PDF 文件路径
  440. :param report_type: 报告类型标识:
  441. - "brain_status": 脑状态测量报告
  442. - "cognitive_aptitude": 先天智力潜能/皮纹学测评报告
  443. - "scanned_image": 扫描图片 PDF(无文字层,需多模态 LLM)
  444. - "auto": 自动检测类型
  445. :return: 结构化解析结果 dict
  446. """
  447. if report_type == "auto":
  448. report_type = self._detect_report_type(file_path)
  449. logger.info("自动检测报告类型: %s", report_type)
  450. if report_type == "brain_status":
  451. return await self._parse_brain_status(file_path)
  452. elif report_type == "cognitive_aptitude":
  453. return await self._parse_cognitive_aptitude(file_path)
  454. elif report_type == "scanned_image":
  455. return await self._parse_scanned_image(file_path)
  456. else:
  457. logger.info("未识别的报告类型 %s,走通用解析", report_type)
  458. return await self.parse_generic(file_path, extra_context, indicators)
  459. # ---- 格式检测 ----
  460. def _detect_report_type(self, file_path: str) -> str:
  461. """根据 PDF 文本内容自动检测报告类型。"""
  462. try:
  463. from PyPDF2 import PdfReader
  464. reader = PdfReader(file_path)
  465. # 检测是否有可提取文本
  466. total_text = ""
  467. for i in range(min(12, len(reader.pages))):
  468. t = reader.pages[i].extract_text() or ""
  469. total_text += t
  470. if len(total_text.strip()) < 20:
  471. # 前几页几乎无文字 → 可能是扫描图片 PDF
  472. return "scanned_image"
  473. # 脑状态测量报告
  474. if "脑状态测量报告" in total_text or "大脑综合状态" in total_text:
  475. return "brain_status"
  476. # 智力潜能 / 皮纹学报告
  477. if ("先天数据" in total_text or "智力潜能" in total_text
  478. or "皮纹" in total_text or "ATD" in total_text
  479. or "trc" in total_text.lower()):
  480. return "cognitive_aptitude"
  481. # 北京肠道菌群报告
  482. if ("肠道菌群检测" in total_text and "高通量测序" in total_text):
  483. return "gut_flora_beijing"
  484. return "generic"
  485. except Exception as e:
  486. logger.warning("报告类型检测失败: %s", e)
  487. return "generic"
  488. # ---- 通用 LLM 调用 ----
  489. async def _call_llm(self, prompt: str, timeout: int = 120) -> str:
  490. """调用 LLM 并返回文本响应。
  491. :raises RuntimeError: LLM 未配置或调用失败
  492. """
  493. if not self.llm_api_key:
  494. raise RuntimeError("LLM 未配置")
  495. import httpx
  496. async with httpx.AsyncClient(timeout=timeout) as client:
  497. resp = await client.post(
  498. f"{settings.llm_base_url}/chat/completions",
  499. json={
  500. "model": settings.llm_model or "gpt-4o",
  501. "messages": [{"role": "user", "content": prompt}],
  502. "temperature": 0.1,
  503. },
  504. headers={"Authorization": f"Bearer {self.llm_api_key}"},
  505. )
  506. resp.raise_for_status()
  507. data = resp.json()
  508. content = data['choices'][0]['message']['content']
  509. return content.replace('```json', '').replace('```', '').strip()
  510. async def _call_llm_vision(self, prompt: str, image_base64: str,
  511. timeout: int = 180) -> str:
  512. """调用多模态 LLM(vision),传入图片 + 文字提示。
  513. :raises RuntimeError: LLM 未配置或调用失败
  514. """
  515. if not self.llm_api_key:
  516. raise RuntimeError("LLM 未配置")
  517. import httpx
  518. async with httpx.AsyncClient(timeout=timeout) as client:
  519. resp = await client.post(
  520. f"{settings.llm_base_url}/chat/completions",
  521. json={
  522. "model": settings.llm_model or "gpt-4o",
  523. "messages": [
  524. {
  525. "role": "user",
  526. "content": [
  527. {"type": "text", "text": prompt},
  528. {
  529. "type": "image_url",
  530. "image_url": {
  531. "url": f"data:image/png;base64,{image_base64}",
  532. },
  533. },
  534. ],
  535. }
  536. ],
  537. "temperature": 0.1,
  538. },
  539. headers={"Authorization": f"Bearer {self.llm_api_key}"},
  540. )
  541. resp.raise_for_status()
  542. data = resp.json()
  543. content = data['choices'][0]['message']['content']
  544. return content.replace('```json', '').replace('```', '').strip()
  545. def _pdf_to_text(self, file_path: str, max_pages: int = 0) -> str:
  546. """用 PyPDF2 提取 PDF 全文。max_pages=0 表示全部。"""
  547. from PyPDF2 import PdfReader
  548. reader = PdfReader(file_path)
  549. n = len(reader.pages) if max_pages == 0 else min(max_pages, len(reader.pages))
  550. return '\n'.join(reader.pages[i].extract_text() or '' for i in range(n))
  551. # ---- 1. 脑状态测量报告 ----
  552. async def _parse_brain_status(self, file_path: str) -> dict:
  553. """解析脑状态测量报告。
  554. 文本可提取,数据清晰。使用 LLM 提取结构化数据。
  555. 返回结构:
  556. {
  557. "reportType": "brain_status",
  558. "reportTypeFamily": "cognitive",
  559. "summary": {
  560. "personName": "张文远",
  561. "age": 44,
  562. "gender": "male",
  563. "reportDate": "2026-05-03",
  564. "reportNumber": "CL79474",
  565. "overallScore": 82.1,
  566. "overallLevel": "良好",
  567. },
  568. "indicators": [
  569. {"name": "大脑综合状态得分", "value": "82.1", "category": "综合评分", "status": "良好"},
  570. {"name": "脑供血问题风险评估", "value": "低风险", "category": "风险评估", "status": "低风险"},
  571. ...
  572. ],
  573. "sections": [
  574. {"title": "疲劳评估", "content": "...", "items": [...]},
  575. {"title": "情绪评估", "content": "...", "items": [...]},
  576. {"title": "睡眠评估", "content": "...", "items": [...]},
  577. ]
  578. }
  579. """
  580. logger.info("开始解析脑状态测量报告: %s", file_path)
  581. text = self._pdf_to_text(file_path)
  582. template = await get_prompt("report_parse_brain") or REPORT_PARSE_BRAIN_TEMPLATE
  583. prompt = template.format(report_text=text)
  584. try:
  585. content = await self._call_llm(prompt, timeout=120)
  586. result = json.loads(content)
  587. logger.info("脑状态报告解析完成: indicators=%d, sections=%d",
  588. len(result.get('indicators', [])),
  589. len(result.get('sections', [])))
  590. return result
  591. except Exception as e:
  592. logger.error("脑状态报告解析失败: %s", e)
  593. return {
  594. "reportType": "brain_status",
  595. "reportTypeFamily": "cognitive",
  596. "error": str(e),
  597. "summary": {},
  598. "indicators": [],
  599. "sections": [],
  600. }
  601. # ---- 2. 智力潜能 / 皮纹学测评报告 ----
  602. async def _parse_cognitive_aptitude(self, file_path: str) -> dict:
  603. """解析先天智力潜能/皮纹学测评报告。
  604. 115页,文本碎片化严重。分块提取 + LLM 合并。
  605. 返回结构:
  606. {
  607. "reportType": "cognitive_aptitude",
  608. "reportTypeFamily": "cognitive",
  609. "summary": {
  610. "personName": "张老师",
  611. "gender": "male",
  612. "region": "北京",
  613. "phone": "15901552192",
  614. "testDate": "2026-04-03",
  615. "birthday": "1982-02-06",
  616. },
  617. "indicators": [
  618. {"name": "TRC", "value": "107+X+M", "category": "先天数据", "status": ""},
  619. {"name": "ATD", "value": "35.5", "category": "思维敏捷性", "status": "超级敏感型"},
  620. ...
  621. ],
  622. "sections": [
  623. {"title": "智力潜能测评", "content": "...", "items": [...]},
  624. {"title": "学习风格测评", "content": "...", "items": [...]},
  625. {"title": "八大智能测试", "content": "...", "items": [...]},
  626. ...
  627. ]
  628. }
  629. """
  630. logger.info("开始解析智力潜能测评报告: %s", file_path)
  631. from PyPDF2 import PdfReader
  632. reader = PdfReader(file_path)
  633. total_pages = len(reader.pages)
  634. # 分块提取文本 — 每块约 8000 字符
  635. all_pages = []
  636. for i in range(total_pages):
  637. t = reader.pages[i].extract_text() or ''
  638. all_pages.append((i + 1, t))
  639. # 合并前 5 页(基本信息区)作为第一块
  640. first_chunk = '\n'.join(t for _, t in all_pages[:5])
  641. # 合并中间数据页(6-60页)作为第二块
  642. mid_chunk = '\n'.join(t for _, t in all_pages[5:min(30, len(all_pages))])
  643. # 合并后续页(30-62页有文字的)
  644. later_chunk = '\n'.join(t for _, t in all_pages[30:min(62, len(all_pages))])
  645. # 第一块: 基本信息 + 目录
  646. template1 = await get_prompt("report_parse_cognitive_basic") or REPORT_PARSE_COGNITIVE_BASIC_TEMPLATE
  647. prompt1 = template1.format(report_text=first_chunk)
  648. # 第二块: 智力潜能/学习风格/八大智能等核心数据
  649. template2 = await get_prompt("report_parse_cognitive_indicators") or REPORT_PARSE_COGNITIVE_INDICATORS_TEMPLATE
  650. prompt2 = template2.format(report_text=mid_chunk)
  651. # 第三块: 后续章节(学科建议/职业建议/心理学测试等)
  652. template3 = await get_prompt("report_parse_cognitive_advice") or REPORT_PARSE_COGNITIVE_ADVICE_TEMPLATE
  653. prompt3 = template3.format(report_text=later_chunk)
  654. result = {
  655. "reportType": "cognitive_aptitude",
  656. "reportTypeFamily": "cognitive",
  657. "summary": {},
  658. "indicators": [],
  659. "sections": [],
  660. }
  661. try:
  662. # 并行请求三块
  663. import asyncio
  664. tasks = []
  665. if first_chunk.strip():
  666. tasks.append(self._call_llm(prompt1, timeout=120))
  667. if mid_chunk.strip():
  668. tasks.append(self._call_llm(prompt2, timeout=120))
  669. if later_chunk.strip():
  670. tasks.append(self._call_llm(prompt3, timeout=120))
  671. responses = await asyncio.gather(*tasks, return_exceptions=True)
  672. # 合并结果
  673. for i, resp in enumerate(responses):
  674. if isinstance(resp, Exception):
  675. logger.warning("智力潜能报告第%d块解析失败: %s", i + 1, resp)
  676. continue
  677. try:
  678. chunk_result = json.loads(str(resp))
  679. if i == 0:
  680. # 第一块: summary + toc
  681. result['summary'] = chunk_result.get('summary', {})
  682. result['toc'] = chunk_result.get('toc', [])
  683. elif i == 1:
  684. # 第二块: indicators + sections
  685. result['indicators'] = chunk_result.get('indicators', [])
  686. result['sections'] = chunk_result.get('sections', [])
  687. elif i == 2:
  688. # 第三块: sections2 + recommendations
  689. if chunk_result.get('sections2'):
  690. result['sections'].extend(chunk_result['sections2'])
  691. if chunk_result.get('recommendations'):
  692. result['recommendations'] = chunk_result['recommendations']
  693. except (json.JSONDecodeError, KeyError) as e:
  694. logger.warning("智力潜能报告第%d块JSON解析失败: %s", i + 1, e)
  695. logger.info("智力潜能报告解析完成: indicators=%d, sections=%d",
  696. len(result.get('indicators', [])),
  697. len(result.get('sections', [])))
  698. return result
  699. except Exception as e:
  700. logger.error("智力潜能报告解析失败: %s", e)
  701. return result
  702. # ---- 3. 扫描图片 PDF(多模态 LLM) ----
  703. async def _parse_scanned_image(self, file_path: str) -> dict:
  704. """解析扫描图片 PDF(无文字层)。
  705. 流程:
  706. 1. 用 PyPDF2 提取页面图片(base64 编码)
  707. 2. 逐页发送给多模态 LLM 进行 OCR + 结构化提取
  708. 3. 合并各页结果
  709. 返回结构与 parse_generic 一致:
  710. {
  711. "reportType": "推断的类型",
  712. "reportTypeFamily": "报告家族",
  713. "summary": {...},
  714. "indicators": [...],
  715. "sections": [...]
  716. }
  717. """
  718. logger.info("开始解析扫描图片PDF: %s", file_path)
  719. from PyPDF2 import PdfReader
  720. reader = PdfReader(file_path)
  721. total_pages = len(reader.pages)
  722. all_indicators = []
  723. all_sections = []
  724. summary = {}
  725. detected_type = "unknown"
  726. for page_idx in range(total_pages):
  727. page = reader.pages[page_idx]
  728. image_b64 = self._extract_page_image_base64(page)
  729. if not image_b64:
  730. logger.warning("第%d页无图片可提取", page_idx + 1)
  731. continue
  732. template = await get_prompt("report_parse_image") or REPORT_PARSE_IMAGE_TEMPLATE
  733. prompt = template.format(page_idx=page_idx + 1, total_pages=total_pages)
  734. try:
  735. content = await self._call_llm_vision(prompt, image_b64, timeout=180)
  736. page_result = json.loads(content)
  737. # 合并指标
  738. if page_result.get('indicators'):
  739. all_indicators.extend(page_result['indicators'])
  740. # 合并段落
  741. if page_result.get('sections'):
  742. all_sections.extend(page_result['sections'])
  743. # 合并基本信息(第一页优先)
  744. if page_idx == 0 and page_result.get('summary'):
  745. summary = page_result['summary']
  746. # 更新检测到的类型
  747. if page_result.get('reportType') and detected_type == "unknown":
  748. detected_type = page_result['reportType']
  749. logger.info("扫描PDF第%d页解析完成: indicators=%d",
  750. page_idx + 1, len(page_result.get('indicators', [])))
  751. except Exception as e:
  752. logger.warning("扫描PDF第%d页解析失败: %s", page_idx + 1, e)
  753. result = {
  754. "reportType": detected_type,
  755. "reportTypeFamily": "other",
  756. "summary": summary,
  757. "indicators": all_indicators,
  758. "sections": all_sections,
  759. }
  760. logger.info("扫描图片PDF解析完成: pages=%d, indicators=%d, sections=%d",
  761. total_pages, len(all_indicators), len(all_sections))
  762. return result
  763. def _extract_page_image_base64(self, page) -> str:
  764. """从 PDF 页面对象中提取图片,返回 base64 编码字符串。
  765. 支持以下情况:
  766. - 页面包含 /XObject 中的 /Image 类型对象
  767. - 页面是整页图片(常见于扫描件)
  768. :return: base64 编码的 PNG 图片,或空字符串(无图片)
  769. """
  770. try:
  771. from PyPDF2 import PdfReader
  772. import io
  773. # 获取页面资源
  774. resources = page.get('/Resources')
  775. if not resources:
  776. return ""
  777. x_objects = resources.get('/XObject')
  778. if not x_objects:
  779. return ""
  780. x_obj = x_objects.get_object()
  781. # 找最大的图片对象
  782. best_image = None
  783. best_size = 0
  784. for name in x_obj:
  785. obj = x_obj[name]
  786. obj_resolved = obj.get_object()
  787. subtype = obj_resolved.get('/Subtype')
  788. if subtype != '/Image':
  789. continue
  790. width = int(obj_resolved.get('/Width', 0))
  791. height = int(obj_resolved.get('/Height', 0))
  792. size = width * height
  793. if size > best_size:
  794. best_size = size
  795. best_image = obj_resolved
  796. if not best_image:
  797. return ""
  798. # 提取图片数据
  799. color_space = best_image.get('/ColorSpace', '/DeviceRGB')
  800. bits_per_component = int(best_image.get('/BitsPerComponent', 8))
  801. width = int(best_image.get('/Width', 0))
  802. height = int(best_image.get('/Height', 0))
  803. raw_data = best_image.get_data()
  804. # 转换为 PIL Image → PNG → base64
  805. # 尝试安装 Pillow(如果没有)
  806. try:
  807. from PIL import Image
  808. except ImportError:
  809. logger.warning("Pillow 未安装,尝试基础 base64 编码")
  810. # 如果没有 Pillow,直接 base64 编码原始数据
  811. return base64.b64encode(raw_data).decode('utf-8')
  812. # 根据 ColorSpace 确定模式
  813. if isinstance(color_space, str):
  814. if 'RGB' in color_space or 'RGB' in str(color_space):
  815. mode = 'RGB'
  816. elif 'Gray' in color_space or 'Gray' in str(color_space):
  817. mode = 'L'
  818. elif 'CMYK' in color_space:
  819. mode = 'CMYK'
  820. else:
  821. mode = 'RGB'
  822. else:
  823. mode = 'RGB'
  824. if width > 0 and height > 0:
  825. img = Image.frombytes(mode, (width, height), raw_data)
  826. # CMYK → RGB
  827. if mode == 'CMYK':
  828. img = img.convert('RGB')
  829. # 缩小图片以减少 token 消耗(最大 2000px 边)
  830. max_dim = 2000
  831. if max(width, height) > max_dim:
  832. ratio = max_dim / max(width, height)
  833. new_size = (int(width * ratio), int(height * ratio))
  834. img = img.resize(new_size, Image.LANCZOS)
  835. # 转 PNG
  836. buf = io.BytesIO()
  837. img.save(buf, format='PNG', optimize=True)
  838. return base64.b64encode(buf.getvalue()).decode('utf-8')
  839. return ""
  840. except Exception as e:
  841. logger.warning("提取页面图片失败: %s", e)
  842. return ""