pdf_parser.py 85 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004
  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_related_bacteria_in_chunk(chunk):
  1366. """从文本片段提取关联菌列表(如:乳杆菌属(缺乏)、另枝菌属(超标))"""
  1367. bacteria = []
  1368. for bm in re.finditer(
  1369. r'([\u4e00-\u9fa5]{2,10}?(?:菌|属|科|杆菌|球菌|梭菌))[((]\s*(超标|缺乏|偏高|偏低|异常|正常)\s*[))]',
  1370. chunk):
  1371. bacteria.append({
  1372. 'bacteria': bm.group(1),
  1373. 'correlation': bm.group(2),
  1374. 'value': '',
  1375. 'status': '',
  1376. })
  1377. return bacteria
  1378. def _parse_bj_disease_risks(text):
  1379. """提取疾病风险评估(含关联菌 P0)"""
  1380. diseases = []
  1381. for keyword in DISEASE_KEYWORDS_BJ:
  1382. idx = text.find(keyword)
  1383. if idx == -1: continue
  1384. chunk = text[idx:idx + 200]
  1385. val_m = re.search(r'(\d+\.\d+)', chunk)
  1386. level_m = re.search(r'(低风险|较低风险|中度风险|较高风险|高风险)', chunk)
  1387. if val_m:
  1388. entry = {
  1389. '疾病': keyword,
  1390. '风险指数': float(val_m.group(1)),
  1391. '风险等级': level_m.group(1) if level_m else '',
  1392. }
  1393. bacteria = _parse_related_bacteria_in_chunk(chunk)
  1394. if bacteria:
  1395. entry['related_bacteria'] = bacteria
  1396. diseases.append(entry)
  1397. # 补充:从「疾病肠菌评估(异常项)」章节提取关联菌,按疾病名匹配补挂
  1398. ab_idx = text.find('疾病肠菌评估')
  1399. if ab_idx != -1:
  1400. ab_sec = text[ab_idx:ab_idx + 1800]
  1401. for entry in diseases:
  1402. if entry.get('related_bacteria'):
  1403. continue
  1404. kw_idx = ab_sec.find(entry['疾病'])
  1405. if kw_idx == -1:
  1406. continue
  1407. # 截断到下一个疾病关键字,避免跨疾病串扰
  1408. end = len(ab_sec)
  1409. for other in DISEASE_KEYWORDS_BJ:
  1410. if other == entry['疾病']:
  1411. continue
  1412. oi = ab_sec.find(other, kw_idx + len(entry['疾病']))
  1413. if oi != -1 and oi < end:
  1414. end = oi
  1415. chunk = ab_sec[kw_idx:end]
  1416. bacteria = _parse_related_bacteria_in_chunk(chunk)
  1417. if bacteria:
  1418. entry['related_bacteria'] = bacteria
  1419. return diseases
  1420. def _parse_bj_nutrient_metabolism(text):
  1421. """提取营养物质及营养素代谢评估"""
  1422. metabolism = {}
  1423. # 主要营养物质
  1424. for n in ['碳水化合物', '蛋白质', '脂肪', '矿物质']:
  1425. m = re.search(rf'{n}[^估]*?(\d+)', text)
  1426. if m:
  1427. metabolism.setdefault('主要营养物质代谢', {})[n] = int(m.group(1))
  1428. # 糖类
  1429. for item in ['葡萄糖(糖酵解途径)', '葡萄糖(磷酸戊糖途径)', '果糖和甘露糖',
  1430. '半乳糖', '淀粉和蔗糖', '氨基糖和核苷酸糖', '丙酮酸']:
  1431. m = re.search(rf'{item[:4]}.*?(\d+)', text)
  1432. if m:
  1433. metabolism.setdefault('糖类代谢', {})[item] = int(m.group(1))
  1434. # 脂类
  1435. for item in ['甘油酯', '甘油磷脂', '甘油三酯', '鞘脂类', '脂肪酸',
  1436. '硬脂酸', '棕榈酸', '花生四烯酸', '二十二碳六烯酸',
  1437. 'Omega-3', 'Omega-6']:
  1438. m = re.search(rf'{item}.*?(\d+)', text)
  1439. if m:
  1440. metabolism.setdefault('脂类代谢', {})[item] = int(m.group(1))
  1441. # 维生素
  1442. for item in ['维生素A', '维生素B1', '维生素B2', '维生素B3', '维生素B5',
  1443. '维生素B6', '维生素B7', '维生素B12', '维生素C', '维生素D',
  1444. '维生素E', '维生素K1', '维生素K2', '维生素K3', '叶酸']:
  1445. m = re.search(rf'{item}.*?(\d+)', text)
  1446. if m:
  1447. metabolism.setdefault('维生素', {})[item] = int(m.group(1))
  1448. # 微量元素
  1449. for item in ['铁', '锌', '钙', '镁', '硒', '锰', '铜', '钴', '镍', '钼', '铬', '钒']:
  1450. m = re.search(rf'{item}[^估]*?(\d+)', text)
  1451. if m:
  1452. metabolism.setdefault('微量元素', {})[item] = int(m.group(1))
  1453. # 氨基酸
  1454. for item in ['赖氨酸', '丝氨酸', '亮氨酸', '色氨酸', '苯丙氨酸',
  1455. '缬氨酸', '组氨酸', '半胱氨酸', '酪氨酸', '丙氨酸',
  1456. '脯氨酸', '苏氨酸', '谷氨酸', '异亮氨酸', '精氨酸',
  1457. '蛋氨酸', '甘氨酸', '天冬氨酸', '牛磺酸']:
  1458. m = re.search(rf'{item}[^估]*?(\d+)', text)
  1459. if m:
  1460. metabolism.setdefault('氨基酸', {})[item] = int(m.group(1))
  1461. # 神经递质
  1462. for item in ['5-羟色胺', 'γ-氨基丁酸', '多巴胺', '乙酰胆碱', '组胺', '去甲肾上腺素']:
  1463. m = re.search(rf'{item}.*?(\d+)', text)
  1464. if m:
  1465. metabolism.setdefault('神经递质', {})[item] = int(m.group(1))
  1466. # 其他单项
  1467. for pattern, key in [
  1468. (r'嘌呤代谢.*?(\d+)', '嘌呤代谢'),
  1469. (r'谷胱甘肽.*?(\d+)', '三肽(谷胱甘肽)'),
  1470. (r'胆汁酸.*?(\d+)', '胆汁酸代谢'),
  1471. (r'硫辛酸.*?(\d+)', '抗自由基(硫辛酸)'),
  1472. (r'辅酶Q.*?(\d+)', '抗自由基(辅酶Q)'),
  1473. ]:
  1474. m = re.search(pattern, text)
  1475. if m:
  1476. metabolism[key] = int(m.group(1))
  1477. return metabolism
  1478. def _parse_bj_antibiotics(text):
  1479. """提取抗生素风险评估"""
  1480. ab = {}
  1481. for cat in ANTIBIOTIC_CATEGORIES_BJ:
  1482. m = re.search(rf'{cat}[::\s]*(\d+)', text)
  1483. if m:
  1484. ab[cat] = int(m.group(1))
  1485. return ab
  1486. def _parse_bj_toxins(text):
  1487. """提取毒性物质清除能力评估"""
  1488. toxins = {}
  1489. toxin_items = [
  1490. "苯甲酸酯", "对氨基苯甲酸乙酯", "对氟苯甲酸乙酯",
  1491. "氯烷烃和氯烯烃", "氯代环己烷", "氯苯",
  1492. "甲苯", "二甲苯", "硝基甲苯", "乙苯", "苯乙烯",
  1493. "阿特拉津", "己内酰胺", "双酚", "二恶英",
  1494. "萘", "多环芳烃",
  1495. ]
  1496. for item in toxin_items:
  1497. m = re.search(rf'{item}.*?(\d+)', text)
  1498. if m:
  1499. toxins[item] = int(m.group(1))
  1500. return toxins
  1501. def _parse_bj_pathogens(text):
  1502. """提取胃肠道感染病原体评估"""
  1503. pathogens = []
  1504. for p in PATHOGEN_LIST_BJ:
  1505. idx = text.find(p)
  1506. if idx == -1: continue
  1507. chunk = text[idx:idx + 150]
  1508. val_m = re.search(r'(\d+\.?\d*)', chunk)
  1509. status_m = re.search(r'(检出|超标|未检出|正常)', chunk)
  1510. if val_m:
  1511. entry = {'名称': p, '检测值': float(val_m.group(1)) if '.' in val_m.group(1) else int(val_m.group(1))}
  1512. if status_m: entry['状态'] = status_m.group(1)
  1513. pathogens.append(entry)
  1514. return pathogens
  1515. def _parse_bj_psych_eval(text, find_table_val=None):
  1516. """提取趣味肠菌评估"""
  1517. psych = {}
  1518. m = re.search(r'最高概率[::\s]*(\S+)', text)
  1519. if m: psych['人格特征'] = {'最高概率': m.group(1)}
  1520. # 认知功能(从表格)
  1521. if find_table_val:
  1522. cog = find_table_val('认知能力', val_col=1)
  1523. if cog:
  1524. psych['认知功能'] = {'认知能力评分': int(cog)}
  1525. return psych
  1526. def _parse_bj_scfa_from_tables(find_table_rows):
  1527. """从表格提取短链脂肪酸合成能力"""
  1528. scfa = {}
  1529. for acid in ['甲酸', '乙酸', '丙酸', '丁酸', '戊酸', '己酸']:
  1530. rows = find_table_rows(acid)
  1531. for name, val in rows:
  1532. if acid in name:
  1533. scfa[acid] = int(val)
  1534. break
  1535. return scfa
  1536. def _parse_bj_core_bacteria_from_tables(all_tables):
  1537. """从表格提取核心菌属精准分布"""
  1538. bacteria = []
  1539. for cn_name, lat_name in CORE_GENERA_BJ:
  1540. for table in all_tables:
  1541. for row in table:
  1542. row_str = ' '.join(str(c) for c in row if c)
  1543. if lat_name in row_str or cn_name in row_str:
  1544. vals = [str(c).strip() for c in row if c and str(c).strip()]
  1545. entry = {'拉丁名': lat_name, '中文名': cn_name}
  1546. for v in vals:
  1547. v_clean = v.replace('"', '').replace('#', '').strip()
  1548. # 判断是否为范围值(如 0.0041-0.0339)
  1549. if '-' in v_clean and v_clean.replace('.','').replace('-',' ').replace(' ','').isdigit():
  1550. entry['参考范围'] = v_clean
  1551. elif v_clean == 'ND':
  1552. if '检测结果' not in entry:
  1553. entry['检测结果'] = 0.0
  1554. elif v_clean.replace('.','').replace('-','').isdigit() and v_clean != '':
  1555. val = float(v_clean)
  1556. if '检测结果' not in entry:
  1557. entry['检测结果'] = val
  1558. elif '人群百分位' not in entry:
  1559. entry['人群百分位'] = val
  1560. if '检测结果' in entry:
  1561. bacteria.append(entry)
  1562. break
  1563. if bacteria and bacteria[-1].get('拉丁名') == lat_name:
  1564. break
  1565. return bacteria
  1566. def _parse_bj_nutrient_from_tables(find_table_rows, find_table_val):
  1567. """从表格提取营养物质及营养素代谢评估"""
  1568. metabolism = {}
  1569. # 主要营养物质
  1570. for n in ['碳水化合物', '蛋白质', '脂肪', '矿物质']:
  1571. rows = find_table_rows(n)
  1572. if rows:
  1573. for name, val in rows:
  1574. if n in name:
  1575. metabolism.setdefault('主要营养物质代谢', {})[n] = int(val)
  1576. # 糖类
  1577. sugar_items = ['葡萄糖(糖酵解途径)', '葡萄糖(磷酸戊糖途径)', '果糖和甘露糖',
  1578. '半乳糖', '淀粉和蔗糖', '氨基糖和核苷酸糖', '丙酮酸']
  1579. for item in sugar_items:
  1580. rows = find_table_rows(item[:3])
  1581. if rows:
  1582. for name, val in rows:
  1583. metabolism.setdefault('糖类代谢', {})[item] = int(val)
  1584. # 脂类
  1585. for item in ['甘油酯', '甘油磷脂', '甘油三酯', '鞘脂类', '脂肪酸',
  1586. '硬脂酸', '棕榈酸', '花生四烯酸', '二十二碳六烯酸',
  1587. 'Omega-3', 'Omega-6']:
  1588. rows = find_table_rows(item[:3])
  1589. if rows:
  1590. for name, val in rows:
  1591. metabolism.setdefault('脂类代谢', {})[item] = int(val)
  1592. # 维生素
  1593. for item in ['维生素A', '维生素B1', '维生素B2', '维生素B3', '维生素B5',
  1594. '维生素B6', '维生素B7', '维生素B12', '维生素C', '维生素D',
  1595. '维生素E', '维生素K1', '维生素K2', '维生素K3', '叶酸']:
  1596. rows = find_table_rows(item)
  1597. if rows:
  1598. for name, val in rows:
  1599. metabolism.setdefault('维生素', {})[item] = int(val)
  1600. # 微量元素
  1601. for item in ['铁', '锌', '钙', '镁', '硒', '锰', '铜', '钴', '镍', '钼', '铬', '钒']:
  1602. rows = find_table_rows(item)
  1603. if rows:
  1604. for name, val in rows:
  1605. if name == item:
  1606. metabolism.setdefault('微量元素', {})[item] = int(val)
  1607. # 氨基酸
  1608. for item in ['赖氨酸', '丝氨酸', '亮氨酸', '色氨酸', '苯丙氨酸',
  1609. '缬氨酸', '组氨酸', '半胱氨酸', '酪氨酸', '丙氨酸',
  1610. '脯氨酸', '苏氨酸', '谷氨酸', '异亮氨酸', '精氨酸',
  1611. '蛋氨酸', '甘氨酸', '天冬氨酸', '牛磺酸']:
  1612. rows = find_table_rows(item[:2])
  1613. if rows:
  1614. for name, val in rows:
  1615. metabolism.setdefault('氨基酸', {})[item] = int(val)
  1616. # 神经递质
  1617. for item in ['5-羟色胺', 'γ-氨基丁酸', '多巴胺', '乙酰胆碱', '组胺', '去甲肾上腺素']:
  1618. rows = find_table_rows(item[:3])
  1619. if rows:
  1620. for name, val in rows:
  1621. metabolism.setdefault('神经递质', {})[item] = int(val)
  1622. # 其他单项
  1623. for item, key in [('嘌呤代谢', '嘌呤代谢'), ('谷胱甘肽', '三肽(谷胱甘肽)'),
  1624. ('胆汁酸', '胆汁酸代谢'), ('硫辛酸', '抗自由基(硫辛酸)'),
  1625. ('辅酶Q', '抗自由基(辅酶Q)')]:
  1626. v = find_table_val(item)
  1627. if v: metabolism[key] = int(v)
  1628. return metabolism
  1629. def _parse_bj_antibiotics_from_tables(find_table_rows):
  1630. """从表格提取抗生素风险评估"""
  1631. ab = {}
  1632. for cat in ANTIBIOTIC_CATEGORIES_BJ:
  1633. rows = find_table_rows(cat, val_col=2)
  1634. for name, val in rows:
  1635. ab[cat] = int(val)
  1636. return ab
  1637. def _parse_bj_toxins_from_tables(all_tables, find_table_rows):
  1638. """从表格提取毒性物质清除能力评估"""
  1639. toxins = {}
  1640. toxin_items = [
  1641. "苯甲酸酯", "对氨基苯甲酸乙酯", "对氟苯甲酸乙酯",
  1642. "氯烷烃和氯烯烃", "氯代环己烷", "氯苯",
  1643. "甲苯", "二甲苯", "硝基甲苯", "乙苯", "苯乙烯",
  1644. "阿特拉津", "己内酰胺", "双酚", "二恶英",
  1645. "萘", "多环芳烃",
  1646. ]
  1647. for item in toxin_items:
  1648. rows = find_table_rows(item[:3], name_col=1, val_col=2)
  1649. for name, val in rows:
  1650. toxins[item] = int(val)
  1651. return toxins
  1652. def _parse_bj_overview(text):
  1653. """提取北京报告概述(统一到标准报告 overview 结构)"""
  1654. r = {}
  1655. m = re.search(r'检测编号[::\s]*(\S+)', text)
  1656. if m: r['report_number'] = m.group(1)
  1657. m = re.search(r'姓\s*名[::\s]*(\S+)', text)
  1658. if m: r['person_name'] = m.group(1)
  1659. m = re.search(r'年\s*龄[::\s]*(\S+)', text)
  1660. if m: r['age'] = m.group(1)
  1661. m = re.search(r'性\s*别[::\s]*(\S+)', text)
  1662. if m: r['gender'] = 'male' if m.group(1) in ('男', 'M') else 'female'
  1663. # 肠型
  1664. gut_type = _parse_bj_gut_type(text)
  1665. if gut_type: r['gut_type'] = gut_type
  1666. # GMHI → 健康总分
  1667. m = re.search(r'肠道微生物健康指数[::\s]*(\d+)', text)
  1668. if m: r['overallScore'] = int(m.group(1))
  1669. return r
  1670. def _parse_beijing_report(file_path: str) -> dict:
  1671. """解析北京菌群报告 PDF,输出统一结构(pdfplumber 表格+文本混合提取)"""
  1672. try:
  1673. import pdfplumber
  1674. with pdfplumber.open(file_path) as pdf:
  1675. text = '\n'.join(page.extract_text() or '' for page in pdf.pages)
  1676. # 提取所有表格
  1677. all_tables = []
  1678. for page in pdf.pages:
  1679. tables = page.extract_tables()
  1680. for table in tables:
  1681. if table and len(table) >= 2:
  1682. all_tables.append(table)
  1683. except Exception as e:
  1684. from PyPDF2 import PdfReader
  1685. reader = PdfReader(file_path)
  1686. text = '\n'.join((page.extract_text() or '') for page in reader.pages)
  1687. all_tables = []
  1688. def find_table_rows(keyword, val_col=1, name_col=0, try_all=False):
  1689. """从表格中找指定关键词的数据行"""
  1690. rows = []
  1691. for table in all_tables:
  1692. for row in table:
  1693. row_str = ' '.join(str(c) for c in row if c)
  1694. if keyword in row_str:
  1695. if name_col < len(row) and val_col < len(row):
  1696. name = str(row[name_col] or '').strip()
  1697. val = str(row[val_col] or '').strip()
  1698. if name and val and val.replace('.','').replace('-','').isdigit():
  1699. rows.append((name, val))
  1700. if rows and not try_all:
  1701. break
  1702. return rows
  1703. def find_table_val(keyword, val_col=1, name_col=0):
  1704. """从表格中找单个值"""
  1705. for table in all_tables:
  1706. for row in table:
  1707. row_str = ' '.join(str(c) for c in row if c)
  1708. if keyword in row_str:
  1709. if val_col < len(row):
  1710. val = str(row[val_col] or '').strip()
  1711. if val and val.replace('.','').isdigit():
  1712. return val
  1713. return None
  1714. result = {
  1715. 'format': 'beijing',
  1716. 'overview': _parse_bj_overview(text),
  1717. 'disease_risks': [],
  1718. 'nutrition': [],
  1719. 'amino_acids': [],
  1720. 'vitamins': [],
  1721. 'trace_elements': [],
  1722. '菌群检出详细列表': {},
  1723. '个体化食物推荐表': {'格式': 'not_found', '条目数': 0, '数据': []},
  1724. # 北京报告特有指标
  1725. '肠道微生物健康指数': _parse_bj_gmhi(text),
  1726. '菌群多样性': _parse_bj_diversity(text),
  1727. '肠道黏膜屏障': _parse_bj_barrier(text),
  1728. '菌群表型评估': _parse_bj_phenotype(text),
  1729. '短链脂肪酸合成能力': _parse_bj_scfa_from_tables(find_table_rows),
  1730. '肠道菌群精准分布': _parse_bj_core_bacteria_from_tables(all_tables),
  1731. '营养物质及营养素代谢评估': _parse_bj_nutrient_from_tables(find_table_rows, find_table_val),
  1732. '抗生素风险评估': _parse_bj_antibiotics_from_tables(find_table_rows),
  1733. '毒性物质清除能力评估': _parse_bj_toxins_from_tables(all_tables, find_table_rows),
  1734. '趣味肠菌评估': _parse_bj_psych_eval(text, find_table_val),
  1735. '胃肠道感染病原体': [],
  1736. '健康整体评估': {},
  1737. '肠道菌群主要检测结果': {},
  1738. }
  1739. # 从表格更新 GMHI/抗炎/免疫/纤维/多样性/屏障
  1740. gmhi_val = find_table_val('肠道微生物健康指数', val_col=1)
  1741. if gmhi_val:
  1742. result['肠道微生物健康指数']['肠道微生物健康指数'] = float(gmhi_val)
  1743. score = float(gmhi_val)
  1744. if score >= 90: result['肠道微生物健康指数']['健康状态'] = '健康'
  1745. elif score >= 80: result['肠道微生物健康指数']['健康状态'] = '健康倾向'
  1746. elif score >= 40: result['肠道微生物健康指数']['健康状态'] = '亚健康'
  1747. elif score >= 20: result['肠道微生物健康指数']['健康状态'] = '不健康倾向'
  1748. else: result['肠道微生物健康指数']['健康状态'] = '不健康'
  1749. for attr, kw in [('肠道抗炎能力', '抗炎能力'), ('肠道免疫力', '免疫力'),
  1750. ('肠道膳食纤维需求', '膳食纤维需求')]:
  1751. v = find_table_val(kw)
  1752. if v:
  1753. result.setdefault('肠道菌群主要检测结果', {})[attr] = int(v)
  1754. if '抗炎' in kw: result['overview']['inflammationScore'] = int(v)
  1755. elif '免疫' in kw: result['overview']['immunityScore'] = int(v)
  1756. # 多样性
  1757. div_val = find_table_val('肠道微生物多样性', val_col=1)
  1758. if div_val:
  1759. result['菌群多样性']['Shannon指数'] = float(div_val)
  1760. # BE比值
  1761. be_val = find_table_val('B/E比值', val_col=1)
  1762. if be_val:
  1763. result['肠道黏膜屏障']['BE比值'] = float(be_val)
  1764. bi_val = find_table_val('双歧杆菌属', val_col=1)
  1765. if bi_val:
  1766. result['肠道黏膜屏障']['双歧杆菌属'] = float(bi_val)
  1767. ent_val = find_table_val('肠杆菌科', val_col=1)
  1768. if ent_val:
  1769. result['肠道黏膜屏障']['肠杆菌科'] = float(ent_val)
  1770. # 肠型
  1771. gut_type = _parse_bj_gut_type(text)
  1772. balance = _parse_bj_balance(text)
  1773. if gut_type or balance:
  1774. result['健康整体评估'] = {}
  1775. if gut_type: result['健康整体评估']['肠型'] = gut_type
  1776. if balance: result['健康整体评估']['菌群平衡评估'] = balance
  1777. # 疾病风险评估(文本提取)
  1778. disease_risks = _parse_bj_disease_risks(text)
  1779. if disease_risks:
  1780. result['disease_risks'] = disease_risks
  1781. # 病原体
  1782. pathogens = _parse_bj_pathogens(text)
  1783. if pathogens:
  1784. result['胃肠道感染病原体'] = pathogens
  1785. # 肠道菌群状态 → overview.gutStatusLevel(P1)
  1786. status = result.get('肠道微生物健康指数', {}).get('肠道菌群状态')
  1787. if status:
  1788. result['overview']['gutStatusLevel'] = status
  1789. # 营养物质 → 标准字段映射
  1790. metabolism = result.get('营养物质及营养素代谢评估', {})
  1791. if metabolism.get('主要营养物质代谢'):
  1792. result['nutrition'] = [
  1793. {'name': k, 'value': str(v), 'status': '正常' if v >= 60 else '偏低'}
  1794. for k, v in metabolism['主要营养物质代谢'].items()
  1795. ]
  1796. if metabolism.get('氨基酸'):
  1797. result['amino_acids'] = [
  1798. {'name': k, 'value': str(v), 'status': '正常' if v >= 60 else '偏低'}
  1799. for k, v in metabolism['氨基酸'].items()
  1800. ]
  1801. if metabolism.get('维生素'):
  1802. result['vitamins'] = [
  1803. {'name': k, 'value': str(v), 'status': '正常' if v >= 60 else '偏低'}
  1804. for k, v in metabolism['维生素'].items()
  1805. ]
  1806. if metabolism.get('微量元素'):
  1807. result['trace_elements'] = [
  1808. {'name': k, 'value': str(v), 'status': '正常' if v >= 60 else '偏低'}
  1809. for k, v in metabolism['微量元素'].items()
  1810. ]
  1811. return result