2026-08-24-user-portrait-prompt.md 27 KB

用户画像 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 列之后),追加:

    portrait_prompt TEXT COMMENT '用户自定义画像 prompt(为空时自动从 profile_snapshot 渲染)',

先 grep 确认 mascot 列位置:

grep -n "mascot" cfc-backend/src/main/resources/schema.sql
  • 步骤 2:在 DatabaseInitializer.runMigrations() 末尾追加迁移

在 DatabaseInitializer.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; 之后追加:

@TableField("portrait_prompt")
private String portraitPrompt;

同时生成 getter/setter(IntelliJ 快捷键 Alt+Insert 或手写)。

  • [ ] 步骤 4:编译验证

    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

    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 接口

    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

    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

    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:编译验证

    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

    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

    """用户画像 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 验证

    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:

from app.portrait_service import get_user_portrait_text

找到 generate_answer 函数中 messages = [SystemMessage(content=...)] 行(人格 prompt 处),在其后追加:

    # 画像注入
    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 之后插入:

    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 结构:

grep -n "def generate_answer\|SystemMessage\|messages = " cfc-langgraph/app/graphs/health_butler_graph.py | head -15

然后在 SystemMessage 组装处插入画像段。

  • [ ] 步骤 4:py_compile 验证

    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

    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 顶部加:

@Resource
private PortraitService portraitService;
  • 步骤 2:改造 chat/send 方法

在 inputs = familyContextService.buildContext(...) 之后追加:

// 注入用户画像
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 注入之后)追加:

// 注入用户画像
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:编译验证

    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

    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 末尾追加:

// 用户画像
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

    <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 相关页面附近):

{
  "path": "pages/profile/portrait-edit",
  "style": {
    "navigationBarTitleText": "我的画像"
  }
}
  • 步骤 4:在「我的」页添加入口

在 cfc-frontend/pages/profile/index.vue 中找到合适位置(如吉祥物设置附近),添加「我的画像」入口按钮或菜单项,跳转至 /pages/profile/portrait-edit。

  • [ ] 步骤 5:语法验证

    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

    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:后端编译

    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 编译

    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:小程序前端验证

    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:提交所有剩余改动

    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 遗留,非本次范围)