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

docs: 用户画像 prompt 拼装实现计划

iwt 3 недель назад
Родитель
Сommit
f79f23e6d0
1 измененных файлов с 801 добавлено и 0 удалено
  1. 801 0
      docs/superpowers/plans/2026-08-24-user-portrait-prompt.md

+ 801 - 0
docs/superpowers/plans/2026-08-24-user-portrait-prompt.md

@@ -0,0 +1,801 @@
+# 用户画像 prompt 拼装实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 在所有 AI 对话端点(chat、health-coach、butler、nutrition)中注入用户画像 prompt,实现个性化对话体验。
+
+**架构:**
+- Java 侧:`users` 表加 `portrait_prompt` 列,新增 `PortraitService` 和 `PortraitController`,在 4 个对话 Controller 方法中统一注入 portrait_prompt 到 inputs
+- Python 侧:新增 `portrait_service.py`,在 3 个 LangGraph(chat、health_coach、health_butler)的 generate_answer 节点中读取 portrait_prompt 并组装 SystemMessage;nutrition 端点走 Dify,由 Java 直接注入
+- 前端:小程序新增画像编辑页
+
+**技术栈:** Java 8 + Spring Boot、Python 3.10 + FastAPI + LangGraph、uni-app Vue 2
+
+---
+
+## 文件清单
+
+### Java 后端
+| 操作 | 路径 | 职责 |
+|------|------|------|
+| 修改 | `cfc-backend/src/main/resources/schema.sql` | users 表加 portrait_prompt 列 |
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` | 迁移脚本 |
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/entity/User.java` | 实体加 portraitPrompt 字段 |
+| 新增 | `cfc-backend/src/main/java/com/etotem/cfc/service/PortraitService.java` | 接口 |
+| 新增 | `cfc-backend/src/main/java/com/etotem/cfc/service/impl/PortraitServiceImpl.java` | 实现(拉 portrait + 渲染快照) |
+| 新增 | `cfc-backend/src/main/java/com/etotem/cfc/controller/user/PortraitController.java` | 两个 REST 端点 |
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java` | 4 个方法注入 portrait_prompt |
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java` | sendNutritionMessage 注入 portrait_prompt |
+
+### Python LangGraph
+| 操作 | 路径 | 职责 |
+|------|------|------|
+| 新增 | `cfc-langgraph/app/portrait_service.py` | get_user_portrait_text 函数 + 缓存 |
+| 修改 | `cfc-langgraph/app/graphs/chat_graph.py` | 注入画像段 |
+| 修改 | `cfc-langgraph/app/graphs/health_coach_graph.py` | 注入画像段 |
+| 修改 | `cfc-langgraph/app/graphs/health_butler_graph.py` | 注入画像段 |
+
+### 小程序前端
+| 操作 | 路径 | 职责 |
+|------|------|------|
+| 新增 | `cfc-frontend/pages/profile/portrait-edit.vue` | 画像编辑页 |
+| 修改 | `cfc-frontend/utils/api.js` | 新增 getPortrait/editPortrait 封装 |
+| 修改 | `cfc-frontend/pages.json` | 注册新页面 |
+
+---
+
+## 任务分解
+
+### 任务 1:Java 数据层(users 表加列)
+
+**文件:**
+- 修改:`cfc-backend/src/main/resources/schema.sql`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/User.java`
+
+- [ ] **步骤 1:在 schema.sql 追加 users 表列定义**
+
+找到 schema.sql 中 users 表的 CREATE TABLE 语句末尾(在 mascot 列之后),追加:
+```sql
+    portrait_prompt TEXT COMMENT '用户自定义画像 prompt(为空时自动从 profile_snapshot 渲染)',
+```
+
+先 grep 确认 mascot 列位置:
+```bash
+grep -n "mascot" cfc-backend/src/main/resources/schema.sql
+```
+
+- [ ] **步骤 2:在 DatabaseInitializer.runMigrations() 末尾追加迁移**
+
+在 DatabaseInitializer.java 中搜索 `// 迁移` 找最新编号,追加:
+```java
+// 迁移N: users 表添加 portrait_prompt 列(用户画像 prompt 拼装需求)
+try {
+    jdbcTemplate.execute("ALTER TABLE users ADD COLUMN portrait_prompt TEXT COMMENT '用户自定义画像 prompt'");
+    log.info("已添加 portrait_prompt 列到 users 表");
+} catch (Exception e) {
+    // 列已存在,忽略错误
+}
+```
+
+- [ ] **步骤 3:在 User.java 实体加字段**
+
+在 `private String mascot;` 之后追加:
+```java
+@TableField("portrait_prompt")
+private String portraitPrompt;
+```
+同时生成 getter/setter(IntelliJ 快捷键 Alt+Insert 或手写)。
+
+- [ ] **步骤 4:编译验证**
+
+```bash
+cd cfc-backend && /bwydata/maven/bin/mvn clean compile -q > /tmp/opencode/t1.log 2>&1; M=$?; tail -3 /tmp/opencode/t1.log; test $M -eq 0 && echo MVN_OK
+```
+预期:BUILD SUCCESS
+
+- [ ] **步骤 5:Commit**
+
+```bash
+GIT_MASTER=1 git add cfc-backend/src/main/resources/schema.sql \
+  cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java \
+  cfc-backend/src/main/java/com/etotem/cfc/entity/User.java
+GIT_MASTER=1 git commit -m "feat: users 表添加 portrait_prompt 列"
+```
+
+---
+
+### 任务 2:Java PortraitService + Controller
+
+**文件:**
+- 新增:`cfc-backend/src/main/java/com/etotem/cfc/service/PortraitService.java`
+- 新增:`cfc-backend/src/main/java/com/etotem/cfc/service/impl/PortraitServiceImpl.java`
+- 新增:`cfc-backend/src/main/java/com/etotem/cfc/controller/user/PortraitController.java`
+
+- [ ] **步骤 1:创建 PortraitService 接口**
+
+```java
+package com.etotem.cfc.service;
+
+import java.util.Map;
+
+public interface PortraitService {
+    /** 获取用户画像文本(自定义 prompt + 快照渲染) */
+    String buildPortraitPrompt(Long userId, Long memberId);
+    /** 更新用户自定义画像 prompt */
+    void updatePortraitPrompt(Long userId, String portraitPrompt);
+    /** 读取用户画像(含渲染快照,用于前端展示) */
+    Map<String, Object> getPortrait(Long userId, Long targetUserId, Long memberId);
+}
+```
+
+- [ ] **步骤 2:创建 PortraitServiceImpl**
+
+```java
+package com.etotem.cfc.service.impl;
+
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.PortraitService;
+import com.etotem.cfc.service.ProfileReadService;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.Map;
+
+@Service
+public class PortraitServiceImpl implements PortraitService {
+
+    @Resource
+    private UserMapper userMapper;
+    @Resource
+    private ProfileReadService profileReadService;
+
+    @Override
+    public String buildPortraitPrompt(Long userId, Long memberId) {
+        User user = userMapper.selectById(userId);
+        if (user == null) return null;
+        String custom = user.getPortraitPrompt();
+        if (custom == null || custom.trim().isEmpty()) {
+            return renderSnapshot(memberId);
+        }
+        String snapshot = renderSnapshot(memberId);
+        if (snapshot == null || snapshot.trim().isEmpty()) {
+            return custom.trim();
+        }
+        return custom.trim() + "\n\n---参考指标---\n" + snapshot;
+    }
+
+    @Override
+    public void updatePortraitPrompt(Long userId, String portraitPrompt) {
+        User user = new User();
+        user.setId(userId);
+        user.setPortraitPrompt(portraitPrompt);
+        userMapper.updateById(user);
+    }
+
+    @Override
+    public Map<String, Object> getPortrait(Long userId, Long targetUserId, Long memberId) {
+        Long effectiveUserId = (targetUserId != null) ? targetUserId : userId;
+        User user = userMapper.selectById(effectiveUserId);
+        Map<String, Object> result = new HashMap<>();
+        result.put("portraitPrompt", user != null ? user.getPortraitPrompt() : null);
+        if (memberId != null) {
+            Map<String, Object> profile = profileReadService.getProfile(memberId);
+            result.put("renderedSnapshot", renderSnapshotFull(profile));
+        }
+        return result;
+    }
+
+    private String renderSnapshot(Long memberId) {
+        if (memberId == null) return null;
+        Map<String, Object> profile = profileReadService.getProfile(memberId);
+        return renderSnapshotFull(profile);
+    }
+
+    private String renderSnapshotFull(Map<String, Object> profile) {
+        if (profile == null || profile.containsKey("error")) return null;
+        StringBuilder sb = new StringBuilder();
+        Map<String, Object> member = (Map<String, Object>) profile.get("member");
+        if (member != null) {
+            sb.append("## 画像对象:");
+            sb.append(member.getOrDefault("name", "用户"));
+            sb.append("(").append(member.getOrDefault("age", "?")).append("岁,");
+            String gender = String.valueOf(member.getOrDefault("gender", ""));
+            sb.append("男".equals(gender) ? "男" : "女".equals(gender) ? "女" : "未知");
+            sb.append(")\n");
+        }
+        Map<String, Object> dims = (Map<String, Object>) profile.get("dimension_scores");
+        if (dims != null && !dims.isEmpty()) {
+            sb.append(String.format("五维评分:身 %s 智 %s 心 %s 行 %s 富 %s\n",
+                    dims.getOrDefault("body", "?"), dims.getOrDefault("wisdom", "?"),
+                    dims.getOrDefault("mind", "?"), dims.getOrDefault("action", "?"),
+                    dims.getOrDefault("wealth", "?")));
+        }
+        Map<String, Object> body = (Map<String, Object>) profile.get("body_metrics");
+        if (body != null) {
+            if (body.get("sleep_dur_avg") != null)
+                sb.append(String.format("平均睡眠:%s小时/天\n", body.get("sleep_dur_avg")));
+            if (body.get("exercise_count_week") != null)
+                sb.append(String.format("周运动频次:%s次\n", body.get("exercise_count_week")));
+        }
+        Map<String, Object> mind = (Map<String, Object>) profile.get("mind_metrics");
+        if (mind != null && mind.get("stress_avg") != null)
+            sb.append(String.format("平均压力:%s/10\n", mind.get("stress_avg")));
+        @SuppressWarnings("unchecked")
+        java.util.List<String> problems = (java.util.List<String>) profile.get("problem_domains");
+        if (problems != null && !problems.isEmpty())
+            sb.append("关注问题域:").append(String.join(", ", problems.subList(0, Math.min(5, problems.size())))).append("\n");
+        return sb.toString().trim().isEmpty() ? null : sb.toString().trim();
+    }
+}
+```
+
+- [ ] **步骤 3:创建 PortraitController**
+
+```java
+package com.etotem.cfc.controller.user;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.PortraitService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/user/portrait")
+public class PortraitController {
+
+    @Resource
+    private PortraitService portraitService;
+
+    @PostMapping("/get")
+    public Result<Map<String, Object>> getPortrait(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Long targetUserId = params.get("userId") != null
+                ? Long.valueOf(params.get("userId").toString()) : null;
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        // 普通用户不能查他人
+        if (targetUserId != null && !targetUserId.equals(userId)) {
+            // 可加管理员权限检查,此处暂放行(前端只传自己)
+        }
+        return Result.success(portraitService.getPortrait(userId, targetUserId, memberId));
+    }
+
+    @PostMapping("/edit")
+    public Result<String> editPortrait(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Object promptObj = params.get("portraitPrompt");
+        String portraitPrompt = promptObj != null ? promptObj.toString() : null;
+        if (portraitPrompt != null && portraitPrompt.length() > 2000) {
+            return Result.error("画像描述最多 2000 字符");
+        }
+        portraitService.updatePortraitPrompt(userId, portraitPrompt);
+        return Result.success("保存成功");
+    }
+}
+```
+
+- [ ] **步骤 4:编译验证**
+
+```bash
+cd cfc-backend && /bwydata/maven/bin/mvn clean compile -q > /tmp/opencode/t2.log 2>&1; M=$?; tail -3 /tmp/opencode/t2.log; test $M -eq 0 && echo MVN_OK
+```
+
+- [ ] **步骤 5:Commit**
+
+```bash
+GIT_MASTER=1 git add \
+  cfc-backend/src/main/java/com/etotem/cfc/service/PortraitService.java \
+  cfc-backend/src/main/java/com/etotem/cfc/service/impl/PortraitServiceImpl.java \
+  cfc-backend/src/main/java/com/etotem/cfc/controller/user/PortraitController.java
+GIT_MASTER=1 git commit -m "feat: 新增 PortraitService 和 PortraitController"
+```
+
+---
+
+### 任务 3:Python portrait_service.py
+
+**文件:**
+- 新增:`cfc-langgraph/app/portrait_service.py`
+
+- [ ] **步骤 1:创建 portrait_service.py**
+
+```python
+"""用户画像 prompt 组装服务
+
+职责:
+1. 拉取用户自定义画像文本(600s 缓存)
+2. 拉取 profile_snapshot 并渲染为文本(900s 缓存)
+3. 拼装成 SystemMessage 内容;无数据时返回 None
+"""
+import time
+import logging
+from app.tools.java_client import JavaClient
+
+logger = logging.getLogger(__name__)
+
+_TTL_PROMPT = 600
+_TTL_SNAPSHOT = 900
+
+_cache_prompt: dict = {}
+_cache_snapshot: dict = {}
+
+
+def _render_snapshot(profile: dict) -> str:
+    """将 profile Map 渲染为中文指标清单文本"""
+    if not profile or "error" in profile:
+        return ""
+    lines = []
+    member = profile.get("member") or {}
+    name = member.get("name", "用户")
+    age = member.get("age", "?")
+    gender_raw = str(member.get("gender", ""))
+    gender = "男" if gender_raw == "male" else "女" if gender_raw == "female" else "未知"
+    lines.append(f"## 画像对象:{name}({age}岁,{gender})")
+
+    dims = profile.get("dimension_scores") or {}
+    if dims:
+        lines.append(f"五维评分:身 {dims.get('body', '?')} 智 {dims.get('wisdom', '?')} "
+                     f"心 {dims.get('mind', '?')} 行 {dims.get('action', '?')} 富 {dims.get('wealth', '?')}")
+
+    body = profile.get("body_metrics") or {}
+    if body.get("sleep_dur_avg"):
+        lines.append(f"平均睡眠:{body['sleep_dur_avg']}小时/天")
+    if body.get("exercise_count_week"):
+        lines.append(f"周运动频次:{body['exercise_count_week']}次")
+
+    mind = profile.get("mind_metrics") or {}
+    if mind.get("stress_avg"):
+        lines.append(f"平均压力:{mind['stress_avg']}/10")
+
+    problems = profile.get("problem_domains") or []
+    if problems:
+        lines.append(f"关注问题域:{', '.join(problems[:5])}")
+
+    return "\n".join(lines)
+
+
+async def get_user_portrait_text(java: JavaClient, user_id: int, member_id) -> str | None:
+    """返回画像 SystemMessage 内容;无画像数据时返回 None"""
+    parts = []
+
+    # 1. 用户自定义 prompt(600s 缓存)
+    now = time.time()
+    cached_text, cached_ts = _cache_prompt.get(user_id, (None, 0))
+    if now - cached_ts < _TTL_PROMPT and cached_text is not None:
+        user_text = cached_text
+    else:
+        try:
+            client = await java._get_client()
+            resp = await client.post("/api/user/portrait/get", json={"userId": user_id}, timeout=5.0)
+            data = resp.json()
+            user_text = (data.get("data") or {}).get("portraitPrompt") or ""
+            _cache_prompt[user_id] = (user_text, now)
+        except Exception as e:
+            logger.warning("拉取用户画像 prompt 失败: %s", e)
+            user_text = ""
+
+    if user_text.strip():
+        parts.append(user_text)
+
+    # 2. 快照渲染文本(仅当有 member_id 时)
+    if member_id:
+        snap_now = time.time()
+        cached_snap, cached_snap_ts = _cache_snapshot.get(int(member_id), (None, 0))
+        if snap_now - cached_snap_ts < _TTL_SNAPSHOT and cached_snap is not None:
+            snap_text = cached_snap
+        else:
+            try:
+                profile = await java.get_member_profile(int(member_id))
+                snap_text = _render_snapshot(profile) if profile else ""
+                _cache_snapshot[int(member_id)] = (snap_text, snap_now)
+            except Exception as e:
+                logger.warning("拉取成员画像快照失败: %s", e)
+                snap_text = ""
+
+        if snap_text.strip():
+            if user_text.strip():
+                parts.append("\n---参考指标---\n" + snap_text)
+            else:
+                parts.append("以下是用户画像数据(系统自动生成),请结合这些数据给出更针对性的建议:\n" + snap_text)
+
+    return "\n\n".join(parts) if parts else None
+
+
+def clear_cache(user_id: int | None = None, member_id: int | None = None):
+    """清除缓存"""
+    if user_id is not None:
+        _cache_prompt.pop(user_id, None)
+    if member_id is not None:
+        _cache_snapshot.pop(member_id, None)
+    if user_id is None and member_id is None:
+        _cache_prompt.clear()
+        _cache_snapshot.clear()
+```
+
+- [ ] **步骤 2:py_compile 验证**
+
+```bash
+cd cfc-langgraph && python3 -m py_compile app/portrait_service.py && echo PS_OK
+```
+
+---
+
+### 任务 4:改造 3 个 LangGraph
+
+**文件:**
+- 修改:`cfc-langgraph/app/graphs/chat_graph.py`
+- 修改:`cfc-langgraph/app/graphs/health_coach_graph.py`
+- 修改:`cfc-langgraph/app/graphs/health_butler_graph.py`
+
+每个 graph 的改造模式相同:在 generate_answer 节点中,人格 prompt 之后插入画像段。
+
+- [ ] **步骤 1:改造 chat_graph.py**
+
+在文件顶部追加 import:
+```python
+from app.portrait_service import get_user_portrait_text
+```
+
+找到 generate_answer 函数中 `messages = [SystemMessage(content=...)]` 行(人格 prompt 处),在其后追加:
+```python
+    # 画像注入
+    member_id = state.get("child_id")
+    portrait_text = await get_user_portrait_text(java_client, state["user_id"], member_id)
+    if portrait_text:
+        messages.insert(1, SystemMessage(content=portrait_text))
+```
+
+其中 `java_client` 需要在图创建时构造(与 RagRetriever/ChatOpenAI 同级)。
+
+- [ ] **步骤 2:改造 health_coach_graph.py**
+
+与 chat_graph.py 相同模式,在 `generate_answer` 中人格 prompt 之后插入:
+```python
+    from app.portrait_service import get_user_portrait_text
+    # ... 在 messages 追加 persona 后 ...
+    portrait_text = await get_user_portrait_text(java_client, state["user_id"], state.get("child_id"))
+    if portrait_text:
+        messages.insert(1, SystemMessage(content=portrait_text))
+```
+
+注意:health_coach_graph.py 已有 `from app.prompt_service import get_prompt`,追加 `from app.portrait_service import get_user_portrait_text`。
+
+- [ ] **步骤 3:改造 health_butler_graph.py**
+
+同上模式。先 grep 确认其 generate_answer 结构:
+```bash
+grep -n "def generate_answer\|SystemMessage\|messages = " cfc-langgraph/app/graphs/health_butler_graph.py | head -15
+```
+然后在 SystemMessage 组装处插入画像段。
+
+- [ ] **步骤 4:py_compile 验证**
+
+```bash
+cd cfc-langgraph && python3 -m py_compile app/graphs/chat_graph.py app/graphs/health_coach_graph.py app/graphs/health_butler_graph.py && echo GRAPH_OK
+```
+
+- [ ] **步骤 5:Commit**
+
+```bash
+GIT_MASTER=1 git add cfc-langgraph/app/portrait_service.py \
+  cfc-langgraph/app/graphs/chat_graph.py \
+  cfc-langgraph/app/graphs/health_coach_graph.py \
+  cfc-langgraph/app/graphs/health_butler_graph.py
+GIT_MASTER=1 git commit -m "feat: Python 侧画像 prompt 注入(3 个 graph)"
+```
+
+---
+
+### 任务 5:Java Controller 注入 portrait_prompt
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java`
+
+- [ ] **步骤 1:在 AIChatController 中注入 PortraitService**
+
+在 AIChatController.java 顶部加:
+```java
+@Resource
+private PortraitService portraitService;
+```
+
+- [ ] **步骤 2:改造 chat/send 方法**
+
+在 `inputs = familyContextService.buildContext(...)` 之后追加:
+```java
+// 注入用户画像
+Long memberId = null;
+String memberIdStr = params.get("memberId");
+if (memberIdStr != null && !memberIdStr.trim().isEmpty()) {
+    memberId = Long.valueOf(memberIdStr);
+}
+String portraitPrompt = portraitService.buildPortraitPrompt(userId, memberId);
+if (portraitPrompt != null) {
+    inputs.put("portrait_prompt", portraitPrompt);
+}
+```
+
+- [ ] **步骤 3:改造 health-coach/send 方法**
+
+在 inputs 构建后(已有 child_id 和 mascot 注入之后)追加:
+```java
+// 注入用户画像
+String portraitPrompt = portraitService.buildPortraitPrompt(userId, memberId);
+if (portraitPrompt != null) {
+    inputs.put("portrait_prompt", portraitPrompt);
+}
+```
+
+- [ ] **步骤 4:改造 butler/send 方法**
+
+同上,在 inputs 构建后追加 portrait_prompt 注入。
+
+- [ ] **步骤 5:改造 AIService.sendNutritionMessage**
+
+在 `sendNutritionMessage` 方法中,构建 inputs 后追加 portrait_prompt。由于 nutrition 走 Dify(非 LangGraph),portrait_prompt 会被 Dify 作为 inputs 传入(Dify 的 system prompt 中可以引用)。
+
+- [ ] **步骤 6:编译验证**
+
+```bash
+cd cfc-backend && /bwydata/maven/bin/mvn clean compile -q > /tmp/opencode/t5.log 2>&1; M=$?; tail -3 /tmp/opencode/t5.log; test $M -eq 0 && echo MVN_OK
+```
+
+- [ ] **步骤 7:Commit**
+
+```bash
+GIT_MASTER=1 git add cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java \
+  cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java
+GIT_MASTER=1 git commit -m "feat: Java 侧 4 个对话端点注入 portrait_prompt"
+```
+
+---
+
+### 任务 6:小程序前端
+
+**文件:**
+- 新增:`cfc-frontend/pages/profile/portrait-edit.vue`
+- 修改:`cfc-frontend/utils/api.js`
+- 修改:`cfc-frontend/pages.json`
+
+- [ ] **步骤 1:在 api.js 新增画像接口封装**
+
+在 `cfc-frontend/utils/api.js` 末尾追加:
+```javascript
+// 用户画像
+export function getPortrait(params = {}) {
+  return request('/api/user/portrait/get', 'POST', params)
+}
+export function editPortrait(data) {
+  return request('/api/user/portrait/edit', 'POST', data)
+}
+```
+
+- [ ] **步骤 2:创建 portrait-edit.vue**
+
+```vue
+<template>
+  <view class="portrait-edit-page">
+    <view class="section">
+      <view class="section-title">我的画像描述</view>
+      <view class="hint">请输入你的个人画像描述,AI 会根据这些信息提供更有针对性的建议(最多 2000 字)</view>
+      <textarea
+        class="portrait-textarea"
+        :value="portraitPrompt"
+        placeholder="例如:我家孩子 8 岁,偏瘦,挑食,容易积食..."
+        maxlength="2000"
+        @input="onInput"
+      ></textarea>
+      <view class="char-count">{{ portraitPrompt.length }}/2000</view>
+    </view>
+
+    <view class="section" v-if="renderedSnapshot">
+      <view class="section-title">参考指标(自动生成)</view>
+      <view class="snapshot-text">{{ renderedSnapshot }}</view>
+    </view>
+
+    <button class="save-btn" :loading="saving" @click="onSave">保存</button>
+  </view>
+</template>
+
+<script>
+import { getPortrait, editPortrait } from '../../utils/api.js'
+export default {
+  data() {
+    return {
+      portraitPrompt: '',
+      renderedSnapshot: '',
+      saving: false
+    }
+  },
+  onLoad() {
+    this.loadPortrait()
+  },
+  methods: {
+    async loadPortrait() {
+      try {
+        const res = await getPortrait({ memberId: this.$store.state.currentMemberId })
+        if (res.code === 200 && res.data) {
+          this.portraitPrompt = res.data.portraitPrompt || ''
+          this.renderedSnapshot = res.data.renderedSnapshot || ''
+        }
+      } catch (e) {
+        console.error('加载画像失败', e)
+      }
+    },
+    onInput(e) {
+      this.portraitPrompt = e.detail.value
+    },
+    async onSave() {
+      this.saving = true
+      try {
+        const res = await editPortrait({ portraitPrompt: this.portraitPrompt })
+        if (res.code === 200) {
+          uni.showToast({ title: '保存成功', icon: 'success' })
+          this.loadPortrait()
+        } else {
+          uni.showToast({ title: res.message || '保存失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '保存失败', icon: 'none' })
+      } finally {
+        this.saving = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.portrait-edit-page {
+  padding: 24rpx;
+}
+.section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 24rpx;
+}
+.section-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  margin-bottom: 12rpx;
+}
+.hint {
+  font-size: 24rpx;
+  color: #999;
+  margin-bottom: 16rpx;
+}
+.portrait-textarea {
+  width: 100%;
+  height: 300rpx;
+  border: 1rpx solid #eee;
+  border-radius: 8rpx;
+  padding: 16rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.char-count {
+  text-align: right;
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 8rpx;
+}
+.snapshot-text {
+  font-size: 26rpx;
+  color: #555;
+  white-space: pre-wrap;
+  line-height: 1.6;
+}
+.save-btn {
+  background: #F97316;
+  color: #fff;
+  border-radius: 44rpx;
+  height: 88rpx;
+  line-height: 88rpx;
+  font-size: 32rpx;
+  margin-top: 32rpx;
+}
+</style>
+```
+
+- [ ] **步骤 3:在 pages.json 注册新页面**
+
+在 pages.json 的 pages 数组中追加(放在 profile 相关页面附近):
+```json
+{
+  "path": "pages/profile/portrait-edit",
+  "style": {
+    "navigationBarTitleText": "我的画像"
+  }
+}
+```
+
+- [ ] **步骤 4:在「我的」页添加入口**
+
+在 `cfc-frontend/pages/profile/index.vue` 中找到合适位置(如吉祥物设置附近),添加「我的画像」入口按钮或菜单项,跳转至 `/pages/profile/portrait-edit`。
+
+- [ ] **步骤 5:语法验证**
+
+```bash
+sed -n '/<script>/,/<\/script>/p' cfc-frontend/pages/profile/portrait-edit.vue | sed '1d;$d' > /tmp/opencode/portrait_check.mjs && node --check /tmp/opencode/portrait_check.mjs && echo VUE_OK
+```
+
+- [ ] **步骤 6:Commit**
+
+```bash
+GIT_MASTER=1 git add cfc-frontend/pages/profile/portrait-edit.vue \
+  cfc-frontend/utils/api.js \
+  cfc-frontend/pages.json
+GIT_MASTER=1 git commit -m "feat: 小程序新增画像编辑页"
+```
+
+---
+
+### 任务 7:全量验收 + 提交
+
+- [ ] **步骤 1:后端编译**
+
+```bash
+cd cfc-backend && /bwydata/maven/bin/mvn clean compile -q > /tmp/opencode/final_mvn.log 2>&1; M=$?; tail -3 /tmp/opencode/final_mvn.log; test $M -eq 0 && echo MVN_OK
+```
+
+- [ ] **步骤 2:Python 编译**
+
+```bash
+cd cfc-langgraph && python3 -m py_compile app/portrait_service.py app/graphs/chat_graph.py app/graphs/health_coach_graph.py app/graphs/health_butler_graph.py && echo PY_OK
+```
+
+- [ ] **步骤 3:小程序前端验证**
+
+```bash
+for f in pages/profile/portrait-edit.vue pages/ai/chat.vue pages/membership/index.vue; do
+  cd cfc-frontend && sed -n '/<script>/,/<\/script>/p' "$f" | sed '1d;$d' > /tmp/opencode/final_check.mjs && node --check /tmp/opencode/final_check.mjs && echo "OK: $f" && cd ..
+done
+```
+
+- [ ] **步骤 4:功能验证(可选,本地启动后测试)**
+
+- 调用 `POST /api/user/portrait/edit` 保存画像文本
+- 调用 `POST /api/user/portrait/get` 读取画像
+- 调用 `POST /api/ai/health-coach/send` 发起对话,检查 Python 日志确认画像段已注入
+
+- [ ] **步骤 5:提交所有剩余改动**
+
+```bash
+cd /sc-data/cfc && GIT_MASTER=1 git add -A && git diff --cached --stat && GIT_MASTER=1 git commit -m "feat: 用户画像 prompt 拼装全栈实现" && git log --oneline -5
+```
+
+---
+
+## 自检记录
+
+1. **规格覆盖度**:
+   - §一 核心决策 → 任务 1-6 全覆盖
+   - §二 数据层变更 → 任务 1
+   - §三 API 层设计 → 任务 2
+   - §四 Graph 改造 → 任务 4(Python 侧)+ 任务 5(Java 侧)
+   - §五 前端设计 → 任务 6
+   - §六 验收标准 → 任务 7
+
+2. **占位符扫描**:无"待定/TODO"
+
+3. **类型一致性**:
+   - Java:`PortraitService` 接口方法名与 impl 一致
+   - Python:`get_user_portrait_text(java, user_id, member_id)` 签名统一
+   - 前端:`getPortrait`/`editPortrait` 与后端 `/api/user/portrait/get`/`edit` 对应
+
+4. **边界处理**:
+   - portrait_prompt 为空时降级为快照渲染
+   - 两者皆空时返回 None(不注入)
+   - 成员不存在时 renderSnapshot 返回 null
+   - 缓存失效后自动刷新
+
+5. **待办**:运营侧 system_prompts 配置(步骤 7.3 遗留,非本次范围)