report_parse_agent.py 38 KB

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