feat(后端):AiServiceClient 改为 submit+poll 异步任务模式
- 内部实现重构:每个公有方法内部 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
This commit is contained in:
@@ -13,6 +13,7 @@ import java.net.http.HttpClient;
|
|||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -20,6 +21,10 @@ import java.util.Optional;
|
|||||||
/**
|
/**
|
||||||
* lpt-ai 独立 AI 服务客户端。
|
* lpt-ai 独立 AI 服务客户端。
|
||||||
* <p>通过 {@code lpt.ai-service.url} 配置服务地址;未配置或调用失败时返回 empty,由调用方降级。</p>
|
* <p>通过 {@code lpt.ai-service.url} 配置服务地址;未配置或调用失败时返回 empty,由调用方降级。</p>
|
||||||
|
* <p>
|
||||||
|
* 内部使用异步任务模式:提交任务到 lpt-ai 后轮询等待结果,
|
||||||
|
* 避免同步等待 LLM 响应时阻塞 HTTP 连接。
|
||||||
|
* </p>
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@@ -29,8 +34,8 @@ public class AiServiceClient {
|
|||||||
|
|
||||||
/** lpt-ai 服务地址,如 http://localhost:5199 */
|
/** lpt-ai 服务地址,如 http://localhost:5199 */
|
||||||
private String url;
|
private String url;
|
||||||
/** 超时秒数 */
|
/** 超时秒数(等待 AI 任务完成的最长时间) */
|
||||||
private int timeoutSeconds = 90;
|
private int timeoutSeconds = 600;
|
||||||
|
|
||||||
private HttpClient httpClient;
|
private HttpClient httpClient;
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
@@ -41,9 +46,9 @@ public class AiServiceClient {
|
|||||||
.connectTimeout(Duration.ofSeconds(10))
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
.build();
|
.build();
|
||||||
if (isConfigured()) {
|
if (isConfigured()) {
|
||||||
log.info("AiServiceClient 已配置: {}", url);
|
log.info("AiServiceClient 已配置: {} (超时 {}s, 异步任务模式)", url, timeoutSeconds);
|
||||||
} else {
|
} else {
|
||||||
log.info("AiServiceClient 未配置,AI 聚合功能不可用(将降级为拼接)");
|
log.info("AiServiceClient 未配置,AI 功能不可用(将降级为内置逻辑)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,10 +56,10 @@ public class AiServiceClient {
|
|||||||
return url != null && !url.isBlank();
|
return url != null && !url.isBlank();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ 公开 API(签名不变,内部改为 submit + poll)============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调用 lpt-ai 将残片聚合为学习报告草稿。
|
* 调用 lpt-ai 将残片聚合为学习报告草稿。
|
||||||
*
|
|
||||||
* @return 聚合后的报告文本;服务未配置或调用失败时返回 empty
|
|
||||||
*/
|
*/
|
||||||
public Optional<String> aggregateReport(String taskName, List<String> fragments, String expectation) {
|
public Optional<String> aggregateReport(String taskName, List<String> fragments, String expectation) {
|
||||||
if (!isConfigured() || fragments == null || fragments.isEmpty()) {
|
if (!isConfigured() || fragments == null || fragments.isEmpty()) {
|
||||||
@@ -64,36 +69,20 @@ public class AiServiceClient {
|
|||||||
List<Map<String, String>> fragmentBodies = fragments.stream()
|
List<Map<String, String>> fragmentBodies = fragments.stream()
|
||||||
.map(content -> Map.of("content", content))
|
.map(content -> Map.of("content", content))
|
||||||
.toList();
|
.toList();
|
||||||
Map<String, Object> body = expectation == null || expectation.isBlank()
|
Map<String, Object> params = expectation == null || expectation.isBlank()
|
||||||
? Map.of("taskName", taskName, "fragments", fragmentBodies)
|
? Map.of("taskName", taskName, "fragments", fragmentBodies)
|
||||||
: Map.of("taskName", taskName, "fragments", fragmentBodies, "expectation", expectation);
|
: Map.of("taskName", taskName, "fragments", fragmentBodies, "expectation", expectation);
|
||||||
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
Optional<JsonNode> result = submitAndWait("aggregate-report", params);
|
||||||
.uri(URI.create(url + "/ai/aggregate-report"))
|
return result.map(r -> r.path("report").asText(null)).filter(s -> s != null && !s.isBlank());
|
||||||
.timeout(Duration.ofSeconds(timeoutSeconds))
|
|
||||||
.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() != 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());
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("lpt-ai 调用异常,将降级: {}", e.getMessage());
|
log.warn("lpt-ai 聚合报告异常,将降级: {}", e.getMessage());
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调用 lpt-ai 从学习数据生成思维导图大纲(缩进文本格式)。
|
* 调用 lpt-ai 从学习数据生成思维导图大纲。
|
||||||
*
|
|
||||||
* @return 缩进大纲文本;服务未配置或调用失败时返回 empty
|
|
||||||
*/
|
*/
|
||||||
public Optional<String> generateMindMap(String taskName, String taskDescription,
|
public Optional<String> generateMindMap(String taskName, String taskDescription,
|
||||||
List<String> reports, List<String> fragments) {
|
List<String> reports, List<String> fragments) {
|
||||||
@@ -101,69 +90,148 @@ public class AiServiceClient {
|
|||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Map<String, Object> body = Map.of(
|
Map<String, Object> params = Map.of(
|
||||||
"taskName", taskName,
|
"taskName", taskName,
|
||||||
"taskDescription", taskDescription != null ? taskDescription : "",
|
"taskDescription", taskDescription != null ? taskDescription : "",
|
||||||
"reports", reports != null ? reports : List.of(),
|
"reports", reports != null ? reports : List.of(),
|
||||||
"fragments", fragments != null ? fragments : List.of()
|
"fragments", fragments != null ? fragments : List.of()
|
||||||
);
|
);
|
||||||
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
Optional<JsonNode> result = submitAndWait("generate-mind-map", params);
|
||||||
.uri(URI.create(url + "/ai/generate-mind-map"))
|
return result.map(r -> r.path("outline").asText(null)).filter(s -> s != null && !s.isBlank());
|
||||||
.timeout(Duration.ofSeconds(timeoutSeconds))
|
|
||||||
.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() != 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());
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("lpt-ai 思维导图调用异常,将降级: {}", e.getMessage());
|
log.warn("lpt-ai 思维导图异常,将降级: {}", e.getMessage());
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调用 lpt-ai 进行语义化回忆对比。
|
* 调用 lpt-ai 进行语义化回忆对比。
|
||||||
* 注意:AI 返回平铺的匹配列表,不返回完整树结构。
|
|
||||||
* 树标注(MATCHED/MISSED)由 StandardMindMapServiceImpl 基于本方法返回的 JSON 完成。
|
|
||||||
*
|
|
||||||
* @return AI 返回的原始 JSON;服务未配置或调用失败时返回 empty
|
|
||||||
*/
|
*/
|
||||||
public Optional<JsonNode> compareRecall(String taskName, String standardOutline, String recallOutline) {
|
public Optional<JsonNode> compareRecall(String taskName, String standardOutline, String recallOutline) {
|
||||||
if (!isConfigured()) {
|
if (!isConfigured()) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Map<String, String> body = Map.of(
|
Map<String, Object> params = Map.of(
|
||||||
"taskName", taskName,
|
"taskName", taskName,
|
||||||
"standardOutline", standardOutline,
|
"standardOutline", standardOutline,
|
||||||
"recallOutline", recallOutline
|
"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()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(url + "/ai/compare-recall"))
|
.uri(URI.create(url + "/ai/tasks"))
|
||||||
.timeout(Duration.ofSeconds(timeoutSeconds))
|
.timeout(Duration.ofSeconds(30)) // 提交本身不应超时
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
|
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
if (response.statusCode() != 200) {
|
if (response.statusCode() != 201) {
|
||||||
log.warn("lpt-ai 回忆对比失败: status={}, body={}", response.statusCode(),
|
log.warn("lpt-ai 提交任务失败: type={}, status={}, body={}",
|
||||||
|
type, response.statusCode(),
|
||||||
response.body() != null ? response.body().substring(0, Math.min(200, response.body().length())) : "");
|
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) {
|
} catch (Exception e) {
|
||||||
log.warn("lpt-ai 回忆对比调用异常,将降级为内置算法: {}", e.getMessage());
|
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();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,4 +56,4 @@ management:
|
|||||||
lpt:
|
lpt:
|
||||||
ai-service:
|
ai-service:
|
||||||
url: http://localhost:5199
|
url: http://localhost:5199
|
||||||
timeout-seconds: 180
|
timeout-seconds: 600
|
||||||
Reference in New Issue
Block a user