Parcourir la 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 il y a 1 mois
Parent
commit
abd9fbdac0
46 fichiers modifiés avec 3745 ajouts et 392 suppressions
  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",
         "temperature": 1.0,
         "timeout": 60,
-        "generation_max_tokens": 2048,
+        "generation_max_tokens": 8192,
         "answer_max_tokens": 4096,
     },
     "vanna": {"enabled": True},
@@ -96,9 +96,16 @@ class Text2SqlRequest(BaseModel):
     datasourceId: int
     dialect: str = "mysql"
     ddl: str = ""
+    schemaVersion: str = ""
     documentation: str = ""
     examples: list[Any] = 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)
     entityMentions: list[str] = Field(default_factory=list)
 
@@ -139,6 +146,12 @@ class RepairRequest(BaseModel):
     query: str
     error: 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
     maxDepth: int = 3
 
@@ -186,7 +199,21 @@ def _neo4j_configured() -> bool:
 def _openai_client(timeout: int | None = None):
     from openai import OpenAI
     return OpenAI(api_key=os.environ["OPENAI_API_KEY"], base_url=SETTINGS["llm"]["base_url"],
-                  timeout=timeout or SETTINGS["llm"]["timeout"], max_retries=0)
+                  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:
@@ -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]
 
 
+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")
 def health():
     return {
@@ -221,88 +287,15 @@ def text2sql(req: Text2SqlRequest):
     if not _llm_configured():
         raise HTTPException(status_code=503, detail="OpenAI provider or OPENAI_API_KEY is not configured")
     try:
-        import pandas as pd
-        from openai import OpenAI
-        from vanna.base import VannaBase
-        from vanna.openai import OpenAI_Chat
-
-        class RequestContext(VannaBase):
-            def __init__(self):
-                self.ddl = [req.ddl] if req.ddl else []
-                self.documentation = [req.documentation] if req.documentation else []
-                self.examples = req.examples
-
-            def get_related_ddl(self, question: str, **kwargs) -> list:
-                return self.ddl
-
-            def get_related_documentation(self, question: str, **kwargs) -> list:
-                return self.documentation
-
-            def get_similar_question_sql(self, question: str, **kwargs) -> list:
-                return self.examples
-
-            def generate_embedding(self, data: str, **kwargs) -> list[float]:
-                return []
-
-            def add_ddl(self, ddl: str, **kwargs) -> str:
-                self.ddl.append(ddl)
-                return str(len(self.ddl))
-
-            def add_documentation(self, documentation: str, **kwargs) -> str:
-                self.documentation.append(documentation)
-                return str(len(self.documentation))
-
-            def add_question_sql(self, question: str, sql: str, **kwargs) -> str:
-                self.examples.append({"question": question, "sql": sql})
-                return str(len(self.examples))
-
-            def get_training_data(self, **kwargs) -> pd.DataFrame:
-                return pd.DataFrame()
-
-            def remove_training_data(self, id: str, **kwargs) -> bool:
-                return False
-
-        class Vanna(RequestContext, OpenAI_Chat):
-            def __init__(self):
-                RequestContext.__init__(self)
-                client = OpenAI(
-                    api_key=os.environ["OPENAI_API_KEY"],
-                    base_url=SETTINGS["llm"]["base_url"],
-                    timeout=SETTINGS["llm"]["timeout"],
-                    max_retries=0,
-                )
-                OpenAI_Chat.__init__(self, client=client, config={
-                    "model": SETTINGS["llm"]["model"],
-                    "temperature": SETTINGS["llm"]["temperature"],
-                })
-
-            def submit_prompt(self, prompt, **kwargs) -> str:
-                response = self.client.chat.completions.create(
-                    model=SETTINGS["llm"]["model"],
-                    messages=prompt,
-                    temperature=SETTINGS["llm"]["temperature"],
-                    max_tokens=SETTINGS["llm"]["generation_max_tokens"],
-                )
-                return response.choices[0].message.content or ""
-
-        vn = Vanna()
-        prompt = [{
-            "role": "system",
-            "content": (
-                f"You generate exactly one final read-only {req.dialect} SQL query. "
-                "Return SQL only, without comments, explanation or intermediate queries. "
-                "Use only the supplied schema. Never generate DML or DDL. "
-                f"Limit results to at most {req.maxRows} rows."
-            ),
-        }, {
-            "role": "user",
-            "content": (
-                f"Schema:\n{req.ddl}\nDocumentation:\n{req.documentation}\n"
-                f"Allowed tables: {req.tableWhitelist}\nEntity mentions that must be grounded to real columns: {req.entityMentions}. Unless an exact stored value is supplied by examples, use LIKE for abbreviated entity names.\nExamples: {req.examples}\nQuestion: {req.query}"
-            ),
-        }]
-        sql = _read_only(_extract_code(vn.submit_prompt(prompt), "SQL"), "SQL")
-        return {"sql": sql, "confidence": 0.0, "usedContext": ["ddl"] if req.ddl else [], "warnings": []}
+        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:
         raise
     except Exception as exc:
@@ -321,7 +314,7 @@ def text2cypher(req: Text2CypherRequest):
         llm = OpenAI(
             api_key=os.environ["OPENAI_API_KEY"],
             base_url=SETTINGS["llm"]["base_url"],
-            timeout=SETTINGS["llm"]["timeout"],
+            timeout=_client_timeout(),
             max_retries=0,
         )
         prompt = (
@@ -348,8 +341,16 @@ def text2cypher(req: Text2CypherRequest):
             temperature=SETTINGS["llm"]["temperature"],
             max_tokens=SETTINGS["llm"]["generation_max_tokens"],
         )
-        cypher = _read_only(_extract_code(response.choices[0].message.content or "", "Cypher"), "Cypher")
-        return {"cypher": cypher, "confidence": 0.0, "usedSchema": req.allowedLabels, "warnings": []}
+        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:
         raise
     except Exception as exc:
@@ -366,7 +367,7 @@ def answer(req: AnswerRequest):
         client = OpenAI(
             api_key=os.environ["OPENAI_API_KEY"],
             base_url=SETTINGS["llm"]["base_url"],
-            timeout=SETTINGS["llm"]["timeout"],
+            timeout=_client_timeout(),
             max_retries=0,
         )
         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")
 def repair(req: RepairRequest):
     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.
 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.

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

@@ -43,5 +43,16 @@ public class KbProperties {
     /**
      * 允许上传的 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 {
 
     private final DataSourceService dataSourceService;
+    private final com.agent.management.rag.structured.StructuredSchemaProfileService structuredProfiles;
 
     @GetMapping
     public Result<List<DataSource>> list() {
@@ -33,20 +34,38 @@ public class DataSourceController {
 
     @PostMapping
     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}")
     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}")
     public Result<Void> delete(@PathVariable Long id) {
+        structuredProfiles.deleteProfiles(id);
         dataSourceService.delete(id);
         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.web.client.RestTemplate;
 import java.util.*;
+import java.time.Duration;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
 
 @Component
 public class RagAiBridgeClient {
     private final RagAiBridgeProperties properties;
     private final RestTemplate rest;
+    private final RestTemplate healthRest;
+    private final Semaphore modelSlots = new Semaphore(1, true);
     public RagAiBridgeClient(RagAiBridgeProperties properties) {
         this.properties=properties;
-        var factory=new SimpleClientHttpRequestFactory();
-        int timeout=(int)Math.min(Integer.MAX_VALUE, properties.getTimeout().toMillis());
-        factory.setConnectTimeout(timeout); factory.setReadTimeout(timeout); this.rest=new RestTemplate(factory);
+        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> textToCypher(Map<String,Object> request){return post("/text2cypher",request);}
     public Map<String,Object> answer(Map<String,Object> request){return post("/answer",request);}
     public Map<String,Object> suggestGovernance(Map<String,Object> request){return post("/governance/suggest",request);}
     public Map<String,Object> plan(Map<String,Object> request){return post("/plan",request);}
     public Map<String,Object> repair(Map<String,Object> request){return post("/repair",request);}
+    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){
+        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");
-        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;}
 }

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

@@ -1,6 +1,8 @@
 package com.agent.management.rag.bridge;
 
 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.PreDestroy;
 import lombok.extern.slf4j.Slf4j;
@@ -34,6 +36,7 @@ public class RagAiBridgeProcessManager {
 
     private final RagAiBridgeProperties properties;
     private final RagAiBridgeClient client;
+    private final AiModelService aiModelService;
     /** spring.ai.openai.* 中的 LLM 配置,通过环境变量传给 Bridge 子进程 */
     private final String llmBaseUrl;
     private final String llmApiKey;
@@ -49,12 +52,14 @@ public class RagAiBridgeProcessManager {
     public RagAiBridgeProcessManager(
             RagAiBridgeProperties properties,
             RagAiBridgeClient client,
+            AiModelService aiModelService,
             @Value("${spring.ai.openai.base-url:}") String llmBaseUrl,
             @Value("${spring.ai.openai.api-key:}") String llmApiKey,
             @Value("${spring.ai.openai.chat.options.model:}") String llmModel,
             @Value("${spring.ai.openai.chat.options.temperature:0.3}") String llmTemperature) {
         this.properties = properties;
         this.client = client;
+        this.aiModelService = aiModelService;
         this.llmBaseUrl = llmBaseUrl;
         this.llmApiKey = llmApiKey;
         this.llmModel = llmModel;
@@ -64,12 +69,19 @@ public class RagAiBridgeProcessManager {
     @PostConstruct
     public void start() {
         log.info("[RagAiBridge] 启动子进程...");
-        starting.set(true);
+        if (!starting.compareAndSet(false, true)) return;
 
         try {
+            if (client.health()) {
+                log.info("[RagAiBridge] existing healthy instance detected; reusing it");
+                starting.set(false);
+                startHealthCheck();
+                return;
+            }
             String scriptPath = resolveScriptPath();
             String pythonPath = properties.getPythonPath();
             int port = properties.getPort();
+            LlmConfig llm = resolveLlmConfig();
 
             // server.py 不支持 --port/--host 命令行参数,全部通过环境变量传
             ProcessBuilder pb = new ProcessBuilder(pythonPath, scriptPath);
@@ -82,27 +94,29 @@ public class RagAiBridgeProcessManager {
             env.put("PYTHONUTF8", "1");
             env.put("RAG_AI_BRIDGE_HOST", properties.getHost());
             env.put("RAG_AI_BRIDGE_PORT", String.valueOf(port));
+            env.put("LLM_TIMEOUT", String.valueOf(properties.getTimeout().toSeconds()));
 
             // 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 详情,避免敏感信息泄露
-            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"));
@@ -174,13 +188,13 @@ public class RagAiBridgeProcessManager {
      */
     public boolean isReady() {
         if (starting.get()) return false;
-        if (process == null || !process.isAlive()) return false;
         return client.health();
     }
 
     private void startHealthCheck() {
         int interval = properties.getHealthCheckInterval();
         if (interval <= 0) return;
+        if (healthScheduler != null && !healthScheduler.isShutdown()) return;
 
         healthScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
             Thread t = new Thread(r, "rag-ai-bridge-health-check");
@@ -190,7 +204,7 @@ public class RagAiBridgeProcessManager {
 
         healthScheduler.scheduleAtFixedRate(() -> {
             if (stopped) return;
-            if (process != null && !process.isAlive()) {
+            if ((process == null || !process.isAlive()) && !client.health()) {
                 log.warn("[RagAiBridge] 子进程已退出,尝试重启...");
                 start();
             }
@@ -208,4 +222,30 @@ public class RagAiBridgeProcessManager {
         }
         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,
                                       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> allowedRelationships = new LinkedHashSet<>(allowed.relationshipTypeNames());
         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()
                 .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()) {
             allowed.nodes().stream()
                     .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> 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());
-            selectedLabels.addAll(item.relationship().startLabels());
-            selectedLabels.addAll(item.relationship().endLabels());
-        });
+            selectedLabels.addAll(required);
+        }
         if (selectedRelationships.isEmpty() && !selectedLabels.isEmpty()) {
             allowed.relationships().stream()
                     .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 java.util.*;
+import java.util.Locale;
 
 @Service
 @RequiredArgsConstructor
@@ -22,6 +23,10 @@ public class GraphOnboardingAssessmentService {
         List<String> orphanLabels = schema.labelNames().stream().filter(label -> !connected.contains(label)).toList();
         long properties = schema.nodes().stream().mapToLong(node -> node.properties().size()).sum()
                 + schema.relationships().stream().mapToLong(rel -> rel.properties().size()).sum();
+        long 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 verifiedExamples = examples.list(RagSourceType.GRAPH, sourceId).stream().filter(item -> item.isVerified()).count();
         Map<String,Object> metadata = graphSources.discoverMetadata(Long.valueOf(sourceId));
@@ -29,6 +34,8 @@ public class GraphOnboardingAssessmentService {
         if (!schema.nodes().isEmpty()) score += 20;
         if (!schema.relationships().isEmpty()) score += 20;
         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 (orphanLabels.size() <= Math.max(1, schema.nodes().size() / 10)) score += 10;
         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 (!orphanLabels.isEmpty()) recommendations.add("审核孤立 Label,确认是否缺少关系或应排除:" + String.join(", ", orphanLabels));
         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 (verifiedExamples < 5) recommendations.add("建议为查找、过滤、聚合、路径、多跳五类问法各审核至少一条 Few-shot");
         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("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("vectorizedEntries", vectorized); result.put("verifiedExamples", verifiedExamples);
         result.put("recommendations", recommendations);
         result.put("constraints", metadata.get("constraints")); result.put("indexes", metadata.get("indexes"));
         result.put("labelCounts", metadata.get("labelCounts"));
+        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;
     }
 }

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

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

+ 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.memory.RagExampleCandidateService candidateImports;
     private final com.agent.management.rag.capability.GraphOnboardingAssessmentService graphAssessment;
+    private final com.agent.management.rag.capability.GraphGovernanceConfigService graphGovernance;
 
     @GetMapping("/{type}/{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) {
         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);
             Optional<String> cypher = explicit.generateCypher(query, id);
             boolean auto = false;
+            int repairCount = 0;
+            Map<String,Object> generationMetadata = Map.of();
             if (cypher.isEmpty()) {
                 auto = true;
                 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) {
                     out.getDiagnostics().put("error", "Cypher generation failed: " + error.getMessage());
                     return out;
                 }
             }
             if (cypher.isEmpty()) {
-                out.getDiagnostics().put("warning", "no Cypher provided and automatic generation is disabled or returned empty");
+                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;
             }
             if (!auto) generated.validateExplicit(query, id, cypher.get());
             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;
             }
             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"));
@@ -83,6 +108,9 @@ public class GraphRagRetriever implements RagRetriever {
             metadata.put("recordCount", records.size());
             metadata.put("durationMs", result.getOrDefault("durationMs", 0));
             metadata.put("warnings", Boolean.TRUE.equals(result.get("truncated")) ? List.of("result truncated") : List.of());
+            metadata.put("repaired", repairCount > 0);
+            metadata.put("repairCount", repairCount);
+            metadata.putAll(generationMetadata);
             evidence.setMetadata(metadata);
             out.getEvidences().add(evidence);
             if (auto && autoLearn(query) && hasResults(result))
@@ -93,20 +121,26 @@ public class GraphRagRetriever implements RagRetriever {
         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 {
             graphs.explainQuery(sourceId, cypher);
-            return Optional.of(cypher);
+            return new PreflightResult(Optional.of(cypher), 0);
         } 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 {
                 Optional<String> repaired = generated.repair(query, sourceId, cypher, explainError.getMessage());
                 if (repaired.isEmpty()) throw new IllegalArgumentException("repair returned empty Cypher");
                 graphs.explainQuery(sourceId, repaired.get());
                 out.getDiagnostics().put("repair", "generated Cypher failed EXPLAIN and was repaired once");
-                return repaired;
+                return new PreflightResult(repaired, 1);
             } catch (Exception repairError) {
                 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) {
         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));
     }
 
@@ -125,4 +160,6 @@ public class GraphRagRetriever implements RagRetriever {
         return !list(result.get("nodes")).isEmpty() || !list(result.get("edges")).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<>();
         Matcher predicateMatcher=LABEL_PREDICATE.matcher(cypher);
         while(predicateMatcher.find()) {
+            if (isInsideSquareBrackets(cypher, predicateMatcher.start())) continue;
             String label=predicateMatcher.group(2);
             if(schema.node(label).isEmpty()) throw new IllegalArgumentException("Cypher uses nonexistent or unauthorized label: "+label);
             variables.putIfAbsent(predicateMatcher.group(1),label);
@@ -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.RagSchemaLinker;
 import com.agent.management.rag.capability.RagCapabilityProfile;
-import com.agent.management.rag.capability.RagSemanticCatalogService;
 import com.agent.management.rag.capability.GraphBusinessSubgraphSelector;
 import com.agent.management.rag.capability.RagEntityMentionExtractor;
 import com.agent.management.rag.memory.RagExampleMemoryService;
@@ -13,6 +12,7 @@ import com.agent.management.rag.model.RagSourceType;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
 
+import java.time.Duration;
 import java.util.*;
 
 @Service
@@ -21,60 +21,94 @@ public class Neo4jGraphRagCypherGenerationService implements CypherGenerationSer
     private final RagAiBridgeClient bridge;
     private final RagCapabilityProfileService profiles;
     private final RagSchemaLinker linker;
-    private final RagSemanticCatalogService semanticCatalog;
     private final GraphBusinessSubgraphSelector subgraphs;
     private final GraphSchemaValidator validator;
     private final RagExampleMemoryService examples;
     private final RagEntityMentionExtractor entities;
+    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) {
+        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();
-        RagCapabilityProfile profile = profiles.get(RagSourceType.GRAPH, String.valueOf(graphSourceId));
-        GraphSchemaSnapshot actual = profile.graphSchema();
-        GraphSchemaSnapshot authorized = actual.filter(stringList(query,"allowedLabels"),
-                stringList(query,"allowedRelationships"), properties(query));
         List<Map<String,Object>> promptExamples = new ArrayList<>(maps(query, "examples"));
         promptExamples.addAll(examples.findSimilar(RagSourceType.GRAPH, String.valueOf(graphSourceId), query.getQuery(), 3));
-        List<String> semanticLabels = semanticCatalog.rank(query.getQuery(), profile, "label:", 8);
-        List<String> semanticRelationships = semanticCatalog.rank(query.getQuery(), profile, "relationship:", 8);
-        GraphSchemaSnapshot semanticSchema = authorized.nodes().size() <= 12 || (semanticLabels.isEmpty() && semanticRelationships.isEmpty()) ? authorized
-                : subgraphs.select(query.getQuery(), profile, authorized, 10, 14);
-        GraphSchemaSnapshot selected = linker.selectGraphSchema(query.getQuery(), semanticSchema, promptExamples);
+        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");
         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<>();
         request.put("query",query.getQuery()); request.put("graphSourceId",graphSourceId);
         request.put("schema",selected.toPromptText()); request.put("examples",normalizeExamples(promptExamples));
-        request.put("businessRules",generationContext(query)); request.put("allowedLabels",selected.labelNames());
+        request.put("businessRules",generationContext(query, selection)); request.put("allowedLabels",selected.labelNames());
         request.put("allowedRelationships",selected.relationshipTypeNames()); request.put("allowedProperties",allowedProperties(selected));
         request.put("maxDepth",maxDepth);
-        request.put("entityMentions", entities.extract(query.getQuery()));
+        request.put("entityMentions", entityMentions(query, selection));
         Object cypher = bridge.textToCypher(request).get("cypher");
         if(cypher==null||String.valueOf(cypher).isBlank()) return Optional.empty();
         String generated=String.valueOf(cypher);
         try {
             validator.validate(generated,selected,maxDepth);
-            return Optional.of(generated);
+            return Optional.of(new GeneratedCypher(generated, false, selection.metadata()));
         } catch (IllegalArgumentException validationError) {
             Object fixed=bridge.repair(Map.of("language","CYPHER","question",query.getQuery(),"query",generated,
-                    "error",validationError.getMessage(),"schemaText",selected.toPromptText(),"maxRows",50,"maxDepth",maxDepth)).get("query");
+                    "error",validationError.getMessage(),"schemaText",selected.toPromptText(),"maxRows",50,"maxDepth",maxDepth),
+                    Duration.ofSeconds(30)).get("query");
             if(fixed==null||String.valueOf(fixed).isBlank()) throw validationError;
-            String repaired=String.valueOf(fixed);validator.validate(repaired,selected,maxDepth);return Optional.of(repaired);
+            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){
-        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);
         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();
         validator.validate(String.valueOf(fixed),schema,maxDepth);return Optional.of(String.valueOf(fixed));
     }
 
     public void validateExplicit(RagQuery query, Long graphSourceId, String cypher) {
+        query = governance.apply(query, graphSourceId);
         GraphSchemaSnapshot authorized = profiles.get(RagSourceType.GRAPH, String.valueOf(graphSourceId)).graphSchema()
                 .filter(stringList(query, "allowedLabels"), stringList(query, "allowedRelationships"), properties(query));
         validator.validate(cypher, authorized, integer(query, "maxDepth", 3));
@@ -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")));}
     private static int integer(RagQuery q,String key,int d){Object v=q.getFilters().get(key);return v instanceof Number n?n.intValue():d;}
     private static String string(RagQuery q,String key){Object value=q.getFilters().get(key);return value==null?"":String.valueOf(value);}
-    private static String generationContext(RagQuery query){String existing=string(query,"businessRules");List<String> rules=stringList(query,"generationRules");return rules.isEmpty()?existing:existing+(existing.isBlank()?"":"\n")+"Generation rules:\n- "+String.join("\n- ",rules);}
+    private static 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<Map<String,Object>> maps(RagQuery q,String key){Object value=q.getFilters().get(key);if(!(value instanceof List<?> list))return List.of();return list.stream().filter(Map.class::isInstance).map(item->{Map<?,?> raw=(Map<?,?>)item;Map<String,Object> map=new LinkedHashMap<>();raw.forEach((k,v)->map.put(String.valueOf(k),v));return map;}).toList();}
     private static List<Map<String,Object>> normalizeExamples(List<Map<String,Object>> examples){return examples.stream().map(item->item.containsKey("cypher")?item:Map.<String,Object>of("question",item.get("question"),"cypher",item.get("query"))).toList();}
     private static Map<String,List<String>> properties(RagQuery q){Object value=q.getFilters().get("allowedProperties");if(!(value instanceof Map<?,?> map))return Map.of();Map<String,List<String>> result=new LinkedHashMap<>();map.forEach((key,raw)->{if(raw instanceof List<?> list)result.put(String.valueOf(key),list.stream().map(String::valueOf).toList());});return result;}
     private static Map<String,List<String>> allowedProperties(GraphSchemaSnapshot schema){Map<String,List<String>> result=new LinkedHashMap<>();schema.nodes().forEach(node->result.put(node.label(),new ArrayList<>(node.properties().keySet())));schema.relationships().forEach(rel->result.put(rel.type(),new ArrayList<>(rel.properties().keySet())));return result;}
+
+    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))
                 .toList();
         for (CompletableFuture<BindingResult> task : tasks) {
-            BindingResult bindingResult = task.join();
-            out.getEvidences().addAll(bindingResult.result().getEvidences());
-            if (!bindingResult.result().getDiagnostics().isEmpty()) {
-                out.getDiagnostics().put(bindingResult.key(), bindingResult.result().getDiagnostics());
-            }
+            merge(out, task.join());
         }
+        rerank(out, request.getQuery());
         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) {
         RagRetrievalResult result = new RagRetrievalResult();
         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) {
         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) {
         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()))
             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;
 
 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.SchemaExplorerService;
 import lombok.RequiredArgsConstructor;
 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
 @RequiredArgsConstructor
 public class StructuredDataRagRetriever implements RagRetriever {
     private final ExplicitSqlGenerationService explicit;
     private final VannaSqlGenerationService generated;
+    private final StructuredSqlRelationshipValidator relationshipValidator;
+    private final StructuredQueryContextService contexts;
     private final SchemaExplorerService explorer;
     private final DataSourceService dataSources;
-    private final RagExampleMemoryService exampleMemory;
 
     public RagSourceType sourceType() { return RagSourceType.STRUCTURED_DATA; }
 
@@ -26,32 +38,75 @@ public class StructuredDataRagRetriever implements RagRetriever {
         out.setSourceType(sourceType());
         try {
             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 {
-                    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) {
                     out.getDiagnostics().put("error", "SQL generation failed: " + error.getMessage());
                     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);
-            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();
             evidence.setId("sql-" + UUID.randomUUID());
             evidence.setSourceType(sourceType());
             evidence.setEvidenceType("SQL_RESULT");
-            evidence.setTitle(dataSources.get(id).getName());
+            evidence.setTitle(source.getName());
             evidence.setSourceName(evidence.getTitle());
             evidence.setContent(summary(result));
             evidence.setScore(1.0);
@@ -62,54 +117,273 @@ public class StructuredDataRagRetriever implements RagRetriever {
             evidence.setSourceRef(ref);
             evidence.setPayload(Map.of("columns", result.columns(), "rows", result.rows(), "rowCount", result.rows().size()));
             Map<String, Object> metadata = new LinkedHashMap<>();
-            metadata.put("sql", sql.get());
-            metadata.put(auto ? "generatedSql" : "explicitSql", sql.get());
+            metadata.put("sql", sql);
+            metadata.put(auto ? "generatedSql" : "explicitSql", sql);
             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);
             out.getEvidences().add(evidence);
-            if (auto && autoLearn(query) && !result.rows().isEmpty())
-                exampleMemory.recordSuccess(sourceType(), String.valueOf(id), query.getQuery(), sql.get());
         } catch (Exception error) {
             out.getDiagnostics().put("error", error.getMessage());
         }
         return out;
     }
 
-    private Optional<String> preflightAndRepair(RagQuery query, Long sourceId, String sql, RagRetrievalResult out) {
+    private PreflightResult preflightAndRepair(RagQuery query, Long sourceId, String sql,
+                                               StructuredQueryContext context, RagRetrievalResult out) {
         try {
+            StructuredSqlRelationshipValidator.ValidationResult validation = relationshipValidator.validate(sql, context);
             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 {
-                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");
+                StructuredSqlRelationshipValidator.ValidationResult validation =
+                        relationshipValidator.validate(repaired.get(), context);
                 explorer.explainQuery(sourceId, repaired.get());
                 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) {
                 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) {
         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));
     }
 
     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));
     }
 
+    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) {
         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;
 
 import com.agent.management.rag.bridge.RagAiBridgeClient;
-import com.agent.management.rag.capability.*;
-import com.agent.management.rag.memory.RagExampleMemoryService;
 import com.agent.management.rag.model.RagQuery;
-import com.agent.management.rag.model.RagSourceType;
-import com.agent.management.service.DataSourceService;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
 
-import java.util.*;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
 
 @Service
 @RequiredArgsConstructor
 public class VannaSqlGenerationService implements SqlGenerationService {
     private final RagAiBridgeClient bridge;
-    private final DataSourceService dataSources;
-    private final RagCapabilityProfileService profiles;
-    private final RagSchemaLinker linker;
-    private final RagSemanticCatalogService semanticCatalog;
-    private final RagExampleMemoryService examples;
-    private final RagEntityMentionExtractor entities;
+    private final StructuredQueryContextService contexts;
 
+    @Override
     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();
-        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<>();
-        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) {
-        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) {
@@ -67,18 +74,43 @@ public class VannaSqlGenerationService implements SqlGenerationService {
                 || "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) {
-        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));
     }
-    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<>();
         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())) {
             var result = session.run(cypher, txConfig);
@@ -241,7 +243,13 @@ public class Neo4jExecutorService {
                     .list(record -> record.asMap(Value::asObject));
             List<Map<String,Object>> counts = session.run("MATCH (n) UNWIND labels(n) AS label RETURN label, count(*) AS count ORDER BY count DESC")
                     .list(record -> record.asMap(Value::asObject));
-            return Map.of("constraints", constraints, "indexes", indexes, "labelCounts", counts);
+            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()); }
     }
 

+ 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) {
-        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.stereotype.Service;
 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 java.io.IOException;
@@ -81,7 +83,7 @@ public class DocumentServiceImpl implements DocumentService {
         log.info("文档已上传,id={},name={},触发异步处理", doc.getId(), doc.getName());
 
         // 触发异步流水线(独立 bean,避免 @Async 自调用代理失效)
-        pipeline.process(doc.getId());
+        triggerPipelineAfterCommit(doc.getId());
 
         return doc;
     }
@@ -124,10 +126,23 @@ public class DocumentServiceImpl implements DocumentService {
         doc.setChunkCount(0);
         doc.setVectorCount(0);
         documentRepository.save(doc);
-        pipeline.process(id);
+        triggerPipelineAfterCommit(id);
         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
     public KbDocument vectorizeDocument(Long 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) {
-        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`)
 }
 
+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 ==============================
 
 export function getSchemas(datasourceId) {

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

@@ -1,6 +1,6 @@
 import request from '../utils/request'
 
-const RAG_TIMEOUT = 90000
+const RAG_TIMEOUT = 0
 
 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 }))
 }
 
-export function retrieveStructuredEvidence(query, sourceIds = ['1'], topK = 5) {
+export function retrieveStructuredEvidence(query, sourceIds, topK = 5) {
   return unwrap(request.post('/rag/structured/retrieve', {
     query, sourceIds, topK, filters: { allowTextToSql: true }
   }, { timeout: RAG_TIMEOUT }))
 }
 
-export function retrieveGraphEvidence(query, sourceIds = ['1'], topK = 5) {
+export function retrieveGraphEvidence(query, sourceIds, topK = 5) {
   return unwrap(request.post('/rag/graph/retrieve', {
     query, sourceIds, topK, filters: { allowTextToCypher: true }
   }, { 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 }))
 }
 
+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) {
   return unwrap(request.get(`/rag/capabilities/${sourceType}/${sourceId}/examples`))
 }
@@ -73,10 +85,9 @@ export function generateRagAnswer(question, evidences) {
 }
 
 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 tasks = []
-  const usesKnowledgeBase = enabled.structured || enabled.graph
 
   if (enabled.document) {
     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 } } }))
   }
 
-  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)

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

@@ -30,7 +30,7 @@ const menuItems = [
       { key: '/kb/datasources', label: '结构化数据', icon: ServerOutline },
       { key: '/kb/graphs', label: '知识图谱', icon: GitNetworkOutline },
       { key: '/kb/rag', label: 'RAG', icon: SparklesOutline },
-      { key: '/kb/rag-governance', label: 'RAG 治理', icon: SettingsOutline }
+      { key: '/kb/rag-governance', label: '图谱治理', icon: SettingsOutline }
     ]
   },
   {

Fichier diff supprimé car celui-ci est trop grand
+ 3 - 1
frontend/src/components/rag/RagEvidencePanel.vue


Fichier diff supprimé car celui-ci est trop grand
+ 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',
     name: 'RagGovernance',
     component: () => import('../views/knowledge/RagGovernance.vue'),
-    meta: { title: 'RAG 治理' }
+    meta: { title: '图谱治理' }
   },
   {
     path: '/kb/rag',

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

@@ -1,5 +1,5 @@
 <script setup>
-import { ref, computed, onMounted } from 'vue'
+import { ref, computed, onMounted, watch } from 'vue'
 import { useMessage } from 'naive-ui'
 import {
   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 QueryHistory from '../../components/knowledge/QueryHistory.vue'
 import {
-  getDataSources, deleteDataSource, testExistingDataSource
+  getDataSources, deleteDataSource, getStructuredRagProfile, refreshStructuredRagProfile, testExistingDataSource
 } from '../../api/datasource'
 
 const message = useMessage()
@@ -26,6 +26,8 @@ const activeTab = ref('tables')
 
 const showForm = ref(false)
 const editing = ref(null)
+const ragProfile = ref(null)
+const profileLoading = ref(false)
 
 const historyVersion = ref(0)  // 用于触发 QueryHistory 刷新
 
@@ -100,6 +102,25 @@ function selectDs(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 双击插入字段回调
 function onInsertTable(name) {
   message.info(`已选中表:${name}(在 SQL Console 中双击表名/字段名可快速插入)`)
@@ -121,6 +142,7 @@ const typeColorMap = {
 onMounted(() => {
   loadDataSources()
 })
+watch(selectedId, loadRagProfile)
 </script>
 
 <template>
@@ -220,6 +242,15 @@ onMounted(() => {
           <div class="empty-sub">选择数据源后可浏览表结构、执行 SQL 查询</div>
         </div>
         <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-tab-pane name="tables" tab="表结构" style="height: 100%">
               <TableExplorer :datasource-id="selectedId" @insert-table="onInsertTable" />
@@ -494,4 +525,7 @@ onMounted(() => {
   flex: 1;
   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>

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

@@ -2,26 +2,29 @@
 import { computed, onMounted, reactive, ref } from 'vue'
 import { useMessage } from 'naive-ui'
 import {
-  addRagQueryExampleCandidates, deleteRagQueryExample, getKnowledgeBaseBindings, listRagQueryExamples, refreshRagCapability,
-  suggestRagGovernance, upsertKnowledgeBaseBindingBySource, verifyRagQueryExample
+  addRagQueryExampleCandidates, deleteRagQueryExample, getGraphGovernance, getGraphReadiness, listRagQueryExamples, refreshRagCapability,
+  suggestRagGovernance, updateGraphGovernance, verifyRagQueryExample
 } from '../../api/rag'
+import { getGraphSources } from '../../api/graphsource'
 
 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 suggestion = ref(null)
 const examples = ref([])
-const bindings = ref([])
+const governanceConfig = ref(null)
+const readiness = ref(null)
 const loading = reactive({ profile: false, suggest: false, apply: false })
 const selectedLabels = ref([])
 const selectedRelationships = ref([])
-const selectedTables = ref([])
 const selectedProperties = reactive({})
 const rulesText = ref('')
+const 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 sql = computed(() => profile.value?.sqlSchema || { tables: [] })
 const relationshipGroups = computed(() => {
   const groups = new Map()
   for (const relation of graph.value.relationships) {
@@ -33,18 +36,15 @@ const relationshipGroups = computed(() => {
   }
   return [...groups.values()]
 })
-const binding = computed(() => bindings.value.find(item =>
-  item.sourceType === form.sourceType && String(item.sourceId) === String(form.sourceId)))
 
 function clearSelection() {
   selectedLabels.value = []
   selectedRelationships.value = []
-  selectedTables.value = []
   Object.keys(selectedProperties).forEach(key => delete selectedProperties[key])
 }
 
 function currentBindingConfig() {
-  const raw = binding.value?.config || binding.value?.configJson || {}
+  const raw = governanceConfig.value?.config || {}
   try { return typeof raw === 'string' ? JSON.parse(raw || '{}') : raw }
   catch { return {} }
 }
@@ -78,35 +78,31 @@ function updateOwnerProperties(owner, values, ownerType) {
 function initializeFromBinding() {
   clearSelection()
   rulesText.value = ''
-  if (!binding.value) return
+  if (!governanceConfig.value?.configured) return
   const config = currentBindingConfig()
-  if (isGraph.value) {
-    const actualLabels = graph.value.nodes.map(node => node.label)
-    const actualRelationships = relationshipGroups.value.map(rel => rel.type)
-    selectedLabels.value = Array.isArray(config.allowedLabels) && config.allowedLabels.length
-      ? config.allowedLabels.filter(name => actualLabels.includes(name)) : actualLabels
-    selectedRelationships.value = Array.isArray(config.allowedRelationships) && config.allowedRelationships.length
-      ? config.allowedRelationships.filter(name => actualRelationships.includes(name)) : actualRelationships
-    const configuredProperties = config.allowedProperties || {}
-    ;[...selectedLabels.value, ...selectedRelationships.value].forEach(owner => {
-      selectedProperties[owner] = Array.isArray(configuredProperties[owner])
-        ? configuredProperties[owner].filter(name => ownerProperties(owner).includes(name))
-        : ownerProperties(owner)
-    })
-  } else {
-    const actualTables = sql.value.tables.map(table => table.name)
-    selectedTables.value = Array.isArray(config.allowedTables) && config.allowedTables.length
-      ? config.allowedTables.filter(name => actualTables.includes(name)) : actualTables
-  }
+  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')
 }
 
 async function loadProfile() {
+  if (!form.sourceId) return message.warning('请先选择图数据库')
   loading.profile = true
   try {
     profile.value = await refreshRagCapability(form.sourceType, form.sourceId)
     examples.value = await listRagQueryExamples(form.sourceType, form.sourceId)
-    bindings.value = await getKnowledgeBaseBindings(1)
+    governanceConfig.value = await getGraphGovernance(form.sourceId)
+    readiness.value = await getGraphReadiness(form.sourceId)
     initializeFromBinding()
   } catch (error) { message.error(error.message || 'Schema 刷新失败') }
   finally { loading.profile = false }
@@ -117,12 +113,10 @@ async function generateSuggestion() {
   try {
     suggestion.value = await suggestRagGovernance(form.sourceType, form.sourceId, form.description)
     const config = suggestion.value?.suggestedConfig || suggestion.value?.config || suggestion.value
-    if (isGraph.value) {
-      selectedLabels.value = config.allowedLabels || selectedLabels.value
-      selectedRelationships.value = config.allowedRelationships || selectedRelationships.value
-      Object.keys(selectedProperties).forEach(key => delete selectedProperties[key])
-      Object.assign(selectedProperties, config.allowedProperties || {})
-    } else selectedTables.value = config.allowedTables || selectedTables.value
+    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')
     message.success('已生成建议,请审核后再应用')
   } catch (error) { message.error(error.message || '建议生成失败') }
@@ -130,35 +124,29 @@ async function generateSuggestion() {
 }
 
 function buildConfig() {
-  const original = binding.value?.config || binding.value?.configJson || {}
+  const original = governanceConfig.value?.config || {}
   const config = typeof original === 'string' ? JSON.parse(original || '{}') : { ...original }
   Object.assign(config, {
     retrievalMode: 'AUTO_GENERATE', topK: config.topK || 5,
     generationRules: rulesText.value.split('\n').map(v => v.trim()).filter(Boolean)
   })
-  if (isGraph.value) Object.assign(config, {
+  Object.assign(config, {
     allowTextToCypher: true, allowedLabels: selectedLabels.value,
     allowedRelationships: selectedRelationships.value,
     allowedProperties: Object.fromEntries(Object.entries(selectedProperties).filter(([owner]) =>
       selectedLabels.value.includes(owner) || selectedRelationships.value.includes(owner)))
   })
-  else Object.assign(config, { allowTextToSql: true, allowedTables: selectedTables.value })
   return config
 }
 
 async function applyConfig() {
-  if (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
   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('授权配置已应用')
   } catch (error) { message.error(error.message || '配置应用失败') }
   finally { loading.apply = false }
@@ -188,16 +176,27 @@ async function importCandidates() {
   } 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>
 
 <template>
   <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-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-button :loading="loading.profile" @click="loadProfile">刷新画像</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">
       <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>{{ 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 class="columns">
       <n-card title="授权审核" class="panel">
-        <template v-if="isGraph">
-          <n-collapse :default-expanded-names="['labels']">
-            <n-collapse-item name="labels" :title="`Labels(${selectedLabels.length}/${graph.nodes.length})`">
-              <n-checkbox-group :value="selectedLabels" @update:value="updateLabels"><n-collapse accordion>
-                <n-collapse-item v-for="node in graph.nodes" :key="node.label" :name="`label:${node.label}`">
-                  <template #header><n-checkbox :value="node.label" :label="node.label" @click.stop /></template>
-                  <div class="schema-detail"><span>Properties</span><n-checkbox-group :value="selectedProperties[node.label]" @update:value="values => updateOwnerProperties(node.label, values, 'label')"><n-space><n-checkbox v-for="(_, name) in node.properties" :key="name" :value="name" :label="name" /></n-space></n-checkbox-group><n-empty v-if="!Object.keys(node.properties || {}).length" size="small" description="无属性" /></div>
-                </n-collapse-item>
-              </n-collapse></n-checkbox-group>
-            </n-collapse-item>
-            <n-collapse-item name="relationships" :title="`Relationships(${selectedRelationships.length}/${relationshipGroups.length})`">
-              <n-checkbox-group :value="selectedRelationships" @update:value="updateRelationships"><n-collapse accordion>
-                <n-collapse-item v-for="rel in relationshipGroups" :key="rel.type" :name="`rel:${rel.type}`">
-                  <template #header><n-checkbox :value="rel.type" :label="rel.type" @click.stop /></template>
-                  <div class="schema-detail"><p>{{ rel.startLabels?.join(' | ') }} → {{ rel.endLabels?.join(' | ') }}</p><span>Properties</span><n-checkbox-group :value="selectedProperties[rel.type]" @update:value="values => updateOwnerProperties(rel.type, values, 'relationship')"><n-space><n-checkbox v-for="(_, name) in rel.properties" :key="name" :value="name" :label="name" /></n-space></n-checkbox-group><n-empty v-if="!Object.keys(rel.properties || {}).length" size="small" description="无属性" /></div>
-                </n-collapse-item>
-              </n-collapse></n-checkbox-group>
-            </n-collapse-item>
-          </n-collapse>
-        </template>
-        <template v-else><n-checkbox-group v-model:value="selectedTables"><n-collapse :default-expanded-names="['tables']"><n-collapse-item name="tables" :title="`Tables(${selectedTables.length}/${sql.tables.length})`"><n-collapse accordion><n-collapse-item v-for="table in sql.tables" :key="table.name" :name="`table:${table.name}`"><template #header><n-checkbox :value="table.name" :label="table.name" @click.stop /></template><div class="schema-detail"><p>{{ table.description || '无表说明' }}</p><n-space><n-tag v-for="column in table.columns" :key="column.name" size="small">{{ column.name }} · {{ column.type }}</n-tag></n-space></div></n-collapse-item></n-collapse></n-collapse-item></n-collapse></n-checkbox-group></template>
+        <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;优先使用明确关系而非笛卡尔积" />
         <n-button type="primary" block class="apply" :loading="loading.apply" @click="applyConfig">应用审核后的授权配置</n-button>
       </n-card>

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

@@ -1,11 +1,13 @@
 <script setup>
-import { computed, ref } from 'vue'
+import { computed, onMounted, ref, watch } from 'vue'
 import { PaperPlaneOutline } from '@vicons/ionicons5'
 import { NButton, NIcon, NInput, useMessage } from 'naive-ui'
 import RagSourcePanel from '../../components/rag/RagSourcePanel.vue'
 import RagChatPanel from '../../components/rag/RagChatPanel.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 input = ref('东海海上目标救援任务中,可用救援力量和任务阶段分别是什么?')
@@ -16,6 +18,14 @@ const topK = ref(5)
 const strategy = ref('hybrid')
 const debug = ref(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 structuredEvidence = ref([])
 const graphEvidence = ref([])
@@ -31,6 +41,65 @@ const createSteps = () => [
 const steps = ref(createSteps())
 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) {
   if (key === 'document') return `已召回 ${data?.evidences?.length || 0} 个文档片段`
   if (key === 'structured') return `已获取 ${data?.evidences?.[0]?.payload?.rowCount || 0} 条查询结果`
@@ -52,6 +121,8 @@ async function submit() {
   const query = input.value.trim()
   if (!query || busy.value) return
   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
   question.value = query
   answer.value = ''
@@ -61,7 +132,15 @@ async function submit() {
     if (!enabled.value[step.key]) { step.description = '当前数据源未启用'; step.time = now() }
   }
   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 || []
     structuredEvidence.value = result.structured?.evidences || []
     graphEvidence.value = result.graph?.evidences || []
@@ -89,7 +168,7 @@ async function submit() {
   <div class="rag-page">
     <div class="rag-heading"><div class="breadcrumb">知识库管理 <span>/</span> RAG</div><h1>多源 RAG 问答工作台</h1><p>问题输入 → 多源检索 → 证据召回 → 融合生成</p></div>
     <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" />
       <RagEvidencePanel :document-evidence="documentEvidence" :structured-evidence="structuredEvidence" :graph-evidence="graphEvidence" :enabled-sources="enabledSources" :diagnostics="diagnostics" :debug="debug" />
     </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 ,开展修改。

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff