Przeglądaj źródła

docs: 用户画像 prompt 拼装设计规范

iwt 2 miesięcy temu
rodzic
commit
bcfe8ad61f

+ 305 - 0
docs/superpowers/specs/2026-08-24-user-portrait-prompt-design.md

@@ -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 阶段