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

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、运行观测或人工声明。
 - 治理问题:运行规则并把高风险问题推进到关闭状态。
 - 治理问题:运行规则并把高风险问题推进到关闭状态。
-- 智能体工作台:粘贴 DDL、OpenAPI YAML/JSON 或资产清单,查看执行轨迹和落库结果。
+- 智能体工作台:上传或粘贴 DDL、OpenAPI YAML/JSON、资产清单,先预检内容,再查看执行轨迹和落库结果。
 - JSON-LD:在“本体模型”中导出当前项目,供图数据库、语义工具或其他平台使用。
 - 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
 ```json
 {
 {

+ 12 - 12
docs/demo-runbook.md

@@ -14,9 +14,9 @@
    .\.venv\Scripts\ontorefactor-governance.exe --host 127.0.0.1 --port 8010
    .\.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”。
 3. 确认项目为“挑战杯 · DCP 手机号迁移治理”。若不存在,点击“一键准备挑战杯 Demo”。
-4. 进入“智能体工作台”,点击“测试模型连接”。连接成功即可,不要在正式演示时重新跑完整模型。
+4. 下载工作台中的三份演示文件,进入“智能体工作台”,点击“测试模型连接”。
 5. 浏览器设为 90% 或 100% 缩放,开启全屏;关闭消息通知和无关窗口。
 5. 浏览器设为 90% 或 100% 缩放,开启全屏;关闭消息通知和无关窗口。
 6. 保留 `data/demo-overview.png` 作为断网时的首页备份图。
 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。
 - 不要展示或朗读 API Key。

+ 2 - 1
pyproject.toml

@@ -15,6 +15,7 @@ dependencies = [
   "uvicorn[standard]>=0.20",
   "uvicorn[standard]>=0.20",
   "pydantic>=2,<3",
   "pydantic>=2,<3",
   "python-dotenv>=1,<2",
   "python-dotenv>=1,<2",
+  "python-multipart>=0.0.9,<1",
   "pyyaml>=6,<7",
   "pyyaml>=6,<7",
 ]
 ]
 
 
@@ -32,7 +33,7 @@ include-package-data = true
 where = ["src"]
 where = ["src"]
 
 
 [tool.setuptools.package-data]
 [tool.setuptools.package-data]
-ontorefactor_governance = ["static/*"]
+ontorefactor_governance = ["static/*", "static/samples/*"]
 
 
 [tool.setuptools.data-files]
 [tool.setuptools.data-files]
 "share/ontorefactor-governance/ontology/m3" = ["ontology/m3/*.ttl"]
 "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:
 def _parse_inventory(content: str) -> dict:
     try:
     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):
     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")}
     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:
         if persist:
             evidence = create_evidence(db, tenant_id, project_id, {"kind": "SourceArtifact", "title": str(payload.get("source_name") or "智能体输入"),
             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"),
                 "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 = {}
             id_map = {}
             for item in result["elements"]:
             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}"
                 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 typing import Any, Literal, Optional
 
 
 from dotenv import load_dotenv
 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.middleware.cors import CORSMiddleware
 from fastapi.responses import FileResponse
 from fastapi.responses import FileResponse
 from fastapi.staticfiles import StaticFiles
 from fastapi.staticfiles import StaticFiles
@@ -17,6 +17,7 @@ from . import __version__
 from .agents import execute_governance_agent, list_agent_runs
 from .agents import execute_governance_agent, list_agent_runs
 from .db import get_database
 from .db import get_database
 from .llm import public_llm_status, test_llm_connection
 from .llm import public_llm_status, test_llm_connection
+from .source_upload import MAX_SOURCE_BYTES, prepare_uploaded_source
 from . import service
 from . import service
 
 
 
 
@@ -134,6 +135,17 @@ class AnalyzeInput(BaseModel):
     persist: bool = True
     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")
 @app.get("/api/health")
 def health():
 def health():
     return {"ok": True, "service": "ontorefactor-governance", "version": __version__,
     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)
     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")
 @app.get("/api/v1/governance/projects/{project_id}/agent-runs")
 def runs(project_id: str, tenant: str = Depends(scope)):
 def runs(project_id: str, tenant: str = Depends(scope)):
     call(service.require_project, get_database(), tenant, project_id)
     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 API = "/api/v1/governance";
 const DEMO_NAMESPACE = "https://onto.qm.cn/ontorefactor/demo/dcp-mobile/1.0";
 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 = {
 const state = {
   tenant: localStorage.getItem("or-tenant") || "local",
   tenant: localStorage.getItem("or-tenant") || "local",
   apiKey: localStorage.getItem("or-api-key") || "",
   apiKey: localStorage.getItem("or-api-key") || "",
@@ -15,6 +7,8 @@ const state = {
   projects: [],
   projects: [],
   activeTab: "overview",
   activeTab: "overview",
   overview: null,
   overview: null,
+  uploadedFile: null,
+  uploadedSource: null,
 };
 };
 
 
 const $ = (selector, root = document) => root.querySelector(selector);
 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 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 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 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) => ({
 const termLabel = (value) => ({
   "source-normalizer": "输入规范化",
   "source-normalizer": "输入规范化",
   "asset-discovery-agent": "资产发现智能体",
   "asset-discovery-agent": "资产发现智能体",
@@ -68,6 +63,7 @@ async function loadProjects(preferredId) {
   $("#project-hint").textContent = currentProject()?.description || "项目完全保存在本地 SQLite 中。";
   $("#project-hint").textContent = currentProject()?.description || "项目完全保存在本地 SQLite 中。";
   $("#empty-state").classList.toggle("hidden", Boolean(state.projectId));
   $("#empty-state").classList.toggle("hidden", Boolean(state.projectId));
   $("#content").classList.toggle("hidden", !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();
   if (state.projectId) await renderActive();
 }
 }
 
 
@@ -307,35 +303,83 @@ async function renderAgent() {
   ]);
   ]);
   const runs = runResult.runs;
   const runs = runResult.runs;
   const demoMode = isDemoProject();
   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、质量规则和治理风险。" : "发现数据资产,推断业务语义并执行治理检查";
   const instruction = demoMode ? "判断 dcp_user.mobile 是否可以迁移并删除;识别业务对象、敏感等级、Owner、质量规则和治理风险。" : "发现数据资产,推断业务语义并执行治理检查";
+  state.uploadedFile = null;
+  state.uploadedSource = null;
   $("#content").innerHTML = `<div class="agent-grid">
   $("#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 class="model-strip ${llm.configured ? "ready" : "offline"}">
         <div><span class="dot"></span><strong>${esc(llm.model)}</strong><small>${llm.configured ? "LongCat 大模型已配置" : "未配置密钥,将使用确定性语义分析"}</small></div>
         <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>
         <button id="test-llm" class="button small ghost" ${llm.configured ? "" : "disabled"}>测试模型连接</button>
       </div>
       </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">
       <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>
         <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>
         <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>
       </form>
       <div id="agent-result"></div>
       <div id="agent-result"></div>
     </article>
     </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>'}
       ${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>`;
     </article></div>`;
   $("#agent-form").addEventListener("submit", runAgent);
   $("#agent-form").addEventListener("submit", runAgent);
   $("#test-llm")?.addEventListener("click", testLLM);
   $("#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() {
 async function testLLM() {
@@ -351,12 +395,25 @@ async function runAgent(event) {
   event.preventDefault();
   event.preventDefault();
   const button = $("#run-agent"); setBusy(button, true, "智能体运行中…");
   const button = $("#run-agent"); setBusy(button, true, "智能体运行中…");
   try {
   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};
     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} 个元素`);
     toast(`智能体已落库 ${result.persisted?.elements || 0} 个元素`);
   } catch (error) { toast(error.message, true); }
   } catch (error) { toast(error.message, true); }
-  finally { setBusy(button, false, "运行智能体"); }
+  finally { setBusy(button, false, "开始智能治理分析"); }
 }
 }
 
 
 async function showRun(id) {
 async function showRun(id) {
@@ -389,10 +446,12 @@ document.addEventListener("click", (event) => {
 });
 });
 
 
 async function init() {
 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) {
   if (demoRequested) {
     state.tenant = "local";
     state.tenant = "local";
-    state.activeTab = "overview";
+    state.activeTab = ["overview", "model", "assertions", "evidence", "issues", "agent"].includes(requestedTab) ? requestedTab : "overview";
     localStorage.setItem("or-tenant", "local");
     localStorage.setItem("or-tenant", "local");
   }
   }
   $("#tenant-input").value = state.tenant;
   $("#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); }
 .model-strip small { color: var(--muted); }
 .form-stack { display: grid; gap: 14px; }
 .form-stack { display: grid; gap: 14px; }
 .form-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
 .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 { 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 { padding: 12px 14px; border-left: 3px solid var(--lime); border-radius: 0 9px 9px 0; background: #f6f3ec; }
 .trace-step strong { display: block; }
 .trace-step strong { display: block; }
 .trace-step small { color: var(--muted); }
 .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; }
 .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 {
 .demo-spotlight {
   position: relative; display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(270px, .55fr); gap: 28px;
   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 { align-items: stretch; flex-direction: column; }
   .toolbar input, .toolbar select { max-width: none; }
   .toolbar input, .toolbar select { max-width: none; }
   .form-pair { grid-template-columns: 1fr; }
   .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-spotlight { grid-template-columns: 1fr; padding: 24px; }
   .demo-proof-grid { grid-template-columns: repeat(2, 1fr); }
   .demo-proof-grid { grid-template-columns: repeat(2, 1fr); }
   .demo-tour-head, .demo-ready { align-items: flex-start; flex-direction: column; }
   .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()
     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):
 def test_llm_status_is_safe_and_forced_mode_requires_key(monkeypatch):
     monkeypatch.delenv("LONGCAT_API_KEY", raising=False)
     monkeypatch.delenv("LONGCAT_API_KEY", raising=False)
     monkeypatch.setenv("ONTOREFACTOR_LLM_MODE", "auto")
     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"