2026-08-24-user-portrait-prompt-design.md 11 KB

用户画像 prompt 拼装设计规范

日期:2026-08-24 状态:设计阶段 关联计划:待 writing-plans 生成 需求来源:浠艾福平台「AI 健康教练人格分化与管家自助选择」功能的延伸——每个用户拥有自己的画像 prompt


一、核心决策

决策项 结论
适用范围 所有 AI 对话端点(chat/send、butler/send、nutrition/send、health-coach/send),不含 health_plan(结构化方案生成,已有独立画像注入逻辑)
画像主体 按 memberId 分派:有 child_id/memberId 时用该成员画像;无时不注入
渲染深度 完整版:五维评分 + 睡眠/压力/运动等指标清单(复用健康方案生成渲染格式)
可编辑性 允许用户手动编辑自由文本画像 prompt
实现方案 A:users 表加 portrait_prompt TEXT 列;Python 侧新增 portrait_service.py
缓存策略 portrait_prompt 600s,profile_snapshot 900s(进程内 dict 缓存,失败降级为不注入)

二、数据层变更

2.1 users 表新增列

ALTER TABLE users ADD COLUMN portrait_prompt TEXT COMMENT '用户自定义画像 prompt(为空时自动从 profile_snapshot 渲染)' AFTER mascot;

位置:schema.sql 的 users 建表语句 + DatabaseInitializer.runMigrations() 迁移脚本。

2.2 User.java 实体

@TableField("portrait_prompt")
private String portraitPrompt;

2.3 不新建表

直接复用已有 profile_snapshot(五维指标 JSON)和 profile_history(历史快照),不新增表。


三、API 层设计(Java 侧)

3.1 新增两个端点(POST,统一放 /api/user/*)

POST /api/user/portrait/get

说明:读取当前登录用户的 portrait_prompt,以及指定成员的 profile_snapshot 渲染文本。

请求体:

{
  "userId": 12345,         // 被查询用户 ID(管理员可传任意用户;普通用户默认取 JWT userId)
  "memberId": 67890        // 可选,成员 ID;不传则只返回 portrait_prompt
}

响应:

{
  "code": 200,
  "data": {
    "portraitPrompt": "我家孩子偏瘦,容易积食...",   // 用户手动编辑的画像文本(可为空)
    "renderedSnapshot": "【五维评分】身 7.2 智 6.8...  # 或 null(无 member 时)"
  }
}

实现要点:

  • 复用 ProfileReadService.getProfile(memberId) 获取快照
  • 渲染函数复用 adapter.py 已有的 profile 渲染逻辑,移植到 Java(UserService 或新增 PortraitRenderService)
  • 管理员角色可查任意用户;普通用户只能查自己

POST /api/user/portrait/edit

说明:更新当前用户的 portrait_prompt。

请求体:

{
  "portraitPrompt": "我家孩子 8 岁,偏瘦,挑食..."   // 最多 2000 字符
}

权限:任何登录用户可编辑自己的;管理员可编辑任意用户(通过请求体加 userId 字段)。

验证:portraitPrompt 长度 ≤ 2000,null 视为清空。

实现:UserController 新增方法 → UserService.updatePortraitPrompt() → Mapper 直接 UPDATE。


四、Python Graph 层设计

4.1 新增 app/portrait_service.py

"""用户画像 prompt 组装服务

职责:
1. 拉取用户自定义画像文本(600s 缓存)
2. 拉取 profile_snapshot 并渲染为文本(900s 缓存)
3. 拼装成 SystemMessage 内容;无数据时返回 None
"""
import time
import logging
from app.tools.java_client import JavaClient
from app.prompt_service import clear_cache

logger = logging.getLogger(__name__)

_TTL_PROMPT = 600    # 用户自定义 prompt 缓存 10 分钟
_TTL_SNAPSHOT = 900  # 画像快照缓存 15 分钟

_cache_prompt: dict[int, tuple[str, float]] = {}  # user_id -> (text, ts)
_cache_snapshot: dict[int, tuple[str, float]] = {}  # member_id -> (text, ts)


def _render_snapshot(profile: dict) -> str:
    """将 profile Map 渲染为中文指标清单文本"""
    if not profile:
        return ""
    lines = []
    member = profile.get("member") or {}
    name = member.get("name", "用户")
    age = member.get("age")
    gender = member.get("gender")
    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: int | None) -> str | None:
    """返回画像 SystemMessage 内容;无画像数据时返回 None"""
    parts = []

    # 1. 用户自定义 prompt
    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(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[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

4.2 各 graph 改造

待改造的 graph 文件(需逐一确认是否存在):

  • app/graphs/health_coach_graph.py ✅ 已知存在
  • app/graphs/chat_graph.py
  • app/graphs/butler_graph.py
  • app/graphs/nutrition_graph.py

通用插入模式(以 health_coach_graph 为例):

from app.portrait_service import get_user_portrait_text

async def generate_answer(state: HealthCoachState) -> dict:
    _ctx = state.get("context") or {}
    _coach_id = _ctx.get("coach_id")
    # ... 现有 persona prompt 逻辑不变 ...
    messages = [SystemMessage(content=_persona_prompt or _base_prompt or DEFAULT_COACH_PROMPT)]

    # 新增:画像段
    member_id = state.get("child_id") or (_ctx.get("member_id") if _ctx else None)
    portrait_text = await get_user_portrait_text(java, state["user_id"], member_id)
    if portrait_text:
        messages.insert(1, SystemMessage(content=portrait_text))

    # ... 后续 context/knowledge/memory 逻辑不变 ...

注意:

  • java 变量需在 graph 创建时提前构造(与 RagRetriever/ChatOpenAI 同级)
  • member_id 来源:state.get("child_id") 优先;若 context 中有 member_id 则也用

4.3 清理缓存 hook

在 /api/user/portrait/edit 响应成功后,调用 portrait_service.clear_cache(user_id) 使旧缓存失效。


五、前端设计

5.1 小程序(cfc-frontend)

新增页面:pages/profile/portrait-edit.vue

入口:在现有「我的」页(profile/index.vue)或聊天页顶部导航区添加「我的画像」入口按钮。

页面内容:

  • 标题:我的画像
  • 文本域:<textarea :value="portraitPrompt" @input="onInput" maxlength="2000" />
  • 显示字数统计(如 42/2000)
  • 「保存」按钮 → 调用 /api/user/portrait/edit
  • 「参考指标」区块:只读,展示系统渲染的画像快照文本(调用 /api/user/portrait/get 获取)

API 封装:在 src/api/user.js 新增:

export function getPortrait(userId) {
  return request('/api/user/portrait/get', 'POST', { userId })
}
export function editPortrait(data) {
  return request('/api/user/portrait/edit', 'POST', data)
}

5.2 Web 管理端

暂不新增。用户自主编辑即可;管理员如需帮用户代填,走 /api/user/portrait/edit?userId=xxx 接口(由技术支持操作)。

5.3 pages.json

注册新页面:

{
  "path": "pages/profile/portrait-edit",
  "style": { "navigationBarTitleText": "我的画像" }
}

六、验收标准

  1. 编译:mvn clean compile EXIT 0;python3 -m py_compile 所有涉及文件 OK
  2. 画像注入:四个对话端点均能在 LLM 收到消息中包含画像 SystemMessage(可通过调试日志验证)
  3. 用户编辑:edit 接口写入成功;get 接口读到最新值;缓存 5 秒内失效
  4. 无画像降级:portrait_prompt 为空且无 member_id 时,LLM 不收到画像段(与改动前行为一致)
  5. memberId 分派:传 child_id=123 时渲染 123 的画像;不传时不渲染
  6. 字符限制:portrait_prompt 超 2000 字符返回 400 错误
  7. 权限:普通用户不能编辑他人 portrait;管理员可以

七、边界与注意事项

  • 多轮对话上下文:画像段在每轮 generate_answer 中重新组装(非持久化到 State),确保每次对话都能反映最新画像
  • 性能:画像数据缓存 TTL 较长(600s/900s),高频对话不会反复拉 Java
  • 与其他 SystemMessage 的位置关系:画像段位于人格 prompt 之后、背景信息之前——保证 LLM 先理解人设,再理解具体用户,最后理解对话上下文
  • 与 mascot 注入的关系:mascot(浠宝/福宝)是人格标识,portrait_prompt 是用户侧个性化数据,二者正交;均注入 SystemMessage

文档版本:v1 编写者:Sisyphus(AI Agent) 审查状态:待用户确认后进入 writing-plans 阶段