|
|
@@ -0,0 +1,1015 @@
|
|
|
+# 用户画像 AI 生成 + 重画功能 实施计划
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** 用户画像由 AI 根据系统行为数据 + 五维指标自动生成,用户可编辑,提供「重画」功能(参考旧画像 + 考虑变化因素)
|
|
|
+
|
|
|
+**Architecture:**
|
|
|
+- Java 侧:新增 `POST /api/user/portrait/generate`、`POST /api/user/portrait/regenerate`,调用 LangGraph portrait 生成/重画 graph
|
|
|
+- Python 侧:新增 `portrait_generation_graph.py`(LLM 分析行为+五维指标生成画像),`app/api/portrait.py` FastAPI 端点
|
|
|
+- 前端:`portrait-edit.vue` 增加「生成画像」「重画」按钮、加载态、结果展示
|
|
|
+
|
|
|
+**Tech Stack:** Java 8 + Spring Boot 2.7.18 + MyBatis-Plus / Python 3.10 + FastAPI + LangGraph + OpenAI / uni-app Vue 2
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Global Constraints
|
|
|
+
|
|
|
+- 统一 `@PostMapping`,禁止 `@GetMapping/@PutMapping/@DeleteMapping`
|
|
|
+- 响应包装 `Result<T>` (code/message/data)
|
|
|
+- JWT Bearer Token 认证,`JwtInterceptor` 拦截 `/api/**`
|
|
|
+- DI 使用 `@Resource`,字段名必须与 Bean Name 一致
|
|
|
+- MyBatis-Plus `@TableName` + `@TableId(type = IdType.AUTO)`
|
|
|
+- 小程序限制:禁止 `?.`、CSS Grid、`:key` 表达式、`new Date(string)`(用 `parseDate()`)
|
|
|
+- Python 侧统一走 LangGraph graph 形式实现 AI 能力
|
|
|
+- 编译验证:`mvn clean compile` / `python3 -m py_compile` 为唯一验证方式
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构映射
|
|
|
+
|
|
|
+### Java 后端新增/修改
|
|
|
+
|
|
|
+| 文件 | 类型 | 说明 |
|
|
|
+|------|------|------|
|
|
|
+| `service/PortraitService.java` | 修改 | 新增 `generatePortrait`、`regeneratePortrait` 接口方法 |
|
|
|
+| `service/impl/PortraitServiceImpl.java` | 修改 | 实现调用 AiGateway / portrait 生成逻辑 |
|
|
|
+| `controller/user/PortraitController.java` | 修改 | 新增 `/generate`、`/regenerate` 端点 |
|
|
|
+| `service/AIService.java` | 修改 | 新增 `generateUserPortrait` 方法(走 AiGateway 调用 LangGraph) |
|
|
|
+
|
|
|
+### Python LangGraph 新增/修改
|
|
|
+
|
|
|
+| 文件 | 类型 | 说明 |
|
|
|
+|------|------|------|
|
|
|
+| `graphs/portrait_generation_graph.py` | 新增 | AI 生成/重画用户画像的 LangGraph |
|
|
|
+| `api/portrait.py` | 新增 | FastAPI 路由:`POST /api/portrait/generate`、`POST /api/portrait/regenerate` |
|
|
|
+| `main.py` | 修改 | 注册 `portrait.router` |
|
|
|
+| `tools/java_client.py` | 修改 | 新增 `get_behavior_summary(user_id, member_id)` 获取行为数据 |
|
|
|
+
|
|
|
+### 小程序前端修改
|
|
|
+
|
|
|
+| 文件 | 类型 | 说明 |
|
|
|
+|------|------|------|
|
|
|
+| `pages/profile/portrait-edit.vue` | 修改 | 增加「生成画像」「重画」按钮、加载态、错误处理 |
|
|
|
+| `utils/api.js` | 修改 | 新增 `generatePortrait`、`regeneratePortrait` 封装 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务分解
|
|
|
+
|
|
|
+### 任务 1:Java PortraitService 接口扩展
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/PortraitService.java`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Produces: 新增 `generatePortrait(Long userId, Long memberId)`、`regeneratePortrait(Long userId, Long memberId)` 方法签名
|
|
|
+
|
|
|
+- [ ] **Step 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);
|
|
|
+
|
|
|
+ /** AI 生成用户画像(分析行为+五维指标) */
|
|
|
+ String generatePortrait(Long userId, Long memberId);
|
|
|
+ /** AI 重画用户画像(参考旧画像+变化因素) */
|
|
|
+ String regeneratePortrait(Long userId, Long memberId);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/PortraitService.java
|
|
|
+git commit -m "feat: PortraitService 新增 generatePortrait/regeneratePortrait 接口"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 2:Java PortraitServiceImpl 实现
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/impl/PortraitServiceImpl.java`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `AIService.generateUserPortrait(userId, memberId, previousPortrait)` 返回 AI 生成的画像文本
|
|
|
+- Produces: `generatePortrait`、`regeneratePortrait` 实现
|
|
|
+
|
|
|
+- [ ] **Step 1: 注入 AIService**
|
|
|
+
|
|
|
+```java
|
|
|
+@Resource
|
|
|
+private AIService aiService;
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 实现 generatePortrait**
|
|
|
+
|
|
|
+```java
|
|
|
+@Override
|
|
|
+public String generatePortrait(Long userId, Long memberId) {
|
|
|
+ // 调用 AIService 生成画像
|
|
|
+ String generated = aiService.generateUserPortrait(userId, memberId, null);
|
|
|
+ if (generated != null && !generated.trim().isEmpty()) {
|
|
|
+ // 保存到用户表
|
|
|
+ updatePortraitPrompt(userId, generated.trim());
|
|
|
+ return generated.trim();
|
|
|
+ }
|
|
|
+ // 失败时降级为快照渲染
|
|
|
+ return renderSnapshot(memberId);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 实现 regeneratePortrait**
|
|
|
+
|
|
|
+```java
|
|
|
+@Override
|
|
|
+public String regeneratePortrait(Long userId, Long memberId) {
|
|
|
+ // 1. 获取当前画像
|
|
|
+ User user = userMapper.selectById(userId);
|
|
|
+ String currentPortrait = user != null ? user.getPortraitPrompt() : null;
|
|
|
+
|
|
|
+ // 2. 调用 AIService 重画(传入旧画像作为参考)
|
|
|
+ String regenerated = aiService.generateUserPortrait(userId, memberId, currentPortrait);
|
|
|
+ if (regenerated != null && !regenerated.trim().isEmpty()) {
|
|
|
+ updatePortraitPrompt(userId, regenerated.trim());
|
|
|
+ return regenerated.trim();
|
|
|
+ }
|
|
|
+ // 失败保持原画像
|
|
|
+ return currentPortrait;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/impl/PortraitServiceImpl.java
|
|
|
+git commit -m "feat: PortraitServiceImpl 实现 generatePortrait/regeneratePortrait"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 3:Java AIService 扩展
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java` (接口)
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/impl/AIServiceImpl.java` (实现)
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Produces: `generateUserPortrait(Long userId, Long memberId, String previousPortrait)` → `String`
|
|
|
+
|
|
|
+- [ ] **Step 1: 扩展 AIService 接口**
|
|
|
+
|
|
|
+```java
|
|
|
+/**
|
|
|
+ * AI 生成用户画像
|
|
|
+ * @param userId 用户ID
|
|
|
+ * @param memberId 成员ID(可空,为空时仅基于用户自身行为)
|
|
|
+ * @param previousPortrait 旧画像文本(重画时传入,首次生成为 null)
|
|
|
+ * @return 生成的画像文本,失败返回 null
|
|
|
+ */
|
|
|
+String generateUserPortrait(Long userId, Long memberId, String previousPortrait);
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 实现 AIServiceImpl.generateUserPortrait**
|
|
|
+
|
|
|
+```java
|
|
|
+@Override
|
|
|
+public String generateUserPortrait(Long userId, Long memberId, String previousPortrait) {
|
|
|
+ try {
|
|
|
+ // 1. 获取用户行为摘要 + 五维画像
|
|
|
+ Map<String, Object> context = familyContextService.buildContext(userId);
|
|
|
+ if (memberId != null) {
|
|
|
+ context.put("memberId", memberId);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 构建输入
|
|
|
+ Map<String, Object> inputs = new HashMap<>();
|
|
|
+ inputs.put("user_id", userId);
|
|
|
+ inputs.put("member_id", memberId);
|
|
|
+ inputs.put("previous_portrait", previousPortrait);
|
|
|
+ inputs.put("behavior_summary", context.get("behaviorSummary"));
|
|
|
+ inputs.put("profile_snapshot", context.get("profileSnapshot"));
|
|
|
+
|
|
|
+ // 3. 调用 LangGraph portrait 生成 graph
|
|
|
+ // 端点:POST /api/portrait/generate (Python 侧)
|
|
|
+ Map<String, Object> resp = aiGateway.chat("", userId, null, inputs);
|
|
|
+ // aiGateway.chat 会路由到 portrait graph
|
|
|
+ // 这里需要专门的方法,或者扩展 aiGateway
|
|
|
+
|
|
|
+ // 临时方案:直接 HTTP 调用 Python 服务
|
|
|
+ // TODO: 在 AiGateway 增加专用方法
|
|
|
+ String result = callPortraitGraph(userId, memberId, previousPortrait, context);
|
|
|
+ return result;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("AI 生成画像失败: userId={}, memberId={}", userId, memberId, e);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> 注:`AiGateway` 目前通过 `chat` 方法统一路由。需要在 `AiGateway` 增加 `generatePortrait` 专用方法,或扩展 `chat` 支持 `portrait` 类型路由。
|
|
|
+
|
|
|
+- [ ] **Step 3: 在 AiGateway 增加 portrait 生成方法**
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/gateway/AiGateway.java`
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/gateway/impl/AiGatewayImpl.java`
|
|
|
+
|
|
|
+```java
|
|
|
+// AiGateway.java
|
|
|
+String generatePortrait(Long userId, Long memberId, String previousPortrait, Map<String, Object> context);
|
|
|
+
|
|
|
+// AiGatewayImpl.java
|
|
|
+@Override
|
|
|
+public String generatePortrait(Long userId, Long memberId, String previousPortrait, Map<String, Object> context) {
|
|
|
+ Map<String, Object> inputs = new HashMap<>();
|
|
|
+ inputs.put("user_id", userId);
|
|
|
+ inputs.put("member_id", memberId);
|
|
|
+ inputs.put("previous_portrait", previousPortrait);
|
|
|
+ inputs.put("behavior_summary", context.get("behaviorSummary"));
|
|
|
+ inputs.put("profile_snapshot", context.get("profileSnapshot"));
|
|
|
+ // 专用 graph 类型标识
|
|
|
+ inputs.put("__graph_type", "portrait_generation");
|
|
|
+
|
|
|
+ Map<String, Object> resp = webClient.post()
|
|
|
+ .uri(pythonBaseUrl + "/api/portrait/generate")
|
|
|
+ .bodyValue(inputs)
|
|
|
+ .retrieve()
|
|
|
+ .bodyToMono(Map.class)
|
|
|
+ .block();
|
|
|
+ // 解析返回的 portrait_text
|
|
|
+ return resp != null ? (String) resp.get("portrait_text") : null;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/service/impl/AIServiceImpl.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/gateway/AiGateway.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/gateway/impl/AiGatewayImpl.java
|
|
|
+git commit -m "feat: AIService + AiGateway 新增 portrait 生成调用"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 4:Java PortraitController 新增端点
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/user/PortraitController.java`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `PortraitService.generatePortrait`、`regeneratePortrait`
|
|
|
+
|
|
|
+- [ ] **Step 1: 新增 /generate 端点**
|
|
|
+
|
|
|
+```java
|
|
|
+@PostMapping("/generate")
|
|
|
+public Result<Map<String, Object>> generatePortrait(
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
+ @RequestBody Map<String, Object> params) {
|
|
|
+ Long memberId = params.get("memberId") != null
|
|
|
+ ? Long.valueOf(params.get("memberId").toString()) : null;
|
|
|
+ String portrait = portraitService.generatePortrait(userId, memberId);
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
+ result.put("portraitPrompt", portrait);
|
|
|
+ // 同时返回快照供前端展示
|
|
|
+ if (memberId != null) {
|
|
|
+ Map<String, Object> profile = profileReadService.getProfile(memberId);
|
|
|
+ result.put("renderedSnapshot", renderSnapshotFull(profile));
|
|
|
+ }
|
|
|
+ return Result.success(result);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 新增 /regenerate 端点**
|
|
|
+
|
|
|
+```java
|
|
|
+@PostMapping("/regenerate")
|
|
|
+public Result<Map<String, Object>> regeneratePortrait(
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
+ @RequestBody Map<String, Object> params) {
|
|
|
+ Long memberId = params.get("memberId") != null
|
|
|
+ ? Long.valueOf(params.get("memberId").toString()) : null;
|
|
|
+ String portrait = portraitService.regeneratePortrait(userId, memberId);
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
+ result.put("portraitPrompt", portrait);
|
|
|
+ if (memberId != null) {
|
|
|
+ Map<String, Object> profile = profileReadService.getProfile(memberId);
|
|
|
+ result.put("renderedSnapshot", renderSnapshotFull(profile));
|
|
|
+ }
|
|
|
+ return Result.success(result);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 复用 renderSnapshotFull(已存在于 impl 中,需提取为工具方法或在 controller 内复制)**
|
|
|
+
|
|
|
+- [ ] **Step 4: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/user/PortraitController.java
|
|
|
+git commit -m "feat: PortraitController 新增 /generate /regenerate 端点"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 5:Python JavaClient 新增 get_behavior_summary
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-langgraph/app/tools/java_client.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 新增 get_behavior_summary 方法**
|
|
|
+
|
|
|
+```python
|
|
|
+async def get_behavior_summary(self, user_id: int, member_id: int | None) -> dict:
|
|
|
+ """获取用户行为摘要(任务完成率、挑战参与、积分流水、健康报告等)"""
|
|
|
+ client = await self._get_client()
|
|
|
+ body = {"user_id": str(user_id)}
|
|
|
+ if member_id:
|
|
|
+ body["member_id"] = member_id
|
|
|
+ resp = await client.post("/api/ai/behavior/summary", json=body)
|
|
|
+ data = resp.json()
|
|
|
+ if data.get("code") == 200:
|
|
|
+ return data.get("data", {})
|
|
|
+ return {}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: py_compile 验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-langgraph && python3 -m py_compile app/tools/java_client.py
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-langgraph/app/tools/java_client.py
|
|
|
+git commit -m "feat: JavaClient 新增 get_behavior_summary 获取行为数据"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 6:Python 后端新增 /api/ai/behavior/summary 端点
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java` 或新增 `AIBehaviorController.java`
|
|
|
+
|
|
|
+> 复用 `AIChatController` 的 `familyContextService.buildContext()` 逻辑,提取行为摘要
|
|
|
+
|
|
|
+- [ ] **Step 1: 新增 AIBehaviorController**
|
|
|
+
|
|
|
+```java
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/ai/behavior")
|
|
|
+public class AIBehaviorController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private FamilyContextService familyContextService;
|
|
|
+
|
|
|
+ @PostMapping("/summary")
|
|
|
+ public Result<Map<String, Object>> getBehaviorSummary(
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
+ @RequestBody Map<String, Object> params) {
|
|
|
+ Long memberId = params.get("member_id") != null
|
|
|
+ ? Long.valueOf(params.get("member_id").toString()) : null;
|
|
|
+ Map<String, Object> context = familyContextService.buildContext(userId);
|
|
|
+ if (memberId != null) {
|
|
|
+ context.put("memberId", memberId);
|
|
|
+ }
|
|
|
+ // 提取行为相关字段
|
|
|
+ Map<String, Object> summary = new HashMap<>();
|
|
|
+ summary.put("behaviorSummary", context.get("behaviorSummary"));
|
|
|
+ summary.put("profileSnapshot", context.get("profileSnapshot"));
|
|
|
+ summary.put("recentTasks", context.get("recentTasks"));
|
|
|
+ summary.put("recentChallenges", context.get("recentChallenges"));
|
|
|
+ summary.put("pointsHistory", context.get("pointsHistory"));
|
|
|
+ summary.put("healthReports", context.get("healthReports"));
|
|
|
+ return Result.success(summary);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIBehaviorController.java
|
|
|
+git commit -m "feat: 新增 AIBehaviorController /behavior/summary 端点"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 7:Python LangGraph portrait_generation_graph.py
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-langgraph/app/graphs/portrait_generation_graph.py`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- State: `PortraitGenerationState` { `user_id`, `member_id`, `previous_portrait`, `behavior_summary`, `profile_snapshot`, `portrait_text` }
|
|
|
+- Returns: `{ "portrait_text": "生成的画像文本" }`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 graph 文件**
|
|
|
+
|
|
|
+```python
|
|
|
+"""用户画像生成/重画 LangGraph
|
|
|
+
|
|
|
+输入:
|
|
|
+- user_id: 用户ID
|
|
|
+- member_id: 成员ID(可空)
|
|
|
+- previous_portrait: 旧画像文本(重画时传入,首次生成为 None)
|
|
|
+- behavior_summary: 行为摘要(任务完成率、挑战、积分、健康报告等)
|
|
|
+- profile_snapshot: 五维画像快照(dimension_scores、body_metrics、mind_metrics、problem_domains)
|
|
|
+
|
|
|
+输出:
|
|
|
+- portrait_text: 生成的画像文本(中文,自然语言描述)
|
|
|
+"""
|
|
|
+from typing import TypedDict
|
|
|
+from langgraph.graph import StateGraph, START, END
|
|
|
+from langchain_openai import ChatOpenAI
|
|
|
+from langchain_core.messages import SystemMessage, HumanMessage
|
|
|
+from app.tools.java_client import JavaClient
|
|
|
+from app.config import settings
|
|
|
+import logging
|
|
|
+
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
+
|
|
|
+
|
|
|
+class PortraitGenerationState(TypedDict):
|
|
|
+ user_id: int
|
|
|
+ member_id: int | None
|
|
|
+ previous_portrait: str | None
|
|
|
+ behavior_summary: dict | None
|
|
|
+ profile_snapshot: dict | None
|
|
|
+ portrait_text: str | None
|
|
|
+
|
|
|
+
|
|
|
+SYSTEM_PROMPT = """你是「浠艾福」家庭成长平台的 AI 画像分析师。
|
|
|
+
|
|
|
+任务:根据用户的行为数据、五维能量指标、健康画像,生成一段自然语言的「用户画像」文本。
|
|
|
+
|
|
|
+画像用途:作为后续所有 AI 对话(健康教练、管家、营养师、通用聊天)的 SystemMessage 注入,帮助 AI 更懂用户,给出更个性化的建议。
|
|
|
+
|
|
|
+生成要求:
|
|
|
+1. 语言自然、温暖、具体,像一位了解家庭的成长顾问写的观察笔记
|
|
|
+2. 必须包含:核心特质、行为模式、关注点、潜在需求
|
|
|
+3. 长度:200-500 字,不要过长
|
|
|
+4. 不要列举原始数据(如"身 7.2 智 6.5"),要转化为洞察(如"身体维度相对突出,但智力维度有提升空间")
|
|
|
+5. 如有 previous_portrait,重画时要体现"变化"与"延续":哪些特质稳定,哪些有新变化,原因可能是什么
|
|
|
+
|
|
|
+输出格式:纯文本,无 JSON,无标记。"""
|
|
|
+
|
|
|
+
|
|
|
+def create_portrait_generation_graph():
|
|
|
+ llm = ChatOpenAI(
|
|
|
+ model=settings.llm_model,
|
|
|
+ api_key=settings.llm_api_key,
|
|
|
+ base_url=settings.llm_base_url,
|
|
|
+ temperature=0.7, # 稍高温度增加多样性
|
|
|
+ )
|
|
|
+ java = JavaClient()
|
|
|
+
|
|
|
+ builder = StateGraph(PortraitGenerationState)
|
|
|
+
|
|
|
+ async def fetch_data(state: PortraitGenerationState) -> dict:
|
|
|
+ """并行拉取行为摘要 + 画像快照"""
|
|
|
+ user_id = state["user_id"]
|
|
|
+ member_id = state.get("member_id")
|
|
|
+
|
|
|
+ # 并行获取
|
|
|
+ behavior_task = java.get_behavior_summary(user_id, member_id)
|
|
|
+ profile_task = java.get_member_profile(member_id) if member_id else None
|
|
|
+
|
|
|
+ behavior = await behavior_task
|
|
|
+ profile = await profile_task if profile_task else {}
|
|
|
+
|
|
|
+ return {
|
|
|
+ "behavior_summary": behavior,
|
|
|
+ "profile_snapshot": profile,
|
|
|
+ }
|
|
|
+
|
|
|
+ async def generate_portrait(state: PortraitGenerationState) -> dict:
|
|
|
+ """LLM 生成画像"""
|
|
|
+ previous = state.get("previous_portrait")
|
|
|
+ behavior = state.get("behavior_summary") or {}
|
|
|
+ profile = state.get("profile_snapshot") or {}
|
|
|
+
|
|
|
+ # 构建用户画像上下文
|
|
|
+ context_parts = []
|
|
|
+
|
|
|
+ if profile:
|
|
|
+ member = profile.get("member") or {}
|
|
|
+ name = member.get("name", "用户")
|
|
|
+ age = member.get("age")
|
|
|
+ gender = member.get("gender")
|
|
|
+ context_parts.append(f"用户:{name},{age}岁,{gender}")
|
|
|
+
|
|
|
+ dims = profile.get("dimension_scores") or {}
|
|
|
+ if dims:
|
|
|
+ context_parts.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"):
|
|
|
+ context_parts.append(f"平均睡眠:{body['sleep_dur_avg']}h/天")
|
|
|
+ if body.get("exercise_count_week"):
|
|
|
+ context_parts.append(f"周运动:{body['exercise_count_week']}次")
|
|
|
+
|
|
|
+ mind = profile.get("mind_metrics") or {}
|
|
|
+ if mind.get("stress_avg"):
|
|
|
+ context_parts.append(f"平均压力:{mind['stress_avg']}/10")
|
|
|
+
|
|
|
+ problems = profile.get("problem_domains") or []
|
|
|
+ if problems:
|
|
|
+ context_parts.append(f"关注问题:{', '.join(problems[:5])}")
|
|
|
+
|
|
|
+ if behavior:
|
|
|
+ context_parts.append("行为摘要:" + str(behavior))
|
|
|
+
|
|
|
+ context_text = "\n".join(context_parts)
|
|
|
+
|
|
|
+ # 构建 HumanMessage
|
|
|
+ if previous:
|
|
|
+ human_content = f"""【重画任务】请基于以下最新数据,重新生成用户画像。
|
|
|
+
|
|
|
+【原画像(参考)】:
|
|
|
+{previous}
|
|
|
+
|
|
|
+【最新数据】:
|
|
|
+{context_text}
|
|
|
+
|
|
|
+要求:保留原画像中仍有效的核心特质,更新已变化的部分,体现"变化与延续"。"""
|
|
|
+ else:
|
|
|
+ human_content = f"""【生成任务】请基于以下数据,生成用户画像。
|
|
|
+
|
|
|
+【数据】:
|
|
|
+{context_text}"""
|
|
|
+
|
|
|
+ messages = [
|
|
|
+ SystemMessage(content=SYSTEM_PROMPT),
|
|
|
+ HumanMessage(content=human_content),
|
|
|
+ ]
|
|
|
+
|
|
|
+ response = await llm.ainvoke(messages)
|
|
|
+ portrait_text = response.content.strip()
|
|
|
+
|
|
|
+ logger.info("生成画像完成: user_id=%d, member_id=%s, len=%d",
|
|
|
+ state["user_id"], state.get("member_id"), len(portrait_text))
|
|
|
+
|
|
|
+ return {"portrait_text": portrait_text}
|
|
|
+
|
|
|
+ builder.add_node("fetch_data", fetch_data)
|
|
|
+ builder.add_node("generate_portrait", generate_portrait)
|
|
|
+
|
|
|
+ builder.add_edge(START, "fetch_data")
|
|
|
+ builder.add_edge("fetch_data", "generate_portrait")
|
|
|
+ builder.add_edge("generate_portrait", END)
|
|
|
+
|
|
|
+ return builder.compile()
|
|
|
+
|
|
|
+
|
|
|
+# 单例
|
|
|
+_portrait_graph = None
|
|
|
+
|
|
|
+def get_portrait_graph():
|
|
|
+ global _portrait_graph
|
|
|
+ if _portrait_graph is None:
|
|
|
+ _portrait_graph = create_portrait_generation_graph()
|
|
|
+ return _portrait_graph
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: py_compile 验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-langgraph && python3 -m py_compile app/graphs/portrait_generation_graph.py
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-langgraph/app/graphs/portrait_generation_graph.py
|
|
|
+git commit -m "feat: 新增 portrait_generation_graph.py AI 生成/重画用户画像"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 8:Python FastAPI portrait 路由
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-langgraph/app/api/portrait.py`
|
|
|
+- Modify: `cfc-langgraph/app/main.py`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- `POST /api/portrait/generate` → `{ "portrait_text": "..." }`
|
|
|
+- `POST /api/portrait/regenerate` → `{ "portrait_text": "..." }`(同一实现,区别在于是否传 previous_portrait)
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 api/portrait.py**
|
|
|
+
|
|
|
+```python
|
|
|
+from fastapi import APIRouter, HTTPException
|
|
|
+from pydantic import BaseModel
|
|
|
+from app.graphs.portrait_generation_graph import get_portrait_graph
|
|
|
+import logging
|
|
|
+
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
+
|
|
|
+router = APIRouter(prefix="/api/portrait", tags=["portrait"])
|
|
|
+
|
|
|
+
|
|
|
+class PortraitGenerateRequest(BaseModel):
|
|
|
+ user_id: int
|
|
|
+ member_id: int | None = None
|
|
|
+ previous_portrait: str | None = None
|
|
|
+ behavior_summary: dict | None = None
|
|
|
+ profile_snapshot: dict | None = None
|
|
|
+
|
|
|
+
|
|
|
+class PortraitGenerateResponse(BaseModel):
|
|
|
+ portrait_text: str
|
|
|
+
|
|
|
+
|
|
|
+@router.post("/generate", response_model=PortraitGenerateResponse)
|
|
|
+async def generate_portrait(req: PortraitGenerateRequest):
|
|
|
+ """AI 生成用户画像(首次生成)"""
|
|
|
+ try:
|
|
|
+ graph = get_portrait_graph()
|
|
|
+ state = {
|
|
|
+ "user_id": req.user_id,
|
|
|
+ "member_id": req.member_id,
|
|
|
+ "previous_portrait": None,
|
|
|
+ "behavior_summary": req.behavior_summary,
|
|
|
+ "profile_snapshot": req.profile_snapshot,
|
|
|
+ "portrait_text": None,
|
|
|
+ }
|
|
|
+ result = await graph.ainvoke(state)
|
|
|
+ return PortraitGenerateResponse(portrait_text=result["portrait_text"])
|
|
|
+ except Exception as e:
|
|
|
+ logger.exception("画像生成失败")
|
|
|
+ raise HTTPException(status_code=500, detail=str(e))
|
|
|
+
|
|
|
+
|
|
|
+@router.post("/regenerate", response_model=PortraitGenerateResponse)
|
|
|
+async def regenerate_portrait(req: PortraitGenerateRequest):
|
|
|
+ """AI 重画用户画像(参考旧画像)"""
|
|
|
+ try:
|
|
|
+ graph = get_portrait_graph()
|
|
|
+ state = {
|
|
|
+ "user_id": req.user_id,
|
|
|
+ "member_id": req.member_id,
|
|
|
+ "previous_portrait": req.previous_portrait,
|
|
|
+ "behavior_summary": req.behavior_summary,
|
|
|
+ "profile_snapshot": req.profile_snapshot,
|
|
|
+ "portrait_text": None,
|
|
|
+ }
|
|
|
+ result = await graph.ainvoke(state)
|
|
|
+ return PortraitGenerateResponse(portrait_text=result["portrait_text"])
|
|
|
+ except Exception as e:
|
|
|
+ logger.exception("画像重画失败")
|
|
|
+ raise HTTPException(status_code=500, detail=str(e))
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 注册路由到 main.py**
|
|
|
+
|
|
|
+```python
|
|
|
+# main.py 第 30 行附近
|
|
|
+from app.api import portrait
|
|
|
+...
|
|
|
+app.include_router(portrait.router)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: py_compile 验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-langgraph && python3 -m py_compile app/api/portrait.py app/graphs/portrait_generation_graph.py
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-langgraph/app/api/portrait.py cfc-langgraph/app/main.py
|
|
|
+git commit -m "feat: Python 侧新增 portrait 生成/重画 API 端点"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 9:小程序前端 API 封装
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-frontend/utils/api.js`
|
|
|
+
|
|
|
+- [ ] **Step 1: 新增封装**
|
|
|
+
|
|
|
+```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)
|
|
|
+}
|
|
|
+export function generatePortrait(params = {}) {
|
|
|
+ return request('/api/user/portrait/generate', 'POST', params)
|
|
|
+}
|
|
|
+export function regeneratePortrait(params = {}) {
|
|
|
+ return request('/api/user/portrait/regenerate', 'POST', params)
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 语法检查**
|
|
|
+
|
|
|
+```bash
|
|
|
+node --check <(sed -n '/<script>/,/<\/script>/p' cfc-frontend/utils/api.js | sed '1d;$d')
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/utils/api.js
|
|
|
+git commit -m "feat: api.js 新增 generatePortrait/regeneratePortrait 封装"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 10:小程序 portrait-edit.vue 界面升级
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-frontend/pages/profile/portrait-edit.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 升级 template(增加生成/重画按钮、加载态)**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="portrait-edit-page">
|
|
|
+ <view class="section">
|
|
|
+ <view class="section-title">我的画像描述</view>
|
|
|
+ <view class="hint">请输入你的个人画像描述,AI 会根据这些信息提供更有针对性的建议(最多 2000 字)</view>
|
|
|
+
|
|
|
+ <!-- 操作按钮行 -->
|
|
|
+ <view class="action-row">
|
|
|
+ <button class="action-btn" :loading="generating" @click="onGenerate" v-if="!portraitPrompt">
|
|
|
+ 🪄 AI 生成画像
|
|
|
+ </button>
|
|
|
+ <button class="action-btn secondary" :loading="regenerating" @click="onRegenerate" v-if="portraitPrompt">
|
|
|
+ 🔄 重画画像
|
|
|
+ </button>
|
|
|
+ </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>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 升级 script(新增 generate/regenerate 方法)**
|
|
|
+
|
|
|
+```javascript
|
|
|
+import { getPortrait, editPortrait, generatePortrait, regeneratePortrait } from '../../utils/api.js'
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ portraitPrompt: '',
|
|
|
+ renderedSnapshot: '',
|
|
|
+ saving: false,
|
|
|
+ generating: false,
|
|
|
+ regenerating: 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 onGenerate() {
|
|
|
+ this.generating = true
|
|
|
+ try {
|
|
|
+ const memberId = this.$store.state.currentMemberId
|
|
|
+ const res = await generatePortrait({ memberId })
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
+ this.portraitPrompt = res.data.portraitPrompt || ''
|
|
|
+ this.renderedSnapshot = res.data.renderedSnapshot || ''
|
|
|
+ uni.showToast({ title: '生成成功', icon: 'success' })
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: res.message || '生成失败', icon: 'none' })
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ uni.showToast({ title: '生成失败', icon: 'none' })
|
|
|
+ } finally {
|
|
|
+ this.generating = false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ async onRegenerate() {
|
|
|
+ this.regenerating = true
|
|
|
+ try {
|
|
|
+ const memberId = this.$store.state.currentMemberId
|
|
|
+ const res = await regeneratePortrait({
|
|
|
+ memberId,
|
|
|
+ previousPortrait: this.portraitPrompt // 传入当前画像作为参考
|
|
|
+ })
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
+ this.portraitPrompt = res.data.portraitPrompt || ''
|
|
|
+ this.renderedSnapshot = res.data.renderedSnapshot || ''
|
|
|
+ uni.showToast({ title: '重画成功', icon: 'success' })
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: res.message || '重画失败', icon: 'none' })
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ uni.showToast({ title: '重画失败', icon: 'none' })
|
|
|
+ } finally {
|
|
|
+ this.regenerating = false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ 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
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 增加样式**
|
|
|
+
|
|
|
+```css
|
|
|
+.action-row {
|
|
|
+ display: flex;
|
|
|
+ gap: 16rpx;
|
|
|
+ margin-bottom: 16rpx;
|
|
|
+}
|
|
|
+.action-btn {
|
|
|
+ flex: 1;
|
|
|
+ height: 72rpx;
|
|
|
+ border-radius: 36rpx;
|
|
|
+ font-size: 26rpx;
|
|
|
+ background: linear-gradient(135deg, #667eea, #764ba2);
|
|
|
+ color: #fff;
|
|
|
+}
|
|
|
+.action-btn.secondary {
|
|
|
+ background: linear-gradient(135deg, #F97316, #F59E0B);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 语法检查**
|
|
|
+
|
|
|
+```bash
|
|
|
+python -c "import re; f=open(r'D:\cfc\cfc-frontend\pages\profile\portrait-edit.vue','r',encoding='utf-8'); c=f.read(); f.close(); m=re.search(r'<script>(.*?)</script>',c,re.DOTALL); open(r'D:\tmp\portrait_check.mjs','w',encoding='utf-8').write(m.group(1))"
|
|
|
+node --check D:\tmp\portrait_check.mjs
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/profile/portrait-edit.vue
|
|
|
+git commit -m "feat: portrait-edit.vue 新增 AI 生成/重画按钮与逻辑"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 11:全量验收
|
|
|
+
|
|
|
+- [ ] **Step 1: 后端编译**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Python 编译**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-langgraph && python3 -m py_compile app/api/portrait.py app/graphs/portrait_generation_graph.py app/tools/java_client.py
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 前端语法检查**
|
|
|
+
|
|
|
+```bash
|
|
|
+# portrait-edit.vue
|
|
|
+python -c "import re; f=open(r'D:\cfc\cfc-frontend\pages\profile\portrait-edit.vue','r',encoding='utf-8'); c=f.read(); f.close(); m=re.search(r'<script>(.*?)</script>',c,re.DOTALL); open(r'D:\tmp\pe.mjs','w',encoding='utf-8').write(m.group(1))"
|
|
|
+node --check D:\tmp\pe.mjs
|
|
|
+
|
|
|
+# api.js
|
|
|
+node --check <(sed -n '/<script>/,/<\/script>/p' cfc-frontend/utils/api.js | sed '1d;$d')
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 本地联调(需启动后端 9082 + LangGraph 9000)**
|
|
|
+
|
|
|
+1. 调用 `POST /api/user/portrait/generate` → 验证返回 portraitPrompt
|
|
|
+2. 调用 `POST /api/user/portrait/regenerate` → 验证返回新画像
|
|
|
+3. 小程序页面点击「生成画像」「重画」→ 验证 UI 正常
|
|
|
+4. 检查 Python 日志确认 portrait_generation_graph 被调用
|
|
|
+
|
|
|
+- [ ] **Step 5: 提交剩余改动**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add -A
|
|
|
+git commit -m "feat: 用户画像 AI 生成/重画全栈实现"
|
|
|
+git push
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 自检记录
|
|
|
+
|
|
|
+1. **规格覆盖度**:
|
|
|
+ - AI 自动生成 → 任务 1-8
|
|
|
+ - 重画(参考旧画像) → 任务 2(regeneratePortrait)、7(previous_portrait 逻辑)
|
|
|
+ - 用户可编辑 → 已有 editPortrait,保留
|
|
|
+ - 前端入口 → 任务 10
|
|
|
+2. **类型一致性**:
|
|
|
+ - Java: `PortraitService` 接口 → impl → controller → AIService → AiGateway
|
|
|
+ - Python: `PortraitGenerateRequest` Pydantic 模型与 Java 请求体对应
|
|
|
+ - 前端: `generatePortrait({memberId})`、`regeneratePortrait({memberId, previousPortrait})` 与后端端点对应
|
|
|
+3. **边界处理**:
|
|
|
+ - 无行为数据时:graph 仍能基于 profile_snapshot 生成
|
|
|
+ - AI 失败:降级为快照渲染(`renderSnapshot`),保留原画像
|
|
|
+ - memberId 为空:仅基于用户自身行为生成
|
|
|
+ - 并发安全:AI 调用无副作用,幂等
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+**Plan 完整,保存至 `docs/superpowers/plans/2026-09-13-user-portrait-ai-generation.md`。**
|
|
|
+
|
|
|
+**Two execution options:**
|
|
|
+
|
|
|
+**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
|
|
|
+
|
|
|
+**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
|
|
|
+
|
|
|
+**Which approach?**
|