Sfoglia il codice sorgente

feat(portrait): AiGateway 画像生成按文档口径透传结构化 behavior_summary/profile_snapshot

计划 docs/superpowers/plans/2026-09-13-user-portrait-ai-generation.md 任务3步骤3:
AiGateway 应把 behavior_summary/profile_snapshot 作为嵌套 dict 传给 LangGraph
portrait_generation_graph,原实现用 v.toString() 扁平化为 {key=value} 字符串,
Python 侧 Pydantic 模型忽略未知字段导致画像生成只剩「暂无额外数据」。

- AiGateway.generatePortrait: 用 putContextNode 结构化序列化两字段,不再扁平化整个 context
- AIService.generateUserPortrait: 组装 behaviorSummary=家庭上下文、profileSnapshot=ProfileReadService.getProfile(memberId)
  注意:文档所指 gateway/impl/AiGatewayImpl.java 在本仓库不存在,
  AiGateway 为具体类 com.etotem.cfc.service.AiGateway(非接口+impl 拆分),方法已在此类实现。
Sisyphus 2 settimane fa
parent
commit
4dfcce4c3b

+ 16 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java

@@ -68,6 +68,9 @@ public class AIService {
     @Resource
     private FamilyContextService familyContextService;
 
+    @Resource
+    private ProfileReadService profileReadService;
+
     @Value("${dify.callback-secret:}")
     private String callbackSecret;
 
@@ -783,12 +786,23 @@ try {
     }
 
     /**
-     * 生成用户画像(通过AiGateway调用LangGraph)
+     * 生成用户画像(通过AiGateway调用LangGraph portrait graph)
+     * 按文档口径组装:behavior_summary = 家庭上下文行为摘要;
+     * profile_snapshot = 五维画像快照(member/dimension_scores/body_metrics/mind_metrics/problem_domains)。
      */
     public String generateUserPortrait(Long userId, Long memberId, String previousPortrait) {
         try {
             Map<String, Object> context = familyContextService.buildContext(userId);
-            if (memberId != null) context.put("memberId", memberId);
+            // 行为摘要:家庭上下文本身(成员任务完成率/五维能量/认知测评/已购标签/健康报告等)
+            Map<String, Object> behaviorSummary = new HashMap<>(context);
+            behaviorSummary.remove("profileSnapshot");
+            behaviorSummary.remove("memberId");
+            context.put("behaviorSummary", behaviorSummary);
+            if (memberId != null) {
+                context.put("memberId", memberId);
+                // 五维画像快照:与 Python portrait_generation_graph 期望的结构一致
+                context.put("profileSnapshot", profileReadService.getProfile(memberId));
+            }
             return aiGateway.generatePortrait(userId, memberId, previousPortrait, context);
         } catch (Exception e) {
             log.error("AI 生成画像失败: userId={}, memberId={}", userId, memberId, e);

+ 19 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java

@@ -486,7 +486,10 @@ public class AiGateway {
             if (memberId != null) body.put("member_id", memberId);
             if (previousPortrait != null) body.put("previous_portrait", previousPortrait);
             if (context != null) {
-                context.forEach((k, v) -> body.put(k, v != null ? v.toString() : ""));
+                // 文档口径:behavior_summary / profile_snapshot 作为结构化 dict 透传,
+                // portrait_generation_graph 依赖 member/dimension_scores/body_metrics 等字段提取上下文
+                putContextNode(body, "behavior_summary", "behaviorSummary", context);
+                putContextNode(body, "profile_snapshot", "profileSnapshot", context);
             }
             HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
             String url = baseUrl + "/api/portrait/generate";
@@ -504,6 +507,21 @@ public class AiGateway {
         }
     }
 
+    /**
+     * 将 context 中的结构化对象(Map/List)序列化为 JSON 节点放入请求体。
+     * 历史实现用 v.toString() 扁平化,Map 会变成 {key=value} 字符串,
+     * 导致 Python 侧永远收不到 profile_snapshot / behavior_summary 的结构化数据。
+     */
+    private void putContextNode(ObjectNode body, String bodyKey, String contextKey, Map<String, Object> context) {
+        Object value = context.get(contextKey);
+        if (value == null) return;
+        try {
+            body.set(bodyKey, objectMapper.valueToTree(value));
+        } catch (Exception e) {
+            log.warn("AiGateway generatePortrait 序列化{}失败: {}", contextKey, e.getMessage());
+        }
+    }
+
     /**
      * 指标归类兜底:将未命中的原始指标名交给 LangGraph 归类为已有/新定义。
      * @return 建议 JSON 字符串(含 code/name)或 null(关闭/熔断/失败)