Просмотр исходного кода

wip(health-plan): 多成员方案支持 + 方案生成返回结构化数据(未完成,待后续完善)

Sisyphus 1 неделя назад
Родитель
Сommit
b361cbe1ea

+ 4 - 4
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java

@@ -31,7 +31,7 @@ public class HealthPlanController {
 
     @Operation(summary = "生成健康方案(文本)")
     @PostMapping("/generate")
-    public Result<String> generate(@RequestBody Map<String, Object> params,
+    public Result<Object> generate(@RequestBody Map<String, Object> params,
                                    @RequestAttribute("userId") Long userId,
                                    @RequestAttribute(value = "familyId", required = false) Long familyId) {
         // 付费墙拦截:家庭会员 或 健康启航计划 任一有效即可生成
@@ -44,15 +44,15 @@ public class HealthPlanController {
         if (!isMember && !Boolean.TRUE.equals(purchaseCheck.get("isActive"))) {
             return Result.error(4001, "请升级为家庭会员或购买健康启航计划");
         }
-        
+
         String memberIds = (String) params.get("memberIds");
         String dimensions = (String) params.get("dimensions");
         String goal = (String) params.get("goal");
         if (dimensions == null || goal == null || goal.trim().isEmpty()) {
             return Result.error("dimensions和goal不能为空");
         }
-        String planContent = healthPlanService.generatePlan(familyId, memberIds, dimensions, goal);
-        return Result.success(planContent);
+        Object result = healthPlanService.generatePlanResult(familyId, memberIds, dimensions, goal);
+        return Result.success(result);
     }
 
     @Operation(summary = "重新生成方案某一部分(营养/饮食/运动)")

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java

@@ -15,6 +15,14 @@ public interface HealthPlanService {
     /** 管理端跨家庭分页查询健康方案 */
     Page<HealthPlan> adminListPlans(Long familyId, String status, String keyword, int page, int size);
     String generatePlan(Long familyId, String memberIds, String dimensions, String goal);
+
+    /**
+     * 生成健康方案并返回结构化结果:
+     * - 单人生成:返回 String(planContent 文本,向后兼容)
+     * - 多人生成:返回 Map({planContent: String, common: Map, individual: Map<memberId, Map>}),
+     *   前端可据此保存 planJson(common/individual 结构),确认时按成员分别生成任务
+     */
+    Object generatePlanResult(Long familyId, String memberIds, String dimensions, String goal);
     String regenerateSection(Long familyId, String section, String feedback, String planJsonStr);
     /** 规划师查看待审核方案列表 */
     List<HealthPlan> listPendingReviewPlans(Long familyId, Long teacherId);

+ 151 - 13
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -217,6 +217,12 @@ public class HealthPlanServiceImpl implements HealthPlanService {
 
     @Override
     public String generatePlan(Long familyId, String memberIds, String dimensions, String goal) {
+        Object result = generatePlanResult(familyId, memberIds, dimensions, goal);
+        return (result instanceof String) ? (String) result : "";
+    }
+
+    @Override
+    public Object generatePlanResult(Long familyId, String memberIds, String dimensions, String goal) {
         if (langgraphBaseUrl == null || langgraphBaseUrl.trim().isEmpty()) {
             return buildSamplePlan(memberIds, dimensions, goal);
         }
@@ -233,6 +239,15 @@ public class HealthPlanServiceImpl implements HealthPlanService {
                 JsonNode root = objectMapper.readTree(response.getBody());
                 if (root.has("success") && root.get("success").asBoolean()) {
                     JsonNode data = root.get("data");
+                    // 多人方案:common(共性)+ individual(各成员个性)
+                    if (data != null && data.has("common")) {
+                        Map<String, Object> multi = new HashMap<>();
+                        multi.put("planContent", buildMultiMemberPlanContent(data));
+                        if (data.has("common")) multi.put("common", data.get("common"));
+                        if (data.has("individual")) multi.put("individual", data.get("individual"));
+                        return multi;
+                    }
+                    // 单人生成:保持原 flat 结构
                     if (data != null && data.has("overview")) {
                         StringBuilder sb = new StringBuilder();
                         sb.append("## 方案概述\n\n").append(data.get("overview").asText()).append("\n\n");
@@ -254,6 +269,45 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         return buildSamplePlan(memberIds, dimensions, goal);
     }
 
+    /**
+     * 渲染多人方案 planContent:先共性,再各成员个性。
+     * LangGraph 返回结构:{common: {overview, sections}, individual: {memberId: {overview, sections}}}
+     */
+    private String buildMultiMemberPlanContent(JsonNode data) {
+        StringBuilder sb = new StringBuilder();
+        JsonNode common = data.get("common");
+        if (common != null && common.has("overview")) {
+            sb.append("# 共性方案\n\n").append(common.get("overview").asText()).append("\n\n");
+        }
+        if (common != null && common.has("sections")) {
+            for (JsonNode section : common.get("sections")) {
+                sb.append("## ").append(section.get("title").asText()).append("\n\n");
+                sb.append(section.get("content").asText()).append("\n\n");
+            }
+        }
+        JsonNode individual = data.get("individual");
+        if (individual != null && individual.isObject()) {
+            java.util.Iterator<java.util.Map.Entry<String, JsonNode>> fields = individual.fields();
+            while (fields.hasNext()) {
+                java.util.Map.Entry<String, JsonNode> entry = fields.next();
+                String memberId = entry.getKey();
+                JsonNode part = entry.getValue();
+                if (part == null) continue;
+                sb.append("\n---\n\n# 成员 ").append(memberId).append(" 个性方案\n\n");
+                if (part.has("overview")) {
+                    sb.append("## 概述\n\n").append(part.get("overview").asText()).append("\n\n");
+                }
+                if (part.has("sections")) {
+                    for (JsonNode section : part.get("sections")) {
+                        sb.append("## ").append(section.get("title").asText()).append("\n\n");
+                        sb.append(section.get("content").asText()).append("\n\n");
+                    }
+                }
+            }
+        }
+        return sb.toString();
+    }
+
 
     @Override
     public String regenerateSection(Long familyId, String section, String feedback, String planJsonStr) {
@@ -318,6 +372,20 @@ public class HealthPlanServiceImpl implements HealthPlanService {
     private static final int DEFAULT_PLAN_DAYS = 14;
 
     private void generateDailyTasksFromPlan(HealthPlan plan) {
+        Long familyId = plan.getFamilyId();
+        String[] memberIdArray = (plan.getMemberIds() != null && !plan.getMemberIds().isEmpty())
+            ? plan.getMemberIds().split(",") : new String[0];
+
+        // 多人结构:planJson 含 common + individual,且 memberIds 多于 1 人
+        // 规则:共性任务给每个成员各生成一份;个性任务只给对应成员
+        JsonNode planRoot = parsePlanJson(plan.getPlanJson());
+        if (planRoot != null && planRoot.has("common") && planRoot.has("individual")
+                && memberIdArray.length > 1) {
+            generateDailyTasksForMembers(plan, planRoot, memberIdArray);
+            return;
+        }
+
+        // 单人生成 / 旧 flat 结构:保持原有逻辑(只取第一个成员)
         String content = plan.getPlanContent();
         List<String> rawLines = new java.util.ArrayList<>();
         List<TaskDraft> structuredDrafts = buildDraftsFromPlanJson(plan);
@@ -331,18 +399,12 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             }
         }
 
-        Long familyId = plan.getFamilyId();
-        String[] memberIdArray = (plan.getMemberIds() != null && !plan.getMemberIds().isEmpty())
-            ? plan.getMemberIds().split(",") : new String[0];
         Long rawMemberId = memberIdArray.length > 0 ? Long.valueOf(memberIdArray[0].trim()) : null;
         Long familyMemberId = resolveFamilyMemberId(rawMemberId, familyId);
-        Long childId = familyMemberId;
-        Long executorId = familyMemberId;
 
         Date now = new Date();
 
         // 方案周期终点(D3):由 health_plans.total_days 决定,缺省 14 天。
-        // 既作为周期任务模板的截止日期(其时分=每日截止时刻),也作为周期生成的终止边界。
         int totalDays = (plan.getTotalDays() != null && plan.getTotalDays() > 0)
             ? plan.getTotalDays() : DEFAULT_PLAN_DAYS;
         Date planEnd = buildPlanEnd(now, totalDays);
@@ -359,9 +421,70 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             }
         }
 
+        insertTasksForMember(plan, familyId, familyMemberId, drafts, now, planEnd);
+    }
+
+    /**
+     * 多人方案:按成员分别生成任务。
+     * 共性任务(common.sections.tasks)每个成员各一份;个性任务(individual.<memberId>.sections.tasks)只给对应成员。
+     */
+    private void generateDailyTasksForMembers(HealthPlan plan, JsonNode root, String[] memberIdArray) {
+        Long familyId = plan.getFamilyId();
+        List<TaskDraft> commonDrafts = buildDraftsFromSections(root.path("common").path("sections"));
+        Date now = new Date();
+        int totalDays = (plan.getTotalDays() != null && plan.getTotalDays() > 0)
+            ? plan.getTotalDays() : DEFAULT_PLAN_DAYS;
+        Date planEnd = buildPlanEnd(now, totalDays);
+
+        for (String rawMemberId : memberIdArray) {
+            if (rawMemberId == null || rawMemberId.trim().isEmpty()) continue;
+            Long rawId = Long.valueOf(rawMemberId.trim());
+            Long familyMemberId = resolveFamilyMemberId(rawId, familyId);
+            List<TaskDraft> memberDrafts = new java.util.ArrayList<>(commonDrafts);
+            memberDrafts.addAll(buildDraftsFromSections(
+                root.path("individual").path(rawMemberId.trim()).path("sections")));
+            if (memberDrafts.isEmpty()) continue;
+            insertTasksForMember(plan, familyId, familyMemberId, memberDrafts, now, planEnd);
+        }
+    }
+
+    /** 从 planJson 的 sections 数组提取任务草稿(仅读取 tasks 字段,无懒回填) */
+    private List<TaskDraft> buildDraftsFromSections(JsonNode sections) {
+        List<TaskDraft> drafts = new java.util.ArrayList<>();
+        if (sections == null || !sections.isArray()) return drafts;
+        for (JsonNode section : sections) {
+            JsonNode tasks = section.path("tasks");
+            if (!tasks.isArray()) continue;
+            for (JsonNode t : tasks) {
+                String actionType = t.path("action_type").asText("");
+                String title = t.path("title").asText("");
+                String dimension = t.path("dimension").asText("");
+                String frequency = t.path("frequency").asText("daily");
+                if (title.isEmpty()) continue;
+                TaskDraft draft = mapStructuredTask(actionType, title, dimension, frequency);
+                if (draft != null) drafts.add(draft);
+            }
+        }
+        return drafts;
+    }
+
+    private JsonNode parsePlanJson(String planJsonStr) {
+        if (planJsonStr == null || planJsonStr.trim().isEmpty()) return null;
+        try {
+            return objectMapper.readTree(planJsonStr);
+        } catch (Exception e) {
+            log.warn("解析 plan_json 失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    /** 为指定成员插入任务:一次性购买锚点 + 一次性/周期任务模板 */
+    private void insertTasksForMember(HealthPlan plan, Long familyId, Long familyMemberId,
+                                      List<TaskDraft> drafts, Date now, Date planEnd) {
+        Long childId = familyMemberId;
+        Long executorId = familyMemberId;
+
         // 1) 先落一次性购买任务,作为服务任务的依赖锚点(D1)
-        // 依赖必须"逐项配对":每个使用任务绑定它前面最近的购买任务。
-        // 若所有使用任务共用首个采购任务,多商品场景闸门语义就是错的(E2E 实跑发现)。
         Long buyTaskId = null;
         List<Long> draftAnchors = new java.util.ArrayList<Long>();
         for (TaskDraft draft : drafts) {
@@ -370,7 +493,7 @@ public class HealthPlanServiceImpl implements HealthPlanService {
                 draftAnchors.add(buyTaskId);
                 continue;
             }
-            Task existing = findPlanTask(plan, familyId, draft.title, "购买任务", 0);
+            Task existing = findPlanTask(plan, familyId, familyMemberId, draft.title, "购买任务", 0);
             if (existing != null) {
                 buyTaskId = existing.getId();
                 draftAnchors.add(existing.getId());
@@ -399,7 +522,7 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             boolean once = "once".equals(draft.frequency);
 
             if (once) {
-                if (findPlanTask(plan, familyId, draft.title, draft.categoryLabel, 0) != null) continue;
+                if (findPlanTask(plan, familyId, familyMemberId, draft.title, draft.categoryLabel, 0) != null) continue;
                 Task task = newPlanTask(plan, familyId, familyMemberId, childId, executorId, draft, now);
                 task.setRepeatType("none");
                 task.setTaskType("onetime");
@@ -415,9 +538,7 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             }
 
             // 周期任务 -> 模板行(is_template=1),交 RepeatTaskGenerator 按日生成实例。
-            // 模板 deadline=planEnd(未来日期),因此不会出现在"今日任务"列表里(列表按 deadline=当天过滤),
-            // 用户只会看到生成出来的实例,天然避免模板/实例重复展示。
-            Task existingTpl = findPlanTask(plan, familyId, draft.title, draft.categoryLabel, 1);
+            Task existingTpl = findPlanTask(plan, familyId, familyMemberId, draft.title, draft.categoryLabel, 1);
             if (existingTpl != null) {
                 // 模板已存在:仅在依赖缺失时补写(修复存量方案已生成的模板)
                 if ("使用任务".equals(draft.categoryLabel)
@@ -467,6 +588,12 @@ public class HealthPlanServiceImpl implements HealthPlanService {
 
     /** 幂等查找:同一方案下的同名任务,templateOnly=true 只找模板行 */
     private Task findPlanTask(HealthPlan plan, Long familyId, String title, String category, int templateOnly) {
+        return findPlanTask(plan, familyId, null, title, category, templateOnly);
+    }
+
+    /** 按成员隔离的幂等查找:multi-member 下共性任务每成员各一份,需按 family_member_id 区分,避免互相拦截 */
+    private Task findPlanTask(HealthPlan plan, Long familyId, Long familyMemberId,
+                              String title, String category, int templateOnly) {
         QueryWrapper<Task> qw = new QueryWrapper<Task>()
             .eq("family_id", familyId)
             .eq("source_type", "health_plan")
@@ -474,6 +601,7 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             .eq("title", title)
             .eq("is_template", templateOnly);
         if (category != null) qw.eq("category", category);
+        if (familyMemberId != null) qw.eq("family_member_id", familyMemberId);
         return taskMapper.selectOne(qw.last("LIMIT 1"));
     }
 
@@ -1006,6 +1134,16 @@ public class HealthPlanServiceImpl implements HealthPlanService {
 
     private String buildPlanJsonFromData(JsonNode data) {
         try {
+            // 多人方案:原样透传 common + individual 结构(与前端保存的 planJson 一致),
+            // 供确认时 generateDailyTasksForMembers 按成员分别生成任务
+            if (data.has("common") && data.has("individual")) {
+                Map<String, Object> planJsonMap = new HashMap<>();
+                planJsonMap.put("common", objectMapper.convertValue(data.get("common"), Map.class));
+                planJsonMap.put("individual", objectMapper.convertValue(data.get("individual"), Map.class));
+                return objectMapper.writeValueAsString(planJsonMap);
+            }
+
+            // 单人 / 旧 flat 结构
             Map<String, Object> planJsonMap = new HashMap<>();
             planJsonMap.put("overview", data.has("overview") ? data.get("overview").asText() : "");
             List<Map<String, Object>> sections = new ArrayList<>();

+ 47 - 4
cfc-langgraph/tests/test_health_plan.py

@@ -1,5 +1,5 @@
-from app.models.health_plan import PlanTask, HealthPlanSection, HealthPlanResponse
-from app.api.adapter import _parse_plan_response, _fallback_extract_tasks
+from app.models.health_plan import PlanTask, HealthPlanSection, HealthPlanResponse, MultiMemberPlanResponse, PlanPart
+from app.api.adapter import _parse_plan_response, _fallback_extract_tasks, _synthesize_multi_member_plan
 
 
 def test_plan_task_valid():
@@ -17,14 +17,14 @@ def test_plan_task_invalid_action_type():
     import pytest
     from pydantic import ValidationError
     with pytest.raises(ValidationError):
-        PlanTask(action_type="cook", title="做饭", dimension="body")
+        PlanTask.model_validate({"action_type": "cook", "title": "做饭", "dimension": "body"})
 
 
 def test_plan_task_invalid_dimension():
     import pytest
     from pydantic import ValidationError
     with pytest.raises(ValidationError):
-        PlanTask(action_type="diet", title="少油少盐", dimension="earth")
+        PlanTask.model_validate({"action_type": "diet", "title": "少油少盐", "dimension": "earth"})
 
 
 def test_plan_response_with_tasks():
@@ -81,3 +81,46 @@ def test_regenerate_tasks_validation_filter():
             continue
     assert len(valid) == 1
     assert valid[0]["action_type"] == "diet"
+
+
+def test_multi_member_plan_response_structure():
+    """多人方案结构校验:common + individual 按 memberId 分组"""
+    resp = MultiMemberPlanResponse()
+    assert resp.common.overview == ""
+    assert resp.individual == {}
+
+
+def test_multi_member_plan_response_with_data():
+    resp = MultiMemberPlanResponse(
+        common=PlanPart(overview="全家共性方案", sections=[
+            HealthPlanSection(
+                key="diet", title="饮食建议", content="## 共同饮食",
+                tasks=[PlanTask(action_type="diet", title="每日三餐均衡", dimension="body", frequency="daily")]
+            )
+        ]),
+        individual={
+            "123": PlanPart(overview="成员A个性方案", sections=[
+                HealthPlanSection(
+                    key="nutrition", title="营养补充", content="## 维生素D3",
+                    tasks=[PlanTask(action_type="buy", title="购买维生素D3", dimension="wealth", frequency="once")]
+                )
+            ])
+        }
+    )
+    data = resp.model_dump()
+    assert data["common"]["sections"][0]["tasks"][0]["action_type"] == "diet"
+    assert data["individual"]["123"]["sections"][0]["tasks"][0]["action_type"] == "buy"
+    assert "123" in data["individual"]
+
+
+def test_multi_member_plan_response_fallback_structure():
+    """LLM 合成失败时程序化兜底:common 置空,individual 透传各成员方案"""
+    resp = MultiMemberPlanResponse(common=PlanPart(overview=""), individual={
+        "456": PlanPart(overview="成员B个性", sections=[
+            HealthPlanSection(key="exercise", title="运动计划", content="## 跑步",
+                             tasks=[PlanTask(action_type="exercise", title="每周跑步3次", dimension="body", frequency="daily")])
+        ])
+    })
+    data = resp.model_dump()
+    assert data["common"]["overview"] == ""
+    assert data["individual"]["456"]["sections"][0]["tasks"][0]["title"] == "每周跑步3次"