|
|
@@ -0,0 +1,1087 @@
|
|
|
+# 生成方案标准内容格式 实现计划
|
|
|
+
|
|
|
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
|
|
+
|
|
|
+**目标:** 让 `plan_json.sections[].tasks` 成为任务生成的唯一事实源——AI 有标准格式返回、用户看到标准格式可编辑、结果确定性地转化为任务。
|
|
|
+
|
|
|
+**架构:** 三端联动改造。LangGraph 侧在 `PLAN_SYSTEM_PROMPT` 追加 `tasks` 输出契约并新增 `PlanTask` Pydantic 模型,用 `PlanResponse.model_validate_json()` 取代手动 `find/rfind` 截取,校验失败时用正则兜底。Java 侧 `generateDailyTasksFromPlan` 优先读 `planJson.sections[].tasks` 映射到 `TaskDraft`,section 无 tasks 时用现有正则兜底并**懒加载回填**落库。前端修复 `submitPlan` 漏写 tasks 的 bug,并在规划师端 `HealthPlanReview.vue` 新增结构化任务条目编辑 UI。
|
|
|
+
|
|
|
+**技术栈:** Python (FastAPI + LangGraph + Pydantic v2)、Java 8 + Spring Boot + MyBatis-Plus + Jackson、Vue 2 小程序、Vue 2 + Element UI 管理端。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构
|
|
|
+
|
|
|
+设计文档:`docs/superpowers/specs/2026-08-28-plan-standard-content-design.md`(已 commit,3db0413d)
|
|
|
+
|
|
|
+### 修改文件与职责
|
|
|
+
|
|
|
+| 文件 | 职责 | 变更类型 |
|
|
|
+|---|---|---|
|
|
|
+| `cfc-langgraph/app/models/health_plan.py` | 新增 `PlanTask` 模型、给 `HealthPlanSection` 加 `tasks` 字段 | 修改 |
|
|
|
+| `cfc-langgraph/app/api/adapter.py` | `PLAN_SYSTEM_PROMPT` 追加 tasks 契约;`health_plan_generate` 用 Pydantic 校验 + 正则兜底;`health_plan_regenerate` 返回 `{content, tasks}`;提取可测的纯解析函数 `_parse_plan_response` | 修改 |
|
|
|
+| `cfc-langgraph/tests/test_health_plan.py` | `PlanTask` 校验、`_parse_plan_response` 正则兜底、重生成 tasks 结构 | 创建 |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java` | `generateDailyTasksFromPlan` 优先读 tasks + 正则兜底 + 懒加载回填;`TaskDraft` 增加 `frequency` 字段并映射到 Task | 修改 |
|
|
|
+| `cfc-frontend/pages/health/health-plan-summary.vue` | `submitPlan` 补 `sections[].tasks`;regenerate 返回 tasks 时更新 | 修改 |
|
|
|
+| `cfc-web/src/views/teacher/HealthPlanReview.vue` | 编辑弹窗新增结构化任务条目编辑(增删改) | 修改 |
|
|
|
+| `docs/superpowers/PROJECT-OVERVIEW.md` | 登记实现计划 | 修改 |
|
|
|
+
|
|
|
+### 与设计文档一致性的说明
|
|
|
+
|
|
|
+设计文档第四节写到 `app/schemas.py`,但实际健康方案 Pydantic 模型位于 **`app/models/health_plan.py`**(已存在 `HealthPlanSection`/`HealthPlanResponse`)。本计划按实际文件位置实现,不新建 `schemas.py`。请以本计划为准。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 1:LangGraph — PlanTask 模型与 tasks 契约
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-langgraph/app/models/health_plan.py`
|
|
|
+
|
|
|
+- [ ] **步骤 1:在 `HealthPlanSection` 增加 `tasks` 字段并新增 `PlanTask` 模型**
|
|
|
+
|
|
|
+在 `app/models/health_plan.py` 中加入:
|
|
|
+
|
|
|
+```python
|
|
|
+from typing import Literal
|
|
|
+
|
|
|
+class PlanTask(BaseModel):
|
|
|
+ action_type: Literal["buy", "read", "exercise", "checkin", "diet", "activity"]
|
|
|
+ title: str
|
|
|
+ dimension: Literal["body", "mind", "wisdom", "action", "wealth"]
|
|
|
+ frequency: Literal["once", "daily"] = "daily"
|
|
|
+ notes: Optional[str] = None
|
|
|
+
|
|
|
+
|
|
|
+class HealthPlanSection(BaseModel):
|
|
|
+ key: str # "nutrition" | "diet" | "exercise"
|
|
|
+ title: str
|
|
|
+ content: str # markdown text
|
|
|
+ items: List[dict] = [] # structured items for UI rendering
|
|
|
+ tasks: List[PlanTask] = [] # machine-readable task intents
|
|
|
+
|
|
|
+
|
|
|
+class HealthPlanResponse(BaseModel):
|
|
|
+ overview: str = ""
|
|
|
+ sections: List[HealthPlanSection] = []
|
|
|
+ abnormal_indicators: List[dict] = []
|
|
|
+ knowledge_sources: List[dict] = []
|
|
|
+```
|
|
|
+
|
|
|
+注意:`from typing import Optional, List` 已在文件顶部。
|
|
|
+
|
|
|
+- [ ] **步骤 2:编写失败测试**
|
|
|
+
|
|
|
+创建 `cfc-langgraph/tests/test_health_plan.py`:
|
|
|
+
|
|
|
+```python
|
|
|
+from app.models.health_plan import PlanTask, HealthPlanSection, HealthPlanResponse
|
|
|
+
|
|
|
+
|
|
|
+def test_plan_task_valid():
|
|
|
+ t = PlanTask(action_type="buy", title="购买维生素D3", dimension="wealth", frequency="once")
|
|
|
+ assert t.frequency == "once"
|
|
|
+ assert t.notes is None
|
|
|
+
|
|
|
+
|
|
|
+def test_plan_task_default_frequency_daily():
|
|
|
+ t = PlanTask(action_type="exercise", title="每周跑步3次", dimension="body")
|
|
|
+ assert t.frequency == "daily"
|
|
|
+
|
|
|
+
|
|
|
+def test_plan_task_invalid_action_type():
|
|
|
+ import pytest
|
|
|
+ from pydantic import ValidationError
|
|
|
+ with pytest.raises(ValidationError):
|
|
|
+ PlanTask(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")
|
|
|
+
|
|
|
+
|
|
|
+def test_plan_response_with_tasks():
|
|
|
+ resp = HealthPlanResponse(
|
|
|
+ overview="总览",
|
|
|
+ sections=[HealthPlanSection(
|
|
|
+ key="nutrition", title="营养补充", content="## 建议",
|
|
|
+ tasks=[PlanTask(action_type="buy", title="购买鱼油", dimension="wealth", frequency="once")]
|
|
|
+ )]
|
|
|
+ )
|
|
|
+ assert resp.sections[0].tasks[0].action_type == "buy"
|
|
|
+ # 序列化后 tasks 字段存在
|
|
|
+ data = resp.model_dump()
|
|
|
+ assert "tasks" in data["sections"][0]
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:运行测试验证失败**
|
|
|
+
|
|
|
+运行:`cd cfc-langgraph && python -m pytest tests/test_health_plan.py -v`
|
|
|
+预期:FAIL,报错 `ModuleNotFoundError`(尚无 PlanTask)或 assert 失败。
|
|
|
+
|
|
|
+- [ ] **步骤 4:运行测试验证通过**
|
|
|
+
|
|
|
+预期上述测试全部 PASS(模型定义后)。
|
|
|
+
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-langgraph/app/models/health_plan.py cfc-langgraph/tests/test_health_plan.py
|
|
|
+git commit -m "feat(langgraph): 健康方案 tasks 模型(PlanTask + section.tasks)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 2:LangGraph — PLAN_SYSTEM_PROMPT 追加 tasks 契约
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-langgraph/app/api/adapter.py`(`PLAN_SYSTEM_PROMPT`,约 586-628 行)
|
|
|
+
|
|
|
+- [ ] **步骤 1:在 PLAN_SYSTEM_PROMPT 的 section 结构中追加 tasks 字段说明**
|
|
|
+
|
|
|
+在 `PLAN_SYSTEM_PROMPT` 的三个 section 示例(nutrition/diet/exercise)后、`abnormal_indicators` 前,追加一个 tasks 契约说明。将现有 `"items": [...]` 行修改为同时包含 `tasks` 示例:
|
|
|
+
|
|
|
+```python
|
|
|
+PLAN_SYSTEM_PROMPT = """你是一个专业的家庭健康方案规划师。根据用户提供的健康数据和目标,生成结构化的健康改善方案。
|
|
|
+
|
|
|
+## 输出格式(必须输出合法 JSON,不要有其他内容)
|
|
|
+
|
|
|
+{
|
|
|
+ "overview": "总体概述(100字以内,说明方案目标和核心策略)",
|
|
|
+ "sections": [
|
|
|
+ {
|
|
|
+ "key": "nutrition",
|
|
|
+ "title": "营养补充建议",
|
|
|
+ "content": "Markdown 格式的详细内容",
|
|
|
+ "items": [
|
|
|
+ {"name": "产品名", "dosage": "用量", "timing": "服用时间", "reason": "推荐理由"}
|
|
|
+ ],
|
|
|
+ "tasks": [
|
|
|
+ {
|
|
|
+ "action_type": "buy",
|
|
|
+ "title": "购买维生素D3补充剂",
|
|
|
+ "dimension": "wealth",
|
|
|
+ "frequency": "once",
|
|
|
+ "notes": "每日一粒,随餐服用"
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "key": "diet",
|
|
|
+ "title": "饮食建议",
|
|
|
+ "content": "Markdown 格式的餐饮建议",
|
|
|
+ "items": [{"meal": "餐型", "food": "食物建议", "notes": "注意事项"}],
|
|
|
+ "tasks": [
|
|
|
+ {
|
|
|
+ "action_type": "diet",
|
|
|
+ "title": "早餐增加高蛋白与膳食纤维",
|
|
|
+ "dimension": "body",
|
|
|
+ "frequency": "daily",
|
|
|
+ "notes": ""
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "key": "exercise",
|
|
|
+ "title": "运动计划",
|
|
|
+ "content": "Markdown 格式的运动建议",
|
|
|
+ "items": [{"type": "运动类型", "duration": "时长", "frequency": "频率", "notes": "注意事项"}],
|
|
|
+ "tasks": [
|
|
|
+ {
|
|
|
+ "action_type": "exercise",
|
|
|
+ "title": "每周3次有氧运动,每次30分钟",
|
|
|
+ "dimension": "body",
|
|
|
+ "frequency": "daily",
|
|
|
+ "notes": ""
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "abnormal_indicators": [
|
|
|
+ {"member": "姓名", "indicator": "指标名", "value": "值", "unit": "单位", "suggestion": "建议"}
|
|
|
+ ]
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## tasks 字段约定
|
|
|
+- 每个 section 的 `tasks` 是该 section 中"可执行的行动项"列表,与 `content`(人类可读 Markdown)分离。
|
|
|
+- `action_type` 取值仅限:`buy`(购买/补充产品)、`read`(阅读)、`exercise`(运动)、`checkin`(打卡/记录)、`diet`(饮食)、`activity`(活动/社交)。
|
|
|
+- `dimension` 取值仅限五维:`body`/`mind`/`wisdom`/`action`/`wealth`。
|
|
|
+- `frequency`:`once`=一次性任务;`daily`=每日重复任务。
|
|
|
+- `title` 是最终写入任务系统的标题,必须是**具体可执行的动作**,不要写纯原理/机制描述。
|
|
|
+- 若某 section 没有可执行的行动项,`tasks` 输出空数组 `[]`。
|
|
|
+
|
|
|
+## 原则
|
|
|
+1. 基于实际数据给出建议,不编造
|
|
|
+2. 引用知识库内容时标注来源
|
|
|
+3. 建议要具体可执行,避免空泛
|
|
|
+4. 营养补充部分要具体到产品类型和用量
|
|
|
+5. 严重健康问题建议咨询医生
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:运行测试验证无回归**
|
|
|
+
|
|
|
+运行:`cd cfc-langgraph && python -m pytest tests/test_health_plan.py -v`
|
|
|
+预期:全部 PASS(prompt 改动不影响模型测试)。
|
|
|
+
|
|
|
+- [ ] **步骤 3:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-langgraph/app/api/adapter.py
|
|
|
+git commit -m "feat(langgraph): 健康方案 prompt 追加 tasks 输出契约"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 3:LangGraph — health_plan_generate 用 Pydantic 校验 + 正则兜底
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-langgraph/app/api/adapter.py`(`health_plan_generate`,约 754-770 行)
|
|
|
+- 测试:`cfc-langgraph/tests/test_health_plan.py`
|
|
|
+
|
|
|
+- [ ] **步骤 1:提取纯解析函数 `_parse_plan_response`(可测试,无 IO)**
|
|
|
+
|
|
|
+在 `app/api/adapter.py` 中,`health_plan_generate` 函数定义之前新增模块级函数:
|
|
|
+
|
|
|
+```python
|
|
|
+import re
|
|
|
+from app.models.health_plan import PlanResponse
|
|
|
+
|
|
|
+# 从 LLM 原始输出中提取 tasks 的正则兜底:在 content 文本里找形如
|
|
|
+# "1. 动作(动词/名词)..." 的行。以可识别的动作词开头视为潜在任务。
|
|
|
+_TASK_FALLBACK_RE = re.compile(r"^\s*(?:\d+[\.、)]|\-\s*)\s*"
|
|
|
+ r"(?:(?:购买|购置|阅读|看|运动|锻炼|跑步|散步|打卡|记录|饮食|吃|少|多|活动|参加|亲子).*)$")
|
|
|
+
|
|
|
+
|
|
|
+def _parse_plan_response(answer: str) -> dict:
|
|
|
+ """解析 LLM 原始输出为 PlanResponse;非法 JSON 或校验失败时用正则兜底提取 tasks。
|
|
|
+
|
|
|
+ 返回 dict:{"success": bool, "data": {...}, "error": str|None}
|
|
|
+ """
|
|
|
+ import json
|
|
|
+ start = answer.find("{")
|
|
|
+ end = answer.rfind("}") + 1
|
|
|
+ if start >= 0 and end > start:
|
|
|
+ try:
|
|
|
+ parsed = json.loads(answer[start:end])
|
|
|
+ resp = PlanResponse.model_validate(parsed)
|
|
|
+ return {"success": True, "data": resp.model_dump(), "error": None}
|
|
|
+ except Exception as e:
|
|
|
+ # 校验失败:尝试正则兜底
|
|
|
+ fallback = _fallback_extract_tasks(parsed if isinstance(parsed, dict) else {})
|
|
|
+ if fallback is not None:
|
|
|
+ return {"success": True, "data": fallback, "error": str(e)}
|
|
|
+ return {"success": False, "data": {"raw": answer, "overview": answer[:200]},
|
|
|
+ "error": str(e)}
|
|
|
+ return {"success": False, "data": {"raw": answer, "overview": answer[:200]},
|
|
|
+ "error": "no JSON found"}
|
|
|
+
|
|
|
+
|
|
|
+def _fallback_extract_tasks(parsed: dict) -> Optional[dict]:
|
|
|
+ """当 LLM 输出缺 tasks 或校验失败时,从各 section.content 用正则提取任务并回填。
|
|
|
+ 任一 section 无 tasks 才触发;全部已含 tasks 则返回 None(表示无需兜底)。"""
|
|
|
+ if not isinstance(parsed, dict):
|
|
|
+ return None
|
|
|
+ sections = parsed.get("sections")
|
|
|
+ if not isinstance(sections, list) or not sections:
|
|
|
+ return None
|
|
|
+ changed = False
|
|
|
+ for sec in sections:
|
|
|
+ if not isinstance(sec, dict):
|
|
|
+ continue
|
|
|
+ tasks = sec.get("tasks")
|
|
|
+ if isinstance(tasks, list) and tasks:
|
|
|
+ continue # 已有 tasks,跳过
|
|
|
+ content = sec.get("content", "")
|
|
|
+ extracted = []
|
|
|
+ for raw in content.split("\n"):
|
|
|
+ line = raw.strip()
|
|
|
+ if not line:
|
|
|
+ continue
|
|
|
+ m = _TASK_FALLBACK_RE.match(line)
|
|
|
+ if not m:
|
|
|
+ continue
|
|
|
+ # 去掉行首编号/项目符号
|
|
|
+ title = re.sub(r"^\s*(?:\d+[\.、)]|\-\s*)\s*", "", line).strip()
|
|
|
+ if not title:
|
|
|
+ continue
|
|
|
+ action = _classify_action(title)
|
|
|
+ if action is None:
|
|
|
+ continue
|
|
|
+ extracted.append({
|
|
|
+ "action_type": action["action_type"],
|
|
|
+ "title": title,
|
|
|
+ "dimension": action["dimension"],
|
|
|
+ "frequency": action["frequency"],
|
|
|
+ "notes": "",
|
|
|
+ })
|
|
|
+ if extracted:
|
|
|
+ sec["tasks"] = extracted
|
|
|
+ changed = True
|
|
|
+ if changed:
|
|
|
+ return parsed
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+def _classify_action(title: str) -> Optional[dict]:
|
|
|
+ """按动作词分类,映射到 action_type + 五维维度 + 频率(与 Java classifyTaskLine 对齐)。"""
|
|
|
+ if re.search(r"购买|购置|采购|下单|买入|囤|选购", title):
|
|
|
+ return {"action_type": "buy", "dimension": "wealth", "frequency": "once"}
|
|
|
+ if re.search(r"阅读|看|读书", title):
|
|
|
+ return {"action_type": "read", "dimension": "wisdom", "frequency": "daily"}
|
|
|
+ if re.search(r"运动|锻炼|跑步|散步|健身|瑜伽|拉伸", title):
|
|
|
+ return {"action_type": "exercise", "dimension": "body", "frequency": "daily"}
|
|
|
+ if re.search(r"打卡|记录|复盘|记", title):
|
|
|
+ return {"action_type": "checkin", "dimension": "mind", "frequency": "daily"}
|
|
|
+ if re.search(r"饮食|吃|少|多|餐|营养|水", title):
|
|
|
+ return {"action_type": "diet", "dimension": "body", "frequency": "daily"}
|
|
|
+ if re.search(r"活动|参加|亲子|社交|户外|游戏", title):
|
|
|
+ return {"action_type": "activity", "dimension": "action", "frequency": "daily"}
|
|
|
+ return None
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:编写失败测试**
|
|
|
+
|
|
|
+在 `tests/test_health_plan.py` 末尾追加:
|
|
|
+
|
|
|
+```python
|
|
|
+from app.api.adapter import _parse_plan_response, _fallback_extract_tasks
|
|
|
+
|
|
|
+
|
|
|
+def test_parse_plan_response_valid():
|
|
|
+ answer = '{"overview":"o","sections":[{"key":"diet","title":"t","content":"c","items":[],"tasks":[{"action_type":"diet","title":"少油少盐","dimension":"body","frequency":"daily"}]}],"abnormal_indicators":[]}'
|
|
|
+ res = _parse_plan_response(answer)
|
|
|
+ assert res["success"] is True
|
|
|
+ assert res["data"]["sections"][0]["tasks"][0]["action_type"] == "diet"
|
|
|
+
|
|
|
+
|
|
|
+def test_parse_plan_response_invalid_action_falls_back():
|
|
|
+ # LLM 输出含非法 action_type("cook"),校验失败但可被 fallback 处理
|
|
|
+ answer = '{"overview":"o","sections":[{"key":"diet","title":"t","content":"1. 少油少盐\\n2. 多吃蔬菜\\n3. 纯原理描述无动作","items":[]}],"abnormal_indicators":[]}'
|
|
|
+ res = _parse_plan_response(answer)
|
|
|
+ # fallback 从 content 提取动作行
|
|
|
+ assert res["success"] is True
|
|
|
+ tasks = res["data"]["sections"][0].get("tasks", [])
|
|
|
+ assert len(tasks) >= 2
|
|
|
+
|
|
|
+
|
|
|
+def test_parse_plan_response_no_json():
|
|
|
+ res = _parse_plan_response("这是纯文本没有 JSON")
|
|
|
+ assert res["success"] is False
|
|
|
+
|
|
|
+
|
|
|
+def test_fallback_skips_when_tasks_exist():
|
|
|
+ parsed = {"sections": [{"key": "diet", "title": "t", "content": "1. 少油少盐", "tasks": [{"action_type": "diet", "title": "已有", "dimension": "body"}]}]}
|
|
|
+ assert _fallback_extract_tasks(parsed) is None
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:运行测试验证失败**
|
|
|
+
|
|
|
+运行:`cd cfc-langgraph && python -m pytest tests/test_health_plan.py -v`
|
|
|
+预期:新增测试 FAIL,报错 `ImportError`(`_parse_plan_response` 尚未定义)。
|
|
|
+
|
|
|
+- [ ] **步骤 4:实现生成逻辑,用 `_parse_plan_response` 替代手动解析**
|
|
|
+
|
|
|
+将 `health_plan_generate` 中 `try` 块内的解析逻辑(当前 757-767 行)替换为:
|
|
|
+
|
|
|
+```python
|
|
|
+ try:
|
|
|
+ response = await llm.ainvoke(messages)
|
|
|
+ answer = response.content
|
|
|
+ result = _parse_plan_response(answer)
|
|
|
+ if result["success"]:
|
|
|
+ return {"success": True, "data": result["data"], "parse_error": result["error"]}
|
|
|
+ return {"success": True, "data": result["data"], "parse_error": result["error"]}
|
|
|
+ except Exception as e:
|
|
|
+ logger.error("方案生成失败: %s", e)
|
|
|
+ return {"success": False, "error": str(e)}
|
|
|
+```
|
|
|
+
|
|
|
+(保持返回结构兼容:成功时 `data` 为结构化 dict;失败时 `data.raw` 为原始文本,前端仍可降级兼容。)
|
|
|
+
|
|
|
+确认 `adapter.py` 顶部 imports 含 `Optional`、`PlanResponse`(`from app.models.health_plan import PlanResponse`)与 `re`。
|
|
|
+
|
|
|
+- [ ] **步骤 5:运行测试验证通过**
|
|
|
+
|
|
|
+运行:`cd cfc-langgraph && python -m pytest tests/test_health_plan.py -v`
|
|
|
+预期:全部 PASS。
|
|
|
+
|
|
|
+- [ ] **步骤 6:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-langgraph/app/models/health_plan.py cfc-langgraph/app/api/adapter.py cfc-langgraph/tests/test_health_plan.py
|
|
|
+git commit -m "feat(langgraph): 健康方案生成 Pydantic 校验 + 正则兜底提取 tasks"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 4:LangGraph — regenerate-section 返回 {content, tasks}
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-langgraph/app/api/adapter.py`(`health_plan_regenerate`,约 773-804 行)
|
|
|
+
|
|
|
+- [ ] **步骤 1:修改 `REGENERATE_SECTION_SYSTEM_PROMPT` 让 LLM 输出 JSON {content, tasks}**
|
|
|
+
|
|
|
+将 `REGENERATE_SECTION_SYSTEM_PROMPT`(630-638 行)替换为:
|
|
|
+
|
|
|
+```python
|
|
|
+REGENERATE_SECTION_SYSTEM_PROMPT = """你是一个家庭健康方案规划师。根据用户反馈重新生成指定部分内容。
|
|
|
+
|
|
|
+## 输出格式(必须输出合法 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 输出 []
|
|
|
+
|
|
|
+## 原则
|
|
|
+- 保持与原格式一致
|
|
|
+- 结合用户反馈进行修改
|
|
|
+- 建议要具体可执行"""
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:修改 `health_plan_regenerate` 解析 JSON 并返回 {content, tasks}**
|
|
|
+
|
|
|
+将 `health_plan_regenerate` 末尾的调用块(799-804 行)替换为:
|
|
|
+
|
|
|
+```python
|
|
|
+ try:
|
|
|
+ response = await llm.ainvoke([SystemMessage(content=prompt)])
|
|
|
+ answer = response.content
|
|
|
+ import json
|
|
|
+ start = answer.find("{")
|
|
|
+ end = answer.rfind("}") + 1
|
|
|
+ content = answer
|
|
|
+ tasks = []
|
|
|
+ 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 []
|
|
|
+ # 用 PlanTask 校验,非法条目丢弃
|
|
|
+ from app.models.health_plan import 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}
|
|
|
+ except Exception as e:
|
|
|
+ logger.error("重新生成 section 失败: %s", e)
|
|
|
+ return {"success": False, "error": str(e)}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:更新测试,覆盖重生成返回结构**
|
|
|
+
|
|
|
+在 `tests/test_health_plan.py` 追加(校验 `PlanTask` 模型在重生成中的过滤逻辑):
|
|
|
+
|
|
|
+```python
|
|
|
+def test_regenerate_tasks_validation_filter():
|
|
|
+ from app.models.health_plan import PlanTask
|
|
|
+ raw_tasks = [
|
|
|
+ {"action_type": "diet", "title": "少油少盐", "dimension": "body", "frequency": "daily"},
|
|
|
+ {"action_type": "cook", "title": "非法", "dimension": "body"}, # 非法 action,应被丢弃
|
|
|
+ ]
|
|
|
+ valid = []
|
|
|
+ for t in raw_tasks:
|
|
|
+ try:
|
|
|
+ valid.append(PlanTask.model_validate(t).model_dump())
|
|
|
+ except Exception:
|
|
|
+ continue
|
|
|
+ assert len(valid) == 1
|
|
|
+ assert valid[0]["action_type"] == "diet"
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 4:运行测试验证通过**
|
|
|
+
|
|
|
+运行:`cd cfc-langgraph && python -m pytest tests/test_health_plan.py -v`
|
|
|
+预期:全部 PASS。
|
|
|
+
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-langgraph/app/api/adapter.py cfc-langgraph/tests/test_health_plan.py
|
|
|
+git commit -m "feat(langgraph): 方案 section 重生成返回 {content, tasks} 结构化"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 5:Java — generateDailyTasksFromPlan 优先读 tasks + 懒加载回填
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java`
|
|
|
+
|
|
|
+**设计要点:** `generateDailyTasksFromPlan`(264-375 行)目前从 `plan_content` 正则提取。改造为优先读 `plan_json.sections[].tasks`;该 section 无 tasks 时用正则兜底并回填落库。
|
|
|
+
|
|
|
+- [ ] **步骤 1:给 `TaskDraft` 增加 `frequency` 字段**
|
|
|
+
|
|
|
+将 `TaskDraft` 静态内部类(452-462 行)改为:
|
|
|
+
|
|
|
+```java
|
|
|
+ // 待生成任务的草稿:标题 + 类别中文标签 + 五维维度码 + 频率(once/daily)+ 父任务ID(购买→使用链)
|
|
|
+ private static class TaskDraft {
|
|
|
+ final String title;
|
|
|
+ final String categoryLabel;
|
|
|
+ final String dimensionCode;
|
|
|
+ final String frequency; // "once" | "daily"
|
|
|
+ Long parentTaskId;
|
|
|
+ TaskDraft(String title, String categoryLabel, String dimensionCode, String frequency) {
|
|
|
+ this.title = title;
|
|
|
+ this.categoryLabel = categoryLabel;
|
|
|
+ this.dimensionCode = dimensionCode;
|
|
|
+ this.frequency = frequency;
|
|
|
+ }
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+**注意:** `classifyTaskLine` 中所有 `new TaskDraft(...)` 调用(483、489、495、498、501、504、507 行)需同步补第四个参数 `frequency`:
|
|
|
+- 购买任务(483 行):`"once"`
|
|
|
+- 使用/服用子任务(489 行):`"daily"`
|
|
|
+- 其余(阅读/运动/打卡/饮食/活动):`"daily"`
|
|
|
+
|
|
|
+- [ ] **步骤 2:改写 `generateDailyTasksFromPlan` 为"优先 tasks、兜底正则、懒加载回填"**
|
|
|
+
|
|
|
+将当前方法体(264-375 行)开头部分(从方法声明到 `for (String raw : rawLines)` 之前)替换为:
|
|
|
+
|
|
|
+```java
|
|
|
+ private void generateDailyTasksFromPlan(HealthPlan plan) {
|
|
|
+ String content = plan.getPlanContent();
|
|
|
+ if (content == null || content.trim().isEmpty()) return;
|
|
|
+
|
|
|
+ List<String> rawLines = new java.util.ArrayList<>();
|
|
|
+ // 优先使用 plan_json.sections[].tasks
|
|
|
+ List<TaskDraft> structuredDrafts = buildDraftsFromPlanJson(plan);
|
|
|
+ boolean usedStructured = !structuredDrafts.isEmpty();
|
|
|
+ if (structuredDrafts.isEmpty()) {
|
|
|
+ rawLines = parseTaskLines(content);
|
|
|
+ }
|
|
|
+ if (usedStructured && structuredDrafts.isEmpty()) return;
|
|
|
+ if (!usedStructured && rawLines.isEmpty()) return;
|
|
|
+
|
|
|
+ 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;
|
|
|
+
|
|
|
+ // 当天截止 23:59:59(严格落在今日,避免存成次日 00:00 导致今日任务查不到)
|
|
|
+ Date deadline = buildTodayDeadline();
|
|
|
+
|
|
|
+ Date now = new Date();
|
|
|
+```
|
|
|
+
|
|
|
+然后将下方循环从 `for (String raw : rawLines)` 改为统一遍历草稿列表:
|
|
|
+
|
|
|
+```java
|
|
|
+ // 统一生成:structured 分支直接用草稿;regex 分支按行分类
|
|
|
+ List<TaskDraft> drafts = new java.util.ArrayList<>();
|
|
|
+ if (usedStructured) {
|
|
|
+ drafts = structuredDrafts;
|
|
|
+ } else {
|
|
|
+ for (String raw : rawLines) {
|
|
|
+ String line = raw.trim();
|
|
|
+ if (line.isEmpty()) continue;
|
|
|
+ List<TaskDraft> cls = classifyTaskLine(line);
|
|
|
+ if (cls != null && !cls.isEmpty()) drafts.addAll(cls);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 购买→使用子任务链的父任务ID(一次性购买任务完成后派生每日使用/服用)
|
|
|
+ Long buyTaskId = null;
|
|
|
+
|
|
|
+ for (TaskDraft draft : drafts) {
|
|
|
+ // 幂等:同一方案今天已有同名任务则跳过(deadline 限定当天)
|
|
|
+ Calendar todayStart = Calendar.getInstance();
|
|
|
+ todayStart.set(Calendar.HOUR_OF_DAY, 0); todayStart.set(Calendar.MINUTE, 0);
|
|
|
+ todayStart.set(Calendar.SECOND, 0); todayStart.set(Calendar.MILLISECOND, 0);
|
|
|
+ Calendar todayEnd = (Calendar) todayStart.clone();
|
|
|
+ todayEnd.add(Calendar.DAY_OF_MONTH, 1);
|
|
|
+
|
|
|
+ boolean exists = taskMapper.selectCount(new QueryWrapper<Task>()
|
|
|
+ .eq("family_id", familyId)
|
|
|
+ .eq("source_type", "plan")
|
|
|
+ .eq("source_id", plan.getId())
|
|
|
+ .eq("title", draft.title)
|
|
|
+ .ge("deadline", todayStart.getTime())
|
|
|
+ .lt("deadline", todayEnd.getTime())) > 0;
|
|
|
+ if (exists) {
|
|
|
+ if ("购买任务".equals(draft.categoryLabel) && buyTaskId == null) {
|
|
|
+ Task existingBuy = taskMapper.selectOne(new QueryWrapper<Task>()
|
|
|
+ .eq("family_id", familyId)
|
|
|
+ .eq("source_type", "plan")
|
|
|
+ .eq("source_id", plan.getId())
|
|
|
+ .eq("title", draft.title)
|
|
|
+ .eq("category", "购买任务")
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ if (existingBuy != null) buyTaskId = existingBuy.getId();
|
|
|
+ }
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ // ...(保留原有 Task 组装逻辑,唯一改动:repeatType/taskType/frequency 改用 draft.frequency)
|
|
|
+ }
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+**频率映射(替换原 342-372 行中的硬编码):**
|
|
|
+- 用一个开关 `String repeatType = "once".equals(draft.frequency) ? "none" : "daily";` 和 `String taskType = "once".equals(draft.frequency) ? "onetime" : "recurring";`、`String freq = "once".equals(draft.frequency) ? null : "daily";`
|
|
|
+- 购买任务(`"购买任务"` 类别):原本就是 once,命中 `frequency=once` → 与 draft.frequency 一致。
|
|
|
+- 使用任务/常规任务:draft.frequency=daily。
|
|
|
+
|
|
|
+在原有三种分支(购买/使用/常规)中,统一用上述计算值替代写死的 `"none"`/`"onetime"`、`"daily"`/`"recurring"`/`"daily"`。
|
|
|
+
|
|
|
+- [ ] **步骤 3:新增 `buildDraftsFromPlanJson` 与懒加载回填**
|
|
|
+
|
|
|
+在 `HealthPlanServiceImpl` 中新增方法:
|
|
|
+
|
|
|
+```java
|
|
|
+ /**
|
|
|
+ * 从 plan_json.sections[].tasks 构建任务草稿。返回 tasks 草稿列表;
|
|
|
+ * 若某 section 无 tasks,则用行分类逻辑兜底并回填 plan_json(懒加载),落库。
|
|
|
+ */
|
|
|
+ private List<TaskDraft> buildDraftsFromPlanJson(HealthPlan plan) {
|
|
|
+ List<TaskDraft> drafts = new java.util.ArrayList<>();
|
|
|
+ String planJsonStr = plan.getPlanJson();
|
|
|
+ if (planJsonStr == null || planJsonStr.trim().isEmpty()) return drafts;
|
|
|
+
|
|
|
+ try {
|
|
|
+ JsonNode root = objectMapper.readTree(planJsonStr);
|
|
|
+ JsonNode sections = root.path("sections");
|
|
|
+ if (!sections.isArray()) return drafts;
|
|
|
+
|
|
|
+ boolean backfilled = false;
|
|
|
+ for (JsonNode section : sections) {
|
|
|
+ JsonNode tasks = section.path("tasks");
|
|
|
+ if (tasks.isArray()) {
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 懒加载回填:section 无 tasks 时用正则从 content 提取并写回
|
|
|
+ if (tasks.isMissingNode() || (tasks.isArray() && tasks.size() == 0)) {
|
|
|
+ String sectionContent = section.path("content").asText("");
|
|
|
+ List<String> lines = parseTaskLines(sectionContent);
|
|
|
+ ArrayNode newTasks = objectMapper.createArrayNode();
|
|
|
+ for (String raw : lines) {
|
|
|
+ String line = raw.trim();
|
|
|
+ if (line.isEmpty()) continue;
|
|
|
+ List<TaskDraft> cls = classifyTaskLine(line);
|
|
|
+ if (cls == null) continue;
|
|
|
+ for (TaskDraft d : cls) {
|
|
|
+ ObjectNode node = objectMapper.createObjectNode();
|
|
|
+ node.put("action_type", actionTypeFromCategory(d.categoryLabel));
|
|
|
+ node.put("title", d.title);
|
|
|
+ node.put("dimension", d.dimensionCode);
|
|
|
+ node.put("frequency", d.frequency);
|
|
|
+ node.put("notes", "");
|
|
|
+ newTasks.add(node);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ((ObjectNode) section).set("tasks", newTasks);
|
|
|
+ backfilled = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (backfilled) {
|
|
|
+ plan.setPlanJson(objectMapper.writeValueAsString(root));
|
|
|
+ healthPlanMapper.updateById(plan);
|
|
|
+ log.info("方案{}懒加载回填 tasks 完成", plan.getId());
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("解析 plan_json 生成任务失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+ return drafts;
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+新增映射方法:
|
|
|
+
|
|
|
+```java
|
|
|
+ /** 将结构化 action_type/dimension/frequency 映射为 TaskDraft;非法类型返回 null。 */
|
|
|
+ private TaskDraft mapStructuredTask(String actionType, String title, String dimension, String frequency) {
|
|
|
+ String cat;
|
|
|
+ String dim;
|
|
|
+ String freq;
|
|
|
+ switch (actionType == null ? "" : actionType) {
|
|
|
+ case "buy": cat = "购买任务"; dim = "wealth"; freq = "once"; break;
|
|
|
+ case "read": cat = "阅读任务"; dim = "wisdom"; freq = "daily"; break;
|
|
|
+ case "exercise": cat = "运动任务"; dim = "body"; freq = "daily"; break;
|
|
|
+ case "checkin": cat = "打卡任务"; dim = "mind"; freq = "daily"; break;
|
|
|
+ case "diet": cat = "饮食任务"; dim = "body"; freq = "daily"; break;
|
|
|
+ case "activity": cat = "活动任务"; dim = "action"; freq = "daily"; break;
|
|
|
+ default: return null;
|
|
|
+ }
|
|
|
+ // dimension/frequency 显式值优先覆盖默认
|
|
|
+ if (dimension != null && !dimension.isEmpty()) dim = dimension;
|
|
|
+ if ("once".equals(frequency) || "daily".equals(frequency)) freq = frequency;
|
|
|
+ String finalFreq = freq;
|
|
|
+ String finalDim = dim;
|
|
|
+ return new TaskDraft(title, cat, finalDim, finalFreq);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 将 TaskDraft 的分类中文标签反解为 action_type 枚举(用于懒加载回填补全结构化字段)。 */
|
|
|
+ private String actionTypeFromCategory(String categoryLabel) {
|
|
|
+ if (categoryLabel == null) return "";
|
|
|
+ switch (categoryLabel) {
|
|
|
+ case "购买任务": return "buy";
|
|
|
+ case "阅读任务": return "read";
|
|
|
+ case "运动任务": return "exercise";
|
|
|
+ case "打卡任务": return "checkin";
|
|
|
+ case "饮食任务": return "diet";
|
|
|
+ case "活动任务": return "activity";
|
|
|
+ default: return "";
|
|
|
+ }
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+**注意:** `buy` 类型的购买任务原逻辑会在 classifyTaskLine 中派生"使用子任务"。structured 分支中 `buy` 只生成购买任务本身,不自动派生使用子任务(因为 LLM 的 tasks 已显式表达任务意图,派生逻辑属于正则启发式,仅在兜底路径保留)。这是**有意的简化**,符合"tasks 是唯一事实源"设计。
|
|
|
+
|
|
|
+- [ ] **步骤 4:检查 imports**
|
|
|
+
|
|
|
+确认 `HealthPlanServiceImpl.java` 已 import:
|
|
|
+- `com.fasterxml.jackson.databind.node.ObjectNode`、`ArrayNode`
|
|
|
+- `com.fasterxml.jackson.databind.JsonNode`(已有 `objectMapper.readTree` 用法,`objectMapper` 字段已存在)
|
|
|
+
|
|
|
+若无,在文件 import 区补充。
|
|
|
+
|
|
|
+- [ ] **步骤 5:运行编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS。
|
|
|
+
|
|
|
+- [ ] **步骤 6:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java
|
|
|
+git commit -m "feat(backend): 方案任务生成优先读 tasks + 正则兜底 + 懒加载回填"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 6:Java — 编译通过(含 TaskDraft 构造器兼容)
|
|
|
+
|
|
|
+**说明:** 任务 5 改了 `TaskDraft` 构造器签名,任务 6 是**验证所有调用点已同步**、整体可编译。
|
|
|
+
|
|
|
+- [ ] **步骤 1:全量编译**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS,无 `TaskDraft` 构造器参数不匹配错误。
|
|
|
+
|
|
|
+若编译报构造函数参数错误,逐个检查 `classifyTaskLine` 及 `buildDraftsFromPlanJson`/`mapStructuredTask` 中的 `new TaskDraft(...)` 调用,确保均为 4 参数。
|
|
|
+
|
|
|
+- [ ] **步骤 2:(可选)运行后端单测**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test`
|
|
|
+预期:既有测试通过(若只有 1 个测试类则无新失败)。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 7:cfc-web — HealthPlanReview.vue 结构化任务条目编辑
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-web/src/views/teacher/HealthPlanReview.vue`
|
|
|
+
|
|
|
+**设计要点:** 编辑弹窗在 `planContent` 纯文本下方新增"任务条目"区域,编辑 `plan_json.sections[].tasks`,保存时透传更新后的 `plan_json`(现有 `pending-review/update` 接口已是透传 planJson)。
|
|
|
+
|
|
|
+- [ ] **步骤 1:在编辑弹窗 `el-dialog` 中新增任务编辑表单**
|
|
|
+
|
|
|
+在 `HealthPlanReview.vue` 的编辑弹窗(line 50-63)内、`方案内容` 文本框之后追加:
|
|
|
+
|
|
|
+```html
|
|
|
+<el-form-item label="任务条目">
|
|
|
+ <div v-for="(sec, si) in editTasks.sections" :key="'sec'+si" style="margin-bottom:12px;border:1px solid #ebeef5;border-radius:4px;padding:8px;">
|
|
|
+ <div style="font-weight:600;margin-bottom:6px;">{{ sec.title || sec.key }}</div>
|
|
|
+ <div v-for="(task, ti) in sec.tasks" :key="'t'+si+'-'+ti" style="display:flex;gap:6px;margin-bottom:6px;align-items:center;">
|
|
|
+ <el-select v-model="task.action_type" size="mini" style="width:90px">
|
|
|
+ <el-option v-for="a in actionTypes" :key="a.value" :label="a.label" :value="a.value" />
|
|
|
+ </el-select>
|
|
|
+ <el-select v-model="task.dimension" size="mini" style="width:80px">
|
|
|
+ <el-option v-for="d in dimensions" :key="d.value" :label="d.label" :value="d.value" />
|
|
|
+ </el-select>
|
|
|
+ <el-select v-model="task.frequency" size="mini" style="width:70px">
|
|
|
+ <el-option label="一次性" value="once" />
|
|
|
+ <el-option label="每日" value="daily" />
|
|
|
+ </el-select>
|
|
|
+ <el-input v-model="task.title" size="mini" placeholder="任务标题" />
|
|
|
+ <el-input v-model="task.notes" size="mini" placeholder="备注" style="width:120px" />
|
|
|
+ <el-button size="mini" type="danger" icon="el-icon-delete" @click="removeTask(si, ti)" />
|
|
|
+ </div>
|
|
|
+ <el-button size="mini" type="text" icon="el-icon-plus" @click="addTask(si)">添加任务</el-button>
|
|
|
+ </div>
|
|
|
+</el-form-item>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:在 data() 增加状态**
|
|
|
+
|
|
|
+扩展 script 的 data():
|
|
|
+
|
|
|
+```js
|
|
|
+data() {
|
|
|
+ return {
|
|
|
+ // ...原有字段...
|
|
|
+ editTasks: { sections: [] },
|
|
|
+ actionTypes: [
|
|
|
+ { value: 'buy', label: '购买' },
|
|
|
+ { value: 'read', label: '阅读' },
|
|
|
+ { value: 'exercise', label: '运动' },
|
|
|
+ { value: 'checkin', label: '打卡' },
|
|
|
+ { value: 'diet', label: '饮食' },
|
|
|
+ { value: 'activity', label: '活动' }
|
|
|
+ ],
|
|
|
+ dimensions: [
|
|
|
+ { value: 'body', label: '身' },
|
|
|
+ { value: 'mind', label: '心' },
|
|
|
+ { value: 'wisdom', label: '智' },
|
|
|
+ { value: 'action', label: '行' },
|
|
|
+ { value: 'wealth', label: '富' }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:在 methods 增加加载/增删任务方法**
|
|
|
+
|
|
|
+在 methods 中新增:
|
|
|
+
|
|
|
+```js
|
|
|
+parsePlanJson(str) {
|
|
|
+ try {
|
|
|
+ return JSON.parse(str || '{}')
|
|
|
+ } catch (e) {
|
|
|
+ return {}
|
|
|
+ }
|
|
|
+},
|
|
|
+loadEditTasks(planJson) {
|
|
|
+ const parsed = this.parsePlanJson(planJson)
|
|
|
+ const sections = (parsed.sections || []).map(s => ({
|
|
|
+ key: s.key || '',
|
|
|
+ title: s.title || '',
|
|
|
+ tasks: (s.tasks || []).map(t => ({
|
|
|
+ action_type: t.action_type || 'diet',
|
|
|
+ title: t.title || '',
|
|
|
+ dimension: t.dimension || 'body',
|
|
|
+ frequency: t.frequency || 'daily',
|
|
|
+ notes: t.notes || ''
|
|
|
+ }))
|
|
|
+ }))
|
|
|
+ this.editTasks = { sections }
|
|
|
+},
|
|
|
+addTask(si) {
|
|
|
+ this.editTasks.sections[si].tasks.push({
|
|
|
+ action_type: 'diet', title: '', dimension: 'body', frequency: 'daily', notes: ''
|
|
|
+ })
|
|
|
+},
|
|
|
+removeTask(si, ti) {
|
|
|
+ this.editTasks.sections[si].tasks.splice(ti, 1)
|
|
|
+},
|
|
|
+buildPlanJsonWithTasks() {
|
|
|
+ const parsed = this.parsePlanJson(this.currentPlan.planJson)
|
|
|
+ const sections = parsed.sections || []
|
|
|
+ this.editTasks.sections.forEach(es => {
|
|
|
+ const target = sections.find(s => s.key === es.key)
|
|
|
+ if (!target) return
|
|
|
+ // 过滤空标题任务
|
|
|
+ target.tasks = es.tasks.filter(t => t.title && t.title.trim())
|
|
|
+ })
|
|
|
+ return JSON.stringify(parsed)
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 4:修改 `showEdit` 初始化 editTasks;`saveEdit` 回写 planJson**
|
|
|
+
|
|
|
+将 `showEdit`(139-146 行)改为:
|
|
|
+
|
|
|
+```js
|
|
|
+showEdit(row) {
|
|
|
+ this.currentPlan = row
|
|
|
+ this.editForm = {
|
|
|
+ planContent: row.planContent || '',
|
|
|
+ reviewComment: row.reviewComment || ''
|
|
|
+ }
|
|
|
+ this.loadEditTasks(row.planJson)
|
|
|
+ this.editVisible = true
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+将 `saveEdit`(147-167 行)中的请求参数改为用 `buildPlanJsonWithTasks()` 生成新 planJson:
|
|
|
+
|
|
|
+```js
|
|
|
+async saveEdit() {
|
|
|
+ this.saving = true
|
|
|
+ try {
|
|
|
+ const newPlanJson = this.buildPlanJsonWithTasks()
|
|
|
+ const res = await updatePlanContent({
|
|
|
+ planId: this.currentPlan.id,
|
|
|
+ planContent: this.editForm.planContent,
|
|
|
+ planJson: newPlanJson
|
|
|
+ })
|
|
|
+ if (res.code === 200) {
|
|
|
+ this.$message.success('保存成功')
|
|
|
+ this.editVisible = false
|
|
|
+ this.loadPlans()
|
|
|
+ } else {
|
|
|
+ this.$message.error(res.message || '保存失败')
|
|
|
+ }
|
|
|
+ } catch(e) {
|
|
|
+ this.$message.error('请求失败')
|
|
|
+ } finally {
|
|
|
+ this.saving = false
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 5:运行构建验证**
|
|
|
+
|
|
|
+运行:`cd cfc-web && npm run build`
|
|
|
+预期:BUILD SUCCESS,无语法/编译错误。
|
|
|
+
|
|
|
+- [ ] **步骤 6:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-web/src/views/teacher/HealthPlanReview.vue
|
|
|
+git commit -m "feat(web): 规划师端健康方案结构化任务条目编辑"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 8:cfc-frontend — submitPlan 补 tasks + regenerate 更新 tasks
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-frontend/pages/health/health-plan-summary.vue`(1741 行)
|
|
|
+
|
|
|
+**设计要点:** 修复 `submitPlan` 组装 planJson 漏写 `sections[].tasks` 的 bug;反馈式重生成接口返回 `{content, tasks}` 时前端同步更新。
|
|
|
+
|
|
|
+- [ ] **步骤 1:修复 `submitPlan` 组装 planJson 漏写 tasks 的 bug**
|
|
|
+
|
|
|
+`submitPlan`(834-842 行)当前的 section 组装:
|
|
|
+
|
|
|
+```js
|
|
|
+var planJson = JSON.stringify({
|
|
|
+ overview: self.planData.overview || '',
|
|
|
+ sections: self.sectionKeys.map(function(key) {
|
|
|
+ return { key: key, content: self.sections[key].content, items: self.sections[key].items || [] }
|
|
|
+ })
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+修复为在 section 对象中补上 `tasks`(透传该 section 已有的 tasks,无则空数组):
|
|
|
+
|
|
|
+```js
|
|
|
+var planJson = JSON.stringify({
|
|
|
+ overview: self.planData.overview || '',
|
|
|
+ sections: self.sectionKeys.map(function(key) {
|
|
|
+ return { key: key, content: self.sections[key].content, items: self.sections[key].items || [], tasks: self.sections[key].tasks || [] }
|
|
|
+ })
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+这样 planJson 的每个 section 都携带 `tasks`,保存后由后端 `buildDraftsFromPlanJson` 消费。
|
|
|
+
|
|
|
+- [ ] **步骤 2:适配 `regenerateCurrentSection` 回流 {content, tasks}**
|
|
|
+
|
|
|
+`regenerateCurrentSection`(789-815 行)当前成功后只更新 content:
|
|
|
+
|
|
|
+```js
|
|
|
+regenerateSection(params).then(function(res) {
|
|
|
+ self.loading = false
|
|
|
+ if (res && res.code === 200 && res.data) {
|
|
|
+ self.sections[key].content = res.data.content || res.data
|
|
|
+ uni.showToast({ title: '生成成功', icon: 'success' })
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: res && res.message ? res.message : '生成失败', icon: 'none' })
|
|
|
+ }
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+修改为同时更新 tasks(重生成接口返回 `{content, tasks}`):
|
|
|
+
|
|
|
+```js
|
|
|
+regenerateSection(params).then(function(res) {
|
|
|
+ self.loading = false
|
|
|
+ if (res && res.code === 200 && res.data) {
|
|
|
+ self.sections[key].content = res.data.content || res.data
|
|
|
+ if (res.data.tasks) {
|
|
|
+ self.sections[key].tasks = res.data.tasks
|
|
|
+ }
|
|
|
+ uni.showToast({ title: '生成成功', icon: 'success' })
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: res && res.message ? res.message : '生成失败', icon: 'none' })
|
|
|
+ }
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+**注意**:`sections[key]` 对象初始定义于 `data()`(约 400 行 `sections: { nutrition: {...}, diet: {...}, exercise: {...} }`)与 `loadHealthPlan`(761-765 行,只回填 content/items)。`tasks` 字段通过动态赋值加入 section 对象,无需预先声明。**禁止在小程序模板中使用可选链 `?.` 访问 tasks**(遵循 cfc-frontend/AGENTS.md 规范)。
|
|
|
+
|
|
|
+- [ ] **步骤 3:运行验证(HBuilderX 手动打包)**
|
|
|
+
|
|
|
+**注意:** 小程序禁止 Agent 自行 `npm run build:mp-weixin`。此改动需在 HBuilderX 中重新打包验证。计划仅做代码改动,打包验证列入手工验收项。
|
|
|
+
|
|
|
+- [ ] **步骤 4:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/health/health-plan-summary.vue
|
|
|
+git commit -m "fix(frontend): 健康方案 submitPlan 补 tasks 字段 + regenerate 更新 tasks"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 9:更新 PROJECT-OVERVIEW.md 并提交
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`docs/superpowers/PROJECT-OVERVIEW.md`
|
|
|
+
|
|
|
+- [ ] **步骤 1:登记实现计划**
|
|
|
+
|
|
|
+按 `docs/superpowers/AGENTS.md` 规范,在 PROJECT-OVERVIEW.md 中更新设计文档状态并登记计划。找到 `2026-08-28-plan-standard-content-design.md` 条目,将状态 `🟡 设计已确认` 更新为 `🟢 已实施`;在 plans 索引区登记本计划。
|
|
|
+
|
|
|
+- [ ] **步骤 2:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add docs/superpowers/PROJECT-OVERVIEW.md
|
|
|
+git commit -m "docs: 登记生成方案标准内容格式实现计划"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 手工验收
|
|
|
+
|
|
|
+| # | 验收点 | 操作 |
|
|
|
+|---|--------|------|
|
|
|
+| 1 | 新方案 AI 生成含 tasks | 家长端生成方案 → 检查 plan_json.sections[].tasks 非空 → 发布 → 任务正确生成且维度匹配 |
|
|
|
+| 2 | 存量无 tasks 老 planJson | 找一条无 tasks 的历史方案 → 首次发布/确认 → 自动回填 → 任务正常 |
|
|
|
+| 3 | 规划师编辑任务条目 | cfc-web 打开编辑 → 增删改任务 → 保存 → 重新发布反映改动 |
|
|
|
+| 4 | LLM 漏输出 tasks | 构造缺 tasks 的响应 → 正则兜底仍能生成任务 |
|
|
|
+| 5 | 小程序验证 | HBuilderX 打包 → 生成方案 → 确认 → 查看任务 |
|
|
|
+
|
|
|
+## 验证命令汇总
|
|
|
+
|
|
|
+| 模块 | 命令 |
|
|
|
+|---|---|
|
|
|
+| LangGraph | `cd cfc-langgraph && python -m pytest tests/test_health_plan.py -v` |
|
|
|
+| 后端 | `cd cfc-backend && mvn clean compile`(+ 可选 `mvn test`) |
|
|
|
+| cfc-web | `cd cfc-web && npm run build` |
|
|
|
+| 小程序 | HBuilderX 手动打包(禁止 Agent 自行打包) |
|