2026-09-13-user-portrait-ai-generation.md 33 KB

用户画像 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 接口

    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: 编译验证

    cd cfc-backend && mvn clean compile -q
    
  • [ ] Step 3: Commit

    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

    @Resource
    private AIService aiService;
    
  • [ ] Step 2: 实现 generatePortrait

    @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

    @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: 编译验证

    cd cfc-backend && mvn clean compile -q
    
  • [ ] Step 5: Commit

    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 接口

    /**
    * AI 生成用户画像
    * @param userId 用户ID
    * @param memberId 成员ID(可空,为空时仅基于用户自身行为)
    * @param previousPortrait 旧画像文本(重画时传入,首次生成为 null)
    * @return 生成的画像文本,失败返回 null
    */
    String generateUserPortrait(Long userId, Long memberId, String previousPortrait);
    
  • [ ] Step 2: 实现 AIServiceImpl.generateUserPortrait

    @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

    // 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: 编译验证

    cd cfc-backend && mvn clean compile -q
    
  • [ ] Step 5: Commit

    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 端点

    @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 端点

    @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: 编译验证

    cd cfc-backend && mvn clean compile -q
    
  • [ ] Step 5: Commit

    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 方法

    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 验证

    cd cfc-langgraph && python3 -m py_compile app/tools/java_client.py
    
  • [ ] Step 3: Commit

    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

    @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: 编译验证

    cd cfc-backend && mvn clean compile -q
    
  • [ ] Step 3: Commit

    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 文件

    """用户画像生成/重画 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 验证

    cd cfc-langgraph && python3 -m py_compile app/graphs/portrait_generation_graph.py
    
  • [ ] Step 3: Commit

    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

    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

    # main.py 第 30 行附近
    from app.api import portrait
    ...
    app.include_router(portrait.router)
    
  • [ ] Step 3: py_compile 验证

    cd cfc-langgraph && python3 -m py_compile app/api/portrait.py app/graphs/portrait_generation_graph.py
    
  • [ ] Step 4: Commit

    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: 新增封装

    // 用户画像
    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: 语法检查

    node --check <(sed -n '/<script>/,/<\/script>/p' cfc-frontend/utils/api.js | sed '1d;$d')
    
  • [ ] Step 3: Commit

    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(增加生成/重画按钮、加载态)

    <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 方法)

    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: 增加样式

    .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: 语法检查

    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

    git add cfc-frontend/pages/profile/portrait-edit.vue
    git commit -m "feat: portrait-edit.vue 新增 AI 生成/重画按钮与逻辑"
    

任务 11:全量验收

  • [ ] Step 1: 后端编译

    cd cfc-backend && mvn clean compile -q
    
  • [ ] Step 2: Python 编译

    cd cfc-langgraph && python3 -m py_compile app/api/portrait.py app/graphs/portrait_generation_graph.py app/tools/java_client.py
    
  • [ ] Step 3: 前端语法检查

    # 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: 提交剩余改动

    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?