|
@@ -0,0 +1,801 @@
|
|
|
|
|
+# 用户画像 prompt 拼装实现计划
|
|
|
|
|
+
|
|
|
|
|
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
|
|
|
|
+
|
|
|
|
|
+**目标:** 在所有 AI 对话端点(chat、health-coach、butler、nutrition)中注入用户画像 prompt,实现个性化对话体验。
|
|
|
|
|
+
|
|
|
|
|
+**架构:**
|
|
|
|
|
+- Java 侧:`users` 表加 `portrait_prompt` 列,新增 `PortraitService` 和 `PortraitController`,在 4 个对话 Controller 方法中统一注入 portrait_prompt 到 inputs
|
|
|
|
|
+- Python 侧:新增 `portrait_service.py`,在 3 个 LangGraph(chat、health_coach、health_butler)的 generate_answer 节点中读取 portrait_prompt 并组装 SystemMessage;nutrition 端点走 Dify,由 Java 直接注入
|
|
|
|
|
+- 前端:小程序新增画像编辑页
|
|
|
|
|
+
|
|
|
|
|
+**技术栈:** Java 8 + Spring Boot、Python 3.10 + FastAPI + LangGraph、uni-app Vue 2
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 文件清单
|
|
|
|
|
+
|
|
|
|
|
+### Java 后端
|
|
|
|
|
+| 操作 | 路径 | 职责 |
|
|
|
|
|
+|------|------|------|
|
|
|
|
|
+| 修改 | `cfc-backend/src/main/resources/schema.sql` | users 表加 portrait_prompt 列 |
|
|
|
|
|
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` | 迁移脚本 |
|
|
|
|
|
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/entity/User.java` | 实体加 portraitPrompt 字段 |
|
|
|
|
|
+| 新增 | `cfc-backend/src/main/java/com/etotem/cfc/service/PortraitService.java` | 接口 |
|
|
|
|
|
+| 新增 | `cfc-backend/src/main/java/com/etotem/cfc/service/impl/PortraitServiceImpl.java` | 实现(拉 portrait + 渲染快照) |
|
|
|
|
|
+| 新增 | `cfc-backend/src/main/java/com/etotem/cfc/controller/user/PortraitController.java` | 两个 REST 端点 |
|
|
|
|
|
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java` | 4 个方法注入 portrait_prompt |
|
|
|
|
|
+| 修改 | `cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java` | sendNutritionMessage 注入 portrait_prompt |
|
|
|
|
|
+
|
|
|
|
|
+### Python LangGraph
|
|
|
|
|
+| 操作 | 路径 | 职责 |
|
|
|
|
|
+|------|------|------|
|
|
|
|
|
+| 新增 | `cfc-langgraph/app/portrait_service.py` | get_user_portrait_text 函数 + 缓存 |
|
|
|
|
|
+| 修改 | `cfc-langgraph/app/graphs/chat_graph.py` | 注入画像段 |
|
|
|
|
|
+| 修改 | `cfc-langgraph/app/graphs/health_coach_graph.py` | 注入画像段 |
|
|
|
|
|
+| 修改 | `cfc-langgraph/app/graphs/health_butler_graph.py` | 注入画像段 |
|
|
|
|
|
+
|
|
|
|
|
+### 小程序前端
|
|
|
|
|
+| 操作 | 路径 | 职责 |
|
|
|
|
|
+|------|------|------|
|
|
|
|
|
+| 新增 | `cfc-frontend/pages/profile/portrait-edit.vue` | 画像编辑页 |
|
|
|
|
|
+| 修改 | `cfc-frontend/utils/api.js` | 新增 getPortrait/editPortrait 封装 |
|
|
|
|
|
+| 修改 | `cfc-frontend/pages.json` | 注册新页面 |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务分解
|
|
|
|
|
+
|
|
|
|
|
+### 任务 1:Java 数据层(users 表加列)
|
|
|
|
|
+
|
|
|
|
|
+**文件:**
|
|
|
|
|
+- 修改:`cfc-backend/src/main/resources/schema.sql`
|
|
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
|
|
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/User.java`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:在 schema.sql 追加 users 表列定义**
|
|
|
|
|
+
|
|
|
|
|
+找到 schema.sql 中 users 表的 CREATE TABLE 语句末尾(在 mascot 列之后),追加:
|
|
|
|
|
+```sql
|
|
|
|
|
+ portrait_prompt TEXT COMMENT '用户自定义画像 prompt(为空时自动从 profile_snapshot 渲染)',
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+先 grep 确认 mascot 列位置:
|
|
|
|
|
+```bash
|
|
|
|
|
+grep -n "mascot" cfc-backend/src/main/resources/schema.sql
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 2:在 DatabaseInitializer.runMigrations() 末尾追加迁移**
|
|
|
|
|
+
|
|
|
|
|
+在 DatabaseInitializer.java 中搜索 `// 迁移` 找最新编号,追加:
|
|
|
|
|
+```java
|
|
|
|
|
+// 迁移N: users 表添加 portrait_prompt 列(用户画像 prompt 拼装需求)
|
|
|
|
|
+try {
|
|
|
|
|
+ jdbcTemplate.execute("ALTER TABLE users ADD COLUMN portrait_prompt TEXT COMMENT '用户自定义画像 prompt'");
|
|
|
|
|
+ log.info("已添加 portrait_prompt 列到 users 表");
|
|
|
|
|
+} catch (Exception e) {
|
|
|
|
|
+ // 列已存在,忽略错误
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 3:在 User.java 实体加字段**
|
|
|
|
|
+
|
|
|
|
|
+在 `private String mascot;` 之后追加:
|
|
|
|
|
+```java
|
|
|
|
|
+@TableField("portrait_prompt")
|
|
|
|
|
+private String portraitPrompt;
|
|
|
|
|
+```
|
|
|
|
|
+同时生成 getter/setter(IntelliJ 快捷键 Alt+Insert 或手写)。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 4:编译验证**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd cfc-backend && /bwydata/maven/bin/mvn clean compile -q > /tmp/opencode/t1.log 2>&1; M=$?; tail -3 /tmp/opencode/t1.log; test $M -eq 0 && echo MVN_OK
|
|
|
|
|
+```
|
|
|
|
|
+预期:BUILD SUCCESS
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+GIT_MASTER=1 git add cfc-backend/src/main/resources/schema.sql \
|
|
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java \
|
|
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/entity/User.java
|
|
|
|
|
+GIT_MASTER=1 git commit -m "feat: users 表添加 portrait_prompt 列"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 任务 2:Java PortraitService + Controller
|
|
|
|
|
+
|
|
|
|
|
+**文件:**
|
|
|
|
|
+- 新增:`cfc-backend/src/main/java/com/etotem/cfc/service/PortraitService.java`
|
|
|
|
|
+- 新增:`cfc-backend/src/main/java/com/etotem/cfc/service/impl/PortraitServiceImpl.java`
|
|
|
|
|
+- 新增:`cfc-backend/src/main/java/com/etotem/cfc/controller/user/PortraitController.java`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:创建 PortraitService 接口**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+package com.etotem.cfc.service;
|
|
|
|
|
+
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+
|
|
|
|
|
+public interface PortraitService {
|
|
|
|
|
+ /** 获取用户画像文本(自定义 prompt + 快照渲染) */
|
|
|
|
|
+ String buildPortraitPrompt(Long userId, Long memberId);
|
|
|
|
|
+ /** 更新用户自定义画像 prompt */
|
|
|
|
|
+ void updatePortraitPrompt(Long userId, String portraitPrompt);
|
|
|
|
|
+ /** 读取用户画像(含渲染快照,用于前端展示) */
|
|
|
|
|
+ Map<String, Object> getPortrait(Long userId, Long targetUserId, Long memberId);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 2:创建 PortraitServiceImpl**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+package com.etotem.cfc.service.impl;
|
|
|
|
|
+
|
|
|
|
|
+import com.etotem.cfc.entity.User;
|
|
|
|
|
+import com.etotem.cfc.mapper.UserMapper;
|
|
|
|
|
+import com.etotem.cfc.service.PortraitService;
|
|
|
|
|
+import com.etotem.cfc.service.ProfileReadService;
|
|
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
|
|
+
|
|
|
|
|
+import javax.annotation.Resource;
|
|
|
|
|
+import java.util.HashMap;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+
|
|
|
|
|
+@Service
|
|
|
|
|
+public class PortraitServiceImpl implements PortraitService {
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private UserMapper userMapper;
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ProfileReadService profileReadService;
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public String buildPortraitPrompt(Long userId, Long memberId) {
|
|
|
|
|
+ User user = userMapper.selectById(userId);
|
|
|
|
|
+ if (user == null) return null;
|
|
|
|
|
+ String custom = user.getPortraitPrompt();
|
|
|
|
|
+ if (custom == null || custom.trim().isEmpty()) {
|
|
|
|
|
+ return renderSnapshot(memberId);
|
|
|
|
|
+ }
|
|
|
|
|
+ String snapshot = renderSnapshot(memberId);
|
|
|
|
|
+ if (snapshot == null || snapshot.trim().isEmpty()) {
|
|
|
|
|
+ return custom.trim();
|
|
|
|
|
+ }
|
|
|
|
|
+ return custom.trim() + "\n\n---参考指标---\n" + snapshot;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public void updatePortraitPrompt(Long userId, String portraitPrompt) {
|
|
|
|
|
+ User user = new User();
|
|
|
|
|
+ user.setId(userId);
|
|
|
|
|
+ user.setPortraitPrompt(portraitPrompt);
|
|
|
|
|
+ userMapper.updateById(user);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public Map<String, Object> getPortrait(Long userId, Long targetUserId, Long memberId) {
|
|
|
|
|
+ Long effectiveUserId = (targetUserId != null) ? targetUserId : userId;
|
|
|
|
|
+ User user = userMapper.selectById(effectiveUserId);
|
|
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
|
|
+ result.put("portraitPrompt", user != null ? user.getPortraitPrompt() : null);
|
|
|
|
|
+ if (memberId != null) {
|
|
|
|
|
+ Map<String, Object> profile = profileReadService.getProfile(memberId);
|
|
|
|
|
+ result.put("renderedSnapshot", renderSnapshotFull(profile));
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String renderSnapshot(Long memberId) {
|
|
|
|
|
+ if (memberId == null) return null;
|
|
|
|
|
+ Map<String, Object> profile = profileReadService.getProfile(memberId);
|
|
|
|
|
+ return renderSnapshotFull(profile);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String renderSnapshotFull(Map<String, Object> profile) {
|
|
|
|
|
+ if (profile == null || profile.containsKey("error")) return null;
|
|
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
|
|
+ Map<String, Object> member = (Map<String, Object>) profile.get("member");
|
|
|
|
|
+ if (member != null) {
|
|
|
|
|
+ sb.append("## 画像对象:");
|
|
|
|
|
+ sb.append(member.getOrDefault("name", "用户"));
|
|
|
|
|
+ sb.append("(").append(member.getOrDefault("age", "?")).append("岁,");
|
|
|
|
|
+ String gender = String.valueOf(member.getOrDefault("gender", ""));
|
|
|
|
|
+ sb.append("男".equals(gender) ? "男" : "女".equals(gender) ? "女" : "未知");
|
|
|
|
|
+ sb.append(")\n");
|
|
|
|
|
+ }
|
|
|
|
|
+ Map<String, Object> dims = (Map<String, Object>) profile.get("dimension_scores");
|
|
|
|
|
+ if (dims != null && !dims.isEmpty()) {
|
|
|
|
|
+ sb.append(String.format("五维评分:身 %s 智 %s 心 %s 行 %s 富 %s\n",
|
|
|
|
|
+ dims.getOrDefault("body", "?"), dims.getOrDefault("wisdom", "?"),
|
|
|
|
|
+ dims.getOrDefault("mind", "?"), dims.getOrDefault("action", "?"),
|
|
|
|
|
+ dims.getOrDefault("wealth", "?")));
|
|
|
|
|
+ }
|
|
|
|
|
+ Map<String, Object> body = (Map<String, Object>) profile.get("body_metrics");
|
|
|
|
|
+ if (body != null) {
|
|
|
|
|
+ if (body.get("sleep_dur_avg") != null)
|
|
|
|
|
+ sb.append(String.format("平均睡眠:%s小时/天\n", body.get("sleep_dur_avg")));
|
|
|
|
|
+ if (body.get("exercise_count_week") != null)
|
|
|
|
|
+ sb.append(String.format("周运动频次:%s次\n", body.get("exercise_count_week")));
|
|
|
|
|
+ }
|
|
|
|
|
+ Map<String, Object> mind = (Map<String, Object>) profile.get("mind_metrics");
|
|
|
|
|
+ if (mind != null && mind.get("stress_avg") != null)
|
|
|
|
|
+ sb.append(String.format("平均压力:%s/10\n", mind.get("stress_avg")));
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ java.util.List<String> problems = (java.util.List<String>) profile.get("problem_domains");
|
|
|
|
|
+ if (problems != null && !problems.isEmpty())
|
|
|
|
|
+ sb.append("关注问题域:").append(String.join(", ", problems.subList(0, Math.min(5, problems.size())))).append("\n");
|
|
|
|
|
+ return sb.toString().trim().isEmpty() ? null : sb.toString().trim();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 3:创建 PortraitController**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+package com.etotem.cfc.controller.user;
|
|
|
|
|
+
|
|
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
|
|
+import com.etotem.cfc.service.PortraitService;
|
|
|
|
|
+import org.springframework.web.bind.annotation.*;
|
|
|
|
|
+
|
|
|
|
|
+import javax.annotation.Resource;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+
|
|
|
|
|
+@RestController
|
|
|
|
|
+@RequestMapping("/api/user/portrait")
|
|
|
|
|
+public class PortraitController {
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private PortraitService portraitService;
|
|
|
|
|
+
|
|
|
|
|
+ @PostMapping("/get")
|
|
|
|
|
+ public Result<Map<String, Object>> getPortrait(
|
|
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
|
|
+ @RequestBody Map<String, Object> params) {
|
|
|
|
|
+ Long targetUserId = params.get("userId") != null
|
|
|
|
|
+ ? Long.valueOf(params.get("userId").toString()) : null;
|
|
|
|
|
+ Long memberId = params.get("memberId") != null
|
|
|
|
|
+ ? Long.valueOf(params.get("memberId").toString()) : null;
|
|
|
|
|
+ // 普通用户不能查他人
|
|
|
|
|
+ if (targetUserId != null && !targetUserId.equals(userId)) {
|
|
|
|
|
+ // 可加管理员权限检查,此处暂放行(前端只传自己)
|
|
|
|
|
+ }
|
|
|
|
|
+ return Result.success(portraitService.getPortrait(userId, targetUserId, memberId));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @PostMapping("/edit")
|
|
|
|
|
+ public Result<String> editPortrait(
|
|
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
|
|
+ @RequestBody Map<String, Object> params) {
|
|
|
|
|
+ Object promptObj = params.get("portraitPrompt");
|
|
|
|
|
+ String portraitPrompt = promptObj != null ? promptObj.toString() : null;
|
|
|
|
|
+ if (portraitPrompt != null && portraitPrompt.length() > 2000) {
|
|
|
|
|
+ return Result.error("画像描述最多 2000 字符");
|
|
|
|
|
+ }
|
|
|
|
|
+ portraitService.updatePortraitPrompt(userId, portraitPrompt);
|
|
|
|
|
+ return Result.success("保存成功");
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 4:编译验证**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd cfc-backend && /bwydata/maven/bin/mvn clean compile -q > /tmp/opencode/t2.log 2>&1; M=$?; tail -3 /tmp/opencode/t2.log; test $M -eq 0 && echo MVN_OK
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+GIT_MASTER=1 git add \
|
|
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/service/PortraitService.java \
|
|
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/service/impl/PortraitServiceImpl.java \
|
|
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/controller/user/PortraitController.java
|
|
|
|
|
+GIT_MASTER=1 git commit -m "feat: 新增 PortraitService 和 PortraitController"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 任务 3:Python portrait_service.py
|
|
|
|
|
+
|
|
|
|
|
+**文件:**
|
|
|
|
|
+- 新增:`cfc-langgraph/app/portrait_service.py`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:创建 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
|
|
|
|
|
+
|
|
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
|
|
+
|
|
|
|
|
+_TTL_PROMPT = 600
|
|
|
|
|
+_TTL_SNAPSHOT = 900
|
|
|
|
|
+
|
|
|
|
|
+_cache_prompt: dict = {}
|
|
|
|
|
+_cache_snapshot: dict = {}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _render_snapshot(profile: dict) -> str:
|
|
|
|
|
+ """将 profile Map 渲染为中文指标清单文本"""
|
|
|
|
|
+ if not profile or "error" in profile:
|
|
|
|
|
+ return ""
|
|
|
|
|
+ lines = []
|
|
|
|
|
+ member = profile.get("member") or {}
|
|
|
|
|
+ name = member.get("name", "用户")
|
|
|
|
|
+ age = member.get("age", "?")
|
|
|
|
|
+ gender_raw = str(member.get("gender", ""))
|
|
|
|
|
+ gender = "男" if gender_raw == "male" else "女" if gender_raw == "female" else "未知"
|
|
|
|
|
+ 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) -> str | None:
|
|
|
|
|
+ """返回画像 SystemMessage 内容;无画像数据时返回 None"""
|
|
|
|
|
+ parts = []
|
|
|
|
|
+
|
|
|
|
|
+ # 1. 用户自定义 prompt(600s 缓存)
|
|
|
|
|
+ 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(int(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[int(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
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def clear_cache(user_id: int | None = None, member_id: int | None = None):
|
|
|
|
|
+ """清除缓存"""
|
|
|
|
|
+ if user_id is not None:
|
|
|
|
|
+ _cache_prompt.pop(user_id, None)
|
|
|
|
|
+ if member_id is not None:
|
|
|
|
|
+ _cache_snapshot.pop(member_id, None)
|
|
|
|
|
+ if user_id is None and member_id is None:
|
|
|
|
|
+ _cache_prompt.clear()
|
|
|
|
|
+ _cache_snapshot.clear()
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 2:py_compile 验证**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd cfc-langgraph && python3 -m py_compile app/portrait_service.py && echo PS_OK
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 任务 4:改造 3 个 LangGraph
|
|
|
|
|
+
|
|
|
|
|
+**文件:**
|
|
|
|
|
+- 修改:`cfc-langgraph/app/graphs/chat_graph.py`
|
|
|
|
|
+- 修改:`cfc-langgraph/app/graphs/health_coach_graph.py`
|
|
|
|
|
+- 修改:`cfc-langgraph/app/graphs/health_butler_graph.py`
|
|
|
|
|
+
|
|
|
|
|
+每个 graph 的改造模式相同:在 generate_answer 节点中,人格 prompt 之后插入画像段。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:改造 chat_graph.py**
|
|
|
|
|
+
|
|
|
|
|
+在文件顶部追加 import:
|
|
|
|
|
+```python
|
|
|
|
|
+from app.portrait_service import get_user_portrait_text
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+找到 generate_answer 函数中 `messages = [SystemMessage(content=...)]` 行(人格 prompt 处),在其后追加:
|
|
|
|
|
+```python
|
|
|
|
|
+ # 画像注入
|
|
|
|
|
+ member_id = state.get("child_id")
|
|
|
|
|
+ portrait_text = await get_user_portrait_text(java_client, state["user_id"], member_id)
|
|
|
|
|
+ if portrait_text:
|
|
|
|
|
+ messages.insert(1, SystemMessage(content=portrait_text))
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+其中 `java_client` 需要在图创建时构造(与 RagRetriever/ChatOpenAI 同级)。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 2:改造 health_coach_graph.py**
|
|
|
|
|
+
|
|
|
|
|
+与 chat_graph.py 相同模式,在 `generate_answer` 中人格 prompt 之后插入:
|
|
|
|
|
+```python
|
|
|
|
|
+ from app.portrait_service import get_user_portrait_text
|
|
|
|
|
+ # ... 在 messages 追加 persona 后 ...
|
|
|
|
|
+ portrait_text = await get_user_portrait_text(java_client, state["user_id"], state.get("child_id"))
|
|
|
|
|
+ if portrait_text:
|
|
|
|
|
+ messages.insert(1, SystemMessage(content=portrait_text))
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+注意:health_coach_graph.py 已有 `from app.prompt_service import get_prompt`,追加 `from app.portrait_service import get_user_portrait_text`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 3:改造 health_butler_graph.py**
|
|
|
|
|
+
|
|
|
|
|
+同上模式。先 grep 确认其 generate_answer 结构:
|
|
|
|
|
+```bash
|
|
|
|
|
+grep -n "def generate_answer\|SystemMessage\|messages = " cfc-langgraph/app/graphs/health_butler_graph.py | head -15
|
|
|
|
|
+```
|
|
|
|
|
+然后在 SystemMessage 组装处插入画像段。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 4:py_compile 验证**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd cfc-langgraph && python3 -m py_compile app/graphs/chat_graph.py app/graphs/health_coach_graph.py app/graphs/health_butler_graph.py && echo GRAPH_OK
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+GIT_MASTER=1 git add cfc-langgraph/app/portrait_service.py \
|
|
|
|
|
+ cfc-langgraph/app/graphs/chat_graph.py \
|
|
|
|
|
+ cfc-langgraph/app/graphs/health_coach_graph.py \
|
|
|
|
|
+ cfc-langgraph/app/graphs/health_butler_graph.py
|
|
|
|
|
+GIT_MASTER=1 git commit -m "feat: Python 侧画像 prompt 注入(3 个 graph)"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 任务 5:Java Controller 注入 portrait_prompt
|
|
|
|
|
+
|
|
|
|
|
+**文件:**
|
|
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java`
|
|
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:在 AIChatController 中注入 PortraitService**
|
|
|
|
|
+
|
|
|
|
|
+在 AIChatController.java 顶部加:
|
|
|
|
|
+```java
|
|
|
|
|
+@Resource
|
|
|
|
|
+private PortraitService portraitService;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 2:改造 chat/send 方法**
|
|
|
|
|
+
|
|
|
|
|
+在 `inputs = familyContextService.buildContext(...)` 之后追加:
|
|
|
|
|
+```java
|
|
|
|
|
+// 注入用户画像
|
|
|
|
|
+Long memberId = null;
|
|
|
|
|
+String memberIdStr = params.get("memberId");
|
|
|
|
|
+if (memberIdStr != null && !memberIdStr.trim().isEmpty()) {
|
|
|
|
|
+ memberId = Long.valueOf(memberIdStr);
|
|
|
|
|
+}
|
|
|
|
|
+String portraitPrompt = portraitService.buildPortraitPrompt(userId, memberId);
|
|
|
|
|
+if (portraitPrompt != null) {
|
|
|
|
|
+ inputs.put("portrait_prompt", portraitPrompt);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 3:改造 health-coach/send 方法**
|
|
|
|
|
+
|
|
|
|
|
+在 inputs 构建后(已有 child_id 和 mascot 注入之后)追加:
|
|
|
|
|
+```java
|
|
|
|
|
+// 注入用户画像
|
|
|
|
|
+String portraitPrompt = portraitService.buildPortraitPrompt(userId, memberId);
|
|
|
|
|
+if (portraitPrompt != null) {
|
|
|
|
|
+ inputs.put("portrait_prompt", portraitPrompt);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 4:改造 butler/send 方法**
|
|
|
|
|
+
|
|
|
|
|
+同上,在 inputs 构建后追加 portrait_prompt 注入。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 5:改造 AIService.sendNutritionMessage**
|
|
|
|
|
+
|
|
|
|
|
+在 `sendNutritionMessage` 方法中,构建 inputs 后追加 portrait_prompt。由于 nutrition 走 Dify(非 LangGraph),portrait_prompt 会被 Dify 作为 inputs 传入(Dify 的 system prompt 中可以引用)。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 6:编译验证**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd cfc-backend && /bwydata/maven/bin/mvn clean compile -q > /tmp/opencode/t5.log 2>&1; M=$?; tail -3 /tmp/opencode/t5.log; test $M -eq 0 && echo MVN_OK
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 7:Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+GIT_MASTER=1 git add cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java \
|
|
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java
|
|
|
|
|
+GIT_MASTER=1 git commit -m "feat: Java 侧 4 个对话端点注入 portrait_prompt"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 任务 6:小程序前端
|
|
|
|
|
+
|
|
|
|
|
+**文件:**
|
|
|
|
|
+- 新增:`cfc-frontend/pages/profile/portrait-edit.vue`
|
|
|
|
|
+- 修改:`cfc-frontend/utils/api.js`
|
|
|
|
|
+- 修改:`cfc-frontend/pages.json`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:在 api.js 新增画像接口封装**
|
|
|
|
|
+
|
|
|
|
|
+在 `cfc-frontend/utils/api.js` 末尾追加:
|
|
|
|
|
+```javascript
|
|
|
|
|
+// 用户画像
|
|
|
|
|
+export function getPortrait(params = {}) {
|
|
|
|
|
+ return request('/api/user/portrait/get', 'POST', params)
|
|
|
|
|
+}
|
|
|
|
|
+export function editPortrait(data) {
|
|
|
|
|
+ return request('/api/user/portrait/edit', 'POST', data)
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 2:创建 portrait-edit.vue**
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<template>
|
|
|
|
|
+ <view class="portrait-edit-page">
|
|
|
|
|
+ <view class="section">
|
|
|
|
|
+ <view class="section-title">我的画像描述</view>
|
|
|
|
|
+ <view class="hint">请输入你的个人画像描述,AI 会根据这些信息提供更有针对性的建议(最多 2000 字)</view>
|
|
|
|
|
+ <textarea
|
|
|
|
|
+ class="portrait-textarea"
|
|
|
|
|
+ :value="portraitPrompt"
|
|
|
|
|
+ placeholder="例如:我家孩子 8 岁,偏瘦,挑食,容易积食..."
|
|
|
|
|
+ maxlength="2000"
|
|
|
|
|
+ @input="onInput"
|
|
|
|
|
+ ></textarea>
|
|
|
|
|
+ <view class="char-count">{{ portraitPrompt.length }}/2000</view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <view class="section" v-if="renderedSnapshot">
|
|
|
|
|
+ <view class="section-title">参考指标(自动生成)</view>
|
|
|
|
|
+ <view class="snapshot-text">{{ renderedSnapshot }}</view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <button class="save-btn" :loading="saving" @click="onSave">保存</button>
|
|
|
|
|
+ </view>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+import { getPortrait, editPortrait } from '../../utils/api.js'
|
|
|
|
|
+export default {
|
|
|
|
|
+ data() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ portraitPrompt: '',
|
|
|
|
|
+ renderedSnapshot: '',
|
|
|
|
|
+ saving: false
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ onLoad() {
|
|
|
|
|
+ this.loadPortrait()
|
|
|
|
|
+ },
|
|
|
|
|
+ methods: {
|
|
|
|
|
+ async loadPortrait() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await getPortrait({ memberId: this.$store.state.currentMemberId })
|
|
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
|
|
+ this.portraitPrompt = res.data.portraitPrompt || ''
|
|
|
|
|
+ this.renderedSnapshot = res.data.renderedSnapshot || ''
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.error('加载画像失败', e)
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ onInput(e) {
|
|
|
|
|
+ this.portraitPrompt = e.detail.value
|
|
|
|
|
+ },
|
|
|
|
|
+ async onSave() {
|
|
|
|
|
+ this.saving = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await editPortrait({ portraitPrompt: this.portraitPrompt })
|
|
|
|
|
+ if (res.code === 200) {
|
|
|
|
|
+ uni.showToast({ title: '保存成功', icon: 'success' })
|
|
|
|
|
+ this.loadPortrait()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ uni.showToast({ title: res.message || '保存失败', icon: 'none' })
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ uni.showToast({ title: '保存失败', icon: 'none' })
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.saving = false
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped>
|
|
|
|
|
+.portrait-edit-page {
|
|
|
|
|
+ padding: 24rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.section {
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ border-radius: 16rpx;
|
|
|
|
|
+ padding: 24rpx;
|
|
|
|
|
+ margin-bottom: 24rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.section-title {
|
|
|
|
|
+ font-size: 32rpx;
|
|
|
|
|
+ font-weight: bold;
|
|
|
|
|
+ margin-bottom: 12rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.hint {
|
|
|
|
|
+ font-size: 24rpx;
|
|
|
|
|
+ color: #999;
|
|
|
|
|
+ margin-bottom: 16rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.portrait-textarea {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 300rpx;
|
|
|
|
|
+ border: 1rpx solid #eee;
|
|
|
|
|
+ border-radius: 8rpx;
|
|
|
|
|
+ padding: 16rpx;
|
|
|
|
|
+ font-size: 28rpx;
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+}
|
|
|
|
|
+.char-count {
|
|
|
|
|
+ text-align: right;
|
|
|
|
|
+ font-size: 22rpx;
|
|
|
|
|
+ color: #999;
|
|
|
|
|
+ margin-top: 8rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.snapshot-text {
|
|
|
|
|
+ font-size: 26rpx;
|
|
|
|
|
+ color: #555;
|
|
|
|
|
+ white-space: pre-wrap;
|
|
|
|
|
+ line-height: 1.6;
|
|
|
|
|
+}
|
|
|
|
|
+.save-btn {
|
|
|
|
|
+ background: #F97316;
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ border-radius: 44rpx;
|
|
|
|
|
+ height: 88rpx;
|
|
|
|
|
+ line-height: 88rpx;
|
|
|
|
|
+ font-size: 32rpx;
|
|
|
|
|
+ margin-top: 32rpx;
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 3:在 pages.json 注册新页面**
|
|
|
|
|
+
|
|
|
|
|
+在 pages.json 的 pages 数组中追加(放在 profile 相关页面附近):
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "path": "pages/profile/portrait-edit",
|
|
|
|
|
+ "style": {
|
|
|
|
|
+ "navigationBarTitleText": "我的画像"
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 4:在「我的」页添加入口**
|
|
|
|
|
+
|
|
|
|
|
+在 `cfc-frontend/pages/profile/index.vue` 中找到合适位置(如吉祥物设置附近),添加「我的画像」入口按钮或菜单项,跳转至 `/pages/profile/portrait-edit`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 5:语法验证**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+sed -n '/<script>/,/<\/script>/p' cfc-frontend/pages/profile/portrait-edit.vue | sed '1d;$d' > /tmp/opencode/portrait_check.mjs && node --check /tmp/opencode/portrait_check.mjs && echo VUE_OK
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 6:Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+GIT_MASTER=1 git add cfc-frontend/pages/profile/portrait-edit.vue \
|
|
|
|
|
+ cfc-frontend/utils/api.js \
|
|
|
|
|
+ cfc-frontend/pages.json
|
|
|
|
|
+GIT_MASTER=1 git commit -m "feat: 小程序新增画像编辑页"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 任务 7:全量验收 + 提交
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:后端编译**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd cfc-backend && /bwydata/maven/bin/mvn clean compile -q > /tmp/opencode/final_mvn.log 2>&1; M=$?; tail -3 /tmp/opencode/final_mvn.log; test $M -eq 0 && echo MVN_OK
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 2:Python 编译**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd cfc-langgraph && python3 -m py_compile app/portrait_service.py app/graphs/chat_graph.py app/graphs/health_coach_graph.py app/graphs/health_butler_graph.py && echo PY_OK
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 3:小程序前端验证**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+for f in pages/profile/portrait-edit.vue pages/ai/chat.vue pages/membership/index.vue; do
|
|
|
|
|
+ cd cfc-frontend && sed -n '/<script>/,/<\/script>/p' "$f" | sed '1d;$d' > /tmp/opencode/final_check.mjs && node --check /tmp/opencode/final_check.mjs && echo "OK: $f" && cd ..
|
|
|
|
|
+done
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 4:功能验证(可选,本地启动后测试)**
|
|
|
|
|
+
|
|
|
|
|
+- 调用 `POST /api/user/portrait/edit` 保存画像文本
|
|
|
|
|
+- 调用 `POST /api/user/portrait/get` 读取画像
|
|
|
|
|
+- 调用 `POST /api/ai/health-coach/send` 发起对话,检查 Python 日志确认画像段已注入
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 5:提交所有剩余改动**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /sc-data/cfc && GIT_MASTER=1 git add -A && git diff --cached --stat && GIT_MASTER=1 git commit -m "feat: 用户画像 prompt 拼装全栈实现" && git log --oneline -5
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 自检记录
|
|
|
|
|
+
|
|
|
|
|
+1. **规格覆盖度**:
|
|
|
|
|
+ - §一 核心决策 → 任务 1-6 全覆盖
|
|
|
|
|
+ - §二 数据层变更 → 任务 1
|
|
|
|
|
+ - §三 API 层设计 → 任务 2
|
|
|
|
|
+ - §四 Graph 改造 → 任务 4(Python 侧)+ 任务 5(Java 侧)
|
|
|
|
|
+ - §五 前端设计 → 任务 6
|
|
|
|
|
+ - §六 验收标准 → 任务 7
|
|
|
|
|
+
|
|
|
|
|
+2. **占位符扫描**:无"待定/TODO"
|
|
|
|
|
+
|
|
|
|
|
+3. **类型一致性**:
|
|
|
|
|
+ - Java:`PortraitService` 接口方法名与 impl 一致
|
|
|
|
|
+ - Python:`get_user_portrait_text(java, user_id, member_id)` 签名统一
|
|
|
|
|
+ - 前端:`getPortrait`/`editPortrait` 与后端 `/api/user/portrait/get`/`edit` 对应
|
|
|
|
|
+
|
|
|
|
|
+4. **边界处理**:
|
|
|
|
|
+ - portrait_prompt 为空时降级为快照渲染
|
|
|
|
|
+ - 两者皆空时返回 None(不注入)
|
|
|
|
|
+ - 成员不存在时 renderSnapshot 返回 null
|
|
|
|
|
+ - 缓存失效后自动刷新
|
|
|
|
|
+
|
|
|
|
|
+5. **待办**:运营侧 system_prompts 配置(步骤 7.3 遗留,非本次范围)
|