From 55b7c1c83c59d7fd1b09c1dd492ff435f344a4c7 Mon Sep 17 00:00:00 2001 From: cat_shark <1716967236@qq.com> Date: Sat, 4 Jul 2026 16:41:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=90=8E=E7=AB=AF)=EF=BC=9AAiServiceClien?= =?UTF-8?q?t=20=E6=94=B9=E4=B8=BA=20submit+poll=20=E5=BC=82=E6=AD=A5?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 内部实现重构:每个公有方法内部 submitAndWait() → submitTask() + pollResult() - submitTask(): POST /ai/tasks,返回 taskId(30s 超时) - pollResult(): 轮询 GET /ai/tasks/:taskId,500ms 首次延迟, 2s × 10 次后退避到 5s,总超时 600s(10 min) - 公有方法签名完全不变:aggregateReport()、generateMindMap()、compareRecall() - 异常失败时返回 Optional.empty(),调用方降级逻辑不变 - application.yml timeout-seconds: 180 → 600 --- .../service/impl/AiServiceClient.java | 182 ++++++++++++------ src/main/resources/application.yml | 2 +- 2 files changed, 126 insertions(+), 58 deletions(-) diff --git a/src/main/java/com/guo/learningprogresstracker/service/impl/AiServiceClient.java b/src/main/java/com/guo/learningprogresstracker/service/impl/AiServiceClient.java index d3ebcaf..7f2dd74 100644 --- a/src/main/java/com/guo/learningprogresstracker/service/impl/AiServiceClient.java +++ b/src/main/java/com/guo/learningprogresstracker/service/impl/AiServiceClient.java @@ -13,6 +13,7 @@ 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; @@ -20,6 +21,10 @@ import java.util.Optional; /** * lpt-ai 独立 AI 服务客户端。 *

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

+ *

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

*/ @Slf4j @Component @@ -29,8 +34,8 @@ public class AiServiceClient { /** lpt-ai 服务地址,如 http://localhost:5199 */ private String url; - /** 超时秒数 */ - private int timeoutSeconds = 90; + /** 超时秒数(等待 AI 任务完成的最长时间) */ + private int timeoutSeconds = 600; private HttpClient httpClient; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -41,9 +46,9 @@ public class AiServiceClient { .connectTimeout(Duration.ofSeconds(10)) .build(); if (isConfigured()) { - log.info("AiServiceClient 已配置: {}", url); + log.info("AiServiceClient 已配置: {} (超时 {}s, 异步任务模式)", url, timeoutSeconds); } else { - log.info("AiServiceClient 未配置,AI 聚合功能不可用(将降级为拼接)"); + log.info("AiServiceClient 未配置,AI 功能不可用(将降级为内置逻辑)"); } } @@ -51,10 +56,10 @@ public class AiServiceClient { return url != null && !url.isBlank(); } + // ============ 公开 API(签名不变,内部改为 submit + poll)============ + /** * 调用 lpt-ai 将残片聚合为学习报告草稿。 - * - * @return 聚合后的报告文本;服务未配置或调用失败时返回 empty */ public Optional aggregateReport(String taskName, List fragments, String expectation) { if (!isConfigured() || fragments == null || fragments.isEmpty()) { @@ -64,36 +69,20 @@ public class AiServiceClient { List> fragmentBodies = fragments.stream() .map(content -> Map.of("content", content)) .toList(); - Map body = expectation == null || expectation.isBlank() + Map params = expectation == null || expectation.isBlank() ? Map.of("taskName", taskName, "fragments", fragmentBodies) : Map.of("taskName", taskName, "fragments", fragmentBodies, "expectation", expectation); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url + "/ai/aggregate-report")) - .timeout(Duration.ofSeconds(timeoutSeconds)) - .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))) - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) { - log.warn("lpt-ai 聚合报告失败: status={}, body={}", response.statusCode(), - response.body() != null ? response.body().substring(0, Math.min(200, response.body().length())) : ""); - return Optional.empty(); - } - JsonNode json = objectMapper.readTree(response.body()); - String report = json.path("report").asText(null); - return Optional.ofNullable(report).filter(s -> !s.isBlank()); + 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()); + log.warn("lpt-ai 聚合报告异常,将降级: {}", e.getMessage()); return Optional.empty(); } } /** - * 调用 lpt-ai 从学习数据生成思维导图大纲(缩进文本格式)。 - * - * @return 缩进大纲文本;服务未配置或调用失败时返回 empty + * 调用 lpt-ai 从学习数据生成思维导图大纲。 */ public Optional generateMindMap(String taskName, String taskDescription, List reports, List fragments) { @@ -101,69 +90,148 @@ public class AiServiceClient { return Optional.empty(); } try { - Map body = Map.of( + Map params = Map.of( "taskName", taskName, "taskDescription", taskDescription != null ? taskDescription : "", "reports", reports != null ? reports : List.of(), "fragments", fragments != null ? fragments : List.of() ); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url + "/ai/generate-mind-map")) - .timeout(Duration.ofSeconds(timeoutSeconds)) - .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))) - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) { - log.warn("lpt-ai 生成思维导图失败: status={}, body={}", response.statusCode(), - response.body() != null ? response.body().substring(0, Math.min(200, response.body().length())) : ""); - return Optional.empty(); - } - JsonNode json = objectMapper.readTree(response.body()); - String outline = json.path("outline").asText(null); - return Optional.ofNullable(outline).filter(s -> !s.isBlank()); + 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()); + log.warn("lpt-ai 思维导图异常,将降级: {}", e.getMessage()); return Optional.empty(); } } /** * 调用 lpt-ai 进行语义化回忆对比。 - * 注意:AI 返回平铺的匹配列表,不返回完整树结构。 - * 树标注(MATCHED/MISSED)由 StandardMindMapServiceImpl 基于本方法返回的 JSON 完成。 - * - * @return AI 返回的原始 JSON;服务未配置或调用失败时返回 empty */ public Optional compareRecall(String taskName, String standardOutline, String recallOutline) { if (!isConfigured()) { return Optional.empty(); } try { - Map body = Map.of( + 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/compare-recall")) - .timeout(Duration.ofSeconds(timeoutSeconds)) + .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() != 200) { - log.warn("lpt-ai 回忆对比失败: status={}, body={}", response.statusCode(), + 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 Optional.empty(); + return null; } - return Optional.of(objectMapper.readTree(response.body())); + 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()); + 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(); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5729de4..a9bc0b4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -56,4 +56,4 @@ management: lpt: ai-service: url: http://localhost:5199 - timeout-seconds: 180 \ No newline at end of file + timeout-seconds: 600 \ No newline at end of file