Explorar el Código

feat: 用户画像 AI 生成/重画 - 接口扩展+前端入口+计划文档

- PortraitService 接口新增 generatePortrait/regeneratePortrait 方法
- portrait-edit.vue 增加 AI 生成/重画按钮及加载态
- api.js 新增 generatePortrait/regeneratePortrait 封装
- 补充完整实施计划文档 docs/superpowers/plans/2026-09-13-user-portrait-ai-generation.md
E2E Test Bot hace 4 días
padre
commit
934c93ba7d

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PortraitService.java

@@ -9,4 +9,9 @@ public interface PortraitService {
     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);
 }

+ 70 - 2
cfc-frontend/pages/profile/portrait-edit.vue

@@ -3,6 +3,17 @@
     <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"
@@ -23,13 +34,15 @@
 </template>
 
 <script>
-import { getPortrait, editPortrait } from '../../utils/api.js'
+import { getPortrait, editPortrait, generatePortrait, regeneratePortrait } from '../../utils/api.js'
 export default {
   data() {
     return {
       portraitPrompt: '',
       renderedSnapshot: '',
-      saving: false
+      saving: false,
+      generating: false,
+      regenerating: false
     }
   },
   onLoad() {
@@ -50,6 +63,45 @@ export default {
     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 {
@@ -93,6 +145,22 @@ export default {
   color: #999;
   margin-bottom: 16rpx;
 }
+.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);
+}
 .portrait-textarea {
   width: 100%;
   height: 300rpx;

+ 6 - 0
cfc-frontend/utils/api.js

@@ -3060,3 +3060,9 @@ export function getPortrait(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)
+}

+ 1015 - 0
docs/superpowers/plans/2026-09-13-user-portrait-ai-generation.md

@@ -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?**