From 958e096e56a326538e05b29cc94e7cbf3aa12d91 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 3 Oct 2026 11:52:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=204A=20=E5=BD=B1=E5=93=8D=E9=9D=A2=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=8E=A5=E5=8F=A3=E5=88=97=E5=87=BA=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E6=9F=90=E5=BC=A0=E8=A1=A8=E7=9A=84=E5=B7=B2=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E5=92=8C=E5=8F=97=E7=AE=A1=E6=A8=A1=E6=9D=BF=EF=BC=8C=E5=8C=BA?= =?UTF-8?q?=E5=88=86=E7=9B=B4=E6=8E=A5=E5=BC=95=E7=94=A8=E5=92=8C=E5=8F=AF?= =?UTF-8?q?=E8=83=BD=E6=B6=89=E5=8F=8A=E3=80=81=E5=90=88=E5=B9=B6=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E6=8C=89=E8=BE=93=E5=85=A5=E8=BF=BD=E6=BA=AF=EF=BC=8C?= =?UTF-8?q?=E8=A1=A8=E8=AF=A6=E6=83=85=E5=92=8C=E4=BF=9D=E5=AD=98=E7=BA=A0?= =?UTF-8?q?=E6=AD=A3=E5=90=8E=E9=83=BD=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/catalog.py | 15 +- backend/app/data/catalog_impact.py | 289 +++++++++++++++++++++ backend/tests/test_catalog_impact.py | 185 +++++++++++++ frontend/src/api/client.ts | 5 +- frontend/src/components/CatalogImpact.tsx | 122 +++++++++ frontend/src/lib/terms.ts | 24 ++ frontend/src/pages/catalog/TableDetail.tsx | 13 +- frontend/src/run/CatalogPatchCard.tsx | 4 +- frontend/src/types.ts | 31 +++ scripts/check-catalog.mjs | 48 ++++ scripts/check-stream.mjs | 11 +- 11 files changed, 741 insertions(+), 6 deletions(-) create mode 100644 backend/app/data/catalog_impact.py create mode 100644 backend/tests/test_catalog_impact.py create mode 100644 frontend/src/components/CatalogImpact.tsx 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(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}/review`, body), + /** 影响面:引用这张表的已发布、受管模板(直接引用 / 可能涉及,合并查询按输入追溯) */ + impact: (sourceId: string, table: string, opts?: RequestOptions) => + get(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}/impact`, opts), /** 目录修改提案的预览:对着当前目录重算改前、改后,不写库(保存遇到 409 后重新载入用) */ patchPreview: (sourceId: string, table: string, changes: CatalogPatchSubmit[]) => post( diff --git a/frontend/src/components/CatalogImpact.tsx b/frontend/src/components/CatalogImpact.tsx new file mode 100644 index 00000000..97542951 --- /dev/null +++ b/frontend/src/components/CatalogImpact.tsx @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Link } from 'react-router-dom' +import { GitBranch } from 'lucide-react' +import clsx from 'clsx' +import { api } from '../api/client' +import { humanizeError } from '../lib/errors' +import { CATALOG_IMPACT_TEXT as T, WORKFLOW_STATUS_LABEL } from '../lib/terms' +import type { CatalogImpact, CatalogImpactNode, CatalogImpactTemplate } from '../types' +import { Skeleton } from './ui' + +// =========================================================================== +// 影响面:哪些已发布、受管的模板引用这张表(GET /catalog/{table}/impact)。数据目录的表详情里一栏,助手的 +// 「建议更新数据目录」卡片保存之后也列一遍——改了目录,人要知道哪些正式出具的数可能跟着变。 +// +// 「直接引用」和「可能涉及」分开写:前者 SQL 里写着这张表,后者是 Agent 运行时自己写 SQL,静态看不出。 +// 合并查询写明经由哪个输入。模板名点进去是画布(定位到那个节点)。 +// =========================================================================== + +const SHOWN = 5 + +export function CatalogImpactList({ sourceId, table, refreshKey, compact = false }: { + sourceId: string + table: string + /** 变了就重新统计(表详情传目录版本:保存之后刷新) */ + refreshKey?: number | string + /** 卡片里的紧凑版:只列前几个模板,节点收成一行 */ + compact?: boolean +}) { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [all, setAll] = useState(false) + const seq = useRef(0) + + const load = useCallback(() => { + const mine = ++seq.current + setError(null) + api.dataCatalog.impact(sourceId, table) + .then((d) => { if (mine === seq.current) setData(d) }) + .catch((e) => { if (mine === seq.current) setError(e) }) + }, [sourceId, table]) + + useEffect(() => { + setData(null) + setAll(false) + load() + }, [load, refreshKey]) + + if (error && !data) { + return ( +

+ {T.error}:{humanizeError(error).title} + +

+ ) + } + if (!data) return
+ + const list = data.templates + if (!list.length) { + return ( +

+ {compact ? T.afterSaveNone : T.none} +

+ ) + } + const shown = all || !compact ? list : list.slice(0, SHOWN) + return ( +
+ {compact &&

{T.afterSave(list.length)}

} +
    + {shown.map((t) => )} +
+ {compact && list.length > shown.length && ( + + )} +
+ ) +} + +function ImpactBadge({ impact }: { impact: 'direct' | 'possible' }) { + return ( + + {T.impact[impact]} + + ) +} + +function nodeText(n: CatalogImpactNode): string { + const who = n.member ? `「${n.label}」${T.member(n.member)}` : `「${n.label}」` + const via = n.via?.length ? `(${T.via(n.via.map((v) => `「${v.label}」`).join('、'))})` : '' + return `${who}${via}` +} + +function TemplateRow({ t, compact }: { t: CatalogImpactTemplate; compact: boolean }) { + const first = t.nodes.find((n) => n.impact === t.impact) ?? t.nodes[0] + const href = `/studio/${encodeURIComponent(t.workflow_id)}${first ? `?focus=${encodeURIComponent(first.node_id)}` : ''}` + return ( +
  • +
    + + {t.name} + {T.version(t.version)} + {WORKFLOW_STATUS_LABEL[t.level] ?? t.level} + +
    + {!compact && ( +
      + {t.nodes.map((n) => ( +
    • + {nodeText(n)} + {T.impact[n.impact]} +
    • + ))} +
    + )} +
  • + ) +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 40bee922..d3e14c11 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -2187,3 +2187,27 @@ export const CATALOG_PATCH_TEXT = { whereRelationNew: (to: string) => `新增指向 ${to} 的关联关系`, sourceOf: (source: string) => `数据源「${source}」`, } + +/** + * 影响面:哪些已发布、受管的模板引用这张表。按每个模板当前的已发布版本统计(正式运行用的是它)。 + * 「直接引用」:查询的 SQL 里写着这张表;「可能涉及」:Agent 绑定了这个数据源的查询工具,SQL 运行时才生成。 + */ +export const CATALOG_IMPACT_TEXT = { + title: '引用这张表的模板', + hint: '按每个模板当前的已发布版本统计', + none: '没有已发布或受管的模板引用这张表', + count: (n: number) => `${formatNumber(n)} 个模板`, + impact: { direct: '直接引用', possible: '可能涉及' } as Record<'direct' | 'possible', string>, + impactHint: { + direct: '查询的 SQL 中写着这张表', + possible: 'Agent 绑定了这个数据源的查询工具,SQL 在运行时生成,可能用到这张表', + } as Record<'direct' | 'possible', string>, + version: (v: number) => `v${v}`, + via: (labels: string) => `经由输入${labels}`, + member: (name: string) => `成员「${name}」`, + afterSave: (n: number) => `${formatNumber(n)} 个已发布模板引用这张表,下次正式运行时会提示数据目录有变化:`, + afterSaveNone: '没有已发布或受管的模板引用这张表', + more: (n: number) => `另有 ${formatNumber(n)} 个`, + error: '无法统计引用这张表的模板', + retry: '重试', +} diff --git a/frontend/src/pages/catalog/TableDetail.tsx b/frontend/src/pages/catalog/TableDetail.tsx index 614d5e68..0016dd7c 100644 --- a/frontend/src/pages/catalog/TableDetail.tsx +++ b/frontend/src/pages/catalog/TableDetail.tsx @@ -11,9 +11,10 @@ import { DeleteButton, EmptyState, ErrorState, Notice, Skeleton, Spinner, confir import { useLeaveGuard } from '../../lib/leave' import { formatDateTime, formatNumber } from '../../lib/format' import { - CATALOG_CARDINALITY_LABEL, CATALOG_KIND_HINT, CATALOG_KIND_LABEL, CATALOG_TABLE_FIELD_HINT, CATALOG_TABLE_FIELD_LABEL, - CATALOG_TEXT as CT, + CATALOG_CARDINALITY_LABEL, CATALOG_IMPACT_TEXT, CATALOG_KIND_HINT, CATALOG_KIND_LABEL, CATALOG_TABLE_FIELD_HINT, + CATALOG_TABLE_FIELD_LABEL, CATALOG_TEXT as CT, } from '../../lib/terms' +import { CatalogImpactList } from '../../components/CatalogImpact' import { CountBadges, ItemMark, StatusChip, StatusLegend, SystemNotesNotice } from './parts' import type { ReviewTarget } from './parts' import { ColumnTable, Value } from './ColumnTable' @@ -382,6 +383,14 @@ export function TableDetail({ sourceId, table, rows, drafted, prev, next, onOpen onChange={setRelations} /> : } + +
    +
    +

    {CATALOG_IMPACT_TEXT.title}

    + {CATALOG_IMPACT_TEXT.hint} +
    + +
    diff --git a/frontend/src/run/CatalogPatchCard.tsx b/frontend/src/run/CatalogPatchCard.tsx index 867592a7..8fac3074 100644 --- a/frontend/src/run/CatalogPatchCard.tsx +++ b/frontend/src/run/CatalogPatchCard.tsx @@ -4,6 +4,7 @@ import { BookMarked, Check, ExternalLink, RotateCw } from 'lucide-react' import clsx from 'clsx' import { ApiError, api } from '../api/client' import { Notice, Spinner, toast } from '../components/ui' +import { CatalogImpactList } from '../components/CatalogImpact' import { CATALOG_PATCH_TEXT as T, CATALOG_STATUS_LABEL } from '../lib/terms' import type { CatalogPatchChange } from '../types' import { @@ -15,7 +16,7 @@ import { // 改后和理由,人点「保存到数据目录」才写入——服务端从不自动写。 // // 保存带着提案对照的版本:别人在这之后改过这张表就 409,卡片写明「这张表刚被修改过」,「重新载入」按最新的 -// 目录重算改前和改后(预览接口,只读),人看过再保存。保存成功后给到数据目录那张表的入口。 +// 目录重算改前和改后(预览接口,只读),人看过再保存。保存成功后列出受影响的模板(影响面),给到数据目录那张表的入口。 // // 卡片的结局(已保存、已忽略、重新载入后的内容)记在模块级的表里:画布右栏在对话层和运行层之间切换会卸载 // 卡片,回来时不能又变回「未保存」,再点一次只会撞上 409。 @@ -151,6 +152,7 @@ export function CatalogPatchCard({ patch, turnId, dense = false }: { {T.open}

    + {saved && } ) : ( diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d41cc963..f1eb560f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2739,3 +2739,34 @@ export interface CatalogPatchPreview { /** 不合法的项的说明(目录刚被改过、列已删除……) */ problems: string[] } + +/** 影响面里模板的一个节点(服务端 catalog_impact.template_refs) */ +export interface CatalogImpactNode { + node_id: string + label: string + type: string + /** direct:SQL 里写着这张表;possible:Agent 运行时自己写 SQL,可能用到 */ + impact: 'direct' | 'possible' + /** 合并查询:经由哪些输入(别名 → 节点) */ + via?: { node_id: string; label: string; alias: string }[] + /** 协作节点:绑定了查询工具的成员 */ + member?: string +} + +/** 引用这张表的一个已发布或受管模板(按它当前的已发布版本算) */ +export interface CatalogImpactTemplate { + workflow_id: string + name: string + /** 当前的已发布版本 */ + version: number + level: 'published' | 'governed' + impact: 'direct' | 'possible' + nodes: CatalogImpactNode[] +} + +/** GET /datasources/{id}/catalog/{table}/impact */ +export interface CatalogImpact { + table: string + /** 直接引用的在前 */ + templates: CatalogImpactTemplate[] +} diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs index 32431142..697eec9b 100644 --- a/scripts/check-catalog.mjs +++ b/scripts/check-catalog.mjs @@ -395,6 +395,11 @@ function catalogHandlers(state, replies = {}) { json({ tables: state.tables[parts(key)[2]].filter((t) => !t.missing).map((t) => t.name), summary: '', synced_at: T0 })(route)], [/^GET \/datasources\/check-cat-[a-z]+\/catalog$/, (route, { key }) => json(listOf(state, parts(key)[2]))(route)], [/^POST \/datasources\/check-cat-[a-z]+\/catalog\/draft$/, reply('draft', draftReply(state))], + // 影响面:引用这张表的已发布、受管模板(只读)。没配的表答空 + [/^GET \/datasources\/check-cat-[a-z]+\/catalog\/[^/]+\/impact$/, reply('impact', (route, { key }) => { + const name = parts(key)[4] + return json({ table: name, templates: state.impact?.[name] ?? [] })(route) + })], [/^GET \/datasources\/check-cat-[a-z]+\/catalog\/[^/]+$/, reply('get', (route, { key }) => { const { src, t } = findTable(state, key) return t ? json(detailOf(src, t))(route) : json({ detail: `数据源中没有表 ${parts(key)[4]},可能已被删除或尚未探查结构` }, 404)(route) @@ -775,6 +780,49 @@ await section('数据目录 · 表详情与单项审阅', async () => { await close() }) +await section('数据目录 · 引用这张表的模板(影响面)', async () => { + const state = freshState() + state.impact = { + visits: [ + { workflow_id: 'wf-daily', name: '入园日报', version: 4, level: 'governed', impact: 'direct', nodes: [ + { node_id: 'q', label: '查询入园人数', type: 'tool', impact: 'direct' }, + { node_id: 'm', label: '合并入园和订单', type: 'merge', impact: 'direct', via: [{ node_id: 'q', label: '查询入园人数', alias: 'v' }] }, + ] }, + { workflow_id: 'wf-ask', name: '客流分析', version: 2, level: 'published', impact: 'possible', nodes: [ + { node_id: 'ask', label: '分析客流', type: 'agent', impact: 'possible' }, + ] }, + ], + } + const { page, sent, errors, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) }) + await openDetail(page, S1, 'visits') + const sec = page.locator('[data-catalog-section="impact"]') + await sec.locator('[data-catalog-impact="2"]').waitFor({ timeout: 3000 }) + const text = await sec.innerText() + check('表详情有「引用这张表的模板」一栏,写明按已发布版本统计', text.includes('引用这张表的模板') && text.includes('按每个模板当前的已发布版本统计')) + const daily = sec.locator('[data-impact-template="wf-daily"]') + check('直接引用:模板名、版本、受管、「直接引用」', ['入园日报', 'v4', '受管', '直接引用'].every((x) => text.includes(x)) + && await daily.getAttribute('data-impact-level') === 'direct') + check('合并查询写明经由哪个输入', (await daily.locator('[data-impact-node="m"]').innerText()).includes('经由输入「查询入园人数」')) + check('Agent 写「可能涉及」,悬停说明原因', (await sec.locator('[data-impact-template="wf-ask"] [data-impact="possible"]').getAttribute('title')).includes('运行时生成')) + check('模板名点进去是画布,定位到那个节点', (await daily.locator('a').first().getAttribute('href')) === '/studio/wf-daily?focus=q') + // 开发模式下 StrictMode 会把挂载时的取数做两遍,只查有没有取、没有写 + check('统计影响面只读', writes(sent, /impact/).length === 0 && count(sent, /\/visits\/impact$/) >= 1, + `${count(sent, /\/visits\/impact$/)} 次`) + // 没有模板引用的表:直说没有 + await openDetail(page, S1, 'orders') + check('没有模板引用:直说没有', (await page.locator('[data-catalog-section="impact"] [data-catalog-impact="0"]').innerText()) + .includes('没有已发布或受管的模板引用这张表')) + // 表详情保存之后(版本变了)重新统计 + const before = count(sent, /\/orders\/impact$/) + await page.locator('[data-catalog-edit]').click() + await page.locator('[data-input="grain"]').fill('一笔订单一行(检查)') + await page.locator('[data-catalog-save]').click() + await until(async () => count(sent, /\/orders\/impact$/) > before, 3000) + check('保存之后重新统计影响面', count(sent, /\/orders\/impact$/) > before, `${before} → ${count(sent, /\/orders\/impact$/)}`) + check('没有运行时报错', errors.length === 0, errors.join(' | ')) + await close() +}) + await section('数据目录 · 批量确认', async () => { const state = freshState() const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) }) diff --git a/scripts/check-stream.mjs b/scripts/check-stream.mjs index 530dbee9..4af14dc9 100644 --- a/scripts/check-stream.mjs +++ b/scripts/check-stream.mjs @@ -795,7 +795,12 @@ await section('建议更新数据目录:卡片的保存、忽略和 409(cata return route.fulfill({ json: detail(5) }) }) await c.page.route('**/api/datasources/src-scenic/catalog/visits/impact', (route) => - route.fulfill({ json: { table: 'visits', templates: [] } })) + route.fulfill({ json: { table: 'visits', templates: [ + { workflow_id: 'wf-daily', name: '入园日报', version: 4, level: 'governed', impact: 'direct', + nodes: [{ node_id: 'q', label: '查询入园人数', type: 'tool', impact: 'direct' }] }, + { workflow_id: 'wf-ask', name: '客流分析', version: 2, level: 'published', impact: 'possible', + nodes: [{ node_id: 'ask', label: '分析客流', type: 'agent', impact: 'possible' }] }, + ] } })) const card = c.page.locator('[data-catalog-patch="visits"]') check('卡片在:标题写「建议更新数据目录」', await card.count() === 1 && (await card.innerText()).includes('建议更新数据目录')) const codes = card.locator('[data-patch-change="columns.status.codes"]') @@ -830,6 +835,10 @@ await section('建议更新数据目录:卡片的保存、忽略和 409(cata check('保存成功:写明第几版,给到数据目录的入口', (await card.locator('[data-patch-done]').innerText()).includes('已保存到数据目录(第 5 版)') && (await card.locator('[data-patch-open]').getAttribute('href')) === '/data/catalog/src-scenic/visits') check('保存后不再有保存按钮', await card.locator('[data-patch-save]').count() === 0) + await card.locator('[data-catalog-impact="2"]').waitFor({ timeout: 3000 }) + const impact = await card.locator('[data-catalog-impact]').innerText() + check('保存后列出受影响的模板:直接引用、可能涉及', impact.includes('2 个已发布模板引用这张表') && impact.includes('入园日报') + && impact.includes('直接引用') && impact.includes('可能涉及'), impact.replace(/\s+/g, ' ')) check('没有运行时报错', c.errors.length === 0, c.errors.join(' | ')) await c.page.close()