Browse Source

1. 实现了结构化数据 Text2SQL RAG 检索能力,新增 Schema 画像、关系发现、查询上下文构建与 SQL 结果修复链路;
2. 实现了知识图谱 Text2Cypher RAG 检索增强,新增实体 grounding、意图规划、邻域查询与显式/生成 Cypher 处理;
3. 新增了 RAG 结构化画像与图谱治理配置的数据模型和持久化层,支持扫描状态与授权配置的落盘管理;
4. 重构了 RAG AI Bridge 客户端与 Python 服务端,支持基于信号量的模型槽位管理与结构化上下文直调 OpenAI(TODO:此功能待确认合理性);
5. 新增了数据源 RAG 画像接口与刷新端点,前端数据源管理页可查看 Text2SQL 画像状态并触发刷新;
6. 改造了 RAG 工作台数据源选择交互,支持独立选择结构化数据源与图谱数据源并展示画像/就绪度;
7. 简化了 RAG 治理页面为图谱治理专用页,接入图数据源列表与独立的治理/就绪度 API;更新了左侧导航与路由标题,将「RAG 治理」统一更名为「图谱治理」;
8. 更新了 prompt.md 需求记录,补充 Text2SQL/Text2Cypher 相关能力说明。

weisijie 1 month ago
parent
commit
abd9fbdac0
46 changed files with 3745 additions and 392 deletions
  1. 111 89
      backend/rag-ai-bridge/server.py
  2. 12 1
      backend/src/main/java/com/agent/management/config/KbProperties.java
  3. 21 2
      backend/src/main/java/com/agent/management/controller/DataSourceController.java
  4. 35 0
      backend/src/main/java/com/agent/management/model/entity/RagGraphGovernanceConfigEntity.java
  5. 56 0
      backend/src/main/java/com/agent/management/model/entity/RagStructuredSchemaProfileEntity.java
  6. 48 6
      backend/src/main/java/com/agent/management/rag/bridge/RagAiBridgeClient.java
  7. 56 16
      backend/src/main/java/com/agent/management/rag/bridge/RagAiBridgeProcessManager.java
  8. 22 5
      backend/src/main/java/com/agent/management/rag/capability/GraphBusinessSubgraphSelector.java
  9. 124 0
      backend/src/main/java/com/agent/management/rag/capability/GraphGovernanceConfigService.java
  10. 38 1
      backend/src/main/java/com/agent/management/rag/capability/GraphOnboardingAssessmentService.java
  11. 3 1
      backend/src/main/java/com/agent/management/rag/capability/RagEntityMentionExtractor.java
  12. 12 0
      backend/src/main/java/com/agent/management/rag/controller/RagCapabilityController.java
  13. 133 0
      backend/src/main/java/com/agent/management/rag/graph/GraphEntityGroundingService.java
  14. 77 0
      backend/src/main/java/com/agent/management/rag/graph/GraphEntityNeighborhoodQueryBuilder.java
  15. 520 0
      backend/src/main/java/com/agent/management/rag/graph/GraphQueryIntentPlanner.java
  16. 53 16
      backend/src/main/java/com/agent/management/rag/graph/GraphRagRetriever.java
  17. 8 0
      backend/src/main/java/com/agent/management/rag/graph/GraphSchemaValidator.java
  18. 105 20
      backend/src/main/java/com/agent/management/rag/graph/Neo4jGraphRagCypherGenerationService.java
  19. 67 5
      backend/src/main/java/com/agent/management/rag/kb/KnowledgeBaseRagRetriever.java
  20. 1 2
      backend/src/main/java/com/agent/management/rag/kb/RagKnowledgeBaseConfigService.java
  21. 310 36
      backend/src/main/java/com/agent/management/rag/structured/StructuredDataRagRetriever.java
  22. 52 0
      backend/src/main/java/com/agent/management/rag/structured/StructuredQueryContext.java
  23. 227 0
      backend/src/main/java/com/agent/management/rag/structured/StructuredQueryContextService.java
  24. 36 0
      backend/src/main/java/com/agent/management/rag/structured/StructuredRelationship.java
  25. 340 0
      backend/src/main/java/com/agent/management/rag/structured/StructuredRelationshipDiscoveryService.java
  26. 33 0
      backend/src/main/java/com/agent/management/rag/structured/StructuredSchemaProfile.java
  27. 405 0
      backend/src/main/java/com/agent/management/rag/structured/StructuredSchemaProfileService.java
  28. 216 0
      backend/src/main/java/com/agent/management/rag/structured/StructuredSqlRelationshipValidator.java
  29. 167 0
      backend/src/main/java/com/agent/management/rag/structured/StructuredValueSampler.java
  30. 82 50
      backend/src/main/java/com/agent/management/rag/structured/VannaSqlGenerationService.java
  31. 12 0
      backend/src/main/java/com/agent/management/repository/RagGraphGovernanceConfigRepository.java
  32. 13 0
      backend/src/main/java/com/agent/management/repository/RagStructuredSchemaProfileRepository.java
  33. 12 4
      backend/src/main/java/com/agent/management/service/Neo4jExecutorService.java
  34. 12 2
      backend/src/main/java/com/agent/management/service/impl/DataSourceServiceImpl.java
  35. 17 2
      backend/src/main/java/com/agent/management/service/impl/DocumentServiceImpl.java
  36. 11 2
      backend/src/main/java/com/agent/management/service/impl/GraphSourceServiceImpl.java
  37. 8 0
      frontend/src/api/datasource.js
  38. 45 38
      frontend/src/api/rag.js
  39. 1 1
      frontend/src/components/layout/AppSidebar.vue
  40. 3 1
      frontend/src/components/rag/RagEvidencePanel.vue
  41. 35 8
      frontend/src/components/rag/RagSourcePanel.vue
  42. 1 1
      frontend/src/router/index.js
  43. 36 2
      frontend/src/views/knowledge/DataSourceManagement.vue
  44. 74 77
      frontend/src/views/knowledge/RagGovernance.vue
  45. 83 4
      frontend/src/views/knowledge/RagWorkbench.vue
  46. 12 0
      prompt.md

+ 111 - 89
backend/rag-ai-bridge/server.py

@@ -22,7 +22,7 @@ DEFAULTS = {
         "base_url": "https://api.openai.com/v1",
         "base_url": "https://api.openai.com/v1",
         "temperature": 1.0,
         "temperature": 1.0,
         "timeout": 60,
         "timeout": 60,
-        "generation_max_tokens": 2048,
+        "generation_max_tokens": 8192,
         "answer_max_tokens": 4096,
         "answer_max_tokens": 4096,
     },
     },
     "vanna": {"enabled": True},
     "vanna": {"enabled": True},
@@ -96,9 +96,16 @@ class Text2SqlRequest(BaseModel):
     datasourceId: int
     datasourceId: int
     dialect: str = "mysql"
     dialect: str = "mysql"
     ddl: str = ""
     ddl: str = ""
+    schemaVersion: str = ""
     documentation: str = ""
     documentation: str = ""
     examples: list[Any] = Field(default_factory=list)
     examples: list[Any] = Field(default_factory=list)
     tableWhitelist: list[str] = Field(default_factory=list)
     tableWhitelist: list[str] = Field(default_factory=list)
+    selectedTables: list[str] = Field(default_factory=list)
+    foreignKeys: list[dict[str, Any]] = Field(default_factory=list)
+    relationships: list[dict[str, Any]] = Field(default_factory=list)
+    sampledValues: dict[str, list[Any]] = Field(default_factory=dict)
+    referenceRows: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
+    contextWarnings: list[str] = Field(default_factory=list)
     maxRows: int = Field(default=SETTINGS["security"]["default_limit"], ge=1, le=1000)
     maxRows: int = Field(default=SETTINGS["security"]["default_limit"], ge=1, le=1000)
     entityMentions: list[str] = Field(default_factory=list)
     entityMentions: list[str] = Field(default_factory=list)
 
 
@@ -139,6 +146,12 @@ class RepairRequest(BaseModel):
     query: str
     query: str
     error: str
     error: str
     schemaText: str
     schemaText: str
+    dialect: str = ""
+    selectedTables: list[str] = Field(default_factory=list)
+    foreignKeys: list[dict[str, Any]] = Field(default_factory=list)
+    relationships: list[dict[str, Any]] = Field(default_factory=list)
+    sampledValues: dict[str, list[Any]] = Field(default_factory=dict)
+    referenceRows: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
     maxRows: int = 50
     maxRows: int = 50
     maxDepth: int = 3
     maxDepth: int = 3
 
 
@@ -186,7 +199,21 @@ def _neo4j_configured() -> bool:
 def _openai_client(timeout: int | None = None):
 def _openai_client(timeout: int | None = None):
     from openai import OpenAI
     from openai import OpenAI
     return OpenAI(api_key=os.environ["OPENAI_API_KEY"], base_url=SETTINGS["llm"]["base_url"],
     return OpenAI(api_key=os.environ["OPENAI_API_KEY"], base_url=SETTINGS["llm"]["base_url"],
-                  timeout=timeout or SETTINGS["llm"]["timeout"], max_retries=0)
+                  timeout=_client_timeout(timeout), max_retries=0)
+
+
+def _client_timeout(timeout: int | None = None):
+    configured = SETTINGS["llm"]["timeout"] if timeout is None else timeout
+    return None if configured is None or configured <= 0 else configured
+
+
+def _completion_content(response) -> str:
+    choice = response.choices[0]
+    content = choice.message.content or ""
+    if content.strip():
+        return content
+    finish_reason = getattr(choice, "finish_reason", None) or "unknown"
+    raise ValueError(f"model returned empty content (finish_reason={finish_reason})")
 
 
 
 
 def _json_object(text: str) -> dict:
 def _json_object(text: str) -> dict:
@@ -203,6 +230,45 @@ def _short_error(error: str) -> str:
     return re.sub(r"(?i)(password|token|api[_ -]?key)\s*[:=]\s*\S+", r"\1=[redacted]", error)[:800]
     return re.sub(r"(?i)(password|token|api[_ -]?key)\s*[:=]\s*\S+", r"\1=[redacted]", error)[:800]
 
 
 
 
+def _text2sql_messages(req: Text2SqlRequest) -> list[dict[str, str]]:
+    if req.dialect.lower() != "mysql":
+        raise ValueError("Text2SQL generation currently supports MySQL only")
+    context = {
+        "dialect": "mysql",
+        "schemaVersion": req.schemaVersion,
+        "selectedTables": req.selectedTables,
+        "minimalDdl": req.ddl,
+        "foreignKeys": req.foreignKeys,
+        "relationships": req.relationships,
+        "sampledValues": req.sampledValues,
+        "referenceRows": req.referenceRows,
+        "entityMentions": req.entityMentions,
+        "generationRules": req.documentation,
+    }
+    return [{
+        "role": "system",
+        "content": (
+            "You generate exactly one final read-only MySQL SQL query. "
+            "Return SQL only, without comments, explanation or intermediate queries. "
+            "Use only tables and columns in minimalDdl and JOIN only through explicitly provided relationships. "
+            "A CONDITIONAL relationship requires every listed condition in the JOIN or WHERE clause. "
+            "Filter literals must be grounded in sampledValues/referenceRows. "
+            "Never invent business mappings, identifiers, columns, tables, values, joins or answers. "
+            "Preserve conjunctions: a request for both A and B must require both, normally with GROUP BY/HAVING or equivalent logic. "
+            "For highest/best by a stated score, ORDER BY that score DESC; never replace it with nearest distance. "
+            "Use MySQL syntax only; DISTINCT ON is forbidden. Never generate DML or DDL. "
+            f"Limit results to at most {req.maxRows} rows."
+        ),
+    }, {
+        "role": "user",
+        "content": (
+            "Verified query context (JSON):\n"
+            + json.dumps(context, ensure_ascii=False, default=str)
+            + f"\nQuestion: {req.query}"
+        ),
+    }]
+
+
 @app.get("/health")
 @app.get("/health")
 def health():
 def health():
     return {
     return {
@@ -221,88 +287,15 @@ def text2sql(req: Text2SqlRequest):
     if not _llm_configured():
     if not _llm_configured():
         raise HTTPException(status_code=503, detail="OpenAI provider or OPENAI_API_KEY is not configured")
         raise HTTPException(status_code=503, detail="OpenAI provider or OPENAI_API_KEY is not configured")
     try:
     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": []}
+        response = _openai_client().chat.completions.create(
+            model=SETTINGS["llm"]["model"], messages=_text2sql_messages(req),
+            temperature=SETTINGS["llm"]["temperature"],
+            max_tokens=SETTINGS["llm"]["generation_max_tokens"],
+        )
+        sql = _read_only(_extract_code(_completion_content(response), "SQL"), "SQL")
+        return {"sql": sql, "confidence": 0.0,
+                "usedContext": ["minimalDdl", "relationships", "sampledValues", "referenceRows"],
+                "warnings": req.contextWarnings}
     except HTTPException:
     except HTTPException:
         raise
         raise
     except Exception as exc:
     except Exception as exc:
@@ -321,7 +314,7 @@ def text2cypher(req: Text2CypherRequest):
         llm = OpenAI(
         llm = OpenAI(
             api_key=os.environ["OPENAI_API_KEY"],
             api_key=os.environ["OPENAI_API_KEY"],
             base_url=SETTINGS["llm"]["base_url"],
             base_url=SETTINGS["llm"]["base_url"],
-            timeout=SETTINGS["llm"]["timeout"],
+            timeout=_client_timeout(),
             max_retries=0,
             max_retries=0,
         )
         )
         prompt = (
         prompt = (
@@ -348,8 +341,16 @@ def text2cypher(req: Text2CypherRequest):
             temperature=SETTINGS["llm"]["temperature"],
             temperature=SETTINGS["llm"]["temperature"],
             max_tokens=SETTINGS["llm"]["generation_max_tokens"],
             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": []}
+        candidate = _extract_code(response.choices[0].message.content or "", "Cypher")
+        warnings = []
+        try:
+            cypher = _read_only(candidate, "Cypher")
+        except ValueError as exc:
+            # The bridge only generates candidates. Java performs the authoritative
+            # schema validation, read-only Guard and the single allowed repair before execution.
+            cypher = candidate
+            warnings.append(str(exc))
+        return {"cypher": cypher, "confidence": 0.0, "usedSchema": req.allowedLabels, "warnings": warnings}
     except HTTPException:
     except HTTPException:
         raise
         raise
     except Exception as exc:
     except Exception as exc:
@@ -366,7 +367,7 @@ def answer(req: AnswerRequest):
         client = OpenAI(
         client = OpenAI(
             api_key=os.environ["OPENAI_API_KEY"],
             api_key=os.environ["OPENAI_API_KEY"],
             base_url=SETTINGS["llm"]["base_url"],
             base_url=SETTINGS["llm"]["base_url"],
-            timeout=SETTINGS["llm"]["timeout"],
+            timeout=_client_timeout(),
             max_retries=0,
             max_retries=0,
         )
         )
         evidence_json = json.dumps(req.evidences[:20], ensure_ascii=False, default=str)
         evidence_json = json.dumps(req.evidences[:20], ensure_ascii=False, default=str)
@@ -422,7 +423,28 @@ Question: {req.question}\nCapabilities: {json.dumps(req.capabilities, ensure_asc
 @app.post("/repair")
 @app.post("/repair")
 def repair(req: RepairRequest):
 def repair(req: RepairRequest):
     language = req.language.upper()
     language = req.language.upper()
-    prompt = f"""Repair this read-only {language} query after EXPLAIN failed. Return only the corrected query.
+    if language == "SQL":
+        context = json.dumps({
+            "dialect": req.dialect or "mysql",
+            "selectedTables": req.selectedTables,
+            "minimalDdl": req.schemaText,
+            "foreignKeys": req.foreignKeys,
+            "relationships": req.relationships,
+            "sampledValues": req.sampledValues,
+            "referenceRows": req.referenceRows,
+        }, ensure_ascii=False, default=str)
+        prompt = f"""Repair this read-only MySQL query after EXPLAIN failed. Return only one corrected SQL statement.
+Use only tables and columns in minimalDdl, only explicitly provided relationships, and only sampled/reference values.
+Every CONDITIONAL relationship must include all listed conditions. Avoid cartesian products.
+Never invent business mappings, schema identifiers, values or answers. Preserve AND semantics for simultaneous conditions.
+For highest/best by a stated score use ORDER BY that score DESC. Do not use PostgreSQL DISTINCT ON.
+Question: {req.question}
+Verified context: {context}
+Failed query: {req.query}
+Error: {_short_error(req.error)}
+Maximum rows: {req.maxRows}."""
+    else:
+        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.
 Use only the supplied schema. Make exactly one statement. Do not use write operations.
 The failed query used a label or relationship type that is NOT in the Schema below — open the Schema, find the closest matching entry, and copy its name EXACTLY (preserve uppercase, underscore, spelling).
 The failed query used a label or relationship type that is NOT in the Schema below — open the Schema, find the closest matching entry, and copy its name EXACTLY (preserve uppercase, underscore, spelling).
 NEVER invent, translate, pluralize, or guess labels from the question text; every label/relationship MUST appear verbatim in the Schema.
 NEVER invent, translate, pluralize, or guess labels from the question text; every label/relationship MUST appear verbatim in the Schema.

+ 12 - 1
backend/src/main/java/com/agent/management/config/KbProperties.java

@@ -43,5 +43,16 @@ public class KbProperties {
     /**
     /**
      * 允许上传的 MIME 类型白名单
      * 允许上传的 MIME 类型白名单
      */
      */
-    private List<String> allowedMimeTypes = new ArrayList<>();
+    private List<String> allowedMimeTypes = new ArrayList<>(List.of(
+            "application/pdf",
+            "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+            "application/msword",
+            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+            "application/vnd.ms-excel",
+            "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+            "application/vnd.ms-powerpoint",
+            "text/plain",
+            "text/markdown",
+            "text/html"
+    ));
 }
 }

+ 21 - 2
backend/src/main/java/com/agent/management/controller/DataSourceController.java

@@ -20,6 +20,7 @@ import java.util.List;
 public class DataSourceController {
 public class DataSourceController {
 
 
     private final DataSourceService dataSourceService;
     private final DataSourceService dataSourceService;
+    private final com.agent.management.rag.structured.StructuredSchemaProfileService structuredProfiles;
 
 
     @GetMapping
     @GetMapping
     public Result<List<DataSource>> list() {
     public Result<List<DataSource>> list() {
@@ -33,20 +34,38 @@ public class DataSourceController {
 
 
     @PostMapping
     @PostMapping
     public Result<DataSource> create(@Valid @RequestBody DataSource ds) {
     public Result<DataSource> create(@Valid @RequestBody DataSource ds) {
-        return Result.success(dataSourceService.create(ds));
+        DataSource saved = dataSourceService.create(ds);
+        structuredProfiles.prewarm(saved.getId());
+        return Result.success(saved);
     }
     }
 
 
     @PutMapping("/{id}")
     @PutMapping("/{id}")
     public Result<DataSource> update(@PathVariable Long id, @RequestBody DataSource ds) {
     public Result<DataSource> update(@PathVariable Long id, @RequestBody DataSource ds) {
-        return Result.success(dataSourceService.update(id, ds));
+        DataSource saved = dataSourceService.update(id, ds);
+        structuredProfiles.refreshAsync(id);
+        return Result.success(saved);
     }
     }
 
 
     @DeleteMapping("/{id}")
     @DeleteMapping("/{id}")
     public Result<Void> delete(@PathVariable Long id) {
     public Result<Void> delete(@PathVariable Long id) {
+        structuredProfiles.deleteProfiles(id);
         dataSourceService.delete(id);
         dataSourceService.delete(id);
         return Result.success(null);
         return Result.success(null);
     }
     }
 
 
+    @GetMapping("/{id}/rag-profile")
+    public Result<java.util.Map<String,Object>> ragProfile(@PathVariable Long id) {
+        dataSourceService.get(id);
+        return Result.success(structuredProfiles.status(id));
+    }
+
+    @PostMapping("/{id}/rag-profile/refresh")
+    public Result<java.util.Map<String,Object>> refreshRagProfile(@PathVariable Long id) {
+        dataSourceService.get(id);
+        structuredProfiles.refreshAsync(id);
+        return Result.success(structuredProfiles.status(id));
+    }
+
     /**
     /**
      * 测试连接(不持久化,可用于新增/编辑前的预检)
      * 测试连接(不持久化,可用于新增/编辑前的预检)
      */
      */

+ 35 - 0
backend/src/main/java/com/agent/management/model/entity/RagGraphGovernanceConfigEntity.java

@@ -0,0 +1,35 @@
+package com.agent.management.model.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.Data;
+
+import java.time.Instant;
+
+@Data
+@Entity
+@Table(name = "rag_graph_governance_config",
+        uniqueConstraints = @UniqueConstraint(name = "uk_rag_graph_governance_source",
+                columnNames = "graph_source_id"))
+public class RagGraphGovernanceConfigEntity {
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    @Column(name = "graph_source_id", nullable = false)
+    private Long graphSourceId;
+
+    @Column(name = "config_json", nullable = false, columnDefinition = "CLOB")
+    private String configJson;
+
+    @Column(name = "schema_version", nullable = false, length = 64)
+    private String schemaVersion;
+
+    @Column(name = "updated_at", nullable = false)
+    private Instant updatedAt;
+}

+ 56 - 0
backend/src/main/java/com/agent/management/model/entity/RagStructuredSchemaProfileEntity.java

@@ -0,0 +1,56 @@
+package com.agent.management.model.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.Data;
+
+import java.time.Instant;
+
+@Data
+@Entity
+@Table(name = "rag_structured_schema_profile",
+        uniqueConstraints = @UniqueConstraint(name = "uk_rag_structured_profile_source",
+                columnNames = {"data_source_id", "catalog_name", "schema_name"}),
+        indexes = @Index(name = "idx_rag_structured_profile_fingerprint",
+                columnList = "data_source_id,source_fingerprint,schema_fingerprint,algorithm_version"))
+public class RagStructuredSchemaProfileEntity {
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    @Column(name = "data_source_id", nullable = false)
+    private Long dataSourceId;
+
+    @Column(name = "catalog_name", nullable = false, length = 200)
+    private String catalogName = "";
+
+    @Column(name = "schema_name", nullable = false, length = 200)
+    private String schemaName = "";
+
+    @Column(name = "profile_json", nullable = false, columnDefinition = "CLOB")
+    private String profileJson;
+
+    @Column(name = "source_fingerprint", nullable = false, length = 64)
+    private String sourceFingerprint;
+
+    @Column(name = "schema_fingerprint", nullable = false, length = 64)
+    private String schemaFingerprint;
+
+    @Column(name = "algorithm_version", nullable = false, length = 40)
+    private String algorithmVersion;
+
+    @Column(name = "scan_status", nullable = false, length = 20)
+    private String scanStatus;
+
+    @Column(name = "scanned_at", nullable = false)
+    private Instant scannedAt;
+
+    @Column(name = "last_error", length = 1000)
+    private String lastError;
+}

+ 48 - 6
backend/src/main/java/com/agent/management/rag/bridge/RagAiBridgeClient.java

@@ -5,28 +5,70 @@ import org.springframework.http.client.SimpleClientHttpRequestFactory;
 import org.springframework.stereotype.Component;
 import org.springframework.stereotype.Component;
 import org.springframework.web.client.RestTemplate;
 import org.springframework.web.client.RestTemplate;
 import java.util.*;
 import java.util.*;
+import java.time.Duration;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
 
 
 @Component
 @Component
 public class RagAiBridgeClient {
 public class RagAiBridgeClient {
     private final RagAiBridgeProperties properties;
     private final RagAiBridgeProperties properties;
     private final RestTemplate rest;
     private final RestTemplate rest;
+    private final RestTemplate healthRest;
+    private final Semaphore modelSlots = new Semaphore(1, true);
     public RagAiBridgeClient(RagAiBridgeProperties properties) {
     public RagAiBridgeClient(RagAiBridgeProperties properties) {
         this.properties=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);
+        this.rest = rest(timeoutMillis(properties.getTimeout()));
+        this.healthRest = rest(2000);
     }
     }
-    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 boolean health(){ if(!properties.isEnabled()) return false; try{return Boolean.TRUE.equals(healthRest.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> 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> 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> 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> 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> plan(Map<String,Object> request){return post("/plan",request);}
     public Map<String,Object> repair(Map<String,Object> request){return post("/repair",request);}
     public Map<String,Object> repair(Map<String,Object> request){return post("/repair",request);}
+    public Map<String,Object> repair(Map<String,Object> request, Duration timeout){return post("/repair",request,timeout);}
     @SuppressWarnings("unchecked") private Map<String,Object> post(String path, Object body){
     @SuppressWarnings("unchecked") private Map<String,Object> post(String path, Object body){
+        return post(path, body, properties.getTimeout());
+    }
+    @SuppressWarnings("unchecked") private Map<String,Object> post(String path, Object body, Duration requestTimeout){
         if(!properties.isEnabled()) throw new IllegalStateException("rag-ai-bridge is disabled");
         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();
+        boolean acquired = false;
+        try {
+            long timeoutMillis = effectiveTimeoutMillis(properties.getTimeout(), requestTimeout);
+            if (timeoutMillis <= 0) {
+                modelSlots.acquire();
+                acquired = true;
+            } else {
+                acquired = modelSlots.tryAcquire(timeoutMillis, TimeUnit.MILLISECONDS);
+                if (!acquired) throw new IllegalStateException("RAG model queue wait timed out");
+            }
+            RestTemplate client = timeoutMillis == timeoutMillis(properties.getTimeout()) ? rest : rest(timeoutMillis);
+            var response=client.postForEntity(url(path),body,Map.class);
+            return response.getBody()==null?Map.of():response.getBody();
+        } catch (InterruptedException error) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException("RAG model request was interrupted", error);
+        } finally {
+            if (acquired) modelSlots.release();
+        }
+    }
+    private RestTemplate rest(long timeoutMillis) {
+        var factory = new SimpleClientHttpRequestFactory();
+        int timeout = timeoutMillis <= 0 ? 0 : (int) Math.min(Integer.MAX_VALUE, timeoutMillis);
+        factory.setConnectTimeout(timeout);
+        factory.setReadTimeout(timeout);
+        return new RestTemplate(factory);
+    }
+    private static long effectiveTimeoutMillis(Duration globalTimeout, Duration requestTimeout) {
+        long global = timeoutMillis(globalTimeout);
+        long request = timeoutMillis(requestTimeout);
+        if (global <= 0) return request;
+        if (request <= 0) return global;
+        return Math.min(global, request);
+    }
+    private static long timeoutMillis(Duration timeout) {
+        return timeout == null || timeout.isZero() || timeout.isNegative() ? 0 : timeout.toMillis();
     }
     }
     private String url(String path){return "http://"+properties.getHost()+":"+properties.getPort()+path;}
     private String url(String path){return "http://"+properties.getHost()+":"+properties.getPort()+path;}
 }
 }

+ 56 - 16
backend/src/main/java/com/agent/management/rag/bridge/RagAiBridgeProcessManager.java

@@ -1,6 +1,8 @@
 package com.agent.management.rag.bridge;
 package com.agent.management.rag.bridge;
 
 
 import com.agent.management.config.RagAiBridgeProperties;
 import com.agent.management.config.RagAiBridgeProperties;
+import com.agent.management.model.entity.AiModel;
+import com.agent.management.service.AiModelService;
 import jakarta.annotation.PostConstruct;
 import jakarta.annotation.PostConstruct;
 import jakarta.annotation.PreDestroy;
 import jakarta.annotation.PreDestroy;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
@@ -34,6 +36,7 @@ public class RagAiBridgeProcessManager {
 
 
     private final RagAiBridgeProperties properties;
     private final RagAiBridgeProperties properties;
     private final RagAiBridgeClient client;
     private final RagAiBridgeClient client;
+    private final AiModelService aiModelService;
     /** spring.ai.openai.* 中的 LLM 配置,通过环境变量传给 Bridge 子进程 */
     /** spring.ai.openai.* 中的 LLM 配置,通过环境变量传给 Bridge 子进程 */
     private final String llmBaseUrl;
     private final String llmBaseUrl;
     private final String llmApiKey;
     private final String llmApiKey;
@@ -49,12 +52,14 @@ public class RagAiBridgeProcessManager {
     public RagAiBridgeProcessManager(
     public RagAiBridgeProcessManager(
             RagAiBridgeProperties properties,
             RagAiBridgeProperties properties,
             RagAiBridgeClient client,
             RagAiBridgeClient client,
+            AiModelService aiModelService,
             @Value("${spring.ai.openai.base-url:}") String llmBaseUrl,
             @Value("${spring.ai.openai.base-url:}") String llmBaseUrl,
             @Value("${spring.ai.openai.api-key:}") String llmApiKey,
             @Value("${spring.ai.openai.api-key:}") String llmApiKey,
             @Value("${spring.ai.openai.chat.options.model:}") String llmModel,
             @Value("${spring.ai.openai.chat.options.model:}") String llmModel,
             @Value("${spring.ai.openai.chat.options.temperature:0.3}") String llmTemperature) {
             @Value("${spring.ai.openai.chat.options.temperature:0.3}") String llmTemperature) {
         this.properties = properties;
         this.properties = properties;
         this.client = client;
         this.client = client;
+        this.aiModelService = aiModelService;
         this.llmBaseUrl = llmBaseUrl;
         this.llmBaseUrl = llmBaseUrl;
         this.llmApiKey = llmApiKey;
         this.llmApiKey = llmApiKey;
         this.llmModel = llmModel;
         this.llmModel = llmModel;
@@ -64,12 +69,19 @@ public class RagAiBridgeProcessManager {
     @PostConstruct
     @PostConstruct
     public void start() {
     public void start() {
         log.info("[RagAiBridge] 启动子进程...");
         log.info("[RagAiBridge] 启动子进程...");
-        starting.set(true);
+        if (!starting.compareAndSet(false, true)) return;
 
 
         try {
         try {
+            if (client.health()) {
+                log.info("[RagAiBridge] existing healthy instance detected; reusing it");
+                starting.set(false);
+                startHealthCheck();
+                return;
+            }
             String scriptPath = resolveScriptPath();
             String scriptPath = resolveScriptPath();
             String pythonPath = properties.getPythonPath();
             String pythonPath = properties.getPythonPath();
             int port = properties.getPort();
             int port = properties.getPort();
+            LlmConfig llm = resolveLlmConfig();
 
 
             // server.py 不支持 --port/--host 命令行参数,全部通过环境变量传
             // server.py 不支持 --port/--host 命令行参数,全部通过环境变量传
             ProcessBuilder pb = new ProcessBuilder(pythonPath, scriptPath);
             ProcessBuilder pb = new ProcessBuilder(pythonPath, scriptPath);
@@ -82,27 +94,29 @@ public class RagAiBridgeProcessManager {
             env.put("PYTHONUTF8", "1");
             env.put("PYTHONUTF8", "1");
             env.put("RAG_AI_BRIDGE_HOST", properties.getHost());
             env.put("RAG_AI_BRIDGE_HOST", properties.getHost());
             env.put("RAG_AI_BRIDGE_PORT", String.valueOf(port));
             env.put("RAG_AI_BRIDGE_PORT", String.valueOf(port));
+            env.put("LLM_TIMEOUT", String.valueOf(properties.getTimeout().toSeconds()));
 
 
             // LLM 配置注入:从 spring.ai.openai.* 读取,实现 application.yml 单一配置源
             // LLM 配置注入:从 spring.ai.openai.* 读取,实现 application.yml 单一配置源
-            if (llmBaseUrl != null && !llmBaseUrl.isBlank()) {
-                env.put("OPENAI_BASE_URL", llmBaseUrl);
+            if (llm.baseUrl() != null && !llm.baseUrl().isBlank()) {
+                env.put("OPENAI_BASE_URL", llm.baseUrl());
             }
             }
-            if (llmApiKey != null && !llmApiKey.isBlank()) {
-                env.put("OPENAI_API_KEY", llmApiKey);
+            if (llm.apiKey() != null && !llm.apiKey().isBlank()) {
+                env.put("OPENAI_API_KEY", llm.apiKey());
             }
             }
-            if (llmModel != null && !llmModel.isBlank()) {
-                env.put("OPENAI_MODEL", llmModel);
+            if (llm.model() != null && !llm.model().isBlank()) {
+                env.put("OPENAI_MODEL", llm.model());
             }
             }
-            if (llmTemperature != null && !llmTemperature.isBlank()) {
-                env.put("LLM_TEMPERATURE", llmTemperature);
+            if (llm.temperature() != null && !llm.temperature().isBlank()) {
+                env.put("LLM_TEMPERATURE", llm.temperature());
             }
             }
 
 
             // 日志中不输出 base_url / model / api_key 详情,避免敏感信息泄露
             // 日志中不输出 base_url / model / api_key 详情,避免敏感信息泄露
-            log.info("[RagAiBridge] 环境变量已注入: BASE_URL={}, MODEL={}, API_KEY={}, TEMPERATURE={}",
-                    (llmBaseUrl != null && !llmBaseUrl.isBlank() ? "已设置" : "未设置"),
-                    (llmModel != null && !llmModel.isBlank() ? "已设置" : "未设置"),
-                    (llmApiKey != null && !llmApiKey.isBlank() ? "已设置" : "未设置"),
-                    llmTemperature);
+            log.info("[RagAiBridge] 使用{}模型配置,BASE_URL={},MODEL={},API_KEY={},TEMPERATURE={}",
+                    llm.source(),
+                    (llm.baseUrl() != null && !llm.baseUrl().isBlank() ? "已设置" : "未设置"),
+                    (llm.model() != null && !llm.model().isBlank() ? "已设置" : "未设置"),
+                    (llm.apiKey() != null && !llm.apiKey().isBlank() ? "已设置" : "未设置"),
+                    llm.temperature());
 
 
             // 设置工作目录为项目根目录
             // 设置工作目录为项目根目录
             File projectRoot = new File(System.getProperty("user.dir"));
             File projectRoot = new File(System.getProperty("user.dir"));
@@ -174,13 +188,13 @@ public class RagAiBridgeProcessManager {
      */
      */
     public boolean isReady() {
     public boolean isReady() {
         if (starting.get()) return false;
         if (starting.get()) return false;
-        if (process == null || !process.isAlive()) return false;
         return client.health();
         return client.health();
     }
     }
 
 
     private void startHealthCheck() {
     private void startHealthCheck() {
         int interval = properties.getHealthCheckInterval();
         int interval = properties.getHealthCheckInterval();
         if (interval <= 0) return;
         if (interval <= 0) return;
+        if (healthScheduler != null && !healthScheduler.isShutdown()) return;
 
 
         healthScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
         healthScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
             Thread t = new Thread(r, "rag-ai-bridge-health-check");
             Thread t = new Thread(r, "rag-ai-bridge-health-check");
@@ -190,7 +204,7 @@ public class RagAiBridgeProcessManager {
 
 
         healthScheduler.scheduleAtFixedRate(() -> {
         healthScheduler.scheduleAtFixedRate(() -> {
             if (stopped) return;
             if (stopped) return;
-            if (process != null && !process.isAlive()) {
+            if ((process == null || !process.isAlive()) && !client.health()) {
                 log.warn("[RagAiBridge] 子进程已退出,尝试重启...");
                 log.warn("[RagAiBridge] 子进程已退出,尝试重启...");
                 start();
                 start();
             }
             }
@@ -208,4 +222,30 @@ public class RagAiBridgeProcessManager {
         }
         }
         return file.getAbsolutePath();
         return file.getAbsolutePath();
     }
     }
+
+    private LlmConfig resolveLlmConfig() {
+        if (hasText(llmApiKey) && !isPlaceholder(llmApiKey) && hasText(llmModel)) {
+            return new LlmConfig(llmBaseUrl, llmApiKey, llmModel, llmTemperature, "application.yml");
+        }
+        for (AiModel model : aiModelService.listModels()) {
+            if (hasText(model.getBaseUrl()) && hasText(model.getApiKey()) && hasText(model.getModelName())) {
+                String temperature = model.getTemperature() == null
+                        ? llmTemperature : String.valueOf(model.getTemperature());
+                return new LlmConfig(model.getBaseUrl(), model.getApiKey(), model.getModelName(),
+                        temperature, "模型管理");
+            }
+        }
+        throw new IllegalStateException("RAG AI Bridge 未找到可用的大模型配置");
+    }
+
+    private static boolean hasText(String value) {
+        return value != null && !value.isBlank();
+    }
+
+    private static boolean isPlaceholder(String value) {
+        return value != null && value.toLowerCase().contains("placeholder");
+    }
+
+    private record LlmConfig(String baseUrl, String apiKey, String model,
+                             String temperature, String source) {}
 }
 }

+ 22 - 5
backend/src/main/java/com/agent/management/rag/capability/GraphBusinessSubgraphSelector.java

@@ -13,6 +13,11 @@ public class GraphBusinessSubgraphSelector {
 
 
     public GraphSchemaSnapshot select(String intent, RagCapabilityProfile profile, GraphSchemaSnapshot allowed,
     public GraphSchemaSnapshot select(String intent, RagCapabilityProfile profile, GraphSchemaSnapshot allowed,
                                       int maxLabels, int maxRelationships) {
                                       int maxLabels, int maxRelationships) {
+        return select(intent, profile, allowed, List.of(), maxLabels, maxRelationships);
+    }
+
+    public GraphSchemaSnapshot select(String intent, RagCapabilityProfile profile, GraphSchemaSnapshot allowed,
+                                      List<String> preferredLabels, int maxLabels, int maxRelationships) {
         Set<String> allowedLabels = new LinkedHashSet<>(allowed.labelNames());
         Set<String> allowedLabels = new LinkedHashSet<>(allowed.labelNames());
         Set<String> allowedRelationships = new LinkedHashSet<>(allowed.relationshipTypeNames());
         Set<String> allowedRelationships = new LinkedHashSet<>(allowed.relationshipTypeNames());
         List<String> rankedLabels = semanticCatalog.rank(intent, profile, "label:", Math.max(4, maxLabels)).stream()
         List<String> rankedLabels = semanticCatalog.rank(intent, profile, "label:", Math.max(4, maxLabels)).stream()
@@ -20,7 +25,9 @@ public class GraphBusinessSubgraphSelector {
         List<String> rankedRelationships = semanticCatalog.rank(intent, profile, "relationship:", Math.max(4, maxRelationships)).stream()
         List<String> rankedRelationships = semanticCatalog.rank(intent, profile, "relationship:", Math.max(4, maxRelationships)).stream()
                 .filter(allowedRelationships::contains).toList();
                 .filter(allowedRelationships::contains).toList();
 
 
-        LinkedHashSet<String> seeds = new LinkedHashSet<>(rankedLabels);
+        LinkedHashSet<String> seeds = new LinkedHashSet<>();
+        if (preferredLabels != null) preferredLabels.stream().filter(allowedLabels::contains).forEach(seeds::add);
+        if (seeds.isEmpty()) rankedLabels.stream().filter(label -> seeds.size() < maxLabels).forEach(seeds::add);
         if (seeds.isEmpty()) {
         if (seeds.isEmpty()) {
             allowed.nodes().stream()
             allowed.nodes().stream()
                     .sorted(Comparator.comparingDouble((GraphSchemaSnapshot.NodeSchema node) ->
                     .sorted(Comparator.comparingDouble((GraphSchemaSnapshot.NodeSchema node) ->
@@ -39,11 +46,21 @@ public class GraphBusinessSubgraphSelector {
 
 
         LinkedHashSet<String> selectedLabels = new LinkedHashSet<>(seeds.stream().limit(Math.max(1, maxLabels)).toList());
         LinkedHashSet<String> selectedLabels = new LinkedHashSet<>(seeds.stream().limit(Math.max(1, maxLabels)).toList());
         LinkedHashSet<String> selectedRelationships = new LinkedHashSet<>();
         LinkedHashSet<String> selectedRelationships = new LinkedHashSet<>();
-        relationshipScores.forEach(item -> {
+        for (RelationshipScore item : relationshipScores) {
+            if (selectedRelationships.size() >= maxRelationships) break;
+            LinkedHashSet<String> required = new LinkedHashSet<>();
+            item.relationship().endpoints().stream()
+                    .filter(endpoint -> selectedLabels.contains(endpoint.startLabel())
+                            || selectedLabels.contains(endpoint.endLabel()))
+                    .forEach(endpoint -> {
+                        required.add(endpoint.startLabel());
+                        required.add(endpoint.endLabel());
+                    });
+            required.removeAll(selectedLabels);
+            if (selectedLabels.size() + required.size() > maxLabels) continue;
             selectedRelationships.add(item.relationship().type());
             selectedRelationships.add(item.relationship().type());
-            selectedLabels.addAll(item.relationship().startLabels());
-            selectedLabels.addAll(item.relationship().endLabels());
-        });
+            selectedLabels.addAll(required);
+        }
         if (selectedRelationships.isEmpty() && !selectedLabels.isEmpty()) {
         if (selectedRelationships.isEmpty() && !selectedLabels.isEmpty()) {
             allowed.relationships().stream()
             allowed.relationships().stream()
                     .filter(rel -> rel.startLabels().stream().anyMatch(selectedLabels::contains)
                     .filter(rel -> rel.startLabels().stream().anyMatch(selectedLabels::contains)

+ 124 - 0
backend/src/main/java/com/agent/management/rag/capability/GraphGovernanceConfigService.java

@@ -0,0 +1,124 @@
+package com.agent.management.rag.capability;
+
+import com.agent.management.model.entity.RagGraphGovernanceConfigEntity;
+import com.agent.management.rag.model.RagQuery;
+import com.agent.management.rag.model.RagSourceType;
+import com.agent.management.repository.RagGraphGovernanceConfigRepository;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Instant;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+public class GraphGovernanceConfigService {
+    private final RagGraphGovernanceConfigRepository repository;
+    private final RagCapabilityProfileService profiles;
+    private final ObjectMapper objectMapper;
+
+    public Map<String, Object> get(Long graphSourceId) {
+        return repository.findByGraphSourceId(graphSourceId).map(this::response)
+                .orElseGet(() -> Map.of("graphSourceId", graphSourceId, "configured", false,
+                        "config", Map.of()));
+    }
+
+    @Transactional
+    public Map<String, Object> save(Long graphSourceId, Map<String, Object> config) {
+        RagCapabilityProfile profile = profiles.get(RagSourceType.GRAPH, String.valueOf(graphSourceId));
+        Map<String, Object> normalized = normalize(config, profile);
+        RagGraphGovernanceConfigEntity entity = repository.findByGraphSourceId(graphSourceId)
+                .orElseGet(RagGraphGovernanceConfigEntity::new);
+        entity.setGraphSourceId(graphSourceId);
+        entity.setSchemaVersion(profile.version());
+        entity.setUpdatedAt(Instant.now());
+        try {
+            entity.setConfigJson(objectMapper.writeValueAsString(normalized));
+        } catch (Exception error) {
+            throw new IllegalArgumentException("invalid graph governance config: " + error.getMessage(), error);
+        }
+        return response(repository.save(entity));
+    }
+
+    public RagQuery apply(RagQuery query, Long graphSourceId) {
+        Map<String, Object> stored = config(graphSourceId);
+        if (stored.isEmpty()) return query;
+        RagQuery effective = new RagQuery();
+        effective.setQuery(query.getQuery());
+        effective.setUserId(query.getUserId());
+        effective.setSessionId(query.getSessionId());
+        effective.setSourceIds(query.getSourceIds());
+        effective.setTopK(query.getTopK());
+        Map<String, Object> filters = new LinkedHashMap<>(stored);
+        if (query.getFilters() != null) filters.putAll(query.getFilters());
+        effective.setFilters(filters);
+        return effective;
+    }
+
+    private Map<String, Object> config(Long graphSourceId) {
+        return repository.findByGraphSourceId(graphSourceId).map(this::parseConfig).orElseGet(Map::of);
+    }
+
+    private static Map<String, Object> normalize(Map<String, Object> raw, RagCapabilityProfile profile) {
+        Map<String, Object> source = raw == null ? Map.of() : raw;
+        List<String> labels = strings(source.get("allowedLabels")).stream()
+                .filter(profile.graphSchema().labelNames()::contains).toList();
+        if (labels.isEmpty()) throw new IllegalArgumentException("at least one real graph label must be authorized");
+        List<String> relationships = strings(source.get("allowedRelationships")).stream()
+                .filter(profile.graphSchema().relationshipTypeNames()::contains).toList();
+        Map<String, List<String>> actualProperties = new LinkedHashMap<>();
+        profile.graphSchema().nodes().forEach(node -> actualProperties.put(node.label(), List.copyOf(node.properties().keySet())));
+        profile.graphSchema().relationships().forEach(rel -> actualProperties.put(rel.type(), List.copyOf(rel.properties().keySet())));
+        Map<String, List<String>> properties = new LinkedHashMap<>();
+        if (source.get("allowedProperties") instanceof Map<?, ?> values) {
+            values.forEach((owner, rawValues) -> {
+                String name = String.valueOf(owner);
+                if ((!labels.contains(name) && !relationships.contains(name)) || !actualProperties.containsKey(name)) return;
+                properties.put(name, strings(rawValues).stream().filter(actualProperties.get(name)::contains).toList());
+            });
+        }
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("retrievalMode", "AUTO_GENERATE");
+        result.put("allowTextToCypher", true);
+        result.put("allowedLabels", labels);
+        result.put("allowedRelationships", relationships);
+        result.put("allowedProperties", properties);
+        result.put("generationRules", strings(source.get("generationRules")));
+        result.put("intentKeywords", stringMap(source.get("intentKeywords")));
+        result.put("genericTerms", strings(source.get("genericTerms")));
+        result.put("enabledGenericQueryModes", strings(source.get("enabledGenericQueryModes")));
+        result.put("maxDepth", source.get("maxDepth") instanceof Number number
+                ? Math.max(1, Math.min(5, number.intValue())) : 3);
+        return result;
+    }
+
+    private Map<String, Object> response(RagGraphGovernanceConfigEntity entity) {
+        return Map.of("graphSourceId", entity.getGraphSourceId(), "configured", true,
+                "schemaVersion", entity.getSchemaVersion(), "updatedAt", entity.getUpdatedAt(),
+                "config", parseConfig(entity));
+    }
+
+    private Map<String, Object> parseConfig(RagGraphGovernanceConfigEntity entity) {
+        try {
+            return objectMapper.readValue(entity.getConfigJson(), new TypeReference<Map<String, Object>>() {});
+        } catch (Exception ignored) {
+            return Map.of();
+        }
+    }
+
+    private static List<String> strings(Object value) {
+        return value instanceof List<?> list ? list.stream().map(String::valueOf).toList() : List.of();
+    }
+
+    private static Map<String, List<String>> stringMap(Object value) {
+        if (!(value instanceof Map<?, ?> map)) return Map.of();
+        Map<String, List<String>> result = new LinkedHashMap<>();
+        map.forEach((key, raw) -> result.put(String.valueOf(key), strings(raw)));
+        return result;
+    }
+}

+ 38 - 1
backend/src/main/java/com/agent/management/rag/capability/GraphOnboardingAssessmentService.java

@@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
 import java.util.*;
 import java.util.*;
+import java.util.Locale;
 
 
 @Service
 @Service
 @RequiredArgsConstructor
 @RequiredArgsConstructor
@@ -22,6 +23,10 @@ public class GraphOnboardingAssessmentService {
         List<String> orphanLabels = schema.labelNames().stream().filter(label -> !connected.contains(label)).toList();
         List<String> orphanLabels = schema.labelNames().stream().filter(label -> !connected.contains(label)).toList();
         long properties = schema.nodes().stream().mapToLong(node -> node.properties().size()).sum()
         long properties = schema.nodes().stream().mapToLong(node -> node.properties().size()).sum()
                 + schema.relationships().stream().mapToLong(rel -> rel.properties().size()).sum();
                 + schema.relationships().stream().mapToLong(rel -> rel.properties().size()).sum();
+        long identifierLabels = schema.nodes().stream().filter(node -> node.properties().keySet().stream()
+                .anyMatch(GraphOnboardingAssessmentService::isIdentifierProperty)).count();
+        long scoreRelationships = schema.relationships().stream().filter(rel -> rel.properties().keySet().stream()
+                .anyMatch(GraphOnboardingAssessmentService::isScoreProperty)).count();
         long vectorized = profile.semanticCatalog().stream().filter(item -> !item.vector().isEmpty()).count();
         long vectorized = profile.semanticCatalog().stream().filter(item -> !item.vector().isEmpty()).count();
         long verifiedExamples = examples.list(RagSourceType.GRAPH, sourceId).stream().filter(item -> item.isVerified()).count();
         long verifiedExamples = examples.list(RagSourceType.GRAPH, sourceId).stream().filter(item -> item.isVerified()).count();
         Map<String,Object> metadata = graphSources.discoverMetadata(Long.valueOf(sourceId));
         Map<String,Object> metadata = graphSources.discoverMetadata(Long.valueOf(sourceId));
@@ -29,6 +34,8 @@ public class GraphOnboardingAssessmentService {
         if (!schema.nodes().isEmpty()) score += 20;
         if (!schema.nodes().isEmpty()) score += 20;
         if (!schema.relationships().isEmpty()) score += 20;
         if (!schema.relationships().isEmpty()) score += 20;
         if (properties > 0) score += 15;
         if (properties > 0) score += 15;
+        if (identifierLabels > 0) score += 10;
+        if (scoreRelationships > 0) score += 5;
         if (vectorized == profile.semanticCatalog().size() && vectorized > 0) score += 20;
         if (vectorized == profile.semanticCatalog().size() && vectorized > 0) score += 20;
         if (orphanLabels.size() <= Math.max(1, schema.nodes().size() / 10)) score += 10;
         if (orphanLabels.size() <= Math.max(1, schema.nodes().size() / 10)) score += 10;
         score += (int)Math.min(15, verifiedExamples * 3);
         score += (int)Math.min(15, verifiedExamples * 3);
@@ -37,17 +44,47 @@ public class GraphOnboardingAssessmentService {
         if (schema.relationships().isEmpty()) recommendations.add("未发现 Relationship,Text-to-Cypher 只能完成单节点查询");
         if (schema.relationships().isEmpty()) recommendations.add("未发现 Relationship,Text-to-Cypher 只能完成单节点查询");
         if (!orphanLabels.isEmpty()) recommendations.add("审核孤立 Label,确认是否缺少关系或应排除:" + String.join(", ", orphanLabels));
         if (!orphanLabels.isEmpty()) recommendations.add("审核孤立 Label,确认是否缺少关系或应排除:" + String.join(", ", orphanLabels));
         if (properties == 0) recommendations.add("缺少 Property 元数据,实体过滤和属性问答准确率会受限");
         if (properties == 0) recommendations.add("缺少 Property 元数据,实体过滤和属性问答准确率会受限");
+        if (identifierLabels == 0) recommendations.add("缺少 id/code 等实体标识属性,陌生问题中的实体名称难以可靠落图");
+        if (scoreRelationships == 0) recommendations.add("未发现 score/rank/priority 等关系指标属性,排序/评分类图查询只能交给 Text2Cypher 尝试");
         if (vectorized < profile.semanticCatalog().size()) recommendations.add("语义目录未完整向量化,请检查 Embedding Bridge");
         if (vectorized < profile.semanticCatalog().size()) recommendations.add("语义目录未完整向量化,请检查 Embedding Bridge");
         if (verifiedExamples < 5) recommendations.add("建议为查找、过滤、聚合、路径、多跳五类问法各审核至少一条 Few-shot");
         if (verifiedExamples < 5) recommendations.add("建议为查找、过滤、聚合、路径、多跳五类问法各审核至少一条 Few-shot");
         Map<String,Object> result = new LinkedHashMap<>();
         Map<String,Object> result = new LinkedHashMap<>();
-        result.put("readinessScore", score); result.put("profileVersion", profile.version());
+        result.put("readinessScore", Math.min(100, score)); result.put("profileVersion", profile.version());
         result.put("labels", schema.nodes().size()); result.put("relationshipPatterns", schema.relationships().size());
         result.put("labels", schema.nodes().size()); result.put("relationshipPatterns", schema.relationships().size());
         result.put("relationshipTypes", schema.relationshipTypeNames().size()); result.put("properties", properties);
         result.put("relationshipTypes", schema.relationshipTypeNames().size()); result.put("properties", properties);
+        result.put("identifierLabels", identifierLabels);
+        result.put("scoreRelationships", scoreRelationships);
+        result.put("supportedQueryModes", supportedQueryModes(schema, identifierLabels, scoreRelationships));
         result.put("orphanLabels", orphanLabels); result.put("semanticEntries", profile.semanticCatalog().size());
         result.put("orphanLabels", orphanLabels); result.put("semanticEntries", profile.semanticCatalog().size());
         result.put("vectorizedEntries", vectorized); result.put("verifiedExamples", verifiedExamples);
         result.put("vectorizedEntries", vectorized); result.put("verifiedExamples", verifiedExamples);
         result.put("recommendations", recommendations);
         result.put("recommendations", recommendations);
         result.put("constraints", metadata.get("constraints")); result.put("indexes", metadata.get("indexes"));
         result.put("constraints", metadata.get("constraints")); result.put("indexes", metadata.get("indexes"));
         result.put("labelCounts", metadata.get("labelCounts"));
         result.put("labelCounts", metadata.get("labelCounts"));
+        result.put("nodeCount", metadata.get("nodeCount"));
+        result.put("relationshipCount", metadata.get("relationshipCount"));
+        return result;
+    }
+
+    private static boolean isIdentifierProperty(String property) {
+        String name = property.toLowerCase(Locale.ROOT);
+        return name.equals("id") || name.equals("code") || name.equals("identifier")
+                || name.endsWith("_id") || name.endsWith("_code");
+    }
+
+    private static boolean isScoreProperty(String property) {
+        String name = property.toLowerCase(Locale.ROOT);
+        return name.equals("score") || name.endsWith("_score") || name.contains("rank")
+                || name.contains("priority") || name.contains("rating")
+                || name.equals("weight") || name.equals("level");
+    }
+
+    private static List<String> supportedQueryModes(com.agent.management.rag.graph.GraphSchemaSnapshot schema,
+                                                    long identifierLabels, long scoreRelationships) {
+        List<String> result = new ArrayList<>();
+        if (identifierLabels > 0) result.add("ENTITY_NEIGHBORHOOD");
+        if (!schema.relationships().isEmpty()) result.add("COUNT_RELATED");
+        if (schema.nodes().stream().anyMatch(node -> !node.properties().isEmpty())) result.add("PROPERTY_FILTERED_EVIDENCE");
+        if (scoreRelationships > 0) result.add("RELATION_RANKING");
         return result;
         return result;
     }
     }
 }
 }

+ 3 - 1
backend/src/main/java/com/agent/management/rag/capability/RagEntityMentionExtractor.java

@@ -6,11 +6,13 @@ import java.util.regex.*;
 
 
 @Component
 @Component
 public class RagEntityMentionExtractor {
 public class RagEntityMentionExtractor {
+    private static final Pattern IDENTIFIER = Pattern.compile(
+            "(?<![A-Za-z0-9_])(?:[A-Z][A-Z0-9]*_)+(?:[A-Z0-9]+)(?![A-Za-z0-9_])");
     private static final Pattern QUOTED = Pattern.compile("[\"'“‘]([^\"'”’]{2,80})[\"'”’]");
     private static final Pattern QUOTED = Pattern.compile("[\"'“‘]([^\"'”’]{2,80})[\"'”’]");
     private static final Pattern DOMAIN = Pattern.compile("[\\p{IsHan}A-Za-z0-9_-]{2,30}(?:号|舰|船|队|组|任务|阶段|事件|机构|部门)");
     private static final Pattern DOMAIN = Pattern.compile("[\\p{IsHan}A-Za-z0-9_-]{2,30}(?:号|舰|船|队|组|任务|阶段|事件|机构|部门)");
     public List<String> extract(String question) {
     public List<String> extract(String question) {
         LinkedHashSet<String> result = new LinkedHashSet<>();
         LinkedHashSet<String> result = new LinkedHashSet<>();
-        add(question, QUOTED, result); add(question, DOMAIN, result);
+        add(question, IDENTIFIER, result); add(question, QUOTED, result); add(question, DOMAIN, result);
         return result.stream().limit(12).toList();
         return result.stream().limit(12).toList();
     }
     }
     private static void add(String text, Pattern pattern, Set<String> out) {
     private static void add(String text, Pattern pattern, Set<String> out) {

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

@@ -20,6 +20,7 @@ public class RagCapabilityController {
     private final com.agent.management.rag.capability.RagGovernanceService governance;
     private final com.agent.management.rag.capability.RagGovernanceService governance;
     private final com.agent.management.rag.memory.RagExampleCandidateService candidateImports;
     private final com.agent.management.rag.memory.RagExampleCandidateService candidateImports;
     private final com.agent.management.rag.capability.GraphOnboardingAssessmentService graphAssessment;
     private final com.agent.management.rag.capability.GraphOnboardingAssessmentService graphAssessment;
+    private final com.agent.management.rag.capability.GraphGovernanceConfigService graphGovernance;
 
 
     @GetMapping("/{type}/{sourceId}")
     @GetMapping("/{type}/{sourceId}")
     public Result<RagCapabilityProfile> get(@PathVariable RagSourceType type, @PathVariable String sourceId) {
     public Result<RagCapabilityProfile> get(@PathVariable RagSourceType type, @PathVariable String sourceId) {
@@ -64,4 +65,15 @@ public class RagCapabilityController {
     public Result<java.util.Map<String,Object>> graphReadiness(@PathVariable String sourceId) {
     public Result<java.util.Map<String,Object>> graphReadiness(@PathVariable String sourceId) {
         return Result.success(graphAssessment.assess(sourceId));
         return Result.success(graphAssessment.assess(sourceId));
     }
     }
+
+    @GetMapping("/GRAPH/{sourceId}/governance")
+    public Result<java.util.Map<String,Object>> graphGovernance(@PathVariable Long sourceId) {
+        return Result.success(graphGovernance.get(sourceId));
+    }
+
+    @PutMapping("/GRAPH/{sourceId}/governance")
+    public Result<java.util.Map<String,Object>> updateGraphGovernance(
+            @PathVariable Long sourceId, @RequestBody java.util.Map<String,Object> config) {
+        return Result.success(graphGovernance.save(sourceId, config));
+    }
 }
 }

+ 133 - 0
backend/src/main/java/com/agent/management/rag/graph/GraphEntityGroundingService.java

@@ -0,0 +1,133 @@
+package com.agent.management.rag.graph;
+
+import com.agent.management.rag.capability.RagEntityMentionExtractor;
+import com.agent.management.service.GraphSourceService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+@Service
+@RequiredArgsConstructor
+public class GraphEntityGroundingService {
+    private static final Pattern IDENTIFIER = Pattern.compile("(?:[A-Z][A-Z0-9]*_)+(?:[A-Z0-9]+)");
+    private static final Pattern IDENTIFIER_PROPERTY = Pattern.compile(
+            "(?i)^(id|code|identifier|[a-z][a-z0-9_]*(?:_id|_code))$");
+    private static final int MAX_MENTIONS = 8;
+    private static final int MAX_PROPERTIES = 12;
+    private final RagEntityMentionExtractor mentions;
+    private final GraphSourceService graphs;
+
+    public GroundingResult ground(Long graphSourceId, String question, GraphSchemaSnapshot authorizedSchema) {
+        List<String> identifiers = mentions.extract(question).stream()
+                .filter(value -> IDENTIFIER.matcher(value).matches())
+                .limit(MAX_MENTIONS).toList();
+        if (identifiers.isEmpty()) return GroundingResult.empty();
+
+        List<String> properties = authorizedSchema.nodes().stream()
+                .flatMap(node -> node.properties().keySet().stream())
+                .filter(name -> IDENTIFIER_PROPERTY.matcher(name).matches())
+                .distinct().limit(MAX_PROPERTIES).toList();
+        if (properties.isEmpty()) {
+            return new GroundingResult(List.of(), List.of("graph schema exposes no identifier-like properties"));
+        }
+
+        try {
+            Map<String, Object> result = graphs.executeQuery(graphSourceId,
+                    lookupCypher(identifiers, properties, authorizedSchema.labelNames()));
+            return new GroundingResult(matches(result.get("nodes"), identifiers, properties,
+                    new LinkedHashSet<>(authorizedSchema.labelNames())), List.of());
+        } catch (Exception error) {
+            return new GroundingResult(List.of(),
+                    List.of("graph entity grounding failed: " + shortMessage(error)));
+        }
+    }
+
+    private static String lookupCypher(List<String> identifiers, List<String> properties, List<String> labels) {
+        String values = identifiers.stream().map(GraphEntityGroundingService::literal)
+                .collect(java.util.stream.Collectors.joining(", "));
+        String propertyPredicate = properties.stream()
+                .map(property -> "n.`" + property.replace("`", "``") + "` IN [" + values + "]")
+                .collect(java.util.stream.Collectors.joining(" OR "));
+        String allowedLabels = labels.stream().map(GraphEntityGroundingService::literal)
+                .collect(java.util.stream.Collectors.joining(", "));
+        return "MATCH (n) WHERE (" + propertyPredicate + ") "
+                + "AND any(label IN labels(n) WHERE label IN [" + allowedLabels + "]) "
+                + "RETURN n LIMIT 20";
+    }
+
+    private static List<EntityMatch> matches(Object rawNodes, List<String> identifiers,
+                                             List<String> properties, Set<String> allowedLabels) {
+        if (!(rawNodes instanceof Collection<?> nodes)) return List.of();
+        List<EntityMatch> result = new ArrayList<>();
+        for (Object item : nodes) {
+            if (!(item instanceof Map<?, ?> node)) continue;
+            List<String> labels = strings(node.get("labels")).stream().filter(allowedLabels::contains).toList();
+            if (labels.isEmpty() || !(node.get("properties") instanceof Map<?, ?> values)) continue;
+            for (String mention : identifiers) {
+                for (String property : properties) {
+                    Object value = values.get(property);
+                    if (value != null && mention.equalsIgnoreCase(String.valueOf(value))) {
+                        result.add(new EntityMatch(mention, labels, property));
+                        break;
+                    }
+                }
+            }
+        }
+        return result.stream().distinct().toList();
+    }
+
+    private static List<String> strings(Object value) {
+        if (!(value instanceof Collection<?> values)) return List.of();
+        return values.stream().map(String::valueOf).toList();
+    }
+
+    private static String literal(String value) {
+        return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'";
+    }
+
+    private static String shortMessage(Exception error) {
+        String message = error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage();
+        return message.length() <= 200 ? message : message.substring(0, 200);
+    }
+
+    public record EntityMatch(String mention, List<String> labels, String property) {
+        public EntityMatch { labels = List.copyOf(labels == null ? List.of() : labels); }
+        public Map<String, Object> toMetadata() {
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("mention", mention);
+            result.put("labels", labels);
+            result.put("property", property);
+            return result;
+        }
+        public String toPromptText() {
+            return mention + " -> labels " + String.join(",", labels) + " via property " + property;
+        }
+    }
+
+    public record GroundingResult(List<EntityMatch> matches, List<String> warnings) {
+        public GroundingResult {
+            matches = List.copyOf(matches == null ? List.of() : matches);
+            warnings = List.copyOf(warnings == null ? List.of() : warnings);
+        }
+        public static GroundingResult empty() { return new GroundingResult(List.of(), List.of()); }
+        public List<String> labels() {
+            LinkedHashSet<String> result = new LinkedHashSet<>();
+            matches.forEach(match -> result.addAll(match.labels()));
+            return new ArrayList<>(result);
+        }
+        public List<Map<String, Object>> metadata() {
+            return matches.stream().map(EntityMatch::toMetadata).toList();
+        }
+        public List<String> promptMentions() {
+            return matches.stream().map(EntityMatch::toPromptText).toList();
+        }
+    }
+}

+ 77 - 0
backend/src/main/java/com/agent/management/rag/graph/GraphEntityNeighborhoodQueryBuilder.java

@@ -0,0 +1,77 @@
+package com.agent.management.rag.graph;
+
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+@Component
+public class GraphEntityNeighborhoodQueryBuilder {
+    private static final int MAX_RELATIONSHIPS = 4;
+
+    public Optional<String> build(GraphSchemaSnapshot schema,
+                                  GraphEntityGroundingService.GroundingResult grounding,
+                                  int maxRows) {
+        if (grounding.matches().isEmpty()) return Optional.empty();
+        GraphEntityGroundingService.EntityMatch entity = grounding.matches().get(0);
+        String entityLabel = entity.labels().stream()
+                .filter(label -> schema.node(label)
+                        .map(node -> node.properties().containsKey(entity.property())).orElse(false))
+                .findFirst().orElse(null);
+        if (entityLabel == null) return Optional.empty();
+
+        List<Neighbor> neighbors = neighbors(schema, new LinkedHashSet<>(entity.labels()));
+        StringBuilder cypher = new StringBuilder("MATCH (n:")
+                .append(identifier(entityLabel)).append(" {")
+                .append(identifier(entity.property())).append(": ")
+                .append(literal(entity.mention())).append("})\n");
+        List<String> returns = new ArrayList<>(List.of("n"));
+        for (int index = 0; index < neighbors.size(); index++) {
+            Neighbor neighbor = neighbors.get(index);
+            String pathVariable = "p" + (index + 1);
+            String nodeVariable = "m" + (index + 1);
+            cypher.append("OPTIONAL MATCH ").append(pathVariable).append("=");
+            if (neighbor.outgoing()) {
+                cypher.append("(n)-[:")
+                        .append(identifier(neighbor.type())).append("]->(")
+                        .append(nodeVariable).append(":").append(identifier(neighbor.otherLabel())).append(")\n");
+            } else {
+                cypher.append("(").append(nodeVariable).append(":")
+                        .append(identifier(neighbor.otherLabel())).append(")-[:")
+                        .append(identifier(neighbor.type())).append("]->(n)\n");
+            }
+            returns.add(pathVariable);
+        }
+        cypher.append("RETURN ").append(String.join(", ", returns))
+                .append(" LIMIT ").append(Math.max(1, maxRows));
+        return Optional.of(cypher.toString());
+    }
+
+    private static List<Neighbor> neighbors(GraphSchemaSnapshot schema, Set<String> entityLabels) {
+        LinkedHashSet<Neighbor> result = new LinkedHashSet<>();
+        for (GraphSchemaSnapshot.RelationshipSchema relationship : schema.relationships()) {
+            for (GraphSchemaSnapshot.RelationshipEndpoint endpoint : relationship.endpoints()) {
+                if (entityLabels.contains(endpoint.startLabel())) {
+                    result.add(new Neighbor(relationship.type(), endpoint.endLabel(), true));
+                } else if (entityLabels.contains(endpoint.endLabel())) {
+                    result.add(new Neighbor(relationship.type(), endpoint.startLabel(), false));
+                }
+                if (result.size() >= MAX_RELATIONSHIPS) return new ArrayList<>(result);
+            }
+        }
+        return new ArrayList<>(result);
+    }
+
+    private static String identifier(String value) {
+        return "`" + value.replace("`", "``") + "`";
+    }
+
+    private static String literal(String value) {
+        return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'";
+    }
+
+    private record Neighbor(String type, String otherLabel, boolean outgoing) {}
+}

+ 520 - 0
backend/src/main/java/com/agent/management/rag/graph/GraphQueryIntentPlanner.java

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

+ 53 - 16
backend/src/main/java/com/agent/management/rag/graph/GraphRagRetriever.java

@@ -26,35 +26,60 @@ public class GraphRagRetriever implements RagRetriever {
             Long id = sourceId(query);
             Long id = sourceId(query);
             Optional<String> cypher = explicit.generateCypher(query, id);
             Optional<String> cypher = explicit.generateCypher(query, id);
             boolean auto = false;
             boolean auto = false;
+            int repairCount = 0;
+            Map<String,Object> generationMetadata = Map.of();
             if (cypher.isEmpty()) {
             if (cypher.isEmpty()) {
                 auto = true;
                 auto = true;
                 try {
                 try {
-                    cypher = generated.generateCypher(query, id);
+                    Optional<Neo4jGraphRagCypherGenerationService.GeneratedCypher> generation = generated.generate(query, id);
+                    cypher = generation.map(Neo4jGraphRagCypherGenerationService.GeneratedCypher::cypher);
+                    generationMetadata = generation.map(Neo4jGraphRagCypherGenerationService.GeneratedCypher::contextMetadata)
+                            .orElse(Map.of());
+                    repairCount = generation.filter(Neo4jGraphRagCypherGenerationService.GeneratedCypher::repaired)
+                            .isPresent() ? 1 : 0;
                 } catch (Exception error) {
                 } catch (Exception error) {
                     out.getDiagnostics().put("error", "Cypher generation failed: " + error.getMessage());
                     out.getDiagnostics().put("error", "Cypher generation failed: " + error.getMessage());
                     return out;
                     return out;
                 }
                 }
             }
             }
             if (cypher.isEmpty()) {
             if (cypher.isEmpty()) {
-                out.getDiagnostics().put("warning", "no Cypher provided and automatic generation is disabled or returned empty");
+                out.getDiagnostics().put("warning", "No Cypher was generated. The generic graph planner did not find a supported intent, and Text2Cypher returned empty content.");
+                out.getDiagnostics().put("suggestions", List.of(
+                        "Use an explicit entity id/name and ask for its related nodes, status, evidence, deployment, or capabilities.",
+                        "Ask count/ranking questions only when the graph has related entities or score/rank/priority properties.",
+                        "For capability matching, ask about capabilities that exist as Capability node property values.",
+                        "Provide explicit Cypher in filters.cypher for unsupported graph question types."));
+                out.getDiagnostics().put("supportedGenerationModes", List.of(
+                        "ENTITY_NEIGHBORHOOD", "COUNT_RELATED", "PROPERTY_FILTERED_EVIDENCE",
+                        "RELATION_RANKING", "CAPABILITY_MATCHING", "LLM_TEXT2CYPHER"));
                 return out;
                 return out;
             }
             }
             if (!auto) generated.validateExplicit(query, id, cypher.get());
             if (!auto) generated.validateExplicit(query, id, cypher.get());
             if (auto) {
             if (auto) {
-                cypher = preflightAndRepair(query, id, cypher.get(), out);
+                PreflightResult preflight = preflightAndRepair(query, id, cypher.get(), out, repairCount == 0);
+                cypher = preflight.cypher();
+                repairCount += preflight.repairCount();
                 if (cypher.isEmpty()) return out;
                 if (cypher.isEmpty()) return out;
             }
             }
             Map<String, Object> result = graphs.executeQuery(id, cypher.get());
             Map<String, Object> result = graphs.executeQuery(id, cypher.get());
-            if (auto && !hasResults(result)) {
-                Optional<String> repaired = generated.repair(query, id, cypher.get(),
-                        "query executed successfully but returned zero results; entity names may be abbreviated, use CONTAINS on an authorized name/title property");
-                if (repaired != null && repaired.isPresent() && !repaired.get().equals(cypher.get())) {
-                    graphs.explainQuery(id, repaired.get());
-                    Map<String,Object> retried = graphs.executeQuery(id, repaired.get());
-                    if (hasResults(retried)) {
-                        cypher = repaired; result = retried;
-                        out.getDiagnostics().put("repair", "zero-result Cypher was repaired once with fuzzy entity matching");
+            if (auto && repairCount == 0 && !hasResults(result)) {
+                try {
+                    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 (hasResults(retried)) {
+                            cypher = repaired; result = retried;
+                            repairCount = 1;
+                            out.getDiagnostics().put("repair", "zero-result Cypher was repaired once with fuzzy entity matching");
+                        } else {
+                            out.getDiagnostics().put("warning", "generated Cypher returned zero results; repair also returned zero results");
+                        }
                     }
                     }
+                } catch (Exception repairError) {
+                    out.getDiagnostics().put("warning", "generated Cypher returned zero results; repair failed: "
+                            + repairError.getMessage());
                 }
                 }
             }
             }
             List<?> nodes = list(result.get("nodes"));
             List<?> nodes = list(result.get("nodes"));
@@ -83,6 +108,9 @@ public class GraphRagRetriever implements RagRetriever {
             metadata.put("recordCount", records.size());
             metadata.put("recordCount", records.size());
             metadata.put("durationMs", result.getOrDefault("durationMs", 0));
             metadata.put("durationMs", result.getOrDefault("durationMs", 0));
             metadata.put("warnings", Boolean.TRUE.equals(result.get("truncated")) ? List.of("result truncated") : List.of());
             metadata.put("warnings", Boolean.TRUE.equals(result.get("truncated")) ? List.of("result truncated") : List.of());
+            metadata.put("repaired", repairCount > 0);
+            metadata.put("repairCount", repairCount);
+            metadata.putAll(generationMetadata);
             evidence.setMetadata(metadata);
             evidence.setMetadata(metadata);
             out.getEvidences().add(evidence);
             out.getEvidences().add(evidence);
             if (auto && autoLearn(query) && hasResults(result))
             if (auto && autoLearn(query) && hasResults(result))
@@ -93,20 +121,26 @@ public class GraphRagRetriever implements RagRetriever {
         return out;
         return out;
     }
     }
 
 
-    private Optional<String> preflightAndRepair(RagQuery query, Long sourceId, String cypher, RagRetrievalResult out) {
+    private PreflightResult preflightAndRepair(RagQuery query, Long sourceId, String cypher,
+                                               RagRetrievalResult out, boolean allowRepair) {
         try {
         try {
             graphs.explainQuery(sourceId, cypher);
             graphs.explainQuery(sourceId, cypher);
-            return Optional.of(cypher);
+            return new PreflightResult(Optional.of(cypher), 0);
         } catch (Exception explainError) {
         } catch (Exception explainError) {
+            if (!allowRepair) {
+                out.getDiagnostics().put("error", "Cypher EXPLAIN failed after repair budget was exhausted: "
+                        + explainError.getMessage());
+                return new PreflightResult(Optional.empty(), 0);
+            }
             try {
             try {
                 Optional<String> repaired = generated.repair(query, sourceId, cypher, explainError.getMessage());
                 Optional<String> repaired = generated.repair(query, sourceId, cypher, explainError.getMessage());
                 if (repaired.isEmpty()) throw new IllegalArgumentException("repair returned empty Cypher");
                 if (repaired.isEmpty()) throw new IllegalArgumentException("repair returned empty Cypher");
                 graphs.explainQuery(sourceId, repaired.get());
                 graphs.explainQuery(sourceId, repaired.get());
                 out.getDiagnostics().put("repair", "generated Cypher failed EXPLAIN and was repaired once");
                 out.getDiagnostics().put("repair", "generated Cypher failed EXPLAIN and was repaired once");
-                return repaired;
+                return new PreflightResult(repaired, 1);
             } catch (Exception repairError) {
             } catch (Exception repairError) {
                 out.getDiagnostics().put("error", "Cypher preflight/repair failed: " + repairError.getMessage());
                 out.getDiagnostics().put("error", "Cypher preflight/repair failed: " + repairError.getMessage());
-                return Optional.empty();
+                return new PreflightResult(Optional.empty(), 1);
             }
             }
         }
         }
     }
     }
@@ -117,6 +151,7 @@ public class GraphRagRetriever implements RagRetriever {
 
 
     private static Long sourceId(RagQuery query) {
     private static Long sourceId(RagQuery query) {
         if (query.getSourceIds() == null || query.getSourceIds().isEmpty()) throw new IllegalArgumentException("sourceIds must contain graphSourceId");
         if (query.getSourceIds() == null || query.getSourceIds().isEmpty()) throw new IllegalArgumentException("sourceIds must contain graphSourceId");
+        if (query.getSourceIds().size() != 1) throw new IllegalArgumentException("Text2Cypher supports exactly one graphSourceId per request");
         return Long.valueOf(query.getSourceIds().get(0));
         return Long.valueOf(query.getSourceIds().get(0));
     }
     }
 
 
@@ -125,4 +160,6 @@ public class GraphRagRetriever implements RagRetriever {
         return !list(result.get("nodes")).isEmpty() || !list(result.get("edges")).isEmpty()
         return !list(result.get("nodes")).isEmpty() || !list(result.get("edges")).isEmpty()
                 || !list(result.get("records")).isEmpty();
                 || !list(result.get("records")).isEmpty();
     }
     }
+
+    private record PreflightResult(Optional<String> cypher, int repairCount) {}
 }
 }

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

@@ -19,6 +19,7 @@ public class GraphSchemaValidator {
         Map<String, String> variables = new HashMap<>();
         Map<String, String> variables = new HashMap<>();
         Matcher predicateMatcher=LABEL_PREDICATE.matcher(cypher);
         Matcher predicateMatcher=LABEL_PREDICATE.matcher(cypher);
         while(predicateMatcher.find()) {
         while(predicateMatcher.find()) {
+            if (isInsideSquareBrackets(cypher, predicateMatcher.start())) continue;
             String label=predicateMatcher.group(2);
             String label=predicateMatcher.group(2);
             if(schema.node(label).isEmpty()) throw new IllegalArgumentException("Cypher uses nonexistent or unauthorized label: "+label);
             if(schema.node(label).isEmpty()) throw new IllegalArgumentException("Cypher uses nonexistent or unauthorized label: "+label);
             variables.putIfAbsent(predicateMatcher.group(1),label);
             variables.putIfAbsent(predicateMatcher.group(1),label);
@@ -81,4 +82,11 @@ public class GraphSchemaValidator {
             }
             }
         }
         }
     }
     }
+
+    private static boolean isInsideSquareBrackets(String cypher, int offset) {
+        int lastOpen = cypher.lastIndexOf('[', offset);
+        if (lastOpen < 0) return false;
+        int lastClose = cypher.lastIndexOf(']', offset);
+        return lastOpen > lastClose;
+    }
 }
 }

+ 105 - 20
backend/src/main/java/com/agent/management/rag/graph/Neo4jGraphRagCypherGenerationService.java

@@ -4,7 +4,6 @@ import com.agent.management.rag.bridge.RagAiBridgeClient;
 import com.agent.management.rag.capability.RagCapabilityProfileService;
 import com.agent.management.rag.capability.RagCapabilityProfileService;
 import com.agent.management.rag.capability.RagSchemaLinker;
 import com.agent.management.rag.capability.RagSchemaLinker;
 import com.agent.management.rag.capability.RagCapabilityProfile;
 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.GraphBusinessSubgraphSelector;
 import com.agent.management.rag.capability.RagEntityMentionExtractor;
 import com.agent.management.rag.capability.RagEntityMentionExtractor;
 import com.agent.management.rag.memory.RagExampleMemoryService;
 import com.agent.management.rag.memory.RagExampleMemoryService;
@@ -13,6 +12,7 @@ import com.agent.management.rag.model.RagSourceType;
 import lombok.RequiredArgsConstructor;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
+import java.time.Duration;
 import java.util.*;
 import java.util.*;
 
 
 @Service
 @Service
@@ -21,60 +21,94 @@ public class Neo4jGraphRagCypherGenerationService implements CypherGenerationSer
     private final RagAiBridgeClient bridge;
     private final RagAiBridgeClient bridge;
     private final RagCapabilityProfileService profiles;
     private final RagCapabilityProfileService profiles;
     private final RagSchemaLinker linker;
     private final RagSchemaLinker linker;
-    private final RagSemanticCatalogService semanticCatalog;
     private final GraphBusinessSubgraphSelector subgraphs;
     private final GraphBusinessSubgraphSelector subgraphs;
     private final GraphSchemaValidator validator;
     private final GraphSchemaValidator validator;
     private final RagExampleMemoryService examples;
     private final RagExampleMemoryService examples;
     private final RagEntityMentionExtractor entities;
     private final RagEntityMentionExtractor entities;
+    private final GraphEntityGroundingService groundingService;
+    private final GraphEntityNeighborhoodQueryBuilder neighborhoodQueries;
+    private final GraphQueryIntentPlanner intentPlanner;
+    private final com.agent.management.rag.capability.GraphGovernanceConfigService governance;
 
 
+    @Override
     public Optional<String> generateCypher(RagQuery query, Long graphSourceId) {
     public Optional<String> generateCypher(RagQuery query, Long graphSourceId) {
+        return generate(query, graphSourceId).map(GeneratedCypher::cypher);
+    }
+
+    public Optional<GeneratedCypher> generate(RagQuery query, Long graphSourceId) {
+        query = governance.apply(query, graphSourceId);
         if (!allowed(query)) return Optional.empty();
         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"));
         List<Map<String,Object>> promptExamples = new ArrayList<>(maps(query, "examples"));
         promptExamples.addAll(examples.findSimilar(RagSourceType.GRAPH, String.valueOf(graphSourceId), query.getQuery(), 3));
         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);
+        SelectionContext selection = selection(query, graphSourceId, promptExamples);
+        GraphSchemaSnapshot selected = selection.schema();
         if (selected.nodes().isEmpty()) throw new IllegalArgumentException("no relevant authorized graph schema is available for Text-to-Cypher");
         if (selected.nodes().isEmpty()) throw new IllegalArgumentException("no relevant authorized graph schema is available for Text-to-Cypher");
         int maxDepth = integer(query, "maxDepth", 3);
         int maxDepth = integer(query, "maxDepth", 3);
+        Optional<GeneratedCypher> plannedQuery = plannedQuery(query, graphSourceId, selection, maxDepth);
+        if (plannedQuery.isPresent()) return plannedQuery;
+        Optional<GeneratedCypher> groundedQuery = neighborhoodQuery(selection, maxDepth);
+        if (groundedQuery.isPresent()) return groundedQuery;
 
 
         Map<String,Object> request = new LinkedHashMap<>();
         Map<String,Object> request = new LinkedHashMap<>();
         request.put("query",query.getQuery()); request.put("graphSourceId",graphSourceId);
         request.put("query",query.getQuery()); request.put("graphSourceId",graphSourceId);
         request.put("schema",selected.toPromptText()); request.put("examples",normalizeExamples(promptExamples));
         request.put("schema",selected.toPromptText()); request.put("examples",normalizeExamples(promptExamples));
-        request.put("businessRules",generationContext(query)); request.put("allowedLabels",selected.labelNames());
+        request.put("businessRules",generationContext(query, selection)); request.put("allowedLabels",selected.labelNames());
         request.put("allowedRelationships",selected.relationshipTypeNames()); request.put("allowedProperties",allowedProperties(selected));
         request.put("allowedRelationships",selected.relationshipTypeNames()); request.put("allowedProperties",allowedProperties(selected));
         request.put("maxDepth",maxDepth);
         request.put("maxDepth",maxDepth);
-        request.put("entityMentions", entities.extract(query.getQuery()));
+        request.put("entityMentions", entityMentions(query, selection));
         Object cypher = bridge.textToCypher(request).get("cypher");
         Object cypher = bridge.textToCypher(request).get("cypher");
         if(cypher==null||String.valueOf(cypher).isBlank()) return Optional.empty();
         if(cypher==null||String.valueOf(cypher).isBlank()) return Optional.empty();
         String generated=String.valueOf(cypher);
         String generated=String.valueOf(cypher);
         try {
         try {
             validator.validate(generated,selected,maxDepth);
             validator.validate(generated,selected,maxDepth);
-            return Optional.of(generated);
+            return Optional.of(new GeneratedCypher(generated, false, selection.metadata()));
         } catch (IllegalArgumentException validationError) {
         } catch (IllegalArgumentException validationError) {
             Object fixed=bridge.repair(Map.of("language","CYPHER","question",query.getQuery(),"query",generated,
             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");
+                    "error",validationError.getMessage(),"schemaText",selected.toPromptText(),"maxRows",50,"maxDepth",maxDepth),
+                    Duration.ofSeconds(30)).get("query");
             if(fixed==null||String.valueOf(fixed).isBlank()) throw validationError;
             if(fixed==null||String.valueOf(fixed).isBlank()) throw validationError;
-            String repaired=String.valueOf(fixed);validator.validate(repaired,selected,maxDepth);return Optional.of(repaired);
+            String repaired=String.valueOf(fixed);validator.validate(repaired,selected,maxDepth);
+            return Optional.of(new GeneratedCypher(repaired, true, selection.metadata()));
         }
         }
     }
     }
 
 
+    private Optional<GeneratedCypher> neighborhoodQuery(SelectionContext selection, int maxDepth) {
+        return neighborhoodQueries.build(selection.schema(), selection.grounding(), 50).map(cypher -> {
+            validator.validate(cypher, selection.schema(), maxDepth);
+            Map<String,Object> metadata = new LinkedHashMap<>(selection.metadata());
+            metadata.put("generationMode", "VERIFIED_ENTITY_NEIGHBORHOOD");
+            return new GeneratedCypher(cypher, false, metadata);
+        });
+    }
+
+    private Optional<GeneratedCypher> plannedQuery(RagQuery query, Long graphSourceId,
+                                                   SelectionContext selection, int maxDepth) {
+        return intentPlanner.plan(query, graphSourceId, selection.authorizedSchema(), selection.grounding(), maxDepth)
+                .map(planned -> {
+                    Map<String,Object> metadata = new LinkedHashMap<>(selection.metadata(selection.authorizedSchema()));
+                    metadata.put("generationMode", planned.generationMode());
+                    metadata.put("intentPlan", planned.intentPlan());
+                    metadata.put("groundingConfidence", planned.groundingConfidence());
+                    metadata.put("fallbackReason", "generic graph intent planner");
+                    return new GeneratedCypher(planned.cypher(), false, metadata);
+                });
+    }
+
     public Optional<String> repair(RagQuery query,Long graphSourceId,String failedCypher,String error){
     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));
+        query = governance.apply(query, graphSourceId);
+        List<Map<String,Object>> promptExamples = new ArrayList<>(maps(query, "examples"));
+        promptExamples.addAll(examples.findSimilar(RagSourceType.GRAPH, String.valueOf(graphSourceId), query.getQuery(), 3));
+        GraphSchemaSnapshot schema = selection(query, graphSourceId, promptExamples).schema();
         int maxDepth=integer(query,"maxDepth",3);
         int maxDepth=integer(query,"maxDepth",3);
         Object fixed=bridge.repair(Map.of("language","CYPHER","question",query.getQuery(),"query",failedCypher,
         Object fixed=bridge.repair(Map.of("language","CYPHER","question",query.getQuery(),"query",failedCypher,
-                "error",error,"schemaText",schema.toPromptText(),"maxRows",50,"maxDepth",maxDepth)).get("query");
+                "error",error,"schemaText",schema.toPromptText(),"maxRows",50,"maxDepth",maxDepth),
+                Duration.ofSeconds(30)).get("query");
         if(fixed==null||String.valueOf(fixed).isBlank())return Optional.empty();
         if(fixed==null||String.valueOf(fixed).isBlank())return Optional.empty();
         validator.validate(String.valueOf(fixed),schema,maxDepth);return Optional.of(String.valueOf(fixed));
         validator.validate(String.valueOf(fixed),schema,maxDepth);return Optional.of(String.valueOf(fixed));
     }
     }
 
 
     public void validateExplicit(RagQuery query, Long graphSourceId, String cypher) {
     public void validateExplicit(RagQuery query, Long graphSourceId, String cypher) {
+        query = governance.apply(query, graphSourceId);
         GraphSchemaSnapshot authorized = profiles.get(RagSourceType.GRAPH, String.valueOf(graphSourceId)).graphSchema()
         GraphSchemaSnapshot authorized = profiles.get(RagSourceType.GRAPH, String.valueOf(graphSourceId)).graphSchema()
                 .filter(stringList(query, "allowedLabels"), stringList(query, "allowedRelationships"), properties(query));
                 .filter(stringList(query, "allowedLabels"), stringList(query, "allowedRelationships"), properties(query));
         validator.validate(cypher, authorized, integer(query, "maxDepth", 3));
         validator.validate(cypher, authorized, integer(query, "maxDepth", 3));
@@ -83,10 +117,61 @@ public class Neo4jGraphRagCypherGenerationService implements CypherGenerationSer
     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")));}
     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 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 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 String generationContext(RagQuery query, SelectionContext selection){String existing=string(query,"businessRules");List<String> rules=stringList(query,"generationRules");String context=rules.isEmpty()?existing:existing+(existing.isBlank()?"":"\n")+"Generation rules:\n- "+String.join("\n- ",rules);if(!selection.grounding().matches().isEmpty())context+=(context.isBlank()?"":"\n")+"Verified graph entities:\n- "+String.join("\n- ",selection.grounding().promptMentions());return context;}
     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<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>> 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 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>> 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;}
     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;}
+
+    private SelectionContext selection(RagQuery query, Long graphSourceId,
+                                       List<Map<String,Object>> promptExamples) {
+        RagCapabilityProfile profile = profiles.get(RagSourceType.GRAPH, String.valueOf(graphSourceId));
+        GraphSchemaSnapshot authorized = profile.graphSchema().filter(stringList(query,"allowedLabels"),
+                stringList(query,"allowedRelationships"), properties(query));
+        GraphEntityGroundingService.GroundingResult grounding =
+                groundingService.ground(graphSourceId, query.getQuery(), authorized);
+        GraphSchemaSnapshot selected;
+        if (!grounding.labels().isEmpty()) {
+            selected = subgraphs.select(query.getQuery(), profile, authorized, grounding.labels(), 8, 10);
+        } else {
+            GraphSchemaSnapshot semanticSchema = authorized.nodes().size() <= 12 ? authorized
+                    : subgraphs.select(query.getQuery(), profile, authorized, 8, 10);
+            selected = linker.selectGraphSchema(query.getQuery(), semanticSchema, promptExamples);
+        }
+        if (selected.nodes().size() > 10 || selected.relationships().size() > 12) {
+            selected = subgraphs.select(query.getQuery(), profile, selected, grounding.labels(), 8, 10);
+        }
+        if (selected.nodes().isEmpty()) {
+            throw new IllegalArgumentException("no relevant authorized graph schema is available for Text-to-Cypher");
+        }
+        return new SelectionContext(selected, authorized, grounding);
+    }
+
+    private List<String> entityMentions(RagQuery query, SelectionContext selection) {
+        LinkedHashSet<String> result = new LinkedHashSet<>(entities.extract(query.getQuery()));
+        result.addAll(selection.grounding().promptMentions());
+        return new ArrayList<>(result);
+    }
+
+    private record SelectionContext(GraphSchemaSnapshot schema,
+                                    GraphSchemaSnapshot authorizedSchema,
+                                    GraphEntityGroundingService.GroundingResult grounding) {
+        Map<String,Object> metadata() {
+            return metadata(schema);
+        }
+        Map<String,Object> metadata(GraphSchemaSnapshot metadataSchema) {
+            Map<String,Object> result = new LinkedHashMap<>();
+            result.put("selectedLabels", metadataSchema.labelNames());
+            result.put("selectedRelationships", metadataSchema.relationshipTypeNames());
+            result.put("entityMatches", grounding.metadata());
+            result.put("schemaSelectionWarnings", grounding.warnings());
+            return result;
+        }
+    }
+
+    public record GeneratedCypher(String cypher, boolean repaired, Map<String,Object> contextMetadata) {
+        public GeneratedCypher(String cypher, boolean repaired) { this(cypher, repaired, Map.of()); }
+        public GeneratedCypher { contextMetadata = Map.copyOf(contextMetadata == null ? Map.of() : contextMetadata); }
+    }
 }
 }

+ 67 - 5
backend/src/main/java/com/agent/management/rag/kb/KnowledgeBaseRagRetriever.java

@@ -61,15 +61,19 @@ public class KnowledgeBaseRagRetriever {
                         subQuestions.getOrDefault(binding.getSourceType(), request.getQuery())), executor))
                         subQuestions.getOrDefault(binding.getSourceType(), request.getQuery())), executor))
                 .toList();
                 .toList();
         for (CompletableFuture<BindingResult> task : tasks) {
         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());
-            }
+            merge(out, task.join());
         }
         }
+        rerank(out, request.getQuery());
         return out;
         return out;
     }
     }
 
 
+    private static void merge(KnowledgeBaseRagResult out, BindingResult bindingResult) {
+        out.getEvidences().addAll(bindingResult.result().getEvidences());
+        if (!bindingResult.result().getDiagnostics().isEmpty()) {
+            out.getDiagnostics().put(bindingResult.key(), bindingResult.result().getDiagnostics());
+        }
+    }
+
     private BindingResult retrieveBinding(KnowledgeBaseRagRequest request, RagKnowledgeSourceBinding binding, String subQuestion) {
     private BindingResult retrieveBinding(KnowledgeBaseRagRequest request, RagKnowledgeSourceBinding binding, String subQuestion) {
         RagRetrievalResult result = new RagRetrievalResult();
         RagRetrievalResult result = new RagRetrievalResult();
         result.setSourceType(binding.getSourceType());
         result.setSourceType(binding.getSourceType());
@@ -140,6 +144,64 @@ public class KnowledgeBaseRagRetriever {
         }
         }
     }
     }
 
 
+    private static void rerank(KnowledgeBaseRagResult out, String question) {
+        if (out.getEvidences().size() <= 1) return;
+        out.getEvidences().forEach(evidence -> {
+            double score = rerankScore(evidence, question);
+            Map<String, Object> metadata = new LinkedHashMap<>(
+                    evidence.getMetadata() == null ? Map.of() : evidence.getMetadata());
+            metadata.put("rerankScore", score);
+            evidence.setMetadata(metadata);
+        });
+        out.getEvidences().sort(Comparator.comparingDouble(
+                (RagEvidence evidence) -> number(evidence.getMetadata() == null ? null : evidence.getMetadata().get("rerankScore")))
+                .reversed());
+        out.getDiagnostics().put("rerank", Map.of("method", "lexical-source-weight", "evidenceCount", out.getEvidences().size()));
+    }
+
+    private static double rerankScore(RagEvidence evidence, String question) {
+        double base = evidence.getScore() == null ? 0 : evidence.getScore();
+        double sourceWeight = switch (evidence.getSourceType()) {
+            case STRUCTURED_DATA -> 0.18;
+            case GRAPH -> 0.16;
+            case DOCUMENT -> 0.08;
+        };
+        double exactness = switch (String.valueOf(evidence.getEvidenceType())) {
+            case "SQL_RESULT", "GRAPH_RESULT" -> 0.12;
+            case "SQL_DIAGNOSTIC" -> -0.15;
+            default -> 0.0;
+        };
+        return base + sourceWeight + exactness + overlap(question, evidenceText(evidence));
+    }
+
+    private static String evidenceText(RagEvidence evidence) {
+        StringBuilder text = new StringBuilder();
+        text.append(evidence.getTitle()).append(' ').append(evidence.getContent()).append(' ');
+        if (evidence.getPayload() != null) text.append(evidence.getPayload());
+        return text.toString();
+    }
+
+    private static double overlap(String question, String evidence) {
+        if (question == null || question.isBlank() || evidence == null || evidence.isBlank()) return 0;
+        String lowerEvidence = evidence.toLowerCase(Locale.ROOT);
+        LinkedHashSet<String> terms = new LinkedHashSet<>();
+        java.util.regex.Matcher ascii = java.util.regex.Pattern.compile("[A-Za-z0-9_]{3,}").matcher(question);
+        while (ascii.find()) terms.add(ascii.group().toLowerCase(Locale.ROOT));
+        java.util.regex.Matcher han = java.util.regex.Pattern.compile("\\p{IsHan}{2,}").matcher(question);
+        while (han.find()) {
+            String run = han.group();
+            if (run.length() <= 4) terms.add(run);
+            else for (int i = 0; i < run.length() - 1; i++) terms.add(run.substring(i, i + 2));
+        }
+        if (terms.isEmpty()) return 0;
+        long hits = terms.stream().filter(term -> lowerEvidence.contains(term.toLowerCase(Locale.ROOT))).count();
+        return Math.min(0.35, hits * 0.035);
+    }
+
+    private static double number(Object value) {
+        return value instanceof Number number ? number.doubleValue() : 0;
+    }
+
     private record BindingResult(RagKnowledgeSourceBinding binding, RagRetrievalResult result) {
     private record BindingResult(RagKnowledgeSourceBinding binding, RagRetrievalResult result) {
         String key() { return binding.getSourceType() + ":" + binding.getSourceId(); }
         String key() { return binding.getSourceType() + ":" + binding.getSourceId(); }
     }
     }

+ 1 - 2
backend/src/main/java/com/agent/management/rag/kb/RagKnowledgeBaseConfigService.java

@@ -67,8 +67,7 @@ public class RagKnowledgeBaseConfigService {
 
 
     private static void validateAuthorization(RagKnowledgeSourceBinding binding, Map<String,Object> config) {
     private static void validateAuthorization(RagKnowledgeSourceBinding binding, Map<String,Object> config) {
         if (config == null || !"AUTO_GENERATE".equals(String.valueOf(config.get("retrievalMode")))) return;
         if (config == null || !"AUTO_GENERATE".equals(String.valueOf(config.get("retrievalMode")))) return;
-        String key = binding.getSourceType() == RagSourceType.GRAPH ? "allowedLabels"
-                : binding.getSourceType() == RagSourceType.STRUCTURED_DATA ? "allowedTables" : null;
+        String key = binding.getSourceType() == RagSourceType.GRAPH ? "allowedLabels" : null;
         if (key != null && (!(config.get(key) instanceof List<?> values) || values.isEmpty()))
         if (key != null && (!(config.get(key) instanceof List<?> values) || values.isEmpty()))
             throw new IllegalArgumentException("automatic generation requires a non-empty explicit " + key);
             throw new IllegalArgumentException("automatic generation requires a non-empty explicit " + key);
     }
     }

+ 310 - 36
backend/src/main/java/com/agent/management/rag/structured/StructuredDataRagRetriever.java

@@ -1,23 +1,35 @@
 package com.agent.management.rag.structured;
 package com.agent.management.rag.structured;
 
 
 import com.agent.management.rag.api.RagRetriever;
 import com.agent.management.rag.api.RagRetriever;
-import com.agent.management.rag.memory.RagExampleMemoryService;
-import com.agent.management.rag.model.*;
+import com.agent.management.rag.model.RagEvidence;
+import com.agent.management.rag.model.RagQuery;
+import com.agent.management.rag.model.RagRetrievalResult;
+import com.agent.management.rag.model.RagSourceRef;
+import com.agent.management.rag.model.RagSourceType;
 import com.agent.management.service.DataSourceService;
 import com.agent.management.service.DataSourceService;
 import com.agent.management.service.SchemaExplorerService;
 import com.agent.management.service.SchemaExplorerService;
 import lombok.RequiredArgsConstructor;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
-import java.util.*;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.regex.Pattern;
 
 
 @Service
 @Service
 @RequiredArgsConstructor
 @RequiredArgsConstructor
 public class StructuredDataRagRetriever implements RagRetriever {
 public class StructuredDataRagRetriever implements RagRetriever {
     private final ExplicitSqlGenerationService explicit;
     private final ExplicitSqlGenerationService explicit;
     private final VannaSqlGenerationService generated;
     private final VannaSqlGenerationService generated;
+    private final StructuredSqlRelationshipValidator relationshipValidator;
+    private final StructuredQueryContextService contexts;
     private final SchemaExplorerService explorer;
     private final SchemaExplorerService explorer;
     private final DataSourceService dataSources;
     private final DataSourceService dataSources;
-    private final RagExampleMemoryService exampleMemory;
 
 
     public RagSourceType sourceType() { return RagSourceType.STRUCTURED_DATA; }
     public RagSourceType sourceType() { return RagSourceType.STRUCTURED_DATA; }
 
 
@@ -26,32 +38,75 @@ public class StructuredDataRagRetriever implements RagRetriever {
         out.setSourceType(sourceType());
         out.setSourceType(sourceType());
         try {
         try {
             Long id = sourceId(query);
             Long id = sourceId(query);
-            Optional<String> sql = explicit.generateSql(query, id);
-            boolean auto = false;
-            if (sql.isEmpty()) {
-                auto = true;
+            Optional<String> explicitSql = explicit.generateSql(query, id);
+            boolean auto = explicitSql.isEmpty();
+            String sql;
+            StructuredQueryContext context = null;
+            List<String> warnings = List.of();
+            List<String> usedRelationships = List.of();
+            List<String> relationshipWarnings = List.of();
+            int repairCount = 0;
+
+            if (auto) {
+                if (!isMysqlSource(id)) {
+                    addStructuredDiagnostic(out, id, "UNSUPPORTED_SQL_DIALECT",
+                            "自动 Text2SQL 当前仅支持 MySQL;该数据源方言暂不支持自动生成 SQL。", Map.of());
+                    return out;
+                }
+                try {
+                    context = contexts.build(query, id);
+                } catch (Exception error) {
+                    out.getDiagnostics().put("error", "structured query context failed: " + error.getMessage());
+                    return out;
+                }
+                Optional<MetricDiagnostic> metricDiagnostic = metricDiagnostic(query, context);
+                if (metricDiagnostic.isPresent()) {
+                    addStructuredDiagnostic(out, id, metricDiagnostic.get().code(), metricDiagnostic.get().message(),
+                            metricDiagnostic.get().metadata());
+                    return out;
+                }
+                VannaSqlGenerationService.GeneratedSql generation;
                 try {
                 try {
-                    sql = generated.generateSql(query, id);
+                    Optional<VannaSqlGenerationService.GeneratedSql> generatedSql = generated.generate(query, id, context);
+                    if (generatedSql.isEmpty()) {
+                        out.getDiagnostics().put("warning", "no SQL provided and automatic generation is disabled or returned empty");
+                        return out;
+                    }
+                    generation = generatedSql.get();
                 } catch (Exception error) {
                 } catch (Exception error) {
                     out.getDiagnostics().put("error", "SQL generation failed: " + error.getMessage());
                     out.getDiagnostics().put("error", "SQL generation failed: " + error.getMessage());
                     return out;
                     return out;
                 }
                 }
+                sql = generation.sql();
+                context = generation.context();
+                if (!id.equals(context.dataSourceId())) {
+                    throw new IllegalStateException("generated SQL context belongs to another datasource");
+                }
+                warnings = generation.warnings();
+                PreflightResult preflight = preflightAndRepair(query, id, sql, context, out);
+                if (preflight.sql().isEmpty()) return out;
+                sql = preflight.sql().get();
+                repairCount = preflight.repairCount();
+                usedRelationships = preflight.usedRelationships();
+                relationshipWarnings = preflight.relationshipWarnings();
+            } else {
+                sql = explicitSql.get();
+                try {
+                    explorer.explainQuery(id, sql);
+                } catch (Exception error) {
+                    out.getDiagnostics().put("error", "explicit SQL EXPLAIN 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);
             int limit = maxRows(query);
-            var result = explorer.executeQuery(id, sql.get(), limit);
+            SchemaExplorerService.QueryResult result = explorer.executeQuery(id, sql, limit);
+            var source = dataSources.get(id);
             RagEvidence evidence = new RagEvidence();
             RagEvidence evidence = new RagEvidence();
             evidence.setId("sql-" + UUID.randomUUID());
             evidence.setId("sql-" + UUID.randomUUID());
             evidence.setSourceType(sourceType());
             evidence.setSourceType(sourceType());
             evidence.setEvidenceType("SQL_RESULT");
             evidence.setEvidenceType("SQL_RESULT");
-            evidence.setTitle(dataSources.get(id).getName());
+            evidence.setTitle(source.getName());
             evidence.setSourceName(evidence.getTitle());
             evidence.setSourceName(evidence.getTitle());
             evidence.setContent(summary(result));
             evidence.setContent(summary(result));
             evidence.setScore(1.0);
             evidence.setScore(1.0);
@@ -62,54 +117,273 @@ public class StructuredDataRagRetriever implements RagRetriever {
             evidence.setSourceRef(ref);
             evidence.setSourceRef(ref);
             evidence.setPayload(Map.of("columns", result.columns(), "rows", result.rows(), "rowCount", result.rows().size()));
             evidence.setPayload(Map.of("columns", result.columns(), "rows", result.rows(), "rowCount", result.rows().size()));
             Map<String, Object> metadata = new LinkedHashMap<>();
             Map<String, Object> metadata = new LinkedHashMap<>();
-            metadata.put("sql", sql.get());
-            metadata.put(auto ? "generatedSql" : "explicitSql", sql.get());
+            metadata.put("sql", sql);
+            metadata.put(auto ? "generatedSql" : "explicitSql", sql);
             metadata.put("executionTimeMs", result.executionTimeMs());
             metadata.put("executionTimeMs", result.executionTimeMs());
-            metadata.put("warnings", List.of());
+            metadata.put("dataSourceId", id);
+            metadata.put("warnings", warnings);
+            metadata.put("dialect", context == null
+                    ? (source.getType() == null ? "" : source.getType().toLowerCase(Locale.ROOT))
+                    : context.dialect());
+            metadata.put("schemaVersion", context == null ? "" : context.schemaVersion());
+            metadata.put("schemaFingerprint", context == null ? "" : context.schemaFingerprint());
+            metadata.put("profileVersion", context == null ? "" : context.profileVersion());
+            metadata.put("selectedTables", context == null ? List.of() : context.selectedTables());
+            metadata.put("sampledValueFields", context == null ? List.of() : context.sampledValueFields());
+            metadata.put("referenceTables", context == null ? List.of() : context.referenceTables());
+            metadata.put("contextWarnings", context == null ? List.of() : context.warnings());
+            metadata.put("usedRelationships", usedRelationships);
+            metadata.put("relationshipWarnings", relationshipWarnings);
+            metadata.put("repaired", repairCount > 0);
+            metadata.put("repairCount", repairCount);
             evidence.setMetadata(metadata);
             evidence.setMetadata(metadata);
             out.getEvidences().add(evidence);
             out.getEvidences().add(evidence);
-            if (auto && autoLearn(query) && !result.rows().isEmpty())
-                exampleMemory.recordSuccess(sourceType(), String.valueOf(id), query.getQuery(), sql.get());
         } catch (Exception error) {
         } catch (Exception error) {
             out.getDiagnostics().put("error", error.getMessage());
             out.getDiagnostics().put("error", error.getMessage());
         }
         }
         return out;
         return out;
     }
     }
 
 
-    private Optional<String> preflightAndRepair(RagQuery query, Long sourceId, String sql, RagRetrievalResult out) {
+    private PreflightResult preflightAndRepair(RagQuery query, Long sourceId, String sql,
+                                               StructuredQueryContext context, RagRetrievalResult out) {
         try {
         try {
+            StructuredSqlRelationshipValidator.ValidationResult validation = relationshipValidator.validate(sql, context);
             explorer.explainQuery(sourceId, sql);
             explorer.explainQuery(sourceId, sql);
-            return Optional.of(sql);
-        } catch (Exception explainError) {
+            return new PreflightResult(Optional.of(sql), 0,
+                    validation.usedRelationships(), validation.warnings());
+        } catch (Exception preflightError) {
             try {
             try {
-                Optional<String> repaired = generated.repair(query, sourceId, sql, explainError.getMessage());
+                Optional<String> repaired = generated.repair(query, sourceId, sql, preflightError.getMessage(), context);
                 if (repaired.isEmpty()) throw new IllegalArgumentException("repair returned empty SQL");
                 if (repaired.isEmpty()) throw new IllegalArgumentException("repair returned empty SQL");
+                StructuredSqlRelationshipValidator.ValidationResult validation =
+                        relationshipValidator.validate(repaired.get(), context);
                 explorer.explainQuery(sourceId, repaired.get());
                 explorer.explainQuery(sourceId, repaired.get());
                 out.getDiagnostics().put("repair", "generated SQL failed EXPLAIN and was repaired once");
                 out.getDiagnostics().put("repair", "generated SQL failed EXPLAIN and was repaired once");
-                return repaired;
+                return new PreflightResult(repaired, 1,
+                        validation.usedRelationships(), validation.warnings());
             } catch (Exception repairError) {
             } catch (Exception repairError) {
                 out.getDiagnostics().put("error", "SQL preflight/repair failed: " + repairError.getMessage());
                 out.getDiagnostics().put("error", "SQL preflight/repair failed: " + repairError.getMessage());
-                return Optional.empty();
+                return new PreflightResult(Optional.empty(), 1, List.of(), List.of(preflightError.getMessage()));
             }
             }
         }
         }
     }
     }
 
 
-    private static boolean autoLearn(RagQuery query) {
-        return query.getFilters() == null || !Boolean.FALSE.equals(query.getFilters().get("autoLearnExamples"));
-    }
-
     private static int maxRows(RagQuery query) {
     private static int maxRows(RagQuery query) {
         Object configured = query.getFilters() == null ? null : query.getFilters().get("maxRows");
         Object configured = query.getFilters() == null ? null : query.getFilters().get("maxRows");
-        int value = configured instanceof Number number ? number.intValue() : (query.getTopK() != null ? query.getTopK() : 50);
+        int value = configured instanceof Number number ? number.intValue()
+                : (query.getTopK() != null ? query.getTopK() : 50);
         return Math.max(1, Math.min(value, 1000));
         return Math.max(1, Math.min(value, 1000));
     }
     }
 
 
     private static Long sourceId(RagQuery query) {
     private static Long sourceId(RagQuery query) {
-        if (query.getSourceIds() == null || query.getSourceIds().isEmpty()) throw new IllegalArgumentException("sourceIds must contain datasourceId");
+        if (query.getSourceIds() == null || query.getSourceIds().isEmpty()) {
+            throw new IllegalArgumentException("sourceIds must contain datasourceId");
+        }
+        if (query.getSourceIds().size() != 1) {
+            throw new IllegalArgumentException("structured Text2SQL supports exactly one datasourceId per request");
+        }
         return Long.valueOf(query.getSourceIds().get(0));
         return Long.valueOf(query.getSourceIds().get(0));
     }
     }
 
 
+    private void addStructuredDiagnostic(RagRetrievalResult out, Long sourceId, String code,
+                                         String message, Map<String, Object> metadata) {
+        out.getDiagnostics().put("warning", message);
+        var source = dataSources.get(sourceId);
+        RagEvidence evidence = new RagEvidence();
+        evidence.setId("sql-diagnostic-" + UUID.randomUUID());
+        evidence.setSourceType(sourceType());
+        evidence.setEvidenceType("SQL_DIAGNOSTIC");
+        evidence.setTitle(source.getName());
+        evidence.setSourceName(evidence.getTitle());
+        evidence.setContent(message);
+        evidence.setScore(1.0);
+        RagSourceRef ref = new RagSourceRef();
+        ref.setSourceId(String.valueOf(sourceId));
+        ref.setSourceName(evidence.getTitle());
+        ref.setLocator(Map.of("datasourceId", sourceId));
+        evidence.setSourceRef(ref);
+        evidence.setPayload(Map.of("columns", List.of(), "rows", List.of(), "rowCount", 0));
+        Map<String, Object> result = new LinkedHashMap<>(metadata == null ? Map.of() : metadata);
+        result.put("diagnostic", code);
+        result.put("dataSourceId", sourceId);
+        evidence.setMetadata(result);
+        out.getEvidences().add(evidence);
+    }
+
+    private boolean isMysqlSource(Long sourceId) {
+        var source = dataSources.get(sourceId);
+        return source.getType() != null && "MYSQL".equalsIgnoreCase(source.getType());
+    }
+
+    private Optional<MetricDiagnostic> metricDiagnostic(RagQuery query, StructuredQueryContext context) {
+        if (!isMetricQuestion(query.getQuery())) return Optional.empty();
+        List<String> metricFields = metricFields(context);
+        if (metricFields.isEmpty()) {
+            return Optional.of(new MetricDiagnostic("NO_STRUCTURED_METRIC_DATA",
+                    "结构化库无可支撑该指标的预计算数据;当前画像未发现评分、排名或优先级字段。",
+                    Map.of("missing", "metric_fields", "selectedTables", context.selectedTables())));
+        }
+        List<String> mentions = entityMentions(query, context);
+        if (!mentions.isEmpty()
+                && !mentionsGroundedForMetricQuery(mentions, metricFields, context)
+                && !mentionsGroundedInScopedEntityFields(mentions, context)
+                && !mentionsExistInScopedEntityFields(mentions, context)) {
+            return Optional.of(new MetricDiagnostic("NO_STRUCTURED_METRIC_DATA",
+                    "结构化库无可支撑该指标的预计算数据;问题中的实体或候选值未出现在含指标字段的数据表中。",
+                    Map.of("missing", "metric_entity_values", "entityMentions", mentions,
+                            "metricFields", metricFields, "selectedTables", context.selectedTables())));
+        }
+        return Optional.empty();
+    }
+
+    private static boolean isMetricQuestion(String question) {
+        String text = question == null ? "" : question.toLowerCase(Locale.ROOT);
+        return containsAny(text, "评分", "得分", "score", "final_score", "排名", "排序", "优先级",
+                "优先", "综合", "评估", "top", "best", "highest", "rank", "rating");
+    }
+
+    private static List<String> metricFields(StructuredQueryContext context) {
+        List<String> result = new java.util.ArrayList<>();
+        for (String rawLine : context.minimalDdl().split("\\R")) {
+            String line = rawLine.trim();
+            java.util.regex.Matcher table = Pattern.compile("(?i)^TABLE\\s+`?([A-Za-z_][A-Za-z0-9_]*)`?\\s*\\((.*)\\)").matcher(line);
+            if (!table.find()) continue;
+            String tableName = table.group(1);
+            for (String rawColumn : table.group(2).split(",")) {
+                java.util.regex.Matcher column = Pattern.compile("\\s*`?([A-Za-z_][A-Za-z0-9_]*)`?\\s+").matcher(rawColumn);
+                if (!column.find()) continue;
+                String name = column.group(1).toLowerCase(Locale.ROOT);
+                if (name.equals("score") || name.endsWith("_score") || name.contains("rank")
+                        || name.contains("priority") || name.contains("rating")
+                        || name.equals("weight") || name.equals("level")) {
+                    result.add(tableName + "." + column.group(1));
+                }
+            }
+        }
+        return result;
+    }
+
+    private static List<String> entityMentions(RagQuery query, StructuredQueryContext context) {
+        LinkedHashSet<String> result = new LinkedHashSet<>();
+        if (query.getQuery() != null) {
+            java.util.regex.Matcher matcher = Pattern.compile("(?:[A-Z][A-Z0-9]*_)+(?:[A-Z0-9]+)").matcher(query.getQuery());
+            while (matcher.find()) result.add(matcher.group());
+        }
+        return result.stream().toList();
+    }
+
+    private static boolean mentionsGroundedForMetricQuery(List<String> mentions, List<String> metricFields,
+                                                          StructuredQueryContext context) {
+        Set<String> metricTables = metricFields.stream()
+                .map(field -> field.substring(0, field.indexOf('.')))
+                .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
+        String evidence = metricReachableTables(metricTables, context).stream()
+                .map(table -> valuesForTable(table, context))
+                .reduce("", (left, right) -> left + " " + right)
+                .toLowerCase(Locale.ROOT);
+        return mentions.stream().map(value -> value.toLowerCase(Locale.ROOT)).anyMatch(evidence::contains);
+    }
+
+    private static Set<String> metricReachableTables(Set<String> metricTables, StructuredQueryContext context) {
+        Set<String> result = new LinkedHashSet<>(metricTables);
+        for (StructuredRelationship relationship : context.relationships()) {
+            if (metricTables.contains(relationship.sourceTable())) result.add(relationship.targetTable());
+            if (metricTables.contains(relationship.targetTable())) result.add(relationship.sourceTable());
+        }
+        for (var foreignKey : context.foreignKeys()) {
+            if (metricTables.contains(foreignKey.sourceTable())) result.add(foreignKey.targetTable());
+            if (metricTables.contains(foreignKey.targetTable())) result.add(foreignKey.sourceTable());
+        }
+        return result;
+    }
+
+    private static boolean mentionsGroundedInScopedEntityFields(List<String> mentions, StructuredQueryContext context) {
+        StringBuilder evidence = new StringBuilder();
+        context.sampledValues().forEach((field, values) -> {
+            if (isScopedEntityField(field)) values.forEach(value -> evidence.append(' ').append(value));
+        });
+        String text = evidence.toString().toLowerCase(Locale.ROOT);
+        return mentions.stream().map(value -> value.toLowerCase(Locale.ROOT)).anyMatch(text::contains);
+    }
+
+    private static boolean isScopedEntityField(String field) {
+        String value = field == null ? "" : field.toLowerCase(Locale.ROOT);
+        int index = value.indexOf('.');
+        String table = index < 0 ? "" : value.substring(0, index);
+        String column = index < 0 ? value : value.substring(index + 1);
+        return table.contains("scenario") || table.contains("mission")
+                || column.contains("target") || column.contains("candidate")
+                || column.contains("platform") || column.contains("scenario")
+                || column.contains("mission");
+    }
+
+    private boolean mentionsExistInScopedEntityFields(List<String> mentions, StructuredQueryContext context) {
+        for (ScopedField field : scopedEntityFields(context)) {
+            for (String mention : mentions) {
+                String sql = "SELECT 1 FROM " + quoteIdentifier(field.table()) + " WHERE "
+                        + quoteIdentifier(field.column()) + " = " + sqlLiteral(mention) + " LIMIT 1";
+                try {
+                    SchemaExplorerService.QueryResult result = explorer.executeQuery(context.dataSourceId(), sql, 1);
+                    if (!result.rows().isEmpty()) return true;
+                } catch (Exception ignored) {
+                    // A failed grounding probe should not break retrieval; the normal diagnostic will explain the miss.
+                }
+            }
+        }
+        return false;
+    }
+
+    private static List<ScopedField> scopedEntityFields(StructuredQueryContext context) {
+        List<ScopedField> result = new java.util.ArrayList<>();
+        for (String rawLine : context.minimalDdl().split("\\R")) {
+            String line = rawLine.trim();
+            java.util.regex.Matcher table = Pattern.compile("(?i)^TABLE\\s+`?([A-Za-z_][A-Za-z0-9_]*)`?\\s*\\((.*)\\)").matcher(line);
+            if (!table.find()) continue;
+            String tableName = table.group(1);
+            for (String rawColumn : table.group(2).split(",")) {
+                java.util.regex.Matcher column = Pattern.compile("\\s*`?([A-Za-z_][A-Za-z0-9_]*)`?\\s+").matcher(rawColumn);
+                if (!column.find()) continue;
+                String columnName = column.group(1);
+                if (isScopedEntityField(tableName + "." + columnName)) result.add(new ScopedField(tableName, columnName));
+            }
+        }
+        return result;
+    }
+
+    private static String quoteIdentifier(String identifier) {
+        return "`" + identifier.replace("`", "") + "`";
+    }
+
+    private static String sqlLiteral(String value) {
+        return "'" + String.valueOf(value).replace("'", "''") + "'";
+    }
+
+    private static String valuesForTable(String table, StructuredQueryContext context) {
+        StringBuilder text = new StringBuilder();
+        context.sampledValues().forEach((field, values) -> {
+            if (field.startsWith(table + ".")) values.forEach(value -> text.append(' ').append(value));
+        });
+        context.referenceRows().getOrDefault(table, List.of()).forEach(row ->
+                row.values().forEach(value -> text.append(' ').append(value)));
+        return text.toString();
+    }
+
+    private static boolean containsAny(String text, String... values) {
+        for (String value : values) {
+            if (text.contains(value)) return true;
+        }
+        return false;
+    }
+
     private static String summary(SchemaExplorerService.QueryResult result) {
     private static String summary(SchemaExplorerService.QueryResult result) {
         return "查询返回 " + result.rows().size() + " 行,列:" + String.join(", ", result.columns());
         return "查询返回 " + result.rows().size() + " 行,列:" + String.join(", ", result.columns());
     }
     }
+
+    private record MetricDiagnostic(String code, String message, Map<String, Object> metadata) {}
+
+    private record PreflightResult(Optional<String> sql, int repairCount,
+                                   List<String> usedRelationships,
+                                   List<String> relationshipWarnings) {}
+
+    private record ScopedField(String table, String column) {}
 }
 }

+ 52 - 0
backend/src/main/java/com/agent/management/rag/structured/StructuredQueryContext.java

@@ -0,0 +1,52 @@
+package com.agent.management.rag.structured;
+
+import com.agent.management.rag.capability.SqlSchemaSnapshot;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/** Text2SQL 单次请求使用的最小、真实数据库上下文。 */
+public record StructuredQueryContext(
+        Long dataSourceId,
+        String dialect,
+        String schemaVersion,
+        String schemaFingerprint,
+        String profileVersion,
+        List<String> selectedTables,
+        String minimalDdl,
+        List<SqlSchemaSnapshot.ForeignKey> foreignKeys,
+        List<StructuredRelationship> relationships,
+        Map<String, List<Object>> sampledValues,
+        Map<String, List<Map<String, Object>>> referenceRows,
+        List<String> entityMentions,
+        List<String> warnings) {
+
+    public StructuredQueryContext {
+        selectedTables = List.copyOf(selectedTables == null ? List.of() : selectedTables);
+        foreignKeys = List.copyOf(foreignKeys == null ? List.of() : foreignKeys);
+        relationships = List.copyOf(relationships == null ? List.of() : relationships);
+        sampledValues = immutableMap(sampledValues);
+        referenceRows = immutableRows(referenceRows);
+        entityMentions = List.copyOf(entityMentions == null ? List.of() : entityMentions);
+        warnings = List.copyOf(warnings == null ? List.of() : warnings);
+    }
+
+    public List<String> sampledValueFields() { return List.copyOf(sampledValues.keySet()); }
+
+    public List<String> referenceTables() { return List.copyOf(referenceRows.keySet()); }
+
+    private static Map<String, List<Object>> immutableMap(Map<String, List<Object>> source) {
+        Map<String, List<Object>> result = new LinkedHashMap<>();
+        if (source != null) source.forEach((key, value) -> result.put(key, List.copyOf(value)));
+        return Map.copyOf(result);
+    }
+
+    private static Map<String, List<Map<String, Object>>> immutableRows(
+            Map<String, List<Map<String, Object>>> source) {
+        Map<String, List<Map<String, Object>>> result = new LinkedHashMap<>();
+        if (source != null) source.forEach((key, rows) -> result.put(key,
+                rows.stream().map(row -> Map.copyOf(new LinkedHashMap<>(row))).toList()));
+        return Map.copyOf(result);
+    }
+}

+ 227 - 0
backend/src/main/java/com/agent/management/rag/structured/StructuredQueryContextService.java

@@ -0,0 +1,227 @@
+package com.agent.management.rag.structured;
+
+import com.agent.management.rag.capability.RagEntityMentionExtractor;
+import com.agent.management.rag.capability.RagTextSimilarity;
+import com.agent.management.rag.capability.SqlSchemaSnapshot;
+import com.agent.management.rag.model.RagQuery;
+import com.agent.management.service.impl.EmbeddingBridgeClient;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+@Service
+@RequiredArgsConstructor
+public class StructuredQueryContextService {
+    private static final double LEXICAL_CONFIDENCE = 0.08;
+    private static final double EMBEDDING_CONFIDENCE = 0.25;
+    private static final int MAX_SEMANTIC_TABLES = 5;
+
+    private final StructuredSchemaProfileService profiles;
+    private final StructuredValueSampler sampler;
+    private final RagEntityMentionExtractor entities;
+    private final EmbeddingBridgeClient embeddings;
+
+    public StructuredQueryContext build(RagQuery query, Long dataSourceId) {
+        StructuredSchemaProfile profile = profiles.getProfile(dataSourceId);
+        if (!"mysql".equalsIgnoreCase(profile.dialect())) {
+            throw new IllegalArgumentException("automatic Text2SQL currently supports MySQL only");
+        }
+        if (!dataSourceId.equals(profile.dataSourceId())) {
+            throw new IllegalStateException("structured schema profile belongs to another datasource");
+        }
+        SqlSchemaSnapshot schema = profile.schemaSnapshot();
+        List<String> warnings = new ArrayList<>(profile.warnings());
+        List<String> selectedTables = selectTables(query.getQuery(), schema, warnings);
+        selectedTables = connectByRelationships(selectedTables, schema, profile.relationships());
+        selectedTables = expandMetricContext(query.getQuery(), selectedTables, schema, profile.relationships());
+        if (selectedTables.isEmpty()) {
+            selectedTables = schema.tables().stream().map(SqlSchemaSnapshot.TableSchema::name).toList();
+            warnings.add("table selection was empty; fell back to all visible schema tables");
+        }
+        String ddl = schema.toDdl(selectedTables);
+        if (ddl.isBlank()) {
+            selectedTables = schema.tables().stream().map(SqlSchemaSnapshot.TableSchema::name).toList();
+            ddl = schema.toDdl(selectedTables);
+            warnings.add("minimal DDL was empty; fell back to all visible schema tables");
+        }
+
+        List<String> selected = selectedTables;
+        List<SqlSchemaSnapshot.ForeignKey> selectedForeignKeys = schema.foreignKeys().stream()
+                .filter(key -> selected.contains(key.sourceTable()) && selected.contains(key.targetTable())).toList();
+        List<StructuredRelationship> selectedRelationships = profile.relationships().stream()
+                .filter(relation -> relation.confidence() >= 0.58)
+                .filter(relation -> selected.contains(relation.sourceTable()) && selected.contains(relation.targetTable()))
+                .toList();
+        StructuredValueSampler.SampleResult samples = sampler.sample(
+                dataSourceId, selectedTables, profile.columnSpecs());
+        warnings.addAll(samples.warnings());
+        return new StructuredQueryContext(
+                dataSourceId,
+                profile.dialect(),
+                profile.schemaFingerprint(),
+                profile.schemaFingerprint(),
+                profile.profileVersion(),
+                selectedTables,
+                ddl,
+                selectedForeignKeys,
+                selectedRelationships,
+                samples.sampledValues(),
+                samples.referenceRows(),
+                entities.extract(query.getQuery()),
+                warnings);
+    }
+
+    List<String> selectTables(String question, SqlSchemaSnapshot schema, List<String> warnings) {
+        List<TableScore> lexical = schema.tables().stream()
+                .map(table -> new TableScore(table.name(), RagTextSimilarity.score(question, semantics(table))))
+                .sorted(Comparator.comparingDouble(TableScore::score).reversed()).toList();
+        double lexicalMax = lexical.stream().mapToDouble(TableScore::score).max().orElse(0);
+        if (lexicalMax >= LEXICAL_CONFIDENCE) {
+            double threshold = Math.max(LEXICAL_CONFIDENCE, lexicalMax * 0.55);
+            return lexical.stream().filter(item -> item.score() >= threshold)
+                    .limit(MAX_SEMANTIC_TABLES).map(TableScore::name).toList();
+        }
+
+        try {
+            List<String> inputs = new ArrayList<>();
+            inputs.add(question == null ? "" : question);
+            schema.tables().stream().map(StructuredQueryContextService::semantics).forEach(inputs::add);
+            List<List<Double>> vectors = embeddings.embed(inputs);
+            if (vectors.size() == inputs.size()) {
+                List<TableScore> semantic = new ArrayList<>();
+                for (int i = 0; i < schema.tables().size(); i++) {
+                    semantic.add(new TableScore(schema.tables().get(i).name(), cosine(vectors.get(0), vectors.get(i + 1))));
+                }
+                semantic.sort(Comparator.comparingDouble(TableScore::score).reversed());
+                double max = semantic.isEmpty() ? 0 : semantic.get(0).score();
+                if (max >= EMBEDDING_CONFIDENCE) {
+                    double threshold = Math.max(EMBEDDING_CONFIDENCE, max - 0.08);
+                    List<String> selected = semantic.stream().filter(item -> item.score() >= threshold)
+                            .limit(MAX_SEMANTIC_TABLES).map(TableScore::name).toList();
+                    if (!selected.isEmpty()) return selected;
+                }
+            }
+        } catch (Exception error) {
+            warnings.add("semantic table selection unavailable: " + shortMessage(error));
+        }
+        warnings.add("table selection confidence was low; fell back to all visible schema tables");
+        return schema.tables().stream().map(SqlSchemaSnapshot.TableSchema::name).toList();
+    }
+
+    static List<String> connectByRelationships(List<String> selected, SqlSchemaSnapshot schema,
+                                               List<StructuredRelationship> relationships) {
+        List<StructuredRelationship> usable = relationships.stream()
+                .filter(StructuredRelationship::usableForSelection).toList();
+        LinkedHashSet<String> result = new LinkedHashSet<>(selected);
+        for (int i = 0; i < selected.size(); i++) {
+            for (int j = i + 1; j < selected.size(); j++) {
+                result.addAll(shortestPath(selected.get(i), selected.get(j), usable));
+            }
+        }
+        Set<String> schemaTables = schema.tables().stream().map(SqlSchemaSnapshot.TableSchema::name)
+                .collect(java.util.stream.Collectors.toSet());
+        return result.stream().filter(schemaTables::contains).toList();
+    }
+
+    static List<String> expandMetricContext(String question, List<String> selected, SqlSchemaSnapshot schema,
+                                            List<StructuredRelationship> relationships) {
+        if (!isMetricQuestion(question)) return selected;
+        Set<String> schemaTables = schema.tables().stream().map(SqlSchemaSnapshot.TableSchema::name)
+                .collect(java.util.stream.Collectors.toSet());
+        Set<String> metricTables = schema.tables().stream()
+                .filter(StructuredQueryContextService::hasMetricColumn)
+                .map(SqlSchemaSnapshot.TableSchema::name)
+                .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
+        LinkedHashSet<String> result = new LinkedHashSet<>(selected);
+        relationships.stream()
+                .filter(StructuredRelationship::usableForSelection)
+                .filter(relation -> metricTables.contains(relation.sourceTable())
+                        || metricTables.contains(relation.targetTable()))
+                .forEach(relation -> {
+                    result.add(relation.sourceTable());
+                    result.add(relation.targetTable());
+                });
+        return result.stream().filter(schemaTables::contains).toList();
+    }
+
+    private static boolean isMetricQuestion(String question) {
+        String text = question == null ? "" : question.toLowerCase(Locale.ROOT);
+        return containsAny(text, "评分", "得分", "score", "final_score", "排名", "排序", "优先级",
+                "优先", "综合", "评估", "top", "best", "highest", "rank", "rating");
+    }
+
+    private static boolean hasMetricColumn(SqlSchemaSnapshot.TableSchema table) {
+        return table.columns().stream().anyMatch(column -> isMetricColumn(column.name()));
+    }
+
+    private static boolean isMetricColumn(String column) {
+        String name = column == null ? "" : column.toLowerCase(Locale.ROOT);
+        return name.equals("score") || name.endsWith("_score") || name.contains("rank")
+                || name.contains("priority") || name.contains("rating")
+                || name.equals("weight") || name.equals("level");
+    }
+
+    private static List<String> shortestPath(String start, String target,
+                                             List<StructuredRelationship> relationships) {
+        Map<String, Set<String>> graph = new HashMap<>();
+        for (StructuredRelationship relation : relationships) {
+            graph.computeIfAbsent(relation.sourceTable(), ignored -> new LinkedHashSet<>()).add(relation.targetTable());
+            graph.computeIfAbsent(relation.targetTable(), ignored -> new LinkedHashSet<>()).add(relation.sourceTable());
+        }
+        ArrayDeque<List<String>> queue = new ArrayDeque<>();
+        queue.add(List.of(start));
+        Set<String> visited = new HashSet<>();
+        while (!queue.isEmpty()) {
+            List<String> path = queue.removeFirst();
+            String current = path.get(path.size() - 1);
+            if (!visited.add(current)) continue;
+            if (current.equals(target)) return path;
+            for (String next : graph.getOrDefault(current, Set.of())) {
+                List<String> extended = new ArrayList<>(path); extended.add(next); queue.addLast(extended);
+            }
+        }
+        return List.of();
+    }
+
+    private static String semantics(SqlSchemaSnapshot.TableSchema table) {
+        StringBuilder text = new StringBuilder(table.name().replace('_', ' ')).append(' ')
+                .append(table.description() == null ? "" : table.description());
+        table.columns().forEach(column -> text.append(' ').append(column.name().replace('_', ' '))
+                .append(' ').append(column.description() == null ? "" : column.description()));
+        return text.toString();
+    }
+
+    private static double cosine(List<Double> left, List<Double> right) {
+        if (left == null || right == null || left.size() != right.size() || left.isEmpty()) return 0;
+        double dot = 0, leftNorm = 0, rightNorm = 0;
+        for (int i = 0; i < left.size(); i++) {
+            double a = left.get(i), b = right.get(i);
+            dot += a * b; leftNorm += a * a; rightNorm += b * b;
+        }
+        return leftNorm == 0 || rightNorm == 0 ? 0 : dot / Math.sqrt(leftNorm * rightNorm);
+    }
+
+    private static String shortMessage(Exception error) {
+        String message = error.getMessage();
+        return message == null ? error.getClass().getSimpleName() : message.substring(0, Math.min(200, message.length()));
+    }
+
+    private static boolean containsAny(String text, String... values) {
+        for (String value : values) {
+            if (text.contains(value)) return true;
+        }
+        return false;
+    }
+
+    private record TableScore(String name, double score) {}
+}

+ 36 - 0
backend/src/main/java/com/agent/management/rag/structured/StructuredRelationship.java

@@ -0,0 +1,36 @@
+package com.agent.management.rag.structured;
+
+import java.util.List;
+
+public record StructuredRelationship(
+        RelationshipType type,
+        String sourceTable,
+        String sourceColumn,
+        String targetTable,
+        String targetColumn,
+        double confidence,
+        List<Condition> conditions,
+        List<String> evidence,
+        List<String> warnings) {
+
+    public StructuredRelationship {
+        conditions = List.copyOf(conditions == null ? List.of() : conditions);
+        evidence = List.copyOf(evidence == null ? List.of() : evidence);
+        warnings = List.copyOf(warnings == null ? List.of() : warnings);
+    }
+
+    public boolean usableForSelection() {
+        return type != RelationshipType.AMBIGUOUS && confidence >= 0.78;
+    }
+
+    public String id() {
+        String condition = conditions.stream().map(item -> item.table() + "." + item.column()
+                + item.operator() + String.valueOf(item.value())).reduce((a, b) -> a + "&" + b).orElse("");
+        return type + ":" + sourceTable + "." + sourceColumn + "->" + targetTable + "." + targetColumn
+                + (condition.isBlank() ? "" : "[" + condition + "]");
+    }
+
+    public enum RelationshipType { PHYSICAL_FK, INFERRED, CONDITIONAL, AMBIGUOUS }
+
+    public record Condition(String table, String column, String operator, Object value) {}
+}

+ 340 - 0
backend/src/main/java/com/agent/management/rag/structured/StructuredRelationshipDiscoveryService.java

@@ -0,0 +1,340 @@
+package com.agent.management.rag.structured;
+
+import com.agent.management.model.entity.DataSource;
+import com.agent.management.rag.capability.SqlSchemaSnapshot;
+import com.agent.management.service.DataSourceService;
+import com.agent.management.service.DynamicJdbcService;
+import com.agent.management.service.SchemaExplorerService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+@Service
+@RequiredArgsConstructor
+public class StructuredRelationshipDiscoveryService {
+    private static final int VALIDATION_SAMPLE_ROWS = 500;
+    private static final int MAX_TARGETS_PER_SOURCE_COLUMN = 6;
+    private static final int MAX_DISCRIMINATOR_VALUES = 20;
+
+    private final DataSourceService dataSources;
+    private final DynamicJdbcService jdbc;
+    private final SchemaExplorerService explorer;
+
+    public DiscoveryResult discover(Long dataSourceId, String catalog, String schema,
+                                    SqlSchemaSnapshot snapshot,
+                                    Map<String, List<SchemaExplorerService.ColumnSpec>> columnsByTable) {
+        List<String> warnings = new ArrayList<>();
+        List<StructuredRelationship> result = new ArrayList<>(physicalRelationships(snapshot));
+        Metadata metadata;
+        try {
+            metadata = metadata(dataSourceId, catalog, schema, snapshot);
+        } catch (Exception error) {
+            warnings.add("relationship metadata unavailable: " + shortMessage(error));
+            return new DiscoveryResult(result, warnings);
+        }
+
+        List<TargetColumn> targets = targetColumns(snapshot, columnsByTable, metadata.uniqueColumns());
+        for (SqlSchemaSnapshot.TableSchema sourceTable : snapshot.tables()) {
+            List<SchemaExplorerService.ColumnSpec> sourceColumns = columnsByTable.getOrDefault(sourceTable.name(), List.of());
+            List<SchemaExplorerService.ColumnSpec> discriminators = sourceColumns.stream()
+                    .filter(column -> isDiscriminator(column.name()) && isShortValueType(column.type())).limit(3).toList();
+            for (SchemaExplorerService.ColumnSpec sourceColumn : sourceColumns) {
+                if (!isRelationColumn(sourceColumn.name())) continue;
+                List<SchemaExplorerService.ColumnSpec> matchingDiscriminators = discriminators.stream()
+                        .filter(discriminator -> discriminatorStem(discriminator.name()).equals(stem(normalize(sourceColumn.name()))))
+                        .toList();
+                List<Candidate> candidates = targets.stream()
+                        .filter(target -> !target.table().equals(sourceTable.name()))
+                        .filter(target -> compatible(sourceColumn.type(), target.column().type()))
+                        .map(target -> new Candidate(sourceTable.name(), sourceColumn, target,
+                                nameScore(sourceTable.name(), sourceColumn, target)))
+                        .filter(candidate -> candidate.nameScore() >= 0.12 || !matchingDiscriminators.isEmpty())
+                        .sorted(Comparator.comparingDouble(Candidate::nameScore).reversed()
+                                .thenComparing(candidate -> candidate.target().table()))
+                        .limit(MAX_TARGETS_PER_SOURCE_COLUMN).toList();
+                List<ScoredCandidate> scored = new ArrayList<>();
+                for (Candidate candidate : candidates) {
+                    try {
+                        Coverage coverage = validate(dataSourceId, candidate, null, null);
+                        double confidence = score(candidate.nameScore(), coverage);
+                        if (confidence >= 0.58) scored.add(new ScoredCandidate(candidate, coverage, confidence));
+                    } catch (Exception error) {
+                        warnings.add("relationship validation " + sourceTable.name() + "." + sourceColumn.name()
+                                + " failed: " + shortMessage(error));
+                    }
+                }
+                markAmbiguity(scored);
+                for (ScoredCandidate candidate : scored) {
+                    boolean ambiguous = candidate.ambiguous();
+                    if (!ambiguous && candidate.candidate().nameScore() < 0.20 && !matchingDiscriminators.isEmpty()) {
+                        List<StructuredRelationship> conditional = conditionalRelationships(
+                                dataSourceId, candidate, matchingDiscriminators, warnings);
+                        if (!conditional.isEmpty()) {
+                            result.addAll(conditional);
+                            continue;
+                        }
+                    }
+                    result.add(toRelationship(candidate, ambiguous
+                            ? StructuredRelationship.RelationshipType.AMBIGUOUS
+                            : StructuredRelationship.RelationshipType.INFERRED));
+                }
+            }
+        }
+        return new DiscoveryResult(deduplicate(result), warnings);
+    }
+
+    private Metadata metadata(Long dataSourceId, String catalog, String schema, SqlSchemaSnapshot snapshot) throws Exception {
+        DataSource source = dataSources.getDecrypted(dataSourceId);
+        Map<String, Set<String>> unique = new LinkedHashMap<>();
+        try (Connection connection = jdbc.getConnection(source)) {
+            DatabaseMetaData meta = connection.getMetaData();
+            for (SqlSchemaSnapshot.TableSchema table : snapshot.tables()) {
+                Set<String> columns = new LinkedHashSet<>();
+                Map<String, List<String>> uniqueGroups = new LinkedHashMap<>();
+                try (var keys = meta.getPrimaryKeys(emptyToNull(catalog), emptyToNull(schema), table.name())) {
+                    while (keys.next()) uniqueGroups.computeIfAbsent(
+                            "PK:" + value(keys.getString("PK_NAME"), table.name()), ignored -> new ArrayList<>())
+                            .add(keys.getString("COLUMN_NAME"));
+                }
+                try (var indexes = meta.getIndexInfo(emptyToNull(catalog), emptyToNull(schema), table.name(), true, false)) {
+                    while (indexes.next()) {
+                        String column = indexes.getString("COLUMN_NAME");
+                        String index = indexes.getString("INDEX_NAME");
+                        if (column != null && index != null) uniqueGroups
+                                .computeIfAbsent("UK:" + index, ignored -> new ArrayList<>()).add(column);
+                    }
+                }
+                uniqueGroups.values().stream().filter(group -> group.size() == 1)
+                        .map(group -> group.get(0)).forEach(columns::add);
+                unique.put(table.name(), columns);
+            }
+        }
+        return new Metadata(unique);
+    }
+
+    private static List<TargetColumn> targetColumns(SqlSchemaSnapshot snapshot,
+                                                     Map<String, List<SchemaExplorerService.ColumnSpec>> columnsByTable,
+                                                     Map<String, Set<String>> uniqueColumns) {
+        List<TargetColumn> result = new ArrayList<>();
+        for (SqlSchemaSnapshot.TableSchema table : snapshot.tables()) {
+            Map<String, SchemaExplorerService.ColumnSpec> byName = new HashMap<>();
+            columnsByTable.getOrDefault(table.name(), List.of()).forEach(column -> byName.put(column.name(), column));
+            for (String name : uniqueColumns.getOrDefault(table.name(), Set.of())) {
+                SchemaExplorerService.ColumnSpec column = byName.get(name);
+                if (column != null && isRelationColumn(name)) result.add(new TargetColumn(table.name(), column));
+            }
+        }
+        return result;
+    }
+
+    private Coverage validate(Long dataSourceId, Candidate candidate, String discriminator, Object value) {
+        String sourceAlias = "s", targetAlias = "t";
+        StringBuilder where = new StringBuilder(quote(candidate.sourceColumn().name())).append(" IS NOT NULL");
+        if (discriminator != null) where.append(" AND ").append(quote(discriminator)).append(" = ").append(literal(value));
+        String sql = "SELECT COUNT(*), COUNT(DISTINCT " + sourceAlias + "." + quote(candidate.sourceColumn().name())
+                + "), SUM(CASE WHEN " + targetAlias + "." + quote(candidate.target().column().name())
+                + " IS NULL THEN 1 ELSE 0 END) FROM (SELECT " + quote(candidate.sourceColumn().name())
+                + " FROM " + quote(candidate.sourceTable()) + " WHERE " + where + " LIMIT " + VALIDATION_SAMPLE_ROWS
+                + ") " + sourceAlias + " LEFT JOIN " + quote(candidate.target().table()) + " " + targetAlias
+                + " ON " + sourceAlias + "." + quote(candidate.sourceColumn().name()) + " = " + targetAlias + "."
+                + quote(candidate.target().column().name());
+        SchemaExplorerService.QueryResult query = explorer.executeQuery(dataSourceId, sql, 1);
+        if (query.rows().isEmpty()) return new Coverage(0, 0, 0);
+        List<Object> row = query.rows().get(0);
+        return new Coverage(number(row, 0), number(row, 1), number(row, 2));
+    }
+
+    private List<StructuredRelationship> conditionalRelationships(
+            Long dataSourceId, ScoredCandidate candidate,
+            List<SchemaExplorerService.ColumnSpec> discriminators, List<String> warnings) {
+        for (SchemaExplorerService.ColumnSpec discriminator : discriminators) {
+            try {
+                String sql = "SELECT DISTINCT " + quote(discriminator.name()) + " FROM "
+                        + quote(candidate.candidate().sourceTable()) + " WHERE " + quote(discriminator.name())
+                        + " IS NOT NULL LIMIT " + (MAX_DISCRIMINATOR_VALUES + 1);
+                SchemaExplorerService.QueryResult values = explorer.executeQuery(
+                        dataSourceId, sql, MAX_DISCRIMINATOR_VALUES + 1);
+                if (values.rows().size() > MAX_DISCRIMINATOR_VALUES) continue;
+                List<StructuredRelationship> relations = new ArrayList<>();
+                for (List<Object> row : values.rows()) {
+                    if (row.isEmpty() || row.get(0) == null) continue;
+                    Coverage coverage = validate(dataSourceId, candidate.candidate(), discriminator.name(), row.get(0));
+                    double confidence = Math.min(1, score(candidate.candidate().nameScore(), coverage) + 0.08);
+                    if (confidence < 0.78) continue;
+                    relations.add(new StructuredRelationship(
+                            StructuredRelationship.RelationshipType.CONDITIONAL,
+                            candidate.candidate().sourceTable(), candidate.candidate().sourceColumn().name(),
+                            candidate.candidate().target().table(), candidate.candidate().target().column().name(),
+                            confidence,
+                            List.of(new StructuredRelationship.Condition(candidate.candidate().sourceTable(),
+                                    discriminator.name(), "=", row.get(0))),
+                            evidence(candidate.candidate(), coverage, "discriminator=" + discriminator.name()), List.of()));
+                }
+                if (!relations.isEmpty()) return relations;
+            } catch (Exception error) {
+                warnings.add("conditional relationship validation failed: " + shortMessage(error));
+            }
+        }
+        return List.of();
+    }
+
+    private static void markAmbiguity(List<ScoredCandidate> candidates) {
+        candidates.sort(Comparator.comparingDouble(ScoredCandidate::confidence).reversed());
+        for (int i = 0; i < candidates.size(); i++) {
+            for (int j = i + 1; j < candidates.size(); j++) {
+                ScoredCandidate left = candidates.get(i), right = candidates.get(j);
+                if (!left.candidate().sourceTable().equals(right.candidate().sourceTable())
+                        || !left.candidate().sourceColumn().name().equals(right.candidate().sourceColumn().name())) continue;
+                if (Math.abs(left.confidence() - right.confidence()) <= 0.05) {
+                    left.setAmbiguous(true); right.setAmbiguous(true);
+                }
+            }
+        }
+    }
+
+    private static StructuredRelationship toRelationship(ScoredCandidate candidate,
+                                                           StructuredRelationship.RelationshipType type) {
+        Candidate value = candidate.candidate();
+        return new StructuredRelationship(type, value.sourceTable(), value.sourceColumn().name(),
+                value.target().table(), value.target().column().name(), candidate.confidence(), List.of(),
+                evidence(value, candidate.coverage(), "targetUnique=true"),
+                type == StructuredRelationship.RelationshipType.AMBIGUOUS
+                        ? List.of("multiple target columns have similar evidence") : List.of());
+    }
+
+    private static List<StructuredRelationship> physicalRelationships(SqlSchemaSnapshot snapshot) {
+        return snapshot.foreignKeys().stream().map(key -> new StructuredRelationship(
+                StructuredRelationship.RelationshipType.PHYSICAL_FK,
+                key.sourceTable(), key.sourceColumn(), key.targetTable(), key.targetColumn(), 1,
+                List.of(), List.of("JDBC imported foreign key: " + String.valueOf(key.name())), List.of())).toList();
+    }
+
+    private static List<String> evidence(Candidate candidate, Coverage coverage, String extra) {
+        return List.of("nameScore=" + round(candidate.nameScore()), "sampleRows=" + coverage.sourceCount(),
+                "coverage=" + round(coverage.coverage()), "orphanRate=" + round(coverage.orphanRate()),
+                "distinctRatio=" + round(coverage.distinctRatio()), extra);
+    }
+
+    private static double score(double nameScore, Coverage coverage) {
+        if (coverage.sourceCount() == 0) return 0;
+        return Math.max(0, Math.min(1, nameScore + 0.20 + 0.35 * coverage.coverage()
+                + 0.10 * coverage.distinctRatio() - 0.20 * coverage.orphanRate()));
+    }
+
+    private static double nameScore(String sourceTable, SchemaExplorerService.ColumnSpec source,
+                                    TargetColumn target) {
+        String sourceName = normalize(source.name()), targetName = normalize(target.column().name());
+        String sourceStem = stem(sourceName), targetTable = singular(normalize(target.table()));
+        double score = 0;
+        if (sourceName.equals(targetName)) score += 0.35;
+        else if (sourceStem.equals(targetTable)) score += 0.30;
+        else if (sourceName.contains(targetTable) || targetTable.contains(sourceStem)) score += 0.20;
+        else if (suffix(sourceName).equals(suffix(targetName))) score += 0.08;
+        if (commentOverlap(source.remarks(), target.column().remarks())) score += 0.05;
+        if (singular(normalize(sourceTable)).equals(targetTable)) score -= 0.10;
+        return Math.max(0, score);
+    }
+
+    private static boolean compatible(String left, String right) {
+        return typeFamily(left).equals(typeFamily(right)) && !typeFamily(left).equals("OTHER");
+    }
+
+    private static String typeFamily(String type) {
+        String value = type == null ? "" : type.toUpperCase(Locale.ROOT);
+        if (value.contains("INT") || value.contains("DECIMAL") || value.contains("NUMERIC")) return "NUMBER";
+        if (value.contains("CHAR") || value.contains("TEXT") || value.contains("ENUM")) return "TEXT";
+        if (value.contains("UUID")) return "TEXT";
+        return "OTHER";
+    }
+
+    private static boolean isRelationColumn(String name) {
+        String value = normalize(name);
+        return value.equals("id") || value.equals("code") || value.endsWith("_id") || value.endsWith("_code");
+    }
+
+    private static boolean isDiscriminator(String name) {
+        String value = normalize(name);
+        return value.equals("type") || value.equals("category") || value.equals("kind")
+                || value.endsWith("_type") || value.endsWith("_category") || value.endsWith("_kind");
+    }
+
+    private static boolean isShortValueType(String type) { return "TEXT".equals(typeFamily(type)); }
+
+    private static boolean commentOverlap(String left, String right) {
+        if (left == null || right == null || left.isBlank() || right.isBlank()) return false;
+        Set<String> tokens = new HashSet<>(List.of(left.toLowerCase(Locale.ROOT).split("[^\\p{L}\\p{N}]+")));
+        for (String token : right.toLowerCase(Locale.ROOT).split("[^\\p{L}\\p{N}]+")) {
+            if (token.length() > 1 && tokens.contains(token)) return true;
+        }
+        return false;
+    }
+
+    private static List<StructuredRelationship> deduplicate(List<StructuredRelationship> relationships) {
+        Map<String, StructuredRelationship> result = new LinkedHashMap<>();
+        relationships.forEach(relation -> result.merge(relation.id(), relation,
+                (left, right) -> left.confidence() >= right.confidence() ? left : right));
+        return List.copyOf(result.values());
+    }
+
+    private static long number(List<Object> row, int index) {
+        if (index >= row.size() || row.get(index) == null) return 0;
+        Object value = row.get(index);
+        return value instanceof Number number ? number.longValue() : Long.parseLong(String.valueOf(value));
+    }
+
+    private static String quote(String identifier) { return "`" + identifier.replace("`", "") + "`"; }
+    private static String literal(Object value) { return "'" + String.valueOf(value).replace("'", "''") + "'"; }
+    private static String normalize(String value) { return value == null ? "" : value.toLowerCase(Locale.ROOT); }
+    private static String suffix(String value) { int index = value.lastIndexOf('_'); return index < 0 ? value : value.substring(index + 1); }
+    private static String stem(String value) { return value.endsWith("_id") ? value.substring(0, value.length() - 3)
+            : value.endsWith("_code") ? value.substring(0, value.length() - 5) : value; }
+    private static String discriminatorStem(String value) {
+        String normalized = normalize(value);
+        for (String suffix : List.of("_type", "_category", "_kind")) {
+            if (normalized.endsWith(suffix)) return normalized.substring(0, normalized.length() - suffix.length());
+        }
+        return normalized.equals("type") || normalized.equals("category") || normalized.equals("kind") ? "id" : normalized;
+    }
+    private static String value(String value, String fallback) {
+        return value == null || value.isBlank() ? fallback : value;
+    }
+    private static String singular(String value) { return value.endsWith("s") && value.length() > 1
+            ? value.substring(0, value.length() - 1) : value; }
+    private static String emptyToNull(String value) { return value == null || value.isBlank() ? null : value; }
+    private static double round(double value) { return Math.round(value * 1000d) / 1000d; }
+    private static String shortMessage(Exception error) { String message = error.getMessage(); return message == null
+            ? error.getClass().getSimpleName() : message.substring(0, Math.min(200, message.length())); }
+
+    private record Metadata(Map<String, Set<String>> uniqueColumns) {}
+    private record TargetColumn(String table, SchemaExplorerService.ColumnSpec column) {}
+    private record Candidate(String sourceTable, SchemaExplorerService.ColumnSpec sourceColumn,
+                             TargetColumn target, double nameScore) {}
+    private record Coverage(long sourceCount, long distinctCount, long orphanCount) {
+        double coverage() { return sourceCount == 0 ? 0 : Math.max(0, (double) (sourceCount - orphanCount) / sourceCount); }
+        double orphanRate() { return sourceCount == 0 ? 1 : Math.min(1, (double) orphanCount / sourceCount); }
+        double distinctRatio() { return sourceCount == 0 ? 0 : Math.min(1, (double) distinctCount / sourceCount); }
+    }
+    private static final class ScoredCandidate {
+        private final Candidate candidate; private final Coverage coverage; private final double confidence;
+        private boolean ambiguous;
+        private ScoredCandidate(Candidate candidate, Coverage coverage, double confidence) {
+            this.candidate = candidate; this.coverage = coverage; this.confidence = confidence;
+        }
+        Candidate candidate() { return candidate; } Coverage coverage() { return coverage; }
+        double confidence() { return confidence; } boolean ambiguous() { return ambiguous; }
+        void setAmbiguous(boolean value) { ambiguous = value; }
+    }
+    public record DiscoveryResult(List<StructuredRelationship> relationships, List<String> warnings) {}
+}

+ 33 - 0
backend/src/main/java/com/agent/management/rag/structured/StructuredSchemaProfile.java

@@ -0,0 +1,33 @@
+package com.agent.management.rag.structured;
+
+import com.agent.management.rag.capability.SqlSchemaSnapshot;
+import com.agent.management.service.SchemaExplorerService;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+public record StructuredSchemaProfile(
+        Long dataSourceId,
+        String dialect,
+        String catalog,
+        String schema,
+        String sourceFingerprint,
+        String schemaFingerprint,
+        String algorithmVersion,
+        String profileVersion,
+        String scanStatus,
+        Instant scannedAt,
+        SqlSchemaSnapshot schemaSnapshot,
+        Map<String, List<SchemaExplorerService.ColumnSpec>> columnSpecs,
+        List<StructuredRelationship> relationships,
+        List<String> warnings) {
+
+    public StructuredSchemaProfile {
+        catalog = catalog == null ? "" : catalog;
+        schema = schema == null ? "" : schema;
+        columnSpecs = Map.copyOf(columnSpecs == null ? Map.of() : columnSpecs);
+        relationships = List.copyOf(relationships == null ? List.of() : relationships);
+        warnings = List.copyOf(warnings == null ? List.of() : warnings);
+    }
+}

+ 405 - 0
backend/src/main/java/com/agent/management/rag/structured/StructuredSchemaProfileService.java

@@ -0,0 +1,405 @@
+package com.agent.management.rag.structured;
+
+import com.agent.management.model.entity.DataSource;
+import com.agent.management.model.entity.RagStructuredSchemaProfileEntity;
+import com.agent.management.rag.capability.SqlSchemaSnapshot;
+import com.agent.management.repository.RagStructuredSchemaProfileRepository;
+import com.agent.management.service.DataSourceService;
+import com.agent.management.service.DynamicJdbcService;
+import com.agent.management.service.SchemaExplorerService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.annotation.PreDestroy;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.sql.Connection;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HexFormat;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+@Service
+public class StructuredSchemaProfileService {
+    public static final String ALGORITHM_VERSION = "logical-rel-v2";
+    private static final Duration RELATION_REVERIFY_INTERVAL = Duration.ofHours(24);
+
+    private final DataSourceService dataSources;
+    private final DynamicJdbcService jdbc;
+    private final SchemaExplorerService explorer;
+    private final StructuredRelationshipDiscoveryService discovery;
+    private final RagStructuredSchemaProfileRepository repository;
+    private final ObjectMapper objectMapper;
+    private final ExecutorService scanExecutor;
+    private final Duration firstScanBudget;
+    private final Map<CacheKey, StructuredSchemaProfile> cache = new ConcurrentHashMap<>();
+    private final Map<CacheKey, CompletableFuture<StructuredSchemaProfile>> scans = new ConcurrentHashMap<>();
+
+    @Autowired
+    public StructuredSchemaProfileService(DataSourceService dataSources, DynamicJdbcService jdbc,
+                                          SchemaExplorerService explorer,
+                                          StructuredRelationshipDiscoveryService discovery,
+                                          RagStructuredSchemaProfileRepository repository,
+                                          ObjectMapper objectMapper) {
+        this(dataSources, jdbc, explorer, discovery, repository, objectMapper,
+                Executors.newFixedThreadPool(2, runnable -> {
+                    Thread thread = new Thread(runnable, "structured-profile-scan");
+                    thread.setDaemon(true);
+                    return thread;
+                }), Duration.ofSeconds(3));
+    }
+
+    StructuredSchemaProfileService(DataSourceService dataSources, DynamicJdbcService jdbc,
+                                   SchemaExplorerService explorer,
+                                   StructuredRelationshipDiscoveryService discovery,
+                                   RagStructuredSchemaProfileRepository repository,
+                                   ObjectMapper objectMapper, ExecutorService scanExecutor,
+                                   Duration firstScanBudget) {
+        this.dataSources = dataSources;
+        this.jdbc = jdbc;
+        this.explorer = explorer;
+        this.discovery = discovery;
+        this.repository = repository;
+        this.objectMapper = objectMapper;
+        this.scanExecutor = scanExecutor;
+        this.firstScanBudget = firstScanBudget;
+    }
+
+    public StructuredSchemaProfile getProfile(Long dataSourceId) {
+        DataSource source = dataSources.getDecrypted(dataSourceId);
+        if (source == null || !dataSourceId.equals(source.getId())) {
+            throw new IllegalArgumentException("structured datasource does not match requested dataSourceId");
+        }
+        Identity identity = identity(source);
+        Baseline baseline = baseline(dataSourceId, source, identity);
+        CacheKey key = new CacheKey(dataSourceId, identity.sourceFingerprint(), identity.catalog(), identity.schema(),
+                baseline.schemaFingerprint(), ALGORITHM_VERSION);
+
+        StructuredSchemaProfile memory = cache.get(key);
+        if (fresh(memory)) return memory;
+
+        Optional<StructuredSchemaProfile> persisted = loadPersisted(key);
+        if (persisted.isPresent() && fresh(persisted.get())) {
+            cache.put(key, persisted.get());
+            return persisted.get();
+        }
+
+        CompletableFuture<StructuredSchemaProfile> scan = scans.computeIfAbsent(key, ignored -> startScan(key, baseline));
+        try {
+            return scan.get(firstScanBudget.toMillis(), TimeUnit.MILLISECONDS);
+        } catch (TimeoutException error) {
+            List<String> warnings = new ArrayList<>(baseline.warnings());
+            warnings.add("logical relationship scan did not complete within query budget; using physical schema only");
+            return fallbackProfile(key, baseline, warnings);
+        } catch (Exception error) {
+            List<String> warnings = new ArrayList<>(baseline.warnings());
+            warnings.add("logical relationship scan failed; using physical schema only: " + shortMessage(error));
+            return fallbackProfile(key, baseline, warnings);
+        }
+    }
+
+    public void prewarm(Long dataSourceId) {
+        CompletableFuture.runAsync(() -> {
+            try { getProfile(dataSourceId); } catch (Exception ignored) { }
+        });
+    }
+
+    public void refreshAsync(Long dataSourceId) {
+        cache.keySet().removeIf(key -> dataSourceId.equals(key.dataSourceId()));
+        CompletableFuture.runAsync(() -> {
+            try {
+                DataSource source = dataSources.getDecrypted(dataSourceId);
+                Identity identity = identity(source);
+                Baseline baseline = baseline(dataSourceId, source, identity);
+                CacheKey key = new CacheKey(dataSourceId, identity.sourceFingerprint(), identity.catalog(),
+                        identity.schema(), baseline.schemaFingerprint(), ALGORITHM_VERSION);
+                scans.computeIfAbsent(key, ignored -> startScan(key, baseline));
+            } catch (Exception ignored) { }
+        });
+    }
+
+    public Map<String, Object> status(Long dataSourceId) {
+        boolean scanning = scans.keySet().stream().anyMatch(key -> dataSourceId.equals(key.dataSourceId()));
+        Optional<RagStructuredSchemaProfileEntity> stored =
+                repository.findFirstByDataSourceIdOrderByScannedAtDesc(dataSourceId);
+        if (stored.isEmpty()) {
+            return Map.of("dataSourceId", dataSourceId, "scanStatus", scanning ? "SCANNING" : "NOT_SCANNED");
+        }
+        RagStructuredSchemaProfileEntity entity = stored.get();
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("dataSourceId", dataSourceId);
+        result.put("scanStatus", scanning ? "SCANNING" : entity.getScanStatus());
+        result.put("schemaFingerprint", entity.getSchemaFingerprint());
+        result.put("algorithmVersion", entity.getAlgorithmVersion());
+        result.put("scannedAt", entity.getScannedAt());
+        result.put("lastError", entity.getLastError() == null ? "" : entity.getLastError());
+        try {
+            StructuredSchemaProfile profile = objectMapper.readValue(
+                    entity.getProfileJson(), StructuredSchemaProfile.class);
+            result.put("profileVersion", profile.profileVersion());
+            result.put("tableCount", profile.schemaSnapshot().tables().size());
+            result.put("physicalRelationships", count(profile, StructuredRelationship.RelationshipType.PHYSICAL_FK));
+            result.put("inferredRelationships", count(profile, StructuredRelationship.RelationshipType.INFERRED));
+            result.put("conditionalRelationships", count(profile, StructuredRelationship.RelationshipType.CONDITIONAL));
+            result.put("ambiguousRelationships", count(profile, StructuredRelationship.RelationshipType.AMBIGUOUS));
+            result.put("dialectSupported", "mysql".equalsIgnoreCase(profile.dialect()));
+            result.put("metricFields", metricFields(profile));
+            result.put("fieldCommentCoverage", fieldCommentCoverage(profile));
+            result.put("recommendedQuestionTypes", recommendedQuestionTypes(profile));
+            result.put("warnings", profile.warnings());
+        } catch (Exception error) {
+            result.put("warnings", List.of("stored profile JSON is unreadable"));
+        }
+        return result;
+    }
+
+    @jakarta.transaction.Transactional
+    public void deleteProfiles(Long dataSourceId) {
+        cache.keySet().removeIf(key -> dataSourceId.equals(key.dataSourceId()));
+        repository.deleteByDataSourceId(dataSourceId);
+    }
+
+    private CompletableFuture<StructuredSchemaProfile> startScan(CacheKey key, Baseline baseline) {
+        CompletableFuture<StructuredSchemaProfile> future = CompletableFuture.supplyAsync(() -> {
+            StructuredRelationshipDiscoveryService.DiscoveryResult discovered = discovery.discover(
+                    key.dataSourceId(), key.catalog(), key.schema(), baseline.snapshot(), baseline.columnsByTable());
+            List<String> warnings = new ArrayList<>(baseline.warnings());
+            warnings.addAll(discovered.warnings());
+            Instant scannedAt = Instant.now();
+            return new StructuredSchemaProfile(key.dataSourceId(), baseline.dialect(), key.catalog(), key.schema(),
+                    key.sourceFingerprint(), key.schemaFingerprint(), key.algorithmVersion(),
+                    profileVersion(key.schemaFingerprint(), scannedAt), "SUCCESS", scannedAt,
+                    baseline.snapshot(), baseline.columnsByTable(), discovered.relationships(), warnings);
+        }, scanExecutor);
+        future.whenComplete((profile, error) -> {
+            try {
+                if (error == null) {
+                    cache.put(key, profile);
+                    saveSuccessful(profile);
+                } else {
+                    saveFailureIfAbsent(key, error);
+                }
+            } finally {
+                scans.remove(key, future);
+            }
+        });
+        return future;
+    }
+
+    private Baseline baseline(Long dataSourceId, DataSource source, Identity identity) {
+        List<String> warnings = new ArrayList<>();
+        Map<String, List<SchemaExplorerService.ColumnSpec>> columnsByTable = new LinkedHashMap<>();
+        List<SqlSchemaSnapshot.TableSchema> tables = new ArrayList<>();
+        for (SchemaExplorerService.TableSummary table : explorer.listTables(dataSourceId, emptyToNull(identity.schema()))) {
+            List<SchemaExplorerService.ColumnSpec> columns = explorer.describeTable(
+                    dataSourceId, emptyToNull(identity.schema()), table.name());
+            columnsByTable.put(table.name(), columns);
+            tables.add(new SqlSchemaSnapshot.TableSchema(table.name(), table.remarks(), columns.stream()
+                    .map(column -> new SqlSchemaSnapshot.ColumnSchema(column.name(), column.type(), column.remarks()))
+                    .toList()));
+        }
+        if (tables.isEmpty()) throw new IllegalArgumentException("datasource schema contains no visible tables");
+        List<SqlSchemaSnapshot.ForeignKey> foreignKeys = new ArrayList<>();
+        for (SqlSchemaSnapshot.TableSchema table : tables) {
+            try {
+                explorer.listForeignKeys(dataSourceId, emptyToNull(identity.schema()), table.name()).stream()
+                        .map(key -> new SqlSchemaSnapshot.ForeignKey(key.name(), key.sourceTable(), key.sourceColumn(),
+                                key.targetTable(), key.targetColumn())).forEach(foreignKeys::add);
+            } catch (Exception error) {
+                warnings.add("foreign keys for " + table.name() + " unavailable: " + shortMessage(error));
+            }
+        }
+        if (foreignKeys.isEmpty()) warnings.add("database metadata exposes no physical foreign keys");
+        SqlSchemaSnapshot snapshot = new SqlSchemaSnapshot(tables, foreignKeys);
+        return new Baseline(dialect(source.getType()), snapshot, columnsByTable,
+                schemaFingerprint(snapshot, columnsByTable), warnings);
+    }
+
+    private Identity identity(DataSource source) {
+        String catalog = "", schema = source.getSchemaName() == null ? "" : source.getSchemaName();
+        try (Connection connection = jdbc.getConnection(source)) {
+            catalog = value(connection.getCatalog());
+            if (schema.isBlank()) {
+                try { schema = value(connection.getSchema()); } catch (Exception ignored) { schema = ""; }
+            }
+        } catch (Exception error) {
+            throw new IllegalArgumentException("cannot resolve requested datasource identity: " + shortMessage(error), error);
+        }
+        String fingerprint = hash(value(source.getType()) + "|" + value(source.getJdbcUrl()) + "|"
+                + value(source.getUsername()) + "|" + catalog + "|" + schema);
+        return new Identity(catalog, schema, fingerprint);
+    }
+
+    private Optional<StructuredSchemaProfile> loadPersisted(CacheKey key) {
+        return repository.findByDataSourceIdAndCatalogNameAndSchemaName(
+                        key.dataSourceId(), key.catalog(), key.schema())
+                .filter(entity -> key.sourceFingerprint().equals(entity.getSourceFingerprint()))
+                .filter(entity -> key.schemaFingerprint().equals(entity.getSchemaFingerprint()))
+                .filter(entity -> key.algorithmVersion().equals(entity.getAlgorithmVersion()))
+                .filter(entity -> "SUCCESS".equals(entity.getScanStatus()))
+                .flatMap(entity -> {
+                    try { return Optional.of(objectMapper.readValue(entity.getProfileJson(), StructuredSchemaProfile.class)); }
+                    catch (Exception ignored) { return Optional.empty(); }
+                });
+    }
+
+    private void saveSuccessful(StructuredSchemaProfile profile) {
+        try {
+            RagStructuredSchemaProfileEntity entity = repository
+                    .findByDataSourceIdAndCatalogNameAndSchemaName(
+                            profile.dataSourceId(), profile.catalog(), profile.schema())
+                    .orElseGet(RagStructuredSchemaProfileEntity::new);
+            entity.setDataSourceId(profile.dataSourceId());
+            entity.setCatalogName(profile.catalog());
+            entity.setSchemaName(profile.schema());
+            entity.setProfileJson(objectMapper.writeValueAsString(profile));
+            entity.setSourceFingerprint(profile.sourceFingerprint());
+            entity.setSchemaFingerprint(profile.schemaFingerprint());
+            entity.setAlgorithmVersion(profile.algorithmVersion());
+            entity.setScanStatus("SUCCESS");
+            entity.setScannedAt(profile.scannedAt());
+            entity.setLastError(null);
+            repository.save(entity);
+        } catch (Exception ignored) {
+            // 持久化失败不能影响本次只读查询;内存画像仍可继续使用。
+        }
+    }
+
+    private void saveFailureIfAbsent(CacheKey key, Throwable error) {
+        try {
+            if (repository.findByDataSourceIdAndCatalogNameAndSchemaName(
+                    key.dataSourceId(), key.catalog(), key.schema()).isPresent()) return;
+            RagStructuredSchemaProfileEntity entity = new RagStructuredSchemaProfileEntity();
+            entity.setDataSourceId(key.dataSourceId());
+            entity.setCatalogName(key.catalog());
+            entity.setSchemaName(key.schema());
+            entity.setProfileJson("{}");
+            entity.setSourceFingerprint(key.sourceFingerprint());
+            entity.setSchemaFingerprint(key.schemaFingerprint());
+            entity.setAlgorithmVersion(key.algorithmVersion());
+            entity.setScanStatus("FAILED");
+            entity.setScannedAt(Instant.now());
+            entity.setLastError(shortMessage(error));
+            repository.save(entity);
+        } catch (Exception ignored) {
+            // 失败状态写入失败也不能阻断业务查询。
+        }
+    }
+
+    private static StructuredSchemaProfile fallbackProfile(CacheKey key, Baseline baseline, List<String> warnings) {
+        List<StructuredRelationship> physical = baseline.snapshot().foreignKeys().stream()
+                .map(foreignKey -> new StructuredRelationship(StructuredRelationship.RelationshipType.PHYSICAL_FK,
+                        foreignKey.sourceTable(), foreignKey.sourceColumn(), foreignKey.targetTable(),
+                        foreignKey.targetColumn(), 1, List.of(),
+                        List.of("JDBC imported foreign key: " + String.valueOf(foreignKey.name())), List.of()))
+                .toList();
+        Instant now = Instant.now();
+        return new StructuredSchemaProfile(key.dataSourceId(), baseline.dialect(), key.catalog(), key.schema(),
+                key.sourceFingerprint(), key.schemaFingerprint(), key.algorithmVersion(),
+                profileVersion(key.schemaFingerprint(), now), "FALLBACK", now,
+                baseline.snapshot(), baseline.columnsByTable(), physical, warnings);
+    }
+
+    private static boolean fresh(StructuredSchemaProfile profile) {
+        return profile != null && "SUCCESS".equals(profile.scanStatus()) && profile.scannedAt() != null
+                && profile.scannedAt().isAfter(Instant.now().minus(RELATION_REVERIFY_INTERVAL));
+    }
+
+    private static String profileVersion(String schemaFingerprint, Instant scannedAt) {
+        return ALGORITHM_VERSION + ":" + schemaFingerprint.substring(0, 12) + ":" + scannedAt.toEpochMilli();
+    }
+
+    private static long count(StructuredSchemaProfile profile, StructuredRelationship.RelationshipType type) {
+        return profile.relationships().stream().filter(relationship -> relationship.type() == type).count();
+    }
+
+    private static List<String> metricFields(StructuredSchemaProfile profile) {
+        List<String> result = new ArrayList<>();
+        profile.columnSpecs().forEach((table, columns) -> columns.forEach(column -> {
+            String name = column.name().toLowerCase();
+            if (name.equals("score") || name.endsWith("_score") || name.contains("rank")
+                    || name.contains("priority") || name.contains("rating")
+                    || name.equals("weight") || name.equals("level")) {
+                result.add(table + "." + column.name());
+            }
+        }));
+        return result;
+    }
+
+    private static double fieldCommentCoverage(StructuredSchemaProfile profile) {
+        long total = profile.columnSpecs().values().stream().mapToLong(List::size).sum();
+        if (total == 0) return 0;
+        long described = profile.columnSpecs().values().stream().flatMap(List::stream)
+                .filter(column -> column.remarks() != null && !column.remarks().isBlank()).count();
+        return Math.round((described * 1000.0 / total)) / 1000.0;
+    }
+
+    private static List<String> recommendedQuestionTypes(StructuredSchemaProfile profile) {
+        List<String> result = new ArrayList<>();
+        result.add("单表筛选/列表查询");
+        if (!profile.relationships().isEmpty()) result.add("多表关联查询");
+        if (!metricFields(profile).isEmpty()) result.add("评分/排名/优先级查询");
+        if (!"mysql".equalsIgnoreCase(profile.dialect())) result.add("自动 Text2SQL 暂不支持该方言");
+        return result;
+    }
+
+    private static String dialect(String type) {
+        if (type == null) return "";
+        return switch (type.toUpperCase()) {
+            case "MYSQL" -> "mysql";
+            case "POSTGRESQL", "PG" -> "postgresql";
+            default -> type.toLowerCase();
+        };
+    }
+
+    private static String hash(String value) {
+        try {
+            return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
+                    .digest(value.getBytes(StandardCharsets.UTF_8)));
+        } catch (Exception error) { throw new IllegalStateException(error); }
+    }
+
+    private static String schemaFingerprint(SqlSchemaSnapshot snapshot,
+                                            Map<String, List<SchemaExplorerService.ColumnSpec>> columnsByTable) {
+        StringBuilder canonical = new StringBuilder();
+        snapshot.tables().forEach(table -> {
+            canonical.append("T|").append(table.name()).append('|').append(value(table.description())).append('\n');
+            columnsByTable.getOrDefault(table.name(), List.of()).forEach(column -> canonical
+                    .append("C|").append(table.name()).append('|').append(column.name()).append('|')
+                    .append(column.type()).append('|').append(column.size()).append('|').append(column.nullable())
+                    .append('|').append(value(column.remarks())).append('\n'));
+        });
+        snapshot.foreignKeys().forEach(key -> canonical.append("F|").append(key.sourceTable()).append('|')
+                .append(key.sourceColumn()).append('|').append(key.targetTable()).append('|')
+                .append(key.targetColumn()).append('\n'));
+        return hash(canonical.toString());
+    }
+
+    private static String value(String value) { return value == null ? "" : value; }
+    private static String emptyToNull(String value) { return value == null || value.isBlank() ? null : value; }
+    private static String shortMessage(Exception error) { String message = error.getMessage(); return message == null
+            ? error.getClass().getSimpleName() : message.substring(0, Math.min(200, message.length())); }
+    private static String shortMessage(Throwable error) { String message = error.getMessage(); return message == null
+            ? error.getClass().getSimpleName() : message.substring(0, Math.min(200, message.length())); }
+
+    @PreDestroy
+    public void close() { scanExecutor.shutdownNow(); }
+
+    private record Identity(String catalog, String schema, String sourceFingerprint) {}
+    private record Baseline(String dialect, SqlSchemaSnapshot snapshot,
+                            Map<String, List<SchemaExplorerService.ColumnSpec>> columnsByTable,
+                            String schemaFingerprint, List<String> warnings) {}
+    public record CacheKey(Long dataSourceId, String sourceFingerprint, String catalog, String schema,
+                           String schemaFingerprint, String algorithmVersion) {}
+}

+ 216 - 0
backend/src/main/java/com/agent/management/rag/structured/StructuredSqlRelationshipValidator.java

@@ -0,0 +1,216 @@
+package com.agent.management.rag.structured;
+
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@Service
+public class StructuredSqlRelationshipValidator {
+    private static final Pattern BASE_TABLE = Pattern.compile(
+            "(?is)\\bFROM\\s+`?([A-Za-z0-9_]+)`?(?:\\s+(?:AS\\s+)?([A-Za-z0-9_]+))?");
+    private static final Pattern JOIN = Pattern.compile(
+            "(?is)\\bJOIN\\s+`?([A-Za-z0-9_]+)`?(?:\\s+(?:AS\\s+)?([A-Za-z0-9_]+))?\\s+ON\\s+(.+?)"
+                    + "(?=\\b(?:LEFT|RIGHT|INNER|OUTER|CROSS)?\\s*JOIN\\b|\\bWHERE\\b|\\bGROUP\\b|\\bORDER\\b|\\bLIMIT\\b|$)");
+    private static final Pattern CARTESIAN_COMMA = Pattern.compile(
+            "(?is)\\bFROM\\s+`?[A-Za-z0-9_]+`?(?:\\s+[A-Za-z0-9_]+)?\\s*,\\s*`?[A-Za-z0-9_]+`?");
+    private static final Pattern CROSS_JOIN = Pattern.compile("(?is)\\bCROSS\\s+JOIN\\b");
+    private static final Pattern AGGREGATE = Pattern.compile("(?i)\\b(?:COUNT|AVG|MIN|MAX|SUM)\\s*\\(");
+    private static final Pattern SCALAR_FROM = Pattern.compile(
+            "(?is)^FROM\\s+`?[A-Za-z0-9_]+`?(?:\\s+(?:AS\\s+)?[A-Za-z0-9_]+)?(?:\\s+WHERE\\s+.+)?$");
+    private static final Set<String> RESERVED = Set.of("where", "left", "right", "inner", "outer", "join",
+            "group", "order", "limit", "having", "on", "union");
+
+    public ValidationResult validate(String sql, StructuredQueryContext context) {
+        int scalarCrossJoinCount = validateCrossJoins(sql);
+        if (CARTESIAN_COMMA.matcher(sql).find()) {
+            throw new IllegalArgumentException("SQL contains an obvious cartesian product");
+        }
+        Matcher base = BASE_TABLE.matcher(sql);
+        if (!base.find()) return new ValidationResult(List.of(), List.of());
+        Map<String, String> aliases = new HashMap<>();
+        String baseTable = base.group(1);
+        aliases.put(alias(base.group(2), baseTable), baseTable);
+        Set<String> introduced = new LinkedHashSet<>();
+        introduced.add(baseTable);
+        List<String> used = new ArrayList<>();
+        List<String> warnings = new ArrayList<>();
+
+        Matcher joins = JOIN.matcher(sql);
+        while (joins.find()) {
+            String joinedTable = joins.group(1);
+            String joinedAlias = alias(joins.group(2), joinedTable);
+            aliases.put(joinedAlias, joinedTable);
+            String onClause = joins.group(3);
+            List<StructuredRelationship> candidates = context.relationships().stream()
+                    .filter(relation -> relation.type() != StructuredRelationship.RelationshipType.AMBIGUOUS)
+                    .filter(relation -> relation.confidence() >= 0.58)
+                    .filter(relation -> connects(relation, joinedTable, introduced))
+                    .filter(relation -> predicatePresent(relation, onClause, aliases))
+                    .sorted((left, right) -> Double.compare(right.confidence(), left.confidence())).toList();
+            StructuredRelationship selected = candidates.stream()
+                    .filter(relation -> conditionsPresent(relation, sql, aliases)).findFirst().orElse(null);
+            if (selected == null) {
+                boolean missingCondition = candidates.stream().anyMatch(relation -> !relation.conditions().isEmpty());
+                throw new IllegalArgumentException(missingCondition
+                        ? "SQL JOIN omitted a required conditional relationship predicate for " + joinedTable
+                        : "SQL JOIN is not supported by the structured relationship profile for " + joinedTable);
+            }
+            used.add(selected.id());
+            if (selected.confidence() < 0.78) {
+                warnings.add("JOIN used a medium-confidence relationship: " + selected.id());
+            }
+            warnings.addAll(selected.warnings());
+            introduced.add(joinedTable);
+        }
+
+        int joinTokenCount = countMatches(Pattern.compile("(?i)\\bJOIN\\b"), sql);
+        if (joinTokenCount - scalarCrossJoinCount != used.size()) {
+            throw new IllegalArgumentException("SQL contains a JOIN without a verifiable ON relationship");
+        }
+        return new ValidationResult(List.copyOf(used), List.copyOf(warnings));
+    }
+
+    private static boolean connects(StructuredRelationship relation, String joinedTable, Set<String> introduced) {
+        return relation.sourceTable().equalsIgnoreCase(joinedTable) && containsIgnoreCase(introduced, relation.targetTable())
+                || relation.targetTable().equalsIgnoreCase(joinedTable) && containsIgnoreCase(introduced, relation.sourceTable());
+    }
+
+    private static boolean predicatePresent(StructuredRelationship relation, String clause, Map<String, String> aliases) {
+        String normalized = normalizeSql(clause);
+        for (String sourceAlias : qualifiers(aliases, relation.sourceTable())) {
+            String source = sourceAlias + "." + relation.sourceColumn();
+            for (String targetAlias : qualifiers(aliases, relation.targetTable())) {
+                String target = targetAlias + "." + relation.targetColumn();
+                if (normalized.contains(source.toLowerCase(Locale.ROOT) + "=" + target.toLowerCase(Locale.ROOT))
+                        || normalized.contains(target.toLowerCase(Locale.ROOT) + "=" + source.toLowerCase(Locale.ROOT))) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private static boolean conditionsPresent(StructuredRelationship relation, String sql, Map<String, String> aliases) {
+        String normalized = normalizeSql(sql);
+        for (StructuredRelationship.Condition condition : relation.conditions()) {
+            boolean found = qualifiers(aliases, condition.table()).stream().anyMatch(alias -> {
+                String field = alias + "." + condition.column();
+                String expected = field.toLowerCase(Locale.ROOT) + condition.operator().toLowerCase(Locale.ROOT)
+                        + normalizeSql(literal(condition.value()));
+                return normalized.contains(expected);
+            });
+            if (!found) return false;
+        }
+        return true;
+    }
+
+    private static List<String> qualifiers(Map<String, String> aliases, String table) {
+        List<String> result = aliases.entrySet().stream().filter(entry -> entry.getValue().equalsIgnoreCase(table))
+                .map(Map.Entry::getKey).toList();
+        return result.isEmpty() ? List.of(table) : result;
+    }
+
+    private static int validateCrossJoins(String sql) {
+        int count = 0;
+        Matcher matcher = CROSS_JOIN.matcher(sql);
+        while (matcher.find()) {
+            int open = skipWhitespace(sql, matcher.end());
+            if (open >= sql.length() || sql.charAt(open) != '(') {
+                throw new IllegalArgumentException("SQL contains an obvious cartesian product");
+            }
+            int close = matchingParenthesis(sql, open);
+            if (close < 0 || !isSafeScalarAggregate(sql.substring(open + 1, close))) {
+                throw new IllegalArgumentException("SQL contains an obvious cartesian product");
+            }
+            int aliasStart = skipWhitespace(sql, close + 1);
+            if (startsWithWord(sql, aliasStart, "AS")) aliasStart = skipWhitespace(sql, aliasStart + 2);
+            if (aliasStart >= sql.length() || !isIdentifierStart(sql.charAt(aliasStart))) {
+                throw new IllegalArgumentException("SQL scalar aggregate JOIN must have an alias");
+            }
+            count++;
+        }
+        return count;
+    }
+
+    private static boolean isSafeScalarAggregate(String query) {
+        String normalized = query.trim().replaceAll("\\s+", " ");
+        String upper = normalized.toUpperCase(Locale.ROOT);
+        if (!upper.startsWith("SELECT ") || upper.contains(";") || upper.contains(" JOIN ")
+                || upper.contains(" UNION ") || upper.contains(" GROUP BY ") || upper.contains(" HAVING ")) {
+            return false;
+        }
+        int from = upper.indexOf(" FROM ");
+        if (from < 0 || countMatches(Pattern.compile("(?i)\\bFROM\\b"), normalized) != 1) return false;
+        String select = normalized.substring("SELECT ".length(), from);
+        String fromClause = normalized.substring(from + 1);
+        return AGGREGATE.matcher(select).find() && SCALAR_FROM.matcher(fromClause).matches()
+                && !CARTESIAN_COMMA.matcher(normalized).find();
+    }
+
+    private static int matchingParenthesis(String value, int open) {
+        int depth = 0;
+        char quote = 0;
+        for (int i = open; i < value.length(); i++) {
+            char current = value.charAt(i);
+            if (quote != 0) {
+                if (current == quote && (i == 0 || value.charAt(i - 1) != '\\')) quote = 0;
+                continue;
+            }
+            if (current == '\'' || current == '"' || current == '`') {
+                quote = current;
+            } else if (current == '(') {
+                depth++;
+            } else if (current == ')' && --depth == 0) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    private static int skipWhitespace(String value, int offset) {
+        int current = offset;
+        while (current < value.length() && Character.isWhitespace(value.charAt(current))) current++;
+        return current;
+    }
+
+    private static boolean startsWithWord(String value, int offset, String word) {
+        int end = offset + word.length();
+        return end <= value.length() && value.regionMatches(true, offset, word, 0, word.length())
+                && (end == value.length() || !Character.isLetterOrDigit(value.charAt(end)));
+    }
+
+    private static boolean isIdentifierStart(char value) {
+        return Character.isLetter(value) || value == '_' || value == '`';
+    }
+
+    private static String alias(String candidate, String table) {
+        return candidate == null || RESERVED.contains(candidate.toLowerCase(Locale.ROOT)) ? table : candidate;
+    }
+
+    private static String normalizeSql(String sql) {
+        return sql.replace("`", "").replaceAll("\\s+", "").toLowerCase(Locale.ROOT);
+    }
+
+    private static String literal(Object value) {
+        return value instanceof Number || value instanceof Boolean ? String.valueOf(value)
+                : "'" + String.valueOf(value).replace("'", "''") + "'";
+    }
+
+    private static boolean containsIgnoreCase(Set<String> values, String expected) {
+        return values.stream().anyMatch(value -> value.equalsIgnoreCase(expected));
+    }
+
+    private static int countMatches(Pattern pattern, String value) {
+        int count = 0; Matcher matcher = pattern.matcher(value); while (matcher.find()) count++; return count;
+    }
+
+    public record ValidationResult(List<String> usedRelationships, List<String> warnings) {}
+}

+ 167 - 0
backend/src/main/java/com/agent/management/rag/structured/StructuredValueSampler.java

@@ -0,0 +1,167 @@
+package com.agent.management.rag.structured;
+
+import com.agent.management.service.SchemaExplorerService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/** 仅使用 Schema 中的标识符执行有界、只读采样。 */
+@Service
+@RequiredArgsConstructor
+public class StructuredValueSampler {
+    private static final int MAX_VALUE_FIELDS = 5;
+    private static final int MAX_DISTINCT_VALUES = 20;
+    private static final int MAX_REFERENCE_TABLES = 2;
+    private static final int MAX_REFERENCE_ROWS = 50;
+    private static final long MAX_REFERENCE_TABLE_SIZE = 200;
+    private static final Set<String> SHORT_TYPES = Set.of(
+            "CHAR", "VARCHAR", "NCHAR", "NVARCHAR", "ENUM", "SET", "BOOLEAN", "BOOL", "BIT");
+    private static final Set<String> NUMERIC_ID_TYPES = Set.of(
+            "TINYINT", "SMALLINT", "MEDIUMINT", "INT", "INTEGER", "BIGINT");
+    private static final Pattern SENSITIVE = Pattern.compile(
+            "(^|_)(password|passwd|pwd|secret|token|key|credential)($|_)", Pattern.CASE_INSENSITIVE);
+
+    private final SchemaExplorerService explorer;
+
+    public SampleResult sample(Long datasourceId,
+                               List<String> selectedTables,
+                               Map<String, List<SchemaExplorerService.ColumnSpec>> columnsByTable) {
+        Map<String, List<Object>> sampledValues = new LinkedHashMap<>();
+        Map<String, List<Map<String, Object>>> referenceRows = new LinkedHashMap<>();
+        List<String> warnings = new ArrayList<>();
+
+        List<FieldCandidate> fields = new ArrayList<>();
+        for (int tableOrder = 0; tableOrder < selectedTables.size(); tableOrder++) {
+            String table = selectedTables.get(tableOrder);
+            for (SchemaExplorerService.ColumnSpec column : columnsByTable.getOrDefault(table, List.of())) {
+                if (isSampleable(column)) {
+                    fields.add(new FieldCandidate(table, column, priority(column.name()), tableOrder));
+                }
+            }
+        }
+        fields.sort(Comparator.comparingInt(FieldCandidate::priority).reversed()
+                .thenComparingInt(FieldCandidate::tableOrder)
+                .thenComparing(item -> item.column().name()));
+        for (FieldCandidate field : fields) {
+            if (sampledValues.size() >= MAX_VALUE_FIELDS) break;
+            try {
+                String identifier = qualifiedField(field.table(), field.column().name());
+                String sql = "SELECT DISTINCT " + quote(field.column().name()) + " FROM " + quote(field.table())
+                        + " WHERE " + quote(field.column().name()) + " IS NOT NULL LIMIT " + (MAX_DISTINCT_VALUES + 1);
+                SchemaExplorerService.QueryResult result = explorer.executeQuery(datasourceId, sql, MAX_DISTINCT_VALUES + 1);
+                if (result.rows().size() > MAX_DISTINCT_VALUES) continue;
+                List<Object> values = result.rows().stream().filter(row -> !row.isEmpty())
+                        .map(row -> row.get(0)).filter(java.util.Objects::nonNull).toList();
+                if (!values.isEmpty()) sampledValues.put(identifier, values);
+            } catch (Exception error) {
+                warnings.add("sampledValues " + qualifiedField(field.table(), field.column().name())
+                        + " failed: " + shortMessage(error));
+            }
+        }
+
+        for (String table : selectedTables) {
+            if (referenceRows.size() >= MAX_REFERENCE_TABLES) break;
+            List<SchemaExplorerService.ColumnSpec> columns = columnsByTable.getOrDefault(table, List.of());
+            List<SchemaExplorerService.ColumnSpec> selected = referenceColumns(columns);
+            if (selected.isEmpty()) continue;
+            try {
+                if (explorer.countRows(datasourceId, null, table) > MAX_REFERENCE_TABLE_SIZE) continue;
+                String sql = "SELECT " + selected.stream().map(column -> quote(column.name()))
+                        .collect(java.util.stream.Collectors.joining(", ")) + " FROM " + quote(table)
+                        + " LIMIT " + MAX_REFERENCE_ROWS;
+                SchemaExplorerService.QueryResult result = explorer.executeQuery(datasourceId, sql, MAX_REFERENCE_ROWS);
+                List<Map<String, Object>> rows = new ArrayList<>();
+                for (List<Object> row : result.rows()) {
+                    Map<String, Object> mapped = new LinkedHashMap<>();
+                    for (int i = 0; i < Math.min(result.columns().size(), row.size()); i++) {
+                        if (row.get(i) != null) mapped.put(result.columns().get(i), row.get(i));
+                    }
+                    if (!mapped.isEmpty()) rows.add(mapped);
+                }
+                if (!rows.isEmpty()) referenceRows.put(table, rows);
+            } catch (Exception error) {
+                warnings.add("referenceRows " + table + " failed: " + shortMessage(error));
+            }
+        }
+        return new SampleResult(sampledValues, referenceRows, warnings);
+    }
+
+    private static boolean isSampleable(SchemaExplorerService.ColumnSpec column) {
+        String type = normalizedType(column.type());
+        return SHORT_TYPES.contains(type) && column.size() <= 255 && !SENSITIVE.matcher(column.name()).find();
+    }
+
+    private static List<SchemaExplorerService.ColumnSpec> referenceColumns(
+            List<SchemaExplorerService.ColumnSpec> columns) {
+        List<SchemaExplorerService.ColumnSpec> ids = columns.stream().filter(StructuredValueSampler::isReferenceIdSafe)
+                .filter(column -> role(column.name(), "id", "code")).limit(2).toList();
+        List<SchemaExplorerService.ColumnSpec> names = columns.stream().filter(StructuredValueSampler::isReferenceSafe)
+                .filter(column -> role(column.name(), "name", "title", "label")).limit(2).toList();
+        if (ids.isEmpty() || names.isEmpty()) return List.of();
+        List<SchemaExplorerService.ColumnSpec> result = new ArrayList<>();
+        result.addAll(ids); result.addAll(names);
+        columns.stream().filter(StructuredValueSampler::isReferenceSafe)
+                .filter(column -> role(column.name(), "type", "category"))
+                .filter(column -> result.stream().noneMatch(existing -> existing.name().equals(column.name())))
+                .limit(2).forEach(result::add);
+        return result;
+    }
+
+    private static boolean isReferenceSafe(SchemaExplorerService.ColumnSpec column) {
+        return isSampleable(column) && !SENSITIVE.matcher(column.name()).find();
+    }
+
+    private static boolean isReferenceIdSafe(SchemaExplorerService.ColumnSpec column) {
+        return !SENSITIVE.matcher(column.name()).find()
+                && (isSampleable(column) || NUMERIC_ID_TYPES.contains(normalizedType(column.type())));
+    }
+
+    private static boolean role(String name, String... roles) {
+        String normalized = name.toLowerCase(Locale.ROOT);
+        for (String role : roles) {
+            if (normalized.equals(role) || normalized.endsWith("_" + role)) return true;
+        }
+        return false;
+    }
+
+    private static int priority(String name) {
+        String normalized = name.toLowerCase(Locale.ROOT);
+        if (normalized.endsWith("_category") || normalized.endsWith("_type")) return 110;
+        if (normalized.contains("status") || normalized.endsWith("_label")) return 100;
+        if (normalized.startsWith("is_") || normalized.startsWith("has_") || normalized.endsWith("_enabled")) return 80;
+        if (normalized.endsWith("_name") || normalized.endsWith("_title")) return 70;
+        if (normalized.endsWith("_code")) return 60;
+        if (normalized.endsWith("_id")) return 50;
+        return 10;
+    }
+
+    private static String normalizedType(String type) {
+        if (type == null) return "";
+        int parenthesis = type.indexOf('(');
+        return (parenthesis < 0 ? type : type.substring(0, parenthesis)).toUpperCase(Locale.ROOT);
+    }
+
+    private static String quote(String identifier) { return "`" + identifier.replace("`", "") + "`"; }
+
+    private static String qualifiedField(String table, String column) { return table + "." + column; }
+
+    private static String shortMessage(Exception error) {
+        String message = error.getMessage();
+        return message == null ? error.getClass().getSimpleName() : message.substring(0, Math.min(200, message.length()));
+    }
+
+    private record FieldCandidate(String table, SchemaExplorerService.ColumnSpec column,
+                                  int priority, int tableOrder) {}
+
+    public record SampleResult(Map<String, List<Object>> sampledValues,
+                               Map<String, List<Map<String, Object>>> referenceRows,
+                               List<String> warnings) {}
+}

+ 82 - 50
backend/src/main/java/com/agent/management/rag/structured/VannaSqlGenerationService.java

@@ -1,64 +1,71 @@
 package com.agent.management.rag.structured;
 package com.agent.management.rag.structured;
 
 
 import com.agent.management.rag.bridge.RagAiBridgeClient;
 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.RagQuery;
-import com.agent.management.rag.model.RagSourceType;
-import com.agent.management.service.DataSourceService;
 import lombok.RequiredArgsConstructor;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
-import java.util.*;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
 
 
 @Service
 @Service
 @RequiredArgsConstructor
 @RequiredArgsConstructor
 public class VannaSqlGenerationService implements SqlGenerationService {
 public class VannaSqlGenerationService implements SqlGenerationService {
     private final RagAiBridgeClient bridge;
     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;
+    private final StructuredQueryContextService contexts;
 
 
+    @Override
     public Optional<String> generateSql(RagQuery query, Long datasourceId) {
     public Optional<String> generateSql(RagQuery query, Long datasourceId) {
+        return generate(query, datasourceId).map(GeneratedSql::sql);
+    }
+
+    public Optional<GeneratedSql> generate(RagQuery query, Long datasourceId) {
         if (!allowed(query)) return Optional.empty();
         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");
+        StructuredQueryContext context = contexts.build(query, datasourceId);
+        return generate(query, datasourceId, context);
+    }
 
 
-        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")));
+    public Optional<GeneratedSql> generate(RagQuery query, Long datasourceId, StructuredQueryContext context) {
+        if (!allowed(query)) return Optional.empty();
+        if (!datasourceId.equals(context.dataSourceId())) {
+            throw new IllegalStateException("structured query context belongs to another datasource");
         }
         }
+        Map<String, Object> request = request(query, datasourceId, context);
+        Map<String, Object> response = bridge.textToSql(request);
+        Object sql = response.get("sql");
+        if (sql == null || String.valueOf(sql).isBlank()) return Optional.empty();
+        List<String> warnings = new ArrayList<>(context.warnings());
+        Object bridgeWarnings = response.get("warnings");
+        if (bridgeWarnings instanceof List<?> list) list.stream().map(String::valueOf).forEach(warnings::add);
+        return Optional.of(new GeneratedSql(String.valueOf(sql), context, warnings));
+    }
+
+    public Optional<String> repair(RagQuery query, Long datasourceId, String failedSql, String error,
+                                   StructuredQueryContext context) {
         Map<String, Object> request = new LinkedHashMap<>();
         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));
+        request.put("language", "SQL");
+        request.put("question", query.getQuery());
+        request.put("query", failedSql);
+        request.put("error", error);
+        request.put("schemaText", context.minimalDdl());
+        request.put("dialect", context.dialect());
+        request.put("selectedTables", context.selectedTables());
+        request.put("foreignKeys", context.foreignKeys());
+        request.put("relationships", context.relationships());
+        request.put("sampledValues", context.sampledValues());
+        request.put("referenceRows", context.referenceRows());
+        request.put("maxRows", limit(query));
+        request.put("maxDepth", 1);
+        Object fixed = bridge.repair(request).get("query");
+        return fixed == null || String.valueOf(fixed).isBlank()
+                ? Optional.empty() : Optional.of(String.valueOf(fixed));
     }
     }
 
 
     public Optional<String> repair(RagQuery query, Long datasourceId, String failedSql, String error) {
     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));
+        return repair(query, datasourceId, failedSql, error, contexts.build(query, datasourceId));
     }
     }
 
 
     static boolean allowed(RagQuery query) {
     static boolean allowed(RagQuery query) {
@@ -67,18 +74,43 @@ public class VannaSqlGenerationService implements SqlGenerationService {
                 || "AUTO_GENERATE".equals(String.valueOf(query.getFilters().get("retrievalMode")));
                 || "AUTO_GENERATE".equals(String.valueOf(query.getFilters().get("retrievalMode")));
     }
     }
 
 
+    private static Map<String, Object> request(RagQuery query, Long datasourceId,
+                                                StructuredQueryContext context) {
+        Map<String, Object> request = new LinkedHashMap<>();
+        request.put("query", query.getQuery());
+        request.put("datasourceId", datasourceId);
+        request.put("dialect", context.dialect());
+        request.put("schemaVersion", context.schemaVersion());
+        request.put("schemaFingerprint", context.schemaFingerprint());
+        request.put("profileVersion", context.profileVersion());
+        request.put("ddl", context.minimalDdl());
+        request.put("selectedTables", context.selectedTables());
+        request.put("foreignKeys", context.foreignKeys());
+        request.put("relationships", context.relationships());
+        request.put("sampledValues", context.sampledValues());
+        request.put("referenceRows", context.referenceRows());
+        request.put("entityMentions", context.entityMentions());
+        request.put("contextWarnings", context.warnings());
+        request.put("documentation", generationRules(query));
+        request.put("maxRows", limit(query));
+        return request;
+    }
+
     private static int limit(RagQuery query) {
     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);
+        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));
         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);
+
+    private static String generationRules(RagQuery query) {
+        if (query.getFilters() == null || !(query.getFilters().get("generationRules") instanceof List<?> rules)) {
+            return "";
+        }
+        return rules.stream().map(String::valueOf).collect(java.util.stream.Collectors.joining("\n- ", "- ", ""));
+    }
+
+    public record GeneratedSql(String sql, StructuredQueryContext context, List<String> warnings) {
+        public GeneratedSql { warnings = List.copyOf(warnings == null ? List.of() : warnings); }
     }
     }
-    @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;}
 }
 }

+ 12 - 0
backend/src/main/java/com/agent/management/repository/RagGraphGovernanceConfigRepository.java

@@ -0,0 +1,12 @@
+package com.agent.management.repository;
+
+import com.agent.management.model.entity.RagGraphGovernanceConfigEntity;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface RagGraphGovernanceConfigRepository
+        extends JpaRepository<RagGraphGovernanceConfigEntity, Long> {
+    Optional<RagGraphGovernanceConfigEntity> findByGraphSourceId(Long graphSourceId);
+    void deleteByGraphSourceId(Long graphSourceId);
+}

+ 13 - 0
backend/src/main/java/com/agent/management/repository/RagStructuredSchemaProfileRepository.java

@@ -0,0 +1,13 @@
+package com.agent.management.repository;
+
+import com.agent.management.model.entity.RagStructuredSchemaProfileEntity;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface RagStructuredSchemaProfileRepository extends JpaRepository<RagStructuredSchemaProfileEntity, Long> {
+    Optional<RagStructuredSchemaProfileEntity> findByDataSourceIdAndCatalogNameAndSchemaName(
+            Long dataSourceId, String catalogName, String schemaName);
+    Optional<RagStructuredSchemaProfileEntity> findFirstByDataSourceIdOrderByScannedAtDesc(Long dataSourceId);
+    void deleteByDataSourceId(Long dataSourceId);
+}

+ 12 - 4
backend/src/main/java/com/agent/management/service/Neo4jExecutorService.java

@@ -109,9 +109,11 @@ public class Neo4jExecutorService {
         Map<String, Map<String, Object>> edges = new LinkedHashMap<>();
         Map<String, Map<String, Object>> edges = new LinkedHashMap<>();
         List<Map<String, Object>> records = new ArrayList<>();
         List<Map<String, Object>> records = new ArrayList<>();
 
 
-        TransactionConfig txConfig = TransactionConfig.builder()
-                .withTimeout(Duration.ofSeconds(props.getQueryTimeoutSeconds()))
-                .build();
+        TransactionConfig.Builder txConfigBuilder = TransactionConfig.builder();
+        if (props.getQueryTimeoutSeconds() > 0) {
+            txConfigBuilder.withTimeout(Duration.ofSeconds(props.getQueryTimeoutSeconds()));
+        }
+        TransactionConfig txConfig = txConfigBuilder.build();
 
 
         try (var session = newSession(driver, gs.getDatabase())) {
         try (var session = newSession(driver, gs.getDatabase())) {
             var result = session.run(cypher, txConfig);
             var result = session.run(cypher, txConfig);
@@ -241,7 +243,13 @@ public class Neo4jExecutorService {
                     .list(record -> record.asMap(Value::asObject));
                     .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<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));
                     .list(record -> record.asMap(Value::asObject));
-            return Map.of("constraints", constraints, "indexes", indexes, "labelCounts", counts);
+            Record totals = session.run("MATCH (n) WITH count(n) AS nodeCount MATCH ()-[r]->() RETURN nodeCount, count(r) AS relationshipCount").single();
+            return Map.of(
+                    "constraints", constraints,
+                    "indexes", indexes,
+                    "labelCounts", counts,
+                    "nodeCount", totals.get("nodeCount").asLong(),
+                    "relationshipCount", totals.get("relationshipCount").asLong());
         } catch (Neo4jException e) { throw new BusinessException("获取图索引/约束失败:" + e.getMessage()); }
         } catch (Neo4jException e) { throw new BusinessException("获取图索引/约束失败:" + e.getMessage()); }
     }
     }
 
 

+ 12 - 2
backend/src/main/java/com/agent/management/service/impl/DataSourceServiceImpl.java

@@ -155,7 +155,17 @@ public class DataSourceServiceImpl implements DataSourceService {
     }
     }
 
 
     private DataSource sanitize(DataSource ds) {
     private DataSource sanitize(DataSource ds) {
-        credentialManager.sanitize(ds);
-        return ds;
+        DataSource safe = new DataSource();
+        safe.setId(ds.getId());
+        safe.setName(ds.getName());
+        safe.setType(ds.getType());
+        safe.setJdbcUrl(ds.getJdbcUrl());
+        safe.setUsername(ds.getUsername());
+        safe.setSchemaName(ds.getSchemaName());
+        safe.setTestStatus(ds.getTestStatus());
+        safe.setTestMessage(ds.getTestMessage());
+        safe.setCreatedAt(ds.getCreatedAt());
+        safe.setUpdatedAt(ds.getUpdatedAt());
+        return safe;
     }
     }
 }
 }

+ 17 - 2
backend/src/main/java/com/agent/management/service/impl/DocumentServiceImpl.java

@@ -14,6 +14,8 @@ import org.springframework.data.domain.Page;
 import org.springframework.data.domain.PageRequest;
 import org.springframework.data.domain.PageRequest;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
 import org.springframework.web.multipart.MultipartFile;
 import org.springframework.web.multipart.MultipartFile;
 
 
 import java.io.IOException;
 import java.io.IOException;
@@ -81,7 +83,7 @@ public class DocumentServiceImpl implements DocumentService {
         log.info("文档已上传,id={},name={},触发异步处理", doc.getId(), doc.getName());
         log.info("文档已上传,id={},name={},触发异步处理", doc.getId(), doc.getName());
 
 
         // 触发异步流水线(独立 bean,避免 @Async 自调用代理失效)
         // 触发异步流水线(独立 bean,避免 @Async 自调用代理失效)
-        pipeline.process(doc.getId());
+        triggerPipelineAfterCommit(doc.getId());
 
 
         return doc;
         return doc;
     }
     }
@@ -124,10 +126,23 @@ public class DocumentServiceImpl implements DocumentService {
         doc.setChunkCount(0);
         doc.setChunkCount(0);
         doc.setVectorCount(0);
         doc.setVectorCount(0);
         documentRepository.save(doc);
         documentRepository.save(doc);
-        pipeline.process(id);
+        triggerPipelineAfterCommit(id);
         return doc;
         return doc;
     }
     }
 
 
+    private void triggerPipelineAfterCommit(Long documentId) {
+        if (TransactionSynchronizationManager.isSynchronizationActive()) {
+            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                @Override
+                public void afterCommit() {
+                    pipeline.process(documentId);
+                }
+            });
+            return;
+        }
+        pipeline.process(documentId);
+    }
+
     @Override
     @Override
     public KbDocument vectorizeDocument(Long id) {
     public KbDocument vectorizeDocument(Long id) {
         KbDocument doc = get(id);
         KbDocument doc = get(id);

+ 11 - 2
backend/src/main/java/com/agent/management/service/impl/GraphSourceServiceImpl.java

@@ -172,7 +172,16 @@ public class GraphSourceServiceImpl implements GraphSourceService {
     }
     }
 
 
     private GraphSource sanitize(GraphSource gs) {
     private GraphSource sanitize(GraphSource gs) {
-        credentialManager.sanitize(gs);
-        return gs;
+        GraphSource safe = new GraphSource();
+        safe.setId(gs.getId());
+        safe.setName(gs.getName());
+        safe.setUri(gs.getUri());
+        safe.setUsername(gs.getUsername());
+        safe.setDatabase(gs.getDatabase());
+        safe.setTestStatus(gs.getTestStatus());
+        safe.setTestMessage(gs.getTestMessage());
+        safe.setCreatedAt(gs.getCreatedAt());
+        safe.setUpdatedAt(gs.getUpdatedAt());
+        return safe;
     }
     }
 }
 }

+ 8 - 0
frontend/src/api/datasource.js

@@ -37,6 +37,14 @@ export function testExistingDataSource(id) {
   return request.post(`/kb/datasources/${id}/test`)
   return request.post(`/kb/datasources/${id}/test`)
 }
 }
 
 
+export function getStructuredRagProfile(id) {
+  return request.get(`/kb/datasources/${id}/rag-profile`)
+}
+
+export function refreshStructuredRagProfile(id) {
+  return request.post(`/kb/datasources/${id}/rag-profile/refresh`)
+}
+
 // ============================== SQL Console ==============================
 // ============================== SQL Console ==============================
 
 
 export function getSchemas(datasourceId) {
 export function getSchemas(datasourceId) {

+ 45 - 38
frontend/src/api/rag.js

@@ -1,6 +1,6 @@
 import request from '../utils/request'
 import request from '../utils/request'
 
 
-const RAG_TIMEOUT = 90000
+const RAG_TIMEOUT = 0
 
 
 const unwrap = (promise) => promise.then(response => response.data)
 const unwrap = (promise) => promise.then(response => response.data)
 
 
@@ -8,13 +8,13 @@ export function retrieveDocumentEvidence(query, topK = 5, mode = 'hybrid') {
   return unwrap(request.post('/rag/document/retrieve', { query, topK, filters: { mode } }, { timeout: RAG_TIMEOUT }))
   return unwrap(request.post('/rag/document/retrieve', { query, topK, filters: { mode } }, { timeout: RAG_TIMEOUT }))
 }
 }
 
 
-export function retrieveStructuredEvidence(query, sourceIds = ['1'], topK = 5) {
+export function retrieveStructuredEvidence(query, sourceIds, topK = 5) {
   return unwrap(request.post('/rag/structured/retrieve', {
   return unwrap(request.post('/rag/structured/retrieve', {
     query, sourceIds, topK, filters: { allowTextToSql: true }
     query, sourceIds, topK, filters: { allowTextToSql: true }
   }, { timeout: RAG_TIMEOUT }))
   }, { timeout: RAG_TIMEOUT }))
 }
 }
 
 
-export function retrieveGraphEvidence(query, sourceIds = ['1'], topK = 5) {
+export function retrieveGraphEvidence(query, sourceIds, topK = 5) {
   return unwrap(request.post('/rag/graph/retrieve', {
   return unwrap(request.post('/rag/graph/retrieve', {
     query, sourceIds, topK, filters: { allowTextToCypher: true }
     query, sourceIds, topK, filters: { allowTextToCypher: true }
   }, { timeout: RAG_TIMEOUT }))
   }, { timeout: RAG_TIMEOUT }))
@@ -36,6 +36,18 @@ export function suggestRagGovernance(sourceType, sourceId, description = '') {
   return unwrap(request.post(`/rag/capabilities/${sourceType}/${sourceId}/suggest`, { description }, { timeout: RAG_TIMEOUT }))
   return unwrap(request.post(`/rag/capabilities/${sourceType}/${sourceId}/suggest`, { description }, { timeout: RAG_TIMEOUT }))
 }
 }
 
 
+export function getGraphGovernance(sourceId) {
+  return unwrap(request.get(`/rag/capabilities/GRAPH/${sourceId}/governance`))
+}
+
+export function updateGraphGovernance(sourceId, config) {
+  return unwrap(request.put(`/rag/capabilities/GRAPH/${sourceId}/governance`, config))
+}
+
+export function getGraphReadiness(sourceId) {
+  return unwrap(request.get(`/rag/capabilities/GRAPH/${sourceId}/readiness`))
+}
+
 export function listRagQueryExamples(sourceType, sourceId) {
 export function listRagQueryExamples(sourceType, sourceId) {
   return unwrap(request.get(`/rag/capabilities/${sourceType}/${sourceId}/examples`))
   return unwrap(request.get(`/rag/capabilities/${sourceType}/${sourceId}/examples`))
 }
 }
@@ -73,10 +85,9 @@ export function generateRagAnswer(question, evidences) {
 }
 }
 
 
 export async function sendRagQuestion(params) {
 export async function sendRagQuestion(params) {
-  const { query, topK = 5, strategy = 'hybrid', enabled, onStep } = params
+  const { query, topK = 5, strategy = 'hybrid', enabled, structuredSourceIds = [], graphSourceIds = [], onStep } = params
   const results = { document: null, structured: null, graph: null, kb: null }
   const results = { document: null, structured: null, graph: null, kb: null }
   const tasks = []
   const tasks = []
-  const usesKnowledgeBase = enabled.structured || enabled.graph
 
 
   if (enabled.document) {
   if (enabled.document) {
     onStep?.('document', 'running')
     onStep?.('document', 'running')
@@ -85,39 +96,35 @@ export async function sendRagQuestion(params) {
       .catch(error => { onStep?.('document', 'failed', null, error); results.document = { evidences: [], diagnostics: { error: error.message } } }))
       .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 } }
-    }))
+  if (enabled.structured) {
+    onStep?.('structured', 'running')
+    tasks.push(retrieveStructuredEvidence(query, structuredSourceIds, topK)
+      .then(data => {
+        results.structured = data
+        const diagnostics = data?.diagnostics || {}
+        const error = Object.keys(diagnostics).some(key => key === 'error')
+          ? new Error(JSON.stringify(diagnostics)) : null
+        onStep?.('structured', error ? 'failed' : 'success', data, error)
+      })
+      .catch(error => {
+        onStep?.('structured', 'failed', null, error)
+        results.structured = { evidences: [], diagnostics: { error: error.message } }
+      }))
+  }
+
+  if (enabled.graph) {
+    onStep?.('graph', 'running')
+    tasks.push(retrieveGraphEvidence(query, graphSourceIds, topK)
+      .then(data => {
+        results.graph = data
+        const diagnostics = data?.diagnostics || {}
+        const error = Object.keys(diagnostics).length ? new Error(JSON.stringify(diagnostics)) : null
+        onStep?.('graph', error ? 'failed' : 'success', data, error)
+      })
+      .catch(error => {
+        onStep?.('graph', 'failed', null, error)
+        results.graph = { evidences: [], diagnostics: { error: error.message } }
+      }))
   }
   }
 
 
   await Promise.all(tasks)
   await Promise.all(tasks)

+ 1 - 1
frontend/src/components/layout/AppSidebar.vue

@@ -30,7 +30,7 @@ const menuItems = [
       { key: '/kb/datasources', label: '结构化数据', icon: ServerOutline },
       { 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', label: 'RAG', icon: SparklesOutline },
-      { key: '/kb/rag-governance', label: 'RAG 治理', icon: SettingsOutline }
+      { key: '/kb/rag-governance', label: '图谱治理', icon: SettingsOutline }
     ]
     ]
   },
   },
   {
   {

File diff suppressed because it is too large
+ 3 - 1
frontend/src/components/rag/RagEvidencePanel.vue


File diff suppressed because it is too large
+ 35 - 8
frontend/src/components/rag/RagSourcePanel.vue


+ 1 - 1
frontend/src/router/index.js

@@ -81,7 +81,7 @@ const routes = [
     path: '/kb/rag-governance',
     path: '/kb/rag-governance',
     name: 'RagGovernance',
     name: 'RagGovernance',
     component: () => import('../views/knowledge/RagGovernance.vue'),
     component: () => import('../views/knowledge/RagGovernance.vue'),
-    meta: { title: 'RAG 治理' }
+    meta: { title: '图谱治理' }
   },
   },
   {
   {
     path: '/kb/rag',
     path: '/kb/rag',

+ 36 - 2
frontend/src/views/knowledge/DataSourceManagement.vue

@@ -1,5 +1,5 @@
 <script setup>
 <script setup>
-import { ref, computed, onMounted } from 'vue'
+import { ref, computed, onMounted, watch } from 'vue'
 import { useMessage } from 'naive-ui'
 import { useMessage } from 'naive-ui'
 import {
 import {
   NButton, NIcon, NSpin, NTabs, NTabPane, NTag
   NButton, NIcon, NSpin, NTabs, NTabPane, NTag
@@ -13,7 +13,7 @@ import TableExplorer from '../../components/knowledge/TableExplorer.vue'
 import SqlConsole from '../../components/knowledge/SqlConsole.vue'
 import SqlConsole from '../../components/knowledge/SqlConsole.vue'
 import QueryHistory from '../../components/knowledge/QueryHistory.vue'
 import QueryHistory from '../../components/knowledge/QueryHistory.vue'
 import {
 import {
-  getDataSources, deleteDataSource, testExistingDataSource
+  getDataSources, deleteDataSource, getStructuredRagProfile, refreshStructuredRagProfile, testExistingDataSource
 } from '../../api/datasource'
 } from '../../api/datasource'
 
 
 const message = useMessage()
 const message = useMessage()
@@ -26,6 +26,8 @@ const activeTab = ref('tables')
 
 
 const showForm = ref(false)
 const showForm = ref(false)
 const editing = ref(null)
 const editing = ref(null)
+const ragProfile = ref(null)
+const profileLoading = ref(false)
 
 
 const historyVersion = ref(0)  // 用于触发 QueryHistory 刷新
 const historyVersion = ref(0)  // 用于触发 QueryHistory 刷新
 
 
@@ -100,6 +102,25 @@ function selectDs(id) {
   selectedId.value = id
   selectedId.value = id
 }
 }
 
 
+async function loadRagProfile() {
+  if (!selectedId.value) { ragProfile.value = null; return }
+  profileLoading.value = true
+  try { ragProfile.value = (await getStructuredRagProfile(selectedId.value)).data || null }
+  catch (error) { message.error('加载RAG画像失败:' + error.message) }
+  finally { profileLoading.value = false }
+}
+
+async function refreshRagProfile() {
+  if (!selectedId.value) return
+  profileLoading.value = true
+  try {
+    ragProfile.value = (await refreshStructuredRagProfile(selectedId.value)).data || null
+    message.success('画像刷新任务已提交')
+    setTimeout(loadRagProfile, 2500)
+  } catch (error) { message.error('刷新RAG画像失败:' + error.message) }
+  finally { profileLoading.value = false }
+}
+
 // TableExplorer / SqlConsole 双击插入字段回调
 // TableExplorer / SqlConsole 双击插入字段回调
 function onInsertTable(name) {
 function onInsertTable(name) {
   message.info(`已选中表:${name}(在 SQL Console 中双击表名/字段名可快速插入)`)
   message.info(`已选中表:${name}(在 SQL Console 中双击表名/字段名可快速插入)`)
@@ -121,6 +142,7 @@ const typeColorMap = {
 onMounted(() => {
 onMounted(() => {
   loadDataSources()
   loadDataSources()
 })
 })
+watch(selectedId, loadRagProfile)
 </script>
 </script>
 
 
 <template>
 <template>
@@ -220,6 +242,15 @@ onMounted(() => {
           <div class="empty-sub">选择数据源后可浏览表结构、执行 SQL 查询</div>
           <div class="empty-sub">选择数据源后可浏览表结构、执行 SQL 查询</div>
         </div>
         </div>
         <div v-else class="main-content">
         <div v-else class="main-content">
+          <div class="profile-strip">
+            <div><b>Text2SQL画像</b><n-tag size="small" :type="ragProfile?.scanStatus === 'SUCCESS' ? 'success' : ragProfile?.scanStatus === 'FAILED' ? 'error' : 'warning'">{{ ragProfile?.scanStatus || '加载中' }}</n-tag></div>
+            <span>表 {{ ragProfile?.tableCount || 0 }}</span>
+            <span>物理关系 {{ ragProfile?.physicalRelationships || 0 }}</span>
+            <span>推断关系 {{ ragProfile?.inferredRelationships || 0 }}</span>
+            <span>条件关系 {{ ragProfile?.conditionalRelationships || 0 }}</span>
+            <span v-if="ragProfile?.scannedAt">{{ new Date(ragProfile.scannedAt).toLocaleString('zh-CN') }}</span>
+            <n-button size="tiny" :loading="profileLoading" @click="refreshRagProfile">刷新画像</n-button>
+          </div>
           <n-tabs v-model:value="activeTab" type="line" animated style="height: 100%">
           <n-tabs v-model:value="activeTab" type="line" animated style="height: 100%">
             <n-tab-pane name="tables" tab="表结构" style="height: 100%">
             <n-tab-pane name="tables" tab="表结构" style="height: 100%">
               <TableExplorer :datasource-id="selectedId" @insert-table="onInsertTable" />
               <TableExplorer :datasource-id="selectedId" @insert-table="onInsertTable" />
@@ -494,4 +525,7 @@ onMounted(() => {
   flex: 1;
   flex: 1;
   min-height: 0;
   min-height: 0;
 }
 }
+.profile-strip{display:flex;align-items:center;gap:14px;min-height:44px;padding:8px 14px;border-bottom:1px solid var(--border-color);font-size:12px;color:var(--text-secondary)}
+.profile-strip>div{display:flex;align-items:center;gap:8px;color:var(--text-primary)}
+.profile-strip .n-button{margin-left:auto}
 </style>
 </style>

+ 74 - 77
frontend/src/views/knowledge/RagGovernance.vue

@@ -2,26 +2,29 @@
 import { computed, onMounted, reactive, ref } from 'vue'
 import { computed, onMounted, reactive, ref } from 'vue'
 import { useMessage } from 'naive-ui'
 import { useMessage } from 'naive-ui'
 import {
 import {
-  addRagQueryExampleCandidates, deleteRagQueryExample, getKnowledgeBaseBindings, listRagQueryExamples, refreshRagCapability,
-  suggestRagGovernance, upsertKnowledgeBaseBindingBySource, verifyRagQueryExample
+  addRagQueryExampleCandidates, deleteRagQueryExample, getGraphGovernance, getGraphReadiness, listRagQueryExamples, refreshRagCapability,
+  suggestRagGovernance, updateGraphGovernance, verifyRagQueryExample
 } from '../../api/rag'
 } from '../../api/rag'
+import { getGraphSources } from '../../api/graphsource'
 
 
 const message = useMessage()
 const message = useMessage()
-const form = reactive({ sourceType: 'GRAPH', sourceId: '1', description: '' })
+const form = reactive({ sourceType: 'GRAPH', sourceId: null, description: '' })
+const graphSources = ref([])
 const profile = ref(null)
 const profile = ref(null)
 const suggestion = ref(null)
 const suggestion = ref(null)
 const examples = ref([])
 const examples = ref([])
-const bindings = ref([])
+const governanceConfig = ref(null)
+const readiness = ref(null)
 const loading = reactive({ profile: false, suggest: false, apply: false })
 const loading = reactive({ profile: false, suggest: false, apply: false })
 const selectedLabels = ref([])
 const selectedLabels = ref([])
 const selectedRelationships = ref([])
 const selectedRelationships = ref([])
-const selectedTables = ref([])
 const selectedProperties = reactive({})
 const selectedProperties = reactive({})
 const rulesText = ref('')
 const rulesText = ref('')
+const sourceOptions = computed(() => graphSources.value.map(source => ({
+  label: `${source.name}(ID: ${source.id})`, value: String(source.id)
+})))
 
 
-const isGraph = computed(() => form.sourceType === 'GRAPH')
 const graph = computed(() => profile.value?.graphSchema || { nodes: [], relationships: [] })
 const graph = computed(() => profile.value?.graphSchema || { nodes: [], relationships: [] })
-const sql = computed(() => profile.value?.sqlSchema || { tables: [] })
 const relationshipGroups = computed(() => {
 const relationshipGroups = computed(() => {
   const groups = new Map()
   const groups = new Map()
   for (const relation of graph.value.relationships) {
   for (const relation of graph.value.relationships) {
@@ -33,18 +36,15 @@ const relationshipGroups = computed(() => {
   }
   }
   return [...groups.values()]
   return [...groups.values()]
 })
 })
-const binding = computed(() => bindings.value.find(item =>
-  item.sourceType === form.sourceType && String(item.sourceId) === String(form.sourceId)))
 
 
 function clearSelection() {
 function clearSelection() {
   selectedLabels.value = []
   selectedLabels.value = []
   selectedRelationships.value = []
   selectedRelationships.value = []
-  selectedTables.value = []
   Object.keys(selectedProperties).forEach(key => delete selectedProperties[key])
   Object.keys(selectedProperties).forEach(key => delete selectedProperties[key])
 }
 }
 
 
 function currentBindingConfig() {
 function currentBindingConfig() {
-  const raw = binding.value?.config || binding.value?.configJson || {}
+  const raw = governanceConfig.value?.config || {}
   try { return typeof raw === 'string' ? JSON.parse(raw || '{}') : raw }
   try { return typeof raw === 'string' ? JSON.parse(raw || '{}') : raw }
   catch { return {} }
   catch { return {} }
 }
 }
@@ -78,35 +78,31 @@ function updateOwnerProperties(owner, values, ownerType) {
 function initializeFromBinding() {
 function initializeFromBinding() {
   clearSelection()
   clearSelection()
   rulesText.value = ''
   rulesText.value = ''
-  if (!binding.value) return
+  if (!governanceConfig.value?.configured) return
   const config = currentBindingConfig()
   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
-  }
+  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)
+  })
   rulesText.value = (config.generationRules || config.rules || []).join('\n')
   rulesText.value = (config.generationRules || config.rules || []).join('\n')
 }
 }
 
 
 async function loadProfile() {
 async function loadProfile() {
+  if (!form.sourceId) return message.warning('请先选择图数据库')
   loading.profile = true
   loading.profile = true
   try {
   try {
     profile.value = await refreshRagCapability(form.sourceType, form.sourceId)
     profile.value = await refreshRagCapability(form.sourceType, form.sourceId)
     examples.value = await listRagQueryExamples(form.sourceType, form.sourceId)
     examples.value = await listRagQueryExamples(form.sourceType, form.sourceId)
-    bindings.value = await getKnowledgeBaseBindings(1)
+    governanceConfig.value = await getGraphGovernance(form.sourceId)
+    readiness.value = await getGraphReadiness(form.sourceId)
     initializeFromBinding()
     initializeFromBinding()
   } catch (error) { message.error(error.message || 'Schema 刷新失败') }
   } catch (error) { message.error(error.message || 'Schema 刷新失败') }
   finally { loading.profile = false }
   finally { loading.profile = false }
@@ -117,12 +113,10 @@ async function generateSuggestion() {
   try {
   try {
     suggestion.value = await suggestRagGovernance(form.sourceType, form.sourceId, form.description)
     suggestion.value = await suggestRagGovernance(form.sourceType, form.sourceId, form.description)
     const config = suggestion.value?.suggestedConfig || suggestion.value?.config || suggestion.value
     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
+    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 || {})
     rulesText.value = (config.generationRules || config.rules || []).join('\n')
     rulesText.value = (config.generationRules || config.rules || []).join('\n')
     message.success('已生成建议,请审核后再应用')
     message.success('已生成建议,请审核后再应用')
   } catch (error) { message.error(error.message || '建议生成失败') }
   } catch (error) { message.error(error.message || '建议生成失败') }
@@ -130,35 +124,29 @@ async function generateSuggestion() {
 }
 }
 
 
 function buildConfig() {
 function buildConfig() {
-  const original = binding.value?.config || binding.value?.configJson || {}
+  const original = governanceConfig.value?.config || {}
   const config = typeof original === 'string' ? JSON.parse(original || '{}') : { ...original }
   const config = typeof original === 'string' ? JSON.parse(original || '{}') : { ...original }
   Object.assign(config, {
   Object.assign(config, {
     retrievalMode: 'AUTO_GENERATE', topK: config.topK || 5,
     retrievalMode: 'AUTO_GENERATE', topK: config.topK || 5,
     generationRules: rulesText.value.split('\n').map(v => v.trim()).filter(Boolean)
     generationRules: rulesText.value.split('\n').map(v => v.trim()).filter(Boolean)
   })
   })
-  if (isGraph.value) Object.assign(config, {
+  Object.assign(config, {
     allowTextToCypher: true, allowedLabels: selectedLabels.value,
     allowTextToCypher: true, allowedLabels: selectedLabels.value,
     allowedRelationships: selectedRelationships.value,
     allowedRelationships: selectedRelationships.value,
     allowedProperties: Object.fromEntries(Object.entries(selectedProperties).filter(([owner]) =>
     allowedProperties: Object.fromEntries(Object.entries(selectedProperties).filter(([owner]) =>
       selectedLabels.value.includes(owner) || selectedRelationships.value.includes(owner)))
       selectedLabels.value.includes(owner) || selectedRelationships.value.includes(owner)))
   })
   })
-  else Object.assign(config, { allowTextToSql: true, allowedTables: selectedTables.value })
   return config
   return config
 }
 }
 
 
 async function applyConfig() {
 async function applyConfig() {
-  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`)
-  }
+  if (!selectedLabels.value.length) return message.error('至少授权一个 Label,空白名单会造成权限语义不明确')
+  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
   loading.apply = true
   try {
   try {
-    // 按 (sourceType, sourceId) 查找或创建绑定:解决用户进入治理页时 kbId=1 下尚未建立绑定记录的问题
-    await upsertKnowledgeBaseBindingBySource(1, form.sourceType, form.sourceId, buildConfig())
-    bindings.value = await getKnowledgeBaseBindings(1)
+    governanceConfig.value = await updateGraphGovernance(form.sourceId, buildConfig())
     message.success('授权配置已应用')
     message.success('授权配置已应用')
   } catch (error) { message.error(error.message || '配置应用失败') }
   } catch (error) { message.error(error.message || '配置应用失败') }
   finally { loading.apply = false }
   finally { loading.apply = false }
@@ -188,16 +176,27 @@ async function importCandidates() {
   } catch (error) { message.error(error.message || '案例导入失败') }
   } catch (error) { message.error(error.message || '案例导入失败') }
 }
 }
 
 
-onMounted(loadProfile)
+async function initialize() {
+  try {
+    const response = await getGraphSources()
+    graphSources.value = response.data || []
+    if (graphSources.value.length) {
+      form.sourceId = String(graphSources.value[0].id)
+      await loadProfile()
+    } else message.warning('尚未配置图数据库,请先在知识图谱页面新增图源')
+  } catch (error) { message.error(error.message || '图数据库列表加载失败') }
+}
+
+onMounted(initialize)
 </script>
 </script>
 
 
 <template>
 <template>
   <div class="page">
   <div class="page">
-    <header><div><h1>RAG 治理中心</h1><p>根据真实 Schema 建议业务子图,审核授权范围与动态 Few-shot。</p></div></header>
+    <header><div><h1>图谱治理</h1><p>根据真实图 Schema 建议业务子图,审核授权范围与动态 Few-shot。</p></div></header>
     <n-card class="toolbar">
     <n-card class="toolbar">
       <n-space align="end">
       <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-tag type="info">图数据库</n-tag></n-form-item>
+        <n-form-item label="图数据库"><n-select v-model:value="form.sourceId" :options="sourceOptions" style="width:240px" @update:value="loadProfile" /></n-form-item>
         <n-form-item label="业务描述"><n-input v-model:value="form.description" placeholder="例如:东海救援任务、力量与阶段" style="width:360px" /></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 :loading="loading.profile" @click="loadProfile">刷新画像</n-button>
         <n-button type="primary" :loading="loading.suggest" @click="generateSuggestion">生成业务子图与案例</n-button>
         <n-button type="primary" :loading="loading.suggest" @click="generateSuggestion">生成业务子图与案例</n-button>
@@ -206,34 +205,32 @@ onMounted(loadProfile)
 
 
     <div class="metrics" v-if="profile">
     <div class="metrics" v-if="profile">
       <n-card size="small"><b>画像版本</b><span>{{ profile.version }}</span></n-card>
       <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>Schema 规模</b><span>{{ `${graph.nodes.length} Labels / ${graph.relationships.length} Relationships` }}</span></n-card>
       <n-card size="small"><b>语义目录</b><span>{{ profile.semanticCatalog?.length || 0 }} 项已向量化</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>
+      <n-card size="small"><b>治理状态</b><span>{{ governanceConfig?.configured ? '已生效' : '未配置' }}</span></n-card>
+      <n-card size="small"><b>就绪度</b><span>{{ readiness?.readinessScore ?? '-' }}</span></n-card>
     </div>
     </div>
 
 
     <div class="columns">
     <div class="columns">
       <n-card title="授权审核" class="panel">
       <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>
+        <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>
         <h3>生成规则(每行一条)</h3><n-input v-model:value="rulesText" type="textarea" :rows="5" placeholder="只生成只读查询&#10;优先使用明确关系而非笛卡尔积" />
         <h3>生成规则(每行一条)</h3><n-input v-model:value="rulesText" type="textarea" :rows="5" placeholder="只生成只读查询&#10;优先使用明确关系而非笛卡尔积" />
         <n-button type="primary" block class="apply" :loading="loading.apply" @click="applyConfig">应用审核后的授权配置</n-button>
         <n-button type="primary" block class="apply" :loading="loading.apply" @click="applyConfig">应用审核后的授权配置</n-button>
       </n-card>
       </n-card>

+ 83 - 4
frontend/src/views/knowledge/RagWorkbench.vue

@@ -1,11 +1,13 @@
 <script setup>
 <script setup>
-import { computed, ref } from 'vue'
+import { computed, onMounted, ref, watch } from 'vue'
 import { PaperPlaneOutline } from '@vicons/ionicons5'
 import { PaperPlaneOutline } from '@vicons/ionicons5'
 import { NButton, NIcon, NInput, useMessage } from 'naive-ui'
 import { NButton, NIcon, NInput, useMessage } from 'naive-ui'
 import RagSourcePanel from '../../components/rag/RagSourcePanel.vue'
 import RagSourcePanel from '../../components/rag/RagSourcePanel.vue'
 import RagChatPanel from '../../components/rag/RagChatPanel.vue'
 import RagChatPanel from '../../components/rag/RagChatPanel.vue'
 import RagEvidencePanel from '../../components/rag/RagEvidencePanel.vue'
 import RagEvidencePanel from '../../components/rag/RagEvidencePanel.vue'
-import { generateRagAnswer, sendRagQuestion } from '../../api/rag'
+import { generateRagAnswer, getGraphReadiness, sendRagQuestion } from '../../api/rag'
+import { getDataSources, getStructuredRagProfile, refreshStructuredRagProfile } from '../../api/datasource'
+import { getGraphSources } from '../../api/graphsource'
 
 
 const message = useMessage()
 const message = useMessage()
 const input = ref('东海海上目标救援任务中,可用救援力量和任务阶段分别是什么?')
 const input = ref('东海海上目标救援任务中,可用救援力量和任务阶段分别是什么?')
@@ -16,6 +18,14 @@ const topK = ref(5)
 const strategy = ref('hybrid')
 const strategy = ref('hybrid')
 const debug = ref(true)
 const debug = ref(true)
 const enabled = ref({ document: true, structured: true, graph: true })
 const enabled = ref({ document: true, structured: true, graph: true })
+const structuredSourceId = ref(null)
+const graphSourceId = ref(null)
+const structuredSourceOptions = ref([])
+const graphSourceOptions = ref([])
+const structuredProfile = ref(null)
+const structuredProfileLoading = ref(false)
+const graphReadiness = ref(null)
+const graphReadinessLoading = ref(false)
 const documentEvidence = ref([])
 const documentEvidence = ref([])
 const structuredEvidence = ref([])
 const structuredEvidence = ref([])
 const graphEvidence = ref([])
 const graphEvidence = ref([])
@@ -31,6 +41,65 @@ const createSteps = () => [
 const steps = ref(createSteps())
 const steps = ref(createSteps())
 const evidenceCount = computed(() => documentEvidence.value.length + structuredEvidence.value.length + graphEvidence.value.length)
 const evidenceCount = computed(() => documentEvidence.value.length + structuredEvidence.value.length + graphEvidence.value.length)
 
 
+const STRUCTURED_SOURCE_STORAGE_KEY = 'rag.structuredSourceId'
+const selectedStructuredSource = computed(() => structuredSourceOptions.value
+  .find(option => String(option.value) === String(structuredSourceId.value))?.source || null)
+
+async function loadStructuredSources() {
+  const response = await getDataSources()
+  structuredSourceOptions.value = (response.data || []).map(source => ({
+    label: `${source.name}(ID:${source.id})`, value: source.id, source
+  }))
+  const currentExists = structuredSourceOptions.value.some(option => String(option.value) === String(structuredSourceId.value))
+  if (currentExists) return
+  const saved = localStorage.getItem(STRUCTURED_SOURCE_STORAGE_KEY)
+  const restored = structuredSourceOptions.value.find(option => String(option.value) === String(saved))
+  structuredSourceId.value = restored?.value ?? null
+}
+
+async function loadStructuredProfile(force = false) {
+  if (structuredSourceId.value == null) { structuredProfile.value = null; return }
+  structuredProfileLoading.value = true
+  try {
+    if (force) await refreshStructuredRagProfile(structuredSourceId.value)
+    const response = await getStructuredRagProfile(structuredSourceId.value)
+    structuredProfile.value = response.data || null
+  } catch (error) {
+    structuredProfile.value = { scanStatus: 'ERROR', lastError: error.message }
+  } finally { structuredProfileLoading.value = false }
+}
+
+async function loadGraphReadiness() {
+  if (graphSourceId.value == null) { graphReadiness.value = null; return }
+  graphReadinessLoading.value = true
+  try {
+    const response = await getGraphReadiness(graphSourceId.value)
+    graphReadiness.value = response?.data || response || null
+  } catch (error) {
+    graphReadiness.value = { readinessScore: '-', recommendations: [error.message] }
+  } finally { graphReadinessLoading.value = false }
+}
+
+async function loadGraphSources() {
+  const response = await getGraphSources()
+  graphSourceOptions.value = (response.data || []).map(source => ({ label: `${source.name}(ID:${source.id})`, value: source.id }))
+  const currentExists = graphSourceOptions.value.some(option => String(option.value) === String(graphSourceId.value))
+  if (!currentExists) graphSourceId.value = graphSourceOptions.value[0]?.value ?? null
+  else await loadGraphReadiness()
+}
+
+watch(structuredSourceId, value => {
+  if (value == null) localStorage.removeItem(STRUCTURED_SOURCE_STORAGE_KEY)
+  else localStorage.setItem(STRUCTURED_SOURCE_STORAGE_KEY, String(value))
+  loadStructuredProfile()
+})
+
+watch(graphSourceId, () => loadGraphReadiness())
+
+onMounted(async () => {
+  await Promise.allSettled([loadStructuredSources(), loadGraphSources()])
+})
+
 function describe(key, data) {
 function describe(key, data) {
   if (key === 'document') return `已召回 ${data?.evidences?.length || 0} 个文档片段`
   if (key === 'document') return `已召回 ${data?.evidences?.length || 0} 个文档片段`
   if (key === 'structured') return `已获取 ${data?.evidences?.[0]?.payload?.rowCount || 0} 条查询结果`
   if (key === 'structured') return `已获取 ${data?.evidences?.[0]?.payload?.rowCount || 0} 条查询结果`
@@ -52,6 +121,8 @@ async function submit() {
   const query = input.value.trim()
   const query = input.value.trim()
   if (!query || busy.value) return
   if (!query || busy.value) return
   if (!Object.values(enabled.value).some(Boolean)) return message.warning('请至少启用一个数据源')
   if (!Object.values(enabled.value).some(Boolean)) return message.warning('请至少启用一个数据源')
+  if (enabled.value.structured && structuredSourceId.value == null) return message.warning('请选择结构化数据源')
+  if (enabled.value.graph && graphSourceId.value == null) return message.warning('请选择图谱数据源')
   busy.value = true
   busy.value = true
   question.value = query
   question.value = query
   answer.value = ''
   answer.value = ''
@@ -61,7 +132,15 @@ async function submit() {
     if (!enabled.value[step.key]) { step.description = '当前数据源未启用'; step.time = now() }
     if (!enabled.value[step.key]) { step.description = '当前数据源未启用'; step.time = now() }
   }
   }
   try {
   try {
-    const result = await sendRagQuestion({ query, topK: topK.value, strategy: strategy.value, enabled: enabled.value, onStep: updateStep })
+    const result = await sendRagQuestion({
+      query,
+      topK: topK.value,
+      strategy: strategy.value,
+      enabled: enabled.value,
+      structuredSourceIds: structuredSourceId.value == null ? [] : [String(structuredSourceId.value)],
+      graphSourceIds: graphSourceId.value == null ? [] : [String(graphSourceId.value)],
+      onStep: updateStep
+    })
     documentEvidence.value = result.document?.evidences || []
     documentEvidence.value = result.document?.evidences || []
     structuredEvidence.value = result.structured?.evidences || []
     structuredEvidence.value = result.structured?.evidences || []
     graphEvidence.value = result.graph?.evidences || []
     graphEvidence.value = result.graph?.evidences || []
@@ -89,7 +168,7 @@ async function submit() {
   <div class="rag-page">
   <div class="rag-page">
     <div class="rag-heading"><div class="breadcrumb">知识库管理 <span>/</span> RAG</div><h1>多源 RAG 问答工作台</h1><p>问题输入 → 多源检索 → 证据召回 → 融合生成</p></div>
     <div class="rag-heading"><div class="breadcrumb">知识库管理 <span>/</span> RAG</div><h1>多源 RAG 问答工作台</h1><p>问题输入 → 多源检索 → 证据召回 → 融合生成</p></div>
     <div class="workbench-grid">
     <div class="workbench-grid">
-      <RagSourcePanel v-model:enabled="enabled" v-model:topK="topK" v-model:strategy="strategy" :busy="busy" />
+      <RagSourcePanel v-model:enabled="enabled" v-model:topK="topK" v-model:strategy="strategy" v-model:structured-source-id="structuredSourceId" v-model:graph-source-id="graphSourceId" :structured-source-options="structuredSourceOptions" :graph-source-options="graphSourceOptions" :structured-source-name="selectedStructuredSource?.name || ''" :structured-profile="structuredProfile" :structured-profile-loading="structuredProfileLoading" :graph-readiness="graphReadiness" :graph-readiness-loading="graphReadinessLoading" :busy="busy" @refresh-structured-sources="loadStructuredSources" @refresh-structured-profile="loadStructuredProfile(true)" @refresh-graph-readiness="loadGraphReadiness" />
       <RagChatPanel :question="question" :steps="steps" :answer="answer" :busy="busy" />
       <RagChatPanel :question="question" :steps="steps" :answer="answer" :busy="busy" />
       <RagEvidencePanel :document-evidence="documentEvidence" :structured-evidence="structuredEvidence" :graph-evidence="graphEvidence" :enabled-sources="enabledSources" :diagnostics="diagnostics" :debug="debug" />
       <RagEvidencePanel :document-evidence="documentEvidence" :structured-evidence="structuredEvidence" :graph-evidence="graphEvidence" :enabled-sources="enabledSources" :diagnostics="diagnostics" :debug="debug" />
     </div>
     </div>

+ 12 - 0
prompt.md

@@ -1248,3 +1248,15 @@ java.lang.IllegalStateException: rag-ai-bridge is disabled
 
 
 ---
 ---
 
 
+修改 @docs/workflow-node-output-envelope.md ,目前程序是机械地将各节点的运行结果放到Json里,只能接收用户定义的固定数量的变量名。但我需要的不只是机械地包装运行结果。例如智能操作节点,根据用户描述的不同,可以匹配用户定义的多个变量,灵活地进行匹配。我需要一些机制,来让节点灵活输出Json结果(如对大模型进行强调、生成结果有问题的话让大模型重新生成等)。请针对各类节点进行设计。
+
+---
+
+读取 @agent-management-rag/0001-1.-Text2SQL.patch 文件,应用其中的更改。注意:
+1. 该更改是其他同事的更改,而我已经有了大量其他更改。所以,行号有可能已变化。所以,不要使用git命令进行合并,而是你来读取文件内容,然后智能把更改写入当前分支。不要使用脚本来进行patch应用。
+2. 不要读取其他patch文件。
+3. 对于新增文件,直接复制到对应路径即可;对于修改文件,由你来进行智能修改。
+
+---
+
+根据 @docs/workflow-node-output-envelope.md ,开展修改。

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