report_parse_agent.py 40 KB

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