Explorar o código

docs: 采集指标补齐 实施计划(方案A,P1+P2)

Sisyphus hai 3 días
pai
achega
2352e11666

+ 1056 - 0
docs/superpowers/plans/2026-10-09-report-type-indicator-driven-parsing.md

@@ -0,0 +1,1056 @@
+# 报告类型「采集指标」补齐 实施计划(方案A)
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 在既有 `report_type_registry.expected_indicators` + `health_report_drafts.collect_validation` 之上,补齐三个缺口:管理端可编辑指标、报告管理页展示采集完整度、非 DAN 解析由清单驱动 LangGraph LLM;并修正种子覆盖行为。
+
+**Architecture:** 沿用既有 JSON 字段,不新建表。后端只做「种子仅空写入」「`/types/save` 入参校验」「报告管理列表批量左连接草稿取 `collect_validation`」「非 DAN 分支下传清单」;前端补编辑对话框与完整度列/明细;LangGraph 侧在 LLM 提示词追加采集指标约束。向后兼容,缺省行为不变。
+
+**Tech Stack:** Spring Boot 2.7.18 / MyBatis-Plus / MySQL 8.0.34(后端);Vue 2.6 + Element UI 2.15(cfc-web);FastAPI + LangGraph + Pydantic(cfc-langgraph);JUnit 5 + Mockito(后端测试);pytest(LangGraph 测试)。
+
+**Spec:** `docs/superpowers/specs/2026-10-09-report-type-indicator-driven-parsing-design.md`
+
+## Global Constraints
+
+- 远程开发目录 `/app/cfc`(git 仓库,分支 `cfclub`);本仓库根即 monorepo,所有路径相对仓库根。
+- **禁止未确认改动生产目录 `/app/pro/cfc`**;部署类操作需用户 go/no-go(AGENTS.md)。
+- 后端构建:`mvn clean package -Dmaven.test.skip=true`;跑单测:`mvn -q -Dtest=<Class> test`。
+- 前端构建:`npm run build`(会触发 `prebuild` bump 版本号 + 生成 changelog,属预期;提交时如冲突以远程版本号为准)。
+- 中文注释/日志风格(既有代码约定)。
+- MySQL 8.0.34:本期**不新增列/索引**,仅改数据写入策略,无函数化索引风险。
+- 向后兼容:新增方法保留旧签名重载;LangGraph `indicators` 为可选字段,缺省行为与现状一致。
+- 不删除既有种子数据;不改 DAN Java 解析器;不改既有匹配算法(复用 `ReportCollectService` 归一化 + CJK 二元组匹配)。
+- `expected_indicators` 存 `JSON.stringify([...])`(字符串数组);空/未定义 = 不校验。
+
+---
+
+## File Structure
+
+**cfc-backend**
+- Modify `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` — `seedExpectedIndicator` 改「仅空时写入」。
+- Modify `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParserAdminController.java` — `saveType` 增 `expectedIndicators` JSON 数组校验。
+- Modify `cfc-backend/src/main/java/com/etotem/cfc/dto/ReportSummaryVO.java` — 增 `collectValidation` 字段。
+- Modify `cfc-backend/src/main/java/com/etotem/cfc/service/ReportSummaryService.java` — `adminPageList`/`getDetail` 批量回填 `collectValidation`。
+- Modify `cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java` — 非 DAN 分支解析前加载并下传清单。
+- Modify `cfc-backend/src/main/java/com/etotem/cfc/service/LangGraphReportParseService.java` — `postParse` body 增 `indicators`,新增带清单的重载。
+- Test `cfc-backend/src/test/java/com/etotem/cfc/config/DatabaseInitializerSeedTest.java`(新建)
+- Test `cfc-backend/src/test/java/com/etotem/cfc/controller/admin/ReportParserAdminControllerTest.java`(新建)
+- Test `cfc-backend/src/test/java/com/etotem/cfc/service/ReportSummaryServiceCompletenessTest.java`(新建)
+
+**cfc-web**
+- Modify `cfc-web/src/views/admin/ReportTypeManagement.vue` — 采集指标 Tag 编辑(对话框)+ 指标数量列。
+- Modify `cfc-web/src/views/admin/ReportManagement.vue` — 采集完整度列 + 详情补充完整度明细。
+
+**cfc-langgraph**
+- Modify `cfc-langgraph/app/api/report_parse.py` — `ParseRequest`/`TypedParseRequest` 增 `indicators`。
+- Modify `cfc-langgraph/app/agents/report_parse_agent.py` — `parse`/`parse_generic`/`parse_by_type` 增 `indicators`,提示词追加约束。
+- Test `cfc-langgraph/tests/test_report_parse.py` — 增 indicators 用例。
+
+---
+
+### Task 1: 后端 — 种子「仅空时写入」
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(方法 `seedExpectedIndicator`)
+- Test: `cfc-backend/src/test/java/com/etotem/cfc/config/DatabaseInitializerSeedTest.java`(新建)
+
+**Interfaces:**
+- Produces: `void seedExpectedIndicator(String typeId, String expectedJson)`(包级可见,供测试调用)——仅当目标行 `expected_indicators IS NULL OR = ''` 时写入;否则跳过。其余调用点/签名不变。
+
+- [ ] **Step 1: 写失败测试**
+
+新建 `cfc-backend/src/test/java/com/etotem/cfc/config/DatabaseInitializerSeedTest.java`:
+
+```java
+package com.etotem.cfc.config;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class DatabaseInitializerSeedTest {
+
+    @Mock
+    private JdbcTemplate jdbcTemplate;
+
+    @InjectMocks
+    private DatabaseInitializer initializer;
+
+    @Test
+    void seedExpectedIndicator_onlyWritesWhenAbsent() {
+        when(jdbcTemplate.update(anyString(), any(), any())).thenReturn(1);
+
+        initializer.seedExpectedIndicator("dan_a1", "[\"总分\"]");
+
+        ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
+        verify(jdbcTemplate).update(sql.capture(), eq("[\"总分\"]"), eq("dan_a1"));
+        assertTrue(sql.getValue().contains("(expected_indicators IS NULL OR expected_indicators = '')"),
+                "SQL 必须仅在列为空时写入,避免覆盖管理员编辑");
+    }
+}
+```
+
+- [ ] **Step 2: 运行测试确认失败**
+
+Run: `cd cfc-backend && mvn -q -Dtest=DatabaseInitializerSeedTest test`
+Expected: 编译失败(`seedExpectedIndicator` 为 `private`,测试无法访问)或断言失败。
+
+- [ ] **Step 3: 改最小实现**
+
+在 `DatabaseInitializer.java` 中把方法签名改为包级可见,并给 SQL 加空值守卫(其余不变):
+
+```java
+    /** 写入类型「应采集指标项」定义(仅当为空时写入,保留管理员编辑) */
+    void seedExpectedIndicator(String typeId, String expectedJson) {
+        try {
+            int updated = jdbcTemplate.update(
+                    "UPDATE report_type_registry SET expected_indicators = ?, updated_at = NOW() " +
+                    "WHERE type_id = ? AND (expected_indicators IS NULL OR expected_indicators = '')",
+                    expectedJson, typeId);
+            if (updated == 0) {
+                log.info("迁移354: 类型 {} 已有指标项定义,跳过种子", typeId);
+            } else {
+                log.info("迁移354: 已为类型 {} 写入指标项定义", typeId);
+            }
+        } catch (Exception e) {
+            log.warn("迁移354: 为类型 {} 写入指标项定义失败: {}", typeId, e.getMessage());
+        }
+    }
+```
+
+- [ ] **Step 4: 运行测试确认通过**
+
+Run: `cd cfc-backend && mvn -q -Dtest=DatabaseInitializerSeedTest test`
+Expected: PASS。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java \
+        cfc-backend/src/test/java/com/etotem/cfc/config/DatabaseInitializerSeedTest.java
+git commit -m "fix(report): 采集指标种子改为仅空时写入,保留管理员编辑"
+```
+
+---
+
+### Task 2: 后端 — `/types/save` expectedIndicators JSON 数组校验
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParserAdminController.java`(方法 `saveType` + 新增私有 helper)
+- Test: `cfc-backend/src/test/java/com/etotem/cfc/controller/admin/ReportParserAdminControllerTest.java`(新建)
+
+**Interfaces:**
+- Consumes: `ReportTypeRegistry.getExpectedIndicators()`(String)
+- Produces: `saveType` 在 `expectedIndicators` 非空且非合法 JSON 字符串数组时返回 `Result.error("expectedIndicators 必须为 JSON 字符串数组")`,不落库;合法/为空时行为不变。
+
+- [ ] **Step 1: 写失败测试**
+
+新建 `cfc-backend/src/test/java/com/etotem/cfc/controller/admin/ReportParserAdminControllerTest.java`:
+
+```java
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ReportTypeRegistry;
+import com.etotem.cfc.mapper.ReportTypeRegistryMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+@ExtendWith(MockitoExtension.class)
+class ReportParserAdminControllerTest {
+
+    @Mock
+    private ReportTypeRegistryMapper typeRegistryMapper;
+
+    @InjectMocks
+    private ReportParserAdminController controller;
+
+    private ReportTypeRegistry baseType() {
+        ReportTypeRegistry t = new ReportTypeRegistry();
+        t.setId(1L);
+        t.setTypeId("dan_a1");
+        t.setDisplayName("素质测评A1");
+        return t;
+    }
+
+    @Test
+    void saveType_rejectsInvalidJsonArray() {
+        ReportTypeRegistry t = baseType();
+        t.setExpectedIndicators("not-json");
+
+        Result<Void> r = controller.saveType(t);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("expectedIndicators"));
+        verifyNoInteractions(typeRegistryMapper);
+    }
+
+    @Test
+    void saveType_rejectsJsonArrayWithNonStringElement() {
+        ReportTypeRegistry t = baseType();
+        t.setExpectedIndicators("[1,2,3]");
+
+        Result<Void> r = controller.saveType(t);
+
+        assertEquals(500, r.getCode());
+        verify(typeRegistryMapper, never()).updateById(t);
+    }
+
+    @Test
+    void saveType_acceptsValidJsonArray() {
+        ReportTypeRegistry t = baseType();
+        t.setExpectedIndicators("[\"总分\",\"百分位\"]");
+
+        Result<Void> r = controller.saveType(t);
+
+        assertEquals(200, r.getCode());
+        verify(typeRegistryMapper).updateById(t);
+    }
+}
+```
+
+- [ ] **Step 2: 运行测试确认失败**
+
+Run: `cd cfc-backend && mvn -q -Dtest=ReportParserAdminControllerTest test`
+Expected: FAIL(非法 JSON 当前会被接受,`assertEquals(500, ...)` 失败)。
+
+- [ ] **Step 3: 改最小实现**
+
+在 `ReportParserAdminController.java` 的 `saveType` 顶部(`displayName` 校验之后、写库之前)插入校验,并新增 helper(`objectMapper` 已是类字段):
+
+```java
+        if (type.getExpectedIndicators() != null && !type.getExpectedIndicators().trim().isEmpty()
+                && !isValidJsonStringArray(type.getExpectedIndicators())) {
+            return Result.error("expectedIndicators 必须为 JSON 字符串数组");
+        }
+```
+
+```java
+    /** 校验字符串是否为 JSON 字符串数组,如 ["总分","百分位"] */
+    private boolean isValidJsonStringArray(String json) {
+        try {
+            com.fasterxml.jackson.databind.JsonNode node = objectMapper.readTree(json);
+            if (node == null || !node.isArray()) {
+                return false;
+            }
+            for (com.fasterxml.jackson.databind.JsonNode el : node) {
+                if (!el.isTextual()) {
+                    return false;
+                }
+            }
+            return true;
+        } catch (Exception e) {
+            return false;
+        }
+    }
+```
+
+- [ ] **Step 4: 运行测试确认通过**
+
+Run: `cd cfc-backend && mvn -q -Dtest=ReportParserAdminControllerTest test`
+Expected: PASS(3 tests)。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParserAdminController.java \
+        cfc-backend/src/test/java/com/etotem/cfc/controller/admin/ReportParserAdminControllerTest.java
+git commit -m "feat(report): /types/save 校验 expectedIndicators 为 JSON 字符串数组"
+```
+
+---
+
+### Task 3: 后端 — `/report-summary/list` 与 `/detail` 暴露 `collectValidation`
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/dto/ReportSummaryVO.java`(增字段)
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ReportSummaryService.java`(注入 draft mapper + `adminPageList`/`getDetail` 批量回填)
+- Test: `cfc-backend/src/test/java/com/etotem/cfc/service/ReportSummaryServiceCompletenessTest.java`(新建)
+
+**Interfaces:**
+- Consumes: `HealthReportDraftMapper`(`BaseMapper<HealthReportDraft>`)、`HealthReportDraft.getConfirmedReportId()/getCollectValidation()/getReportType()`、`ReportSummary.getSourceTable()/getSourceId()`。
+- Produces: `ReportSummaryVO.getCollectValidation()`(String,可为 null)。关联键:`health_report_drafts.confirmed_report_id = report_summary.source_id`;DAN 行 `source_table='dan_report_uploads'`(草稿 `report_type` 以 `dan` 开头),其余 `source_table='health_reports'`(草稿 `report_type` 非 `dan*`)。关联不到则 null(前端显示 `-`)。
+
+- [ ] **Step 1: 写失败测试**
+
+新建 `cfc-backend/src/test/java/com/etotem/cfc/service/ReportSummaryServiceCompletenessTest.java`:
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.dto.ReportListQueryDTO;
+import com.etotem.cfc.dto.ReportSummaryVO;
+import com.etotem.cfc.entity.HealthReportDraft;
+import com.etotem.cfc.entity.ReportSummary;
+import com.etotem.cfc.mapper.HealthReportDraftMapper;
+import com.etotem.cfc.mapper.ReportSummaryMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class ReportSummaryServiceCompletenessTest {
+
+    @Mock
+    private ReportSummaryMapper reportSummaryMapper;
+
+    @Mock
+    private HealthReportDraftMapper healthReportDraftMapper;
+
+    @InjectMocks
+    private ReportSummaryService service;
+
+    @Test
+    void adminPageList_populatesCollectValidationForDanRow() {
+        ReportSummary rs = new ReportSummary();
+        rs.setId(1L);
+        rs.setReportType("dan");
+        rs.setSourceTable("dan_report_uploads");
+        rs.setSourceId(53L);
+
+        doAnswer(inv -> {
+            Page<ReportSummary> p = inv.getArgument(0);
+            p.setRecords(Collections.singletonList(rs));
+            p.setTotal(1);
+            return p;
+        }).when(reportSummaryMapper).selectPage(any(), any());
+
+        HealthReportDraft draft = new HealthReportDraft();
+        draft.setConfirmedReportId(53L);
+        draft.setReportType("dan_a2");
+        draft.setCollectValidation("{\"typeId\":\"dan_a2\",\"expected\":31,\"matched\":30,\"pass\":false,\"missing\":[\"百分位\"]}");
+        when(healthReportDraftMapper.selectList(any())).thenReturn(Collections.singletonList(draft));
+
+        ReportListQueryDTO query = new ReportListQueryDTO();
+        query.setPage(1);
+        query.setSize(20);
+
+        Page<ReportSummaryVO> page = service.adminPageList(query);
+
+        List<ReportSummaryVO> records = page.getRecords();
+        assertEquals(1, records.size());
+        assertEquals(draft.getCollectValidation(), records.get(0).getCollectValidation());
+    }
+}
+```
+
+- [ ] **Step 2: 运行测试确认失败**
+
+Run: `cd cfc-backend && mvn -q -Dtest=ReportSummaryServiceCompletenessTest test`
+Expected: 编译失败(`ReportSummaryVO.getCollectValidation()` 不存在)。
+
+- [ ] **Step 3a: 增 VO 字段**
+
+在 `ReportSummaryVO.java` 的 `private Date createdAt;` 之后加:
+
+```java
+  private String collectValidation;
+```
+
+- [ ] **Step 3b: ReportSummaryService 注入 mapper + 回填**
+
+在 `ReportSummaryService.java` 字段区加:
+
+```java
+    @Resource
+    private com.etotem.cfc.mapper.HealthReportDraftMapper healthReportDraftMapper;
+```
+
+在 import 区加:
+
+```java
+import com.etotem.cfc.entity.HealthReportDraft;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+```
+
+在 `adminPageList` 生成 `voList` 之前加载两张映射,并在循环里回填:
+
+```java
+        Map<Long, String> danValidation = loadCollectValidationBySource(page.getRecords(), "dan_report_uploads");
+        Map<Long, String> healthValidation = loadCollectValidationBySource(page.getRecords(), "health_reports");
+
+        Page<ReportSummaryVO> voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
+        List<ReportSummaryVO> voList = new ArrayList<>();
+        for (ReportSummary rs : page.getRecords()) {
+            ReportSummaryVO vo = new ReportSummaryVO();
+            vo.setId(rs.getId());
+            vo.setReportType(rs.getReportType());
+            vo.setReportTypeName(ReportSummaryVO.getReportTypeName(rs.getReportType()));
+            vo.setTitle(rs.getTitle());
+            vo.setSubjectName(rs.getSubjectName());
+            vo.setSummary(rs.getSummary());
+            vo.setHasFile(rs.getHasFile() != null && rs.getHasFile() == 1);
+            vo.setOverallScore(rs.getOverallScore());
+            vo.setReportDate(rs.getReportDate() != null ? rs.getReportDate().toString() : "");
+            vo.setStatus(rs.getStatus());
+            vo.setCreatedAt(rs.getCreatedAt());
+            if ("dan_report_uploads".equals(rs.getSourceTable())) {
+                vo.setCollectValidation(danValidation.get(rs.getSourceId()));
+            } else if ("health_reports".equals(rs.getSourceTable())) {
+                vo.setCollectValidation(healthValidation.get(rs.getSourceId()));
+            }
+            voList.add(vo);
+        }
+```
+
+新增 helper(放在 `getDetail` 附近):
+
+```java
+    /** 批量读取草稿的采集校验结果,键 = confirmed_report_id(= report_summary.source_id) */
+    private Map<Long, String> loadCollectValidationBySource(List<ReportSummary> rows, String sourceTable) {
+        Map<Long, String> map = new HashMap<>();
+        if (rows == null || rows.isEmpty()) {
+            return map;
+        }
+        Set<Long> ids = rows.stream()
+                .filter(r -> sourceTable.equals(r.getSourceTable()))
+                .map(ReportSummary::getSourceId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (ids.isEmpty()) {
+            return map;
+        }
+        LambdaQueryWrapper<HealthReportDraft> w = new LambdaQueryWrapper<>();
+        w.in(HealthReportDraft::getConfirmedReportId, ids)
+                .isNotNull(HealthReportDraft::getCollectValidation);
+        if ("dan_report_uploads".equals(sourceTable)) {
+            w.likeRight(HealthReportDraft::getReportType, "dan");
+        } else {
+            w.and(x -> x.isNull(HealthReportDraft::getReportType)
+                    .or().notLikeRight(HealthReportDraft::getReportType, "dan"));
+        }
+        for (HealthReportDraft d : healthReportDraftMapper.selectList(w)) {
+            if (d.getConfirmedReportId() != null && d.getCollectValidation() != null) {
+                map.put(d.getConfirmedReportId(), d.getCollectValidation());
+            }
+        }
+        return map;
+    }
+```
+
+在 `getDetail(Long summaryId)` 的 `vo.setCreatedAt(...)` 之后加单行回填:
+
+```java
+        vo.setCollectValidation(loadCollectValidationBySource(
+                Collections.singletonList(s), s.getSourceTable()).get(s.getSourceId()));
+```
+
+在 import 区加:
+
+```java
+import java.util.Collections;
+```
+
+- [ ] **Step 4: 运行测试确认通过**
+
+Run: `cd cfc-backend && mvn -q -Dtest=ReportSummaryServiceCompletenessTest test`
+Expected: PASS。若因 `SortUtil.applySort(wrapper)` 依赖线程上下文而报错,在测试方法首行加 `com.etotem.cfc.util.SortContext.clear();`(或等价清理方法);测试线程无排序上下文时应为 no-op。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/ReportSummaryVO.java \
+        cfc-backend/src/main/java/com/etotem/cfc/service/ReportSummaryService.java \
+        cfc-backend/src/test/java/com/etotem/cfc/service/ReportSummaryServiceCompletenessTest.java
+git commit -m "feat(report): 报告管理列表/详情回填采集完整度 collect_validation"
+```
+
+---
+
+### Task 4: 前端 — ReportTypeManagement.vue 采集指标编辑
+
+**Files:**
+- Modify: `cfc-web/src/views/admin/ReportTypeManagement.vue`
+
+**Interfaces:**
+- Consumes: `/types/list` 行 `expectedIndicators`(JSON 字符串)、`saveReportType`(`@/api/reportParser`)。
+- Produces: 编辑对话框可增删指标 Tag;保存时以 `expectedIndicators: JSON.stringify(array)` 提交;列表新增「采集指标」数量列。
+
+- [ ] **Step 1: 加列表「采集指标」列**
+
+在「识别阈值」/「状态」列附近(`minConfidence` 列之后)插入:
+
+```html
+        <el-table-column label="采集指标" width="90">
+          <template slot-scope="{ row }">
+            <el-tag size="mini" type="info">{{ indicatorCount(row.expectedIndicators) }} 项</el-tag>
+          </template>
+        </el-table-column>
+```
+
+- [ ] **Step 2: 对话框加指标编辑区**
+
+在编辑对话框「识别阈值」`el-form-item` 之后、`</el-form>` 之前插入:
+
+```html
+        <el-form-item label="采集指标">
+          <el-tag
+            v-for="(tag, idx) in indicators"
+            :key="idx"
+            closable
+            size="small"
+            style="margin: 0 6px 6px 0;"
+            @close="removeIndicator(idx)"
+          >{{ tag }}</el-tag>
+          <el-input
+            v-if="indicatorInputVisible"
+            ref="indicatorInput"
+            v-model="indicatorInput"
+            size="mini"
+            style="width: 160px;"
+            @keyup.enter.native="confirmIndicator"
+            @blur="confirmIndicator"
+          />
+          <el-button v-else size="mini" @click="showIndicatorInput">+ 添加指标</el-button>
+          <div class="el-form-item-msg">该类型报告应采集的指标项名称;留空表示不校验</div>
+        </el-form-item>
+```
+
+- [ ] **Step 3: data / methods 改动**
+
+在 `data()` 的 `form` 之后增:
+
+```js
+      indicators: [], indicatorInput: '', indicatorInputVisible: false,
+```
+
+`handleCreate` 中重置:
+
+```js
+      this.indicators = []
+```
+
+`handleEdit` 中回填(替换原 `this.form = { ...row }`):
+
+```js
+      this.form = { ...row }
+      this.indicators = this.safeParseIndicators(row.expectedIndicators)
+```
+
+`handleSubmit` 中提交 payload(替换原 `await saveReportType(this.form)` 一行):
+
+```js
+        const payload = { ...this.form, expectedIndicators: JSON.stringify(this.indicators) }
+        await saveReportType(payload)
+```
+
+新增 methods(放在 `fetchTypes` 之后):
+
+```js
+    safeParseIndicators(raw) {
+      if (!raw) return []
+      try {
+        const arr = JSON.parse(raw)
+        return Array.isArray(arr) ? arr.filter(x => typeof x === 'string') : []
+      } catch (e) { return [] }
+    },
+    indicatorCount(raw) { return this.safeParseIndicators(raw).length },
+    showIndicatorInput() {
+      this.indicatorInputVisible = true
+      this.$nextTick(() => this.$refs.indicatorInput && this.$refs.indicatorInput.focus())
+    },
+    confirmIndicator() {
+      const v = (this.indicatorInput || '').trim()
+      if (v && !this.indicators.includes(v)) this.indicators.push(v)
+      this.indicatorInput = ''
+      this.indicatorInputVisible = false
+    },
+    removeIndicator(idx) { this.indicators.splice(idx, 1) },
+```
+
+- [ ] **Step 4: 构建验证**
+
+Run: `cd cfc-web && npx vue-cli-service build`
+Expected: 构建成功(`Build complete`),无 ESLint/编译错误。注意用 `npx` 绕过 `prebuild`(避免本地 bump 版本号);正式部署走 `npm run build`。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add cfc-web/src/views/admin/ReportTypeManagement.vue
+git commit -m "feat(web): 报告类型管理支持编辑采集指标"
+```
+
+---
+
+### Task 5: 前端 — ReportManagement.vue 采集完整度列 + 详情明细
+
+**Files:**
+- Modify: `cfc-web/src/views/admin/ReportManagement.vue`
+
+**Interfaces:**
+- Consumes: 列表行 `collectValidation`(JSON 字符串:`{typeId, expected, matched, pass, missing[]}`)、`/api/report-summary/detail/{id}` 返回的 `collectValidation`。
+- Produces: 列表「采集完整度」列(`已采/应采`,`pass` 绿/红);详情弹窗展示完整度与缺失项。
+
+- [ ] **Step 1: 加完整度列**
+
+在「状态」列之后插入:
+
+```html
+        <el-table-column label="采集完整度" width="120">
+          <template slot-scope="scope">
+            <el-tag
+              v-if="scope.row.collectValidation"
+              size="small"
+              :type="collectPass(scope.row) ? 'success' : 'danger'"
+            >{{ collectText(scope.row) }}</el-tag>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+```
+
+- [ ] **Step 2: 详情弹窗补完整度**
+
+替换 `viewDetail` 中的 `$alert` 内容为:
+
+```js
+          var cv = null
+          try { cv = d.collectValidation ? JSON.parse(d.collectValidation) : null } catch (e) { cv = null }
+          var cvLine = '<p>采集完整度: ' + (cv
+            ? (cv.matched + '/' + cv.expected + (cv.pass ? '(通过)' : '(缺失: ' + ((cv.missing || []).join('、') || '-') + ')'))
+            : '-') + '</p>'
+          self.$alert(
+            '<p>标题: ' + d.title + '</p>' +
+            '<p>类型: ' + d.reportTypeName + '</p>' +
+            '<p>主体: ' + d.subjectName + '</p>' +
+            '<p>日期: ' + d.reportDate + '</p>' +
+            '<p>评分: ' + (d.overallScore || '-') + '</p>' +
+            cvLine +
+            '<p>状态: ' + d.status + '</p>',
+            '报告详情',
+            { dangerouslyUseHTMLString: true }
+          )
+```
+
+- [ ] **Step 3: 加解析方法**
+
+在 `getTypeTag` 之前加:
+
+```js
+    parseCollect(row) {
+      if (!row || !row.collectValidation) return null
+      try { return JSON.parse(row.collectValidation) } catch (e) { return null }
+    },
+    collectPass(row) { const c = this.parseCollect(row); return !!(c && c.pass === true) },
+    collectText(row) {
+      const c = this.parseCollect(row)
+      return c ? (c.matched + '/' + c.expected) : '-'
+    },
+```
+
+- [ ] **Step 4: 构建验证**
+
+Run: `cd cfc-web && npx vue-cli-service build`
+Expected: 构建成功。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add cfc-web/src/views/admin/ReportManagement.vue
+git commit -m "feat(web): 报告管理展示采集完整度列与明细"
+```
+
+---
+
+### Task 6: 后端 — 非 DAN 解析下传清单
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java`(`collect` 非 DAN 分支)
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/LangGraphReportParseService.java`(重载 + body)
+
+**Interfaces:**
+- Consumes: 既有 `ReportCollectService.loadExpectedIndicators(String typeId)`(返回 `List<String>`,已存在)。
+- Produces:
+  - `LangGraphReportParseService.parseByScript(String pdfPath, List<String> indicators)`
+  - `LangGraphReportParseService.parseWithDetect(String pdfPath, List<String> indicators)`
+  - 旧签名 `parseByScript(String)` / `parseWithDetect(String)` 保留(委托传 `null`)。
+  - HTTP body 增可选字段 `indicators`(`/report/parse` 与 `/report/parse-typed`)。
+
+- [ ] **Step 1: LangGraphReportParseService 增重载**
+
+替换 `parseByScript` 与 `parseWithDetect` 为带清单版本(保留旧重载):
+
+```java
+    public ParsedReportPayload.Payload parseByScript(String pdfPath) {
+        return parseByScript(pdfPath, null);
+    }
+
+    public ParsedReportPayload.Payload parseByScript(String pdfPath, List<String> indicators) {
+        Map<String, Object> data = postParse("/api/v1/report/parse", pdfPath, indicators);
+        if (data == null) {
+            return null;
+        }
+        ParsedReportPayload.Payload payload = buildPayloadFromLgData(data);
+        return isValid(payload) ? payload : null;
+    }
+
+    public ParsedReportPayload.Payload parseWithDetect(String pdfPath) {
+        return parseWithDetect(pdfPath, null);
+    }
+
+    public ParsedReportPayload.Payload parseWithDetect(String pdfPath, List<String> indicators) {
+        String detected = detectType(pdfPath);
+        if (detected != null && !DETECTED_GENERIC.equals(detected)) {
+            ParsedReportPayload.Payload typed = parseByType(pdfPath, detected, indicators);
+            if (typed != null) {
+                log.info("LangGraph 识别到类型 {} 并解析成功", detected);
+                return typed;
+            }
+            log.warn("LangGraph 识别类型 {} 但解析结果无效,回退算法脚本", detected);
+        } else {
+            log.info("LangGraph 无法识别报告类型(generic),尝试算法脚本解析");
+        }
+        return parseByScript(pdfPath, indicators);
+    }
+```
+
+`parseByType` 增清单参数并注入 body:
+
+```java
+    private ParsedReportPayload.Payload parseByType(String pdfPath, String reportType, List<String> indicators) {
+        Map<String, Object> body = new HashMap<>();
+        body.put("file_path", pdfPath);
+        body.put("report_type", reportType);
+        if (indicators != null && !indicators.isEmpty()) {
+            body.put("indicators", indicators);
+        }
+        Map<String, Object> data = postParse("/api/v1/report/parse-typed", body);
+        if (data == null) {
+            return null;
+        }
+        ParsedReportPayload.Payload payload = buildPayloadFromTypedData(data);
+        return isValid(payload) ? payload : null;
+    }
+```
+
+`postParse(path, pdfPath)` 增清单参数:
+
+```java
+    private Map<String, Object> postParse(String path, String pdfPath) {
+        return postParse(path, pdfPath, null);
+    }
+
+    private Map<String, Object> postParse(String path, String pdfPath, List<String> indicators) {
+        Map<String, Object> body = new HashMap<>();
+        body.put("file_path", pdfPath);
+        if (indicators != null && !indicators.isEmpty()) {
+            body.put("indicators", indicators);
+        }
+        return postParse(path, body);
+    }
+```
+
+确保已 `import java.util.List;`。
+
+- [ ] **Step 2: ReportCollectService 下传清单**
+
+在 `collect` 中,于「解析分支」之前计算清单(`validationTypeId` 与后续校验复用):
+
+```java
+            // 采集指标清单(用于驱动非 DAN 的 LangGraph LLM 解析)
+            String validationTypeId = (!"unknown".equals(typeId)) ? typeId : draft.getReportType();
+            List<String> expectedIndicators = loadExpectedIndicators(validationTypeId);
+```
+
+把两个调用改为带清单版本:
+
+```java
+                payload = langGraphReportParseService.parseByScript(pdfPath, expectedIndicators);
+```
+
+```java
+                payload = langGraphReportParseService.parseWithDetect(pdfPath, expectedIndicators);
+```
+
+删除下方原第 214 行的重复声明 `String validationTypeId = ...;`(改为使用上面已声明的变量)。
+
+确保 `import java.util.List;` 存在。
+
+- [ ] **Step 3: 编译验证**
+
+Run: `cd cfc-backend && mvn -q -DskipTests compile`
+Expected: BUILD SUCCESS(无编译错误)。
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java \
+        cfc-backend/src/main/java/com/etotem/cfc/service/LangGraphReportParseService.java
+git commit -m "feat(report): 非 DAN 解析下传 expected_indicators 驱动 LangGraph LLM"
+```
+
+---
+
+### Task 7: LangGraph — 注入采集指标约束
+
+**Files:**
+- Modify: `cfc-langgraph/app/api/report_parse.py`(`ParseRequest`、`TypedParseRequest`)
+- Modify: `cfc-langgraph/app/agents/report_parse_agent.py`(`parse`/`_parse_with_llm`/`parse_generic`/`parse_by_type` + 模块级 helper)
+- Test: `cfc-langgraph/tests/test_report_parse.py`(增用例)
+
+**Interfaces:**
+- Consumes: 请求体可选 `indicators: Optional[list[str]]`。
+- Produces: `_indicator_constraint(indicators)`(模块级);`ReportParseAgent.parse(file_path, indicators=None)`、`parse_generic(file_path, extra_context=None, indicators=None)`、`parse_by_type(file_path, report_type, extra_context=None, indicators=None)`。清单非空时在 LLM 提示词追加约束;缺省行为不变。
+
+- [ ] **Step 1: 写失败测试**
+
+在 `cfc-langgraph/tests/test_report_parse.py` 末尾追加:
+
+```python
+def test_indicator_constraint_contains_names():
+    from app.agents.report_parse_agent import _indicator_constraint
+    s = _indicator_constraint(["总分", "菌群健康"])
+    assert "总分" in s and "菌群健康" in s
+    assert _indicator_constraint(None) == ""
+    assert _indicator_constraint([]) == ""
+
+
+@pytest.mark.asyncio
+async def test_report_parse_accepts_indicators_field():
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as client:
+        resp = await client.post("/api/v1/report/parse", json={
+            "file_path": "/nonexistent.pdf",
+            "family_id": 1,
+            "user_id": 1,
+            "indicators": ["总分", "菌群健康"],
+        })
+        # 字段被接受(非 422 校验错误),仅文件不存在
+        assert resp.status_code == 400
+        assert "文件不存在" in resp.json()["detail"]
+```
+
+- [ ] **Step 2: 运行测试确认失败**
+
+Run: `cd cfc-langgraph && .venv/bin/python -m pytest tests/test_report_parse.py -k "indicator or accepts_indicators" -q`
+(无 `.venv` 时用 `python -m pytest`)
+Expected: FAIL(`_indicator_constraint` 不存在 / `indicators` 被忽略但断言仍可能过→以第一条为准)。
+
+- [ ] **Step 3a: report_parse.py 增字段**
+
+`ParseRequest` 增:
+
+```python
+class ParseRequest(BaseModel):
+    file_path: str
+    family_id: Optional[int] = None
+    user_id: Optional[int] = None
+    indicators: Optional[list[str]] = None
+```
+
+`/report/parse` handler 透传:
+
+```python
+        result = await agent.parse(req.file_path, req.indicators)
+```
+
+`TypedParseRequest` 增:
+
+```python
+class TypedParseRequest(BaseModel):
+    file_path: str
+    report_type: str = "auto"
+    extra_context: Optional[dict] = None
+    indicators: Optional[list[str]] = None
+```
+
+`/report/parse-typed` handler 透传:
+
+```python
+        result = await agent.parse_by_type(req.file_path, req.report_type, req.extra_context, req.indicators)
+```
+
+- [ ] **Step 3b: report_parse_agent.py 注入约束**
+
+在模块级(`class ReportParseAgent` 之前)加:
+
+```python
+def _indicator_constraint(indicators) -> str:
+    """采集指标清单 → LLM 提示词约束块;清单为空时返回空串(保持现状)。"""
+    if not indicators:
+        return ""
+    names = "、".join(str(x) for x in indicators if x)
+    if not names:
+        return ""
+    return (f"\n\n【采集指标约束】请仅提取以下指标项,name 必须与清单一致;"
+            f"报告中缺失的项返回 null,不得臆造:{names}")
+```
+
+`parse` 增参数并透传:
+
+```python
+    async def parse(self, file_path: str, indicators: Optional[list] = None) -> dict:
+        ...
+        if result.get('_parse_incomplete') or not result.get('disease_risks'):
+            logger.info("算法解析不完整,尝试 LLM 兜底")
+            llm_result = await self._parse_with_llm(file_path, indicators)
+```
+
+`_parse_with_llm` 增参数并追加约束:
+
+```python
+    async def _parse_with_llm(self, file_path: str, indicators: Optional[list] = None) -> Optional[dict]:
+        ...
+            template = await get_prompt("report_parse_gut") or REPORT_PARSE_GUT_TEMPLATE
+            prompt = template.format(report_text=text[:8000]) + _indicator_constraint(indicators)
+```
+
+`parse_generic` 增参数并追加约束:
+
+```python
+    async def parse_generic(self, file_path: str, extra_context: Optional[dict] = None,
+                            indicators: Optional[list] = None) -> dict:
+        ...
+            prompt = template.format(report_text=text[:12000] + ctx_str) + _indicator_constraint(indicators)
+```
+
+`parse_by_type` 增参数并在 generic 分支透传:
+
+```python
+    async def parse_by_type(self, file_path: str, report_type: str,
+                            extra_context: Optional[dict] = None,
+                            indicators: Optional[list] = None) -> dict:
+        ...
+        else:
+            logger.info("未识别的报告类型 %s,走通用解析", report_type)
+            return await self.parse_generic(file_path, extra_context, indicators)
+```
+
+确保文件顶部已有 `from typing import Optional`(若无则补)。
+
+- [ ] **Step 4: 运行测试确认通过**
+
+Run: `cd cfc-langgraph && .venv/bin/python -m pytest tests/test_report_parse.py -q`
+Expected: PASS(含既有用例不回归)。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add cfc-langgraph/app/api/report_parse.py \
+        cfc-langgraph/app/agents/report_parse_agent.py \
+        cfc-langgraph/tests/test_report_parse.py
+git commit -m "feat(langgraph): /report/parse 支持 indicators 采集指标约束注入"
+```
+
+---
+
+### Task 8: 全量验证 + 推送 + 部署(部署需用户 go/no-go)
+
+**Files:** 无代码改动;仅验证与部署。
+
+- [ ] **Step 1: 后端全量编译 + 相关单测**
+
+Run:
+```bash
+cd cfc-backend
+mvn -q -Dtest=DatabaseInitializerSeedTest,ReportParserAdminControllerTest,ReportSummaryServiceCompletenessTest test
+mvn -q clean package -Dmaven.test.skip=true
+```
+Expected: 单测 PASS;jar 构建成功。
+
+- [ ] **Step 2: 前端构建**
+
+Run: `cd cfc-web && npm run build`
+Expected: `Build complete`;`dist/` 生成(注意会 bump 版本号 + 生成 changelog)。
+
+- [ ] **Step 3: LangGraph 测试**
+
+Run: `cd cfc-langgraph && .venv/bin/python -m pytest tests/test_report_parse.py -q`
+Expected: PASS。
+
+- [ ] **Step 4: 推送**
+
+```bash
+git add -A
+git commit -m "chore: 采集指标功能全量验证与构建产物"   # 若无改动则跳过
+git push origin cfclub
+```
+注意:若 bump 版本号与远程冲突,以远程版本号为准(skip 本地 chore 提交),避免版本回退。
+
+- [ ] **Step 5: 部署(须用户确认后再执行)**
+
+> 部署前必须:`tar -czf /app/pro/cfc_backup_$(date +%Y%m%d_%H%M%S).tar.gz -C /app pro/cfc`;确认工作区干净;尊重 `/app/pro/cfc` 禁改约束。
+
+后端(远程 `/app/cfc`):
+```bash
+cd /app/cfc/cfc-backend && mvn clean package -Dmaven.test.skip=true -Dmaven.compile.fork=true
+cp target/cfc-backend-1.0.0.jar /app/pro/cfc/backend/
+systemctl restart cfc-backend
+```
+前端:
+```bash
+cd /app/cfc/cfc-web && npm run build
+rsync -a --delete dist/ /app/pro/cfc/frontend/
+```
+LangGraph(按 `cfc-langgraph/deploy.sh`,路径 `/opt/sc-data/cfc/cfc-langgraph/`;容器 `docker-compose restart langgraph-svc` 或 `up -d --build`)。
+
+- [ ] **Step 6: 部署后验证**
+
+- 后端:`systemctl status cfc-backend`(active)→ `curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:9082/api/cart/list` 预期 200。
+- 日志:`grep $(systemctl show -p MainPID --value cfc-backend) /app/pro/cfc/backend/cfc-backend.log | grep ERROR` 应为 0;确认 `Tomcat started on 9082`。
+- 前端:`curl -s -o /dev/null -w "%{http_code}" -k https://cfc.etotem.com.cn/admin/` 预期 200。
+- 功能(管理端):报告类型管理页能编辑并保存采集指标;报告管理页出现「采集完整度」列,5 份样本 DAN 上传可见 `growthMindset` 缺失/A2 `auto` 多余(复用既有 `collect_validation`)。
+- 种子策略:DB 改某类型 `expected_indicators`,`systemctl restart cfc-backend`,复查值未被覆盖。
+- LangGraph:上传非 DAN(如 gut_flora)报告,LangGraph 日志出现指标约束提示词;不传指标时行为与现状一致。
+
+---
+
+## 自检(Self-Review)
+
+**Spec 覆盖:**
+- §2 目标1(管理端可编辑)→ Task 2 + Task 4。✅
+- §2 目标2(完整度展示)→ Task 3 + Task 5。✅
+- §2 目标3(驱动解析)→ Task 6 + Task 7。✅
+- §2 目标4(种子不覆盖)→ Task 1。✅
+- §4.1 种子仅空写入 → Task 1。✅
+- §4.2 管理端 + `/types/save` 校验 → Task 2、Task 4。✅
+- §4.3 报告管理页完整度(list 批量左连接)→ Task 3、Task 5。✅
+- §4.4 LangGraph 注入(提示词)→ Task 6、Task 7。✅
+- §6 错误处理(空清单跳过 / 非法 JSON 400 / 关联不到显示 `-` / LangGraph 缺省兼容)→ 分别落到 Task 1/2/3/7。✅
+- §7 测试策略 → Task 1/2/3/7 单测 + Task 8 集成验证。✅
+
+**Placeholder 扫描:** 无 TBD/TODO/"类似 Task N";每个代码步骤含完整代码块。✅
+
+**类型/签名一致性:**
+- `seedExpectedIndicator(String,String)`(Task 1 定义)→ 调用点不变。✅
+- `ReportSummaryVO.collectValidation`(Task 3 定义)→ Task 5 消费 `row.collectValidation`。✅
+- `parseByScript(String,List)` / `parseWithDetect(String,List)`(Task 6 定义)→ Task 6 调用点一致。✅
+- `_indicator_constraint` / `parse(file_path, indicators)` / `parse_by_type(..., indicators)`(Task 7 定义并自洽)。✅
+- 后端 `indicators` body 字段 ↔ LangGraph `ParseRequest.indicators` / `TypedParseRequest.indicators` 名称一致。✅
+
+**待决项已定:** 完整度 = list 批量左连接(Task 3);LangGraph 注入 = 提示词(Task 7)。✅