From 8ed0bc23f7a9318d4264254fa1e938106a01396f Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 3 Oct 2026 10:15:52 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=202B=EF=BC=9A=E5=BC=95=E5=85=A5=20sqlglot?= =?UTF-8?q?=EF=BC=8CSQL=20=E6=A3=80=E6=9F=A5=E7=9A=84=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=B1=82=E2=80=94=E2=80=94=E6=8C=89=E6=95=B0=E6=8D=AE=E6=BA=90?= =?UTF-8?q?=E6=96=B9=E8=A8=80=E8=A7=A3=E6=9E=90=E3=80=81=E8=AE=A4=E8=A1=A8?= =?UTF-8?q?=E5=92=8C=E5=88=AB=E5=90=8D=E3=80=81=E8=A7=A3=E6=9E=90=E4=B8=8D?= =?UTF-8?q?=E4=BA=86=E4=B8=80=E5=BE=8B=E8=B7=B3=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/data/sqlcheck.py | 474 ++++++++++++++++++ backend/pyproject.toml | 4 + backend/requirements.lock | 2 + .../tests/fixtures/catalog/scenic_notes.py | 127 +++++ backend/tests/test_sqlcheck.py | 112 +++++ 5 files changed, 719 insertions(+) create mode 100644 backend/app/data/sqlcheck.py create mode 100644 backend/tests/fixtures/catalog/scenic_notes.py create mode 100644 backend/tests/test_sqlcheck.py diff --git a/backend/app/data/sqlcheck.py b/backend/app/data/sqlcheck.py new file mode 100644 index 00000000..e6cbed01 --- /dev/null +++ b/backend/app/data/sqlcheck.py @@ -0,0 +1,474 @@ +"""基于数据目录的 SQL 检查:按数据源方言把 SQL 解析成语法树,对照目录里记下的事实找出「跑得通、数却不对」的写法。 + +**为什么要有它。** 助手自查以前只核对列名在不在(copilot.sql_unknown_columns,正则)。列名全对的 SQL 照样会算错: +订单关联订单明细以后对订单金额求和(每笔订单按明细行数重复相加)、把每天的库存加成「一个月的库存」、对折扣率求 +平均、忘了筛掉作废的入园记录……这些错误数据库不会报,结果看起来也像回事。数据目录记下了判断它们要的事实(关系的 +基数、列的度量类型、有效记录条件、码值、业务日期),这里把两者对起来。 + +**七条规则,code 稳定**(界面、修复、出具都按它认): + +- fanout_sum:沿一对多方向关联之后,对「一」那一侧的度量列求和或计数(重复计算); +- stock_summed:对存量列求和,分组里却没有这张表的业务日期(跨期加总存量); +- join_unconfirmed:关联条件对不上目录里的任何关系; +- ratio_aggregated:对比率列直接求和或求平均; +- missing_valid_filter:表定义了有效记录条件,查询完全没涉及条件里的列; +- unknown_code:对有码值的列写了 = 或 IN,值不在码值表里; +- wrong_date_column:表定义了业务日期,查询却按这张表的另一个时间列分组或筛选。 + +**级别看依据。** 依据全是 confirmed / verified(人工确认、外键约束、数据剖析)时,fanout_sum、stock_summed 是 error, +其余是 warning;依据里有 proposed(命名推断、模型起草)就一律降为 info。join_unconfirmed 反过来看:对上推断的关系是 +info,什么都对不上是 warning,对上有确证的关系不报。rejected 的项一律视而不见(catalog.visible_notes),被驳回的关系 +既不算「对上了」,也不拿来判断基数。 + +**宁可漏报,不可误报。** error 会把助手搭的图打回去改、挡住受管级别的发布、让出具降档;误报一次,用户就会学会无视它。 +所以拿不准一律跳过:解析不了(语法错、方言特性、多条语句、不是查询)、模板里有控制语句、列对不上表(别名不认识、 +没加表前缀又有歧义、来自子查询)、关联条件里有 OR 或解析不了的列、分组写成表达式之后看不清粒度……都不报。 +写法上能想到的「其实没问题」的情形也放过:按明细主键分组、去重聚合、窗口函数、两边的列一起算、业务日期筛到某一天、 +CTE 或子查询里已经筛过有效记录。 + +这里不改写、不执行 SQL,也和 engine/direct_select.py 无关:那边刻意只用守卫的 SQLite 分词(证据下钻要和执行时的读法 +完全一致),这边是多方言的静态检查,读错了最多漏报一条提示。 + +用法:SqlChecker 按一个数据源建一次(目录读一次、关系图用到时才建、建了就复用),check(sql) 返回 SqlCheck 列表。 +助手自查、发布门禁、数据源查询工具三处共用它(load_checkers 从库里读目录,graph_checks 查一张图里写死的 SQL)。 +""" +from __future__ import annotations + +import logging +import re +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, field +from typing import Any + +from app.data import catalog + +logger = logging.getLogger(__name__) + +#: 规则编号,按严重程度大致排序 +CHECK_CODES = ("fanout_sum", "stock_summed", "join_unconfirmed", "ratio_aggregated", "missing_valid_filter", + "unknown_code", "wrong_date_column") +#: 结果的级别,从重到轻 +LEVELS = ("error", "warning", "info") +#: 依据有确证时报 error 的规则:它们算出来的数必然是错的。其余规则只是「很可能不对」 +_ERROR_CODES = frozenset({"fanout_sum", "stock_summed"}) +_STRONG = frozenset({"confirmed", "verified"}) +_STATUS_RANK = {"confirmed": 3, "verified": 2, "proposed": 1} + +#: 数据源类型 → sqlglot 方言。不认识的类型不查:拿别的方言硬读,读错了就是误报 +_DIALECTS = {"sqlite": "sqlite", "postgres": "postgres", "postgresql": "postgres", "mysql": "mysql", + "mariadb": "mysql", "oracle": "oracle"} + +#: 太长的 SQL 不查:解析耗时随长度增长,数据源查询工具每次执行都要跑一遍,不能拖慢查询 +MAX_SQL_CHARS = 20_000 +#: 一条 SQL 最多报这么多条:再多就是 SQL 整体有问题,逐条列出只会淹没重点 +MAX_CHECKS = 20 +_EXCERPT_MAX = 160 + +#: 模板占位({{ input.week }})换成这个标识符再解析:在引号里是一个认得出的字符串(码值检查跳过它), +#: 在引号外是一个不属于任何表的列名(各条规则都对不上它)。不换成数字 0:放在引号里的 0 会被当成码值去比 +_SENTINEL = "__agentlab_tpl__" +_TEMPLATE = re.compile(r"\{\{.*?\}\}", re.S) + +#: 名字看得出是时间的列:xxx_at、xxx_time、xxx_date、xxx_on、createdAt…(SQLite 的日期常存成文本,只看类型认不全) +_TEMPORAL_NAME = re.compile(r"(?:^|_)(?:at|time|date|day|dt|ts|on|timestamp|datetime)$|[a-z0-9](?:At|Time|Date|On)$", + re.IGNORECASE) +_TEMPORAL_TYPE = re.compile(r"DATE|TIME", re.IGNORECASE) + + +def dialect_of(kind: str | None) -> str | None: + """数据源类型对应的 sqlglot 方言;不支持的类型返回 None(不查)。""" + return _DIALECTS.get((kind or "").strip().lower()) + + +@dataclass(frozen=True) +class SqlCheck: + """一条检查结果。 + + message 给人看(界面、发布前检查、出具降档的原因),for_model 交回模型改写时用(写 SQL 里的真名和改法)。 + table 是 schema_cache 里的表名;column、relation_id、sql_excerpt 没有时不出现在 as_dict 里。 + """ + + code: str + level: str + message: str + for_model: str + table: str + column: str | None = None + relation_id: str | None = None + sql_excerpt: str | None = None + + def as_dict(self) -> dict[str, Any]: + out: dict[str, Any] = {"code": self.code, "level": self.level, "message": self.message, + "for_model": self.for_model, "table": self.table} + for key in ("column", "relation_id", "sql_excerpt"): + if (value := getattr(self, key)) is not None: + out[key] = value + return out + + +def _level(code: str, statuses: Iterable[str | None]) -> str: + """依据全有确证:error(fanout_sum、stock_summed)或 warning;有一项只是推断:info。""" + if all(s in _STRONG for s in statuses): + return "error" if code in _ERROR_CODES else "warning" + return "info" + + +# ========================================================================== +# 检查器:一个数据源一个 +# ========================================================================== + + +class SqlChecker: + """按一个数据源的方言、表结构和目录检查 SQL。 + + notes 是 {表名: 目录 notes},原样传(含被驳回的项):关系图要知道哪些关系被驳回了,免得按表结构又推回来; + 各条规则只看去掉驳回项之后的那份。没有目录(notes 为空)的源什么都不查——没有可对照的东西, + 也不让还没起草目录的部署多出一堆提示;这时 check 不解析 SQL,几乎没有开销。 + """ + + def __init__(self, *, kind: str | None, schema_cache: Mapping[str, Any] | None, + notes: Mapping[str, Mapping[str, Any]] | None, source_name: str | None = None) -> None: + self.dialect = dialect_of(kind) + self.schema_cache: Mapping[str, Any] = schema_cache or {} + self.source_name = source_name + self._raw = {str(k): v for k, v in (notes or {}).items() if isinstance(v, Mapping) and v} + self._visible = {k: catalog.visible_notes(v) for k, v in self._raw.items()} + self._visible_lower = {k.lower(): v for k, v in self._visible.items()} + self._edges: dict[str, list[catalog.JoinEdge]] | None = None + + @property + def enabled(self) -> bool: + """有没有可能报出东西:方言认得、有目录、探查过结构。""" + return bool(self.dialect and self._raw and (self.schema_cache.get("tables") or {})) + + # ---- 表、目录、关系 + + def table_meta(self, table: Any) -> tuple[str, Mapping[str, Any]] | None: + """SQL 里的一张表 → (schema_cache 的表名, 表结构)。带了别的 schema、库名的不认(宁可当作不认识)。""" + tables = self.schema_cache.get("tables") or {} + name, db = table.name, table.text("db") + if not name or table.text("catalog"): + return None + if db: + qualified = f"{db}.{name}" + if qualified in tables: + return qualified, tables[qualified] + schema = str(self.schema_cache.get("schema") or "") + if not schema or schema.lower() != db.lower(): + return None + key = catalog.resolve_table_name(self.schema_cache, name) + meta = tables.get(key) if key else None + return (key, meta) if key and isinstance(meta, Mapping) else None + + def notes_of(self, table: str) -> dict[str, Any]: + return self._visible.get(table) or self._visible_lower.get(table.lower()) or {} + + def edges_between(self, a: str, b: str) -> list[catalog.JoinEdge]: + """从 a 连到 b 的边(每条关系正反各一条)。关系图第一次用到时才建,之后复用。 + + 和助手挑表看到的是同一张图(catalog.relation_graph):目录里的关系,加上没起草过的表按外键、命名现推的; + 被驳回的关系不进图,也不会被现推回来。 + """ + if self._edges is None: + graph = catalog.relation_graph(self._raw, schema_cache=self.schema_cache) + index: dict[str, list[catalog.JoinEdge]] = {} + for table, edges in graph.items(): + index.setdefault(table.lower(), []).extend(edges) + self._edges = index + return [e for e in self._edges.get(a.lower(), []) if e.to_table.lower() == b.lower()] + + # ---- 检查 + + def check(self, sql: Any) -> list[SqlCheck]: + """检查一条 SQL。解析不了、不是单条查询、或者不支持的写法,返回空列表;从不抛异常。""" + if not self.enabled or not isinstance(sql, str): + return [] + text = sql.strip() + if not text or len(text) > MAX_SQL_CHARS or "{%" in text: + return [] + text = _TEMPLATE.sub(_SENTINEL, text) + try: + import sqlglot + from sqlglot import exp + from sqlglot.optimizer.scope import traverse_scope + + statements = [s for s in sqlglot.parse(text, read=self.dialect) if s is not None] + if len(statements) != 1 or not isinstance(statements[0], exp.Query): + return [] + root = statements[0] + scopes = traverse_scope(root) + except Exception: # noqa: BLE001 - 语法错、方言特性、sqlglot 不支持的写法:一律不查 + return [] + names = {c.name.lower() for c in root.find_all(exp.Column) if c.name} + found: list[SqlCheck] = [] + for scope in scopes: + if not isinstance(scope.expression, exp.Select): + continue + try: + found += _ScopeCheck(self, scope, statement_columns=names).run() + except Exception: # noqa: BLE001 - 规则本身的缺陷不能连累查询;这一段不报,记下来修 + logger.warning("SQL 检查跳过了一段查询", exc_info=True) + return _fold(found) + + def check_dicts(self, sql: Any) -> list[dict[str, Any]]: + return [c.as_dict() for c in self.check(sql)] + + +def check_sql(sql: Any, *, kind: str | None, schema_cache: Mapping[str, Any] | None, + notes: Mapping[str, Mapping[str, Any]] | None, source_name: str | None = None) -> list[SqlCheck]: + """只查一条时的便捷写法。要查多条(同一个源)就建一个 SqlChecker 复用:关系图只建一次。""" + return SqlChecker(kind=kind, schema_cache=schema_cache, notes=notes, source_name=source_name).check(sql) + + +def _fold(found: list[SqlCheck]) -> list[SqlCheck]: + """同一件事只报一次(规则、表、列、关系相同的留最重的那条),重的在前,最多 MAX_CHECKS 条。""" + kept: dict[tuple[Any, ...], SqlCheck] = {} + for check in found: + key = (check.code, check.table.lower(), (check.column or "").lower(), check.relation_id) + if key not in kept or LEVELS.index(check.level) < LEVELS.index(kept[key].level): + kept[key] = check + ordered = sorted(kept.values(), key=lambda c: LEVELS.index(c.level)) + return ordered[:MAX_CHECKS] + + +# ========================================================================== +# 一段 SELECT 里的分析 +# ========================================================================== + + +@dataclass +class _Table: + """一段 SELECT 里出现的一张实表:别名、schema_cache 表名、表结构、目录(去掉驳回项)。""" + + alias: str + name: str + meta: Mapping[str, Any] + notes: Mapping[str, Any] + order: int + #: FROM / JOIN 里写这张表的那个节点(摘录 SQL 用) + node: Any = None + columns: dict[str, str] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.columns = {str(c.get("name")).lower(): str(c.get("name")) + for c in self.meta.get("columns") or [] if isinstance(c, Mapping) and c.get("name")} + + def item(self, name: str) -> Mapping[str, Any] | None: + value = self.notes.get(name) + return value if isinstance(value, Mapping) and "value" in value else None + + def column_item(self, column: str, name: str) -> Mapping[str, Any] | None: + columns = self.notes.get("columns") if isinstance(self.notes.get("columns"), Mapping) else {} + items = columns.get(column) + if not isinstance(items, Mapping): + items = next((v for k, v in columns.items() if str(k).lower() == column.lower()), None) + value = items.get(name) if isinstance(items, Mapping) else None + return value if isinstance(value, Mapping) and "value" in value else None + + def label(self) -> str: + item = self.item("label") + return str(item["value"]) if item and isinstance(item.get("value"), str) and item["value"].strip() \ + else self.name + + def column_label(self, column: str) -> str: + item = self.column_item(column, "label") + return str(item["value"]) if item and isinstance(item.get("value"), str) and item["value"].strip() \ + else column + + def measure(self, column: str) -> tuple[str, str] | None: + """(度量类型, 状态)。""" + item = self.column_item(column, "measure") + return (str(item["value"]), str(item.get("status"))) if item else None + + def keys(self) -> tuple[list[str], str] | None: + """业务主键:目录写了用目录的(带状态),没写用表结构的主键(数据库约束,算有确证)。""" + item = self.item("keys") + if item and isinstance(item.get("value"), list) and item["value"]: + return [str(k) for k in item["value"]], str(item.get("status")) + pk = self.meta.get("primary_key") + return ([str(k) for k in pk], "verified") if isinstance(pk, list) and pk else None + + def business_date(self) -> tuple[str, str] | None: + """(业务日期列的真名, 状态)。列不在表结构里(写的是表达式、或者列已改名)就当没有。""" + item = self.item("business_date") + value = item.get("value") if item else None + column = value.get("column") if isinstance(value, Mapping) else None + if not isinstance(column, str) or column.lower() not in self.columns: + return None + return self.columns[column.lower()], str(item.get("status")) + + def temporal(self, column: str) -> bool: + meta = next((c for c in self.meta.get("columns") or [] + if isinstance(c, Mapping) and str(c.get("name")).lower() == column.lower()), None) + kind = str((meta or {}).get("type") or "") + return bool(_TEMPORAL_TYPE.search(kind) or _TEMPORAL_NAME.search(column)) + + +#: 一列解析到的位置:(表, 列的真名) +_Ref = tuple[_Table, str] + + +def _unparen(node: Any) -> Any: + from sqlglot import exp + + while isinstance(node, exp.Paren): + node = node.this + return node + + +def _conjuncts(node: Any) -> list[Any]: + """AND 连起来的各个条件(只拆最外层)。""" + from sqlglot import exp + + node = _unparen(node) + if isinstance(node, exp.And): + return _conjuncts(node.left) + _conjuncts(node.right) + return [node] if node is not None else [] + + +def _bare_column(node: Any) -> Any: + """剥掉不改变「这是哪一列」的外壳(括号、类型转换、COALESCE / IFNULL 给默认值、ROUND),剩下的是列就返回它。""" + from sqlglot import exp + + while True: + node = _unparen(node) + if isinstance(node, (exp.Cast, exp.TryCast, exp.Round)): + node = node.this + elif isinstance(node, exp.Coalesce) and all(isinstance(_unparen(e), exp.Literal) + for e in node.expressions): + node = node.this + else: + break + return node if isinstance(node, exp.Column) else None + + +class _ScopeCheck: + """一段 SELECT(根查询、CTE、子查询、UNION 的一支各算一段)对照目录检查。""" + + def __init__(self, checker: SqlChecker, scope: Any, *, statement_columns: set[str]) -> None: + from sqlglot import exp + + self.checker = checker + self.dialect = checker.dialect + self.scope = scope + self.select = scope.expression + self.statement_columns = statement_columns + self.tables: dict[str, _Table] = {} + #: 不是实表的来源(子查询、CTE、认不出的表):{别名: 它透出的列名,说不清就是 None} + self.opaque: dict[str, set[str] | None] = {} + for order, (alias, (node, source)) in enumerate(scope.selected_sources.items()): + key = str(alias).lower() + if isinstance(source, exp.Table): + found = checker.table_meta(source) + if found: + name, meta = found + self.tables[key] = _Table(key, name, meta, checker.notes_of(name), order, node) + continue + self.opaque[key] = None + else: + inner = getattr(source, "expression", None) + exposes = None + if isinstance(inner, exp.Select) and not any(isinstance(s, exp.Star) or + (isinstance(s, exp.Column) and s.is_star) + for s in inner.selects): + exposes = {n.lower() for n in inner.named_selects} + self.opaque[key] = exposes + # FROM / JOIN 里还有没进 selected_sources 的东西(表函数、LATERAL):没加前缀的列可能出自它们 + items = ([self.select.args["from_"].this] if self.select.args.get("from_") else []) + \ + [j.this for j in self.select.args.get("joins") or []] + if len(items) != len(scope.selected_sources): + self.opaque["\0"] = None + self.nodes = list(scope.walk()) + self.columns = [n for n in self.nodes if isinstance(n, exp.Column)] + self._matched: list[tuple[str, str, catalog.JoinEdge]] | None = None + self._join_checks: list[SqlCheck] = [] + + # ---- 工具 + + def excerpt(self, node: Any) -> str | None: + try: + text = node.sql(dialect=self.dialect) + except Exception: # noqa: BLE001 + return None + text = text.replace(_SENTINEL, "{{…}}") + return text if len(text) <= _EXCERPT_MAX else text[:_EXCERPT_MAX - 1] + "…" + + def resolve(self, column: Any) -> _Ref | None: + """列 → (表, 真名)。别名不认识、列不在那张表里、没加前缀又可能出自别处,都返回 None。""" + name = column.name.lower() if column.name else "" + if not name or column.is_star: + return None + qualifier = column.table.lower() if column.table else "" + if qualifier: + table = self.tables.get(qualifier) + return (table, table.columns[name]) if table and name in table.columns else None + if any(exposes is None or name in exposes for exposes in self.opaque.values()): + return None + hits = [t for t in self.tables.values() if name in t.columns] + return (hits[0], hits[0].columns[name]) if len(hits) == 1 else None + + def refs(self, node: Any) -> list[_Ref | None]: + """node 里每一列的解析结果(不进嵌套的子查询)。""" + from sqlglot import exp + + if node is None: + return [] + if isinstance(node, exp.Column): + return [self.resolve(node)] + return [self.resolve(c) for c in node.find_all(exp.Column) + if not any(isinstance(p, (exp.Subquery, exp.Select)) for p in _parents(c, stop=node))] + + def group_refs(self) -> list[_Ref | None]: + """分组里用到的列。GROUP BY 别名、GROUP BY 1 换成 SELECT 里对应的表达式(两种读法都算上,宁可多认)。""" + from sqlglot import exp + + group = self.select.args.get("group") + if group is None: + return [] + out = self.refs(group) + selects = self.select.selects + by_alias = {s.alias.lower(): s.this for s in selects if isinstance(s, exp.Alias) and s.alias} + for item in group.expressions: + item = _unparen(item) + if isinstance(item, exp.Literal) and not item.is_string and str(item.this).isdigit(): + index = int(item.this) - 1 + if 0 <= index < len(selects): + out += self.refs(selects[index].this if isinstance(selects[index], exp.Alias) + else selects[index]) + elif isinstance(item, exp.Column) and not item.table and item.name.lower() in by_alias: + out += self.refs(by_alias[item.name.lower()]) + return out + + def where(self) -> Any: + where = self.select.args.get("where") + return where.this if where is not None else None + + def aggregates(self, *kinds: type) -> list[tuple[Any, Any]]: + """(聚合节点, 参数):不含窗口函数、去重聚合、COUNT(*)、参数里套子查询的。""" + from sqlglot import exp + + out = [] + for node in self.nodes: + if not isinstance(node, kinds) or isinstance(node.parent, exp.Window): + continue + arg = node.this + if arg is None or isinstance(arg, (exp.Distinct, exp.Star)) or \ + any(isinstance(n, (exp.Subquery, exp.Select)) for n in arg.walk()): + continue + out.append((node, arg)) + return out + + def run(self) -> list[SqlCheck]: + return [] + + +def _parents(node: Any, *, stop: Any) -> Iterable[Any]: + """node 到 stop 之间(不含两端)的各层父节点。""" + parent = node.parent + while parent is not None and parent is not stop: + yield parent + parent = parent.parent + + +__all__ = ["CHECK_CODES", "LEVELS", "MAX_CHECKS", "MAX_SQL_CHARS", "SqlCheck", "SqlChecker", "check_sql", + "dialect_of"] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6d078465..d7af0048 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -30,6 +30,10 @@ dependencies = [ "jsonschema>=4.23", "beautifulsoup4>=4.12", "cryptography>=43.0", + # 数据目录的 SQL 检查(data/sqlcheck.py)按数据源方言解析 SQL。sqlglot 的大版本常改语法树节点和 + # 方言行为(节点改名、某个写法从解析成 Anonymous 变成专门的节点),规则是照着这些节点写的, + # 上限卡到下一个大版本——升级要重新锁、跑一遍 test_sqlcheck + "sqlglot>=30.21,<31", ] [project.optional-dependencies] diff --git a/backend/requirements.lock b/backend/requirements.lock index 04354cd9..ee31c969 100644 --- a/backend/requirements.lock +++ b/backend/requirements.lock @@ -261,6 +261,8 @@ soupsieve==2.9.2 # via beautifulsoup4 sqlalchemy==2.0.52 # via agentlab-backend (pyproject.toml) +sqlglot==30.21.0 + # via agentlab-backend (pyproject.toml) sqlite-vec==0.1.9 # via langgraph-checkpoint-sqlite sse-starlette==3.4.11 diff --git a/backend/tests/fixtures/catalog/scenic_notes.py b/backend/tests/fixtures/catalog/scenic_notes.py new file mode 100644 index 00000000..c17a8af4 --- /dev/null +++ b/backend/tests/fixtures/catalog/scenic_notes.py @@ -0,0 +1,127 @@ +"""景区业务库(scenic.py)的一份数据目录,给 SQL 检查(data/sqlcheck.py)的测试用。 + +只写检查要用到的几张表:订单与订单明细(一对多,外键)、入园记录(有效记录条件、码值、业务日期)、 +库存快照(存量)、会员等级(比率)、停车记录(两个时间列,业务日期按出场时间)、渠道(文字码值)。 +支付记录、会员这些表故意不写目录:关系要靠表结构现推(外键 verified、命名推断 proposed)。 + +notes(status) 里「检查拿来当依据的项」一律用给定的状态:confirmed / verified / proposed / rejected。 +来源跟着状态走(confirmed 记人工填写,verified 记外键或数据剖析,其余记模型起草),和真实目录里的组合一致。 +单独改某一项的状态用 override:{("orders", "columns.total_amount.measure"): "proposed"} 这样的路径。 +表名、数据全部虚构。 +""" +from __future__ import annotations + +import copy +from typing import Any + +from app.data import catalog +from app.data.catalog import make_item + +_SOURCE_OF = {"confirmed": "human", "verified": "profile", "proposed": "llm", "rejected": "llm"} + + +def _item(value: Any, status: str) -> dict[str, Any]: + return make_item(value, _SOURCE_OF[status], status) + + +def _rel(table: str, columns: list[str], to_table: str, to_columns: list[str], cardinality: str, + status: str) -> dict[str, Any]: + source = {"confirmed": "human", "verified": "fk"}.get(status, "name") + return {"id": catalog.relation_id(table, columns, to_table, to_columns), "columns": columns, "to_table": to_table, + "to_columns": to_columns, "cardinality": cardinality, "coverage": None, "source": source, + "status": status} + + +#: 订单明细 → 订单 这条关系的编号(断言 relation_id 用) +ORDER_ITEMS_TO_ORDERS = catalog.relation_id("order_items", ["order_id"], "orders", ["id"]) + + +def notes(status: str = "confirmed", override: dict[tuple[str, str], str] | None = None) -> dict[str, dict[str, Any]]: + """{表名: notes}。名称类的项(中文名)一律人工确认,只有检查的依据随 status 变。""" + s = status + out: dict[str, dict[str, Any]] = { + "orders": { + "label": _item("订单", "confirmed"), + "grain": _item("每笔订单一行", s), + "keys": _item(["id"], s), + "kind": _item("fact", s), + "business_date": _item({"column": "ordered_at", "rule": "按下单时间计"}, s), + "valid_filter": _item("status = 1", s), + "columns": { + "total_amount": {"label": _item("订单金额", "confirmed"), "unit": _item("元", s), + "measure": _item("flow", s)}, + "status": {"label": _item("订单状态", "confirmed"), "measure": _item("status", s), + "codes": _item({"1": "已支付", "2": "已退款"}, s)}, + "ordered_at": {"label": _item("下单时间", "confirmed")}, + }, + }, + "order_items": { + "label": _item("订单明细", "confirmed"), + "grain": _item("每笔订单的每个票种一行", s), + "keys": _item(["id"], s), + "columns": { + "amount": {"label": _item("明细金额", "confirmed"), "measure": _item("flow", s)}, + "qty": {"label": _item("张数", "confirmed"), "measure": _item("flow", s)}, + }, + "relations": [_rel("order_items", ["order_id"], "orders", ["id"], "many_to_one", s)], + }, + "visits": { + "label": _item("入园记录", "confirmed"), + "keys": _item(["id"], s), + "business_date": _item({"column": "visit_time", "rule": "按检票时间计"}, s), + "valid_filter": _item("status = 1", s), + "columns": { + "visitor_count": {"label": _item("入园人数", "confirmed"), "measure": _item("flow", s)}, + "status": {"label": _item("检票状态", "confirmed"), "codes": _item({"1": "有效", "0": "作废"}, s)}, + "visit_time": {"label": _item("检票时间", "confirmed")}, + }, + "relations": [_rel("visits", ["park_id"], "parks", ["id"], "many_to_one", s)], + }, + "inventory_snapshots": { + "label": _item("库存快照", "confirmed"), + "kind": _item("snapshot", s), + "business_date": _item({"column": "snapshot_date", "rule": "每天营业结束时盘点"}, s), + "columns": { + "on_hand": {"label": _item("在库数量", "confirmed"), "measure": _item("stock", s)}, + "snapshot_date": {"label": _item("盘点日期", "confirmed")}, + }, + }, + "member_levels": { + "label": _item("会员等级", "confirmed"), + "columns": {"discount_rate": {"label": _item("折扣率", "confirmed"), "measure": _item("ratio", s)}}, + }, + "parking_records": { + "label": _item("停车记录", "confirmed"), + "business_date": _item({"column": "exited_at", "rule": "按出场时间计费"}, s), + "columns": { + "fee": {"label": _item("停车费", "confirmed"), "measure": _item("flow", s)}, + "entered_at": {"label": _item("入场时间", "confirmed")}, + "exited_at": {"label": _item("出场时间", "confirmed")}, + }, + }, + "channels": { + "label": _item("渠道", "confirmed"), + "columns": {"channel_type": {"label": _item("渠道类型", "confirmed"), + "codes": _item({"线上直销": "官网和小程序", "线下": "窗口售票", + "分销": "旅行社和第三方平台"}, s)}}, + }, + } + for (table, path), wanted in (override or {}).items(): + out[table] = set_status(out[table], path, wanted) + return out + + +def set_status(table_notes: dict[str, Any], path: str, status: str) -> dict[str, Any]: + """改一项的状态(不改入参)。path:表级字段名、columns.<列>.<字段>,或 relations.<编号>。""" + notes_ = copy.deepcopy(table_notes) + parts = path.split(".") + if parts[0] == "relations": + target = next(r for r in notes_["relations"] if r["id"] == parts[1]) + elif parts[0] == "columns": + target = notes_["columns"][parts[1]][parts[2]] + else: + target = notes_[parts[0]] + target["status"] = status + target["source"] = {"confirmed": "human", "verified": "fk"}.get(status, "llm") if parts[0] == "relations" \ + else _SOURCE_OF[status] + return notes_ diff --git a/backend/tests/test_sqlcheck.py b/backend/tests/test_sqlcheck.py new file mode 100644 index 00000000..d52835bf --- /dev/null +++ b/backend/tests/test_sqlcheck.py @@ -0,0 +1,112 @@ +"""基于数据目录的 SQL 检查(data/sqlcheck.py):七条规则的正例、反例,级别随依据的状态变化,以及解析不了的写法。 + +用合成的景区业务库(tests/fixtures/catalog/scenic.py)和它的一份目录(scenic_notes.py)。检查只做有把握的判断: +解析不了、遇到不支持的写法、列对不上表,一律跳过不报——它的 error 会把图打回去改、挡住受管级别的发布、让出具降档, +误报的代价比漏报大。 +""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from app.data.engine import engines +from app.data.introspect import introspect +from app.data.sqlcheck import SqlChecker, check_sql, dialect_of +from tests.fixtures.catalog import scenic_notes + +_CACHE: dict[str, dict] = {} + + +@pytest.fixture +async def cache(scenic_db): + """景区库的表结构缓存(真实探查一次:带外键和唯一约束,关系现推要用)。每个测试进程只探查一次。""" + if scenic_db not in _CACHE: + source = SimpleNamespace(id="sqlcheck-scenic", name="scenic", kind="sqlite", database=scenic_db, host=None, + port=None, username=None, password=None, options={}, readonly=True, description="", + schema_cache={}, origin="manual") + try: + _CACHE[scenic_db] = await introspect(source) + finally: + await engines.invalidate(source.id) + return _CACHE[scenic_db] + + +def run(cache, sql, status="confirmed", override=None, kind="sqlite"): + checker = SqlChecker(kind=kind, schema_cache=cache, notes=scenic_notes.notes(status, override), + source_name="scenic") + return checker.check(sql) + + +def found(cache, sql, status="confirmed", override=None, kind="sqlite"): + """[(code, level, table, column)],按检查给出的顺序。""" + return [(c.code, c.level, c.table, c.column) for c in run(cache, sql, status, override, kind)] + + +def codes(cache, sql, status="confirmed", override=None): + return {c.code for c in run(cache, sql, status, override)} + + +def test_dialects(): + assert dialect_of("sqlite") == "sqlite" + assert dialect_of("postgres") == dialect_of("postgresql") == "postgres" + assert dialect_of("mysql") == dialect_of("mariadb") == "mysql" + assert dialect_of("oracle") == "oracle" + assert dialect_of("mssql") is None and dialect_of(None) is None + + +# -------------------------------------------------------------------------- +# 复杂写法:照样分析得出,或者跳过;不报错、不误报 +# -------------------------------------------------------------------------- + +CLEAN_COMPLEX = [ + # CTE + 窗口函数 + 子查询 + """WITH daily AS ( + SELECT substr(o.ordered_at, 1, 10) AS d, SUM(i.amount) AS amt + FROM orders o JOIN order_items i ON i.order_id = o.id + WHERE o.status = 1 GROUP BY substr(o.ordered_at, 1, 10)) + SELECT d, amt, SUM(amt) OVER (ORDER BY d) AS running, + (SELECT COUNT(*) FROM visits v WHERE v.status = 1) AS visits + FROM daily ORDER BY d""", + # UNION + "SELECT 'web' AS ch, SUM(total_amount) FROM orders WHERE status = 1 AND channel_id = 1 " + "UNION ALL SELECT 'box', SUM(total_amount) FROM orders WHERE status = 1 AND channel_id = 3", + # 相关子查询、EXISTS + "SELECT o.id FROM orders o WHERE o.status = 1 AND EXISTS (SELECT 1 FROM order_items i WHERE i.order_id = o.id)", + # 引号括起来的驼峰标识符 + 'SELECT t.tag_name, COUNT(*) FROM member_tag_links l JOIN "memberTags" t ON t.id = l."memberTagId" ' + "GROUP BY t.tag_name", +] + + +@pytest.mark.parametrize("sql", CLEAN_COMPLEX) +async def test_complex_but_clean_sql_reports_nothing(cache, sql): + assert run(cache, sql) == [] + + +@pytest.mark.parametrize("sql", [ + "SELECT FROM WHERE", + "SELEC total_amount FROM orders", + "SELECT SUM(o.total_amount FROM orders o JOIN order_items i ON i.order_id = o.id", + "SELECT 1; SELECT SUM(on_hand) FROM inventory_snapshots", + "DELETE FROM visits", + "", + " ", + # 方言特性:SQLite 读不了 Oracle 的 q 引号 + "SELECT SUM(on_hand) FROM inventory_snapshots WHERE q'[x]' = 'x'", + # 模板里的控制语句:拼出来是什么样说不清 + "SELECT SUM(on_hand) FROM inventory_snapshots {% if x %}WHERE snapshot_date = '2026-09-30'{% endif %}", + # 结构里没有的表、别名对不上的列 + "SELECT SUM(x.on_hand) FROM inventory_snapshots s", + "SELECT SUM(on_hand) FROM not_a_table", +]) +async def test_unparseable_or_unsupported_sql_is_skipped(cache, sql): + assert run(cache, sql) == [] + + +async def test_no_catalog_means_no_checks(cache): + sql = "SELECT SUM(o.total_amount) FROM orders o JOIN order_items i ON i.id = o.id" + assert SqlChecker(kind="sqlite", schema_cache=cache, notes={}).check(sql) == [] + assert check_sql(sql, kind="sqlite", schema_cache=cache, notes=None) == [] + # 没探查过结构:对不上表,一样不判 + assert check_sql(sql, kind="sqlite", schema_cache={}, notes=scenic_notes.notes()) == [] From 0e689c1b182b29d8fdcfeddc518c7605ace8adb3 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 3 Oct 2026 10:16:12 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=202B=EF=BC=9ASQL=20=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E7=9A=84=E4=B8=83=E6=9D=A1=E8=A7=84=E5=88=99=E2=80=94=E2=80=94?= =?UTF-8?q?=E4=B8=80=E5=AF=B9=E5=A4=9A=E9=87=8D=E5=A4=8D=E8=AE=A1=E7=AE=97?= =?UTF-8?q?=E3=80=81=E5=AD=98=E9=87=8F=E8=B7=A8=E6=9C=9F=E6=B1=82=E5=92=8C?= =?UTF-8?q?=E3=80=81=E5=85=B3=E8=81=94=E5=AF=B9=E4=B8=8D=E4=B8=8A=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E3=80=81=E6=AF=94=E7=8E=87=E6=B1=82=E5=92=8C=E3=80=81?= =?UTF-8?q?=E6=BC=8F=E7=AD=9B=E6=9C=89=E6=95=88=E8=AE=B0=E5=BD=95=E3=80=81?= =?UTF-8?q?=E7=A0=81=E5=80=BC=E4=B8=8D=E5=9C=A8=E8=A1=A8=E9=87=8C=E3=80=81?= =?UTF-8?q?=E7=94=A8=E9=94=99=E6=97=B6=E9=97=B4=E5=88=97=EF=BC=8C=E7=BA=A7?= =?UTF-8?q?=E5=88=AB=E9=9A=8F=E4=BE=9D=E6=8D=AE=E7=9A=84=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=8F=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/data/sqlcheck.py | 487 ++++++++++++++++++++++++++++++++- backend/tests/test_sqlcheck.py | 395 +++++++++++++++++++++++++- 2 files changed, 880 insertions(+), 2 deletions(-) diff --git a/backend/app/data/sqlcheck.py b/backend/app/data/sqlcheck.py index e6cbed01..8ef5f089 100644 --- a/backend/app/data/sqlcheck.py +++ b/backend/app/data/sqlcheck.py @@ -459,7 +459,457 @@ class _ScopeCheck: return out def run(self) -> list[SqlCheck]: - return [] + out: list[SqlCheck] = [] + if self.tables: + out += self.matched_join_checks() + out += self.fanout() + out += self.stock_summed() + out += self.ratio_aggregated() + out += self.missing_valid_filter() + out += self.unknown_code() + out += self.wrong_date_column() + return out + + # ---- 关联:认出每一对表的关联条件,对照目录里的关系 + + def matched_joins(self) -> list[tuple[str, str, catalog.JoinEdge]]: + """对上目录关系的关联:[(别名 x, 别名 y, 从 x 看过去的边)]。顺带记下 join_unconfirmed。""" + if self._matched is None: + self._matched = [] + self._analyze_joins() + return self._matched + + def matched_join_checks(self) -> list[SqlCheck]: + self.matched_joins() + return self._join_checks + + def _analyze_joins(self) -> None: + from sqlglot import exp + + groups: dict[tuple[str, str], dict[str, Any]] = {} + skip: set[str] = set() + seen: list[str] = [] + first = self.select.args.get("from_") + if first is not None: + seen.append(first.this.alias_or_name.lower()) + + def add(conds: list[Any], *, owner: str | None) -> None: + for cond in conds: + cond = _unparen(cond) + if not isinstance(cond, exp.EQ): + continue + left, right = _unparen(cond.left), _unparen(cond.right) + if not (isinstance(left, exp.Column) and isinstance(right, exp.Column)): + continue + a, b = self.resolve(left), self.resolve(right) + if a is None or b is None: + if owner is not None: + skip.add(owner) # 关联条件里有认不出的列:说不清这一连是按什么连的 + continue + if a[0].alias == b[0].alias: + continue + if a[0].order > b[0].order: + a, b = b, a + group = groups.setdefault((a[0].alias, b[0].alias), {"pairs": set(), "conds": []}) + group["pairs"].add((a[1].lower(), b[1].lower())) + group["conds"].append(cond) + + for join in self.select.args.get("joins") or []: + alias = join.this.alias_or_name.lower() + if join.args.get("method"): # NATURAL JOIN:按同名列连,不去猜 + skip.add(alias) + on, using = join.args.get("on"), join.args.get("using") + if on is not None: + conds = _conjuncts(on) + if any(isinstance(_unparen(c), exp.Or) for c in conds): + skip.add(alias) + add(conds, owner=alias) + elif using: + right = self.tables.get(alias) + for ident in using: + name = ident.name.lower() + lefts = [self.tables[s] for s in seen if s in self.tables and name in self.tables[s].columns] + if right is None or len(lefts) != 1 or name not in right.columns: + skip.add(alias) + break + left = lefts[0] + group = groups.setdefault((left.alias, right.alias), {"pairs": set(), "conds": []}) + group["pairs"].add((name, name)) + group["conds"].append(ident) + seen.append(alias) + # 逗号连接(以及显式 JOIN 外加的等值条件)写在 WHERE 里 + add(_conjuncts(self.where()), owner=None) + + for (x, y), group in groups.items(): + if x in skip or y in skip: + continue + a, b = self.tables[x], self.tables[y] + if a.name.lower() == b.name.lower(): + continue # 自己连自己:目录不记这种关系 + pairs = group["pairs"] + candidates = self.checker.edges_between(a.name, b.name) + matched = [e for e in candidates + if {(f.lower(), t.lower()) for f, t in zip(e.from_columns, e.to_columns)} <= pairs] + best = max(matched, key=lambda e: _STATUS_RANK.get(e.status, 0), default=None) + if best is not None: + self._matched.append((x, y, best)) + if not (a.notes or b.notes): + continue # 两张表都没写目录:没有可对照的,不判 + condition = " AND ".join(filter(None, (self.excerpt(c) for c in group["conds"]))) + if best is None: + self._join_checks.append(self._join_unknown(a, b, candidates, condition)) + elif best.status not in _STRONG: + self._join_checks.append(self._join_proposed(a, b, best, condition)) + + def _join_unknown(self, a: _Table, b: _Table, candidates: list[catalog.JoinEdge], condition: str) -> SqlCheck: + known = ";".join(dict.fromkeys(e.condition() for e in candidates)) + hint = f"目录里这两张表的关系是:{known}。照它改关联条件" if known else \ + "目录里没有这两张表之间的关系。确认这样连两边各是什么粒度、会不会一对多" + return SqlCheck( + code="join_unconfirmed", level="warning", table=b.name, sql_excerpt=condition or None, + message=(f"「{a.label()}」与「{b.label()}」的关联条件对不上数据目录中的任何关系,可能连错了列。" + "请对照数据目录修改关联条件,或在数据目录中补充并确认这条关系"), + for_model=(f"{a.name} 和 {b.name} 按 {condition or '这组条件'} 关联,数据目录里没有对应的关系。{hint};" + "确实要这样连的话,避免一对多关联之后对「一」那一侧的列求和")) + + def _join_proposed(self, a: _Table, b: _Table, edge: catalog.JoinEdge, condition: str) -> SqlCheck: + return SqlCheck( + code="join_unconfirmed", level="info", table=b.name, relation_id=edge.relation_id, + sql_excerpt=condition or None, + message=(f"「{a.label()}」与「{b.label()}」按推断的关系关联({edge.condition()}),这条关系尚未确认。" + "请在数据目录中确认"), + for_model=(f"{a.name} 和 {b.name} 的关联依据的是推断出来的关系({edge.condition()}),没人确认过。" + "核对两边的列确实对应、基数和你以为的一致")) + + # ---- fanout_sum + + def _fanned(self, start: str) -> tuple[str, str, list[catalog.JoinEdge]] | None: + """start 这张表会不会被关联放大:沿多对一、一对一往外走,碰到一条一对多的边就会。返回 (V, W, 路径)。 + + 按 W 的业务主键分组时,每组只有一条 W:这条边不放大,接着往外找。对不上关系的关联不知道基数,不往外走。 + """ + adjacency: dict[str, list[tuple[str, catalog.JoinEdge]]] = {} + for x, y, edge in self.matched_joins(): + adjacency.setdefault(x, []).append((y, edge)) + adjacency.setdefault(y, []).append((x, edge.reversed())) + grouped = {(r[0].alias, r[1].lower()) for r in self.group_refs() if r} + # 反连接(LEFT JOIN 明细 … WHERE 明细.id IS NULL):留下的是没有明细的订单,每条订单至多一行 + absent = {r[0].alias for c in _conjuncts(self.where()) if self._is_null(c) for r in self.refs(c) if r} + visited = {start} + queue: list[tuple[str, list[catalog.JoinEdge]]] = [(start, [])] + while queue: + here, path = queue.pop(0) + for there, edge in adjacency.get(here, []): + if there in visited: + continue + keys = self.tables[there].keys() + collapsed = there in absent or (bool(keys) and all((there, k.lower()) in grouped for k in keys[0])) + if edge.cardinality == "one_to_many" and not collapsed: + return here, there, path + [edge] + if edge.cardinality in ("many_to_one", "one_to_one") or collapsed: + visited.add(there) + queue.append((there, path + [edge])) + return None + + @staticmethod + def _is_null(cond: Any) -> bool: + """col IS NULL(不含 IS NOT NULL)。""" + from sqlglot import exp + + cond = _unparen(cond) + return isinstance(cond, exp.Is) and isinstance(_unparen(cond.expression), exp.Null) and \ + isinstance(_unparen(cond.this), exp.Column) + + def fanout(self) -> list[SqlCheck]: + from sqlglot import exp + + if not self.matched_joins(): + return [] + out = [] + for node, arg in self.aggregates(exp.Sum, exp.Count): + refs = self.refs(arg) + if not refs or any(r is None for r in refs) or len({r[0].alias for r in refs}) != 1: + continue + table = refs[0][0] + basis = self._fanout_basis(table, [r[1] for r in refs], counting=isinstance(node, exp.Count)) + if basis is None: + continue + cause = self._fanned(table.alias) + if cause is None: + continue + column, status = basis + via, many, path = cause + out.append(self._fanout_check(node, table, column, self.tables[via], self.tables[many], path, + [status] + [e.status for e in path], counting=isinstance(node, exp.Count))) + return out + + def _fanout_basis(self, table: _Table, columns: list[str], *, counting: bool) -> tuple[str, str] | None: + """参数里能说明「这是一张表自己的数」的那一列和它的依据:求和看度量类型(流量、存量), + 计数另外认标识列和业务主键(数订单数却数成了明细行数)。""" + keys = table.keys() + for column in columns: + measure = table.measure(column) + wanted = ("flow", "stock", "identifier") if counting else ("flow", "stock") + if measure and measure[0] in wanted: + return column, measure[1] + if counting and keys and column.lower() in {k.lower() for k in keys[0]}: + return column, keys[1] + return None + + def _fanout_check(self, node: Any, table: _Table, column: str, via: _Table, many: _Table, + path: list[catalog.JoinEdge], statuses: list[str], *, counting: bool) -> SqlCheck: + what = "计数" if counting else "求和" + excerpt = self.excerpt(node) + if via.alias == table.alias: + head = f"「{table.label()}」关联「{many.label()}」是一对多," + model_head = f"{table.name} 关联 {many.name} 是一对多(一行 {table.name} 对多行 {many.name})," + else: + head = f"「{via.label()}」关联「{many.label()}」是一对多,「{table.label()}」的每一行会随之重复," + model_head = (f"{via.name} 关联 {many.name} 是一对多,经 {via.name} 连过去之后 {table.name} 的每一行" + f"都按 {many.name} 的行数重复,") + # 直接连的:常见的本意是「明细的数」,改法之一是换成对明细求和;隔一层连的(明细经订单连支付记录), + # 本意是明细自己的数,要么先把支付记录汇总,要么根本不该连它 + if counting: + fix = f"请改为对「{table.label()}」去重计数,或先在子查询中把「{many.label()}」汇总后再关联" + model_fix = (f"改成 COUNT(DISTINCT {table.name} 的主键),或者先在子查询里把 {many.name} 汇总成每条 " + f"{via.name} 一行再关联") + elif via.alias == table.alias: + fix = (f"请先在子查询中把「{many.label()}」按「{table.label()}」汇总后再关联," + f"或改为对「{many.label()}」上的对应列求和") + model_fix = (f"先在子查询里把 {many.name} 按关联列汇总成每条 {table.name} 一行再关联;或者改成对 {many.name} " + f"上的对应列求和;只要 {table.name} 自己的数就不要关联 {many.name}") + else: + fix = f"请先在子查询中把「{many.label()}」按「{via.label()}」汇总后再关联,或去掉与「{many.label()}」的关联" + model_fix = (f"先在子查询里把 {many.name} 按关联列汇总成每条 {via.name} 一行再关联;用不到 {many.name} 的列" + f"就去掉这个关联") + return SqlCheck( + code="fanout_sum", level=_level("fanout_sum", statuses), table=table.name, column=column, + relation_id=path[-1].relation_id, sql_excerpt=excerpt, + message=f"{head}对「{table.label()}」的「{table.column_label(column)}」{what}会重复计算。{fix}", + for_model=f"{model_head}{excerpt or what} 会把 {table.name}.{column} 重复计算。{model_fix}") + + # ---- stock_summed + + def _date_equivalents(self, table: _Table, column: str) -> set[tuple[str, str]]: + """业务日期列,加上关联、筛选条件里和它等值的列(经日期维度表分组也算按日期分组)。""" + out = {(table.alias, column.lower())} + conds = _conjuncts(self.where()) + for join in self.select.args.get("joins") or []: + conds += _conjuncts(join.args.get("on")) + from sqlglot import exp + + for cond in conds: + cond = _unparen(cond) + if isinstance(cond, exp.EQ): + a, b = self.refs(cond.left), self.refs(cond.right) + if len(a) == 1 and len(b) == 1 and a[0] and b[0]: + ka, kb = (a[0][0].alias, a[0][1].lower()), (b[0][0].alias, b[0][1].lower()) + if ka in out: + out.add(kb) + elif kb in out: + out.add(ka) + return out + + def _mentions(self, refs: list[_Ref | None], wanted: set[tuple[str, str]], name: str) -> bool: + """refs 里有没有 wanted 里的列;认不出来、但名字就是 name 的列也算有(宁可漏报)。""" + return any((r[0].alias, r[1].lower()) in wanted if r else False for r in refs) or \ + self._unresolved_named(refs, name) + + def _unresolved_named(self, refs: list[_Ref | None], name: str) -> bool: + return False if not any(r is None for r in refs) else name.lower() in self.statement_columns + + def stock_summed(self) -> list[SqlCheck]: + from sqlglot import exp + + out = [] + for node, arg in self.aggregates(exp.Sum): + refs = self.refs(arg) + if not refs or any(r is None for r in refs) or len({r[0].alias for r in refs}) != 1: + continue + table = refs[0][0] + stock = next(((c, m[1]) for c in (r[1] for r in refs) if (m := table.measure(c)) and m[0] == "stock"), None) + date = table.business_date() + if stock is None or date is None or any(r[1].lower() == date[0].lower() for r in refs): + continue + equivalents = self._date_equivalents(table, date[0]) + if self._mentions(self.group_refs(), equivalents, date[0]): + continue + pinned = False + for cond in (self.where().walk() if self.where() is not None else []): + if isinstance(cond, (exp.EQ, exp.In)) and self._mentions(self.refs(cond), equivalents, date[0]): + pinned = True # 筛到某一天(或某几天):不算跨期加总 + break + if pinned: + continue + column, label = stock[0], table.column_label(stock[0]) + date_label = table.column_label(date[0]) + excerpt = self.excerpt(node) + out.append(SqlCheck( + code="stock_summed", level=_level("stock_summed", [stock[1], date[1]]), table=table.name, + column=column, sql_excerpt=excerpt, + message=(f"「{table.label()}」的「{label}」是存量,跨日期求和会把不同日期的存量加在一起。" + f"请按「{date_label}」分组,或筛选到某一天后再求和"), + for_model=(f"{table.name}.{column} 是存量(某一时点的值),{excerpt or 'SUM'} 把不同日期的存量加在一起了。" + f"按 {date[0]} 分组,或者在 WHERE 里把 {date[0]} 限定到某一天(比如期末那天)再求和;" + "要看一段时间的水平就用 AVG,或取期末那天的值"))) + return out + + # ---- ratio_aggregated + + def ratio_aggregated(self) -> list[SqlCheck]: + from sqlglot import exp + + out = [] + for node, arg in self.aggregates(exp.Sum, exp.Avg): + column = _bare_column(arg) + ref = self.resolve(column) if column is not None else None + measure = ref[0].measure(ref[1]) if ref else None + if not ref or not measure or measure[0] != "ratio": + continue + table, name = ref + what = "求平均" if isinstance(node, exp.Avg) else "求和" + excerpt = self.excerpt(node) + out.append(SqlCheck( + code="ratio_aggregated", level=_level("ratio_aggregated", [measure[1]]), table=table.name, + column=name, sql_excerpt=excerpt, + message=(f"「{table.label()}」的「{table.column_label(name)}」是比率,直接{what}得不到正确的结果。" + "请分别汇总分子和分母后再相除"), + for_model=(f"{table.name}.{name} 是比率,{excerpt or what} 直接把比率{what}了。分别对分子、分母求和以后" + f"再相除;要按权重平均就写 SUM(权重 * {name}) / SUM(权重)"))) + return out + + # ---- missing_valid_filter + + def missing_valid_filter(self) -> list[SqlCheck]: + out = [] + done: set[str] = set() + local = [self.resolve(c) for c in self.columns] + for table in sorted(self.tables.values(), key=lambda t: t.order): + item = table.item("valid_filter") + condition = item.get("value") if item else None + if table.name in done or not isinstance(condition, str) or not condition.strip(): + continue + wanted = self._filter_columns(condition, table) + if not wanted: + continue + done.add(table.name) + if any(r and r[0].name == table.name and r[1].lower() in wanted for r in local): + continue + # 没加前缀、认不出是哪张表的同名列:可能就是它,不报 + if any(c.name.lower() in wanted for c, r in zip(self.columns, local) if r is None): + continue + # 子查询、CTE 里的表:外层可能按透出的列筛(SELECT … FROM (SELECT * FROM t) x WHERE x.status = 1) + if not self.scope.is_root and wanted & self.statement_columns: + continue + cols = "、".join(table.columns[c] for c in sorted(wanted)) + out.append(SqlCheck( + code="missing_valid_filter", level=_level("missing_valid_filter", [item.get("status")]), + table=table.name, sql_excerpt=self.excerpt(table.node), + message=(f"「{table.label()}」定义了有效记录条件「{condition.strip()}」,查询中没有按它筛选," + "结果会包含无效记录。请在筛选条件中加上该条件,或确认确实需要包含全部记录"), + for_model=(f"{table.name} 的有效记录条件是 {condition.strip()},这条 SQL 没有用到其中的列({cols})。" + "在 WHERE 里加上这个条件(有别名就加上别名前缀);确实要包含无效记录的话,在条件里显式写出这一列"))) + return out + + def _filter_columns(self, condition: str, table: _Table) -> set[str]: + """有效记录条件里用到的、这张表上的列(小写)。条件解析不了就返回空集(不查)。""" + import sqlglot + from sqlglot import exp + + try: + parsed = sqlglot.parse_one(f"SELECT 1 FROM t WHERE {condition}", read=self.dialect) + except Exception: # noqa: BLE001 + return set() + return {c.name.lower() for c in parsed.find_all(exp.Column) if c.name and c.name.lower() in table.columns} + + # ---- unknown_code + + def unknown_code(self) -> list[SqlCheck]: + from sqlglot import exp + + hits: dict[tuple[str, str], tuple[_Table, str, Mapping[str, Any], list[str], Any]] = {} + for node in self.nodes: + if isinstance(node, exp.EQ): + pairs = [(node.left, [node.right]), (node.right, [node.left])] + elif isinstance(node, exp.In) and not node.args.get("query") and node.expressions: + pairs = [(node.this, node.expressions)] + else: + continue + for target, values in pairs: + column = _unparen(target) + if not isinstance(column, exp.Column): + continue + ref = self.resolve(column) + item = ref[0].column_item(ref[1], "codes") if ref else None + codes = item.get("value") if item else None + if not isinstance(codes, Mapping) or not codes: + continue + for value in values: + literal = _literal(value) + if literal is None or _known_code(literal, codes): + continue + key = (ref[0].name, ref[1].lower()) + entry = hits.setdefault(key, (ref[0], ref[1], item, [], node)) + if literal[0] not in entry[3]: + entry[3].append(literal[0]) + out = [] + for table, column, item, values, node in hits.values(): + codes = item["value"] + shown = "、".join(f"{k}={v}" for k, v in list(codes.items())[:10]) + ("等" if len(codes) > 10 else "") + told = "、".join(f"「{v}」" for v in values[:5]) + listed = "、".join(f"{k}({v})" for k, v in list(codes.items())[:10]) + hint = f";拿不准就先用 db_schema__{self.checker.source_name} 查这张表的码值" if self.checker.source_name else "" + out.append(SqlCheck( + code="unknown_code", level=_level("unknown_code", [item.get("status")]), table=table.name, + column=column, sql_excerpt=self.excerpt(node), + message=(f"「{table.label()}」的「{table.column_label(column)}」没有码值{told}(已知码值:{shown})。" + "请按数据目录中的码值修改筛选条件"), + for_model=(f"{table.name}.{column} 的码值只有 {listed},SQL 里写的 {'、'.join(values[:5])} 不在其中。" + f"照目录里的码值改{hint}"))) + return out + + # ---- wrong_date_column + + def wrong_date_column(self) -> list[SqlCheck]: + from sqlglot import exp + + out = [] + where = self.where() + where_refs = [(c, self.resolve(c)) for c in (where.find_all(exp.Column) if where is not None else []) + if not any(isinstance(p, (exp.Subquery, exp.Select)) for p in _parents(c, stop=where))] + group = self.group_refs() + for table in sorted(self.tables.values(), key=lambda t: t.order): + date = table.business_date() + if date is None: + continue + own = {(table.alias, date[0].lower())} + if self._mentions([r for _, r in where_refs] + group, own, date[0]): + continue # 业务日期已经用上了:另一个时间列只是附加条件 + used: dict[str, set[str]] = {} + for column, ref in where_refs: + # IS NULL / IS NOT NULL 只是要求有值,不是按这一列划时间范围 + if ref and ref[0].alias == table.alias and not isinstance(column.parent, exp.Is): + used.setdefault(ref[1], set()).add("筛选") + for ref in group: + if ref and ref[0].alias == table.alias: + used.setdefault(ref[1], set()).add("分组") + for column, how in used.items(): + if column.lower() == date[0].lower() or not table.temporal(column): + continue + verb = "分组和筛选" if len(how) == 2 else next(iter(how)) + label, date_label = table.column_label(column), table.column_label(date[0]) + item = table.item("business_date") + rule = (item.get("value") or {}).get("rule") if item else None + out.append(SqlCheck( + code="wrong_date_column", level=_level("wrong_date_column", [date[1]]), table=table.name, + column=column, sql_excerpt=self.excerpt(next((c for c, r in where_refs if r and + r[1] == column), None) or exp.column(column)), + message=(f"「{table.label()}」的业务日期按「{date_label}」计,查询却按「{label}」{verb}。" + f"请改用「{date_label}」,或确认确实要按「{label}」统计"), + for_model=(f"{table.name} 的业务日期列是 {date[0]}{f'({rule})' if rule else ''},这条 SQL 却按 " + f"{column} {verb}。按业务日期统计就把 {column} 换成 {date[0]};确实要按 {column} 统计的话" + "保留,并在结果说明里写清口径"))) + return out def _parents(node: Any, *, stop: Any) -> Iterable[Any]: @@ -470,5 +920,40 @@ def _parents(node: Any, *, stop: Any) -> Iterable[Any]: parent = parent.parent +def _literal(node: Any) -> tuple[str, bool] | None: + """字面量 → (文本, 是不是字符串)。负数照常认;模板占位、参数、表达式返回 None(值到运行时才知道)。""" + from sqlglot import exp + + node = _unparen(node) + negative = False + if isinstance(node, exp.Neg): + node, negative = _unparen(node.this), True + if not isinstance(node, exp.Literal): + return None + text = str(node.this) + if _SENTINEL in text: + return None + if node.is_string: + return (text, True) if not negative else None + return (f"-{text}" if negative else text), False + + +def _number(text: str) -> float | None: + try: + return float(text.strip()) + except ValueError: + return None + + +def _known_code(literal: tuple[str, bool], codes: Mapping[str, Any]) -> bool: + """值在不在码值表里:原样相等、不分大小写相等、或者两边都是数且数值相等(1 和 '1'、1.0 都算 1)。""" + text = literal[0] + keys = [str(k) for k in codes] + if text in keys or text.strip().lower() in {k.strip().lower() for k in keys}: + return True + number = _number(text) + return number is not None and any(_number(k) == number for k in keys) + + __all__ = ["CHECK_CODES", "LEVELS", "MAX_CHECKS", "MAX_SQL_CHARS", "SqlCheck", "SqlChecker", "check_sql", "dialect_of"] diff --git a/backend/tests/test_sqlcheck.py b/backend/tests/test_sqlcheck.py index d52835bf..79c896e7 100644 --- a/backend/tests/test_sqlcheck.py +++ b/backend/tests/test_sqlcheck.py @@ -12,8 +12,9 @@ import pytest from app.data.engine import engines from app.data.introspect import introspect -from app.data.sqlcheck import SqlChecker, check_sql, dialect_of +from app.data.sqlcheck import CHECK_CODES, SqlChecker, check_sql, dialect_of from tests.fixtures.catalog import scenic_notes +from tests.fixtures.catalog.scenic_notes import ORDER_ITEMS_TO_ORDERS _CACHE: dict[str, dict] = {} @@ -47,6 +48,32 @@ def codes(cache, sql, status="confirmed", override=None): return {c.code for c in run(cache, sql, status, override)} +# -------------------------------------------------------------------------- +# 结果的形状 +# -------------------------------------------------------------------------- + + +async def test_result_shape_and_wording(cache): + sql = ("SELECT o.channel_id, SUM(o.total_amount) AS amt FROM orders o " + "JOIN order_items i ON i.order_id = o.id WHERE o.status = 1 GROUP BY o.channel_id") + [check] = run(cache, sql) + out = check.as_dict() + assert set(out) == {"code", "level", "message", "for_model", "table", "column", "relation_id", "sql_excerpt"} + assert out["code"] == "fanout_sum" and out["level"] == "error" + assert out["table"] == "orders" and out["column"] == "total_amount" + assert out["relation_id"] == ORDER_ITEMS_TO_ORDERS + assert "SUM(o.total_amount)" in out["sql_excerpt"] + assert out["message"].startswith("「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算。") + # 给模型的写 SQL 里的真名和改法;给人看的不出现工具名和对模型的指令 + assert "orders" in out["for_model"] and "order_items" in out["for_model"] + assert "db_schema__" not in out["message"] and "db_" not in out["message"] + # 没有的可选字段不出现 + single = run(cache, "SELECT AVG(discount_rate) FROM member_levels")[0].as_dict() + assert "relation_id" not in single and single["column"] == "discount_rate" + assert set(CHECK_CODES) == {"fanout_sum", "stock_summed", "join_unconfirmed", "ratio_aggregated", + "missing_valid_filter", "unknown_code", "wrong_date_column"} + + def test_dialects(): assert dialect_of("sqlite") == "sqlite" assert dialect_of("postgres") == dialect_of("postgresql") == "postgres" @@ -55,6 +82,326 @@ def test_dialects(): assert dialect_of("mssql") is None and dialect_of(None) is None +# -------------------------------------------------------------------------- +# fanout_sum:沿一对多关联之后对「一」那一侧的度量列求和、计数 +# -------------------------------------------------------------------------- + +FANOUT = [ + "SELECT SUM(o.total_amount) FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1", + # 先写明细、后连订单:订单照样随明细重复 + "SELECT SUM(o.total_amount) FROM order_items i JOIN orders o ON o.id = i.order_id WHERE o.status = 1", + # 包一层 COALESCE、CASE 里只用订单的列:还是订单的数 + "SELECT SUM(COALESCE(o.total_amount, 0)) FROM orders o LEFT JOIN order_items i ON o.id = i.order_id " + "WHERE o.status = 1", + "SELECT SUM(CASE WHEN o.status = 1 THEN o.total_amount END) FROM orders o JOIN order_items i " + "ON i.order_id = o.id AND i.qty > 0", + # 逗号连接、条件写在 WHERE 里 + "SELECT SUM(o.total_amount) FROM orders o, order_items i WHERE i.order_id = o.id AND o.status = 1", +] + + +@pytest.mark.parametrize("sql", FANOUT) +async def test_fanout_sum_positive(cache, sql): + assert ("fanout_sum", "error", "orders", "total_amount") in found(cache, sql) + + +async def test_fanout_count_of_the_one_side_key(cache): + sql = "SELECT COUNT(o.id) FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1" + [check] = run(cache, sql) + assert (check.code, check.level, check.table, check.column) == ("fanout_sum", "error", "orders", "id") + assert "计数会重复计算" in check.message + + +async def test_fanout_through_two_hops_with_an_inferred_foreign_key(cache): + """支付记录没写目录:订单 → 支付记录的关系按外键现推(verified),明细经订单连支付记录,明细的金额照样重复。""" + sql = ("SELECT SUM(i.amount) FROM order_items i JOIN orders o ON o.id = i.order_id " + "JOIN payments p ON p.order_id = o.id WHERE o.status = 1") + [check] = [c for c in run(cache, sql) if c.code == "fanout_sum"] + assert (check.level, check.table, check.column) == ("error", "order_items", "amount") + assert "「订单」关联「payments」是一对多,「订单明细」的每一行会随之重复" in check.message + assert "去掉与「payments」的关联" in check.message + + +FANOUT_NEGATIVE = [ + # 对「多」那一侧求和:本来就在明细粒度上 + "SELECT SUM(i.amount) FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1", + "SELECT SUM(i.amount) FROM order_items i JOIN orders o ON o.id = i.order_id WHERE o.status = 1", + # 不关联 + "SELECT SUM(o.total_amount) FROM orders o WHERE o.status = 1", + # 去重计数、去重求和:写的人已经在处理重复 + "SELECT COUNT(DISTINCT o.id) FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1", + # 两边的列一起算:按明细逐行算出来的数 + "SELECT SUM(i.qty * o.total_amount) FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1", + # 按明细的主键分组:每组只有一行明细,订单不重复 + "SELECT i.id, SUM(o.total_amount) FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1 " + "GROUP BY i.id", + # 明细先在子查询里按订单汇总再连:一对一 + "SELECT SUM(o.total_amount), SUM(x.n) FROM orders o JOIN (SELECT order_id, COUNT(*) AS n FROM order_items " + "GROUP BY order_id) x ON x.order_id = o.id WHERE o.status = 1", + # 计数所有行:说不清是要数订单还是数明细 + "SELECT COUNT(*) FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1", + # 窗口函数:不是分组聚合 + "SELECT o.id, SUM(o.total_amount) OVER (PARTITION BY o.channel_id) FROM orders o " + "JOIN order_items i ON i.order_id = o.id WHERE o.status = 1", + # 反连接:只留没有明细的订单,每笔订单至多一行 + "SELECT SUM(o.total_amount) FROM orders o LEFT JOIN order_items i ON i.order_id = o.id " + "WHERE i.id IS NULL AND o.status = 1", +] + + +@pytest.mark.parametrize("sql", FANOUT_NEGATIVE) +async def test_fanout_sum_negative(cache, sql): + assert "fanout_sum" not in codes(cache, sql) + + +async def test_fanout_level_follows_the_status_of_its_basis(cache): + sql = FANOUT[0] + assert ("fanout_sum", "error", "orders", "total_amount") in found(cache, sql, "verified") + assert ("fanout_sum", "info", "orders", "total_amount") in found(cache, sql, "proposed") + # 关系确认了、度量类型只是推断:依据里有推断就降为 info + assert ("fanout_sum", "info", "orders", "total_amount") in found( + cache, sql, override={("orders", "columns.total_amount.measure"): "proposed"}) + # 关系被驳回:不知道是一对多,不报重复计算;关联本身对不上目录,单独提示 + rejected = found(cache, sql, override={("order_items", f"relations.{ORDER_ITEMS_TO_ORDERS}"): "rejected"}) + assert not [f for f in rejected if f[0] == "fanout_sum"] + assert ("join_unconfirmed", "warning", "order_items", None) in rejected + # 度量类型被驳回:不知道订单金额能不能加,不报 + assert "fanout_sum" not in codes(cache, sql, override={("orders", "columns.total_amount.measure"): "rejected"}) + + +# -------------------------------------------------------------------------- +# stock_summed:存量跨时间求和 +# -------------------------------------------------------------------------- + +STOCK = [ + "SELECT SUM(on_hand) FROM inventory_snapshots", + 'SELECT "storeID", SUM(on_hand) FROM inventory_snapshots GROUP BY "storeID"', + "SELECT SUM(s.on_hand) FROM inventory_snapshots s WHERE s.snapshot_date BETWEEN '2026-09-01' AND '2026-09-30'", +] + + +@pytest.mark.parametrize("sql", STOCK) +async def test_stock_summed_positive(cache, sql): + assert ("stock_summed", "error", "inventory_snapshots", "on_hand") in found(cache, sql) + + +STOCK_NEGATIVE = [ + "SELECT snapshot_date, SUM(on_hand) FROM inventory_snapshots GROUP BY snapshot_date", + "SELECT SUM(on_hand) FROM inventory_snapshots WHERE snapshot_date = '2026-09-30'", + "SELECT substr(snapshot_date, 1, 7) AS m, SUM(on_hand) FROM inventory_snapshots GROUP BY substr(snapshot_date, 1, 7)", + "SELECT AVG(on_hand) FROM inventory_snapshots", + "SELECT MAX(on_hand) FROM inventory_snapshots", + "SELECT snapshot_date, SUM(on_hand) OVER (PARTITION BY snapshot_date) FROM inventory_snapshots", + # 经日期维度表分组:维度表的日期和快照日期相等 + "SELECT d.biz_date, SUM(s.on_hand) FROM inventory_snapshots s JOIN dim_dates d ON d.biz_date = s.snapshot_date " + "GROUP BY d.biz_date", +] + + +@pytest.mark.parametrize("sql", STOCK_NEGATIVE) +async def test_stock_summed_negative(cache, sql): + assert "stock_summed" not in codes(cache, sql) + + +async def test_stock_level_follows_status(cache): + sql = STOCK[0] + assert ("stock_summed", "error", "inventory_snapshots", "on_hand") in found(cache, sql, "verified") + assert ("stock_summed", "info", "inventory_snapshots", "on_hand") in found(cache, sql, "proposed") + assert "stock_summed" not in codes(cache, sql, "rejected") + # 业务日期被驳回:不知道按哪一列算时间,不报 + assert "stock_summed" not in codes(cache, sql, override={("inventory_snapshots", "business_date"): "rejected"}) + + +# -------------------------------------------------------------------------- +# join_unconfirmed:关联条件对不上目录里的关系 +# -------------------------------------------------------------------------- + +async def test_join_unconfirmed_positive(cache): + # 列连错了:订单的主键对明细的主键 + wrong = found(cache, "SELECT SUM(i.amount) FROM orders o JOIN order_items i ON i.id = o.id WHERE o.status = 1") + assert ("join_unconfirmed", "warning", "order_items", None) in wrong + [check] = [c for c in run(cache, "SELECT o.id FROM orders o JOIN members m ON m.id = o.channel_id " + "WHERE o.status = 1") if c.code == "join_unconfirmed"] + assert check.level == "warning" and "o.channel_id" in check.sql_excerpt + assert check.table == "members" and "「订单」与「members」的关联条件" in check.message + # 只对上命名推断出来的关系(入园记录.member_id → 会员):提示一句,级别 info + proposed = found(cache, "SELECT m.name FROM visits v JOIN members m ON m.id = v.member_id WHERE v.status = 1") + assert ("join_unconfirmed", "info", "members", None) in proposed + + +JOIN_NEGATIVE = [ + "SELECT SUM(i.amount) FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1", + # 多写的过滤条件不影响对关系 + "SELECT SUM(i.amount) FROM orders o JOIN order_items i ON i.order_id = o.id AND i.qty > 0 WHERE o.status = 1", + # 没写目录的表之间按外键现推(verified) + "SELECT SUM(p.paid_amount) FROM orders o JOIN payments p ON p.order_id = o.id WHERE o.status = 1", + # 两张表都没写目录:没有可对照的目录,不判 + "SELECT COUNT(*) FROM tour_groups t JOIN agencies a ON a.channel_id = t.agency_id", + # 子查询、CTE 当一边:不知道它的粒度,不判 + "WITH x AS (SELECT order_id, SUM(amount) AS a FROM order_items GROUP BY order_id) " + "SELECT SUM(x.a) FROM orders o JOIN x ON x.order_id = o.id WHERE o.status = 1", + # 自己连自己:目录不记这种关系 + "SELECT c.name FROM categories c JOIN categories p ON p.id = c.\"parentId\"", + # 没有等值条件 + "SELECT COUNT(*) FROM orders o JOIN order_items i ON i.amount > o.total_amount WHERE o.status = 1", + # OR 连起来的条件:说不清按哪条连 + "SELECT COUNT(*) FROM orders o JOIN order_items i ON i.order_id = o.id OR i.id = o.id WHERE o.status = 1", +] + + +@pytest.mark.parametrize("sql", JOIN_NEGATIVE) +async def test_join_unconfirmed_negative(cache, sql): + assert "join_unconfirmed" not in codes(cache, sql) + + +async def test_join_level_follows_status(cache): + sql = "SELECT SUM(i.amount) FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1" + assert "join_unconfirmed" not in codes(cache, sql, "confirmed") + assert "join_unconfirmed" not in codes(cache, sql, "verified") + # 结果记在 JOIN 引入的那张表上 + assert ("join_unconfirmed", "info", "order_items", None) in found(cache, sql, "proposed") + assert ("join_unconfirmed", "warning", "order_items", None) in found(cache, sql, "rejected") + + +# -------------------------------------------------------------------------- +# ratio_aggregated:对比率求和、求平均 +# -------------------------------------------------------------------------- + +@pytest.mark.parametrize("sql", [ + "SELECT AVG(discount_rate) FROM member_levels", + "SELECT SUM(discount_rate) FROM member_levels", + "SELECT ROUND(AVG(l.discount_rate), 2) FROM member_levels l", +]) +async def test_ratio_aggregated_positive(cache, sql): + assert found(cache, sql) == [("ratio_aggregated", "warning", "member_levels", "discount_rate")] + + +@pytest.mark.parametrize("sql", [ + "SELECT level_name, discount_rate FROM member_levels", + "SELECT MAX(discount_rate) FROM member_levels", + # 加权:比率乘上数量再加,是正确的算法 + "SELECT SUM(m.id * l.discount_rate) FROM members m JOIN member_levels l ON l.id = m.member_level_id", +]) +async def test_ratio_aggregated_negative(cache, sql): + assert "ratio_aggregated" not in codes(cache, sql) + + +async def test_ratio_level_follows_status(cache): + sql = "SELECT AVG(discount_rate) FROM member_levels" + assert found(cache, sql, "verified") == [("ratio_aggregated", "warning", "member_levels", "discount_rate")] + assert found(cache, sql, "proposed") == [("ratio_aggregated", "info", "member_levels", "discount_rate")] + assert found(cache, sql, "rejected") == [] + + +# -------------------------------------------------------------------------- +# missing_valid_filter:没按有效记录条件筛 +# -------------------------------------------------------------------------- + +@pytest.mark.parametrize("sql", [ + "SELECT COUNT(*) FROM visits", + "SELECT SUM(visitor_count) FROM visits WHERE park_id = 1", + "SELECT v.park_id, SUM(v.visitor_count) FROM visits v GROUP BY v.park_id", +]) +async def test_missing_valid_filter_positive(cache, sql): + [check] = run(cache, sql) + assert (check.code, check.level, check.table) == ("missing_valid_filter", "warning", "visits") + assert "有效记录条件「status = 1」" in check.message + + +@pytest.mark.parametrize("sql", [ + "SELECT COUNT(*) FROM visits WHERE status = 1", + "SELECT COUNT(*) FROM visits v WHERE v.status IN (0, 1)", + # 按状态分组:写的人就是要看每种状态 + "SELECT status, COUNT(*) FROM visits GROUP BY status", + # 条件写在聚合里 + "SELECT SUM(CASE WHEN status = 1 THEN visitor_count ELSE 0 END) FROM visits", + # CTE 里筛过 + "WITH ok AS (SELECT * FROM visits WHERE status = 1) SELECT COUNT(*) FROM ok", + # 子查询原样透出、外层再筛 + "SELECT COUNT(*) FROM (SELECT * FROM visits) x WHERE x.status = 1", +]) +async def test_missing_valid_filter_negative(cache, sql): + assert "missing_valid_filter" not in codes(cache, sql) + + +async def test_valid_filter_level_follows_status(cache): + sql = "SELECT COUNT(*) FROM visits" + assert found(cache, sql, "verified") == [("missing_valid_filter", "warning", "visits", None)] + assert found(cache, sql, "proposed") == [("missing_valid_filter", "info", "visits", None)] + assert found(cache, sql, "rejected") == [] + + +# -------------------------------------------------------------------------- +# unknown_code:写了码值表里没有的值 +# -------------------------------------------------------------------------- + +async def test_unknown_code_positive(cache): + [check] = run(cache, "SELECT COUNT(*) FROM visits WHERE status = 3") + assert (check.code, check.level, check.table, check.column) == ("unknown_code", "warning", "visits", "status") + assert "「3」" in check.message and "1=有效、0=作废" in check.message + [listed] = run(cache, "SELECT COUNT(*) FROM visits WHERE status IN (1, 5, 7)") + assert "「5」、「7」" in listed.message and "「1」" not in listed.message + assert found(cache, "SELECT COUNT(*) FROM channels WHERE channel_type = '线上'") == [ + ("unknown_code", "warning", "channels", "channel_type")] + + +@pytest.mark.parametrize("sql", [ + "SELECT COUNT(*) FROM visits WHERE status = 1", + "SELECT COUNT(*) FROM visits WHERE status = '1'", + "SELECT COUNT(*) FROM visits WHERE 0 = status OR status = 1", + "SELECT COUNT(*) FROM visits WHERE status IN (0, 1)", + "SELECT COUNT(*) FROM visits WHERE status = 1.0", + "SELECT COUNT(*) FROM channels WHERE channel_type = '分销'", + # 模板和参数:值要到运行时才知道 + "SELECT COUNT(*) FROM visits WHERE status = '{{ input.status }}'", + "SELECT COUNT(*) FROM visits WHERE status = {{ input.status }}", + "SELECT COUNT(*) FROM visits WHERE status = ?", + "SELECT COUNT(*) FROM visits WHERE status = :status", +]) +async def test_unknown_code_negative(cache, sql): + assert "unknown_code" not in codes(cache, sql) + + +async def test_unknown_code_level_follows_status(cache): + sql = "SELECT COUNT(*) FROM channels WHERE channel_type = '线上'" + assert found(cache, sql, "verified") == [("unknown_code", "warning", "channels", "channel_type")] + assert found(cache, sql, "proposed") == [("unknown_code", "info", "channels", "channel_type")] + assert found(cache, sql, "rejected") == [] + + +# -------------------------------------------------------------------------- +# wrong_date_column:按另一个时间列分组、筛选 +# -------------------------------------------------------------------------- + +@pytest.mark.parametrize("sql", [ + "SELECT substr(entered_at, 1, 10) AS d, SUM(fee) FROM parking_records GROUP BY substr(entered_at, 1, 10)", + "SELECT SUM(p.fee) FROM parking_records p WHERE p.entered_at >= '2026-09-01'", +]) +async def test_wrong_date_column_positive(cache, sql): + [check] = run(cache, sql) + assert (check.code, check.level, check.table, check.column) == ( + "wrong_date_column", "warning", "parking_records", "entered_at") + assert "业务日期按「出场时间」计" in check.message and "「入场时间」" in check.message + + +@pytest.mark.parametrize("sql", [ + "SELECT substr(exited_at, 1, 10) AS d, SUM(fee) FROM parking_records GROUP BY substr(exited_at, 1, 10)", + "SELECT SUM(fee) FROM parking_records WHERE exited_at >= '2026-09-01' AND entered_at IS NOT NULL", + # 两个时间都用上了:写的人知道两者的区别 + "SELECT SUM(fee) FROM parking_records WHERE exited_at >= '2026-09-01' AND entered_at < '2026-09-30'", + "SELECT entered_at, exited_at, fee FROM parking_records", +]) +async def test_wrong_date_column_negative(cache, sql): + assert "wrong_date_column" not in codes(cache, sql) + + +async def test_wrong_date_level_follows_status(cache): + sql = "SELECT SUM(p.fee) FROM parking_records p WHERE p.entered_at >= '2026-09-01'" + assert found(cache, sql, "verified") == [("wrong_date_column", "warning", "parking_records", "entered_at")] + assert found(cache, sql, "proposed") == [("wrong_date_column", "info", "parking_records", "entered_at")] + assert found(cache, sql, "rejected") == [] + + # -------------------------------------------------------------------------- # 复杂写法:照样分析得出,或者跳过;不报错、不误报 # -------------------------------------------------------------------------- @@ -84,6 +431,15 @@ async def test_complex_but_clean_sql_reports_nothing(cache, sql): assert run(cache, sql) == [] +async def test_problems_inside_cte_and_union_are_found(cache): + cte = ("WITH t AS (SELECT o.channel_id, SUM(o.total_amount) AS amt FROM orders o " + "JOIN order_items i ON i.order_id = o.id WHERE o.status = 1 GROUP BY o.channel_id) SELECT * FROM t") + assert ("fanout_sum", "error", "orders", "total_amount") in found(cache, cte) + union = ("SELECT SUM(on_hand) FROM inventory_snapshots UNION ALL " + "SELECT SUM(on_hand) FROM inventory_snapshots WHERE snapshot_date = '2026-09-30'") + assert found(cache, union) == [("stock_summed", "error", "inventory_snapshots", "on_hand")] + + @pytest.mark.parametrize("sql", [ "SELECT FROM WHERE", "SELEC total_amount FROM orders", @@ -104,9 +460,46 @@ async def test_unparseable_or_unsupported_sql_is_skipped(cache, sql): assert run(cache, sql) == [] +async def test_other_dialects(cache): + pg = "SELECT s.on_hand::numeric FROM inventory_snapshots s WHERE s.snapshot_date = $1" + assert run(cache, pg, kind="postgres") == [] + assert found(cache, "SELECT SUM(s.on_hand)::numeric FROM inventory_snapshots s", kind="postgres") == [ + ("stock_summed", "error", "inventory_snapshots", "on_hand")] + mysql = "SELECT `v`.`status`, COUNT(*) FROM `visits` `v` WHERE `v`.`status` = 3 GROUP BY `v`.`status`" + assert found(cache, mysql, kind="mysql") == [("unknown_code", "warning", "visits", "status")] + # 能读就照常分析,读不了就跳过:只要求不抛 + assert isinstance(run(cache, "SELECT SUM(on_hand) FROM inventory_snapshots WHERE q'[it''s]' = 'x'", + kind="oracle"), list) + assert run(cache, "SELECT SUM(on_hand) FROM inventory_snapshots", kind="mssql") == [] + + async def test_no_catalog_means_no_checks(cache): sql = "SELECT SUM(o.total_amount) FROM orders o JOIN order_items i ON i.id = o.id" assert SqlChecker(kind="sqlite", schema_cache=cache, notes={}).check(sql) == [] assert check_sql(sql, kind="sqlite", schema_cache=cache, notes=None) == [] # 没探查过结构:对不上表,一样不判 assert check_sql(sql, kind="sqlite", schema_cache={}, notes=scenic_notes.notes()) == [] + + +async def test_duplicates_are_folded_and_errors_come_first(cache): + sql = ("SELECT SUM(o.total_amount), SUM(o.total_amount) * 2, AVG(l.discount_rate) " + "FROM orders o JOIN order_items i ON i.order_id = o.id JOIN members m ON m.id = o.member_id " + "JOIN member_levels l ON l.id = m.member_level_id WHERE o.status = 1") + out = found(cache, sql) + assert [f for f in out if f[0] == "fanout_sum"] == [("fanout_sum", "error", "orders", "total_amount")] + levels = [f[1] for f in out] + assert levels == sorted(levels, key=["error", "warning", "info"].index) + + +async def test_checker_builds_relation_graph_lazily_and_reuses_it(cache, monkeypatch): + from app.data import catalog + + calls = [] + real = catalog.relation_graph + monkeypatch.setattr(catalog, "relation_graph", lambda *a, **k: calls.append(1) or real(*a, **k)) + checker = SqlChecker(kind="sqlite", schema_cache=cache, notes=scenic_notes.notes()) + checker.check("SELECT AVG(discount_rate) FROM member_levels") + assert calls == [] # 单表查询用不到关系图 + checker.check(FANOUT[0]) + checker.check(FANOUT[1]) + assert calls == [1] From 8d31164a8be99473a364e1583b0fdd41d833562a Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 3 Oct 2026 10:19:48 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=202B=EF=BC=9A=E5=8A=A9=E6=89=8B=E8=87=AA?= =?UTF-8?q?=E6=9F=A5=E6=8E=A5=E4=B8=8A=20SQL=20=E6=A3=80=E6=9F=A5=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=B7=A5=E5=85=B7=E8=8A=82=E7=82=B9=E9=87=8C=E5=86=99?= =?UTF-8?q?=E6=AD=BB=E7=9A=84=20SQL=20=E6=9F=A5=E5=87=BA=20error=20?= =?UTF-8?q?=E8=BF=9B=E4=BF=AE=E6=AD=A3=E8=BD=AE=EF=BC=8Cwarning=20?= =?UTF-8?q?=E5=92=8C=20info=20=E7=95=99=E5=9C=A8=E4=BA=A4=E4=BB=98?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/copilot.py | 39 +++- backend/app/data/sqlcheck.py | 91 ++++++++- backend/tests/test_copilot_sqlcheck.py | 245 +++++++++++++++++++++++++ 3 files changed, 367 insertions(+), 8 deletions(-) create mode 100644 backend/tests/test_copilot_sqlcheck.py diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 78eb92db..283dde72 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.coded import DATASOURCE_SCOPE_EMPTY, CodedHTTPException # 起别名:本文件底下有个叫 explain 的接口,generate 里又有个局部变量叫 raw,同名会互相盖掉 from app.core.errors import explain as explain_error, graph_error, not_configured, raw as raw_error +from app.data.sqlcheck import CHECK_CODES from app.db.base import get_session from app.db.models import Workflow from app.engine.layout import CORRIDOR_MIN, MIN_ROW_GAP, NODE_W, _height, auto_layout @@ -1081,6 +1082,7 @@ def _blocking_issues( nodes: dict[str, dict[str, Any]], edges: list[dict[str, Any]], scope: set[str] | None = None, *, sources: list[Any] | None = None, baseline: list[dict[str, Any]] | None = None, level: str | None = None, defaults: dict[str, Any] | None = None, + checkers: dict[str, Any] | None = None, ) -> list[dict[str, Any]] | None: """按运行时同一套校验,挑出会挡住运行的问题。图本身不成形时返回 None。 @@ -1098,6 +1100,9 @@ def _blocking_issues( defaults 是全图默认(graph.defaults):报告撰写节点的 numbers / claims、审批策略都跟随它,不带上的话 自查看到的问题和真正的门禁对不上。 + + checkers 是各数据源基于数据目录的 SQL 检查器(data/sqlcheck.load_checkers):调用工具节点里写死的 SQL + 查出 error 级的问题(一对多关联后重复计算、存量跨期求和)也算挡住,交回模型改;warning、info 不挡。 """ try: spec = GraphSpec.model_validate({"nodes": list(nodes.values()), "edges": edges, @@ -1106,7 +1111,7 @@ def _blocking_issues( return None out = ([i.model_dump() for i in validate_graph(spec).issues if i.level == "error"] + _issue_dicts(scope_issues(list(nodes.values()), scope), "datasource_out_of_scope") - + authored_issues(spec, sources or [], baseline)) + + [i for i in authored_issues(spec, sources or [], baseline, checkers=checkers) if i["level"] == "error"]) if level: from app.engine.governance import lint_for_publish @@ -1181,7 +1186,7 @@ def _unsourced_reports(spec: GraphSpec, changed: set[str]) -> list[dict[str, Any def authored_issues(spec: GraphSpec, sources: list[Any], baseline: list[dict[str, Any]] | None = None, - ) -> list[dict[str, Any]]: + *, checkers: dict[str, Any] | None = None) -> list[dict[str, Any]]: """助手搭图时要打回、运行时却不挡的写法,按 error 交回模型改: - sql_unknown_column:调用工具节点的 SQL 里有数据源结构(schema_cache)里查不到的列——凭空 @@ -1189,6 +1194,11 @@ def authored_issues(spec: GraphSpec, sources: list[Any], baseline: list[dict[str - parse_model_text:整形节点按 JSON 解析 agent / llm 写的文字(validate 里只是 warning:旧图照跑) - report_no_source:报告撰写节点的上游没有任何证据来源(运行时只给一条警告,报告里的数全没有出处)。 metrics_from 指向不是口径卡的节点,validate 本来就报 error,自查照样交回去 + + 另外,给了 checkers(各数据源基于数据目录的 SQL 检查器)时,调用工具节点里写死的 SQL 再对照目录查一遍 + (code 是 data/sqlcheck.CHECK_CODES 里的规则编号),级别照检查结果:error 交回模型改,warning、info + 由调用方放进交付的问题清单。列名都对不上的 SQL 先改列名,这一轮不叠加目录检查。Agent 节点的 SQL 要到 + 运行时才写出来,这里拿不到,由数据源查询工具在执行后检查。 """ changed = _changed(spec, baseline) out: list[dict[str, Any]] = [] @@ -1212,6 +1222,7 @@ def authored_issues(spec: GraphSpec, sources: list[Any], baseline: list[dict[str continue found = sql_unknown_columns(sql, source) if not found: + out += _catalog_issues(node.id, sql, (checkers or {}).get(source.name)) continue unknown, known = found listed = "、".join(known[:20]) + (f" 等 {len(known)} 列" if len(known) > 20 else "") @@ -1225,6 +1236,14 @@ def authored_issues(spec: GraphSpec, sources: list[Any], baseline: list[dict[str return out +def _catalog_issues(node_id: str, sql: str, checker: Any) -> list[dict[str, Any]]: + """一个调用工具节点的 SQL 对照数据目录查出来的问题,形状和别的自查问题一样(node_id、field 都在), + 另带 table、column、relation_id、sql_excerpt 给界面定位。""" + if checker is None: + return [] + return [{**check, "node_id": node_id, "edge_id": None, "field": "args.sql"} for check in checker.check_dicts(sql)] + + # -------------------------------------------------------------------------- # SQL 里的列名对照数据源结构 # @@ -1599,10 +1618,14 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g """ from fastapi.responses import StreamingResponse + from app.data.sqlcheck import load_checkers + sources = await _sources(session, payload.datasource_ids) scope = {r.name for r in sources} if payload.datasource_ids else None tool_list = _tool_catalog(sources) datasources = _datasource_section(sources) + _scope_note(sources, scope is not None) + # 基于数据目录的 SQL 检查器:在开流之前读好目录(流里不再碰这个会话),自查和收尾共用 + checkers = await load_checkers(session, sources) system = ( "你是一个 agent 工作流编排专家。根据用户需求,以操作流的方式逐步搭出一张可执行的工作流图。\n\n" f"{NODE_REFERENCE}\n\n可用工具:\n{tool_list}{datasources}\n\n" @@ -1721,7 +1744,7 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g # 循环 / 分支条件里套了 {{ }}),前面的步骤白跑,报错还停在半路 repaired = 0 for round_no in range(1, _SELF_CHECK_ROUNDS + 1): - errors = _blocking_issues(nodes, edges, scope, sources=sources, baseline=baseline) + errors = _blocking_issues(nodes, edges, scope, sources=sources, baseline=baseline, checkers=checkers) if not errors: break yield _sse({"op": "check", "status": "repairing", "round": round_no, "issues": errors}) @@ -1739,7 +1762,7 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g "message": f"自查修正失败:{explain_error(e)[0]}", "detail": raw_error(e)}) break repaired = round_no - remaining = _blocking_issues(nodes, edges, scope, sources=sources, baseline=baseline) + remaining = _blocking_issues(nodes, edges, scope, sources=sources, baseline=baseline, checkers=checkers) # 工具绑定变化不挡运行,但要在自查这一步就说出来:工具被改没了的图照样 # 能跑,只是跑出来的是模型「假设」查过库的答案 changes = tool_changes(baseline, list(nodes.values())) @@ -1763,9 +1786,11 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g issues = [i.model_dump() for i in validate_graph(spec).issues if i.level != "info"] issues += _issue_dicts(scope_issues(list(nodes.values()), scope), "datasource_out_of_scope") - # 自查没改好的猜列名留在画布上:运行时不挡(结构缓存可能旧了),但要看得见 - issues += [{**i, "level": "warning"} for i in authored_issues(spec, sources, baseline) - if i["code"] == "sql_unknown_column"] + # 自查没改好的猜列名留在画布上:运行时不挡(结构缓存可能旧了),但要看得见。 + # 对照数据目录的 SQL 检查照原级别列出:warning、info 本来就不返工,改不好的 error 也在这里看得见 + authored = authored_issues(spec, sources, baseline, checkers=checkers) + issues += [{**i, "level": "warning"} for i in authored if i["code"] == "sql_unknown_column"] + issues += [i for i in authored if i["code"] in CHECK_CODES] # 跳过的节点要说出来,不能安静地少一步。code 给前端认:这一类要单独 # 提示「少了一步」,不能和普通校验警告混在一起 issues += [ diff --git a/backend/app/data/sqlcheck.py b/backend/app/data/sqlcheck.py index 8ef5f089..e1fd9ae0 100644 --- a/backend/app/data/sqlcheck.py +++ b/backend/app/data/sqlcheck.py @@ -955,5 +955,94 @@ def _known_code(literal: tuple[str, bool], codes: Mapping[str, Any]) -> bool: return number is not None and any(_number(k) == number for k in keys) +# ========================================================================== +# 工作流里写死的 SQL、从库里读目录 +# ========================================================================== + + +def tool_query(node: Any) -> tuple[str, str] | None: + """调用工具节点里写死的查询:(数据源名, SQL)。不是数据源查询工具、SQL 不是字符串的返回 None。 + + Agent 节点的 SQL 要到运行时才由模型写出来,静态拿不到,这里不管;它们由数据源查询工具在执行后检查。 + """ + from app.tools.datasource import QUERY_PREFIX + + if str(getattr(node, "type", "")) != "tool": + return None + config = getattr(node, "config", None) or {} + tool = str(config.get("tool") or "") + args = config.get("args") + sql = args.get("sql") if isinstance(args, Mapping) else None + if not tool.startswith(QUERY_PREFIX) or not isinstance(sql, str) or not sql.strip(): + return None + return tool[len(QUERY_PREFIX):], sql + + +def graph_sources(nodes: Iterable[Any]) -> set[str]: + """一张图里调用工具节点写死查询用到的数据源名。""" + return {found[0] for node in nodes if (found := tool_query(node))} + + +def graph_checks(nodes: Iterable[Any], checkers: Mapping[str, SqlChecker], *, + only: set[str] | None = None) -> list[tuple[Any, SqlCheck]]: + """一张图里调用工具节点写死的 SQL 逐个检查:[(节点, 结果)]。only 给了就只查这些节点。""" + out: list[tuple[Any, SqlCheck]] = [] + for node in nodes: + if only is not None and getattr(node, "id", None) not in only: + continue + found = tool_query(node) + checker = checkers.get(found[0]) if found else None + if checker is not None: + out += [(node, check) for check in checker.check(found[1])] + return out + + +async def load_checkers(session: Any, sources: Iterable[Any]) -> dict[str, SqlChecker]: + """给一批数据源(DataSource 行)各建一个检查器:{源名: SqlChecker}。没有目录的源不建。 + + 一次查询读出这些源的全部目录。读不到就当没有目录(返回空 dict):检查是附加的,不能挡住自查和发布本身。 + """ + from sqlalchemy import select + + from app.db.models import CatalogNote + + rows = [s for s in sources if getattr(s, "id", None) and dialect_of(getattr(s, "kind", None))] + if not rows: + return {} + try: + notes = (await session.execute( + select(CatalogNote.source_id, CatalogNote.table_name, CatalogNote.notes) + .where(CatalogNote.source_id.in_([r.id for r in rows])) + )).all() + except Exception: # noqa: BLE001 + logger.exception("读取数据目录失败,本次不做基于目录的 SQL 检查") + return {} + by_source: dict[str, dict[str, Any]] = {} + for source_id, table, value in notes: + if isinstance(value, dict) and value: + by_source.setdefault(source_id, {})[table] = value + return {r.name: SqlChecker(kind=r.kind, schema_cache=r.schema_cache, notes=by_source[r.id], source_name=r.name) + for r in rows if r.id in by_source} + + +async def load_checkers_by_name(session: Any, names: Iterable[str]) -> dict[str, SqlChecker]: + """按数据源名建检查器(只看启用着的源)。发布门禁用:图里写的是源名。""" + from sqlalchemy import select + + from app.db.models import DataSource + + wanted = sorted({n for n in names if n}) + if not wanted: + return {} + try: + rows = list((await session.execute( + select(DataSource).where(DataSource.name.in_(wanted), DataSource.enabled.is_(True)) + )).scalars()) + except Exception: # noqa: BLE001 + logger.exception("读取数据源失败,本次不做基于目录的 SQL 检查") + return {} + return await load_checkers(session, rows) + + __all__ = ["CHECK_CODES", "LEVELS", "MAX_CHECKS", "MAX_SQL_CHARS", "SqlCheck", "SqlChecker", "check_sql", - "dialect_of"] + "dialect_of", "graph_checks", "graph_sources", "load_checkers", "load_checkers_by_name", "tool_query"] diff --git a/backend/tests/test_copilot_sqlcheck.py b/backend/tests/test_copilot_sqlcheck.py new file mode 100644 index 00000000..5d0d0348 --- /dev/null +++ b/backend/tests/test_copilot_sqlcheck.py @@ -0,0 +1,245 @@ +"""助手搭完图的自查接上基于数据目录的 SQL 检查(data/sqlcheck.py)。 + +- 调用工具节点里写死的 SQL 走现有的自查:error 级(一对多关联后重复计算、存量跨期求和)交回模型改,进修正轮; + warning、info 不返工,放进 final 的问题清单。 +- Agent 节点的 SQL 要到运行时才由模型写出来,静态拿不到,不查(运行时由数据源查询工具检查)。 +- 这一轮没碰的节点不查:用户自己写的 SQL 不替他改(和列名核对同一个规矩)。 +- 没有目录的数据源什么都不查。 +""" +from __future__ import annotations + +import json +import uuid +from types import SimpleNamespace + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.data import catalog +from app.data.engine import engines +from app.data.introspect import introspect +from app.data.sqlcheck import SqlChecker +from tests.fixtures.catalog import scenic_notes + +FANOUT_SQL = ("SELECT SUM(o.total_amount) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id " + "WHERE o.status = 1") +#: 改好的:对明细求和;没按订单状态筛,留一条 warning +FIXED_SQL = "SELECT SUM(i.amount) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id" +CLEAN_SQL = "SELECT SUM(i.amount) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1" + +_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 _spec(*nodes): + from app.engine.schema import GraphSpec + + return GraphSpec.model_validate({"nodes": list(nodes), "edges": [ + {"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}) + + +def _node(nid, ntype, **config): + return {"id": nid, "type": ntype, "data": {"label": nid, "config": config}} + + +def _fetch(sql, source="scenic"): + return _node("fetch", "tool", tool=f"db_query__{source}", args={"sql": sql}) + + +async def _checkers(scenic_db, status="confirmed"): + cache = await _schema(scenic_db) + source = SimpleNamespace(id="s1", name="scenic", kind="sqlite", schema_cache=cache) + checker = SqlChecker(kind="sqlite", schema_cache=cache, notes=scenic_notes.notes(status), source_name="scenic") + return source, {"scenic": checker} + + +# -------------------------------------------------------------------------- +# authored_issues / _blocking_issues +# -------------------------------------------------------------------------- + + +async def test_sql_check_errors_block_and_warnings_do_not(scenic_db): + from app.api.copilot import _blocking_issues, authored_issues + + source, checkers = await _checkers(scenic_db) + spec = _spec(_node("start", "input"), _fetch(FANOUT_SQL), _node("out", "output", fields=[])) + [issue] = authored_issues(spec, [source], checkers=checkers) + assert issue["code"] == "fanout_sum" and issue["level"] == "error" + assert issue["node_id"] == "fetch" and issue["field"] == "args.sql" + assert issue["table"] == "orders" and issue["column"] == "total_amount" and issue["sql_excerpt"] + assert issue["message"].startswith("「订单」关联「订单明细」是一对多") and "order_items" in issue["for_model"] + nodes = {n.id: n.model_dump(mode="json") for n in spec.nodes} + assert [i["code"] for i in _blocking_issues(nodes, [], sources=[source], checkers=checkers)] == ["fanout_sum"] + + # 只有 warning:不挡(不返工),但 authored_issues 照样带出来,收尾时放进 final + warn = _spec(_node("start", "input"), _fetch(FIXED_SQL), _node("out", "output", fields=[])) + [w] = authored_issues(warn, [source], checkers=checkers) + assert (w["code"], w["level"]) == ("missing_valid_filter", "warning") + nodes = {n.id: n.model_dump(mode="json") for n in warn.nodes} + assert _blocking_issues(nodes, [], sources=[source], checkers=checkers) == [] + + # 依据只是推断:降为 info,也不挡 + _, proposed = await _checkers(scenic_db, "proposed") + assert {i["level"] for i in authored_issues(spec, [source], checkers=proposed)} == {"info"} + # 不给检查器(没有目录):和以前一样什么都不查 + assert authored_issues(spec, [source]) == [] + + +async def test_unknown_columns_come_first(scenic_db): + """列名都对不上的 SQL 先改列名:这时不再叠加目录检查的结果,免得一轮里塞给模型两套说法。""" + from app.api.copilot import authored_issues + + source, checkers = await _checkers(scenic_db) + bad = ("SELECT SUM(o.total_amt) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id " + "WHERE o.status = 1") + spec = _spec(_node("start", "input"), _fetch(bad), _node("out", "output", fields=[])) + assert [i["code"] for i in authored_issues(spec, [source], checkers=checkers)] == ["sql_unknown_column"] + + +async def test_agent_prompts_and_untouched_nodes_are_not_checked(scenic_db): + from app.api.copilot import authored_issues + + source, checkers = await _checkers(scenic_db) + agent = _node("ask", "agent", prompt=f"用这条 SQL 查:{FANOUT_SQL}", tools=["db_query__scenic"]) + spec = _spec(_node("start", "input"), agent, _node("out", "output", fields=[])) + assert authored_issues(spec, [source], checkers=checkers) == [] + # 用户自己写的、这一轮没改的 SQL 不替他改 + fetch = _fetch(FANOUT_SQL) + untouched = _spec(_node("start", "input"), fetch, _node("out", "output", fields=[])) + assert authored_issues(untouched, [source], baseline=[fetch], checkers=checkers) == [] + # 模板占位照常检查其余部分 + templated = _fetch("SELECT SUM(o.total_amount) FROM orders o JOIN order_items i ON i.order_id = o.id " + "WHERE o.status = 1 AND o.ordered_at >= '{{ input.since }}'") + out = authored_issues(_spec(_node("start", "input"), templated, _node("out", "output", fields=[])), [source], + checkers=checkers) + assert [i["code"] for i in out] == ["fanout_sum"] + + +# -------------------------------------------------------------------------- +# 端到端:假模型搭图,error 进修正轮,warning 留在 final +# -------------------------------------------------------------------------- + + +class _Scripted: + """第一轮吐 first;收到自查请求(human 里有「上线前自查」)时吐 fix。""" + + def __init__(self, first, fix): + self.first, self.fix = first, fix + self.calls: list[str] = [] + + async def astream(self, messages): + from langchain_core.messages import AIMessageChunk + + human = next(text for role, text in messages if role == "human") + self.calls.append(human) + for op in (self.fix if "上线前自查" in human else self.first): + yield AIMessageChunk(content=json.dumps(op, ensure_ascii=False) + "\n") + + +def _ops(source, sql): + return [ + {"op": "plan", "summary": "查订单金额"}, + {"op": "add_node", "node": {"id": "start", "type": "input", "label": "输入", "config": {}}}, + {"op": "add_node", "node": {"id": "fetch", "type": "tool", "label": "取数", + "config": {"tool": f"db_query__{source}", "args": {"sql": sql}}}}, + {"op": "add_edge", "edge": {"source": "start", "target": "fetch"}}, + {"op": "add_node", "node": {"id": "out", "type": "output", "label": "成果", + "config": {"fields": [{"name": "r", "value": "{{ nodes.fetch }}"}]}}}, + {"op": "add_edge", "edge": {"source": "fetch", "target": "out"}}, + {"op": "done", "explanation": "查一下", "run": True}, + ] + + +@pytest.fixture +async def scenic_source(scenic_db): + """库里一个带目录的景区数据源:表结构真实探查,目录按 scenic_notes 写进 catalog_notes。""" + from app.db.base import SessionLocal + from app.db.models import DataSource + + name = f"scenic_{uuid.uuid4().hex[:8]}" + async with SessionLocal() as session: + row = DataSource(name=name, kind="sqlite", database=scenic_db, readonly=True) + row.schema_cache = await _schema(scenic_db) + session.add(row) + await session.commit() + for table, notes in scenic_notes.notes().items(): + await catalog.write_entry(session, row.id, table, notes, if_version=0, actor="王敏") + source_id = row.id + yield name + async with SessionLocal() as session: + row = await session.get(DataSource, source_id) + row.enabled = False + await session.commit() + + +async def _stream(monkeypatch, model, source): + import app.api.copilot as copilot + from app.main import app + + async def _model(*_a, **_k): + return model, "mock-fast" + + monkeypatch.setattr(copilot, "get_chat_model", _model) + events: list[dict] = [] + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with client.stream("POST", "/api/copilot/generate-stream", + json={"instruction": "上周订单金额多少", "intent": "answer", + "datasource_ids": [source]}) as r: + async for line in r.aiter_lines(): + if line.startswith("data: "): + events.append(json.loads(line[6:])) + return events + + +async def test_fanout_is_sent_back_and_fixed_before_running(scenic_source, monkeypatch): + model = _Scripted(_ops(scenic_source, FANOUT_SQL), + [{"op": "update_node", "id": "fetch", "config": {"args": {"sql": FIXED_SQL}}}, + {"op": "done", "explanation": "改好了"}]) + events = await _stream(monkeypatch, model, scenic_source) + checks = [e for e in events if e["op"] == "check"] + assert [c["status"] for c in checks] == ["repairing", "passed"], checks + [issue] = checks[0]["issues"] + assert issue["code"] == "fanout_sum" and issue["node_id"] == "fetch" + # 交回模型的是 for_model:写 SQL 里的真名和改法 + assert "orders 关联 order_items 是一对多" in model.calls[1] and "「fetch」" in model.calls[1] + final = events[-1] + assert final["op"] == "final" and final["autorun"] is True + sql = next(n for n in final["graph"]["nodes"] if n["id"] == "fetch")["data"]["config"]["args"]["sql"] + assert sql == FIXED_SQL + # 剩下的 warning 不返工,留在 final 里看得见 + [warn] = [i for i in final["issues"] if i.get("code") == "missing_valid_filter"] + assert warn["level"] == "warning" and warn["node_id"] == "fetch" and "status = 1" in warn["message"] + assert not [i for i in final["issues"] if i["level"] == "error"] + + +async def test_a_fanout_that_is_not_fixed_stops_autorun(scenic_source, monkeypatch): + model = _Scripted(_ops(scenic_source, FANOUT_SQL), + [{"op": "update_node", "id": "fetch", "config": {"args": {"sql": FANOUT_SQL + " "}}}, + {"op": "done", "explanation": "改了"}]) + events = await _stream(monkeypatch, model, scenic_source) + checks = [e for e in events if e["op"] == "check"] + assert [c["status"] for c in checks] == ["repairing", "repairing", "failed"], checks + final = events[-1] + assert final["autorun"] is False + assert [i["code"] for i in final["issues"] if i["level"] == "error"] == ["fanout_sum"] + + +async def test_clean_sql_costs_no_extra_call(scenic_source, monkeypatch): + model = _Scripted(_ops(scenic_source, CLEAN_SQL), []) + events = await _stream(monkeypatch, model, scenic_source) + assert len(model.calls) == 1 + assert [e["status"] for e in events if e["op"] == "check"] == ["passed"] + final = events[-1] + assert final["autorun"] is True + assert not [i for i in final["issues"] if i.get("code") in ("fanout_sum", "missing_valid_filter")] From 8682324a6feb99319a3f7613b45efa5fda3bc702 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 3 Oct 2026 10:24:38 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=202B=EF=BC=9A=E5=8F=91=E5=B8=83=E9=97=A8?= =?UTF-8?q?=E7=A6=81=E6=8E=A5=E4=B8=8A=20SQL=20=E6=A3=80=E6=9F=A5=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=8F=97=E7=AE=A1=E7=BA=A7=E5=88=AB=E8=A2=AB=20error?= =?UTF-8?q?=20=E6=8C=A1=E4=BD=8F=E3=80=81warning=20=E8=BF=9B=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=EF=BC=8C=E5=8F=91=E5=B8=83=E5=89=8D=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E3=80=81=E8=87=AA=E5=8A=A8=E4=BF=AE=E5=A4=8D=E5=92=8C=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E5=90=8C=E4=B8=80=E4=B8=AA=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/copilot.py | 34 +++- backend/app/api/workflows.py | 21 ++- backend/app/engine/autofix.py | 17 +- backend/app/engine/governance.py | 32 +++- backend/tests/test_publish_sqlcheck.py | 212 +++++++++++++++++++++++++ 5 files changed, 297 insertions(+), 19 deletions(-) create mode 100644 backend/tests/test_publish_sqlcheck.py diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 283dde72..a53d6ffe 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -1109,16 +1109,32 @@ def _blocking_issues( **({"defaults": defaults} if isinstance(defaults, dict) else {})}) except Exception: # noqa: BLE001 - 结构不合法留给收尾那一步报 return None + # 带发布级别时 SQL 检查交给门禁(查全图、按级别定挡不挡),自查这边就不再查一遍,免得同一处报两条 + authored = authored_issues(spec, sources or [], baseline, checkers=None if level else checkers) out = ([i.model_dump() for i in validate_graph(spec).issues if i.level == "error"] + _issue_dicts(scope_issues(list(nodes.values()), scope), "datasource_out_of_scope") - + [i for i in authored_issues(spec, sources or [], baseline, checkers=checkers) if i["level"] == "error"]) + + [i for i in authored if i["level"] == "error"]) if level: from app.engine.governance import lint_for_publish - out += [i.model_dump() for i in lint_for_publish(spec, level=level).issues if i.level == "error"] + gate = [i.model_dump() for i in lint_for_publish(spec, level=level, checkers=checkers).issues + if i.level == "error"] + out += _sql_model_hints(gate, spec, checkers) return out +def _sql_model_hints(issues: list[dict[str, Any]], spec: GraphSpec, checkers: dict[str, Any] | None, + ) -> list[dict[str, Any]]: + """门禁报的 SQL 问题只有给人看的那句(ValidationIssue 不带 for_model);交回模型改时补上给模型的改法。""" + if not checkers or not any(i.get("code") in CHECK_CODES for i in issues): + return issues + from app.data.sqlcheck import graph_checks + + hints = {(node.id, c.code, c.message): c.for_model for node, c in graph_checks(spec.nodes, checkers)} + return [{**i, "for_model": hints[key]} if (key := (i.get("node_id"), i.get("code"), i.get("message"))) in hints + else i for i in issues] + + def _changed(spec: GraphSpec, baseline: list[dict[str, Any]] | None) -> set[str]: """这一轮新加或改过配置的节点。改图时只查这些:用户自己写的、这一轮没碰的 SQL 不替他改。""" before = {n.get("id"): (n.get("data") or {}).get("config") for n in baseline or [] if isinstance(n, dict)} @@ -1887,13 +1903,17 @@ async def assist_publish_fix( """ from app.engine.autofix import forbidden_changes, judge + from app.data.sqlcheck import load_checkers + sources = await _sources(session, None) + # 和发布接口同一套 SQL 检查:受管级别里 SQL 的 error 也是挡住发布的问题,一并交给助手改 + checkers = await load_checkers(session, sources) base = copy.deepcopy(graph) before_nodes = {str(n["id"]): n for n in copy.deepcopy(base.get("nodes") or []) if isinstance(n, dict)} before_edges = [e for e in copy.deepcopy(base.get("edges") or []) if isinstance(e, dict)] baseline = list(copy.deepcopy(before_nodes).values()) errors = _blocking_issues(before_nodes, before_edges, sources=sources, baseline=baseline, level=level, - defaults=base.get("defaults")) or [] + defaults=base.get("defaults"), checkers=checkers) or [] out: dict[str, Any] = {"accepted": False, "reason": None, "summary": "", "questions": [], "graph": graph, "ops": []} if not errors: @@ -1949,7 +1969,7 @@ async def assist_publish_fix( proposed = _proposed_graph(base, nodes, edges) reasons = forbidden_changes(base, proposed, tried) after = _blocking_issues(_nodes_by_id(proposed), proposed.get("edges") or [], sources=sources, - baseline=baseline, level=level, defaults=proposed.get("defaults")) + baseline=baseline, level=level, defaults=proposed.get("defaults"), checkers=checkers) if reasons: out["reason"] = "助手的修改降低了要求,已作废:" + ";".join(reasons) elif after is None: @@ -2217,7 +2237,11 @@ async def upgrade_evidence(payload: UpgradeIn, session: AsyncSession = Depends(g out["notes"] = [n for n in out["notes"] if n["rule"] != "R5" or n["node_id"] in still] elif helped["reason"]: out["rejected"].append({"fix_id": "assist", "reason": helped["reason"]}) - issues = publish_issues(GraphSpec.model_validate(out["graph"]), level=payload.level) + from app.data.sqlcheck import graph_sources, load_checkers_by_name + + upgraded = GraphSpec.model_validate(out["graph"]) + checkers = await load_checkers_by_name(session, graph_sources(upgraded.nodes)) + issues = publish_issues(upgraded, level=payload.level, checkers=checkers) out["issues"] = [i.model_dump() for i in issues] out["ok"] = not any(i.level == "error" for i in issues) return out diff --git a/backend/app/api/workflows.py b/backend/app/api/workflows.py index 26924e0d..5038dfc0 100644 --- a/backend/app/api/workflows.py +++ b/backend/app/api/workflows.py @@ -324,7 +324,8 @@ async def publish_workflow( except Exception as e: # noqa: BLE001 return {"ok": False, "issues": [{"level": "error", "message": f"无法解析工作流结构:{graph_error(e)}"}]} - issues = [i.model_dump() for i in publish_issues(spec, level=payload.level)] + checkers = await _sql_checkers(session, spec) + issues = [i.model_dump() for i in publish_issues(spec, level=payload.level, checkers=checkers)] if any(i["level"] == "error" for i in issues): return {"ok": False, "level": payload.level, "version": version, "issues": issues} @@ -400,6 +401,14 @@ async def _gate_context(session: AsyncSession, spec: GraphSpec) -> tuple[dict[st return versions, cards +async def _sql_checkers(session: AsyncSession, spec: GraphSpec) -> dict[str, Any]: + """图里调用工具节点写死的 SQL 用到的数据源,各建一个基于数据目录的 SQL 检查器。发布、发布前检查、 + 自动修复都传这一份给门禁(governance.lint_for_publish):门禁本身不查库,三处口径才一致。""" + from app.data.sqlcheck import graph_sources, load_checkers_by_name + + return await load_checkers_by_name(session, graph_sources(spec.nodes)) + + def _unreadable(level: str, e: Exception) -> dict[str, Any]: return {"level": level, "ok": False, "fixes": [], "issues": [ {"level": "error", "node_id": None, "edge_id": None, "field": None, "code": None, "fix": None, @@ -422,7 +431,8 @@ async def publish_check( except Exception as e: # noqa: BLE001 return _unreadable(payload.level, e) versions, cards = await _gate_context(session, spec) - return check(spec, level=payload.level, versions=versions, cards=cards) + return check(spec, level=payload.level, versions=versions, cards=cards, + checkers=await _sql_checkers(session, spec)) @router.post("/{workflow_id}/autofix") @@ -446,7 +456,9 @@ async def autofix( {"fix_id": fid, "reason": "无法解析工作流结构,不能应用修复"} for fid in payload.apply], "remaining": bad["issues"], "fixes": [], "handoff": [], "assist": None, "ok": False} versions, cards = await _gate_context(session, spec) - out = apply_fixes(graph, payload.apply, payload.choices, level=payload.level, versions=versions, cards=cards) + checkers = await _sql_checkers(session, spec) + out = apply_fixes(graph, payload.apply, payload.choices, level=payload.level, versions=versions, cards=cards, + checkers=checkers) out["assist"] = None # 人在选项里选了「交给 Copilot」(handoff),和点「交给 Copilot」是一回事 if payload.assist or out["handoff"]: @@ -464,7 +476,8 @@ async def autofix( out["changes"] += diff_changes(out["graph"], fixed, fix_id="assist", label="助手的修改") out["ops"] += helped["ops"] out["applied"].append("assist") - after = apply_fixes(fixed, [], level=payload.level, versions=versions, cards=cards) + after = apply_fixes(fixed, [], level=payload.level, versions=versions, cards=cards, + checkers=await _sql_checkers(session, GraphSpec.model_validate(fixed))) out.update(graph=fixed, remaining=after["remaining"], fixes=after["fixes"], ok=after["ok"]) elif helped["reason"]: out["rejected"].append({"fix_id": "assist", "reason": helped["reason"]}) diff --git a/backend/app/engine/autofix.py b/backend/app/engine/autofix.py index 65e2618f..db2fd86a 100644 --- a/backend/app/engine/autofix.py +++ b/backend/app/engine/autofix.py @@ -559,9 +559,13 @@ def annotate(issues: list[Any], fixes: list[dict[str, Any]]) -> list[dict[str, A def check(spec: GraphSpec, *, level: str, versions: dict[str, Any] | None = None, - cards: dict[str, list[dict[str, Any]]] | None = None) -> dict[str, Any]: - """发布前检查:和真正发布同一套口径的问题,以及能怎么修。只读。""" - issues = publish_issues(spec, level=level) + cards: dict[str, list[dict[str, Any]]] | None = None, + checkers: dict[str, Any] | None = None) -> dict[str, Any]: + """发布前检查:和真正发布同一套口径的问题,以及能怎么修。只读。 + + checkers:基于数据目录的 SQL 检查器(data/sqlcheck),和发布接口传的是同一份(governance.lint_for_publish)。 + """ + issues = publish_issues(spec, level=level, checkers=checkers) fixes = plan_fixes(spec, issues, level=level, versions=versions, cards=cards) return {"level": level, "ok": not any(i.level == "error" for i in issues), "issues": annotate(issues, fixes), "fixes": [public(f) for f in fixes]} @@ -702,7 +706,8 @@ def _op(graph: dict[str, Any], node_id: str | None, edits: list[tuple[str | None def apply_fixes(graph: dict[str, Any] | GraphSpec, fix_ids: list[str], choices: dict[str, Any] | None = None, *, level: str, versions: dict[str, Any] | None = None, - cards: dict[str, list[dict[str, Any]]] | None = None) -> dict[str, Any]: + cards: dict[str, list[dict[str, Any]]] | None = None, + checkers: dict[str, Any] | None = None) -> dict[str, Any]: """在副本上逐个应用修复,每一个都复核:降低要求、错误没变少或者冒出新错误的,丢弃并写明原因。 返回 {graph, changes, ops, applied, rejected, handoff, remaining, fixes, ok}。graph 只是预览,不落库。 @@ -711,7 +716,7 @@ def apply_fixes(graph: dict[str, Any] | GraphSpec, fix_ids: list[str], choices: raw = graph.model_dump(mode="json") if isinstance(graph, GraphSpec) else graph current = copy.deepcopy(raw) spec = GraphSpec.model_validate(current) - issues = publish_issues(spec, level=level) + issues = publish_issues(spec, level=level, checkers=checkers) plan = plan_fixes(spec, issues, level=level, versions=versions, cards=cards) known = {f["id"] for f in plan} rank = {code: i for i, code in enumerate(_ORDER)} @@ -743,7 +748,7 @@ def apply_fixes(graph: dict[str, Any] | GraphSpec, fix_ids: list[str], choices: except Exception: # noqa: BLE001 - 修复规则把图改坏了:丢弃,不交给人一张存不回去的图 rejected.append({"fix_id": fid, "reason": "修复后的工作流结构无法解析,已放弃这项修复"}) continue - trial_issues = publish_issues(trial_spec, level=level) + trial_issues = publish_issues(trial_spec, level=level, checkers=checkers) # choice 里人亲手选的值不算「替人决定」(比如把沙箱代码标成取数);别的降低要求照样拦 chosen = {(str(nid), path) for nid, path, _, _ in edits} if fix["kind"] == "choice" else set() why = ";".join(forbidden_changes(current, trial, chosen=chosen)) or judge( diff --git a/backend/app/engine/governance.py b/backend/app/engine/governance.py index 9bdd07d3..567b3e45 100644 --- a/backend/app/engine/governance.py +++ b/backend/app/engine/governance.py @@ -2,6 +2,7 @@ from __future__ import annotations import re from collections import defaultdict +from collections.abc import Mapping from typing import Any, Callable from sqlalchemy import func, select @@ -29,7 +30,7 @@ UPGRADE_POLICIES = {"recompute", "dual", "incomparable"} UPGRADE_POLICY_LABELS = {k: option_label("upgrade_policy", k) for k in ("recompute", "dual", "incomparable")} -def lint_for_publish(spec: GraphSpec, *, level: str) -> ValidationResult: +def lint_for_publish(spec: GraphSpec, *, level: str, checkers: Mapping[str, Any] | None = None) -> ValidationResult: """发布门禁。 published:基础可运行性之外只给警告。 @@ -38,6 +39,9 @@ def lint_for_publish(spec: GraphSpec, *, level: str) -> ValidationResult: 每条问题带机器编号(code):发布前自动修复(engine/autofix.py)按它认问题、出修复, 文案可以改,编号不能随便改。证据门禁 G1–G5 见 _lint_evidence。 + + checkers 是各数据源基于数据目录的 SQL 检查器(data/sqlcheck.load_checkers_by_name),由调用方读好传进来: + 门禁本身不查库。给了的话,调用工具节点里写死的 SQL 对照目录查一遍(_lint_sql)。 """ result = ValidationResult() strict = level == "governed" @@ -100,6 +104,7 @@ def lint_for_publish(spec: GraphSpec, *, level: str) -> ValidationResult: "(那里会保存取数快照)", code="governed.code_network", node_id=node.id, field="network") _lint_evidence(spec, flag, strict=strict) + _lint_sql(spec, flag, checkers) if strict and not has_contract_output: result.add( @@ -109,15 +114,34 @@ def lint_for_publish(spec: GraphSpec, *, level: str) -> ValidationResult: return result -def publish_issues(spec: GraphSpec, *, level: str) -> list[Any]: +def publish_issues(spec: GraphSpec, *, level: str, checkers: Mapping[str, Any] | None = None) -> list[Any]: """发布时拦的全部问题:validate_graph 和 lint_for_publish 的并集,顺序也照发布接口。 - 发布、发布前检查、自动修复复核都走这一个口径——检查说能发的,真发布时不会被拦。 + 发布、发布前检查、自动修复复核都走这一个口径——检查说能发的,真发布时不会被拦。checkers 见 lint_for_publish: + 这几处都要传同一份,否则检查和发布对 SQL 的判断不一致。 """ - gate = lint_for_publish(spec, level=level).issues + gate = lint_for_publish(spec, level=level, checkers=checkers).issues return [*_not_repeated(validate_graph(spec).issues, gate, spec), *gate] +def _lint_sql(spec: GraphSpec, flag: Callable[..., None], checkers: Mapping[str, Any] | None) -> None: + """调用工具节点里写死的 SQL 对照数据目录检查(data/sqlcheck.py)。 + + error 级(依据有确证的重复计算、存量跨期求和)按硬性问题报:受管级别挡住发布,已发布级别只警告——和别的 + 受管要求同一个口径。warning 两档都只提示。info 的依据只是推断(模型起草、命名推断),不进门禁:发布时 + 摆一排「推断,未确认」的提示,只会让人学会无视门禁。编号就是规则编号(fanout_sum…),没有确定性的修复, + 改 SQL 交给人或助手。Agent 节点的 SQL 要到运行时才写出来,不在这里查。 + """ + if not checkers: + return + from app.data.sqlcheck import graph_checks + + for node, check in graph_checks(spec.nodes, checkers): + if check.level == "info": + continue + flag(check.message, code=check.code, node_id=node.id, hard=check.level == "error", field="args.sql") + + #: validate 里和门禁 G4、G5 说同一件事的两条提示(schema.evidence_issues) _REPEATS = frozenset({"evidence.caliber_compute_input", "evidence.cite_fields_off"}) diff --git a/backend/tests/test_publish_sqlcheck.py b/backend/tests/test_publish_sqlcheck.py new file mode 100644 index 00000000..69cb1432 --- /dev/null +++ b/backend/tests/test_publish_sqlcheck.py @@ -0,0 +1,212 @@ +"""发布门禁接上基于数据目录的 SQL 检查(data/sqlcheck.py)。 + +- 受管级别:调用工具节点里写死的 SQL 有 error 级问题(一对多关联后重复计算、存量跨期求和)就挡住发布; +- 已发布级别:同样的问题只是警告,不挡; +- warning 级的问题两档都进提示,info(依据只是推断)不进门禁; +- 发布前检查、自动修复和真正发布同一个口径:检查说能发的,真发布时不会被拦。 +""" +from __future__ import annotations + +import copy +import json +import uuid +from types import SimpleNamespace + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.data import catalog +from app.data.engine import engines +from app.data.introspect import introspect +from app.data.sqlcheck import SqlChecker +from app.engine.governance import lint_for_publish, publish_issues +from app.engine.schema import GraphSpec +from app.main import app +from tests.fixtures.catalog import scenic_notes + +FANOUT_SQL = ("SELECT SUM(o.total_amount) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id " + "WHERE o.status = 1") +CLEAN_SQL = "SELECT SUM(i.amount) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1" +WARN_SQL = "SELECT SUM(i.amount) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id" + +_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, "position": {"x": 10, "y": 20}, "data": {"label": label or nid, "config": config}} + + +def weekly(source: str, sql: str) -> dict: + """input → 取数(调用工具,写死的 SQL)→ 口径卡 → 报告撰写 → 出具,其余受管要求都满足。""" + nodes = [ + node("start", "input", "周期"), + node("fetch", "tool", "取数", tool=f"db_query__{source}", args={"sql": sql}), + node("card", "metrics", "周报口径卡", caliber="周报口径", metrics=[ + {"id": "gmv", "name": "订单金额", "unit": "元", "expression": "cell(nodes.fetch, 0, 'gmv')"}]), + node("write", "report", "报告撰写", instructions="写周报", + numbers="strict", on_violation="fail", claims="require_citation"), + node("done", "output", "出具", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}], + contract={"report_from": "write", "metrics_from": ["card"], "required": ["gmv"], "strict": True}), + ] + edges = [{"id": f"e{i}", "source": a["id"], "target": b["id"]} for i, (a, b) in enumerate(zip(nodes, nodes[1:]))] + return {"nodes": nodes, "edges": edges, "defaults": {"approval": "dangerous"}} + + +async def _checkers(scenic_db, status="confirmed"): + cache = await _schema(scenic_db) + return {"scenic": SqlChecker(kind="sqlite", schema_cache=cache, notes=scenic_notes.notes(status), + source_name="scenic")} + + +def _sql_issues(issues): + return [(i.code, i.level, i.node_id, i.field) for i in issues if i.field == "args.sql"] + + +async def test_governed_level_blocks_error_level_sql_problems(scenic_db): + checkers = await _checkers(scenic_db) + spec = GraphSpec.model_validate(weekly("scenic", FANOUT_SQL)) + assert not [i for i in publish_issues(spec, level="governed") if i.level == "error"], "其余受管要求都满足" + issues = publish_issues(spec, level="governed", checkers=checkers) + assert _sql_issues(issues) == [("fanout_sum", "error", "fetch", "args.sql")] + [issue] = [i for i in issues if i.code == "fanout_sum"] + assert issue.message.startswith("「订单」关联「订单明细」是一对多") + # 已发布级别:同一个问题只警告 + assert _sql_issues(publish_issues(spec, level="published", checkers=checkers)) == [ + ("fanout_sum", "warning", "fetch", "args.sql")] + # 依据只是推断:info 不进门禁 + assert _sql_issues(lint_for_publish(spec, level="governed", checkers=await _checkers(scenic_db, "proposed")) + .issues) == [] + # 没有检查器(数据源没有目录):和以前一样 + assert _sql_issues(lint_for_publish(spec, level="governed").issues) == [] + + +async def test_warnings_are_hints_on_both_levels(scenic_db): + checkers = await _checkers(scenic_db) + spec = GraphSpec.model_validate(weekly("scenic", WARN_SQL)) + for level in ("governed", "published"): + assert _sql_issues(publish_issues(spec, level=level, checkers=checkers)) == [ + ("missing_valid_filter", "warning", "fetch", "args.sql")] + clean = GraphSpec.model_validate(weekly("scenic", CLEAN_SQL)) + assert _sql_issues(publish_issues(clean, level="governed", checkers=checkers)) == [] + + +async def test_autofix_check_and_apply_use_the_same_gate(scenic_db): + from app.engine.autofix import apply_fixes, check + + checkers = await _checkers(scenic_db) + graph = weekly("scenic", FANOUT_SQL) + body = check(GraphSpec.model_validate(graph), level="governed", checkers=checkers) + assert body["ok"] is False and "fanout_sum" in {i["code"] for i in body["issues"]} + out = apply_fixes(copy.deepcopy(graph), [], level="governed", checkers=checkers) + assert out["ok"] is False and "fanout_sum" in {i["code"] for i in out["remaining"]} + + +# -------------------------------------------------------------------------- +# 接口:发布、发布前检查、自动修复 +# -------------------------------------------------------------------------- + + +@pytest.fixture +async def scenic_source(scenic_db): + from app.db.base import SessionLocal + from app.db.models import DataSource + + name = f"scenic_{uuid.uuid4().hex[:8]}" + async with SessionLocal() as session: + row = DataSource(name=name, kind="sqlite", database=scenic_db, readonly=True) + row.schema_cache = await _schema(scenic_db) + session.add(row) + await session.commit() + for table, notes in scenic_notes.notes().items(): + await catalog.write_entry(session, row.id, table, notes, if_version=0, actor="王敏") + source_id = row.id + yield name + async with SessionLocal() as session: + row = await session.get(DataSource, source_id) + row.enabled = False + await session.commit() + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +async def test_publish_endpoints_block_governed_fanout(client, scenic_source): + r = await client.post("/api/workflows", json={"name": f"订单周报-{scenic_source}", + "graph": weekly(scenic_source, FANOUT_SQL)}) + assert r.status_code == 201, r.text + wid = r.json()["id"] + + checked = (await client.post(f"/api/workflows/{wid}/publish-check", json={"level": "governed"})).json() + assert checked["ok"] is False + [issue] = [i for i in checked["issues"] if i["code"] == "fanout_sum"] + assert issue["level"] == "error" and issue["node_id"] == "fetch" and issue["field"] == "args.sql" + assert issue["fix"] is None, "SQL 写错没有确定性的修复,交给人或助手改" + + fixed = (await client.post(f"/api/workflows/{wid}/autofix", json={"level": "governed", "apply": []})).json() + assert fixed["ok"] is False and "fanout_sum" in {i["code"] for i in fixed["remaining"]} + + blocked = (await client.post(f"/api/workflows/{wid}/publish", json={"level": "governed"})).json() + assert blocked["ok"] is False and "fanout_sum" in {i.get("code") for i in blocked["issues"]} + # 已发布级别不挡,问题作为警告带回 + published = (await client.post(f"/api/workflows/{wid}/publish", json={"level": "published"})).json() + assert published["ok"] is True + assert [i["level"] for i in published["issues"] if i.get("code") == "fanout_sum"] == ["warning"] + + # 改好 SQL:受管级别发得出去,剩下的 warning 进提示 + await client.patch(f"/api/workflows/{wid}", json={"graph": weekly(scenic_source, WARN_SQL)}) + ok = (await client.post(f"/api/workflows/{wid}/publish", json={"level": "governed"})).json() + assert ok["ok"] is True, ok["issues"] + assert [i["code"] for i in ok["issues"] if i.get("field") == "args.sql"] == ["missing_valid_filter"] + + +class _Scripted: + """按剧本吐操作流的模型;记下每次收到的请求。""" + + def __init__(self, ops): + self.ops, self.calls = ops, [] + + async def astream(self, messages): + from langchain_core.messages import AIMessageChunk + + self.calls.append(messages) + for op in self.ops: + yield AIMessageChunk(content=json.dumps(op, ensure_ascii=False) + "\n") + + +async def test_publish_fix_assistant_gets_the_sql_error_and_its_fix_is_adopted(client, scenic_source, monkeypatch): + """确定性修复修不了 SQL:交给助手。助手看到的是给模型的那句改法,改好的 SQL 复核通过才采纳。""" + import app.api.copilot as copilot + + model = _Scripted([{"op": "plan", "summary": "改 SQL"}, + {"op": "update_node", "id": "fetch", + "config": {"tool": f"db_query__{scenic_source}", "args": {"sql": CLEAN_SQL}}}, + {"op": "done", "explanation": "改为对明细金额求和"}]) + + async def _model(*_a, **_k): + return model, "mock-fast" + + monkeypatch.setattr(copilot, "get_chat_model", _model) + wid = (await client.post("/api/workflows", json={"name": f"订单周报-助手-{scenic_source}", + "graph": weekly(scenic_source, FANOUT_SQL)})).json()["id"] + out = (await client.post(f"/api/workflows/{wid}/autofix", + json={"level": "governed", "apply": [], "assist": True})).json() + human = next(text for role, text in model.calls[0] if role == "human") + assert "orders 关联 order_items 是一对多" in human + assert out["assist"]["ok"] is True and out["applied"] == ["assist"] and out["ok"] is True, out + sql = next(n for n in out["graph"]["nodes"] if n["id"] == "fetch")["data"]["config"]["args"]["sql"] + assert sql == CLEAN_SQL From 7f07fc370a6ea333bc1be36a9b0d69fa58e8f8ad Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 3 Oct 2026 10:32:54 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=202B=EF=BC=9A=E8=BF=90=E8=A1=8C=E6=97=B6?= =?UTF-8?q?=E6=8E=A5=E4=B8=8A=20SQL=20=E6=A3=80=E6=9F=A5=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E7=BB=93=E6=9E=9C=E5=92=8C=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E5=B8=A6=20checks=E3=80=81=E8=AF=81=E6=8D=AE=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E7=9A=84=E6=9F=A5=E8=AF=A2=E6=AD=A5=E9=AA=A4=E5=B8=A6=E4=B8=8A?= =?UTF-8?q?=EF=BC=8C=E5=A5=91=E7=BA=A6=E6=8C=87=E6=A0=87=E7=9A=84=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=E6=9F=A5=E8=AF=A2=E6=9C=89=20error=20=E6=97=B6?= =?UTF-8?q?=E5=87=BA=E5=85=B7=E9=99=8D=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/evidence.py | 21 +- backend/app/engine/evidence.py | 6 + backend/app/engine/issuance.py | 11 + backend/app/engine/nodes/io.py | 4 +- backend/app/engine/nodes/metrics.py | 85 +++++++- backend/app/tools/datasource.py | 33 +++ backend/tests/test_runtime_sqlcheck.py | 288 +++++++++++++++++++++++++ 7 files changed, 445 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_runtime_sqlcheck.py diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index 7b3ba4c7..bbb9b38b 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -825,6 +825,9 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s # 拿截断的查询结果整组算出来的:面板标「结果不完整」并写明原因。完整的指标没有这两个键 **({"incomplete": True, "incomplete_reason": source.get("incomplete_reason") or ""} if source.get("incomplete") else {}), + # 来源查询没通过 SQL 检查:面板标出来并写明原因(问题和改法在查询步骤的 checks 里)。没问题的没有这两个键 + **({"sql_check_failed": True, "sql_check_reason": source.get("sql_check_reason") or ""} + if source.get("sql_check_failed") else {}), "node_id": entry.get("node_id"), "artifact": artifact or None, "eid": cite.get("eid"), "eid_ok": cite.get("eid") == entry.get("eid") == make_eid("metric", artifact or None, {"metric": metric_id}), "hash_ok": hash_ok and metric is not None, @@ -904,6 +907,8 @@ async def _query_step(artifact: str, cells: list[tuple[Any, Any]], doc: dict[str hint(期 4,P4-SPEC 2.8.2):调用方是带文档标记的单元格片段时为真。这时快照若是上传表格的(有 data_version), 步骤多一个 `provenance: true`,界面据此才去请求推断来源接口;其余情况一个键都不加,形状和期 4 之前完全相同 (期 4 之前的文档、手工源、指标链都不多发请求)。不看封存状态:未封存的运行照常推断,响应里照实标明。 + + checks:快照里记下的 SQL 检查结果(数据目录阶段 2B,见 _query_checks),只在查出问题时有这个键。 """ catalog = doc.get("catalog") or {} alias = _query_alias(catalog, artifact) @@ -948,7 +953,8 @@ async def _query_step(artifact: str, cells: list[tuple[Any, Any]], doc: dict[str masked=[columns[i] for i in sorted(masked)], # 查询时从驱动的原始值记下的列类型(老快照没有):文本列里的 "2026" 按文本显示,不当数 column_types=snap.get("column_types") if isinstance(snap.get("column_types"), dict) else {}, - **({"provenance": True} if hint and snap.get("data_version") else {})) + **({"provenance": True} if hint and snap.get("data_version") else {}), + **({"checks": checks} if (checks := _query_checks(snap)) else {})) if len(index) > MAX_WINDOW_ROWS: index, step["window_truncated"] = index[:MAX_WINDOW_ROWS], True step["row_index"] = index @@ -958,6 +964,19 @@ async def _query_step(artifact: str, cells: list[tuple[Any, Any]], doc: dict[str return step +#: 查询步骤里每条 SQL 检查结果交给界面的字段(data/sqlcheck.SqlCheck.as_dict 去掉 for_model) +_CHECK_FIELDS = ("code", "level", "message", "table", "column", "relation_id", "sql_excerpt") + + +def _query_checks(snap: dict[str, Any]) -> list[dict[str, Any]]: + """查询快照里记下的 SQL 检查结果(数据源查询工具执行后对照数据目录查的)。给模型的那句改法不上界面。 + 快照里没有(没查出问题、或者是升级前的快照)返回空列表,步骤里就不加这个键。""" + checks = snap.get("checks") + if not isinstance(checks, list): + return [] + return [{k: c[k] for k in _CHECK_FIELDS if k in c} for c in checks if isinstance(c, dict) and c.get("code")] + + # -------------------------------------------------------------------------- # GET /api/runs/{run_id}/evidence/segments/{segment_id}/provenance(期 4:推断的来源) # -------------------------------------------------------------------------- diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index 198bb816..44abc1bd 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -902,6 +902,9 @@ def build_catalog( # 只在不完整时有这两个键,完整的指标条目和以前一字不差 **({"incomplete": True, "incomplete_reason": metric.get("incomplete_reason") or ""} if metric.get("incomplete") else {}), + # 来源查询没通过 SQL 检查(data/sqlcheck.py 的 error 级问题):同样只在有问题时有这两个键 + **({"sql_check_failed": True, "sql_check_reason": metric.get("sql_check_reason") or ""} + if metric.get("sql_check_failed") else {}), } queries = retrievals = 0 @@ -3109,6 +3112,9 @@ def catalog_prompt(catalog: dict[str, Any], *, budget: int = 12000, cells_allowe # 不完整的指标照样能引用(单格、口径都对),但写作者得知道它不是全量:不写行数,免得照抄成裸数字 caveat = "(基于被截断的查询结果计算,结果不完整:引用时须如实说明,不要写成全部数据)" \ if e.get("incomplete") else "" + # 取数的 SQL 没通过检查(重复计算、存量跨期加总):数是算错的可能很大,写作者引用时得交代 + if e.get("sql_check_failed"): + caveat += "(所依据的查询未通过 SQL 检查,结果存疑:引用时须如实说明,不要当作确定的结论)" lines.append(f"- [[{e['alias']}]] {e.get('name')} = {e.get('rendered')}{caveat}") inputs = [e for e in catalog.values() if e.get("kind") == "input"] if inputs: diff --git a/backend/app/engine/issuance.py b/backend/app/engine/issuance.py index c7aad257..5b030f3a 100644 --- a/backend/app/engine/issuance.py +++ b/backend/app/engine/issuance.py @@ -462,6 +462,17 @@ def incomplete_gaps(metrics: list[dict[str, Any]]) -> list[str]: for m in metrics if isinstance(m, dict) and m.get("incomplete")] +def sql_check_gaps(metrics: list[dict[str, Any]]) -> list[str]: + """契约收来的指标里来源查询没通过 SQL 检查的(口径卡标了 sql_check_failed:取数的那条 SQL 对照数据目录查出 + error 级问题,比如一对多关联之后对「一」那一侧求和),每个一句缺口。 + + 和 incomplete_gaps 同一个道理:数字照样回指得上口径卡,口径卡照样复算一致,但算它的那条查询本身就把数算错了 + (重复计算、存量跨期加总),盖完整出具的章等于替一个错数作保。原因用口径卡记下的那句,写明是哪条检查。 + """ + return [f"指标「{m.get('name') or m.get('id')}」{m.get('sql_check_reason') or '所依据的查询未通过 SQL 检查'}" + for m in metrics if isinstance(m, dict) and m.get("sql_check_failed")] + + def _claims_effect( policy: str | dict[str, Any] | None, unsupported: list[Any] | int | None, diff --git a/backend/app/engine/nodes/io.py b/backend/app/engine/nodes/io.py index 6ed8c676..4d79ba03 100644 --- a/backend/app/engine/nodes/io.py +++ b/backend/app/engine/nodes/io.py @@ -147,7 +147,7 @@ def _apply_contract( ) -> dict[str, Any]: from datetime import datetime, timezone - from app.engine.issuance import decide_tier, incomplete_gaps, trace_numbers + from app.engine.issuance import decide_tier, incomplete_gaps, sql_check_gaps, trace_numbers nodes = state.get("nodes") or {} # 写了 report_from 就是引用模式:按报告文档逐段复核,不再按数值回指叙述 @@ -188,6 +188,8 @@ def _apply_contract( gaps.append(f"指标来源未解析:{'、'.join(unresolved[:5])}") # 拿截断的查询结果整组算出来的指标:数回指得上,但算的只是取回的那一截,不能完整出具 gaps.extend(incomplete_gaps(metrics)) + # 来源查询没通过 SQL 检查(error 级:重复计算、存量跨期加总)的指标:口径卡复算得一致,算的却是错数 + gaps.extend(sql_check_gaps(metrics)) # 引用模式下,缺输入记成空值的指标(口径卡 on_missing=null)就是缺:报告里引用不了它, # 也不能因为 id 在清单里就算「齐了」。旧模式照旧只看 id,行为不变 diff --git a/backend/app/engine/nodes/metrics.py b/backend/app/engine/nodes/metrics.py index d3826b12..c6b73c9c 100644 --- a/backend/app/engine/nodes/metrics.py +++ b/backend/app/engine/nodes/metrics.py @@ -49,6 +49,9 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: 表达式把截断的查询结果当成整组用了(len、sum、max……,见 expressions.TruncatedUse)的指标, 值照算,但标 incomplete 并写明原因:它算的只是取回的那一截。出具契约据此降档(issuance.incomplete_gaps)。 + + 取数的那条 SQL 对照数据目录查出 error 级问题的(查询快照的 checks,见 data/sqlcheck.py:一对多关联后重复计算、 + 存量跨期加总),同样值照算,标 sql_check_failed 并写明原因,出具契约据此降档(issuance.sql_check_gaps)。 """ definitions = ctx.cfg("metrics", []) or [] caliber_raw, caliber_version = ctx.cfg("caliber", "") or ctx.node.title, str(ctx.cfg("caliber_version", "") or "v1") @@ -71,6 +74,8 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: metrics: list[dict[str, Any]] = [] errors: list[str] = [] + #: 查询快照工件 id → 它的 error 级检查结果。一张卡里多个指标常常取自同一次查询,快照只读一次 + checked: dict[str, list[dict[str, Any]]] = {} for definition in definitions: metric_id = str(definition.get("id") or "").strip() expr = str(definition.get("expression") or "").strip() @@ -127,6 +132,12 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: sources = list(use.sources.values()) incomplete = {"incomplete": True, "incomplete_reason": _incomplete_reason(sources), "truncated_sources": sources} + inputs = [_input_of(path, values[path], state, ctx, tctx) for path in paths] + sql_check: dict[str, Any] = {} + if failed := _failed_queries(inputs, list(use.sources.values()), paths, tctx, checked): + sql_check = {"sql_check_failed": True, "sql_check_reason": _sql_check_reason(failed), + "sql_check_sources": [{"artifact": artifact, "codes": list(dict.fromkeys( + str(c.get("code")) for c in found))} for artifact, found in failed]} metrics.append( { "id": metric_id, @@ -137,7 +148,7 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: "decimals": decimals, "format": display["format"], "rendered": _rendered(value, definition, display), - "inputs": [_input_of(path, values[path], state, ctx, tctx) for path in paths], + "inputs": inputs, "substituted": substituted, "recompute_ok": _recompute(substituted, tctx, value, decimals), # 值是空的就是缺输入,不论 on_missing 是哪一种:fail 模式下直接取到空值的 @@ -145,6 +156,8 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: "status": "ok" if value is not None else "missing_input", # 只在不完整时出现:完整的指标和以前一字不差,老运行复核时目录也对得上 **incomplete, + # 只在来源查询没通过 SQL 检查时出现,同上 + **sql_check, } ) @@ -158,12 +171,16 @@ async def run_metrics(state: GraphState, ctx: NodeContext) -> dict[str, Any]: else f"- {m['id']}({m['name']})= {MISSING}(缺少输入,无值)") # 不写行数:叙述节点照抄进正文就是一个没有出处的数 + ("(不完整:基于被截断的查询结果计算)" if m.get("incomplete") else "") + + ("(存疑:所依据的查询未通过 SQL 检查)" if m.get("sql_check_failed") else "") for m in metrics ] for m in metrics: if m.get("incomplete"): ctx.emit(EventType.LOG, level="warn", code="metric_incomplete", metric=m["id"], message=f"指标「{m['name']}」{m['incomplete_reason']}") + if m.get("sql_check_failed"): + ctx.emit(EventType.LOG, level="warn", code="metric_sql_check", metric=m["id"], + message=f"指标「{m['name']}」{m['sql_check_reason']}") card = { "kind": "metric_set", "caliber": caliber, @@ -331,6 +348,72 @@ def _incomplete_reason(sources: list[dict[str, Any]]) -> str: return f"基于 {len(fetched)} 份被截断的查询结果计算(分别只取回了{'、'.join(fetched)}),结果不完整" +#: 取值链的最后一段:.字段 或 [下标] +_LAST_STEP = re.compile(r"(?:\.[^.\[\]]+|\[[^\[\]]*\])$") + + +def _failed_queries(inputs: list[dict[str, Any]], truncated: list[dict[str, Any]], paths: list[str], + tctx: dict[str, Any], checked: dict[str, list[dict[str, Any]]], + ) -> list[tuple[str, list[dict[str, Any]]]]: + """这个指标用到的查询里,SQL 检查有 error 的:[(查询快照工件 id, error 级检查结果)]。 + + 用到了哪些查询,三处来历合起来认:输入的出处(cell() 取的格、Agent 经 cite_fields 交来的字段都带着快照 id)、 + 截断跟踪记下的整组、取值链上的查询结果本身(数据整形解析过的 vars.res.rows[0][0],往上找到 vars.res 那份 + 带 artifact 的结果)。检查结果从快照里读,不信内存里那份:快照按哈希寻址,就是查询当时存下的那份。 + 读不出来的快照不算(另有别的检查报快照的问题)。Agent 没开 cite_fields 时说不清数出自哪次查询,不算。 + """ + artifacts = [item.get("artifact") for item in inputs] + [s.get("artifact") for s in truncated] + artifacts += [_result_artifact(path, tctx) for path in paths if cell_parts(path) is None] + out = [] + for artifact in dict.fromkeys(a for a in artifacts if isinstance(a, str) and a): + if artifact not in checked: + checked[artifact] = _error_checks(artifact) + if checked[artifact]: + out.append((artifact, checked[artifact])) + return out + + +def _result_artifact(path: str, tctx: dict[str, Any]) -> str | None: + """顺着取值链往上找(vars.res.rows[0][0] → vars.res.rows[0] → vars.res.rows → vars.res),第一个查询结果的快照 id。 + 数据源工具交回的是 JSON 文本(调用工具节点的输出),解析过的是对象(数据整形之后),两种都认。""" + current = path + for _ in range(8): + value = _value_of(current, tctx) + if isinstance(value, str) and value.lstrip().startswith("{") and '"artifact"' in value: + try: + value = json.loads(value) + except ValueError: + value = None + if isinstance(value, dict) and isinstance(value.get("artifact"), str) and isinstance(value.get("rows"), list): + return value["artifact"] + shorter = _LAST_STEP.sub("", current) + if not shorter or shorter == current: + return None + current = shorter + return None + + +def _error_checks(artifact: str) -> list[dict[str, Any]]: + try: + snapshot = artifact_store.load(artifact) + except Exception: # noqa: BLE001 - 快照读不出来、被改过:这里不报,证据层另有说法 + return [] + checks = snapshot.get("checks") if isinstance(snapshot, dict) else None + return [c for c in checks if isinstance(c, dict) and c.get("level") == "error"] if isinstance(checks, list) else [] + + +def _sql_check_reason(failed: list[tuple[str, list[dict[str, Any]]]]) -> str: + """「所依据的查询未通过 SQL 检查(「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算), + 结果不可靠」:出具声明、运行日志、证据面板都用这句。只摘问题那半句,改法留在查询步骤里看。""" + problems = list(dict.fromkeys(str(c.get("message") or "").split("。")[0] for _, found in failed for c in found)) + problems = [p for p in problems if p] + if not problems: + return "所依据的查询未通过 SQL 检查,结果不可靠" + if len(problems) == 1: + return f"所依据的查询未通过 SQL 检查({problems[0]}),结果不可靠" + return f"所依据的查询有 {len(problems)} 处未通过 SQL 检查({problems[0]}等),结果不可靠" + + def _truncated_field(path: str, state: GraphState, ctx: NodeContext) -> dict[str, Any] | None: """agent 经 cite_fields 交来、被截断切开的数组字段:{rows, artifact}。别的取值链返回 None。 diff --git a/backend/app/tools/datasource.py b/backend/app/tools/datasource.py index b7c5da17..575fc847 100644 --- a/backend/app/tools/datasource.py +++ b/backend/app/tools/datasource.py @@ -33,6 +33,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.events import EventType from app.data import catalog as data_catalog from app.data import introspect, table_versions +from app.data.sqlcheck import SqlChecker from app.data.engine import SnapshotTampered, masked_columns, query_timeout, run_query from app.data.guard import QueryLimits, SqlRejected, is_write from app.data.tabular import UNSHAPED_NOTE @@ -53,6 +54,10 @@ DATA_UNAVAILABLE = "查询失败(数据不可用):" #: 运行中途补固定一个源时发的那条日志事件的 code(_pin_late) LATE_PIN_CODE = "data_version_pinned" +#: 每次查询之后基于数据目录的 SQL 检查最多等这么久。正常一条 SQL 几毫秒;等不到就放弃这一次检查, +#: 查询结果照常交回——检查是附加的,不能拖慢查询 +SQL_CHECK_TIMEOUT_S = 2.0 + class _QueryArgs(BaseModel): sql: str = Field(description="要执行的 SQL,一次一条") @@ -445,6 +450,15 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False, # 数据源在 options.query_timeout_s 里配了就用它,否则取缺省。数据库按它停下语句, # 引擎按它告诉界面上限是多少(metadata["timeout_s"]) seconds = query_timeout(source) + # 基于数据目录的 SQL 检查器:和冻结进表结构快照的是同一版目录。第一次查询时才建(关系图也是用到才建) + checker: list[SqlChecker] = [] + + def _checker() -> SqlChecker: + if not checker: + checker.append(SqlChecker(kind=source.kind, schema_cache=source.schema_cache, + notes={name: entry.notes for name, entry in (catalog or {}).items()}, + source_name=source.name)) + return checker[0] async def _run(sql: str, limit: int | None = None) -> str: if gone := _vanished(source, fixed): @@ -470,6 +484,11 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False, payload = result.to_payload() payload["source"] = source.name + # 对照数据目录检查这条 SQL(一对多关联后重复计算、存量跨期求和……)。结果交给模型(Agent 据此改写), + # 也随查询快照存下:证据面板看得到,口径卡据此给指标标「存疑」、出具降档。没查出问题不加这个键, + # 快照和以前一字不差 + if checks := await _sql_checks(_checker(), sql): + payload["checks"] = checks # 表结构快照:这次查询时数据源的结构冻结下来,报告里写的表名、字段名按它核对。事后数据源 # 重新探查、改了结构,已经跑完的运行核对的还是当时那一份。上传源冻结的是绑定快照里那份 if schema := await _store_schema(source, ctx, catalog=catalog): @@ -511,6 +530,20 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False, ) +async def _sql_checks(checker: SqlChecker, sql: str) -> list[dict[str, Any]]: + """一条查询的 SQL 检查结果(as_dict 的形状)。在线程里跑、限时 SQL_CHECK_TIMEOUT_S:解析是纯计算,放在事件循环里 + 会挡住同一进程里别的运行。出错、超时都返回空列表——检查不能让一次成功的查询变成失败。""" + if not checker.enabled: + return [] + try: + return await asyncio.wait_for(asyncio.to_thread(checker.check_dicts, sql), timeout=SQL_CHECK_TIMEOUT_S) + except asyncio.TimeoutError: + logger.warning("SQL 检查超过 %.1f 秒,本次跳过(数据源 %s)", SQL_CHECK_TIMEOUT_S, checker.source_name) + except Exception: # noqa: BLE001 + logger.exception("SQL 检查出错,本次跳过(数据源 %s)", checker.source_name) + return [] + + async def _store_schema(source: Any, ctx: ToolContext, *, catalog: Mapping[str, data_catalog.CatalogEntry] | None = None) -> str | None: """把数据源此刻的 schema_cache 存成 schema_snapshot 工件,返回工件 id。 diff --git a/backend/tests/test_runtime_sqlcheck.py b/backend/tests/test_runtime_sqlcheck.py new file mode 100644 index 00000000..5a8029b4 --- /dev/null +++ b/backend/tests/test_runtime_sqlcheck.py @@ -0,0 +1,288 @@ +"""运行时的 SQL 检查:数据源查询工具每次执行成功后对照数据目录检查一遍。 + +- 结果写进查询结果(模型看得到,Agent 能据此改写 SQL)和查询快照的 checks 字段;没查出问题时不加这个键, + 快照和以前一字不差。检查出错、超时都不影响查询本身。 +- 证据接口的查询步骤带上 checks(不带给模型的那句 for_model)。 +- 契约指标的来源查询有 error 级问题:口径卡把这个指标标 sql_check_failed 并写明原因,出具按 gaps 降档; + 写作目录提醒写作者,证据面板的指标步骤标出来。和阶段 0 的「结果不完整」走同一条路。 +""" +from __future__ import annotations + +import asyncio +import json +import time +import uuid +from types import SimpleNamespace + +import pytest +from httpx import ASGITransport, AsyncClient +from langchain_core.messages import AIMessage +from sqlalchemy import select + +from app.core import artifact_store +from app.data import catalog +from app.data.engine import engines +from app.data.introspect import introspect +from app.db.base import SessionLocal +from app.db.models import DataSource, Run, RunEvent +from app.engine.evidence import iter_segments +from app.engine.issuance import decide_tier, sql_check_gaps +from app.engine.runner import run_manager +from app.main import app +from app.tools.datasource import build_datasource_tools +from app.tools.registry import ToolContext +from tests.fixtures.catalog import scenic_notes + +FANOUT_SQL = ("SELECT SUM(o.total_amount) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id " + "WHERE o.status = 1") +CLEAN_SQL = "SELECT SUM(i.amount) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1" +REASON = ("所依据的查询未通过 SQL 检查(「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算)," + "结果不可靠") + +_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] + + +async def _source(path: str, *, with_catalog: bool = True) -> tuple[str, str]: + name = f"scenic_{uuid.uuid4().hex[:8]}" + async with SessionLocal() as session: + row = DataSource(name=name, kind="sqlite", database=path, readonly=True, enabled=True) + row.schema_cache = await _schema(path) + session.add(row) + await session.commit() + if with_catalog: + for table, notes in scenic_notes.notes().items(): + await catalog.write_entry(session, row.id, table, notes, if_version=0, actor="王敏") + return row.id, name + + +@pytest.fixture +async def scenic_source(scenic_db): + source_id, name = await _source(scenic_db) + yield name + await engines.invalidate(source_id) + + +async def _query_tool(name: str): + ctx = ToolContext(run_id=uuid.uuid4().hex, node_id="n1", data_versions=None) + async with SessionLocal() as session: + [tool] = await build_datasource_tools([f"db_query__{name}"], ctx, session) + return tool + + +# -------------------------------------------------------------------------- +# 数据源查询工具 +# -------------------------------------------------------------------------- + + +async def test_query_result_and_snapshot_carry_checks(scenic_source): + tool = await _query_tool(scenic_source) + result = json.loads(await tool.coroutine(sql=FANOUT_SQL)) + assert result["rows"] and result["artifact"] + [check] = result["checks"] + assert check["code"] == "fanout_sum" and check["level"] == "error" and check["table"] == "orders" + assert check["message"].startswith("「订单」关联「订单明细」是一对多") and "order_items" in check["for_model"] + assert artifact_store.load(result["artifact"])["checks"] == result["checks"] + + clean = json.loads(await tool.coroutine(sql=CLEAN_SQL)) + assert "checks" not in clean and "checks" not in artifact_store.load(clean["artifact"]) + + +async def test_sources_without_catalog_are_not_checked(scenic_db): + source_id, name = await _source(scenic_db, with_catalog=False) + try: + result = json.loads(await (await _query_tool(name)).coroutine(sql=FANOUT_SQL)) + assert result["rows"] and "checks" not in result + finally: + await engines.invalidate(source_id) + + +async def test_a_failing_or_slow_check_never_breaks_the_query(scenic_source, monkeypatch): + from app.data import sqlcheck + from app.tools import datasource + + tool = await _query_tool(scenic_source) + + def boom(self, sql): + raise RuntimeError("规则缺陷") + + monkeypatch.setattr(sqlcheck.SqlChecker, "check", boom) + result = json.loads(await tool.coroutine(sql=FANOUT_SQL)) + assert result["rows"] and "checks" not in result + + def slow(self, sql): + time.sleep(1.0) + return [] + + monkeypatch.setattr(sqlcheck.SqlChecker, "check", slow) + monkeypatch.setattr(datasource, "SQL_CHECK_TIMEOUT_S", 0.05) + started = time.monotonic() + result = json.loads(await tool.coroutine(sql=FANOUT_SQL)) + assert result["rows"] and "checks" not in result + assert time.monotonic() - started < 0.9, "检查超时就放弃,不等它跑完" + + +# -------------------------------------------------------------------------- +# 出具:契约指标的来源查询有 error 级问题时降档 +# -------------------------------------------------------------------------- + + +def test_sql_check_failures_become_readable_gaps(): + metrics = [{"id": "gmv", "name": "订单金额", "value": 1, "sql_check_failed": True, "sql_check_reason": REASON}, + {"id": "n", "name": "订单数", "value": 2}] + assert sql_check_gaps(metrics) == [f"指标「订单金额」{REASON}"] + assert sql_check_gaps(metrics[1:]) == [] + tier = decide_tier(missing_required=[], missing_expected=[], unmatched=[], strict=False, + gaps=sql_check_gaps(metrics)) + assert tier == "degraded" + + +@pytest.fixture +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}} + + +def chain(nodes: list[dict]) -> dict: + return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +GMV = {"id": "gmv", "name": "订单金额", "unit": "元", "format": "plain", "expression": "cell(nodes.fetch, 0, 'gmv')"} +#: 同一份结果经数据整形解析后按下标取:来历记成 transform,要顺着取值链找到那份查询结果 +GMV2 = {"id": "gmv2", "name": "订单金额(整形后)", "unit": "元", "format": "plain", + "expression": "vars.res.rows[0][0]"} +WEEKLY = "本周订单金额 [[m:gmv]]。" + + +def weekly(source: str, sql: str) -> dict: + return chain([ + node("start", "input"), + node("fetch", "tool", tool=f"db_query__{source}", args={"sql": sql}), + node("parse", "transform", mode="json", template="{{ nodes.fetch }}", assign_to="res"), + node("card", "metrics", caliber="订单口径", caliber_version="v1", metrics=[GMV, GMV2]), + node("write", "report", instructions="写订单周报"), + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}], + contract={"report_from": "write", "metrics_from": ["card"], "required": ["gmv"]}), + ]) + + +class Writer: + """报告写作者:每次都照 text 写;记下看到的提示。""" + + def __init__(self, monkeypatch, text: str = WEEKLY): + from app.providers import mock_model + + self.prompts: list[str] = [] + + def decide(model, messages): + self.prompts.append("\n".join(str(m.content) for m in messages)) + return AIMessage(content=text) + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide) + + +async def wait(run_id: str) -> Run: + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row.status in ("succeeded", "failed") and row.manifest_seq is not None: + return row + raise AssertionError(f"run {run_id} 没有结束:{row.status}") + + +async def events(run_id: str, etype: str, node_id: str | None = None) -> list[RunEvent]: + async with SessionLocal() as session: + q = select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == etype) + if node_id: + q = q.where(RunEvent.node_id == node_id) + return list((await session.execute(q.order_by(RunEvent.seq))).scalars()) + + +async def output_of(run_id: str, node_id: str) -> dict: + [finished] = await events(run_id, "node.finished", node_id) + return artifact_store.load(finished.data["artifact"]) + + +async def test_a_fanout_behind_a_contract_metric_degrades_the_issuance(scenic_source, engine_up, monkeypatch): + writer = Writer(monkeypatch) + run = await run_manager.start(graph=weekly(scenic_source, FANOUT_SQL), input_payload={}) + row = await wait(run.id) + assert row.status == "succeeded", row.error + + card = await output_of(row.id, "card") + metrics = {m["id"]: m for m in card["metrics"]} + [query_end] = [e.data for e in await events(row.id, "tool.end", "fetch")] + for mid in ("gmv", "gmv2"): + assert metrics[mid]["sql_check_failed"] is True and metrics[mid]["sql_check_reason"] == REASON + assert metrics[mid]["sql_check_sources"] == [{"artifact": query_end["query_artifact"], + "codes": ["fanout_sum"]}] + assert "(存疑:所依据的查询未通过 SQL 检查)" in card["text"] + warns = [e.data for e in await events(row.id, "log", "card") if e.data.get("code") == "metric_sql_check"] + assert [w["message"] for w in warns] == [f"指标「订单金额」{REASON}", f"指标「订单金额(整形后)」{REASON}"] + + issuance = row.output["_issuance"] + assert issuance["tier"] == "degraded", issuance + assert f"指标「订单金额」{REASON}" in issuance["gaps"] + # 写作目录提醒写作者:这个数存疑,引用时要说明 + assert "所依据的查询未通过 SQL 检查,结果存疑" in writer.prompts[0] + + # 证据面板:指标步骤标出来,查询步骤带着检查结果(不带给模型的那句) + doc = artifact_store.load(row.output["_evidence"]["doc_artifact"]) + assert doc["catalog"]["m:gmv"]["sql_check_failed"] is True + seg = next(s for s in iter_segments(doc) if s.get("ref") == "m:gmv") + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + body = (await client.get(f"/api/runs/{row.id}/evidence/segments/{seg['id']}")).json() + metric = body["chain"][0] + assert metric["step"] == "metric" and metric["sql_check_failed"] is True + assert metric["sql_check_reason"] == REASON + [query] = [s for s in body["chain"] if s["step"] == "query"] + [check] = query["checks"] + assert check["code"] == "fanout_sum" and check["level"] == "error" and check["table"] == "orders" + assert check["message"].startswith("「订单」关联「订单明细」是一对多") and "for_model" not in check + + +async def test_the_same_card_on_a_clean_query_issues_formally(scenic_source, engine_up, monkeypatch): + Writer(monkeypatch) + run = await run_manager.start(graph=weekly(scenic_source, CLEAN_SQL), input_payload={}) + row = await wait(run.id) + assert row.status == "succeeded", row.error + card = await output_of(row.id, "card") + assert all("sql_check_failed" not in m for m in card["metrics"]) + issuance = row.output["_issuance"] + assert issuance["tier"] == "formal" and issuance["gaps"] == [], issuance + doc = artifact_store.load(row.output["_evidence"]["doc_artifact"]) + seg = next(s for s in iter_segments(doc) if s.get("ref") == "m:gmv") + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + body = (await client.get(f"/api/runs/{row.id}/evidence/segments/{seg['id']}")).json() + assert "sql_check_failed" not in body["chain"][0] + assert all("checks" not in s for s in body["chain"] if s["step"] == "query") + + +async def test_warnings_alone_do_not_degrade(scenic_source, engine_up, monkeypatch): + """只有 warning(这里是没按订单状态筛):查询结果和快照里照样带着,但不影响出具档位。""" + Writer(monkeypatch) + sql = "SELECT SUM(i.amount) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id" + run = await run_manager.start(graph=weekly(scenic_source, sql), input_payload={}) + row = await wait(run.id) + assert row.status == "succeeded", row.error + [query_end] = [e.data for e in await events(row.id, "tool.end", "fetch")] + snapshot = artifact_store.load(query_end["query_artifact"]) + assert [c["code"] for c in snapshot["checks"]] == ["missing_valid_filter"] + assert row.output["_issuance"]["tier"] == "formal", row.output["_issuance"]