# 用户画像 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 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 getPortrait(Long userId, Long targetUserId, Long memberId) { Long effectiveUserId = (targetUserId != null) ? targetUserId : userId; User user = userMapper.selectById(effectiveUserId); Map result = new HashMap<>(); result.put("portraitPrompt", user != null ? user.getPortraitPrompt() : null); if (memberId != null) { Map profile = profileReadService.getProfile(memberId); result.put("renderedSnapshot", renderSnapshotFull(profile)); } return result; } private String renderSnapshot(Long memberId) { if (memberId == null) return null; Map profile = profileReadService.getProfile(memberId); return renderSnapshotFull(profile); } private String renderSnapshotFull(Map profile) { if (profile == null || profile.containsKey("error")) return null; StringBuilder sb = new StringBuilder(); Map member = (Map) 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 dims = (Map) 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 body = (Map) 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 mind = (Map) 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 problems = (java.util.List) 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> getPortrait( @RequestAttribute("userId") Long userId, @RequestBody Map 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 editPortrait( @RequestAttribute("userId") Long userId, @RequestBody Map 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 ``` - [ ] **步骤 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 '/