report_parse_agent.py 39 KB

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