diff --git a/backend/app/api/catalog.py b/backend/app/api/catalog.py
index e1c96af0..10a26f87 100644
--- a/backend/app/api/catalog.py
+++ b/backend/app/api/catalog.py
@@ -6,6 +6,7 @@
- POST /catalog/{table}/review 单项审阅:确认、驳回、撤销审阅
- POST /catalog/{table}/patch/preview 目录修改提案的预览(只读):对着当前目录算改前、改后
- POST /catalog/{table}/patch 保存目录修改提案(乐观锁),记为人工确认
+- GET /catalog/{table}/impact 影响面:引用这张表的已发布、受管模板(data/catalog_impact.py)
- POST /catalog/draft 同步起草(注释、外键、命名推断,可选模型)
规则都在 data/catalog.py,这里只做取源、转换形状和把异常翻成状态码:版本不符 409,结构不合规、审阅路径
@@ -22,7 +23,7 @@ from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.runs import actor_of
-from app.data import catalog, introspect, table_versions
+from app.data import catalog, catalog_impact, introspect, table_versions
from app.db.base import get_session
from app.db.models import DataSource
@@ -280,3 +281,15 @@ async def save_catalog_patch(source_id: str, table: str, payload: CatalogPatchIn
except catalog.CatalogInvalid as e:
raise _invalid(e) from e
return await _detail(session, source, key, entry)
+
+
+@router.get("/{source_id}/catalog/{table}/impact")
+async def catalog_table_impact(source_id: str, table: str,
+ session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
+ """引用这张表的已发布、受管模板,每个模板按当前的已发布版本算:调用工具节点的 SQL 用到了这张表算直接引用,
+ Agent 绑定了这个源的查询工具算可能涉及,合并查询按输入追溯。改目录之前、保存纠正之后都看它。"""
+ row, source = await _resolved(session, source_id)
+ key, _ = await _table_key(session, source, table)
+ tables = _tables_of(source)
+ templates = await catalog_impact.table_impact(session, row.name, key, list(tables) if tables else None)
+ return {"table": key, "templates": templates}
diff --git a/backend/app/data/catalog_impact.py b/backend/app/data/catalog_impact.py
new file mode 100644
index 00000000..7fd60f17
--- /dev/null
+++ b/backend/app/data/catalog_impact.py
@@ -0,0 +1,289 @@
+"""数据目录的影响面与发布时的目录版本(阶段 4A:维护闭环)。
+
+目录改了一项(「在园人数是存量」),用到这张表的模板算出来的数可能就该变了。这里回答两件事:
+
+- **影响面**:哪些已发布、受管的模板用到了这张表(table_impact)。按每个模板当前的已发布版本算,不看画布上的
+ 草稿——正式运行跑的是已发布的那一版。调用工具节点写死的 SQL 用到了这张表算「直接引用」;Agent 绑定了这个源
+ 的查询工具算「可能涉及」(SQL 是运行时写的,静态看不出);合并查询按它的输入一路追溯。
+- **发布时的目录版本**:发布时记下模板 SQL 用到的每张表的目录版本 {源名: {表名: 版本}}(catalog_versions_for),
+ 从这一版发起正式运行时和当前的比(catalog_drift),有变化只提醒、不拦运行——目录是给人和助手看的说明,
+ 不是运行的输入;拦下来等于让一次文字修订卡住正式出具。
+
+表名的取法和证据台账同一个口径:evidence.sql_tables 取 FROM / JOIN 后面的表(去掉注释、字符串、CTE),
+去掉 schema 前缀后按 name_key 对到表结构上(和 catalog.table_usage 一致)。
+"""
+from __future__ import annotations
+
+import logging
+from collections.abc import Iterable, Mapping
+from typing import Any
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.data.names import name_key
+
+logger = logging.getLogger(__name__)
+
+#: 影响程度:direct 直接引用(SQL 里写着这张表);possible 可能涉及(Agent 运行时自己写 SQL)
+IMPACTS = ("direct", "possible")
+
+
+def _lookup(tables: Iterable[str]) -> dict[str, str]:
+ out: dict[str, str] = {}
+ for name in tables:
+ out.setdefault(name_key(name), name)
+ return out
+
+
+def sql_table_keys(sql: str, tables: Iterable[str] | None) -> set[str]:
+ """SQL 用到的表,对到表结构的写法上。tables 不给(不知道表结构)时用 SQL 里去掉 schema 前缀的原名。"""
+ from app.engine.evidence import sql_tables
+
+ try:
+ names = sql_tables(sql or "")
+ except Exception: # noqa: BLE001 - 解析不了就当没用到表:影响面和版本记录都是提示,不能因此报错
+ return set()
+ shorts = [str(n).rsplit(".", 1)[-1] for n in names]
+ if tables is None:
+ return set(shorts)
+ lookup = _lookup(tables)
+ return {hit for s in shorts if (hit := lookup.get(name_key(s))) is not None}
+
+
+def graph_sql_tables(spec: Any, tables_by_source: Mapping[str, Iterable[str]] | None = None) -> dict[str, set[str]]:
+ """一张图里调用工具节点写死的 SQL 用到的表:{源名: {表名}}。Agent 的 SQL 运行时才有,不在这里。
+
+ tables_by_source 是各源表结构里的表名(schema_cache 的 tables 的键):给了的源按它规范表名、对不上的不算;
+ 没给的源用 SQL 里的原名。
+ """
+ from app.data.sqlcheck import tool_query
+
+ out: dict[str, set[str]] = {}
+ for node in getattr(spec, "nodes", []) or []:
+ found = tool_query(node)
+ if not found:
+ continue
+ source, sql = found
+ known = (tables_by_source or {}).get(source)
+ hits = sql_table_keys(sql, list(known) if known is not None else None)
+ if hits:
+ out.setdefault(source, set()).update(hits)
+ return out
+
+
+def _kind(node: Any) -> str:
+ return str(getattr(node.type, "value", node.type))
+
+
+def _setting(spec: Any, node: Any, key: str) -> Any:
+ """节点配置的实际取值:节点上没写就取图级 defaults(和运行时 NodeContext.cfg 同一个规则)。"""
+ value = (node.config or {}).get(key)
+ return (getattr(spec, "defaults", None) or {}).get(key) if value in (None, "") else value
+
+
+def template_refs(spec: Any, source: str, table: str, tables: Iterable[str] | None) -> list[dict[str, Any]]:
+ """一张图里哪些节点用到了某个源的某张表,按图里的顺序。每项 {node_id, label, type, impact, via?, member?}。
+
+ - 调用工具节点:调用 db_query__<源>、SQL 里有这张表 → direct。
+ - Agent 节点、协作成员:绑定了 db_query__<源> → possible(每个成员各一项,带 member)。
+ - 合并查询:输入(别名 → 节点)里有用到这张表的 → 取输入里最强的那一级,via 写经由哪些输入。合并的合并
+ 一路追到底;成环(校验会报错的图)时停下。
+ """
+ from app.data.sqlcheck import tool_query
+ from app.tools.datasource import QUERY_PREFIX
+
+ tool = f"{QUERY_PREFIX}{source}"
+ wanted = name_key(table)
+ known = list(tables) if tables is not None else None
+ nodes = list(getattr(spec, "nodes", []) or [])
+ by_id = {n.id: n for n in nodes}
+ own: dict[str, dict[str, Any]] = {}
+ out: list[dict[str, Any]] = []
+
+ def base(node: Any, impact: str, **extra: Any) -> dict[str, Any]:
+ return {"node_id": node.id, "label": node.title, "type": _kind(node), "impact": impact, **extra}
+
+ for node in nodes:
+ kind = _kind(node)
+ if kind == "tool":
+ found = tool_query(node)
+ if found and found[0] == source and wanted in {name_key(t) for t in sql_table_keys(found[1], known)}:
+ own[node.id] = base(node, "direct")
+ elif kind == "agent":
+ if tool in [str(t).strip() for t in _setting(spec, node, "tools") or [] if isinstance(t, str)]:
+ own[node.id] = base(node, "possible")
+ elif kind == "supervisor":
+ for member in _setting(spec, node, "agents") or []:
+ if isinstance(member, Mapping) and tool in [str(t).strip() for t in member.get("tools") or []
+ if isinstance(t, str)]:
+ out.append(base(node, "possible", member=str(member.get("name") or "")))
+
+ resolved: dict[str, dict[str, Any] | None] = {}
+
+ def through(node_id: str, trail: frozenset[str]) -> dict[str, Any] | None:
+ """合并查询节点经由输入用到这张表的样子;用不到返回 None。"""
+ if node_id in resolved:
+ return resolved[node_id]
+ node = by_id.get(node_id)
+ if node is None or _kind(node) != "merge" or node_id in trail:
+ return own.get(node_id)
+ via: list[dict[str, Any]] = []
+ best: str | None = None
+ inputs = (node.config or {}).get("inputs")
+ for alias, target in (inputs.items() if isinstance(inputs, Mapping) else []):
+ if not isinstance(target, str):
+ continue
+ hit = own.get(target) or through(target, trail | {node_id})
+ if hit is None:
+ continue
+ via.append({"node_id": target, "label": by_id[target].title if target in by_id else target, "alias": alias})
+ best = "direct" if hit["impact"] == "direct" or best == "direct" else "possible"
+ resolved[node_id] = base(node, best, via=via) if best else None
+ return resolved[node_id]
+
+ for node in nodes:
+ if node.id in own:
+ out.append(own[node.id])
+ elif _kind(node) == "merge" and (hit := through(node.id, frozenset())):
+ out.append(hit)
+ order = {n.id: i for i, n in enumerate(nodes)}
+ out.sort(key=lambda r: order.get(r["node_id"], 0))
+ return out
+
+
+async def _published(session: AsyncSession) -> list[tuple[Any, Any]]:
+ """每个发布过的模板和它当前的已发布版本:[(Workflow, WorkflowVersion)]。"""
+ from app.db.models import Workflow, WorkflowVersion
+
+ rows = (await session.execute(
+ select(Workflow, WorkflowVersion)
+ .join(WorkflowVersion, (WorkflowVersion.workflow_id == Workflow.id)
+ & (WorkflowVersion.version == Workflow.published_version))
+ .where(Workflow.published_version.is_not(None))
+ )).all()
+ return [(w, v) for w, v in rows]
+
+
+async def table_impact(session: AsyncSession, source: str, table: str,
+ tables: Iterable[str] | None) -> list[dict[str, Any]]:
+ """引用某个源某张表的已发布、受管模板,直接引用的在前,同级按名字。
+
+ 每项 {workflow_id, name, version, level, impact, nodes}:version 是当前的已发布版本,level 是它发布时的级别
+ (published / governed;记级别之前发布的老版本按工作流现在的状态)。图解析不了的版本跳过。
+ """
+ from app.engine.schema import GraphSpec
+
+ known = list(tables) if tables is not None else None
+ out: list[dict[str, Any]] = []
+ for workflow, version in await _published(session):
+ try:
+ spec = GraphSpec.model_validate(version.graph or {})
+ except Exception: # noqa: BLE001 - 解析不了的版本发起不了正式运行,也就谈不上影响
+ continue
+ refs = template_refs(spec, source, table, known)
+ if not refs:
+ continue
+ level = version.level or (workflow.status if workflow.status in ("published", "governed") else "published")
+ out.append({
+ "workflow_id": workflow.id, "name": workflow.name, "version": version.version, "level": level,
+ "impact": "direct" if any(r["impact"] == "direct" for r in refs) else "possible", "nodes": refs,
+ })
+ out.sort(key=lambda t: (t["impact"] != "direct", t["name"]))
+ return out
+
+
+# ==========================================================================
+# 发布时的目录版本
+# ==========================================================================
+
+
+async def _sources_by_name(session: AsyncSession, names: Iterable[str]) -> dict[str, Any]:
+ """启用着的数据源:{源名: (DataSource 行, 用来读表结构的源)}。上传源解析成当前快照的视图,解析不了用原行。"""
+ from app.data import table_versions
+ from app.db.models import DataSource
+
+ wanted = sorted({n for n in names if n})
+ if not wanted:
+ return {}
+ rows = (await session.execute(
+ select(DataSource).where(DataSource.name.in_(wanted), DataSource.enabled.is_(True))
+ )).scalars()
+ out: dict[str, Any] = {}
+ for row in rows:
+ try:
+ view = await table_versions.resolve_source(session, row)
+ except table_versions.SnapshotMissing:
+ view = row
+ out[row.name] = (row, view)
+ return out
+
+
+def _schema_tables(view: Any) -> list[str] | None:
+ tables = (getattr(view, "schema_cache", None) or {}).get("tables")
+ return list(tables) if isinstance(tables, Mapping) and tables else None
+
+
+async def catalog_versions_for(session: AsyncSession, spec: Any) -> dict[str, dict[str, int]]:
+ """发布时要记下的目录版本:{源名: {表名: 版本}},只收模板 SQL 用到的表;还没有目录的表记 0。
+
+ 记 0 是有意的:发布之后有人给这张表补了目录,同样算「自发布以来有变化」。找不到的源(停用、删了)不记:
+ 没有目录可比,正式运行时工具自己会报源不存在。读库出错时返回空 dict——记不下版本不该挡住发布。
+ """
+ from app.data import catalog
+
+ try:
+ names = graph_sql_tables(spec).keys()
+ sources = await _sources_by_name(session, names)
+ tables_by_source = {name: _schema_tables(view) for name, (_, view) in sources.items()}
+ used = graph_sql_tables(spec, {k: v for k, v in tables_by_source.items() if v is not None})
+ out: dict[str, dict[str, int]] = {}
+ for name, tables in sorted(used.items()):
+ if name not in sources:
+ continue
+ entries = await catalog.read_catalog(session, sources[name][0].id)
+ out[name] = {t: (entries[t].version if t in entries else 0) for t in sorted(tables)}
+ return out
+ except Exception: # noqa: BLE001
+ logger.exception("记录发布时的目录版本失败,本次不记")
+ return {}
+
+
+async def catalog_drift(session: AsyncSession, recorded: Mapping[str, Any] | None) -> list[dict[str, Any]]:
+ """发布时记下的目录版本和现在比:变了的表 [{source, source_id, table, label, published, current}]。
+
+ 没记过(这个功能之前发布的版本)返回空列表——不知道当时是哪一版,不能说它变了。现在找不到的源跳过(理由同
+ catalog_versions_for)。label 是表现在的中文名(没有或被驳回时为 None)。读库出错返回空列表:提醒是附加的。
+ """
+ from app.data import catalog
+
+ if not isinstance(recorded, Mapping) or not recorded:
+ return []
+ try:
+ sources = await _sources_by_name(session, recorded.keys())
+ out: list[dict[str, Any]] = []
+ for name in sorted(recorded):
+ pinned = recorded[name]
+ if name not in sources or not isinstance(pinned, Mapping):
+ continue
+ row = sources[name][0]
+ entries = await catalog.read_catalog(session, row.id)
+ for table in sorted(pinned):
+ then = pinned[table]
+ entry = entries.get(table)
+ now = entry.version if entry else 0
+ if isinstance(then, int) and then == now:
+ continue
+ label = (entry.notes.get("label") if entry else None) or {}
+ out.append({"source": name, "source_id": row.id, "table": table,
+ "label": label.get("value") if isinstance(label, dict) and label.get("status") != "rejected"
+ else None,
+ "published": then if isinstance(then, int) else None, "current": now})
+ return out
+ except Exception: # noqa: BLE001
+ logger.exception("比对发布时的目录版本失败,本次不提醒")
+ return []
+
+
+__all__ = ["IMPACTS", "catalog_drift", "catalog_versions_for", "graph_sql_tables", "sql_table_keys", "table_impact",
+ "template_refs"]
diff --git a/backend/tests/test_catalog_impact.py b/backend/tests/test_catalog_impact.py
new file mode 100644
index 00000000..7e07e2af
--- /dev/null
+++ b/backend/tests/test_catalog_impact.py
@@ -0,0 +1,185 @@
+"""目录的影响面:改了一张表的目录,哪些已发布、受管的模板会受影响(阶段 4A)。
+
+GET /api/datasources/{id}/catalog/{table}/impact,每个模板按它当前的已发布版本算(不是画布上的草稿):
+- 调用工具节点调用这个源的 db_query__,SQL 里用到了这张表:直接引用。表名的取法和证据台账同一个口径
+ (evidence.sql_tables,按 name_key 对到表结构上)。
+- Agent 节点(协作成员也算)绑定了这个源的查询工具:可能涉及——SQL 是运行时写的,静态看不出用不用这张表。
+- 合并查询节点按它的输入追溯:输入里有直接引用的,它也算直接引用,并写明经由哪个输入。
+- 没发布过的草稿、别的源的同名表、只在未发布草稿里用到的,都不算。
+"""
+from __future__ import annotations
+
+import uuid
+from types import SimpleNamespace
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+
+from app.data.engine import engines
+from app.data.introspect import introspect
+# 模型要在建表之前注册进 metadata:conftest 的建表只导入了 app.db.base,这个文件单独跑时没有别的模块先导入它们
+from app.db import models # noqa: F401
+
+_CACHE: dict[str, dict] = {}
+
+
+async def _schema(path: str) -> dict:
+ if path not in _CACHE:
+ probe = SimpleNamespace(id=f"probe-{uuid.uuid4().hex[:6]}", name="scenic", kind="sqlite", database=path,
+ host=None, port=None, username=None, password=None, options={}, readonly=True,
+ description="", schema_cache={}, origin="manual")
+ try:
+ _CACHE[path] = await introspect(probe)
+ finally:
+ await engines.invalidate(probe.id)
+ return _CACHE[path]
+
+
+def _node(nid, ntype, label=None, **config):
+ return {"id": nid, "type": ntype, "data": {"label": label or nid, "config": config}}
+
+
+def _graph(*nodes):
+ nodes = [_node("start", "input"), *nodes, _node("out", "output", fields=[])]
+ return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
+
+
+def _query(nid, source, sql, label=None):
+ return _node(nid, "tool", label, tool=f"db_query__{source}", args={"sql": sql})
+
+
+@pytest.fixture
+async def world(scenic_db):
+ """一个景区数据源、另一个同结构的源,和几份模板;用完全部删掉。"""
+ from app.db.base import SessionLocal
+ from app.db.models import DataSource, Workflow, WorkflowVersion
+
+ tag = uuid.uuid4().hex[:8]
+ src, other = f"scenic_{tag}", f"mirror_{tag}"
+ created: list[str] = []
+ async with SessionLocal() as session:
+ rows = []
+ for name in (src, other):
+ row = DataSource(name=name, kind="sqlite", database=scenic_db, readonly=True)
+ row.schema_cache = await _schema(scenic_db)
+ session.add(row)
+ rows.append(row)
+ await session.commit()
+
+ async def template(name, versions, *, published=None, level="published"):
+ wf = Workflow(name=f"{name}-{tag}", graph=versions[-1], version=len(versions),
+ status=level if published else "draft", published_version=published)
+ session.add(wf)
+ await session.flush()
+ for i, g in enumerate(versions, start=1):
+ session.add(WorkflowVersion(workflow_id=wf.id, version=i, graph=g,
+ level=level if published == i else None))
+ created.append(wf.id)
+ return wf
+
+ direct = await template("入园日报", [_graph(_query("q", src, "SELECT COUNT(*) FROM main.VISITS v WHERE v.status = 1",
+ "查询入园人数"))], published=1)
+ agent = await template("入园分析", [_graph(_node("ask", "agent", "分析入园", prompt="分析",
+ tools=[f"db_query__{src}", f"db_schema__{src}"]))],
+ published=1, level="governed")
+ team = await template("协作分析", [_graph(_node("team", "supervisor", "协作", agents=[
+ {"name": "analyst", "tools": [f"db_query__{src}"]}]))], published=1)
+ merged = await template("渠道合并", [_graph(
+ _query("q1", src, "SELECT id, park_id FROM visits", "入园"),
+ _query("q2", src, "SELECT id, channel_id FROM orders", "订单"),
+ _node("m", "merge", "合并入园和订单", inputs={"a": "q1", "b": "q2"}, sql="SELECT * FROM a JOIN b ON a.id = b.id"),
+ _node("m2", "merge", "再合并", inputs={"x": "m"}, sql="SELECT * FROM x"),
+ )], published=1)
+ # 不算的:草稿、别的源的同名表、只在未发布的新版本里用到
+ await template("草稿", [_graph(_query("q", src, "SELECT * FROM visits"))])
+ await template("镜像", [_graph(_query("q", other, "SELECT * FROM visits"))], published=1)
+ await template("后来才用", [_graph(_query("q", src, "SELECT * FROM orders")),
+ _graph(_query("q", src, "SELECT * FROM visits"))], published=1)
+ # 已发布的是旧版本:画布上后来删掉了这张表,影响面仍按已发布的那一版算
+ stale = await template("改过画布", [_graph(_query("q", src, "SELECT * FROM visits")),
+ _graph(_query("q", src, "SELECT * FROM orders"))], published=1)
+ await session.commit()
+ ids = SimpleNamespace(source=rows[0].id, other=rows[1].id, src=src, direct=direct.id, agent=agent.id,
+ team=team.id, merged=merged.id, stale=stale.id)
+ yield ids
+ async with SessionLocal() as session:
+ for wid in created:
+ if (wf := await session.get(Workflow, wid)) is not None:
+ await session.delete(wf)
+ for sid in (ids.source, ids.other):
+ if (row := await session.get(DataSource, sid)) is not None:
+ await session.delete(row)
+ await session.commit()
+
+
+async def _impact(source_id: str, table: str) -> dict:
+ from app.main import app
+
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
+ r = await client.get(f"/api/datasources/{source_id}/catalog/{table}/impact")
+ assert r.status_code == 200, r.text
+ return r.json()
+
+
+async def test_impact_classifies_direct_possible_and_merge(world):
+ out = await _impact(world.source, "visits")
+ assert out["table"] == "visits"
+ by_id = {t["workflow_id"]: t for t in out["templates"]}
+ assert set(by_id) == {world.direct, world.agent, world.team, world.merged, world.stale}
+
+ direct = by_id[world.direct]
+ assert direct["impact"] == "direct" and direct["version"] == 1 and direct["level"] == "published"
+ assert [(n["node_id"], n["label"], n["impact"]) for n in direct["nodes"]] == [("q", "查询入园人数", "direct")]
+
+ agent = by_id[world.agent]
+ assert agent["impact"] == "possible" and agent["level"] == "governed"
+ assert [(n["node_id"], n["impact"]) for n in agent["nodes"]] == [("ask", "possible")]
+ assert by_id[world.team]["impact"] == "possible"
+ assert by_id[world.team]["nodes"][0]["member"] == "analyst"
+
+ merged = by_id[world.merged]
+ assert merged["impact"] == "direct"
+ nodes = {n["node_id"]: n for n in merged["nodes"]}
+ assert set(nodes) == {"q1", "m", "m2"} # 查订单的 q2 不涉及这张表
+ assert nodes["m"]["impact"] == "direct" and nodes["m"]["via"] == [{"node_id": "q1", "label": "入园", "alias": "a"}]
+ # 合并的合并:一路追到底
+ assert nodes["m2"]["impact"] == "direct" and nodes["m2"]["via"] == [{"node_id": "m", "label": "合并入园和订单",
+ "alias": "x"}]
+ # 直接引用的排在前面
+ impacts = [t["impact"] for t in out["templates"]]
+ assert impacts == sorted(impacts, key=lambda i: i != "direct")
+
+
+async def test_impact_of_another_table(world):
+ out = await _impact(world.source, "orders")
+ by_id = {t["workflow_id"]: t for t in out["templates"]}
+ # 已发布的旧版本只查了入园记录:画布上后来改成查订单,不算
+ assert world.stale not in by_id
+ assert by_id[world.merged]["impact"] == "direct"
+ assert {n["node_id"] for n in by_id[world.merged]["nodes"]} == {"q2", "m", "m2"}
+ # 只绑了查询工具的 Agent 对每张表都是「可能涉及」
+ assert by_id[world.agent]["impact"] == "possible"
+ assert world.direct not in by_id
+
+
+async def test_impact_unknown_table_is_404(world):
+ from app.main import app
+
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
+ r = await client.get(f"/api/datasources/{world.source}/catalog/ghost_table/impact")
+ assert r.status_code == 404
+
+
+def test_graph_sql_tables_uses_the_evidence_rules():
+ """发布时记目录版本和影响面共用这一份:只看调用工具节点写死的 SQL,表名按 name_key 对到表结构上。"""
+ from app.data.catalog_impact import graph_sql_tables
+ from app.engine.schema import GraphSpec
+
+ spec = GraphSpec.model_validate(_graph(
+ _query("q1", "shop", "SELECT * FROM main.Orders o JOIN order_items i ON i.order_id = o.id -- FROM ghost"),
+ _query("q2", "shop", "WITH t AS (SELECT 1) SELECT * FROM t, customers"),
+ _query("q3", "other", "SELECT * FROM orders"),
+ _node("ask", "agent", tools=["db_query__shop"]),
+ ))
+ tables = {"shop": {"orders": {}, "order_items": {}, "customers": {}}, "other": {"orders": {}}}
+ assert graph_sql_tables(spec, tables) == {"shop": {"orders", "order_items", "customers"}, "other": {"orders"}}
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 237d5778..572ed88e 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -1,5 +1,5 @@
import type {
- ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogDetail, CatalogDraftOut, CatalogList, CatalogNotes,
+ ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogDetail, CatalogDraftOut, CatalogImpact, CatalogList, CatalogNotes,
CatalogPatchPreview, CatalogPatchSubmit, CatalogReviewAction, CommitOut, CurrentRecipe, EditPreview, EditRequest, ImportRecord,
ManifestOut, PurgeRawBody, PurgeRawOut, QuestionAnswer, Recipe, RedraftRulesOut, RemovePeriodBody, RemovePeriodOut,
RevokeAcceptanceBody, RevokeAcceptanceOut, SnapshotOut, Staging,
@@ -669,6 +669,9 @@ export const api = {
/** 单项审阅。path:表级项写字段名(grain);列级项 columns.<列名>.<字段>;关系 relations.<编号> */
review: (sourceId: string, table: string, body: { path: string; action: CatalogReviewAction; if_version: number }) =>
post
+ {T.error}:{humanizeError(error).title}
+
+
+ {compact ? T.afterSaveNone : T.none}
+ {T.afterSave(list.length)}
+ {shown.map((t) =>
+ {compact && list.length > shown.length && (
+
+ )}
+
+ {t.nodes.map((n) => (
+
+ )}
+ {CATALOG_IMPACT_TEXT.title}
+ {CATALOG_IMPACT_TEXT.hint}
+