feat(思维导图):AI 生成接入 lpt-ai 服务

RemoteAiMindMapClient 原为 TODO 空桩,依赖 lpt.ai.endpoint/api-key
配置,从未生效。现改为依赖 AiServiceClient(lpt.ai-service.url),
调用 lpt-ai 的 /ai/generate-mind-map 接口获取 AI 大纲。

变更:
- AiServiceClient 新增 generateMindMap():调用 POST /ai/generate-mind-map
- RemoteAiMindMapClient 重写:注入 AiServiceClient,isAvailable()
  改为检查 AiServiceClient.isConfigured(),generate() 用 lpt-ai
  返回的大纲文本解析为 MindMapNode 树
- StandardMindMapServiceImpl.doGenerate():优先选 AI 客户端
  (非 BUILTIN),AI 失败时自动降级到内置规则引擎
- application.yml:移除不再使用的 lpt.ai.endpoint/api-key 配置
This commit is contained in:
2026-07-04 11:14:27 +08:00
parent baf9ae8761
commit cd5ee0b404
4 changed files with 97 additions and 45 deletions
@@ -89,4 +89,44 @@ public class AiServiceClient {
return Optional.empty();
}
}
/**
* 调用 lpt-ai 从学习数据生成思维导图大纲(缩进文本格式)。
*
* @return 缩进大纲文本;服务未配置或调用失败时返回 empty
*/
public Optional<String> generateMindMap(String taskName, String taskDescription,
List<String> reports, List<String> fragments) {
if (!isConfigured()) {
return Optional.empty();
}
try {
Map<String, Object> body = 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<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) {
log.warn("lpt-ai 思维导图调用异常,将降级: {}", e.getMessage());
return Optional.empty();
}
}
}
@@ -6,55 +6,35 @@ import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.service.MindMapAiClient;
import com.guo.learningprogresstracker.utils.MindMapNode;
import jakarta.annotation.PostConstruct;
import lombok.Setter;
import com.guo.learningprogresstracker.utils.MindMapTreeTool;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* 远程 AI 思维导图生成客户端(预留实现)
* <p>通过 {@code lpt.ai.endpoint} / {@code lpt.ai.api-key} 配置;未配置时不可用,
* 上层服务会回退到 {@link BuiltinMindMapGenerator}。</p>
* 远程 AI 思维导图生成客户端。
* <p>通过 lpt-ai 服务({@code lpt.ai-service.url})调用大语言模型生成思维导图。
* 未配置 lpt-ai 时不可用,会回退到 {@link BuiltinMindMapGenerator}。</p>
*/
@Slf4j
@Component
@ConfigurationProperties(prefix = "lpt.ai")
@Setter
@RequiredArgsConstructor
public class RemoteAiMindMapClient implements MindMapAiClient {
/** AI 服务端点 */
private String endpoint;
/** API Key */
private String apiKey;
/** 模型名称 */
private String model = "claude-fable-5";
/** 超时秒数 */
private int timeoutSeconds = 60;
private boolean enabled = false;
@PostConstruct
void init() {
enabled = apiKey != null && !apiKey.isBlank() && endpoint != null && !endpoint.isBlank();
if (enabled) {
log.info("RemoteAiMindMapClient 已启用: endpoint={}, model={}", endpoint, model);
} else {
log.info("RemoteAiMindMapClient 未配置,将使用内置生成器");
}
}
private final AiServiceClient aiServiceClient;
@Override
public boolean isAvailable() {
return enabled;
return aiServiceClient.isConfigured();
}
@Override
public String generatorName() {
return "AI-" + model;
return "AI";
}
@Override
@@ -63,12 +43,40 @@ public class RemoteAiMindMapClient implements MindMapAiClient {
List<StudyReportFragmentsEntity> fragments,
List<TaskApplicationEntity> applications,
String clientHint) {
if (!enabled) {
if (!isAvailable()) {
return Optional.empty();
}
// TODO: 调用远程 AI API,构造 prompt 发送 reports/fragments
// RestClient.create().post().uri(endpoint).body(prompt).retrieve()
log.warn("RemoteAiMindMapClient: 远程 AI 调用尚未实现,请配置 API Key 并实现请求逻辑");
return Optional.empty();
// 提取报告和残片的内容文本
List<String> reportTexts = reports.stream()
.map(StudyReportsEntity::getContent)
.filter(c -> c != null && !c.isBlank())
.collect(Collectors.toList());
List<String> fragmentTexts = fragments.stream()
.map(StudyReportFragmentsEntity::getContent)
.filter(c -> c != null && !c.isBlank())
.collect(Collectors.toList());
if (reportTexts.isEmpty() && fragmentTexts.isEmpty()) {
return Optional.empty();
}
Optional<String> optOutline = aiServiceClient.generateMindMap(
task.getTaskName(),
task.getTaskDescription(),
reportTexts,
fragmentTexts
);
if (optOutline.isEmpty() || optOutline.get().isBlank()) {
log.warn("RemoteAiMindMapClient: lpt-ai 返回空大纲,降级");
return Optional.empty();
}
String outline = optOutline.get();
MindMapNode root = MindMapTreeTool.parseOutline(outline);
log.info("RemoteAiMindMapClient: 为任务[{}]生成 AI 导图,共 {} 个节点,{} 层",
task.getTaskNum(), MindMapTreeTool.countNodes(root), MindMapTreeTool.maxDepth(root));
return Optional.of(root);
}
}
@@ -251,10 +251,10 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
throw new OperationFailedException("任务[" + taskNum + "]没有学习报告或残片,无法生成思维导图");
}
// 找可用的 AI 客户端
// 优先选 AI 客户端(非 BUILTIN),其次内置生成器
MindMapAiClient client = aiClients.stream()
.filter(MindMapAiClient::isAvailable)
.findFirst()
.min(Comparator.comparing(c -> "BUILTIN".equals(c.generatorName()) ? 1 : 0))
.orElse(null);
if (client == null) {
@@ -262,6 +262,16 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
}
Optional<MindMapNode> optRoot = client.generate(task, reports, fragments, applications, null);
// AI 生成失败时尝试降级到内置生成器
if (optRoot.isEmpty() && !"BUILTIN".equals(client.generatorName())) {
log.info("AI 思维导图生成失败,降级到内置生成器");
MindMapAiClient fallback = aiClients.stream()
.filter(c -> "BUILTIN".equals(c.generatorName()) && c.isAvailable())
.findFirst().orElse(null);
if (fallback != null) {
optRoot = fallback.generate(task, reports, fragments, applications, null);
}
}
if (optRoot.isEmpty()) {
throw new OperationFailedException("思维导图生成失败");
}