|
|
@@ -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)。✅
|