package com.guo.learningprogresstracker.service.impl; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Optional; /** * lpt-ai 独立 AI 服务客户端。 *

通过 {@code lpt.ai-service.url} 配置服务地址;未配置或调用失败时返回 empty,由调用方降级。

*

* 内部使用异步任务模式:提交任务到 lpt-ai 后轮询等待结果, * 避免同步等待 LLM 响应时阻塞 HTTP 连接。 *

*/ @Slf4j @Component @ConfigurationProperties(prefix = "lpt.ai-service") @Setter public class AiServiceClient { /** lpt-ai 服务地址,如 http://localhost:5199 */ private String url; /** 超时秒数(等待 AI 任务完成的最长时间) */ private int timeoutSeconds = 600; private HttpClient httpClient; private final ObjectMapper objectMapper = new ObjectMapper(); @PostConstruct void init() { httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); if (isConfigured()) { log.info("AiServiceClient 已配置: {} (超时 {}s, 异步任务模式)", url, timeoutSeconds); } else { log.info("AiServiceClient 未配置,AI 功能不可用(将降级为内置逻辑)"); } } public boolean isConfigured() { return url != null && !url.isBlank(); } // ============ 公开 API(签名不变,内部改为 submit + poll)============ /** * 调用 lpt-ai 将残片聚合为学习报告草稿。 */ public Optional aggregateReport(String taskName, List fragments, String expectation) { if (!isConfigured() || fragments == null || fragments.isEmpty()) { return Optional.empty(); } try { List> fragmentBodies = fragments.stream() .map(content -> Map.of("content", content)) .toList(); Map params = expectation == null || expectation.isBlank() ? Map.of("taskName", taskName, "fragments", fragmentBodies) : Map.of("taskName", taskName, "fragments", fragmentBodies, "expectation", expectation); Optional result = submitAndWait("aggregate-report", params); return result.map(r -> r.path("report").asText(null)).filter(s -> s != null && !s.isBlank()); } catch (Exception e) { log.warn("lpt-ai 聚合报告异常,将降级: {}", e.getMessage()); return Optional.empty(); } } /** * 调用 lpt-ai 从学习数据生成思维导图大纲。 */ public Optional generateMindMap(String taskName, String taskDescription, List reports, List fragments) { if (!isConfigured()) { return Optional.empty(); } try { Map params = Map.of( "taskName", taskName, "taskDescription", taskDescription != null ? taskDescription : "", "reports", reports != null ? reports : List.of(), "fragments", fragments != null ? fragments : List.of() ); Optional result = submitAndWait("generate-mind-map", params); return result.map(r -> r.path("outline").asText(null)).filter(s -> s != null && !s.isBlank()); } catch (Exception e) { log.warn("lpt-ai 思维导图异常,将降级: {}", e.getMessage()); return Optional.empty(); } } /** * 调用 lpt-ai 进行语义化回忆对比。 */ public Optional compareRecall(String taskName, String standardOutline, String recallOutline) { if (!isConfigured()) { return Optional.empty(); } try { Map params = Map.of( "taskName", taskName, "standardOutline", standardOutline, "recallOutline", recallOutline ); return submitAndWait("compare-recall", params); } catch (Exception e) { log.warn("lpt-ai 回忆对比异常,将降级为内置算法: {}", e.getMessage()); return Optional.empty(); } } // ============ 内部异步 submit + poll ============ /** 提交任务并同步等待结果(内部轮询,调用方无感知) */ private Optional submitAndWait(String type, Map params) { String taskId = submitTask(type, params); if (taskId == null) { return Optional.empty(); } return pollResult(taskId); } /** 提交任务到 lpt-ai /ai/tasks */ private String submitTask(String type, Map params) { try { Map body = Map.of("type", type, "params", params); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url + "/ai/tasks")) .timeout(Duration.ofSeconds(30)) // 提交本身不应超时 .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))) .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != 201) { log.warn("lpt-ai 提交任务失败: type={}, status={}, body={}", type, response.statusCode(), response.body() != null ? response.body().substring(0, Math.min(200, response.body().length())) : ""); return null; } JsonNode json = objectMapper.readTree(response.body()); String taskId = json.path("taskId").asText(null); if (taskId == null || taskId.isBlank()) { log.warn("lpt-ai 提交任务成功但未返回 taskId"); return null; } log.info("lpt-ai 任务已提交: type={}, taskId={}", type, taskId); return taskId; } catch (Exception e) { log.warn("lpt-ai 提交任务异常: {}", e.getMessage()); return null; } } /** 轮询等待任务完成 */ private Optional pollResult(String taskId) { Instant deadline = Instant.now().plusSeconds(timeoutSeconds); int pollCount = 0; try { // 给 worker 一点启动时间 Thread.sleep(500); while (Instant.now().isBefore(deadline)) { pollCount++; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url + "/ai/tasks/" + taskId)) .timeout(Duration.ofSeconds(10)) .GET() .build(); try { HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 404) { log.warn("lpt-ai 任务 {} 不存在(可能已过期或被清理)", taskId); return Optional.empty(); } if (response.statusCode() != 200) { log.warn("lpt-ai 查询任务失败: taskId={}, status={}", taskId, response.statusCode()); // 临时故障,继续轮询 Thread.sleep(2000); continue; } JsonNode json = objectMapper.readTree(response.body()); String status = json.path("status").asText(""); switch (status) { case "done": log.info("lpt-ai 任务完成: taskId={}, 轮询次数={}", taskId, pollCount); return Optional.ofNullable(json.path("result")); case "failed": String err = json.path("error").asText("未知错误"); log.warn("lpt-ai 任务失败: taskId={}, error={}", taskId, err); return Optional.empty(); case "pending": case "running": // 还在处理,继续轮询 break; default: log.warn("lpt-ai 未知任务状态: taskId={}, status={}", taskId, status); return Optional.empty(); } } catch (Exception e) { log.warn("lpt-ai 轮询异常 (第{}次): {}", pollCount, e.getMessage()); } // 退避:前 10 次 2s,之后 5s long delay = pollCount <= 10 ? 2000 : 5000; Thread.sleep(delay); } log.warn("lpt-ai 任务超时: taskId={}, timeout={}s", taskId, timeoutSeconds); return Optional.empty(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.warn("lpt-ai 轮询被中断: taskId={}", taskId); return Optional.empty(); } } }