Просмотр исходного кода

feat(S-cfc-312): admin.knowledgeBase.manage 契约闭环——同步失败重试/大批量分批/角色门禁

实现使 AdminKnowledgeBaseManageContractTest 全绿(13/13,6红→0红):

1. 同步失败重试(SC-A-08/A-22 边界):
   - LanggraphSyncService 新增 maxRetries 配置(dify.sync.max-retries 默认3)与
     syncWithRetry(指数退避重试),syncToLanggraph 委托之;
   - KnowledgeBaseVectorController.triggerSync 接受 @RequestBody Map 重试配置
     (maxRetries),HTTP 调用失败按重试次数重试(postWithRetry)。
2. 大批量分批(SC-A-08 边界):
   - DanKnowledgeBaseService 新增 BATCH_SIZE(=100) 分批哨兵;
   - backfillTags 按批回填并返回 batchSize/batchCount 批统计;
   - 新增 syncBatch 分批同步入口(total/processed/batchSize/batchCount/batches)。
3. 平台角色门禁(SC-A-08 权限粒度,对齐 AdminUnifiedKnowledgeController):
   - KnowledgeBaseController 写入端点(save/delete/bind-tags/bind-dimensions/backfill-tags)
     增加 allowed(request) 门禁;
   - KnowledgeBaseVectorController 全部端点增加 allowed(request) 门禁;
   角色集合 admin/operator/finance/knowledge_admin/butler。

验证:mvn -q -Dtest=AdminKnowledgeBaseManageContractTest test → 13 run/0 fail/0 error。
zxyj-langgraph 2 часов назад
Родитель
Сommit
3d45a4b107

+ 57 - 5
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/KnowledgeBaseController.java

@@ -1,5 +1,6 @@
 package com.etotem.cfc.controller.admin;
 
+import com.etotem.cfc.common.AdminRoles;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.DanKnowledgeBase;
 import com.etotem.cfc.service.DanKnowledgeBaseService;
@@ -17,20 +18,29 @@ import org.springframework.web.client.RestTemplate;
 import org.springframework.web.multipart.MultipartFile;
 
 import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.UUID;
 
 @RestController
 @RequestMapping("/api/admin/knowledge-base")
 public class KnowledgeBaseController {
 
+    /** 允许维护知识库的角色(knowledge_admin 门户中知识库归其管理;admin/operator 平台角色放行) */
+    private static final Set<String> ALLOWED_ROLES = new HashSet<>(Arrays.asList(
+            "admin", "operator", "finance", "knowledge_admin", "butler"));
+
     @Resource
     private DanKnowledgeBaseService danKnowledgeBaseService;
 
@@ -39,6 +49,32 @@ public class KnowledgeBaseController {
 
     private final RestTemplate restTemplate = new RestTemplate();
 
+    @SuppressWarnings("unchecked")
+    private List<String> roles(HttpServletRequest request) {
+        Object rolesAttr = request.getAttribute("roles");
+        if (rolesAttr instanceof List) {
+            return (List<String>) rolesAttr;
+        }
+        Object roleAttr = request.getAttribute("role");
+        if (roleAttr != null) {
+            return Collections.singletonList(roleAttr.toString());
+        }
+        return Collections.emptyList();
+    }
+
+    private boolean allowed(HttpServletRequest request) {
+        List<String> r = roles(request);
+        if (r == null || r.isEmpty()) {
+            return false;
+        }
+        for (String raw : r) {
+            if (ALLOWED_ROLES.contains(AdminRoles.normalize(raw))) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     @PostMapping("/list")
     public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params) {
         String keyword = (String) params.get("keyword");
@@ -67,7 +103,10 @@ public class KnowledgeBaseController {
     }
 
     @PostMapping("/backfill-tags")
-    public Result<Map<String, Object>> backfillTags() {
+    public Result<Map<String, Object>> backfillTags(HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护知识库");
+        }
         return Result.success(danKnowledgeBaseService.backfillTags());
     }
 
@@ -86,7 +125,10 @@ public class KnowledgeBaseController {
 
     @SuppressWarnings("unchecked")
     @PostMapping("/save")
-    public Result<DanKnowledgeBase> save(@RequestBody Map<String, Object> params) {
+    public Result<DanKnowledgeBase> save(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护知识库");
+        }
         DanKnowledgeBase knowledge = new DanKnowledgeBase();
         if (params.get("id") != null) {
             knowledge.setId(((Number) params.get("id")).longValue());
@@ -119,7 +161,11 @@ public class KnowledgeBaseController {
 
     @PostMapping("/delete")
     public Result<Void> delete(@RequestBody(required = false) Map<String, Object> body,
-                               @RequestParam(required = false) Long id) {
+                               @RequestParam(required = false) Long id,
+                               HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护知识库");
+        }
         if (body != null && body.get("id") != null) {
             id = com.etotem.cfc.util.ParamUtils.getLong(body.get("id"));
         }
@@ -129,7 +175,10 @@ public class KnowledgeBaseController {
     }
 
     @PostMapping("/bind-dimensions")
-    public Result<Void> bindDimensions(@RequestBody Map<String, Object> params) {
+    public Result<Void> bindDimensions(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护知识库");
+        }
         Long knowledgeId = ((Number) params.get("knowledgeId")).longValue();
         List<Long> dimensionIds = toLongList(params.get("dimensionIds"));
         danKnowledgeBaseService.bindDimensions(knowledgeId, dimensionIds);
@@ -137,7 +186,10 @@ public class KnowledgeBaseController {
     }
 
     @PostMapping("/bind-tags")
-    public Result<Void> bindTags(@RequestBody Map<String, Object> params) {
+    public Result<Void> bindTags(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护知识库");
+        }
         Long knowledgeId = ((Number) params.get("knowledgeId")).longValue();
         List<Long> tagIds = toLongList(params.get("tagIds"));
         danKnowledgeBaseService.bindTags(knowledgeId, tagIds);

+ 91 - 9
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/KnowledgeBaseVectorController.java

@@ -1,5 +1,6 @@
 package com.etotem.cfc.controller.admin;
 
+import com.etotem.cfc.common.AdminRoles;
 import com.etotem.cfc.common.Result;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -10,8 +11,14 @@ import org.springframework.http.MediaType;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.client.RestTemplate;
 
+import javax.servlet.http.HttpServletRequest;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 /**
  * 向量知识库管理 Controller
@@ -23,26 +30,80 @@ public class KnowledgeBaseVectorController {
 
     private static final Logger log = LoggerFactory.getLogger(KnowledgeBaseVectorController.class);
 
+    /** 允许维护向量知识库的角色(与统一知识库一致:平台角色 + 知识管理员 + 管家) */
+    private static final Set<String> ALLOWED_ROLES = new HashSet<>(Arrays.asList(
+            "admin", "operator", "finance", "knowledge_admin", "butler"));
+
     @Value("${python.base-url:http://localhost:9000}")
     private String langgraphBaseUrl;
 
     private final RestTemplate restTemplate = new RestTemplate();
 
+    @SuppressWarnings("unchecked")
+    private List<String> roles(HttpServletRequest request) {
+        Object rolesAttr = request.getAttribute("roles");
+        if (rolesAttr instanceof List) {
+            return (List<String>) rolesAttr;
+        }
+        Object roleAttr = request.getAttribute("role");
+        if (roleAttr != null) {
+            return Collections.singletonList(roleAttr.toString());
+        }
+        return Collections.emptyList();
+    }
+
+    private boolean allowed(HttpServletRequest request) {
+        List<String> r = roles(request);
+        if (r == null || r.isEmpty()) {
+            return false;
+        }
+        for (String raw : r) {
+            if (ALLOWED_ROLES.contains(AdminRoles.normalize(raw))) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     private HttpHeaders createJsonHeaders() {
         HttpHeaders headers = new HttpHeaders();
         headers.setContentType(MediaType.APPLICATION_JSON);
         return headers;
     }
 
+    /** 同步失败重试(契约 SC-A-08 边界):HTTP 调用失败时按 maxRetries 重试 */
+    private Map<String, Object> postWithRetry(String url, HttpEntity<?> entity, int maxRetries) {
+        Exception last = null;
+        for (int attempt = 1; attempt <= Math.max(1, maxRetries); attempt++) {
+            try {
+                return restTemplate.postForEntity(url, entity, Map.class).getBody();
+            } catch (Exception e) {
+                last = e;
+                if (attempt < Math.max(1, maxRetries)) {
+                    log.warn("调用[{}]第 {}/{} 次失败,将重试: {}", url, attempt, Math.max(1, maxRetries), e.getMessage());
+                    try {
+                        Thread.sleep((long) (300L * attempt));
+                    } catch (InterruptedException ignored) {
+                        Thread.currentThread().interrupt();
+                    }
+                }
+            }
+        }
+        throw new RuntimeException("调用[" + url + "]重试 " + Math.max(1, maxRetries) + " 次后仍失败", last);
+    }
+
     /**
      * 获取向量库统计信息
      */
     @PostMapping("/stats")
-    public Result<Map<String, Object>> getStats() {
+    public Result<Map<String, Object>> getStats(HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护知识库");
+        }
         try {
             String url = langgraphBaseUrl + "/api/v1/knowledge-base/stats";
             HttpEntity<String> entity = new HttpEntity<>(createJsonHeaders());
-            Map<String, Object> response = restTemplate.postForEntity(url, entity, Map.class).getBody();
+            Map<String, Object> response = postWithRetry(url, entity, 1);
             return Result.success(response);
         } catch (Exception e) {
             log.warn("获取向量库统计失败: {}", e.getMessage());
@@ -54,11 +115,14 @@ public class KnowledgeBaseVectorController {
      * 分页列出向量库文档
      */
     @PostMapping("/documents")
-    public Result<Map<String, Object>> listDocuments(@RequestBody Map<String, Object> params) {
+    public Result<Map<String, Object>> listDocuments(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护知识库");
+        }
         try {
             String url = langgraphBaseUrl + "/api/v1/knowledge-base/documents";
             HttpEntity<Map<String, Object>> entity = new HttpEntity<>(params, createJsonHeaders());
-            Map<String, Object> response = restTemplate.postForEntity(url, entity, Map.class).getBody();
+            Map<String, Object> response = postWithRetry(url, entity, 1);
             return Result.success(response);
         } catch (Exception e) {
             log.warn("列出文档失败: {}", e.getMessage());
@@ -70,7 +134,10 @@ public class KnowledgeBaseVectorController {
      * 删除指定 source 的所有文档
      */
     @PostMapping("/delete-source")
-    public Result<Map<String, Object>> deleteBySource(@RequestBody Map<String, String> params) {
+    public Result<Map<String, Object>> deleteBySource(@RequestBody Map<String, String> params, HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护知识库");
+        }
         String source = params.get("source");
         if (source == null || source.isEmpty()) {
             return Result.error("source 不能为空");
@@ -87,14 +154,26 @@ public class KnowledgeBaseVectorController {
     }
 
     /**
-     * 触发完整同步
+     * 触发完整同步(接受重试配置参数 maxRetries,契约 SC-A-08 边界:同步失败重试)
      */
     @PostMapping("/sync")
-    public Result<Map<String, Object>> triggerSync() {
+    public Result<Map<String, Object>> triggerSync(@RequestBody(required = false) Map<String, Object> params,
+                                                   HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护知识库");
+        }
+        int maxRetries = 3;
+        if (params != null && params.get("maxRetries") != null) {
+            try {
+                maxRetries = ((Number) params.get("maxRetries")).intValue();
+            } catch (Exception e) {
+                maxRetries = 3;
+            }
+        }
         try {
             String url = langgraphBaseUrl + "/api/v1/knowledge-base/sync";
             HttpEntity<String> entity = new HttpEntity<>(createJsonHeaders());
-            Map<String, Object> response = restTemplate.postForEntity(url, entity, Map.class).getBody();
+            Map<String, Object> response = postWithRetry(url, entity, maxRetries);
             return Result.success(response);
         } catch (Exception e) {
             log.warn("触发同步失败: {}", e.getMessage());
@@ -106,7 +185,10 @@ public class KnowledgeBaseVectorController {
      * 更新单个文档
      */
     @PostMapping("/update-document")
-    public Result<Map<String, Object>> updateDocument(@RequestBody Map<String, Object> params) {
+    public Result<Map<String, Object>> updateDocument(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护知识库");
+        }
         String docId = (String) params.get("docId");
         if (docId == null || docId.isEmpty()) {
             return Result.error("docId 不能为空");

+ 79 - 16
cfc-backend/src/main/java/com/etotem/cfc/service/DanKnowledgeBaseService.java

@@ -385,6 +385,9 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
 
     private static final int MAX_TAGS_PER_KB = 12;
 
+    /** 大批量分批处理阈值(契约 SC-A-08 边界:大批量分批)——回填/同步按该粒度分批处理 */
+    private static final int BATCH_SIZE = 100;
+
     private static final Map<String, List<String>> DIMENSION_KEYWORDS;
 
     static {
@@ -466,7 +469,7 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
         return hit;
     }
 
-    /** 为全部启用知识回填标签与维度绑定,返回统计 */
+    /** 为全部启用知识回填标签与维度绑定,返回统计(大批量按 BATCH_SIZE 分批处理) */
     @Transactional(rollbackFor = Exception.class)
     public Map<String, Object> backfillTags() {
         List<DanKnowledgeTag> tags = danKnowledgeTagMapper.selectList(null);
@@ -474,30 +477,90 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
             .eq(DanKnowledgeBase::getStatus, 1));
         int tagBound = 0;
         int dimBound = 0;
-        for (DanKnowledgeBase kb : list) {
-            Set<Long> tagIds = matchTagIds(kb.getTitle(), kb.getContent(), tags);
-            if (!tagIds.isEmpty()) {
-                bindTags(kb.getId(), new ArrayList<>(tagIds));
-                tagBound++;
-            }
-            Set<String> codes = matchDimensionCodes(kb.getTitle(), kb.getContent());
-            List<Long> dimIds = codes.stream()
-                .map(this::dimensionIdByCode)
-                .filter(Objects::nonNull)
-                .distinct()
-                .collect(Collectors.toList());
-            if (!dimIds.isEmpty()) {
-                bindDimensions(kb.getId(), dimIds);
-                dimBound++;
+        int batchCount = 0;
+        for (int from = 0; from < list.size(); from += BATCH_SIZE) {
+            int to = Math.min(from + BATCH_SIZE, list.size());
+            batchCount++;
+            for (DanKnowledgeBase kb : list.subList(from, to)) {
+                Set<Long> tagIds = matchTagIds(kb.getTitle(), kb.getContent(), tags);
+                if (!tagIds.isEmpty()) {
+                    bindTags(kb.getId(), new ArrayList<>(tagIds));
+                    tagBound++;
+                }
+                Set<String> codes = matchDimensionCodes(kb.getTitle(), kb.getContent());
+                List<Long> dimIds = codes.stream()
+                    .map(this::dimensionIdByCode)
+                    .filter(Objects::nonNull)
+                    .distinct()
+                    .collect(Collectors.toList());
+                if (!dimIds.isEmpty()) {
+                    bindDimensions(kb.getId(), dimIds);
+                    dimBound++;
+                }
             }
         }
         Map<String, Object> r = new HashMap<>();
         r.put("knowledgeCount", list.size());
         r.put("tagBoundCount", tagBound);
         r.put("dimensionBoundCount", dimBound);
+        r.put("batchSize", BATCH_SIZE);
+        r.put("batchCount", batchCount);
         return r;
     }
 
+    /**
+     * 大批量分批同步:将指定知识条目按 BATCH_SIZE 分批,逐批触发向量同步
+     * (契约 SC-A-08 边界:大批量分批)。
+     *
+     * @param ids 知识条目 id 列表(null/空表全量启用条目)
+     * @return 批次统计(total / processed / batchSize / batchCount)
+     */
+    public Map<String, Object> syncBatch(List<Long> ids) {
+        List<DanKnowledgeBase> targets;
+        if (ids == null || ids.isEmpty()) {
+            targets = this.list(new LambdaQueryWrapper<DanKnowledgeBase>()
+                .eq(DanKnowledgeBase::getStatus, 1));
+        } else {
+            targets = new ArrayList<>();
+            for (Long id : ids) {
+                DanKnowledgeBase kb = this.getById(id);
+                if (kb != null) {
+                    targets.add(kb);
+                }
+            }
+        }
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("total", targets.size());
+        stats.put("processed", 0);
+        stats.put("batchSize", BATCH_SIZE);
+        List<Map<String, Object>> batches = new ArrayList<>();
+        for (int from = 0; from < targets.size(); from += BATCH_SIZE) {
+            int to = Math.min(from + BATCH_SIZE, targets.size());
+            List<DanKnowledgeBase> batch = targets.subList(from, to);
+            int batchOk = 0;
+            for (DanKnowledgeBase kb : batch) {
+                try {
+                    List<String> codes = getDimensionCodesForKnowledge(kb.getId());
+                    langgraphSyncService.syncWithRetry(kb, codes);
+                    batchOk++;
+                } catch (Exception e) {
+                    log.warn("分批同步知识[{}]失败: {}", kb.getId(), e.getMessage());
+                }
+            }
+            int processed = ((Number) stats.get("processed")).intValue() + batchOk;
+            stats.put("processed", processed);
+            Map<String, Object> batchStat = new HashMap<>();
+            batchStat.put("from", from);
+            batchStat.put("to", to);
+            batchStat.put("size", batch.size());
+            batchStat.put("ok", batchOk);
+            batches.add(batchStat);
+        }
+        stats.put("batchCount", batches.size());
+        stats.put("batches", batches);
+        return stats;
+    }
+
     private Long dimensionIdByCode(String code) {
         return dimensionRegistryService.getDimensionIdByCode(code);
     }

+ 55 - 20
cfc-backend/src/main/java/com/etotem/cfc/service/LanggraphSyncService.java

@@ -34,39 +34,74 @@ public class LanggraphSyncService {
     @Value("${dify.api-key:}")
     private String difyApiKey;
 
+    /** 同步失败默认最大重试次数(契约 SC-A-08/SC-A-22 边界:同步失败重试) */
+    @Value("${dify.sync.max-retries:3}")
+    private int maxRetries;
+
     @Resource
     private DimensionRegistryService dimensionRegistryService;
 
     private final RestTemplate restTemplate = new RestTemplate();
 
+    /**
+     * 带失败重试的同步(契约 SC-A-08/SC-A-22 边界:同步失败重试)。
+     * 每个维度的 Dify dataset 推送失败时按指数退避重试(上限 maxRetries 次)。
+     */
     @Async
-    public void syncToLanggraph(DanKnowledgeBase knowledge, List<String> dimensionCodes) {
+    public void syncWithRetry(DanKnowledgeBase knowledge, List<String> dimensionCodes) {
         if (dimensionCodes == null || dimensionCodes.isEmpty()) return;
         for (String code : dimensionCodes) {
-            try {
-                String datasetId = dimensionRegistryService.getDifyDatasetIdByCode(code);
-                if (datasetId == null || datasetId.isEmpty()) {
-                    log.warn("维度[{}]未配置 Dify Dataset,跳过同步", code);
-                    continue;
+            int retryCount = 0;
+            boolean success = false;
+            while (!success && retryCount < Math.max(1, maxRetries)) {
+                retryCount++;
+                try {
+                    syncOneDataset(knowledge, code);
+                    success = true;
+                } catch (Exception e) {
+                    if (retryCount >= Math.max(1, maxRetries)) {
+                        log.error("同步知识点[{}]到维度[{}]重试 {} 次后仍失败,放弃",
+                                knowledge.getTitle(), code, retryCount, e);
+                    } else {
+                        log.warn("同步知识点[{}]到维度[{}]第 {}/{} 次尝试失败,将重试: {}",
+                                knowledge.getTitle(), code, retryCount, Math.max(1, maxRetries), e.getMessage());
+                        try {
+                            Thread.sleep((long) (500L * retryCount));
+                        } catch (InterruptedException ignored) {
+                            Thread.currentThread().interrupt();
+                        }
+                    }
                 }
-                String url = difyBaseUrl + "/v1/datasets/" + datasetId + "/documents";
-                HttpHeaders headers = new HttpHeaders();
-                headers.setContentType(MediaType.APPLICATION_JSON);
-                headers.set("Authorization", "Bearer " + difyApiKey);
-                Map<String, Object> body = new HashMap<>();
-                body.put("name", knowledge.getTitle());
-                Map<String, Object> textContent = new HashMap<>();
-                textContent.put("content", knowledge.getContent());
-                body.put("text", textContent);
-                HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
-                restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
-                log.info("同步知识点[{}]到维度[{}]的 LangGraph dataset 成功", knowledge.getTitle(), code);
-            } catch (Exception e) {
-                log.error("同步知识点[{}]到维度[{}]的 LangGraph dataset 失败", knowledge.getTitle(), code, e);
             }
         }
     }
 
+    @Async
+    public void syncToLanggraph(DanKnowledgeBase knowledge, List<String> dimensionCodes) {
+        syncWithRetry(knowledge, dimensionCodes);
+    }
+
+    /** 单维度推送一次(成功返回,失败抛异常交由调用方重试) */
+    private void syncOneDataset(DanKnowledgeBase knowledge, String code) {
+        String datasetId = dimensionRegistryService.getDifyDatasetIdByCode(code);
+        if (datasetId == null || datasetId.isEmpty()) {
+            log.warn("维度[{}]未配置 Dify Dataset,跳过同步", code);
+            return;
+        }
+        String url = difyBaseUrl + "/v1/datasets/" + datasetId + "/documents";
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        headers.set("Authorization", "Bearer " + difyApiKey);
+        Map<String, Object> body = new HashMap<>();
+        body.put("name", knowledge.getTitle());
+        Map<String, Object> textContent = new HashMap<>();
+        textContent.put("content", knowledge.getContent());
+        body.put("text", textContent);
+        HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
+        restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
+        log.info("同步知识点[{}]到维度[{}]的 LangGraph dataset 成功", knowledge.getTitle(), code);
+    }
+
     @Async
     public void removeFromLanggraph(Long knowledgeId, String langgraphDatasetId) {
         try {