|
|
@@ -0,0 +1,520 @@
|
|
|
+package com.agent.management.rag.graph;
|
|
|
+
|
|
|
+import com.agent.management.rag.model.RagQuery;
|
|
|
+import com.agent.management.service.GraphSourceService;
|
|
|
+import lombok.RequiredArgsConstructor;
|
|
|
+import org.springframework.stereotype.Component;
|
|
|
+
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Collection;
|
|
|
+import java.util.Comparator;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.LinkedHashSet;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Locale;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.Optional;
|
|
|
+import java.util.Set;
|
|
|
+import java.util.regex.Pattern;
|
|
|
+
|
|
|
+@Component
|
|
|
+@RequiredArgsConstructor
|
|
|
+public class GraphQueryIntentPlanner {
|
|
|
+ private static final Pattern IDENTIFIER = Pattern.compile("(?:[A-Z][A-Z0-9]*_)+(?:[A-Z0-9]+)");
|
|
|
+ private static final int MAX_ROWS = 50;
|
|
|
+ private final GraphSourceService graphs;
|
|
|
+ private final GraphSchemaValidator validator;
|
|
|
+
|
|
|
+ public Optional<PlannedCypher> plan(RagQuery query, Long graphSourceId, GraphSchemaSnapshot schema,
|
|
|
+ GraphEntityGroundingService.GroundingResult grounding, int maxDepth) {
|
|
|
+ if (!enabled(query)) return Optional.empty();
|
|
|
+ String question = query.getQuery() == null ? "" : query.getQuery();
|
|
|
+ String text = question.toLowerCase(Locale.ROOT);
|
|
|
+ Optional<PlannedCypher> planned = modeEnabled(query, "RELATION_RANKING") ? relationRanking(question, text, schema, grounding) : Optional.empty();
|
|
|
+ if (planned.isEmpty() && modeEnabled(query, "COUNT_RELATED")) planned = countRelated(question, text, schema);
|
|
|
+ if (planned.isEmpty() && modeEnabled(query, "CAPABILITY_MATCHING")) planned = capabilityMatching(question, text, graphSourceId, schema, genericTerms(query));
|
|
|
+ if (planned.isEmpty() && modeEnabled(query, "ENTITY_NEIGHBORHOOD")) planned = entityNeighborhood(question, text, schema, grounding);
|
|
|
+ if (planned.isEmpty() && modeEnabled(query, "PROPERTY_FILTERED_EVIDENCE")) planned = propertyFilteredEvidence(question, text, graphSourceId, schema);
|
|
|
+ planned.ifPresent(item -> validator.validate(item.cypher(), schema, maxDepth));
|
|
|
+ return planned;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Optional<PlannedCypher> relationRanking(String question, String text, GraphSchemaSnapshot schema,
|
|
|
+ GraphEntityGroundingService.GroundingResult grounding) {
|
|
|
+ if (grounding.matches().isEmpty() || !containsAny(text, "预计算", "支援关系", "协防关系",
|
|
|
+ "support", "ranking", "score", "评分", "排序", "排名", "最高", "top")) {
|
|
|
+ return Optional.empty();
|
|
|
+ }
|
|
|
+ GraphEntityGroundingService.EntityMatch entity = grounding.matches().get(0);
|
|
|
+ List<RankingBranch> branches = new ArrayList<>();
|
|
|
+ for (GraphSchemaSnapshot.RelationshipSchema relationship : schema.relationships()) {
|
|
|
+ Optional<String> scoreProperty = scoreProperty(relationship);
|
|
|
+ if (scoreProperty.isEmpty()) continue;
|
|
|
+ for (GraphSchemaSnapshot.RelationshipEndpoint endpoint : relationship.endpoints()) {
|
|
|
+ for (String label : entity.labels()) {
|
|
|
+ if (label.equals(endpoint.endLabel())) {
|
|
|
+ branches.add(new RankingBranch(endpoint.startLabel(), relationship.type(), endpoint.endLabel(), true,
|
|
|
+ scoreProperty.get()));
|
|
|
+ } else if (label.equals(endpoint.startLabel())) {
|
|
|
+ branches.add(new RankingBranch(endpoint.endLabel(), relationship.type(), endpoint.startLabel(), false,
|
|
|
+ scoreProperty.get()));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (branches.isEmpty()) return Optional.empty();
|
|
|
+ String targetLabel = entity.labels().stream()
|
|
|
+ .filter(label -> schema.node(label).map(node -> node.properties().containsKey(entity.property())).orElse(false))
|
|
|
+ .findFirst().orElse(entity.labels().get(0));
|
|
|
+ StringBuilder cypher = new StringBuilder();
|
|
|
+ LinkedHashSet<String> seen = new LinkedHashSet<>();
|
|
|
+ for (RankingBranch branch : branches) {
|
|
|
+ String key = branch.otherLabel() + "|" + branch.relationship() + "|" + branch.incomingToEntity();
|
|
|
+ if (!seen.add(key)) continue;
|
|
|
+ if (!cypher.isEmpty()) cypher.append("\nUNION ALL\n");
|
|
|
+ if (branch.incomingToEntity()) {
|
|
|
+ cypher.append("MATCH (x:").append(id(branch.otherLabel())).append(")-[r:")
|
|
|
+ .append(id(branch.relationship())).append("]->(target:")
|
|
|
+ .append(id(targetLabel)).append(" {").append(id(entity.property())).append(": ")
|
|
|
+ .append(lit(entity.mention())).append("})\n");
|
|
|
+ } else {
|
|
|
+ cypher.append("MATCH (target:").append(id(targetLabel)).append(" {")
|
|
|
+ .append(id(entity.property())).append(": ").append(lit(entity.mention()))
|
|
|
+ .append("})-[r:").append(id(branch.relationship())).append("]->(x:")
|
|
|
+ .append(id(branch.otherLabel())).append(")\n");
|
|
|
+ }
|
|
|
+ String idExpression = property(branch.otherLabel(), schema, "id")
|
|
|
+ .map(prop -> "x." + id(prop)).orElse("null");
|
|
|
+ String nameExpression = property(branch.otherLabel(), schema, "name")
|
|
|
+ .map(prop -> "x." + id(prop)).orElse(idExpression);
|
|
|
+ cypher.append("RETURN x, r, target, labels(x) AS node_labels, ")
|
|
|
+ .append(idExpression).append(" AS id, ").append(nameExpression).append(" AS name, ")
|
|
|
+ .append("r.").append(id(branch.scoreProperty())).append(" AS score, ")
|
|
|
+ .append("r.label AS label, r.algorithm AS algorithm");
|
|
|
+ }
|
|
|
+ cypher.append("\nORDER BY score DESC\nLIMIT ").append(MAX_ROWS);
|
|
|
+ return Optional.of(new PlannedCypher(cypher.toString(), "RELATION_RANKING",
|
|
|
+ Map.of("entity", entity.toMetadata(), "question", question), 0.90));
|
|
|
+ }
|
|
|
+
|
|
|
+ private Optional<PlannedCypher> countRelated(String question, String text, GraphSchemaSnapshot schema) {
|
|
|
+ if (!containsAny(text, "最多", "最少", "数量最多", "数量最少", "highest", "lowest", "most", "least")) {
|
|
|
+ return Optional.empty();
|
|
|
+ }
|
|
|
+ String order = containsAny(text, "最少", "lowest", "least") ? "ASC" : "DESC";
|
|
|
+ LabelScore target = bestLabel(question, schema, "target");
|
|
|
+ if (target == null) return Optional.empty();
|
|
|
+ List<CountCandidate> candidates = new ArrayList<>();
|
|
|
+ for (GraphSchemaSnapshot.RelationshipSchema relationship : schema.relationships()) {
|
|
|
+ for (GraphSchemaSnapshot.RelationshipEndpoint endpoint : relationship.endpoints()) {
|
|
|
+ if (endpoint.endLabel().equals(target.label())) {
|
|
|
+ candidates.add(new CountCandidate(endpoint.startLabel(), target.label(), relationship.type(), true,
|
|
|
+ target.score() + labelScore(question, endpoint.startLabel(), "source")));
|
|
|
+ } else if (endpoint.startLabel().equals(target.label())) {
|
|
|
+ candidates.add(new CountCandidate(endpoint.endLabel(), target.label(), relationship.type(), false,
|
|
|
+ target.score() + labelScore(question, endpoint.endLabel(), "source")));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ CountCandidate best = candidates.stream().max(Comparator.comparingDouble(CountCandidate::score)).orElse(null);
|
|
|
+ if (best == null || best.score() < 0.55) return Optional.empty();
|
|
|
+ String relatedId = propertyOrFirst(best.targetLabel(), schema, "id").orElseThrow();
|
|
|
+ String relatedName = property(best.targetLabel(), schema, "name").orElse(relatedId);
|
|
|
+ String sourceId = propertyOrFirst(best.sourceLabel(), schema, "id").orElseThrow();
|
|
|
+ String sourceName = property(best.sourceLabel(), schema, "name").orElse(sourceId);
|
|
|
+ String pattern = best.outgoing()
|
|
|
+ ? "(source:" + id(best.sourceLabel()) + ")-[rel:" + id(best.relationship()) + "]->(related:" + id(best.targetLabel()) + ")"
|
|
|
+ : "(related:" + id(best.targetLabel()) + ")-[rel:" + id(best.relationship()) + "]->(source:" + id(best.sourceLabel()) + ")";
|
|
|
+ String cypher = "MATCH " + pattern + "\n"
|
|
|
+ + "WITH source, collect(DISTINCT related) AS relatedNodes, count(DISTINCT related) AS relatedCount\n"
|
|
|
+ + "ORDER BY relatedCount " + order + ", source." + id(sourceId) + "\n"
|
|
|
+ + "LIMIT 1\n"
|
|
|
+ + "RETURN source, relatedNodes, [node IN relatedNodes | node." + id(relatedId) + "] AS relatedIds, "
|
|
|
+ + "[node IN relatedNodes | node." + id(relatedName) + "] AS relatedNames, "
|
|
|
+ + "source." + id(sourceId) + " AS sourceId, source." + id(sourceName) + " AS sourceName, relatedCount";
|
|
|
+ return Optional.of(new PlannedCypher(cypher, "COUNT_RELATED",
|
|
|
+ Map.of("sourceLabel", best.sourceLabel(), "targetLabel", best.targetLabel(),
|
|
|
+ "relationship", best.relationship(), "order", order), best.score()));
|
|
|
+ }
|
|
|
+
|
|
|
+ private Optional<PlannedCypher> capabilityMatching(String question, String text, Long graphSourceId,
|
|
|
+ GraphSchemaSnapshot schema, Set<String> genericTerms) {
|
|
|
+ if (!containsAny(text, "能力", "匹配", "需要", "保障", "capability", "require", "support")) {
|
|
|
+ return Optional.empty();
|
|
|
+ }
|
|
|
+ String capabilityLabel = schema.labelNames().stream()
|
|
|
+ .filter(label -> splitCamel(label).toLowerCase(Locale.ROOT).contains("capability"))
|
|
|
+ .findFirst().orElse(null);
|
|
|
+ if (capabilityLabel == null) return Optional.empty();
|
|
|
+ List<String> capabilityProperties = List.of("id", "name", "category").stream()
|
|
|
+ .map(preferred -> property(capabilityLabel, schema, preferred))
|
|
|
+ .flatMap(Optional::stream)
|
|
|
+ .distinct().toList();
|
|
|
+ if (capabilityProperties.isEmpty()) return Optional.empty();
|
|
|
+ LinkedHashSet<String> terms = capabilityTerms(question, text, graphSourceId, capabilityLabel, capabilityProperties, genericTerms);
|
|
|
+ if (terms.isEmpty()) return Optional.empty();
|
|
|
+ List<CapabilityBranch> branches = new ArrayList<>();
|
|
|
+ for (GraphSchemaSnapshot.RelationshipSchema relationship : schema.relationships()) {
|
|
|
+ if (!splitCamel(relationship.type()).toLowerCase(Locale.ROOT).contains("capability")) continue;
|
|
|
+ for (GraphSchemaSnapshot.RelationshipEndpoint endpoint : relationship.endpoints()) {
|
|
|
+ if (endpoint.endLabel().equals(capabilityLabel)) {
|
|
|
+ branches.add(new CapabilityBranch(endpoint.startLabel(), relationship.type(), true));
|
|
|
+ } else if (endpoint.startLabel().equals(capabilityLabel)) {
|
|
|
+ branches.add(new CapabilityBranch(endpoint.endLabel(), relationship.type(), false));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (branches.isEmpty()) return Optional.empty();
|
|
|
+ String condition = capabilityCondition("cap", capabilityProperties, terms);
|
|
|
+ String capabilityId = property(capabilityLabel, schema, "id").orElse(capabilityProperties.get(0));
|
|
|
+ String capabilityName = property(capabilityLabel, schema, "name").orElse(capabilityId);
|
|
|
+ StringBuilder cypher = new StringBuilder();
|
|
|
+ LinkedHashSet<String> seen = new LinkedHashSet<>();
|
|
|
+ for (CapabilityBranch branch : branches) {
|
|
|
+ String key = branch.entityLabel() + "|" + branch.relationship() + "|" + branch.outgoingToCapability();
|
|
|
+ if (!seen.add(key)) continue;
|
|
|
+ if (!cypher.isEmpty()) cypher.append("\nUNION ALL\n");
|
|
|
+ if (branch.outgoingToCapability()) {
|
|
|
+ cypher.append("MATCH (entity:").append(id(branch.entityLabel())).append(")-[rel:")
|
|
|
+ .append(id(branch.relationship())).append("]->(cap:")
|
|
|
+ .append(id(capabilityLabel)).append(")\n");
|
|
|
+ } else {
|
|
|
+ cypher.append("MATCH (cap:").append(id(capabilityLabel)).append(")-[rel:")
|
|
|
+ .append(id(branch.relationship())).append("]->(entity:")
|
|
|
+ .append(id(branch.entityLabel())).append(")\n");
|
|
|
+ }
|
|
|
+ String entityId = property(branch.entityLabel(), schema, "id")
|
|
|
+ .map(prop -> "entity." + id(prop)).orElse("null");
|
|
|
+ String entityName = property(branch.entityLabel(), schema, "name")
|
|
|
+ .map(prop -> "entity." + id(prop)).orElse(entityId);
|
|
|
+ cypher.append("WHERE ").append(condition).append("\n")
|
|
|
+ .append("WITH entity, collect(DISTINCT cap) AS matchedCapabilities, ")
|
|
|
+ .append("count(DISTINCT cap) AS matchedCount, ")
|
|
|
+ .append("avg(toFloat(coalesce(rel.level, rel.confidence, 0))) AS avgLevel\n")
|
|
|
+ .append("RETURN entity, matchedCapabilities, labels(entity) AS nodeLabels, ")
|
|
|
+ .append(entityId).append(" AS entityId, ").append(entityName).append(" AS entityName, ")
|
|
|
+ .append("[cap IN matchedCapabilities | cap.").append(id(capabilityId)).append("] AS capabilityIds, ")
|
|
|
+ .append("[cap IN matchedCapabilities | cap.").append(id(capabilityName)).append("] AS capabilityNames, ")
|
|
|
+ .append("matchedCount, avgLevel");
|
|
|
+ }
|
|
|
+ cypher.append("\nORDER BY matchedCount DESC, avgLevel DESC, entityId\nLIMIT ").append(MAX_ROWS);
|
|
|
+ return Optional.of(new PlannedCypher(cypher.toString(), "CAPABILITY_MATCHING",
|
|
|
+ Map.of("capabilityLabel", capabilityLabel, "terms", new ArrayList<>(terms),
|
|
|
+ "relationships", branches.stream().map(CapabilityBranch::relationship).distinct().toList()),
|
|
|
+ Math.min(0.9, 0.55 + terms.size() * 0.08)));
|
|
|
+ }
|
|
|
+
|
|
|
+ private Optional<PlannedCypher> propertyFilteredEvidence(String question, String text, Long graphSourceId,
|
|
|
+ GraphSchemaSnapshot schema) {
|
|
|
+ if (!containsAny(text, "状态", "公开", "证据", "未知", "维护", "available", "unknown", "public", "evidence", "status")) {
|
|
|
+ return Optional.empty();
|
|
|
+ }
|
|
|
+ List<PropertyFilter> filters = filtersFromSampledValues(question, text, graphSourceId, schema);
|
|
|
+ if (filters.isEmpty()) return Optional.empty();
|
|
|
+ PropertyFilter primary = filters.get(0);
|
|
|
+ Optional<DocumentEvidence> evidence = documentEvidence(schema, primary.label());
|
|
|
+ String where = filters.stream()
|
|
|
+ .filter(filter -> filter.label().equals(primary.label()))
|
|
|
+ .map(filter -> "entity." + id(filter.property()) + " = " + lit(filter.value()))
|
|
|
+ .reduce((left, right) -> left + " AND " + right).orElse("");
|
|
|
+ if (where.isBlank()) return Optional.empty();
|
|
|
+ StringBuilder cypher = new StringBuilder();
|
|
|
+ if (evidence.isPresent()) {
|
|
|
+ DocumentEvidence doc = evidence.get();
|
|
|
+ if (doc.outgoingFromDocument()) {
|
|
|
+ cypher.append("MATCH p=(doc:").append(id(doc.documentLabel())).append(")-[ev:")
|
|
|
+ .append(id(doc.relationship())).append("]->(entity:").append(id(primary.label())).append(")\n");
|
|
|
+ } else {
|
|
|
+ cypher.append("MATCH p=(entity:").append(id(primary.label())).append(")-[ev:")
|
|
|
+ .append(id(doc.relationship())).append("]->(doc:").append(id(doc.documentLabel())).append(")\n");
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ cypher.append("MATCH (entity:").append(id(primary.label())).append(")\n");
|
|
|
+ }
|
|
|
+ cypher.append("WHERE ").append(where).append("\n")
|
|
|
+ .append("RETURN ").append(evidence.isPresent() ? "p, doc, ev, " : "")
|
|
|
+ .append("entity, entity.id AS entityId, entity.name AS name LIMIT ").append(MAX_ROWS);
|
|
|
+ return Optional.of(new PlannedCypher(cypher.toString(), "PROPERTY_FILTERED_EVIDENCE",
|
|
|
+ Map.of("filters", filters.stream().map(PropertyFilter::toMetadata).toList()), 0.82));
|
|
|
+ }
|
|
|
+
|
|
|
+ private Optional<PlannedCypher> entityNeighborhood(String question, String text, GraphSchemaSnapshot schema,
|
|
|
+ GraphEntityGroundingService.GroundingResult grounding) {
|
|
|
+ if (grounding.matches().isEmpty()) return Optional.empty();
|
|
|
+ if (containsAny(text, "最多", "最少", "排名", "排序", "评分", "score", "top")) return Optional.empty();
|
|
|
+ if (!containsAny(text, "关联", "相关", "周边", "有哪些", "是什么", "状态", "部署", "链", "证据", "能力")) {
|
|
|
+ return Optional.empty();
|
|
|
+ }
|
|
|
+ GraphEntityGroundingService.EntityMatch entity = grounding.matches().get(0);
|
|
|
+ String entityLabel = entity.labels().stream()
|
|
|
+ .filter(label -> schema.node(label).map(node -> node.properties().containsKey(entity.property())).orElse(false))
|
|
|
+ .findFirst().orElse(null);
|
|
|
+ if (entityLabel == null) return Optional.empty();
|
|
|
+ List<Neighbor> neighbors = neighbors(schema, new LinkedHashSet<>(entity.labels()));
|
|
|
+ if (neighbors.isEmpty()) return Optional.empty();
|
|
|
+ StringBuilder cypher = new StringBuilder("MATCH (n:").append(id(entityLabel)).append(" {")
|
|
|
+ .append(id(entity.property())).append(": ").append(lit(entity.mention())).append("})\n");
|
|
|
+ List<String> returns = new ArrayList<>(List.of("n"));
|
|
|
+ for (int i = 0; i < neighbors.size(); i++) {
|
|
|
+ Neighbor neighbor = neighbors.get(i);
|
|
|
+ String path = "p" + (i + 1);
|
|
|
+ String node = "m" + (i + 1);
|
|
|
+ cypher.append("OPTIONAL MATCH ").append(path).append("=");
|
|
|
+ if (neighbor.outgoing()) {
|
|
|
+ cypher.append("(n)-[:").append(id(neighbor.relationship())).append("]->(")
|
|
|
+ .append(node).append(":").append(id(neighbor.otherLabel())).append(")\n");
|
|
|
+ } else {
|
|
|
+ cypher.append("(").append(node).append(":").append(id(neighbor.otherLabel()))
|
|
|
+ .append(")-[:").append(id(neighbor.relationship())).append("]->(n)\n");
|
|
|
+ }
|
|
|
+ returns.add(path);
|
|
|
+ }
|
|
|
+ cypher.append("RETURN ").append(String.join(", ", returns)).append(" LIMIT ").append(MAX_ROWS);
|
|
|
+ return Optional.of(new PlannedCypher(cypher.toString(), "ENTITY_NEIGHBORHOOD",
|
|
|
+ Map.of("entity", entity.toMetadata(), "expandedRelationships",
|
|
|
+ neighbors.stream().map(Neighbor::relationship).distinct().toList()), 0.88));
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<PropertyFilter> filtersFromSampledValues(String question, String text, Long graphSourceId,
|
|
|
+ GraphSchemaSnapshot schema) {
|
|
|
+ List<PropertyFilter> result = new ArrayList<>();
|
|
|
+ for (GraphSchemaSnapshot.NodeSchema node : schema.nodes()) {
|
|
|
+ for (String property : node.properties().keySet()) {
|
|
|
+ if (!isFilterProperty(property, text)) continue;
|
|
|
+ for (String value : sampleValues(graphSourceId, node.label(), property)) {
|
|
|
+ if (valueMatchesQuestion(value, question, text)) {
|
|
|
+ result.add(new PropertyFilter(node.label(), property, value));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return result.stream().distinct().limit(4).toList();
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private List<String> sampleValues(Long graphSourceId, String label, String property) {
|
|
|
+ try {
|
|
|
+ String cypher = "MATCH (n:" + id(label) + ") WHERE n." + id(property)
|
|
|
+ + " IS NOT NULL RETURN DISTINCT n." + id(property) + " AS value LIMIT 100";
|
|
|
+ Object records = graphs.executeQuery(graphSourceId, cypher).get("records");
|
|
|
+ if (!(records instanceof Collection<?> rows)) return List.of();
|
|
|
+ List<String> values = new ArrayList<>();
|
|
|
+ for (Object row : rows) {
|
|
|
+ if (row instanceof Map<?, ?> map && map.get("value") != null) {
|
|
|
+ values.add(String.valueOf(map.get("value")));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return values;
|
|
|
+ } catch (Exception ignored) {
|
|
|
+ return List.of();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Optional<DocumentEvidence> documentEvidence(GraphSchemaSnapshot schema, String entityLabel) {
|
|
|
+ List<String> documentLabels = schema.labelNames().stream()
|
|
|
+ .filter(label -> label.toLowerCase(Locale.ROOT).contains("document")
|
|
|
+ || label.toLowerCase(Locale.ROOT).contains("evidence")
|
|
|
+ || label.equalsIgnoreCase("Doc")).toList();
|
|
|
+ for (String documentLabel : documentLabels) {
|
|
|
+ for (GraphSchemaSnapshot.RelationshipSchema relationship : schema.relationships()) {
|
|
|
+ for (GraphSchemaSnapshot.RelationshipEndpoint endpoint : relationship.endpoints()) {
|
|
|
+ if (endpoint.startLabel().equals(documentLabel) && endpoint.endLabel().equals(entityLabel)) {
|
|
|
+ return Optional.of(new DocumentEvidence(documentLabel, relationship.type(), true));
|
|
|
+ }
|
|
|
+ if (endpoint.endLabel().equals(documentLabel) && endpoint.startLabel().equals(entityLabel)) {
|
|
|
+ return Optional.of(new DocumentEvidence(documentLabel, relationship.type(), false));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return Optional.empty();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static List<Neighbor> neighbors(GraphSchemaSnapshot schema, Set<String> entityLabels) {
|
|
|
+ LinkedHashSet<Neighbor> result = new LinkedHashSet<>();
|
|
|
+ for (GraphSchemaSnapshot.RelationshipSchema relationship : schema.relationships()) {
|
|
|
+ for (GraphSchemaSnapshot.RelationshipEndpoint endpoint : relationship.endpoints()) {
|
|
|
+ if (entityLabels.contains(endpoint.startLabel())) {
|
|
|
+ result.add(new Neighbor(relationship.type(), endpoint.endLabel(), true));
|
|
|
+ } else if (entityLabels.contains(endpoint.endLabel())) {
|
|
|
+ result.add(new Neighbor(relationship.type(), endpoint.startLabel(), false));
|
|
|
+ }
|
|
|
+ if (result.size() >= 8) return new ArrayList<>(result);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return new ArrayList<>(result);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static LabelScore bestLabel(String question, GraphSchemaSnapshot schema, String role) {
|
|
|
+ return schema.labelNames().stream()
|
|
|
+ .map(label -> new LabelScore(label, labelScore(question, label, role)))
|
|
|
+ .max(Comparator.comparingDouble(LabelScore::score))
|
|
|
+ .filter(score -> score.score() >= 0.25).orElse(null);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double labelScore(String question, String label, String role) {
|
|
|
+ String text = question == null ? "" : question.toLowerCase(Locale.ROOT);
|
|
|
+ String normalized = splitCamel(label).toLowerCase(Locale.ROOT);
|
|
|
+ double score = 0;
|
|
|
+ for (String token : normalized.split("[^a-z0-9]+")) {
|
|
|
+ if (!token.isBlank() && text.contains(token)) score += 0.35;
|
|
|
+ }
|
|
|
+ if (normalized.contains("sensor") && containsAny(text, "传感器", "雷达", "sensor")) score += role.equals("target") ? 1.0 : 0.4;
|
|
|
+ if (normalized.contains("document") && containsAny(text, "文档", "证据", "document", "evidence")) score += role.equals("target") ? 0.8 : 0.5;
|
|
|
+ if ((normalized.contains("site") || normalized.contains("region") || normalized.contains("area") || normalized.contains("location"))
|
|
|
+ && containsAny(text, "部署", "区域", "地点", "位置", "site", "region")) score += role.equals("target") ? 0.8 : 0.5;
|
|
|
+ if (normalized.contains("capability") && containsAny(text, "能力", "capability")) score += role.equals("target") ? 0.8 : 0.5;
|
|
|
+ if ((normalized.contains("system") || normalized.contains("equipment") || normalized.contains("platform"))
|
|
|
+ && containsAny(text, "系统", "装备", "平台", "候选", "system", "equipment", "platform")) score += role.equals("source") ? 1.0 : 0.3;
|
|
|
+ if ((normalized.contains("ship") || normalized.contains("carrier") || normalized.contains("platform"))
|
|
|
+ && containsAny(text, "舰", "船", "航母", "ship", "carrier")) score += 0.7;
|
|
|
+ return score;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Optional<String> property(String label, GraphSchemaSnapshot schema, String preferred) {
|
|
|
+ return schema.node(label).flatMap(node -> node.properties().keySet().stream()
|
|
|
+ .filter(name -> name.equalsIgnoreCase(preferred) || name.toLowerCase(Locale.ROOT).endsWith("_" + preferred))
|
|
|
+ .findFirst());
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Optional<String> propertyOrFirst(String label, GraphSchemaSnapshot schema, String preferred) {
|
|
|
+ Optional<String> matched = property(label, schema, preferred);
|
|
|
+ if (matched.isPresent()) return matched;
|
|
|
+ return schema.node(label).flatMap(node -> node.properties().keySet().stream().findFirst());
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean isFilterProperty(String property, String text) {
|
|
|
+ String name = property.toLowerCase(Locale.ROOT);
|
|
|
+ return (name.contains("status") && containsAny(text, "状态", "unknown", "ready", "维护", "status"))
|
|
|
+ || ((name.contains("nature") || name.contains("type") || name.contains("category"))
|
|
|
+ && containsAny(text, "公开", "public", "模拟", "类型", "类别"));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean valueMatchesQuestion(String value, String question, String text) {
|
|
|
+ String normalized = value.toLowerCase(Locale.ROOT);
|
|
|
+ if (text.contains(normalized)) return true;
|
|
|
+ if (normalized.contains("unknown") && containsAny(text, "未知", "unknown")) return true;
|
|
|
+ if (normalized.contains("public") && containsAny(text, "公开", "public")) return true;
|
|
|
+ if (normalized.contains("maintenance") && containsAny(text, "维护", "maintenance")) return true;
|
|
|
+ if (normalized.contains("ready") && containsAny(text, "可用", "就绪", "ready")) return true;
|
|
|
+ if (normalized.contains("simulated") && containsAny(text, "模拟", "simulated")) return true;
|
|
|
+ return question != null && question.contains(value);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Optional<String> scoreProperty(GraphSchemaSnapshot.RelationshipSchema relationship) {
|
|
|
+ return relationship.properties().keySet().stream().filter(property -> {
|
|
|
+ String name = property.toLowerCase(Locale.ROOT);
|
|
|
+ return name.equals("score") || name.endsWith("_score") || name.contains("rank")
|
|
|
+ || name.contains("priority") || name.equals("weight") || name.equals("rating");
|
|
|
+ }).findFirst();
|
|
|
+ }
|
|
|
+
|
|
|
+ private LinkedHashSet<String> capabilityTerms(String question, String text, Long graphSourceId,
|
|
|
+ String capabilityLabel, List<String> properties,
|
|
|
+ Set<String> genericTerms) {
|
|
|
+ LinkedHashSet<String> result = new LinkedHashSet<>();
|
|
|
+ for (String property : properties) {
|
|
|
+ for (String value : sampleValues(graphSourceId, capabilityLabel, property)) {
|
|
|
+ result.addAll(overlappingTerms(value, question, text, genericTerms));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static List<String> overlappingTerms(String value, String question, String text, Set<String> genericTerms) {
|
|
|
+ if (value == null || value.isBlank()) return List.of();
|
|
|
+ LinkedHashSet<String> result = new LinkedHashSet<>();
|
|
|
+ String normalized = value.toLowerCase(Locale.ROOT);
|
|
|
+ if (text.contains(normalized) && normalized.length() >= 2) result.add(value);
|
|
|
+ java.util.regex.Matcher han = Pattern.compile("\\p{IsHan}+").matcher(value);
|
|
|
+ while (han.find()) {
|
|
|
+ String run = han.group();
|
|
|
+ if (question != null && question.contains(run) && !genericTerms.contains(run)) result.add(run);
|
|
|
+ for (int i = 0; i < run.length() - 1; i++) {
|
|
|
+ String term = run.substring(i, i + 2);
|
|
|
+ if (question != null && question.contains(term) && !genericTerms.contains(term)) result.add(term);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for (String token : normalized.split("[^a-z0-9]+")) {
|
|
|
+ if (token.length() >= 3 && text.contains(token)) result.add(token);
|
|
|
+ }
|
|
|
+ return new ArrayList<>(result);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String capabilityCondition(String variable, List<String> properties, Set<String> terms) {
|
|
|
+ List<String> parts = new ArrayList<>();
|
|
|
+ for (String property : properties) {
|
|
|
+ for (String term : terms) {
|
|
|
+ if (containsHan(term)) {
|
|
|
+ parts.add(variable + "." + id(property) + " CONTAINS " + lit(term));
|
|
|
+ } else {
|
|
|
+ parts.add("toLower(toString(" + variable + "." + id(property) + ")) CONTAINS "
|
|
|
+ + lit(term.toLowerCase(Locale.ROOT)));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return "(" + String.join(" OR ", parts) + ")";
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean containsHan(String value) {
|
|
|
+ return value != null && Pattern.compile("\\p{IsHan}").matcher(value).find();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean enabled(RagQuery query) {
|
|
|
+ Object value = query.getFilters() == null ? null : query.getFilters().get("enableGenericGraphPlanner");
|
|
|
+ return !Boolean.FALSE.equals(value);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean modeEnabled(RagQuery query, String mode) {
|
|
|
+ Object value = query.getFilters() == null ? null : query.getFilters().get("enabledGenericQueryModes");
|
|
|
+ if (!(value instanceof List<?> list) || list.isEmpty()) return true;
|
|
|
+ return list.stream().map(String::valueOf).anyMatch(mode::equalsIgnoreCase);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Set<String> genericTerms(RagQuery query) {
|
|
|
+ Object value = query.getFilters() == null ? null : query.getFilters().get("genericTerms");
|
|
|
+ if (value instanceof List<?> list && !list.isEmpty()) {
|
|
|
+ return list.stream().map(String::valueOf)
|
|
|
+ .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
|
|
|
+ }
|
|
|
+ return Set.of("保障", "补给", "支援", "能力", "任务", "平台", "模拟", "直接", "匹配", "组合",
|
|
|
+ "淇濋殰", "琛ョ粰", "鏀彺", "鑳藉姏", "浠诲姟", "骞冲彴", "妯℃嫙", "鐩存帴", "鍖归厤", "缁勫悎");
|
|
|
+ }
|
|
|
+ private static String id(String value) {
|
|
|
+ return "`" + value.replace("`", "``") + "`";
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String lit(String value) {
|
|
|
+ return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'";
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String splitCamel(String value) {
|
|
|
+ return value == null ? "" : value.replaceAll("([a-z])([A-Z])", "$1 $2").replace('_', ' ');
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean containsAny(String text, String... values) {
|
|
|
+ for (String value : values) {
|
|
|
+ if (text.contains(value)) return true;
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ public record PlannedCypher(String cypher, String generationMode, Map<String, Object> intentPlan,
|
|
|
+ double groundingConfidence) {
|
|
|
+ public PlannedCypher {
|
|
|
+ groundingConfidence = Math.max(0, Math.min(1, groundingConfidence));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ private record RankingBranch(String otherLabel, String relationship, String entityLabel, boolean incomingToEntity,
|
|
|
+ String scoreProperty) {}
|
|
|
+ private record CountCandidate(String sourceLabel, String targetLabel, String relationship, boolean outgoing, double score) {}
|
|
|
+ private record CapabilityBranch(String entityLabel, String relationship, boolean outgoingToCapability) {}
|
|
|
+ private record LabelScore(String label, double score) {}
|
|
|
+ private record PropertyFilter(String label, String property, String value) {
|
|
|
+ Map<String, Object> toMetadata() {
|
|
|
+ return Map.of("label", label, "property", property, "value", value);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ private record DocumentEvidence(String documentLabel, String relationship, boolean outgoingFromDocument) {}
|
|
|
+ private record Neighbor(String relationship, String otherLabel, boolean outgoing) {}
|
|
|
+}
|