Selaa lähdekoodia

feat(ai-q): 服务层 — 场景 CRUD + start/answer/finish 会话编排 + AiGateway 扩展

Xiaogang Liao 1 kuukausi sitten
vanhempi
sitoutus
3741198490

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

@@ -11,6 +11,7 @@ import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.http.HttpEntity;
 import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
 import org.springframework.stereotype.Service;
 import org.springframework.web.client.RestTemplate;
 
@@ -43,7 +44,16 @@ public class AiGateway {
     @Value("${python.circuit-breaker.reset-timeout-ms:30000}")
     private int resetTimeoutMs;
 
+    @Value("${langgraph.profile-timeout-ms:90000}")
+    private int profileTimeoutMs;
+
     private final RestTemplate restTemplate = new RestTemplate();
+
+    /**
+     * 画像生成专用客户端:独立 90s 读超时(LangGraph 推理可达 30-60s)。
+     * 在 {@link #init()} 中设置 factory(@Value 注入晚于构造)。
+     */
+    private final RestTemplate profileRestTemplate = new RestTemplate();
     private final ObjectMapper objectMapper = new ObjectMapper();
 
     // 熔断器状态
@@ -53,8 +63,13 @@ public class AiGateway {
 
     @PostConstruct
     public void init() {
+        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
+        factory.setConnectTimeout(5000);
+        factory.setReadTimeout(profileTimeoutMs);
+        profileRestTemplate.setRequestFactory(factory);
         if (enabled) {
-            log.info("AiGateway 已启用: baseUrl={}, timeout={}ms", baseUrl, timeoutMs);
+            log.info("AiGateway 已启用: baseUrl={}, timeout={}ms, profile-timeout={}ms",
+                    baseUrl, timeoutMs, profileTimeoutMs);
         } else {
             log.info("AiGateway 已禁用, 所有请求走 Dify");
         }
@@ -333,4 +348,71 @@ public class AiGateway {
             return null;
         }
     }
+
+    /**
+     * 调用 LangGraph 动态出题(/api/v1/qna/advance)
+     */
+    public Map<String, Object> advanceQuestionnaire(Map<String, Object> scene, List<Map<String, Object>> history) {
+        if (!enabled || isCircuitOpen()) return null;
+        try {
+            ObjectNode body = objectMapper.createObjectNode();
+            body.set("scene", objectMapper.valueToTree(scene));
+            ArrayNode hist = body.putArray("history");
+            if (history != null) {
+                for (Map<String, Object> item : history) {
+                    hist.add(objectMapper.valueToTree(item));
+                }
+            }
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/qna/advance";
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                Map<String, Object> result = new LinkedHashMap<>();
+                result.put("action", root.has("action") ? root.get("action").asText() : "ask");
+                result.put("question", root.has("question") ? objectMapper.convertValue(root.get("question"), Map.class) : null);
+                result.put("reason", root.has("reason") ? root.get("reason").asText() : "");
+                consecutiveFailures.set(0);
+                return result;
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway advanceQuestionnaire 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+
+    /**
+     * 调用 LangGraph 生成画像(/api/v1/qna/profile,独立 90s 读超时)
+     */
+    public Map<String, Object> generateProfile(Map<String, Object> scene, List<Map<String, Object>> history) {
+        if (!enabled || isCircuitOpen()) return null;
+        try {
+            ObjectNode body = objectMapper.createObjectNode();
+            body.set("scene", objectMapper.valueToTree(scene));
+            ArrayNode hist = body.putArray("history");
+            if (history != null) {
+                for (Map<String, Object> item : history) {
+                    hist.add(objectMapper.valueToTree(item));
+                }
+            }
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/qna/profile";
+            ResponseEntity<String> response = profileRestTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                Map<String, Object> result = new LinkedHashMap<>();
+                result.put("profile", root.has("profile") ? objectMapper.convertValue(root.get("profile"), Map.class) : null);
+                result.put("kb_used", root.has("kb_used") ? root.get("kb_used").asBoolean() : false);
+                consecutiveFailures.set(0);
+                return result;
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway generateProfile 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
 }

+ 21 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AiQuestionnaireService.java

@@ -0,0 +1,21 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.AiQProfile;
+import com.etotem.cfc.entity.AiQScene;
+import com.etotem.cfc.entity.AiQSession;
+
+import java.util.List;
+import java.util.Map;
+
+public interface AiQuestionnaireService {
+    AiQScene saveScene(AiQScene scene, Long adminId);
+    List<AiQScene> listScenes(Boolean enabledOnly);
+    void deleteScene(Long id, Long adminId);
+
+    Map<String, Object> start(Long userId, Long sceneId, Long memberId);
+    Map<String, Object> answer(Long userId, Long sessionId, String answer);
+    AiQProfile finish(Long userId, Long sessionId);
+    Map<String, Object> getProfileDetail(Long userId, Long sessionId);
+    List<AiQSession> getHistory(Long userId, Long memberId, Long sceneId);
+    void abort(Long userId, Long sessionId);
+}

+ 333 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/AiQuestionnaireServiceImpl.java

@@ -0,0 +1,333 @@
+package com.etotem.cfc.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.AiQProfile;
+import com.etotem.cfc.entity.AiQScene;
+import com.etotem.cfc.entity.AiQSession;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.AiQProfileMapper;
+import com.etotem.cfc.mapper.AiQSceneMapper;
+import com.etotem.cfc.mapper.AiQSessionMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.service.AiGateway;
+import com.etotem.cfc.service.AiQuestionnaireService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service("aiQuestionnaireService")
+public class AiQuestionnaireServiceImpl implements AiQuestionnaireService {
+
+    private static final Logger log = LoggerFactory.getLogger(AiQuestionnaireServiceImpl.class);
+
+    @Resource
+    private AiQSceneMapper aiQSceneMapper;
+
+    @Resource
+    private AiQSessionMapper aiQSessionMapper;
+
+    @Resource
+    private AiQProfileMapper aiQProfileMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private AiGateway aiGateway;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    // ── 场景管理 ──
+
+    @Override
+    public AiQScene saveScene(AiQScene scene, Long adminId) {
+        scene.setUpdatedAt(new Date());
+        if (scene.getId() != null) {
+            aiQSceneMapper.updateById(scene);
+        } else {
+            scene.setCreatedAt(new Date());
+            if (scene.getEnabled() == null) scene.setEnabled(1);
+            if (scene.getMaxQuestions() == null) scene.setMaxQuestions(12);
+            aiQSceneMapper.insert(scene);
+        }
+        return scene;
+    }
+
+    @Override
+    public List<AiQScene> listScenes(Boolean enabledOnly) {
+        List<AiQScene> all = aiQSceneMapper.selectList(null);
+        if (enabledOnly == null || !enabledOnly) return all;
+        List<AiQScene> result = new ArrayList<>();
+        for (AiQScene s : all) {
+            if (s.getEnabled() != null && s.getEnabled() == 1) result.add(s);
+        }
+        return result;
+    }
+
+    @Override
+    public void deleteScene(Long id, Long adminId) {
+        Long cnt = aiQSessionMapper.selectCount(new LambdaQueryWrapper<AiQSession>()
+                .eq(AiQSession::getSceneId, id));
+        if (cnt != null && cnt > 0) {
+            throw new RuntimeException("该场景已有问卷记录,请改用禁用");
+        }
+        aiQSceneMapper.deleteById(id);
+    }
+
+    // ── 会话流程 ──
+
+    private AiQScene requireScene(Long sceneId) {
+        AiQScene scene = aiQSceneMapper.selectById(sceneId);
+        if (scene == null) throw new RuntimeException("场景不存在");
+        if (scene.getEnabled() == null || scene.getEnabled() != 1) throw new RuntimeException("场景未启用");
+        return scene;
+    }
+
+    private FamilyMember requireMember(Long userId, Long memberId) {
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        if (member == null) throw new RuntimeException("家庭成员不存在");
+        return member;
+    }
+
+    private Map<String, Object> toSceneMap(AiQScene s) {
+        Map<String, Object> m = new HashMap<>();
+        m.put("scene_key", s.getSceneKey());
+        m.put("scene_name", s.getSceneName());
+        m.put("opening_prompt", s.getOpeningPrompt());
+        try {
+            m.put("dimensions_json", objectMapper.readValue(
+                    s.getDimensionsJson() == null ? "{}" : s.getDimensionsJson(), Map.class));
+        } catch (Exception e) {
+            m.put("dimensions_json", new HashMap<>());
+        }
+        m.put("kb_scope", Arrays.asList(
+                s.getKbScope() == null ? "microbiome" : s.getKbScope().split(",")));
+        m.put("max_questions", s.getMaxQuestions() == null ? 12 : s.getMaxQuestions());
+        m.put("system_prompt", s.getSystemPrompt());
+        return m;
+    }
+
+    private List<Map<String, Object>> parseHistory(AiQSession session) {
+        List<Map<String, Object>> history = new ArrayList<>();
+        try {
+            if (session.getHistoryJson() != null && !session.getHistoryJson().isEmpty()) {
+                history = objectMapper.readValue(session.getHistoryJson(),
+                        objectMapper.getTypeFactory().constructCollectionType(List.class, Map.class));
+            }
+        } catch (Exception e) {
+            log.warn("解析会话历史失败: sessionId={}", session.getId());
+        }
+        return history;
+    }
+
+    private Map<String, Object> parseQuestion(String json) {
+        try {
+            return json == null ? null : objectMapper.readValue(json, Map.class);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    @Override
+    public Map<String, Object> start(Long userId, Long sceneId, Long memberId) {
+        AiQScene scene = requireScene(sceneId);
+        FamilyMember member = requireMember(userId, memberId);
+
+        AiQSession session = new AiQSession();
+        session.setSceneId(sceneId);
+        session.setUserId(userId);
+        session.setMemberId(memberId);
+        session.setFamilyId(member.getFamilyId());
+        session.setStatus("running");
+        session.setHistoryJson("[]");
+        session.setQuestionCount(0);
+        session.setCreatedAt(new Date());
+        aiQSessionMapper.insert(session);
+
+        List<Map<String, Object>> history = new ArrayList<>();
+        Map<String, Object> resp = aiGateway.advanceQuestionnaire(toSceneMap(scene), history);
+        Map<String, Object> question;
+        if (resp != null && resp.get("question") != null) {
+            question = (Map<String, Object>) resp.get("question");
+        } else {
+            question = fallbackQuestion(0);
+        }
+        session.setCurrentQuestionJson(toJson(question));
+        aiQSessionMapper.updateById(session);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("sessionId", session.getId());
+        result.put("question", question);
+        result.put("answeredCount", 0);
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> answer(Long userId, Long sessionId, String answer) {
+        AiQSession session = aiQSessionMapper.selectById(sessionId);
+        if (session == null) throw new RuntimeException("会话不存在");
+        if (!"running".equals(session.getStatus())) throw new RuntimeException("问卷已完成");
+        if (answer == null || answer.trim().isEmpty()) throw new RuntimeException("请先作答");
+
+        AiQScene scene = requireScene(session.getSceneId());
+
+        List<Map<String, Object>> history = parseHistory(session);
+        Map<String, Object> current = parseQuestion(session.getCurrentQuestionJson());
+        Map<String, Object> item = new LinkedHashMap<>();
+        item.put("question", current == null ? fallbackQuestion(history.size()) : current);
+        item.put("answer", answer.trim());
+        history.add(item);
+        session.setHistoryJson(toJson(history));
+        session.setQuestionCount(history.size());
+
+        int max = scene.getMaxQuestions() == null ? 12 : scene.getMaxQuestions();
+        if (history.size() >= max) {
+            AiQProfile profile = doGenerateProfile(session, scene, history);
+            session.setStatus("finished");
+            session.setFinishedAt(new Date());
+            session.setCurrentQuestionJson(null);
+            aiQSessionMapper.updateById(session);
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("action", "finish");
+            result.put("finished", true);
+            result.put("profile", toProfileMap(profile));
+            return result;
+        }
+
+        Map<String, Object> resp = aiGateway.advanceQuestionnaire(toSceneMap(scene), history);
+        Map<String, Object> question;
+        if (resp != null && resp.get("question") != null) {
+            question = (Map<String, Object>) resp.get("question");
+        } else {
+            question = fallbackQuestion(history.size());
+        }
+        session.setCurrentQuestionJson(toJson(question));
+        aiQSessionMapper.updateById(session);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("action", "ask");
+        result.put("finished", false);
+        result.put("question", question);
+        result.put("answeredCount", history.size());
+        return result;
+    }
+
+    @Override
+    public AiQProfile finish(Long userId, Long sessionId) {
+        AiQSession session = aiQSessionMapper.selectById(sessionId);
+        if (session == null) throw new RuntimeException("会话不存在");
+        if ("finished".equals(session.getStatus())) {
+            return aiQProfileMapper.selectOne(new LambdaQueryWrapper<AiQProfile>()
+                    .eq(AiQProfile::getSessionId, sessionId));
+        }
+        AiQScene scene = requireScene(session.getSceneId());
+        List<Map<String, Object>> history = parseHistory(session);
+        if (history.isEmpty()) throw new RuntimeException("尚无回答,无法生成画像");
+
+        AiQProfile profile = doGenerateProfile(session, scene, history);
+        session.setStatus("finished");
+        session.setFinishedAt(new Date());
+        session.setCurrentQuestionJson(null);
+        aiQSessionMapper.updateById(session);
+        return profile;
+    }
+
+    private AiQProfile doGenerateProfile(AiQSession session, AiQScene scene, List<Map<String, Object>> history) {
+        Map<String, Object> resp = aiGateway.generateProfile(toSceneMap(scene), history);
+        AiQProfile profile = new AiQProfile();
+        profile.setSessionId(session.getId());
+        profile.setSceneId(session.getSceneId());
+        profile.setMemberId(session.getMemberId());
+        if (resp != null && resp.get("profile") != null) {
+            Map<String, Object> p = (Map<String, Object>) resp.get("profile");
+            Object up = p.get("user_profile");
+            Object np = p.get("need_profile");
+            profile.setUserProfileJson(toJson(up == null ? Collections.emptyList() : up));
+            profile.setNeedProfileJson(toJson(np == null ? Collections.emptyList() : np));
+            profile.setKbUsed(Boolean.TRUE.equals(resp.get("kb_used")) ? 1 : 0);
+            profile.setRawResult(toJson(p));
+        } else {
+            throw new RuntimeException("画像生成失败,请稍后重试");
+        }
+        profile.setCreatedAt(new Date());
+        aiQProfileMapper.insert(profile);
+        return aiQProfileMapper.selectById(profile.getId());
+    }
+
+    @Override
+    public Map<String, Object> getProfileDetail(Long userId, Long sessionId) {
+        AiQSession session = aiQSessionMapper.selectById(sessionId);
+        if (session == null) throw new RuntimeException("会话不存在");
+        AiQProfile profile = aiQProfileMapper.selectOne(new LambdaQueryWrapper<AiQProfile>()
+                .eq(AiQProfile::getSessionId, sessionId));
+        if (profile == null) throw new RuntimeException("画像不存在");
+        return toProfileMap(profile);
+    }
+
+    @Override
+    public List<AiQSession> getHistory(Long userId, Long memberId, Long sceneId) {
+        LambdaQueryWrapper<AiQSession> qw = new LambdaQueryWrapper<AiQSession>()
+                .eq(AiQSession::getMemberId, memberId)
+                .orderByDesc(AiQSession::getUpdatedAt);
+        if (sceneId != null) qw.eq(AiQSession::getSceneId, sceneId);
+        return aiQSessionMapper.selectList(qw);
+    }
+
+    @Override
+    public void abort(Long userId, Long sessionId) {
+        AiQSession session = aiQSessionMapper.selectById(sessionId);
+        if (session == null) return;
+        if ("running".equals(session.getStatus())) {
+            session.setStatus("aborted");
+            aiQSessionMapper.updateById(session);
+        }
+    }
+
+    // ── 工具方法 ──
+
+    private Map<String, Object> fallbackQuestion(int index) {
+        Map<String, Object> q = new LinkedHashMap<>();
+        q.put("id", "fb" + (index + 1));
+        q.put("type", "text");
+        q.put("text", "请简单描述您最近一周的饮食和作息情况。");
+        return q;
+    }
+
+    private String toJson(Object o) {
+        try {
+            return objectMapper.writeValueAsString(o);
+        } catch (Exception e) {
+            return "{}";
+        }
+    }
+
+    private Map<String, Object> toProfileMap(AiQProfile p) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("id", p.getId());
+        m.put("sessionId", p.getSessionId());
+        m.put("memberId", p.getMemberId());
+        try {
+            m.put("userProfile", p.getUserProfileJson() == null ? Collections.emptyList()
+                    : objectMapper.readValue(p.getUserProfileJson(), List.class));
+            m.put("needProfile", p.getNeedProfileJson() == null ? Collections.emptyList()
+                    : objectMapper.readValue(p.getNeedProfileJson(), List.class));
+        } catch (Exception e) {
+            m.put("userProfile", Collections.emptyList());
+            m.put("needProfile", Collections.emptyList());
+        }
+        m.put("kbUsed", p.getKbUsed());
+        m.put("createdAt", p.getCreatedAt());
+        return m;
+    }
+}

+ 1 - 0
cfc-backend/src/main/resources/application.yml

@@ -107,6 +107,7 @@ langgraph:
   # 开发环境默认 localhost,生产环境指向 ai.etotem.com.cn
   # TODO(部署): 容器化时需确保 uploads/health-reports 对 LangGraph 容器可见
   base-url: ${LANGGRAPH_BASE_URL:http://localhost:9000}
+  profile-timeout-ms: 90000   # 画像生成推理可达 30-60s,独立超时
 
 math:
   verify-mode: dify  # dify | eval; dify=走Dify工作流, eval=服务端计算(兜底)