Procházet zdrojové kódy

feat(health-plan): HL-04/R4 方案板块对话式调整(自然语言意图 → 局部微调)

- LangGraph: 新增 POST /api/v1/health/plan/adjust 端点(HealthPlanAdjustRequest + ADJUST_SECTION_SYSTEM_PROMPT,保持成员个性块,tasks 校验入库)
- Backend: HealthPlanController 新增 POST /api/health/plan/adjust(adjustSection)+ Service 层 adjustPlanSection(RestTemplate 转发 LangGraph;AI 不可用返回 null → 控制器 500 'AI 调整服务暂不可用',绝不覆盖用户内容)
- Frontend: health-plan-summary.vue 新增对话式调整聊天面板(dialogueRounds/canSendDialogue/sendDialogueAdjust/resetDialogue,切板块/退出编辑清空),与 textarea 并存;顺带修复 regenerateCurrentSection 发送 existing_section_content 与后端 planJson 键名错配(现改发 planJson)
- 契约测试: adjust 正常/参数缺失/AI 降级 3 用例(总数 34,全绿)
- 文档: API_REFERENCE.md 新增 4.49.1 对话式调整;cfc-web build 通过
Agnes Agent před 5 dny
rodič
revize
e7c379e175

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

@@ -70,6 +70,28 @@ public class HealthPlanController {
         return Result.success(result);
     }
 
+    @Operation(summary = "对话式调整方案板块(输入自然语言调整意图 → 返回调整后板块)")
+    @PostMapping("/adjust")
+    public Result<Map<String, Object>> adjustSection(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        String section = (String) params.get("section");
+        String intent = (String) params.get("intent");
+        String sectionContent = (String) params.get("existing_section_content");
+        if (sectionContent == null) {
+            sectionContent = (String) params.get("planJson");
+        }
+        if (section == null || intent == null || intent.trim().isEmpty()) {
+            return Result.error("section和intent不能为空");
+        }
+        Map<String, Object> result = healthPlanService.adjustPlanSection(familyId, section, intent, sectionContent);
+        if (result == null) {
+            return Result.error("AI 调整服务暂不可用,请稍后重试");
+        }
+        return Result.success(result);
+    }
+
     @Operation(summary = "保存/更新健康方案(支持结构化 planJson)")
     @PostMapping("/save")
     public Result<Long> save(@RequestBody Map<String, Object> params,

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

@@ -24,6 +24,18 @@ public interface HealthPlanService {
      */
     Object generatePlanResult(Long familyId, String memberIds, String dimensions, String goal);
     String regenerateSection(Long familyId, String section, String feedback, String planJsonStr);
+
+    /**
+     * 对话式调整方案板块(HL-04/R4):输入自然语言调整意图,基于当前板块内容返回调整后内容。
+     * 与 regenerateSection(整段重生成)不同,本方法在既有内容基础上做局部微调。
+     *
+     * @param familyId       家庭ID(透传 LangGraph 上下文,可为 null)
+     * @param section        板块 key:nutrition | diet | exercise
+     * @param intent         自然语言调整意图(如「睡眠建议改温和一点」)
+     * @param sectionContent 当前板块完整内容(含多成员个性块)
+     * @return Map{section, content, tasks?};AI 服务不可用或调整失败返回 null(调用方保持原内容)
+     */
+    Map<String, Object> adjustPlanSection(Long familyId, String section, String intent, String sectionContent);
     /** 规划师查看待审核方案列表 */
     List<HealthPlan> listPendingReviewPlans(Long familyId, Long teacherId);
     /** 规划师编辑方案内容 */

+ 35 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -387,6 +387,41 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         return buildSampleSection(section, feedback);
     }
 
+    @Override
+    public Map<String, Object> adjustPlanSection(Long familyId, String section, String intent, String sectionContent) {
+        // LangGraph 未配置或调用失败时返回 null,由调用方保持原内容(安全降级,绝不覆盖用户内容)
+        if (langgraphBaseUrl == null || langgraphBaseUrl.trim().isEmpty()) {
+            return null;
+        }
+        try {
+            RestTemplate restTemplate = new RestTemplate();
+            Map<String, Object> body = new HashMap<>();
+            body.put("section", section);
+            body.put("intent", intent != null ? intent : "");
+            body.put("existing_section_content", sectionContent != null ? sectionContent : "");
+            String url = langgraphBaseUrl + "/api/v1/health/plan/adjust";
+            ResponseEntity<String> response = restTemplate.postForEntity(url, body, String.class);
+            if (response.getBody() != null && response.getStatusCode().is2xxSuccessful()) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                if (root.has("success") && root.get("success").asBoolean()) {
+                    Map<String, Object> result = new LinkedHashMap<>();
+                    result.put("section", section);
+                    result.put("content", root.has("content") ? root.get("content").asText() : "");
+                    if (root.has("tasks") && root.get("tasks").isArray()) {
+                        result.put("tasks", objectMapper.convertValue(root.get("tasks"), List.class));
+                    }
+                    if (root.has("note")) {
+                        result.put("note", root.get("note").asText());
+                    }
+                    return result;
+                }
+            }
+        } catch (Exception e) {
+            log.warn("调用LangGraph对话式调整方案板块失败: {}", e.getMessage());
+        }
+        return null;
+    }
+
     private String buildSampleSection(String section, String feedback) {
         switch (section) {
             case "nutrition":

+ 41 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/PlanLifecycleParentContractTest.java

@@ -136,6 +136,47 @@ class PlanLifecycleParentContractTest extends AssessmentPlanContractTestBase {
         assertTrue(r.getMessage().contains("section和planJson不能为空"));
     }
 
+    // ==================== HL-04/R4 对话式调整方案板块 ====================
+
+    @Test
+    @DisplayName("HL-04/R4 正常:对话式调整板块 → 200 返回调整后内容")
+    void adjust_正常() throws Exception {
+        Map<String, Object> adjusted = new HashMap<>();
+        adjusted.put("section", "nutrition");
+        adjusted.put("content", "调整后的营养板块");
+        when(planSvc.adjustPlanSection(7L, "nutrition", "温和一点", "原营养板块")).thenReturn(adjusted);
+        Map<String, Object> params = new HashMap<>();
+        params.put("section", "nutrition");
+        params.put("intent", "温和一点");
+        params.put("existing_section_content", "原营养板块");
+        Result<Map<String, Object>> r = controller.adjustSection(params, 1L, 7L);
+        assertEquals(200, r.getCode());
+        assertEquals("调整后的营养板块", r.getData().get("content"));
+    }
+
+    @Test
+    @DisplayName("HL-04/R4 异常:section/intent 为空 → 业务提示")
+    void adjust_异常_参数缺失() throws Exception {
+        Map<String, Object> params = new HashMap<>();
+        params.put("section", "nutrition");
+        Result<Map<String, Object>> r = controller.adjustSection(params, 1L, 7L);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("section和intent不能为空"));
+    }
+
+    @Test
+    @DisplayName("HL-04/R4 降级:AI 调整服务不可用 → 500 业务提示,不替换板块内容")
+    void adjust_降级_AI不可用() throws Exception {
+        when(planSvc.adjustPlanSection(7L, "nutrition", "温和一点", "原营养板块")).thenReturn(null);
+        Map<String, Object> params = new HashMap<>();
+        params.put("section", "nutrition");
+        params.put("intent", "温和一点");
+        params.put("existing_section_content", "原营养板块");
+        Result<Map<String, Object>> r = controller.adjustSection(params, 1L, 7L);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("暂不可用"));
+    }
+
     // ==================== SC-P-42/SC-P-81 方案编辑与草稿保存 ====================
 
     @Test

+ 162 - 2
cfc-frontend/pages/health/health-plan-summary.vue

@@ -166,6 +166,27 @@
         <input class="feedback-input" v-model="sectionFeedback" placeholder="如有修改意见请填写(可选)..." />
       </view>
 
+      <!-- 对话式调整(HL-04/R4):自然语言调整意图 → 调后端调整接口 → 刷新板块 -->
+      <view class="dialogue-panel">
+        <view class="dialogue-head">
+          <text class="dialogue-title">💬 对话式调整</text>
+          <text class="dialogue-tip">输入调整意图(如「睡眠建议改温和一点」),AI 将在当前板块上修改</text>
+        </view>
+        <scroll-view scroll-y class="dialogue-list" v-if="dialogueRounds.length > 0">
+          <view v-for="(r, ri) in dialogueRounds" :key="ri" class="dialogue-row" :class="r.role === 'user' ? 'row-user' : 'row-ai'">
+            <view class="dialogue-bubble" :class="r.role === 'user' ? 'bubble-user' : 'bubble-ai'">
+              <text class="bubble-text">{{ r.text }}</text>
+            </view>
+          </view>
+        </scroll-view>
+        <view class="dialogue-input-row">
+          <input class="dialogue-input" v-model="dialogueIntent" placeholder="如:睡眠建议改温和一点..."
+                 confirm-type="send" :disabled="loading" @confirm="sendDialogueAdjust" />
+          <button class="btn-dialogue-send" :disabled="!canSendDialogue"
+                  @click="sendDialogueAdjust">{{ loading ? '处理中' : '发送' }}</button>
+        </view>
+      </view>
+
       <view class="section-actions">
         <button class="btn-edit" @click="regenerateCurrentSection" :disabled="loading">🔄 重新生成</button>
         <button class="btn-confirm" @click="confirmCurrentSection" :disabled="loading">保存</button>
@@ -226,7 +247,7 @@
 </template>
 
 <script>
-import { generateHealthPlan, regenerateSection, confirmPlan, saveHealthPlan, getHealthPlanDetail } from '../../utils/api'
+import { generateHealthPlan, regenerateSection, adjustPlanSection, confirmPlan, saveHealthPlan, getHealthPlanDetail } from '../../utils/api'
 import { getHealthStatus, saveHealthStatus, getDietPreferences, saveDietPreferences, getMyMembership, saveHealthSignature } from '../../utils/api'
 import MemberChipPicker from '@/pages/health/components/MemberChipPicker.vue'
 import ComprehensiveSurvey from '@/pages/health/components/ComprehensiveSurvey.vue'
@@ -268,6 +289,9 @@ export default {
       editing: false,
       // 当前正在编辑的板块下标(0=nutrition,1=diet,2=exercise),逐板块确认时递增
       editIndex: 0,
+      // 对话式调整(HL-04/R4):多轮对话记录 + 输入框
+      dialogueRounds: [],
+      dialogueIntent: '',
       // 查看模式
       viewMode: false,
       planDetail: null,
@@ -310,6 +334,10 @@ export default {
     currentSectionHint: function() {
       return this.sectionHints[this.editIndex] || ''
     },
+    // 对话式调整发送按钮可用性:有调整意图且不在请求中
+    canSendDialogue: function() {
+      return !this.loading && this.dialogueIntent && String(this.dialogueIntent).trim() !== ''
+    },
 selectedMembers: function() {
       var self = this
       var out = []
@@ -1095,7 +1123,7 @@ selectedMembers: function() {
       var params = {
         section: key,
         feedback: self.sectionFeedback || '',
-        existing_section_content: existing,
+        planJson: existing,
         memberIds: self.selectedMemberIds.join(','),
         dimensions: 'body',
         goal: self.userGoal.trim()
@@ -1116,6 +1144,50 @@ selectedMembers: function() {
         uni.showToast({ title: '请求失败', icon: 'none' })
       })
     },
+    // 对话式调整(HL-04/R4):自然语言调整意图 → 调后端调整接口 → 刷新当前板块内容
+    sendDialogueAdjust: function() {
+      var self = this
+      var key = self.currentSectionKey
+      var intent = (self.dialogueIntent || '').trim()
+      if (!key || !intent || self.loading) return
+      var current = self.sections[key].content || ''
+      self.dialogueRounds.push({ role: 'user', text: intent })
+      self.dialogueIntent = ''
+      self.loading = true
+      var params = {
+        section: key,
+        intent: intent,
+        existing_section_content: current,
+        memberIds: self.selectedMemberIds.join(','),
+        dimensions: 'body',
+        goal: self.userGoal.trim()
+      }
+      adjustPlanSection(params).then(function(res) {
+        self.loading = false
+        if (res && res.code === 200 && res.data && res.data.content) {
+          self.sections[key].content = res.data.content
+          if (res.data.tasks) {
+            self.sections[key].tasks = res.data.tasks
+          }
+          self.dialogueRounds.push({ role: 'ai', text: '已按「' + intent + '」调整该板块' })
+          uni.showToast({ title: '调整成功', icon: 'success' })
+        } else {
+          var msg = res && res.message ? res.message : '调整失败,请重试'
+          self.dialogueRounds.push({ role: 'ai', text: msg })
+          uni.showToast({ title: msg, icon: 'none' })
+        }
+      }).catch(function() {
+        self.loading = false
+        var msg = '请求失败,请检查网络'
+        self.dialogueRounds.push({ role: 'ai', text: msg })
+        uni.showToast({ title: msg, icon: 'none' })
+      })
+    },
+    // 切换板块/退出编辑时清空对话记录(对话上下文随板块切换失效)
+    resetDialogue: function() {
+      this.dialogueRounds = []
+      this.dialogueIntent = ''
+    },
     confirmCurrentSection: function() {
       var self = this
       var key = self.currentSectionKey
@@ -1130,8 +1202,10 @@ selectedMembers: function() {
       if (nextIdx >= 0) {
         self.editIndex = nextIdx
         self.editing = true
+        self.resetDialogue()
       } else {
         self.editing = false
+        self.resetDialogue()
       }
     },
     startEdit: function() {
@@ -1143,6 +1217,7 @@ selectedMembers: function() {
       this.editIndex = idx
       this.editing = true
       this.sectionFeedback = ''
+      this.resetDialogue()
     },
     // 点击预览板块 → 直接打开该板块编辑(R1:方案预览板块可点击进入编辑)
     openSectionEditor: function(i) {
@@ -1151,10 +1226,12 @@ selectedMembers: function() {
       this.editIndex = idx
       this.editing = true
       this.sectionFeedback = ''
+      this.resetDialogue()
     },
     exitEditMode: function() {
       this.editing = false
       this.sectionFeedback = ''
+      this.resetDialogue()
     },
     submitPlan: function() {
       var self = this
@@ -1592,6 +1669,89 @@ selectedMembers: function() {
   flex: 1;
   margin-bottom: 0;
 }
+/* 对话式调整(HL-04/R4) */
+.dialogue-panel {
+  margin-top: 16rpx;
+  background: $app-bg;
+  border: 2rpx solid $facade-hairline-2;
+  border-radius: 16rpx;
+  padding: 20rpx;
+}
+.dialogue-head {
+  margin-bottom: 12rpx;
+}
+.dialogue-title {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  font-weight: bold;
+  color: $facade-text-strong;
+}
+.dialogue-tip {
+  display: block;
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+  margin-top: 4rpx;
+}
+.dialogue-list {
+  max-height: 320rpx;
+  margin-bottom: 12rpx;
+}
+.dialogue-row {
+  display: flex;
+  margin-bottom: 10rpx;
+}
+.dialogue-row.row-user { justify-content: flex-end; }
+.dialogue-row.row-ai { justify-content: flex-start; }
+.dialogue-bubble {
+  max-width: 88%;
+  border-radius: 16rpx;
+  padding: 14rpx 20rpx;
+}
+.bubble-user {
+  background: $brand-primary;
+  color: $app-surface;
+}
+.bubble-ai {
+  background: $app-surface;
+  border: 2rpx solid $facade-hairline-2;
+  color: $facade-text-strong;
+}
+.bubble-text {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  line-height: 1.6;
+  word-break: break-all;
+}
+.dialogue-input-row {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+}
+.dialogue-input {
+  flex: 1;
+  height: 72rpx;
+  background: $app-surface;
+  border: 2rpx solid $brand-primary;
+  border-radius: 36rpx;
+  padding: 0 24rpx;
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-text-strong;
+  box-sizing: border-box;
+}
+.btn-dialogue-send {
+  flex-shrink: 0;
+  width: 128rpx;
+  height: 72rpx;
+  line-height: 72rpx;
+  padding: 0;
+  margin: 0;
+  background: linear-gradient(135deg, $brand-primary, $brand-primary-light);
+  color: $app-surface;
+  border: none;
+  border-radius: 36rpx;
+  font-size: calc(26rpx * var(--font-scale, 1));
+  text-align: center;
+}
+.btn-dialogue-send[disabled] { opacity: 0.4; }
+.btn-dialogue-send::after { border: none; }
 .back-link {
   text-align: center;
   font-size: calc(26rpx * var(--font-scale, 1));

+ 1 - 0
cfc-frontend/utils/api.js

@@ -2630,6 +2630,7 @@ export const getDimensionHistory = (data) => request('/api/dimension/history', '
 // ===== 健康方案汇总 =====
 export const generateHealthPlan = (data) => request('/api/health/plan/generate', 'POST', data)
 export const regenerateSection = (data) => request('/api/health/plan/regenerate-section', 'POST', data)
+export const adjustPlanSection = (data) => request('/api/health/plan/adjust', 'POST', data)
 export const confirmPlan = (data) => request('/api/health/plan/confirm', 'POST', data)
 export const saveHealthPlan = (data) => request('/api/health/plan/save', 'POST', data)
 // 规划师方案维护

+ 106 - 0
cfc-langgraph/app/api/adapter.py

@@ -566,6 +566,18 @@ class HealthPlanRegenerateRequest(BaseModel):
     family_id: Optional[int] = Field(default=None, validation_alias=AliasChoices("family_id", "familyId"))
 
 
+class HealthPlanAdjustRequest(BaseModel):
+    """对话式调整方案 request(HL-04/R4):自然语言调整意图 → 调整后板块内容"""
+    model_config = ConfigDict(populate_by_name=True)
+    section: str  # nutrition | diet | exercise
+    intent: str   # 自然语言调整意图,如「睡眠建议改温和一点」
+    existing_section_content: str = ""  # 当前板块完整内容(含多成员个性块)
+    member_ids: Optional[str] = Field(default=None, validation_alias=AliasChoices("member_ids", "memberIds"))
+    dimensions: Optional[str] = None
+    goal: str = ""
+    family_id: Optional[int] = Field(default=None, validation_alias=AliasChoices("family_id", "familyId"))
+
+
 TEXT_PLAN_SYSTEM_PROMPT = """你是一个专业的家庭健康方案生成器。请根据用户提供的健康数据,生成一份结构化的健康改善方案。
 
 输出格式要求:
@@ -704,6 +716,35 @@ REGENERATE_SECTION_SYSTEM_PROMPT = """你是一个家庭健康方案规划师。
 - 建议要具体可执行"""
 
 
+ADJUST_SECTION_SYSTEM_PROMPT = """你是一个家庭健康方案规划师。用户会用自然语言对方案的某个板块提出调整意图,请在「当前内容」基础上做针对性修改,而不是整段重写。
+
+## 调整原则
+- 只修改用户调整意图所指向的部分;与意图无关的建议、已有排版与格式一律保持不变
+- 保持原 Markdown 结构、标题层级与措辞风格
+- 若「当前内容」包含「### 成员 X」等成员个性块,除非调整意图明确指向该成员,否则这些块必须原样保留
+- 建议要具体、可执行,不泛泛而谈;调整后仍针对该板块主题(营养补充/饮食/运动)输出
+
+## 输出格式(必须输出合法 JSON,不要有其他内容)
+{
+  "content": "调整后的板块内容(Markdown)",
+  "tasks": [
+    {
+      "action_type": "buy|read|exercise|checkin|diet|activity",
+      "title": "可执行任务标题",
+      "dimension": "body|mind|wisdom|action|wealth",
+      "frequency": "once|daily",
+      "notes": "补充说明"
+    }
+  ]
+}
+
+## tasks 约定
+- action_type 取值:buy/read/exercise/checkin/diet/activity
+- dimension 取值:body/mind/wisdom/action/wealth
+- frequency:once=一次性;daily=每日重复
+- 调整意图不涉及新行动项时,tasks 必须输出 []"""
+
+
 def _format_diet_preferences(dp: dict) -> str:
     if not dp:
         return ""
@@ -1332,6 +1373,71 @@ async def health_plan_regenerate(req: HealthPlanRegenerateRequest):
         return {"success": False, "error": str(e)}
 
 
+@router.post("/health/plan/adjust", response_model=dict)
+async def health_plan_adjust(req: HealthPlanAdjustRequest):
+    """对话式调整方案板块(HL-04/R4):
+    输入自然语言调整意图(如「睡眠建议改温和一点」)→ 返回调整后的板块内容。
+    与 regenerate-section 的区别:只在当前内容基础上做局部微调,不整段重写。"""
+    java = JavaClient()
+    llm = ChatOpenAI(
+        model=settings.llm_model,
+        api_key=settings.llm_api_key,
+        base_url=settings.llm_base_url,
+        temperature=0.3,
+    )
+
+    members_info, all_indicators, kb_results = await _collect_plan_data(java, None, req.member_ids or "", req.goal, req.dimensions or "")
+
+    # 构建上下文
+    ctx_parts = [f"目标: {req.goal}"]
+    for m in members_info:
+        ctx_parts.append(f"- {m['name']} (年龄: {m['age']})")
+        hs_text = _format_health_status(m.get("health_status"))
+        if hs_text:
+            ctx_parts.append(f"  健康自检: {hs_text}")
+        dp_text = _format_diet_preferences(m.get("diet_preferences"))
+        if dp_text:
+            ctx_parts.append(f"  饮食偏好: {dp_text}")
+    for ind in all_indicators[:10]:
+        if ind.get("status") in ("abnormal", "high", "low", "偏高", "偏低"):
+            ctx_parts.append(f"- 【异常】{ind.get('_member_name','')} - {ind.get('indicatorName','')}: {ind.get('indicatorValue','')}")
+
+    prompt = await get_prompt("health_plan_adjust") or ADJUST_SECTION_SYSTEM_PROMPT
+    prompt += f"\n\n## 调整板块\n{req.section}"
+    prompt += f"\n\n## 当前内容\n{req.existing_section_content[:2000]}"
+    prompt += f"\n\n## 用户调整意图\n{req.intent}"
+    if ctx_parts:
+        prompt += "\n\n## 相关背景\n" + "\n".join(ctx_parts[:10])
+
+    try:
+        response = await llm.ainvoke([SystemMessage(content=prompt)])
+        answer = response.content
+        start = answer.find("{")
+        end = answer.rfind("}") + 1
+        content = answer
+        tasks = []
+        note = ""
+        if start >= 0 and end > start:
+            try:
+                parsed = json.loads(answer[start:end])
+                content = parsed.get("content") or answer
+                raw_tasks = parsed.get("tasks") or []
+                note = parsed.get("note") or ""
+                # 用 PlanTask 校验,非法条目丢弃
+                for t in raw_tasks:
+                    try:
+                        pt = PlanTask.model_validate(t)
+                        tasks.append(pt.model_dump())
+                    except Exception:
+                        continue
+            except Exception as e:
+                logger.warning("解析调整 section JSON 失败: %s", e)
+        return {"success": True, "content": content, "tasks": tasks, "note": note}
+    except Exception as e:
+        logger.error("对话式调整 section 失败: %s", e)
+        return {"success": False, "error": str(e)}
+
+
 @router.post("/nutrition/send", response_model=DifyResponse)
 async def nutrition_send(req: DifyChatRequest):
     """AI 营养助手 — 基于健康报告的个性化营养建议"""

+ 15 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -1882,6 +1882,21 @@ interactionType 取值:`PHONE` / `WECHAT` / `MEETING` / `OTHER`。
 
 *文档更新:2026-10-17(健康方案级联删除,见 `specs/2026-10-17-health-plan-deletion-design.md`)*
 
+### 4.49.1 健康方案对话式调整(`POST /api/health/plan/adjust`)
+
+用户在「方案预览 → 板块编辑」内以**自然语言**输入调整意图(如「睡眠建议改温和一点」),后端调用 LangGraph 调整接口返回调整后的板块内容,前端刷新该板块(HL-04 / R4 需求;与既有 textarea 编辑 + 「重新生成」并存)。
+
+| 端点 | 权限 | 请求 | 返回 |
+|---|---|---|---|
+| `POST /api/health/plan/adjust` | 登录用户(JWT) | `{section, intent, existing_section_content, memberIds?, dimensions?, goal?}` | `Result<Map>{section, content, tasks?}` |
+
+- **`section`**:`nutrition` / `diet` / `exercise`(必填);**`intent`**:自然语言调整意图(必填);**`existing_section_content`**:当前板块完整内容(含多成员个性块,必填),后端亦兼容 `planJson` 键名。
+- **调整语义**:区别于 `regenerate-section`(整段重生成),`adjust` 仅在当前内容基础上做**局部微调**,保留无关建议与格式、成员个性块。
+- **AI 链路**:后端 `HealthPlanServiceImpl.adjustPlanSection` 转发至 LangGraph `POST /api/v1/health/plan/adjust`;Langkraph 不可用或调整失败时返回 `Result.error("AI 调整服务暂不可用,请稍后重试")`,**不替换板块内容**(安全降级,绝不覆盖用户已有内容)。
+- **后端实现**:`HealthPlanController.adjustSection` → `HealthPlanService.adjustPlanSection(familyId, section, intent, sectionContent)`。
+
+*文档更新:2026-11-08(HL-04 R4 方案对话式调整,见 `specs/2026-11-07-health-plan-flow-rework-change.md`)*
+
 ### 4.50 活动海报模板(`/api/admin/poster-template/*` + `/api/poster-template/*`)
 
 Web 管理端定义活动海报模板(迁移381 `activity_poster_templates` 表),小程序端活动详情页「生成海报」拉取可用模板渲染画布。管理端接口校验 `@RequestAttribute("role")`,仅 `admin`/`operator` 可访问;`templateKey` 限定 `gradient`(渐变)/`minimal`(深色)/`card`(白底卡片),`name` 全局唯一。