diff --git a/pom.xml b/pom.xml index 851893c2..b164fbfb 100644 --- a/pom.xml +++ b/pom.xml @@ -50,6 +50,11 @@ 2.0.23 3.1.687 + + 5.26.0 + 2024.1.0 + 1.2.0-beta8 + 3.2.2 3.2.2 @@ -341,6 +346,33 @@ ruoyi-generator ${revision} + + + org.ruoyi + ruoyi-graph + ${revision} + + + + + org.neo4j.driver + neo4j-java-driver + ${neo4j-driver.version} + + + + + org.neo4j + neo4j-cypher-dsl + ${neo4j-cypher-dsl.version} + + + + + dev.langchain4j + langchain4j-community-neo4j + ${langchain4j-neo4j.version} + diff --git a/ruoyi-admin/pom.xml b/ruoyi-admin/pom.xml index 6f84d8b5..3cf17cc7 100644 --- a/ruoyi-admin/pom.xml +++ b/ruoyi-admin/pom.xml @@ -57,6 +57,12 @@ ruoyi-generator + + + org.ruoyi + ruoyi-graph + + diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index 1fd51a25..a722f88b 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -46,7 +46,7 @@ spring.data: # 端口,默认为6379 port: 6379 # 数据库索引 - database: 0 + database: 2 # 密码(如没有密码请注释掉) # password: 123456 # 连接超时时间 @@ -104,3 +104,54 @@ dashscope: key: sk-xxxx model: qvq-max +--- # Neo4j 知识图谱配置 +neo4j: + uri: bolt://127.0.0.1:7687 + username: neo4j + password: MySecurePass123! + database: neo4j + max-connection-pool-size: 50 + connection-timeout-seconds: 30 + +# 知识图谱配置 +knowledge: + graph: + # 是否启用知识图谱功能 + enabled: true + # 图数据库类型: neo4j 或 apache-age + database-type: neo4j + # 是否自动创建索引 + auto-create-index: true + # 批量处理大小 + batch-size: 1000 + # 最大重试次数 + max-retry-count: 3 + + # 实体抽取配置 + extraction: + # 置信度阈值(低于此值的实体将被过滤) + confidence-threshold: 0.7 + # 最大实体数量(每个文档) + max-entities-per-doc: 100 + # 最大关系数量(每个文档) + max-relations-per-doc: 200 + # 文本分片大小(用于长文档) + chunk-size: 2000 + # 分片重叠大小 + chunk-overlap: 200 + + # 查询配置 + query: + # 默认查询限制数量 + default-limit: 100 + # 最大查询限制数量 + max-limit: 1000 + # 路径查询最大深度 + max-path-depth: 5 + # 查询超时时间(秒) + timeout-seconds: 30 + # 是否启用查询缓存 + cache-enabled: true + # 缓存过期时间(分钟) + cache-expire-minutes: 60 + diff --git a/ruoyi-modules/pom.xml b/ruoyi-modules/pom.xml index 80af4ecd..af85f578 100644 --- a/ruoyi-modules/pom.xml +++ b/ruoyi-modules/pom.xml @@ -21,7 +21,8 @@ ruoyi-chat ruoyi-system ruoyi-generator - ruoyi-wechat + ruoyi-wechat + ruoyi-graph diff --git a/ruoyi-modules/ruoyi-graph/pom.xml b/ruoyi-modules/ruoyi-graph/pom.xml new file mode 100644 index 00000000..af1ee8e6 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/pom.xml @@ -0,0 +1,100 @@ + + + 4.0.0 + + org.ruoyi + ruoyi-modules + ${revision} + ../pom.xml + + + ruoyi-graph + + + 知识图谱模块 + + + + 1.0.0-beta4 + + + + + + + dev.langchain4j + langchain4j-bom + ${langchain4j.version} + pom + import + + + + + + + + org.ruoyi + ruoyi-common-web + + + + org.ruoyi + ruoyi-common-mybatis + + + + org.ruoyi + ruoyi-common-redis + + + + org.ruoyi + ruoyi-common-doc + + + + + org.ruoyi + ruoyi-knowledge-api + + + + + org.ruoyi + ruoyi-chat-api + + + + + org.neo4j.driver + neo4j-java-driver + + + + + org.neo4j + neo4j-cypher-dsl + + + + org.ruoyi + ruoyi-chat + + + + + dev.langchain4j + langchain4j + + + + dev.langchain4j + langchain4j-open-ai + + + + diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/config/GraphAsyncConfig.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/config/GraphAsyncConfig.java new file mode 100644 index 00000000..894600c3 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/config/GraphAsyncConfig.java @@ -0,0 +1,67 @@ +package org.ruoyi.graph.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * 图谱构建异步任务配置 + * + * @author ruoyi + * @date 2025-10-11 + */ +@Slf4j +@EnableAsync +@Configuration +public class GraphAsyncConfig { + + /** + * 图谱构建专用线程池 + * 用于执行图谱构建任务,避免阻塞主线程池 + */ + @Bean("graphBuildExecutor") + public Executor graphBuildExecutor() { + log.info("初始化图谱构建线程池..."); + + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + + // 核心线程数:CPU核心数 + int processors = Runtime.getRuntime().availableProcessors(); + executor.setCorePoolSize(processors); + + // 最大线程数:CPU核心数 * 2 + executor.setMaxPoolSize(processors * 2); + + // 队列容量:100个任务 + executor.setQueueCapacity(100); + + // 线程空闲时间:60秒 + executor.setKeepAliveSeconds(60); + + // 线程名称前缀 + executor.setThreadNamePrefix("graph-build-"); + + // 拒绝策略:由调用线程处理 + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + + // 等待所有任务完成后关闭线程池 + executor.setWaitForTasksToCompleteOnShutdown(true); + + // 等待时间:60秒 + executor.setAwaitTerminationSeconds(60); + + // 初始化 + executor.initialize(); + + log.info("图谱构建线程池初始化完成: corePoolSize={}, maxPoolSize={}, queueCapacity={}", + processors, processors * 2, 100); + + return executor; + } +} + diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/config/GraphProperties.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/config/GraphProperties.java new file mode 100644 index 00000000..41cde14a --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/config/GraphProperties.java @@ -0,0 +1,113 @@ +package org.ruoyi.graph.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 知识图谱配置属性 + * + * @author ruoyi + * @date 2025-09-30 + */ +@Data +@Component +@ConfigurationProperties(prefix = "knowledge.graph") +public class GraphProperties { + + /** + * 是否启用知识图谱功能 + */ + private Boolean enabled = true; + + /** + * 图数据库类型: neo4j 或 apache-age + */ + private String databaseType = "neo4j"; + + /** + * 是否自动创建索引 + */ + private Boolean autoCreateIndex = true; + + /** + * 批量处理大小 + */ + private Integer batchSize = 1000; + + /** + * 最大重试次数 + */ + private Integer maxRetryCount = 3; + + /** + * 实体抽取配置 + */ + private ExtractionConfig extraction = new ExtractionConfig(); + + /** + * 查询配置 + */ + private QueryConfig query = new QueryConfig(); + + @Data + public static class ExtractionConfig { + /** + * 置信度阈值(低于此值的实体将被过滤) + */ + private Double confidenceThreshold = 0.7; + + /** + * 最大实体数量(每个文档) + */ + private Integer maxEntitiesPerDoc = 100; + + /** + * 最大关系数量(每个文档) + */ + private Integer maxRelationsPerDoc = 200; + + /** + * 文本分片大小(用于长文档) + */ + private Integer chunkSize = 2000; + + /** + * 分片重叠大小 + */ + private Integer chunkOverlap = 200; + } + + @Data + public static class QueryConfig { + /** + * 默认查询限制数量 + */ + private Integer defaultLimit = 100; + + /** + * 最大查询限制数量 + */ + private Integer maxLimit = 1000; + + /** + * 路径查询最大深度 + */ + private Integer maxPathDepth = 5; + + /** + * 查询超时时间(秒) + */ + private Integer timeoutSeconds = 30; + + /** + * 是否启用查询缓存 + */ + private Boolean cacheEnabled = true; + + /** + * 缓存过期时间(分钟) + */ + private Integer cacheExpireMinutes = 60; + } +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/config/Neo4jConfig.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/config/Neo4jConfig.java new file mode 100644 index 00000000..626021b5 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/config/Neo4jConfig.java @@ -0,0 +1,70 @@ +package org.ruoyi.graph.config; + +import lombok.Data; +import org.neo4j.driver.AuthTokens; +import org.neo4j.driver.Driver; +import org.neo4j.driver.GraphDatabase; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Neo4j配置类 + * + * @author ruoyi + * @date 2025-09-30 + */ +@Data +@Configuration +@ConfigurationProperties(prefix = "neo4j") +public class Neo4jConfig { + + /** + * Neo4j连接URI + * 例如: bolt://localhost:7687 + */ + private String uri; + + /** + * 用户名 + */ + private String username; + + /** + * 密码 + */ + private String password; + + /** + * 数据库名称(Neo4j 4.0+支持多数据库) + * 默认: neo4j + */ + private String database = "neo4j"; + + /** + * 最大连接池大小 + */ + private Integer maxConnectionPoolSize = 50; + + /** + * 连接超时时间(秒) + */ + private Integer connectionTimeoutSeconds = 30; + + /** + * 创建Neo4j Driver Bean + * + * @return Neo4j Driver + */ + @Bean + public Driver neo4jDriver() { + return GraphDatabase.driver( + uri, + AuthTokens.basic(username, password), + org.neo4j.driver.Config.builder() + .withMaxConnectionPoolSize(maxConnectionPoolSize) + .withConnectionTimeout(connectionTimeoutSeconds, java.util.concurrent.TimeUnit.SECONDS) + .build() + ); + } +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/constants/GraphConstants.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/constants/GraphConstants.java new file mode 100644 index 00000000..832b3233 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/constants/GraphConstants.java @@ -0,0 +1,103 @@ +package org.ruoyi.graph.constants; + +/** + * 知识图谱常量 + * + * @author ruoyi + * @date 2025-09-30 + */ +public class GraphConstants { + + /** + * 图谱记录分隔符 + */ + public static final String GRAPH_RECORD_DELIMITER = "##"; + + /** + * 图谱元组分隔符 + */ + public static final String GRAPH_TUPLE_DELIMITER = "<|>"; + + /** + * 图谱完成标记 + */ + public static final String GRAPH_COMPLETION_DELIMITER = "<|COMPLETE|>"; + + /** + * 实体类型:人物 + */ + public static final String ENTITY_TYPE_PERSON = "PERSON"; + + /** + * 实体类型:组织机构 + */ + public static final String ENTITY_TYPE_ORGANIZATION = "ORGANIZATION"; + + /** + * 实体类型:地点 + */ + public static final String ENTITY_TYPE_LOCATION = "LOCATION"; + + /** + * 实体类型:概念 + */ + public static final String ENTITY_TYPE_CONCEPT = "CONCEPT"; + + /** + * 实体类型:事件 + */ + public static final String ENTITY_TYPE_EVENT = "EVENT"; + + /** + * 实体类型:产品 + */ + public static final String ENTITY_TYPE_PRODUCT = "PRODUCT"; + + /** + * 实体类型:技术 + */ + public static final String ENTITY_TYPE_TECHNOLOGY = "TECHNOLOGY"; + + /** + * 默认实体抽取类型列表 + */ + public static final String[] DEFAULT_ENTITY_TYPES = { + ENTITY_TYPE_PERSON, + ENTITY_TYPE_ORGANIZATION, + ENTITY_TYPE_LOCATION, + ENTITY_TYPE_CONCEPT, + ENTITY_TYPE_EVENT, + ENTITY_TYPE_PRODUCT, + ENTITY_TYPE_TECHNOLOGY + }; + + /** + * 元数据键:知识库UUID + */ + public static final String METADATA_KB_UUID = "kb_uuid"; + + /** + * 元数据键:知识库条目UUID + */ + public static final String METADATA_KB_ITEM_UUID = "kb_item_uuid"; + + /** + * 元数据键:文档UUID + */ + public static final String METADATA_DOC_UUID = "doc_uuid"; + + /** + * 元数据键:片段UUID + */ + public static final String METADATA_SEGMENT_UUID = "segment_uuid"; + + /** + * RAG最大片段大小(token数) + */ + public static final int RAG_MAX_SEGMENT_SIZE_IN_TOKENS = 512; + + /** + * RAG片段重叠大小(token数) + */ + public static final int RAG_SEGMENT_OVERLAP_IN_TOKENS = 50; +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/controller/GraphInstanceController.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/controller/GraphInstanceController.java new file mode 100644 index 00000000..871eb2fe --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/controller/GraphInstanceController.java @@ -0,0 +1,416 @@ +package org.ruoyi.graph.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.ruoyi.common.core.domain.R; +import org.ruoyi.common.web.core.BaseController; +import org.ruoyi.graph.domain.GraphBuildTask; +import org.ruoyi.graph.domain.GraphInstance; +import org.ruoyi.graph.enums.GraphStatusEnum; +import org.ruoyi.graph.service.IGraphBuildTaskService; +import org.ruoyi.graph.service.IGraphInstanceService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 图谱实例管理控制器 + * + * @author ruoyi + * @date 2025-09-30 + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/graph/instance") +@Tag(name = "图谱实例管理", description = "知识图谱实例的创建、查询、更新、删除") +public class GraphInstanceController extends BaseController { + + private final IGraphInstanceService graphInstanceService; + private final IGraphBuildTaskService buildTaskService; + + /** + * 辅助方法:根据ID或UUID获取图谱实例 + */ + private GraphInstance getInstanceByIdOrUuid(String id) { + GraphInstance instance = null; + + // 尝试作为数字ID查询 + try { + Long numericId = Long.parseLong(id); + instance = graphInstanceService.getById(numericId); + } catch (NumberFormatException e) { + // 不是数字,尝试作为UUID查询 + instance = graphInstanceService.getByUuid(id); + } + + return instance; + } + + /** + * 创建图谱实例 + */ + @Operation(summary = "创建图谱实例") + @PostMapping + public R createInstance(@RequestBody GraphInstance graphInstance) { + try { + if (graphInstance.getKnowledgeId() == null || graphInstance.getKnowledgeId().trim().isEmpty()) { + return R.fail("知识库ID不能为空"); + } + if (graphInstance.getInstanceName() == null || graphInstance.getInstanceName().trim().isEmpty()) { + return R.fail("图谱名称不能为空"); + } + + // 创建基础实例 + GraphInstance instance = graphInstanceService.createInstance( + graphInstance.getKnowledgeId(), + graphInstance.getInstanceName(), + graphInstance.getConfig() + ); + + // 设置扩展属性 + boolean needUpdate = false; + if (graphInstance.getModelName() != null) { + instance.setModelName(graphInstance.getModelName()); + needUpdate = true; + } + if (graphInstance.getEntityTypes() != null) { + instance.setEntityTypes(graphInstance.getEntityTypes()); + needUpdate = true; + } + if (graphInstance.getRelationTypes() != null) { + instance.setRelationTypes(graphInstance.getRelationTypes()); + needUpdate = true; + } + if (graphInstance.getRemark() != null) { + instance.setRemark(graphInstance.getRemark()); + needUpdate = true; + } + + // 如果有扩展属性,更新到数据库 + if (needUpdate) { + graphInstanceService.updateInstance(instance); + } + + return R.ok(instance); + } catch (Exception e) { + return R.fail("创建图谱实例失败: " + e.getMessage()); + } + } + + /** + * 更新图谱实例 + */ + @Operation(summary = "更新图谱实例") + @PutMapping + public R updateInstance(@RequestBody GraphInstance graphInstance) { + try { + if (graphInstance.getId() == null && (graphInstance.getGraphUuid() == null || graphInstance.getGraphUuid().trim().isEmpty())) { + return R.fail("图谱ID不能为空"); + } + + // 如果有 instanceName,更新基本信息 + if (graphInstance.getInstanceName() != null) { + // 这里可以添加更新实例名称的逻辑 + } + + // 更新配置 + if (graphInstance.getConfig() != null) { + graphInstanceService.updateConfig(graphInstance.getGraphUuid(), graphInstance.getConfig()); + } + + // 更新模型名称、实体类型、关系类型等 + // 注意:这里需要在 Service 层实现完整的更新逻辑 + + GraphInstance instance = graphInstanceService.getByUuid(graphInstance.getGraphUuid()); + return R.ok(instance); + } catch (Exception e) { + return R.fail("更新图谱实例失败: " + e.getMessage()); + } + } + + /** + * 根据ID或UUID获取图谱实例 + */ + @Operation(summary = "获取图谱实例") + @GetMapping("/{id}") + public R getByUuid(@PathVariable String id) { + try { + GraphInstance instance = getInstanceByIdOrUuid(id); + + if (instance == null) { + return R.fail("图谱实例不存在"); + } + return R.ok(instance); + } catch (Exception e) { + return R.fail("获取图谱实例失败: " + e.getMessage()); + } + } + + /** + * 获取图谱实例列表(支持分页和条件查询) + */ + @Operation(summary = "获取图谱实例列表") + @GetMapping("/list") + public R> list( + @RequestParam(required = false) String instanceName, + @RequestParam(required = false) String knowledgeId, + @RequestParam(required = false) String status, + @RequestParam(required = false, defaultValue = "1") Integer pageNum, + @RequestParam(required = false, defaultValue = "10") Integer pageSize) { + try { + // 使用枚举转换前端状态字符串为数字状态码 + Integer graphStatus = GraphStatusEnum.getCodeByStatusKey(status); + + // 创建分页对象 + Page page = new Page<>(pageNum, pageSize); + + // 调用 Service 层分页查询 + Page result = graphInstanceService.queryPage(page, instanceName, knowledgeId, graphStatus); + + // 构造返回结果 + Map data = new HashMap<>(); + data.put("rows", result.getRecords()); + data.put("total", result.getTotal()); + + return R.ok(data); + } catch (Exception e) { + return R.fail("获取图谱列表失败: " + e.getMessage()); + } + } + + /** + * 根据知识库ID获取图谱列表 + */ + @Operation(summary = "获取知识库的图谱列表") + @GetMapping("/knowledge/{knowledgeId}") + public R> listByKnowledge(@PathVariable String knowledgeId) { + try { + List instances = graphInstanceService.listByKnowledgeId(knowledgeId); + return R.ok(instances); + } catch (Exception e) { + return R.fail("获取图谱列表失败: " + e.getMessage()); + } + } + + /** + * 更新图谱状态 + */ + @Operation(summary = "更新图谱状态") + @PutMapping("/status/{graphUuid}") + public R updateStatus( + @PathVariable String graphUuid, + @RequestParam Integer status) { + try { + boolean success = graphInstanceService.updateStatus(graphUuid, status); + return success ? R.ok() : R.fail("更新状态失败"); + } catch (Exception e) { + return R.fail("更新状态失败: " + e.getMessage()); + } + } + + /** + * 更新图谱统计信息 + */ + @Operation(summary = "更新图谱统计") + @PutMapping("/counts/{graphUuid}") + public R updateCounts( + @PathVariable String graphUuid, + @RequestParam Integer nodeCount, + @RequestParam Integer relationshipCount) { + try { + boolean success = graphInstanceService.updateCounts(graphUuid, nodeCount, relationshipCount); + return success ? R.ok() : R.fail("更新统计失败"); + } catch (Exception e) { + return R.fail("更新统计失败: " + e.getMessage()); + } + } + + /** + * 更新图谱配置 + */ + @Operation(summary = "更新图谱配置") + @PutMapping("/config/{graphUuid}") + public R updateConfig( + @PathVariable String graphUuid, + @RequestBody Map request) { + try { + String config = request.get("config"); + boolean success = graphInstanceService.updateConfig(graphUuid, config); + return success ? R.ok() : R.fail("更新配置失败"); + } catch (Exception e) { + return R.fail("更新配置失败: " + e.getMessage()); + } + } + + /** + * 删除图谱实例(软删除) + */ + @Operation(summary = "删除图谱实例") + @DeleteMapping("/{id}") + public R deleteInstance(@PathVariable String id) { + try { + // 获取图谱实例 + GraphInstance instance = getInstanceByIdOrUuid(id); + if (instance == null) { + return R.fail("图谱实例不存在"); + } + + boolean success = graphInstanceService.deleteInstance(instance.getGraphUuid()); + return success ? R.ok() : R.fail("删除失败"); + } catch (Exception e) { + return R.fail("删除图谱实例失败: " + e.getMessage()); + } + } + + /** + * 物理删除图谱实例及其数据 + */ + @Operation(summary = "彻底删除图谱") + @DeleteMapping("/permanent/{graphUuid}") + public R deleteInstanceAndData(@PathVariable String graphUuid) { + try { + boolean success = graphInstanceService.deleteInstanceAndData(graphUuid); + return success ? R.ok() : R.fail("删除失败"); + } catch (Exception e) { + return R.fail("彻底删除图谱失败: " + e.getMessage()); + } + } + + /** + * 获取图谱统计信息 + */ + @Operation(summary = "获取图谱统计") + @GetMapping("/stats/{graphUuid}") + public R> getStatistics(@PathVariable String graphUuid) { + try { + Map stats = graphInstanceService.getStatistics(graphUuid); + return R.ok(stats); + } catch (Exception e) { + return R.fail("获取统计信息失败: " + e.getMessage()); + } + } + + /** + * 构建图谱(全量构建知识库) + */ + @Operation(summary = "构建图谱") + @PostMapping("/build/{id}") + public R buildGraph(@PathVariable String id) { + try { + // 获取图谱实例 + GraphInstance instance = getInstanceByIdOrUuid(id); + + if (instance == null) { + return R.fail("图谱实例不存在"); + } + + // 更新状态为构建中 + graphInstanceService.updateStatus(instance.getGraphUuid(), 10); // 10=构建中 + + // 创建构建任务(全量构建) + GraphBuildTask task = buildTaskService.createTask( + instance.getGraphUuid(), + instance.getKnowledgeId(), + null, // docId=null 表示全量构建 + 1 // taskType=1 全量构建 + ); + + // 异步启动任务 + buildTaskService.startTask(task.getTaskUuid()); + + return R.ok(task); + } catch (Exception e) { + return R.fail("启动构建任务失败: " + e.getMessage()); + } + } + + /** + * 重建图谱(清空后重新构建) + */ + @Operation(summary = "重建图谱") + @PostMapping("/rebuild/{id}") + public R rebuildGraph(@PathVariable String id) { + try { + // 获取图谱实例 + GraphInstance instance = getInstanceByIdOrUuid(id); + + if (instance == null) { + return R.fail("图谱实例不存在"); + } + + // 更新状态为构建中 + graphInstanceService.updateStatus(instance.getGraphUuid(), 10); // 10=构建中 + + // 创建重建任务 + GraphBuildTask task = buildTaskService.createTask( + instance.getGraphUuid(), + instance.getKnowledgeId(), + null, // docId=null 表示全量 + 2 // taskType=2 重建 + ); + + // 异步启动任务 + buildTaskService.startTask(task.getTaskUuid()); + + return R.ok(task); + } catch (Exception e) { + return R.fail("启动重建任务失败: " + e.getMessage()); + } + } + + /** + * 获取构建状态 + */ + @Operation(summary = "获取构建状态") + @GetMapping("/status/{id}") + public R> getBuildStatus(@PathVariable String id) { + try { + // 获取图谱实例 + GraphInstance instance = getInstanceByIdOrUuid(id); + + if (instance == null) { + return R.fail("图谱实例不存在"); + } + + // 获取最新的构建任务 + GraphBuildTask latestTask = buildTaskService.getLatestTask(instance.getGraphUuid()); + + Map result = new HashMap<>(); + result.put("graphStatus", instance.getGraphStatus()); + result.put("nodeCount", instance.getNodeCount()); + result.put("relationshipCount", instance.getRelationshipCount()); + + if (latestTask != null) { + result.put("taskStatus", latestTask.getTaskStatus()); + // ⭐ 确保 progress 不为 null,前端期望是 number 类型 + Integer progress = latestTask.getProgress(); + result.put("progress", progress != null ? progress : 0); + result.put("errorMessage", latestTask.getErrorMessage()); + + // 转换状态字符串(兼容前端) + String status = "NOT_BUILT"; + if (instance.getGraphStatus() == 10) status = "BUILDING"; + else if (instance.getGraphStatus() == 20) status = "COMPLETED"; + else if (instance.getGraphStatus() == 30) status = "FAILED"; + + result.put("status", status); + } else { + // ⭐ 如果没有任务,也返回默认值 + result.put("taskStatus", null); + result.put("progress", 0); + result.put("errorMessage", null); + result.put("status", "NOT_BUILT"); + } + + return R.ok(result); + } catch (Exception e) { + return R.fail("获取构建状态失败: " + e.getMessage()); + } + } +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/controller/GraphQueryController.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/controller/GraphQueryController.java new file mode 100644 index 00000000..86796f47 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/controller/GraphQueryController.java @@ -0,0 +1,225 @@ +package org.ruoyi.graph.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.ruoyi.common.core.domain.R; +import org.ruoyi.common.web.core.BaseController; +import org.ruoyi.graph.domain.GraphEdge; +import org.ruoyi.graph.domain.GraphVertex; +import org.ruoyi.graph.dto.GraphExtractionResult; +import org.ruoyi.graph.service.IGraphExtractionService; +import org.ruoyi.graph.service.IGraphRAGService; +import org.ruoyi.graph.service.IGraphStoreService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 图谱查询控制器 + * + * @author ruoyi + * @date 2025-09-30 + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/graph/query") +@Tag(name = "图谱查询", description = "知识图谱查询相关接口") +public class GraphQueryController extends BaseController { + + private final IGraphStoreService graphStoreService; + private final IGraphExtractionService graphExtractionService; + private final IGraphRAGService graphRAGService; + + /** + * 获取知识库的图谱数据 + */ + @Operation(summary = "获取知识库图谱") + @GetMapping("/knowledge/{knowledgeId}") + public R> getGraphByKnowledge( + @PathVariable String knowledgeId, + @RequestParam(defaultValue = "100") Integer limit) { + + try { + // 查询节点 + List vertices = graphStoreService.queryVerticesByKnowledgeId(knowledgeId, limit); + + // 查询关系 + List edges = graphStoreService.queryEdgesByKnowledgeId(knowledgeId, limit); + + Map result = new HashMap<>(); + result.put("vertices", vertices); + result.put("edges", edges); + result.put("vertexCount", vertices.size()); + result.put("edgeCount", edges.size()); + + return R.ok(result); + } catch (Exception e) { + return R.fail("获取图谱数据失败: " + e.getMessage()); + } + } + + /** + * 搜索实体节点 + */ + @Operation(summary = "搜索实体") + @GetMapping("/search/entity") + public R> searchEntity( + @RequestParam String keyword, + @RequestParam(required = false) String knowledgeId, + @RequestParam(defaultValue = "20") Integer limit) { + + try { + List vertices = graphStoreService.searchVerticesByName(keyword, knowledgeId, limit); + return R.ok(vertices); + } catch (Exception e) { + return R.fail("搜索实体失败: " + e.getMessage()); + } + } + + /** + * 查询实体的邻居节点 + */ + @Operation(summary = "查询邻居节点") + @GetMapping("/neighbors/{nodeId}") + public R> getNeighbors( + @PathVariable String nodeId, + @RequestParam(required = false) String knowledgeId, + @RequestParam(defaultValue = "20") Integer limit) { + + try { + List neighbors = graphStoreService.getNeighbors(nodeId, knowledgeId, limit); + return R.ok(neighbors); + } catch (Exception e) { + return R.fail("查询邻居节点失败: " + e.getMessage()); + } + } + + /** + * 查询两个实体之间的路径 + */ + @Operation(summary = "查询实体路径") + @GetMapping("/path") + public R>> findPath( + @RequestParam String startNodeId, + @RequestParam String endNodeId, + @RequestParam(defaultValue = "5") Integer maxDepth) { + + try { + List> paths = graphStoreService.findPaths(startNodeId, endNodeId, maxDepth); + return R.ok(paths); + } catch (Exception e) { + return R.fail("查询路径失败: " + e.getMessage()); + } + } + + /** + * 从文本抽取实体和关系(测试用) + */ + @Operation(summary = "文本实体抽取") + @PostMapping("/extract") + public R extractFromText(@RequestBody Map request) { + try { + String text = request.get("text"); + if (text == null || text.trim().isEmpty()) { + return R.fail("文本不能为空"); + } + + String modelName = request.get("modelName"); + GraphExtractionResult result; + + if (modelName != null && !modelName.trim().isEmpty()) { + result = graphExtractionService.extractFromTextWithModel(text, modelName); + } else { + result = graphExtractionService.extractFromText(text); + } + + return R.ok(result); + } catch (Exception e) { + return R.fail("实体抽取失败: " + e.getMessage()); + } + } + + /** + * 将文本入库到图谱 + */ + @Operation(summary = "文本入库") + @PostMapping("/ingest") + public R ingestText(@RequestBody Map request) { + try { + String text = (String) request.get("text"); + String knowledgeId = (String) request.get("knowledgeId"); + String modelName = (String) request.get("modelName"); + + if (text == null || text.trim().isEmpty()) { + return R.fail("文本不能为空"); + } + if (knowledgeId == null || knowledgeId.trim().isEmpty()) { + return R.fail("知识库ID不能为空"); + } + + @SuppressWarnings("unchecked") + Map metadata = (Map) request.get("metadata"); + + GraphExtractionResult result; + if (modelName != null && !modelName.trim().isEmpty()) { + result = graphRAGService.ingestTextWithModel(text, knowledgeId, metadata, modelName); + } else { + result = graphRAGService.ingestText(text, knowledgeId, metadata); + } + return R.ok(result); + } catch (Exception e) { + return R.fail("文本入库失败: " + e.getMessage()); + } + } + + /** + * 基于图谱检索 + */ + @Operation(summary = "图谱检索") + @GetMapping("/retrieve") + public R retrieveFromGraph( + @RequestParam String query, + @RequestParam String knowledgeId, + @RequestParam(defaultValue = "10") Integer maxResults) { + + try { + String result = graphRAGService.retrieveFromGraph(query, knowledgeId, maxResults); + return R.ok(result); + } catch (Exception e) { + return R.fail("图谱检索失败: " + e.getMessage()); + } + } + + /** + * 删除知识库的图谱数据 + */ + @Operation(summary = "删除图谱数据") + @DeleteMapping("/knowledge/{knowledgeId}") + public R deleteGraphData(@PathVariable String knowledgeId) { + try { + boolean success = graphRAGService.deleteGraphData(knowledgeId); + return success ? R.ok() : R.fail("删除失败"); + } catch (Exception e) { + return R.fail("删除图谱数据失败: " + e.getMessage()); + } + } + + /** + * 获取图谱统计信息 + */ + @Operation(summary = "图谱统计") + @GetMapping("/stats/{knowledgeId}") + public R> getGraphStats(@PathVariable String knowledgeId) { + try { + Map stats = graphStoreService.getStatistics(knowledgeId); + return R.ok(stats); + } catch (Exception e) { + return R.fail("获取统计信息失败: " + e.getMessage()); + } + } +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/controller/Neo4jTestController.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/controller/Neo4jTestController.java new file mode 100644 index 00000000..f94a342b --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/controller/Neo4jTestController.java @@ -0,0 +1,145 @@ +package org.ruoyi.graph.controller; + +import lombok.RequiredArgsConstructor; +import org.ruoyi.common.core.domain.R; +import org.ruoyi.graph.config.GraphProperties; +import org.ruoyi.graph.config.Neo4jConfig; +import org.ruoyi.graph.util.Neo4jTestUtil; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * Neo4j测试控制器 + * 仅用于开发环境测试Neo4j连接 + * + * @author ruoyi + * @date 2025-09-30 + */ +@RestController +@RequestMapping("/graph/test") +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "knowledge.graph", name = "enabled", havingValue = "true") +public class Neo4jTestController { + + private final Neo4jTestUtil neo4jTestUtil; + private final Neo4jConfig neo4jConfig; + private final GraphProperties graphProperties; + + + @GetMapping("/connection") + public R> testConnection() { + Map result = neo4jTestUtil.testConnection(); + + if ("SUCCESS".equals(result.get("connection"))) { + return R.ok("Neo4j连接成功", result); + } else { + return R.fail(result.get("error").toString()); + } + } + + + @GetMapping("/config") + public R> getConfig() { + Map config = new HashMap<>(); + config.put("neo4j", Map.of( + "uri", neo4jConfig.getUri(), + "username", neo4jConfig.getUsername(), + "database", neo4jConfig.getDatabase(), + "maxConnectionPoolSize", neo4jConfig.getMaxConnectionPoolSize() + )); + config.put("graph", Map.of( + "enabled", graphProperties.getEnabled(), + "databaseType", graphProperties.getDatabaseType(), + "batchSize", graphProperties.getBatchSize(), + "extraction", graphProperties.getExtraction(), + "query", graphProperties.getQuery() + )); + return R.ok(config); + } + + + @PostMapping("/node") + public R> createTestNode(@RequestParam String name) { + Map result = neo4jTestUtil.createTestNode(name); + + if (Boolean.TRUE.equals(result.get("success"))) { + return R.ok("测试节点创建成功", result); + } else { + return R.fail(result.get("error").toString()); + } + } + + + @GetMapping("/node/{name}") + public R> queryTestNode(@PathVariable String name) { + Map result = neo4jTestUtil.queryTestNode(name); + + if (Boolean.TRUE.equals(result.get("found"))) { + return R.ok("节点查询成功", result); + } else { + return R.ok("未找到节点", result); + } + } + + + @PostMapping("/relationship") + public R> createTestRelationship( + @RequestParam String source, + @RequestParam String target) { + + Map result = neo4jTestUtil.createTestRelationship(source, target); + + if (Boolean.TRUE.equals(result.get("success"))) { + return R.ok("测试关系创建成功", result); + } else { + return R.fail(result.get("error").toString()); + } + } + + + @DeleteMapping("/nodes") + public R> deleteAllTestNodes() { + Map result = neo4jTestUtil.deleteAllTestNodes(); + + if (Boolean.TRUE.equals(result.get("success"))) { + return R.ok("测试节点清理完成", result); + } else { + return R.fail(result.get("error").toString()); + } + } + + + @GetMapping("/statistics") + public R> getStatistics() { + Map result = neo4jTestUtil.getStatistics(); + + if (result.containsKey("error")) { + return R.fail(result.get("error").toString()); + } + return R.ok(result); + } + + + @GetMapping("/health") + public R health() { + Map result = neo4jTestUtil.testConnection(); + + if ("SUCCESS".equals(result.get("connection"))) { + return R.ok("Neo4j服务正常"); + } else { + return R.fail("Neo4j服务异常: " + result.get("error")); + } + } + + /** + * 调试关系查询 - 查看指定知识库的所有关系 + */ + @GetMapping("/debug/relationships/{knowledgeId}") + public R> debugRelationships(@PathVariable String knowledgeId) { + Map result = neo4jTestUtil.debugRelationships(knowledgeId); + return R.ok(result); + } +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/GraphBuildTask.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/GraphBuildTask.java new file mode 100644 index 00000000..03cbccff --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/GraphBuildTask.java @@ -0,0 +1,107 @@ +package org.ruoyi.graph.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import org.ruoyi.core.domain.BaseEntity; + +import java.io.Serial; +import java.util.Date; + +/** + * 图谱构建任务对象 graph_build_task + * + * @author ruoyi + * @date 2025-09-30 + */ +@Data +@TableName("graph_build_task") +public class GraphBuildTask extends BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id") + private Long id; + + /** + * 任务UUID + */ + private String taskUuid; + + /** + * 图谱UUID + */ + private String graphUuid; + + /** + * 知识库ID + */ + private String knowledgeId; + + /** + * 文档ID(可选,null表示全量构建) + */ + private String docId; + + /** + * 任务类型:1全量构建、2增量更新、3重建 + */ + private Integer taskType; + + /** + * 任务状态:1待执行、2执行中、3成功、4失败 + */ + private Integer taskStatus; + + /** + * 进度百分比(0-100) + */ + private Integer progress; + + /** + * 总文档数 + */ + private Integer totalDocs; + + /** + * 已处理文档数 + */ + private Integer processedDocs; + + /** + * 提取的实体数 + */ + private Integer extractedEntities; + + /** + * 提取的关系数 + */ + private Integer extractedRelations; + + /** + * 错误信息 + */ + private String errorMessage; + + /** + * 结果摘要(JSON格式) + */ + private String resultSummary; + + /** + * 开始时间 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date startTime; + + /** + * 结束时间 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date endTime; +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/GraphEdge.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/GraphEdge.java new file mode 100644 index 00000000..f4d35899 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/GraphEdge.java @@ -0,0 +1,137 @@ +package org.ruoyi.graph.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Map; + +/** + * 图关系实体 + * + * @author ruoyi + * @date 2025-09-30 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@TableName("graph_edge") +public class GraphEdge implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id") + private Long id; + + /** + * 图谱UUID + */ + private String graphUuid; + + /** + * 关系唯一标识(Neo4j中的关系ID) + */ + private String edgeId; + + /** + * 关系标签(类型) + */ + private String label; + + /** + * 源节点ID + */ + private String sourceNodeId; + + /** + * 源节点名称 + */ + private String sourceName; + + /** + * 目标节点ID + */ + private String targetNodeId; + + /** + * 目标节点名称 + */ + private String targetName; + + /** + * 关系类型编码 + */ + private String relationType; + + /** + * 关系描述 + */ + private String description; + + /** + * 关系权重(0.0-1.0) + */ + private Double weight; + + /** + * 置信度(0.0-1.0) + */ + private Double confidence; + + /** + * 来源知识库ID + */ + private String knowledgeId; + + /** + * 来源文档ID列表(JSON格式) + */ + private String docIds; + + /** + * 来源片段ID列表(JSON格式) + */ + private String fragmentIds; + + /** + * 文本段ID(关联到具体的文本段) + */ + @JsonProperty("text_segment_id") + private String textSegmentId; + + /** + * 其他属性(JSON格式) + */ + private String properties; + + /** + * 元数据(JSON格式) + */ + private Map metadata; + + /** + * 源节点元数据 + */ + private Map sourceMetadata; + + /** + * 目标节点元数据 + */ + private Map targetMetadata; + + /** + * 备注 + */ + private String remark; +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/GraphEntityType.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/GraphEntityType.java new file mode 100644 index 00000000..8828b8d5 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/GraphEntityType.java @@ -0,0 +1,65 @@ +package org.ruoyi.graph.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.core.domain.BaseEntity; + +import java.io.Serial; + +/** + * 图谱实体类型定义对象 graph_entity_type + * + * @author ruoyi + * @date 2025-09-30 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("graph_entity_type") +public class GraphEntityType extends BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id") + private Long id; + + /** + * 实体类型名称 + */ + private String typeName; + + /** + * 类型编码 + */ + private String typeCode; + + /** + * 描述 + */ + private String description; + + /** + * 可视化颜色 + */ + private String color; + + /** + * 图标 + */ + private String icon; + + /** + * 显示顺序 + */ + private Integer sort; + + /** + * 是否启用(0否 1是) + */ + private Integer isEnable; +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/GraphInstance.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/GraphInstance.java new file mode 100644 index 00000000..e45d6e82 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/GraphInstance.java @@ -0,0 +1,117 @@ +package org.ruoyi.graph.domain; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.core.domain.BaseEntity; + +import java.io.Serial; + +/** + * 知识图谱实例对象 knowledge_graph_instance + * + * @author ruoyi + * @date 2025-09-30 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("knowledge_graph_instance") +public class GraphInstance extends BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id") + private Long id; + + /** + * 图谱UUID + */ + private String graphUuid; + + /** + * 关联knowledge_info.kid + */ + private String knowledgeId; + + /** + * 图谱名称 + */ + private String graphName; + + /** + * 图谱实例名称(前端使用,不映射到数据库) + */ + @TableField(exist = false) + private String instanceName; + + /** + * 构建状态:10构建中、20已完成、30失败 + */ + private Integer graphStatus; + + /** + * 节点数量 + */ + private Integer nodeCount; + + /** + * 关系数量 + */ + private Integer relationshipCount; + + /** + * 图谱配置(JSON格式) + */ + private String config; + + /** + * LLM模型名称 + */ + private String modelName; + + /** + * 实体类型(逗号分隔) + */ + private String entityTypes; + + /** + * 关系类型(逗号分隔) + */ + private String relationTypes; + + /** + * 错误信息 + */ + private String errorMessage; + + /** + * 删除标志(0代表存在 1代表删除) + */ + private String delFlag; + + /** + * 备注 + */ + private String remark; + + /** + * 获取实例名称(兼容前端) + */ + public String getInstanceName() { + return instanceName != null ? instanceName : graphName; + } + + /** + * 设置实例名称(同步到graphName) + */ + public void setInstanceName(String instanceName) { + this.instanceName = instanceName; + this.graphName = instanceName; + } +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/GraphRelationType.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/GraphRelationType.java new file mode 100644 index 00000000..6cf9e581 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/GraphRelationType.java @@ -0,0 +1,65 @@ +package org.ruoyi.graph.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.core.domain.BaseEntity; + +import java.io.Serial; + +/** + * 图谱关系类型定义对象 graph_relation_type + * + * @author ruoyi + * @date 2025-09-30 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("graph_relation_type") +public class GraphRelationType extends BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id") + private Long id; + + /** + * 关系名称 + */ + private String relationName; + + /** + * 关系编码 + */ + private String relationCode; + + /** + * 描述 + */ + private String description; + + /** + * 关系方向:0双向、1单向 + */ + private Integer direction; + + /** + * 可视化样式(JSON格式) + */ + private String style; + + /** + * 显示顺序 + */ + private Integer sort; + + /** + * 是否启用(0否 1是) + */ + private Integer isEnable; +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/GraphVertex.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/GraphVertex.java new file mode 100644 index 00000000..8c36925f --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/GraphVertex.java @@ -0,0 +1,112 @@ +package org.ruoyi.graph.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Map; + +/** + * 图节点实体 + * + * @author ruoyi + * @date 2025-09-30 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@TableName("graph_vertex") +public class GraphVertex implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id") + private Long id; + + /** + * 图谱UUID + */ + private String graphUuid; + + /** + * 节点唯一标识(Neo4j中的节点ID) + */ + private String nodeId; + + /** + * 节点名称 + */ + private String name; + + /** + * 节点标签(类型) + */ + private String label; + + /** + * 节点类型编码 + */ + private String type; + + /** + * 描述信息 + */ + private String description; + + /** + * 置信度(0.0-1.0) + */ + private Double confidence; + + /** + * 来源知识库ID + */ + private String knowledgeId; + + /** + * 来源文档ID列表(JSON格式) + */ + private String docIds; + + /** + * 来源片段ID列表(JSON格式) + */ + private String fragmentIds; + + /** + * 文本段ID(关联到具体的文本段) + */ + @JsonProperty("text_segment_id") + private String textSegmentId; + + /** + * 别名列表(JSON格式) + */ + private String aliases; + + /** + * 其他属性(JSON格式) + */ + private String properties; + + /** + * 元数据(JSON格式) + */ + private Map metadata; + + /** + * 备注 + */ + private String remark; +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/KnowledgeBaseGraphSegment.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/KnowledgeBaseGraphSegment.java new file mode 100644 index 00000000..b728c2ee --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/domain/KnowledgeBaseGraphSegment.java @@ -0,0 +1,97 @@ +package org.ruoyi.graph.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.ruoyi.core.domain.BaseEntity; + + +import java.io.Serial; + +/** + * 知识图谱片段实体 + * + * @author ruoyi + * @date 2025-09-30 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("knowledge_base_graph_segment") +public class KnowledgeBaseGraphSegment extends BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 片段UUID + */ + private String uuid; + + /** + * 知识库UUID + */ + private String kbUuid; + + /** + * 知识库条目UUID + */ + private String kbItemUuid; + + /** + * 文档UUID + */ + private String docUuid; + + /** + * 片段文本内容 + */ + private String segmentText; + + /** + * 片段索引(第几个片段) + */ + private Integer chunkIndex; + + /** + * 总片段数 + */ + private Integer totalChunks; + + /** + * 抽取状态:0-待处理 1-处理中 2-已完成 3-失败 + */ + private Integer extractionStatus; + + /** + * 抽取的实体数量 + */ + private Integer entityCount; + + /** + * 抽取的关系数量 + */ + private Integer relationCount; + + /** + * 消耗的token数 + */ + private Integer tokenUsed; + + /** + * 错误信息 + */ + private String errorMessage; + + /** + * 用户ID + */ + private Long userId; +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/dto/ExtractedEntity.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/dto/ExtractedEntity.java new file mode 100644 index 00000000..a703a928 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/dto/ExtractedEntity.java @@ -0,0 +1,45 @@ +package org.ruoyi.graph.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 抽取的实体 + * + * @author ruoyi + * @date 2025-09-30 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ExtractedEntity implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 实体名称 + */ + private String name; + + /** + * 实体类型 + */ + private String type; + + /** + * 实体描述 + */ + private String description; + + /** + * 置信度(0.0-1.0) + */ + private Double confidence; +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/dto/ExtractedRelation.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/dto/ExtractedRelation.java new file mode 100644 index 00000000..5f5b65fe --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/dto/ExtractedRelation.java @@ -0,0 +1,50 @@ +package org.ruoyi.graph.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 抽取的关系 + * + * @author ruoyi + * @date 2025-09-30 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ExtractedRelation implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 源实体名称 + */ + private String sourceEntity; + + /** + * 目标实体名称 + */ + private String targetEntity; + + /** + * 关系描述 + */ + private String description; + + /** + * 关系强度(0-10) + */ + private Integer strength; + + /** + * 置信度(0.0-1.0) + */ + private Double confidence; +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/dto/GraphExtractionResult.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/dto/GraphExtractionResult.java new file mode 100644 index 00000000..c88e0af5 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/dto/GraphExtractionResult.java @@ -0,0 +1,56 @@ +package org.ruoyi.graph.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 图谱抽取结果 + * + * @author ruoyi + * @date 2025-09-30 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class GraphExtractionResult implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 抽取的实体列表 + */ + private List entities; + + /** + * 抽取的关系列表 + */ + private List relations; + + /** + * 原始LLM响应 + */ + private String rawResponse; + + /** + * 消耗的token数 + */ + private Integer tokenUsed; + + /** + * 是否成功 + */ + private Boolean success; + + /** + * 错误信息 + */ + private String errorMessage; +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/enums/GraphStatusEnum.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/enums/GraphStatusEnum.java new file mode 100644 index 00000000..1b610d6c --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/enums/GraphStatusEnum.java @@ -0,0 +1,78 @@ +package org.ruoyi.graph.enums; + +import lombok.Getter; + +/** + * 图谱构建状态枚举 + * + * @author ruoyi + * @date 2025-09-30 + */ +@Getter +public enum GraphStatusEnum { + + /** + * 未构建 + */ + NOT_BUILT(0, "未构建", "NOT_BUILT"), + + /** + * 构建中 + */ + BUILDING(10, "构建中", "BUILDING"), + + /** + * 已完成 + */ + COMPLETED(20, "已完成", "COMPLETED"), + + /** + * 失败 + */ + FAILED(30, "失败", "FAILED"); + + private final Integer code; + private final String description; + private final String statusKey; + + GraphStatusEnum(Integer code, String description, String statusKey) { + this.code = code; + this.description = description; + this.statusKey = statusKey; + } + + /** + * 根据code获取枚举 + */ + public static GraphStatusEnum getByCode(Integer code) { + for (GraphStatusEnum status : values()) { + if (status.getCode().equals(code)) { + return status; + } + } + return null; + } + + /** + * 根据前端状态字符串获取状态码 + */ + public static Integer getCodeByStatusKey(String statusKey) { + if (statusKey == null || statusKey.trim().isEmpty()) { + return null; + } + for (GraphStatusEnum status : values()) { + if (status.getStatusKey().equals(statusKey)) { + return status.getCode(); + } + } + return null; + } + + /** + * 根据状态码获取前端状态字符串 + */ + public static String getStatusKeyByCode(Integer code) { + GraphStatusEnum status = getByCode(code); + return status != null ? status.getStatusKey() : "NOT_BUILT"; + } +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/enums/TaskStatusEnum.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/enums/TaskStatusEnum.java new file mode 100644 index 00000000..56410d8a --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/enums/TaskStatusEnum.java @@ -0,0 +1,53 @@ +package org.ruoyi.graph.enums; + +import lombok.Getter; + +/** + * 构建任务状态枚举 + * + * @author ruoyi + * @date 2025-09-30 + */ +@Getter +public enum TaskStatusEnum { + + /** + * 待执行 + */ + PENDING(1, "待执行"), + + /** + * 执行中 + */ + RUNNING(2, "执行中"), + + /** + * 成功 + */ + SUCCESS(3, "成功"), + + /** + * 失败 + */ + FAILED(4, "失败"); + + private final Integer code; + private final String description; + + TaskStatusEnum(Integer code, String description) { + this.code = code; + this.description = description; + } + + /** + * 根据code获取枚举 + */ + public static TaskStatusEnum getByCode(Integer code) { + for (TaskStatusEnum status : values()) { + if (status.getCode().equals(code)) { + return status; + } + } + return null; + } +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/enums/TaskTypeEnum.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/enums/TaskTypeEnum.java new file mode 100644 index 00000000..c32bfd9b --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/enums/TaskTypeEnum.java @@ -0,0 +1,48 @@ +package org.ruoyi.graph.enums; + +import lombok.Getter; + +/** + * 构建任务类型枚举 + * + * @author ruoyi + * @date 2025-09-30 + */ +@Getter +public enum TaskTypeEnum { + + /** + * 全量构建 + */ + FULL_BUILD(1, "全量构建"), + + /** + * 增量更新 + */ + INCREMENTAL_UPDATE(2, "增量更新"), + + /** + * 重建 + */ + REBUILD(3, "重建"); + + private final Integer code; + private final String description; + + TaskTypeEnum(Integer code, String description) { + this.code = code; + this.description = description; + } + + /** + * 根据code获取枚举 + */ + public static TaskTypeEnum getByCode(Integer code) { + for (TaskTypeEnum type : values()) { + if (type.getCode().equals(code)) { + return type; + } + } + return null; + } +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/factory/GraphLLMServiceFactory.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/factory/GraphLLMServiceFactory.java new file mode 100644 index 00000000..637b3893 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/factory/GraphLLMServiceFactory.java @@ -0,0 +1,82 @@ +package org.ruoyi.graph.factory; + +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.graph.service.llm.IGraphLLMService; +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 图谱LLM服务工厂类 + * 参考 ruoyi-chat 的 ChatServiceFactory 设计 + * 根据模型类别自动选择对应的LLM服务实现 + * + * @author ruoyi + * @date 2025-10-11 + */ +@Slf4j +@Component +public class GraphLLMServiceFactory implements ApplicationContextAware { + + private final Map llmServiceMap = new ConcurrentHashMap<>(); + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + // 初始化时收集所有 IGraphLLMService 的实现 + Map serviceMap = applicationContext.getBeansOfType(IGraphLLMService.class); + + for (IGraphLLMService service : serviceMap.values()) { + if (service != null) { + String category = service.getCategory(); + llmServiceMap.put(category, service); + log.info("注册图谱LLM服务: category={}, service={}", + category, service.getClass().getSimpleName()); + } + } + + log.info("图谱LLM服务工厂初始化完成,共注册 {} 个服务", llmServiceMap.size()); + } + + /** + * 根据模型类别获取对应的LLM服务实现 + * + * @param category 模型类别(如: openai, qwen, zhipu) + * @return LLM服务实现 + * @throws IllegalArgumentException 如果不支持该类别 + */ + public IGraphLLMService getLLMService(String category) { + IGraphLLMService service = llmServiceMap.get(category); + + if (service == null) { + log.error("不支持的模型类别: {}, 可用类别: {}", category, llmServiceMap.keySet()); + throw new IllegalArgumentException("不支持的模型类别: " + category + + ", 可用类别: " + llmServiceMap.keySet()); + } + + return service; + } + + /** + * 获取所有支持的模型类别 + * + * @return 模型类别集合 + */ + public java.util.Set getSupportedCategories() { + return llmServiceMap.keySet(); + } + + /** + * 检查是否支持指定的模型类别 + * + * @param category 模型类别 + * @return 是否支持 + */ + public boolean isSupported(String category) { + return llmServiceMap.containsKey(category); + } +} + diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/listener/GraphExtractionListener.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/listener/GraphExtractionListener.java new file mode 100644 index 00000000..08dac86f --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/listener/GraphExtractionListener.java @@ -0,0 +1,85 @@ +package org.ruoyi.graph.listener; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Response; +import okhttp3.sse.EventSource; +import okhttp3.sse.EventSourceListener; +import org.jetbrains.annotations.NotNull; +import org.ruoyi.common.chat.entity.chat.ChatCompletionResponse; + +import java.util.concurrent.CompletableFuture; + +/** + * 图谱抽取LLM响应监听器 + * 用于收集完整的LLM响应(非流式) + * + * @author ruoyi-ai + */ +@Slf4j +public class GraphExtractionListener extends EventSourceListener { + + private final StringBuilder responseBuilder = new StringBuilder(); + private final CompletableFuture responseFuture; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public GraphExtractionListener(CompletableFuture responseFuture) { + this.responseFuture = responseFuture; + } + + @Override + public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { + log.debug("LLM连接已建立"); + } + + @Override + public void onEvent(@NotNull EventSource eventSource, String id, String type, @NotNull String data) { + try { + if ("[DONE]".equals(data)) { + // 响应完成,返回完整内容 + responseFuture.complete(responseBuilder.toString()); + return; + } + + // 解析响应 + ChatCompletionResponse completionResponse = objectMapper.readValue(data, ChatCompletionResponse.class); + if (completionResponse != null && + completionResponse.getChoices() != null && + !completionResponse.getChoices().isEmpty()) { + + Object content = completionResponse.getChoices().get(0).getDelta().getContent(); + if (content != null) { + responseBuilder.append(content); + } + } + } catch (Exception e) { + log.error("解析LLM响应失败: {}", e.getMessage(), e); + responseFuture.completeExceptionally(e); + } + } + + @Override + public void onClosed(@NotNull EventSource eventSource) { + log.debug("LLM连接已关闭"); + // 如果还没有完成,就用当前内容完成 + if (!responseFuture.isDone()) { + responseFuture.complete(responseBuilder.toString()); + } + } + + @Override + public void onFailure(@NotNull EventSource eventSource, Throwable t, Response response) { + String errorMsg = "LLM调用失败"; + if (response != null && response.body() != null) { + try { + errorMsg = response.body().string(); + } catch (Exception e) { + errorMsg = response.toString(); + } + } + log.error("LLM调用失败: {}", errorMsg, t); + responseFuture.completeExceptionally( + new RuntimeException(errorMsg, t) + ); + } +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/mapper/GraphBuildTaskMapper.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/mapper/GraphBuildTaskMapper.java new file mode 100644 index 00000000..dcb4977c --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/mapper/GraphBuildTaskMapper.java @@ -0,0 +1,65 @@ +package org.ruoyi.graph.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.ruoyi.graph.domain.GraphBuildTask; + +import java.util.List; + +/** + * 图谱构建任务Mapper接口 + * + * @author ruoyi + * @date 2025-09-30 + */ +public interface GraphBuildTaskMapper extends BaseMapper { + + /** + * 根据任务UUID查询 + * + * @param taskUuid 任务UUID + * @return 构建任务 + */ + GraphBuildTask selectByTaskUuid(String taskUuid); + + /** + * 根据图谱UUID查询任务列表 + * + * @param graphUuid 图谱UUID + * @return 任务列表 + */ + List selectByGraphUuid(String graphUuid); + + /** + * 根据知识库ID查询任务列表 + * + * @param knowledgeId 知识库ID + * @return 任务列表 + */ + List selectByKnowledgeId(String knowledgeId); + + /** + * 查询待执行和执行中的任务 + * + * @return 任务列表 + */ + List selectPendingAndRunningTasks(); + + /** + * 更新任务进度 + * + * @param taskUuid 任务UUID + * @param progress 进度 + * @param processedDocs 已处理文档数 + * @return 影响行数 + */ + int updateProgress(String taskUuid, Integer progress, Integer processedDocs); + + /** + * 更新任务状态 + * + * @param taskUuid 任务UUID + * @param status 状态 + * @return 影响行数 + */ + int updateStatus(String taskUuid, Integer status); +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/mapper/GraphEntityTypeMapper.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/mapper/GraphEntityTypeMapper.java new file mode 100644 index 00000000..d5ce4e99 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/mapper/GraphEntityTypeMapper.java @@ -0,0 +1,38 @@ +package org.ruoyi.graph.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.ruoyi.graph.domain.GraphEntityType; + +import java.util.List; + +/** + * 图谱实体类型Mapper接口 + * + * @author ruoyi + * @date 2025-09-30 + */ +public interface GraphEntityTypeMapper extends BaseMapper { + + /** + * 根据类型编码查询 + * + * @param typeCode 类型编码 + * @return 实体类型 + */ + GraphEntityType selectByTypeCode(String typeCode); + + /** + * 查询所有启用的实体类型 + * + * @return 实体类型列表 + */ + List selectEnabledTypes(); + + /** + * 批量查询实体类型 + * + * @param typeCodes 类型编码列表 + * @return 实体类型列表 + */ + List selectByTypeCodes(List typeCodes); +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/mapper/GraphInstanceMapper.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/mapper/GraphInstanceMapper.java new file mode 100644 index 00000000..d7ea08e6 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/mapper/GraphInstanceMapper.java @@ -0,0 +1,48 @@ +package org.ruoyi.graph.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.ruoyi.graph.domain.GraphInstance; + +/** + * 知识图谱实例Mapper接口 + * + * @author ruoyi + * @date 2025-09-30 + */ +public interface GraphInstanceMapper extends BaseMapper { + + /** + * 根据图谱UUID查询 + * + * @param graphUuid 图谱UUID + * @return 图谱实例 + */ + GraphInstance selectByGraphUuid(String graphUuid); + + /** + * 根据知识库ID查询图谱列表 + * + * @param knowledgeId 知识库ID + * @return 图谱实例列表 + */ + java.util.List selectByKnowledgeId(String knowledgeId); + + /** + * 更新节点和关系数量 + * + * @param graphUuid 图谱UUID + * @param nodeCount 节点数量 + * @param relationshipCount 关系数量 + * @return 影响行数 + */ + int updateCounts(String graphUuid, Integer nodeCount, Integer relationshipCount); + + /** + * 更新图谱状态 + * + * @param graphUuid 图谱UUID + * @param status 状态 + * @return 影响行数 + */ + int updateStatus(String graphUuid, Integer status); +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/mapper/GraphRelationTypeMapper.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/mapper/GraphRelationTypeMapper.java new file mode 100644 index 00000000..311a1e01 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/mapper/GraphRelationTypeMapper.java @@ -0,0 +1,38 @@ +package org.ruoyi.graph.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.ruoyi.graph.domain.GraphRelationType; + +import java.util.List; + +/** + * 图谱关系类型Mapper接口 + * + * @author ruoyi + * @date 2025-09-30 + */ +public interface GraphRelationTypeMapper extends BaseMapper { + + /** + * 根据关系编码查询 + * + * @param relationCode 关系编码 + * @return 关系类型 + */ + GraphRelationType selectByRelationCode(String relationCode); + + /** + * 查询所有启用的关系类型 + * + * @return 关系类型列表 + */ + List selectEnabledTypes(); + + /** + * 批量查询关系类型 + * + * @param relationCodes 关系编码列表 + * @return 关系类型列表 + */ + List selectByRelationCodes(List relationCodes); +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/prompt/GraphExtractPrompt.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/prompt/GraphExtractPrompt.java new file mode 100644 index 00000000..d1d4ea8d --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/prompt/GraphExtractPrompt.java @@ -0,0 +1,174 @@ +package org.ruoyi.graph.prompt; + +import org.ruoyi.graph.constants.GraphConstants; + +import java.util.Arrays; +import java.util.stream.Collectors; + +/** + * 图谱实体关系抽取提示词 + * 参考 Microsoft GraphRAG 项目 + * https://github.com/microsoft/graphrag/blob/main/graphrag/index/graph/extractors/graph/prompts.py + * + * @author ruoyi + * @date 2025-09-30 + */ +public class GraphExtractPrompt { + + /** + * 中文实体关系抽取提示词 + */ + public static final String GRAPH_EXTRACTION_PROMPT_CN = """ + -目标- + 给定一个可能与此活动相关的文本文档以及实体类型列表,从文本中识别出所有这些类型的实体以及识别出的实体之间的所有关系。 + + -步骤- + 1. 识别所有实体。对于每个识别出的实体,提取以下信息: + - entity_name:实体的名称,首字母大写 + - entity_type:以下类型之一:[{entity_types}] + - entity_description:实体的属性和活动的全面描述 + 将每个实体格式化为 ("entity"{tuple_delimiter}{tuple_delimiter}{tuple_delimiter}) + + 2. 从步骤1中识别出的实体中,识别出所有明确相关的 (source_entity, target_entity) 对。 + 对于每对相关的实体,提取以下信息: + - source_entity:在步骤1中识别的源实体的名称 + - target_entity:在步骤1中识别的目标实体的名称 + - relationship_description:解释你认为源实体和目标实体之间相关的原因 + - relationship_strength:一个表示源实体和目标实体之间关系强度的数字分数(0-10) + 将每个关系格式化为 ("relationship"{tuple_delimiter}{tuple_delimiter}{tuple_delimiter}{tuple_delimiter}) + + 3. 以英文返回输出,作为所有在步骤1和步骤2中识别的实体和关系的列表。使用 **{record_delimiter}** 作为列表分隔符。 + + 4. 完成时,输出 {completion_delimiter} + + ###################### + -示例- + ###################### + 示例 1: + Entity_types: ORGANIZATION,PERSON + 文本: + The Verdantis's Central Institution is scheduled to meet on Monday and Thursday, with the institution planning to release its latest policy decision on Thursday at 1:30 p.m. PDT, followed by a press conference where Central Institution Chair Martin Smith will take questions. Investors expect the Market Strategy Committee to hold its benchmark interest rate steady in a range of 3.5%-3.75%. + ###################### + 输出: + ("entity"{tuple_delimiter}CENTRAL INSTITUTION{tuple_delimiter}ORGANIZATION{tuple_delimiter}The Central Institution is the Federal Reserve of Verdantis, which is setting interest rates on Monday and Thursday) + {record_delimiter} + ("entity"{tuple_delimiter}MARTIN SMITH{tuple_delimiter}PERSON{tuple_delimiter}Martin Smith is the chair of the Central Institution) + {record_delimiter} + ("entity"{tuple_delimiter}MARKET STRATEGY COMMITTEE{tuple_delimiter}ORGANIZATION{tuple_delimiter}The Central Institution committee makes key decisions about interest rates and the growth of Verdantis's money supply) + {record_delimiter} + ("relationship"{tuple_delimiter}MARTIN SMITH{tuple_delimiter}CENTRAL INSTITUTION{tuple_delimiter}Martin Smith is the Chair of the Central Institution and will answer questions at a press conference{tuple_delimiter}9) + {completion_delimiter} + + ###################### + 示例 2: + Entity_types: ORGANIZATION + 文本: + TechGlobal's (TG) stock skyrocketed in its opening day on the Global Exchange Thursday. But IPO experts warn that the semiconductor corporation's debut on the public markets isn't indicative of how other newly listed companies may perform. + + TechGlobal, a formerly public company, was taken private by Vision Holdings in 2014. The well-established chip designer says it powers 85% of premium smartphones. + ###################### + 输出: + ("entity"{tuple_delimiter}TECHGLOBAL{tuple_delimiter}ORGANIZATION{tuple_delimiter}TechGlobal is a stock now listed on the Global Exchange which powers 85% of premium smartphones) + {record_delimiter} + ("entity"{tuple_delimiter}VISION HOLDINGS{tuple_delimiter}ORGANIZATION{tuple_delimiter}Vision Holdings is a firm that previously owned TechGlobal) + {record_delimiter} + ("relationship"{tuple_delimiter}TECHGLOBAL{tuple_delimiter}VISION HOLDINGS{tuple_delimiter}Vision Holdings formerly owned TechGlobal from 2014 until present{tuple_delimiter}5) + {completion_delimiter} + + ###################### + 示例 3: + Entity_types: ORGANIZATION,LOCATION,PERSON + 文本: + Five Aurelians jailed for 8 years in Firuzabad and widely regarded as hostages are on their way home to Aurelia. + + The swap orchestrated by Quintara was finalized when $8bn of Firuzi funds were transferred to financial institutions in Krohaara, the capital of Quintara. + + The exchange initiated in Firuzabad's capital, Tiruzia, led to the four men and one woman, who are also Firuzi nationals, boarding a chartered flight to Krohaara. + + They were welcomed by senior Aurelian officials and are now on their way to Aurelia's capital, Cashion. + + The Aurelians include 39-year-old businessman Samuel Namara, who has been held in Tiruzia's Alhamia Prison, as well as journalist Durke Bataglani, 59, and environmentalist Meggie Tazbah, 53, who also holds Bratinas nationality. + ###################### + 输出: + ("entity"{tuple_delimiter}FIRUZABAD{tuple_delimiter}LOCATION{tuple_delimiter}Firuzabad held Aurelians as hostages) + {record_delimiter} + ("entity"{tuple_delimiter}AURELIA{tuple_delimiter}LOCATION{tuple_delimiter}Country seeking to release hostages) + {record_delimiter} + ("entity"{tuple_delimiter}QUINTARA{tuple_delimiter}LOCATION{tuple_delimiter}Country that negotiated a swap of money in exchange for hostages) + {record_delimiter} + ("entity"{tuple_delimiter}TIRUZIA{tuple_delimiter}LOCATION{tuple_delimiter}Capital of Firuzabad where the Aurelians were being held) + {record_delimiter} + ("entity"{tuple_delimiter}KROHAARA{tuple_delimiter}LOCATION{tuple_delimiter}Capital city in Quintara) + {record_delimiter} + ("entity"{tuple_delimiter}CASHION{tuple_delimiter}LOCATION{tuple_delimiter}Capital city in Aurelia) + {record_delimiter} + ("entity"{tuple_delimiter}SAMUEL NAMARA{tuple_delimiter}PERSON{tuple_delimiter}Aurelian who spent time in Tiruzia's Alhamia Prison) + {record_delimiter} + ("entity"{tuple_delimiter}ALHAMIA PRISON{tuple_delimiter}LOCATION{tuple_delimiter}Prison in Tiruzia) + {record_delimiter} + ("entity"{tuple_delimiter}DURKE BATAGLANI{tuple_delimiter}PERSON{tuple_delimiter}Aurelian journalist who was held hostage) + {record_delimiter} + ("entity"{tuple_delimiter}MEGGIE TAZBAH{tuple_delimiter}PERSON{tuple_delimiter}Bratinas national and environmentalist who was held hostage) + {record_delimiter} + ("relationship"{tuple_delimiter}FIRUZABAD{tuple_delimiter}AURELIA{tuple_delimiter}Firuzabad negotiated a hostage exchange with Aurelia{tuple_delimiter}2) + {record_delimiter} + ("relationship"{tuple_delimiter}QUINTARA{tuple_delimiter}AURELIA{tuple_delimiter}Quintara brokered the hostage exchange between Firuzabad and Aurelia{tuple_delimiter}2) + {record_delimiter} + ("relationship"{tuple_delimiter}SAMUEL NAMARA{tuple_delimiter}ALHAMIA PRISON{tuple_delimiter}Samuel Namara was a prisoner at Alhamia prison{tuple_delimiter}8) + {record_delimiter} + ("relationship"{tuple_delimiter}SAMUEL NAMARA{tuple_delimiter}MEGGIE TAZBAH{tuple_delimiter}Samuel Namara and Meggie Tazbah were exchanged in the same hostage release{tuple_delimiter}2) + {completion_delimiter} + + ###################### + -真实数据- + ###################### + Entity_types: {entity_types} + 文本: {input_text} + ###################### + 输出: + """.replace("{tuple_delimiter}", GraphConstants.GRAPH_TUPLE_DELIMITER) + .replace("{entity_types}", Arrays.stream(GraphConstants.DEFAULT_ENTITY_TYPES).collect(Collectors.joining(","))) + .replace("{completion_delimiter}", GraphConstants.GRAPH_COMPLETION_DELIMITER) + .replace("{record_delimiter}", GraphConstants.GRAPH_RECORD_DELIMITER); + + /** + * 继续抽取提示词(当第一次抽取遗漏实体时使用) + */ + public static final String CONTINUE_PROMPT = """ + 在上一次抽取中遗漏了许多实体和关系。 + 请记住只提取与之前提取的类型匹配的实体。 + 使用相同的格式在下面添加它们: + """; + + /** + * 循环检查提示词 + */ + public static final String LOOP_PROMPT = """ + 似乎仍然可能遗漏了一些实体和关系。 + 如果还有需要添加的实体或关系,请回答 YES | NO。 + """; + + /** + * 生成提取提示词 + * + * @param inputText 输入文本 + * @return 完整的提示词 + */ + public static String buildExtractionPrompt(String inputText) { + return GRAPH_EXTRACTION_PROMPT_CN.replace("{input_text}", inputText); + } + + /** + * 生成提取提示词(自定义实体类型) + * + * @param inputText 输入文本 + * @param entityTypes 实体类型列表 + * @return 完整的提示词 + */ + public static String buildExtractionPrompt(String inputText, String[] entityTypes) { + String entityTypesStr = Arrays.stream(entityTypes).collect(Collectors.joining(",")); + return GRAPH_EXTRACTION_PROMPT_CN + .replace("{input_text}", inputText) + .replace(Arrays.stream(GraphConstants.DEFAULT_ENTITY_TYPES).collect(Collectors.joining(",")), entityTypesStr); + } +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/IGraphBuildTaskService.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/IGraphBuildTaskService.java new file mode 100644 index 00000000..f86eca7c --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/IGraphBuildTaskService.java @@ -0,0 +1,136 @@ +package org.ruoyi.graph.service; + +import org.ruoyi.graph.domain.GraphBuildTask; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * 图谱构建任务服务接口 + * + * @author ruoyi + * @date 2025-09-30 + */ +public interface IGraphBuildTaskService { + + /** + * 创建构建任务 + * + * @param graphUuid 图谱UUID + * @param knowledgeId 知识库ID + * @param docId 文档ID(可选) + * @param taskType 任务类型 + * @return 任务信息 + */ + GraphBuildTask createTask(String graphUuid, String knowledgeId, String docId, Integer taskType); + + /** + * 启动构建任务(异步) + * + * @param taskUuid 任务UUID + * @return 异步结果 + */ + void startTask(String taskUuid); + + /** + * 根据UUID获取任务 + * + * @param taskUuid 任务UUID + * @return 任务信息 + */ + GraphBuildTask getByUuid(String taskUuid); + + /** + * 根据图谱UUID获取任务列表 + * + * @param graphUuid 图谱UUID + * @return 任务列表 + */ + List listByGraphUuid(String graphUuid); + + /** + * 获取图谱的最新构建任务 + * + * @param graphUuid 图谱UUID + * @return 最新任务 + */ + GraphBuildTask getLatestTask(String graphUuid); + + /** + * 根据知识库ID获取任务列表 + * + * @param knowledgeId 知识库ID + * @return 任务列表 + */ + List listByKnowledgeId(String knowledgeId); + + /** + * 获取待执行和执行中的任务 + * + * @return 任务列表 + */ + List getPendingAndRunningTasks(); + + /** + * 更新任务进度 + * + * @param taskUuid 任务UUID + * @param progress 进度百分比 + * @param processedDocs 已处理文档数 + * @return 是否成功 + */ + boolean updateProgress(String taskUuid, Integer progress, Integer processedDocs); + + /** + * 更新任务状态 + * + * @param taskUuid 任务UUID + * @param status 状态 + * @return 是否成功 + */ + boolean updateStatus(String taskUuid, Integer status); + + /** + * 更新提取统计信息 + * + * @param taskUuid 任务UUID + * @param extractedEntities 提取的实体数 + * @param extractedRelations 提取的关系数 + * @return 是否成功 + */ + boolean updateExtractionStats(String taskUuid, Integer extractedEntities, Integer extractedRelations); + + /** + * 标记任务为成功 + * + * @param taskUuid 任务UUID + * @param resultSummary 结果摘要 + * @return 是否成功 + */ + boolean markSuccess(String taskUuid, String resultSummary); + + /** + * 标记任务为失败 + * + * @param taskUuid 任务UUID + * @param errorMessage 错误信息 + * @return 是否成功 + */ + boolean markFailed(String taskUuid, String errorMessage); + + /** + * 取消任务 + * + * @param taskUuid 任务UUID + * @return 是否成功 + */ + boolean cancelTask(String taskUuid); + + /** + * 重试失败的任务 + * + * @param taskUuid 任务UUID + * @return 新任务UUID + */ + String retryTask(String taskUuid); +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/IGraphExtractionService.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/IGraphExtractionService.java new file mode 100644 index 00000000..948e4897 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/IGraphExtractionService.java @@ -0,0 +1,46 @@ +package org.ruoyi.graph.service; + +import org.ruoyi.graph.dto.GraphExtractionResult; + +/** + * 图谱实体关系抽取服务接口 + * + * @author ruoyi + * @date 2025-09-30 + */ +public interface IGraphExtractionService { + + /** + * 从文本中抽取实体和关系 + * + * @param text 输入文本 + * @return 抽取结果 + */ + GraphExtractionResult extractFromText(String text); + + /** + * 从文本中抽取实体和关系(自定义实体类型) + * + * @param text 输入文本 + * @param entityTypes 实体类型列表 + * @return 抽取结果 + */ + GraphExtractionResult extractFromText(String text, String[] entityTypes); + + /** + * 从文本中抽取实体和关系(使用指定的LLM模型) + * + * @param text 输入文本 + * @param modelName LLM模型名称 + * @return 抽取结果 + */ + GraphExtractionResult extractFromTextWithModel(String text, String modelName); + + /** + * 解析LLM响应为实体和关系 + * + * @param response LLM响应文本 + * @return 抽取结果 + */ + GraphExtractionResult parseGraphResponse(String response); +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/IGraphInstanceService.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/IGraphInstanceService.java new file mode 100644 index 00000000..51f59a64 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/IGraphInstanceService.java @@ -0,0 +1,120 @@ +package org.ruoyi.graph.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.ruoyi.graph.domain.GraphInstance; + +import java.util.List; + +/** + * 图谱实例服务接口 + * + * @author ruoyi + * @date 2025-09-30 + */ +public interface IGraphInstanceService { + + /** + * 创建图谱实例 + * + * @param knowledgeId 知识库ID + * @param graphName 图谱名称 + * @param config 配置信息 + * @return 图谱实例 + */ + GraphInstance createInstance(String knowledgeId, String graphName, String config); + + /** + * 根据主键ID获取图谱实例 + * + * @param id 主键ID + * @return 图谱实例 + */ + GraphInstance getById(Long id); + + /** + * 根据UUID获取图谱实例 + * + * @param graphUuid 图谱UUID + * @return 图谱实例 + */ + GraphInstance getByUuid(String graphUuid); + + /** + * 更新图谱实例 + * + * @param instance 图谱实例 + * @return 是否成功 + */ + boolean updateInstance(GraphInstance instance); + + /** + * 根据知识库ID获取图谱列表 + * + * @param knowledgeId 知识库ID + * @return 图谱实例列表 + */ + List listByKnowledgeId(String knowledgeId); + + /** + * 条件查询图谱实例列表(分页) + * + * @param page 分页对象 + * @param instanceName 图谱名称(模糊查询) + * @param knowledgeId 知识库ID + * @param graphStatus 图谱状态码 + * @return 分页结果 + */ + Page queryPage(Page page, String instanceName, String knowledgeId, Integer graphStatus); + + /** + * 更新图谱状态 + * + * @param graphUuid 图谱UUID + * @param status 状态 + * @return 是否成功 + */ + boolean updateStatus(String graphUuid, Integer status); + + /** + * 更新图谱统计信息 + * + * @param graphUuid 图谱UUID + * @param nodeCount 节点数量 + * @param relationshipCount 关系数量 + * @return 是否成功 + */ + boolean updateCounts(String graphUuid, Integer nodeCount, Integer relationshipCount); + + /** + * 更新图谱配置 + * + * @param graphUuid 图谱UUID + * @param config 配置信息 + * @return 是否成功 + */ + boolean updateConfig(String graphUuid, String config); + + /** + * 删除图谱实例(软删除) + * + * @param graphUuid 图谱UUID + * @return 是否成功 + */ + boolean deleteInstance(String graphUuid); + + /** + * 物理删除图谱实例及其数据 + * + * @param graphUuid 图谱UUID + * @return 是否成功 + */ + boolean deleteInstanceAndData(String graphUuid); + + /** + * 获取图谱统计信息 + * + * @param graphUuid 图谱UUID + * @return 统计信息 + */ + java.util.Map getStatistics(String graphUuid); +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/IGraphRAGService.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/IGraphRAGService.java new file mode 100644 index 00000000..03706412 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/IGraphRAGService.java @@ -0,0 +1,75 @@ +package org.ruoyi.graph.service; + +import org.ruoyi.graph.dto.GraphExtractionResult; + +import java.util.Map; + +/** + * GraphRAG服务接口 + * 负责文档的图谱化处理和基于图谱的检索 + * + * @author ruoyi + * @date 2025-09-30 + */ +public interface IGraphRAGService { + + /** + * 将文本入库到图谱 + * + * @param text 文本内容 + * @param knowledgeId 知识库ID + * @param metadata 元数据 + * @return 抽取结果 + */ + GraphExtractionResult ingestText(String text, String knowledgeId, Map metadata); + + /** + * 将文本入库到图谱(指定模型) + * + * @param text 文本内容 + * @param knowledgeId 知识库ID + * @param metadata 元数据 + * @param modelName LLM模型名称 + * @return 抽取结果 + */ + GraphExtractionResult ingestTextWithModel(String text, String knowledgeId, Map metadata, String modelName); + + /** + * 将文档入库到图谱(自动分片) + * + * @param documentText 文档内容 + * @param knowledgeId 知识库ID + * @param metadata 元数据 + * @return 总抽取结果(合并所有分片) + */ + GraphExtractionResult ingestDocument(String documentText, String knowledgeId, Map metadata); + + /** + * 将文档入库到图谱(自动分片,指定模型) + * + * @param documentText 文档内容 + * @param knowledgeId 知识库ID + * @param metadata 元数据 + * @param modelName LLM模型名称 + * @return 总抽取结果(合并所有分片) + */ + GraphExtractionResult ingestDocumentWithModel(String documentText, String knowledgeId, Map metadata, String modelName); + + /** + * 基于图谱检索相关内容 + * + * @param query 查询文本 + * @param knowledgeId 知识库ID + * @param maxResults 最大结果数 + * @return 检索到的相关实体和关系 + */ + String retrieveFromGraph(String query, String knowledgeId, int maxResults); + + /** + * 删除知识库的图谱数据 + * + * @param knowledgeId 知识库ID + * @return 是否成功 + */ + boolean deleteGraphData(String knowledgeId); +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/IGraphStoreService.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/IGraphStoreService.java new file mode 100644 index 00000000..0a8f95aa --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/IGraphStoreService.java @@ -0,0 +1,267 @@ +package org.ruoyi.graph.service; + +import org.ruoyi.graph.domain.GraphEdge; +import org.ruoyi.graph.domain.GraphVertex; + +import java.util.List; + +/** + * 图存储服务接口 + * 核心服务:负责与Neo4j图数据库交互 + * + * @author ruoyi + * @date 2025-09-30 + */ +public interface IGraphStoreService { + + // ==================== 节点操作 ==================== + + /** + * 添加单个节点 + * + * @param vertex 节点信息 + * @return 是否成功 + */ + boolean addVertex(GraphVertex vertex); + + /** + * 批量添加节点 + * + * @param vertices 节点列表 + * @return 成功添加的节点数 + */ + int addVertices(List vertices); + + /** + * 获取节点信息 + * + * @param nodeId 节点ID + * @param graphUuid 图谱UUID + * @return 节点信息 + */ + GraphVertex getVertex(String nodeId, String graphUuid); + + /** + * 根据条件搜索节点 + * + * @param graphUuid 图谱UUID + * @param label 节点标签(可选) + * @param limit 返回数量限制 + * @return 节点列表 + */ + List searchVertices(String graphUuid, String label, Integer limit); + + /** + * 根据名称搜索节点 + * + * @param graphUuid 图谱UUID + * @param name 节点名称 + * @return 节点列表 + */ + List searchVerticesByName(String graphUuid, String name); + + /** + * 根据关键词和知识库ID搜索节点 + * + * @param keyword 关键词 + * @param knowledgeId 知识库ID(可选) + * @param limit 限制数量 + * @return 节点列表 + */ + List searchVerticesByName(String keyword, String knowledgeId, Integer limit); + + /** + * 根据知识库ID查询节点 + * + * @param knowledgeId 知识库ID + * @param limit 限制数量 + * @return 节点列表 + */ + List queryVerticesByKnowledgeId(String knowledgeId, Integer limit); + + /** + * 更新节点信息 + * + * @param vertex 节点信息 + * @return 是否成功 + */ + boolean updateVertex(GraphVertex vertex); + + /** + * 删除节点 + * + * @param nodeId 节点ID + * @param graphUuid 图谱UUID + * @param includeEdges 是否同时删除相关关系 + * @return 是否成功 + */ + boolean deleteVertex(String nodeId, String graphUuid, boolean includeEdges); + + // ==================== 关系操作 ==================== + + /** + * 添加关系 + * + * @param edge 关系信息 + * @return 是否成功 + */ + boolean addEdge(GraphEdge edge); + + /** + * 批量添加关系 + * + * @param edges 关系列表 + * @return 成功添加的关系数 + */ + int addEdges(List edges); + + /** + * 获取关系信息 + * + * @param edgeId 关系ID + * @param graphUuid 图谱UUID + * @return 关系信息 + */ + GraphEdge getEdge(String edgeId, String graphUuid); + + /** + * 搜索关系 + * + * @param graphUuid 图谱UUID + * @param sourceNodeId 源节点ID(可选) + * @param targetNodeId 目标节点ID(可选) + * @param limit 返回数量限制 + * @return 关系列表 + */ + List searchEdges(String graphUuid, String sourceNodeId, String targetNodeId, Integer limit); + + /** + * 根据知识库ID查询关系 + * + * @param knowledgeId 知识库ID + * @param limit 限制数量 + * @return 关系列表 + */ + List queryEdgesByKnowledgeId(String knowledgeId, Integer limit); + + /** + * 获取节点的所有关系 + * + * @param nodeId 节点ID + * @param graphUuid 图谱UUID + * @param direction 方向: IN(入边), OUT(出边), BOTH(双向) + * @return 关系列表 + */ + List getNodeEdges(String nodeId, String graphUuid, String direction); + + /** + * 更新关系信息 + * + * @param edge 关系信息 + * @return 是否成功 + */ + boolean updateEdge(GraphEdge edge); + + /** + * 删除关系 + * + * @param edgeId 关系ID + * @param graphUuid 图谱UUID + * @return 是否成功 + */ + boolean deleteEdge(String edgeId, String graphUuid); + + // ==================== 图谱管理 ==================== + + /** + * 创建图谱Schema + * + * @param graphUuid 图谱UUID + * @return 是否成功 + */ + boolean createGraphSchema(String graphUuid); + + /** + * 删除整个图谱数据 + * + * @param graphUuid 图谱UUID + * @return 是否成功 + */ + boolean deleteGraph(String graphUuid); + + /** + * 根据知识库ID删除图谱数据 + * + * @param knowledgeId 知识库ID + * @return 是否成功 + */ + boolean deleteByKnowledgeId(String knowledgeId); + + /** + * 获取图谱统计信息 + * + * @param graphUuid 图谱UUID + * @return 统计信息 {nodeCount, relationshipCount} + */ + java.util.Map getGraphStatistics(String graphUuid); + + /** + * 根据知识库ID获取统计信息 + * + * @param knowledgeId 知识库ID + * @return 统计信息 + */ + java.util.Map getStatistics(String knowledgeId); + + // ==================== 高级查询 ==================== + + /** + * 查找两个节点之间的路径 + * + * @param sourceNodeId 源节点ID + * @param targetNodeId 目标节点ID + * @param graphUuid 图谱UUID + * @param maxDepth 最大深度 + * @return 路径列表 + */ + List> findPaths(String sourceNodeId, String targetNodeId, String graphUuid, Integer maxDepth); + + /** + * 查找路径(简化版) + * + * @param startNodeId 起始节点ID + * @param endNodeId 结束节点ID + * @param maxDepth 最大深度 + * @return 路径列表 + */ + List> findPaths(String startNodeId, String endNodeId, Integer maxDepth); + + /** + * 查找节点的邻居节点 + * + * @param nodeId 节点ID + * @param graphUuid 图谱UUID + * @param depth 深度(几度关系) + * @return 邻居节点列表 + */ + List findNeighbors(String nodeId, String graphUuid, Integer depth); + + /** + * 获取节点的邻居(简化版) + * + * @param nodeId 节点ID + * @param knowledgeId 知识库ID(可选) + * @param limit 限制数量 + * @return 邻居节点列表 + */ + List getNeighbors(String nodeId, String knowledgeId, Integer limit); + + /** + * 执行自定义Cypher查询 + * + * @param cypher Cypher查询语句 + * @param params 参数 + * @return 查询结果 + */ + List> executeCypher(String cypher, java.util.Map params); +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/impl/GraphBuildTaskServiceImpl.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/impl/GraphBuildTaskServiceImpl.java new file mode 100644 index 00000000..7dfcff09 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/impl/GraphBuildTaskServiceImpl.java @@ -0,0 +1,734 @@ +package org.ruoyi.graph.service.impl; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson2.JSON; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.domain.bo.KnowledgeAttachBo; +import org.ruoyi.domain.vo.KnowledgeAttachVo; +import org.ruoyi.graph.domain.GraphBuildTask; +import org.ruoyi.graph.domain.GraphInstance; +import org.ruoyi.graph.dto.GraphExtractionResult; +import org.ruoyi.graph.mapper.GraphBuildTaskMapper; +import org.ruoyi.graph.service.IGraphBuildTaskService; +import org.ruoyi.graph.service.IGraphInstanceService; +import org.ruoyi.graph.service.IGraphRAGService; +import org.ruoyi.service.IKnowledgeAttachService; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * 图谱构建任务服务实现 + * + * @author ruoyi + * @date 2025-09-30 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GraphBuildTaskServiceImpl implements IGraphBuildTaskService { + + private final GraphBuildTaskMapper taskMapper; + private final IGraphRAGService graphRAGService; + private final IKnowledgeAttachService knowledgeAttachService; + private final IGraphInstanceService graphInstanceService; + + @Override + @Transactional(rollbackFor = Exception.class) + public GraphBuildTask createTask(String graphUuid, String knowledgeId, String docId, Integer taskType) { + GraphBuildTask task = new GraphBuildTask(); + task.setTaskUuid(IdUtil.fastSimpleUUID()); + task.setGraphUuid(graphUuid); + task.setKnowledgeId(knowledgeId); + task.setDocId(docId); + + // 设置任务类型和状态(使用整数) + task.setTaskType(taskType != null ? taskType : 1); + task.setTaskStatus(1); // 1-待处理 + task.setProgress(0); + + taskMapper.insert(task); + + log.info("创建图谱构建任务: taskId={}, taskUuid={}, graphUuid={}, knowledgeId={}, type={}", + task.getId(), task.getTaskUuid(), graphUuid, knowledgeId, task.getTaskType()); + + return task; + } + + @Override + @Async("graphBuildExecutor") // ⭐ 启用异步执行,使用专用线程池 + public void startTask(String taskUuid) { + // 记录线程信息 + String threadName = Thread.currentThread().getName(); + log.info("🚀 图谱构建任务启动 - taskUuid: {}, 线程: {}", taskUuid, threadName); + + long startTime = System.currentTimeMillis(); + + try { + // 1. 验证任务存在性 + GraphBuildTask task = getByUuid(taskUuid); + if (task == null) { + log.error("❌ 任务不存在: taskUuid={}", taskUuid); + return; + } + + // 2. 检查任务状态(防止重复执行) + if (task.getTaskStatus() != 1) { // 1-待处理 + log.warn("⚠️ 任务状态不允许执行: taskUuid={}, currentStatus={}", + taskUuid, task.getTaskStatus()); + return; + } + + // 3. 更新任务状态为运行中 + boolean statusUpdated = updateStatus(taskUuid, 2); // 2-运行中 + if (!statusUpdated) { + log.error("❌ 更新任务状态失败: taskUuid={}", taskUuid); + return; + } + + log.info("✅ 任务状态已更新为运行中: taskUuid={}", taskUuid); + + // 4. 执行图谱构建逻辑 + try { + executeTaskLogic(task); + + long duration = (System.currentTimeMillis() - startTime) / 1000; + log.info("🎉 图谱构建任务完成: taskUuid={}, 耗时: {}秒, 线程: {}", + taskUuid, duration, threadName); + + } catch (OutOfMemoryError oom) { + // 特殊处理OOM错误 + log.error("💥 图谱构建任务内存溢出: taskUuid={}, 线程: {}", taskUuid, threadName, oom); + markFailed(taskUuid, "内存溢出,请减少批处理文档数量或增加JVM内存"); + + // 建议垃圾回收 + System.gc(); + + } catch (InterruptedException ie) { + // 特殊处理中断异常 + Thread.currentThread().interrupt(); + log.error("⚠️ 图谱构建任务被中断: taskUuid={}, 线程: {}", taskUuid, threadName, ie); + markFailed(taskUuid, "任务被中断: " + ie.getMessage()); + + } catch (Exception e) { + // 处理其他业务异常 + log.error("❌ 图谱构建任务执行失败: taskUuid={}, 线程: {}", taskUuid, threadName, e); + + // 提取简洁的错误信息 + String errorMsg = extractErrorMessage(e); + markFailed(taskUuid, errorMsg); + } + + } catch (Exception e) { + // 处理外层异常(如数据库访问异常) + log.error("❌ 图谱构建任务启动失败: taskUuid={}, 线程: {}", taskUuid, threadName, e); + + try { + String errorMsg = extractErrorMessage(e); + markFailed(taskUuid, errorMsg); + } catch (Exception markFailEx) { + log.error("❌ 标记任务失败时出错: taskUuid={}", taskUuid, markFailEx); + } + } + } + + /** + * 提取简洁的错误信息(用于前端显示) + * + * @param e 异常对象 + * @return 简洁的错误信息 + */ + private String extractErrorMessage(Exception e) { + // 1. 优先使用自定义异常消息 + String message = e.getMessage(); + if (StrUtil.isNotBlank(message) && message.length() < 200) { + return message; + } + + // 2. 检查原因链 + Throwable cause = e.getCause(); + if (cause != null && StrUtil.isNotBlank(cause.getMessage())) { + String causeMsg = cause.getMessage(); + if (causeMsg.length() < 200) { + return causeMsg; + } + } + + // 3. 使用异常类名 + return e.getClass().getSimpleName() + ": " + + (message != null ? message.substring(0, Math.min(150, message.length())) : "未知错误"); + } + + @Override + public GraphBuildTask getByUuid(String taskUuid) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(GraphBuildTask::getTaskUuid, taskUuid); + return taskMapper.selectOne(wrapper); + } + + @Override + public List listByGraphUuid(String graphUuid) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(GraphBuildTask::getGraphUuid, graphUuid); + wrapper.orderByDesc(GraphBuildTask::getCreateTime); + return taskMapper.selectList(wrapper); + } + + @Override + public GraphBuildTask getLatestTask(String graphUuid) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(GraphBuildTask::getGraphUuid, graphUuid); + wrapper.orderByDesc(GraphBuildTask::getCreateTime); + wrapper.last("LIMIT 1"); + return taskMapper.selectOne(wrapper); + } + + @Override + public List listByKnowledgeId(String knowledgeId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(GraphBuildTask::getKnowledgeId, knowledgeId); + wrapper.orderByDesc(GraphBuildTask::getCreateTime); + return taskMapper.selectList(wrapper); + } + + @Override + public List getPendingAndRunningTasks() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(GraphBuildTask::getTaskStatus, 1, 2); // 1-待处理, 2-运行中 + wrapper.orderByAsc(GraphBuildTask::getCreateTime); + return taskMapper.selectList(wrapper); + } + + @Override + public boolean updateProgress(String taskUuid, Integer progress, Integer processedDocs) { + try { + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.eq(GraphBuildTask::getTaskUuid, taskUuid); + wrapper.set(GraphBuildTask::getProgress, progress); + + if (processedDocs != null) { + wrapper.set(GraphBuildTask::getProcessedDocs, processedDocs); + } + + int rows = taskMapper.update(null, wrapper); + log.info("📊 更新任务进度: taskUuid={}, progress={}%, processedDocs={}, rows={}", + taskUuid, progress, processedDocs, rows); + return rows > 0; + } catch (Exception e) { + log.error("更新任务进度失败: taskUuid={}, progress={}", taskUuid, progress, e); + return false; + } + } + + @Override + public boolean updateStatus(String taskUuid, Integer status) { + try { + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.eq(GraphBuildTask::getTaskUuid, taskUuid); + wrapper.set(GraphBuildTask::getTaskStatus, status); + + // 如果是开始运行,设置开始时间 + if (status == 2) { + wrapper.set(GraphBuildTask::getStartTime, new Date()); + } + + // 如果是完成或失败,设置结束时间 + if (status == 3 || status == 4) { + wrapper.set(GraphBuildTask::getEndTime, new Date()); + } + + int rows = taskMapper.update(null, wrapper); + + log.info("更新任务状态: taskUuid={}, status={}, rows={}", taskUuid, status, rows); + return rows > 0; + } catch (Exception e) { + log.error("更新任务状态失败: taskUuid={}, status={}", taskUuid, status, e); + return false; + } + } + + @Override + public boolean updateExtractionStats(String taskUuid, Integer extractedEntities, Integer extractedRelations) { + try { + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.eq(GraphBuildTask::getTaskUuid, taskUuid); + + if (extractedEntities != null) { + wrapper.set(GraphBuildTask::getExtractedEntities, extractedEntities); + } + if (extractedRelations != null) { + wrapper.set(GraphBuildTask::getExtractedRelations, extractedRelations); + } + + int rows = taskMapper.update(null, wrapper); + return rows > 0; + } catch (Exception e) { + log.error("更新提取统计失败: taskUuid={}", taskUuid, e); + return false; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean markSuccess(String taskUuid, String resultSummary) { + try { + // 1. 更新任务状态 + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.eq(GraphBuildTask::getTaskUuid, taskUuid); + wrapper.set(GraphBuildTask::getTaskStatus, 3); // 3-已完成 + wrapper.set(GraphBuildTask::getProgress, 100); + wrapper.set(GraphBuildTask::getEndTime, new Date()); + wrapper.set(GraphBuildTask::getResultSummary, resultSummary); + + int rows = taskMapper.update(null, wrapper); + + // 2. 更新图谱实例状态为"已完成" + GraphBuildTask task = getByUuid(taskUuid); + if (task != null && task.getGraphUuid() != null) { + graphInstanceService.updateStatus(task.getGraphUuid(), 20); // 20-已完成 + log.info("更新图谱实例状态为已完成: graphUuid={}", task.getGraphUuid()); + } + + log.info("标记任务成功: taskUuid={}, rows={}", taskUuid, rows); + return rows > 0; + } catch (Exception e) { + log.error("标记任务成功失败: taskUuid={}", taskUuid, e); + return false; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean markFailed(String taskUuid, String errorMessage) { + try { + // 1. 更新任务状态 + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.eq(GraphBuildTask::getTaskUuid, taskUuid); + wrapper.set(GraphBuildTask::getTaskStatus, 4); // 4-失败 + wrapper.set(GraphBuildTask::getErrorMessage, errorMessage); + wrapper.set(GraphBuildTask::getEndTime, new Date()); + + int rows = taskMapper.update(null, wrapper); + + // 2. 更新图谱实例状态为"失败" + GraphBuildTask task = getByUuid(taskUuid); + if (task != null && task.getGraphUuid() != null) { + graphInstanceService.updateStatus(task.getGraphUuid(), 30); // 30-失败 + log.info("更新图谱实例状态为失败: graphUuid={}", task.getGraphUuid()); + } + + log.error("标记任务失败: taskUuid={}, error={}, rows={}", taskUuid, errorMessage, rows); + return rows > 0; + } catch (Exception e) { + log.error("标记任务失败失败: taskUuid={}", taskUuid, e); + return false; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean cancelTask(String taskUuid) { + try { + GraphBuildTask task = getByUuid(taskUuid); + if (task == null) { + log.error("任务不存在: taskUuid={}", taskUuid); + return false; + } + + // 只能取消待处理或运行中的任务 + if (task.getTaskStatus() != 1 && task.getTaskStatus() != 2) { + log.warn("任务状态不允许取消: taskUuid={}, status={}", taskUuid, task.getTaskStatus()); + return false; + } + + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.eq(GraphBuildTask::getTaskUuid, taskUuid); + wrapper.set(GraphBuildTask::getTaskStatus, 4); // 4-失败 + wrapper.set(GraphBuildTask::getErrorMessage, "任务已取消"); + wrapper.set(GraphBuildTask::getEndTime, new Date()); + + int rows = taskMapper.update(null, wrapper); + + log.info("取消任务: taskUuid={}, rows={}", taskUuid, rows); + return rows > 0; + } catch (Exception e) { + log.error("取消任务失败: taskUuid={}", taskUuid, e); + return false; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String retryTask(String taskUuid) { + try { + GraphBuildTask oldTask = getByUuid(taskUuid); + if (oldTask == null) { + log.error("任务不存在: taskUuid={}", taskUuid); + return null; + } + + // 创建新任务 + GraphBuildTask newTask = createTask( + oldTask.getGraphUuid(), + oldTask.getKnowledgeId(), + oldTask.getDocId(), + oldTask.getTaskType() + ); + + log.info("重试任务: oldTaskUuid={}, newTaskUuid={}", taskUuid, newTask.getTaskUuid()); + return newTask.getTaskUuid(); + } catch (Exception e) { + log.error("重试任务失败: taskUuid={}", taskUuid, e); + return null; + } + } + + /** + * 执行图谱构建任务的核心逻辑 + * + * @param task 构建任务 + * @throws Exception 执行过程中的异常 + */ + private void executeTaskLogic(GraphBuildTask task) throws Exception { + String taskUuid = task.getTaskUuid(); + String graphUuid = task.getGraphUuid(); + String knowledgeId = task.getKnowledgeId(); + String docId = task.getDocId(); + Integer taskType = task.getTaskType(); + + long startTime = System.currentTimeMillis(); + int totalDocs = 0; + int processedDocs = 0; + int successDocs = 0; // ⭐ 新增:成功处理的文档数 + int failedDocs = 0; // ⭐ 新增:失败的文档数 + int totalEntities = 0; + int totalRelations = 0; + + // ⭐ 记录初始内存状态 + Runtime runtime = Runtime.getRuntime(); + long initialMemory = runtime.totalMemory() - runtime.freeMemory(); + log.info("📊 初始内存使用: {} MB / {} MB", + initialMemory / 1024 / 1024, + runtime.maxMemory() / 1024 / 1024); + + try { + // 0. 获取图谱实例配置(包括LLM模型) + String modelName = null; + if (StrUtil.isNotBlank(graphUuid)) { + GraphInstance graphInstance = graphInstanceService.getByUuid(graphUuid); + if (graphInstance != null && StrUtil.isNotBlank(graphInstance.getModelName())) { + modelName = graphInstance.getModelName(); + log.info("使用图谱实例配置的模型: {}", modelName); + } + } + + // 1. 获取需要处理的文档列表 + List documents; + + if (taskType == 1) { + // 类型1: 全量构建(知识库所有文档) + if (StrUtil.isBlank(knowledgeId)) { + throw new RuntimeException("知识库构建任务缺少知识库ID"); + } + + // 查询知识库下的所有文档 + KnowledgeAttachBo bo = new KnowledgeAttachBo(); + bo.setKid(knowledgeId); + log.info("🔍 准备查询文档: knowledgeId={}, bo.getKid()={}", knowledgeId, bo.getKid()); + documents = knowledgeAttachService.queryList(bo); + log.info("📋 查询返回文档数: {}", documents != null ? documents.size() : "null"); + + } else if (taskType == 2) { + // 类型2: 重建(清空后全量重建) + if (StrUtil.isBlank(knowledgeId)) { + throw new RuntimeException("知识库构建任务缺少知识库ID"); + } + + // ⭐ 先清空该知识库的旧图谱数据 + log.info("🗑️ 重建模式:先清空知识库的旧图谱数据,knowledgeId: {}", knowledgeId); + boolean deleted = graphRAGService.deleteGraphData(knowledgeId); + if (deleted) { + log.info("✅ 旧图谱数据清空成功"); + } else { + log.warn("⚠️ 旧图谱数据清空失败(可能是没有旧数据)"); + } + + // 查询知识库下的所有文档 + KnowledgeAttachBo bo = new KnowledgeAttachBo(); + bo.setKid(knowledgeId); + log.info("🔍 准备查询文档: knowledgeId={}, bo.getKid()={}", knowledgeId, bo.getKid()); + documents = knowledgeAttachService.queryList(bo); + log.info("📋 查询返回文档数: {}", documents != null ? documents.size() : "null"); + + } else if (taskType == 3) { + // 类型3: 单文档增量构建 + if (StrUtil.isBlank(docId)) { + throw new RuntimeException("单文档构建任务缺少文档ID"); + } + + // 根据docId查询单个文档 + KnowledgeAttachBo bo = new KnowledgeAttachBo(); + bo.setDocId(docId); + log.info("🔍 准备查询单个文档: docId={}, bo.getDocId()={}", docId, bo.getDocId()); + documents = knowledgeAttachService.queryList(bo); + log.info("📋 查询返回文档数: {}", documents != null ? documents.size() : "null"); + + } else { + throw new RuntimeException("未知的任务类型: " + taskType); + } + + if (documents == null || documents.isEmpty()) { + String errorMsg = String.format( + "❌ 没有找到需要处理的文档!\n" + + " taskUuid: %s\n" + + " knowledgeId: %s\n" + + " docId: %s\n" + + " taskType: %d\n" + + " documents: %s\n" + + "请检查:\n" + + " 1. knowledge_attach 表中是否有 kid='%s' 的记录\n" + + " 2. knowledgeId 是否正确传递\n" + + " 3. KnowledgeAttachService.queryList() 是否正确执行", + taskUuid, knowledgeId, docId, taskType, + documents == null ? "null" : "empty list", + knowledgeId + ); + log.warn(errorMsg); + + Map summary = new HashMap<>(); + summary.put("message", "没有找到需要处理的文档"); + summary.put("totalDocs", 0); + summary.put("knowledgeId", knowledgeId); + summary.put("taskType", taskType); + markSuccess(taskUuid, JSON.toJSONString(summary)); // ⭐ 使用 JSON 序列化 + return; + } + + totalDocs = documents.size(); + log.info("开始构建图谱,共 {} 个文档", totalDocs); + + // ⭐ 更新任务的 total_docs 字段 + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(GraphBuildTask::getTaskUuid, taskUuid); + updateWrapper.set(GraphBuildTask::getTotalDocs, totalDocs); + taskMapper.update(null, updateWrapper); + log.info("📊 更新任务total_docs: {}", totalDocs); + + // 限制处理文档数量,避免内存溢出 + int maxDocsPerBatch = 50; // 每批最多处理50个文档 + if (totalDocs > maxDocsPerBatch) { + log.warn("文档数量较多({}个),建议分批处理,当前批次限制为{}个", totalDocs, maxDocsPerBatch); + documents = documents.subList(0, Math.min(maxDocsPerBatch, totalDocs)); + totalDocs = documents.size(); + + // ⭐ 重新更新 total_docs(因为被限制了) + LambdaUpdateWrapper updateWrapper2 = new LambdaUpdateWrapper<>(); + updateWrapper2.eq(GraphBuildTask::getTaskUuid, taskUuid); + updateWrapper2.set(GraphBuildTask::getTotalDocs, totalDocs); + taskMapper.update(null, updateWrapper2); + log.info("📊 更新限制后的total_docs: {}", totalDocs); + } + + // 2. 逐个处理文档(带内存管理和错误恢复) + for (int i = 0; i < documents.size(); i++) { + KnowledgeAttachVo doc = documents.get(i); + long docStartTime = System.currentTimeMillis(); + + try { + // ⭐ 检查内存状态 + long usedMemory = runtime.totalMemory() - runtime.freeMemory(); + long maxMemory = runtime.maxMemory(); + double memoryUsage = (double) usedMemory / maxMemory * 100; + + if (memoryUsage > 80) { + log.warn("⚠️ 内存使用率过高: {}/{}MB ({}%), 建议垃圾回收", + usedMemory / 1024 / 1024, + maxMemory / 1024 / 1024, + String.format("%.2f", memoryUsage)); + System.gc(); + try { + Thread.sleep(1000); // 等待GC完成 + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + log.warn("⚠️ 等待GC时被中断"); + } + } + + log.info("📄 处理文档 [{}/{}]: docId={}, docName={}", + i + 1, totalDocs, doc.getDocId(), doc.getDocName()); + + // 2.1 获取文档内容 + String content = doc.getContent(); + if (StrUtil.isBlank(content)) { + log.warn("⚠️ 文档内容为空,跳过: docId={}", doc.getDocId()); + processedDocs++; + failedDocs++; + continue; + } + + // 限制单个文档内容大小,避免内存溢出 + if (content.length() > 50000) { + log.warn("⚠️ 文档内容过大({} 字符),截断处理: docId={}", + content.length(), doc.getDocId()); + content = content.substring(0, 50000); + } + + // 2.2 准备元数据(不包含大字段) + Map metadata = new HashMap<>(); + metadata.put("docId", doc.getDocId()); + metadata.put("docName", doc.getDocName()); + metadata.put("docType", doc.getDocType()); + metadata.put("kid", doc.getKid()); + + // 2.3 调用GraphRAG服务进行图谱入库(使用图谱实例配置的模型) + GraphExtractionResult result = null; + try { + if (content.length() > 2000) { + // 长文档,使用分片处理 + result = graphRAGService.ingestDocumentWithModel( + content, knowledgeId, metadata, modelName); + } else { + // 短文档,直接处理 + result = graphRAGService.ingestTextWithModel( + content, knowledgeId, metadata, modelName); + } + } catch (OutOfMemoryError oom) { + // OOM单独处理:强制GC后继续 + log.error("💥 处理文档时OOM,强制垃圾回收: docId={}", doc.getDocId()); + System.gc(); + try { + Thread.sleep(2000); // 等待GC完成 + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + log.warn("⚠️ 等待GC时被中断"); + // 中断时不继续处理,跳出循环 + throw ie; + } + processedDocs++; + failedDocs++; + continue; + } catch (Exception e) { + log.error("❌ LLM调用失败,跳过文档: docId={}, error={}", + doc.getDocId(), e.getMessage()); + processedDocs++; + failedDocs++; + continue; + } + + // 2.4 统计结果 + if (result != null && result.getSuccess()) { + int entities = result.getEntities().size(); + int relations = result.getRelations().size(); + totalEntities += entities; + totalRelations += relations; + successDocs++; + + long docDuration = System.currentTimeMillis() - docStartTime; + log.info("✅ 文档处理成功: docId={}, 实体数={}, 关系数={}, 耗时={}ms", + doc.getDocId(), entities, relations, docDuration); + } else { + failedDocs++; + log.warn("⚠️ 文档处理失败: docId={}, error={}", + doc.getDocId(), result != null ? result.getErrorMessage() : "unknown"); + } + + // 2.5 更新进度 + processedDocs++; + int progress = (processedDocs * 100) / totalDocs; + log.info("📈 文档进度: {}/{}, 进度={}%", processedDocs, totalDocs, progress); + boolean updated = updateProgress(taskUuid, progress, processedDocs); + if (!updated) { + log.warn("⚠️ 进度更新失败: taskUuid={}, progress={}", taskUuid, progress); + } + + // 2.6 定期进行垃圾回收和内存检查 + if ((i + 1) % 10 == 0) { + long currentMemory = runtime.totalMemory() - runtime.freeMemory(); + log.info("📊 已处理{}/{}个文档, 内存使用: {} MB", + i + 1, totalDocs, currentMemory / 1024 / 1024); + System.gc(); + try { + Thread.sleep(500); // 短暂等待GC + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + log.warn("⚠️ 等待GC时被中断"); + } + } + + } catch (InterruptedException ie) { + // 中断异常:重新抛出,终止任务 + Thread.currentThread().interrupt(); + log.error("⚠️ 任务被中断,停止处理文档: docId={}", doc.getDocId()); + throw ie; + } catch (Exception e) { + log.error("❌ 处理文档时发生异常: docId={}, error={}", + doc.getDocId(), e.getMessage(), e); + processedDocs++; + failedDocs++; + // 继续处理下一个文档(不中断整个任务) + } finally { + // 释放文档引用,帮助GC + documents.set(i, null); + } + } + + // 3. 构建完成,生成详细摘要 + long duration = (System.currentTimeMillis() - startTime) / 1000; + long finalMemory = runtime.totalMemory() - runtime.freeMemory(); + + Map summary = new HashMap<>(); + summary.put("totalDocs", totalDocs); + summary.put("processedDocs", processedDocs); + summary.put("successDocs", successDocs); // ⭐ 成功文档数 + summary.put("failedDocs", failedDocs); // ⭐ 失败文档数 + summary.put("totalEntities", totalEntities); + summary.put("totalRelations", totalRelations); + summary.put("duration", duration + "秒"); + summary.put("avgTimePerDoc", totalDocs > 0 ? (duration * 1000 / totalDocs) + "ms" : "N/A"); // ⭐ 平均处理时间 + summary.put("memoryUsed", (finalMemory - initialMemory) / 1024 / 1024 + "MB"); // ⭐ 内存增量 + summary.put("status", "completed"); + summary.put("modelName", modelName != null ? modelName : "default"); // ⭐ 使用的模型 + + // 更新统计信息到任务 + updateExtractionStats(taskUuid, totalEntities, totalRelations); + + markSuccess(taskUuid, JSON.toJSONString(summary)); + + log.info("🎉 图谱构建任务完成汇总:"); + log.info(" - taskUuid: {}", taskUuid); + log.info(" - 文档总数: {}", totalDocs); + log.info(" - 成功处理: {} 个", successDocs); + log.info(" - 失败文档: {} 个", failedDocs); + log.info(" - 实体总数: {}", totalEntities); + log.info(" - 关系总数: {}", totalRelations); + log.info(" - 总耗时: {} 秒", duration); + log.info(" - 平均耗时: {} ms/文档", totalDocs > 0 ? duration * 1000 / totalDocs : 0); + log.info(" - 内存增量: {} MB", (finalMemory - initialMemory) / 1024 / 1024); + + } catch (InterruptedException ie) { + // 中断异常:向上抛出 + Thread.currentThread().interrupt(); + log.error("⚠️ 图谱构建任务被中断: taskUuid={}", taskUuid, ie); + throw ie; + } catch (Exception e) { + log.error("❌ 图谱构建任务执行失败: taskUuid={}", taskUuid, e); + throw e; + } finally { + // 清理资源,帮助GC + System.gc(); + log.info("📊 最终内存状态: {} MB / {} MB", + (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024, + runtime.maxMemory() / 1024 / 1024); + } + } +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/impl/GraphExtractionServiceImpl.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/impl/GraphExtractionServiceImpl.java new file mode 100644 index 00000000..3ef53ba1 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/impl/GraphExtractionServiceImpl.java @@ -0,0 +1,370 @@ +package org.ruoyi.graph.service.impl; + +import cn.hutool.core.util.StrUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.domain.vo.ChatModelVo; +import org.ruoyi.graph.constants.GraphConstants; +import org.ruoyi.graph.dto.ExtractedEntity; +import org.ruoyi.graph.dto.ExtractedRelation; +import org.ruoyi.graph.dto.GraphExtractionResult; +import org.ruoyi.graph.factory.GraphLLMServiceFactory; +import org.ruoyi.graph.prompt.GraphExtractPrompt; +import org.ruoyi.graph.service.IGraphExtractionService; +import org.ruoyi.graph.service.llm.IGraphLLMService; +import org.ruoyi.service.IChatModelService; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + + +/** + * 图谱实体关系抽取服务实现 + * 使用工厂模式支持多种LLM模型(参考 ruoyi-chat 设计) + * + * @author ruoyi + * @date 2025-09-30 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GraphExtractionServiceImpl implements IGraphExtractionService { + + private final IChatModelService chatModelService; + private final GraphLLMServiceFactory llmServiceFactory; + + /** + * 实体匹配正则表达式 + * 格式: ("entity"<|>ENTITY_NAME<|>ENTITY_TYPE<|>ENTITY_DESCRIPTION) + */ + private static final Pattern ENTITY_PATTERN = Pattern.compile( + "\\(\"entity\"" + + Pattern.quote(GraphConstants.GRAPH_TUPLE_DELIMITER) + + "([^" + Pattern.quote(GraphConstants.GRAPH_TUPLE_DELIMITER) + "]+)" + + Pattern.quote(GraphConstants.GRAPH_TUPLE_DELIMITER) + + "([^" + Pattern.quote(GraphConstants.GRAPH_TUPLE_DELIMITER) + "]+)" + + Pattern.quote(GraphConstants.GRAPH_TUPLE_DELIMITER) + + "([^)]+)\\)" + ); + + /** + * 关系匹配正则表达式 + * 格式: ("relationship"<|>SOURCE<|>TARGET<|>DESCRIPTION<|>STRENGTH) + */ + private static final Pattern RELATION_PATTERN = Pattern.compile( + "\\(\"relationship\"" + + Pattern.quote(GraphConstants.GRAPH_TUPLE_DELIMITER) + + "([^" + Pattern.quote(GraphConstants.GRAPH_TUPLE_DELIMITER) + "]+)" + + Pattern.quote(GraphConstants.GRAPH_TUPLE_DELIMITER) + + "([^" + Pattern.quote(GraphConstants.GRAPH_TUPLE_DELIMITER) + "]+)" + + Pattern.quote(GraphConstants.GRAPH_TUPLE_DELIMITER) + + "([^" + Pattern.quote(GraphConstants.GRAPH_TUPLE_DELIMITER) + "]+)" + + Pattern.quote(GraphConstants.GRAPH_TUPLE_DELIMITER) + + "([^)]+)\\)" + ); + + @Override + public GraphExtractionResult extractFromText(String text) { + return extractFromText(text, GraphConstants.DEFAULT_ENTITY_TYPES); + } + + @Override + public GraphExtractionResult extractFromText(String text, String[] entityTypes) { + log.info("开始从文本中抽取实体和关系,文本长度: {}", text.length()); + + try { + // 1. 构建提示词 + String prompt = GraphExtractPrompt.buildExtractionPrompt(text, entityTypes); + + // 2. 调用LLM(使用默认模型) + String llmResponse = callLLM(prompt); + + // 3. 解析响应 + GraphExtractionResult result = parseGraphResponse(llmResponse); + result.setRawResponse(llmResponse); + result.setSuccess(true); + + log.info("抽取完成,实体数: {}, 关系数: {}", + result.getEntities().size(), result.getRelations().size()); + + return result; + + } catch (Exception e) { + log.error("实体关系抽取失败", e); + return GraphExtractionResult.builder() + .entities(new ArrayList<>()) + .relations(new ArrayList<>()) + .success(false) + .errorMessage(e.getMessage()) + .build(); + } + } + + @Override + public GraphExtractionResult extractFromTextWithModel(String text, String modelName) { + log.info("开始从文本中抽取实体和关系,使用模型: {}, 文本长度: {}", modelName, text.length()); + + try { + // 1. 获取模型配置 + ChatModelVo chatModel = chatModelService.selectModelByName(modelName); + if (chatModel == null) { + log.warn("未找到模型: {}, 使用默认模型", modelName); + return extractFromText(text); + } + + // 2. 构建提示词 + String prompt = GraphExtractPrompt.buildExtractionPrompt(text, GraphConstants.DEFAULT_ENTITY_TYPES); + + // 3. 调用LLM(使用指定模型) + String llmResponse = callLLMWithModel(prompt, chatModel); + + // 4. 解析响应 + GraphExtractionResult result = parseGraphResponse(llmResponse); + result.setRawResponse(llmResponse); + result.setSuccess(true); + + log.info("抽取完成,实体数: {}, 关系数: {}, 使用模型: {}", + result.getEntities().size(), result.getRelations().size(), modelName); + + // ⭐ 调试:如果没有关系,记录原始响应(便于诊断) + if (result.getRelations().isEmpty() && !result.getEntities().isEmpty()) { + log.warn("⚠️ LLM 提取到 {} 个实体,但没有提取到任何关系!", result.getEntities().size()); + log.warn("LLM 原始响应预览(前500字符): {}", + llmResponse.length() > 500 ? llmResponse.substring(0, 500) + "..." : llmResponse); + } + + return result; + + } catch (Exception e) { + log.error("实体关系抽取失败,模型: {}", modelName, e); + return GraphExtractionResult.builder() + .entities(new ArrayList<>()) + .relations(new ArrayList<>()) + .success(false) + .errorMessage(e.getMessage()) + .build(); + } + } + + @Override + public GraphExtractionResult parseGraphResponse(String response) { + log.debug("开始解析图谱响应,响应长度: {}", response != null ? response.length() : 0); + + List entities = new ArrayList<>(); + List relations = new ArrayList<>(); + + if (StrUtil.isBlank(response)) { + log.warn("响应为空,无法解析"); + return GraphExtractionResult.builder() + .entities(entities) + .relations(relations) + .success(false) + .errorMessage("LLM响应为空") + .build(); + } + + try { + // 1. 解析实体 + Matcher entityMatcher = ENTITY_PATTERN.matcher(response); + while (entityMatcher.find()) { + String name = entityMatcher.group(1).trim(); + String type = entityMatcher.group(2).trim(); + String description = entityMatcher.group(3).trim(); + + // ⭐ 过滤无效实体(N/A 或包含特殊字符) + if (isInvalidEntity(name, type)) { + log.debug("跳过无效实体: name={}, type={}", name, type); + continue; + } + + ExtractedEntity entity = ExtractedEntity.builder() + .name(name) + .type(type) + .description(description) + .build(); + + entities.add(entity); + log.debug("解析到实体: name={}, type={}", name, type); + } + + // 2. 解析关系 + Matcher relationMatcher = RELATION_PATTERN.matcher(response); + while (relationMatcher.find()) { + String sourceEntity = relationMatcher.group(1).trim(); + String targetEntity = relationMatcher.group(2).trim(); + String description = relationMatcher.group(3).trim(); + String strengthStr = relationMatcher.group(4).trim(); + + Integer strength = parseStrength(strengthStr); + Double confidence = calculateConfidence(strength); + + ExtractedRelation relation = ExtractedRelation.builder() + .sourceEntity(sourceEntity) + .targetEntity(targetEntity) + .description(description) + .strength(strength) + .confidence(confidence) + .build(); + + relations.add(relation); + log.debug("解析到关系: sourceEntity={}, targetEntity={}, strength={}", + sourceEntity, targetEntity, strength); + } + + log.info("解析完成,实体数: {}, 关系数: {}", entities.size(), relations.size()); + + return GraphExtractionResult.builder() + .entities(entities) + .relations(relations) + .success(true) + .build(); + + } catch (Exception e) { + log.error("解析图谱响应失败", e); + return GraphExtractionResult.builder() + .entities(entities) + .relations(relations) + .success(false) + .errorMessage("解析失败: " + e.getMessage()) + .build(); + } + } + + /** + * 调用LLM获取响应(使用默认模型) + * + * @param prompt 提示词 + * @return LLM响应 + */ + private String callLLM(String prompt) { + // 获取聊天分类的最高优先级模型作为默认模型 + ChatModelVo defaultModel = chatModelService.selectModelByCategoryWithHighestPriority("chat"); + if (defaultModel == null) { + // 如果没有chat分类的模型,尝试查询任意可用模型 + List models = chatModelService.queryList(new org.ruoyi.domain.bo.ChatModelBo()); + if (models != null && !models.isEmpty()) { + defaultModel = models.get(0); + } + } + + if (defaultModel == null) { + log.error("未找到可用的LLM模型"); + throw new RuntimeException("未找到可用的LLM模型,请先配置聊天模型"); + } + + log.info("使用默认模型: {}", defaultModel.getModelName()); + return callLLMWithModel(prompt, defaultModel); + } + + /** + * 使用指定模型调用LLM获取响应(使用工厂模式,支持多种LLM) + * + * @param prompt 提示词 + * @param chatModel 模型配置 + * @return LLM响应 + */ + private String callLLMWithModel(String prompt, ChatModelVo chatModel) { + log.info("调用LLM模型: model={}, category={}, 提示词长度={}", + chatModel.getModelName(), chatModel.getCategory(), prompt.length()); + + try { + // 根据模型类别获取对应的LLM服务实现 + IGraphLLMService llmService = llmServiceFactory.getLLMService(chatModel.getCategory()); + + // 调用LLM进行图谱抽取 + String responseText = llmService.extractGraph(prompt, chatModel); + + log.info("LLM调用成功: model={}, category={}, 响应长度={}", + chatModel.getModelName(), chatModel.getCategory(), responseText.length()); + + return responseText; + + } catch (IllegalArgumentException e) { + // 不支持的模型类别,降级到默认实现 + log.warn("不支持的模型类别: {}, 尝试使用OpenAI兼容模式", chatModel.getCategory()); + + try { + IGraphLLMService openAiService = llmServiceFactory.getLLMService("openai"); + return openAiService.extractGraph(prompt, chatModel); + } catch (Exception fallbackEx) { + log.error("降级调用也失败: {}", fallbackEx.getMessage(), fallbackEx); + throw new RuntimeException("LLM调用失败: " + fallbackEx.getMessage(), fallbackEx); + } + + } catch (Exception e) { + log.error("LLM调用失败: {}", e.getMessage(), e); + throw new RuntimeException("LLM调用失败: " + e.getMessage(), e); + } + } + + /** + * 解析关系强度 + * + * @param strengthStr 强度字符串 + * @return 强度值(0-10) + */ + private Integer parseStrength(String strengthStr) { + try { + // 尝试解析为整数 + int strength = Integer.parseInt(strengthStr); + // 限制在0-10范围内 + return Math.max(0, Math.min(10, strength)); + } catch (NumberFormatException e) { + log.debug("无法解析关系强度: {}, 使用默认值5", strengthStr); + return 5; // 默认中等强度 + } + } + + /** + * 验证实体是否有效 + * 过滤 N/A 以及包含 Neo4j 不支持的特殊字符的实体 + * + * @param name 实体名称 + * @param type 实体类型 + * @return true=无效,false=有效 + */ + private boolean isInvalidEntity(String name, String type) { + // 1. 检查是否为 N/A + if ("N/A".equalsIgnoreCase(name) || "N/A".equalsIgnoreCase(type)) { + return true; + } + + // 2. 检查是否为空或纯空格 + if (StrUtil.isBlank(name) || StrUtil.isBlank(type)) { + return true; + } + + // 3. 检查类型是否包含 Neo4j Label 不支持的字符 + // Neo4j Label 规则:不能包含 / : & | 等特殊字符 + if (type.matches(".*[/:&|\\\\].*")) { + log.warn("⚠️ 实体类型包含非法字符,将被过滤: type={}", type); + return true; + } + + // 4. 检查名称是否过长(Neo4j 建议 < 256) + if (name.length() > 255 || type.length() > 64) { + log.warn("⚠️ 实体名称或类型过长,将被过滤: name.length={}, type.length={}", + name.length(), type.length()); + return true; + } + + return false; + } + + /** + * 根据关系强度计算置信度 + * + * @param strength 关系强度(0-10) + * @return 置信度(0.0-1.0) + */ + private Double calculateConfidence(Integer strength) { + if (strength == null) { + return 0.5; + } + // 将0-10的强度映射到0.3-1.0的置信度 + return 0.3 + (strength / 10.0) * 0.7; + } +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/impl/GraphInstanceServiceImpl.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/impl/GraphInstanceServiceImpl.java new file mode 100644 index 00000000..71101b10 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/impl/GraphInstanceServiceImpl.java @@ -0,0 +1,284 @@ +package org.ruoyi.graph.service.impl; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + + +import org.ruoyi.common.core.utils.StringUtils; +import org.ruoyi.graph.domain.GraphInstance; +import org.ruoyi.graph.mapper.GraphInstanceMapper; +import org.ruoyi.graph.service.IGraphInstanceService; +import org.ruoyi.graph.service.IGraphStoreService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 图谱实例服务实现 + * + * @author ruoyi + * @date 2025-09-30 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GraphInstanceServiceImpl implements IGraphInstanceService { + + private final GraphInstanceMapper graphInstanceMapper; + private final IGraphStoreService graphStoreService; + + @Override + @Transactional(rollbackFor = Exception.class) + public GraphInstance createInstance(String knowledgeId, String graphName, String config) { + // 检查是否已存在 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(GraphInstance::getKnowledgeId, knowledgeId); + GraphInstance existing = graphInstanceMapper.selectOne(wrapper); + + if (existing != null) { + log.warn("知识库 {} 已存在图谱实例", knowledgeId); + return existing; + } + + // 创建新实例 + GraphInstance instance = new GraphInstance(); + instance.setGraphUuid(String.valueOf(IdUtil.getSnowflake().nextId())); // UUID + instance.setKnowledgeId(knowledgeId); + instance.setGraphName(StringUtils.isNotBlank(graphName) ? graphName : "知识图谱-" + knowledgeId); + instance.setGraphStatus(0); // 0-未构建(新建时状态为未构建,需手动点击"构建"按钮) + instance.setNodeCount(0); + instance.setRelationshipCount(0); + + // 解析配置 + if (StringUtils.isNotBlank(config)) { + instance.setConfig(config); + } + + graphInstanceMapper.insert(instance); + + // 创建 Neo4j Schema + graphStoreService.createGraphSchema(knowledgeId); + + log.info("创建图谱实例成功: knowledgeId={}, instanceId={}", knowledgeId, instance.getId()); + return instance; + } + + @Override + public GraphInstance getById(Long id) { + return graphInstanceMapper.selectById(id); + } + + @Override + public GraphInstance getByUuid(String graphUuid) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(GraphInstance::getGraphUuid, graphUuid); + return graphInstanceMapper.selectOne(wrapper); + } + + @Override + public boolean updateInstance(GraphInstance instance) { + try { + int rows = graphInstanceMapper.updateById(instance); + log.info("更新图谱实例: id={}, rows={}", instance.getId(), rows); + return rows > 0; + } catch (Exception e) { + log.error("更新图谱实例失败: id={}", instance.getId(), e); + return false; + } + } + + @Override + public List listByKnowledgeId(String knowledgeId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(GraphInstance::getKnowledgeId, knowledgeId); + wrapper.orderByDesc(GraphInstance::getCreateTime); + return graphInstanceMapper.selectList(wrapper); + } + + @Override + public Page queryPage(Page page, String instanceName, String knowledgeId, Integer graphStatus) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + // 图谱名称模糊查询 + if (StringUtils.isNotBlank(instanceName)) { + wrapper.like(GraphInstance::getGraphName, instanceName.trim()); + } + + // 知识库ID精确查询 + if (StringUtils.isNotBlank(knowledgeId)) { + wrapper.eq(GraphInstance::getKnowledgeId, knowledgeId.trim()); + } + + // 状态精确查询 + if (graphStatus != null) { + wrapper.eq(GraphInstance::getGraphStatus, graphStatus); + } + + // 只查询未删除的记录 + wrapper.eq(GraphInstance::getDelFlag, "0"); + + // 按创建时间倒序 + wrapper.orderByDesc(GraphInstance::getCreateTime); + + return graphInstanceMapper.selectPage(page, wrapper); + } + + @Override + public boolean updateStatus(String graphUuid, Integer status) { + try { + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.eq(GraphInstance::getGraphUuid, graphUuid); + wrapper.set(GraphInstance::getGraphStatus, status); + + int rows = graphInstanceMapper.update(null, wrapper); + + log.info("更新图谱状态: graphUuid={}, status={}, rows={}", graphUuid, status, rows); + return rows > 0; + } catch (Exception e) { + log.error("更新图谱状态失败: graphUuid={}, status={}", graphUuid, status, e); + return false; + } + } + + @Override + public boolean updateCounts(String graphUuid, Integer nodeCount, Integer relationshipCount) { + try { + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.eq(GraphInstance::getGraphUuid, graphUuid); + + if (nodeCount != null) { + wrapper.set(GraphInstance::getNodeCount, nodeCount); + } + if (relationshipCount != null) { + wrapper.set(GraphInstance::getRelationshipCount, relationshipCount); + } + + int rows = graphInstanceMapper.update(null, wrapper); + + log.info("更新图谱统计: graphUuid={}, nodeCount={}, relationshipCount={}, rows={}", + graphUuid, nodeCount, relationshipCount, rows); + return rows > 0; + } catch (Exception e) { + log.error("更新图谱统计失败: graphUuid={}", graphUuid, e); + return false; + } + } + + @Override + public boolean updateConfig(String graphUuid, String config) { + try { + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.eq(GraphInstance::getGraphUuid, graphUuid); + wrapper.set(GraphInstance::getConfig, config); + + int rows = graphInstanceMapper.update(null, wrapper); + + log.info("更新图谱配置: graphUuid={}, rows={}", graphUuid, rows); + return rows > 0; + } catch (Exception e) { + log.error("更新图谱配置失败: graphUuid={}", graphUuid, e); + return false; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean deleteInstance(String graphUuid) { + try { + log.info("🗑️ 开始删除图谱实例及数据,graphUuid: {}", graphUuid); + + // ⭐ 1. 先获取实例信息(获取knowledgeId) + GraphInstance instance = getByUuid(graphUuid); + if (instance == null) { + log.warn("⚠️ 图谱实例不存在: graphUuid={}", graphUuid); + return false; + } + + String knowledgeId = instance.getKnowledgeId(); + + // ⭐ 2. 删除Neo4j中的图数据(通过knowledgeId) + if (StrUtil.isNotBlank(knowledgeId)) { + log.info("删除Neo4j图数据,knowledgeId: {}", knowledgeId); + boolean neo4jDeleted = graphStoreService.deleteByKnowledgeId(knowledgeId); + if (neo4jDeleted) { + log.info("✅ Neo4j图数据删除成功"); + } else { + log.warn("⚠️ Neo4j图数据删除失败(可能是没有数据)"); + } + } else { + log.warn("⚠️ 实例没有关联知识库ID,跳过Neo4j数据删除"); + } + + // 3. 删除MySQL中的实例记录 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(GraphInstance::getGraphUuid, graphUuid); + int rows = graphInstanceMapper.delete(wrapper); + + log.info("✅ 删除图谱实例成功: graphUuid={}, knowledgeId={}, rows={}", + graphUuid, knowledgeId, rows); + return rows > 0; + } catch (Exception e) { + log.error("❌ 删除图谱实例失败: graphUuid={}", graphUuid, e); + return false; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean deleteInstanceAndData(String graphUuid) { + try { + // 1. 删除 Neo4j 中的图谱数据 + boolean graphDeleted = graphStoreService.deleteGraph(graphUuid); + + // 2. 删除 MySQL 中的实例记录 + boolean instanceDeleted = deleteInstance(graphUuid); + + log.info("删除图谱实例及数据: graphUuid={}, graphDeleted={}, instanceDeleted={}", + graphUuid, graphDeleted, instanceDeleted); + + return graphDeleted && instanceDeleted; + } catch (Exception e) { + log.error("删除图谱实例及数据失败: graphUuid={}", graphUuid, e); + return false; + } + } + + @Override + public Map getStatistics(String graphUuid) { + try { + // 从 Neo4j 获取实时统计 + Map stats = graphStoreService.getGraphStatistics(graphUuid); + + // 更新到 MySQL(异步) + if (stats.containsKey("nodeCount") && stats.containsKey("relationshipCount")) { + updateCounts( + graphUuid, + (Integer) stats.get("nodeCount"), + (Integer) stats.get("relationshipCount") + ); + } + + // 添加实例信息 + GraphInstance instance = getByUuid(graphUuid); + if (instance != null) { + stats.put("graphName", instance.getGraphName()); + stats.put("status", instance.getGraphStatus()); + stats.put("createTime", instance.getCreateTime()); + stats.put("updateTime", instance.getUpdateTime()); + } + + return stats; + } catch (Exception e) { + log.error("获取图谱统计信息失败: graphUuid={}", graphUuid, e); + return new HashMap<>(); + } + } +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/impl/GraphRAGServiceImpl.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/impl/GraphRAGServiceImpl.java new file mode 100644 index 00000000..7423499f --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/impl/GraphRAGServiceImpl.java @@ -0,0 +1,460 @@ +package org.ruoyi.graph.service.impl; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.graph.constants.GraphConstants; +import org.ruoyi.graph.domain.GraphEdge; +import org.ruoyi.graph.domain.GraphVertex; +import org.ruoyi.graph.dto.ExtractedEntity; +import org.ruoyi.graph.dto.ExtractedRelation; +import org.ruoyi.graph.dto.GraphExtractionResult; +import org.ruoyi.graph.service.IGraphExtractionService; +import org.ruoyi.graph.service.IGraphRAGService; +import org.ruoyi.graph.service.IGraphStoreService; +import org.springframework.stereotype.Service; + +import java.util.*; + +/** + * GraphRAG服务实现 + * + * @author ruoyi + * @date 2025-09-30 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GraphRAGServiceImpl implements IGraphRAGService { + + private final IGraphExtractionService graphExtractionService; + private final IGraphStoreService graphStoreService; + + @Override + public GraphExtractionResult ingestText(String text, String knowledgeId, Map metadata) { + return ingestTextWithModel(text, knowledgeId, metadata, null); + } + + @Override + public GraphExtractionResult ingestTextWithModel(String text, String knowledgeId, Map metadata, String modelName) { + log.info("开始将文本入库到图谱,知识库ID: {}, 模型: {}, 文本长度: {}", + knowledgeId, modelName != null ? modelName : "默认", text.length()); + + try { + // 1. 从文本中抽取实体和关系 + GraphExtractionResult extractionResult; + if (StrUtil.isNotBlank(modelName)) { + extractionResult = graphExtractionService.extractFromTextWithModel(text, modelName); + } else { + extractionResult = graphExtractionService.extractFromText(text); + } + + if (!extractionResult.getSuccess()) { + log.error("实体抽取失败: {}", extractionResult.getErrorMessage()); + return extractionResult; + } + + // 2. 将抽取的实体转换为图节点 + List vertices = convertEntitiesToVertices( + extractionResult.getEntities(), + knowledgeId, + metadata + ); + + // 3. 批量添加节点到Neo4j,并建立实体名称→nodeId的映射 + Map entityNameToNodeIdMap = new HashMap<>(); + if (!vertices.isEmpty()) { + int addedCount = graphStoreService.addVertices(vertices); + log.info("成功添加 {} 个节点到图谱", addedCount); + + // ⭐ 建立映射:实体名称 → nodeId + for (GraphVertex vertex : vertices) { + entityNameToNodeIdMap.put(vertex.getName(), vertex.getNodeId()); + } + log.debug("建立实体名称映射: {} 个实体", entityNameToNodeIdMap.size()); + } + + // 4. 将抽取的关系转换为图边,使用映射填充nodeId + List edges = convertRelationsToEdges( + extractionResult.getRelations(), + knowledgeId, + metadata, + entityNameToNodeIdMap // ⭐ 传入映射 + ); + + // 5. 批量添加关系到Neo4j + if (!edges.isEmpty()) { + int addedCount = graphStoreService.addEdges(edges); + log.info("成功添加 {} 个关系到图谱", addedCount); + } + + return extractionResult; + + } catch (Exception e) { + log.error("文本入库失败", e); + return GraphExtractionResult.builder() + .entities(new ArrayList<>()) + .relations(new ArrayList<>()) + .success(false) + .errorMessage(e.getMessage()) + .build(); + } + } + + @Override + public GraphExtractionResult ingestDocument(String documentText, String knowledgeId, Map metadata) { + return ingestDocumentWithModel(documentText, knowledgeId, metadata, null); + } + + @Override + public GraphExtractionResult ingestDocumentWithModel(String documentText, String knowledgeId, Map metadata, String modelName) { + log.info("开始将文档入库到图谱,知识库ID: {}, 模型: {}, 文档长度: {}", + knowledgeId, modelName != null ? modelName : "默认", documentText.length()); + + // 如果文档较短,直接处理 + if (documentText.length() < GraphConstants.RAG_MAX_SEGMENT_SIZE_IN_TOKENS * 4) { + return ingestTextWithModel(documentText, knowledgeId, metadata, modelName); + } + + // 文档较长,需要分片处理 + List chunks = splitDocument(documentText); + log.info("文档已分割为 {} 个片段", chunks.size()); + + // 合并结果 + List allEntities = new ArrayList<>(); + List allRelations = new ArrayList<>(); + int totalTokenUsed = 0; + + for (int i = 0; i < chunks.size(); i++) { + String chunk = chunks.get(i); + log.debug("处理第 {}/{} 个片段", i + 1, chunks.size()); + + // 为每个片段添加序号元数据 + Map chunkMetadata = new HashMap<>(metadata); + chunkMetadata.put("chunk_index", i); + chunkMetadata.put("total_chunks", chunks.size()); + + GraphExtractionResult result = ingestTextWithModel(chunk, knowledgeId, chunkMetadata, modelName); + + if (result.getSuccess()) { + allEntities.addAll(result.getEntities()); + allRelations.addAll(result.getRelations()); + if (result.getTokenUsed() != null) { + totalTokenUsed += result.getTokenUsed(); + } + } + } + + // 去重实体(基于名称和类型) + List uniqueEntities = deduplicateEntities(allEntities); + log.info("去重后实体数: {} -> {}", allEntities.size(), uniqueEntities.size()); + + return GraphExtractionResult.builder() + .entities(uniqueEntities) + .relations(allRelations) + .tokenUsed(totalTokenUsed) + .success(true) + .build(); + } + + @Override + public String retrieveFromGraph(String query, String knowledgeId, int maxResults) { + log.info("从图谱检索相关内容,查询: {}, 知识库ID: {}", query, knowledgeId); + + try { + // 1. 从查询中抽取关键词(简单分词) + List keywords = extractKeywords(query); + log.debug("提取的关键词: {}", keywords); + + if (keywords.isEmpty()) { + return "未能从查询中提取关键信息"; + } + + // 2. 在图谱中搜索相关实体节点 + List matchedNodes = new ArrayList<>(); + for (String keyword : keywords) { + List nodes = graphStoreService.searchVerticesByName( + keyword, knowledgeId, Math.min(5, maxResults) + ); + matchedNodes.addAll(nodes); + } + + if (matchedNodes.isEmpty()) { + return "图谱中未找到相关实体"; + } + + log.info("找到 {} 个匹配的实体节点", matchedNodes.size()); + + // 3. 去重(按nodeId) + Map uniqueNodes = new HashMap<>(); + for (GraphVertex node : matchedNodes) { + uniqueNodes.putIfAbsent(node.getNodeId(), node); + } + matchedNodes = new ArrayList<>(uniqueNodes.values()); + + // 限制结果数量 + if (matchedNodes.size() > maxResults) { + matchedNodes = matchedNodes.subList(0, maxResults); + } + + // 4. 为每个匹配节点获取邻居,构建子图上下文 + StringBuilder result = new StringBuilder(); + result.append("### 图谱检索结果\n\n"); + result.append(String.format("查询: %s\n", query)); + result.append(String.format("找到 %d 个相关实体:\n\n", matchedNodes.size())); + + for (int i = 0; i < matchedNodes.size(); i++) { + GraphVertex node = matchedNodes.get(i); + result.append(String.format("**%d. %s** (%s)\n", i + 1, node.getName(), node.getLabel())); + + if (StrUtil.isNotBlank(node.getDescription())) { + result.append(String.format(" 描述: %s\n", node.getDescription())); + } + + // 获取邻居节点(1跳) + List neighbors = graphStoreService.getNeighbors( + node.getNodeId(), knowledgeId, 5 + ); + + if (!neighbors.isEmpty()) { + result.append(" 关联实体: "); + List neighborNames = neighbors.stream() + .map(GraphVertex::getName) + .limit(5) + .collect(java.util.stream.Collectors.toList()); + result.append(String.join(", ", neighborNames)); + result.append("\n"); + } + + result.append("\n"); + } + + // 5. 添加统计信息 + result.append("---\n"); + result.append(String.format("总计: %d 个实体节点\n", matchedNodes.size())); + + return result.toString(); + + } catch (Exception e) { + log.error("图谱检索失败", e); + return "检索失败: " + e.getMessage(); + } + } + + /** + * 从查询中提取关键词 + * + * @param query 查询文本 + * @return 关键词列表 + */ + private List extractKeywords(String query) { + List keywords = new ArrayList<>(); + + // 简单的中文分词策略 + // 1. 去除标点符号 + String cleaned = query.replaceAll("[\\p{Punct}\\s]+", " "); + + // 2. 按空格分割 + String[] words = cleaned.split("\\s+"); + + // 3. 过滤停用词和短词 + Set stopWords = new HashSet<>(java.util.Arrays.asList( + "的", "了", "和", "是", "在", "我", "有", "个", "这", "那", "为", + "与", "或", "但", "等", "及", "而", "中", "如", "一", "二", "三" + )); + + for (String word : words) { + word = word.trim(); + if (word.length() >= 2 && !stopWords.contains(word)) { + keywords.add(word); + } + } + + // 4. 如果没有提取到关键词,尝试按2-3字切分 + if (keywords.isEmpty() && query.length() >= 2) { + for (int i = 0; i <= query.length() - 2; i++) { + String chunk = query.substring(i, Math.min(i + 3, query.length())); + if (chunk.length() >= 2 && !stopWords.contains(chunk)) { + keywords.add(chunk); + } + } + } + + // 去重并限制数量 + return keywords.stream() + .distinct() + .limit(5) + .collect(java.util.stream.Collectors.toList()); + } + + @Override + public boolean deleteGraphData(String knowledgeId) { + log.info("删除知识库图谱数据,知识库ID: {}", knowledgeId); + + try { + // 删除该知识库的所有节点和关系 + return graphStoreService.deleteByKnowledgeId(knowledgeId); + } catch (Exception e) { + log.error("删除图谱数据失败", e); + return false; + } + } + + /** + * 将抽取的实体转换为图节点 + */ + private List convertEntitiesToVertices( + List entities, + String knowledgeId, + Map metadata) { + + List vertices = new ArrayList<>(); + + for (ExtractedEntity entity : entities) { + GraphVertex vertex = new GraphVertex(); + vertex.setNodeId(IdUtil.simpleUUID()); // 生成唯一ID + vertex.setName(entity.getName()); + vertex.setLabel(entity.getType()); + vertex.setDescription(entity.getDescription()); + vertex.setKnowledgeId(knowledgeId); + vertex.setConfidence(entity.getConfidence() != null ? entity.getConfidence() : 1.0); + + // 添加元数据 + if (metadata != null && !metadata.isEmpty()) { + vertex.setMetadata(metadata); + } + + vertices.add(vertex); + } + + return vertices; + } + + /** + * 将抽取的关系转换为图边 + * + * @param relations 抽取的关系列表 + * @param knowledgeId 知识库ID + * @param metadata 元数据 + * @param entityNameToNodeIdMap 实体名称到节点ID的映射 + * @return 图边列表 + */ + private List convertRelationsToEdges( + List relations, + String knowledgeId, + Map metadata, + Map entityNameToNodeIdMap) { + + List edges = new ArrayList<>(); + int skippedCount = 0; + + for (ExtractedRelation relation : relations) { + // ⭐ 通过实体名称查找对应的nodeId + String sourceNodeId = entityNameToNodeIdMap.get(relation.getSourceEntity()); + String targetNodeId = entityNameToNodeIdMap.get(relation.getTargetEntity()); + + // 如果找不到对应的节点ID,跳过这个关系 + if (sourceNodeId == null || targetNodeId == null) { + log.warn("⚠️ 跳过关系(节点未找到): {} -> {}", + relation.getSourceEntity(), relation.getTargetEntity()); + skippedCount++; + continue; + } + + GraphEdge edge = new GraphEdge(); + edge.setEdgeId(IdUtil.simpleUUID()); + edge.setSourceNodeId(sourceNodeId); // ⭐ 设置源节点ID + edge.setTargetNodeId(targetNodeId); // ⭐ 设置目标节点ID + edge.setSourceName(relation.getSourceEntity()); + edge.setTargetName(relation.getTargetEntity()); + edge.setLabel("RELATED_TO"); // 默认关系类型 + edge.setDescription(relation.getDescription()); + edge.setWeight(relation.getStrength() / 10.0); // 转换为0-1的权重 + edge.setKnowledgeId(knowledgeId); + edge.setConfidence(relation.getConfidence() != null ? relation.getConfidence() : 1.0); + + // 添加元数据 + if (metadata != null && !metadata.isEmpty()) { + edge.setMetadata(metadata); + } + + edges.add(edge); + } + + if (skippedCount > 0) { + log.warn("⚠️ 共跳过 {} 个关系(对应的实体节点未找到)", skippedCount); + } + + return edges; + } + + /** + * 分割文档为多个片段 + */ + private List splitDocument(String text) { + List chunks = new ArrayList<>(); + int chunkSize = GraphConstants.RAG_MAX_SEGMENT_SIZE_IN_TOKENS * 4; // 简单估算字符数 + int overlap = GraphConstants.RAG_SEGMENT_OVERLAP_IN_TOKENS * 4; + + int start = 0; + while (start < text.length()) { + int end = Math.min(start + chunkSize, text.length()); + + // 尝试在句子边界分割 + if (end < text.length()) { + int lastPeriod = text.lastIndexOf('。', end); + int lastNewline = text.lastIndexOf('\n', end); + int boundary = Math.max(lastPeriod, lastNewline); + + if (boundary > start) { + end = boundary + 1; + } + } + + chunks.add(text.substring(start, end)); + + // ⭐ 修复死循环:确保 start 一定会增加 + // 如果已经到达文本末尾,直接退出 + if (end >= text.length()) { + break; + } + + // 计算下一个起始位置,确保至少前进1个字符 + int nextStart = end - overlap; + if (nextStart <= start) { + // 如果 overlap 太大导致无法前进,强制前进到 end + start = end; + } else { + start = nextStart; + } + } + + return chunks; + } + + /** + * 去重实体 + */ + private List deduplicateEntities(List entities) { + Map entityMap = new HashMap<>(); + + for (ExtractedEntity entity : entities) { + String key = entity.getName() + "|" + entity.getType(); + + if (!entityMap.containsKey(key)) { + entityMap.put(key, entity); + } else { + // 如果已存在,保留置信度更高的 + ExtractedEntity existing = entityMap.get(key); + double entityConf = entity.getConfidence() != null ? entity.getConfidence() : 1.0; + double existingConf = existing.getConfidence() != null ? existing.getConfidence() : 1.0; + if (entityConf > existingConf) { + entityMap.put(key, entity); + } + } + } + + return new ArrayList<>(entityMap.values()); + } +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/impl/GraphStoreServiceImpl.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/impl/GraphStoreServiceImpl.java new file mode 100644 index 00000000..bff03c07 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/impl/GraphStoreServiceImpl.java @@ -0,0 +1,927 @@ +package org.ruoyi.graph.service.impl; + +import com.alibaba.fastjson2.JSON; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.Result; +import org.neo4j.driver.Session; +import org.neo4j.driver.types.Node; +import org.neo4j.driver.types.Relationship; +import org.ruoyi.graph.config.GraphProperties; +import org.ruoyi.graph.domain.GraphEdge; +import org.ruoyi.graph.domain.GraphVertex; +import org.ruoyi.graph.service.IGraphStoreService; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +import static org.neo4j.driver.Values.parameters; + +/** + * 图存储服务实现 + * 负责与 Neo4j 图数据库交互 + * + * @author ruoyi + * @date 2025-09-30 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GraphStoreServiceImpl implements IGraphStoreService { + + private final Driver neo4jDriver; + private final GraphProperties graphProperties; + + // ==================== 节点操作 ==================== + + @Override + public boolean addVertex(GraphVertex vertex) { + try (Session session = neo4jDriver.session()) { + String cypher = "CREATE (n:" + vertex.getLabel() + " {" + + "id: $id, " + + "name: $name, " + + "description: $description, " + + "knowledgeId: $knowledgeId, " + + "docIds: $docIds, " + + "properties: $properties, " + + "confidence: $confidence" + + "}) RETURN n"; + + Result result = session.run(cypher, parameters( + "id", vertex.getNodeId(), // ⭐ 修复:使用 nodeId 而不是 id + "name", vertex.getName(), + "description", vertex.getDescription(), + "knowledgeId", vertex.getKnowledgeId(), + "docIds", vertex.getDocIds(), + "properties", vertex.getProperties(), + "confidence", vertex.getConfidence() + )); + + return result.hasNext(); + } catch (Exception e) { + log.error("添加节点失败: {}", vertex, e); + return false; + } + } + + @Override + public int addVertices(List vertices) { + if (vertices == null || vertices.isEmpty()) { + return 0; + } + + int successCount = 0; + int batchSize = graphProperties.getBatchSize(); + + try (Session session = neo4jDriver.session()) { + // 分批处理 + for (int i = 0; i < vertices.size(); i += batchSize) { + List batch = vertices.subList( + i, Math.min(i + batchSize, vertices.size()) + ); + + successCount += session.writeTransaction(tx -> { + int count = 0; + for (GraphVertex vertex : batch) { + String cypher = "CREATE (n:" + vertex.getLabel() + " {" + + "id: $id, name: $name, description: $description, " + + "knowledgeId: $knowledgeId, docIds: $docIds, " + + "properties: $properties, confidence: $confidence})"; + + tx.run(cypher, parameters( + "id", vertex.getNodeId(), // ⭐ 修复:使用 nodeId 而不是 id + "name", vertex.getName(), + "description", vertex.getDescription(), + "knowledgeId", vertex.getKnowledgeId(), + "docIds", vertex.getDocIds(), + "properties", vertex.getProperties(), + "confidence", vertex.getConfidence() + )); + count++; + } + return count; + }); + } + } catch (Exception e) { + log.error("批量添加节点失败", e); + } + + return successCount; + } + + @Override + public GraphVertex getVertex(String nodeId, String graphUuid) { + try (Session session = neo4jDriver.session()) { + String cypher = "MATCH (n) WHERE n.id = $nodeId AND n.knowledgeId = $graphUuid RETURN n"; + + Result result = session.run(cypher, parameters( + "nodeId", nodeId, + "graphUuid", graphUuid + )); + + if (result.hasNext()) { + Record record = result.single(); + return nodeToVertex(record.get("n").asNode()); + } + return null; + } catch (Exception e) { + log.error("获取节点失败: nodeId={}, graphUuid={}", nodeId, graphUuid, e); + return null; + } + } + + @Override + public List searchVertices(String graphUuid, String label, Integer limit) { + try (Session session = neo4jDriver.session()) { + StringBuilder cypher = new StringBuilder("MATCH (n"); + if (label != null && !label.isEmpty()) { + cypher.append(":").append(label); + } + cypher.append(") WHERE n.knowledgeId = $graphUuid RETURN n"); + + if (limit != null && limit > 0) { + cypher.append(" LIMIT $limit"); + } + + Map params = new HashMap<>(); + params.put("graphUuid", graphUuid); + if (limit != null && limit > 0) { + params.put("limit", limit); + } + + Result result = session.run(cypher.toString(), params); + + return result.stream() + .map(record -> nodeToVertex(record.get("n").asNode())) + .collect(Collectors.toList()); + } catch (Exception e) { + log.error("搜索节点失败: graphUuid={}, label={}", graphUuid, label, e); + return Collections.emptyList(); + } + } + + @Override + public List searchVerticesByName(String graphUuid, String name) { + try (Session session = neo4jDriver.session()) { + String cypher = "MATCH (n) WHERE n.knowledgeId = $graphUuid AND n.name CONTAINS $name RETURN n"; + + Result result = session.run(cypher, parameters( + "graphUuid", graphUuid, + "name", name + )); + + return result.stream() + .map(record -> nodeToVertex(record.get("n").asNode())) + .collect(Collectors.toList()); + } catch (Exception e) { + log.error("按名称搜索节点失败: graphUuid={}, name={}", graphUuid, name, e); + return Collections.emptyList(); + } + } + + @Override + public List searchVerticesByName(String keyword, String knowledgeId, Integer limit) { + List vertices = new ArrayList<>(); + + try (Session session = neo4jDriver.session()) { + String cypher; + Map params = new HashMap<>(); + params.put("keyword", keyword); + params.put("limit", limit); + + if (knowledgeId != null && !knowledgeId.isEmpty()) { + cypher = "MATCH (n {knowledgeId: $knowledgeId}) " + + "WHERE n.name CONTAINS $keyword " + + "RETURN n LIMIT $limit"; + params.put("knowledgeId", knowledgeId); + } else { + cypher = "MATCH (n) " + + "WHERE n.name CONTAINS $keyword " + + "RETURN n LIMIT $limit"; + } + + Result result = session.run(cypher, params); + + result.stream().forEach(record -> { + Node node = record.get("n").asNode(); + vertices.add(nodeToVertex(node)); + }); + + log.info("搜索到 {} 个节点,关键词: {}", vertices.size(), keyword); + return vertices; + } catch (Exception e) { + log.error("按关键词搜索节点失败: keyword={}, knowledgeId={}", keyword, knowledgeId, e); + return vertices; + } + } + + @Override + public boolean updateVertex(GraphVertex vertex) { + try (Session session = neo4jDriver.session()) { + String cypher = "MATCH (n {id: $id, knowledgeId: $knowledgeId}) " + + "SET n.name = $name, n.description = $description, " + + "n.properties = $properties, n.confidence = $confidence " + + "RETURN n"; + + Result result = session.run(cypher, parameters( + "id", vertex.getId(), + "knowledgeId", vertex.getKnowledgeId(), + "name", vertex.getName(), + "description", vertex.getDescription(), + "properties", vertex.getProperties(), + "confidence", vertex.getConfidence() + )); + + return result.hasNext(); + } catch (Exception e) { + log.error("更新节点失败: {}", vertex, e); + return false; + } + } + + @Override + public boolean deleteVertex(String nodeId, String graphUuid, boolean includeEdges) { + try (Session session = neo4jDriver.session()) { + String cypher; + if (includeEdges) { + cypher = "MATCH (n {id: $nodeId, knowledgeId: $graphUuid}) DETACH DELETE n"; + } else { + cypher = "MATCH (n {id: $nodeId, knowledgeId: $graphUuid}) DELETE n"; + } + + session.run(cypher, parameters( + "nodeId", nodeId, + "graphUuid", graphUuid + )); + + return true; + } catch (Exception e) { + log.error("删除节点失败: nodeId={}, graphUuid={}", nodeId, graphUuid, e); + return false; + } + } + + // ==================== 关系操作 ==================== + + @Override + public boolean addEdge(GraphEdge edge) { + try (Session session = neo4jDriver.session()) { + String cypher = "MATCH (s {id: $startNodeId, knowledgeId: $knowledgeId}) " + + "MATCH (t {id: $endNodeId, knowledgeId: $knowledgeId}) " + + "CREATE (s)-[r:" + edge.getLabel() + " {" + + "id: $id, description: $description, weight: $weight, " + + "docIds: $docIds, properties: $properties, confidence: $confidence" + + "}]->(t) RETURN r"; + + Result result = session.run(cypher, parameters( + "startNodeId", edge.getSourceNodeId(), + "endNodeId", edge.getTargetNodeId(), + "knowledgeId", edge.getKnowledgeId(), + "id", edge.getEdgeId(), + "description", edge.getDescription(), + "weight", edge.getWeight(), + "docIds", edge.getDocIds(), + "properties", edge.getProperties(), + "confidence", edge.getConfidence() + )); + + return result.hasNext(); + } catch (Exception e) { + log.error("添加关系失败: {}", edge, e); + return false; + } + } + + @Override + public int addEdges(List edges) { + if (edges == null || edges.isEmpty()) { + return 0; + } + + log.info("🔄 开始批量添加 {} 个关系到Neo4j", edges.size()); + int successCount = 0; + int failedCount = 0; + int batchSize = graphProperties.getBatchSize(); + + try (Session session = neo4jDriver.session()) { + for (int i = 0; i < edges.size(); i += batchSize) { + List batch = edges.subList( + i, Math.min(i + batchSize, edges.size()) + ); + + int batchIndex = i / batchSize + 1; + log.debug("处理第 {}/{} 批,本批 {} 个关系", + batchIndex, (edges.size() + batchSize - 1) / batchSize, batch.size()); + + successCount += session.writeTransaction(tx -> { + int count = 0; + for (GraphEdge edge : batch) { + try { + String cypher = "MATCH (s {id: $startNodeId, knowledgeId: $knowledgeId}) " + + "MATCH (t {id: $endNodeId, knowledgeId: $knowledgeId}) " + + "CREATE (s)-[r:" + edge.getLabel() + " {" + + "id: $id, knowledgeId: $knowledgeId, description: $description, weight: $weight, " + + "docIds: $docIds, properties: $properties, confidence: $confidence" + + "}]->(t)"; + + Result result = tx.run(cypher, parameters( + "startNodeId", edge.getSourceNodeId(), + "endNodeId", edge.getTargetNodeId(), + "knowledgeId", edge.getKnowledgeId(), + "id", edge.getEdgeId(), + "description", edge.getDescription(), + "weight", edge.getWeight(), + "docIds", edge.getDocIds(), + "properties", edge.getProperties(), + "confidence", edge.getConfidence() + )); + + // ⭐ 检查是否真的创建了关系 + if (result.consume().counters().relationshipsCreated() > 0) { + count++; + } else { + log.warn("⚠️ 关系创建失败(节点未找到): {} -> {} (knowledgeId: {})", + edge.getSourceNodeId(), edge.getTargetNodeId(), edge.getKnowledgeId()); + } + } catch (Exception e) { + log.error("❌ 添加单个关系失败: {} -> {}, 错误: {}", + edge.getSourceNodeId(), edge.getTargetNodeId(), e.getMessage()); + } + } + return count; + }); + } + } catch (Exception e) { + log.error("❌ 批量添加关系失败", e); + } + + failedCount = edges.size() - successCount; + log.info("✅ 关系添加完成: 成功 {}/{}, 失败 {}", successCount, edges.size(), failedCount); + + if (failedCount > 0) { + log.warn("⚠️ 有 {} 个关系添加失败,可能原因:", failedCount); + log.warn(" 1. 源节点或目标节点不存在"); + log.warn(" 2. sourceNodeId/targetNodeId 不匹配"); + log.warn(" 3. knowledgeId 不匹配"); + } + + return successCount; + } + + @Override + public GraphEdge getEdge(String edgeId, String graphUuid) { + try (Session session = neo4jDriver.session()) { + String cypher = "MATCH (s)-[r]->(t) " + + "WHERE r.id = $edgeId AND r.knowledgeId = $graphUuid " + + "RETURN s, r, t"; + + Result result = session.run(cypher, parameters( + "edgeId", edgeId, + "graphUuid", graphUuid + )); + + if (result.hasNext()) { + Record record = result.single(); + return relationshipToEdge( + record.get("s").asNode(), + record.get("r").asRelationship(), + record.get("t").asNode() + ); + } + return null; + } catch (Exception e) { + log.error("获取关系失败: edgeId={}, graphUuid={}", edgeId, graphUuid, e); + return null; + } + } + + @Override + public List searchEdges(String graphUuid, String sourceNodeId, String targetNodeId, Integer limit) { + try (Session session = neo4jDriver.session()) { + StringBuilder cypher = new StringBuilder("MATCH (s)-[r]->(t) WHERE r.knowledgeId = $graphUuid"); + + Map params = new HashMap<>(); + params.put("graphUuid", graphUuid); + + if (sourceNodeId != null && !sourceNodeId.isEmpty()) { + cypher.append(" AND s.id = $sourceNodeId"); + params.put("sourceNodeId", sourceNodeId); + } + + if (targetNodeId != null && !targetNodeId.isEmpty()) { + cypher.append(" AND t.id = $targetNodeId"); + params.put("targetNodeId", targetNodeId); + } + + cypher.append(" RETURN s, r, t"); + + if (limit != null && limit > 0) { + cypher.append(" LIMIT $limit"); + params.put("limit", limit); + } + + Result result = session.run(cypher.toString(), params); + + return result.stream() + .map(record -> relationshipToEdge( + record.get("s").asNode(), + record.get("r").asRelationship(), + record.get("t").asNode() + )) + .collect(Collectors.toList()); + } catch (Exception e) { + log.error("搜索关系失败: graphUuid={}", graphUuid, e); + return Collections.emptyList(); + } + } + + @Override + public List getNodeEdges(String nodeId, String graphUuid, String direction) { + try (Session session = neo4jDriver.session()) { + String cypher; + switch (direction.toUpperCase()) { + case "IN": + cypher = "MATCH (s)-[r]->(t {id: $nodeId, knowledgeId: $graphUuid}) RETURN s, r, t"; + break; + case "OUT": + cypher = "MATCH (s {id: $nodeId, knowledgeId: $graphUuid})-[r]->(t) RETURN s, r, t"; + break; + case "BOTH": + default: + cypher = "MATCH (s)-[r]-(t {id: $nodeId, knowledgeId: $graphUuid}) RETURN s, r, t"; + break; + } + + Result result = session.run(cypher, parameters( + "nodeId", nodeId, + "graphUuid", graphUuid + )); + + return result.stream() + .map(record -> relationshipToEdge( + record.get("s").asNode(), + record.get("r").asRelationship(), + record.get("t").asNode() + )) + .collect(Collectors.toList()); + } catch (Exception e) { + log.error("获取节点关系失败: nodeId={}, graphUuid={}", nodeId, graphUuid, e); + return Collections.emptyList(); + } + } + + @Override + public boolean updateEdge(GraphEdge edge) { + try (Session session = neo4jDriver.session()) { + String cypher = "MATCH ()-[r {id: $id, knowledgeId: $knowledgeId}]->() " + + "SET r.description = $description, r.weight = $weight, " + + "r.properties = $properties, r.confidence = $confidence " + + "RETURN r"; + + Result result = session.run(cypher, parameters( + "id", edge.getEdgeId(), + "knowledgeId", edge.getKnowledgeId(), + "description", edge.getDescription(), + "weight", edge.getWeight(), + "properties", edge.getProperties(), + "confidence", edge.getConfidence() + )); + + return result.hasNext(); + } catch (Exception e) { + log.error("更新关系失败: {}", edge, e); + return false; + } + } + + @Override + public boolean deleteEdge(String edgeId, String graphUuid) { + try (Session session = neo4jDriver.session()) { + String cypher = "MATCH ()-[r {id: $edgeId, knowledgeId: $graphUuid}]->() DELETE r"; + + session.run(cypher, parameters( + "edgeId", edgeId, + "graphUuid", graphUuid + )); + + return true; + } catch (Exception e) { + log.error("删除关系失败: edgeId={}, graphUuid={}", edgeId, graphUuid, e); + return false; + } + } + + // ==================== 图谱管理 ==================== + + @Override + public boolean createGraphSchema(String graphUuid) { + try (Session session = neo4jDriver.session()) { + // 创建索引以提高查询性能 - 使用正确的Neo4j 4.x/5.x语法 + session.run("CREATE INDEX entity_id_index IF NOT EXISTS FOR (n:Entity) ON (n.id)"); + session.run("CREATE INDEX entity_knowledge_id_index IF NOT EXISTS FOR (n:Entity) ON (n.knowledgeId)"); + session.run("CREATE INDEX entity_name_index IF NOT EXISTS FOR (n:Entity) ON (n.name)"); + + // 为关系也创建索引 + session.run("CREATE INDEX relation_id_index IF NOT EXISTS FOR ()-[r:RELATION]-() ON (r.id)"); + session.run("CREATE INDEX relation_type_index IF NOT EXISTS FOR ()-[r:RELATION]-() ON (r.type)"); + + log.info("图谱Schema创建成功: graphUuid={}", graphUuid); + return true; + } catch (Exception e) { + log.error("创建图谱Schema失败: graphUuid={}", graphUuid, e); + return false; + } + } + + @Override + public boolean deleteGraph(String graphUuid) { + try (Session session = neo4jDriver.session()) { + String cypher = "MATCH (n {knowledgeId: $graphUuid}) DETACH DELETE n"; + + session.run(cypher, parameters("graphUuid", graphUuid)); + + log.info("图谱数据删除成功: graphUuid={}", graphUuid); + return true; + } catch (Exception e) { + log.error("删除图谱数据失败: graphUuid={}", graphUuid, e); + return false; + } + } + + @Override + public Map getGraphStatistics(String graphUuid) { + Map stats = new HashMap<>(); + + try (Session session = neo4jDriver.session()) { + // 统计节点数 + Result nodeResult = session.run( + "MATCH (n {knowledgeId: $graphUuid}) RETURN count(n) as count", + parameters("graphUuid", graphUuid) + ); + stats.put("nodeCount", nodeResult.single().get("count").asInt()); + + // 统计关系数 + Result relResult = session.run( + "MATCH ()-[r {knowledgeId: $graphUuid}]->() RETURN count(r) as count", + parameters("graphUuid", graphUuid) + ); + stats.put("relationshipCount", relResult.single().get("count").asInt()); + + } catch (Exception e) { + log.error("获取图谱统计信息失败: graphUuid={}", graphUuid, e); + } + + return stats; + } + + // ==================== 高级查询 ==================== + + @Override + public List> findPaths(String sourceNodeId, String targetNodeId, String graphUuid, Integer maxDepth) { + try (Session session = neo4jDriver.session()) { + String cypher = "MATCH path = (s {id: $sourceNodeId, knowledgeId: $graphUuid})" + + "-[*1.." + (maxDepth != null ? maxDepth : 5) + "]->" + + "(t {id: $targetNodeId, knowledgeId: $graphUuid}) " + + "RETURN nodes(path) as path LIMIT 10"; + + Result result = session.run(cypher, parameters( + "sourceNodeId", sourceNodeId, + "targetNodeId", targetNodeId, + "graphUuid", graphUuid + )); + + List> paths = new ArrayList<>(); + result.stream().forEach(record -> { + List path = record.get("path").asList( + value -> nodeToVertex(value.asNode()) + ); + paths.add(path); + }); + + return paths; + } catch (Exception e) { + log.error("查找路径失败: source={}, target={}, graphUuid={}", sourceNodeId, targetNodeId, graphUuid, e); + return Collections.emptyList(); + } + } + + @Override + public List findNeighbors(String nodeId, String graphUuid, Integer depth) { + try (Session session = neo4jDriver.session()) { + String cypher = "MATCH (s {id: $nodeId, knowledgeId: $graphUuid})" + + "-[*1.." + (depth != null ? depth : 1) + "]-(neighbor) " + + "RETURN DISTINCT neighbor"; + + Result result = session.run(cypher, parameters( + "nodeId", nodeId, + "graphUuid", graphUuid + )); + + return result.stream() + .map(record -> nodeToVertex(record.get("neighbor").asNode())) + .collect(Collectors.toList()); + } catch (Exception e) { + log.error("查找邻居节点失败: nodeId={}, graphUuid={}", nodeId, graphUuid, e); + return Collections.emptyList(); + } + } + + @Override + public List> executeCypher(String cypher, Map params) { + try (Session session = neo4jDriver.session()) { + Result result = session.run(cypher, params != null ? params : Collections.emptyMap()); + + return result.stream() + .map(Record::asMap) + .collect(Collectors.toList()); + } catch (Exception e) { + log.error("执行Cypher查询失败: {}", cypher, e); + return Collections.emptyList(); + } + } + + // ==================== 辅助方法 ==================== + + /** + * Neo4j Node 转换为 GraphVertex + */ + private GraphVertex nodeToVertex(Node node) { + GraphVertex vertex = new GraphVertex(); + vertex.setNodeId(node.get("id").asString(null)); + vertex.setLabel(node.labels().iterator().next()); + vertex.setName(node.get("name").asString(null)); + vertex.setDescription(node.get("description").asString(null)); + vertex.setKnowledgeId(node.get("knowledgeId").asString(null)); + vertex.setDocIds(node.get("docIds").asString(null)); + + // 处理 confidence(可能为空) + if (node.containsKey("confidence") && !node.get("confidence").isNull()) { + vertex.setConfidence(node.get("confidence").asDouble()); + } + + // 处理 properties(转换为JSON字符串) + if (node.containsKey("properties") && !node.get("properties").isNull()) { + Map propsMap = node.get("properties").asMap(); + vertex.setProperties(JSON.toJSONString(propsMap)); + } + + return vertex; + } + + /** + * Neo4j Relationship 转换为 GraphEdge + */ + private GraphEdge relationshipToEdge(Node source, Relationship rel, Node target) { + GraphEdge edge = new GraphEdge(); + edge.setEdgeId(rel.get("id").asString(null)); + edge.setLabel(rel.type()); + edge.setSourceNodeId(source.get("id").asString(null)); + edge.setTargetNodeId(target.get("id").asString(null)); + edge.setDescription(rel.get("description").asString(null)); + edge.setKnowledgeId(rel.get("knowledgeId").asString(null)); + edge.setDocIds(rel.get("docIds").asString(null)); + + // 处理 weight(可能为空) + if (rel.containsKey("weight") && !rel.get("weight").isNull()) { + edge.setWeight(rel.get("weight").asDouble()); + } + + // 处理 confidence(可能为空) + if (rel.containsKey("confidence") && !rel.get("confidence").isNull()) { + edge.setConfidence(rel.get("confidence").asDouble()); + } + + // 处理 properties(转换为JSON字符串) + if (rel.containsKey("properties") && !rel.get("properties").isNull()) { + Map propsMap = rel.get("properties").asMap(); + edge.setProperties(JSON.toJSONString(propsMap)); + } + + return edge; + } + + // ==================== 新增的方法实现 ==================== + + @Override + public List queryVerticesByKnowledgeId(String knowledgeId, Integer limit) { + List vertices = new ArrayList<>(); + + try (Session session = neo4jDriver.session()) { + String cypher = "MATCH (n {knowledgeId: $knowledgeId}) " + + "RETURN n LIMIT $limit"; + + Result result = session.run(cypher, parameters( + "knowledgeId", knowledgeId, + "limit", limit + )); + + result.stream().forEach(record -> { + Node node = record.get("n").asNode(); + vertices.add(nodeToVertex(node)); + }); + + log.info("查询到 {} 个节点,知识库ID: {}", vertices.size(), knowledgeId); + return vertices; + } catch (Exception e) { + log.error("查询节点失败", e); + return vertices; + } + } + + @Override + public List queryEdgesByKnowledgeId(String knowledgeId, Integer limit) { + List edges = new ArrayList<>(); + + try (Session session = neo4jDriver.session()) { + // ⭐ 修复:通过节点的 knowledgeId 过滤关系,兼容旧数据 + String cypher = "MATCH (s {knowledgeId: $knowledgeId})-[r]->(t {knowledgeId: $knowledgeId}) " + + "RETURN s, r, t LIMIT $limit"; + + log.info("🔍 开始查询关系 - knowledgeId: {}, limit: {}", knowledgeId, limit); + log.debug("执行Cypher: {}", cypher); + + Result result = session.run(cypher, parameters( + "knowledgeId", knowledgeId, + "limit", limit + )); + + int count = 0; + while (result.hasNext()) { + Record record = result.next(); + Node source = record.get("s").asNode(); + Relationship rel = record.get("r").asRelationship(); + Node target = record.get("t").asNode(); + + // 调试:打印关系详情 + if (count < 3) { // 只打印前3个 + log.debug("关系#{} - 类型: {}, 起点: {} ({}), 终点: {} ({})", + count + 1, + rel.type(), + source.get("name").asString(), + source.get("id").asString(), + target.get("name").asString(), + target.get("id").asString() + ); + } + + edges.add(relationshipToEdge(source, rel, target)); + count++; + } + + log.info("✅ 查询到 {} 个关系,知识库ID: {}", edges.size(), knowledgeId); + return edges; + } catch (Exception e) { + log.error("❌ 查询关系失败 - knowledgeId: {}", knowledgeId, e); + return edges; + } + } + + @Override + public boolean deleteByKnowledgeId(String knowledgeId) { + try (Session session = neo4jDriver.session()) { + log.info("🗑️ 开始删除知识库图谱数据,knowledgeId: {}", knowledgeId); + + // ⭐ 先删除关系(通过节点的knowledgeId过滤,兼容旧数据) + String deleteRelsQuery = "MATCH (s {knowledgeId: $knowledgeId})-[r]->(t {knowledgeId: $knowledgeId}) DELETE r"; + Result relResult = session.run(deleteRelsQuery, parameters("knowledgeId", knowledgeId)); + int deletedRels = relResult.consume().counters().relationshipsDeleted(); + log.info("✅ 删除了 {} 个关系", deletedRels); + + // 再删除节点 + String deleteNodesQuery = "MATCH (n {knowledgeId: $knowledgeId}) DELETE n"; + Result nodeResult = session.run(deleteNodesQuery, parameters("knowledgeId", knowledgeId)); + int deletedNodes = nodeResult.consume().counters().nodesDeleted(); + log.info("✅ 删除了 {} 个节点", deletedNodes); + + log.info("✅ 删除知识库图谱数据成功,knowledgeId: {}, 节点: {}, 关系: {}", + knowledgeId, deletedNodes, deletedRels); + return true; + } catch (Exception e) { + log.error("❌ 删除知识库图谱数据失败,knowledgeId: {}", knowledgeId, e); + return false; + } + } + + @Override + public Map getStatistics(String knowledgeId) { + Map stats = new HashMap<>(); + + try (Session session = neo4jDriver.session()) { + // 统计节点数 + String nodeCountQuery = "MATCH (n {knowledgeId: $knowledgeId}) RETURN count(n) as count"; + Result nodeResult = session.run(nodeCountQuery, parameters("knowledgeId", knowledgeId)); + int nodeCount = 0; + if (nodeResult.hasNext()) { + nodeCount = nodeResult.single().get("count").asInt(); + stats.put("nodeCount", nodeCount); + stats.put("totalNodes", nodeCount); // ⭐ 前端需要的字段 + } + + // ⭐ 统计关系数(通过节点过滤,与查询/删除逻辑一致) + String relCountQuery = "MATCH (s {knowledgeId: $knowledgeId})-[r]->(t {knowledgeId: $knowledgeId}) RETURN count(r) as count"; + Result relResult = session.run(relCountQuery, parameters("knowledgeId", knowledgeId)); + int relCount = 0; + if (relResult.hasNext()) { + relCount = relResult.single().get("count").asInt(); + stats.put("relationshipCount", relCount); + stats.put("totalEdges", relCount); // ⭐ 前端需要的字段 + } + + // 统计节点类型分布 + String labelQuery = "MATCH (n {knowledgeId: $knowledgeId}) " + + "RETURN labels(n)[0] as label, count(*) as count " + + "ORDER BY count DESC LIMIT 10"; + Result labelResult = session.run(labelQuery, parameters("knowledgeId", knowledgeId)); + + Map labelDistribution = new HashMap<>(); + labelResult.stream().forEach(record -> { + String label = record.get("label").asString(); + int count = record.get("count").asInt(); + labelDistribution.put(label, count); + }); + stats.put("labelDistribution", labelDistribution); + stats.put("entityTypes", labelDistribution); // ⭐ 前端需要的字段 + + log.info("📊 获取图谱统计信息: knowledgeId={}, 节点={}, 关系={}, 类型={}", + knowledgeId, nodeCount, relCount, labelDistribution.size()); + return stats; + } catch (Exception e) { + log.error("❌ 获取统计信息失败: knowledgeId={}", knowledgeId, e); + return stats; + } + } + + @Override + public List getNeighbors(String nodeId, String knowledgeId, Integer limit) { + List neighbors = new ArrayList<>(); + + try (Session session = neo4jDriver.session()) { + String cypher; + Map params = new HashMap<>(); + params.put("nodeId", nodeId); + params.put("limit", limit); + + if (knowledgeId != null && !knowledgeId.isEmpty()) { + cypher = "MATCH (n {id: $nodeId, knowledgeId: $knowledgeId})-[]-(neighbor {knowledgeId: $knowledgeId}) " + + "RETURN DISTINCT neighbor LIMIT $limit"; + params.put("knowledgeId", knowledgeId); + } else { + cypher = "MATCH (n {id: $nodeId})-[]-(neighbor) " + + "RETURN DISTINCT neighbor LIMIT $limit"; + } + + Result result = session.run(cypher, params); + + result.stream().forEach(record -> { + Node node = record.get("neighbor").asNode(); + neighbors.add(nodeToVertex(node)); + }); + + log.info("查询到 {} 个邻居节点", neighbors.size()); + return neighbors; + } catch (Exception e) { + log.error("查询邻居节点失败", e); + return neighbors; + } + } + + @Override + public List> findPaths(String startNodeId, String endNodeId, Integer maxDepth) { + List> paths = new ArrayList<>(); + + try (Session session = neo4jDriver.session()) { + String cypher = "MATCH path = (start {id: $startNodeId})-[*1.." + maxDepth + "]-(end {id: $endNodeId}) " + + "RETURN nodes(path) as pathNodes " + + "LIMIT 10"; + + Result result = session.run(cypher, parameters( + "startNodeId", startNodeId, + "endNodeId", endNodeId + )); + + result.stream().forEach(record -> { + List pathNodes = record.get("pathNodes").asList(); + List path = new ArrayList<>(); + + for (Object obj : pathNodes) { + if (obj instanceof Node) { + path.add(nodeToVertex((Node) obj)); + } + } + + if (!path.isEmpty()) { + paths.add(path); + } + }); + + log.info("查询到 {} 条路径", paths.size()); + return paths; + } catch (Exception e) { + log.error("查询路径失败", e); + return paths; + } + } +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/llm/IGraphLLMService.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/llm/IGraphLLMService.java new file mode 100644 index 00000000..c3610d34 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/llm/IGraphLLMService.java @@ -0,0 +1,32 @@ +package org.ruoyi.graph.service.llm; + +import org.ruoyi.domain.vo.ChatModelVo; + +/** + * 图谱LLM服务接口 + * 参考 ruoyi-chat 的 IChatService 设计 + * 支持多种LLM模型(OpenAI、Qwen、Zhipu等) + * + * @author ruoyi + * @date 2025-10-11 + */ +public interface IGraphLLMService { + + /** + * 调用LLM进行图谱实体关系抽取 + * + * @param prompt 提示词(包含文本和抽取指令) + * @param chatModel 模型配置 + * @return LLM响应文本 + */ + String extractGraph(String prompt, ChatModelVo chatModel); + + /** + * 获取此服务支持的模型类别 + * 例如: "openai", "qwen", "zhipu", "ollama" + * + * @return 模型类别标识 + */ + String getCategory(); +} + diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/llm/impl/DeepSeekGraphLLMServiceImpl.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/llm/impl/DeepSeekGraphLLMServiceImpl.java new file mode 100644 index 00000000..fbfb19aa --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/llm/impl/DeepSeekGraphLLMServiceImpl.java @@ -0,0 +1,85 @@ +package org.ruoyi.graph.service.llm.impl; + +import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; +import dev.langchain4j.model.openai.OpenAiStreamingChatModel; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.domain.vo.ChatModelVo; +import org.ruoyi.graph.service.llm.IGraphLLMService; +import org.springframework.stereotype.Service; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** + * DeepSeek 图谱LLM服务实现 + * 支持 DeepSeek 系列模型 + * + * 注意:使用 langchain4j 的 OpenAiStreamingChatModel,通过 CompletableFuture 转换为同步调用 + * 参考 DeepSeekChatImpl 的实现,但改为同步模式 + * + * @author ruoyi + * @date 2025-10-13 + */ +@Slf4j +@Service +public class DeepSeekGraphLLMServiceImpl implements IGraphLLMService { + + @Override + public String extractGraph(String prompt, ChatModelVo chatModel) { + log.info("DeepSeek模型调用: model={}, apiHost={}, 提示词长度={}", + chatModel.getModelName(), chatModel.getApiHost(), prompt.length()); + + try { + // 使用 langchain4j 的 OpenAiStreamingChatModel(参考 DeepSeekChatImpl) + StreamingChatModel streamingModel = OpenAiStreamingChatModel.builder() + .baseUrl(chatModel.getApiHost()) + .apiKey(chatModel.getApiKey()) + .modelName(chatModel.getModelName()) + .temperature(0.8) + .logRequests(false) + .logResponses(false) + .build(); + + // 用于收集完整响应 + StringBuilder fullResponse = new StringBuilder(); + CompletableFuture responseFuture = new CompletableFuture<>(); + + // 发送流式消息,但通过 CompletableFuture 转换为同步 + long startTime = System.currentTimeMillis(); + streamingModel.chat(prompt, new StreamingChatResponseHandler() { + @Override + public void onPartialResponse(String partialResponse) { + fullResponse.append(partialResponse); + } + + @Override + public void onCompleteResponse(ChatResponse completeResponse) { + long duration = System.currentTimeMillis() - startTime; + String responseText = fullResponse.toString(); + log.info("DeepSeek模型响应成功: 耗时={}ms, 响应长度={}", duration, responseText.length()); + responseFuture.complete(responseText); + } + + @Override + public void onError(Throwable error) { + log.error("DeepSeek模型调用错误: {}", error.getMessage()); + responseFuture.completeExceptionally(error); + } + }); + + // 同步等待结果(最多2分钟) + return responseFuture.get(2, TimeUnit.MINUTES); + + } catch (Exception e) { + log.error("DeepSeek模型调用失败: {}", e.getMessage(), e); + throw new RuntimeException("DeepSeek模型调用失败: " + e.getMessage(), e); + } + } + + @Override + public String getCategory() { + return "deepseek"; // 对应 ChatModel 表中的 category 字段 + } +} diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/llm/impl/DifyGraphLLMServiceImpl.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/llm/impl/DifyGraphLLMServiceImpl.java new file mode 100644 index 00000000..0ac3bb6a --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/llm/impl/DifyGraphLLMServiceImpl.java @@ -0,0 +1,107 @@ +package org.ruoyi.graph.service.llm.impl; + +import io.github.imfangs.dify.client.DifyClient; +import io.github.imfangs.dify.client.DifyClientFactory; +import io.github.imfangs.dify.client.callback.ChatStreamCallback; +import io.github.imfangs.dify.client.enums.ResponseMode; +import io.github.imfangs.dify.client.event.ErrorEvent; +import io.github.imfangs.dify.client.event.MessageEndEvent; +import io.github.imfangs.dify.client.event.MessageEvent; +import io.github.imfangs.dify.client.model.DifyConfig; +import io.github.imfangs.dify.client.model.chat.ChatMessage; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.domain.vo.ChatModelVo; +import org.ruoyi.graph.service.llm.IGraphLLMService; +import org.springframework.stereotype.Service; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** + * Dify 图谱LLM服务实现 + * 支持 Dify 平台的对话模型 + * + * 注意:Dify 使用流式调用,通过 CompletableFuture 实现同步等待 + * + * @author ruoyi + * @date 2025-10-11 + */ +@Slf4j +@Service +public class DifyGraphLLMServiceImpl implements IGraphLLMService { + + @Override + public String extractGraph(String prompt, ChatModelVo chatModel) { + log.info("Dify模型调用: model={}, apiHost={}, 提示词长度={}", + chatModel.getModelName(), chatModel.getApiHost(), prompt.length()); + + try { + // 创建 Dify 客户端配置 + DifyConfig config = DifyConfig.builder() + .baseUrl(chatModel.getApiHost()) + .apiKey(chatModel.getApiKey()) + .connectTimeout(5000) + .readTimeout(120000) // 2分钟超时 + .writeTimeout(30000) + .build(); + + DifyClient chatClient = DifyClientFactory.createClient(config); + + // 创建聊天消息(使用流式模式) + ChatMessage message = ChatMessage.builder() + .query(prompt) + .user("graph-system") // 图谱系统用户 + .responseMode(ResponseMode.STREAMING) // 流式模式 + .build(); + + // 用于收集完整响应 + StringBuilder fullResponse = new StringBuilder(); + CompletableFuture responseFuture = new CompletableFuture<>(); + + // 发送流式消息 + long startTime = System.currentTimeMillis(); + chatClient.sendChatMessageStream(message, new ChatStreamCallback() { + @Override + public void onMessage(MessageEvent event) { + fullResponse.append(event.getAnswer()); + } + + @Override + public void onMessageEnd(MessageEndEvent event) { + long duration = System.currentTimeMillis() - startTime; + String responseText = fullResponse.toString(); + log.info("Dify模型响应成功: 耗时={}ms, 响应长度={}, messageId={}", + duration, responseText.length(), event.getMessageId()); + responseFuture.complete(responseText); + } + + @Override + public void onError(ErrorEvent event) { + log.error("Dify模型调用错误: {}", event.getMessage()); + responseFuture.completeExceptionally( + new RuntimeException("Dify调用错误: " + event.getMessage()) + ); + } + + @Override + public void onException(Throwable throwable) { + log.error("Dify模型调用异常: {}", throwable.getMessage(), throwable); + responseFuture.completeExceptionally(throwable); + } + }); + + // 同步等待结果(最多2分钟) + return responseFuture.get(2, TimeUnit.MINUTES); + + } catch (Exception e) { + log.error("Dify模型调用失败: {}", e.getMessage(), e); + throw new RuntimeException("Dify模型调用失败: " + e.getMessage(), e); + } + } + + @Override + public String getCategory() { + return "dify"; // 对应 ChatModel 表中的 category 字段 + } +} + diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/llm/impl/OpenAIGraphLLMServiceImpl.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/llm/impl/OpenAIGraphLLMServiceImpl.java new file mode 100644 index 00000000..8d7c7a31 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/llm/impl/OpenAIGraphLLMServiceImpl.java @@ -0,0 +1,78 @@ +package org.ruoyi.graph.service.llm.impl; + +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.chat.config.ChatConfig; +import org.ruoyi.common.chat.entity.chat.ChatCompletion; +import org.ruoyi.common.chat.entity.chat.ChatCompletionResponse; +import org.ruoyi.common.chat.entity.chat.Message; +import org.ruoyi.common.chat.openai.OpenAiStreamClient; +import org.ruoyi.domain.vo.ChatModelVo; +import org.ruoyi.graph.service.llm.IGraphLLMService; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; + +/** + * OpenAI 图谱LLM服务实现 + * 支持 OpenAI 兼容的模型(GPT-3.5, GPT-4, 自定义OpenAI兼容接口等) + * + * @author ruoyi + * @date 2025-10-11 + */ +@Slf4j +@Service +public class OpenAIGraphLLMServiceImpl implements IGraphLLMService { + + @Override + public String extractGraph(String prompt, ChatModelVo chatModel) { + log.info("OpenAI模型调用: model={}, apiHost={}, 提示词长度={}", + chatModel.getModelName(), chatModel.getApiHost(), prompt.length()); + + try { + // 创建 OpenAiStreamClient + OpenAiStreamClient client = ChatConfig.createOpenAiStreamClient( + chatModel.getApiHost(), + chatModel.getApiKey() + ); + + // 构建消息 + List messages = Collections.singletonList( + Message.builder() + .role(Message.Role.USER) + .content(prompt) + .build() + ); + + // 构建请求(非流式,同步调用) + ChatCompletion completion = ChatCompletion.builder() + .messages(messages) + .model(chatModel.getModelName()) + .stream(false) // 同步调用 + .build(); + + // 同步调用 LLM + long startTime = System.currentTimeMillis(); + ChatCompletionResponse response = client.chatCompletion(completion); + long duration = System.currentTimeMillis() - startTime; + + // 提取响应文本 + Object content = response.getChoices().get(0).getMessage().getContent(); + String responseText = content != null ? content.toString() : ""; + + log.info("OpenAI模型响应成功: 耗时={}ms, 响应长度={}", duration, responseText.length()); + + return responseText; + + } catch (Exception e) { + log.error("OpenAI模型调用失败: {}", e.getMessage(), e); + throw new RuntimeException("OpenAI模型调用失败: " + e.getMessage(), e); + } + } + + @Override + public String getCategory() { + return "openai"; // 对应 ChatModel 表中的 category 字段 + } +} + diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/llm/impl/QwenGraphLLMServiceImpl.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/llm/impl/QwenGraphLLMServiceImpl.java new file mode 100644 index 00000000..5eeaa47b --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/llm/impl/QwenGraphLLMServiceImpl.java @@ -0,0 +1,80 @@ +package org.ruoyi.graph.service.llm.impl; + +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.chat.config.ChatConfig; +import org.ruoyi.common.chat.entity.chat.ChatCompletion; +import org.ruoyi.common.chat.entity.chat.ChatCompletionResponse; +import org.ruoyi.common.chat.entity.chat.Message; +import org.ruoyi.common.chat.openai.OpenAiStreamClient; +import org.ruoyi.domain.vo.ChatModelVo; +import org.ruoyi.graph.service.llm.IGraphLLMService; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; + +/** + * 通义千问(Qwen)图谱LLM服务实现 + * 支持阿里云通义千问系列模型 + * + * 注意:通义千问的API与OpenAI兼容,因此可以复用 OpenAiStreamClient + * + * @author ruoyi + * @date 2025-10-11 + */ +@Slf4j +@Service +public class QwenGraphLLMServiceImpl implements IGraphLLMService { + + @Override + public String extractGraph(String prompt, ChatModelVo chatModel) { + log.info("Qwen模型调用: model={}, apiHost={}, 提示词长度={}", + chatModel.getModelName(), chatModel.getApiHost(), prompt.length()); + + try { + // 通义千问API与OpenAI兼容,可以直接使用 OpenAiStreamClient + OpenAiStreamClient client = ChatConfig.createOpenAiStreamClient( + chatModel.getApiHost(), + chatModel.getApiKey() + ); + + // 构建消息 + List messages = Collections.singletonList( + Message.builder() + .role(Message.Role.USER) + .content(prompt) + .build() + ); + + // 构建请求(非流式,同步调用) + ChatCompletion completion = ChatCompletion.builder() + .messages(messages) + .model(chatModel.getModelName()) + .stream(false) // 同步调用 + .build(); + + // 同步调用 LLM + long startTime = System.currentTimeMillis(); + ChatCompletionResponse response = client.chatCompletion(completion); + long duration = System.currentTimeMillis() - startTime; + + // 提取响应文本 + Object content = response.getChoices().get(0).getMessage().getContent(); + String responseText = content != null ? content.toString() : ""; + + log.info("Qwen模型响应成功: 耗时={}ms, 响应长度={}", duration, responseText.length()); + + return responseText; + + } catch (Exception e) { + log.error("Qwen模型调用失败: {}", e.getMessage(), e); + throw new RuntimeException("Qwen模型调用失败: " + e.getMessage(), e); + } + } + + @Override + public String getCategory() { + return "qwen"; // 对应 ChatModel 表中的 category 字段 + } +} + diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/llm/impl/ZhipuGraphLLMServiceImpl.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/llm/impl/ZhipuGraphLLMServiceImpl.java new file mode 100644 index 00000000..b23552c5 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/service/llm/impl/ZhipuGraphLLMServiceImpl.java @@ -0,0 +1,80 @@ +package org.ruoyi.graph.service.llm.impl; + +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.chat.config.ChatConfig; +import org.ruoyi.common.chat.entity.chat.ChatCompletion; +import org.ruoyi.common.chat.entity.chat.ChatCompletionResponse; +import org.ruoyi.common.chat.entity.chat.Message; +import org.ruoyi.common.chat.openai.OpenAiStreamClient; +import org.ruoyi.domain.vo.ChatModelVo; +import org.ruoyi.graph.service.llm.IGraphLLMService; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; + +/** + * 智谱AI(Zhipu)图谱LLM服务实现 + * 支持智谱AI系列模型(GLM-4等) + * + * 注意:智谱AI的API与OpenAI兼容,因此可以复用 OpenAiStreamClient + * + * @author ruoyi + * @date 2025-10-11 + */ +@Slf4j +@Service +public class ZhipuGraphLLMServiceImpl implements IGraphLLMService { + + @Override + public String extractGraph(String prompt, ChatModelVo chatModel) { + log.info("Zhipu模型调用: model={}, apiHost={}, 提示词长度={}", + chatModel.getModelName(), chatModel.getApiHost(), prompt.length()); + + try { + // 智谱AI API与OpenAI兼容,可以直接使用 OpenAiStreamClient + OpenAiStreamClient client = ChatConfig.createOpenAiStreamClient( + chatModel.getApiHost(), + chatModel.getApiKey() + ); + + // 构建消息 + List messages = Collections.singletonList( + Message.builder() + .role(Message.Role.USER) + .content(prompt) + .build() + ); + + // 构建请求(非流式,同步调用) + ChatCompletion completion = ChatCompletion.builder() + .messages(messages) + .model(chatModel.getModelName()) + .stream(false) // 同步调用 + .build(); + + // 同步调用 LLM + long startTime = System.currentTimeMillis(); + ChatCompletionResponse response = client.chatCompletion(completion); + long duration = System.currentTimeMillis() - startTime; + + // 提取响应文本 + Object content = response.getChoices().get(0).getMessage().getContent(); + String responseText = content != null ? content.toString() : ""; + + log.info("Zhipu模型响应成功: 耗时={}ms, 响应长度={}", duration, responseText.length()); + + return responseText; + + } catch (Exception e) { + log.error("Zhipu模型调用失败: {}", e.getMessage(), e); + throw new RuntimeException("Zhipu模型调用失败: " + e.getMessage(), e); + } + } + + @Override + public String getCategory() { + return "zhipu"; // 对应 ChatModel 表中的 category 字段 + } +} + diff --git a/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/util/Neo4jTestUtil.java b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/util/Neo4jTestUtil.java new file mode 100644 index 00000000..23b0fee8 --- /dev/null +++ b/ruoyi-modules/ruoyi-graph/src/main/java/org/ruoyi/graph/util/Neo4jTestUtil.java @@ -0,0 +1,367 @@ +package org.ruoyi.graph.util; + +import lombok.extern.slf4j.Slf4j; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.Result; +import org.neo4j.driver.Session; +import org.neo4j.driver.types.Node; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +import static org.neo4j.driver.Values.parameters; + +/** + * Neo4j连接测试工具类 + * + * @author ruoyi + * @date 2025-09-30 + */ +@Slf4j +@Component +public class Neo4jTestUtil { + + private final Driver driver; + + public Neo4jTestUtil(Driver driver) { + this.driver = driver; + } + + /** + * 测试Neo4j连接 + * + * @return 测试结果 + */ + public Map testConnection() { + Map result = new HashMap<>(); + + try (Session session = driver.session()) { + // 1. 测试基本连接 + Result pingResult = session.run("RETURN 1 as num"); + if (pingResult.hasNext()) { + Record record = pingResult.single(); + result.put("connection", "SUCCESS"); + result.put("pingResult", record.get("num").asInt()); + } + + // 2. 获取数据库信息 + Result dbInfoResult = session.run("CALL dbms.components() YIELD name, versions, edition"); + if (dbInfoResult.hasNext()) { + Record dbInfo = dbInfoResult.single(); + result.put("neo4jVersion", dbInfo.get("versions").asList().get(0)); + result.put("neo4jEdition", dbInfo.get("edition").asString()); + } + + // 3. 测试节点数量 + Result countResult = session.run("MATCH (n) RETURN count(n) as count"); + if (countResult.hasNext()) { + result.put("totalNodes", countResult.single().get("count").asInt()); + } + + log.info("Neo4j连接测试成功: {}", result); + return result; + + } catch (Exception e) { + log.error("Neo4j连接测试失败", e); + result.put("connection", "FAILED"); + result.put("error", e.getMessage()); + return result; + } + } + + /** + * 创建测试节点 + * + * @param name 节点名称 + * @return 创建的节点信息 + */ + public Map createTestNode(String name) { + Map result = new HashMap<>(); + + try (Session session = driver.session()) { + Result queryResult = session.run( + "CREATE (p:TestNode {name: $name, createTime: datetime()}) RETURN p", + parameters("name", name) + ); + + if (queryResult.hasNext()) { + Record record = queryResult.single(); + Node node = record.get("p").asNode(); + + result.put("success", true); + result.put("nodeId", node.elementId()); + result.put("labels", node.labels()); + result.put("properties", node.asMap()); + + log.info("测试节点创建成功: {}", result); + } + + return result; + + } catch (Exception e) { + log.error("创建测试节点失败", e); + result.put("success", false); + result.put("error", e.getMessage()); + return result; + } + } + + /** + * 查询测试节点 + * + * @param name 节点名称 + * @return 节点信息 + */ + public Map queryTestNode(String name) { + Map result = new HashMap<>(); + + try (Session session = driver.session()) { + Result queryResult = session.run( + "MATCH (p:TestNode {name: $name}) RETURN p", + parameters("name", name) + ); + + if (queryResult.hasNext()) { + Record record = queryResult.single(); + Node node = record.get("p").asNode(); + + result.put("found", true); + result.put("nodeId", node.elementId()); + result.put("properties", node.asMap()); + } else { + result.put("found", false); + } + + return result; + + } catch (Exception e) { + log.error("查询测试节点失败", e); + result.put("error", e.getMessage()); + return result; + } + } + + /** + * 删除所有测试节点 + * + * @return 删除的节点数量 + */ + public Map deleteAllTestNodes() { + Map result = new HashMap<>(); + + try (Session session = driver.session()) { + Result queryResult = session.run( + "MATCH (p:TestNode) DELETE p RETURN count(p) as deleted" + ); + + if (queryResult.hasNext()) { + int deleted = queryResult.single().get("deleted").asInt(); + result.put("success", true); + result.put("deletedCount", deleted); + log.info("删除测试节点数量: {}", deleted); + } + + return result; + + } catch (Exception e) { + log.error("删除测试节点失败", e); + result.put("success", false); + result.put("error", e.getMessage()); + return result; + } + } + + /** + * 创建测试关系 + * + * @param sourceName 源节点名称 + * @param targetName 目标节点名称 + * @return 创建结果 + */ + public Map createTestRelationship(String sourceName, String targetName) { + Map result = new HashMap<>(); + + try (Session session = driver.session()) { + // 先创建两个节点 + session.run( + "MERGE (s:TestNode {name: $sourceName}) " + + "MERGE (t:TestNode {name: $targetName})", + parameters("sourceName", sourceName, "targetName", targetName) + ); + + // 创建关系 + Result queryResult = session.run( + "MATCH (s:TestNode {name: $sourceName}) " + + "MATCH (t:TestNode {name: $targetName}) " + + "CREATE (s)-[r:TEST_RELATION {createTime: datetime()}]->(t) " + + "RETURN r", + parameters("sourceName", sourceName, "targetName", targetName) + ); + + if (queryResult.hasNext()) { + result.put("success", true); + result.put("source", sourceName); + result.put("target", targetName); + log.info("测试关系创建成功: {} -> {}", sourceName, targetName); + } + + return result; + + } catch (Exception e) { + log.error("创建测试关系失败", e); + result.put("success", false); + result.put("error", e.getMessage()); + return result; + } + } + + /** + * 获取Neo4j统计信息 + * + * @return 统计信息 + */ + public Map getStatistics() { + Map result = new HashMap<>(); + + try (Session session = driver.session()) { + // 节点总数 + Result nodeCountResult = session.run("MATCH (n) RETURN count(n) as count"); + result.put("totalNodes", nodeCountResult.single().get("count").asInt()); + + // 关系总数 + Result relCountResult = session.run("MATCH ()-[r]->() RETURN count(r) as count"); + result.put("totalRelationships", relCountResult.single().get("count").asInt()); + + // 节点标签分布 + Result labelResult = session.run( + "MATCH (n) RETURN labels(n) as label, count(*) as count ORDER BY count DESC LIMIT 10" + ); + Map labelDistribution = new HashMap<>(); + labelResult.stream().forEach(record -> { + String label = record.get("label").asList().toString(); + int count = record.get("count").asInt(); + labelDistribution.put(label, count); + }); + result.put("labelDistribution", labelDistribution); + + log.info("Neo4j统计信息: {}", result); + return result; + + } catch (Exception e) { + log.error("获取统计信息失败", e); + result.put("error", e.getMessage()); + return result; + } + } + + /** + * 调试关系查询 - 详细诊断指定知识库的关系数据 + * + * @param knowledgeId 知识库ID + * @return 调试信息 + */ + public Map debugRelationships(String knowledgeId) { + Map result = new HashMap<>(); + + try (Session session = driver.session()) { + log.info("🔍 开始调试关系查询 - knowledgeId: {}", knowledgeId); + + // 1. 检查该知识库的节点数量 + Result nodeCountResult = session.run( + "MATCH (n {knowledgeId: $knowledgeId}) RETURN count(n) as count", + parameters("knowledgeId", knowledgeId) + ); + int nodeCount = nodeCountResult.single().get("count").asInt(); + result.put("nodeCount", nodeCount); + log.info("✅ 该知识库节点数量: {}", nodeCount); + + // 2. 检查总关系数量(不限制知识库) + Result totalRelResult = session.run("MATCH ()-[r]->() RETURN count(r) as count"); + int totalRelCount = totalRelResult.single().get("count").asInt(); + result.put("totalRelationships", totalRelCount); + log.info("✅ 数据库总关系数量: {}", totalRelCount); + + // 3. 检查带 knowledgeId 的关系数量(旧查询方式) + Result relWithKnowledgeIdResult = session.run( + "MATCH ()-[r {knowledgeId: $knowledgeId}]->() RETURN count(r) as count", + parameters("knowledgeId", knowledgeId) + ); + int relWithKnowledgeId = relWithKnowledgeIdResult.single().get("count").asInt(); + result.put("relationshipsWithKnowledgeId", relWithKnowledgeId); + log.info("✅ 带 knowledgeId 的关系数量: {}", relWithKnowledgeId); + + // 4. 通过节点过滤关系数量(新查询方式) + Result relByNodesResult = session.run( + "MATCH (s {knowledgeId: $knowledgeId})-[r]->(t {knowledgeId: $knowledgeId}) RETURN count(r) as count", + parameters("knowledgeId", knowledgeId) + ); + int relByNodes = relByNodesResult.single().get("count").asInt(); + result.put("relationshipsByNodes", relByNodes); + log.info("✅ 通过节点过滤的关系数量: {}", relByNodes); + + // 5. 采样前5个关系详情(如果有) + if (relByNodes > 0) { + Result sampleResult = session.run( + "MATCH (s {knowledgeId: $knowledgeId})-[r]->(t {knowledgeId: $knowledgeId}) " + + "RETURN s.name as sourceName, s.id as sourceId, type(r) as relType, " + + "t.name as targetName, t.id as targetId, r.knowledgeId as relKnowledgeId " + + "LIMIT 5", + parameters("knowledgeId", knowledgeId) + ); + + java.util.List> samples = new java.util.ArrayList<>(); + sampleResult.stream().forEach(record -> { + Map sample = new HashMap<>(); + sample.put("sourceName", record.get("sourceName").asString()); + sample.put("sourceId", record.get("sourceId").asString()); + sample.put("relationType", record.get("relType").asString()); + sample.put("targetName", record.get("targetName").asString()); + sample.put("targetId", record.get("targetId").asString()); + sample.put("relationshipKnowledgeId", + record.get("relKnowledgeId").isNull() ? null : record.get("relKnowledgeId").asString()); + samples.add(sample); + }); + result.put("sampleRelationships", samples); + log.info("✅ 采样到 {} 个关系", samples.size()); + } + + // 6. 检查是否有孤立节点(没有任何关系的节点) + Result isolatedNodesResult = session.run( + "MATCH (n {knowledgeId: $knowledgeId}) " + + "WHERE NOT (n)-[]-() " + + "RETURN count(n) as count", + parameters("knowledgeId", knowledgeId) + ); + int isolatedNodes = isolatedNodesResult.single().get("count").asInt(); + result.put("isolatedNodes", isolatedNodes); + log.info("✅ 孤立节点数量: {}", isolatedNodes); + + // 7. 诊断结论 + Map diagnosis = new HashMap<>(); + if (nodeCount == 0) { + diagnosis.put("issue", "该知识库没有节点数据"); + diagnosis.put("solution", "请先构建图谱或检查 knowledgeId 是否正确"); + } else if (relByNodes == 0 && totalRelCount > 0) { + diagnosis.put("issue", "数据库中有关系,但该知识库查询不到"); + diagnosis.put("solution", "可能是 knowledgeId 不匹配,检查关系的 knowledgeId 属性"); + } else if (relByNodes == 0) { + diagnosis.put("issue", "该知识库没有关系数据"); + diagnosis.put("solution", "检查 LLM 抽取是否识别到实体关系"); + } else { + diagnosis.put("status", "正常"); + diagnosis.put("message", "关系数据正常,可以正常查询"); + } + result.put("diagnosis", diagnosis); + + log.info("🎯 调试完成: {}", diagnosis); + return result; + + } catch (Exception e) { + log.error("❌ 调试关系查询失败", e); + result.put("error", e.getMessage()); + return result; + } + } +} diff --git a/script/docker/neo4j/docker-compose.yml b/script/docker/neo4j/docker-compose.yml new file mode 100644 index 00000000..4717078d --- /dev/null +++ b/script/docker/neo4j/docker-compose.yml @@ -0,0 +1,65 @@ +version: '3.8' + +services: + neo4j: + image: neo4j:5.15.0 + container_name: ruoyi-neo4j + restart: unless-stopped + ports: + # HTTP端口 + - "7474:7474" + # HTTPS端口 + - "7473:7473" + # Bolt端口 + - "7687:7687" + environment: + # 初始密码设置(首次启动后需要修改) + - NEO4J_AUTH=neo4j/your_password + # 接受许可协议 + - NEO4J_ACCEPT_LICENSE_AGREEMENT=yes + # 内存配置(根据服务器配置调整) + - NEO4J_dbms_memory_heap_initial__size=512m + - NEO4J_dbms_memory_heap_max__size=2g + - NEO4J_dbms_memory_pagecache_size=1g + # 事务日志配置 + - NEO4J_dbms_tx__log_rotation_retention__policy=3 days + # 允许从任何主机连接 + - NEO4J_dbms_default__listen__address=0.0.0.0 + # 启用APOC插件 + - NEO4J_dbms_security_procedures_unrestricted=apoc.* + - NEO4J_dbms_security_procedures_allowlist=apoc.* + # 日志级别 + - NEO4J_dbms_logs_debug_level=INFO + volumes: + # 数据持久化 + - neo4j_data:/data + # 日志持久化 + - neo4j_logs:/logs + # 导入目录 + - neo4j_import:/var/lib/neo4j/import + # 插件目录 + - neo4j_plugins:/plugins + networks: + - ruoyi-network + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider localhost:7474 || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 40s + +volumes: + neo4j_data: + name: ruoyi-neo4j-data + neo4j_logs: + name: ruoyi-neo4j-logs + neo4j_import: + name: ruoyi-neo4j-import + neo4j_plugins: + name: ruoyi-neo4j-plugins + +networks: + ruoyi-network: + name: ruoyi-network + driver: bridge + diff --git a/script/sql/graph_menu.sql b/script/sql/graph_menu.sql new file mode 100644 index 00000000..da15556c --- /dev/null +++ b/script/sql/graph_menu.sql @@ -0,0 +1,274 @@ +-- ============================================= +-- 知识图谱菜单配置SQL +-- 执行此脚本后,图谱管理菜单将显示在系统中 +-- ============================================= + +-- 注意:请根据实际情况修改以下内容: +-- 1. parent_id: 运营管理的菜单ID(需要先查询获取) +-- 2. order_num: 菜单排序号 +-- 3. create_by: 创建人 + +-- ============================================= +-- 第一步:查询运营管理的菜单ID +-- ============================================= +-- SELECT menu_id FROM sys_menu WHERE menu_name = '运营管理' AND parent_id = 0; +-- 假设查询结果为: 2000(请根据实际情况修改) + +SET @operator_menu_id = (SELECT menu_id FROM sys_menu WHERE menu_name = '运营管理' AND parent_id = 0 LIMIT 1); + +-- ============================================= +-- 第二步:插入图谱管理目录 +-- ============================================= +INSERT INTO sys_menu ( + menu_id, + menu_name, + parent_id, + order_num, + path, + component, + is_frame, + is_cache, + menu_type, + visible, + status, + perms, + icon, + create_dept, + create_by, + create_time, + update_by, + update_time, + remark +) VALUES ( + 1950000000000000001, -- 菜单ID(使用雪花ID规则) + '图谱管理', -- 菜单名称 + @operator_menu_id, -- 父菜单ID(运营管理) + 15, -- 排序号(在知识库管理之后) + 'graph', -- 路由地址 + NULL, -- 组件路径(目录为空) + 1, -- 是否外链(0否 1是) + 0, -- 是否缓存(0缓存 1不缓存) + 'M', -- 菜单类型(M目录 C菜单 F按钮) + '0', -- 显示状态(0显示 1隐藏) + '0', -- 菜单状态(0正常 1停用) + NULL, -- 权限标识 + 'carbon:chart-relationship', -- 菜单图标 + 103, -- 创建部门 + 1, -- 创建者(用户ID) + NOW(), -- 创建时间 + 1, -- 更新者(用户ID) + NOW(), -- 更新时间 + '知识图谱管理目录' -- 备注 +); + +-- 设置图谱管理目录ID +SET @graph_menu_id = 1950000000000000001; + +-- ============================================= +-- 第三步:插入图谱实例管理菜单 +-- ============================================= +INSERT INTO sys_menu ( + menu_id, + menu_name, + parent_id, + order_num, + path, + component, + is_frame, + is_cache, + menu_type, + visible, + status, + perms, + icon, + create_dept, + create_by, + create_time, + update_by, + update_time, + remark +) VALUES ( + 1950000000000000002, -- 菜单ID + '图谱实例', -- 菜单名称 + @graph_menu_id, -- 父菜单ID(图谱管理) + 1, -- 排序号 + 'graphInstance', -- 路由地址 + 'operator/graphInstance/index', -- 组件路径 + 1, -- 是否外链 + 0, -- 是否缓存 + 'C', -- 菜单类型 + '0', -- 显示状态 + '0', -- 菜单状态 + 'operator:graph:list', -- 权限标识 + 'ant-design:node-index-outlined', -- 菜单图标 + 103, -- 创建部门 + 1, -- 创建者(用户ID) + NOW(), -- 创建时间 + 1, -- 更新者(用户ID) + NOW(), -- 更新时间 + '图谱实例管理菜单' -- 备注 +); + +-- 设置图谱实例菜单ID +SET @graph_instance_menu_id = 1950000000000000002; + +-- ============================================= +-- 第四步:插入图谱实例管理的按钮权限 +-- ============================================= + +-- 查询按钮 +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, remark) +VALUES (1950000000000000003, '图谱实例查询', @graph_instance_menu_id, 1, '#', '', 1, 0, 'F', '0', '0', 'operator:graph:query', '#', 103, 1, NOW(), ''); + +-- 新增按钮 +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, remark) +VALUES (1950000000000000004, '图谱实例新增', @graph_instance_menu_id, 2, '#', '', 1, 0, 'F', '0', '0', 'operator:graph:add', '#', 103, 1, NOW(), ''); + +-- 编辑按钮 +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, remark) +VALUES (1950000000000000005, '图谱实例编辑', @graph_instance_menu_id, 3, '#', '', 1, 0, 'F', '0', '0', 'operator:graph:edit', '#', 103, 1, NOW(), ''); + +-- 删除按钮 +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, remark) +VALUES (1950000000000000006, '图谱实例删除', @graph_instance_menu_id, 4, '#', '', 1, 0, 'F', '0', '0', 'operator:graph:remove', '#', 103, 1, NOW(), ''); + +-- 导出按钮 +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, remark) +VALUES (1950000000000000007, '图谱实例导出', @graph_instance_menu_id, 5, '#', '', 1, 0, 'F', '0', '0', 'operator:graph:export', '#', 103, 1, NOW(), ''); + +-- 构建按钮 +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, remark) +VALUES (1950000000000000008, '图谱构建', @graph_instance_menu_id, 6, '#', '', 1, 0, 'F', '0', '0', 'operator:graph:build', '#', 103, 1, NOW(), ''); + +-- 重建按钮 +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, remark) +VALUES (1950000000000000009, '图谱重建', @graph_instance_menu_id, 7, '#', '', 1, 0, 'F', '0', '0', 'operator:graph:rebuild', '#', 103, 1, NOW(), ''); + +-- ============================================= +-- 第五步:插入图谱可视化菜单 +-- ============================================= +INSERT INTO sys_menu ( + menu_id, + menu_name, + parent_id, + order_num, + path, + component, + is_frame, + is_cache, + menu_type, + visible, + status, + perms, + icon, + create_dept, + create_by, + create_time, + update_by, + update_time, + remark +) VALUES ( + 1950000000000000010, -- 菜单ID + '图谱可视化', -- 菜单名称 + @graph_menu_id, -- 父菜单ID(图谱管理) + 2, -- 排序号 + 'graphVisualization', -- 路由地址 + 'operator/graphVisualization/index', -- 组件路径 + 1, -- 是否外链 + 0, -- 是否缓存 + 'C', -- 菜单类型 + '0', -- 显示状态 + '0', -- 菜单状态 + 'operator:graph:view', -- 权限标识 + 'carbon:chart-network', -- 菜单图标 + 103, -- 创建部门 + 1, -- 创建者(用户ID) + NOW(), -- 创建时间 + 1, -- 更新者(用户ID) + NOW(), -- 更新时间 + '图谱可视化菜单' -- 备注 +); + +-- ============================================= +-- 第六步:插入图谱检索测试菜单 +-- ============================================= +INSERT INTO sys_menu ( + menu_id, + menu_name, + parent_id, + order_num, + path, + component, + is_frame, + is_cache, + menu_type, + visible, + status, + perms, + icon, + create_dept, + create_by, + create_time, + update_by, + update_time, + remark +) VALUES ( + 1950000000000000011, -- 菜单ID + '图谱检索测试', -- 菜单名称 + @graph_menu_id, -- 父菜单ID(图谱管理) + 3, -- 排序号 + 'graphRAG', -- 路由地址 + 'operator/graphRAG/index', -- 组件路径 + 1, -- 是否外链 + 0, -- 是否缓存 + 'C', -- 菜单类型 + '0', -- 显示状态 + '0', -- 菜单状态 + 'operator:graph:retrieve', -- 权限标识 + 'carbon:search-advanced', -- 菜单图标 + 103, -- 创建部门 + 1, -- 创建者(用户ID) + NOW(), -- 创建时间 + 1, -- 更新者(用户ID) + NOW(), -- 更新时间 + '图谱检索测试菜单' -- 备注 +); + +-- 设置图谱检索测试菜单ID +SET @graph_rag_menu_id = 1950000000000000011; + +-- ============================================= +-- 第七步:插入图谱检索测试的按钮权限 +-- ============================================= + +-- 实体抽取按钮 +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, remark) +VALUES (1950000000000000012, '实体抽取', @graph_rag_menu_id, 1, '#', '', 1, 0, 'F', '0', '0', 'operator:graph:extract', '#', 103, 1, NOW(), ''); + +-- 文本入库按钮 +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, remark) +VALUES (1950000000000000013, '文本入库', @graph_rag_menu_id, 2, '#', '', 1, 0, 'F', '0', '0', 'operator:graph:ingest', '#', 103, 1, NOW(), ''); + +-- ============================================= +-- 完成提示 +-- ============================================= +SELECT '图谱管理菜单配置完成!' AS message; +SELECT '请刷新浏览器页面,菜单将显示在"运营管理"下' AS tip; + +-- ============================================= +-- 查询结果验证 +-- ============================================= +SELECT + m1.menu_name AS '一级菜单', + m2.menu_name AS '二级菜单', + m3.menu_name AS '三级菜单/按钮', + m3.perms AS '权限标识', + m3.path AS '路由地址', + m3.component AS '组件路径' +FROM sys_menu m1 +LEFT JOIN sys_menu m2 ON m2.parent_id = m1.menu_id +LEFT JOIN sys_menu m3 ON m3.parent_id = m2.menu_id +WHERE m1.menu_name = '运营管理' + AND m2.menu_name = '图谱管理' +ORDER BY m2.order_num, m3.order_num; + diff --git a/script/sql/knowledge_graph_schema.sql b/script/sql/knowledge_graph_schema.sql new file mode 100644 index 00000000..ed11ef1e --- /dev/null +++ b/script/sql/knowledge_graph_schema.sql @@ -0,0 +1,204 @@ +-- ======================================== +-- RuoYi AI 知识图谱数据库表结构 +-- ======================================== +-- 创建时间: 2025-09-30 +-- 说明: 知识图谱功能的MySQL表结构 +-- ======================================== + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ---------------------------- +-- 1. 知识图谱实例表 +-- ---------------------------- +DROP TABLE IF EXISTS `knowledge_graph_instance`; +CREATE TABLE `knowledge_graph_instance` ( + `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键', + `graph_uuid` VARCHAR(32) NOT NULL COMMENT '图谱UUID', + `knowledge_id` VARCHAR(50) NOT NULL COMMENT '关联knowledge_info.kid', + `graph_name` VARCHAR(100) NOT NULL COMMENT '图谱名称', + `graph_status` TINYINT(2) DEFAULT 10 COMMENT '构建状态:10构建中、20已完成、30失败', + `node_count` INT(11) DEFAULT 0 COMMENT '节点数量', + `relationship_count` INT(11) DEFAULT 0 COMMENT '关系数量', + `config` JSON COMMENT '图谱配置(JSON格式)', + `model_name` VARCHAR(100) DEFAULT NULL COMMENT 'LLM模型名称', + `entity_types` VARCHAR(500) DEFAULT NULL COMMENT '实体类型(逗号分隔)', + `relation_types` VARCHAR(500) DEFAULT NULL COMMENT '关系类型(逗号分隔)', + `error_message` TEXT COMMENT '错误信息', + `create_dept` BIGINT(20) DEFAULT NULL COMMENT '创建部门', + `create_by` VARCHAR(64) DEFAULT '' COMMENT '创建者', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` VARCHAR(64) DEFAULT '' COMMENT '更新者', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `del_flag` CHAR(1) DEFAULT '0' COMMENT '删除标志(0代表存在 1代表删除)', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_graph_uuid` (`graph_uuid`) USING BTREE, + KEY `idx_knowledge_id` (`knowledge_id`) USING BTREE, + KEY `idx_graph_status` (`graph_status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识图谱实例表'; + +-- ---------------------------- +-- 2. 实体类型定义表 +-- ---------------------------- +DROP TABLE IF EXISTS `graph_entity_type`; +CREATE TABLE `graph_entity_type` ( + `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键', + `type_name` VARCHAR(50) NOT NULL COMMENT '实体类型名称', + `type_code` VARCHAR(20) NOT NULL COMMENT '类型编码', + `description` VARCHAR(200) DEFAULT NULL COMMENT '描述', + `color` VARCHAR(10) DEFAULT '#1890ff' COMMENT '可视化颜色', + `icon` VARCHAR(50) DEFAULT NULL COMMENT '图标', + `sort` INT(4) DEFAULT 0 COMMENT '显示顺序', + `is_enable` TINYINT(1) DEFAULT 1 COMMENT '是否启用(0否 1是)', + `create_by` VARCHAR(64) DEFAULT '' COMMENT '创建者', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` VARCHAR(64) DEFAULT '' COMMENT '更新者', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_type_code` (`type_code`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图谱实体类型定义表'; + +-- ---------------------------- +-- 3. 关系类型定义表 +-- ---------------------------- +DROP TABLE IF EXISTS `graph_relation_type`; +CREATE TABLE `graph_relation_type` ( + `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键', + `relation_name` VARCHAR(50) NOT NULL COMMENT '关系名称', + `relation_code` VARCHAR(20) NOT NULL COMMENT '关系编码', + `description` VARCHAR(200) DEFAULT NULL COMMENT '描述', + `direction` TINYINT(1) DEFAULT 1 COMMENT '关系方向:0双向、1单向', + `style` JSON COMMENT '可视化样式(JSON格式)', + `sort` INT(4) DEFAULT 0 COMMENT '显示顺序', + `is_enable` TINYINT(1) DEFAULT 1 COMMENT '是否启用(0否 1是)', + `create_by` VARCHAR(64) DEFAULT '' COMMENT '创建者', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` VARCHAR(64) DEFAULT '' COMMENT '更新者', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_relation_code` (`relation_code`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图谱关系类型定义表'; + +-- ---------------------------- +-- 4. 图谱构建任务表 +-- ---------------------------- +DROP TABLE IF EXISTS `graph_build_task`; +CREATE TABLE `graph_build_task` ( + `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键', + `task_uuid` VARCHAR(32) NOT NULL COMMENT '任务UUID', + `graph_uuid` VARCHAR(32) NOT NULL COMMENT '图谱UUID', + `knowledge_id` VARCHAR(50) NOT NULL COMMENT '知识库ID', + `doc_id` VARCHAR(50) DEFAULT NULL COMMENT '文档ID(可选,null表示全量构建)', + `task_type` TINYINT(2) DEFAULT 1 COMMENT '任务类型:1全量构建、2增量更新、3重建', + `task_status` TINYINT(2) DEFAULT 1 COMMENT '任务状态:1待执行、2执行中、3成功、4失败', + `progress` INT(3) DEFAULT 0 COMMENT '进度百分比(0-100)', + `total_docs` INT(11) DEFAULT 0 COMMENT '总文档数', + `processed_docs` INT(11) DEFAULT 0 COMMENT '已处理文档数', + `extracted_entities` INT(11) DEFAULT 0 COMMENT '提取的实体数', + `extracted_relations` INT(11) DEFAULT 0 COMMENT '提取的关系数', + `error_message` TEXT COMMENT '错误信息', + `result_summary` JSON COMMENT '结果摘要(JSON格式)', + `start_time` DATETIME DEFAULT NULL COMMENT '开始时间', + `end_time` DATETIME DEFAULT NULL COMMENT '结束时间', + `create_dept` BIGINT(20) DEFAULT NULL COMMENT '创建部门', + `create_by` VARCHAR(64) DEFAULT '' COMMENT '创建者', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` VARCHAR(64) DEFAULT '' COMMENT '更新者', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_task_uuid` (`task_uuid`) USING BTREE, + KEY `idx_graph_uuid` (`graph_uuid`) USING BTREE, + KEY `idx_knowledge_id` (`knowledge_id`) USING BTREE, + KEY `idx_task_status` (`task_status`) USING BTREE, + KEY `idx_create_time` (`create_time`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图谱构建任务表'; + +-- ---------------------------- +-- 5. 图谱查询历史表 +-- ---------------------------- +DROP TABLE IF EXISTS `graph_query_history`; +CREATE TABLE `graph_query_history` ( + `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键', + `query_uuid` VARCHAR(32) NOT NULL COMMENT '查询UUID', + `user_id` BIGINT(20) NOT NULL COMMENT '用户ID', + `knowledge_id` VARCHAR(50) DEFAULT NULL COMMENT '知识库ID', + `graph_uuid` VARCHAR(32) DEFAULT NULL COMMENT '图谱UUID', + `query_text` TEXT NOT NULL COMMENT '查询文本', + `query_type` TINYINT(2) DEFAULT 1 COMMENT '查询类型:1实体查询、2关系查询、3路径查询、4混合查询', + `cypher_query` TEXT COMMENT '生成的Cypher查询', + `result_count` INT(11) DEFAULT 0 COMMENT '结果数量', + `response_time` INT(11) DEFAULT 0 COMMENT '响应时间(ms)', + `is_success` TINYINT(1) DEFAULT 1 COMMENT '是否成功(0否 1是)', + `error_message` TEXT COMMENT '错误信息', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_query_uuid` (`query_uuid`) USING BTREE, + KEY `idx_user_id` (`user_id`) USING BTREE, + KEY `idx_knowledge_id` (`knowledge_id`) USING BTREE, + KEY `idx_create_time` (`create_time`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图谱查询历史表'; + +-- ---------------------------- +-- 6. 图谱统计信息表 +-- ---------------------------- +DROP TABLE IF EXISTS `graph_statistics`; +CREATE TABLE `graph_statistics` ( + `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键', + `graph_uuid` VARCHAR(32) NOT NULL COMMENT '图谱UUID', + `stat_date` DATE NOT NULL COMMENT '统计日期', + `total_nodes` INT(11) DEFAULT 0 COMMENT '总节点数', + `total_relationships` INT(11) DEFAULT 0 COMMENT '总关系数', + `node_type_distribution` JSON COMMENT '节点类型分布(JSON格式)', + `relation_type_distribution` JSON COMMENT '关系类型分布(JSON格式)', + `query_count` INT(11) DEFAULT 0 COMMENT '查询次数', + `avg_query_time` INT(11) DEFAULT 0 COMMENT '平均查询时间(ms)', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_graph_date` (`graph_uuid`, `stat_date`) USING BTREE, + KEY `idx_stat_date` (`stat_date`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图谱统计信息表'; + +-- ---------------------------- +-- 初始化基础数据:实体类型 +-- ---------------------------- +INSERT INTO `graph_entity_type` (`type_name`, `type_code`, `description`, `color`, `icon`, `sort`) VALUES +('人物', 'PERSON', '人物实体,包括真实人物和虚拟角色', '#1890ff', 'user', 1), +('机构', 'ORGANIZATION', '组织机构,包括公司、政府机构等', '#52c41a', 'bank', 2), +('地点', 'LOCATION', '地理位置,包括国家、城市、地址等', '#fa8c16', 'environment', 3), +('概念', 'CONCEPT', '抽象概念,包括理论、方法等', '#722ed1', 'bulb', 4), +('事件', 'EVENT', '事件记录,包括历史事件、活动等', '#eb2f96', 'calendar', 5), +('产品', 'PRODUCT', '产品或服务', '#13c2c2', 'shopping', 6), +('技术', 'TECHNOLOGY', '技术或工具', '#2f54eb', 'tool', 7), +('文档', 'DOCUMENT', '文档或资料', '#faad14', 'file-text', 8); + +-- ---------------------------- +-- 初始化基础数据:关系类型 +-- ---------------------------- +INSERT INTO `graph_relation_type` (`relation_name`, `relation_code`, `description`, `direction`, `sort`) VALUES +('属于', 'BELONGS_TO', '隶属关系,表示从属或归属', 1, 1), +('位于', 'LOCATED_IN', '地理位置关系', 1, 2), +('相关', 'RELATED_TO', '一般关联关系', 0, 3), +('导致', 'CAUSES', '因果关系', 1, 4), +('包含', 'CONTAINS', '包含关系', 1, 5), +('提及', 'MENTIONS', '文档提及实体的关系', 1, 6), +('部分', 'PART_OF', '部分关系', 1, 7), +('实例', 'INSTANCE_OF', '实例关系', 1, 8), +('相似', 'SIMILAR_TO', '相似关系', 0, 9), +('前序', 'PRECEDES', '时序关系', 1, 10), +('工作于', 'WORKS_AT', '人物与机构的工作关系', 1, 11), +('创建', 'CREATED_BY', '创建关系', 1, 12), +('使用', 'USES', '使用关系', 1, 13); + +SET FOREIGN_KEY_CHECKS = 1; + +-- ---------------------------- +-- 完成 +-- ---------------------------- +-- 知识图谱数据库表结构创建完成 +-- 请执行以下命令应用到数据库: +-- mysql -u root -p ruoyi-ai < knowledge_graph_schema.sql +-- ---------------------------- diff --git a/script/sql/knowledge_graph_segment.sql b/script/sql/knowledge_graph_segment.sql new file mode 100644 index 00000000..ea555a7f --- /dev/null +++ b/script/sql/knowledge_graph_segment.sql @@ -0,0 +1,28 @@ +-- 知识图谱片段表 +-- 用于记录从文档中抽取图谱时的文本片段信息 +CREATE TABLE IF NOT EXISTS `knowledge_base_graph_segment` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `uuid` VARCHAR(64) NOT NULL COMMENT '片段UUID', + `kb_uuid` VARCHAR(64) NOT NULL COMMENT '知识库UUID', + `kb_item_uuid` VARCHAR(64) COMMENT '知识库条目UUID', + `doc_uuid` VARCHAR(64) COMMENT '文档UUID', + `segment_text` TEXT COMMENT '片段文本内容', + `chunk_index` INT DEFAULT 0 COMMENT '片段索引(第几个片段)', + `total_chunks` INT DEFAULT 1 COMMENT '总片段数', + `extraction_status` TINYINT DEFAULT 0 COMMENT '抽取状态:0-待处理 1-处理中 2-已完成 3-失败', + `entity_count` INT DEFAULT 0 COMMENT '抽取的实体数量', + `relation_count` INT DEFAULT 0 COMMENT '抽取的关系数量', + `token_used` INT DEFAULT 0 COMMENT '消耗的token数', + `error_message` VARCHAR(500) COMMENT '错误信息', + `user_id` BIGINT COMMENT '用户ID', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `remark` VARCHAR(500) COMMENT '备注', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_uuid` (`uuid`), + KEY `idx_kb_uuid` (`kb_uuid`), + KEY `idx_kb_item_uuid` (`kb_item_uuid`), + KEY `idx_doc_uuid` (`doc_uuid`), + KEY `idx_user_id` (`user_id`), + KEY `idx_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识图谱片段表'; diff --git a/script/sql/update/add_graph_build_task_fields.sql b/script/sql/update/add_graph_build_task_fields.sql new file mode 100644 index 00000000..4e4ab1c9 --- /dev/null +++ b/script/sql/update/add_graph_build_task_fields.sql @@ -0,0 +1,35 @@ +-- ======================================== +-- 为 graph_build_task 表添加缺失字段 +-- ======================================== +-- 执行日期: 2025-10-11 +-- 说明: 添加 create_dept 和 update_by 字段以符合 MyBatis-Plus BaseEntity 规范 +-- ======================================== + +-- 检查表是否存在 +SELECT 'Adding fields to graph_build_task table...' AS status; + +-- 添加 create_dept 字段(如果不存在) +ALTER TABLE `graph_build_task` +ADD COLUMN `create_dept` BIGINT(20) NULL COMMENT '创建部门' AFTER `end_time`; + +-- 添加 update_by 字段(如果已存在 create_by 但缺少 update_by) +-- 注意:update_by 应该在 create_time 之前 +ALTER TABLE `graph_build_task` +ADD COLUMN `update_by` VARCHAR(64) DEFAULT '' COMMENT '更新者' AFTER `create_by`; + +-- 验证字段是否添加成功 +SELECT 'Fields added successfully!' AS status; + +-- 查看表结构 +DESCRIBE `graph_build_task`; + +-- ======================================== +-- 说明 +-- ======================================== +-- create_dept: 创建部门ID,与创建者关联 +-- update_by: 更新者用户名或ID +-- +-- 这两个字段是 MyBatis-Plus BaseEntity 的标准字段 +-- 添加后可以正常使用自动填充功能 +-- ======================================== + diff --git a/script/sql/update/add_graph_instance_fields.sql b/script/sql/update/add_graph_instance_fields.sql new file mode 100644 index 00000000..c9c0c4e2 --- /dev/null +++ b/script/sql/update/add_graph_instance_fields.sql @@ -0,0 +1,16 @@ +-- 为 knowledge_graph_instance 表添加新字段 +-- 用于支持图谱实例管理的扩展功能 +-- 执行日期: 2025-01-11 + +-- 添加 LLM 模型名称字段 +ALTER TABLE knowledge_graph_instance +ADD COLUMN model_name VARCHAR(100) DEFAULT NULL COMMENT 'LLM模型名称' AFTER config; + +-- 添加实体类型字段 +ALTER TABLE knowledge_graph_instance +ADD COLUMN entity_types VARCHAR(500) DEFAULT NULL COMMENT '实体类型(逗号分隔)' AFTER model_name; + +-- 添加关系类型字段 +ALTER TABLE knowledge_graph_instance +ADD COLUMN relation_types VARCHAR(500) DEFAULT NULL COMMENT '关系类型(逗号分隔)' AFTER entity_types; + diff --git a/script/sql/update/fix_graph_visualization_menu.sql b/script/sql/update/fix_graph_visualization_menu.sql new file mode 100644 index 00000000..b6d8c1c3 --- /dev/null +++ b/script/sql/update/fix_graph_visualization_menu.sql @@ -0,0 +1,115 @@ +-- ============================================= +-- 修复图谱可视化菜单配置 +-- 日期: 2025-10-13 +-- 说明: 确保图谱可视化菜单正确配置,支持独立访问 +-- ============================================= + +-- 1. 检查图谱可视化菜单是否存在 +SELECT + menu_id, + menu_name, + parent_id, + path, + component, + visible, + status, + menu_type +FROM sys_menu +WHERE menu_name = '图谱可视化' OR path = 'graphVisualization'; + +-- 2. 如果菜单不存在,插入菜单 +-- 注意:如果已存在,此语句会因主键冲突而失败,这是正常的 +INSERT INTO sys_menu ( + menu_id, + menu_name, + parent_id, + order_num, + path, + component, + is_frame, + is_cache, + menu_type, + visible, + status, + perms, + icon, + create_dept, + create_by, + create_time, + update_by, + update_time, + remark +) +SELECT + 1950000000000000010, -- 菜单ID + '图谱可视化', -- 菜单名称 + (SELECT menu_id FROM sys_menu WHERE menu_name = '图谱管理' LIMIT 1), -- 父菜单ID + 2, -- 排序号 + 'graphVisualization', -- 路由地址 + 'operator/graphVisualization/index', -- 组件路径 + 1, -- 是否外链(1=否) + 0, -- 是否缓存(0=缓存) + 'C', -- 菜单类型(C=菜单) + '0', -- 显示状态(0=显示) + '0', -- 菜单状态(0=正常) + 'operator:graph:view', -- 权限标识 + 'carbon:chart-network', -- 菜单图标 + 103, -- 创建部门 + 1, -- 创建者(用户ID) + NOW(), -- 创建时间 + 1, -- 更新者(用户ID) + NOW(), -- 更新时间 + '图谱可视化菜单' -- 备注 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_menu WHERE menu_id = 1950000000000000010 +); + +-- 3. 更新现有菜单(如果已存在) +UPDATE sys_menu +SET + path = 'graphVisualization', + component = 'operator/graphVisualization/index', + visible = '0', + status = '0', + menu_type = 'C', + is_frame = 1, + is_cache = 0, + update_by = 1, + update_time = NOW() +WHERE menu_name = '图谱可视化'; + +-- 4. 验证菜单配置 +SELECT + menu_id, + menu_name, + parent_id, + path, + component, + visible AS '显示状态(0=显示)', + status AS '菜单状态(0=正常)', + menu_type AS '菜单类型(C=菜单)', + perms AS '权限标识' +FROM sys_menu +WHERE menu_name = '图谱可视化'; + +-- 5. 检查父菜单 +SELECT + m1.menu_id, + m1.menu_name, + m1.path, + m2.menu_name AS parent_name, + m2.path AS parent_path +FROM sys_menu m1 +LEFT JOIN sys_menu m2 ON m1.parent_id = m2.menu_id +WHERE m1.menu_name = '图谱可视化'; + +-- ============================================= +-- 执行说明 +-- ============================================= +-- 1. 在 MySQL 客户端或 Navicat 中执行此 SQL +-- 2. 检查输出,确认菜单配置正确 +-- 3. 重新登录系统以刷新菜单权限 +-- 4. 访问 http://localhost:5666/#/operator/graphVisualization?id=xxx&knowledgeId=xxx +-- ============================================= +