55b7c1c83c
- 内部实现重构:每个公有方法内部 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
239 lines
9.7 KiB
Java
239 lines
9.7 KiB
Java
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 服务客户端。
|
||
* <p>通过 {@code lpt.ai-service.url} 配置服务地址;未配置或调用失败时返回 empty,由调用方降级。</p>
|
||
* <p>
|
||
* 内部使用异步任务模式:提交任务到 lpt-ai 后轮询等待结果,
|
||
* 避免同步等待 LLM 响应时阻塞 HTTP 连接。
|
||
* </p>
|
||
*/
|
||
@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<String> aggregateReport(String taskName, List<String> fragments, String expectation) {
|
||
if (!isConfigured() || fragments == null || fragments.isEmpty()) {
|
||
return Optional.empty();
|
||
}
|
||
try {
|
||
List<Map<String, String>> fragmentBodies = fragments.stream()
|
||
.map(content -> Map.of("content", content))
|
||
.toList();
|
||
Map<String, Object> params = expectation == null || expectation.isBlank()
|
||
? Map.of("taskName", taskName, "fragments", fragmentBodies)
|
||
: Map.of("taskName", taskName, "fragments", fragmentBodies, "expectation", expectation);
|
||
|
||
Optional<JsonNode> 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<String> generateMindMap(String taskName, String taskDescription,
|
||
List<String> reports, List<String> fragments) {
|
||
if (!isConfigured()) {
|
||
return Optional.empty();
|
||
}
|
||
try {
|
||
Map<String, Object> params = Map.of(
|
||
"taskName", taskName,
|
||
"taskDescription", taskDescription != null ? taskDescription : "",
|
||
"reports", reports != null ? reports : List.of(),
|
||
"fragments", fragments != null ? fragments : List.of()
|
||
);
|
||
|
||
Optional<JsonNode> 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<JsonNode> compareRecall(String taskName, String standardOutline, String recallOutline) {
|
||
if (!isConfigured()) {
|
||
return Optional.empty();
|
||
}
|
||
try {
|
||
Map<String, Object> 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<JsonNode> submitAndWait(String type, Map<String, Object> 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<String, Object> params) {
|
||
try {
|
||
Map<String, Object> 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<String> 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<JsonNode> 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<String> 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();
|
||
}
|
||
}
|
||
}
|