From 5ef5ec0c4b1c499df977cc7d672d4ef9e23d2d1f Mon Sep 17 00:00:00 2001 From: jack ning Date: Tue, 22 Apr 2025 15:53:37 +0800 Subject: [PATCH] update modules/ai: add 1 mod 7 files --- .../bytedesk/ai/robot/RobotRestService.java | 4 +- .../listener/SpringAIEventListener.java | 4 +- .../springai/rag/SpringAIRagController.java | 4 +- .../springai/service/BaseSpringAIService.java | 4 +- .../service/SpringAIFullTextService.java | 393 ++++++++++++++++++ ...e.java => SpringAIVectorStoreService.java} | 15 +- .../com/bytedesk/kbase/llm/qa/QaEntity.java | 2 +- modules/pom.xml | 7 + 8 files changed, 420 insertions(+), 13 deletions(-) create mode 100644 modules/ai/src/main/java/com/bytedesk/ai/springai/service/SpringAIFullTextService.java rename modules/ai/src/main/java/com/bytedesk/ai/springai/service/{SpringAIVectorService.java => SpringAIVectorStoreService.java} (99%) diff --git a/modules/ai/src/main/java/com/bytedesk/ai/robot/RobotRestService.java b/modules/ai/src/main/java/com/bytedesk/ai/robot/RobotRestService.java index 0c8d435914..d731e05dd4 100644 --- a/modules/ai/src/main/java/com/bytedesk/ai/robot/RobotRestService.java +++ b/modules/ai/src/main/java/com/bytedesk/ai/robot/RobotRestService.java @@ -32,7 +32,7 @@ import com.bytedesk.ai.provider.LlmProviderEntity; import com.bytedesk.ai.provider.LlmProviderRestService; import com.bytedesk.ai.robot.RobotJsonLoader.Robot; import com.bytedesk.ai.robot.RobotJsonLoader.RobotConfiguration; -import com.bytedesk.ai.springai.service.SpringAIVectorService; +import com.bytedesk.ai.springai.service.SpringAIVectorStoreService; import com.bytedesk.ai.utils.ConvertAiUtils; import com.bytedesk.core.base.BaseRestService; import com.bytedesk.core.category.CategoryTypeEnum; @@ -88,7 +88,7 @@ public class RobotRestService extends BaseRestService springAIVectorService; + private final Optional springAIVectorService; private final LlmProviderRestService llmProviderRestService; diff --git a/modules/ai/src/main/java/com/bytedesk/ai/springai/listener/SpringAIEventListener.java b/modules/ai/src/main/java/com/bytedesk/ai/springai/listener/SpringAIEventListener.java index fa9781520f..4342e49809 100644 --- a/modules/ai/src/main/java/com/bytedesk/ai/springai/listener/SpringAIEventListener.java +++ b/modules/ai/src/main/java/com/bytedesk/ai/springai/listener/SpringAIEventListener.java @@ -20,7 +20,7 @@ import java.util.concurrent.ConcurrentHashMap; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; -import com.bytedesk.ai.springai.service.SpringAIVectorService; +import com.bytedesk.ai.springai.service.SpringAIVectorStoreService; import com.bytedesk.core.quartz.event.QuartzOneMinEvent; import com.bytedesk.core.redis.pubsub.RedisPubsubParseFileErrorEvent; import com.bytedesk.core.redis.pubsub.RedisPubsubParseFileSuccessEvent; @@ -50,7 +50,7 @@ import lombok.extern.slf4j.Slf4j; @RequiredArgsConstructor public class SpringAIEventListener { - private final SpringAIVectorService springAiVectorService; + private final SpringAIVectorStoreService springAiVectorService; // 存储收集到的FAQ实体,用于批量处理 private final ConcurrentHashMap faqCreateMap = new ConcurrentHashMap<>(); diff --git a/modules/ai/src/main/java/com/bytedesk/ai/springai/rag/SpringAIRagController.java b/modules/ai/src/main/java/com/bytedesk/ai/springai/rag/SpringAIRagController.java index df49272a9f..d7adbfbad4 100644 --- a/modules/ai/src/main/java/com/bytedesk/ai/springai/rag/SpringAIRagController.java +++ b/modules/ai/src/main/java/com/bytedesk/ai/springai/rag/SpringAIRagController.java @@ -44,7 +44,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import com.bytedesk.ai.springai.service.SpringAIVectorService; +import com.bytedesk.ai.springai.service.SpringAIVectorStoreService; import com.bytedesk.core.utils.JsonResult; import lombok.RequiredArgsConstructor; @@ -67,7 +67,7 @@ public class SpringAIRagController { @Qualifier("ollamaChatModel") private final Optional ollamaChatModel; - private final SpringAIVectorService springAIVectorService; + private final SpringAIVectorStoreService springAIVectorService; // rag // https://docs.spring.io/spring-ai/reference/api/retrieval-augmented-generation.html#_questionansweradvisor diff --git a/modules/ai/src/main/java/com/bytedesk/ai/springai/service/BaseSpringAIService.java b/modules/ai/src/main/java/com/bytedesk/ai/springai/service/BaseSpringAIService.java index 702d50a563..0b4c3f69e4 100644 --- a/modules/ai/src/main/java/com/bytedesk/ai/springai/service/BaseSpringAIService.java +++ b/modules/ai/src/main/java/com/bytedesk/ai/springai/service/BaseSpringAIService.java @@ -33,7 +33,7 @@ import lombok.extern.slf4j.Slf4j; public abstract class BaseSpringAIService implements SpringAIService { @Autowired(required = false) - protected Optional springAIVectorService; + protected Optional springAIVectorService; @Autowired protected IMessageSendService messageSendService; @@ -61,7 +61,7 @@ public abstract class BaseSpringAIService implements SpringAIService { } // 可以保留一个带参数的构造函数用于单元测试或特殊情况 - protected BaseSpringAIService(Optional springAIVectorService, + protected BaseSpringAIService(Optional springAIVectorService, IMessageSendService messageSendService) { this.springAIVectorService = springAIVectorService; this.messageSendService = messageSendService; diff --git a/modules/ai/src/main/java/com/bytedesk/ai/springai/service/SpringAIFullTextService.java b/modules/ai/src/main/java/com/bytedesk/ai/springai/service/SpringAIFullTextService.java new file mode 100644 index 0000000000..16efce50cc --- /dev/null +++ b/modules/ai/src/main/java/com/bytedesk/ai/springai/service/SpringAIFullTextService.java @@ -0,0 +1,393 @@ +/* + * @Author: jackning 270580156@qq.com + * @Date: 2025-04-22 15:26:22 + * @LastEditors: jackning 270580156@qq.com + * @LastEditTime: 2025-04-22 15:50:24 + * @Description: bytedesk.com https://github.com/Bytedesk/bytedesk + * Please be aware of the BSL license restrictions before installing Bytedesk IM – + * selling, reselling, or hosting Bytedesk IM as a service is a breach of the terms and automatically terminates your rights under the license. + * Business Source License 1.1: https://github.com/Bytedesk/bytedesk/blob/main/LICENSE + * contact: 270580156@qq.com + * + * Copyright (c) 2025 by bytedesk.com, All Rights Reserved. + */ +package com.bytedesk.ai.springai.service; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.elasticsearch.action.admin.indices.delete.DeleteIndexRequest; +import org.elasticsearch.action.bulk.BulkRequest; +import org.elasticsearch.action.bulk.BulkResponse; +import org.elasticsearch.action.delete.DeleteRequest; +import org.elasticsearch.action.index.IndexRequest; +import org.elasticsearch.action.search.SearchRequest; +import org.elasticsearch.action.search.SearchResponse; +import org.elasticsearch.client.RequestOptions; +import org.elasticsearch.client.RestHighLevelClient; +import org.elasticsearch.client.indices.CreateIndexRequest; +import org.elasticsearch.client.indices.GetIndexRequest; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.index.query.BoolQueryBuilder; +import org.elasticsearch.index.query.QueryBuilders; +import org.elasticsearch.search.SearchHit; +import org.elasticsearch.search.builder.SearchSourceBuilder; +import org.elasticsearch.search.sort.SortBuilders; +import org.elasticsearch.search.sort.SortOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.alibaba.fastjson2.JSON; +import com.bytedesk.kbase.llm.qa.QaEntity; + +import lombok.extern.slf4j.Slf4j; + +/** + * elasticsearch 全文检索服务 + * @author jackning + */ +@Slf4j +@Service +public class SpringAIFullTextService { + + private static final String QA_INDEX = "bytedesk_qa"; + + @Autowired + private RestHighLevelClient elasticsearchClient; + + /** + * 初始化索引 + */ + public void initIndex() { + try { + // 检查索引是否存在 + boolean exists = elasticsearchClient.indices().exists(new GetIndexRequest(QA_INDEX), RequestOptions.DEFAULT); + if (!exists) { + // 创建索引 + CreateIndexRequest createIndexRequest = new CreateIndexRequest(QA_INDEX); + // 设置分片和复制 + createIndexRequest.settings(Settings.builder() + .put("index.number_of_shards", 3) + .put("index.number_of_replicas", 1) + .build()); + + // 设置映射 + Map properties = new HashMap<>(); + + // 问题字段 - 标准分词器 + Map question = new HashMap<>(); + question.put("type", "text"); + question.put("analyzer", "standard"); + question.put("search_analyzer", "standard"); + + // 问题列表 - 标准分词器 + Map questionList = new HashMap<>(); + questionList.put("type", "text"); + questionList.put("analyzer", "standard"); + questionList.put("search_analyzer", "standard"); + + // 答案 - 标准分词器 + Map answer = new HashMap<>(); + answer.put("type", "text"); + answer.put("analyzer", "standard"); + answer.put("search_analyzer", "standard"); + + // 其他字段 + Map uid = new HashMap<>(); + uid.put("type", "keyword"); + + Map kbUid = new HashMap<>(); + kbUid.put("type", "keyword"); + + Map categoryUid = new HashMap<>(); + categoryUid.put("type", "keyword"); + + Map orgUid = new HashMap<>(); + orgUid.put("type", "keyword"); + + Map userUid = new HashMap<>(); + userUid.put("type", "keyword"); + + Map enabled = new HashMap<>(); + enabled.put("type", "boolean"); + + Map tagList = new HashMap<>(); + tagList.put("type", "keyword"); + + Map startDate = new HashMap<>(); + startDate.put("type", "date"); + startDate.put("format", "yyyy-MM-dd'T'HH:mm:ss||yyyy-MM-dd||epoch_millis"); + + Map endDate = new HashMap<>(); + endDate.put("type", "date"); + endDate.put("format", "yyyy-MM-dd'T'HH:mm:ss||yyyy-MM-dd||epoch_millis"); + + Map createdAt = new HashMap<>(); + createdAt.put("type", "date"); + createdAt.put("format", "yyyy-MM-dd'T'HH:mm:ss||yyyy-MM-dd||epoch_millis"); + + // 添加所有属性 + properties.put("uid", uid); + properties.put("question", question); + properties.put("questionList", questionList); + properties.put("answer", answer); + properties.put("kbUid", kbUid); + properties.put("categoryUid", categoryUid); + properties.put("orgUid", orgUid); + properties.put("userUid", userUid); + properties.put("enabled", enabled); + properties.put("tagList", tagList); + properties.put("startDate", startDate); + properties.put("endDate", endDate); + properties.put("createdAt", createdAt); + + Map mapping = new HashMap<>(); + mapping.put("properties", properties); + + createIndexRequest.mapping(mapping); + + // 创建索引 + elasticsearchClient.indices().create(createIndexRequest, RequestOptions.DEFAULT); + log.info("创建索引 {} 成功", QA_INDEX); + } else { + log.info("索引 {} 已存在", QA_INDEX); + } + } catch (IOException e) { + log.error("初始化索引失败: {}", e.getMessage()); + } + } + + /** + * 删除索引 + */ + public void deleteIndex() { + try { + boolean exists = elasticsearchClient.indices().exists(new GetIndexRequest(QA_INDEX), RequestOptions.DEFAULT); + if (exists) { + DeleteIndexRequest request = new DeleteIndexRequest(QA_INDEX); + elasticsearchClient.indices().delete(request, RequestOptions.DEFAULT); + log.info("索引 {} 已删除", QA_INDEX); + } + } catch (IOException e) { + log.error("删除索引失败: {}", e.getMessage()); + } + } + + /** + * 索引单个QA实体 + */ + public void indexQa(QaEntity qa) { + try { + Map jsonMap = entityToMap(qa); + + IndexRequest indexRequest = new IndexRequest(QA_INDEX) + .id(qa.getUid()) + .source(jsonMap); + + elasticsearchClient.index(indexRequest, RequestOptions.DEFAULT); + log.info("成功索引QA: {}", qa.getQuestion()); + } catch (IOException e) { + log.error("索引QA失败: {}", e.getMessage()); + } + } + + /** + * 批量索引QA实体 + */ + public void bulkIndexQas(List qaList) { + try { + BulkRequest bulkRequest = new BulkRequest(); + + for (QaEntity qa : qaList) { + Map jsonMap = entityToMap(qa); + + IndexRequest indexRequest = new IndexRequest(QA_INDEX) + .id(qa.getUid()) + .source(jsonMap); + + bulkRequest.add(indexRequest); + } + + BulkResponse bulkResponse = elasticsearchClient.bulk(bulkRequest, RequestOptions.DEFAULT); + if (bulkResponse.hasFailures()) { + log.error("批量索引部分失败: {}", bulkResponse.buildFailureMessage()); + } else { + log.info("批量索引 {} 个QA成功", qaList.size()); + } + } catch (IOException e) { + log.error("批量索引失败: {}", e.getMessage()); + } + } + + /** + * 从索引中删除QA实体 + */ + public void deleteQa(String qaUid) { + try { + DeleteRequest deleteRequest = new DeleteRequest(QA_INDEX, qaUid); + elasticsearchClient.delete(deleteRequest, RequestOptions.DEFAULT); + log.info("成功从索引中删除QA: {}", qaUid); + } catch (IOException e) { + log.error("删除QA索引失败: {}", e.getMessage()); + } + } + + /** + * 批量删除QA实体 + */ + public void bulkDeleteQas(List qaUids) { + try { + BulkRequest bulkRequest = new BulkRequest(); + + for (String qaUid : qaUids) { + DeleteRequest deleteRequest = new DeleteRequest(QA_INDEX, qaUid); + bulkRequest.add(deleteRequest); + } + + BulkResponse bulkResponse = elasticsearchClient.bulk(bulkRequest, RequestOptions.DEFAULT); + if (bulkResponse.hasFailures()) { + log.error("批量删除部分失败: {}", bulkResponse.buildFailureMessage()); + } else { + log.info("批量删除 {} 个QA成功", qaUids.size()); + } + } catch (IOException e) { + log.error("批量删除失败: {}", e.getMessage()); + } + } + + /** + * 搜索QA + * @param keyword 搜索关键词 + * @param kbUid 知识库UID (可选) + * @param categoryUid 分类UID (可选) + * @param orgUid 组织UID (可选) + * @param tagList 标签列表 (可选) + * @param page 页码 + * @param size 每页大小 + * @return 搜索结果列表 + */ + public List searchQas(String keyword, String kbUid, String categoryUid, + String orgUid, List tagList, + int page, int size) { + try { + SearchRequest searchRequest = new SearchRequest(QA_INDEX); + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); + + // 使用布尔查询构建复杂查询 + BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); + + // 关键词搜索 + if (keyword != null && !keyword.trim().isEmpty()) { + // 多字段搜索,给予问题字段更高的权重 + boolQuery.must(QueryBuilders.multiMatchQuery(keyword, "question^3", "questionList^2", "answer") + .fuzziness("AUTO")); // 启用模糊匹配,处理拼写错误 + } else { + // 如果没有关键词,匹配所有文档 + boolQuery.must(QueryBuilders.matchAllQuery()); + } + + // 过滤条件 - 知识库 + if (kbUid != null && !kbUid.trim().isEmpty()) { + boolQuery.filter(QueryBuilders.termQuery("kbUid", kbUid)); + } + + // 过滤条件 - 分类 + if (categoryUid != null && !categoryUid.trim().isEmpty()) { + boolQuery.filter(QueryBuilders.termQuery("categoryUid", categoryUid)); + } + + // 过滤条件 - 组织 + if (orgUid != null && !orgUid.trim().isEmpty()) { + boolQuery.filter(QueryBuilders.termQuery("orgUid", orgUid)); + } + + // 过滤条件 - 标签 + if (tagList != null && !tagList.isEmpty()) { + for (String tag : tagList) { + boolQuery.filter(QueryBuilders.termQuery("tagList", tag)); + } + } + + // 只显示启用的QA + boolQuery.filter(QueryBuilders.termQuery("enabled", true)); + + searchSourceBuilder.query(boolQuery); + + // 分页 + searchSourceBuilder.from((page - 1) * size); + searchSourceBuilder.size(size); + + // 排序 - 按创建时间降序 + searchSourceBuilder.sort(SortBuilders.fieldSort("createdAt").order(SortOrder.DESC)); + + searchRequest.source(searchSourceBuilder); + + SearchResponse searchResponse = elasticsearchClient.search(searchRequest, RequestOptions.DEFAULT); + + List results = new ArrayList<>(); + for (SearchHit hit : searchResponse.getHits().getHits()) { + QaEntity qa = JSON.parseObject(hit.getSourceAsString(), QaEntity.class); + results.add(qa); + } + + log.info("搜索结果数: {}", results.size()); + return results; + + } catch (IOException e) { + log.error("搜索QA失败: {}", e.getMessage()); + return new ArrayList<>(); + } + } + + /** + * 将QaEntity转换为Map用于索引 + */ + private Map entityToMap(QaEntity qa) { + Map map = new HashMap<>(); + + // 基本字段 + map.put("uid", qa.getUid()); + map.put("question", qa.getQuestion()); + map.put("answer", qa.getAnswer()); + + // 处理问题列表 + if (qa.getQuestionList() != null && !qa.getQuestionList().isEmpty()) { + map.put("questionList", qa.getQuestionList()); + } + + // 标签列表 + if (qa.getTagList() != null && !qa.getTagList().isEmpty()) { + map.put("tagList", qa.getTagList()); + } + + // 其他元数据 + if (qa.getKbaseEntity() != null) { + map.put("kbUid", qa.getKbaseEntity().getUid()); + } + + map.put("categoryUid", qa.getCategoryUid()); + map.put("orgUid", qa.getOrgUid()); + map.put("userUid", qa.getUserUid()); + map.put("enabled", qa.isEnabled()); + + // 日期 + if (qa.getStartDate() != null) { + map.put("startDate", qa.getStartDate().toString()); + } + + if (qa.getEndDate() != null) { + map.put("endDate", qa.getEndDate().toString()); + } + + if (qa.getCreatedAt() != null) { + map.put("createdAt", qa.getCreatedAt().toString()); + } + + return map; + } +} diff --git a/modules/ai/src/main/java/com/bytedesk/ai/springai/service/SpringAIVectorService.java b/modules/ai/src/main/java/com/bytedesk/ai/springai/service/SpringAIVectorStoreService.java similarity index 99% rename from modules/ai/src/main/java/com/bytedesk/ai/springai/service/SpringAIVectorService.java rename to modules/ai/src/main/java/com/bytedesk/ai/springai/service/SpringAIVectorStoreService.java index f6dc94bacd..ba29520dc0 100644 --- a/modules/ai/src/main/java/com/bytedesk/ai/springai/service/SpringAIVectorService.java +++ b/modules/ai/src/main/java/com/bytedesk/ai/springai/service/SpringAIVectorStoreService.java @@ -2,7 +2,7 @@ * @Author: jackning 270580156@qq.com * @Date: 2024-07-27 21:27:01 * @LastEditors: jackning 270580156@qq.com - * @LastEditTime: 2025-04-22 15:18:45 + * @LastEditTime: 2025-04-22 15:44:41 * @Description: bytedesk.com https://github.com/Bytedesk/bytedesk * Please be aware of the BSL license restrictions before installing Bytedesk IM – * selling, reselling, or hosting Bytedesk IM as a service is a breach of the terms and automatically terminates your rights under the license. @@ -35,6 +35,7 @@ import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder; import org.springframework.core.io.Resource; import org.springframework.lang.NonNull; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.util.UriComponentsBuilder; import java.net.URI; import java.util.Map; @@ -67,7 +68,7 @@ import org.springframework.util.Assert; @Slf4j @Service @AllArgsConstructor -public class SpringAIVectorService { +public class SpringAIVectorStoreService { // private final Optional bytedeskOllamaRedisVectorStore; // private final Optional bytedeskZhipuaiRedisVectorStore; @@ -273,6 +274,7 @@ public class SpringAIVectorService { } // string content 转换成 List documents + @Transactional public List readTextDemo(String name, String content, String kbUid, String orgUid) { log.info("Converting string content to documents"); Assert.hasText(content, "Content must not be empty"); @@ -314,6 +316,7 @@ public class SpringAIVectorService { } // 使用reader直接将content字符串,转换成 List documents + @Transactional public List readText(TextEntity textEntity) { log.info("Converting string content to documents"); Assert.notNull(textEntity, "TextEntity must not be null"); @@ -367,13 +370,12 @@ public class SpringAIVectorService { } // - @Transactional(rollbackFor = Exception.class) + @Transactional public List readQa(QaEntity qaEntity) { log.info("Converting string content to documents"); Assert.notNull(qaEntity, "QaEntity must not be null"); // String content = qaEntity.toJson(); - //qaEntity.getQuestion() + "\n" + qaEntity.getAnswer(); // 创建Document对象 Document document = new Document(content); // 使用TokenTextSplitter分割文本 @@ -422,6 +424,7 @@ public class SpringAIVectorService { } // 使用reader直接将qaEntity字符串,转换成 List documents + @Transactional public List readFaq(FaqEntity faqEntity) { log.info("Converting string content to documents"); Assert.notNull(faqEntity, "FaqEntity must not be null"); @@ -476,6 +479,7 @@ public class SpringAIVectorService { } // 抓取website + @Transactional public List readWebsite(WebsiteEntity websiteEntity) { log.info("Loading document from website: {}", websiteEntity.getUrl()); Assert.notNull(websiteEntity, "WebsiteEntity must not be null"); @@ -552,6 +556,7 @@ public class SpringAIVectorService { } // 存储到vector store + @Transactional private void storeDocuments(List docList, FileEntity file) { Assert.notNull(docList, "Document list must not be null"); Assert.notNull(file, "FileEntity must not be null"); @@ -705,6 +710,7 @@ public class SpringAIVectorService { * @param docId 文档ID * @param content 新的文档内容 */ + @Transactional public void updateDoc(String docId, String content, String kbUid) { Assert.hasText(docId, "Document ID must not be empty"); Assert.hasText(content, "Content must not be empty"); @@ -756,6 +762,7 @@ public class SpringAIVectorService { deleteDocs(List.of(docId)); } + @Transactional public void deleteDocs(List docIdList) { Assert.notEmpty(docIdList, "Document ID list must not be empty"); // 删除splitEntity diff --git a/modules/kbase/src/main/java/com/bytedesk/kbase/llm/qa/QaEntity.java b/modules/kbase/src/main/java/com/bytedesk/kbase/llm/qa/QaEntity.java index eb80564ae9..cebe431cd5 100644 --- a/modules/kbase/src/main/java/com/bytedesk/kbase/llm/qa/QaEntity.java +++ b/modules/kbase/src/main/java/com/bytedesk/kbase/llm/qa/QaEntity.java @@ -42,7 +42,7 @@ import lombok.experimental.Accessors; import lombok.experimental.SuperBuilder; /** - * qa: Frequently Asked Questions + * qa: question answer for llm */ @Entity @Data diff --git a/modules/pom.xml b/modules/pom.xml index 4c2f6febb6..37a9b0fbb6 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -105,6 +105,13 @@ provided + + + org.springframework.boot + spring-boot-starter-data-elasticsearch + provided + + org.springframework.boot spring-boot-starter-actuator