pdf_parser.py 83 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957
  1. """
  2. 菌群报告 PDF 解析器
  3. 从 extract_full_report_v5.py 提取核心逻辑,封装为可调用函数
  4. """
  5. import os
  6. import re
  7. from PyPDF2 import PdfReader
  8. # === 常量 ===
  9. # Kangxi 部首 → CJK 统一汉字(与 extract_full_report_v5.py 完全一致)
  10. RADICAL_MAP = {
  11. '\u2f18': '卜', '\u2f1f': '土', '\u2f24': '大', '\u2f26': '子',
  12. '\u2f29': '小', '\u2f2d': '山', '\u2f32': '干', '\u2f3c': '心',
  13. '\u2f42': '文', '\u2f46': '无', '\u2f4a': '木', '\u2f50': '比',
  14. '\u2f54': '水', '\u2f55': '火', '\u2f5c': '牛', '\u2f5f': '玉',
  15. '\u2f60': '瓜', '\u2f62': '甘', '\u2f63': '生', '\u2f64': '用',
  16. '\u2f69': '白', '\u2f6a': '皮', '\u2f6c': '目', '\u2f6f': '石',
  17. '\u2f75': '竹', '\u2f76': '米', '\u2f7a': '羊', '\u2f7b': '羽',
  18. '\u2f7c': '老', '\u2f7f': '耳', '\u2f81': '肉', '\u2f90': '衣',
  19. '\u2f95': '谷', '\u2f96': '豆', '\u2f9d': '身', '\u2fa6': '金',
  20. '\u2faf': '面', '\u2fb2': '韭', '\u2fb9': '香', '\u2fca': '黑',
  21. '\u2ec9': '贝', '\u2edd': '食', '\u2ee2': '马', '\u2ee5': '鱼',
  22. '\u2ee8': '麦', '\u2ee9': '黄', '\u2ef0': '龙',
  23. # 氏 radical U+2F52 → U+6C0F
  24. '\u2f52': '氏',
  25. # 以下为本项目报告 PDF 中实际出现的部首字形(U+2F00-Kangxi / U+2E80-CJK Radicals)
  26. '\u2f00': '一', '\u2f04': '乙', '\u2f06': '二', '\u2f08': '人',
  27. '\u2f09': '儿', '\u2f0a': '入', '\u2f0f': '几', '\u2f12': '力',
  28. '\u2f17': '十', '\u2f1c': '又', '\u2f1d': '口', '\u2f20': '士',
  29. '\u2f25': '女', '\u2f2b': '尸', '\u2f2f': '工', '\u2f34': '广',
  30. '\u2f38': '弓', '\u2f3f': '手', '\u2f40': '支', '\u2f45': '方',
  31. '\u2f47': '日', '\u2f49': '月', '\u2f4c': '止', '\u2f51': '毛',
  32. '\u2f53': '气', '\u2f5a': '片', '\u2f5b': '牙', '\u2f5d': '犬',
  33. '\u2f70': '示', '\u2f74': '立', '\u2f79': '网', '\u2f7d': '而',
  34. '\u2f83': '自', '\u2f84': '至', '\u2f86': '舌', '\u2f8a': '色',
  35. '\u2f8d': '虫', '\u2f8e': '血', '\u2f8f': '行', '\u2f94': '言',
  36. '\u2f9b': '走', '\u2f9c': '足', '\u2f9f': '辛', '\u2fa5': '里',
  37. '\u2fae': '非', '\u2fb0': '革', '\u2fb3': '音', '\u2fb8': '首',
  38. '\u2fbc': '高', '\u2fcf': '鼠', '\u2fd0': '鼻',
  39. '\u2e9f': '母', '\u2ec5': '见', '\u2ec6': '角', '\u2ed3': '长',
  40. '\u2ed4': '门', '\u2ed8': '青', '\u2ed9': '韦', '\u2edb': '风',
  41. '\u2ee3': '骨', '\u2eec': '齐',
  42. }
  43. KNOWN_MACRO = ['碳水化合物', '蛋白质', '脂肪', '纤维素', '乳制品']
  44. KNOWN_AMINO = ['苏氨酸', '异亮氨酸', '亮氨酸', '赖氨酸', '蛋氨酸', '胱氨酸',
  45. '苯丙氨酸', '酪氨酸', '缬氨酸', '组氨酸', '丙氨酸', '丝氨酸', '甘氨酸',
  46. '脯氨酸', '谷氨酸', '天门冬氨酸', '天冬氨酸', '天冬酰胺', '谷氨酰胺',
  47. '精氨酸', '色氨酸']
  48. KNOWN_VITAMINS = ['维生素A', '维生素B1', '维生素B2', '维生素B5', '维生素B6',
  49. '叶酸', '维生素B12', '维生素C', '维生素D', '维生素K2', '维生素E']
  50. KNOWN_TRACE = ['铁', '锌']
  51. KNOWN_DISEASE_RISKS = ['炎症性肠炎', '肠易激综合征', '感染性腹泻', '自闭症',
  52. '抑郁症', '甲状腺疾病', '肺部感染或疾病', '自体免疫病', '结直肠癌',
  53. '肥胖', '便秘', '过敏', '失眠', '肝病', '肾病', '胃病', '胆病',
  54. '心脑血管疾病', 'II型糖尿病']
  55. KNOWN_BARRIER = ['肠道炎症水平', '肠道产气', '肠道屏障', '脂多糖LPS',
  56. '次级胆汁酸', '对甲酚(p-Cresol)', '吲哚', '苯酚', '腐胺', '硫化氢', '尸胺']
  57. KNOWN_SCFA = ['丁酸盐(Butyrate)', '丙酸盐(Propionate)', '乙酸盐(Acetate)', '异戊酸盐(Isovaleric)']
  58. KNOWN_NEURO = ['血清素(5-HT)', 'γ-氨基丁酸(GABA)', '谷氨酸(Glutamate)',
  59. '色氨酸(Tryptophan)', 'DOPAC', '多巴胺', '组胺(Histamine)', '一氧化氮',
  60. '喹啉(Quinolinic)', '维生素K2', '肌醇(Inositol)', '肾上腺素',
  61. '去甲肾上腺素', '乙酰胆碱', '皮质醇']
  62. KNOWN_ANTIBIOTICS = ['β-内酰胺酶类', '氨基糖苷类', '大环内酯类', '呋喃类',
  63. '喹诺酮类', '磺胺类', '甲氧苄啶类', '氯霉素类', '四环素类']
  64. KNOWN_PATHOGENS = ['幽门螺杆菌', '艰难梭菌', '沙门氏菌', '志贺氏菌', '弯曲杆菌']
  65. # === 菌群表标题(完整版 extract_full_report_v5.py 移植) ===
  66. BACTERIA_TABLE_TITLES = [
  67. '核心菌属构成表', '益生菌', '有害菌属构成表',
  68. '其它重要菌属构成表', '病原菌属构成表',
  69. ]
  70. PHYLUM_TABLE_TITLES = ['菌门构成表', '菌群门水平构成表', '门水平菌群构成']
  71. CLASS_TABLE_TITLES = ['菌纲构成表', '菌群纲水平构成表', '纲水平菌群构成']
  72. ORDER_TABLE_TITLES = ['菌目构成表', '菌群目水平构成表', '目水平菌群构成']
  73. FAMILY_TABLE_TITLES = ['菌科构成表', '菌群科水平构成表', '科水平菌群构成']
  74. GENUS_TABLE_TITLES = ['菌属构成表', '菌群属水平构成表', '属水平菌群构成']
  75. SPECIES_TABLE_TITLES = ['菌种构成表', '菌群种水平构成表', '种水平菌群构成']
  76. DISEASE_BACTERIA_TITLES = [
  77. '肥胖相关菌', '便秘相关菌', '抑郁相关菌', '过敏相关菌',
  78. '腹胀相关菌', '失眠相关菌', '肠道健康相关菌',
  79. '多动症相关菌', '自闭症相关菌',
  80. ]
  81. # === 食物推荐表(完整版 extract_full_report_v5.py 移植) ===
  82. COLUMNS_FOOD = ['名称', '分类', '推荐指数', '能量KJ', '蛋白g', '脂肪g',
  83. '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']
  84. KNOWN_CATS = ['主食', '乳制品', '干果', '坚果', '快餐', '水产品',
  85. '水果', '汤', '肉类', '蔬菜', '豆类及豆制品', '蛋类', '饮料']
  86. FOOD_SKIP_TEXTS = [
  87. '根据您的肠道菌群', '分值从-100', '食物推荐考虑', '食物推荐是综合',
  88. '需要注意的是', '本饮食推荐', '该饮食推荐根据', '后续表格中的营养',
  89. '16S 高通量测序', '基于机器学习和', '肠道菌群健康检测报告说明',
  90. '检测方法及局限性', '数据分析及模型', '结果解读及使用',
  91. '影响因素说明', '建议将检测结果', '营养建议说明',
  92. '重要提示', '推荐食物清单', '实际食用时需结合', '如有特殊疾病',
  93. '免责声明', '本检测报告仅供', '以上模型预测', '正常范围的定义',
  94. '募极生物',
  95. ]
  96. def norm(s):
  97. return ''.join(RADICAL_MAP.get(c, c) for c in s)
  98. def detect_format(pages_text):
  99. """检测 triplet / inline 格式(v5 detect_format 移植,页面级判定)
  100. 仅当同一页同时含'疾病风险评估'+'指标范围'且存在内嵌数字的行时才算 inline,
  101. 否则按 triplet 处理(标准模板报告为 triplet,报告类 PDF 为 inline)。
  102. """
  103. for pt in pages_text:
  104. t = norm(pt)
  105. if '疾病风险评估' in t and '指标范围' in t:
  106. for line in t.split('\n'):
  107. ls = line.strip()
  108. if not ls:
  109. continue
  110. if re.search(r'[\u4e00-\u9fff]+\d+\.?\d*[\u4e00-\u9fff]+', ls):
  111. return 'inline'
  112. return 'triplet'
  113. return 'triplet'
  114. def extract_text(file_path):
  115. """读取 PDF 并提取文本"""
  116. reader = PdfReader(file_path)
  117. lines = []
  118. for page in reader.pages:
  119. text = norm(page.extract_text() or '')
  120. for line in text.split('\n'):
  121. ls = line.strip()
  122. if ls:
  123. lines.append(ls)
  124. return lines
  125. def parse_overview(lines):
  126. """提取报告概述"""
  127. text = '\n'.join(lines)
  128. r = {}
  129. m = re.search(r'编号[::\s]*(\d+)', text)
  130. if m: r['report_number'] = m.group(1)
  131. m = re.search(r'姓名[::\s]*([\u4e00-\u9fff]{2,10})', text)
  132. if m: r['person_name'] = re.sub(r'(编号|年龄|性别|备注|肠道).*', '', m.group(1))[:4]
  133. m = re.search(r'年龄[::\s]*(\d+)', text)
  134. if m: r['age'] = int(m.group(1))
  135. m = re.search(r'性别[::\s]*([\u4e00-\u9fff])', text)
  136. if m: r['gender'] = 'male' if m.group(1) == '男' else 'female'
  137. for kw in ['健康总分', '菌群健康', '慢病控制', '营养均衡', '肠道菌群平衡',
  138. '菌群多样性', '有益菌', '有害菌', '核心菌属']:
  139. m = re.search(rf'{kw}\s*(\d+)', text)
  140. if m: r[kw] = int(m.group(1))
  141. m = re.search(r'肠道预测年龄[::\s]*([\d.]+)', text)
  142. if m: r['gut_age'] = m.group(1)
  143. m = re.search(r'肠型[::\s]*(\S+)', text)
  144. if m: r['gut_type'] = m.group(1)
  145. return r
  146. def parse_triplet_until(lines, stop_markers):
  147. """三元组解析:3行一组 名称/数值/状态"""
  148. results = []
  149. i = 0
  150. while i < len(lines):
  151. if any(lines[i] == sm or lines[i].startswith(sm) for sm in stop_markers):
  152. break
  153. if lines[i] in ('指标范围', '名称', '丰度', '评估'):
  154. i += 1
  155. continue
  156. name = lines[i]
  157. if i + 2 >= len(lines): break
  158. val = lines[i + 1]
  159. status = lines[i + 2]
  160. if re.match(r'^-?\d+\.?\d*$', val):
  161. results.append({'name': name, 'value': val, 'status': status})
  162. i += 3
  163. else:
  164. i += 1
  165. return results
  166. # ==========================================
  167. # 完整分区提取(移植自 extract_full_report_v5.py,修复分区错位)
  168. # 原问题:疾病风险 stop 标记缺'营养状况评估'导致吞并营养/氨基酸;
  169. # 维生素/微量元素标题在数据后(Case B)导致为空;屏障/SCFA/神经递质/病原菌/抗生素缺失
  170. # ==========================================
  171. def _extract_all_triplet(all_lines):
  172. """按'指标范围'数据页标记分段提取(v5 extract_all_triplet 移植)
  173. 数据页索引约定(v5 实证):
  174. data_pages[0] = 疾病风险页('疾病风险评估'标题前也有'指标范围')
  175. data_pages[1] = 营养状况评估页(含前5个氨基酸:苏氨酸/异亮氨酸/亮氨酸/赖氨酸/蛋氨酸)
  176. data_pages[2] = 剩余氨基酸页
  177. data_pages[3] = 维生素评估页(含微量元素)
  178. """
  179. data_pages = []
  180. for i, line in enumerate(all_lines):
  181. if line == '指标范围':
  182. data_pages.append(i)
  183. result = {}
  184. # 数据页1: 疾病风险评估(按已知疾病名过滤在调用方做)
  185. if len(data_pages) >= 1:
  186. i = data_pages[0] + 1
  187. if i < len(all_lines) and all_lines[i] == '疾病风险评估':
  188. i += 1
  189. rows = parse_triplet_until(all_lines[i:],
  190. ['主要营养评估', '氨基酸评估', '维生素评估', '微量元素评估',
  191. '主要消化道致病菌', '抗生素风险评估', '抗生素耐药风险'])
  192. result['disease_risks'] = [r for r in rows if '注:' not in r['name'] and '注:' not in r['name']]
  193. # 数据页2: 营养状况评估(含前5个氨基酸)
  194. if len(data_pages) >= 2:
  195. i = data_pages[1] + 1
  196. if i < len(all_lines) and all_lines[i] == '营养状况评估':
  197. i += 1
  198. rows = parse_triplet_until(all_lines[i:], ['主要营养评估', '氨基酸评估'])
  199. result['nutrition_rows'] = rows[:5]
  200. result['amino_p2'] = rows[5:10] if len(rows) > 5 else []
  201. # 数据页3: 剩余氨基酸
  202. if len(data_pages) >= 3:
  203. i = data_pages[2] + 1
  204. if i < len(all_lines) and '氨基酸' in all_lines[i]:
  205. while i < len(all_lines) and '氨基酸' in all_lines[i]:
  206. i += 1
  207. remaining = parse_triplet_until(all_lines[i:], ['氨基酸评估'])
  208. result['amino_p3'] = remaining
  209. # 数据页4: 维生素评估 + 微量元素
  210. if len(data_pages) >= 4:
  211. i = data_pages[3] + 1
  212. if i < len(all_lines) and all_lines[i] == '维生素评估':
  213. i += 1
  214. vit_rows = parse_triplet_until(all_lines[i:], ['微量元素评估', '主要消化道致病菌'])
  215. vit, trace = [], []
  216. for r in vit_rows:
  217. if '维生素' in r['name']:
  218. vit.append(r)
  219. else:
  220. trace.append(r)
  221. result['vitamin_rows'] = vit
  222. result['trace_rows'] = trace
  223. # 主要消化道致病菌(特殊格式:致病菌/丰度%/评估)
  224. for i, line in enumerate(all_lines):
  225. if line == '主要消化道致病菌':
  226. j = i + 1
  227. while j < len(all_lines) and all_lines[j] in ('致病菌', '丰度', '评估'):
  228. j += 1
  229. path_rows = []
  230. while j < len(all_lines) and not all_lines[j].startswith('肠道屏障'):
  231. name = all_lines[j]
  232. if j + 2 < len(all_lines) and re.match(r'^\d+%$', all_lines[j+1]):
  233. path_rows.append({'name': name, 'value': all_lines[j+1], 'status': all_lines[j+2]})
  234. j += 3
  235. else:
  236. j += 1
  237. result['pathogens'] = path_rows
  238. break
  239. # 抗生素风险评估(去重:同名只保留第一条)
  240. for i, line in enumerate(all_lines):
  241. if line == '抗生素风险评估':
  242. abx_rows = parse_triplet_until(all_lines[i+1:], ['抗生素耐药风险', '个体化食物推荐表'])
  243. seen = set()
  244. deduped = []
  245. for r in abx_rows:
  246. n = r.get('name', '')
  247. if n and n not in seen:
  248. seen.add(n)
  249. deduped.append(r)
  250. result['antibiotics'] = deduped
  251. break
  252. return result
  253. def _extract_barrier_and_scfa_triplet(all_lines):
  254. """肠道屏障及代谢物/短链脂肪酸/神经递质及激素 - 已知指标名精确匹配(v5 移植)"""
  255. results = {'barrier': [], 'scfa': [], 'neurotransmitter': []}
  256. current_section = None
  257. i = 0
  258. while i < len(all_lines):
  259. line = all_lines[i]
  260. if line == '肠道屏障及菌群代谢物':
  261. current_section = 'barrier'
  262. i += 1
  263. continue
  264. if '短链脂肪酸' in line:
  265. current_section = 'scfa'
  266. i += 1
  267. continue
  268. if '神经递质' in line:
  269. current_section = 'neurotransmitter'
  270. i += 1
  271. continue
  272. if current_section is None:
  273. i += 1
  274. continue
  275. # 跳过头行
  276. if line in ('名称', '评估值', '正常范围', '过量', '缺乏', '相关症状') or \
  277. '过量 /' in line or '缺乏' in line:
  278. i += 1
  279. continue
  280. # 提前终止
  281. if current_section == 'barrier' and line in ('短链脂肪酸', '抗生素风险', '抗生素耐药风险'):
  282. break
  283. if current_section == 'scfa' and line in ('神经递质', '神经递质及激素指标', '神经递质及激素', '抗生素风险'):
  284. break
  285. if current_section == 'neurotransmitter' and line in ('抗生素风险', '个体化食物推荐表'):
  286. break
  287. # 只处理已知指标名
  288. known = {'barrier': KNOWN_BARRIER, 'scfa': KNOWN_SCFA, 'neurotransmitter': KNOWN_NEURO}
  289. known_list = known[current_section]
  290. matched_name = None
  291. for kn in sorted(known_list, key=len, reverse=True):
  292. if line.startswith(kn) or line == kn:
  293. matched_name = kn
  294. break
  295. if matched_name is None:
  296. i += 1
  297. continue
  298. # 模式1:整行解析 名称+数值+状态+范围(inline 单行)
  299. parts = line.split()
  300. if len(parts) >= 2:
  301. vi = None
  302. for pi, p in enumerate(parts):
  303. if re.match(r'^\d+$', p) and pi >= 1:
  304. vi = pi
  305. break
  306. if vi:
  307. val = parts[vi]
  308. status = ''
  309. range_ = ''
  310. for rp in parts[vi+1:]:
  311. if rp in ('正常', '过多', '轻度产气', '过低', '不足', '缺乏', '不⾜'):
  312. status = rp
  313. elif re.match(r'^\d+-\d+$', rp):
  314. range_ = rp
  315. results[current_section].append({
  316. 'name': matched_name, 'value': val, 'status': status, 'refRange': range_
  317. })
  318. i += 1
  319. continue
  320. # 模式2:下一行是数值(三元组)
  321. if i + 1 < len(all_lines) and re.match(r'^\d+$', all_lines[i+1]):
  322. val = all_lines[i+1]
  323. status = ''
  324. range_ = ''
  325. for j in range(2, min(6, len(all_lines)-i)):
  326. if all_lines[i+j] in ('正常', '过多', '轻度产气', '过低', '不足', '缺乏', '不⾜'):
  327. status = all_lines[i+j]
  328. elif re.match(r'^\d+-\d+$', all_lines[i+j]):
  329. range_ = all_lines[i+j]
  330. results[current_section].append({
  331. 'name': matched_name, 'value': val, 'status': status, 'refRange': range_
  332. })
  333. i += 2
  334. continue
  335. i += 1
  336. # 去重(每个 section 内每个指标名只保留一条)
  337. for section in ('barrier', 'scfa', 'neurotransmitter'):
  338. seen = set()
  339. deduped = []
  340. for item in results[section]:
  341. n = item.get('name', '')
  342. if n and n not in seen:
  343. seen.add(n)
  344. deduped.append(item)
  345. results[section] = deduped
  346. return results
  347. def _extract_names_from_region(region_str, known_names, end_pos):
  348. """从区域文本中提取已知名称+数值+状态(v5 移植)"""
  349. for h in ['肠道屏障及菌群代谢物名称评估值正常范围过量 / 缺乏相关症状',
  350. '名称评估值正常范围过量 / 缺乏相关症状',
  351. '短链脂肪酸评估值正常范围过量 / 缺乏相关症状',
  352. '神经递质及激素评估值正常范围过量 / 缺乏相关症状',
  353. '过量 / 缺乏相关症状']:
  354. region_str = region_str.replace(h, '')
  355. positions = []
  356. for kn in known_names:
  357. # 找所有匹配位置,选第一个后面有数字(允许空格)且不是头部标记的
  358. search_start = 0
  359. while True:
  360. ii = region_str.find(kn, search_start)
  361. if ii == -1:
  362. break
  363. after = region_str[ii+len(kn):ii+len(kn)+10].lstrip()
  364. if re.match(r'\d', after):
  365. positions.append((ii, kn))
  366. break # 找到第一个有效匹配
  367. search_start = ii + 1
  368. if not positions:
  369. return []
  370. # 去重:同名先后取前面(较精准的),不同名重叠取较长的
  371. positions.sort()
  372. filtered = []
  373. for i, (pos, name) in enumerate(positions):
  374. has_longer = False
  375. for j, (pos2, name2) in enumerate(positions):
  376. if i != j and pos2 <= pos and pos + len(name) <= pos2 + len(name2) and len(name2) > len(name):
  377. has_longer = True
  378. break
  379. if not has_longer:
  380. filtered.append((pos, name))
  381. positions = filtered
  382. seen_names = {}
  383. for pos, name in positions:
  384. if name not in seen_names:
  385. seen_names[name] = pos
  386. positions = [(pos, name) for name, pos in seen_names.items()]
  387. positions.sort()
  388. local = []
  389. for i, (ii, kn) in enumerate(positions):
  390. start_after = ii + len(kn)
  391. after_text = region_str[start_after:].lstrip()
  392. vm = re.match(r'(\d+(?:\.\d+)?)', after_text)
  393. if not vm:
  394. continue
  395. val = vm.group(1)
  396. whitespace_skipped = len(region_str[start_after:]) - len(after_text)
  397. val_start = start_after + whitespace_skipped
  398. val_end = val_start + len(val)
  399. if i + 1 < len(positions):
  400. next_idx = positions[i + 1][0]
  401. status = region_str[val_end:next_idx].strip()
  402. else:
  403. status = region_str[val_end:end_pos].strip()
  404. # 清理状态中的残留标记
  405. status = re.sub(
  406. r'注意大环内酯类|注意呋喃类|注意氯霉素类|注意喹诺酮类|注意磺胺类|'
  407. r'注意甲氧苄啶类|注意四环素类|正常氨基酸评估|正常维生素评估|'
  408. r'正常微量元素评估|指标范围|疾病风险评估|营养状况评估|'
  409. r'抗生素风险评估|抗生素耐药风险|主要消化道致病菌',
  410. '', status).strip()
  411. # 截断:状态中出现'注'(如'注:本检测仅用于...')只取之前部分
  412. status = re.split(r'注[::]', status)[0].strip()
  413. if len(status) > 20:
  414. status = status[:20]
  415. local.append({'name': kn, 'value': val, 'status': status})
  416. return local
  417. def _extract_inline_module(all_text, start_marker, end_markers, known_names):
  418. """从 inline 文本中提取已知名称的指标(v5 移植)
  419. 支持两种布局:
  420. A) 标题在前、数据在后(标准)
  421. B) 标题在后、数据在前('指标范围'与标题之间)
  422. """
  423. sm_idx = all_text.find(start_marker)
  424. if sm_idx == -1:
  425. return []
  426. end_pos = len(all_text)
  427. for em in end_markers:
  428. ei = all_text.find(em, sm_idx + len(start_marker))
  429. if ei != -1 and ei < end_pos:
  430. end_pos = ei
  431. # Case A: 标题在前,数据在标题和 end_marker 之间
  432. region_a = all_text[sm_idx:end_pos]
  433. result_a = _extract_names_from_region(region_a, known_names, len(region_a))
  434. # Case B: 标题在后,数据在'指标范围'和标题之间
  435. zb_idx = all_text.rfind('指标范围', 0, sm_idx)
  436. if zb_idx == -1:
  437. zb_idx = max(0, sm_idx - 500)
  438. region_b = all_text[zb_idx:sm_idx]
  439. result_b = _extract_names_from_region(region_b, known_names, len(region_b))
  440. # 取匹配多的那个
  441. if len(result_a) >= len(result_b):
  442. return result_a
  443. return result_b
  444. def _extract_pathogens_inline(text):
  445. """提取 inline 格式中主要消化道致病菌数据(幽门螺杆菌0%未检出...)"""
  446. results = []
  447. sidx = text.find('主要消化道致病菌')
  448. if sidx == -1:
  449. return results
  450. eidx = text.find('肠道屏障', sidx)
  451. if eidx == -1:
  452. eidx = len(text)
  453. region = text[sidx:eidx]
  454. for name in KNOWN_PATHOGENS:
  455. idx = region.find(name)
  456. if idx == -1:
  457. continue
  458. after = region[idx + len(name):]
  459. m = re.match(r'\s*(\d+%)', after)
  460. if not m:
  461. continue
  462. abundance = m.group(1)
  463. assessment_start = idx + len(name) + m.end()
  464. assessment_end = len(region)
  465. for next_name in KNOWN_PATHOGENS:
  466. if next_name == name:
  467. continue
  468. ni = region.find(next_name, assessment_start)
  469. if ni != -1 and ni < assessment_end:
  470. assessment_end = ni
  471. assessment = region[assessment_start:assessment_end].strip()
  472. results.append({'name': name, 'value': abundance, 'status': assessment})
  473. return results
  474. def _final_fallback_scan(full_text, result, section_known_map):
  475. """最终的全文正则扫描——弥补所有提取器漏掉的已知指标项(v5 移植)"""
  476. for section, known_list in section_known_map.items():
  477. existing = {r['name'] for r in result.get(section, [])}
  478. for name in known_list:
  479. if name in existing:
  480. continue
  481. # 搜索多个变体:完整名、去掉括号的后缀
  482. search_names = [name]
  483. paren = name.find('(')
  484. if paren != -1:
  485. search_names.append(name[:paren])
  486. found = False
  487. for sname in search_names:
  488. idx = full_text.find(sname)
  489. if idx == -1:
  490. continue
  491. # 跳过名称之后紧跟着的重复名称(正字+部首两次出现的情况)
  492. skip = idx + len(sname)
  493. while skip < len(full_text):
  494. ch = full_text[skip]
  495. if ch == '\n' or ch == ' ':
  496. skip += 1
  497. elif '\u4e00' <= ch <= '\u9fff' or '\u2f00' <= ch <= '\u2fdf':
  498. skip += 1
  499. else:
  500. break
  501. after = full_text[skip:].lstrip()
  502. m = re.match(r'(\d+(?:\.\d+)?)', after)
  503. if not m:
  504. continue
  505. val = m.group(1)
  506. val_end_in_after = m.end()
  507. status_raw = after[val_end_in_after:].lstrip()
  508. status_m = re.search(r'([\u4e00-\u9fff/]+)', status_raw)
  509. status = status_m.group(1).strip() if status_m else status_raw[:20]
  510. if len(status) > 20:
  511. status = status[:20]
  512. result.setdefault(section, []).append({'name': name, 'value': val, 'status': status})
  513. found = True
  514. break
  515. return result
  516. def parse_report_pdf(file_path: str) -> dict:
  517. """主函数:解析 PDF 返回结构化数据(v5 完整提取逻辑移植)"""
  518. reader = PdfReader(file_path)
  519. pages_text = [p.extract_text() or '' for p in reader.pages]
  520. lines = extract_text(file_path)
  521. full_text = '\n'.join(lines)
  522. # 指纹检测:北京菌群报告
  523. if detect_beijing_format(full_text):
  524. return _parse_beijing_report(file_path)
  525. fmt = detect_format(pages_text)
  526. result = {'format': fmt, 'overview': parse_overview(lines)}
  527. _SECTION_KNOWN_MAP = {
  528. 'disease_risks': KNOWN_DISEASE_RISKS,
  529. 'nutrition': KNOWN_MACRO,
  530. 'amino_acids': KNOWN_AMINO,
  531. 'vitamins': KNOWN_VITAMINS,
  532. 'trace_elements': KNOWN_TRACE,
  533. '抗生素风险评估': KNOWN_ANTIBIOTICS,
  534. '肠道屏障及代谢物': KNOWN_BARRIER,
  535. '短链脂肪酸': KNOWN_SCFA,
  536. '神经递质及激素': KNOWN_NEURO,
  537. }
  538. if fmt == 'triplet':
  539. parsed = _extract_all_triplet(lines)
  540. # 疾病风险评估(已知疾病名词表过滤)
  541. disease_set = set(KNOWN_DISEASE_RISKS)
  542. result['disease_risks'] = [r for r in parsed.get('disease_risks', []) if r['name'] in disease_set]
  543. # 兜底:inline 方式补充 triplet 漏掉的项目
  544. for r in _extract_inline_module(full_text, '疾病风险评估',
  545. ['主要营养评估', '主要消化道致病菌'], KNOWN_DISEASE_RISKS):
  546. if r['name'] not in {x['name'] for x in result['disease_risks']}:
  547. result['disease_risks'].append(r)
  548. # 主要营养评估
  549. nutri_set = set(KNOWN_MACRO)
  550. result['nutrition'] = [r for r in parsed.get('nutrition_rows', []) if r['name'] in nutri_set]
  551. for r in _extract_inline_module(full_text, '营养状况评估',
  552. ['主要营养评估', '氨基酸评估'], KNOWN_MACRO):
  553. if r['name'] not in {x['name'] for x in result['nutrition']}:
  554. result['nutrition'].append(r)
  555. # 氨基酸评估(营养页内嵌 p2 + 独立页 p3 合并 + inline 兜底)
  556. amino_rows = parsed.get('amino_p2', []) + parsed.get('amino_p3', [])
  557. amino_set = set(KNOWN_AMINO)
  558. result['amino_acids'] = [r for r in amino_rows if r['name'] in amino_set]
  559. for r in _extract_inline_module(full_text, '氨基酸评估',
  560. ['维生素评估', '主要消化道致病菌'], KNOWN_AMINO):
  561. if r['name'] not in {x['name'] for x in result['amino_acids']}:
  562. result['amino_acids'].append(r)
  563. # 维生素评估 / 微量元素评估
  564. vit_set = set(KNOWN_VITAMINS)
  565. trace_set = set(KNOWN_TRACE)
  566. result['vitamins'] = [r for r in parsed.get('vitamin_rows', []) if r['name'] in vit_set]
  567. result['trace_elements'] = [r for r in parsed.get('trace_rows', []) if r['name'] in trace_set]
  568. for r in _extract_inline_module(full_text, '维生素评估',
  569. ['微量元素评估', '主要消化道致病菌'], KNOWN_VITAMINS):
  570. if r['name'] not in {x['name'] for x in result['vitamins']}:
  571. result['vitamins'].append(r)
  572. for r in _extract_inline_module(full_text, '微量元素评估',
  573. ['主要消化道致病菌'], KNOWN_TRACE):
  574. if r['name'] not in {x['name'] for x in result['trace_elements']}:
  575. result['trace_elements'].append(r)
  576. # 主要消化道致病菌
  577. result['主要消化道致病菌'] = parsed.get('pathogens', [])
  578. # 抗生素风险评估(已知抗生素词表过滤)
  579. abx_set = set(KNOWN_ANTIBIOTICS)
  580. result['抗生素风险评估'] = [r for r in parsed.get('antibiotics', []) if r['name'] in abx_set]
  581. # 肠道屏障 + 短链脂肪酸 + 神经递质(已知指标名精确匹配)
  582. bs = _extract_barrier_and_scfa_triplet(lines)
  583. result['肠道屏障及代谢物'] = bs['barrier']
  584. result['短链脂肪酸'] = bs['scfa']
  585. result['神经递质及激素'] = bs['neurotransmitter']
  586. # inline 兜底补充
  587. for section_key, start_mk, end_mks, known_list in [
  588. ('肠道屏障及代谢物', '肠道屏障及菌群代谢物',
  589. ['短链脂肪酸', '神经递质', '抗生素风险'], KNOWN_BARRIER),
  590. ('短链脂肪酸', '短链脂肪酸',
  591. ['神经递质', '抗生素风险'], KNOWN_SCFA),
  592. ('神经递质及激素', '神经递质',
  593. ['抗生素风险', '个体化食物推荐表'], KNOWN_NEURO),
  594. ]:
  595. existing = {r['name'] for r in result.get(section_key, [])}
  596. for r in _extract_inline_module(full_text, start_mk, end_mks, known_list):
  597. if r['name'] not in existing:
  598. result.setdefault(section_key, []).append(r)
  599. existing.add(r['name'])
  600. # 最终兜底:全文正则扫描所有已知指标名
  601. _final_fallback_scan(full_text, result, _SECTION_KNOWN_MAP)
  602. else:
  603. # inline 格式(v5 extract_pdf_to_json inline 分支移植)
  604. result['disease_risks'] = _extract_inline_module(full_text, '疾病风险评估',
  605. ['主要营养评估', '主要消化道致病菌'], KNOWN_DISEASE_RISKS)
  606. result['nutrition'] = _extract_inline_module(full_text, '营养状况评估',
  607. ['主要营养评估', '氨基酸评估'], KNOWN_MACRO)
  608. result['amino_acids'] = _extract_inline_module(full_text, '营养状况评估',
  609. ['主要营养评估', '氨基酸评估'], KNOWN_AMINO)
  610. amino_p2 = _extract_inline_module(full_text, '氨基酸评估',
  611. ['维生素评估', '主要消化道致病菌'], KNOWN_AMINO)
  612. for a in amino_p2:
  613. if a['name'] not in {x['name'] for x in result['amino_acids']}:
  614. result['amino_acids'].append(a)
  615. result['vitamins'] = _extract_inline_module(full_text, '维生素评估',
  616. ['微量元素评估', '主要消化道致病菌'], KNOWN_VITAMINS)
  617. result['trace_elements'] = _extract_inline_module(full_text, '微量元素评估',
  618. ['主要消化道致病菌'], KNOWN_TRACE)
  619. result['主要消化道致病菌'] = _extract_pathogens_inline(full_text)
  620. result['抗生素风险评估'] = _extract_inline_module(full_text, '抗生素风险评估',
  621. ['抗生素耐药风险', '个体化食物推荐表'], KNOWN_ANTIBIOTICS)
  622. result['肠道屏障及代谢物'] = _extract_inline_module(full_text, '肠道屏障及菌群代谢物',
  623. ['短链脂肪酸', '神经递质', '抗生素风险'], KNOWN_BARRIER)
  624. result['短链脂肪酸'] = _extract_inline_module(full_text, '短链脂肪酸',
  625. ['神经递质', '抗生素风险'], KNOWN_SCFA)
  626. result['神经递质及激素'] = _extract_inline_module(full_text, '神经递质',
  627. ['抗生素风险', '个体化食物推荐表'], KNOWN_NEURO)
  628. _final_fallback_scan(full_text, result, _SECTION_KNOWN_MAP)
  629. # 菌群检出详细列表(核心/益生菌/有害菌/病原菌 + 门纲目科属种)
  630. bacteria_tables = _extract_bacteria_tables(file_path)
  631. result['菌群检出详细列表'] = bacteria_tables
  632. # 个体化食物推荐表(保留原始键名与结构)
  633. food_rows, food_fmt = _extract_food_rows(file_path)
  634. result['个体化食物推荐表'] = {
  635. '格式': food_fmt,
  636. '条目数': len(food_rows),
  637. '数据': food_rows
  638. }
  639. # 统一结构:北京报告特有字段(标准报告为空)
  640. result['肠道微生物健康指数'] = {}
  641. result['菌群多样性'] = {}
  642. result['肠道黏膜屏障'] = {}
  643. result['菌群表型评估'] = {}
  644. result['短链脂肪酸合成能力'] = {}
  645. result['肠道菌群精准分布'] = []
  646. result['营养物质及营养素代谢评估'] = {}
  647. result.setdefault('抗生素风险评估', {})
  648. result['毒性物质清除能力评估'] = {}
  649. result['趣味肠菌评估'] = {}
  650. result['胃肠道感染病原体'] = []
  651. result['健康整体评估'] = {}
  652. result['肠道菌群主要检测结果'] = {}
  653. return result
  654. def parse_report_pdf_with_fallback(file_path: str) -> dict:
  655. """算法解析 + 简单校验,返回结构化数据"""
  656. result = parse_report_pdf(file_path)
  657. # 简单校验:如果关键字段缺失,标记为解析不完整
  658. if not result.get('overview', {}).get('overallScore') and \
  659. not result.get('overview', {}).get('健康总分'):
  660. result['_parse_incomplete'] = True
  661. return result
  662. def _parse_bacteria_table(reader, pages_text, full_text, title, fmt, skip_header=False):
  663. """从PDF中解析一个菌群表格(移植自 extract_full_report_v5.py)"""
  664. results = []
  665. sidx = full_text.find(title)
  666. if sidx == -1:
  667. return results
  668. # 病原菌检出表特殊处理:找"仅列出检出的病原菌"(跳过前面的说明文字中的"病原菌")
  669. if title == '病原菌':
  670. better_sidx = full_text.find('仅列出检出的病原菌')
  671. if better_sidx != -1:
  672. sidx = better_sidx
  673. # 找表格结束位置(下一个标题或页尾)
  674. end_pos = len(full_text)
  675. for t in (BACTERIA_TABLE_TITLES + PHYLUM_TABLE_TITLES + CLASS_TABLE_TITLES
  676. + ORDER_TABLE_TITLES + FAMILY_TABLE_TITLES + GENUS_TABLE_TITLES
  677. + SPECIES_TABLE_TITLES + DISEASE_BACTERIA_TITLES
  678. + ['指标范围', '个体化食物推荐表', '报告总结', '健康总分']):
  679. if t == title:
  680. continue
  681. ei = full_text.find(t, sidx + len(title))
  682. if ei != -1 and ei < end_pos:
  683. end_pos = ei
  684. region = full_text[sidx:end_pos]
  685. # 检测区域实际格式:如果换行数很少(<3)则是inline格式,即使全局fmt=triplet
  686. lines_from_region = [l.strip() for l in region.split('\n') if l.strip()]
  687. actual_fmt = fmt
  688. if fmt == 'triplet' and len(lines_from_region) <= 5:
  689. actual_fmt = 'inline'
  690. if actual_fmt == 'triplet':
  691. # 三元组格式:每个字段单独一行
  692. lines = [l.strip() for l in region.split('\n') if l.strip()]
  693. start = 0
  694. for i, line in enumerate(lines):
  695. if line == '名称':
  696. start = i + 1
  697. break
  698. if line.startswith('名称'):
  699. if skip_header:
  700. start = i + 1
  701. break
  702. i = start
  703. while i < len(lines):
  704. name = lines[i]
  705. if not name or len(name) <= 1 or name in ['说明', '检测结果', '结果解释', '建议']:
  706. i += 1
  707. continue
  708. if name.startswith('说明:') or name.startswith('改善方式'):
  709. i += 1
  710. continue
  711. if len(name) > 80:
  712. i += 1
  713. continue
  714. # 找丰度%
  715. if i + 1 < len(lines) and re.match(r'^[\d]+\.?[\d]*%?$|^ND$', lines[i + 1]):
  716. pct = lines[i + 1]
  717. normal_range = ''
  718. pop_level = ''
  719. detection_rate = ''
  720. desc = ''
  721. j = i + 2
  722. # 正常范围 (允许小数,如 0.06-6.96, 0.03-3.07)
  723. if j < len(lines) and re.match(r'^[\d]+\.?[\d]*-[\d]+\.?[\d]*$', lines[j]):
  724. normal_range = lines[j]
  725. j += 1
  726. # 人群水平% (允许小数,如 15.66%)
  727. if j < len(lines) and re.match(r'^\d+\.?\d*%$', lines[j]):
  728. pop_level = lines[j]
  729. j += 1
  730. # 检出率%
  731. if j < len(lines) and re.match(r'^\d+\.?\d*%$', lines[j]):
  732. detection_rate = lines[j]
  733. j += 1
  734. # 说明
  735. if j < len(lines) and lines[j].startswith('说明'):
  736. desc = lines[j]
  737. j += 1
  738. # 改善方式
  739. if j < len(lines) and lines[j].startswith('改善方式'):
  740. if desc:
  741. desc += ' | ' + lines[j]
  742. else:
  743. desc = lines[j]
  744. j += 1
  745. entry = {'名称': name, '丰度%': pct}
  746. if normal_range:
  747. entry['正常范围%'] = normal_range
  748. if pop_level:
  749. entry['人群水平%'] = pop_level
  750. if detection_rate:
  751. entry['检出率%'] = detection_rate
  752. if desc:
  753. entry['说明'] = desc
  754. results.append(entry)
  755. i = j
  756. else:
  757. i += 1
  758. else:
  759. # inline 格式
  760. sample = region[:500]
  761. truly_compressed = bool(re.search(r'[a-z]\d', sample, re.IGNORECASE))
  762. if not truly_compressed:
  763. # 有空格分隔的inline格式,使用re.finditer
  764. region_clean = region
  765. for hdr in ['名称', '丰度%', '正常范围%', '处于人群%水平', '%正常人有检出',
  766. '人群水平%', '%人检出']:
  767. region_clean = region_clean.replace(hdr, '')
  768. region_clean = re.sub(
  769. r'说明:[\u4e00-\u9fff\s,。、;:,.;:()()、/a-zA-Z0-9\-]{10,}?(?=[\u4e00-\u9fff]|$)',
  770. '', region_clean)
  771. # 扫描所有匹配的数据行
  772. for m in re.finditer(
  773. r'([\u4e00-\u9fff]{2,12}(?:[((][\u4e00-\u9fff\w]+[))])?)\s+' # 中文名
  774. r'(?:[A-Z][a-z]+(?:\s[A-Z][a-z]+)*\s+)?' # 可选英文名
  775. r'(ND|[\d]+\.?[\d]*%?)\s+' # 丰度
  776. r'([\d]+\.?[\d]*-[\d]+\.?[\d]*)?\s*' # 可选正常范围
  777. r'(\d+\.?\d*%?)\s+' # 人群水平%
  778. r'(\d+\.?\d*%)', # 检出率%
  779. region_clean):
  780. name = m.group(1).strip()
  781. pct = m.group(2)
  782. if '病原菌' in name or '构成表' in name or '说明' in name or len(name) <= 1:
  783. continue
  784. normal_range = m.group(3) or ''
  785. pop_level = m.group(4)
  786. if not pop_level.endswith('%'):
  787. pop_level += '%'
  788. detection_rate = m.group(5)
  789. entry = {'名称': name, '丰度%': pct}
  790. if normal_range:
  791. entry['正常范围%'] = normal_range
  792. entry['人群水平%'] = pop_level
  793. entry['检出率%'] = detection_rate
  794. results.append(entry)
  795. # 模式1没有匹配时:仅中文名+丰度+人群水平(+检出率)
  796. if not results:
  797. for m in re.finditer(
  798. r'([\u4e00-\u9fff]{2,10}[\u4e00-\u9fff]?)\s+'
  799. r'(ND|[\d]+\.?[\d]*%?)\s+'
  800. r'(\d+\.?\d*%)\s+'
  801. r'(\d+\.?\d*%)?',
  802. region_clean):
  803. name = m.group(1).strip()
  804. pct = m.group(2)
  805. if '病原菌' in name or '构成表' in name or '说明' in name or len(name) <= 1:
  806. continue
  807. pop_level = m.group(3)
  808. detection_rate = m.group(4) or ''
  809. entry = {'名称': name, '丰度%': pct, '人群水平%': pop_level}
  810. if detection_rate:
  811. entry['检出率%'] = detection_rate
  812. results.append(entry)
  813. return results
  814. def _parse_phylum_tables(reader, full_text, fmt, title_list=None):
  815. """提取菌群层级构成表(门/纲/目/科/属/种 level)(移植自 extract_full_report_v5.py)"""
  816. if title_list is None:
  817. title_list = PHYLUM_TABLE_TITLES
  818. results = []
  819. for phylum_title in title_list:
  820. rows = _parse_bacteria_table(reader, None, full_text, phylum_title, fmt)
  821. results.extend(rows)
  822. return results
  823. def _parse_taxonomy_levels(reader, full_text, fmt):
  824. """从"菌群检出详细列表"中提取纲目科属种各级数据(移植自 extract_full_report_v5.py)"""
  825. results = {}
  826. sidx = full_text.find('菌群检出详细列表')
  827. if sidx == -1:
  828. return results
  829. end_pos = len(full_text)
  830. for t in ['个体化食物推荐表', '报告总结', '健康总分']:
  831. ei = full_text.find(t, sidx)
  832. if ei != -1 and ei < end_pos:
  833. end_pos = ei
  834. section = full_text[sidx:end_pos]
  835. for level, level_name in [('纲', '菌纲构成'), ('目', '菌目构成'),
  836. ('科', '菌科构成'), ('属', '菌属构成'),
  837. ('种', '菌种构成')]:
  838. marker = f'\n{level}\n名称\n丰度%'
  839. marker2 = f'{level} 名称 丰度%'
  840. li = section.find(marker)
  841. level_start = None
  842. if li == -1:
  843. li2 = section.find(marker2)
  844. if li2 != -1:
  845. li = li2
  846. level_start = li2 + len(marker2)
  847. else:
  848. compressed_marker = f'{level}名称丰度%人群水平%%人检出'
  849. cli = section.find(compressed_marker)
  850. if cli == -1:
  851. continue
  852. # 压缩格式解析:用正则提取数据
  853. level_start = cli + len(compressed_marker)
  854. level_end = len(section)
  855. for next_level in ['目', '科', '属', '种']:
  856. if next_level == level:
  857. continue
  858. ni = section.find(f'{next_level}名称丰度%人群水平%%人检出', level_start)
  859. if ni != -1 and ni < level_end:
  860. level_end = ni
  861. break
  862. level_region = section[level_start:level_end]
  863. compressed_pattern = re.compile(
  864. r'([\u4e00-\u9fff·]+(?:\s[\u4e00-\u9fff·]+)?\s+)?' # 可选中文名
  865. r'([A-Za-z][A-Za-z\s.\-]*?)' # 拉丁名(可能含空格)
  866. r'(\d+\.?\d*%)(\d+\.?\d*%)(\d+\.?\d*%)' # 三连百分比
  867. )
  868. rows = []
  869. for m in compressed_pattern.finditer(level_region):
  870. cn_name = (m.group(1) or '').strip()
  871. latin_name = m.group(2).strip()
  872. pct = m.group(3)
  873. pop_level = m.group(4)
  874. detection = m.group(5)
  875. name = cn_name if cn_name else latin_name
  876. entry = {'名称': name, '丰度%': pct, '人群水平%': pop_level, '检出率%': detection}
  877. rows.append(entry)
  878. if rows:
  879. results[level_name] = rows
  880. continue
  881. else:
  882. level_start = li + len(marker)
  883. level_end = len(section)
  884. for next_level in ['纲', '目', '科', '属', '种']:
  885. if next_level == level:
  886. continue
  887. ni = section.find(f'\n{next_level}\n名称', level_start)
  888. if ni != -1 and ni < level_end:
  889. level_end = ni
  890. break
  891. level_region = section[level_start:level_end]
  892. lines = [l.strip() for l in level_region.split('\n') if l.strip()]
  893. rows = []
  894. i = 0
  895. while i < len(lines):
  896. if lines[i] in ['名称', '丰度%', '人群水平%', '%人检出']:
  897. i += 1
  898. continue
  899. name = lines[i]
  900. if i + 2 < len(lines) and re.match(r'^[\d]+\.?[\d]*%?$', lines[i + 1]):
  901. pct = lines[i + 1]
  902. pop_level = lines[i + 2] if i + 2 < len(lines) else ''
  903. detection = lines[i + 3] if i + 3 < len(lines) and re.match(r'^[\d.]+%$', lines[i + 3]) else ''
  904. entry = {'名称': name, '丰度%': pct, '人群水平%': pop_level}
  905. if detection:
  906. entry['检出率%'] = detection
  907. rows.append(entry)
  908. i += 4 if detection else 3
  909. else:
  910. i += 1
  911. if rows:
  912. results[level_name] = rows
  913. return results
  914. def _extract_bacteria_tables(pdf_path):
  915. """提取菌群检出详细列表,返回 {中文分组名: [行]}(移植自 extract_full_report_v5.py)
  916. 与原始脚本保持一致的文本构造与格式检测:full_text 使用
  917. '\\n'.join(norm(p.extract_text()) for p in reader.pages)(不 strip、不过滤空行),
  918. fmt 使用原脚本 L672-683 的独立检测逻辑。
  919. """
  920. reader = PdfReader(pdf_path)
  921. full_text = '\n'.join(norm(p.extract_text()) for p in reader.pages)
  922. # 原脚本 extract_bacteria_tables 的格式检测(L674-683)
  923. fmt = 'triplet' if '指标范围' in full_text and '疾病风险评估' in full_text else 'inline'
  924. for pt in [p.extract_text() for p in reader.pages]:
  925. t = norm(pt)
  926. if '疾病风险评估' in t and '指标范围' in t:
  927. for line in t.split('\n'):
  928. if re.search(r'[\u4e00-\u9fff]+\d+\.?\d*[\u4e00-\u9fff]+', line.strip()):
  929. fmt = 'inline'
  930. break
  931. break
  932. all_tables = {}
  933. # 核心菌属构成表1-3
  934. core_genus = []
  935. for i in range(1, 4):
  936. title = f'核心菌属构成表{i}'
  937. rows = _parse_bacteria_table(reader, None, full_text, title, fmt)
  938. core_genus.extend(rows)
  939. all_tables['核心菌属'] = core_genus
  940. # 益生菌(使用更精确的表头定位,跳过前面的说明文字)
  941. prob_marker = '仅列出丰度前22的益生菌种'
  942. prob_sidx = full_text.find(prob_marker)
  943. if prob_sidx != -1:
  944. prob_rows = _parse_bacteria_table(reader, None, full_text, prob_marker, fmt, skip_header=True)
  945. else:
  946. prob_rows = _parse_bacteria_table(reader, None, full_text, '益生菌', fmt, skip_header=True)
  947. all_tables['益生菌'] = [r for r in prob_rows if r.get('名称') and r['名称'] not in
  948. ['我的益生菌都为ND', '仅列出丰度前22的益生菌种。']]
  949. # 有害菌属构成表1-2
  950. harmful = []
  951. for i in range(1, 3):
  952. title = f'有害菌属构成表{i}'
  953. rows = _parse_bacteria_table(reader, None, full_text, title, fmt)
  954. harmful.extend(rows)
  955. all_tables['有害菌属'] = harmful
  956. # 其它重要菌属
  957. other_rows = _parse_bacteria_table(reader, None, full_text, '其它重要菌属构成表', fmt)
  958. all_tables['其它重要菌属'] = other_rows
  959. # 病原菌属构成表
  960. patho_genus = _parse_bacteria_table(reader, None, full_text, '病原菌属构成表', fmt)
  961. all_tables['病原菌属'] = patho_genus
  962. # 病原菌(检出列表)
  963. patho_rows = _parse_bacteria_table(reader, None, full_text, '病原菌', fmt, skip_header=True)
  964. all_tables['病原菌检出'] = [r for r in patho_rows if r.get('名称') and len(r['名称']) >= 2
  965. and '仅列出' not in r['名称'] and '说明' not in r['名称']]
  966. # 菌门构成表(phylum level)
  967. phylum_rows = _parse_phylum_tables(reader, full_text, fmt)
  968. if phylum_rows:
  969. all_tables['菌门构成'] = phylum_rows
  970. # 菌纲构成表
  971. class_rows = _parse_phylum_tables(reader, full_text, fmt, CLASS_TABLE_TITLES)
  972. if class_rows:
  973. all_tables['菌纲构成'] = class_rows
  974. # 菌目构成表
  975. order_rows = _parse_phylum_tables(reader, full_text, fmt, ORDER_TABLE_TITLES)
  976. if order_rows:
  977. all_tables['菌目构成'] = order_rows
  978. # 菌科构成表
  979. family_rows = _parse_phylum_tables(reader, full_text, fmt, FAMILY_TABLE_TITLES)
  980. if family_rows:
  981. all_tables['菌科构成'] = family_rows
  982. # 菌属构成表
  983. genus_rows = _parse_phylum_tables(reader, full_text, fmt, GENUS_TABLE_TITLES)
  984. if genus_rows:
  985. all_tables['菌属构成'] = genus_rows
  986. # 菌种构成表
  987. species_rows = _parse_phylum_tables(reader, full_text, fmt, SPECIES_TABLE_TITLES)
  988. if species_rows:
  989. all_tables['菌种构成'] = species_rows
  990. # 菌群层级(纲目科属种)- 从"菌群检出详细列表"统一入口提取
  991. taxonomy_rows = _parse_taxonomy_levels(reader, full_text, fmt)
  992. for key, rows in taxonomy_rows.items():
  993. if rows:
  994. all_tables[key] = rows
  995. return all_tables
  996. def split_7_fields(s):
  997. """将压缩数字串切分为 7 个字段(移植自 extract_full_report_v5.py)"""
  998. results = []
  999. ranges = [(2, 4), (1, 2), (1, 2), (1, 2), (1, 2), (1, 2), (1, 4)]
  1000. def backtrack(pos, idx, nums):
  1001. if idx == 7:
  1002. if pos == len(s):
  1003. results.append(list(nums))
  1004. return
  1005. if pos >= len(s):
  1006. return
  1007. lo, hi = ranges[idx]
  1008. for w in range(lo, min(hi + 1, len(s) - pos + 1)):
  1009. chunk = s[pos:pos + w]
  1010. if chunk.isdigit():
  1011. backtrack(pos + w, idx + 1, nums + [int(chunk)])
  1012. backtrack(0, 0, [])
  1013. return results
  1014. def decode_compressed(name, num_str, ref_vals=None):
  1015. """解码压缩食物数字串(移植自 extract_full_report_v5.py)"""
  1016. raw = num_str.lstrip('-')
  1017. has_neg = num_str.startswith('-')
  1018. candidates = []
  1019. for rec_len in range(1, 3):
  1020. if rec_len > len(raw):
  1021. continue
  1022. rec = ('-' if has_neg else '') + raw[:rec_len]
  1023. try:
  1024. rec_val = int(rec)
  1025. if not (-100 <= rec_val <= 100):
  1026. continue
  1027. except Exception:
  1028. continue
  1029. remain = raw[rec_len:]
  1030. for nums in split_7_fields(remain):
  1031. if ref_vals:
  1032. matches = sum(1 for i in range(7) if ref_vals[i] == nums[i])
  1033. if matches >= 6:
  1034. candidates.append([rec_val] + nums)
  1035. else:
  1036. candidates.append([rec_val] + nums)
  1037. if not candidates:
  1038. return None
  1039. if ref_vals:
  1040. candidates.sort(key=lambda r: (sum(1 for i in range(7) if ref_vals[i] == r[1:][i]),
  1041. -len(str(abs(r[0])))), reverse=True)
  1042. if sum(1 for i in range(7) if ref_vals[i] == candidates[0][1:][i]) < 6:
  1043. return None
  1044. else:
  1045. candidates.sort(key=lambda r: (len(str(abs(r[0]))),
  1046. -sum(1 for i in range(7) if r[1:][i] == 0)))
  1047. return candidates[0]
  1048. def extract_food_table(pdf_path, ref_lookup=None):
  1049. """提取个体化食物推荐表(移植自 extract_full_report_v5.py)"""
  1050. reader = PdfReader(pdf_path)
  1051. food_start = None
  1052. for i, page in enumerate(reader.pages):
  1053. if '个体化食物推荐表' in page.extract_text():
  1054. food_start = i
  1055. break
  1056. if food_start is None:
  1057. return [], 'not_found'
  1058. first_text = norm(reader.pages[food_start + 1].extract_text())
  1059. lines = [l.strip() for l in first_text.split('\n')
  1060. if l.strip() and not re.match(r'\d+/\d+', l)]
  1061. # 判断压缩格式:任何一行超过100字符(单行密集格式),或前10行中超过3行长行
  1062. is_compressed = (len(lines) >= 1 and any(len(l) > 100 for l in lines[:10])) or \
  1063. sum(1 for l in lines[:10] if len(l) > 100) >= 2
  1064. rows = []
  1065. if is_compressed:
  1066. fmt = 'compressed'
  1067. for i in range(food_start + 1, len(reader.pages)):
  1068. text = norm(reader.pages[i].extract_text())
  1069. text = re.sub(r'\d+/\d+', '', text)
  1070. header = '名称分类推荐指数能量KJ蛋白g脂肪g碳水化合物g淀粉g总膳食纤维g胆固醇mg'
  1071. text = text.replace(header, '')
  1072. for kw in FOOD_SKIP_TEXTS:
  1073. text = text.replace(kw, '')
  1074. while text:
  1075. best_cat, best_idx = None, len(text)
  1076. for cat in KNOWN_CATS:
  1077. idx = text.find(cat)
  1078. if idx != -1 and idx < best_idx:
  1079. best_idx, best_cat = idx, cat
  1080. if best_cat is None:
  1081. break
  1082. name = text[:best_idx]
  1083. text = text[best_idx + len(best_cat):]
  1084. num_str = ''
  1085. while text and (text[0].isdigit() or text[0] in '-\u2212\u2014'):
  1086. c = '-' if text[0] in '\u2212\u2014' else text[0]
  1087. num_str += c
  1088. text = text[1:]
  1089. if not name or not num_str:
  1090. continue
  1091. ref_vals = ref_lookup.get(name) if ref_lookup else None
  1092. decoded = decode_compressed(name, num_str, ref_vals)
  1093. if decoded:
  1094. rows.append(dict(zip(COLUMNS_FOOD, [name, best_cat] + [str(v) for v in decoded])))
  1095. else:
  1096. fmt = 'vertical'
  1097. all_lines = []
  1098. for i in range(food_start + 1, len(reader.pages)):
  1099. for line in norm(reader.pages[i].extract_text()).split('\n'):
  1100. lt = line.strip()
  1101. if not lt or re.match(r'\d+/\d+', lt) or lt in COLUMNS_FOOD:
  1102. continue
  1103. if len(lt) > 60 and any(k in lt for k in FOOD_SKIP_TEXTS):
  1104. continue
  1105. all_lines.append(lt)
  1106. i = 0
  1107. while i + 9 < len(all_lines):
  1108. name = all_lines[i].strip()
  1109. cat = all_lines[i + 1].strip()
  1110. if cat not in KNOWN_CATS:
  1111. i += 1
  1112. continue
  1113. nums = []
  1114. ok = True
  1115. for j in range(2, 10):
  1116. v = all_lines[i + j].replace('\u2212', '-').replace('\u2014', '-').strip()
  1117. try:
  1118. int(v)
  1119. nums.append(v)
  1120. except Exception:
  1121. ok = False
  1122. break
  1123. if ok and len(nums) == 8:
  1124. rows.append(dict(zip(COLUMNS_FOOD, [name, cat] + nums)))
  1125. i += 1
  1126. return rows, fmt
  1127. def _extract_food_rows(pdf_path):
  1128. """食物推荐表解析:优先复用同目录其他报告的参考营养表做压缩格式解码"""
  1129. ref_nutrition = {}
  1130. base_dir = os.path.dirname(pdf_path) or '.'
  1131. for fname in sorted(os.listdir(base_dir)):
  1132. if fname.lower().endswith('.pdf') and fname != os.path.basename(pdf_path):
  1133. try:
  1134. tr, _ = extract_food_table(os.path.join(base_dir, fname))
  1135. if len(tr) > 100:
  1136. for r in tr:
  1137. ref_nutrition[r['名称']] = [int(r[k]) for k in
  1138. ['能量KJ', '蛋白g', '脂肪g', '碳水化合物g', '淀粉g',
  1139. '总膳食纤维g', '胆固醇mg']]
  1140. break
  1141. except Exception:
  1142. continue
  1143. food_rows, food_fmt = extract_food_table(pdf_path, ref_nutrition or None)
  1144. return food_rows, food_fmt
  1145. # ============================================================
  1146. # 北京菌群报告 — 指纹检测
  1147. # ============================================================
  1148. FINGERPRINTS_BEIJING = [
  1149. "肠道微生物健康指数",
  1150. "GMHI",
  1151. "肠道菌群主要检测结果",
  1152. "核心菌属综合评分",
  1153. "有益菌综合评分",
  1154. "有害菌综合评分",
  1155. "肠道菌群精准分布",
  1156. "营养物质及营养素代谢评估",
  1157. "趣味肠菌评估",
  1158. "高通量测序",
  1159. ]
  1160. def detect_beijing_format(text):
  1161. """检测是否为北京菌群报告格式"""
  1162. beijing_matches = sum(1 for f in FINGERPRINTS_BEIJING if f in text)
  1163. if beijing_matches >= 3:
  1164. return True
  1165. return False
  1166. # ============================================================
  1167. # 北京菌群报告 — 章节提取(PyPDF2 版)
  1168. # ============================================================
  1169. # 疾病风险关键词(北京报告特有)
  1170. DISEASE_KEYWORDS_BJ = [
  1171. "溃疡性结肠炎", "克罗恩病", "腹泻型肠易激综合征",
  1172. "硬化性胆管炎", "非酒精性脂肪性肝病", "肝硬化",
  1173. "便秘", "IgA肾病", "肾结石", "格雷夫斯病",
  1174. "慢性淋巴细胞性甲状腺炎", "强直性脊柱炎",
  1175. "精神分裂症", "痛经",
  1176. "结直肠癌", "胃癌", "甲状腺癌", "肺癌", "乳腺癌",
  1177. "高血压", "冠心病", "2型糖尿病", "高脂血症",
  1178. "食管癌", "胆结石", "哮喘", "心脏病",
  1179. "膜性肾病", "帕金森病", "类风湿关节炎",
  1180. ]
  1181. # 抗生素分类(北京报告格式)
  1182. ANTIBIOTIC_CATEGORIES_BJ = [
  1183. "β内酰胺类", "氨基糖苷类", "大环内酯类", "四环素类",
  1184. "氯霉素类", "磺酰胺类", "喹诺酮类", "万古霉素类",
  1185. "头孢菌素类", "磷霉素类", "甲氧苄氨嘧啶类",
  1186. "N-乙酰基转移酶类", "O-磷酸转移酶类", "16S甲基转移酶",
  1187. ]
  1188. # 核心菌属 22 种(北京报告)
  1189. CORE_GENERA_BJ = [
  1190. ("阿克曼菌属", "Akkermansia"), ("另枝菌属", "Alistipes"),
  1191. ("拟杆菌属", "Bacteroides"), ("双歧杆菌属", "Bifidobacterium"),
  1192. ("布劳特氏菌属", "Blautia"), ("梭菌属", "Clostridium"),
  1193. ("粪球菌属", "Coprococcus"), ("戴阿利斯特杆菌属", "Dialister"),
  1194. ("多尔氏菌属", "Dorea"), ("真杆菌属", "Eubacterium"),
  1195. ("粪杆菌属", "Faecalibacterium"), ("Lachnoclostridium属", "Lachnoclostridium"),
  1196. ("毛螺菌属", "Lachnospira"), ("乳杆菌属", "Lactobacillus"),
  1197. ("巨单胞菌属", "Megamonas"), ("颤螺旋菌属", "Oscillospira"),
  1198. ("副拟杆菌属", "Parabacteroides"), ("考拉杆菌属", "Phascolarctobacterium"),
  1199. ("普雷沃氏菌属", "Prevotella"), ("罗氏菌属", "Roseburia"),
  1200. ("瘤胃球菌属", "Ruminococcus"), ("萨特氏菌属", "Sutterella"),
  1201. ]
  1202. # 病原体列表(北京报告)
  1203. PATHOGEN_LIST_BJ = [
  1204. "气单胞菌属", "蜡样芽孢杆菌", "唐菖蒲伯克霍尔德氏菌",
  1205. "弯曲杆菌属", "艰难梭菌", "肉毒梭菌", "产气荚膜梭菌",
  1206. "幽门螺杆菌", "肺炎克雷伯菌", "类志贺邻单胞菌",
  1207. "沙门氏菌属", "志贺氏菌属", "金黄色葡萄球菌",
  1208. "肺炎链球菌", "霍乱弧菌", "拟态弧菌", "副溶血弧菌",
  1209. "小肠结肠炎耶尔森氏菌", "假结核耶尔森氏菌",
  1210. ]
  1211. def _find_content_after_toc(text):
  1212. """找到北京报告中 TOC 之后的内容起始位置"""
  1213. # 找"第一部分 健康整体评估"后的内容,或者"健康整体评估"正文
  1214. markers = ['第一部分 健康整体评估', '菌群得分总览', '您属于肠']
  1215. for m in markers:
  1216. idx = text.find(m)
  1217. if idx != -1:
  1218. return idx
  1219. return 0
  1220. def _find_last_section(text, section_title):
  1221. """找到章节标题的最后一次出现(实际内容,非目录)"""
  1222. last = -1
  1223. start = 0
  1224. while True:
  1225. idx = text.find(section_title, start)
  1226. if idx == -1:
  1227. break
  1228. last = idx
  1229. start = idx + 1
  1230. return last
  1231. def _parse_bj_gmhi(text):
  1232. """提取 GMHI 肠道微生物健康指数(从内容区提取)"""
  1233. gmhi = {}
  1234. # 找最后一次出现的 GMHI 值(内容区,非目录)
  1235. content_start = _find_content_after_toc(text)
  1236. content = text[content_start:]
  1237. m = re.search(r'GMHI[)\)]?[为是](\d+)', content)
  1238. if m: gmhi['GMHI'] = float(m.group(1))
  1239. m = re.search(r'肠道微生物健康指数[为](\d+)', content)
  1240. if m: gmhi['肠道微生物健康指数'] = float(m.group(1))
  1241. m = re.search(r'肠道菌群状态[为::\s]*(\S+?)[。,\n]', content)
  1242. if m: gmhi['肠道菌群状态'] = m.group(1)
  1243. # 健康状态推断
  1244. if gmhi.get('肠道微生物健康指数') or gmhi.get('GMHI'):
  1245. score = gmhi.get('肠道微生物健康指数') or gmhi.get('GMHI', 0)
  1246. if score >= 90: gmhi['健康状态'] = '健康'
  1247. elif score >= 80: gmhi['健康状态'] = '健康倾向'
  1248. elif score >= 40: gmhi['健康状态'] = '亚健康'
  1249. elif score >= 20: gmhi['健康状态'] = '不健康倾向'
  1250. else: gmhi['健康状态'] = '不健康'
  1251. return gmhi
  1252. def _parse_bj_gut_type(text):
  1253. """提取肠型"""
  1254. content_start = _find_content_after_toc(text)
  1255. content = text[content_start:]
  1256. m = re.search(r'属于\s*肠[型道]\s*([IVX]+)', content)
  1257. if m: return f'肠型{m.group(1)}'
  1258. m = re.search(r'属于\s*肠[型道]\s*(\S+?)[,。,.\n]', content)
  1259. if m: return m.group(1).strip()
  1260. return None
  1261. def _parse_bj_balance(text):
  1262. """提取肠道菌群平衡评估"""
  1263. m = re.search(r'(I{1,3}度失衡)', text)
  1264. if m: return m.group(1)
  1265. return None
  1266. def _parse_bj_barrier(text):
  1267. """提取肠道黏膜屏障/BE比值"""
  1268. barrier = {}
  1269. # 在内容区找表格
  1270. content_start = _find_content_after_toc(text)
  1271. content = text[content_start:]
  1272. # 找 BE比值 表格行
  1273. m = re.search(r'BE比[值例][::\s]*(\d+\.?\d*)', text)
  1274. if m: barrier['BE比值'] = float(m.group(1))
  1275. m = re.search(r'双歧杆菌[属菌][::\s]*(\d+\.?\d*)', text)
  1276. if m: barrier['双歧杆菌属'] = float(m.group(1))
  1277. m = re.search(r'肠杆菌[科属][::\s]*(\d+\.?\d*)', text)
  1278. if m: barrier['肠杆菌科'] = float(m.group(1))
  1279. # 综合评估在"BE比值"附近找
  1280. be_idx = content.find('BE比')
  1281. if be_idx != -1:
  1282. chunk = content[be_idx:be_idx + 300]
  1283. m = re.search(r'(正常|轻度损伤|中度损伤|重度损伤|损伤)', chunk)
  1284. if m: barrier['综合评估'] = m.group(1)
  1285. return barrier
  1286. def _parse_bj_diversity(text):
  1287. """提取菌群多样性"""
  1288. div = {}
  1289. content_start = _find_content_after_toc(text)
  1290. content = text[content_start:]
  1291. m = re.search(r'多样性指数[为](\d+\.?\d*)', content)
  1292. if m: div['Shannon指数'] = float(m.group(1))
  1293. m = re.search(r'高于[::\s]*(\d+)%', content)
  1294. if m: div['人群百分位'] = f'高于{m.group(1)}%'
  1295. m = re.search(r'菌种总数[::\s]*(\d+)', content)
  1296. if m: div['菌种总数'] = int(m.group(1))
  1297. return div
  1298. def _parse_bj_phenotype(text):
  1299. """提取菌群表型评估"""
  1300. pheno = {}
  1301. # 找表型评估表格区域
  1302. last_idx = _find_last_section(text, '菌群表型评估')
  1303. if last_idx == -1:
  1304. last_idx = _find_last_section(text, '肠道菌群表型评估')
  1305. if last_idx == -1:
  1306. return pheno
  1307. # 取表格区域
  1308. region = text[last_idx:last_idx + 1500]
  1309. # 解析表格行: 指标名 评估值 结果
  1310. for label, kw in [('革兰氏阳性菌', '阳性菌'), ('革兰氏阴性菌', '阴性菌'),
  1311. ('生物膜合成', '生物膜'), ('好氧菌', '好氧'),
  1312. ('厌氧菌', '厌氧'), ('兼性厌氧菌', '兼性'),
  1313. ('氧化胁迫耐受', '氧化'), ('致病潜力', '致病')]:
  1314. # 找该行: 值在指标名附近
  1315. idx = region.find(kw)
  1316. if idx == -1:
  1317. continue
  1318. chunk = region[idx:idx + 100]
  1319. val_m = re.search(r'(\d+\.?\d*)', chunk)
  1320. if val_m:
  1321. entry = {'评估值': float(val_m.group(1))}
  1322. for status in ['正常', '异常']:
  1323. if status in chunk:
  1324. entry['结果'] = status
  1325. break
  1326. pheno[label] = entry
  1327. return pheno
  1328. def _parse_bj_scfa(text):
  1329. """提取短链脂肪酸合成能力"""
  1330. scfa = {}
  1331. # 找 SCFA 表格区域(最后一次出现)
  1332. last_idx = _find_last_section(text, '短链脂肪酸合成能力')
  1333. if last_idx == -1:
  1334. last_idx = _find_last_section(text, '短链脂肪酸')
  1335. if last_idx == -1:
  1336. return scfa
  1337. region = text[last_idx:last_idx + 800]
  1338. for acid in ['甲酸', '乙酸', '丙酸', '丁酸', '戊酸', '己酸']:
  1339. m = re.search(rf'{acid}[^a-zA-Z]*?(\d+)', region)
  1340. if m:
  1341. scfa[acid] = int(m.group(1))
  1342. return scfa
  1343. def _parse_bj_core_bacteria(text):
  1344. """提取核心菌属精准分布"""
  1345. bacteria = []
  1346. for cn_name, lat_name in CORE_GENERA_BJ:
  1347. idx = text.find(lat_name)
  1348. if idx == -1: idx = text.find(cn_name)
  1349. if idx == -1: continue
  1350. chunk = text[max(0, idx - 50):idx + 200]
  1351. val_m = re.search(r'(\d+\.?\d*)', chunk)
  1352. status_m = re.search(r'(缺失|严重超标|超标|偏低|正常)', chunk)
  1353. pct_m = re.search(r'(\d+\.?\d*)%', chunk)
  1354. range_m = re.search(r'([\d.]+-[\d.]+)', chunk)
  1355. entry = {
  1356. '拉丁名': lat_name,
  1357. '中文名': cn_name,
  1358. '检测结果': float(val_m.group(1)) if val_m else None,
  1359. '状态': status_m.group(1) if status_m else '未知',
  1360. '人群百分位': float(pct_m.group(1)) if pct_m else None,
  1361. '参考范围': range_m.group(1) if range_m else None,
  1362. }
  1363. bacteria.append(entry)
  1364. return bacteria
  1365. def _parse_bj_disease_risks(text):
  1366. """提取疾病风险评估"""
  1367. diseases = []
  1368. for keyword in DISEASE_KEYWORDS_BJ:
  1369. idx = text.find(keyword)
  1370. if idx == -1: continue
  1371. chunk = text[idx:idx + 200]
  1372. val_m = re.search(r'(\d+\.\d+)', chunk)
  1373. level_m = re.search(r'(低风险|较低风险|中度风险|较高风险|高风险)', chunk)
  1374. if val_m:
  1375. diseases.append({
  1376. '疾病': keyword,
  1377. '风险指数': float(val_m.group(1)),
  1378. '风险等级': level_m.group(1) if level_m else '',
  1379. })
  1380. return diseases
  1381. def _parse_bj_nutrient_metabolism(text):
  1382. """提取营养物质及营养素代谢评估"""
  1383. metabolism = {}
  1384. # 主要营养物质
  1385. for n in ['碳水化合物', '蛋白质', '脂肪', '矿物质']:
  1386. m = re.search(rf'{n}[^估]*?(\d+)', text)
  1387. if m:
  1388. metabolism.setdefault('主要营养物质代谢', {})[n] = int(m.group(1))
  1389. # 糖类
  1390. for item in ['葡萄糖(糖酵解途径)', '葡萄糖(磷酸戊糖途径)', '果糖和甘露糖',
  1391. '半乳糖', '淀粉和蔗糖', '氨基糖和核苷酸糖', '丙酮酸']:
  1392. m = re.search(rf'{item[:4]}.*?(\d+)', text)
  1393. if m:
  1394. metabolism.setdefault('糖类代谢', {})[item] = int(m.group(1))
  1395. # 脂类
  1396. for item in ['甘油酯', '甘油磷脂', '甘油三酯', '鞘脂类', '脂肪酸',
  1397. '硬脂酸', '棕榈酸', '花生四烯酸', '二十二碳六烯酸',
  1398. 'Omega-3', 'Omega-6']:
  1399. m = re.search(rf'{item}.*?(\d+)', text)
  1400. if m:
  1401. metabolism.setdefault('脂类代谢', {})[item] = int(m.group(1))
  1402. # 维生素
  1403. for item in ['维生素A', '维生素B1', '维生素B2', '维生素B3', '维生素B5',
  1404. '维生素B6', '维生素B7', '维生素B12', '维生素C', '维生素D',
  1405. '维生素E', '维生素K1', '维生素K2', '维生素K3', '叶酸']:
  1406. m = re.search(rf'{item}.*?(\d+)', text)
  1407. if m:
  1408. metabolism.setdefault('维生素', {})[item] = int(m.group(1))
  1409. # 微量元素
  1410. for item in ['铁', '锌', '钙', '镁', '硒', '锰', '铜', '钴', '镍', '钼', '铬', '钒']:
  1411. m = re.search(rf'{item}[^估]*?(\d+)', text)
  1412. if m:
  1413. metabolism.setdefault('微量元素', {})[item] = int(m.group(1))
  1414. # 氨基酸
  1415. for item in ['赖氨酸', '丝氨酸', '亮氨酸', '色氨酸', '苯丙氨酸',
  1416. '缬氨酸', '组氨酸', '半胱氨酸', '酪氨酸', '丙氨酸',
  1417. '脯氨酸', '苏氨酸', '谷氨酸', '异亮氨酸', '精氨酸',
  1418. '蛋氨酸', '甘氨酸', '天冬氨酸', '牛磺酸']:
  1419. m = re.search(rf'{item}[^估]*?(\d+)', text)
  1420. if m:
  1421. metabolism.setdefault('氨基酸', {})[item] = int(m.group(1))
  1422. # 神经递质
  1423. for item in ['5-羟色胺', 'γ-氨基丁酸', '多巴胺', '乙酰胆碱', '组胺', '去甲肾上腺素']:
  1424. m = re.search(rf'{item}.*?(\d+)', text)
  1425. if m:
  1426. metabolism.setdefault('神经递质', {})[item] = int(m.group(1))
  1427. # 其他单项
  1428. for pattern, key in [
  1429. (r'嘌呤代谢.*?(\d+)', '嘌呤代谢'),
  1430. (r'谷胱甘肽.*?(\d+)', '三肽(谷胱甘肽)'),
  1431. (r'胆汁酸.*?(\d+)', '胆汁酸代谢'),
  1432. (r'硫辛酸.*?(\d+)', '抗自由基(硫辛酸)'),
  1433. (r'辅酶Q.*?(\d+)', '抗自由基(辅酶Q)'),
  1434. ]:
  1435. m = re.search(pattern, text)
  1436. if m:
  1437. metabolism[key] = int(m.group(1))
  1438. return metabolism
  1439. def _parse_bj_antibiotics(text):
  1440. """提取抗生素风险评估"""
  1441. ab = {}
  1442. for cat in ANTIBIOTIC_CATEGORIES_BJ:
  1443. m = re.search(rf'{cat}[::\s]*(\d+)', text)
  1444. if m:
  1445. ab[cat] = int(m.group(1))
  1446. return ab
  1447. def _parse_bj_toxins(text):
  1448. """提取毒性物质清除能力评估"""
  1449. toxins = {}
  1450. toxin_items = [
  1451. "苯甲酸酯", "对氨基苯甲酸乙酯", "对氟苯甲酸乙酯",
  1452. "氯烷烃和氯烯烃", "氯代环己烷", "氯苯",
  1453. "甲苯", "二甲苯", "硝基甲苯", "乙苯", "苯乙烯",
  1454. "阿特拉津", "己内酰胺", "双酚", "二恶英",
  1455. "萘", "多环芳烃",
  1456. ]
  1457. for item in toxin_items:
  1458. m = re.search(rf'{item}.*?(\d+)', text)
  1459. if m:
  1460. toxins[item] = int(m.group(1))
  1461. return toxins
  1462. def _parse_bj_pathogens(text):
  1463. """提取胃肠道感染病原体评估"""
  1464. pathogens = []
  1465. for p in PATHOGEN_LIST_BJ:
  1466. idx = text.find(p)
  1467. if idx == -1: continue
  1468. chunk = text[idx:idx + 150]
  1469. val_m = re.search(r'(\d+\.?\d*)', chunk)
  1470. status_m = re.search(r'(检出|超标|未检出|正常)', chunk)
  1471. if val_m:
  1472. entry = {'名称': p, '检测值': float(val_m.group(1)) if '.' in val_m.group(1) else int(val_m.group(1))}
  1473. if status_m: entry['状态'] = status_m.group(1)
  1474. pathogens.append(entry)
  1475. return pathogens
  1476. def _parse_bj_psych_eval(text, find_table_val=None):
  1477. """提取趣味肠菌评估"""
  1478. psych = {}
  1479. m = re.search(r'最高概率[::\s]*(\S+)', text)
  1480. if m: psych['人格特征'] = {'最高概率': m.group(1)}
  1481. # 认知功能(从表格)
  1482. if find_table_val:
  1483. cog = find_table_val('认知能力', val_col=1)
  1484. if cog:
  1485. psych['认知功能'] = {'认知能力评分': int(cog)}
  1486. return psych
  1487. def _parse_bj_scfa_from_tables(find_table_rows):
  1488. """从表格提取短链脂肪酸合成能力"""
  1489. scfa = {}
  1490. for acid in ['甲酸', '乙酸', '丙酸', '丁酸', '戊酸', '己酸']:
  1491. rows = find_table_rows(acid)
  1492. for name, val in rows:
  1493. if acid in name:
  1494. scfa[acid] = int(val)
  1495. break
  1496. return scfa
  1497. def _parse_bj_core_bacteria_from_tables(all_tables):
  1498. """从表格提取核心菌属精准分布"""
  1499. bacteria = []
  1500. for cn_name, lat_name in CORE_GENERA_BJ:
  1501. for table in all_tables:
  1502. for row in table:
  1503. row_str = ' '.join(str(c) for c in row if c)
  1504. if lat_name in row_str or cn_name in row_str:
  1505. vals = [str(c).strip() for c in row if c and str(c).strip()]
  1506. entry = {'拉丁名': lat_name, '中文名': cn_name}
  1507. for v in vals:
  1508. v_clean = v.replace('"', '').replace('#', '').strip()
  1509. # 判断是否为范围值(如 0.0041-0.0339)
  1510. if '-' in v_clean and v_clean.replace('.','').replace('-',' ').replace(' ','').isdigit():
  1511. entry['参考范围'] = v_clean
  1512. elif v_clean == 'ND':
  1513. if '检测结果' not in entry:
  1514. entry['检测结果'] = 0.0
  1515. elif v_clean.replace('.','').replace('-','').isdigit() and v_clean != '':
  1516. val = float(v_clean)
  1517. if '检测结果' not in entry:
  1518. entry['检测结果'] = val
  1519. elif '人群百分位' not in entry:
  1520. entry['人群百分位'] = val
  1521. if '检测结果' in entry:
  1522. bacteria.append(entry)
  1523. break
  1524. if bacteria and bacteria[-1].get('拉丁名') == lat_name:
  1525. break
  1526. return bacteria
  1527. def _parse_bj_nutrient_from_tables(find_table_rows, find_table_val):
  1528. """从表格提取营养物质及营养素代谢评估"""
  1529. metabolism = {}
  1530. # 主要营养物质
  1531. for n in ['碳水化合物', '蛋白质', '脂肪', '矿物质']:
  1532. rows = find_table_rows(n)
  1533. if rows:
  1534. for name, val in rows:
  1535. if n in name:
  1536. metabolism.setdefault('主要营养物质代谢', {})[n] = int(val)
  1537. # 糖类
  1538. sugar_items = ['葡萄糖(糖酵解途径)', '葡萄糖(磷酸戊糖途径)', '果糖和甘露糖',
  1539. '半乳糖', '淀粉和蔗糖', '氨基糖和核苷酸糖', '丙酮酸']
  1540. for item in sugar_items:
  1541. rows = find_table_rows(item[:3])
  1542. if rows:
  1543. for name, val in rows:
  1544. metabolism.setdefault('糖类代谢', {})[item] = int(val)
  1545. # 脂类
  1546. for item in ['甘油酯', '甘油磷脂', '甘油三酯', '鞘脂类', '脂肪酸',
  1547. '硬脂酸', '棕榈酸', '花生四烯酸', '二十二碳六烯酸',
  1548. 'Omega-3', 'Omega-6']:
  1549. rows = find_table_rows(item[:3])
  1550. if rows:
  1551. for name, val in rows:
  1552. metabolism.setdefault('脂类代谢', {})[item] = int(val)
  1553. # 维生素
  1554. for item in ['维生素A', '维生素B1', '维生素B2', '维生素B3', '维生素B5',
  1555. '维生素B6', '维生素B7', '维生素B12', '维生素C', '维生素D',
  1556. '维生素E', '维生素K1', '维生素K2', '维生素K3', '叶酸']:
  1557. rows = find_table_rows(item)
  1558. if rows:
  1559. for name, val in rows:
  1560. metabolism.setdefault('维生素', {})[item] = int(val)
  1561. # 微量元素
  1562. for item in ['铁', '锌', '钙', '镁', '硒', '锰', '铜', '钴', '镍', '钼', '铬', '钒']:
  1563. rows = find_table_rows(item)
  1564. if rows:
  1565. for name, val in rows:
  1566. if name == item:
  1567. metabolism.setdefault('微量元素', {})[item] = int(val)
  1568. # 氨基酸
  1569. for item in ['赖氨酸', '丝氨酸', '亮氨酸', '色氨酸', '苯丙氨酸',
  1570. '缬氨酸', '组氨酸', '半胱氨酸', '酪氨酸', '丙氨酸',
  1571. '脯氨酸', '苏氨酸', '谷氨酸', '异亮氨酸', '精氨酸',
  1572. '蛋氨酸', '甘氨酸', '天冬氨酸', '牛磺酸']:
  1573. rows = find_table_rows(item[:2])
  1574. if rows:
  1575. for name, val in rows:
  1576. metabolism.setdefault('氨基酸', {})[item] = int(val)
  1577. # 神经递质
  1578. for item in ['5-羟色胺', 'γ-氨基丁酸', '多巴胺', '乙酰胆碱', '组胺', '去甲肾上腺素']:
  1579. rows = find_table_rows(item[:3])
  1580. if rows:
  1581. for name, val in rows:
  1582. metabolism.setdefault('神经递质', {})[item] = int(val)
  1583. # 其他单项
  1584. for item, key in [('嘌呤代谢', '嘌呤代谢'), ('谷胱甘肽', '三肽(谷胱甘肽)'),
  1585. ('胆汁酸', '胆汁酸代谢'), ('硫辛酸', '抗自由基(硫辛酸)'),
  1586. ('辅酶Q', '抗自由基(辅酶Q)')]:
  1587. v = find_table_val(item)
  1588. if v: metabolism[key] = int(v)
  1589. return metabolism
  1590. def _parse_bj_antibiotics_from_tables(find_table_rows):
  1591. """从表格提取抗生素风险评估"""
  1592. ab = {}
  1593. for cat in ANTIBIOTIC_CATEGORIES_BJ:
  1594. rows = find_table_rows(cat, val_col=2)
  1595. for name, val in rows:
  1596. ab[cat] = int(val)
  1597. return ab
  1598. def _parse_bj_toxins_from_tables(all_tables, find_table_rows):
  1599. """从表格提取毒性物质清除能力评估"""
  1600. toxins = {}
  1601. toxin_items = [
  1602. "苯甲酸酯", "对氨基苯甲酸乙酯", "对氟苯甲酸乙酯",
  1603. "氯烷烃和氯烯烃", "氯代环己烷", "氯苯",
  1604. "甲苯", "二甲苯", "硝基甲苯", "乙苯", "苯乙烯",
  1605. "阿特拉津", "己内酰胺", "双酚", "二恶英",
  1606. "萘", "多环芳烃",
  1607. ]
  1608. for item in toxin_items:
  1609. rows = find_table_rows(item[:3], name_col=1, val_col=2)
  1610. for name, val in rows:
  1611. toxins[item] = int(val)
  1612. return toxins
  1613. def _parse_bj_overview(text):
  1614. """提取北京报告概述(统一到标准报告 overview 结构)"""
  1615. r = {}
  1616. m = re.search(r'检测编号[::\s]*(\S+)', text)
  1617. if m: r['report_number'] = m.group(1)
  1618. m = re.search(r'姓\s*名[::\s]*(\S+)', text)
  1619. if m: r['person_name'] = m.group(1)
  1620. m = re.search(r'年\s*龄[::\s]*(\S+)', text)
  1621. if m: r['age'] = m.group(1)
  1622. m = re.search(r'性\s*别[::\s]*(\S+)', text)
  1623. if m: r['gender'] = 'male' if m.group(1) in ('男', 'M') else 'female'
  1624. # 肠型
  1625. gut_type = _parse_bj_gut_type(text)
  1626. if gut_type: r['gut_type'] = gut_type
  1627. # GMHI → 健康总分
  1628. m = re.search(r'肠道微生物健康指数[::\s]*(\d+)', text)
  1629. if m: r['overallScore'] = int(m.group(1))
  1630. return r
  1631. def _parse_beijing_report(file_path: str) -> dict:
  1632. """解析北京菌群报告 PDF,输出统一结构(pdfplumber 表格+文本混合提取)"""
  1633. try:
  1634. import pdfplumber
  1635. with pdfplumber.open(file_path) as pdf:
  1636. text = '\n'.join(page.extract_text() or '' for page in pdf.pages)
  1637. # 提取所有表格
  1638. all_tables = []
  1639. for page in pdf.pages:
  1640. tables = page.extract_tables()
  1641. for table in tables:
  1642. if table and len(table) >= 2:
  1643. all_tables.append(table)
  1644. except Exception as e:
  1645. from PyPDF2 import PdfReader
  1646. reader = PdfReader(file_path)
  1647. text = '\n'.join((page.extract_text() or '') for page in reader.pages)
  1648. all_tables = []
  1649. def find_table_rows(keyword, val_col=1, name_col=0, try_all=False):
  1650. """从表格中找指定关键词的数据行"""
  1651. rows = []
  1652. for table in all_tables:
  1653. for row in table:
  1654. row_str = ' '.join(str(c) for c in row if c)
  1655. if keyword in row_str:
  1656. if name_col < len(row) and val_col < len(row):
  1657. name = str(row[name_col] or '').strip()
  1658. val = str(row[val_col] or '').strip()
  1659. if name and val and val.replace('.','').replace('-','').isdigit():
  1660. rows.append((name, val))
  1661. if rows and not try_all:
  1662. break
  1663. return rows
  1664. def find_table_val(keyword, val_col=1, name_col=0):
  1665. """从表格中找单个值"""
  1666. for table in all_tables:
  1667. for row in table:
  1668. row_str = ' '.join(str(c) for c in row if c)
  1669. if keyword in row_str:
  1670. if val_col < len(row):
  1671. val = str(row[val_col] or '').strip()
  1672. if val and val.replace('.','').isdigit():
  1673. return val
  1674. return None
  1675. result = {
  1676. 'format': 'beijing',
  1677. 'overview': _parse_bj_overview(text),
  1678. 'disease_risks': [],
  1679. 'nutrition': [],
  1680. 'amino_acids': [],
  1681. 'vitamins': [],
  1682. 'trace_elements': [],
  1683. '菌群检出详细列表': {},
  1684. '个体化食物推荐表': {'格式': 'not_found', '条目数': 0, '数据': []},
  1685. # 北京报告特有指标
  1686. '肠道微生物健康指数': _parse_bj_gmhi(text),
  1687. '菌群多样性': _parse_bj_diversity(text),
  1688. '肠道黏膜屏障': _parse_bj_barrier(text),
  1689. '菌群表型评估': _parse_bj_phenotype(text),
  1690. '短链脂肪酸合成能力': _parse_bj_scfa_from_tables(find_table_rows),
  1691. '肠道菌群精准分布': _parse_bj_core_bacteria_from_tables(all_tables),
  1692. '营养物质及营养素代谢评估': _parse_bj_nutrient_from_tables(find_table_rows, find_table_val),
  1693. '抗生素风险评估': _parse_bj_antibiotics_from_tables(find_table_rows),
  1694. '毒性物质清除能力评估': _parse_bj_toxins_from_tables(all_tables, find_table_rows),
  1695. '趣味肠菌评估': _parse_bj_psych_eval(text, find_table_val),
  1696. '胃肠道感染病原体': [],
  1697. '健康整体评估': {},
  1698. '肠道菌群主要检测结果': {},
  1699. }
  1700. # 从表格更新 GMHI/抗炎/免疫/纤维/多样性/屏障
  1701. gmhi_val = find_table_val('肠道微生物健康指数', val_col=1)
  1702. if gmhi_val:
  1703. result['肠道微生物健康指数']['肠道微生物健康指数'] = float(gmhi_val)
  1704. score = float(gmhi_val)
  1705. if score >= 90: result['肠道微生物健康指数']['健康状态'] = '健康'
  1706. elif score >= 80: result['肠道微生物健康指数']['健康状态'] = '健康倾向'
  1707. elif score >= 40: result['肠道微生物健康指数']['健康状态'] = '亚健康'
  1708. elif score >= 20: result['肠道微生物健康指数']['健康状态'] = '不健康倾向'
  1709. else: result['肠道微生物健康指数']['健康状态'] = '不健康'
  1710. for attr, kw in [('肠道抗炎能力', '抗炎能力'), ('肠道免疫力', '免疫力'),
  1711. ('肠道膳食纤维需求', '膳食纤维需求')]:
  1712. v = find_table_val(kw)
  1713. if v:
  1714. result.setdefault('肠道菌群主要检测结果', {})[attr] = int(v)
  1715. if '抗炎' in kw: result['overview']['inflammationScore'] = int(v)
  1716. elif '免疫' in kw: result['overview']['immunityScore'] = int(v)
  1717. # 多样性
  1718. div_val = find_table_val('肠道微生物多样性', val_col=1)
  1719. if div_val:
  1720. result['菌群多样性']['Shannon指数'] = float(div_val)
  1721. # BE比值
  1722. be_val = find_table_val('B/E比值', val_col=1)
  1723. if be_val:
  1724. result['肠道黏膜屏障']['BE比值'] = float(be_val)
  1725. bi_val = find_table_val('双歧杆菌属', val_col=1)
  1726. if bi_val:
  1727. result['肠道黏膜屏障']['双歧杆菌属'] = float(bi_val)
  1728. ent_val = find_table_val('肠杆菌科', val_col=1)
  1729. if ent_val:
  1730. result['肠道黏膜屏障']['肠杆菌科'] = float(ent_val)
  1731. # 肠型
  1732. gut_type = _parse_bj_gut_type(text)
  1733. balance = _parse_bj_balance(text)
  1734. if gut_type or balance:
  1735. result['健康整体评估'] = {}
  1736. if gut_type: result['健康整体评估']['肠型'] = gut_type
  1737. if balance: result['健康整体评估']['菌群平衡评估'] = balance
  1738. # 疾病风险评估(文本提取)
  1739. disease_risks = _parse_bj_disease_risks(text)
  1740. if disease_risks:
  1741. result['disease_risks'] = disease_risks
  1742. # 病原体
  1743. pathogens = _parse_bj_pathogens(text)
  1744. if pathogens:
  1745. result['胃肠道感染病原体'] = pathogens
  1746. # 营养物质 → 标准字段映射
  1747. metabolism = result.get('营养物质及营养素代谢评估', {})
  1748. if metabolism.get('主要营养物质代谢'):
  1749. result['nutrition'] = [
  1750. {'name': k, 'value': str(v), 'status': '正常' if v >= 60 else '偏低'}
  1751. for k, v in metabolism['主要营养物质代谢'].items()
  1752. ]
  1753. if metabolism.get('氨基酸'):
  1754. result['amino_acids'] = [
  1755. {'name': k, 'value': str(v), 'status': '正常' if v >= 60 else '偏低'}
  1756. for k, v in metabolism['氨基酸'].items()
  1757. ]
  1758. if metabolism.get('维生素'):
  1759. result['vitamins'] = [
  1760. {'name': k, 'value': str(v), 'status': '正常' if v >= 60 else '偏低'}
  1761. for k, v in metabolism['维生素'].items()
  1762. ]
  1763. if metabolism.get('微量元素'):
  1764. result['trace_elements'] = [
  1765. {'name': k, 'value': str(v), 'status': '正常' if v >= 60 else '偏低'}
  1766. for k, v in metabolism['微量元素'].items()
  1767. ]
  1768. return result