Sfoglia il codice sorgente

1. 实现了统一 RAG 检索编排,支持文档、知识库、结构化数据与知识图谱多源召回;
2. 新增了图谱 Schema 发现、校验与 Cypher 生成能力,支持业务子图筛选和元数据治理;
3. 新增了结构化数据 SQL 生成与执行链路,支持显式规则和 Vanna AI Bridge 两种模式;
4. 实现了 RAG 知识库配置、来源绑定与查询样例记忆,支持检索规划和候选示例复用;
5. 新增了 RAG 能力评估与治理接口,提供实体识别、Schema 链接和语义目录管理;
6. 实现了本地 RAG AI Bridge 服务,提供 Text2SQL、Text2Cypher 与答案生成接口;
7. 完善了 Embedding Bridge 混合检索接口,支持过滤条件、分类字段和向量批量生成;
8. 新增了 RAG 工作台与治理页面,支持来源选择、证据展示和能力状态检查;
9. 新增了 RAG 架构设计、集成交接文档与核心检索单元测试。

weisijie 1 mese fa
parent
commit
daf96216ee
100 ha cambiato i file con 5024 aggiunte e 90 eliminazioni
  1. 2 0
      .gitignore
  2. 18 9
      backend/embedding-bridge/backend/indexing/milvus_client.py
  3. 1 0
      backend/embedding-bridge/backend/indexing/milvus_writer.py
  4. 44 0
      backend/embedding-bridge/server.py
  5. 3 0
      backend/rag-ai-bridge/.env.example
  6. 28 0
      backend/rag-ai-bridge/README.md
  7. 27 0
      backend/rag-ai-bridge/config.example.yaml
  8. 7 0
      backend/rag-ai-bridge/requirements.txt
  9. 438 0
      backend/rag-ai-bridge/server.py
  10. 30 0
      backend/rag-ai-bridge/test_server.py
  11. 7 0
      backend/src/main/java/com/agent/management/config/RagAiBridgeProperties.java
  12. 6 0
      backend/src/main/java/com/agent/management/model/entity/RagKnowledgeBase.java
  13. 4 0
      backend/src/main/java/com/agent/management/model/entity/RagKnowledgeSourceBinding.java
  14. 25 0
      backend/src/main/java/com/agent/management/model/entity/RagQueryExample.java
  15. 3 0
      backend/src/main/java/com/agent/management/rag/api/RagRetriever.java
  16. 32 0
      backend/src/main/java/com/agent/management/rag/bridge/RagAiBridgeClient.java
  17. 57 0
      backend/src/main/java/com/agent/management/rag/capability/GraphBusinessSubgraphSelector.java
  18. 53 0
      backend/src/main/java/com/agent/management/rag/capability/GraphOnboardingAssessmentService.java
  19. 13 0
      backend/src/main/java/com/agent/management/rag/capability/RagCapabilityProfile.java
  20. 77 0
      backend/src/main/java/com/agent/management/rag/capability/RagCapabilityProfileService.java
  21. 24 0
      backend/src/main/java/com/agent/management/rag/capability/RagEntityMentionExtractor.java
  22. 134 0
      backend/src/main/java/com/agent/management/rag/capability/RagGovernanceService.java
  23. 80 0
      backend/src/main/java/com/agent/management/rag/capability/RagSchemaLinker.java
  24. 54 0
      backend/src/main/java/com/agent/management/rag/capability/RagSemanticCatalogService.java
  25. 7 0
      backend/src/main/java/com/agent/management/rag/capability/RagSemanticEntry.java
  26. 31 0
      backend/src/main/java/com/agent/management/rag/capability/RagTextSimilarity.java
  27. 29 0
      backend/src/main/java/com/agent/management/rag/capability/SqlSchemaSnapshot.java
  28. 2 0
      backend/src/main/java/com/agent/management/rag/controller/DocumentRagDebugController.java
  29. 2 0
      backend/src/main/java/com/agent/management/rag/controller/GraphRagDebugController.java
  30. 2 0
      backend/src/main/java/com/agent/management/rag/controller/KnowledgeBaseRagDebugController.java
  31. 35 0
      backend/src/main/java/com/agent/management/rag/controller/RagAnswerController.java
  32. 67 0
      backend/src/main/java/com/agent/management/rag/controller/RagCapabilityController.java
  33. 5 0
      backend/src/main/java/com/agent/management/rag/controller/RagEvaluationController.java
  34. 2 0
      backend/src/main/java/com/agent/management/rag/controller/StructuredRagDebugController.java
  35. 31 0
      backend/src/main/java/com/agent/management/rag/document/DocumentRagRetriever.java
  36. 25 0
      backend/src/main/java/com/agent/management/rag/evaluation/RagEvaluationService.java
  37. 1 0
      backend/src/main/java/com/agent/management/rag/graph/CypherGenerationService.java
  38. 1 0
      backend/src/main/java/com/agent/management/rag/graph/ExplicitCypherGenerationService.java
  39. 120 0
      backend/src/main/java/com/agent/management/rag/graph/GraphRagRetriever.java
  40. 32 0
      backend/src/main/java/com/agent/management/rag/graph/GraphSchemaDiscoveryService.java
  41. 107 0
      backend/src/main/java/com/agent/management/rag/graph/GraphSchemaSnapshot.java
  42. 84 0
      backend/src/main/java/com/agent/management/rag/graph/GraphSchemaValidator.java
  43. 86 0
      backend/src/main/java/com/agent/management/rag/graph/Neo4jGraphRagCypherGenerationService.java
  44. 146 0
      backend/src/main/java/com/agent/management/rag/kb/KnowledgeBaseRagRetriever.java
  45. 42 0
      backend/src/main/java/com/agent/management/rag/kb/RagKnowledgeBaseConfigService.java
  46. 32 0
      backend/src/main/java/com/agent/management/rag/kb/RagQuestionPlanningService.java
  47. 21 0
      backend/src/main/java/com/agent/management/rag/kb/RagRetrievalExecutorConfig.java
  48. 64 0
      backend/src/main/java/com/agent/management/rag/memory/RagExampleCandidateService.java
  49. 92 0
      backend/src/main/java/com/agent/management/rag/memory/RagExampleMemoryService.java
  50. 4 0
      backend/src/main/java/com/agent/management/rag/model/KnowledgeBaseRagRequest.java
  51. 4 0
      backend/src/main/java/com/agent/management/rag/model/KnowledgeBaseRagResult.java
  52. 4 0
      backend/src/main/java/com/agent/management/rag/model/RagEvidence.java
  53. 4 0
      backend/src/main/java/com/agent/management/rag/model/RagQuery.java
  54. 4 0
      backend/src/main/java/com/agent/management/rag/model/RagRetrievalResult.java
  55. 4 0
      backend/src/main/java/com/agent/management/rag/model/RagSourceRef.java
  56. 2 0
      backend/src/main/java/com/agent/management/rag/model/RagSourceType.java
  57. 2 0
      backend/src/main/java/com/agent/management/rag/structured/ExplicitSqlGenerationService.java
  58. 2 0
      backend/src/main/java/com/agent/management/rag/structured/SqlGenerationService.java
  59. 115 0
      backend/src/main/java/com/agent/management/rag/structured/StructuredDataRagRetriever.java
  60. 84 0
      backend/src/main/java/com/agent/management/rag/structured/VannaSqlGenerationService.java
  61. 2 0
      backend/src/main/java/com/agent/management/repository/RagKnowledgeBaseRepository.java
  62. 2 0
      backend/src/main/java/com/agent/management/repository/RagKnowledgeSourceBindingRepository.java
  63. 15 0
      backend/src/main/java/com/agent/management/repository/RagQueryExampleRepository.java
  64. 7 0
      backend/src/main/java/com/agent/management/service/GraphSourceService.java
  65. 80 0
      backend/src/main/java/com/agent/management/service/Neo4jExecutorService.java
  66. 6 0
      backend/src/main/java/com/agent/management/service/SchemaExplorerService.java
  67. 17 0
      backend/src/main/java/com/agent/management/service/impl/EmbeddingBridgeClient.java
  68. 15 0
      backend/src/main/java/com/agent/management/service/impl/GraphSourceServiceImpl.java
  69. 13 0
      backend/src/main/java/com/agent/management/service/impl/SchemaExplorerServiceImpl.java
  70. 31 26
      backend/src/main/java/com/agent/management/service/impl/VectorStoreServiceImpl.java
  71. 6 0
      backend/src/main/resources/application.yml.example
  72. 35 0
      backend/src/test/java/com/agent/management/rag/capability/GraphBusinessSubgraphSelectorTest.java
  73. 3 0
      backend/src/test/java/com/agent/management/rag/capability/RagEntityMentionExtractorTest.java
  74. 54 0
      backend/src/test/java/com/agent/management/rag/capability/RagSchemaLinkerTest.java
  75. 3 0
      backend/src/test/java/com/agent/management/rag/graph/ExplicitCypherGenerationServiceTest.java
  76. 97 0
      backend/src/test/java/com/agent/management/rag/graph/GraphRagRetrieverTest.java
  77. 39 0
      backend/src/test/java/com/agent/management/rag/graph/GraphSchemaSnapshotTest.java
  78. 37 0
      backend/src/test/java/com/agent/management/rag/graph/GraphSchemaValidatorTest.java
  79. 107 0
      backend/src/test/java/com/agent/management/rag/kb/KnowledgeBaseRagRetrieverTest.java
  80. 49 0
      backend/src/test/java/com/agent/management/rag/kb/RagKnowledgeBaseConfigServiceTest.java
  81. 34 0
      backend/src/test/java/com/agent/management/rag/memory/RagExampleCandidateServiceTest.java
  82. 58 0
      backend/src/test/java/com/agent/management/rag/memory/RagExampleMemoryServiceTest.java
  83. 3 0
      backend/src/test/java/com/agent/management/rag/structured/ExplicitSqlGenerationServiceTest.java
  84. 102 0
      backend/src/test/java/com/agent/management/rag/structured/StructuredDataRagRetrieverTest.java
  85. 64 0
      backend/src/test/java/com/agent/management/rag/structured/VannaSqlGenerationServiceTest.java
  86. 32 0
      backend/src/test/java/com/agent/management/service/impl/VectorStoreServiceImplTest.java
  87. 236 0
      docs/rag/RAG_INTEGRATION_HANDOFF.md
  88. 296 0
      docs/rag/graph-rag-design.md
  89. 161 0
      docs/rag/rag-advanced-architecture-review.md
  90. 341 0
      docs/rag/structured-data-rag-design.md
  91. 38 30
      frontend/package-lock.json
  92. 2 0
      frontend/package.json
  93. 119 0
      frontend/src/api/rag.js
  94. 18 25
      frontend/src/components/layout/AppSidebar.vue
  95. 40 0
      frontend/src/components/rag/RagChatPanel.vue
  96. 61 0
      frontend/src/components/rag/RagEvidencePanel.vue
  97. 49 0
      frontend/src/components/rag/RagSourcePanel.vue
  98. 12 0
      frontend/src/router/index.js
  99. 28 0
      frontend/src/types/rag.js
  100. 250 0
      frontend/src/views/knowledge/RagGovernance.vue

+ 2 - 0
.gitignore

@@ -28,6 +28,8 @@ backend/src/main/resources/application.yml
 .env
 .env.*
 !.env.example
+backend/rag-ai-bridge/.env
+backend/rag-ai-bridge/config.yaml
 
 # ===== 日志 =====
 logs/

+ 18 - 9
backend/embedding-bridge/backend/indexing/milvus_client.py

@@ -242,6 +242,8 @@ class MilvusStore:
     ) -> list[dict]:
         output_fields = [
             "text",
+            "document_id",
+            "category_id",
             "filename",
             "file_type",
             "page_number",
@@ -280,17 +282,20 @@ class MilvusStore:
         formatted_results = []
         for hits in results:
             for hit in hits:
+                entity = hit.get("entity", hit)
                 formatted_results.append({
                     "id": hit.get("id"),
-                    "text": hit.get("text", ""),
-                    "filename": hit.get("filename", ""),
-                    "file_type": hit.get("file_type", ""),
-                    "page_number": hit.get("page_number", 0),
-                    "chunk_id": hit.get("chunk_id", ""),
-                    "parent_chunk_id": hit.get("parent_chunk_id", ""),
-                    "root_chunk_id": hit.get("root_chunk_id", ""),
-                    "chunk_level": hit.get("chunk_level", 0),
-                    "chunk_idx": hit.get("chunk_idx", 0),
+                    "text": entity.get("text", ""),
+                    "document_id": entity.get("document_id"),
+                    "category_id": entity.get("category_id"),
+                    "filename": entity.get("filename", ""),
+                    "file_type": entity.get("file_type", ""),
+                    "page_number": entity.get("page_number", 0),
+                    "chunk_id": entity.get("chunk_id", ""),
+                    "parent_chunk_id": entity.get("parent_chunk_id", ""),
+                    "root_chunk_id": entity.get("root_chunk_id", ""),
+                    "chunk_level": entity.get("chunk_level", 0),
+                    "chunk_idx": entity.get("chunk_idx", 0),
                     "score": hit.get("distance", 0.0),
                 })
         return formatted_results
@@ -310,6 +315,8 @@ class MilvusStore:
                 limit=top_k,
                 output_fields=[
                     "text",
+                    "document_id",
+                    "category_id",
                     "filename",
                     "file_type",
                     "page_number",
@@ -329,6 +336,8 @@ class MilvusStore:
                 formatted_results.append({
                     "id": hit.get("id"),
                     "text": hit.get("entity", {}).get("text", ""),
+                    "document_id": hit.get("entity", {}).get("document_id"),
+                    "category_id": hit.get("entity", {}).get("category_id"),
                     "filename": hit.get("entity", {}).get("filename", ""),
                     "file_type": hit.get("entity", {}).get("file_type", ""),
                     "page_number": hit.get("entity", {}).get("page_number", 0),

+ 1 - 0
backend/embedding-bridge/backend/indexing/milvus_writer.py

@@ -38,6 +38,7 @@ class MilvusWriter:
                         "dense_embedding": dense_emb,
                         "text": doc["text"],
                         "document_id": doc.get("document_id"),
+                        "category_id": doc.get("category_id"),
                         "filename": doc["filename"],
                         "file_type": doc["file_type"],
                         "file_path": doc.get("file_path", ""),

+ 44 - 0
backend/embedding-bridge/server.py

@@ -152,6 +152,17 @@ class DeleteByIdsRequest(BaseModel):
     vector_ids: List[str]
 
 
+class RetrieveRequest(BaseModel):
+    query: str = Field(min_length=1)
+    top_k: int = Field(default=5, ge=1, le=100)
+    mode: str = "hybrid"
+    filter_expr: str = ""
+
+
+class EmbedRequest(BaseModel):
+    texts: List[str] = Field(min_length=1, max_length=500)
+
+
 # ---------------------------------------------------------------------------
 # 预导入含 C 扩展的依赖(必须在主线程完成,避免在 uvicorn 工作线程中首次加载触发 segfault)
 # ---------------------------------------------------------------------------
@@ -188,6 +199,15 @@ def health():
     return {"status": "ok"}
 
 
+@app.post("/embed")
+def embed(req: EmbedRequest):
+    try:
+        from backend.indexing.embedding import embedding_service
+        return {"vectors": embedding_service.get_embeddings(req.texts)}
+    except Exception as exc:
+        raise HTTPException(status_code=500, detail=f"embedding failed: {exc}") from exc
+
+
 # ---------------------------------------------------------------------------
 # 文档分块 + 向量化
 # ---------------------------------------------------------------------------
@@ -210,6 +230,7 @@ def index_document(req: IndexRequest):
         # 为所有 chunk 注入 document_id
         for c in chunks:
             c["document_id"] = req.document_id
+            c["category_id"] = req.category_id
 
         writer = _get_writer()
         vector_ids = writer.write_chunks(chunks)
@@ -294,6 +315,29 @@ def delete_by_vector_ids(req: DeleteByIdsRequest):
 # ---------------------------------------------------------------------------
 # 主入口
 # ---------------------------------------------------------------------------
+@app.post("/retrieve")
+def retrieve(req: RetrieveRequest):
+    try:
+        from backend.indexing.embedding import embedding_service
+        from backend.indexing.milvus_client import get_milvus_store
+
+        mode = req.mode.strip().lower()
+        if mode not in ("dense", "hybrid"):
+            raise HTTPException(status_code=400, detail="mode must be dense or hybrid")
+        dense = embedding_service.get_embeddings([req.query])[0]
+        store = get_milvus_store()
+        if mode == "dense":
+            hits = store.dense_retrieve(dense, req.top_k, req.filter_expr)
+        else:
+            hits = store.hybrid_retrieve(dense, req.query, req.top_k, filter_expr=req.filter_expr)
+        return {"mode": mode, "top_k": req.top_k, "hits": hits}
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error("[retrieve] failed: %s", e, exc_info=True)
+        raise HTTPException(status_code=500, detail=f"retrieval failed: {e}")
+
+
 if __name__ == "__main__":
     parser = argparse.ArgumentParser()
     parser.add_argument("--port", type=int, default=int(os.getenv("EMBEDDING_BRIDGE_PORT", "18732")))

+ 3 - 0
backend/rag-ai-bridge/.env.example

@@ -0,0 +1,3 @@
+# 复制为 .env 后填写真实值。不要提交 .env。
+OPENAI_API_KEY=
+NEO4J_PASSWORD=

+ 28 - 0
backend/rag-ai-bridge/README.md

@@ -0,0 +1,28 @@
+# RAG AI Bridge
+
+该服务只生成 SQL/Cypher,不直接执行业务 SQL 或最终 Cypher。生成结果返回 Java 后,由现有 Guard 校验并执行。
+
+## 启动
+
+```powershell
+cd backend/rag-ai-bridge
+python -m venv .venv
+.\.venv\Scripts\activate
+pip install -r requirements.txt
+copy config.example.yaml config.yaml
+copy .env.example .env
+# 修改 config.yaml 和 .env
+python server.py
+```
+
+`config.yaml` 保存服务地址、模型名称、Neo4j 地址及只读策略等非敏感配置;`.env` 只保存 `OPENAI_API_KEY` 和 `NEO4J_PASSWORD`。两者均为本地文件,不应提交。
+
+配置优先级:系统环境变量 > `.env` > `config.yaml` > 内置默认值。没有 `config.yaml` 时服务仍会使用默认值启动,并输出复制示例文件的提示。缺少密钥时服务也能启动,`GET /health` 会分别返回 `llmConfigured=false` 或 `neo4jConfigured=false`。
+
+接口:
+
+- `GET /health`
+- `POST /text2sql`
+- `POST /text2cypher`
+
+环境变量覆盖项包括:`RAG_AI_BRIDGE_HOST`、`RAG_AI_BRIDGE_PORT`、`LLM_PROVIDER`、`OPENAI_MODEL`、`OPENAI_BASE_URL`、`LLM_TEMPERATURE`、`LLM_TIMEOUT`、`VANNA_ENABLED`、`TEXT2CYPHER_ENABLED`、`NEO4J_URI`、`NEO4J_USERNAME`、`READONLY_SQL`、`READONLY_CYPHER`、`DEFAULT_LIMIT`。

+ 27 - 0
backend/rag-ai-bridge/config.example.yaml

@@ -0,0 +1,27 @@
+server:
+  host: 127.0.0.1
+  port: 18733
+
+llm:
+  provider: openai
+  model: gpt-4o-mini
+  base_url: https://api.openai.com/v1
+  temperature: 1.0
+  timeout: 60
+  generation_max_tokens: 2048
+  answer_max_tokens: 4096
+
+vanna:
+  enabled: true
+
+text2cypher:
+  enabled: true
+
+neo4j:
+  uri: bolt://127.0.0.1:7687
+  username: neo4j
+
+security:
+  readonly_sql: true
+  readonly_cypher: true
+  default_limit: 50

+ 7 - 0
backend/rag-ai-bridge/requirements.txt

@@ -0,0 +1,7 @@
+fastapi>=0.112,<0.113
+uvicorn>=0.30,<0.31
+PyYAML>=6,<7
+python-dotenv>=1,<2
+vanna[openai]>=0.7,<1
+openai>=1.51,<3
+numpy==1.26.4

+ 438 - 0
backend/rag-ai-bridge/server.py

@@ -0,0 +1,438 @@
+import logging
+import json
+import os
+import re
+from pathlib import Path
+from typing import Any
+
+import yaml
+from dotenv import load_dotenv
+from fastapi import FastAPI, HTTPException
+from pydantic import BaseModel, Field
+
+BRIDGE_DIR = Path(__file__).resolve().parent
+CONFIG_PATH = BRIDGE_DIR / "config.yaml"
+ENV_PATH = BRIDGE_DIR / ".env"
+
+DEFAULTS = {
+    "server": {"host": "127.0.0.1", "port": 18733},
+    "llm": {
+        "provider": "openai",
+        "model": "gpt-4o-mini",
+        "base_url": "https://api.openai.com/v1",
+        "temperature": 1.0,
+        "timeout": 60,
+        "generation_max_tokens": 2048,
+        "answer_max_tokens": 4096,
+    },
+    "vanna": {"enabled": True},
+    "text2cypher": {"enabled": True},
+    "neo4j": {"uri": "bolt://127.0.0.1:7687", "username": "neo4j"},
+    "security": {"readonly_sql": True, "readonly_cypher": True, "default_limit": 50},
+}
+
+ENV_MAPPINGS = {
+    "RAG_AI_BRIDGE_HOST": ("server", "host", str),
+    "RAG_AI_BRIDGE_PORT": ("server", "port", int),
+    "LLM_PROVIDER": ("llm", "provider", str),
+    "OPENAI_MODEL": ("llm", "model", str),
+    "OPENAI_BASE_URL": ("llm", "base_url", str),
+    "LLM_TEMPERATURE": ("llm", "temperature", float),
+    "LLM_TIMEOUT": ("llm", "timeout", int),
+    "LLM_GENERATION_MAX_TOKENS": ("llm", "generation_max_tokens", int),
+    "LLM_ANSWER_MAX_TOKENS": ("llm", "answer_max_tokens", int),
+    "VANNA_ENABLED": ("vanna", "enabled", "bool"),
+    "TEXT2CYPHER_ENABLED": ("text2cypher", "enabled", "bool"),
+    "NEO4J_URI": ("neo4j", "uri", str),
+    "NEO4J_USERNAME": ("neo4j", "username", str),
+    "READONLY_SQL": ("security", "readonly_sql", "bool"),
+    "READONLY_CYPHER": ("security", "readonly_cypher", "bool"),
+    "DEFAULT_LIMIT": ("security", "default_limit", int),
+}
+
+
+def _merge(base: dict, override: dict) -> dict:
+    result = {key: value.copy() if isinstance(value, dict) else value for key, value in base.items()}
+    for key, value in override.items():
+        if isinstance(value, dict) and isinstance(result.get(key), dict):
+            result[key] = _merge(result[key], value)
+        else:
+            result[key] = value
+    return result
+
+
+def _convert(value: str, converter):
+    if converter == "bool":
+        return value.strip().lower() in {"1", "true", "yes", "on"}
+    return converter(value)
+
+
+def load_settings() -> dict:
+    file_config = {}
+    if CONFIG_PATH.exists():
+        with CONFIG_PATH.open("r", encoding="utf-8") as stream:
+            file_config = yaml.safe_load(stream) or {}
+        if not isinstance(file_config, dict):
+            raise ValueError("config.yaml root must be a mapping")
+    else:
+        logging.warning("config.yaml not found; using defaults. Copy config.example.yaml to config.yaml to customize.")
+
+    # override=False 保证操作系统环境变量优先于 .env。
+    load_dotenv(ENV_PATH, override=False)
+    settings = _merge(DEFAULTS, file_config)
+    for env_name, (section, key, converter) in ENV_MAPPINGS.items():
+        value = os.getenv(env_name)
+        if value is not None and value != "":
+            settings.setdefault(section, {})[key] = _convert(value, converter)
+    return settings
+
+
+SETTINGS = load_settings()
+app = FastAPI(title="RAG AI Bridge", version="1.1.0")
+
+
+class Text2SqlRequest(BaseModel):
+    query: str = Field(min_length=1)
+    datasourceId: int
+    dialect: str = "mysql"
+    ddl: str = ""
+    documentation: str = ""
+    examples: list[Any] = Field(default_factory=list)
+    tableWhitelist: list[str] = Field(default_factory=list)
+    maxRows: int = Field(default=SETTINGS["security"]["default_limit"], ge=1, le=1000)
+    entityMentions: list[str] = Field(default_factory=list)
+
+
+class Text2CypherRequest(BaseModel):
+    query: str = Field(min_length=1)
+    graphSourceId: int
+    schema: str = ""
+    examples: list[Any] = Field(default_factory=list)
+    allowedLabels: list[str] = Field(default_factory=list)
+    allowedRelationships: list[str] = Field(default_factory=list)
+    allowedProperties: dict[str, list[str]] = Field(default_factory=dict)
+    businessRules: str = ""
+    maxDepth: int = Field(default=3, ge=1, le=10)
+    entityMentions: list[str] = Field(default_factory=list)
+
+
+class AnswerRequest(BaseModel):
+    question: str = Field(min_length=1)
+    evidences: list[dict[str, Any]] = Field(default_factory=list)
+
+
+class GovernanceRequest(BaseModel):
+    sourceType: str
+    schemaText: str
+    sourceDescription: str = ""
+    maxExamples: int = Field(default=8, ge=1, le=20)
+
+
+class PlanRequest(BaseModel):
+    question: str
+    capabilities: dict[str, str] = Field(default_factory=dict)
+
+
+class RepairRequest(BaseModel):
+    language: str
+    question: str
+    query: str
+    error: str
+    schemaText: str
+    maxRows: int = 50
+    maxDepth: int = 3
+
+
+def _read_only(text: str, language: str) -> str:
+    match = re.search(r"```(?:sql|cypher)?\s*(.*?)```", text, re.I | re.S)
+    query = (match.group(1) if match else text).strip()
+    if query.endswith(";"):
+        query = query[:-1].rstrip()
+    if ";" in query:
+        raise ValueError(f"generated {language} contains multiple statements")
+    readonly_enabled = SETTINGS["security"]["readonly_sql" if language == "SQL" else "readonly_cypher"]
+    if not readonly_enabled:
+        return query
+    forbidden = r"\b(insert|update|delete|merge|create|drop|alter|truncate|grant|revoke|load\s+csv|call)\b"
+    if re.search(forbidden, query, re.I):
+        raise ValueError(f"generated {language} contains a write or unsafe operation")
+    if language == "SQL" and not re.match(r"^(select|with)\b", query, re.I):
+        raise ValueError(f"generated SQL is not a SELECT/CTE: {query[:200]!r}")
+    if language == "Cypher" and not re.match(r"^(match|optional\s+match|with|unwind)\b", query, re.I):
+        raise ValueError("generated Cypher is not read-only")
+    return query
+
+
+def _extract_code(text: str, language: str) -> str:
+    text = re.sub(r"<think>.*?</think>", "", text, flags=re.I | re.S).strip()
+    match = re.search(rf"```(?:{language.lower()})?\s*(.*?)```", text, re.I | re.S)
+    if match:
+        return match.group(1).strip()
+    start = r"\b(?:SELECT|WITH)\b" if language == "SQL" else r"\b(?:MATCH|OPTIONAL\s+MATCH|WITH|UNWIND)\b"
+    statement = re.search(start + r"[\s\S]*", text, re.I)
+    extracted = (statement.group(0) if statement else text).strip()
+    return re.split(r"\n\s*(?:Explanation|Reasoning|说明|解释)\s*[::]", extracted, maxsplit=1, flags=re.I)[0].strip()
+
+
+def _llm_configured() -> bool:
+    return SETTINGS["llm"]["provider"].lower() == "openai" and bool(os.getenv("OPENAI_API_KEY"))
+
+
+def _neo4j_configured() -> bool:
+    neo4j = SETTINGS["neo4j"]
+    return bool(neo4j.get("uri") and neo4j.get("username") and os.getenv("NEO4J_PASSWORD"))
+
+
+def _openai_client(timeout: int | None = None):
+    from openai import OpenAI
+    return OpenAI(api_key=os.environ["OPENAI_API_KEY"], base_url=SETTINGS["llm"]["base_url"],
+                  timeout=timeout or SETTINGS["llm"]["timeout"], max_retries=0)
+
+
+def _json_object(text: str) -> dict:
+    cleaned = re.sub(r"<think>.*?</think>", "", text or "", flags=re.I | re.S).strip()
+    fenced = re.search(r"```(?:json)?\s*(.*?)```", cleaned, re.I | re.S)
+    candidate = fenced.group(1) if fenced else cleaned
+    start, end = candidate.find("{"), candidate.rfind("}")
+    if start < 0 or end < start:
+        raise ValueError("LLM did not return a JSON object")
+    return json.loads(candidate[start:end + 1])
+
+
+def _short_error(error: str) -> str:
+    return re.sub(r"(?i)(password|token|api[_ -]?key)\s*[:=]\s*\S+", r"\1=[redacted]", error)[:800]
+
+
+@app.get("/health")
+def health():
+    return {
+        "ok": True,
+        "llmConfigured": _llm_configured(),
+        "neo4jConfigured": _neo4j_configured(),
+        "vannaEnabled": bool(SETTINGS["vanna"]["enabled"]),
+        "text2cypherEnabled": bool(SETTINGS["text2cypher"]["enabled"]),
+    }
+
+
+@app.post("/text2sql")
+def text2sql(req: Text2SqlRequest):
+    if not SETTINGS["vanna"]["enabled"]:
+        raise HTTPException(status_code=503, detail="Vanna SQL generation is disabled")
+    if not _llm_configured():
+        raise HTTPException(status_code=503, detail="OpenAI provider or OPENAI_API_KEY is not configured")
+    try:
+        import pandas as pd
+        from openai import OpenAI
+        from vanna.base import VannaBase
+        from vanna.openai import OpenAI_Chat
+
+        class RequestContext(VannaBase):
+            def __init__(self):
+                self.ddl = [req.ddl] if req.ddl else []
+                self.documentation = [req.documentation] if req.documentation else []
+                self.examples = req.examples
+
+            def get_related_ddl(self, question: str, **kwargs) -> list:
+                return self.ddl
+
+            def get_related_documentation(self, question: str, **kwargs) -> list:
+                return self.documentation
+
+            def get_similar_question_sql(self, question: str, **kwargs) -> list:
+                return self.examples
+
+            def generate_embedding(self, data: str, **kwargs) -> list[float]:
+                return []
+
+            def add_ddl(self, ddl: str, **kwargs) -> str:
+                self.ddl.append(ddl)
+                return str(len(self.ddl))
+
+            def add_documentation(self, documentation: str, **kwargs) -> str:
+                self.documentation.append(documentation)
+                return str(len(self.documentation))
+
+            def add_question_sql(self, question: str, sql: str, **kwargs) -> str:
+                self.examples.append({"question": question, "sql": sql})
+                return str(len(self.examples))
+
+            def get_training_data(self, **kwargs) -> pd.DataFrame:
+                return pd.DataFrame()
+
+            def remove_training_data(self, id: str, **kwargs) -> bool:
+                return False
+
+        class Vanna(RequestContext, OpenAI_Chat):
+            def __init__(self):
+                RequestContext.__init__(self)
+                client = OpenAI(
+                    api_key=os.environ["OPENAI_API_KEY"],
+                    base_url=SETTINGS["llm"]["base_url"],
+                    timeout=SETTINGS["llm"]["timeout"],
+                    max_retries=0,
+                )
+                OpenAI_Chat.__init__(self, client=client, config={
+                    "model": SETTINGS["llm"]["model"],
+                    "temperature": SETTINGS["llm"]["temperature"],
+                })
+
+            def submit_prompt(self, prompt, **kwargs) -> str:
+                response = self.client.chat.completions.create(
+                    model=SETTINGS["llm"]["model"],
+                    messages=prompt,
+                    temperature=SETTINGS["llm"]["temperature"],
+                    max_tokens=SETTINGS["llm"]["generation_max_tokens"],
+                )
+                return response.choices[0].message.content or ""
+
+        vn = Vanna()
+        prompt = [{
+            "role": "system",
+            "content": (
+                f"You generate exactly one final read-only {req.dialect} SQL query. "
+                "Return SQL only, without comments, explanation or intermediate queries. "
+                "Use only the supplied schema. Never generate DML or DDL. "
+                f"Limit results to at most {req.maxRows} rows."
+            ),
+        }, {
+            "role": "user",
+            "content": (
+                f"Schema:\n{req.ddl}\nDocumentation:\n{req.documentation}\n"
+                f"Allowed tables: {req.tableWhitelist}\nEntity mentions that must be grounded to real columns: {req.entityMentions}. Unless an exact stored value is supplied by examples, use LIKE for abbreviated entity names.\nExamples: {req.examples}\nQuestion: {req.query}"
+            ),
+        }]
+        sql = _read_only(_extract_code(vn.submit_prompt(prompt), "SQL"), "SQL")
+        return {"sql": sql, "confidence": 0.0, "usedContext": ["ddl"] if req.ddl else [], "warnings": []}
+    except HTTPException:
+        raise
+    except Exception as exc:
+        raise HTTPException(status_code=503, detail=f"Vanna SQL generation unavailable: {exc}") from exc
+
+
+@app.post("/text2cypher")
+def text2cypher(req: Text2CypherRequest):
+    if not SETTINGS["text2cypher"]["enabled"]:
+        raise HTTPException(status_code=503, detail="Text-to-Cypher generation is disabled")
+    if not _llm_configured():
+        raise HTTPException(status_code=503, detail="OpenAI provider or OPENAI_API_KEY is not configured")
+    try:
+        from openai import OpenAI
+
+        llm = OpenAI(
+            api_key=os.environ["OPENAI_API_KEY"],
+            base_url=SETTINGS["llm"]["base_url"],
+            timeout=SETTINGS["llm"]["timeout"],
+            max_retries=0,
+        )
+        prompt = (
+            "Generate one read-only Cypher query for the question. "
+            "Return only Cypher, without explanation. Never use CREATE, MERGE, DELETE, SET, REMOVE, DROP, LOAD CSV or CALL.\n"
+            "Return nodes, relationships, or paths that carry the requested properties; do not return only scalar projections.\n"
+            f"Schema:\n{req.schema}\n"
+            f"Allowed labels: {req.allowedLabels}\n"
+            f"Allowed relationships: {req.allowedRelationships}\n"
+            f"Allowed properties: {req.allowedProperties}\n"
+            f"Business rules:\n{req.businessRules}\n"
+            f"Maximum path depth: {req.maxDepth}\n"
+            f"Entity mentions that must be grounded to an allowed property (prefer name/title/code): {req.entityMentions}. User names may be abbreviations; unless an exact stored value is known from examples, use CONTAINS instead of equality.\n"
+            f"Examples:\n{req.examples}\n"
+            f"Question: {req.query}"
+        )
+        response = llm.chat.completions.create(
+            model=SETTINGS["llm"]["model"],
+            messages=[{"role": "user", "content": prompt}],
+            temperature=SETTINGS["llm"]["temperature"],
+            max_tokens=SETTINGS["llm"]["generation_max_tokens"],
+        )
+        cypher = _read_only(_extract_code(response.choices[0].message.content or "", "Cypher"), "Cypher")
+        return {"cypher": cypher, "confidence": 0.0, "usedSchema": req.allowedLabels, "warnings": []}
+    except HTTPException:
+        raise
+    except Exception as exc:
+        raise HTTPException(status_code=503, detail=f"Neo4j GraphRAG Cypher generation unavailable: {exc}") from exc
+
+
+@app.post("/answer")
+def answer(req: AnswerRequest):
+    if not _llm_configured():
+        raise HTTPException(status_code=503, detail="OpenAI provider or OPENAI_API_KEY is not configured")
+    try:
+        from openai import OpenAI
+
+        client = OpenAI(
+            api_key=os.environ["OPENAI_API_KEY"],
+            base_url=SETTINGS["llm"]["base_url"],
+            timeout=SETTINGS["llm"]["timeout"],
+            max_retries=0,
+        )
+        evidence_json = json.dumps(req.evidences[:20], ensure_ascii=False, default=str)
+        response = client.chat.completions.create(
+            model=SETTINGS["llm"]["model"],
+            messages=[
+                {"role": "system", "content": "你是多源RAG回答助手。只能依据给定证据作答,明确区分文档、结构化数据和图谱依据;证据不足时直说,不得编造。回答使用简体中文。"},
+                {"role": "user", "content": f"问题:{req.question}\n\n证据:{evidence_json}"},
+            ],
+            temperature=SETTINGS["llm"]["temperature"],
+            max_tokens=SETTINGS["llm"]["answer_max_tokens"],
+        )
+        return {"answer": response.choices[0].message.content or "", "warnings": []}
+    except Exception as exc:
+        raise HTTPException(status_code=503, detail=f"Answer generation unavailable: {exc}") from exc
+
+
+@app.post("/governance/suggest")
+def governance_suggest(req: GovernanceRequest):
+    if not _llm_configured(): raise HTTPException(status_code=503, detail="LLM is not configured")
+    prompt = f"""Analyze the real {req.sourceType} schema and propose a safe business-facing RAG policy.
+Return JSON only with keys: summary, allowedLabels, allowedRelationships, allowedProperties,
+tableWhitelist, documentation, businessRules, examples. examples must contain question plus sql or cypher.
+Never invent schema names. Prefer a coherent business subgraph over opening unrelated technical metadata.
+Generate at most {req.maxExamples} representative reviewed-candidate examples covering lookup, filtering,
+aggregation or graph traversal as applicable.
+Source description: {req.sourceDescription}
+Schema:\n{req.schemaText}"""
+    try:
+        response = _openai_client(12).chat.completions.create(model=SETTINGS["llm"]["model"],
+            messages=[{"role":"user","content":prompt}], temperature=SETTINGS["llm"]["temperature"],
+            max_tokens=SETTINGS["llm"]["answer_max_tokens"])
+        return _json_object(response.choices[0].message.content or "")
+    except Exception as exc:
+        raise HTTPException(status_code=503, detail=f"governance suggestion unavailable: {exc}") from exc
+
+
+@app.post("/plan")
+def plan(req: PlanRequest):
+    if not _llm_configured(): raise HTTPException(status_code=503, detail="LLM is not configured")
+    prompt = f"""Split the user question into source-specific retrieval questions. Return JSON only:
+{{"DOCUMENT":"...","STRUCTURED_DATA":"...","GRAPH":"..."}}.
+Omit sources that cannot contribute according to their capability description. Preserve the user's intent.
+Question: {req.question}\nCapabilities: {json.dumps(req.capabilities, ensure_ascii=False)}"""
+    try:
+        response = _openai_client(12).chat.completions.create(model=SETTINGS["llm"]["model"],
+            messages=[{"role":"user","content":prompt}], temperature=SETTINGS["llm"]["temperature"], max_tokens=1024)
+        return _json_object(response.choices[0].message.content or "")
+    except Exception as exc:
+        raise HTTPException(status_code=503, detail=f"question planning unavailable: {exc}") from exc
+
+
+@app.post("/repair")
+def repair(req: RepairRequest):
+    language = req.language.upper()
+    prompt = f"""Repair this read-only {language} query after EXPLAIN failed. Return only the corrected query.
+Use only the supplied schema. Make exactly one statement. Do not use write operations.
+Question: {req.question}\nSchema: {req.schemaText}\nFailed query: {req.query}\nError: {_short_error(req.error)}
+Maximum rows: {req.maxRows}; maximum graph depth: {req.maxDepth}."""
+    try:
+        response = _openai_client(20).chat.completions.create(model=SETTINGS["llm"]["model"],
+            messages=[{"role":"user","content":prompt}], temperature=SETTINGS["llm"]["temperature"],
+            max_tokens=SETTINGS["llm"]["generation_max_tokens"])
+        fixed = _read_only(_extract_code(response.choices[0].message.content or "", language), language)
+        return {"query": fixed}
+    except Exception as exc:
+        raise HTTPException(status_code=503, detail=f"query repair unavailable: {exc}") from exc
+
+
+if __name__ == "__main__":
+    import uvicorn
+
+    uvicorn.run(app, host=str(SETTINGS["server"]["host"]), port=int(SETTINGS["server"]["port"]))

+ 30 - 0
backend/rag-ai-bridge/test_server.py

@@ -0,0 +1,30 @@
+import pytest
+
+from server import _extract_code, _read_only
+
+
+@pytest.mark.parametrize(
+    ("language", "raw", "expected"),
+    [
+        ("SQL", "<think>hidden</think>```sql\nSELECT * FROM rescue_forces\n```", "SELECT * FROM rescue_forces"),
+        ("SQL", "Here is the query:\nSELECT name FROM rescue_forces\nExplanation: done", "SELECT name FROM rescue_forces"),
+        ("Cypher", "```cypher\nMATCH (m:Mission) RETURN m\n```", "MATCH (m:Mission) RETURN m"),
+        ("Cypher", "思考\nMATCH (s:Stage) RETURN s\n说明:done", "MATCH (s:Stage) RETURN s"),
+    ],
+)
+def test_extract_and_accept_read_only(language, raw, expected):
+    assert _read_only(_extract_code(raw, language), language) == expected
+
+
+@pytest.mark.parametrize(
+    ("language", "query"),
+    [
+        ("SQL", "DELETE FROM rescue_forces"),
+        ("SQL", "SELECT 1; DROP TABLE rescue_forces"),
+        ("Cypher", "MATCH (n) DELETE n"),
+        ("Cypher", "MATCH (n) RETURN n; CREATE ()"),
+    ],
+)
+def test_rejects_writes_and_multiple_statements(language, query):
+    with pytest.raises(ValueError):
+        _read_only(query, language)

+ 7 - 0
backend/src/main/java/com/agent/management/config/RagAiBridgeProperties.java

@@ -0,0 +1,7 @@
+package com.agent.management.config;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+import java.time.Duration;
+@Data @Component @ConfigurationProperties(prefix="rag.ai-bridge")
+public class RagAiBridgeProperties { private boolean enabled=false; private String baseUrl="http://127.0.0.1:18733"; private Duration timeout=Duration.ofSeconds(300); }

+ 6 - 0
backend/src/main/java/com/agent/management/model/entity/RagKnowledgeBase.java

@@ -0,0 +1,6 @@
+package com.agent.management.model.entity;
+import jakarta.persistence.*; import lombok.Data; import java.time.LocalDateTime;
+@Data @Entity @Table(name="rag_knowledge_base")
+public class RagKnowledgeBase { public enum Type { DOCUMENT_ONLY, STRUCTURED_ONLY, GRAPH_ONLY, HYBRID }
+ @Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id; @Column(nullable=false) private String name; @Column(columnDefinition="TEXT") private String description; @Enumerated(EnumType.STRING) @Column(nullable=false) private Type type=Type.HYBRID; private boolean enabled=true; private LocalDateTime createdAt; private LocalDateTime updatedAt;
+ @PrePersist void create(){createdAt=updatedAt=LocalDateTime.now();} @PreUpdate void update(){updatedAt=LocalDateTime.now();}}

+ 4 - 0
backend/src/main/java/com/agent/management/model/entity/RagKnowledgeSourceBinding.java

@@ -0,0 +1,4 @@
+package com.agent.management.model.entity;
+import com.agent.management.rag.model.RagSourceType; import jakarta.persistence.*; import lombok.Data; import java.time.LocalDateTime;
+@Data @Entity @Table(name="rag_knowledge_source_binding", indexes=@Index(name="idx_rag_binding_kb",columnList="knowledge_base_id"))
+public class RagKnowledgeSourceBinding { @Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id; @Column(name="knowledge_base_id",nullable=false) private Long knowledgeBaseId; @Enumerated(EnumType.STRING) @Column(nullable=false) private RagSourceType sourceType; @Column(nullable=false) private String sourceId; private boolean enabled=true; private Integer priority=0; @Column(columnDefinition="TEXT") private String configJson; private LocalDateTime createdAt; private LocalDateTime updatedAt; @PrePersist void create(){createdAt=updatedAt=LocalDateTime.now();} @PreUpdate void update(){updatedAt=LocalDateTime.now();}}

+ 25 - 0
backend/src/main/java/com/agent/management/model/entity/RagQueryExample.java

@@ -0,0 +1,25 @@
+package com.agent.management.model.entity;
+
+import com.agent.management.rag.model.RagSourceType;
+import jakarta.persistence.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@Entity
+@Table(name = "rag_query_example", indexes = @Index(name = "idx_rag_example_source", columnList = "source_type,source_id"))
+public class RagQueryExample {
+    @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
+    @Enumerated(EnumType.STRING) @Column(name = "source_type", nullable = false) private RagSourceType sourceType;
+    @Column(name = "source_id", nullable = false) private String sourceId;
+    @Column(nullable = false, length = 1000) private String question;
+    @Column(name = "query_text", nullable = false, columnDefinition = "TEXT") private String queryText;
+    @Column(name = "schema_version", length = 64) private String schemaVersion;
+    private boolean verified = true;
+    @Column(name = "success_count") private long successCount = 1;
+    @Column(name = "created_at") private LocalDateTime createdAt;
+    @Column(name = "updated_at") private LocalDateTime updatedAt;
+    @PrePersist void create(){createdAt=updatedAt=LocalDateTime.now();}
+    @PreUpdate void update(){updatedAt=LocalDateTime.now();}
+}

+ 3 - 0
backend/src/main/java/com/agent/management/rag/api/RagRetriever.java

@@ -0,0 +1,3 @@
+package com.agent.management.rag.api;
+import com.agent.management.rag.model.*;
+public interface RagRetriever { RagSourceType sourceType(); RagRetrievalResult retrieve(RagQuery query); }

+ 32 - 0
backend/src/main/java/com/agent/management/rag/bridge/RagAiBridgeClient.java

@@ -0,0 +1,32 @@
+package com.agent.management.rag.bridge;
+import com.agent.management.config.RagAiBridgeProperties;
+import org.springframework.http.*;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
+import java.util.*;
+
+@Component
+public class RagAiBridgeClient {
+    private final RagAiBridgeProperties properties;
+    private final RestTemplate rest;
+    public RagAiBridgeClient(RagAiBridgeProperties properties) {
+        this.properties=properties;
+        var factory=new SimpleClientHttpRequestFactory();
+        int timeout=(int)Math.min(Integer.MAX_VALUE, properties.getTimeout().toMillis());
+        factory.setConnectTimeout(timeout); factory.setReadTimeout(timeout); this.rest=new RestTemplate(factory);
+    }
+    public boolean health(){ if(!properties.isEnabled()) return false; try{return Boolean.TRUE.equals(rest.getForEntity(url("/health"),Map.class).getBody().get("ok"));}catch(Exception e){return false;} }
+    public Map<String,Object> textToSql(Map<String,Object> request){return post("/text2sql",request);}
+    public Map<String,Object> textToCypher(Map<String,Object> request){return post("/text2cypher",request);}
+    public Map<String,Object> answer(Map<String,Object> request){return post("/answer",request);}
+    public Map<String,Object> suggestGovernance(Map<String,Object> request){return post("/governance/suggest",request);}
+    public Map<String,Object> plan(Map<String,Object> request){return post("/plan",request);}
+    public Map<String,Object> repair(Map<String,Object> request){return post("/repair",request);}
+    @SuppressWarnings("unchecked") private Map<String,Object> post(String path, Object body){
+        if(!properties.isEnabled()) throw new IllegalStateException("rag-ai-bridge is disabled");
+        var response=rest.postForEntity(url(path),body,Map.class);
+        return response.getBody()==null?Map.of():response.getBody();
+    }
+    private String url(String path){return properties.getBaseUrl().replaceAll("/+$","")+path;}
+}

+ 57 - 0
backend/src/main/java/com/agent/management/rag/capability/GraphBusinessSubgraphSelector.java

@@ -0,0 +1,57 @@
+package com.agent.management.rag.capability;
+
+import com.agent.management.rag.graph.GraphSchemaSnapshot;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+
+@Service
+@RequiredArgsConstructor
+public class GraphBusinessSubgraphSelector {
+    private final RagSemanticCatalogService semanticCatalog;
+
+    public GraphSchemaSnapshot select(String intent, RagCapabilityProfile profile, GraphSchemaSnapshot allowed,
+                                      int maxLabels, int maxRelationships) {
+        Set<String> allowedLabels = new LinkedHashSet<>(allowed.labelNames());
+        Set<String> allowedRelationships = new LinkedHashSet<>(allowed.relationshipTypeNames());
+        List<String> rankedLabels = semanticCatalog.rank(intent, profile, "label:", Math.max(4, maxLabels)).stream()
+                .filter(allowedLabels::contains).toList();
+        List<String> rankedRelationships = semanticCatalog.rank(intent, profile, "relationship:", Math.max(4, maxRelationships)).stream()
+                .filter(allowedRelationships::contains).toList();
+
+        LinkedHashSet<String> seeds = new LinkedHashSet<>(rankedLabels);
+        if (seeds.isEmpty()) {
+            allowed.nodes().stream()
+                    .sorted(Comparator.comparingDouble((GraphSchemaSnapshot.NodeSchema node) ->
+                            RagTextSimilarity.score(intent, node.label() + " " + String.join(" ", node.properties().keySet()))).reversed())
+                    .limit(Math.max(1, maxLabels / 2)).map(GraphSchemaSnapshot.NodeSchema::label).forEach(seeds::add);
+        }
+
+        Set<String> rankedRelSet = new HashSet<>(rankedRelationships);
+        List<RelationshipScore> relationshipScores = allowed.relationships().stream().map(rel -> {
+            boolean start = rel.startLabels().stream().anyMatch(seeds::contains);
+            boolean end = rel.endLabels().stream().anyMatch(seeds::contains);
+            int score = (rankedRelSet.contains(rel.type()) ? 4 : 0) + (start && end ? 3 : start || end ? 1 : 0);
+            return new RelationshipScore(rel, score);
+        }).filter(item -> item.score() > 0).sorted(Comparator.comparingInt(RelationshipScore::score).reversed())
+                .limit(Math.max(1, maxRelationships)).toList();
+
+        LinkedHashSet<String> selectedLabels = new LinkedHashSet<>(seeds.stream().limit(Math.max(1, maxLabels)).toList());
+        LinkedHashSet<String> selectedRelationships = new LinkedHashSet<>();
+        relationshipScores.forEach(item -> {
+            selectedRelationships.add(item.relationship().type());
+            selectedLabels.addAll(item.relationship().startLabels());
+            selectedLabels.addAll(item.relationship().endLabels());
+        });
+        if (selectedRelationships.isEmpty() && !selectedLabels.isEmpty()) {
+            allowed.relationships().stream()
+                    .filter(rel -> rel.startLabels().stream().anyMatch(selectedLabels::contains)
+                            && rel.endLabels().stream().anyMatch(selectedLabels::contains))
+                    .limit(maxRelationships).map(GraphSchemaSnapshot.RelationshipSchema::type).forEach(selectedRelationships::add);
+        }
+        return allowed.filter(new ArrayList<>(selectedLabels), new ArrayList<>(selectedRelationships), Map.of());
+    }
+
+    private record RelationshipScore(GraphSchemaSnapshot.RelationshipSchema relationship, int score) {}
+}

+ 53 - 0
backend/src/main/java/com/agent/management/rag/capability/GraphOnboardingAssessmentService.java

@@ -0,0 +1,53 @@
+package com.agent.management.rag.capability;
+
+import com.agent.management.rag.memory.RagExampleMemoryService;
+import com.agent.management.rag.model.RagSourceType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+
+@Service
+@RequiredArgsConstructor
+public class GraphOnboardingAssessmentService {
+    private final RagCapabilityProfileService profiles;
+    private final RagExampleMemoryService examples;
+    private final com.agent.management.service.GraphSourceService graphSources;
+
+    public Map<String,Object> assess(String sourceId) {
+        RagCapabilityProfile profile = profiles.get(RagSourceType.GRAPH, sourceId);
+        var schema = profile.graphSchema();
+        Set<String> connected = new LinkedHashSet<>();
+        schema.relationships().forEach(rel -> { connected.addAll(rel.startLabels()); connected.addAll(rel.endLabels()); });
+        List<String> orphanLabels = schema.labelNames().stream().filter(label -> !connected.contains(label)).toList();
+        long properties = schema.nodes().stream().mapToLong(node -> node.properties().size()).sum()
+                + schema.relationships().stream().mapToLong(rel -> rel.properties().size()).sum();
+        long vectorized = profile.semanticCatalog().stream().filter(item -> !item.vector().isEmpty()).count();
+        long verifiedExamples = examples.list(RagSourceType.GRAPH, sourceId).stream().filter(item -> item.isVerified()).count();
+        Map<String,Object> metadata = graphSources.discoverMetadata(Long.valueOf(sourceId));
+        int score = 0;
+        if (!schema.nodes().isEmpty()) score += 20;
+        if (!schema.relationships().isEmpty()) score += 20;
+        if (properties > 0) score += 15;
+        if (vectorized == profile.semanticCatalog().size() && vectorized > 0) score += 20;
+        if (orphanLabels.size() <= Math.max(1, schema.nodes().size() / 10)) score += 10;
+        score += (int)Math.min(15, verifiedExamples * 3);
+        List<String> recommendations = new ArrayList<>();
+        if (schema.nodes().isEmpty()) recommendations.add("未发现 Label,请检查连接权限或图数据");
+        if (schema.relationships().isEmpty()) recommendations.add("未发现 Relationship,Text-to-Cypher 只能完成单节点查询");
+        if (!orphanLabels.isEmpty()) recommendations.add("审核孤立 Label,确认是否缺少关系或应排除:" + String.join(", ", orphanLabels));
+        if (properties == 0) recommendations.add("缺少 Property 元数据,实体过滤和属性问答准确率会受限");
+        if (vectorized < profile.semanticCatalog().size()) recommendations.add("语义目录未完整向量化,请检查 Embedding Bridge");
+        if (verifiedExamples < 5) recommendations.add("建议为查找、过滤、聚合、路径、多跳五类问法各审核至少一条 Few-shot");
+        Map<String,Object> result = new LinkedHashMap<>();
+        result.put("readinessScore", score); result.put("profileVersion", profile.version());
+        result.put("labels", schema.nodes().size()); result.put("relationshipPatterns", schema.relationships().size());
+        result.put("relationshipTypes", schema.relationshipTypeNames().size()); result.put("properties", properties);
+        result.put("orphanLabels", orphanLabels); result.put("semanticEntries", profile.semanticCatalog().size());
+        result.put("vectorizedEntries", vectorized); result.put("verifiedExamples", verifiedExamples);
+        result.put("recommendations", recommendations);
+        result.put("constraints", metadata.get("constraints")); result.put("indexes", metadata.get("indexes"));
+        result.put("labelCounts", metadata.get("labelCounts"));
+        return result;
+    }
+}

+ 13 - 0
backend/src/main/java/com/agent/management/rag/capability/RagCapabilityProfile.java

@@ -0,0 +1,13 @@
+package com.agent.management.rag.capability;
+
+import com.agent.management.rag.graph.GraphSchemaSnapshot;
+import com.agent.management.rag.model.RagSourceType;
+
+import java.time.Instant;
+import java.util.List;
+
+public record RagCapabilityProfile(RagSourceType sourceType, String sourceId, String version,
+                                   Instant generatedAt, SqlSchemaSnapshot sqlSchema,
+                                   GraphSchemaSnapshot graphSchema, List<RagSemanticEntry> semanticCatalog) {
+    public RagCapabilityProfile { semanticCatalog = List.copyOf(semanticCatalog == null ? List.of() : semanticCatalog); }
+}

+ 77 - 0
backend/src/main/java/com/agent/management/rag/capability/RagCapabilityProfileService.java

@@ -0,0 +1,77 @@
+package com.agent.management.rag.capability;
+
+import com.agent.management.rag.graph.GraphSchemaDiscoveryService;
+import com.agent.management.rag.model.RagSourceType;
+import com.agent.management.service.DataSourceService;
+import com.agent.management.service.SchemaExplorerService;
+import com.agent.management.service.impl.EmbeddingBridgeClient;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.AbstractMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Service
+@RequiredArgsConstructor
+public class RagCapabilityProfileService {
+    private static final Duration TTL = Duration.ofMinutes(5);
+    private final SchemaExplorerService sqlExplorer;
+    private final DataSourceService dataSources;
+    private final GraphSchemaDiscoveryService graphSchemas;
+    private final EmbeddingBridgeClient embeddings;
+    private final Map<String, RagCapabilityProfile> cache = new ConcurrentHashMap<>();
+
+    public RagCapabilityProfile get(RagSourceType type, String sourceId) {
+        String key = type + ":" + sourceId;
+        RagCapabilityProfile cached = cache.get(key);
+        if (cached != null && cached.generatedAt().plus(TTL).isAfter(Instant.now())) return cached;
+        return refresh(type, sourceId);
+    }
+
+    public RagCapabilityProfile refresh(RagSourceType type, String sourceId) {
+        Long id = Long.valueOf(sourceId);
+        RagCapabilityProfile profile;
+        if (type == RagSourceType.STRUCTURED_DATA) {
+            var ds = dataSources.getDecrypted(id);
+            List<SqlSchemaSnapshot.TableSchema> tables = sqlExplorer.listTables(id, ds.getSchemaName()).stream()
+                    .map(table -> new SqlSchemaSnapshot.TableSchema(table.name(), table.remarks(),
+                            sqlExplorer.describeTable(id, ds.getSchemaName(), table.name()).stream()
+                                    .map(column -> new SqlSchemaSnapshot.ColumnSchema(column.name(), column.type(), column.remarks()))
+                                    .toList()))
+                    .toList();
+            List<SqlSchemaSnapshot.ForeignKey> foreignKeys = tables.stream().flatMap(table -> sqlExplorer.listForeignKeys(id, ds.getSchemaName(), table.name()).stream())
+                    .map(key -> new SqlSchemaSnapshot.ForeignKey(key.name(),key.sourceTable(),key.sourceColumn(),key.targetTable(),key.targetColumn())).toList();
+            SqlSchemaSnapshot sql = new SqlSchemaSnapshot(tables, foreignKeys);
+            List<RagSemanticEntry> catalog = vectorize(tables.stream().map(table ->
+                    new AbstractMap.SimpleEntry<>("table:" + table.name(), table.name() + " " + table.description() + " " +
+                            table.columns().stream().map(column -> column.name()+" "+column.description()).collect(java.util.stream.Collectors.joining(" ")))).toList());
+            profile = new RagCapabilityProfile(type, sourceId, Integer.toHexString(sql.hashCode()), Instant.now(), sql, null, catalog);
+        } else if (type == RagSourceType.GRAPH) {
+            var graph = graphSchemas.refresh(id);
+            List<Map.Entry<String,String>> texts = new java.util.ArrayList<>();
+            graph.nodes().forEach(node -> texts.add(Map.entry("label:"+node.label(), node.label()+" "+String.join(" ",node.properties().keySet()))));
+            graph.relationships().forEach(rel -> texts.add(Map.entry("relationship:"+rel.type(), rel.type()+" "+String.join(" ",rel.startLabels())+" "+String.join(" ",rel.endLabels()))));
+            profile = new RagCapabilityProfile(type, sourceId, Integer.toHexString(graph.hashCode()), Instant.now(), null, graph, vectorize(texts));
+        } else {
+            throw new IllegalArgumentException("capability profile is unsupported for source type: " + type);
+        }
+        cache.put(type + ":" + sourceId, profile);
+        return profile;
+    }
+
+    private List<RagSemanticEntry> vectorize(List<? extends Map.Entry<String,String>> items) {
+        if (items.isEmpty()) return List.of();
+        try {
+            List<List<Double>> vectors = embeddings.embed(items.stream().map(Map.Entry::getValue).toList());
+            List<RagSemanticEntry> result = new java.util.ArrayList<>();
+            for (int i=0;i<items.size();i++) result.add(new RagSemanticEntry(items.get(i).getKey(),items.get(i).getValue(),vectors.get(i)));
+            return result;
+        } catch (Exception ignored) {
+            return items.stream().map(item -> new RagSemanticEntry(item.getKey(),item.getValue(),List.of())).toList();
+        }
+    }
+}

+ 24 - 0
backend/src/main/java/com/agent/management/rag/capability/RagEntityMentionExtractor.java

@@ -0,0 +1,24 @@
+package com.agent.management.rag.capability;
+
+import org.springframework.stereotype.Component;
+import java.util.*;
+import java.util.regex.*;
+
+@Component
+public class RagEntityMentionExtractor {
+    private static final Pattern QUOTED = Pattern.compile("[\"'“‘]([^\"'”’]{2,80})[\"'”’]");
+    private static final Pattern DOMAIN = Pattern.compile("[\\p{IsHan}A-Za-z0-9_-]{2,30}(?:号|舰|船|队|组|任务|阶段|事件|机构|部门)");
+    public List<String> extract(String question) {
+        LinkedHashSet<String> result = new LinkedHashSet<>();
+        add(question, QUOTED, result); add(question, DOMAIN, result);
+        return result.stream().limit(12).toList();
+    }
+    private static void add(String text, Pattern pattern, Set<String> out) {
+        Matcher matcher = pattern.matcher(text == null ? "" : text);
+        while (matcher.find()) {
+            String value=matcher.groupCount()>0?matcher.group(1):matcher.group();
+            value=value.replaceFirst("^(?:查询|当前|以及|和|与|及|关于)","");
+            if(value.length()>=2) out.add(value);
+        }
+    }
+}

+ 134 - 0
backend/src/main/java/com/agent/management/rag/capability/RagGovernanceService.java

@@ -0,0 +1,134 @@
+package com.agent.management.rag.capability;
+
+import com.agent.management.rag.bridge.RagAiBridgeClient;
+import com.agent.management.rag.model.RagSourceType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+public class RagGovernanceService {
+    private final RagCapabilityProfileService profiles;
+    private final RagAiBridgeClient bridge;
+    private final RagSemanticCatalogService semanticCatalog;
+    private final GraphBusinessSubgraphSelector subgraphs;
+
+    public Map<String,Object> suggest(RagSourceType type, String sourceId, String description) {
+        RagCapabilityProfile profile = profiles.refresh(type, sourceId);
+        String schema;
+        if (type == RagSourceType.STRUCTURED_DATA) {
+            List<String> tables = semanticCatalog.rank(description, profile, "table:", 12);
+            schema = profile.sqlSchema().toDdl(tables);
+        } else {
+            schema = subgraphs.select(description, profile, profile.graphSchema(), 16, 20).toPromptText();
+        }
+        Map<String,Object> request = new LinkedHashMap<>();
+        request.put("sourceType", type.name()); request.put("schemaText", schema);
+        request.put("sourceDescription", description == null ? "" : description); request.put("maxExamples", 8);
+        Map<String,Object> suggestion;
+        try {
+            suggestion = normalize(type, bridge.suggestGovernance(request), profile);
+            suggestion.put("generator", "LLM");
+        } catch (Exception error) {
+            suggestion = fallback(profile, description);
+            suggestion.put("generator", "SEMANTIC_FALLBACK");
+            suggestion.put("warning", "模型建议不可用,已按语义目录生成可审核草稿:" + shortMessage(error));
+        }
+        suggestion.put("profileVersion", profile.version()); suggestion.put("sourceType", type.name());
+        suggestion.put("sourceId", sourceId);
+        return suggestion;
+    }
+
+    private Map<String,Object> normalize(RagSourceType type, Map<String,Object> raw, RagCapabilityProfile profile) {
+        Map<String,Object> config = new LinkedHashMap<>();
+        config.put("retrievalMode", "AUTO_GENERATE");
+        config.put("generationRules", rules(raw.get("businessRules"), raw.get("rules")));
+        if (type == RagSourceType.STRUCTURED_DATA) {
+            config.put("allowTextToSql", true);
+            var actualTables = new java.util.HashSet<>(profile.sqlSchema().tables().stream().map(SqlSchemaSnapshot.TableSchema::name).toList());
+            config.put("allowedTables", strings(raw.containsKey("allowedTables") ? raw.get("allowedTables") : raw.get("tableWhitelist"))
+                    .stream().filter(actualTables::contains).toList());
+            config.put("maxRows", 100);
+        } else {
+            config.put("allowTextToCypher", true);
+            List<String> labels = strings(raw.get("allowedLabels")).stream().filter(profile.graphSchema().labelNames()::contains).toList();
+            List<String> relationships = strings(raw.get("allowedRelationships")).stream().filter(profile.graphSchema().relationshipTypeNames()::contains).toList();
+            config.put("allowedLabels", labels);
+            config.put("allowedRelationships", relationships);
+            config.put("allowedProperties", validProperties(raw.get("allowedProperties"), profile, labels, relationships));
+            config.put("maxDepth", 3);
+        }
+        Map<String,Object> result = new LinkedHashMap<>();
+        result.put("summary", String.valueOf(raw.getOrDefault("summary", "基于实际 Schema 生成的授权草稿")));
+        result.put("suggestedConfig", config);
+        result.put("examples", raw.get("examples") instanceof List<?> list ? list : List.of());
+        return result;
+    }
+
+    private static List<String> rules(Object primary, Object secondary) {
+        List<String> values = strings(primary);
+        return values.isEmpty() ? strings(secondary) : values;
+    }
+
+    private static List<String> strings(Object value) {
+        if (value instanceof List<?> list) return list.stream().map(String::valueOf).map(String::trim).filter(item -> !item.isEmpty()).toList();
+        if (value == null) return List.of();
+        return java.util.Arrays.stream(String.valueOf(value).split("[\\r\\n;;]+"))
+                .map(String::trim).filter(item -> !item.isEmpty()).toList();
+    }
+
+    private static Map<String,List<String>> validProperties(Object value, RagCapabilityProfile profile,
+                                                             List<String> labels, List<String> relationships) {
+        if (!(value instanceof Map<?,?> raw)) return Map.of();
+        Map<String,List<String>> actual = new LinkedHashMap<>();
+        profile.graphSchema().nodes().forEach(node -> actual.put(node.label(), node.properties().keySet().stream().toList()));
+        profile.graphSchema().relationships().forEach(rel -> actual.put(rel.type(), rel.properties().keySet().stream().toList()));
+        Map<String,List<String>> result = new LinkedHashMap<>();
+        raw.forEach((owner, properties) -> {
+            String name = String.valueOf(owner);
+            if ((!labels.contains(name) && !relationships.contains(name)) || !actual.containsKey(name)) return;
+            result.put(name, strings(properties).stream().filter(actual.get(name)::contains).toList());
+        });
+        return result;
+    }
+
+    private Map<String,Object> fallback(RagCapabilityProfile profile, String description) {
+        Map<String,Object> result = new LinkedHashMap<>();
+        Map<String,Object> config = new LinkedHashMap<>();
+        config.put("retrievalMode", "AUTO_GENERATE");
+        config.put("generationRules", List.of("仅生成只读查询", "只使用已审核的 Schema 元素", "结果必须限制返回规模"));
+        if (profile.sourceType() == RagSourceType.STRUCTURED_DATA) {
+            List<String> tables = semanticCatalog.rank(description, profile, "table:", 8);
+            if (tables.isEmpty()) tables = profile.sqlSchema().tables().stream().limit(8).map(SqlSchemaSnapshot.TableSchema::name).toList();
+            config.put("allowTextToSql", true); config.put("allowedTables", tables); config.put("maxRows", 100);
+            result.put("examples", tables.stream().limit(4).map(table -> Map.of("question", "查询 " + table + " 的主要记录", "sql", "SELECT * FROM " + table + " LIMIT 20")).toList());
+        } else {
+            List<String> labels = semanticCatalog.rank(description, profile, "label:", 12);
+            List<String> relationships = semanticCatalog.rank(description, profile, "relationship:", 12);
+            if (labels.isEmpty()) labels = profile.graphSchema().labelNames().stream().limit(12).toList();
+            List<String> selectedLabels = labels;
+            if (relationships.isEmpty()) relationships = profile.graphSchema().relationships().stream()
+                    .filter(rel -> rel.startLabels().stream().anyMatch(selectedLabels::contains) || rel.endLabels().stream().anyMatch(selectedLabels::contains))
+                    .limit(12).map(com.agent.management.rag.graph.GraphSchemaSnapshot.RelationshipSchema::type).distinct().toList();
+            var subgraph = profile.graphSchema().filter(labels, relationships, Map.of());
+            Map<String,List<String>> properties = new LinkedHashMap<>();
+            subgraph.nodes().forEach(node -> properties.put(node.label(), node.properties().keySet().stream().toList()));
+            subgraph.relationships().forEach(rel -> properties.put(rel.type(), rel.properties().keySet().stream().toList()));
+            config.put("allowTextToCypher", true); config.put("allowedLabels", subgraph.labelNames());
+            config.put("allowedRelationships", subgraph.relationshipTypeNames()); config.put("allowedProperties", properties); config.put("maxDepth", 3);
+            result.put("examples", subgraph.nodes().stream().limit(4).map(node -> Map.of("question", "查询 " + node.label() + " 及其关联信息", "cypher", "MATCH (n:`" + node.label() + "`) RETURN n LIMIT 20")).toList());
+        }
+        result.put("suggestedConfig", config);
+        result.put("summary", "基于实际 Schema 与向量语义相似度生成的授权草稿,应用前需人工审核");
+        return result;
+    }
+
+    private static String shortMessage(Exception error) {
+        String value = error.getMessage();
+        return value == null ? error.getClass().getSimpleName() : value.substring(0, Math.min(value.length(), 180));
+    }
+}

+ 80 - 0
backend/src/main/java/com/agent/management/rag/capability/RagSchemaLinker.java

@@ -0,0 +1,80 @@
+package com.agent.management.rag.capability;
+
+import com.agent.management.rag.graph.GraphSchemaSnapshot;
+import org.springframework.stereotype.Component;
+
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@Component
+public class RagSchemaLinker {
+    private static final Pattern LABEL = Pattern.compile(":`?([A-Za-z_]\\w*)`?");
+
+    public List<String> selectSqlTables(String question, SqlSchemaSnapshot schema,
+                                        List<String> policyWhitelist, Map<String, String> tableHints) {
+        Set<String> policy = new HashSet<>(policyWhitelist == null ? List.of() : policyWhitelist);
+        List<ScoredName> scores = new ArrayList<>();
+        for (SqlSchemaSnapshot.TableSchema table : schema.tables()) {
+            if (!policy.isEmpty() && !policy.contains(table.name())) continue;
+            StringBuilder semantics = new StringBuilder(table.name()).append(' ').append(nullToEmpty(table.description()))
+                    .append(' ').append(nullToEmpty(tableHints == null ? null : tableHints.get(table.name())));
+            table.columns().forEach(column -> semantics.append(' ').append(column.name()).append(' ')
+                    .append(nullToEmpty(column.description())));
+            scores.add(new ScoredName(table.name(), RagTextSimilarity.score(question, semantics.toString())));
+        }
+        return select(scores);
+    }
+
+    public GraphSchemaSnapshot selectGraphSchema(String question, GraphSchemaSnapshot schema,
+                                                  List<Map<String, Object>> examples) {
+        Set<String> selectedNames = new LinkedHashSet<>();
+        double bestExampleScore = 0;
+        String bestQuery = null;
+        for (Map<String, Object> example : examples == null ? List.<Map<String, Object>>of() : examples) {
+            double score = RagTextSimilarity.score(question, String.valueOf(example.getOrDefault("question", "")));
+            if (score > bestExampleScore) {
+                bestExampleScore = score;
+                Object query = example.containsKey("cypher") ? example.get("cypher") : example.get("query");
+                bestQuery = query == null ? null : String.valueOf(query);
+            }
+        }
+        if (bestExampleScore > 0.15 && bestQuery != null) {
+            Matcher matcher = LABEL.matcher(bestQuery);
+            while (matcher.find()) selectedNames.add(matcher.group(1));
+        }
+        if (selectedNames.isEmpty()) {
+            for (String label : schema.labelNames()) {
+                if (RagTextSimilarity.score(question, label) > 0) selectedNames.add(label);
+            }
+            for (String relationship : schema.relationshipTypeNames()) {
+                if (RagTextSimilarity.score(question, relationship) > 0) selectedNames.add(relationship);
+            }
+        }
+        if (selectedNames.isEmpty()) return schema;
+        List<String> labels = schema.labelNames().stream().filter(selectedNames::contains).toList();
+        List<String> relationships = schema.relationshipTypeNames().stream().filter(selectedNames::contains).toList();
+        if (relationships.isEmpty()) {
+            relationships = schema.relationships().stream()
+                    .filter(rel -> rel.startLabels().stream().anyMatch(labels::contains)
+                            && rel.endLabels().stream().anyMatch(labels::contains))
+                    .map(GraphSchemaSnapshot.RelationshipSchema::type).distinct().toList();
+        }
+        Set<String> connected = new LinkedHashSet<>(labels);
+        for (GraphSchemaSnapshot.RelationshipSchema rel : schema.relationships()) {
+            if (relationships.contains(rel.type())) { connected.addAll(rel.startLabels()); connected.addAll(rel.endLabels()); }
+        }
+        return schema.filter(new ArrayList<>(connected), relationships, Map.of());
+    }
+
+    private static List<String> select(List<ScoredName> scores) {
+        if (scores.isEmpty()) return List.of();
+        double max = scores.stream().mapToDouble(ScoredName::score).max().orElse(0);
+        if (max <= 0) return scores.stream().map(ScoredName::name).toList();
+        double threshold = Math.max(0.08, max * 0.55);
+        return scores.stream().filter(item -> item.score() >= threshold).map(ScoredName::name).toList();
+    }
+
+    private static String nullToEmpty(String value) { return value == null ? "" : value; }
+    private record ScoredName(String name, double score) {}
+}

+ 54 - 0
backend/src/main/java/com/agent/management/rag/capability/RagSemanticCatalogService.java

@@ -0,0 +1,54 @@
+package com.agent.management.rag.capability;
+
+import com.agent.management.service.impl.EmbeddingBridgeClient;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+public class RagSemanticCatalogService {
+    private final EmbeddingBridgeClient embeddings;
+
+    public List<String> rank(String question, RagCapabilityProfile profile, String prefix, int limit) {
+        List<RagSemanticEntry> candidates = profile.semanticCatalog().stream()
+                .filter(item -> item.key().startsWith(prefix) && !item.vector().isEmpty()).toList();
+        if (candidates.isEmpty()) return List.of();
+        try {
+            List<List<Double>> vectors = embeddings.embed(List.of(question));
+            if (vectors.isEmpty()) return List.of();
+            List<Double> query = vectors.get(0);
+            return candidates.stream().map(item -> new Score(item.key().substring(prefix.length()), cosine(query, item.vector())))
+                    .filter(item -> item.score > 0.2).sorted(Comparator.comparingDouble(Score::score).reversed())
+                    .limit(Math.max(1, limit)).map(Score::name).toList();
+        } catch (Exception ignored) { return List.of(); }
+    }
+
+    public List<String> rankTexts(String question, Map<String,String> texts, int limit) {
+        if (texts == null || texts.isEmpty()) return List.of();
+        try {
+            List<String> keys = List.copyOf(texts.keySet());
+            List<String> inputs = new java.util.ArrayList<>();
+            inputs.add(question); keys.forEach(key -> inputs.add(texts.get(key)));
+            List<List<Double>> vectors = embeddings.embed(inputs);
+            if (vectors.size() != inputs.size()) return List.of();
+            List<Double> query = vectors.get(0);
+            Map<String,Double> scores = new LinkedHashMap<>();
+            for (int i = 0; i < keys.size(); i++) scores.put(keys.get(i), cosine(query, vectors.get(i + 1)));
+            return scores.entrySet().stream().sorted(Map.Entry.<String,Double>comparingByValue().reversed())
+                    .limit(Math.max(0, limit)).map(Map.Entry::getKey).toList();
+        } catch (Exception ignored) { return List.of(); }
+    }
+
+    private static double cosine(List<Double> left, List<Double> right) {
+        int size = Math.min(left.size(), right.size());
+        double dot = 0, a = 0, b = 0;
+        for (int i = 0; i < size; i++) { dot += left.get(i) * right.get(i); a += left.get(i) * left.get(i); b += right.get(i) * right.get(i); }
+        return a == 0 || b == 0 ? 0 : dot / Math.sqrt(a * b);
+    }
+    private record Score(String name, double score) {}
+}

+ 7 - 0
backend/src/main/java/com/agent/management/rag/capability/RagSemanticEntry.java

@@ -0,0 +1,7 @@
+package com.agent.management.rag.capability;
+
+import java.util.List;
+
+public record RagSemanticEntry(String key, String text, List<Double> vector) {
+    public RagSemanticEntry { vector = List.copyOf(vector == null ? List.of() : vector); }
+}

+ 31 - 0
backend/src/main/java/com/agent/management/rag/capability/RagTextSimilarity.java

@@ -0,0 +1,31 @@
+package com.agent.management.rag.capability;
+
+import java.util.*;
+
+public final class RagTextSimilarity {
+    private RagTextSimilarity() {}
+
+    public static double score(String left, String right) {
+        Set<String> a = tokens(left); Set<String> b = tokens(right);
+        if (a.isEmpty() || b.isEmpty()) return 0;
+        long intersection = a.stream().filter(b::contains).count();
+        return intersection / (double) Math.max(1, Math.min(a.size(), b.size()));
+    }
+
+    public static Set<String> tokens(String text) {
+        if (text == null) return Set.of();
+        String normalized = text.toLowerCase(Locale.ROOT).replaceAll("[^\\p{L}\\p{N}_]+", " ").trim();
+        Set<String> tokens = new LinkedHashSet<>();
+        for (String part : normalized.split("\\s+")) {
+            if (part.isBlank()) continue;
+            tokens.add(part);
+            if (part.codePointCount(0, part.length()) > 1) {
+                int[] points = part.codePoints().toArray();
+                for (int i = 0; i < points.length - 1; i++) {
+                    tokens.add(new String(points, i, 2));
+                }
+            }
+        }
+        return tokens;
+    }
+}

+ 29 - 0
backend/src/main/java/com/agent/management/rag/capability/SqlSchemaSnapshot.java

@@ -0,0 +1,29 @@
+package com.agent.management.rag.capability;
+
+import java.util.List;
+
+public record SqlSchemaSnapshot(List<TableSchema> tables, List<ForeignKey> foreignKeys) {
+    public SqlSchemaSnapshot(List<TableSchema> tables) { this(tables, List.of()); }
+    public SqlSchemaSnapshot { tables = List.copyOf(tables == null ? List.of() : tables); foreignKeys=List.copyOf(foreignKeys==null?List.of():foreignKeys); }
+    public record TableSchema(String name, String description, List<ColumnSchema> columns) {
+        public TableSchema { columns = List.copyOf(columns == null ? List.of() : columns); }
+    }
+    public record ColumnSchema(String name, String type, String description) {}
+    public record ForeignKey(String name,String sourceTable,String sourceColumn,String targetTable,String targetColumn) {}
+
+    public String toDdl(List<String> selectedTables) {
+        var allowed = selectedTables == null || selectedTables.isEmpty() ? null : new java.util.HashSet<>(selectedTables);
+        StringBuilder ddl = new StringBuilder();
+        for (TableSchema table : tables) {
+            if (allowed != null && !allowed.contains(table.name())) continue;
+            ddl.append("TABLE ").append(table.name()).append(" (");
+            ddl.append(table.columns().stream().map(column -> column.name() + " " + column.type()
+                    + (column.description() == null || column.description().isBlank() ? "" : " /* " + column.description() + " */"))
+                    .collect(java.util.stream.Collectors.joining(", ")));
+            ddl.append(");\n");
+        }
+        for (ForeignKey key : foreignKeys) if ((allowed==null||allowed.contains(key.sourceTable())) && (allowed==null||allowed.contains(key.targetTable())))
+            ddl.append("FOREIGN KEY ").append(key.sourceTable()).append('.').append(key.sourceColumn()).append(" -> ").append(key.targetTable()).append('.').append(key.targetColumn()).append(";\n");
+        return ddl.toString();
+    }
+}

+ 2 - 0
backend/src/main/java/com/agent/management/rag/controller/DocumentRagDebugController.java

@@ -0,0 +1,2 @@
+package com.agent.management.rag.controller; import com.agent.management.common.Result; import com.agent.management.rag.document.DocumentRagRetriever; import com.agent.management.rag.model.*; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*;
+@RestController @RequestMapping("/api/rag/document") @RequiredArgsConstructor public class DocumentRagDebugController {private final DocumentRagRetriever retriever;@PostMapping("/retrieve") public Result<RagRetrievalResult> retrieve(@RequestBody RagQuery q){return Result.success(retriever.retrieve(q));}}

+ 2 - 0
backend/src/main/java/com/agent/management/rag/controller/GraphRagDebugController.java

@@ -0,0 +1,2 @@
+package com.agent.management.rag.controller; import com.agent.management.common.Result; import com.agent.management.rag.graph.GraphRagRetriever; import com.agent.management.rag.model.*; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*;
+@RestController @RequestMapping("/api/rag/graph") @RequiredArgsConstructor public class GraphRagDebugController {private final GraphRagRetriever retriever;@PostMapping("/retrieve") public Result<RagRetrievalResult> retrieve(@RequestBody RagQuery q){return Result.success(retriever.retrieve(q));}}

+ 2 - 0
backend/src/main/java/com/agent/management/rag/controller/KnowledgeBaseRagDebugController.java

@@ -0,0 +1,2 @@
+package com.agent.management.rag.controller; import com.agent.management.common.Result; import com.agent.management.model.entity.RagKnowledgeSourceBinding; import com.agent.management.rag.kb.KnowledgeBaseRagRetriever; import com.agent.management.rag.kb.RagKnowledgeBaseConfigService; import com.agent.management.rag.model.*; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import java.util.*;
+@RestController @RequestMapping("/api/rag/kb") @RequiredArgsConstructor public class KnowledgeBaseRagDebugController {private final KnowledgeBaseRagRetriever retriever;private final RagKnowledgeBaseConfigService configs;@PostMapping("/retrieve") public Result<KnowledgeBaseRagResult> retrieve(@RequestBody KnowledgeBaseRagRequest q){return Result.success(retriever.retrieve(q));}@GetMapping("/{id}/bindings") public Result<List<RagKnowledgeSourceBinding>> bindings(@PathVariable Long id){return Result.success(configs.listBindings(id));}@PutMapping("/{id}/bindings/{bindingId}/config") public Result<RagKnowledgeSourceBinding> updateConfig(@PathVariable Long id,@PathVariable Long bindingId,@RequestBody Map<String,Object> config){return Result.success(configs.updateConfig(id,bindingId,config));}}

+ 35 - 0
backend/src/main/java/com/agent/management/rag/controller/RagAnswerController.java

@@ -0,0 +1,35 @@
+package com.agent.management.rag.controller;
+
+import com.agent.management.common.Result;
+import com.agent.management.rag.bridge.RagAiBridgeClient;
+import com.agent.management.rag.model.RagEvidence;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/rag")
+@RequiredArgsConstructor
+public class RagAnswerController {
+    private final RagAiBridgeClient bridge;
+
+    @PostMapping("/answer")
+    public Result<Map<String, Object>> answer(@RequestBody AnswerRequest request) {
+        return Result.success(bridge.answer(Map.of(
+                "question", request.getQuestion(),
+                "evidences", request.getEvidences() == null ? List.of() : request.getEvidences()
+        )));
+    }
+
+    @Data
+    public static class AnswerRequest {
+        private String question;
+        private List<RagEvidence> evidences;
+    }
+}

+ 67 - 0
backend/src/main/java/com/agent/management/rag/controller/RagCapabilityController.java

@@ -0,0 +1,67 @@
+package com.agent.management.rag.controller;
+
+import com.agent.management.common.Result;
+import com.agent.management.model.entity.RagQueryExample;
+import com.agent.management.rag.capability.RagCapabilityProfile;
+import com.agent.management.rag.capability.RagCapabilityProfileService;
+import com.agent.management.rag.memory.RagExampleMemoryService;
+import com.agent.management.rag.model.RagSourceType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/rag/capabilities")
+@RequiredArgsConstructor
+public class RagCapabilityController {
+    private final RagCapabilityProfileService profiles;
+    private final RagExampleMemoryService examples;
+    private final com.agent.management.rag.capability.RagGovernanceService governance;
+    private final com.agent.management.rag.memory.RagExampleCandidateService candidateImports;
+    private final com.agent.management.rag.capability.GraphOnboardingAssessmentService graphAssessment;
+
+    @GetMapping("/{type}/{sourceId}")
+    public Result<RagCapabilityProfile> get(@PathVariable RagSourceType type, @PathVariable String sourceId) {
+        return Result.success(profiles.get(type, sourceId));
+    }
+
+    @PostMapping("/{type}/{sourceId}/refresh")
+    public Result<RagCapabilityProfile> refresh(@PathVariable RagSourceType type, @PathVariable String sourceId) {
+        return Result.success(profiles.refresh(type, sourceId));
+    }
+
+    @GetMapping("/{type}/{sourceId}/examples")
+    public Result<List<RagQueryExample>> examples(@PathVariable RagSourceType type, @PathVariable String sourceId) {
+        return Result.success(examples.list(type, sourceId));
+    }
+
+    @PostMapping("/{type}/{sourceId}/examples/candidates")
+    public Result<java.util.Map<String,Object>> addCandidates(@PathVariable RagSourceType type, @PathVariable String sourceId,
+                                                               @RequestBody List<java.util.Map<String,Object>> candidates) {
+        return Result.success(candidateImports.importValidated(type, sourceId, candidates));
+    }
+
+    @PutMapping("/examples/{id}/verified")
+    public Result<RagQueryExample> verify(@PathVariable Long id, @RequestParam boolean value) {
+        return Result.success(examples.setVerified(id, value));
+    }
+
+    @DeleteMapping("/examples/{id}")
+    public Result<Void> delete(@PathVariable Long id) {
+        examples.delete(id);
+        return Result.success(null);
+    }
+
+    @PostMapping("/{type}/{sourceId}/suggest")
+    public Result<java.util.Map<String,Object>> suggest(@PathVariable RagSourceType type,@PathVariable String sourceId,
+                                                        @RequestBody(required=false) java.util.Map<String,Object> body) {
+        String description=body==null?"":String.valueOf(body.getOrDefault("description",""));
+        return Result.success(governance.suggest(type,sourceId,description));
+    }
+
+    @GetMapping("/GRAPH/{sourceId}/readiness")
+    public Result<java.util.Map<String,Object>> graphReadiness(@PathVariable String sourceId) {
+        return Result.success(graphAssessment.assess(sourceId));
+    }
+}

+ 5 - 0
backend/src/main/java/com/agent/management/rag/controller/RagEvaluationController.java

@@ -0,0 +1,5 @@
+package com.agent.management.rag.controller;
+import com.agent.management.common.Result;import com.agent.management.rag.evaluation.RagEvaluationService;import lombok.RequiredArgsConstructor;import org.springframework.web.bind.annotation.*;import java.util.*;
+@RestController @RequestMapping("/api/rag/evaluation") @RequiredArgsConstructor
+public class RagEvaluationController {private final RagEvaluationService service;
+ @PostMapping("/run") public Result<Map<String,Object>> run(@RequestBody Map<String,Object> body){Long id=Long.valueOf(String.valueOf(body.getOrDefault("knowledgeBaseId",1)));Object raw=body.get("cases");List<Map<String,Object>> cases=new ArrayList<>();if(raw instanceof List<?> list)for(Object item:list)if(item instanceof Map<?,?> map){Map<String,Object> value=new LinkedHashMap<>();map.forEach((k,v)->value.put(String.valueOf(k),v));cases.add(value);}return Result.success(service.run(id,cases));}}

+ 2 - 0
backend/src/main/java/com/agent/management/rag/controller/StructuredRagDebugController.java

@@ -0,0 +1,2 @@
+package com.agent.management.rag.controller; import com.agent.management.common.Result; import com.agent.management.rag.structured.StructuredDataRagRetriever; import com.agent.management.rag.model.*; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*;
+@RestController @RequestMapping("/api/rag/structured") @RequiredArgsConstructor public class StructuredRagDebugController {private final StructuredDataRagRetriever retriever;@PostMapping("/retrieve") public Result<RagRetrievalResult> retrieve(@RequestBody RagQuery q){return Result.success(retriever.retrieve(q));}}

+ 31 - 0
backend/src/main/java/com/agent/management/rag/document/DocumentRagRetriever.java

@@ -0,0 +1,31 @@
+package com.agent.management.rag.document;
+import com.agent.management.rag.api.RagRetriever; import com.agent.management.rag.model.*; import com.agent.management.repository.*; import com.agent.management.service.impl.EmbeddingBridgeClient; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.util.*; import java.util.function.Function; import java.util.stream.Collectors;
+@Service @RequiredArgsConstructor
+public class DocumentRagRetriever implements RagRetriever {
+ private final EmbeddingBridgeClient bridge; private final KbChunkRepository chunks; private final KbDocumentRepository documents;
+ public RagSourceType sourceType(){return RagSourceType.DOCUMENT;}
+ public RagRetrievalResult retrieve(RagQuery query){
+  RagRetrievalResult out=new RagRetrievalResult(); out.setSourceType(sourceType());
+  try{
+   int topK=query.getTopK()!=null&&query.getTopK()>0?Math.min(query.getTopK(),100):5; String mode=filter(query,"mode","hybrid");
+   String expr=documentFilter(query.getSourceIds()); Map<String,Object> response=bridge.retrieve(query.getQuery(),topK,mode,expr);
+   List<Map<String,Object>> hits=maps(response.get("hits"));
+   List<String> ids=hits.stream().map(h->text(h.get("chunk_id"))).filter(s->!s.isBlank()).toList();
+   Map<String,com.agent.management.model.entity.KbChunk> found=chunks.findByChunkIdIn(ids).stream().collect(Collectors.toMap(com.agent.management.model.entity.KbChunk::getChunkId,Function.identity(),(a,b)->a));
+   for(Map<String,Object> hit:hits) out.getEvidences().add(evidence(hit,found.get(text(hit.get("chunk_id"))),mode));
+   out.getDiagnostics().put("mode",mode); out.getDiagnostics().put("topK",topK);
+  }catch(Exception e){out.getDiagnostics().put("error",e.getMessage());}
+  return out;
+ }
+ private RagEvidence evidence(Map<String,Object> hit, com.agent.management.model.entity.KbChunk chunk,String mode){
+  String chunkId=text(hit.get("chunk_id")); String title=text(hit.get("filename")); String sourceId=text(hit.get("document_id"));
+  if(chunk!=null){sourceId=String.valueOf(chunk.getDocumentId()); title=documents.findById(chunk.getDocumentId()).map(com.agent.management.model.entity.KbDocument::getName).orElse(title);}
+  RagEvidence e=new RagEvidence(); e.setId(text(hit.get("id"))); e.setSourceType(sourceType()); e.setEvidenceType("DOCUMENT_CHUNK"); e.setTitle(title); e.setContent(text(hit.get("text"))); e.setScore(number(hit.get("score"))); e.setSourceName(title);
+  RagSourceRef ref=new RagSourceRef(); ref.setSourceId(sourceId); ref.setSourceName(title); ref.setLocator(Map.of("chunkId",chunkId,"pageNumber",value(hit.get("page_number"),0))); e.setSourceRef(ref);
+  Map<String,Object> payload=new LinkedHashMap<>(); payload.put("chunkId",chunkId); payload.put("parentChunkId",text(hit.get("parent_chunk_id"))); payload.put("rootChunkId",text(hit.get("root_chunk_id"))); payload.put("chunkLevel",value(hit.get("chunk_level"),0)); if("dense".equals(mode))payload.put("denseScore",e.getScore()); else payload.put("hybridScore",e.getScore()); e.setPayload(payload); e.setMetadata(new LinkedHashMap<>(hit)); return e;
+ }
+ private static String documentFilter(List<String> ids){if(ids==null||ids.isEmpty())return ""; List<Long> safe=new ArrayList<>(); for(String id:ids)try{safe.add(Long.parseLong(id));}catch(Exception ignored){} return safe.isEmpty()?"":"document_id in ["+safe.stream().map(String::valueOf).collect(Collectors.joining(","))+"]";}
+ @SuppressWarnings("unchecked") private static List<Map<String,Object>> maps(Object o){return o instanceof List<?> l?l.stream().filter(Map.class::isInstance).map(x->(Map<String,Object>)x).toList():List.of();}
+ private static String filter(RagQuery q,String key,String fallback){Object v=q.getFilters()==null?null:q.getFilters().get(key);return v==null?fallback:String.valueOf(v);}
+ private static Object value(Object v,Object d){return v==null?d:v;} private static String text(Object v){return v==null?"":String.valueOf(v);} private static double number(Object v){return v instanceof Number n?n.doubleValue():0;}
+}

+ 25 - 0
backend/src/main/java/com/agent/management/rag/evaluation/RagEvaluationService.java

@@ -0,0 +1,25 @@
+package com.agent.management.rag.evaluation;
+
+import com.agent.management.rag.kb.KnowledgeBaseRagRetriever;
+import com.agent.management.rag.model.*;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import java.util.*;
+
+@Service @RequiredArgsConstructor
+public class RagEvaluationService {
+    private final KnowledgeBaseRagRetriever retriever;
+    public Map<String,Object> run(Long knowledgeBaseId, List<Map<String,Object>> cases) {
+        List<Map<String,Object>> results=new ArrayList<>(); int passed=0; long totalMs=0;
+        for(Map<String,Object> item:cases==null?List.<Map<String,Object>>of():cases){
+            String question=String.valueOf(item.getOrDefault("question","")); String expected=String.valueOf(item.getOrDefault("expectedSource",""));
+            KnowledgeBaseRagRequest request=new KnowledgeBaseRagRequest();request.setKnowledgeBaseId(knowledgeBaseId);request.setQuery(question);request.setTopK(5);
+            if(!expected.isBlank()) request.setFilters(Map.of("sourceTypes",List.of(expected)));
+            long start=System.currentTimeMillis(); KnowledgeBaseRagResult result=retriever.retrieve(request); long elapsed=System.currentTimeMillis()-start;totalMs+=elapsed;
+            boolean sourceMatched=expected.isBlank()||result.getEvidences().stream().anyMatch(e->e.getSourceType().name().equalsIgnoreCase(expected));
+            boolean ok=sourceMatched&&!result.getEvidences().isEmpty()&&result.getDiagnostics().isEmpty();if(ok)passed++;
+            results.add(Map.of("question",question,"passed",ok,"evidenceCount",result.getEvidences().size(),"latencyMs",elapsed,"diagnostics",result.getDiagnostics()));
+        }
+        int total=results.size();return Map.of("total",total,"passed",passed,"passRate",total==0?0d:(double)passed/total,"averageLatencyMs",total==0?0:totalMs/total,"results",results);
+    }
+}

+ 1 - 0
backend/src/main/java/com/agent/management/rag/graph/CypherGenerationService.java

@@ -0,0 +1 @@
+package com.agent.management.rag.graph; import com.agent.management.rag.model.RagQuery; import java.util.Optional; public interface CypherGenerationService {Optional<String> generateCypher(RagQuery query,Long graphSourceId);}

+ 1 - 0
backend/src/main/java/com/agent/management/rag/graph/ExplicitCypherGenerationService.java

@@ -0,0 +1 @@
+package com.agent.management.rag.graph; import com.agent.management.rag.model.RagQuery; import org.springframework.stereotype.Service; import java.util.Optional; @Service public class ExplicitCypherGenerationService implements CypherGenerationService {public Optional<String> generateCypher(RagQuery q,Long id){Object v=q.getFilters()==null?null:q.getFilters().get("cypher");return v==null||String.valueOf(v).isBlank()?Optional.empty():Optional.of(String.valueOf(v));}}

+ 120 - 0
backend/src/main/java/com/agent/management/rag/graph/GraphRagRetriever.java

@@ -0,0 +1,120 @@
+package com.agent.management.rag.graph;
+
+import com.agent.management.rag.api.RagRetriever;
+import com.agent.management.rag.memory.RagExampleMemoryService;
+import com.agent.management.rag.model.*;
+import com.agent.management.service.GraphSourceService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+
+@Service
+@RequiredArgsConstructor
+public class GraphRagRetriever implements RagRetriever {
+    private final ExplicitCypherGenerationService explicit;
+    private final Neo4jGraphRagCypherGenerationService generated;
+    private final GraphSourceService graphs;
+    private final RagExampleMemoryService exampleMemory;
+
+    public RagSourceType sourceType() { return RagSourceType.GRAPH; }
+
+    public RagRetrievalResult retrieve(RagQuery query) {
+        RagRetrievalResult out = new RagRetrievalResult();
+        out.setSourceType(sourceType());
+        try {
+            Long id = sourceId(query);
+            Optional<String> cypher = explicit.generateCypher(query, id);
+            boolean auto = false;
+            if (cypher.isEmpty()) {
+                auto = true;
+                try {
+                    cypher = generated.generateCypher(query, id);
+                } catch (Exception error) {
+                    out.getDiagnostics().put("error", "Cypher generation failed: " + error.getMessage());
+                    return out;
+                }
+            }
+            if (cypher.isEmpty()) {
+                out.getDiagnostics().put("warning", "no Cypher provided and automatic generation is disabled or returned empty");
+                return out;
+            }
+            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()) {
+                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()) {
+                        cypher = repaired; result = retried;
+                        out.getDiagnostics().put("repair", "zero-result Cypher was repaired once with fuzzy entity matching");
+                    }
+                }
+            }
+            List<?> nodes = list(result.get("nodes"));
+            List<?> edges = list(result.get("edges"));
+            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.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()));
+            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("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()))
+                exampleMemory.recordSuccess(sourceType(), String.valueOf(id), query.getQuery(), cypher.get());
+        } catch (Exception error) {
+            out.getDiagnostics().put("error", error.getMessage());
+        }
+        return out;
+    }
+
+    private Optional<String> preflightAndRepair(RagQuery query, Long sourceId, String cypher, RagRetrievalResult out) {
+        try {
+            graphs.explainQuery(sourceId, cypher);
+            return Optional.of(cypher);
+        } catch (Exception explainError) {
+            try {
+                Optional<String> repaired = generated.repair(query, sourceId, cypher, explainError.getMessage());
+                if (repaired.isEmpty()) throw new IllegalArgumentException("repair returned empty Cypher");
+                graphs.explainQuery(sourceId, repaired.get());
+                out.getDiagnostics().put("repair", "generated Cypher failed EXPLAIN and was repaired once");
+                return repaired;
+            } catch (Exception repairError) {
+                out.getDiagnostics().put("error", "Cypher preflight/repair failed: " + repairError.getMessage());
+                return Optional.empty();
+            }
+        }
+    }
+
+    private static boolean autoLearn(RagQuery query) {
+        return query.getFilters() == null || !Boolean.FALSE.equals(query.getFilters().get("autoLearnExamples"));
+    }
+
+    private static Long sourceId(RagQuery query) {
+        if (query.getSourceIds() == null || query.getSourceIds().isEmpty()) throw new IllegalArgumentException("sourceIds must contain graphSourceId");
+        return Long.valueOf(query.getSourceIds().get(0));
+    }
+
+    private static List<?> list(Object value) { return value instanceof List<?> list ? list : List.of(); }
+}

+ 32 - 0
backend/src/main/java/com/agent/management/rag/graph/GraphSchemaDiscoveryService.java

@@ -0,0 +1,32 @@
+package com.agent.management.rag.graph;
+
+import com.agent.management.service.GraphSourceService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Service
+@RequiredArgsConstructor
+public class GraphSchemaDiscoveryService {
+    private static final Duration CACHE_TTL = Duration.ofMinutes(5);
+    private final GraphSourceService graphSources;
+    private final Map<Long, CachedSchema> cache = new ConcurrentHashMap<>();
+
+    public GraphSchemaSnapshot get(Long graphSourceId) {
+        CachedSchema cached = cache.get(graphSourceId);
+        if (cached != null && cached.loadedAt().plus(CACHE_TTL).isAfter(Instant.now())) return cached.schema();
+        return refresh(graphSourceId);
+    }
+
+    public GraphSchemaSnapshot refresh(Long graphSourceId) {
+        GraphSchemaSnapshot schema = graphSources.discoverSchema(graphSourceId);
+        cache.put(graphSourceId, new CachedSchema(schema, Instant.now()));
+        return schema;
+    }
+
+    private record CachedSchema(GraphSchemaSnapshot schema, Instant loadedAt) {}
+}

+ 107 - 0
backend/src/main/java/com/agent/management/rag/graph/GraphSchemaSnapshot.java

@@ -0,0 +1,107 @@
+package com.agent.management.rag.graph;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+public record GraphSchemaSnapshot(List<NodeSchema> nodes, List<RelationshipSchema> relationships) {
+    public GraphSchemaSnapshot {
+        nodes = List.copyOf(nodes == null ? List.of() : nodes);
+        relationships = List.copyOf(relationships == null ? List.of() : relationships);
+    }
+
+    public record NodeSchema(String label, Map<String, List<String>> properties) {
+        public NodeSchema {
+            properties = immutableProperties(properties);
+        }
+    }
+
+    public record RelationshipSchema(String type, List<String> startLabels, List<String> endLabels,
+                                     Map<String, List<String>> properties) {
+        public RelationshipSchema {
+            startLabels = List.copyOf(startLabels == null ? List.of() : startLabels);
+            endLabels = List.copyOf(endLabels == null ? List.of() : endLabels);
+            properties = immutableProperties(properties);
+        }
+    }
+
+    public Optional<NodeSchema> node(String label) {
+        return nodes.stream().filter(node -> node.label().equals(label)).findFirst();
+    }
+
+    public List<String> labelNames() {
+        return nodes.stream().map(NodeSchema::label).toList();
+    }
+
+    public List<String> relationshipTypeNames() {
+        return relationships.stream().map(RelationshipSchema::type).distinct().toList();
+    }
+
+    public GraphSchemaSnapshot filter(List<String> allowedLabels, List<String> allowedRelationships,
+                                      Map<String, List<String>> allowedProperties) {
+        Set<String> labelFilter = normalizedSet(allowedLabels);
+        Set<String> relationshipFilter = normalizedSet(allowedRelationships);
+        List<NodeSchema> filteredNodes = nodes.stream()
+                .filter(node -> labelFilter.isEmpty() || labelFilter.contains(node.label()))
+                .map(node -> new NodeSchema(node.label(), filterProperties(node.label(), node.properties(), allowedProperties)))
+                .toList();
+        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)))
+                .toList();
+        return new GraphSchemaSnapshot(filteredNodes, filteredRelationships);
+    }
+
+    public String toPromptText() {
+        StringBuilder text = new StringBuilder("Nodes:\n");
+        for (NodeSchema node : nodes) {
+            text.append("(:").append(node.label()).append(" {")
+                    .append(formatProperties(node.properties())).append("})\n");
+        }
+        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");
+                }
+            }
+        }
+        return text.toString();
+    }
+
+    private static Map<String, List<String>> filterProperties(String owner, Map<String, List<String>> actual,
+                                                               Map<String, List<String>> allowed) {
+        if (allowed == null || allowed.isEmpty()) return actual;
+        List<String> configured = allowed.get(owner);
+        if (configured == null) configured = allowed.get("*");
+        if (configured == null) return Map.of();
+        Set<String> names = new HashSet<>(configured);
+        return actual.entrySet().stream().filter(entry -> names.contains(entry.getKey()))
+                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a, LinkedHashMap::new));
+    }
+
+    private static Set<String> normalizedSet(List<String> values) {
+        return values == null ? Set.of() : values.stream().filter(Objects::nonNull)
+                .collect(Collectors.toCollection(LinkedHashSet::new));
+    }
+
+    private static Map<String, List<String>> immutableProperties(Map<String, List<String>> source) {
+        if (source == null || source.isEmpty()) return Map.of();
+        Map<String, List<String>> result = new LinkedHashMap<>();
+        source.forEach((key, value) -> result.put(key, List.copyOf(value == null ? List.of() : value)));
+        return Collections.unmodifiableMap(result);
+    }
+
+    private static String formatProperties(Map<String, List<String>> properties) {
+        return properties.entrySet().stream()
+                .map(entry -> entry.getKey() + ": " + String.join("|", entry.getValue()))
+                .collect(Collectors.joining(", "));
+    }
+}

+ 84 - 0
backend/src/main/java/com/agent/management/rag/graph/GraphSchemaValidator.java

@@ -0,0 +1,84 @@
+package com.agent.management.rag.graph;
+
+import org.springframework.stereotype.Component;
+
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@Component
+public class GraphSchemaValidator {
+    private static final Pattern NODE = Pattern.compile("\\(([A-Za-z_]\\w*)?\\s*(?::\\s*`?([A-Za-z_]\\w*)`?)?\\s*(?:\\{([^}]*)})?\\)");
+    private static final Pattern RELATIONSHIP = Pattern.compile("\\[[^]]*?:\\s*`?([A-Za-z_]\\w*)`?([^]]*)]");
+    private static final Pattern PROPERTY_REFERENCE = Pattern.compile("\\b([A-Za-z_]\\w*)\\s*\\.\\s*`?([A-Za-z_]\\w*)`?");
+    private static final Pattern MAP_PROPERTY = Pattern.compile("(?:^|,)\\s*`?([A-Za-z_]\\w*)`?\\s*:");
+    private static final Pattern VARIABLE_LENGTH = Pattern.compile("\\*\\s*(\\d*)\\s*(?:\\.\\.\\s*(\\d*))?");
+    private static final Pattern LABEL_PREDICATE = Pattern.compile("\\b([A-Za-z_]\\w*)\\s*:\\s*`?([A-Za-z_]\\w*)`?");
+
+    public void validate(String cypher, GraphSchemaSnapshot schema, int maxDepth) {
+        Map<String, String> variables = new HashMap<>();
+        Matcher predicateMatcher=LABEL_PREDICATE.matcher(cypher);
+        while(predicateMatcher.find()) {
+            String label=predicateMatcher.group(2);
+            if(schema.node(label).isEmpty()) throw new IllegalArgumentException("Cypher uses nonexistent or unauthorized label: "+label);
+            variables.putIfAbsent(predicateMatcher.group(1),label);
+        }
+        Matcher nodeMatcher = NODE.matcher(cypher);
+        while (nodeMatcher.find()) {
+            String variable = nodeMatcher.group(1);
+            String label = nodeMatcher.group(2);
+            if (label != null) {
+                GraphSchemaSnapshot.NodeSchema node = schema.node(label)
+                        .orElseThrow(() -> new IllegalArgumentException("Cypher uses nonexistent or unauthorized label: " + label));
+                if (variable != null) variables.put(variable, label);
+                validateMapProperties(nodeMatcher.group(3), node.properties().keySet(), "label " + label);
+            } else if (variable == null || !variables.containsKey(variable)) {
+                throw new IllegalArgumentException("Cypher node patterns must use an authorized label");
+            }
+        }
+
+        Set<String> allowedRelationships = new HashSet<>(schema.relationshipTypeNames());
+        Matcher relationshipMatcher = RELATIONSHIP.matcher(cypher);
+        while (relationshipMatcher.find()) {
+            String type = relationshipMatcher.group(1);
+            if (!allowedRelationships.contains(type)) {
+                throw new IllegalArgumentException("Cypher uses nonexistent or unauthorized relationship: " + type);
+            }
+            validateVariableLength(relationshipMatcher.group(2), maxDepth);
+        }
+
+        Matcher propertyMatcher = PROPERTY_REFERENCE.matcher(cypher);
+        while (propertyMatcher.find()) {
+            String label = variables.get(propertyMatcher.group(1));
+            if (label == null) continue;
+            String property = propertyMatcher.group(2);
+            if (!schema.node(label).orElseThrow().properties().containsKey(property)) {
+                throw new IllegalArgumentException("Cypher uses nonexistent or unauthorized property: " + label + "." + property);
+            }
+        }
+    }
+
+    private static void validateMapProperties(String map, Set<String> allowed, String owner) {
+        if (map == null) return;
+        Matcher matcher = MAP_PROPERTY.matcher(map);
+        while (matcher.find()) {
+            if (!allowed.contains(matcher.group(1))) {
+                throw new IllegalArgumentException("Cypher uses nonexistent or unauthorized property on " + owner + ": " + matcher.group(1));
+            }
+        }
+    }
+
+    private static void validateVariableLength(String relationshipTail, int maxDepth) {
+        Matcher matcher = VARIABLE_LENGTH.matcher(relationshipTail == null ? "" : relationshipTail);
+        while (matcher.find()) {
+            String lower = matcher.group(1);
+            String upper = matcher.group(2);
+            if (upper == null || upper.isBlank()) {
+                if (lower == null || lower.isBlank()) throw new IllegalArgumentException("unbounded variable-length paths are forbidden");
+                if (Integer.parseInt(lower) > maxDepth) throw new IllegalArgumentException("path depth exceeds maxDepth=" + maxDepth);
+            } else if (Integer.parseInt(upper) > maxDepth) {
+                throw new IllegalArgumentException("path depth exceeds maxDepth=" + maxDepth);
+            }
+        }
+    }
+}

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

@@ -0,0 +1,86 @@
+package com.agent.management.rag.graph;
+
+import com.agent.management.rag.bridge.RagAiBridgeClient;
+import com.agent.management.rag.capability.RagCapabilityProfileService;
+import com.agent.management.rag.capability.RagSchemaLinker;
+import com.agent.management.rag.capability.RagCapabilityProfile;
+import com.agent.management.rag.capability.RagSemanticCatalogService;
+import com.agent.management.rag.capability.GraphBusinessSubgraphSelector;
+import com.agent.management.rag.capability.RagEntityMentionExtractor;
+import com.agent.management.rag.memory.RagExampleMemoryService;
+import com.agent.management.rag.model.RagQuery;
+import com.agent.management.rag.model.RagSourceType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+
+@Service
+@RequiredArgsConstructor
+public class Neo4jGraphRagCypherGenerationService implements CypherGenerationService {
+    private final RagAiBridgeClient bridge;
+    private final RagCapabilityProfileService profiles;
+    private final RagSchemaLinker linker;
+    private final RagSemanticCatalogService semanticCatalog;
+    private final GraphBusinessSubgraphSelector subgraphs;
+    private final GraphSchemaValidator validator;
+    private final RagExampleMemoryService examples;
+    private final RagEntityMentionExtractor entities;
+
+    public Optional<String> generateCypher(RagQuery query, Long graphSourceId) {
+        if (!allowed(query)) return Optional.empty();
+        RagCapabilityProfile profile = profiles.get(RagSourceType.GRAPH, String.valueOf(graphSourceId));
+        GraphSchemaSnapshot actual = profile.graphSchema();
+        GraphSchemaSnapshot authorized = actual.filter(stringList(query,"allowedLabels"),
+                stringList(query,"allowedRelationships"), properties(query));
+        List<Map<String,Object>> promptExamples = new ArrayList<>(maps(query, "examples"));
+        promptExamples.addAll(examples.findSimilar(RagSourceType.GRAPH, String.valueOf(graphSourceId), query.getQuery(), 3));
+        List<String> semanticLabels = semanticCatalog.rank(query.getQuery(), profile, "label:", 8);
+        List<String> semanticRelationships = semanticCatalog.rank(query.getQuery(), profile, "relationship:", 8);
+        GraphSchemaSnapshot semanticSchema = authorized.nodes().size() <= 12 || (semanticLabels.isEmpty() && semanticRelationships.isEmpty()) ? authorized
+                : subgraphs.select(query.getQuery(), profile, authorized, 10, 14);
+        GraphSchemaSnapshot selected = linker.selectGraphSchema(query.getQuery(), semanticSchema, promptExamples);
+        if (selected.nodes().isEmpty()) throw new IllegalArgumentException("no relevant authorized graph schema is available for Text-to-Cypher");
+        int maxDepth = integer(query, "maxDepth", 3);
+
+        Map<String,Object> request = new LinkedHashMap<>();
+        request.put("query",query.getQuery()); request.put("graphSourceId",graphSourceId);
+        request.put("schema",selected.toPromptText()); request.put("examples",normalizeExamples(promptExamples));
+        request.put("businessRules",generationContext(query)); request.put("allowedLabels",selected.labelNames());
+        request.put("allowedRelationships",selected.relationshipTypeNames()); request.put("allowedProperties",allowedProperties(selected));
+        request.put("maxDepth",maxDepth);
+        request.put("entityMentions", entities.extract(query.getQuery()));
+        Object cypher = bridge.textToCypher(request).get("cypher");
+        if(cypher==null||String.valueOf(cypher).isBlank()) return Optional.empty();
+        String generated=String.valueOf(cypher);
+        try {
+            validator.validate(generated,selected,maxDepth);
+            return Optional.of(generated);
+        } catch (IllegalArgumentException validationError) {
+            Object fixed=bridge.repair(Map.of("language","CYPHER","question",query.getQuery(),"query",generated,
+                    "error",validationError.getMessage(),"schemaText",selected.toPromptText(),"maxRows",50,"maxDepth",maxDepth)).get("query");
+            if(fixed==null||String.valueOf(fixed).isBlank()) throw validationError;
+            String repaired=String.valueOf(fixed);validator.validate(repaired,selected,maxDepth);return Optional.of(repaired);
+        }
+    }
+
+    public Optional<String> repair(RagQuery query,Long graphSourceId,String failedCypher,String error){
+        GraphSchemaSnapshot schema=profiles.get(RagSourceType.GRAPH,String.valueOf(graphSourceId)).graphSchema()
+                .filter(stringList(query,"allowedLabels"),stringList(query,"allowedRelationships"),properties(query));
+        int maxDepth=integer(query,"maxDepth",3);
+        Object fixed=bridge.repair(Map.of("language","CYPHER","question",query.getQuery(),"query",failedCypher,
+                "error",error,"schemaText",schema.toPromptText(),"maxRows",50,"maxDepth",maxDepth)).get("query");
+        if(fixed==null||String.valueOf(fixed).isBlank())return Optional.empty();
+        validator.validate(String.valueOf(fixed),schema,maxDepth);return Optional.of(String.valueOf(fixed));
+    }
+
+    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);}
+    private static String generationContext(RagQuery query){String existing=string(query,"businessRules");List<String> rules=stringList(query,"generationRules");return rules.isEmpty()?existing:existing+(existing.isBlank()?"":"\n")+"Generation rules:\n- "+String.join("\n- ",rules);}
+    private static List<String> stringList(RagQuery q,String key){Object value=q.getFilters().get(key);return value instanceof List<?> list?list.stream().map(String::valueOf).toList():List.of();}
+    private static List<Map<String,Object>> maps(RagQuery q,String key){Object value=q.getFilters().get(key);if(!(value instanceof List<?> list))return List.of();return list.stream().filter(Map.class::isInstance).map(item->{Map<?,?> raw=(Map<?,?>)item;Map<String,Object> map=new LinkedHashMap<>();raw.forEach((k,v)->map.put(String.valueOf(k),v));return map;}).toList();}
+    private static List<Map<String,Object>> normalizeExamples(List<Map<String,Object>> examples){return examples.stream().map(item->item.containsKey("cypher")?item:Map.<String,Object>of("question",item.get("question"),"cypher",item.get("query"))).toList();}
+    private static Map<String,List<String>> properties(RagQuery q){Object value=q.getFilters().get("allowedProperties");if(!(value instanceof Map<?,?> map))return Map.of();Map<String,List<String>> result=new LinkedHashMap<>();map.forEach((key,raw)->{if(raw instanceof List<?> list)result.put(String.valueOf(key),list.stream().map(String::valueOf).toList());});return result;}
+    private static Map<String,List<String>> allowedProperties(GraphSchemaSnapshot schema){Map<String,List<String>> result=new LinkedHashMap<>();schema.nodes().forEach(node->result.put(node.label(),new ArrayList<>(node.properties().keySet())));schema.relationships().forEach(rel->result.put(rel.type(),new ArrayList<>(rel.properties().keySet())));return result;}
+}

+ 146 - 0
backend/src/main/java/com/agent/management/rag/kb/KnowledgeBaseRagRetriever.java

@@ -0,0 +1,146 @@
+package com.agent.management.rag.kb;
+
+import com.agent.management.model.entity.RagKnowledgeBase;
+import com.agent.management.model.entity.RagKnowledgeSourceBinding;
+import com.agent.management.rag.api.RagRetriever;
+import com.agent.management.rag.model.*;
+import com.agent.management.repository.RagKnowledgeBaseRepository;
+import com.agent.management.repository.RagKnowledgeSourceBindingRepository;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.beans.factory.annotation.Qualifier;
+
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Service
+public class KnowledgeBaseRagRetriever {
+    private final RagKnowledgeBaseRepository bases;
+    private final RagKnowledgeSourceBindingRepository bindings;
+    private final Map<RagSourceType, RagRetriever> retrievers;
+    private final ObjectMapper mapper;
+    private final Executor executor;
+    private final RagQuestionPlanningService planner;
+
+    public KnowledgeBaseRagRetriever(RagKnowledgeBaseRepository bases,
+                                     RagKnowledgeSourceBindingRepository bindings,
+                                     List<RagRetriever> retrievers,
+                                     ObjectMapper mapper,
+                                     @Qualifier("ragRetrievalExecutor") Executor executor,
+                                     RagQuestionPlanningService planner) {
+        this.bases = bases;
+        this.bindings = bindings;
+        this.retrievers = retrievers.stream()
+                .collect(Collectors.toMap(RagRetriever::sourceType, Function.identity()));
+        this.mapper = mapper;
+        this.executor = executor;
+        this.planner = planner;
+    }
+
+    public KnowledgeBaseRagResult retrieve(KnowledgeBaseRagRequest request) {
+        KnowledgeBaseRagResult out = new KnowledgeBaseRagResult();
+        out.setKnowledgeBaseId(request.getKnowledgeBaseId());
+        Optional<RagKnowledgeBase> base = bases.findById(request.getKnowledgeBaseId());
+        if (base.isEmpty() || !base.get().isEnabled()) {
+            out.getDiagnostics().put("error", "knowledge base does not exist or is disabled");
+            return out;
+        }
+
+        List<RagKnowledgeSourceBinding> selected = bindings
+                .findByKnowledgeBaseIdAndEnabledTrueOrderByPriorityAsc(request.getKnowledgeBaseId())
+                .stream().filter(binding -> requested(request, binding.getSourceType())).toList();
+        selected.forEach(binding -> out.getEnabledSources().add(binding.getSourceType()));
+        Map<RagSourceType,String> subQuestions = planner.plan(request.getQuery(), selected);
+
+        List<CompletableFuture<BindingResult>> tasks = selected.stream()
+                .map(binding -> CompletableFuture.supplyAsync(() -> retrieveBinding(request, binding,
+                        subQuestions.getOrDefault(binding.getSourceType(), request.getQuery())), executor))
+                .toList();
+        for (CompletableFuture<BindingResult> task : tasks) {
+            BindingResult bindingResult = task.join();
+            out.getEvidences().addAll(bindingResult.result().getEvidences());
+            if (!bindingResult.result().getDiagnostics().isEmpty()) {
+                out.getDiagnostics().put(bindingResult.key(), bindingResult.result().getDiagnostics());
+            }
+        }
+        return out;
+    }
+
+    private BindingResult retrieveBinding(KnowledgeBaseRagRequest request, RagKnowledgeSourceBinding binding, String subQuestion) {
+        RagRetrievalResult result = new RagRetrievalResult();
+        result.setSourceType(binding.getSourceType());
+        RagRetriever retriever = retrievers.get(binding.getSourceType());
+        if (retriever == null) {
+            result.getDiagnostics().put("error", "retriever unavailable");
+            return new BindingResult(binding, result);
+        }
+        try {
+            Map<String, Object> filters = mergedFilters(request, binding);
+            RagQuery query = new RagQuery();
+            query.setQuery(subQuestion);
+            query.setUserId(request.getUserId());
+            query.setSessionId(request.getSessionId());
+            query.setSourceIds(List.of(binding.getSourceId()));
+            query.setTopK(request.getTopK() != null ? request.getTopK() : integer(filters.get("topK")));
+            query.setFilters(filters);
+            result = retriever.retrieve(query);
+        } catch (Exception e) {
+            result.getDiagnostics().put("error", e.getMessage());
+        }
+        return new BindingResult(binding, result);
+    }
+
+    private Map<String, Object> mergedFilters(KnowledgeBaseRagRequest request,
+                                               RagKnowledgeSourceBinding binding) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        if (binding.getConfigJson() != null && !binding.getConfigJson().isBlank()) {
+            try {
+                result.putAll(mapper.readValue(binding.getConfigJson(), new TypeReference<>() {}));
+            } catch (Exception e) {
+                result.put("configWarning", "invalid configJson: " + e.getMessage());
+            }
+        }
+        if (binding.getSourceType() == RagSourceType.STRUCTURED_DATA && result.containsKey("defaultSql")) {
+            result.putIfAbsent("sql", result.get("defaultSql"));
+        }
+        if (binding.getSourceType() == RagSourceType.GRAPH && result.containsKey("defaultCypher")) {
+            result.putIfAbsent("cypher", result.get("defaultCypher"));
+        }
+        if (request.getFilters() != null) {
+            String key = switch (binding.getSourceType()) {
+                case DOCUMENT -> "document";
+                case STRUCTURED_DATA -> "structured";
+                case GRAPH -> "graph";
+            };
+            Object scoped = request.getFilters().get(key);
+            if (scoped instanceof Map<?, ?> map) {
+                map.forEach((filterKey, value) -> result.put(String.valueOf(filterKey), value));
+            }
+        }
+        return result;
+    }
+
+    private static boolean requested(KnowledgeBaseRagRequest request, RagSourceType type) {
+        if (request.getFilters() == null) return true;
+        Object value = request.getFilters().get("sourceTypes");
+        if (!(value instanceof List<?> list) || list.isEmpty()) return true;
+        return list.stream().map(String::valueOf).anyMatch(type.name()::equalsIgnoreCase);
+    }
+
+    private static Integer integer(Object value) {
+        if (value instanceof Number number) return number.intValue();
+        try {
+            return value == null ? null : Integer.valueOf(String.valueOf(value));
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private record BindingResult(RagKnowledgeSourceBinding binding, RagRetrievalResult result) {
+        String key() { return binding.getSourceType() + ":" + binding.getSourceId(); }
+    }
+}

+ 42 - 0
backend/src/main/java/com/agent/management/rag/kb/RagKnowledgeBaseConfigService.java

@@ -0,0 +1,42 @@
+package com.agent.management.rag.kb;
+
+import com.agent.management.model.entity.RagKnowledgeSourceBinding;
+import com.agent.management.repository.RagKnowledgeSourceBindingRepository;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+public class RagKnowledgeBaseConfigService {
+    private final RagKnowledgeSourceBindingRepository bindings;
+    private final ObjectMapper mapper;
+
+    public List<RagKnowledgeSourceBinding> listBindings(Long knowledgeBaseId) {
+        return bindings.findByKnowledgeBaseIdOrderByPriorityAsc(knowledgeBaseId);
+    }
+
+    public RagKnowledgeSourceBinding updateConfig(Long knowledgeBaseId, Long bindingId, Map<String, Object> config) {
+        RagKnowledgeSourceBinding binding = bindings.findById(bindingId)
+                .filter(item -> knowledgeBaseId.equals(item.getKnowledgeBaseId()))
+                .orElseThrow(() -> new IllegalArgumentException("knowledge base binding does not exist: " + bindingId));
+        validateAuthorization(binding, config);
+        try {
+            binding.setConfigJson(mapper.writeValueAsString(config == null ? Map.of() : config));
+            return bindings.save(binding);
+        } catch (Exception e) {
+            throw new IllegalArgumentException("invalid binding config: " + e.getMessage(), e);
+        }
+    }
+
+    private static void validateAuthorization(RagKnowledgeSourceBinding binding, Map<String,Object> config) {
+        if (config == null || !"AUTO_GENERATE".equals(String.valueOf(config.get("retrievalMode")))) return;
+        String key = binding.getSourceType() == com.agent.management.rag.model.RagSourceType.GRAPH ? "allowedLabels"
+                : binding.getSourceType() == com.agent.management.rag.model.RagSourceType.STRUCTURED_DATA ? "allowedTables" : null;
+        if (key != null && (!(config.get(key) instanceof List<?> values) || values.isEmpty()))
+            throw new IllegalArgumentException("automatic generation requires a non-empty explicit " + key);
+    }
+}

+ 32 - 0
backend/src/main/java/com/agent/management/rag/kb/RagQuestionPlanningService.java

@@ -0,0 +1,32 @@
+package com.agent.management.rag.kb;
+
+import com.agent.management.model.entity.RagKnowledgeSourceBinding;
+import com.agent.management.rag.bridge.RagAiBridgeClient;
+import com.agent.management.rag.model.RagSourceType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+
+@Service
+@RequiredArgsConstructor
+public class RagQuestionPlanningService {
+    private final RagAiBridgeClient bridge;
+
+    public Map<RagSourceType,String> plan(String question, List<RagKnowledgeSourceBinding> bindings) {
+        Map<String,String> capabilities = new LinkedHashMap<>();
+        bindings.forEach(binding -> capabilities.put(binding.getSourceType().name(),
+                binding.getConfigJson() == null ? "enabled source" : binding.getConfigJson()));
+        try {
+            Map<String,Object> response = bridge.plan(Map.of("question",question,"capabilities",capabilities));
+            Map<RagSourceType,String> plan = new EnumMap<>(RagSourceType.class);
+            response.forEach((key,value) -> {
+                try { if(value!=null&&!String.valueOf(value).isBlank())plan.put(RagSourceType.valueOf(key),String.valueOf(value)); }
+                catch (IllegalArgumentException ignored) {}
+            });
+            return plan;
+        } catch (Exception ignored) {
+            return Map.of();
+        }
+    }
+}

+ 21 - 0
backend/src/main/java/com/agent/management/rag/kb/RagRetrievalExecutorConfig.java

@@ -0,0 +1,21 @@
+package com.agent.management.rag.kb;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.Executor;
+
+@Configuration
+public class RagRetrievalExecutorConfig {
+    @Bean(name = "ragRetrievalExecutor")
+    public Executor ragRetrievalExecutor() {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        executor.setCorePoolSize(3);
+        executor.setMaxPoolSize(6);
+        executor.setQueueCapacity(50);
+        executor.setThreadNamePrefix("rag-retrieval-");
+        executor.initialize();
+        return executor;
+    }
+}

+ 64 - 0
backend/src/main/java/com/agent/management/rag/memory/RagExampleCandidateService.java

@@ -0,0 +1,64 @@
+package com.agent.management.rag.memory;
+
+import com.agent.management.rag.capability.RagCapabilityProfileService;
+import com.agent.management.rag.graph.GraphSchemaValidator;
+import com.agent.management.rag.model.RagSourceType;
+import com.agent.management.service.GraphSourceService;
+import com.agent.management.service.SchemaExplorerService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+
+@Service
+@RequiredArgsConstructor
+public class RagExampleCandidateService {
+    private final RagExampleMemoryService memory;
+    private final RagCapabilityProfileService profiles;
+    private final GraphSchemaValidator graphValidator;
+    private final GraphSourceService graphSources;
+    private final SchemaExplorerService sqlExplorer;
+
+    public Map<String,Object> importValidated(RagSourceType type, String sourceId, List<Map<String,Object>> candidates) {
+        int accepted = 0;
+        List<Map<String,String>> rejected = new ArrayList<>();
+        for (Map<String,Object> candidate : candidates == null ? List.<Map<String,Object>>of() : candidates) {
+            String question = String.valueOf(candidate.getOrDefault("question", ""));
+            try {
+                String query = query(candidate);
+                preflight(type, sourceId, query);
+                memory.addCandidates(type, sourceId, List.of(candidate));
+                accepted++;
+            } catch (Exception error) {
+                rejected.add(Map.of("question", question, "reason", shortMessage(error)));
+            }
+        }
+        Map<String,Object> result = new LinkedHashMap<>();
+        result.put("accepted", accepted); result.put("rejected", rejected);
+        result.put("examples", memory.list(type, sourceId));
+        return result;
+    }
+
+    private void preflight(RagSourceType type, String sourceId, String query) {
+        if (query.isBlank()) throw new IllegalArgumentException("candidate query is empty");
+        Long id = Long.valueOf(sourceId);
+        if (type == RagSourceType.GRAPH) {
+            var schema = profiles.get(type, sourceId).graphSchema();
+            graphValidator.validate(query, schema, 5);
+            graphSources.explainQuery(id, query);
+        } else if (type == RagSourceType.STRUCTURED_DATA) {
+            sqlExplorer.explainQuery(id, query);
+        } else throw new IllegalArgumentException("Few-shot candidate is unsupported for " + type);
+    }
+
+    private static String query(Map<String,Object> candidate) {
+        Object value = candidate.containsKey("sql") ? candidate.get("sql")
+                : candidate.containsKey("cypher") ? candidate.get("cypher") : candidate.get("query");
+        return value == null ? "" : String.valueOf(value).trim();
+    }
+
+    private static String shortMessage(Exception error) {
+        String message = error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage();
+        return message.substring(0, Math.min(200, message.length()));
+    }
+}

+ 92 - 0
backend/src/main/java/com/agent/management/rag/memory/RagExampleMemoryService.java

@@ -0,0 +1,92 @@
+package com.agent.management.rag.memory;
+
+import com.agent.management.model.entity.RagQueryExample;
+import com.agent.management.rag.capability.RagTextSimilarity;
+import com.agent.management.rag.capability.RagSemanticCatalogService;
+import com.agent.management.rag.model.RagSourceType;
+import com.agent.management.repository.RagQueryExampleRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+
+@Service
+@RequiredArgsConstructor
+public class RagExampleMemoryService {
+    private final RagQueryExampleRepository repository;
+    private final RagSemanticCatalogService semanticCatalog;
+    private final com.agent.management.rag.capability.RagCapabilityProfileService profiles;
+
+    public List<Map<String, Object>> findSimilar(RagSourceType type, String sourceId, String question, int limit) {
+        String version = profiles.get(type, sourceId).version();
+        List<RagQueryExample> candidates = repository.findBySourceTypeAndSourceIdAndVerifiedTrue(type, sourceId).stream()
+                .filter(item -> {
+                    if (item.getSchemaVersion() == null) { item.setSchemaVersion(version); repository.save(item); return true; }
+                    if (version.equals(item.getSchemaVersion())) return true;
+                    item.setVerified(false); repository.save(item); return false;
+                }).toList();
+        Map<String,String> texts = new LinkedHashMap<>();
+        for (int i = 0; i < candidates.size(); i++) texts.put(String.valueOf(i), candidates.get(i).getQuestion());
+        List<String> semanticOrder = semanticCatalog.rankTexts(question, texts, limit);
+        java.util.stream.Stream<RagQueryExample> ordered = semanticOrder.isEmpty() ? candidates.stream()
+                .sorted(Comparator.comparingDouble((RagQueryExample item) ->
+                        RagTextSimilarity.score(question, item.getQuestion())).reversed())
+                : semanticOrder.stream().map(index -> candidates.get(Integer.parseInt(index)));
+        return ordered.limit(Math.max(0, limit))
+                .map(item -> Map.<String, Object>of("question", item.getQuestion(), "query", item.getQueryText()))
+                .toList();
+    }
+
+    public void recordSuccess(RagSourceType type, String sourceId, String question, String queryText) {
+        if (corrupted(question) || corrupted(queryText)) return;
+        repository.findFirstBySourceTypeAndSourceIdAndQuestionAndQueryText(type, sourceId, question, queryText)
+                .ifPresentOrElse(existing -> {
+                    existing.setSuccessCount(existing.getSuccessCount() + 1);
+                    repository.save(existing);
+                }, () -> {
+                    RagQueryExample example = new RagQueryExample();
+                    example.setSourceType(type); example.setSourceId(sourceId); example.setQuestion(question);
+                    example.setQueryText(queryText); example.setVerified(false);
+                    example.setSchemaVersion(profiles.get(type, sourceId).version()); repository.save(example);
+                });
+    }
+
+    public List<RagQueryExample> list(RagSourceType type, String sourceId) {
+        return repository.findBySourceTypeAndSourceId(type, sourceId);
+    }
+
+    public RagQueryExample setVerified(Long id, boolean verified) {
+        RagQueryExample example = repository.findById(id)
+                .orElseThrow(() -> new IllegalArgumentException("query example does not exist: " + id));
+        example.setVerified(verified);
+        return repository.save(example);
+    }
+
+    public void delete(Long id) {
+        if (!repository.existsById(id)) throw new IllegalArgumentException("query example does not exist: " + id);
+        repository.deleteById(id);
+    }
+
+    public List<RagQueryExample> addCandidates(RagSourceType type, String sourceId, List<Map<String,Object>> candidates) {
+        for (Map<String,Object> candidate : candidates == null ? List.<Map<String,Object>>of() : candidates) {
+            String question = String.valueOf(candidate.getOrDefault("question", "")).trim();
+            Object rawQuery = candidate.containsKey("sql") ? candidate.get("sql")
+                    : candidate.containsKey("cypher") ? candidate.get("cypher") : candidate.get("query");
+            String query = rawQuery == null ? "" : String.valueOf(rawQuery).trim();
+            if (!question.isEmpty() && !query.isEmpty() && readOnlyCandidate(query)) recordSuccess(type, sourceId, question, query);
+        }
+        return list(type, sourceId);
+    }
+
+    private static boolean readOnlyCandidate(String query) {
+        String normalized = query.toUpperCase(Locale.ROOT);
+        return java.util.regex.Pattern.compile("\\b(INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE|CREATE|MERGE|SET|REMOVE|LOAD\\s+CSV)\\b")
+                .matcher(normalized).find() == false;
+    }
+
+    private static boolean corrupted(String value) {
+        if (value == null || value.isBlank() || value.indexOf('\uFFFD') >= 0) return true;
+        long questionMarks = value.chars().filter(ch -> ch == '?').count();
+        return value.contains("????") || questionMarks >= Math.max(4, value.length() / 3);
+    }
+}

+ 4 - 0
backend/src/main/java/com/agent/management/rag/model/KnowledgeBaseRagRequest.java

@@ -0,0 +1,4 @@
+package com.agent.management.rag.model;
+import lombok.Data;
+import java.util.*;
+@Data public class KnowledgeBaseRagRequest { private Long knowledgeBaseId; private String query; private String userId; private String sessionId; private Integer topK; private Map<String,Object> filters; }

+ 4 - 0
backend/src/main/java/com/agent/management/rag/model/KnowledgeBaseRagResult.java

@@ -0,0 +1,4 @@
+package com.agent.management.rag.model;
+import lombok.Data;
+import java.util.*;
+@Data public class KnowledgeBaseRagResult { private Long knowledgeBaseId; private List<RagSourceType> enabledSources = new ArrayList<>(); private List<RagEvidence> evidences = new ArrayList<>(); private Map<String,Object> diagnostics = new LinkedHashMap<>(); }

+ 4 - 0
backend/src/main/java/com/agent/management/rag/model/RagEvidence.java

@@ -0,0 +1,4 @@
+package com.agent.management.rag.model;
+import lombok.Data;
+import java.util.*;
+@Data public class RagEvidence { private String id; private RagSourceType sourceType; private String evidenceType; private String title; private String content; private Double score; private String sourceName; private RagSourceRef sourceRef; private Map<String,Object> payload; private Map<String,Object> metadata; }

+ 4 - 0
backend/src/main/java/com/agent/management/rag/model/RagQuery.java

@@ -0,0 +1,4 @@
+package com.agent.management.rag.model;
+import lombok.Data;
+import java.util.*;
+@Data public class RagQuery { private String query; private String userId; private String sessionId; private List<String> sourceIds; private Integer topK; private Map<String,Object> filters; }

+ 4 - 0
backend/src/main/java/com/agent/management/rag/model/RagRetrievalResult.java

@@ -0,0 +1,4 @@
+package com.agent.management.rag.model;
+import lombok.Data;
+import java.util.*;
+@Data public class RagRetrievalResult { private RagSourceType sourceType; private List<RagEvidence> evidences = new ArrayList<>(); private Map<String,Object> diagnostics = new LinkedHashMap<>(); }

+ 4 - 0
backend/src/main/java/com/agent/management/rag/model/RagSourceRef.java

@@ -0,0 +1,4 @@
+package com.agent.management.rag.model;
+import lombok.Data;
+import java.util.*;
+@Data public class RagSourceRef { private String sourceId; private String sourceName; private Map<String,Object> locator; }

+ 2 - 0
backend/src/main/java/com/agent/management/rag/model/RagSourceType.java

@@ -0,0 +1,2 @@
+package com.agent.management.rag.model;
+public enum RagSourceType { DOCUMENT, STRUCTURED_DATA, GRAPH }

+ 2 - 0
backend/src/main/java/com/agent/management/rag/structured/ExplicitSqlGenerationService.java

@@ -0,0 +1,2 @@
+package com.agent.management.rag.structured; import com.agent.management.rag.model.RagQuery; import org.springframework.stereotype.Service; import java.util.Optional;
+@Service public class ExplicitSqlGenerationService implements SqlGenerationService { public Optional<String> generateSql(RagQuery q,Long id){Object v=q.getFilters()==null?null:q.getFilters().get("sql"); return v==null||String.valueOf(v).isBlank()?Optional.empty():Optional.of(String.valueOf(v));}}

+ 2 - 0
backend/src/main/java/com/agent/management/rag/structured/SqlGenerationService.java

@@ -0,0 +1,2 @@
+package com.agent.management.rag.structured; import com.agent.management.rag.model.RagQuery; import java.util.Optional;
+public interface SqlGenerationService { Optional<String> generateSql(RagQuery query,Long datasourceId); }

+ 115 - 0
backend/src/main/java/com/agent/management/rag/structured/StructuredDataRagRetriever.java

@@ -0,0 +1,115 @@
+package com.agent.management.rag.structured;
+
+import com.agent.management.rag.api.RagRetriever;
+import com.agent.management.rag.memory.RagExampleMemoryService;
+import com.agent.management.rag.model.*;
+import com.agent.management.service.DataSourceService;
+import com.agent.management.service.SchemaExplorerService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+
+@Service
+@RequiredArgsConstructor
+public class StructuredDataRagRetriever implements RagRetriever {
+    private final ExplicitSqlGenerationService explicit;
+    private final VannaSqlGenerationService generated;
+    private final SchemaExplorerService explorer;
+    private final DataSourceService dataSources;
+    private final RagExampleMemoryService exampleMemory;
+
+    public RagSourceType sourceType() { return RagSourceType.STRUCTURED_DATA; }
+
+    public RagRetrievalResult retrieve(RagQuery query) {
+        RagRetrievalResult out = new RagRetrievalResult();
+        out.setSourceType(sourceType());
+        try {
+            Long id = sourceId(query);
+            Optional<String> sql = explicit.generateSql(query, id);
+            boolean auto = false;
+            if (sql.isEmpty()) {
+                auto = true;
+                try {
+                    sql = generated.generateSql(query, id);
+                } catch (Exception error) {
+                    out.getDiagnostics().put("error", "SQL generation failed: " + error.getMessage());
+                    return out;
+                }
+            }
+            if (sql.isEmpty()) {
+                out.getDiagnostics().put("warning", "no SQL provided and automatic generation is disabled or returned empty");
+                return out;
+            }
+            if (auto) {
+                sql = preflightAndRepair(query, id, sql.get(), out);
+                if (sql.isEmpty()) return out;
+            }
+            int limit = maxRows(query);
+            var result = explorer.executeQuery(id, sql.get(), limit);
+            RagEvidence evidence = new RagEvidence();
+            evidence.setId("sql-" + UUID.randomUUID());
+            evidence.setSourceType(sourceType());
+            evidence.setEvidenceType("SQL_RESULT");
+            evidence.setTitle(dataSources.get(id).getName());
+            evidence.setSourceName(evidence.getTitle());
+            evidence.setContent(summary(result));
+            evidence.setScore(1.0);
+            RagSourceRef ref = new RagSourceRef();
+            ref.setSourceId(String.valueOf(id));
+            ref.setSourceName(evidence.getTitle());
+            ref.setLocator(Map.of("datasourceId", id));
+            evidence.setSourceRef(ref);
+            evidence.setPayload(Map.of("columns", result.columns(), "rows", result.rows(), "rowCount", result.rows().size()));
+            Map<String, Object> metadata = new LinkedHashMap<>();
+            metadata.put("sql", sql.get());
+            metadata.put(auto ? "generatedSql" : "explicitSql", sql.get());
+            metadata.put("executionTimeMs", result.executionTimeMs());
+            metadata.put("warnings", List.of());
+            evidence.setMetadata(metadata);
+            out.getEvidences().add(evidence);
+            if (auto && autoLearn(query) && !result.rows().isEmpty())
+                exampleMemory.recordSuccess(sourceType(), String.valueOf(id), query.getQuery(), sql.get());
+        } catch (Exception error) {
+            out.getDiagnostics().put("error", error.getMessage());
+        }
+        return out;
+    }
+
+    private Optional<String> preflightAndRepair(RagQuery query, Long sourceId, String sql, RagRetrievalResult out) {
+        try {
+            explorer.explainQuery(sourceId, sql);
+            return Optional.of(sql);
+        } catch (Exception explainError) {
+            try {
+                Optional<String> repaired = generated.repair(query, sourceId, sql, explainError.getMessage());
+                if (repaired.isEmpty()) throw new IllegalArgumentException("repair returned empty SQL");
+                explorer.explainQuery(sourceId, repaired.get());
+                out.getDiagnostics().put("repair", "generated SQL failed EXPLAIN and was repaired once");
+                return repaired;
+            } catch (Exception repairError) {
+                out.getDiagnostics().put("error", "SQL preflight/repair failed: " + repairError.getMessage());
+                return Optional.empty();
+            }
+        }
+    }
+
+    private static boolean autoLearn(RagQuery query) {
+        return query.getFilters() == null || !Boolean.FALSE.equals(query.getFilters().get("autoLearnExamples"));
+    }
+
+    private static int maxRows(RagQuery query) {
+        Object configured = query.getFilters() == null ? null : query.getFilters().get("maxRows");
+        int value = configured instanceof Number number ? number.intValue() : (query.getTopK() != null ? query.getTopK() : 50);
+        return Math.max(1, Math.min(value, 1000));
+    }
+
+    private static Long sourceId(RagQuery query) {
+        if (query.getSourceIds() == null || query.getSourceIds().isEmpty()) throw new IllegalArgumentException("sourceIds must contain datasourceId");
+        return Long.valueOf(query.getSourceIds().get(0));
+    }
+
+    private static String summary(SchemaExplorerService.QueryResult result) {
+        return "查询返回 " + result.rows().size() + " 行,列:" + String.join(", ", result.columns());
+    }
+}

+ 84 - 0
backend/src/main/java/com/agent/management/rag/structured/VannaSqlGenerationService.java

@@ -0,0 +1,84 @@
+package com.agent.management.rag.structured;
+
+import com.agent.management.rag.bridge.RagAiBridgeClient;
+import com.agent.management.rag.capability.*;
+import com.agent.management.rag.memory.RagExampleMemoryService;
+import com.agent.management.rag.model.RagQuery;
+import com.agent.management.rag.model.RagSourceType;
+import com.agent.management.service.DataSourceService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+
+@Service
+@RequiredArgsConstructor
+public class VannaSqlGenerationService implements SqlGenerationService {
+    private final RagAiBridgeClient bridge;
+    private final DataSourceService dataSources;
+    private final RagCapabilityProfileService profiles;
+    private final RagSchemaLinker linker;
+    private final RagSemanticCatalogService semanticCatalog;
+    private final RagExampleMemoryService examples;
+    private final RagEntityMentionExtractor entities;
+
+    public Optional<String> generateSql(RagQuery query, Long datasourceId) {
+        if (!allowed(query)) return Optional.empty();
+        var datasource = dataSources.getDecrypted(datasourceId);
+        RagCapabilityProfile profile = profiles.get(RagSourceType.STRUCTURED_DATA, String.valueOf(datasourceId));
+        SqlSchemaSnapshot schema = profile.sqlSchema();
+        List<String> policyWhitelist = stringList(query, "tableWhitelist");
+        List<String> selectedTables = linker.selectSqlTables(query.getQuery(), schema, policyWhitelist,
+                stringMap(query, "tableHints"));
+        List<String> semanticTables = semanticCatalog.rank(query.getQuery(), profile, "table:", 4);
+        if (selectedTables.size() > 8 && !semanticTables.isEmpty()) {
+            Set<String> allowed = policyWhitelist.isEmpty() ? null : new HashSet<>(policyWhitelist);
+            selectedTables = semanticTables.stream().filter(name -> allowed == null || allowed.contains(name)).toList();
+        }
+        String ddl = schema.toDdl(selectedTables);
+        if (ddl.isBlank()) throw new IllegalArgumentException("no relevant authorized table schema is available for Text-to-SQL");
+
+        List<Object> promptExamples = new ArrayList<>(objectList(query, "examples"));
+        for (Map<String, Object> learned : examples.findSimilar(RagSourceType.STRUCTURED_DATA,
+                String.valueOf(datasourceId), query.getQuery(), 3)) {
+            promptExamples.add(Map.of("question", learned.get("question"), "sql", learned.get("query")));
+        }
+        Map<String, Object> request = new LinkedHashMap<>();
+        request.put("query", query.getQuery()); request.put("datasourceId", datasourceId);
+        request.put("dialect", datasource.getType()); request.put("ddl", ddl);
+        request.put("documentation", generationContext(query, "documentation")); request.put("examples", promptExamples);
+        request.put("tableWhitelist", selectedTables); request.put("maxRows", limit(query));
+        request.put("entityMentions", entities.extract(query.getQuery()));
+        Object sql = bridge.textToSql(request).get("sql");
+        return sql == null || String.valueOf(sql).isBlank() ? Optional.empty() : Optional.of(String.valueOf(sql));
+    }
+
+    public Optional<String> repair(RagQuery query, Long datasourceId, String failedSql, String error) {
+        SqlSchemaSnapshot schema=profiles.get(RagSourceType.STRUCTURED_DATA,String.valueOf(datasourceId)).sqlSchema();
+        List<String> selected=linker.selectSqlTables(query.getQuery(),schema,stringList(query,"tableWhitelist"),stringMap(query,"tableHints"));
+        Object fixed=bridge.repair(Map.of("language","SQL","question",query.getQuery(),"query",failedSql,
+                "error",error,"schemaText",schema.toDdl(selected),"maxRows",limit(query),"maxDepth",1)).get("query");
+        return fixed==null||String.valueOf(fixed).isBlank()?Optional.empty():Optional.of(String.valueOf(fixed));
+    }
+
+    static boolean allowed(RagQuery query) {
+        if (query.getFilters() == null) return false;
+        return Boolean.TRUE.equals(query.getFilters().get("allowTextToSql"))
+                || "AUTO_GENERATE".equals(String.valueOf(query.getFilters().get("retrievalMode")));
+    }
+
+    private static int limit(RagQuery query) {
+        Object configured = query.getFilters().get("maxRows");
+        int value = configured instanceof Number number ? number.intValue() : (query.getTopK() != null ? query.getTopK() : 50);
+        return Math.max(1, Math.min(value, 1000));
+    }
+    private static String string(RagQuery query, String key) { Object value=query.getFilters().get(key); return value==null?"":String.valueOf(value); }
+    private static String generationContext(RagQuery query, String existingKey) {
+        String existing = string(query, existingKey);
+        List<String> rules = stringList(query, "generationRules");
+        return rules.isEmpty() ? existing : existing + (existing.isBlank() ? "" : "\n") + "Generation rules:\n- " + String.join("\n- ", rules);
+    }
+    @SuppressWarnings("unchecked") private static List<Object> objectList(RagQuery query,String key){Object value=query.getFilters().get(key);return value instanceof List<?> list?(List<Object>)list:List.of();}
+    private static List<String> stringList(RagQuery query,String key){Object value=query.getFilters().get(key);return value instanceof List<?> list?list.stream().map(String::valueOf).toList():List.of();}
+    private static Map<String,String> stringMap(RagQuery query,String key){Object value=query.getFilters().get(key);if(!(value instanceof Map<?,?> map))return Map.of();Map<String,String> result=new LinkedHashMap<>();map.forEach((k,v)->result.put(String.valueOf(k),String.valueOf(v)));return result;}
+}

+ 2 - 0
backend/src/main/java/com/agent/management/repository/RagKnowledgeBaseRepository.java

@@ -0,0 +1,2 @@
+package com.agent.management.repository; import com.agent.management.model.entity.RagKnowledgeBase; import org.springframework.data.jpa.repository.JpaRepository;
+public interface RagKnowledgeBaseRepository extends JpaRepository<RagKnowledgeBase,Long>{}

+ 2 - 0
backend/src/main/java/com/agent/management/repository/RagKnowledgeSourceBindingRepository.java

@@ -0,0 +1,2 @@
+package com.agent.management.repository; import com.agent.management.model.entity.RagKnowledgeSourceBinding; import org.springframework.data.jpa.repository.JpaRepository; import java.util.*;
+public interface RagKnowledgeSourceBindingRepository extends JpaRepository<RagKnowledgeSourceBinding,Long>{List<RagKnowledgeSourceBinding> findByKnowledgeBaseIdAndEnabledTrueOrderByPriorityAsc(Long knowledgeBaseId);List<RagKnowledgeSourceBinding> findByKnowledgeBaseIdOrderByPriorityAsc(Long knowledgeBaseId);}

+ 15 - 0
backend/src/main/java/com/agent/management/repository/RagQueryExampleRepository.java

@@ -0,0 +1,15 @@
+package com.agent.management.repository;
+
+import com.agent.management.model.entity.RagQueryExample;
+import com.agent.management.rag.model.RagSourceType;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface RagQueryExampleRepository extends JpaRepository<RagQueryExample, Long> {
+    List<RagQueryExample> findBySourceTypeAndSourceIdAndVerifiedTrue(RagSourceType sourceType, String sourceId);
+    List<RagQueryExample> findBySourceTypeAndSourceId(RagSourceType sourceType, String sourceId);
+    Optional<RagQueryExample> findFirstBySourceTypeAndSourceIdAndQuestionAndQueryText(
+            RagSourceType sourceType, String sourceId, String question, String queryText);
+}

+ 7 - 0
backend/src/main/java/com/agent/management/service/GraphSourceService.java

@@ -1,6 +1,7 @@
 package com.agent.management.service;
 
 import com.agent.management.model.entity.GraphSource;
+import com.agent.management.rag.graph.GraphSchemaSnapshot;
 
 import java.util.List;
 import java.util.Map;
@@ -34,8 +35,14 @@ public interface GraphSourceService {
      */
     Map<String, Object> executeQuery(Long id, String cypher);
 
+    void explainQuery(Long id, String cypher);
+
     /** 列出图谱的所有 node labels */
     List<String> listLabels(Long id);
 
+    /** 自动发现节点、属性、关系类型及方向。 */
+    GraphSchemaSnapshot discoverSchema(Long id);
+    Map<String,Object> discoverMetadata(Long id);
+
     record TestResult(boolean success, String message) {}
 }

+ 80 - 0
backend/src/main/java/com/agent/management/service/Neo4jExecutorService.java

@@ -3,6 +3,7 @@ package com.agent.management.service;
 import com.agent.management.common.exception.BusinessException;
 import com.agent.management.config.Neo4jProperties;
 import com.agent.management.model.entity.GraphSource;
+import com.agent.management.rag.graph.GraphSchemaSnapshot;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.neo4j.driver.AuthTokens;
@@ -27,6 +28,9 @@ import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
 
 /**
  * Neo4j 动态 Driver 管理 + Cypher 执行
@@ -159,6 +163,82 @@ public class Neo4jExecutorService {
         }
     }
 
+    public GraphSchemaSnapshot discoverSchema(GraphSource gs) {
+        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<>();
+        try (var session = newSession(driver, gs.getDatabase())) {
+            var nodes = session.run("CALL db.schema.nodeTypeProperties() " +
+                    "YIELD nodeLabels, propertyName, propertyTypes " +
+                    "RETURN nodeLabels, propertyName, propertyTypes");
+            while (nodes.hasNext()) {
+                Record record = nodes.next();
+                for (String label : record.get("nodeLabels").asList(Value::asString)) {
+                    Map<String, List<String>> properties = nodeProperties.computeIfAbsent(label, ignored -> new TreeMap<>());
+                    if (!record.get("propertyName").isNull()) {
+                        properties.put(record.get("propertyName").asString(),
+                                record.get("propertyTypes").asList(Value::asString));
+                    }
+                }
+            }
+
+            var relationshipProps = session.run("CALL db.schema.relTypeProperties() " +
+                    "YIELD relType, propertyName, propertyTypes " +
+                    "RETURN relType, propertyName, propertyTypes");
+            while (relationshipProps.hasNext()) {
+                Record record = relationshipProps.next();
+                String type = normalizeSchemaToken(record.get("relType").asString());
+                relationshipProperties.computeIfAbsent(type, ignored -> new TreeMap<>());
+                if (!record.get("propertyName").isNull()) {
+                    relationshipProperties.get(type).put(record.get("propertyName").asString(),
+                            record.get("propertyTypes").asList(Value::asString));
+                }
+            }
+
+            var directions = session.run("MATCH (start)-[relationship]->(end) " +
+                    "UNWIND labels(start) AS startLabel UNWIND labels(end) AS endLabel " +
+                    "RETURN DISTINCT type(relationship) AS relationshipType, startLabel, endLabel");
+            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());
+                relationshipProperties.computeIfAbsent(type, ignored -> new TreeMap<>());
+            }
+        } catch (Neo4jException e) {
+            throw new BusinessException("获取图 Schema 失败:" + e.getMessage());
+        }
+
+        List<GraphSchemaSnapshot.NodeSchema> nodes = nodeProperties.entrySet().stream()
+                .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()))
+                .toList();
+        return new GraphSchemaSnapshot(nodes, relationships);
+    }
+
+    public Map<String,Object> discoverMetadata(GraphSource gs) {
+        Driver driver = getOrCreateDriver(gs);
+        try (var session = newSession(driver, gs.getDatabase())) {
+            List<Map<String,Object>> constraints = session.run("SHOW CONSTRAINTS YIELD name, type, labelsOrTypes, properties RETURN name, type, labelsOrTypes, properties")
+                    .list(record -> record.asMap(Value::asObject));
+            List<Map<String,Object>> indexes = session.run("SHOW INDEXES YIELD name, type, entityType, labelsOrTypes, properties, state RETURN name, type, entityType, labelsOrTypes, properties, state")
+                    .list(record -> record.asMap(Value::asObject));
+            List<Map<String,Object>> counts = session.run("MATCH (n) UNWIND labels(n) AS label RETURN label, count(*) AS count ORDER BY count DESC")
+                    .list(record -> record.asMap(Value::asObject));
+            return Map.of("constraints", constraints, "indexes", indexes, "labelCounts", counts);
+        } catch (Neo4jException e) { throw new BusinessException("获取图索引/约束失败:" + e.getMessage()); }
+    }
+
+    private static String normalizeSchemaToken(String value) {
+        return value == null ? "" : value.replace("`", "").replace(":", "").trim();
+    }
+
     /**
      * 关闭并清除指定图谱的 driver
      */

+ 6 - 0
backend/src/main/java/com/agent/management/service/SchemaExplorerService.java

@@ -22,6 +22,7 @@ public interface SchemaExplorerService {
      * 描述表字段
      */
     List<ColumnSpec> describeTable(Long datasourceId, String schema, String table);
+    List<ForeignKeySpec> listForeignKeys(Long datasourceId, String schema, String table);
 
     /**
      * 表行数统计(近似值)
@@ -37,6 +38,10 @@ public interface SchemaExplorerService {
      */
     QueryResult executeQuery(Long datasourceId, String sql, Integer limitOverride);
 
+    default void explainQuery(Long datasourceId, String sql) {
+        executeQuery(datasourceId, "EXPLAIN " + sql, 1);
+    }
+
     /**
      * 预览表数据(自动生成安全 SELECT)
      *
@@ -50,5 +55,6 @@ public interface SchemaExplorerService {
     // ===== DTO =====
     record TableSummary(String name, String type, String remarks) {}
     record ColumnSpec(String name, String type, int size, boolean nullable, String remarks) {}
+    record ForeignKeySpec(String name, String sourceTable, String sourceColumn, String targetTable, String targetColumn) {}
     record QueryResult(List<String> columns, List<List<Object>> rows, long executionTimeMs) {}
 }

+ 17 - 0
backend/src/main/java/com/agent/management/service/impl/EmbeddingBridgeClient.java

@@ -130,6 +130,23 @@ public class EmbeddingBridgeClient {
         return deleted instanceof Number n ? n.intValue() : 0;
     }
 
+    public Map<String, Object> retrieve(String query, int topK, String mode, String filterExpr) {
+        Map<String, Object> body = Map.of(
+                "query", query, "top_k", topK,
+                "mode", mode == null || mode.isBlank() ? "hybrid" : mode,
+                "filter_expr", filterExpr == null ? "" : filterExpr);
+        return postJson("/retrieve", body, new TypeReference<>() {});
+    }
+
+    @SuppressWarnings("unchecked")
+    public List<List<Double>> embed(List<String> texts) {
+        Map<String, Object> response = postJson("/embed", Map.of("texts", texts), new TypeReference<>() {});
+        Object vectors = response.get("vectors");
+        if (!(vectors instanceof List<?> rows)) return List.of();
+        return rows.stream().map(row -> ((List<?>) row).stream()
+                .map(value -> ((Number) value).doubleValue()).toList()).toList();
+    }
+
     private <T> T postJson(String path, Object body, TypeReference<T> typeRef) {
         try {
             String jsonBody = objectMapper.writeValueAsString(body);

+ 15 - 0
backend/src/main/java/com/agent/management/service/impl/GraphSourceServiceImpl.java

@@ -2,6 +2,7 @@ package com.agent.management.service.impl;
 
 import com.agent.management.common.exception.BusinessException;
 import com.agent.management.model.entity.GraphSource;
+import com.agent.management.rag.graph.GraphSchemaSnapshot;
 import com.agent.management.repository.GraphSourceRepository;
 import com.agent.management.service.CredentialManager;
 import com.agent.management.service.CypherGuardService;
@@ -139,12 +140,26 @@ public class GraphSourceServiceImpl implements GraphSourceService {
         return neo4jExecutor.executeQuery(gs, cypher);
     }
 
+    @Override
+    public void explainQuery(Long id, String cypher) {
+        cypherGuard.validate(cypher);
+        neo4jExecutor.executeQuery(getDecrypted(id), "EXPLAIN " + cypher);
+    }
+
     @Override
     public List<String> listLabels(Long id) {
         GraphSource gs = getDecrypted(id);
         return neo4jExecutor.listLabels(gs);
     }
 
+    @Override
+    public GraphSchemaSnapshot discoverSchema(Long id) {
+        return neo4jExecutor.discoverSchema(getDecrypted(id));
+    }
+
+    @Override
+    public Map<String,Object> discoverMetadata(Long id) { return neo4jExecutor.discoverMetadata(getDecrypted(id)); }
+
     // =========================== 内部 ===========================
 
     private void validateRequired(GraphSource gs) {

+ 13 - 0
backend/src/main/java/com/agent/management/service/impl/SchemaExplorerServiceImpl.java

@@ -101,6 +101,19 @@ public class SchemaExplorerServiceImpl implements SchemaExplorerService {
         }
     }
 
+    @Override
+    public List<ForeignKeySpec> listForeignKeys(Long datasourceId, String schema, String table) {
+        DataSource ds = dataSourceService.getDecrypted(datasourceId);
+        try (Connection conn = jdbcService.getConnection(ds)) {
+            List<ForeignKeySpec> result = new ArrayList<>();
+            try (var rs = conn.getMetaData().getImportedKeys(conn.getCatalog(), resolveSchema(conn, ds, schema), table)) {
+                while (rs.next()) result.add(new ForeignKeySpec(rs.getString("FK_NAME"), rs.getString("FKTABLE_NAME"),
+                        rs.getString("FKCOLUMN_NAME"), rs.getString("PKTABLE_NAME"), rs.getString("PKCOLUMN_NAME")));
+            }
+            return result;
+        } catch (SQLException e) { throw new BusinessException("读取外键失败: " + e.getMessage()); }
+    }
+
     @Override
     public long countRows(Long datasourceId, String schema, String table) {
         if (table == null || table.trim().isEmpty()) {

+ 31 - 26
backend/src/main/java/com/agent/management/service/impl/VectorStoreServiceImpl.java

@@ -6,45 +6,50 @@ import org.springframework.ai.document.Document;
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.stereotype.Service;
 
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 
-/**
- * VectorStoreService 实现:当前版本已切换到本地 Embedding Bridge 写入 Milvus。
- *
- * <p>本类保留仅用于:
- * 1. {@link #isAvailable()} 检查 Milvus 连接是否可达;
- * 2. 兼容旧的检索入口({@link #similaritySearch}),但本次实现暂返回空列表,
- *    待后续接入 Bridge 的 Hybrid Search 后再提供完整检索能力。</p>
- */
 @Slf4j
 @Service
 public class VectorStoreServiceImpl implements VectorStoreService {
-
     private final ObjectProvider<org.springframework.ai.vectorstore.VectorStore> vectorStoreProvider;
+    private final EmbeddingBridgeClient embeddingBridgeClient;
 
-    public VectorStoreServiceImpl(ObjectProvider<org.springframework.ai.vectorstore.VectorStore> vectorStoreProvider) {
+    public VectorStoreServiceImpl(ObjectProvider<org.springframework.ai.vectorstore.VectorStore> vectorStoreProvider,
+                                  EmbeddingBridgeClient embeddingBridgeClient) {
         this.vectorStoreProvider = vectorStoreProvider;
+        this.embeddingBridgeClient = embeddingBridgeClient;
     }
 
-    @Override
-    public List<String> add(List<Document> documents) {
-        log.warn("VectorStoreService.add 已弃用,写入请通过 EmbeddingBridgeClient");
-        return List.of();
-    }
-
-    @Override
-    public void deleteByIds(List<String> ids) {
-        log.warn("VectorStoreService.deleteByIds 已弃用,删除请通过 EmbeddingBridgeClient");
-    }
+    @Override public List<String> add(List<Document> documents) { log.warn("VectorStoreService.add is deprecated"); return List.of(); }
+    @Override public void deleteByIds(List<String> ids) { log.warn("VectorStoreService.deleteByIds is deprecated"); }
 
     @Override
     public List<Document> similaritySearch(String query, int topK, Double similarityThreshold, String filterExpression) {
-        log.warn("similaritySearch 暂未实现(待接入 Embedding Bridge Hybrid Search),返回空结果");
-        return List.of();
+        String bridgeFilter = filterExpression == null ? "" : filterExpression.replace("categoryId", "category_id");
+        Map<String, Object> response = embeddingBridgeClient.retrieve(query, topK, "hybrid", bridgeFilter);
+        if (!(response.get("hits") instanceof List<?> hits)) return List.of();
+        List<Document> documents = new ArrayList<>();
+        for (Object item : hits) {
+            if (!(item instanceof Map<?, ?> hit)) continue;
+            double score = hit.get("score") instanceof Number n ? n.doubleValue() : 0.0;
+            if (similarityThreshold != null && score < similarityThreshold) continue;
+            Map<String, Object> metadata = new LinkedHashMap<>();
+            hit.forEach((key, value) -> { if (key != null && value != null) metadata.put(String.valueOf(key), value); });
+            metadata.put("distance", score);
+            metadata.put("docName", text(hit.get("filename")));
+            putIfNotNull(metadata, "documentId", hit.get("document_id"));
+            putIfNotNull(metadata, "categoryId", hit.get("category_id"));
+            metadata.put("chunkId", text(hit.get("chunk_id")));
+            putIfNotNull(metadata, "chunkIndex", hit.get("chunk_idx"));
+            documents.add(new Document(text(hit.get("text")), metadata));
+        }
+        return documents;
     }
 
-    @Override
-    public boolean isAvailable() {
-        return vectorStoreProvider.getIfAvailable() != null;
-    }
+    @Override public boolean isAvailable() { return embeddingBridgeClient.health() || vectorStoreProvider.getIfAvailable() != null; }
+    private static void putIfNotNull(Map<String, Object> metadata, String key, Object value) { if (value != null) metadata.put(key, value); }
+    private static String text(Object value) { return value == null ? "" : String.valueOf(value); }
 }

+ 6 - 0
backend/src/main/resources/application.yml.example

@@ -1,6 +1,12 @@
 server:
   port: 2438
 
+rag:
+  ai-bridge:
+    enabled: ${RAG_AI_BRIDGE_ENABLED:false}
+    base-url: ${RAG_AI_BRIDGE_BASE_URL:http://127.0.0.1:18733}
+    timeout: ${RAG_AI_BRIDGE_TIMEOUT:300s}
+
 skill:
   # Skill 文件存放目录,请修改为你的实际路径
   # 该目录下的每个子目录被视为一个 Skill,子目录内应包含 SKILL.md

+ 35 - 0
backend/src/test/java/com/agent/management/rag/capability/GraphBusinessSubgraphSelectorTest.java

@@ -0,0 +1,35 @@
+package com.agent.management.rag.capability;
+
+import com.agent.management.rag.graph.GraphSchemaSnapshot;
+import com.agent.management.rag.model.RagSourceType;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+
+class GraphBusinessSubgraphSelectorTest {
+    @Test
+    void expandsSemanticSeedsThroughRelationshipEndpoints() {
+        RagSemanticCatalogService semantic = mock(RagSemanticCatalogService.class);
+        RagCapabilityProfile profile = new RagCapabilityProfile(RagSourceType.GRAPH, "1", "v", Instant.now(), null, schema(), List.of());
+        when(semantic.rank("任务风险", profile, "label:", 8)).thenReturn(List.of("Mission"));
+        when(semantic.rank("任务风险", profile, "relationship:", 8)).thenReturn(List.of("THREATENS"));
+
+        GraphSchemaSnapshot selected = new GraphBusinessSubgraphSelector(semantic).select("任务风险", profile, schema(), 8, 8);
+
+        assertThat(selected.labelNames()).contains("Mission", "RiskEvent").doesNotContain("Report");
+        assertThat(selected.relationshipTypeNames()).containsExactly("THREATENS");
+    }
+
+    private static GraphSchemaSnapshot schema() {
+        return new GraphSchemaSnapshot(List.of(
+                new GraphSchemaSnapshot.NodeSchema("Mission", Map.of("name", List.of("String"))),
+                new GraphSchemaSnapshot.NodeSchema("RiskEvent", Map.of("name", List.of("String"))),
+                new GraphSchemaSnapshot.NodeSchema("Report", Map.of("title", List.of("String")))),
+                List.of(new GraphSchemaSnapshot.RelationshipSchema("THREATENS", List.of("RiskEvent"), List.of("Mission"), Map.of())));
+    }
+}

+ 3 - 0
backend/src/test/java/com/agent/management/rag/capability/RagEntityMentionExtractorTest.java

@@ -0,0 +1,3 @@
+package com.agent.management.rag.capability;
+import org.junit.jupiter.api.Test;import static org.assertj.core.api.Assertions.assertThat;
+class RagEntityMentionExtractorTest {@Test void extractsQuotedAndDomainEntities(){assertThat(new RagEntityMentionExtractor().extract("查询“爱达号”以及蛟龙队")) .contains("爱达号","蛟龙队");}}

+ 54 - 0
backend/src/test/java/com/agent/management/rag/capability/RagSchemaLinkerTest.java

@@ -0,0 +1,54 @@
+package com.agent.management.rag.capability;
+
+import com.agent.management.rag.graph.GraphSchemaSnapshot;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class RagSchemaLinkerTest {
+    private final RagSchemaLinker linker = new RagSchemaLinker();
+
+    @Test
+    void selectsRelevantSqlTableFromQuestionAndSemanticHints() {
+        SqlSchemaSnapshot schema = new SqlSchemaSnapshot(List.of(
+                new SqlSchemaSnapshot.TableSchema("rescue_forces", "救援力量和可调度队伍", List.of(
+                        new SqlSchemaSnapshot.ColumnSchema("status", "VARCHAR", "调度状态 READY 表示可用"),
+                        new SqlSchemaSnapshot.ColumnSchema("capacity", "INT", "载员能力")
+                )),
+                new SqlSchemaSnapshot.TableSchema("audit_log", "系统审计记录", List.of(
+                        new SqlSchemaSnapshot.ColumnSchema("created_at", "TIMESTAMP", "创建时间")
+                ))
+        ));
+
+        assertThat(linker.selectSqlTables("有哪些可用救援力量并按载员能力排序", schema, List.of(), Map.of()))
+                .containsExactly("rescue_forces");
+    }
+
+    @Test
+    void similarGraphExampleSelectsItsSchemaSubgraph() {
+        GraphSchemaSnapshot graph = new GraphSchemaSnapshot(
+                List.of(
+                        new GraphSchemaSnapshot.NodeSchema("Mission", Map.of("name", List.of("String"))),
+                        new GraphSchemaSnapshot.NodeSchema("Stage", Map.of("name", List.of("String"))),
+                        new GraphSchemaSnapshot.NodeSchema("Secret", Map.of("value", List.of("String")))
+                ),
+                List.of(
+                        new GraphSchemaSnapshot.RelationshipSchema("HAS_STAGE", List.of("Mission"), List.of("Stage"), Map.of()),
+                        new GraphSchemaSnapshot.RelationshipSchema("SECRET_LINK", List.of("Mission"), List.of("Secret"), Map.of())
+                )
+        );
+        List<Map<String, Object>> examples = List.of(Map.of(
+                "question", "任务包括哪些阶段",
+                "cypher", "MATCH (:Mission)-[:HAS_STAGE]->(:Stage) RETURN 1"
+        ));
+
+        GraphSchemaSnapshot selected = linker.selectGraphSchema("东海任务阶段是什么", graph, examples);
+
+        assertThat(selected.labelNames()).containsExactlyInAnyOrder("Mission", "Stage");
+        assertThat(selected.relationshipTypeNames()).containsExactly("HAS_STAGE");
+    }
+}

+ 3 - 0
backend/src/test/java/com/agent/management/rag/graph/ExplicitCypherGenerationServiceTest.java

@@ -0,0 +1,3 @@
+package com.agent.management.rag.graph;
+import com.agent.management.rag.model.RagQuery; import org.junit.jupiter.api.Test; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat;
+class ExplicitCypherGenerationServiceTest {@Test void readsExplicitCypher(){RagQuery q=new RagQuery();q.setFilters(Map.of("cypher","MATCH (n) RETURN n"));assertThat(new ExplicitCypherGenerationService().generateCypher(q,1L)).contains("MATCH (n) RETURN n");}}

+ 97 - 0
backend/src/test/java/com/agent/management/rag/graph/GraphRagRetrieverTest.java

@@ -0,0 +1,97 @@
+package com.agent.management.rag.graph;
+
+import com.agent.management.model.entity.GraphSource;
+import com.agent.management.rag.model.RagQuery;
+import com.agent.management.rag.memory.RagExampleMemoryService;
+import com.agent.management.service.GraphSourceService;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+
+class GraphRagRetrieverTest {
+    @Test
+    void generatedCypherReturnsToJavaExecutor() {
+        ExplicitCypherGenerationService explicit = mock(ExplicitCypherGenerationService.class);
+        Neo4jGraphRagCypherGenerationService generated = mock(Neo4jGraphRagCypherGenerationService.class);
+        GraphSourceService graphs = mock(GraphSourceService.class);
+        RagQuery query = new RagQuery(); query.setQuery("q"); query.setSourceIds(List.of("1")); query.setFilters(Map.of("allowTextToCypher", true));
+        when(explicit.generateCypher(query, 1L)).thenReturn(Optional.empty());
+        when(generated.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(), "durationMs", 1));
+        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(graphs).executeQuery(1L, "MATCH (n) RETURN n");
+        assertThat(result.getEvidences()).singleElement().satisfies(e -> {
+            assertThat(e.getMetadata()).containsEntry("generatedCypher", "MATCH (n) RETURN n");
+            assertThat(e.getPayload()).containsKeys("nodes", "edges", "paths");
+        });
+    }
+
+    @Test
+    void explicitCypherStillWinsAndIsMarkedExplicit() {
+        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.of("MATCH (n) RETURN n"));
+        when(graphs.executeQuery(1L, "MATCH (n) 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);
+        assertThat(result.getEvidences()).singleElement().satisfies(e -> assertThat(e.getMetadata())
+                .containsEntry("explicitCypher", "MATCH (n) RETURN n")
+                .doesNotContainKey("generatedCypher"));
+    }
+
+    @Test
+    void generationFailureIsVisibleInDiagnostics() {
+        ExplicitCypherGenerationService explicit = mock(ExplicitCypherGenerationService.class);
+        Neo4jGraphRagCypherGenerationService generated = mock(Neo4jGraphRagCypherGenerationService.class);
+        RagQuery query = query();
+        when(explicit.generateCypher(query, 1L)).thenReturn(Optional.empty());
+        when(generated.generateCypher(query, 1L)).thenThrow(new IllegalStateException("schema unavailable"));
+
+        var result = new GraphRagRetriever(explicit, generated, mock(GraphSourceService.class), mock(RagExampleMemoryService.class)).retrieve(query);
+
+        assertThat(result.getEvidences()).isEmpty();
+        assertThat(result.getDiagnostics()).containsEntry("error", "Cypher generation failed: schema unavailable");
+    }
+
+    @Test
+    void failedExplainIsRepairedExactlyOnceBeforeExecution() {
+        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 (x:Bad) RETURN x"));
+        doThrow(new IllegalArgumentException("unknown label")).when(graphs).explainQuery(1L, "MATCH (x:Bad) RETURN x");
+        when(generated.repair(query, 1L, "MATCH (x:Bad) RETURN x", "unknown label")).thenReturn(Optional.of("MATCH (x) RETURN x"));
+        when(graphs.executeQuery(1L, "MATCH (x) RETURN x")).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);
+
+        verify(generated, times(1)).repair(query, 1L, "MATCH (x:Bad) RETURN x", "unknown label");
+        verify(graphs).explainQuery(1L, "MATCH (x) RETURN x");
+        verify(graphs).executeQuery(1L, "MATCH (x) RETURN x");
+        assertThat(result.getDiagnostics()).containsKey("repair");
+    }
+
+    private static RagQuery query() {
+        RagQuery query = new RagQuery();
+        query.setQuery("q");
+        query.setSourceIds(List.of("1"));
+        query.setFilters(Map.of("allowTextToCypher", true));
+        return query;
+    }
+}

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

@@ -0,0 +1,39 @@
+package com.agent.management.rag.graph;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class GraphSchemaSnapshotTest {
+    @Test
+    void whitelistIntersectsRealSchemaAndKeepsDirectionAndProperties() {
+        GraphSchemaSnapshot schema = new GraphSchemaSnapshot(
+                List.of(
+                        new GraphSchemaSnapshot.NodeSchema("Mission", Map.of("name", List.of("STRING"))),
+                        new GraphSchemaSnapshot.NodeSchema("Stage", Map.of("name", List.of("STRING"), "sequence", List.of("INTEGER"), "goal", List.of("STRING"))),
+                        new GraphSchemaSnapshot.NodeSchema("Secret", Map.of("value", List.of("STRING")))
+                ),
+                List.of(
+                        new GraphSchemaSnapshot.RelationshipSchema("HAS_STAGE", List.of("Mission"), List.of("Stage"), Map.of()),
+                        new GraphSchemaSnapshot.RelationshipSchema("SECRET_LINK", List.of("Mission"), List.of("Secret"), Map.of())
+                )
+        );
+
+        GraphSchemaSnapshot filtered = schema.filter(
+                List.of("Mission", "Stage"),
+                List.of("HAS_STAGE"),
+                Map.of("Mission", List.of("name"), "Stage", List.of("name", "sequence", "goal"))
+        );
+
+        assertThat(filtered.labelNames()).containsExactly("Mission", "Stage");
+        assertThat(filtered.relationshipTypeNames()).containsExactly("HAS_STAGE");
+        assertThat(filtered.node("Stage").orElseThrow().properties()).containsOnlyKeys("name", "sequence", "goal");
+        assertThat(filtered.relationships()).singleElement().satisfies(rel -> {
+            assertThat(rel.startLabels()).containsExactly("Mission");
+            assertThat(rel.endLabels()).containsExactly("Stage");
+        });
+    }
+}

+ 37 - 0
backend/src/test/java/com/agent/management/rag/graph/GraphSchemaValidatorTest.java

@@ -0,0 +1,37 @@
+package com.agent.management.rag.graph;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class GraphSchemaValidatorTest {
+    private final GraphSchemaSnapshot schema = new GraphSchemaSnapshot(
+            List.of(
+                    new GraphSchemaSnapshot.NodeSchema("Mission", Map.of("name", List.of("STRING"))),
+                    new GraphSchemaSnapshot.NodeSchema("Stage", Map.of("name", List.of("STRING"), "sequence", List.of("INTEGER"), "goal", List.of("STRING")))
+            ),
+            List.of(new GraphSchemaSnapshot.RelationshipSchema("HAS_STAGE", List.of("Mission"), List.of("Stage"), Map.of()))
+    );
+    private final GraphSchemaValidator validator = new GraphSchemaValidator();
+
+    @Test
+    void acceptsRealAuthorizedSchema() {
+        validator.validate("MATCH (m:Mission)-[:HAS_STAGE]->(s:Stage) RETURN s.name, s.sequence, s.goal", schema, 3);
+    }
+
+    @Test
+    void rejectsUnknownLabelRelationshipAndProperty() {
+        assertThatThrownBy(() -> validator.validate("MATCH (n:Secret) RETURN n", schema, 3)).hasMessageContaining("label");
+        assertThatThrownBy(() -> validator.validate("MATCH (:Mission)-[:UNKNOWN]->(:Stage) RETURN 1", schema, 3)).hasMessageContaining("relationship");
+        assertThatThrownBy(() -> validator.validate("MATCH (s:Stage) RETURN s.secret", schema, 3)).hasMessageContaining("property");
+    }
+
+    @Test
+    void rejectsUnboundedAndTooDeepPaths() {
+        assertThatThrownBy(() -> validator.validate("MATCH (:Mission)-[:HAS_STAGE*]->(:Stage) RETURN 1", schema, 3)).hasMessageContaining("unbounded");
+        assertThatThrownBy(() -> validator.validate("MATCH (:Mission)-[:HAS_STAGE*1..4]->(:Stage) RETURN 1", schema, 3)).hasMessageContaining("maxDepth");
+    }
+}

+ 107 - 0
backend/src/test/java/com/agent/management/rag/kb/KnowledgeBaseRagRetrieverTest.java

@@ -0,0 +1,107 @@
+package com.agent.management.rag.kb;
+
+import com.agent.management.model.entity.RagKnowledgeBase;
+import com.agent.management.model.entity.RagKnowledgeSourceBinding;
+import com.agent.management.rag.api.RagRetriever;
+import com.agent.management.rag.model.*;
+import com.agent.management.repository.RagKnowledgeBaseRepository;
+import com.agent.management.repository.RagKnowledgeSourceBindingRepository;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class KnowledgeBaseRagRetrieverTest {
+    @Test
+    void bindingResolvesSourceAndScopedFiltersOverrideConfigWithoutDefaults() {
+        RagKnowledgeBaseRepository bases = mock(RagKnowledgeBaseRepository.class);
+        RagKnowledgeSourceBindingRepository bindings = mock(RagKnowledgeSourceBindingRepository.class);
+        RagRetriever structured = mock(RagRetriever.class);
+        when(structured.sourceType()).thenReturn(RagSourceType.STRUCTURED_DATA);
+        RagRetrievalResult rr = new RagRetrievalResult();
+        rr.setSourceType(RagSourceType.STRUCTURED_DATA);
+        when(structured.retrieve(any())).thenReturn(rr);
+
+        RagKnowledgeBase kb = new RagKnowledgeBase();
+        kb.setId(1L);
+        kb.setEnabled(true);
+        RagKnowledgeSourceBinding binding = new RagKnowledgeSourceBinding();
+        binding.setSourceType(RagSourceType.STRUCTURED_DATA);
+        binding.setSourceId("9");
+        binding.setEnabled(true);
+        binding.setConfigJson("{\"allowTextToSql\":true,\"maxRows\":50,\"tableWhitelist\":[\"rescue_forces\"]}");
+        when(bases.findById(1L)).thenReturn(Optional.of(kb));
+        when(bindings.findByKnowledgeBaseIdAndEnabledTrueOrderByPriorityAsc(1L)).thenReturn(List.of(binding));
+
+        KnowledgeBaseRagRequest request = new KnowledgeBaseRagRequest();
+        request.setKnowledgeBaseId(1L);
+        request.setQuery("q");
+        request.setFilters(Map.of("structured", Map.of("maxRows", 10)));
+        RagQuestionPlanningService planner=mock(RagQuestionPlanningService.class);when(planner.plan(anyString(),anyList())).thenReturn(Map.of());
+        new KnowledgeBaseRagRetriever(bases, bindings, List.of(structured), new ObjectMapper(), Runnable::run,planner).retrieve(request);
+
+        ArgumentCaptor<RagQuery> captor = ArgumentCaptor.forClass(RagQuery.class);
+        verify(structured).retrieve(captor.capture());
+        assertThat(captor.getValue().getSourceIds()).containsExactly("9");
+        assertThat(captor.getValue().getFilters())
+                .containsEntry("allowTextToSql", true)
+                .containsEntry("maxRows", 10)
+                .doesNotContainKeys("defaultSql", "sql");
+    }
+
+    @Test
+    void retrievesMultipleBindingsConcurrently() throws Exception {
+        RagKnowledgeBaseRepository bases = mock(RagKnowledgeBaseRepository.class);
+        RagKnowledgeSourceBindingRepository bindings = mock(RagKnowledgeSourceBindingRepository.class);
+        RagKnowledgeBase kb = new RagKnowledgeBase(); kb.setId(1L); kb.setEnabled(true);
+        when(bases.findById(1L)).thenReturn(Optional.of(kb));
+        RagKnowledgeSourceBinding structuredBinding = binding(RagSourceType.STRUCTURED_DATA, "1", 10);
+        RagKnowledgeSourceBinding graphBinding = binding(RagSourceType.GRAPH, "2", 20);
+        when(bindings.findByKnowledgeBaseIdAndEnabledTrueOrderByPriorityAsc(1L))
+                .thenReturn(List.of(structuredBinding, graphBinding));
+
+        CountDownLatch bothStarted = new CountDownLatch(2);
+        CountDownLatch release = new CountDownLatch(1);
+        RagRetriever structured = blockingRetriever(RagSourceType.STRUCTURED_DATA, bothStarted, release);
+        RagRetriever graph = blockingRetriever(RagSourceType.GRAPH, bothStarted, release);
+        KnowledgeBaseRagRequest request = new KnowledgeBaseRagRequest();
+        request.setKnowledgeBaseId(1L); request.setQuery("q");
+        RagQuestionPlanningService concurrentPlanner=mock(RagQuestionPlanningService.class);when(concurrentPlanner.plan(anyString(),anyList())).thenReturn(Map.of());
+        KnowledgeBaseRagRetriever subject = new KnowledgeBaseRagRetriever(
+                bases, bindings, List.of(structured, graph), new ObjectMapper(),
+                command -> new Thread(command, "test-rag-retrieval").start(), concurrentPlanner);
+
+        Thread invocation = new Thread(() -> subject.retrieve(request));
+        invocation.start();
+        assertThat(bothStarted.await(1, TimeUnit.SECONDS)).isTrue();
+        release.countDown();
+        invocation.join(2000);
+        assertThat(invocation.isAlive()).isFalse();
+    }
+
+    private static RagKnowledgeSourceBinding binding(RagSourceType type, String sourceId, int priority) {
+        RagKnowledgeSourceBinding binding = new RagKnowledgeSourceBinding();
+        binding.setSourceType(type); binding.setSourceId(sourceId); binding.setPriority(priority); binding.setEnabled(true);
+        return binding;
+    }
+
+    private static RagRetriever blockingRetriever(RagSourceType type, CountDownLatch started, CountDownLatch release) {
+        return new RagRetriever() {
+            @Override public RagSourceType sourceType() { return type; }
+            @Override public RagRetrievalResult retrieve(RagQuery query) {
+                started.countDown();
+                try { release.await(2, TimeUnit.SECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
+                RagRetrievalResult result = new RagRetrievalResult(); result.setSourceType(type); return result;
+            }
+        };
+    }
+}

+ 49 - 0
backend/src/test/java/com/agent/management/rag/kb/RagKnowledgeBaseConfigServiceTest.java

@@ -0,0 +1,49 @@
+package com.agent.management.rag.kb;
+
+import com.agent.management.model.entity.RagKnowledgeSourceBinding;
+import com.agent.management.rag.model.RagSourceType;
+import com.agent.management.repository.RagKnowledgeSourceBindingRepository;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.*;
+
+class RagKnowledgeBaseConfigServiceTest {
+    @Test
+    void automaticGraphGenerationRejectsEmptyAuthorization() {
+        RagKnowledgeSourceBindingRepository repository = mock(RagKnowledgeSourceBindingRepository.class);
+        RagKnowledgeSourceBinding binding = binding(RagSourceType.GRAPH);
+        when(repository.findById(2L)).thenReturn(Optional.of(binding));
+        RagKnowledgeBaseConfigService service = new RagKnowledgeBaseConfigService(repository, new ObjectMapper());
+
+        assertThatThrownBy(() -> service.updateConfig(1L, 2L,
+                Map.of("retrievalMode", "AUTO_GENERATE", "allowedLabels", List.of())))
+                .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("allowedLabels");
+        verify(repository, never()).save(any());
+    }
+
+    @Test
+    void explicitAuthorizationCanBeSaved() {
+        RagKnowledgeSourceBindingRepository repository = mock(RagKnowledgeSourceBindingRepository.class);
+        RagKnowledgeSourceBinding binding = binding(RagSourceType.STRUCTURED_DATA);
+        when(repository.findById(2L)).thenReturn(Optional.of(binding));
+        when(repository.save(binding)).thenReturn(binding);
+        RagKnowledgeBaseConfigService service = new RagKnowledgeBaseConfigService(repository, new ObjectMapper());
+
+        service.updateConfig(1L, 2L, Map.of("retrievalMode", "AUTO_GENERATE", "allowedTables", List.of("rescue_forces")));
+
+        verify(repository).save(binding);
+    }
+
+    private static RagKnowledgeSourceBinding binding(RagSourceType type) {
+        RagKnowledgeSourceBinding binding = new RagKnowledgeSourceBinding();
+        binding.setKnowledgeBaseId(1L);
+        binding.setSourceType(type);
+        return binding;
+    }
+}

+ 34 - 0
backend/src/test/java/com/agent/management/rag/memory/RagExampleCandidateServiceTest.java

@@ -0,0 +1,34 @@
+package com.agent.management.rag.memory;
+
+import com.agent.management.rag.capability.RagCapabilityProfileService;
+import com.agent.management.rag.graph.GraphSchemaValidator;
+import com.agent.management.rag.model.RagSourceType;
+import com.agent.management.service.GraphSourceService;
+import com.agent.management.service.SchemaExplorerService;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+
+class RagExampleCandidateServiceTest {
+    @Test
+    void importsOnlyCandidatesThatPassDatabaseExplain() {
+        RagExampleMemoryService memory = mock(RagExampleMemoryService.class);
+        SchemaExplorerService sql = mock(SchemaExplorerService.class);
+        doThrow(new IllegalArgumentException("unknown column")).when(sql).explainQuery(1L, "SELECT bad FROM missing");
+        when(memory.list(RagSourceType.STRUCTURED_DATA, "1")).thenReturn(List.of());
+        RagExampleCandidateService service = new RagExampleCandidateService(memory, mock(RagCapabilityProfileService.class),
+                mock(GraphSchemaValidator.class), mock(GraphSourceService.class), sql);
+
+        Map<String,Object> result = service.importValidated(RagSourceType.STRUCTURED_DATA, "1", List.of(
+                Map.of("question", "正常", "sql", "SELECT 1"),
+                Map.of("question", "错误", "sql", "SELECT bad FROM missing")));
+
+        assertThat(result).containsEntry("accepted", 1);
+        assertThat((List<?>)result.get("rejected")).hasSize(1);
+        verify(memory, times(1)).addCandidates(eq(RagSourceType.STRUCTURED_DATA), eq("1"), anyList());
+    }
+}

+ 58 - 0
backend/src/test/java/com/agent/management/rag/memory/RagExampleMemoryServiceTest.java

@@ -0,0 +1,58 @@
+package com.agent.management.rag.memory;
+
+import com.agent.management.model.entity.RagQueryExample;
+import com.agent.management.rag.model.RagSourceType;
+import com.agent.management.repository.RagQueryExampleRepository;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+
+class RagExampleMemoryServiceTest {
+    @Test
+    void retrievesVerifiedExamplesByQuestionSimilarity() {
+        RagQueryExampleRepository repository = mock(RagQueryExampleRepository.class);
+        when(repository.findBySourceTypeAndSourceIdAndVerifiedTrue(RagSourceType.STRUCTURED_DATA, "1"))
+                .thenReturn(List.of(
+                        example("查询可用救援力量", "SELECT * FROM rescue_forces WHERE status='READY'"),
+                        example("统计审计日志", "SELECT COUNT(*) FROM audit_log")
+                ));
+        var profiles=mock(com.agent.management.rag.capability.RagCapabilityProfileService.class); when(profiles.get(any(),anyString())).thenReturn(new com.agent.management.rag.capability.RagCapabilityProfile(RagSourceType.STRUCTURED_DATA,"1","v",java.time.Instant.now(),null,null,List.of())); RagExampleMemoryService service = new RagExampleMemoryService(repository, mock(com.agent.management.rag.capability.RagSemanticCatalogService.class),profiles);
+
+        assertThat(service.findSimilar(RagSourceType.STRUCTURED_DATA, "1", "当前有哪些可用救援力量", 1))
+                .singleElement().satisfies(item -> assertThat(item.get("query")).isEqualTo("SELECT * FROM rescue_forces WHERE status='READY'"));
+    }
+
+    @Test
+    void learnedExampleRequiresReviewBeforeReuse() {
+        RagQueryExampleRepository repository = mock(RagQueryExampleRepository.class);
+        when(repository.findFirstBySourceTypeAndSourceIdAndQuestionAndQueryText(any(), anyString(), anyString(), anyString()))
+                .thenReturn(java.util.Optional.empty());
+        when(repository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
+        var profiles=mock(com.agent.management.rag.capability.RagCapabilityProfileService.class); when(profiles.get(any(),anyString())).thenReturn(new com.agent.management.rag.capability.RagCapabilityProfile(RagSourceType.GRAPH,"1","v",java.time.Instant.now(),null,null,List.of())); RagExampleMemoryService service = new RagExampleMemoryService(repository, mock(com.agent.management.rag.capability.RagSemanticCatalogService.class),profiles);
+
+        service.recordSuccess(RagSourceType.GRAPH, "1", "任务阶段", "MATCH (n) RETURN n");
+
+        org.mockito.ArgumentCaptor<RagQueryExample> captor = org.mockito.ArgumentCaptor.forClass(RagQueryExample.class);
+        verify(repository).save(captor.capture());
+        assertThat(captor.getValue().isVerified()).isFalse();
+    }
+
+    @Test
+    void corruptedQuestionIsNotLearned() {
+        RagQueryExampleRepository repository = mock(RagQueryExampleRepository.class);
+        RagExampleMemoryService service = new RagExampleMemoryService(repository, mock(com.agent.management.rag.capability.RagSemanticCatalogService.class),mock(com.agent.management.rag.capability.RagCapabilityProfileService.class));
+
+        service.recordSuccess(RagSourceType.GRAPH, "1", "????????????", "MATCH (n) RETURN n");
+
+        verifyNoInteractions(repository);
+    }
+
+    private static RagQueryExample example(String question, String query) {
+        RagQueryExample example = new RagQueryExample();
+        example.setQuestion(question); example.setQueryText(query); example.setVerified(true);
+        return example;
+    }
+}

+ 3 - 0
backend/src/test/java/com/agent/management/rag/structured/ExplicitSqlGenerationServiceTest.java

@@ -0,0 +1,3 @@
+package com.agent.management.rag.structured;
+import com.agent.management.rag.model.RagQuery; import org.junit.jupiter.api.Test; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat;
+class ExplicitSqlGenerationServiceTest {@Test void readsExplicitSql(){RagQuery q=new RagQuery();q.setFilters(Map.of("sql","SELECT 1"));assertThat(new ExplicitSqlGenerationService().generateSql(q,1L)).contains("SELECT 1");}@Test void returnsEmptyWithoutSql(){assertThat(new ExplicitSqlGenerationService().generateSql(new RagQuery(),1L)).isEmpty();}}

+ 102 - 0
backend/src/test/java/com/agent/management/rag/structured/StructuredDataRagRetrieverTest.java

@@ -0,0 +1,102 @@
+package com.agent.management.rag.structured;
+
+import com.agent.management.model.entity.DataSource;
+import com.agent.management.rag.model.RagQuery;
+import com.agent.management.rag.memory.RagExampleMemoryService;
+import com.agent.management.service.DataSourceService;
+import com.agent.management.service.SchemaExplorerService;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+
+class StructuredDataRagRetrieverTest {
+    @Test
+    void generatedSqlReturnsToJavaExecutor() {
+        ExplicitSqlGenerationService explicit = mock(ExplicitSqlGenerationService.class);
+        VannaSqlGenerationService generated = mock(VannaSqlGenerationService.class);
+        SchemaExplorerService explorer = mock(SchemaExplorerService.class);
+        DataSourceService dataSources = mock(DataSourceService.class);
+        RagQuery query = new RagQuery(); query.setQuery("q"); query.setSourceIds(List.of("1")); query.setTopK(5); query.setFilters(Map.of("allowTextToSql", true));
+        when(explicit.generateSql(query, 1L)).thenReturn(Optional.empty());
+        when(generated.generateSql(query, 1L)).thenReturn(Optional.of("SELECT 1"));
+        when(explorer.executeQuery(1L, "SELECT 1", 5)).thenReturn(new SchemaExplorerService.QueryResult(List.of("value"), List.of(List.of(1)), 2));
+        DataSource source = new DataSource(); source.setName("test"); when(dataSources.get(1L)).thenReturn(source);
+
+        var result = new StructuredDataRagRetriever(explicit, generated, explorer, dataSources, mock(RagExampleMemoryService.class)).retrieve(query);
+
+        verify(explorer).executeQuery(1L, "SELECT 1", 5);
+        assertThat(result.getEvidences()).singleElement().satisfies(e -> {
+            assertThat(e.getMetadata()).containsEntry("generatedSql", "SELECT 1");
+            assertThat(e.getPayload()).containsKeys("columns", "rows", "rowCount");
+        });
+    }
+
+    @Test
+    void explicitSqlStillWinsAndIsMarkedExplicit() {
+        ExplicitSqlGenerationService explicit = mock(ExplicitSqlGenerationService.class);
+        VannaSqlGenerationService generated = mock(VannaSqlGenerationService.class);
+        SchemaExplorerService explorer = mock(SchemaExplorerService.class);
+        DataSourceService dataSources = mock(DataSourceService.class);
+        RagQuery query = query();
+        when(explicit.generateSql(query, 1L)).thenReturn(Optional.of("SELECT 2"));
+        when(explorer.executeQuery(1L, "SELECT 2", 5)).thenReturn(new SchemaExplorerService.QueryResult(List.of("value"), List.of(List.of(2)), 1));
+        DataSource source = new DataSource(); source.setName("test"); when(dataSources.get(1L)).thenReturn(source);
+
+        var result = new StructuredDataRagRetriever(explicit, generated, explorer, dataSources, mock(RagExampleMemoryService.class)).retrieve(query);
+
+        verifyNoInteractions(generated);
+        assertThat(result.getEvidences()).singleElement().satisfies(e -> assertThat(e.getMetadata())
+                .containsEntry("explicitSql", "SELECT 2")
+                .doesNotContainKey("generatedSql"));
+    }
+
+    @Test
+    void generationFailureIsVisibleInDiagnostics() {
+        ExplicitSqlGenerationService explicit = mock(ExplicitSqlGenerationService.class);
+        VannaSqlGenerationService generated = mock(VannaSqlGenerationService.class);
+        RagQuery query = query();
+        when(explicit.generateSql(query, 1L)).thenReturn(Optional.empty());
+        when(generated.generateSql(query, 1L)).thenThrow(new IllegalStateException("bridge unavailable"));
+
+        var result = new StructuredDataRagRetriever(explicit, generated, mock(SchemaExplorerService.class), mock(DataSourceService.class), mock(RagExampleMemoryService.class)).retrieve(query);
+
+        assertThat(result.getEvidences()).isEmpty();
+        assertThat(result.getDiagnostics()).containsEntry("error", "SQL generation failed: bridge unavailable");
+    }
+
+    @Test
+    void failedExplainIsRepairedExactlyOnceBeforeExecution() {
+        ExplicitSqlGenerationService explicit = mock(ExplicitSqlGenerationService.class);
+        VannaSqlGenerationService generated = mock(VannaSqlGenerationService.class);
+        SchemaExplorerService explorer = mock(SchemaExplorerService.class);
+        DataSourceService dataSources = mock(DataSourceService.class);
+        RagQuery query = query();
+        when(explicit.generateSql(query, 1L)).thenReturn(Optional.empty());
+        when(generated.generateSql(query, 1L)).thenReturn(Optional.of("SELECT bad"));
+        doThrow(new IllegalArgumentException("unknown column")).when(explorer).explainQuery(1L, "SELECT bad");
+        when(generated.repair(query, 1L, "SELECT bad", "unknown column")).thenReturn(Optional.of("SELECT 1"));
+        when(explorer.executeQuery(1L, "SELECT 1", 5)).thenReturn(new SchemaExplorerService.QueryResult(List.of("value"), List.of(List.of(1)), 1));
+        DataSource source = new DataSource(); source.setName("test"); when(dataSources.get(1L)).thenReturn(source);
+
+        var result = new StructuredDataRagRetriever(explicit, generated, explorer, dataSources, mock(RagExampleMemoryService.class)).retrieve(query);
+
+        verify(generated, times(1)).repair(query, 1L, "SELECT bad", "unknown column");
+        verify(explorer).explainQuery(1L, "SELECT 1");
+        verify(explorer).executeQuery(1L, "SELECT 1", 5);
+        assertThat(result.getDiagnostics()).containsKey("repair");
+    }
+
+    private static RagQuery query() {
+        RagQuery query = new RagQuery();
+        query.setQuery("q");
+        query.setSourceIds(List.of("1"));
+        query.setTopK(5);
+        query.setFilters(Map.of("allowTextToSql", true));
+        return query;
+    }
+}

+ 64 - 0
backend/src/test/java/com/agent/management/rag/structured/VannaSqlGenerationServiceTest.java

@@ -0,0 +1,64 @@
+package com.agent.management.rag.structured;
+
+import com.agent.management.model.entity.DataSource;
+import com.agent.management.rag.bridge.RagAiBridgeClient;
+import com.agent.management.rag.capability.*;
+import com.agent.management.rag.memory.RagExampleMemoryService;
+import com.agent.management.rag.model.RagQuery;
+import com.agent.management.rag.model.RagSourceType;
+import com.agent.management.service.DataSourceService;
+import com.agent.management.service.SchemaExplorerService;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+
+class VannaSqlGenerationServiceTest {
+    @Test
+    void sendsOnlyWhitelistedTableDdlAndConfiguredContext() {
+        RagAiBridgeClient bridge = mock(RagAiBridgeClient.class);
+        DataSourceService dataSources = mock(DataSourceService.class);
+        RagCapabilityProfileService profiles = mock(RagCapabilityProfileService.class);
+        RagSchemaLinker linker = mock(RagSchemaLinker.class);
+        RagExampleMemoryService examples = mock(RagExampleMemoryService.class);
+        DataSource source = new DataSource();
+        source.setType("MYSQL");
+        when(dataSources.getDecrypted(1L)).thenReturn(source);
+        SqlSchemaSnapshot snapshot = new SqlSchemaSnapshot(List.of(
+                new SqlSchemaSnapshot.TableSchema("rescue_forces", "", List.of(
+                        new SqlSchemaSnapshot.ColumnSchema("name", "VARCHAR", "力量名称"),
+                        new SqlSchemaSnapshot.ColumnSchema("status", "VARCHAR", "状态"))),
+                new SqlSchemaSnapshot.TableSchema("secret_table", "", List.of(
+                        new SqlSchemaSnapshot.ColumnSchema("secret", "VARCHAR", "")))
+        ));
+        when(profiles.get(RagSourceType.STRUCTURED_DATA, "1"))
+                .thenReturn(new RagCapabilityProfile(RagSourceType.STRUCTURED_DATA,"1","v",java.time.Instant.now(),snapshot,null,List.of()));
+        when(linker.selectSqlTables(eq("可用力量"), eq(snapshot), anyList(), anyMap())).thenReturn(List.of("rescue_forces"));
+        when(examples.findSimilar(any(), anyString(), anyString(), anyInt())).thenReturn(List.of());
+        when(bridge.textToSql(any())).thenReturn(Map.of("sql", "SELECT name FROM rescue_forces"));
+        RagQuery query = new RagQuery();
+        query.setQuery("可用力量");
+        query.setFilters(Map.of(
+                "allowTextToSql", true,
+                "tableWhitelist", List.of("rescue_forces"),
+                "documentation", "READY 表示可用",
+                "examples", List.of(Map.of("question", "q", "sql", "SELECT 1")),
+                "maxRows", 20
+        ));
+
+        assertThat(new VannaSqlGenerationService(bridge, dataSources, profiles, linker, mock(RagSemanticCatalogService.class), examples, new RagEntityMentionExtractor()).generateSql(query, 1L))
+                .contains("SELECT name FROM rescue_forces");
+
+        ArgumentCaptor<Map<String, Object>> request = ArgumentCaptor.forClass(Map.class);
+        verify(bridge).textToSql(request.capture());
+        assertThat(String.valueOf(request.getValue().get("ddl")))
+                .contains("rescue_forces", "name VARCHAR", "status VARCHAR")
+                .doesNotContain("secret_table");
+        assertThat(request.getValue()).containsEntry("documentation", "READY 表示可用")
+                .containsEntry("maxRows", 20);
+    }
+}

+ 32 - 0
backend/src/test/java/com/agent/management/service/impl/VectorStoreServiceImplTest.java

@@ -0,0 +1,32 @@
+package com.agent.management.service.impl;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.vectorstore.VectorStore;
+import org.springframework.beans.factory.ObjectProvider;
+
+import java.util.List;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+
+class VectorStoreServiceImplTest {
+    @Test
+    void toleratesMissingOptionalMilvusMetadata() {
+        EmbeddingBridgeClient bridge = mock(EmbeddingBridgeClient.class);
+        @SuppressWarnings("unchecked") ObjectProvider<VectorStore> provider = mock(ObjectProvider.class);
+        Map<String, Object> hit = new LinkedHashMap<>();
+        hit.put("text", "chunk");
+        hit.put("score", 0.8);
+        hit.put("document_id", null);
+        hit.put("category_id", null);
+        when(bridge.retrieve("q", 5, "hybrid", "")).thenReturn(Map.of("hits", List.of(hit)));
+
+        var result = new VectorStoreServiceImpl(provider, bridge).similaritySearch("q", 5, 0.0, null);
+
+        assertThat(result).hasSize(1);
+        assertThat(result.get(0).getText()).isEqualTo("chunk");
+        assertThat(result.get(0).getMetadata()).doesNotContainKeys("documentId", "categoryId");
+    }
+}

+ 236 - 0
docs/rag/RAG_INTEGRATION_HANDOFF.md

@@ -0,0 +1,236 @@
+# RAG 泛化改造整合与运行说明
+
+## 1. 交付范围
+
+本源码包来自 `agent-management-rag` 工作分支,基线为本地 `master` 分支。交付包不包含 Git 历史、密钥、本地数据库、上传文件、日志、Python 虚拟环境、Node 依赖和 Java 构建产物。
+
+主要新增页面:
+
+- `/kb/rag`:多源 RAG 问答运行台。
+- `/kb/rag-governance`:Schema 授权、生成规则和动态 Few-shot 治理台。
+
+## 2. 相比 master 的主要改造
+
+### 2.1 多源 RAG
+
+- 新增统一的文档、结构化数据库和 Neo4j 图谱证据模型。
+- 新增知识库及数据源绑定配置,支持按数据源独立启停和配置。
+- 结构化数据与图谱检索并行执行,最终与 Milvus 文档证据融合。
+- 新增 RAG 回答生成接口,回答只能依据检索证据。
+
+### 2.2 Text-to-SQL 与 Text-to-Cypher
+
+- 根据真实数据库 Schema 动态生成 SQL/Cypher,不再仅依赖固定模板。
+- SQL 支持表白名单、表提示、业务文档、最大行数和动态 Few-shot。
+- Cypher 支持 Label、Relationship、Property 白名单、最大深度和业务规则。
+- 生成结果在 Java 侧执行只读安全校验。
+- 自动生成的查询执行前先进行 `EXPLAIN`;失败时结合 Schema 和脱敏错误自动修复一次,再次失败则终止。
+
+### 2.3 Schema 画像与语义目录
+
+- 自动发现 MySQL/H2 等结构化数据库的 Table、Column 和类型。
+- 自动发现 Neo4j 的 Label、Relationship、Property 及连接方向。
+- 为表、Label 和 Relationship 建立向量语义目录。
+- 大型 Schema 按问题语义裁剪;小型已授权 Schema 完整保留。
+- Schema 画像带版本和缓存,可手动刷新。
+
+### 2.4 RAG 治理
+
+- 根据实际 Schema 和业务描述生成“建议开放的业务子图”。
+- 治理结果统一为 `suggestedConfig` 和 `examples`:
+  - `suggestedConfig` 回填授权表单,人工点击“应用”后才生效。
+  - `examples` 导入后默认未审核,审核通过后才参与生成。
+- 支持可视化审核 Table、Label、Relationship、Property 和生成规则。
+- 授权区按 Label、Relationship、Table 树状折叠;加载时回显已保存配置,避免默认全选误扩大白名单。
+- 支持动态 Few-shot 启用、停用和删除。
+- 自动学习的成功查询默认未审核;损坏编码、明显乱码和写操作案例不会进入案例库。
+- 仅把实际返回证据的自动查询纳入学习,0 行结构化结果不会生成 Few-shot。
+- 模型建议在展示和应用前会再次与真实 Schema 取交集,空授权不能启用自动生成。
+- LLM 治理建议超时时,按向量语义目录生成确定性草稿,避免页面长时间无响应。
+
+### 2.5 问题规划与稳定性
+
+- 根据已启用数据源自动拆解结构化数据和图谱子问题。
+- SQL 与图谱任务使用独立线程池并行执行。
+- RAG AI Bridge 设置短超时和无重试策略,失败时返回可见诊断。
+- 修复 Milvus 稀疏向量兼容、文档索引与本地 Embedding Bridge 交互问题。
+
+### 2.6 前端
+
+- 新增多源 RAG 工作台、证据面板、过程状态和融合结果。
+- 新增 RAG 治理中心和导航入口。
+- 新增 Cytoscape 图谱结果展示及 Markdown 安全渲染依赖。
+
+## 3. 环境要求
+
+- Windows 10/11(当前脚本重点验证环境)
+- Java 17
+- Maven 3.6+
+- Node.js 18+ 与 npm
+- Python 3.10 或 3.11,建议使用独立虚拟环境
+- Docker Desktop
+- Milvus 2.5.5
+- Neo4j 5 Community
+- 一个 OpenAI 兼容的聊天模型接口
+
+建议预留至少 8 GB 内存;首次启动本地 `BAAI/bge-m3` 会下载约 2 GB 模型文件。
+
+## 4. 配置
+
+### 4.1 Java 应用
+
+复制:
+
+```powershell
+Copy-Item backend/src/main/resources/application.yml.example backend/src/main/resources/application.yml
+```
+
+至少检查这些环境变量:
+
+```powershell
+$env:OPENAI_API_KEY="你的模型密钥"
+$env:JASYPT_KEY="生产环境随机密钥"
+$env:EMBEDDING_BRIDGE_PYTHON_PATH="python"
+$env:RAG_AI_BRIDGE_ENABLED="true"
+```
+
+`application.yml` 不应提交或在环境之间直接传播真实密钥。
+
+### 4.2 RAG AI Bridge
+
+复制示例配置:
+
+```powershell
+Copy-Item backend/rag-ai-bridge/.env.example backend/rag-ai-bridge/.env
+Copy-Item backend/rag-ai-bridge/config.example.yaml backend/rag-ai-bridge/config.yaml
+```
+
+填写 `.env` 中的 `OPENAI_API_KEY`、`NEO4J_PASSWORD`,并按模型服务修改 `config.yaml` 的 `model` 和 `base_url`。
+
+安装依赖:
+
+```powershell
+python -m pip install -r backend/rag-ai-bridge/requirements.txt
+python -m pip install -r backend/embedding-bridge/requirements.txt
+```
+
+### 4.3 Milvus
+
+`docker-compose.yml` 默认数据目录是 `F:/Services/milvus-2.5.5/volumes`。其他机器必须先覆盖:
+
+```powershell
+$env:MILVUS_DATA_DIR="D:/Services/milvus/volumes"
+docker compose up -d
+```
+
+端口:`19530`(gRPC)、`9091`(管理/健康接口)。
+
+### 4.4 Neo4j
+
+Neo4j 独立部署,不由当前 `docker-compose.yml` 创建:
+
+```powershell
+docker run -d --name neo4j-community --restart unless-stopped `
+  -p 7474:7474 -p 7687:7687 `
+  -e NEO4J_AUTH=neo4j/请替换为强密码 `
+  -v neo4j_community_data:/data `
+  -v neo4j_community_logs:/logs `
+  -v neo4j_community_import:/var/lib/neo4j/import `
+  -v neo4j_community_plugins:/plugins `
+  neo4j:5-community
+```
+
+页面中保存图谱源时只保存连接配置;真正查询时才建立连接。
+
+## 5. 构建与启动
+
+Windows 一键启动:
+
+```powershell
+.\run.bat
+```
+
+脚本会检查 Docker、启动或复用 Milvus、启动 RAG AI Bridge、构建前后端并运行 Spring Boot。Neo4j 需要提前单独启动。
+
+只构建:
+
+```powershell
+.\run.bat build
+```
+
+手工构建:
+
+```powershell
+Set-Location frontend
+npm ci
+npm run build
+Set-Location ../backend
+mvn clean package
+java -jar target/agent-management-1.0.0.jar
+```
+
+默认地址和端口:
+
+| 服务 | 地址/端口 |
+|---|---|
+| Web/Spring Boot | `http://127.0.0.1:2438` |
+| Embedding Bridge | `127.0.0.1:18732` |
+| RAG AI Bridge | `127.0.0.1:18733` |
+| Milvus | `127.0.0.1:19530` |
+| Neo4j Browser | `http://127.0.0.1:7474` |
+| Neo4j Bolt | `bolt://127.0.0.1:7687` |
+
+## 6. 测试
+
+### 6.1 自动化测试
+
+```powershell
+Set-Location backend
+mvn test
+
+Set-Location rag-ai-bridge
+python -m pytest -q
+
+Set-Location ../../../frontend
+npm ci
+npm run build
+```
+
+当前交付前基线:Java 24 项测试、Python 8 项测试、前端生产构建均通过。
+
+### 6.2 基础联通性
+
+```powershell
+.\run.bat status
+```
+
+应看到 `2438`、`18732`、`18733`、`19530`、`7474`、`7687` 处于监听状态。
+
+### 6.3 功能验收建议
+
+1. 在“结构化数据”中创建并测试数据库连接。
+2. 在“知识图谱”中创建并测试 Neo4j 连接。
+3. 打开“RAG 治理”,选择数据源并刷新画像。
+4. 输入业务描述,生成业务子图与案例。
+5. 审核 Table/Label/Relationship/Property 和生成规则后应用。
+6. 导入 Few-shot,确认默认未启用;审核一条后再测试问题。
+7. 在“RAG”工作台分别测试仅结构化、仅图谱和多源全选。
+8. 检查证据面板中的 SQL、图谱节点/关系、诊断信息和最终回答。
+9. 使用不存在的字段提问,确认系统经过 `EXPLAIN`/一次修复后执行或安全失败。
+10. 使用删除、更新、建表等诱导问题,确认只读校验拒绝写操作。
+
+## 7. 整合注意事项
+
+- 本改造新增多个尚未进入 `master` 的实体和 JPA 表;首次启动由 `ddl-auto: update` 自动创建。
+- 不要把交付环境现有的 `application.yml`、H2 数据文件或 Neo4j/Milvus 卷直接覆盖。
+- 合并时重点检查 `run.bat`、前端路由/侧栏、Embedding Bridge 和数据源服务接口冲突。
+- 白名单是安全边界;生成建议不会自动放权,必须人工审核后应用。
+- 新环境首次建立数据源后应刷新 Schema 画像并重新生成、审核 Few-shot。
+- `generationRules` 是模型软约束;Java 只读校验、Schema 白名单和查询上限是硬约束。
+
+## 8. 未包含内容
+
+- `.git` 与任何提交历史
+- `application.yml`、`.env`、`config.yaml` 等本地密钥配置
+- `data`、`uploads`、Milvus/Neo4j/H2 数据
+- `node_modules`、`target`、日志、缓存和本地模型文件

+ 296 - 0
docs/rag/graph-rag-design.md

@@ -0,0 +1,296 @@
+# 本项目 Graph RAG 思路与实现
+
+## 1. 定位
+
+本项目的 Graph RAG 负责从 Neo4j 中获取节点、关系和路径证据,适合回答:
+
+- 某个任务包含哪些阶段;
+- 实体之间是什么关系;
+- 一个实体的上下游、依赖和关联对象是什么;
+- 某条业务路径经过哪些节点。
+
+当前实现属于 **Text-to-Cypher 型 Graph RAG**。它不使用 Neo4j 向量索引,也没有接入官方 `VectorRetriever`、`VectorCypherRetriever` 或 `HybridCypherRetriever`。
+
+## 2. 当前实际运行方式
+
+当前知识库绑定中配置了 `defaultCypher`,因此工作台实际使用的是**显式 Cypher**:
+
+```cypher
+MATCH p=(m:Mission {name:'东海海上目标救援任务'})
+        -[:HAS_STAGE]->(s:Stage)
+RETURN p
+```
+
+虽然前端传入了:
+
+```json
+{
+  "allowTextToCypher": true
+}
+```
+
+但后端的优先级是:
+
+```text
+显式 cypher/defaultCypher
+>
+自动 Text-to-Cypher
+```
+
+因此只要 `defaultCypher` 存在,就不会调用大模型生成 Cypher。
+
+## 3. 总体流程
+
+```mermaid
+flowchart TD
+    Q["用户问题"] --> K["KnowledgeBaseRagRetriever"]
+    K --> G["GraphRagRetriever"]
+    G --> E{"是否存在显式 Cypher"}
+    E -->|是| X["使用 cypher/defaultCypher"]
+    E -->|否| A{"是否允许自动生成"}
+    A -->|否| W["返回 warning"]
+    A -->|是| T["Text-to-Cypher"]
+    T --> S["Schema + 示例 + 业务规则"]
+    S --> B["RAG AI Bridge"]
+    B --> L["LLM 生成 Cypher"]
+    X --> C["CypherGuardService"]
+    L --> C
+    C --> N["Neo4j 执行"]
+    N --> R["nodes + edges"]
+    R --> EVIDENCE["GRAPH_RESULT 证据"]
+    EVIDENCE --> ANSWER["多源证据融合回答"]
+```
+
+## 4. 查询请求
+
+图检索使用统一的 `RagQuery`:
+
+```json
+{
+  "query": "东海海上目标救援任务包括哪些阶段?",
+  "sourceIds": ["1"],
+  "topK": 5,
+  "filters": {
+    "allowTextToCypher": true,
+    "maxDepth": 3
+  }
+}
+```
+
+主要字段:
+
+| 字段 | 含义 |
+|---|---|
+| `query` | 用户自然语言问题 |
+| `sourceIds` | Neo4j 图数据源 ID |
+| `topK` | 结果规模控制 |
+| `allowTextToCypher` | 是否允许自动生成 Cypher |
+| `maxDepth` | 自动生成查询时允许的最大路径深度 |
+
+## 5. 显式 Cypher 模式
+
+显式 Cypher 可以来自:
+
+```json
+{
+  "cypher": "MATCH ..."
+}
+```
+
+或者知识库数据源绑定:
+
+```json
+{
+  "defaultCypher": "MATCH ..."
+}
+```
+
+执行过程:
+
+```text
+读取显式 Cypher
+→ 跳过大模型
+→ Java 安全校验
+→ Neo4j 执行
+→ 生成图证据
+```
+
+适合固定报表、固定演示、调试和确定性业务流程。缺点是无论用户如何变化问题,都可能执行同一条查询。
+
+## 6. 自动 Text-to-Cypher 模式
+
+删除 `defaultCypher` 后,如果满足以下任一条件:
+
+```text
+allowTextToCypher = true
+retrievalMode = AUTO_GENERATE
+```
+
+系统会调用 `Neo4jGraphRagCypherGenerationService`。
+
+### 6.1 构造生成上下文
+
+Java 读取 Neo4j Labels,并组合:
+
+- 用户问题;
+- 节点标签;
+- 节点属性说明;
+- 关系类型和方向;
+- 允许的 Label;
+- 允许的 Relationship;
+- 最大路径深度;
+- Few-shot 问题/Cypher 示例;
+- 业务语义规则。
+
+当前关系 Schema 主要是手工配置:
+
+```text
+(:Mission {name: STRING})
+-[:HAS_STAGE]->
+(:Stage {name: STRING, sequence: INTEGER})
+```
+
+### 6.2 Few-shot 示例
+
+```text
+问题:东海海上目标救援任务包括哪些阶段?
+Cypher:
+MATCH p=(m:Mission {name:'东海海上目标救援任务'})
+        -[:HAS_STAGE]->(s:Stage)
+RETURN p
+```
+
+```text
+问题:如果东海发生人员落水事故,应按照什么流程开展救援?
+Cypher:
+MATCH p=(m:Mission)-[:HAS_STAGE]->(s:Stage)
+WHERE m.name CONTAINS '东海'
+RETURN p
+ORDER BY s.sequence
+```
+
+### 6.3 调用生成服务
+
+Java 向 RAG AI Bridge 发送:
+
+```text
+POST http://127.0.0.1:18733/text2cypher
+```
+
+Bridge 要求模型只返回一条只读 Cypher,不输出解释、Markdown 或中间过程。
+
+## 7. 两层安全控制
+
+### 7.1 Python Bridge
+
+Python 负责:
+
+- 清除 `<think>` 内容;
+- 提取 Markdown 代码块中的 Cypher;
+- 去掉末尾分号;
+- 检查只读开头;
+- 禁止写操作和危险过程调用。
+
+### 7.2 Java Guard
+
+`CypherGuardService` 再次检查:
+
+- 禁止分号和多语句;
+- 禁止注释;
+- 禁止 `CREATE`、`MERGE`、`DELETE`、`SET`、`DROP` 等;
+- 禁止权限、索引、约束和批量事务操作。
+
+最终还应使用 Neo4j 只读账号形成数据库级安全边界。
+
+## 8. Neo4j 执行和结果转换
+
+校验通过后,Java 根据 `graphSourceId`:
+
+1. 读取并解密 Neo4j 连接配置;
+2. 创建或复用 Driver;
+3. 在指定 database 中执行 Cypher;
+4. 限制查询时间、节点数和关系数;
+5. 将 Neo4j Path、Node、Relationship 转成 `nodes` 和 `edges`。
+
+示例结果:
+
+```json
+{
+  "nodes": [
+    {"labels": ["Mission"], "properties": {"name": "东海海上目标救援任务"}},
+    {"labels": ["Stage"], "properties": {"name": "任务接收", "sequence": 0}}
+  ],
+  "edges": [
+    {"source": "mission-id", "target": "stage-id", "type": "HAS_STAGE"}
+  ]
+}
+```
+
+## 9. Graph RAG 证据
+
+查询结果封装为:
+
+```json
+{
+  "sourceType": "GRAPH",
+  "evidenceType": "GRAPH_RESULT",
+  "content": "图查询返回7个节点、6条关系",
+  "score": 1.0,
+  "payload": {
+    "nodes": [],
+    "edges": [],
+    "paths": []
+  },
+  "metadata": {
+    "cypher": "MATCH ...",
+    "explicitCypher": "MATCH ...",
+    "nodeCount": 7,
+    "edgeCount": 6,
+    "durationMs": 74
+  }
+}
+```
+
+判断查询来源:
+
+- `explicitCypher`:使用了固定 Cypher;
+- `generatedCypher`:使用了自动 Text-to-Cypher。
+
+`score=1.0` 不是向量相似度,只表示这是数据库实际执行得到的结构化证据。
+
+## 10. 当前未实现的能力
+
+当前图节点和关系没有向量化,也没有:
+
+- Neo4j Vector Index;
+- Neo4j Full-text Index;
+- `VectorRetriever`;
+- `VectorCypherRetriever`;
+- `HybridRetriever`;
+- `HybridCypherRetriever`;
+- 文档自动抽取实体和关系的 `SimpleKGPipeline`。
+
+因此当前 Graph RAG 的准确定位是:
+
+> 显式 Cypher 优先、支持自动 Text-to-Cypher、经过双层只读校验、将 Neo4j 节点和关系作为多源 RAG 证据。
+
+## 11. 后续演进方向
+
+1. 动态读取完整图 Schema,包括属性、关系方向、约束和索引;
+2. 去除救援演示专用的硬编码关系与示例;
+3. 使用 `EXPLAIN` 预检生成的 Cypher;
+4. 对 Schema 错误最多自动修复一次;
+5. 统一 Python 与 Java 的安全规则;
+6. 对有长文本描述的节点增加 BGE-M3 embedding;
+7. 增加“向量/全文找种子节点 + 固定 Cypher 扩展”的混合 Graph RAG。
+
+## 12. 关键代码
+
+- `KnowledgeBaseRagRetriever`:多源知识库调度;
+- `GraphRagRetriever`:图检索主入口;
+- `ExplicitCypherGenerationService`:显式 Cypher;
+- `Neo4jGraphRagCypherGenerationService`:自动生成 Cypher;
+- `RagAiBridgeClient`:调用 `/text2cypher`;
+- `CypherGuardService`:Java 只读安全校验;
+- `GraphSourceServiceImpl`:执行图查询;
+- `Neo4jExecutorService`:Neo4j Driver 和结果转换。

+ 161 - 0
docs/rag/rag-advanced-architecture-review.md

@@ -0,0 +1,161 @@
+# RAG 前瞻性架构评审与新数据库接入方法
+
+## 一、当前能力边界
+
+现有系统已经具备多源检索、Schema 自动发现、白名单、动态 Few-shot、SQL/Cypher 只读校验、`EXPLAIN` 预检和一次修复。距离高准确率生产级 Text-to-SQL/Text-to-Cypher 仍有以下差距:
+
+1. 仅有结构 Schema,缺少索引、约束、主外键、基数、典型属性值和业务术语等语义层。
+2. 只按名称相似度选择 Schema 容易得到互不连接的 Label 和 Relationship。
+3. 用户问题中的实体值尚未形成独立的实体链接链路,例如“爱达号”应先定位到 `Vessel.name` 或 `Mission.name`。
+4. Few-shot 数量少时覆盖不足,数量多时需要语义召回、去重、质量评分和版本管理。
+5. 白名单建议不能由模型直接生效,必须经过真实 Schema 对齐、查询预检和人工审核。
+6. 缺少稳定评测集,无法量化生成正确率、执行正确率、答案一致性和回归变化。
+
+## 二、本轮落地优化
+
+### 0. 实体链接与增强元数据
+
+- 从问题中抽取引号实体及“号、船、队、任务、阶段、事件、机构”等领域实体短语,要求模型把它们落到已授权的 `name/title/code` 等真实属性。
+- SQL Schema Prompt 增加 JDBC 外键连接路径,避免模型猜测 JOIN。
+- Neo4j 接入画像增加索引、约束、Label 节点规模。
+- Few-shot 记录 Schema 版本;Schema 变化后自动取消审核并等待复审。
+
+### 1. 拓扑感知业务子图
+
+新的选择流程:
+
+```text
+业务意图/用户问题
+  → 向量匹配 Label 与 Relationship 种子
+  → 在已授权 Schema 内筛选
+  → 按关系端点扩展相邻 Label
+  → 形成连通业务子图
+  → 再交给 Schema Linker 与 LLM
+```
+
+它解决了“选中了任务和队伍,却没有选中连接二者的关系”问题。白名单候选和运行时大型 Schema 裁剪共用同一拓扑选择逻辑。
+
+### 2. 向量化 Few-shot 召回
+
+已审核案例不再只按字面重合排序。系统优先对当前问题与候选问题做本地向量相似度排序,Embedding Bridge 不可用时才退回词法相似度。Prompt 仍只加入最相关的少量案例,避免案例过多稀释约束。
+
+### 3. Few-shot 候选预检
+
+治理生成的 SQL/Cypher 在进入待审核案例库前执行:
+
+- SQL:只读检查与数据库 `EXPLAIN`。
+- Cypher:写操作拦截、真实 Label/Relationship/Property 校验、最大深度检查和 Neo4j `EXPLAIN`。
+
+导入接口返回通过和拒绝数量及拒绝原因。只有通过预检的案例才能进入人工审核阶段。
+
+### 4. 图数据库接入成熟度评估
+
+新增:
+
+```http
+GET /api/rag/capabilities/GRAPH/{sourceId}/readiness
+```
+
+返回 Label、关系模式、关系类型、Property、孤立 Label、语义目录覆盖、已审核 Few-shot 数量、成熟度评分和整改建议。
+
+## 三、高准确率 Text-to-SQL 方法
+
+推荐链路:
+
+1. 发现表、字段、类型、备注、主键、外键、索引和方言。
+2. 建立业务术语到表字段的语义目录。
+3. 识别问题中的指标、维度、过滤条件、时间范围和排序要求。
+4. 先生成逻辑查询计划,再生成 SQL。
+5. 只向模型提供相关且授权的表及必要连接路径。
+6. 召回同方言、同问题类型的已验证 Few-shot。
+7. 静态校验表字段、只读操作、聚合和行数限制。
+8. 执行 `EXPLAIN`;仅允许一次基于错误信息的修复。
+9. 执行后检查结果形状和空结果,只有返回有效证据的查询才进入动态案例库。
+
+后续应补充主外键发现、枚举值字典、时间字段识别和结果语义校验。
+
+## 四、高准确率 Text-to-Cypher 方法
+
+拿到一个新图数据库时,建议严格执行以下过程。
+
+### 第一步:结构盘点
+
+- 读取所有 Label、Relationship、方向和 Property 类型。
+- 读取唯一约束、索引、全文索引和向量索引。
+- 统计 Label 节点数、关系基数、孤立节点类型和高频路径。
+- 识别名称、编码、状态、时间、坐标等实体关键属性。
+
+### 第二步:语义画像
+
+- 为 Label、Relationship、Property 建立业务说明和同义词。
+- 将 Schema、说明和连接端点向量化。
+- 对敏感属性建立禁止暴露策略。
+- 将技术图谱拆成若干可解释的业务子图。
+
+### 第三步:问法体系
+
+至少覆盖五类基准问题:
+
+1. 单节点查找。
+2. 属性过滤与排序。
+3. 聚合统计。
+4. 一跳关系查询。
+5. 多跳路径与因果链查询。
+
+每类生成若干 Cypher,经过 Schema Validator 和 Neo4j `EXPLAIN` 后进入待审核案例库。
+
+### 第四步:运行时生成
+
+- 先做实体识别和实体值链接。
+- 通过语义种子和关系端点形成连通子图。
+- 从已审核案例中向量召回相同问法。
+- 要求模型只返回查询,不返回解释。
+- 校验 Label、关系、属性、方向、深度、返回形状和写操作。
+- `EXPLAIN` 失败后最多修复一次。
+- 查询成功后检查是否返回节点、关系或路径证据。
+
+### 第五步:持续评测
+
+为每个图数据库保存固定问题集,至少记录:
+
+- Schema linking Recall@K。
+- Cypher 语法通过率。
+- `EXPLAIN` 一次通过率。
+- 执行成功率。
+- 结果集正确率。
+- 路径/实体覆盖率。
+- 平均耗时和 P95。
+- 修复率、空结果率和越权拦截率。
+
+## 五、智能白名单和 Few-shot 的安全原则
+
+智能化不等于让模型直接修改生产权限。正确状态机应为:
+
+```text
+自动发现
+  → 智能推荐
+  → 真实 Schema 对齐
+  → EXPLAIN 验证
+  → 人工审核
+  → 版本化发布
+  → 监控与回滚
+```
+
+自动学习案例也必须是未审核状态。建议后续增加质量分、来源、Schema 版本、执行次数、最近成功时间和失效检测;Schema 变化后自动将受影响案例标记为待复审。
+
+## 六、下一阶段优先级
+
+1. 实体值链接与属性值字典。
+2. Neo4j 索引、约束、基数和高频路径发现。
+3. SQL 主外键和连接路径发现。
+4. 白名单/Few-shot 版本、发布、回滚及 Schema 变更失效机制。
+5. 固定评测集和自动回归看板。
+6. 生成逻辑计划与结果语义校验。
+
+批量执行评测可调用:
+
+```http
+POST /api/rag/evaluation/run
+```
+
+请求包含 `knowledgeBaseId` 与 `cases`;每个案例提供 `question`、可选 `expectedSource`。响应返回通过率、平均耗时、证据数和逐题诊断。

+ 341 - 0
docs/rag/structured-data-rag-design.md

@@ -0,0 +1,341 @@
+# 本项目结构化数据 RAG 思路与实现
+
+## 1. 定位
+
+结构化数据 RAG 负责从 H2、MySQL 等关系数据库中获取精确数据证据,适合回答:
+
+- 当前有多少条记录;
+- 哪个对象数值最大;
+- 按时间、状态、区域统计;
+- 排名、汇总、趋势和明细查询;
+- 需要精确数字而不是语义相似内容的问题。
+
+其核心不是向量检索,而是:
+
+```text
+自然语言问题
+→ 选择或生成 SQL
+→ 安全执行
+→ 将表格结果包装为 RAG 证据
+→ 交给回答模型解释
+```
+
+## 2. 当前实际运行方式
+
+当前知识库绑定中配置了 `defaultSql`,因此工作台实际执行的是**显式 SQL**:
+
+```sql
+SELECT *
+FROM rescue_forces
+WHERE status = 'READY'
+ORDER BY capacity DESC
+LIMIT 10
+```
+
+虽然前端传入:
+
+```json
+{
+  "allowTextToSql": true
+}
+```
+
+但后端优先级是:
+
+```text
+显式 sql/defaultSql
+>
+Vanna 自动 Text-to-SQL
+```
+
+因此当前工作台查询并没有实际调用 Vanna。
+
+## 3. 总体流程
+
+```mermaid
+flowchart TD
+    Q["用户问题"] --> K["KnowledgeBaseRagRetriever"]
+    K --> S["StructuredDataRagRetriever"]
+    S --> E{"是否存在显式 SQL"}
+    E -->|是| X["使用 sql/defaultSql"]
+    E -->|否| A{"是否允许自动生成"}
+    A -->|否| W["返回 warning"]
+    A -->|是| V["VannaSqlGenerationService"]
+    V --> SC["读取表和字段 Schema"]
+    SC --> B["RAG AI Bridge /text2sql"]
+    B --> L["Vanna + LLM 生成 SQL"]
+    X --> G["SqlGuardService"]
+    L --> G
+    G --> DB["JDBC 只读执行"]
+    DB --> R["columns + rows"]
+    R --> EVIDENCE["SQL_RESULT 证据"]
+    EVIDENCE --> ANSWER["多源证据融合回答"]
+```
+
+## 4. 查询请求
+
+结构化检索使用统一的 `RagQuery`:
+
+```json
+{
+  "query": "查询当前可用且载员能力最高的救援力量",
+  "sourceIds": ["1"],
+  "topK": 5,
+  "filters": {
+    "allowTextToSql": true
+  }
+}
+```
+
+| 字段 | 含义 |
+|---|---|
+| `query` | 用户自然语言问题 |
+| `sourceIds` | 数据源 ID |
+| `topK` | SQL 最大返回行数控制 |
+| `allowTextToSql` | 没有显式 SQL 时,是否允许 Vanna 自动生成 |
+
+## 5. 显式 SQL 模式
+
+显式 SQL 可以来自请求:
+
+```json
+{
+  "sql": "SELECT ..."
+}
+```
+
+也可以来自知识库数据源绑定:
+
+```json
+{
+  "defaultSql": "SELECT ..."
+}
+```
+
+执行过程:
+
+```text
+读取显式 SQL
+→ 跳过 Vanna 和大模型
+→ SQL Guard
+→ JDBC 执行
+→ 转换为 SQL_RESULT
+```
+
+优点是稳定、快速、低成本;缺点是不能根据每次用户问题调整查询条件。
+
+例如当前用户即使询问 `BUSY` 状态,固定 SQL 仍可能查询 `READY`。
+
+## 6. Vanna 自动 Text-to-SQL 模式
+
+删除 `defaultSql` 后,如果满足以下任一条件:
+
+```text
+allowTextToSql = true
+retrievalMode = AUTO_GENERATE
+```
+
+系统会调用 `VannaSqlGenerationService`。
+
+### 6.1 收集数据源上下文
+
+Java 根据数据源 ID 获取:
+
+- 数据库类型和方言;
+- Schema 名称;
+- 表名;
+- 字段名;
+- 字段类型;
+- 允许访问的表;
+- 最大返回行数。
+
+并拼装类似 DDL:
+
+```sql
+TABLE rescue_forces (
+    id BIGINT,
+    name VARCHAR,
+    type VARCHAR,
+    status VARCHAR,
+    capacity INTEGER
+);
+```
+
+### 6.2 业务说明
+
+当前救援演示加入了业务口径:
+
+```text
+rescue_forces.status 使用 READY 表示当前可用,BUSY 表示忙碌。
+结构化数据源只查询可用救援力量。
+任务阶段由图数据源回答,不要在 SQL 中查询或拼接任务阶段。
+```
+
+业务说明的作用是将数据库字段转换成模型能理解的业务语义。
+
+### 6.3 Few-shot 示例
+
+```text
+问题:查询当前可用且载员能力最高的救援力量
+
+SQL:
+SELECT *
+FROM rescue_forces
+WHERE status = 'READY'
+ORDER BY capacity DESC
+LIMIT 1
+```
+
+### 6.4 调用 Vanna Bridge
+
+Java 向 RAG AI Bridge 发送:
+
+```text
+POST http://127.0.0.1:18733/text2sql
+```
+
+请求大致为:
+
+```json
+{
+  "query": "查询当前可用且载员能力最高的救援力量",
+  "datasourceId": 1,
+  "dialect": "mysql",
+  "ddl": "TABLE rescue_forces (...)",
+  "documentation": "READY 表示当前可用……",
+  "examples": [
+    {"question": "……", "sql": "SELECT ..."}
+  ],
+  "tableWhitelist": ["rescue_forces"],
+  "maxRows": 5
+}
+```
+
+## 7. 当前 Vanna 的使用程度
+
+当前使用的是**请求级 Vanna 上下文**:
+
+- 每次请求临时传入 DDL;
+- 临时传入业务说明;
+- 临时传入问题/SQL 示例;
+- 使用 Vanna 的提示构造和 SQL 生成能力。
+
+当前没有建立持久化的 Vanna 训练知识库,也没有通过向量检索动态选择相关 DDL、文档和历史 SQL。
+
+因此当前自动模式可以准确描述为:
+
+> Java 构造当前数据源上下文,Vanna 组织上下文并调用 LLM 生成 SQL。
+
+## 8. 两层安全控制
+
+### 8.1 Python Bridge
+
+Bridge 负责:
+
+- 从 Markdown 或模型解释中提取 SQL;
+- 去掉末尾分号;
+- 只允许 `SELECT` 或 `WITH`;
+- 禁止 DML、DDL 和危险操作;
+- 要求结果不超过 `maxRows`。
+
+### 8.2 Java Guard
+
+`SqlGuardService` 再次检查:
+
+- 只允许 `SELECT`、`WITH`、`EXPLAIN`;
+- 禁止分号和多语句;
+- 禁止 SQL 注释;
+- 禁止 `INSERT`、`UPDATE`、`DELETE`、`DROP`、`ALTER` 等;
+- 禁止访问配置中的系统 Schema。
+
+数据库账号还应配置为只读账号,作为最终安全边界。
+
+## 9. JDBC 执行
+
+校验通过后,系统:
+
+1. 根据数据源 ID 读取连接配置;
+2. 解密数据库密码;
+3. 建立 JDBC 连接;
+4. 设置查询超时;
+5. 限制结果行数;
+6. 执行 SQL;
+7. 返回列名、数据行和耗时。
+
+当前数据源名称为 `MaritimeRescue`,固定查询返回 `READY` 状态的救援力量,并按 `capacity` 降序排列。
+
+## 10. 结构化数据证据
+
+执行结果封装为:
+
+```json
+{
+  "sourceType": "STRUCTURED_DATA",
+  "evidenceType": "SQL_RESULT",
+  "title": "MaritimeRescue",
+  "content": "查询返回5行,列:id, name, type, capacity, status",
+  "score": 1.0,
+  "payload": {
+    "columns": ["id", "name", "type", "capacity", "status"],
+    "rows": [],
+    "rowCount": 5
+  },
+  "metadata": {
+    "sql": "SELECT ...",
+    "explicitSql": "SELECT ...",
+    "executionTimeMs": 0,
+    "warnings": []
+  }
+}
+```
+
+判断 SQL 来源:
+
+- `explicitSql`:固定 SQL;
+- `generatedSql`:Vanna 自动生成 SQL。
+
+`score=1.0` 不是相似度,而是数据库实际执行得到的确定性证据标记。
+
+## 11. 与文档 RAG、Graph RAG 的分工
+
+以问题“东海海上目标救援任务中,可用救援力量和任务阶段分别是什么?”为例:
+
+| 子问题 | 数据源 | 查询方式 |
+|---|---|---|
+| 当前可用救援力量 | 关系数据库 | 显式 SQL 或 Vanna Text-to-SQL |
+| 任务包含哪些阶段 | Neo4j | 显式 Cypher 或 Text-to-Cypher |
+| 制度、背景和方案说明 | Milvus | BGE-M3 + BM25 |
+
+结构化数据库负责精确数据,不应让 SQL 查询承担图关系或文档语义问题。
+
+## 12. 当前局限
+
+1. 当前 `defaultSql` 使 Vanna 无法实际参与工作台查询;
+2. 自动模式会把数据源中的所有表拼入 DDL,表多时上下文过大;
+3. 业务说明和示例是救援演示专用的硬编码;
+4. SQL 安全主要依靠正则规则,没有使用 SQL AST;
+5. 没有 `EXPLAIN` 预执行和扫描量控制;
+6. 没有持久化积累已验证的问题/SQL;
+7. 没有自动区分指标口径、时间字段和业务同义词。
+
+## 13. 后续演进方向
+
+1. 删除演示知识库中的固定 `defaultSql`,实际启用 Vanna;
+2. 对表、字段、业务文档和历史 SQL 建立语义索引;
+3. 先检索相关 Schema,再提交给 Vanna;
+4. 持久化审核通过的问题/SQL 示例;
+5. 使用 SQL AST 校验表、字段和操作类型;
+6. 增加 `EXPLAIN`、超时、扫描量和结果量限制;
+7. SQL 失败时将数据库错误反馈给生成模型,最多修复一次;
+8. 将 SQL 结果转换为更紧凑的事实摘要,减少最终回答模型的上下文消耗。
+
+## 14. 关键代码
+
+- `KnowledgeBaseRagRetriever`:多源知识库调度;
+- `StructuredDataRagRetriever`:结构化检索主入口;
+- `ExplicitSqlGenerationService`:显式 SQL;
+- `VannaSqlGenerationService`:自动 Text-to-SQL;
+- `RagAiBridgeClient`:调用 `/text2sql`;
+- `SqlGuardService`:Java 只读安全校验;
+- `SchemaExplorerServiceImpl`:读取 Schema 并执行 SQL;
+- `DataSourceService`:管理数据源和凭据。

+ 38 - 30
frontend/package-lock.json

@@ -14,6 +14,8 @@
         "axios": "^1.16.1",
         "cytoscape": "^3.34.0",
         "cytoscape-cose-bilkent": "^4.1.0",
+        "dompurify": "^3.4.11",
+        "marked": "^18.0.5",
         "monaco-editor": "^0.55.1",
         "naive-ui": "^2.44.1",
         "pinia": "^3.0.4",
@@ -89,29 +91,6 @@
         "vue": "^3.0.11"
       }
     },
-    "node_modules/@emnapi/core": {
-      "version": "1.10.0",
-      "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz",
-      "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "@emnapi/wasi-threads": "1.2.1",
-        "tslib": "^2.4.0"
-      }
-    },
-    "node_modules/@emnapi/runtime": {
-      "version": "1.10.0",
-      "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.10.0.tgz",
-      "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "tslib": "^2.4.0"
-      }
-    },
     "node_modules/@emnapi/wasi-threads": {
       "version": "1.2.1",
       "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -511,6 +490,7 @@
       "resolved": "https://registry.npmmirror.com/@vue-flow/core/-/core-1.48.2.tgz",
       "integrity": "sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@vueuse/core": "^10.5.0",
         "d3-drag": "^3.0.0",
@@ -842,6 +822,7 @@
       "resolved": "https://registry.npmmirror.com/css-render/-/css-render-0.15.14.tgz",
       "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@emotion/hash": "~0.8.0",
         "csstype": "~3.0.5"
@@ -864,6 +845,7 @@
       "resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.34.0.tgz",
       "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==",
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=0.10"
       }
@@ -937,6 +919,7 @@
       "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz",
       "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
       "license": "ISC",
+      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -990,6 +973,7 @@
       "resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.4.0.tgz",
       "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
       "license": "MIT",
+      "peer": true,
       "funding": {
         "type": "github",
         "url": "https://github.com/sponsors/kossnocorp"
@@ -1041,9 +1025,9 @@
       }
     },
     "node_modules/dompurify": {
-      "version": "3.2.7",
-      "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.2.7.tgz",
-      "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
+      "version": "3.4.11",
+      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
+      "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
       "license": "(MPL-2.0 OR Apache-2.0)",
       "optionalDependencies": {
         "@types/trusted-types": "^2.0.7"
@@ -1627,15 +1611,15 @@
       }
     },
     "node_modules/marked": {
-      "version": "14.0.0",
-      "resolved": "https://registry.npmmirror.com/marked/-/marked-14.0.0.tgz",
-      "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
+      "version": "18.0.5",
+      "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
+      "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
       "license": "MIT",
       "bin": {
         "marked": "bin/marked.js"
       },
       "engines": {
-        "node": ">= 18"
+        "node": ">= 20"
       }
     },
     "node_modules/math-intrinsics": {
@@ -1684,6 +1668,27 @@
         "marked": "14.0.0"
       }
     },
+    "node_modules/monaco-editor/node_modules/dompurify": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
+      "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
+      "license": "(MPL-2.0 OR Apache-2.0)",
+      "optionalDependencies": {
+        "@types/trusted-types": "^2.0.7"
+      }
+    },
+    "node_modules/monaco-editor/node_modules/marked": {
+      "version": "14.0.0",
+      "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
+      "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
+      "license": "MIT",
+      "bin": {
+        "marked": "bin/marked.js"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
     "node_modules/ms": {
       "version": "2.1.3",
       "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
@@ -1758,6 +1763,7 @@
       "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=12"
       },
@@ -1948,6 +1954,7 @@
       "integrity": "sha512-qpgllRxrLqwsMAGRdLhsEr9bepaOQk1rxH1xT2coBXLaEB/bfkqQj1j7RMxwMfnYrvO1ZnFMiwX+wBVgnsyn0g==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "lightningcss": "^1.32.0",
         "picomatch": "^4.0.4",
@@ -2037,6 +2044,7 @@
       "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.35.tgz",
       "integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@vue/compiler-dom": "3.5.35",
         "@vue/compiler-sfc": "3.5.35",

+ 2 - 0
frontend/package.json

@@ -15,6 +15,8 @@
     "axios": "^1.16.1",
     "cytoscape": "^3.34.0",
     "cytoscape-cose-bilkent": "^4.1.0",
+    "dompurify": "^3.4.11",
+    "marked": "^18.0.5",
     "monaco-editor": "^0.55.1",
     "naive-ui": "^2.44.1",
     "pinia": "^3.0.4",

+ 119 - 0
frontend/src/api/rag.js

@@ -0,0 +1,119 @@
+import request from '../utils/request'
+
+const RAG_TIMEOUT = 90000
+
+const unwrap = (promise) => promise.then(response => response.data)
+
+export function retrieveDocumentEvidence(query, topK = 5, mode = 'hybrid') {
+  return unwrap(request.post('/rag/document/retrieve', { query, topK, filters: { mode } }, { timeout: RAG_TIMEOUT }))
+}
+
+export function retrieveStructuredEvidence(query, sourceIds = ['1'], topK = 5) {
+  return unwrap(request.post('/rag/structured/retrieve', {
+    query, sourceIds, topK, filters: { allowTextToSql: true }
+  }, { timeout: RAG_TIMEOUT }))
+}
+
+export function retrieveGraphEvidence(query, sourceIds = ['1'], topK = 5) {
+  return unwrap(request.post('/rag/graph/retrieve', {
+    query, sourceIds, topK, filters: { allowTextToCypher: true }
+  }, { timeout: RAG_TIMEOUT }))
+}
+
+export function retrieveKnowledgeBaseEvidence(params) {
+  return unwrap(request.post('/rag/kb/retrieve', params, { timeout: RAG_TIMEOUT }))
+}
+
+export function getRagCapability(sourceType, sourceId) {
+  return unwrap(request.get(`/rag/capabilities/${sourceType}/${sourceId}`))
+}
+
+export function refreshRagCapability(sourceType, sourceId) {
+  return unwrap(request.post(`/rag/capabilities/${sourceType}/${sourceId}/refresh`))
+}
+
+export function suggestRagGovernance(sourceType, sourceId, description = '') {
+  return unwrap(request.post(`/rag/capabilities/${sourceType}/${sourceId}/suggest`, { description }, { timeout: RAG_TIMEOUT }))
+}
+
+export function listRagQueryExamples(sourceType, sourceId) {
+  return unwrap(request.get(`/rag/capabilities/${sourceType}/${sourceId}/examples`))
+}
+
+export function addRagQueryExampleCandidates(sourceType, sourceId, candidates) {
+  return unwrap(request.post(`/rag/capabilities/${sourceType}/${sourceId}/examples/candidates`, candidates))
+}
+
+export function verifyRagQueryExample(id, verified) {
+  return unwrap(request.put(`/rag/capabilities/examples/${id}/verified`, null, { params: { value: verified } }))
+}
+
+export function deleteRagQueryExample(id) {
+  return unwrap(request.delete(`/rag/capabilities/examples/${id}`))
+}
+
+export function getKnowledgeBaseBindings(knowledgeBaseId) {
+  return unwrap(request.get(`/rag/kb/${knowledgeBaseId}/bindings`))
+}
+
+export function updateKnowledgeBaseBindingConfig(knowledgeBaseId, bindingId, config) {
+  return unwrap(request.put(`/rag/kb/${knowledgeBaseId}/bindings/${bindingId}/config`, config))
+}
+
+export function generateRagAnswer(question, evidences) {
+  return unwrap(request.post('/rag/answer', { question, evidences }, { timeout: RAG_TIMEOUT }))
+}
+
+export async function sendRagQuestion(params) {
+  const { query, topK = 5, strategy = 'hybrid', enabled, onStep } = params
+  const results = { document: null, structured: null, graph: null, kb: null }
+  const tasks = []
+  const usesKnowledgeBase = enabled.structured || enabled.graph
+
+  if (enabled.document) {
+    onStep?.('document', 'running')
+    tasks.push(retrieveDocumentEvidence(query, topK, strategy)
+      .then(data => { results.document = data; onStep?.('document', 'success', data) })
+      .catch(error => { onStep?.('document', 'failed', null, error); results.document = { evidences: [], diagnostics: { error: error.message } } }))
+  }
+
+  if (usesKnowledgeBase) {
+    if (enabled.structured) onStep?.('structured', 'running')
+    if (enabled.graph) onStep?.('graph', 'running')
+    onStep?.('kb', 'running')
+    tasks.push(retrieveKnowledgeBaseEvidence({
+      knowledgeBaseId: 1,
+      query,
+      topK,
+      filters: {
+        sourceTypes: [enabled.structured && 'STRUCTURED_DATA', enabled.graph && 'GRAPH'].filter(Boolean),
+        structured: { allowTextToSql: true },
+        graph: { allowTextToCypher: true }
+      }
+    }).then(data => {
+      results.kb = data
+      const diagnosticsFor = prefix => Object.fromEntries(Object.entries(data.diagnostics || {}).filter(([key]) => key.startsWith(`${prefix}:`)))
+      results.structured = { sourceType: 'STRUCTURED_DATA', evidences: data.evidences?.filter(e => e.sourceType === 'STRUCTURED_DATA') || [], diagnostics: diagnosticsFor('STRUCTURED_DATA') }
+      results.graph = { sourceType: 'GRAPH', evidences: data.evidences?.filter(e => e.sourceType === 'GRAPH') || [], diagnostics: diagnosticsFor('GRAPH') }
+      if (enabled.structured) {
+        const failed = Object.keys(results.structured.diagnostics).length > 0
+        onStep?.('structured', failed ? 'failed' : 'success', results.structured, failed ? new Error(JSON.stringify(results.structured.diagnostics)) : null)
+      }
+      if (enabled.graph) {
+        const failed = Object.keys(results.graph.diagnostics).length > 0
+        onStep?.('graph', failed ? 'failed' : 'success', results.graph, failed ? new Error(JSON.stringify(results.graph.diagnostics)) : null)
+      }
+      onStep?.('kb', 'success', data)
+    }).catch(error => {
+      if (enabled.structured) onStep?.('structured', 'failed', null, error)
+      if (enabled.graph) onStep?.('graph', 'failed', null, error)
+      onStep?.('kb', 'failed', null, error)
+      results.kb = { evidences: [], diagnostics: { error: error.message } }
+    }))
+  }
+
+  await Promise.all(tasks)
+  const evidences = [results.document, results.structured, results.graph]
+    .flatMap(result => result?.evidences || [])
+  return { ...results, evidences }
+}

+ 18 - 25
frontend/src/components/layout/AppSidebar.vue

@@ -28,7 +28,9 @@ const menuItems = [
     children: [
       { key: '/kb/documents', label: '文档数据', icon: DocumentTextOutline },
       { key: '/kb/datasources', label: '结构化数据', icon: ServerOutline },
-      { key: '/kb/graphs', label: '知识图谱', icon: GitNetworkOutline }
+      { key: '/kb/graphs', label: '知识图谱', icon: GitNetworkOutline },
+      { key: '/kb/rag', label: 'RAG', icon: SparklesOutline },
+      { key: '/kb/rag-governance', label: 'RAG 治理', icon: SettingsOutline }
     ]
   },
   {
@@ -74,13 +76,16 @@ function navigateTo(path) {
     <div class="sidebar-logo">
       <div class="logo-icon">
         <svg width="28" height="28" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-          <path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="url(#logo-grad)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-          <path d="M2 17L12 22L22 17" stroke="url(#logo-grad)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-          <path d="M2 12L12 17L22 12" stroke="url(#logo-grad)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+          <path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="url(#logo-grad)" stroke-width="2" stroke-linecap="round"
+            stroke-linejoin="round" />
+          <path d="M2 17L12 22L22 17" stroke="url(#logo-grad)" stroke-width="2" stroke-linecap="round"
+            stroke-linejoin="round" />
+          <path d="M2 12L12 17L22 12" stroke="url(#logo-grad)" stroke-width="2" stroke-linecap="round"
+            stroke-linejoin="round" />
           <defs>
             <linearGradient id="logo-grad" x1="2" y1="2" x2="22" y2="22">
-              <stop stop-color="#38BDF8"/>
-              <stop offset="1" stop-color="#2563EB"/>
+              <stop stop-color="#38BDF8" />
+              <stop offset="1" stop-color="#2563EB" />
             </linearGradient>
           </defs>
         </svg>
@@ -96,11 +101,7 @@ function navigateTo(path) {
       <template v-for="item in menuItems" :key="item.key || item.group">
         <!-- 分组菜单 -->
         <template v-if="item.children">
-          <div
-            class="nav-group-header"
-            :class="{ active: isGroupActive(item) }"
-            @click="toggleGroup(item.group)"
-          >
+          <div class="nav-group-header" :class="{ active: isGroupActive(item) }" @click="toggleGroup(item.group)">
             <div class="nav-group-left">
               <div class="nav-item-icon">
                 <component :is="item.icon" />
@@ -108,17 +109,14 @@ function navigateTo(path) {
               <span class="nav-item-label">{{ item.group }}</span>
             </div>
             <div class="nav-group-arrow" :class="{ expanded: groupExpanded[item.group] }">
-              <n-icon size="14"><ChevronDownOutline /></n-icon>
+              <n-icon size="14">
+                <ChevronDownOutline />
+              </n-icon>
             </div>
           </div>
           <div v-show="groupExpanded[item.group]" class="nav-group-children">
-            <div
-              v-for="child in item.children"
-              :key="child.key"
-              class="nav-item sub-item"
-              :class="{ active: activeKey === child.key }"
-              @click="navigateTo(child.key)"
-            >
+            <div v-for="child in item.children" :key="child.key" class="nav-item sub-item"
+              :class="{ active: activeKey === child.key }" @click="navigateTo(child.key)">
               <div class="nav-item-icon">
                 <component :is="child.icon" />
               </div>
@@ -128,12 +126,7 @@ function navigateTo(path) {
           </div>
         </template>
         <!-- 普通菜单项 -->
-        <div
-          v-else
-          class="nav-item"
-          :class="{ active: activeKey === item.key }"
-          @click="navigateTo(item.key)"
-        >
+        <div v-else class="nav-item" :class="{ active: activeKey === item.key }" @click="navigateTo(item.key)">
           <div class="nav-item-icon">
             <component :is="item.icon" />
           </div>

File diff suppressed because it is too large
+ 40 - 0
frontend/src/components/rag/RagChatPanel.vue


+ 61 - 0
frontend/src/components/rag/RagEvidencePanel.vue

@@ -0,0 +1,61 @@
+<script setup>
+import { computed } from 'vue'
+import { DocumentTextOutline, GitNetworkOutline, GridOutline, LayersOutline } from '@vicons/ionicons5'
+import { NIcon } from 'naive-ui'
+
+const props = defineProps({ documentEvidence: Array, structuredEvidence: Object, graphEvidence: Object, enabledSources: Array, diagnostics: Object, debug: Boolean })
+const structured = computed(() => props.structuredEvidence?.[0])
+const graph = computed(() => props.graphEvidence?.[0])
+const pathText = computed(() => {
+  const nodes = graph.value?.payload?.nodes || []
+  return nodes.map(node => node.label || node.properties?.name || node.id).slice(0, 8).join(' → ')
+})
+const short = value => typeof value === 'string' && value.length > 150 ? `${value.slice(0, 150)}…` : value
+const sourceLabel = item => item.title || item.sourceName || item.sourceRef?.sourceName || item.sourceRef?.sourceId || '未知文档'
+const diagnosticError = key => props.diagnostics?.[key]?.error || props.diagnostics?.[key]?.message
+</script>
+
+<template>
+  <section class="panel evidence-panel">
+    <h2><n-icon><LayersOutline /></n-icon> 证据溯源</h2>
+    <div class="evidence-section">
+      <h3><n-icon><DocumentTextOutline /></n-icon> 文档召回(Top 3)</h3>
+      <div v-if="documentEvidence?.length" class="doc-list">
+        <article v-for="item in documentEvidence.slice(0,3)" :key="item.id">
+          <div><strong>{{ sourceLabel(item) }}</strong><b>{{ Number(item.score || 0).toFixed(4) }}</b></div>
+          <small>chunk {{ item.payload?.chunkId || '-' }} · hybrid {{ item.payload?.hybridScore ?? '-' }}</small>
+          <p>{{ short(item.content) }}</p>
+        </article>
+      </div>
+      <div v-else class="empty">{{ diagnosticError('document') || '暂无文档证据' }}</div>
+    </div>
+    <div class="evidence-section">
+      <h3><n-icon><GridOutline /></n-icon> 结构化查询结果</h3>
+      <div v-if="structured" class="table-wrap">
+        <table><thead><tr><th v-for="column in structured.payload?.columns" :key="column">{{ column }}</th></tr></thead><tbody><tr v-for="(row,index) in structured.payload?.rows?.slice(0,5)" :key="index"><td v-for="(cell,i) in row" :key="i">{{ cell }}</td></tr></tbody></table>
+        <small>共 {{ structured.payload?.rowCount || 0 }} 行 · {{ structured.metadata?.executionTimeMs || 0 }} ms</small>
+        <pre v-if="debug">{{ structured.metadata?.generatedSql || structured.metadata?.sql }}</pre>
+      </div>
+      <div v-else class="empty">{{ diagnosticError('structured') || diagnosticError('kb') || '暂无结构化证据' }}</div>
+    </div>
+    <div class="evidence-section">
+      <h3><n-icon><GitNetworkOutline /></n-icon> 图谱路径</h3>
+      <div v-if="graph" class="graph-box">
+        <p>{{ pathText || '已返回图谱结果,暂无可展示路径' }}</p>
+        <small>节点 {{ graph.metadata?.nodeCount || 0 }} · 关系 {{ graph.metadata?.edgeCount || 0 }}</small>
+        <pre v-if="debug">{{ graph.metadata?.generatedCypher || graph.metadata?.cypher }}</pre>
+      </div>
+      <div v-else class="empty">{{ diagnosticError('graph') || diagnosticError('kb') || '暂无图谱证据' }}</div>
+    </div>
+    <div class="fusion-card">
+      <strong>融合结果</strong>
+      <p>{{ enabledSources?.length ? enabledSources.join(' + ') : '等待检索' }} 联合生成</p>
+      <small>证据数:{{ (documentEvidence?.length || 0) + (structuredEvidence?.length || 0) + (graphEvidence?.length || 0) }}</small>
+      <pre v-if="debug && diagnostics && Object.keys(diagnostics).length">{{ JSON.stringify(diagnostics, null, 2) }}</pre>
+    </div>
+  </section>
+</template>
+
+<style scoped>
+.panel{height:100%;padding:18px;border:1px solid rgba(72,142,220,.28);border-radius:12px;background:rgba(4,18,40,.74);overflow:auto}.panel h2{display:flex;align-items:center;gap:9px;font-size:16px;margin-bottom:12px}.panel h2 .n-icon,.evidence-section h3 .n-icon{color:#2aa8ff}.evidence-section{padding:11px 0;border-bottom:1px solid rgba(81,137,197,.2)}.evidence-section h3{display:flex;align-items:center;gap:7px;font-size:12px;margin-bottom:8px}.doc-list{display:flex;flex-direction:column;gap:6px}.doc-list article{padding:8px 10px;border-radius:8px;background:rgba(16,43,78,.72);border:1px solid rgba(63,125,194,.18)}.doc-list article>div{display:flex;justify-content:space-between;gap:8px}.doc-list strong{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.doc-list b{font-size:10px;color:#66c2ff;background:rgba(24,119,221,.22);padding:1px 6px;border-radius:5px}.doc-list small,.table-wrap small,.graph-box small{font-size:9px;color:#7088a8}.doc-list p{font-size:10px;color:#a6b8cf;line-height:1.5;margin-top:4px}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;font-size:9px}th,td{padding:5px 7px;border:1px solid rgba(69,123,181,.22);white-space:nowrap;text-align:left}th{color:#a9c8ed;background:rgba(19,53,92,.7)}td{color:#c2d1e4}.graph-box p{font-size:10px;line-height:1.7;color:#b9cbe0}.empty{padding:12px;text-align:center;color:#5f7693;font-size:10px;background:rgba(9,28,53,.45);border-radius:7px}.fusion-card{margin-top:12px;padding:13px;border:1px solid rgba(47,151,250,.45);border-radius:9px;background:linear-gradient(135deg,rgba(12,48,89,.8),rgba(7,29,55,.75))}.fusion-card strong{font-size:12px}.fusion-card p{color:#36dc94;font-size:11px;margin:5px 0}.fusion-card small{color:#8aa2c0;font-size:10px}pre{margin-top:7px;padding:7px;border-radius:6px;white-space:pre-wrap;word-break:break-all;font-size:8px;color:#6fb8f7;background:#06152a;max-height:90px;overflow:auto}
+</style>

+ 49 - 0
frontend/src/components/rag/RagSourcePanel.vue

@@ -0,0 +1,49 @@
+<script setup>
+import { ServerOutline, DocumentTextOutline, GitNetworkOutline, OptionsOutline } from '@vicons/ionicons5'
+import { NIcon, NSwitch, NInputNumber, NSelect } from 'naive-ui'
+
+const props = defineProps({ enabled: Object, topK: Number, strategy: String, busy: Boolean })
+const emit = defineEmits(['update:enabled', 'update:topK', 'update:strategy'])
+
+const cards = [
+  { key: 'document', title: '文档知识库', subtitle: 'Milvus', detail: 'bge-m3 (dense)  |  BM25 (sparse)', stats: '文档 128 | Chunk 12,480', icon: DocumentTextOutline, tone: 'blue' },
+  { key: 'structured', title: '结构化数据', subtitle: 'H2 / MySQL', detail: '真实 SQL 查询封装', stats: '表 12 | 查询封装', icon: ServerOutline, tone: 'green' },
+  { key: 'graph', title: '知识图谱', subtitle: 'Neo4j', detail: '节点 / 关系 / 路径', stats: '节点 8,420 | 关系 21,300', icon: GitNetworkOutline, tone: 'purple' }
+]
+
+function toggle(key, value) {
+  emit('update:enabled', { ...props.enabled, [key]: value })
+}
+</script>
+
+<template>
+  <section class="panel source-panel">
+    <h2><n-icon><OptionsOutline /></n-icon> 数据源编排 / 检索配置</h2>
+    <div class="source-list">
+      <article v-for="card in cards" :key="card.key" class="source-card" :class="card.tone">
+        <div class="source-icon"><n-icon><component :is="card.icon" /></n-icon></div>
+        <div class="source-copy">
+          <strong>{{ card.title }}</strong>
+          <span>{{ card.subtitle }}</span>
+          <small>{{ card.detail }}</small>
+          <small>{{ card.stats }}</small>
+        </div>
+        <div class="source-toggle">
+          <em>{{ enabled[card.key] ? '已启用' : '已停用' }}</em>
+          <n-switch :value="enabled[card.key]" :disabled="busy" @update:value="toggle(card.key, $event)" />
+        </div>
+      </article>
+    </div>
+    <div class="config-card">
+      <h3>检索配置</h3>
+      <label><span>TopK</span><n-input-number :value="topK" :min="1" :max="20" size="small" @update:value="emit('update:topK', $event)" /></label>
+      <label><span>检索策略</span><n-select :value="strategy" :options="[{label:'Hybrid',value:'hybrid'},{label:'Dense',value:'dense'}]" size="small" @update:value="emit('update:strategy', $event)" /></label>
+      <label><span>父级上下文扩展</span><b>L2</b></label>
+      <label><span>调试模式</span><b class="on">开</b></label>
+    </div>
+  </section>
+</template>
+
+<style scoped>
+.panel{height:100%;padding:18px;border:1px solid rgba(72,142,220,.28);border-radius:12px;background:rgba(4,18,40,.74);overflow:auto}.panel h2{display:flex;align-items:center;gap:9px;font-size:16px;margin-bottom:16px}.panel h2 .n-icon{color:#2aa8ff}.source-list{display:flex;flex-direction:column;gap:10px}.source-card{position:relative;display:grid;grid-template-columns:54px 1fr auto;gap:12px;align-items:start;padding:14px;border:1px solid rgba(58,133,214,.35);border-radius:11px;background:linear-gradient(135deg,rgba(12,43,79,.8),rgba(8,26,50,.72))}.source-card.green{border-color:rgba(30,190,123,.3);background:linear-gradient(135deg,rgba(9,63,57,.7),rgba(7,29,42,.75))}.source-card.purple{border-color:rgba(139,92,246,.32);background:linear-gradient(135deg,rgba(49,34,97,.72),rgba(11,26,51,.75))}.source-icon{width:50px;height:50px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:28px;color:#42b6ff;background:rgba(24,115,220,.22)}.green .source-icon{color:#52e1a2;background:rgba(25,180,116,.2)}.purple .source-icon{color:#b792ff;background:rgba(126,76,220,.2)}.source-copy{display:flex;flex-direction:column;min-width:0}.source-copy strong{font-size:16px}.source-copy span{font-size:14px;margin:2px 0}.source-copy small{color:#8ea6c5;font-size:11px;line-height:1.6}.source-toggle{display:flex;align-items:center;gap:7px}.source-toggle em{font-style:normal;color:#25dc8d;font-size:11px;white-space:nowrap}.config-card{margin-top:14px;padding:15px;border:1px solid rgba(67,131,210,.25);border-radius:10px;background:rgba(9,29,58,.72)}.config-card h3{font-size:14px;margin-bottom:10px}.config-card label{min-height:38px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid rgba(86,134,190,.14);font-size:12px;color:#9db1cc}.config-card label:last-child{border:0}.config-card .n-input-number,.config-card .n-select{width:105px}.config-card b{color:#d6e5f8;font-weight:500}.config-card .on{color:#34d399}
+</style>

+ 12 - 0
frontend/src/router/index.js

@@ -76,6 +76,18 @@ const routes = [
     name: 'GraphSourceManagement',
     component: () => import('../views/knowledge/GraphSourceManagement.vue'),
     meta: { title: '知识图谱' }
+  },
+  {
+    path: '/kb/rag-governance',
+    name: 'RagGovernance',
+    component: () => import('../views/knowledge/RagGovernance.vue'),
+    meta: { title: 'RAG 治理' }
+  },
+  {
+    path: '/kb/rag',
+    name: 'RagWorkbench',
+    component: () => import('../views/knowledge/RagWorkbench.vue'),
+    meta: { title: 'RAG' }
   }
 ]
 

+ 28 - 0
frontend/src/types/rag.js

@@ -0,0 +1,28 @@
+/** @typedef {'DOCUMENT'|'STRUCTURED_DATA'|'GRAPH'} RagSourceType */
+/** @typedef {'pending'|'running'|'success'|'failed'} RagStepStatus */
+
+/**
+ * @typedef {Object} RagEvidence
+ * @property {string=} id
+ * @property {RagSourceType} sourceType
+ * @property {string=} evidenceType
+ * @property {string=} title
+ * @property {string=} content
+ * @property {number=} score
+ * @property {string=} sourceName
+ * @property {Object=} sourceRef
+ * @property {Object=} payload
+ * @property {Object=} metadata
+ */
+
+/**
+ * @typedef {Object} RagStep
+ * @property {'document'|'structured'|'graph'|'kb'|'fusion'} key
+ * @property {string} title
+ * @property {string} description
+ * @property {RagStepStatus} status
+ * @property {string=} time
+ * @property {string=} error
+ */
+
+export const RAG_SOURCE = Object.freeze({ DOCUMENT: 'DOCUMENT', STRUCTURED: 'STRUCTURED_DATA', GRAPH: 'GRAPH' })

+ 250 - 0
frontend/src/views/knowledge/RagGovernance.vue

@@ -0,0 +1,250 @@
+<script setup>
+import { computed, onMounted, reactive, ref } from 'vue'
+import { useMessage } from 'naive-ui'
+import {
+  addRagQueryExampleCandidates, deleteRagQueryExample, getKnowledgeBaseBindings, listRagQueryExamples, refreshRagCapability,
+  suggestRagGovernance, updateKnowledgeBaseBindingConfig, verifyRagQueryExample
+} from '../../api/rag'
+
+const message = useMessage()
+const form = reactive({ sourceType: 'GRAPH', sourceId: '1', description: '' })
+const profile = ref(null)
+const suggestion = ref(null)
+const examples = ref([])
+const bindings = ref([])
+const loading = reactive({ profile: false, suggest: false, apply: false })
+const selectedLabels = ref([])
+const selectedRelationships = ref([])
+const selectedTables = ref([])
+const selectedProperties = reactive({})
+const rulesText = ref('')
+
+const isGraph = computed(() => form.sourceType === 'GRAPH')
+const graph = computed(() => profile.value?.graphSchema || { nodes: [], relationships: [] })
+const sql = computed(() => profile.value?.sqlSchema || { tables: [] })
+const relationshipGroups = computed(() => {
+  const groups = new Map()
+  for (const relation of graph.value.relationships) {
+    const current = groups.get(relation.type) || { type: relation.type, startLabels: [], endLabels: [], properties: {} }
+    current.startLabels = [...new Set([...current.startLabels, ...(relation.startLabels || [])])]
+    current.endLabels = [...new Set([...current.endLabels, ...(relation.endLabels || [])])]
+    Object.assign(current.properties, relation.properties || {})
+    groups.set(relation.type, current)
+  }
+  return [...groups.values()]
+})
+const binding = computed(() => bindings.value.find(item =>
+  item.sourceType === form.sourceType && String(item.sourceId) === String(form.sourceId)))
+
+function clearSelection() {
+  selectedLabels.value = []
+  selectedRelationships.value = []
+  selectedTables.value = []
+  Object.keys(selectedProperties).forEach(key => delete selectedProperties[key])
+}
+
+function currentBindingConfig() {
+  const raw = binding.value?.config || binding.value?.configJson || {}
+  try { return typeof raw === 'string' ? JSON.parse(raw || '{}') : raw }
+  catch { return {} }
+}
+
+function ownerProperties(owner) {
+  const node = graph.value.nodes.find(item => item.label === owner)
+  if (node) return Object.keys(node.properties || {})
+  const relation = relationshipGroups.value.find(item => item.type === owner)
+  return Object.keys(relation?.properties || {})
+}
+
+function updateLabels(values) {
+  selectedLabels.value = values
+  values.forEach(owner => { if (!Array.isArray(selectedProperties[owner])) selectedProperties[owner] = ownerProperties(owner) })
+}
+
+function updateRelationships(values) {
+  selectedRelationships.value = values
+  values.forEach(owner => { if (!Array.isArray(selectedProperties[owner])) selectedProperties[owner] = ownerProperties(owner) })
+}
+
+function updateOwnerProperties(owner, values, ownerType) {
+  selectedProperties[owner] = values
+  if (!values.length) return
+  if (ownerType === 'label' && !selectedLabels.value.includes(owner))
+    selectedLabels.value = [...selectedLabels.value, owner]
+  if (ownerType === 'relationship' && !selectedRelationships.value.includes(owner))
+    selectedRelationships.value = [...selectedRelationships.value, owner]
+}
+
+function initializeFromBinding() {
+  clearSelection()
+  rulesText.value = ''
+  if (!binding.value) return
+  const config = currentBindingConfig()
+  if (isGraph.value) {
+    const actualLabels = graph.value.nodes.map(node => node.label)
+    const actualRelationships = relationshipGroups.value.map(rel => rel.type)
+    selectedLabels.value = Array.isArray(config.allowedLabels) && config.allowedLabels.length
+      ? config.allowedLabels.filter(name => actualLabels.includes(name)) : actualLabels
+    selectedRelationships.value = Array.isArray(config.allowedRelationships) && config.allowedRelationships.length
+      ? config.allowedRelationships.filter(name => actualRelationships.includes(name)) : actualRelationships
+    const configuredProperties = config.allowedProperties || {}
+    ;[...selectedLabels.value, ...selectedRelationships.value].forEach(owner => {
+      selectedProperties[owner] = Array.isArray(configuredProperties[owner])
+        ? configuredProperties[owner].filter(name => ownerProperties(owner).includes(name))
+        : ownerProperties(owner)
+    })
+  } else {
+    const actualTables = sql.value.tables.map(table => table.name)
+    selectedTables.value = Array.isArray(config.allowedTables) && config.allowedTables.length
+      ? config.allowedTables.filter(name => actualTables.includes(name)) : actualTables
+  }
+  rulesText.value = (config.generationRules || config.rules || []).join('\n')
+}
+
+async function loadProfile() {
+  loading.profile = true
+  try {
+    profile.value = await refreshRagCapability(form.sourceType, form.sourceId)
+    examples.value = await listRagQueryExamples(form.sourceType, form.sourceId)
+    bindings.value = await getKnowledgeBaseBindings(1)
+    initializeFromBinding()
+  } catch (error) { message.error(error.message || 'Schema 刷新失败') }
+  finally { loading.profile = false }
+}
+
+async function generateSuggestion() {
+  loading.suggest = true
+  try {
+    suggestion.value = await suggestRagGovernance(form.sourceType, form.sourceId, form.description)
+    const config = suggestion.value?.suggestedConfig || suggestion.value?.config || suggestion.value
+    if (isGraph.value) {
+      selectedLabels.value = config.allowedLabels || selectedLabels.value
+      selectedRelationships.value = config.allowedRelationships || selectedRelationships.value
+      Object.keys(selectedProperties).forEach(key => delete selectedProperties[key])
+      Object.assign(selectedProperties, config.allowedProperties || {})
+    } else selectedTables.value = config.allowedTables || selectedTables.value
+    rulesText.value = (config.generationRules || config.rules || []).join('\n')
+    message.success('已生成建议,请审核后再应用')
+  } catch (error) { message.error(error.message || '建议生成失败') }
+  finally { loading.suggest = false }
+}
+
+function buildConfig() {
+  const original = binding.value?.config || binding.value?.configJson || {}
+  const config = typeof original === 'string' ? JSON.parse(original || '{}') : { ...original }
+  Object.assign(config, {
+    retrievalMode: 'AUTO_GENERATE', topK: config.topK || 5,
+    generationRules: rulesText.value.split('\n').map(v => v.trim()).filter(Boolean)
+  })
+  if (isGraph.value) Object.assign(config, {
+    allowTextToCypher: true, allowedLabels: selectedLabels.value,
+    allowedRelationships: selectedRelationships.value,
+    allowedProperties: Object.fromEntries(Object.entries(selectedProperties).filter(([owner]) =>
+      selectedLabels.value.includes(owner) || selectedRelationships.value.includes(owner)))
+  })
+  else Object.assign(config, { allowTextToSql: true, allowedTables: selectedTables.value })
+  return config
+}
+
+async function applyConfig() {
+  if (!binding.value) return message.error('知识库 1 中没有匹配的数据源绑定')
+  if (isGraph.value && !selectedLabels.value.length) return message.error('至少授权一个 Label,空白名单会造成权限语义不明确')
+  if (!isGraph.value && !selectedTables.value.length) return message.error('至少授权一个 Table,空白名单会造成权限语义不明确')
+  if (isGraph.value) {
+    const orphan = Object.entries(selectedProperties).find(([owner, values]) => values?.length
+      && !selectedLabels.value.includes(owner) && !selectedRelationships.value.includes(owner))
+    if (orphan) return message.error(`${orphan[0]} 已选择属性但未授权所属 Label/Relationship`)
+  }
+  loading.apply = true
+  try {
+    await updateKnowledgeBaseBindingConfig(1, binding.value.id, buildConfig())
+    bindings.value = await getKnowledgeBaseBindings(1)
+    message.success('授权配置已应用')
+  } catch (error) { message.error(error.message || '配置应用失败') }
+  finally { loading.apply = false }
+}
+
+async function setVerified(item, value) {
+  try { Object.assign(item, await verifyRagQueryExample(item.id, value)); message.success(value ? '案例已审核通过' : '案例已取消授权') }
+  catch (error) { message.error(error.message || '案例审核失败') }
+}
+
+async function removeExample(item) {
+  try {
+    await deleteRagQueryExample(item.id)
+    examples.value = examples.value.filter(example => example.id !== item.id)
+    message.success('案例已删除')
+  } catch (error) { message.error(error.message || '案例删除失败') }
+}
+
+async function importCandidates() {
+  const candidates = suggestion.value?.examples || []
+  if (!candidates.length) return message.warning('当前建议中没有可导入案例')
+  try {
+    const result = await addRagQueryExampleCandidates(form.sourceType, form.sourceId, candidates)
+    examples.value = result.examples || []
+    const rejected = result.rejected?.length || 0
+    message[rejected ? 'warning' : 'success'](`已通过预检并导入 ${result.accepted || 0} 条,拒绝 ${rejected} 条`)
+  } catch (error) { message.error(error.message || '案例导入失败') }
+}
+
+onMounted(loadProfile)
+</script>
+
+<template>
+  <div class="page">
+    <header><div><h1>RAG 治理中心</h1><p>根据真实 Schema 建议业务子图,审核授权范围与动态 Few-shot。</p></div></header>
+    <n-card class="toolbar">
+      <n-space align="end">
+        <n-form-item label="数据源类型"><n-select v-model:value="form.sourceType" style="width:180px" :options="[{label:'图数据库',value:'GRAPH'},{label:'结构化数据库',value:'STRUCTURED_DATA'}]" @update:value="loadProfile" /></n-form-item>
+        <n-form-item label="数据源 ID"><n-input v-model:value="form.sourceId" style="width:110px" /></n-form-item>
+        <n-form-item label="业务描述"><n-input v-model:value="form.description" placeholder="例如:东海救援任务、力量与阶段" style="width:360px" /></n-form-item>
+        <n-button :loading="loading.profile" @click="loadProfile">刷新画像</n-button>
+        <n-button type="primary" :loading="loading.suggest" @click="generateSuggestion">生成业务子图与案例</n-button>
+      </n-space>
+    </n-card>
+
+    <div class="metrics" v-if="profile">
+      <n-card size="small"><b>画像版本</b><span>{{ profile.version }}</span></n-card>
+      <n-card size="small"><b>Schema 规模</b><span>{{ isGraph ? `${graph.nodes.length} Labels / ${graph.relationships.length} Relationships` : `${sql.tables.length} Tables` }}</span></n-card>
+      <n-card size="small"><b>语义目录</b><span>{{ profile.semanticCatalog?.length || 0 }} 项已向量化</span></n-card>
+      <n-card size="small"><b>绑定状态</b><span>{{ binding ? '可应用' : '未绑定' }}</span></n-card>
+    </div>
+
+    <div class="columns">
+      <n-card title="授权审核" class="panel">
+        <template v-if="isGraph">
+          <n-collapse :default-expanded-names="['labels']">
+            <n-collapse-item name="labels" :title="`Labels(${selectedLabels.length}/${graph.nodes.length})`">
+              <n-checkbox-group :value="selectedLabels" @update:value="updateLabels"><n-collapse accordion>
+                <n-collapse-item v-for="node in graph.nodes" :key="node.label" :name="`label:${node.label}`">
+                  <template #header><n-checkbox :value="node.label" :label="node.label" @click.stop /></template>
+                  <div class="schema-detail"><span>Properties</span><n-checkbox-group :value="selectedProperties[node.label]" @update:value="values => updateOwnerProperties(node.label, values, 'label')"><n-space><n-checkbox v-for="(_, name) in node.properties" :key="name" :value="name" :label="name" /></n-space></n-checkbox-group><n-empty v-if="!Object.keys(node.properties || {}).length" size="small" description="无属性" /></div>
+                </n-collapse-item>
+              </n-collapse></n-checkbox-group>
+            </n-collapse-item>
+            <n-collapse-item name="relationships" :title="`Relationships(${selectedRelationships.length}/${relationshipGroups.length})`">
+              <n-checkbox-group :value="selectedRelationships" @update:value="updateRelationships"><n-collapse accordion>
+                <n-collapse-item v-for="rel in relationshipGroups" :key="rel.type" :name="`rel:${rel.type}`">
+                  <template #header><n-checkbox :value="rel.type" :label="rel.type" @click.stop /></template>
+                  <div class="schema-detail"><p>{{ rel.startLabels?.join(' | ') }} → {{ rel.endLabels?.join(' | ') }}</p><span>Properties</span><n-checkbox-group :value="selectedProperties[rel.type]" @update:value="values => updateOwnerProperties(rel.type, values, 'relationship')"><n-space><n-checkbox v-for="(_, name) in rel.properties" :key="name" :value="name" :label="name" /></n-space></n-checkbox-group><n-empty v-if="!Object.keys(rel.properties || {}).length" size="small" description="无属性" /></div>
+                </n-collapse-item>
+              </n-collapse></n-checkbox-group>
+            </n-collapse-item>
+          </n-collapse>
+        </template>
+        <template v-else><n-checkbox-group v-model:value="selectedTables"><n-collapse :default-expanded-names="['tables']"><n-collapse-item name="tables" :title="`Tables(${selectedTables.length}/${sql.tables.length})`"><n-collapse accordion><n-collapse-item v-for="table in sql.tables" :key="table.name" :name="`table:${table.name}`"><template #header><n-checkbox :value="table.name" :label="table.name" @click.stop /></template><div class="schema-detail"><p>{{ table.description || '无表说明' }}</p><n-space><n-tag v-for="column in table.columns" :key="column.name" size="small">{{ column.name }} · {{ column.type }}</n-tag></n-space></div></n-collapse-item></n-collapse></n-collapse-item></n-collapse></n-checkbox-group></template>
+        <h3>生成规则(每行一条)</h3><n-input v-model:value="rulesText" type="textarea" :rows="5" placeholder="只生成只读查询&#10;优先使用明确关系而非笛卡尔积" />
+        <n-button type="success" block class="apply" :loading="loading.apply" @click="applyConfig">应用审核后的授权配置</n-button>
+      </n-card>
+
+      <n-card title="建议与画像" class="panel"><n-alert type="info" :show-icon="false">JSON 是建议草稿:suggestedConfig 会同步到左侧表单,点击“应用”后才生效;examples 需点击导入并逐条审核。</n-alert><n-tag v-if="suggestion" type="warning" class="apply">草稿 · 尚未应用</n-tag><n-button v-if="suggestion?.examples?.length" block class="apply" @click="importCandidates">导入为待审核 Few-shot</n-button><pre>{{ JSON.stringify(suggestion || { message: '点击“生成业务子图与案例”获取建议' }, null, 2) }}</pre></n-card>
+
+      <n-card title="动态 Few-shot 审核" class="panel"><n-empty v-if="!examples.length" description="暂无学习案例" /><div v-for="item in examples" :key="item.id" class="example"><div><b>{{ item.question }}</b><code>{{ item.queryText || item.generatedQuery }}</code></div><n-space vertical align="end"><n-switch :value="item.verified" @update:value="value => setVerified(item, value)" /><n-popconfirm positive-text="删除" negative-text="取消" @positive-click="removeExample(item)"><template #trigger><n-button size="tiny" type="error" ghost>删除</n-button></template>删除后无法恢复,确定删除这条 Few-shot 吗?</n-popconfirm></n-space></div></n-card>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.page{padding:24px;min-height:100vh;background:var(--bg-primary);color:var(--text-primary)}header{display:flex;justify-content:space-between;margin-bottom:18px}h1{margin:0 0 6px;font-size:25px}p{margin:0;color:var(--text-secondary)}.toolbar{margin-bottom:16px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px}.metrics :deep(.n-card__content){display:flex;flex-direction:column;gap:8px}.metrics span{color:#38bdf8}.columns{display:grid;grid-template-columns:1.2fr 1fr 1fr;gap:14px;align-items:start}.panel{max-height:calc(100vh - 280px);overflow:auto}.schema-detail{padding:4px 10px 12px 34px}.schema-detail>span{display:block;color:var(--text-secondary);font-size:12px;margin-bottom:8px}.schema-detail p{font-size:12px;color:var(--text-secondary);margin:0 0 10px}.apply{margin-top:18px}pre{white-space:pre-wrap;word-break:break-word;padding:12px;background:#061426;border-radius:8px;color:#93c5fd;font-size:12px}.example{display:flex;justify-content:space-between;gap:12px;padding:12px 0;border-bottom:1px solid var(--border-color)}.example code{display:block;white-space:pre-wrap;margin-top:8px;color:var(--text-secondary)}@media(max-width:1200px){.columns{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}
+</style>

Some files were not shown because too many files changed in this diff