Просмотр исходного кода

feat: add governance source file uploads

caozheng 2 недель назад
Родитель
Сommit
f5f98ac678

+ 22 - 2
README.md

@@ -79,10 +79,30 @@ LongCat 官方文档:[LongCat API 开放平台](https://longcat.chat/platform/
 - 语义断言:接受或驳回智能体推断出的候选关系。
 - 证据中心:追溯断言对应的 DDL、OpenAPI、运行观测或人工声明。
 - 治理问题:运行规则并把高风险问题推进到关闭状态。
-- 智能体工作台:粘贴 DDL、OpenAPI YAML/JSON 或资产清单,查看执行轨迹和落库结果。
+- 智能体工作台:上传或粘贴 DDL、OpenAPI YAML/JSON、资产清单,先预检内容,再查看执行轨迹和落库结果。
 - JSON-LD:在“本体模型”中导出当前项目,供图数据库、语义工具或其他平台使用。
 
-资产清单格式如下:
+文件上传支持 `.sql`、`.json`、`.yaml` 和 `.yml`,单文件最大 512KB。工作台提供三份可下载的演示输入;选择文件后,平台会自动识别类型,显示表、字段、API、Schema、敏感字段和文件哈希,预检不会写入数据库。点击“开始智能治理分析”后,原始文件会作为 `SourceArtifact` 保存到 SQLite 的 `governance_evidence` 表,运行记录和输入哈希保存到 `governance_agent_runs` 表。
+
+上传接口:
+
+```text
+POST /api/v1/governance/projects/{project_id}/sources/preview
+POST /api/v1/governance/projects/{project_id}/agent-runs/upload
+```
+
+例如用 DDL 直接运行智能体:
+
+```powershell
+curl.exe -X POST "http://127.0.0.1:8010/api/v1/governance/projects/<project_id>/agent-runs/upload" `
+  -H "X-Tenant-ID: local" `
+  -F "file=@src/ontorefactor_governance/static/samples/01-dcp-user.sql" `
+  -F "source_type=auto" `
+  -F "semantic_mode=llm" `
+  -F "instruction=识别业务语义、敏感字段、Owner、质量规则和治理风险"
+```
+
+资产清单支持 JSON 或 YAML,JSON 格式如下:
 
 ```json
 {

+ 12 - 12
docs/demo-runbook.md

@@ -14,9 +14,9 @@
    .\.venv\Scripts\ontorefactor-governance.exe --host 127.0.0.1 --port 8010
    ```
 
-2. 打开演示直达地址:<http://127.0.0.1:8010/?demo=1>。
+2. 从上传开始演示时,打开:<http://127.0.0.1:8010/?demo=1&tab=agent>;从总览开始则打开 <http://127.0.0.1:8010/?demo=1>。
 3. 确认项目为“挑战杯 · DCP 手机号迁移治理”。若不存在,点击“一键准备挑战杯 Demo”。
-4. 进入“智能体工作台”,点击“测试模型连接”。连接成功即可,不要在正式演示时重新跑完整模型。
+4. 下载工作台中的三份演示文件,进入“智能体工作台”,点击“测试模型连接”。
 5. 浏览器设为 90% 或 100% 缩放,开启全屏;关闭消息通知和无关窗口。
 6. 保留 `data/demo-overview.png` 作为断网时的首页备份图。
 
@@ -26,14 +26,14 @@
 
 | 时间 | 页面与操作 | 建议讲法 | 屏幕证据 |
 |---|---|---|---|
-| 0:00–0:45 | 治理总览 | “企业重构真正危险的不是改代码,而是不知道一个字段还被谁使用。我们用一个真实工程问题演示:手机号字段能否删除?” | 首页问题卡与 `BLOCKED` 门禁 |
-| 0:45–1:30 | 点击步骤 01“输入证据” | “系统不是让大模型凭空回答,而是先接入 DDL、OpenAPI、SQL AST、生产 Trace、质量报告和专家签字。” | 8 份证据及内容哈希 |
-| 1:30–2:30 | 点击步骤 02“智能体协作” | “资产发现和门禁检查是确定性程序;LongCat 只负责业务语义、分类、Owner 和规则候选。所有步骤在同一个 LambdAgent 运行中可审计。” | 三个 Agent、LongCat-2.0、2752 Token、Prompt v2 |
-| 2:30–3:45 | 返回总览,点击“查看字段级影响半径” | “传统搜索只能找到代码引用;本体图能从字段跨到 API、任务、业务对象、Owner、安全策略和质量门禁。” | 31 个影响对象、56 条已接受关系、三跳影响列 |
-| 3:45–4:45 | 点击步骤 04“候选断言” | “模型结论不是事实。13 条建议都有置信度,全部强制为 pending;只有人工接受后才能进入权威图谱。” | pending 状态、接受/驳回按钮、最高置信度上限 85% |
-| 4:45–5:45 | 点击步骤 05“治理门禁” | “门禁不采用模型的主观判断,而是执行确定性规则:3 个生产消费者未归零、完整率只有 99.87%、两项签字未完成,因此禁止破坏性 DDL。” | 4 条核心开放问题与负责人 |
-| 5:45–6:30 | 点击步骤 06“标准交付” | “业务、软件、治理语义被放进 M3–M0 四层模型,并可导出 JSON-LD;企业换数据源或换模型,不需要推倒治理语言。” | 四层元素、Profile、JSON-LD 导出 |
-| 6:30–7:00 | 回到总览收束 | “AI 的价值不是替专家拍板,而是把几天的证据搜集和影响评审压缩成分钟级、可复核的治理闭环。” | `暂缓删除` 与整改路径 |
+| 0:00–0:50 | 智能体工作台,上传 `01-dcp-user.sql` | “我们不从一张做好的图开始,而是从企业真实会提供的数据库定义开始。平台先识别文件类型、表字段、敏感字段和内容哈希。” | 上传预检卡:1 张表、7 个字段、敏感字段、SHA-256 |
+| 0:50–1:40 | 选择 LongCat,点击“开始智能治理分析” | “LambdAgent 同时执行资产发现、LongCat 业务语义分析和确定性治理控制;等待期间先解释三条职责边界。” | 模型在线状态与运行按钮;完成后显示元素、断言、问题数量 |
+| 1:40–2:25 | 打开审计轨迹与证据中心 | “原始文件不是分析后就丢弃,而是连同哈希、模型、Prompt、Token 和执行步骤一起保存。” | `SourceArtifact` 原文、模型、Token、Prompt v2、哈希 |
+| 2:25–3:15 | 上传 OpenAPI 或资产清单,只做预检 | “同一个入口还能接收接口契约和现有资产清单,文件类型会自动判断。正式接入时可由 CI/CD 调用同一 API。” | API/Schema 或资产数量预览;无需再次运行完整模型 |
+| 3:15–4:15 | 返回总览,点击“查看字段级影响半径” | “传统搜索只能找到代码引用;本体图能从字段跨到 API、任务、业务对象、Owner、安全策略和质量门禁。” | 31 个影响对象、56 条已接受关系、三跳影响列 |
+| 4:15–5:05 | 点击步骤 04“候选断言” | “模型结论不是事实。模型建议都有置信度并强制为 pending;只有人工接受后才能进入权威图谱。” | pending 状态、接受/驳回按钮、最高置信度上限 85% |
+| 5:05–6:10 | 点击步骤 05“治理门禁” | “门禁不采用模型的主观判断,而是执行确定性规则:3 个生产消费者未归零、完整率只有 99.87%、两项签字未完成,因此禁止破坏性 DDL。” | 4 条核心开放问题与负责人 |
+| 6:10–7:00 | 标准交付并回到总览 | “业务、软件、治理语义进入 M3–M0 四层模型并可导出 JSON-LD。AI 把证据搜集和影响评审压缩为分钟级,但最终决策仍可复核。” | JSON-LD、`暂缓删除` 与整改路径 |
 
 ## 开场和收尾原话
 
@@ -69,8 +69,8 @@
 
 ## 现场不要做
 
-- 不要从空白项目开始手工录入数据。
-- 不要在 7 分钟演示中等待完整 LongCat 重跑;模型服务排队可能需要 30–90 秒
+- 不要从空白项目开始手工录入数据;使用工作台提供的演示文件
+- 如果现场网络明显不稳定,将语义模式切到“仅确定性规则”完成上传分析,再用右侧预热记录证明 LongCat 的真实调用
 - 不要点击“重新校验”,它会根据当前候选模型重新生成辅助问题,改变首页基准数字。
 - 不要现场接受或驳回断言,除非已经准备好重新初始化演示数据库。
 - 不要展示或朗读 API Key。

+ 2 - 1
pyproject.toml

@@ -15,6 +15,7 @@ dependencies = [
   "uvicorn[standard]>=0.20",
   "pydantic>=2,<3",
   "python-dotenv>=1,<2",
+  "python-multipart>=0.0.9,<1",
   "pyyaml>=6,<7",
 ]
 
@@ -32,7 +33,7 @@ include-package-data = true
 where = ["src"]
 
 [tool.setuptools.package-data]
-ontorefactor_governance = ["static/*"]
+ontorefactor_governance = ["static/*", "static/samples/*"]
 
 [tool.setuptools.data-files]
 "share/ontorefactor-governance/ontology/m3" = ["ontology/m3/*.ttl"]

+ 6 - 5
src/ontorefactor_governance/agents.py

@@ -112,11 +112,11 @@ def _parse_openapi(content: str) -> dict:
 
 def _parse_inventory(content: str) -> dict:
     try:
-        document = json.loads(content)
-    except ValueError as exc:
-        raise GovernanceError("inventory 必须是 JSON", code="INVALID_INVENTORY") from exc
+        document = yaml.safe_load(content)
+    except yaml.YAMLError as exc:
+        raise GovernanceError("资产清单必须是合法的 JSON 或 YAML", code="INVALID_INVENTORY") from exc
     if not isinstance(document, dict):
-        raise GovernanceError("inventory 顶层必须是对象", code="INVALID_INVENTORY")
+        raise GovernanceError("资产清单顶层必须是对象", code="INVALID_INVENTORY")
     return {key: document.get(key) or [] for key in ("tables", "apis", "schemas")}
 
 
@@ -261,7 +261,8 @@ def execute_governance_agent(db, tenant_id: str, project_id: str, payload: dict,
         if persist:
             evidence = create_evidence(db, tenant_id, project_id, {"kind": "SourceArtifact", "title": str(payload.get("source_name") or "智能体输入"),
                 "uri": f"evidence://agent-run/{run_id}", "content": content, "source": str(payload.get("source_name") or "inline"),
-                "metadata": {"run_id": run_id, "source_type": payload.get("source_type"), "semantic_mode": semantic_mode}})
+                "metadata": {"run_id": run_id, "source_type": payload.get("source_type"), "semantic_mode": semantic_mode,
+                             **dict(payload.get("source_metadata") or {})}})
             id_map = {}
             for item in result["elements"]:
                 item_source = f"longcat:{settings.model}@{PROMPT_VERSION}" if item["local_id"].startswith("llm:") else f"agent-run:{run_id}"

+ 53 - 1
src/ontorefactor_governance/api.py

@@ -7,7 +7,7 @@ from pathlib import Path
 from typing import Any, Literal, Optional
 
 from dotenv import load_dotenv
-from fastapi import Depends, FastAPI, Header, HTTPException, Query
+from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, UploadFile
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.responses import FileResponse
 from fastapi.staticfiles import StaticFiles
@@ -17,6 +17,7 @@ from . import __version__
 from .agents import execute_governance_agent, list_agent_runs
 from .db import get_database
 from .llm import public_llm_status, test_llm_connection
+from .source_upload import MAX_SOURCE_BYTES, prepare_uploaded_source
 from . import service
 
 
@@ -134,6 +135,17 @@ class AnalyzeInput(BaseModel):
     persist: bool = True
 
 
+async def uploaded_source(file: UploadFile, source_type: str) -> dict[str, Any]:
+    """Read one bounded text upload and convert parser errors to the API error shape."""
+    try:
+        data = await file.read(MAX_SOURCE_BYTES + 1)
+        return prepare_uploaded_source(file.filename or "uploaded-source", data, source_type)
+    except service.GovernanceError as exc:
+        raise HTTPException(status_code=exc.status, detail={"code": exc.code, "message": str(exc)}) from exc
+    finally:
+        await file.close()
+
+
 @app.get("/api/health")
 def health():
     return {"ok": True, "service": "ontorefactor-governance", "version": __version__,
@@ -257,6 +269,46 @@ def analyze(project_id: str, body: AnalyzeInput, tenant: str = Depends(scope)):
     return call(execute_governance_agent, get_database(), tenant, project_id, body.model_dump(), persist=body.persist)
 
 
+@app.post("/api/v1/governance/projects/{project_id}/sources/preview")
+async def preview_source(
+    project_id: str,
+    file: UploadFile = File(description="DDL、OpenAPI 或资产清单文本文件,最大 512KB"),
+    source_type: Literal["auto", "ddl", "openapi", "inventory"] = Form(default="auto"),
+    tenant: str = Depends(scope),
+):
+    """Validate an uploaded source and return a non-persistent discovery preview."""
+    call(service.require_project, get_database(), tenant, project_id)
+    prepared = await uploaded_source(file, source_type)
+    return {"accepted": True, **prepared}
+
+
+@app.post("/api/v1/governance/projects/{project_id}/agent-runs/upload")
+async def analyze_upload(
+    project_id: str,
+    file: UploadFile = File(description="DDL、OpenAPI 或资产清单文本文件,最大 512KB"),
+    source_type: Literal["auto", "ddl", "openapi", "inventory"] = Form(default="auto"),
+    source_name: str = Form(default="", max_length=256),
+    environment: str = Form(default="development", max_length=64),
+    instruction: str = Form(default="", max_length=1000),
+    semantic_mode: Literal["auto", "llm", "deterministic"] = Form(default="auto"),
+    persist: bool = Form(default=True),
+    tenant: str = Depends(scope),
+):
+    """Run the governance agents directly from an uploaded source file."""
+    call(service.require_project, get_database(), tenant, project_id)
+    prepared = await uploaded_source(file, source_type)
+    payload = {
+        "source_type": prepared["source_type"],
+        "source_name": source_name.strip() or prepared["source_name"],
+        "content": prepared["content"],
+        "environment": environment,
+        "instruction": instruction,
+        "semantic_mode": semantic_mode,
+        "source_metadata": {"upload": prepared["file"], "detected": prepared["detected"]},
+    }
+    return call(execute_governance_agent, get_database(), tenant, project_id, payload, persist=persist)
+
+
 @app.get("/api/v1/governance/projects/{project_id}/agent-runs")
 def runs(project_id: str, tenant: str = Depends(scope)):
     call(service.require_project, get_database(), tenant, project_id)

+ 88 - 0
src/ontorefactor_governance/source_upload.py

@@ -0,0 +1,88 @@
+"""Safe decoding, type detection and preview for uploaded governance sources."""
+from __future__ import annotations
+
+import hashlib
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+from .agents import SOURCE_TYPES, normalize_source
+from .service import GovernanceError
+
+
+MAX_SOURCE_BYTES = 512 * 1024
+UPLOAD_TYPES = ("auto", *SOURCE_TYPES)
+_EXTENSION_TYPES = {".sql": "ddl"}
+
+
+def decode_source_file(data: bytes) -> tuple[str, str]:
+    if not data:
+        raise GovernanceError("上传文件不能为空", code="EMPTY_SOURCE")
+    if len(data) > MAX_SOURCE_BYTES:
+        raise GovernanceError("上传文件超过 512KB", code="SOURCE_TOO_LARGE", status=413)
+    if b"\x00" in data:
+        raise GovernanceError("上传文件不是可读取的文本文件", code="BINARY_SOURCE")
+    for encoding in ("utf-8-sig", "gb18030"):
+        try:
+            return data.decode(encoding), encoding
+        except UnicodeDecodeError:
+            continue
+    raise GovernanceError("文件编码无法识别,请使用 UTF-8 或 GB18030", code="INVALID_SOURCE_ENCODING")
+
+
+def infer_source_type(filename: str, content: str, requested: str = "auto") -> str:
+    requested = str(requested or "auto").lower()
+    if requested not in UPLOAD_TYPES:
+        raise GovernanceError(f"source_type 必须是 {', '.join(UPLOAD_TYPES)}")
+    if requested != "auto":
+        return requested
+
+    suffix = Path(filename or "").suffix.lower()
+    if suffix in _EXTENSION_TYPES:
+        return _EXTENSION_TYPES[suffix]
+    if suffix not in {".json", ".yaml", ".yml"}:
+        raise GovernanceError("无法从文件名识别类型,请上传 .sql、.json、.yaml 或 .yml", code="UNSUPPORTED_SOURCE_FILE")
+    try:
+        document = yaml.safe_load(content)
+    except yaml.YAMLError as exc:
+        raise GovernanceError(f"JSON/YAML 解析失败: {exc}", code="INVALID_SOURCE") from exc
+    if not isinstance(document, dict):
+        raise GovernanceError("JSON/YAML 顶层必须是对象", code="INVALID_SOURCE")
+    if "openapi" in document or "swagger" in document or "paths" in document:
+        return "openapi"
+    if any(key in document for key in ("tables", "apis", "schemas")):
+        return "inventory"
+    raise GovernanceError("无法判断是 OpenAPI 还是资产清单,请在界面中手动选择输入类型", code="AMBIGUOUS_SOURCE_TYPE")
+
+
+def prepare_uploaded_source(filename: str, data: bytes, requested_type: str = "auto") -> dict[str, Any]:
+    safe_name = Path(filename or "uploaded-source").name[:256]
+    content, encoding = decode_source_file(data)
+    source_type = infer_source_type(safe_name, content, requested_type)
+    normalized = normalize_source({"source_type": source_type, "source_name": safe_name, "content": content})
+    parsed = normalized["parsed"]
+    tables = parsed.get("tables") or []
+    schemas = parsed.get("schemas") or []
+    columns = [column for table in tables for column in (table.get("columns") or [])]
+    properties = [prop for schema in schemas for prop in (schema.get("properties") or [])]
+    sensitive = [str(item.get("name") or "") for item in (*columns, *properties) if item.get("sensitive")]
+    return {
+        "source_type": source_type,
+        "source_name": safe_name,
+        "content": content,
+        "file": {
+            "name": safe_name,
+            "size_bytes": len(data),
+            "encoding": encoding,
+            "sha256": hashlib.sha256(data).hexdigest(),
+        },
+        "detected": {
+            "tables": len(tables),
+            "columns": len(columns),
+            "apis": len(parsed.get("apis") or []),
+            "schemas": len(schemas),
+            "sensitive_fields": sensitive,
+        },
+        "parsed": parsed,
+    }

+ 85 - 26
src/ontorefactor_governance/static/app.js

@@ -1,13 +1,5 @@
 const API = "/api/v1/governance";
 const DEMO_NAMESPACE = "https://onto.qm.cn/ontorefactor/demo/dcp-mobile/1.0";
-const DEMO_DDL = `CREATE TABLE dcp_user (
-  user_id BIGINT PRIMARY KEY,
-  tenant_id BIGINT NOT NULL,
-  mobile VARCHAR(32),
-  mobile_cipher VARCHAR(256),
-  full_name VARCHAR(128),
-  updated_at TIMESTAMP NOT NULL
-);`;
 const state = {
   tenant: localStorage.getItem("or-tenant") || "local",
   apiKey: localStorage.getItem("or-api-key") || "",
@@ -15,6 +7,8 @@ const state = {
   projects: [],
   activeTab: "overview",
   overview: null,
+  uploadedFile: null,
+  uploadedSource: null,
 };
 
 const $ = (selector, root = document) => root.querySelector(selector);
@@ -22,6 +16,7 @@ const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
 const esc = (value) => String(value ?? "").replace(/[&<>'"]/g, (char) => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char]));
 const date = (value) => value ? new Intl.DateTimeFormat("zh-CN", {dateStyle:"medium", timeStyle:"short"}).format(new Date(value)) : "—";
 const badge = (value) => `<span class="badge ${esc(String(value).toLowerCase())}">${esc(value)}</span>`;
+const formatBytes = (value) => Number(value || 0) < 1024 ? `${Number(value || 0)} B` : `${(Number(value) / 1024).toFixed(1)} KB`;
 const termLabel = (value) => ({
   "source-normalizer": "输入规范化",
   "asset-discovery-agent": "资产发现智能体",
@@ -68,6 +63,7 @@ async function loadProjects(preferredId) {
   $("#project-hint").textContent = currentProject()?.description || "项目完全保存在本地 SQLite 中。";
   $("#empty-state").classList.toggle("hidden", Boolean(state.projectId));
   $("#content").classList.toggle("hidden", !state.projectId);
+  $$(".tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.tab === state.activeTab));
   if (state.projectId) await renderActive();
 }
 
@@ -307,35 +303,83 @@ async function renderAgent() {
   ]);
   const runs = runResult.runs;
   const demoMode = isDemoProject();
-  const sourceName = demoMode ? "V3_8_0__mobile_cipher.sql" : "console-import";
-  const sourceContent = demoMode ? DEMO_DDL : `CREATE TABLE customer_order (
-  order_id INTEGER PRIMARY KEY,
-  customer_id INTEGER NOT NULL,
-  total_amount DECIMAL(12,2),
-  created_at TIMESTAMP
-);`;
   const instruction = demoMode ? "判断 dcp_user.mobile 是否可以迁移并删除;识别业务对象、敏感等级、Owner、质量规则和治理风险。" : "发现数据资产,推断业务语义并执行治理检查";
+  state.uploadedFile = null;
+  state.uploadedSource = null;
   $("#content").innerHTML = `<div class="agent-grid">
-    <article class="section-card panel"><div class="section-head"><div><h2>治理多智能体工作台</h2><p>由 LambdAgent 完成规范化、并行分析、LongCat 语义推理、门禁校验与证据落库</p></div></div>
+    <article class="section-card panel"><div class="section-head"><div><h2>上传治理输入</h2><p>从真实文件开始:系统先解析预检,再交给 LambdAgent 与 LongCat 分析</p></div><span class="step-pill">01 · INPUT</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>
-      ${demoMode && runs.some(run => run.status === "completed" && run.llm_model) ? '<div class="demo-ready"><strong>✓ 真实模型结果已预热</strong><span>现场可直接查看右侧审计轨迹;需要时也可以再次实时运行。</span></div>' : ""}
+      ${demoMode && runs.some(run => run.status === "completed" && run.llm_model) ? '<div class="demo-ready"><strong>✓ 真实模型结果已预热</strong><span>现场可先上传文件实时分析,网络异常时再查看右侧预热轨迹。</span></div>' : ""}
       <form id="agent-form" class="form-stack">
+        <label id="source-drop" class="source-drop" for="source-file">
+          <input id="source-file" type="file" accept=".sql,.json,.yaml,.yml,application/json,text/yaml,application/yaml">
+          <span class="upload-icon">⇧</span>
+          <strong>点击选择或拖入治理源文件</strong>
+          <small>支持 SQL DDL、OpenAPI JSON/YAML、资产清单 JSON/YAML · 最大 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></div>
+        <div id="source-preview" class="source-preview hidden"></div>
         <div class="form-pair"><label>输入格式<select id="source-type"><option value="ddl">SQL DDL</option><option value="openapi">OpenAPI JSON/YAML</option><option value="inventory">资产清单 JSON/YAML</option></select></label><label>语义分析方式<select id="semantic-mode"><option value="auto">自动选择(推荐)</option><option value="llm" ${demoMode ? "selected" : ""}>强制 LongCat-2.0</option><option value="deterministic">仅确定性规则</option></select></label></div>
-        <label>来源名称<input id="source-name" value="${esc(sourceName)}" required></label>
-        <label>原始内容<textarea id="source-content" spellcheck="false">${esc(sourceContent)}</textarea></label>
+        <label>来源名称<input id="source-name" value="" placeholder="上传后自动填写,也可手动输入" required></label>
+        <label>原始内容<textarea id="source-content" spellcheck="false" placeholder="上传后在这里显示原文;也可以直接粘贴 DDL、OpenAPI 或资产清单"></textarea></label>
         <label>运行说明<input id="agent-instruction" value="${esc(instruction)}"></label>
-        <div class="button-row"><button id="run-agent" class="button primary" type="submit">运行智能体</button><span class="muted">自动结论以 pending 状态等待人工确认</span></div>
+        <div class="button-row"><button id="run-agent" class="button primary" type="submit">开始智能治理分析</button><span class="muted">模型断言以 pending 状态等待人工确认</span></div>
       </form>
       <div id="agent-result"></div>
     </article>
-    <article class="section-card panel"><div class="section-head"><div><h2>运行历史</h2><p>完整保留每一步输入输出摘要</p></div></div>
+    <article class="section-card panel"><div class="section-head"><div><h2>智能体审计轨迹</h2><p>每次输入、模型和执行步骤均可追溯</p></div><span class="step-pill">02 · AGENTS</span></div>
       ${runs.length ? `<div class="trace">${runs.map(x => `<div class="trace-step"><strong>${esc(x.source_type || "governance-analysis")} · ${esc(x.semantic_mode || "deterministic")}</strong><small>${date(x.created_at)} · ${esc(x.status)}${x.llm_model ? ` · ${esc(x.llm_model)} · ${Number(x.input_tokens || 0) + Number(x.output_tokens || 0)} tokens` : ""}</small><br><button class="link-button" data-run="${esc(x.id)}">查看执行轨迹</button></div>`).join("")}</div>` : '<div class="inline-empty">尚未运行智能体</div>'}
     </article></div>`;
   $("#agent-form").addEventListener("submit", runAgent);
   $("#test-llm")?.addEventListener("click", testLLM);
+  $("#source-file").addEventListener("change", (event) => handleSourceFile(event.target.files?.[0]));
+  const drop = $("#source-drop");
+  ["dragenter", "dragover"].forEach(name => drop.addEventListener(name, (event) => { event.preventDefault(); drop.classList.add("dragging"); }));
+  ["dragleave", "drop"].forEach(name => drop.addEventListener(name, (event) => { event.preventDefault(); drop.classList.remove("dragging"); }));
+  drop.addEventListener("drop", (event) => handleSourceFile(event.dataTransfer?.files?.[0]));
+  $("#source-content").addEventListener("input", () => {
+    if (!state.uploadedFile) return;
+    state.uploadedFile = null;
+    state.uploadedSource = null;
+    $("#source-preview").innerHTML = '<strong>已切换为文本编辑模式</strong><span>运行时将提交当前编辑后的内容。</span>';
+  });
+}
+
+async function handleSourceFile(file) {
+  if (!file) return;
+  const preview = $("#source-preview");
+  const drop = $("#source-drop");
+  state.uploadedFile = null;
+  state.uploadedSource = null;
+  preview.classList.remove("hidden", "accepted");
+  preview.innerHTML = `<strong>正在校验 ${esc(file.name)}…</strong><span>识别格式并预检文件结构</span>`;
+  drop.classList.add("loading");
+  try {
+    const form = new FormData();
+    form.append("file", file, file.name);
+    form.append("source_type", "auto");
+    const result = await request(`/projects/${state.projectId}/sources/preview`, {method:"POST", body:form});
+    state.uploadedFile = file;
+    state.uploadedSource = result;
+    $("#source-type").value = result.source_type;
+    $("#source-name").value = result.source_name;
+    $("#source-content").value = result.content;
+    const found = result.detected || {};
+    const facts = result.source_type === "ddl"
+      ? `${found.tables || 0} 张表 · ${found.columns || 0} 个字段`
+      : result.source_type === "openapi"
+        ? `${found.apis || 0} 个 API · ${found.schemas || 0} 个 Schema`
+        : `${found.tables || 0} 张表 · ${found.apis || 0} 个 API · ${found.schemas || 0} 个 Schema`;
+    preview.classList.add("accepted");
+    preview.innerHTML = `<div><strong>✓ 文件预检通过</strong><span>${esc(result.source_name)} · ${esc(formatBytes(result.file?.size_bytes))} · ${esc(result.source_type.toUpperCase())}</span></div><div class="preview-facts"><b>${esc(facts)}</b><small>${found.sensitive_fields?.length ? `发现敏感字段:${esc(found.sensitive_fields.join("、"))}` : "未命中敏感字段词典"}</small></div><code>SHA-256 ${esc((result.file?.sha256 || "").slice(0, 16))}…</code>`;
+    toast(`文件预检通过:${facts}`);
+  } catch (error) {
+    preview.innerHTML = `<strong>文件预检未通过</strong><span>${esc(error.message)}</span>`;
+    toast(error.message, true);
+  } finally { drop.classList.remove("loading"); }
 }
 
 async function testLLM() {
@@ -351,12 +395,25 @@ async function runAgent(event) {
   event.preventDefault();
   const button = $("#run-agent"); setBusy(button, true, "智能体运行中…");
   try {
-    const result = await request(`/projects/${state.projectId}/agent-runs/analyze`, {method:"POST", body:JSON.stringify({source_type:$("#source-type").value, source_name:$("#source-name").value, content:$("#source-content").value, instruction:$("#agent-instruction").value, semantic_mode:$("#semantic-mode").value, environment:"development", persist:true})});
+    let result;
+    if (state.uploadedFile) {
+      const form = new FormData();
+      form.append("file", state.uploadedFile, state.uploadedFile.name);
+      form.append("source_type", state.uploadedSource?.source_type || $("#source-type").value);
+      form.append("source_name", $("#source-name").value);
+      form.append("instruction", $("#agent-instruction").value);
+      form.append("semantic_mode", $("#semantic-mode").value);
+      form.append("environment", "development");
+      form.append("persist", "true");
+      result = await request(`/projects/${state.projectId}/agent-runs/upload`, {method:"POST", body:form});
+    } else {
+      result = await request(`/projects/${state.projectId}/agent-runs/analyze`, {method:"POST", body:JSON.stringify({source_type:$("#source-type").value, source_name:$("#source-name").value, content:$("#source-content").value, instruction:$("#agent-instruction").value, semantic_mode:$("#semantic-mode").value, environment:"development", persist:true})});
+    }
     const summary = {...(result.result?.summary || {}), semantic_mode:result.semantic_mode, model:result.llm?.model || "规则引擎", tokens:Number(result.llm?.input_tokens || 0) + Number(result.llm?.output_tokens || 0), persisted:result.persisted};
-    $("#agent-result").innerHTML = `<h3>运行结果</h3><div class="result-box">${esc(JSON.stringify(summary, null, 2))}</div>`;
+    $("#agent-result").innerHTML = `<section class="run-complete"><div class="run-complete-head"><span>✓</span><div><strong>治理分析完成并已落库</strong><small>${esc(summary.model)} · ${esc(summary.semantic_mode)} · ${Number(summary.tokens || 0)} tokens</small></div></div><div class="run-metrics"><button data-open-tab="model"><strong>${Number(summary.elements || 0)}</strong><span>本体元素</span></button><button data-open-tab="assertions"><strong>${Number(summary.assertions || 0)}</strong><span>语义断言</span></button><button data-open-tab="issues"><strong>${Number(summary.issues || 0)}</strong><span>治理问题</span></button></div><p>原始文件已进入证据中心,模型断言可人工复核,治理问题可继续形成整改闭环。</p><details><summary>查看原始运行摘要</summary><div class="result-box">${esc(JSON.stringify(summary, null, 2))}</div></details></section>`;
     toast(`智能体已落库 ${result.persisted?.elements || 0} 个元素`);
   } catch (error) { toast(error.message, true); }
-  finally { setBusy(button, false, "运行智能体"); }
+  finally { setBusy(button, false, "开始智能治理分析"); }
 }
 
 async function showRun(id) {
@@ -389,10 +446,12 @@ document.addEventListener("click", (event) => {
 });
 
 async function init() {
-  const demoRequested = new URLSearchParams(window.location.search).get("demo") === "1";
+  const query = new URLSearchParams(window.location.search);
+  const demoRequested = query.get("demo") === "1";
+  const requestedTab = query.get("tab");
   if (demoRequested) {
     state.tenant = "local";
-    state.activeTab = "overview";
+    state.activeTab = ["overview", "model", "assertions", "evidence", "issues", "agent"].includes(requestedTab) ? requestedTab : "overview";
     localStorage.setItem("or-tenant", "local");
   }
   $("#tenant-input").value = state.tenant;

+ 9 - 0
src/ontorefactor_governance/static/samples/01-dcp-user.sql

@@ -0,0 +1,9 @@
+CREATE TABLE iam.dcp_user (
+  user_id BIGINT PRIMARY KEY,
+  tenant_id BIGINT NOT NULL,
+  mobile VARCHAR(32),
+  mobile_cipher VARCHAR(256),
+  full_name VARCHAR(128),
+  email VARCHAR(128),
+  updated_at TIMESTAMP NOT NULL
+);

+ 30 - 0
src/ontorefactor_governance/static/samples/02-user-profile-openapi.yaml

@@ -0,0 +1,30 @@
+openapi: 3.0.3
+info:
+  title: DCP User Profile API
+  version: 1.0.0
+paths:
+  /api/v1/users/{id}/profile:
+    get:
+      operationId: queryUserProfile
+      summary: 查询用户画像
+      responses:
+        "200":
+          description: 用户画像
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/UserProfile"
+components:
+  schemas:
+    UserProfile:
+      type: object
+      required: [userId, tenantId, mobile]
+      properties:
+        userId:
+          type: integer
+        tenantId:
+          type: integer
+        mobile:
+          type: string
+        email:
+          type: string

+ 26 - 0
src/ontorefactor_governance/static/samples/03-dcp-asset-inventory.json

@@ -0,0 +1,26 @@
+{
+  "tables": [
+    {
+      "schema": "iam",
+      "name": "dcp_user",
+      "columns": [
+        {"name": "user_id", "data_type": "BIGINT", "primary_key": true, "nullable": false},
+        {"name": "tenant_id", "data_type": "BIGINT", "nullable": false},
+        {"name": "mobile", "data_type": "VARCHAR(32)", "sensitive": true},
+        {"name": "mobile_cipher", "data_type": "VARCHAR(256)", "sensitive": true}
+      ]
+    }
+  ],
+  "apis": [
+    {"path": "/api/v1/users/{id}/profile", "method": "GET", "operation_id": "queryUserProfile"}
+  ],
+  "schemas": [
+    {
+      "name": "UserProfile",
+      "properties": [
+        {"name": "userId", "data_type": "integer"},
+        {"name": "mobile", "data_type": "string", "sensitive": true}
+      ]
+    }
+  ]
+}

+ 31 - 0
src/ontorefactor_governance/static/styles.css

@@ -133,11 +133,40 @@ td.wrap { min-width: 220px; max-width: 420px; white-space: normal; }
 .model-strip small { color: var(--muted); }
 .form-stack { display: grid; gap: 14px; }
 .form-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
+.step-pill { flex: 0 0 auto; padding: 5px 9px; color: var(--green); border: 1px solid rgb(40 121 106 / 18%); border-radius: 99px; background: rgb(40 121 106 / 8%); font-size: 9px; font-weight: 900; letter-spacing: .1em; }
+.source-drop { place-items: center; min-height: 155px; padding: 24px; color: var(--muted); border: 1.5px dashed #aebdb5; border-radius: 14px; background: linear-gradient(145deg, rgb(200 235 103 / 8%), rgb(40 121 106 / 5%)); text-align: center; cursor: pointer; transition: .18s; }
+.source-drop:hover, .source-drop.dragging { color: var(--green); border-color: var(--green-2); background: rgb(200 235 103 / 14%); transform: translateY(-1px); }
+.source-drop.loading { opacity: .62; pointer-events: none; }
+.source-drop input { display: none; }
+.source-drop strong { color: var(--ink); font-size: 15px; }
+.source-drop small { max-width: 520px; font-size: 11px; }
+.upload-icon { display: grid; place-items: center; width: 42px; height: 42px; margin-bottom: 3px; color: var(--green); border-radius: 13px; background: var(--lime); font-size: 25px; font-weight: 300; }
+.sample-files { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-top: -6px; color: var(--muted); font-size: 11px; }
+.sample-files a { padding: 4px 8px; color: var(--green); border: 1px solid var(--line); border-radius: 7px; background: #fff; font-weight: 800; text-decoration: none; }
+.sample-files a:hover { border-color: var(--green-2); }
+.source-preview { display: grid; grid-template-columns: minmax(170px, .9fr) minmax(210px, 1.1fr) auto; gap: 14px; align-items: center; padding: 13px 15px; color: #7a2c20; border: 1px solid rgb(198 91 75 / 24%); border-radius: 11px; background: rgb(198 91 75 / 8%); }
+.source-preview.accepted { color: var(--green); border-color: rgb(40 121 106 / 22%); background: rgb(40 121 106 / 8%); }
+.source-preview > div { display: grid; gap: 2px; }
+.source-preview span, .source-preview small { color: var(--muted); font-size: 10px; }
+.source-preview code { color: var(--muted); font-size: 9px; white-space: nowrap; }
+.preview-facts b { font-size: 12px; }
 .trace { display: grid; gap: 10px; margin-top: 14px; }
 .trace-step { padding: 12px 14px; border-left: 3px solid var(--lime); border-radius: 0 9px 9px 0; background: #f6f3ec; }
 .trace-step strong { display: block; }
 .trace-step small { color: var(--muted); }
 .result-box { padding: 15px; border-radius: 11px; color: #dbece8; background: #183d38; white-space: pre-wrap; word-break: break-word; font: 12px/1.6 "Cascadia Code", monospace; }
+.run-complete { margin-top: 20px; padding: 18px; border: 1px solid rgb(40 121 106 / 22%); border-radius: 14px; background: linear-gradient(145deg, rgb(200 235 103 / 13%), rgb(40 121 106 / 7%)); }
+.run-complete-head { display: flex; align-items: center; gap: 11px; }
+.run-complete-head > span { display: grid; place-items: center; width: 34px; height: 34px; color: #fff; border-radius: 50%; background: var(--green-2); font-weight: 900; }
+.run-complete-head > div { display: grid; }
+.run-complete-head small { color: var(--muted); }
+.run-metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 15px 0 10px; }
+.run-metrics button { display: grid; gap: 2px; padding: 12px; color: var(--green); border: 1px solid var(--line); border-radius: 10px; background: #fff; text-align: left; }
+.run-metrics strong { font: 700 25px/1 Georgia, serif; }
+.run-metrics span { color: var(--muted); font-size: 10px; }
+.run-complete p { color: var(--muted); font-size: 11px; }
+.run-complete details summary { color: var(--green); cursor: pointer; font-size: 11px; font-weight: 800; }
+.run-complete details .result-box { margin-top: 10px; }
 
 .demo-spotlight {
   position: relative; display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(270px, .55fr); gap: 28px;
@@ -233,6 +262,8 @@ dialog::backdrop { background: rgb(12 25 24 / 45%); backdrop-filter: blur(4px);
   .toolbar { align-items: stretch; flex-direction: column; }
   .toolbar input, .toolbar select { max-width: none; }
   .form-pair { grid-template-columns: 1fr; }
+  .source-preview { grid-template-columns: 1fr; }
+  .source-preview code { white-space: normal; }
   .demo-spotlight { grid-template-columns: 1fr; padding: 24px; }
   .demo-proof-grid { grid-template-columns: repeat(2, 1fr); }
   .demo-tour-head, .demo-ready { align-items: flex-start; flex-direction: column; }

+ 41 - 0
tests/test_api.py

@@ -61,6 +61,47 @@ def test_agent_endpoint(monkeypatch):
     db.close()
 
 
+def test_source_file_preview_and_upload_agent_endpoint(monkeypatch):
+    db = Database("sqlite:///:memory:")
+    monkeypatch.setattr(api, "get_database", lambda: db)
+    client = TestClient(api.app)
+    headers = {"X-Tenant-ID": "upload-api"}
+    project_id = client.post("/api/v1/governance/projects", headers=headers, json={
+        "name": "Uploaded governance", "namespace": "urn:api:uploaded"
+    }).json()["id"]
+    ddl = b"CREATE TABLE iam.customer (id BIGINT PRIMARY KEY, mobile VARCHAR(32));"
+
+    preview = client.post(
+        f"/api/v1/governance/projects/{project_id}/sources/preview",
+        headers=headers,
+        files={"file": ("customer.sql", ddl, "text/plain")},
+        data={"source_type": "auto"},
+    )
+    assert preview.status_code == 200
+    assert preview.json()["source_type"] == "ddl"
+    assert preview.json()["detected"] == {
+        "tables": 1, "columns": 2, "apis": 0, "schemas": 0, "sensitive_fields": ["mobile"]
+    }
+    assert preview.json()["content"].startswith("CREATE TABLE")
+
+    analyzed = client.post(
+        f"/api/v1/governance/projects/{project_id}/agent-runs/upload",
+        headers=headers,
+        files={"file": ("customer.sql", ddl, "text/plain")},
+        data={"source_type": "auto", "semantic_mode": "deterministic", "instruction": "识别敏感字段"},
+    )
+    assert analyzed.status_code == 200
+    assert analyzed.json()["status"] == "completed"
+    assert analyzed.json()["persisted"]["elements"] > 0
+    evidence = client.get(
+        f"/api/v1/governance/projects/{project_id}/evidence", headers=headers
+    ).json()["evidence"]
+    source = next(item for item in evidence if item["kind"] == "SourceArtifact")
+    assert source["content"].startswith("CREATE TABLE")
+    assert source["metadata"]["upload"]["name"] == "customer.sql"
+    db.close()
+
+
 def test_llm_status_is_safe_and_forced_mode_requires_key(monkeypatch):
     monkeypatch.delenv("LONGCAT_API_KEY", raising=False)
     monkeypatch.setenv("ONTOREFACTOR_LLM_MODE", "auto")

+ 32 - 0
tests/test_source_upload.py

@@ -0,0 +1,32 @@
+import json
+
+import pytest
+
+from ontorefactor_governance.service import GovernanceError
+from ontorefactor_governance.source_upload import MAX_SOURCE_BYTES, prepare_uploaded_source
+
+
+def test_auto_detects_openapi_yaml_and_inventory_json():
+    openapi = prepare_uploaded_source(
+        "contract.yaml",
+        b"openapi: 3.0.3\npaths:\n  /health:\n    get:\n      operationId: health\n",
+    )
+    assert openapi["source_type"] == "openapi"
+    assert openapi["detected"]["apis"] == 1
+
+    inventory_content = json.dumps({"tables": [{"schema": "crm", "name": "customer", "columns": []}]})
+    inventory = prepare_uploaded_source("assets.json", inventory_content.encode())
+    assert inventory["source_type"] == "inventory"
+    assert inventory["detected"]["tables"] == 1
+
+
+def test_inventory_yaml_and_upload_limits_are_supported():
+    inventory = prepare_uploaded_source(
+        "assets.yml",
+        b"tables:\n  - schema: crm\n    name: customer\n    columns: []\napis: []\nschemas: []\n",
+    )
+    assert inventory["source_type"] == "inventory"
+
+    with pytest.raises(GovernanceError) as exc:
+        prepare_uploaded_source("too-large.sql", b"x" * (MAX_SOURCE_BYTES + 1), "ddl")
+    assert exc.value.code == "SOURCE_TOO_LARGE"