Procházet zdrojové kódy

feat(parser): 移植报告PDF菌群表与食物推荐表解析

- 对齐 RADICAL_MAP/FOOD_SKIP_TEXTS/DISEASE_BACTERIA_TITLES 与原脚本
- 移植 _parse_bacteria_table/_parse_phylum_tables/_parse_taxonomy_levels
- parse_report_pdf 接入菌群检出详细列表(11分组)与个体化食物推荐表
- Java 端 convertLgFloraList/convertLgFoodList 解析 LangGraph snake_case 输出
liaoxg před 1 měsícem
rodič
revize
f6ad80e513

+ 92 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -1578,9 +1578,101 @@ public class HealthReportController {
             payload.setIndicators(inds);
         }
 
+        // 菌群/饮食推荐分组(LangGraph 输出 snake_case 键,缺省时安全跳过)
+        payload.setGutFlora(convertLgFloraList(lgData.get("gut_flora")));
+        payload.setProbioticSpecies(convertLgFloraList(lgData.get("probiotic_species")));
+        payload.setTaxonomyClass(convertLgFloraList(lgData.get("taxonomy_class")));
+        payload.setTaxonomyOrder(convertLgFloraList(lgData.get("taxonomy_order")));
+        payload.setTaxonomyFamily(convertLgFloraList(lgData.get("taxonomy_family")));
+        payload.setTaxonomyGenus(convertLgFloraList(lgData.get("taxonomy_genus")));
+        payload.setTaxonomySpecies(convertLgFloraList(lgData.get("taxonomy_species")));
+        payload.setPathogenGenus(convertLgFloraList(lgData.get("pathogen_genus")));
+        payload.setPathogenDetection(convertLgFloraList(lgData.get("pathogen_detection")));
+        payload.setFoods(convertLgFoodList(lgData.get("foods")));
+
         return payload;
     }
 
+    /**
+     * 将 LangGraph 输出的菌群列表(snake_case 元素)转换为 ParsedReportPayload.Flora 列表。
+     * 键缺失或类型不符时安全返回空列表。
+     */
+    private List<ParsedReportPayload.Flora> convertLgFloraList(Object raw) {
+        List<ParsedReportPayload.Flora> floraList = new ArrayList<>();
+        if (!(raw instanceof List)) {
+            return floraList;
+        }
+        for (Object item : (List<?>) raw) {
+            if (!(item instanceof Map)) {
+                continue;
+            }
+            Map<String, Object> m = (Map<String, Object>) item;
+            ParsedReportPayload.Flora f = new ParsedReportPayload.Flora();
+            f.setBacteriaName(objectToString(m.get("name")));
+            f.setBacteriaValue(objectToString(m.get("value")));
+            f.setNormalRange(objectToString(m.get("normal_range")));
+            f.setPopulationLevel(objectToString(m.get("population_level")));
+            f.setDetectionRate(objectToString(m.get("detection_rate")));
+            f.setDescription(objectToString(m.get("description")));
+            f.setCategory(objectToString(m.get("category")));
+            f.setLevel(objectToString(m.get("level")));
+            if (f.getBacteriaName() == null && f.getDescription() == null) {
+                continue;
+            }
+            floraList.add(f);
+        }
+        return floraList;
+    }
+
+    /**
+     * 将 LangGraph 输出的饮食推荐列表(snake_case 元素)转换为 ParsedReportPayload.FoodItem 列表。
+     */
+    private List<ParsedReportPayload.FoodItem> convertLgFoodList(Object raw) {
+        List<ParsedReportPayload.FoodItem> foodList = new ArrayList<>();
+        if (!(raw instanceof List)) {
+            return foodList;
+        }
+        for (Object item : (List<?>) raw) {
+            if (!(item instanceof Map)) {
+                continue;
+            }
+            Map<String, Object> m = (Map<String, Object>) item;
+            ParsedReportPayload.FoodItem fi = new ParsedReportPayload.FoodItem();
+            fi.setName(objectToString(m.get("name")));
+            fi.setCategory(objectToString(m.get("category")));
+            fi.setScore(toInteger(m.get("score")));
+            fi.setEnergyKj(toDecimal(m.get("energy_kj")));
+            fi.setProtein(toDecimal(m.get("protein")));
+            fi.setFat(toDecimal(m.get("fat")));
+            fi.setCarbs(toDecimal(m.get("carbs")));
+            fi.setStarch(toDecimal(m.get("starch")));
+            fi.setFiber(toDecimal(m.get("fiber")));
+            fi.setCholesterol(toDecimal(m.get("cholesterol")));
+            if (fi.getName() == null) {
+                continue;
+            }
+            foodList.add(fi);
+        }
+        return foodList;
+    }
+
+    private static BigDecimal toDecimal(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof BigDecimal) {
+            return (BigDecimal) value;
+        }
+        if (value instanceof Number) {
+            return BigDecimal.valueOf(((Number) value).doubleValue());
+        }
+        try {
+            return new BigDecimal(value.toString());
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
     private static Integer toInteger(Object value) {
         if (value == null) return null;
         if (value instanceof Integer) return (Integer) value;

+ 597 - 10
cfc-langgraph/app/parsers/pdf_parser.py

@@ -2,21 +2,27 @@
 菌群报告 PDF 解析器
 从 extract_full_report_v5.py 提取核心逻辑,封装为可调用函数
 """
+import os
 import re
 from PyPDF2 import PdfReader
 
 # === 常量 ===
+# Kangxi 部首 → CJK 统一汉字(与 extract_full_report_v5.py 完全一致)
 RADICAL_MAP = {
-    '\u2f52': '\u6c0f', '\u2f51': '\u6bcd', '\u2f59': '\u6b6f',
-    '\u2f04': '\u4e59', '\u2f20': '\u4e00', '\u2f21': '\u4e28',
-    '\u2f22': '\u4e3f', '\u2f23': '\u4e39', '\u2f2b': '\u5c38',
-    '\u2f2d': '\u5c71', '\u2f44': '\u4e59', '\u2f53': '\u6c14',
-    '\u2f55': '\u6c34', '\u2f5c': '\u725b', '\u2f5f': '\u7389',
-    '\u2f7a': '\u7f8a', '\u2f81': '\u8089', '\u2f83': '\u81ea',
-    '\u2f8a': '\u8272', '\u2f8e': '\u8840', '\u2f95': '\u79be',
-    '\u2f96': '\u8c46', '\u2faf': '\u9762', '\u2fb9': '\u9999',
-    '\u2fca': '\u9ed1', '\u2ec9': '\u8d1d', '\u2edd': '\u98df',
-    '\u2ee2': '\u9a6c', '\u2ee9': '\u9ec4',
+    '\u2f18': '卜', '\u2f1f': '土', '\u2f24': '大', '\u2f26': '子',
+    '\u2f29': '小', '\u2f2d': '山', '\u2f32': '干', '\u2f3c': '心',
+    '\u2f42': '文', '\u2f46': '无', '\u2f4a': '木', '\u2f50': '比',
+    '\u2f54': '水', '\u2f55': '火', '\u2f5c': '牛', '\u2f5f': '玉',
+    '\u2f60': '瓜', '\u2f62': '甘', '\u2f63': '生', '\u2f64': '用',
+    '\u2f69': '白', '\u2f6a': '皮', '\u2f6c': '目', '\u2f6f': '石',
+    '\u2f75': '竹', '\u2f76': '米', '\u2f7a': '羊', '\u2f7b': '羽',
+    '\u2f7c': '老', '\u2f7f': '耳', '\u2f81': '肉', '\u2f90': '衣',
+    '\u2f95': '谷', '\u2f96': '豆', '\u2f9d': '身', '\u2fa6': '金',
+    '\u2faf': '面', '\u2fb2': '韭', '\u2fb9': '香', '\u2fca': '黑',
+    '\u2ec9': '贝', '\u2edd': '食', '\u2ee2': '马', '\u2ee5': '鱼',
+    '\u2ee8': '麦', '\u2ee9': '黄', '\u2ef0': '龙',
+    # 氏 radical U+2F52 → U+6C0F
+    '\u2f52': '氏',
 }
 
 KNOWN_MACRO = ['碳水化合物', '蛋白质', '脂肪', '纤维素', '乳制品']
@@ -42,6 +48,39 @@ KNOWN_ANTIBIOTICS = ['β-内酰胺酶类', '氨基糖苷类', '大环内酯类',
     '喹诺酮类', '磺胺类', '甲氧苄啶类', '氯霉素类', '四环素类']
 KNOWN_PATHOGENS = ['幽门螺杆菌', '艰难梭菌', '沙门氏菌', '志贺氏菌', '弯曲杆菌']
 
+# === 菌群表标题(完整版 extract_full_report_v5.py 移植) ===
+BACTERIA_TABLE_TITLES = [
+    '核心菌属构成表', '益生菌', '有害菌属构成表',
+    '其它重要菌属构成表', '病原菌属构成表',
+]
+PHYLUM_TABLE_TITLES = ['菌门构成表', '菌群门水平构成表', '门水平菌群构成']
+CLASS_TABLE_TITLES = ['菌纲构成表', '菌群纲水平构成表', '纲水平菌群构成']
+ORDER_TABLE_TITLES = ['菌目构成表', '菌群目水平构成表', '目水平菌群构成']
+FAMILY_TABLE_TITLES = ['菌科构成表', '菌群科水平构成表', '科水平菌群构成']
+GENUS_TABLE_TITLES = ['菌属构成表', '菌群属水平构成表', '属水平菌群构成']
+SPECIES_TABLE_TITLES = ['菌种构成表', '菌群种水平构成表', '种水平菌群构成']
+DISEASE_BACTERIA_TITLES = [
+    '肥胖相关菌', '便秘相关菌', '抑郁相关菌', '过敏相关菌',
+    '腹胀相关菌', '失眠相关菌', '肠道健康相关菌',
+    '多动症相关菌', '自闭症相关菌',
+]
+
+# === 食物推荐表(完整版 extract_full_report_v5.py 移植) ===
+COLUMNS_FOOD = ['名称', '分类', '推荐指数', '能量KJ', '蛋白g', '脂肪g',
+    '碳水化合物g', '淀粉g', '总膳食纤维g', '胆固醇mg']
+KNOWN_CATS = ['主食', '乳制品', '干果', '坚果', '快餐', '水产品',
+    '水果', '汤', '肉类', '蔬菜', '豆类及豆制品', '蛋类', '饮料']
+FOOD_SKIP_TEXTS = [
+    '根据您的肠道菌群', '分值从-100', '食物推荐考虑', '食物推荐是综合',
+    '需要注意的是', '本饮食推荐', '该饮食推荐根据', '后续表格中的营养',
+    '16S 高通量测序', '基于机器学习和', '肠道菌群健康检测报告说明',
+    '检测方法及局限性', '数据分析及模型', '结果解读及使用',
+    '影响因素说明', '建议将检测结果', '营养建议说明',
+    '重要提示', '推荐食物清单', '实际食用时需结合', '如有特殊疾病',
+    '免责声明', '本检测报告仅供', '以上模型预测', '正常范围的定义',
+    '募极生物',
+]
+
 
 def norm(s):
     return ''.join(RADICAL_MAP.get(c, c) for c in s)
@@ -153,6 +192,18 @@ def parse_report_pdf(file_path: str) -> dict:
             result['trace_elements'] = [r for r in vits if '维生素' not in r['name']]
             break
 
+    # 菌群检出详细列表(核心/益生菌/有害菌/病原菌 + 门纲目科属种)
+    bacteria_tables = _extract_bacteria_tables(file_path)
+    result['菌群检出详细列表'] = bacteria_tables
+
+    # 个体化食物推荐表(保留原始键名与结构)
+    food_rows, food_fmt = _extract_food_rows(file_path)
+    result['个体化食物推荐表'] = {
+        '格式': food_fmt,
+        '条目数': len(food_rows),
+        '数据': food_rows
+    }
+
     return result
 
 
@@ -164,3 +215,539 @@ def parse_report_pdf_with_fallback(file_path: str) -> dict:
        not result.get('overview', {}).get('健康总分'):
         result['_parse_incomplete'] = True
     return result
+
+
+def _parse_bacteria_table(reader, pages_text, full_text, title, fmt, skip_header=False):
+    """从PDF中解析一个菌群表格(移植自 extract_full_report_v5.py)"""
+    results = []
+    sidx = full_text.find(title)
+    if sidx == -1:
+        return results
+
+    # 病原菌检出表特殊处理:找"仅列出检出的病原菌"(跳过前面的说明文字中的"病原菌")
+    if title == '病原菌':
+        better_sidx = full_text.find('仅列出检出的病原菌')
+        if better_sidx != -1:
+            sidx = better_sidx
+
+    # 找表格结束位置(下一个标题或页尾)
+    end_pos = len(full_text)
+    for t in (BACTERIA_TABLE_TITLES + PHYLUM_TABLE_TITLES + CLASS_TABLE_TITLES
+              + ORDER_TABLE_TITLES + FAMILY_TABLE_TITLES + GENUS_TABLE_TITLES
+              + SPECIES_TABLE_TITLES + DISEASE_BACTERIA_TITLES
+              + ['指标范围', '个体化食物推荐表', '报告总结', '健康总分']):
+        if t == title:
+            continue
+        ei = full_text.find(t, sidx + len(title))
+        if ei != -1 and ei < end_pos:
+            end_pos = ei
+
+    region = full_text[sidx:end_pos]
+
+    # 检测区域实际格式:如果换行数很少(<3)则是inline格式,即使全局fmt=triplet
+    lines_from_region = [l.strip() for l in region.split('\n') if l.strip()]
+    actual_fmt = fmt
+    if fmt == 'triplet' and len(lines_from_region) <= 5:
+        actual_fmt = 'inline'
+
+    if actual_fmt == 'triplet':
+        # 三元组格式:每个字段单独一行
+        lines = [l.strip() for l in region.split('\n') if l.strip()]
+        start = 0
+        for i, line in enumerate(lines):
+            if line == '名称':
+                start = i + 1
+                break
+            if line.startswith('名称'):
+                if skip_header:
+                    start = i + 1
+                break
+
+        i = start
+        while i < len(lines):
+            name = lines[i]
+            if not name or len(name) <= 1 or name in ['说明', '检测结果', '结果解释', '建议']:
+                i += 1
+                continue
+            if name.startswith('说明:') or name.startswith('改善方式'):
+                i += 1
+                continue
+            if len(name) > 80:
+                i += 1
+                continue
+
+            # 找丰度%
+            if i + 1 < len(lines) and re.match(r'^[\d]+\.?[\d]*%?$|^ND$', lines[i + 1]):
+                pct = lines[i + 1]
+                normal_range = ''
+                pop_level = ''
+                detection_rate = ''
+                desc = ''
+                j = i + 2
+                # 正常范围 (允许小数,如 0.06-6.96, 0.03-3.07)
+                if j < len(lines) and re.match(r'^[\d]+\.?[\d]*-[\d]+\.?[\d]*$', lines[j]):
+                    normal_range = lines[j]
+                    j += 1
+                # 人群水平% (允许小数,如 15.66%)
+                if j < len(lines) and re.match(r'^\d+\.?\d*%$', lines[j]):
+                    pop_level = lines[j]
+                    j += 1
+                # 检出率%
+                if j < len(lines) and re.match(r'^\d+\.?\d*%$', lines[j]):
+                    detection_rate = lines[j]
+                    j += 1
+                # 说明
+                if j < len(lines) and lines[j].startswith('说明'):
+                    desc = lines[j]
+                    j += 1
+                # 改善方式
+                if j < len(lines) and lines[j].startswith('改善方式'):
+                    if desc:
+                        desc += ' | ' + lines[j]
+                    else:
+                        desc = lines[j]
+                    j += 1
+
+                entry = {'名称': name, '丰度%': pct}
+                if normal_range:
+                    entry['正常范围%'] = normal_range
+                if pop_level:
+                    entry['人群水平%'] = pop_level
+                if detection_rate:
+                    entry['检出率%'] = detection_rate
+                if desc:
+                    entry['说明'] = desc
+                results.append(entry)
+                i = j
+            else:
+                i += 1
+    else:
+        # inline 格式
+        sample = region[:500]
+        truly_compressed = bool(re.search(r'[a-z]\d', sample, re.IGNORECASE))
+        if not truly_compressed:
+            # 有空格分隔的inline格式,使用re.finditer
+            region_clean = region
+            for hdr in ['名称', '丰度%', '正常范围%', '处于人群%水平', '%正常人有检出',
+                        '人群水平%', '%人检出']:
+                region_clean = region_clean.replace(hdr, '')
+            region_clean = re.sub(
+                r'说明:[\u4e00-\u9fff\s,。、;:,.;:()()、/a-zA-Z0-9\-]{10,}?(?=[\u4e00-\u9fff]|$)',
+                '', region_clean)
+            # 扫描所有匹配的数据行
+            for m in re.finditer(
+                    r'([\u4e00-\u9fff]{2,12}(?:[((][\u4e00-\u9fff\w]+[))])?)\s+'  # 中文名
+                    r'(?:[A-Z][a-z]+(?:\s[A-Z][a-z]+)*\s+)?'  # 可选英文名
+                    r'(ND|[\d]+\.?[\d]*%?)\s+'  # 丰度
+                    r'([\d]+\.?[\d]*-[\d]+\.?[\d]*)?\s*'  # 可选正常范围
+                    r'(\d+\.?\d*%?)\s+'  # 人群水平%
+                    r'(\d+\.?\d*%)',  # 检出率%
+                    region_clean):
+                name = m.group(1).strip()
+                pct = m.group(2)
+                if '病原菌' in name or '构成表' in name or '说明' in name or len(name) <= 1:
+                    continue
+                normal_range = m.group(3) or ''
+                pop_level = m.group(4)
+                if not pop_level.endswith('%'):
+                    pop_level += '%'
+                detection_rate = m.group(5)
+                entry = {'名称': name, '丰度%': pct}
+                if normal_range:
+                    entry['正常范围%'] = normal_range
+                entry['人群水平%'] = pop_level
+                entry['检出率%'] = detection_rate
+                results.append(entry)
+            # 模式1没有匹配时:仅中文名+丰度+人群水平(+检出率)
+            if not results:
+                for m in re.finditer(
+                        r'([\u4e00-\u9fff]{2,10}[\u4e00-\u9fff]?)\s+'
+                        r'(ND|[\d]+\.?[\d]*%?)\s+'
+                        r'(\d+\.?\d*%)\s+'
+                        r'(\d+\.?\d*%)?',
+                        region_clean):
+                    name = m.group(1).strip()
+                    pct = m.group(2)
+                    if '病原菌' in name or '构成表' in name or '说明' in name or len(name) <= 1:
+                        continue
+                    pop_level = m.group(3)
+                    detection_rate = m.group(4) or ''
+                    entry = {'名称': name, '丰度%': pct, '人群水平%': pop_level}
+                    if detection_rate:
+                        entry['检出率%'] = detection_rate
+                    results.append(entry)
+
+    return results
+
+
+def _parse_phylum_tables(reader, full_text, fmt, title_list=None):
+    """提取菌群层级构成表(门/纲/目/科/属/种 level)(移植自 extract_full_report_v5.py)"""
+    if title_list is None:
+        title_list = PHYLUM_TABLE_TITLES
+    results = []
+    for phylum_title in title_list:
+        rows = _parse_bacteria_table(reader, None, full_text, phylum_title, fmt)
+        results.extend(rows)
+    return results
+
+
+def _parse_taxonomy_levels(reader, full_text, fmt):
+    """从"菌群检出详细列表"中提取纲目科属种各级数据(移植自 extract_full_report_v5.py)"""
+    results = {}
+    sidx = full_text.find('菌群检出详细列表')
+    if sidx == -1:
+        return results
+
+    end_pos = len(full_text)
+    for t in ['个体化食物推荐表', '报告总结', '健康总分']:
+        ei = full_text.find(t, sidx)
+        if ei != -1 and ei < end_pos:
+            end_pos = ei
+
+    section = full_text[sidx:end_pos]
+
+    for level, level_name in [('纲', '菌纲构成'), ('目', '菌目构成'),
+                              ('科', '菌科构成'), ('属', '菌属构成'),
+                              ('种', '菌种构成')]:
+        marker = f'\n{level}\n名称\n丰度%'
+        marker2 = f'{level} 名称 丰度%'
+        li = section.find(marker)
+        level_start = None
+        if li == -1:
+            li2 = section.find(marker2)
+            if li2 != -1:
+                li = li2
+                level_start = li2 + len(marker2)
+            else:
+                compressed_marker = f'{level}名称丰度%人群水平%%人检出'
+                cli = section.find(compressed_marker)
+                if cli == -1:
+                    continue
+                # 压缩格式解析:用正则提取数据
+                level_start = cli + len(compressed_marker)
+                level_end = len(section)
+                for next_level in ['目', '科', '属', '种']:
+                    if next_level == level:
+                        continue
+                    ni = section.find(f'{next_level}名称丰度%人群水平%%人检出', level_start)
+                    if ni != -1 and ni < level_end:
+                        level_end = ni
+                        break
+                level_region = section[level_start:level_end]
+                compressed_pattern = re.compile(
+                    r'([\u4e00-\u9fff·]+(?:\s[\u4e00-\u9fff·]+)?\s+)?'  # 可选中文名
+                    r'([A-Za-z][A-Za-z\s.\-]*?)'  # 拉丁名(可能含空格)
+                    r'(\d+\.?\d*%)(\d+\.?\d*%)(\d+\.?\d*%)'  # 三连百分比
+                )
+                rows = []
+                for m in compressed_pattern.finditer(level_region):
+                    cn_name = (m.group(1) or '').strip()
+                    latin_name = m.group(2).strip()
+                    pct = m.group(3)
+                    pop_level = m.group(4)
+                    detection = m.group(5)
+                    name = cn_name if cn_name else latin_name
+                    entry = {'名称': name, '丰度%': pct, '人群水平%': pop_level, '检出率%': detection}
+                    rows.append(entry)
+                if rows:
+                    results[level_name] = rows
+                continue
+        else:
+            level_start = li + len(marker)
+
+        level_end = len(section)
+        for next_level in ['纲', '目', '科', '属', '种']:
+            if next_level == level:
+                continue
+            ni = section.find(f'\n{next_level}\n名称', level_start)
+            if ni != -1 and ni < level_end:
+                level_end = ni
+                break
+
+        level_region = section[level_start:level_end]
+        lines = [l.strip() for l in level_region.split('\n') if l.strip()]
+        rows = []
+        i = 0
+        while i < len(lines):
+            if lines[i] in ['名称', '丰度%', '人群水平%', '%人检出']:
+                i += 1
+                continue
+            name = lines[i]
+            if i + 2 < len(lines) and re.match(r'^[\d]+\.?[\d]*%?$', lines[i + 1]):
+                pct = lines[i + 1]
+                pop_level = lines[i + 2] if i + 2 < len(lines) else ''
+                detection = lines[i + 3] if i + 3 < len(lines) and re.match(r'^[\d.]+%$', lines[i + 3]) else ''
+                entry = {'名称': name, '丰度%': pct, '人群水平%': pop_level}
+                if detection:
+                    entry['检出率%'] = detection
+                rows.append(entry)
+                i += 4 if detection else 3
+            else:
+                i += 1
+
+        if rows:
+            results[level_name] = rows
+
+    return results
+
+
+def _extract_bacteria_tables(pdf_path):
+    """提取菌群检出详细列表,返回 {中文分组名: [行]}(移植自 extract_full_report_v5.py)
+
+    与原始脚本保持一致的文本构造与格式检测:full_text 使用
+    '\\n'.join(norm(p.extract_text()) for p in reader.pages)(不 strip、不过滤空行),
+    fmt 使用原脚本 L672-683 的独立检测逻辑。
+    """
+    reader = PdfReader(pdf_path)
+    full_text = '\n'.join(norm(p.extract_text()) for p in reader.pages)
+
+    # 原脚本 extract_bacteria_tables 的格式检测(L674-683)
+    fmt = 'triplet' if '指标范围' in full_text and '疾病风险评估' in full_text else 'inline'
+    for pt in [p.extract_text() for p in reader.pages]:
+        t = norm(pt)
+        if '疾病风险评估' in t and '指标范围' in t:
+            for line in t.split('\n'):
+                if re.search(r'[\u4e00-\u9fff]+\d+\.?\d*[\u4e00-\u9fff]+', line.strip()):
+                    fmt = 'inline'
+                    break
+            break
+
+    all_tables = {}
+
+    # 核心菌属构成表1-3
+    core_genus = []
+    for i in range(1, 4):
+        title = f'核心菌属构成表{i}'
+        rows = _parse_bacteria_table(reader, None, full_text, title, fmt)
+        core_genus.extend(rows)
+    all_tables['核心菌属'] = core_genus
+
+    # 益生菌(使用更精确的表头定位,跳过前面的说明文字)
+    prob_marker = '仅列出丰度前22的益生菌种'
+    prob_sidx = full_text.find(prob_marker)
+    if prob_sidx != -1:
+        prob_rows = _parse_bacteria_table(reader, None, full_text, prob_marker, fmt, skip_header=True)
+    else:
+        prob_rows = _parse_bacteria_table(reader, None, full_text, '益生菌', fmt, skip_header=True)
+    all_tables['益生菌'] = [r for r in prob_rows if r.get('名称') and r['名称'] not in
+                            ['我的益生菌都为ND', '仅列出丰度前22的益生菌种。']]
+
+    # 有害菌属构成表1-2
+    harmful = []
+    for i in range(1, 3):
+        title = f'有害菌属构成表{i}'
+        rows = _parse_bacteria_table(reader, None, full_text, title, fmt)
+        harmful.extend(rows)
+    all_tables['有害菌属'] = harmful
+
+    # 其它重要菌属
+    other_rows = _parse_bacteria_table(reader, None, full_text, '其它重要菌属构成表', fmt)
+    all_tables['其它重要菌属'] = other_rows
+
+    # 病原菌属构成表
+    patho_genus = _parse_bacteria_table(reader, None, full_text, '病原菌属构成表', fmt)
+    all_tables['病原菌属'] = patho_genus
+
+    # 病原菌(检出列表)
+    patho_rows = _parse_bacteria_table(reader, None, full_text, '病原菌', fmt, skip_header=True)
+    all_tables['病原菌检出'] = [r for r in patho_rows if r.get('名称') and len(r['名称']) >= 2
+                               and '仅列出' not in r['名称'] and '说明' not in r['名称']]
+
+    # 菌门构成表(phylum level)
+    phylum_rows = _parse_phylum_tables(reader, full_text, fmt)
+    if phylum_rows:
+        all_tables['菌门构成'] = phylum_rows
+
+    # 菌纲构成表
+    class_rows = _parse_phylum_tables(reader, full_text, fmt, CLASS_TABLE_TITLES)
+    if class_rows:
+        all_tables['菌纲构成'] = class_rows
+
+    # 菌目构成表
+    order_rows = _parse_phylum_tables(reader, full_text, fmt, ORDER_TABLE_TITLES)
+    if order_rows:
+        all_tables['菌目构成'] = order_rows
+
+    # 菌科构成表
+    family_rows = _parse_phylum_tables(reader, full_text, fmt, FAMILY_TABLE_TITLES)
+    if family_rows:
+        all_tables['菌科构成'] = family_rows
+
+    # 菌属构成表
+    genus_rows = _parse_phylum_tables(reader, full_text, fmt, GENUS_TABLE_TITLES)
+    if genus_rows:
+        all_tables['菌属构成'] = genus_rows
+
+    # 菌种构成表
+    species_rows = _parse_phylum_tables(reader, full_text, fmt, SPECIES_TABLE_TITLES)
+    if species_rows:
+        all_tables['菌种构成'] = species_rows
+
+    # 菌群层级(纲目科属种)- 从"菌群检出详细列表"统一入口提取
+    taxonomy_rows = _parse_taxonomy_levels(reader, full_text, fmt)
+    for key, rows in taxonomy_rows.items():
+        if rows:
+            all_tables[key] = rows
+
+    return all_tables
+
+
+def split_7_fields(s):
+    """将压缩数字串切分为 7 个字段(移植自 extract_full_report_v5.py)"""
+    results = []
+    ranges = [(2, 4), (1, 2), (1, 2), (1, 2), (1, 2), (1, 2), (1, 4)]
+
+    def backtrack(pos, idx, nums):
+        if idx == 7:
+            if pos == len(s):
+                results.append(list(nums))
+            return
+        if pos >= len(s):
+            return
+        lo, hi = ranges[idx]
+        for w in range(lo, min(hi + 1, len(s) - pos + 1)):
+            chunk = s[pos:pos + w]
+            if chunk.isdigit():
+                backtrack(pos + w, idx + 1, nums + [int(chunk)])
+
+    backtrack(0, 0, [])
+    return results
+
+
+def decode_compressed(name, num_str, ref_vals=None):
+    """解码压缩食物数字串(移植自 extract_full_report_v5.py)"""
+    raw = num_str.lstrip('-')
+    has_neg = num_str.startswith('-')
+    candidates = []
+    for rec_len in range(1, 3):
+        if rec_len > len(raw):
+            continue
+        rec = ('-' if has_neg else '') + raw[:rec_len]
+        try:
+            rec_val = int(rec)
+            if not (-100 <= rec_val <= 100):
+                continue
+        except Exception:
+            continue
+        remain = raw[rec_len:]
+        for nums in split_7_fields(remain):
+            if ref_vals:
+                matches = sum(1 for i in range(7) if ref_vals[i] == nums[i])
+                if matches >= 6:
+                    candidates.append([rec_val] + nums)
+            else:
+                candidates.append([rec_val] + nums)
+    if not candidates:
+        return None
+    if ref_vals:
+        candidates.sort(key=lambda r: (sum(1 for i in range(7) if ref_vals[i] == r[1:][i]),
+                                       -len(str(abs(r[0])))), reverse=True)
+        if sum(1 for i in range(7) if ref_vals[i] == candidates[0][1:][i]) < 6:
+            return None
+    else:
+        candidates.sort(key=lambda r: (len(str(abs(r[0]))),
+                                       -sum(1 for i in range(7) if r[1:][i] == 0)))
+    return candidates[0]
+
+
+def extract_food_table(pdf_path, ref_lookup=None):
+    """提取个体化食物推荐表(移植自 extract_full_report_v5.py)"""
+    reader = PdfReader(pdf_path)
+    food_start = None
+    for i, page in enumerate(reader.pages):
+        if '个体化食物推荐表' in page.extract_text():
+            food_start = i
+            break
+    if food_start is None:
+        return [], 'not_found'
+
+    first_text = norm(reader.pages[food_start + 1].extract_text())
+    lines = [l.strip() for l in first_text.split('\n')
+             if l.strip() and not re.match(r'\d+/\d+', l)]
+    # 判断压缩格式:任何一行超过100字符(单行密集格式),或前10行中超过3行长行
+    is_compressed = (len(lines) >= 1 and any(len(l) > 100 for l in lines[:10])) or \
+                    sum(1 for l in lines[:10] if len(l) > 100) >= 2
+
+    rows = []
+    if is_compressed:
+        fmt = 'compressed'
+        for i in range(food_start + 1, len(reader.pages)):
+            text = norm(reader.pages[i].extract_text())
+            text = re.sub(r'\d+/\d+', '', text)
+            header = '名称分类推荐指数能量KJ蛋白g脂肪g碳水化合物g淀粉g总膳食纤维g胆固醇mg'
+            text = text.replace(header, '')
+            for kw in FOOD_SKIP_TEXTS:
+                text = text.replace(kw, '')
+            while text:
+                best_cat, best_idx = None, len(text)
+                for cat in KNOWN_CATS:
+                    idx = text.find(cat)
+                    if idx != -1 and idx < best_idx:
+                        best_idx, best_cat = idx, cat
+                if best_cat is None:
+                    break
+                name = text[:best_idx]
+                text = text[best_idx + len(best_cat):]
+                num_str = ''
+                while text and (text[0].isdigit() or text[0] in '-\u2212\u2014'):
+                    c = '-' if text[0] in '\u2212\u2014' else text[0]
+                    num_str += c
+                    text = text[1:]
+                if not name or not num_str:
+                    continue
+                ref_vals = ref_lookup.get(name) if ref_lookup else None
+                decoded = decode_compressed(name, num_str, ref_vals)
+                if decoded:
+                    rows.append(dict(zip(COLUMNS_FOOD, [name, best_cat] + [str(v) for v in decoded])))
+    else:
+        fmt = 'vertical'
+        all_lines = []
+        for i in range(food_start + 1, len(reader.pages)):
+            for line in norm(reader.pages[i].extract_text()).split('\n'):
+                lt = line.strip()
+                if not lt or re.match(r'\d+/\d+', lt) or lt in COLUMNS_FOOD:
+                    continue
+                if len(lt) > 60 and any(k in lt for k in FOOD_SKIP_TEXTS):
+                    continue
+                all_lines.append(lt)
+        i = 0
+        while i + 9 < len(all_lines):
+            name = all_lines[i].strip()
+            cat = all_lines[i + 1].strip()
+            if cat not in KNOWN_CATS:
+                i += 1
+                continue
+            nums = []
+            ok = True
+            for j in range(2, 10):
+                v = all_lines[i + j].replace('\u2212', '-').replace('\u2014', '-').strip()
+                try:
+                    int(v)
+                    nums.append(v)
+                except Exception:
+                    ok = False
+                    break
+            if ok and len(nums) == 8:
+                rows.append(dict(zip(COLUMNS_FOOD, [name, cat] + nums)))
+            i += 1
+    return rows, fmt
+
+
+def _extract_food_rows(pdf_path):
+    """食物推荐表解析:优先复用同目录其他报告的参考营养表做压缩格式解码"""
+    ref_nutrition = {}
+    base_dir = os.path.dirname(pdf_path) or '.'
+    for fname in sorted(os.listdir(base_dir)):
+        if fname.lower().endswith('.pdf') and fname != os.path.basename(pdf_path):
+            try:
+                tr, _ = extract_food_table(os.path.join(base_dir, fname))
+                if len(tr) > 100:
+                    for r in tr:
+                        ref_nutrition[r['名称']] = [int(r[k]) for k in
+                            ['能量KJ', '蛋白g', '脂肪g', '碳水化合物g', '淀粉g',
+                             '总膳食纤维g', '胆固醇mg']]
+                    break
+            except Exception:
+                continue
+    food_rows, food_fmt = extract_food_table(pdf_path, ref_nutrition or None)
+    return food_rows, food_fmt