feat(报告):AI 聚合残片生成报告草稿

- AiServiceClient:调用 lpt-ai 独立服务,未配置/失败时返回 empty
- GET /study-sessions/{sessionNum}/report-draft:AI 聚合(含预期对比),降级为残片拼接
- 草稿仅作为编辑起点,最终报告仍由用户确认后保存
- application.yml 增加 lpt.ai-service.url 配置
This commit is contained in:
2026-07-03 23:52:32 +08:00
parent 3c4db2fead
commit 21c5b8f761
4 changed files with 134 additions and 1 deletions
@@ -99,4 +99,12 @@ public class StudySessionController {
return CommonResult.success(allFragments);
}
/**
* 生成学习报告草稿:AI 聚合残片(不可用时降级为拼接),返回给前端作为编辑起点
*/
@GetMapping("/{sessionNum}/report-draft")
public CommonResult<String> getReportDraft(@PathVariable("sessionNum") String sessionNum) throws ErrorParameterException {
return CommonResult.success("请求成功", studySessionsServiceImpl.generateReportDraft(sessionNum));
}
}
@@ -0,0 +1,92 @@
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.util.List;
import java.util.Map;
import java.util.Optional;
/**
* lpt-ai 独立 AI 服务客户端。
* <p>通过 {@code lpt.ai-service.url} 配置服务地址;未配置或调用失败时返回 empty,由调用方降级。</p>
*/
@Slf4j
@Component
@ConfigurationProperties(prefix = "lpt.ai-service")
@Setter
public class AiServiceClient {
/** lpt-ai 服务地址,如 http://localhost:5199 */
private String url;
/** 超时秒数 */
private int timeoutSeconds = 90;
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 已配置: {}", url);
} else {
log.info("AiServiceClient 未配置,AI 聚合功能不可用(将降级为拼接)");
}
}
public boolean isConfigured() {
return url != null && !url.isBlank();
}
/**
* 调用 lpt-ai 将残片聚合为学习报告草稿。
*
* @return 聚合后的报告文本;服务未配置或调用失败时返回 empty
*/
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> body = 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<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) {
log.warn("lpt-ai 调用异常,将降级: {}", e.getMessage());
return Optional.empty();
}
}
}
@@ -16,6 +16,7 @@ import com.guo.learningprogresstracker.mapStruct.StudySessionConvert;
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
import com.guo.learningprogresstracker.service.StudyExpectationsService;
import com.guo.learningprogresstracker.service.StudySessionsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -41,6 +42,8 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
private final StudyReportFragmentsServiceImpl studyReportFragmentsServiceImpl;
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
private final StudyReportsMapper studyReportsMapper;
private final AiServiceClient aiServiceClient;
private final StudyExpectationsService studyExpectationsService;
public StudySessionResponse startOrContinueStudySession(String taskNum) throws NotFindEntitiesException, ServiceException {
TaskEntity taskEntity = tasksServiceImpl.getOneOpt(
@@ -158,4 +161,30 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
return StudySessionConvert.MAPPER.toStudySessionResponse(session);
}
/**
* 生成学习报告草稿:优先调用 lpt-ai 聚合残片,服务不可用时降级为按序拼接。
* 草稿仅作为编辑起点返回,不落库——最终报告由用户确认后经 endedStudySession 保存。
*/
public String generateReportDraft(String sessionNum) throws ErrorParameterException {
StudySessionsEntity session = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
.eq(StudySessionsEntity::getSessionNum, sessionNum))
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
ArrayList<String> fragments = getAllFragments(sessionNum);
if (fragments.isEmpty()) {
return "";
}
String taskName = tasksServiceImpl.getOneOpt(
Wrappers.lambdaQuery(TaskEntity.class).eq(TaskEntity::getTaskNum, session.getTaskNum()))
.map(TaskEntity::getTaskName)
.orElse("学习任务");
String expectation = Optional.ofNullable(studyExpectationsService.getBySessionNum(sessionNum))
.map(e -> e.getDescription())
.orElse(null);
return aiServiceClient.aggregateReport(taskName, fragments, expectation)
.orElseGet(() -> String.join("\n", fragments));
}
}
+5 -1
View File
@@ -58,4 +58,8 @@ lpt:
endpoint:
api-key:
model: claude-fable-5
timeout-seconds: 60
timeout-seconds: 60
# lpt-ai 独立服务地址(可选,未配置时报告草稿降级为残片拼接)
ai-service:
url:
timeout-seconds: 90