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

feat: separate governance Q&A and add zoomable ontology canvas

caozheng 2 тижнів тому
батько
коміт
a926d2cc68

+ 7 - 5
README.md

@@ -28,7 +28,7 @@ pip install -e ".[dev]"
 ontorefactor-governance
 ```
 
-打开 [http://127.0.0.1:8010](http://127.0.0.1:8010)。点击“创建空白挑战杯 Demo”只会创建空项目,不会写入项目本体、关系、证据、规则或结论;随后到“智能体工作台”上传演示文件。交互式 API 文档位于 [http://127.0.0.1:8010/docs](http://127.0.0.1:8010/docs)。
+打开 [http://127.0.0.1:8010](http://127.0.0.1:8010)。点击“创建空白挑战杯 Demo”只会创建空项目,不会写入项目本体、关系、证据、规则或结论;随后到“本体构建”上传演示文件。交互式 API 文档位于 [http://127.0.0.1:8010/docs](http://127.0.0.1:8010/docs)。
 
 默认数据文件是当前目录下的 `ontorefactor.db`。可通过环境变量修改:
 
@@ -62,7 +62,7 @@ LONGCAT_MODEL=LongCat-2.0
 LONGCAT_THINKING=disabled
 ```
 
-重新启动平台后,进入“智能体工作台”。页面显示“LongCat 大模型已配置”时,可以先点击“测试模型连接”,然后选择以下语义方式:
+重新启动平台后,进入“本体构建”。页面显示 LongCat-2.0 已配置时,可以先点击“测试模型连接”,然后选择以下语义方式:
 
 - `自动选择`:有密钥时使用 LongCat,没有密钥时使用确定性语义规则。
 - `强制 LongCat-2.0`:模型不可用时本次运行失败,不会伪装成 AI 结果。
@@ -72,14 +72,15 @@ LongCat 官方文档:[LongCat API 开放平台](https://longcat.chat/platform/
 
 ## 如何使用
 
-挑战杯现场演示可直接打开 <http://127.0.0.1:8010/?demo=1&tab=agent>。下载并一次选择工作台中的四份输入,运行三个构建智能体,检查 Diff 后发布本体;第四个治理决策智能体会在发布后执行。完整讲稿见 [`docs/demo-runbook.md`](docs/demo-runbook.md)。
+挑战杯现场演示可直接打开 <http://127.0.0.1:8010/?demo=1&tab=agent>。下载并一次选择页面中的四份输入,运行三个构建智能体,检查 Diff 后发布本体;然后切换到“治理问答”,用自然语言单独调用第四个治理决策智能体。完整讲稿见 [`docs/demo-runbook.md`](docs/demo-runbook.md)。
 
 - 治理总览:观察模型数量、开放问题、待审断言和 M3–M0 分布。
 - 本体模型:检索业务、软件、数据和治理元素,点击对象执行关系影响分析。
 - 语义断言:接受或驳回智能体推断出的候选关系。
 - 证据中心:追溯断言对应的 DDL、OpenAPI、运行观测或人工声明。
 - 治理问题:运行规则并把高风险问题推进到关闭状态。
-- 智能体工作台:多文件上传、候选本体构建、Diff 审核、本体版本发布和发布后治理决策。
+- 本体构建:前三个智能体协作完成多文件解析、候选本体构建、Diff 审核和版本发布。
+- 治理问答:第四个智能体独立接收自然语言问题,只读已发布本体和证据并执行确定性门禁。
 - JSON-LD:在“本体模型”中导出当前项目,供图数据库、语义工具或其他平台使用。
 
 文件上传支持 `.sql`、`.json`、`.yaml` 和 `.yml`,每批 1–12 份、单文件最大 512KB、总量最大 3MB。工作台提供四份演示输入:DDL、OpenAPI、资产清单、治理证据包。构建成功后,原文件作为 `SourceArtifact` 保存,候选元素与待审断言分别落库,并在 `ontology_builds` 中记录输入哈希、Diff、校验、模型指标和执行轨迹。治理规则只在发布时启用。
@@ -92,6 +93,7 @@ POST /api/v1/governance/projects/{project_id}/agent-runs/upload
 POST /api/v1/governance/projects/{project_id}/ontology-builds/upload
 GET  /api/v1/governance/projects/{project_id}/ontology-builds
 POST /api/v1/governance/projects/{project_id}/ontology-builds/{build_id}/publish
+POST /api/v1/governance/projects/{project_id}/governance-agent/ask
 GET  /api/v1/governance/projects/{project_id}/ontology-versions
 ```
 
@@ -112,7 +114,7 @@ curl.exe -X POST "http://127.0.0.1:8010/api/v1/governance/projects/<project_id>/
 
 ## 动态决策闭环
 
-项目不预置项目本体、关系、治理证据、规则或结论。前三个智能体从用户文件构建候选本体,人工确认后发布为不可变版本。发布后,治理决策智能体再从数据库加载已发布本体、启用规则、全部当前证据和语义断言,形成脱敏、长度受控的上下文并执行门禁;每次决策保存上下文哈希、证据 ID、断言 ID 和结果快照。
+项目不预置项目本体、关系、治理证据、规则或结论。前三个智能体从用户文件构建候选本体,人工确认后发布为不可变版本;该流程不会自动执行治理决策。用户随后在“治理问答”中提问,第四个智能体才从数据库加载已发布本体、启用规则、全部当前证据和语义断言,形成脱敏、长度受控的上下文并执行门禁;每次问答保存问题、上下文哈希、证据 ID、断言 ID 和结果快照。
 
 字段退役门禁检查五项事实:代码/APM 消费者是否归零、迁移质量是否达标、Owner 与安全审批是否完成、旧契约兼容周期是否完成、加密替代字段是否就绪。结果只有三种:
 

+ 3 - 3
docs/demo-runbook.md

@@ -32,7 +32,7 @@
 | 1:20–2:15 | 选择 LongCat,点击“运行 3 个构建智能体” | “资产构建负责确定性解析;语义融合负责业务对象、Owner、分类与关系;本体校验负责端点、谓词、URI 和 Diff。” | 三阶段完成、候选元素/关系/规则数量 |
 | 2:15–3:00 | 展开候选元素和 Diff | “此时只是 candidate/pending,还没有进入正式本体。新增和已匹配数量都由当前版本动态计算。” | 本体 Diff、候选元素预览 |
 | 3:00–3:40 | 保持“同时接受模型断言”未勾选,点击“审核并发布” | “结构事实经人工确认后发布为 v1;模型推断默认仍待复核,不会伪装成人工事实。” | v1、已接受关系、待审语义断言 |
-| 3:40–4:45 | 查看发布后的决策卡 | “第四个治理决策智能体现在才从数据库查询已发布本体、规则与全部当前证据。确定性门禁得到 `BLOCKED`。” | 1 项通过、4 项失败及建议 |
+| 3:40–4:45 | 切换到“治理问答”,输入“dcp_user.mobile 是否可以迁移并删除?” | “第四个治理决策智能体被单独调用,从数据库查询已发布本体、规则与全部当前证据。确定性门禁得到 `BLOCKED`。” | 问答式结论、1 项通过、4 项失败及建议 |
 | 4:45–5:35 | 打开本体模型并做字段影响分析 | “从字段可以追到 API、任务、Owner、安全策略和质量规则,关系都有来源与审核状态。” | 三跳影响子图 |
 | 5:35–6:20 | 打开语义断言、证据中心 | “原文件、模型推断、置信度、审核状态、证据 ID 和生成者都可追溯。” | pending/accepted、证据原文 |
 | 6:20–7:00 | 返回总览或导出 JSON-LD | “本体构建是治理工作的一部分,不是前置假设;AI 提候选,人工发布,确定性规则守住生产风险。” | 已发布版本、`BLOCKED`、JSON-LD |
@@ -44,7 +44,7 @@
 1. 资产构建智能体:确定性解析和跨文件归并;
 2. 语义融合智能体:LongCat 或确定性语义建议;
 3. 本体校验智能体:白名单、引用完整性和版本 Diff;
-4. 治理决策智能体:只在发布后查询数据库并执行确定性门禁。
+4. 治理决策智能体:在独立“治理问答”页面接受问题,只读已发布本体并执行确定性门禁。
 
 如果配置了 LambdAgentPaaS,四个职责的脱敏摘要会写入 PaaS 运行记录;核心数据和事务仍属于独立治理平台。
 
@@ -60,7 +60,7 @@
 
 ### 为什么先生成语义断言,再给治理意见?
 
-现在正是这个顺序:构建阶段先形成候选本体和语义断言,用户审核并发布结构版本,治理决策智能体随后才读取这些结果和当前证据。模型断言可以继续逐条复核,不会因发布结构事实自动变成已确认事实。
+现在正是这个顺序:构建阶段先形成候选本体和语义断言,用户审核并发布结构版本;发布操作不会自动执行治理决策。用户进入“治理问答”提出问题后,第四个智能体才读取这些结果和当前证据。模型断言可以继续逐条复核,不会因发布结构事实自动变成已确认事实。
 
 ### LongCat 不可用怎么办?
 

+ 15 - 0
src/ontorefactor_governance/api.py

@@ -21,6 +21,7 @@ from .builds import (
 )
 from .db import get_database
 from .decision import evaluate_retirement_gate, list_decisions
+from .governance_qa import ask_governance_agent
 from .llm import public_llm_status, test_llm_connection
 from .paas import paas_status
 from .source_upload import MAX_SOURCE_BYTES, prepare_uploaded_source
@@ -148,6 +149,12 @@ class RetirementDecisionInput(BaseModel):
     persist: bool = True
 
 
+class GovernanceQuestionInput(BaseModel):
+    question: str = Field(min_length=1, max_length=2000)
+    include_pending_assertions: bool = True
+    persist: bool = True
+
+
 class OntologyBuildSourceInput(BaseModel):
     source_type: Literal["ddl", "openapi", "inventory"]
     source_name: str = Field(default="", max_length=256)
@@ -301,6 +308,14 @@ def decisions(project_id: str, limit: int = Query(default=100, ge=1, le=200), te
     return {"decisions": list_decisions(get_database(), tenant, project_id, limit)}
 
 
+@app.post("/api/v1/governance/projects/{project_id}/governance-agent/ask")
+def ask_governance_question(project_id: str, body: GovernanceQuestionInput, tenant: str = Depends(scope)):
+    return call(
+        ask_governance_agent, get_database(), tenant, project_id, body.question,
+        include_pending=body.include_pending_assertions, persist=body.persist,
+    )
+
+
 @app.get("/api/v1/governance/projects/{project_id}/issues")
 def issues(project_id: str, status: str = "open", tenant: str = Depends(scope)):
     return {"issues": call(service.list_issues, get_database(), tenant, project_id, status)}

+ 3 - 1
src/ontorefactor_governance/builds.py

@@ -655,7 +655,9 @@ def publish_ontology_build(
          _dump(element_ids), _dump(accepted_ids), _dump(snapshot), reviewer.strip(), now),
     )
 
-    target = decision_target.strip() or str(build.get("decision_target") or "").strip()
+    # Governance is an independent fourth-agent workflow. Publishing only runs
+    # it when an API caller explicitly supplies a target for compatibility.
+    target = decision_target.strip()
     decision, decision_trace, paas_run = None, [], None
     if target:
         decision_context = Context(run_id=f"{build['run_id']}:decision")

+ 154 - 0
src/ontorefactor_governance/governance_qa.py

@@ -0,0 +1,154 @@
+"""Question-oriented facade for the standalone governance decision agent."""
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+from dataclasses import asdict
+
+from lambdagent import Context, Tool
+
+from .db import gen_id, now_utc
+from .decision import collect_project_context, evaluate_retirement_gate, resolve_retirement_target
+from .paas import record_agent_run
+from .service import GovernanceError, require_project
+
+
+def _dump(value) -> str:
+    return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
+
+
+def _question_target(db, tenant_id: str, project_id: str, question: str) -> dict | None:
+    """Resolve a field mentioned inside a natural-language question.
+
+    Structured asset references are preferred. A compact containment fallback
+    lets questions such as “dcp_user.mobile 是否可以删除” match the URI built
+    from the uploaded DDL without asking an LLM to decide the target.
+    """
+    candidates = re.findall(
+        r"[A-Za-z_][A-Za-z0-9_-]*(?:[./:#][A-Za-z_][A-Za-z0-9_-]*)+",
+        question,
+    )
+    for candidate in sorted(candidates, key=len, reverse=True):
+        target = resolve_retirement_target(db, tenant_id, project_id, candidate)
+        if target:
+            return target
+
+    compact_question = re.sub(r"[^a-z0-9]+", "", question.lower())
+    values = db.fetchall(
+        "SELECT * FROM ontology_elements WHERE tenant_id=? AND project_id=? "
+        "AND kind IN ('Column','PhysicalColumn') ORDER BY CASE layer WHEN 'M1' THEN 0 ELSE 1 END,name",
+        (tenant_id, project_id),
+    )
+    matches = []
+    for item in values:
+        uri = str(item.get("uri") or "").lower()
+        name = str(item.get("name") or "").lower()
+        aliases = {
+            re.sub(r"[^a-z0-9]+", "", name),
+            re.sub(r"[^a-z0-9]+", "", uri),
+            re.sub(r"[^a-z0-9]+", "", uri.rsplit("/", 1)[-1]),
+        }
+        aliases = {alias for alias in aliases if len(alias) >= 4}
+        score = max((len(alias) for alias in aliases if alias in compact_question), default=0)
+        if score:
+            matches.append((-score, 0 if item.get("layer") == "M1" else 1, item))
+    if not matches:
+        return None
+    target = sorted(matches, key=lambda value: (value[0], value[1], value[2]["name"]))[0][2]
+    target["attributes"] = json.loads(target.get("attributes") or "{}")
+    return target
+
+
+def _answer(decision: dict) -> str:
+    target = decision.get("target") or {}
+    name = target.get("name") or "该治理对象"
+    failed = [item["label"] for item in decision.get("checks") or [] if item.get("state") == "failed"]
+    unknown = [item["label"] for item in decision.get("checks") or [] if item.get("state") == "unknown"]
+    if decision["status"] == "ALLOW":
+        return f"可以。{name} 已通过全部确定性治理门禁,可进入受控变更审批与执行窗口。"
+    if decision["status"] == "BLOCKED":
+        return f"目前不可以。{name} 被以下治理门禁阻止:{'、'.join(failed)}。"
+    if not target.get("id"):
+        return "暂时无法判断,因为问题中没有解析到当前项目里的字段。请在问题中带上 schema.table.column 或字段 URI。"
+    return f"证据不足,暂时不能批准 {name} 的变更。还需要补齐:{'、'.join(unknown)}。"
+
+
+def ask_governance_agent(
+    db,
+    tenant_id: str,
+    project_id: str,
+    question: str,
+    *,
+    include_pending: bool = True,
+    persist: bool = True,
+) -> dict:
+    """Run the fourth agent independently against the latest published ontology."""
+    require_project(db, tenant_id, project_id)
+    if not db.fetchone(
+        "SELECT id FROM ontology_versions WHERE tenant_id=? AND project_id=? ORDER BY version_number DESC LIMIT 1",
+        (tenant_id, project_id),
+    ):
+        raise GovernanceError(
+            "请先使用前三个构建智能体生成并发布本体,再向治理决策智能体提问。",
+            code="ONTOLOGY_NOT_PUBLISHED",
+            status=409,
+        )
+    question = question.strip()
+    if not question:
+        raise GovernanceError("治理问题不能为空", code="EMPTY_GOVERNANCE_QUESTION")
+
+    run_id, created_at = gen_id("gar_"), now_utc()
+    target = _question_target(db, tenant_id, project_id, question)
+    target_hint = target["id"] if target else question
+    project_context = collect_project_context(db, tenant_id, project_id, target_hint=target_hint)
+    context_hash = hashlib.sha256(_dump(project_context).encode("utf-8")).hexdigest()
+    input_hash = hashlib.sha256(question.encode("utf-8")).hexdigest()
+    db.execute(
+        "INSERT INTO governance_agent_runs "
+        "(id,tenant_id,project_id,source_type,input_hash,status,semantic_mode,context_hash,context_scope,created_at) "
+        "VALUES (?,?,?,?,?,'running','deterministic',?,?,?)",
+        (run_id, tenant_id, project_id, "governance-question", input_hash, context_hash,
+         _dump(project_context.get("scope") or {}), created_at),
+    )
+    db.commit()
+    context = Context(run_id=run_id)
+    try:
+        decision = Tool(
+            "governance-decision-agent",
+            lambda value: evaluate_retirement_gate(
+                db, tenant_id, project_id, value["target_hint"], persist=persist,
+                run_id=run_id, include_pending=include_pending,
+            ),
+        ).apply({"target_hint": target_hint}, context)
+        response = {
+            "run_id": run_id,
+            "agent": "governance-decision-agent",
+            "question": question,
+            "intent": "FIELD_RETIREMENT",
+            "answer": _answer(decision),
+            "resolved_target": decision.get("target") or {},
+            "decision": decision,
+            "context_scope": project_context.get("scope") or {},
+            "trace": [asdict(item) for item in context.trace],
+        }
+        paas_run = record_agent_run(
+            "governance-decision-agent",
+            input_summary={"project_id": project_id, "question": question, "target": target_hint},
+            output_summary={"status": decision["status"], "summary": decision["summary"]},
+            duration_ms=int(sum(float(item.duration_ms or 0) for item in context.trace)),
+        )
+        response["paas_run"] = paas_run
+        db.execute(
+            "UPDATE governance_agent_runs SET status='completed',decision_id=?,result_json=?,trace_json=?,completed_at=? WHERE id=?",
+            (decision.get("id") or "", _dump(response), _dump(response["trace"]), now_utc(), run_id),
+        )
+        db.commit()
+        return response
+    except Exception as exc:
+        db.execute(
+            "UPDATE governance_agent_runs SET status='failed',error=?,completed_at=? WHERE id=?",
+            (str(exc)[:2000], now_utc(), run_id),
+        )
+        db.commit()
+        raise

+ 163 - 31
src/ontorefactor_governance/static/app.js

@@ -118,6 +118,7 @@ async function validate() {
 }
 
 function activateTab() {
+  document.body.classList.toggle("workspace-mode", state.activeTab !== "overview");
   $$(".tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.tab === state.activeTab));
   renderActive().catch((error) => {
     $("#content").innerHTML = `<div class="panel inline-empty">加载失败:${esc(error.message)}</div>`;
@@ -127,7 +128,8 @@ function activateTab() {
 
 async function renderActive() {
   if (!state.projectId) return;
-  const renderers = {overview: renderOverview, model: renderModel, assertions: renderAssertions, evidence: renderEvidence, issues: renderIssues, agent: renderOntologyBuilder};
+  document.body.classList.toggle("workspace-mode", state.activeTab !== "overview");
+  const renderers = {overview: renderOverview, model: renderModel, assertions: renderAssertions, evidence: renderEvidence, issues: renderIssues, agent: renderOntologyBuilder, decision: renderGovernanceAssistant};
   $("#content").innerHTML = '<div class="panel inline-empty">正在加载治理数据…</div>';
   await renderers[state.activeTab]();
 }
@@ -319,38 +321,110 @@ async function renderModel() {
   const graph = await request(`/projects/${state.projectId}/graph`);
   const elements = graph.nodes || [];
   $("#content").innerHTML = `
-    <div class="model-intro panel"><div><span>ONTOLOGY EXPLORER</span><h2>M3–M0 分层本体图</h2><p>M3 是系统稳定语法;M2 从标准类型库按上传文件涉及的类型装配;M1/M0 来自文件抽取与智能体语义融合。</p></div><div class="view-switch"><button class="button small primary" data-model-view="graph">图谱视图</button><button class="button small ghost" data-model-view="table">表格视图</button></div></div>
-    <div class="toolbar model-toolbar"><select id="layer-filter"><option value="">全部层级</option>${["M3","M2","M1","M0"].map(x => `<option>${x}</option>`).join("")}</select><select id="profile-filter"><option value="">全部 Profile</option>${[...new Set(elements.map(x => x.profile))].filter(Boolean).map(x => `<option value="${esc(x)}">${esc(modelProfileLabel(x))}</option>`).join("")}</select><select id="node-status-filter"><option value="">全部节点状态</option><option>candidate</option><option>published</option></select><select id="edge-status-filter"><option value="">全部关系状态</option><option>pending</option><option>accepted</option></select><input id="model-search" placeholder="搜索名称、类型或 URI"><span class="spacer"></span><button id="download-jsonld" class="button ghost">导出 JSON-LD</button></div>
+    <div class="model-intro panel"><div><span>ONTOLOGY EXPLORER</span><h2>M3–M0 可缩放分层图</h2><p>恢复大尺寸节点;可使用工具栏或鼠标滚轮缩放,按住画布拖动平移,并随时重置到 100% 或适配全图。</p></div><div class="view-switch"><button class="button small primary" data-model-view="graph">图谱视图</button><button class="button small ghost" data-model-view="table">表格视图</button></div></div>
+    <div class="toolbar model-toolbar"><select id="relation-scope"><option value="typing">类型主链(推荐)</option><option value="semantic">业务语义关系</option><option value="all">全部关系</option></select><select id="layer-filter"><option value="">全部层级</option>${["M3","M2","M1","M0"].map(x => `<option>${x}</option>`).join("")}</select><select id="profile-filter"><option value="">全部 Profile</option>${[...new Set(elements.map(x => x.profile))].filter(Boolean).map(x => `<option value="${esc(x)}">${esc(modelProfileLabel(x))}</option>`).join("")}</select><select id="node-status-filter"><option value="">全部节点状态</option><option>candidate</option><option>published</option></select><select id="edge-status-filter"><option value="">全部关系状态</option><option>pending</option><option>accepted</option></select><input id="model-search" placeholder="搜索节点"><span class="spacer"></span><button id="download-jsonld" class="button ghost">导出 JSON-LD</button></div>
     <div class="ontology-legend panel"><div><b>Profile</b><span class="legend-dot business"></span>业务<span class="legend-dot software"></span>软件<span class="legend-dot governance"></span>治理<span class="legend-dot meta"></span>元模型</div><div><b>节点</b><span class="legend-node candidate"></span>candidate 虚线<span class="legend-node published"></span>published 实线</div><div><b>关系</b><span class="legend-edge pending"></span>pending 虚线<span class="legend-edge accepted"></span>accepted 实线</div></div>
-    <section id="graph-view" class="ontology-graph-shell panel"><div id="ontology-graph"></div></section>
+    <section id="graph-view" class="ontology-graph-shell panel"><div class="graph-canvas-toolbar"><div><button class="button small ghost" data-graph-zoom="out" title="缩小">−</button><button class="button small ghost" data-graph-zoom="in" title="放大">+</button><button class="button small ghost" data-graph-zoom="reset">100%</button><button class="button small ghost" data-graph-zoom="fit">适配全图</button></div><span id="graph-zoom-label">100%</span><small>滚轮缩放 · 按住画布拖动</small></div><div id="ontology-viewport" class="ontology-viewport"><div id="ontology-graph"></div></div></section>
     <section id="table-view" class="hidden"><div class="table-wrap"><table><thead><tr><th>层级</th><th>名称</th><th>类型</th><th>Profile</th><th>状态</th><th>来源</th><th>关系</th></tr></thead><tbody id="element-rows"></tbody></table></div></section>
     <p id="model-stat" class="muted model-stat"></p>`;
+  const graphViewState = {scale: 1, x: 18, y: 18, dragging: false, moved: false, startX: 0, startY: 0, originX: 0, originY: 0};
+  const applyGraphTransform = () => {
+    const canvas = $("#ontology-graph");
+    if (!canvas) return;
+    canvas.style.transform = `translate(${graphViewState.x}px, ${graphViewState.y}px) scale(${graphViewState.scale})`;
+    $("#graph-zoom-label").textContent = `${Math.round(graphViewState.scale * 100)}%`;
+  };
+  const setGraphScale = (nextScale, clientX, clientY) => {
+    const viewport = $("#ontology-viewport");
+    const rect = viewport.getBoundingClientRect();
+    const x = clientX == null ? rect.width / 2 : clientX - rect.left;
+    const y = clientY == null ? rect.height / 2 : clientY - rect.top;
+    const previous = graphViewState.scale;
+    graphViewState.scale = Math.min(2.5, Math.max(.3, nextScale));
+    graphViewState.x = x - ((x - graphViewState.x) / previous) * graphViewState.scale;
+    graphViewState.y = y - ((y - graphViewState.y) / previous) * graphViewState.scale;
+    applyGraphTransform();
+  };
+  const resetGraphView = () => {
+    Object.assign(graphViewState, {scale:1, x:18, y:18});
+    applyGraphTransform();
+  };
+  const fitGraphView = () => {
+    const viewport = $("#ontology-viewport"), svg = $("#ontology-graph svg");
+    if (!viewport || !svg) return;
+    const view = svg.viewBox.baseVal;
+    graphViewState.scale = Math.min(1, Math.max(.3, Math.min((viewport.clientWidth - 32) / view.width, (viewport.clientHeight - 32) / view.height)));
+    graphViewState.x = (viewport.clientWidth - view.width * graphViewState.scale) / 2;
+    graphViewState.y = Math.max(16, (viewport.clientHeight - view.height * graphViewState.scale) / 2);
+    applyGraphTransform();
+  };
   const draw = () => {
     const layer = $("#layer-filter").value;
     const profile = $("#profile-filter").value;
     const nodeStatus = $("#node-status-filter").value;
     const edgeStatus = $("#edge-status-filter").value;
+    const relationScope = $("#relation-scope").value;
     const query = $("#model-search").value.trim().toLowerCase();
     const filtered = elements.filter(node => (!layer || node.layer === layer) && (!profile || node.profile === profile) && (!nodeStatus || node.status === nodeStatus) && (!query || `${node.name} ${node.kind} ${node.uri}`.toLowerCase().includes(query)));
     const visibleIds = new Set(filtered.map(node => node.id));
-    const visibleEdges = (graph.edges || []).filter(edge => visibleIds.has(edge.subject_id) && visibleIds.has(edge.object_id) && (!edgeStatus || edge.review_status === edgeStatus));
+    const visibleEdges = (graph.edges || []).filter(edge => visibleIds.has(edge.subject_id) && visibleIds.has(edge.object_id) && (!edgeStatus || edge.review_status === edgeStatus) && (relationScope === "all" || (relationScope === "typing" ? edge.predicate === "INSTANCE_OF" : edge.predicate !== "INSTANCE_OF")));
     $("#ontology-graph").innerHTML = ontologyGraphMarkup(filtered, visibleEdges);
     $("#element-rows").innerHTML = filtered.map(node => `<tr><td>${badge(node.layer)}</td><td><button class="link-button" data-model-node="${esc(node.id)}">${esc(node.name)}</button><br><small class="code muted">${esc(node.uri)}</small></td><td>${esc(node.kind)}</td><td>${esc(modelProfileLabel(node.profile))}</td><td>${badge(node.status)}</td><td>${esc(node.source || "—")}</td><td>${(graph.edges || []).filter(edge => edge.subject_id === node.id || edge.object_id === node.id).length}</td></tr>`).join("") || '<tr><td colspan="7" class="inline-empty">没有匹配的本体节点</td></tr>';
     $("#model-stat").textContent = `当前显示 ${filtered.length} / ${elements.length} 个节点,${visibleEdges.length} / ${(graph.edges || []).length} 条关系。点击任一节点查看属性、来源、证据、置信度与审核状态。`;
     $$('[data-model-node]').forEach(element => {
-      element.addEventListener("click", () => showOntologyNode(element.dataset.modelNode, graph));
+      element.addEventListener("click", () => { if (!graphViewState.moved) showOntologyNode(element.dataset.modelNode, graph); });
       element.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === " ") showOntologyNode(element.dataset.modelNode, graph); });
     });
   };
-  ["#layer-filter", "#profile-filter", "#node-status-filter", "#edge-status-filter", "#model-search"].forEach(selector => $(selector).addEventListener("input", draw));
+  ["#relation-scope", "#layer-filter", "#profile-filter", "#node-status-filter", "#edge-status-filter", "#model-search"].forEach(selector => $(selector).addEventListener("input", draw));
   $$('[data-model-view]').forEach(button => button.addEventListener("click", () => {
     const graphView = button.dataset.modelView === "graph";
     $("#graph-view").classList.toggle("hidden", !graphView);
     $("#table-view").classList.toggle("hidden", graphView);
     $$('[data-model-view]').forEach(item => item.className = `button small ${item === button ? "primary" : "ghost"}`);
   }));
+  $$('[data-graph-zoom]').forEach(button => button.addEventListener("click", () => {
+    const action = button.dataset.graphZoom;
+    if (action === "in") setGraphScale(graphViewState.scale * 1.2);
+    if (action === "out") setGraphScale(graphViewState.scale / 1.2);
+    if (action === "reset") resetGraphView();
+    if (action === "fit") fitGraphView();
+  }));
+  const viewport = $("#ontology-viewport");
+  viewport.addEventListener("wheel", event => {
+    event.preventDefault();
+    setGraphScale(graphViewState.scale * (event.deltaY < 0 ? 1.12 : 1 / 1.12), event.clientX, event.clientY);
+  }, {passive:false});
+  viewport.addEventListener("pointerdown", event => {
+    graphViewState.dragging = true;
+    graphViewState.moved = false;
+    graphViewState.startX = event.clientX;
+    graphViewState.startY = event.clientY;
+    graphViewState.originX = graphViewState.x;
+    graphViewState.originY = graphViewState.y;
+    viewport.setPointerCapture(event.pointerId);
+    viewport.classList.add("dragging");
+  });
+  viewport.addEventListener("pointermove", event => {
+    if (!graphViewState.dragging) return;
+    const dx = event.clientX - graphViewState.startX, dy = event.clientY - graphViewState.startY;
+    if (Math.abs(dx) + Math.abs(dy) > 4) graphViewState.moved = true;
+    graphViewState.x = graphViewState.originX + dx;
+    graphViewState.y = graphViewState.originY + dy;
+    applyGraphTransform();
+  });
+  const endDrag = event => {
+    if (!graphViewState.dragging) return;
+    graphViewState.dragging = false;
+    viewport.classList.remove("dragging");
+    if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId);
+    setTimeout(() => { graphViewState.moved = false; }, 0);
+  };
+  viewport.addEventListener("pointerup", endDrag);
+  viewport.addEventListener("pointercancel", endDrag);
   $("#download-jsonld").addEventListener("click", downloadJsonLd);
   draw();
+  resetGraphView();
 }
 
 async function showImpact(id) {
@@ -429,6 +503,72 @@ async function resolveIssue(id) {
   } catch (error) { toast(error.message, true); }
 }
 
+function governanceAnswerMarkup(result) {
+  if (!result?.decision) return "";
+  const decision = result.decision;
+  const summary = decision.summary || {};
+  const scope = result.context_scope || {};
+  const statusLabel = {ALLOW:"允许进入受控变更", BLOCKED:"当前不允许变更", INSUFFICIENT_EVIDENCE:"证据不足"}[decision.status] || decision.status;
+  return `<section class="qa-answer ${esc(decision.status.toLowerCase())}">
+    <div class="qa-answer-head"><span class="qa-avatar">04</span><div><small>治理决策智能体</small><h3>${esc(result.answer || decision.recommendation)}</h3><p>识别对象:${esc(decision.target?.name || "未识别")} · ${esc(decision.target?.uri || "请补充资产标识")}</p></div><strong>${esc(statusLabel)}</strong></div>
+    <div class="qa-summary"><article><b>${Number(summary.passed || 0)}</b><span>通过</span></article><article><b>${Number(summary.failed || 0)}</b><span>阻塞</span></article><article><b>${Number(summary.unknown || 0)}</b><span>未知</span></article><article><b>${Number(decision.evidence_ids?.length || 0)}</b><span>引用证据</span></article></div>
+    <div class="qa-checks">${(decision.checks || []).map(item => `<article class="${esc(item.state)}"><span>${item.state === "passed" ? "✓" : item.state === "failed" ? "×" : "?"}</span><div><strong>${esc(item.label)}</strong><small>${esc(item.message)}</small></div>${badge(item.state)}</article>`).join("")}</div>
+    <div class="qa-provenance"><span>本次读取 ${Number(scope.included_assets || 0)} 个本体资产、${Number(scope.included_relations || 0)} 条关系、${Number(scope.included_evidence || 0)} 份证据</span><span>结论由确定性门禁产生 · 运行 ${esc(result.run_id || decision.run_id || "—")}</span></div>
+  </section>`;
+}
+
+async function renderGovernanceAssistant() {
+  const [versionResult, runResult] = await Promise.all([
+    request(`/projects/${state.projectId}/ontology-versions`),
+    request(`/projects/${state.projectId}/agent-runs`),
+  ]);
+  const versions = versionResult.versions || [];
+  const qaRuns = (runResult.runs || []).filter(run => run.source_type === "governance-question");
+  const latest = qaRuns.find(run => run.status === "completed")?.result_json || null;
+  const ready = Boolean(versions.length);
+  const example = isDemoProject() ? "dcp_user.mobile 是否可以迁移并删除?" : "customer.mobile 字段现在可以下线吗?";
+  $("#content").innerHTML = `<div class="governance-qa-layout">
+    <section class="qa-workspace panel">
+      <div class="qa-hero"><div><span>AGENT 04 · GOVERNANCE DECISION</span><h2>向治理决策智能体提问</h2><p>第四个智能体独立读取已发布本体、语义断言、质量报告和审批证据,再调用确定性门禁回答;不会修改本体。</p></div><div class="qa-ready ${ready ? "ready" : "waiting"}"><i></i><strong>${ready ? `本体 ${esc(versions[0].label)} 已就绪` : "等待本体发布"}</strong><small>${ready ? `${date(versions[0].created_at)} · 可开始治理问答` : "请先到“本体构建”运行前三个智能体并发布"}</small></div></div>
+      <div class="agent-separation"><article><b>01–03</b><div><strong>本体构建智能体组</strong><small>文件 → 候选本体 → 校验 → 人工发布</small></div><span>只写本体</span></article><i>→</i><article class="active"><b>04</b><div><strong>治理决策智能体</strong><small>问题 → 证据检索 → 确定性门禁 → 回答</small></div><span>只读本体</span></article></div>
+      <div class="qa-conversation">
+        <article class="qa-message user"><span>你</span><div><small>示例问题</small><p>${esc(example)}</p></div></article>
+        ${latest ? governanceAnswerMarkup(latest) : '<article class="qa-message agent"><span>04</span><div><small>治理决策智能体</small><p>请告诉我你想治理的字段以及动作。例如:“dcp_user.mobile 是否可以迁移并删除?”</p></div></article>'}
+      </div>
+      <form id="governance-question-form" class="qa-form">
+        <textarea id="governance-question" ${ready ? "" : "disabled"} maxlength="2000" placeholder="请输入治理问题,建议包含 schema.table.column 等资产标识">${esc(example)}</textarea>
+        <div><label class="inline-check"><input id="question-include-pending" type="checkbox" checked> 评估时纳入待审核语义断言</label><button id="ask-governance" class="button primary" type="submit" ${ready ? "" : "disabled"}>询问第 4 个智能体</button></div>
+      </form>
+      <div class="qa-examples"><span>你还可以问:</span><button data-governance-example="dcp_user.mobile 当前被哪些治理门禁阻塞?">当前有哪些阻塞?</button><button data-governance-example="dcp_user.mobile 的迁移证据是否充分?">迁移证据充分吗?</button><button data-governance-example="dcp_user.mobile 是否已经完成必要审批?">必要审批完成了吗?</button></div>
+    </section>
+    <aside class="qa-history panel"><div class="section-head"><div><h2>问答审计记录</h2><p>问题、目标、证据范围和结论均留痕</p></div><span class="step-pill">AUDIT</span></div>${qaRuns.length ? `<div class="trace">${qaRuns.map(run => `<div class="trace-step"><strong>${esc(run.result_json?.question || "治理问题")}</strong><small>${date(run.created_at)} · ${esc(run.result_json?.decision?.status || run.status)}</small><br><button class="link-button" data-run="${esc(run.id)}">查看决策轨迹</button></div>`).join("")}</div>` : '<div class="inline-empty">尚无治理问答记录</div>'}</aside>
+  </div>`;
+  $("#governance-question-form").addEventListener("submit", askGovernanceQuestion);
+  $$('[data-governance-example]').forEach(button => button.addEventListener("click", () => {
+    $("#governance-question").value = button.dataset.governanceExample;
+    $("#governance-question").focus();
+  }));
+}
+
+async function askGovernanceQuestion(event) {
+  event.preventDefault();
+  const question = $("#governance-question").value.trim();
+  if (!question) { toast("请输入治理问题", true); return; }
+  const button = $("#ask-governance");
+  setBusy(button, true, "正在读取本体与证据…");
+  try {
+    const result = await request(`/projects/${state.projectId}/governance-agent/ask`, {method:"POST", body:JSON.stringify({
+      question,
+      include_pending_assertions:Boolean($("#question-include-pending").checked),
+      persist:true,
+    })});
+    await renderGovernanceAssistant();
+    $(".qa-conversation").innerHTML = `<article class="qa-message user"><span>你</span><div><small>本次问题</small><p>${esc(question)}</p></div></article>${governanceAnswerMarkup(result)}`;
+    toast(`治理决策完成:${result.decision.status}`);
+  } catch (error) { toast(error.message, true); }
+  finally { setBusy(button, false, "询问第 4 个智能体"); }
+}
+
 async function renderAgent() {
   const [runResult, llm] = await Promise.all([
     request(`/projects/${state.projectId}/agent-runs`),
@@ -564,14 +704,13 @@ function buildDiffMarkup(build) {
   </div>`;
 }
 
-function buildStagesMarkup(build, published = false) {
+function buildStagesMarkup() {
   const stages = [
     ["01", "资产构建智能体", "解析 DDL、OpenAPI 与清单,形成结构资产"],
     ["02", "语义融合智能体", "识别业务对象、责任、敏感等级与跨源关系"],
     ["03", "本体校验智能体", "检查标识、关系端点与谓词,并生成 Diff"],
-    ["04", "治理决策智能体", published ? "读取已发布本体和当前证据,执行确定性门禁" : "等待人工发布候选本体后执行"],
   ];
-  return `<div class="build-stages">${stages.map((item, index) => `<article class="${index < 3 || published ? "done" : "waiting"}"><b>${item[0]}</b><div><strong>${item[1]}</strong><small>${item[2]}</small></div><span>${index < 3 || published ? "" : "待发布"}</span></article>`).join("")}</div>`;
+  return `<div class="build-stages build-stages-three">${stages.map(item => `<article class="done"><b>${item[0]}</b><div><strong>${item[1]}</strong><small>${item[2]}</small></div><span>✓</span></article>`).join("")}</div>`;
 }
 
 function candidateBuildMarkup(build) {
@@ -586,24 +725,19 @@ function candidateBuildMarkup(build) {
   </section>`;
 }
 
-function publishedHistoryMarkup(build, version, demo) {
-  const decision = demo?.gate;
-  const decisionCard = decision ? `<div class="decision-result ${esc(decision.status.toLowerCase())}"><div><span>数据库当前治理门禁</span><strong>${esc(decision.status)}</strong><small>${esc(decision.target?.name || demo.asset_name || "")}</small></div><p>${esc(decision.recommendation)}</p><div class="decision-checks">${decision.checks.map(item => `<span class="${esc(item.state)}">${item.state === "passed" ? "✓" : item.state === "failed" ? "×" : "?"} ${esc(item.label)}</span>`).join("")}</div></div>` : "";
-  return `<section class="run-complete build-result-card published-build"><div class="run-complete-head"><span>✓</span><div><strong>当前本体 ${esc(version?.label || "v1")} 已发布</strong><small>批次 ${esc(build.id)} · ${Number(build.element_ids?.length || 0)} 个元素 · ${Number(build.assertion_ids?.length || 0)} 条关系</small></div></div>${buildStagesMarkup(build, true)}${buildDiffMarkup(build)}${decisionCard}<div class="button-row"><button class="button secondary" data-open-tab="model">查看正式本体</button><button class="button ghost" data-open-tab="assertions">复核语义断言</button></div></section>`;
+function publishedHistoryMarkup(build, version) {
+  return `<section class="run-complete build-result-card published-build"><div class="run-complete-head"><span>✓</span><div><strong>当前本体 ${esc(version?.label || "v1")} 已发布</strong><small>批次 ${esc(build.id)} · ${Number(build.element_ids?.length || 0)} 个元素 · ${Number(build.assertion_ids?.length || 0)} 条关系</small></div></div>${buildStagesMarkup()}${buildDiffMarkup(build)}<div class="build-next-step"><div><strong>本体构建已经完成</strong><small>治理决策不在构建阶段自动执行,请单独向第 4 个智能体提问。</small></div><button class="button secondary" data-open-tab="decision">进入治理问答</button><button class="button ghost" data-open-tab="model">查看正式本体</button></div></section>`;
 }
 
 function publishedBuildMarkup(result) {
-  const decision = result.decision;
-  const decisionCard = decision ? `<div class="decision-result ${esc(decision.status.toLowerCase())}"><div><span>动态治理门禁</span><strong>${esc(decision.status)}</strong><small>${esc(decision.target?.name || "")}</small></div><p>${esc(decision.recommendation)}</p><div class="decision-checks">${decision.checks.map(item => `<span class="${esc(item.state)}">${item.state === "passed" ? "✓" : item.state === "failed" ? "×" : "?"} ${esc(item.label)}</span>`).join("")}</div></div>` : "";
-  return `<section class="run-complete build-result-card published-build"><div class="run-complete-head"><span>✓</span><div><strong>本体 ${esc(result.version?.label || "")} 已发布</strong><small>${Number(result.published?.elements || 0)} 个元素 · ${Number(result.published?.accepted_assertions || 0)} 条已接受关系 · ${Number(result.published?.pending_assertions || 0)} 条语义断言待复核</small></div></div>${buildStagesMarkup({}, Boolean(decision))}${decisionCard}<div class="button-row"><button class="button secondary" data-open-tab="model">查看正式本体</button><button class="button ghost" data-open-tab="assertions">复核语义断言</button><button class="button ghost" data-open-tab="issues">查看治理问题</button></div></section>`;
+  return `<section class="run-complete build-result-card published-build"><div class="run-complete-head"><span>✓</span><div><strong>本体 ${esc(result.version?.label || "")} 已发布</strong><small>${Number(result.published?.elements || 0)} 个元素 · ${Number(result.published?.accepted_assertions || 0)} 条已接受关系 · ${Number(result.published?.pending_assertions || 0)} 条语义断言待复核</small></div></div>${buildStagesMarkup()}<div class="build-next-step"><div><strong>前三个智能体的工作到此结束</strong><small>现在可以让独立的治理决策智能体读取正式本体并回答问题。</small></div><button class="button secondary" data-open-tab="decision">向第 4 个智能体提问</button><button class="button ghost" data-open-tab="model">查看正式本体</button></div></section>`;
 }
 
 async function renderOntologyBuilder() {
-  const [buildResult, versionResult, llm, overviewResult] = await Promise.all([
+  const [buildResult, versionResult, llm] = await Promise.all([
     request(`/projects/${state.projectId}/ontology-builds`),
     request(`/projects/${state.projectId}/ontology-versions`),
     request("/llm/status"),
-    request(`/projects/${state.projectId}/overview`),
   ]);
   const builds = buildResult.builds || [];
   const versions = versionResult.versions || [];
@@ -613,19 +747,18 @@ async function renderOntologyBuilder() {
   const latestPublished = builds.find(item => item.status === "published");
   $("#content").innerHTML = `<div class="agent-grid ontology-builder">
     <article class="section-card panel"><div class="section-head"><div><h2>从文件构建候选本体</h2><p>项目从零开始;上传什么,就构建什么,不使用预置项目本体和关系</p></div><span class="step-pill">BUILD</span></div>
-      <div class="model-strip ${llm.configured ? "ready" : "offline"}"><div><span class="dot"></span><strong>${esc(llm.model)}</strong><small>${llm.configured ? "LongCat 负责语义融合,规则负责结构解析和最终门禁" : "未配置模型,将使用确定性语义融合"}</small></div><button id="test-llm" class="button small ghost" ${llm.configured ? "" : "disabled"}>测试模型连接</button></div>
+      <div class="model-strip ${llm.configured ? "ready" : "offline"}"><div><span class="dot"></span><strong>${esc(llm.model)}</strong><small>${llm.configured ? "LongCat 参与语义融合;结构解析和本体校验保持确定性" : "未配置模型,将使用确定性语义融合"}</small></div><button id="test-llm" class="button small ghost" ${llm.configured ? "" : "disabled"}>测试模型连接</button></div>
       <form id="ontology-build-form" class="form-stack">
         <label id="build-source-drop" class="source-drop" for="build-source-files"><input id="build-source-files" type="file" multiple accept=".sql,.json,.yaml,.yml,application/json,text/yaml,application/yaml"><span class="upload-icon">⇧</span><strong>选择或拖入 1–12 份治理文件</strong><small>推荐一次上传 DDL、OpenAPI、资产清单和治理证据包 · 单文件最大 512KB</small></label>
         <div class="sample-files"><span>下载演示输入:</span><a href="/static/samples/01-dcp-user.sql" download>① DDL</a><a href="/static/samples/02-user-profile-openapi.yaml" download>② OpenAPI</a><a href="/static/samples/03-dcp-asset-inventory.json" download>③ 资产清单</a><a href="/static/samples/04-governance-evidence.json" download>④ 治理证据包</a></div>
         <div id="build-file-list" class="build-file-list"><div class="inline-empty">尚未选择文件</div></div>
         <div class="form-pair"><label>语义融合方式<select id="build-semantic-mode"><option value="auto">自动选择(推荐)</option><option value="llm" ${demoMode && llm.configured ? "selected" : ""}>LongCat-2.0</option><option value="deterministic">确定性规则</option></select></label><label>运行环境<select id="build-environment"><option value="production" ${demoMode ? "selected" : ""}>production</option><option value="development">development</option><option value="test">test</option></select></label></div>
-        <label>发布后要判断的治理对象<input id="build-decision-target" value="${demoMode ? "dcp_user.mobile" : ""}" placeholder="例如:schema.table.column;留空则发布后不执行退役门禁"></label>
         <label>构建说明<input id="build-instruction" value="${demoMode ? "从上传文件构建 DCP 手机号迁移本体,识别业务对象、敏感等级、Owner、质量规则与治理风险。" : "从全部上传文件构建项目候选本体,并标出不确定语义。"}"></label>
-        <div class="button-row"><button id="run-build" class="button primary" type="submit">运行 3 个构建智能体</button><span class="muted">先生成候选本体和 Diff,人工发布后第 4 个智能体才给出治理决策</span></div>
+        <div class="button-row"><button id="run-build" class="button primary" type="submit">运行本体构建(3 个智能体协作)</button><span class="muted">只生成、校验和发布本体;不会自动运行治理决策</span></div>
       </form>
-      <div id="agent-result">${latestReview ? candidateBuildMarkup(latestReview) : latestPublished ? publishedHistoryMarkup(latestPublished, versions[0], overviewResult.demo) : ""}</div>
+      <div id="agent-result">${latestReview ? candidateBuildMarkup(latestReview) : latestPublished ? publishedHistoryMarkup(latestPublished, versions[0]) : ""}</div>
     </article>
-    <article class="section-card panel"><div class="section-head"><div><h2>构建批次与版本</h2><p>每次输入、Diff、审核发布和决策都可追溯</p></div><span class="step-pill">AUDIT</span></div>
+    <article class="section-card panel"><div class="section-head"><div><h2>构建批次与版本</h2><p>每次输入、Diff 和审核发布都可追溯</p></div><span class="step-pill">AUDIT</span></div>
       <div class="version-summary"><strong>${versions.length}</strong><span>已发布本体版本</span><small>${versions[0] ? `当前 ${esc(versions[0].label)} · ${date(versions[0].created_at)}` : "尚未发布"}</small></div>
       ${builds.length ? `<div class="trace build-history">${builds.map(item => `<div class="trace-step"><strong>${esc(item.id)} · ${esc(item.status)}</strong><small>${date(item.created_at)} · ${Number(item.source_count || 0)} 份用户文件 · ${esc(item.semantic_mode)}</small><div class="history-diff"><span>+${Number(item.diff_json?.new_elements || 0)} 元素</span><span>+${Number(item.diff_json?.new_assertions || 0)} 关系</span>${item.status === "review_required" ? `<button class="link-button" data-publish-build="${esc(item.id)}">审核发布</button>` : ""}</div></div>`).join("")}</div>` : '<div class="inline-empty">尚无构建批次。选择文件开始构建。</div>'}
     </article></div>`;
@@ -661,25 +794,24 @@ async function runOntologyBuild(event) {
     form.append("semantic_mode", $("#build-semantic-mode").value);
     form.append("environment", $("#build-environment").value);
     form.append("instruction", $("#build-instruction").value);
-    form.append("decision_target", $("#build-decision-target").value);
+    form.append("decision_target", "");
     const result = await request(`/projects/${state.projectId}/ontology-builds/upload`, {method:"POST", body:form});
     await renderOntologyBuilder();
     $("#agent-result").innerHTML = candidateBuildMarkup(result);
     toast(`候选本体已生成:新增 ${result.diff?.new_elements || 0} 个元素、${result.diff?.new_assertions || 0} 条关系`);
   } catch (error) { toast(error.message, true); }
-  finally { setBusy(button, false, "运行 3 个构建智能体"); }
+  finally { setBusy(button, false, "运行本体构建(3 个智能体协作)"); }
 }
 
 async function publishOntologyBuild(buildId) {
-  const target = $("#build-decision-target")?.value || (isDemoProject() ? "dcp_user.mobile" : "");
   const acceptSemantic = Boolean($("#accept-semantic")?.checked);
   const button = document.querySelector(`[data-publish-build="${CSS.escape(buildId)}"]`);
-  setBusy(button, true, "发布并决策中…");
+  setBusy(button, true, "正在发布本体…");
   try {
-    const result = await request(`/projects/${state.projectId}/ontology-builds/${buildId}/publish`, {method:"POST", body:JSON.stringify({reviewer:"web-console", accept_semantic_assertions:acceptSemantic, decision_target:target})});
+    const result = await request(`/projects/${state.projectId}/ontology-builds/${buildId}/publish`, {method:"POST", body:JSON.stringify({reviewer:"web-console", accept_semantic_assertions:acceptSemantic, decision_target:""})});
     await renderOntologyBuilder();
     $("#agent-result").innerHTML = publishedBuildMarkup(result);
-    toast(`本体 ${result.version?.label || ""} 已发布${result.decision ? `,门禁结论 ${result.decision.status}` : ""}`);
+    toast(`本体 ${result.version?.label || ""} 已发布,可以前往治理问答`);
   } catch (error) { toast(error.message, true); }
   finally { setBusy(button, false, "审核并发布 vNext"); }
 }
@@ -727,7 +859,7 @@ async function init() {
     state.tenant = "local";
   }
   if (demoRequested || requestedTab) {
-    state.activeTab = ["overview", "model", "assertions", "evidence", "issues", "agent"].includes(requestedTab) ? requestedTab : "overview";
+    state.activeTab = ["overview", "model", "assertions", "evidence", "issues", "agent", "decision"].includes(requestedTab) ? requestedTab : "overview";
   }
   $("#tenant-input").value = state.tenant;
   $("#api-key-input").value = state.apiKey;

+ 2 - 1
src/ontorefactor_governance/static/index.html

@@ -49,7 +49,8 @@
         <button class="tab" data-tab="assertions">语义断言</button>
         <button class="tab" data-tab="evidence">证据中心</button>
         <button class="tab" data-tab="issues">治理问题</button>
-        <button class="tab" data-tab="agent">智能体工作台</button>
+        <button class="tab" data-tab="agent">本体构建</button>
+        <button class="tab" data-tab="decision">治理问答</button>
       </nav>
 
       <section id="empty-state" class="empty-state panel hidden">

+ 91 - 3
src/ontorefactor_governance/static/styles.css

@@ -76,6 +76,8 @@ h1 span { color: var(--green-2); }
 .tab { flex: 1; min-width: 120px; padding: 11px 14px; color: var(--muted); background: transparent; border: 0; border-radius: 8px; font-weight: 700; }
 .tab.active { color: #fff; background: var(--green); }
 #content { min-height: 430px; padding-top: 24px; }
+.workspace-mode .hero { display: none; }
+.workspace-mode #content { padding-top: 16px; }
 
 .metric-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; }
 .metric { position: relative; overflow: hidden; min-height: 128px; padding: 18px; }
@@ -246,9 +248,16 @@ td.wrap { min-width: 220px; max-width: 420px; white-space: normal; }
 .legend-node.candidate { border-style: dashed; }
 .legend-edge { width: 27px; height: 0; border-top: 2px solid #718078; }
 .legend-edge.pending { border-top-style: dashed; }
-.ontology-graph-shell { overflow: auto; max-height: min(70vh, 920px); background: #f8f5ee; }
-#ontology-graph { min-height: 360px; }
-.ontology-svg { display: block; font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; }
+.ontology-graph-shell { overflow: hidden; background: #f8f5ee; }
+.graph-canvas-toolbar { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-bottom: 1px solid var(--line); background: #fffdf9; }
+.graph-canvas-toolbar > div { display: flex; gap: 5px; }
+.graph-canvas-toolbar .button { min-width: 35px; padding: 7px 10px; }
+.graph-canvas-toolbar > span { min-width: 42px; color: var(--green); font-size: 10px; font-weight: 900; text-align: center; }
+.graph-canvas-toolbar > small { margin-left: auto; color: var(--muted); font-size: 9px; }
+.ontology-viewport { position: relative; height: min(68vh, 720px); min-height: 480px; overflow: hidden; cursor: grab; touch-action: none; background-color: #f8f5ee; background-image: radial-gradient(rgb(40 121 106 / 13%) .7px, transparent .7px); background-size: 18px 18px; }
+.ontology-viewport.dragging { cursor: grabbing; user-select: none; }
+#ontology-graph { position: absolute; top: 0; left: 0; width: max-content; transform-origin: 0 0; will-change: transform; }
+.ontology-svg { display: block; width: auto; height: auto; min-width: 0; font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; }
 .ontology-lane rect { fill: rgb(255 253 249 / 78%); stroke: #ddd8cc; stroke-width: 1; }
 .ontology-lane:nth-child(even) rect { fill: rgb(238 242 234 / 74%); }
 .lane-title { fill: #174f46; font-family: Georgia, serif; font-size: 22px; font-weight: 700; }
@@ -341,6 +350,73 @@ dialog::backdrop { background: rgb(12 25 24 / 45%); backdrop-filter: blur(4px);
 .definition-list dt { color: var(--muted); }
 .definition-list dd { margin: 0; overflow-wrap: anywhere; }
 
+.governance-qa-layout { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(300px, .55fr); gap: 16px; }
+.qa-workspace, .qa-history { min-width: 0; padding: 22px; }
+.qa-hero { display: flex; align-items: center; justify-content: space-between; gap: 22px; padding-bottom: 17px; border-bottom: 1px solid var(--line); }
+.qa-hero > div:first-child { display: grid; gap: 3px; }
+.qa-hero > div:first-child > span { color: var(--green-2); font-size: 9px; font-weight: 900; letter-spacing: .13em; }
+.qa-hero h2 { margin: 0; font-family: Georgia, "Noto Serif SC", serif; font-size: 26px; font-weight: 500; }
+.qa-hero p { max-width: 710px; margin: 3px 0 0; color: var(--muted); font-size: 11px; }
+.qa-ready { display: grid; grid-template-columns: 10px auto; gap: 0 8px; flex: 0 0 auto; padding: 11px 13px; border: 1px solid var(--line); border-radius: 10px; background: #f7f4ed; }
+.qa-ready i { grid-row: 1 / 3; align-self: center; width: 9px; height: 9px; border-radius: 50%; background: var(--amber); }
+.qa-ready.ready i { background: var(--green-2); box-shadow: 0 0 0 4px rgb(40 121 106 / 11%); }
+.qa-ready strong { font-size: 10px; }
+.qa-ready small { color: var(--muted); font-size: 8px; }
+.agent-separation { display: grid; grid-template-columns: 1fr auto 1fr; gap: 11px; align-items: center; margin: 16px 0; }
+.agent-separation > article { display: grid; grid-template-columns: 38px 1fr auto; gap: 10px; align-items: center; padding: 12px; border: 1px solid var(--line); border-radius: 11px; background: #f7f4ed; }
+.agent-separation > article.active { border-color: rgb(40 121 106 / 30%); background: rgb(40 121 106 / 7%); }
+.agent-separation article > b { display: grid; place-items: center; height: 34px; color: var(--green); border-radius: 9px; background: #fff; font: 700 16px/1 Georgia, serif; }
+.agent-separation article > div { display: grid; min-width: 0; }
+.agent-separation article strong { font-size: 11px; }
+.agent-separation article small { color: var(--muted); font-size: 8px; }
+.agent-separation article > span { padding: 3px 6px; color: var(--green); border-radius: 99px; background: #fff; font-size: 8px; white-space: nowrap; }
+.agent-separation > i { color: var(--green-2); font-style: normal; font-weight: 900; }
+.qa-conversation { display: grid; gap: 10px; max-height: 460px; overflow: auto; padding: 16px; border: 1px solid var(--line); border-radius: 13px; background: #f7f4ed; }
+.qa-message { display: flex; gap: 9px; align-items: flex-start; }
+.qa-message > span, .qa-avatar { display: grid; place-items: center; flex: 0 0 31px; width: 31px; height: 31px; color: #fff; border-radius: 9px; background: var(--green); font-size: 9px; font-weight: 900; }
+.qa-message.user { flex-direction: row-reverse; }
+.qa-message.user > span { color: var(--green); background: var(--lime); }
+.qa-message > div { max-width: 82%; padding: 10px 12px; border-radius: 4px 11px 11px; background: #fff; box-shadow: 0 5px 14px rgb(25 45 39 / 5%); }
+.qa-message.user > div { border-radius: 11px 4px 11px 11px; background: #eaf4d4; }
+.qa-message small { color: var(--muted); font-size: 8px; }
+.qa-message p { margin: 2px 0 0; font-size: 12px; }
+.qa-answer { padding: 15px; color: #eaf4f1; border-radius: 13px; background: var(--green); }
+.qa-answer.blocked { background: #743c33; }
+.qa-answer.insufficient_evidence { background: #80611e; }
+.qa-answer-head { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; gap: 10px; align-items: start; }
+.qa-answer-head > div { display: grid; min-width: 0; }
+.qa-answer-head small, .qa-answer-head p { color: rgb(255 255 255 / 68%); font-size: 8px; }
+.qa-answer-head h3 { margin: 2px 0; color: #fff; font-size: 14px; }
+.qa-answer-head p { margin: 0; overflow-wrap: anywhere; }
+.qa-answer-head > strong { padding: 5px 8px; color: var(--green); border-radius: 99px; background: var(--lime); font-size: 9px; white-space: nowrap; }
+.qa-answer.blocked .qa-answer-head > strong { color: #702e24; background: #ffad99; }
+.qa-answer.insufficient_evidence .qa-answer-head > strong { color: #624812; background: #ffdc88; }
+.qa-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 7px; margin: 13px 0; }
+.qa-summary article { display: flex; align-items: baseline; gap: 5px; padding: 8px; border-radius: 8px; background: rgb(255 255 255 / 9%); }
+.qa-summary b { color: #fff; font: 700 19px/1 Georgia, serif; }
+.qa-summary span { color: rgb(255 255 255 / 68%); font-size: 8px; }
+.qa-checks { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; }
+.qa-checks article { display: grid; grid-template-columns: 22px 1fr auto; gap: 7px; align-items: center; min-width: 0; padding: 8px; color: var(--ink); border-radius: 8px; background: rgb(255 255 255 / 94%); }
+.qa-checks article > span { display: grid; place-items: center; width: 20px; height: 20px; color: #fff; border-radius: 50%; background: var(--amber); font-size: 9px; font-weight: 900; }
+.qa-checks article.passed > span { background: var(--green-2); }
+.qa-checks article.failed > span { background: var(--red); }
+.qa-checks article > div { display: grid; min-width: 0; }
+.qa-checks strong { font-size: 9px; }
+.qa-checks small { overflow: hidden; color: var(--muted); font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
+.qa-checks .badge { font-size: 7px; }
+.qa-provenance { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px; margin-top: 11px; color: rgb(255 255 255 / 64%); font-size: 7px; }
+.qa-form { display: grid; gap: 8px; margin-top: 12px; }
+.qa-form textarea { min-height: 78px; max-height: 150px; font-family: inherit; font-size: 12px; }
+.qa-form > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
+.qa-form .inline-check { color: var(--muted); font-size: 9px; }
+.qa-examples { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 11px; color: var(--muted); font-size: 9px; }
+.qa-examples button { padding: 5px 8px; color: var(--green); border: 1px solid var(--line); border-radius: 99px; background: #fff; font-size: 8px; }
+.qa-history { align-self: start; max-height: calc(100vh - 160px); overflow: auto; }
+.qa-history .section-head { align-items: flex-start; }
+.build-next-step { display: grid; grid-template-columns: 1fr auto auto; gap: 10px; align-items: center; margin-top: 15px; padding: 13px; border: 1px solid rgb(40 121 106 / 20%); border-radius: 10px; background: rgb(40 121 106 / 7%); }
+.build-next-step > div { display: grid; }
+.build-next-step small { color: var(--muted); font-size: 9px; }
+
 .ontology-builder .section-card { min-width: 0; }
 .build-file-list { display: grid; gap: 8px; }
 .build-file-list > article { display: grid; grid-template-columns: 34px 1fr auto; gap: 10px; align-items: center; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #f7f4ed; }
@@ -350,6 +426,7 @@ dialog::backdrop { background: rgb(12 25 24 / 45%); backdrop-filter: blur(4px);
 .build-file-list > article small, .build-file-list > article > span, .build-file-list > p { color: var(--muted); font-size: 10px; }
 .build-file-list > p { margin: 1px 3px 0; text-align: right; }
 .build-stages { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 17px 0; }
+.build-stages-three { grid-template-columns: repeat(3, 1fr); }
 .build-stages article { position: relative; display: grid; grid-template-columns: 25px 1fr; gap: 7px; min-width: 0; padding: 11px; border: 1px solid var(--line); border-radius: 10px; background: #fff; }
 .build-stages article > b { color: var(--green-2); font: 700 18px/1 Georgia, serif; }
 .build-stages article > div { display: grid; gap: 4px; min-width: 0; }
@@ -384,6 +461,8 @@ dialog::backdrop { background: rgb(12 25 24 / 45%); backdrop-filter: blur(4px);
   .metric-grid { grid-template-columns: repeat(3, 1fr); }
   .hero { grid-template-columns: 1fr 340px; gap: 28px; }
   .demo-steps { grid-template-columns: repeat(3, 1fr); }
+  .governance-qa-layout { grid-template-columns: 1fr; }
+  .qa-history { max-height: none; }
 }
 
 @media (max-width: 760px) {
@@ -411,10 +490,19 @@ dialog::backdrop { background: rgb(12 25 24 / 45%); backdrop-filter: blur(4px);
   .decision-result { grid-template-columns: 1fr; }
   .decision-checks { grid-column: auto; }
   .build-stages, .build-diff { grid-template-columns: repeat(2, 1fr); }
+  .build-stages-three { grid-template-columns: 1fr; }
   .candidate-preview { grid-template-columns: repeat(2, 1fr); }
   .publish-bar { grid-template-columns: 1fr; }
   .model-intro { align-items: flex-start; flex-direction: column; }
   .node-detail-grid { grid-template-columns: 1fr; }
   .node-detail-hero { align-items: flex-start; flex-direction: column; }
   .node-detail-status { justify-items: start; }
+  .qa-hero, .qa-form > div { align-items: flex-start; flex-direction: column; }
+  .agent-separation { grid-template-columns: 1fr; }
+  .agent-separation > i { transform: rotate(90deg); justify-self: center; }
+  .qa-checks { grid-template-columns: 1fr; }
+  .build-next-step { grid-template-columns: 1fr; }
+  .graph-canvas-toolbar { flex-wrap: wrap; }
+  .graph-canvas-toolbar > small { width: 100%; margin-left: 0; }
+  .ontology-viewport { height: 60vh; min-height: 420px; }
 }

+ 10 - 0
tests/test_api.py

@@ -195,6 +195,16 @@ def test_challenge_cup_demo_bootstrap_endpoint(monkeypatch):
     assert evaluated.json()["status"] == "BLOCKED"
     assert evaluated.json()["summary"] == {"passed": 1, "failed": 4, "unknown": 0, "total": 5}
 
+    answered = client.post(
+        f"/api/v1/governance/projects/{project_id}/governance-agent/ask",
+        headers=headers,
+        json={"question": "dcp_user.mobile 是否可以迁移并删除?", "persist": True},
+    )
+    assert answered.status_code == 200
+    assert answered.json()["agent"] == "governance-decision-agent"
+    assert answered.json()["decision"]["status"] == "BLOCKED"
+    assert answered.json()["resolved_target"]["id"]
+
     decisions = client.get(
         f"/api/v1/governance/projects/{project_id}/decisions", headers=headers
     ).json()["decisions"]

+ 30 - 1
tests/test_decision.py

@@ -7,8 +7,9 @@ from ontorefactor_governance.agents import execute_governance_agent
 from ontorefactor_governance.builds import publish_ontology_build, run_ontology_build
 from ontorefactor_governance.db import Database
 from ontorefactor_governance.decision import collect_project_context, evaluate_retirement_gate, resolve_retirement_target
+from ontorefactor_governance.governance_qa import ask_governance_agent
 from ontorefactor_governance.llm import LongCatProvider, PROMPT_VERSION
-from ontorefactor_governance.service import create_assertion, create_evidence, seed_challenge_cup_demo, upsert_element
+from ontorefactor_governance.service import GovernanceError, create_assertion, create_evidence, seed_challenge_cup_demo, upsert_element
 
 
 SAMPLES = Path(__file__).parents[1] / "src" / "ontorefactor_governance" / "static" / "samples"
@@ -70,6 +71,34 @@ def test_demo_decision_is_computed_from_evidence_not_seeded_issues(db):
     assert not db.fetchall("SELECT id FROM governance_issues WHERE project_id=? AND rule_id LIKE 'demo:%'", (project_id,))
 
 
+def test_fourth_agent_answers_a_natural_language_question_independently(db, monkeypatch):
+    monkeypatch.delenv("AGENTPAAS_BASE_URL", raising=False)
+    project_id, _ = _demo(db)
+
+    result = ask_governance_agent(
+        db, "decision-demo", project_id,
+        "请问 dcp_user.mobile 现在是否可以迁移并删除?",
+    )
+
+    assert result["agent"] == "governance-decision-agent"
+    assert result["decision"]["status"] == "BLOCKED"
+    assert result["resolved_target"]["id"]
+    assert "目前不可以" in result["answer"]
+    run = db.fetchone("SELECT * FROM governance_agent_runs WHERE id=?", (result["run_id"],))
+    assert run["source_type"] == "governance-question"
+    assert run["decision_id"] == result["decision"]["id"]
+
+
+def test_fourth_agent_requires_a_published_ontology(db):
+    project_id = seed_challenge_cup_demo(db, "unpublished-qa")["project"]["id"]
+
+    with pytest.raises(GovernanceError) as error:
+        ask_governance_agent(db, "unpublished-qa", project_id, "dcp_user.mobile 可以删除吗?")
+
+    assert error.value.code == "ONTOLOGY_NOT_PUBLISHED"
+    assert error.value.status == 409
+
+
 def test_retirement_gate_returns_insufficient_when_required_evidence_is_missing(db):
     project_id, target_id = _demo(db)
     db.execute("DELETE FROM governance_evidence WHERE project_id=?", (project_id,))