Переглянути джерело

1. 修复了图 RAG 标量结果丢失问题,查询证据支持返回 records 行数据;
2. 修复了图谱关系方向展开问题,Schema Prompt 保留真实起止节点组合;
3. 增强了显式 Cypher 安全校验,统一校验授权 Schema 并禁止业务 CALL;
4. 完善了图 RAG 回归测试,覆盖标量结果、关系端点和 Cypher 守卫。

weisijie 1 місяць тому
батько
коміт
0a4680ad75

+ 13 - 5
backend/src/main/java/com/agent/management/rag/graph/GraphRagRetriever.java

@@ -39,18 +39,19 @@ public class GraphRagRetriever implements RagRetriever {
                 out.getDiagnostics().put("warning", "no Cypher provided and automatic generation is disabled or returned empty");
                 return out;
             }
+            if (!auto) generated.validateExplicit(query, id, cypher.get());
             if (auto) {
                 cypher = preflightAndRepair(query, id, cypher.get(), out);
                 if (cypher.isEmpty()) return out;
             }
             Map<String, Object> result = graphs.executeQuery(id, cypher.get());
-            if (auto && list(result.get("nodes")).isEmpty() && list(result.get("edges")).isEmpty()) {
+            if (auto && !hasResults(result)) {
                 Optional<String> repaired = generated.repair(query, id, cypher.get(),
                         "query executed successfully but returned zero results; entity names may be abbreviated, use CONTAINS on an authorized name/title property");
                 if (repaired != null && repaired.isPresent() && !repaired.get().equals(cypher.get())) {
                     graphs.explainQuery(id, repaired.get());
                     Map<String,Object> retried = graphs.executeQuery(id, repaired.get());
-                    if (!list(retried.get("nodes")).isEmpty() || !list(retried.get("edges")).isEmpty()) {
+                    if (hasResults(retried)) {
                         cypher = repaired; result = retried;
                         out.getDiagnostics().put("repair", "zero-result Cypher was repaired once with fuzzy entity matching");
                     }
@@ -58,30 +59,33 @@ public class GraphRagRetriever implements RagRetriever {
             }
             List<?> nodes = list(result.get("nodes"));
             List<?> edges = list(result.get("edges"));
+            List<?> records = list(result.get("records"));
             RagEvidence evidence = new RagEvidence();
             evidence.setId("graph-" + UUID.randomUUID());
             evidence.setSourceType(sourceType());
             evidence.setEvidenceType("GRAPH_RESULT");
             evidence.setTitle(graphs.get(id).getName());
             evidence.setSourceName(evidence.getTitle());
-            evidence.setContent("图查询返回 " + nodes.size() + " 个节点、" + edges.size() + " 条关系");
+            evidence.setContent("图查询返回 " + nodes.size() + " 个节点、" + edges.size()
+                    + " 条关系、" + records.size() + " 行记录");
             evidence.setScore(1.0);
             RagSourceRef ref = new RagSourceRef();
             ref.setSourceId(String.valueOf(id));
             ref.setSourceName(evidence.getTitle());
             ref.setLocator(Map.of("graphSourceId", id));
             evidence.setSourceRef(ref);
-            evidence.setPayload(Map.of("nodes", nodes, "edges", edges, "paths", List.of()));
+            evidence.setPayload(Map.of("nodes", nodes, "edges", edges, "records", records, "paths", List.of()));
             Map<String, Object> metadata = new LinkedHashMap<>();
             metadata.put("cypher", cypher.get());
             metadata.put(auto ? "generatedCypher" : "explicitCypher", cypher.get());
             metadata.put("nodeCount", nodes.size());
             metadata.put("edgeCount", edges.size());
+            metadata.put("recordCount", records.size());
             metadata.put("durationMs", result.getOrDefault("durationMs", 0));
             metadata.put("warnings", Boolean.TRUE.equals(result.get("truncated")) ? List.of("result truncated") : List.of());
             evidence.setMetadata(metadata);
             out.getEvidences().add(evidence);
-            if (auto && autoLearn(query) && (!nodes.isEmpty() || !edges.isEmpty()))
+            if (auto && autoLearn(query) && hasResults(result))
                 exampleMemory.recordSuccess(sourceType(), String.valueOf(id), query.getQuery(), cypher.get());
         } catch (Exception error) {
             out.getDiagnostics().put("error", error.getMessage());
@@ -117,4 +121,8 @@ public class GraphRagRetriever implements RagRetriever {
     }
 
     private static List<?> list(Object value) { return value instanceof List<?> list ? list : List.of(); }
+    private static boolean hasResults(Map<String, Object> result) {
+        return !list(result.get("nodes")).isEmpty() || !list(result.get("edges")).isEmpty()
+                || !list(result.get("records")).isEmpty();
+    }
 }

+ 33 - 13
backend/src/main/java/com/agent/management/rag/graph/GraphSchemaSnapshot.java

@@ -15,12 +15,21 @@ public record GraphSchemaSnapshot(List<NodeSchema> nodes, List<RelationshipSchem
         }
     }
 
+    public record RelationshipEndpoint(String startLabel, String endLabel) {}
+
     public record RelationshipSchema(String type, List<String> startLabels, List<String> endLabels,
-                                     Map<String, List<String>> properties) {
+                                     Map<String, List<String>> properties,
+                                     List<RelationshipEndpoint> endpoints) {
+        public RelationshipSchema(String type, List<String> startLabels, List<String> endLabels,
+                                  Map<String, List<String>> properties) {
+            this(type, startLabels, endLabels, properties, cartesianEndpoints(startLabels, endLabels));
+        }
+
         public RelationshipSchema {
             startLabels = List.copyOf(startLabels == null ? List.of() : startLabels);
             endLabels = List.copyOf(endLabels == null ? List.of() : endLabels);
             properties = immutableProperties(properties);
+            endpoints = List.copyOf(endpoints == null ? List.of() : endpoints);
         }
     }
 
@@ -47,12 +56,15 @@ public record GraphSchemaSnapshot(List<NodeSchema> nodes, List<RelationshipSchem
         Set<String> retainedLabels = filteredNodes.stream().map(NodeSchema::label).collect(Collectors.toSet());
         List<RelationshipSchema> filteredRelationships = relationships.stream()
                 .filter(rel -> relationshipFilter.isEmpty() || relationshipFilter.contains(rel.type()))
-                .filter(rel -> rel.startLabels().stream().anyMatch(retainedLabels::contains)
-                        && rel.endLabels().stream().anyMatch(retainedLabels::contains))
-                .map(rel -> new RelationshipSchema(rel.type(),
-                        rel.startLabels().stream().filter(retainedLabels::contains).toList(),
-                        rel.endLabels().stream().filter(retainedLabels::contains).toList(),
-                        filterProperties(rel.type(), rel.properties(), allowedProperties)))
+                .map(rel -> Map.entry(rel, rel.endpoints().stream()
+                        .filter(endpoint -> retainedLabels.contains(endpoint.startLabel())
+                                && retainedLabels.contains(endpoint.endLabel())).toList()))
+                .filter(entry -> !entry.getValue().isEmpty())
+                .map(entry -> new RelationshipSchema(entry.getKey().type(),
+                        entry.getValue().stream().map(RelationshipEndpoint::startLabel).distinct().toList(),
+                        entry.getValue().stream().map(RelationshipEndpoint::endLabel).distinct().toList(),
+                        filterProperties(entry.getKey().type(), entry.getKey().properties(), allowedProperties),
+                        entry.getValue()))
                 .toList();
         return new GraphSchemaSnapshot(filteredNodes, filteredRelationships);
     }
@@ -65,12 +77,10 @@ public record GraphSchemaSnapshot(List<NodeSchema> nodes, List<RelationshipSchem
         }
         text.append("Relationships:\n");
         for (RelationshipSchema rel : relationships) {
-            for (String start : rel.startLabels()) {
-                for (String end : rel.endLabels()) {
-                    text.append("(:").append(start).append(")-[:").append(rel.type());
-                    if (!rel.properties().isEmpty()) text.append(" {").append(formatProperties(rel.properties())).append("}");
-                    text.append("]->(:").append(end).append(")\n");
-                }
+            for (RelationshipEndpoint endpoint : rel.endpoints()) {
+                text.append("(:").append(endpoint.startLabel()).append(")-[:").append(rel.type());
+                if (!rel.properties().isEmpty()) text.append(" {").append(formatProperties(rel.properties())).append("}");
+                text.append("]->(:").append(endpoint.endLabel()).append(")\n");
             }
         }
         return text.toString();
@@ -99,6 +109,16 @@ public record GraphSchemaSnapshot(List<NodeSchema> nodes, List<RelationshipSchem
         return Collections.unmodifiableMap(result);
     }
 
+    private static List<RelationshipEndpoint> cartesianEndpoints(List<String> starts, List<String> ends) {
+        List<RelationshipEndpoint> result = new ArrayList<>();
+        for (String start : starts == null ? List.<String>of() : starts) {
+            for (String end : ends == null ? List.<String>of() : ends) {
+                result.add(new RelationshipEndpoint(start, end));
+            }
+        }
+        return result;
+    }
+
     private static String formatProperties(Map<String, List<String>> properties) {
         return properties.entrySet().stream()
                 .map(entry -> entry.getKey() + ": " + String.join("|", entry.getValue()))

+ 6 - 0
backend/src/main/java/com/agent/management/rag/graph/Neo4jGraphRagCypherGenerationService.java

@@ -74,6 +74,12 @@ public class Neo4jGraphRagCypherGenerationService implements CypherGenerationSer
         validator.validate(String.valueOf(fixed),schema,maxDepth);return Optional.of(String.valueOf(fixed));
     }
 
+    public void validateExplicit(RagQuery query, Long graphSourceId, String cypher) {
+        GraphSchemaSnapshot authorized = profiles.get(RagSourceType.GRAPH, String.valueOf(graphSourceId)).graphSchema()
+                .filter(stringList(query, "allowedLabels"), stringList(query, "allowedRelationships"), properties(query));
+        validator.validate(cypher, authorized, integer(query, "maxDepth", 3));
+    }
+
     static boolean allowed(RagQuery q){if(q.getFilters()==null)return false;return Boolean.TRUE.equals(q.getFilters().get("allowTextToCypher"))||"AUTO_GENERATE".equals(String.valueOf(q.getFilters().get("retrievalMode")));}
     private static int integer(RagQuery q,String key,int d){Object v=q.getFilters().get(key);return v instanceof Number n?n.intValue():d;}
     private static String string(RagQuery q,String key){Object value=q.getFilters().get(key);return value==null?"":String.valueOf(value);}

+ 5 - 5
backend/src/main/java/com/agent/management/service/CypherGuardService.java

@@ -13,21 +13,21 @@ import java.util.regex.Pattern;
  *
  * <p>规则:
  * <ul>
- *   <li>只允许 MATCH / OPTIONAL MATCH / WITH / RETURN / CALL / SHOW / PROFILE / EXPLAIN / UNWIND / USE 开头</li>
+ *   <li>只允许 MATCH / OPTIONAL MATCH / WITH / RETURN / SHOW / PROFILE / EXPLAIN / UNWIND / USE 开头</li>
  *   <li>禁止 CREATE / MERGE / DELETE / DETACH / SET / REMOVE / DROP / RENAME / CONSTRAINT / INDEX / GRANT / REVOKE / DENY / LOAD / SUBMIT / FOREACH(in write context)等关键词</li>
  *   <li>禁止分号(防止多语句注入)</li>
  *   <li>禁止注释(// 和 /* *\/)</li>
  * </ul>
  * </p>
  *
- * <p>注意:CALL 子查询的写操作防护通过整体关键词扫描完成。</p>
+ * <p>注意:业务查询禁止 CALL,包括子查询和过程调用;内部 Schema 探测不经过本守卫。</p>
  */
 @Slf4j
 @Service
 public class CypherGuardService {
 
     private static final Set<String> ALLOWED_PREFIXES = Set.of(
-            "MATCH", "OPTIONAL", "WITH", "RETURN", "CALL", "SHOW",
+            "MATCH", "OPTIONAL", "WITH", "RETURN", "SHOW",
             "PROFILE", "EXPLAIN", "UNWIND", "USE"
     );
 
@@ -45,7 +45,7 @@ public class CypherGuardService {
             "IN TRANSACTIONS",
             // 过程调用中可能危险(CALL 自身允许,但禁止其内部出现写关键词——通过整体扫描覆盖)
             // DBA
-            "ADMIN", "ADMINISTER"
+            "ADMIN", "ADMINISTER", "CALL"
     );
 
     /** 提取 Cypher 第一个非空单词(忽略前导空白和左括号) */
@@ -65,7 +65,7 @@ public class CypherGuardService {
                     "CONSTRAINT", "INDEX",
                     "GRANT", "REVOKE", "DENY",
                     "LOAD", "SUBMIT",
-                    "ADMIN", "ADMINISTER"
+                    "ADMIN", "ADMINISTER", "CALL"
             ) + ")\\b",
             Pattern.CASE_INSENSITIVE
     );

+ 55 - 10
backend/src/main/java/com/agent/management/service/Neo4jExecutorService.java

@@ -26,11 +26,11 @@ import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.TreeMap;
-import java.util.TreeSet;
 
 /**
  * Neo4j 动态 Driver 管理 + Cypher 执行
@@ -107,6 +107,7 @@ public class Neo4jExecutorService {
         // 用 LinkedHashMap 去重(elementId 唯一)
         Map<String, Map<String, Object>> nodes = new LinkedHashMap<>();
         Map<String, Map<String, Object>> edges = new LinkedHashMap<>();
+        List<Map<String, Object>> records = new ArrayList<>();
 
         TransactionConfig txConfig = TransactionConfig.builder()
                 .withTimeout(Duration.ofSeconds(props.getQueryTimeoutSeconds()))
@@ -115,9 +116,12 @@ public class Neo4jExecutorService {
         try (var session = newSession(driver, gs.getDatabase())) {
             var result = session.run(cypher, txConfig);
             for (Record record : result.list()) {
+                Map<String, Object> row = new LinkedHashMap<>();
                 for (var pair : record.fields()) {
                     collectValue(pair.value(), nodes, edges, 0);
+                    row.put(pair.key(), toSerializableValue(pair.value(), 0));
                 }
+                records.add(row);
                 // 提前截断检查
                 if (nodes.size() > props.getMaxNodes() || edges.size() > props.getMaxEdges()) {
                     break;
@@ -139,6 +143,7 @@ public class Neo4jExecutorService {
         Map<String, Object> response = new LinkedHashMap<>();
         response.put("nodes", nodeList);
         response.put("edges", edgeList);
+        response.put("records", records);
         response.put("truncated", truncated);
         response.put("durationMs", durationMs);
         log.info("GraphSource id={} 查询完成:{} nodes / {} edges in {} ms",
@@ -167,8 +172,7 @@ public class Neo4jExecutorService {
         Driver driver = getOrCreateDriver(gs);
         Map<String, Map<String, List<String>>> nodeProperties = new TreeMap<>();
         Map<String, Map<String, List<String>>> relationshipProperties = new TreeMap<>();
-        Map<String, Set<String>> starts = new TreeMap<>();
-        Map<String, Set<String>> ends = new TreeMap<>();
+        Map<String, Set<GraphSchemaSnapshot.RelationshipEndpoint>> endpoints = new TreeMap<>();
         try (var session = newSession(driver, gs.getDatabase())) {
             var nodes = session.run("CALL db.schema.nodeTypeProperties() " +
                     "YIELD nodeLabels, propertyName, propertyTypes " +
@@ -203,8 +207,9 @@ public class Neo4jExecutorService {
             while (directions.hasNext()) {
                 Record record = directions.next();
                 String type = record.get("relationshipType").asString();
-                starts.computeIfAbsent(type, ignored -> new TreeSet<>()).add(record.get("startLabel").asString());
-                ends.computeIfAbsent(type, ignored -> new TreeSet<>()).add(record.get("endLabel").asString());
+                endpoints.computeIfAbsent(type, ignored -> new LinkedHashSet<>())
+                        .add(new GraphSchemaSnapshot.RelationshipEndpoint(
+                                record.get("startLabel").asString(), record.get("endLabel").asString()));
                 relationshipProperties.computeIfAbsent(type, ignored -> new TreeMap<>());
             }
         } catch (Neo4jException e) {
@@ -215,9 +220,14 @@ public class Neo4jExecutorService {
                 .map(entry -> new GraphSchemaSnapshot.NodeSchema(entry.getKey(), entry.getValue()))
                 .toList();
         List<GraphSchemaSnapshot.RelationshipSchema> relationships = relationshipProperties.entrySet().stream()
-                .map(entry -> new GraphSchemaSnapshot.RelationshipSchema(entry.getKey(),
-                        new ArrayList<>(starts.getOrDefault(entry.getKey(), Set.of())),
-                        new ArrayList<>(ends.getOrDefault(entry.getKey(), Set.of())), entry.getValue()))
+                .map(entry -> {
+                    List<GraphSchemaSnapshot.RelationshipEndpoint> pairs =
+                            new ArrayList<>(endpoints.getOrDefault(entry.getKey(), Set.of()));
+                    return new GraphSchemaSnapshot.RelationshipSchema(entry.getKey(),
+                            pairs.stream().map(GraphSchemaSnapshot.RelationshipEndpoint::startLabel).distinct().toList(),
+                            pairs.stream().map(GraphSchemaSnapshot.RelationshipEndpoint::endLabel).distinct().toList(),
+                            entry.getValue(), pairs);
+                })
                 .toList();
         return new GraphSchemaSnapshot(nodes, relationships);
     }
@@ -321,10 +331,40 @@ public class Neo4jExecutorService {
         }
     }
 
+    private Object toSerializableValue(Value value, int depth) {
+        if (depth > MAX_RECURSION_DEPTH || value == null || value.isNull()) return null;
+        String typeName;
+        try {
+            typeName = value.type().name();
+        } catch (Exception e) {
+            return null;
+        }
+        return switch (typeName) {
+            case "NODE" -> nodeData(value.asNode());
+            case "RELATIONSHIP" -> relationshipData(value.asRelationship());
+            case "PATH" -> {
+                Path path = value.asPath();
+                List<Map<String, Object>> pathNodes = new ArrayList<>();
+                List<Map<String, Object>> pathEdges = new ArrayList<>();
+                path.nodes().forEach(node -> pathNodes.add(nodeData(node)));
+                path.relationships().forEach(rel -> pathEdges.add(relationshipData(rel)));
+                yield Map.of("nodes", pathNodes, "edges", pathEdges);
+            }
+            case "LIST" -> value.asList(item -> toSerializableValue(item, depth + 1));
+            case "MAP" -> value.asMap(item -> toSerializableValue(item, depth + 1));
+            default -> value.asObject();
+        };
+    }
+
     private void putNode(Node node, Map<String, Map<String, Object>> nodes) {
         String id = node.elementId();
         if (nodes.containsKey(id)) return;
+        nodes.put(id, nodeData(node));
+    }
+
+    private Map<String, Object> nodeData(Node node) {
         Map<String, Object> data = new LinkedHashMap<>();
+        String id = node.elementId();
         data.put("id", id);
         // label:取第一个 label 或回退到 id
         List<String> labels = new ArrayList<>();
@@ -336,20 +376,25 @@ public class Neo4jExecutorService {
         Object display = pickDisplay(props);
         if (display != null) data.put("label", String.valueOf(display));
         data.put("properties", props);
-        nodes.put(id, data);
+        return data;
     }
 
     private void putRelationship(Relationship rel, Map<String, Map<String, Object>> edges) {
         String id = rel.elementId();
         if (edges.containsKey(id)) return;
+        edges.put(id, relationshipData(rel));
+    }
+
+    private Map<String, Object> relationshipData(Relationship rel) {
         Map<String, Object> data = new LinkedHashMap<>();
+        String id = rel.elementId();
         data.put("id", id);
         data.put("source", rel.startNodeElementId());
         data.put("target", rel.endNodeElementId());
         data.put("label", rel.type());
         data.put("type", rel.type());
         data.put("properties", rel.asMap());
-        edges.put(id, data);
+        return data;
     }
 
     /** 优先选 name / title / label 作为显示 */

+ 26 - 4
backend/src/test/java/com/agent/management/rag/graph/GraphRagRetrieverTest.java

@@ -40,18 +40,40 @@ class GraphRagRetrieverTest {
         Neo4jGraphRagCypherGenerationService generated = mock(Neo4jGraphRagCypherGenerationService.class);
         GraphSourceService graphs = mock(GraphSourceService.class);
         RagQuery query = query();
-        when(explicit.generateCypher(query, 1L)).thenReturn(Optional.of("MATCH (n) RETURN n"));
-        when(graphs.executeQuery(1L, "MATCH (n) RETURN n")).thenReturn(Map.of("nodes", List.of(), "edges", List.of()));
+        when(explicit.generateCypher(query, 1L)).thenReturn(Optional.of("MATCH (n:Mission) RETURN n"));
+        when(graphs.executeQuery(1L, "MATCH (n:Mission) RETURN n")).thenReturn(Map.of("nodes", List.of(), "edges", List.of()));
         GraphSource source = new GraphSource(); source.setName("test"); when(graphs.get(1L)).thenReturn(source);
 
         var result = new GraphRagRetriever(explicit, generated, graphs, mock(RagExampleMemoryService.class)).retrieve(query);
 
-        verifyNoInteractions(generated);
+        verify(generated).validateExplicit(query, 1L, "MATCH (n:Mission) RETURN n");
+        verify(generated, never()).generateCypher(any(), anyLong());
         assertThat(result.getEvidences()).singleElement().satisfies(e -> assertThat(e.getMetadata())
-                .containsEntry("explicitCypher", "MATCH (n) RETURN n")
+                .containsEntry("explicitCypher", "MATCH (n:Mission) RETURN n")
                 .doesNotContainKey("generatedCypher"));
     }
 
+    @Test
+    void scalarRecordsCountAsResultsAndAreExposedAsEvidence() {
+        ExplicitCypherGenerationService explicit = mock(ExplicitCypherGenerationService.class);
+        Neo4jGraphRagCypherGenerationService generated = mock(Neo4jGraphRagCypherGenerationService.class);
+        GraphSourceService graphs = mock(GraphSourceService.class);
+        RagQuery query = query();
+        when(explicit.generateCypher(query, 1L)).thenReturn(Optional.empty());
+        when(generated.generateCypher(query, 1L)).thenReturn(Optional.of("MATCH (n:Mission) RETURN count(n) AS total"));
+        when(graphs.executeQuery(1L, "MATCH (n:Mission) RETURN count(n) AS total"))
+                .thenReturn(Map.of("nodes", List.of(), "edges", List.of(), "records", List.of(Map.of("total", 3L))));
+        GraphSource source = new GraphSource(); source.setName("test"); when(graphs.get(1L)).thenReturn(source);
+
+        var result = new GraphRagRetriever(explicit, generated, graphs, mock(RagExampleMemoryService.class)).retrieve(query);
+
+        verify(generated, never()).repair(any(), anyLong(), anyString(), anyString());
+        assertThat(result.getEvidences()).singleElement().satisfies(evidence -> {
+            assertThat(evidence.getPayload().get("records")).isEqualTo(List.of(Map.of("total", 3L)));
+            assertThat(evidence.getMetadata()).containsEntry("recordCount", 1);
+        });
+    }
+
     @Test
     void generationFailureIsVisibleInDiagnostics() {
         ExplicitCypherGenerationService explicit = mock(ExplicitCypherGenerationService.class);

+ 20 - 0
backend/src/test/java/com/agent/management/rag/graph/GraphSchemaSnapshotTest.java

@@ -8,6 +8,26 @@ import java.util.Map;
 import static org.assertj.core.api.Assertions.assertThat;
 
 class GraphSchemaSnapshotTest {
+    @Test
+    void promptKeepsObservedRelationshipEndpointPairs() {
+        GraphSchemaSnapshot schema = new GraphSchemaSnapshot(
+                List.of(
+                        new GraphSchemaSnapshot.NodeSchema("A", Map.of()),
+                        new GraphSchemaSnapshot.NodeSchema("B", Map.of()),
+                        new GraphSchemaSnapshot.NodeSchema("C", Map.of()),
+                        new GraphSchemaSnapshot.NodeSchema("D", Map.of())
+                ),
+                List.of(new GraphSchemaSnapshot.RelationshipSchema("REL", List.of("A", "C"),
+                        List.of("B", "D"), Map.of(), List.of(
+                        new GraphSchemaSnapshot.RelationshipEndpoint("A", "B"),
+                        new GraphSchemaSnapshot.RelationshipEndpoint("C", "D"))))
+        );
+
+        assertThat(schema.toPromptText())
+                .contains("(:A)-[:REL]->(:B)", "(:C)-[:REL]->(:D)")
+                .doesNotContain("(:A)-[:REL]->(:D)", "(:C)-[:REL]->(:B)");
+    }
+
     @Test
     void whitelistIntersectsRealSchemaAndKeepsDirectionAndProperties() {
         GraphSchemaSnapshot schema = new GraphSchemaSnapshot(

+ 17 - 0
backend/src/test/java/com/agent/management/service/CypherGuardServiceTest.java

@@ -0,0 +1,17 @@
+package com.agent.management.service;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class CypherGuardServiceTest {
+    private final CypherGuardService guard = new CypherGuardService();
+
+    @Test
+    void rejectsProcedureCalls() {
+        assertThatThrownBy(() -> guard.validate("CALL db.labels() YIELD label RETURN label"))
+                .hasMessageContaining("仅允许查询语句");
+        assertThatThrownBy(() -> guard.validate("MATCH (n) CALL custom.write(n) RETURN n"))
+                .hasMessageContaining("禁止的关键词");
+    }
+}