|
@@ -0,0 +1,305 @@
|
|
|
|
|
+# 用户画像 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)或聊天页顶部导航区添加「我的画像」入口按钮。
|
|
|
|
|
+
|
|
|
|
|
+**页面内容**:
|
|
|
|
|
+- 标题:我的画像
|
|
|
|
|
+- 文本域:`<textarea :value="portraitPrompt" @input="onInput" maxlength="2000" />`
|
|
|
|
|
+- 显示字数统计(如 42/2000)
|
|
|
|
|
+- 「保存」按钮 → 调用 `/api/user/portrait/edit`
|
|
|
|
|
+- 「参考指标」区块:只读,展示系统渲染的画像快照文本(调用 `/api/user/portrait/get` 获取)
|
|
|
|
|
+
|
|
|
|
|
+**API 封装**:在 `src/api/user.js` 新增:
|
|
|
|
|
+```javascript
|
|
|
|
|
+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
|
|
|
|
|
+
|
|
|
|
|
+注册新页面:
|
|
|
|
|
+```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 阶段
|