日期: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 缓存,失败降级为不注入) |
ALTER TABLE users ADD COLUMN portrait_prompt TEXT COMMENT '用户自定义画像 prompt(为空时自动从 profile_snapshot 渲染)' AFTER mascot;
位置:schema.sql 的 users 建表语句 + DatabaseInitializer.runMigrations() 迁移脚本。
@TableField("portrait_prompt")
private String portraitPrompt;
直接复用已有 profile_snapshot(五维指标 JSON)和 profile_history(历史快照),不新增表。
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。
"""用户画像 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
待改造的 graph 文件(需逐一确认是否存在):
app/graphs/health_coach_graph.py ✅ 已知存在app/graphs/chat_graph.pyapp/graphs/butler_graph.pyapp/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 同级)state.get("child_id") 优先;若 context 中有 member_id 则也用在 /api/user/portrait/edit 响应成功后,调用 portrait_service.clear_cache(user_id) 使旧缓存失效。
新增页面:pages/profile/portrait-edit.vue
入口:在现有「我的」页(profile/index.vue)或聊天页顶部导航区添加「我的画像」入口按钮。
页面内容:
<textarea :value="portraitPrompt" @input="onInput" maxlength="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)
}
暂不新增。用户自主编辑即可;管理员如需帮用户代填,走 /api/user/portrait/edit?userId=xxx 接口(由技术支持操作)。
注册新页面:
{
"path": "pages/profile/portrait-edit",
"style": { "navigationBarTitleText": "我的画像" }
}
mvn clean compile EXIT 0;python3 -m py_compile 所有涉及文件 OKgenerate_answer 中重新组装(非持久化到 State),确保每次对话都能反映最新画像文档版本:v1 编写者:Sisyphus(AI Agent) 审查状态:待用户确认后进入 writing-plans 阶段