# 用户画像 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 表新增列 ```sql ALTER TABLE users ADD COLUMN portrait_prompt TEXT COMMENT '用户自定义画像 prompt(为空时自动从 profile_snapshot 渲染)' AFTER mascot; ``` **位置**:schema.sql 的 users 建表语句 + DatabaseInitializer.runMigrations() 迁移脚本。 ### 2.2 User.java 实体 ```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 渲染文本。 **请求体**: ```json { "userId": 12345, // 被查询用户 ID(管理员可传任意用户;普通用户默认取 JWT userId) "memberId": 67890 // 可选,成员 ID;不传则只返回 portrait_prompt } ``` **响应**: ```json { "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。 **请求体**: ```json { "portraitPrompt": "我家孩子 8 岁,偏瘦,挑食..." // 最多 2000 字符 } ``` **权限**:任何登录用户可编辑自己的;管理员可编辑任意用户(通过请求体加 userId 字段)。 **验证**:portraitPrompt 长度 ≤ 2000,null 视为清空。 **实现**:`UserController` 新增方法 → `UserService.updatePortraitPrompt()` → Mapper 直接 UPDATE。 --- ## 四、Python Graph 层设计 ### 4.1 新增 app/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 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 为例): ```python 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)或聊天页顶部导航区添加「我的画像」入口按钮。 **页面内容**: - 标题:我的画像 - 文本域:`