pdf_parser.py 61 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465
  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. }
  26. KNOWN_MACRO = ['碳水化合物', '蛋白质', '脂肪', '纤维素', '乳制品']
  27. KNOWN_AMINO = ['苏氨酸', '异亮氨酸', '亮氨酸', '赖氨酸', '蛋氨酸', '胱氨酸',
  28. '苯丙氨酸', '酪氨酸', '缬氨酸', '组氨酸', '丙氨酸', '丝氨酸', '甘氨酸',
  29. '脯氨酸', '谷氨酸', '天门冬氨酸', '天冬氨酸', '天冬酰胺', '谷氨酰胺',
  30. '精氨酸', '色氨酸']
  31. KNOWN_VITAMINS = ['维生素A', '维生素B1', '维生素B2', '维生素B5', '维生素B6',
  32. '叶酸', '维生素B12', '维生素C', '维生素D', '维生素K2', '维生素E']
  33. KNOWN_TRACE = ['铁', '锌']
  34. KNOWN_DISEASE_RISKS = ['炎症性肠炎', '肠易激综合征', '感染性腹泻', '自闭症',
  35. '抑郁症', '甲状腺疾病', '肺部感染或疾病', '自体免疫病', '结直肠癌',
  36. '肥胖', '便秘', '过敏', '失眠', '肝病', '肾病', '胃病', '胆病',
  37. '心脑血管疾病', 'II型糖尿病']
  38. KNOWN_BARRIER = ['肠道炎症水平', '肠道产气', '肠道屏障', '脂多糖LPS',
  39. '次级胆汁酸', '对甲酚(p-Cresol)', '吲哚', '苯酚', '腐胺', '硫化氢', '尸胺']
  40. KNOWN_SCFA = ['丁酸盐(Butyrate)', '丙酸盐(Propionate)', '乙酸盐(Acetate)', '异戊酸盐(Isovaleric)']
  41. KNOWN_NEURO = ['血清素(5-HT)', 'γ-氨基丁酸(GABA)', '谷氨酸(Glutamate)',
  42. '色氨酸(Tryptophan)', 'DOPAC', '多巴胺', '组胺(Histamine)', '一氧化氮',
  43. '喹啉(Quinolinic)', '维生素K2', '肌醇(Inositol)', '肾上腺素',
  44. '去甲肾上腺素', '乙酰胆碱', '皮质醇']
  45. KNOWN_ANTIBIOTICS = ['β-内酰胺酶类', '氨基糖苷类', '大环内酯类', '呋喃类',
  46. '喹诺酮类', '磺胺类', '甲氧苄啶类', '氯霉素类', '四环素类']
  47. KNOWN_PATHOGENS = ['幽门螺杆菌', '艰难梭菌', '沙门氏菌', '志贺氏菌', '弯曲杆菌']
  48. # === 菌群表标题(完整版 extract_full_report_v5.py 移植) ===
  49. BACTERIA_TABLE_TITLES = [
  50. '核心菌属构成表', '益生菌', '有害菌属构成表',
  51. '其它重要菌属构成表', '病原菌属构成表',
  52. ]
  53. PHYLUM_TABLE_TITLES = ['菌门构成表', '菌群门水平构成表', '门水平菌群构成']
  54. CLASS_TABLE_TITLES = ['菌纲构成表', '菌群纲水平构成表', '纲水平菌群构成']
  55. ORDER_TABLE_TITLES = ['菌目构成表', '菌群目水平构成表', '目水平菌群构成']
  56. FAMILY_TABLE_TITLES = ['菌科构成表', '菌群科水平构成表', '科水平菌群构成']
  57. GENUS_TABLE_TITLES = ['菌属构成表', '菌群属水平构成表', '属水平菌群构成']
  58. SPECIES_TABLE_TITLES = ['菌种构成表', '菌群种水平构成表', '种水平菌群构成']
  59. DISEASE_BACTERIA_TITLES = [
  60. '肥胖相关菌', '便秘相关菌', '抑郁相关菌', '过敏相关菌',
  61. '腹胀相关菌', '失眠相关菌', '肠道健康相关菌',
  62. '多动症相关菌', '自闭症相关菌',
  63. ]
  64. # === 食物推荐表(完整版 extract_full_report_v5.py 移植) ===
  65. COLUMNS_FOOD = ['名称', '分类', '推荐指数', '能量KJ', '蛋白g', '脂肪g',
  66. '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']
  67. KNOWN_CATS = ['主食', '乳制品', '干果', '坚果', '快餐', '水产品',
  68. '水果', '汤', '肉类', '蔬菜', '豆类及豆制品', '蛋类', '饮料']
  69. FOOD_SKIP_TEXTS = [
  70. '根据您的肠道菌群', '分值从-100', '食物推荐考虑', '食物推荐是综合',
  71. '需要注意的是', '本饮食推荐', '该饮食推荐根据', '后续表格中的营养',
  72. '16S 高通量测序', '基于机器学习和', '肠道菌群健康检测报告说明',
  73. '检测方法及局限性', '数据分析及模型', '结果解读及使用',
  74. '影响因素说明', '建议将检测结果', '营养建议说明',
  75. '重要提示', '推荐食物清单', '实际食用时需结合', '如有特殊疾病',
  76. '免责声明', '本检测报告仅供', '以上模型预测', '正常范围的定义',
  77. '募极生物',
  78. ]
  79. def norm(s):
  80. return ''.join(RADICAL_MAP.get(c, c) for c in s)
  81. def detect_format(lines):
  82. """检测 triplet / inline 格式"""
  83. text = '\n'.join(lines)
  84. has_triplet = '指标范围' in text and '疾病风险评估' in text
  85. for line in lines:
  86. if len(line) > 15 and re.search(r'[\u4e00-\u9fff]+[\d.]+[\u4e00-\u9fff/]+', line):
  87. for known in KNOWN_DISEASE_RISKS:
  88. if known in line:
  89. return 'inline'
  90. return 'triplet' if has_triplet else 'inline'
  91. def extract_text(file_path):
  92. """读取 PDF 并提取文本"""
  93. reader = PdfReader(file_path)
  94. lines = []
  95. for page in reader.pages:
  96. text = norm(page.extract_text() or '')
  97. for line in text.split('\n'):
  98. ls = line.strip()
  99. if ls:
  100. lines.append(ls)
  101. return lines
  102. def parse_overview(lines):
  103. """提取报告概述"""
  104. text = '\n'.join(lines)
  105. r = {}
  106. m = re.search(r'编号[::\s]*(\d+)', text)
  107. if m: r['report_number'] = m.group(1)
  108. m = re.search(r'姓名[::\s]*([\u4e00-\u9fff]{2,10})', text)
  109. if m: r['person_name'] = re.sub(r'(编号|年龄|性别|备注|肠道).*', '', m.group(1))[:4]
  110. m = re.search(r'年龄[::\s]*(\d+)', text)
  111. if m: r['age'] = int(m.group(1))
  112. m = re.search(r'性别[::\s]*([\u4e00-\u9fff])', text)
  113. if m: r['gender'] = 'male' if m.group(1) == '男' else 'female'
  114. for kw in ['健康总分', '菌群健康', '慢病控制', '营养均衡', '肠道菌群平衡',
  115. '菌群多样性', '有益菌', '有害菌', '核心菌属']:
  116. m = re.search(rf'{kw}\s*(\d+)', text)
  117. if m: r[kw] = int(m.group(1))
  118. m = re.search(r'肠道预测年龄[::\s]*([\d.]+)', text)
  119. if m: r['gut_age'] = m.group(1)
  120. m = re.search(r'肠型[::\s]*(\S+)', text)
  121. if m: r['gut_type'] = m.group(1)
  122. return r
  123. def parse_triplet_until(lines, stop_markers):
  124. """三元组解析:3行一组 名称/数值/状态"""
  125. results = []
  126. i = 0
  127. while i < len(lines):
  128. if any(lines[i] == sm or lines[i].startswith(sm) for sm in stop_markers):
  129. break
  130. if lines[i] in ('指标范围', '名称', '丰度', '评估'):
  131. i += 1
  132. continue
  133. name = lines[i]
  134. if i + 2 >= len(lines): break
  135. val = lines[i + 1]
  136. status = lines[i + 2]
  137. if re.match(r'^-?\d+\.?\d*$', val):
  138. results.append({'name': name, 'value': val, 'status': status})
  139. i += 3
  140. else:
  141. i += 1
  142. return results
  143. def parse_report_pdf(file_path: str) -> dict:
  144. """主函数:解析 PDF 返回结构化数据"""
  145. lines = extract_text(file_path)
  146. full_text = '\n'.join(lines)
  147. # 指纹检测:北京菌群报告
  148. if detect_beijing_format(full_text):
  149. return _parse_beijing_report(file_path)
  150. fmt = detect_format(lines)
  151. result = {'format': fmt, 'overview': parse_overview(lines)}
  152. # 疾病风险评估
  153. for i, line in enumerate(lines):
  154. if '疾病风险评估' in line and '注' not in line:
  155. risks = parse_triplet_until(lines[i+1:],
  156. ['主要营养评估', '氨基酸评估', '维生素评估', '微量元素评估', '抗生素风险评估'])
  157. result['disease_risks'] = [r for r in risks if '注' not in r['name']]
  158. break
  159. # 主要营养评估
  160. for i, line in enumerate(lines):
  161. if '主要营养评估' in line:
  162. nutrients = parse_triplet_until(lines[i+1:], ['氨基酸评估'])
  163. result['nutrition'] = nutrients[:5]
  164. break
  165. # 氨基酸评估
  166. for i, line in enumerate(lines):
  167. if '氨基酸评估' in line:
  168. aminos = parse_triplet_until(lines[i+1:], ['维生素评估', '微量元素评估'])
  169. result['amino_acids'] = aminos
  170. break
  171. # 维生素评估
  172. for i, line in enumerate(lines):
  173. if '维生素评估' in line:
  174. vits = parse_triplet_until(lines[i+1:], ['微量元素评估', '抗生素风险评估'])
  175. result['vitamins'] = [r for r in vits if '维生素' in r['name']]
  176. result['trace_elements'] = [r for r in vits if '维生素' not in r['name']]
  177. break
  178. # 菌群检出详细列表(核心/益生菌/有害菌/病原菌 + 门纲目科属种)
  179. bacteria_tables = _extract_bacteria_tables(file_path)
  180. result['菌群检出详细列表'] = bacteria_tables
  181. # 个体化食物推荐表(保留原始键名与结构)
  182. food_rows, food_fmt = _extract_food_rows(file_path)
  183. result['个体化食物推荐表'] = {
  184. '格式': food_fmt,
  185. '条目数': len(food_rows),
  186. '数据': food_rows
  187. }
  188. # 统一结构:北京报告特有字段(标准报告为空)
  189. result['肠道微生物健康指数'] = {}
  190. result['菌群多样性'] = {}
  191. result['肠道黏膜屏障'] = {}
  192. result['菌群表型评估'] = {}
  193. result['短链脂肪酸合成能力'] = {}
  194. result['肠道菌群精准分布'] = []
  195. result['营养物质及营养素代谢评估'] = {}
  196. result['抗生素风险评估'] = {}
  197. result['毒性物质清除能力评估'] = {}
  198. result['趣味肠菌评估'] = {}
  199. result['胃肠道感染病原体'] = []
  200. result['健康整体评估'] = {}
  201. result['肠道菌群主要检测结果'] = {}
  202. return result
  203. def parse_report_pdf_with_fallback(file_path: str) -> dict:
  204. """算法解析 + 简单校验,返回结构化数据"""
  205. result = parse_report_pdf(file_path)
  206. # 简单校验:如果关键字段缺失,标记为解析不完整
  207. if not result.get('overview', {}).get('overallScore') and \
  208. not result.get('overview', {}).get('健康总分'):
  209. result['_parse_incomplete'] = True
  210. return result
  211. def _parse_bacteria_table(reader, pages_text, full_text, title, fmt, skip_header=False):
  212. """从PDF中解析一个菌群表格(移植自 extract_full_report_v5.py)"""
  213. results = []
  214. sidx = full_text.find(title)
  215. if sidx == -1:
  216. return results
  217. # 病原菌检出表特殊处理:找"仅列出检出的病原菌"(跳过前面的说明文字中的"病原菌")
  218. if title == '病原菌':
  219. better_sidx = full_text.find('仅列出检出的病原菌')
  220. if better_sidx != -1:
  221. sidx = better_sidx
  222. # 找表格结束位置(下一个标题或页尾)
  223. end_pos = len(full_text)
  224. for t in (BACTERIA_TABLE_TITLES + PHYLUM_TABLE_TITLES + CLASS_TABLE_TITLES
  225. + ORDER_TABLE_TITLES + FAMILY_TABLE_TITLES + GENUS_TABLE_TITLES
  226. + SPECIES_TABLE_TITLES + DISEASE_BACTERIA_TITLES
  227. + ['指标范围', '个体化食物推荐表', '报告总结', '健康总分']):
  228. if t == title:
  229. continue
  230. ei = full_text.find(t, sidx + len(title))
  231. if ei != -1 and ei < end_pos:
  232. end_pos = ei
  233. region = full_text[sidx:end_pos]
  234. # 检测区域实际格式:如果换行数很少(<3)则是inline格式,即使全局fmt=triplet
  235. lines_from_region = [l.strip() for l in region.split('\n') if l.strip()]
  236. actual_fmt = fmt
  237. if fmt == 'triplet' and len(lines_from_region) <= 5:
  238. actual_fmt = 'inline'
  239. if actual_fmt == 'triplet':
  240. # 三元组格式:每个字段单独一行
  241. lines = [l.strip() for l in region.split('\n') if l.strip()]
  242. start = 0
  243. for i, line in enumerate(lines):
  244. if line == '名称':
  245. start = i + 1
  246. break
  247. if line.startswith('名称'):
  248. if skip_header:
  249. start = i + 1
  250. break
  251. i = start
  252. while i < len(lines):
  253. name = lines[i]
  254. if not name or len(name) <= 1 or name in ['说明', '检测结果', '结果解释', '建议']:
  255. i += 1
  256. continue
  257. if name.startswith('说明:') or name.startswith('改善方式'):
  258. i += 1
  259. continue
  260. if len(name) > 80:
  261. i += 1
  262. continue
  263. # 找丰度%
  264. if i + 1 < len(lines) and re.match(r'^[\d]+\.?[\d]*%?$|^ND$', lines[i + 1]):
  265. pct = lines[i + 1]
  266. normal_range = ''
  267. pop_level = ''
  268. detection_rate = ''
  269. desc = ''
  270. j = i + 2
  271. # 正常范围 (允许小数,如 0.06-6.96, 0.03-3.07)
  272. if j < len(lines) and re.match(r'^[\d]+\.?[\d]*-[\d]+\.?[\d]*$', lines[j]):
  273. normal_range = lines[j]
  274. j += 1
  275. # 人群水平% (允许小数,如 15.66%)
  276. if j < len(lines) and re.match(r'^\d+\.?\d*%$', lines[j]):
  277. pop_level = lines[j]
  278. j += 1
  279. # 检出率%
  280. if j < len(lines) and re.match(r'^\d+\.?\d*%$', lines[j]):
  281. detection_rate = lines[j]
  282. j += 1
  283. # 说明
  284. if j < len(lines) and lines[j].startswith('说明'):
  285. desc = lines[j]
  286. j += 1
  287. # 改善方式
  288. if j < len(lines) and lines[j].startswith('改善方式'):
  289. if desc:
  290. desc += ' | ' + lines[j]
  291. else:
  292. desc = lines[j]
  293. j += 1
  294. entry = {'名称': name, '丰度%': pct}
  295. if normal_range:
  296. entry['正常范围%'] = normal_range
  297. if pop_level:
  298. entry['人群水平%'] = pop_level
  299. if detection_rate:
  300. entry['检出率%'] = detection_rate
  301. if desc:
  302. entry['说明'] = desc
  303. results.append(entry)
  304. i = j
  305. else:
  306. i += 1
  307. else:
  308. # inline 格式
  309. sample = region[:500]
  310. truly_compressed = bool(re.search(r'[a-z]\d', sample, re.IGNORECASE))
  311. if not truly_compressed:
  312. # 有空格分隔的inline格式,使用re.finditer
  313. region_clean = region
  314. for hdr in ['名称', '丰度%', '正常范围%', '处于人群%水平', '%正常人有检出',
  315. '人群水平%', '%人检出']:
  316. region_clean = region_clean.replace(hdr, '')
  317. region_clean = re.sub(
  318. r'说明:[\u4e00-\u9fff\s,。、;:,.;:()()、/a-zA-Z0-9\-]{10,}?(?=[\u4e00-\u9fff]|$)',
  319. '', region_clean)
  320. # 扫描所有匹配的数据行
  321. for m in re.finditer(
  322. r'([\u4e00-\u9fff]{2,12}(?:[((][\u4e00-\u9fff\w]+[))])?)\s+' # 中文名
  323. r'(?:[A-Z][a-z]+(?:\s[A-Z][a-z]+)*\s+)?' # 可选英文名
  324. r'(ND|[\d]+\.?[\d]*%?)\s+' # 丰度
  325. r'([\d]+\.?[\d]*-[\d]+\.?[\d]*)?\s*' # 可选正常范围
  326. r'(\d+\.?\d*%?)\s+' # 人群水平%
  327. r'(\d+\.?\d*%)', # 检出率%
  328. region_clean):
  329. name = m.group(1).strip()
  330. pct = m.group(2)
  331. if '病原菌' in name or '构成表' in name or '说明' in name or len(name) <= 1:
  332. continue
  333. normal_range = m.group(3) or ''
  334. pop_level = m.group(4)
  335. if not pop_level.endswith('%'):
  336. pop_level += '%'
  337. detection_rate = m.group(5)
  338. entry = {'名称': name, '丰度%': pct}
  339. if normal_range:
  340. entry['正常范围%'] = normal_range
  341. entry['人群水平%'] = pop_level
  342. entry['检出率%'] = detection_rate
  343. results.append(entry)
  344. # 模式1没有匹配时:仅中文名+丰度+人群水平(+检出率)
  345. if not results:
  346. for m in re.finditer(
  347. r'([\u4e00-\u9fff]{2,10}[\u4e00-\u9fff]?)\s+'
  348. r'(ND|[\d]+\.?[\d]*%?)\s+'
  349. r'(\d+\.?\d*%)\s+'
  350. r'(\d+\.?\d*%)?',
  351. region_clean):
  352. name = m.group(1).strip()
  353. pct = m.group(2)
  354. if '病原菌' in name or '构成表' in name or '说明' in name or len(name) <= 1:
  355. continue
  356. pop_level = m.group(3)
  357. detection_rate = m.group(4) or ''
  358. entry = {'名称': name, '丰度%': pct, '人群水平%': pop_level}
  359. if detection_rate:
  360. entry['检出率%'] = detection_rate
  361. results.append(entry)
  362. return results
  363. def _parse_phylum_tables(reader, full_text, fmt, title_list=None):
  364. """提取菌群层级构成表(门/纲/目/科/属/种 level)(移植自 extract_full_report_v5.py)"""
  365. if title_list is None:
  366. title_list = PHYLUM_TABLE_TITLES
  367. results = []
  368. for phylum_title in title_list:
  369. rows = _parse_bacteria_table(reader, None, full_text, phylum_title, fmt)
  370. results.extend(rows)
  371. return results
  372. def _parse_taxonomy_levels(reader, full_text, fmt):
  373. """从"菌群检出详细列表"中提取纲目科属种各级数据(移植自 extract_full_report_v5.py)"""
  374. results = {}
  375. sidx = full_text.find('菌群检出详细列表')
  376. if sidx == -1:
  377. return results
  378. end_pos = len(full_text)
  379. for t in ['个体化食物推荐表', '报告总结', '健康总分']:
  380. ei = full_text.find(t, sidx)
  381. if ei != -1 and ei < end_pos:
  382. end_pos = ei
  383. section = full_text[sidx:end_pos]
  384. for level, level_name in [('纲', '菌纲构成'), ('目', '菌目构成'),
  385. ('科', '菌科构成'), ('属', '菌属构成'),
  386. ('种', '菌种构成')]:
  387. marker = f'\n{level}\n名称\n丰度%'
  388. marker2 = f'{level} 名称 丰度%'
  389. li = section.find(marker)
  390. level_start = None
  391. if li == -1:
  392. li2 = section.find(marker2)
  393. if li2 != -1:
  394. li = li2
  395. level_start = li2 + len(marker2)
  396. else:
  397. compressed_marker = f'{level}名称丰度%人群水平%%人检出'
  398. cli = section.find(compressed_marker)
  399. if cli == -1:
  400. continue
  401. # 压缩格式解析:用正则提取数据
  402. level_start = cli + len(compressed_marker)
  403. level_end = len(section)
  404. for next_level in ['目', '科', '属', '种']:
  405. if next_level == level:
  406. continue
  407. ni = section.find(f'{next_level}名称丰度%人群水平%%人检出', level_start)
  408. if ni != -1 and ni < level_end:
  409. level_end = ni
  410. break
  411. level_region = section[level_start:level_end]
  412. compressed_pattern = re.compile(
  413. r'([\u4e00-\u9fff·]+(?:\s[\u4e00-\u9fff·]+)?\s+)?' # 可选中文名
  414. r'([A-Za-z][A-Za-z\s.\-]*?)' # 拉丁名(可能含空格)
  415. r'(\d+\.?\d*%)(\d+\.?\d*%)(\d+\.?\d*%)' # 三连百分比
  416. )
  417. rows = []
  418. for m in compressed_pattern.finditer(level_region):
  419. cn_name = (m.group(1) or '').strip()
  420. latin_name = m.group(2).strip()
  421. pct = m.group(3)
  422. pop_level = m.group(4)
  423. detection = m.group(5)
  424. name = cn_name if cn_name else latin_name
  425. entry = {'名称': name, '丰度%': pct, '人群水平%': pop_level, '检出率%': detection}
  426. rows.append(entry)
  427. if rows:
  428. results[level_name] = rows
  429. continue
  430. else:
  431. level_start = li + len(marker)
  432. level_end = len(section)
  433. for next_level in ['纲', '目', '科', '属', '种']:
  434. if next_level == level:
  435. continue
  436. ni = section.find(f'\n{next_level}\n名称', level_start)
  437. if ni != -1 and ni < level_end:
  438. level_end = ni
  439. break
  440. level_region = section[level_start:level_end]
  441. lines = [l.strip() for l in level_region.split('\n') if l.strip()]
  442. rows = []
  443. i = 0
  444. while i < len(lines):
  445. if lines[i] in ['名称', '丰度%', '人群水平%', '%人检出']:
  446. i += 1
  447. continue
  448. name = lines[i]
  449. if i + 2 < len(lines) and re.match(r'^[\d]+\.?[\d]*%?$', lines[i + 1]):
  450. pct = lines[i + 1]
  451. pop_level = lines[i + 2] if i + 2 < len(lines) else ''
  452. detection = lines[i + 3] if i + 3 < len(lines) and re.match(r'^[\d.]+%$', lines[i + 3]) else ''
  453. entry = {'名称': name, '丰度%': pct, '人群水平%': pop_level}
  454. if detection:
  455. entry['检出率%'] = detection
  456. rows.append(entry)
  457. i += 4 if detection else 3
  458. else:
  459. i += 1
  460. if rows:
  461. results[level_name] = rows
  462. return results
  463. def _extract_bacteria_tables(pdf_path):
  464. """提取菌群检出详细列表,返回 {中文分组名: [行]}(移植自 extract_full_report_v5.py)
  465. 与原始脚本保持一致的文本构造与格式检测:full_text 使用
  466. '\\n'.join(norm(p.extract_text()) for p in reader.pages)(不 strip、不过滤空行),
  467. fmt 使用原脚本 L672-683 的独立检测逻辑。
  468. """
  469. reader = PdfReader(pdf_path)
  470. full_text = '\n'.join(norm(p.extract_text()) for p in reader.pages)
  471. # 原脚本 extract_bacteria_tables 的格式检测(L674-683)
  472. fmt = 'triplet' if '指标范围' in full_text and '疾病风险评估' in full_text else 'inline'
  473. for pt in [p.extract_text() for p in reader.pages]:
  474. t = norm(pt)
  475. if '疾病风险评估' in t and '指标范围' in t:
  476. for line in t.split('\n'):
  477. if re.search(r'[\u4e00-\u9fff]+\d+\.?\d*[\u4e00-\u9fff]+', line.strip()):
  478. fmt = 'inline'
  479. break
  480. break
  481. all_tables = {}
  482. # 核心菌属构成表1-3
  483. core_genus = []
  484. for i in range(1, 4):
  485. title = f'核心菌属构成表{i}'
  486. rows = _parse_bacteria_table(reader, None, full_text, title, fmt)
  487. core_genus.extend(rows)
  488. all_tables['核心菌属'] = core_genus
  489. # 益生菌(使用更精确的表头定位,跳过前面的说明文字)
  490. prob_marker = '仅列出丰度前22的益生菌种'
  491. prob_sidx = full_text.find(prob_marker)
  492. if prob_sidx != -1:
  493. prob_rows = _parse_bacteria_table(reader, None, full_text, prob_marker, fmt, skip_header=True)
  494. else:
  495. prob_rows = _parse_bacteria_table(reader, None, full_text, '益生菌', fmt, skip_header=True)
  496. all_tables['益生菌'] = [r for r in prob_rows if r.get('名称') and r['名称'] not in
  497. ['我的益生菌都为ND', '仅列出丰度前22的益生菌种。']]
  498. # 有害菌属构成表1-2
  499. harmful = []
  500. for i in range(1, 3):
  501. title = f'有害菌属构成表{i}'
  502. rows = _parse_bacteria_table(reader, None, full_text, title, fmt)
  503. harmful.extend(rows)
  504. all_tables['有害菌属'] = harmful
  505. # 其它重要菌属
  506. other_rows = _parse_bacteria_table(reader, None, full_text, '其它重要菌属构成表', fmt)
  507. all_tables['其它重要菌属'] = other_rows
  508. # 病原菌属构成表
  509. patho_genus = _parse_bacteria_table(reader, None, full_text, '病原菌属构成表', fmt)
  510. all_tables['病原菌属'] = patho_genus
  511. # 病原菌(检出列表)
  512. patho_rows = _parse_bacteria_table(reader, None, full_text, '病原菌', fmt, skip_header=True)
  513. all_tables['病原菌检出'] = [r for r in patho_rows if r.get('名称') and len(r['名称']) >= 2
  514. and '仅列出' not in r['名称'] and '说明' not in r['名称']]
  515. # 菌门构成表(phylum level)
  516. phylum_rows = _parse_phylum_tables(reader, full_text, fmt)
  517. if phylum_rows:
  518. all_tables['菌门构成'] = phylum_rows
  519. # 菌纲构成表
  520. class_rows = _parse_phylum_tables(reader, full_text, fmt, CLASS_TABLE_TITLES)
  521. if class_rows:
  522. all_tables['菌纲构成'] = class_rows
  523. # 菌目构成表
  524. order_rows = _parse_phylum_tables(reader, full_text, fmt, ORDER_TABLE_TITLES)
  525. if order_rows:
  526. all_tables['菌目构成'] = order_rows
  527. # 菌科构成表
  528. family_rows = _parse_phylum_tables(reader, full_text, fmt, FAMILY_TABLE_TITLES)
  529. if family_rows:
  530. all_tables['菌科构成'] = family_rows
  531. # 菌属构成表
  532. genus_rows = _parse_phylum_tables(reader, full_text, fmt, GENUS_TABLE_TITLES)
  533. if genus_rows:
  534. all_tables['菌属构成'] = genus_rows
  535. # 菌种构成表
  536. species_rows = _parse_phylum_tables(reader, full_text, fmt, SPECIES_TABLE_TITLES)
  537. if species_rows:
  538. all_tables['菌种构成'] = species_rows
  539. # 菌群层级(纲目科属种)- 从"菌群检出详细列表"统一入口提取
  540. taxonomy_rows = _parse_taxonomy_levels(reader, full_text, fmt)
  541. for key, rows in taxonomy_rows.items():
  542. if rows:
  543. all_tables[key] = rows
  544. return all_tables
  545. def split_7_fields(s):
  546. """将压缩数字串切分为 7 个字段(移植自 extract_full_report_v5.py)"""
  547. results = []
  548. ranges = [(2, 4), (1, 2), (1, 2), (1, 2), (1, 2), (1, 2), (1, 4)]
  549. def backtrack(pos, idx, nums):
  550. if idx == 7:
  551. if pos == len(s):
  552. results.append(list(nums))
  553. return
  554. if pos >= len(s):
  555. return
  556. lo, hi = ranges[idx]
  557. for w in range(lo, min(hi + 1, len(s) - pos + 1)):
  558. chunk = s[pos:pos + w]
  559. if chunk.isdigit():
  560. backtrack(pos + w, idx + 1, nums + [int(chunk)])
  561. backtrack(0, 0, [])
  562. return results
  563. def decode_compressed(name, num_str, ref_vals=None):
  564. """解码压缩食物数字串(移植自 extract_full_report_v5.py)"""
  565. raw = num_str.lstrip('-')
  566. has_neg = num_str.startswith('-')
  567. candidates = []
  568. for rec_len in range(1, 3):
  569. if rec_len > len(raw):
  570. continue
  571. rec = ('-' if has_neg else '') + raw[:rec_len]
  572. try:
  573. rec_val = int(rec)
  574. if not (-100 <= rec_val <= 100):
  575. continue
  576. except Exception:
  577. continue
  578. remain = raw[rec_len:]
  579. for nums in split_7_fields(remain):
  580. if ref_vals:
  581. matches = sum(1 for i in range(7) if ref_vals[i] == nums[i])
  582. if matches >= 6:
  583. candidates.append([rec_val] + nums)
  584. else:
  585. candidates.append([rec_val] + nums)
  586. if not candidates:
  587. return None
  588. if ref_vals:
  589. candidates.sort(key=lambda r: (sum(1 for i in range(7) if ref_vals[i] == r[1:][i]),
  590. -len(str(abs(r[0])))), reverse=True)
  591. if sum(1 for i in range(7) if ref_vals[i] == candidates[0][1:][i]) < 6:
  592. return None
  593. else:
  594. candidates.sort(key=lambda r: (len(str(abs(r[0]))),
  595. -sum(1 for i in range(7) if r[1:][i] == 0)))
  596. return candidates[0]
  597. def extract_food_table(pdf_path, ref_lookup=None):
  598. """提取个体化食物推荐表(移植自 extract_full_report_v5.py)"""
  599. reader = PdfReader(pdf_path)
  600. food_start = None
  601. for i, page in enumerate(reader.pages):
  602. if '个体化食物推荐表' in page.extract_text():
  603. food_start = i
  604. break
  605. if food_start is None:
  606. return [], 'not_found'
  607. first_text = norm(reader.pages[food_start + 1].extract_text())
  608. lines = [l.strip() for l in first_text.split('\n')
  609. if l.strip() and not re.match(r'\d+/\d+', l)]
  610. # 判断压缩格式:任何一行超过100字符(单行密集格式),或前10行中超过3行长行
  611. is_compressed = (len(lines) >= 1 and any(len(l) > 100 for l in lines[:10])) or \
  612. sum(1 for l in lines[:10] if len(l) > 100) >= 2
  613. rows = []
  614. if is_compressed:
  615. fmt = 'compressed'
  616. for i in range(food_start + 1, len(reader.pages)):
  617. text = norm(reader.pages[i].extract_text())
  618. text = re.sub(r'\d+/\d+', '', text)
  619. header = '名称分类推荐指数能量KJ蛋白g脂肪g碳水化合物g淀粉g总膳食纤维g胆固醇mg'
  620. text = text.replace(header, '')
  621. for kw in FOOD_SKIP_TEXTS:
  622. text = text.replace(kw, '')
  623. while text:
  624. best_cat, best_idx = None, len(text)
  625. for cat in KNOWN_CATS:
  626. idx = text.find(cat)
  627. if idx != -1 and idx < best_idx:
  628. best_idx, best_cat = idx, cat
  629. if best_cat is None:
  630. break
  631. name = text[:best_idx]
  632. text = text[best_idx + len(best_cat):]
  633. num_str = ''
  634. while text and (text[0].isdigit() or text[0] in '-\u2212\u2014'):
  635. c = '-' if text[0] in '\u2212\u2014' else text[0]
  636. num_str += c
  637. text = text[1:]
  638. if not name or not num_str:
  639. continue
  640. ref_vals = ref_lookup.get(name) if ref_lookup else None
  641. decoded = decode_compressed(name, num_str, ref_vals)
  642. if decoded:
  643. rows.append(dict(zip(COLUMNS_FOOD, [name, best_cat] + [str(v) for v in decoded])))
  644. else:
  645. fmt = 'vertical'
  646. all_lines = []
  647. for i in range(food_start + 1, len(reader.pages)):
  648. for line in norm(reader.pages[i].extract_text()).split('\n'):
  649. lt = line.strip()
  650. if not lt or re.match(r'\d+/\d+', lt) or lt in COLUMNS_FOOD:
  651. continue
  652. if len(lt) > 60 and any(k in lt for k in FOOD_SKIP_TEXTS):
  653. continue
  654. all_lines.append(lt)
  655. i = 0
  656. while i + 9 < len(all_lines):
  657. name = all_lines[i].strip()
  658. cat = all_lines[i + 1].strip()
  659. if cat not in KNOWN_CATS:
  660. i += 1
  661. continue
  662. nums = []
  663. ok = True
  664. for j in range(2, 10):
  665. v = all_lines[i + j].replace('\u2212', '-').replace('\u2014', '-').strip()
  666. try:
  667. int(v)
  668. nums.append(v)
  669. except Exception:
  670. ok = False
  671. break
  672. if ok and len(nums) == 8:
  673. rows.append(dict(zip(COLUMNS_FOOD, [name, cat] + nums)))
  674. i += 1
  675. return rows, fmt
  676. def _extract_food_rows(pdf_path):
  677. """食物推荐表解析:优先复用同目录其他报告的参考营养表做压缩格式解码"""
  678. ref_nutrition = {}
  679. base_dir = os.path.dirname(pdf_path) or '.'
  680. for fname in sorted(os.listdir(base_dir)):
  681. if fname.lower().endswith('.pdf') and fname != os.path.basename(pdf_path):
  682. try:
  683. tr, _ = extract_food_table(os.path.join(base_dir, fname))
  684. if len(tr) > 100:
  685. for r in tr:
  686. ref_nutrition[r['名称']] = [int(r[k]) for k in
  687. ['能量KJ', '蛋白g', '脂肪g', '碳水化合物g', '淀粉g',
  688. '总膳食纤维g', '胆固醇mg']]
  689. break
  690. except Exception:
  691. continue
  692. food_rows, food_fmt = extract_food_table(pdf_path, ref_nutrition or None)
  693. return food_rows, food_fmt
  694. # ============================================================
  695. # 北京菌群报告 — 指纹检测
  696. # ============================================================
  697. FINGERPRINTS_BEIJING = [
  698. "肠道微生物健康指数",
  699. "GMHI",
  700. "肠道菌群主要检测结果",
  701. "核心菌属综合评分",
  702. "有益菌综合评分",
  703. "有害菌综合评分",
  704. "肠道菌群精准分布",
  705. "营养物质及营养素代谢评估",
  706. "趣味肠菌评估",
  707. "高通量测序",
  708. ]
  709. def detect_beijing_format(text):
  710. """检测是否为北京菌群报告格式"""
  711. beijing_matches = sum(1 for f in FINGERPRINTS_BEIJING if f in text)
  712. if beijing_matches >= 3:
  713. return True
  714. return False
  715. # ============================================================
  716. # 北京菌群报告 — 章节提取(PyPDF2 版)
  717. # ============================================================
  718. # 疾病风险关键词(北京报告特有)
  719. DISEASE_KEYWORDS_BJ = [
  720. "溃疡性结肠炎", "克罗恩病", "腹泻型肠易激综合征",
  721. "硬化性胆管炎", "非酒精性脂肪性肝病", "肝硬化",
  722. "便秘", "IgA肾病", "肾结石", "格雷夫斯病",
  723. "慢性淋巴细胞性甲状腺炎", "强直性脊柱炎",
  724. "精神分裂症", "痛经",
  725. "结直肠癌", "胃癌", "甲状腺癌", "肺癌", "乳腺癌",
  726. "高血压", "冠心病", "2型糖尿病", "高脂血症",
  727. "食管癌", "胆结石", "哮喘", "心脏病",
  728. "膜性肾病", "帕金森病", "类风湿关节炎",
  729. ]
  730. # 抗生素分类(北京报告格式)
  731. ANTIBIOTIC_CATEGORIES_BJ = [
  732. "β内酰胺类", "氨基糖苷类", "大环内酯类", "四环素类",
  733. "氯霉素类", "磺酰胺类", "喹诺酮类", "万古霉素类",
  734. "头孢菌素类", "磷霉素类", "甲氧苄氨嘧啶类",
  735. "N-乙酰基转移酶类", "O-磷酸转移酶类", "16S甲基转移酶",
  736. ]
  737. # 核心菌属 22 种(北京报告)
  738. CORE_GENERA_BJ = [
  739. ("阿克曼菌属", "Akkermansia"), ("另枝菌属", "Alistipes"),
  740. ("拟杆菌属", "Bacteroides"), ("双歧杆菌属", "Bifidobacterium"),
  741. ("布劳特氏菌属", "Blautia"), ("梭菌属", "Clostridium"),
  742. ("粪球菌属", "Coprococcus"), ("戴阿利斯特杆菌属", "Dialister"),
  743. ("多尔氏菌属", "Dorea"), ("真杆菌属", "Eubacterium"),
  744. ("粪杆菌属", "Faecalibacterium"), ("Lachnoclostridium属", "Lachnoclostridium"),
  745. ("毛螺菌属", "Lachnospira"), ("乳杆菌属", "Lactobacillus"),
  746. ("巨单胞菌属", "Megamonas"), ("颤螺旋菌属", "Oscillospira"),
  747. ("副拟杆菌属", "Parabacteroides"), ("考拉杆菌属", "Phascolarctobacterium"),
  748. ("普雷沃氏菌属", "Prevotella"), ("罗氏菌属", "Roseburia"),
  749. ("瘤胃球菌属", "Ruminococcus"), ("萨特氏菌属", "Sutterella"),
  750. ]
  751. # 病原体列表(北京报告)
  752. PATHOGEN_LIST_BJ = [
  753. "气单胞菌属", "蜡样芽孢杆菌", "唐菖蒲伯克霍尔德氏菌",
  754. "弯曲杆菌属", "艰难梭菌", "肉毒梭菌", "产气荚膜梭菌",
  755. "幽门螺杆菌", "肺炎克雷伯菌", "类志贺邻单胞菌",
  756. "沙门氏菌属", "志贺氏菌属", "金黄色葡萄球菌",
  757. "肺炎链球菌", "霍乱弧菌", "拟态弧菌", "副溶血弧菌",
  758. "小肠结肠炎耶尔森氏菌", "假结核耶尔森氏菌",
  759. ]
  760. def _find_content_after_toc(text):
  761. """找到北京报告中 TOC 之后的内容起始位置"""
  762. # 找"第一部分 健康整体评估"后的内容,或者"健康整体评估"正文
  763. markers = ['第一部分 健康整体评估', '菌群得分总览', '您属于肠']
  764. for m in markers:
  765. idx = text.find(m)
  766. if idx != -1:
  767. return idx
  768. return 0
  769. def _find_last_section(text, section_title):
  770. """找到章节标题的最后一次出现(实际内容,非目录)"""
  771. last = -1
  772. start = 0
  773. while True:
  774. idx = text.find(section_title, start)
  775. if idx == -1:
  776. break
  777. last = idx
  778. start = idx + 1
  779. return last
  780. def _parse_bj_gmhi(text):
  781. """提取 GMHI 肠道微生物健康指数(从内容区提取)"""
  782. gmhi = {}
  783. # 找最后一次出现的 GMHI 值(内容区,非目录)
  784. content_start = _find_content_after_toc(text)
  785. content = text[content_start:]
  786. m = re.search(r'GMHI[)\)]?[为是](\d+)', content)
  787. if m: gmhi['GMHI'] = float(m.group(1))
  788. m = re.search(r'肠道微生物健康指数[为](\d+)', content)
  789. if m: gmhi['肠道微生物健康指数'] = float(m.group(1))
  790. m = re.search(r'肠道菌群状态[为::\s]*(\S+?)[。,\n]', content)
  791. if m: gmhi['肠道菌群状态'] = m.group(1)
  792. # 健康状态推断
  793. if gmhi.get('肠道微生物健康指数') or gmhi.get('GMHI'):
  794. score = gmhi.get('肠道微生物健康指数') or gmhi.get('GMHI', 0)
  795. if score >= 90: gmhi['健康状态'] = '健康'
  796. elif score >= 80: gmhi['健康状态'] = '健康倾向'
  797. elif score >= 40: gmhi['健康状态'] = '亚健康'
  798. elif score >= 20: gmhi['健康状态'] = '不健康倾向'
  799. else: gmhi['健康状态'] = '不健康'
  800. return gmhi
  801. def _parse_bj_gut_type(text):
  802. """提取肠型"""
  803. content_start = _find_content_after_toc(text)
  804. content = text[content_start:]
  805. m = re.search(r'属于\s*肠[型道]\s*([IVX]+)', content)
  806. if m: return f'肠型{m.group(1)}'
  807. m = re.search(r'属于\s*肠[型道]\s*(\S+?)[,。,.\n]', content)
  808. if m: return m.group(1).strip()
  809. return None
  810. def _parse_bj_balance(text):
  811. """提取肠道菌群平衡评估"""
  812. m = re.search(r'(I{1,3}度失衡)', text)
  813. if m: return m.group(1)
  814. return None
  815. def _parse_bj_barrier(text):
  816. """提取肠道黏膜屏障/BE比值"""
  817. barrier = {}
  818. # 在内容区找表格
  819. content_start = _find_content_after_toc(text)
  820. content = text[content_start:]
  821. # 找 BE比值 表格行
  822. m = re.search(r'BE比[值例][::\s]*(\d+\.?\d*)', text)
  823. if m: barrier['BE比值'] = float(m.group(1))
  824. m = re.search(r'双歧杆菌[属菌][::\s]*(\d+\.?\d*)', text)
  825. if m: barrier['双歧杆菌属'] = float(m.group(1))
  826. m = re.search(r'肠杆菌[科属][::\s]*(\d+\.?\d*)', text)
  827. if m: barrier['肠杆菌科'] = float(m.group(1))
  828. # 综合评估在"BE比值"附近找
  829. be_idx = content.find('BE比')
  830. if be_idx != -1:
  831. chunk = content[be_idx:be_idx + 300]
  832. m = re.search(r'(正常|轻度损伤|中度损伤|重度损伤|损伤)', chunk)
  833. if m: barrier['综合评估'] = m.group(1)
  834. return barrier
  835. def _parse_bj_diversity(text):
  836. """提取菌群多样性"""
  837. div = {}
  838. content_start = _find_content_after_toc(text)
  839. content = text[content_start:]
  840. m = re.search(r'多样性指数[为](\d+\.?\d*)', content)
  841. if m: div['Shannon指数'] = float(m.group(1))
  842. m = re.search(r'高于[::\s]*(\d+)%', content)
  843. if m: div['人群百分位'] = f'高于{m.group(1)}%'
  844. m = re.search(r'菌种总数[::\s]*(\d+)', content)
  845. if m: div['菌种总数'] = int(m.group(1))
  846. return div
  847. def _parse_bj_phenotype(text):
  848. """提取菌群表型评估"""
  849. pheno = {}
  850. # 找表型评估表格区域
  851. last_idx = _find_last_section(text, '菌群表型评估')
  852. if last_idx == -1:
  853. last_idx = _find_last_section(text, '肠道菌群表型评估')
  854. if last_idx == -1:
  855. return pheno
  856. # 取表格区域
  857. region = text[last_idx:last_idx + 1500]
  858. # 解析表格行: 指标名 评估值 结果
  859. for label, kw in [('革兰氏阳性菌', '阳性菌'), ('革兰氏阴性菌', '阴性菌'),
  860. ('生物膜合成', '生物膜'), ('好氧菌', '好氧'),
  861. ('厌氧菌', '厌氧'), ('兼性厌氧菌', '兼性'),
  862. ('氧化胁迫耐受', '氧化'), ('致病潜力', '致病')]:
  863. # 找该行: 值在指标名附近
  864. idx = region.find(kw)
  865. if idx == -1:
  866. continue
  867. chunk = region[idx:idx + 100]
  868. val_m = re.search(r'(\d+\.?\d*)', chunk)
  869. if val_m:
  870. entry = {'评估值': float(val_m.group(1))}
  871. for status in ['正常', '异常']:
  872. if status in chunk:
  873. entry['结果'] = status
  874. break
  875. pheno[label] = entry
  876. return pheno
  877. def _parse_bj_scfa(text):
  878. """提取短链脂肪酸合成能力"""
  879. scfa = {}
  880. # 找 SCFA 表格区域(最后一次出现)
  881. last_idx = _find_last_section(text, '短链脂肪酸合成能力')
  882. if last_idx == -1:
  883. last_idx = _find_last_section(text, '短链脂肪酸')
  884. if last_idx == -1:
  885. return scfa
  886. region = text[last_idx:last_idx + 800]
  887. for acid in ['甲酸', '乙酸', '丙酸', '丁酸', '戊酸', '己酸']:
  888. m = re.search(rf'{acid}[^a-zA-Z]*?(\d+)', region)
  889. if m:
  890. scfa[acid] = int(m.group(1))
  891. return scfa
  892. def _parse_bj_core_bacteria(text):
  893. """提取核心菌属精准分布"""
  894. bacteria = []
  895. for cn_name, lat_name in CORE_GENERA_BJ:
  896. idx = text.find(lat_name)
  897. if idx == -1: idx = text.find(cn_name)
  898. if idx == -1: continue
  899. chunk = text[max(0, idx - 50):idx + 200]
  900. val_m = re.search(r'(\d+\.?\d*)', chunk)
  901. status_m = re.search(r'(缺失|严重超标|超标|偏低|正常)', chunk)
  902. pct_m = re.search(r'(\d+\.?\d*)%', chunk)
  903. range_m = re.search(r'([\d.]+-[\d.]+)', chunk)
  904. entry = {
  905. '拉丁名': lat_name,
  906. '中文名': cn_name,
  907. '检测结果': float(val_m.group(1)) if val_m else None,
  908. '状态': status_m.group(1) if status_m else '未知',
  909. '人群百分位': float(pct_m.group(1)) if pct_m else None,
  910. '参考范围': range_m.group(1) if range_m else None,
  911. }
  912. bacteria.append(entry)
  913. return bacteria
  914. def _parse_bj_disease_risks(text):
  915. """提取疾病风险评估"""
  916. diseases = []
  917. for keyword in DISEASE_KEYWORDS_BJ:
  918. idx = text.find(keyword)
  919. if idx == -1: continue
  920. chunk = text[idx:idx + 200]
  921. val_m = re.search(r'(\d+\.\d+)', chunk)
  922. level_m = re.search(r'(低风险|较低风险|中度风险|较高风险|高风险)', chunk)
  923. if val_m:
  924. diseases.append({
  925. '疾病': keyword,
  926. '风险指数': float(val_m.group(1)),
  927. '风险等级': level_m.group(1) if level_m else '',
  928. })
  929. return diseases
  930. def _parse_bj_nutrient_metabolism(text):
  931. """提取营养物质及营养素代谢评估"""
  932. metabolism = {}
  933. # 主要营养物质
  934. for n in ['碳水化合物', '蛋白质', '脂肪', '矿物质']:
  935. m = re.search(rf'{n}[^估]*?(\d+)', text)
  936. if m:
  937. metabolism.setdefault('主要营养物质代谢', {})[n] = int(m.group(1))
  938. # 糖类
  939. for item in ['葡萄糖(糖酵解途径)', '葡萄糖(磷酸戊糖途径)', '果糖和甘露糖',
  940. '半乳糖', '淀粉和蔗糖', '氨基糖和核苷酸糖', '丙酮酸']:
  941. m = re.search(rf'{item[:4]}.*?(\d+)', text)
  942. if m:
  943. metabolism.setdefault('糖类代谢', {})[item] = int(m.group(1))
  944. # 脂类
  945. for item in ['甘油酯', '甘油磷脂', '甘油三酯', '鞘脂类', '脂肪酸',
  946. '硬脂酸', '棕榈酸', '花生四烯酸', '二十二碳六烯酸',
  947. 'Omega-3', 'Omega-6']:
  948. m = re.search(rf'{item}.*?(\d+)', text)
  949. if m:
  950. metabolism.setdefault('脂类代谢', {})[item] = int(m.group(1))
  951. # 维生素
  952. for item in ['维生素A', '维生素B1', '维生素B2', '维生素B3', '维生素B5',
  953. '维生素B6', '维生素B7', '维生素B12', '维生素C', '维生素D',
  954. '维生素E', '维生素K1', '维生素K2', '维生素K3', '叶酸']:
  955. m = re.search(rf'{item}.*?(\d+)', text)
  956. if m:
  957. metabolism.setdefault('维生素', {})[item] = int(m.group(1))
  958. # 微量元素
  959. for item in ['铁', '锌', '钙', '镁', '硒', '锰', '铜', '钴', '镍', '钼', '铬', '钒']:
  960. m = re.search(rf'{item}[^估]*?(\d+)', text)
  961. if m:
  962. metabolism.setdefault('微量元素', {})[item] = int(m.group(1))
  963. # 氨基酸
  964. for item in ['赖氨酸', '丝氨酸', '亮氨酸', '色氨酸', '苯丙氨酸',
  965. '缬氨酸', '组氨酸', '半胱氨酸', '酪氨酸', '丙氨酸',
  966. '脯氨酸', '苏氨酸', '谷氨酸', '异亮氨酸', '精氨酸',
  967. '蛋氨酸', '甘氨酸', '天冬氨酸', '牛磺酸']:
  968. m = re.search(rf'{item}[^估]*?(\d+)', text)
  969. if m:
  970. metabolism.setdefault('氨基酸', {})[item] = int(m.group(1))
  971. # 神经递质
  972. for item in ['5-羟色胺', 'γ-氨基丁酸', '多巴胺', '乙酰胆碱', '组胺', '去甲肾上腺素']:
  973. m = re.search(rf'{item}.*?(\d+)', text)
  974. if m:
  975. metabolism.setdefault('神经递质', {})[item] = int(m.group(1))
  976. # 其他单项
  977. for pattern, key in [
  978. (r'嘌呤代谢.*?(\d+)', '嘌呤代谢'),
  979. (r'谷胱甘肽.*?(\d+)', '三肽(谷胱甘肽)'),
  980. (r'胆汁酸.*?(\d+)', '胆汁酸代谢'),
  981. (r'硫辛酸.*?(\d+)', '抗自由基(硫辛酸)'),
  982. (r'辅酶Q.*?(\d+)', '抗自由基(辅酶Q)'),
  983. ]:
  984. m = re.search(pattern, text)
  985. if m:
  986. metabolism[key] = int(m.group(1))
  987. return metabolism
  988. def _parse_bj_antibiotics(text):
  989. """提取抗生素风险评估"""
  990. ab = {}
  991. for cat in ANTIBIOTIC_CATEGORIES_BJ:
  992. m = re.search(rf'{cat}[::\s]*(\d+)', text)
  993. if m:
  994. ab[cat] = int(m.group(1))
  995. return ab
  996. def _parse_bj_toxins(text):
  997. """提取毒性物质清除能力评估"""
  998. toxins = {}
  999. toxin_items = [
  1000. "苯甲酸酯", "对氨基苯甲酸乙酯", "对氟苯甲酸乙酯",
  1001. "氯烷烃和氯烯烃", "氯代环己烷", "氯苯",
  1002. "甲苯", "二甲苯", "硝基甲苯", "乙苯", "苯乙烯",
  1003. "阿特拉津", "己内酰胺", "双酚", "二恶英",
  1004. "萘", "多环芳烃",
  1005. ]
  1006. for item in toxin_items:
  1007. m = re.search(rf'{item}.*?(\d+)', text)
  1008. if m:
  1009. toxins[item] = int(m.group(1))
  1010. return toxins
  1011. def _parse_bj_pathogens(text):
  1012. """提取胃肠道感染病原体评估"""
  1013. pathogens = []
  1014. for p in PATHOGEN_LIST_BJ:
  1015. idx = text.find(p)
  1016. if idx == -1: continue
  1017. chunk = text[idx:idx + 150]
  1018. val_m = re.search(r'(\d+\.?\d*)', chunk)
  1019. status_m = re.search(r'(检出|超标|未检出|正常)', chunk)
  1020. if val_m:
  1021. entry = {'名称': p, '检测值': float(val_m.group(1)) if '.' in val_m.group(1) else int(val_m.group(1))}
  1022. if status_m: entry['状态'] = status_m.group(1)
  1023. pathogens.append(entry)
  1024. return pathogens
  1025. def _parse_bj_psych_eval(text, find_table_val=None):
  1026. """提取趣味肠菌评估"""
  1027. psych = {}
  1028. m = re.search(r'最高概率[::\s]*(\S+)', text)
  1029. if m: psych['人格特征'] = {'最高概率': m.group(1)}
  1030. # 认知功能(从表格)
  1031. if find_table_val:
  1032. cog = find_table_val('认知能力', val_col=1)
  1033. if cog:
  1034. psych['认知功能'] = {'认知能力评分': int(cog)}
  1035. return psych
  1036. def _parse_bj_scfa_from_tables(find_table_rows):
  1037. """从表格提取短链脂肪酸合成能力"""
  1038. scfa = {}
  1039. for acid in ['甲酸', '乙酸', '丙酸', '丁酸', '戊酸', '己酸']:
  1040. rows = find_table_rows(acid)
  1041. for name, val in rows:
  1042. if acid in name:
  1043. scfa[acid] = int(val)
  1044. break
  1045. return scfa
  1046. def _parse_bj_core_bacteria_from_tables(all_tables):
  1047. """从表格提取核心菌属精准分布"""
  1048. bacteria = []
  1049. for cn_name, lat_name in CORE_GENERA_BJ:
  1050. for table in all_tables:
  1051. for row in table:
  1052. row_str = ' '.join(str(c) for c in row if c)
  1053. if lat_name in row_str or cn_name in row_str:
  1054. vals = [str(c).strip() for c in row if c and str(c).strip()]
  1055. entry = {'拉丁名': lat_name, '中文名': cn_name}
  1056. for v in vals:
  1057. v_clean = v.replace('"', '').replace('#', '').strip()
  1058. # 判断是否为范围值(如 0.0041-0.0339)
  1059. if '-' in v_clean and v_clean.replace('.','').replace('-',' ').replace(' ','').isdigit():
  1060. entry['参考范围'] = v_clean
  1061. elif v_clean == 'ND':
  1062. if '检测结果' not in entry:
  1063. entry['检测结果'] = 0.0
  1064. elif v_clean.replace('.','').replace('-','').isdigit() and v_clean != '':
  1065. val = float(v_clean)
  1066. if '检测结果' not in entry:
  1067. entry['检测结果'] = val
  1068. elif '人群百分位' not in entry:
  1069. entry['人群百分位'] = val
  1070. if '检测结果' in entry:
  1071. bacteria.append(entry)
  1072. break
  1073. if bacteria and bacteria[-1].get('拉丁名') == lat_name:
  1074. break
  1075. return bacteria
  1076. def _parse_bj_nutrient_from_tables(find_table_rows, find_table_val):
  1077. """从表格提取营养物质及营养素代谢评估"""
  1078. metabolism = {}
  1079. # 主要营养物质
  1080. for n in ['碳水化合物', '蛋白质', '脂肪', '矿物质']:
  1081. rows = find_table_rows(n)
  1082. if rows:
  1083. for name, val in rows:
  1084. if n in name:
  1085. metabolism.setdefault('主要营养物质代谢', {})[n] = int(val)
  1086. # 糖类
  1087. sugar_items = ['葡萄糖(糖酵解途径)', '葡萄糖(磷酸戊糖途径)', '果糖和甘露糖',
  1088. '半乳糖', '淀粉和蔗糖', '氨基糖和核苷酸糖', '丙酮酸']
  1089. for item in sugar_items:
  1090. rows = find_table_rows(item[:3])
  1091. if rows:
  1092. for name, val in rows:
  1093. metabolism.setdefault('糖类代谢', {})[item] = int(val)
  1094. # 脂类
  1095. for item in ['甘油酯', '甘油磷脂', '甘油三酯', '鞘脂类', '脂肪酸',
  1096. '硬脂酸', '棕榈酸', '花生四烯酸', '二十二碳六烯酸',
  1097. 'Omega-3', 'Omega-6']:
  1098. rows = find_table_rows(item[:3])
  1099. if rows:
  1100. for name, val in rows:
  1101. metabolism.setdefault('脂类代谢', {})[item] = int(val)
  1102. # 维生素
  1103. for item in ['维生素A', '维生素B1', '维生素B2', '维生素B3', '维生素B5',
  1104. '维生素B6', '维生素B7', '维生素B12', '维生素C', '维生素D',
  1105. '维生素E', '维生素K1', '维生素K2', '维生素K3', '叶酸']:
  1106. rows = find_table_rows(item)
  1107. if rows:
  1108. for name, val in rows:
  1109. metabolism.setdefault('维生素', {})[item] = int(val)
  1110. # 微量元素
  1111. for item in ['铁', '锌', '钙', '镁', '硒', '锰', '铜', '钴', '镍', '钼', '铬', '钒']:
  1112. rows = find_table_rows(item)
  1113. if rows:
  1114. for name, val in rows:
  1115. if name == item:
  1116. metabolism.setdefault('微量元素', {})[item] = int(val)
  1117. # 氨基酸
  1118. for item in ['赖氨酸', '丝氨酸', '亮氨酸', '色氨酸', '苯丙氨酸',
  1119. '缬氨酸', '组氨酸', '半胱氨酸', '酪氨酸', '丙氨酸',
  1120. '脯氨酸', '苏氨酸', '谷氨酸', '异亮氨酸', '精氨酸',
  1121. '蛋氨酸', '甘氨酸', '天冬氨酸', '牛磺酸']:
  1122. rows = find_table_rows(item[:2])
  1123. if rows:
  1124. for name, val in rows:
  1125. metabolism.setdefault('氨基酸', {})[item] = int(val)
  1126. # 神经递质
  1127. for item in ['5-羟色胺', 'γ-氨基丁酸', '多巴胺', '乙酰胆碱', '组胺', '去甲肾上腺素']:
  1128. rows = find_table_rows(item[:3])
  1129. if rows:
  1130. for name, val in rows:
  1131. metabolism.setdefault('神经递质', {})[item] = int(val)
  1132. # 其他单项
  1133. for item, key in [('嘌呤代谢', '嘌呤代谢'), ('谷胱甘肽', '三肽(谷胱甘肽)'),
  1134. ('胆汁酸', '胆汁酸代谢'), ('硫辛酸', '抗自由基(硫辛酸)'),
  1135. ('辅酶Q', '抗自由基(辅酶Q)')]:
  1136. v = find_table_val(item)
  1137. if v: metabolism[key] = int(v)
  1138. return metabolism
  1139. def _parse_bj_antibiotics_from_tables(find_table_rows):
  1140. """从表格提取抗生素风险评估"""
  1141. ab = {}
  1142. for cat in ANTIBIOTIC_CATEGORIES_BJ:
  1143. rows = find_table_rows(cat, val_col=2)
  1144. for name, val in rows:
  1145. ab[cat] = int(val)
  1146. return ab
  1147. def _parse_bj_toxins_from_tables(all_tables, find_table_rows):
  1148. """从表格提取毒性物质清除能力评估"""
  1149. toxins = {}
  1150. toxin_items = [
  1151. "苯甲酸酯", "对氨基苯甲酸乙酯", "对氟苯甲酸乙酯",
  1152. "氯烷烃和氯烯烃", "氯代环己烷", "氯苯",
  1153. "甲苯", "二甲苯", "硝基甲苯", "乙苯", "苯乙烯",
  1154. "阿特拉津", "己内酰胺", "双酚", "二恶英",
  1155. "萘", "多环芳烃",
  1156. ]
  1157. for item in toxin_items:
  1158. rows = find_table_rows(item[:3], name_col=1, val_col=2)
  1159. for name, val in rows:
  1160. toxins[item] = int(val)
  1161. return toxins
  1162. def _parse_bj_overview(text):
  1163. """提取北京报告概述(统一到标准报告 overview 结构)"""
  1164. r = {}
  1165. m = re.search(r'检测编号[::\s]*(\S+)', text)
  1166. if m: r['report_number'] = m.group(1)
  1167. m = re.search(r'姓\s*名[::\s]*(\S+)', text)
  1168. if m: r['person_name'] = m.group(1)
  1169. m = re.search(r'年\s*龄[::\s]*(\S+)', text)
  1170. if m: r['age'] = m.group(1)
  1171. m = re.search(r'性\s*别[::\s]*(\S+)', text)
  1172. if m: r['gender'] = 'male' if m.group(1) in ('男', 'M') else 'female'
  1173. # 肠型
  1174. gut_type = _parse_bj_gut_type(text)
  1175. if gut_type: r['gut_type'] = gut_type
  1176. # GMHI → 健康总分
  1177. m = re.search(r'肠道微生物健康指数[::\s]*(\d+)', text)
  1178. if m: r['overallScore'] = int(m.group(1))
  1179. return r
  1180. def _parse_beijing_report(file_path: str) -> dict:
  1181. """解析北京菌群报告 PDF,输出统一结构(pdfplumber 表格+文本混合提取)"""
  1182. try:
  1183. import pdfplumber
  1184. with pdfplumber.open(file_path) as pdf:
  1185. text = '\n'.join(page.extract_text() or '' for page in pdf.pages)
  1186. # 提取所有表格
  1187. all_tables = []
  1188. for page in pdf.pages:
  1189. tables = page.extract_tables()
  1190. for table in tables:
  1191. if table and len(table) >= 2:
  1192. all_tables.append(table)
  1193. except Exception as e:
  1194. from PyPDF2 import PdfReader
  1195. reader = PdfReader(file_path)
  1196. text = '\n'.join((page.extract_text() or '') for page in reader.pages)
  1197. all_tables = []
  1198. def find_table_rows(keyword, val_col=1, name_col=0, try_all=False):
  1199. """从表格中找指定关键词的数据行"""
  1200. rows = []
  1201. for table in all_tables:
  1202. for row in table:
  1203. row_str = ' '.join(str(c) for c in row if c)
  1204. if keyword in row_str:
  1205. if name_col < len(row) and val_col < len(row):
  1206. name = str(row[name_col] or '').strip()
  1207. val = str(row[val_col] or '').strip()
  1208. if name and val and val.replace('.','').replace('-','').isdigit():
  1209. rows.append((name, val))
  1210. if rows and not try_all:
  1211. break
  1212. return rows
  1213. def find_table_val(keyword, val_col=1, name_col=0):
  1214. """从表格中找单个值"""
  1215. for table in all_tables:
  1216. for row in table:
  1217. row_str = ' '.join(str(c) for c in row if c)
  1218. if keyword in row_str:
  1219. if val_col < len(row):
  1220. val = str(row[val_col] or '').strip()
  1221. if val and val.replace('.','').isdigit():
  1222. return val
  1223. return None
  1224. result = {
  1225. 'format': 'beijing',
  1226. 'overview': _parse_bj_overview(text),
  1227. 'disease_risks': [],
  1228. 'nutrition': [],
  1229. 'amino_acids': [],
  1230. 'vitamins': [],
  1231. 'trace_elements': [],
  1232. '菌群检出详细列表': {},
  1233. '个体化食物推荐表': {'格式': 'not_found', '条目数': 0, '数据': []},
  1234. # 北京报告特有指标
  1235. '肠道微生物健康指数': _parse_bj_gmhi(text),
  1236. '菌群多样性': _parse_bj_diversity(text),
  1237. '肠道黏膜屏障': _parse_bj_barrier(text),
  1238. '菌群表型评估': _parse_bj_phenotype(text),
  1239. '短链脂肪酸合成能力': _parse_bj_scfa_from_tables(find_table_rows),
  1240. '肠道菌群精准分布': _parse_bj_core_bacteria_from_tables(all_tables),
  1241. '营养物质及营养素代谢评估': _parse_bj_nutrient_from_tables(find_table_rows, find_table_val),
  1242. '抗生素风险评估': _parse_bj_antibiotics_from_tables(find_table_rows),
  1243. '毒性物质清除能力评估': _parse_bj_toxins_from_tables(all_tables, find_table_rows),
  1244. '趣味肠菌评估': _parse_bj_psych_eval(text, find_table_val),
  1245. '胃肠道感染病原体': [],
  1246. '健康整体评估': {},
  1247. '肠道菌群主要检测结果': {},
  1248. }
  1249. # 从表格更新 GMHI/抗炎/免疫/纤维/多样性/屏障
  1250. gmhi_val = find_table_val('肠道微生物健康指数', val_col=1)
  1251. if gmhi_val:
  1252. result['肠道微生物健康指数']['肠道微生物健康指数'] = float(gmhi_val)
  1253. score = float(gmhi_val)
  1254. if score >= 90: result['肠道微生物健康指数']['健康状态'] = '健康'
  1255. elif score >= 80: result['肠道微生物健康指数']['健康状态'] = '健康倾向'
  1256. elif score >= 40: result['肠道微生物健康指数']['健康状态'] = '亚健康'
  1257. elif score >= 20: result['肠道微生物健康指数']['健康状态'] = '不健康倾向'
  1258. else: result['肠道微生物健康指数']['健康状态'] = '不健康'
  1259. for attr, kw in [('肠道抗炎能力', '抗炎能力'), ('肠道免疫力', '免疫力'),
  1260. ('肠道膳食纤维需求', '膳食纤维需求')]:
  1261. v = find_table_val(kw)
  1262. if v:
  1263. result.setdefault('肠道菌群主要检测结果', {})[attr] = int(v)
  1264. if '抗炎' in kw: result['overview']['inflammationScore'] = int(v)
  1265. elif '免疫' in kw: result['overview']['immunityScore'] = int(v)
  1266. # 多样性
  1267. div_val = find_table_val('肠道微生物多样性', val_col=1)
  1268. if div_val:
  1269. result['菌群多样性']['Shannon指数'] = float(div_val)
  1270. # BE比值
  1271. be_val = find_table_val('B/E比值', val_col=1)
  1272. if be_val:
  1273. result['肠道黏膜屏障']['BE比值'] = float(be_val)
  1274. bi_val = find_table_val('双歧杆菌属', val_col=1)
  1275. if bi_val:
  1276. result['肠道黏膜屏障']['双歧杆菌属'] = float(bi_val)
  1277. ent_val = find_table_val('肠杆菌科', val_col=1)
  1278. if ent_val:
  1279. result['肠道黏膜屏障']['肠杆菌科'] = float(ent_val)
  1280. # 肠型
  1281. gut_type = _parse_bj_gut_type(text)
  1282. balance = _parse_bj_balance(text)
  1283. if gut_type or balance:
  1284. result['健康整体评估'] = {}
  1285. if gut_type: result['健康整体评估']['肠型'] = gut_type
  1286. if balance: result['健康整体评估']['菌群平衡评估'] = balance
  1287. # 疾病风险评估(文本提取)
  1288. disease_risks = _parse_bj_disease_risks(text)
  1289. if disease_risks:
  1290. result['disease_risks'] = disease_risks
  1291. # 病原体
  1292. pathogens = _parse_bj_pathogens(text)
  1293. if pathogens:
  1294. result['胃肠道感染病原体'] = pathogens
  1295. # 营养物质 → 标准字段映射
  1296. metabolism = result.get('营养物质及营养素代谢评估', {})
  1297. if metabolism.get('主要营养物质代谢'):
  1298. result['nutrition'] = [
  1299. {'name': k, 'value': str(v), 'status': '正常' if v >= 60 else '偏低'}
  1300. for k, v in metabolism['主要营养物质代谢'].items()
  1301. ]
  1302. if metabolism.get('氨基酸'):
  1303. result['amino_acids'] = [
  1304. {'name': k, 'value': str(v), 'status': '正常' if v >= 60 else '偏低'}
  1305. for k, v in metabolism['氨基酸'].items()
  1306. ]
  1307. if metabolism.get('维生素'):
  1308. result['vitamins'] = [
  1309. {'name': k, 'value': str(v), 'status': '正常' if v >= 60 else '偏低'}
  1310. for k, v in metabolism['维生素'].items()
  1311. ]
  1312. if metabolism.get('微量元素'):
  1313. result['trace_elements'] = [
  1314. {'name': k, 'value': str(v), 'status': '正常' if v >= 60 else '偏低'}
  1315. for k, v in metabolism['微量元素'].items()
  1316. ]
  1317. return result