report_parse_agent.py 40 KB

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